fix(web): reuse project combobox in timeline popovers

This commit is contained in:
2026-03-30 13:34:59 +02:00
parent 9268a38df4
commit f0bea6235d
13 changed files with 525 additions and 203 deletions
@@ -31,10 +31,7 @@ interface CommentItem {
replies: CommentReply[]; replies: CommentReply[];
} }
type CommentEntityType = "estimate";
interface CommentThreadProps { interface CommentThreadProps {
entityType: CommentEntityType;
entityId: string; entityId: string;
} }
@@ -113,37 +110,36 @@ function CommentBody({ body }: { body: string }) {
function SingleComment({ function SingleComment({
comment, comment,
entityType,
entityId, entityId,
isReply = false, isReply = false,
}: { }: {
comment: CommentItem | CommentReply; comment: CommentItem | CommentReply;
entityType: CommentEntityType;
entityId: string; entityId: string;
isReply?: boolean; isReply?: boolean;
}) { }) {
const [showReplyInput, setShowReplyInput] = useState(false); const [showReplyInput, setShowReplyInput] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);
const utils = trpc.useUtils(); const utils = trpc.useUtils();
const commentTarget = { entityType: "estimate" as const, entityId };
const createMutation = trpc.comment.create.useMutation({ const createMutation = trpc.comment.create.useMutation({
onSuccess: () => { onSuccess: () => {
setShowReplyInput(false); setShowReplyInput(false);
void utils.comment.list.invalidate({ entityType, entityId }); void utils.comment.list.invalidate(commentTarget);
void utils.comment.count.invalidate({ entityType, entityId }); void utils.comment.count.invalidate(commentTarget);
}, },
}); });
const resolveMutation = trpc.comment.resolve.useMutation({ const resolveMutation = trpc.comment.resolve.useMutation({
onSuccess: () => { onSuccess: () => {
void utils.comment.list.invalidate({ entityType, entityId }); void utils.comment.list.invalidate(commentTarget);
}, },
}); });
const deleteMutation = trpc.comment.delete.useMutation({ const deleteMutation = trpc.comment.delete.useMutation({
onSuccess: () => { onSuccess: () => {
void utils.comment.list.invalidate({ entityType, entityId }); void utils.comment.list.invalidate(commentTarget);
void utils.comment.count.invalidate({ entityType, entityId }); void utils.comment.count.invalidate(commentTarget);
}, },
}); });
@@ -217,12 +213,12 @@ function SingleComment({
{showReplyInput && ( {showReplyInput && (
<div className="mt-3"> <div className="mt-3">
<CommentInput <CommentInput
entityType={entityType} entityType={commentTarget.entityType}
entityId={entityId} entityId={entityId}
parentId={comment.id} parentId={comment.id}
onSubmit={(replyBody) => { onSubmit={(replyBody) => {
createMutation.mutate({ createMutation.mutate({
entityType, entityType: commentTarget.entityType,
entityId, entityId,
parentId: comment.id, parentId: comment.id,
body: replyBody, body: replyBody,
@@ -259,7 +255,6 @@ function SingleComment({
<SingleComment <SingleComment
key={reply.id} key={reply.id}
comment={reply} comment={reply}
entityType={entityType}
entityId={entityId} entityId={entityId}
isReply isReply
/> />
@@ -270,18 +265,19 @@ function SingleComment({
); );
} }
export function CommentThread({ entityType, entityId }: CommentThreadProps) { export function CommentThread({ entityId }: CommentThreadProps) {
const utils = trpc.useUtils(); const utils = trpc.useUtils();
const commentTarget = { entityType: "estimate" as const, entityId };
const commentsQuery = trpc.comment.list.useQuery( const commentsQuery = trpc.comment.list.useQuery(
{ entityType, entityId }, commentTarget,
{ staleTime: 10_000 }, { staleTime: 10_000 },
); );
const createMutation = trpc.comment.create.useMutation({ const createMutation = trpc.comment.create.useMutation({
onSuccess: () => { onSuccess: () => {
void utils.comment.list.invalidate({ entityType, entityId }); void utils.comment.list.invalidate(commentTarget);
void utils.comment.count.invalidate({ entityType, entityId }); void utils.comment.count.invalidate(commentTarget);
}, },
}); });
@@ -312,7 +308,6 @@ export function CommentThread({ entityType, entityId }: CommentThreadProps) {
<SingleComment <SingleComment
key={comment.id} key={comment.id}
comment={comment} comment={comment}
entityType={entityType}
entityId={entityId} entityId={entityId}
/> />
))} ))}
@@ -322,11 +317,11 @@ export function CommentThread({ entityType, entityId }: CommentThreadProps) {
{/* New comment input */} {/* New comment input */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-4"> <div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<CommentInput <CommentInput
entityType={entityType} entityType={commentTarget.entityType}
entityId={entityId} entityId={entityId}
onSubmit={(body) => { onSubmit={(body) => {
createMutation.mutate({ createMutation.mutate({
entityType, entityType: commentTarget.entityType,
entityId, entityId,
body, body,
}); });
@@ -135,7 +135,7 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
const commentCountQuery = trpc.comment.count.useQuery( const commentCountQuery = trpc.comment.count.useQuery(
{ entityType: "estimate", entityId: estimateId }, { entityType: "estimate", entityId: estimateId },
{ staleTime: 30_000 }, { enabled: canViewCosts, staleTime: 30_000 },
); );
const commentCount = commentCountQuery.data ?? 0; const commentCount = commentCountQuery.data ?? 0;
@@ -364,7 +364,7 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
<h2 className="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-50"> <h2 className="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-50">
Comments Comments
</h2> </h2>
<CommentThread entityType="estimate" entityId={estimate.id} /> <CommentThread entityId={estimate.id} />
</div> </div>
)} )}
</> </>
@@ -6,6 +6,7 @@ import { createPortal } from "react-dom";
import { AllocationStatus } from "@capakraken/shared"; import { AllocationStatus } from "@capakraken/shared";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js"; import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
import { ProjectCombobox } from "~/components/ui/ProjectCombobox.js";
interface BatchAssignPopoverProps { interface BatchAssignPopoverProps {
resourceIds: string[]; resourceIds: string[];
@@ -33,23 +34,10 @@ export function BatchAssignPopover({
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const invalidateTimeline = useInvalidateTimeline(); const invalidateTimeline = useInvalidateTimeline();
const [search, setSearch] = useState("");
const [selectedProjectId, setSelectedProjectId] = useState<string | null>( const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
null, null,
); );
const [hoursPerDay, setHoursPerDay] = useState(8); 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({ const batchMutation = trpc.timeline.batchQuickAssign.useMutation({
onSuccess: () => { onSuccess: () => {
@@ -136,54 +124,12 @@ export function BatchAssignPopover({
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1"> <label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
Project Project
</label> </label>
{selectedProject && !dropdownOpen ? ( <ProjectCombobox
<div value={selectedProjectId}
className="flex items-center gap-2 border border-sky-300 dark:border-sky-700 rounded-lg px-3 py-2 cursor-pointer bg-sky-50 dark:bg-sky-950/30" onChange={setSelectedProjectId}
onClick={() => { placeholder="Search project…"
setDropdownOpen(true); className="w-full"
setSearch(""); />
}}
>
<span className="text-sm text-gray-800 dark:text-gray-200 truncate flex-1">
{selectedProject.name}
</span>
<span className="text-xs text-gray-400 dark:text-gray-500">
&#9662;
</span>
</div>
) : (
<div className="relative">
<input
autoFocus
type="text"
placeholder="Search projects\u2026"
value={search}
onChange={(e) => 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 && (
<div className="absolute top-full left-0 right-0 z-50 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-lg dark:shadow-black/40 mt-1 max-h-44 overflow-y-auto">
{projects.map((p) => (
<button
key={p.id}
type="button"
onClick={() => {
setSelectedProjectId(p.id);
setDropdownOpen(false);
setSearch("");
}}
className="w-full text-left px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-2 border-b border-gray-50 dark:border-gray-700 last:border-0"
>
<span className="text-sm text-gray-800 dark:text-gray-200 truncate">
{p.name}
</span>
</button>
))}
</div>
)}
</div>
)}
</div> </div>
{/* Hours per day */} {/* Hours per day */}
@@ -1,13 +1,14 @@
"use client"; "use client";
import { clsx } from "clsx"; import { clsx } from "clsx";
import { useEffect, useRef, useState } from "react"; import { useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { AllocationStatus } from "@capakraken/shared"; import { AllocationStatus } from "@capakraken/shared";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js"; import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
import { useViewportPopover } from "~/hooks/useViewportPopover.js"; import { useViewportPopover } from "~/hooks/useViewportPopover.js";
import { DateInput } from "~/components/ui/DateInput.js"; import { DateInput } from "~/components/ui/DateInput.js";
import { ProjectCombobox } from "~/components/ui/ProjectCombobox.js";
interface NewAllocationPopoverProps { interface NewAllocationPopoverProps {
resourceId: string; resourceId: string;
@@ -46,7 +47,6 @@ export function NewAllocationPopover({
}); });
const invalidateTimeline = useInvalidateTimeline(); const invalidateTimeline = useInvalidateTimeline();
const [search, setSearch] = useState("");
const [selectedProjectId, setSelectedProjectId] = useState<string | null>( const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
suggestedProjectId ?? null, suggestedProjectId ?? null,
); );
@@ -54,17 +54,6 @@ export function NewAllocationPopover({
const [hoursPerDay, setHoursPerDay] = useState(8); const [hoursPerDay, setHoursPerDay] = useState(8);
const [start, setStart] = useState(toDateInput(startDate)); const [start, setStart] = useState(toDateInput(startDate));
const [end, setEnd] = useState(toDateInput(endDate)); 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({ const createMutation = trpc.timeline.quickAssign.useMutation({
onSuccess: () => { onSuccess: () => {
@@ -126,41 +115,12 @@ export function NewAllocationPopover({
{/* Project picker */} {/* Project picker */}
<div> <div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Project</label> <label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Project</label>
{selectedProject && !dropdownOpen ? ( <ProjectCombobox
<div value={selectedProjectId}
className="flex items-center gap-2 border border-brand-300 dark:border-sky-700 rounded-lg px-3 py-2 cursor-pointer bg-brand-50 dark:bg-sky-950/30" onChange={setSelectedProjectId}
onClick={() => { setDropdownOpen(true); setSearch(""); }} placeholder="Search project…"
> className="w-full"
<span className="text-sm text-gray-800 dark:text-gray-200 truncate flex-1">{selectedProject.name}</span> />
<span className="text-xs text-gray-400 dark:text-gray-500"></span>
</div>
) : (
<div className="relative">
<input
autoFocus={dropdownOpen}
type="text"
placeholder="Search projects…"
value={search}
onChange={(e) => 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 && (
<div className="absolute top-full left-0 right-0 z-50 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-lg dark:shadow-black/40 mt-1 max-h-44 overflow-y-auto">
{projects.map((p) => (
<button
key={p.id}
type="button"
onClick={() => { setSelectedProjectId(p.id); setDropdownOpen(false); setSearch(""); }}
className="w-full text-left px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-2 border-b border-gray-50 dark:border-gray-700 last:border-0"
>
<span className="text-sm text-gray-800 dark:text-gray-200 truncate">{p.name}</span>
</button>
))}
</div>
)}
</div>
)}
</div> </div>
{/* Role */} {/* Role */}
+2 -19
View File
@@ -19,6 +19,7 @@
- `project.isImageGenConfigured`, `project.isDalleConfigured`: covered as authenticated low-risk configuration checks - `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 - `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 - `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 ### 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 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 - 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 ## 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 ## Slice Definition
+74
View File
@@ -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
+11
View File
@@ -73,6 +73,17 @@ Reasoning:
- `list`: `controller-finance` - `list`: `controller-finance`
- drafting, versioning, export generation, and approval writes: `manager-write` - 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` ### `packages/api/src/router/system-role-config.ts`
- all reads and writes: `admin-only` - all reads and writes: `admin-only`
@@ -361,6 +361,9 @@ describe("assistant router tool gating", () => {
expect(controllerNames).toContain("query_change_history"); expect(controllerNames).toContain("query_change_history");
expect(controllerNames).toContain("get_entity_timeline"); expect(controllerNames).toContain("get_entity_timeline");
expect(controllerNames).toContain("search_by_skill"); 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_resources_csv");
expect(controllerNames).toContain("export_projects_csv"); expect(controllerNames).toContain("export_projects_csv");
expect(controllerNames).toContain("list_audit_log_entries"); 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("query_change_history");
expect(userNames).not.toContain("get_entity_timeline"); expect(userNames).not.toContain("get_entity_timeline");
expect(userNames).not.toContain("search_by_skill"); 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_resources_csv");
expect(userNames).not.toContain("export_projects_csv"); expect(userNames).not.toContain("export_projects_csv");
expect(userNames).not.toContain("list_audit_log_entries"); 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("get_current_user")).toContain("authenticated user's own profile");
expect(toolDescriptions.get("search_resources")).toContain("Resource overview access required"); 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("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("list_notifications")).toContain("current user");
expect(toolDescriptions.get("get_unread_notification_count")).toContain("current user"); expect(toolDescriptions.get("get_unread_notification_count")).toContain("current user");
expect(toolDescriptions.get("list_tasks")).toContain("current user"); expect(toolDescriptions.get("list_tasks")).toContain("current user");
@@ -4845,10 +4845,12 @@ describe("assistant import/export and dispo tools", () => {
const commentFindUnique = vi.fn().mockResolvedValue({ const commentFindUnique = vi.fn().mockResolvedValue({
id: "comment_1", id: "comment_1",
authorId: "user_1", authorId: "user_1",
entityType: "estimate",
entityId: "est_1",
}); });
const db = { const db = {
user: { estimate: {
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }), findUnique: vi.fn().mockResolvedValue({ id: "est_1" }),
}, },
comment: { comment: {
findMany: vi.fn().mockResolvedValue([ findMany: vi.fn().mockResolvedValue([
@@ -4931,8 +4933,17 @@ describe("assistant import/export and dispo tools", () => {
}, },
orderBy: { createdAt: "asc" }, orderBy: { createdAt: "asc" },
}); });
expect(db.user.findUnique).toHaveBeenCalledWith({ expect(db.estimate.findUnique).toHaveBeenCalledTimes(3);
where: { email: "assistant@example.com" }, 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 }, select: { id: true },
}); });
expect(db.comment.create).toHaveBeenCalledWith({ expect(db.comment.create).toHaveBeenCalledWith({
@@ -4949,7 +4960,7 @@ describe("assistant import/export and dispo tools", () => {
}); });
expect(commentFindUnique).toHaveBeenCalledWith({ expect(commentFindUnique).toHaveBeenCalledWith({
where: { id: "comment_1" }, where: { id: "comment_1" },
select: { id: true, authorId: true }, select: { id: true, authorId: true, entityType: true, entityId: true },
}); });
expect(db.comment.update).toHaveBeenCalledWith({ expect(db.comment.update).toHaveBeenCalledWith({
where: { id: "comment_1" }, 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 () => { it("returns a stable assistant error when creating a comment with an empty body", async () => {
const ctx = createToolContext( const ctx = createToolContext(
{ {
user: { estimate: {
findUnique: vi.fn(), findUnique: vi.fn(),
}, },
comment: { comment: {
@@ -5026,14 +5037,13 @@ describe("assistant import/export and dispo tools", () => {
expect(JSON.parse(result.content)).toEqual({ expect(JSON.parse(result.content)).toEqual({
error: "Comment body is required.", error: "Comment body is required.",
}); });
expect(ctx.db.user.findUnique).not.toHaveBeenCalled();
expect(ctx.db.comment.create).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 () => { it("returns a stable assistant error when creating a comment with a body that is too long", async () => {
const ctx = createToolContext( const ctx = createToolContext(
{ {
user: { estimate: {
findUnique: vi.fn(), findUnique: vi.fn(),
}, },
comment: { comment: {
@@ -5056,15 +5066,14 @@ describe("assistant import/export and dispo tools", () => {
expect(JSON.parse(result.content)).toEqual({ expect(JSON.parse(result.content)).toEqual({
error: "Comment body must be at most 10000 characters.", error: "Comment body must be at most 10000 characters.",
}); });
expect(ctx.db.user.findUnique).not.toHaveBeenCalled();
expect(ctx.db.comment.create).not.toHaveBeenCalled(); expect(ctx.db.comment.create).not.toHaveBeenCalled();
}); });
it("returns a stable assistant error when the comment author disappears during creation", async () => { it("returns a stable assistant error when the comment author disappears during creation", async () => {
const ctx = createToolContext( const ctx = createToolContext(
{ {
user: { estimate: {
findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), findUnique: vi.fn().mockResolvedValue({ id: "est_1" }),
}, },
comment: { comment: {
create: vi.fn().mockRejectedValue({ 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 () => { it("returns a stable assistant error when a mentioned user disappears during comment creation", async () => {
const ctx = createToolContext( const ctx = createToolContext(
{ {
user: { estimate: {
findUnique: vi.fn().mockResolvedValue({ id: "user_1" }), findUnique: vi.fn().mockResolvedValue({ id: "est_1" }),
}, },
comment: { comment: {
create: vi.fn().mockResolvedValue({ create: vi.fn().mockResolvedValue({
@@ -5170,8 +5179,13 @@ describe("assistant import/export and dispo tools", () => {
findUnique: vi.fn().mockResolvedValue({ findUnique: vi.fn().mockResolvedValue({
id: "comment_1", id: "comment_1",
authorId: "user_2", authorId: "user_2",
entityType: "estimate",
entityId: "est_1",
}), }),
}, },
estimate: {
findUnique: vi.fn().mockResolvedValue({ id: "est_1" }),
},
}, },
{ userRole: SystemRole.CONTROLLER }, { userRole: SystemRole.CONTROLLER },
); );
@@ -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<string, unknown>,
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 } },
},
});
});
});
+7 -7
View File
@@ -4371,11 +4371,11 @@ export const TOOL_DEFINITIONS: ToolDef[] = [
type: "function", type: "function",
function: { function: {
name: "list_comments", 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: { parameters: {
type: "object", type: "object",
properties: { 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" }, entityId: { type: "string", description: "Entity ID" },
}, },
required: ["entityType", "entityId"], required: ["entityType", "entityId"],
@@ -4450,11 +4450,11 @@ export const TOOL_DEFINITIONS: ToolDef[] = [
type: "function", type: "function",
function: { function: {
name: "create_comment", 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: { parameters: {
type: "object", type: "object",
properties: { 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" }, entityId: { type: "string", description: "Entity ID" },
body: { type: "string", description: "Comment body text. Use @[Name](userId) for mentions." }, body: { type: "string", description: "Comment body text. Use @[Name](userId) for mentions." },
}, },
@@ -4466,7 +4466,7 @@ export const TOOL_DEFINITIONS: ToolDef[] = [
type: "function", type: "function",
function: { function: {
name: "resolve_comment", 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: { parameters: {
type: "object", type: "object",
properties: { 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 caller = createCommentCaller(createScopedCallerContext(ctx));
const comments = await caller.list({ const comments = await caller.list({
entityType: params.entityType, entityType: params.entityType,
@@ -8965,7 +8965,7 @@ const executors = {
}, },
async create_comment(params: { async create_comment(params: {
entityType: string; entityType: "estimate";
entityId: string; entityId: string;
body: string; body: string;
}, ctx: ToolContext) { }, ctx: ToolContext) {
+3
View File
@@ -251,6 +251,9 @@ const RESOURCE_OVERVIEW_TOOLS = new Set([
/** Tools that follow controllerProcedure access rules in the main API. */ /** Tools that follow controllerProcedure access rules in the main API. */
const CONTROLLER_ONLY_TOOLS = new Set([ const CONTROLLER_ONLY_TOOLS = new Set([
"search_by_skill", "search_by_skill",
"list_comments",
"create_comment",
"resolve_comment",
"search_projects", "search_projects",
"get_project", "get_project",
"search_estimates", "search_estimates",
+97 -33
View File
@@ -1,34 +1,12 @@
import { z } from "zod"; import { z } from "zod";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { SystemRole } from "@capakraken/shared"; 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 { createNotification } from "../lib/create-notification.js";
import { createAuditEntry } from "../lib/audit.js"; import { createAuditEntry } from "../lib/audit.js";
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── 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<string> {
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. * Parse @mentions from comment body.
* Pattern: @[Display Name](userId) * Pattern: @[Display Name](userId)
@@ -44,6 +22,74 @@ function parseMentions(body: string): string[] {
return Array.from(ids); 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 ─────────────────────────────────────────────────────────────────── // ─── Router ───────────────────────────────────────────────────────────────────
export const commentRouter = createTRPCRouter({ export const commentRouter = createTRPCRouter({
@@ -51,11 +97,13 @@ export const commentRouter = createTRPCRouter({
list: protectedProcedure list: protectedProcedure
.input( .input(
z.object({ z.object({
entityType: z.string(), entityType: CommentEntityTypeSchema,
entityId: z.string(), entityId: z.string(),
}), }),
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
await assertCommentEntityAccess(ctx, input.entityType, input.entityId);
return ctx.db.comment.findMany({ return ctx.db.comment.findMany({
where: { where: {
entityType: input.entityType, entityType: input.entityType,
@@ -79,11 +127,13 @@ export const commentRouter = createTRPCRouter({
count: protectedProcedure count: protectedProcedure
.input( .input(
z.object({ z.object({
entityType: z.string(), entityType: CommentEntityTypeSchema,
entityId: z.string(), entityId: z.string(),
}), }),
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
await assertCommentEntityAccess(ctx, input.entityType, input.entityId);
return ctx.db.comment.count({ return ctx.db.comment.count({
where: { where: {
entityType: input.entityType, entityType: input.entityType,
@@ -96,25 +146,33 @@ export const commentRouter = createTRPCRouter({
create: protectedProcedure create: protectedProcedure
.input( .input(
z.object({ z.object({
entityType: z.string(), entityType: CommentEntityTypeSchema,
entityId: z.string(), entityId: z.string(),
parentId: z.string().optional(), parentId: z.string().optional(),
body: z.string().min(1).max(10_000), body: z.string().min(1).max(10_000),
}), }),
) )
.mutation(async ({ ctx, input }) => { .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); const mentions = parseMentions(input.body);
// If replying, verify the parent exists // If replying, verify the parent exists
if (input.parentId) { if (input.parentId) {
const parent = await ctx.db.comment.findUnique({ const parent = await ctx.db.comment.findUnique({
where: { id: input.parentId }, where: { id: input.parentId },
select: { id: true }, select: { id: true, entityType: true, entityId: true },
}); });
if (!parent) { if (!parent) {
throw new TRPCError({ code: "NOT_FOUND", message: "Parent comment not found" }); 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({ const comment = await ctx.db.comment.create({
@@ -149,7 +207,7 @@ export const commentRouter = createTRPCRouter({
entityId: input.entityId, entityId: input.entityId,
entityType: input.entityType, entityType: input.entityType,
senderId: authorId, senderId: authorId,
link: `/estimates/${input.entityId}?tab=comments`, link: policy.buildLink(input.entityId),
channel: "in_app", channel: "in_app",
}), }),
), ),
@@ -179,18 +237,21 @@ export const commentRouter = createTRPCRouter({
}), }),
) )
.mutation(async ({ ctx, input }) => { .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 dbUser = ctx.dbUser;
const existing = await ctx.db.comment.findUnique({ const existing = await ctx.db.comment.findUnique({
where: { id: input.id }, where: { id: input.id },
select: { id: true, authorId: true }, select: { id: true, authorId: true, entityType: true, entityId: true },
}); });
if (!existing) { if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Comment not found" }); 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 // Only the author or an admin can resolve
const isAdmin = dbUser?.systemRole === SystemRole.ADMIN; const isAdmin = dbUser?.systemRole === SystemRole.ADMIN;
if (existing.authorId !== userId && !isAdmin) { if (existing.authorId !== userId && !isAdmin) {
@@ -226,18 +287,21 @@ export const commentRouter = createTRPCRouter({
delete: protectedProcedure delete: protectedProcedure
.input(z.object({ id: z.string() })) .input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => { .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 dbUser = ctx.dbUser;
const existing = await ctx.db.comment.findUnique({ const existing = await ctx.db.comment.findUnique({
where: { id: input.id }, where: { id: input.id },
select: { id: true, authorId: true }, select: { id: true, authorId: true, entityType: true, entityId: true },
}); });
if (!existing) { if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Comment not found" }); throw new TRPCError({ code: "NOT_FOUND", message: "Comment not found" });
} }
await assertCommentEntityAccess(ctx, existing.entityType, existing.entityId);
const isAdmin = dbUser?.systemRole === SystemRole.ADMIN; const isAdmin = dbUser?.systemRole === SystemRole.ADMIN;
if (existing.authorId !== userId && !isAdmin) { if (existing.authorId !== userId && !isAdmin) {
throw new TRPCError({ throw new TRPCError({