The {IN: [...]} operator object for `id` crashes Civi APIv3 on this
install — Civi's error renderer calls htmlentities() on the array
value and dies, producing the 500 with the html error page seen at
6c3e8dd's deploy.
Single-id calls succeed (confirmed in the user's API Explorer test
against id=150). So loop one call per file id and run them in
parallel via Promise.allSettled. Reports typically reference a
handful of files, so the round-trip overhead is small and individual
file failures don't poison the whole report.
Previous commit (8ace5f4) passed `return: ["id", "url"]` to APIv3
Attachment.get. APIv3 expects `return` as a comma-separated string
("id,url"); arrays are v4 syntax. Civi caught the type mismatch but
its error-rendering pathway then crashed on htmlentities() (which
was passed the offending array), producing a 500 with an HTML error
page rather than a clean JSON error.
Fix: pass return as "id,url" and add sequential:1 (canonical v3 client
shape — values come back as an array). The civi3 helper already
normalizes either response shape, but sequential matches what real
APIv3 clients send.
Logs to confirm after deploy:
- Success: no "[staff/report] Attachment.get (v3) failed" warning.
- File links resolve directly without bouncing off the
/civicrm/webform-mw/file extension route.
APIv4 Attachment isn't exposed on this Civi install (confirmed in the
June 2026 upload-spike notes), so the Attachment.get call we shipped at
63e73e7 silently returned nothing and we fell through to the bare
/civicrm/file URL — which crashes Civi on a null fcs JWT decode.
APIv3 Attachment.get IS exposed and returns the signed URL with fcs
baked in (verified against id=150 in the user's API Explorer):
"url": "https://.../civicrm/file?reset=1&id=150&fcs=<JWT>"
Changes:
- lib/civicrm.ts: add a civi3() helper that calls /civicrm/ajax/rest with
AuthX headers, normalizing v3's array-or-keyed-object values shape into
a plain array.
- app/api/staff/report/route.ts: replace the dead v4 Attachment.get with
civi3("Attachment", "get", { id: {IN: [...]}, return: ["id","url"] }).
Each file's url goes into the value payload as before, so the frontend
needs no change.
Fallback chain remains intact: if Attachment.get fails (auth, endpoint
unavailable, etc.) the frontend still uses the /civicrm/webform-mw/file
extension route from b65bc6d/41467bd.
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.
UX iteration after first live look:
- Sticky anchor strip below the header with a chip per section (incl.
Submissions) so staff can jump around a long page.
- Compact one-line rows that show only the latest value; multi-history
fields get a muted 'N earlier entries' toggle that reveals the rest
inline. Same affordance for file fields.
- Empty fields collapse under a single 'N empty fields' toggle per
section instead of taking a row each.
- Stage 5: Y1_Q<n>_<metric> fields render as a read-only matrix table
(rows: metrics; columns: Q1..Q4) matching the form's matrix layout.
File proxy (/api/staff/file) deleted. APIv4 Attachment isn't exposed
on this Civi instance (per the June upload spike), which is why the
previous proxy returned broken images. Staff are already authenticated
to Civi when they arrive here, so file fields now render as outbound
links to CIVI_BASE_URL/civicrm/file?reset=1&id=<id> and the browser
uses the staff session. No more proxy auth, no more SSRF surface to
harden, no broken images.
CIVI_BASE_URL flows from the staff page (server component) into the
client as a prop. No secret material crosses the boundary.
- Allowlist inline MIME types (png/jpeg/gif/webp/pdf only); everything
else, including SVG and HTML, served as application/octet-stream
with content-disposition: attachment.
- X-Content-Type-Options: nosniff and a restrictive CSP on every response.
- Validate the upstream URL Civi returns: must match CIVI_BASE_URL origin
before we attach basic-auth creds and follow it. redirect: manual to
prevent off-host hops.
- Drop SVG from the client's inline-image list (server forces download).