refactor(api): extract calculation rule procedures

This commit is contained in:
2026-03-31 21:15:02 +02:00
parent 06642e6dc9
commit 70171d43fd
4 changed files with 342 additions and 100 deletions
@@ -24,6 +24,7 @@ Done
- `utilization-category`
- `system-role-config`
- `audit-log`
- `calculation-rules`
Ready next
- none in the conflict-safe backlog
@@ -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<string, unknown>) {
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",
}));
});
});
@@ -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<TRPCContext, "db" | "dbUser">;
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<typeof CalculationRuleIdInputSchema>,
) {
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<typeof CreateCalculationRuleInputSchema>,
) {
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<string, unknown>,
source: "ui",
});
return rule;
}
export async function updateCalculationRule(
ctx: CalculationRuleProcedureContext,
input: z.infer<typeof UpdateCalculationRuleInputSchema>,
) {
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<string, unknown>,
after: updated as unknown as Record<string, unknown>,
source: "ui",
});
return updated;
}
export async function deleteCalculationRule(
ctx: CalculationRuleProcedureContext,
input: z.infer<typeof CalculationRuleIdInputSchema>,
) {
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<string, unknown>,
source: "ui",
});
return { success: true };
}
+20 -100
View File
@@ -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<string, unknown>,
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<string, unknown>,
after: updated as unknown as Record<string, unknown>,
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<string, unknown>,
source: "ui",
});
return { success: true };
}),
.input(CalculationRuleIdInputSchema)
.mutation(({ ctx, input }) => deleteCalculationRule(ctx, input)),
});