diff --git a/src/server/api/routers/listings/comments.test.ts b/src/server/api/routers/listings/comments.test.ts index 37060c3f..8bbf03e6 100644 --- a/src/server/api/routers/listings/comments.test.ts +++ b/src/server/api/routers/listings/comments.test.ts @@ -7,6 +7,10 @@ vi.unmock('@/server/api/root') const mockHandleCommentVoteTrustEffects = vi.fn().mockResolvedValue(undefined) const mockEmitNotificationEvent = vi.fn() const mockCheckSpamContent = vi.fn().mockResolvedValue(undefined) +const mockAnalyticsComment = vi.fn() +const mockAnalyticsCommentVote = vi.fn() +const mockAnalyticsFirstTimeAction = vi.fn() +const mockLoggerError = vi.fn() vi.mock('@/server/utils/vote-trust-effects', () => ({ handleCommentVoteTrustEffects: (...args: unknown[]) => mockHandleCommentVoteTrustEffects(...args), @@ -33,8 +37,19 @@ vi.mock('@/server/utils/spam-check', () => ({ vi.mock('@/lib/analytics', () => ({ default: { - engagement: { comment: vi.fn(), commentVote: vi.fn() }, - userJourney: { firstTimeAction: vi.fn() }, + engagement: { + comment: (...args: unknown[]) => mockAnalyticsComment(...args), + commentVote: (...args: unknown[]) => mockAnalyticsCommentVote(...args), + }, + userJourney: { + firstTimeAction: (...args: unknown[]) => mockAnalyticsFirstTimeAction(...args), + }, + }, +})) + +vi.mock('@/lib/logger', () => ({ + logger: { + error: (...args: unknown[]) => mockLoggerError(...args), }, })) @@ -44,6 +59,7 @@ const USER_ID = '00000000-0000-4000-a000-000000000001' const AUTHOR_ID = '00000000-0000-4000-a000-000000000002' const LISTING_ID = '00000000-0000-4000-a000-000000000010' const COMMENT_ID = '00000000-0000-4000-a000-000000000020' +const PARENT_COMMENT_ID = '00000000-0000-4000-a000-000000000021' function createMockPrisma() { const mockTx = { @@ -103,6 +119,10 @@ function createCaller(overrides: { userId?: string; role?: Role; prisma?: MockPr } } +function flushBackgroundTasks(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)) +} + describe('handheld comments router — voteComment', () => { beforeEach(() => { vi.clearAllMocks() @@ -244,6 +264,147 @@ describe('handheld comments router — create', () => { expect(prisma.comment.create).toHaveBeenCalled() }) + it('emits listing comment notification and analytics for a top-level comment', async () => { + const { caller } = createCaller() + + await caller.create({ + listingId: LISTING_ID, + content: 'Runs well with these settings', + }) + + expect(mockEmitNotificationEvent).toHaveBeenCalledWith({ + eventType: 'LISTING_COMMENTED', + entityType: 'listing', + entityId: LISTING_ID, + triggeredBy: USER_ID, + payload: { + listingId: LISTING_ID, + commentId: COMMENT_ID, + parentId: undefined, + commentText: 'Runs well with these settings', + }, + }) + expect(mockAnalyticsComment).toHaveBeenCalledWith({ + action: 'created', + commentId: COMMENT_ID, + listingId: LISTING_ID, + isReply: false, + contentLength: 'Runs well with these settings'.length, + }) + await flushBackgroundTasks() + + expect(mockAnalyticsFirstTimeAction).toHaveBeenCalledWith({ + userId: USER_ID, + action: 'first_comment', + }) + }) + + it('emits reply notification and analytics for a child comment', async () => { + const { caller, prisma } = createCaller() + prisma.comment.findUnique.mockResolvedValue({ id: PARENT_COMMENT_ID }) + + await caller.create({ + listingId: LISTING_ID, + content: 'Replying with more settings', + parentId: PARENT_COMMENT_ID, + }) + + expect(prisma.comment.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + parent: { connect: { id: PARENT_COMMENT_ID } }, + }), + }), + ) + expect(mockEmitNotificationEvent).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: 'COMMENT_REPLIED', + payload: expect.objectContaining({ + listingId: LISTING_ID, + commentId: COMMENT_ID, + parentId: PARENT_COMMENT_ID, + commentText: 'Replying with more settings', + }), + }), + ) + expect(mockAnalyticsComment).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'reply', + isReply: true, + }), + ) + }) + + it('does not track first comment journey analytics after the first comment', async () => { + const { caller, prisma } = createCaller() + prisma.comment.count.mockResolvedValue(2) + + await caller.create({ + listingId: LISTING_ID, + content: 'Another comment', + }) + + await flushBackgroundTasks() + + expect(mockAnalyticsFirstTimeAction).not.toHaveBeenCalled() + }) + + it('returns the created comment when first-comment analytics fails', async () => { + const analyticsError = new Error('count failed') + const { caller, prisma } = createCaller() + prisma.comment.count.mockRejectedValue(analyticsError) + + const result = await caller.create({ + listingId: LISTING_ID, + content: 'Runs well with these settings', + }) + + expect(result.id).toBe(COMMENT_ID) + expect(prisma.comment.create).toHaveBeenCalled() + + await flushBackgroundTasks() + + expect(mockLoggerError).toHaveBeenCalledWith( + '[ListingCommentService] Failed to track first comment analytics', + expect.any(Error), + { + userId: USER_ID, + commentId: COMMENT_ID, + }, + ) + }) + + it('does not check spam or create when the listing is missing', async () => { + const { caller, prisma } = createCaller() + prisma.listing.findUnique.mockResolvedValue(null) + + await expect( + caller.create({ + listingId: LISTING_ID, + content: 'Runs well with these settings', + }), + ).rejects.toThrow('Report not found') + + expect(mockCheckSpamContent).not.toHaveBeenCalled() + expect(prisma.comment.create).not.toHaveBeenCalled() + }) + + it('does not check spam or create when the parent comment is missing', async () => { + const { caller, prisma } = createCaller() + prisma.comment.findUnique.mockResolvedValue(null) + + await expect( + caller.create({ + listingId: LISTING_ID, + content: 'Replying with more settings', + parentId: PARENT_COMMENT_ID, + }), + ).rejects.toThrow('Parent comment not found') + + expect(mockCheckSpamContent).not.toHaveBeenCalled() + expect(prisma.comment.create).not.toHaveBeenCalled() + }) + it('passes a human verification token to the spam check when retrying creation', async () => { const { caller, prisma } = createCaller() diff --git a/src/server/api/routers/listings/comments.ts b/src/server/api/routers/listings/comments.ts index ad4ff94b..890c08f0 100644 --- a/src/server/api/routers/listings/comments.ts +++ b/src/server/api/routers/listings/comments.ts @@ -16,90 +16,21 @@ import { canManageCommentPins } from '@/server/api/utils/pinPermissions' import { notificationEventEmitter, NOTIFICATION_EVENTS } from '@/server/notifications/eventEmitter' import { CommentsRepository } from '@/server/repositories/comments.repository' import { logAudit } from '@/server/services/audit.service' +import { ListingCommentService } from '@/server/services/listing-comment.service' import { isUserBanned } from '@/server/utils/query-builders' -import { checkSpamContent } from '@/server/utils/spam-check' import { handleCommentVoteTrustEffects } from '@/server/utils/vote-trust-effects' import { roleIncludesRole } from '@/utils/permission-system' import { canDeleteComment, canEditComment } from '@/utils/permissions' import { AuditAction, AuditEntityType, Role } from '@orm/client' export const commentsRouter = createTRPCRouter({ - // TODO: This should use a repository, too much logic in here. create: protectedProcedure.input(CreateCommentSchema).mutation(async ({ ctx, input }) => { - const { listingId, content, parentId, humanVerificationToken } = input - const userId = ctx.session.user.id - - const listing = await ctx.prisma.listing.findUnique({ - where: { id: listingId }, - }) - - if (!listing) return ResourceError.listing.notFound() - - // If parentId is provided, check if parent comment exists - if (parentId) { - const parentComment = await ctx.prisma.comment.findUnique({ where: { id: parentId } }) - - if (!parentComment) return ResourceError.comment.parentNotFound() - } - - const userExists = await ctx.prisma.user.findUnique({ - where: { id: userId }, - select: { id: true }, - }) - - if (!userExists) return ResourceError.user.notInDatabase(userId) - - await checkSpamContent({ - prisma: ctx.prisma, - userId, - content, - entityType: 'comment', - challengeMode: 'challenge', - humanVerificationToken, + const service = new ListingCommentService(ctx.prisma) + return service.create({ + ...input, + userId: ctx.session.user.id, headers: ctx.headers, }) - - const repository = new CommentsRepository(ctx.prisma) - const comment = await repository.create({ - content, - user: { connect: { id: userId } }, - listing: { connect: { id: listingId } }, - ...(parentId && { parent: { connect: { id: parentId } } }), - }) - - notificationEventEmitter.emitNotificationEvent({ - eventType: parentId - ? NOTIFICATION_EVENTS.COMMENT_REPLIED - : NOTIFICATION_EVENTS.LISTING_COMMENTED, - entityType: 'listing', - entityId: listingId, - triggeredBy: userId, - payload: { - listingId, - commentId: comment.id, - parentId: parentId ?? undefined, - commentText: content, - }, - }) - - analytics.engagement.comment({ - action: parentId ? 'reply' : 'created', - commentId: comment.id, - listingId: listingId, - isReply: !!parentId, - contentLength: content.length, - }) - - // Check if this is user's first comment for journey analytics - const userCommentCount = await ctx.prisma.comment.count({ - where: { userId: userId }, - }) - - if (userCommentCount === 1) { - analytics.userJourney.firstTimeAction({ userId: userId, action: 'first_comment' }) - } - - return comment }), get: publicProcedure.input(GetCommentsSchema).query(async ({ ctx, input }) => { diff --git a/src/server/repositories/comments.repository.ts b/src/server/repositories/comments.repository.ts index 71145eab..ca197cbd 100644 --- a/src/server/repositories/comments.repository.ts +++ b/src/server/repositories/comments.repository.ts @@ -123,21 +123,65 @@ export class CommentsRepository extends BaseRepository { }) } - /** - * Create a new comment - */ - async create( - data: Prisma.CommentCreateInput, - ): Promise> { - return this.prisma.comment.create({ - data, - include: CommentsRepository.includes.minimal, + async listingExists(listingId: string): Promise { + const listing = await this.handleDatabaseOperation( + () => this.prisma.listing.findUnique({ where: { id: listingId }, select: { id: true } }), + 'Listing', + ) + + return listing !== null + } + + async commentExists(commentId: string): Promise { + const comment = await this.handleDatabaseOperation( + () => this.prisma.comment.findUnique({ where: { id: commentId }, select: { id: true } }), + 'Comment', + ) + + return comment !== null + } + + async userExists(userId: string): Promise { + const user = await this.handleDatabaseOperation( + () => this.prisma.user.findUnique({ where: { id: userId }, select: { id: true } }), + 'User', + ) + + return user !== null + } + + async countByUser(userId: string): Promise { + return this.handleDatabaseOperation( + () => this.prisma.comment.count({ where: { userId } }), + 'Comment', + ) + } + + async create(data: Prisma.CommentCreateInput): Promise { + return this.handleDatabaseOperation( + () => + this.prisma.comment.create({ + data, + include: CommentsRepository.includes.minimal, + }), + 'Comment', + ) + } + + async createForListing(input: { + content: string + userId: string + listingId: string + parentId?: string + }): Promise { + return this.create({ + content: input.content, + user: { connect: { id: input.userId } }, + listing: { connect: { id: input.listingId } }, + ...(input.parentId ? { parent: { connect: { id: input.parentId } } } : {}), }) } - /** - * Update a comment - */ async update( id: string, data: Prisma.CommentUpdateInput, @@ -260,3 +304,7 @@ export class CommentsRepository extends BaseRepository { } } } + +export type MinimalComment = Prisma.CommentGetPayload<{ + include: typeof CommentsRepository.includes.minimal +}> diff --git a/src/server/services/listing-comment.service.ts b/src/server/services/listing-comment.service.ts new file mode 100644 index 00000000..4dd96192 --- /dev/null +++ b/src/server/services/listing-comment.service.ts @@ -0,0 +1,101 @@ +import analytics from '@/lib/analytics' +import { ResourceError } from '@/lib/errors' +import { logger } from '@/lib/logger' +import { notificationEventEmitter, NOTIFICATION_EVENTS } from '@/server/notifications/eventEmitter' +import { CommentsRepository, type MinimalComment } from '@/server/repositories/comments.repository' +import { checkSpamContent } from '@/server/utils/spam-check' +import { type PrismaClient } from '@orm/client' + +interface CreateListingCommentInput { + listingId: string + content: string + userId: string + parentId?: string | null + humanVerificationToken?: string + headers?: Headers +} + +export class ListingCommentService { + private readonly comments: CommentsRepository + + constructor(private readonly prisma: PrismaClient) { + this.comments = new CommentsRepository(prisma) + } + + async create(input: CreateListingCommentInput): Promise { + if (!(await this.comments.listingExists(input.listingId))) { + return ResourceError.listing.notFound() + } + + if (input.parentId && !(await this.comments.commentExists(input.parentId))) { + return ResourceError.comment.parentNotFound() + } + + if (!(await this.comments.userExists(input.userId))) { + return ResourceError.user.notInDatabase(input.userId) + } + + await checkSpamContent({ + prisma: this.prisma, + userId: input.userId, + content: input.content, + entityType: 'comment', + challengeMode: 'challenge', + humanVerificationToken: input.humanVerificationToken, + headers: input.headers, + }) + + const comment = await this.comments.createForListing({ + content: input.content, + userId: input.userId, + listingId: input.listingId, + parentId: input.parentId ?? undefined, + }) + + this.emitCreatedNotification(comment.id, input) + this.trackCreatedComment(comment.id, input) + void this.trackFirstComment(input.userId).catch((error: unknown) => { + logger.error('[ListingCommentService] Failed to track first comment analytics', error, { + userId: input.userId, + commentId: comment.id, + }) + }) + + return comment + } + + private emitCreatedNotification(commentId: string, input: CreateListingCommentInput): void { + notificationEventEmitter.emitNotificationEvent({ + eventType: input.parentId + ? NOTIFICATION_EVENTS.COMMENT_REPLIED + : NOTIFICATION_EVENTS.LISTING_COMMENTED, + entityType: 'listing', + entityId: input.listingId, + triggeredBy: input.userId, + payload: { + listingId: input.listingId, + commentId, + parentId: input.parentId ?? undefined, + commentText: input.content, + }, + }) + } + + private trackCreatedComment(commentId: string, input: CreateListingCommentInput): void { + analytics.engagement.comment({ + action: input.parentId ? 'reply' : 'created', + commentId, + listingId: input.listingId, + isReply: Boolean(input.parentId), + contentLength: input.content.length, + }) + } + + private async trackFirstComment(userId: string): Promise { + const userCommentCount = await this.comments.countByUser(userId) + + if (userCommentCount === 1) { + analytics.userJourney.firstTimeAction({ userId, action: 'first_comment' }) + } + } +}