mirror of
https://github.com/logos-co/logos-web.git
synced 2026-08-27 12:11:14 +00:00
feat: route all three intake forms to Notion & CiviCRM
* add forms for CRM funnels * a * feat(civi-crm): add notion-coalition-partner API route Accepts coalition-partner form submissions and writes them to a Notion DB. Resolves numeric IDs for country, skills and chat service to human-readable labels. Same hCaptcha guard pattern as afform-submit. Includes CORS headers for cross-origin requests from apps/web. * feat(web): wire coalition-partner form to Notion API route * fix(web): checkbox default value and broken checked visual state Checkboxes were initialising to true instead of false, causing unchecked submissions to send true to the API. peer-checked: on the Check icon also never fired because it is not a direct sibling of the peer input -- replaced with group-has-[input:checked]: which works via the label's group class. * chore(civir-crm-api): suppress unused variable warnings in coalition-partner route * fix(civi-crm): set CORS headers for public API routes via next.config * set profile and lead source - https://civicrm.logos.co/civicrm/admin/afform/submissions#/?name=afformActivistBuilder - https://civicrm.logos.co/civicrm/contact/view?reset=1&cid=1000 - https://civicrm.logos.co/civicrm/contact/view?reset=1&cid=1000 - https://civicrm.logos.co/civicrm/contact/view/case?reset=1&id=388&cid=1000&action=view&context=case&selectedChild=case&key=CRMCaseFormSearch3vjNUK027Z0YMdmpnTbHyNePydwogixSnFN3Z2YluYYR4_8939 * default checkboxes to true * remove titles * u * add policy * t * fix(civi-crm): avoid readonly env writes in CORS test Use a keyed env helper in the public CORS test so cleanup and setup no longer rely on delete or readonly typed env properties. * fix(web): align node programme route keys Rename node program route/message keys to node programme and update links, metadata, sitemap entries, and contract tests. * chore(civi-crm): lint:fix * docs(funnel): document Notion intake CRM setup Add funnel docs covering goals, property mapping, completed schema DDL, Hide-when-empty limitations, manual UI checklist, and implementation roadmap. * feat(civi-crm): add Notion lib for funnel intake submissions Self-contained maps, property builder, and submitToNotion with Organization option matching and Notion-Version 2026-03-11. * docs(funnel): add implementation guide and sync status Document phased steps, Notion lib layout, and refresh README/roadmap for completed schema and submitToNotion work still pending wiring. * docs(funnel): expand implementation steps and Notion lib notes Document goals per phase, legacy route vs src/lib/notion/, and link schema mapping to the code. * refactor(civi-crm): extract Afform submit into submit-afform lib Decouple CiviCRM writes from the afform-submit route for upcoming Notion + CiviCRM orchestration. * feat(civi-crm): add funnel intake submit disable flags FUNNEL_INTAKE_NOTION_DISABLED and FUNNEL_INTAKE_CIVICRM_DISABLED opt out per destination; unset means submit (default). * feat(civi-crm): orchestrate afform-submit with Notion and CiviCRM Verify hCaptcha once; Notion required when enabled, CiviCRM best-effort. Point coalition partner at the shared endpoint; add CORS for public web. * feat(civi-crm): set Notion BU to Movement on funnel intake * chore(civi-crm): remove notion coalition partner route * docs(civi-crm): document funnel intake endpoint and env * docs(funnel): document orchestrator, env flags, and BU default * docs(funnel): document cleanup completion and deploy checklist Record removal of notion-coalition-partner, single afform-submit path, and non-local Notion env requirements for operators. * docs(funnel): replace per-phase docs with consolidated AGENTS.md Merge README, implementation, and notion-database into a single agent-targeted reference covering request flow, code layout, env vars, and the full IFT BD CRM schema (all 32 properties, funnel relevance annotated). * refactor(civi-crm): rename NOTION_COALITION_PARTNER_DB_ID to NOTION_DB_ID * docs: rename NOTION_COALITION_PARTNER_DB_ID to NOTION_DB_ID * feat(notion): add scorecard page content to funnel form submissions heading_2, four bulleted_list_item blocks, and a paragraph for the overall fit line using proper Notion block types. * refactor(civi-crm): drop wildcard CORS from afform-submit Rely on the public-cors middleware for origin allowlisting instead of emitting Access-Control-Allow-Origin: * and a redundant OPTIONS handler. --------- Co-authored-by: Felicio <felicio@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<boolean> {
|
||||
async function verifyHCaptcha(
|
||||
token: string,
|
||||
remoteip: string
|
||||
): Promise<boolean> {
|
||||
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<string, unknown>,
|
||||
status: number
|
||||
): NextResponse {
|
||||
return NextResponse.json(body, { status })
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
let body: Record<string, unknown>
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown>,
|
||||
fieldDefs: AfformFieldDef[],
|
||||
formName: string
|
||||
): Promise<CiviCrmSubmitResult> {
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown>
|
||||
|
||||
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, unknown>): 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<string, unknown>,
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import type { AfformIntakeFormName } from '@/lib/civicrm/afform-case-defaults'
|
||||
|
||||
export const SKILLS_MAP: Record<string, string> = {
|
||||
'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<string, string> = {
|
||||
'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<AfformIntakeFormName, string> = {
|
||||
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<string, string> = {
|
||||
'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)',
|
||||
}
|
||||
@@ -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<string, NotionSelectProperty | { type: string }>
|
||||
}
|
||||
|
||||
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<string[]> {
|
||||
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<string, unknown>,
|
||||
formName: string
|
||||
): Promise<NotionSubmitResult> {
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -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<HTMLInputElement | HTMLTextAreaElement> | string) => {
|
||||
(field: string) =>
|
||||
(event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement> | 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({
|
||||
<Check
|
||||
className={cn(
|
||||
'size-3',
|
||||
isChecked
|
||||
? 'text-brand-dark-green'
|
||||
: 'text-transparent'
|
||||
isChecked ? 'text-brand-dark-green' : 'text-transparent'
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
@@ -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 (
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
Reference in New Issue
Block a user