diff --git a/docs/route-access-matrix.md b/docs/route-access-matrix.md index 45b422d..578413c 100644 --- a/docs/route-access-matrix.md +++ b/docs/route-access-matrix.md @@ -115,6 +115,16 @@ Reasoning: - `list` already exposes `_count.children` and `_count.projects`, and `getTree` reveals the full client hierarchy used in planning and reporting flows - detailed client reads add parent/child structure plus project counts, so they should align with the explicit planning audience instead of broad authenticated access +### `packages/api/src/router/utilization-category.ts` + +- `list`, `getById`: `planning-read` +- create and update: `admin-only` + +Reasoning: + +- the categories feed project configuration and planning/reporting workflows instead of broad self-service screens +- `getById` includes `_count.projects`, so the detailed read should not remain a generic authenticated route + ## Assistant Parity Rule - assistant tool visibility must never widen the audience of the backing router diff --git a/packages/api/src/__tests__/assistant-router.test.ts b/packages/api/src/__tests__/assistant-router.test.ts index d5e5b21..792f46a 100644 --- a/packages/api/src/__tests__/assistant-router.test.ts +++ b/packages/api/src/__tests__/assistant-router.test.ts @@ -389,6 +389,7 @@ describe("assistant router tool gating", () => { expect(userWithoutPlanning).not.toContain("list_demands"); expect(userWithoutPlanning).not.toContain("list_clients"); expect(userWithoutPlanning).not.toContain("list_roles"); + expect(userWithoutPlanning).not.toContain("list_utilization_categories"); expect(userWithoutPlanning).not.toContain("check_resource_availability"); expect(userWithoutPlanning).not.toContain("find_capacity"); expect(userWithoutPlanning).not.toContain("get_staffing_suggestions"); @@ -397,6 +398,7 @@ describe("assistant router tool gating", () => { expect(userWithPlanning).toContain("list_demands"); expect(userWithPlanning).toContain("list_clients"); expect(userWithPlanning).toContain("list_roles"); + expect(userWithPlanning).toContain("list_utilization_categories"); 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__/master-data-router-auth.test.ts b/packages/api/src/__tests__/master-data-router-auth.test.ts index 0848a48..0f7be44 100644 --- a/packages/api/src/__tests__/master-data-router-auth.test.ts +++ b/packages/api/src/__tests__/master-data-router-auth.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import { clientRouter } from "../router/client.js"; import { countryRouter } from "../router/country.js"; import { orgUnitRouter } from "../router/org-unit.js"; +import { utilizationCategoryRouter } from "../router/utilization-category.js"; import { createCallerFactory } from "../trpc.js"; function createProtectedContext( @@ -506,4 +507,73 @@ describe("master-data router authorization", () => { include: { _count: { select: { projects: true, children: true } } }, })); }); + + it("requires planning read access for utilization-category reads with project counts", async () => { + const listFindMany = vi.fn(); + const getByIdFindUnique = vi.fn(); + const caller = createCallerFactory(utilizationCategoryRouter)(createProtectedContext({ + utilizationCategory: { + findMany: listFindMany, + findUnique: getByIdFindUnique, + }, + })); + + await expect(caller.list({ isActive: true })).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Planning read access required", + }); + await expect(caller.getById({ id: "util_chargeable" })).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Planning read access required", + }); + + expect(listFindMany).not.toHaveBeenCalled(); + expect(getByIdFindUnique).not.toHaveBeenCalled(); + }); + + it("allows utilization-category reads for users with planning access", async () => { + const listFindMany = vi.fn().mockResolvedValue([ + { + id: "util_chargeable", + code: "CHARGEABLE", + name: "Chargeable", + description: "Revenue-generating project work", + sortOrder: 1, + isDefault: true, + isActive: true, + }, + ]); + const getByIdFindUnique = vi.fn().mockResolvedValue({ + id: "util_chargeable", + code: "CHARGEABLE", + name: "Chargeable", + description: "Revenue-generating project work", + sortOrder: 1, + isDefault: true, + isActive: true, + _count: { projects: 12 }, + }); + const caller = createCallerFactory(utilizationCategoryRouter)(createProtectedContext({ + utilizationCategory: { + findMany: listFindMany, + findUnique: getByIdFindUnique, + }, + }, { + granted: [PermissionKey.VIEW_PLANNING], + })); + + const listResult = await caller.list({ isActive: true }); + const byIdResult = await caller.getById({ id: "util_chargeable" }); + + expect(listResult).toHaveLength(1); + expect(byIdResult._count.projects).toBe(12); + expect(listFindMany).toHaveBeenCalledWith({ + where: { isActive: true }, + orderBy: { sortOrder: "asc" }, + }); + expect(getByIdFindUnique).toHaveBeenCalledWith({ + where: { id: "util_chargeable" }, + include: { _count: { select: { projects: true } } }, + }); + }); }); diff --git a/packages/api/src/router/assistant.ts b/packages/api/src/router/assistant.ts index 432153c..9553972 100644 --- a/packages/api/src/router/assistant.ts +++ b/packages/api/src/router/assistant.ts @@ -230,6 +230,7 @@ const PLANNING_READ_TOOLS = new Set([ "list_demands", "list_clients", "list_roles", + "list_utilization_categories", "check_resource_availability", "get_staffing_suggestions", "find_capacity", diff --git a/packages/api/src/router/utilization-category.ts b/packages/api/src/router/utilization-category.ts index 1eba95c..60b40a0 100644 --- a/packages/api/src/router/utilization-category.ts +++ b/packages/api/src/router/utilization-category.ts @@ -6,10 +6,10 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { createAuditEntry } from "../lib/audit.js"; -import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js"; +import { adminProcedure, createTRPCRouter, planningReadProcedure } from "../trpc.js"; export const utilizationCategoryRouter = createTRPCRouter({ - list: protectedProcedure + list: planningReadProcedure .input(z.object({ isActive: z.boolean().optional() }).optional()) .query(async ({ ctx, input }) => { return ctx.db.utilizationCategory.findMany({ @@ -20,7 +20,7 @@ export const utilizationCategoryRouter = createTRPCRouter({ }); }), - getById: protectedProcedure + getById: planningReadProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const cat = await findUniqueOrThrow(