fix(web): reuse project combobox in timeline popovers
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 } },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<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.
|
||||
* 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<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({
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user