From 814d5adfdede5644d895063d663ae8ace3baad01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 19:16:57 +0200 Subject: [PATCH] refactor(api): extract comment procedures --- .../comment-procedure-support.test.ts | 238 ++++++++++++++++ .../src/router/comment-procedure-support.ts | 254 +++++++++++++++++ packages/api/src/router/comment.ts | 265 ++---------------- 3 files changed, 516 insertions(+), 241 deletions(-) create mode 100644 packages/api/src/__tests__/comment-procedure-support.test.ts create mode 100644 packages/api/src/router/comment-procedure-support.ts diff --git a/packages/api/src/__tests__/comment-procedure-support.test.ts b/packages/api/src/__tests__/comment-procedure-support.test.ts new file mode 100644 index 0000000..f28508e --- /dev/null +++ b/packages/api/src/__tests__/comment-procedure-support.test.ts @@ -0,0 +1,238 @@ +import { TRPCError } from "@trpc/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + countComments, + createComment, + deleteComment, + listCommentMentionCandidates, + listComments, + resolveComment, +} from "../router/comment-procedure-support.js"; + +const { + assertCommentEntityAccess, + createNotification, + createAuditEntry, +} = vi.hoisted(() => ({ + assertCommentEntityAccess: vi.fn(), + createNotification: vi.fn(), + createAuditEntry: vi.fn(), +})); + +vi.mock("../lib/comment-entity-registry.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + assertCommentEntityAccess, + }; +}); + +vi.mock("../lib/create-notification.js", () => ({ + createNotification, +})); + +vi.mock("../lib/audit.js", () => ({ + createAuditEntry, +})); + +function createContext(overrides: Record = {}) { + return { + db: { + comment: { + findMany: vi.fn().mockResolvedValue([]), + count: vi.fn().mockResolvedValue(2), + findUnique: vi.fn().mockResolvedValue({ + id: "comment_1", + authorId: "user_1", + entityType: "estimate", + entityId: "est_1", + }), + create: vi.fn().mockResolvedValue({ + id: "comment_1", + body: "Hi @[Bob](user_2)", + author: { id: "user_1", name: "Alice", email: "alice@example.com", image: null }, + }), + update: vi.fn().mockResolvedValue({ + id: "comment_1", + resolved: true, + author: { id: "user_1", name: "Alice", email: "alice@example.com", image: null }, + }), + deleteMany: vi.fn().mockResolvedValue({ count: 1 }), + delete: vi.fn().mockResolvedValue(undefined), + }, + }, + dbUser: { + id: "user_1", + systemRole: "CONTROLLER", + permissionOverrides: null, + }, + roleDefaults: null, + ...overrides, + }; +} + +describe("comment procedure support", () => { + beforeEach(() => { + assertCommentEntityAccess.mockReset(); + createNotification.mockReset(); + createAuditEntry.mockReset(); + assertCommentEntityAccess.mockResolvedValue({ + buildLink: (entityId: string) => `/estimates/${entityId}?tab=comments`, + listMentionCandidates: vi.fn().mockResolvedValue([ + { id: "user_2", name: "Bob", email: "bob@example.com" }, + ]), + }); + }); + + it("lists and counts comments after access checks", async () => { + const ctx = createContext(); + + const listResult = await listComments(ctx as never, { + entityType: "estimate", + entityId: "est_1", + }); + const countResult = await countComments(ctx as never, { + entityType: "estimate", + entityId: "est_1", + }); + + expect(listResult).toEqual([]); + expect(countResult).toBe(2); + expect(assertCommentEntityAccess).toHaveBeenNthCalledWith(1, ctx, "estimate", "est_1"); + expect(assertCommentEntityAccess).toHaveBeenNthCalledWith(2, ctx, "estimate", "est_1"); + }); + + it("normalizes mention candidate queries via the policy returned from access checks", async () => { + const listMentionCandidates = vi.fn().mockResolvedValue([ + { id: "user_2", name: "Bob", email: "bob@example.com" }, + ]); + assertCommentEntityAccess.mockResolvedValue({ + buildLink: (entityId: string) => `/estimates/${entityId}?tab=comments`, + listMentionCandidates, + }); + + const result = await listCommentMentionCandidates(createContext() as never, { + entityType: "estimate", + entityId: "est_1", + query: "", + }); + + expect(result).toEqual([{ id: "user_2", name: "Bob", email: "bob@example.com" }]); + expect(listMentionCandidates).toHaveBeenCalledWith(expect.anything(), "est_1", undefined); + }); + + it("creates comments, validates parent ownership, and sends mention notifications", async () => { + const ctx = createContext({ + db: { + comment: { + findMany: vi.fn(), + count: vi.fn(), + findUnique: vi.fn().mockResolvedValue({ + id: "comment_parent", + entityType: "estimate", + entityId: "est_1", + }), + create: vi.fn().mockResolvedValue({ + id: "comment_1", + body: "Hi @[Bob](user_2) and @[Alice](user_1)", + author: { id: "user_1", name: "Alice", email: "alice@example.com", image: null }, + }), + update: vi.fn(), + deleteMany: vi.fn(), + delete: vi.fn(), + }, + }, + }); + + const result = await createComment(ctx as never, { + entityType: "estimate", + entityId: "est_1", + parentId: "comment_parent", + body: "Hi @[Bob](user_2) and @[Alice](user_1)", + }); + + expect(result.id).toBe("comment_1"); + expect(ctx.db.comment.create).toHaveBeenCalledWith({ + data: { + entityType: "estimate", + entityId: "est_1", + parentId: "comment_parent", + authorId: "user_1", + body: "Hi @[Bob](user_2) and @[Alice](user_1)", + mentions: ["user_2", "user_1"], + }, + include: { + author: { select: { id: true, name: true, email: true, image: true } }, + }, + }); + expect(createNotification).toHaveBeenCalledTimes(1); + expect(createNotification).toHaveBeenCalledWith(expect.objectContaining({ + userId: "user_2", + entityId: "est_1", + entityType: "estimate", + })); + }); + + it("rejects mismatched parent entities before creating a reply", async () => { + const ctx = createContext({ + db: { + comment: { + findMany: vi.fn(), + count: vi.fn(), + findUnique: vi.fn().mockResolvedValue({ + id: "comment_parent", + entityType: "estimate", + entityId: "est_other", + }), + create: vi.fn(), + update: vi.fn(), + deleteMany: vi.fn(), + delete: vi.fn(), + }, + }, + }); + + await expect(createComment(ctx as never, { + entityType: "estimate", + entityId: "est_1", + parentId: "comment_parent", + body: "Reply", + })).rejects.toThrowError(new TRPCError({ + code: "BAD_REQUEST", + message: "Parent comment does not belong to the requested entity", + })); + + expect(ctx.db.comment.create).not.toHaveBeenCalled(); + }); + + it("resolves and deletes comments only after management checks", async () => { + const ctx = createContext({ + dbUser: { + id: "user_admin", + systemRole: "ADMIN", + permissionOverrides: null, + }, + }); + + const resolved = await resolveComment(ctx as never, { + id: "comment_1", + resolved: true, + }); + await deleteComment(ctx as never, { id: "comment_1" }); + + expect(resolved.resolved).toBe(true); + expect(ctx.db.comment.update).toHaveBeenCalledWith({ + where: { id: "comment_1" }, + data: { resolved: true }, + include: { + author: { select: { id: true, name: true, email: true, image: true } }, + }, + }); + expect(ctx.db.comment.deleteMany).toHaveBeenCalledWith({ + where: { parentId: "comment_1" }, + }); + expect(ctx.db.comment.delete).toHaveBeenCalledWith({ + where: { id: "comment_1" }, + }); + }); +}); diff --git a/packages/api/src/router/comment-procedure-support.ts b/packages/api/src/router/comment-procedure-support.ts new file mode 100644 index 0000000..df59131 --- /dev/null +++ b/packages/api/src/router/comment-procedure-support.ts @@ -0,0 +1,254 @@ +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", + }); +} diff --git a/packages/api/src/router/comment.ts b/packages/api/src/router/comment.ts index 0c24122..9663968 100644 --- a/packages/api/src/router/comment.ts +++ b/packages/api/src/router/comment.ts @@ -1,257 +1,40 @@ -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 ─────────────────────────────────────────────────────────────────── + CommentEntityInputSchema, + CommentMentionCandidatesInputSchema, + CreateCommentInputSchema, + DeleteCommentInputSchema, + ResolveCommentInputSchema, + countComments, + createComment, + deleteComment, + listCommentMentionCandidates, + listComments, + resolveComment, +} from "./comment-procedure-support.js"; 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); + .input(CommentEntityInputSchema) + .query(({ ctx, input }) => listComments(ctx, input)), - 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); - }), + .input(CommentMentionCandidatesInputSchema) + .query(({ ctx, input }) => listCommentMentionCandidates(ctx, input)), - /** 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); + .input(CommentEntityInputSchema) + .query(({ ctx, input }) => countComments(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: 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); + .input(CreateCommentInputSchema) + .mutation(({ ctx, input }) => createComment(ctx, input)), - // 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; + .input(ResolveCommentInputSchema) + .mutation(({ ctx, input }) => resolveComment(ctx, input)), - 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", - }); - }), + .input(DeleteCommentInputSchema) + .mutation(({ ctx, input }) => deleteComment(ctx, input)), });