diff --git a/packages/api/src/__tests__/role-procedure-support.test.ts b/packages/api/src/__tests__/role-procedure-support.test.ts new file mode 100644 index 0000000..ea5ef24 --- /dev/null +++ b/packages/api/src/__tests__/role-procedure-support.test.ts @@ -0,0 +1,235 @@ +import { PermissionKey } from "@capakraken/shared"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + createRole, + deactivateRole, + deleteRole, + RoleIdInputSchema, + UpdateRoleProcedureInputSchema, + updateRole, +} from "../router/role-procedure-support.js"; + +const { countPlanningEntries } = vi.hoisted(() => ({ + countPlanningEntries: vi.fn(), +})); + +const { emitRoleCreated, emitRoleDeleted, emitRoleUpdated } = vi.hoisted(() => ({ + emitRoleCreated: vi.fn(), + emitRoleDeleted: vi.fn(), + emitRoleUpdated: vi.fn(), +})); + +vi.mock("@capakraken/application", () => ({ + countPlanningEntries, +})); + +vi.mock("../sse/event-bus.js", () => ({ + emitRoleCreated, + emitRoleDeleted, + emitRoleUpdated, +})); + +function createContext(db: Record, permissions: PermissionKey[] = [PermissionKey.MANAGE_ROLES]) { + return { + db: db as never, + dbUser: { + id: "user_manager", + systemRole: "MANAGER", + permissionOverrides: null, + }, + permissions: new Set(permissions), + } as const; +} + +describe("role procedure support", () => { + beforeEach(() => { + countPlanningEntries.mockReset(); + emitRoleCreated.mockReset(); + emitRoleDeleted.mockReset(); + emitRoleUpdated.mockReset(); + }); + + it("creates roles with audit and zero allocation counts", async () => { + const role = { + id: "role_fx", + name: "FX", + description: null, + color: "#111111", + _count: { resourceRoles: 2 }, + }; + const db = { + role: { + findUnique: vi.fn().mockResolvedValue(null), + create: vi.fn().mockResolvedValue(role), + }, + auditLog: { + create: vi.fn().mockResolvedValue(undefined), + }, + }; + + const result = await createRole(createContext(db), { + name: "FX", + description: undefined, + color: "#111111", + }); + + expect(result).toEqual({ + ...role, + _count: { resourceRoles: 2, allocations: 0 }, + }); + expect(db.role.create).toHaveBeenCalledWith({ + data: { + name: "FX", + description: null, + color: "#111111", + }, + include: { _count: { select: { resourceRoles: true } } }, + }); + expect(db.auditLog.create).toHaveBeenCalledWith({ + data: { + entityType: "Role", + entityId: "role_fx", + action: "CREATE", + changes: { after: role }, + }, + }); + expect(emitRoleCreated).toHaveBeenCalledWith({ id: "role_fx", name: "FX" }); + }); + + it("requires manage roles permission for mutations", async () => { + const ctx = createContext({ + role: { + findUnique: vi.fn(), + }, + auditLog: { + create: vi.fn(), + }, + }, []); + + await expect(createRole(ctx, { + name: "FX", + description: undefined, + color: undefined, + })).rejects.toMatchObject({ + code: "FORBIDDEN", + }); + }); + + it("updates roles and emits the updated identifier payload", async () => { + countPlanningEntries.mockResolvedValue({ + countsByRoleId: new Map([["role_fx", 3]]), + }); + const existing = { + id: "role_fx", + name: "FX", + description: "Old", + color: "#111111", + isActive: true, + _count: { resourceRoles: 1 }, + }; + const updated = { + id: "role_fx", + name: "FX Lead", + description: "Updated", + color: "#222222", + isActive: true, + _count: { resourceRoles: 1 }, + }; + const db = { + role: { + findUnique: vi.fn() + .mockResolvedValueOnce(existing) + .mockResolvedValueOnce(null), + update: vi.fn().mockResolvedValue(updated), + }, + demandRequirement: {}, + assignment: {}, + auditLog: { + create: vi.fn().mockResolvedValue(undefined), + }, + }; + + const result = await updateRole(createContext(db), UpdateRoleProcedureInputSchema.parse({ + id: "role_fx", + data: { + name: "FX Lead", + description: "Updated", + color: "#222222", + }, + })); + + expect(result).toEqual({ + ...updated, + _count: { resourceRoles: 1, allocations: 3 }, + }); + expect(db.role.update).toHaveBeenCalledWith({ + where: { id: "role_fx" }, + data: { + name: "FX Lead", + description: "Updated", + color: "#222222", + }, + include: { _count: { select: { resourceRoles: true } } }, + }); + expect(emitRoleUpdated).toHaveBeenCalledWith({ id: "role_fx", name: "FX Lead" }); + }); + + it("blocks deletion when the role is still referenced", async () => { + countPlanningEntries.mockResolvedValue({ + countsByRoleId: new Map([["role_fx", 2]]), + }); + const db = { + role: { + findUnique: vi.fn().mockResolvedValue({ + id: "role_fx", + name: "FX", + _count: { resourceRoles: 1 }, + }), + delete: vi.fn(), + }, + demandRequirement: {}, + assignment: {}, + auditLog: { + create: vi.fn(), + }, + }; + + await expect(deleteRole(createContext(db), RoleIdInputSchema.parse({ id: "role_fx" }))).rejects.toMatchObject({ + code: "PRECONDITION_FAILED", + message: expect.stringContaining("Deactivate it instead"), + }); + expect(db.role.delete).not.toHaveBeenCalled(); + expect(emitRoleDeleted).not.toHaveBeenCalled(); + }); + + it("deactivates roles and preserves planning counts in the response", async () => { + countPlanningEntries.mockResolvedValue({ + countsByRoleId: new Map([["role_fx", 4]]), + }); + const db = { + role: { + update: vi.fn().mockResolvedValue({ + id: "role_fx", + name: "FX", + isActive: false, + _count: { resourceRoles: 2 }, + }), + }, + demandRequirement: {}, + assignment: {}, + auditLog: { + create: vi.fn().mockResolvedValue(undefined), + }, + }; + + const result = await deactivateRole(createContext(db), RoleIdInputSchema.parse({ id: "role_fx" })); + + expect(result).toEqual({ + id: "role_fx", + name: "FX", + isActive: false, + _count: { resourceRoles: 2, allocations: 4 }, + }); + expect(emitRoleUpdated).toHaveBeenCalledWith({ id: "role_fx", isActive: false }); + }); +}); diff --git a/packages/api/src/router/role-procedure-support.ts b/packages/api/src/router/role-procedure-support.ts new file mode 100644 index 0000000..77c8b2c --- /dev/null +++ b/packages/api/src/router/role-procedure-support.ts @@ -0,0 +1,153 @@ +import { CreateRoleSchema, PermissionKey, UpdateRoleSchema } from "@capakraken/shared"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { findUniqueOrThrow } from "../db/helpers.js"; +import { emitRoleCreated, emitRoleDeleted, emitRoleUpdated } from "../sse/event-bus.js"; +import type { TRPCContext } from "../trpc.js"; +import { requirePermission } from "../trpc.js"; +import { + appendZeroAllocationCount, + assertRoleNameAvailable, + attachSingleRolePlanningEntryCount, + buildRoleCreateData, + buildRoleUpdateData, +} from "./role-support.js"; + +export const RoleIdInputSchema = z.object({ + id: z.string(), +}); + +export const UpdateRoleProcedureInputSchema = z.object({ + id: z.string(), + data: UpdateRoleSchema, +}); + +type RoleMutationContext = Pick & { + permissions: Set; +}; + +export async function createRole( + ctx: RoleMutationContext, + input: z.infer, +) { + requirePermission(ctx, PermissionKey.MANAGE_ROLES); + await assertRoleNameAvailable(ctx.db, input.name); + + const role = await ctx.db.role.create({ + data: buildRoleCreateData(input), + include: { _count: { select: { resourceRoles: true } } }, + }); + + await ctx.db.auditLog.create({ + data: { + entityType: "Role", + entityId: role.id, + action: "CREATE", + changes: { after: role }, + }, + }); + + emitRoleCreated({ id: role.id, name: role.name }); + + return appendZeroAllocationCount(role); +} + +export async function updateRole( + ctx: RoleMutationContext, + input: z.infer, +) { + requirePermission(ctx, PermissionKey.MANAGE_ROLES); + const existing = await findUniqueOrThrow( + ctx.db.role.findUnique({ where: { id: input.id } }), + "Role", + ); + + if (input.data.name && input.data.name !== existing.name) { + await assertRoleNameAvailable(ctx.db, input.data.name, input.id); + } + + const updated = await ctx.db.role.update({ + where: { id: input.id }, + data: buildRoleUpdateData(input.data), + include: { _count: { select: { resourceRoles: true } } }, + }); + + await ctx.db.auditLog.create({ + data: { + entityType: "Role", + entityId: input.id, + action: "UPDATE", + changes: { before: existing, after: updated }, + }, + }); + + emitRoleUpdated({ id: updated.id, name: updated.name }); + + return attachSingleRolePlanningEntryCount(ctx.db, updated); +} + +export async function deleteRole( + ctx: RoleMutationContext, + input: z.infer, +) { + requirePermission(ctx, PermissionKey.MANAGE_ROLES); + const role = await findUniqueOrThrow( + ctx.db.role.findUnique({ + where: { id: input.id }, + include: { _count: { select: { resourceRoles: true } } }, + }), + "Role", + ); + + const roleWithCounts = await attachSingleRolePlanningEntryCount(ctx.db, role); + + if ( + roleWithCounts._count.resourceRoles > 0 || + roleWithCounts._count.allocations > 0 + ) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: `Cannot delete role assigned to ${roleWithCounts._count.resourceRoles} resource(s) and ${roleWithCounts._count.allocations} allocation(s). Deactivate it instead.`, + }); + } + + await ctx.db.role.delete({ where: { id: input.id } }); + + await ctx.db.auditLog.create({ + data: { + entityType: "Role", + entityId: input.id, + action: "DELETE", + changes: { before: role }, + }, + }); + + emitRoleDeleted(input.id); + + return { success: true }; +} + +export async function deactivateRole( + ctx: RoleMutationContext, + input: z.infer, +) { + requirePermission(ctx, PermissionKey.MANAGE_ROLES); + const role = await ctx.db.role.update({ + where: { id: input.id }, + data: { isActive: false }, + include: { _count: { select: { resourceRoles: true } } }, + }); + + await ctx.db.auditLog.create({ + data: { + entityType: "Role", + entityId: input.id, + action: "UPDATE", + changes: { after: { isActive: false } }, + }, + }); + + emitRoleUpdated({ id: role.id, isActive: false }); + + return attachSingleRolePlanningEntryCount(ctx.db, role); +} diff --git a/packages/api/src/router/role.ts b/packages/api/src/router/role.ts index 152b64a..7673678 100644 --- a/packages/api/src/router/role.ts +++ b/packages/api/src/router/role.ts @@ -1,24 +1,25 @@ -import { CreateRoleSchema, PermissionKey, UpdateRoleSchema } from "@capakraken/shared"; -import { TRPCError } from "@trpc/server"; +import { CreateRoleSchema } from "@capakraken/shared"; 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, planningReadProcedure, protectedProcedure, - requirePermission, } from "../trpc.js"; import { - appendZeroAllocationCount, - assertRoleNameAvailable, + createRole, + deactivateRole, + deleteRole, + RoleIdInputSchema, + UpdateRoleProcedureInputSchema, + updateRole, +} from "./role-procedure-support.js"; +import { attachRolePlanningEntryCounts, attachSingleRolePlanningEntryCount, - buildRoleCreateData, buildRoleListWhere, - buildRoleUpdateData, findRoleByIdentifier, } from "./role-support.js"; @@ -108,123 +109,17 @@ export const roleRouter = createTRPCRouter({ create: managerProcedure .input(CreateRoleSchema) - .mutation(async ({ ctx, input }) => { - requirePermission(ctx, PermissionKey.MANAGE_ROLES); - await assertRoleNameAvailable(ctx.db, input.name); - - const role = await ctx.db.role.create({ - data: buildRoleCreateData(input), - include: { _count: { select: { resourceRoles: true } } }, - }); - - await ctx.db.auditLog.create({ - data: { - entityType: "Role", - entityId: role.id, - action: "CREATE", - changes: { after: role }, - }, - }); - - emitRoleCreated({ id: role.id, name: role.name }); - - return appendZeroAllocationCount(role); - }), + .mutation(({ ctx, input }) => createRole(ctx, input)), update: managerProcedure - .input(z.object({ id: z.string(), data: UpdateRoleSchema })) - .mutation(async ({ ctx, input }) => { - requirePermission(ctx, PermissionKey.MANAGE_ROLES); - const existing = await findUniqueOrThrow( - ctx.db.role.findUnique({ where: { id: input.id } }), - "Role", - ); - - if (input.data.name && input.data.name !== existing.name) { - await assertRoleNameAvailable(ctx.db, input.data.name, input.id); - } - - const updated = await ctx.db.role.update({ - where: { id: input.id }, - data: buildRoleUpdateData(input.data), - include: { _count: { select: { resourceRoles: true } } }, - }); - - await ctx.db.auditLog.create({ - data: { - entityType: "Role", - entityId: input.id, - action: "UPDATE", - changes: { before: existing, after: updated }, - }, - }); - - emitRoleUpdated({ id: updated.id, name: updated.name }); - - return attachSingleRolePlanningEntryCount(ctx.db, updated); - }), + .input(UpdateRoleProcedureInputSchema) + .mutation(({ ctx, input }) => updateRole(ctx, input)), delete: managerProcedure - .input(z.object({ id: z.string() })) - .mutation(async ({ ctx, input }) => { - requirePermission(ctx, PermissionKey.MANAGE_ROLES); - const role = await findUniqueOrThrow( - ctx.db.role.findUnique({ - where: { id: input.id }, - include: { _count: { select: { resourceRoles: true } } }, - }), - "Role", - ); - - const roleWithCounts = await attachSingleRolePlanningEntryCount(ctx.db, role); - - if ( - roleWithCounts._count.resourceRoles > 0 || - roleWithCounts._count.allocations > 0 - ) { - throw new TRPCError({ - code: "PRECONDITION_FAILED", - message: `Cannot delete role assigned to ${roleWithCounts._count.resourceRoles} resource(s) and ${roleWithCounts._count.allocations} allocation(s). Deactivate it instead.`, - }); - } - - await ctx.db.role.delete({ where: { id: input.id } }); - - await ctx.db.auditLog.create({ - data: { - entityType: "Role", - entityId: input.id, - action: "DELETE", - changes: { before: role }, - }, - }); - - emitRoleDeleted(input.id); - - return { success: true }; - }), + .input(RoleIdInputSchema) + .mutation(({ ctx, input }) => deleteRole(ctx, input)), deactivate: managerProcedure - .input(z.object({ id: z.string() })) - .mutation(async ({ ctx, input }) => { - requirePermission(ctx, PermissionKey.MANAGE_ROLES); - const role = await ctx.db.role.update({ - where: { id: input.id }, - data: { isActive: false }, - include: { _count: { select: { resourceRoles: true } } }, - }); - - await ctx.db.auditLog.create({ - data: { - entityType: "Role", - entityId: input.id, - action: "UPDATE", - changes: { after: { isActive: false } }, - }, - }); - - emitRoleUpdated({ id: role.id, isActive: false }); - - return attachSingleRolePlanningEntryCount(ctx.db, role); - }), + .input(RoleIdInputSchema) + .mutation(({ ctx, input }) => deactivateRole(ctx, input)), });