- Service Name:
ihnyc-rc-vote - Live Site: https://vote.ihnyc-rc.org
- Repository:
https://github.com/dghauri0/ihnyc-rc-vote.git
🧭 Overview
- Cloudflare Worker built with Hono framework
- Admin and public routes for election management
- Uses D1 for elections/tokens/ballots storage
- Durable Object (TokenManager) for token validation and coordination
- Scheduled tasks handle Notion sync and email workflows
- Rate limiting and IP-based protection
Sources: ihnyc-rc-vote/wrangler.jsonc, ihnyc-rc-vote/src/index.ts, ihnyc-rc-vote/src/routes/vote.ts
👥 Key User Flows
Resident Voting Flow
- Receive invite email with tokenized link
- Visit
/e/:id/votewith token - TokenManager validates token via Durable Object
- Cast vote (election or poll)
- Ballot stored in D1
- Rate limiting prevents duplicate votes
Admin Election Management
- Create election via
/admin/elections - Generate token batches
- Send invites via Resend
- Monitor voting progress
- Publish results when ready
Scheduled Tasks
- Notion Sync: Updates election status in Notion
- Reminder Emails: Sends vote reminders before deadline
- Results Emails: Sends results after election closes
Sources: ihnyc-rc-vote/src/routes/vote.ts, ihnyc-rc-vote/src/routes/admin.ts, ihnyc-rc-vote/src/index.ts, ihnyc-rc-vote/src/utils/notion.ts, ihnyc-rc-vote/src/utils/reminders.ts
🧾 Manual Election Results (ihnyc-rc-vote)
This feature lives in the voting service admin UI (not the landing site). Use it to attach certified legacy or imported results that override computed results on the public results page.
UI Walkthrough (Admin Election Page)
- Open the election in the admin UI and locate Manual Results (Legacy / Imported).
- Optional: fill Certified By, Certified At (ISO), and Notes.
- Paste Results JSON and click Save Manual Results.
- The badge flips to “Manual results attached.” Use Clear Manual Results to remove the override.
Results JSON Shape
{
"contests": [
{
"label": "President",
"items": [
{ "label": "Alice", "votes": 120 },
{ "label": "Bob", "votes": 95 }
]
}
]
}Validation Rules
contestsis required and must be an array.- Each contest needs a non-empty
labeland anitemsarray. - Each item needs a non-empty
labeland an integervotesvalue>= 0.
API Endpoints (Optional)
- PUT
/admin/elections/:id/manual-resultsto save manual results. - DELETE
/admin/elections/:id/manual-resultsto clear manual results. - GET
/e/:id/resultsreturnsresults_mode: "manual"when overrides are present.
Sources: ihnyc-rc-vote/src/templates/admin-election.tsx, ihnyc-rc-vote/src/routes/admin.ts, ihnyc-rc-vote/src/models/manual-results.ts, ihnyc-rc-vote/src/routes/results.ts, ihnyc-rc-vote/README.md
📎 Notion Attachments (Supporting Documents)
Elections can display supporting documents pulled from the Notion “Any Voting” database. Add files to the Attachments (Files & media) property on the Notion item.
Behavior
- The public election page shows a “Supporting documents” section only when attachments exist.
- A primary link, View attachments in Notion, uses the stable Notion page URL.
- Individual file links are best-effort and may expire (Notion signed URLs).
Notes
- No uploads happen in the voting app; it only stores attachment metadata and links from Notion.
- If a file link expires, use the Notion page link to access the attachment.
- Signed file URLs are refreshed during pull syncs (cron or Retry update).
Sources: ihnyc-rc-vote/src/utils/notion.ts, ihnyc-rc-vote/src/index.ts, ihnyc-rc-vote/src/routes/admin.ts, ihnyc-rc-vote/src/templates/election-page.tsx
🔁 Notion Bidirectional Sync (Cron + Manual)
The voting service syncs with Notion in two directions on a schedule, and admins can manually force a sync from the Manage Election page.
What Syncs
- Vote → Notion: election status updates (Not started / In Voting / Done) and Results Link.
- Notion → Vote:
Name,Description, andAttachments(Files & media), plus the Notion page URL.
Sync timestamps
notion_last_push_at: last successful write-back to Notion.notion_last_pull_at: last successful pull-in from Notion.notion_last_sync_at: legacy field kept for compatibility.
Pull gating and manual retry
Cron pull is gated by notion_last_pull_at and SYNC_INTERVAL_MS. Push writes only update notion_last_push_at and do not affect pull gating. The admin Retry update endpoint always pulls immediately (no interval gate) and records notion_last_pull_at.
Sync Flow (Mermaid)
sequenceDiagram autonumber participant Cron as Scheduled Handler participant DB as D1 (elections) participant Notion as Notion API participant Admin as Retry Update Endpoint Cron->>DB: getElectionsWithNotionLinks() Cron->>Notion: update status + results link Cron->>DB: updateElectionNotionPushSync(now) Cron->>DB: check notion_last_pull_at alt last_pull < interval Cron-->>Cron: skip pull-in else pull-in Cron->>Notion: getPageProperties() Cron->>DB: update title/description Cron->>DB: update attachments_json + page_url Cron->>DB: updateElectionNotionPullSync(now) end Admin->>Notion: update status + results link Admin->>DB: updateElectionNotionPushSync(now) Admin->>Notion: getPageProperties() (forced) Admin->>DB: updateElectionNotionPullSync(now)
Sources: ihnyc-rc-vote/src/index.ts, ihnyc-rc-vote/src/utils/notion.ts, ihnyc-rc-vote/src/routes/admin.ts
📚 Detailed Guides
- ops: Infrastructure, deployments, and runbooks
- authentication: Cloudflare Access, API key fallback, local bypass
- token-management: Durable Object token validation flow
- voting-system: Ballot types and validation rules
- results-calculation: Condorcet + poll results computation
- magic-link-invites: Batch invite (magic link) mode
- rate-limiting: Per-route limits and enforcement
- email-system: Resend templates and sending behavior
- distribution-lists: Bulk email groups
- notion-setup: Notion allowlist setup
- notion-integration: Sync behavior and API usage
- migrations: Migration workflow + runbooks
- audit-logging: Logged actions and schema
- testing: Test structure and local runs
- api-reference: Admin and public endpoints
🔗 Key Routes and Endpoints
| Route | Method | Auth | Purpose | Source |
|---|---|---|---|---|
/health | GET | Public | Health check | ihnyc-rc-vote/src/index.ts |
/ | GET | Public | Home page with published results | ihnyc-rc-vote/src/index.ts |
/e/:id | GET | Public | Election info page | ihnyc-rc-vote/src/routes/vote.ts |
/e/:id/vote | GET/POST | Token | Vote page and submission | ihnyc-rc-vote/src/routes/vote.ts |
/e/:id/vote/poll | GET/POST | Token | Poll vote flow | ihnyc-rc-vote/src/routes/vote.ts |
/e/:id/results | GET | Public (visibility rules) | Results page | ihnyc-rc-vote/src/routes/results.ts |
/vote/my-elections | GET | Magic link | Batch invite landing | ihnyc-rc-vote/src/routes/vote.ts |
/admin/elections | GET/POST | Access or API Key | Elections CRUD | ihnyc-rc-vote/src/routes/admin.ts |
/admin/elections/:id/tokens | GET/POST | Access or API Key | Token batches | ihnyc-rc-vote/src/routes/admin.ts |
/admin/elections/:id/invite | POST | Access or API Key | Send invites | ihnyc-rc-vote/src/routes/admin.ts |
/admin/bulk-invites | POST | Access or API Key | Batch invite management | ihnyc-rc-vote/src/routes/admin.ts |
🗄️ Data and Storage
| Storage | Binding | Purpose | Source |
|---|---|---|---|
| D1 | DB | Elections, tokens, ballots | ihnyc-rc-vote/wrangler.jsonc |
| Durable Object | TokenManager | Token coordination and validation | ihnyc-rc-vote/src/durable-objects/TokenManager.ts |
| Static Assets | ASSETS | UI assets served from binding | ihnyc-rc-vote/src/index.ts |
Sources: ihnyc-rc-vote/wrangler.jsonc, ihnyc-rc-vote/src/index.ts
🗄️ Database Schema
Tables
| Table | Purpose | Notes |
|---|---|---|
elections | Election metadata + Notion sync config | invite_mode controls batch vs individual; settings_json holds feature flags |
tokens | Vote tokens (hashed) | used_at marks consumption |
ballots | Vote submissions | payload_json stores choice/ranking |
candidates | Candidate list for ranked elections | Ordered by display_order |
invites | Invite delivery tracking | token_hash is nullable; status includes QUEUED |
audit_logs | Non-sensitive activity log | metadata stores JSON |
distribution_lists | Reusable email groups | Paired with distribution_list_emails |
distribution_list_emails | Emails in a distribution list | Unique on (list_id, email) |
email_magic_links | Batch invite magic links | magic_token is unique |
Indexes (non-PK)
elections:idx_elections_open_at,idx_elections_close_at,idx_elections_ballot_typetokens:idx_tokens_election,idx_tokens_usedballots:idx_ballots_electioncandidates:idx_candidates_electionaudit_logs:idx_audit_logs_action,idx_audit_logs_timestamp,idx_audit_logs_electioninvites:idx_invites_election,idx_invites_email,idx_invites_status,idx_invites_token_hash,idx_invites_email_lookup,idx_invites_reminder_sent_atdistribution_list_emails:idx_distribution_list_emails_list,idx_distribution_list_emails_emailemail_magic_links:idx_magic_links_email,idx_magic_links_token,idx_magic_links_expires
Notes
invites.token_hashis nullable and not enforced as a foreign key so failed sends can keep invite records when tokens are deleted.- Ballots are intentionally unlinkable to invites/emails; only
election_idties them to an election.
ER Diagram (Mermaid)
erDiagram elections { TEXT id PK TEXT title TEXT description INTEGER open_at INTEGER close_at TEXT ballot_type TEXT settings_json INTEGER created_at TEXT notion_database_id TEXT notion_page_id TEXT notion_page_url TEXT notion_status_property_name TEXT notion_status_value_in_voting TEXT notion_status_value_done INTEGER notion_last_sync_at TEXT notion_last_push_at TEXT notion_last_pull_at TEXT notion_last_sync_error INTEGER notion_done_set_at TEXT notion_results_link_property_name INTEGER results_emails_sent_at TEXT attachments_json TEXT manual_results_json TEXT manual_results_certified_at TEXT manual_results_certified_by TEXT manual_results_notes TEXT invite_mode } tokens { TEXT token_hash PK TEXT election_id FK TEXT batch_name INTEGER created_at INTEGER used_at } ballots { TEXT id PK TEXT election_id FK TEXT ballot_type TEXT payload_json INTEGER created_at } candidates { TEXT id PK TEXT election_id FK TEXT name INTEGER display_order } audit_logs { TEXT id PK TEXT action TEXT ip_address TEXT user_agent TEXT election_id FK INTEGER timestamp TEXT metadata } invites { TEXT id PK TEXT election_id FK TEXT email TEXT token_hash TEXT token_plaintext TEXT status TEXT last_error INTEGER created_at INTEGER sent_at INTEGER resend_count INTEGER reminder_sent_at INTEGER reminder_count } distribution_lists { TEXT id PK TEXT name TEXT description INTEGER created_at INTEGER updated_at } distribution_list_emails { TEXT id PK TEXT list_id FK TEXT email INTEGER created_at } email_magic_links { TEXT id PK TEXT email TEXT magic_token INTEGER expires_at INTEGER used_at INTEGER created_at } elections ||--o{ tokens : issues elections ||--o{ ballots : records elections ||--o{ candidates : defines elections ||--o{ invites : sends elections ||--o{ audit_logs : logs distribution_lists ||--o{ distribution_list_emails : includes
Sources: ihnyc-rc-vote/migrations/001_initial_schema.sql, ihnyc-rc-vote/migrations/002_audit_logs.sql, ihnyc-rc-vote/migrations/003_invites.sql, ihnyc-rc-vote/migrations/004_allow_null_token_hash.sql, ihnyc-rc-vote/migrations/005_add_token_plaintext.sql, ihnyc-rc-vote/migrations/006_notion_integration.sql, ihnyc-rc-vote/migrations/007_add_poll_ballot_type.sql, ihnyc-rc-vote/migrations/008_add_results_emails_sent_at.sql, ihnyc-rc-vote/migrations/009_distribution_lists.sql, ihnyc-rc-vote/migrations/010_add_queued_invite_status.sql, ihnyc-rc-vote/migrations/011_batch_invites.sql, ihnyc-rc-vote/migrations/012_add_resend_count.sql, ihnyc-rc-vote/migrations/013_add_invite_reminder_tracking.sql
🔄 Automations and Integrations
Email Delivery
- Resend: Sends invites and results
- Configured via
RESEND_API_KEYandFROM_EMAIL
Notion Sync
- Bidirectional: push status/results link and pull name/description/attachments
- Uses Notion API with
NOTION_API_KEY - Scheduled via cron (every minute)
Admin Auth
- Cloudflare Access: Headers-based auth in production
- ADMIN_API_KEY: Fallback for API auth
- Local dev bypass available
Rate Limiting
- IP-based rate limiting on vote submissions
- Prevents ballot stuffing
Sources: ihnyc-rc-vote/src/utils/email.ts, ihnyc-rc-vote/src/utils/notion.ts, ihnyc-rc-vote/src/middleware/auth.ts, ihnyc-rc-vote/src/middleware/rate-limit.ts
🗺️ Service Flow Diagram
Voting service architecture and integrations
flowchart LR subgraph Users U_RES["USER: Resident"] U_ADMIN["USER: Admin"] end subgraph Services S_VOTE["SVC: ihnyc-rc-vote"] end subgraph External_Systems EXT_RESEND["EXT: Resend"] EXT_NOTION["EXT: Notion"] EXT_ACCESS["EXT: Cloudflare Access"] end subgraph Storage STORE_D1["STORE: D1"] STORE_DO["STORE: Durable Object"] STORE_ASSETS["STORE: Static Assets"] end U_RES -->|vote| S_VOTE U_ADMIN -->|admin| S_VOTE S_VOTE -->|elections| STORE_D1 S_VOTE -->|validate| STORE_DO S_VOTE -->|assets| STORE_ASSETS S_VOTE -->|emails| EXT_RESEND S_VOTE -->|sync| EXT_NOTION U_ADMIN -->|auth| EXT_ACCESS
Sources: ihnyc-rc-vote/src/index.ts, ihnyc-rc-vote/src/routes/vote.ts, ihnyc-rc-vote/src/routes/admin.ts, ihnyc-rc-vote/wrangler.jsonc, ihnyc-rc-vote/src/middleware/auth.ts
🛠️ Common Ops
Local Development
# Copy env vars
cp .dev.vars.example .dev.vars
# Start dev server
npm run devDeployment
npm run deploySet Production Secrets
wrangler secret put RESEND_API_KEY
wrangler secret put BASE_URL
wrangler secret put ADMIN_API_KEY
wrangler secret put NOTION_API_KEYSources: ihnyc-rc-vote/README.md
🔐 Environment Variables
| Name | Purpose | Required |
|---|---|---|
RESEND_API_KEY | Email delivery API key | Yes |
FROM_EMAIL | Sender email address | Yes |
BASE_URL | Service base URL | Yes |
ADMIN_API_KEY | Admin API authentication | Yes (production) |
NOTION_API_KEY | Notion integration | For sync feature |
NOTION_DATABASE_ID | Notion database ID | For sync feature |
Sources: ihnyc-rc-vote/.dev.vars.example, ihnyc-rc-vote/README.md