diff --git a/apps/web/src/components/comments/CommentThread.tsx b/apps/web/src/components/comments/CommentThread.tsx
index ff2fe5f..71037a1 100644
--- a/apps/web/src/components/comments/CommentThread.tsx
+++ b/apps/web/src/components/comments/CommentThread.tsx
@@ -31,10 +31,7 @@ interface CommentItem {
replies: CommentReply[];
}
-type CommentEntityType = "estimate";
-
interface CommentThreadProps {
- entityType: CommentEntityType;
entityId: string;
}
@@ -113,37 +110,36 @@ function CommentBody({ body }: { body: string }) {
function SingleComment({
comment,
- entityType,
entityId,
isReply = false,
}: {
comment: CommentItem | CommentReply;
- entityType: CommentEntityType;
entityId: string;
isReply?: boolean;
}) {
const [showReplyInput, setShowReplyInput] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const utils = trpc.useUtils();
+ const commentTarget = { entityType: "estimate" as const, entityId };
const createMutation = trpc.comment.create.useMutation({
onSuccess: () => {
setShowReplyInput(false);
- void utils.comment.list.invalidate({ entityType, entityId });
- void utils.comment.count.invalidate({ entityType, entityId });
+ void utils.comment.list.invalidate(commentTarget);
+ void utils.comment.count.invalidate(commentTarget);
},
});
const resolveMutation = trpc.comment.resolve.useMutation({
onSuccess: () => {
- void utils.comment.list.invalidate({ entityType, entityId });
+ void utils.comment.list.invalidate(commentTarget);
},
});
const deleteMutation = trpc.comment.delete.useMutation({
onSuccess: () => {
- void utils.comment.list.invalidate({ entityType, entityId });
- void utils.comment.count.invalidate({ entityType, entityId });
+ void utils.comment.list.invalidate(commentTarget);
+ void utils.comment.count.invalidate(commentTarget);
},
});
@@ -217,12 +213,12 @@ function SingleComment({
{showReplyInput && (
{
createMutation.mutate({
- entityType,
+ entityType: commentTarget.entityType,
entityId,
parentId: comment.id,
body: replyBody,
@@ -259,7 +255,6 @@ function SingleComment({
@@ -270,18 +265,19 @@ function SingleComment({
);
}
-export function CommentThread({ entityType, entityId }: CommentThreadProps) {
+export function CommentThread({ entityId }: CommentThreadProps) {
const utils = trpc.useUtils();
+ const commentTarget = { entityType: "estimate" as const, entityId };
const commentsQuery = trpc.comment.list.useQuery(
- { entityType, entityId },
+ commentTarget,
{ staleTime: 10_000 },
);
const createMutation = trpc.comment.create.useMutation({
onSuccess: () => {
- void utils.comment.list.invalidate({ entityType, entityId });
- void utils.comment.count.invalidate({ entityType, entityId });
+ void utils.comment.list.invalidate(commentTarget);
+ void utils.comment.count.invalidate(commentTarget);
},
});
@@ -312,7 +308,6 @@ export function CommentThread({ entityType, entityId }: CommentThreadProps) {
))}
@@ -322,11 +317,11 @@ export function CommentThread({ entityType, entityId }: CommentThreadProps) {
{/* New comment input */}
{
createMutation.mutate({
- entityType,
+ entityType: commentTarget.entityType,
entityId,
body,
});
diff --git a/apps/web/src/components/estimates/EstimateWorkspaceClient.tsx b/apps/web/src/components/estimates/EstimateWorkspaceClient.tsx
index 98f1a81..7f4672d 100644
--- a/apps/web/src/components/estimates/EstimateWorkspaceClient.tsx
+++ b/apps/web/src/components/estimates/EstimateWorkspaceClient.tsx
@@ -135,7 +135,7 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
const commentCountQuery = trpc.comment.count.useQuery(
{ entityType: "estimate", entityId: estimateId },
- { staleTime: 30_000 },
+ { enabled: canViewCosts, staleTime: 30_000 },
);
const commentCount = commentCountQuery.data ?? 0;
@@ -364,7 +364,7 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
Comments
-
+
)}
>
diff --git a/apps/web/src/components/timeline/BatchAssignPopover.tsx b/apps/web/src/components/timeline/BatchAssignPopover.tsx
index 5dd8f84..956767f 100644
--- a/apps/web/src/components/timeline/BatchAssignPopover.tsx
+++ b/apps/web/src/components/timeline/BatchAssignPopover.tsx
@@ -6,6 +6,7 @@ import { createPortal } from "react-dom";
import { AllocationStatus } from "@capakraken/shared";
import { trpc } from "~/lib/trpc/client.js";
import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
+import { ProjectCombobox } from "~/components/ui/ProjectCombobox.js";
interface BatchAssignPopoverProps {
resourceIds: string[];
@@ -33,23 +34,10 @@ export function BatchAssignPopover({
const ref = useRef(null);
const invalidateTimeline = useInvalidateTimeline();
- const [search, setSearch] = useState("");
const [selectedProjectId, setSelectedProjectId] = useState(
null,
);
const [hoursPerDay, setHoursPerDay] = useState(8);
- const [dropdownOpen, setDropdownOpen] = useState(true);
-
- const { data: projectsData } = trpc.project.list.useQuery(
- { search, limit: 20 },
- { staleTime: 30_000 },
- );
-
- const projects = (projectsData?.projects ?? []) as Array<{
- id: string;
- name: string;
- }>;
- const selectedProject = projects.find((p) => p.id === selectedProjectId);
const batchMutation = trpc.timeline.batchQuickAssign.useMutation({
onSuccess: () => {
@@ -136,54 +124,12 @@ export function BatchAssignPopover({
- {selectedProject && !dropdownOpen ? (
- {
- setDropdownOpen(true);
- setSearch("");
- }}
- >
-
- {selectedProject.name}
-
-
- ▾
-
-
- ) : (
-
-
setSearch(e.target.value)}
- onFocus={() => setDropdownOpen(true)}
- className="w-full border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-sky-400 dark:focus:ring-sky-500"
- />
- {dropdownOpen && projects.length > 0 && (
-
- {projects.map((p) => (
-
- ))}
-
- )}
-
- )}
+
{/* Hours per day */}
diff --git a/apps/web/src/components/timeline/NewAllocationPopover.tsx b/apps/web/src/components/timeline/NewAllocationPopover.tsx
index 1e1f27c..26cd253 100644
--- a/apps/web/src/components/timeline/NewAllocationPopover.tsx
+++ b/apps/web/src/components/timeline/NewAllocationPopover.tsx
@@ -1,13 +1,14 @@
"use client";
import { clsx } from "clsx";
-import { useEffect, useRef, useState } from "react";
+import { useState } from "react";
import { createPortal } from "react-dom";
import { AllocationStatus } from "@capakraken/shared";
import { trpc } from "~/lib/trpc/client.js";
import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
import { useViewportPopover } from "~/hooks/useViewportPopover.js";
import { DateInput } from "~/components/ui/DateInput.js";
+import { ProjectCombobox } from "~/components/ui/ProjectCombobox.js";
interface NewAllocationPopoverProps {
resourceId: string;
@@ -46,7 +47,6 @@ export function NewAllocationPopover({
});
const invalidateTimeline = useInvalidateTimeline();
- const [search, setSearch] = useState("");
const [selectedProjectId, setSelectedProjectId] = useState(
suggestedProjectId ?? null,
);
@@ -54,17 +54,6 @@ export function NewAllocationPopover({
const [hoursPerDay, setHoursPerDay] = useState(8);
const [start, setStart] = useState(toDateInput(startDate));
const [end, setEnd] = useState(toDateInput(endDate));
- const [dropdownOpen, setDropdownOpen] = useState(!suggestedProjectId);
-
- const { data: projectsData } = trpc.project.list.useQuery(
- { search, limit: 20 },
- { staleTime: 30_000 },
- );
-
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const projects = (projectsData?.projects ?? []) as Array<{ id: string; name: string; orderType?: string }>;
- const selectedProject = projects.find((p) => p.id === selectedProjectId)
- ?? (suggestedProjectId ? projects.find((p) => p.id === suggestedProjectId) : null);
const createMutation = trpc.timeline.quickAssign.useMutation({
onSuccess: () => {
@@ -126,41 +115,12 @@ export function NewAllocationPopover({
{/* Project picker */}
- {selectedProject && !dropdownOpen ? (
-
{ setDropdownOpen(true); setSearch(""); }}
- >
- {selectedProject.name}
- ▾
-
- ) : (
-
-
setSearch(e.target.value)}
- onFocus={() => setDropdownOpen(true)}
- className="w-full border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
- />
- {dropdownOpen && projects.length > 0 && (
-
- {projects.map((p) => (
-
- ))}
-
- )}
-
- )}
+
{/* Role */}
diff --git a/docs/audience-scoping-backlog.md b/docs/audience-scoping-backlog.md
index c6387a1..188fd7d 100644
--- a/docs/audience-scoping-backlog.md
+++ b/docs/audience-scoping-backlog.md
@@ -19,6 +19,7 @@
- `project.isImageGenConfigured`, `project.isDalleConfigured`: covered as authenticated low-risk configuration checks
- `notification` self-service and manager boundaries: auth-covered across list, unread counts, reminders, deletes, broadcasts, task creation, and assignment boundaries
- `assistant-tools` parity metadata: descriptions and parity assertions now match narrowed router audiences for resource overview, controller-only, self-service, and manager broadcast/task tools
+- `comment` architecture phase 1: generic free-form entity comments replaced by an explicit supported-entity registry, currently limited to `estimate` with controller/manager/admin access plus entity-aware checks on list/count/create/resolve/delete
### Dirty Files To Avoid Mixing Into This Batch
@@ -45,27 +46,9 @@ These files already have unrelated local edits. Audience parity work that would
- the previously identified small hardening and tests/docs candidates have been completed, including the notification auth follow-up and assistant tool parity metadata cleanup
- the remaining audience work is now architectural (`comment.ts`) or depends on broader policy decisions rather than another ready-made auth slice
-### Needs Architecture Or Policy Design
-
-These routes should not be batch-edited as “small safe slices” until a visibility model exists.
-
-#### `packages/api/src/router/comment.ts`
-
-- Current state: all core routes are `protectedProcedure`
-- Why this is not a small slice:
- - reads and writes are keyed by generic `entityType` and `entityId`
- - visibility depends on the backing entity, not on the comment record alone
- - the current author/admin checks for resolve/delete are not enough to decide who may list or create comments on each entity class
-- Design work needed:
- - define which entity types can host comments
- - map each entity type to its audience source of truth
- - centralize entity visibility checks before any comment read/write
-- Recommended path:
- - treat comments as a separate architecture ticket, not part of the quick hardening batch
-
## Recommended Next Order
-1. `comment` architecture design ticket
+1. extend the comment entity registry only when a second real consumer exists and its backing audience is explicitly documented
## Slice Definition
diff --git a/docs/comment-visibility-architecture.md b/docs/comment-visibility-architecture.md
new file mode 100644
index 0000000..5ea99ae
--- /dev/null
+++ b/docs/comment-visibility-architecture.md
@@ -0,0 +1,74 @@
+# Comment Visibility Architecture
+
+**Date:** 2026-03-30
+**Status:** Phase 1 implemented
+
+## Problem
+
+The original comment router accepted arbitrary `entityType` and `entityId` pairs behind `protectedProcedure`.
+That was too broad:
+
+- comment visibility depends on the backing entity, not on the comment record alone
+- generic strings allowed clients and assistant tools to imply support for entity types that had no explicit policy
+- author/admin checks on `resolve` and `delete` were not enough, because list/create access was still effectively "any authenticated user"
+
+## Current Product Reality
+
+There is only one real first-party consumer today:
+
+- web UI estimate workspace comments via `entityType: "estimate"`
+
+The older examples for `scope_item`, `estimate_version`, and `demand_line` were aspirational, not backed by an explicit visibility model or active UI.
+
+## Architecture Decision
+
+Comments now use an explicit entity registry.
+
+- supported entity types are allowlisted, not free-form
+- each entity type owns:
+ - its audience rule
+ - its existence check
+ - its deep link builder for notifications
+- every comment route calls the entity access layer before touching comment data
+
+## Phase 1 Policy
+
+Supported entity types:
+
+- `estimate`
+
+Audience:
+
+- same audience as the estimate workspace
+- controller, manager, or admin only
+
+Route effects:
+
+- `list`, `count`, `create`, `resolve`, and `delete` all require estimate visibility first
+- `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
+
+## Why This Shape
+
+- It closes the real security gap now without pretending a generic multi-entity policy already exists.
+- 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.
+
+## Extension Rules For Future Entity Types
+
+To add another commentable entity:
+
+1. Add the entity type to the registry, not just to input examples.
+2. Define the backing audience source of truth.
+3. Add an existence check for that entity.
+4. Add a notification link builder for that entity.
+5. Update assistant tool metadata and assistant visibility gates in the same change.
+6. Add router auth tests for unauthenticated, plain authenticated, and elevated callers.
+7. Update `docs/route-access-matrix.md`.
+
+## Non-Goals In Phase 1
+
+- generic comment support for arbitrary entities
+- row-level polymorphic authorization based only on `entityType` strings
+- automatic inheritance for future entities without explicit onboarding
diff --git a/docs/route-access-matrix.md b/docs/route-access-matrix.md
index f17e886..718d8f4 100644
--- a/docs/route-access-matrix.md
+++ b/docs/route-access-matrix.md
@@ -73,6 +73,17 @@ Reasoning:
- `list`: `controller-finance`
- drafting, versioning, export generation, and approval writes: `manager-write`
+### `packages/api/src/router/comment.ts`
+
+- `list`, `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
+- 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`
- all reads and writes: `admin-only`
diff --git a/packages/api/src/__tests__/assistant-router.test.ts b/packages/api/src/__tests__/assistant-router.test.ts
index 1e7138c..32f6613 100644
--- a/packages/api/src/__tests__/assistant-router.test.ts
+++ b/packages/api/src/__tests__/assistant-router.test.ts
@@ -361,6 +361,9 @@ describe("assistant router tool gating", () => {
expect(controllerNames).toContain("query_change_history");
expect(controllerNames).toContain("get_entity_timeline");
expect(controllerNames).toContain("search_by_skill");
+ expect(controllerNames).toContain("list_comments");
+ expect(controllerNames).toContain("create_comment");
+ expect(controllerNames).toContain("resolve_comment");
expect(controllerNames).toContain("export_resources_csv");
expect(controllerNames).toContain("export_projects_csv");
expect(controllerNames).toContain("list_audit_log_entries");
@@ -373,6 +376,9 @@ describe("assistant router tool gating", () => {
expect(userNames).not.toContain("query_change_history");
expect(userNames).not.toContain("get_entity_timeline");
expect(userNames).not.toContain("search_by_skill");
+ expect(userNames).not.toContain("list_comments");
+ expect(userNames).not.toContain("create_comment");
+ expect(userNames).not.toContain("resolve_comment");
expect(userNames).not.toContain("export_resources_csv");
expect(userNames).not.toContain("export_projects_csv");
expect(userNames).not.toContain("list_audit_log_entries");
@@ -957,6 +963,11 @@ describe("assistant router tool gating", () => {
expect(toolDescriptions.get("get_current_user")).toContain("authenticated user's own profile");
expect(toolDescriptions.get("search_resources")).toContain("Resource overview access required");
expect(toolDescriptions.get("search_by_skill")).toContain("Controller/manager/admin access required");
+ expect(toolDescriptions.get("list_comments")).toContain("Currently only estimate comments are enabled");
+ expect(toolDescriptions.get("list_comments")).toContain("Controller/manager/admin access required");
+ expect(toolDescriptions.get("create_comment")).toContain("Currently only estimate comments are enabled");
+ expect(toolDescriptions.get("create_comment")).toContain("Controller/manager/admin access required");
+ expect(toolDescriptions.get("resolve_comment")).toContain("Currently only estimate comments are enabled");
expect(toolDescriptions.get("list_notifications")).toContain("current user");
expect(toolDescriptions.get("get_unread_notification_count")).toContain("current user");
expect(toolDescriptions.get("list_tasks")).toContain("current user");
diff --git a/packages/api/src/__tests__/assistant-tools-import-export.test.ts b/packages/api/src/__tests__/assistant-tools-import-export.test.ts
index 20bb7dd..32a68f2 100644
--- a/packages/api/src/__tests__/assistant-tools-import-export.test.ts
+++ b/packages/api/src/__tests__/assistant-tools-import-export.test.ts
@@ -4845,10 +4845,12 @@ describe("assistant import/export and dispo tools", () => {
const commentFindUnique = vi.fn().mockResolvedValue({
id: "comment_1",
authorId: "user_1",
+ entityType: "estimate",
+ entityId: "est_1",
});
const db = {
- user: {
- findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
+ estimate: {
+ findUnique: vi.fn().mockResolvedValue({ id: "est_1" }),
},
comment: {
findMany: vi.fn().mockResolvedValue([
@@ -4931,8 +4933,17 @@ describe("assistant import/export and dispo tools", () => {
},
orderBy: { createdAt: "asc" },
});
- expect(db.user.findUnique).toHaveBeenCalledWith({
- where: { email: "assistant@example.com" },
+ expect(db.estimate.findUnique).toHaveBeenCalledTimes(3);
+ expect(db.estimate.findUnique).toHaveBeenNthCalledWith(1, {
+ where: { id: "est_1" },
+ select: { id: true },
+ });
+ expect(db.estimate.findUnique).toHaveBeenNthCalledWith(2, {
+ where: { id: "est_1" },
+ select: { id: true },
+ });
+ expect(db.estimate.findUnique).toHaveBeenNthCalledWith(3, {
+ where: { id: "est_1" },
select: { id: true },
});
expect(db.comment.create).toHaveBeenCalledWith({
@@ -4949,7 +4960,7 @@ describe("assistant import/export and dispo tools", () => {
});
expect(commentFindUnique).toHaveBeenCalledWith({
where: { id: "comment_1" },
- select: { id: true, authorId: true },
+ select: { id: true, authorId: true, entityType: true, entityId: true },
});
expect(db.comment.update).toHaveBeenCalledWith({
where: { id: "comment_1" },
@@ -5003,7 +5014,7 @@ describe("assistant import/export and dispo tools", () => {
it("returns a stable assistant error when creating a comment with an empty body", async () => {
const ctx = createToolContext(
{
- user: {
+ estimate: {
findUnique: vi.fn(),
},
comment: {
@@ -5026,14 +5037,13 @@ describe("assistant import/export and dispo tools", () => {
expect(JSON.parse(result.content)).toEqual({
error: "Comment body is required.",
});
- expect(ctx.db.user.findUnique).not.toHaveBeenCalled();
expect(ctx.db.comment.create).not.toHaveBeenCalled();
});
it("returns a stable assistant error when creating a comment with a body that is too long", async () => {
const ctx = createToolContext(
{
- user: {
+ estimate: {
findUnique: vi.fn(),
},
comment: {
@@ -5056,15 +5066,14 @@ describe("assistant import/export and dispo tools", () => {
expect(JSON.parse(result.content)).toEqual({
error: "Comment body must be at most 10000 characters.",
});
- expect(ctx.db.user.findUnique).not.toHaveBeenCalled();
expect(ctx.db.comment.create).not.toHaveBeenCalled();
});
it("returns a stable assistant error when the comment author disappears during creation", async () => {
const ctx = createToolContext(
{
- user: {
- findUnique: vi.fn().mockResolvedValue({ id: "user_1" }),
+ estimate: {
+ findUnique: vi.fn().mockResolvedValue({ id: "est_1" }),
},
comment: {
create: vi.fn().mockRejectedValue({
@@ -5095,8 +5104,8 @@ describe("assistant import/export and dispo tools", () => {
it("returns a stable assistant error when a mentioned user disappears during comment creation", async () => {
const ctx = createToolContext(
{
- user: {
- findUnique: vi.fn().mockResolvedValue({ id: "user_1" }),
+ estimate: {
+ findUnique: vi.fn().mockResolvedValue({ id: "est_1" }),
},
comment: {
create: vi.fn().mockResolvedValue({
@@ -5170,8 +5179,13 @@ describe("assistant import/export and dispo tools", () => {
findUnique: vi.fn().mockResolvedValue({
id: "comment_1",
authorId: "user_2",
+ entityType: "estimate",
+ entityId: "est_1",
}),
},
+ estimate: {
+ findUnique: vi.fn().mockResolvedValue({ id: "est_1" }),
+ },
},
{ userRole: SystemRole.CONTROLLER },
);
diff --git a/packages/api/src/__tests__/comment-router-auth.test.ts b/packages/api/src/__tests__/comment-router-auth.test.ts
new file mode 100644
index 0000000..bdd08e6
--- /dev/null
+++ b/packages/api/src/__tests__/comment-router-auth.test.ts
@@ -0,0 +1,261 @@
+import { SystemRole } from "@capakraken/shared";
+import { describe, expect, it, vi } from "vitest";
+import { commentRouter } from "../router/comment.js";
+import { createCallerFactory } from "../trpc.js";
+
+const createCaller = createCallerFactory(commentRouter);
+
+function createContext(
+ db: Record,
+ options: {
+ role?: SystemRole;
+ session?: boolean;
+ } = {},
+) {
+ const { role = SystemRole.USER, session = true } = options;
+
+ return {
+ session: session
+ ? {
+ user: { email: "user@example.com", name: "User", image: null },
+ expires: "2099-01-01T00:00:00.000Z",
+ }
+ : null,
+ db: db as never,
+ dbUser: session
+ ? {
+ id: role === SystemRole.ADMIN ? "user_admin" : "user_1",
+ systemRole: role,
+ permissionOverrides: null,
+ }
+ : null,
+ };
+}
+
+describe("comment router authorization", () => {
+ it("requires authentication before listing estimate comments", async () => {
+ const estimateFindUnique = vi.fn();
+ const commentFindMany = vi.fn();
+ const caller = createCaller(createContext({
+ estimate: {
+ findUnique: estimateFindUnique,
+ },
+ comment: {
+ findMany: commentFindMany,
+ },
+ }, { session: false }));
+
+ await expect(caller.list({ entityType: "estimate", entityId: "est_1" })).rejects.toMatchObject({
+ code: "UNAUTHORIZED",
+ message: "Authentication required",
+ });
+
+ expect(estimateFindUnique).not.toHaveBeenCalled();
+ expect(commentFindMany).not.toHaveBeenCalled();
+ });
+
+ it("forbids plain users from reading or creating estimate comments", async () => {
+ const estimateFindUnique = vi.fn();
+ const commentFindMany = vi.fn();
+ const commentCount = vi.fn();
+ const commentCreate = vi.fn();
+ const caller = createCaller(createContext({
+ estimate: {
+ findUnique: estimateFindUnique,
+ },
+ comment: {
+ findMany: commentFindMany,
+ count: commentCount,
+ create: commentCreate,
+ },
+ }));
+
+ await expect(caller.list({ entityType: "estimate", entityId: "est_1" })).rejects.toMatchObject({
+ code: "FORBIDDEN",
+ message: "Controller access required",
+ });
+ await expect(caller.count({ entityType: "estimate", entityId: "est_1" })).rejects.toMatchObject({
+ code: "FORBIDDEN",
+ message: "Controller access required",
+ });
+ await expect(caller.create({
+ entityType: "estimate",
+ entityId: "est_1",
+ body: "Please review this estimate.",
+ })).rejects.toMatchObject({
+ code: "FORBIDDEN",
+ message: "Controller access required",
+ });
+
+ expect(estimateFindUnique).not.toHaveBeenCalled();
+ expect(commentFindMany).not.toHaveBeenCalled();
+ expect(commentCount).not.toHaveBeenCalled();
+ expect(commentCreate).not.toHaveBeenCalled();
+ });
+
+ it("allows controllers to list, count, and create estimate comments", async () => {
+ const estimateFindUnique = vi.fn().mockResolvedValue({ id: "est_1" });
+ const commentFindMany = vi.fn().mockResolvedValue([]);
+ const commentCount = vi.fn().mockResolvedValue(2);
+ const commentCreate = vi.fn().mockResolvedValue({
+ id: "comment_1",
+ body: "Please review this estimate.",
+ author: { id: "user_1", name: "Controller User", email: "user@example.com", image: null },
+ });
+ const caller = createCaller(createContext({
+ estimate: {
+ findUnique: estimateFindUnique,
+ },
+ comment: {
+ findMany: commentFindMany,
+ count: commentCount,
+ create: commentCreate,
+ },
+ notification: {
+ create: vi.fn(),
+ },
+ auditLog: {
+ create: vi.fn(),
+ },
+ }, { role: SystemRole.CONTROLLER }));
+
+ const listResult = await caller.list({ entityType: "estimate", entityId: "est_1" });
+ const countResult = await caller.count({ entityType: "estimate", entityId: "est_1" });
+ const createResult = await caller.create({
+ entityType: "estimate",
+ entityId: "est_1",
+ body: "Please review this estimate.",
+ });
+
+ expect(listResult).toEqual([]);
+ expect(countResult).toBe(2);
+ expect(createResult.id).toBe("comment_1");
+ expect(estimateFindUnique).toHaveBeenCalledTimes(3);
+ expect(commentCreate).toHaveBeenCalledWith({
+ data: {
+ entityType: "estimate",
+ entityId: "est_1",
+ authorId: "user_1",
+ body: "Please review this estimate.",
+ mentions: [],
+ },
+ include: {
+ author: { select: { id: true, name: true, email: true, image: true } },
+ },
+ });
+ });
+
+ it("rejects unsupported comment entity types before touching the database", async () => {
+ const estimateFindUnique = vi.fn();
+ const commentFindMany = vi.fn();
+ const caller = createCaller(createContext({
+ estimate: {
+ findUnique: estimateFindUnique,
+ },
+ comment: {
+ findMany: commentFindMany,
+ },
+ }, { role: SystemRole.CONTROLLER }));
+
+ await expect(caller.list({
+ entityType: "scope_item" as never,
+ entityId: "scope_1",
+ })).rejects.toMatchObject({
+ code: "BAD_REQUEST",
+ });
+
+ expect(estimateFindUnique).not.toHaveBeenCalled();
+ expect(commentFindMany).not.toHaveBeenCalled();
+ });
+
+ it("rejects replies whose parent comment belongs to another entity", async () => {
+ const estimateFindUnique = vi.fn().mockResolvedValue({ id: "est_1" });
+ const parentFindUnique = vi.fn().mockResolvedValue({
+ id: "comment_parent",
+ entityType: "estimate",
+ entityId: "est_2",
+ });
+ const commentCreate = vi.fn();
+ const caller = createCaller(createContext({
+ estimate: {
+ findUnique: estimateFindUnique,
+ },
+ comment: {
+ findUnique: parentFindUnique,
+ create: commentCreate,
+ },
+ }, { role: SystemRole.CONTROLLER }));
+
+ await expect(caller.create({
+ entityType: "estimate",
+ entityId: "est_1",
+ parentId: "comment_parent",
+ body: "Replying on the right estimate.",
+ })).rejects.toMatchObject({
+ code: "BAD_REQUEST",
+ message: "Parent comment does not belong to the requested entity",
+ });
+
+ expect(commentCreate).not.toHaveBeenCalled();
+ });
+
+ it("requires comment authorship or admin rights after entity visibility is granted", async () => {
+ const estimateFindUnique = vi.fn().mockResolvedValue({ id: "est_1" });
+ const commentFindUnique = vi.fn().mockResolvedValue({
+ id: "comment_1",
+ authorId: "user_2",
+ entityType: "estimate",
+ entityId: "est_1",
+ });
+ const commentUpdate = vi.fn();
+ const controllerCaller = createCaller(createContext({
+ estimate: {
+ findUnique: estimateFindUnique,
+ },
+ comment: {
+ findUnique: commentFindUnique,
+ update: commentUpdate,
+ },
+ }, { role: SystemRole.CONTROLLER }));
+
+ await expect(controllerCaller.resolve({ id: "comment_1", resolved: true })).rejects.toMatchObject({
+ code: "FORBIDDEN",
+ message: "Only the comment author or an admin can resolve comments",
+ });
+
+ const adminUpdate = vi.fn().mockResolvedValue({
+ id: "comment_1",
+ body: "Needs review",
+ resolved: true,
+ author: { id: "user_2", name: "Other User", email: "other@example.com", image: null },
+ });
+ const adminCaller = createCaller(createContext({
+ estimate: {
+ findUnique: vi.fn().mockResolvedValue({ id: "est_1" }),
+ },
+ comment: {
+ findUnique: vi.fn().mockResolvedValue({
+ id: "comment_1",
+ authorId: "user_2",
+ entityType: "estimate",
+ entityId: "est_1",
+ }),
+ update: adminUpdate,
+ },
+ auditLog: {
+ create: vi.fn(),
+ },
+ }, { role: SystemRole.ADMIN }));
+
+ const result = await adminCaller.resolve({ id: "comment_1", resolved: true });
+
+ expect(result.resolved).toBe(true);
+ expect(adminUpdate).toHaveBeenCalledWith({
+ where: { id: "comment_1" },
+ data: { resolved: true },
+ include: {
+ author: { select: { id: true, name: true, email: true, image: true } },
+ },
+ });
+ });
+});
diff --git a/packages/api/src/router/assistant-tools.ts b/packages/api/src/router/assistant-tools.ts
index d7f1e30..bec040a 100644
--- a/packages/api/src/router/assistant-tools.ts
+++ b/packages/api/src/router/assistant-tools.ts
@@ -4371,11 +4371,11 @@ export const TOOL_DEFINITIONS: ToolDef[] = [
type: "function",
function: {
name: "list_comments",
- description: "List comments (with replies) for a specific entity such as an estimate, scope item, or demand line.",
+ description: "List comments (with replies) for a supported comment-enabled entity. Currently only estimate comments are enabled. Controller/manager/admin access required.",
parameters: {
type: "object",
properties: {
- entityType: { type: "string", description: "Entity type (e.g. 'estimate', 'estimate_version', 'scope_item', 'demand_line')" },
+ entityType: { type: "string", enum: ["estimate"], description: "Supported entity type. Currently only 'estimate'." },
entityId: { type: "string", description: "Entity ID" },
},
required: ["entityType", "entityId"],
@@ -4450,11 +4450,11 @@ export const TOOL_DEFINITIONS: ToolDef[] = [
type: "function",
function: {
name: "create_comment",
- description: "Add a comment to an entity (estimate, scope item, demand line, etc.). Supports @mentions. Always confirm with the user first.",
+ description: "Add a comment to a supported comment-enabled entity. Currently only estimate comments are enabled. Controller/manager/admin access required. Supports @mentions. Always confirm with the user first.",
parameters: {
type: "object",
properties: {
- entityType: { type: "string", description: "Entity type (e.g. 'estimate', 'estimate_version', 'scope_item')" },
+ entityType: { type: "string", enum: ["estimate"], description: "Supported entity type. Currently only 'estimate'." },
entityId: { type: "string", description: "Entity ID" },
body: { type: "string", description: "Comment body text. Use @[Name](userId) for mentions." },
},
@@ -4466,7 +4466,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = [
type: "function",
function: {
name: "resolve_comment",
- description: "Mark a comment as resolved (or unresolve it). Only the comment author or an admin can do this.",
+ description: "Mark a comment as resolved (or unresolve it) on a supported comment-enabled entity. Currently only estimate comments are enabled. Controller/manager/admin visibility is required, and only the comment author or an admin can change resolution.",
parameters: {
type: "object",
properties: {
@@ -8863,7 +8863,7 @@ const executors = {
};
},
- async list_comments(params: { entityType: string; entityId: string }, ctx: ToolContext) {
+ async list_comments(params: { entityType: "estimate"; entityId: string }, ctx: ToolContext) {
const caller = createCommentCaller(createScopedCallerContext(ctx));
const comments = await caller.list({
entityType: params.entityType,
@@ -8965,7 +8965,7 @@ const executors = {
},
async create_comment(params: {
- entityType: string;
+ entityType: "estimate";
entityId: string;
body: string;
}, ctx: ToolContext) {
diff --git a/packages/api/src/router/assistant.ts b/packages/api/src/router/assistant.ts
index 86992f8..feef38d 100644
--- a/packages/api/src/router/assistant.ts
+++ b/packages/api/src/router/assistant.ts
@@ -251,6 +251,9 @@ const RESOURCE_OVERVIEW_TOOLS = new Set([
/** Tools that follow controllerProcedure access rules in the main API. */
const CONTROLLER_ONLY_TOOLS = new Set([
"search_by_skill",
+ "list_comments",
+ "create_comment",
+ "resolve_comment",
"search_projects",
"get_project",
"search_estimates",
diff --git a/packages/api/src/router/comment.ts b/packages/api/src/router/comment.ts
index a0e372f..7ff5aa3 100644
--- a/packages/api/src/router/comment.ts
+++ b/packages/api/src/router/comment.ts
@@ -1,34 +1,12 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { SystemRole } from "@capakraken/shared";
-import { createTRPCRouter, protectedProcedure } from "../trpc.js";
+import { createTRPCRouter, protectedProcedure, type TRPCContext } from "../trpc.js";
import { createNotification } from "../lib/create-notification.js";
import { createAuditEntry } from "../lib/audit.js";
// ─── Helpers ──────────────────────────────────────────────────────────────────
-/** Resolve the DB user id from the session email. Throws UNAUTHORIZED if not found. */
-async function resolveUserId(ctx: {
- db: {
- user: {
- findUnique: (args: {
- where: { email: string };
- select: { id: true };
- }) => Promise<{ id: string } | null>;
- };
- };
- session: { user?: { email?: string | null } | null };
-}): Promise {
- const email = ctx.session.user?.email;
- if (!email) throw new TRPCError({ code: "UNAUTHORIZED" });
- const user = await ctx.db.user.findUnique({
- where: { email },
- select: { id: true },
- });
- if (!user) throw new TRPCError({ code: "UNAUTHORIZED" });
- return user.id;
-}
-
/**
* Parse @mentions from comment body.
* Pattern: @[Display Name](userId)
@@ -44,6 +22,74 @@ 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({
@@ -51,11 +97,13 @@ export const commentRouter = createTRPCRouter({
list: protectedProcedure
.input(
z.object({
- entityType: z.string(),
+ entityType: CommentEntityTypeSchema,
entityId: z.string(),
}),
)
.query(async ({ ctx, input }) => {
+ await assertCommentEntityAccess(ctx, input.entityType, input.entityId);
+
return ctx.db.comment.findMany({
where: {
entityType: input.entityType,
@@ -79,11 +127,13 @@ export const commentRouter = createTRPCRouter({
count: protectedProcedure
.input(
z.object({
- entityType: z.string(),
+ entityType: CommentEntityTypeSchema,
entityId: z.string(),
}),
)
.query(async ({ ctx, input }) => {
+ await assertCommentEntityAccess(ctx, input.entityType, input.entityId);
+
return ctx.db.comment.count({
where: {
entityType: input.entityType,
@@ -96,25 +146,33 @@ export const commentRouter = createTRPCRouter({
create: protectedProcedure
.input(
z.object({
- entityType: z.string(),
+ entityType: CommentEntityTypeSchema,
entityId: z.string(),
parentId: z.string().optional(),
body: z.string().min(1).max(10_000),
}),
)
.mutation(async ({ ctx, input }) => {
- const authorId = await resolveUserId(ctx);
+ 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);
// If replying, verify the parent exists
if (input.parentId) {
const parent = await ctx.db.comment.findUnique({
where: { id: input.parentId },
- select: { id: true },
+ select: { id: true, entityType: true, entityId: true },
});
if (!parent) {
throw new TRPCError({ code: "NOT_FOUND", message: "Parent comment not found" });
}
+ if (parent.entityType !== input.entityType || parent.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({
@@ -149,7 +207,7 @@ export const commentRouter = createTRPCRouter({
entityId: input.entityId,
entityType: input.entityType,
senderId: authorId,
- link: `/estimates/${input.entityId}?tab=comments`,
+ link: policy.buildLink(input.entityId),
channel: "in_app",
}),
),
@@ -179,18 +237,21 @@ export const commentRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
- const userId = await resolveUserId(ctx);
+ 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 },
+ 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);
+
// Only the author or an admin can resolve
const isAdmin = dbUser?.systemRole === SystemRole.ADMIN;
if (existing.authorId !== userId && !isAdmin) {
@@ -226,18 +287,21 @@ export const commentRouter = createTRPCRouter({
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
- const userId = await resolveUserId(ctx);
+ 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 },
+ 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);
+
const isAdmin = dbUser?.systemRole === SystemRole.ADMIN;
if (existing.authorId !== userId && !isAdmin) {
throw new TRPCError({