import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { createAuditEntry } from "../lib/audit.js"; import { assertCommentEntityAccess, CommentEntityTypeSchema } from "../lib/comment-entity-registry.js"; import { createNotification } from "../lib/create-notification.js"; import type { TRPCContext } from "../trpc.js"; import { assertCommentManageableByActor, buildCommentCreateData, buildCommentMentionNotifications, commentBelongsToEntity, commentThreadInclude, commentWithAuthorInclude, parseCommentMentions, } from "./comment-support.js"; export const CommentEntityInputSchema = z.object({ entityType: CommentEntityTypeSchema, entityId: z.string(), }); export const CommentMentionCandidatesInputSchema = CommentEntityInputSchema.extend({ query: z.string().trim().max(100).optional(), }); export const CreateCommentInputSchema = CommentEntityInputSchema.extend({ parentId: z.string().optional(), body: z.string().min(1).max(10_000), }); export const ResolveCommentInputSchema = z.object({ id: z.string(), resolved: z.boolean().default(true), }); export const DeleteCommentInputSchema = z.object({ id: z.string(), }); type CommentProcedureContext = Pick; type CreateCommentProcedureContext = Pick; export async function listComments( ctx: CommentProcedureContext, input: z.infer, ) { await assertCommentEntityAccess(ctx, input.entityType, input.entityId); return ctx.db.comment.findMany({ where: { entityType: input.entityType, entityId: input.entityId, parentId: null, }, include: commentThreadInclude, orderBy: { createdAt: "asc" }, }); } export async function countComments( ctx: CommentProcedureContext, input: z.infer, ) { await assertCommentEntityAccess(ctx, input.entityType, input.entityId); return ctx.db.comment.count({ where: { entityType: input.entityType, entityId: input.entityId, }, }); } export async function listCommentMentionCandidates( ctx: CommentProcedureContext, input: z.infer, ) { 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); } export async function createComment( ctx: CreateCommentProcedureContext, input: z.infer, ) { 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 (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, }); 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", ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), after: comment as unknown as Record, source: "ui", }); return comment; } async function findManageableComment( ctx: CommentProcedureContext, id: string, ) { const existing = await ctx.db.comment.findUnique({ where: { 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); return existing; } export async function resolveComment( ctx: CommentProcedureContext, input: z.infer, ) { const userId = ctx.dbUser?.id; if (!userId) { throw new TRPCError({ code: "UNAUTHORIZED" }); } const existing = await findManageableComment(ctx, input.id); assertCommentManageableByActor({ authorId: existing.authorId, actorUserId: userId, isAdmin: ctx.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", ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), summary: input.resolved ? "Resolved comment" : "Unresolved comment", after: updated as unknown as Record, source: "ui", }); return updated; } export async function deleteComment( ctx: CommentProcedureContext, input: z.infer, ) { const userId = ctx.dbUser?.id; if (!userId) { throw new TRPCError({ code: "UNAUTHORIZED" }); } const existing = await findManageableComment(ctx, input.id); assertCommentManageableByActor({ authorId: existing.authorId, actorUserId: userId, isAdmin: ctx.dbUser?.systemRole === "ADMIN", action: "delete", }); 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", ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), before: existing as unknown as Record, source: "ui", }); }