Staff report: pass Civi-signed file URLs through to clicks

Civi serves uploaded files at /civicrm/file?id=X&eid=Y&fcs=<JWT>; 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.
This commit is contained in:
Joel Brock
2026-06-10 09:29:42 -07:00
parent 229ef51537
commit 63e73e7fe6
2 changed files with 97 additions and 17 deletions
+82 -10
View File
@@ -290,10 +290,21 @@ async function buildLivePayload(orgId: number): Promise<StaffReportPayload> {
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<StaffReportPayload> {
"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<StaffReportPayload> {
const rows = activityRes.values ?? [];
// Civi serves uploaded files at /civicrm/file?id=X&eid=Y&fcs=<JWT>; 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<number>();
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<number, string>();
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<StaffReportPayload> {
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<StaffReportPayload> {
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 });