refactor(api): extract import export procedures
This commit is contained in:
@@ -13,9 +13,9 @@ Done
|
|||||||
- `holiday-calendar`
|
- `holiday-calendar`
|
||||||
- `org-unit`
|
- `org-unit`
|
||||||
- `dispo`
|
- `dispo`
|
||||||
|
- `insights`
|
||||||
|
|
||||||
Ready next
|
Ready next
|
||||||
- `insights`
|
|
||||||
- `import-export`
|
- `import-export`
|
||||||
|
|
||||||
Deferred or blocked
|
Deferred or blocked
|
||||||
|
|||||||
@@ -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<string, unknown>, 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<Partial<TRPCError>>({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: `Permission required: ${PermissionKey.IMPORT_DATA}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown>,
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { controllerProcedure, createTRPCRouter, managerProcedure } from "../trpc.js";
|
||||||
import type { BlueprintFieldDefinition } from "@capakraken/shared";
|
import {
|
||||||
import { z } from "zod";
|
exportProjectsCsv,
|
||||||
import { controllerProcedure, createTRPCRouter, managerProcedure, requirePermission } from "../trpc.js";
|
exportResourcesCsv,
|
||||||
|
importCsv,
|
||||||
|
importCsvInputSchema,
|
||||||
|
} from "./import-export-procedure-support.js";
|
||||||
|
|
||||||
export const importExportRouter = createTRPCRouter({
|
export const importExportRouter = createTRPCRouter({
|
||||||
/**
|
exportResourcesCSV: controllerProcedure.query(({ ctx }) => exportResourcesCsv(ctx)),
|
||||||
* 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)
|
exportProjectsCSV: controllerProcedure.query(({ ctx }) => exportProjectsCsv(ctx)),
|
||||||
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<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
|
importCSV: managerProcedure
|
||||||
.input(
|
.input(importCsvInputSchema)
|
||||||
z.object({
|
.mutation(({ ctx, input }) => importCsv(ctx, input)),
|
||||||
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;
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user