[object Object]

← back to Norma Platform

Add local field assignment and shift planning to campaign follow-ups

fba8ff69607997e515d2be41ba1721cea9d28f13 · 2026-09-09 13:59:12 -0700 · Steve Abrams

Files touched

Diff

commit fba8ff69607997e515d2be41ba1721cea9d28f13
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Sep 9 13:59:12 2026 -0700

    Add local field assignment and shift planning to campaign follow-ups
---
 components/campaigns/CampaignWorkspace.tsx | 6 +++---
 db/026_campaign_platform.sql               | 1 +
 db/027_field_assignments.sql               | 7 +++++++
 docs/platform/RUNBOOK.md                   | 2 +-
 lib/campaigns/store.ts                     | 8 +++++---
 lib/campaigns/types.ts                     | 2 +-
 scripts/campaign-preview.sh                | 1 +
 scripts/test-instance.sh                   | 5 ++++-
 tests/campaign-platform.mjs                | 2 +-
 9 files changed, 24 insertions(+), 10 deletions(-)

diff --git a/components/campaigns/CampaignWorkspace.tsx b/components/campaigns/CampaignWorkspace.tsx
index 549dd87..56cd0e2 100644
--- a/components/campaigns/CampaignWorkspace.tsx
+++ b/components/campaigns/CampaignWorkspace.tsx
@@ -112,7 +112,7 @@ export default function CampaignWorkspace() {
       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 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>{(t.assignee||t.shift_label||t.due_at)&&<p className={styles.note}><strong>{t.assignee||'Unassigned'}</strong>{t.shift_label&&<> · {t.shift_label}</>}{t.due_at&&<> · due {timestamp(t.due_at)}</>}</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>}) {
@@ -123,7 +123,7 @@ function EditorDialog({editor,campaigns,selected,onClose,onSave}:{editor:Editor;
   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==='task')data={...data,campaign_id:val('campaign_id'),body:val('body'),channel:val('channel'),assignee:val('assignee'),shift_label:val('shift_label'),due_at:val('due_at')?new Date(val('due_at')).toISOString():null};
       if(editor.type==='action'){
         const settings:Record<string,unknown>={};
         if(kind==='petition'||kind==='advocacy')settings.target=val('target');
@@ -141,7 +141,7 @@ function EditorDialog({editor,campaigns,selected,onClose,onSave}:{editor:Editor;
     {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==='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}>Assignee<input name="assignee" maxLength={160} defaultValue={task?.assignee}/></label><label className={styles.field}>Shift label<input name="shift_label" maxLength={160} placeholder="Saturday park crew" defaultValue={task?.shift_label}/></label><label className={styles.field}>Due<input type="datetime-local" name="due_at" defaultValue={localDate(task?.due_at||undefined)}/></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>}
diff --git a/db/026_campaign_platform.sql b/db/026_campaign_platform.sql
index 570d74b..926f77d 100644
--- a/db/026_campaign_platform.sql
+++ b/db/026_campaign_platform.sql
@@ -36,6 +36,7 @@ CREATE TABLE IF NOT EXISTS organizing_tasks (
  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')),
+ assignee text NOT NULL DEFAULT '', shift_label text NOT NULL DEFAULT '', due_at timestamptz,
  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)
 );
diff --git a/db/027_field_assignments.sql b/db/027_field_assignments.sql
new file mode 100644
index 0000000..a13db3c
--- /dev/null
+++ b/db/027_field_assignments.sql
@@ -0,0 +1,7 @@
+-- Additive field-organizing metadata for campaign follow-up tasks.
+BEGIN;
+ALTER TABLE organizing_tasks ADD COLUMN IF NOT EXISTS assignee text NOT NULL DEFAULT '';
+ALTER TABLE organizing_tasks ADD COLUMN IF NOT EXISTS shift_label text NOT NULL DEFAULT '';
+ALTER TABLE organizing_tasks ADD COLUMN IF NOT EXISTS due_at timestamptz;
+CREATE INDEX IF NOT EXISTS organizing_tasks_due ON organizing_tasks(org_id,due_at) WHERE due_at IS NOT NULL;
+COMMIT;
diff --git a/docs/platform/RUNBOOK.md b/docs/platform/RUNBOOK.md
index 0b0e13b..1476d7c 100644
--- a/docs/platform/RUNBOOK.md
+++ b/docs/platform/RUNBOOK.md
@@ -48,7 +48,7 @@ The gate runs its own isolated process with public campaign intake disabled. Whi
 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.
+4. Volunteer shifts, task assignment, phone/text/canvass operations, collaboration and appropriate service connectors. The current milestone now stores local assignee, shift label and due time on organizer tasks; delivery remains deferred.
 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.
diff --git a/lib/campaigns/store.ts b/lib/campaigns/store.ts
index 421b12a..d563a95 100644
--- a/lib/campaigns/store.ts
+++ b/lib/campaigns/store.ts
@@ -105,9 +105,11 @@ export async function saveTask(s: Scope, taskId: string | null, data: Record<str
     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];
+    const due = data.due_at ?? old?.due_at ?? null;
+    if (due !== null && !((typeof due === 'string' || due instanceof Date) && Number.isFinite(Date.parse(String(due))))) throw new CampaignError(400,'Due time must be a valid date.');
+    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'),text(data.assignee ?? old?.assignee ?? '','Assignee',160,false),text(data.shift_label ?? old?.shift_label ?? '','Shift',160,false),due];
+    const row = taskId ? (await client.query('UPDATE organizing_tasks SET title=$3,body=$4,channel=$5,status=$6,assignee=$7,shift_label=$8,due_at=$9,updated_at=now() WHERE org_id=$1 AND campaign_id=$2 AND id=$10 RETURNING *',[...values,taskId])).rows[0]
+      : (await client.query('INSERT INTO organizing_tasks(org_id,campaign_id,title,body,channel,status,assignee,shift_label,due_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING *',values)).rows[0];
     await audit(client,s,`followup.${row.status}`,row.id); return row;
   });
 }
diff --git a/lib/campaigns/types.ts b/lib/campaigns/types.ts
index 4b3db26..af1b1c7 100644
--- a/lib/campaigns/types.ts
+++ b/lib/campaigns/types.ts
@@ -8,7 +8,7 @@ export interface Campaign { id: string; org_id: string; title: string; summary:
 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 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'; assignee?: string; shift_label?: string; due_at?: string | null; 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' };
diff --git a/scripts/campaign-preview.sh b/scripts/campaign-preview.sh
index da431f4..106eeb9 100644
--- a/scripts/campaign-preview.sh
+++ b/scripts/campaign-preview.sh
@@ -6,4 +6,5 @@ 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
+psql postgresql://127.0.0.1:5432/sdcc_test -v ON_ERROR_STOP=1 -f db/027_field_assignments.sql
 exec bash scripts/test-instance.sh
diff --git a/scripts/test-instance.sh b/scripts/test-instance.sh
index 4d97ebd..002a4d6 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 db/026_campaign_platform.sql; do
+for m in db/024_user_management.sql db/025_email_assign_read.sql db/026_campaign_platform.sql db/027_field_assignments.sql; do
   if psql "$DATABASE_URL" -v ON_ERROR_STOP=0 -f "$m" >/dev/null 2>&1; then
     echo "[test-instance] ensured $m"
   else
@@ -49,6 +49,9 @@ missing=$(psql "$DATABASE_URL" -Atc "
     UNION ALL
     SELECT 'organizing_participation (missing migration 026)'
       WHERE to_regclass('public.organizing_participation') IS NULL
+    UNION ALL
+    SELECT 'organizing_tasks.due_at (missing migration 027)'
+      WHERE NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='organizing_tasks' AND column_name='due_at')
   ) 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-platform.mjs b/tests/campaign-platform.mjs
index b97ae10..d8116ba 100644
--- a/tests/campaign-platform.mjs
+++ b/tests/campaign-platform.mjs
@@ -84,7 +84,7 @@ try {
     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;
+    const r=await hit('/api/campaigns/tasks','POST',{campaign_id:primary,title:'Organizer checklist',body:'Review the campaign actions.',channel:'email',assignee:'Alex Organizer',shift_label:'Saturday park crew',due_at:new Date(Date.now()+86400000).toISOString()});assert.equal(r.status,201);const task=r.body.task.id;assert.equal(r.body.task.assignee,'Alex Organizer');assert.equal(r.body.task.shift_label,'Saturday park crew');
     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);

← 1e82ed2 Capture populated campaign preview at desktop width  ·  back to Norma Platform  ·  Refresh campaign verification after field assignment update f1431e1 →