From 3a30fecc134d4dc4cba72067203a9689e32a3082 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Mon, 30 Mar 2026 09:58:39 +0200 Subject: [PATCH] feat(role): scope planning-linked role reads to planning audience --- .../src/__tests__/assistant-router.test.ts | 2 + .../role-router-planning-counts.test.ts | 108 +++++++++++++++++- packages/api/src/router/assistant.ts | 1 + packages/api/src/router/role.ts | 14 ++- 4 files changed, 120 insertions(+), 5 deletions(-) diff --git a/packages/api/src/__tests__/assistant-router.test.ts b/packages/api/src/__tests__/assistant-router.test.ts index 97a7fc8..95cb49f 100644 --- a/packages/api/src/__tests__/assistant-router.test.ts +++ b/packages/api/src/__tests__/assistant-router.test.ts @@ -385,12 +385,14 @@ describe("assistant router tool gating", () => { expect(userWithoutPlanning).not.toContain("list_allocations"); expect(userWithoutPlanning).not.toContain("list_demands"); + expect(userWithoutPlanning).not.toContain("list_roles"); expect(userWithoutPlanning).not.toContain("check_resource_availability"); expect(userWithoutPlanning).not.toContain("find_capacity"); expect(userWithoutPlanning).not.toContain("get_staffing_suggestions"); expect(userWithoutPlanning).not.toContain("find_best_project_resource"); expect(userWithPlanning).toContain("list_allocations"); expect(userWithPlanning).toContain("list_demands"); + expect(userWithPlanning).toContain("list_roles"); expect(userWithPlanning).toContain("check_resource_availability"); expect(userWithPlanning).toContain("find_capacity"); expect(userWithPlanning).not.toContain("get_staffing_suggestions"); diff --git a/packages/api/src/__tests__/role-router-planning-counts.test.ts b/packages/api/src/__tests__/role-router-planning-counts.test.ts index bde35d8..778eeb3 100644 --- a/packages/api/src/__tests__/role-router-planning-counts.test.ts +++ b/packages/api/src/__tests__/role-router-planning-counts.test.ts @@ -1,4 +1,4 @@ -import { AllocationStatus, SystemRole } from "@capakraken/shared"; +import { AllocationStatus, PermissionKey, SystemRole } from "@capakraken/shared"; import { TRPCError } from "@trpc/server"; import { describe, expect, it, vi } from "vitest"; import { roleRouter } from "../router/role.js"; @@ -27,7 +27,55 @@ function createManagerCaller(db: Record) { }); } +function createProtectedCallerWithOverrides( + db: Record, + overrides: { granted?: PermissionKey[]; denied?: PermissionKey[] } | null, +) { + return createCaller({ + session: { + user: { email: "user@example.com", name: "User", image: null }, + expires: "2026-03-13T00:00:00.000Z", + }, + db: db as never, + dbUser: { + id: "user_2", + systemRole: SystemRole.USER, + permissionOverrides: overrides, + }, + }); +} + describe("role router planning counts", () => { + it("requires planning read access for role list views with planning counts", async () => { + const caller = createProtectedCallerWithOverrides({}, null); + + await expect(caller.list({})).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Planning read access required", + }); + + await expect(caller.getByIdentifier({ identifier: "role_fx" })).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Planning read access required", + }); + + await expect(caller.getById({ id: "role_fx" })).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Planning read access required", + }); + }); + + it("does not treat viewCosts as a substitute for viewPlanning on role list views", async () => { + const caller = createProtectedCallerWithOverrides({}, { + granted: [PermissionKey.VIEW_COSTS], + }); + + await expect(caller.list({})).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Planning read access required", + }); + }); + it("reports planning entry counts for roles", async () => { const db = { role: { @@ -116,6 +164,64 @@ describe("role router planning counts", () => { expect(result[0]?._count.allocations).toBe(2); }); + it("allows users with viewPlanning to load a role by identifier with planning counts", async () => { + const db = { + role: { + findUnique: vi.fn().mockResolvedValue({ + id: "role_fx", + name: "FX", + description: null, + color: "#111111", + isActive: true, + _count: { resourceRoles: 2 }, + }), + }, + demandRequirement: { + findMany: vi.fn().mockResolvedValue([]), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([ + { + id: "assignment_1", + demandRequirementId: null, + resourceId: "resource_1", + projectId: "project_1", + startDate: new Date("2026-03-19"), + endDate: new Date("2026-03-20"), + hoursPerDay: 8, + percentage: 100, + role: "FX", + roleId: "role_fx", + dailyCostCents: 32000, + status: AllocationStatus.ACTIVE, + metadata: {}, + createdAt: new Date("2026-03-13"), + updatedAt: new Date("2026-03-13"), + }, + ]), + }, + }; + + const caller = createProtectedCallerWithOverrides(db, { + granted: [PermissionKey.VIEW_PLANNING], + }); + const result = await caller.getByIdentifier({ identifier: "role_fx" }); + + expect(result._count.resourceRoles).toBe(2); + expect(result._count.allocations).toBe(1); + expect(db.role.findUnique).toHaveBeenCalledWith(expect.objectContaining({ + where: { id: "role_fx" }, + select: expect.objectContaining({ + id: true, + name: true, + description: true, + color: true, + isActive: true, + _count: expect.any(Object), + }), + })); + }); + it("blocks deleting a role that is only used by explicit demand or assignment rows", async () => { const db = { role: { diff --git a/packages/api/src/router/assistant.ts b/packages/api/src/router/assistant.ts index 9bf19f4..5227aae 100644 --- a/packages/api/src/router/assistant.ts +++ b/packages/api/src/router/assistant.ts @@ -229,6 +229,7 @@ const COST_TOOLS = new Set([ const PLANNING_READ_TOOLS = new Set([ "list_allocations", "list_demands", + "list_roles", "check_resource_availability", "get_staffing_suggestions", "find_capacity", diff --git a/packages/api/src/router/role.ts b/packages/api/src/router/role.ts index ee66d4c..dd9b3b1 100644 --- a/packages/api/src/router/role.ts +++ b/packages/api/src/router/role.ts @@ -5,7 +5,13 @@ import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { RESOURCE_BRIEF_SELECT } from "../db/selects.js"; import { emitRoleCreated, emitRoleDeleted, emitRoleUpdated } from "../sse/event-bus.js"; -import { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js"; +import { + createTRPCRouter, + managerProcedure, + planningReadProcedure, + protectedProcedure, + requirePermission, +} from "../trpc.js"; async function loadRolePlanningEntryCounts( db: Pick, @@ -54,7 +60,7 @@ async function attachSinglePlanningEntryCount< } export const roleRouter = createTRPCRouter({ - list: protectedProcedure + list: planningReadProcedure .input( z.object({ isActive: z.boolean().optional(), @@ -120,7 +126,7 @@ export const roleRouter = createTRPCRouter({ return role; }), - getByIdentifier: protectedProcedure + getByIdentifier: planningReadProcedure .input(z.object({ identifier: z.string() })) .query(async ({ ctx, input }) => { const select = { @@ -161,7 +167,7 @@ export const roleRouter = createTRPCRouter({ return attachSinglePlanningEntryCount(ctx.db, role); }), - getById: protectedProcedure + getById: planningReadProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const role = await findUniqueOrThrow(