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"; import { assertCommentManageableByActor, buildCommentCreateData, buildCommentMentionNotifications, commentBelongsToEntity, commentThreadInclude, commentWithAuthorInclude, parseCommentMentions, } from "./comment-support.js"; // ─── 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: commentThreadInclude, 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 = parseCommentMentions(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 (!commentBelongsToEntity({ comment: parent, entityType: input.entityType, 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: buildCommentCreateData({ entityType: input.entityType, entityId: input.entityId, parentId: input.parentId, authorId, body: input.body, mentions, }), include: commentWithAuthorInclude, }); // 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 notifications = buildCommentMentionNotifications({ mentionedUserIds, authorName, body: input.body, entityId: input.entityId, entityType: input.entityType, senderId: authorId, link: policy.buildLink(input.entityId), }); await Promise.all( notifications.map((notification) => createNotification({ db: ctx.db, ...notification, }), ), ); } 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); assertCommentManageableByActor({ authorId: existing.authorId, actorUserId: userId, isAdmin: dbUser?.systemRole === "ADMIN", action: "resolve", }); const updated = await ctx.db.comment.update({ where: { id: input.id }, data: { resolved: input.resolved }, include: commentWithAuthorInclude, }); 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); assertCommentManageableByActor({ authorId: existing.authorId, actorUserId: userId, isAdmin: dbUser?.systemRole === "ADMIN", action: "delete", }); // 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", }); }), });