Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 163 additions & 2 deletions src/server/api/routers/listings/comments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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),
},
}))

Expand All @@ -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 = {
Expand Down Expand Up @@ -103,6 +119,10 @@ function createCaller(overrides: { userId?: string; role?: Role; prisma?: MockPr
}
}

function flushBackgroundTasks(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0))
}

describe('handheld comments router — voteComment', () => {
beforeEach(() => {
vi.clearAllMocks()
Expand Down Expand Up @@ -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()

Expand Down
79 changes: 5 additions & 74 deletions src/server/api/routers/listings/comments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
72 changes: 60 additions & 12 deletions src/server/repositories/comments.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,21 +123,65 @@ export class CommentsRepository extends BaseRepository {
})
}

/**
* Create a new comment
*/
async create(
data: Prisma.CommentCreateInput,
): Promise<Prisma.CommentGetPayload<{ include: typeof CommentsRepository.includes.minimal }>> {
return this.prisma.comment.create({
data,
include: CommentsRepository.includes.minimal,
async listingExists(listingId: string): Promise<boolean> {
const listing = await this.handleDatabaseOperation(
() => this.prisma.listing.findUnique({ where: { id: listingId }, select: { id: true } }),
'Listing',
)

return listing !== null
}

async commentExists(commentId: string): Promise<boolean> {
const comment = await this.handleDatabaseOperation(
() => this.prisma.comment.findUnique({ where: { id: commentId }, select: { id: true } }),
'Comment',
)

return comment !== null
}

async userExists(userId: string): Promise<boolean> {
const user = await this.handleDatabaseOperation(
() => this.prisma.user.findUnique({ where: { id: userId }, select: { id: true } }),
'User',
)

return user !== null
}

async countByUser(userId: string): Promise<number> {
return this.handleDatabaseOperation(
() => this.prisma.comment.count({ where: { userId } }),
'Comment',
)
}

async create(data: Prisma.CommentCreateInput): Promise<MinimalComment> {
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<MinimalComment> {
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,
Expand Down Expand Up @@ -260,3 +304,7 @@ export class CommentsRepository extends BaseRepository {
}
}
}

export type MinimalComment = Prisma.CommentGetPayload<{
include: typeof CommentsRepository.includes.minimal
}>
Loading
Loading