diff --git a/CLAUDE.md b/CLAUDE.md
index db509fa..11e1a22 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -129,6 +129,9 @@ These are DIFFERENT VALUES. When bundling user data for the frontend, include bo
### Confirmation-email calendar attachments (availability parser)
`get_calendar_email_attachment_from_availability` (`services/volunteers_service.py`) runs on every application submit/update to build `.ics` attachments from `availability`. It only understands the machine-generated slot format the mentor/volunteer forms emit (`"Sunday, Oct 12: ☀️ Morning (9am - 12pm PST)"`). The **judge form's `availability` is a free-text field**, so the function has an early guard: if the string contains no `Weekday, Mon D` prefix it logs INFO and returns `[]`. Don't remove the guard or re-raise its logging to ERROR — that was the Aug 2026 Sentry noise ("CRITICAL: All patterns failed to match slot") firing on every judge application with availability text. Genuine structured-parse failures log a single WARNING per slot; the per-pattern cascade logs are DEBUG. Tests: `api/volunteers/tests/test_volunteers_service.py` (free-text skip + structured regression).
+## Hackathon requests (`/hack/request` → `hackathon_requests` collection)
+`create_hackathon` / `update_hackathon_request` (`services/hackathons_service.py`) pass the full form payload to `send_hackathon_request_email(..., request_data=json)`, which renders a "Your Submission" table in the confirmation email via `_render_request_summary_html`. Field labels/orders live in `_REQUEST_SUMMARY_FIELDS` (+ `_RESPONSIBILITY_LABELS`, `_NONPROFIT_SOURCE_LABELS` — the latter mirrors the frontend form's checkbox labels; keep in sync if `HackathonRequestForm.js` options change). All values are HTML-escaped (user input into email HTML); empty fields, `donationPercentage: 0`, and internal keys (`status`, `created`, agreements) are skipped. Tests: `api/messages/tests/test_hackathon_requests.py`.
+
## 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: `` 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.
diff --git a/api/messages/tests/test_hackathon_requests.py b/api/messages/tests/test_hackathon_requests.py
index 36c3972..bc74946 100644
--- a/api/messages/tests/test_hackathon_requests.py
+++ b/api/messages/tests/test_hackathon_requests.py
@@ -13,6 +13,7 @@
get_hackathon_request_by_id,
create_hackathon,
update_hackathon_request,
+ _render_request_summary_html,
)
@@ -322,6 +323,8 @@ def test_sends_confirmation_email(self, mock_db, mock_slack_audit, mock_email, m
call_args = mock_email.call_args[0]
assert call_args[0] == "Diana"
assert call_args[1] == "diana@example.com"
+ # The full form payload is passed through so the email can include it
+ assert mock_email.call_args[1]["request_data"]["companyName"] == "Email Corp"
@patch('services.hackathons_service.send_slack')
@patch('services.hackathons_service.send_slack_audit')
@@ -338,3 +341,62 @@ def test_skips_email_without_contact_info(self, mock_db, mock_slack_audit, mock_
with patch('services.hackathons_service.send_hackathon_request_email') as mock_email:
create_hackathon(payload)
mock_email.assert_not_called()
+
+
+class TestRenderRequestSummaryHtml:
+ """Test cases for the submission summary embedded in the confirmation email."""
+
+ def test_renders_submitted_fields_with_labels(self):
+ html = _render_request_summary_html({
+ "companyName": "ASU Coding Club",
+ "organizationType": "university",
+ "eventFormat": "in-person",
+ "participantType": ["students", "industry-professionals"],
+ "budget": 15000,
+ "responsibilities": {"venue": "requestor", "judges": "shared"},
+ })
+ assert "Your Submission" in html
+ assert "ASU Coding Club" in html
+ assert "University" in html
+ assert "In Person" in html
+ assert "Students, Industry Professionals" in html
+ assert "$15,000" in html
+ assert "Venue & equipment: Your organization" in html
+ assert "Judges: Shared" in html
+
+ def test_escapes_html_in_user_values(self):
+ html = _render_request_summary_html({
+ "companyName": '',
+ })
+ assert "