From 05c07c6b6a5e0d8da6faf1c003ff93cce4e3efd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 20:04:17 +0200 Subject: [PATCH] refactor(api): extract management level procedures --- .../__tests__/management-level-router.test.ts | 180 ++++++++++++++ .../management-level-procedure-support.ts | 220 ++++++++++++++++++ packages/api/src/router/management-level.ts | 191 ++------------- 3 files changed, 423 insertions(+), 168 deletions(-) create mode 100644 packages/api/src/__tests__/management-level-router.test.ts create mode 100644 packages/api/src/router/management-level-procedure-support.ts diff --git a/packages/api/src/__tests__/management-level-router.test.ts b/packages/api/src/__tests__/management-level-router.test.ts new file mode 100644 index 0000000..1ad8d7e --- /dev/null +++ b/packages/api/src/__tests__/management-level-router.test.ts @@ -0,0 +1,180 @@ +import { SystemRole } from "@capakraken/shared"; +import { describe, expect, it, vi } from "vitest"; +import { managementLevelRouter } from "../router/management-level.js"; +import { createCallerFactory } from "../trpc.js"; + +const createCaller = createCallerFactory(managementLevelRouter); + +function createPlanningCaller(db: Record) { + return createCaller({ + session: { + user: { email: "planning@example.com", name: "Planning", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + db: db as never, + dbUser: { + id: "user_planning", + systemRole: SystemRole.MANAGER, + permissionOverrides: null, + }, + permissions: new Set(["view:planning"]), + }); +} + +function createAdminCaller(db: Record) { + return createCaller({ + session: { + user: { email: "admin@example.com", name: "Admin", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + db: db as never, + dbUser: { + id: "user_admin", + systemRole: SystemRole.ADMIN, + permissionOverrides: null, + }, + }); +} + +describe("managementLevel router", () => { + it("lists groups ordered by sort order", async () => { + const findMany = vi.fn().mockResolvedValue([ + { id: "grp_1", name: "Seniority", sortOrder: 1, levels: [] }, + ]); + + const caller = createPlanningCaller({ + managementLevelGroup: { findMany }, + }); + const result = await caller.listGroups(); + + expect(findMany).toHaveBeenCalledWith({ + include: { levels: { orderBy: { name: "asc" } } }, + orderBy: { sortOrder: "asc" }, + }); + expect(result).toHaveLength(1); + }); + + it("creates a group through the router", async () => { + const create = vi.fn().mockResolvedValue({ + id: "grp_1", + name: "Seniority", + targetPercentage: 0.2, + sortOrder: 1, + levels: [], + }); + + const caller = createAdminCaller({ + managementLevelGroup: { + findUnique: vi.fn().mockResolvedValue(null), + create, + }, + }); + const result = await caller.createGroup({ + name: "Seniority", + targetPercentage: 0.2, + sortOrder: 1, + }); + + expect(create).toHaveBeenCalledWith(expect.objectContaining({ + data: { + name: "Seniority", + targetPercentage: 0.2, + sortOrder: 1, + }, + include: { levels: true }, + })); + expect(result.id).toBe("grp_1"); + }); + + it("updates a group and preserves auditable before/after state", async () => { + const update = vi.fn().mockResolvedValue({ + id: "grp_1", + name: "Leadership", + targetPercentage: 0.3, + sortOrder: 2, + levels: [], + }); + + const caller = createAdminCaller({ + managementLevelGroup: { + findUnique: vi + .fn() + .mockResolvedValueOnce({ id: "grp_1", name: "Seniority", targetPercentage: 0.2, sortOrder: 1 }) + .mockResolvedValueOnce(null), + update, + }, + }); + const result = await caller.updateGroup({ + id: "grp_1", + data: { name: "Leadership", targetPercentage: 0.3, sortOrder: 2 }, + }); + + expect(update).toHaveBeenCalledWith({ + where: { id: "grp_1" }, + data: { name: "Leadership", targetPercentage: 0.3, sortOrder: 2 }, + include: { levels: true }, + }); + expect(result.name).toBe("Leadership"); + }); + + it("creates and updates a level through the router", async () => { + const create = vi.fn().mockResolvedValue({ + id: "lvl_1", + name: "Principal", + groupId: "grp_1", + }); + const update = vi.fn().mockResolvedValue({ + id: "lvl_1", + name: "Lead", + groupId: "grp_1", + }); + + const caller = createAdminCaller({ + managementLevelGroup: { + findUnique: vi.fn().mockResolvedValue({ id: "grp_1", name: "Seniority" }), + }, + managementLevel: { + findUnique: vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ id: "lvl_1", name: "Principal", groupId: "grp_1" }) + .mockResolvedValueOnce(null), + create, + update, + }, + }); + + const created = await caller.createLevel({ name: "Principal", groupId: "grp_1" }); + const updated = await caller.updateLevel({ id: "lvl_1", data: { name: "Lead" } }); + + expect(create).toHaveBeenCalledWith({ + data: { name: "Principal", groupId: "grp_1" }, + }); + expect(update).toHaveBeenCalledWith({ + where: { id: "lvl_1" }, + data: { name: "Lead" }, + }); + expect(created.id).toBe("lvl_1"); + expect(updated.name).toBe("Lead"); + }); + + it("deletes a deletable level and returns success", async () => { + const deleteFn = vi.fn().mockResolvedValue({}); + + const caller = createAdminCaller({ + managementLevel: { + findUnique: vi.fn().mockResolvedValue({ + id: "lvl_1", + name: "Lead", + _count: { resources: 0 }, + }), + delete: deleteFn, + }, + }); + + const result = await caller.deleteLevel({ id: "lvl_1" }); + + expect(deleteFn).toHaveBeenCalledWith({ where: { id: "lvl_1" } }); + expect(result).toEqual({ success: true }); + }); +}); diff --git a/packages/api/src/router/management-level-procedure-support.ts b/packages/api/src/router/management-level-procedure-support.ts new file mode 100644 index 0000000..4c28a8b --- /dev/null +++ b/packages/api/src/router/management-level-procedure-support.ts @@ -0,0 +1,220 @@ +import { + CreateManagementLevelGroupSchema, + CreateManagementLevelSchema, + UpdateManagementLevelGroupSchema, + UpdateManagementLevelSchema, +} from "@capakraken/shared"; +import { z } from "zod"; +import { findUniqueOrThrow } from "../db/helpers.js"; +import { createAuditEntry } from "../lib/audit.js"; +import type { TRPCContext } from "../trpc.js"; +import { + assertManagementLevelDeletable, + assertManagementLevelGroupNameAvailable, + assertManagementLevelNameAvailable, + buildManagementLevelCreateData, + buildManagementLevelGroupCreateData, + buildManagementLevelGroupUpdateData, + buildManagementLevelUpdateData, +} from "./management-level-support.js"; + +type ManagementLevelProcedureContext = Pick; + +function withAuditUser(userId: string | undefined) { + return userId ? { userId } : {}; +} + +export const managementLevelGroupIdInputSchema = z.object({ id: z.string() }); + +export const managementLevelGroupUpdateInputSchema = z.object({ + id: z.string(), + data: UpdateManagementLevelGroupSchema, +}); + +export const managementLevelIdInputSchema = z.object({ id: z.string() }); + +export const managementLevelUpdateInputSchema = z.object({ + id: z.string(), + data: UpdateManagementLevelSchema, +}); + +type ManagementLevelGroupIdInput = z.infer; +type ManagementLevelGroupCreateInput = z.infer; +type ManagementLevelGroupUpdateInput = z.infer; +type ManagementLevelCreateInput = z.infer; +type ManagementLevelUpdateInput = z.infer; +type ManagementLevelIdInput = z.infer; + +export async function listManagementLevelGroups( + ctx: ManagementLevelProcedureContext, +) { + return ctx.db.managementLevelGroup.findMany({ + include: { levels: { orderBy: { name: "asc" } } }, + orderBy: { sortOrder: "asc" }, + }); +} + +export async function getManagementLevelGroupById( + ctx: ManagementLevelProcedureContext, + input: ManagementLevelGroupIdInput, +) { + return findUniqueOrThrow( + ctx.db.managementLevelGroup.findUnique({ + where: { id: input.id }, + include: { + levels: { orderBy: { name: "asc" } }, + _count: { select: { resources: true } }, + }, + }), + "Management level group", + ); +} + +export async function createManagementLevelGroup( + ctx: ManagementLevelProcedureContext, + input: ManagementLevelGroupCreateInput, +) { + await assertManagementLevelGroupNameAvailable(ctx.db, input.name); + + const created = await ctx.db.managementLevelGroup.create({ + data: buildManagementLevelGroupCreateData(input), + include: { levels: true }, + }); + + void createAuditEntry({ + db: ctx.db, + entityType: "ManagementLevelGroup", + entityId: created.id, + entityName: created.name, + action: "CREATE", + ...withAuditUser(ctx.dbUser?.id), + after: created as unknown as Record, + source: "ui", + }); + + return created; +} + +export async function updateManagementLevelGroup( + ctx: ManagementLevelProcedureContext, + input: ManagementLevelGroupUpdateInput, +) { + const existing = await findUniqueOrThrow( + ctx.db.managementLevelGroup.findUnique({ where: { id: input.id } }), + "Group", + ); + + if (input.data.name && input.data.name !== existing.name) { + await assertManagementLevelGroupNameAvailable(ctx.db, input.data.name, existing.id); + } + + const updated = await ctx.db.managementLevelGroup.update({ + where: { id: input.id }, + data: buildManagementLevelGroupUpdateData(input.data), + include: { levels: true }, + }); + + void createAuditEntry({ + db: ctx.db, + entityType: "ManagementLevelGroup", + entityId: updated.id, + entityName: updated.name, + action: "UPDATE", + ...withAuditUser(ctx.dbUser?.id), + before: existing as unknown as Record, + after: updated as unknown as Record, + source: "ui", + }); + + return updated; +} + +export async function createManagementLevel( + ctx: ManagementLevelProcedureContext, + input: ManagementLevelCreateInput, +) { + await findUniqueOrThrow( + ctx.db.managementLevelGroup.findUnique({ where: { id: input.groupId } }), + "Group", + ); + await assertManagementLevelNameAvailable(ctx.db, input.name); + + const created = await ctx.db.managementLevel.create({ + data: buildManagementLevelCreateData(input), + }); + + void createAuditEntry({ + db: ctx.db, + entityType: "ManagementLevel", + entityId: created.id, + entityName: created.name, + action: "CREATE", + ...withAuditUser(ctx.dbUser?.id), + after: created as unknown as Record, + source: "ui", + }); + + return created; +} + +export async function updateManagementLevel( + ctx: ManagementLevelProcedureContext, + input: ManagementLevelUpdateInput, +) { + const existing = await findUniqueOrThrow( + ctx.db.managementLevel.findUnique({ where: { id: input.id } }), + "Level", + ); + + if (input.data.name && input.data.name !== existing.name) { + await assertManagementLevelNameAvailable(ctx.db, input.data.name, existing.id); + } + + const updated = await ctx.db.managementLevel.update({ + where: { id: input.id }, + data: buildManagementLevelUpdateData(input.data), + }); + + void createAuditEntry({ + db: ctx.db, + entityType: "ManagementLevel", + entityId: updated.id, + entityName: updated.name, + action: "UPDATE", + ...withAuditUser(ctx.dbUser?.id), + before: existing as unknown as Record, + after: updated as unknown as Record, + source: "ui", + }); + + return updated; +} + +export async function deleteManagementLevel( + ctx: ManagementLevelProcedureContext, + input: ManagementLevelIdInput, +) { + const level = await findUniqueOrThrow( + ctx.db.managementLevel.findUnique({ + where: { id: input.id }, + include: { _count: { select: { resources: true } } }, + }), + "Level", + ); + + assertManagementLevelDeletable(level); + await ctx.db.managementLevel.delete({ where: { id: input.id } }); + + void createAuditEntry({ + db: ctx.db, + entityType: "ManagementLevel", + entityId: level.id, + entityName: level.name, + action: "DELETE", + ...withAuditUser(ctx.dbUser?.id), + before: level as unknown as Record, + source: "ui", + }); + + return { success: true }; +} diff --git a/packages/api/src/router/management-level.ts b/packages/api/src/router/management-level.ts index 14b35a3..7a4ab87 100644 --- a/packages/api/src/router/management-level.ts +++ b/packages/api/src/router/management-level.ts @@ -1,195 +1,50 @@ import { CreateManagementLevelGroupSchema, CreateManagementLevelSchema, - UpdateManagementLevelGroupSchema, - UpdateManagementLevelSchema, } from "@capakraken/shared"; -import { z } from "zod"; -import { findUniqueOrThrow } from "../db/helpers.js"; -import { createAuditEntry } from "../lib/audit.js"; import { adminProcedure, createTRPCRouter, planningReadProcedure } from "../trpc.js"; import { - assertManagementLevelDeletable, - assertManagementLevelGroupNameAvailable, - assertManagementLevelNameAvailable, - buildManagementLevelCreateData, - buildManagementLevelGroupCreateData, - buildManagementLevelGroupUpdateData, - buildManagementLevelUpdateData, -} from "./management-level-support.js"; + createManagementLevel, + createManagementLevelGroup, + deleteManagementLevel, + getManagementLevelGroupById, + listManagementLevelGroups, + managementLevelGroupIdInputSchema, + managementLevelGroupUpdateInputSchema, + managementLevelIdInputSchema, + managementLevelUpdateInputSchema, + updateManagementLevel, + updateManagementLevelGroup, +} from "./management-level-procedure-support.js"; export const managementLevelRouter = createTRPCRouter({ // ─── Groups ───────────────────────────────────────────── - listGroups: planningReadProcedure.query(async ({ ctx }) => { - return ctx.db.managementLevelGroup.findMany({ - include: { levels: { orderBy: { name: "asc" } } }, - orderBy: { sortOrder: "asc" }, - }); - }), + listGroups: planningReadProcedure.query(({ ctx }) => listManagementLevelGroups(ctx)), getGroupById: planningReadProcedure - .input(z.object({ id: z.string() })) - .query(async ({ ctx, input }) => { - const group = await findUniqueOrThrow( - ctx.db.managementLevelGroup.findUnique({ - where: { id: input.id }, - include: { - levels: { orderBy: { name: "asc" } }, - _count: { select: { resources: true } }, - }, - }), - "Management level group", - ); - return group; - }), + .input(managementLevelGroupIdInputSchema) + .query(({ ctx, input }) => getManagementLevelGroupById(ctx, input)), createGroup: adminProcedure .input(CreateManagementLevelGroupSchema) - .mutation(async ({ ctx, input }) => { - await assertManagementLevelGroupNameAvailable(ctx.db, input.name); - const created = await ctx.db.managementLevelGroup.create({ - data: buildManagementLevelGroupCreateData(input), - include: { levels: true }, - }); - - void createAuditEntry({ - db: ctx.db, - entityType: "ManagementLevelGroup", - entityId: created.id, - entityName: created.name, - action: "CREATE", - userId: ctx.dbUser?.id, - after: created as unknown as Record, - source: "ui", - }); - - return created; - }), + .mutation(({ ctx, input }) => createManagementLevelGroup(ctx, input)), updateGroup: adminProcedure - .input(z.object({ id: z.string(), data: UpdateManagementLevelGroupSchema })) - .mutation(async ({ ctx, input }) => { - const existing = await findUniqueOrThrow( - ctx.db.managementLevelGroup.findUnique({ where: { id: input.id } }), - "Group", - ); - - if (input.data.name && input.data.name !== existing.name) { - await assertManagementLevelGroupNameAvailable(ctx.db, input.data.name, existing.id); - } - - const before = existing as unknown as Record; - - const updated = await ctx.db.managementLevelGroup.update({ - where: { id: input.id }, - data: buildManagementLevelGroupUpdateData(input.data), - include: { levels: true }, - }); - - void createAuditEntry({ - db: ctx.db, - entityType: "ManagementLevelGroup", - entityId: updated.id, - entityName: updated.name, - action: "UPDATE", - userId: ctx.dbUser?.id, - before, - after: updated as unknown as Record, - source: "ui", - }); - - return updated; - }), + .input(managementLevelGroupUpdateInputSchema) + .mutation(({ ctx, input }) => updateManagementLevelGroup(ctx, input)), // ─── Levels ───────────────────────────────────────────── createLevel: adminProcedure .input(CreateManagementLevelSchema) - .mutation(async ({ ctx, input }) => { - await findUniqueOrThrow( - ctx.db.managementLevelGroup.findUnique({ where: { id: input.groupId } }), - "Group", - ); - - await assertManagementLevelNameAvailable(ctx.db, input.name); - - const created = await ctx.db.managementLevel.create({ - data: buildManagementLevelCreateData(input), - }); - - void createAuditEntry({ - db: ctx.db, - entityType: "ManagementLevel", - entityId: created.id, - entityName: created.name, - action: "CREATE", - userId: ctx.dbUser?.id, - after: created as unknown as Record, - source: "ui", - }); - - return created; - }), + .mutation(({ ctx, input }) => createManagementLevel(ctx, input)), updateLevel: adminProcedure - .input(z.object({ id: z.string(), data: UpdateManagementLevelSchema })) - .mutation(async ({ ctx, input }) => { - const existing = await findUniqueOrThrow( - ctx.db.managementLevel.findUnique({ where: { id: input.id } }), - "Level", - ); - - if (input.data.name && input.data.name !== existing.name) { - await assertManagementLevelNameAvailable(ctx.db, input.data.name, existing.id); - } - - const before = existing as unknown as Record; - - const updated = await ctx.db.managementLevel.update({ - where: { id: input.id }, - data: buildManagementLevelUpdateData(input.data), - }); - - void createAuditEntry({ - db: ctx.db, - entityType: "ManagementLevel", - entityId: updated.id, - entityName: updated.name, - action: "UPDATE", - userId: ctx.dbUser?.id, - before, - after: updated as unknown as Record, - source: "ui", - }); - - return updated; - }), + .input(managementLevelUpdateInputSchema) + .mutation(({ ctx, input }) => updateManagementLevel(ctx, input)), deleteLevel: adminProcedure - .input(z.object({ id: z.string() })) - .mutation(async ({ ctx, input }) => { - const level = await findUniqueOrThrow( - ctx.db.managementLevel.findUnique({ - where: { id: input.id }, - include: { _count: { select: { resources: true } } }, - }), - "Level", - ); - assertManagementLevelDeletable(level); - await ctx.db.managementLevel.delete({ where: { id: input.id } }); - - void createAuditEntry({ - db: ctx.db, - entityType: "ManagementLevel", - entityId: level.id, - entityName: level.name, - action: "DELETE", - userId: ctx.dbUser?.id, - before: level as unknown as Record, - source: "ui", - }); - - return { success: true }; - }), + .input(managementLevelIdInputSchema) + .mutation(({ ctx, input }) => deleteManagementLevel(ctx, input)), });