Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ To send a Slack DM, pass the Slack user ID as `channel`: `send_slack(message=...

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.

## Volunteer applications (`volunteers` collection) — staff-owned fields
`create_or_update_volunteer` (`services/volunteers_service.py`) has exactly ONE production caller: `handle_submit` (`api/volunteers/volunteers_views.py`), backing only the self-service `/api/{mentor,judge,hacker,volunteer,sponsor}/application/<event_id>/{submit,update}` routes. It still persists the whole `volunteer_data` dict (no allowlist — new form fields flow through for free) **except** `STAFF_OWNED_VOLUNTEER_FIELDS`, which is stripped from the payload once, before the create/update branch. That covers both paths: on update `set(merge=True)` omits the keys so stored values survive; on create it stops a payload from overriding the `isSelected: False` seed to self-approve. The set covers approval (`isSelected`), check-in (`isCheckedIn`/`checkedIn`/`checkInTime`/`checkInTimeList`/`checkOutTime`/`checkoutTimeList`), refund bookkeeping (`deposit_status`, `deposit_refund_*`), `certificates` and `sent_emails`. **Deliberately NOT in the set:** `stripe_payment_intent_id`/`deposit_amount_cents`/`deposit_disposition` — the hacker Stripe Checkout return legitimately writes those via `/update`; adding them breaks deposits. The bug that motivated this (Aug 2026): all five frontend forms shipped `isSelected: false` from their `initialFormData` (sponsor hardcoded it), and the old guard only preserved the stored flag when the key was *absent* — so every application edit silently un-approved an approved mentor/judge/volunteer/sponsor. The same strip also closes a **privilege-escalation hole that was reachable**: before this, ANY logged-in user could POST `isSelected: true` to `/submit` and land an already-approved mentor or judge doc (the create path seeds `isSelected: False` then does `volunteer_doc.update(volunteer_data)`), granting `MentorTeamPanel` write access via `user_is_mentor_for_event` and survey-trust via `get_user_event_roles`. The submit/update routes were also `@auth.optional_user` and `handle_submit` had an `elif 'user_id' in volunteer_data` identity fallback — but the *anonymous* variant was NOT actually exploitable: `send_slack_audit` interpolates `user.user_id` before that fallback, and PropelAuth's `LoggedOutUser` has no `user_id`, so an unauthenticated POST raised AttributeError → caught → 400. That's an accident, not a control, so the routes are now `@auth.require_user` and `handle_submit` takes identity from the token ONLY — never a body `user_id`. Approval changes only via `update_volunteer_selection` (`POST /api/admin/volunteer/<volunteer_id>/select`). Regression tests: `api/volunteers/tests/test_volunteers_service.py`. **Identity resolution is shared:** `find_volunteer_by_caller_identity(propel_user_id, event_id, volunteer_type)` (`services/volunteers_service.py`) is THE 3-way resolver — propel UUID → PropelAuth email → OAuth `user_id` — used by `handle_get` (the GET route), `create_or_update_volunteer` (the write path), and `api/mentors/mentors_service.py::_find_mentor_volunteer` (delegates). Keeping read and write on the same resolver is load-bearing: when the write path matched propel UUID only, a user whose doc was stored under another identity shape saw their app on read, edited it, missed the write lookup, and fell into the CREATE branch — spawning a duplicate `isSelected: False` doc and orphaning the approved one. The email step uses the **verified PropelAuth email only** — never the form-payload email, which would let a caller hijack someone else's application by typing their address. Don't add a fourth copy of this lookup; delegate. (Surveys' `get_user_event_roles` is intentionally separate — it scans all volunteer_types in one pass.) **Notification gate:** `_notifications_disabled()` in `volunteers_service.py` suppresses the Slack/Resend fan-out (`send_admin_notification_email`, `send_slack_volunteer_notification`, `send_volunteer_confirmation_email`, `send_mentor_checkin_notification`) when `ENVIRONMENT=test` — before this, unit tests exercising `create_or_update_volunteer` posted REAL Slack messages and attempted REAL Resend sends. Mirror this gate on any new outbound-notification function in this service.

## Volunteer time tracking (`/api/users/volunteering`)
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.

Expand Down Expand Up @@ -175,6 +178,13 @@ Config store for the Slack praise-bot (repo `ohack-slack-bot/praise-bot`): the b
- `GET /api/praise-bot/config` — bot-facing, authed via `X-Api-Key` against `BACKEND_BOT_CONFIG_TOKEN` (falls back to `BACKEND_PRAISE_TOKEN`) through the shared `common/utils/api_key.py:check_api_key` (hmac.compare_digest; new code should use this instead of the inline checks in messages_views). Returns `configured: false` when 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 with `cron.validate()`); repos normalized to `owner/repo`; `global` is upsert-only (no DELETE); `community` is a singleton (POST 400s if one exists). `source.orgs` is 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 with `ENVIRONMENT=test`).

## Volunteer job board (`api/jobs/`, `job_listings` + `job_applications` collections, Aug 2026)
Powers the frontend's `/jobs` pages and `/admin/jobs`. Blueprint `api/jobs/jobs_views.py` + `jobs_service.py`.
- `job_listings` doc id = **slug** (immutable after create; POST 409s duplicates). Statuses draft|published|hidden|closed — public list returns published+closed (lean fields), single-get 404s draft/hidden but returns closed (shared links render a closed panel). `posted_at` auto-stamped on first publish. 300s TTL caches (`get_public_listings`/`get_public_listing`) cleared on every admin write. Validators + `ALLOWED_JOB_*`/`JOB_LISTING_ADMIN_KEYS` constants live in `common/utils/validators.py`.
- `POST /api/jobs/<slug>/apply` is `@auth.require_user` + `@RateLimiter` + recaptcha (imports volunteers_service `verify_recaptcha`, keeps the `FLASK_ENV=development` bypass): validates via `validate_job_application` (visa_ack must be True, work sample ≥ 200 chars — keep in sync with the frontend's `MIN_WORK_SAMPLE_CHARS`), verifies `resume_url` is under the caller's own `job_applications/<db_id>/` CDN prefix and `video_url` is own-CDN (`users/<db_id>/`, the bio-video mint) or an `ALLOWED_VIDEO_LINK_HOSTS` link, then 409s if the user already applied to that listing. Resume mint `POST /api/jobs/apply/resume-upload-url` reuses `common/utils/cdn.generate_signed_upload_url` (PDF only, 10MB, resolves the user via users_service `_resolve_and_ensure_user`).
- Emails (Resend, all behind the local `_notifications_disabled()` mirror): applicant confirmation with the **reply-within-5-days responsiveness ask** (`reply_to: questions@ohack.org`), FYI to questions@ohack.org, and warm accept/reject decision emails via `POST /api/jobs/admin/applications/<id>/decision` (`{decision, personal_note?}`; records into `sent_emails` ArrayUnion + `status_history`). Admin routes are `volunteer.admin`-gated; application PATCH allowlist is `status`/`admin_notes` only.
- Seed: `scripts/seed_job_listings.py` (dry-run default, `--apply` writes the three Fall 2026 roles as drafts, **skips existing slugs** so admin edits survive re-runs; validates against `validate_job_listing` so seed/validator drift fails loudly).

## Public portfolio (profile → portfolio, Aug 2026)

The public profile payload (`GET /api/users/<id_or_slug>/profile/public`) is now the "portfolio" payload. Load-bearing contracts:
Expand Down
2 changes: 2 additions & 0 deletions api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ def add_headers(response):
from api.surveys import surveys_views
from api.feedback import feedback_views
from api.praisebot import praisebot_views
from api.jobs import jobs_views

app.register_blueprint(messages_views.bp)
app.register_blueprint(exception_views.bp)
Expand All @@ -215,5 +216,6 @@ def add_headers(response):
app.register_blueprint(surveys_views.bp)
app.register_blueprint(feedback_views.bp)
app.register_blueprint(praisebot_views.bp)
app.register_blueprint(jobs_views.bp)

return app
Empty file added api/jobs/__init__.py
Empty file.
Loading
Loading