Skip to content
mychiffonnPublic

About

Discover and sign petitions that you support. Improve change.org

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

SignFast

A signer-first civic petition feed: instead of browsing petitions chronologically or by popularity, each signer gets a personally ranked feed based on their stated interests, location, and how much civic weight a petition actually carries — then signs with two taps and a saved signature.

What this is. A portfolio prototype of a discovery/personalization layer, built with public Change.org petitions as sample content. It is not a live petition service: signatures are recorded locally to demonstrate the product flow and are never submitted to Change.org or any decision maker. Every petition links back to its original source, and the sign flow points signers to the real petition. The design deliberately keeps signing native rather than intermediating it — see Positioning in the technical decisions.

The problem

Petition platforms like Change.org optimize for virality, not for a signer's actual interests or civic leverage. A visitor sees whatever is trending nationally, has to manually search for the local school-board or planning-commission petition that affects them, and gets no signal on which petitions have a realistic chance of reaching a decision-maker versus which are symbolic. The result: civic attention gets spent on the loudest campaigns, not the ones a given person is best positioned to act on.

What's at stake is participation quality, not just quantity — low-friction signing (bulk-sign, saved signature) plus relevant surfacing is what turns a one-time visitor into someone who keeps showing up for local civic action.

What it does

  • Personalized feed (/feed) — ranks active petitions per signer using a weighted score of relevance (interest/keyword/location match), merit (civic impact, recipient feasibility, track record), and urgency (deadline pressure, progress toward goal). See lib/ranking.ts.
  • AI summaries — on-demand, cached 2–3 sentence neutral summaries per petition via Claude Haiku, with a deterministic fallback when no API key is configured (lib/ai.ts).
  • Bulk signing — select several recommended petitions and sign them all in one flow with a single saved legal name/signature (components/BulkSignModal.tsx, app/api/signatures/bulk).
  • Onboarding — captures interests, custom keywords, and location once, used to drive all future ranking.
  • Content ingestion — a pipeline that pulls public petitions into the app from their OpenGraph/JSON-LD metadata, infers categories from keywords, and idempotently upserts them (lib/ingest.ts, scripts/ingest-petitions.ts).
  • Continuous-improvement pipeline — every impression and signature is logged as an engagement event; a recalibration script periodically nudges the hand-tuned "civic impact" weights per category toward what signers actually convert on. See Data pipeline below.

Results

Working end-to-end on a seeded San Francisco petition dataset (18 petitions across 12 categories, mix of active/closed/victory states):

  • Ranking correctly separates a signer's top-relevance petitions from off-interest ones and excludes already-signed/expired/closed items (verified in lib/ranking.test.ts).
  • The recalibration pipeline was run against simulated engagement (high conversion on health/safety/environment, low elsewhere) and correctly shifted those categories' weights up (e.g. health 0.95 → 0.98, safety 0.90 → 0.98) and low-engagement categories down, while leaving under-sampled categories at their defaults — confirming the feedback loop closes correctly.
  • The ingestion pipeline was run end-to-end against a served petition page: it extracted the signature count from JSON-LD, inferred the correct categories from the text (transit, neighborhood), derived a goal/deadline, and upserted idempotently on re-run.
  • Full test suite (vitest, 7 tests across ranking, ingestion, and the bulk-sign API) and tsc --noEmit pass; next build produces a clean production build with 9 dynamic API routes and 5 static pages.

Demo

SignFast demo

(Record with pnpm dev, then a screen capture tool such as Kap or ffmpeg — walk through onboarding → feed → bulk sign. Save as docs/demo.gif.)

Installation

Requires Node.js 20+ and pnpm.

pnpm install

# environment
cp .env.example .env   # then fill in the values below

# database (SQLite by default — no external service needed)
pnpm exec prisma migrate dev
pnpm run prisma:seed

pnpm dev               # http://localhost:3000

Environment variables (.env):

Variable Required Purpose
DATABASE_URL yes Prisma datasource, e.g. file:./dev.db for local SQLite
AUTH_SECRET yes NextAuth.js v5 session signing secret (npx auth secret to generate)
ANTHROPIC_API_KEY no Enables real AI petition summaries; falls back to a truncated-text summary when unset

Seeded demo account: demo@signfast.app / demo1234.

Other commands:

pnpm test              # vitest unit tests
pnpm test:e2e           # playwright end-to-end tests
pnpm run pipeline:ingest urls.txt  # ingest public petitions from a URL list
pnpm run pipeline:weights          # run the ranking-weight recalibration job
pnpm run build          # production build (runs prisma generate first)

Technical decisions

Stack

Next.js 16 (App Router, Turbopack) with Prisma/SQLite, NextAuth v5 credentials auth, Tailwind v4, and Zod for input validation. SQLite was chosen for zero-setup local development and because the current data volume (thousands of petitions/signatures, not millions) doesn't need anything heavier yet — see the scaling plan below for when and how that changes.

Positioning: why native signing

