diff --git a/docs/api-router-procedure-support-backlog.md b/docs/api-router-procedure-support-backlog.md index e707b93..3722a30 100644 --- a/docs/api-router-procedure-support-backlog.md +++ b/docs/api-router-procedure-support-backlog.md @@ -12,9 +12,9 @@ Done - `country` - `holiday-calendar` - `org-unit` +- `dispo` Ready next -- `dispo` - `insights` - `import-export` diff --git a/packages/api/src/__tests__/dispo-procedure-support.test.ts b/packages/api/src/__tests__/dispo-procedure-support.test.ts new file mode 100644 index 0000000..69baeef --- /dev/null +++ b/packages/api/src/__tests__/dispo-procedure-support.test.ts @@ -0,0 +1,255 @@ +import { DispoStagedRecordType, ImportBatchStatus, StagedRecordStatus } from "@capakraken/db"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + assessDispoImportReadiness, + stageDispoImportBatch, +} = vi.hoisted(() => ({ + assessDispoImportReadiness: vi.fn(), + stageDispoImportBatch: vi.fn(), +})); + +const { + cancelImportBatchMutation, + commitImportBatchMutation, + resolveStagedRecordMutation, +} = vi.hoisted(() => ({ + cancelImportBatchMutation: vi.fn(), + commitImportBatchMutation: vi.fn(), + resolveStagedRecordMutation: vi.fn(), +})); + +const { + getImportBatchQuery, + listImportBatchesQuery, + listStagedAssignmentsQuery, + listStagedProjectsQuery, + listStagedResourcesQuery, + listStagedUnresolvedRecordsQuery, + listStagedVacationsQuery, +} = vi.hoisted(() => ({ + getImportBatchQuery: vi.fn(), + listImportBatchesQuery: vi.fn(), + listStagedAssignmentsQuery: vi.fn(), + listStagedProjectsQuery: vi.fn(), + listStagedResourcesQuery: vi.fn(), + listStagedUnresolvedRecordsQuery: vi.fn(), + listStagedVacationsQuery: vi.fn(), +})); + +vi.mock("@capakraken/application", () => ({ + assessDispoImportReadiness, + stageDispoImportBatch, +})); + +vi.mock("../router/dispo-management.js", () => ({ + cancelImportBatch: cancelImportBatchMutation, + commitImportBatch: commitImportBatchMutation, + resolveStagedRecord: resolveStagedRecordMutation, +})); + +vi.mock("../router/dispo-read.js", () => ({ + getImportBatch: getImportBatchQuery, + listImportBatches: listImportBatchesQuery, + listStagedAssignments: listStagedAssignmentsQuery, + listStagedProjects: listStagedProjectsQuery, + listStagedResources: listStagedResourcesQuery, + listStagedUnresolvedRecords: listStagedUnresolvedRecordsQuery, + listStagedVacations: listStagedVacationsQuery, +})); + +import { + cancelImportBatch, + commitImportBatch, + getImportBatch, + listImportBatches, + listStagedAssignments, + listStagedProjects, + listStagedResources, + listStagedUnresolvedRecords, + listStagedVacations, + resolveStagedRecord, + stageImportBatch as stageImportBatchProcedure, + validateImportBatch, +} from "../router/dispo-procedure-support.js"; + +function createContext(db: Record) { + return { + db: db as never, + dbUser: { id: "user_admin" } as never, + }; +} + +describe("dispo procedure support", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("forwards stage-import inputs to the application layer", async () => { + stageDispoImportBatch.mockResolvedValue({ id: "batch_1" }); + const db = { marker: "db" }; + + const result = await stageImportBatchProcedure(createContext(db), { + chargeabilityWorkbookPath: "/tmp/chargeability.xlsx", + costWorkbookPath: "/tmp/cost.xlsx", + notes: null, + planningWorkbookPath: "/tmp/planning.xlsx", + referenceWorkbookPath: "/tmp/reference.xlsx", + rosterWorkbookPath: "/tmp/roster.xlsx", + }); + + expect(stageDispoImportBatch).toHaveBeenCalledWith(db, { + chargeabilityWorkbookPath: "/tmp/chargeability.xlsx", + costWorkbookPath: "/tmp/cost.xlsx", + notes: null, + planningWorkbookPath: "/tmp/planning.xlsx", + referenceWorkbookPath: "/tmp/reference.xlsx", + rosterWorkbookPath: "/tmp/roster.xlsx", + }); + expect(result).toEqual({ id: "batch_1" }); + }); + + it("forwards validate-import inputs without requiring router context", async () => { + assessDispoImportReadiness.mockResolvedValue({ ready: true }); + + const result = await validateImportBatch({ + chargeabilityWorkbookPath: "/tmp/chargeability.xlsx", + importBatchId: "batch_1", + notes: "recheck", + planningWorkbookPath: "/tmp/planning.xlsx", + referenceWorkbookPath: "/tmp/reference.xlsx", + }); + + expect(assessDispoImportReadiness).toHaveBeenCalledWith({ + chargeabilityWorkbookPath: "/tmp/chargeability.xlsx", + importBatchId: "batch_1", + notes: "recheck", + planningWorkbookPath: "/tmp/planning.xlsx", + referenceWorkbookPath: "/tmp/reference.xlsx", + }); + expect(result).toEqual({ ready: true }); + }); + + it("delegates staged-record reads to the read helpers", async () => { + const db = { marker: "db" }; + listImportBatchesQuery.mockResolvedValue({ items: [], nextCursor: undefined }); + getImportBatchQuery.mockResolvedValue({ id: "batch_1" }); + listStagedResourcesQuery.mockResolvedValue({ items: [{ id: "res_1" }] }); + listStagedProjectsQuery.mockResolvedValue({ items: [{ id: "proj_1" }] }); + listStagedAssignmentsQuery.mockResolvedValue({ items: [{ id: "assign_1" }] }); + listStagedVacationsQuery.mockResolvedValue({ items: [{ id: "vac_1" }] }); + listStagedUnresolvedRecordsQuery.mockResolvedValue({ items: [{ id: "unres_1" }] }); + + await expect( + listImportBatches(createContext(db), { limit: 50, status: ImportBatchStatus.STAGED }), + ).resolves.toEqual({ items: [], nextCursor: undefined }); + await expect( + getImportBatch(createContext(db), { id: "batch_1" }), + ).resolves.toEqual({ id: "batch_1" }); + await expect( + listStagedResources(createContext(db), { + importBatchId: "batch_1", + limit: 25, + status: StagedRecordStatus.STAGED, + }), + ).resolves.toEqual({ items: [{ id: "res_1" }] }); + await expect( + listStagedProjects(createContext(db), { + importBatchId: "batch_1", + isTbd: true, + limit: 25, + }), + ).resolves.toEqual({ items: [{ id: "proj_1" }] }); + await expect( + listStagedAssignments(createContext(db), { + importBatchId: "batch_1", + limit: 25, + resourceExternalId: "R-1", + }), + ).resolves.toEqual({ items: [{ id: "assign_1" }] }); + await expect( + listStagedVacations(createContext(db), { + importBatchId: "batch_1", + limit: 25, + }), + ).resolves.toEqual({ items: [{ id: "vac_1" }] }); + await expect( + listStagedUnresolvedRecords(createContext(db), { + importBatchId: "batch_1", + limit: 25, + recordType: DispoStagedRecordType.PROJECT, + }), + ).resolves.toEqual({ items: [{ id: "unres_1" }] }); + + expect(listImportBatchesQuery).toHaveBeenCalledWith(db, { + limit: 50, + status: ImportBatchStatus.STAGED, + }); + expect(getImportBatchQuery).toHaveBeenCalledWith(db, "batch_1"); + expect(listStagedResourcesQuery).toHaveBeenCalledWith(db, { + importBatchId: "batch_1", + limit: 25, + status: StagedRecordStatus.STAGED, + }); + expect(listStagedProjectsQuery).toHaveBeenCalledWith(db, { + importBatchId: "batch_1", + isTbd: true, + limit: 25, + }); + expect(listStagedAssignmentsQuery).toHaveBeenCalledWith(db, { + importBatchId: "batch_1", + limit: 25, + resourceExternalId: "R-1", + }); + expect(listStagedVacationsQuery).toHaveBeenCalledWith(db, { + importBatchId: "batch_1", + limit: 25, + }); + expect(listStagedUnresolvedRecordsQuery).toHaveBeenCalledWith(db, { + importBatchId: "batch_1", + limit: 25, + recordType: DispoStagedRecordType.PROJECT, + }); + }); + + it("passes user-scoped mutations through the management helpers", async () => { + const db = { marker: "db" }; + cancelImportBatchMutation.mockResolvedValue({ id: "batch_1", status: ImportBatchStatus.CANCELLED }); + resolveStagedRecordMutation.mockResolvedValue({ id: "record_1", status: StagedRecordStatus.APPROVED }); + commitImportBatchMutation.mockResolvedValue({ importedAssignments: 4 }); + + await expect( + cancelImportBatch(createContext(db), { id: "batch_1" }), + ).resolves.toEqual({ id: "batch_1", status: ImportBatchStatus.CANCELLED }); + await expect( + resolveStagedRecord(createContext(db), { + action: "APPROVE", + id: "record_1", + recordType: DispoStagedRecordType.ASSIGNMENT, + }), + ).resolves.toEqual({ id: "record_1", status: StagedRecordStatus.APPROVED }); + await expect( + commitImportBatch(createContext(db), { + allowTbdUnresolved: true, + importBatchId: "batch_1", + importTbdProjects: false, + }), + ).resolves.toEqual({ importedAssignments: 4 }); + + expect(cancelImportBatchMutation).toHaveBeenCalledWith(db, { + id: "batch_1", + userId: "user_admin", + }); + expect(resolveStagedRecordMutation).toHaveBeenCalledWith(db, { + action: "APPROVE", + id: "record_1", + recordType: DispoStagedRecordType.ASSIGNMENT, + }); + expect(commitImportBatchMutation).toHaveBeenCalledWith(db, { + allowTbdUnresolved: true, + importBatchId: "batch_1", + importTbdProjects: false, + userId: "user_admin", + }); + }); +}); diff --git a/packages/api/src/__tests__/dispo-router.test.ts b/packages/api/src/__tests__/dispo-router.test.ts new file mode 100644 index 0000000..a6b886e --- /dev/null +++ b/packages/api/src/__tests__/dispo-router.test.ts @@ -0,0 +1,172 @@ +import { ImportBatchStatus } from "@capakraken/db"; +import { SystemRole } from "@capakraken/shared"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + assessDispoImportReadiness, + stageDispoImportBatch, +} = vi.hoisted(() => ({ + assessDispoImportReadiness: vi.fn(), + stageDispoImportBatch: vi.fn(), +})); + +const { createAuditEntry } = vi.hoisted(() => ({ + createAuditEntry: vi.fn(), +})); + +vi.mock("@capakraken/application", () => ({ + assessDispoImportReadiness, + stageDispoImportBatch, +})); + +vi.mock("../lib/audit.js", () => ({ + createAuditEntry, +})); + +import { dispoRouter } from "../router/dispo.js"; +import { createCallerFactory } from "../trpc.js"; + +const createCaller = createCallerFactory(dispoRouter); + +function createDispoCaller( + db: Record, + options: { role?: SystemRole } = {}, +) { + const { role = SystemRole.ADMIN } = options; + + return createCaller({ + session: { + user: { email: "user@example.com", name: "User", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + db: db as never, + dbUser: { + id: role === SystemRole.ADMIN ? "user_admin" : "user_1", + systemRole: role, + permissionOverrides: null, + }, + }); +} + +describe("dispo router", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("requires admin access for dispo import procedures", async () => { + const findMany = vi.fn(); + const caller = createDispoCaller( + { importBatch: { findMany } }, + { role: SystemRole.USER }, + ); + + await expect(caller.listImportBatches({ limit: 10 })).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Admin role required", + }); + await expect( + caller.stageImportBatch({ + chargeabilityWorkbookPath: "/tmp/chargeability.xlsx", + planningWorkbookPath: "/tmp/planning.xlsx", + referenceWorkbookPath: "/tmp/reference.xlsx", + }), + ).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Admin role required", + }); + + expect(findMany).not.toHaveBeenCalled(); + expect(stageDispoImportBatch).not.toHaveBeenCalled(); + }); + + it("lists import batches through the thin router wiring", async () => { + const findMany = vi.fn().mockResolvedValue([ + { id: "batch_2", createdAt: new Date("2026-03-29T00:00:00.000Z") }, + { id: "batch_1", createdAt: new Date("2026-03-28T00:00:00.000Z") }, + ]); + + const caller = createDispoCaller({ + importBatch: { findMany }, + }); + const result = await caller.listImportBatches({ + limit: 1, + status: ImportBatchStatus.STAGED, + }); + + expect(findMany).toHaveBeenCalledWith({ + where: { status: ImportBatchStatus.STAGED }, + orderBy: { createdAt: "desc" }, + take: 2, + }); + expect(result).toEqual({ + items: [{ id: "batch_2", createdAt: new Date("2026-03-29T00:00:00.000Z") }], + nextCursor: "batch_1", + }); + }); + + it("stages, validates, and cancels import batches through the admin router", async () => { + stageDispoImportBatch.mockResolvedValue({ id: "batch_1", status: ImportBatchStatus.STAGED }); + assessDispoImportReadiness.mockResolvedValue({ ready: true, issues: [] }); + const findUnique = vi.fn().mockResolvedValue({ + id: "batch_1", + status: ImportBatchStatus.STAGED, + }); + const update = vi.fn().mockResolvedValue({ + id: "batch_1", + status: ImportBatchStatus.CANCELLED, + }); + + const caller = createDispoCaller({ + importBatch: { findUnique, update }, + }); + + const staged = await caller.stageImportBatch({ + chargeabilityWorkbookPath: "/tmp/chargeability.xlsx", + notes: "overnight patch", + planningWorkbookPath: "/tmp/planning.xlsx", + referenceWorkbookPath: "/tmp/reference.xlsx", + }); + const validated = await caller.validateImportBatch({ + chargeabilityWorkbookPath: "/tmp/chargeability.xlsx", + planningWorkbookPath: "/tmp/planning.xlsx", + referenceWorkbookPath: "/tmp/reference.xlsx", + }); + const cancelled = await caller.cancelImportBatch({ id: "batch_1" }); + + expect(stageDispoImportBatch).toHaveBeenCalledWith( + expect.objectContaining({ importBatch: { findUnique, update } }), + { + chargeabilityWorkbookPath: "/tmp/chargeability.xlsx", + notes: "overnight patch", + planningWorkbookPath: "/tmp/planning.xlsx", + referenceWorkbookPath: "/tmp/reference.xlsx", + }, + ); + expect(assessDispoImportReadiness).toHaveBeenCalledWith({ + chargeabilityWorkbookPath: "/tmp/chargeability.xlsx", + planningWorkbookPath: "/tmp/planning.xlsx", + referenceWorkbookPath: "/tmp/reference.xlsx", + }); + expect(findUnique).toHaveBeenCalledWith({ + where: { id: "batch_1" }, + select: { id: true, status: true }, + }); + expect(update).toHaveBeenCalledWith({ + where: { id: "batch_1" }, + data: { + status: ImportBatchStatus.CANCELLED, + }, + }); + expect(staged).toEqual({ id: "batch_1", status: ImportBatchStatus.STAGED }); + expect(validated).toEqual({ ready: true, issues: [] }); + expect(cancelled).toEqual({ id: "batch_1", status: ImportBatchStatus.CANCELLED }); + expect(createAuditEntry).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: "ImportBatch", + entityId: "batch_1", + summary: "Cancelled import batch", + userId: "user_admin", + }), + ); + }); +}); diff --git a/packages/api/src/router/dispo-procedure-support.ts b/packages/api/src/router/dispo-procedure-support.ts new file mode 100644 index 0000000..3969e1f --- /dev/null +++ b/packages/api/src/router/dispo-procedure-support.ts @@ -0,0 +1,213 @@ +import { + DispoStagedRecordType, + ImportBatchStatus, + StagedRecordStatus, +} from "@capakraken/db"; +import { + assessDispoImportReadiness, + stageDispoImportBatch as stageDispoImportBatchApplication, +} from "@capakraken/application"; +import { z } from "zod"; +import type { TRPCContext } from "../trpc.js"; +import { + cancelImportBatch as cancelImportBatchMutation, + commitImportBatch as commitImportBatchMutation, + resolveStagedRecord as resolveStagedRecordMutation, +} from "./dispo-management.js"; +import { + getImportBatch as getImportBatchQuery, + listImportBatches as listImportBatchesQuery, + listStagedAssignments as listStagedAssignmentsQuery, + listStagedProjects as listStagedProjectsQuery, + listStagedResources as listStagedResourcesQuery, + listStagedUnresolvedRecords as listStagedUnresolvedRecordsQuery, + listStagedVacations as listStagedVacationsQuery, +} from "./dispo-read.js"; + +type DispoProcedureContext = Pick; + +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.", + }); + +export const stageImportBatchInputSchema = z.object({ + chargeabilityWorkbookPath: workbookPathSchema, + costWorkbookPath: workbookPathSchema.optional(), + notes: z.string().nullish(), + planningWorkbookPath: workbookPathSchema, + referenceWorkbookPath: workbookPathSchema, + rosterWorkbookPath: workbookPathSchema.optional(), +}); + +export const validateImportBatchInputSchema = z.object({ + chargeabilityWorkbookPath: workbookPathSchema, + costWorkbookPath: workbookPathSchema.optional(), + importBatchId: z.string().optional(), + notes: z.string().nullish(), + planningWorkbookPath: workbookPathSchema, + referenceWorkbookPath: workbookPathSchema, + rosterWorkbookPath: workbookPathSchema.optional(), +}); + +export const listImportBatchesInputSchema = paginationSchema.extend({ + status: importBatchStatusSchema.optional(), +}); + +export const importBatchIdInputSchema = z.object({ + id: z.string(), +}); + +export const listStagedResourcesInputSchema = paginationSchema.extend({ + importBatchId: z.string(), + status: stagedRecordStatusSchema.optional(), +}); + +export const listStagedProjectsInputSchema = paginationSchema.extend({ + importBatchId: z.string(), + isTbd: z.boolean().optional(), + status: stagedRecordStatusSchema.optional(), +}); + +export const listStagedAssignmentsInputSchema = paginationSchema.extend({ + importBatchId: z.string(), + resourceExternalId: z.string().optional(), + status: stagedRecordStatusSchema.optional(), +}); + +export const listStagedVacationsInputSchema = paginationSchema.extend({ + importBatchId: z.string(), + resourceExternalId: z.string().optional(), +}); + +export const listStagedUnresolvedRecordsInputSchema = paginationSchema.extend({ + importBatchId: z.string(), + recordType: stagedRecordTypeSchema.optional(), +}); + +export const resolveStagedRecordInputSchema = z.object({ + action: z.enum(["APPROVE", "REJECT", "SKIP"]), + id: z.string(), + recordType: stagedRecordTypeSchema, +}); + +export const commitImportBatchInputSchema = z.object({ + allowTbdUnresolved: z.boolean().optional(), + importBatchId: z.string(), + importTbdProjects: z.boolean().optional(), +}); + +type StageImportBatchInput = z.infer; +type ValidateImportBatchInput = z.infer; +type ListImportBatchesInput = z.infer; +type ImportBatchIdInput = z.infer; +type ListStagedResourcesInput = z.infer; +type ListStagedProjectsInput = z.infer; +type ListStagedAssignmentsInput = z.infer; +type ListStagedVacationsInput = z.infer; +type ListStagedUnresolvedRecordsInput = z.infer; +type ResolveStagedRecordInput = z.infer; +type CommitImportBatchInput = z.infer; + +export async function stageImportBatch( + ctx: DispoProcedureContext, + input: StageImportBatchInput, +) { + return stageDispoImportBatchApplication(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 } : {}), + }); +} + +export async function validateImportBatch(input: ValidateImportBatchInput) { + 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 } : {}), + }); +} + +export async function listImportBatches(ctx: DispoProcedureContext, input: ListImportBatchesInput) { + return listImportBatchesQuery(ctx.db, input); +} + +export async function getImportBatch(ctx: DispoProcedureContext, input: ImportBatchIdInput) { + return getImportBatchQuery(ctx.db, input.id); +} + +export async function cancelImportBatch(ctx: DispoProcedureContext, input: ImportBatchIdInput) { + return cancelImportBatchMutation(ctx.db, { id: input.id, userId: ctx.dbUser?.id }); +} + +export async function listStagedResources( + ctx: DispoProcedureContext, + input: ListStagedResourcesInput, +) { + return listStagedResourcesQuery(ctx.db, input); +} + +export async function listStagedProjects( + ctx: DispoProcedureContext, + input: ListStagedProjectsInput, +) { + return listStagedProjectsQuery(ctx.db, input); +} + +export async function listStagedAssignments( + ctx: DispoProcedureContext, + input: ListStagedAssignmentsInput, +) { + return listStagedAssignmentsQuery(ctx.db, input); +} + +export async function listStagedVacations( + ctx: DispoProcedureContext, + input: ListStagedVacationsInput, +) { + return listStagedVacationsQuery(ctx.db, input); +} + +export async function listStagedUnresolvedRecords( + ctx: DispoProcedureContext, + input: ListStagedUnresolvedRecordsInput, +) { + return listStagedUnresolvedRecordsQuery(ctx.db, input); +} + +export async function resolveStagedRecord( + ctx: DispoProcedureContext, + input: ResolveStagedRecordInput, +) { + return resolveStagedRecordMutation(ctx.db, input); +} + +export async function commitImportBatch( + ctx: DispoProcedureContext, + input: CommitImportBatchInput, +) { + return commitImportBatchMutation(ctx.db, { + importBatchId: input.importBatchId, + allowTbdUnresolved: input.allowTbdUnresolved, + importTbdProjects: input.importTbdProjects, + userId: ctx.dbUser?.id, + }); +} diff --git a/packages/api/src/router/dispo.ts b/packages/api/src/router/dispo.ts index 0870f6c..5ff57ff 100644 --- a/packages/api/src/router/dispo.ts +++ b/packages/api/src/router/dispo.ts @@ -1,221 +1,76 @@ import { - ImportBatchStatus, - StagedRecordStatus, - DispoStagedRecordType, -} from "@capakraken/db"; -import { - assessDispoImportReadiness, - stageDispoImportBatch, -} from "@capakraken/application"; -import { z } from "zod"; -import { adminProcedure, createTRPCRouter } from "../trpc.js"; -import { commitImportBatch, cancelImportBatch, resolveStagedRecord } from "./dispo-management.js"; -import { + cancelImportBatch, + commitImportBatch, + commitImportBatchInputSchema, getImportBatch, + importBatchIdInputSchema, listImportBatches, + listImportBatchesInputSchema, listStagedAssignments, + listStagedAssignmentsInputSchema, listStagedProjects, + listStagedProjectsInputSchema, listStagedResources, + listStagedResourcesInputSchema, listStagedUnresolvedRecords, + listStagedUnresolvedRecordsInputSchema, listStagedVacations, -} from "./dispo-read.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 ────────────────────────────────────────────────────────────────── + listStagedVacationsInputSchema, + resolveStagedRecord, + resolveStagedRecordInputSchema, + stageImportBatch, + stageImportBatchInputSchema, + validateImportBatch, + validateImportBatchInputSchema, +} from "./dispo-procedure-support.js"; +import { adminProcedure, createTRPCRouter } from "../trpc.js"; 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 ─────────────────────────────────────────────── + .input(stageImportBatchInputSchema) + .mutation(({ ctx, input }) => stageImportBatch(ctx, input)), 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 ───────────────────────────────────────────────── + .input(validateImportBatchInputSchema) + .query(({ input }) => validateImportBatch(input)), listImportBatches: adminProcedure - .input( - paginationSchema.extend({ - status: importBatchStatusSchema.optional(), - }), - ) - .query(async ({ ctx, input }) => { - return listImportBatches(ctx.db, input); - }), - - // ── 4. getImportBatch ──────────────────────────────────────────────────── + .input(listImportBatchesInputSchema) + .query(({ ctx, input }) => listImportBatches(ctx, input)), getImportBatch: adminProcedure - .input(z.object({ id: z.string() })) - .query(async ({ ctx, input }) => { - return getImportBatch(ctx.db, input.id); - }), - - // ── 5. cancelImportBatch ───────────────────────────────────────────────── + .input(importBatchIdInputSchema) + .query(({ ctx, input }) => getImportBatch(ctx, input)), cancelImportBatch: adminProcedure - .input(z.object({ id: z.string() })) - .mutation(async ({ ctx, input }) => { - return cancelImportBatch(ctx.db, { id: input.id, userId: ctx.dbUser?.id }); - }), - - // ── 6. listStagedResources ─────────────────────────────────────────────── + .input(importBatchIdInputSchema) + .mutation(({ ctx, input }) => cancelImportBatch(ctx, input)), listStagedResources: adminProcedure - .input( - paginationSchema.extend({ - importBatchId: z.string(), - status: stagedRecordStatusSchema.optional(), - }), - ) - .query(async ({ ctx, input }) => { - return listStagedResources(ctx.db, input); - }), - - // ── 7. listStagedProjects ──────────────────────────────────────────────── + .input(listStagedResourcesInputSchema) + .query(({ ctx, input }) => listStagedResources(ctx, input)), listStagedProjects: adminProcedure - .input( - paginationSchema.extend({ - importBatchId: z.string(), - isTbd: z.boolean().optional(), - status: stagedRecordStatusSchema.optional(), - }), - ) - .query(async ({ ctx, input }) => { - return listStagedProjects(ctx.db, input); - }), - - // ── 8. listStagedAssignments ───────────────────────────────────────────── + .input(listStagedProjectsInputSchema) + .query(({ ctx, input }) => listStagedProjects(ctx, input)), listStagedAssignments: adminProcedure - .input( - paginationSchema.extend({ - importBatchId: z.string(), - resourceExternalId: z.string().optional(), - status: stagedRecordStatusSchema.optional(), - }), - ) - .query(async ({ ctx, input }) => { - return listStagedAssignments(ctx.db, input); - }), - - // ── 9. listStagedVacations ─────────────────────────────────────────────── + .input(listStagedAssignmentsInputSchema) + .query(({ ctx, input }) => listStagedAssignments(ctx, input)), listStagedVacations: adminProcedure - .input( - paginationSchema.extend({ - importBatchId: z.string(), - resourceExternalId: z.string().optional(), - }), - ) - .query(async ({ ctx, input }) => { - return listStagedVacations(ctx.db, input); - }), - - // ── 10. listStagedUnresolvedRecords ────────────────────────────────────── + .input(listStagedVacationsInputSchema) + .query(({ ctx, input }) => listStagedVacations(ctx, input)), listStagedUnresolvedRecords: adminProcedure - .input( - paginationSchema.extend({ - importBatchId: z.string(), - recordType: stagedRecordTypeSchema.optional(), - }), - ) - .query(async ({ ctx, input }) => { - return listStagedUnresolvedRecords(ctx.db, input); - }), - - // ── 11. resolveStagedRecord ────────────────────────────────────────────── + .input(listStagedUnresolvedRecordsInputSchema) + .query(({ ctx, input }) => listStagedUnresolvedRecords(ctx, input)), resolveStagedRecord: adminProcedure - .input( - z.object({ - action: z.enum(["APPROVE", "REJECT", "SKIP"]), - id: z.string(), - recordType: stagedRecordTypeSchema, - }), - ) - .mutation(async ({ ctx, input }) => { - return resolveStagedRecord(ctx.db, input); - }), - - // ── 12. commitImportBatch ──────────────────────────────────────────────── + .input(resolveStagedRecordInputSchema) + .mutation(({ ctx, input }) => resolveStagedRecord(ctx, input)), commitImportBatch: adminProcedure - .input( - z.object({ - allowTbdUnresolved: z.boolean().optional(), - importBatchId: z.string(), - importTbdProjects: z.boolean().optional(), - }), - ) - .mutation(async ({ ctx, input }) => { - return commitImportBatch(ctx.db, { - importBatchId: input.importBatchId, - allowTbdUnresolved: input.allowTbdUnresolved, - importTbdProjects: input.importTbdProjects, - userId: ctx.dbUser?.id, - }); - }), + .input(commitImportBatchInputSchema) + .mutation(({ ctx, input }) => commitImportBatch(ctx, input)), });