From cec4169bea4fbe1c697458d48bfcf3cd6623e504 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 19:56:14 +0200 Subject: [PATCH] refactor(api): extract rate card procedures --- .../rate-card-procedure-support.test.ts | 151 ++++++++ .../src/__tests__/rate-card-router.test.ts | 88 ++++- .../src/router/rate-card-procedure-support.ts | 355 ++++++++++++++++++ packages/api/src/router/rate-card.ts | 318 +++------------- 4 files changed, 621 insertions(+), 291 deletions(-) create mode 100644 packages/api/src/__tests__/rate-card-procedure-support.test.ts create mode 100644 packages/api/src/router/rate-card-procedure-support.ts diff --git a/packages/api/src/__tests__/rate-card-procedure-support.test.ts b/packages/api/src/__tests__/rate-card-procedure-support.test.ts new file mode 100644 index 0000000..65d2706 --- /dev/null +++ b/packages/api/src/__tests__/rate-card-procedure-support.test.ts @@ -0,0 +1,151 @@ +import { SystemRole } from "@capakraken/shared"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { createAuditEntry } = vi.hoisted(() => ({ + createAuditEntry: vi.fn(), +})); + +vi.mock("../lib/audit.js", () => ({ + createAuditEntry, +})); + +import { + addRateCardLine, + createRateCard, + replaceRateCardLines, +} from "../router/rate-card-procedure-support.js"; + +function createManagerContext(db: Record) { + return { + db: db as never, + dbUser: { + id: "manager_1", + systemRole: SystemRole.MANAGER, + permissionOverrides: null, + }, + }; +} + +describe("rate-card procedure support", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("creates a rate card and writes a summarized audit entry", async () => { + const create = vi.fn().mockResolvedValue({ + id: "rc_1", + name: "Standard 2026", + lines: [{ id: "line_1" }], + }); + + const result = await createRateCard(createManagerContext({ + rateCard: { create }, + }), { + name: "Standard 2026", + currency: "EUR", + lines: [{ + costRateCents: 9_500, + billRateCents: 14_000, + chapter: "Delivery", + attributes: {}, + }], + }); + + expect(result).toEqual({ + id: "rc_1", + name: "Standard 2026", + lines: [{ id: "line_1" }], + }); + expect(create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + name: "Standard 2026", + currency: "EUR", + }), + })); + expect(createAuditEntry).toHaveBeenCalledWith(expect.objectContaining({ + entityType: "RateCard", + entityId: "rc_1", + action: "CREATE", + after: { + name: "Standard 2026", + currency: "EUR", + lineCount: 1, + }, + })); + }); + + it("adds a line and uses the rate card name in audit metadata", async () => { + const lineCreate = vi.fn().mockResolvedValue({ + id: "line_1", + rateCardId: "rc_1", + chapter: "Delivery", + costRateCents: 9_500, + billRateCents: 14_000, + }); + + const result = await addRateCardLine(createManagerContext({ + rateCard: { + findUnique: vi.fn().mockResolvedValue({ id: "rc_1", name: "Standard 2026" }), + }, + rateCardLine: { + create: lineCreate, + }, + }), { + rateCardId: "rc_1", + line: { + costRateCents: 9_500, + billRateCents: 14_000, + chapter: "Delivery", + attributes: {}, + }, + }); + + expect(result.id).toBe("line_1"); + expect(lineCreate).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + rateCardId: "rc_1", + chapter: "Delivery", + }), + })); + expect(createAuditEntry).toHaveBeenCalledWith(expect.objectContaining({ + entityType: "RateCardLine", + entityId: "line_1", + entityName: "Standard 2026 - Delivery", + })); + }); + + it("replaces lines inside a transaction and audits the replacement count", async () => { + const deleteMany = vi.fn().mockResolvedValue({ count: 2 }); + const create = vi.fn() + .mockResolvedValueOnce({ id: "line_1", rateCardId: "rc_1", costRateCents: 8_000 }) + .mockResolvedValueOnce({ id: "line_2", rateCardId: "rc_1", costRateCents: 9_500 }); + + const db = { + rateCard: { + findUnique: vi.fn().mockResolvedValue({ id: "rc_1", name: "Standard 2026" }), + }, + rateCardLine: {}, + $transaction: vi.fn(async (callback: (tx: { rateCardLine: { deleteMany: typeof deleteMany; create: typeof create } }) => unknown) => callback({ + rateCardLine: { deleteMany, create }, + })), + }; + + const result = await replaceRateCardLines(createManagerContext(db), { + rateCardId: "rc_1", + lines: [ + { costRateCents: 8_000, chapter: "Delivery", attributes: {} }, + { costRateCents: 9_500, chapter: "Pipeline", attributes: {} }, + ], + }); + + expect(deleteMany).toHaveBeenCalledWith({ where: { rateCardId: "rc_1" } }); + expect(create).toHaveBeenCalledTimes(2); + expect(result).toHaveLength(2); + expect(createAuditEntry).toHaveBeenCalledWith(expect.objectContaining({ + entityType: "RateCard", + entityId: "rc_1", + summary: "Replaced all lines with 2 new lines", + after: { replacedLineCount: 2 }, + })); + }); +}); diff --git a/packages/api/src/__tests__/rate-card-router.test.ts b/packages/api/src/__tests__/rate-card-router.test.ts index 56f1341..92dd587 100644 --- a/packages/api/src/__tests__/rate-card-router.test.ts +++ b/packages/api/src/__tests__/rate-card-router.test.ts @@ -20,27 +20,55 @@ function createControllerCaller(db: Record) { }); } +function createManagerCaller(db: Record) { + return createCaller({ + session: { + user: { email: "manager@example.com", name: "Manager", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + db: db as never, + dbUser: { + id: "user_2", + systemRole: SystemRole.MANAGER, + permissionOverrides: null, + }, + }); +} + describe("rateCard router", () => { describe("list", () => { - it("returns rate cards with line counts", async () => { + it("returns rate cards with line counts through the router", async () => { const findMany = vi.fn().mockResolvedValue([ { id: "rc_1", name: "Standard 2026", currency: "EUR", isActive: true, _count: { lines: 5 } }, { id: "rc_2", name: "India Rates", currency: "INR", isActive: true, _count: { lines: 3 } }, ]); - const result = await findMany({ - where: {}, - include: { _count: { select: { lines: true } } }, - orderBy: [{ isActive: "desc" }, { effectiveFrom: "desc" }, { name: "asc" }], + const caller = createControllerCaller({ + rateCard: { findMany }, + }); + const result = await caller.list({ + search: "Standard", + isActive: true, }); + expect(findMany).toHaveBeenCalledWith({ + where: { + isActive: true, + name: { contains: "Standard", mode: "insensitive" }, + }, + include: { + _count: { select: { lines: true } }, + client: { select: { id: true, name: true, code: true } }, + }, + orderBy: [{ isActive: "desc" }, { effectiveFrom: "desc" }, { name: "asc" }], + }); expect(result).toHaveLength(2); expect(result[0]._count.lines).toBe(5); }); }); describe("create", () => { - it("creates a rate card with lines", async () => { + it("creates a rate card with lines through the router", async () => { const create = vi.fn().mockResolvedValue({ id: "rc_new", name: "Q1 2026 Rates", @@ -51,16 +79,21 @@ describe("rateCard router", () => { ], }); - const result = await create({ - data: { - name: "Q1 2026 Rates", - currency: "EUR", - lines: { - create: [{ costRateCents: 9500, billRateCents: 14000, chapter: "Digital Content Production" }], - }, - }, + const caller = createManagerCaller({ + rateCard: { create }, + }); + const result = await caller.create({ + name: "Q1 2026 Rates", + currency: "EUR", + lines: [{ costRateCents: 9500, billRateCents: 14000, chapter: "Digital Content Production", attributes: {} }], }); + expect(create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + name: "Q1 2026 Rates", + currency: "EUR", + }), + })); expect(result.id).toBe("rc_new"); expect(result.lines).toHaveLength(1); expect(result.lines[0].costRateCents).toBe(9500); @@ -211,19 +244,34 @@ describe("rateCard router", () => { }); describe("replaceLines", () => { - it("deletes all lines and creates new ones in a transaction", async () => { + it("deletes all lines and creates new ones in a transaction through the router", async () => { const deleteMany = vi.fn().mockResolvedValue({ count: 3 }); const createLine = vi.fn() .mockResolvedValueOnce({ id: "rcl_new_1", costRateCents: 8000 }) .mockResolvedValueOnce({ id: "rcl_new_2", costRateCents: 9500 }); - await deleteMany({ where: { rateCardId: "rc_1" } }); - const line1 = await createLine({ data: { rateCardId: "rc_1", costRateCents: 8000 } }); - const line2 = await createLine({ data: { rateCardId: "rc_1", costRateCents: 9500 } }); + const caller = createManagerCaller({ + rateCard: { + findUnique: vi.fn().mockResolvedValue({ id: "rc_1", name: "Standard 2026" }), + }, + rateCardLine: {}, + $transaction: vi.fn(async (callback: (tx: { rateCardLine: { deleteMany: typeof deleteMany; create: typeof createLine } }) => unknown) => callback({ + rateCardLine: { deleteMany, create: createLine }, + })), + }); + const result = await caller.replaceLines({ + rateCardId: "rc_1", + lines: [ + { costRateCents: 8000, chapter: "Delivery", attributes: {} }, + { costRateCents: 9500, chapter: "Pipeline", attributes: {} }, + ], + }); expect(deleteMany).toHaveBeenCalledWith({ where: { rateCardId: "rc_1" } }); - expect(line1.id).toBe("rcl_new_1"); - expect(line2.id).toBe("rcl_new_2"); + expect(result).toEqual([ + { id: "rcl_new_1", costRateCents: 8000 }, + { id: "rcl_new_2", costRateCents: 9500 }, + ]); }); }); }); diff --git a/packages/api/src/router/rate-card-procedure-support.ts b/packages/api/src/router/rate-card-procedure-support.ts new file mode 100644 index 0000000..06056b4 --- /dev/null +++ b/packages/api/src/router/rate-card-procedure-support.ts @@ -0,0 +1,355 @@ +import { + CreateRateCardLineSchema, + CreateRateCardSchema, + UpdateRateCardLineSchema, + UpdateRateCardSchema, +} 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 { + lookupBestRateMatch, + rateCardLineSelect, + resolveBestRate, + resolveBestRateLineMatch, +} from "./rate-card-read.js"; +import { + buildRateCardCreateAuditAfter, + buildRateCardCreateData, + buildRateCardLineCreateAuditAfter, + buildRateCardLineCreateData, + buildRateCardLineUpdateData, + buildRateCardListWhere, + buildRateCardNestedLineCreateData, + buildRateCardReplaceLinesAuditAfter, + buildRateCardUpdateData, + rateCardCreateInclude, + rateCardDetailInclude, + rateCardSummaryInclude, +} from "./rate-card-write-support.js"; + +type RateCardProcedureContext = Pick; + +function withAuditUser(userId: string | undefined) { + return userId ? { userId } : {}; +} + +export const rateCardListInputSchema = z.object({ + isActive: z.boolean().optional(), + search: z.string().optional(), + clientId: z.string().optional(), + effectiveAt: z.coerce.date().optional(), +}).optional(); + +export const rateCardByIdInputSchema = z.object({ id: z.string() }); + +export const rateCardLookupBestMatchInputSchema = z.object({ + clientId: z.string().optional(), + chapter: z.string().optional(), + managementLevelId: z.string().optional(), + roleName: z.string().optional(), + seniority: z.string().optional(), +}); + +export const rateCardResolveBestRateInputSchema = z.object({ + resourceId: z.string().optional(), + roleName: z.string().optional(), + date: z.coerce.date().optional(), +}); + +export const rateCardUpdateInputSchema = z.object({ + id: z.string(), + data: UpdateRateCardSchema, +}); + +export const rateCardAddLineInputSchema = z.object({ + rateCardId: z.string(), + line: CreateRateCardLineSchema, +}); + +export const rateCardUpdateLineInputSchema = z.object({ + lineId: z.string(), + data: UpdateRateCardLineSchema, +}); + +export const rateCardDeleteLineInputSchema = z.object({ lineId: z.string() }); + +export const rateCardReplaceLinesInputSchema = z.object({ + rateCardId: z.string(), + lines: z.array(CreateRateCardLineSchema), +}); + +export const rateCardResolveLineInputSchema = z.object({ + rateCardId: z.string(), + roleId: z.string().optional(), + chapter: z.string().optional(), + location: z.string().optional(), + seniority: z.string().optional(), + workType: z.string().optional(), +}); + +type RateCardListInput = z.infer; +type RateCardByIdInput = z.infer; +type RateCardLookupBestMatchInput = z.infer; +type RateCardResolveBestRateInput = z.infer; +type RateCardCreateInput = z.infer; +type RateCardUpdateInput = z.infer; +type RateCardDeactivateInput = z.infer; +type RateCardAddLineInput = z.infer; +type RateCardUpdateLineInput = z.infer; +type RateCardDeleteLineInput = z.infer; +type RateCardReplaceLinesInput = z.infer; +type RateCardResolveLineInput = z.infer; + +export async function listRateCards( + ctx: RateCardProcedureContext, + input: RateCardListInput, +) { + return ctx.db.rateCard.findMany({ + where: buildRateCardListWhere(input), + include: rateCardSummaryInclude, + orderBy: [{ isActive: "desc" }, { effectiveFrom: "desc" }, { name: "asc" }], + }); +} + +export async function getRateCardById( + ctx: RateCardProcedureContext, + input: RateCardByIdInput, +) { + return findUniqueOrThrow( + ctx.db.rateCard.findUnique({ + where: { id: input.id }, + include: rateCardDetailInclude, + }), + "Rate card", + ); +} + +export async function lookupRateCardBestMatch( + ctx: RateCardProcedureContext, + input: RateCardLookupBestMatchInput, +) { + return lookupBestRateMatch(ctx.db, input); +} + +export async function resolveRateCard( + ctx: RateCardProcedureContext, + input: RateCardResolveBestRateInput, +) { + return resolveBestRate(ctx.db, input); +} + +export async function createRateCard( + ctx: RateCardProcedureContext, + input: RateCardCreateInput, +) { + const { lines, name, currency } = input; + + const rateCard = await ctx.db.rateCard.create({ + data: buildRateCardCreateData(input), + include: rateCardCreateInclude, + }); + + void createAuditEntry({ + db: ctx.db, + entityType: "RateCard", + entityId: rateCard.id, + entityName: rateCard.name, + action: "CREATE", + ...withAuditUser(ctx.dbUser?.id), + after: buildRateCardCreateAuditAfter({ name, currency, lines }), + source: "ui", + }); + + return rateCard; +} + +export async function updateRateCard( + ctx: RateCardProcedureContext, + input: RateCardUpdateInput, +) { + const before = await findUniqueOrThrow( + ctx.db.rateCard.findUnique({ where: { id: input.id } }), + "Rate card", + ); + + const updated = await ctx.db.rateCard.update({ + where: { id: input.id }, + data: buildRateCardUpdateData(input.data), + include: rateCardSummaryInclude, + }); + + void createAuditEntry({ + db: ctx.db, + entityType: "RateCard", + entityId: input.id, + entityName: updated.name, + action: "UPDATE", + ...withAuditUser(ctx.dbUser?.id), + before: before as unknown as Record, + after: updated as unknown as Record, + source: "ui", + }); + + return updated; +} + +export async function deactivateRateCard( + ctx: RateCardProcedureContext, + input: RateCardDeactivateInput, +) { + const deactivated = await ctx.db.rateCard.update({ + where: { id: input.id }, + data: { isActive: false }, + }); + + void createAuditEntry({ + db: ctx.db, + entityType: "RateCard", + entityId: input.id, + entityName: deactivated.name, + action: "DELETE", + ...withAuditUser(ctx.dbUser?.id), + source: "ui", + summary: "Deactivated rate card", + }); + + return deactivated; +} + +export async function addRateCardLine( + ctx: RateCardProcedureContext, + input: RateCardAddLineInput, +) { + const rateCard = await findUniqueOrThrow( + ctx.db.rateCard.findUnique({ where: { id: input.rateCardId } }), + "Rate card", + ); + + const line = await ctx.db.rateCardLine.create({ + data: buildRateCardLineCreateData(input.rateCardId, input.line), + select: rateCardLineSelect, + }); + + void createAuditEntry({ + db: ctx.db, + entityType: "RateCardLine", + entityId: line.id, + entityName: `${rateCard.name} - ${input.line.chapter ?? "line"}`, + action: "CREATE", + ...withAuditUser(ctx.dbUser?.id), + after: buildRateCardLineCreateAuditAfter({ + rateCardId: input.rateCardId, + line: input.line, + }), + source: "ui", + }); + + return line; +} + +export async function updateRateCardLine( + ctx: RateCardProcedureContext, + input: RateCardUpdateLineInput, +) { + const before = await findUniqueOrThrow( + ctx.db.rateCardLine.findUnique({ where: { id: input.lineId } }), + "Rate card line", + ); + + const updated = await ctx.db.rateCardLine.update({ + where: { id: input.lineId }, + data: buildRateCardLineUpdateData(input.data), + select: rateCardLineSelect, + }); + + void createAuditEntry({ + db: ctx.db, + entityType: "RateCardLine", + entityId: input.lineId, + action: "UPDATE", + ...withAuditUser(ctx.dbUser?.id), + before: before as unknown as Record, + after: updated as unknown as Record, + source: "ui", + }); + + return updated; +} + +export async function deleteRateCardLine( + ctx: RateCardProcedureContext, + input: RateCardDeleteLineInput, +) { + const line = await findUniqueOrThrow( + ctx.db.rateCardLine.findUnique({ where: { id: input.lineId } }), + "Rate card line", + ); + + await ctx.db.rateCardLine.delete({ where: { id: input.lineId } }); + + void createAuditEntry({ + db: ctx.db, + entityType: "RateCardLine", + entityId: input.lineId, + action: "DELETE", + ...withAuditUser(ctx.dbUser?.id), + before: line as unknown as Record, + source: "ui", + }); + + return { deleted: true }; +} + +export async function replaceRateCardLines( + ctx: RateCardProcedureContext, + input: RateCardReplaceLinesInput, +) { + const rateCard = await findUniqueOrThrow( + ctx.db.rateCard.findUnique({ where: { id: input.rateCardId } }), + "Rate card", + ); + + const result = await ctx.db.$transaction(async (tx) => { + await tx.rateCardLine.deleteMany({ where: { rateCardId: input.rateCardId } }); + + return Promise.all( + input.lines.map((line) => + tx.rateCardLine.create({ + data: { + rateCardId: input.rateCardId, + ...buildRateCardNestedLineCreateData(line), + }, + select: rateCardLineSelect, + }), + ), + ); + }); + + void createAuditEntry({ + db: ctx.db, + entityType: "RateCard", + entityId: input.rateCardId, + entityName: rateCard.name, + action: "UPDATE", + ...withAuditUser(ctx.dbUser?.id), + after: buildRateCardReplaceLinesAuditAfter(result.length), + source: "ui", + summary: `Replaced all lines with ${result.length} new lines`, + }); + + return result; +} + +export async function resolveRateCardLine( + ctx: RateCardProcedureContext, + input: RateCardResolveLineInput, +) { + const { rateCardId, ...criteria } = input; + const lines = await ctx.db.rateCardLine.findMany({ + where: { rateCardId }, + select: rateCardLineSelect, + }); + return resolveBestRateLineMatch(lines, criteria); +} diff --git a/packages/api/src/router/rate-card.ts b/packages/api/src/router/rate-card.ts index 326d9e8..0e76b98 100644 --- a/packages/api/src/router/rate-card.ts +++ b/packages/api/src/router/rate-card.ts @@ -1,306 +1,82 @@ -import { - CreateRateCardLineSchema, - CreateRateCardSchema, - UpdateRateCardLineSchema, - UpdateRateCardSchema, -} from "@capakraken/shared"; -import { z } from "zod"; -import { findUniqueOrThrow } from "../db/helpers.js"; +import { CreateRateCardSchema } from "@capakraken/shared"; import { createTRPCRouter, controllerProcedure, managerProcedure } from "../trpc.js"; -import { createAuditEntry } from "../lib/audit.js"; import { - lookupBestRateMatch, - rateCardLineSelect, - resolveBestRate, - resolveBestRateLineMatch, -} from "./rate-card-read.js"; -import { - buildRateCardCreateAuditAfter, - buildRateCardCreateData, - buildRateCardLineCreateAuditAfter, - buildRateCardLineCreateData, - buildRateCardLineUpdateData, - buildRateCardListWhere, - buildRateCardReplaceLinesAuditAfter, - buildRateCardUpdateData, - rateCardCreateInclude, - rateCardDetailInclude, - buildRateCardNestedLineCreateData, - rateCardSummaryInclude, -} from "./rate-card-write-support.js"; + addRateCardLine, + createRateCard, + deactivateRateCard, + deleteRateCardLine, + getRateCardById, + listRateCards, + lookupRateCardBestMatch, + rateCardAddLineInputSchema, + rateCardByIdInputSchema, + rateCardDeleteLineInputSchema, + rateCardListInputSchema, + rateCardLookupBestMatchInputSchema, + rateCardReplaceLinesInputSchema, + rateCardResolveBestRateInputSchema, + rateCardResolveLineInputSchema, + rateCardUpdateInputSchema, + rateCardUpdateLineInputSchema, + replaceRateCardLines, + resolveRateCard, + resolveRateCardLine, + updateRateCard, + updateRateCardLine, +} from "./rate-card-procedure-support.js"; export const rateCardRouter = createTRPCRouter({ list: controllerProcedure - .input( - z.object({ - isActive: z.boolean().optional(), - search: z.string().optional(), - clientId: z.string().optional(), - effectiveAt: z.coerce.date().optional(), - }).optional(), - ) - .query(async ({ ctx, input }) => { - return ctx.db.rateCard.findMany({ - where: buildRateCardListWhere(input), - include: rateCardSummaryInclude, - orderBy: [{ isActive: "desc" }, { effectiveFrom: "desc" }, { name: "asc" }], - }); - }), + .input(rateCardListInputSchema) + .query(({ ctx, input }) => listRateCards(ctx, input)), getById: controllerProcedure - .input(z.object({ id: z.string() })) - .query(async ({ ctx, input }) => { - const rateCard = await findUniqueOrThrow( - ctx.db.rateCard.findUnique({ - where: { id: input.id }, - include: rateCardDetailInclude, - }), - "Rate card", - ); - return rateCard; - }), + .input(rateCardByIdInputSchema) + .query(({ ctx, input }) => getRateCardById(ctx, input)), lookupBestMatch: controllerProcedure - .input(z.object({ - clientId: z.string().optional(), - chapter: z.string().optional(), - managementLevelId: z.string().optional(), - roleName: z.string().optional(), - seniority: z.string().optional(), - })) - .query(async ({ ctx, input }) => lookupBestRateMatch(ctx.db, input)), + .input(rateCardLookupBestMatchInputSchema) + .query(({ ctx, input }) => lookupRateCardBestMatch(ctx, input)), resolveBestRate: controllerProcedure - .input(z.object({ - resourceId: z.string().optional(), - roleName: z.string().optional(), - date: z.coerce.date().optional(), - })) - .query(async ({ ctx, input }) => resolveBestRate(ctx.db, input)), + .input(rateCardResolveBestRateInputSchema) + .query(({ ctx, input }) => resolveRateCard(ctx, input)), create: managerProcedure .input(CreateRateCardSchema) - .mutation(async ({ ctx, input }) => { - const { lines, name, currency } = input; - - const rateCard = await ctx.db.rateCard.create({ - data: buildRateCardCreateData(input), - include: rateCardCreateInclude, - }); - - void createAuditEntry({ - db: ctx.db, - entityType: "RateCard", - entityId: rateCard.id, - entityName: rateCard.name, - action: "CREATE", - userId: ctx.dbUser?.id, - after: buildRateCardCreateAuditAfter({ name, currency, lines }), - source: "ui", - }); - - return rateCard; - }), + .mutation(({ ctx, input }) => createRateCard(ctx, input)), update: managerProcedure - .input(z.object({ id: z.string(), data: UpdateRateCardSchema })) - .mutation(async ({ ctx, input }) => { - const before = await findUniqueOrThrow( - ctx.db.rateCard.findUnique({ where: { id: input.id } }), - "Rate card", - ); - - const updated = await ctx.db.rateCard.update({ - where: { id: input.id }, - data: buildRateCardUpdateData(input.data), - include: rateCardSummaryInclude, - }); - - void createAuditEntry({ - db: ctx.db, - entityType: "RateCard", - entityId: input.id, - entityName: updated.name, - action: "UPDATE", - userId: ctx.dbUser?.id, - before: before as unknown as Record, - after: updated as unknown as Record, - source: "ui", - }); - - return updated; - }), + .input(rateCardUpdateInputSchema) + .mutation(({ ctx, input }) => updateRateCard(ctx, input)), deactivate: managerProcedure - .input(z.object({ id: z.string() })) - .mutation(async ({ ctx, input }) => { - const deactivated = await ctx.db.rateCard.update({ - where: { id: input.id }, - data: { isActive: false }, - }); - - void createAuditEntry({ - db: ctx.db, - entityType: "RateCard", - entityId: input.id, - entityName: deactivated.name, - action: "DELETE", - userId: ctx.dbUser?.id, - source: "ui", - summary: "Deactivated rate card", - }); - - return deactivated; - }), + .input(rateCardByIdInputSchema) + .mutation(({ ctx, input }) => deactivateRateCard(ctx, input)), // ─── Line CRUD ───────────────────────────────────────────────────────────── addLine: managerProcedure - .input(z.object({ rateCardId: z.string(), line: CreateRateCardLineSchema })) - .mutation(async ({ ctx, input }) => { - const rateCard = await findUniqueOrThrow( - ctx.db.rateCard.findUnique({ where: { id: input.rateCardId } }), - "Rate card", - ); - - const line = await ctx.db.rateCardLine.create({ - data: buildRateCardLineCreateData(input.rateCardId, input.line), - select: rateCardLineSelect, - }); - - void createAuditEntry({ - db: ctx.db, - entityType: "RateCardLine", - entityId: line.id, - entityName: `${rateCard.name} — ${input.line.chapter ?? "line"}`, - action: "CREATE", - userId: ctx.dbUser?.id, - after: buildRateCardLineCreateAuditAfter({ - rateCardId: input.rateCardId, - line: input.line, - }), - source: "ui", - }); - - return line; - }), + .input(rateCardAddLineInputSchema) + .mutation(({ ctx, input }) => addRateCardLine(ctx, input)), updateLine: managerProcedure - .input(z.object({ lineId: z.string(), data: UpdateRateCardLineSchema })) - .mutation(async ({ ctx, input }) => { - const before = await findUniqueOrThrow( - ctx.db.rateCardLine.findUnique({ where: { id: input.lineId } }), - "Rate card line", - ); - - const updated = await ctx.db.rateCardLine.update({ - where: { id: input.lineId }, - data: buildRateCardLineUpdateData(input.data), - select: rateCardLineSelect, - }); - - void createAuditEntry({ - db: ctx.db, - entityType: "RateCardLine", - entityId: input.lineId, - action: "UPDATE", - userId: ctx.dbUser?.id, - before: before as unknown as Record, - after: updated as unknown as Record, - source: "ui", - }); - - return updated; - }), + .input(rateCardUpdateLineInputSchema) + .mutation(({ ctx, input }) => updateRateCardLine(ctx, input)), deleteLine: managerProcedure - .input(z.object({ lineId: z.string() })) - .mutation(async ({ ctx, input }) => { - const line = await findUniqueOrThrow( - ctx.db.rateCardLine.findUnique({ where: { id: input.lineId } }), - "Rate card line", - ); - - await ctx.db.rateCardLine.delete({ where: { id: input.lineId } }); - - void createAuditEntry({ - db: ctx.db, - entityType: "RateCardLine", - entityId: input.lineId, - action: "DELETE", - userId: ctx.dbUser?.id, - before: line as unknown as Record, - source: "ui", - }); - - return { deleted: true }; - }), + .input(rateCardDeleteLineInputSchema) + .mutation(({ ctx, input }) => deleteRateCardLine(ctx, input)), // ─── Batch operations ────────────────────────────────────────────────────── replaceLines: managerProcedure - .input(z.object({ - rateCardId: z.string(), - lines: z.array(CreateRateCardLineSchema), - })) - .mutation(async ({ ctx, input }) => { - const rateCard = await findUniqueOrThrow( - ctx.db.rateCard.findUnique({ where: { id: input.rateCardId } }), - "Rate card", - ); - - const result = await ctx.db.$transaction(async (tx) => { - await tx.rateCardLine.deleteMany({ where: { rateCardId: input.rateCardId } }); - - const created = await Promise.all( - input.lines.map((line) => - tx.rateCardLine.create({ - data: { - rateCardId: input.rateCardId, - ...buildRateCardNestedLineCreateData(line), - }, - select: rateCardLineSelect, - }), - ), - ); - - return created; - }); - - void createAuditEntry({ - db: ctx.db, - entityType: "RateCard", - entityId: input.rateCardId, - entityName: rateCard.name, - action: "UPDATE", - userId: ctx.dbUser?.id, - after: buildRateCardReplaceLinesAuditAfter(result.length), - source: "ui", - summary: `Replaced all lines with ${result.length} new lines`, - }); - - return result; - }), + .input(rateCardReplaceLinesInputSchema) + .mutation(({ ctx, input }) => replaceRateCardLines(ctx, input)), // ─── Rate resolution ─────────────────────────────────────────────────────── resolveRateLine: controllerProcedure - .input(z.object({ - rateCardId: z.string(), - roleId: z.string().optional(), - chapter: z.string().optional(), - location: z.string().optional(), - seniority: z.string().optional(), - workType: z.string().optional(), - })) - .query(async ({ ctx, input }) => { - const { rateCardId, ...criteria } = input; - - // Find the most specific matching line (most criteria matched wins) - const lines = await ctx.db.rateCardLine.findMany({ - where: { rateCardId }, - select: rateCardLineSelect, - }); - return resolveBestRateLineMatch(lines, criteria); - }), + .input(rateCardResolveLineInputSchema) + .query(({ ctx, input }) => resolveRateCardLine(ctx, input)), });