diff --git a/packages/api/src/__tests__/utilization-category-router.test.ts b/packages/api/src/__tests__/utilization-category-router.test.ts new file mode 100644 index 0000000..babed3e --- /dev/null +++ b/packages/api/src/__tests__/utilization-category-router.test.ts @@ -0,0 +1,179 @@ +import { SystemRole } from "@capakraken/shared"; +import { describe, expect, it, vi } from "vitest"; +import { utilizationCategoryRouter } from "../router/utilization-category.js"; +import { createCallerFactory } from "../trpc.js"; + +vi.mock("../lib/audit.js", () => ({ + createAuditEntry: vi.fn().mockResolvedValue(undefined), +})); + +const createCaller = createCallerFactory(utilizationCategoryRouter); + +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: "admin_1", + systemRole: SystemRole.ADMIN, + permissionOverrides: null, + }, + roleDefaults: null, + }); +} + +describe("utilization category router", () => { + it("lists utilization categories with normalized filters", async () => { + const findMany = vi.fn().mockResolvedValue([ + { + id: "util_1", + code: "BILLABLE", + name: "Billable", + isActive: true, + sortOrder: 1, + }, + ]); + const caller = createAdminCaller({ + utilizationCategory: { + findMany, + }, + }); + + const result = await caller.list({ isActive: true }); + + expect(result).toHaveLength(1); + expect(findMany).toHaveBeenCalledWith({ + where: { isActive: true }, + orderBy: { sortOrder: "asc" }, + }); + }); + + it("gets a utilization category by id including project counts", async () => { + const findUnique = vi.fn().mockResolvedValue({ + id: "util_1", + code: "BILLABLE", + name: "Billable", + _count: { projects: 3 }, + }); + const caller = createAdminCaller({ + utilizationCategory: { + findUnique, + }, + }); + + const result = await caller.getById({ id: "util_1" }); + + expect(findUnique).toHaveBeenCalledWith({ + where: { id: "util_1" }, + include: { _count: { select: { projects: true } } }, + }); + expect(result).toMatchObject({ + id: "util_1", + code: "BILLABLE", + name: "Billable", + _count: { projects: 3 }, + }); + }); + + it("creates a default category after unsetting previous defaults", async () => { + const findUnique = vi.fn().mockResolvedValue(null); + const updateMany = vi.fn().mockResolvedValue({ count: 1 }); + const create = vi.fn().mockResolvedValue({ + id: "util_1", + code: "BILLABLE", + name: "Billable", + isDefault: true, + }); + const caller = createAdminCaller({ + utilizationCategory: { + findUnique, + updateMany, + create, + }, + }); + + const result = await caller.create({ + code: "BILLABLE", + name: "Billable", + sortOrder: 1, + isDefault: true, + }); + + expect(updateMany).toHaveBeenCalledWith({ + where: { isDefault: true }, + data: { isDefault: false }, + }); + expect(create).toHaveBeenCalledWith({ + data: { + code: "BILLABLE", + name: "Billable", + sortOrder: 1, + isDefault: true, + }, + }); + expect(result).toMatchObject({ + id: "util_1", + code: "BILLABLE", + name: "Billable", + isDefault: true, + }); + }); + + it("updates a category and unsets other defaults when requested", async () => { + const findUnique = vi + .fn() + .mockResolvedValueOnce({ + id: "util_1", + code: "BILLABLE", + name: "Billable", + isDefault: false, + }) + .mockResolvedValueOnce(null); + const updateMany = vi.fn().mockResolvedValue({ count: 1 }); + const update = vi.fn().mockResolvedValue({ + id: "util_1", + code: "BILLABLE", + name: "Billable Updated", + isDefault: true, + }); + const caller = createAdminCaller({ + utilizationCategory: { + findUnique, + updateMany, + update, + }, + }); + + const result = await caller.update({ + id: "util_1", + data: { + name: "Billable Updated", + isDefault: true, + }, + }); + + expect(updateMany).toHaveBeenCalledWith({ + where: { + isDefault: true, + id: { not: "util_1" }, + }, + data: { isDefault: false }, + }); + expect(update).toHaveBeenCalledWith({ + where: { id: "util_1" }, + data: { + name: "Billable Updated", + isDefault: true, + }, + }); + expect(result).toMatchObject({ + id: "util_1", + code: "BILLABLE", + name: "Billable Updated", + isDefault: true, + }); + }); +}); diff --git a/packages/api/src/router/utilization-category-procedure-support.ts b/packages/api/src/router/utilization-category-procedure-support.ts new file mode 100644 index 0000000..3e8ae29 --- /dev/null +++ b/packages/api/src/router/utilization-category-procedure-support.ts @@ -0,0 +1,122 @@ +import { + CreateUtilizationCategorySchema, + UpdateUtilizationCategorySchema, +} 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 { + assertUtilizationCategoryCodeAvailable, + buildUtilizationCategoryCreateData, + buildUtilizationCategoryListWhere, + buildUtilizationCategoryUpdateData, + unsetDefaultUtilizationCategory, +} from "./utilization-category-support.js"; + +export const UtilizationCategoryListInputSchema = z.object({ + isActive: z.boolean().optional(), +}).optional(); + +export const UtilizationCategoryByIdInputSchema = z.object({ + id: z.string(), +}); + +export const CreateUtilizationCategoryInputSchema = CreateUtilizationCategorySchema; + +export const UpdateUtilizationCategoryInputSchema = z.object({ + id: z.string(), + data: UpdateUtilizationCategorySchema, +}); + +type UtilizationCategoryContext = Pick; + +export async function listUtilizationCategories( + ctx: Pick, + input: z.infer, +) { + return ctx.db.utilizationCategory.findMany({ + where: buildUtilizationCategoryListWhere(input ?? {}), + orderBy: { sortOrder: "asc" }, + }); +} + +export async function getUtilizationCategoryById( + ctx: Pick, + input: z.infer, +) { + return findUniqueOrThrow( + ctx.db.utilizationCategory.findUnique({ + where: { id: input.id }, + include: { _count: { select: { projects: true } } }, + }), + "Utilization category", + ); +} + +export async function createUtilizationCategory( + ctx: UtilizationCategoryContext, + input: z.infer, +) { + await assertUtilizationCategoryCodeAvailable(ctx.db, input.code); + + if (input.isDefault) { + await unsetDefaultUtilizationCategory(ctx.db); + } + + const created = await ctx.db.utilizationCategory.create({ + data: buildUtilizationCategoryCreateData(input), + }); + + void createAuditEntry({ + db: ctx.db, + entityType: "UtilizationCategory", + entityId: created.id, + entityName: created.name, + action: "CREATE", + userId: ctx.dbUser?.id, + after: created as unknown as Record, + source: "ui", + }); + + return created; +} + +export async function updateUtilizationCategory( + ctx: UtilizationCategoryContext, + input: z.infer, +) { + const existing = await findUniqueOrThrow( + ctx.db.utilizationCategory.findUnique({ where: { id: input.id } }), + "Utilization category", + ); + + if (input.data.code && input.data.code !== existing.code) { + await assertUtilizationCategoryCodeAvailable(ctx.db, input.data.code, existing.id); + } + + if (input.data.isDefault) { + 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: buildUtilizationCategoryUpdateData(input.data), + }); + + void createAuditEntry({ + db: ctx.db, + entityType: "UtilizationCategory", + entityId: updated.id, + entityName: updated.name, + action: "UPDATE", + userId: ctx.dbUser?.id, + before, + after: updated as unknown as Record, + source: "ui", + }); + + return updated; +} diff --git a/packages/api/src/router/utilization-category.ts b/packages/api/src/router/utilization-category.ts index 97d469a..fd856e7 100644 --- a/packages/api/src/router/utilization-category.ts +++ b/packages/api/src/router/utilization-category.ts @@ -1,106 +1,29 @@ -import { - CreateUtilizationCategorySchema, - UpdateUtilizationCategorySchema, -} 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 { - assertUtilizationCategoryCodeAvailable, - buildUtilizationCategoryCreateData, - buildUtilizationCategoryListWhere, - buildUtilizationCategoryUpdateData, - unsetDefaultUtilizationCategory, -} from "./utilization-category-support.js"; + CreateUtilizationCategoryInputSchema, + UpdateUtilizationCategoryInputSchema, + UtilizationCategoryByIdInputSchema, + UtilizationCategoryListInputSchema, + createUtilizationCategory, + getUtilizationCategoryById, + listUtilizationCategories, + updateUtilizationCategory, +} from "./utilization-category-procedure-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: buildUtilizationCategoryListWhere(input ?? {}), - orderBy: { sortOrder: "asc" }, - }); - }), + .input(UtilizationCategoryListInputSchema) + .query(({ ctx, input }) => listUtilizationCategories(ctx, input)), getById: planningReadProcedure - .input(z.object({ id: z.string() })) - .query(async ({ ctx, input }) => { - const cat = await findUniqueOrThrow( - ctx.db.utilizationCategory.findUnique({ - where: { id: input.id }, - include: { _count: { select: { projects: true } } }, - }), - "Utilization category", - ); - return cat; - }), + .input(UtilizationCategoryByIdInputSchema) + .query(({ ctx, input }) => getUtilizationCategoryById(ctx, input)), create: adminProcedure - .input(CreateUtilizationCategorySchema) - .mutation(async ({ ctx, input }) => { - await assertUtilizationCategoryCodeAvailable(ctx.db, input.code); - - // If setting as default, unset the current default first - if (input.isDefault) { - await unsetDefaultUtilizationCategory(ctx.db); - } - - const created = await ctx.db.utilizationCategory.create({ - data: buildUtilizationCategoryCreateData(input), - }); - - void createAuditEntry({ - db: ctx.db, - entityType: "UtilizationCategory", - entityId: created.id, - entityName: created.name, - action: "CREATE", - userId: ctx.dbUser?.id, - after: created as unknown as Record, - source: "ui", - }); - - return created; - }), + .input(CreateUtilizationCategoryInputSchema) + .mutation(({ ctx, input }) => createUtilizationCategory(ctx, input)), update: adminProcedure - .input(z.object({ id: z.string(), data: UpdateUtilizationCategorySchema })) - .mutation(async ({ ctx, input }) => { - const existing = await findUniqueOrThrow( - ctx.db.utilizationCategory.findUnique({ where: { id: input.id } }), - "Utilization category", - ); - - if (input.data.code && input.data.code !== existing.code) { - await assertUtilizationCategoryCodeAvailable(ctx.db, input.data.code, existing.id); - } - - // If setting as default, unset others - if (input.data.isDefault) { - 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: buildUtilizationCategoryUpdateData(input.data), - }); - - void createAuditEntry({ - db: ctx.db, - entityType: "UtilizationCategory", - entityId: updated.id, - entityName: updated.name, - action: "UPDATE", - userId: ctx.dbUser?.id, - before, - after: updated as unknown as Record, - source: "ui", - }); - - return updated; - }), + .input(UpdateUtilizationCategoryInputSchema) + .mutation(({ ctx, input }) => updateUtilizationCategory(ctx, input)), });