Files
CapaKraken/packages/api/src/router/dispo.ts
T

460 lines
16 KiB
TypeScript

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<string, unknown>,
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<string, StagedRecordStatus> = {
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<string, unknown>;
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;
}),
});