This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
- Install dependencies:
pip install -r requirements.txt - Run the app:
flask run(orgunicorn api.wsgi:app --log-file=- --log-level debug --preload --workers 1) - Run tests:
pytest - Run a single test:
pytest path/to/test_file.py::test_function_name - Run linting:
pylint api/ common/ db/ model/ services/ - Python environment: Miniconda with
conda activate py39_ohack_backend(Python 3.9.13)
A note about this codebase: This was originally taken from Auth0 and messages_views.py and messages_service.py were the original files we built from. Over time we have created a better structure for this backend service. Please do not update these two files anymore, you should consider the other code that exists first, and then add a new _views.py and a new _service.py if you're unable to find a good place to update the code as it relates to the file. If you find code in these two "messages" files that you plan to update, it's encouraged to migrate that code elsewhere to help iteratively move away from messages_views and messages_service.
The app is created via api/__init__.py:create_app(). Each API domain is a Blueprint registered there. The WSGI entrypoint is api/wsgi.py.
Each API domain lives in api/<domain>/ with a consistent structure:
<domain>_views.py— Flask Blueprint with route definitions. Routes use@cross_origin()and PropelAuth's@auth.require_userfor authentication.<domain>_service.py— Business logic called by views. Some older services live inservices/at the top level (hearts, users, volunteers, etc.).tests/— Tests for that domain.
API domains: messages, certificates, contact, github, hearts, judging, leaderboard, llm, newsletters, problemstatements, slack, store, teams, users, validate, volunteers.
db/interface.py— AbstractDatabaseInterfacebase class.db/firestore.py— Production implementation using Firebase Firestore.db/mem.py— In-memory implementation (enabled viaIN_MEMORY_DATABASE=Trueenv var).db/db.py— Singleton that selects the implementation and re-exports all DB operations as module-level functions. All service code imports fromdb.db.
Data classes (User, Hackathon, Nonprofit, ProblemStatement, JudgeAssignment, JudgeScore, JudgePanel, etc.) with serialize()/deserialize() methods for Firestore document conversion.
PropelAuth via common/auth.py. Routes get the authenticated user from @auth.require_user and org context from the X-Org-Id header.
common/utils/redis_cache.py — Redis with TTLCache fallback. Used via @cached_with_key() decorator.
common/log.py — Structured JSON logging with get_logger(), info(), debug(), warning(), error(), exception() helpers. Supports colored console output.
FLASK_APP=api, FLASK_RUN_PORT=6060, CLIENT_ORIGIN_URL, FIREBASE_CERT_CONFIG, OPENAI_API_KEY, PROPEL_AUTH_KEY, PROPEL_AUTH_URL, REDIS_URL (optional), IN_MEMORY_DATABASE (optional), ENVIRONMENT=test (for MockFirestore).
- Hackathon attendance is derived from the
volunteerscollection viaservices.volunteers_service.get_user_hackathon_attendance(user_id, email)— hackers always count; mentors/judges/volunteers requireisSelected=TrueANDcheckInTime. The legacyusers.hackathonsFirestore-ref array is no longer the source of truth. - Public profile (
GET /api/users/<db_id>/profile/public) augments withhackathon_history(volunteer-derived),praises_count, andpraises_recent(top 3) when the corresponding privacy field is"public". - Privacy fields (
model.user.privacy_fields) includepraises, which defaults to"public"(others default toTruewhich is treated as private). - New public route:
GET /api/users/<db_id>/praises?limit=&offset=returns paginated received praises (403 when private).
Deployed to Fly.io (fly.toml, app: backend-ohack, region: sjc). Uses gunicorn (Procfile). Port 6060.
Dockerfile CMD --workers 1, default sync class) — a single slow request blocks the entire API. Fix plan (workers/threads, caching, Sentry error sweep, traffic evidence): docs/perf-reliability-plan-2026-06.md.
- Tests live in
api/<domain>/tests/ortest/at the repo root. - The app has heavy external dependencies (Firestore, OpenAI, PropelAuth, Slack, PIL). Tests must pre-mock these modules in
sys.modulesbefore importing service code. ENVIRONMENT=testenables MockFirestore in the DB layer.
- Python 3.9.13 (Flask backend)
- Imports: Group standard library, then third-party, then local imports
- Types: Use type hints for function parameters and return values
- Naming: snake_case for variables/functions, PascalCase for classes
- Error handling: try/except with specific exceptions
- Linting: pylint (
.pylintrcdisables missing-module-docstring, missing-function-docstring, too-few-public-methods)
Backend runs on Python 3.9. def foo() -> X | None: raises TypeError at import time, blowing up every endpoint that imports the module. Use Optional[X] from typing. Audit any new services/ module before committing.
auth_user (current_user from propelauth_flask) wraps the full PropelAuth User class. The attributes are NOT what they look like:
user.org_id_to_org_member_info— dict of{org_id: OrgMemberInfo}. NOTorg_id_to_org_info(which silently returnsNonefromgetattr, leaving every admin check returningFalse).- Each
OrgMemberInfois an object, not a dict. Use.user_permissions(attribute) or.user_has_permission(perm)(method).org_info.get("user_permissions")always returnsNone.
Pattern for "is this user a global admin":
def is_admin(propel_user) -> bool:
if not propel_user or not getattr(propel_user, "user_id", None):
return False
for org_info in (getattr(propel_user, "org_id_to_org_member_info", None) or {}).values():
if org_info.user_has_permission("volunteer.admin"):
return True
return FalseFor most route protection, prefer the existing decorator: @auth.require_org_member_with_permission("volunteer.admin", req_to_org_id=getOrgId). Only roll your own check when combining multiple gates (per-resource editors list, etc.).
.where("X", "==", v).order_by("Y") (where Y != X) needs a composite index declared in firestore.indexes.json AND deployed via firebase deploy --only firestore:indexes. The local Firestore emulator silently allows these queries; production Firestore returns 500 with "The query requires an index" — the route just hangs/errors.
Two options:
- Sort in Python after a single-field where (preferred when the result set is small):
sorted([... for d in coll.where(...).stream()], key=lambda x: x["pos"]). No index needed because single-field equality is auto-indexed. - Add the composite index to
firestore.indexes.jsonAND deploy. Don't forget the deploy step — committing to the repo doesn't apply it.
ref.set({"some_map": {...}}, merge=True) does NOT replace some_map — it recursively merges, so a key you dropped from the Python dict stays in Firestore. To delete a nested key you must write firestore.DELETE_FIELD at that exact path: ref.set({"some_map": {key: firestore.DELETE_FIELD}}, merge=True). MockFirestore (test/ENVIRONMENT=test) REPLACES maps instead of deep-merging, so this passes locally and only breaks in prod. This caused the "mentor coverage cleared but stays checked" bug — toggle_mentor_coverage (api/mentors/mentors_service.py) popped the slug then wrote the dict, which never removed it. Pattern to copy is there now: write only the changed nested keys, DELETE_FIELD to remove. Mixing DELETE_FIELD sentinels and real values in the same nested map is allowed; DELETE_FIELD on a non-existent path is a no-op.
A user authenticated via PropelAuth may NOT exist in the Firestore users collection. The collection is populated lazily — only when someone hits GET /api/users/profile or saves profile metadata. Never assume fetch_users() includes everyone with a propel_user_id referenced elsewhere (assignees, editors, mentions, etc.).
When resolving propel_id → display profile, fall back to services.users_service.get_oauth_user_from_propel_user_id(pid) for IDs not in the cached fetch_users() index. Cache the fallback aggressively (5 min minimum) — PropelAuth API calls aren't free.
get_oauth_user_from_propel_user_id(propel_id) returns the raw OAuth userinfo. Provider-specific fields:
- Slack:
https://slack.com/user_id(e.g.UC31XTRT5) is the Slack workspace user ID.https://slack.com/user_image_192for avatar.emailalways present. - Google: no Slack ID.
picturefor avatar.emailalways present. - Detect by presence of
https://slack.com/user_id— Google responses don't have it.
To send a Slack DM, pass the Slack user ID as channel: send_slack(message=..., channel="UC31XTRT5"). chat.postMessage opens (or reuses) a DM channel.
User.id= Firestore document ID (used by/api/users/{id}/profile/publicand the frontend/profile/{id}route).User.user_id= PropelAuth user ID (thepropel_id, what's stored inassignees[],editors[], etc.).
These are DIFFERENT VALUES. When bundling user data for the frontend, include both: {user_id: propel_id, db_id: firestore_doc_id, name, profile_image}. The frontend needs db_id to build profile links and user_id (propel) for matching against assignees/editors/mentions.
GET/POST in api/users/users_views.py → services/users_service.py. Both resolve identity through _resolve_and_ensure_user(propel_id), in this order so a broken OAuth provider token can NEVER block volunteering: (1) fetch_user_by_propel_id(propel_id) — direct Firestore lookup on the stored propel_id field, NO external call (covers everyone who has saved a profile); (2) the OAuth provider round-trip (get_oauth_user_from_propel_user_id → sub → fetch_user_by_user_id), the best source for the OAuth-format user_id + avatar, lazily creating a doc for new users; (3) the PropelAuth user-metadata fallback (_fetch_propel_metadata → auth.fetch_user_metadata_by_user_id) — RELIABLE, does NOT depend on the provider token — which resolves an existing doc by email (backfilling propel_id) or lazily creates one from the metadata (user_id set to the propel UUID since we lack the oauth-format id without the provider call; propel_id is the canonical match so step 1 hits forever after). The bug this fixes: the WRITE used to depend SOLELY on step 2; when get_oauth_user_from_propel_user_id returns None (expired/unavailable provider token, PropelAuth hiccup, or its 5-min negative cache) the write 404'd ("Couldn't log that time") while the read masked it by returning empty. Critical: get_profile_metadata (which creates the doc) ALSO depends on the OAuth round-trip, so a user whose OAuth has always failed may have NO doc at all — step 3 (metadata) is what resolves/creates them. fetch_user_by_propel_id/fetch_user_by_email live in db/{db,firestore,mem}.py (single-field equality queries — auto-indexed, no composite index). Logging: get_oauth_user_from_propel_user_id now logs the PropelAuth response BODY (truncated) on non-200 and a debug line when serving a cached miss — previously the root cause (e.g. "no linked OAuth connection", wrong PROPEL_AUTH_URL/KEY) was invisible during a tight retry window. Tests: api/users/tests/test_volunteer_resolve.py (6 cases). NOTE — date/locale is NOT a factor: <input type=date> always yields an ISO yyyy-MM-dd value regardless of the user's locale. get_volunteering_time now returns ([], 0, 0) (never None/404) so the page shows a clean zero-state, and filters in a SINGLE pass — an entry may carry commitmentHours, finalHours, or BOTH (manual logs send both), no concat/duplicate. save_volunteering_time accepts an optional timestamp (backdated manual logs) + manual:true flag; hours are float-cleaned, non-negative, capped at 1000.
Powers the frontend /admin/communication template editor and the volunteer send-email dialogs. Blueprint: api/email_templates/email_templates_views.py (/api/admin/templates*, all volunteer.admin-gated); service: services/email_templates_service.py; seed data: services/email_templates_seed.py.
- Layout:
email_templates/{slug}main doc +email_templates/{slug}/versions/{000N}append-only content snapshots.versionon the main doc = current version number; version doc ids are zero-padded for natural ordering. - Versioning rules: content-key edits (
title/category/category_key/applicable_roles/message/icon) bump version + append a snapshot; status-only patches don't bump; revert never rewrites history — it copies the target version's content forward as a NEW version (change_note: "Reverted to version N"). - Seeding:
email_templates_seed.pyholds the original 22 hardcoded frontend templates (GENERATED from frontendsrc/lib/messageTemplates.js— regenerate, don't hand-edit; editing it does not change live emails). Auto-seeds on first list call when the collection is empty;POST /api/admin/templates/seedre-inserts missing seed docs only — never overwrites admin edits. - DELETE is a hard delete (doc + versions). Deleted seed templates can be restored via /seed (history restarts at v1).
Two scripts live in scripts/ for diagnosing and backfilling team rosters on /hack/<event_id>:
audit_hackathon_team_users.py --event-id <id>(read-only) — walkshackathons/{id}.teams[] -> teams/{id}.users[]and reports per-team member counts, dangling refs (team points to deleted user doc), and "ghost" users (no name + no propel_id = imported but never logged in).import_hackathon_users_from_csv.py --csv <path> --event-id <id> --csv-type {registrants|projects|roster} [--apply]— dry-run by default.projectsparses Devpost projects CSVs; the team-member triplet offset is resolved by header lookup (Team Member 1 First Name), since old 23-col exports have no "Team Number" column while newer 24-col ones do. Each parsed "email" is validated with the email regex — rows where the triplet shifted off-axis are skipped with a warning rather than written as bogus user docs.rosterparses a genericteam,email[,first_name,last_name,name]CSV for backfilling memberships;registrantsjust seeds user docs. Users are matched byemail_address(case-insensitive). Imported users getimported=True,import_source,import_event_id, blankuser_id/propel_id. Team membership writes are additive — never removes existing members. Re-runnable.cleanup_bogus_imported_users.py [--event-id <id>] [--apply]— finds and removes the user docs left behind by the older off-by-oneparse_projectsbug. Fingerprint:imported=TrueANDpropel_id=""ANDemail_addresspresent but not a valid email ANDimport_sourcestarts withprojects-. For each matched user it prunes the doc-ref from every team'susers[]that references it, then deletes the user doc. Dry-run by default. After running, re-runimport_hackathon_users_from_csv.py --csv-type projectsagainst the affected events to import the real members.backfill_devpost_winners.py --event-id <id> --devpost-url <url> [--projects-csv <path>] [--apply]— scrapes the Devpost project gallery for EVERY project tile, flagging winners (aside.entry-badge img.winner). For each project it matches to a Firestore team via a layered strategy:teams.devpost_linkexact-URL → team name (case-insensitive) → email-overlap via Devpost projects CSV (auto-discovered from/tmp/devpost_files/<event_id>/projects-*.csv). Two backfills happen in one pass: (1) any matched team with an emptydevpost_linkgets the gallery URL written; (2) matched WINNERS additionally get/software/<slug>fetched for prize text + member names, with prize strings mapped to status — "1st place" →FOUNDING_ENGINEERS, "Completion" or "2nd place" →COMPLETION_SUPPORT, anything else marked Winner →CATEGORY_WINNER(rank-based; multi-prize teams get the best status, all prize text retained inawards: []). Conflicts (team already has a differentdevpost_link) are logged but never overwritten. Unmatched winners exit with code 2 so a human notices; unmatched non-winners are listed for visibility but don't fail the run (typical for teams that registered only on Devpost). Only setsstatus,awards,winners_backfilled_at/source, anddevpost_link; never touchesusers[]. Re-runnable. Addsbeautifulsoup4to requirements.
GET /emails (list) supports ONLY limit (max 100)/after/before — no recipient or date filter; results newest-first. Per-recipient status = Emails.get(id) using the resend_id stored in volunteer sent_emails, or webhooks. Rate limit is low single-digit req/s per team and shared with email sending.
- Primary path:
GET /api/admin/emails/resend-status— per-IDEmails.getwith Redis cache (resend:status:{id}). Terminal events (delivered,bounced,complained, etc.) TTL 7 days; transient (sent,queued, etc.) TTL 120 s. Cap 100 IDs, 0.3 s throttle between calls. - List crawl:
POST /api/admin/emails/resend-list— never blocks on page load. Always returns from cache immediately. Passforce: trueto kick a background crawl. Date-bounded to 90 days; max 30 pages; 0.5 s between pages. Cache TTL 15 min fresh / 1 hour stale. - Admin UI: on volunteer load, bulk-fetches IDs from
sent_emailsvia the status endpoint. "Sync from Resend" button sendsforce: trueand shows a 30 s snackbar if the sync is still running. - Confirmation email tracking:
send_volunteer_confirmation_emailnow acceptsvolunteer_idand appends asent_emailsrecord withrecipient_type: 'application_confirmation'after sending.
scripts/sync_resend_audience.py --source {all|profiles|volunteers|mentors|judges|sponsors|helpers|leads} --audience "<name>" [--event-id <id>] [--selected-only] [--apply] — pulls emails from Firestore (users.email_address, volunteers.email filtered by volunteer_type, leads.email) and upserts contacts into a Resend audience (creates if missing). Dry-run by default. Re-runnable: lists existing audience contacts first and only POSTs new emails. Needs RESEND_API_KEY with audiences scope — the existing RESEND_WELCOME_EMAIL_KEY is send-only and will 401. Uses the deprecated resend.Audiences SDK class (now an alias for Segments) — fine for now, but if it breaks switch to resend.Segments.
The frontend /hack/<event_id> page's "Team Members:" list is teams.users[] (DocumentReferences). The bug pattern that motivated this: a team's users[] only contains the user who created the team on ohack.dev; everyone else registered via Devpost/JotForm and was never linked. Use audit first to confirm, then import ... --csv-type roster (or projects for old Devpost exports) to backfill.
Post-event / live-event feedback, stored in a NEW surveys collection — deliberately distinct from the peer-to-peer feedback collection. Blueprint api/surveys/surveys_views.py + services in api/surveys/surveys_service.py. Routes (/api/surveys/<event_id>/...):
GET context— public (@auth.optional_user). Returnsmode(live|post|upcoming), the caller's eligibleroles,primary_role,requires_captcha,already_submitted, and a lighteventblock.POST responses— public (@auth.optional_user). Logged-in volunteers who are eligible for the event are "trusted" and skip CAPTCHA; everyone else (nonprofit partners — no flag yet — and anonymous) must pass reCAPTCHA via the sharedverify_recaptchafromapi.contact.contact_service.GET responses/GET summary(per-event) andGET /api/surveys/overview(cross-event) —volunteer.admin-gated.overviewis one scan of thesurveyscollection grouped byevent_id(get_cross_event_survey_overview): per-eventcount/by_mode/by_role, averages of the two universal scales (overall_rating,would_return),first/last_response, joined with hackathontitle/dates/tz (lazy-importget_hackathon_list). Aggregates only — no per-response data, no PII. Powers the "Compare events" sub-view; static/surveys/overviewdoesn't collide with/surveys/<event_id>/....
compute_event_mode is timezone-aware off start_date/end_date (mirrors the frontend isHackathonExpired; missing dates → live). Eligible roles come from the volunteers collection via get_user_event_roles — hacker counts on application, mentor/judge/volunteer/sponsor require isSelected — matched 3 ways (propel UUID / email / OAuth user_id) like handle_get. Role scope: a trusted volunteer may submit ONLY for a role they're selected for; everyone else is restricted to nonprofit (allowed_roles_for). Enforced in submit_survey_response (403 on mismatch) and surfaced as allowed_roles in the context response so the frontend can scope its selector. Logged-in responses upsert by doc id {event_id}__{mode}__{propel_user_id} with a full set() (NOT merge=True, which would deep-merge the answers map and keep cleared keys) carrying created_at forward; anonymous responses are random-uuid docs. Heavy/external deps (get_hackathon_by_event_id, verify_recaptcha, slack) are lazy-imported inside functions so the module imports cheaply (testable; pure date/mode logic covered in api/surveys/tests/). Question IDs/catalog live frontend-side; backend stores answers verbatim. Live-mode submissions ping the #feedback Slack channel; post-mode is audit-only.
Read-only admin aggregation for the frontend /admin/feedback dashboard. New blueprint api/feedback/feedback_views.py (NOT messages_views — frozen) + api/feedback/feedback_service.py (the admin READ side; writes still live in services/feedback_service.py + services/onboarding_service.py + the surveys domain). Both routes volunteer.admin-gated, ?limit= (default 500, cap 2000):
GET /api/admin/feedback/peer— peer-to-peerfeedbackcollection, newest first, giver/receiver names resolved via a one-shotfetch_users()directory (giver hidden whenis_anonymous); lightby_relationship/by_rolesummary.GET /api/admin/feedback/onboarding—onboarding_feedbackscollection, newest first, + rating & ease distributions. KeepsclientInfo.userAgent, drops the IP. Field shape (load-bearing for the admin UI): mapscontactForFollowup→contact: {willing, firstName, email}(the form storesfirstName+willing, NOTname— don't revert toname);overallRating0 = unrated (skipped in the average); timestamps are normalized to ISO, stripping a__Timestamp__export sentinel (seescripts/sync_hackathons_from_csv.py) if present. Event surveys reuse theapi/surveysadmin routes (/responses,/summary,/overview) — not duplicated here.
Config store for the Slack praise-bot (repo ohack-slack-bot/praise-bot): the bot polls it every ~60s so channels/repos/crons/toggles are managed from /admin/praise-bot instead of Fly.io env vars. Blueprint api/praisebot/praisebot_views.py + praisebot_service.py.
- Doc types in one collection: fixed doc id
global(dry_run/llm_enabled/timezone),github_watcher(sourcemode: hackathon|repos, digest + optional rollup crons),calendar_reminder, and singletoncommunity(intro matchmaker + weekly digest). Audit fieldscreated_at/updated_at/updated_byon every doc. GET /api/praise-bot/config— bot-facing, authed viaX-Api-KeyagainstBACKEND_BOT_CONFIG_TOKEN(falls back toBACKEND_PRAISE_TOKEN) through the sharedcommon/utils/api_key.py:check_api_key(hmac.compare_digest; new code should use this instead of the inline checks in messages_views). Returnsconfigured: falsewhen the collection is empty → bot uses its env defaults.GET/POST /api/praise-bot/admin/config,PATCH/DELETE /api/praise-bot/admin/config/<doc_id>—volunteer.admin-gated. Validation is whitelist-per-type (_ALLOWED_KEYS— the enforcement point that keeps secrets out of docs); crons validated as 5 fields (bot re-validates withcron.validate()); repos normalized toowner/repo;globalis upsert-only (no DELETE);communityis a singleton (POST 400s if one exists).source.orgsis accepted/stored but ignored by the bot until org-watching ships. 15s TTL cache on the assembled config, cleared on mutation. Tests:api/praisebot/tests/(mockfirestore, run withENVIRONMENT=test).