Operator Handbook

Reference

Reference

Lookup tables. Everything here was read off the code rather than remembered — but code moves, so treat a surprise as a reason to check the source, not as a fact.

Environment variables

Variable Read by Default Notes
DATABASE_PATH backend ./data/app.db Must point at the mounted volume in production
JWT_SECRET backend change-me-in-production Changing it logs every student out
ADMIN_TOKEN backend (empty) The admin password. Compared as a plain bearer token
PORT backend 3000 Injected by Railway; do not set there
CORS_ORIGIN backend http://localhost:5173 Also the base URL for password-reset links and new-question emails
LOG_LEVEL backend info Fastify log level
YOUTUBE_API_KEY backend (none) YouTube endpoints return 503 without it
RESEND_API_KEY backend (none) Without it, reset links and new-question emails are logged instead of sent
EMAIL_FROM backend Sender for all outgoing email
VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY backend (none) Without both, /api/push/vapid-public-key returns 503 and push sends are skipped/logged. Generate with npx web-push generate-vapid-keys
VAPID_SUBJECT backend mailto:admin@example.com Contact address sent to push services with each notification
VITE_API_URL frontend (empty) Build-time only. Empty uses the dev proxy

Note: .env.example also contains FINANCIAL_MODELLING_PREP_API_KEY. No code reads it — it is a leftover from an SMI data provider that did not work out. Safe to delete.

Public endpoints

No authentication.

Method Path Purpose
GET /health Returns {"status":"ok"}. Used by Docker and Railway health checks
POST /api/auth/register {pseudonym, email, password, consent} → JWT (30-day expiry). Pseudonym is 3–30 chars, letters/digits/hyphen/underscore; password min 6; consent must be true
POST /api/auth/login {identifier, password} → JWT. identifier matches pseudonym or email
POST /api/auth/forgot-password {email} → always a generic 200, to prevent user enumeration
POST /api/auth/reset-password {token, password}. Tokens are single-use and expire after 1 hour

Student endpoints

Require Authorization: Bearer <jwt>.

Method Path Purpose
GET /api/me Own profile and settings
PATCH /api/me/settings Update email_notifications
GET /api/questions Open questions (deadline > now and published_at <= now), with the caller's own vote
POST /api/votes {question_id, choice} where choice is "A" or "B"
GET /api/history Past questions with the caller's own vote and result

Vote submission fails with:

Status Reason
404 Question does not exist
409 Deadline passed, question already resolved, or already voted

Every one of these endpoints joins votes for the authenticated user only. No student can see another student's vote through the API.

Admin endpoints

Require Authorization: Bearer <ADMIN_TOKEN>. A wrong or missing token gives 403.

Method Path Purpose
GET /admin/questions All questions with vote counts
POST /admin/questions Create a question manually
POST /admin/questions/:id/resolve {ground_truth} — sets truth and scores all votes. 409 if already resolved
DELETE /admin/questions/:id Deletes the question, its votes, and any YouTube suggestion row
GET /admin/questions/:id/votes Per-question vote list with pseudonyms
GET /admin/export All votes. ?format=csv for CSV, otherwise JSON
GET /admin/youtube/suggest Today's video pair. ?refresh=true discards the cached one
POST /admin/youtube/approve Publishes today's pending suggestion on the daily anchors
POST /admin/youtube/resolve The race tick: snapshot baselines, close finished races
POST /admin/smi/daily Create today's SMI question
POST /admin/smi/resolve Resolve expired SMI questions
POST /admin/notifications/dispatch Email opted-in participants about newly published questions

POST /admin/questions body:

{
  "title": "string, 1-200 chars",
  "description": "string, required",
  "option_a": "string, required",
  "option_b": "string, required",
  "image_url": "string, optional",
  "deadline": "ISO-8601 date-time, required"
}

Database tables

SQLite in WAL mode, foreign keys on.

usersid, pseudonym (unique), email (unique), password_hash (bcrypt), email_notifications, created_at.

password_resetsid, user_id, token_hash (SHA-256 of the emailed token), expires_at, used_at, created_at.

questionsid, title, description, option_a, option_b, image_url, deadline, resolved_at, ground_truth ('A'/'B'/null), created_at, plus option_a_image, option_b_image, option_a_views, option_b_views, published_at, race_starts_at, race_ends_at, notified_at.

votesid, user_id, question_id, choice ('A'/'B'), is_correct (null until resolved, then 0/1), voted_at. Unique on (user_id, question_id), which is what makes voting one-shot.

smi_questionsid, question_date (unique), question_id, prev_close, prev_date, created_at.

youtube_suggestionsid, suggested_date (unique), per-video *_id, *_title, *_channel, *_thumbnail, *_subscribers, *_published_at, *_views, then approved, question_id, and the race snapshots race_start_views_a/b, race_start_at, race_end_views_a/b, race_end_at.

Schema changes are additive ALTER TABLE statements in apps/backend/src/db/migrate.ts, wrapped in try/catch and re-run on every start. There is no migration versioning — adding a column means appending to that list.

Export CSV columns

question_id, deadline, pseudonym, question_title, option_a, option_b,
ground_truth, user_vote, is_correct, voted_at

Ordered by deadline, then question_id, then pseudonym. Unresolved questions appear with empty ground_truth and is_correct.

Frontend routes

Path Page
/register, /login Account creation and sign-in
/forgot-password, /reset-password Password recovery
/today Voting
/history Own past votes and results
/settings Email notification preference, theme
/admin/login Admin token entry
/admin/questions Admin dashboard (/admin redirects here)

Commands

# Development
docker compose up --build                    # both services, hot reload
docker compose exec backend npm run seed     # test data
docker compose down -v                       # stop and wipe the database

npm run dev                                  # local, without Docker
npm run seed

# Quality
npm run test                                 # all workspaces
npm run test --workspace=apps/backend
npx vitest run apps/backend/src/test/auth.test.ts    # single file
npm run lint
npm run format

# Docs site
cd docs-site && npm install && npm run build
npm run serve                                # preview on :4180
npm run dev                                  # rebuild on save

# Analysis
cd analysis && pip install -r requirements.txt
python test_expert_algorithm.py
python run.py votes.csv --sweep

Source map

Where to look when the answer is not in this handbook.

Question File
What are the daily anchors? apps/backend/src/services/schedule.ts
How does the SMI job work? apps/backend/src/services/smiService.ts
How does the YouTube race work? apps/backend/src/services/youtubeResolver.ts
Who gets a new-question email, and when? apps/backend/src/services/notifications.ts
What is the database schema? apps/backend/src/db/migrate.ts
How does admin auth work? apps/backend/src/plugins/authenticate.ts
What does the admin UI do? apps/frontend/src/pages/AdminQuestions.tsx
Colours, spacing, fonts apps/frontend/src/tokens.css
The Expert Algorithm itself analysis/expert_algorithm.py, docs/algorithm.md
Architecture rationale docs/architecture.md
Extending the app docs/extending.md

ETH Zurich — Expert Algorithm voting app. Generated from docs-site/content/.