← back to Petitionyour
lib/petitions.js
202 lines
const crypto = require('crypto');
const { readJSON, writeJSON } = require('./store');
const CATEGORIES = [
'Local & City',
'State',
'Federal / Congress',
'Environment',
'Education',
'Healthcare',
'Public Safety',
'Other',
];
function slugify(title) {
return title
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.slice(0, 60)
.replace(/^-+|-+$/g, '');
}
function loadPetitions() {
return readJSON('petitions', []);
}
function savePetitions(list) {
writeJSON('petitions', list);
}
function loadSignatures() {
return readJSON('signatures', []);
}
function saveSignatures(list) {
writeJSON('signatures', list);
}
function signatureCount(petitionId) {
return loadSignatures().filter((s) => s.petitionId === petitionId).length;
}
function withCounts(petition) {
return { ...petition, signatureCount: signatureCount(petition.id) };
}
function listPetitions() {
return loadPetitions().map(withCounts);
}
function getPetitionBySlug(slug) {
const p = loadPetitions().find((x) => x.slug === slug);
return p ? withCounts(p) : null;
}
function getPetitionById(id) {
const p = loadPetitions().find((x) => x.id === id);
return p ? withCounts(p) : null;
}
function createPetition({ title, description, target, targetType, category, creatorName }) {
title = String(title || '').trim();
description = String(description || '').trim();
target = String(target || '').trim();
if (!title || !description || !target) {
throw new Error('Title, description, and target are required.');
}
const petitions = loadPetitions();
const base = slugify(title) || 'petition';
let slug = base;
let n = 1;
const existingSlugs = new Set(petitions.map((p) => p.slug));
while (existingSlugs.has(slug)) {
n += 1;
slug = `${base}-${n}`;
}
const petition = {
id: crypto.randomUUID(),
slug,
title,
description,
target,
targetType: targetType && CATEGORIES ? String(targetType).trim() : '',
category: CATEGORIES.includes(category) ? category : 'Other',
creatorName: String(creatorName || '').trim(),
createdAt: new Date().toISOString(),
};
petitions.push(petition);
savePetitions(petitions);
return withCounts(petition);
}
// Parse the targeted-reps payload from the sign form. It arrives as a JSON
// string in a hidden field (the reps panel writes it via fetch results). We
// store only a compact, non-PII descriptor per office — enough to compute
// aggregate reach, never a claim that anything was sent.
function parseTargetedReps(raw) {
if (!raw) return [];
let arr;
try {
arr = typeof raw === 'string' ? JSON.parse(raw) : raw;
} catch (e) {
return [];
}
if (!Array.isArray(arr)) return [];
return arr
.map((r) => ({
bioguide: r && r.bioguide ? String(r.bioguide).slice(0, 20) : null,
name: r && r.name ? String(r.name).slice(0, 120) : null,
chamber: r && (r.chamber === 'senate' || r.chamber === 'house') ? r.chamber : null,
state: r && r.state ? String(r.state).slice(0, 4) : null,
district: r && (typeof r.district === 'number') ? r.district : null,
}))
.filter((r) => r.bioguide || r.name)
.slice(0, 60); // whole state delegations are large but bounded
}
function addSignature(petitionSlug, { name, email, zip, comment, showNamePublicly, updatesOptIn, targetedReps }) {
const petitions = loadPetitions();
const petition = petitions.find((p) => p.slug === petitionSlug);
if (!petition) throw new Error('Petition not found.');
name = String(name || '').trim();
email = String(email || '').trim().toLowerCase();
zip = String(zip || '').trim();
if (!name) throw new Error('Name is required.');
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
throw new Error('A valid email is required.');
}
if (zip && !/^\d{5}(-\d{4})?$/.test(zip)) {
throw new Error('ZIP must be 5 digits (optionally ZIP+4).');
}
const signatures = loadSignatures();
const already = signatures.find(
(s) => s.petitionId === petition.id && s.email === email
);
if (already) {
throw new Error('This email has already signed this petition.');
}
const sig = {
id: crypto.randomUUID(),
petitionId: petition.id,
name,
email,
zip,
comment: String(comment || '').trim().slice(0, 500),
showNamePublicly: !!showNamePublicly,
updatesOptIn: !!updatesOptIn, // consent flag only — no send mechanism wired (Steve-gated)
// Offices this signer resolved via the reps panel. Recorded for aggregate
// reach only — we never sent anything on their behalf.
targetedReps: parseTargetedReps(targetedReps),
createdAt: new Date().toISOString(),
};
signatures.push(sig);
saveSignatures(signatures);
return { signature: sig, petition: withCounts(petition) };
}
function listSignaturesForPetition(petitionId, { publicOnly = true } = {}) {
const sigs = loadSignatures()
.filter((s) => s.petitionId === petitionId)
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
if (!publicOnly) return sigs;
return sigs.map((s) => ({
...s,
name: s.showNamePublicly ? s.name : 'Anonymous supporter',
email: undefined,
}));
}
// Aggregate reach: how many UNIQUE congressional offices this petition's
// signers have targeted via the reps panel. Honest framing helper for the UI.
function repReachForPetition(petitionId) {
const sigs = loadSignatures().filter((s) => s.petitionId === petitionId);
const offices = new Set();
let signersWithTargets = 0;
sigs.forEach((s) => {
const reps = Array.isArray(s.targetedReps) ? s.targetedReps : [];
if (reps.length) signersWithTargets += 1;
reps.forEach((r) => {
const key = r.bioguide || [r.chamber, r.state, r.district, r.name].join('|');
if (key) offices.add(key);
});
});
return { uniqueOffices: offices.size, signersWithTargets };
}
module.exports = {
CATEGORIES,
slugify,
listPetitions,
getPetitionBySlug,
getPetitionById,
createPetition,
addSignature,
listSignaturesForPetition,
repReachForPetition,
};