diff --git a/docs/api-router-procedure-support-backlog.md b/docs/api-router-procedure-support-backlog.md index 4b51423..8b6ca85 100644 --- a/docs/api-router-procedure-support-backlog.md +++ b/docs/api-router-procedure-support-backlog.md @@ -26,6 +26,7 @@ Done - `audit-log` - `calculation-rules` - `webhook` +- `role` Ready next - none in the conflict-safe backlog diff --git a/packages/api/src/__tests__/assistant-tools-master-data-roles-read.test.ts b/packages/api/src/__tests__/assistant-tools-master-data-roles-read.test.ts new file mode 100644 index 0000000..c1606b9 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-master-data-roles-read.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PermissionKey, SystemRole } from "@capakraken/shared"; + +vi.mock("@capakraken/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + countPlanningEntries: vi.fn().mockResolvedValue({ countsByRoleId: new Map() }), + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + getDashboardPeakTimes: vi.fn().mockResolvedValue([]), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + }; +}); + +import { countPlanningEntries } from "@capakraken/application"; +import { executeTool } from "../router/assistant-tools.js"; +import { createToolContext } from "./assistant-tools-master-data-read-test-helpers.js"; + +describe("assistant master data roles read tool", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(countPlanningEntries).mockResolvedValue({ countsByRoleId: new Map() }); + }); + + it("routes role reads through their backing router", async () => { + const db = { + role: { + findMany: vi.fn().mockResolvedValue([ + { + id: "role_anim", + name: "Animation", + color: "#112233", + _count: { resourceRoles: 2 }, + }, + ]), + }, + }; + const ctx = createToolContext(db, { + userRole: SystemRole.CONTROLLER, + permissions: [PermissionKey.VIEW_PLANNING], + }); + + const rolesResult = await executeTool("list_roles", "{}", ctx); + + expect(db.role.findMany).toHaveBeenCalledWith({ + where: {}, + include: { + _count: { + select: { resourceRoles: true }, + }, + }, + orderBy: { name: "asc" }, + }); + expect(JSON.parse(rolesResult.content)).toEqual([ + { + id: "role_anim", + name: "Animation", + color: "#112233", + }, + ]); + }); +}); diff --git a/packages/api/src/__tests__/role-procedure-support.test.ts b/packages/api/src/__tests__/role-procedure-support.test.ts index ea5ef24..c1929ce 100644 --- a/packages/api/src/__tests__/role-procedure-support.test.ts +++ b/packages/api/src/__tests__/role-procedure-support.test.ts @@ -4,10 +4,18 @@ import { createRole, deactivateRole, deleteRole, + getRoleById, + getRoleByIdentifier, + listRoles, + resolveRoleByIdentifier, RoleIdInputSchema, + RoleIdentifierInputSchema, + RoleListInputSchema, + ResolveRoleIdentifierInputSchema, UpdateRoleProcedureInputSchema, updateRole, } from "../router/role-procedure-support.js"; +import { RESOURCE_BRIEF_SELECT } from "../db/selects.js"; const { countPlanningEntries } = vi.hoisted(() => ({ countPlanningEntries: vi.fn(), @@ -49,6 +57,163 @@ describe("role procedure support", () => { emitRoleUpdated.mockReset(); }); + it("lists roles with planning entry counts", async () => { + countPlanningEntries.mockResolvedValue({ + countsByRoleId: new Map([["role_fx", 2]]), + }); + const db = { + role: { + findMany: vi.fn().mockResolvedValue([ + { + id: "role_fx", + name: "FX", + _count: { resourceRoles: 1 }, + }, + ]), + }, + demandRequirement: {}, + assignment: {}, + }; + + const result = await listRoles( + createContext(db), + RoleListInputSchema.parse({ search: "FX" }), + ); + + expect(result).toEqual([ + { + id: "role_fx", + name: "FX", + _count: { resourceRoles: 1, allocations: 2 }, + }, + ]); + expect(db.role.findMany).toHaveBeenCalledWith({ + where: { name: { contains: "FX", mode: "insensitive" } }, + include: { _count: { select: { resourceRoles: true } } }, + orderBy: { name: "asc" }, + }); + }); + + it("resolves roles by identifier for protected read paths", async () => { + const db = { + role: { + findUnique: vi.fn().mockResolvedValue({ + id: "role_fx", + name: "FX", + color: "#111111", + isActive: true, + }), + }, + }; + + const result = await resolveRoleByIdentifier( + createContext(db), + ResolveRoleIdentifierInputSchema.parse({ identifier: "role_fx" }), + ); + + expect(result).toEqual({ + id: "role_fx", + name: "FX", + color: "#111111", + isActive: true, + }); + expect(db.role.findUnique).toHaveBeenCalledWith({ + where: { id: "role_fx" }, + select: { + id: true, + name: true, + color: true, + isActive: true, + }, + }); + }); + + it("loads a role by identifier and attaches planning counts", async () => { + countPlanningEntries.mockResolvedValue({ + countsByRoleId: new Map([["role_fx", 3]]), + }); + const db = { + role: { + findUnique: vi.fn().mockResolvedValue({ + id: "role_fx", + name: "FX", + description: null, + color: "#111111", + isActive: true, + _count: { resourceRoles: 2 }, + }), + }, + demandRequirement: {}, + assignment: {}, + }; + + const result = await getRoleByIdentifier( + createContext(db), + RoleIdentifierInputSchema.parse({ identifier: "role_fx" }), + ); + + expect(result).toEqual({ + id: "role_fx", + name: "FX", + description: null, + color: "#111111", + isActive: true, + _count: { resourceRoles: 2, allocations: 3 }, + }); + expect(db.role.findUnique).toHaveBeenCalledWith({ + where: { id: "role_fx" }, + select: { + id: true, + name: true, + description: true, + color: true, + isActive: true, + _count: { select: { resourceRoles: true } }, + }, + }); + }); + + it("loads a role by id with resource role details and planning counts", async () => { + countPlanningEntries.mockResolvedValue({ + countsByRoleId: new Map([["role_fx", 1]]), + }); + const db = { + role: { + findUnique: vi.fn().mockResolvedValue({ + id: "role_fx", + name: "FX", + _count: { resourceRoles: 1 }, + resourceRoles: [], + }), + }, + demandRequirement: {}, + assignment: {}, + }; + + const result = await getRoleById( + createContext(db), + RoleIdInputSchema.parse({ id: "role_fx" }), + ); + + expect(result).toEqual({ + id: "role_fx", + name: "FX", + _count: { resourceRoles: 1, allocations: 1 }, + resourceRoles: [], + }); + expect(db.role.findUnique).toHaveBeenCalledWith({ + where: { id: "role_fx" }, + include: { + _count: { select: { resourceRoles: true } }, + resourceRoles: { + include: { + resource: { select: RESOURCE_BRIEF_SELECT }, + }, + }, + }, + }); + }); + it("creates roles with audit and zero allocation counts", async () => { const role = { id: "role_fx", diff --git a/packages/api/src/router/role-procedure-support.ts b/packages/api/src/router/role-procedure-support.ts index 77c8b2c..6093151 100644 --- a/packages/api/src/router/role-procedure-support.ts +++ b/packages/api/src/router/role-procedure-support.ts @@ -2,17 +2,34 @@ import { CreateRoleSchema, PermissionKey, UpdateRoleSchema } from "@capakraken/s import { TRPCError } from "@trpc/server"; 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 type { TRPCContext } from "../trpc.js"; import { requirePermission } from "../trpc.js"; import { appendZeroAllocationCount, assertRoleNameAvailable, + attachRolePlanningEntryCounts, attachSingleRolePlanningEntryCount, buildRoleCreateData, buildRoleUpdateData, + buildRoleListWhere, + findRoleByIdentifier, } from "./role-support.js"; +export const RoleListInputSchema = z.object({ + isActive: z.boolean().optional(), + search: z.string().optional(), +}); + +export const RoleIdentifierInputSchema = z.object({ + identifier: z.string(), +}); + +export const ResolveRoleIdentifierInputSchema = z.object({ + identifier: z.string().trim().min(1), +}); + export const RoleIdInputSchema = z.object({ id: z.string(), }); @@ -26,6 +43,91 @@ type RoleMutationContext = Pick & { permissions: Set; }; +type RoleReadContext = Pick; + +export async function listRoles( + ctx: RoleReadContext, + input: z.infer, +) { + const roles = await ctx.db.role.findMany({ + where: buildRoleListWhere(input), + include: { + _count: { + select: { resourceRoles: true }, + }, + }, + orderBy: { name: "asc" }, + }); + + return attachRolePlanningEntryCounts(ctx.db, roles); +} + +export async function resolveRoleByIdentifier( + ctx: RoleReadContext, + input: z.infer, +) { + const select = { + id: true, + name: true, + color: true, + isActive: true, + } as const; + + return findRoleByIdentifier<{ + id: string; + name: string; + color: string | null; + isActive: boolean; + }>(ctx.db, input.identifier, select); +} + +export async function getRoleByIdentifier( + ctx: RoleReadContext, + input: z.infer, +) { + const select = { + id: true, + name: true, + description: true, + color: true, + isActive: true, + _count: { select: { resourceRoles: true } }, + } as const; + + const role = await findRoleByIdentifier<{ + id: string; + name: string; + description: string | null; + color: string | null; + isActive: boolean; + _count: { resourceRoles: number }; + }>(ctx.db, input.identifier, select); + + return attachSingleRolePlanningEntryCount(ctx.db, role); +} + +export async function getRoleById( + ctx: RoleReadContext, + input: z.infer, +) { + const role = await findUniqueOrThrow( + ctx.db.role.findUnique({ + where: { id: input.id }, + include: { + _count: { select: { resourceRoles: true } }, + resourceRoles: { + include: { + resource: { select: RESOURCE_BRIEF_SELECT }, + }, + }, + }, + }), + "Role", + ); + + return attachSingleRolePlanningEntryCount(ctx.db, role); +} + export async function createRole( ctx: RoleMutationContext, input: z.infer, diff --git a/packages/api/src/router/role.ts b/packages/api/src/router/role.ts index 7673678..ddaea40 100644 --- a/packages/api/src/router/role.ts +++ b/packages/api/src/router/role.ts @@ -1,7 +1,4 @@ import { CreateRoleSchema } from "@capakraken/shared"; -import { z } from "zod"; -import { findUniqueOrThrow } from "../db/helpers.js"; -import { RESOURCE_BRIEF_SELECT } from "../db/selects.js"; import { createTRPCRouter, managerProcedure, @@ -12,100 +9,34 @@ import { createRole, deactivateRole, deleteRole, + getRoleById, + getRoleByIdentifier, + listRoles, + ResolveRoleIdentifierInputSchema, + resolveRoleByIdentifier, + RoleIdentifierInputSchema, RoleIdInputSchema, + RoleListInputSchema, UpdateRoleProcedureInputSchema, updateRole, } from "./role-procedure-support.js"; -import { - attachRolePlanningEntryCounts, - attachSingleRolePlanningEntryCount, - buildRoleListWhere, - findRoleByIdentifier, -} from "./role-support.js"; export const roleRouter = createTRPCRouter({ list: planningReadProcedure - .input( - z.object({ - isActive: z.boolean().optional(), - search: z.string().optional(), - }), - ) - .query(async ({ ctx, input }) => { - const roles = await ctx.db.role.findMany({ - where: buildRoleListWhere(input), - include: { - _count: { - select: { resourceRoles: true }, - }, - }, - orderBy: { name: "asc" }, - }); - - return attachRolePlanningEntryCounts(ctx.db, roles); - }), + .input(RoleListInputSchema) + .query(({ ctx, input }) => listRoles(ctx, input)), resolveByIdentifier: protectedProcedure - .input(z.object({ identifier: z.string().trim().min(1) })) - .query(async ({ ctx, input }) => { - const select = { - id: true, - name: true, - color: true, - isActive: true, - } as const; - - return findRoleByIdentifier<{ - id: string; - name: string; - color: string | null; - isActive: boolean; - }>(ctx.db, input.identifier, select); - }), + .input(ResolveRoleIdentifierInputSchema) + .query(({ ctx, input }) => resolveRoleByIdentifier(ctx, input)), getByIdentifier: planningReadProcedure - .input(z.object({ identifier: z.string() })) - .query(async ({ ctx, input }) => { - const select = { - id: true, - name: true, - description: true, - color: true, - isActive: true, - _count: { select: { resourceRoles: true } }, - } as const; - - const role = await findRoleByIdentifier<{ - id: string; - name: string; - description: string | null; - color: string | null; - isActive: boolean; - _count: { resourceRoles: number }; - }>(ctx.db, input.identifier, select); - return attachSingleRolePlanningEntryCount(ctx.db, role); - }), + .input(RoleIdentifierInputSchema) + .query(({ ctx, input }) => getRoleByIdentifier(ctx, input)), getById: planningReadProcedure - .input(z.object({ id: z.string() })) - .query(async ({ ctx, input }) => { - const role = await findUniqueOrThrow( - ctx.db.role.findUnique({ - where: { id: input.id }, - include: { - _count: { select: { resourceRoles: true } }, - resourceRoles: { - include: { - resource: { select: RESOURCE_BRIEF_SELECT }, - }, - }, - }, - }), - "Role", - ); - - return attachSingleRolePlanningEntryCount(ctx.db, role); - }), + .input(RoleIdInputSchema) + .query(({ ctx, input }) => getRoleById(ctx, input)), create: managerProcedure .input(CreateRoleSchema)