Staff report: inline lightbox for image/PDF attachments

Adds a /api/staff/file proxy that re-streams Civi attachments with
Content-Disposition: inline so a native <dialog> lightbox can preview
images and PDFs in place. Office docs keep their plain download link
and gain a "View in Google Docs" secondary link (uses the Civi-signed
URL so Google can fetch without our staff key).

Also threads mime through /api/staff/report (Attachment.get mime_type)
so the dispatcher picks the right affordance without relying solely on
filename inference.
This commit is contained in:
Joel Brock
2026-06-15 11:56:45 -07:00
parent 5203dabeac
commit 6850ff9dee
8 changed files with 648 additions and 29 deletions
+16 -6
View File
@@ -193,7 +193,7 @@ function buildStubPayload(orgId: number): StaffReportPayload {
{
activityId: 9012,
date: daysAgo(3),
value: { id: 4242, file_name: "co-op-vision.pdf" },
value: { id: 4242, file_name: "co-op-vision.pdf", mime: "application/pdf" },
},
],
},
@@ -373,6 +373,7 @@ async function buildLivePayload(orgId: number): Promise<StaffReportPayload> {
}
const urlByFileId = new Map<number, string>();
const mimeByFileId = new Map<number, string>();
if (fileIds.size > 0) {
// APIv3 Attachment.get doesn't accept an IN-clause cleanly on this Civi
// install — passing {IN: [...]} for `id` crashes Civi's error renderer
@@ -382,11 +383,15 @@ async function buildLivePayload(orgId: number): Promise<StaffReportPayload> {
// small. Each request is independent; we issue them in parallel.
const lookups = await Promise.allSettled(
Array.from(fileIds).map((fid) =>
civi3<{ id: string | number; url?: string }>("Attachment", "get", {
id: fid,
return: "id,url",
sequential: 1,
}).then((r) => ({ fid, row: r.values?.[0] })),
civi3<{ id: string | number; url?: string; mime_type?: string }>(
"Attachment",
"get",
{
id: fid,
return: "id,url,mime_type",
sequential: 1,
},
).then((r) => ({ fid, row: r.values?.[0] })),
),
);
for (const result of lookups) {
@@ -401,6 +406,9 @@ async function buildLivePayload(orgId: number): Promise<StaffReportPayload> {
if (row && typeof row.url === "string" && row.url.length > 0) {
urlByFileId.set(fid, row.url);
}
if (row && typeof row.mime_type === "string" && row.mime_type.length > 0) {
mimeByFileId.set(fid, row.mime_type);
}
}
}
@@ -447,6 +455,7 @@ async function buildLivePayload(orgId: number): Promise<StaffReportPayload> {
id: raw,
file_name: typeof fname === "string" ? fname : undefined,
url: Number.isFinite(fid) ? urlByFileId.get(fid) : undefined,
mime: Number.isFinite(fid) ? mimeByFileId.get(fid) : undefined,
};
}
return {
@@ -480,6 +489,7 @@ async function buildLivePayload(orgId: number): Promise<StaffReportPayload> {
id: v,
file_name: typeof fname === "string" ? fname : undefined,
url: Number.isFinite(fid) ? urlByFileId.get(fid) : undefined,
mime: Number.isFinite(fid) ? mimeByFileId.get(fid) : undefined,
};
}
entries.push({ activityId: row.id, date: row.activity_date_time, value });