import { z } from "zod"; import { TRPCError } from "@trpc/server"; import { SystemRole } from "@planarchy/shared"; import { createTRPCRouter, protectedProcedure } from "../trpc.js"; import { emitNotificationCreated } from "../sse/event-bus.js"; // ─── Helpers ────────────────────────────────────────────────────────────────── /** Resolve the DB user id from the session email. Throws UNAUTHORIZED if not found. */ async function resolveUserId(ctx: { db: { user: { findUnique: (args: { where: { email: string }; select: { id: true }; }) => Promise<{ id: string } | null>; }; }; session: { user?: { email?: string | null } | null }; }): Promise { const email = ctx.session.user?.email; if (!email) throw new TRPCError({ code: "UNAUTHORIZED" }); const user = await ctx.db.user.findUnique({ where: { email }, select: { id: true }, }); if (!user) throw new TRPCError({ code: "UNAUTHORIZED" }); return user.id; } /** * 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: z.string(), entityId: z.string(), }), ) .query(async ({ ctx, input }) => { 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" }, }); }), /** Count comments for a given entity (used for badge) */ count: protectedProcedure .input( z.object({ entityType: z.string(), entityId: z.string(), }), ) .query(async ({ ctx, input }) => { 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: z.string(), entityId: z.string(), parentId: z.string().optional(), body: z.string().min(1).max(10_000), }), ) .mutation(async ({ ctx, input }) => { const authorId = await resolveUserId(ctx); 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 }, }); if (!parent) { throw new TRPCError({ code: "NOT_FOUND", message: "Parent comment not found" }); } } 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(async (userId) => { const notification = await ctx.db.notification.create({ data: { userId, type: "COMMENT_MENTION", title: `${authorName} mentioned you in a comment`, body: truncatedBody, entityId: input.entityId, entityType: input.entityType, senderId: authorId, link: `/estimates/${input.entityId}?tab=comments`, channel: "in_app", }, }); emitNotificationCreated(userId, notification.id); }), ); } 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 = await resolveUserId(ctx); const dbUser = ctx.dbUser; const existing = await ctx.db.comment.findUnique({ where: { id: input.id }, select: { id: true, authorId: true }, }); if (!existing) { throw new TRPCError({ code: "NOT_FOUND", message: "Comment not found" }); } // Only the author or an admin can resolve const isAdmin = dbUser?.systemRole === SystemRole.ADMIN; if (existing.authorId !== userId && !isAdmin) { throw new TRPCError({ code: "FORBIDDEN", message: "Only the comment author or an admin can resolve comments", }); } return ctx.db.comment.update({ where: { id: input.id }, data: { resolved: input.resolved }, include: { author: { select: { id: true, name: true, email: true, image: true } }, }, }); }), /** 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 = await resolveUserId(ctx); const dbUser = ctx.dbUser; const existing = await ctx.db.comment.findUnique({ where: { id: input.id }, select: { id: true, authorId: true }, }); if (!existing) { throw new TRPCError({ code: "NOT_FOUND", message: "Comment not found" }); } const isAdmin = dbUser?.systemRole === 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 } }); }), });