From 0760887a20738ea3243a86f4b316f382f70ca1eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 12:13:28 +0200 Subject: [PATCH] refactor(api): extract dispo router support modules --- packages/api/src/router/dispo-management.ts | 150 +++++++++++ packages/api/src/router/dispo-read.ts | 194 ++++++++++++++ packages/api/src/router/dispo.ts | 282 ++------------------ 3 files changed, 366 insertions(+), 260 deletions(-) create mode 100644 packages/api/src/router/dispo-management.ts create mode 100644 packages/api/src/router/dispo-read.ts diff --git a/packages/api/src/router/dispo-management.ts b/packages/api/src/router/dispo-management.ts new file mode 100644 index 0000000..5968fb9 --- /dev/null +++ b/packages/api/src/router/dispo-management.ts @@ -0,0 +1,150 @@ +import { + DispoStagedRecordType, + ImportBatchStatus, + StagedRecordStatus, +} from "@capakraken/db"; +import { commitDispoImportBatch } from "@capakraken/application"; +import { TRPCError } from "@trpc/server"; +import { createAuditEntry } from "../lib/audit.js"; + +type AuditDb = Parameters[0]["db"]; +type CommitDb = Parameters[0]; + +export async function cancelImportBatch( + db: AuditDb & { + importBatch: { findUnique: Function; update: Function }; + }, + input: { id: string; userId?: string | undefined }, +) { + const batch = await db.importBatch.findUnique({ + where: { id: input.id }, + select: { id: true, status: true }, + }); + + 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}"`, + }); + } + + const cancelled = await db.importBatch.update({ + where: { id: input.id }, + data: { status: ImportBatchStatus.CANCELLED }, + }); + + void createAuditEntry({ + db, + entityType: "ImportBatch", + entityId: input.id, + action: "UPDATE", + summary: "Cancelled import batch", + after: cancelled as Record, + source: "ui", + ...(input.userId !== undefined ? { userId: input.userId } : {}), + }); + + return cancelled; +} + +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 }; + }, + 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}`, + }); + } +} + +export async function commitImportBatch( + db: CommitDb, + input: { + importBatchId: string; + allowTbdUnresolved?: boolean | undefined; + importTbdProjects?: boolean | undefined; + userId?: string | undefined; + }, +) { + const result = await commitDispoImportBatch(db, { + importBatchId: input.importBatchId, + ...(input.allowTbdUnresolved !== undefined ? { allowTbdUnresolved: input.allowTbdUnresolved } : {}), + ...(input.importTbdProjects !== undefined ? { importTbdProjects: input.importTbdProjects } : {}), + }); + + const counts = result as unknown as Record; + void createAuditEntry({ + db: db as AuditDb, + entityType: "ImportBatch", + entityId: input.importBatchId, + entityName: input.importBatchId, + action: "IMPORT", + summary: `Committed import batch (${JSON.stringify(counts)})`, + after: counts, + source: "ui", + ...(input.userId !== undefined ? { userId: input.userId } : {}), + }); + + return result; +} diff --git a/packages/api/src/router/dispo-read.ts b/packages/api/src/router/dispo-read.ts new file mode 100644 index 0000000..e36fdd7 --- /dev/null +++ b/packages/api/src/router/dispo-read.ts @@ -0,0 +1,194 @@ +import { + ImportBatchStatus, + StagedRecordStatus, +} from "@capakraken/db"; +import { TRPCError } from "@trpc/server"; + +type PaginationInput = { + cursor?: string | undefined; + limit: number; +}; + +function toPaginatedResult(items: T[], limit: number) { + let nextCursor: string | undefined; + if (items.length > limit) { + const next = items.pop(); + nextCursor = next?.id; + } + + return { items, nextCursor }; +} + +function toCursorPagination(input: PaginationInput) { + return { + take: input.limit + 1, + ...(input.cursor ? { cursor: { id: input.cursor }, skip: 1 } : {}), + }; +} + +export async function listImportBatches( + db: { importBatch: { findMany: Function } }, + input: PaginationInput & { status?: ImportBatchStatus | undefined }, +) { + const items = await db.importBatch.findMany({ + where: { + ...(input.status !== undefined ? { status: input.status } : {}), + }, + orderBy: { createdAt: "desc" }, + ...toCursorPagination(input), + }); + + return toPaginatedResult(items, input.limit); +} + +export async function getImportBatch( + db: { + importBatch: { findUnique: Function }; + stagedResource: { count: Function }; + stagedClient: { count: Function }; + stagedProject: { count: Function }; + stagedAssignment: { count: Function }; + stagedVacation: { count: Function }; + stagedAvailabilityRule: { count: Function }; + stagedUnresolvedRecord: { count: Function }; + }, + id: string, +) { + const batch = await db.importBatch.findUnique({ + where: { id }, + }); + + if (!batch) { + throw new TRPCError({ code: "NOT_FOUND", message: `Import batch "${id}" not found` }); + } + + const [ + resourceCount, + clientCount, + projectCount, + assignmentCount, + vacationCount, + availabilityRuleCount, + unresolvedCount, + ] = await Promise.all([ + db.stagedResource.count({ where: { importBatchId: id } }), + db.stagedClient.count({ where: { importBatchId: id } }), + db.stagedProject.count({ where: { importBatchId: id } }), + db.stagedAssignment.count({ where: { importBatchId: id } }), + db.stagedVacation.count({ where: { importBatchId: id } }), + db.stagedAvailabilityRule.count({ where: { importBatchId: id } }), + db.stagedUnresolvedRecord.count({ where: { importBatchId: id } }), + ]); + + return { + ...batch, + counts: { + assignmentCount, + availabilityRuleCount, + clientCount, + projectCount, + resourceCount, + unresolvedCount, + vacationCount, + }, + }; +} + +export async function listStagedResources( + db: { stagedResource: { findMany: Function } }, + input: PaginationInput & { + importBatchId: string; + status?: StagedRecordStatus | undefined; + }, +) { + const items = await db.stagedResource.findMany({ + where: { + importBatchId: input.importBatchId, + ...(input.status !== undefined ? { status: input.status } : {}), + }, + orderBy: { canonicalExternalId: "asc" }, + ...toCursorPagination(input), + }); + + return toPaginatedResult(items, input.limit); +} + +export async function listStagedProjects( + db: { stagedProject: { findMany: Function } }, + input: PaginationInput & { + importBatchId: string; + isTbd?: boolean | undefined; + status?: StagedRecordStatus | undefined; + }, +) { + const items = await db.stagedProject.findMany({ + where: { + importBatchId: input.importBatchId, + ...(input.status !== undefined ? { status: input.status } : {}), + ...(input.isTbd !== undefined ? { isTbd: input.isTbd } : {}), + }, + orderBy: { projectKey: "asc" }, + ...toCursorPagination(input), + }); + + return toPaginatedResult(items, input.limit); +} + +export async function listStagedAssignments( + db: { stagedAssignment: { findMany: Function } }, + input: PaginationInput & { + importBatchId: string; + resourceExternalId?: string | undefined; + status?: StagedRecordStatus | undefined; + }, +) { + const items = await db.stagedAssignment.findMany({ + where: { + importBatchId: input.importBatchId, + ...(input.status !== undefined ? { status: input.status } : {}), + ...(input.resourceExternalId !== undefined ? { resourceExternalId: input.resourceExternalId } : {}), + }, + orderBy: { createdAt: "asc" }, + ...toCursorPagination(input), + }); + + return toPaginatedResult(items, input.limit); +} + +export async function listStagedVacations( + db: { stagedVacation: { findMany: Function } }, + input: PaginationInput & { + importBatchId: string; + resourceExternalId?: string | undefined; + }, +) { + const items = await db.stagedVacation.findMany({ + where: { + importBatchId: input.importBatchId, + ...(input.resourceExternalId !== undefined ? { resourceExternalId: input.resourceExternalId } : {}), + }, + orderBy: { startDate: "asc" }, + ...toCursorPagination(input), + }); + + return toPaginatedResult(items, input.limit); +} + +export async function listStagedUnresolvedRecords( + db: { stagedUnresolvedRecord: { findMany: Function } }, + input: PaginationInput & { + importBatchId: string; + recordType?: string | undefined; + }, +) { + const items = await db.stagedUnresolvedRecord.findMany({ + where: { + importBatchId: input.importBatchId, + ...(input.recordType !== undefined ? { recordType: input.recordType } : {}), + }, + orderBy: { createdAt: "asc" }, + ...toCursorPagination(input), + }); + + return toPaginatedResult(items, input.limit); +} diff --git a/packages/api/src/router/dispo.ts b/packages/api/src/router/dispo.ts index 5ef35e9..0870f6c 100644 --- a/packages/api/src/router/dispo.ts +++ b/packages/api/src/router/dispo.ts @@ -5,13 +5,20 @@ import { } from "@capakraken/db"; import { assessDispoImportReadiness, - commitDispoImportBatch, stageDispoImportBatch, } from "@capakraken/application"; -import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { adminProcedure, createTRPCRouter } from "../trpc.js"; -import { createAuditEntry } from "../lib/audit.js"; +import { commitImportBatch, cancelImportBatch, resolveStagedRecord } from "./dispo-management.js"; +import { + getImportBatch, + listImportBatches, + listStagedAssignments, + listStagedProjects, + listStagedResources, + listStagedUnresolvedRecords, + listStagedVacations, +} from "./dispo-read.js"; // ─── Shared schemas ────────────────────────────────────────────────────────── @@ -93,24 +100,7 @@ export const dispoRouter = createTRPCRouter({ }), ) .query(async ({ ctx, input }) => { - const { cursor, limit, status } = input; - - const items = await ctx.db.importBatch.findMany({ - where: { - ...(status !== undefined ? { status } : {}), - }, - orderBy: { createdAt: "desc" }, - take: limit + 1, - ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), - }); - - let nextCursor: string | undefined; - if (items.length > limit) { - const next = items.pop(); - nextCursor = next?.id; - } - - return { items, nextCursor }; + return listImportBatches(ctx.db, input); }), // ── 4. getImportBatch ──────────────────────────────────────────────────── @@ -118,44 +108,7 @@ export const dispoRouter = createTRPCRouter({ getImportBatch: adminProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { - const batch = await ctx.db.importBatch.findUnique({ - where: { id: input.id }, - }); - - if (!batch) { - throw new TRPCError({ code: "NOT_FOUND", message: `Import batch "${input.id}" not found` }); - } - - const [ - resourceCount, - clientCount, - projectCount, - assignmentCount, - vacationCount, - availabilityRuleCount, - unresolvedCount, - ] = await Promise.all([ - ctx.db.stagedResource.count({ where: { importBatchId: input.id } }), - ctx.db.stagedClient.count({ where: { importBatchId: input.id } }), - ctx.db.stagedProject.count({ where: { importBatchId: input.id } }), - ctx.db.stagedAssignment.count({ where: { importBatchId: input.id } }), - ctx.db.stagedVacation.count({ where: { importBatchId: input.id } }), - ctx.db.stagedAvailabilityRule.count({ where: { importBatchId: input.id } }), - ctx.db.stagedUnresolvedRecord.count({ where: { importBatchId: input.id } }), - ]); - - return { - ...batch, - counts: { - assignmentCount, - availabilityRuleCount, - clientCount, - projectCount, - resourceCount, - unresolvedCount, - vacationCount, - }, - }; + return getImportBatch(ctx.db, input.id); }), // ── 5. cancelImportBatch ───────────────────────────────────────────────── @@ -163,43 +116,7 @@ export const dispoRouter = createTRPCRouter({ cancelImportBatch: adminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { - const batch = await ctx.db.importBatch.findUnique({ - where: { id: input.id }, - select: { id: true, status: true }, - }); - - 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}"`, - }); - } - - const cancelled = await ctx.db.importBatch.update({ - where: { id: input.id }, - data: { status: ImportBatchStatus.CANCELLED }, - }); - - void createAuditEntry({ - db: ctx.db, - entityType: "ImportBatch", - entityId: input.id, - action: "UPDATE", - userId: ctx.dbUser?.id, - summary: "Cancelled import batch", - after: cancelled as unknown as Record, - source: "ui", - }); - - return cancelled; + return cancelImportBatch(ctx.db, { id: input.id, userId: ctx.dbUser?.id }); }), // ── 6. listStagedResources ─────────────────────────────────────────────── @@ -212,25 +129,7 @@ export const dispoRouter = createTRPCRouter({ }), ) .query(async ({ ctx, input }) => { - const { cursor, importBatchId, limit, status } = input; - - const items = await ctx.db.stagedResource.findMany({ - where: { - importBatchId, - ...(status !== undefined ? { status } : {}), - }, - orderBy: { canonicalExternalId: "asc" }, - take: limit + 1, - ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), - }); - - let nextCursor: string | undefined; - if (items.length > limit) { - const next = items.pop(); - nextCursor = next?.id; - } - - return { items, nextCursor }; + return listStagedResources(ctx.db, input); }), // ── 7. listStagedProjects ──────────────────────────────────────────────── @@ -244,26 +143,7 @@ export const dispoRouter = createTRPCRouter({ }), ) .query(async ({ ctx, input }) => { - const { cursor, importBatchId, isTbd, limit, status } = input; - - const items = await ctx.db.stagedProject.findMany({ - where: { - importBatchId, - ...(status !== undefined ? { status } : {}), - ...(isTbd !== undefined ? { isTbd } : {}), - }, - orderBy: { projectKey: "asc" }, - take: limit + 1, - ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), - }); - - let nextCursor: string | undefined; - if (items.length > limit) { - const next = items.pop(); - nextCursor = next?.id; - } - - return { items, nextCursor }; + return listStagedProjects(ctx.db, input); }), // ── 8. listStagedAssignments ───────────────────────────────────────────── @@ -277,26 +157,7 @@ export const dispoRouter = createTRPCRouter({ }), ) .query(async ({ ctx, input }) => { - const { cursor, importBatchId, limit, resourceExternalId, status } = input; - - const items = await ctx.db.stagedAssignment.findMany({ - where: { - importBatchId, - ...(status !== undefined ? { status } : {}), - ...(resourceExternalId !== undefined ? { resourceExternalId } : {}), - }, - orderBy: { createdAt: "asc" }, - take: limit + 1, - ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), - }); - - let nextCursor: string | undefined; - if (items.length > limit) { - const next = items.pop(); - nextCursor = next?.id; - } - - return { items, nextCursor }; + return listStagedAssignments(ctx.db, input); }), // ── 9. listStagedVacations ─────────────────────────────────────────────── @@ -309,25 +170,7 @@ export const dispoRouter = createTRPCRouter({ }), ) .query(async ({ ctx, input }) => { - const { cursor, importBatchId, limit, resourceExternalId } = input; - - const items = await ctx.db.stagedVacation.findMany({ - where: { - importBatchId, - ...(resourceExternalId !== undefined ? { resourceExternalId } : {}), - }, - orderBy: { startDate: "asc" }, - take: limit + 1, - ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), - }); - - let nextCursor: string | undefined; - if (items.length > limit) { - const next = items.pop(); - nextCursor = next?.id; - } - - return { items, nextCursor }; + return listStagedVacations(ctx.db, input); }), // ── 10. listStagedUnresolvedRecords ────────────────────────────────────── @@ -340,25 +183,7 @@ export const dispoRouter = createTRPCRouter({ }), ) .query(async ({ ctx, input }) => { - const { cursor, importBatchId, limit, recordType } = input; - - const items = await ctx.db.stagedUnresolvedRecord.findMany({ - where: { - importBatchId, - ...(recordType !== undefined ? { recordType } : {}), - }, - orderBy: { createdAt: "asc" }, - take: limit + 1, - ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), - }); - - let nextCursor: string | undefined; - if (items.length > limit) { - const next = items.pop(); - nextCursor = next?.id; - } - - return { items, nextCursor }; + return listStagedUnresolvedRecords(ctx.db, input); }), // ── 11. resolveStagedRecord ────────────────────────────────────────────── @@ -372,56 +197,7 @@ export const dispoRouter = createTRPCRouter({ }), ) .mutation(async ({ ctx, input }) => { - const statusMap: Record = { - APPROVE: StagedRecordStatus.APPROVED, - REJECT: StagedRecordStatus.REJECTED, - SKIP: StagedRecordStatus.REJECTED, - }; - const nextStatus = statusMap[input.action]!; - - // Delegate table lookup based on record type - switch (input.recordType) { - case DispoStagedRecordType.RESOURCE: - return ctx.db.stagedResource.update({ - where: { id: input.id }, - data: { status: nextStatus }, - }); - case DispoStagedRecordType.CLIENT: - return ctx.db.stagedClient.update({ - where: { id: input.id }, - data: { status: nextStatus }, - }); - case DispoStagedRecordType.PROJECT: - return ctx.db.stagedProject.update({ - where: { id: input.id }, - data: { status: nextStatus }, - }); - case DispoStagedRecordType.ASSIGNMENT: - return ctx.db.stagedAssignment.update({ - where: { id: input.id }, - data: { status: nextStatus }, - }); - case DispoStagedRecordType.VACATION: - return ctx.db.stagedVacation.update({ - where: { id: input.id }, - data: { status: nextStatus }, - }); - case DispoStagedRecordType.AVAILABILITY_RULE: - return ctx.db.stagedAvailabilityRule.update({ - where: { id: input.id }, - data: { status: nextStatus }, - }); - case DispoStagedRecordType.UNRESOLVED: - return ctx.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}`, - }); - } + return resolveStagedRecord(ctx.db, input); }), // ── 12. commitImportBatch ──────────────────────────────────────────────── @@ -435,25 +211,11 @@ export const dispoRouter = createTRPCRouter({ }), ) .mutation(async ({ ctx, input }) => { - const result = await commitDispoImportBatch(ctx.db, { + return commitImportBatch(ctx.db, { importBatchId: input.importBatchId, - ...(input.allowTbdUnresolved !== undefined ? { allowTbdUnresolved: input.allowTbdUnresolved } : {}), - ...(input.importTbdProjects !== undefined ? { importTbdProjects: input.importTbdProjects } : {}), - }); - - const counts = result as unknown as Record; - void createAuditEntry({ - db: ctx.db, - entityType: "ImportBatch", - entityId: input.importBatchId, - entityName: input.importBatchId, - action: "IMPORT", + allowTbdUnresolved: input.allowTbdUnresolved, + importTbdProjects: input.importTbdProjects, userId: ctx.dbUser?.id, - summary: `Committed import batch (${JSON.stringify(counts)})`, - after: counts, - source: "ui", }); - - return result; }), });