Compare commits

...
22 Commits
Author SHA1 Message Date
Joel Brock a0ebef6bc5 copy edit, form subheader 2026-06-22 10:28:10 -07:00
Joel Brock e4085aa0f3 Lightbox: pin and size to visible viewport when framed
The staff-report iframe is expanded to full content height (no nested
scrollbar), so a modal <dialog> centered in its viewport landed in the
middle of the whole document and sat off-screen unless the parent page
was scrolled there. Cross-origin means the iframe can't read the
parent's scroll position itself.

Tab.tpl now broadcasts the iframe's visible slice (webform-mw-viewport,
rAF-throttled on scroll/resize/load and after each height change), and
AttachmentLightbox pins to that slice and fills its height. Falls back
to a fixed box when no viewport message arrives (older extension), and
standalone mode keeps native centering at a taller 85vh.

Extension bumped to 0.3.1 (template change -> re-copy + cv flush on
prod). PRODUCTION_CUTOVER.md change log updated.
2026-06-19 08:03:40 -07:00
Joel Brock b4f733ee65 PROD update info help text on the form with latest from CiviCRM prd 2026-06-19 08:01:01 -07:00
Joel Brock 5124010b8a Lightbox: fix proxy 404, cap modal size, allow multi-Civi embed
Three quick fixes off first-deploy testing:

1. /api/staff/file 404'd for valid files. Refactor fileBelongsToOrg
   to SELECT the org's and activities' file columns and JS-compare
   instead of WHERE ... OR with custom field refs (APIv4 fragility
   around nested OR + dotted custom fields). Same ownership probe,
   same shape /api/staff/report itself uses to read file values.

2. Lightbox ballooned to full report height because the staff iframe
   auto-grows to fit content (often 3000+ px). Cap to a fixed
   640px x min(92vw, 900px) box so it stays a reasonable preview
   regardless of iframe document size.

3. Production frame-ancestors blocked crm.fci.coop from iframing
   survey.fci.coop -- the CSP only included the dev Civi origin
   derived from CIVI_BASE_URL. Add CIVI_FRAME_ALLOWED_ORIGINS
   (comma-separated) so one app deploy can be embedded by both
   dev and prod Civi. Falls back to CIVI_BASE_URL for single-Civi
   compatibility.

PRODUCTION_CUTOVER.md updated inline and in the change log.
2026-06-16 16:15:28 -07:00
Joel Brock 0019996b15 Staff file proxy: restore IDOR check via column ownership
Replaces the dropped entity_file→org check with a probe against the
actual ownership chain — the file_id stored in a custom-field column
on the org or on one of its activities.

For each request:
  1. Discover file-typed CustomField refs in ACTIVITY_GROUP_NAMES and
     ORG_GROUP_NAMES (one CustomField.get).
  2. In parallel, probe:
       - Contact.get(id=orgId)         WHERE any org file field == fileId
       - Activity.get(target=orgId)    WHERE any activity file field == fileId
     using APIv4 OR clauses.
  3. Allow only if at least one probe returns a hit.

This is the same ownership the staff report itself uses to surface the
file — the proxy now refuses to broker bytes for any file id that
wouldn't appear in the org's own report. civicrm_entity_file remains
unused for auth (it's anchored to the submitter, not the org).
2026-06-15 16:03:48 -07:00
Joel Brock e74462da0b Lightbox: fix proxy 404 and PDF iframe block
Two bugs surfaced on first dev-server test:

1. /api/staff/file 404s for valid file ids. The old per-org check
   read civicrm_entity_file and required entity_id==orgId, but our
   upload route anchors files to the submitter's contact id, not the
   org's — the entity_file row is metadata-only on this install
   (see comment in app/api/upload/route.ts). The custom-field column
   is the real ownership signal, which /api/staff/report already uses,
   and the staff key already gates org access. Drop the bogus check;
   keep the entity_table whitelist as defence.

2. Same-origin PDF iframe blocked by frame-ancestors 'none'. The
   strict global CSP excludes /staff/report; add /api/staff/file to
   the same embed-friendly profile so the lightbox iframe can load.

Also move the sandbox/default-src 'none' CSP to the attachment path
only — a strict sandbox header breaks Chrome's PDF viewer on inline
responses (it needs to load fonts and plugin-mode rendering). On
inline we rely on the SAFE_INLINE_MIMES allowlist + X-Content-Type-
Options + the app's global CSP.
2026-06-15 16:00:30 -07:00
Joel Brock 49d0d24950 Staff file proxy: allowlist inline mimes, force download otherwise
Defence in depth against XSS through a non-allowlisted upload path:
our /api/upload route validates mimes, but the underlying civicrm_file
row can be populated through other routes (Civi admin UI uploads,
imports). A row with mime_type=text/html or image/svg+xml would have
been served inline from this same-origin proxy.

- SAFE_INLINE_MIMES allowlist: png/jpeg/gif/webp/pdf only
- Anything outside it is rewritten to application/octet-stream plus
  Content-Disposition: attachment so the browser downloads
- Adds Content-Security-Policy sandbox so even a mistaken inline serve
  cannot run script or exfiltrate
2026-06-15 11:59:12 -07:00
Joel Brock 6850ff9dee 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.
2026-06-15 11:56:45 -07:00
Joel Brock 5203dabeac Submit confirm: trim copy and use 'survey' wording
Drops the trailing sentence about returning later for another check-in (now redundant with the post-submit CTA) and matches the rest of the UI's 'survey' terminology.
2026-06-11 14:41:18 -07:00
Joel Brock 13d7f6e93b Timeline: draw stage-period ranges from activities, milestones on top
Each stage-transition activity now becomes a horizontal range on its lane: start = the activity's date, end = the next activity with a higher stage rank, or extending to today if still in effect. Milestone date-field dots overlay on top of the ranges. Activity dots at each range start carry a tooltip with the activity subject. Added computeStageRanges in lib/stageRank.ts and switched DateTimeline to take activities directly (deriving both the rank resolver and the ranges internally). Wide year-spanning timelines now show their full extent even when no milestone dates have been entered.
2026-06-11 14:14:36 -07:00
Joel Brock d3b88d92c2 Timeline: place dots by co-op stage at the event date
Each date-field event is now plotted on the lane corresponding to the Framework Stage the co-op was in on the field's stored date, resolved from the activity stream's stage transitions. Date_Opened is pinned to Stage 5 as the journey anchor. Events that pre-date any known transition fall back to the field's section rank so they still surface somewhere. Extracts the STAGE_RANK map (previously inline in ReportView) into lib/stageRank.ts alongside the new buildStageRankAtDate resolver factory.
2026-06-11 13:56:27 -07:00
Joel Brock ade938eaaa Survey copy: clarify subtitle and rename submit-another button
Subtitle now tells respondents they can skip fields they do not know. Post-submit CTA says Submit an update since the same form is the ongoing update channel, not a one-off survey.
2026-06-11 13:47:35 -07:00
Joel Brock 8dfbbea0ba Report: drop Stage 0 lane, flip 5→1 axis, fix Actual label clipping
DateTimeline now skips section rank 0 (intake) and renders stage lanes
top-to-bottom 5→1, matching how the framework actually numbers stages.

MembershipChart's latest-Actual label was placed at the circle's
x + 6, which overflowed the SVG when the most-recent point landed near
the right padding. Anchor it to xOf(maxT) - 4 with textAnchor="end",
mirroring the Goal label so both endpoint labels stay inside the chart.
2026-06-11 13:39:42 -07:00
Joel Brock 2dc8ec4e6d Civi extension: bump version to 0.3.0 2026-06-10 12:35:41 -07:00
Joel Brock 325615576a Upload: route through Civi extension multipart endpoint
Every JSON-based upload path on this Civi stores the `content` field
verbatim on disk — confirmed against both APIv4 File.create AND APIv3
Attachment.create (both came back as base64 text in hex dumps). The
multipart `file` part to /civicrm/ajax/rest is also a dead end: APIv3
Attachment.create on this install doesn't see $_FILES (rejected with
"Mandatory key(s) missing: id or content or options.move-file").

The one path Civi honors is APIv3 Attachment.create + options.move-file
— pointing at a filesystem path the Civi server can read. So expose a
tiny multipart endpoint in the WebForm-mw Civi extension that copies
PHP's $_FILES['file']['tmp_name'] into the API call, then return the
new file id as JSON. PHP's $_FILES preserves binary natively.

Civi extension (requires admin deploy):
- CRM/WebformMw/Page/Upload.php  : multipart POST handler. Validates
  the upload, requires `access CiviCRM`, whitelists entity_table to
  civicrm_contact|civicrm_activity, calls Attachment.create with
  move-file pointing at the tmp upload, returns {id, name} JSON.
- xml/Menu/webform_mw.xml        : registers civicrm/webform-mw/upload.

WebForm-mw side:
- lib/civicrm.ts : new civiMultipart() helper. POSTs multipart to an
  arbitrary Civi path (not /civicrm/ajax/rest) with the same AuthX
  headers. Returns the parsed JSON body.
- app/api/upload/route.ts : send the upload's bytes via civiMultipart
  to civicrm/webform-mw/upload. Comment-block now records all four
  upload paths we tried so a future reader doesn't repeat the cycle.

Deploy: admin syncs the updated civi-extension/webform-mw/ directory
and Disable/Re-enables the extension (or runs cv flush) so the new
menu route is registered.
2026-06-10 12:28:43 -07:00
Joel Brock e42e3b70ef Upload: APIv3 Attachment.create with base64 content (not multipart)
The previous multipart attempt (f74fd07) was rejected by Civi with
"Mandatory key(s) missing: id or content or options.move-file" — the
/civicrm/ajax/rest endpoint on this install doesn't expose $_FILES to
v3 actions; only params['content'] is consulted. Our multipart `file`
part was ignored.

APIv3 Attachment.create has historically been the file-upload entry
point used by Civi's own form widgets and auto-decodes the `content`
field from base64, unlike v4 File.create which stores it verbatim.
Send the same shape we tried first (entity_table, entity_id, name,
mime_type, content=base64) but to v3 Attachment instead of v4 File.

The civi3Upload helper in lib/civicrm.ts is kept in place — it's not
useful for this endpoint but the multipart-POST shape may be needed
later for other Civi entities that do read $_FILES.
2026-06-10 12:16:32 -07:00
Joel Brock f74fd07ba5 Upload: switch to APIv3 Attachment.create multipart (decode bytes properly)
APIv4 File.create on this Civi install stores the `content` field
verbatim — no base64 decoding. The hex dump of a downloaded file
confirms it: bytes start with 69 56 42 4f ("iVBO...") which is the
base64 encoding of the PNG header (89 50 4e 47), not the header itself.
Every file uploaded via the form has been corrupt on disk since launch.

JSON can't carry binary safely (high bytes break UTF-8), so the fix is
to stop trying. APIv3 Attachment.create accepts a multipart `file` part
the standard way (read from $_FILES on the server side) which preserves
bytes exactly.

Changes:
- lib/civicrm.ts: new civi3Upload() helper. POSTs multipart/form-data
  with `entity`, `action`, `json`, and `file` parts to /civicrm/ajax/rest
  using the same AuthX headers as civi3(). Wraps the Uint8Array into an
  ArrayBuffer slice so Blob's narrower BlobPart typing accepts it.
- app/api/upload/route.ts: replace the v4 File.create JSON call with
  civi3Upload("Attachment", "create", ...). Attachment.create requires
  an entity context, so anchor to the form-filler's contact id. Our
  custom-field flow uses the returned file id directly (no entity_file
  linkage needed for prefill/download), so the extra civicrm_entity_file
  row is metadata-only.

Note: existing files in Civi (uploaded via the buggy path) are still
corrupt on disk. New uploads will be intact. To recover the old ones
the user would need to re-upload via the form, or run a one-off
backfill that reads the base64 text out of /civicrm.files/upload/ and
rewrites each file with its decoded bytes.
2026-06-10 11:57:52 -07:00
Joel Brock e22c9226d8 Staff report: query Attachment.get per file id (no IN clause)
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.
2026-06-10 11:34:54 -07:00
Joel Brock 6c3e8dd6a0 Staff report: APIv3 return is a string, not an array
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.
2026-06-10 11:25:06 -07:00
Joel Brock 8ace5f41fe Staff report: signed file URLs via APIv3 Attachment.get
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.
2026-06-10 11:05:00 -07:00
Joel Brock 41467bd4cf File redirect: per-entity permission check before minting fcs
Security-review follow-up to b65bc6d. The file-redirect route signs an
fcs JWT that Civi's /civicrm/file handler accepts as proof of access.
Any user with the base `access CiviCRM` permission could iterate file
IDs and have us laundering tokens past the entity-level ACLs that would
normally apply (e.g. a staff user without view permission on a given
contact could still pull files attached to that contact).

Tighten it:

- Resolve the file's linked entity_table + entity_id (was: entity_id only).
- Run the entity-type's native permission check before signing the JWT:
    civicrm_activity -> CRM_Activity_BAO_Activity::checkPermission
    civicrm_contact  -> CRM_Contact_BAO_Contact_Permission::allow
  Unknown entity types deny by default — adding a new type requires an
  explicit edit here, so we don't accidentally widen the surface.
- Drop JWT lifetime from a week to 10 minutes. The token is minted at
  click time (the user hits this route fresh on each file click), so
  the long lifetime served no purpose and made each URL a longer-lived
  bearer credential.

Files missing from civicrm_entity_file or pointing at unsupported entity
types now 403 via CRM_Utils_System::permissionDenied() instead of
producing a download URL.
2026-06-10 10:49:28 -07:00
Joel Brock b65bc6d0e0 File redirect route: mint fcs JWT in extension, link from staff report
Path 1 (APIv4 Attachment.get + select url) shipped but didn't fix the
Firebase\JWT decode crash — the deployed Civi version either omits `url`
from Attachment.get or returns it without the fcs param. Falling back to
the bare /civicrm/file?id=X URL hits the same JWT null crash.

Path 2: route file clicks through a tiny redirect endpoint in the Civi
extension instead. The extension runs PHP on Civi, has access to the
crypto.jwt service, and mints the same shape of token Civi's own file
URL builder uses ({exp, "civi.file": <id>}) before 302-redirecting to
the canonical /civicrm/file URL.

