From 96637ee99c128cd1a5b471bf4819b7eecd24d316 Mon Sep 17 00:00:00 2001 From: yingshinlee Date: Thu, 11 Jun 2026 22:14:56 +0800 Subject: [PATCH 1/6] feat(comment): add campaignDiscussion comment type for campaign discussion board - new COMMENT_TYPE campaignDiscussion bound to campaign via targetId/targetTypeId - putComment: accept campaignId; only succeeded participants (campaign_user.state) or campaign organizers/managers may comment; cap content at 240 chars - WritingChallenge.discussion / discussionCount: public read resolvers - Comment.node resolves campaign comments to WritingChallenge - campaignService.isParticipant helper Co-Authored-By: Claude Fable 5 --- src/common/enums/index.ts | 4 + src/connectors/campaignService.ts | 6 ++ src/mutations/comment/putComment.ts | 83 +++++++++++++++-- src/queries/campaign/index.ts | 4 + src/queries/comment/campaign/discussion.ts | 88 +++++++++++++++++++ .../comment/campaign/discussionCount.ts | 22 +++++ src/queries/comment/node.ts | 3 + src/types/campaign.ts | 6 ++ src/types/comment.ts | 2 + 9 files changed, 209 insertions(+), 9 deletions(-) create mode 100644 src/queries/comment/campaign/discussion.ts create mode 100644 src/queries/comment/campaign/discussionCount.ts diff --git a/src/common/enums/index.ts b/src/common/enums/index.ts index d2ea1a554..10fa17e8e 100644 --- a/src/common/enums/index.ts +++ b/src/common/enums/index.ts @@ -60,6 +60,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( @@ -251,6 +252,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/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/mutations/comment/putComment.ts b/src/mutations/comment/putComment.ts index e148c4b42..a1720af57 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 = @@ -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/queries/campaign/index.ts b/src/queries/campaign/index.ts index 245a73a1b..3e03808ff 100644 --- a/src/queries/campaign/index.ts +++ b/src/queries/campaign/index.ts @@ -3,6 +3,8 @@ 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' @@ -55,6 +57,8 @@ const schema: GQLResolvers = { managerIds?.includes(viewer.id) ?? false, participants, articles, + discussion: campaignDiscussion, + discussionCount: campaignDiscussionCount, channelEnabled, showOther: ({ showOther }) => showOther, showAd: ({ showAd }) => showAd, 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/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 } ` From 3360f4d605166a1c106bbb716f583843df86c3c5 Mon Sep 17 00:00:00 2001 From: yingshinlee Date: Fri, 12 Jun 2026 13:31:11 +0800 Subject: [PATCH 2/6] style: blank line between import groups (eslint import/order) --- src/queries/campaign/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/queries/campaign/index.ts b/src/queries/campaign/index.ts index 3e03808ff..ecb54568b 100644 --- a/src/queries/campaign/index.ts +++ b/src/queries/campaign/index.ts @@ -5,6 +5,7 @@ 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' From a1f92f47ae86f55f545dd95624062d88962020f2 Mon Sep 17 00:00:00 2001 From: yingshinlee Date: Fri, 12 Jun 2026 13:46:37 +0800 Subject: [PATCH 3/6] chore: regenerate GraphQL schema types for campaignDiscussion - run codegen so schema.graphql / schema.d.ts include the new campaignDiscussion comment type, campaign discussion field and CommentInput.campaignId (CI relies on committed generated types) - putComment: guard article/moment notification blocks with targetAuthor (now string|undefined since campaign discussions have no single author) Co-Authored-By: Claude Fable 5 --- schema.graphql | 8 ++++++++ src/definitions/schema.d.ts | 17 +++++++++++++++++ src/mutations/comment/putComment.ts | 4 ++-- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/schema.graphql b/schema.graphql index 08d59966e..60b9d8469 100644 --- a/schema.graphql +++ b/schema.graphql @@ -1073,6 +1073,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!]! @@ -1724,6 +1730,7 @@ input CommentInput { articleId: ID circleId: ID momentId: ID + campaignId: ID } input CommentCommentsInput { @@ -1914,6 +1921,7 @@ enum CommentType { circleDiscussion circleBroadcast moment + campaignDiscussion } """ diff --git a/src/definitions/schema.d.ts b/src/definitions/schema.d.ts index b7436698d..4bf95ae8e 100644 --- a/src/definitions/schema.d.ts +++ b/src/definitions/schema.d.ts @@ -1432,6 +1432,7 @@ export type GQLCommentEdge = { export type GQLCommentInput = { articleId?: InputMaybe + campaignId?: InputMaybe circleId?: InputMaybe content: Scalars['String']['input'] mentions?: InputMaybe> @@ -1472,6 +1473,7 @@ export type GQLCommentState = 'active' | 'archived' | 'banned' | 'collapsed' export type GQLCommentType = | 'article' + | 'campaignDiscussion' | 'circleBroadcast' | 'circleDiscussion' | 'moment' @@ -5111,6 +5113,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'] @@ -5136,6 +5142,10 @@ export type GQLWritingChallengeDescriptionArgs = { input?: InputMaybe } +export type GQLWritingChallengeDiscussionArgs = { + input: GQLCommentsInput +} + export type GQLWritingChallengeFeaturedDescriptionArgs = { input?: InputMaybe } @@ -11929,6 +11939,13 @@ export type GQLWritingChallengeResolvers< ContextType, Partial > + discussion?: Resolver< + GQLResolversTypes['CommentConnection'], + ParentType, + ContextType, + RequireFields + > + discussionCount?: Resolver featuredDescription?: Resolver< GQLResolversTypes['String'], ParentType, diff --git a/src/mutations/comment/putComment.ts b/src/mutations/comment/putComment.ts index a1720af57..2e9657d11 100644 --- a/src/mutations/comment/putComment.ts +++ b/src/mutations/comment/putComment.ts @@ -425,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) { @@ -559,7 +559,7 @@ const resolver: GQLMutationResolvers['putComment'] = async ( } } - if (isMoment) { + if (isMoment && targetAuthor) { const isMentioned = !!data.mentionedUserIds?.includes(targetAuthor) if (!isMentioned) { notificationService.trigger({ From ec9564e5c291e4b76ef69a5c4d4f1aee1bf5b2c4 Mon Sep 17 00:00:00 2001 From: Mashbean Date: Sat, 13 Jun 2026 10:10:19 +0800 Subject: [PATCH 4/6] fix(comment): handle campaignDiscussion in vote/delete/pin and guard archived campaign - commentService.upvote & unvoteComment: add explicit campaignDiscussion branch with participant/organizer permission, skip blocked check (no single target author) instead of falling through to circle - deleteComment: invalidate the Campaign node (not Circle) and allow campaign creator/organizers/managers to delete discussion comments - togglePinComment: throw ForbiddenError for campaignDiscussion instead of loading it as a circle - putComment: reject commenting on archived campaigns - discussionCount: count via commentService.count (active + collapsed) to match the public discussion list Co-Authored-By: Claude Fable 5 --- src/connectors/commentService.ts | 42 +++++++++++++++---- src/mutations/comment/deleteComment.ts | 10 +++++ src/mutations/comment/putComment.ts | 10 +++-- src/mutations/comment/togglePinComment.ts | 2 + src/mutations/comment/unvoteComment.ts | 23 +++++++++- .../comment/campaign/discussionCount.ts | 17 ++------ 6 files changed, 78 insertions(+), 26 deletions(-) diff --git a/src/connectors/commentService.ts b/src/connectors/commentService.ts index 833ab78a7..bee2c0dbe 100644 --- a/src/connectors/commentService.ts +++ b/src/connectors/commentService.ts @@ -1,5 +1,6 @@ import type { Article, + Campaign, Circle, Comment, CommunityWatchAction, @@ -32,6 +33,7 @@ import { import { v4 } from 'uuid' import { BaseService } from './baseService.js' +import { CampaignService } from './campaignService.js' import { NotificationService } from './notification/notificationService.js' import { PaymentService } from './paymentService.js' import { SpamDetector } from './spamDetector.js' @@ -644,25 +646,31 @@ export class CommentService extends BaseService { // check target let article: Article let circle: Circle | undefined = undefined - let targetAuthorId: string + let campaign: Campaign | undefined = undefined + let targetAuthorId: string | undefined if (comment.type === COMMENT_TYPE.article) { article = await this.models.articleIdLoader.load(comment.targetId) targetAuthorId = article.authorId } else if (comment.type === COMMENT_TYPE.moment) { const moment = await this.models.momentIdLoader.load(comment.targetId) targetAuthorId = moment.authorId + } else if (comment.type === COMMENT_TYPE.campaignDiscussion) { + // campaign discussion has no single target author + campaign = await this.models.campaignIdLoader.load(comment.targetId) } else { circle = await this.models.circleIdLoader.load(comment.targetId) targetAuthorId = circle.owner } - const userService = new UserService(this.connections) - const isBlocked = await userService.blocked({ - userId: targetAuthorId, - targetId: user.id, - }) - if (isBlocked) { - throw new ForbiddenError('blocked user has no permission') + if (targetAuthorId) { + const userService = new UserService(this.connections) + const isBlocked = await userService.blocked({ + userId: targetAuthorId, + targetId: user.id, + }) + if (isBlocked) { + throw new ForbiddenError('blocked user has no permission') + } } // check permission @@ -680,6 +688,24 @@ export class CommentService extends BaseService { } } + if (campaign) { + const campaignService = new CampaignService(this.connections) + const isParticipant = await campaignService.isParticipant( + campaign.id, + user.id + ) + const isOrganizer = + campaign.creatorId === user.id || + (campaign.organizerIds ?? []).includes(user.id) || + (campaign.managerIds ?? []).includes(user.id) + + if (!isParticipant && !isOrganizer) { + throw new ForbiddenError( + 'only campaign participants have the permission' + ) + } + } + // check is voted before const voted = await this.findVotesByUserId({ userId: user.id, diff --git a/src/mutations/comment/deleteComment.ts b/src/mutations/comment/deleteComment.ts index 9bb68dadf..71caf2505 100644 --- a/src/mutations/comment/deleteComment.ts +++ b/src/mutations/comment/deleteComment.ts @@ -42,6 +42,14 @@ const resolver: GQLMutationResolvers['deleteComment'] = async ( ? [(await atomService.momentIdLoader.load(comment.targetId)).authorId] : []), ] + if (comment.type === COMMENT_TYPE.campaignDiscussion) { + const campaign = await atomService.campaignIdLoader.load(comment.targetId) + authorized.push( + campaign.creatorId, + ...(campaign.organizerIds ?? []), + ...(campaign.managerIds ?? []) + ) + } if (!authorized.includes(viewer.id)) { throw new ForbiddenError('viewer has no permission') } @@ -65,6 +73,8 @@ const resolver: GQLMutationResolvers['deleteComment'] = async ( ? NODE_TYPES.Article : comment.type === COMMENT_TYPE.moment ? NODE_TYPES.Moment + : comment.type === COMMENT_TYPE.campaignDiscussion + ? NODE_TYPES.Campaign : NODE_TYPES.Circle, }, redis: connections.redis, diff --git a/src/mutations/comment/putComment.ts b/src/mutations/comment/putComment.ts index 2e9657d11..b2d67f759 100644 --- a/src/mutations/comment/putComment.ts +++ b/src/mutations/comment/putComment.ts @@ -13,6 +13,7 @@ import { ARTICLE_ACCESS_TYPE, ARTICLE_STATE, BUNDLED_NOTICE_TYPE, + CAMPAIGN_STATE, COMMENT_TYPE, COMMENT_STATE, NOTICE_TYPE, @@ -31,6 +32,7 @@ import { CommentNotFoundError, MomentNotFoundError, ForbiddenByStateError, + ForbiddenByTargetStateError, ForbiddenError, UserInputError, } from '#common/errors.js' @@ -216,6 +218,10 @@ const resolver: GQLMutationResolvers['putComment'] = async ( throw new CampaignNotFoundError('target campaign does not exists') } + if (campaign.state === CAMPAIGN_STATE.archived) { + throw new ForbiddenByTargetStateError('campaign is archived') + } + const { id: typeId } = await atomService.findFirst({ table: 'entity_type', where: { table: 'campaign' }, @@ -316,9 +322,7 @@ const resolver: GQLMutationResolvers['putComment'] = async ( (campaign.managerIds ?? []).includes(viewer.id) if (!isParticipant && !isOrganizer) { - throw new ForbiddenError( - 'only campaign participants have the permission' - ) + throw new ForbiddenError('only campaign participants have the permission') } } diff --git a/src/mutations/comment/togglePinComment.ts b/src/mutations/comment/togglePinComment.ts index 18e9f940e..334eddef8 100644 --- a/src/mutations/comment/togglePinComment.ts +++ b/src/mutations/comment/togglePinComment.ts @@ -38,6 +38,8 @@ const resolver: Exclude< if (article.authorId !== viewer.id) { throw new ForbiddenError('viewer has no permission') } + } else if (comment.type === COMMENT_TYPE.campaignDiscussion) { + throw new ForbiddenError('cannot pin campaign discussion comment') } else { circle = await atomService.circleIdLoader.load(comment.targetId) const targetAuthor = circle.owner diff --git a/src/mutations/comment/unvoteComment.ts b/src/mutations/comment/unvoteComment.ts index 6028e58b7..37a955fc4 100644 --- a/src/mutations/comment/unvoteComment.ts +++ b/src/mutations/comment/unvoteComment.ts @@ -3,6 +3,7 @@ import type { Article, Circle, Moment, + Campaign, } from '#definitions/index.js' import { COMMENT_TYPE, USER_STATE, NOTICE_TYPE } from '#common/enums/index.js' @@ -17,6 +18,7 @@ const resolver: GQLMutationResolvers['unvoteComment'] = async ( dataSources: { atomService, paymentService, + campaignService, commentService, notificationService, }, @@ -33,13 +35,17 @@ const resolver: GQLMutationResolvers['unvoteComment'] = async ( let article: Article let circle: Circle | undefined = undefined let moment: Moment - let targetAuthor: string + let campaign: Campaign | undefined = undefined + let targetAuthor: string | undefined if (comment.type === COMMENT_TYPE.article) { article = await atomService.articleIdLoader.load(comment.targetId) targetAuthor = article.authorId } else if (comment.type === COMMENT_TYPE.moment) { moment = await atomService.momentIdLoader.load(comment.targetId) targetAuthor = moment.authorId + } else if (comment.type === COMMENT_TYPE.campaignDiscussion) { + // campaign discussion has no single target author + campaign = await atomService.campaignIdLoader.load(comment.targetId) } else { circle = await atomService.circleIdLoader.load(comment.targetId) targetAuthor = circle.owner @@ -68,6 +74,21 @@ const resolver: GQLMutationResolvers['unvoteComment'] = async ( } } + 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') + } + } + await commentService.unvote({ commentId: dbId, userId: viewer.id }) if ( diff --git a/src/queries/comment/campaign/discussionCount.ts b/src/queries/comment/campaign/discussionCount.ts index 30286c77f..eca085749 100644 --- a/src/queries/comment/campaign/discussionCount.ts +++ b/src/queries/comment/campaign/discussionCount.ts @@ -1,22 +1,11 @@ import type { GQLWritingChallengeResolvers } from '#definitions/index.js' -import { COMMENT_STATE, COMMENT_TYPE } from '#common/enums/index.js' +import { 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 -} + { dataSources: { commentService } } +) => commentService.count(id, COMMENT_TYPE.campaignDiscussion) export default resolver From 409a9d5b5edcd66c19caac41f960ff6fe6f88c71 Mon Sep 17 00:00:00 2001 From: Mashbean Date: Sat, 13 Jun 2026 10:45:03 +0800 Subject: [PATCH 5/6] test(comment): cover campaignDiscussion permissions and behavior Add integration tests for the campaign discussion comment feature covering putComment (participant/organizer/manager permission matrix, archived-campaign guard), vote/unvote (including the circle fallthrough fix), deleteComment author/organizer permissions, togglePinComment forbidden case, and discussion list/discussionCount consistency. Also add the missing DB migration that extends the comment_type_check constraint to allow 'campaign_discussion'. Without it, every attempt to create a campaignDiscussion comment fails at the database layer with a check-constraint violation, breaking the feature end-to-end. The feat branch added the code, GraphQL schema, and resolvers for the new type but never added this migration. Co-Authored-By: Claude Fable 5 --- ...er_comment_type_add_campaign_discussion.js | 26 + src/types/__test__/2/campaignComment.test.ts | 512 ++++++++++++++++++ 2 files changed, 538 insertions(+) create mode 100644 db/migrations/20260612000000_alter_comment_type_add_campaign_discussion.js create mode 100644 src/types/__test__/2/campaignComment.test.ts diff --git a/db/migrations/20260612000000_alter_comment_type_add_campaign_discussion.js b/db/migrations/20260612000000_alter_comment_type_add_campaign_discussion.js new file mode 100644 index 000000000..831c32bd7 --- /dev/null +++ b/db/migrations/20260612000000_alter_comment_type_add_campaign_discussion.js @@ -0,0 +1,26 @@ +import { alterEnumString } from '../utils.js' + +const table = 'comment' + +export const up = async (knex) => { + await knex.raw( + alterEnumString(table, 'type', [ + 'article', + 'circle_discussion', + 'circle_broadcast', + 'moment', + 'campaign_discussion', + ]) + ) +} + +export const down = async (knex) => { + await knex.raw( + alterEnumString(table, 'type', [ + 'article', + 'circle_discussion', + 'circle_broadcast', + 'moment', + ]) + ) +} diff --git a/src/types/__test__/2/campaignComment.test.ts b/src/types/__test__/2/campaignComment.test.ts new file mode 100644 index 000000000..4179027c3 --- /dev/null +++ b/src/types/__test__/2/campaignComment.test.ts @@ -0,0 +1,512 @@ +import type { Connections, Campaign } from '#definitions/index.js' + +import { v4 as uuidv4 } from 'uuid' + +import { + CAMPAIGN_STATE, + CAMPAIGN_USER_STATE, + COMMENT_STATE, + COMMENT_TYPE, + NODE_TYPES, +} from '#common/enums/index.js' +import { + AtomService, + CampaignService, + CommentService, +} from '#connectors/index.js' +import { toGlobalId } from '#common/utils/index.js' + +import { testClient, genConnections, closeConnections } from '../utils.js' + +let connections: Connections +let atomService: AtomService +let campaignService: CampaignService +let commentService: CommentService + +beforeAll(async () => { + connections = await genConnections() + atomService = new AtomService(connections) + campaignService = new CampaignService(connections) + commentService = new CommentService(connections) +}, 30000) + +afterAll(async () => { + await closeConnections(connections) +}) + +const PUT_COMMENT = /* GraphQL */ ` + mutation ($input: PutCommentInput!) { + putComment(input: $input) { + id + node { + ... on Campaign { + id + } + } + } + } +` + +const DELETE_COMMENT = /* GraphQL */ ` + mutation ($input: DeleteCommentInput!) { + deleteComment(input: $input) { + state + } + } +` + +const VOTE_COMMENT = /* GraphQL */ ` + mutation ($input: VoteCommentInput!) { + voteComment(input: $input) { + id + upvotes + downvotes + } + } +` + +const UNVOTE_COMMENT = /* GraphQL */ ` + mutation ($input: UnvoteCommentInput!) { + unvoteComment(input: $input) { + id + upvotes + downvotes + } + } +` + +const TOGGLE_PIN_COMMENT = /* GraphQL */ ` + mutation ($input: ToggleItemInput!) { + togglePinComment(input: $input) { + id + pinned + } + } +` + +const baseCampaignData = { + name: 'test campaign discussion', + 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, +} + +// directly set a campaign_user record to a given state, bypassing the +// auto-approve flow in campaignService.apply (which always succeeds) +const setApplicationState = async ( + campaignId: string, + userId: string, + state: (typeof CAMPAIGN_USER_STATE)[keyof typeof CAMPAIGN_USER_STATE] +) => { + const existing = await atomService.findFirst({ + table: 'campaign_user', + where: { campaignId, userId }, + }) + if (existing) { + return atomService.update({ + table: 'campaign_user', + where: { id: existing.id }, + data: { state }, + }) + } + return atomService.create({ + table: 'campaign_user', + data: { campaignId, userId, state }, + }) +} + +const createCampaignComment = async ( + campaign: Campaign, + authorId: string, + state: (typeof COMMENT_STATE)[keyof typeof COMMENT_STATE] = COMMENT_STATE.active, + parentCommentId: string | null = null +) => { + const { id: targetTypeId } = await atomService.findFirst({ + table: 'entity_type', + where: { table: 'campaign' }, + }) + return atomService.create({ + table: 'comment', + data: { + uuid: uuidv4(), + content: '

campaign discussion comment

', + authorId, + targetId: campaign.id, + targetTypeId, + parentCommentId, + type: COMMENT_TYPE.campaignDiscussion, + state, + }, + }) +} + +describe('put campaignDiscussion comment', () => { + // existing seeded users with usernames + const participantId = '2' + const nonParticipantId = '3' + const pendingId = '4' + const rejectedId = '5' + const creatorId = '1' + const organizerId = '6' + const managerId = '7' + + let campaign: Campaign + let campaignGlobalId: string + + const putCampaignComment = async (userId: string, campaignId: string) => { + const server = await testClient({ userId, isAuth: true, connections }) + return server.executeOperation({ + query: PUT_COMMENT, + variables: { + input: { + comment: { + content: 'test campaign discussion comment', + campaignId, + type: 'campaignDiscussion', + }, + }, + }, + }) + } + + beforeAll(async () => { + campaign = await campaignService.createWritingChallenge({ + ...baseCampaignData, + creatorId, + state: CAMPAIGN_STATE.active, + organizerIds: [organizerId], + managerIds: [managerId], + }) + campaignGlobalId = toGlobalId({ + type: NODE_TYPES.Campaign, + id: campaign.id, + }) + await setApplicationState( + campaign.id, + participantId, + CAMPAIGN_USER_STATE.succeeded + ) + await setApplicationState( + campaign.id, + pendingId, + CAMPAIGN_USER_STATE.pending + ) + await setApplicationState( + campaign.id, + rejectedId, + CAMPAIGN_USER_STATE.rejected + ) + }) + + test('succeeded participant can comment', async () => { + const { errors, data } = await putCampaignComment( + participantId, + campaignGlobalId + ) + expect(errors).toBeUndefined() + expect(data.putComment.id).toBeDefined() + expect(data.putComment.node.id).toBe(campaignGlobalId) + }) + + test('creator/organizer/manager can comment', async () => { + for (const userId of [creatorId, organizerId, managerId]) { + const { errors, data } = await putCampaignComment( + userId, + campaignGlobalId + ) + expect(errors).toBeUndefined() + expect(data.putComment.id).toBeDefined() + } + }) + + test('non-participant can not comment', async () => { + const { errors } = await putCampaignComment( + nonParticipantId, + campaignGlobalId + ) + expect(errors?.[0].extensions.code).toBe('FORBIDDEN') + }) + + test('pending applicant can not comment', async () => { + const { errors } = await putCampaignComment(pendingId, campaignGlobalId) + expect(errors?.[0].extensions.code).toBe('FORBIDDEN') + }) + + test('rejected applicant can not comment', async () => { + const { errors } = await putCampaignComment(rejectedId, campaignGlobalId) + expect(errors?.[0].extensions.code).toBe('FORBIDDEN') + }) + + test('can not comment on archived campaign', async () => { + const archivedCampaign = await campaignService.createWritingChallenge({ + ...baseCampaignData, + creatorId, + state: CAMPAIGN_STATE.archived, + }) + await setApplicationState( + archivedCampaign.id, + participantId, + CAMPAIGN_USER_STATE.succeeded + ) + const { errors } = await putCampaignComment( + participantId, + toGlobalId({ type: NODE_TYPES.Campaign, id: archivedCampaign.id }) + ) + expect(errors?.[0].extensions.code).toBe('FORBIDDEN_BY_TARGET_STATE') + }) + + test('finished and pending campaigns accept comments', async () => { + for (const state of [CAMPAIGN_STATE.finished, CAMPAIGN_STATE.pending]) { + const c = await campaignService.createWritingChallenge({ + ...baseCampaignData, + creatorId, + state, + }) + await setApplicationState( + c.id, + participantId, + CAMPAIGN_USER_STATE.succeeded + ) + const { errors, data } = await putCampaignComment( + participantId, + toGlobalId({ type: NODE_TYPES.Campaign, id: c.id }) + ) + expect(errors).toBeUndefined() + expect(data.putComment.id).toBeDefined() + } + }) +}) + +describe('vote/unvote campaignDiscussion comment', () => { + const participantId = '2' + const nonParticipantId = '3' + let campaign: Campaign + let comment: { id: string } + + beforeAll(async () => { + campaign = await campaignService.createWritingChallenge({ + ...baseCampaignData, + creatorId: '1', + state: CAMPAIGN_STATE.active, + }) + await setApplicationState( + campaign.id, + participantId, + CAMPAIGN_USER_STATE.succeeded + ) + comment = await createCampaignComment(campaign, participantId) + }) + + test('participant can upvote a campaign discussion comment (no circle fallthrough)', async () => { + const server = await testClient({ + userId: participantId, + isAuth: true, + connections, + }) + const { errors, data } = await server.executeOperation({ + query: VOTE_COMMENT, + variables: { + input: { + id: toGlobalId({ type: NODE_TYPES.Comment, id: comment.id }), + vote: 'up', + }, + }, + }) + expect(errors).toBeUndefined() + expect(data.voteComment.upvotes).toBe(1) + expect(data.voteComment.downvotes).toBe(0) + + const upvotes = await commentService.countUpVote(comment.id) + expect(upvotes).toBe(1) + }) + + test('participant can unvote a campaign discussion comment', async () => { + const server = await testClient({ + userId: participantId, + isAuth: true, + connections, + }) + const { errors, data } = await server.executeOperation({ + query: UNVOTE_COMMENT, + variables: { + input: { + id: toGlobalId({ type: NODE_TYPES.Comment, id: comment.id }), + }, + }, + }) + expect(errors).toBeUndefined() + expect(data.unvoteComment.upvotes).toBe(0) + + const upvotes = await commentService.countUpVote(comment.id) + expect(upvotes).toBe(0) + }) + + test('non-participant can not upvote a campaign discussion comment', async () => { + const server = await testClient({ + userId: nonParticipantId, + isAuth: true, + connections, + }) + const { errors } = await server.executeOperation({ + query: VOTE_COMMENT, + variables: { + input: { + id: toGlobalId({ type: NODE_TYPES.Comment, id: comment.id }), + vote: 'up', + }, + }, + }) + expect(errors?.[0].extensions.code).toBe('FORBIDDEN') + }) +}) + +describe('delete campaignDiscussion comment', () => { + const authorId = '2' + const otherUserId = '3' + const creatorId = '1' + const organizerId = '6' + const managerId = '7' + + let campaign: Campaign + + const deleteComment = async (userId: string, commentId: string) => { + const server = await testClient({ userId, isAuth: true, connections }) + return server.executeOperation({ + query: DELETE_COMMENT, + variables: { + input: { id: toGlobalId({ type: NODE_TYPES.Comment, id: commentId }) }, + }, + }) + } + + beforeAll(async () => { + campaign = await campaignService.createWritingChallenge({ + ...baseCampaignData, + creatorId, + state: CAMPAIGN_STATE.active, + organizerIds: [organizerId], + managerIds: [managerId], + }) + await setApplicationState( + campaign.id, + authorId, + CAMPAIGN_USER_STATE.succeeded + ) + }) + + test('comment author can delete own comment', async () => { + const comment = await createCampaignComment(campaign, authorId) + const { errors, data } = await deleteComment(authorId, comment.id) + expect(errors).toBeUndefined() + expect(data.deleteComment.state).toBe(COMMENT_STATE.archived) + }) + + test('creator/organizer/manager can delete others comments', async () => { + for (const userId of [creatorId, organizerId, managerId]) { + const comment = await createCampaignComment(campaign, authorId) + const { errors, data } = await deleteComment(userId, comment.id) + expect(errors).toBeUndefined() + expect(data.deleteComment.state).toBe(COMMENT_STATE.archived) + } + }) + + test('unrelated user can not delete others comment', async () => { + const comment = await createCampaignComment(campaign, authorId) + const { errors } = await deleteComment(otherUserId, comment.id) + expect(errors?.[0].extensions.code).toBe('FORBIDDEN') + }) +}) + +describe('pin campaignDiscussion comment', () => { + test('togglePinComment on a campaign discussion comment is forbidden', async () => { + const campaign = await campaignService.createWritingChallenge({ + ...baseCampaignData, + creatorId: '1', + state: CAMPAIGN_STATE.active, + }) + const comment = await createCampaignComment(campaign, '1') + const server = await testClient({ userId: '1', isAuth: true, connections }) + const { errors } = await server.executeOperation({ + query: TOGGLE_PIN_COMMENT, + variables: { + input: { + id: toGlobalId({ type: NODE_TYPES.Comment, id: comment.id }), + enabled: true, + }, + }, + }) + expect(errors?.[0].extensions.code).toBe('FORBIDDEN') + }) +}) + +describe('query campaign discussion list and count', () => { + const QUERY_DISCUSSION = /* GraphQL */ ` + query ($input: CampaignInput!, $commentsInput: CommentsInput!) { + campaign(input: $input) { + id + ... on WritingChallenge { + discussionCount + discussion(input: $commentsInput) { + totalCount + edges { + node { + id + state + } + } + } + } + } + } + ` + + const authorId = '2' + let campaign: Campaign + + beforeAll(async () => { + campaign = await campaignService.createWritingChallenge({ + ...baseCampaignData, + creatorId: '1', + state: CAMPAIGN_STATE.active, + }) + await setApplicationState( + campaign.id, + authorId, + CAMPAIGN_USER_STATE.succeeded + ) + // 2 active top-level comments, 1 archived, 1 banned + await createCampaignComment(campaign, authorId, COMMENT_STATE.active) + await createCampaignComment(campaign, authorId, COMMENT_STATE.active) + await createCampaignComment(campaign, authorId, COMMENT_STATE.archived) + await createCampaignComment(campaign, authorId, COMMENT_STATE.banned) + }) + + test('archived/banned comments are excluded from public list', async () => { + const server = await testClient({ connections }) + const { errors, data } = await server.executeOperation({ + query: QUERY_DISCUSSION, + variables: { + input: { shortHash: campaign.shortHash }, + commentsInput: { first: 10 }, + }, + }) + expect(errors).toBeUndefined() + const states = data.campaign.discussion.edges.map((e: any) => e.node.state) + expect(states).not.toContain(COMMENT_STATE.archived) + expect(states).not.toContain(COMMENT_STATE.banned) + expect(states.length).toBe(2) + }) + + test('discussionCount counts active/collapsed comments', async () => { + const server = await testClient({ connections }) + const { errors, data } = await server.executeOperation({ + query: QUERY_DISCUSSION, + variables: { + input: { shortHash: campaign.shortHash }, + commentsInput: { first: 10 }, + }, + }) + expect(errors).toBeUndefined() + // count is by service.count: active + collapsed only + expect(data.campaign.discussionCount).toBe(2) + }) +}) From cc29ecf30459a567dfcb0dddb6aea6c704f800d1 Mon Sep 17 00:00:00 2001 From: Mashbean Date: Sat, 13 Jun 2026 11:14:21 +0800 Subject: [PATCH 6/6] test(comment): cover campaignDiscussion error paths Add error-path coverage for putComment campaignDiscussion: missing campaignId, non-existing campaign, and over-length content; plus organizer upvote and non-participant unvote-forbidden cases. Co-Authored-By: Claude Fable 5 --- src/types/__test__/2/campaignComment.test.ts | 88 ++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/src/types/__test__/2/campaignComment.test.ts b/src/types/__test__/2/campaignComment.test.ts index 4179027c3..a841687d6 100644 --- a/src/types/__test__/2/campaignComment.test.ts +++ b/src/types/__test__/2/campaignComment.test.ts @@ -7,6 +7,7 @@ import { CAMPAIGN_USER_STATE, COMMENT_STATE, COMMENT_TYPE, + MAX_CAMPAIGN_COMMENT_LENGTH, NODE_TYPES, } from '#common/enums/index.js' import { @@ -274,11 +275,61 @@ describe('put campaignDiscussion comment', () => { expect(data.putComment.id).toBeDefined() } }) + + test('campaignId is required for campaignDiscussion type', async () => { + const server = await testClient({ + userId: participantId, + isAuth: true, + connections, + }) + const { errors } = await server.executeOperation({ + query: PUT_COMMENT, + variables: { + input: { + comment: { + content: 'test campaign discussion comment', + type: 'campaignDiscussion', + }, + }, + }, + }) + expect(errors?.[0].extensions.code).toBe('BAD_USER_INPUT') + }) + + test('can not comment on non-existing campaign', async () => { + const { errors } = await putCampaignComment( + participantId, + toGlobalId({ type: NODE_TYPES.Campaign, id: '99999999' }) + ) + expect(errors?.[0].extensions.code).toBe('CAMPAIGN_NOT_FOUND') + }) + + test('content exceeding length limit is rejected', async () => { + const server = await testClient({ + userId: participantId, + isAuth: true, + connections, + }) + const { errors } = await server.executeOperation({ + query: PUT_COMMENT, + variables: { + input: { + comment: { + content: 'x'.repeat(MAX_CAMPAIGN_COMMENT_LENGTH + 1), + campaignId: campaignGlobalId, + type: 'campaignDiscussion', + }, + }, + }, + }) + expect(errors?.[0].extensions.code).toBe('BAD_USER_INPUT') + }) }) describe('vote/unvote campaignDiscussion comment', () => { const participantId = '2' const nonParticipantId = '3' + const organizerId = '6' let campaign: Campaign let comment: { id: string } @@ -287,6 +338,7 @@ describe('vote/unvote campaignDiscussion comment', () => { ...baseCampaignData, creatorId: '1', state: CAMPAIGN_STATE.active, + organizerIds: [organizerId], }) await setApplicationState( campaign.id, @@ -357,6 +409,42 @@ describe('vote/unvote campaignDiscussion comment', () => { }) expect(errors?.[0].extensions.code).toBe('FORBIDDEN') }) + + test('organizer (non-participant) can upvote a campaign discussion comment', async () => { + const server = await testClient({ + userId: organizerId, + isAuth: true, + connections, + }) + const { errors, data } = await server.executeOperation({ + query: VOTE_COMMENT, + variables: { + input: { + id: toGlobalId({ type: NODE_TYPES.Comment, id: comment.id }), + vote: 'up', + }, + }, + }) + expect(errors).toBeUndefined() + expect(data.voteComment.upvotes).toBe(1) + }) + + test('non-participant can not unvote a campaign discussion comment', async () => { + const server = await testClient({ + userId: nonParticipantId, + isAuth: true, + connections, + }) + const { errors } = await server.executeOperation({ + query: UNVOTE_COMMENT, + variables: { + input: { + id: toGlobalId({ type: NODE_TYPES.Comment, id: comment.id }), + }, + }, + }) + expect(errors?.[0].extensions.code).toBe('FORBIDDEN') + }) }) describe('delete campaignDiscussion comment', () => {