Civic Discourse — Weekly Vote
Do you agree with the 1st Amendment to the Constitution?
Cast your YES / NO vote. Results update live. AI summary publishes at close.
Current Topic
Closes (HAPPY HOUR): loading… (Fri 5 PM ET)
Total votes: 0
After you vote, your choice is locked for this topic. New topics open each Friday at 5 PM ET.
Time Remaining
00:00:00
Ends —
AI Summary (Preview)
Debate sentiment and key arguments will appear here at close.
/**
* Cloudflare Worker (Modules syntax)
* Endpoints:
* GET /tally?topic=topic-key -> { yes, no }
* POST /vote -> { ok, yes, no, accepted }
*
* D1 tables:
* tallies(topic_key TEXT PK, yes INT, no INT)
* votes(topic_key TEXT, email_hash TEXT, choice TEXT CHECK('YES'/'NO'), ts TEXT, PRIMARY KEY(topic_key,email_hash))
*
* CORS: set CORS_ORIGIN in wrangler.toml for stricter origin; defaults to "*".
*/
export default {
async fetch(req, env) {
const url = new URL(req.url);
const origin = env.CORS_ORIGIN || "*";
const cors = {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Headers": "content-type",
"Access-Control-Allow-Methods": "GET,POST,OPTIONS",
};
if (req.method === "OPTIONS") {
return new Response(null, { headers: cors });
}
// Health
if (url.pathname === "/health") {
return new Response(JSON.stringify({ ok: true }), { headers: { "content-type": "application/json", ...cors }});
}
// GET /tally?topic=...
if (req.method === "GET" && url.pathname === "/tally") {
const topic = url.searchParams.get("topic") || "";
if (!topic) {
return new Response(JSON.stringify({ error: "missing topic" }), {
status: 400, headers: { "content-type": "application/json", ...cors }
});
}
const row = await env.DB.prepare(
"SELECT yes, no FROM tallies WHERE topic_key = ?1"
).bind(topic).first();
const yes = row?.yes ?? 0;
const no = row?.no ?? 0;
return new Response(JSON.stringify({ yes, no }), {
headers: { "content-type": "application/json", ...cors }
});
}
// POST /vote
if (req.method === "POST" && url.pathname === "/vote") {
let payload;
try { payload = await req.json(); }
catch {
return new Response(JSON.stringify({ error: "bad json" }), {
status: 400, headers: { "content-type": "application/json", ...cors }
});
}
const topic = String(payload.vote_topic_key || "").trim();
const choice = String(payload.vote_choice || "").trim().toUpperCase();
const email = String(payload.email || "").trim();
const name = String(payload.name || "").trim();
const phone = String(payload.phone || "").trim();
const ts = String(payload.vote_timestamp || new Date().toISOString());
if (!topic || !email || (choice !== "YES" && choice !== "NO")) {
return new Response(JSON.stringify({ error: "missing fields" }), {
status: 400, headers: { "content-type": "application/json", ...cors }
});
}
// Hash email (lowercased)
const emailHash = await sha256Lower(email);
// Ensure tallies row exists
await env.DB.prepare(
"INSERT OR IGNORE INTO tallies (topic_key, yes, no) VALUES (?1, 0, 0)"
).bind(topic).run();
// One vote per (topic, email_hash). If first time, insert and increment tally.
const insert = await env.DB.prepare(
"INSERT OR IGNORE INTO votes (topic_key, email_hash, choice, ts) VALUES (?1, ?2, ?3, ?4)"
).bind(topic, emailHash, choice, ts).run();
if (insert.meta.changes === 1) {
const col = choice === "YES" ? "yes" : "no";
await env.DB.prepare(`UPDATE tallies SET ${col} = ${col} + 1 WHERE topic_key = ?1`).bind(topic).run();
}
// Current tallies
const row = await env.DB.prepare(
"SELECT yes, no FROM tallies WHERE topic_key = ?1"
).bind(topic).first();
return new Response(JSON.stringify({
ok: true,
yes: row?.yes ?? 0,
no: row?.no ?? 0,
accepted: insert.meta.changes === 1
}), { headers: { "content-type": "application/json", ...cors }});
}
return new Response("Not found", { status: 404, headers: cors });
}
};
/** Hash helpers */
async function sha256Lower(email) {
const data = new TextEncoder().encode(email.toLowerCase());
const hash = await crypto.subtle.digest("SHA-256", data);
return [...new Uint8Array(hash)].map(b => b.toString(16).padStart(2, "0")).join("");
}
-- setup.sql (run once to init the database)
CREATE TABLE IF NOT EXISTS tallies (
topic_key TEXT PRIMARY KEY,
yes INTEGER NOT NULL DEFAULT 0,
no INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS votes (
topic_key TEXT NOT NULL,
email_hash TEXT NOT NULL,
choice TEXT NOT NULL CHECK (choice IN ('YES','NO')),
ts TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (topic_key, email_hash)
);