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
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: `<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
62 changes: 62 additions & 0 deletions api/messages/tests/test_hackathon_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
get_hackathon_request_by_id,
create_hackathon,
update_hackathon_request,
_render_request_summary_html,
)


Expand Down Expand Up @@ -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')
Expand All @@ -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 &amp; equipment: Your organization" in html
assert "Judges: Shared" in html

def test_escapes_html_in_user_values(self):
html = _render_request_summary_html({
"companyName": '<script>alert("x")</script>',
})
assert "<script>" not in html
assert "&lt;script&gt;" in html

def test_skips_empty_fields_and_internal_keys(self):
html = _render_request_summary_html({
"companyName": "Acme",
"contactPhone": "",
"alternateDate": None,
"nonprofitSource": [],
"donationPercentage": 0,
"status": "pending",
"agreeToContact": True,
})
assert "Contact phone" not in html
assert "Alternate call date" not in html
assert "Donation percentage" not in html
assert "pending" not in html
assert "agree" not in html.lower()

def test_empty_or_missing_data_renders_nothing(self):
assert _render_request_summary_html(None) == ""
assert _render_request_summary_html({}) == ""
assert _render_request_summary_html("not-a-dict") == ""

def test_custom_theme_and_dates_humanized(self):
html = _render_request_summary_html({
"hackathonTheme": "custom",
"customTheme": "AI for accessibility",
"expectedHackathonDate": "2027-02-20T00:00:00.000Z",
})
assert "Custom — AI for accessibility" in html
assert "February" in html and "2027" in html
151 changes: 148 additions & 3 deletions services/hackathons_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -822,9 +822,151 @@ def update_hackathon_volunteers(event_id, volunteer_type, json, propel_id):


from services.email_service import add_utm
from html import escape as html_escape


# Labels mirror the frontend admin view (HackathonRequestDetailDialog.js) so
# the applicant's copy of their submission reads the same as what we review.
_REQUEST_SUMMARY_FIELDS = [
("companyName", "Organization"),
("organizationType", "Organization type"),
("contactName", "Contact name"),
("contactEmail", "Contact email"),
("contactPhone", "Contact phone"),
("employeeCount", "Expected participants"),
("participantType", "Participant types"),
("hackathonTheme", "Theme"),
("expectedHackathonDate", "Expected hackathon date"),
("preferredDate", "Preferred call date"),
("alternateDate", "Alternate call date"),
("location", "Location"),
("eventFormat", "Event format"),
("hasNonprofitList", "Has a nonprofit list"),
("nonprofitDetails", "Nonprofit details"),
("hasWorkedWithNonprofitsBefore", "Worked with nonprofits before"),
("nonprofitSource", "Nonprofit sources"),
("preferredNonprofitLocation", "Preferred nonprofit location"),
("specificRegion", "Specific region"),
("budget", "Budget"),
("donationPercentage", "Donation percentage"),
("additionalInfo", "Additional information"),
]

_RESPONSIBILITY_LABELS = {
"venue": "Venue & equipment",
"food": "Food & refreshments",
"prizes": "Prizes & swag",
"judges": "Judges",
"mentors": "Technical mentors",
"marketing": "Marketing & communications",
"nonprofitRecruitment": "Nonprofit recruitment",
"participantRecruitment": "Participant recruitment",
"postEventSupport": "Post-event support",
}

_RESPONSIBILITY_OWNER_LABELS = {
"requestor": "Your organization",
"ohack": "Opportunity Hack",
"shared": "Shared",
}

# Mirrors the checkbox labels on the /hack/request form.
_NONPROFIT_SOURCE_LABELS = {
"own-list": "We'll provide our own nonprofit partners",
"ohack-support": "We'd like Opportunity Hack to help identify nonprofits",
"open-call": "We'd like to do an open call for nonprofit applications",
}


def _humanize_request_value(key, value, request_data):
"""Format one hackathon-request field for the confirmation email. Returns
a display string, or None to skip the row."""
if value is None or value == "" or value == []:
return None
if key == "hackathonTheme" and value == "custom":
custom = request_data.get("customTheme")
return f"Custom — {custom}" if custom else "Custom"
if key == "budget":
try:
return f"${float(value):,.0f}"
except (TypeError, ValueError):
return str(value)
if key == "donationPercentage":
try:
return f"{value}%" if float(value) > 0 else None
except (TypeError, ValueError):
return str(value)
if key in ("expectedHackathonDate", "preferredDate", "alternateDate"):
try:
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
return parsed.strftime("%A, %B %-d, %Y")
except ValueError:
return str(value)
if key == "nonprofitSource" and isinstance(value, list):
return "; ".join(_NONPROFIT_SOURCE_LABELS.get(v, str(v)) for v in value)
if isinstance(value, list):
return ", ".join(str(v).replace("-", " ").title() for v in value)
if isinstance(value, bool):
return "Yes" if value else "No"
if isinstance(value, str) and key in (
"organizationType",
"eventFormat",
"hasNonprofitList",
"hasWorkedWithNonprofitsBefore",
"preferredNonprofitLocation",
):
return value.replace("-", " ").title()
return str(value)


def _render_request_summary_html(request_data):
"""Render the submitted form contents as an HTML table for the
confirmation email. Returns "" when there's nothing to show."""
if not isinstance(request_data, dict) or not request_data:
return ""

rows = []
for key, label in _REQUEST_SUMMARY_FIELDS:
display = _humanize_request_value(key, request_data.get(key), request_data)
if display is None:
continue
rows.append(
f'<tr><td style="padding: 6px 12px 6px 0; color: #555; vertical-align: top; white-space: nowrap;">{html_escape(label)}</td>'
f'<td style="padding: 6px 0; color: #333;">{html_escape(display)}</td></tr>'
)

responsibilities = request_data.get("responsibilities")
if isinstance(responsibilities, dict):
resp_rows = []
for resp_key, resp_label in _RESPONSIBILITY_LABELS.items():
owner = responsibilities.get(resp_key)
if not owner:
continue
owner_label = _RESPONSIBILITY_OWNER_LABELS.get(owner, str(owner))
resp_rows.append(
f"{html_escape(resp_label)}: {html_escape(owner_label)}"
)
if resp_rows:
rows.append(
'<tr><td style="padding: 6px 12px 6px 0; color: #555; vertical-align: top; white-space: nowrap;">Responsibilities</td>'
f'<td style="padding: 6px 0; color: #333;">{"<br>".join(resp_rows)}</td></tr>'
)

if not rows:
return ""

def send_hackathon_request_email(contact_name, contact_email, request_id):
return f"""
<div style="background-color: #f7f7f7; padding: 15px; border-radius: 5px; margin: 20px 0;">
<h2 style="color: #0088FE; margin-top: 0;">Your Submission</h2>
<p style="margin-top: 0;">Here's a copy of what you sent us for your records:</p>
<table role="presentation" cellpadding="0" cellspacing="0" style="width: 100%; border-collapse: collapse; font-size: 14px;">
{''.join(rows)}
</table>
</div>
"""


def send_hackathon_request_email(contact_name, contact_email, request_id, request_data=None):
"""
Send a specialized confirmation email to someone who has submitted a hackathon request.
"""
Expand All @@ -839,6 +981,7 @@ def send_hackathon_request_email(contact_name, contact_email, request_id):

base_url = os.getenv("FRONTEND_URL", "https://www.ohack.dev")
edit_link = f"{base_url}/hack/request/{request_id}"
submission_summary_html = _render_request_summary_html(request_data)

html_content = f"""
<!DOCTYPE html>
Expand Down Expand Up @@ -866,6 +1009,8 @@ def send_hackathon_request_email(contact_name, contact_email, request_id):
</ol>
</div>

{submission_summary_html}

<p><strong>Need to make changes to your request?</strong><br>
You can <a href="{add_utm(edit_link, medium='email', campaign='hackathon_request', content=image_utm_content)}" style="color: #0088FE; font-weight: bold;">edit your request here</a> at any time.</p>

Expand Down Expand Up @@ -924,7 +1069,7 @@ def create_hackathon(json):
json["id"] = doc_id

if "contactEmail" in json and "contactName" in json:
send_hackathon_request_email(json["contactName"], json["contactEmail"], doc_id)
send_hackathon_request_email(json["contactName"], json["contactEmail"], doc_id, request_data=json)

send_slack(
message=":rocket: New Hackathon Request :rocket: with json: " + str(json), channel="log-hackathon-requests", icon_emoji=":rocket:")
Expand Down Expand Up @@ -956,7 +1101,7 @@ def update_hackathon_request(doc_id, json):
if doc:
doc_dict = doc.get().to_dict()
send_slack_audit(action="update_hackathon_request", message="Updating", payload=doc_dict)
send_hackathon_request_email(json["contactName"], json["contactEmail"], doc_id)
send_hackathon_request_email(json["contactName"], json["contactEmail"], doc_id, request_data=json)
doc_dict["updated"] = datetime.now().isoformat()

doc.update(json)
Expand Down
Loading