From b482c71fcea678af33aef09059d6770997b67cf7 Mon Sep 17 00:00:00 2001 From: Greg V <6913307+gregv@users.noreply.github.com> Date: Sun, 30 Aug 2026 07:14:45 -0700 Subject: [PATCH] Include submitted form contents in hackathon request confirmation email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /hack/request confirmation email was generic — recipients had no copy of what they submitted. Render an HTML-escaped 'Your Submission' table (labels mirror the admin HackathonRequestDetailDialog) in the email for both create and self-service update. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 3 + api/messages/tests/test_hackathon_requests.py | 62 +++++++ services/hackathons_service.py | 151 +++++++++++++++++- 3 files changed, 213 insertions(+), 3 deletions(-) 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 "