refactor(api): extract utilization category support

This commit is contained in:
2026-03-31 13:49:10 +02:00
parent daf3588cab
commit 6f69021fe5
3 changed files with 163 additions and 35 deletions
@@ -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,
});
});
});
@@ -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<PrismaClient, "utilizationCategory">;
type UtilizationCategoryListInput = {
isActive?: boolean | undefined;
};
type CreateUtilizationCategoryInput = z.infer<typeof CreateUtilizationCategorySchema>;
type UpdateUtilizationCategoryInput = z.infer<typeof UpdateUtilizationCategorySchema>;
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<void> {
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<void> {
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 } : {}),
};
}
+14 -35
View File
@@ -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<string, unknown>;
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({