refactor(api): extract dispo management support

This commit is contained in:
2026-03-31 14:31:59 +02:00
parent 5be1ef15dd
commit aeffb2a069
3 changed files with 171 additions and 73 deletions
@@ -0,0 +1,65 @@
import {
DispoStagedRecordType,
ImportBatchStatus,
StagedRecordStatus,
} from "@capakraken/db";
import { TRPCError } from "@trpc/server";
import { describe, expect, it } from "vitest";
import {
assertImportBatchCancelable,
buildCancelledImportBatchUpdateData,
buildDispoImportCommitAuditSummary,
buildResolvedStagedRecordUpdateData,
resolveDispoStagedRecordStatus,
resolveDispoStagedRecordStoreKey,
terminalImportBatchStatuses,
} from "../router/dispo-management-support.js";
describe("dispo management support", () => {
it("guards terminal import batches from cancellation", () => {
expect(terminalImportBatchStatuses).toEqual([
ImportBatchStatus.COMMITTED,
ImportBatchStatus.CANCELLED,
]);
expect(() => assertImportBatchCancelable({
id: "batch_1",
status: ImportBatchStatus.PENDING,
})).not.toThrow();
expect(() => assertImportBatchCancelable({
id: "batch_1",
status: ImportBatchStatus.CANCELLED,
})).toThrowError(
new TRPCError({
code: "BAD_REQUEST",
message: 'Cannot cancel batch in status "CANCELLED"',
}),
);
expect(buildCancelledImportBatchUpdateData()).toEqual({
status: ImportBatchStatus.CANCELLED,
});
});
it("maps staged-record actions and store keys", () => {
expect(resolveDispoStagedRecordStatus("APPROVE")).toBe(StagedRecordStatus.APPROVED);
expect(resolveDispoStagedRecordStatus("REJECT")).toBe(StagedRecordStatus.REJECTED);
expect(resolveDispoStagedRecordStatus("SKIP")).toBe(StagedRecordStatus.REJECTED);
expect(buildResolvedStagedRecordUpdateData("APPROVE")).toEqual({
status: StagedRecordStatus.APPROVED,
});
expect(resolveDispoStagedRecordStoreKey(DispoStagedRecordType.RESOURCE)).toBe("stagedResource");
expect(resolveDispoStagedRecordStoreKey(DispoStagedRecordType.AVAILABILITY_RULE)).toBe("stagedAvailabilityRule");
expect(resolveDispoStagedRecordStoreKey(DispoStagedRecordType.UNRESOLVED)).toBe("stagedUnresolvedRecord");
});
it("builds commit audit summaries", () => {
expect(buildDispoImportCommitAuditSummary({
created: 4,
rejected: 1,
})).toBe('Committed import batch ({"created":4,"rejected":1})');
});
});
@@ -0,0 +1,89 @@
import {
DispoStagedRecordType,
ImportBatchStatus,
StagedRecordStatus,
} from "@capakraken/db";
import { TRPCError } from "@trpc/server";
type StagedRecordAction = "APPROVE" | "REJECT" | "SKIP";
export const terminalImportBatchStatuses: readonly ImportBatchStatus[] = [
ImportBatchStatus.COMMITTED,
ImportBatchStatus.CANCELLED,
];
export function assertImportBatchCancelable(input: {
id: string;
status: ImportBatchStatus;
}): void {
if (terminalImportBatchStatuses.includes(input.status)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Cannot cancel batch in status "${input.status}"`,
});
}
}
export function resolveDispoStagedRecordStatus(
action: StagedRecordAction,
): StagedRecordStatus {
switch (action) {
case "APPROVE":
return StagedRecordStatus.APPROVED;
case "REJECT":
case "SKIP":
return StagedRecordStatus.REJECTED;
}
}
export function resolveDispoStagedRecordStoreKey(
recordType: DispoStagedRecordType,
): keyof DispoStagedRecordStores {
switch (recordType) {
case DispoStagedRecordType.RESOURCE:
return "stagedResource";
case DispoStagedRecordType.CLIENT:
return "stagedClient";
case DispoStagedRecordType.PROJECT:
return "stagedProject";
case DispoStagedRecordType.ASSIGNMENT:
return "stagedAssignment";
case DispoStagedRecordType.VACATION:
return "stagedVacation";
case DispoStagedRecordType.AVAILABILITY_RULE:
return "stagedAvailabilityRule";
case DispoStagedRecordType.UNRESOLVED:
return "stagedUnresolvedRecord";
default:
throw new TRPCError({
code: "BAD_REQUEST",
message: `Unknown record type: ${recordType as string}`,
});
}
}
export type DispoStagedRecordStores = {
stagedResource: { update: Function };
stagedClient: { update: Function };
stagedProject: { update: Function };
stagedAssignment: { update: Function };
stagedVacation: { update: Function };
stagedAvailabilityRule: { update: Function };
stagedUnresolvedRecord: { update: Function };
};
export function buildCancelledImportBatchUpdateData() {
return { status: ImportBatchStatus.CANCELLED } as const;
}
export function buildResolvedStagedRecordUpdateData(
action: StagedRecordAction,
) {
return { status: resolveDispoStagedRecordStatus(action) } as const;
}
export function buildDispoImportCommitAuditSummary(
counts: Record<string, unknown>,
): string {
return `Committed import batch (${JSON.stringify(counts)})`;
}
+17 -73
View File
@@ -1,11 +1,17 @@
import {
DispoStagedRecordType,
ImportBatchStatus,
StagedRecordStatus,
} from "@capakraken/db";
import { commitDispoImportBatch } from "@capakraken/application";
import { TRPCError } from "@trpc/server";
import { createAuditEntry } from "../lib/audit.js";
import {
assertImportBatchCancelable,
buildCancelledImportBatchUpdateData,
buildDispoImportCommitAuditSummary,
buildResolvedStagedRecordUpdateData,
resolveDispoStagedRecordStoreKey,
type DispoStagedRecordStores,
} from "./dispo-management-support.js";
type AuditDb = Parameters<typeof createAuditEntry>[0]["db"];
type CommitDb = Parameters<typeof commitDispoImportBatch>[0];
@@ -24,21 +30,11 @@ export async function cancelImportBatch(
if (!batch) {
throw new TRPCError({ code: "NOT_FOUND", message: `Import batch "${input.id}" not found` });
}
const terminalStatuses: ImportBatchStatus[] = [
ImportBatchStatus.COMMITTED,
ImportBatchStatus.CANCELLED,
];
if (terminalStatuses.includes(batch.status)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Cannot cancel batch in status "${batch.status}"`,
});
}
assertImportBatchCancelable({ id: input.id, status: batch.status });
const cancelled = await db.importBatch.update({
where: { id: input.id },
data: { status: ImportBatchStatus.CANCELLED },
data: buildCancelledImportBatchUpdateData(),
});
void createAuditEntry({
@@ -56,66 +52,14 @@ export async function cancelImportBatch(
}
export async function resolveStagedRecord(
db: {
stagedResource: { update: Function };
stagedClient: { update: Function };
stagedProject: { update: Function };
stagedAssignment: { update: Function };
stagedVacation: { update: Function };
stagedAvailabilityRule: { update: Function };
stagedUnresolvedRecord: { update: Function };
},
db: DispoStagedRecordStores,
input: { action: "APPROVE" | "REJECT" | "SKIP"; id: string; recordType: DispoStagedRecordType },
) {
const statusMap: Record<string, StagedRecordStatus> = {
APPROVE: StagedRecordStatus.APPROVED,
REJECT: StagedRecordStatus.REJECTED,
SKIP: StagedRecordStatus.REJECTED,
};
const nextStatus = statusMap[input.action]!;
switch (input.recordType) {
case DispoStagedRecordType.RESOURCE:
return db.stagedResource.update({
where: { id: input.id },
data: { status: nextStatus },
});
case DispoStagedRecordType.CLIENT:
return db.stagedClient.update({
where: { id: input.id },
data: { status: nextStatus },
});
case DispoStagedRecordType.PROJECT:
return db.stagedProject.update({
where: { id: input.id },
data: { status: nextStatus },
});
case DispoStagedRecordType.ASSIGNMENT:
return db.stagedAssignment.update({
where: { id: input.id },
data: { status: nextStatus },
});
case DispoStagedRecordType.VACATION:
return db.stagedVacation.update({
where: { id: input.id },
data: { status: nextStatus },
});
case DispoStagedRecordType.AVAILABILITY_RULE:
return db.stagedAvailabilityRule.update({
where: { id: input.id },
data: { status: nextStatus },
});
case DispoStagedRecordType.UNRESOLVED:
return db.stagedUnresolvedRecord.update({
where: { id: input.id },
data: { status: nextStatus },
});
default:
throw new TRPCError({
code: "BAD_REQUEST",
message: `Unknown record type: ${input.recordType as string}`,
});
}
const storeKey = resolveDispoStagedRecordStoreKey(input.recordType);
return db[storeKey].update({
where: { id: input.id },
data: buildResolvedStagedRecordUpdateData(input.action),
});
}
export async function commitImportBatch(
@@ -140,7 +84,7 @@ export async function commitImportBatch(
entityId: input.importBatchId,
entityName: input.importBatchId,
action: "IMPORT",
summary: `Committed import batch (${JSON.stringify(counts)})`,
summary: buildDispoImportCommitAuditSummary(counts),
after: counts,
source: "ui",
...(input.userId !== undefined ? { userId: input.userId } : {}),