import { TRPCError } from "@trpc/server"; import { COMMENT_ENTITY_LABELS, COMMENT_ENTITY_TYPE_VALUES, PermissionKey, resolvePermissions, SystemRole, type PermissionOverrides, } from "@capakraken/shared"; import type { CommentEntityType } from "@capakraken/shared"; import { z } from "zod"; import type { TRPCContext } from "../trpc.js"; import { assertCanReadResource } from "./resource-access.js"; export const CommentEntityTypeSchema = z.enum(COMMENT_ENTITY_TYPE_VALUES); export type CommentMentionCandidate = { id: string; name: string | null; email: string; }; type CommentEntityPolicy = { assertAccess: (ctx: Pick, entityId: string) => Promise; buildLink: (entityId: string) => string; listMentionCandidates: ( ctx: Pick, entityId: string, query?: string, ) => Promise; }; const CONTROLLER_COMMENT_ROLES = new Set([ SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER, ]); const RESOURCE_STAFF_PERMISSION_KEYS = new Set([ PermissionKey.VIEW_ALL_RESOURCES, PermissionKey.MANAGE_RESOURCES, ]); const MENTION_CANDIDATE_LIMIT = 20; function buildMentionSearchWhere(query?: string) { if (!query) { return undefined; } return { OR: [ { name: { contains: query, mode: "insensitive" as const } }, { email: { contains: query, mode: "insensitive" as const } }, ], }; } async function listEstimateMentionCandidates( ctx: Pick, _entityId: string, query?: string, ): Promise { return ctx.db.user.findMany({ where: { systemRole: { in: Array.from(CONTROLLER_COMMENT_ROLES) }, ...buildMentionSearchWhere(query), }, select: { id: true, name: true, email: true, }, orderBy: [{ name: "asc" }, { email: "asc" }], take: MENTION_CANDIDATE_LIMIT, }); } async function listResourceMentionCandidates( ctx: Pick, entityId: string, query?: string, ): Promise { const resource = await ctx.db.resource.findUnique({ where: { id: entityId }, select: { userId: true }, }); if (!resource) { throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found", }); } const mentionWhere = buildMentionSearchWhere(query); const users = await ctx.db.user.findMany({ ...(mentionWhere ? { where: mentionWhere } : {}), select: { id: true, name: true, email: true, systemRole: true, permissionOverrides: true, }, orderBy: [{ name: "asc" }, { email: "asc" }], }); return users .filter((user) => { if (user.id === resource.userId) { return true; } const permissions = resolvePermissions( user.systemRole as SystemRole, user.permissionOverrides as PermissionOverrides | null, ctx.roleDefaults ?? undefined, ); return Array.from(RESOURCE_STAFF_PERMISSION_KEYS).some((permission) => permissions.has(permission), ); }) .slice(0, MENTION_CANDIDATE_LIMIT) .map(({ id, name, email }) => ({ id, name, email })); } async function assertEstimateCommentAccess( ctx: Pick, entityId: string, ) { const role = ctx.dbUser?.systemRole as SystemRole | undefined; if (!role || !CONTROLLER_COMMENT_ROLES.has(role)) { throw new TRPCError({ code: "FORBIDDEN", message: "Controller access required", }); } const estimate = await ctx.db.estimate.findUnique({ where: { id: entityId }, select: { id: true }, }); if (!estimate) { throw new TRPCError({ code: "NOT_FOUND", message: "Estimate not found", }); } } async function assertResourceCommentAccess( ctx: Pick, entityId: string, ) { const resource = await ctx.db.resource.findUnique({ where: { id: entityId }, select: { id: true }, }); if (!resource) { throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found", }); } await assertCanReadResource( ctx, entityId, "You can only comment on your own resource unless you have staff access", ); } const COMMENT_ENTITY_POLICIES: Record = { estimate: { assertAccess: assertEstimateCommentAccess, buildLink: (entityId) => `/estimates/${entityId}?tab=comments`, listMentionCandidates: listEstimateMentionCandidates, }, resource: { assertAccess: assertResourceCommentAccess, buildLink: (entityId) => `/resources/${entityId}#comments`, listMentionCandidates: listResourceMentionCandidates, }, }; export function getSupportedCommentEntityTypes(): CommentEntityType[] { return [...COMMENT_ENTITY_TYPE_VALUES]; } export function getSupportedCommentEntityLabelList(): string { return getSupportedCommentEntityTypes() .map((entityType) => COMMENT_ENTITY_LABELS[entityType]) .join(", "); } export function getCommentToolEntityDescription(): string { return `Supported entity type. One of: ${getSupportedCommentEntityLabelList()}.`; } export function getCommentToolScopeSentence(): string { return `Supported comment entities: ${getSupportedCommentEntityLabelList()}. Estimate comments require controller/manager/admin visibility; resource comments follow resource detail visibility.`; } export function isSupportedCommentEntityType(value: string): value is CommentEntityType { return (COMMENT_ENTITY_TYPE_VALUES as readonly string[]).includes(value); } export function getCommentEntityPolicy(entityType: string): CommentEntityPolicy { if (!isSupportedCommentEntityType(entityType)) { throw new TRPCError({ code: "BAD_REQUEST", message: `Comments are not supported for entity type: ${entityType}`, }); } return COMMENT_ENTITY_POLICIES[entityType]; } export async function assertCommentEntityAccess( ctx: Pick, entityType: string, entityId: string, ) { const policy = getCommentEntityPolicy(entityType); await policy.assertAccess(ctx, entityId); return policy; }