import { ImportBatchStatus, StagedRecordStatus, DispoStagedRecordType, } 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"; // ─── Shared schemas ────────────────────────────────────────────────────────── const paginationSchema = z.object({ cursor: z.string().optional(), limit: z.number().int().min(1).max(200).default(50), }); const importBatchStatusSchema = z.nativeEnum(ImportBatchStatus); const stagedRecordStatusSchema = z.nativeEnum(StagedRecordStatus); const stagedRecordTypeSchema = z.nativeEnum(DispoStagedRecordType); const workbookPathSchema = z .string() .trim() .min(1, "Workbook path is required.") .refine((value) => value.toLowerCase().endsWith(".xlsx"), { message: "Only .xlsx workbook paths are supported.", }); // ─── Router ────────────────────────────────────────────────────────────────── export const dispoRouter = createTRPCRouter({ // ── 1. stageImportBatch ────────────────────────────────────────────────── stageImportBatch: adminProcedure .input( z.object({ chargeabilityWorkbookPath: workbookPathSchema, costWorkbookPath: workbookPathSchema.optional(), notes: z.string().nullish(), planningWorkbookPath: workbookPathSchema, referenceWorkbookPath: workbookPathSchema, rosterWorkbookPath: workbookPathSchema.optional(), }), ) .mutation(async ({ ctx, input }) => { return stageDispoImportBatch(ctx.db, { chargeabilityWorkbookPath: input.chargeabilityWorkbookPath, planningWorkbookPath: input.planningWorkbookPath, referenceWorkbookPath: input.referenceWorkbookPath, ...(input.costWorkbookPath !== undefined ? { costWorkbookPath: input.costWorkbookPath } : {}), ...(input.notes !== undefined ? { notes: input.notes } : {}), ...(input.rosterWorkbookPath !== undefined ? { rosterWorkbookPath: input.rosterWorkbookPath } : {}), }); }), // ── 2. validateImportBatch ─────────────────────────────────────────────── validateImportBatch: adminProcedure .input( z.object({ chargeabilityWorkbookPath: workbookPathSchema, costWorkbookPath: workbookPathSchema.optional(), importBatchId: z.string().optional(), notes: z.string().nullish(), planningWorkbookPath: workbookPathSchema, referenceWorkbookPath: workbookPathSchema, rosterWorkbookPath: workbookPathSchema.optional(), }), ) .query(async ({ input }) => { return assessDispoImportReadiness({ chargeabilityWorkbookPath: input.chargeabilityWorkbookPath, planningWorkbookPath: input.planningWorkbookPath, referenceWorkbookPath: input.referenceWorkbookPath, ...(input.costWorkbookPath !== undefined ? { costWorkbookPath: input.costWorkbookPath } : {}), ...(input.importBatchId !== undefined ? { importBatchId: input.importBatchId } : {}), ...(input.notes !== undefined ? { notes: input.notes } : {}), ...(input.rosterWorkbookPath !== undefined ? { rosterWorkbookPath: input.rosterWorkbookPath } : {}), }); }), // ── 3. listImportBatches ───────────────────────────────────────────────── listImportBatches: adminProcedure .input( paginationSchema.extend({ status: importBatchStatusSchema.optional(), }), ) .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 }; }), // ── 4. getImportBatch ──────────────────────────────────────────────────── 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, }, }; }), // ── 5. cancelImportBatch ───────────────────────────────────────────────── 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; }), // ── 6. listStagedResources ─────────────────────────────────────────────── listStagedResources: adminProcedure .input( paginationSchema.extend({ importBatchId: z.string(), status: stagedRecordStatusSchema.optional(), }), ) .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 }; }), // ── 7. listStagedProjects ──────────────────────────────────────────────── listStagedProjects: adminProcedure .input( paginationSchema.extend({ importBatchId: z.string(), isTbd: z.boolean().optional(), status: stagedRecordStatusSchema.optional(), }), ) .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 }; }), // ── 8. listStagedAssignments ───────────────────────────────────────────── listStagedAssignments: adminProcedure .input( paginationSchema.extend({ importBatchId: z.string(), resourceExternalId: z.string().optional(), status: stagedRecordStatusSchema.optional(), }), ) .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 }; }), // ── 9. listStagedVacations ─────────────────────────────────────────────── listStagedVacations: adminProcedure .input( paginationSchema.extend({ importBatchId: z.string(), resourceExternalId: z.string().optional(), }), ) .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 }; }), // ── 10. listStagedUnresolvedRecords ────────────────────────────────────── listStagedUnresolvedRecords: adminProcedure .input( paginationSchema.extend({ importBatchId: z.string(), recordType: stagedRecordTypeSchema.optional(), }), ) .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 }; }), // ── 11. resolveStagedRecord ────────────────────────────────────────────── resolveStagedRecord: adminProcedure .input( z.object({ action: z.enum(["APPROVE", "REJECT", "SKIP"]), id: z.string(), recordType: stagedRecordTypeSchema, }), ) .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}`, }); } }), // ── 12. commitImportBatch ──────────────────────────────────────────────── commitImportBatch: adminProcedure .input( z.object({ allowTbdUnresolved: z.boolean().optional(), importBatchId: z.string(), importTbdProjects: z.boolean().optional(), }), ) .mutation(async ({ ctx, input }) => { const result = await commitDispoImportBatch(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", userId: ctx.dbUser?.id, summary: `Committed import batch (${JSON.stringify(counts)})`, after: counts, source: "ui", }); return result; }), });