diff --git a/packages/api/src/__tests__/management-level-support.test.ts b/packages/api/src/__tests__/management-level-support.test.ts new file mode 100644 index 0000000..2cf316b --- /dev/null +++ b/packages/api/src/__tests__/management-level-support.test.ts @@ -0,0 +1,71 @@ +import { TRPCError } from "@trpc/server"; +import { describe, expect, it, vi } from "vitest"; +import { + assertManagementLevelDeletable, + assertManagementLevelGroupNameAvailable, + assertManagementLevelNameAvailable, + buildManagementLevelCreateData, + buildManagementLevelGroupCreateData, + buildManagementLevelGroupUpdateData, + buildManagementLevelUpdateData, +} from "../router/management-level-support.js"; + +describe("management level support", () => { + it("rejects duplicate group names outside the ignored id", async () => { + const db = { + managementLevelGroup: { + findUnique: vi.fn().mockResolvedValue({ id: "group_existing", name: "Team Leads" }), + }, + } as never; + + await expect(assertManagementLevelGroupNameAvailable(db, "Team Leads")).rejects.toBeInstanceOf(TRPCError); + }); + + it("rejects duplicate level names outside the ignored id", async () => { + const db = { + managementLevel: { + findUnique: vi.fn().mockResolvedValue({ id: "level_existing", name: "Senior Team Lead" }), + }, + } as never; + + await expect(assertManagementLevelNameAvailable(db, "Senior Team Lead")).rejects.toBeInstanceOf(TRPCError); + }); + + it("builds create and sparse update payloads", () => { + expect(buildManagementLevelGroupCreateData({ + name: "Team Leads", + targetPercentage: 0.72, + sortOrder: 10, + })).toEqual({ + name: "Team Leads", + targetPercentage: 0.72, + sortOrder: 10, + }); + + expect(buildManagementLevelGroupUpdateData({ + targetPercentage: 0.8, + })).toEqual({ + targetPercentage: 0.8, + }); + + expect(buildManagementLevelCreateData({ + name: "Senior Team Lead", + groupId: "group_1", + })).toEqual({ + name: "Senior Team Lead", + groupId: "group_1", + }); + + expect(buildManagementLevelUpdateData({ + groupId: "group_2", + })).toEqual({ + groupId: "group_2", + }); + }); + + it("rejects deletion when a level is still assigned", () => { + expect(() => assertManagementLevelDeletable({ + _count: { resources: 2 }, + })).toThrow(TRPCError); + }); +}); diff --git a/packages/api/src/router/management-level-support.ts b/packages/api/src/router/management-level-support.ts new file mode 100644 index 0000000..8312033 --- /dev/null +++ b/packages/api/src/router/management-level-support.ts @@ -0,0 +1,100 @@ +import type { Prisma, PrismaClient } from "@capakraken/db"; +import { + CreateManagementLevelGroupSchema, + CreateManagementLevelSchema, + UpdateManagementLevelGroupSchema, + UpdateManagementLevelSchema, +} from "@capakraken/shared"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; + +type ManagementLevelGroupDb = Pick; +type ManagementLevelDb = Pick; + +type ManagementLevelDeleteRecord = { + _count: { + resources: number; + }; +}; + +type CreateManagementLevelGroupInput = z.infer; +type UpdateManagementLevelGroupInput = z.infer; +type CreateManagementLevelInput = z.infer; +type UpdateManagementLevelInput = z.infer; + +export async function assertManagementLevelGroupNameAvailable( + db: ManagementLevelGroupDb, + name: string, + ignoreId?: string, +): Promise { + const existing = await db.managementLevelGroup.findUnique({ where: { name } }); + if (existing && existing.id !== ignoreId) { + throw new TRPCError({ + code: "CONFLICT", + message: `Group "${name}" already exists`, + }); + } +} + +export async function assertManagementLevelNameAvailable( + db: ManagementLevelDb, + name: string, + ignoreId?: string, +): Promise { + const existing = await db.managementLevel.findUnique({ where: { name } }); + if (existing && existing.id !== ignoreId) { + throw new TRPCError({ + code: "CONFLICT", + message: `Level "${name}" already exists`, + }); + } +} + +export function buildManagementLevelGroupCreateData( + input: CreateManagementLevelGroupInput, +): Prisma.ManagementLevelGroupUncheckedCreateInput { + return { + name: input.name, + targetPercentage: input.targetPercentage, + sortOrder: input.sortOrder, + }; +} + +export function buildManagementLevelGroupUpdateData( + input: UpdateManagementLevelGroupInput, +): Prisma.ManagementLevelGroupUncheckedUpdateInput { + return { + ...(input.name !== undefined ? { name: input.name } : {}), + ...(input.targetPercentage !== undefined ? { targetPercentage: input.targetPercentage } : {}), + ...(input.sortOrder !== undefined ? { sortOrder: input.sortOrder } : {}), + }; +} + +export function buildManagementLevelCreateData( + input: CreateManagementLevelInput, +): Prisma.ManagementLevelUncheckedCreateInput { + return { + name: input.name, + groupId: input.groupId, + }; +} + +export function buildManagementLevelUpdateData( + input: UpdateManagementLevelInput, +): Prisma.ManagementLevelUncheckedUpdateInput { + return { + ...(input.name !== undefined ? { name: input.name } : {}), + ...(input.groupId !== undefined ? { groupId: input.groupId } : {}), + }; +} + +export function assertManagementLevelDeletable( + level: ManagementLevelDeleteRecord, +): void { + if (level._count.resources > 0) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: `Cannot delete level assigned to ${level._count.resources} resource(s)`, + }); + } +} diff --git a/packages/api/src/router/management-level.ts b/packages/api/src/router/management-level.ts index 4f79340..14b35a3 100644 --- a/packages/api/src/router/management-level.ts +++ b/packages/api/src/router/management-level.ts @@ -4,11 +4,19 @@ import { UpdateManagementLevelGroupSchema, UpdateManagementLevelSchema, } from "@capakraken/shared"; -import { TRPCError } from "@trpc/server"; 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"; export const managementLevelRouter = createTRPCRouter({ // ─── Groups ───────────────────────────────────────────── @@ -39,16 +47,9 @@ export const managementLevelRouter = createTRPCRouter({ createGroup: adminProcedure .input(CreateManagementLevelGroupSchema) .mutation(async ({ ctx, input }) => { - const existing = await ctx.db.managementLevelGroup.findUnique({ where: { name: input.name } }); - if (existing) { - throw new TRPCError({ code: "CONFLICT", message: `Group "${input.name}" already exists` }); - } + await assertManagementLevelGroupNameAvailable(ctx.db, input.name); const created = await ctx.db.managementLevelGroup.create({ - data: { - name: input.name, - targetPercentage: input.targetPercentage, - sortOrder: input.sortOrder, - }, + data: buildManagementLevelGroupCreateData(input), include: { levels: true }, }); @@ -75,21 +76,14 @@ export const managementLevelRouter = createTRPCRouter({ ); if (input.data.name && input.data.name !== existing.name) { - const conflict = await ctx.db.managementLevelGroup.findUnique({ where: { name: input.data.name } }); - if (conflict) { - throw new TRPCError({ code: "CONFLICT", message: `Group "${input.data.name}" already exists` }); - } + 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: { - ...(input.data.name !== undefined ? { name: input.data.name } : {}), - ...(input.data.targetPercentage !== undefined ? { targetPercentage: input.data.targetPercentage } : {}), - ...(input.data.sortOrder !== undefined ? { sortOrder: input.data.sortOrder } : {}), - }, + data: buildManagementLevelGroupUpdateData(input.data), include: { levels: true }, }); @@ -118,13 +112,10 @@ export const managementLevelRouter = createTRPCRouter({ "Group", ); - const existing = await ctx.db.managementLevel.findUnique({ where: { name: input.name } }); - if (existing) { - throw new TRPCError({ code: "CONFLICT", message: `Level "${input.name}" already exists` }); - } + await assertManagementLevelNameAvailable(ctx.db, input.name); const created = await ctx.db.managementLevel.create({ - data: { name: input.name, groupId: input.groupId }, + data: buildManagementLevelCreateData(input), }); void createAuditEntry({ @@ -150,20 +141,14 @@ export const managementLevelRouter = createTRPCRouter({ ); if (input.data.name && input.data.name !== existing.name) { - const conflict = await ctx.db.managementLevel.findUnique({ where: { name: input.data.name } }); - if (conflict) { - throw new TRPCError({ code: "CONFLICT", message: `Level "${input.data.name}" already exists` }); - } + 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: { - ...(input.data.name !== undefined ? { name: input.data.name } : {}), - ...(input.data.groupId !== undefined ? { groupId: input.data.groupId } : {}), - }, + data: buildManagementLevelUpdateData(input.data), }); void createAuditEntry({ @@ -191,12 +176,7 @@ export const managementLevelRouter = createTRPCRouter({ }), "Level", ); - if (level._count.resources > 0) { - throw new TRPCError({ - code: "PRECONDITION_FAILED", - message: `Cannot delete level assigned to ${level._count.resources} resource(s)`, - }); - } + assertManagementLevelDeletable(level); await ctx.db.managementLevel.delete({ where: { id: input.id } }); void createAuditEntry({