diff --git a/codegen.json b/codegen.json index 15b60470d..7f322492a 100644 --- a/codegen.json +++ b/codegen.json @@ -77,6 +77,7 @@ "IcymiTopic": "./misc.js#MattersChoiceTopic", "Moment": "./moment.js#Moment", "MomentFeedApplication": "./moment.js#MomentFeedUser", + "Quote": "./quote.js#Quote", "Writing": "./index.js#Writing", "Campaign": "./campaign.js#Campaign", "CampaignOSS": "./campaign.js#Campaign", diff --git a/db/migrations/20260611100000_create_quote_table.js b/db/migrations/20260611100000_create_quote_table.js new file mode 100644 index 000000000..0e0adc29b --- /dev/null +++ b/db/migrations/20260611100000_create_quote_table.js @@ -0,0 +1,34 @@ +import { baseDown } from '../utils.js' + +const table = 'quote' + +const indexName = `${table}_user_id_article_id_content_unique` + +export const up = async (knex) => { + await knex('entity_type').insert({ table }) + await knex.schema.createTable(table, (t) => { + t.bigIncrements('id').primary() + t.text('content').notNullable() + t.bigInteger('article_id').unsigned().notNullable() + t.bigInteger('campaign_id').unsigned().notNullable() + t.bigInteger('user_id').unsigned().notNullable() + t.enu('state', ['active', 'archived', 'banned']).notNullable() + t.timestamp('created_at').defaultTo(knex.fn.now()) + t.timestamp('updated_at').defaultTo(knex.fn.now()) + + t.foreign('article_id').references('id').inTable('article') + t.foreign('campaign_id').references('id').inTable('campaign') + t.foreign('user_id').references('id').inTable('user') + + t.index('campaign_id') + t.index('article_id') + t.index(['user_id', 'created_at']) + }) + + // create a partial unique index to dedupe active quotes + await knex.raw( + `CREATE UNIQUE INDEX ${indexName} ON ${table} ("user_id", "article_id", "content") WHERE state = 'active'` + ) +} + +export const down = baseDown(table) diff --git a/schema.graphql b/schema.graphql index 08d59966e..40d3af350 100644 --- a/schema.graphql +++ b/schema.graphql @@ -321,6 +321,14 @@ type Mutation { unlikeMoment(input: UnlikeMomentInput!): Moment! applyMomentFeed: User! updateMomentFeedApplicationState(input: UpdateMomentFeedApplicationStateInput!): User! + + """Post a quote (selected from an article) onto the campaign quote wall.""" + putQuote(input: PutQuoteInput!): Quote! + + """ + Retract a quote from the wall (poster, source article author, or admin). + """ + deleteQuote(input: DeleteQuoteInput!): Boolean! putTopicChannel(input: PutTopicChannelInput!): TopicChannel! putCurationChannel(input: PutCurationChannelInput!): CurationChannel! putTagChannel(input: PutTagChannelInput!): Tag! @@ -1073,6 +1081,12 @@ type WritingChallenge implements Node & Campaign & Channel { state: CampaignState! participants(input: CampaignParticipantsInput!): CampaignParticipantConnection! articles(input: CampaignArticlesInput!): CampaignArticleConnection! + + """Comments made by campaign participants (public to read).""" + discussion(input: CommentsInput!): CommentConnection! + + """Discussion (include replies) count of this campaign.""" + discussionCount: Int! application: CampaignApplication featuredDescription(input: TranslationArgs): String! organizers: [User!]! @@ -1080,6 +1094,12 @@ type WritingChallenge implements Node & Campaign & Channel { showOther: Boolean! showAd: Boolean! oss: CampaignOSS! + + """Quotes on this campaign's quote wall (public).""" + quotes(input: QuotesInput!): QuoteConnection! + + """Quote count of this campaign's quote wall.""" + quoteCount: Int! } type CampaignOSS { @@ -1724,6 +1744,7 @@ input CommentInput { articleId: ID circleId: ID momentId: ID + campaignId: ID } input CommentCommentsInput { @@ -1914,6 +1935,7 @@ enum CommentType { circleDiscussion circleBroadcast moment + campaignDiscussion } """ @@ -4443,6 +4465,44 @@ enum MomentFeedUserReviewedBy { seed } +input PutQuoteInput { + articleId: ID! + content: String! +} + +input DeleteQuoteInput { + id: ID! +} + +input QuotesInput { + first: Int + after: String + + """random sampling for wall display; refetch to shuffle. when true, after is ignored""" + random: Boolean +} + +type Quote { + id: ID! + content: String! + article: Article! + + """the user who posted this quote onto the wall""" + poster: User! + createdAt: DateTime! +} + +type QuoteConnection implements Connection { + totalCount: Int! + pageInfo: PageInfo! + edges: [QuoteEdge!] +} + +type QuoteEdge { + cursor: String! + node: Quote! +} + interface Channel { id: ID! shortHash: String! diff --git a/src/common/enums/index.ts b/src/common/enums/index.ts index d2ea1a554..ad3f88812 100644 --- a/src/common/enums/index.ts +++ b/src/common/enums/index.ts @@ -29,6 +29,7 @@ export * from './appreciation.js' export * from './metrics.js' export * from './badges.js' export * from './moment.js' +export * from './quote.js' export * from './campaign.js' export * from './channel.js' export * from './feedback.js' @@ -60,6 +61,7 @@ export const COMMENT_TYPE = { circleDiscussion: 'circle_discussion', circleBroadcast: 'circle_broadcast', moment: 'moment', + campaignDiscussion: 'campaign_discussion', } as const export const COMMENT_TYPES_REVERSED = Object.fromEntries( @@ -168,6 +170,7 @@ export enum NODE_TYPES { Collection = 'Collection', Report = 'Report', Moment = 'Moment', + Quote = 'Quote', Campaign = 'Campaign', CampaignStage = 'CampaignStage', TopicChannel = 'TopicChannel', @@ -251,6 +254,9 @@ export const MAX_ARTICLE_CONTENT_REVISION_LENGTH = 50 export const MAX_ARTICLE_COMMENT_LENGTH = 1200 +// campaign discussion comment length cap (matches moment / 短動態 = 240) +export const MAX_CAMPAIGN_COMMENT_LENGTH = 240 + export const MAX_PINNED_WORKS_LIMIT = 3 export const LATEST_WORKS_NUM = 4 diff --git a/src/common/enums/quote.ts b/src/common/enums/quote.ts new file mode 100644 index 000000000..f1734a20c --- /dev/null +++ b/src/common/enums/quote.ts @@ -0,0 +1,12 @@ +export const QUOTE_STATE = { + active: 'active', + archived: 'archived', + banned: 'banned', +} as const + +// a quote is a "one-glance" excerpt; longer text is a paragraph, not a quote +export const MAX_QUOTE_LENGTH = 80 + +// anti-abuse caps (product-decided defaults, tunable) +export const QUOTE_DAILY_LIMIT = 5 +export const QUOTE_PER_ARTICLE_LIMIT = 2 diff --git a/src/connectors/atomService.ts b/src/connectors/atomService.ts index 4510cb027..cfd38c025 100644 --- a/src/connectors/atomService.ts +++ b/src/connectors/atomService.ts @@ -647,6 +647,7 @@ const UPATEABLE_TABLES = [ 'collection', 'matters_choice_topic', 'moment', + 'quote', 'moment_asset', 'moment_article', 'moment_tag', diff --git a/src/connectors/campaignService.ts b/src/connectors/campaignService.ts index 34fed97c2..9d07e6a9f 100644 --- a/src/connectors/campaignService.ts +++ b/src/connectors/campaignService.ts @@ -226,6 +226,12 @@ export class CampaignService { where: { userId, campaignId }, }) + // whether the user has successfully applied to (i.e. participates in) the campaign + public isParticipant = async (campaignId: string, userId: string) => { + const application = await this.getApplication(campaignId, userId) + return application?.state === CAMPAIGN_USER_STATE.succeeded + } + public findAndCountAll = async ( { skip, take }: { skip: number; take: number }, { diff --git a/src/definitions/index.d.ts b/src/definitions/index.d.ts index 3658993fb..2ed548d05 100644 --- a/src/definitions/index.d.ts +++ b/src/definitions/index.d.ts @@ -126,6 +126,7 @@ import type { PayoutAccount, Transaction, } from './payment.js' +import type { Quote } from './quote.js' import type { Report } from './report.js' import type { Tag, TagTranslation, UserTagsOrder } from './tag.js' import type { Translation } from './translation.js' @@ -172,6 +173,7 @@ export * from './wallet.js' export * from './misc.js' export * from './schema.js' export * from './moment.js' +export * from './quote.js' export * from './campaign.js' export * from './translation.js' export * from './channel.js' @@ -287,6 +289,7 @@ export interface TableTypeMap { featured_comment_materialized: FeaturedCommentMaterialized feature_flag: FeatureFlag moment: Moment + quote: Quote moment_asset: MomentAsset moment_article: MomentArticle moment_tag: MomentTag diff --git a/src/definitions/quote.d.ts b/src/definitions/quote.d.ts new file mode 100644 index 000000000..8e531d4a0 --- /dev/null +++ b/src/definitions/quote.d.ts @@ -0,0 +1,13 @@ +import type { QUOTE_STATE } from '#common/enums/index.js' +import type { ValueOf } from './generic.js' + +export interface Quote { + id: string + content: string + articleId: string + campaignId: string + userId: string + state: ValueOf + createdAt: Date + updatedAt: Date +} diff --git a/src/definitions/schema.d.ts b/src/definitions/schema.d.ts index b7436698d..50a127e38 100644 --- a/src/definitions/schema.d.ts +++ b/src/definitions/schema.d.ts @@ -38,6 +38,7 @@ import { Moment as MomentModel, MomentFeedUser as MomentFeedUserModel, } from './moment.js' +import { Quote as QuoteModel } from './quote.js' import { Campaign as CampaignModel, CampaignStage as CampaignStageModel, @@ -1432,6 +1433,7 @@ export type GQLCommentEdge = { export type GQLCommentInput = { articleId?: InputMaybe + campaignId?: InputMaybe circleId?: InputMaybe content: Scalars['String']['input'] mentions?: InputMaybe> @@ -1472,6 +1474,7 @@ export type GQLCommentState = 'active' | 'archived' | 'banned' | 'collapsed' export type GQLCommentType = | 'article' + | 'campaignDiscussion' | 'circleBroadcast' | 'circleDiscussion' | 'moment' @@ -1685,6 +1688,10 @@ export type GQLDeleteMomentInput = { id: Scalars['ID']['input'] } +export type GQLDeleteQuoteInput = { + id: Scalars['ID']['input'] +} + export type GQLDeleteTagsInput = { ids: Array } @@ -2279,6 +2286,8 @@ export type GQLMutation = { /** Remove a draft. */ deleteDraft?: Maybe deleteMoment: GQLMoment + /** Retract a quote from the wall (poster, source article author, or admin). */ + deleteQuote: Scalars['Boolean']['output'] deleteTags?: Maybe directImageUpload: GQLAsset /** Edit an article. */ @@ -2327,6 +2336,8 @@ export type GQLMutation = { putMoment: GQLMoment /** Create or Update an OAuth Client, used in OSS. */ putOAuthClient?: Maybe + /** Post a quote (selected from an article) onto the campaign quote wall. */ + putQuote: GQLQuote putRemark?: Maybe putRestrictedUsers: Array putSkippedListItem?: Maybe> @@ -2558,6 +2569,10 @@ export type GQLMutationDeleteMomentArgs = { input: GQLDeleteMomentInput } +export type GQLMutationDeleteQuoteArgs = { + input: GQLDeleteQuoteInput +} + export type GQLMutationDeleteTagsArgs = { input: GQLDeleteTagsInput } @@ -2666,6 +2681,10 @@ export type GQLMutationPutOAuthClientArgs = { input: GQLPutOAuthClientInput } +export type GQLMutationPutQuoteArgs = { + input: GQLPutQuoteInput +} + export type GQLMutationPutRemarkArgs = { input: GQLPutRemarkInput } @@ -3448,6 +3467,11 @@ export type GQLPutOAuthClientInput = { website?: InputMaybe } +export type GQLPutQuoteInput = { + articleId: Scalars['ID']['input'] + content: Scalars['String']['input'] +} + export type GQLPutRemarkInput = { id: Scalars['ID']['input'] remark: Scalars['String']['input'] @@ -3610,8 +3634,38 @@ export type GQLQueryUserArgs = { input: GQLUserInput } +export type GQLQuote = { + __typename?: 'Quote' + article: GQLArticle + content: Scalars['String']['output'] + createdAt: Scalars['DateTime']['output'] + id: Scalars['ID']['output'] + /** the user who posted this quote onto the wall */ + poster: GQLUser +} + +export type GQLQuoteConnection = GQLConnection & { + __typename?: 'QuoteConnection' + edges?: Maybe> + pageInfo: GQLPageInfo + totalCount: Scalars['Int']['output'] +} + export type GQLQuoteCurrency = 'HKD' | 'TWD' | 'USD' +export type GQLQuoteEdge = { + __typename?: 'QuoteEdge' + cursor: Scalars['String']['output'] + node: GQLQuote +} + +export type GQLQuotesInput = { + after?: InputMaybe + first?: InputMaybe + /** random sampling for wall display; refetch to shuffle. when true, after is ignored */ + random?: InputMaybe +} + export type GQLReadArticleInput = { id: Scalars['ID']['input'] } @@ -5111,6 +5165,10 @@ export type GQLWritingChallenge = GQLCampaign & channelEnabled: Scalars['Boolean']['output'] cover?: Maybe description?: Maybe + /** Comments made by campaign participants (public to read). */ + discussion: GQLCommentConnection + /** Discussion (include replies) count of this campaign. */ + discussionCount: Scalars['Int']['output'] featuredDescription: Scalars['String']['output'] id: Scalars['ID']['output'] isManager: Scalars['Boolean']['output'] @@ -5120,6 +5178,10 @@ export type GQLWritingChallenge = GQLCampaign & organizers: Array oss: GQLCampaignOss participants: GQLCampaignParticipantConnection + /** Quote count of this campaign's quote wall. */ + quoteCount: Scalars['Int']['output'] + /** Quotes on this campaign's quote wall (public). */ + quotes: GQLQuoteConnection shortHash: Scalars['String']['output'] showAd: Scalars['Boolean']['output'] showOther: Scalars['Boolean']['output'] @@ -5136,6 +5198,10 @@ export type GQLWritingChallengeDescriptionArgs = { input?: InputMaybe } +export type GQLWritingChallengeDiscussionArgs = { + input: GQLCommentsInput +} + export type GQLWritingChallengeFeaturedDescriptionArgs = { input?: InputMaybe } @@ -5152,6 +5218,10 @@ export type GQLWritingChallengeParticipantsArgs = { input: GQLCampaignParticipantsInput } +export type GQLWritingChallengeQuotesArgs = { + input: GQLQuotesInput +} + export type GQLWritingConnection = GQLConnection & { __typename?: 'WritingConnection' edges?: Maybe> @@ -5385,6 +5455,9 @@ export type GQLResolversInterfaceTypes< | (Omit & { edges?: Maybe> }) + | (Omit & { + edges?: Maybe> + }) | (Omit & { edges?: Maybe> }) @@ -5749,6 +5822,7 @@ export type GQLResolversTypes = ResolversObject<{ DeleteCurationChannelArticlesInput: GQLDeleteCurationChannelArticlesInput DeleteDraftInput: GQLDeleteDraftInput DeleteMomentInput: GQLDeleteMomentInput + DeleteQuoteInput: GQLDeleteQuoteInput DeleteTagsInput: GQLDeleteTagsInput DirectImageUploadInput: GQLDirectImageUploadInput Donator: ResolverTypeWrapper< @@ -5968,6 +6042,7 @@ export type GQLResolversTypes = ResolversObject<{ PutIcymiTopicInput: GQLPutIcymiTopicInput PutMomentInput: GQLPutMomentInput PutOAuthClientInput: GQLPutOAuthClientInput + PutQuoteInput: GQLPutQuoteInput PutRemarkInput: GQLPutRemarkInput PutRestrictedUsersInput: GQLPutRestrictedUsersInput PutSkippedListItemInput: GQLPutSkippedListItemInput @@ -5977,7 +6052,17 @@ export type GQLResolversTypes = ResolversObject<{ PutUserFederationSettingInput: GQLPutUserFederationSettingInput PutWritingChallengeInput: GQLPutWritingChallengeInput Query: ResolverTypeWrapper<{}> + Quote: ResolverTypeWrapper + QuoteConnection: ResolverTypeWrapper< + Omit & { + edges?: Maybe> + } + > QuoteCurrency: GQLQuoteCurrency + QuoteEdge: ResolverTypeWrapper< + Omit & { node: GQLResolversTypes['Quote'] } + > + QuotesInput: GQLQuotesInput ReadArticleInput: GQLReadArticleInput ReadHistory: ResolverTypeWrapper< Omit & { article: GQLResolversTypes['Article'] } @@ -6500,6 +6585,7 @@ export type GQLResolversParentTypes = ResolversObject<{ DeleteCurationChannelArticlesInput: GQLDeleteCurationChannelArticlesInput DeleteDraftInput: GQLDeleteDraftInput DeleteMomentInput: GQLDeleteMomentInput + DeleteQuoteInput: GQLDeleteQuoteInput DeleteTagsInput: GQLDeleteTagsInput DirectImageUploadInput: GQLDirectImageUploadInput Donator: GQLResolversUnionTypes['Donator'] @@ -6664,6 +6750,7 @@ export type GQLResolversParentTypes = ResolversObject<{ PutIcymiTopicInput: GQLPutIcymiTopicInput PutMomentInput: GQLPutMomentInput PutOAuthClientInput: GQLPutOAuthClientInput + PutQuoteInput: GQLPutQuoteInput PutRemarkInput: GQLPutRemarkInput PutRestrictedUsersInput: GQLPutRestrictedUsersInput PutSkippedListItemInput: GQLPutSkippedListItemInput @@ -6673,6 +6760,14 @@ export type GQLResolversParentTypes = ResolversObject<{ PutUserFederationSettingInput: GQLPutUserFederationSettingInput PutWritingChallengeInput: GQLPutWritingChallengeInput Query: {} + Quote: QuoteModel + QuoteConnection: Omit & { + edges?: Maybe> + } + QuoteEdge: Omit & { + node: GQLResolversParentTypes['Quote'] + } + QuotesInput: GQLQuotesInput ReadArticleInput: GQLReadArticleInput ReadHistory: Omit & { article: GQLResolversParentTypes['Article'] @@ -8585,6 +8680,7 @@ export type GQLConnectionResolvers< | 'MomentConnection' | 'NoticeConnection' | 'OAuthClientConnection' + | 'QuoteConnection' | 'ReadHistoryConnection' | 'RecentSearchConnection' | 'ReportConnection' @@ -9400,6 +9496,12 @@ export type GQLMutationResolvers< ContextType, RequireFields > + deleteQuote?: Resolver< + GQLResolversTypes['Boolean'], + ParentType, + ContextType, + RequireFields + > deleteTags?: Resolver< Maybe, ParentType, @@ -9567,6 +9669,12 @@ export type GQLMutationResolvers< ContextType, RequireFields > + putQuote?: Resolver< + GQLResolversTypes['Quote'], + ParentType, + ContextType, + RequireFields + > putRemark?: Resolver< Maybe, ParentType, @@ -10579,6 +10687,41 @@ export type GQLQueryResolvers< viewer?: Resolver, ParentType, ContextType> }> +export type GQLQuoteResolvers< + ContextType = Context, + ParentType extends GQLResolversParentTypes['Quote'] = GQLResolversParentTypes['Quote'] +> = ResolversObject<{ + article?: Resolver + content?: Resolver + createdAt?: Resolver + id?: Resolver + poster?: Resolver + __isTypeOf?: IsTypeOfResolverFn +}> + +export type GQLQuoteConnectionResolvers< + ContextType = Context, + ParentType extends GQLResolversParentTypes['QuoteConnection'] = GQLResolversParentTypes['QuoteConnection'] +> = ResolversObject<{ + edges?: Resolver< + Maybe>, + ParentType, + ContextType + > + pageInfo?: Resolver + totalCount?: Resolver + __isTypeOf?: IsTypeOfResolverFn +}> + +export type GQLQuoteEdgeResolvers< + ContextType = Context, + ParentType extends GQLResolversParentTypes['QuoteEdge'] = GQLResolversParentTypes['QuoteEdge'] +> = ResolversObject<{ + cursor?: Resolver + node?: Resolver + __isTypeOf?: IsTypeOfResolverFn +}> + export type GQLReadHistoryResolvers< ContextType = Context, ParentType extends GQLResolversParentTypes['ReadHistory'] = GQLResolversParentTypes['ReadHistory'] @@ -11929,6 +12072,13 @@ export type GQLWritingChallengeResolvers< ContextType, Partial > + discussion?: Resolver< + GQLResolversTypes['CommentConnection'], + ParentType, + ContextType, + RequireFields + > + discussionCount?: Resolver featuredDescription?: Resolver< GQLResolversTypes['String'], ParentType, @@ -11962,6 +12112,13 @@ export type GQLWritingChallengeResolvers< ContextType, RequireFields > + quoteCount?: Resolver + quotes?: Resolver< + GQLResolversTypes['QuoteConnection'], + ParentType, + ContextType, + RequireFields + > shortHash?: Resolver showAd?: Resolver showOther?: Resolver @@ -12133,6 +12290,9 @@ export type GQLResolvers = ResolversObject<{ PinnableWork?: GQLPinnableWorkResolvers Price?: GQLPriceResolvers Query?: GQLQueryResolvers + Quote?: GQLQuoteResolvers + QuoteConnection?: GQLQuoteConnectionResolvers + QuoteEdge?: GQLQuoteEdgeResolvers ReadHistory?: GQLReadHistoryResolvers ReadHistoryConnection?: GQLReadHistoryConnectionResolvers ReadHistoryEdge?: GQLReadHistoryEdgeResolvers diff --git a/src/mutations/comment/putComment.ts b/src/mutations/comment/putComment.ts index e148c4b42..2e9657d11 100644 --- a/src/mutations/comment/putComment.ts +++ b/src/mutations/comment/putComment.ts @@ -6,6 +6,7 @@ import type { Circle, Comment, Moment, + Campaign, } from '#definitions/index.js' import { @@ -16,6 +17,7 @@ import { COMMENT_STATE, NOTICE_TYPE, MAX_ARTICLE_COMMENT_LENGTH, + MAX_CAMPAIGN_COMMENT_LENGTH, MAX_MOMENT_COMMENT_LENGTH, MAX_CONTENT_LINK_TEXT_LENGTH, NODE_TYPES, @@ -24,6 +26,7 @@ import { } from '#common/enums/index.js' import { ArticleNotFoundError, + CampaignNotFoundError, CircleNotFoundError, CommentNotFoundError, MomentNotFoundError, @@ -60,6 +63,7 @@ const resolver: GQLMutationResolvers['putComment'] = async ( articleId, circleId, momentId, + campaignId, }, id, }, @@ -71,6 +75,7 @@ const resolver: GQLMutationResolvers['putComment'] = async ( commentService, paymentService, articleService, + campaignService, notificationService, userService, connections, @@ -104,6 +109,7 @@ const resolver: GQLMutationResolvers['putComment'] = async ( const isCircleDiscussion = type === 'circleDiscussion' const isCircleBroadcast = type === 'circleBroadcast' const isMoment = type === 'moment' + const isCampaignDiscussion = type === 'campaignDiscussion' if (isArticleType && !articleId) { throw new UserInputError('`articleId` is required if `type` is `article`') } else if ((isCircleDiscussion || isCircleBroadcast) && !circleId) { @@ -112,6 +118,10 @@ const resolver: GQLMutationResolvers['putComment'] = async ( ) } else if (isMoment && !momentId) { throw new UserInputError('`momentId` is required if `type` is `moment`') + } else if (isCampaignDiscussion && !campaignId) { + throw new UserInputError( + '`campaignId` is required if `type` is `campaignDiscussion`' + ) } else { data.type = COMMENT_TYPE[type] } @@ -130,6 +140,12 @@ const resolver: GQLMutationResolvers['putComment'] = async ( if (isMoment && stripHtml(content).length > MAX_MOMENT_COMMENT_LENGTH) { throw new UserInputError('content reach length limit') } + if ( + isCampaignDiscussion && + stripHtml(content).length > MAX_CAMPAIGN_COMMENT_LENGTH + ) { + throw new UserInputError('content reach length limit') + } /** * check target @@ -137,6 +153,7 @@ const resolver: GQLMutationResolvers['putComment'] = async ( let article: Article | undefined let circle: Circle | undefined let moment: Moment | undefined + let campaign: Campaign | undefined let targetAuthor: string | undefined if (articleId) { const { id: articleDbId } = fromGlobalId(articleId) @@ -191,9 +208,26 @@ const resolver: GQLMutationResolvers['putComment'] = async ( data.targetId = moment.id targetAuthor = moment.authorId + } else if (campaignId) { + const { id: campaignDbId } = fromGlobalId(campaignId) + campaign = await atomService.campaignIdLoader.load(campaignDbId) + + if (!campaign) { + throw new CampaignNotFoundError('target campaign does not exists') + } + + const { id: typeId } = await atomService.findFirst({ + table: 'entity_type', + where: { table: 'campaign' }, + }) + data.targetTypeId = typeId + data.targetId = campaign.id + + // campaign discussion has no single "author" to notify / block against + targetAuthor = undefined } else { throw new UserInputError( - '`articleId` or `circleId` or `momentId` is required' + '`articleId` or `circleId` or `momentId` or `campaignId` is required' ) } @@ -269,13 +303,35 @@ const resolver: GQLMutationResolvers['putComment'] = async ( } } - // check whether viewer is blocked by target author - const isBlocked = await userService.blocked({ - userId: targetAuthor, - targetId: viewer.id, - }) - if (isBlocked) { - throw new ForbiddenError('viewer is blocked by target author') + // campaign discussion is public to read, but only succeeded participants + // (or the campaign organizers/managers) may comment + if (campaign) { + const isParticipant = await campaignService.isParticipant( + campaign.id, + viewer.id + ) + const isOrganizer = + campaign.creatorId === viewer.id || + (campaign.organizerIds ?? []).includes(viewer.id) || + (campaign.managerIds ?? []).includes(viewer.id) + + if (!isParticipant && !isOrganizer) { + throw new ForbiddenError( + 'only campaign participants have the permission' + ) + } + } + + // check whether viewer is blocked by target author (skip when no single author, + // e.g. campaign discussion) + if (targetAuthor) { + const isBlocked = await userService.blocked({ + userId: targetAuthor, + targetId: viewer.id, + }) + if (isBlocked) { + throw new ForbiddenError('viewer is blocked by target author') + } } data.mentionedUserIds = @@ -369,7 +425,7 @@ const resolver: GQLMutationResolvers['putComment'] = async ( (targetAuthor !== parentCommentAuthor && targetAuthor !== replyToCommentAuthor)) - if (isArticleType && shouldNotifyArticleAuthor) { + if (isArticleType && targetAuthor && shouldNotifyArticleAuthor) { const isMentioned = !!data.mentionedUserIds?.includes(targetAuthor) if (!isMentioned) { @@ -503,7 +559,7 @@ const resolver: GQLMutationResolvers['putComment'] = async ( } } - if (isMoment) { + if (isMoment && targetAuthor) { const isMentioned = !!data.mentionedUserIds?.includes(targetAuthor) if (!isMentioned) { notificationService.trigger({ @@ -534,7 +590,11 @@ const resolver: GQLMutationResolvers['putComment'] = async ( ], tag: `put-comment:${newComment.id}`, }) - } else if (!(isCircleBroadcast && isLevel1Comment)) { + } else if ( + (isCircleDiscussion || isCircleBroadcast) && + !(isCircleBroadcast && isLevel1Comment) + ) { + // NOTE: campaignDiscussion mentions intentionally send no notice in MVP const noticeType = isCircleBroadcast ? NOTICE_TYPE.circle_new_broadcast_comments : NOTICE_TYPE.circle_new_discussion_comments @@ -587,6 +647,11 @@ const resolver: GQLMutationResolvers['putComment'] = async ( id: moment?.id as string, type: NODE_TYPES.Moment, } + : isCampaignDiscussion + ? { + id: campaign?.id as string, + type: NODE_TYPES.Campaign, + } : { id: circle?.id as string, type: NODE_TYPES.Circle, diff --git a/src/mutations/index.ts b/src/mutations/index.ts index a3e2e1324..215acf224 100644 --- a/src/mutations/index.ts +++ b/src/mutations/index.ts @@ -10,6 +10,7 @@ import draft from './draft/index.js' import moment from './moment/index.js' import notice from './notice/index.js' import oauthClient from './oauthClient/index.js' +import quote from './quote/index.js' import system from './system/index.js' import user from './user/index.js' @@ -25,5 +26,6 @@ export default merge( oauthClient, collection, moment, + quote, channel ) diff --git a/src/mutations/quote/deleteQuote.ts b/src/mutations/quote/deleteQuote.ts new file mode 100644 index 000000000..726e8ef35 --- /dev/null +++ b/src/mutations/quote/deleteQuote.ts @@ -0,0 +1,65 @@ +import type { GQLMutationResolvers } from '#definitions/index.js' + +import { NODE_TYPES, QUOTE_STATE } from '#common/enums/index.js' +import { + EntityNotFoundError, + ForbiddenError, + UserInputError, +} from '#common/errors.js' +import { fromGlobalId } from '#common/utils/index.js' +import { invalidateFQC } from '@matters/apollo-response-cache' + +const resolver: GQLMutationResolvers['deleteQuote'] = async ( + _, + { input: { id } }, + { + viewer, + dataSources: { + atomService, + connections: { redis }, + }, + } +) => { + const { id: quoteDbId, type } = fromGlobalId(id) + if (type !== 'Quote') { + throw new UserInputError('invalid id') + } + const quote = await atomService.findFirst({ + table: 'quote', + where: { id: quoteDbId }, + }) + if (!quote) { + throw new EntityNotFoundError('quote does not exists') + } + + // retraction is allowed for: the poster, the source article's author + // (the quoted words are theirs), or admin. checked before any idempotent + // shortcut so an unauthorized viewer can never probe a quote's existence + const article = await atomService.articleIdLoader.load(quote.articleId) + const isPoster = viewer.id === quote.userId + const isArticleAuthor = viewer.id === article?.authorId + if (!isPoster && !isArticleAuthor && !viewer.hasRole('admin')) { + throw new ForbiddenError('viewer has no permission') + } + + // idempotent: already retracted, nothing to do + if (quote.state !== QUOTE_STATE.active) { + return true + } + + // soft delete: hidden, not erased + await atomService.update({ + table: 'quote', + where: { id: quote.id }, + data: { state: QUOTE_STATE.archived }, + }) + + invalidateFQC({ + node: { id: quote.campaignId, type: NODE_TYPES.Campaign }, + redis, + }) + + return true +} + +export default resolver diff --git a/src/mutations/quote/index.ts b/src/mutations/quote/index.ts new file mode 100644 index 000000000..b93f3e273 --- /dev/null +++ b/src/mutations/quote/index.ts @@ -0,0 +1,9 @@ +import deleteQuote from './deleteQuote.js' +import putQuote from './putQuote.js' + +export default { + Mutation: { + putQuote, + deleteQuote, + }, +} diff --git a/src/mutations/quote/putQuote.ts b/src/mutations/quote/putQuote.ts new file mode 100644 index 000000000..a58bec0ae --- /dev/null +++ b/src/mutations/quote/putQuote.ts @@ -0,0 +1,180 @@ +import type { GQLMutationResolvers } from '#definitions/index.js' + +import { + ARTICLE_LICENSE_TYPE, + ARTICLE_STATE, + MAX_QUOTE_LENGTH, + NODE_TYPES, + QUOTE_DAILY_LIMIT, + QUOTE_PER_ARTICLE_LIMIT, + QUOTE_STATE, + USER_STATE, +} from '#common/enums/index.js' +import { + ActionLimitExceededError, + ArticleNotFoundError, + ForbiddenByStateError, + ForbiddenError, + UserInputError, +} from '#common/errors.js' +import { fromGlobalId, stripHtml } from '#common/utils/index.js' +import { invalidateFQC } from '@matters/apollo-response-cache' + +// collapse whitespace so "selected text" can be matched against the +// article content regardless of line breaks / indentation +const normalize = (text: string) => text.replace(/\s+/g, '') + +const resolver: GQLMutationResolvers['putQuote'] = async ( + _, + { input: { articleId, content } }, + { + viewer, + dataSources: { + atomService, + articleService, + connections: { knexRO, redis }, + }, + } +) => { + if (!viewer.userName) { + throw new ForbiddenError('user has no username') + } + if ( + [USER_STATE.banned, USER_STATE.archived, USER_STATE.frozen].includes( + viewer.state + ) + ) { + throw new ForbiddenByStateError(`${viewer.state} user has no permission`) + } + + // content: required, plain text, capped at MAX_QUOTE_LENGTH + const quoteText = stripHtml(content).trim() + if (quoteText.length <= 0) { + throw new UserInputError('"content" is required') + } + if (quoteText.length > MAX_QUOTE_LENGTH) { + throw new UserInputError( + `quote can not be longer than ${MAX_QUOTE_LENGTH} characters` + ) + } + + // target article + const { id: articleDbId, type } = fromGlobalId(articleId) + if (type !== 'Article') { + throw new UserInputError('invalid id') + } + const article = await atomService.findFirst({ + table: 'article', + where: { id: articleDbId, state: ARTICLE_STATE.active }, + }) + if (!article) { + throw new ArticleNotFoundError('target article does not exists') + } + + // license gate: ARR (all rights reserved) -> only the author may quote + const isAuthor = viewer.id === article.authorId + const articleVersion = await articleService.loadLatestArticleVersion( + article.id + ) + if (articleVersion.license === ARTICLE_LICENSE_TYPE.arr && !isAuthor) { + throw new ForbiddenError( + 'only the author can quote an all-rights-reserved article' + ) + } + + // the quote must be selected from the article content, not free-typed; + // this is the primary anti-abuse mechanism + const articleContent = await articleService.loadLatestArticleContent( + article.id + ) + if (!normalize(stripHtml(articleContent)).includes(normalize(quoteText))) { + throw new UserInputError('quote must be an excerpt of the article') + } + + // wall is campaign-scoped: the article must belong to a campaign + const campaignArticle = await atomService.findFirst({ + table: 'campaign_article', + where: { articleId: article.id, deleted: false }, + orderBy: [{ column: 'createdAt', order: 'desc' }], + }) + if (!campaignArticle) { + throw new UserInputError('only campaign articles can be quoted onto wall') + } + const campaignId = campaignArticle.campaignId + + // dedupe: same user + same article + identical content + const duplicated = await atomService.findFirst({ + table: 'quote', + where: { + userId: viewer.id, + articleId: article.id, + content: quoteText, + state: QUOTE_STATE.active, + }, + }) + if (duplicated) { + throw new UserInputError('this quote is already on the wall') + } + + // per-article cap (prevents plastering the wall with one article) + const perArticleCount = await atomService.count({ + table: 'quote', + where: { + userId: viewer.id, + articleId: article.id, + state: QUOTE_STATE.active, + }, + }) + if (perArticleCount >= QUOTE_PER_ARTICLE_LIMIT) { + throw new ActionLimitExceededError( + `up to ${QUOTE_PER_ARTICLE_LIMIT} quotes per article` + ) + } + + // daily cap; counts posting actions (UTC day), retraction does not refund + const todayStart = new Date() + todayStart.setUTCHours(0, 0, 0, 0) + const dailyCount = await knexRO('quote') + .count('id') + .where({ userId: viewer.id }) + .andWhere('createdAt', '>=', todayStart) + .first() + if (Number(dailyCount?.count ?? 0) >= QUOTE_DAILY_LIMIT) { + throw new ActionLimitExceededError( + `up to ${QUOTE_DAILY_LIMIT} quotes per day` + ) + } + + let quote + try { + quote = await atomService.create({ + table: 'quote', + data: { + content: quoteText, + articleId: article.id, + campaignId, + userId: viewer.id, + state: QUOTE_STATE.active, + }, + }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (err: any) { + // duplicate key error + if ( + err.code === '23505' && + err.constraint === 'quote_user_id_article_id_content_unique' + ) { + throw new UserInputError('this quote is already on the wall') + } + throw err + } + + invalidateFQC({ + node: { id: campaignId, type: NODE_TYPES.Campaign }, + redis, + }) + + return quote +} + +export default resolver diff --git a/src/queries/campaign/index.ts b/src/queries/campaign/index.ts index 245a73a1b..89cc5d5b8 100644 --- a/src/queries/campaign/index.ts +++ b/src/queries/campaign/index.ts @@ -3,6 +3,9 @@ import type { GQLResolvers } from '#definitions/index.js' import { NODE_TYPES } from '#common/enums/index.js' import { toGlobalId, fromDatetimeRangeString } from '#common/utils/index.js' +import campaignDiscussion from '../comment/campaign/discussion.js' +import campaignDiscussionCount from '../comment/campaign/discussionCount.js' + import announcements from './announcements.js' import application from './application.js' import articles from './articles.js' @@ -18,6 +21,8 @@ import name from './name.js' import navbarTitle from './navbarTitle.js' import organizers from './organizers.js' import participants from './participants.js' +import quoteCount from './quoteCount.js' +import quotes from './quotes.js' import stageDescription from './stage/description.js' import stageName from './stage/name.js' import stages from './stages.js' @@ -55,6 +60,10 @@ const schema: GQLResolvers = { managerIds?.includes(viewer.id) ?? false, participants, articles, + discussion: campaignDiscussion, + discussionCount: campaignDiscussionCount, + quotes, + quoteCount, channelEnabled, showOther: ({ showOther }) => showOther, showAd: ({ showAd }) => showAd, diff --git a/src/queries/campaign/quoteCount.ts b/src/queries/campaign/quoteCount.ts new file mode 100644 index 000000000..2860a48d9 --- /dev/null +++ b/src/queries/campaign/quoteCount.ts @@ -0,0 +1,18 @@ +import type { GQLWritingChallengeResolvers } from '#definitions/index.js' + +import { QUOTE_STATE } from '#common/enums/index.js' + +const resolver: GQLWritingChallengeResolvers['quoteCount'] = async ( + { id }, + _, + { dataSources: { atomService } } +) => { + const count = await atomService.count({ + table: 'quote', + where: { campaignId: id, state: QUOTE_STATE.active }, + }) + + return count +} + +export default resolver diff --git a/src/queries/campaign/quotes.ts b/src/queries/campaign/quotes.ts new file mode 100644 index 000000000..45388196d --- /dev/null +++ b/src/queries/campaign/quotes.ts @@ -0,0 +1,50 @@ +import type { GQLWritingChallengeResolvers } from '#definitions/index.js' + +import { QUOTE_STATE } from '#common/enums/index.js' +import { connectionFromArray, fromConnectionArgs } from '#common/utils/index.js' + +const DEFAULT_TAKE = 12 + +// public quote wall of a campaign; `random: true` returns a random sample +// (the "shuffle" button simply refetches), in which case `after` is ignored +const resolver: GQLWritingChallengeResolvers['quotes'] = async ( + { id }, + { input }, + { + dataSources: { + atomService, + connections: { knexRO }, + }, + } +) => { + const { random } = input + const { take, skip } = fromConnectionArgs(input, { + defaultTake: DEFAULT_TAKE, + }) + + const [quotes, totalCount] = await Promise.all([ + knexRO('quote') + .select() + .where({ campaignId: id, state: QUOTE_STATE.active }) + .modify((builder) => { + if (random) { + builder.orderByRaw('random()') + } else { + builder.orderBy('id', 'desc').offset(skip) + } + }) + .limit(take), + atomService.count({ + table: 'quote', + where: { campaignId: id, state: QUOTE_STATE.active }, + }), + ]) + + return connectionFromArray( + quotes, + random ? { first: take } : input, + totalCount + ) +} + +export default resolver diff --git a/src/queries/comment/campaign/discussion.ts b/src/queries/comment/campaign/discussion.ts new file mode 100644 index 000000000..939dd6b10 --- /dev/null +++ b/src/queries/comment/campaign/discussion.ts @@ -0,0 +1,88 @@ +import type { CommentFilter } from '#connectors/index.js' +import type { GQLWritingChallengeResolvers } from '#definitions/index.js' + +import { COMMENT_TYPE } from '#common/enums/index.js' +import { + connectionFromArray, + connectionFromArrayWithKeys, + cursorToKeys, + fromGlobalId, +} from '#common/utils/index.js' + +// Campaign discussion is public to read: unlike circle discussion, there is no +// membership check here. Write permission is enforced in the putComment mutation. +const resolver: GQLWritingChallengeResolvers['discussion'] = async ( + { id }, + { input: { sort, first, ...rest } }, + { dataSources: { atomService, commentService } } +) => { + if (!id) { + return connectionFromArray([], rest) + } + + // resolve sort to order + const order = sort === 'oldest' ? 'asc' : 'desc' + + // set default for first in forward pagination. use null for query all. + // TODO: use "last" for backward pagination + if (!rest.before && typeof first === 'undefined') { + first = 10 + } + + // handle pagination + let before + let after + if (rest.after) { + after = cursorToKeys(rest.after).idCursor?.toString() + } + if (rest.before) { + before = cursorToKeys(rest.before).idCursor?.toString() + } + + // handle filter + const { id: targetTypeId } = await atomService.findFirst({ + table: 'entity_type', + where: { table: 'campaign' }, + }) + + const where = { + type: COMMENT_TYPE.campaignDiscussion, + targetId: id, + targetTypeId, + parentCommentId: null, + } as CommentFilter + + if (rest.filter) { + const { parentComment, author, state } = rest.filter + if (parentComment || parentComment === null) { + where.parentCommentId = parentComment + ? fromGlobalId(parentComment).id + : null + } + if (author) { + where.authorId = fromGlobalId(author).id + } + if (state) { + where.state = state + } + } + + const [comments, totalCount] = await commentService.find({ + sort, + before, + after, + first, + where, + order, + includeAfter: rest.includeAfter, + includeBefore: rest.includeBefore, + }) + + if (!comments.length) { + return connectionFromArray([], rest) + } + + return connectionFromArrayWithKeys(comments, rest, totalCount) +} + +export default resolver diff --git a/src/queries/comment/campaign/discussionCount.ts b/src/queries/comment/campaign/discussionCount.ts new file mode 100644 index 000000000..30286c77f --- /dev/null +++ b/src/queries/comment/campaign/discussionCount.ts @@ -0,0 +1,22 @@ +import type { GQLWritingChallengeResolvers } from '#definitions/index.js' + +import { COMMENT_STATE, COMMENT_TYPE } from '#common/enums/index.js' + +const resolver: GQLWritingChallengeResolvers['discussionCount'] = async ( + { id }, + _, + { dataSources: { atomService } } +) => { + const count = await atomService.count({ + table: 'comment', + where: { + state: COMMENT_STATE.active, + targetId: id, + type: COMMENT_TYPE.campaignDiscussion, + }, + }) + + return count +} + +export default resolver diff --git a/src/queries/comment/node.ts b/src/queries/comment/node.ts index 5bed1d9c3..8b64d4037 100644 --- a/src/queries/comment/node.ts +++ b/src/queries/comment/node.ts @@ -18,6 +18,9 @@ const resolver: GQLCommentResolvers['node'] = async ( } else if (type === COMMENT_TYPE.moment) { const moment = await atomService.momentIdLoader.load(targetId) return { ...moment, __type: 'Moment' } + } else if (type === COMMENT_TYPE.campaignDiscussion) { + const campaign = await atomService.campaignIdLoader.load(targetId) + return { ...campaign, __type: 'WritingChallenge' } } else { const circle = await atomService.findFirst({ table: 'circle', diff --git a/src/queries/index.ts b/src/queries/index.ts index c37b7cda1..27d7eb213 100644 --- a/src/queries/index.ts +++ b/src/queries/index.ts @@ -11,6 +11,7 @@ import moment from './moment/index.js' import notice from './notice/index.js' import oauthClient from './oauthClient/index.js' import oauthRequestToken from './oauthRequestToken.js' +import quote from './quote/index.js' import recommendation from './recommendation.js' import response from './response/index.js' import scalars from './scalars.js' @@ -32,6 +33,7 @@ export default merge( exchangeRates, recommendation, moment, + quote, campaign, channel ) diff --git a/src/queries/quote/index.ts b/src/queries/quote/index.ts new file mode 100644 index 000000000..b8cad6531 --- /dev/null +++ b/src/queries/quote/index.ts @@ -0,0 +1,17 @@ +import type { GQLResolvers } from '#definitions/index.js' + +import { NODE_TYPES } from '#common/enums/index.js' +import { toGlobalId } from '#common/utils/index.js' + +const schema: GQLResolvers = { + Quote: { + id: ({ id }) => toGlobalId({ type: NODE_TYPES.Quote, id }), + article: ({ articleId }, _, { dataSources: { atomService } }) => + atomService.articleIdLoader.load(articleId), + poster: ({ userId }, _, { dataSources: { atomService } }) => + atomService.userIdLoader.load(userId), + createdAt: ({ createdAt }) => createdAt, + }, +} + +export default schema diff --git a/src/types/__test__/2/quote.test.ts b/src/types/__test__/2/quote.test.ts new file mode 100644 index 000000000..780e82cbc --- /dev/null +++ b/src/types/__test__/2/quote.test.ts @@ -0,0 +1,456 @@ +import type { Connections, Campaign, Article } from '#definitions/index.js' + +import { + NODE_TYPES, + USER_STATE, + CAMPAIGN_STATE, + QUOTE_STATE, + QUOTE_DAILY_LIMIT, + QUOTE_PER_ARTICLE_LIMIT, +} from '#common/enums/index.js' +import { toGlobalId, fromGlobalId } from '#common/utils/index.js' +import { AtomService, CampaignService } from '#connectors/index.js' + +import { genConnections, closeConnections, testClient } from '../utils.js' + +let connections: Connections +let atomService: AtomService +let campaignService: CampaignService + +// seed article #1 (author = user #1, license cc_by_nc_nd_4) has content +// "
some html string
". excerpts below are all valid sub-strings, +// which matters because putQuote rejects anything that is not an excerpt. +const ARTICLE_DB_ID = '1' +const articleGlobalId = toGlobalId({ + type: NODE_TYPES.Article, + id: ARTICLE_DB_ID, +}) + +// poster is user #2 so that "poster", "article author" and "other" are three +// distinct identities for the deleteQuote permission matrix. +const posterUser = { id: '2', state: USER_STATE.active, userName: 'test2' } + +const campaignData = { + name: 'quote test campaign', + applicationPeriod: [new Date('2024-01-01'), new Date('2024-01-02')] as const, + writingPeriod: [new Date('2024-01-03'), new Date('2024-01-04')] as const, + creatorId: '1', + state: CAMPAIGN_STATE.active, +} + +let campaign: Campaign + +// put the seed article onto a campaign wall so putQuote's campaign gate passes +const setupCampaignWithArticle = async () => { + const article = (await atomService.findFirst({ + table: 'article', + where: { id: ARTICLE_DB_ID }, + })) as Article + const _campaign = await campaignService.createWritingChallenge(campaignData) + const _stages = await campaignService.updateStages(_campaign.id, [ + { name: 'stage1' }, + ]) + await campaignService.apply(_campaign, { + id: article.authorId, + userName: 'test1', + state: USER_STATE.active, + }) + await campaignService.submitArticleToCampaign( + article, + _campaign.id, + _stages[0].id + ) + return _campaign +} + +beforeAll(async () => { + connections = await genConnections() + atomService = new AtomService(connections) + campaignService = new CampaignService(connections) + campaign = await setupCampaignWithArticle() +}, 30000) + +afterAll(async () => { + await closeConnections(connections) +}) + +// remove every quote between tests so per-article / daily caps and dedupe +// start from a clean slate each time +afterEach(async () => { + await connections.knex('quote').del() +}) + +const PUT_QUOTE = /* GraphQL */ ` + mutation ($input: PutQuoteInput!) { + putQuote(input: $input) { + id + content + article { + id + } + poster { + id + } + } + } +` + +const DELETE_QUOTE = /* GraphQL */ ` + mutation ($input: DeleteQuoteInput!) { + deleteQuote(input: $input) + } +` + +// create a quote row directly, bypassing the resolver, for delete/query tests +const seedQuote = async ({ + content, + userId = posterUser.id, + state = QUOTE_STATE.active, + campaignId = campaign.id, + articleId = ARTICLE_DB_ID, +}: { + content: string + userId?: string + state?: keyof typeof QUOTE_STATE + campaignId?: string + articleId?: string +}) => + atomService.create({ + table: 'quote', + data: { content, articleId, campaignId, userId, state }, + }) + +describe('putQuote', () => { + test('happy path: logged-in user puts an excerpt onto the wall', async () => { + const server = await testClient({ + connections, + context: { viewer: posterUser }, + isAuth: true, + }) + const content = 'some html string' + const { errors, data } = await server.executeOperation({ + query: PUT_QUOTE, + variables: { input: { articleId: articleGlobalId, content } }, + }) + expect(errors).toBeUndefined() + expect(data.putQuote.content).toBe(content) + expect(data.putQuote.article.id).toBe(articleGlobalId) + expect(data.putQuote.poster.id).toBe( + toGlobalId({ type: NODE_TYPES.User, id: posterUser.id }) + ) + + // persisted with the correct campaign attribution + const { id: quoteDbId } = fromGlobalId(data.putQuote.id) + const row = await atomService.findFirst({ + table: 'quote', + where: { id: quoteDbId }, + }) + expect(row.campaignId).toBe(campaign.id) + expect(row.userId).toBe(posterUser.id) + expect(row.state).toBe(QUOTE_STATE.active) + }) + + test('visitors are blocked by the auth directive', async () => { + const server = await testClient({ connections, isAuth: false }) + const { errors } = await server.executeOperation({ + query: PUT_QUOTE, + variables: { + input: { articleId: articleGlobalId, content: 'some html string' }, + }, + }) + expect(errors).toBeDefined() + expect(errors.length).toBe(1) + }) + + test('content longer than the cap is rejected', async () => { + const server = await testClient({ + connections, + context: { viewer: posterUser }, + isAuth: true, + }) + const { errors } = await server.executeOperation({ + query: PUT_QUOTE, + variables: { + input: { articleId: articleGlobalId, content: 'a'.repeat(81) }, + }, + }) + expect(errors?.[0].extensions.code).toBe('BAD_USER_INPUT') + }) + + test('content that is not an excerpt of the article is rejected', async () => { + const server = await testClient({ + connections, + context: { viewer: posterUser }, + isAuth: true, + }) + const { errors } = await server.executeOperation({ + query: PUT_QUOTE, + variables: { + input: { + articleId: articleGlobalId, + content: 'this text is not in the article', + }, + }, + }) + expect(errors?.[0].extensions.code).toBe('BAD_USER_INPUT') + }) + + test('dedupe: same user + article + content is rejected on second put', async () => { + const server = await testClient({ + connections, + context: { viewer: posterUser }, + isAuth: true, + }) + const content = 'html string' + const { errors: errors1 } = await server.executeOperation({ + query: PUT_QUOTE, + variables: { input: { articleId: articleGlobalId, content } }, + }) + expect(errors1).toBeUndefined() + + const { errors: errors2 } = await server.executeOperation({ + query: PUT_QUOTE, + variables: { input: { articleId: articleGlobalId, content } }, + }) + expect(errors2?.[0].extensions.code).toBe('BAD_USER_INPUT') + + // only one row persisted + const count = await atomService.count({ + table: 'quote', + where: { userId: posterUser.id, articleId: ARTICLE_DB_ID }, + }) + expect(count).toBe(1) + }) + + test('per-article cap blocks the next quote once the limit is reached', async () => { + // pre-fill up to the per-article limit with distinct excerpts + await seedQuote({ content: 'some' }) + await seedQuote({ content: 'html' }) + expect(QUOTE_PER_ARTICLE_LIMIT).toBe(2) + + const server = await testClient({ + connections, + context: { viewer: posterUser }, + isAuth: true, + }) + const { errors } = await server.executeOperation({ + query: PUT_QUOTE, + variables: { input: { articleId: articleGlobalId, content: 'string' } }, + }) + expect(errors?.[0].extensions.code).toBe('ACTION_LIMIT_EXCEEDED') + }) + + test('daily cap blocks the next quote once the limit is reached', async () => { + // seed the daily limit worth of quotes for this user (today, any article) + expect(QUOTE_DAILY_LIMIT).toBe(5) + for (let i = 0; i < QUOTE_DAILY_LIMIT; i++) { + await seedQuote({ content: `daily-${i}` }) + } + const server = await testClient({ + connections, + context: { viewer: posterUser }, + isAuth: true, + }) + const { errors } = await server.executeOperation({ + query: PUT_QUOTE, + variables: { input: { articleId: articleGlobalId, content: 'string' } }, + }) + expect(errors?.[0].extensions.code).toBe('ACTION_LIMIT_EXCEEDED') + }) +}) + +describe('deleteQuote', () => { + test('poster can retract their own quote', async () => { + const quote = await seedQuote({ content: 'some html string' }) + const server = await testClient({ + connections, + context: { viewer: posterUser }, + isAuth: true, + }) + const { errors, data } = await server.executeOperation({ + query: DELETE_QUOTE, + variables: { + input: { id: toGlobalId({ type: NODE_TYPES.Quote, id: quote.id }) }, + }, + }) + expect(errors).toBeUndefined() + expect(data.deleteQuote).toBe(true) + + const row = await atomService.findFirst({ + table: 'quote', + where: { id: quote.id }, + }) + expect(row.state).toBe(QUOTE_STATE.archived) + }) + + test('a non-poster, non-author, non-admin is forbidden', async () => { + // quote posted by user #2 on article authored by user #1; viewer is user + // #3, who is none of poster/author/admin + const quote = await seedQuote({ content: 'some html string' }) + const other = { id: '3', state: USER_STATE.active, userName: 'test3' } + const server = await testClient({ + connections, + context: { viewer: other }, + isAuth: true, + }) + const { errors } = await server.executeOperation({ + query: DELETE_QUOTE, + variables: { + input: { id: toGlobalId({ type: NODE_TYPES.Quote, id: quote.id }) }, + }, + }) + expect(errors?.[0].extensions.code).toBe('FORBIDDEN') + }) + + test('archived quote does not leak existence: still FORBIDDEN to outsiders', async () => { + const quote = await seedQuote({ + content: 'some html string', + state: QUOTE_STATE.archived, + }) + const other = { id: '3', state: USER_STATE.active, userName: 'test3' } + const server = await testClient({ + connections, + context: { viewer: other }, + isAuth: true, + }) + const { errors } = await server.executeOperation({ + query: DELETE_QUOTE, + variables: { + input: { id: toGlobalId({ type: NODE_TYPES.Quote, id: quote.id }) }, + }, + }) + // permission is checked before the idempotent short-circuit + expect(errors?.[0].extensions.code).toBe('FORBIDDEN') + }) + + test('non-existent id yields ENTITY_NOT_FOUND', async () => { + const server = await testClient({ + connections, + context: { viewer: posterUser }, + isAuth: true, + }) + const { errors } = await server.executeOperation({ + query: DELETE_QUOTE, + variables: { + input: { id: toGlobalId({ type: NODE_TYPES.Quote, id: '9999999' }) }, + }, + }) + expect(errors?.[0].extensions.code).toBe('ENTITY_NOT_FOUND') + }) + + test('idempotent: deleting an already-archived quote (by poster) succeeds', async () => { + const quote = await seedQuote({ + content: 'some html string', + state: QUOTE_STATE.archived, + }) + const server = await testClient({ + connections, + context: { viewer: posterUser }, + isAuth: true, + }) + const { errors, data } = await server.executeOperation({ + query: DELETE_QUOTE, + variables: { + input: { id: toGlobalId({ type: NODE_TYPES.Quote, id: quote.id }) }, + }, + }) + expect(errors).toBeUndefined() + expect(data.deleteQuote).toBe(true) + }) +}) + +describe('quotes query', () => { + const QUERY_CAMPAIGN_QUOTES = /* GraphQL */ ` + query ($campaignInput: CampaignInput!, $quotesInput: QuotesInput!) { + campaign(input: $campaignInput) { + ... on WritingChallenge { + quotes(input: $quotesInput) { + totalCount + pageInfo { + hasNextPage + endCursor + } + edges { + cursor + node { + id + content + } + } + } + } + } + } + ` + + test('returns active quotes and filters out archived / banned', async () => { + await seedQuote({ content: 'some' }) + await seedQuote({ content: 'html', state: QUOTE_STATE.archived }) + await seedQuote({ content: 'string', state: QUOTE_STATE.banned }) + + const server = await testClient({ connections }) + const { errors, data } = await server.executeOperation({ + query: QUERY_CAMPAIGN_QUOTES, + variables: { + campaignInput: { shortHash: campaign.shortHash }, + quotesInput: { first: 10 }, + }, + }) + expect(errors).toBeUndefined() + expect(data.campaign.quotes.totalCount).toBe(1) + expect(data.campaign.quotes.edges.length).toBe(1) + expect(data.campaign.quotes.edges[0].node.content).toBe('some') + }) + + test('pagination: after cursor advances pages and reports hasNextPage', async () => { + await seedQuote({ content: 'some' }) + await seedQuote({ content: 'html' }) + + const server = await testClient({ connections }) + const { data, errors } = await server.executeOperation({ + query: QUERY_CAMPAIGN_QUOTES, + variables: { + campaignInput: { shortHash: campaign.shortHash }, + quotesInput: { first: 1 }, + }, + }) + expect(errors).toBeUndefined() + expect(data.campaign.quotes.totalCount).toBe(2) + expect(data.campaign.quotes.edges.length).toBe(1) + expect(data.campaign.quotes.pageInfo.hasNextPage).toBe(true) + + const { data: data2, errors: errors2 } = await server.executeOperation({ + query: QUERY_CAMPAIGN_QUOTES, + variables: { + campaignInput: { shortHash: campaign.shortHash }, + quotesInput: { + first: 1, + after: data.campaign.quotes.edges[0].cursor, + }, + }, + }) + expect(errors2).toBeUndefined() + expect(data2.campaign.quotes.edges.length).toBe(1) + expect(data2.campaign.quotes.edges[0].node.id).not.toBe( + data.campaign.quotes.edges[0].node.id + ) + expect(data2.campaign.quotes.pageInfo.hasNextPage).toBe(false) + }) + + test('random mode does not error', async () => { + await seedQuote({ content: 'some' }) + await seedQuote({ content: 'html' }) + + const server = await testClient({ connections }) + const { errors, data } = await server.executeOperation({ + query: QUERY_CAMPAIGN_QUOTES, + variables: { + campaignInput: { shortHash: campaign.shortHash }, + quotesInput: { first: 10, random: true }, + }, + }) + expect(errors).toBeUndefined() + expect(data.campaign.quotes.totalCount).toBe(2) + expect(data.campaign.quotes.edges.length).toBe(2) + }) +}) diff --git a/src/types/campaign.ts b/src/types/campaign.ts index c45cfe8df..23597b2ad 100644 --- a/src/types/campaign.ts +++ b/src/types/campaign.ts @@ -130,6 +130,12 @@ export default /* GraphQL */ ` participants(input: CampaignParticipantsInput!): CampaignParticipantConnection! articles(input: CampaignArticlesInput!): CampaignArticleConnection! + "Comments made by campaign participants (public to read)." + discussion(input: CommentsInput!): CommentConnection! @complexity(multipliers: ["input.first"], value: 1) + + "Discussion (include replies) count of this campaign." + discussionCount: Int! + application: CampaignApplication @privateCache featuredDescription(input: TranslationArgs): String! diff --git a/src/types/comment.ts b/src/types/comment.ts index 705e48de5..25b6dc7de 100644 --- a/src/types/comment.ts +++ b/src/types/comment.ts @@ -180,6 +180,7 @@ export default /* GraphQL */ ` articleId: ID circleId: ID momentId: ID + campaignId: ID } input CommentCommentsInput { @@ -361,5 +362,6 @@ export default /* GraphQL */ ` circleDiscussion circleBroadcast moment + campaignDiscussion } ` diff --git a/src/types/index.ts b/src/types/index.ts index bbe1d6830..d273bb83d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -9,6 +9,7 @@ import moment from './moment.js' import notice from './notice.js' import oauthClient from './oauthClient.js' import payment from './payment.js' +import quote from './quote.js' import response from './response.js' import scalars from './scalars.js' import system from './system.js' @@ -40,5 +41,6 @@ export default [ oauthClient, collection, moment, + quote, channel, ] diff --git a/src/types/quote.ts b/src/types/quote.ts new file mode 100644 index 000000000..f9c05c06f --- /dev/null +++ b/src/types/quote.ts @@ -0,0 +1,61 @@ +import { AUTH_MODE, NODE_TYPES } from '#common/enums/index.js' +import { isProd } from '#common/environment.js' + +const PUT_QUOTE_RATE_LIMIT = isProd ? 6 : 30 + +export default /* GraphQL */ ` + extend type Mutation { + "Post a quote (selected from an article) onto the campaign quote wall." + putQuote(input: PutQuoteInput!): Quote! @auth(mode: "${AUTH_MODE.oauth}") @rateLimit(limit: ${PUT_QUOTE_RATE_LIMIT}, period: 300) + + "Retract a quote from the wall (poster, source article author, or admin)." + deleteQuote(input: DeleteQuoteInput!): Boolean! @auth(mode: "${AUTH_MODE.oauth}") + } + + extend type WritingChallenge { + "Quotes on this campaign's quote wall (public)." + quotes(input: QuotesInput!): QuoteConnection! @complexity(multipliers: ["input.first"], value: 1) + + "Quote count of this campaign's quote wall." + quoteCount: Int! + } + + input PutQuoteInput { + articleId: ID! + content: String! + } + + input DeleteQuoteInput { + id: ID! + } + + input QuotesInput { + first: Int @constraint(min: 0, max: 50) + after: String + + "random sampling for wall display; refetch to shuffle. when true, after is ignored" + random: Boolean + } + + type Quote { + id: ID! + content: String! + article: Article! @logCache(type: "${NODE_TYPES.Article}") + + "the user who posted this quote onto the wall" + poster: User! @logCache(type: "${NODE_TYPES.User}") + + createdAt: DateTime! + } + + type QuoteConnection implements Connection { + totalCount: Int! + pageInfo: PageInfo! + edges: [QuoteEdge!] + } + + type QuoteEdge { + cursor: String! + node: Quote! + } +`