diff --git a/docs/api-router-procedure-support-backlog.md b/docs/api-router-procedure-support-backlog.md index 8be5620..0fc4cdb 100644 --- a/docs/api-router-procedure-support-backlog.md +++ b/docs/api-router-procedure-support-backlog.md @@ -24,6 +24,7 @@ Done - `utilization-category` - `system-role-config` - `audit-log` +- `calculation-rules` Ready next - none in the conflict-safe backlog diff --git a/packages/api/src/__tests__/calculation-rule-procedure-support.test.ts b/packages/api/src/__tests__/calculation-rule-procedure-support.test.ts new file mode 100644 index 0000000..d38804b --- /dev/null +++ b/packages/api/src/__tests__/calculation-rule-procedure-support.test.ts @@ -0,0 +1,192 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PROJECT_BRIEF_SELECT } from "../db/selects.js"; +import { createAuditEntry } from "../lib/audit.js"; +import { + createCalculationRule, + deleteCalculationRule, + getCalculationRuleById, + listActiveCalculationRules, + listCalculationRules, + updateCalculationRule, +} from "../router/calculation-rule-procedure-support.js"; + +vi.mock("../lib/audit.js", () => ({ + createAuditEntry: vi.fn(), +})); + +function createContext(db: Record) { + return { + db: db as never, + dbUser: { id: "user_mgr" }, + }; +} + +describe("calculation-rule-procedure-support", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("lists calculation rules with the expected project include", async () => { + const findMany = vi.fn().mockResolvedValue([{ id: "calc_1" }]); + const ctx = createContext({ + calculationRule: { findMany }, + }); + + const result = await listCalculationRules(ctx); + + expect(result).toEqual([{ id: "calc_1" }]); + expect(findMany).toHaveBeenCalledWith({ + orderBy: [{ priority: "desc" }, { name: "asc" }], + include: { project: { select: PROJECT_BRIEF_SELECT } }, + }); + }); + + it("gets a rule by id with the expected project include", async () => { + const findUnique = vi.fn().mockResolvedValue({ id: "calc_1", name: "Rush Fee" }); + const ctx = createContext({ + calculationRule: { findUnique }, + }); + + const result = await getCalculationRuleById(ctx, { id: "calc_1" }); + + expect(result).toEqual({ id: "calc_1", name: "Rush Fee" }); + expect(findUnique).toHaveBeenCalledWith({ + where: { id: "calc_1" }, + include: { project: { select: PROJECT_BRIEF_SELECT } }, + }); + }); + + it("lists only active rules for engine use", async () => { + const findMany = vi.fn().mockResolvedValue([{ id: "calc_2", isActive: true }]); + const ctx = createContext({ + calculationRule: { findMany }, + }); + + const result = await listActiveCalculationRules(ctx); + + expect(result).toEqual([{ id: "calc_2", isActive: true }]); + expect(findMany).toHaveBeenCalledWith({ + where: { isActive: true }, + orderBy: [{ priority: "desc" }], + }); + }); + + it("creates a rule and records an audit entry", async () => { + const created = { + id: "calc_3", + name: "Vacation Discount", + triggerType: "VACATION", + costEffect: "REDUCE", + chargeabilityEffect: "COUNT", + priority: 10, + isActive: true, + }; + const create = vi.fn().mockResolvedValue(created); + const ctx = createContext({ + calculationRule: { create }, + }); + + const result = await createCalculationRule(ctx, { + name: "Vacation Discount", + triggerType: "VACATION", + costEffect: "REDUCE", + chargeabilityEffect: "COUNT", + priority: 10, + isActive: true, + }); + + expect(result).toBe(created); + expect(create).toHaveBeenCalledWith({ + data: { + name: "Vacation Discount", + triggerType: "VACATION", + costEffect: "REDUCE", + chargeabilityEffect: "COUNT", + priority: 10, + isActive: true, + }, + }); + expect(createAuditEntry).toHaveBeenCalledWith(expect.objectContaining({ + db: ctx.db, + entityType: "CalculationRule", + entityId: "calc_3", + entityName: "Vacation Discount", + action: "CREATE", + userId: "user_mgr", + after: created, + source: "ui", + })); + }); + + it("updates a rule and records before/after audit snapshots", async () => { + const before = { + id: "calc_4", + name: "Rush Fee", + priority: 90, + isActive: true, + }; + const after = { + id: "calc_4", + name: "Rush Fee", + priority: 95, + isActive: false, + }; + const findUnique = vi.fn().mockResolvedValue(before); + const update = vi.fn().mockResolvedValue(after); + const ctx = createContext({ + calculationRule: { findUnique, update }, + }); + + const result = await updateCalculationRule(ctx, { + id: "calc_4", + priority: 95, + isActive: false, + }); + + expect(result).toBe(after); + expect(findUnique).toHaveBeenCalledWith({ where: { id: "calc_4" } }); + expect(update).toHaveBeenCalledWith({ + where: { id: "calc_4" }, + data: { + priority: 95, + isActive: false, + }, + }); + expect(createAuditEntry).toHaveBeenCalledWith(expect.objectContaining({ + db: ctx.db, + entityType: "CalculationRule", + entityId: "calc_4", + entityName: "Rush Fee", + action: "UPDATE", + userId: "user_mgr", + before, + after, + source: "ui", + })); + }); + + it("deletes a rule and records a delete audit entry", async () => { + const rule = { id: "calc_5", name: "Legacy Rule" }; + const findUnique = vi.fn().mockResolvedValue(rule); + const deleteFn = vi.fn().mockResolvedValue(rule); + const ctx = createContext({ + calculationRule: { findUnique, delete: deleteFn }, + }); + + const result = await deleteCalculationRule(ctx, { id: "calc_5" }); + + expect(result).toEqual({ success: true }); + expect(findUnique).toHaveBeenCalledWith({ where: { id: "calc_5" } }); + expect(deleteFn).toHaveBeenCalledWith({ where: { id: "calc_5" } }); + expect(createAuditEntry).toHaveBeenCalledWith(expect.objectContaining({ + db: ctx.db, + entityType: "CalculationRule", + entityId: "calc_5", + entityName: "Legacy Rule", + action: "DELETE", + userId: "user_mgr", + before: rule, + source: "ui", + })); + }); +}); diff --git a/packages/api/src/router/calculation-rule-procedure-support.ts b/packages/api/src/router/calculation-rule-procedure-support.ts new file mode 100644 index 0000000..6b3d077 --- /dev/null +++ b/packages/api/src/router/calculation-rule-procedure-support.ts @@ -0,0 +1,129 @@ +import { + CreateCalculationRuleSchema, + UpdateCalculationRuleSchema, +} from "@capakraken/shared"; +import { z } from "zod"; +import { findUniqueOrThrow } from "../db/helpers.js"; +import { PROJECT_BRIEF_SELECT } from "../db/selects.js"; +import { createAuditEntry } from "../lib/audit.js"; +import type { TRPCContext } from "../trpc.js"; +import { + buildCalculationRuleCreateData, + buildCalculationRuleUpdateData, +} from "./calculation-rule-support.js"; + +export const CalculationRuleIdInputSchema = z.object({ + id: z.string(), +}); + +export const CreateCalculationRuleInputSchema = CreateCalculationRuleSchema; +export const UpdateCalculationRuleInputSchema = UpdateCalculationRuleSchema; + +type CalculationRuleProcedureContext = Pick; + +function withAuditUser(userId: string | undefined) { + return userId ? { userId } : {}; +} + +export async function listCalculationRules(ctx: CalculationRuleProcedureContext) { + return ctx.db.calculationRule.findMany({ + orderBy: [{ priority: "desc" }, { name: "asc" }], + include: { project: { select: PROJECT_BRIEF_SELECT } }, + }); +} + +export async function getCalculationRuleById( + ctx: CalculationRuleProcedureContext, + input: z.infer, +) { + return findUniqueOrThrow( + ctx.db.calculationRule.findUnique({ + where: { id: input.id }, + include: { project: { select: PROJECT_BRIEF_SELECT } }, + }), + "CalculationRule", + ); +} + +export async function listActiveCalculationRules(ctx: CalculationRuleProcedureContext) { + return ctx.db.calculationRule.findMany({ + where: { isActive: true }, + orderBy: [{ priority: "desc" }], + }); +} + +export async function createCalculationRule( + ctx: CalculationRuleProcedureContext, + input: z.infer, +) { + const rule = await ctx.db.calculationRule.create({ + data: buildCalculationRuleCreateData(input), + }); + + void createAuditEntry({ + db: ctx.db, + entityType: "CalculationRule", + entityId: rule.id, + entityName: rule.name, + action: "CREATE", + ...withAuditUser(ctx.dbUser?.id), + after: rule as unknown as Record, + source: "ui", + }); + + return rule; +} + +export async function updateCalculationRule( + ctx: CalculationRuleProcedureContext, + input: z.infer, +) { + const { id, ...data } = input; + const before = await findUniqueOrThrow( + ctx.db.calculationRule.findUnique({ where: { id } }), + "CalculationRule", + ); + + const updated = await ctx.db.calculationRule.update({ + where: { id }, + data: buildCalculationRuleUpdateData(data), + }); + + void createAuditEntry({ + db: ctx.db, + entityType: "CalculationRule", + entityId: 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 deleteCalculationRule( + ctx: CalculationRuleProcedureContext, + input: z.infer, +) { + const rule = await findUniqueOrThrow( + ctx.db.calculationRule.findUnique({ where: { id: input.id } }), + "CalculationRule", + ); + await ctx.db.calculationRule.delete({ where: { id: input.id } }); + + void createAuditEntry({ + db: ctx.db, + entityType: "CalculationRule", + entityId: input.id, + entityName: rule.name, + action: "DELETE", + ...withAuditUser(ctx.dbUser?.id), + before: rule as unknown as Record, + source: "ui", + }); + + return { success: true }; +} diff --git a/packages/api/src/router/calculation-rules.ts b/packages/api/src/router/calculation-rules.ts index ac62a16..e0e4690 100644 --- a/packages/api/src/router/calculation-rules.ts +++ b/packages/api/src/router/calculation-rules.ts @@ -1,115 +1,35 @@ -import { - CreateCalculationRuleSchema, - UpdateCalculationRuleSchema, -} from "@capakraken/shared"; -import { z } from "zod"; -import { findUniqueOrThrow } from "../db/helpers.js"; -import { PROJECT_BRIEF_SELECT } from "../db/selects.js"; import { createTRPCRouter, controllerProcedure, managerProcedure } from "../trpc.js"; -import { createAuditEntry } from "../lib/audit.js"; import { - buildCalculationRuleCreateData, - buildCalculationRuleUpdateData, -} from "./calculation-rule-support.js"; + CalculationRuleIdInputSchema, + CreateCalculationRuleInputSchema, + createCalculationRule, + deleteCalculationRule, + getCalculationRuleById, + listActiveCalculationRules, + listCalculationRules, + UpdateCalculationRuleInputSchema, + updateCalculationRule, +} from "./calculation-rule-procedure-support.js"; export const calculationRuleRouter = createTRPCRouter({ - list: controllerProcedure.query(async ({ ctx }) => { - return ctx.db.calculationRule.findMany({ - orderBy: [{ priority: "desc" }, { name: "asc" }], - include: { project: { select: PROJECT_BRIEF_SELECT } }, - }); - }), + list: controllerProcedure.query(({ ctx }) => listCalculationRules(ctx)), getById: controllerProcedure - .input(z.object({ id: z.string() })) - .query(async ({ ctx, input }) => { - return findUniqueOrThrow( - ctx.db.calculationRule.findUnique({ - where: { id: input.id }, - include: { project: { select: PROJECT_BRIEF_SELECT } }, - }), - "CalculationRule", - ); - }), + .input(CalculationRuleIdInputSchema) + .query(({ ctx, input }) => getCalculationRuleById(ctx, input)), /** Get all active rules (optimized for engine use — no project include) */ - getActive: controllerProcedure.query(async ({ ctx }) => { - return ctx.db.calculationRule.findMany({ - where: { isActive: true }, - orderBy: [{ priority: "desc" }], - }); - }), + getActive: controllerProcedure.query(({ ctx }) => listActiveCalculationRules(ctx)), create: managerProcedure - .input(CreateCalculationRuleSchema) - .mutation(async ({ ctx, input }) => { - const rule = await ctx.db.calculationRule.create({ - data: buildCalculationRuleCreateData(input), - }); - - void createAuditEntry({ - db: ctx.db, - entityType: "CalculationRule", - entityId: rule.id, - entityName: rule.name, - action: "CREATE", - userId: ctx.dbUser?.id, - after: rule as unknown as Record, - source: "ui", - }); - - return rule; - }), + .input(CreateCalculationRuleInputSchema) + .mutation(({ ctx, input }) => createCalculationRule(ctx, input)), update: managerProcedure - .input(UpdateCalculationRuleSchema) - .mutation(async ({ ctx, input }) => { - const { id, ...data } = input; - const before = await findUniqueOrThrow( - ctx.db.calculationRule.findUnique({ where: { id } }), - "CalculationRule", - ); - - const updated = await ctx.db.calculationRule.update({ - where: { id }, - data: buildCalculationRuleUpdateData(data), - }); - - void createAuditEntry({ - db: ctx.db, - entityType: "CalculationRule", - entityId: 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(UpdateCalculationRuleInputSchema) + .mutation(({ ctx, input }) => updateCalculationRule(ctx, input)), delete: managerProcedure - .input(z.object({ id: z.string() })) - .mutation(async ({ ctx, input }) => { - const rule = await findUniqueOrThrow( - ctx.db.calculationRule.findUnique({ where: { id: input.id } }), - "CalculationRule", - ); - await ctx.db.calculationRule.delete({ where: { id: input.id } }); - - void createAuditEntry({ - db: ctx.db, - entityType: "CalculationRule", - entityId: input.id, - entityName: rule.name, - action: "DELETE", - userId: ctx.dbUser?.id, - before: rule as unknown as Record, - source: "ui", - }); - - return { success: true }; - }), + .input(CalculationRuleIdInputSchema) + .mutation(({ ctx, input }) => deleteCalculationRule(ctx, input)), });