From f14d2679cce28975134d024232257e2f2064f029 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 20:36:46 +0200 Subject: [PATCH] refactor(api): extract import export procedures --- docs/api-router-procedure-support-backlog.md | 2 +- .../import-export-procedure-support.test.ts | 228 ++++++++++++++++++ .../__tests__/import-export-router.test.ts | 136 +++++++++++ .../router/import-export-procedure-support.ts | 196 +++++++++++++++ packages/api/src/router/import-export.ts | 162 +------------ 5 files changed, 572 insertions(+), 152 deletions(-) create mode 100644 packages/api/src/__tests__/import-export-procedure-support.test.ts create mode 100644 packages/api/src/__tests__/import-export-router.test.ts create mode 100644 packages/api/src/router/import-export-procedure-support.ts diff --git a/docs/api-router-procedure-support-backlog.md b/docs/api-router-procedure-support-backlog.md index 3722a30..4268ad2 100644 --- a/docs/api-router-procedure-support-backlog.md +++ b/docs/api-router-procedure-support-backlog.md @@ -13,9 +13,9 @@ Done - `holiday-calendar` - `org-unit` - `dispo` +- `insights` Ready next -- `insights` - `import-export` Deferred or blocked diff --git a/packages/api/src/__tests__/import-export-procedure-support.test.ts b/packages/api/src/__tests__/import-export-procedure-support.test.ts new file mode 100644 index 0000000..36a3dd5 --- /dev/null +++ b/packages/api/src/__tests__/import-export-procedure-support.test.ts @@ -0,0 +1,228 @@ +import { PermissionKey } from "@capakraken/shared"; +import { TRPCError } from "@trpc/server"; +import { describe, expect, it, vi } from "vitest"; +import { + exportProjectsCsv, + exportResourcesCsv, + importCsv, +} from "../router/import-export-procedure-support.js"; + +function createContext(db: Record, permissions?: PermissionKey[]) { + return { + db: db as never, + permissions: new Set(permissions ?? []), + }; +} + +describe("import-export procedure support", () => { + it("exports active resources with visible custom fields as CSV", async () => { + const csv = await exportResourcesCsv( + createContext({ + resource: { + findMany: vi.fn().mockResolvedValue([ + { + eid: "E-001", + displayName: "Ada Lovelace", + email: "ada@example.com", + chapter: "Consulting", + lcrCents: 10000, + ucrCents: 20000, + currency: "EUR", + chargeabilityTarget: 0.8, + dynamicFields: { favoriteTool: "Excel, Sheets" }, + }, + ]), + }, + blueprint: { + findMany: vi.fn().mockResolvedValue([ + { + fieldDefs: [ + { key: "favoriteTool", label: "Favorite Tool", showInList: true }, + { key: "privateNote", label: "Private Note", showInList: false }, + ], + }, + ]), + }, + }), + ); + + expect(csv).toBe( + "eid,displayName,email,chapter,lcrCents,ucrCents,currency,chargeabilityTarget,Favorite Tool\n" + + 'E-001,Ada Lovelace,ada@example.com,Consulting,10000,20000,EUR,0.8,"Excel, Sheets"', + ); + }); + + it("exports projects with formatted dates and custom columns", async () => { + const csv = await exportProjectsCsv( + createContext({ + project: { + findMany: vi.fn().mockResolvedValue([ + { + shortCode: "APO", + name: "Apollo", + orderType: "TIME_AND_MATERIAL", + status: "ACTIVE", + budgetCents: 300000, + startDate: new Date("2026-03-01T00:00:00.000Z"), + endDate: new Date("2026-06-30T00:00:00.000Z"), + winProbability: 80, + dynamicFields: { sponsor: "Finance" }, + }, + ]), + }, + blueprint: { + findMany: vi.fn().mockResolvedValue([ + { fieldDefs: [{ key: "sponsor", label: "Sponsor", showInList: true }] }, + ]), + }, + }), + ); + + expect(csv).toBe( + "shortCode,name,orderType,status,budgetCents,startDate,endDate,winProbability,Sponsor\n" + + "APO,Apollo,TIME_AND_MATERIAL,ACTIVE,300000,2026-03-01,2026-06-30,80,Finance", + ); + }); + + it("runs dry-run imports without touching the database", async () => { + const resourceFindFirst = vi.fn(); + const auditCreate = vi.fn(); + + const result = await importCsv( + createContext( + { + resource: { + findFirst: resourceFindFirst, + update: vi.fn(), + }, + auditLog: { + create: auditCreate, + }, + }, + [PermissionKey.IMPORT_DATA], + ), + { + entityType: "resources", + rows: [{ eid: "E-001", displayName: "Ada Lovelace" }], + dryRun: true, + }, + ); + + expect(result).toEqual({ + total: 1, + created: 0, + updated: 0, + errors: [], + dryRun: true, + message: "Dry run: 1 rows validated", + }); + expect(resourceFindFirst).not.toHaveBeenCalled(); + expect(auditCreate).not.toHaveBeenCalled(); + }); + + it("updates matching resources and records import errors", async () => { + const resourceFindFirst = vi + .fn() + .mockResolvedValueOnce({ + id: "res_1", + displayName: "Ada", + email: "ada-old@example.com", + chapter: "Old", + lcrCents: 9000, + }) + .mockResolvedValueOnce(null); + const resourceUpdate = vi.fn().mockResolvedValue({ id: "res_1" }); + const auditCreate = vi.fn().mockResolvedValue({ id: "audit_1" }); + + const result = await importCsv( + createContext( + { + resource: { + findFirst: resourceFindFirst, + update: resourceUpdate, + }, + auditLog: { + create: auditCreate, + }, + }, + [PermissionKey.IMPORT_DATA], + ), + { + entityType: "resources", + rows: [ + { + eid: "E-001", + displayName: "Ada Lovelace", + email: "ada@example.com", + chapter: "Consulting", + lcrCents: "12000", + }, + { + eid: "E-404", + displayName: "Missing User", + }, + ], + dryRun: false, + }, + ); + + expect(resourceUpdate).toHaveBeenCalledWith({ + where: { id: "res_1" }, + data: { + displayName: "Ada Lovelace", + email: "ada@example.com", + chapter: "Consulting", + lcrCents: 12000, + }, + }); + expect(auditCreate).toHaveBeenCalledWith({ + data: { + entityType: "resources", + entityId: "bulk-import", + action: "IMPORT", + changes: { + summary: { + total: 2, + created: 0, + updated: 1, + errors: [{ row: 2, message: "New resource creation via import requires full data" }], + dryRun: false, + }, + }, + }, + }); + expect(result).toEqual({ + total: 2, + created: 0, + updated: 1, + errors: [{ row: 2, message: "New resource creation via import requires full data" }], + dryRun: false, + }); + }); + + it("rejects imports without the explicit import permission", async () => { + await expect( + importCsv( + createContext({ + resource: { + findFirst: vi.fn(), + update: vi.fn(), + }, + auditLog: { + create: vi.fn(), + }, + }), + { + entityType: "resources", + rows: [], + dryRun: true, + }, + ), + ).rejects.toEqual( + expect.objectContaining>({ + code: "FORBIDDEN", + message: `Permission required: ${PermissionKey.IMPORT_DATA}`, + }), + ); + }); +}); diff --git a/packages/api/src/__tests__/import-export-router.test.ts b/packages/api/src/__tests__/import-export-router.test.ts new file mode 100644 index 0000000..9c05209 --- /dev/null +++ b/packages/api/src/__tests__/import-export-router.test.ts @@ -0,0 +1,136 @@ +import { PermissionKey, SystemRole } from "@capakraken/shared"; +import { describe, expect, it, vi } from "vitest"; +import { importExportRouter } from "../router/import-export.js"; +import { createCallerFactory } from "../trpc.js"; + +const createCaller = createCallerFactory(importExportRouter); + +function createProtectedCaller( + db: Record, + options: { + role?: SystemRole; + granted?: PermissionKey[]; + denied?: PermissionKey[]; + } = {}, +) { + const { role = SystemRole.USER, granted = [], denied = [] } = options; + const hasOverrides = granted.length > 0 || denied.length > 0; + + 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: hasOverrides ? { ...(granted.length > 0 ? { granted } : {}), ...(denied.length > 0 ? { denied } : {}) } : null, + }, + roleDefaults: null, + }); +} + +describe("import-export router", () => { + it("exports resources for controller callers", async () => { + const caller = createProtectedCaller( + { + resource: { + findMany: vi.fn().mockResolvedValue([ + { + eid: "E-001", + displayName: "Ada Lovelace", + email: "ada@example.com", + chapter: "Consulting", + lcrCents: 10000, + ucrCents: 20000, + currency: "EUR", + chargeabilityTarget: 0.8, + dynamicFields: {}, + }, + ]), + }, + blueprint: { + findMany: vi.fn().mockResolvedValue([]), + }, + }, + { role: SystemRole.CONTROLLER }, + ); + + const csv = await caller.exportResourcesCSV(); + + expect(csv).toContain("eid,displayName,email,chapter"); + expect(csv).toContain("E-001,Ada Lovelace"); + }); + + it("allows managers with import permission to import CSV rows", async () => { + const resourceFindFirst = vi.fn().mockResolvedValue({ + id: "res_1", + displayName: "Ada", + email: "ada-old@example.com", + chapter: "Old", + lcrCents: 9000, + }); + const resourceUpdate = vi.fn().mockResolvedValue({ id: "res_1" }); + const auditCreate = vi.fn().mockResolvedValue({ id: "audit_1" }); + + const caller = createProtectedCaller( + { + resource: { + findFirst: resourceFindFirst, + update: resourceUpdate, + }, + auditLog: { + create: auditCreate, + }, + }, + { + role: SystemRole.MANAGER, + granted: [PermissionKey.IMPORT_DATA], + }, + ); + + const result = await caller.importCSV({ + entityType: "resources", + rows: [{ eid: "E-001", displayName: "Ada Lovelace" }], + dryRun: false, + }); + + expect(resourceUpdate).toHaveBeenCalledWith({ + where: { id: "res_1" }, + data: { + displayName: "Ada Lovelace", + email: "ada-old@example.com", + chapter: "Old", + lcrCents: 9000, + }, + }); + expect(result.updated).toBe(1); + }); + + it("blocks managers without the import permission", async () => { + const caller = createProtectedCaller( + { + resource: { + findFirst: vi.fn(), + update: vi.fn(), + }, + auditLog: { + create: vi.fn(), + }, + }, + { + role: SystemRole.MANAGER, + denied: [PermissionKey.IMPORT_DATA], + }, + ); + + await expect( + caller.importCSV({ + entityType: "resources", + rows: [], + dryRun: true, + }), + ).rejects.toThrow(PermissionKey.IMPORT_DATA); + }); +}); diff --git a/packages/api/src/router/import-export-procedure-support.ts b/packages/api/src/router/import-export-procedure-support.ts new file mode 100644 index 0000000..eac9abf --- /dev/null +++ b/packages/api/src/router/import-export-procedure-support.ts @@ -0,0 +1,196 @@ +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 ImportExportProcedureContext = Pick & { + 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: ImportExportProcedureContext) { + 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: ImportExportProcedureContext) { + 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: ImportExportProcedureContext, 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: ImportExportProcedureContext, input: ImportCsvInput) { + requirePermission( + { permissions: ctx.permissions ?? new Set() }, + 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` }; + } + + 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, 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 ctx.db.auditLog.create({ + data: { + entityType: input.entityType, + entityId: "bulk-import", + action: "IMPORT", + changes: { summary: results }, + }, + }); + + return results; +} diff --git a/packages/api/src/router/import-export.ts b/packages/api/src/router/import-export.ts index 3282bec..4c96563 100644 --- a/packages/api/src/router/import-export.ts +++ b/packages/api/src/router/import-export.ts @@ -1,157 +1,17 @@ -import { BlueprintTarget, PermissionKey } from "@capakraken/shared"; -import type { BlueprintFieldDefinition } from "@capakraken/shared"; -import { z } from "zod"; -import { controllerProcedure, createTRPCRouter, managerProcedure, requirePermission } from "../trpc.js"; +import { controllerProcedure, createTRPCRouter, managerProcedure } from "../trpc.js"; +import { + exportProjectsCsv, + exportResourcesCsv, + importCsv, + importCsvInputSchema, +} from "./import-export-procedure-support.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 }, - }), - ]); + exportResourcesCSV: controllerProcedure.query(({ ctx }) => exportResourcesCsv(ctx)), - // 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); + exportProjectsCSV: controllerProcedure.query(({ ctx }) => exportProjectsCsv(ctx)), - 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; - }), + .input(importCsvInputSchema) + .mutation(({ ctx, input }) => importCsv(ctx, input)), });