Quadratic Voting (live)
This application is built atop
- Front-end: NextJS (React)
- Back-end: NodeJS + Express serverless functions
- Database: PostgreSQL + the Prisma DB toolkit
At a fundamental level, the way in which voting links are generated and sessions are handled is kept simple:
- An
eventstable keeps track of open voting events. Each event has asecret_key (uuid)to manage the event. - A
voterstable keeps track of all voters and their preferences. Each voter has aid (uuid)that together with theevent_uuid (uuid)represents their unique voting URL.
Important files:
- prisma/schema.sql contains the SQL schema for the application.
- pages/api/events/details.js contains the QV calculation logic.
Each event has a privacy_mode chosen at creation time:
- Anonymous (default) — voter names and per-voter vote data are never
returned to the event organizer. The organizer sees only aggregate
totals in the downloaded report. This matches the behavior of every
event created before
privacy_modewas introduced; existing events are migrated toanonymousexplicitly. - Identified — voters are required to enter a name on the ballot page, and the name is stored alongside their allocation. Names are trimmed and internal whitespace is collapsed before storage; case is preserved exactly as the voter typed it.
The downloaded XLSX report differs by mode:
- Anonymous — one sheet (
data) with one row per option containing the aggregate QV-weighted total. Byte-identical to what the report contained beforeprivacy_modeexisted. - Identified — the same
datasheet, plus a second sheet namedVoterswith one row per voter. Columns: voter name, one column per option in the order they were created (matching the ballot), and a finalCredits usedcolumn with the total credits (Σ votes²) the voter spent. Rows are sorted alphabetically by name (case-insensitive sort, display case preserved).
Once any voter casts a vote, the event's privacy_mode is locked. The
API rejects mode-change requests after the first non-zero allocation is
submitted, so voters can't have the privacy contract changed underneath
them mid-event.
Existing events created before this feature shipped display as Anonymous on their settings page and cannot be switched.
Independently of privacy_mode, each event chooses a link_mode that
determines how voters reach the ballot:
- Per-voter link (
unique, default) — the organizer pre-allocatesnum_votersballot rows at event creation, each with its own personal URL. Each link can submit one ballot. Suitable for known voter rosters where one-person-one-vote matters. This matches the behavior of every event created beforelink_modewas introduced; existing events are migrated touniqueexplicitly. - Public link (
public) — a single URL anyone can visit at/vote?event=<event_uuid>. No voter rows are pre-allocated; each submission creates its own row at vote time.
Public mode is intentionally low-trust. The same person can submit multiple times by reloading the page or sharing the link further. Suitable for demos, workshops, and classroom polls — not for consequential votes.
All four (privacy_mode × link_mode) combinations are supported and
behave independently. The two axes are locked separately once the first
vote is cast — the API returns 409 if either is changed after voting
begins.
- Setup your PostgreSQL database
# Import schema
psql -f prisma/schema.sql
-
Setup environment variables. Copy prisma/.env.sample to
prisma/.envand replaceDATABASE_URLwith your PostgreSQL DB url. -
Run application
# Install dependencies
yarn
# Run application
yarn dev
# Build container
docker build . -t rxc_qv
# Run
docker run -d --env DATABASE_URL=postgresql://__USER__:__PASSWORD__@__HOST__/__DATABASE__ -p 2000:2000 rxc_qv