🧭 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

  1. Receive invite email with tokenized link
  2. Visit /e/:id/vote with token
  3. TokenManager validates token via Durable Object
  4. Cast vote (election or poll)
  5. Ballot stored in D1
  6. Rate limiting prevents duplicate votes

Admin Election Management

  1. Create election via /admin/elections
  2. Generate token batches
  3. Send invites via Resend
  4. Monitor voting progress
  5. 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

  • contests is required and must be an array.
  • Each contest needs a non-empty label and an items array.
  • Each item needs a non-empty label and an integer votes value >= 0.

API Endpoints (Optional)

  • PUT /admin/elections/:id/manual-results to save manual results.
  • DELETE /admin/elections/:id/manual-results to clear manual results.
  • GET /e/:id/results returns results_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, and Attachments (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

🔗 Key Routes and Endpoints

RouteMethodAuthPurposeSource
/healthGETPublicHealth checkihnyc-rc-vote/src/index.ts
/GETPublicHome page with published resultsihnyc-rc-vote/src/index.ts
/e/:idGETPublicElection info pageihnyc-rc-vote/src/routes/vote.ts
/e/:id/voteGET/POSTTokenVote page and submissionihnyc-rc-vote/src/routes/vote.ts
/e/:id/vote/pollGET/POSTTokenPoll vote flowihnyc-rc-vote/src/routes/vote.ts
/e/:id/resultsGETPublic (visibility rules)Results pageihnyc-rc-vote/src/routes/results.ts
/vote/my-electionsGETMagic linkBatch invite landingihnyc-rc-vote/src/routes/vote.ts
/admin/electionsGET/POSTAccess or API KeyElections CRUDihnyc-rc-vote/src/routes/admin.ts
/admin/elections/:id/tokensGET/POSTAccess or API KeyToken batchesihnyc-rc-vote/src/routes/admin.ts
/admin/elections/:id/invitePOSTAccess or API KeySend invitesihnyc-rc-vote/src/routes/admin.ts
/admin/bulk-invitesPOSTAccess or API KeyBatch invite managementihnyc-rc-vote/src/routes/admin.ts

🗄️ Data and Storage

StorageBindingPurposeSource
D1DBElections, tokens, ballotsihnyc-rc-vote/wrangler.jsonc
Durable ObjectTokenManagerToken coordination and validationihnyc-rc-vote/src/durable-objects/TokenManager.ts
Static AssetsASSETSUI assets served from bindingihnyc-rc-vote/src/index.ts

Sources: ihnyc-rc-vote/wrangler.jsonc, ihnyc-rc-vote/src/index.ts


🗄️ Database Schema

Tables

TablePurposeNotes
electionsElection metadata + Notion sync configinvite_mode controls batch vs individual; settings_json holds feature flags
tokensVote tokens (hashed)used_at marks consumption
ballotsVote submissionspayload_json stores choice/ranking
candidatesCandidate list for ranked electionsOrdered by display_order
invitesInvite delivery trackingtoken_hash is nullable; status includes QUEUED
audit_logsNon-sensitive activity logmetadata stores JSON
distribution_listsReusable email groupsPaired with distribution_list_emails
distribution_list_emailsEmails in a distribution listUnique on (list_id, email)
email_magic_linksBatch invite magic linksmagic_token is unique

Indexes (non-PK)

  • elections: idx_elections_open_at, idx_elections_close_at, idx_elections_ballot_type
  • tokens: idx_tokens_election, idx_tokens_used
  • ballots: idx_ballots_election
  • candidates: idx_candidates_election
  • audit_logs: idx_audit_logs_action, idx_audit_logs_timestamp, idx_audit_logs_election
  • invites: idx_invites_election, idx_invites_email, idx_invites_status, idx_invites_token_hash, idx_invites_email_lookup, idx_invites_reminder_sent_at
  • distribution_list_emails: idx_distribution_list_emails_list, idx_distribution_list_emails_email
  • email_magic_links: idx_magic_links_email, idx_magic_links_token, idx_magic_links_expires

Notes

  • invites.token_hash is 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_id ties 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_KEY and FROM_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 dev

Deployment

npm run deploy

Set Production Secrets

wrangler secret put RESEND_API_KEY
wrangler secret put BASE_URL
wrangler secret put ADMIN_API_KEY
wrangler secret put NOTION_API_KEY

Sources: ihnyc-rc-vote/README.md


🔐 Environment Variables

NamePurposeRequired
RESEND_API_KEYEmail delivery API keyYes
FROM_EMAILSender email addressYes
BASE_URLService base URLYes
ADMIN_API_KEYAdmin API authenticationYes (production)
NOTION_API_KEYNotion integrationFor sync feature
NOTION_DATABASE_IDNotion database IDFor sync feature

Sources: ihnyc-rc-vote/.dev.vars.example, ihnyc-rc-vote/README.md