From d9e9a8618e2edb1c8360f31565346bb49a91b48b Mon Sep 17 00:00:00 2001 From: Greg V Date: Mon, 16 Feb 2026 08:22:09 -0700 Subject: [PATCH 1/4] Fix AttributeError on LoggedInUser missing first_name/last_name LoggedInUser from propelauth_flask only exposes user_id, not first_name/last_name. Use user_id for admin identification in audit logs and message tracking. Co-Authored-By: Claude Opus 4.6 --- services/volunteers_service.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/volunteers_service.py b/services/volunteers_service.py index 6d27193..7cd7f30 100644 --- a/services/volunteers_service.py +++ b/services/volunteers_service.py @@ -1708,7 +1708,7 @@ def send_volunteer_message( # Update volunteer document with message tracking try: - admin_full_name = f"{admin_user.first_name} {admin_user.last_name}" if admin_user else "Admin" + admin_full_name = f"Admin ({admin_user.user_id})" if admin_user else "Admin" email_subject = f"{subject} - Message from Opportunity Hack Team" message_record = { @@ -1847,7 +1847,7 @@ def send_email_to_address( # Enhanced Slack audit message from common.utils.slack import send_slack_audit message_preview = message[:100] + "..." if len(message) > 100 else message - admin_full_name = f"{admin_user.first_name} {admin_user.last_name}" if admin_user else "Admin" + admin_full_name = f"Admin ({admin_user.user_id})" if admin_user else "Admin" send_slack_audit( action="admin_send_email_to_address", From a55eb85501898568cb41f7ea4a7bd5a7f4743549 Mon Sep 17 00:00:00 2001 From: Greg V Date: Mon, 16 Feb 2026 14:11:18 -0700 Subject: [PATCH 2/4] Add server-side validation for hacker required screening questions - Update validate_hackathon_data to accept and validate hacker_required_questions in constraints - Add server-side validation of required question answers in create_or_update_volunteer for hacker applications Co-Authored-By: Claude Opus 4.6 --- common/utils/validators.py | 16 +++++++++++++++- services/volunteers_service.py | 27 ++++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/common/utils/validators.py b/common/utils/validators.py index c1917eb..a7cf9ce 100644 --- a/common/utils/validators.py +++ b/common/utils/validators.py @@ -101,7 +101,21 @@ def validate_hackathon_data(data): if not all(isinstance(constraints.get(k), int) for k in ["max_people_per_team", "max_teams_per_problem", "min_people_per_team"]): raise ValueError("Constraints must be integers") - # Add more specific validations as needed + # Validate hacker_required_questions if present + hacker_required_questions = constraints.get("hacker_required_questions", {}) + if hacker_required_questions: + questions = hacker_required_questions.get("questions", []) + if not isinstance(questions, list): + raise ValueError("hacker_required_questions.questions must be a list") + for i, q in enumerate(questions): + if not isinstance(q, dict): + raise ValueError(f"Question {i} must be an object") + if not isinstance(q.get("question"), str) or not q.get("question"): + raise ValueError(f"Question {i} must have a non-empty 'question' string") + if not isinstance(q.get("required_answer"), bool): + raise ValueError(f"Question {i} must have a boolean 'required_answer'") + if not isinstance(q.get("error"), str) or not q.get("error"): + raise ValueError(f"Question {i} must have a non-empty 'error' string") if __name__ == "__main__": # Simple tests diff --git a/services/volunteers_service.py b/services/volunteers_service.py index 7cd7f30..d38d7f8 100644 --- a/services/volunteers_service.py +++ b/services/volunteers_service.py @@ -720,7 +720,32 @@ def create_or_update_volunteer( if not verify_recaptcha(recaptcha_token): warning(logger, "reCAPTCHA verification failed", email=email) return {"error": "reCAPTCHA verification failed"} - + + # Validate required question answers for hacker applications + if volunteer_data.get('volunteer_type') == 'hacker': + submitted_answers = volunteer_data.get('requiredQuestionAnswers', []) + if submitted_answers: + try: + db_temp = get_db() + hackathon_docs = db_temp.collection('hackathons').where('event_id', '==', event_id).limit(1).stream() + hackathon_data = None + for doc in hackathon_docs: + hackathon_data = doc.to_dict() + break + + if hackathon_data: + questions = hackathon_data.get('constraints', {}).get('hacker_required_questions', {}).get('questions', []) + if questions: + if len(submitted_answers) != len(questions): + warning(logger, "Required question answer count mismatch", email=email, event_id=event_id) + return {"error": "Required question answers do not match the expected number of questions"} + for i, q in enumerate(questions): + if i >= len(submitted_answers) or submitted_answers[i] != q.get('required_answer'): + warning(logger, "Required question answer incorrect", email=email, event_id=event_id, question_index=i) + return {"error": q.get('error', 'You do not meet the eligibility requirements for this event.')} + except Exception as e: + exception(logger, "Error validating required questions", exc_info=e, email=email, event_id=event_id) + db = get_db() volunteer_type = volunteer_data.get('volunteer_type') From 277ff272c0f2464af9bb6b7ae3faf56056facd55 Mon Sep 17 00:00:00 2001 From: Greg V Date: Mon, 16 Feb 2026 22:42:29 -0700 Subject: [PATCH 3/4] Add timezone field to hackathon data with IANA validation Store an optional timezone (IANA format) on each hackathon document, defaulting to America/Phoenix for backward compatibility. Validate timezone values using zoneinfo.ZoneInfo when provided. Co-Authored-By: Claude Opus 4.6 --- api/messages/messages_service.py | 1 + common/utils/validators.py | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/api/messages/messages_service.py b/api/messages/messages_service.py index d7936c1..c1f8496 100644 --- a/api/messages/messages_service.py +++ b/api/messages/messages_service.py @@ -1686,6 +1686,7 @@ def save_hackathon(json_data, propel_id): "prize": "0", "swag": "0", }), + "timezone": json_data.get("timezone", "America/Phoenix"), "last_updated": firestore.SERVER_TIMESTAMP, "last_updated_by": propel_id, } diff --git a/common/utils/validators.py b/common/utils/validators.py index a7cf9ce..b5ab767 100644 --- a/common/utils/validators.py +++ b/common/utils/validators.py @@ -2,6 +2,7 @@ from urllib.parse import urlparse import logging from datetime import datetime +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError logger = logging.getLogger(__name__) @@ -96,6 +97,14 @@ def validate_hackathon_data(data): except ValueError as e: raise ValueError(f"Invalid date format: {str(e)}") + # Validate timezone if provided + timezone = data.get("timezone") + if timezone: + try: + ZoneInfo(timezone) + except (ZoneInfoNotFoundError, KeyError): + raise ValueError(f"Invalid timezone: {timezone}") + # Validate constraints constraints = data.get("constraints", {}) if not all(isinstance(constraints.get(k), int) for k in ["max_people_per_team", "max_teams_per_problem", "min_people_per_team"]): From 532ae5f96697b0073be437038b18faedb8c650a6 Mon Sep 17 00:00:00 2001 From: Greg V Date: Sun, 22 Feb 2026 07:53:28 -0700 Subject: [PATCH 4/4] [Admin] Get email status from resend instead of db --- api/volunteers/volunteers_views.py | 36 ++++++++- services/volunteers_service.py | 119 ++++++++++++++++++++++++----- 2 files changed, 135 insertions(+), 20 deletions(-) diff --git a/api/volunteers/volunteers_views.py b/api/volunteers/volunteers_views.py index 1cbb499..163b44e 100644 --- a/api/volunteers/volunteers_views.py +++ b/api/volunteers/volunteers_views.py @@ -16,6 +16,7 @@ mentor_checkout, send_volunteer_message, send_email_to_address, + get_resend_email_statuses, ) from common.auth import auth, auth_user @@ -593,6 +594,7 @@ def admin_send_email_to_address(): subject = request_data.get('subject', 'Message from Opportunity Hack Team') recipient_type = request_data.get('recipient_type', 'volunteer') name = request_data.get('name') + volunteer_id = request_data.get('volunteer_id') if not email: return _error_response("Email address is required", 400) @@ -609,7 +611,8 @@ def admin_send_email_to_address(): admin_user_id=auth_user.user_id, admin_user=auth_user, recipient_type=recipient_type, - name=name + name=name, + volunteer_id=volunteer_id ) if result['success']: @@ -622,3 +625,34 @@ def admin_send_email_to_address(): except Exception as e: logger.error("Error in admin_send_email_to_address: %s", str(e)) return _error_response(f"Failed to send email: {str(e)}") + + +@bp.route('/admin/emails/resend-status', methods=['POST']) +@auth.require_org_member_with_permission("volunteer.admin", req_to_org_id=getOrgId) +def admin_get_resend_email_statuses(): + """Admin endpoint to fetch delivery status for Resend email IDs.""" + try: + request_data = _process_request() + email_ids = request_data.get('email_ids', []) + + logger.info("Fetching Resend email statuses for %d email IDs", len(email_ids) if isinstance(email_ids, list) else 0) + logger.debug("Email IDs requested: %s", email_ids) + + if not email_ids or not isinstance(email_ids, list): + logger.warning("Invalid or missing email_ids in request: %s", email_ids) + return _error_response("email_ids array is required", 400) + + result = get_resend_email_statuses(email_ids) + + if result['success']: + logger.info("Successfully fetched statuses for %d email IDs", len(email_ids)) + logger.debug("Email status results: %s", result) + return _success_response(result, "Email statuses fetched successfully") + + logger.error("Failed to fetch email statuses. Error: %s", result.get('error', 'Unknown error')) + return _error_response(result.get('error', 'Unknown error'), 500) + + except Exception as e: + logger.error("Error in admin_get_resend_email_statuses: %s", str(e)) + logger.exception(e) + return _error_response(f"Failed to fetch email statuses: {str(e)}") diff --git a/services/volunteers_service.py b/services/volunteers_service.py index d38d7f8..a8089fc 100644 --- a/services/volunteers_service.py +++ b/services/volunteers_service.py @@ -1601,14 +1601,15 @@ def _send_email_to_user( params["attachments"] = qr_code_attachments email_result = resend.Emails.send(params) + resend_email_id = email_result.get('id') if isinstance(email_result, dict) else getattr(email_result, 'id', None) info(logger, "Email sent to user", - volunteer_id=volunteer_id, email=email, result=email_result, recipient_type=recipient_type) - return True, None + volunteer_id=volunteer_id, email=email, result=email_result, resend_email_id=resend_email_id, recipient_type=recipient_type) + return True, None, resend_email_id except Exception as email_error: error(logger, "Failed to send email to user", volunteer_id=volunteer_id, exc_info=email_error) - return False, str(email_error) + return False, str(email_error), None def send_volunteer_message( @@ -1718,7 +1719,7 @@ def send_volunteer_message( delivery_status['slack_error'] = slack_error # Send email message - email_success, email_error = _send_email_to_user( + email_success, email_error, resend_email_id = _send_email_to_user( email=email, name=name, subject=subject, @@ -1731,30 +1732,31 @@ def send_volunteer_message( delivery_status['email_sent'] = email_success delivery_status['email_error'] = email_error - # Update volunteer document with message tracking + # Update volunteer document with message tracking (sent_emails with Resend ID) try: admin_full_name = f"Admin ({admin_user.user_id})" if admin_user else "Admin" email_subject = f"{subject} - Message from Opportunity Hack Team" - message_record = { + sent_email_record = { + 'resend_id': resend_email_id, 'subject': email_subject, 'timestamp': _get_current_timestamp(), 'sent_by': admin_full_name, 'recipient_type': recipient_type, - 'delivery_status': delivery_status } - # Add the message to the volunteer's messages_sent array + # Add the record to the volunteer's sent_emails array volunteer_ref = db.collection('volunteers').document(volunteer_id) volunteer_ref.update({ - 'messages_sent': firestore.ArrayUnion([message_record]), + 'sent_emails': firestore.ArrayUnion([sent_email_record]), 'last_message_timestamp': _get_current_timestamp(), 'updated_timestamp': _get_current_timestamp() }) user_id = volunteer.get('user_id', '') - info(logger, "Updated volunteer document with message tracking", - volunteer_id=volunteer_id, message_timestamp=message_record['timestamp']) + info(logger, "Updated volunteer document with sent_emails tracking", + volunteer_id=volunteer_id, resend_email_id=resend_email_id, + message_timestamp=sent_email_record['timestamp']) _clear_volunteer_caches( user_id=user_id, @@ -1764,8 +1766,9 @@ def send_volunteer_message( ) except Exception as tracking_error: - error(logger, "Failed to update volunteer document with message tracking", - volunteer_id=volunteer_id, exc_info=tracking_error) + error(logger, "Failed to update volunteer document with sent_emails tracking", + volunteer_id=volunteer_id, resend_email_id=resend_email_id, + exc_info=tracking_error) # Enhanced Slack audit message with recipient context from common.utils.slack import send_slack_audit @@ -1827,7 +1830,8 @@ def send_email_to_address( admin_user_id: str, admin_user: Any = None, recipient_type: str = 'volunteer', - name: Optional[str] = None + name: Optional[str] = None, + volunteer_id: Optional[str] = None ) -> Dict[str, Any]: """ Send an email to a specific email address using the same template as send_volunteer_message. @@ -1840,6 +1844,7 @@ def send_email_to_address( admin_user: The admin user object recipient_type: Type of recipient (mentor, sponsor, judge, volunteer, hacker, etc.) name: Optional recipient name (defaults to 'Volunteer') + volunteer_id: Optional volunteer document ID for tracking sent emails Returns: Dict containing delivery status @@ -1858,7 +1863,7 @@ def send_email_to_address( _, message_for_email, qr_code_attachments = _process_qr_code_in_message(message) # Send email message - email_success, email_error = _send_email_to_user( + email_success, email_error, resend_email_id = _send_email_to_user( email=email, name=recipient_name, subject=subject, @@ -1866,13 +1871,44 @@ def send_email_to_address( recipient_type=recipient_type, volunteer_type=recipient_type, qr_code_attachments=qr_code_attachments, - volunteer_id=None + volunteer_id=volunteer_id ) + admin_full_name = f"Admin ({admin_user.user_id})" if admin_user else "Admin" + + # Save sent_emails record to volunteer document if volunteer_id is provided + if volunteer_id and email_success: + try: + from db.db import get_db + db = get_db() + email_subject = f"{subject} - Message from Opportunity Hack Team" + + sent_email_record = { + 'resend_id': resend_email_id, + 'subject': email_subject, + 'timestamp': _get_current_timestamp(), + 'sent_by': admin_full_name, + 'recipient_type': recipient_type, + } + + volunteer_ref = db.collection('volunteers').document(volunteer_id) + volunteer_ref.update({ + 'sent_emails': firestore.ArrayUnion([sent_email_record]), + 'last_message_timestamp': _get_current_timestamp(), + 'updated_timestamp': _get_current_timestamp() + }) + + info(logger, "Updated volunteer document with sent_emails tracking (email-only path)", + volunteer_id=volunteer_id, resend_email_id=resend_email_id) + + except Exception as tracking_error: + error(logger, "Failed to update volunteer document with sent_emails tracking (email-only path)", + volunteer_id=volunteer_id, resend_email_id=resend_email_id, + exc_info=tracking_error) + # Enhanced Slack audit message from common.utils.slack import send_slack_audit message_preview = message[:100] + "..." if len(message) > 100 else message - admin_full_name = f"Admin ({admin_user.user_id})" if admin_user else "Admin" send_slack_audit( action="admin_send_email_to_address", @@ -1883,7 +1919,9 @@ def send_email_to_address( "recipient_type": recipient_type, "message_preview": message_preview, "email_sent": email_success, - "email_error": email_error + "email_error": email_error, + "resend_email_id": resend_email_id, + "volunteer_id": volunteer_id } ) @@ -1893,7 +1931,8 @@ def send_email_to_address( 'recipient_name': recipient_name, 'recipient_type': recipient_type, 'email_sent': email_success, - 'email_error': email_error + 'email_error': email_error, + 'resend_email_id': resend_email_id } if not email_success: @@ -1908,3 +1947,45 @@ def send_email_to_address( 'error': f"Failed to send email: {str(e)}", 'recipient_email': email } + + +def get_resend_email_statuses(email_ids: list) -> Dict[str, Any]: + """ + Fetch delivery status for a list of Resend email IDs. + + Args: + email_ids: List of Resend email IDs to look up + + Returns: + Dict with 'statuses' mapping each email ID to its Resend status + """ + try: + resend.api_key = os.environ.get('RESEND_EMAIL_STATUS_KEY') + if not resend.api_key: + return {'success': False, 'error': 'Resend API key not configured'} + + statuses = {} + for eid in email_ids[:50]: # Cap at 50 to avoid excessive API calls + try: + email_data = resend.Emails.get(eid) + statuses[eid] = { + 'id': eid, + 'to': getattr(email_data, 'to', []) if not isinstance(email_data, dict) else email_data.get('to', []), + 'subject': getattr(email_data, 'subject', '') if not isinstance(email_data, dict) else email_data.get('subject', ''), + 'created_at': getattr(email_data, 'created_at', '') if not isinstance(email_data, dict) else email_data.get('created_at', ''), + 'last_event': getattr(email_data, 'last_event', '') if not isinstance(email_data, dict) else email_data.get('last_event', ''), + } + except Exception as fetch_error: + warning(logger, "Failed to fetch Resend email status", + resend_email_id=eid, exc_info=fetch_error) + statuses[eid] = { + 'id': eid, + 'last_event': 'unknown', + 'error': str(fetch_error) + } + + return {'success': True, 'statuses': statuses} + + except Exception as e: + error(logger, "Error fetching Resend email statuses", exc_info=e) + return {'success': False, 'error': str(e)}