diff --git a/packages/api/src/__tests__/utilization-category-support.test.ts b/packages/api/src/__tests__/utilization-category-support.test.ts new file mode 100644 index 0000000..bb31394 --- /dev/null +++ b/packages/api/src/__tests__/utilization-category-support.test.ts @@ -0,0 +1,73 @@ +import { TRPCError } from "@trpc/server"; +import { describe, expect, it, vi } from "vitest"; +import { + assertUtilizationCategoryCodeAvailable, + buildUtilizationCategoryCreateData, + buildUtilizationCategoryListWhere, + buildUtilizationCategoryUpdateData, + unsetDefaultUtilizationCategory, +} from "../router/utilization-category-support.js"; + +describe("utilization category support", () => { + it("builds list filters", () => { + expect(buildUtilizationCategoryListWhere({ isActive: true })).toEqual({ + isActive: true, + }); + }); + + it("rejects duplicate codes outside the ignored id", async () => { + const db = { + utilizationCategory: { + findUnique: vi.fn().mockResolvedValue({ id: "util_existing", code: "CHARGEABLE" }), + }, + } as never; + + await expect(assertUtilizationCategoryCodeAvailable(db, "CHARGEABLE")).rejects.toBeInstanceOf(TRPCError); + }); + + it("unsets the current default with and without an ignored id", async () => { + const db = { + utilizationCategory: { + updateMany: vi.fn().mockResolvedValue({ count: 1 }), + }, + } as never; + + await unsetDefaultUtilizationCategory(db); + await unsetDefaultUtilizationCategory(db, "util_keep"); + + expect(db.utilizationCategory.updateMany).toHaveBeenNthCalledWith(1, { + where: { isDefault: true }, + data: { isDefault: false }, + }); + expect(db.utilizationCategory.updateMany).toHaveBeenNthCalledWith(2, { + where: { isDefault: true, id: { not: "util_keep" } }, + data: { isDefault: false }, + }); + }); + + it("builds create and sparse update payloads", () => { + expect(buildUtilizationCategoryCreateData({ + code: "CHARGEABLE", + name: "Chargeable", + description: "Revenue-generating project work", + sortOrder: 1, + isDefault: true, + })).toEqual({ + code: "CHARGEABLE", + name: "Chargeable", + description: "Revenue-generating project work", + sortOrder: 1, + isDefault: true, + }); + + expect(buildUtilizationCategoryUpdateData({ + description: null, + isActive: false, + isDefault: false, + })).toEqual({ + description: null, + isActive: false, + isDefault: false, + }); + }); +}); diff --git a/packages/api/src/router/utilization-category-support.ts b/packages/api/src/router/utilization-category-support.ts new file mode 100644 index 0000000..32ddbf4 --- /dev/null +++ b/packages/api/src/router/utilization-category-support.ts @@ -0,0 +1,76 @@ +import type { Prisma, PrismaClient } from "@capakraken/db"; +import { + CreateUtilizationCategorySchema, + UpdateUtilizationCategorySchema, +} from "@capakraken/shared"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; + +type UtilizationCategoryDb = Pick; + +type UtilizationCategoryListInput = { + isActive?: boolean | undefined; +}; + +type CreateUtilizationCategoryInput = z.infer; +type UpdateUtilizationCategoryInput = z.infer; + +export function buildUtilizationCategoryListWhere( + input: UtilizationCategoryListInput, +): Prisma.UtilizationCategoryWhereInput { + return { + ...(input.isActive !== undefined ? { isActive: input.isActive } : {}), + }; +} + +export async function assertUtilizationCategoryCodeAvailable( + db: UtilizationCategoryDb, + code: string, + ignoreId?: string, +): Promise { + const existing = await db.utilizationCategory.findUnique({ where: { code } }); + if (existing && existing.id !== ignoreId) { + throw new TRPCError({ + code: "CONFLICT", + message: `Code "${code}" already exists`, + }); + } +} + +export async function unsetDefaultUtilizationCategory( + db: UtilizationCategoryDb, + ignoreId?: string, +): Promise { + await db.utilizationCategory.updateMany({ + where: { + isDefault: true, + ...(ignoreId ? { id: { not: ignoreId } } : {}), + }, + data: { isDefault: false }, + }); +} + +export function buildUtilizationCategoryCreateData( + input: CreateUtilizationCategoryInput, +): Prisma.UtilizationCategoryUncheckedCreateInput { + return { + code: input.code, + name: input.name, + ...(input.description !== undefined ? { description: input.description } : {}), + sortOrder: input.sortOrder, + isDefault: input.isDefault, + }; +} + +export function buildUtilizationCategoryUpdateData( + input: UpdateUtilizationCategoryInput, +): Prisma.UtilizationCategoryUncheckedUpdateInput { + return { + ...(input.code !== undefined ? { code: input.code } : {}), + ...(input.name !== undefined ? { name: input.name } : {}), + ...(input.description !== undefined ? { description: input.description } : {}), + ...(input.sortOrder !== undefined ? { sortOrder: input.sortOrder } : {}), + ...(input.isActive !== undefined ? { isActive: input.isActive } : {}), + ...(input.isDefault !== undefined ? { isDefault: input.isDefault } : {}), + }; +} diff --git a/packages/api/src/router/utilization-category.ts b/packages/api/src/router/utilization-category.ts index 60b40a0..97d469a 100644 --- a/packages/api/src/router/utilization-category.ts +++ b/packages/api/src/router/utilization-category.ts @@ -2,20 +2,24 @@ import { CreateUtilizationCategorySchema, UpdateUtilizationCategorySchema, } 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 { + assertUtilizationCategoryCodeAvailable, + buildUtilizationCategoryCreateData, + buildUtilizationCategoryListWhere, + buildUtilizationCategoryUpdateData, + unsetDefaultUtilizationCategory, +} from "./utilization-category-support.js"; export const utilizationCategoryRouter = createTRPCRouter({ list: planningReadProcedure .input(z.object({ isActive: z.boolean().optional() }).optional()) .query(async ({ ctx, input }) => { return ctx.db.utilizationCategory.findMany({ - where: { - ...(input?.isActive !== undefined ? { isActive: input.isActive } : {}), - }, + where: buildUtilizationCategoryListWhere(input ?? {}), orderBy: { sortOrder: "asc" }, }); }), @@ -36,27 +40,15 @@ export const utilizationCategoryRouter = createTRPCRouter({ create: adminProcedure .input(CreateUtilizationCategorySchema) .mutation(async ({ ctx, input }) => { - const existing = await ctx.db.utilizationCategory.findUnique({ where: { code: input.code } }); - if (existing) { - throw new TRPCError({ code: "CONFLICT", message: `Code "${input.code}" already exists` }); - } + await assertUtilizationCategoryCodeAvailable(ctx.db, input.code); // If setting as default, unset the current default first if (input.isDefault) { - await ctx.db.utilizationCategory.updateMany({ - where: { isDefault: true }, - data: { isDefault: false }, - }); + await unsetDefaultUtilizationCategory(ctx.db); } const created = await ctx.db.utilizationCategory.create({ - data: { - code: input.code, - name: input.name, - ...(input.description !== undefined ? { description: input.description } : {}), - sortOrder: input.sortOrder, - isDefault: input.isDefault, - }, + data: buildUtilizationCategoryCreateData(input), }); void createAuditEntry({ @@ -82,32 +74,19 @@ export const utilizationCategoryRouter = createTRPCRouter({ ); if (input.data.code && input.data.code !== existing.code) { - const conflict = await ctx.db.utilizationCategory.findUnique({ where: { code: input.data.code } }); - if (conflict) { - throw new TRPCError({ code: "CONFLICT", message: `Code "${input.data.code}" already exists` }); - } + await assertUtilizationCategoryCodeAvailable(ctx.db, input.data.code, existing.id); } // If setting as default, unset others if (input.data.isDefault) { - await ctx.db.utilizationCategory.updateMany({ - where: { isDefault: true, id: { not: input.id } }, - data: { isDefault: false }, - }); + await unsetDefaultUtilizationCategory(ctx.db, input.id); } const before = existing as unknown as Record; const updated = await ctx.db.utilizationCategory.update({ where: { id: input.id }, - data: { - ...(input.data.code !== undefined ? { code: input.data.code } : {}), - ...(input.data.name !== undefined ? { name: input.data.name } : {}), - ...(input.data.description !== undefined ? { description: input.data.description } : {}), - ...(input.data.sortOrder !== undefined ? { sortOrder: input.data.sortOrder } : {}), - ...(input.data.isActive !== undefined ? { isActive: input.data.isActive } : {}), - ...(input.data.isDefault !== undefined ? { isDefault: input.data.isDefault } : {}), - }, + data: buildUtilizationCategoryUpdateData(input.data), }); void createAuditEntry({