← 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,
};