* feat(funnel): add per-form required field list Single source for which funnel answers are required, plus the check the intake endpoint runs a submission against. * refactor(web): build the funnel form schema from the shared list Form definitions no longer carry a `required` flag, they describe rendering only. The three pages pass REQUIRED_FIELDS_BY_FORM into ConnectFormSection, which derives the zod schema and the required markers from it. * fix(civi-crm): validate required funnel fields on afform-submit Only formName, the hear-about answer and the captcha were checked, so a direct POST could create a record with no name, email or country. A payload missing a required answer now gets a 400 naming the fields. * docs(funnel): document the required-field validation
4.6 KiB
civi-crm — Architecture Document
Node ≥ 24, pnpm 11.1
1. Overview
civi-crm is a Next.js application in apps/civi-crm that hosts the public intake endpoint for the funnel forms on apps/web. It has no database, no pages and no authenticated area.
It used to be a read/write web layer on top of CiviCRM for Circle Case management. The CiviCRM instances were shut down and every interaction with them was removed in logos-web#123; the app was kept for the one endpoint other projects still depend on. The funnel itself is documented in docs/funnel/AGENTS.md, which is the reference for anything about form submissions.
| Route | Purpose |
|---|---|
POST /api/public/afform-submit |
Public intake endpoint for the three funnel forms |
The app used to sit behind an Infra-managed Keycloak reverse proxy, which exempted /api/public/*. Nothing in the app reads the proxy headers anymore and there is no authenticated area left, so the proxy is no longer needed in front of it. Whether it stays is an Infra decision; the app behaves the same either way.
2. Tech Stack
| Concern | Choice | Rationale |
|---|---|---|
| Framework | Next.js 16 | Route Handlers plus middleware; no pages are rendered |
| Language | TypeScript (strict null checks, bundler module resolution) | Consistent with packages/config/typescript/base.json |
| Testing | Vitest | Mocks the outbound HTTP calls (Notion, n8n, hCaptcha) |
| Database | None | Submissions are forwarded to Notion and n8n |
| Linting | @repo/config/eslint/next |
Same as apps/web |
Dev server runs on port 3002.
3. File Architecture
apps/civi-crm/
├── src/
│ ├── app/
│ │ └── api/public/afform-submit/
│ │ ├── route.ts # Orchestrator: validation, captcha, fan-out
│ │ └── __tests__/route.test.ts
│ ├── lib/
│ │ ├── intake-submit-flags.ts # FUNNEL_INTAKE_NOTION_DISABLED
│ │ ├── public-cors.ts # Allowed origins for /api/public/*
│ │ ├── notion/ # Property builder + page POST
│ │ └── n8n/ # Steward webhook payload + POST
│ └── middleware.ts # CORS headers on /api/public/*
├── next.config.mjs
└── vitest.config.ts
4. Request Handling
POST /api/public/afform-submit is the only handler. In order:
- Parse the body; reject a
formNameoutside the three allowed funnel forms. - Reject a submission whose
hearAboutanswer is missing or is not a known option id. Enforced server-side so a tampered submission cannot skip the field. - Reject a submission missing any other field the form marks required, or whose
emailis not a plausible address. The list lives in@repo/funnel(REQUIRED_FIELDS_BY_FORM), whichapps/webalso builds its form schema from; the400names the offendingfields. - Verify the hCaptcha token when
HCAPTCHA_SECRETis set. Tokens are single-use, so this happens once and every destination write shares that one verification. - Forward the steward form to the n8n/Baserow webhook. Best-effort: a failure is logged and does not fail the request.
- Write the Notion page unless
FUNNEL_INTAKE_NOTION_DISABLEDis truthy. A failure returns502.
The legacy fields[] key (CiviCRM Afform field definitions) is dropped from the payload if a client still sends it.
CORS
src/middleware.ts answers preflight requests and sets the response headers for /api/public/*, using the allowlist in src/lib/public-cors.ts: logos.co in production, plus localhost and logos-co-web*.vercel.app previews elsewhere, plus anything in CORS_ALLOWED_ORIGINS.
5. Environment Variables
See apps/civi-crm/.env.example for the full list with comments.
| Variable | Purpose |
|---|---|
HCAPTCHA_SECRET |
Verify captcha tokens; unset = captcha not verified |
NOTION_API_TOKEN, NOTION_DB_ID |
Notion intake target |
NOTION_DATA_SOURCE_ID |
Pins writes when the database holds multiple data sources |
FUNNEL_INTAKE_NOTION_DISABLED |
Skip the Notion write |
N8N_STEWARD_WEBHOOK_TOKEN |
X-Webhook-Token for the steward webhook; unset = forward skipped |
CORS_ALLOWED_ORIGINS |
Extra comma-separated origins for /api/public/* |
6. Testing
pnpm --filter civi-crm test
Vitest covers the endpoint behaviour, the Notion property mapping, the n8n payload building, the env flag, and the CORS allowlist.