From dd71e8f80b40e1bc5dfeb2de9cf326f0a3faaa5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Mon, 30 Mar 2026 18:50:36 +0200 Subject: [PATCH] fix(comment): align mention audience with entity visibility --- .../src/components/comments/CommentInput.tsx | 27 +- docs/architecture-hardening-backlog.md | 35 +++ docs/comment-visibility-architecture.md | 64 ++++- docs/route-access-matrix.md | 9 +- .../src/__tests__/comment-router-auth.test.ts | 261 ++++++++++++++++++ .../api/src/lib/comment-entity-registry.ts | 226 +++++++++++++++ packages/api/src/router/comment.ts | 91 ++---- 7 files changed, 616 insertions(+), 97 deletions(-) create mode 100644 docs/architecture-hardening-backlog.md create mode 100644 packages/api/src/lib/comment-entity-registry.ts diff --git a/apps/web/src/components/comments/CommentInput.tsx b/apps/web/src/components/comments/CommentInput.tsx index c704aeb..4f20c9e 100644 --- a/apps/web/src/components/comments/CommentInput.tsx +++ b/apps/web/src/components/comments/CommentInput.tsx @@ -1,12 +1,13 @@ "use client"; +import type { CommentEntityType } from "@capakraken/shared"; import { createPortal } from "react-dom"; import { useCallback, useEffect, useRef, useState } from "react"; import { trpc } from "~/lib/trpc/client.js"; import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js"; interface CommentInputProps { - entityType: "estimate"; + entityType: CommentEntityType; entityId: string; parentId?: string; onSubmit: (body: string) => void; @@ -23,8 +24,8 @@ interface MentionCandidate { } export function CommentInput({ - entityType: _entityType, - entityId: _entityId, + entityType, + entityId, parentId: _parentId, onSubmit, onCancel, @@ -38,25 +39,17 @@ export function CommentInput({ const [cursorPosition, setCursorPosition] = useState(0); const textareaRef = useRef(null); - // Fetch users for mention autocomplete (only when needed) - const usersQuery = trpc.user.listAssignable.useQuery(undefined, { + const usersQuery = trpc.comment.listMentionCandidates.useQuery({ + entityType, + entityId, + ...(mentionQuery && mentionQuery.length > 0 ? { query: mentionQuery } : {}), + }, { enabled: mentionQuery !== null, staleTime: 60_000, }); - const users = usersQuery.data ?? []; - - // Filter users based on mention query const filteredUsers: MentionCandidate[] = - mentionQuery !== null - ? users.filter((u) => { - const q = mentionQuery.toLowerCase(); - return ( - (u.name?.toLowerCase().includes(q) ?? false) || - u.email.toLowerCase().includes(q) - ); - }).slice(0, 8) - : []; + mentionQuery !== null ? (usersQuery.data ?? []).slice(0, 8) : []; const mentionOpen = mentionQuery !== null && filteredUsers.length > 0; const { panelRef, position } = useAnchoredOverlay({ open: mentionOpen, diff --git a/docs/architecture-hardening-backlog.md b/docs/architecture-hardening-backlog.md new file mode 100644 index 0000000..14b8e79 --- /dev/null +++ b/docs/architecture-hardening-backlog.md @@ -0,0 +1,35 @@ +# Architecture Hardening Backlog + +**Date:** 2026-03-30 +**Purpose:** Keep the remaining cleanup work for the current quality/security scope in a single prioritized list, so small hardening slices can be completed before larger redesign work. + +## Recently Completed + +- SSE audience model narrowed to canonical `user:*`, `permission:*`, and `resource:*` scopes only +- CI architecture guardrail added for SSE audience scoping +- import boundaries hardened for server dispo workbooks and browser spreadsheet uploads +- AI and SMTP runtime diagnostics sanitized before they reach logs or admin-facing error messages +- transitive audit hotspots for `flatted` and `picomatch` pinned through root `pnpm.overrides` +- `apps/web` export paths migrated from direct `xlsx` usage to a shared `exceljs` workbook export helper +- `packages/application` workbook reading and `packages/engine` XLSX export serialization migrated from `xlsx` to `exceljs` +- `pnpm audit --audit-level=high` no longer reports high-severity dependency findings +- `apps/web` now has focused Vitest coverage for browser spreadsheet parsing and skill-matrix workbook parsing +- cron routes, Redis helpers, reminder scheduling, webhook dispatching, and SSE fallback paths now use structured logger calls instead of raw `console.*` +- `packages/api` now has focused Vitest coverage for reminder scheduler and webhook dispatcher logging failures +- `apps/web` typecheck is now decoupled from generated `.next-e2e` artifacts via a dedicated `tsconfig.typecheck.json` +- comment entity support is now centralized across shared constants, API registry policy, assistant tool metadata, and the web comment target API without pretending a second consumer exists +- `resource` is now onboarded as the second real comment entity, reusing the same ownership and staff-visibility rules as the resource detail route +- comment mention autocomplete now uses a dedicated entity-scoped API route instead of inheriting the narrower `user.listAssignable` audience + +## Next Up + +No queued hardening slice is currently pinned in this document. +Reassess after the current batch so the next item reflects the then-real highest-risk gap instead of stale cleanup residue. + +## Working Rule + +For the next batches, prefer work in this order: + +1. remove or isolate known-risk runtime dependencies +2. add guardrails and tests around already-hardened code +3. only then expand architecture surface area diff --git a/docs/comment-visibility-architecture.md b/docs/comment-visibility-architecture.md index 5ea99ae..f25c4f1 100644 --- a/docs/comment-visibility-architecture.md +++ b/docs/comment-visibility-architecture.md @@ -1,7 +1,7 @@ # Comment Visibility Architecture **Date:** 2026-03-30 -**Status:** Phase 1 implemented +**Status:** Phase 4 mention audience aligned with entity visibility ## Problem @@ -14,11 +14,12 @@ That was too broad: ## Current Product Reality -There is only one real first-party consumer today: +There are now two real first-party consumers: - web UI estimate workspace comments via `entityType: "estimate"` +- web UI resource detail comments via `entityType: "resource"` -The older examples for `scope_item`, `estimate_version`, and `demand_line` were aspirational, not backed by an explicit visibility model or active UI. +The older examples for `scope_item`, `estimate_version`, and `demand_line` remain aspirational, not backed by an explicit visibility model or active UI. ## Architecture Decision @@ -31,20 +32,26 @@ Comments now use an explicit entity registry. - its deep link builder for notifications - every comment route calls the entity access layer before touching comment data -## Phase 1 Policy +## Current Policy Supported entity types: - `estimate` +- `resource` Audience: - same audience as the estimate workspace - controller, manager, or admin only +- `resource` comments inherit the exact resource detail audience: + - self-service for the caller's own linked resource + - broad visibility for users who already have resource overview access Route effects: - `list`, `count`, `create`, `resolve`, and `delete` all require estimate visibility first +- `listMentionCandidates` uses the same entity visibility gate before returning mention candidates +- the same routes require resource visibility first for `entityType: "resource"` - `resolve` and `delete` still require comment author or admin after entity visibility is granted - replies are only allowed when the parent comment belongs to the same entity tuple - mention notifications use the entity policy link builder instead of hardcoded route assumptions scattered through the router @@ -55,6 +62,53 @@ Route effects: - It keeps future comment expansion additive: a new entity type must be onboarded deliberately. - It gives the assistant and UI one source of truth for what is actually supported today. +## Phase 2 Foundation + +Phase 2 does not add a fake second entity type. +Instead, it removes duplication around the real registry: + +- supported comment entity types now live in shared constants instead of being re-declared in multiple layers +- the API owns a dedicated comment entity registry module for: + - access checks + - notification link building + - assistant-facing supported-entity metadata +- assistant tool schemas and descriptions derive their comment entity metadata from that registry layer +- the web comment thread now receives an explicit `{ entityType, entityId }` target instead of hardcoding `estimate` internally + +This keeps the product honest today while making a future second consumer additive rather than copy-paste driven. + +## Phase 3 Resource Onboarding + +`resource` is now the second deliberate commentable entity because it already had: + +- a real product detail surface +- an explicit access model in the API +- a coherent notification deep link target + +Implementation shape: + +- shared constants now expose both supported entity types +- the API registry now maps `resource` to: + - existence checks via the backing resource record + - audience checks via the same resource-read ownership rules as `resource.getById` + - notification links via `/resources/:id#comments` +- the resource detail page now renders a first-party comment thread instead of leaving the second consumer theoretical +- assistant comment tools now remain entity-scoped instead of pretending every comment operation is controller-only + +## Phase 4 Mention Audience Alignment + +Mention autocomplete no longer depends on `user.listAssignable`. +Instead, comments own a dedicated mention-candidate route scoped by the same entity tuple as comment read/write access. + +Current shape: + +- `comment.listMentionCandidates` first enforces the entity access policy +- estimate mention candidates are limited to `ADMIN`, `MANAGER`, and `CONTROLLER` users, matching estimate comment visibility +- resource mention candidates include: + - the linked owner of that resource + - users who already have broad resource visibility through effective permissions +- `user.listAssignable` remains a separate operational lookup for assignment flows and is not widened as a side effect of comment support + ## Extension Rules For Future Entity Types To add another commentable entity: @@ -67,7 +121,7 @@ To add another commentable entity: 6. Add router auth tests for unauthenticated, plain authenticated, and elevated callers. 7. Update `docs/route-access-matrix.md`. -## Non-Goals In Phase 1 +## Non-Goals - generic comment support for arbitrary entities - row-level polymorphic authorization based only on `entityType` strings diff --git a/docs/route-access-matrix.md b/docs/route-access-matrix.md index 718d8f4..836a0b9 100644 --- a/docs/route-access-matrix.md +++ b/docs/route-access-matrix.md @@ -75,13 +75,16 @@ Reasoning: ### `packages/api/src/router/comment.ts` -- `list`, `count`, `create`, `resolve`, `delete`: `entity-scoped` +- `list`, `listMentionCandidates`, `count`, `create`, `resolve`, `delete`: `entity-scoped` Reasoning: - comments must inherit the audience of the backing entity, not the comment row itself -- Phase 1 intentionally supports only `estimate`, because that is the only real product consumer today -- estimate comments therefore inherit the estimate workspace audience: controller, manager, or admin +- supported entity types are currently `estimate` and `resource` +- estimate comments inherit the estimate workspace audience: controller, manager, or admin +- resource comments inherit the resource detail audience: self-service for the caller's own linked resource, plus broad access for users who already have resource overview visibility +- mention autocomplete uses the same entity-scoped access check instead of reusing assignment-oriented user directory routes +- the registry keeps router policy, assistant metadata, and web comment targets on the same supported-entity definition - future entity types must be added through an explicit registry with per-entity access checks, assistant parity, and router tests in the same slice ### `packages/api/src/router/system-role-config.ts` diff --git a/packages/api/src/__tests__/comment-router-auth.test.ts b/packages/api/src/__tests__/comment-router-auth.test.ts index bdd08e6..8c61a42 100644 --- a/packages/api/src/__tests__/comment-router-auth.test.ts +++ b/packages/api/src/__tests__/comment-router-auth.test.ts @@ -145,6 +145,267 @@ describe("comment router authorization", () => { }); }); + it("returns estimate mention candidates only for the controller audience", async () => { + const estimateFindUnique = vi.fn().mockResolvedValue({ id: "est_1" }); + const userFindMany = vi.fn().mockResolvedValue([ + { id: "user_admin", name: "Admin User", email: "admin@example.com" }, + { id: "user_controller", name: "Controller User", email: "controller@example.com" }, + ]); + const caller = createCaller(createContext({ + estimate: { + findUnique: estimateFindUnique, + }, + user: { + findMany: userFindMany, + }, + }, { role: SystemRole.CONTROLLER })); + + const result = await caller.listMentionCandidates({ + entityType: "estimate", + entityId: "est_1", + query: "con", + }); + + expect(result).toEqual([ + { id: "user_admin", name: "Admin User", email: "admin@example.com" }, + { id: "user_controller", name: "Controller User", email: "controller@example.com" }, + ]); + expect(estimateFindUnique).toHaveBeenCalledTimes(1); + expect(userFindMany).toHaveBeenCalledWith({ + where: { + systemRole: { in: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER] }, + OR: [ + { name: { contains: "con", mode: "insensitive" } }, + { email: { contains: "con", mode: "insensitive" } }, + ], + }, + select: { + id: true, + name: true, + email: true, + }, + orderBy: [{ name: "asc" }, { email: "asc" }], + take: 20, + }); + }); + + it("forbids plain users from reading estimate mention candidates", async () => { + const estimateFindUnique = vi.fn(); + const userFindMany = vi.fn(); + const caller = createCaller(createContext({ + estimate: { + findUnique: estimateFindUnique, + }, + user: { + findMany: userFindMany, + }, + })); + + await expect(caller.listMentionCandidates({ + entityType: "estimate", + entityId: "est_1", + query: "con", + })).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Controller access required", + }); + + expect(estimateFindUnique).not.toHaveBeenCalled(); + expect(userFindMany).not.toHaveBeenCalled(); + }); + + it("allows users to list, count, and create comments on their own resource", async () => { + const resourceFindUnique = vi.fn().mockResolvedValue({ id: "res_1" }); + const resourceFindFirst = vi.fn().mockResolvedValue({ id: "res_1" }); + const commentFindMany = vi.fn().mockResolvedValue([]); + const commentCount = vi.fn().mockResolvedValue(1); + const commentCreate = vi.fn().mockResolvedValue({ + id: "comment_resource_1", + body: "Please update my profile summary.", + author: { id: "user_1", name: "Resource User", email: "user@example.com", image: null }, + }); + const caller = createCaller(createContext({ + resource: { + findUnique: resourceFindUnique, + findFirst: resourceFindFirst, + }, + comment: { + findMany: commentFindMany, + count: commentCount, + create: commentCreate, + }, + notification: { + create: vi.fn(), + }, + auditLog: { + create: vi.fn(), + }, + })); + + const listResult = await caller.list({ entityType: "resource", entityId: "res_1" }); + const countResult = await caller.count({ entityType: "resource", entityId: "res_1" }); + const createResult = await caller.create({ + entityType: "resource", + entityId: "res_1", + body: "Please update my profile summary.", + }); + + expect(listResult).toEqual([]); + expect(countResult).toBe(1); + expect(createResult.id).toBe("comment_resource_1"); + expect(resourceFindUnique).toHaveBeenCalledTimes(3); + expect(resourceFindFirst).toHaveBeenCalledTimes(3); + expect(commentCreate).toHaveBeenCalledWith({ + data: { + entityType: "resource", + entityId: "res_1", + authorId: "user_1", + body: "Please update my profile summary.", + mentions: [], + }, + include: { + author: { select: { id: true, name: true, email: true, image: true } }, + }, + }); + }); + + it("returns resource mention candidates for the own-resource audience only", async () => { + const resourceFindUnique = vi + .fn() + .mockResolvedValueOnce({ id: "res_1" }) + .mockResolvedValueOnce({ userId: "user_1" }); + const resourceFindFirst = vi.fn().mockResolvedValue({ id: "res_1" }); + const userFindMany = vi.fn().mockResolvedValue([ + { + id: "user_1", + name: "Resource User", + email: "user@example.com", + systemRole: SystemRole.USER, + permissionOverrides: null, + }, + { + id: "manager_1", + name: "Manager User", + email: "manager@example.com", + systemRole: SystemRole.MANAGER, + permissionOverrides: null, + }, + { + id: "viewer_1", + name: "Viewer User", + email: "viewer@example.com", + systemRole: SystemRole.VIEWER, + permissionOverrides: null, + }, + { + id: "user_2", + name: "Override Staff", + email: "override@example.com", + systemRole: SystemRole.USER, + permissionOverrides: { granted: ["viewAllResources"] }, + }, + ]); + const caller = createCaller(createContext({ + resource: { + findUnique: resourceFindUnique, + findFirst: resourceFindFirst, + }, + user: { + findMany: userFindMany, + }, + })); + + const result = await caller.listMentionCandidates({ + entityType: "resource", + entityId: "res_1", + }); + + expect(result).toEqual([ + { id: "user_1", name: "Resource User", email: "user@example.com" }, + { id: "manager_1", name: "Manager User", email: "manager@example.com" }, + { id: "user_2", name: "Override Staff", email: "override@example.com" }, + ]); + expect(resourceFindUnique).toHaveBeenCalledTimes(2); + expect(resourceFindFirst).toHaveBeenCalledTimes(1); + expect(userFindMany).toHaveBeenCalledWith({ + where: undefined, + select: { + id: true, + name: true, + email: true, + systemRole: true, + permissionOverrides: true, + }, + orderBy: [{ name: "asc" }, { email: "asc" }], + }); + }); + + it("forbids users from reading or creating comments on foreign resources", async () => { + const resourceFindUnique = vi.fn().mockResolvedValue({ id: "res_2" }); + const resourceFindFirst = vi.fn().mockResolvedValue({ id: "res_1" }); + const commentFindMany = vi.fn(); + const commentCount = vi.fn(); + const commentCreate = vi.fn(); + const caller = createCaller(createContext({ + resource: { + findUnique: resourceFindUnique, + findFirst: resourceFindFirst, + }, + comment: { + findMany: commentFindMany, + count: commentCount, + create: commentCreate, + }, + })); + + await expect(caller.list({ entityType: "resource", entityId: "res_2" })).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "You can only comment on your own resource unless you have staff access", + }); + await expect(caller.count({ entityType: "resource", entityId: "res_2" })).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "You can only comment on your own resource unless you have staff access", + }); + await expect(caller.create({ + entityType: "resource", + entityId: "res_2", + body: "This should not work.", + })).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "You can only comment on your own resource unless you have staff access", + }); + + expect(commentFindMany).not.toHaveBeenCalled(); + expect(commentCount).not.toHaveBeenCalled(); + expect(commentCreate).not.toHaveBeenCalled(); + }); + + it("forbids users from reading mention candidates on foreign resources", async () => { + const resourceFindUnique = vi.fn().mockResolvedValue({ id: "res_2" }); + const resourceFindFirst = vi.fn().mockResolvedValue({ id: "res_1" }); + const userFindMany = vi.fn(); + const caller = createCaller(createContext({ + resource: { + findUnique: resourceFindUnique, + findFirst: resourceFindFirst, + }, + user: { + findMany: userFindMany, + }, + })); + + await expect(caller.listMentionCandidates({ + entityType: "resource", + entityId: "res_2", + query: "staff", + })).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "You can only comment on your own resource unless you have staff access", + }); + + expect(userFindMany).not.toHaveBeenCalled(); + }); + it("rejects unsupported comment entity types before touching the database", async () => { const estimateFindUnique = vi.fn(); const commentFindMany = vi.fn(); diff --git a/packages/api/src/lib/comment-entity-registry.ts b/packages/api/src/lib/comment-entity-registry.ts new file mode 100644 index 0000000..7901c41 --- /dev/null +++ b/packages/api/src/lib/comment-entity-registry.ts @@ -0,0 +1,226 @@ +import { TRPCError } from "@trpc/server"; +import { + COMMENT_ENTITY_LABELS, + COMMENT_ENTITY_TYPE_VALUES, + PermissionKey, + resolvePermissions, + SystemRole, + type PermissionOverrides, +} from "@capakraken/shared"; +import type { CommentEntityType } from "@capakraken/shared"; +import { z } from "zod"; +import type { TRPCContext } from "../trpc.js"; +import { assertCanReadResource } from "./resource-access.js"; + +export const CommentEntityTypeSchema = z.enum(COMMENT_ENTITY_TYPE_VALUES); + +export type CommentMentionCandidate = { + id: string; + name: string | null; + email: string; +}; + +type CommentEntityPolicy = { + assertAccess: (ctx: Pick, entityId: string) => Promise; + buildLink: (entityId: string) => string; + listMentionCandidates: ( + ctx: Pick, + entityId: string, + query?: string, + ) => Promise; +}; + +const CONTROLLER_COMMENT_ROLES = new Set([ + SystemRole.ADMIN, + SystemRole.MANAGER, + SystemRole.CONTROLLER, +]); +const RESOURCE_STAFF_PERMISSION_KEYS = new Set([ + PermissionKey.VIEW_ALL_RESOURCES, + PermissionKey.MANAGE_RESOURCES, +]); +const MENTION_CANDIDATE_LIMIT = 20; + +function buildMentionSearchWhere(query?: string) { + if (!query) { + return undefined; + } + + return { + OR: [ + { name: { contains: query, mode: "insensitive" as const } }, + { email: { contains: query, mode: "insensitive" as const } }, + ], + }; +} + +async function listEstimateMentionCandidates( + ctx: Pick, + _entityId: string, + query?: string, +): Promise { + return ctx.db.user.findMany({ + where: { + systemRole: { in: Array.from(CONTROLLER_COMMENT_ROLES) }, + ...buildMentionSearchWhere(query), + }, + select: { + id: true, + name: true, + email: true, + }, + orderBy: [{ name: "asc" }, { email: "asc" }], + take: MENTION_CANDIDATE_LIMIT, + }); +} + +async function listResourceMentionCandidates( + ctx: Pick, + entityId: string, + query?: string, +): Promise { + const resource = await ctx.db.resource.findUnique({ + where: { id: entityId }, + select: { userId: true }, + }); + if (!resource) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Resource not found", + }); + } + + const mentionWhere = buildMentionSearchWhere(query); + const users = await ctx.db.user.findMany({ + ...(mentionWhere ? { where: mentionWhere } : {}), + select: { + id: true, + name: true, + email: true, + systemRole: true, + permissionOverrides: true, + }, + orderBy: [{ name: "asc" }, { email: "asc" }], + }); + + return users + .filter((user) => { + if (user.id === resource.userId) { + return true; + } + + const permissions = resolvePermissions( + user.systemRole as SystemRole, + user.permissionOverrides as PermissionOverrides | null, + ctx.roleDefaults ?? undefined, + ); + + return Array.from(RESOURCE_STAFF_PERMISSION_KEYS).some((permission) => + permissions.has(permission), + ); + }) + .slice(0, MENTION_CANDIDATE_LIMIT) + .map(({ id, name, email }) => ({ id, name, email })); +} + +async function assertEstimateCommentAccess( + ctx: Pick, + entityId: string, +) { + const role = ctx.dbUser?.systemRole as SystemRole | undefined; + if (!role || !CONTROLLER_COMMENT_ROLES.has(role)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Controller access required", + }); + } + + const estimate = await ctx.db.estimate.findUnique({ + where: { id: entityId }, + select: { id: true }, + }); + if (!estimate) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Estimate not found", + }); + } +} + +async function assertResourceCommentAccess( + ctx: Pick, + entityId: string, +) { + const resource = await ctx.db.resource.findUnique({ + where: { id: entityId }, + select: { id: true }, + }); + if (!resource) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Resource not found", + }); + } + + await assertCanReadResource( + ctx, + entityId, + "You can only comment on your own resource unless you have staff access", + ); +} + +const COMMENT_ENTITY_POLICIES: Record = { + estimate: { + assertAccess: assertEstimateCommentAccess, + buildLink: (entityId) => `/estimates/${entityId}?tab=comments`, + listMentionCandidates: listEstimateMentionCandidates, + }, + resource: { + assertAccess: assertResourceCommentAccess, + buildLink: (entityId) => `/resources/${entityId}#comments`, + listMentionCandidates: listResourceMentionCandidates, + }, +}; + +export function getSupportedCommentEntityTypes(): CommentEntityType[] { + return [...COMMENT_ENTITY_TYPE_VALUES]; +} + +export function getSupportedCommentEntityLabelList(): string { + return getSupportedCommentEntityTypes() + .map((entityType) => COMMENT_ENTITY_LABELS[entityType]) + .join(", "); +} + +export function getCommentToolEntityDescription(): string { + return `Supported entity type. One of: ${getSupportedCommentEntityLabelList()}.`; +} + +export function getCommentToolScopeSentence(): string { + return `Supported comment entities: ${getSupportedCommentEntityLabelList()}. Estimate comments require controller/manager/admin visibility; resource comments follow resource detail visibility.`; +} + +export function isSupportedCommentEntityType(value: string): value is CommentEntityType { + return (COMMENT_ENTITY_TYPE_VALUES as readonly string[]).includes(value); +} + +export function getCommentEntityPolicy(entityType: string): CommentEntityPolicy { + if (!isSupportedCommentEntityType(entityType)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Comments are not supported for entity type: ${entityType}`, + }); + } + + return COMMENT_ENTITY_POLICIES[entityType]; +} + +export async function assertCommentEntityAccess( + ctx: Pick, + entityType: string, + entityId: string, +) { + const policy = getCommentEntityPolicy(entityType); + await policy.assertAccess(ctx, entityId); + return policy; +} diff --git a/packages/api/src/router/comment.ts b/packages/api/src/router/comment.ts index 7ff5aa3..6d36e7b 100644 --- a/packages/api/src/router/comment.ts +++ b/packages/api/src/router/comment.ts @@ -1,9 +1,9 @@ import { z } from "zod"; import { TRPCError } from "@trpc/server"; -import { SystemRole } from "@capakraken/shared"; -import { createTRPCRouter, protectedProcedure, type TRPCContext } from "../trpc.js"; +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 ────────────────────────────────────────────────────────────────── @@ -22,74 +22,6 @@ function parseMentions(body: string): string[] { return Array.from(ids); } -const COMMENT_ENTITY_TYPE_VALUES = ["estimate"] as const; -const CommentEntityTypeSchema = z.enum(COMMENT_ENTITY_TYPE_VALUES); -type CommentEntityType = z.infer; - -type CommentEntityPolicy = { - assertAccess: (ctx: Pick, entityId: string) => Promise; - buildLink: (entityId: string) => string; -}; - -const CONTROLLER_COMMENT_ROLES = new Set([ - SystemRole.ADMIN, - SystemRole.MANAGER, - SystemRole.CONTROLLER, -]); - -async function assertEstimateCommentAccess(ctx: Pick, entityId: string) { - const role = ctx.dbUser?.systemRole as SystemRole | undefined; - if (!role || !CONTROLLER_COMMENT_ROLES.has(role)) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "Controller access required", - }); - } - - const estimate = await ctx.db.estimate.findUnique({ - where: { id: entityId }, - select: { id: true }, - }); - if (!estimate) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Estimate not found", - }); - } -} - -const COMMENT_ENTITY_POLICIES: Record = { - estimate: { - assertAccess: assertEstimateCommentAccess, - buildLink: (entityId) => `/estimates/${entityId}?tab=comments`, - }, -}; - -function isSupportedCommentEntityType(value: string): value is CommentEntityType { - return (COMMENT_ENTITY_TYPE_VALUES as readonly string[]).includes(value); -} - -function getCommentEntityPolicy(entityType: string): CommentEntityPolicy { - if (!isSupportedCommentEntityType(entityType)) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Comments are not supported for entity type: ${entityType}`, - }); - } - - return COMMENT_ENTITY_POLICIES[entityType]; -} - -async function assertCommentEntityAccess( - ctx: Pick, - entityType: string, - entityId: string, -) { - const policy = getCommentEntityPolicy(entityType); - await policy.assertAccess(ctx, entityId); - return policy; -} - // ─── Router ─────────────────────────────────────────────────────────────────── export const commentRouter = createTRPCRouter({ @@ -123,6 +55,21 @@ export const commentRouter = createTRPCRouter({ }); }), + /** 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( @@ -253,7 +200,7 @@ export const commentRouter = createTRPCRouter({ await assertCommentEntityAccess(ctx, existing.entityType, existing.entityId); // Only the author or an admin can resolve - const isAdmin = dbUser?.systemRole === SystemRole.ADMIN; + const isAdmin = dbUser?.systemRole === "ADMIN"; if (existing.authorId !== userId && !isAdmin) { throw new TRPCError({ code: "FORBIDDEN", @@ -302,7 +249,7 @@ export const commentRouter = createTRPCRouter({ await assertCommentEntityAccess(ctx, existing.entityType, existing.entityId); - const isAdmin = dbUser?.systemRole === SystemRole.ADMIN; + const isAdmin = dbUser?.systemRole === "ADMIN"; if (existing.authorId !== userId && !isAdmin) { throw new TRPCError({ code: "FORBIDDEN",