The obvious "aggregator" design would scrape petitions and, when a user signs, route them to Change.org to leave the real signature. This prototype deliberately does not do that, for two reasons. First, it would destroy the most valuable technical asset here — the closed learning loop depends on observing the sign event, which is lost if signing happens off-platform. Second, the moment of signature (and the contact captured there) is a petition platform's core asset; a third party that intermediates it is an adversary, not an improvement. So this is framed as a personalization/discovery layer that would live inside such a product, with signing kept native and clearly labeled as a demo, and every petition attributed and linked back to its source. That constraint — surface and rank, don't intermediate — is the honest version of "how this could be improved."

Ranking as a pure, testable function

lib/ranking.ts has zero I/O: it's a set of pure scoring functions (relevanceScore, meritScore, urgencyScore) combined into petitionScore, unit-tested independently of the database or network. The API route (app/api/petitions/route.ts) is the only place that touches Prisma and passes plain objects in. This keeps the ranking logic auditable and cheap to test, and made it straightforward to thread data-driven weights through without touching the scoring math itself. The ingestion module (lib/ingest.ts) follows the same shape — pure extraction/normalization functions tested against HTML fixtures, with network and database confined to the runner script.

Content ingestion pipeline

scripts/ingest-petitions.ts takes a list of public petition URLs, fetches each, and feeds the HTML to pure extractors in lib/ingest.ts:

  1. Extract — pulls title/description/image from OpenGraph meta tags and the signature count from JSON-LD (interactionStatistic). It reads only the public, consumer-facing structured data a site exposes to crawlers, not rendered DOM internals — the defensible surface to ingest from.
  2. Normalize — infers categories from a keyword map over the title/description (reusing the app's own category slugs), derives a goal tier above the current count, and sets a demo deadline. Fields the public metadata can't provide (jurisdiction, real goal) are flagged in code as heuristics a production ingester would resolve from an authenticated feed.
  3. Load — idempotently upserts by slug, so re-running only refreshes signature counts rather than duplicating rows. The runner fetches sequentially with a delay and a descriptive User-Agent.

This is the front half of the same data platform as the ranking loop below: ingestion produces the content, engagement events measure how signers respond to it, and recalibration feeds that back into ranking.

Data pipeline: continuous ranking improvement

The impactByCategory weights in lib/ranking.ts started as hand-tuned guesses (e.g. "health petitions matter more than arts petitions"). That doesn't scale as a strategy — it should be replaced by what signers actually do. The implemented slice:

  1. Event log (EngagementEvent model) — an append-only log of impression events (when a petition is shown in a signer's recommended feed) and sign events (when they actually sign), each tagged by category. Logged from app/api/petitions/route.ts and both signature routes via lib/engagement.ts.
  2. Recalibration job (scripts/compute-category-weights.ts) — aggregates events per category, computes a signer conversion rate (signs/impressions), and nudges each category's default weight toward categories with above-average conversion (capped at ±0.08, and only once a category has ≥20 impressions so single-petition noise can't dominate). Results are upserted into a CategoryWeight table.
  3. Consumption — lib/engagement.ts#loadImpactWeights() reads CategoryWeight, falling back to the hardcoded defaults for any category without enough data, and the feed route passes that merged map through the ranking functions.

This is intentionally a minimal, inspectable slice — a shrinkage-adjusted conversion signal, not a black-box model — so a wrong recalibration is easy to diagnose and roll back (delete the CategoryWeight row, or just don't run the job).

Target architecture at scale (not yet built, but what this slice is a stepping stone toward):

  • Datastore: move from SQLite to Postgres once concurrent writes matter; keep Prisma as the ORM (schema changes are already Postgres-compatible).
  • Event ingestion: today, impression/sign logging is a synchronous await inside the request path — fine at low QPS, but it couples ranking latency to write latency. At scale, emit events to a queue (SQS/Kafka) and have a separate consumer batch-write them, so a slow analytics write never blocks a feed response.
  • Feature store: precompute and cache per-category and per-petition engagement aggregates (e.g. in Redis or a materialized Postgres view) refreshed on a schedule, rather than recomputing weights ad hoc from the raw event table — keeps the recalibration job's read cost flat as event volume grows.
  • Scheduling: run pipeline:weights (or its successor) on a cron (e.g. hourly) instead of manually; the script is already idempotent (upsert) and side-effect-free on failure.
  • Experimentation: before a new weight set goes live for everyone, A/B it — hold out a signer cohort on default weights, compare conversion, and only promote a weight set that wins. The current design already separates "compute" from "consume," so this is a routing change (which CategoryWeight snapshot a request reads), not a rewrite.
  • Model evolution: category-level conversion is a first, coarse signal. The natural next step is a learned per-signer relevance model (e.g. logistic regression over interest match, category, recency, location) trained on the same event log, with the current heuristic scorer as a fallback/baseline to compare against — the event log is the reusable asset either way.

Caching AI summaries

lib/ai.ts writes the Claude-generated (or fallback) summary back onto the Petition row on first request and never regenerates it. This trades a small amount of staleness for avoiding a paid API call on every petition view — acceptable since petition descriptions rarely change after publication.

Rate limiting

Signature submission is rate-limited per-user in-memory (app/api/signatures/rateLimit.ts). That's a known limitation for a multi-instance deployment (each instance has its own counter) — acceptable for a single-instance deployment today, but the fix at scale is moving the counter to Redis alongside the feature-store cache mentioned above, not reinventing a new mechanism.

About

Discover and sign petitions that you support. Improve change.org

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages