refactor(api): extract import export procedures
This commit is contained in:
@@ -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<TRPCContext, "db"> & {
|
||||
permissions?: Set<PermissionKey>;
|
||||
};
|
||||
|
||||
type ImportRow = Record<string, string>;
|
||||
|
||||
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<typeof importCsvInputSchema>;
|
||||
|
||||
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<string, unknown>) ?? {};
|
||||
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<string, unknown>) ?? {};
|
||||
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>() },
|
||||
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;
|
||||
}
|
||||
@@ -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<string, unknown> ?? {};
|
||||
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<string, unknown> ?? {};
|
||||
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)),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user