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
+7 -7
View File
@@ -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) {
+3
View File
@@ -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",
+97 -33
View File
@@ -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({