diff --git a/packages/api/src/__tests__/dispo-management-support.test.ts b/packages/api/src/__tests__/dispo-management-support.test.ts new file mode 100644 index 0000000..09bdad5 --- /dev/null +++ b/packages/api/src/__tests__/dispo-management-support.test.ts @@ -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})'); + }); +}); diff --git a/packages/api/src/router/dispo-management-support.ts b/packages/api/src/router/dispo-management-support.ts new file mode 100644 index 0000000..eda00d2 --- /dev/null +++ b/packages/api/src/router/dispo-management-support.ts @@ -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 { + return `Committed import batch (${JSON.stringify(counts)})`; +} diff --git a/packages/api/src/router/dispo-management.ts b/packages/api/src/router/dispo-management.ts index 5968fb9..216f1e4 100644 --- a/packages/api/src/router/dispo-management.ts +++ b/packages/api/src/router/dispo-management.ts @@ -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[0]["db"]; type CommitDb = Parameters[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 = { - 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 } : {}),