From ab46eca8b3cea0dbb7bf9fb903e492c6bde4847f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 14:28:07 +0200 Subject: [PATCH] refactor(api): extract comment router support --- .../api/src/__tests__/comment-support.test.ts | 109 +++++++++++++++++ packages/api/src/router/comment-support.ts | 112 ++++++++++++++++++ packages/api/src/router/comment.ts | 108 +++++++---------- 3 files changed, 265 insertions(+), 64 deletions(-) create mode 100644 packages/api/src/__tests__/comment-support.test.ts create mode 100644 packages/api/src/router/comment-support.ts diff --git a/packages/api/src/__tests__/comment-support.test.ts b/packages/api/src/__tests__/comment-support.test.ts new file mode 100644 index 0000000..7d29016 --- /dev/null +++ b/packages/api/src/__tests__/comment-support.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import { TRPCError } from "@trpc/server"; +import { + assertCommentManageableByActor, + buildCommentCreateData, + buildCommentMentionNotifications, + commentAuthorSelect, + commentBelongsToEntity, + commentThreadInclude, + commentWithAuthorInclude, + parseCommentMentions, +} from "../router/comment-support.js"; + +describe("comment support", () => { + it("parses unique mention ids from comment markdown", () => { + expect(parseCommentMentions("Hi @[Alice](user_1) and @[Bob](user_2) and @[Alice](user_1)")).toEqual([ + "user_1", + "user_2", + ]); + }); + + it("exposes stable include definitions for comment reads and writes", () => { + expect(commentAuthorSelect).toEqual({ + id: true, + name: true, + email: true, + image: true, + }); + + expect(commentWithAuthorInclude).toEqual({ + author: { select: commentAuthorSelect }, + }); + + expect(commentThreadInclude).toEqual({ + author: { select: commentAuthorSelect }, + replies: { + include: { + author: { select: commentAuthorSelect }, + }, + orderBy: { createdAt: "asc" }, + }, + }); + }); + + it("builds comment create payloads and mention notifications", () => { + expect(buildCommentCreateData({ + entityType: "estimate", + entityId: "est_1", + parentId: "comment_parent", + authorId: "user_1", + body: "Please review @[Bob](user_2)", + mentions: ["user_2"], + })).toEqual({ + entityType: "estimate", + entityId: "est_1", + parentId: "comment_parent", + authorId: "user_1", + body: "Please review @[Bob](user_2)", + mentions: ["user_2"], + }); + + expect(buildCommentMentionNotifications({ + mentionedUserIds: ["user_2"], + authorName: "Alice Example", + body: "x".repeat(121), + entityId: "est_1", + entityType: "estimate", + senderId: "user_1", + link: "/estimates/est_1", + })).toEqual([ + { + userId: "user_2", + type: "COMMENT_MENTION", + title: "Alice Example mentioned you in a comment", + body: `${"x".repeat(120)}...`, + entityId: "est_1", + entityType: "estimate", + senderId: "user_1", + link: "/estimates/est_1", + channel: "in_app", + }, + ]); + }); + + it("checks entity ownership and author-or-admin permissions", () => { + expect(commentBelongsToEntity({ + comment: { + entityType: "resource", + entityId: "res_1", + }, + entityType: "resource", + entityId: "res_1", + })).toBe(true); + + expect(() => assertCommentManageableByActor({ + authorId: "user_2", + actorUserId: "user_1", + isAdmin: false, + action: "delete", + })).toThrowError(TRPCError); + + expect(() => assertCommentManageableByActor({ + authorId: "user_2", + actorUserId: "user_1", + isAdmin: true, + action: "resolve", + })).not.toThrow(); + }); +}); diff --git a/packages/api/src/router/comment-support.ts b/packages/api/src/router/comment-support.ts new file mode 100644 index 0000000..f57e0e4 --- /dev/null +++ b/packages/api/src/router/comment-support.ts @@ -0,0 +1,112 @@ +import { TRPCError } from "@trpc/server"; + +export const commentAuthorSelect = { + id: true, + name: true, + email: true, + image: true, +} as const; + +export const commentWithAuthorInclude = { + author: { select: commentAuthorSelect }, +} as const; + +export const commentThreadInclude = { + author: { select: commentAuthorSelect }, + replies: { + include: { + author: { select: commentAuthorSelect }, + }, + orderBy: { createdAt: "asc" as const }, + }, +} as const; + +export function parseCommentMentions(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); +} + +export function buildCommentCreateData(input: { + entityType: string; + entityId: string; + parentId?: string | undefined; + authorId: string; + body: string; + mentions: string[]; +}) { + return { + entityType: input.entityType, + entityId: input.entityId, + ...(input.parentId !== undefined ? { parentId: input.parentId } : {}), + authorId: input.authorId, + body: input.body, + mentions: input.mentions, + }; +} + +export function commentBelongsToEntity(input: { + comment: { + entityType: string; + entityId: string; + }; + entityType: string; + entityId: string; +}): boolean { + return ( + input.comment.entityType === input.entityType && + input.comment.entityId === input.entityId + ); +} + +export function assertCommentManageableByActor(input: { + authorId: string; + actorUserId: string; + isAdmin: boolean; + action: "resolve" | "delete"; +}) { + if (input.authorId === input.actorUserId || input.isAdmin) { + return; + } + + throw new TRPCError({ + code: "FORBIDDEN", + message: input.action === "resolve" + ? "Only the comment author or an admin can resolve comments" + : "Only the comment author or an admin can delete comments", + }); +} + +function truncateCommentNotificationBody(body: string): string { + return body.length > 120 ? `${body.slice(0, 120)}...` : body; +} + +export function buildCommentMentionNotifications(input: { + mentionedUserIds: string[]; + authorName: string; + body: string; + entityId: string; + entityType: string; + senderId: string; + link: string; +}) { + const truncatedBody = truncateCommentNotificationBody(input.body); + + return input.mentionedUserIds.map((userId) => ({ + userId, + type: "COMMENT_MENTION" as const, + title: `${input.authorName} mentioned you in a comment`, + body: truncatedBody, + entityId: input.entityId, + entityType: input.entityType, + senderId: input.senderId, + link: input.link, + channel: "in_app" as const, + })); +} diff --git a/packages/api/src/router/comment.ts b/packages/api/src/router/comment.ts index 6d36e7b..0c24122 100644 --- a/packages/api/src/router/comment.ts +++ b/packages/api/src/router/comment.ts @@ -4,23 +4,15 @@ 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); -} +import { + assertCommentManageableByActor, + buildCommentCreateData, + buildCommentMentionNotifications, + commentBelongsToEntity, + commentThreadInclude, + commentWithAuthorInclude, + parseCommentMentions, +} from "./comment-support.js"; // ─── Router ─────────────────────────────────────────────────────────────────── @@ -42,15 +34,7 @@ export const commentRouter = createTRPCRouter({ 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" }, - }, - }, + include: commentThreadInclude, orderBy: { createdAt: "asc" }, }); }), @@ -103,7 +87,7 @@ export const commentRouter = createTRPCRouter({ 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); + const mentions = parseCommentMentions(input.body); // If replying, verify the parent exists if (input.parentId) { @@ -114,7 +98,11 @@ export const commentRouter = createTRPCRouter({ if (!parent) { throw new TRPCError({ code: "NOT_FOUND", message: "Parent comment not found" }); } - if (parent.entityType !== input.entityType || parent.entityId !== input.entityId) { + 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", @@ -123,39 +111,36 @@ export const commentRouter = createTRPCRouter({ } const comment = await ctx.db.comment.create({ - data: { + data: buildCommentCreateData({ entityType: input.entityType, entityId: input.entityId, - ...(input.parentId !== undefined ? { parentId: input.parentId } : {}), + parentId: input.parentId, authorId, body: input.body, mentions, - }, - include: { - author: { select: { id: true, name: true, email: true, image: true } }, - }, + }), + 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 truncatedBody = - input.body.length > 120 ? `${input.body.slice(0, 120)}...` : input.body; + const notifications = buildCommentMentionNotifications({ + mentionedUserIds, + authorName, + body: input.body, + entityId: input.entityId, + entityType: input.entityType, + senderId: authorId, + link: policy.buildLink(input.entityId), + }); await Promise.all( - mentionedUserIds.map((userId) => + notifications.map((notification) => 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", + ...notification, }), ), ); @@ -199,21 +184,17 @@ export const commentRouter = createTRPCRouter({ 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", - }); - } + 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: { - author: { select: { id: true, name: true, email: true, image: true } }, - }, + include: commentWithAuthorInclude, }); void createAuditEntry({ @@ -249,13 +230,12 @@ export const commentRouter = createTRPCRouter({ 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", - }); - } + 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({