Joel Brock 2400931a04 File upload pipeline: wire end-to-end via APIv4 File.create
Closes the file-upload gap. Files now actually land in CiviCRM (verified
empirically against the live Civi instance via spike scripts).

Spike findings (see scripts/spike-file-upload.mjs):
  - APIv4 Attachment is NOT exposed on this Civi
  - APIv4 File + EntityFile ARE exposed; File.create accepts inline
    base64 `content` and returns a usable file id
  - Custom file fields store the file id directly in the custom column,
    so EntityFile linkage is unnecessary for this use case
  - Round-trip via Contact.update + Contact.get .file_name join verified
    on a real org contact

Pipeline:

  Renderer (FileField) picks up onChange  →
    POST /api/upload (multipart) with file + cid + cs + fieldRef  →
      verifyChecksum, MIME allowlist + magic-byte sniff, 5 MB cap  →
        civi.File.create({ file_name, mime_type, content: base64 })  →
          returns { id, file_name }  →
            renderer stores in RHF state via setValue
  Form submit  →
    POST /api/submit (JSON) with the {id, file_name} value  →
      submit detects the file shape and writes the id as the value of
      the activity/contact custom field

File changes:

  app/api/upload/route.ts
    Replaced the 501 stub with the real File.create call. Comment
    documents that EntityFile linkage is intentionally skipped and that
    orphan cleanup is owned by a CiviCRM scheduled job.

  app/api/submit/route.ts
    For type:"file" values shaped as {id, file_name}, write the id as
    the custom field value (activity or contact, depending on the
    civiField / civiContactField the field declares).

  components/fields/FieldRenderer.tsx
    Replaced the bare <input type=file> register() with FileField, an
    upload-on-pick subcomponent. The native input is NOT register()'d:
    its FileList value was the original bug. FileField owns its
    uploading + error state and writes {id, file_name} via setValue on
    success. Submit is blocked upstream while uploads are in flight.

  components/StageSection.tsx, components/EngagementForm.tsx
    Thread setValue, cid, cs, and an onUploadStateChange callback
    through to FieldRenderer. EngagementForm tracks uploads-in-flight
    count; onSubmit refuses to submit while the count is > 0.

  config/form.ts
    Promotes Certificate_of_Incorporation from readonly to a real
    file field now that the pipeline works.

  app/api/data/route.ts
    Drops the readonly carveout that was only needed while the
    certificate was readonly.

  scripts/list-civi-entities.mjs (new)
    APIv4 entity probe + APIv3 attachment-API probe. Used to determine
    that File (not Attachment) was the right entity on this Civi.

  scripts/spike-file-upload.mjs (new)
    The actual end-to-end test that proved out the pipeline before
    wiring. Safe to re-run on any Civi instance during future audits.

Not in this change:
  - Orphan attachment cleanup (CiviCRM scheduled job, Civi admin scope)
  - Per-field MIME allowlists (single global list for v1)
  - S3 / presigned-URL path for >5 MB files (deferred; capped at 5 MB
    today to stay under Amplify Lambda's 6 MB sync payload limit)
2026-06-05 07:48:57 -07:00
2026-05-19 16:48:38 -07:00

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 form
  • https://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 (19002100), 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 via aria-describedby.
  • Required fields use aria-required, a visible * with aria-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 with aria-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 M1M12 / Q1Q4 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.

S
Description
No description provided
Readme
704 KiB
Languages
TypeScript 83.7%
JavaScript 11%
PHP 2.8%
CSS 1.7%
Smarty 0.8%