fix(comment): align mention audience with entity visibility
This commit is contained in:
@@ -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<HTMLTextAreaElement>(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<HTMLTextAreaElement>({
|
||||
open: mentionOpen,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<TRPCContext, "db" | "dbUser" | "roleDefaults">, entityId: string) => Promise<void>;
|
||||
buildLink: (entityId: string) => string;
|
||||
listMentionCandidates: (
|
||||
ctx: Pick<TRPCContext, "db" | "roleDefaults">,
|
||||
entityId: string,
|
||||
query?: string,
|
||||
) => Promise<CommentMentionCandidate[]>;
|
||||
};
|
||||
|
||||
const CONTROLLER_COMMENT_ROLES = new Set<SystemRole>([
|
||||
SystemRole.ADMIN,
|
||||
SystemRole.MANAGER,
|
||||
SystemRole.CONTROLLER,
|
||||
]);
|
||||
const RESOURCE_STAFF_PERMISSION_KEYS = new Set<PermissionKey>([
|
||||
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<TRPCContext, "db">,
|
||||
_entityId: string,
|
||||
query?: string,
|
||||
): Promise<CommentMentionCandidate[]> {
|
||||
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<TRPCContext, "db" | "roleDefaults">,
|
||||
entityId: string,
|
||||
query?: string,
|
||||
): Promise<CommentMentionCandidate[]> {
|
||||
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<TRPCContext, "db" | "dbUser" | "roleDefaults">,
|
||||
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<TRPCContext, "db" | "dbUser" | "roleDefaults">,
|
||||
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<CommentEntityType, CommentEntityPolicy> = {
|
||||
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<TRPCContext, "db" | "dbUser" | "roleDefaults">,
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
) {
|
||||
const policy = getCommentEntityPolicy(entityType);
|
||||
await policy.assertAccess(ctx, entityId);
|
||||
return policy;
|
||||
}
|
||||
@@ -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<typeof CommentEntityTypeSchema>;
|
||||
|
||||
type CommentEntityPolicy = {
|
||||
assertAccess: (ctx: Pick<TRPCContext, "db" | "dbUser">, entityId: string) => Promise<void>;
|
||||
buildLink: (entityId: string) => string;
|
||||
};
|
||||
|
||||
const CONTROLLER_COMMENT_ROLES = new Set<SystemRole>([
|
||||
SystemRole.ADMIN,
|
||||
SystemRole.MANAGER,
|
||||
SystemRole.CONTROLLER,
|
||||
]);
|
||||
|
||||
async function assertEstimateCommentAccess(ctx: Pick<TRPCContext, "db" | "dbUser">, 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<CommentEntityType, CommentEntityPolicy> = {
|
||||
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<TRPCContext, "db" | "dbUser">,
|
||||
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",
|
||||
|
||||
Reference in New Issue
Block a user