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.
Co-op Survey
A tokenized, mobile-friendly web form that lets external co-op contacts update their organization's tracking data on CiviCRM, plus a read-only activity report showing every value the co-op has shared over time. Built to wrap CiviCRM's existing custom-field schema without modifying it.
How it works
Each co-op has a designated Primary Contact (the individual) linked to the Organization record in CiviCRM. Staff send that contact a personalized link generated against the contact's CiviCRM checksum:
https://survey.fci.coop/?cid=<contactId>&cs=<checksum>— the formhttps://survey.fci.coop/report?cid=<contactId>&cs=<checksum>— the report
When the link is opened, the app verifies the checksum against CiviCRM, resolves the organization through the Primary Contact relationship, and loads the right view.
Form. Sections are organized by the co-op development stages (Inquiry → Convene & Prepare → Grow & Plan → Connect & Gather → Excite & Build → Fulfill & Stabilize). The org's current stage controls which sections are editable; past and current stages are open for editing, future stages render as previews with their fields locked so the co-op can see the framework ahead. Fields prefill with each value's most recent non-empty entry from past surveys. On submit, a new "Check-in (organizing)" activity is created; nothing is overwritten.
Stage authority. The current stage is derived from the most recent "Check-in (organizing)" activity whose Stage field is set. Staff own stage transitions by manually setting Stage on a "Check-in (organizing)" activity they create; the form itself never writes Stage, so org self-submissions can't override a staff transition. Orgs with no stage-bearing activity default to "Inquiry."
Report. A read-only view of every field that has ever held a value, grouped by stage. Each row shows the most recent value prominently; an "N earlier entries" disclosure expands a chronological list of prior values with their dates. Empty stages and untouched fields are hidden so the page stays calm.
Visual & UX details
- Field Almanac aesthetic: warm cream paper, deep botanical green ink, a sparing terracotta accent. Fraunces (display) and DM Sans (body) at variable weights. Subtle paper-grain texture via layered CSS gradients.
- Journey rail runs down the left side on desktop, with a marker per stage and a "Now" pill at the current stage. Past stages are filled with a checkmark, future stages are dashed and locked. A small inter-card stem stands in for the rail on mobile.
- Draft auto-save to
localStorage(30-day TTL) so partial answers survive page refreshes; a "Draft restored" banner appears on return. - Successful submit lands on a destination screen instead of a reloaded form, preventing accidental duplicate submits from back-button or autofill replay.
- Currency live preview, date min/max bounds (1900–2100), and thousands-separator formatting on numeric fields.
- Hand-drawn stage icons for each of the six stages; FCI brand logo in the header.
Accessibility
- Real
<label htmlFor>on every control; help text and inline errors linked viaaria-describedby. - Required fields use
aria-required, a visible*witharia-label="required", and typed error messages. - Failed-validation submit auto-expands the section containing the first error, scrolls it into view, and focuses the field.
- Accordion sections follow the disclosure pattern:
<button>headers witharia-expanded+aria-controls,role="region"panels. - All animations honour
prefers-reduced-motion. - Skip-to-content link present for keyboard users.
- iOS safe-area inset respected on the sticky submit bar.
Environment variables
The app runs in stub mode when any of the CiviCRM variables below are missing — the UI is fully exercisable against synthesized data with no live CRM calls. Production startup will refuse to boot in stub mode.
| Variable | Required? | Purpose |
|---|---|---|
CIVI_BASE_URL |
yes | CiviCRM root, e.g. https://crm.fci.coop |
CIVI_API_KEY |
yes | API key of a Civi user with permission to read contacts/activities and create activities |
CIVI_SITE_KEY |
yes | The site_key from civicrm.settings.php |
CIVI_HTTP_AUTH_USER |
optional | Username, if CiviCRM sits behind HTTP Basic Auth at the webserver layer |
CIVI_HTTP_AUTH_PASS |
optional | Password for the above |
HEALTH_TOKEN |
optional | If set, /api/health requires ?token=… in production |
PREVIEW_ADMIN_TOKEN |
optional | If set, /api/preview-link requires a matching Authorization: Bearer … header (admin convenience endpoint for minting form links) |
NODE_ENV |
— | Set to production for the production build |
Copy .env.example to .env.local for local development.
Run locally
npm install
npm run dev
Open http://localhost:3000/?cid=1&cs=anything — any non-empty cs
works while in stub mode. The report is at /report?cid=1&cs=anything.
Deploy
The repo ships with amplify.yml for AWS Amplify Hosting
(see AMPLIFY_DEPLOY.md for a first-time walkthrough) and render.yaml
for Render (see DEPLOYMENT.md). The app is a
standard Next.js 16 App Router project and runs anywhere Node 20+ can
run next start — Vercel, App Runner, Fly, self-hosted, etc.
Build + start:
npm run build
npm start
Set all three required CiviCRM variables in the host's secret store; the app auto-leaves stub mode the moment they're all present.
A /healthz route returns a lightweight 200 for platform health checks;
/api/health returns a richer diagnostic payload (gated by
HEALTH_TOKEN in production).
Editing the form
The form definition lives in config/form.ts — one TypeScript file
declaring sections, fields, types, visibility rules, and matrix groups
(used for compact M1–M12 / Q1–Q4 layouts in Stage 5). Each field
references its CiviCRM custom field via APIv4's <group_name>.<field_name>
syntax. Edit the file, restart npm run dev, you're done.
A browser-based form builder is out of scope for this version.