import { z } from "zod"; import { TRPCError } from "@trpc/server"; import { createTRPCRouter, protectedProcedure } from "../trpc.js"; import { createNotification } from "../lib/create-notification.js"; import { createAuditEntry } from "../lib/audit.js"; import { assertCommentEntityAccess, CommentEntityTypeSchema } from "../lib/comment-entity-registry.js"; // ─── Helpers ────────────────────────────────────────────────────────────────── /** * Parse @mentions from comment body. * Pattern: @[Display Name](userId) * Returns an array of unique user IDs. */ function parseMentions(body: string): string[] { const regex = /@\[([^\]]+)\]\(([^)]+)\)/g; const ids = new Set(); let match: RegExpExecArray | null; while ((match = regex.exec(body)) !== null) { ids.add(match[2]!); } return Array.from(ids); } // ─── Router ─────────────────────────────────────────────────────────────────── export const commentRouter = createTRPCRouter({ /** List comments for a given entity, with author info and 1-level nested replies */ list: protectedProcedure .input( z.object({ entityType: CommentEntityTypeSchema, entityId: z.string(), }), ) .query(async ({ ctx, input }) => { await assertCommentEntityAccess(ctx, input.entityType, input.entityId); return ctx.db.comment.findMany({ where: { entityType: input.entityType, entityId: input.entityId, parentId: null, // only top-level comments }, include: { author: { select: { id: true, name: true, email: true, image: true } }, replies: { include: { author: { select: { id: true, name: true, email: true, image: true } }, }, orderBy: { createdAt: "asc" }, }, }, orderBy: { createdAt: "asc" }, }); }), /** List mention candidates that match the audience of the backing comment entity */ listMentionCandidates: protectedProcedure .input( z.object({ entityType: CommentEntityTypeSchema, entityId: z.string(), query: z.string().trim().max(100).optional(), }), ) .query(async ({ ctx, input }) => { const normalizedQuery = input.query && input.query.length > 0 ? input.query : undefined; const policy = await assertCommentEntityAccess(ctx, input.entityType, input.entityId); return policy.listMentionCandidates(ctx, input.entityId, normalizedQuery); }), /** Count comments for a given entity (used for badge) */ count: protectedProcedure .input( z.object({ entityType: CommentEntityTypeSchema, entityId: z.string(), }), ) .query(async ({ ctx, input }) => { await assertCommentEntityAccess(ctx, input.entityType, input.entityId); return ctx.db.comment.count({ where: { entityType: input.entityType, entityId: input.entityId, }, }); }), /** Create a comment, parse @mentions, and notify mentioned users */ create: protectedProcedure .input( z.object({ entityType: CommentEntityTypeSchema, entityId: z.string(), parentId: z.string().optional(), body: z.string().min(1).max(10_000), }), ) .mutation(async ({ ctx, input }) => { const policy = await assertCommentEntityAccess(ctx, input.entityType, input.entityId); const authorId = ctx.dbUser?.id; if (!authorId) throw new TRPCError({ code: "UNAUTHORIZED" }); const mentions = parseMentions(input.body); // If replying, verify the parent exists if (input.parentId) { const parent = await ctx.db.comment.findUnique({ where: { id: input.parentId }, select: { id: true, entityType: true, entityId: true }, }); if (!parent) { throw new TRPCError({ code: "NOT_FOUND", message: "Parent comment not found" }); } if (parent.entityType !== input.entityType || parent.entityId !== input.entityId) { throw new TRPCError({ code: "BAD_REQUEST", message: "Parent comment does not belong to the requested entity", }); } } const comment = await ctx.db.comment.create({ data: { entityType: input.entityType, entityId: input.entityId, ...(input.parentId !== undefined ? { parentId: input.parentId } : {}), authorId, body: input.body, mentions, }, include: { author: { select: { id: true, name: true, email: true, image: true } }, }, }); // Create notifications for mentioned users (excluding the author) const mentionedUserIds = mentions.filter((id) => id !== authorId); if (mentionedUserIds.length > 0) { const authorName = comment.author.name ?? comment.author.email; const truncatedBody = input.body.length > 120 ? `${input.body.slice(0, 120)}...` : input.body; await Promise.all( mentionedUserIds.map((userId) => createNotification({ db: ctx.db, userId, type: "COMMENT_MENTION", title: `${authorName} mentioned you in a comment`, body: truncatedBody, entityId: input.entityId, entityType: input.entityType, senderId: authorId, link: policy.buildLink(input.entityId), channel: "in_app", }), ), ); } void createAuditEntry({ db: ctx.db, entityType: "Comment", entityId: comment.id, entityName: input.body.slice(0, 50), action: "CREATE", userId: ctx.dbUser?.id, after: comment as unknown as Record, source: "ui", }); return comment; }), /** Resolve or unresolve a comment (author or admin only) */ resolve: protectedProcedure .input( z.object({ id: z.string(), resolved: z.boolean().default(true), }), ) .mutation(async ({ ctx, input }) => { const userId = ctx.dbUser?.id; if (!userId) throw new TRPCError({ code: "UNAUTHORIZED" }); const dbUser = ctx.dbUser; const existing = await ctx.db.comment.findUnique({ where: { id: input.id }, select: { id: true, authorId: true, entityType: true, entityId: true }, }); if (!existing) { throw new TRPCError({ code: "NOT_FOUND", message: "Comment not found" }); } await assertCommentEntityAccess(ctx, existing.entityType, existing.entityId); // Only the author or an admin can resolve const isAdmin = dbUser?.systemRole === "ADMIN"; if (existing.authorId !== userId && !isAdmin) { throw new TRPCError({ code: "FORBIDDEN", message: "Only the comment author or an admin can resolve comments", }); } const updated = await ctx.db.comment.update({ where: { id: input.id }, data: { resolved: input.resolved }, include: { author: { select: { id: true, name: true, email: true, image: true } }, }, }); void createAuditEntry({ db: ctx.db, entityType: "Comment", entityId: input.id, action: "UPDATE", userId: ctx.dbUser?.id, summary: input.resolved ? "Resolved comment" : "Unresolved comment", after: updated as unknown as Record, source: "ui", }); return updated; }), /** Delete a comment (author or admin only). Hard-deletes, including all replies. */ delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const userId = ctx.dbUser?.id; if (!userId) throw new TRPCError({ code: "UNAUTHORIZED" }); const dbUser = ctx.dbUser; const existing = await ctx.db.comment.findUnique({ where: { id: input.id }, select: { id: true, authorId: true, entityType: true, entityId: true }, }); if (!existing) { throw new TRPCError({ code: "NOT_FOUND", message: "Comment not found" }); } await assertCommentEntityAccess(ctx, existing.entityType, existing.entityId); const isAdmin = dbUser?.systemRole === "ADMIN"; if (existing.authorId !== userId && !isAdmin) { throw new TRPCError({ code: "FORBIDDEN", message: "Only the comment author or an admin can delete comments", }); } // Delete all replies first (they reference this comment as parent) await ctx.db.comment.deleteMany({ where: { parentId: input.id }, }); await ctx.db.comment.delete({ where: { id: input.id } }); void createAuditEntry({ db: ctx.db, entityType: "Comment", entityId: input.id, action: "DELETE", userId: ctx.dbUser?.id, before: existing as unknown as Record, source: "ui", }); }), });