diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 978c6e5..358bc26 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -3,6 +3,12 @@ This app is built to deploy to **[Render](https://render.com)** as a Web Service via the included `render.yaml` blueprint. +For the **CiviCRM-side** setup that must happen before this app can +work (extension install, custom-field defaults, relationship types, +option groups, post-deploy verification), see +[`PRODUCTION_CUTOVER.md`](./PRODUCTION_CUTOVER.md). Keep that doc +current — every Civi prerequisite we discover belongs there. + ## Pre-flight checklist Before deploying, confirm: diff --git a/PRODUCTION_CUTOVER.md b/PRODUCTION_CUTOVER.md new file mode 100644 index 0000000..ded2a89 --- /dev/null +++ b/PRODUCTION_CUTOVER.md @@ -0,0 +1,278 @@ +# Production cutover guide + +Step-by-step CiviCRM-side checklist to recreate the Co-op Survey +environment on a new CiviCRM (e.g. when moving from +`client.crm.fci.coop` to production `crm.fci.coop`). The Next.js app +deploy is covered separately in [`DEPLOYMENT.md`](./DEPLOYMENT.md) (Render) +and [`AMPLIFY_DEPLOY.md`](./AMPLIFY_DEPLOY.md) (AWS Amplify). This +document captures everything that must exist *inside CiviCRM* for those +deployments to work. + +Keep this file current — every time we discover a manual Civi-side +prerequisite, add it here so the next environment cutover is a single +read-through. + +--- + +## 0. Prerequisites on the target CiviCRM + +- CiviCRM 5.50+ on PHP 7.4 or 8.x. +- A service-user contact with an **API key** generated (Contact view → + edit → API Key field). The same user's permissions are what the app + runs as. +- The site's `CIVICRM_SITE_KEY` from `civicrm.settings.php`. +- If CiviCRM sits behind webserver-level Basic Auth, credentials for it. + +These three (`CIVI_BASE_URL`, `CIVI_API_KEY`, `CIVI_SITE_KEY`) are the +core env vars the app needs. + +--- + +## 1. Activity type and custom field groups + +The form writes one activity per submission, of type +**`Check-in (organizing)`** (machine name, exact spelling). + +That activity type must exist with the **six** custom field groups +attached, all extending `Activity` filtered to this single activity +type: + +| Custom group machine name | Stage | +|----------------------------------|-------| +| `Check_in_data__organizing_` | 0 (always-on survey data + the `Stage` snapshot field) | +| `Stage_1` | 1 — Convene & Prepare | +| `Stage_2` | 2 — Grow & Plan | +| `Stage_3` | 3 — Connect & Gather | +| `Stage_4` | 4 — Excite & Build | +| `Stage_5` | 5 — Fulfill & Stabilize | + +Field names *within* those groups are referenced explicitly by +`config/form.ts` (search for `civiField:`). If you renamed any field on +the source CRM, mirror the change here or update the config. + +**Sanity check** after import: `Activity → Search` for any existing +`Check-in (organizing)` row and confirm every custom field renders. + +--- + +## 2. The `Stage` custom field default — **must be cleared** + +Custom field id 242 (label "Stage", inside the +`Check_in_data__organizing_` group) **must have an empty default +value**. The form is intentionally not the authority on stage; staff +set Stage on their own stage-change check-ins, and `/api/data` derives +the org's current stage from the most recent stage-bearing activity. + +If a default is set (the original config had "Unknown" as the default), +every form-submitted activity gets stamped with that value and +contaminates the stage-derivation logic. + +The Civi admin UI's dropdown does **not** allow you to select "blank", +so clear it via API or SQL: + +**Option A — API (preferred):** + +```bash +cv api4 CustomField.update \ + where='[["id","=",242]]' \ + values='{"default_value":null}' +``` + +Or in the API Explorer v4 (`Support → Developer → API Explorer v4`): + +``` +Entity: CustomField +Action: update +where: [["id","=",242]] +values: {"default_value": null} +``` + +**Option B — direct SQL** (only if the API isn't handy): + +```sql +UPDATE civicrm_custom_field SET default_value = NULL WHERE id = 242; +``` + +Then `cv flush` or `Administer → System Settings → Cleanup Caches`. + +**Cleanup of historical bad data** (optional, only if production was +running with the bad default for a while): + +```bash +cv api4 Activity.update \ + where='[["activity_type_id:name","=","Check-in (organizing)"], + ["Check_in_data__organizing_.Stage","=","Unknown"]]' \ + values='{"Check_in_data__organizing_.Stage": null}' +``` + +Verify: pull one activity back and confirm Stage is null: + +```bash +cv api4 Activity.get \ + where='[["activity_type_id:name","=","Check-in (organizing)"]]' \ + select='["id","Check_in_data__organizing_.Stage"]' \ + limit=5 +``` + +--- + +## 3. Framework Stage option group on Organizations + +Each Organization contact has a custom field that holds its current +Framework Stage. The option group backing it (referenced by +`STAGE_OPTION_GROUP_ID` in `config/form.ts`, currently `75`) must +contain these six values, spelled exactly: + +- `Inquiry` +- `Organizing` +- `Feasibility` +- `Business feasibility` +- `Store Implementation` +- `Stabilize newly opened co-op` + +If the option group id differs in the target CRM, update +`STAGE_OPTION_GROUP_ID` in `config/form.ts` and redeploy. + +--- + +## 4. Relationship type: form-filler → organization + +The form resolves "what org is this submission about?" by looking up +the form-filler's **`Primary Contact`** relationship to an +Organization. The relationship type must exist with: + +- `name_a_b` = `Primary Contact` +- side A = Individual +- side B = Organization + +This is the value of `FORM_CONTACT_RELATIONSHIP` in `config/form.ts`. +If you use a different relationship type in the target CRM, update that +constant and redeploy. + +**Per-org setup** (this is the recurring operational step): for each +Organization that should receive a survey, create exactly one *active* +`Primary Contact` relationship from the staff/board contact who will +fill out the form to that Organization. + +- Zero active relationships → the form returns + `No active "Primary Contact" relationship found for your contact.` +- More than one active relationship → the form refuses with + `multiple active … relationships; staff must resolve before this link + will work.` (Deactivate the older ones.) + +--- + +## 5. Install the `webform-mw` Civi extension + +The extension adds an **Engagement Report** tab to Organization +contact pages that embeds the staff report in an iframe. + +1. Copy `WebForm-mw/civi-extension/webform-mw/` to the CRM's + `[civicrm.extensionsDir]` (usually + `/sites/default/ext/`). The directory must be named + exactly `webform-mw` (matches `` in `info.xml`). + +2. `Administer → System Settings → Extensions → Add new → Refresh`, + then **Install** next to "WebForm-mw". + +3. Configure — add to `civicrm.settings.php`: + + ```php + define('WEBFORM_MW_APP_URL', 'https://survey.fci.coop'); + define('WEBFORM_MW_STAFF_KEY', '...same value as STAFF_REPORT_KEY in the app env...'); + ``` + + `WEBFORM_MW_STAFF_KEY` **must** equal the `STAFF_REPORT_KEY` env var + on the Next.js app. Rotate them together. + +4. Confirm: open any Organization contact in CiviCRM — there should be + an **Engagement Report** tab. Click it; the staff report should + load with no nested scrollbar (the extension auto-sizes the iframe + via `postMessage`). + +Full extension docs: +[`civi-extension/webform-mw/README.md`](./civi-extension/webform-mw/README.md). + +--- + +## 6. CSP / `frame-ancestors` — app side + +The Next.js app's `/staff/report` route must allow the CiviCRM origin +in its `frame-ancestors` CSP, or the iframe will refuse to render. + +The build reads `CIVI_BASE_URL` and adds its origin to the CSP +automatically — so make sure `CIVI_BASE_URL` on the app deploy points +at the **production** CRM origin (`https://crm.fci.coop`), not +`client.crm.fci.coop`. + +Confirm after deploy: + +```bash +curl -sI https://survey.fci.coop/staff/report | grep -i content-security-policy +``` + +Should include `frame-ancestors 'self' https://crm.fci.coop` (or +whatever your production CRM origin is). + +--- + +## 7. App env vars (cross-reference) + +The full list lives in [`DEPLOYMENT.md`](./DEPLOYMENT.md#first-time-render-setup). +Production-specific reminders: + +- `CIVI_BASE_URL` — production CRM, not the client/staging one. +- `STAFF_REPORT_KEY` — long random secret, identical to the + `WEBFORM_MW_STAFF_KEY` in `civicrm.settings.php` (step 5). +- `HEALTH_TOKEN` — set this in production, or `/api/health` returns + 404. Use `openssl rand -hex 32`. +- Confirm none of the three core `CIVI_*` vars are missing — the app + refuses to start in `NODE_ENV=production` if any are unset. + +--- + +## 8. Post-deploy verification + +Run these against the production deploy: + +1. `curl https://survey.fci.coop/healthz` + → `{"ok":true,"service":"coop-checkin"}` + +2. `curl 'https://survey.fci.coop/api/health?token=$HEALTH_TOKEN'` + → all checks green. This validates Civi connectivity, the + `Primary Contact` relationship type, the `Check-in (organizing)` + activity type, all six custom groups, the Stage option group, and + the option-group ID match. + +3. Pick a test Individual contact that has a `Primary Contact` + relationship to a test Organization. Generate a Civi checksum for + that Individual and load: + `https://survey.fci.coop/?cid=&cs=` — the form should + load with prefill. + +4. Submit a stub answer. Then in Civi, pull the new activity: + + ```bash + cv api4 Activity.get \ + where='[["activity_type_id:name","=","Check-in (organizing)"]]' \ + orderBy='{"id":"DESC"}' limit=1 \ + select='["id","subject","Check_in_data__organizing_.Stage"]' + ``` + + - `Stage` must be **null** (not "Unknown"). If it's "Unknown", + step 2 above wasn't completed on this CRM. + - `subject` should be `Co-op Survey (form submission)`. + +5. Open the target Organization in CiviCRM → **Engagement Report** + tab. The staff report should render the submission you just made. + +--- + +## Change log + +When you discover a new manual prerequisite, append a short note here +so the rationale survives. + +- **2026-06-08** — Documented the field-242 "Unknown" default issue + after a production submission was stamped `Stage = "Unknown"`. + Cleared via `CustomField.update`; see step 2.