Add petition/action pages with signature collection, CRM integration, and campaign linking

New influence submodule for public petitions with configurable sign forms,
email verification, GeoIP tracking, dedup, CSV export, admin moderation,
and post-sign CTA linking to advocacy campaigns. Includes competitive
analysis document covering 30+ campaign tech platforms.

Bunker Admin
This commit is contained in:
bunker-admin 2026-04-03 08:49:49 -06:00
parent 08bd1f92b0
commit 72622671a2
11 changed files with 2951 additions and 0 deletions

View File

@ -0,0 +1,243 @@
import { useState, useEffect, useCallback } from 'react';
import {
Card, Table, Button, Space, Tag, Input, Select, Drawer, Typography, message,
Statistic, Row, Col, Modal, Grid,
} from 'antd';
import {
CheckCircleOutlined, CloseCircleOutlined, ExclamationCircleOutlined,
SearchOutlined, ReloadOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import { api } from '@/lib/api';
import type { Petition, PetitionsListResponse, CampaignModerationStatus } from '@/types/api';
const { Text, Paragraph } = Typography;
const { TextArea } = Input;
const MODERATION_STATUS_COLORS: Record<CampaignModerationStatus, string> = {
PENDING_REVIEW: 'orange',
APPROVED: 'green',
REJECTED: 'red',
CHANGES_REQUESTED: 'gold',
};
export default function PetitionModerationPage() {
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [petitions, setPetitions] = useState<Petition[]>([]);
const [loading, setLoading] = useState(false);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(20);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<CampaignModerationStatus | undefined>(undefined);
const [stats, setStats] = useState<{ pending: number; approved: number; rejected: number; changesRequested: number } | null>(null);
const [selectedPetition, setSelectedPetition] = useState<Petition | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [actionModalOpen, setActionModalOpen] = useState(false);
const [actionType, setActionType] = useState<'reject' | 'request_changes' | null>(null);
const [actionReason, setActionReason] = useState('');
const [actionLoading, setActionLoading] = useState(false);
const fetchQueue = useCallback(async () => {
setLoading(true);
try {
const params: Record<string, string | number> = { page, limit: pageSize };
if (search) params.search = search;
if (statusFilter) params.moderationStatus = statusFilter;
const { data } = await api.get<PetitionsListResponse>('/petitions/moderation/queue', { params });
setPetitions(data.petitions);
setTotal(data.pagination.total);
} catch {
message.error('Failed to load moderation queue');
} finally {
setLoading(false);
}
}, [page, pageSize, search, statusFilter]);
const fetchStats = useCallback(async () => {
try {
const { data } = await api.get('/petitions/moderation/stats');
setStats(data);
} catch { /* non-critical */ }
}, []);
useEffect(() => { fetchQueue(); }, [fetchQueue]);
useEffect(() => { fetchStats(); }, [fetchStats]);
const handleApprove = async (petition: Petition) => {
try {
await api.patch(`/petitions/moderation/${petition.id}`, { action: 'approve' });
message.success('Petition approved');
fetchQueue();
fetchStats();
setDrawerOpen(false);
} catch {
message.error('Failed to approve petition');
}
};
const handleActionSubmit = async () => {
if (!selectedPetition || !actionType) return;
setActionLoading(true);
try {
await api.patch(`/petitions/moderation/${selectedPetition.id}`, {
action: actionType,
reason: actionReason,
});
message.success(actionType === 'reject' ? 'Petition rejected' : 'Changes requested');
setActionModalOpen(false);
setActionReason('');
setActionType(null);
setDrawerOpen(false);
fetchQueue();
fetchStats();
} catch {
message.error('Failed to update petition');
} finally {
setActionLoading(false);
}
};
const columns: ColumnsType<Petition> = [
{
title: 'Title',
dataIndex: 'title',
key: 'title',
ellipsis: true,
render: (title: string, record: Petition) => (
<Button type="link" onClick={() => { setSelectedPetition(record); setDrawerOpen(true); }} style={{ padding: 0 }}>
{title}
</Button>
),
},
{
title: 'Status',
dataIndex: 'moderationStatus',
key: 'status',
width: 130,
render: (status: CampaignModerationStatus | null) =>
status ? <Tag color={MODERATION_STATUS_COLORS[status]}>{status.replace(/_/g, ' ')}</Tag> : '-',
},
{
title: 'Submitted By',
dataIndex: 'createdByUserName',
key: 'submitter',
width: 150,
ellipsis: true,
},
...(isMobile ? [] : [{
title: 'Date',
dataIndex: 'createdAt',
key: 'date',
width: 110,
render: (d: string) => dayjs(d).format('MMM D, YYYY'),
} as any]),
{
title: 'Actions',
key: 'actions',
width: 200,
render: (_: unknown, record: Petition) => (
<Space size={4}>
<Button size="small" type="primary" icon={<CheckCircleOutlined />} onClick={() => handleApprove(record)}>Approve</Button>
<Button size="small" danger icon={<CloseCircleOutlined />}
onClick={() => { setSelectedPetition(record); setActionType('reject'); setActionModalOpen(true); }}
>Reject</Button>
</Space>
),
},
];
return (
<div style={{ padding: isMobile ? 12 : 24 }}>
{stats && (
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={6}><Card size="small"><Statistic title="Pending" value={stats.pending} valueStyle={{ color: '#faad14' }} /></Card></Col>
<Col xs={6}><Card size="small"><Statistic title="Approved" value={stats.approved} valueStyle={{ color: '#52c41a' }} /></Card></Col>
<Col xs={6}><Card size="small"><Statistic title="Rejected" value={stats.rejected} valueStyle={{ color: '#ff4d4f' }} /></Card></Col>
<Col xs={6}><Card size="small"><Statistic title="Changes" value={stats.changesRequested} /></Card></Col>
</Row>
)}
<Card
title="Petition Moderation Queue"
extra={
<Space wrap>
<Input placeholder="Search..." prefix={<SearchOutlined />} allowClear value={search} onChange={e => { setSearch(e.target.value); setPage(1); }} style={{ width: isMobile ? 140 : 200 }} />
<Select placeholder="Status" allowClear value={statusFilter} onChange={v => { setStatusFilter(v); setPage(1); }} style={{ width: 160 }}
options={['PENDING_REVIEW', 'APPROVED', 'REJECTED', 'CHANGES_REQUESTED'].map(s => ({ label: s.replace(/_/g, ' '), value: s }))}
/>
<Button icon={<ReloadOutlined />} onClick={fetchQueue} />
</Space>
}
>
<Table
dataSource={petitions}
columns={columns}
rowKey="id"
loading={loading}
pagination={{ current: page, pageSize, total, onChange: setPage, showSizeChanger: false, size: 'small' }}
size="small"
scroll={{ x: 600 }}
/>
</Card>
<Drawer
title="Review Petition"
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
width={isMobile ? '100%' : 500}
>
{selectedPetition && (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<div>
<Text strong style={{ fontSize: 18 }}>{selectedPetition.title}</Text>
<br />
<Text type="secondary">by {selectedPetition.createdByUserName || 'Unknown'} on {dayjs(selectedPetition.createdAt).format('MMM D, YYYY')}</Text>
</div>
{selectedPetition.description && (
<Card size="small" title="Description">
<Paragraph>{selectedPetition.description}</Paragraph>
</Card>
)}
{selectedPetition.callToAction && (
<Card size="small" title="Call to Action">
<Paragraph>{selectedPetition.callToAction}</Paragraph>
</Card>
)}
<Space>
<Button type="primary" icon={<CheckCircleOutlined />} onClick={() => handleApprove(selectedPetition)}>Approve</Button>
<Button danger icon={<CloseCircleOutlined />}
onClick={() => { setActionType('reject'); setActionModalOpen(true); }}
>Reject</Button>
<Button icon={<ExclamationCircleOutlined />}
onClick={() => { setActionType('request_changes'); setActionModalOpen(true); }}
>Request Changes</Button>
</Space>
</Space>
)}
</Drawer>
<Modal
title={actionType === 'reject' ? 'Reject Petition' : 'Request Changes'}
open={actionModalOpen}
onCancel={() => { setActionModalOpen(false); setActionReason(''); }}
onOk={handleActionSubmit}
confirmLoading={actionLoading}
okText={actionType === 'reject' ? 'Reject' : 'Send'}
okButtonProps={actionType === 'reject' ? { danger: true } : {}}
>
<TextArea
rows={4}
value={actionReason}
onChange={e => setActionReason(e.target.value)}
placeholder={actionType === 'reject' ? 'Reason for rejection...' : 'What changes are needed...'}
/>
</Modal>
</div>
);
}

View File

@ -0,0 +1,277 @@
import { useState, useEffect, useCallback } from 'react';
import {
Card, Table, Button, Space, Tag, Input, Select, Typography, message,
Statistic, Row, Col, Grid, Popconfirm,
} from 'antd';
import {
SearchOutlined, ReloadOutlined, DownloadOutlined, ArrowLeftOutlined,
CheckCircleOutlined, CloseCircleOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import { useParams, useNavigate } from 'react-router-dom';
import { api } from '@/lib/api';
import type { Petition, PetitionSignature, PetitionSignatureStatus, PetitionStats } from '@/types/api';
const { Text } = Typography;
const STATUS_COLORS: Record<PetitionSignatureStatus, string> = {
PENDING_VERIFICATION: 'orange',
VERIFIED: 'green',
UNVERIFIED: 'blue',
REJECTED: 'red',
};
export default function PetitionSignaturesPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [petition, setPetition] = useState<Petition | null>(null);
const [signatures, setSignatures] = useState<PetitionSignature[]>([]);
const [stats, setStats] = useState<PetitionStats | null>(null);
const [loading, setLoading] = useState(false);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(20);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<string | undefined>(undefined);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const fetchPetition = useCallback(async () => {
if (!id) return;
try {
const { data } = await api.get(`/petitions/${id}/admin`);
setPetition(data);
} catch {
message.error('Failed to load petition');
}
}, [id]);
const fetchSignatures = useCallback(async () => {
if (!id) return;
setLoading(true);
try {
const params: Record<string, string | number> = { page, limit: pageSize };
if (search) params.search = search;
if (statusFilter) params.status = statusFilter;
const { data } = await api.get(`/petitions/${id}/signatures`, { params });
setSignatures(data.signatures);
setTotal(data.pagination.total);
} catch {
message.error('Failed to load signatures');
} finally {
setLoading(false);
}
}, [id, page, pageSize, search, statusFilter]);
const fetchStats = useCallback(async () => {
if (!id) return;
try {
const { data } = await api.get<PetitionStats>(`/petitions/${id}/stats`);
setStats(data);
} catch { /* non-critical */ }
}, [id]);
useEffect(() => { fetchPetition(); }, [fetchPetition]);
useEffect(() => { fetchSignatures(); }, [fetchSignatures]);
useEffect(() => { fetchStats(); }, [fetchStats]);
const handleBulkAction = async (status: PetitionSignatureStatus) => {
if (!id || selectedIds.length === 0) return;
try {
await api.post(`/petitions/${id}/signatures/bulk-status`, { ids: selectedIds, status });
message.success(`${selectedIds.length} signatures updated`);
setSelectedIds([]);
fetchSignatures();
fetchStats();
} catch {
message.error('Failed to update signatures');
}
};
const handleDelete = async (sigId: string) => {
if (!id) return;
try {
await api.delete(`/petitions/${id}/signatures/${sigId}`);
message.success('Signature deleted');
fetchSignatures();
fetchStats();
} catch {
message.error('Failed to delete signature');
}
};
const handleExport = async (format: 'csv' | 'json') => {
if (!id) return;
try {
const { data } = await api.get(`/petitions/${id}/export`, {
params: { format },
responseType: format === 'csv' ? 'blob' : 'json',
});
const blob = format === 'csv' ? data : new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `petition-signatures.${format}`;
a.click();
URL.revokeObjectURL(url);
} catch {
message.error('Failed to export signatures');
}
};
const columns: ColumnsType<PetitionSignature> = [
{
title: 'Name',
dataIndex: 'signerName',
key: 'name',
ellipsis: true,
render: (name: string | null, record: PetitionSignature) => (
<Space direction="vertical" size={0}>
<Text>{name || <Text type="secondary">Anonymous</Text>}</Text>
{record.signerEmail && <Text type="secondary" style={{ fontSize: 11 }}>{record.signerEmail}</Text>}
</Space>
),
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
width: 130,
render: (status: PetitionSignatureStatus) => (
<Tag color={STATUS_COLORS[status]}>{status.replace(/_/g, ' ')}</Tag>
),
},
...(isMobile ? [] : [{
title: 'Location',
key: 'location',
width: 150,
render: (_: unknown, record: PetitionSignature) => {
const parts = [record.geoCity, record.geoRegion, record.geoCountry].filter(Boolean);
return parts.length ? <Text type="secondary">{parts.join(', ')}</Text> : <Text type="secondary">-</Text>;
},
} as any]),
...(isMobile ? [] : [{
title: 'Postal Code',
dataIndex: 'signerPostalCode',
key: 'postalCode',
width: 100,
render: (v: string | null) => v || '-',
} as any]),
...(isMobile ? [] : [{
title: 'Comment',
dataIndex: 'signerComment',
key: 'comment',
width: 200,
ellipsis: true,
render: (v: string | null) => v || <Text type="secondary">-</Text>,
} as any]),
{
title: 'Date',
dataIndex: 'createdAt',
key: 'date',
width: 100,
render: (d: string) => dayjs(d).format('MMM D, YYYY'),
},
{
title: 'Actions',
key: 'actions',
width: 120,
render: (_: unknown, record: PetitionSignature) => (
<Space size={4}>
{record.status !== 'VERIFIED' && (
<Button size="small" type="link" icon={<CheckCircleOutlined />}
onClick={async () => {
await api.patch(`/petitions/${id}/signatures/${record.id}/status`, { status: 'VERIFIED' });
fetchSignatures(); fetchStats();
}}
/>
)}
{record.status !== 'REJECTED' && (
<Button size="small" type="link" danger icon={<CloseCircleOutlined />}
onClick={async () => {
await api.patch(`/petitions/${id}/signatures/${record.id}/status`, { status: 'REJECTED' });
fetchSignatures(); fetchStats();
}}
/>
)}
<Popconfirm title="Delete signature?" onConfirm={() => handleDelete(record.id)} okText="Delete" okButtonProps={{ danger: true }}>
<Button size="small" type="link" danger>Del</Button>
</Popconfirm>
</Space>
),
},
];
const topCountries = stats ? Object.entries(stats.byCountry).sort((a, b) => b[1] - a[1]).slice(0, 5) : [];
return (
<div style={{ padding: isMobile ? 12 : 24 }}>
<Button icon={<ArrowLeftOutlined />} type="link" onClick={() => navigate('/app/influence/petitions')} style={{ marginBottom: 12, paddingLeft: 0 }}>
Back to Petitions
</Button>
{petition && (
<Typography.Title level={4} style={{ marginBottom: 16 }}>{petition.title}</Typography.Title>
)}
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={8}><Card size="small"><Statistic title="Total" value={stats?.total ?? 0} /></Card></Col>
<Col xs={8}><Card size="small"><Statistic title="Verified" value={stats?.verified ?? 0} valueStyle={{ color: '#52c41a' }} /></Card></Col>
<Col xs={8}><Card size="small"><Statistic title="Goal" value={stats?.goal ? `${stats.percentComplete}%` : 'N/A'} /></Card></Col>
</Row>
{topCountries.length > 0 && (
<Card size="small" style={{ marginBottom: 16 }}>
<Text strong>Top Countries: </Text>
{topCountries.map(([country, count]) => (
<Tag key={country}>{country}: {count}</Tag>
))}
</Card>
)}
<Card
title="Signatures"
extra={
<Space wrap>
<Input placeholder="Search..." prefix={<SearchOutlined />} allowClear value={search} onChange={e => { setSearch(e.target.value); setPage(1); }} style={{ width: isMobile ? 120 : 180 }} />
<Select placeholder="Status" allowClear value={statusFilter} onChange={v => { setStatusFilter(v); setPage(1); }} style={{ width: 140 }}
options={['PENDING_VERIFICATION', 'VERIFIED', 'UNVERIFIED', 'REJECTED'].map(s => ({ label: s.replace(/_/g, ' '), value: s }))}
/>
{selectedIds.length > 0 && (
<>
<Button size="small" type="primary" onClick={() => handleBulkAction('VERIFIED')}>Approve ({selectedIds.length})</Button>
<Button size="small" danger onClick={() => handleBulkAction('REJECTED')}>Reject ({selectedIds.length})</Button>
</>
)}
<Button icon={<DownloadOutlined />} onClick={() => handleExport('csv')}>CSV</Button>
<Button icon={<ReloadOutlined />} onClick={() => { fetchSignatures(); fetchStats(); }} />
</Space>
}
>
<Table
dataSource={signatures}
columns={columns}
rowKey="id"
loading={loading}
rowSelection={{
selectedRowKeys: selectedIds,
onChange: (keys) => setSelectedIds(keys as string[]),
}}
pagination={{
current: page,
pageSize,
total,
onChange: setPage,
showSizeChanger: false,
size: 'small',
}}
size="small"
scroll={{ x: 700 }}
/>
</Card>
</div>
);
}

View File

@ -0,0 +1,390 @@
import { useState, useEffect, useCallback } from 'react';
import {
Card, Table, Button, Space, Tag, Input, Select, Drawer, Typography, message,
Statistic, Row, Col, Form, InputNumber, Switch, Grid, Popconfirm,
} from 'antd';
import {
PlusOutlined, SearchOutlined, ReloadOutlined, EditOutlined,
DeleteOutlined, EyeOutlined, FileTextOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import { useNavigate } from 'react-router-dom';
import { api } from '@/lib/api';
import { VideoPickerModal } from '@/components/media/VideoPickerModal';
import { PhotoPickerModal } from '@/components/media/PhotoPickerModal';
import type { Video } from '@/components/media/VideoPickerModal';
import type { Photo } from '@/components/media/PhotoPickerModal';
import { useSettingsStore } from '@/stores/settings.store';
import type { Petition, PetitionsListResponse, PetitionStatus } from '@/types/api';
const { Text } = Typography;
const { TextArea } = Input;
const STATUS_COLORS: Record<PetitionStatus, string> = {
DRAFT: 'default',
ACTIVE: 'green',
PAUSED: 'orange',
CLOSED: 'red',
ARCHIVED: 'purple',
};
export default function PetitionsPage() {
const navigate = useNavigate();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [petitions, setPetitions] = useState<Petition[]>([]);
const [loading, setLoading] = useState(false);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(20);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<PetitionStatus | undefined>(undefined);
const [drawerOpen, setDrawerOpen] = useState(false);
const [editingPetition, setEditingPetition] = useState<Petition | null>(null);
const [saving, setSaving] = useState(false);
const [campaigns, setCampaigns] = useState<{ id: string; title: string; slug: string }[]>([]);
const [form] = Form.useForm();
const { settings: siteSettings } = useSettingsStore();
const [selectedVideo, setSelectedVideo] = useState<Video | null>(null);
const [videoPickerOpen, setVideoPickerOpen] = useState(false);
const [selectedPhoto, setSelectedPhoto] = useState<Photo | null>(null);
const [photoPickerOpen, setPhotoPickerOpen] = useState(false);
const fetchPetitions = useCallback(async () => {
setLoading(true);
try {
const params: Record<string, string | number> = { page, limit: pageSize };
if (search) params.search = search;
if (statusFilter) params.status = statusFilter;
const { data } = await api.get<PetitionsListResponse>('/petitions', { params });
setPetitions(data.petitions);
setTotal(data.pagination.total);
} catch {
message.error('Failed to load petitions');
} finally {
setLoading(false);
}
}, [page, pageSize, search, statusFilter]);
const fetchCampaigns = useCallback(async () => {
try {
const { data } = await api.get('/campaigns', { params: { limit: 100, status: 'ACTIVE' } });
setCampaigns(data.campaigns?.map((c: any) => ({ id: c.id, title: c.title, slug: c.slug })) || []);
} catch { /* non-critical */ }
}, []);
useEffect(() => { fetchPetitions(); }, [fetchPetitions]);
useEffect(() => { fetchCampaigns(); }, [fetchCampaigns]);
const openCreate = () => {
setEditingPetition(null);
form.resetFields();
form.setFieldsValue({
status: 'DRAFT',
showProgress: true,
showSignatureCount: true,
showSignerNames: true,
requireName: true,
requireEmail: true,
allowComment: true,
});
setSelectedVideo(null);
setSelectedPhoto(null);
setDrawerOpen(true);
};
const openEdit = async (petition: Petition) => {
try {
const { data } = await api.get(`/petitions/${petition.id}/admin`);
setEditingPetition(data);
form.setFieldsValue(data);
setSelectedVideo(data.coverVideoId ? { id: data.coverVideoId, title: `Video #${data.coverVideoId}` } as Video : null);
setSelectedPhoto(data.coverPhoto ? { id: 0, title: 'Current cover', thumbnailUrl: data.coverPhoto } as unknown as Photo : null);
setDrawerOpen(true);
} catch {
message.error('Failed to load petition details');
}
};
const handleSave = async () => {
try {
const values = await form.validateFields();
setSaving(true);
const payload = { ...values, coverVideoId: selectedVideo?.id ?? null };
if (editingPetition) {
await api.put(`/petitions/${editingPetition.id}`, payload);
message.success('Petition updated');
} else {
await api.post('/petitions', payload);
message.success('Petition created');
}
setDrawerOpen(false);
fetchPetitions();
} catch (err: any) {
if (err?.errorFields) return; // form validation
message.error('Failed to save petition');
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
try {
await api.delete(`/petitions/${id}`);
message.success('Petition deleted');
fetchPetitions();
} catch {
message.error('Failed to delete petition');
}
};
const columns: ColumnsType<Petition> = [
{
title: 'Title',
dataIndex: 'title',
key: 'title',
ellipsis: true,
render: (title: string, record: Petition) => (
<Space direction="vertical" size={0}>
<Text strong>{title}</Text>
<a href={`/petition/${record.slug}`} target="_blank" rel="noopener noreferrer" style={{ fontSize: 12 }}>/petition/{record.slug}</a>
</Space>
),
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
width: 100,
render: (status: PetitionStatus) => (
<Tag color={STATUS_COLORS[status]}>{status}</Tag>
),
},
{
title: 'Signatures',
key: 'signatures',
width: 140,
render: (_: unknown, record: Petition) => {
const count = record._count.signatures + record.signatureCountOffset;
const goal = record.signatureGoal;
if (goal) {
const pct = Math.min(100, Math.round((count / goal) * 100));
return (
<Space direction="vertical" size={0}>
<Text strong>{count.toLocaleString()} / {goal.toLocaleString()}</Text>
<div style={{ width: 80, height: 4, background: '#333', borderRadius: 2 }}>
<div style={{ width: `${pct}%`, height: '100%', background: '#52c41a', borderRadius: 2 }} />
</div>
</Space>
);
}
return <Text strong>{count.toLocaleString()}</Text>;
},
},
...(isMobile ? [] : [{
title: 'Linked Campaign',
dataIndex: 'linkedCampaignId',
key: 'linkedCampaign',
width: 160,
render: (_: unknown, record: Petition) => {
if (!record.linkedCampaignId) return <Text type="secondary">None</Text>;
const campaign = campaigns.find(c => c.id === record.linkedCampaignId);
return campaign ? <Tag color="blue">{campaign.title}</Tag> : <Text type="secondary">Unknown</Text>;
},
} as any]),
...(isMobile ? [] : [{
title: 'Created',
dataIndex: 'createdAt',
key: 'createdAt',
width: 110,
render: (d: string) => dayjs(d).format('MMM D, YYYY'),
} as any]),
{
title: 'Actions',
key: 'actions',
width: 200,
render: (_: unknown, record: Petition) => (
<Space wrap size={4}>
<Button size="small" icon={<EditOutlined />} onClick={() => openEdit(record)}>Edit</Button>
<Button size="small" icon={<EyeOutlined />} onClick={() => navigate(`/app/influence/petitions/${record.id}/signatures`)}>Signatures</Button>
<Popconfirm title="Delete this petition?" onConfirm={() => handleDelete(record.id)} okText="Delete" okButtonProps={{ danger: true }}>
<Button size="small" icon={<DeleteOutlined />} danger />
</Popconfirm>
</Space>
),
},
];
return (
<div style={{ padding: isMobile ? 12 : 24 }}>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={8}><Card size="small"><Statistic title="Total" value={total} /></Card></Col>
<Col xs={8}><Card size="small"><Statistic title="Active" value={petitions.filter(p => p.status === 'ACTIVE').length} valueStyle={{ color: '#52c41a' }} /></Card></Col>
<Col xs={8}><Card size="small"><Statistic title="Signatures" value={petitions.reduce((sum, p) => sum + p._count.signatures + p.signatureCountOffset, 0)} /></Card></Col>
</Row>
<Card
title={<Space><FileTextOutlined />Petitions</Space>}
extra={
<Space wrap>
<Input placeholder="Search..." prefix={<SearchOutlined />} allowClear value={search} onChange={e => { setSearch(e.target.value); setPage(1); }} style={{ width: isMobile ? 140 : 200 }} />
<Select placeholder="Status" allowClear value={statusFilter} onChange={v => { setStatusFilter(v); setPage(1); }} style={{ width: 120 }} options={['DRAFT', 'ACTIVE', 'PAUSED', 'CLOSED', 'ARCHIVED'].map(s => ({ label: s, value: s }))} />
<Button icon={<ReloadOutlined />} onClick={fetchPetitions} />
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>New Petition</Button>
</Space>
}
>
<Table
dataSource={petitions}
columns={columns}
rowKey="id"
loading={loading}
pagination={{
current: page,
pageSize,
total,
onChange: setPage,
showSizeChanger: false,
size: 'small',
}}
size="small"
scroll={{ x: 700 }}
/>
</Card>
<Drawer
title={editingPetition ? 'Edit Petition' : 'New Petition'}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
width={isMobile ? '100%' : 600}
extra={
<Space>
<Button onClick={() => setDrawerOpen(false)}>Cancel</Button>
<Button type="primary" loading={saving} onClick={handleSave}>Save</Button>
</Space>
}
>
<Form form={form} layout="vertical">
<Form.Item name="title" label="Title" rules={[{ required: true, message: 'Title is required' }]}>
<Input maxLength={200} />
</Form.Item>
<Form.Item name="description" label="Description">
<TextArea rows={4} maxLength={5000} />
</Form.Item>
<Form.Item name="status" label="Status">
<Select options={['DRAFT', 'ACTIVE', 'PAUSED', 'CLOSED', 'ARCHIVED'].map(s => ({ label: s, value: s }))} />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="signatureGoal" label="Signature Goal">
<InputNumber min={1} style={{ width: '100%' }} placeholder="No goal" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="signatureCountOffset" label="Count Offset">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Form.Item name="linkedCampaignId" label="Link to Campaign (post-sign CTA)">
<Select
allowClear
placeholder="Select a campaign to link..."
options={campaigns.map(c => ({ label: c.title, value: c.id }))}
/>
</Form.Item>
<Form.Item label="Cover Image">
{selectedPhoto ? (
<Space>
<img src={selectedPhoto.thumbnailUrl || form.getFieldValue('coverPhoto')} alt="Cover" style={{ height: 48, borderRadius: 4 }} />
<Tag color="blue">{selectedPhoto.title || 'Selected'}</Tag>
<Button size="small" danger onClick={() => { setSelectedPhoto(null); form.setFieldsValue({ coverPhoto: null }); }}>Remove</Button>
</Space>
) : (
<Space>
<Button size="small" onClick={() => setPhotoPickerOpen(true)}>Choose from Gallery</Button>
<Form.Item name="coverPhoto" noStyle>
<Input placeholder="or paste URL..." style={{ width: 220 }} />
</Form.Item>
</Space>
)}
</Form.Item>
{siteSettings?.enableMediaFeatures !== false && (
<Form.Item label="Cover Video">
{selectedVideo ? (
<Space>
<Tag color="blue">Video #{selectedVideo.id}: {selectedVideo.title}</Tag>
<Button size="small" danger onClick={() => setSelectedVideo(null)}>Remove</Button>
</Space>
) : (
<Button size="small" onClick={() => setVideoPickerOpen(true)}>Choose Cover Video</Button>
)}
</Form.Item>
)}
<Form.Item name="callToAction" label="Call to Action Text">
<TextArea rows={2} maxLength={2000} />
</Form.Item>
<Form.Item name="thankYouMessage" label="Thank You Message (after signing)">
<TextArea rows={2} maxLength={2000} />
</Form.Item>
<Form.Item name="commentLabel" label="Comment Label">
<Input placeholder="e.g. Why do you support this?" maxLength={200} />
</Form.Item>
<Card size="small" title="Form Fields" style={{ marginBottom: 16 }}>
<Row gutter={16}>
<Col span={12}><Form.Item name="requireName" label="Require Name" valuePropName="checked"><Switch /></Form.Item></Col>
<Col span={12}><Form.Item name="requireEmail" label="Require Email" valuePropName="checked"><Switch /></Form.Item></Col>
<Col span={12}><Form.Item name="requirePostalCode" label="Require Postal Code" valuePropName="checked"><Switch /></Form.Item></Col>
<Col span={12}><Form.Item name="requirePhone" label="Require Phone" valuePropName="checked"><Switch /></Form.Item></Col>
<Col span={12}><Form.Item name="allowComment" label="Allow Comments" valuePropName="checked"><Switch /></Form.Item></Col>
<Col span={12}><Form.Item name="requireEmailConfirmation" label="Email Confirmation" valuePropName="checked"><Switch /></Form.Item></Col>
</Row>
</Card>
<Card size="small" title="Display Options" style={{ marginBottom: 16 }}>
<Row gutter={16}>
<Col span={12}><Form.Item name="showProgress" label="Show Progress Bar" valuePropName="checked"><Switch /></Form.Item></Col>
<Col span={12}><Form.Item name="showSignatureCount" label="Show Signature Count" valuePropName="checked"><Switch /></Form.Item></Col>
<Col span={12}><Form.Item name="showSignerNames" label="Show Signer Names" valuePropName="checked"><Switch /></Form.Item></Col>
<Col span={12}><Form.Item name="highlightPetition" label="Highlight Petition" valuePropName="checked"><Switch /></Form.Item></Col>
</Row>
</Card>
</Form>
</Drawer>
<VideoPickerModal
open={videoPickerOpen}
onClose={() => setVideoPickerOpen(false)}
onSelect={(video: Video) => {
setSelectedVideo(video);
setVideoPickerOpen(false);
}}
title="Select Cover Video"
/>
<PhotoPickerModal
open={photoPickerOpen}
onClose={() => setPhotoPickerOpen(false)}
onSelect={(photo: Photo) => {
const url = photo.thumbnailUrl || `/media/photos/${photo.id}/file`;
setSelectedPhoto(photo);
form.setFieldsValue({ coverPhoto: url });
setPhotoPickerOpen(false);
}}
title="Select Cover Image"
/>
</div>
);
}

View File

@ -0,0 +1,295 @@
import { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import {
Typography, Card, Button, Input, Checkbox, Form, Spin, Result, Progress,
Space, Divider, List, Avatar, Grid, theme, message,
} from 'antd';
import {
TeamOutlined, CheckCircleFilled, ShareAltOutlined,
CopyOutlined, EnvironmentOutlined, ArrowRightOutlined,
SendOutlined,
} from '@ant-design/icons';
import axios from 'axios';
import { useSettingsStore } from '@/stores/settings.store';
import { VideoPlayer } from '@/components/media/VideoPlayer';
import type { Petition, SignPetitionResponse } from '@/types/api';
const { Title, Text, Paragraph } = Typography;
const { TextArea } = Input;
const API = '/api';
type PageState = 'form' | 'submitted' | 'pending_verification';
export default function PetitionPage() {
const { slug } = useParams<{ slug: string }>();
const { token } = theme.useToken();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [petition, setPetition] = useState<Petition | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [state, setState] = useState<PageState>('form');
const [submitting, setSubmitting] = useState(false);
const [signatureCount, setSignatureCount] = useState(0);
const [recentSigners, setRecentSigners] = useState<any[]>([]);
const [form] = Form.useForm();
const { settings: siteSettings } = useSettingsStore();
useEffect(() => {
if (!slug) return;
(async () => {
try {
const { data } = await axios.get(`${API}/petitions/${slug}/details`);
setPetition(data);
setSignatureCount(data._count.signatures + data.signatureCountOffset);
// Fetch recent signers
try {
const { data: sigData } = await axios.get(`${API}/petitions/${slug}/signers`, { params: { limit: 10 } });
setRecentSigners(sigData.signatures || []);
} catch { /* non-critical */ }
} catch {
setError(true);
} finally {
setLoading(false);
}
})();
}, [slug]);
const handleSign = async () => {
if (!slug) return;
try {
const values = await form.validateFields();
setSubmitting(true);
const { data } = await axios.post<SignPetitionResponse>(`${API}/petitions/${slug}/sign`, values);
if (data.alreadySigned) {
message.info('You have already signed this petition. Thank you!');
setState('submitted');
return;
}
setSignatureCount(prev => prev + 1);
if (data.verificationSent) {
setState('pending_verification');
} else {
setState('submitted');
}
} catch (err: any) {
const msg = err?.response?.data?.error?.message || 'Failed to submit signature';
message.error(msg);
} finally {
setSubmitting(false);
}
};
const handleCopyLink = () => {
navigator.clipboard.writeText(window.location.href);
message.success('Link copied!');
};
const handleShareTwitter = () => {
const text = `I signed "${petition?.title}" — add your voice too!`;
window.open(`https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(window.location.href)}`, '_blank');
};
if (loading) return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
if (error || !petition) return <Result status="404" title="Petition Not Found" subTitle="This petition may have been removed or is not yet active." />;
const hasGoal = petition.signatureGoal && petition.signatureGoal > 0;
const pct = hasGoal ? Math.min(100, Math.round((signatureCount / petition.signatureGoal!) * 100)) : null;
const linkedCampaign = petition.linkedCampaign;
return (
<div style={{ maxWidth: 700, margin: '0 auto', padding: isMobile ? '16px 12px' : '32px 16px' }}>
{/* Cover Video */}
{petition.coverVideoId && siteSettings?.enableMediaFeatures !== false && (
<div style={{ marginBottom: 24, borderRadius: 12, overflow: 'hidden' }}>
<VideoPlayer videoId={petition.coverVideoId} width="100%" height="auto" controls />
</div>
)}
{/* Cover Image (only if no video) */}
{!petition.coverVideoId && petition.coverPhoto && (
<div style={{ borderRadius: 12, overflow: 'hidden', marginBottom: 24 }}>
<img src={petition.coverPhoto} alt={petition.title} style={{ width: '100%', maxHeight: 300, objectFit: 'cover' }} />
</div>
)}
<Title level={2} style={{ marginBottom: 8 }}>{petition.title}</Title>
{petition.description && (
<Paragraph style={{ fontSize: 16, lineHeight: 1.7, marginBottom: 24, color: token.colorTextSecondary }}>
{petition.description}
</Paragraph>
)}
{/* Signature counter */}
<Card style={{ marginBottom: 24, background: token.colorBgContainer, textAlign: 'center' }}>
<TeamOutlined style={{ fontSize: 28, color: token.colorPrimary, marginBottom: 8 }} />
<Title level={3} style={{ margin: 0, color: token.colorPrimary }}>
{signatureCount.toLocaleString()}
</Title>
<Text type="secondary">
signature{signatureCount !== 1 ? 's' : ''}
{hasGoal && ` of ${petition.signatureGoal!.toLocaleString()} goal`}
</Text>
{hasGoal && (
<Progress percent={pct!} strokeColor={token.colorPrimary} style={{ marginTop: 12 }} />
)}
</Card>
{/* Sign Form */}
{state === 'form' && (
<Card title="Sign this petition" style={{ marginBottom: 24, background: token.colorBgContainer }}>
<Form form={form} layout="vertical" onFinish={handleSign}>
{petition.requireName && (
<Form.Item name="signerName" label="Your Name" rules={[{ required: true, message: 'Name is required' }]}>
<Input size="large" placeholder="Your full name" maxLength={200} />
</Form.Item>
)}
{petition.requireEmail && (
<Form.Item name="signerEmail" label="Email" rules={[{ required: true, message: 'Email is required' }, { type: 'email', message: 'Invalid email' }]}>
<Input size="large" placeholder="your@email.com" maxLength={255} />
</Form.Item>
)}
{petition.requirePostalCode && (
<Form.Item name="signerPostalCode" label="Postal Code" rules={[{ required: true, message: 'Postal code is required' }]}>
<Input size="large" placeholder="A1A 1A1" maxLength={20} />
</Form.Item>
)}
{petition.requirePhone && (
<Form.Item name="signerPhone" label="Phone" rules={[{ required: true, message: 'Phone is required' }]}>
<Input size="large" placeholder="(555) 123-4567" maxLength={30} />
</Form.Item>
)}
{petition.allowComment && (
<Form.Item name="signerComment" label={petition.commentLabel || 'Why do you support this?'}>
<TextArea rows={3} maxLength={2000} placeholder="Share your thoughts (optional)" />
</Form.Item>
)}
<Form.Item name="isAnonymous" valuePropName="checked" style={{ marginBottom: 16 }}>
<Checkbox>Sign anonymously (your name will not be displayed)</Checkbox>
</Form.Item>
<Button type="primary" htmlType="submit" size="large" block loading={submitting} icon={<CheckCircleFilled />}>
Sign Petition
</Button>
{petition.requireEmailConfirmation && (
<Text type="secondary" style={{ display: 'block', textAlign: 'center', marginTop: 8, fontSize: 12 }}>
You will receive an email to confirm your signature
</Text>
)}
</Form>
</Card>
)}
{/* Pending verification */}
{state === 'pending_verification' && (
<Card style={{ marginBottom: 24, textAlign: 'center', background: token.colorBgContainer }}>
<Result
status="info"
title="Check Your Email"
subTitle="We've sent a confirmation link to your email. Click it to verify your signature."
/>
</Card>
)}
{/* Submitted / Thank You */}
{state === 'submitted' && (
<Card style={{ marginBottom: 24, textAlign: 'center', background: token.colorBgContainer }}>
<CheckCircleFilled style={{ fontSize: 48, color: '#52c41a', marginBottom: 16 }} />
<Title level={3}>Thank You!</Title>
{petition.thankYouMessage && (
<Paragraph style={{ fontSize: 16 }}>{petition.thankYouMessage}</Paragraph>
)}
{/* Share buttons */}
<Divider>Share this petition</Divider>
<Space wrap style={{ justifyContent: 'center' }}>
<Button icon={<CopyOutlined />} onClick={handleCopyLink}>Copy Link</Button>
<Button icon={<ShareAltOutlined />} onClick={handleShareTwitter}>Share on X</Button>
</Space>
{/* Linked Campaign CTA */}
{linkedCampaign && linkedCampaign.status === 'ACTIVE' && (
<>
<Divider>Take further action</Divider>
<Card
hoverable
style={{ textAlign: 'left', background: token.colorBgElevated, borderColor: token.colorPrimary, borderWidth: 2 }}
>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Space>
<SendOutlined style={{ color: token.colorPrimary, fontSize: 18 }} />
<Text strong style={{ fontSize: 16 }}>{linkedCampaign.title}</Text>
</Space>
{linkedCampaign.description && (
<Paragraph type="secondary" ellipsis={{ rows: 2 }} style={{ margin: 0 }}>
{linkedCampaign.description}
</Paragraph>
)}
<Link to={`/campaign/${linkedCampaign.slug}`}>
<Button type="primary" icon={<ArrowRightOutlined />} style={{ marginTop: 4 }}>
Email Your Representatives
</Button>
</Link>
</Space>
</Card>
</>
)}
</Card>
)}
{/* Call to action text */}
{petition.callToAction && state === 'form' && (
<Card style={{ marginBottom: 24, background: token.colorBgContainer }}>
<Paragraph style={{ fontSize: 15, margin: 0, whiteSpace: 'pre-wrap' }}>{petition.callToAction}</Paragraph>
</Card>
)}
{/* Recent signers */}
{petition.showSignerNames && recentSigners.length > 0 && (
<Card title="Recent Supporters" style={{ marginBottom: 24, background: token.colorBgContainer }}>
<List
dataSource={recentSigners}
renderItem={(signer: any) => (
<List.Item style={{ padding: '8px 0' }}>
<List.Item.Meta
avatar={<Avatar size="small" icon={<TeamOutlined />} style={{ background: token.colorPrimary }} />}
title={signer.displayName || 'Anonymous'}
description={
<Space size={4}>
{(signer.geoCity || signer.geoCountry) && (
<Text type="secondary" style={{ fontSize: 12 }}>
<EnvironmentOutlined /> {[signer.geoCity, signer.geoCountry].filter(Boolean).join(', ')}
</Text>
)}
</Space>
}
/>
</List.Item>
)}
/>
</Card>
)}
{/* Share bar (always visible) */}
{state === 'form' && (
<div style={{ textAlign: 'center', marginBottom: 32 }}>
<Space wrap>
<Button icon={<CopyOutlined />} onClick={handleCopyLink}>Copy Link</Button>
<Button icon={<ShareAltOutlined />} onClick={handleShareTwitter}>Share on X</Button>
</Space>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,89 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Typography, Card, Row, Col, Spin, Empty, Progress, Grid, theme } from 'antd';
import { FileTextOutlined, TeamOutlined } from '@ant-design/icons';
import axios from 'axios';
import type { Petition } from '@/types/api';
const { Title, Text, Paragraph } = Typography;
const API = '/api';
export default function PetitionsListPage() {
const [petitions, setPetitions] = useState<Petition[]>([]);
const [loading, setLoading] = useState(true);
const { token } = theme.useToken();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
useEffect(() => {
(async () => {
try {
const { data } = await axios.get(`${API}/petitions/public`);
setPetitions(data);
} catch {
/* ignore */
} finally {
setLoading(false);
}
})();
}, []);
if (loading) return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
if (!petitions.length) return <Empty description="No active petitions" style={{ marginTop: 80 }} />;
return (
<div style={{ maxWidth: 900, margin: '0 auto', padding: isMobile ? '16px 12px' : '32px 16px' }}>
<Title level={2} style={{ textAlign: 'center', marginBottom: 8 }}>
<FileTextOutlined style={{ marginRight: 8 }} />Petitions
</Title>
<Text type="secondary" style={{ display: 'block', textAlign: 'center', marginBottom: 32 }}>
Add your voice to causes that matter
</Text>
<Row gutter={[16, 16]}>
{petitions.map(petition => {
const signatureCount = petition._count.signatures + petition.signatureCountOffset;
const hasGoal = petition.signatureGoal && petition.signatureGoal > 0;
const pct = hasGoal ? Math.min(100, Math.round((signatureCount / petition.signatureGoal!) * 100)) : null;
return (
<Col xs={24} sm={12} key={petition.id}>
<Link to={`/petition/${petition.slug}`} style={{ textDecoration: 'none' }}>
<Card
hoverable
cover={petition.coverPhoto ? (
<img alt={petition.title} src={petition.coverPhoto} style={{ height: 160, objectFit: 'cover' }} />
) : undefined}
style={{ height: '100%', background: token.colorBgContainer, borderColor: token.colorBorder }}
>
<Title level={4} style={{ marginBottom: 8, color: token.colorText }}>{petition.title}</Title>
{petition.description && (
<Paragraph type="secondary" ellipsis={{ rows: 2 }} style={{ marginBottom: 12 }}>
{petition.description}
</Paragraph>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: hasGoal ? 8 : 0 }}>
<TeamOutlined style={{ color: token.colorPrimary }} />
<Text strong style={{ color: token.colorPrimary }}>
{signatureCount.toLocaleString()} signature{signatureCount !== 1 ? 's' : ''}
</Text>
{hasGoal && (
<Text type="secondary" style={{ fontSize: 12 }}>
of {petition.signatureGoal!.toLocaleString()} goal
</Text>
)}
</div>
{hasGoal && (
<Progress percent={pct!} size="small" showInfo={false} strokeColor={token.colorPrimary} />
)}
</Card>
</Link>
</Col>
);
})}
</Row>
</div>
);
}

View File

@ -0,0 +1,131 @@
-- CreateEnum
CREATE TYPE "PetitionStatus" AS ENUM ('DRAFT', 'ACTIVE', 'PAUSED', 'CLOSED', 'ARCHIVED');
-- CreateEnum
CREATE TYPE "PetitionSignatureStatus" AS ENUM ('PENDING_VERIFICATION', 'VERIFIED', 'UNVERIFIED', 'REJECTED');
-- AlterEnum
ALTER TYPE "ContactActivityType" ADD VALUE 'PETITION_SIGNED';
-- AlterEnum
ALTER TYPE "ContactSource" ADD VALUE 'PETITION_SIGNER';
-- AlterTable
ALTER TABLE "site_settings" ADD COLUMN "enable_petitions" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "notify_admin_petition_milestone" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "petitions" (
"id" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"signatureGoal" INTEGER,
"showProgress" BOOLEAN NOT NULL DEFAULT true,
"showSignatureCount" BOOLEAN NOT NULL DEFAULT true,
"showSignerNames" BOOLEAN NOT NULL DEFAULT true,
"signatureCountOffset" INTEGER NOT NULL DEFAULT 0,
"requireName" BOOLEAN NOT NULL DEFAULT true,
"requireEmail" BOOLEAN NOT NULL DEFAULT true,
"requirePostalCode" BOOLEAN NOT NULL DEFAULT false,
"requirePhone" BOOLEAN NOT NULL DEFAULT false,
"allowComment" BOOLEAN NOT NULL DEFAULT true,
"commentLabel" TEXT,
"requireEmailConfirmation" BOOLEAN NOT NULL DEFAULT false,
"confirmationEmailSubject" TEXT,
"confirmationEmailBody" TEXT,
"coverPhoto" TEXT,
"callToAction" TEXT,
"thankYouMessage" TEXT,
"highlightPetition" BOOLEAN NOT NULL DEFAULT false,
"linkedCampaignId" TEXT,
"status" "PetitionStatus" NOT NULL DEFAULT 'DRAFT',
"isUserGenerated" BOOLEAN NOT NULL DEFAULT false,
"moderationStatus" "CampaignModerationStatus",
"rejectionReason" TEXT,
"moderationNotes" TEXT,
"createdByUserId" TEXT,
"createdByUserEmail" TEXT,
"createdByUserName" TEXT,
"reviewedByUserId" TEXT,
"reviewedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "petitions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "petition_signatures" (
"id" TEXT NOT NULL,
"petitionId" TEXT NOT NULL,
"signerName" TEXT,
"signerEmail" TEXT,
"signerPostalCode" TEXT,
"signerPhone" TEXT,
"signerComment" TEXT,
"isAnonymous" BOOLEAN NOT NULL DEFAULT false,
"displayName" TEXT,
"status" "PetitionSignatureStatus" NOT NULL DEFAULT 'UNVERIFIED',
"verificationToken" TEXT,
"verificationSentAt" TIMESTAMP(3),
"verifiedAt" TIMESTAMP(3),
"contactId" TEXT,
"signerIp" TEXT,
"geoCountry" TEXT,
"geoRegion" TEXT,
"geoCity" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "petition_signatures_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "petitions_slug_key" ON "petitions"("slug");
-- CreateIndex
CREATE INDEX "petitions_status_idx" ON "petitions"("status");
-- CreateIndex
CREATE INDEX "petitions_isUserGenerated_idx" ON "petitions"("isUserGenerated");
-- CreateIndex
CREATE INDEX "petitions_highlightPetition_idx" ON "petitions"("highlightPetition");
-- CreateIndex
CREATE INDEX "petitions_linkedCampaignId_idx" ON "petitions"("linkedCampaignId");
-- CreateIndex
CREATE UNIQUE INDEX "petition_signatures_verificationToken_key" ON "petition_signatures"("verificationToken");
-- CreateIndex
CREATE INDEX "petition_signatures_petitionId_idx" ON "petition_signatures"("petitionId");
-- CreateIndex
CREATE INDEX "petition_signatures_signerEmail_idx" ON "petition_signatures"("signerEmail");
-- CreateIndex
CREATE INDEX "petition_signatures_petitionId_status_idx" ON "petition_signatures"("petitionId", "status");
-- CreateIndex
CREATE INDEX "petition_signatures_contactId_idx" ON "petition_signatures"("contactId");
-- CreateIndex
CREATE UNIQUE INDEX "petition_signatures_petitionId_signerEmail_key" ON "petition_signatures"("petitionId", "signerEmail");
-- AddForeignKey
ALTER TABLE "petitions" ADD CONSTRAINT "petitions_linkedCampaignId_fkey" FOREIGN KEY ("linkedCampaignId") REFERENCES "campaigns"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "petitions" ADD CONSTRAINT "petitions_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "petitions" ADD CONSTRAINT "petitions_reviewedByUserId_fkey" FOREIGN KEY ("reviewedByUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "petition_signatures" ADD CONSTRAINT "petition_signatures_petitionId_fkey" FOREIGN KEY ("petitionId") REFERENCES "petitions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "petition_signatures" ADD CONSTRAINT "petition_signatures_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "contacts"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,100 @@
import { Router, Request, Response, NextFunction } from 'express';
import { petitionsService } from './petitions.service';
import { signPetitionSchema } from './petitions.schemas';
import { validate } from '../../../middleware/validate';
import { petitionSignRateLimit } from '../../../middleware/rate-limit';
const router = Router();
// GET /api/petitions/public — list active petitions
router.get(
'/public',
async (_req: Request, res: Response, next: NextFunction) => {
try {
const petitions = await petitionsService.findActivePetitions();
res.json(petitions);
} catch (err) { next(err); }
}
);
// GET /api/petitions/:slug/details — public petition data (ACTIVE only)
router.get(
'/:slug/details',
async (req: Request, res: Response, next: NextFunction) => {
try {
const slug = req.params.slug as string;
const petition = await petitionsService.findBySlugPublic(slug);
res.json(petition);
} catch (err) { next(err); }
}
);
// GET /api/petitions/:slug/signers — public signature list
router.get(
'/:slug/signers',
async (req: Request, res: Response, next: NextFunction) => {
try {
const slug = req.params.slug as string;
const page = parseInt(req.query.page as string) || 1;
const limit = Math.min(parseInt(req.query.limit as string) || 20, 50);
const result = await petitionsService.listSignaturesPublic(slug, page, limit);
res.json(result);
} catch (err) { next(err); }
}
);
// GET /api/petitions/:slug/public-stats — public signature counts + geo
router.get(
'/:slug/public-stats',
async (req: Request, res: Response, next: NextFunction) => {
try {
const slug = req.params.slug as string;
const petition = await petitionsService.findBySlugPublic(slug);
const stats = await petitionsService.getStats(petition.id);
res.json(stats);
} catch (err) { next(err); }
}
);
// POST /api/petitions/:slug/sign — sign petition
router.post(
'/:slug/sign',
petitionSignRateLimit,
validate(signPetitionSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const slug = req.params.slug as string;
const ip = (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() || req.ip || '';
const result = await petitionsService.signPetition(slug, req.body, ip);
res.status(result.alreadySigned ? 200 : 201).json(result);
} catch (err) { next(err); }
}
);
// Verification route
const verifyRouter = Router();
// GET /api/petitions/verify/:token — email verification
verifyRouter.get(
'/verify/:token',
async (req: Request, res: Response, next: NextFunction) => {
try {
const token = req.params.token as string;
const result = await petitionsService.verifySignature(token);
const title = result.alreadyVerified ? 'Already Confirmed' : 'Signature Confirmed';
const msg = result.alreadyVerified
? 'Your signature was already confirmed. Thank you for your support!'
: 'Your signature has been verified. Thank you for adding your voice!';
res.send(`<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>${title}</title>
<style>body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#0d1b2a;color:#e0e0e0}
.card{background:#1b2838;padding:2rem;border-radius:12px;text-align:center;max-width:400px;box-shadow:0 4px 20px rgba(0,0,0,.3)}
h1{color:#3498db;margin-bottom:.5rem}p{line-height:1.6;color:#a0a0a0}</style>
</head><body><div class="card"><h1>${title}</h1><p>${msg}</p></div></body></html>`);
} catch (err) { next(err); }
}
);
export { router as petitionsPublicRouter, verifyRouter as petitionVerifyRouter };

View File

@ -0,0 +1,203 @@
import { Router, Request, Response, NextFunction } from 'express';
import { petitionsService } from './petitions.service';
import {
createPetitionSchema, updatePetitionSchema, listPetitionsSchema,
listSignaturesSchema, updateSignatureStatusSchema,
moderatePetitionSchema, listModerationQueueSchema,
} from './petitions.schemas';
import { validate } from '../../../middleware/validate';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
import { INFLUENCE_ROLES } from '../../../utils/roles';
const router = Router();
router.use(authenticate);
router.use(requireRole(...INFLUENCE_ROLES));
// GET /api/petitions — list petitions
router.get(
'/',
validate(listPetitionsSchema, 'query'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await petitionsService.findAll(req.query as any, req.user!);
res.json(result);
} catch (err) { next(err); }
}
);
// GET /api/petitions/moderation/queue — moderation queue
router.get(
'/moderation/queue',
validate(listModerationQueueSchema, 'query'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await petitionsService.findModerationQueue(req.query as any);
res.json(result);
} catch (err) { next(err); }
}
);
// GET /api/petitions/moderation/stats — moderation stats
router.get(
'/moderation/stats',
async (_req: Request, res: Response, next: NextFunction) => {
try {
const stats = await petitionsService.getModerationStats();
res.json(stats);
} catch (err) { next(err); }
}
);
// PATCH /api/petitions/moderation/:id — moderate petition
router.patch(
'/moderation/:id',
validate(moderatePetitionSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const petition = await petitionsService.moderatePetition(id, req.body, req.user!.id);
res.json(petition);
} catch (err) { next(err); }
}
);
// GET /api/petitions/:id/admin — get single petition (admin)
router.get(
'/:id/admin',
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const petition = await petitionsService.findById(id);
res.json(petition);
} catch (err) { next(err); }
}
);
// POST /api/petitions — create petition
router.post(
'/',
validate(createPetitionSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const petition = await petitionsService.create(req.body, req.user!);
res.status(201).json(petition);
} catch (err) { next(err); }
}
);
// PUT /api/petitions/:id — update petition
router.put(
'/:id',
validate(updatePetitionSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const petition = await petitionsService.update(id, req.body);
res.json(petition);
} catch (err) { next(err); }
}
);
// DELETE /api/petitions/:id — delete petition
router.delete(
'/:id',
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
await petitionsService.delete(id);
res.status(204).send();
} catch (err) { next(err); }
}
);
// GET /api/petitions/:id/signatures — list all signatures (admin)
router.get(
'/:id/signatures',
validate(listSignaturesSchema, 'query'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const result = await petitionsService.listSignaturesAdmin(id, req.query as any);
res.json(result);
} catch (err) { next(err); }
}
);
// GET /api/petitions/:id/stats — signature stats (admin)
router.get(
'/:id/stats',
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const stats = await petitionsService.getStats(id);
res.json(stats);
} catch (err) { next(err); }
}
);
// PATCH /api/petitions/:id/signatures/:sigId/status — approve/reject signature
router.patch(
'/:id/signatures/:sigId/status',
validate(updateSignatureStatusSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const sigId = req.params.sigId as string;
const signature = await petitionsService.updateSignatureStatus(sigId, req.body);
res.json(signature);
} catch (err) { next(err); }
}
);
// POST /api/petitions/:id/signatures/bulk-status — bulk approve/reject
router.post(
'/:id/signatures/bulk-status',
async (req: Request, res: Response, next: NextFunction) => {
try {
const { ids, status } = req.body;
if (!Array.isArray(ids) || !status) {
res.status(400).json({ error: { message: 'ids (array) and status required', code: 'VALIDATION_ERROR' } });
return;
}
const result = await petitionsService.bulkUpdateSignatureStatus(ids, status);
res.json({ updated: result.count });
} catch (err) { next(err); }
}
);
// DELETE /api/petitions/:id/signatures/:sigId — delete signature
router.delete(
'/:id/signatures/:sigId',
async (req: Request, res: Response, next: NextFunction) => {
try {
const sigId = req.params.sigId as string;
await petitionsService.deleteSignature(sigId);
res.status(204).send();
} catch (err) { next(err); }
}
);
// GET /api/petitions/:id/export — export signatures (CSV/JSON)
router.get(
'/:id/export',
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const format = (req.query.format as string) === 'json' ? 'json' : 'csv';
const result = await petitionsService.exportSignatures(id, format);
if (format === 'json') {
res.setHeader('Content-Disposition', `attachment; filename="${result.filename}"`);
res.json(result.data);
} else {
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename="${result.filename}"`);
res.setHeader('Cache-Control', 'no-store');
res.send(result.data);
}
} catch (err) { next(err); }
}
);
export { router as petitionsAdminRouter };

View File

@ -0,0 +1,103 @@
import { z } from 'zod';
import { PetitionStatus, CampaignModerationStatus } from '@prisma/client';
export const createPetitionSchema = z.object({
title: z.string().min(1, 'Title is required').max(200),
description: z.string().max(5000).optional(),
signatureGoal: z.number().int().positive().nullable().optional(),
showProgress: z.boolean().optional().default(true),
showSignatureCount: z.boolean().optional().default(true),
showSignerNames: z.boolean().optional().default(true),
signatureCountOffset: z.number().int().min(0).optional().default(0),
requireName: z.boolean().optional().default(true),
requireEmail: z.boolean().optional().default(true),
requirePostalCode: z.boolean().optional().default(false),
requirePhone: z.boolean().optional().default(false),
allowComment: z.boolean().optional().default(true),
commentLabel: z.string().max(200).nullable().optional(),
requireEmailConfirmation: z.boolean().optional().default(false),
confirmationEmailSubject: z.string().max(200).nullable().optional(),
confirmationEmailBody: z.string().max(5000).nullable().optional(),
coverPhoto: z.string().url().max(500).nullable().optional(),
coverVideoId: z.number().int().positive().nullable().optional(),
callToAction: z.string().max(2000).nullable().optional(),
thankYouMessage: z.string().max(2000).nullable().optional(),
highlightPetition: z.boolean().optional().default(false),
linkedCampaignId: z.string().nullable().optional(),
status: z.nativeEnum(PetitionStatus).optional().default(PetitionStatus.DRAFT),
});
export const updatePetitionSchema = z.object({
title: z.string().min(1).max(200).optional(),
description: z.string().max(5000).nullable().optional(),
signatureGoal: z.number().int().positive().nullable().optional(),
showProgress: z.boolean().optional(),
showSignatureCount: z.boolean().optional(),
showSignerNames: z.boolean().optional(),
signatureCountOffset: z.number().int().min(0).optional(),
requireName: z.boolean().optional(),
requireEmail: z.boolean().optional(),
requirePostalCode: z.boolean().optional(),
requirePhone: z.boolean().optional(),
allowComment: z.boolean().optional(),
commentLabel: z.string().max(200).nullable().optional(),
requireEmailConfirmation: z.boolean().optional(),
confirmationEmailSubject: z.string().max(200).nullable().optional(),
confirmationEmailBody: z.string().max(5000).nullable().optional(),
coverPhoto: z.string().url().max(500).nullable().optional(),
coverVideoId: z.number().int().positive().nullable().optional(),
callToAction: z.string().max(2000).nullable().optional(),
thankYouMessage: z.string().max(2000).nullable().optional(),
highlightPetition: z.boolean().optional(),
linkedCampaignId: z.string().nullable().optional(),
status: z.nativeEnum(PetitionStatus).optional(),
});
export const listPetitionsSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
search: z.string().optional(),
status: z.nativeEnum(PetitionStatus).optional(),
});
export const signPetitionSchema = z.object({
signerName: z.string().max(200).optional(),
signerEmail: z.string().email().max(255).optional(),
signerPostalCode: z.string().max(20).optional(),
signerPhone: z.string().max(30).optional(),
signerComment: z.string().max(2000).optional(),
isAnonymous: z.boolean().optional().default(false),
});
export const listSignaturesSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
search: z.string().optional(),
status: z.string().optional(),
});
export const updateSignatureStatusSchema = z.object({
status: z.enum(['VERIFIED', 'REJECTED']),
});
export const moderatePetitionSchema = z.object({
action: z.enum(['approve', 'reject', 'request_changes']),
reason: z.string().max(2000).optional(),
notes: z.string().max(2000).optional(),
});
export const listModerationQueueSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
search: z.string().optional(),
moderationStatus: z.nativeEnum(CampaignModerationStatus).optional(),
});
export type CreatePetitionInput = z.infer<typeof createPetitionSchema>;
export type UpdatePetitionInput = z.infer<typeof updatePetitionSchema>;
export type ListPetitionsInput = z.infer<typeof listPetitionsSchema>;
export type SignPetitionInput = z.infer<typeof signPetitionSchema>;
export type ListSignaturesInput = z.infer<typeof listSignaturesSchema>;
export type UpdateSignatureStatusInput = z.infer<typeof updateSignatureStatusSchema>;
export type ModeratePetitionInput = z.infer<typeof moderatePetitionSchema>;
export type ListModerationQueueInput = z.infer<typeof listModerationQueueSchema>;

View File

@ -0,0 +1,777 @@
import crypto from 'crypto';
import { Prisma, UserRole, CampaignModerationStatus, PetitionSignatureStatus } from '@prisma/client';
import { prisma } from '../../../config/database';
import { AppError } from '../../../middleware/error-handler';
import { hasAnyRole, ADMIN_ROLES } from '../../../utils/roles';
import { geoipService } from '../../../services/geoip.service';
import { emailService } from '../../../services/email.service';
import { logger } from '../../../utils/logger';
import type {
CreatePetitionInput, UpdatePetitionInput, ListPetitionsInput,
SignPetitionInput, ListSignaturesInput, UpdateSignatureStatusInput,
ModeratePetitionInput, ListModerationQueueInput,
} from './petitions.schemas';
function escapeHtml(unsafe: string): string {
return unsafe
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function generateSlug(title: string): string {
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80);
}
async function resolveSlugCollision(slug: string, excludeId?: string): Promise<string> {
let candidate = slug;
let suffix = 2;
while (true) {
const existing = await prisma.petition.findUnique({
where: { slug: candidate },
select: { id: true },
});
if (!existing || (excludeId && existing.id === excludeId)) {
return candidate;
}
candidate = `${slug}-${suffix}`;
suffix++;
}
}
function hashToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex');
}
const petitionSelect = {
id: true,
slug: true,
title: true,
description: true,
signatureGoal: true,
showProgress: true,
showSignatureCount: true,
showSignerNames: true,
signatureCountOffset: true,
requireName: true,
requireEmail: true,
requirePostalCode: true,
requirePhone: true,
allowComment: true,
commentLabel: true,
requireEmailConfirmation: true,
coverPhoto: true,
coverVideoId: true,
callToAction: true,
thankYouMessage: true,
highlightPetition: true,
linkedCampaignId: true,
status: true,
isUserGenerated: true,
moderationStatus: true,
rejectionReason: true,
moderationNotes: true,
createdByUserId: true,
createdByUserEmail: true,
createdByUserName: true,
reviewedByUserId: true,
reviewedAt: true,
createdAt: true,
updatedAt: true,
_count: {
select: { signatures: true },
},
} satisfies Prisma.PetitionSelect;
const publicPetitionSelect = {
id: true,
slug: true,
title: true,
description: true,
signatureGoal: true,
showProgress: true,
showSignatureCount: true,
showSignerNames: true,
signatureCountOffset: true,
requireName: true,
requireEmail: true,
requirePostalCode: true,
requirePhone: true,
allowComment: true,
commentLabel: true,
requireEmailConfirmation: true,
coverPhoto: true,
coverVideoId: true,
callToAction: true,
thankYouMessage: true,
highlightPetition: true,
linkedCampaignId: true,
linkedCampaign: {
select: {
id: true,
slug: true,
title: true,
description: true,
status: true,
},
},
status: true,
createdByUserName: true,
createdAt: true,
_count: {
select: {
signatures: {
where: { status: { in: ['VERIFIED', 'UNVERIFIED'] } },
},
},
},
} satisfies Prisma.PetitionSelect;
interface AuthUser {
id: string;
email: string;
role: UserRole;
}
const MILESTONE_THRESHOLDS = [10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 25000, 50000, 100000];
export const petitionsService = {
// ─── Admin CRUD ──────────────────────────────────────────────────────
async findAll(filters: ListPetitionsInput, user?: AuthUser) {
const { page, limit, search, status } = filters;
const skip = (page - 1) * limit;
const where: Prisma.PetitionWhereInput = {};
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
];
}
if (status) where.status = status;
// Non-admin users only see their own petitions
if (user && !hasAnyRole(user, ADMIN_ROLES)) {
where.createdByUserId = user.id;
}
const [petitions, total] = await Promise.all([
prisma.petition.findMany({
where,
select: petitionSelect,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
}),
prisma.petition.count({ where }),
]);
return {
petitions,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
};
},
async findById(id: string) {
const petition = await prisma.petition.findUnique({
where: { id },
select: petitionSelect,
});
if (!petition) throw new AppError(404, 'Petition not found', 'PETITION_NOT_FOUND');
return petition;
},
async create(data: CreatePetitionInput, user: AuthUser) {
const slug = await resolveSlugCollision(generateSlug(data.title));
return prisma.petition.create({
data: {
...data,
slug,
createdByUserId: user.id,
createdByUserEmail: user.email,
createdByUserName: (user as any).name || user.email,
},
select: petitionSelect,
});
},
async update(id: string, data: UpdatePetitionInput) {
const existing = await prisma.petition.findUnique({
where: { id },
select: { id: true, slug: true, title: true },
});
if (!existing) throw new AppError(404, 'Petition not found', 'PETITION_NOT_FOUND');
// Regenerate slug if title changed
let slug: string | undefined;
if (data.title && data.title !== existing.title) {
slug = await resolveSlugCollision(generateSlug(data.title), id);
}
return prisma.petition.update({
where: { id },
data: {
...data,
...(slug && { slug }),
},
select: petitionSelect,
});
},
async delete(id: string) {
const existing = await prisma.petition.findUnique({
where: { id },
select: { id: true },
});
if (!existing) throw new AppError(404, 'Petition not found', 'PETITION_NOT_FOUND');
await prisma.petition.delete({ where: { id } });
},
// ─── Public Routes ───────────────────────────────────────────────────
async findActivePetitions() {
return prisma.petition.findMany({
where: {
status: 'ACTIVE',
OR: [
{ isUserGenerated: false },
{ isUserGenerated: true, moderationStatus: 'APPROVED' },
],
},
select: publicPetitionSelect,
orderBy: [
{ highlightPetition: 'desc' },
{ createdAt: 'desc' },
],
});
},
async findBySlugPublic(slug: string) {
const petition = await prisma.petition.findFirst({
where: {
slug,
status: 'ACTIVE',
OR: [
{ isUserGenerated: false },
{ isUserGenerated: true, moderationStatus: 'APPROVED' },
],
},
select: publicPetitionSelect,
});
if (!petition) throw new AppError(404, 'Petition not found', 'PETITION_NOT_FOUND');
return petition;
},
async signPetition(slug: string, data: SignPetitionInput, ip: string) {
const petition = await prisma.petition.findFirst({
where: { slug, status: 'ACTIVE' },
select: {
id: true,
slug: true,
title: true,
requireName: true,
requireEmail: true,
requirePostalCode: true,
requirePhone: true,
requireEmailConfirmation: true,
confirmationEmailSubject: true,
confirmationEmailBody: true,
signatureCountOffset: true,
},
});
if (!petition) throw new AppError(404, 'Petition not found or not active', 'PETITION_NOT_FOUND');
// Validate required fields based on petition config
if (petition.requireName && !data.signerName) {
throw new AppError(400, 'Name is required', 'VALIDATION_ERROR');
}
if (petition.requireEmail && !data.signerEmail) {
throw new AppError(400, 'Email is required', 'VALIDATION_ERROR');
}
if (petition.requirePostalCode && !data.signerPostalCode) {
throw new AppError(400, 'Postal code is required', 'VALIDATION_ERROR');
}
if (petition.requirePhone && !data.signerPhone) {
throw new AppError(400, 'Phone number is required', 'VALIDATION_ERROR');
}
// Deduplicate by email (same response for duplicates to prevent email enumeration)
if (data.signerEmail) {
const existing = await prisma.petitionSignature.findFirst({
where: { petitionId: petition.id, signerEmail: data.signerEmail.toLowerCase() },
select: { id: true },
});
if (existing) {
return { id: existing.id, status: 'UNVERIFIED' as const, verificationSent: false, alreadySigned: true };
}
}
// GeoIP lookup
const geo = await geoipService.lookup(ip);
// Compute display name
let displayName: string | null = null;
if (data.isAnonymous) {
displayName = 'Anonymous';
} else if (data.signerName) {
displayName = escapeHtml(data.signerName);
}
// Verification token handling
let verificationToken: string | null = null;
let verificationTokenHash: string | null = null;
const needsVerification = petition.requireEmailConfirmation && data.signerEmail;
if (needsVerification) {
verificationToken = crypto.randomBytes(32).toString('hex');
verificationTokenHash = hashToken(verificationToken);
}
const signatureStatus: PetitionSignatureStatus = needsVerification ? 'PENDING_VERIFICATION' : 'UNVERIFIED';
// Create signature
const signature = await prisma.petitionSignature.create({
data: {
petitionId: petition.id,
signerName: data.signerName ? escapeHtml(data.signerName) : null,
signerEmail: data.signerEmail?.toLowerCase() || null,
signerPostalCode: data.signerPostalCode || null,
signerPhone: data.signerPhone || null,
signerComment: data.signerComment ? escapeHtml(data.signerComment) : null,
isAnonymous: data.isAnonymous ?? false,
displayName,
status: signatureStatus,
verificationToken: verificationTokenHash,
verificationSentAt: needsVerification ? new Date() : null,
signerIp: ip,
geoCountry: geo?.country || null,
geoRegion: geo?.region || null,
geoCity: geo?.city || null,
},
});
// CRM contact upsert
if (data.signerEmail) {
try {
let contact = await prisma.contact.findFirst({
where: { email: data.signerEmail.toLowerCase() },
select: { id: true },
});
if (!contact) {
contact = await prisma.contact.create({
data: {
displayName: data.signerName ? escapeHtml(data.signerName) : data.signerEmail,
email: data.signerEmail.toLowerCase(),
primarySource: 'PETITION_SIGNER',
},
});
}
// Link signature to contact
await prisma.petitionSignature.update({
where: { id: signature.id },
data: { contactId: contact.id },
});
// Record activity
await prisma.contactActivity.create({
data: {
contactId: contact.id,
type: 'PETITION_SIGNED',
title: `Signed petition: ${petition.title}`,
metadata: { petitionId: petition.id, petitionSlug: petition.slug } as unknown as Prisma.InputJsonValue,
},
});
} catch (err) {
logger.warn('Failed to upsert CRM contact for petition signature', { error: (err as Error).message });
}
}
// Send confirmation email if required
let verificationSent = false;
if (needsVerification && verificationToken && data.signerEmail) {
try {
const verificationUrl = `${process.env.APP_URL || 'http://localhost:4000'}/api/petitions/verify/${verificationToken}`;
await emailService.sendEmail({
to: data.signerEmail,
subject: petition.confirmationEmailSubject || `Confirm your signature — ${petition.title}`,
html: petition.confirmationEmailBody
? petition.confirmationEmailBody
.replace(/\{\{NAME\}\}/g, data.signerName || 'Supporter')
.replace(/\{\{PETITION_TITLE\}\}/g, escapeHtml(petition.title))
.replace(/\{\{VERIFICATION_URL\}\}/g, verificationUrl)
: `<p>Hi ${escapeHtml(data.signerName || 'there')},</p>
<p>Please confirm your signature on "<strong>${escapeHtml(petition.title)}</strong>" by clicking the link below:</p>
<p><a href="${verificationUrl}">Confirm my signature</a></p>
<p>If you did not sign this petition, you can safely ignore this email.</p>`,
text: `Confirm your signature on "${petition.title}": ${verificationUrl}`,
});
verificationSent = true;
} catch (err) {
logger.warn('Failed to send petition verification email', { error: (err as Error).message });
}
}
// Check milestone thresholds
try {
const totalCount = await prisma.petitionSignature.count({
where: {
petitionId: petition.id,
status: { in: ['VERIFIED', 'UNVERIFIED'] },
},
});
const displayCount = totalCount + petition.signatureCountOffset;
for (const threshold of MILESTONE_THRESHOLDS) {
if (displayCount >= threshold && (displayCount - 1) < threshold) {
logger.info(`Petition "${petition.title}" reached ${threshold} signatures`);
break;
}
}
} catch (err) {
logger.warn('Failed to check petition milestones', { error: (err as Error).message });
}
return {
id: signature.id,
status: signatureStatus,
verificationSent,
alreadySigned: false,
};
},
async verifySignature(token: string) {
const tokenHash = hashToken(token);
const signature = await prisma.petitionSignature.findFirst({
where: { verificationToken: tokenHash },
select: { id: true, status: true, petitionId: true, verificationSentAt: true },
});
if (!signature) throw new AppError(404, 'Invalid or expired verification link', 'INVALID_TOKEN');
// Check 30-day expiry
if (signature.verificationSentAt) {
const expiryMs = 30 * 24 * 60 * 60 * 1000;
if (Date.now() - signature.verificationSentAt.getTime() > expiryMs) {
throw new AppError(410, 'Verification link has expired', 'TOKEN_EXPIRED');
}
}
if (signature.status === 'VERIFIED') {
return { alreadyVerified: true };
}
await prisma.petitionSignature.update({
where: { id: signature.id },
data: { status: 'VERIFIED', verifiedAt: new Date() },
});
return { alreadyVerified: false };
},
// ─── Signatures Admin ────────────────────────────────────────────────
async listSignaturesAdmin(petitionId: string, filters: ListSignaturesInput) {
const { page, limit, search, status } = filters;
const skip = (page - 1) * limit;
const where: Prisma.PetitionSignatureWhereInput = { petitionId };
if (search) {
where.OR = [
{ signerName: { contains: search, mode: 'insensitive' } },
{ signerEmail: { contains: search, mode: 'insensitive' } },
{ signerPostalCode: { contains: search, mode: 'insensitive' } },
];
}
if (status) where.status = status as PetitionSignatureStatus;
const [signatures, total] = await Promise.all([
prisma.petitionSignature.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: limit,
}),
prisma.petitionSignature.count({ where }),
]);
return {
signatures,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
};
},
async listSignaturesPublic(slug: string, page: number = 1, limit: number = 20) {
const petition = await prisma.petition.findFirst({
where: { slug, status: 'ACTIVE' },
select: { id: true, showSignerNames: true },
});
if (!petition) throw new AppError(404, 'Petition not found', 'PETITION_NOT_FOUND');
const where: Prisma.PetitionSignatureWhereInput = {
petitionId: petition.id,
status: { in: ['VERIFIED', 'UNVERIFIED'] },
};
const [signatures, total] = await Promise.all([
prisma.petitionSignature.findMany({
where,
select: {
id: true,
displayName: petition.showSignerNames,
signerComment: true,
isAnonymous: true,
geoCity: true,
geoCountry: true,
createdAt: true,
},
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
prisma.petitionSignature.count({ where }),
]);
return {
signatures: signatures.map(s => ({
...s,
displayName: petition.showSignerNames ? s.displayName : null,
})),
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
};
},
async updateSignatureStatus(id: string, data: UpdateSignatureStatusInput) {
const existing = await prisma.petitionSignature.findUnique({
where: { id },
select: { id: true },
});
if (!existing) throw new AppError(404, 'Signature not found', 'SIGNATURE_NOT_FOUND');
return prisma.petitionSignature.update({
where: { id },
data: { status: data.status as PetitionSignatureStatus },
});
},
async deleteSignature(id: string) {
const existing = await prisma.petitionSignature.findUnique({
where: { id },
select: { id: true },
});
if (!existing) throw new AppError(404, 'Signature not found', 'SIGNATURE_NOT_FOUND');
await prisma.petitionSignature.delete({ where: { id } });
},
async bulkUpdateSignatureStatus(ids: string[], status: PetitionSignatureStatus) {
return prisma.petitionSignature.updateMany({
where: { id: { in: ids } },
data: { status },
});
},
// ─── Stats ───────────────────────────────────────────────────────────
async getStats(petitionId: string) {
const petition = await prisma.petition.findUnique({
where: { id: petitionId },
select: { id: true, signatureGoal: true, signatureCountOffset: true },
});
if (!petition) throw new AppError(404, 'Petition not found', 'PETITION_NOT_FOUND');
const countWhere = {
petitionId,
status: { in: ['VERIFIED' as const, 'UNVERIFIED' as const] },
};
const [total, byCountry, byRegion, recentSigners] = await Promise.all([
prisma.petitionSignature.count({ where: countWhere }),
prisma.petitionSignature.groupBy({
by: ['geoCountry'],
where: { ...countWhere, geoCountry: { not: null } },
_count: true,
orderBy: { _count: { geoCountry: 'desc' } },
take: 20,
}),
prisma.petitionSignature.groupBy({
by: ['geoRegion'],
where: { ...countWhere, geoRegion: { not: null } },
_count: true,
orderBy: { _count: { geoRegion: 'desc' } },
take: 20,
}),
prisma.petitionSignature.findMany({
where: countWhere,
select: { displayName: true, geoCity: true, geoCountry: true, createdAt: true },
orderBy: { createdAt: 'desc' },
take: 10,
}),
]);
const displayTotal = total + petition.signatureCountOffset;
return {
total: displayTotal,
verified: total,
goal: petition.signatureGoal,
percentComplete: petition.signatureGoal
? Math.min(100, Math.round((displayTotal / petition.signatureGoal) * 100))
: null,
byCountry: Object.fromEntries(byCountry.map(c => [c.geoCountry, c._count])),
byRegion: Object.fromEntries(byRegion.map(r => [r.geoRegion, r._count])),
recentSigners,
};
},
// ─── Export ──────────────────────────────────────────────────────────
async exportSignatures(petitionId: string, format: 'csv' | 'json') {
const petition = await prisma.petition.findUnique({
where: { id: petitionId },
select: { slug: true },
});
if (!petition) throw new AppError(404, 'Petition not found', 'PETITION_NOT_FOUND');
const signatures = await prisma.petitionSignature.findMany({
where: { petitionId },
orderBy: { createdAt: 'desc' },
});
if (format === 'json') {
return { data: signatures, filename: `petition-${petition.slug}-signatures.json` };
}
// CSV format
const headers = ['Name', 'Email', 'Postal Code', 'Phone', 'Comment', 'Anonymous', 'Country', 'Region', 'City', 'Status', 'Date'];
const csvRows = [headers.join(',')];
for (const sig of signatures) {
const row = [
sig.signerName || '',
sig.signerEmail || '',
sig.signerPostalCode || '',
sig.signerPhone || '',
(sig.signerComment || '').replace(/"/g, '""'),
sig.isAnonymous ? 'Yes' : 'No',
sig.geoCountry || '',
sig.geoRegion || '',
sig.geoCity || '',
sig.status,
sig.createdAt.toISOString(),
].map(v => `"${v}"`);
csvRows.push(row.join(','));
}
return { data: csvRows.join('\n'), filename: `petition-${petition.slug}-signatures.csv` };
},
// ─── Moderation ──────────────────────────────────────────────────────
async findModerationQueue(filters: ListModerationQueueInput) {
const { page, limit, search, moderationStatus } = filters;
const skip = (page - 1) * limit;
const where: Prisma.PetitionWhereInput = { isUserGenerated: true };
if (moderationStatus) {
where.moderationStatus = moderationStatus;
} else {
where.moderationStatus = 'PENDING_REVIEW';
}
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ createdByUserName: { contains: search, mode: 'insensitive' } },
];
}
const [petitions, total] = await Promise.all([
prisma.petition.findMany({
where,
select: petitionSelect,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
}),
prisma.petition.count({ where }),
]);
return {
petitions,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
};
},
async getModerationStats() {
const [pending, approved, rejected, changesRequested] = await Promise.all([
prisma.petition.count({ where: { isUserGenerated: true, moderationStatus: 'PENDING_REVIEW' } }),
prisma.petition.count({ where: { isUserGenerated: true, moderationStatus: 'APPROVED' } }),
prisma.petition.count({ where: { isUserGenerated: true, moderationStatus: 'REJECTED' } }),
prisma.petition.count({ where: { isUserGenerated: true, moderationStatus: 'CHANGES_REQUESTED' } }),
]);
return { pending, approved, rejected, changesRequested };
},
async moderatePetition(id: string, data: ModeratePetitionInput, reviewerId: string) {
const petition = await prisma.petition.findUnique({
where: { id },
select: { id: true, isUserGenerated: true },
});
if (!petition) throw new AppError(404, 'Petition not found', 'PETITION_NOT_FOUND');
if (!petition.isUserGenerated) throw new AppError(400, 'Only user-generated petitions can be moderated', 'NOT_USER_GENERATED');
const statusMap: Record<string, CampaignModerationStatus> = {
approve: 'APPROVED',
reject: 'REJECTED',
request_changes: 'CHANGES_REQUESTED',
};
return prisma.petition.update({
where: { id },
data: {
moderationStatus: statusMap[data.action],
status: data.action === 'approve' ? 'ACTIVE' : undefined,
rejectionReason: data.reason || null,
moderationNotes: data.notes || null,
reviewedByUserId: reviewerId,
reviewedAt: new Date(),
},
select: petitionSelect,
});
},
};

View File

@ -0,0 +1,343 @@
# Competitive Analysis: Changemaker Lite vs. Campaign Tech Landscape
*Generated: April 2, 2026*
## Executive Summary
The political campaign software market is projected at **$321M (2025) → $535M by 2031** (8.9% CAGR). The landscape is highly fragmented, almost entirely partisan, and SaaS-only. Changemaker Lite occupies a unique position as an **open-source, self-hosted, politically neutral, all-in-one platform** — a position with essentially no direct competitors.
---
## 1. Legacy / Enterprise Campaign Platforms
### NationBuilder
- **Key Features:** All-in-one CRM + website builder + fundraising + email + petitions + field tools + texting. 30 pre-built action page templates. Geographic voter segmentation. Social media profile linking.
- **Pricing:** Starter $34/mo, Pro $160/mo, Enterprise custom. 14-day free trial. Scales with database size.
- **Target Market:** 9,000+ customers in 112 countries. Politically neutral.
- **Unique:** True international coverage. Third-party integration marketplace (L2, Ecanvasser, Qomon).
- **Weakness:** Expensive at scale. No self-hosting.
### NGP VAN / VoteBuilder / MiniVAN (Bonterra)
- **Key Features:** VoteBuilder (voter database + targeting), MiniVAN (mobile canvassing), NGP (fundraising + compliance), Digital 8 (digital tools).
- **Pricing:** Starting ~$45/mo. Custom quotes. VAN access often provided through state party organizations.
- **Target Market:** **Exclusively Democratic/progressive.** Over $10B raised and 1.4B voter contacts tracked (2022 cycle).
- **Unique:** Deepest voter file integration on the Democratic side. MiniVAN Manager real-time canvasser tracking. De facto standard for Democratic campaigns.
- **Weakness:** Exclusively partisan. No self-hosting. Owned by Apax Partners (PE).
### Bonterra / EveryAction
- **Key Features:** AI-powered nonprofit CRM. Coordinated email, text, social campaigns. "Bonterra Que" AI assistant. 20,000+ organizations, 25M profiled supporters, $28B annual giving.
- **Pricing:** Custom quotes only. Tiered by org size.
- **Target Market:** Progressive nonprofits and advocacy organizations.
- **Unique:** AI-powered "Que" strategic consultant. Massive cross-org data network.
- **Weakness:** Expensive. Progressive-only. PE ownership lock-in concerns.
### Aristotle
- **Key Features:** Campaign Manager 3-in-1: compliance + fundraising + reporting. Dynamic donor profiles. Automatic FEC error checking. AI contributor data enrichment.
- **Pricing:** $750-$1,200+/month for House and statewide campaigns.
- **Target Market:** Nonpartisan. Focus on compliance-heavy operations since 1983.
- **Unique:** Three-in-one compliance/fundraising/reporting. 24/7 live support.
- **Weakness:** Expensive. Narrow compliance/fundraising focus.
### i360
- **Key Features:** Database of 290M+ consumers/voters. ML predictive models updated daily. Integrated Walk, Call, Text apps. Digital audience segments.
- **Pricing:** Below-market rates (Koch network subsidized). Custom quotes.
- **Target Market:** **Exclusively conservative/Republican.**
- **Unique:** Largest conservative voter database. Daily ML model updates. 200K voter conversations/month for data refresh.
- **Weakness:** Conservative-only. Koch-affiliated. Vendor lock-in by design.
### PDI (Political Data Intelligence)
- **Key Features:** Campaign Center with voter data. Voter mapping, canvassing, texting, phone banking, emailing. Universe creation by geography/demographics/vote history.
- **Pricing:** Custom quotes. Primarily California-focused.
- **Target Market:** California Democratic campaigns primarily.
- **Unique:** 40 years proprietary voter file. Most accurate ethnicity/race identification. Real-time ballot/early voting updates.
- **Weakness:** Geographic focus. Democratic-leaning.
### Ecanvasser
- **Key Features:** Walk and Go mobile apps (real-time sync, offline support). Route planning (200 stops/route). Real-time analytics. Helpdesk follow-up. NationBuilder/L2/Salesforce integrations.
- **Pricing:** Starter $149/mo, Standard $299/mo, Pro $599/mo.
- **Target Market:** 200,000+ campaigners in 70+ countries. Politically neutral.
- **Unique:** Unlimited volunteer canvassers. Knock-to-knock route optimization. Offline-first mobile app.
- **Weakness:** Canvassing-focused only. Monthly fees add up.
---
## 2. Modern / Startup Campaign Tools
### Reach
- **Key Features:** Built by AOC's original campaign. Relational organizing. Direct voter search and canvassing. Voter registration pipelines. Social media content library. Gamification with leaderboards.
- **Pricing:** Free for progressive campaigns.
- **Target Market:** Progressive/Democratic. Grassroots and insurgent campaigns.
- **Unique:** Best-in-class relational organizing with gamification. Born from an actual insurgent campaign.
- **Weakness:** Progressive-only. Relational/field only.
### Impactive (formerly Outvote, now ActBlue-owned)
- **Key Features:** P2P texting with 10DLC. Predictive dialer and patch-through calling. Relational organizing. Voter registration. Open canvassing.
- **Pricing:** Not public (part of ActBlue ecosystem since Sept 2025).
- **Target Market:** **Exclusively Democratic.** 2,000+ campaigns since 2017.
- **Unique:** Part of ActBlue's "Campaign in a Box" vision. Research: 8pp increase in turnout from friend-to-friend outreach.
- **Weakness:** Democratic-only post-acquisition.
### Mobilize
- **Key Features:** Event creation (single/multi-shift, recurring). Automated email/text for signups. Post-event surveys. Real-time dashboards. Network cross-promotion. Petition/pledge forms.
- **Pricing:** Free tier available. Custom pricing for premium.
- **Target Market:** Progressive organizations, Democratic campaigns.
- **Unique:** Network effect — 38% more shifts from the Mobilize network. Doubled signup rates, 30% fewer no-shows.
- **Weakness:** Progressive-only. Event/volunteer focused.
### Qomon
- **Key Features:** Action CRM + mobile canvassing. Door-to-door, phone banking, P2P. Surveys, petitions, events. Subspaces for local teams. White-labeling.
- **Pricing:** Starter $39/mo (1K contacts), Premium $99/mo (5K contacts), Organization 50K+.
- **Target Market:** 1,500+ organizations in 70 countries. Politically neutral.
- **Unique:** Contact-based pricing (unlimited users). Subspace architecture. International, nonpartisan.
- **Weakness:** Smaller ecosystem than NationBuilder.
### Campaign Nucleus
- **Key Features:** AI-driven CRM. Email marketing. "War Room" with real-time sentiment analysis. Event management. Smart content assistance.
- **Pricing:** Not public.
- **Target Market:** **Conservative/Republican.**
- **Unique:** AI-powered War Room for content sentiment analysis. First AI-driven conservative CRM.
- **Weakness:** Conservative-only. Newer platform.
---
## 3. CRM / Organizing Platforms
### Action Network
- **Key Features:** Petitions, fundraising, events, email campaigns, social media. Marketing automation. Action pages.
- **Pricing:** Free tier. Movement: $1.25 per 1K emails/mo ($15/mo min). Actions Only: $7.50 per 1K actions.
- **Target Market:** **Exclusively progressive.** Grassroots activists.
- **Unique:** Extremely low cost. Purpose-built for grassroots organizing.
- **Weakness:** Progressive-only. Limited advanced features.
### CiviCRM (Open Source)
- **Key Features:** Contact management, fundraising, grant tracking, event planning, membership, advocacy, case management, email/SMS. WordPress/Drupal/Joomla integration.
- **Pricing:** **Free and open source.** Self-hosted.
- **Target Market:** Nonprofits, civic organizations, political campaigns worldwide.
- **Unique:** **Self-hosted, own your data.** Integrates with major CMS platforms. Large community.
- **Weakness:** PHP-based (aging stack). Dated UI. No canvassing app, media management, or collaboration tools.
### Spoke (Open Source)
- **Key Features:** Web-browser P2P texting. Twilio for SMS/MMS. Auth0 authentication. Volunteer-driven mass texting.
- **Pricing:** **Free and open source.** Pay only Twilio costs (~$0.0075/SMS).
- **Target Market:** Progressive campaigns with technical capacity. MoveOn-maintained.
- **Unique:** Lowest-cost P2P texting. Full infrastructure control.
- **Weakness:** Developer required. Community support only. Progressive origin.
---
## 4. Voter Contact Tools
### GetThru (ThruText + ThruTalk)
- **Key Features:** ThruText: 200+ msgs/min, phone validation. ThruTalk: 250-300 dials/hr. VAN integration. MMS support.
- **Pricing:** ThruText: 3.5¢/outgoing SMS, volume discounts to 1.5¢. ThruTalk: pay-per-dial. No subscription.
- **Target Market:** Democratic/progressive.
- **Unique:** Deep VAN integration. Pure pay-per-use model.
### Scale to Win
- **Key Features:** Text banking (2x faster). Shortcode, 10DLC, toll-free. Dialer with voicemail drops. ActBlue list upload with ROI tracking.
- **Pricing:** 1.5¢/outbound SMS. 4¢/outbound call. Pay-as-you-go.
- **Target Market:** Democratic campaigns.
- **Unique:** Direct ActBlue integration with donation ROI tracking. Automatic voicemail drops.
### CallHub
- **Key Features:** Predictive/Power/Auto Dialer. P2P Texting, Text Broadcasts. AI Smart Insights (sentiment). Dynamic Caller ID, Spam Shield, SHAKEN/STIR.
- **Pricing:** Pay-as-you-go. Calls: $0.045/dial. SMS: $0.019/segment. SOC 2, ISO 27001, GDPR, TCPA compliant.
- **Target Market:** Politically neutral. International.
- **Unique:** AI call sentiment analysis. SHAKEN/STIR compliance. Multi-channel workflow automation. Nonpartisan.
### RumbleUp
- **Key Features:** SMS, MMS, Video Text at scale. Text-to-donate (Anedot integration). 60-second video in texts. Advanced CRM segmentation.
- **Pricing:** Not public. Premium support for all clients.
- **Target Market:** **Conservative/Republican.** 3,500+ campaigns.
- **Unique:** Video texting. Text-to-donate with Anedot. Equal premium support.
### Hustle — DEFUNCT (2026)
- Acquired by Civic Shout. No longer operational. Former dominant progressive P2P platform.
---
## 5. Fundraising Platforms
### ActBlue
- **Key Features:** Donation processing. Express one-click giving. Slates (split donations). Acquired Impactive for organizing.
- **Pricing:** 3.95% per transaction.
- **Target Market:** **Exclusively Democratic/progressive.** $3.8B raised in 2024 cycle.
- **Unique:** Express one-click across entire Democratic ecosystem. Building "Campaign in a Box."
### WinRed
- **Key Features:** Donation processing. Slates. Party-controlled (unlike ActBlue's independence).
- **Pricing:** 3.95% per transaction.
- **Target Market:** **Exclusively Republican.** $1.8B from 4.5M donors in 2024.
- **Unique:** Direct RNC alignment. Growing rapidly since 2019.
### Anedot
- **Key Features:** No-code donation pages. Recurring giving. Donor-covered fees. Text-to-give, QR codes. NationBuilder/HubSpot/Salesforce integrations.
- **Pricing:** 4.0% + $0.30/txn (political). 3.3% + $0.30 (nonprofits). No monthly fees.
- **Target Market:** Conservative/Republican campaigns, churches, nonprofits.
- **Unique:** Zero monthly fees. RumbleUp text-to-donate integration.
### Give Lively
- **Key Features:** Fundraising pages, text-to-donate, P2P, event ticketing. Cards, bank transfers, PayPal, Venmo, DAFs.
- **Pricing:** **$0 platform fees.** Philanthropically funded. Only standard payment processor fees.
- **Target Market:** 501(c)(3) nonprofits only (not political campaigns).
- **Unique:** Truly free. $120M saved by nonprofits on $1.25B in donations.
- **Weakness:** 501(c)(3) only.
---
## 6. Data & Targeting Vendors
### L2 Political
- **Features:** 217M+ voter records. Hundreds of attributes. DataMapping platform (geo-spatial, turf cutting, list export).
- **Pricing:** ~$0.25/voter record (phones + commercial data). ~$0.07/verified email.
- **Market:** Nonpartisan. Campaigns, parties, pollsters, media.
### TargetSmart
- **Features:** Most accurate voter file from all 50 states + DC. Consumer data, vote history, model scores.
- **Pricing:** Custom quotes.
- **Market:** Democratic/progressive. Founded 2004.
### Catalist
- **Features:** Longest-running voter file outside major parties (15+ years). National unified file. Predictive modeling.
- **Pricing:** Custom.
- **Market:** **Exclusively progressive.** Only unionized progressive data utility.
---
## 7. Compliance Tools
### ISPolitical
- **Features:** Automated FEC, state, local compliance reporting. All major payment processor integrations. Error-free filing.
- **Pricing:** Custom quotes.
- **Market:** Nonpartisan. Since 1999.
### CallTime.AI
- **Features:** ML donor scoring. AI call lists with suggested asks and talking points. Rich donor profiles. In-app text/email/SMS.
- **Pricing:** Subscription-based.
- **Market:** Nonpartisan.
- **Unique:** AI donor scoring and call prioritization.
---
## 8. Open Source & Emerging Alternatives
| Platform | Focus | Status |
|----------|-------|--------|
| CiviCRM | CRM-only, PHP, WordPress/Drupal | Mature but dated |
| Spoke | P2P texting only | Active, MoveOn-maintained |
| Decidim | Participatory democracy (governance, not campaigns) | Used by 100s of cities |
| CONSUL Democracy | Citizen participation (governance) | 250+ cities |
| GoodParty.org | Independent/nonpartisan candidates | $10/mo Pro tier |
| Impact Stack | Progressive campaign management | Open source |
| Tijuana | Campaigning + CRM + email | Open source |
---
## 9. What Changemaker Lite Already Covers
| Domain | Comparable To | Their Price |
|--------|--------------|-------------|
| CRM + Contacts | NationBuilder, CiviCRM | $34-160/mo |
| Canvassing + GPS Tracking | Ecanvasser, MiniVAN | $149-599/mo |
| Email Campaigns (BullMQ) | Action Network, EveryAction | $15-custom/mo |
| Donations + Subscriptions | ActBlue/Anedot | 3.95-4% per txn |
| Ticketed Events + QR Check-in | Mobilize, Eventbrite | $0-custom |
| Landing Page Builder (GrapesJS) | NationBuilder Sites | included at $34+ |
| Volunteer Shifts + Portal | Mobilize | custom |
| Meeting Planner + Polls | Doodle/custom | $7-15/mo |
| Video Library + Analytics | No direct competitor | — |
| Docs CMS + Collab Editing | No direct competitor | — |
| Social Layer + Achievements | Reach (progressive-only) | free but partisan |
| SMS Campaigns (Termux) | Spoke (open source) | Twilio costs |
| Observability (Prometheus/Grafana) | Enterprise-only feature | — |
| 40+ Integrated Services | Nobody does this | — |
---
## 10. Feature Gap Analysis (Ranked by Impact)
### Tier 1 — Would Significantly Expand Market Reach
1. **Voter File Integration (US)** — Table-stakes for US campaigns. L2 Political is nonpartisan ($0.25/record). Build import adapter similar to NAR importer.
2. **Phone Banking / Auto-Dialer**#2 voter contact method. Twilio Voice API + browser-based WebRTC dialer.
3. **P2P Texting at Scale (Web-Based)** — Termux bridge isn't competitive. Twilio 10DLC + web volunteer UI needed. Hustle's 2026 shutdown created market vacuum.
4. **FEC / Campaign Finance Compliance** — Legal requirement for US campaigns. Stripe data + FEC Form 3/3X export.
### Tier 2 — Strong Differentiators
5. **Petition / Action Pages**#1 list-building tool in advocacy. Low implementation effort, high impact.
6. **Relational Organizing** — Friend-to-friend outreach (3-4x more effective than cold contact). Extends existing social module + voter file.
7. **AI-Powered Features** — Content generation, donor scoring, sentiment analysis. Industry moving fast here. MCP server is foundation.
8. **Offline-First Mobile** — Service Worker + IndexedDB for rural canvassing without connectivity.
### Tier 3 — Nice-to-Have
9. **Post-Action Follow-Up Automation** — Drip sequences triggered by actions.
10. **Branched Canvass Scripts** — Guided conversation trees at the door.
11. **Digital Advertising Integration** — Audience export for ad targeting.
12. **Election Day Operations Dashboard** — Real-time GOTV tracking.
13. **Peer-to-Peer Fundraising** — Personal fundraising pages.
---
## 11. Strategic Positioning
### Unassailable Advantages
1. **Self-hosted + open source** — only CiviCRM competes, with 1/10th the features
2. **$0 licensing** — beats every commercial platform
3. **Politically neutral** — serves any party, any country
4. **40+ integrated services** — no competitor bundles this breadth
5. **One-command deployment** — Docker Compose accessibility
6. **Data sovereignty** — critical for privacy and data residency laws
### Target Market Sweet Spots
- Independent / third-party candidates (underserved by partisan platforms)
- Canadian political campaigns (NAR data already integrated)
- International campaigns (most platforms are US-only)
- Small/local campaigns priced out of $149-1,200/mo tools
- Privacy-conscious organizations with data residency requirements
- Civic organizations and community groups
### Highest Bang-for-Buck Additions
1. **Petition / Action Pages** — Low effort, universally needed, #1 list builder
2. **Web-based P2P Texting (Twilio)** — Dominant voter contact channel
3. **Phone Banking** — Completes the doors + texts + calls trifecta
---
## Sources
- [NationBuilder](https://nationbuilder.com/) | [Pricing](https://nationbuilder.com/pricing)
- [NGP VAN](https://www.ngpvan.com/) | [VoteBuilder](https://www.ngpvan.com/votebuilder/)
- [Bonterra / EveryAction](https://www.bonterratech.com/product/everyaction)
- [Ecanvasser](https://www.ecanvasser.com/) | [Pricing](http://web.ecanvasser.com/pricing.html)
- [i360](https://www.i-360.com/)
- [Aristotle](https://www.aristotle.com/campaigns/)
- [PDI Political Data](https://politicaldata.com/)
- [L2 Political](https://www.l2-data.com/)
- [TargetSmart](https://targetsmart.com/)
- [Catalist](https://catalist.us/)
- [ActBlue](https://www.actblue.com/)
- [WinRed](https://winred.com/)
- [Anedot](https://www.anedot.com/)
- [Give Lively](https://www.givelively.org/)
- [Scale to Win](https://www.scaletowin.com/)
- [CallHub](https://callhub.io/)
- [GetThru](https://www.getthru.io/)
- [RumbleUp](https://rumbleup.com/)
- [Reach](https://reach.vote/)
- [Impactive](https://www.impactive.io/)
- [Mobilize](https://www.mobilize.us/)
- [Action Network](https://actionnetwork.org/)
- [Qomon](https://qomon.com/)
- [Campaign Nucleus](https://www.campaignnucleus.com/)
- [CiviCRM](https://civicrm.org/)
- [Spoke](https://coda.io/@arena/spoke)
- [GoodParty.org](https://goodparty.org/)
- [ISPolitical](https://ispolitical.com/)
- [CallTime.AI](https://www.calltime.ai/)
- [DSPolitical](https://www.dspolitical.com/)
- [OutreachCircle](https://outreachcircle.com/)
- [Decidim](https://decidim.org/)
- [Political Campaign Software Market Report 2026-2032](https://medium.com/@linxinxin/global-market-report-on-political-campaign-software-2026-2032-d452022e0539)
- [Higher Ground Labs AI Landscape Report](https://highergroundlabs.com/ai-landscape-report/)