Civi extension changes:
- New CRM/WebformMw/Page/File.php — resolves eid from civicrm_entity_file
  if not supplied, signs a 7-day JWT via Civi::service('crypto.jwt'),
  redirects.
- xml/Menu/webform_mw.xml — registers civicrm/webform-mw/file. Requires
  `access CiviCRM` (the user is already authenticated in the parent Civi
  tab when they click the link).

Frontend (StaffReportView.tsx, FieldValue):
- When Attachment.get's url is missing, fall back to the new extension
  route instead of bare /civicrm/file. Attachment.get's url remains the
  fast path when present.

Deploy: admin needs to push the updated extension files to the Civi
server, then Disable/Enable webform-mw (or cv flush) so the new menu
route registers in civicrm_menu.
2026-06-10 10:47:34 -07:00
23 changed files with 1856 additions and 190 deletions
+75 -12
View File
@@ -164,16 +164,42 @@ fill out the form to that Organization.
## 5. Install the `webform-mw` Civi extension ## 5. Install the `webform-mw` Civi extension
The extension adds an **Engagement Report** tab to Organization The extension does two jobs:
contact pages that embeds the staff report in an iframe.
- adds an **Engagement Report** tab to Organization contact pages that
embeds the staff report in an iframe; and
- exposes the **file-upload proxy route** `civicrm/webform-mw/upload`
(handled by `CRM_WebformMw_Page_Upload`) that the app's `/api/upload`
POSTs binary files to. This route was **added in v0.3.0** — a CRM
running an older version has the report tab but every form file
upload returns **502 Bad Gateway** (the app can't reach the route, so
`/api/upload` fails closed). **Production must be on v0.3.0 or later.**
1. Copy `WebForm-mw/civi-extension/webform-mw/` to the CRM's 1. Copy `WebForm-mw/civi-extension/webform-mw/` to the CRM's
`[civicrm.extensionsDir]` (usually `[civicrm.extensionsDir]` (usually
`<civi-root>/sites/default/ext/`). The directory must be named `<civi-root>/sites/default/ext/`). The directory must be named
exactly `webform-mw` (matches `<key>` in `info.xml`). exactly `webform-mw` (matches `<key>` in `info.xml`). When
**upgrading** an already-installed extension, overwrite the existing
directory in place.
2. `Administer → System Settings → Extensions → Add new → Refresh`, 2. `Administer → System Settings → Extensions → Add new → Refresh`,
then **Install** next to "WebForm-mw". then **Install** next to "WebForm-mw" (first install) or run the
**Upgrade** action if one is offered. If neither, **Disable** then
**Enable** the extension.
**Then flush caches**`cv flush`, or
**Administer → System Settings → Cleanup Caches**. New menu routes
(like `civicrm/webform-mw/upload`) are only registered after the
router is rebuilt; copying files without a flush leaves the upload
route 404ing and uploads 502ing.
Confirm the upload route resolves (a `400` means the route is live;
a `404`/login redirect means the flush didn't take):
```bash
curl -s -X POST https://crm.fci.coop/civicrm/webform-mw/upload
# → {"error":"Missing file part"} ✓ route exists
```
3. Configure — add to `civicrm.settings.php`: 3. Configure — add to `civicrm.settings.php`:
@@ -197,13 +223,20 @@ Full extension docs:
## 6. CSP / `frame-ancestors` — app side ## 6. CSP / `frame-ancestors` — app side
The Next.js app's `/staff/report` route must allow the CiviCRM origin The Next.js app's `/staff/report` and `/api/staff/file` routes must
in its `frame-ancestors` CSP, or the iframe will refuse to render. allow every CiviCRM origin that will iframe them, or the browser will
refuse to render.
The build reads `CIVI_BASE_URL` and adds its origin to the CSP Set **`CIVI_FRAME_ALLOWED_ORIGINS`** (comma-separated) in the app
automatically — so make sure `CIVI_BASE_URL` on the app deploy points deploy env. Each origin needs the scheme:
at the **production** CRM origin (`https://crm.fci.coop`), not
`client.crm.fci.coop`. ```
CIVI_FRAME_ALLOWED_ORIGINS=https://crm.fci.coop,https://client.crm.fci.coop
```
Include both prod and any staging Civi origins you want to keep
embedding. If unset, the build falls back to the origin of
`CIVI_BASE_URL` (single-Civi compatibility).
Confirm after deploy: Confirm after deploy:
@@ -211,8 +244,11 @@ Confirm after deploy:
curl -sI https://survey.fci.coop/staff/report | grep -i content-security-policy curl -sI https://survey.fci.coop/staff/report | grep -i content-security-policy
``` ```
Should include `frame-ancestors 'self' https://crm.fci.coop` (or Should include
whatever your production CRM origin is). `frame-ancestors 'self' https://crm.fci.coop https://client.crm.fci.coop`
(or whatever list you configured). If you see only one origin and the
other Civi is failing to embed, the env var is missing or stale —
trigger a new build, not just a restart.
--- ---
@@ -266,6 +302,11 @@ Run these against the production deploy:
5. Open the target Organization in CiviCRM → **Engagement Report** 5. Open the target Organization in CiviCRM → **Engagement Report**
tab. The staff report should render the submission you just made. tab. The staff report should render the submission you just made.
6. On the form, attach a file to any file field. It should upload
without error. A **502** here means the `webform-mw` extension on
this CRM is older than v0.3.0 (or the post-upgrade cache flush was
skipped) — see step 5.
--- ---
## Change log ## Change log
@@ -276,3 +317,25 @@ so the rationale survives.
- **2026-06-08** — Documented the field-242 "Unknown" default issue - **2026-06-08** — Documented the field-242 "Unknown" default issue
after a production submission was stamped `Stage = "Unknown"`. after a production submission was stamped `Stage = "Unknown"`.
Cleared via `CustomField.update`; see step 2. Cleared via `CustomField.update`; see step 2.
- **2026-06-18** — Extension bumped to **v0.3.1** (lightbox fix). The
staff-report iframe is expanded to full content height, so the
attachment lightbox (a modal inside the iframe) centered in the whole
document and sat off-screen unless the parent page was scrolled to the
middle. `Tab.tpl` now broadcasts the iframe's visible slice
(`webform-mw-viewport`) and the app pins/sizes the lightbox to it.
**Re-copy the extension to prod and `cv flush`** (template change) —
same procedure as step 5; no settings change.
- **2026-06-16** — Step 5 now states the file-upload feature requires
extension **v0.3.0+** (the `civicrm/webform-mw/upload` proxy route)
and documents the upgrade-vs-first-install path plus the mandatory
cache flush. Surfaced when production uploads returned **502**: prod
Civi still had v0.1.0, which has the Engagement Report tab but not
the upload route, so `/api/upload` couldn't reach it and failed
closed. Added a post-deploy upload check as step 6 of section 8.
- **2026-06-16** — Step 6 split off `CIVI_FRAME_ALLOWED_ORIGINS` as a
separate env from `CIVI_BASE_URL`. Surfaced after the production
cutover hit a `frame-ancestors` block: the app's CSP only listed the
staging Civi origin (derived from `CIVI_BASE_URL`), so prod
(`crm.fci.coop`) couldn't iframe `survey.fci.coop`. The new var
takes a comma-separated list so one app deploy can be embedded by
both dev and prod Civi.
+310
View File
@@ -0,0 +1,310 @@
/**
* GET /api/staff/file?id=<fileId>&org=<orgId>&key=<STAFF_REPORT_KEY>
*
* Server-side proxy that fetches a CiviCRM attachment and re-streams it
* with `Content-Disposition: inline`, so the staff-report lightbox can
* preview images and PDFs in-place. Civi's own `/civicrm/file` handler
* always sends `attachment`, which forces a download — that's correct for
* its UI but wrong for an embedded preview.
*
* Authorization layers:
* 1. STAFF_REPORT_KEY query param (same gate as /api/staff/report).
* 2. Per-file ownership probe: the requested fileId must appear as a
* value in one of the org's file-typed custom-field columns, OR in
* one of the org's activities' file-typed columns. This is the same
* ownership chain the staff report uses to surface the file in the
* first place. NOT enforced via civicrm_entity_file — that linkage
* is metadata-only here (upload anchors to submitter, not org).
*
* The upstream fetch uses the URL Civi returns from `Attachment.get`,
* which includes a freshly-minted `fcs` JWT. We don't carry any user
* session cookies — that JWT is the auth for `/civicrm/file`.
*
* STUB MODE: if Civi env vars are unset, 404. Stub-mode previews aren't
* meaningful (there are no real bytes to serve).
*/
import { NextRequest, NextResponse } from "next/server";
import { isStaffKeyValid } from "@/lib/staff-auth";
import { civi, civi3 } from "@/lib/civicrm";
import { resolveMime } from "@/lib/mime.mjs";
const MAX_BYTES = 10 * 1024 * 1024;
// Only mimes the lightbox actually renders inline are allowed through the
// `Content-Disposition: inline` path. Everything else gets coerced to
// application/octet-stream + attachment so it always downloads.
//
// Why this matters: our /api/upload route validates uploaded mimes
// against an allowlist, but the underlying civicrm_file row can be
// populated by other paths too — a Civi admin uploading directly through
// the CiviCRM UI, a future Civi import, etc. If any of those routes
// stored mime_type="text/html" or "image/svg+xml", an inline serve from
// this same-origin proxy would let arbitrary script run against
// /api/* and the staff key. Defence in depth: don't trust mime_type
// when the response carries `inline`.
//
// Explicitly NOT in this set: svg (script-bearing), html, xml, any text/*.
const SAFE_INLINE_MIMES = new Set<string>([
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"application/pdf",
]);
function isCiviStubMode(): boolean {
return !(
process.env.CIVI_BASE_URL &&
process.env.CIVI_API_KEY &&
process.env.CIVI_SITE_KEY
);
}
interface AttachmentRow {
id: string | number;
url?: string;
mime_type?: string;
name?: string;
}
async function fetchAttachment(fileId: number): Promise<AttachmentRow | null> {
const res = await civi3<AttachmentRow>("Attachment", "get", {
id: fileId,
return: "id,url,mime_type,name",
sequential: 1,
});
return res.values?.[0] ?? null;
}
// Same groups /api/staff/report scans. Kept in sync deliberately: this
// proxy must only authorise files reachable through the same set of
// fields the report itself surfaces.
const ACTIVITY_GROUP_NAMES = [
"Check_in_data__organizing_",
"Stage_1",
"Stage_2",
"Stage_3",
"Stage_4",
"Stage_5",
];
const ORG_GROUP_NAMES = ["Food_Co_op_Organizing"];
const ACTIVITY_TYPE_NAME = "Check-in (organizing)";
interface FileFieldRefs {
org: string[];
activity: string[];
}
/**
* Discover the APIv4 field references for File-typed custom fields the
* staff report cares about. Returns refs like "Stage_1.Vision_Upload"
* split by whether they live on the Organization Contact or on Activities.
*/
async function discoverFileFieldRefs(): Promise<FileFieldRefs> {
const res = await civi<{
name: string;
data_type: string;
"custom_group_id.name": string;
}>("CustomField", "get", {
select: ["name", "data_type", "custom_group_id.name"],
where: [
["custom_group_id.name", "IN", [...ACTIVITY_GROUP_NAMES, ...ORG_GROUP_NAMES]],
["data_type", "=", "File"],
["is_active", "=", true],
],
limit: 500,
});
const org: string[] = [];
const activity: string[] = [];
for (const row of res.values ?? []) {
const group = row["custom_group_id.name"];
const ref = `${group}.${row.name}`;
if (ORG_GROUP_NAMES.includes(group)) org.push(ref);
else if (ACTIVITY_GROUP_NAMES.includes(group)) activity.push(ref);
}
return { org, activity };
}
/**
* Confirm fileId is reachable from this org through the same column
* ownership the staff report uses.
*
* Implementation: fetch the org row and the org's activities, selecting
* the file-typed custom-field columns, then check in JS whether any
* column value equals fileId. We avoid APIv4 OR clauses against custom
* fields because that combination has been fragile in practice; the
* SELECT-and-compare path is the same shape /api/staff/report uses
* successfully.
*
* We do NOT use civicrm_entity_file for this check: our upload route
* anchors files to the submitter's contact id (not the org's), so that
* linkage doesn't reflect ownership. The custom-field column is the
* authoritative chain.
*/
async function fileBelongsToOrg(fileId: number, orgId: number): Promise<boolean> {
let refs: FileFieldRefs;
try {
refs = await discoverFileFieldRefs();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error("[staff/file] CustomField.get failed:", msg);
return false;
}
const matches = (rows: Array<Record<string, unknown>>, fieldRefs: string[]): boolean => {
for (const row of rows) {
for (const ref of fieldRefs) {
const v = row[ref];
if (v === undefined || v === null || v === "") continue;
if (Number(v) === fileId) return true;
}
}
return false;
};
const orgProbe =
refs.org.length > 0
? civi<Record<string, unknown>>("Contact", "get", {
where: [["id", "=", orgId]],
select: ["id", ...refs.org],
limit: 1,
}).catch((e: unknown) => {
console.error(
"[staff/file] Contact.get probe failed:",
e instanceof Error ? e.message : String(e),
);
return { values: [] as Array<Record<string, unknown>> };
})
: Promise.resolve({ values: [] as Array<Record<string, unknown>> });
const activityProbe =
refs.activity.length > 0
? civi<Record<string, unknown>>("Activity", "get", {
where: [
["target_contact_id", "=", orgId],
["activity_type_id:name", "=", ACTIVITY_TYPE_NAME],
],
select: ["id", ...refs.activity],
limit: 500,
}).catch((e: unknown) => {
console.error(
"[staff/file] Activity.get probe failed:",
e instanceof Error ? e.message : String(e),
);
return { values: [] as Array<Record<string, unknown>> };
})
: Promise.resolve({ values: [] as Array<Record<string, unknown>> });
const [orgRes, actRes] = await Promise.all([orgProbe, activityProbe]);
return (
matches(orgRes.values ?? [], refs.org) || matches(actRes.values ?? [], refs.activity)
);
}
export async function GET(req: NextRequest) {
const url = new URL(req.url);
const key = url.searchParams.get("key");
const idStr = url.searchParams.get("id");
const orgStr = url.searchParams.get("org");
const wantsDownload = url.searchParams.get("dl") === "1";
if (!isStaffKeyValid(key)) {
return new NextResponse("Not found", { status: 404 });
}
const fileId = Number(idStr);
const orgId = Number(orgStr);
if (!idStr || !Number.isFinite(fileId) || fileId <= 0) {
return NextResponse.json({ error: "Missing or invalid file id." }, { status: 400 });
}
if (!orgStr || !Number.isFinite(orgId) || orgId <= 0) {
return NextResponse.json({ error: "Missing or invalid org id." }, { status: 400 });
}
if (isCiviStubMode()) {
return new NextResponse("Not found", { status: 404 });
}
let row: AttachmentRow | null;
try {
row = await fetchAttachment(fileId);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error("[staff/file] Attachment.get failed:", msg);
return NextResponse.json({ error: "Could not look up the file." }, { status: 502 });
}
if (!row || !row.url) {
return new NextResponse("Not found", { status: 404 });
}
// Authorisation: prove this file actually belongs to `orgId` via the
// custom-field columns the staff report itself surfaces. Without this a
// staff key (which is org-agnostic) could be used to enumerate file ids
// outside any report context.
const owned = await fileBelongsToOrg(fileId, orgId);
if (!owned) {
return new NextResponse("Not found", { status: 404 });
}
// The signed URL Civi returns is sometimes a relative path (depends on
// Civi config). Normalise against CIVI_BASE_URL so fetch() has an
// absolute URL.
const base = (process.env.CIVI_BASE_URL ?? "").replace(/\/+$/, "");
const upstream = row.url.startsWith("http")
? row.url
: `${base}${row.url.startsWith("/") ? "" : "/"}${row.url}`;
let upstreamRes: Response;
try {
upstreamRes = await fetch(upstream, { cache: "no-store" });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error("[staff/file] upstream fetch failed:", msg);
return NextResponse.json({ error: "Upstream fetch failed." }, { status: 502 });
}
if (!upstreamRes.ok || !upstreamRes.body) {
return new NextResponse("Not found", { status: upstreamRes.status === 404 ? 404 : 502 });
}
const contentLengthRaw = upstreamRes.headers.get("content-length");
const contentLength = contentLengthRaw ? Number(contentLengthRaw) : NaN;
if (Number.isFinite(contentLength) && contentLength > MAX_BYTES) {
return NextResponse.json({ error: "File too large for inline preview." }, { status: 413 });
}
const resolved = resolveMime(
row.mime_type ?? upstreamRes.headers.get("content-type"),
row.name,
);
// Inline is only allowed for mimes the lightbox actually renders. Anything
// else (incl. an attacker-controlled mime_type from a non-allowlisted
// upload path) is downgraded to octet-stream + attachment so the browser
// downloads instead of executing.
const inlineSafe = !wantsDownload && SAFE_INLINE_MIMES.has(resolved);
const mime = inlineSafe ? resolved : "application/octet-stream";
const disposition = inlineSafe ? "inline" : "attachment";
const safeName = (row.name ?? `file-${fileId}`).replace(/[\r\n"\\]/g, "_");
const headers: Record<string, string> = {
"Content-Type": mime,
"Content-Disposition": `${disposition}; filename="${safeName}"`,
"Cache-Control": "private, no-store",
"X-Content-Type-Options": "nosniff",
};
// For attachment responses, layer on a strict sandbox CSP as defence in
// depth — the file is being downloaded so the CSP has no UX effect, but
// if a future change ever flips it to inline by mistake, the sandbox
// blocks script + plugins. For inline responses we rely on the SAFE
// MIME allowlist + nosniff + the app's global CSP, because a strict
// `sandbox` here breaks Chrome's PDF viewer (it can't load fonts or
// plugin-mode rendering under sandbox).
if (!inlineSafe) {
headers["Content-Security-Policy"] = "sandbox; default-src 'none'";
}
if (Number.isFinite(contentLength)) {
headers["Content-Length"] = String(contentLength);
}
return new NextResponse(upstreamRes.body, { status: 200, headers });
}
+43 -28
View File
@@ -13,7 +13,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { isStaffKeyValid } from "@/lib/staff-auth"; import { isStaffKeyValid } from "@/lib/staff-auth";
import { civi } from "@/lib/civicrm"; import { civi, civi3 } from "@/lib/civicrm";
import { mapCustomFieldRow } from "@/lib/staff-field-mapping.mjs"; import { mapCustomFieldRow } from "@/lib/staff-field-mapping.mjs";
import type { import type {
StaffReportPayload, StaffReportPayload,
@@ -193,7 +193,7 @@ function buildStubPayload(orgId: number): StaffReportPayload {
{ {
activityId: 9012, activityId: 9012,
date: daysAgo(3), date: daysAgo(3),
value: { id: 4242, file_name: "co-op-vision.pdf" }, value: { id: 4242, file_name: "co-op-vision.pdf", mime: "application/pdf" },
}, },
], ],
}, },
@@ -352,13 +352,11 @@ async function buildLivePayload(orgId: number): Promise<StaffReportPayload> {
const rows = activityRes.values ?? []; const rows = activityRes.values ?? [];
// Civi serves uploaded files at /civicrm/file?id=X&eid=Y&fcs=<JWT>; the fcs // Civi serves uploaded files at /civicrm/file?id=X&fcs=<JWT>; the fcs is
// is a JWT signed with Civi's site key. Without it, the file handler // an HS256 JWT signed with the site key. Without it, /civicrm/file
// crashes on a null JWT decode. We don't have the site key on this side, // crashes on a null JWT decode. APIv4 Attachment isn't exposed on this
// so ask Civi for signed URLs via APIv4 Attachment.get and pass them // install, but APIv3 Attachment.get is — and it returns `url` with the
// straight through to the client. If Attachment.get doesn't expose `url` // fcs already baked in. We pass the URL straight through to the client.
// 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 fileIds = new Set<number>();
const collectId = (v: unknown) => { const collectId = (v: unknown) => {
if (v === null || v === undefined || v === "") return; if (v === null || v === undefined || v === "") return;
@@ -375,27 +373,42 @@ async function buildLivePayload(orgId: number): Promise<StaffReportPayload> {
} }
const urlByFileId = new Map<number, string>(); const urlByFileId = new Map<number, string>();
const mimeByFileId = new Map<number, string>();
if (fileIds.size > 0) { if (fileIds.size > 0) {
try { // APIv3 Attachment.get doesn't accept an IN-clause cleanly on this Civi
const attachRes = await civi<{ id: number; url?: string }>("Attachment", "get", { // install — passing {IN: [...]} for `id` crashes Civi's error renderer
select: ["id", "url"], // on htmlentities(array). The same call with `id: <single>` works
where: [["id", "IN", Array.from(fileIds)]], // (verified in API Explorer), so we loop one call per file id. Reports
// Bypass permission checks: we already gated this whole route on // typically reference a handful of files, so the round-trip cost is
// STAFF_REPORT_KEY, and we want every file the org's activities // small. Each request is independent; we issue them in parallel.
// reference, regardless of which contact "owns" them. const lookups = await Promise.allSettled(
checkPermissions: false, Array.from(fileIds).map((fid) =>
limit: 0, civi3<{ id: string | number; url?: string; mime_type?: string }>(
}); "Attachment",
for (const a of attachRes.values ?? []) { "get",
if (typeof a.url === "string" && a.url.length > 0) { {
urlByFileId.set(a.id, a.url); id: fid,
} return: "id,url,mime_type",
} sequential: 1,
} catch (e) { },
console.warn( ).then((r) => ({ fid, row: r.values?.[0] })),
"[staff/report] Attachment.get failed; file links will lack fcs:", ),
e instanceof Error ? e.message : String(e),
); );
for (const result of lookups) {
if (result.status === "rejected") {
console.warn(
"[staff/report] Attachment.get (v3) failed for one file:",
result.reason instanceof Error ? result.reason.message : String(result.reason),
);
continue;
}
const { fid, row } = result.value;
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);
}
} }
} }
@@ -442,6 +455,7 @@ async function buildLivePayload(orgId: number): Promise<StaffReportPayload> {
id: raw, id: raw,
file_name: typeof fname === "string" ? fname : undefined, file_name: typeof fname === "string" ? fname : undefined,
url: Number.isFinite(fid) ? urlByFileId.get(fid) : undefined, url: Number.isFinite(fid) ? urlByFileId.get(fid) : undefined,
mime: Number.isFinite(fid) ? mimeByFileId.get(fid) : undefined,
}; };
} }
return { return {
@@ -475,6 +489,7 @@ async function buildLivePayload(orgId: number): Promise<StaffReportPayload> {
id: v, id: v,
file_name: typeof fname === "string" ? fname : undefined, file_name: typeof fname === "string" ? fname : undefined,
url: Number.isFinite(fid) ? urlByFileId.get(fid) : 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 }); entries.push({ activityId: row.id, date: row.activity_date_time, value });
+30 -30
View File
@@ -25,7 +25,7 @@
*/ */
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { civi, verifyChecksum } from "@/lib/civicrm"; import { civiMultipart, verifyChecksum } from "@/lib/civicrm";
import { allFields } from "@/config/form"; import { allFields } from "@/config/form";
import { rateLimit, clientIp } from "@/lib/rate-limit"; import { rateLimit, clientIp } from "@/lib/rate-limit";
@@ -205,44 +205,44 @@ export async function POST(req: Request) {
); );
} }
// APIv4 File.create with inline base64 content. Spike (June 2026) on // Path history exhausted before we got here:
// this Civi instance confirmed: // 1. APIv4 File.create + content:base64 → stores base64 text on disk.
// - APIv4 Attachment is NOT exposed // 2. APIv3 Attachment.create + content:base64 → same; v3 doesn't decode either.
// - APIv4 File + EntityFile ARE exposed // 3. APIv3 Attachment.create + multipart file → /civicrm/ajax/rest drops $_FILES.
// - File.create with file_name + mime_type + content (base64) returns
// a usable file id
// - Custom file fields store the file id directly in the custom
// column, so EntityFile linkage is not needed for our use case
// - Round-trip via Contact.update + Contact.get .file_name join works
// //
// We do not create EntityFile rows here. Civi's custom-field renderer // The one path Civi reliably honors is options.move-file: APIv3
// joins through the custom column to civicrm_file directly, and the // Attachment.create with a server-side filesystem path. PHP's $_FILES
// form-side prefill/read code in /api/data uses the same join. // preserves binary natively, so the WebForm-mw Civi extension exposes
// a tiny multipart endpoint that copies the upload's tmp_name into
// Attachment.create as options.move-file. We POST the file there.
// //
// The returned id is what the frontend stores in RHF state and // Attachment.create requires (entity_table, entity_id); our custom-field
// ultimately sends as the field value on /api/submit. /api/submit then // flow uses the returned file id directly in the custom column (no
// writes that id to the activity custom field (for stage-N file fields) // entity_file linkage needed), so we anchor to the form-filler's contact
// or to the org contact custom field (for Food_Co_op_Organizing.*). // id and accept the metadata-only civicrm_entity_file row.
// //
// Orphan files: if the user uploads and then abandons the form, the // Orphan files: civicrm_file rows linger if the user uploads then
// File row persists with no entity referencing it. Cleanup is handled // abandons. Cleanup is handled by a CiviCRM scheduled job (separately
// by a CiviCRM scheduled job (configured separately by the Civi admin) // configured by the Civi admin).
// that deletes File rows with no inbound references older than ~24h.
let fileId: number; let fileId: number;
try { try {
const res = await civi<{ id: number }>("File", "create", { const res = await civiMultipart<{ id?: number; name?: string; error?: string }>(
values: { "civicrm/webform-mw/upload",
file_name: safeName, {
entity_table: "civicrm_contact",
entity_id: String(Number(cid)),
name: safeName,
mime_type: clientMime, mime_type: clientMime,
content: Buffer.from(bytes).toString("base64"),
}, },
}); { bytes, filename: safeName, mime: clientMime },
const id = res.values?.[0]?.id; );
if (!id) throw new Error("File.create returned no id"); if (!res || !res.id || !Number.isFinite(res.id)) {
fileId = Number(id); throw new Error(res?.error ?? "Upload endpoint returned no id");
}
fileId = Number(res.id);
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
console.error("[upload] File.create failed:", msg); console.error("[upload] Civi extension upload failed:", msg);
return NextResponse.json( return NextResponse.json(
{ error: "Could not save the upload. Please try again." }, { error: "Could not save the upload. Please try again." },
{ status: 502 }, { status: 502 },
@@ -0,0 +1,92 @@
<?php
/**
* File redirect page.
*
* External callers (the WebForm-mw staff report iframe) link to this route
* with `id=<civicrm_file.id>` and rely on us to mint the `fcs` JWT that
* Civi's `/civicrm/file` handler requires. The JWT is signed with the
* site's crypto key, which we have here because we're running inside
* CiviCRM; the Next.js side doesn't.
*
* URL shape: /civicrm/webform-mw/file?id=<fileId>
*
* Authorization: minting an fcs JWT is effectively "issuing a bearer
* credential for this file" — once issued, /civicrm/file will serve the
* bytes against any session. To avoid becoming a credential-laundering
* IDOR, we look up the file's linked entity and run the entity-type's
* native permission check before signing. The base `access CiviCRM`
* permission only gates reaching this endpoint at all; per-record ACLs
* happen here. Unknown entity types are denied by default.
*/
class CRM_WebformMw_Page_File extends CRM_Core_Page {
public function run() {
$fileId = (int) CRM_Utils_Request::retrieve('id', 'Positive', $this, TRUE);
// Look up the file's linked entity. We need both entity_table and
// entity_id — entity_table drives which permission API to call.
$dao = CRM_Core_DAO::executeQuery(
"SELECT entity_table, entity_id FROM civicrm_entity_file WHERE file_id = %1 LIMIT 1",
[1 => [$fileId, 'Positive']]
);
if (!$dao->fetch()) {
CRM_Utils_System::permissionDenied();
return;
}
$entityTable = (string) $dao->entity_table;
$entityId = (int) $dao->entity_id;
if (!$this->canViewEntity($entityTable, $entityId)) {
CRM_Utils_System::permissionDenied();
return;
}
// Mint a short-lived fcs JWT. The token is minted at click time (not
// at report-render time), so 10 minutes covers normal redirect-and-fetch
// latency without making the URL a long-lived bearer credential.
$payload = [
'exp' => time() + 60 * 10,
'civi.file' => (string) $fileId,
];
$fcs = \Civi::service('crypto.jwt')->encode($payload);
$url = CRM_Utils_System::url(
'civicrm/file',
"reset=1&id={$fileId}&eid={$entityId}&fcs=" . urlencode($fcs),
FALSE,
NULL,
FALSE,
TRUE
);
CRM_Utils_System::redirect($url);
}
/**
* Check whether the current Civi user can view the entity a file is
* linked to. Restricted to the entity types the WebForm-mw staff report
* actually surfaces (activity custom-field files and contact custom-field
* files); everything else denies. New entity types should be added here
* deliberately so we don't accidentally widen the surface.
*/
private function canViewEntity(string $entityTable, int $entityId): bool {
if ($entityId <= 0) {
return FALSE;
}
switch ($entityTable) {
case 'civicrm_activity':
return CRM_Activity_BAO_Activity::checkPermission(
$entityId,
CRM_Core_Permission::VIEW
);
case 'civicrm_contact':
return CRM_Contact_BAO_Contact_Permission::allow(
$entityId,
CRM_Core_Permission::VIEW
);
default:
return FALSE;
}
}
}
@@ -0,0 +1,118 @@
<?php
/**
* File upload proxy.
*
* External callers (the WebForm-mw /api/upload route on Amplify) POST a
* multipart request here with a `file` part. We hand the uploaded temp
* path to APIv3 Attachment.create via `options.move-file`, which is the
* one upload pathway Civi reliably honors on this install:
*
* - APIv4 File.create + content:base64 -> stores base64 text on disk.
* - APIv3 Attachment.create + content:b64 -> same.
* - APIv3 Attachment.create + multipart -> /civicrm/ajax/rest doesn't
* expose $_FILES to the
* action, so "file" is
* silently ignored.
* - APIv3 Attachment.create + options.move-file -> WORKS. Civi reads
* the path, moves the file
* into civicrm.files/upload,
* writes correct bytes.
*
* Required POST fields:
* file the binary (multipart `file` part)
* entity_table e.g. "civicrm_contact" (per Attachment.create contract)
* entity_id the entity id to link to
* name (optional) file_name; defaults to the upload's name
* mime_type (optional) defaults to the upload's reported type
*
* Returns JSON: { id, name } on success, { error } with 4xx/5xx otherwise.
*
* Authorization: `access CiviCRM`. AuthX is expected to authenticate the
* Bearer + Site-Key headers WebForm-mw sends.
*/
class CRM_WebformMw_Page_Upload extends CRM_Core_Page {
public function run() {
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
$this->jsonError('POST required', 405);
return;
}
if (!CRM_Core_Permission::check('access CiviCRM')) {
$this->jsonError('Permission denied', 403);
return;
}
if (empty($_FILES['file']) || !is_array($_FILES['file'])) {
$this->jsonError('Missing file part', 400);
return;
}
$upload = $_FILES['file'];
if ((int) ($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
$this->jsonError('Upload failed (php error ' . (int) $upload['error'] . ')', 400);
return;
}
if (empty($upload['tmp_name']) || !is_uploaded_file($upload['tmp_name'])) {
$this->jsonError('Invalid upload tmp path', 400);
return;
}
$entityTable = (string) ($_POST['entity_table'] ?? '');
$entityId = (int) ($_POST['entity_id'] ?? 0);
// Whitelist entity tables to mirror the redirect route's defensive scope.
if (!in_array($entityTable, ['civicrm_contact', 'civicrm_activity'], TRUE)) {
$this->jsonError('Invalid entity_table', 400);
return;
}
if ($entityId <= 0) {
$this->jsonError('Invalid entity_id', 400);
return;
}
$name = (string) ($_POST['name'] ?? $upload['name'] ?? 'upload');
$mime = (string) ($_POST['mime_type'] ?? $upload['type'] ?? 'application/octet-stream');
try {
$result = civicrm_api3('Attachment', 'create', [
'entity_table' => $entityTable,
'entity_id' => $entityId,
'name' => $name,
'mime_type' => $mime,
'options' => [
'move-file' => $upload['tmp_name'],
],
]);
$fileId = NULL;
if (!empty($result['id'])) {
$fileId = (int) $result['id'];
}
elseif (!empty($result['values']) && is_array($result['values'])) {
$first = reset($result['values']);
if (!empty($first['id'])) {
$fileId = (int) $first['id'];
}
}
if (!$fileId) {
throw new Exception('Attachment.create returned no id');
}
$this->jsonOk(['id' => $fileId, 'name' => $name]);
}
catch (Throwable $e) {
$this->jsonError('Attachment.create failed: ' . $e->getMessage(), 500);
}
}
private function jsonOk(array $payload): void {
header('Content-Type: application/json', TRUE, 200);
echo json_encode($payload);
CRM_Utils_System::civiExit();
}
private function jsonError(string $message, int $status): void {
header('Content-Type: application/json', TRUE, $status);
echo json_encode(['error' => $message]);
CRM_Utils_System::civiExit();
}
}
+1 -1
View File
@@ -17,7 +17,7 @@
<url desc="Main Extension Page">https://github.com/joelbrock/WebForm-mw</url> <url desc="Main Extension Page">https://github.com/joelbrock/WebForm-mw</url>
</urls> </urls>
<releaseDate>2026-06-05</releaseDate> <releaseDate>2026-06-05</releaseDate>
<version>0.1.0</version> <version>0.3.1</version>
<develStage>beta</develStage> <develStage>beta</develStage>
<compatibility> <compatibility>
<ver>5.50</ver> <ver>5.50</ver>
@@ -22,7 +22,41 @@
// Add a little headroom so the report's own bottom padding isn't clipped. // Add a little headroom so the report's own bottom padding isn't clipped.
var h = Math.max(600, Math.floor(d.height) + 24); var h = Math.max(600, Math.floor(d.height) + 24);
frame.style.height = h + 'px'; frame.style.height = h + 'px';
// Geometry changed tell the child where its visible slice now is.
scheduleViewport();
}, false); }, false);
// The iframe is expanded to full content height, so the report has no
// internal scroll context. A modal opened inside it (the attachment
// lightbox) would center in the full iframe and sit off-screen. Broadcast
// the iframe's currently-visible region (in the child's own content
// coordinates) so the lightbox can pin and size itself to it. The child
// listens for 'webform-mw-viewport'.
var ticking = false;
var raf = window.requestAnimationFrame || function (cb) { return setTimeout(cb, 16); };
function postViewport() {
ticking = false;
if (!frame.contentWindow) return;
var r = frame.getBoundingClientRect();
var winH = window.innerHeight || document.documentElement.clientHeight;
var visibleTop = Math.max(0, r.top);
var visibleBottom = Math.min(winH, r.bottom);
frame.contentWindow.postMessage({
type: 'webform-mw-viewport',
// px from the iframe's content top down to the first visible row.
top: Math.max(0, -r.top),
height: Math.max(0, visibleBottom - visibleTop)
}, APP_ORIGIN || '*');
}
function scheduleViewport() {
if (ticking) return;
ticking = true;
raf(postViewport);
}
window.addEventListener('scroll', scheduleViewport, { passive: true });
window.addEventListener('resize', scheduleViewport);
window.addEventListener('load', scheduleViewport);
scheduleViewport();
})(); })();
</script> </script>
{else} {else}
@@ -6,4 +6,16 @@
<page_callback>CRM_WebformMw_Page_Tab</page_callback> <page_callback>CRM_WebformMw_Page_Tab</page_callback>
<access_arguments>access CiviCRM</access_arguments> <access_arguments>access CiviCRM</access_arguments>
</item> </item>
<item>
<path>civicrm/webform-mw/file</path>
<title>WebForm-mw file redirect</title>
<page_callback>CRM_WebformMw_Page_File</page_callback>
<access_arguments>access CiviCRM</access_arguments>
</item>
<item>
<path>civicrm/webform-mw/upload</path>
<title>WebForm-mw file upload proxy</title>
<page_callback>CRM_WebformMw_Page_Upload</page_callback>
<access_arguments>access CiviCRM</access_arguments>
</item>
</menu> </menu>
+3 -4
View File
@@ -322,9 +322,8 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
return; return;
} }
const confirmed = window.confirm( const confirmed = window.confirm(
"Submit your check-in?\n\n" + "Submit your survey?\n\n" +
"Please confirm your responses are complete and ready. " + "Please confirm your responses are complete and ready. ",
"After submitting, you can come back later and submit another check-in with updates.",
); );
if (!confirmed) return; if (!confirmed) return;
setSubmitState({ kind: "submitting" }); setSubmitState({ kind: "submitting" });
@@ -759,7 +758,7 @@ function SuccessDestination({
onClick={onAnother} onClick={onAnother}
className="inline-flex items-center justify-center rounded-md border border-rule bg-paper px-5 py-2 text-sm font-medium text-ink-soft transition hover:bg-paper-2/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-500/40" className="inline-flex items-center justify-center rounded-md border border-rule bg-paper px-5 py-2 text-sm font-medium text-ink-soft transition hover:bg-paper-2/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-500/40"
> >
Submit another survey Submit an update
</button> </button>
<p className="text-xs text-ink-mute">It&apos;s safe to close this window.</p> <p className="text-xs text-ink-mute">It&apos;s safe to close this window.</p>
</div> </div>
+6 -10
View File
@@ -10,6 +10,7 @@ import type {
StageSectionConfig, StageSectionConfig,
} from "@/types/form"; } from "@/types/form";
import { STAGE_OPTION_GROUP_ID } from "@/config/form"; import { STAGE_OPTION_GROUP_ID } from "@/config/form";
import { STAGE_RANK } from "@/lib/stageRank";
import { StageIcon } from "./StageIcon"; import { StageIcon } from "./StageIcon";
import { import {
FieldHistoryGroup, FieldHistoryGroup,
@@ -39,15 +40,6 @@ type LoadState =
type PathwayState = "past" | "current" | "future"; type PathwayState = "past" | "current" | "future";
const STAGE_RANK: Record<string, number> = {
Inquiry: 0,
Organizing: 1,
Feasibility: 2,
"Business feasibility": 3,
"Store Implementation": 4,
"Stabilize newly opened co-op": 5,
};
export function ReportView({ config, cid, cs }: ReportViewProps) { export function ReportView({ config, cid, cs }: ReportViewProps) {
const [load, setLoad] = useState<LoadState>({ kind: "loading" }); const [load, setLoad] = useState<LoadState>({ kind: "loading" });
@@ -126,7 +118,11 @@ export function ReportView({ config, cid, cs }: ReportViewProps) {
dateRange={dateRange} dateRange={dateRange}
/> />
<DateTimeline sections={config.sections} fieldHistory={data.fieldHistory} /> <DateTimeline
sections={config.sections}
fieldHistory={data.fieldHistory}
activities={data.activities}
/>
{sectionsToRender.length === 0 ? ( {sectionsToRender.length === 0 ? (
<EmptyState /> <EmptyState />
+38 -22
View File
@@ -19,6 +19,7 @@ import {
computeDateRange, computeDateRange,
} from "./report/FieldHistory"; } from "./report/FieldHistory";
import { LoadingState, EmptyState, ErrorState } from "./report/ReportStates"; import { LoadingState, EmptyState, ErrorState } from "./report/ReportStates";
import { FileLink } from "./report/FileLink";
interface StaffReportViewProps { interface StaffReportViewProps {
org: number; org: number;
@@ -207,6 +208,8 @@ export function StaffReportView({
section={section} section={section}
options={data.options} options={data.options}
civiBaseUrl={civiBaseUrl} civiBaseUrl={civiBaseUrl}
org={org}
authKey={authKey}
/> />
))} ))}
@@ -271,10 +274,14 @@ function StaffSection({
section, section,
options, options,
civiBaseUrl, civiBaseUrl,
org,
authKey,
}: { }: {
section: StaffReportSection; section: StaffReportSection;
options: Record<number, SelectOption[]>; options: Record<number, SelectOption[]>;
civiBaseUrl: string; civiBaseUrl: string;
org: number;
authKey: string;
}) { }) {
const filled = section.fields.filter((f) => f.history.length > 0); const filled = section.fields.filter((f) => f.history.length > 0);
const empty = section.fields.filter((f) => f.history.length === 0); const empty = section.fields.filter((f) => f.history.length === 0);
@@ -328,6 +335,8 @@ function StaffSection({
field={f} field={f}
options={options} options={options}
civiBaseUrl={civiBaseUrl} civiBaseUrl={civiBaseUrl}
org={org}
authKey={authKey}
/> />
))} ))}
</ul> </ul>
@@ -374,10 +383,14 @@ function CompactFieldRow({
field, field,
options, options,
civiBaseUrl, civiBaseUrl,
org,
authKey,
}: { }: {
field: StaffReportField; field: StaffReportField;
options: Record<number, SelectOption[]>; options: Record<number, SelectOption[]>;
civiBaseUrl: string; civiBaseUrl: string;
org: number;
authKey: string;
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const latest = field.history[0]; const latest = field.history[0];
@@ -394,6 +407,8 @@ function CompactFieldRow({
entry={latest} entry={latest}
options={options} options={options}
civiBaseUrl={civiBaseUrl} civiBaseUrl={civiBaseUrl}
org={org}
authKey={authKey}
/> />
</span> </span>
{latest.date ? ( {latest.date ? (
@@ -431,6 +446,8 @@ function CompactFieldRow({
entry={e} entry={e}
options={options} options={options}
civiBaseUrl={civiBaseUrl} civiBaseUrl={civiBaseUrl}
org={org}
authKey={authKey}
/> />
</span> </span>
</li> </li>
@@ -446,40 +463,33 @@ function FieldValue({
entry, entry,
options, options,
civiBaseUrl, civiBaseUrl,
org,
authKey,
}: { }: {
field: StaffReportField; field: StaffReportField;
entry: FieldHistoryEntry; entry: FieldHistoryEntry;
options: Record<number, SelectOption[]>; options: Record<number, SelectOption[]>;
civiBaseUrl: string; civiBaseUrl: string;
org: number;
authKey: string;
}) { }) {
if (field.descriptor.render === "file") { if (field.descriptor.render === "file") {
const v = entry.value as const v = entry.value as
| { id?: number | string; file_name?: string; url?: string } | { id?: number | string; file_name?: string; url?: string; mime?: string }
| null; | null;
if (!v || v.id === undefined) return <span></span>; if (!v || v.id === undefined) return <span></span>;
const id = String(v.id); const id = String(v.id);
const name = v.file_name ?? `file-${id}`; const name = v.file_name ?? `file-${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 ( return (
<a <FileLink
href={href} fileId={id}
target="_blank" fileName={name}
rel="noopener noreferrer" civiSignedUrl={v.url}
className="text-ink underline decoration-rule underline-offset-4 hover:decoration-ink" mime={v.mime}
> org={org}
{name} authKey={authKey}
</a> civiBaseUrl={civiBaseUrl}
/>
); );
} }
return ( return (
@@ -756,7 +766,13 @@ function StaffDateTimeline({ data }: { data: StaffReportPayload }) {
{ rank: 0, id: "all", label: "All dated events", fields: fieldConfigs }, { rank: 0, id: "all", label: "All dated events", fields: fieldConfigs },
]; ];
return <DateTimeline sections={sections} fieldHistory={fieldHistory} />; return (
<DateTimeline
sections={sections}
fieldHistory={fieldHistory}
activities={data.activities}
/>
);
} }
// formatLongDate retained for symmetry with other report views; unused here // formatLongDate retained for symmetry with other report views; unused here
+182
View File
@@ -0,0 +1,182 @@
"use client";
import { useEffect, useRef, useState } from "react";
/**
* Modal preview for image and PDF attachments.
*
* Uses the native <dialog> element for focus trap, Esc-to-close, and
* inert-background semantics — saves ~100 lines of bespoke a11y wiring
* that we'd otherwise have to maintain.
*
* For an `image/*` mime, renders an <img>. For `application/pdf`, an
* <iframe>. Anything else should not reach this component — FileLink is
* responsible for branching office/other types to plain download links.
*/
export function AttachmentLightbox({
open,
onClose,
previewSrc,
downloadHref,
filename,
mime,
}: {
open: boolean;
onClose: () => void;
/** URL the <img>/<iframe> loads from. Should serve with Content-Disposition: inline. */
previewSrc: string;
/** Anchor target for the Download button. Serves with Content-Disposition: attachment. */
downloadHref: string;
filename: string;
/** Resolved mime; used to pick between <img> and <iframe>. */
mime: string;
}) {
const ref = useRef<HTMLDialogElement | null>(null);
// When embedded in the CiviCRM tab, the iframe is auto-expanded to its full
// content height (no nested scrollbar), so a modal <dialog> — which centers
// in *its* viewport — lands in the vertical middle of the whole document and
// is off-screen unless the parent page happens to be scrolled there. The
// iframe can't read the parent's scroll position (cross-origin), so the
// extension's tab template broadcasts the iframe's currently-visible slice
// as `webform-mw-viewport` messages. We pin the dialog to that slice and
// size it to fill the visible height. Standalone (non-embedded) keeps the
// native viewport centering.
const [framed, setFramed] = useState(false);
const vpRef = useRef<{ top: number; height: number } | null>(null);
const [vp, setVp] = useState<{ top: number; height: number } | null>(null);
useEffect(() => {
setFramed(window.parent !== window);
}, []);
useEffect(() => {
const onMsg = (e: MessageEvent) => {
if (e.source !== window.parent) return;
const d = e.data as { type?: string; top?: number; height?: number } | null;
if (!d || d.type !== "webform-mw-viewport") return;
const next = { top: Number(d.top) || 0, height: Number(d.height) || 0 };
vpRef.current = next;
// Only reflect into render state while open — many FileLinks mount a
// (closed) lightbox each, and we don't want every one re-rendering on
// each scroll frame.
if (open) setVp(next);
};
window.addEventListener("message", onMsg);
return () => window.removeEventListener("message", onMsg);
}, [open]);
// Seed from the latest known viewport the moment we open.
useEffect(() => {
if (open) setVp(vpRef.current);
}, [open]);
// Drive the native <dialog>'s open state from our prop.
useEffect(() => {
const dlg = ref.current;
if (!dlg) return;
if (open && !dlg.open) {
dlg.showModal();
} else if (!open && dlg.open) {
dlg.close();
}
}, [open]);
// Native <dialog> fires a 'close' event on Esc and on form-method=dialog
// submit. Mirror that back into React state so the parent stays in sync.
useEffect(() => {
const dlg = ref.current;
if (!dlg) return;
const handle = () => onClose();
dlg.addEventListener("close", handle);
return () => dlg.removeEventListener("close", handle);
}, [onClose]);
// Close when the user clicks the backdrop (everything outside the inner
// panel). The dialog itself receives the click event when the backdrop
// is hit because the panel uses pointer-events the same way.
const onDialogClick = (e: React.MouseEvent<HTMLDialogElement>) => {
if (e.target === ref.current) onClose();
};
const isImage = mime.startsWith("image/");
const isPdf = mime === "application/pdf";
// Vertical placement + height.
// - framed + known viewport: pin to the visible slice and fill it.
// - framed but no viewport yet (e.g. an older extension that doesn't
// broadcast): fall back to a safe fixed box so we never balloon to the
// full multi-thousand-pixel iframe height.
// - standalone: native viewport centering, tall enough for documents.
const margin = 16;
const dialogStyle: React.CSSProperties | undefined =
framed && vp
? { top: Math.max(8, vp.top + margin), bottom: "auto", marginTop: 0, marginBottom: 0 }
: undefined;
const panelHeight = framed
? vp
? Math.max(360, vp.height - margin * 2)
: 640
: undefined;
return (
<dialog
ref={ref}
onClick={onDialogClick}
aria-label={`Preview: ${filename}`}
style={dialogStyle}
className="m-auto w-[min(92vw,900px)] rounded-md bg-transparent p-0 shadow-2xl backdrop:bg-ink/70"
>
<div
style={panelHeight !== undefined ? { height: panelHeight } : undefined}
className={`flex flex-col overflow-hidden rounded-md ${framed ? "" : "h-[85vh] max-h-[860px]"}`}
>
<header className="flex items-center justify-between gap-4 bg-paper px-4 py-2.5 sm:px-5">
<p className="min-w-0 truncate font-display text-sm text-ink">
{filename}
</p>
<div className="flex flex-shrink-0 items-center gap-3">
<a
href={downloadHref}
className="text-xs font-medium text-leaf-700 underline decoration-rule underline-offset-4 hover:decoration-ink hover:text-leaf-800"
target="_blank"
rel="noopener noreferrer"
>
Download
</a>
<button
type="button"
onClick={onClose}
className="rounded px-2 py-1 text-xs font-medium text-ink-soft hover:bg-rule-soft/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-700"
aria-label="Close preview"
>
Close
</button>
</div>
</header>
<div className="flex flex-1 items-center justify-center overflow-hidden bg-ink/90 p-3">
{isImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={previewSrc}
alt={filename}
className="max-h-full max-w-full object-contain"
/>
) : isPdf ? (
<iframe
src={previewSrc}
title={filename}
className="h-full w-full border-0 bg-paper"
/>
) : (
// Defensive: FileLink shouldn't open the lightbox for non-previewable
// types, but if it does, surface a clear message instead of an empty box.
<p className="px-6 text-paper">
Preview not available. Use Download above to open the file.
</p>
)}
</div>
</div>
</dialog>
);
}
+216 -55
View File
@@ -1,66 +1,106 @@
"use client"; "use client";
import type { FieldHistoryEntry, StageSectionConfig } from "@/types/form"; import type { ActivitySummary, FieldHistoryEntry, StageSectionConfig } from "@/types/form";
import { formatShortDate } from "./FieldHistory"; import { formatShortDate } from "./FieldHistory";
import { buildStageRankAtDate, computeStageRanges, type StageRange } from "@/lib/stageRank";
/** /**
* Date-grouped timeline strip at the top of the report. Walks every * Date-grouped timeline strip at the top of the report. Renders five
* date-type field across all stage sections (05), pulls each field's * horizontal swim lanes (Stage 5 on top down to Stage 1 at the bottom)
* most-recent entered date, and plots events into six horizontal swim * and overlays two kinds of events:
* lanes — one per stage. Stage 5's `Date_Opened` gets visual emphasis *
* as the journey's anchor at the right end of the time axis. A faint * 1. Stage-period ranges, one per stage-transition activity, drawn
* "Today" tick anchors the reader if today falls within the range, and * as a soft bar from the activity date to the date of the next
* an adaptive month/year axis runs beneath the lanes. Each dot exposes * activity that moved the co-op to a higher stage. The latest
* a tooltip on hover or keyboard focus. * open stage extends to today.
* 2. Milestone dots from date-type custom fields. The dot is placed
* on the lane corresponding to the stage the co-op was in on the
* field's stored date (resolved from the activity stream). When
* that's unknowable (date precedes any transition), it falls back
* to the field's own section rank. Stage 5's `Date_Opened` is
* pinned to Stage 5 as the journey anchor.
*
* A faint "Today" tick anchors the reader if today falls within the
* range, and an adaptive month/year axis runs beneath the lanes. Each
* dot exposes a tooltip on hover or keyboard focus.
*/ */
export function DateTimeline({ export function DateTimeline({
sections, sections,
fieldHistory, fieldHistory,
activities,
}: { }: {
sections: StageSectionConfig[]; sections: StageSectionConfig[];
fieldHistory: Record<string, FieldHistoryEntry[]>; fieldHistory: Record<string, FieldHistoryEntry[]>;
activities: ActivitySummary[];
}) { }) {
type Event = { const stageRankAtDate = buildStageRankAtDate(activities);
const ranges = computeStageRanges(activities);
type MilestoneEvent = {
rank: number; rank: number;
fieldLabel: string; fieldLabel: string;
date: string; // YYYY-MM-DD or full ISO date: string;
isOpened: boolean; isOpened: boolean;
}; };
const events: Event[] = []; const milestones: MilestoneEvent[] = [];
for (const section of sections) { for (const section of sections) {
if (section.rank < 0 || section.rank > 5) continue;
for (const f of section.fields) { for (const f of section.fields) {
if (f.type !== "date") continue; if (f.type !== "date") continue;
const history = fieldHistory[f.name]; const history = fieldHistory[f.name];
if (!history || history.length === 0) continue; if (!history || history.length === 0) continue;
const v = history[0].value; const v = history[0].value;
if (typeof v !== "string" || v.length === 0) continue; if (typeof v !== "string" || v.length === 0) continue;
events.push({ const isOpened = f.name === "Date_Opened";
rank: section.rank, const resolved = stageRankAtDate(v);
fieldLabel: f.label, let rank = isOpened ? 5 : (resolved ?? section.rank);
date: v, if (rank < 1 || rank > 5) rank = section.rank;
isOpened: f.name === "Date_Opened", if (rank < 1 || rank > 5) continue;
}); milestones.push({ rank, fieldLabel: f.label, date: v, isOpened });
} }
} }
if (events.length === 0) return null;
const times = events.map((e) => new Date(e.date).getTime()).filter(Number.isFinite); // Collect every time value that should influence the time axis: range
if (times.length === 0) return null; // starts, range ends (excluding open-ended), milestone dates, and today
// if any range is open (so the open range visibly extends to "now").
const openedEvent = events.find((e) => e.isOpened); const timeBag: number[] = [];
const openedTime = openedEvent ? new Date(openedEvent.date).getTime() : undefined; let hasOpenRange = false;
const minT = Math.min(...times); for (const r of ranges) {
// If Date_Opened is set, anchor the right edge there; if any other event const ts = new Date(r.startDate).getTime();
// is later, extend so nothing falls off the chart. if (Number.isFinite(ts)) timeBag.push(ts);
const maxT = Math.max(...times, openedTime ?? -Infinity); if (r.endDate === null) {
const tRange = maxT - minT || 1; hasOpenRange = true;
} else {
const te = new Date(r.endDate).getTime();
if (Number.isFinite(te)) timeBag.push(te);
}
}
for (const m of milestones) {
const t = new Date(m.date).getTime();
if (Number.isFinite(t)) timeBag.push(t);
}
const todayT = Date.now(); const todayT = Date.now();
if (hasOpenRange) timeBag.push(todayT);
if (timeBag.length === 0) return null;
let minT = Math.min(...timeBag);
let maxT = Math.max(...timeBag);
if (minT === maxT) {
const pad = 30 * 24 * 3600 * 1000;
minT -= pad;
maxT += pad;
}
const tRange = maxT - minT || 1;
const todayPct = const todayPct =
todayT >= minT && todayT <= maxT ? ((todayT - minT) / tRange) * 100 : null; todayT >= minT && todayT <= maxT ? ((todayT - minT) / tRange) * 100 : null;
const ticks = generateAxisTicks(minT, maxT); const ticks = generateAxisTicks(minT, maxT);
const rangesByRank = new Map<number, StageRange[]>();
for (const r of ranges) {
const list = rangesByRank.get(r.rank) ?? [];
list.push(r);
rangesByRank.set(r.rank, list);
}
return ( return (
<section <section
aria-labelledby="timeline-heading" aria-labelledby="timeline-heading"
@@ -80,9 +120,10 @@ export function DateTimeline({
</p> </p>
</div> </div>
<ol className="mt-4 space-y-2"> <ol className="mt-4 space-y-2">
{[0, 1, 2, 3, 4, 5].map((rank) => { {[5, 4, 3, 2, 1].map((rank) => {
const rowEvents = events.filter((e) => e.rank === rank); const rowRanges = rangesByRank.get(rank) ?? [];
const isEmpty = rowEvents.length === 0; const rowMilestones = milestones.filter((e) => e.rank === rank);
const isEmpty = rowRanges.length === 0 && rowMilestones.length === 0;
return ( return (
<li <li
key={rank} key={rank}
@@ -99,6 +140,30 @@ export function DateTimeline({
(isEmpty ? "bg-rule-soft/60" : "bg-rule-soft") (isEmpty ? "bg-rule-soft/60" : "bg-rule-soft")
} }
/> />
{/* Stage-period ranges: soft bar from activity start to next
higher-stage activity (or to today if still open). */}
{rowRanges.map((r, i) => {
const ts = new Date(r.startDate).getTime();
if (!Number.isFinite(ts)) return null;
const endT =
r.endDate === null
? Math.max(todayT, ts)
: new Date(r.endDate).getTime();
if (!Number.isFinite(endT)) return null;
const startPct = ((ts - minT) / tRange) * 100;
const endPct = ((endT - minT) / tRange) * 100;
const widthPct = Math.max(endPct - startPct, 0.5);
return (
<StageRangeBar
key={`r-${i}`}
rank={rank}
range={r}
startPct={startPct}
widthPct={widthPct}
isOpen={r.endDate === null}
/>
);
})}
{todayPct !== null && ( {todayPct !== null && (
<span <span
aria-hidden aria-hidden
@@ -106,19 +171,23 @@ export function DateTimeline({
className="absolute top-0 bottom-0 w-px -translate-x-1/2 border-l border-dashed border-clay-300/70" className="absolute top-0 bottom-0 w-px -translate-x-1/2 border-l border-dashed border-clay-300/70"
/> />
)} )}
{rowEvents.map((e, i) => { {/* Activity start dots — placed at each range start for
const t = new Date(e.date).getTime(); keyboard focus + tooltip with the activity subject. */}
{rowRanges.map((r, i) => {
const t = new Date(r.startDate).getTime();
if (!Number.isFinite(t)) return null; if (!Number.isFinite(t)) return null;
const x = ((t - minT) / tRange) * 100; const x = ((t - minT) / tRange) * 100;
return ( return (
<TimelineDot <ActivityDot key={`a-${i}`} rank={rank} range={r} x={x} />
key={i}
rank={rank}
event={e}
x={x}
/>
); );
})} })}
{/* Milestone date-field dots layered on top. */}
{rowMilestones.map((e, i) => {
const t = new Date(e.date).getTime();
if (!Number.isFinite(t)) return null;
const x = ((t - minT) / tRange) * 100;
return <MilestoneDot key={`m-${i}`} rank={rank} event={e} x={x} />;
})}
</div> </div>
</li> </li>
); );
@@ -138,7 +207,6 @@ export function DateTimeline({
/> />
{ticks.map((tk, i) => { {ticks.map((tk, i) => {
const x = ((tk.t - minT) / tRange) * 100; const x = ((tk.t - minT) / tRange) * 100;
// Edge labels shift so they don't overflow the row.
const alignClass = const alignClass =
x < 6 x < 6
? "left-0 origin-top-left" ? "left-0 origin-top-left"
@@ -185,8 +253,14 @@ export function DateTimeline({
)} )}
{/* Accessible event list — invisible to sighted users but readable by SR */} {/* Accessible event list — invisible to sighted users but readable by SR */}
<ul className="sr-only"> <ul className="sr-only">
{events.map((e, i) => ( {ranges.map((r, i) => (
<li key={i}> <li key={`r-${i}`}>
Stage {r.rank}: {formatShortDate(r.startDate)}
{r.endDate ? ` to ${formatShortDate(r.endDate)}` : " (current)"}
</li>
))}
{milestones.map((e, i) => (
<li key={`m-${i}`}>
Stage {e.rank}: {e.fieldLabel} {formatShortDate(e.date)} Stage {e.rank}: {e.fieldLabel} {formatShortDate(e.date)}
{e.isOpened ? " (opened)" : ""} {e.isOpened ? " (opened)" : ""}
</li> </li>
@@ -198,8 +272,6 @@ export function DateTimeline({
function stageDotBg(rank: number): string { function stageDotBg(rank: number): string {
switch (rank) { switch (rank) {
case 0:
return "bg-leaf-200";
case 1: case 1:
return "bg-leaf-300"; return "bg-leaf-300";
case 2: case 2:
@@ -215,14 +287,108 @@ function stageDotBg(rank: number): string {
} }
} }
function stageRangeBg(rank: number): string {
switch (rank) {
case 1:
return "bg-leaf-300/30";
case 2:
return "bg-leaf-500/25";
case 3:
return "bg-leaf-600/25";
case 4:
return "bg-leaf-700/25";
case 5:
return "bg-clay-700/25";
default:
return "bg-leaf-500/25";
}
}
/** Soft horizontal bar marking a stage period on a lane. */
function StageRangeBar({
rank,
range,
startPct,
widthPct,
isOpen,
}: {
rank: number;
range: StageRange;
startPct: number;
widthPct: number;
isOpen: boolean;
}) {
return (
<span
aria-hidden
title={
`Stage ${rank}: ${formatShortDate(range.startDate)}` +
(range.endDate ? `${formatShortDate(range.endDate)}` : " → current")
}
style={{ left: `${startPct}%`, width: `${widthPct}%` }}
className={
"absolute top-1/2 -translate-y-1/2 h-2 rounded-full " +
stageRangeBg(rank) +
(isOpen ? " ring-1 ring-inset ring-clay-300/40" : "")
}
/>
);
}
/** Dot at the start of a stage period; carries the activity tooltip. */
function ActivityDot({
rank,
range,
x,
}: {
rank: number;
range: StageRange;
x: number;
}) {
const tipAlign =
x < 18 ? "left-0" : x > 82 ? "right-0" : "left-1/2 -translate-x-1/2";
return (
<span
role="img"
tabIndex={0}
aria-label={`Stage ${rank} check-in, ${formatShortDate(range.startDate)}`}
style={{ left: `${x}%` }}
className={
"group/dot absolute top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-500/40 ring-1 ring-paper h-3 w-3 " +
stageDotBg(rank)
}
>
<span
role="tooltip"
className={
"pointer-events-none absolute bottom-full z-30 mb-2 hidden whitespace-nowrap rounded-md bg-ink/95 px-2.5 py-1.5 text-[11px] leading-snug text-paper shadow-lg group-hover/dot:block group-focus-within/dot:block " +
tipAlign
}
>
<span className="block font-medium">
{range.subject ?? `Stage ${rank} check-in`}
</span>
<span className="mt-0.5 block tabular-nums text-paper-2/80">
{formatShortDate(range.startDate)}
{range.endDate ? (
<> {formatShortDate(range.endDate)}</>
) : (
<span className="ml-1.5 text-paper-2/60"> current</span>
)}
</span>
</span>
</span>
);
}
/** /**
* A single dot on the timeline plus its hover/focus tooltip. Keyboard * A single milestone date-field dot plus its hover/focus tooltip. Keyboard
* users can Tab to each dot and the tooltip will appear via * users can Tab to each dot and the tooltip will appear via
* `group-focus-within`. The tooltip is anchored above the dot; for dots * `group-focus-within`. The tooltip is anchored above the dot; for dots
* near the left or right edge of the row, anchor flips so the card * near the left or right edge of the row, anchor flips so the card
* doesn't overflow. * doesn't overflow.
*/ */
function TimelineDot({ function MilestoneDot({
rank, rank,
event, event,
x, x,
@@ -236,11 +402,7 @@ function TimelineDot({
const size = isOpened ? "h-3.5 w-3.5" : "h-2.5 w-2.5"; const size = isOpened ? "h-3.5 w-3.5" : "h-2.5 w-2.5";
const ring = isOpened ? "ring-2" : "ring-1"; const ring = isOpened ? "ring-2" : "ring-1";
const tipAlign = const tipAlign =
x < 18 x < 18 ? "left-0" : x > 82 ? "right-0" : "left-1/2 -translate-x-1/2";
? "left-0"
: x > 82
? "right-0"
: "left-1/2 -translate-x-1/2";
return ( return (
<span <span
role="img" role="img"
@@ -303,7 +465,6 @@ export function generateAxisTicks(minT: number, maxT: number): Array<{ t: number
plan.unit === "year" plan.unit === "year"
? 0 ? 0
: Math.floor(start.getMonth() / plan.step) * plan.step; : Math.floor(start.getMonth() / plan.step) * plan.step;
// Don't skip a tick that sits right at minT — start from minT-aligned step.
while (true) { while (true) {
const t = new Date(y, m, 1).getTime(); const t = new Date(y, m, 1).getTime();
if (t > maxT) break; if (t > maxT) break;
+133
View File
@@ -0,0 +1,133 @@
"use client";
import { useState } from "react";
import { AttachmentLightbox } from "./AttachmentLightbox";
import { categoryFromMime, resolveMime } from "@/lib/mime.mjs";
/**
* Render an attachment row in the staff report with the right affordance
* for its type:
*
* - image / pdf -> button opens the inline lightbox
* - office -> download link + "View in Google Docs" secondary link
* - other -> plain download link
*
* The proxy URL is `/api/staff/file?id=&org=&key=` (re-streams with
* Content-Disposition: inline for previews, or `&dl=1` for downloads). The
* Civi-signed URL (carrying its short-lived fcs JWT) is passed straight to
* Google's Docs Viewer for office files; we deliberately don't proxy that
* one because Google's servers must fetch it without our staff key.
*/
export function FileLink({
fileId,
fileName,
civiSignedUrl,
mime: explicitMime,
org,
authKey,
civiBaseUrl,
}: {
fileId: number | string;
fileName: string;
/** From Attachment.get (includes fcs). Used for office Google Viewer + download fallback. */
civiSignedUrl?: string;
mime?: string;
org: number;
authKey: string;
civiBaseUrl: string;
}) {
const [open, setOpen] = useState(false);
const mime = resolveMime(explicitMime, fileName);
const category = categoryFromMime(mime);
const proxyBase =
`/api/staff/file?id=${encodeURIComponent(String(fileId))}` +
`&org=${encodeURIComponent(String(org))}` +
`&key=${encodeURIComponent(authKey)}`;
const previewSrc = proxyBase;
// Prefer Civi's signed URL for downloads when present (one fewer hop
// through our Lambda); the proxy is the fallback.
const downloadHref = civiSignedUrl
? absUrl(civiSignedUrl, civiBaseUrl)
: `${proxyBase}&dl=1`;
const linkClass =
"text-ink underline decoration-rule underline-offset-4 hover:decoration-ink";
if (category === "image" || category === "pdf") {
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className={
"bg-transparent p-0 text-left " +
linkClass +
" focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-700"
}
>
{fileName}
</button>
<AttachmentLightbox
open={open}
onClose={() => setOpen(false)}
previewSrc={previewSrc}
downloadHref={downloadHref}
filename={fileName}
mime={mime}
/>
</>
);
}
if (category === "office") {
// Google Docs Viewer renders DOC/DOCX/XLS/XLSX in a new tab. It fetches
// the source URL server-side, so the URL must be reachable without our
// staff key — that's why we pass the Civi-signed URL straight through.
const gview = civiSignedUrl
? `https://docs.google.com/viewer?url=${encodeURIComponent(absUrl(civiSignedUrl, civiBaseUrl))}`
: null;
return (
<span className="inline-flex flex-wrap items-baseline gap-x-2 gap-y-1">
<a
href={downloadHref}
target="_blank"
rel="noopener noreferrer"
className={linkClass}
>
{fileName}
</a>
{gview && (
<a
href={gview}
target="_blank"
rel="noopener noreferrer"
title="Opens in Google Docs Viewer (file bytes are sent to Google to render)"
className="text-xs font-medium text-leaf-700 underline decoration-rule underline-offset-4 hover:decoration-ink hover:text-leaf-800"
>
View in Google Docs
</a>
)}
</span>
);
}
// "other" — unknown types: just a download link.
return (
<a
href={downloadHref}
target="_blank"
rel="noopener noreferrer"
className={linkClass}
>
{fileName}
</a>
);
}
function absUrl(u: string, base: string): string {
if (u.startsWith("http")) return u;
if (!base) return u;
return `${base.replace(/\/+$/, "")}${u.startsWith("/") ? "" : "/"}${u}`;
}
+5 -2
View File
@@ -281,11 +281,14 @@ export function MembershipChart({
/> />
))} ))}
{/* Most-recent point value labels */} {/* Most-recent point value labels. Both anchor to the chart's right
edge so the carry-forward endpoint label stays inside the viewBox
instead of overflowing past the right padding. */}
{actualCoords.length > 0 && ( {actualCoords.length > 0 && (
<text <text
x={actualCoords[actualCoords.length - 1].x + 6} x={xOf(maxT) - 4}
y={actualCoords[actualCoords.length - 1].y - 6} y={actualCoords[actualCoords.length - 1].y - 6}
textAnchor="end"
className="fill-leaf-800 text-[10px] font-medium tabular-nums" className="fill-leaf-800 text-[10px] font-medium tabular-nums"
> >
{numberFmt.format(actualCoords[actualCoords.length - 1].v)} {numberFmt.format(actualCoords[actualCoords.length - 1].v)}
+10 -10
View File
@@ -184,7 +184,7 @@ const stage0: StageSectionConfig = {
label: "Member-Owner Goal for current Stage", label: "Member-Owner Goal for current Stage",
type: "number", type: "number",
civiField: `${G0}.Member_Goal_for_current_Stage`, civiField: `${G0}.Member_Goal_for_current_Stage`,
help: "What is your member-owner goal for your current co-op development Stage?", help: "What is your member / owner goal for your current co-op development Stage?",
}, },
{ {
name: "Members__current_", name: "Members__current_",
@@ -199,7 +199,7 @@ const stage0: StageSectionConfig = {
type: "number", type: "number",
civiField: `${G0}.Total_members_at_opening`, civiField: `${G0}.Total_members_at_opening`,
visibleWhen: visibleAtOrAfter(S.Stabilize), visibleWhen: visibleAtOrAfter(S.Stabilize),
help: "How many member-owners will be required at opening for this store?", help: "How many member-owners did the co-op have on opening day?",
}, },
// { // {
// name: "Volunteers_Helping_In_Store", // name: "Volunteers_Helping_In_Store",
@@ -285,7 +285,7 @@ const stage0: StageSectionConfig = {
type: "currency", type: "currency",
visibleWhen: visibleAtOrAfter(S.BusinessFeasibility, S.StoreImplementation, S.Stabilize), visibleWhen: visibleAtOrAfter(S.BusinessFeasibility, S.StoreImplementation, S.Stabilize),
civiField: `${G0}.Member_equity_raised`, civiField: `${G0}.Member_equity_raised`,
help: "Member-owner equity raised, as of the time of survey", help: "Member-owner equity raised, as of the time of check-in",
}, },
// { // {
// name: "Member_loans_total", // name: "Member_loans_total",
@@ -299,7 +299,7 @@ const stage0: StageSectionConfig = {
type: "currency", type: "currency",
visibleWhen: visibleAtOrAfter(S.BusinessFeasibility, S.StoreImplementation, S.Stabilize), visibleWhen: visibleAtOrAfter(S.BusinessFeasibility, S.StoreImplementation, S.Stabilize),
civiField: `${G0}.Member_loans_raised`, civiField: `${G0}.Member_loans_raised`,
help: "Total amount (USD) raised from member-owner loans, as of the time of survey", help: "Total amount (USD) raised from member-owner loans, as of the time of check-in",
}, },
// { // {
// name: "Member_preferred_shares_total", // name: "Member_preferred_shares_total",
@@ -320,7 +320,7 @@ const stage0: StageSectionConfig = {
// { name: "Grant_Donations_Needed", label: "Grants / Donations Needed", type: "currency", civiField: `${G0}.Grant_Donations_Needed` }, // { name: "Grant_Donations_Needed", label: "Grants / Donations Needed", type: "currency", civiField: `${G0}.Grant_Donations_Needed` },
{ name: "Grants_Donations_Raised", label: "Grants / Donations Raised", type: "currency", visibleWhen: visibleAtOrAfter(S.BusinessFeasibility, S.StoreImplementation, S.Stabilize), civiField: `${G0}.Grants_Donations_Raised`, help: "Total amount (USD) raised from Grants and Donations in the latest Sources and Uses doc" }, { name: "Grants_Donations_Raised", label: "Grants / Donations Raised", type: "currency", visibleWhen: visibleAtOrAfter(S.BusinessFeasibility, S.StoreImplementation, S.Stabilize), civiField: `${G0}.Grants_Donations_Raised`, help: "Total amount (USD) raised from Grants and Donations in the latest Sources and Uses doc" },
// { name: "Other_sources_total", label: "Other sources total needed", type: "currency", civiField: `${G0}.Other_sources_total` }, // { name: "Other_sources_total", label: "Other sources total needed", type: "currency", civiField: `${G0}.Other_sources_total` },
{ name: "Other_sources_raised", label: "Other sources raised", type: "currency", visibleWhen: visibleAtOrAfter(S.BusinessFeasibility, S.StoreImplementation, S.Stabilize), civiField: `${G0}.Other_sources_raised`, help: "Total amount (USD) successfully raised from other sources in the latest Sources and Uses doc (e.g. community loans or grants, free fill, vendor credit, landlord contribution, etc). Does not include member-owner contributions (loans or equity) or bank debt." }, { name: "Other_sources_raised", label: "Other sources raised", type: "currency", visibleWhen: visibleAtOrAfter(S.BusinessFeasibility, S.StoreImplementation, S.Stabilize), civiField: `${G0}.Other_sources_raised`, help: "Total amount (USD) successfully raised from other sources in the latest Sources and Uses doc (e.g. community loans or grants, free fill, vendor credit, landlord contribution, etc). Does not include member contributions (loans or equity) or bank debt." },
// { name: "Date_Closed_Folded", label: "Date Closed / Folded", type: "date", civiField: `${G0}.Date_Closed_Folded` }, // { name: "Date_Closed_Folded", label: "Date Closed / Folded", type: "date", civiField: `${G0}.Date_Closed_Folded` },
{ {
name: "FTEs", name: "FTEs",
@@ -346,7 +346,7 @@ const stage1: StageSectionConfig = {
label: "Preliminary Market Assessment", label: "Preliminary Market Assessment",
type: "date", type: "date",
civiField: `${G1}.Preliminary_Market_Assessment`, civiField: `${G1}.Preliminary_Market_Assessment`,
help: "What date was your Preliminary Market Assessment completed?", help: "What date was your most recent Preliminary Market Assessment completed?",
}, },
{ {
name: "Preliminary_Market_Assessment_Upload", name: "Preliminary_Market_Assessment_Upload",
@@ -360,7 +360,7 @@ const stage1: StageSectionConfig = {
label: "Preliminary Sources And Uses", label: "Preliminary Sources And Uses",
type: "date", type: "date",
civiField: `${G1}.Preliminary_Sources_Uses`, civiField: `${G1}.Preliminary_Sources_Uses`,
help: "When was your Preliminary Sources & Uses completed?", help: "When was your most recent Preliminary Sources & Uses completed?",
}, },
{ {
name: "Preliminary_Sources_Uses_Upload", name: "Preliminary_Sources_Uses_Upload",
@@ -551,14 +551,14 @@ const stage3: StageSectionConfig = {
label: "Capital Campaign: Owner Participation %", label: "Capital Campaign: Owner Participation %",
type: "percent", type: "percent",
civiField: `${G3}.Capital_Campaign_Owner_Participation_`, civiField: `${G3}.Capital_Campaign_Owner_Participation_`,
help: "What percentage of your owners have contributed to your capital campaign?", help: "What percentage of your member-owners have contributed to your capital campaign?",
}, },
{ {
name: "Capital_Campaign_Average_Owner_Investment", name: "Capital_Campaign_Average_Owner_Investment",
label: "Capital Campaign: Average Owner Investment", label: "Capital Campaign: Average Owner Investment",
type: "currency", type: "currency",
civiField: `${G3}.Capital_Campaign_Average_Owner_Investment`, civiField: `${G3}.Capital_Campaign_Average_Owner_Investment`,
help: "What is the average investment size from your owners? (i.e. total raised divided by number of owners participating. If your co-op raised $100,000 from 100 owners, the average owner investment would be $1,000. $100,000 / 100 = $1,000.)", help: "What is the average investment size from your member-owners? (i.e. total raised divided by number of member-owners participating. If your co-op raised $100,000 from 100 owners, the average member-owner investment would be $1,000. $100,000 / 100 = $1,000.)",
}, },
] as FieldConfig[], ] as FieldConfig[],
fieldGroups: [ fieldGroups: [
@@ -781,7 +781,7 @@ export const formConfig: FormConfig = {
id: "org_engagement_check_in", id: "org_engagement_check_in",
title: "Co-op Survey", title: "Co-op Survey",
subtitle: subtitle:
"Thank you for updating your co-op information. The questions you'll see depend on where you are in the Framework.", "Thank you for updating your co-op information.",
stageField: "current_stage", stageField: "current_stage",
sections: [submitterInfo, stage0, stage1, stage2, stage3, stage4, stage5], sections: [submitterInfo, stage0, stage1, stage2, stage3, stage4, stage5],
}; };
+210
View File
@@ -86,6 +86,216 @@ export async function civi<T = unknown>(
return (await res.json()) as CiviApiResponse<T>; return (await res.json()) as CiviApiResponse<T>;
} }
/**
* Legacy APIv3 call. Some Civi entities (notably Attachment) are exposed
* only via APIv3 on this install; this helper hits the universal
* /civicrm/ajax/rest endpoint with AuthX headers. Returns the normalized
* values list — APIv3 may return values as either an array or an object
* keyed by id, depending on version; we flatten to an array.
*/
export async function civi3<T = unknown>(
entity: string,
action: string,
params: Record<string, unknown>,
opts: CiviApiOptions = {},
): Promise<CiviApiResponse<T>> {
if (isStubMode()) {
console.warn(`${STUB_LOG_PREFIX} v3 ${entity}.${action} — env not set, returning empty values`);
return { values: [] };
}
const base = opts.baseUrl ?? process.env.CIVI_BASE_URL!;
const url = `${base}/civicrm/ajax/rest`;
const body = new URLSearchParams({
entity,
action,
json: JSON.stringify(params),
});
const headers: Record<string, string> = {
"Content-Type": "application/x-www-form-urlencoded",
"X-Civi-Auth": `Bearer ${process.env.CIVI_API_KEY}`,
"X-Civi-Key": process.env.CIVI_SITE_KEY!,
// v3's rest endpoint enforces this header as CSRF protection.
"X-Requested-With": "XMLHttpRequest",
};
if (process.env.CIVI_HTTP_AUTH_USER && process.env.CIVI_HTTP_AUTH_PASS) {
const creds = Buffer.from(
`${process.env.CIVI_HTTP_AUTH_USER}:${process.env.CIVI_HTTP_AUTH_PASS}`,
).toString("base64");
headers["Authorization"] = `Basic ${creds}`;
}
const res = await fetch(url, {
method: "POST",
headers,
body,
cache: "no-store",
});
if (!res.ok) {
const text = await res.text();
throw new Error(`CiviCRM v3 ${entity}.${action} failed (${res.status}): ${text}`);
}
const json = (await res.json()) as {
is_error?: number;
error_message?: string;
values?: T[] | Record<string, T>;
count?: number;
};
if (json.is_error) {
throw new Error(
`CiviCRM v3 ${entity}.${action} error: ${json.error_message ?? "unknown"}`,
);
}
const raw = json.values;
const values: T[] = Array.isArray(raw)
? raw
: raw && typeof raw === "object"
? Object.values(raw)
: [];
return { values, count: json.count };
}
/**
* Multipart APIv3 call. Used for binary uploads — APIv4 File.create stores
* the `content` field verbatim (no base64 decoding), so files come back
* corrupted. APIv3 Attachment.create accepts the file via the standard
* multipart `file` part (read from $_FILES on the server) which preserves
* the bytes exactly.
*
* Caller provides `params` (non-binary metadata) and `file` (the binary +
* filename + mime). Authentication is the same AuthX headers as civi3().
*/
export async function civi3Upload<T = unknown>(
entity: string,
action: string,
params: Record<string, unknown>,
file: { bytes: Uint8Array; filename: string; mime: string },
opts: CiviApiOptions = {},
): Promise<CiviApiResponse<T>> {
if (isStubMode()) {
console.warn(
`${STUB_LOG_PREFIX} v3-multipart ${entity}.${action} — env not set, returning empty values`,
);
return { values: [] };
}
const base = opts.baseUrl ?? process.env.CIVI_BASE_URL!;
const url = `${base}/civicrm/ajax/rest`;
const form = new FormData();
form.append("entity", entity);
form.append("action", action);
form.append("json", JSON.stringify(params));
// Wrap the Uint8Array in a fresh ArrayBuffer slice so Blob's typing
// (which only accepts ArrayBuffer, not the wider ArrayBufferLike) is
// happy. The slice is a no-op on real Uint8Array inputs.
const fileBuf = file.bytes.buffer.slice(
file.bytes.byteOffset,
file.bytes.byteOffset + file.bytes.byteLength,
) as ArrayBuffer;
form.append(
"file",
new Blob([fileBuf], { type: file.mime }),
file.filename,
);
// Do NOT set Content-Type — fetch sets multipart/form-data with the
// correct boundary automatically when body is a FormData.
const headers: Record<string, string> = {
"X-Civi-Auth": `Bearer ${process.env.CIVI_API_KEY}`,
"X-Civi-Key": process.env.CIVI_SITE_KEY!,
"X-Requested-With": "XMLHttpRequest",
};
if (process.env.CIVI_HTTP_AUTH_USER && process.env.CIVI_HTTP_AUTH_PASS) {
const creds = Buffer.from(
`${process.env.CIVI_HTTP_AUTH_USER}:${process.env.CIVI_HTTP_AUTH_PASS}`,
).toString("base64");
headers["Authorization"] = `Basic ${creds}`;
}
const res = await fetch(url, {
method: "POST",
headers,
body: form,
cache: "no-store",
});
if (!res.ok) {
const text = await res.text();
throw new Error(
`CiviCRM v3 ${entity}.${action} (multipart) failed (${res.status}): ${text}`,
);
}
const json = (await res.json()) as {
is_error?: number;
error_message?: string;
values?: T[] | Record<string, T>;
count?: number;
};
if (json.is_error) {
throw new Error(
`CiviCRM v3 ${entity}.${action} (multipart) error: ${json.error_message ?? "unknown"}`,
);
}
const raw = json.values;
const values: T[] = Array.isArray(raw)
? raw
: raw && typeof raw === "object"
? Object.values(raw)
: [];
return { values, count: json.count };
}
/**
* POST a multipart request directly to an arbitrary Civi route. Used for
* extension endpoints that handle multipart uploads natively (the v3/v4
* ajax/rest path silently drops $_FILES on this install, and JSON+base64
* stores the literal base64 text on disk).
*
* The caller's `path` is appended to CIVI_BASE_URL. AuthX headers are sent
* the same way as the other helpers. Returns the parsed JSON body.
*/
export async function civiMultipart<T = unknown>(
path: string,
fields: Record<string, string>,
file: { bytes: Uint8Array; filename: string; mime: string },
opts: CiviApiOptions = {},
): Promise<T> {
if (isStubMode()) {
console.warn(`${STUB_LOG_PREFIX} multipart ${path} — env not set`);
return {} as T;
}
const base = opts.baseUrl ?? process.env.CIVI_BASE_URL!;
const url = `${base.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
const form = new FormData();
for (const [k, v] of Object.entries(fields)) form.append(k, v);
const fileBuf = file.bytes.buffer.slice(
file.bytes.byteOffset,
file.bytes.byteOffset + file.bytes.byteLength,
) as ArrayBuffer;
form.append("file", new Blob([fileBuf], { type: file.mime }), file.filename);
const headers: Record<string, string> = {
"X-Civi-Auth": `Bearer ${process.env.CIVI_API_KEY}`,
"X-Civi-Key": process.env.CIVI_SITE_KEY!,
"X-Requested-With": "XMLHttpRequest",
};
if (process.env.CIVI_HTTP_AUTH_USER && process.env.CIVI_HTTP_AUTH_PASS) {
const creds = Buffer.from(
`${process.env.CIVI_HTTP_AUTH_USER}:${process.env.CIVI_HTTP_AUTH_PASS}`,
).toString("base64");
headers["Authorization"] = `Basic ${creds}`;
}
const res = await fetch(url, {
method: "POST",
headers,
body: form,
cache: "no-store",
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Civi POST ${path} failed (${res.status}): ${text}`);
}
return (await res.json()) as T;
}
/** /**
* Validate a contact checksum (cid + cs) against CiviCRM. * Validate a contact checksum (cid + cs) against CiviCRM.
* *
+86
View File
@@ -0,0 +1,86 @@
// @ts-check
/**
* Mime helpers for staff-report attachment rendering.
*
* The staff report shows file attachments with three different affordances:
* - images / PDF -> inline lightbox preview
* - office docs -> plain download + "View in Google Docs" link
* - anything else -> plain download
*
* Civi can serve a mime via `Attachment.get`, but historical uploads may
* have a stale or missing `mime_type` column. Fall back to extension-based
* inference so we always reach a stable category.
*
* Written as JS+JSDoc rather than TS so Node's built-in --test runner can
* import this file directly without any tooling — matches the pattern set
* by lib/staff-field-mapping.mjs.
*
* @typedef {"image" | "pdf" | "office" | "other"} AttachmentCategory
*/
/** @type {Record<string, string>} */
const EXT_TO_MIME = {
pdf: "application/pdf",
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
doc: "application/msword",
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
xls: "application/vnd.ms-excel",
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
};
const OFFICE_MIMES = new Set([
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
]);
/**
* Pull the lowercased extension from a filename, or "" if absent.
* @param {string | undefined | null} filename
* @returns {string}
*/
export function extOf(filename) {
if (!filename) return "";
const dot = filename.lastIndexOf(".");
if (dot < 0 || dot === filename.length - 1) return "";
return filename.slice(dot + 1).toLowerCase();
}
/**
* Map a filename's extension to a known mime, or null if unrecognised.
* @param {string | undefined | null} filename
* @returns {string | null}
*/
export function mimeFromFilename(filename) {
const ext = extOf(filename);
return EXT_TO_MIME[ext] ?? null;
}
/**
* Resolve a mime by trusting the explicit value first, then falling back to
* filename inference. Returns "application/octet-stream" if nothing matches.
* @param {string | undefined | null} explicit
* @param {string | undefined | null} filename
* @returns {string}
*/
export function resolveMime(explicit, filename) {
if (explicit && explicit !== "application/octet-stream") return explicit;
return mimeFromFilename(filename) ?? explicit ?? "application/octet-stream";
}
/**
* Categorise a mime for UI dispatch.
* @param {string} mime
* @returns {AttachmentCategory}
*/
export function categoryFromMime(mime) {
if (mime.startsWith("image/")) return "image";
if (mime === "application/pdf") return "pdf";
if (OFFICE_MIMES.has(mime)) return "office";
return "other";
}
+82
View File
@@ -0,0 +1,82 @@
// Run with: npm run test:mime
import test from "node:test";
import assert from "node:assert/strict";
import {
extOf,
mimeFromFilename,
resolveMime,
categoryFromMime,
} from "./mime.mjs";
test("extOf returns lowercased extension", () => {
assert.equal(extOf("Photo.JPG"), "jpg");
assert.equal(extOf("doc.tar.gz"), "gz");
});
test("extOf handles missing or trailing-dot names", () => {
assert.equal(extOf(""), "");
assert.equal(extOf(null), "");
assert.equal(extOf("no-extension"), "");
assert.equal(extOf("trailing."), "");
});
test("mimeFromFilename recognises known types", () => {
assert.equal(mimeFromFilename("vision.pdf"), "application/pdf");
assert.equal(mimeFromFilename("budget.XLSX"),
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
assert.equal(mimeFromFilename("logo.PNG"), "image/png");
});
test("mimeFromFilename returns null for unknown extensions", () => {
assert.equal(mimeFromFilename("archive.zip"), null);
assert.equal(mimeFromFilename("readme"), null);
});
test("resolveMime trusts an explicit non-octet mime", () => {
assert.equal(resolveMime("application/pdf", "anything.txt"), "application/pdf");
});
test("resolveMime falls back to filename when explicit is missing", () => {
assert.equal(resolveMime(undefined, "report.pdf"), "application/pdf");
assert.equal(resolveMime(null, "image.gif"), "image/gif");
});
test("resolveMime falls back to filename when explicit is octet-stream", () => {
assert.equal(
resolveMime("application/octet-stream", "spreadsheet.xlsx"),
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
);
});
test("resolveMime returns octet-stream when nothing is known", () => {
assert.equal(resolveMime(null, null), "application/octet-stream");
assert.equal(resolveMime(undefined, "unknownfile"), "application/octet-stream");
});
test("categoryFromMime: image", () => {
assert.equal(categoryFromMime("image/png"), "image");
assert.equal(categoryFromMime("image/webp"), "image");
});
test("categoryFromMime: pdf", () => {
assert.equal(categoryFromMime("application/pdf"), "pdf");
});
test("categoryFromMime: office variants", () => {
assert.equal(categoryFromMime("application/msword"), "office");
assert.equal(
categoryFromMime("application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
"office",
);
assert.equal(categoryFromMime("application/vnd.ms-excel"), "office");
assert.equal(
categoryFromMime("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
"office",
);
});
test("categoryFromMime: other", () => {
assert.equal(categoryFromMime("application/zip"), "other");
assert.equal(categoryFromMime("text/plain"), "other");
assert.equal(categoryFromMime("application/octet-stream"), "other");
});
+121
View File
@@ -0,0 +1,121 @@
import type { ActivitySummary } from "@/types/form";
/**
* CiviCRM activity stage values → Framework Stage rank (1..5). "Inquiry"
* predates Stage 1 and is conventionally rank 0 (pre-engagement); callers
* that render stage lanes 1..5 should treat 0 as "no rank in scope".
*/
export const STAGE_RANK: Record<string, number> = {
Inquiry: 0,
Organizing: 1,
Feasibility: 2,
"Business feasibility": 3,
"Store Implementation": 4,
"Stabilize newly opened co-op": 5,
};
/**
* Build a resolver that returns the co-op's Framework Stage rank at a given
* point in time, derived from the activity stream. Only activities that
* carry a non-empty `stage` value are stage transitions; their stage holds
* from that activity's date forward until the next transition.
*
* Returns `null` when the date precedes any known transition (i.e. we have
* no evidence of which stage the co-op was in at that point) — callers can
* fall back to a field-section default in that case.
*/
export function buildStageRankAtDate(
activities: ActivitySummary[]
): (isoDate: string) => number | null {
const transitions = activities
.filter(
(a): a is ActivitySummary & { stage: string } =>
typeof a.stage === "string" && a.stage.length > 0
)
.map((a) => ({ t: new Date(a.date).getTime(), stage: a.stage }))
.filter((tr) => Number.isFinite(tr.t))
.sort((a, b) => a.t - b.t);
return (isoDate: string) => {
const t = new Date(isoDate).getTime();
if (!Number.isFinite(t)) return null;
let last: number | null = null;
for (const tr of transitions) {
if (tr.t > t) break;
const r = STAGE_RANK[tr.stage];
if (typeof r === "number") last = r;
}
return last;
};
}
export interface StageRange {
rank: number;
/** ISO date this stage period begins (the stage-transition activity). */
startDate: string;
/**
* ISO date the period ends — the next chronologically-later activity with
* a higher stage rank. `null` means the period is open-ended (still in
* effect today); the chart should extend it to the right edge.
*/
endDate: string | null;
activityId: number;
subject: string | null;
}
/**
* Derive per-activity stage ranges from the activity stream. Each
* stage-transition activity (one with a non-empty `stage`) produces one
* range: start = the activity's date, end = the date of the next
* chronologically-following activity whose stage rank is *strictly higher*.
* If no later activity has a higher rank, the range is open-ended.
*
* Multiple activities at the same stage each yield their own range; they'll
* share the same end date if they sit between the same two higher-rank
* boundaries.
*/
export function computeStageRanges(activities: ActivitySummary[]): StageRange[] {
type Item = {
t: number;
rank: number;
activityId: number;
isoDate: string;
subject: string | null;
};
const items: Item[] = [];
for (const a of activities) {
if (typeof a.stage !== "string" || a.stage.length === 0) continue;
const r = STAGE_RANK[a.stage];
if (typeof r !== "number" || r < 1 || r > 5) continue;
const t = new Date(a.date).getTime();
if (!Number.isFinite(t)) continue;
items.push({
t,
rank: r,
activityId: a.id,
isoDate: a.date,
subject: a.subject ?? null,
});
}
items.sort((x, y) => x.t - y.t);
const ranges: StageRange[] = [];
for (let i = 0; i < items.length; i++) {
const cur = items[i];
let endIso: string | null = null;
for (let j = i + 1; j < items.length; j++) {
if (items[j].rank > cur.rank) {
endIso = items[j].isoDate;
break;
}
}
ranges.push({
rank: cur.rank,
startDate: cur.isoDate,
endDate: endIso,
activityId: cur.activityId,
subject: cur.subject,
});
}
return ranges;
}
+46 -15
View File
@@ -6,14 +6,15 @@ import type { NextConfig } from "next";
* *
* Two profiles: * Two profiles:
* - strict (default): frame-ancestors 'none' + X-Frame-Options: DENY. * - strict (default): frame-ancestors 'none' + X-Frame-Options: DENY.
* Applied to every route except /staff/report. * Applied to every route except the embed-friendly ones.
* - staff-embed: frame-ancestors 'self' <civi-origin>, no X-Frame-Options. * - staff-embed: frame-ancestors 'self' <civi-origin>, no X-Frame-Options.
* Lets the CiviCRM "Engagement Report" extension embed the staff page * Lets the CiviCRM "Engagement Report" extension embed /staff/report,
* in an iframe on contact pages. * and lets the lightbox iframe inside that page load the
* /api/staff/file proxy for PDF preview.
* *
* The catch-all source uses a negative lookahead so it does NOT match * The catch-all source uses a negative lookahead so it does NOT match the
* /staff/report — otherwise both rules apply and the browser ANDs the * embed-friendly routes — otherwise both rules apply and the browser ANDs
* frame-ancestors directives together, blocking embedding entirely. * the frame-ancestors directives together, blocking embedding entirely.
*/ */
const isDev = process.env.NODE_ENV !== "production"; const isDev = process.env.NODE_ENV !== "production";
const devOnlyDynamicScript = isDev ? " 'unsafe-eval'" : ""; const devOnlyDynamicScript = isDev ? " 'unsafe-eval'" : "";
@@ -42,13 +43,38 @@ const sharedHeaders = [
}, },
]; ];
function civiOriginForCsp(): string { /**
const raw = process.env.CIVI_BASE_URL; * Civi origins permitted to embed the staff report in an iframe.
if (!raw) return ""; *
* Reads `CIVI_FRAME_ALLOWED_ORIGINS` (comma-separated origins) so a single
* survey.fci.coop deployment can be framed by both the dev Civi
* (client.crm.fci.coop) and the production Civi (crm.fci.coop). Falls
* back to the origin of `CIVI_BASE_URL` when the multi-origin var isn't
* set so existing single-Civi deployments keep working.
*
* Each entry is validated as a parsable URL; bad values are dropped and
* logged at build time rather than silently making the CSP invalid.
*/
function civiOriginsForCsp(): string[] {
const explicit = process.env.CIVI_FRAME_ALLOWED_ORIGINS;
if (explicit) {
const raws = explicit.split(",").map((s) => s.trim()).filter(Boolean);
const origins: string[] = [];
for (const raw of raws) {
try { try {
return new URL(raw).origin; origins.push(new URL(raw).origin);
} catch { } catch {
return ""; console.warn(`[next.config] ignoring invalid CIVI_FRAME_ALLOWED_ORIGINS entry: ${raw}`);
}
}
return origins;
}
const base = process.env.CIVI_BASE_URL;
if (!base) return [];
try {
return [new URL(base).origin];
} catch {
return [];
} }
} }
@@ -59,12 +85,13 @@ const strictHeaders = [
]; ];
const staffEmbedHeaders = (() => { const staffEmbedHeaders = (() => {
const origin = civiOriginForCsp(); const origins = civiOriginsForCsp();
const frameAncestors = origin ? `'self' ${origin}` : "'self'"; const frameAncestors = origins.length > 0 ? `'self' ${origins.join(" ")}` : "'self'";
return [ return [
{ key: "Content-Security-Policy", value: buildCsp(frameAncestors) }, { key: "Content-Security-Policy", value: buildCsp(frameAncestors) },
...sharedHeaders, ...sharedHeaders,
// Intentionally NO X-Frame-Options: frame-ancestors above is the policy. // Intentionally NO X-Frame-Options: frame-ancestors above is the policy.
// (X-Frame-Options can only encode one origin; CSP supersedes here.)
]; ];
})(); })();
@@ -84,8 +111,12 @@ const nextConfig: NextConfig = {
async headers() { async headers() {
return [ return [
{ source: "/staff/report", headers: staffEmbedHeaders }, { source: "/staff/report", headers: staffEmbedHeaders },
// Catch-all that explicitly excludes /staff/report — see header notes. // The lightbox in /staff/report iframes this proxy for inline PDF
{ source: "/((?!staff/report).*)", headers: strictHeaders }, // previews. Must share the embed-friendly profile so the browser
// doesn't block the same-origin iframe.
{ source: "/api/staff/file", headers: staffEmbedHeaders },
// Catch-all that excludes the embed-friendly routes — see header notes.
{ source: "/((?!staff/report|api/staff/file).*)", headers: strictHeaders },
]; ];
}, },
}; };
+3 -1
View File
@@ -8,7 +8,9 @@
"start": "next start", "start": "next start",
"lint": "eslint", "lint": "eslint",
"sync-help": "node --env-file=.env.local scripts/sync-help-from-civi.mjs", "sync-help": "node --env-file=.env.local scripts/sync-help-from-civi.mjs",
"test:mapping": "node --test lib/staff-field-mapping.test.mjs" "test:mapping": "node --test lib/staff-field-mapping.test.mjs",
"test:mime": "node --test lib/mime.test.mjs",
"test": "node --test lib/staff-field-mapping.test.mjs lib/mime.test.mjs"
}, },
"dependencies": { "dependencies": {
"next": "16.2.6", "next": "16.2.6",