From 9209d6dc02dce574d654731d03af5b2d23b1ab59 Mon Sep 17 00:00:00 2001 From: Joel Brock Date: Tue, 19 May 2026 15:51:35 -0700 Subject: [PATCH] Prefill file fields with their file_name joined from CiviCRM CiviCRM APIv4 file custom fields return a bare file id by default; an extra '.file_name' join is required to get the human-readable filename. Both the form prefill walk (lib/prefill.ts) and the report walk (app/api/report/route.ts) now request '.file_name' for every file-type field alongside the primary value, and wrap the prefill into a { id, file_name } object so downstream UI has both. Falls back to file_name undefined when the join returns null (eg orphaned id). The form's FilePriorIndicator already accepts the object shape, so it now shows the filename inline. The report's FormattedValue gets a matching case: renders the file_name string if present, falls back to 'Attachment #' when only the id came through. --- app/api/report/route.ts | 18 +++++++++++++++++- components/ReportView.tsx | 12 +++++++++++- lib/prefill.ts | 25 ++++++++++++++++++++++--- 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/app/api/report/route.ts b/app/api/report/route.ts index 0301f54..bd01aca 100644 --- a/app/api/report/route.ts +++ b/app/api/report/route.ts @@ -158,15 +158,26 @@ export async function GET(req: NextRequest) { const orgId = orgs[0].contact_id_b; // Org name + activity walk + option groups, in parallel. + // For file-type fields, also request the joined `.file_name` so the + // report can surface a human-readable filename rather than the raw + // file id that APIv4 returns by default. const civiFieldNames = Array.from( new Set(allFields.map((f) => f.civiField).filter((f): f is string => !!f)), ); + const fileFieldRefs = Array.from( + new Set( + allFields + .filter((f) => f.type === "file" && f.civiField) + .map((f) => `${f.civiField!}.file_name`), + ), + ); const select = [ "id", "activity_date_time", "subject", ACTIVITY_STAGE_FIELD, ...civiFieldNames, + ...fileFieldRefs, ]; const [orgRes, activityRes, options] = await Promise.all([ @@ -227,10 +238,15 @@ export async function GET(req: NextRequest) { for (const row of rows) { const v = row[f.civiField]; if (v === null || v === undefined || v === "") continue; + let value: unknown = v; + if (f.type === "file") { + const fname = row[`${f.civiField}.file_name`]; + value = { id: v, file_name: typeof fname === "string" ? fname : undefined }; + } entries.push({ activityId: row.id, date: row.activity_date_time, - value: v, + value, }); } if (entries.length > 0) fieldHistory[f.name] = entries; diff --git a/components/ReportView.tsx b/components/ReportView.tsx index 50ec593..83a6123 100644 --- a/components/ReportView.tsx +++ b/components/ReportView.tsx @@ -459,8 +459,18 @@ function FormattedValue({ const labels = parts.map((p) => opts?.find((o) => o.value === p)?.label ?? p); return <>{labels.join(", ")}; } - case "file": + case "file": { + // Prefill / history wraps file values into { id, file_name } so the + // UI can show a human-readable name. Fall back to whatever scalar + // came through if the shape is different. + if (typeof value === "object" && value !== null) { + const o = value as Record; + const fname = o.file_name ?? o.name ?? o.label ?? o.filename; + if (typeof fname === "string" && fname.length > 0) return <>{fname}; + if (typeof o.id !== "undefined") return <>Attachment #{String(o.id)}; + } return <>{String(value)}; + } case "textarea": case "text": case "email": diff --git a/lib/prefill.ts b/lib/prefill.ts index be09714..5d3ae29 100644 --- a/lib/prefill.ts +++ b/lib/prefill.ts @@ -39,7 +39,19 @@ export async function loadPrefill( activityTypeName = "Org Engagement Submission", ): Promise { const civiSelected = fields.filter((f) => f.civiField); - const select = ["id", "activity_date_time", ...new Set(civiSelected.map((f) => f.civiField!))]; + + // CiviCRM File custom fields return a file id by default. To surface a + // human-readable filename in the prefill (so the prior-attachment + // indicator can show it), also request the joined `.file_name` for any + // file-type field. + const fileFieldRefs = new Set( + civiSelected.filter((f) => f.type === "file").map((f) => f.civiField!), + ); + + const selectSet = new Set(["id", "activity_date_time"]); + for (const f of civiSelected) selectSet.add(f.civiField!); + for (const ref of fileFieldRefs) selectSet.add(`${ref}.file_name`); + const select = Array.from(selectSet); const res = await civi("Activity", "get", { select, @@ -56,10 +68,17 @@ export async function loadPrefill( for (const f of civiSelected) { for (const row of rows) { const v = row[f.civiField!]; - if (v !== null && v !== undefined && v !== "") { + if (v === null || v === undefined || v === "") continue; + if (f.type === "file") { + const fname = row[`${f.civiField!}.file_name`]; + out[f.name] = { + id: v, + file_name: typeof fname === "string" ? fname : undefined, + }; + } else { out[f.name] = v; - break; } + break; } }