3c0179fcec
Prevents mutations from committing without an audit trail if the auditLog.create call fails after the main write already succeeded. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
198 lines
5.7 KiB
TypeScript
198 lines
5.7 KiB
TypeScript
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<TRPCContext, "db">;
|
|
|
|
type ImportExportMutationContext = ImportExportReadContext & {
|
|
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: 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<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: 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<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: 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;
|
|
}
|