import { BlueprintTarget, PermissionKey } from "@capakraken/shared"; import type { BlueprintFieldDefinition } from "@capakraken/shared"; import { z } from "zod"; import type { TRPCContext } from "../trpc.js"; import { requirePermission } from "../trpc.js"; type ImportExportReadContext = Pick; type ImportExportMutationContext = ImportExportReadContext & { permissions: Set; }; type ImportRow = Record; export const importCsvInputSchema = z.object({ entityType: z.enum(["resources", "projects", "allocations"]), rows: z.array(z.record(z.string(), z.string())), dryRun: z.boolean().default(true), }); type ImportCsvInput = z.infer; function escapeCsvValue(value: unknown): string { const serialized = value === null || value === undefined ? "" : String(value); return serialized.includes(",") || serialized.includes('"') || serialized.includes("\n") ? `"${serialized.replace(/"/g, '""')}"` : serialized; } function resolveVisibleBlueprintFields(fieldDefs: unknown): BlueprintFieldDefinition[] { return (fieldDefs as BlueprintFieldDefinition[]).filter((field) => field.showInList); } function buildCsv(headers: unknown[], rows: unknown[][]) { return [headers.map(escapeCsvValue).join(","), ...rows.map((row) => row.map(escapeCsvValue).join(","))].join("\n"); } export async function exportResourcesCsv(ctx: ImportExportReadContext) { const [resources, globalBlueprints] = await Promise.all([ ctx.db.resource.findMany({ where: { isActive: true }, orderBy: { eid: "asc" }, }), ctx.db.blueprint.findMany({ where: { target: BlueprintTarget.RESOURCE, isGlobal: true, isActive: true }, select: { fieldDefs: true }, }), ]); const customDefs = globalBlueprints.flatMap((blueprint) => resolveVisibleBlueprintFields(blueprint.fieldDefs), ); const headers = [ "eid", "displayName", "email", "chapter", "lcrCents", "ucrCents", "currency", "chargeabilityTarget", ...customDefs.map((field) => field.label), ]; const rows = resources.map((resource) => { const dynamicFields = (resource.dynamicFields as Record) ?? {}; return [ resource.eid, resource.displayName, resource.email, resource.chapter ?? "", resource.lcrCents, resource.ucrCents, resource.currency, resource.chargeabilityTarget, ...customDefs.map((field) => dynamicFields[field.key] ?? ""), ]; }); return buildCsv(headers, rows); } export async function exportProjectsCsv(ctx: ImportExportReadContext) { const [projects, globalBlueprints] = await Promise.all([ ctx.db.project.findMany({ orderBy: { shortCode: "asc" } }), ctx.db.blueprint.findMany({ where: { target: BlueprintTarget.PROJECT, isGlobal: true, isActive: true }, select: { fieldDefs: true }, }), ]); const customDefs = globalBlueprints.flatMap((blueprint) => resolveVisibleBlueprintFields(blueprint.fieldDefs), ); const headers = [ "shortCode", "name", "orderType", "status", "budgetCents", "startDate", "endDate", "winProbability", ...customDefs.map((field) => field.label), ]; const rows = projects.map((project) => { const dynamicFields = (project.dynamicFields as Record) ?? {}; return [ project.shortCode, project.name, project.orderType, project.status, project.budgetCents, project.startDate.toISOString().split("T")[0], project.endDate.toISOString().split("T")[0], project.winProbability, ...customDefs.map((field) => dynamicFields[field.key] ?? ""), ]; }); return buildCsv(headers, rows); } async function importResourceRow(ctx: ImportExportMutationContext, row: ImportRow) { const existing = await ctx.db.resource.findFirst({ where: { eid: row["eid"] ?? "" }, }); if (!existing) { return { updated: false, error: "New resource creation via import requires full data" }; } await ctx.db.resource.update({ where: { id: existing.id }, data: { displayName: row["displayName"] ?? existing.displayName, email: row["email"] ?? existing.email, chapter: row["chapter"] ?? existing.chapter, lcrCents: row["lcrCents"] ? parseInt(row["lcrCents"], 10) : existing.lcrCents, }, }); return { updated: true, error: null }; } export async function importCsv(ctx: ImportExportMutationContext, input: ImportCsvInput) { requirePermission(ctx, PermissionKey.IMPORT_DATA); const results = { total: input.rows.length, created: 0, updated: 0, errors: [] as { row: number; message: string }[], dryRun: input.dryRun, }; if (input.dryRun) { return { ...results, message: `Dry run: ${input.rows.length} rows validated` }; } await ctx.db.$transaction(async (tx) => { for (let index = 0; index < input.rows.length; index += 1) { const row = input.rows[index]; if (!row) { continue; } try { if (input.entityType === "resources") { const outcome = await importResourceRow({ ...ctx, db: tx as unknown as typeof ctx.db }, row); if (outcome.updated) { results.updated += 1; } else if (outcome.error) { results.errors.push({ row: index + 1, message: outcome.error }); } } } catch (error) { results.errors.push({ row: index + 1, message: error instanceof Error ? error.message : "Unknown error", }); } } await tx.auditLog.create({ data: { entityType: input.entityType, entityId: "bulk-import", action: "IMPORT", changes: { summary: results }, }, }); }); return results; }