diff --git a/apps/civi-crm/.env.example b/apps/civi-crm/.env.example index 31d3f97a1f..48be976757 100644 --- a/apps/civi-crm/.env.example +++ b/apps/civi-crm/.env.example @@ -17,5 +17,14 @@ KEYCLOAK_USER_EMAIL_HEADER= # Development mock: overrides Keycloak header — set in .env.local only, never in staging/production DEV_USER_EMAIL_MOCK= +# Notion integration — intake funnel (all three connect forms) +NOTION_API_TOKEN= +NOTION_DB_ID= + +# Optional: set to 1, true, yes, or on to skip that destination on POST /api/public/afform-submit. +# Unset = submit to that destination (default). +FUNNEL_INTAKE_NOTION_DISABLED= +FUNNEL_INTAKE_CIVICRM_DISABLED= + # Set to DEBUG to log every CiviCRM HTTP request (URL, body, status, latency) to the console # LOG_LEVEL=DEBUG diff --git a/apps/civi-crm/AGENTS.md b/apps/civi-crm/AGENTS.md index 9cd5536914..50344ed604 100644 --- a/apps/civi-crm/AGENTS.md +++ b/apps/civi-crm/AGENTS.md @@ -52,4 +52,7 @@ pnpm --filter civi-crm test ## Environment -See `.env.local.example` for all required variables. Set `DEV_USER_EMAIL_MOCK` in `.env.local` during local development to mock Keycloak identity. This variable must not be set in staging or production. +See `.env.local.example` for all required variables. + +- **Local development**: set `DEV_USER_EMAIL_MOCK` in `.env.local` to mock Keycloak identity. This variable must not be set in staging or production. +- **Intake funnel (public endpoint)**: `POST /api/public/afform-submit` is used by the three connect forms on `apps/web`. If Notion intake is enabled in a non-local environment, ensure `NOTION_API_TOKEN` and `NOTION_DB_ID` are set in that deployment. You can opt out per destination without code changes via `FUNNEL_INTAKE_NOTION_DISABLED` and `FUNNEL_INTAKE_CIVICRM_DISABLED`. diff --git a/apps/civi-crm/next.config.mjs b/apps/civi-crm/next.config.mjs index 460ed4b26e..242844057f 100644 --- a/apps/civi-crm/next.config.mjs +++ b/apps/civi-crm/next.config.mjs @@ -8,6 +8,18 @@ const nextConfig = { cacheComponents: true, transpilePackages: ['@acid-info/logos-ui'], turbopack: { root: workspaceRoot }, + async headers() { + return [ + { + source: '/api/public/:path*', + headers: [ + { key: 'Access-Control-Allow-Origin', value: '*' }, + { key: 'Access-Control-Allow-Methods', value: 'POST, OPTIONS' }, + { key: 'Access-Control-Allow-Headers', value: 'Content-Type' }, + ], + }, + ] + }, } export default nextConfig diff --git a/apps/civi-crm/src/app/api/public/afform-submit/route.ts b/apps/civi-crm/src/app/api/public/afform-submit/route.ts index 59d77005e9..0a793653d3 100644 --- a/apps/civi-crm/src/app/api/public/afform-submit/route.ts +++ b/apps/civi-crm/src/app/api/public/afform-submit/route.ts @@ -1,13 +1,13 @@ import { type NextRequest, NextResponse } from 'next/server' -import { isAfformIntakeFormName } from '@/lib/civicrm/afform-case-defaults' +import { type AfformFieldDef } from '@/lib/civicrm/build-afform-values' +import { submitToCiviCrm } from '@/lib/civicrm/submit-afform' import { - buildAfformValues, - type AfformFieldDef, -} from '@/lib/civicrm/build-afform-values' + isCiviCrmIntakeSubmitEnabled, + isNotionIntakeSubmitEnabled, +} from '@/lib/intake-submit-flags' +import { submitToNotion } from '@/lib/notion/submit' -const CIVICRM_BASE_URL = process.env.CIVICRM_BASE_URL ?? '' -const CIVICRM_API_KEY = process.env.CIVICRM_API_KEY ?? '' const HCAPTCHA_SECRET = process.env.HCAPTCHA_SECRET ?? '' const ALLOWED_FORMS = new Set([ @@ -16,7 +16,10 @@ const ALLOWED_FORMS = new Set([ 'afformCoalitionPartner', ]) -async function verifyHCaptcha(token: string, remoteip: string): Promise { +async function verifyHCaptcha( + token: string, + remoteip: string +): Promise { const res = await fetch('https://api.hcaptcha.com/siteverify', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, @@ -38,25 +41,27 @@ function getClientIp(req: NextRequest): string { ) } -export async function POST(req: NextRequest) { - if (!CIVICRM_BASE_URL || !CIVICRM_API_KEY) { - return NextResponse.json( - { error: 'Server configuration error' }, - { status: 500 } - ) - } +function jsonResponse( + body: Record, + status: number +): NextResponse { + return NextResponse.json(body, { status }) +} +export async function POST(req: NextRequest) { let body: Record try { body = await req.json() } catch { - return NextResponse.json( - { error: 'Invalid request body' }, - { status: 400 } - ) + return jsonResponse({ error: 'Invalid request body' }, 400) } - const { formName, captchaToken, fields: fieldDefs, ...formData } = body as { + const { + formName, + captchaToken, + fields: fieldDefs, + ...formData + } = body as { formName?: string captchaToken?: string fields?: AfformFieldDef[] @@ -64,64 +69,58 @@ export async function POST(req: NextRequest) { } if (!formName || !ALLOWED_FORMS.has(formName)) { - return NextResponse.json({ error: 'Invalid form name' }, { status: 400 }) + return jsonResponse({ error: 'Invalid form name' }, 400) } if (!fieldDefs || !Array.isArray(fieldDefs)) { - return NextResponse.json( - { error: 'Missing field definitions' }, - { status: 400 } - ) + return jsonResponse({ error: 'Missing field definitions' }, 400) } if (HCAPTCHA_SECRET) { if (!captchaToken || typeof captchaToken !== 'string') { - return NextResponse.json( - { error: 'Captcha token missing' }, - { status: 400 } - ) + return jsonResponse({ error: 'Captcha token missing' }, 400) } const valid = await verifyHCaptcha(captchaToken, getClientIp(req)) if (!valid) { - return NextResponse.json( - { error: 'Captcha verification failed' }, - { status: 403 } + return jsonResponse({ error: 'Captcha verification failed' }, 403) + } + } + + const notionEnabled = isNotionIntakeSubmitEnabled() + const civiEnabled = isCiviCrmIntakeSubmitEnabled() + + if (!notionEnabled && !civiEnabled) { + return jsonResponse({ success: true }, 201) + } + + if (notionEnabled) { + const notionResult = await submitToNotion(formData, formName) + if (!notionResult.ok) { + return jsonResponse( + { + error: 'Failed to submit form. Please try again.', + detail: notionResult.message, + }, + 502 ) } } - try { - const values = buildAfformValues( - formData, - fieldDefs, - isAfformIntakeFormName(formName) ? formName : undefined - ) - const params = JSON.stringify({ name: formName, values, args: {} }) - - const res = await fetch( - `${CIVICRM_BASE_URL}/civicrm/ajax/api4/Afform/submit`, - { - method: 'POST', - headers: { - 'X-Civi-Auth': `Bearer ${CIVICRM_API_KEY}`, - 'X-Requested-With': 'XMLHttpRequest', - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: `params=${encodeURIComponent(params)}`, + if (civiEnabled) { + const civiResult = await submitToCiviCrm(formData, fieldDefs, formName) + if (!civiResult.ok) { + if (!notionEnabled) { + return jsonResponse( + { + error: 'Failed to submit form. Please try again.', + detail: civiResult.message, + }, + 502 + ) } - ) - - if (!res.ok) { - const text = await res.text() - throw new Error(`Afform.submit (${res.status}): ${text.slice(0, 200)}`) + return jsonResponse({ success: true, detail: civiResult.message }, 201) } - - return NextResponse.json({ success: true }, { status: 201 }) - } catch (e) { - const message = e instanceof Error ? e.message : 'Unknown error' - return NextResponse.json( - { error: 'Failed to submit form. Please try again.', detail: message }, - { status: 502 } - ) } + + return jsonResponse({ success: true }, 201) } diff --git a/apps/civi-crm/src/lib/__tests__/intake-submit-flags.test.ts b/apps/civi-crm/src/lib/__tests__/intake-submit-flags.test.ts new file mode 100644 index 0000000000..cca7ecbb39 --- /dev/null +++ b/apps/civi-crm/src/lib/__tests__/intake-submit-flags.test.ts @@ -0,0 +1,50 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { + isCiviCrmIntakeSubmitEnabled, + isNotionIntakeSubmitEnabled, +} from '../intake-submit-flags' + +describe('intake submit flags', () => { + const originalEnv = process.env + + beforeEach(() => { + process.env = { ...originalEnv } + delete process.env.FUNNEL_INTAKE_NOTION_DISABLED + delete process.env.FUNNEL_INTAKE_CIVICRM_DISABLED + }) + + afterEach(() => { + process.env = originalEnv + }) + + it('enables both destinations by default', () => { + expect(isNotionIntakeSubmitEnabled()).toBe(true) + expect(isCiviCrmIntakeSubmitEnabled()).toBe(true) + }) + + it.each(['1', 'true', 'TRUE', ' yes ', 'on'])( + 'disables Notion when FUNNEL_INTAKE_NOTION_DISABLED=%s', + (value) => { + process.env.FUNNEL_INTAKE_NOTION_DISABLED = value + expect(isNotionIntakeSubmitEnabled()).toBe(false) + expect(isCiviCrmIntakeSubmitEnabled()).toBe(true) + } + ) + + it.each(['1', 'true', 'yes'])( + 'disables CiviCRM when FUNNEL_INTAKE_CIVICRM_DISABLED=%s', + (value) => { + process.env.FUNNEL_INTAKE_CIVICRM_DISABLED = value + expect(isCiviCrmIntakeSubmitEnabled()).toBe(false) + expect(isNotionIntakeSubmitEnabled()).toBe(true) + } + ) + + it('treats non-truthy disable values as enabled', () => { + process.env.FUNNEL_INTAKE_NOTION_DISABLED = 'false' + process.env.FUNNEL_INTAKE_CIVICRM_DISABLED = '0' + expect(isNotionIntakeSubmitEnabled()).toBe(true) + expect(isCiviCrmIntakeSubmitEnabled()).toBe(true) + }) +}) diff --git a/apps/civi-crm/src/lib/civicrm/__tests__/submit-afform.test.ts b/apps/civi-crm/src/lib/civicrm/__tests__/submit-afform.test.ts new file mode 100644 index 0000000000..d7963f3c41 --- /dev/null +++ b/apps/civi-crm/src/lib/civicrm/__tests__/submit-afform.test.ts @@ -0,0 +1,87 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { submitToCiviCrm } from '../submit-afform' + +const fieldDefs = [ + { + entity: 'Individual1', + formKey: 'name', + fieldName: 'first_name', + join: null, + inputType: 'text', + }, +] + +describe('submitToCiviCrm', () => { + const originalEnv = process.env + + beforeEach(() => { + process.env = { + ...originalEnv, + CIVICRM_BASE_URL: 'https://civi.example', + CIVICRM_API_KEY: 'test-key', + } + vi.stubGlobal('fetch', vi.fn()) + }) + + afterEach(() => { + process.env = originalEnv + vi.unstubAllGlobals() + }) + + it('returns ok when Afform.submit succeeds', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + text: async () => '', + } as Response) + + const result = await submitToCiviCrm( + { name: 'Ada' }, + fieldDefs, + 'afformActivistBuilder' + ) + + expect(result).toEqual({ ok: true }) + expect(fetch).toHaveBeenCalledWith( + 'https://civi.example/civicrm/ajax/api4/Afform/submit', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'X-Civi-Auth': 'Bearer test-key', + }), + }) + ) + }) + + it('returns failure when CiviCRM is not configured', async () => { + delete process.env.CIVICRM_BASE_URL + + const result = await submitToCiviCrm( + { name: 'Ada' }, + fieldDefs, + 'afformActivistBuilder' + ) + + expect(result).toEqual({ ok: false, message: 'CiviCRM is not configured' }) + expect(fetch).not.toHaveBeenCalled() + }) + + it('returns failure when Afform.submit responds with an error', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: false, + status: 500, + text: async () => 'internal error', + } as Response) + + const result = await submitToCiviCrm( + { name: 'Ada' }, + fieldDefs, + 'afformActivistBuilder' + ) + + expect(result).toEqual({ + ok: false, + message: 'Afform.submit (500): internal error', + }) + }) +}) diff --git a/apps/civi-crm/src/lib/civicrm/submit-afform.ts b/apps/civi-crm/src/lib/civicrm/submit-afform.ts new file mode 100644 index 0000000000..de6b2f39e0 --- /dev/null +++ b/apps/civi-crm/src/lib/civicrm/submit-afform.ts @@ -0,0 +1,57 @@ +import { isAfformIntakeFormName } from './afform-case-defaults' +import { + buildAfformValues, + type AfformFieldDef, +} from './build-afform-values' + +export type CiviCrmSubmitResult = + | { ok: true } + | { ok: false; message: string } + +export async function submitToCiviCrm( + formData: Record, + fieldDefs: AfformFieldDef[], + formName: string +): Promise { + const baseUrl = process.env.CIVICRM_BASE_URL ?? '' + const apiKey = process.env.CIVICRM_API_KEY ?? '' + + if (!baseUrl || !apiKey) { + return { ok: false, message: 'CiviCRM is not configured' } + } + + try { + const values = buildAfformValues( + formData, + fieldDefs, + isAfformIntakeFormName(formName) ? formName : undefined + ) + const params = JSON.stringify({ name: formName, values, args: {} }) + + const res = await fetch( + `${baseUrl}/civicrm/ajax/api4/Afform/submit`, + { + method: 'POST', + headers: { + 'X-Civi-Auth': `Bearer ${apiKey}`, + 'X-Requested-With': 'XMLHttpRequest', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: `params=${encodeURIComponent(params)}`, + } + ) + + if (!res.ok) { + const text = await res.text() + return { + ok: false, + message: `Afform.submit (${res.status}): ${text.slice(0, 200)}`, + } + } + + return { ok: true } + } catch (e) { + const message = e instanceof Error ? e.message : 'Unknown error' + return { ok: false, message } + } +} diff --git a/apps/civi-crm/src/lib/intake-submit-flags.ts b/apps/civi-crm/src/lib/intake-submit-flags.ts new file mode 100644 index 0000000000..c3f2e199aa --- /dev/null +++ b/apps/civi-crm/src/lib/intake-submit-flags.ts @@ -0,0 +1,16 @@ +const TRUTHY = new Set(['1', 'true', 'yes', 'on']) + +function isEnvFlagEnabled(value: string | undefined): boolean { + if (!value?.trim()) return false + return TRUTHY.has(value.trim().toLowerCase()) +} + +/** When `FUNNEL_INTAKE_NOTION_DISABLED` is unset or not truthy, Notion intake submit runs. */ +export function isNotionIntakeSubmitEnabled(): boolean { + return !isEnvFlagEnabled(process.env.FUNNEL_INTAKE_NOTION_DISABLED) +} + +/** When `FUNNEL_INTAKE_CIVICRM_DISABLED` is unset or not truthy, CiviCRM intake submit runs. */ +export function isCiviCrmIntakeSubmitEnabled(): boolean { + return !isEnvFlagEnabled(process.env.FUNNEL_INTAKE_CIVICRM_DISABLED) +} diff --git a/apps/civi-crm/src/lib/notion/__tests__/build-notion-properties.test.ts b/apps/civi-crm/src/lib/notion/__tests__/build-notion-properties.test.ts new file mode 100644 index 0000000000..02fa08296e --- /dev/null +++ b/apps/civi-crm/src/lib/notion/__tests__/build-notion-properties.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest' + +import { + buildNotionProperties, + resolveOrganizationSelect, +} from '../build-notion-properties' + +describe('resolveOrganizationSelect', () => { + it('returns canonical option name on case-insensitive match', () => { + expect( + resolveOrganizationSelect('logos', ['Logos', 'Status']) + ).toBe('Logos') + }) + + it('returns submitted value when no option matches', () => { + expect(resolveOrganizationSelect('New Org', ['Logos'])).toBe('New Org') + }) + + it('returns empty string for blank input', () => { + expect(resolveOrganizationSelect(' ', ['Logos'])).toBe('') + }) +}) + +describe('buildNotionProperties', () => { + const baseData = { + name: 'Ada Lovelace', + email: 'ada@example.com', + city: 'London', + country: '1226', + skills: ['1', '6'], + affiliatedOrgs: 'Logos', + website: ['https://example.com', 'https://logos.co'], + chat: ['adal', 'ada_logos'], + chatService: ['3', '2'], + questions: 'How do I join?', + wantsEvents: true, + wantsNewsletter: false, + } + + it('maps coalition partner fields to funnel properties', () => { + const properties = buildNotionProperties( + { ...baseData, backgroundPartner: 'We build networks.' }, + 'afformCoalitionPartner', + 'Logos' + ) + + expect(properties.Name).toEqual({ + title: [{ type: 'text', text: { content: 'Ada Lovelace' } }], + }) + expect(properties['Email/Website']).toEqual({ email: 'ada@example.com' }) + expect(properties.City).toEqual({ + rich_text: [{ type: 'text', text: { content: 'London' } }], + }) + expect(properties.Country).toEqual({ + rich_text: [{ type: 'text', text: { content: 'United Kingdom' } }], + }) + expect(properties.Organization).toEqual({ select: { name: 'Logos' } }) + expect(properties.Profile).toEqual({ + select: { name: 'Coalition Partner' }, + }) + expect(properties.BU).toEqual({ multi_select: [{ name: 'Movement' }] }) + expect(properties['Mvmt Status']).toEqual({ select: { name: 'New Lead' } }) + expect(properties.Website).toEqual({ + url: 'https://example.com | https://logos.co', + }) + expect(properties['Phone or Social Handle']).toEqual({ + phone_number: 'adal (X) | ada_logos (Telegram)', + }) + expect(properties.Skills).toEqual({ + multi_select: [{ name: 'Developer' }, { name: 'Researcher' }], + }) + expect(properties.Background).toEqual({ + rich_text: [{ type: 'text', text: { content: 'We build networks.' } }], + }) + expect(properties['Tech Vision']).toBeUndefined() + expect(properties['Activities Vision']).toBeUndefined() + expect(properties.Questions).toEqual({ + rich_text: [{ type: 'text', text: { content: 'How do I join?' } }], + }) + expect(properties['Wants Events']).toEqual({ checkbox: true }) + expect(properties['Wants Newsletter']).toEqual({ checkbox: false }) + }) + + it('maps activist builder background and tech vision', () => { + const properties = buildNotionProperties( + { + ...baseData, + backgroundBuilder: 'Builder bio', + techVision: 'Local mesh tools', + }, + 'afformActivistBuilder', + 'Logos' + ) + + expect(properties.BU).toEqual({ multi_select: [{ name: 'Movement' }] }) + expect(properties.Profile).toEqual({ + select: { name: 'Activist Builder' }, + }) + expect(properties.Background).toEqual({ + rich_text: [{ type: 'text', text: { content: 'Builder bio' } }], + }) + expect(properties['Tech Vision']).toEqual({ + rich_text: [{ type: 'text', text: { content: 'Local mesh tools' } }], + }) + }) + + it('maps activist leader background and activities vision', () => { + const properties = buildNotionProperties( + { + ...baseData, + backgroundLeader: 'Leader bio', + activitiesVision: 'Community workshops', + }, + 'afformActivistLeaderSteward', + 'Logos' + ) + + expect(properties.BU).toEqual({ multi_select: [{ name: 'Movement' }] }) + expect(properties.Profile).toEqual({ + select: { name: 'Activist Leader / Steward' }, + }) + expect(properties.Background).toEqual({ + rich_text: [{ type: 'text', text: { content: 'Leader bio' } }], + }) + expect(properties['Activities Vision']).toEqual({ + rich_text: [ + { type: 'text', text: { content: 'Community workshops' } }, + ], + }) + }) + + it('omits Organization when resolved value is empty', () => { + const properties = buildNotionProperties( + baseData, + 'afformCoalitionPartner', + '' + ) + + expect(properties.Organization).toBeUndefined() + }) +}) diff --git a/apps/civi-crm/src/lib/notion/build-notion-properties.ts b/apps/civi-crm/src/lib/notion/build-notion-properties.ts new file mode 100644 index 0000000000..fc7ce04a76 --- /dev/null +++ b/apps/civi-crm/src/lib/notion/build-notion-properties.ts @@ -0,0 +1,146 @@ +import { + isAfformIntakeFormName, + type AfformIntakeFormName, +} from '@/lib/civicrm/afform-case-defaults' + +import { + CHAT_SERVICE_MAP, + COUNTRY_MAP, + BU_MOVEMENT, + MVMT_STATUS_NEW_LEAD, + PROFILE_BY_FORM, + SKILLS_MAP, +} from './maps' + +export type NotionPageProperties = Record + +function toArray(v: unknown): string[] { + return Array.isArray(v) ? (v as string[]) : v ? [String(v)] : [] +} + +function trim(s: unknown): string { + return s && typeof s === 'string' ? s.trim() : '' +} + +function richText(content: string): NotionPageProperties { + return { + rich_text: [{ type: 'text', text: { content: content.slice(0, 2000) } }], + } +} + +function optionalRichText(content: string): NotionPageProperties | undefined { + if (!content) return undefined + return richText(content) +} + +export function resolveOrganizationSelect( + submitted: string, + existingOptions: readonly string[] +): string { + const value = submitted.trim() + if (!value) return '' + const lower = value.toLowerCase() + const match = existingOptions.find((option) => option.toLowerCase() === lower) + return match ?? value +} + +function getBackground(data: Record): string { + return ( + trim(data.backgroundPartner) || + trim(data.backgroundBuilder) || + trim(data.backgroundLeader) || + '' + ) +} + +function getProfileName(formName: string): string | undefined { + if (!isAfformIntakeFormName(formName)) return undefined + return PROFILE_BY_FORM[formName as AfformIntakeFormName] +} + +export function buildNotionProperties( + data: Record, + formName: string, + organizationSelect: string +): NotionPageProperties { + const name = trim(data.name) || 'Unknown' + const email = trim(data.email) + const city = trim(data.city) + const countryId = trim(data.country) + const country = COUNTRY_MAP[countryId] ?? countryId + + const background = getBackground(data) + const techVision = trim(data.techVision) + const activitiesVision = trim(data.activitiesVision) + const questions = trim(data.questions) + const wantsEvents = data.wantsEvents === true + const wantsNewsletter = data.wantsNewsletter === true + + const skillIds = toArray(data.skills) + const skillNames = skillIds + .map((id) => SKILLS_MAP[id] ?? id) + .filter(Boolean) + .map((skillName) => ({ name: skillName })) + + const websiteArr = toArray(data.website).map(trim).filter(Boolean) + const websitesStr = websiteArr.join(' | ') + + const chatArr = toArray(data.chat).map(trim) + const chatServiceArr = toArray(data.chatService).map(trim) + const chatPairs = chatArr + .map((handle, i): string | null => { + if (!handle) return null + const svcId = chatServiceArr[i] ?? '' + const svcLabel = CHAT_SERVICE_MAP[svcId] ?? svcId + return svcLabel ? `${handle} (${svcLabel})` : handle + }) + .filter((v): v is string => v !== null) + const chatStr = chatPairs.join(' | ') + + const profileName = getProfileName(formName) + + const properties: NotionPageProperties = { + Name: { title: [{ type: 'text', text: { content: name } }] }, + BU: { multi_select: [{ name: BU_MOVEMENT }] }, + 'Mvmt Status': { select: { name: MVMT_STATUS_NEW_LEAD } }, + 'Wants Events': { checkbox: wantsEvents }, + 'Wants Newsletter': { checkbox: wantsNewsletter }, + Skills: { multi_select: skillNames }, + } + + if (email) { + properties['Email/Website'] = { email } + } + if (city) { + properties.City = richText(city) + } + if (country) { + properties.Country = richText(country) + } + if (organizationSelect) { + properties.Organization = { select: { name: organizationSelect } } + } + if (profileName) { + properties.Profile = { select: { name: profileName } } + } + if (websitesStr) { + properties.Website = { url: websitesStr } + } + if (chatStr) { + properties['Phone or Social Handle'] = { phone_number: chatStr } + } + + const backgroundProp = optionalRichText(background) + if (backgroundProp) properties.Background = backgroundProp + + const techVisionProp = optionalRichText(techVision) + if (techVisionProp) properties['Tech Vision'] = techVisionProp + + const activitiesVisionProp = optionalRichText(activitiesVision) + if (activitiesVisionProp) properties['Activities Vision'] = activitiesVisionProp + + const questionsProp = optionalRichText(questions) + if (questionsProp) properties.Questions = questionsProp + + return properties +} diff --git a/apps/civi-crm/src/lib/notion/maps.ts b/apps/civi-crm/src/lib/notion/maps.ts new file mode 100644 index 0000000000..fcfca0c5e9 --- /dev/null +++ b/apps/civi-crm/src/lib/notion/maps.ts @@ -0,0 +1,295 @@ +import type { AfformIntakeFormName } from '@/lib/civicrm/afform-case-defaults' + +export const SKILLS_MAP: Record = { + '1': 'Developer', + '2': 'Web3 builder', + '3': 'Privacy domain expert', + '4': 'Website developer', + '5': 'Product designer', + '6': 'Researcher', + '7': 'Activist', + '8': 'Project manager', + '9': 'Community builder', + '10': 'Thought leader / Influencer', + '11': 'Creative', + '12': 'Marketer', + '13': 'Fundraiser', + '14': 'Educator', + '15': 'Policy advocate', + '16': 'Translator', +} + +export const CHAT_SERVICE_MAP: Record = { + '1': 'Discord', + '2': 'Telegram', + '3': 'X', + '4': 'Farcaster', + '5': 'Signal', + '6': 'GitHub', + '7': 'Status App', + '8': 'WhatsApp', + '9': 'Other', +} + +export const PROFILE_BY_FORM: Record = { + afformCoalitionPartner: 'Coalition Partner', + afformActivistBuilder: 'Activist Builder', + afformActivistLeaderSteward: 'Activist Leader / Steward', +} + +export const MVMT_STATUS_NEW_LEAD = 'New Lead' + +export const BU_MOVEMENT = 'Movement' + +export const COUNTRY_MAP: Record = { + '1001': 'Afghanistan', + '1002': 'Albania', + '1003': 'Algeria', + '1004': 'American Samoa', + '1005': 'Andorra', + '1006': 'Angola', + '1007': 'Anguilla', + '1008': 'Antarctica', + '1009': 'Antigua and Barbuda', + '1010': 'Argentina', + '1011': 'Armenia', + '1012': 'Aruba', + '1013': 'Australia', + '1014': 'Austria', + '1015': 'Azerbaijan', + '1016': 'Bahrain', + '1017': 'Bangladesh', + '1018': 'Barbados', + '1019': 'Belarus', + '1020': 'Belgium', + '1021': 'Belize', + '1022': 'Benin', + '1023': 'Bermuda', + '1024': 'Bhutan', + '1025': 'Bolivia', + '1026': 'Bosnia and Herzegovina', + '1027': 'Botswana', + '1028': 'Bouvet Island', + '1029': 'Brazil', + '1030': 'British Indian Ocean Territory', + '1031': 'Virgin Islands, British', + '1032': 'Brunei Darussalam', + '1033': 'Bulgaria', + '1034': 'Burkina Faso', + '1035': 'Myanmar', + '1036': 'Burundi', + '1037': 'Cambodia', + '1038': 'Cameroon', + '1039': 'Canada', + '1040': 'Cape Verde', + '1041': 'Cayman Islands', + '1042': 'Central African Republic', + '1043': 'Chad', + '1044': 'Chile', + '1045': 'China', + '1046': 'Christmas Island', + '1047': 'Cocos (Keeling) Islands', + '1048': 'Colombia', + '1049': 'Comoros', + '1050': 'Congo, The Democratic Republic of the', + '1051': 'Congo, Republic of the', + '1052': 'Cook Islands', + '1053': 'Costa Rica', + '1054': "Côte d'Ivoire", + '1055': 'Croatia', + '1056': 'Cuba', + '1057': 'Cyprus', + '1058': 'Czech Republic', + '1059': 'Denmark', + '1060': 'Djibouti', + '1061': 'Dominica', + '1062': 'Dominican Republic', + '1063': 'Timor-Leste', + '1064': 'Ecuador', + '1065': 'Egypt', + '1066': 'El Salvador', + '1067': 'Equatorial Guinea', + '1068': 'Eritrea', + '1069': 'Estonia', + '1070': 'Ethiopia', + '1072': 'Falkland Islands (Malvinas)', + '1073': 'Faroe Islands', + '1074': 'Fiji', + '1075': 'Finland', + '1076': 'France', + '1077': 'French Guiana', + '1078': 'French Polynesia', + '1079': 'French Southern Territories', + '1080': 'Gabon', + '1081': 'Georgia', + '1082': 'Germany', + '1083': 'Ghana', + '1084': 'Gibraltar', + '1085': 'Greece', + '1086': 'Greenland', + '1087': 'Grenada', + '1088': 'Guadeloupe', + '1089': 'Guam', + '1090': 'Guatemala', + '1091': 'Guinea', + '1092': 'Guinea-Bissau', + '1093': 'Guyana', + '1094': 'Haiti', + '1095': 'Heard Island and McDonald Islands', + '1096': 'Holy See (Vatican City State)', + '1097': 'Honduras', + '1098': 'Hong Kong', + '1099': 'Hungary', + '1100': 'Iceland', + '1101': 'India', + '1102': 'Indonesia', + '1103': 'Iran, Islamic Republic of', + '1104': 'Iraq', + '1105': 'Ireland', + '1106': 'Israel', + '1107': 'Italy', + '1108': 'Jamaica', + '1109': 'Japan', + '1110': 'Jordan', + '1111': 'Kazakhstan', + '1112': 'Kenya', + '1113': 'Kiribati', + '1114': "Korea, Democratic People's Republic of", + '1115': 'Korea, Republic of', + '1116': 'Kuwait', + '1117': 'Kyrgyzstan', + '1118': "Lao People's Democratic Republic", + '1119': 'Latvia', + '1120': 'Lebanon', + '1121': 'Lesotho', + '1122': 'Liberia', + '1123': 'Libya', + '1124': 'Liechtenstein', + '1125': 'Lithuania', + '1126': 'Luxembourg', + '1127': 'Macao', + '1128': 'North Macedonia', + '1129': 'Madagascar', + '1130': 'Malawi', + '1131': 'Malaysia', + '1132': 'Maldives', + '1133': 'Mali', + '1134': 'Malta', + '1135': 'Marshall Islands', + '1136': 'Martinique', + '1137': 'Mauritania', + '1138': 'Mauritius', + '1139': 'Mayotte', + '1140': 'Mexico', + '1141': 'Micronesia, Federated States of', + '1142': 'Moldova', + '1143': 'Monaco', + '1144': 'Mongolia', + '1145': 'Montserrat', + '1146': 'Morocco', + '1147': 'Mozambique', + '1148': 'Namibia', + '1149': 'Nauru', + '1150': 'Nepal', + '1152': 'Netherlands', + '1153': 'New Caledonia', + '1154': 'New Zealand', + '1155': 'Nicaragua', + '1156': 'Niger', + '1157': 'Nigeria', + '1158': 'Niue', + '1159': 'Norfolk Island', + '1160': 'Northern Mariana Islands', + '1161': 'Norway', + '1162': 'Oman', + '1163': 'Pakistan', + '1164': 'Palau', + '1165': 'Palestine, State of', + '1166': 'Panama', + '1167': 'Papua New Guinea', + '1168': 'Paraguay', + '1169': 'Peru', + '1170': 'Philippines', + '1171': 'Pitcairn', + '1172': 'Poland', + '1173': 'Portugal', + '1174': 'Puerto Rico', + '1175': 'Qatar', + '1176': 'Romania', + '1177': 'Russian Federation', + '1178': 'Rwanda', + '1179': 'Reunion', + '1180': 'Saint Helena', + '1181': 'Saint Kitts and Nevis', + '1182': 'Saint Lucia', + '1183': 'Saint Pierre and Miquelon', + '1184': 'Saint Vincent and the Grenadines', + '1185': 'Samoa', + '1186': 'San Marino', + '1187': 'Saudi Arabia', + '1188': 'Senegal', + '1189': 'Seychelles', + '1190': 'Sierra Leone', + '1191': 'Singapore', + '1192': 'Slovakia', + '1193': 'Slovenia', + '1194': 'Solomon Islands', + '1195': 'Somalia', + '1196': 'South Africa', + '1197': 'South Georgia and the South Sandwich Islands', + '1198': 'Spain', + '1199': 'Sri Lanka', + '1200': 'Sudan', + '1201': 'Suriname', + '1202': 'Svalbard and Jan Mayen', + '1203': 'Eswatini', + '1204': 'Sweden', + '1205': 'Switzerland', + '1206': 'Syrian Arab Republic', + '1207': 'Sao Tome and Principe', + '1208': 'Taiwan', + '1209': 'Tajikistan', + '1210': 'Tanzania, United Republic of', + '1211': 'Thailand', + '1212': 'Bahamas', + '1213': 'Gambia', + '1214': 'Togo', + '1215': 'Tokelau', + '1216': 'Tonga', + '1217': 'Trinidad and Tobago', + '1218': 'Tunisia', + '1219': 'Turkey', + '1220': 'Turkmenistan', + '1221': 'Turks and Caicos Islands', + '1222': 'Tuvalu', + '1223': 'Uganda', + '1224': 'Ukraine', + '1225': 'United Arab Emirates', + '1226': 'United Kingdom', + '1227': 'United States Minor Outlying Islands', + '1228': 'United States', + '1229': 'Uruguay', + '1230': 'Uzbekistan', + '1231': 'Vanuatu', + '1232': 'Venezuela', + '1233': 'Viet Nam', + '1234': 'Virgin Islands, U.S.', + '1235': 'Wallis and Futuna', + '1236': 'Western Sahara', + '1237': 'Yemen', + '1239': 'Zambia', + '1240': 'Zimbabwe', + '1241': 'Åland Islands', + '1242': 'Serbia', + '1243': 'Montenegro', + '1244': 'Jersey', + '1245': 'Guernsey', + '1246': 'Isle of Man', + '1247': 'South Sudan', + '1248': 'Curaçao', + '1249': 'Sint Maarten (Dutch Part)', + '1250': 'Bonaire, Saint Eustatius and Saba', + '1251': 'Kosovo', + '1252': 'Saint Barthélemy', + '1253': 'Saint Martin (French part)', +} diff --git a/apps/civi-crm/src/lib/notion/submit.ts b/apps/civi-crm/src/lib/notion/submit.ts new file mode 100644 index 0000000000..4002159a5f --- /dev/null +++ b/apps/civi-crm/src/lib/notion/submit.ts @@ -0,0 +1,165 @@ +import { + buildNotionProperties, + resolveOrganizationSelect, +} from './build-notion-properties' + +const NOTION_API_VERSION = '2026-03-11' + +type NotionSelectProperty = { + type: 'select' + select: { options: { name: string }[] } +} + +type NotionDatabaseResponse = { + properties?: Record +} + +function notionHeaders(token: string): HeadersInit { + return { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'Notion-Version': NOTION_API_VERSION, + } +} + +async function fetchOrganizationOptions( + databaseId: string, + token: string +): Promise { + const res = await fetch(`https://api.notion.com/v1/databases/${databaseId}`, { + headers: notionHeaders(token), + }) + + if (!res.ok) { + const text = await res.text() + throw new Error( + `Notion database GET (${res.status}): ${text.slice(0, 200)}` + ) + } + + const json = (await res.json()) as NotionDatabaseResponse + const organization = json.properties?.Organization + if (!organization || organization.type !== 'select') { + return [] + } + + const selectProp = organization as NotionSelectProperty + return selectProp.select.options.map((option) => option.name) +} + +export type NotionSubmitResult = + | { ok: true } + | { ok: false; message: string } + +export async function submitToNotion( + formData: Record, + formName: string +): Promise { + const token = process.env.NOTION_API_TOKEN ?? '' + const databaseId = process.env.NOTION_DB_ID ?? '' + + if (!token || !databaseId) { + return { ok: false, message: 'Notion is not configured' } + } + + try { + const organizationOptions = await fetchOrganizationOptions( + databaseId, + token + ) + const organizationSelect = resolveOrganizationSelect( + typeof formData.affiliatedOrgs === 'string' + ? formData.affiliatedOrgs + : String(formData.affiliatedOrgs ?? ''), + organizationOptions + ) + + const properties = buildNotionProperties( + formData, + formName, + organizationSelect + ) + + const res = await fetch('https://api.notion.com/v1/pages', { + method: 'POST', + headers: notionHeaders(token), + body: JSON.stringify({ + parent: { database_id: databaseId }, + properties, + children: [ + { + object: 'block', + type: 'heading_2', + heading_2: { + rich_text: [{ type: 'text', text: { content: 'Scorecard' } }], + }, + }, + { + object: 'block', + type: 'bulleted_list_item', + bulleted_list_item: { + rich_text: [ + { type: 'text', text: { content: 'Commitment & Reliability:' } }, + ], + }, + }, + { + object: 'block', + type: 'bulleted_list_item', + bulleted_list_item: { + rich_text: [ + { + type: 'text', + text: { content: 'Facilitation & Distributed Leadership:' }, + }, + ], + }, + }, + { + object: 'block', + type: 'bulleted_list_item', + bulleted_list_item: { + rich_text: [ + { type: 'text', text: { content: 'Execution Ability:' } }, + ], + }, + }, + { + object: 'block', + type: 'bulleted_list_item', + bulleted_list_item: { + rich_text: [ + { + type: 'text', + text: { content: 'Relevant Skills/Experience:' }, + }, + ], + }, + }, + { + object: 'block', + type: 'paragraph', + paragraph: { + rich_text: [ + { type: 'text', text: { content: '⇒ Overall Fit:' } }, + ], + }, + }, + ], + }), + }) + + if (!res.ok) { + const text = await res.text() + return { + ok: false, + message: `Notion API (${res.status}): ${text.slice(0, 200)}`, + } + } + + return { ok: true } + } catch (e) { + const message = e instanceof Error ? e.message : 'Unknown error' + return { ok: false, message } + } +} diff --git a/apps/web/components/sections/connect/connect-form-section.tsx b/apps/web/components/sections/connect/connect-form-section.tsx index 144a5a51ff..636448eb1e 100644 --- a/apps/web/components/sections/connect/connect-form-section.tsx +++ b/apps/web/components/sections/connect/connect-form-section.tsx @@ -16,7 +16,11 @@ import { import { Button } from '@/components/ui/button' import { buildFormSchema } from '@/lib/civicrm/contactFormSchema' -import type { AfformConfig, AfformField, AfformOptions } from '@/lib/civicrm/types' +import type { + AfformConfig, + AfformField, + AfformOptions, +} from '@/lib/civicrm/types' import { cn } from '@/lib/cn' import { getOptionsForField } from './get-field-options' @@ -55,7 +59,7 @@ function buildInitialData(fields: AfformField[]): FormValues { } else if (field.repeatable) { data[field.formKey] = [''] } else { - data[field.formKey] = field.inputType === 'checkbox' ? true : '' + data[field.formKey] = field.inputType === 'checkbox' ? false : '' } } return data @@ -128,7 +132,8 @@ export function ConnectFormSection({ }, [validate]) const handleChange = - (field: string) => (event: ChangeEvent | string) => { + (field: string) => + (event: ChangeEvent | string) => { const value = typeof event === 'string' ? event : event.target.value setFormData((prev) => ({ ...prev, [field]: value })) } @@ -145,8 +150,7 @@ export function ConnectFormSection({ arr = [...current.map(String)] } else { const single = String(current ?? '') - arr = - single.trim() !== '' ? [single, ''] : [single] + arr = single.trim() !== '' ? [single, ''] : [single] } arr[index] = value return { ...prev, [fieldKey]: arr } @@ -177,7 +181,9 @@ export function ConnectFormSection({ setFormData((prev) => ({ ...prev, chat: [ - ...(Array.isArray(prev.chat) ? prev.chat.map(String) : [String(prev.chat ?? '')]), + ...(Array.isArray(prev.chat) + ? prev.chat.map(String) + : [String(prev.chat ?? '')]), '', ], chatService: [ @@ -211,20 +217,21 @@ export function ConnectFormSection({ setFormData((prev) => ({ ...prev, [field]: event.target.checked })) } - const handleMultiselectToggle = (fieldKey: string, optionValue: string) => () => { - setFormData((prev) => { - const current = prev[fieldKey] - const arr = Array.isArray(current) - ? [...current.map(String)] - : current - ? [String(current)] - : [] - const idx = arr.indexOf(optionValue) - const next = - idx === -1 ? [...arr, optionValue] : arr.filter((_, i) => i !== idx) - return { ...prev, [fieldKey]: next } - }) - } + const handleMultiselectToggle = + (fieldKey: string, optionValue: string) => () => { + setFormData((prev) => { + const current = prev[fieldKey] + const arr = Array.isArray(current) + ? [...current.map(String)] + : current + ? [String(current)] + : [] + const idx = arr.indexOf(optionValue) + const next = + idx === -1 ? [...arr, optionValue] : arr.filter((_, i) => i !== idx) + return { ...prev, [fieldKey]: next } + }) + } const onSubmit = async (e: FormEvent) => { e.preventDefault() @@ -489,9 +496,7 @@ export function ConnectFormSection({ @@ -535,10 +540,7 @@ export function ConnectFormSection({ let values = Array.isArray(value) ? value.map(String) : [String(value ?? '')] - if ( - values.length === 1 && - values[0].trim() !== '' - ) { + if (values.length === 1 && values[0].trim() !== '') { values = [values[0], ''] } return ( diff --git a/docs/civi-crm/architecture.md b/docs/civi-crm/architecture.md index 1a664697bc..18a143b7c1 100644 --- a/docs/civi-crm/architecture.md +++ b/docs/civi-crm/architecture.md @@ -353,6 +353,7 @@ All routes are Next.js Route Handlers in `src/app/api/`. They act as a thin back | `PATCH` | `/api/cases/[id]/coordinator` | Delete old `/Relationship` + create new `/Relationship` | Non-atomic; failure mode described in §9.3 | | `PATCH` | `/api/contacts/[contactId]` | Fan-out per update target; `/Contact` for `Skills_Socials` fields + parallel `/Email` for `email_primary` | | | `GET` | `/api/coordinators` | `/Relationship` (all with `case_id IS NOT NULL` + `Case Coordinator is`) → deduplicated | Used for filter dropdown | +| `POST` | `/api/public/afform-submit` | Best-effort (optional): `/Afform.submit` | Public intake funnel endpoint used by the three connect forms on `apps/web`. Verifies hCaptcha once (single-use token), submits to Notion (primary) and then to CiviCRM as a backup. Use `FUNNEL_INTAKE_NOTION_DISABLED` / `FUNNEL_INTAKE_CIVICRM_DISABLED` to opt out per destination without code changes. | ### 7.2 `GET /api/cases` — optimised fetch @@ -545,6 +546,15 @@ KEYCLOAK_USER_EMAIL_HEADER= # the Keycloak header. Use in .env.local during local development. # Remove or leave unset in staging/production. DEV_USER_EMAIL_MOCK= + +# Notion integration — intake funnel (POST /api/public/afform-submit) +# Required in preview/staging/production when Notion intake is enabled. +NOTION_API_TOKEN= +NOTION_DB_ID= + +# Intake funnel (POST /api/public/afform-submit) — optional opt-out per destination +# FUNNEL_INTAKE_NOTION_DISABLED=1 +# FUNNEL_INTAKE_CIVICRM_DISABLED=1 ``` No `NEXT_PUBLIC_` prefixed env vars are needed — all CiviCRM communication is server-side. diff --git a/docs/funnel/AGENTS.md b/docs/funnel/AGENTS.md new file mode 100644 index 0000000000..1d6d07868b --- /dev/null +++ b/docs/funnel/AGENTS.md @@ -0,0 +1,182 @@ +# Funnel intake -- architecture reference + +Target audience: AI agents reading this codebase. + +## What this is + +Three public connect forms (Coalition Partner, Activist Builder, Activist Leader / Steward) post to a single API endpoint on `apps/civi-crm`. Every submission creates a row in the **IFT BD CRM** Notion database (primary, required) and an Afform record in CiviCRM (backup, best-effort). + +--- + +## Request flow + +``` +apps/web (static) + └── connect-form-section.tsx + │ POST { formName, captchaToken, fields[], ...formFields } + ▼ +apps/civi-crm + POST /api/public/afform-submit + ├── 1. validate body, formName (must be one of three allowed values), fields[] + ├── 2. verify hCaptcha (once, single-use token -- cannot verify twice) + ├── 3. read env flags (FUNNEL_INTAKE_NOTION_DISABLED / FUNNEL_INTAKE_CIVICRM_DISABLED) + ├── 4. submitToNotion(formData, formName) -- REQUIRED; failure → 502 to client + └── 5. submitToCiviCrm(formData, fields, formName) -- best-effort; failure → 201 + detail +``` + +The reason both writes are in one handler: hCaptcha tokens are single-use. One POST, one token, two backend writes in sequence. + +--- + +## Code layout + +| Path | Role | +| --- | --- | +| `apps/civi-crm/src/app/api/public/afform-submit/route.ts` | Orchestrator: validation, captcha, calls both libs | +| `apps/civi-crm/src/lib/intake-submit-flags.ts` | Reads `FUNNEL_INTAKE_*_DISABLED` env flags | +| `apps/civi-crm/src/lib/notion/maps.ts` | `SKILLS_MAP`, `CHAT_SERVICE_MAP`, `COUNTRY_MAP`, `PROFILE_BY_FORM`, `MVMT_STATUS_NEW_LEAD`, `BU_MOVEMENT` | +| `apps/civi-crm/src/lib/notion/build-notion-properties.ts` | `buildNotionProperties` + `resolveOrganizationSelect` | +| `apps/civi-crm/src/lib/notion/submit.ts` | `submitToNotion` -- GETs DB for org options, builds properties, POSTs page | +| `apps/civi-crm/src/lib/civicrm/submit-afform.ts` | `submitToCiviCrm` -- builds Afform values, POSTs to CiviCRM API | +| `apps/civi-crm/src/lib/civicrm/build-afform-values.ts` | `buildAfformValues` (shared) | +| `apps/civi-crm/src/lib/civicrm/afform-case-defaults.ts` | `AfformIntakeFormName` type + case defaults | +| `apps/civi-crm/src/lib/notion/__tests__/build-notion-properties.test.ts` | Property mapping unit tests | + +The Notion and CiviCRM libs have **no cross-imports**. Removing one means deleting its folder and one call site in the orchestrator. + +--- + +## Forms and `formName` + +| Web page | `formName` in POST body | Notion `Profile` value | +| --- | --- | --- | +| `/coalition-partner` | `afformCoalitionPartner` | `Coalition Partner` | +| `/activist-builder` | `afformActivistBuilder` | `Activist Builder` | +| `/activist-leader-steward` | `afformActivistLeaderSteward` | `Activist Leader / Steward` | + +--- + +## Env variables + +### Required when Notion intake is enabled (default) + +| Variable | Purpose | +| --- | --- | +| `NOTION_API_TOKEN` | Notion integration secret | +| `NOTION_DB_ID` | ID of the Notion database | + +### Required for live submissions (unchanged from pre-funnel) + +| Variable | Purpose | +| --- | --- | +| `HCAPTCHA_SECRET` | Verify captcha tokens from `apps/web` | +| `CIVICRM_BASE_URL` | CiviCRM instance base URL | +| `CIVICRM_API_KEY` | CiviCRM API key | + +### Optional opt-outs + +| Variable | Effect when truthy (`1`, `true`, `yes`, `on`) | +| --- | --- | +| `FUNNEL_INTAKE_NOTION_DISABLED` | Skip Notion; CiviCRM becomes required | +| `FUNNEL_INTAKE_CIVICRM_DISABLED` | Skip CiviCRM; Notion only | + +Default (no flags set): Notion required, CiviCRM best-effort. + +--- + +## `submitToNotion` runtime behaviour + +1. Read `NOTION_API_TOKEN` and `NOTION_DB_ID`; return `{ ok: false }` if either is missing. +2. `GET /v1/databases/{id}` to read current `Organization` select options. +3. `resolveOrganizationSelect(affiliatedOrgs, options)` -- lowercase compare; use canonical option name if matched, else use the submitted value (Notion auto-creates the option). +4. `buildNotionProperties(formData, formName, organizationSelect)` -- see field mapping below. +5. `POST /v1/pages` with `parent.database_id` and `properties`. Notion API version: `2026-03-11`. +6. Return `{ ok: true }` or `{ ok: false, message }`. + +Empty optional properties (rich text, url, email, select) are omitted from the POST body so rows stay sparse. + +--- + +## IFT BD CRM -- full database schema + +The table below lists every property in the database as of 2026-05-29. The **Funnel** column marks whether the funnel intake writes to the property, and how. + +| Property | Notion type | Funnel | Notes | +| --- | --- | --- | --- | +| `Name` | title | **yes -- reused** | From form `name`; fallback `"Unknown"` | +| `Email/Website` | email | **yes -- reused** | From `email`; omitted if empty | +| `Profile` | select | **yes -- reused** | Derived from `formName` via `PROFILE_BY_FORM`; options: `Coalition Partner`, `Activist Builder`, `Activist Leader / Steward` | +| `Organization` | select | **yes -- reused** | From `affiliatedOrgs`; case-insensitive match against existing options; unmatched values create a new option | +| `Website` | url | **yes -- reused** | `website[]` joined with ` \| ` into a single url field | +| `Phone or Social Handle` | phone_number | **yes -- reused** | `chat[]` + `chatService[]` joined as `handle (Service) \| ...` | +| `Mvmt Status` | select | **yes -- reused** | Always written as `New Lead` on intake; other options: `Active`, `Onboarding`, `Approved`, `Redirected - Post Call`, `No Show`, `Call Scheduled`, `Redirected`, `Eligible` | +| `BU` | multi_select | **yes -- reused** | Always written as `Movement`; other options: `IR`, `Comms`, `Ecodev` | +| `Added` | created_time | **yes -- auto** | Read-only; set by Notion on row creation; not written by intake | +| `City` | rich_text | **yes -- added** | From `city`; omitted if empty | +| `Country` | rich_text | **yes -- added** | From `country` (CiviCRM numeric ID mapped to full name via `COUNTRY_MAP`) | +| `Skills` | multi_select | **yes -- added** | From `skills[]` (CiviCRM numeric IDs mapped to labels via `SKILLS_MAP`); 16 options (see below) | +| `Background` | rich_text | **yes -- added** | First non-empty of `backgroundPartner`, `backgroundBuilder`, `backgroundLeader` | +| `Tech Vision` | rich_text | **yes -- added** | From `techVision`; Activist Builder only; omitted if empty | +| `Activities Vision` | rich_text | **yes -- added** | From `activitiesVision`; Activist Leader / Steward only; omitted if empty | +| `Questions` | rich_text | **yes -- added** | From `questions`; omitted if empty | +| `Wants Events` | checkbox | **yes -- added** | From `wantsEvents` boolean | +| `Wants Newsletter` | checkbox | **yes -- added** | From `wantsNewsletter` boolean | +| `Account Owner` | person | no | BD team member assigned to the row | +| `Contacts` | rich_text | no | Free-form contact notes; manually populated | +| `Event Touchpoints` | multi_select | no | Events where the contact was met; options: `EthCC 2025`, `Protocolberg 2025`, `EthDenver 2025`, `EthDam`, `ETHCC`, `Inbound`, `Devcon 2024`, `Decentralized Data Summit`, `Devconnect 2025` | +| `Last Contact` | date | no | Date of most recent BD interaction; manually set | +| `Last edited time` | last_edited_time | no | System-managed; read-only | +| `Nimbus Status` | status | no | Nimbus-specific workflow status; options: `Not started`, `In progress`, `Done` | +| `Platform` | multi_select | no | Technical platform tags; options: `JS Browser`, `JS Electron`, `NodeJS`, `Rust`, `Golang`, `C++` | +| `Priority` | select | no | BD priority; options: `Low`, `Medium`, `High`, `To be established` | +| `Segment` | multi_select | no | Market segment; options: `Social`, `Infrastructure`, `Cross-chain`, `L2`, `Studio`, `DeFi`, `Tooling`, `Nodes`, `AI`, `Wallets`, `Investor`, `Oracle`, `indexer` | +| `Stack` | multi_select | no | Logos stack involvement; options: `Nimbus`, `Logos Storage`, `Logos Messaging`, `Logos Blockchain` | +| `Status` | select | no | BD pipeline stage; options: `Lead`, `Qualified`, `Solution Eng`, `Preliminary interest`, `Confirmed`, `Future`, `Negotiation`, `Lost`, `Archive` | +| `Tags` | multi_select | no | Miscellaneous labels; options include `Wallet dapp SDK user`, `Chat SDK user`, `Potential Waku users`, `Grant Recipient`, `Operator`, and others | +| `Total Funding` | number | no | Funding amount in USD; manually populated | +| `User Persona Type` | multi_select | no | Persona classification; options include `Node Operator`, `Developer`, `Integrator`, `Partner`, `Investor`, `Community`, and others | +| `Waku Solution Engineers` | person | no | Waku team member assigned to the row | + +### Skills multi_select options (16) + +`Developer`, `Web3 builder`, `Privacy domain expert`, `Website developer`, `Product designer`, `Researcher`, `Activist`, `Project manager`, `Community builder`, `Thought leader / Influencer`, `Creative`, `Marketer`, `Fundraiser`, `Educator`, `Policy advocate`, `Translator` + +### How the DB was extended for funnel intake + +Nine columns were added via `notion-update-data-source` DDL. The pre-existing properties were not modified. + +```sql +ADD COLUMN "City" RICH_TEXT; +ADD COLUMN "Country" RICH_TEXT; +ADD COLUMN "Skills" MULTI_SELECT('Developer','Web3 builder','Privacy domain expert','Website developer','Product designer','Researcher','Activist','Project manager','Community builder','Thought leader / Influencer','Creative','Marketer','Fundraiser','Educator','Policy advocate','Translator'); +ADD COLUMN "Background" RICH_TEXT; +ADD COLUMN "Tech Vision" RICH_TEXT; +ADD COLUMN "Activities Vision" RICH_TEXT; +ADD COLUMN "Questions" RICH_TEXT; +ADD COLUMN "Wants Events" CHECKBOX; +ADD COLUMN "Wants Newsletter" CHECKBOX +``` + +**Known limitation:** "Hide when empty" per property cannot be set via the Notion API or MCP. It must be toggled manually in the Notion UI for each of the nine new properties (Database -> ... -> Properties -> each property -> Visibility -> Hide when empty). + +--- + +## Key design decisions + +- **One endpoint for all three forms** -- hCaptcha tokens are single-use; all three forms point at `POST /api/public/afform-submit`. +- **Notion required, CiviCRM best-effort** -- Notion failure returns 502; CiviCRM failure returns 201 with a `detail` field. +- **One `Background` column** -- All `background*` textarea variants collapse into a single rich-text property. +- **Joined multi-values** -- `website[]` -> pipe-separated string in `Website` (url); `chat[]` -> `handle (Service)` entries in `Phone or Social Handle`. +- **`Organization` grows over time** -- Unmatched submitted values are written as-is and Notion creates a new select option. +- **Env var name** -- `NOTION_DB_ID`. + +--- + +## Testing + +```bash +pnpm --filter civi-crm test +``` + +Notion property mapping: `apps/civi-crm/src/lib/notion/__tests__/build-notion-properties.test.ts` +CiviCRM value building: `apps/civi-crm/src/lib/civicrm/__tests__/build-afform-values.test.ts`