← back to Norma Platform
Build unified campaign workspace with five action journeys and supporter history
c2e108fa7eee86f384eef953bfee1b2dc3f73179 · 2026-09-09 13:04:26 -0700 · Steve Abrams
Files touched
M .gitignoreA app/act/[id]/page.tsxA app/api/action-center/[id]/route.tsA app/api/campaigns/[[...path]]/route.tsA app/campaigns/page.tsxM components/AppShell.tsxM components/NikkiShell.tsxM components/Sidebar.tsxA components/campaigns/ActionPage.tsxA components/campaigns/CampaignWorkspace.tsxA components/campaigns/campaigns.module.cssA db/026_campaign_platform.sqlA docs/platform/DECISION.mdA docs/platform/PLAN.mdA docs/platform/RUNBOOK.mdA lib/campaigns/intake.tsA lib/campaigns/store.tsA lib/campaigns/types.tsA lib/campaigns/validation.tsM middleware.tsA scripts/campaign-preview.shA scripts/seed-campaign-demo.mjsM scripts/test-instance.shA tests/campaign-disabled.mjsA tests/campaign-platform.mjsA tests/platform/workspace.spec.tsA verification/platform/api-results.jsonA verification/platform/browser-results.jsonA verification/platform/browser.config.tsA verification/platform/demo.jsonA verification/platform/publication-results.jsonA verification/platform/screenshots/chromium-advocacy.pngA verification/platform/screenshots/chromium-campaign.pngA verification/platform/screenshots/chromium-event.pngA verification/platform/screenshots/chromium-fundraiser.pngA verification/platform/screenshots/chromium-mobile.pngA verification/platform/screenshots/chromium-receipt.pngA verification/platform/screenshots/chromium-volunteer.pngA verification/platform/screenshots/webkit-advocacy.pngA verification/platform/screenshots/webkit-campaign.pngA verification/platform/screenshots/webkit-event.pngA verification/platform/screenshots/webkit-fundraiser.pngA verification/platform/screenshots/webkit-mobile.pngA verification/platform/screenshots/webkit-receipt.pngA verification/platform/screenshots/webkit-volunteer.png
Diff
commit c2e108fa7eee86f384eef953bfee1b2dc3f73179
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Sep 9 13:04:26 2026 -0700
Build unified campaign workspace with five action journeys and supporter history
---
.gitignore | 4 +
app/act/[id]/page.tsx | 3 +
app/api/action-center/[id]/route.ts | 13 +
app/api/campaigns/[[...path]]/route.ts | 42 +++
app/campaigns/page.tsx | 5 +
components/AppShell.tsx | 6 +-
components/NikkiShell.tsx | 9 +
components/Sidebar.tsx | 3 +
components/campaigns/ActionPage.tsx | 55 ++++
components/campaigns/CampaignWorkspace.tsx | 154 +++++++++
components/campaigns/campaigns.module.css | 90 ++++++
db/026_campaign_platform.sql | 57 ++++
docs/platform/DECISION.md | 9 +
docs/platform/PLAN.md | 45 +++
docs/platform/RUNBOOK.md | 60 ++++
lib/campaigns/intake.ts | 94 ++++++
lib/campaigns/store.ts | 130 ++++++++
lib/campaigns/types.ts | 15 +
lib/campaigns/validation.ts | 99 ++++++
middleware.ts | 9 +
scripts/campaign-preview.sh | 9 +
scripts/seed-campaign-demo.mjs | 37 +++
scripts/test-instance.sh | 5 +-
tests/campaign-disabled.mjs | 18 ++
tests/campaign-platform.mjs | 108 +++++++
tests/platform/workspace.spec.ts | 85 +++++
verification/platform/api-results.json | 63 ++++
verification/platform/browser-results.json | 343 +++++++++++++++++++++
verification/platform/browser.config.ts | 2 +
verification/platform/demo.json | 42 +++
verification/platform/publication-results.json | 15 +
.../platform/screenshots/chromium-advocacy.png | Bin 0 -> 104117 bytes
.../platform/screenshots/chromium-campaign.png | Bin 0 -> 146092 bytes
.../platform/screenshots/chromium-event.png | Bin 0 -> 109204 bytes
.../platform/screenshots/chromium-fundraiser.png | Bin 0 -> 84683 bytes
.../platform/screenshots/chromium-mobile.png | Bin 0 -> 197167 bytes
.../platform/screenshots/chromium-receipt.png | Bin 0 -> 92978 bytes
.../platform/screenshots/chromium-volunteer.png | Bin 0 -> 97962 bytes
.../platform/screenshots/webkit-advocacy.png | Bin 0 -> 340777 bytes
.../platform/screenshots/webkit-campaign.png | Bin 0 -> 460237 bytes
verification/platform/screenshots/webkit-event.png | Bin 0 -> 367416 bytes
.../platform/screenshots/webkit-fundraiser.png | Bin 0 -> 307126 bytes
.../platform/screenshots/webkit-mobile.png | Bin 0 -> 686129 bytes
.../platform/screenshots/webkit-receipt.png | Bin 0 -> 303988 bytes
.../platform/screenshots/webkit-volunteer.png | Bin 0 -> 323938 bytes
45 files changed, 1626 insertions(+), 3 deletions(-)
diff --git a/.gitignore b/.gitignore
index 329ff09..77a1312 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,8 @@
# dependencies
node_modules/
+/node_modules
+/verification/platform/browser-artifacts/
/.pnp
.pnp.*
.yarn/*
@@ -64,3 +66,5 @@ agents/*/data/
test-results/
playwright-report/
/tests/e2e/.auth/
+
+/verification/platform/verification/
diff --git a/app/act/[id]/page.tsx b/app/act/[id]/page.tsx
new file mode 100644
index 0000000..cc88c78
--- /dev/null
+++ b/app/act/[id]/page.tsx
@@ -0,0 +1,3 @@
+import { Suspense } from 'react';
+import ActionPage from '@/components/campaigns/ActionPage';
+export default function Page(){return <Suspense fallback={<p>Loading action…</p>}><ActionPage/></Suspense>;}
diff --git a/app/api/action-center/[id]/route.ts b/app/api/action-center/[id]/route.ts
new file mode 100644
index 0000000..e052cbb
--- /dev/null
+++ b/app/api/action-center/[id]/route.ts
@@ -0,0 +1,13 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { participate, publicAction } from '@/lib/campaigns/intake';
+import { body, failure } from '@/lib/campaigns/validation';
+export const dynamic = 'force-dynamic';
+type Context = {params: Promise<{id:string}>};
+export async function GET(request: NextRequest, context: Context) {
+ try { return NextResponse.json(await publicAction(request,(await context.params).id),{headers:{'Cache-Control':'no-store'}}); }
+ catch(error) { return failure(error); }
+}
+export async function POST(request: NextRequest, context: Context) {
+ try { return NextResponse.json(await participate(request,(await context.params).id,await body(request)),{status:202,headers:{'Cache-Control':'no-store'}}); }
+ catch(error) { return failure(error); }
+}
diff --git a/app/api/campaigns/[[...path]]/route.ts b/app/api/campaigns/[[...path]]/route.ts
new file mode 100644
index 0000000..cd8e4de
--- /dev/null
+++ b/app/api/campaigns/[[...path]]/route.ts
@@ -0,0 +1,42 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { body, CampaignError, failure, publishingEnabled, sandbox, scope } from '@/lib/campaigns/validation';
+import { attendance, createAction, createCampaign, detail, saveTask, updateAction, updateCampaign, workspace } from '@/lib/campaigns/store';
+import { query } from '@/lib/db';
+export const dynamic = 'force-dynamic';
+type Context = {params: Promise<{path?: string[]}>};
+const json = (data: unknown, status=200) => NextResponse.json(data,{status,headers:{'Cache-Control':'private, no-store'}});
+export async function GET(request: NextRequest, context: Context) {
+ try {
+ const s = await scope(request); const p = (await context.params).path ?? [];
+ if (!p.length) return json({...await workspace(s.org),publishing_enabled:publishingEnabled(),sandbox:sandbox()});
+ if (p.length === 1 && p[0] === 'export') {
+ const rows = (await query(`SELECT s.full_name,s.email,s.created_at,count(p.id)::int participation_count,
+ count(p.id) FILTER(WHERE p.consent_requested)::int email_update_requests
+ FROM organizing_supporters s LEFT JOIN organizing_participation p ON p.supporter_id=s.id WHERE s.org_id=$1 GROUP BY s.id ORDER BY s.created_at DESC LIMIT 10000`,[s.org])).rows;
+ const escape = (value: unknown) => { let v = String(value ?? ''); if (/^[=+@\-\t\r]/.test(v)) v = "'"+v; return '"'+v.replace(/"/g,'""')+'"'; };
+ const csv = ['name,email,created_at,participations,email_update_requests,subscription_status',...rows.map(r=>[r.full_name,r.email,r.created_at.toISOString(),r.participation_count,r.email_update_requests,'not_subscribed'].map(escape).join(','))].join('\r\n');
+ return new NextResponse(csv,{headers:{'Content-Type':'text/csv; charset=utf-8','Content-Disposition':'attachment; filename="norma-supporters.csv"','Cache-Control':'private, no-store'}});
+ }
+ if (p.length === 1) return json(await detail(s.org,p[0]));
+ throw new CampaignError(404,'Endpoint not found.');
+ } catch(error) { return failure(error); }
+}
+export async function POST(request: NextRequest, context: Context) {
+ try {
+ const s = await scope(request); const data = await body(request); const p = (await context.params).path ?? [];
+ if (!p.length) return json({campaign:await createCampaign(s,data)},201);
+ if (p.length === 2 && p[1] === 'actions') return json({action:await createAction(s,p[0],data)},201);
+ if (p.length === 1 && p[0] === 'tasks') return json({task:await saveTask(s,null,data)},201);
+ throw new CampaignError(404,'Endpoint not found.');
+ } catch(error) { return failure(error); }
+}
+export async function PATCH(request: NextRequest, context: Context) {
+ try {
+ const s = await scope(request); const data = await body(request); const p = (await context.params).path ?? [];
+ if (p.length === 1) return json({campaign:await updateCampaign(s,p[0],data)});
+ if (p.length === 2 && p[0] === 'actions') return json({action:await updateAction(s,p[1],data)});
+ if (p.length === 2 && p[0] === 'tasks') return json({task:await saveTask(s,p[1],data)});
+ if (p.length === 2 && p[0] === 'participation') return json({participation:await attendance(s,p[1],data)});
+ throw new CampaignError(404,'Endpoint not found.');
+ } catch(error) { return failure(error); }
+}
diff --git a/app/campaigns/page.tsx b/app/campaigns/page.tsx
new file mode 100644
index 0000000..46e2cf1
--- /dev/null
+++ b/app/campaigns/page.tsx
@@ -0,0 +1,5 @@
+'use client';
+import { AuthProvider } from '@/components/AuthProvider';
+import { OrgProvider } from '@/components/OrgProvider';
+import CampaignWorkspace from '@/components/campaigns/CampaignWorkspace';
+export default function CampaignsPage(){return <AuthProvider><OrgProvider><CampaignWorkspace/></OrgProvider></AuthProvider>;}
diff --git a/components/AppShell.tsx b/components/AppShell.tsx
index 26cb4c3..650d4d6 100644
--- a/components/AppShell.tsx
+++ b/components/AppShell.tsx
@@ -10,6 +10,7 @@ import { OrgProvider, useOrg } from './OrgProvider';
import Sidebar, { type TabId } from './Sidebar';
import NikkiShell from './NikkiShell';
import DashboardTab from './dashboard/DashboardTab';
+import CampaignWorkspace from './campaigns/CampaignWorkspace';
import DraftsTab from './drafts/DraftsTab';
import PetitionsTab from './petitions/PetitionsTab';
import GrantsTab from './grants/GrantsTab';
@@ -125,8 +126,7 @@ function Shell() {
if (tab === 'journalists' && id) setJournalistNavId(id);
setActiveTab(tab);
// Note: we don't strip the params from the URL — the user can refresh and land back here.
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
+ }, [searchParams]);
/* ── Keyboard shortcuts for sidebar navigation ──────────────────────────── */
useEffect(() => {
@@ -254,6 +254,7 @@ function Shell() {
switch (activeTab) {
case 'dashboard': return <DashboardTab onNavigate={(tab) => handleTabChange(tab as TabId)} />;
+ case 'campaigns': return <CampaignWorkspace />;
case 'drafts': return <DraftsTab onNavigateToSettings={() => handleTabChange('settings')} />;
case 'news': return <NewsTab onNavigate={(tab) => handleTabChange(tab as TabId)} onNavigateToJournalist={(name) => { setJournalistNavId(`name:${name}`); setActiveTab('journalists'); }} />;
case 'petitions': return <PetitionsTab onNavigate={(tab) => handleTabChange(tab as TabId)} />;
@@ -409,6 +410,7 @@ function Shell() {
/* Tab label for the top bar */
const TAB_LABELS: Record<TabId, string> = {
dashboard: 'Dashboard',
+ campaigns: 'Campaigns',
drafts: 'Drafts',
news: 'News Feed',
petitions: 'Petitions',
diff --git a/components/NikkiShell.tsx b/components/NikkiShell.tsx
index 1387453..1fbd42c 100644
--- a/components/NikkiShell.tsx
+++ b/components/NikkiShell.tsx
@@ -1,12 +1,14 @@
'use client';
import { useState, useEffect } from 'react';
+import { useSearchParams } from 'next/navigation';
import { LogOut, Menu, LayoutDashboard, Newspaper, Megaphone, Award, MoreHorizontal } from 'lucide-react';
import GlobalSearch from './GlobalSearch';
import { useAuth } from './AuthProvider';
import { useOrg } from './OrgProvider';
import Sidebar, { type TabId } from './Sidebar';
import NikkiDashboard from './nikki/NikkiDashboard';
+import CampaignWorkspace from './campaigns/CampaignWorkspace';
import DraftsTab from './drafts/DraftsTab';
import PetitionsTab from './petitions/PetitionsTab';
import GrantsTab from './grants/GrantsTab';
@@ -38,6 +40,11 @@ export default function NikkiShell() {
const [activeTab, setActiveTab] = useState<TabId>('email-sends');
const [sidebarOpen, setSidebarOpen] = useState(false);
const [journalistNavId, setJournalistNavId] = useState<string | null>(null);
+ const searchParams = useSearchParams();
+ useEffect(() => {
+ const tab = searchParams.get('tab') as TabId | null;
+ if (tab) { setActiveTab(tab); setSidebarOpen(false); }
+ }, [searchParams]);
function handleTabChange(tab: TabId) {
setActiveTab(tab);
@@ -87,6 +94,7 @@ export default function NikkiShell() {
switch (activeTab) {
case 'dashboard': return <NikkiDashboard onNavigate={(tab) => handleTabChange(tab as TabId)} />;
+ case 'campaigns': return <CampaignWorkspace />;
case 'drafts': return <DraftsTab onNavigateToSettings={() => {}} />;
case 'news': return <NewsTab onNavigate={(tab) => handleTabChange(tab as TabId)} onNavigateToJournalist={(name) => { setJournalistNavId(`name:${name}`); setActiveTab('journalists'); }} />;
case 'petitions': return <PetitionsTab onNavigate={(tab) => handleTabChange(tab as TabId)} />;
@@ -128,6 +136,7 @@ export default function NikkiShell() {
}
const TAB_LABELS: Partial<Record<TabId, string>> = {
+ campaigns: 'Campaigns',
dashboard: 'Dashboard',
drafts: 'Drafts',
news: 'News Feed',
diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx
index 83d1c7b..63428ae 100644
--- a/components/Sidebar.tsx
+++ b/components/Sidebar.tsx
@@ -73,6 +73,7 @@ import {
/* ─── Types ──────────────────────────────────────────────────────────────── */
export type TabId =
+ | 'campaigns'
| 'dashboard'
| 'drafts'
| 'news'
@@ -192,6 +193,7 @@ interface SidebarProps {
/* Staff only sees Email & Outreach + Settings by default.
Other modules can be activated from Settings → Module Manager. */
const STAFF_DEFAULT_TABS: TabId[] = [
+ 'campaigns',
// Workspace
'dashboard', 'drafts', 'news', 'petitions', 'grants', 'pipeline', 'statements', 'donations',
// Email & Outreach
@@ -247,6 +249,7 @@ const KBD_MAP: Partial<Record<TabId, string>> = {
const NAV_ITEMS: NavItem[] = [
// workspace — org-local, pinned at top
{ id: 'dashboard', label: 'Dashboard', Icon: LayoutDashboard, section: 'workspace' },
+ { id: 'campaigns', label: 'Campaigns', Icon: Megaphone, section: 'workspace' },
{ id: 'drafts', label: 'Drafts', Icon: FileText, section: 'workspace' },
{ id: 'news', label: 'News', Icon: Newspaper, section: 'workspace' },
{ id: 'petitions', label: 'Petitions', Icon: Megaphone, section: 'workspace' },
diff --git a/components/campaigns/ActionPage.tsx b/components/campaigns/ActionPage.tsx
new file mode 100644
index 0000000..2b67964
--- /dev/null
+++ b/components/campaigns/ActionPage.tsx
@@ -0,0 +1,55 @@
+'use client';
+import { useEffect, useRef, useState, type FormEvent } from 'react';
+import { useParams, useSearchParams } from 'next/navigation';
+import { ArrowUpRight, CalendarDays, CheckCircle2, HeartHandshake, MapPin } from 'lucide-react';
+import { KIND_LABEL, type CampaignAction } from '@/lib/campaigns/types';
+import styles from './campaigns.module.css';
+interface ActionData {action:CampaignAction;campaign:{title:string;summary:string;status:string};organization:string;preview:boolean;sandbox:boolean;accepting:boolean;unavailable_reason?:string;consent_text:string;next_actions:{id:string;title:string;kind:CampaignAction['kind']}[]}
+const money=(value:number)=>new Intl.NumberFormat('en-US',{style:'currency',currency:'USD'}).format(value/100);
+export default function ActionPage(){
+ const {id}=useParams<{id:string}>();const params=useSearchParams();const preview=params.get('preview')==='1';
+ const [data,setData]=useState<ActionData|null>(null);const [error,setError]=useState('');const [busy,setBusy]=useState(false);const [result,setResult]=useState<{receipt:string;message:string}|null>(null);
+ const [amount,setAmount]=useState(2500);const [frequency,setFrequency]=useState('once');const key=useRef('');
+ useEffect(()=>{const controller=new AbortController();setData(null);setError('');setResult(null);key.current='';
+ const headers:Record<string,string>={};if(preview)headers['X-Org-Id']=localStorage.getItem('norma-active-org')||localStorage.getItem('norma-user-orgid')||'';
+ fetch(`/api/action-center/${id}${preview?'?preview=1':''}`,{headers,signal:controller.signal,cache:'no-store'}).then(async res=>{const body=await res.json();if(!res.ok)throw new Error(body.error||'Action unavailable.');return body;}).then(next=>{setData(next);setAmount(next.action.config.suggested_amounts?.[1]||next.action.config.suggested_amounts?.[0]||2500);}).catch(err=>{if(err.name!=='AbortError')setError(err.message);});return ()=>controller.abort();
+ },[id,preview]);
+ async function submit(event:FormEvent<HTMLFormElement>){event.preventDefault();if(!data)return;const fields=new FormData(event.currentTarget);setBusy(true);setError('');if(!key.current)key.current=crypto.randomUUID();
+ try {const response=await fetch('/api/action-center/'+id,{method:'POST',headers:{'Content-Type':'application/json','Idempotency-Key':key.current},body:JSON.stringify({full_name:fields.get('full_name'),email:fields.get('email'),consent_requested:fields.get('consent_requested')==='on',website:fields.get('website'),amount_cents:amount,frequency,source:params.get('source')||'direct'})});const out=await response.json();if(!response.ok)throw new Error(out.error||'Submission could not be saved.');setResult(out);
+ const refreshHeaders:Record<string,string>={};if(preview)refreshHeaders['X-Org-Id']=localStorage.getItem('norma-active-org')||localStorage.getItem('norma-user-orgid')||'';
+ const updated=await fetch(`/api/action-center/${id}${preview?'?preview=1':''}`,{headers:refreshHeaders,cache:'no-store'}).catch(()=>null);
+ if(updated?.ok)setData(await updated.json());}
+ catch(err){setError((err as Error).message);}finally{setBusy(false);}}
+ function calendar(){if(!data)return;const a=data.action;const esc=(v:string)=>v.replaceAll('\\','\\\\').replaceAll('\n','\\n').replaceAll(',','\\,').replaceAll(';','\\;');const date=(v:string)=>new Date(v).toISOString().replace(/[-:]/g,'').replace(/\.\d{3}Z/,'Z');
+ const ics=['BEGIN:VCALENDAR','VERSION:2.0','PRODID:-//Norma//Events//EN','BEGIN:VEVENT',`UID:${a.id}@norma.local`,`DTSTAMP:${date(new Date().toISOString())}`,`DTSTART:${date(a.config.starts_at!)}`,`DTEND:${date(a.config.ends_at!)}`,`SUMMARY:${esc(a.title)}`,`LOCATION:${esc(a.config.location||'')}`,'END:VEVENT','END:VCALENDAR'].join('\r\n');const url=URL.createObjectURL(new Blob([ics],{type:'text/calendar;charset=utf-8'}));const anchor=document.createElement('a');anchor.href=url;anchor.download='norma-event.ics';anchor.click();URL.revokeObjectURL(url);}
+ const a=data?.action;
+ return <div className={styles.workspace}><div className={styles.public}><div className={styles.topline}><span className={styles.brand}><span className={styles.mark}>N</span>{data?.organization||'Norma'}</span><span className={styles.eyebrow}>A shared purpose. A place to begin.</span></div>
+ {data?.sandbox&&<div className={styles.note}><strong>Sandbox preview.</strong> This is a test campaign. Submissions are saved as test participation; no money is collected or messages sent.</div>}
+ {data?.preview&&<div className={styles.note}>Organizer preview · {data.campaign.status} campaign / {a?.status} action. <a href="/campaigns">Return to workspace</a></div>}
+ {!data&&!error&&<p role="status" style={{padding:40}}>Loading this action…</p>}
+ {error&&<div className={styles.error} role="alert">{error}</div>}
+ {data&&a&&<><header className={styles.publichero}><span className={styles.eyebrow}>{KIND_LABEL[a.kind]} / {data.campaign.title}</span><h1>{a.title}</h1><p className={styles.muted}>{data.campaign.summary}</p></header>
+ <div className={styles.publicbody}><div><p className={styles.prose}>{a.description}</p>{a.config.target&&<div className={styles.note}><strong>To:</strong> {a.config.target}</div>}
+ {a.kind==='event'&&<article className={styles.card} style={{marginTop:20}}><h3 className={styles.formtitle}>Event details</h3><p><CalendarDays size={16} style={{display:'inline'}}/> {new Date(a.config.starts_at!).toLocaleString(undefined,{dateStyle:'full',timeStyle:'short'})}</p><p><MapPin size={16} style={{display:'inline'}}/> {a.config.location}</p><p>{Math.max(0,a.config.capacity!-(a.participation_count||0))} places remaining · {a.config.capacity} total</p><button type="button" className={styles.textbutton} style={{marginTop:14}} onClick={calendar}>Save to calendar</button></article>}
+ {a.config.instructions&&<article className={styles.card} style={{marginTop:20}}><h3 className={styles.formtitle}>How to take part</h3><p className={styles.prose}>{a.config.instructions}</p></article>}
+ {!result&&<><div className={styles.sectionhead}><h2>{a.kind==='fundraiser'?money(a.pledged_cents||0):a.participation_count||0}</h2><span className={styles.muted}>{a.kind==='fundraiser'?'pledged, not collected':'people taking part'}</span></div>
+ {a.config.goal&&<><div className={styles.progress}><span style={{width:`${Math.min(100,(a.participation_count||0)/a.config.goal*100)}%`}}/></div><p className={styles.muted}>Working toward {a.config.goal.toLocaleString()} signatures.</p></>}</>}
+ </div><div className={styles.card}>
+ {result?<div role="status"><CheckCircle2 size={34}/><h2 style={{margin:'18px 0'}}>You’re part of the story.</h2><p>{result.message}</p><p className={styles.fineprint}>Keep this reference: {result.receipt}</p>{a.kind==='event'&&<button className={styles.button} style={{marginTop:20}} onClick={calendar}>Save event to calendar</button>}
+ {a.kind==='fundraiser'&&a.config.hosted_url&&<a className={styles.button} style={{marginTop:20}} href={a.config.hosted_url} target="_blank" rel="noopener noreferrer">Continue on ActBlue <ArrowUpRight size={15}/></a>}
+ {!!data.next_actions?.length&&<><h3 style={{marginTop:26}}>Another way to help</h3><ul className={styles.list}>{data.next_actions.map(next=><li key={next.id}><a href={`/act/${next.id}?source=next-action`}>{KIND_LABEL[next.kind]}: {next.title} ↗</a></li>)}</ul></>}
+ </div>:<form onSubmit={submit}>
+ <h2 className={styles.formtitle}>{({petition:'Add your name.',fundraiser:'Make a pledge.',event:'Save your place.',volunteer:'Lend a hand.',advocacy:'Take the next step.'})[a.kind]}</h2>
+ <div className={styles.stack}>
+ {a.kind==='fundraiser'&&<><div className={styles.amounts}>{a.config.suggested_amounts?.map(value=><button key={value} type="button" className={styles.amount} aria-pressed={value===amount} onClick={()=>setAmount(value)}>{money(value)}</button>)}</div><label className={styles.field}>Pledge amount (USD)<input type="number" min="1" max="1000000" step="0.01" value={amount/100||''} onChange={e=>setAmount(Math.round(Number(e.target.value)*100))} required/></label><label className={styles.field}>Frequency<select value={frequency} onChange={e=>setFrequency(e.target.value)}><option value="once">One time</option><option value="monthly">Monthly intention</option></select></label><p className={styles.fineprint}>This records your intention to give. It does not charge a card or start recurring billing.</p></>}
+ <label className={styles.field}>Full name<input name="full_name" required autoComplete="name" maxLength={160}/></label><label className={styles.field}>Email address<input type="email" name="email" required autoComplete="email" maxLength={254}/></label>
+ <div hidden aria-hidden="true"><label>Leave blank<input name="website" tabIndex={-1} autoComplete="off"/></label></div>
+ <label className={styles.check}><input type="checkbox" name="consent_requested"/>{data.consent_text}</label>
+ <button className={styles.button} type="submit" disabled={busy||!data.accepting}>{busy?'Saving…':({petition:'Add my name',fundraiser:'Record my pledge',event:'Confirm RSVP',volunteer:'Register my interest',advocacy:'Record my participation'})[a.kind]}</button>
+ {!data.accepting&&<p className={styles.muted}>{data.unavailable_reason||'Activate the campaign and this action in the organizer workspace to accept responses.'}</p>}{!data.accepting&&!!data.next_actions?.length&&<><h3>Another way to help</h3><ul className={styles.list}>{data.next_actions.map(next=><li key={next.id}><a href={`/act/${next.id}?source=next-action`}>{next.title} ↗</a></li>)}</ul></>}
+ </div><p className={styles.fineprint}>Your response is shared with {data.organization} to manage this action. Your email address is not displayed publicly. Campaign updates are optional.</p>
+ {a.kind==='fundraiser'&&a.config.hosted_url&&<p className={styles.fineprint}>Prefer to give now? <a href={a.config.hosted_url} target="_blank" rel="noopener noreferrer">Open the hosted ActBlue contribution form ↗</a></p>}
+ </form>}
+ </div></div><div className={styles.topline}><span className={styles.muted}><HeartHandshake size={16} style={{display:'inline'}}/> Powered by Norma</span><span className={styles.muted}>{data.organization}</span></div></>}
+ </div></div>;
+}
diff --git a/components/campaigns/CampaignWorkspace.tsx b/components/campaigns/CampaignWorkspace.tsx
new file mode 100644
index 0000000..8e1f33b
--- /dev/null
+++ b/components/campaigns/CampaignWorkspace.tsx
@@ -0,0 +1,154 @@
+'use client';
+
+import { useCallback, useEffect, useRef, useState, type CSSProperties, type FormEvent, type ReactNode } from 'react';
+import { ArrowLeft, ArrowUpRight, CalendarDays, Download, Megaphone, Plus, RefreshCw, X } from 'lucide-react';
+import Link from 'next/link';
+import { useOrg } from '@/components/OrgProvider';
+import { useAuth } from '@/components/AuthProvider';
+import { ACTION_KINDS, KIND_LABEL, type ActionKind, type Campaign, type CampaignAction, type CampaignDetail, type OrganizingTask, type Workspace } from '@/lib/campaigns/types';
+import styles from './campaigns.module.css';
+
+const dollars = (value: number) => new Intl.NumberFormat('en-US',{style:'currency',currency:'USD',maximumFractionDigits:0}).format(Number(value || 0)/100);
+const timestamp = (value: string) => new Date(value).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
+function DateChip({value}:{value:string}) { return <time className={styles.date} dateTime={value} title={value}>Created {timestamp(value)}</time>; }
+function Badge({value}:{value:string}) { return <span className={`${styles.badge} ${value === 'active' ? styles.active : ''}`}>{value}</span>; }
+function Empty({title,children}:{title:string;children:ReactNode}) { return <div className={styles.empty}><h2>{title}</h2>{children}</div>; }
+interface Editor { type:'campaign'|'action'|'task'; campaign?:Campaign; action?:CampaignAction; task?:OrganizingTask; kind?:ActionKind }
+
+export default function CampaignWorkspace() {
+ const {org,orgs,switchOrg,loading:orgLoading} = useOrg(); const {role,loading:authLoading} = useAuth();
+ const [workspace,setWorkspace] = useState<Workspace|null>(null); const [selected,setSelected] = useState<string|null>(null);
+ const [detail,setDetail] = useState<CampaignDetail|null>(null); const [tab,setTab] = useState('Campaigns');
+ const [error,setError] = useState(''); const [loading,setLoading] = useState(false); const [busy,setBusy] = useState(false);
+ const [editor,setEditor] = useState<Editor|null>(null); const [search,setSearch] = useState(''); const [filter,setFilter] = useState('all');
+ const [sort,setSort] = useState('newest'); const [density,setDensity] = useState(330); const generation = useRef(0);
+ const orgId = org?.id;
+ useEffect(()=>{setSort(localStorage.getItem('norma-campaign-sort') || 'newest');setDensity(Number(localStorage.getItem('norma-campaign-density')) || 330);},[]);
+ const api = useCallback(async (path='', options:RequestInit={}) => {
+ const headers = new Headers(options.headers); headers.set('X-Org-Id',orgId || '');
+ if (options.body) headers.set('Content-Type','application/json');
+ const res = await fetch('/api/campaigns'+path,{...options,headers,cache:'no-store'});
+ if (!res.ok) { const data = await res.json().catch(()=>({})); throw new Error(data.error || `Request failed (${res.status})`); }
+ return res;
+ },[orgId]);
+ const reload = useCallback(async()=>{
+ if (!orgId) return; const gen = generation.current;
+ setLoading(true);setError('');
+ try {
+ const next = await (await api()).json();
+ const nextDetail = selected ? await (await api('/'+selected)).json() : null;
+ if(gen===generation.current) {setWorkspace(next);setDetail(nextDetail);}
+ } catch(err) { if(gen===generation.current) setError((err as Error).message); }
+ finally { if(gen===generation.current) setLoading(false); }
+ },[api,orgId,selected]);
+ useEffect(()=>{generation.current++;setWorkspace(null);setSelected(null);setDetail(null);setEditor(null);setError('');setSearch('');},[orgId]);
+ useEffect(()=>{void reload(); return ()=>{generation.current++;};},[reload]);
+ async function mutate(path:string, data:unknown, method='PATCH') {
+ setBusy(true);setError('');
+ try { await api(path,{method,body:JSON.stringify(data)});await reload(); }
+ catch(err) {setError((err as Error).message);} finally {setBusy(false);}
+ }
+ async function exportCsv() {
+ try {const res=await api('/export'); const url=URL.createObjectURL(await res.blob()); const a=document.createElement('a');a.href=url;a.download='norma-supporters.csv';a.click();URL.revokeObjectURL(url);}
+ catch(err){setError((err as Error).message);}
+ }
+ function selectCampaign(value:string|null){setSelected(value);setDetail(null);setTab('Campaigns');setSearch('');}
+ const campaigns=(workspace?.campaigns ?? []).filter(c=>(filter==='all'||c.status===filter)&&`${c.title} ${c.summary}`.toLowerCase().includes(search.toLowerCase())).sort((a,b)=>sort==='title'?a.title.localeCompare(b.title):sort==='supporters'?(b.supporter_count||0)-(a.supporter_count||0):sort==='oldest'?a.created_at.localeCompare(b.created_at):b.created_at.localeCompare(a.created_at));
+ const supporters=(workspace?.supporters ?? []).filter(s=>`${s.full_name} ${s.email}`.toLowerCase().includes(search.toLowerCase()));
+ const tasks=selected ? detail?.tasks ?? [] : workspace?.tasks ?? [];
+ return <div className={styles.workspace}>
+ <div className={styles.topline}><Link href="/" className={styles.brand}><span className={styles.mark}>N</span> Norma <span className={styles.muted}>/ Campaigns</span></Link>
+ <div className={styles.row}><label className={styles.field}>Organization<select aria-label="Campaign organization" value={orgId||''} onChange={e=>switchOrg(e.target.value)} disabled={role!=='admin'}><option value="">Choose an organization</option>{orgs.map(o=><option key={o.id} value={o.id}>{o.org_name}</option>)}</select></label>
+ <button className={`${styles.button} ${styles.secondary}`} onClick={()=>void reload()} disabled={loading||!orgId} aria-label="Refresh workspace"><RefreshCw size={16}/></button></div>
+ </div>
+ <div className={styles.hero}><div><div className={styles.eyebrow}>Organize. Fundraise. Bring people together.</div><h1>{selected ? detail?.campaign.title || 'Campaign workspace' : 'One campaign. Every way to act.'}</h1><p>{selected ? detail?.campaign.summary : 'Turn a first signature into lasting participation. Keep your actions, supporters, events, and follow-ups connected.'}</p></div>
+ {!selected && orgId && <button className={styles.button} onClick={()=>setEditor({type:'campaign'})}><Plus size={17}/> Create campaign</button>}
+ </div>
+ {workspace?.sandbox && <div className={styles.note}><strong>Sandbox workspace.</strong> Forms use test data. Pledges do not collect money, and follow-ups do not send messages.</div>}
+ {error && <div className={styles.error} role="alert">{error} <button className={styles.textbutton} onClick={()=>void reload()}>Retry</button></div>}
+ {(authLoading||orgLoading) && <p role="status">Loading your workspace…</p>}
+ {!authLoading && role && !['admin','staff'].includes(role) ? <Empty title="Organizer access required"><p>Your account does not have campaign-management access.</p><a className={styles.button} href="/pulse">Return to Pulse</a></Empty> : !orgId && !orgLoading ? <Empty title="Choose your organization"><p>Select an organization above to open its campaigns and supporters.</p></Empty> : <>
+ {!selected && workspace && <div className={styles.stats}>
+ {[['Campaigns',workspace.stats.campaigns,'From draft to completion'],['Supporters',workspace.stats.supporters,'Connected across your actions'],['Pledged',dollars(workspace.stats.pledged_cents),'Commitments, not collected funds'],['Participation',workspace.stats.participation,'Signatures, RSVPs and actions']].map(([label,value,note])=><div className={styles.stat} key={label}><span className={styles.eyebrow}>{label}</span><strong>{value}</strong><small>{note}</small></div>)}
+ </div>}
+ {selected ? <div className={styles.row}><button className={styles.textbutton} onClick={()=>selectCampaign(null)}><ArrowLeft size={14} style={{display:'inline'}}/> All campaigns</button>{detail && <><Badge value={detail.campaign.status}/><button className={styles.textbutton} onClick={()=>setEditor({type:'campaign',campaign:detail.campaign})}>Edit campaign</button><label className={styles.field}>Campaign status<select aria-label="Campaign status" value={detail.campaign.status} disabled={busy} onChange={e=>void mutate('/'+selected,{status:e.target.value})}>{['draft','active','paused','completed','archived'].map(v=><option key={v}>{v}</option>)}</select></label></>}</div> : <div className={styles.tabs} role="tablist" aria-label="Campaign workspace sections">{['Campaigns','Supporters','Follow-ups','Insights','Connections'].map(t=><button role="tab" aria-selected={tab===t} className={styles.tab} key={t} onClick={()=>{setTab(t);setSearch('');}}>{t}</button>)}</div>}
+ {loading && !workspace && <p role="status">Loading campaigns…</p>}
+ {!selected && tab==='Campaigns' && workspace && <>
+ <div className={styles.controls}><input aria-label="Search campaigns" type="search" placeholder="Find a campaign…" value={search} onChange={e=>setSearch(e.target.value)}/>
+ <label className={styles.field}>Status<select value={filter} onChange={e=>setFilter(e.target.value)}><option value="all">All statuses</option>{['draft','active','paused','completed','archived'].map(v=><option key={v}>{v}</option>)}</select></label>
+ <label className={styles.field}>Sort<select value={sort} onChange={e=>{setSort(e.target.value);localStorage.setItem('norma-campaign-sort',e.target.value);}}><option value="newest">Newest</option><option value="oldest">Oldest</option><option value="title">Title A–Z</option><option value="supporters">Most supporters</option></select></label>
+ <label className={styles.field}>Card size<input type="range" min="240" max="480" step="20" value={density} onChange={e=>{setDensity(Number(e.target.value));localStorage.setItem('norma-campaign-density',e.target.value);}}/></label>
+ </div>
+ {campaigns.length ? <div className={styles.grid} style={{'--card-width':`${density}px`} as CSSProperties}>{campaigns.map(c=><article className={styles.card} key={c.id}>
+ <div className={styles.cardhead}><Megaphone size={23}/><Badge value={c.status}/></div><h3><button className={styles.textbutton} style={{fontSize:19}} onClick={()=>selectCampaign(c.id)}>{c.title}</button></h3><p>{c.summary || 'Add a summary to tell your team what this campaign will achieve.'}</p>
+ <div className={styles.progress}><span style={{width:`${Math.min(100,(c.supporter_count||0)/c.goal_supporters*100)}%`}}/></div><div className={styles.row}><strong>{c.supporter_count || 0}</strong><span className={styles.muted}>of {c.goal_supporters.toLocaleString()} supporters</span></div><DateChip value={c.created_at}/>
+ <div className={styles.cardfoot}><span>{c.action_count} actions · {dollars(c.pledged_cents||0)} pledged</span><button className={styles.textbutton} onClick={()=>selectCampaign(c.id)}>Open campaign ↗</button></div>
+ </article>)}</div> : <Empty title={search||filter!=='all'?'No matching campaigns':'Start with one shared purpose'}><p>Create a campaign, then add a petition, fundraiser, event, volunteer opportunity, or advocacy action.</p><button className={styles.button} onClick={()=>setEditor({type:'campaign'})}>Create your first campaign</button></Empty>}
+ <div className={styles.sectionhead}><h2>Build a connected journey</h2><span className={styles.muted}>Five ways to participate, one supporter record</span></div><div className={styles.grid}>{ACTION_KINDS.map((kind,i)=><div className={styles.card} key={kind}><span className={styles.eyebrow}>0{i+1}</span><h3>{KIND_LABEL[kind]}</h3><p>{({petition:'Gather signatures around a clear request.',fundraiser:'Capture giving intentions or link a hosted contribution form.',event:'Register attendees, respect capacity, and track check-ins.',volunteer:'Give willing supporters a concrete next step.',advocacy:'Share an action and its recipient; record participation.'})[kind]}</p></div>)}</div>
+ </>}
+ {selected && detail && <>
+ <DateChip value={detail.campaign.created_at}/><div className={styles.sectionhead}><h2>Ways to take part</h2><button className={styles.button} onClick={()=>setEditor({type:'action',kind:'petition'})}><Plus size={17}/> Add action</button></div>
+ {detail.actions.length ? <div className={styles.grid}>{detail.actions.map(a=><article className={styles.card} key={a.id}><div className={styles.cardhead}><span className={styles.eyebrow}>{KIND_LABEL[a.kind]}</span><Badge value={a.status}/></div><h3>{a.title}</h3><p>{a.description}</p>{a.kind==='event' && <p><CalendarDays size={13} style={{display:'inline'}}/> {timestamp(a.config.starts_at!)} · {a.config.location}</p>}
+ <div className={styles.progress}><span style={{width:`${Math.min(100,(a.participation_count||0)/(a.config.capacity || a.config.goal || detail.campaign.goal_supporters)*100)}%`}}/></div><p><strong>{a.participation_count}</strong> participants {a.kind==='fundraiser'&&` · ${dollars(a.pledged_cents||0)} pledged`}{a.kind==='event'&&` / ${a.config.capacity} places`}</p><DateChip value={a.created_at}/>
+ <div className={styles.cardfoot}><a href={`/act/${a.id}?preview=1`} target="_blank" rel="noreferrer">Preview ↗</a><button className={styles.textbutton} onClick={()=>setEditor({type:'action',action:a})}>Edit</button><button className={styles.textbutton} disabled={busy} onClick={()=>void mutate('/actions/'+a.id,{status:a.status==='active'?'paused':'active'})}>{a.status==='active'?'Pause':'Activate'}</button>{a.status==='active'&&detail.campaign.status==='active'&&<a href={`/act/${a.id}`} target="_blank" rel="noreferrer">Open form ↗</a>}</div>
+ </article>)}</div> : <Empty title="Give people a way in"><p>Add your first action. Every response becomes part of this campaign’s supporter history.</p><button className={styles.button} onClick={()=>setEditor({type:'action',kind:'petition'})}>Add an action</button></Empty>}
+ <div className={styles.sectionhead}><h2>Participation</h2><span className={styles.muted}>Latest {detail.participation.length} responses</span></div>
+ {detail.participation.length ? <div className={styles.tablewrap}><table className={styles.table}><thead><tr><th>Supporter</th><th>Action</th><th>Response</th><th>Source</th><th>Created</th></tr></thead><tbody>{detail.participation.map(p=><tr key={p.id}><td><strong>{p.full_name}</strong><small>{p.email}</small></td><td>{p.action_title}<small>{KIND_LABEL[p.kind]}</small></td><td>{p.kind==='event'?<select aria-label={`Attendance for ${p.full_name}`} value={p.status} disabled={busy} onChange={e=>void mutate('/participation/'+p.id,{status:e.target.value})}>{['registered','checked_in','cancelled'].map(v=><option key={v}>{v}</option>)}</select>:p.kind==='fundraiser'?`${dollars(p.amount_cents)} / ${p.frequency} pledge`:p.status}<small>{p.consent_requested?'Email update request pending confirmation':'No email updates requested'}</small></td><td>{p.source}</td><td title={p.created_at}>{timestamp(p.created_at)}</td></tr>)}</tbody></table></div>:<p className={styles.muted}>Responses will appear here after an active form is submitted.</p>}
+ {renderTasks()}
+ </>}
+ {!selected && tab==='Supporters' && workspace && <><div className={styles.sectionhead}><div><h2>People, connected.</h2><p className={styles.muted}>A single record across your campaign actions. Latest 500 supporters.</p></div><button className={`${styles.button} ${styles.secondary}`} onClick={()=>void exportCsv()}><Download size={16}/> Export all (up to 10,000)</button></div><div className={styles.controls}><input type="search" aria-label="Search supporters" placeholder="Find by name or email…" value={search} onChange={e=>setSearch(e.target.value)}/></div><div className={styles.note}>An email update request is not a verified subscription. Export includes consent-request status; do not use it as a send-ready mailing list.</div>{supporters.length?<div className={styles.tablewrap}><table className={styles.table}><thead><tr><th>Supporter</th><th>Participation</th><th>Pledged</th><th>Email updates</th><th>Created</th></tr></thead><tbody>{supporters.map(s=><tr key={s.id}><td><strong>{s.full_name}</strong><small>{s.email}</small></td><td>{s.participation_count} actions<small>{s.kinds.map(k=>KIND_LABEL[k]).join(', ')}</small></td><td>{dollars(s.pledged_cents)}</td><td>{s.consent_requests?'Confirmation needed':'Not requested'}</td><td title={s.created_at}>{timestamp(s.created_at)}</td></tr>)}</tbody></table></div>:<Empty title="Your supporter history starts here"><p>Participation connects people across campaigns without creating duplicate records.</p></Empty>}</>}
+ {!selected && tab==='Follow-ups' && renderTasks()}
+ {!selected && tab==='Insights' && workspace && <><div className={styles.sectionhead}><h2>See what participation leads to.</h2></div><div className={styles.two}><article className={styles.card}><h3>Organizing outcomes</h3><ul className={styles.list}><li>{workspace.stats.volunteer_signups} volunteer signups</li><li>{workspace.stats.checked_in} event check-ins</li><li>{workspace.stats.monthly_pledges} monthly giving intentions</li><li>{dollars(workspace.stats.pledged_cents)} in pledges · payment collection is separate</li></ul><Link href="/?tab=donations" className={styles.textbutton}>Open the existing donation ledger ↗</Link></article><article className={styles.card}><h3>Where participation starts</h3><p>Share a form with <code>?source=newsletter</code> to attribute responses.</p><ul className={styles.list}>{workspace.sources.map(s=><li key={s.source}><strong>{s.source}</strong> · {s.count} responses · {dollars(s.pledged_cents)} pledged</li>)}</ul>{!workspace.sources.length&&<p>No attribution data yet.</p>}</article></div><div className={styles.sectionhead}><h2>Workspace activity</h2></div><div className={styles.card}>{workspace.audit.length?workspace.audit.map(a=><div className={styles.timeline} key={a.id}><p><strong>{a.event.replaceAll('.',' · ')}</strong> — {a.actor}</p><time className={styles.date} title={a.created_at}>{timestamp(a.created_at)}</time></div>):<p>No activity recorded yet.</p>}</div></>}
+ {!selected && tab==='Connections' && <><div className={styles.sectionhead}><h2>Your existing tools, in the same workspace.</h2></div><div className={styles.grid}>{[
+ ['Petition studio','Write, manage, and deliver petition campaigns.','/?tab=petitions'],['Donation ledger','Review recorded giving separately from campaign pledges.','/?tab=donations'],['Contacts','Work with your existing contact directory and imports.','/?tab=contacts'],['Email workspace','Prepare correspondence through your configured email tools.','/?tab=gmail-crm'],['Social workspace','Prepare and manage your social content.','/?tab=social-overview'],['API connections','Inspect Norma’s existing connected services.','/?tab=integrations'],
+ ].filter(([, , url])=>role==='admin'||!['/?tab=social-overview','/?tab=integrations'].includes(url)).map(([title,copy,url])=><a className={styles.card} href={url} key={title}><ArrowUpRight size={18}/><h3>{title}</h3><p>{copy}</p></a>)}</div><div className={styles.note}><strong>Giving and delivery readiness.</strong> Fundraiser forms can link to a hosted ActBlue contribution page. Card collection, recurring billing, refunds, email/SMS sends, and dialing need verified provider integrations. This workspace records pledges and prepares follow-ups; it does not represent those integrations as live.</div></>}
+ </>}
+ {editor && orgId && <EditorDialog key={orgId+editor.type+(editor.action?.id||editor.campaign?.id||editor.task?.id||'new')} editor={editor} campaigns={workspace?.campaigns||[]} selected={selected} onClose={()=>setEditor(null)} onSave={async(data)=>{
+ let path='';let method='POST';
+ if(editor.type==='campaign'){path=editor.campaign?'/'+editor.campaign.id:'';method=editor.campaign?'PATCH':'POST';}
+ if(editor.type==='action'){path=editor.action?'/actions/'+editor.action.id:'/'+selected+'/actions';method=editor.action?'PATCH':'POST';}
+ if(editor.type==='task'){path=editor.task?'/tasks/'+editor.task.id:'/tasks';method=editor.task?'PATCH':'POST';}
+ const res=await (await api(path,{method,body:JSON.stringify(data)})).json();setEditor(null);
+ if(editor.type==='campaign'&&!editor.campaign)selectCampaign(res.campaign.id);else await reload();
+ }}/>}
+ </div>;
+ function renderTasks(){return <><div className={styles.sectionhead}><div><h2>The next good step.</h2><p className={styles.muted}>Organizer tasks and message drafts. “Ready” never sends a message.</p></div><button className={styles.button} disabled={!workspace?.campaigns.length} onClick={()=>setEditor({type:'task'})}><Plus size={16}/> Add follow-up</button></div>{tasks.length?<div className={styles.grid}>{tasks.map(t=><article className={styles.card} key={t.id}><div className={styles.cardhead}><span className={styles.eyebrow}>{t.channel}</span><Badge value={t.status}/></div><h3>{t.title}</h3>{t.supporter_name&&<p><strong>{t.supporter_name}</strong><br/><span className={styles.muted}>{t.supporter_email}</span></p>}{!selected&&<button className={styles.textbutton} onClick={()=>selectCampaign(t.campaign_id)}>View campaign: {t.campaign_title||'Open campaign'} ↗</button>}<p style={{whiteSpace:'pre-wrap'}}>{t.body}</p><DateChip value={t.created_at}/><div className={styles.cardfoot}><button className={styles.textbutton} onClick={()=>setEditor({type:'task',task:t})}>Edit draft</button><button className={styles.textbutton} disabled={busy} onClick={()=>void mutate('/tasks/'+t.id,{status:t.status==='draft'?'ready':t.status==='ready'?'completed':'draft'})}>{t.status==='draft'?'Mark ready':t.status==='ready'?'Mark task complete':'Reopen draft'}</button></div></article>)}</div>:<Empty title="Keep the momentum going"><p>New participation creates a review task automatically. Add your own email draft, event reminder, or volunteer checklist.</p></Empty>}</>;}
+}
+
+function EditorDialog({editor,campaigns,selected,onClose,onSave}:{editor:Editor;campaigns:Campaign[];selected:string|null;onClose:()=>void;onSave:(data:Record<string,unknown>)=>Promise<void>}) {
+ const ref=useRef<HTMLDialogElement>(null);const [kind,setKind]=useState<ActionKind>(editor.action?.kind||editor.kind||'petition');const [busy,setBusy]=useState(false);const [error,setError]=useState('');
+ const action=editor.action;const c=editor.campaign;const task=editor.task;
+ useEffect(()=>{ref.current?.showModal();return ()=>ref.current?.close();},[]);
+ const title=(action||c||task?'Edit ':'Create ')+(editor.type==='task'?'follow-up':editor.type);
+ async function submit(e:FormEvent<HTMLFormElement>){e.preventDefault();const f=new FormData(e.currentTarget);const val=(key:string)=>String(f.get(key)||'');setBusy(true);setError('');
+ try {let data:Record<string,unknown>={title:val('title')};
+ if(editor.type==='campaign')data={...data,summary:val('summary'),goal_supporters:Number(val('goal_supporters')),goal_cents:Math.round(Number(val('goal_dollars'))*100)};
+ if(editor.type==='task')data={...data,campaign_id:val('campaign_id'),body:val('body'),channel:val('channel')};
+ if(editor.type==='action'){
+ const settings:Record<string,unknown>={};
+ if(kind==='petition'||kind==='advocacy')settings.target=val('target');
+ if(kind==='petition')settings.goal=Number(val('goal'));
+ if(kind==='event'){settings.location=val('location');settings.capacity=Number(val('capacity'));settings.starts_at=new Date(val('starts_at')).toISOString();settings.ends_at=new Date(val('ends_at')).toISOString();}
+ if(kind==='volunteer'||kind==='advocacy')settings.instructions=val('instructions');
+ if(kind==='fundraiser'){settings.suggested_amounts=val('amounts').split(',').map(s=>Math.round(Number(s.trim())*100));settings.hosted_url=val('hosted_url');}
+ data={...data,kind,description:val('description'),config:settings};
+ }await onSave(data);
+ }catch(err){setError((err as Error).message);}finally{setBusy(false);}}
+ const localDate=(v?:string)=>{if(!v)return '';const d=new Date(v);return new Date(d.getTime()-d.getTimezoneOffset()*60000).toISOString().slice(0,16);};
+ return <dialog ref={ref} className={styles.modal} aria-label={title} onCancel={e=>{if(busy)e.preventDefault();else onClose();}}><div className={styles.modalhead}><h2>{title}</h2><button className={`${styles.button} ${styles.secondary}`} aria-label="Close editor" disabled={busy} onClick={onClose}><X size={16}/></button></div><form onSubmit={submit}>
+ {error&&<div className={styles.error} role="alert">{error}</div>}<div className={styles.fields}>
+ {editor.type==='action'&&<label className={`${styles.field} ${styles.full}`}>Action type<select value={kind} onChange={e=>setKind(e.target.value as ActionKind)} disabled={!!action}>{ACTION_KINDS.map(k=><option key={k} value={k}>{KIND_LABEL[k]}</option>)}</select></label>}
+ {editor.type==='task'&&<label className={`${styles.field} ${styles.full}`}>Campaign<select name="campaign_id" defaultValue={task?.campaign_id||selected||campaigns[0]?.id} required>{campaigns.map(campaign=><option key={campaign.id} value={campaign.id}>{campaign.title}</option>)}</select></label>}
+ <label className={`${styles.field} ${styles.full}`}>Title<input name="title" required maxLength={160} defaultValue={action?.title||c?.title||task?.title} autoFocus/></label>
+ {editor.type==='campaign'&&<><label className={`${styles.field} ${styles.full}`}>Campaign summary<textarea name="summary" rows={3} maxLength={4000} defaultValue={c?.summary}/></label><label className={styles.field}>Supporter goal<input type="number" name="goal_supporters" min="1" max="100000000" defaultValue={c?.goal_supporters||100} required/></label><label className={styles.field}>Fundraising goal (USD)<input type="number" name="goal_dollars" min="0" max="1000000000" step=".01" defaultValue={Number(c?.goal_cents||0)/100}/></label></>}
+ {editor.type==='task'&&<><label className={styles.field}>Channel<select name="channel" defaultValue={task?.channel||'organizing'}>{['organizing','email','sms','phone','canvass'].map(v=><option key={v}>{v}</option>)}</select></label><label className={`${styles.field} ${styles.full}`}>Draft or task notes<textarea name="body" rows={5} maxLength={8000} defaultValue={task?.body}/></label><p className={`${styles.muted} ${styles.full}`}>Saved for your team to review. No messages or calls are sent.</p></>}
+ {editor.type==='action'&&<><label className={`${styles.field} ${styles.full}`}>Description<textarea name="description" rows={3} maxLength={6000} defaultValue={action?.description}/></label>
+ {(kind==='petition'||kind==='advocacy')&&<label className={`${styles.field} ${styles.full}`}>Decision-maker or recipient<input name="target" required maxLength={200} defaultValue={action?.config.target}/></label>}
+ {kind==='petition'&&<label className={styles.field}>Signature goal<input name="goal" type="number" min="1" max="100000000" defaultValue={action?.config.goal||100} required/></label>}
+ {kind==='fundraiser'&&<><label className={`${styles.field} ${styles.full}`}>Suggested amounts (USD, comma-separated)<input name="amounts" defaultValue={action?.config.suggested_amounts?.map(n=>n/100).join(', ')||'10, 25, 50, 100'} required/></label><label className={`${styles.field} ${styles.full}`}>Hosted ActBlue contribution URL (optional)<input type="url" name="hosted_url" placeholder="https://secure.actblue.com/donate/your-form" defaultValue={action?.config.hosted_url}/></label><p className={`${styles.muted} ${styles.full}`}>This form records giving intentions. A hosted contribution link opens the external provider; collected payments are not inferred from pledges.</p></>}
+ {kind==='event'&&<><label className={styles.field}>Starts (your local time)<input type="datetime-local" name="starts_at" required defaultValue={localDate(action?.config.starts_at)}/></label><label className={styles.field}>Ends (your local time)<input type="datetime-local" name="ends_at" required defaultValue={localDate(action?.config.ends_at)}/></label><label className={`${styles.field} ${styles.full}`}>Location or meeting details<input name="location" required maxLength={300} defaultValue={action?.config.location}/></label><label className={styles.field}>Capacity<input name="capacity" type="number" min="1" max="100000" defaultValue={action?.config.capacity||25} required/></label></>}
+ {(kind==='volunteer'||kind==='advocacy')&&<label className={`${styles.field} ${styles.full}`}>Participation instructions<textarea name="instructions" rows={4} maxLength={3000} defaultValue={action?.config.instructions}/></label>}
+ </>}
+ </div><div className={styles.modalfoot}><button type="button" className={`${styles.button} ${styles.secondary}`} onClick={onClose} disabled={busy}>Cancel</button><button type="submit" className={styles.button} disabled={busy}>{busy?'Saving…':'Save '+(editor.type==='task'?'follow-up':editor.type)}</button></div>
+ </form></dialog>;
+}
diff --git a/components/campaigns/campaigns.module.css b/components/campaigns/campaigns.module.css
new file mode 100644
index 0000000..f778ab4
--- /dev/null
+++ b/components/campaigns/campaigns.module.css
@@ -0,0 +1,90 @@
+.workspace { --ink:#17382e; --muted:#596b63; --line:#dce4da; --paper:#f7f8f2; --accent:#f0bd53; color:var(--ink); background:var(--paper); font-family:'Plus Jakarta Sans',sans-serif; padding:32px clamp(16px,3vw,48px) 70px; min-height:calc(100vh - 90px); }
+.workspace * { box-sizing:border-box; }
+.workspace h1,.workspace h2,.workspace h3,.workspace p { margin:0; }
+.workspace h1 { font-family:Georgia,serif; font-weight:400; font-size:clamp(32px,3.4vw,50px); line-height:1.1; letter-spacing:-1.4px; }
+.workspace h2 { font-family:Georgia,serif; font-weight:400; font-size:29px; }
+.workspace h3 { font-size:18px; font-weight:700; }
+.workspace a { color:inherit; }
+.topline,.row,.controls,.cardhead,.cardfoot,.modalhead { display:flex; align-items:center; gap:12px; }
+.topline,.cardhead,.cardfoot,.modalhead { justify-content:space-between; }
+.topline { padding-bottom:24px; border-bottom:1px solid var(--line); flex-wrap:wrap; }
+.brand { font-weight:800; font-size:17px; display:flex; gap:10px; align-items:center; }
+.mark { background:var(--ink); color:white; display:grid; place-items:center; border-radius:10px; width:36px; height:36px; }
+.eyebrow { text-transform:uppercase; letter-spacing:2px; font-size:10px; font-weight:800; color:var(--muted); }
+.hero { margin:32px 0 24px; display:flex; justify-content:space-between; align-items:flex-end; gap:24px; }
+.hero p { color:var(--muted); max-width:660px; margin-top:14px; font-size:14px; line-height:1.7; }
+.hero .eyebrow { margin-bottom:12px; }
+.button { border:1px solid var(--ink); background:var(--ink); color:#fff !important; min-height:42px; padding:10px 17px; display:inline-flex; justify-content:center; align-items:center; gap:8px; border-radius:9px; font-size:13px; font-weight:700; cursor:pointer; white-space:nowrap; text-decoration:none; }
+.button:hover { background:#285641; }
+.button:disabled { opacity:.55; cursor:wait; }
+.secondary { background:white; color:var(--ink) !important; border-color:var(--line); }
+.secondary:hover { background:#edf2e9; }
+.textbutton { padding:4px 0; border:0; background:none; color:var(--ink); font-weight:700; font-size:12px; cursor:pointer; text-align:left; }
+.button:focus-visible,.textbutton:focus-visible,.tab:focus-visible,.workspace input:focus-visible,.workspace select:focus-visible,.workspace textarea:focus-visible { outline:3px solid #cf941d; outline-offset:3px; }
+.note { background:#fff6df; border:1px solid #eddbad; padding:12px 16px; border-radius:8px; font-size:12px; color:#654711; line-height:1.6; margin:16px 0; }
+.error { border:1px solid #d99689; color:#842c24; background:#fff2ef; padding:14px; border-radius:8px; margin:14px 0; font-size:13px; }
+.success { padding:14px; background:#e4f1de; color:#17382e; border:1px solid #b7d6ae; border-radius:8px; margin:14px 0; }
+.stats { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); border:1px solid var(--line); background:white; border-radius:12px; overflow:hidden; margin:24px 0; }
+.stat { padding:21px; border-right:1px solid var(--line); }
+.stat:last-child { border:0; }
+.stat strong { display:block; font-size:29px; letter-spacing:-1px; margin:8px 0 4px; }
+.stat small,.muted { color:var(--muted); font-size:12px; line-height:1.6; }
+.tabs { display:flex; gap:25px; border-bottom:1px solid var(--line); margin-bottom:25px; overflow:auto; }
+.tab { border:0; border-bottom:3px solid transparent; padding:14px 0; font-size:13px; color:var(--muted); background:none; cursor:pointer; white-space:nowrap; }
+.tab[aria-selected=true] { color:var(--ink); border-bottom-color:var(--ink); font-weight:800; }
+.controls { flex-wrap:wrap; margin:18px 0 22px; }
+.controls input[type=search] { flex:1; min-width:180px; }
+.workspace input:not([type=checkbox]):not([type=range]),.workspace select,.workspace textarea { border:1px solid #b9c6b8; border-radius:7px; background:#fff; color:var(--ink); padding:10px 12px; font-size:13px; min-height:42px; }
+.workspace input[type=range] { accent-color:var(--ink); width:100px; }
+.workspace input[type=checkbox] { accent-color:var(--ink); width:18px; height:18px; flex-shrink:0; }
+.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,var(--card-width,310px)),1fr)); gap:18px; }
+.card { border:1px solid var(--line); background:#fff; border-radius:12px; padding:22px; min-width:0; transition:box-shadow .18s; }
+.card:hover { box-shadow:0 6px 18px #17382e08; }
+.card h3 { margin:17px 0 9px; }
+.card p { font-size:13px; color:var(--muted); line-height:1.7; }
+.cardhead { align-items:flex-start; }
+.badge { display:inline-flex; border-radius:5px; padding:4px 8px; background:#edf0e9; color:#4b6051; font-size:10px; font-weight:800; text-transform:uppercase; letter-spacing:.5px; }
+.active { background:#dff0d6; color:#245923; }
+.date { display:block; margin-top:12px; font-size:10px; color:var(--muted); }
+.progress { height:5px; border-radius:4px; background:#e8ede5; margin:15px 0 10px; overflow:hidden; }
+.progress span { height:100%; display:block; background:#537f50; border-radius:4px; }
+.cardfoot { border-top:1px solid var(--line); padding-top:14px; margin-top:18px; flex-wrap:wrap; font-size:11px; color:var(--muted); }
+.empty { border:1px dashed #afbfab; border-radius:12px; padding:44px 24px; text-align:center; background:#f0f4e9; }
+.empty p { margin:12px auto 22px; max-width:490px; color:var(--muted); font-size:14px; line-height:1.7; }
+.sectionhead { display:flex; align-items:center; justify-content:space-between; gap:20px; margin:28px 0 18px; }
+.two { display:grid; grid-template-columns:1.35fr 1fr; gap:22px; }
+.tablewrap { overflow:auto; border:1px solid var(--line); border-radius:10px; background:#fff; }
+.table { width:100%; border-collapse:collapse; font-size:12px; }
+.table th { text-align:left; font-size:10px; text-transform:uppercase; letter-spacing:.8px; color:var(--muted); background:#f1f5eb; white-space:nowrap; }
+.table th,.table td { padding:14px 17px; border-bottom:1px solid var(--line); vertical-align:top; }
+.table tr:last-child td { border-bottom:0; }
+.table td strong { display:block; font-size:13px; }
+.table td small { display:block; color:var(--muted); margin-top:5px; }
+.stack { display:flex; flex-direction:column; gap:14px; }
+.workspace select { appearance:none; -webkit-appearance:none; padding-right:36px; background-image:linear-gradient(45deg,transparent 50%,#17382e 50%),linear-gradient(135deg,#17382e 50%,transparent 50%); background-position:calc(100% - 17px) 50%,calc(100% - 12px) 50%; background-size:5px 5px; background-repeat:no-repeat; }
+.fields { display:grid; grid-template-columns:1fr 1fr; gap:17px; }
+.field { display:flex; flex-direction:column; gap:7px; font-size:12px; font-weight:700; min-width:0; }
+.full { grid-column:1/-1; }
+.check { display:flex; align-items:flex-start; gap:10px; font-size:12px; line-height:1.6; font-weight:400; }
+.modal { width:min(650px,calc(100vw - 28px)); max-height:90vh; border:1px solid var(--line); padding:0; border-radius:16px; color:var(--ink); background:var(--paper); }
+.modal::backdrop { background:#0b251ab8; backdrop-filter:blur(3px); }
+.modalhead { padding:22px; border-bottom:1px solid var(--line); }
+.modal form { padding:24px; }
+.modalfoot { display:flex; gap:12px; justify-content:flex-end; margin-top:24px; }
+.list { margin:16px 0; padding:0; list-style:none; }
+.list li { border-bottom:1px solid var(--line); padding:13px 0; font-size:13px; line-height:1.6; }
+.timeline { border-left:2px solid #d0dfca; padding:0 0 0 16px; margin:14px 0; }
+.timeline p { font-size:12px; margin:0 0 4px; }
+.public { max-width:1050px; margin:auto; }
+.publichero { padding:50px 0 35px; border-bottom:1px solid var(--line); }
+.publichero h1 { max-width:800px; margin:18px 0; }
+.publicbody { display:grid; grid-template-columns:1.1fr 1fr; gap:40px; margin:35px 0; align-items:start; }
+.prose { font-size:15px; line-height:1.9; white-space:pre-wrap; overflow-wrap:anywhere; }
+.amounts { display:grid; grid-template-columns:repeat(3,1fr); gap:8px; }
+.amount { border:1px solid var(--line); background:white; border-radius:7px; padding:12px; cursor:pointer; color:var(--ink); font-weight:700; }
+.amount[aria-pressed=true] { border-color:var(--ink); background:#e5efd9; }
+.formtitle { margin-bottom:20px !important; }
+.fineprint { font-size:11px; line-height:1.6; color:var(--muted); margin-top:18px !important; }
+@media(max-width:800px) { .hero { flex-direction:column; align-items:flex-start; } .stats { grid-template-columns:repeat(2,1fr); } .stat { border-bottom:1px solid var(--line); } .two,.publicbody { grid-template-columns:1fr; } .tabs { gap:20px; } .publichero { padding-top:25px; } }
+@media(max-width:480px) { .fields { grid-template-columns:1fr; } .workspace { padding:18px 15px 45px; } .stat { padding:14px; } .stat strong { font-size:25px; } .controls label { width:auto; } .sectionhead { align-items:flex-start; flex-direction:column; } .topline select { width:100%; } }
+@media(prefers-reduced-motion:reduce) { .card { transition:none; } }
diff --git a/db/026_campaign_platform.sql b/db/026_campaign_platform.sql
new file mode 100644
index 0000000..570d74b
--- /dev/null
+++ b/db/026_campaign_platform.sql
@@ -0,0 +1,57 @@
+-- Additive campaign workspace. Apply only to the intended organization's database.
+BEGIN;
+CREATE TABLE IF NOT EXISTS organizing_campaigns (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(), org_id uuid NOT NULL REFERENCES nonprofit_accounts(id),
+ title text NOT NULL, summary text NOT NULL DEFAULT '', status text NOT NULL DEFAULT 'draft' CHECK(status IN ('draft','active','paused','completed','archived')),
+ goal_supporters integer NOT NULL DEFAULT 100 CHECK(goal_supporters BETWEEN 1 AND 100000000),
+ goal_cents bigint NOT NULL DEFAULT 0 CHECK(goal_cents BETWEEN 0 AND 100000000000),
+ created_by text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE(id,org_id)
+);
+CREATE TABLE IF NOT EXISTS organizing_actions (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(), org_id uuid NOT NULL, campaign_id uuid NOT NULL,
+ kind text NOT NULL CHECK(kind IN ('petition','fundraiser','event','volunteer','advocacy')),
+ title text NOT NULL, description text NOT NULL DEFAULT '', status text NOT NULL DEFAULT 'draft' CHECK(status IN ('draft','active','paused')),
+ config jsonb NOT NULL DEFAULT '{}', created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(),
+ FOREIGN KEY(campaign_id,org_id) REFERENCES organizing_campaigns(id,org_id), UNIQUE(id,org_id)
+);
+CREATE TABLE IF NOT EXISTS organizing_supporters (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(), org_id uuid NOT NULL REFERENCES nonprofit_accounts(id),
+ email text NOT NULL, full_name text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE(org_id,email), UNIQUE(id,org_id)
+);
+CREATE TABLE IF NOT EXISTS organizing_participation (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(), org_id uuid NOT NULL, action_id uuid NOT NULL, supporter_id uuid NOT NULL,
+ status text NOT NULL DEFAULT 'registered' CHECK(status IN ('registered','checked_in','cancelled')),
+ amount_cents bigint NOT NULL DEFAULT 0 CHECK(amount_cents BETWEEN 0 AND 100000000),
+ frequency text NOT NULL DEFAULT 'once' CHECK(frequency IN ('once','monthly')),
+ source text NOT NULL DEFAULT 'direct', consent_requested boolean NOT NULL DEFAULT false,
+ consent_text text NOT NULL DEFAULT '', idempotency_key text NOT NULL, request_hash text NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(),
+ FOREIGN KEY(action_id,org_id) REFERENCES organizing_actions(id,org_id),
+ FOREIGN KEY(supporter_id,org_id) REFERENCES organizing_supporters(id,org_id),
+ UNIQUE(action_id,supporter_id), UNIQUE(action_id,idempotency_key)
+);
+CREATE TABLE IF NOT EXISTS organizing_tasks (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(), org_id uuid NOT NULL, campaign_id uuid NOT NULL,
+ participation_id uuid REFERENCES organizing_participation(id), title text NOT NULL, body text NOT NULL DEFAULT '',
+ channel text NOT NULL DEFAULT 'organizing' CHECK(channel IN ('email','sms','phone','canvass','organizing')),
+ status text NOT NULL DEFAULT 'draft' CHECK(status IN ('draft','ready','completed')),
+ created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(),
+ FOREIGN KEY(campaign_id,org_id) REFERENCES organizing_campaigns(id,org_id), UNIQUE(participation_id)
+);
+CREATE TABLE IF NOT EXISTS organizing_audit (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(), org_id uuid NOT NULL REFERENCES nonprofit_accounts(id),
+ actor text NOT NULL, event text NOT NULL, entity_id uuid NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+CREATE TABLE IF NOT EXISTS organizing_intake_limits (
+ key text PRIMARY KEY, hits integer NOT NULL DEFAULT 1, expires_at timestamptz NOT NULL
+);
+CREATE INDEX IF NOT EXISTS organizing_campaigns_org ON organizing_campaigns(org_id,created_at DESC);
+CREATE INDEX IF NOT EXISTS organizing_actions_org ON organizing_actions(org_id,campaign_id);
+CREATE INDEX IF NOT EXISTS organizing_participation_org ON organizing_participation(org_id,created_at DESC);
+CREATE INDEX IF NOT EXISTS organizing_participation_supporter ON organizing_participation(supporter_id);
+CREATE INDEX IF NOT EXISTS organizing_tasks_org ON organizing_tasks(org_id,created_at DESC);
+CREATE INDEX IF NOT EXISTS organizing_audit_org ON organizing_audit(org_id,created_at DESC);
+CREATE INDEX IF NOT EXISTS organizing_intake_limits_expiry ON organizing_intake_limits(expires_at);
+COMMIT;
diff --git a/docs/platform/DECISION.md b/docs/platform/DECISION.md
new file mode 100644
index 0000000..5fd6417
--- /dev/null
+++ b/docs/platform/DECISION.md
@@ -0,0 +1,9 @@
+# DTD architecture decision
+
+Decision: extend Norma with an organization-scoped campaign workspace, distinct action/participation models, and shared supporter identity.
+
+Run: /tmp/norma-platform-dtd-20260909. Actual voters: Codex and Qwen voted A (2/2 valid votes); Claude disabled under zero-cost mode; Grok, Kimi and Muse unavailable. No votes inferred from unavailable providers. Confidence: medium, reduced by limited provider availability.
+
+The separate Codex prosecutor/defender/judge review returned FINAL: KEEP. Strongest objection: centralizing supporter data can multiply authorization and consent mistakes. Controlling defense: integration does not require universal access; tenant scope, domain-specific records, and purpose/channel consent must be enforced at the server. Production/provider readiness remains unproven until the deployment-specific checks pass.
+
+Implementation constraints: organization scoped composite foreign keys; no donor participation implying outreach consent; money intent distinct from money received; no implicit send paths; public DTO omits supporter PII. Build and verification take place in isolated worktree and sdcc_test.
diff --git a/docs/platform/PLAN.md b/docs/platform/PLAN.md
new file mode 100644
index 0000000..8dedb24
--- /dev/null
+++ b/docs/platform/PLAN.md
@@ -0,0 +1,45 @@
+# Norma unified campaign platform
+
+Ticket: TK-11327-build-unified-norma-campaign-organizing
+Research date: 2026-09-09. Scope: ActBlue + MoveOn public product functions combined with Norma's existing modules. This is an original implementation, not a copy of their assets or an assertion of full provider parity.
+
+## Reference inventory
+
+| Capability | Primary reference | Norma delivery |
+| --- | --- | --- |
+| Custom contribution forms, recurring asks, tandem giving, event tickets, reporting, integrations | https://www.actblue.com/products/fundraising/ | Campaign fundraiser action with amount/frequency choices and contribution intents; validated hosted ActBlue handoff. Native money movement, refunds, split settlement and recurring billing remain provider work. |
+| Unified supporter record and activity | https://www.actblue.com/products/actblue-crm/ | Organization-scoped participant records tied to petitions, pledges, RSVPs and volunteer actions. No voter-file matching or demographic targeting. |
+| Texting, phone banking, canvassing, volunteer coordination | https://www.actblue.com/products/field-tools/ | Campaign outreach drafts and organizing tasks, volunteer intake and event attendance. External dialing/text delivery require configured providers. |
+| Petition creation, signing, updates and delivery | https://front.moveon.org/frequently-asked-questions/ | Campaign petition forms and unified participation ledger; existing Norma petition editor remains available. Update drafts and export evidence; actual external delivery is explicit. |
+| Petition-to-action organizing | https://front.moveon.org/resource/petitions__campaign_tips/ | Related fundraiser/event/volunteer/advocacy action pages, supporter activity, automatic organizer follow-up drafts and common reporting. |
+| Shareable campaign pages | https://www.actblue.com/solutions/state-and-local-campaigns/ | Responsive original action pages with draft preview, lifecycle controls and share links. |
+
+## Architecture
+
+Extend the existing Next.js application and PostgreSQL database. Add a Campaigns entry in the existing sidebar and a standalone /campaigns workspace available to admin/staff with organization-scoped APIs. Additive migration 026; no modifications to existing donation/petition records. A campaign joins action forms, supporters, participation, organizer tasks, and reports through composite organization foreign keys. Links to legacy modules preserve existing workflows.
+
+Public action intake records intentions/participation, never charges cards or sends messages. One-time and monthly intentions are labeled pledges. Hosted giving can link to validated secure.actblue.com contribution pages. Payment totals are never inferred from pledges. Outreach drafts remain drafts; consent requests are not treated as verified subscriptions. Publishing requires NORMA_CAMPAIGN_PUBLIC_ENABLED=true; preview mode is explicit, local, and uses sdcc_test only.
+
+All private routes require a valid signed admin/staff session. Staff cannot override their organization via headers. Public routes expose active form configuration and aggregate progress only, never supporter PII. Draft previews require organizer auth. Public POSTs are bounded, validated, same-origin checked, rate limited, and transactional. Event capacity checks lock the action; idempotency conflicts cannot create duplicate supporters or activity. Mutations have an organization-scoped audit trail.
+
+## Acceptance journeys
+
+- Create an organization-scoped campaign, reload and read it back; update details and lifecycle.
+- Add petition, fundraiser, event, volunteer and advocacy actions with type-specific validation.
+- Preview forms on desktop/mobile; submit to sandbox and see persisted success and activity.
+- Retry an intake request without double-counting; reject same key with changed payload.
+- Event RSVP honors capacity under concurrent requests; organizer can mark attendance/cancel.
+- Supporter record joins participation across forms, reports consent requests truthfully, supports name/email search and organization-wide CSV export (up to 10,000 records).
+- Follow-up drafts appear from action intake and can be edited/marked ready; no send operation exists.
+- Campaign insights separate pledged money, confirmed participation, volunteer/attendance metrics and attribution.
+- Anonymous private API access denied; foreign-org IDs rejected for staff; drafts hidden from anonymous visitors.
+- Publish is disabled outside configured sandbox or explicitly enabled deployment.
+- Chromium and WebKit exercise real UI forms with screenshots and browser error logging.
+
+## Deferred provider capabilities (not represented as implemented)
+
+Card/wallet vaults and one-click giving, settlement/refunds/chargebacks, recurring payment collection and cancellation, tandem split settlement, receipts and email/SMS delivery, phone dialing, voter-file and relational contact matching, paid ticket checkout, large-scale deliverability, native apps, live provider webhooks, production launch and availability/load guarantees. Each needs integration credentials, provider contracts/configuration and its own verified end-to-end proof.
+
+## Verification and rollout
+
+Worktree /Users/macstudio3/Projects/Norma-platform; feature/unified-campaign-platform. Existing :7400 stays on its deployed build. Run only additive migration on sdcc_test and isolated preview :7416. Build, scoped lint/type check, API security/capacity/idempotency lifecycle tests, real browser campaign creation and participation, and the existing durable battery. Save actual timestamps, commit identity, screenshots and commands under verification/platform. Keep the reviewable preview available; no remote push or live migration in this task.
diff --git a/docs/platform/RUNBOOK.md b/docs/platform/RUNBOOK.md
new file mode 100644
index 0000000..0b0e13b
--- /dev/null
+++ b/docs/platform/RUNBOOK.md
@@ -0,0 +1,60 @@
+# Campaign workspace preview
+
+Ticket: TK-11327-build-unified-norma-campaign-organizing.
+
+## Open the review build
+
+The isolated workspace is at http://127.0.0.1:7416/campaigns. Sign in with the repository's seeded **test** administrator (`admin` / `TestPass123!`), then select **Riverside Community · Sandbox**. These credentials belong only to `sdcc_test`.
+
+Open **A reading room for everyone** to review all five action types. Every action has a draft preview, lifecycle controls, a public form, and persisted participation. Open a form to submit a test response using an `example.test` address. Return to the campaign to review participation, or use Supporters, Follow-ups and Insights for the joined record, next task, and source attribution.
+
+Supporter export includes all organization records up to 10,000; the search field filters the on-screen list only. Follow-up tasks show their campaign and participant context. Marking a task ready never sends it.
+
+Demo data is synthetic and is deliberately retained for review. `scripts/seed-campaign-demo.mjs` generates its IDs in `verification/platform/demo.json`. API tests remove their owned operational fixtures; browser tests retain archived campaigns and synthetic responses as evidence.
+
+## Reproduce locally
+
+Use a configured `sdcc_test` database with Norma's existing seeded accounts. The preview script applies additive migration 026 only to that database. It cannot migrate `sdcc`.
+
+```sh
+env SESSION_SECRET=norma-test-secret-do-not-use-in-prod-0f3a9c DATABASE_URL=postgresql://127.0.0.1:5432/sdcc_test npm run build
+bash scripts/campaign-preview.sh
+```
+
+From another terminal in this worktree:
+
+```sh
+node tests/campaign-platform.mjs
+npx playwright test --config verification/platform/browser.config.ts
+node scripts/seed-campaign-demo.mjs
+GATE_PORT=7417 npm run gate
+```
+
+The gate runs its own isolated process with public campaign intake disabled. While that server is running, `NORMA_TEST_URL=http://127.0.0.1:7417 node tests/campaign-disabled.mjs` checks the default publication boundary. The preview must be rebuilt/restarted after source changes.
+
+## Explicit integration boundaries
+
+- `NORMA_CAMPAIGN_SANDBOX=true` works only when `DATABASE_URL` names `sdcc_test`. The preview uses this mode.
+- Public activation/intake defaults off elsewhere. `NORMA_CAMPAIGN_PUBLIC_ENABLED=true` must be deliberately configured for a separately approved public deployment.
+- Set `NORMA_PUBLIC_ORIGIN` to the exact external origin behind an HTTPS reverse proxy. The ingress must overwrite client-IP headers, strip caller-supplied forwarded headers, and impose global abuse limits. The per-action limiter is not a substitute for ingress protection.
+- A pledge is not a payment or a recurring subscription. The optional HTTPS ActBlue link opens an external contribution form; the user selects amount/frequency on that provider. There is no payment webhook reconciliation or accounting inference.
+- A participation email-update checkbox records a request, not a verified subscription. There is no email verification, automated delivery, suppression handling, or unsubscribe journey in this milestone. Do not import these requests into a send-ready list.
+- One email has one response per action. Identical retries return the original receipt; a changed duplicate returns a clear conflict instead of silently changing a pledge or consent. Supporter correction/self-service is future work.
+- Existing donation, contact, petition and messaging modules are linked, but their historic records are not automatically merged into campaign supporter history.
+- The theme/age toolbar remains visible, following the earlier explicit user preference. Browserbase is omitted because this preview is on loopback; local Chromium, WebKit and OpenClaw provide evidence.
+
+## Next functional milestones
+
+1. Verified supporter identity and contact synchronization; consent-confirmation, suppression and unsubscribe states with an immutable consent log.
+2. One complete provider-sandbox follow-up → delivery result → second action journey, including retry, bounce, opt-out and failure handling.
+3. Provider-backed contributions, verified webhooks, reconciliation, recurring management, refunds, receipts, paid events and split allocation where supported by provider contracts.
+4. Volunteer shifts, task assignment, phone/text/canvass operations, collaboration and appropriate service connectors.
+5. Pagination beyond current workspace/export limits, reliability/load proof, monitoring and the approved public launch.
+
+No single milestone above should be described as full ActBlue/MoveOn feature parity until its actual boundaries have been implemented and independently exercised.
+
+## Rollout and recovery
+
+The feature lives on `feature/unified-campaign-platform` in `/Users/macstudio3/Projects/Norma-platform`. The original checkout and running :7400 service remain separate. No production schema mutation or restart is part of this preview.
+
+For a later approved launch: review the diff and evidence, take a database backup, apply additive migration 026 with `ON_ERROR_STOP=1`, build with the intended environment, run the isolated gate and deployment-specific canaries, then switch the service. Roll back application code if needed; retain the additive campaign tables and their records rather than deleting supporter data. Keep public activation off until provider/consent/abuse controls are verified.
diff --git a/lib/campaigns/intake.ts b/lib/campaigns/intake.ts
new file mode 100644
index 0000000..d4d5833
--- /dev/null
+++ b/lib/campaigns/intake.ts
@@ -0,0 +1,94 @@
+import { createHash, createHmac } from 'crypto';
+import { query } from '@/lib/db';
+import { audit, transaction } from './store';
+import { CampaignError, choice, id, number, publishingEnabled, sandbox, scope, text } from './validation';
+import { CONSENT_TEXT } from './types';
+
+export async function publicAction(request: Request, actionId: string) {
+ const row = (await query(`SELECT a.*,c.title campaign_title,c.summary campaign_summary,c.status campaign_status,n.org_name
+ FROM organizing_actions a JOIN organizing_campaigns c ON c.id=a.campaign_id JOIN nonprofit_accounts n ON n.id=a.org_id
+ WHERE a.id=$1`,[id(actionId)])).rows[0];
+ if (!row) throw new CampaignError(404,'Action not found.');
+ const preview = new URL(request.url).searchParams.get('preview') === '1';
+ if (preview) {
+ const auth = await scope(request);
+ if (auth.org !== row.org_id) throw new CampaignError(404,'Action not found.');
+ } else if (!publishingEnabled() || row.status !== 'active' || row.campaign_status !== 'active') throw new CampaignError(404,'This action is not accepting participation.');
+ const counts = (await query(`SELECT count(*)::int participation_count, COALESCE(sum(amount_cents),0)::float8 pledged_cents
+ FROM organizing_participation WHERE action_id=$1 AND status!='cancelled'`,[actionId])).rows[0];
+ const nextActions = publishingEnabled() && row.campaign_status === 'active'
+ ? (await query("SELECT id,title,kind FROM organizing_actions WHERE campaign_id=$1 AND id!=$2 AND status='active' ORDER BY created_at LIMIT 10",[row.campaign_id,actionId])).rows : [];
+ const unavailableReason = row.kind === 'event' && Date.parse(row.config.starts_at) <= Date.now()
+ ? 'Registration for this event has closed.' : row.kind === 'event' && counts.participation_count >= row.config.capacity
+ ? 'This event is full. Please choose another way to take part.' : '';
+ return { next_actions: nextActions, action: {id: row.id, title: row.title, description: row.description, kind: row.kind, config: row.config, status: row.status, ...counts },
+ campaign: {title: row.campaign_title, summary: row.campaign_summary, status: row.campaign_status}, organization: row.org_name,
+ preview, sandbox: sandbox(), accepting: publishingEnabled() && row.status === 'active' && row.campaign_status === 'active' && !unavailableReason, unavailable_reason: unavailableReason, consent_text: CONSENT_TEXT };
+}
+export async function participate(request: Request, actionId: string, data: Record<string, unknown>) {
+ id(actionId);
+ if (!publishingEnabled()) throw new CampaignError(409,'This deployment is not accepting public participation.');
+ const fullName = text(data.full_name,'Full name',160);
+ const email = text(data.email,'Email',254).toLowerCase();
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw new CampaignError(400,'Enter a valid email address.');
+ if (data.consent_requested !== undefined && typeof data.consent_requested !== 'boolean') throw new CampaignError(400,'Email consent must be a checkbox choice.');
+ const consent = data.consent_requested === true;
+ const source = text(data.source ?? 'direct','Source',80);
+ if (!/^[a-z0-9_.-]+$/i.test(source)) throw new CampaignError(400,'Source must use letters, numbers, dots, dashes or underscores.');
+ const key = text(request.headers.get('idempotency-key'),'Submission key',100);
+ if (!/^[a-zA-Z0-9_-]{16,100}$/.test(key)) throw new CampaignError(400,'A valid submission key is required.');
+ if (data.website) throw new CampaignError(400,'Submission rejected.');
+ // Reject unknown/inactive actions before allocating a limiter row. The
+ // transaction below checks lifecycle again while holding the row locks.
+ if (!(await query(`SELECT a.id FROM organizing_actions a JOIN organizing_campaigns c ON c.id=a.campaign_id
+ WHERE a.id=$1 AND a.status='active' AND c.status='active'`,[actionId])).rowCount) throw new CampaignError(404,'This action is not accepting participation.');
+ await query(`DELETE FROM organizing_intake_limits WHERE key IN
+ (SELECT key FROM organizing_intake_limits WHERE expires_at<now() ORDER BY expires_at LIMIT 100)`);
+ // Single durable limiter per action/IP, without persisting raw IP addresses.
+ const clientIp = request.headers.get('x-real-ip') || request.headers.get('x-forwarded-for')?.split(',')[0].trim() || 'local';
+ const limitKey = createHmac('sha256',process.env.SESSION_SECRET || 'invalid').update(`${actionId}:${clientIp}`).digest('hex');
+ const limit = (await query(`INSERT INTO organizing_intake_limits(key,hits,expires_at) VALUES($1,1,now()+interval '5 minutes')
+ ON CONFLICT(key) DO UPDATE SET hits=CASE WHEN organizing_intake_limits.expires_at<now() THEN 1 ELSE organizing_intake_limits.hits+1 END,
+ expires_at=CASE WHEN organizing_intake_limits.expires_at<now() THEN now()+interval '5 minutes' ELSE organizing_intake_limits.expires_at END RETURNING hits`,[limitKey])).rows[0];
+ if (limit.hits > 60) throw new CampaignError(429,'Too many submissions. Please try again in a few minutes.');
+ return transaction(async client => {
+ const campaign = (await client.query(`SELECT c.* FROM organizing_campaigns c JOIN organizing_actions a ON a.campaign_id=c.id WHERE a.id=$1 FOR SHARE OF c`,[actionId])).rows[0];
+ const action = (await client.query('SELECT * FROM organizing_actions WHERE id=$1 FOR UPDATE',[actionId])).rows[0];
+ if (!campaign || !action || campaign.status !== 'active' || action.status !== 'active') throw new CampaignError(404,'This action is not accepting participation.');
+ const amount = action.kind === 'fundraiser' ? number(data.amount_cents,'Pledge amount in cents',100,100000000) : 0;
+ const frequency = action.kind === 'fundraiser' ? choice(data.frequency ?? 'once',['once','monthly'] as const,'pledge frequency') : 'once';
+ const fingerprint = createHash('sha256').update(JSON.stringify({fullName,email,amount,frequency,consent,source})).digest('hex');
+ const priorKey = (await client.query('SELECT id,status,request_hash FROM organizing_participation WHERE action_id=$1 AND idempotency_key=$2',[actionId,key])).rows[0];
+ if (priorKey) {
+ if (priorKey.request_hash !== fingerprint) throw new CampaignError(409,'This submission key was already used. Refresh the form before changing your response.');
+ if (priorKey.status === 'cancelled') throw new CampaignError(409,'This response was cancelled. Contact the organizer before registering again.');
+ return { receipt: priorKey.id, message: message(action.kind), sandbox: sandbox() };
+ }
+ const prior = (await client.query(`SELECT p.id,p.status,p.request_hash FROM organizing_participation p JOIN organizing_supporters s ON s.id=p.supporter_id WHERE p.action_id=$1 AND s.email=$2`,[actionId,email])).rows[0];
+ if (prior?.status === 'cancelled') throw new CampaignError(409,'An earlier response needs organizer assistance before it can be changed.');
+ if (prior && prior.request_hash !== fingerprint) throw new CampaignError(409,'A different response is already recorded for this email. Contact the organizer to update it.');
+ if (prior) return {receipt: prior.id, message: message(action.kind), sandbox: sandbox()};
+ if (action.kind === 'event') {
+ if (Date.parse(action.config.starts_at) <= Date.now()) throw new CampaignError(409,'Registration for this event has closed.');
+ const used = (await client.query("SELECT count(*)::int n FROM organizing_participation WHERE action_id=$1 AND status!='cancelled'",[actionId])).rows[0].n;
+ if (used >= action.config.capacity) throw new CampaignError(409,'This event is full. Please check another event.');
+ }
+ const supporter = (await client.query(`INSERT INTO organizing_supporters(org_id,email,full_name) VALUES($1,$2,$3)
+ ON CONFLICT(org_id,email) DO UPDATE SET email=EXCLUDED.email RETURNING id`,[action.org_id,email,fullName])).rows[0];
+ const record = (await client.query(`INSERT INTO organizing_participation(org_id,action_id,supporter_id,amount_cents,frequency,source,consent_requested,consent_text,idempotency_key,request_hash)
+ VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING id`,[action.org_id,actionId,supporter.id,amount,frequency,source,consent,consent ? CONSENT_TEXT : '',key,fingerprint])).rows[0];
+ await client.query(`INSERT INTO organizing_tasks(org_id,campaign_id,participation_id,title,body,channel)
+ VALUES($1,$2,$3,$4,$5,'organizing') ON CONFLICT(participation_id) DO NOTHING`,[action.org_id,action.campaign_id,record.id,
+ `Review ${action.kind} participation: ${action.title}`.slice(0,180),
+ `Review the new ${action.kind} response in campaign participation. ${consent ? 'An email update request was recorded; obtain confirmation before adding to a mailing list.' : 'No email updates were requested.'} ${action.kind === 'fundraiser' ? 'This is a pledge, not a processed payment.' : ''}`]);
+ await audit(client,{org:action.org_id,actor:'public-form',role:'public'},`${action.kind}.received`,record.id);
+ return {receipt:record.id,message:message(action.kind),sandbox:sandbox()};
+ });
+}
+function message(kind: string) {
+ if (kind === 'fundraiser') return 'Your pledge has been recorded. No payment was taken and no recurring charge was started.';
+ if (kind === 'event') return 'Your RSVP has been recorded. Save the event details below; no confirmation email has been sent.';
+ if (kind === 'volunteer') return 'Your volunteer interest has been recorded for the organizing team.';
+ if (kind === 'advocacy') return 'Your participation has been recorded. Follow the action instructions; no message was sent on your behalf.';
+ return 'Your petition participation has been recorded. Thank you for taking part.';
+}
diff --git a/lib/campaigns/store.ts b/lib/campaigns/store.ts
new file mode 100644
index 0000000..421b12a
--- /dev/null
+++ b/lib/campaigns/store.ts
@@ -0,0 +1,130 @@
+import type { PoolClient } from 'pg';
+import { getClient, query } from '@/lib/db';
+import { CampaignError, assertPublishing, choice, config, id, number, text } from './validation';
+import { ACTION_KINDS } from './types';
+export type Scope = { org: string; actor: string; role: string };
+export async function transaction<T>(run: (client: PoolClient) => Promise<T>) {
+ const client = await getClient();
+ try { await client.query('BEGIN'); const result = await run(client); await client.query('COMMIT'); return result; }
+ catch (error) { await client.query('ROLLBACK'); throw error; } finally { client.release(); }
+}
+export async function audit(client: PoolClient, s: Scope, event: string, entity: string) {
+ await client.query('INSERT INTO organizing_audit (org_id,actor,event,entity_id) VALUES ($1,$2,$3,$4)', [s.org, s.actor, event, entity]);
+}
+export async function workspace(org: string) {
+ const results = await Promise.all([
+ query(`SELECT c.*, (SELECT count(*)::int FROM organizing_actions a WHERE a.campaign_id=c.id) action_count,
+ (SELECT count(DISTINCT p.supporter_id)::int FROM organizing_participation p JOIN organizing_actions a ON a.id=p.action_id WHERE a.campaign_id=c.id AND p.status!='cancelled') supporter_count,
+ (SELECT COALESCE(sum(p.amount_cents),0)::float8 FROM organizing_participation p JOIN organizing_actions a ON a.id=p.action_id WHERE a.campaign_id=c.id AND p.status!='cancelled') pledged_cents
+ FROM organizing_campaigns c WHERE org_id=$1 ORDER BY created_at DESC LIMIT 200`, [org]),
+ query(`SELECT s.*, count(p.id)::int participation_count, COALESCE(sum(p.amount_cents) FILTER(WHERE p.status!='cancelled'),0)::float8 pledged_cents,
+ count(p.id) FILTER(WHERE p.consent_requested)::int consent_requests, COALESCE(array_agg(DISTINCT a.kind) FILTER(WHERE a.kind IS NOT NULL),'{}') kinds
+ FROM organizing_supporters s LEFT JOIN organizing_participation p ON p.supporter_id=s.id LEFT JOIN organizing_actions a ON a.id=p.action_id
+ WHERE s.org_id=$1 GROUP BY s.id ORDER BY s.created_at DESC LIMIT 500`, [org]),
+ query(`SELECT t.*,c.title campaign_title,s.full_name supporter_name,s.email supporter_email FROM organizing_tasks t
+ JOIN organizing_campaigns c ON c.id=t.campaign_id LEFT JOIN organizing_participation p ON p.id=t.participation_id
+ LEFT JOIN organizing_supporters s ON s.id=p.supporter_id WHERE t.org_id=$1 ORDER BY t.created_at DESC LIMIT 500`, [org]),
+ query(`SELECT (SELECT count(*)::int FROM organizing_campaigns WHERE org_id=$1) campaigns,
+ (SELECT count(*)::int FROM organizing_supporters WHERE org_id=$1) supporters,
+ count(*) FILTER(WHERE p.status!='cancelled')::int participation,
+ COALESCE(sum(p.amount_cents) FILTER(WHERE p.status!='cancelled'),0)::float8 pledged_cents,
+ count(*) FILTER(WHERE p.frequency='monthly' AND p.status!='cancelled')::int monthly_pledges,
+ count(*) FILTER(WHERE p.status='checked_in')::int checked_in,
+ count(*) FILTER(WHERE a.kind='volunteer' AND p.status!='cancelled')::int volunteer_signups
+ FROM organizing_participation p JOIN organizing_actions a ON a.id=p.action_id WHERE p.org_id=$1`, [org]),
+ query(`SELECT source,count(*)::int count,COALESCE(sum(amount_cents),0)::float8 pledged_cents FROM organizing_participation WHERE org_id=$1 AND status!='cancelled' GROUP BY source ORDER BY count(*) DESC LIMIT 100`, [org]),
+ query('SELECT * FROM organizing_audit WHERE org_id=$1 ORDER BY created_at DESC LIMIT 40', [org]),
+ ]);
+ return { campaigns: results[0].rows, supporters: results[1].rows, tasks: results[2].rows, stats: results[3].rows[0], sources: results[4].rows, audit: results[5].rows };
+}
+export async function detail(org: string, campaignId: string) {
+ const campaign = (await query('SELECT * FROM organizing_campaigns WHERE id=$1 AND org_id=$2', [id(campaignId), org])).rows[0];
+ if (!campaign) throw new CampaignError(404, 'Campaign not found.');
+ const [actions, participation, tasks] = await Promise.all([
+ query(`SELECT a.*,count(p.id) FILTER(WHERE p.status!='cancelled')::int participation_count,
+ COALESCE(sum(p.amount_cents) FILTER(WHERE p.status!='cancelled'),0)::float8 pledged_cents
+ FROM organizing_actions a LEFT JOIN organizing_participation p ON p.action_id=a.id WHERE a.campaign_id=$1 AND a.org_id=$2 GROUP BY a.id ORDER BY a.created_at`, [campaignId, org]),
+ query(`SELECT p.*,a.title action_title,a.kind,s.full_name,s.email FROM organizing_participation p
+ JOIN organizing_actions a ON a.id=p.action_id JOIN organizing_supporters s ON s.id=p.supporter_id
+ WHERE a.campaign_id=$1 AND p.org_id=$2 ORDER BY p.created_at DESC LIMIT 500`, [campaignId, org]),
+ query(`SELECT t.*,c.title campaign_title,s.full_name supporter_name,s.email supporter_email FROM organizing_tasks t
+ JOIN organizing_campaigns c ON c.id=t.campaign_id LEFT JOIN organizing_participation p ON p.id=t.participation_id
+ LEFT JOIN organizing_supporters s ON s.id=p.supporter_id WHERE t.campaign_id=$1 AND t.org_id=$2 ORDER BY t.created_at DESC LIMIT 200`, [campaignId, org]),
+ ]);
+ return { campaign, actions: actions.rows, participation: participation.rows, tasks: tasks.rows };
+}
+export async function createCampaign(s: Scope, data: Record<string, unknown>) {
+ const title = text(data.title, 'Campaign title', 160); const summary = text(data.summary, 'Campaign summary', 4000, false);
+ const supporters = number(data.goal_supporters ?? 100, 'Supporter goal', 1, 100000000);
+ const cents = number(data.goal_cents ?? 0, 'Fundraising goal in cents', 0, 100000000000);
+ return transaction(async client => {
+ const row = (await client.query('INSERT INTO organizing_campaigns(org_id,title,summary,goal_supporters,goal_cents,created_by) VALUES($1,$2,$3,$4,$5,$6) RETURNING *', [s.org,title,summary,supporters,cents,s.actor])).rows[0];
+ await audit(client,s,'campaign.created',row.id); return row;
+ });
+}
+export async function updateCampaign(s: Scope, campaignId: string, data: Record<string, unknown>) {
+ return transaction(async client => {
+ const old = (await client.query('SELECT * FROM organizing_campaigns WHERE id=$1 AND org_id=$2 FOR UPDATE', [id(campaignId),s.org])).rows[0];
+ if (!old) throw new CampaignError(404,'Campaign not found.');
+ const status = choice(data.status ?? old.status,['draft','active','paused','completed','archived'] as const,'campaign status');
+ if (status === 'active') assertPublishing();
+ const row = (await client.query('UPDATE organizing_campaigns SET title=$3,summary=$4,status=$5,goal_supporters=$6,goal_cents=$7,updated_at=now() WHERE id=$1 AND org_id=$2 RETURNING *',
+ [campaignId,s.org,text(data.title ?? old.title,'Campaign title',160),text(data.summary ?? old.summary,'Campaign summary',4000,false),status,number(data.goal_supporters ?? old.goal_supporters,'Supporter goal',1,100000000),number(data.goal_cents ?? Number(old.goal_cents),'Fundraising goal in cents',0,100000000000)])).rows[0];
+ await audit(client,s,`campaign.${status}`,campaignId); return row;
+ });
+}
+export async function createAction(s: Scope, campaignId: string, data: Record<string, unknown>) {
+ const kind = choice(data.kind,ACTION_KINDS,'action kind'); const settings = config(data.config ?? {}, kind);
+ return transaction(async client => {
+ const campaign = (await client.query('SELECT id FROM organizing_campaigns WHERE id=$1 AND org_id=$2 FOR SHARE',[id(campaignId),s.org])).rows[0];
+ if (!campaign) throw new CampaignError(404,'Campaign not found.');
+ const row = (await client.query('INSERT INTO organizing_actions(org_id,campaign_id,kind,title,description,config) VALUES($1,$2,$3,$4,$5,$6) RETURNING *',
+ [s.org,campaignId,kind,text(data.title,'Action title',160),text(data.description,'Action description',6000,false),JSON.stringify(settings)])).rows[0];
+ await audit(client,s,`action.${kind}.created`,row.id); return row;
+ });
+}
+export async function updateAction(s: Scope, actionId: string, data: Record<string, unknown>) {
+ return transaction(async client => {
+ const old = (await client.query('SELECT * FROM organizing_actions WHERE id=$1 AND org_id=$2 FOR UPDATE',[id(actionId),s.org])).rows[0];
+ if (!old) throw new CampaignError(404,'Action not found.');
+ const status = choice(data.status ?? old.status,['draft','active','paused'] as const,'action status');
+ if (status === 'active') assertPublishing();
+ const settings = config(data.config ?? old.config,old.kind);
+ if (old.kind === 'event') {
+ const used = (await client.query("SELECT count(*)::int n FROM organizing_participation WHERE action_id=$1 AND status!='cancelled'",[actionId])).rows[0].n;
+ if (settings.capacity! < used) throw new CampaignError(409,'Capacity cannot be lower than current registrations.');
+ }
+ const row = (await client.query('UPDATE organizing_actions SET title=$3,description=$4,config=$5,status=$6,updated_at=now() WHERE id=$1 AND org_id=$2 RETURNING *',
+ [actionId,s.org,text(data.title ?? old.title,'Action title',160),text(data.description ?? old.description,'Action description',6000,false),JSON.stringify(settings),status])).rows[0];
+ await audit(client,s,`action.${status}`,actionId); return row;
+ });
+}
+export async function saveTask(s: Scope, taskId: string | null, data: Record<string, unknown>) {
+ return transaction(async client => {
+ const old = taskId ? (await client.query('SELECT * FROM organizing_tasks WHERE id=$1 AND org_id=$2 FOR UPDATE',[id(taskId),s.org])).rows[0] : null;
+ if (taskId && !old) throw new CampaignError(404,'Follow-up not found.');
+ const campaignId = id(old?.campaign_id ?? data.campaign_id);
+ if (!(await client.query('SELECT id FROM organizing_campaigns WHERE id=$1 AND org_id=$2',[campaignId,s.org])).rowCount) throw new CampaignError(404,'Campaign not found.');
+ const values = [s.org,campaignId,text(data.title ?? old?.title,'Follow-up title',180),text(data.body ?? old?.body,'Follow-up notes',8000,false),choice(data.channel ?? old?.channel ?? 'organizing',['email','sms','phone','canvass','organizing'] as const,'channel'),choice(data.status ?? old?.status ?? 'draft',['draft','ready','completed'] as const,'follow-up status')];
+ const row = taskId ? (await client.query('UPDATE organizing_tasks SET title=$3,body=$4,channel=$5,status=$6,updated_at=now() WHERE org_id=$1 AND campaign_id=$2 AND id=$7 RETURNING *',[...values,taskId])).rows[0]
+ : (await client.query('INSERT INTO organizing_tasks(org_id,campaign_id,title,body,channel,status) VALUES($1,$2,$3,$4,$5,$6) RETURNING *',values)).rows[0];
+ await audit(client,s,`followup.${row.status}`,row.id); return row;
+ });
+}
+export async function attendance(s: Scope, participationId: string, data: Record<string, unknown>) {
+ const status = choice(data.status,['registered','checked_in','cancelled'] as const,'attendance status');
+ return transaction(async client => {
+ // Lock action before participation, the same order as intake/capacity updates.
+ const found = (await client.query('SELECT action_id FROM organizing_participation WHERE id=$1 AND org_id=$2',[id(participationId),s.org])).rows[0];
+ if (!found) throw new CampaignError(404,'Participation not found.');
+ const action = (await client.query('SELECT * FROM organizing_actions WHERE id=$1 AND org_id=$2 FOR UPDATE',[found.action_id,s.org])).rows[0];
+ if (action.kind !== 'event') throw new CampaignError(400,'Attendance changes apply only to event registrations.');
+ const old = (await client.query('SELECT * FROM organizing_participation WHERE id=$1 AND org_id=$2 FOR UPDATE',[participationId,s.org])).rows[0];
+ if (old.status === 'cancelled' && status !== 'cancelled') {
+ const used = (await client.query("SELECT count(*)::int n FROM organizing_participation WHERE action_id=$1 AND status!='cancelled'",[action.id])).rows[0].n;
+ if (used >= action.config.capacity) throw new CampaignError(409,'This event is full.');
+ }
+ const row = (await client.query('UPDATE organizing_participation SET status=$3,updated_at=now() WHERE id=$1 AND org_id=$2 RETURNING *',[participationId,s.org,status])).rows[0];
+ await audit(client,s,`attendance.${status}`,participationId); return row;
+ });
+}
diff --git a/lib/campaigns/types.ts b/lib/campaigns/types.ts
new file mode 100644
index 0000000..4b3db26
--- /dev/null
+++ b/lib/campaigns/types.ts
@@ -0,0 +1,15 @@
+export const ACTION_KINDS = ['petition', 'fundraiser', 'event', 'volunteer', 'advocacy'] as const;
+export type ActionKind = typeof ACTION_KINDS[number];
+export type CampaignStatus = 'draft' | 'active' | 'paused' | 'completed' | 'archived';
+export type ActionStatus = 'draft' | 'active' | 'paused';
+export type TaskChannel = 'email' | 'sms' | 'phone' | 'canvass' | 'organizing';
+export interface ActionConfig { target?: string; capacity?: number; starts_at?: string; ends_at?: string; location?: string; goal?: number; suggested_amounts?: number[]; hosted_url?: string; instructions?: string }
+export interface Campaign { id: string; org_id: string; title: string; summary: string; status: CampaignStatus; goal_supporters: number; goal_cents: number; created_at: string; updated_at: string; created_by: string; action_count?: number; supporter_count?: number; pledged_cents?: number }
+export interface CampaignAction { id: string; org_id: string; campaign_id: string; kind: ActionKind; title: string; description: string; status: ActionStatus; config: ActionConfig; created_at: string; updated_at: string; participation_count?: number; pledged_cents?: number }
+export interface Supporter { id: string; email: string; full_name: string; created_at: string; participation_count: number; pledged_cents: number; consent_requests: number; kinds: ActionKind[] }
+export interface Participation { id: string; action_id: string; action_title: string; kind: ActionKind; full_name: string; email: string; status: 'registered' | 'checked_in' | 'cancelled'; amount_cents: number; frequency: 'once' | 'monthly'; source: string; consent_requested: boolean; created_at: string }
+export interface OrganizingTask { supporter_name?: string; supporter_email?: string; campaign_title?: string; id: string; campaign_id: string; title: string; body: string; channel: TaskChannel; status: 'draft' | 'ready' | 'completed'; created_at: string; updated_at: string }
+export interface CampaignDetail { campaign: Campaign; actions: CampaignAction[]; participation: Participation[]; tasks: OrganizingTask[] }
+export interface Workspace { campaigns: Campaign[]; supporters: Supporter[]; tasks: OrganizingTask[]; stats: { campaigns: number; supporters: number; participation: number; pledged_cents: number; monthly_pledges: number; checked_in: number; volunteer_signups: number }; sources: { source: string; count: number; pledged_cents: number }[]; audit: { id: string; actor: string; event: string; created_at: string }[]; publishing_enabled: boolean; sandbox: boolean }
+export const KIND_LABEL: Record<ActionKind, string> = { petition: 'Petition', fundraiser: 'Fundraiser', event: 'Event', volunteer: 'Volunteer', advocacy: 'Advocacy action' };
+export const CONSENT_TEXT = 'I would like to receive campaign updates by email. My request will need confirmation before I am added to a mailing list.';
diff --git a/lib/campaigns/validation.ts b/lib/campaigns/validation.ts
new file mode 100644
index 0000000..625c971
--- /dev/null
+++ b/lib/campaigns/validation.ts
@@ -0,0 +1,99 @@
+import { NextResponse } from 'next/server';
+import { requireRole } from '@/lib/require-role';
+import { query } from '@/lib/db';
+import { ACTION_KINDS, type ActionConfig } from './types';
+
+export class CampaignError extends Error { constructor(public status: number, message: string) { super(message); } }
+export function id(value: unknown): string {
+ if (typeof value !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)) throw new CampaignError(400, 'A valid record ID is required.');
+ return value;
+}
+export function text(value: unknown, name: string, max = 200, required = true): string {
+ if (value == null && !required) return '';
+ if (typeof value !== 'string' || value.length > max || (required && !value.trim())) throw new CampaignError(400, `${name} must be ${required ? '1' : '0'}–${max} characters.`);
+ return value.trim();
+}
+export function number(value: unknown, name: string, min: number, max: number): number {
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < min || value > max) throw new CampaignError(400, `${name} must be a whole number between ${min} and ${max}.`);
+ return value;
+}
+export function choice<T extends string>(value: unknown, values: readonly T[], name: string): T {
+ if (typeof value !== 'string' || !values.includes(value as T)) throw new CampaignError(400, `Invalid ${name}.`);
+ return value as T;
+}
+export function sameOrigin(request: Request) {
+ const origin = request.headers.get('origin');
+ // Next may normalize request.url to localhost even when the browser used
+ // 127.0.0.1. Host is supplied by the browser transport, not page JavaScript.
+ // Reverse-proxy deployments may pin their public origin explicitly.
+ const incoming = new URL(request.url);
+ const protocol = process.env.USE_HTTPS === 'true' ? 'https:' : incoming.protocol;
+ const expected = process.env.NORMA_PUBLIC_ORIGIN || `${protocol}//${request.headers.get('host') || incoming.host}`;
+ if (origin && origin !== expected) throw new CampaignError(403, 'Cross-origin submission rejected.');
+ if (request.headers.get('sec-fetch-site') === 'cross-site') throw new CampaignError(403, 'Cross-site submission rejected.');
+}
+export async function body(request: Request): Promise<Record<string, unknown>> {
+ sameOrigin(request);
+ const reader = request.body?.getReader();
+ if (!reader) throw new CampaignError(400, 'A JSON body is required.');
+ const chunks: Uint8Array[] = []; let bytes = 0;
+ try {
+ for (;;) { const chunk = await reader.read(); if (chunk.done) break; bytes += chunk.value.byteLength;
+ if (bytes > 32768) { await reader.cancel(); throw new CampaignError(413, 'Form is too large.'); } chunks.push(chunk.value); }
+ } finally { reader.releaseLock(); }
+ try {
+ const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'));
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error();
+ return parsed;
+ } catch { throw new CampaignError(400, 'A valid JSON object is required.'); }
+}
+export async function scope(request: Request) {
+ const auth = requireRole(request, 'admin', 'staff');
+ if (auth instanceof NextResponse) throw new CampaignError(auth.status, auth.status === 401 ? 'Sign in to continue.' : 'You do not have permission to manage campaigns.');
+ const requested = request.headers.get('x-org-id');
+ if (auth.role !== 'admin' && requested && requested !== auth.orgId) throw new CampaignError(403, 'Organization does not match your session.');
+ const org = id(auth.role === 'admin' ? requested : auth.orgId);
+ if (!(await query('SELECT id FROM nonprofit_accounts WHERE id=$1', [org])).rowCount) throw new CampaignError(404, 'Organization not found.');
+ return { org, actor: auth.username, role: auth.role };
+}
+export function sandbox() {
+ try { return process.env.NORMA_CAMPAIGN_SANDBOX === 'true' && new URL(process.env.DATABASE_URL || '').pathname === '/sdcc_test'; } catch { return false; }
+}
+export function publishingEnabled() { return sandbox() || process.env.NORMA_CAMPAIGN_PUBLIC_ENABLED === 'true'; }
+export function assertPublishing() { if (!publishingEnabled()) throw new CampaignError(409, 'Public campaign publishing is not enabled for this deployment. You can save and preview drafts.'); }
+export function config(raw: unknown, kind: unknown): ActionConfig {
+ choice(kind, ACTION_KINDS, 'action type');
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new CampaignError(400, 'Action settings are required.');
+ const data = raw as Record<string, unknown>; const out: ActionConfig = {};
+ if (kind === 'petition' || kind === 'advocacy') out.target = text(data.target, 'Decision-maker or recipient', 200);
+ if (kind === 'petition') out.goal = number(data.goal ?? 100, 'Signature goal', 1, 100000000);
+ if (kind === 'event') {
+ out.location = text(data.location, 'Event location', 300);
+ for (const key of ['starts_at', 'ends_at'] as const) {
+ const val = text(data[key], key, 50);
+ if (!/T.*(?:Z|[+-]\d\d:\d\d)$/.test(val) || !Number.isFinite(Date.parse(val))) throw new CampaignError(400, 'Event times must include a timezone.');
+ out[key] = new Date(val).toISOString();
+ }
+ if (Date.parse(out.ends_at!) <= Date.parse(out.starts_at!)) throw new CampaignError(400, 'Event end must be after its start.');
+ out.capacity = number(data.capacity, 'Event capacity', 1, 100000);
+ }
+ if (kind === 'volunteer' || kind === 'advocacy') out.instructions = text(data.instructions, 'Instructions', 3000, false);
+ if (kind === 'fundraiser') {
+ const amounts = data.suggested_amounts ?? [1000, 2500, 5000, 10000];
+ if (!Array.isArray(amounts) || amounts.length < 1 || amounts.length > 6) throw new CampaignError(400, 'Choose 1–6 suggested amounts.');
+ out.suggested_amounts = [...new Set(amounts.map(v => number(v, 'Suggested cents', 100, 100000000)))];
+ const hosted = text(data.hosted_url, 'Hosted contribution URL', 1000, false);
+ if (hosted) {
+ let url: URL; try { url = new URL(hosted); } catch { throw new CampaignError(400, 'Hosted giving URL is invalid.'); }
+ if (url.protocol !== 'https:' || url.hostname !== 'secure.actblue.com' || url.port || url.username || url.password || !url.pathname.startsWith('/donate/')) throw new CampaignError(400, 'Use an HTTPS secure.actblue.com/donate/ contribution form URL.');
+ out.hosted_url = url.href;
+ }
+ }
+ return out;
+}
+export function failure(error: unknown) {
+ if (error instanceof CampaignError) return NextResponse.json({ error: error.message }, { status: error.status });
+ if (error && typeof error === 'object' && 'code' in error && error.code === '23505') return NextResponse.json({ error: 'This record already exists.' }, { status: 409 });
+ console.error('[campaign-platform]', error instanceof Error ? error.message : 'Unexpected failure');
+ return NextResponse.json({ error: 'The campaign service could not complete that request. Please retry.' }, { status: 500 });
+}
diff --git a/middleware.ts b/middleware.ts
index a836917..9220dca 100644
--- a/middleware.ts
+++ b/middleware.ts
@@ -17,6 +17,12 @@ export function middleware(request: NextRequest) {
return NextResponse.next();
}
+ // Action handlers enforce lifecycle, publication mode and preview permissions.
+ // Only this dedicated public namespace bypasses the page-login redirect.
+ if (pathname.startsWith('/act/') || pathname.startsWith('/api/action-center/')) {
+ return NextResponse.next();
+ }
+
// Pulse public pages: allow unauthenticated access to the public-facing
// petition platform (home, petitions list, individual petition, about, create)
// But pulse user pages (activity, tracked, profile) require auth
@@ -78,6 +84,9 @@ export function middleware(request: NextRequest) {
}
if (!tokenValid) {
+ if (pathname === '/api/campaigns' || pathname.startsWith('/api/campaigns/')) {
+ return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
+ }
// Pulse user pages redirect to login with return URL
if (pathname.startsWith('/pulse/')) {
const loginUrl = new URL('/login', request.url);
diff --git a/scripts/campaign-preview.sh b/scripts/campaign-preview.sh
new file mode 100644
index 0000000..da431f4
--- /dev/null
+++ b/scripts/campaign-preview.sh
@@ -0,0 +1,9 @@
+#!/usr/bin/env bash
+# Isolated campaign platform preview. Never uses the live sdcc database.
+set -euo pipefail
+cd "$(dirname "${BASH_SOURCE[0]}")/.."
+export PATH="/opt/homebrew/opt/postgresql@14/bin:/opt/homebrew/bin:$PATH"
+export PORT="${PORT:-7416}"
+export NORMA_CAMPAIGN_SANDBOX=true
+psql postgresql://127.0.0.1:5432/sdcc_test -v ON_ERROR_STOP=1 -f db/026_campaign_platform.sql
+exec bash scripts/test-instance.sh
diff --git a/scripts/seed-campaign-demo.mjs b/scripts/seed-campaign-demo.mjs
new file mode 100644
index 0000000..2b2774f
--- /dev/null
+++ b/scripts/seed-campaign-demo.mjs
@@ -0,0 +1,37 @@
+// Explicit sandbox-only demo, using reserved example.test email addresses.
+import fs from 'node:fs';
+import {randomUUID} from 'node:crypto';
+import pg from 'pg';
+const base='http://127.0.0.1:7416';const org='8a030726-45fc-4ea6-a8c3-65906e904128';
+const db=new pg.Pool({connectionString:'postgresql://127.0.0.1:5432/sdcc_test'});
+await db.query(`INSERT INTO nonprofit_accounts(id,org_name,short_name,contact_name,contact_email,password_hash,mission,brand_color,is_active,onboarding_complete)
+ VALUES($1,'Riverside Community · Sandbox','Riverside','Preview Team','preview@example.test','!demo-account-login-disabled','A shared place to read, learn and volunteer.','#17382e',true,true) ON CONFLICT(id) DO NOTHING`,[org]);
+await db.end();
+const auth=await fetch(base+'/api/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:'admin',password:'TestPass123!'})});
+if(!auth.ok)throw new Error('Seeded admin login failed');const cookie=auth.headers.get('set-cookie').split(';')[0];
+async function api(path='',data,method=data?'POST':'GET'){const r=await fetch(base+'/api/campaigns'+path,{method,headers:{Cookie:cookie,'X-Org-Id':org,'Content-Type':'application/json'},body:data?JSON.stringify(data):undefined});const body=await r.json();if(!r.ok)throw new Error(JSON.stringify(body));return body;}
+let workspace=await api();if(!workspace.sandbox)throw new Error('Refusing to seed outside sandbox');
+const defs=[{title:'A reading room for everyone',summary:'Bring neighbors together around a welcoming place to read, learn, and share. One campaign connects our petition, giving pledges, open house, and volunteer crew.',goal_supporters:40,goal_cents:500000},{title:'Saturday park crew',summary:'A small team can make a visible difference. Gather volunteers and plan a morning caring for our shared space.',goal_supporters:25,goal_cents:0}];
+const campaigns=[];const actionIds=[];
+for(let index=0;index<defs.length;index++){
+ const definition=defs[index];let campaign=workspace.campaigns.find(c=>c.title===definition.title);if(!campaign)campaign=(await api('',definition)).campaign;campaigns.push(campaign.id);await api('/'+campaign.id,{status:'active'},'PATCH');
+ const existing=(await api('/'+campaign.id)).actions;
+ const starts=new Date(Date.now()+7*86400000);starts.setHours(10,0,0,0);const ends=new Date(starts.getTime()+2*3600000);
+ const actions=index===0?[
+ {kind:'petition',title:'Make room for community reading',description:'Add your name to the community reading-room proposal. We will share participation with the program coordinator as the team plans the next season.',config:{target:'Riverside program coordinator',goal:40}},
+ {kind:'fundraiser',title:'Help put books on the shelves',description:'Tell us what you would like to contribute toward books, comfortable seating, and accessible reading materials. This sandbox form records a pledge; no payment is collected.',config:{suggested_amounts:[1000,2500,5000,10000]}},
+ {kind:'event',title:'Come to the reading-room open house',description:'Meet the volunteer team, browse the shelves, and help imagine what this space can become. Everyone is welcome.',config:{starts_at:starts.toISOString(),ends_at:ends.toISOString(),capacity:30,location:'Riverside Community Room · demo venue'}},
+ {kind:'volunteer',title:'Join the reading-room volunteer crew',description:'Offer a little time to help welcome visitors, organize books, or support a community reading session.',config:{instructions:'Register your interest. The organizing team will review availability and coordinate the next volunteer orientation.'}},
+ {kind:'advocacy',title:'Help shape the next chapter',description:'Review the public program agenda and bring a practical suggestion to the next open meeting.',config:{target:'Riverside program coordinator',instructions:'Read the agenda, write down your suggestion, and bring it to the open meeting. Record your participation here; this form does not send a message on your behalf.'}},
+ ]:[{kind:'volunteer',title:'Join Saturday’s park crew',description:'Help care for the place we share. Register to join a neighborhood volunteer morning.',config:{instructions:'The team will review your interest and prepare the volunteer plan.'}}];
+ for(const def of actions){let action=existing.find(a=>a.title===def.title);if(!action)action=(await api('/'+campaign.id+'/actions',def)).action;await api('/actions/'+action.id,{status:'active'},'PATCH');actionIds.push({id:action.id,kind:action.kind,campaign_id:campaign.id});}
+}
+const names=['Alex Sample','Jordan Sample','Taylor Sample','Morgan Sample','Casey Sample','Riley Sample','Jamie Sample','Sam Sample'];
+for(let i=0;i<names.length;i++){
+ for(const action of actionIds.filter((_,j)=>j===0||j===(i%5)+1)){
+ const response=await fetch(base+'/api/action-center/'+action.id,{method:'POST',headers:{'Content-Type':'application/json','Idempotency-Key':randomUUID()},body:JSON.stringify({full_name:names[i],email:`demo-person-${i}@example.test`,consent_requested:i%3===0,source:i%2===0?'community-newsletter':'open-house',amount_cents:2500*(i+1),frequency:i%2===0?'monthly':'once'})});if(!response.ok)throw new Error(await response.text());
+ }
+}
+workspace=await api();if(!workspace.tasks.some(t=>t.title==='Plan the volunteer welcome session'))await api('/tasks',{campaign_id:campaigns[0],title:'Plan the volunteer welcome session',body:'Review volunteer interests, choose an accessible meeting time, and prepare a simple orientation checklist. Confirm communication preferences before any outreach.',channel:'organizing'});
+fs.mkdirSync('verification/platform',{recursive:true});fs.writeFileSync('verification/platform/demo.json',JSON.stringify({timestamp:new Date().toISOString(),organization_id:org,campaigns,actions:actionIds,url:base+'/campaigns',data:'Synthetic demo names and reserved example.test email addresses; no live funds or sends'},null,2));
+console.log(JSON.stringify({url:base+'/campaigns',organization:'Riverside Community · Sandbox',campaigns:campaigns.length,actions:actionIds.length}));
diff --git a/scripts/test-instance.sh b/scripts/test-instance.sh
index c6cc9a0..4d97ebd 100755
--- a/scripts/test-instance.sh
+++ b/scripts/test-instance.sh
@@ -31,7 +31,7 @@ export CRON_SECRET="test-cron-secret"
# suite back to red with no explanation. (Do NOT blanket-apply db/*.sql: seeds
# would duplicate fixture rows and alpha-order would run migrations before
# schema.sql. Add specific idempotent migrations here as the suite grows.)
-for m in db/024_user_management.sql db/025_email_assign_read.sql; do
+for m in db/024_user_management.sql db/025_email_assign_read.sql db/026_campaign_platform.sql; do
if psql "$DATABASE_URL" -v ON_ERROR_STOP=0 -f "$m" >/dev/null 2>&1; then
echo "[test-instance] ensured $m"
else
@@ -46,6 +46,9 @@ missing=$(psql "$DATABASE_URL" -Atc "
SELECT 'gmail_messages.assigned_user_id (missing migration 025)'
WHERE NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name='gmail_messages' AND column_name='assigned_user_id')
+ UNION ALL
+ SELECT 'organizing_participation (missing migration 026)'
+ WHERE to_regclass('public.organizing_participation') IS NULL
) t" 2>/dev/null || echo "PSQL_UNREACHABLE")
if [ -n "$missing" ]; then
echo "[test-instance] FATAL: sdcc_test is missing required schema: $missing"
diff --git a/tests/campaign-disabled.mjs b/tests/campaign-disabled.mjs
new file mode 100644
index 0000000..5412395
--- /dev/null
+++ b/tests/campaign-disabled.mjs
@@ -0,0 +1,18 @@
+// Verifies the production-default publication boundary on an isolated test server.
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import {randomUUID} from 'node:crypto';
+const base=process.env.NORMA_TEST_URL||'http://127.0.0.1:7417';
+const url=new URL(base);if(!['127.0.0.1','localhost'].includes(url.hostname)||url.port==='7400')throw new Error('Use an isolated test server');
+const auth=await fetch(base+'/api/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:'teststaff',password:'TestPass123!'})});assert.equal(auth.status,200);const cookie=auth.headers.get('set-cookie').split(';')[0];
+const workspace=await fetch(base+'/api/campaigns',{headers:{Cookie:cookie}});assert.equal(workspace.status,200);const data=await workspace.json();assert.equal(data.sandbox,false);assert.equal(data.publishing_enabled,false);
+const checks=[];
+for(const campaign of data.campaigns.slice(0,1)){
+ const res=await fetch(base+'/api/campaigns/'+campaign.id,{method:'PATCH',headers:{Cookie:cookie,'Content-Type':'application/json'},body:JSON.stringify({status:'active'})});assert.equal(res.status,409);
+ checks.push({name:'Default deployment rejects activation',verdict:'PASS'});
+}
+assert.ok(checks.length,'Run browser fixtures first so an existing campaign is available');
+const post=await fetch(base+'/api/action-center/'+randomUUID(),{method:'POST',headers:{'Content-Type':'application/json','Idempotency-Key':randomUUID()},body:JSON.stringify({full_name:'Default gate',email:'default@example.test'})});assert.equal(post.status,409);
+checks.push({name:'Default deployment rejects public intake',verdict:'PASS'});
+fs.mkdirSync('verification/platform',{recursive:true});fs.writeFileSync('verification/platform/publication-results.json',JSON.stringify({timestamp:new Date().toISOString(),base,checks,cleanup:'No records created or changed; activation and intake rejected.'},null,2));
+console.log('PASS: production-default activation and public intake are disabled');
diff --git a/tests/campaign-platform.mjs b/tests/campaign-platform.mjs
new file mode 100644
index 0000000..b97ae10
--- /dev/null
+++ b/tests/campaign-platform.mjs
@@ -0,0 +1,108 @@
+/** Durable campaign integration tests. Uses only sdcc_test and local :7416. */
+import assert from 'node:assert/strict';
+import { randomUUID } from 'node:crypto';
+import fs from 'node:fs';
+import pg from 'pg';
+const base=process.env.NORMA_TEST_URL||'http://127.0.0.1:7416';
+const url=new URL(base);if(!['127.0.0.1','localhost'].includes(url.hostname)||url.port==='7400')throw new Error('Refusing to test campaign mutations on live or remote service');
+const db=new pg.Pool({connectionString:'postgresql://127.0.0.1:5432/sdcc_test'});
+const run=randomUUID().slice(0,8), campaigns=[], actions=[], checks=[];let cookie, org, staffCookie, foreign;
+async function hit(path,method='GET',data,c=cookie,o=org,extra={}){const headers={'Content-Type':'application/json',...extra};if(c)headers.Cookie=c;if(o)headers['X-Org-Id']=o;const res=await fetch(base+path,{method,headers,body:data===undefined?undefined:JSON.stringify(data),redirect:'manual'});const txt=await res.text();let body;try{body=JSON.parse(txt);}catch{body=txt;}return {status:res.status,body,headers:res.headers};}
+async function check(name,fn){await fn();checks.push({name,verdict:'PASS'});console.log('PASS '+name);}
+async function login(username){const r=await hit('/api/auth/login','POST',{username,password:'TestPass123!'},null,null);assert.equal(r.status,200);return {cookie:r.headers.get('set-cookie').split(';')[0],data:r.body};}
+async function newCampaign(title,owner=org){const r=await hit('/api/campaigns','POST',{title,summary:'Neutral fixture for campaign workflow verification',goal_supporters:25,goal_cents:500000},cookie,owner);assert.equal(r.status,201,JSON.stringify(r.body));campaigns.push(r.body.campaign.id);return r.body.campaign.id;}
+async function newAction(campaign,kind,config){const r=await hit('/api/campaigns/'+campaign+'/actions','POST',{kind,title:run+' '+kind,description:'Fixture action',config});assert.equal(r.status,201,JSON.stringify(r.body));actions.push(r.body.action.id);return r.body.action.id;}
+async function intake(action,email,key=randomUUID(),extra={}){return hit('/api/action-center/'+action,'POST',{full_name:'Test Supporter',email,source:'test-newsletter',consent_requested:false,...extra},null,null,{'Idempotency-Key':key});}
+let primary,petition,event,fundraiser,volunteer,advocacy;
+try {
+ cookie=(await login('admin')).cookie;const staff=await login('teststaff');staffCookie=staff.cookie;org=staff.data.orgId;
+ assert.ok(org,'seeded staff org required');
+ const accounts=(await hit('/api/accounts')).body.orgs;foreign=accounts.find(x=>x.id!==org)?.id;assert.ok(foreign,'second fixture org required');
+ await check('Private API rejects anonymous access and malformed/oversized bodies',async()=>{
+ assert.equal((await hit('/api/campaigns','GET',undefined,null,null)).status,401);
+ const bad=await fetch(base+'/api/campaigns',{method:'POST',headers:{Cookie:cookie,'X-Org-Id':org,'Content-Type':'application/json'},body:'{'});assert.equal(bad.status,400);
+ assert.equal((await hit('/api/campaigns','POST',{title:'x'.repeat(33000)})).status,413);
+ assert.equal((await hit('/api/campaigns','POST',{title:'Missing scope'},cookie,null)).status,400);
+ });
+ await check('Campaign creation and read-back are persisted',async()=>{primary=await newCampaign(run+' Reading room');const r=await hit('/api/campaigns/'+primary);assert.equal(r.body.campaign.title,run+' Reading room');const persisted=await db.query('SELECT title FROM organizing_campaigns WHERE id=$1',[primary]);assert.equal(persisted.rows[0].title,r.body.campaign.title);});
+ await check('Staff org spoof and foreign campaign IDs are denied',async()=>{const other=await newCampaign(run+' Other org',foreign);assert.equal((await hit('/api/campaigns','GET',undefined,staffCookie,foreign)).status,403);assert.equal((await hit('/api/campaigns/'+other,'GET',undefined,staffCookie,org)).status,404);assert.equal((await hit('/api/campaigns/'+other,'PATCH',{title:'spoof'},staffCookie,org)).status,404);assert.equal((await hit('/api/campaigns/'+other+'/actions','POST',{kind:'volunteer',title:'spoof',config:{}},staffCookie,org)).status,404);});
+ await check('Five typed actions save and enforce type-specific validation',async()=>{
+ petition=await newAction(primary,'petition',{target:'Library board',goal:50});
+ fundraiser=await newAction(primary,'fundraiser',{suggested_amounts:[1000,2500],hosted_url:'https://secure.actblue.com/donate/example'});
+ event=await newAction(primary,'event',{starts_at:new Date(Date.now()+86400000).toISOString(),ends_at:new Date(Date.now()+90000000).toISOString(),capacity:1,location:'Reading room'});
+ volunteer=await newAction(primary,'volunteer',{instructions:'Review the volunteer schedule.'});
+ advocacy=await newAction(primary,'advocacy',{target:'Program coordinator',instructions:'Read the public agenda.'});
+ assert.equal((await hit('/api/campaigns/'+primary+'/actions','POST',{kind:'fundraiser',title:'Bad URL',config:{hosted_url:'https://evil.test/donate/x'}})).status,400);
+ assert.equal((await hit('/api/campaigns/'+primary+'/actions','POST',{kind:'event',title:'Bad dates',config:{starts_at:'tomorrow',ends_at:'yesterday',capacity:1,location:'here'}})).status,400);
+ assert.equal((await hit('/api/campaigns/'+primary+'/actions','POST',{kind:'petition',title:'No recipient',config:{}})).status,400);
+ });
+ await check('Draft is hidden from public but available in authenticated preview',async()=>{
+ assert.equal((await hit('/api/action-center/'+petition,'GET',undefined,null,null)).status,404);
+ assert.equal((await hit('/api/action-center/'+petition+'?preview=1')).status,200);
+ assert.equal((await hit('/api/action-center/'+petition+'?preview=1','GET',undefined,staffCookie,foreign)).status,403);
+ assert.equal((await intake(petition,run+'@example.test')).status,404);
+ });
+ await check('Explicit sandbox activation makes campaign and actions available',async()=>{assert.equal((await hit('/api/campaigns/'+primary,'PATCH',{status:'active'})).status,200);for(const action of actions)assert.equal((await hit('/api/campaigns/actions/'+action,'PATCH',{status:'active'})).status,200);const page=await hit('/api/action-center/'+petition,'GET',undefined,null,null);assert.equal(page.status,200);assert.equal(page.body.next_actions.length,4);assert.ok(!JSON.stringify(page.body).includes('supporter'));});
+ const shared=run+'-shared@example.test', idem=randomUUID();let receipt;
+ await check('Petition intake is transactional and idempotent',async()=>{
+ const r=await intake(petition,shared,idem);assert.equal(r.status,202);receipt=r.body.receipt;
+ assert.equal((await intake(petition,shared,idem)).body.receipt,receipt);
+ assert.equal((await intake(petition,shared,idem,{full_name:'Changed'})).status,409);
+ assert.equal((await intake(petition,shared)).status,202);
+ assert.equal((await intake(petition,shared,randomUUID(),{consent_requested:true})).status,409);
+ const rows=await db.query('SELECT count(*)::int n FROM organizing_participation WHERE action_id=$1',[petition]);assert.equal(rows.rows[0].n,1);
+ const draft=await db.query('SELECT * FROM organizing_tasks WHERE participation_id=$1',[receipt]);assert.equal(draft.rowCount,1);assert.equal(draft.rows[0].status,'draft');
+ const task=(await hit('/api/campaigns')).body.tasks.find(t=>t.participation_id===receipt);assert.equal(task.supporter_email,shared);assert.equal(task.supporter_name,'Test Supporter');assert.equal(task.campaign_title,run+' Reading room');
+ });
+ await check('Pledges do not touch donation ledger; monthly intent is explicit',async()=>{
+ const before=(await db.query('SELECT count(*)::int n FROM donations')).rows[0].n;
+ const r=await intake(fundraiser,shared,randomUUID(),{amount_cents:2500,frequency:'monthly',consent_requested:true});assert.equal(r.status,202);assert.match(r.body.message,/No payment/);
+ const stored=(await db.query('SELECT * FROM organizing_participation WHERE id=$1',[r.body.receipt])).rows[0];assert.equal(stored.frequency,'monthly');assert.equal(Number(stored.amount_cents),2500);assert.ok(stored.consent_text.includes('confirmation'));
+ assert.equal((await db.query('SELECT count(*)::int n FROM donations')).rows[0].n,before);
+ assert.equal((await intake(fundraiser,shared,randomUUID(),{amount_cents:10000,frequency:'once',consent_requested:true})).status,409);
+ assert.equal((await intake(fundraiser,run+'-bad@example.test',randomUUID(),{amount_cents:-50})).status,400);
+ });
+ await check('Concurrent RSVP honors capacity and attendance lifecycle',async()=>{
+ const keys=Array.from({length:6},()=>randomUUID());
+ const attempts=await Promise.all(keys.map((key,i)=>intake(event,`${run}-event${i}@example.test`,key)));
+ assert.equal(attempts.filter(x=>x.status===202).length,1);assert.equal(attempts.filter(x=>x.status===409).length,5);
+ const rsvp=attempts.find(x=>x.status===202).body.receipt;
+ const full=(await hit('/api/action-center/'+event,'GET',undefined,null,null)).body;assert.equal(full.accepting,false);assert.match(full.unavailable_reason,/full/);
+ assert.equal((await hit('/api/campaigns/participation/'+rsvp,'PATCH',{status:'checked_in'})).status,200);
+ assert.equal((await hit('/api/campaigns/participation/'+rsvp,'PATCH',{status:'cancelled'})).status,200);
+ const accepted=attempts.findIndex(x=>x.status===202);
+ assert.equal((await intake(event,`${run}-event${accepted}@example.test`,keys[accepted])).status,409);
+ assert.equal((await intake(event,run+'-replacement@example.test')).status,202);
+ assert.equal((await hit('/api/campaigns/participation/'+rsvp,'PATCH',{status:'registered'})).status,409);
+ const past={starts_at:new Date(Date.now()-7200000).toISOString(),ends_at:new Date(Date.now()-3600000).toISOString(),capacity:1,location:'Reading room'};
+ assert.equal((await hit('/api/campaigns/actions/'+event,'PATCH',{config:past})).status,200);
+ const closed=(await hit('/api/action-center/'+event,'GET',undefined,null,null)).body;assert.equal(closed.accepting,false);assert.match(closed.unavailable_reason,/closed/);
+ });
+ await check('Shared supporter identity spans volunteer and advocacy actions',async()=>{
+ assert.equal((await intake(volunteer,shared)).status,202);assert.equal((await intake(advocacy,shared)).status,202);
+ const matches=(await db.query('SELECT count(*)::int n FROM organizing_supporters WHERE org_id=$1 AND email=$2',[org,shared])).rows[0].n;assert.equal(matches,1);
+ const w=(await hit('/api/campaigns')).body;const person=w.supporters.find(x=>x.email===shared);assert.equal(person.participation_count,4);assert.equal(person.consent_requests,1);assert.equal(person.pledged_cents,2500);
+ });
+ await check('Follow-up drafts save, transition, and remain unsent',async()=>{
+ const r=await hit('/api/campaigns/tasks','POST',{campaign_id:primary,title:'Organizer checklist',body:'Review the campaign actions.',channel:'email'});assert.equal(r.status,201);const task=r.body.task.id;
+ assert.equal((await hit('/api/campaigns/tasks/'+task,'PATCH',{status:'ready',body:'Updated draft'})).body.task.status,'ready');
+ assert.equal((await hit('/api/campaigns/tasks/'+task,'PATCH',{status:'sent'})).status,400);
+ assert.equal((await hit('/api/campaigns/tasks/'+task,'PATCH',{status:'completed'})).status,200);
+ assert.equal((await hit('/api/campaigns/tasks/'+task,'PATCH',{body:'Other org'},cookie,foreign)).status,404);
+ });
+ await check('CSV is tenant scoped and neutralizes spreadsheet formulas',async()=>{
+ const email=run+'-csv@example.test';assert.equal((await intake(volunteer,email,randomUUID(),{full_name:'=1+1'})).status,202);
+ const csv=await hit('/api/campaigns/export');assert.equal(csv.status,200);assert.ok(csv.body.includes('"\'=1+1"'));assert.ok(csv.body.includes('not_subscribed'));
+ const other=await hit('/api/campaigns/export','GET',undefined,cookie,foreign);assert.ok(!other.body.includes(email));
+ });
+ await check('Cross-origin requests and duplicate hidden fields are rejected',async()=>{
+ const r=await hit('/api/action-center/'+volunteer,'POST',{full_name:'Test',email:run+'-cors@example.test'},null,null,{Origin:'https://evil.test','Idempotency-Key':randomUUID()});assert.equal(r.status,403);
+ assert.equal((await intake(volunteer,run+'-bot@example.test',randomUUID(),{website:'bot'})).status,400);
+ });
+ await check('Pause immediately stops new public intake',async()=>{assert.equal((await hit('/api/campaigns/'+primary,'PATCH',{status:'paused'})).status,200);assert.equal((await intake(volunteer,run+'-paused@example.test')).status,404);assert.equal((await hit('/api/action-center/'+petition,'GET',undefined,null,null)).status,404);});
+ console.log(`${checks.length} integration journeys passed`);
+} catch(error) {checks.push({name:'Execution failure',verdict:'FAIL',reason:error.message});console.error(error);process.exitCode=1;}
+finally {
+ if(campaigns.length){await db.query('DELETE FROM organizing_tasks WHERE campaign_id=ANY($1::uuid[])',[campaigns]);await db.query('DELETE FROM organizing_participation WHERE action_id=ANY($1::uuid[])',[actions]);await db.query("DELETE FROM organizing_supporters WHERE email LIKE $1 AND org_id=$2",[run+'%',org]);await db.query('DELETE FROM organizing_actions WHERE campaign_id=ANY($1::uuid[])',[campaigns]);await db.query('DELETE FROM organizing_audit WHERE entity_id=ANY($1::uuid[])',[campaigns.concat(actions)]);await db.query('DELETE FROM organizing_campaigns WHERE id=ANY($1::uuid[])',[campaigns]);}
+ await db.end();fs.mkdirSync('verification/platform',{recursive:true});fs.writeFileSync('verification/platform/api-results.json',JSON.stringify({timestamp:new Date().toISOString(),base,checks,cleanup:'Owned fixture campaign, action, supporter and participation records removed; audit receipt rows retained as verification history.'},null,2));
+}
diff --git a/tests/platform/workspace.spec.ts b/tests/platform/workspace.spec.ts
new file mode 100644
index 0000000..0f67901
--- /dev/null
+++ b/tests/platform/workspace.spec.ts
@@ -0,0 +1,85 @@
+import { test, expect } from '@playwright/test';
+import fs from 'node:fs';
+import {randomUUID} from 'node:crypto';
+
+test('Create campaign, activate petition, submit, and inspect supporter and follow-up',async({page},testInfo)=>{
+ const run=randomUUID().slice(0,8);const errors:string[]=[];const failed:string[]=[];
+ page.on('pageerror',e=>errors.push(e.message));
+ page.on('response',r=>{if(r.status()>=500&&r.url().includes('/api/'))failed.push(`${r.status()} ${r.url()}`);});
+ await page.goto('/login');await page.getByPlaceholder('Username or email').fill('admin');await page.getByPlaceholder('Password').fill('TestPass123!');await page.getByPlaceholder('Password').press('Enter');await page.waitForURL(url=>url.pathname==='/');
+ const staff=await page.request.post('/api/auth/login',{data:{username:'teststaff',password:'TestPass123!'}});const org=(await staff.json()).orgId;
+ // Restore admin cookie after discovering the seeded tenant.
+ await page.request.post('/api/auth/login',{data:{username:'admin',password:'TestPass123!'}});
+ await page.goto('/campaigns');await page.getByRole('combobox',{name:'Campaign organization'}).selectOption(org);
+ await expect(page.getByRole('button',{name:'Create campaign',exact:true})).toBeVisible();
+ await page.getByRole('button',{name:'Create campaign',exact:true}).click();const dialog=page.getByRole('dialog',{name:'Create campaign'});
+ await dialog.getByLabel('Title',{exact:true}).fill('Browser '+run+' reading room');await dialog.getByLabel('Campaign summary').fill('A shared place for community reading and volunteer activity.');await dialog.getByLabel('Supporter goal').fill('100');await dialog.getByLabel('Fundraising goal').fill('5000');await dialog.getByRole('button',{name:'Save campaign'}).click();
+ await expect(page.getByRole('heading',{name:'Browser '+run+' reading room',exact:true})).toBeVisible();await page.getByRole('combobox',{name:'Campaign status'}).selectOption('active');
+ await expect(page.getByRole('combobox',{name:'Campaign status'})).toHaveValue('active');
+ await page.getByRole('button',{name:'Add action',exact:true}).click();const action=page.getByRole('dialog',{name:'Create action'});await action.getByLabel('Title',{exact:true}).fill('Keep the reading room open '+run);await action.getByLabel('Description',{exact:true}).fill('Let the program coordinator know you want to take part.');await action.getByLabel('Decision-maker or recipient').fill('Program coordinator');await action.getByRole('button',{name:'Save action'}).click();
+ await expect(page.getByRole('heading',{name:'Keep the reading room open '+run})).toBeVisible();await page.getByRole('button',{name:'Activate',exact:true}).click();
+ const formLink=page.getByRole('link',{name:'Open form ↗'});await expect(formLink).toBeVisible();const formPath=await formLink.getAttribute('href');
+ fs.mkdirSync('verification/platform/screenshots',{recursive:true});await page.screenshot({path:`verification/platform/screenshots/${testInfo.project.name}-campaign.png`,fullPage:true});
+ await page.goto(formPath!+'?source=browser-test');await expect(page.getByRole('heading',{name:'Add your name.'})).toBeVisible();await page.getByLabel('Full name',{exact:true}).fill('Browser Supporter '+run);await page.getByLabel('Email address').fill(`browser-${run}@example.test`);await page.getByRole('button',{name:'Add my name'}).click();await expect(page.getByText('Your petition participation has been recorded.',{exact:false})).toBeVisible();
+ await page.screenshot({path:`verification/platform/screenshots/${testInfo.project.name}-receipt.png`,fullPage:true});
+ await expect(page.getByText('people taking part',{exact:true})).toHaveCount(0);
+ await page.goto('/campaigns');await page.getByRole('tab',{name:'Supporters',exact:true}).click();await page.getByRole('searchbox',{name:'Search supporters'}).fill('browser-'+run);await expect(page.getByRole('cell',{name:'Browser Supporter '+run,exact:false})).toBeVisible();
+ await page.getByRole('tab',{name:'Follow-ups',exact:true}).click();await expect(page.getByRole('heading',{name:'Review petition participation: Keep the reading room open '+run})).toBeVisible();
+ await expect(page.getByRole('article').filter({has:page.getByRole('heading',{name:'Review petition participation: Keep the reading room open '+run})})).toContainText('browser-'+run+'@example.test');
+ await page.getByRole('tab',{name:'Insights',exact:true}).click();await expect(page.getByText('browser-test',{exact:true})).toBeVisible();
+ await page.getByRole('tab',{name:'Campaigns',exact:true}).click();await page.getByRole('searchbox',{name:'Search campaigns'}).fill('Browser '+run);await page.getByRole('button',{name:'Browser '+run+' reading room',exact:true}).click();
+ await expect(page.getByRole('cell',{name:'Browser Supporter '+run,exact:false})).toBeVisible();
+ await page.getByRole('combobox',{name:'Campaign status'}).selectOption('archived');await expect(page.getByRole('combobox',{name:'Campaign status'})).toHaveValue('archived');
+ expect(failed).toEqual([]);expect(errors).toEqual([]);
+});
+
+test('Campaign workspace stays usable on a narrow viewport with keyboard dialog dismissal',async({page},testInfo)=>{
+ await page.setViewportSize({width:390,height:844});await page.goto('/login');await page.getByPlaceholder('Username or email').fill('teststaff');await page.getByPlaceholder('Password').fill('TestPass123!');await page.getByPlaceholder('Password').press('Enter');await page.waitForURL(url=>url.pathname==='/');
+ await page.goto('/campaigns');await expect(page.getByRole('button',{name:'Create campaign',exact:true})).toBeVisible();await page.getByRole('button',{name:'Create campaign',exact:true}).click();await expect(page.getByRole('dialog')).toBeVisible();await page.keyboard.press('Escape');await expect(page.getByRole('dialog')).toHaveCount(0);
+ const width=await page.locator('[class*="workspace"]').evaluate(el=>({scroll:el.scrollWidth,client:el.clientWidth}));expect(width.scroll).toBeLessThanOrEqual(width.client+2);
+ fs.mkdirSync('verification/platform/screenshots',{recursive:true});await page.screenshot({path:`verification/platform/screenshots/${testInfo.project.name}-mobile.png`,fullPage:true});
+ await page.goto('/?tab=campaigns');await expect(page.getByRole('heading',{name:'One campaign. Every way to act.'})).toBeVisible();
+ const size=await page.getByRole('combobox',{name:'Campaign organization'}).boundingBox();expect(size!.height).toBeGreaterThanOrEqual(42);
+});
+
+test('Create and use fundraiser, event, volunteer and advocacy forms',async({page},testInfo)=>{
+ const run=randomUUID().slice(0,8);const failures:string[]=[];
+ page.on('pageerror',e=>failures.push(e.message));
+ page.on('response',r=>{if(r.status()>=500&&r.url().includes('/api/'))failures.push(`${r.status()} ${r.url()}`);});
+ await page.goto('/login');await page.getByPlaceholder('Username or email').fill('teststaff');await page.getByPlaceholder('Password').fill('TestPass123!');await page.getByPlaceholder('Password').press('Enter');await page.waitForURL(url=>url.pathname==='/');
+ await page.goto('/campaigns');await page.getByRole('button',{name:'Create campaign',exact:true}).click();
+ const campaign=page.getByRole('dialog');await campaign.getByLabel('Title',{exact:true}).fill('All forms '+run);await campaign.getByRole('button',{name:'Save campaign'}).click();
+ await expect(page.getByRole('heading',{name:'All forms '+run})).toBeVisible();await page.getByRole('combobox',{name:'Campaign status'}).selectOption('active');await expect(page.getByRole('combobox',{name:'Campaign status'})).toHaveValue('active');
+ const paths:Record<string,string>={};
+ for(const kind of ['fundraiser','event','volunteer','advocacy']){
+ await page.getByRole('button',{name:'Add action',exact:true}).click();const dialog=page.getByRole('dialog');await dialog.getByLabel('Action type').selectOption(kind);await dialog.getByLabel('Title',{exact:true}).fill(kind+' '+run);await dialog.getByLabel('Description',{exact:true}).fill('A neutral browser fixture.');
+ if(kind==='fundraiser')await dialog.getByLabel('Suggested amounts').fill('10, 25, 75');
+ if(kind==='event'){
+ const future=new Date(Date.now()+86400000).toISOString().slice(0,16), end=new Date(Date.now()+90000000).toISOString().slice(0,16);
+ await dialog.getByLabel('Starts (your local time)').fill(future);await dialog.getByLabel('Ends (your local time)').fill(end);await dialog.getByLabel('Location or meeting details').fill('Test community room');await dialog.getByLabel('Capacity',{exact:true}).fill('2');
+ }
+ if(kind==='advocacy')await dialog.getByLabel('Decision-maker or recipient').fill('Program coordinator');
+ if(kind==='volunteer'||kind==='advocacy')await dialog.getByLabel('Participation instructions').fill('Review the community program agenda.');
+ await dialog.getByRole('button',{name:'Save action'}).click();await expect(dialog).toHaveCount(0);
+ const card=page.getByRole('article').filter({has:page.getByRole('heading',{name:kind+' '+run,exact:true})});await card.getByRole('button',{name:'Activate',exact:true}).click();const link=card.getByRole('link',{name:'Open form ↗'});await expect(link).toBeVisible();paths[kind]=(await link.getAttribute('href'))!;
+ }
+ for(const [kind,path] of Object.entries(paths)){
+ await page.goto(path+'?source=browser-all-forms');await expect(page.getByRole('heading',{name:kind+' '+run,exact:true})).toBeVisible();
+ if(kind==='fundraiser'){await page.getByRole('button',{name:'$75.00',exact:true}).click();await page.getByLabel('Frequency').selectOption('monthly');}
+ await page.getByLabel('Full name',{exact:true}).fill('All Forms '+run);await page.getByLabel('Email address').fill(`allforms-${run}@example.test`);
+ await page.getByRole('button',{name:({fundraiser:'Record my pledge',event:'Confirm RSVP',volunteer:'Register my interest',advocacy:'Record my participation'})[kind]}).click();
+ await expect(page.getByText('Keep this reference:',{exact:false})).toBeVisible();
+ if(kind==='fundraiser')await expect(page.getByText('No payment was taken',{exact:false})).toBeVisible();
+ if(kind==='event'){const [download]=await Promise.all([page.waitForEvent('download'),page.getByRole('button',{name:'Save event to calendar'}).click()]);expect(download.suggestedFilename()).toBe('norma-event.ics');const downloaded=await download.path();expect(fs.readFileSync(downloaded!,'utf8')).toContain('BEGIN:VEVENT');}
+ await page.screenshot({path:`verification/platform/screenshots/${testInfo.project.name}-${kind}.png`,fullPage:true});
+ if(kind==='event'){
+ const extra=await page.request.post(path.replace('/act/','/api/action-center/'),{headers:{'Idempotency-Key':randomUUID()},data:{full_name:'Second Event Fixture',email:`event-second-${run}@example.test`,source:'browser-test'}});expect(extra.status()).toBe(202);
+ await page.goto(path);await expect(page.getByRole('button',{name:'Confirm RSVP'})).toBeDisabled();await expect(page.getByText('This event is full.',{exact:false})).toBeVisible();await expect(page.getByRole('heading',{name:'Another way to help'})).toBeVisible();
+ }
+ }
+ await page.goto('/campaigns');await page.getByRole('tab',{name:'Supporters',exact:true}).click();await page.getByRole('searchbox',{name:'Search supporters'}).fill('allforms-'+run);const supporter=page.getByRole('row').filter({hasText:'allforms-'+run});await expect(supporter).toContainText('4 actions');await expect(supporter).toContainText('$75');
+ const [csv]=await Promise.all([page.waitForEvent('download'),page.getByRole('button',{name:'Export all'}).click()]);expect(fs.readFileSync((await csv.path())!,'utf8')).toContain('allforms-'+run);
+ await page.getByRole('tab',{name:'Campaigns',exact:true}).click();await page.getByRole('button',{name:'All forms '+run,exact:true}).click();await page.getByRole('combobox',{name:'Attendance for All Forms '+run}).selectOption('checked_in');await expect(page.getByRole('combobox',{name:'Attendance for All Forms '+run})).toHaveValue('checked_in');
+ const followup=page.getByRole('article').filter({has:page.getByRole('heading',{name:'Review volunteer participation: volunteer '+run})});await followup.getByRole('button',{name:'Edit draft'}).click();const task=page.getByRole('dialog');await task.getByLabel('Draft or task notes').fill('Prepare the volunteer welcome checklist.');await task.getByRole('button',{name:'Save follow-up'}).click();await expect(followup).toContainText('Prepare the volunteer welcome checklist.');await followup.getByRole('button',{name:'Mark ready',exact:true}).click();await expect(followup.getByRole('button',{name:'Mark task complete'})).toBeVisible();
+ await page.getByRole('combobox',{name:'Campaign status'}).selectOption('archived');await expect(page.getByRole('combobox',{name:'Campaign status'})).toHaveValue('archived');expect(failures).toEqual([]);
+});
diff --git a/verification/platform/api-results.json b/verification/platform/api-results.json
new file mode 100644
index 0000000..9a0938a
--- /dev/null
+++ b/verification/platform/api-results.json
@@ -0,0 +1,63 @@
+{
+ "timestamp": "2026-09-09T19:49:17.937Z",
+ "base": "http://127.0.0.1:7416",
+ "checks": [
+ {
+ "name": "Private API rejects anonymous access and malformed/oversized bodies",
+ "verdict": "PASS"
+ },
+ {
+ "name": "Campaign creation and read-back are persisted",
+ "verdict": "PASS"
+ },
+ {
+ "name": "Staff org spoof and foreign campaign IDs are denied",
+ "verdict": "PASS"
+ },
+ {
+ "name": "Five typed actions save and enforce type-specific validation",
+ "verdict": "PASS"
+ },
+ {
+ "name": "Draft is hidden from public but available in authenticated preview",
+ "verdict": "PASS"
+ },
+ {
+ "name": "Explicit sandbox activation makes campaign and actions available",
+ "verdict": "PASS"
+ },
+ {
+ "name": "Petition intake is transactional and idempotent",
+ "verdict": "PASS"
+ },
+ {
+ "name": "Pledges do not touch donation ledger; monthly intent is explicit",
+ "verdict": "PASS"
+ },
+ {
+ "name": "Concurrent RSVP honors capacity and attendance lifecycle",
+ "verdict": "PASS"
+ },
+ {
+ "name": "Shared supporter identity spans volunteer and advocacy actions",
+ "verdict": "PASS"
+ },
+ {
+ "name": "Follow-up drafts save, transition, and remain unsent",
+ "verdict": "PASS"
+ },
+ {
+ "name": "CSV is tenant scoped and neutralizes spreadsheet formulas",
+ "verdict": "PASS"
+ },
+ {
+ "name": "Cross-origin requests and duplicate hidden fields are rejected",
+ "verdict": "PASS"
+ },
+ {
+ "name": "Pause immediately stops new public intake",
+ "verdict": "PASS"
+ }
+ ],
+ "cleanup": "Owned fixture campaign, action, supporter and participation records removed; audit receipt rows retained as verification history."
+}
\ No newline at end of file
diff --git a/verification/platform/browser-results.json b/verification/platform/browser-results.json
new file mode 100644
index 0000000..04fb8dd
--- /dev/null
+++ b/verification/platform/browser-results.json
@@ -0,0 +1,343 @@
+{
+ "config": {
+ "argv": [
+ "/opt/homebrew/Cellar/node/26.4.0/bin/node",
+ "/Users/macstudio3/Projects/Norma-platform/node_modules/.bin/playwright",
+ "test",
+ "--config",
+ "verification/platform/browser.config.ts"
+ ],
+ "configFile": "/Users/macstudio3/Projects/Norma-platform/verification/platform/browser.config.ts",
+ "rootDir": "/Users/macstudio3/Projects/Norma-platform/tests/platform",
+ "failOnFlakyTests": false,
+ "forbidOnly": false,
+ "fullyParallel": false,
+ "globalSetup": null,
+ "globalTeardown": null,
+ "globalTimeout": 0,
+ "grep": {},
+ "grepInvert": null,
+ "maxFailures": 0,
+ "metadata": {
+ "actualWorkers": 1
+ },
+ "preserveOutput": "always",
+ "projects": [
+ {
+ "outputDir": "/Users/macstudio3/Projects/Norma-platform/verification/platform/browser-artifacts",
+ "repeatEach": 1,
+ "retries": 0,
+ "metadata": {
+ "actualWorkers": 1
+ },
+ "id": "chromium",
+ "name": "chromium",
+ "testDir": "/Users/macstudio3/Projects/Norma-platform/tests/platform",
+ "testIgnore": [],
+ "testMatch": [
+ "**/*.@(spec|test).?(c|m)[jt]s?(x)"
+ ],
+ "timeout": 60000
+ },
+ {
+ "outputDir": "/Users/macstudio3/Projects/Norma-platform/verification/platform/browser-artifacts",
+ "repeatEach": 1,
+ "retries": 0,
+ "metadata": {
+ "actualWorkers": 1
+ },
+ "id": "webkit",
+ "name": "webkit",
+ "testDir": "/Users/macstudio3/Projects/Norma-platform/tests/platform",
+ "testIgnore": [],
+ "testMatch": [
+ "**/*.@(spec|test).?(c|m)[jt]s?(x)"
+ ],
+ "timeout": 60000
+ }
+ ],
+ "quiet": false,
+ "reporter": [
+ [
+ "list",
+ null
+ ],
+ [
+ "json",
+ {
+ "outputFile": "browser-results.json"
+ }
+ ]
+ ],
+ "reportSlowTests": {
+ "max": 5,
+ "threshold": 300000
+ },
+ "shard": null,
+ "tags": [],
+ "updateSnapshots": "missing",
+ "updateSourceMethod": "patch",
+ "version": "1.62.1",
+ "workers": 1,
+ "webServer": null
+ },
+ "suites": [
+ {
+ "title": "workspace.spec.ts",
+ "file": "workspace.spec.ts",
+ "column": 0,
+ "line": 0,
+ "specs": [
+ {
+ "title": "Create campaign, activate petition, submit, and inspect supporter and follow-up",
+ "ok": true,
+ "tags": [],
+ "tests": [
+ {
+ "timeout": 60000,
+ "annotations": [],
+ "expectedStatus": "passed",
+ "projectId": "chromium",
+ "projectName": "chromium",
+ "results": [
+ {
+ "workerIndex": 0,
+ "parallelIndex": 0,
+ "status": "passed",
+ "duration": 2197,
+ "errors": [],
+ "stdout": [],
+ "stderr": [],
+ "retry": 0,
+ "startTime": "2026-09-09T19:49:18.123Z",
+ "annotations": [],
+ "attachments": [
+ {
+ "name": "video",
+ "contentType": "video/webm",
+ "path": "/Users/macstudio3/Projects/Norma-platform/verification/platform/browser-artifacts/workspace-Create-campaign--31f54-ect-supporter-and-follow-up-chromium/video.webm"
+ }
+ ]
+ }
+ ],
+ "status": "expected"
+ }
+ ],
+ "id": "b163ed09f68681248ada-d0b515c43c1af02c5b03",
+ "file": "workspace.spec.ts",
+ "line": 5,
+ "column": 5
+ },
+ {
+ "title": "Campaign workspace stays usable on a narrow viewport with keyboard dialog dismissal",
+ "ok": true,
+ "tags": [],
+ "tests": [
+ {
+ "timeout": 60000,
+ "annotations": [],
+ "expectedStatus": "passed",
+ "projectId": "chromium",
+ "projectName": "chromium",
+ "results": [
+ {
+ "workerIndex": 0,
+ "parallelIndex": 0,
+ "status": "passed",
+ "duration": 1035,
+ "errors": [],
+ "stdout": [],
+ "stderr": [],
+ "retry": 0,
+ "startTime": "2026-09-09T19:49:20.592Z",
+ "annotations": [],
+ "attachments": [
+ {
+ "name": "video",
+ "contentType": "video/webm",
+ "path": "/Users/macstudio3/Projects/Norma-platform/verification/platform/browser-artifacts/workspace-Campaign-workspa-87f3c-h-keyboard-dialog-dismissal-chromium/video.webm"
+ }
+ ]
+ }
+ ],
+ "status": "expected"
+ }
+ ],
+ "id": "b163ed09f68681248ada-f2a144bb3bf9dae4745c",
+ "file": "workspace.spec.ts",
+ "line": 36,
+ "column": 5
+ },
+ {
+ "title": "Create and use fundraiser, event, volunteer and advocacy forms",
+ "ok": true,
+ "tags": [],
+ "tests": [
+ {
+ "timeout": 60000,
+ "annotations": [],
+ "expectedStatus": "passed",
+ "projectId": "chromium",
+ "projectName": "chromium",
+ "results": [
+ {
+ "workerIndex": 0,
+ "parallelIndex": 0,
+ "status": "passed",
+ "duration": 9237,
+ "errors": [],
+ "stdout": [],
+ "stderr": [],
+ "retry": 0,
+ "startTime": "2026-09-09T19:49:21.630Z",
+ "annotations": [],
+ "attachments": [
+ {
+ "name": "video",
+ "contentType": "video/webm",
+ "path": "/Users/macstudio3/Projects/Norma-platform/verification/platform/browser-artifacts/workspace-Create-and-use-f-4ed69-olunteer-and-advocacy-forms-chromium/video.webm"
+ }
+ ]
+ }
+ ],
+ "status": "expected"
+ }
+ ],
+ "id": "b163ed09f68681248ada-0806bdff7a7bd373cd6a",
+ "file": "workspace.spec.ts",
+ "line": 45,
+ "column": 5
+ },
+ {
+ "title": "Create campaign, activate petition, submit, and inspect supporter and follow-up",
+ "ok": true,
+ "tags": [],
+ "tests": [
+ {
+ "timeout": 60000,
+ "annotations": [],
+ "expectedStatus": "passed",
+ "projectId": "webkit",
+ "projectName": "webkit",
+ "results": [
+ {
+ "workerIndex": 1,
+ "parallelIndex": 0,
+ "status": "passed",
+ "duration": 5982,
+ "errors": [],
+ "stdout": [],
+ "stderr": [],
+ "retry": 0,
+ "startTime": "2026-09-09T19:49:32.960Z",
+ "annotations": [],
+ "attachments": [
+ {
+ "name": "video",
+ "contentType": "video/webm",
+ "path": "/Users/macstudio3/Projects/Norma-platform/verification/platform/browser-artifacts/workspace-Create-campaign--31f54-ect-supporter-and-follow-up-webkit/video.webm"
+ }
+ ]
+ }
+ ],
+ "status": "expected"
+ }
+ ],
+ "id": "b163ed09f68681248ada-1e4de6dc00c898d047f1",
+ "file": "workspace.spec.ts",
+ "line": 5,
+ "column": 5
+ },
+ {
+ "title": "Campaign workspace stays usable on a narrow viewport with keyboard dialog dismissal",
+ "ok": true,
+ "tags": [],
+ "tests": [
+ {
+ "timeout": 60000,
+ "annotations": [],
+ "expectedStatus": "passed",
+ "projectId": "webkit",
+ "projectName": "webkit",
+ "results": [
+ {
+ "workerIndex": 1,
+ "parallelIndex": 0,
+ "status": "passed",
+ "duration": 1988,
+ "errors": [],
+ "stdout": [],
+ "stderr": [],
+ "retry": 0,
+ "startTime": "2026-09-09T19:49:42.222Z",
+ "annotations": [],
+ "attachments": [
+ {
+ "name": "video",
+ "contentType": "video/webm",
+ "path": "/Users/macstudio3/Projects/Norma-platform/verification/platform/browser-artifacts/workspace-Campaign-workspa-87f3c-h-keyboard-dialog-dismissal-webkit/video.webm"
+ }
+ ]
+ }
+ ],
+ "status": "expected"
+ }
+ ],
+ "id": "b163ed09f68681248ada-6f04ee676033b5f24530",
+ "file": "workspace.spec.ts",
+ "line": 36,
+ "column": 5
+ },
+ {
+ "title": "Create and use fundraiser, event, volunteer and advocacy forms",
+ "ok": true,
+ "tags": [],
+ "tests": [
+ {
+ "timeout": 60000,
+ "annotations": [],
+ "expectedStatus": "passed",
+ "projectId": "webkit",
+ "projectName": "webkit",
+ "results": [
+ {
+ "workerIndex": 1,
+ "parallelIndex": 0,
+ "status": "passed",
+ "duration": 9478,
+ "errors": [],
+ "stdout": [],
+ "stderr": [],
+ "retry": 0,
+ "startTime": "2026-09-09T19:49:44.229Z",
+ "annotations": [],
+ "attachments": [
+ {
+ "name": "video",
+ "contentType": "video/webm",
+ "path": "/Users/macstudio3/Projects/Norma-platform/verification/platform/browser-artifacts/workspace-Create-and-use-f-4ed69-olunteer-and-advocacy-forms-webkit/video.webm"
+ }
+ ]
+ }
+ ],
+ "status": "expected"
+ }
+ ],
+ "id": "b163ed09f68681248ada-4078a34968d046896c2e",
+ "file": "workspace.spec.ts",
+ "line": 45,
+ "column": 5
+ }
+ ]
+ }
+ ],
+ "errors": [],
+ "stats": {
+ "startTime": "2026-09-09T19:49:17.854Z",
+ "duration": 35959.447,
+ "expected": 6,
+ "skipped": 0,
+ "unexpected": 0,
+ "flaky": 0
+ }
+}
\ No newline at end of file
diff --git a/verification/platform/browser.config.ts b/verification/platform/browser.config.ts
new file mode 100644
index 0000000..fd38830
--- /dev/null
+++ b/verification/platform/browser.config.ts
@@ -0,0 +1,2 @@
+import { defineConfig, devices } from '@playwright/test';
+export default defineConfig({testDir:'../../tests/platform',timeout:60000,expect:{timeout:10000},workers:1,fullyParallel:false,retries:0,reporter:[['list'],['json',{outputFile:'browser-results.json'}]],outputDir:'browser-artifacts',use:{baseURL:'http://127.0.0.1:7416',headless:true,viewport:{width:1440,height:1000},screenshot:'only-on-failure',trace:'retain-on-failure',video:'on'},projects:[{name:'chromium',use:{...devices['Desktop Chrome'],viewport:{width:1440,height:1000}}},{name:'webkit',use:{...devices['Desktop Safari'],viewport:{width:1440,height:1000}}}]});
diff --git a/verification/platform/demo.json b/verification/platform/demo.json
new file mode 100644
index 0000000..d79100f
--- /dev/null
+++ b/verification/platform/demo.json
@@ -0,0 +1,42 @@
+{
+ "timestamp": "2026-09-09T19:49:36.113Z",
+ "organization_id": "8a030726-45fc-4ea6-a8c3-65906e904128",
+ "campaigns": [
+ "70b3a5a1-6392-4ebc-a8dd-87c64bd57768",
+ "0361475f-901f-470a-b6b2-e3e2a03ad4f1"
+ ],
+ "actions": [
+ {
+ "id": "0532978a-30c9-49ed-ba4c-23bd01d41279",
+ "kind": "petition",
+ "campaign_id": "70b3a5a1-6392-4ebc-a8dd-87c64bd57768"
+ },
+ {
+ "id": "88944589-2706-4a16-a2ca-d1582c0a6a29",
+ "kind": "fundraiser",
+ "campaign_id": "70b3a5a1-6392-4ebc-a8dd-87c64bd57768"
+ },
+ {
+ "id": "c1caef99-2c2a-4dfb-b2ce-10ecda0bf5c6",
+ "kind": "event",
+ "campaign_id": "70b3a5a1-6392-4ebc-a8dd-87c64bd57768"
+ },
+ {
+ "id": "4645e266-ba31-4ad6-a6ee-1219c9a6c055",
+ "kind": "volunteer",
+ "campaign_id": "70b3a5a1-6392-4ebc-a8dd-87c64bd57768"
+ },
+ {
+ "id": "fbeff313-0a63-4d87-893e-f3ecdfe6c9cf",
+ "kind": "advocacy",
+ "campaign_id": "70b3a5a1-6392-4ebc-a8dd-87c64bd57768"
+ },
+ {
+ "id": "c6636d64-3fbc-4971-9a0a-fefe5cb7617d",
+ "kind": "volunteer",
+ "campaign_id": "0361475f-901f-470a-b6b2-e3e2a03ad4f1"
+ }
+ ],
+ "url": "http://127.0.0.1:7416/campaigns",
+ "data": "Synthetic demo names and reserved example.test email addresses; no live funds or sends"
+}
\ No newline at end of file
diff --git a/verification/platform/publication-results.json b/verification/platform/publication-results.json
new file mode 100644
index 0000000..d623012
--- /dev/null
+++ b/verification/platform/publication-results.json
@@ -0,0 +1,15 @@
+{
+ "timestamp": "2026-09-09T20:04:04.090Z",
+ "base": "http://127.0.0.1:7417",
+ "checks": [
+ {
+ "name": "Default deployment rejects activation",
+ "verdict": "PASS"
+ },
+ {
+ "name": "Default deployment rejects public intake",
+ "verdict": "PASS"
+ }
+ ],
+ "cleanup": "No records created or changed; activation and intake rejected."
+}
\ No newline at end of file
diff --git a/verification/platform/screenshots/chromium-advocacy.png b/verification/platform/screenshots/chromium-advocacy.png
new file mode 100644
index 0000000..f61bbb3
Binary files /dev/null and b/verification/platform/screenshots/chromium-advocacy.png differ
diff --git a/verification/platform/screenshots/chromium-campaign.png b/verification/platform/screenshots/chromium-campaign.png
new file mode 100644
index 0000000..7429811
Binary files /dev/null and b/verification/platform/screenshots/chromium-campaign.png differ
diff --git a/verification/platform/screenshots/chromium-event.png b/verification/platform/screenshots/chromium-event.png
new file mode 100644
index 0000000..dbf5a32
Binary files /dev/null and b/verification/platform/screenshots/chromium-event.png differ
diff --git a/verification/platform/screenshots/chromium-fundraiser.png b/verification/platform/screenshots/chromium-fundraiser.png
new file mode 100644
index 0000000..8cbd1c0
Binary files /dev/null and b/verification/platform/screenshots/chromium-fundraiser.png differ
diff --git a/verification/platform/screenshots/chromium-mobile.png b/verification/platform/screenshots/chromium-mobile.png
new file mode 100644
index 0000000..656f7a8
Binary files /dev/null and b/verification/platform/screenshots/chromium-mobile.png differ
diff --git a/verification/platform/screenshots/chromium-receipt.png b/verification/platform/screenshots/chromium-receipt.png
new file mode 100644
index 0000000..acf0ed2
Binary files /dev/null and b/verification/platform/screenshots/chromium-receipt.png differ
diff --git a/verification/platform/screenshots/chromium-volunteer.png b/verification/platform/screenshots/chromium-volunteer.png
new file mode 100644
index 0000000..803df60
Binary files /dev/null and b/verification/platform/screenshots/chromium-volunteer.png differ
diff --git a/verification/platform/screenshots/webkit-advocacy.png b/verification/platform/screenshots/webkit-advocacy.png
new file mode 100644
index 0000000..6fe84bb
Binary files /dev/null and b/verification/platform/screenshots/webkit-advocacy.png differ
diff --git a/verification/platform/screenshots/webkit-campaign.png b/verification/platform/screenshots/webkit-campaign.png
new file mode 100644
index 0000000..8367c4c
Binary files /dev/null and b/verification/platform/screenshots/webkit-campaign.png differ
diff --git a/verification/platform/screenshots/webkit-event.png b/verification/platform/screenshots/webkit-event.png
new file mode 100644
index 0000000..973c5bc
Binary files /dev/null and b/verification/platform/screenshots/webkit-event.png differ
diff --git a/verification/platform/screenshots/webkit-fundraiser.png b/verification/platform/screenshots/webkit-fundraiser.png
new file mode 100644
index 0000000..ed51fd7
Binary files /dev/null and b/verification/platform/screenshots/webkit-fundraiser.png differ
diff --git a/verification/platform/screenshots/webkit-mobile.png b/verification/platform/screenshots/webkit-mobile.png
new file mode 100644
index 0000000..a81cd6b
Binary files /dev/null and b/verification/platform/screenshots/webkit-mobile.png differ
diff --git a/verification/platform/screenshots/webkit-receipt.png b/verification/platform/screenshots/webkit-receipt.png
new file mode 100644
index 0000000..61b0109
Binary files /dev/null and b/verification/platform/screenshots/webkit-receipt.png differ
diff --git a/verification/platform/screenshots/webkit-volunteer.png b/verification/platform/screenshots/webkit-volunteer.png
new file mode 100644
index 0000000..ec64a6d
Binary files /dev/null and b/verification/platform/screenshots/webkit-volunteer.png differ
← 1113c0b auto-data-snapshot: 2026-09-09T11:09:48 (1 data files) — age
·
back to Norma Platform
·
Record campaign browser proof and explicit provider integrat 4de7056 →