import { BlueprintTarget, PermissionKey } from "@planarchy/shared"; import type { BlueprintFieldDefinition } from "@planarchy/shared"; import { z } from "zod"; import { controllerProcedure, createTRPCRouter, managerProcedure, requirePermission } from "../trpc.js"; export const importExportRouter = createTRPCRouter({ /** * Export resources as CSV. */ exportResourcesCSV: controllerProcedure.query(async ({ ctx }) => { 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 }, }), ]); // Collect all custom field defs that should appear in exports (showInList = true) const customDefs = globalBlueprints .flatMap((b) => b.fieldDefs as unknown as BlueprintFieldDefinition[]) .filter((f) => f.showInList); function escapeCSV(v: unknown): string { const s = v === null || v === undefined ? "" : String(v); return s.includes(",") || s.includes('"') || s.includes("\n") ? `"${s.replace(/"/g, '""')}"` : s; } const builtinHeaders = ["eid", "displayName", "email", "chapter", "lcrCents", "ucrCents", "currency", "chargeabilityTarget"]; const customHeaders = customDefs.map((f) => f.label); const headers = [...builtinHeaders, ...customHeaders]; const rows = resources.map((r) => { const df = r.dynamicFields as unknown as Record ?? {}; const builtins = [r.eid, r.displayName, r.email, r.chapter ?? "", r.lcrCents, r.ucrCents, r.currency, r.chargeabilityTarget]; const customs = customDefs.map((f) => df[f.key] ?? ""); return [...builtins, ...customs].map(escapeCSV).join(","); }); return [headers.map(escapeCSV).join(","), ...rows].join("\n"); }), /** * Export projects as CSV. */ exportProjectsCSV: controllerProcedure.query(async ({ ctx }) => { 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((b) => b.fieldDefs as unknown as BlueprintFieldDefinition[]) .filter((f) => f.showInList); function escapeCSV(v: unknown): string { const s = v === null || v === undefined ? "" : String(v); return s.includes(",") || s.includes('"') || s.includes("\n") ? `"${s.replace(/"/g, '""')}"` : s; } const builtinHeaders = ["shortCode", "name", "orderType", "status", "budgetCents", "startDate", "endDate", "winProbability"]; const headers = [...builtinHeaders, ...customDefs.map((f) => f.label)]; const rows = projects.map((p) => { const df = p.dynamicFields as unknown as Record ?? {}; const builtins = [ p.shortCode, p.name, p.orderType, p.status, p.budgetCents, p.startDate.toISOString().split("T")[0], p.endDate.toISOString().split("T")[0], p.winProbability, ]; return [...builtins, ...customDefs.map((f) => df[f.key] ?? "")].map(escapeCSV).join(","); }); return [headers.map(escapeCSV).join(","), ...rows].join("\n"); }), /** * Import resources from CSV data (parsed client-side). */ importCSV: managerProcedure .input( z.object({ entityType: z.enum(["resources", "projects", "allocations"]), rows: z.array(z.record(z.string(), z.string())), dryRun: z.boolean().default(true), }), ) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.IMPORT_DATA); const { entityType, rows, dryRun } = input; const results = { total: rows.length, created: 0, updated: 0, errors: [] as { row: number; message: string }[], dryRun, }; if (dryRun) { // Validate without committing return { ...results, message: `Dry run: ${rows.length} rows validated` }; } // Basic import logic per entity type for (let i = 0; i < rows.length; i++) { const row = rows[i]; if (!row) continue; try { if (entityType === "resources") { const existing = await ctx.db.resource.findFirst({ where: { eid: row["eid"] ?? "" }, }); if (existing) { 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"]) : existing.lcrCents, }, }); results.updated++; } else { results.errors.push({ row: i + 1, message: "New resource creation via import requires full data" }); } } } catch (err) { results.errors.push({ row: i + 1, message: err instanceof Error ? err.message : "Unknown error" }); } } await ctx.db.auditLog.create({ data: { entityType: entityType, entityId: "bulk-import", action: "IMPORT", changes: { summary: results }, }, }); return results; }), });