From 63e73e7fe684a6a52f09a069bd77840e93b76596 Mon Sep 17 00:00:00 2001 From: Joel Brock Date: Wed, 10 Jun 2026 09:29:42 -0700 Subject: [PATCH] Staff report: pass Civi-signed file URLs through to clicks Civi serves uploaded files at /civicrm/file?id=X&eid=Y&fcs=; the fcs is a JWT signed with the site key. Without it, Civi's file handler crashes on a null JWT decode (Firebase\JWT\JWT::decode argument null). We don't have the site key on the Next.js side, so let Civi mint the URLs for us. Backend (/api/staff/report): - Add file_name selects for org-side file fields (Certificate of Incorporation and friends) so org files have names alongside URLs. - Collect every file id referenced by activity and org custom fields. - Call APIv4 Attachment.get with select: ["id", "url"] to fetch signed URLs in one round trip. Build a urlByFileId map. - Org-side file values are now wrapped in { id, file_name, url } shape matching the activity-side files (previously bare file ids that the frontend couldn't render). - Activity-side file values gain a url property from the map. - If Attachment.get doesn't expose url on this Civi version, the call is caught and we fall through to bare URLs without fcs (no regression). Frontend (FieldValue): - Prefer v.url when present, normalizing absolute and relative shapes against CIVI_BASE_URL. - Fall back to /civicrm/file?reset=1&id=X if url wasn't provided. --- app/api/staff/report/route.ts | 92 ++++++++++++++++++++++++++++++---- components/StaffReportView.tsx | 22 +++++--- 2 files changed, 97 insertions(+), 17 deletions(-) diff --git a/app/api/staff/report/route.ts b/app/api/staff/report/route.ts index bc5786a..248bd47 100644 --- a/app/api/staff/report/route.ts +++ b/app/api/staff/report/route.ts @@ -290,10 +290,21 @@ async function buildLivePayload(orgId: number): Promise { const activityDescriptors = descriptors.filter((d) => d.groupKind === "activity"); const orgDescriptors = descriptors.filter((d) => d.groupKind === "org"); - // 2. Org Contact (display_name + every org-side custom field). - const orgSelect = ["id", "display_name", "contact_type", ...orgDescriptors.map((d) => d.civiField)]; - // 3. Activities (every activity-side custom field + file-name/url joins). - const fileFieldRefs = activityDescriptors + // 2. Org Contact (display_name + every org-side custom field, plus file-name + // joins for any file-typed org fields so the staff report can render a + // label next to the link). + const orgFileNameRefs = orgDescriptors + .filter((d) => d.render === "file") + .map((d) => `${d.civiField}.file_name`); + const orgSelect = [ + "id", + "display_name", + "contact_type", + ...orgDescriptors.map((d) => d.civiField), + ...orgFileNameRefs, + ]; + // 3. Activities (every activity-side custom field + file-name joins). + const activityFileNameRefs = activityDescriptors .filter((d) => d.render === "file") .map((d) => `${d.civiField}.file_name`); const activitySelect = [ @@ -303,7 +314,7 @@ async function buildLivePayload(orgId: number): Promise { "source_contact_id.display_name", ACTIVITY_STAGE_FIELD, ...activityDescriptors.map((d) => d.civiField), - ...fileFieldRefs, + ...activityFileNameRefs, ]; // 4. Option groups for every select/multiselect + the stage option group. const optionGroupIds = Array.from( @@ -341,6 +352,53 @@ async function buildLivePayload(orgId: number): Promise { const rows = activityRes.values ?? []; + // Civi serves uploaded files at /civicrm/file?id=X&eid=Y&fcs=; the fcs + // is a JWT signed with Civi's site key. Without it, the file handler + // crashes on a null JWT decode. We don't have the site key on this side, + // so ask Civi for signed URLs via APIv4 Attachment.get and pass them + // straight through to the client. If Attachment.get doesn't expose `url` + // on this Civi version, the frontend falls back to a bare /civicrm/file + // URL (still broken, but no worse than before). + const fileIds = new Set(); + const collectId = (v: unknown) => { + if (v === null || v === undefined || v === "") return; + const n = typeof v === "number" ? v : Number(v); + if (Number.isFinite(n) && n > 0) fileIds.add(n); + }; + for (const d of activityDescriptors) { + if (d.render !== "file") continue; + for (const row of rows) collectId(row[d.civiField]); + } + for (const d of orgDescriptors) { + if (d.render !== "file") continue; + collectId(org[d.civiField]); + } + + const urlByFileId = new Map(); + if (fileIds.size > 0) { + try { + const attachRes = await civi<{ id: number; url?: string }>("Attachment", "get", { + select: ["id", "url"], + where: [["id", "IN", Array.from(fileIds)]], + // Bypass permission checks: we already gated this whole route on + // STAFF_REPORT_KEY, and we want every file the org's activities + // reference, regardless of which contact "owns" them. + checkPermissions: false, + limit: 0, + }); + for (const a of attachRes.values ?? []) { + if (typeof a.url === "string" && a.url.length > 0) { + urlByFileId.set(a.id, a.url); + } + } + } catch (e) { + console.warn( + "[staff/report] Attachment.get failed; file links will lack fcs:", + e instanceof Error ? e.message : String(e), + ); + } + } + // Activity summaries. const activities: ActivitySummary[] = rows.map((r) => ({ id: r.id, @@ -373,11 +431,23 @@ async function buildLivePayload(orgId: number): Promise { groupKind: "org", fields: orgDescriptors.map((d) => { const raw = org[d.civiField]; - const history: FieldHistoryEntry[] = - raw === null || raw === undefined || raw === "" - ? [] - : [{ activityId: 0, date: "", value: raw }]; - return { descriptor: d, history }; + if (raw === null || raw === undefined || raw === "") { + return { descriptor: d, history: [] }; + } + let value: unknown = raw; + if (d.render === "file") { + const fid = Number(raw); + const fname = org[`${d.civiField}.file_name`]; + value = { + id: raw, + file_name: typeof fname === "string" ? fname : undefined, + url: Number.isFinite(fid) ? urlByFileId.get(fid) : undefined, + }; + } + return { + descriptor: d, + history: [{ activityId: 0, date: "", value }], + }; }), }); } @@ -400,9 +470,11 @@ async function buildLivePayload(orgId: number): Promise { let value: unknown = v; if (d.render === "file") { const fname = row[`${d.civiField}.file_name`]; + const fid = Number(v); value = { id: v, file_name: typeof fname === "string" ? fname : undefined, + url: Number.isFinite(fid) ? urlByFileId.get(fid) : undefined, }; } entries.push({ activityId: row.id, date: row.activity_date_time, value }); diff --git a/components/StaffReportView.tsx b/components/StaffReportView.tsx index efe2643..ca9953a 100644 --- a/components/StaffReportView.tsx +++ b/components/StaffReportView.tsx @@ -453,16 +453,24 @@ function FieldValue({ civiBaseUrl: string; }) { if (field.descriptor.render === "file") { - const v = entry.value as { id?: number | string; file_name?: string } | null; + const v = entry.value as + | { id?: number | string; file_name?: string; url?: string } + | null; if (!v || v.id === undefined) return ; const id = String(v.id); const name = v.file_name ?? `file-${id}`; - // Civi serves uploaded files at /civicrm/file?reset=1&id=. - // The staff member is already authenticated to Civi (they came from - // there); the browser sends their session cookie automatically. - const href = civiBaseUrl - ? `${civiBaseUrl}/civicrm/file?reset=1&id=${encodeURIComponent(id)}` - : "#"; + // Prefer the Civi-signed URL (carries the fcs JWT) returned by + // Attachment.get; Civi's file handler crashes on a null fcs decode if we + // hit /civicrm/file?id=X bare. Fall back to a bare URL only if signed + // URLs weren't available (e.g. older Civi without `url` on Attachment). + let href = "#"; + if (v.url) { + href = v.url.startsWith("http") + ? v.url + : `${civiBaseUrl}${v.url.startsWith("/") ? "" : "/"}${v.url}`; + } else if (civiBaseUrl) { + href = `${civiBaseUrl}/civicrm/file?reset=1&id=${encodeURIComponent(id)}`; + } return (