From f1aaa352837718bea18d513957471549a31b0acb Mon Sep 17 00:00:00 2001 From: Greg V Date: Wed, 27 May 2026 12:09:41 -0700 Subject: [PATCH 1/3] [Mentors] Per-team mentor support panel + leaderboard boost signals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend half of the new per-team mentor coordination panel. Pairs with the matching frontend PR in opportunity-hack/frontend-ohack.dev. Today the OHack mentor experience is documented but invisible to other mentors during an event — multiple mentors visit the same teams, work gets duplicated, off-shift mentors miss what others saw, and there's no durable record of which teams were under-supported. This change gives every team on /hack//team/ a public mentor support surface with coverage attribution, public notes, ownerable concern flags, and a 4-criterion judging-readiness rubric. New module: api/mentors - api/mentors/mentors_service.py * MENTOR_COVERAGE_ITEMS — 6-item team-observation checklist (intro_made, scope_reviewed, architecture_discussed, repo_health_checked, criteria_walkthrough, demo_devpost_reviewed). Slugs MUST stay in lockstep with the frontend MENTOR_COVERAGE_ITEMS array. * user_is_mentor_for_event() — auth gate. Translates the PropelAuth UUID to OAuth user_id + email via get_propel_user_details_by_id, then looks up a volunteers doc with volunteer_type='mentor', event_id=, isSelected=True. Matches by email OR user_id for robustness (same identity gotcha pattern as user_is_on_team in api/teams). * get_mentor_self_status() — backs the new /api/volunteer//me endpoint with a lean {is_mentor, volunteer} payload. * toggle_mentor_coverage / add_mentor_note / delete_mentor_note / raise_mentor_flag / take_over_mentor_flag / resolve_mentor_flag / set_mentor_rating — full CRUD over the new mentor_* fields on the team doc. * Slack volume is intentionally QUIET: only flag-raises, flag-resolutions, and the first-time 'all 6 covered' milestone broadcast to the team's slack_channel. Flag-raises also heartbeat the per-event mentor channel (hackathon.mentor_slack_channel, falling back to f'{event_id}-mentors'). Coverage toggles, notes, take-overs, and ratings are silent (still audit-logged via send_slack_audit). * All write paths route the response through get_team() so DocumentRefs on users[] are flattened + enriched (same fix pattern as #227). - api/mentors/mentors_views.py — 7 routes under /api/team//mentor/... All @auth.require_user; service-layer gates on user_is_mentor_for_event. api/__init__.py — registers the new mentors blueprint. api/volunteers/volunteers_views.py — new GET /api/volunteer//me endpoint (mentor type only for now). Frontend uses this to decide interactive vs. read-only rendering of the panel. api/leaderboard/leaderboard_service.py — new collect_mentor_panel_opportunities() injects two derived signals into the existing mentor_opportunities array consumed by the 'Teams Ready for a Boost' widget on /hack/#stats: - Any team with an open mentor flag (cap of 2 per team to limit noise) - During the live event window only: any team with mentor_last_touched_at > 4 hours ago Best-effort: any failure here is logged and ignored so the broader leaderboard never breaks. common/utils/validators.py — allows mentor_slack_channel as a top-level field on hackathon docs (optional string, ≤80 chars). Falls back to a name-derived guess at runtime if unset. New optional Firestore fields on a team doc (no migration; legacy docs stay valid): mentor_checklist: { [slug]: {done, checked_at, checked_by_propel_id, checked_by_name, note?} } mentor_notes: [{id, created_at, author_propel_id, author_name, body, deleted_at?, deleted_by_propel_id?}] mentor_flags: [{id, created_at, raised_by_propel_id, raised_by_name, severity, body, owner_propel_id, owner_name, resolved_at?, resolved_by_propel_id?, resolved_by_name?, resolution_note?}] mentor_ratings: [{rated_at, rated_by_propel_id, rated_by_name, criterion, score, note?}] mentor_last_touched_at, mentor_last_touched_by_name, mentor_open_flag_count, mentor_coverage_completed_at, mentor_coverage_completed_by_name (denormalized; the leaderboard reads these directly). Co-Authored-By: Claude Opus 4.7 --- api/__init__.py | 2 + api/leaderboard/leaderboard_service.py | 106 +++++ api/mentors/__init__.py | 0 api/mentors/mentors_service.py | 572 +++++++++++++++++++++++++ api/mentors/mentors_views.py | 116 +++++ api/volunteers/volunteers_views.py | 20 + common/utils/validators.py | 9 + 7 files changed, 825 insertions(+) create mode 100644 api/mentors/__init__.py create mode 100644 api/mentors/mentors_service.py create mode 100644 api/mentors/mentors_views.py diff --git a/api/__init__.py b/api/__init__.py index cb7e30e..98b5a47 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -185,6 +185,7 @@ def add_headers(response): from api.llm import llm_views from api.store import store_views from api.planning import planning_views + from api.mentors import mentors_views app.register_blueprint(messages_views.bp) app.register_blueprint(exception_views.bp) @@ -205,5 +206,6 @@ def add_headers(response): app.register_blueprint(judging_views.bp) app.register_blueprint(store_views.bp) app.register_blueprint(planning_views.bp) + app.register_blueprint(mentors_views.bp) return app diff --git a/api/leaderboard/leaderboard_service.py b/api/leaderboard/leaderboard_service.py index 58ce1e3..0904c61 100644 --- a/api/leaderboard/leaderboard_service.py +++ b/api/leaderboard/leaderboard_service.py @@ -504,6 +504,105 @@ def categorize_mentor_opportunities(achievements: List[Dict]) -> List[Dict]: return opportunities + +def collect_mentor_panel_opportunities(event_id: str) -> List[Dict]: + """ + Derive mentor-boost opportunities from the per-team mentor panel state: + - Any team with an open mentor flag (raised by a mentor) + - Any team that has had NO mentor touch in the last 4 hours during a + live event window (start_date <= now <= end_date + 1 day) + + These are appended to the existing GitHub-derived opportunities so the + "Teams Ready for a Boost" widget on /hack/#stats covers both + coding-activity AND mentor-coverage gaps. + """ + from datetime import datetime, timedelta + from common.utils.firebase import get_hackathon_by_event_id + from db.db import get_db + + opportunities: List[Dict] = [] + + try: + hackathon = get_hackathon_by_event_id(event_id) or {} + except Exception as e: + logger.warning("collect_mentor_panel_opportunities: hackathon lookup failed: %s", e) + hackathon = {} + + now = datetime.now() + start_date = None + end_date = None + try: + if hackathon.get("start_date"): + start_date = datetime.fromisoformat(hackathon["start_date"].replace("Z", "")) + if hackathon.get("end_date"): + end_date = datetime.fromisoformat(hackathon["end_date"].replace("Z", "")) + except Exception: + pass + is_live = bool( + start_date and end_date and start_date <= now <= (end_date + timedelta(days=1)) + ) + + try: + db = get_db() + team_docs = db.collection("teams").where("hackathon_event_id", "==", event_id).stream() + except Exception as e: + logger.warning("collect_mentor_panel_opportunities: team query failed: %s", e) + return opportunities + + stale_threshold = now - timedelta(hours=4) + + for snap in team_docs: + try: + t = snap.to_dict() or {} + team_name = t.get("name") or "Unnamed team" + team_id = snap.id + + # Open flag → one opportunity per flag (cap at 2 per team to limit noise) + flags = t.get("mentor_flags") or [] + open_flags = [f for f in flags if not f.get("resolved_at")][:2] + for f in open_flags: + severity = f.get("severity", "needs_attention") + body = (f.get("body") or "").strip() + preview = (body[:80] + "...") if len(body) > 80 else body + opportunities.append({ + "type": "mentor_opportunity", + "team": team_name, + "teamPage": f"https://www.ohack.dev/hack/{event_id}/team/{team_id}", + "icon": "flag", + "value": "Open flag" if severity == "needs_attention" else "Blocked", + "description": f"{f.get('raised_by_name', 'Mentor')}: {preview}", + "members": len(t.get("users") or []), + }) + + # No mentor touch in 4h during a live event + if is_live: + last_touched_iso = t.get("mentor_last_touched_at") + touched_recently = False + if last_touched_iso: + try: + last_touched = datetime.fromisoformat(last_touched_iso.replace("Z", "")) + if last_touched >= stale_threshold: + touched_recently = True + except Exception: + pass + if not touched_recently: + opportunities.append({ + "type": "mentor_opportunity", + "team": team_name, + "teamPage": f"https://www.ohack.dev/hack/{event_id}/team/{team_id}", + "icon": "schedule", + "value": "No mentor touch in 4h", + "description": ( + "No mentor has checked on this team in the last 4 hours — drop by!" + ), + "members": len(t.get("users") or []), + }) + except Exception as e: + logger.warning("collect_mentor_panel_opportunities: skipped a team: %s", e) + continue + + return opportunities + def get_leaderboard_analytics(event_id: str, contributors: List[Dict], achievements: List[Dict]) -> Dict[str, Any]: """ Generate leaderboard analytics including statistics and achievements. @@ -542,6 +641,13 @@ def get_leaderboard_analytics(event_id: str, contributors: List[Dict], achieveme team_achievements = categorize_team_achievements(achievements, contributors) mentor_opportunities = categorize_mentor_opportunities(achievements) + # Append mentor-panel-derived opportunities (open flags + stale touch). + # Best-effort: any failure here must not break the broader leaderboard. + try: + mentor_opportunities = mentor_opportunities + collect_mentor_panel_opportunities(event_id) + except Exception as e: + logger.warning("Failed to collect mentor panel opportunities for %s: %s", event_id, e) + return { "eventName": event_name, "githubOrg": github_org, diff --git a/api/mentors/__init__.py b/api/mentors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/mentors/mentors_service.py b/api/mentors/mentors_service.py new file mode 100644 index 0000000..7cb55be --- /dev/null +++ b/api/mentors/mentors_service.py @@ -0,0 +1,572 @@ +""" +Per-team mentor support panel — backend service. + +Lets multiple approved mentors collaborate on a team during a hackathon: +mark coverage items done, leave public notes, raise/own/resolve flags, +rate judging-readiness on a 4-criterion rubric. All data is public-read +(reads come through the existing get_team / mentor-feed endpoint); writes +require a volunteer doc with volunteer_type='mentor', isSelected=True, +matched to the caller's PropelAuth identity for this event. + +Slack notifications are deliberately quiet: only flag-raises, flag-resolutions, +and the team's first "all coverage covered" milestone broadcast to the team +channel; flag-raises also heartbeat the per-event mentor channel. +""" +import uuid +import logging +from datetime import datetime +from typing import Optional, Tuple + +from db.db import get_db +from common.utils.firestore_helpers import clear_all_caches as clear_cache +from common.utils.slack import send_slack, send_slack_audit +from common.utils.firebase import get_hackathon_by_event_id +from services.users_service import get_propel_user_details_by_id +from services.volunteers_service import get_volunteer_by_email, get_volunteer_by_user_id +from services.teams_service import get_team + +logger = logging.getLogger("myapp") + +# Canonical 6-item team-coverage list. Slugs MUST stay in lockstep with the +# frontend MENTOR_COVERAGE_ITEMS in src/components/Teams/mentorCoverage.js. +MENTOR_COVERAGE_ITEMS = [ + {"slug": "intro_made", "label": "Mentor introductions made", + "blurb": "A mentor has connected with the team in their Slack channel."}, + {"slug": "scope_reviewed", "label": "Project scope reviewed", + "blurb": "Scope is realistic for the hackathon window and ties to a real nonprofit need."}, + {"slug": "architecture_discussed", "label": "Architecture & tech stack discussed", + "blurb": "Stack is sensible; nothing single-file or trivially shallow."}, + {"slug": "repo_health_checked", "label": "GitHub repo reviewed", + "blurb": "Commits flowing, structure sensible, README starting to take shape."}, + {"slug": "criteria_walkthrough", "label": "Judging criteria walkthrough", + "blurb": "Team has been shown the 4-criterion rubric and knows what judges look for."}, + {"slug": "demo_devpost_reviewed", "label": "Demo video + DevPost reviewed", + "blurb": "A mentor has reviewed at least a draft of the demo video and DevPost submission."}, +] +MENTOR_COVERAGE_SLUGS = {item["slug"]: item for item in MENTOR_COVERAGE_ITEMS} + +ALLOWED_FLAG_SEVERITIES = {"needs_attention", "blocked"} +ALLOWED_CRITERIA = {"scope", "documentation", "polish", "security"} +ALLOWED_SCORES = {"green", "yellow", "red"} + +MAX_NOTE_LEN = 1000 +MAX_FLAG_BODY_LEN = 500 +MAX_RATING_NOTE_LEN = 300 + + +# -------- identity / auth -------------------------------------------------- + +def _resolve_caller(propel_user_id): + """ + Return (email, oauth_user_id, name) for the calling PropelAuth user. + Any of those three can be used to match against a volunteer doc. + """ + try: + details = get_propel_user_details_by_id(propel_user_id) or () + email = details[0] if len(details) > 0 else None + oauth_user_id = details[1] if len(details) > 1 else None + name = details[4] if len(details) > 4 else None + return email, oauth_user_id, name + except Exception as e: + logger.warning("mentors._resolve_caller failed: %s", e) + return None, None, None + + +def user_is_mentor_for_event(propel_user_id, event_id) -> bool: + """ + True iff the caller has an approved (isSelected=True) mentor volunteer + record for THIS event. Matches by email first, then by OAuth user_id — + either is enough. + """ + if not propel_user_id or not event_id: + return False + email, oauth_user_id, _ = _resolve_caller(propel_user_id) + + candidates = [] + if email: + try: + v = get_volunteer_by_email(email, event_id, "mentor") + if v: + candidates.append(v) + except Exception as e: + logger.warning("user_is_mentor_for_event: email lookup failed: %s", e) + if oauth_user_id: + try: + v = get_volunteer_by_user_id(oauth_user_id, event_id, "mentor") + if v: + candidates.append(v) + except Exception as e: + logger.warning("user_is_mentor_for_event: user_id lookup failed: %s", e) + + for v in candidates: + if v.get("isSelected"): + return True + return False + + +def get_mentor_self_status(propel_user_id, event_id): + """ + Returns { is_mentor, volunteer } for the GET /api/volunteer//me + endpoint. The volunteer subset is intentionally lean (no PII beyond what + the caller already knows about themselves). + """ + if not propel_user_id or not event_id: + return {"is_mentor": False, "volunteer": None} + email, oauth_user_id, _ = _resolve_caller(propel_user_id) + chosen = None + if email: + try: + v = get_volunteer_by_email(email, event_id, "mentor") + if v and v.get("isSelected"): + chosen = v + except Exception: + pass + if chosen is None and oauth_user_id: + try: + v = get_volunteer_by_user_id(oauth_user_id, event_id, "mentor") + if v and v.get("isSelected"): + chosen = v + except Exception: + pass + if chosen is None: + return {"is_mentor": False, "volunteer": None} + return { + "is_mentor": True, + "volunteer": { + "name": chosen.get("name"), + "email": chosen.get("email"), + "isSelected": True, + "checkInTime": chosen.get("checkInTime"), + }, + } + + +# -------- shared helpers --------------------------------------------------- + +def _team_doc_or_404(team_id): + db = get_db() + ref = db.collection("teams").document(team_id) + snap = ref.get() + if not snap.exists: + return None, None, None + return ref, snap.to_dict() or {}, db + + +def _caller_attribution(propel_user_id): + _, _, name = _resolve_caller(propel_user_id) + return name or "A mentor" + + +def _touch(team_data, name, now_iso): + """Mutate team_data in-place with denormalized last-touch info.""" + team_data["mentor_last_touched_at"] = now_iso + team_data["mentor_last_touched_by_name"] = name + + +def _open_flag_count(flags): + if not isinstance(flags, list): + return 0 + return sum(1 for f in flags if not f.get("resolved_at")) + + +def _coverage_done_count(checklist): + if not isinstance(checklist, dict): + return 0 + return sum(1 for slug in MENTOR_COVERAGE_SLUGS if checklist.get(slug, {}).get("done")) + + +def _mentor_channel_for_event(event_id): + """Resolve the per-event mentor Slack channel — explicit field, then + fallback to a name-derived guess. Returns None if neither is available.""" + if not event_id: + return None + try: + h = get_hackathon_by_event_id(event_id) or {} + explicit = h.get("mentor_slack_channel") + if explicit: + return explicit + except Exception as e: + logger.warning("_mentor_channel_for_event: hackathon lookup failed: %s", e) + # Fallback: -mentors (deliberate convention; harmless if the + # channel doesn't exist — send_slack will just log and move on). + return f"{event_id}-mentors".replace("_", "-").lower() + + +def _project_link(team_data, team_id): + event_id = team_data.get("hackathon_event_id") or "" + if event_id: + return f"https://ohack.dev/hack/{event_id}/team/{team_id}" + return f"https://ohack.dev/hack/team/{team_id}" + + +def _build_response(team_id, **extra): + """Round-trip the team through get_team so users[] DocumentReferences are + flattened + enriched (Flask can't JSON-serialize raw refs).""" + fresh = (get_team(team_id) or {}).get("team") or {} + return {"success": True, "team": fresh, **extra}, 200 + + +# -------- coverage checklist ---------------------------------------------- + +def toggle_mentor_coverage(propel_user_id, team_id, item_slug, done, note=None): + """ + Mark/unmark a coverage item. Mentor coverage is reversible (situations + evolve mid-event), unlike the team-completion checklist. Quiet by + default — only the first time a team hits 6/6 does a Slack message fire. + """ + if item_slug not in MENTOR_COVERAGE_SLUGS: + return {"error": f"Unknown coverage item: {item_slug}"}, 400 + if not isinstance(done, bool): + return {"error": "Field 'done' must be a boolean"}, 400 + + event_id_guess = None + ref, team_data, _ = _team_doc_or_404(team_id) + if ref is None: + return {"error": "Team not found"}, 404 + event_id_guess = team_data.get("hackathon_event_id") + if not user_is_mentor_for_event(propel_user_id, event_id_guess): + return {"error": "You must be an approved mentor for this event."}, 403 + + name = _caller_attribution(propel_user_id) + now_iso = datetime.now().isoformat() + checklist = dict(team_data.get("mentor_checklist") or {}) + if done: + new_entry = { + "done": True, + "checked_at": now_iso, + "checked_by_propel_id": propel_user_id, + "checked_by_name": name, + } + if note: + new_entry["note"] = str(note)[:MAX_RATING_NOTE_LEN] + checklist[item_slug] = new_entry + else: + # Unchecking: drop the entry entirely (the absence is the "not done" state). + checklist.pop(item_slug, None) + + new_count = sum(1 for s in MENTOR_COVERAGE_SLUGS if checklist.get(s, {}).get("done")) + total = len(MENTOR_COVERAGE_ITEMS) + + update = {"mentor_checklist": checklist} + _touch(update, name, now_iso) + + # First-time-complete milestone: stamp it once so we never re-broadcast. + fire_milestone = False + if done and new_count == total and not team_data.get("mentor_coverage_completed_at"): + update["mentor_coverage_completed_at"] = now_iso + update["mentor_coverage_completed_by_name"] = name + fire_milestone = True + + ref.set(update, merge=True) + + if fire_milestone: + slack_channel = team_data.get("slack_channel") + if slack_channel: + try: + send_slack( + message=( + f":sparkles: *{team_data.get('name', 'This team')}* has full mentor coverage " + f"({total}/{total} items)! Nice work team & mentors. " + f"Final coverage marker: *{name}*." + ), + channel=slack_channel, + ) + except Exception as e: + logger.error("toggle_mentor_coverage: milestone slack failed: %s", e) + + send_slack_audit( + action="mentor_coverage_toggle", + message=f"Team {team_id} coverage {item_slug}={done} by {name} ({new_count}/{total})", + payload={"team_id": team_id, "item": item_slug, "done": done, "by": propel_user_id}, + ) + clear_cache() + return _build_response(team_id, done=new_count, total=total) + + +# -------- notes feed ------------------------------------------------------ + +def add_mentor_note(propel_user_id, team_id, body): + if not body or not isinstance(body, str): + return {"error": "Note body is required"}, 400 + body = body.strip() + if not body: + return {"error": "Note body is required"}, 400 + if len(body) > MAX_NOTE_LEN: + return {"error": f"Note body must be at most {MAX_NOTE_LEN} characters"}, 400 + + ref, team_data, _ = _team_doc_or_404(team_id) + if ref is None: + return {"error": "Team not found"}, 404 + event_id = team_data.get("hackathon_event_id") + if not user_is_mentor_for_event(propel_user_id, event_id): + return {"error": "You must be an approved mentor for this event."}, 403 + + name = _caller_attribution(propel_user_id) + now_iso = datetime.now().isoformat() + notes = list(team_data.get("mentor_notes") or []) + notes.append({ + "id": uuid.uuid4().hex, + "created_at": now_iso, + "author_propel_id": propel_user_id, + "author_name": name, + "body": body, + }) + + update = {"mentor_notes": notes} + _touch(update, name, now_iso) + ref.set(update, merge=True) + + send_slack_audit( + action="mentor_note_add", + message=f"Mentor note added on team {team_id} by {name}", + payload={"team_id": team_id, "by": propel_user_id, "len": len(body)}, + ) + clear_cache() + return _build_response(team_id) + + +def delete_mentor_note(propel_user_id, team_id, note_id): + ref, team_data, _ = _team_doc_or_404(team_id) + if ref is None: + return {"error": "Team not found"}, 404 + event_id = team_data.get("hackathon_event_id") + if not user_is_mentor_for_event(propel_user_id, event_id): + return {"error": "You must be an approved mentor for this event."}, 403 + + notes = list(team_data.get("mentor_notes") or []) + target = next((n for n in notes if n.get("id") == note_id), None) + if target is None: + return {"error": "Note not found"}, 404 + if target.get("deleted_at"): + return {"error": "Note already deleted"}, 409 + if target.get("author_propel_id") != propel_user_id: + return {"error": "You can only delete your own notes."}, 403 + + name = _caller_attribution(propel_user_id) + now_iso = datetime.now().isoformat() + target["deleted_at"] = now_iso + target["deleted_by_propel_id"] = propel_user_id + + update = {"mentor_notes": notes} + _touch(update, name, now_iso) + ref.set(update, merge=True) + + send_slack_audit( + action="mentor_note_delete", + message=f"Mentor note {note_id} soft-deleted on team {team_id} by {name}", + payload={"team_id": team_id, "note_id": note_id, "by": propel_user_id}, + ) + clear_cache() + return _build_response(team_id) + + +# -------- flags ----------------------------------------------------------- + +def raise_mentor_flag(propel_user_id, team_id, severity, body): + if severity not in ALLOWED_FLAG_SEVERITIES: + return {"error": f"Severity must be one of: {sorted(ALLOWED_FLAG_SEVERITIES)}"}, 400 + if not body or not isinstance(body, str): + return {"error": "Flag body is required"}, 400 + body = body.strip() + if not body: + return {"error": "Flag body is required"}, 400 + if len(body) > MAX_FLAG_BODY_LEN: + return {"error": f"Flag body must be at most {MAX_FLAG_BODY_LEN} characters"}, 400 + + ref, team_data, _ = _team_doc_or_404(team_id) + if ref is None: + return {"error": "Team not found"}, 404 + event_id = team_data.get("hackathon_event_id") + if not user_is_mentor_for_event(propel_user_id, event_id): + return {"error": "You must be an approved mentor for this event."}, 403 + + name = _caller_attribution(propel_user_id) + now_iso = datetime.now().isoformat() + flags = list(team_data.get("mentor_flags") or []) + new_flag = { + "id": uuid.uuid4().hex, + "created_at": now_iso, + "raised_by_propel_id": propel_user_id, + "raised_by_name": name, + "severity": severity, + "body": body, + "owner_propel_id": propel_user_id, + "owner_name": name, + } + flags.append(new_flag) + + update = { + "mentor_flags": flags, + "mentor_open_flag_count": _open_flag_count(flags), + } + _touch(update, name, now_iso) + ref.set(update, merge=True) + + # Loud: post into team channel + heartbeat the per-event mentor channel. + team_name = team_data.get("name", "this team") + project_link = _project_link(team_data, team_id) + severity_emoji = ":warning:" if severity == "needs_attention" else ":rotating_light:" + team_msg = ( + f"{severity_emoji} *Mentor flag raised on {team_name}* by *{name}*\n" + f">{body}\n" + f"Owned by *{name}*. Other mentors can take over from the team page.\n" + f"{project_link}" + ) + slack_channel = team_data.get("slack_channel") + if slack_channel: + try: + send_slack(message=team_msg, channel=slack_channel) + except Exception as e: + logger.error("raise_mentor_flag: team-channel slack failed: %s", e) + + mentor_channel = _mentor_channel_for_event(event_id) + if mentor_channel: + try: + send_slack( + message=( + f"{severity_emoji} *{team_name}*: {body[:120]}" + f"{'...' if len(body) > 120 else ''}\n{project_link}" + ), + channel=mentor_channel, + ) + except Exception as e: + logger.warning("raise_mentor_flag: mentor-channel slack failed: %s", e) + + send_slack_audit( + action="mentor_flag_raise", + message=f"Flag raised on team {team_id} by {name} ({severity})", + payload={"team_id": team_id, "severity": severity, "by": propel_user_id}, + ) + clear_cache() + return _build_response(team_id, flag_id=new_flag["id"]) + + +def take_over_mentor_flag(propel_user_id, team_id, flag_id): + ref, team_data, _ = _team_doc_or_404(team_id) + if ref is None: + return {"error": "Team not found"}, 404 + event_id = team_data.get("hackathon_event_id") + if not user_is_mentor_for_event(propel_user_id, event_id): + return {"error": "You must be an approved mentor for this event."}, 403 + + flags = list(team_data.get("mentor_flags") or []) + target = next((f for f in flags if f.get("id") == flag_id), None) + if target is None: + return {"error": "Flag not found"}, 404 + if target.get("resolved_at"): + return {"error": "Flag is already resolved"}, 409 + + name = _caller_attribution(propel_user_id) + now_iso = datetime.now().isoformat() + target["owner_propel_id"] = propel_user_id + target["owner_name"] = name + + update = {"mentor_flags": flags} + _touch(update, name, now_iso) + ref.set(update, merge=True) + + send_slack_audit( + action="mentor_flag_takeover", + message=f"Flag {flag_id} on team {team_id} taken over by {name}", + payload={"team_id": team_id, "flag_id": flag_id, "by": propel_user_id}, + ) + clear_cache() + return _build_response(team_id) + + +def resolve_mentor_flag(propel_user_id, team_id, flag_id, resolution_note): + if not resolution_note or not str(resolution_note).strip(): + return {"error": "Resolution note is required"}, 400 + resolution_note = str(resolution_note).strip()[:MAX_FLAG_BODY_LEN] + + ref, team_data, _ = _team_doc_or_404(team_id) + if ref is None: + return {"error": "Team not found"}, 404 + event_id = team_data.get("hackathon_event_id") + if not user_is_mentor_for_event(propel_user_id, event_id): + return {"error": "You must be an approved mentor for this event."}, 403 + + flags = list(team_data.get("mentor_flags") or []) + target = next((f for f in flags if f.get("id") == flag_id), None) + if target is None: + return {"error": "Flag not found"}, 404 + if target.get("resolved_at"): + return {"error": "Flag is already resolved"}, 409 + + name = _caller_attribution(propel_user_id) + now_iso = datetime.now().isoformat() + target["resolved_at"] = now_iso + target["resolved_by_propel_id"] = propel_user_id + target["resolved_by_name"] = name + target["resolution_note"] = resolution_note + + update = { + "mentor_flags": flags, + "mentor_open_flag_count": _open_flag_count(flags), + } + _touch(update, name, now_iso) + ref.set(update, merge=True) + + team_name = team_data.get("name", "this team") + slack_channel = team_data.get("slack_channel") + if slack_channel: + try: + send_slack( + message=( + f":white_check_mark: *Mentor flag resolved on {team_name}* by *{name}*\n" + f">{resolution_note}" + ), + channel=slack_channel, + ) + except Exception as e: + logger.error("resolve_mentor_flag: slack failed: %s", e) + + send_slack_audit( + action="mentor_flag_resolve", + message=f"Flag {flag_id} on team {team_id} resolved by {name}", + payload={"team_id": team_id, "flag_id": flag_id, "by": propel_user_id}, + ) + clear_cache() + return _build_response(team_id) + + +# -------- judging-readiness rating ---------------------------------------- + +def set_mentor_rating(propel_user_id, team_id, criterion, score, note=None): + if criterion not in ALLOWED_CRITERIA: + return {"error": f"Criterion must be one of: {sorted(ALLOWED_CRITERIA)}"}, 400 + if score not in ALLOWED_SCORES: + return {"error": f"Score must be one of: {sorted(ALLOWED_SCORES)}"}, 400 + + ref, team_data, _ = _team_doc_or_404(team_id) + if ref is None: + return {"error": "Team not found"}, 404 + event_id = team_data.get("hackathon_event_id") + if not user_is_mentor_for_event(propel_user_id, event_id): + return {"error": "You must be an approved mentor for this event."}, 403 + + name = _caller_attribution(propel_user_id) + now_iso = datetime.now().isoformat() + ratings = list(team_data.get("mentor_ratings") or []) + entry = { + "rated_at": now_iso, + "rated_by_propel_id": propel_user_id, + "rated_by_name": name, + "criterion": criterion, + "score": score, + } + if note: + entry["note"] = str(note).strip()[:MAX_RATING_NOTE_LEN] + ratings.append(entry) + + update = {"mentor_ratings": ratings} + _touch(update, name, now_iso) + ref.set(update, merge=True) + + send_slack_audit( + action="mentor_rating_set", + message=f"Rating {criterion}={score} set on team {team_id} by {name}", + payload={"team_id": team_id, "criterion": criterion, "score": score, "by": propel_user_id}, + ) + clear_cache() + return _build_response(team_id) diff --git a/api/mentors/mentors_views.py b/api/mentors/mentors_views.py new file mode 100644 index 0000000..99a276f --- /dev/null +++ b/api/mentors/mentors_views.py @@ -0,0 +1,116 @@ +""" +Per-team mentor support panel — Flask routes. + +All mutating routes require an authenticated user; the underlying service +functions also verify the caller has an approved (isSelected=True) mentor +volunteer record for THIS event. The public read goes through the existing +GET /api/messages/team/ path (no new GET needed — mentor_* fields are +already part of the team doc). +""" +import logging +from flask import Blueprint, request + +from common.auth import auth, auth_user +from api.mentors.mentors_service import ( + add_mentor_note, + delete_mentor_note, + raise_mentor_flag, + take_over_mentor_flag, + resolve_mentor_flag, + toggle_mentor_coverage, + set_mentor_rating, +) + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + +bp_name = "api-mentors" +bp_url_prefix = "/api/team" +bp = Blueprint(bp_name, __name__, url_prefix=bp_url_prefix) + + +def _unauthorized(): + return {"error": "Unauthorized"}, 401 + + +@bp.route("//mentor/coverage", methods=["POST"]) +@auth.require_user +def toggle_mentor_coverage_api(teamid): + """Body: { item: , done: bool, note?: str }""" + if not (auth_user and auth_user.user_id): + return _unauthorized() + body = request.get_json() or {} + item = body.get("item") + done = body.get("done") + note = body.get("note") + if item is None or done is None: + return {"error": "Both 'item' and 'done' are required"}, 400 + return toggle_mentor_coverage(auth_user.user_id, teamid, item, bool(done), note=note) + + +@bp.route("//mentor/notes", methods=["POST"]) +@auth.require_user +def add_mentor_note_api(teamid): + """Body: { body: str }""" + if not (auth_user and auth_user.user_id): + return _unauthorized() + body = request.get_json() or {} + text = body.get("body") + return add_mentor_note(auth_user.user_id, teamid, text) + + +@bp.route("//mentor/notes/", methods=["DELETE"]) +@auth.require_user +def delete_mentor_note_api(teamid, note_id): + if not (auth_user and auth_user.user_id): + return _unauthorized() + return delete_mentor_note(auth_user.user_id, teamid, note_id) + + +@bp.route("//mentor/flags", methods=["POST"]) +@auth.require_user +def raise_mentor_flag_api(teamid): + """Body: { severity: 'needs_attention'|'blocked', body: str }""" + if not (auth_user and auth_user.user_id): + return _unauthorized() + body = request.get_json() or {} + severity = body.get("severity") + text = body.get("body") + if not severity or not text: + return {"error": "Both 'severity' and 'body' are required"}, 400 + return raise_mentor_flag(auth_user.user_id, teamid, severity, text) + + +@bp.route("//mentor/flags//take-over", methods=["POST"]) +@auth.require_user +def take_over_mentor_flag_api(teamid, flag_id): + if not (auth_user and auth_user.user_id): + return _unauthorized() + return take_over_mentor_flag(auth_user.user_id, teamid, flag_id) + + +@bp.route("//mentor/flags//resolve", methods=["POST"]) +@auth.require_user +def resolve_mentor_flag_api(teamid, flag_id): + """Body: { resolution_note: str }""" + if not (auth_user and auth_user.user_id): + return _unauthorized() + body = request.get_json() or {} + note = body.get("resolution_note") + return resolve_mentor_flag(auth_user.user_id, teamid, flag_id, note) + + +@bp.route("//mentor/ratings", methods=["POST"]) +@auth.require_user +def set_mentor_rating_api(teamid): + """Body: { criterion: 'scope'|'documentation'|'polish'|'security', + score: 'green'|'yellow'|'red', note?: str }""" + if not (auth_user and auth_user.user_id): + return _unauthorized() + body = request.get_json() or {} + criterion = body.get("criterion") + score = body.get("score") + note = body.get("note") + if not criterion or not score: + return {"error": "Both 'criterion' and 'score' are required"}, 400 + return set_mentor_rating(auth_user.user_id, teamid, criterion, score, note=note) diff --git a/api/volunteers/volunteers_views.py b/api/volunteers/volunteers_views.py index cc8ec2f..77b31e7 100644 --- a/api/volunteers/volunteers_views.py +++ b/api/volunteers/volunteers_views.py @@ -214,6 +214,26 @@ def admin_list_mentors(user, org, event_id): """Admin endpoint to list mentor applications.""" return handle_admin_list(user, event_id, 'mentor') + +@bp.route('/volunteer//me', methods=['GET']) +@auth.require_user +def get_my_volunteer_status_for_event(event_id): + """ + Lightweight self-check for the calling user against a single event. + Default type is 'mentor' (used by the per-team mentor panel to decide + interactive vs. read-only). + Returns { is_mentor: bool, volunteer: {name, email, isSelected, checkInTime?} | null }. + """ + user = auth_user + if not user or not user.user_id: + return _error_response("Authentication required", 401) + vtype = request.args.get('type', 'mentor') + if vtype != 'mentor': + # Keep the surface narrow for now; extend later if needed. + return _error_response("Only type=mentor is supported", 400) + from api.mentors.mentors_service import get_mentor_self_status + return get_mentor_self_status(user.user_id, event_id) + # Sponsor routes @bp.route('/sponsor/application//submit', methods=['POST']) @auth.optional_user diff --git a/common/utils/validators.py b/common/utils/validators.py index 826628a..1c50e27 100644 --- a/common/utils/validators.py +++ b/common/utils/validators.py @@ -297,6 +297,15 @@ def _skip(field, reason): _skip("planning", str(e)) cleaned.pop("planning") + # mentor_slack_channel — optional channel-name string for the per-event + # mentor coordination channel. The per-team MentorTeamPanel falls back to + # a name-derived guess ("-mentors") when this is unset. + if "mentor_slack_channel" in cleaned and cleaned["mentor_slack_channel"] is not None: + msc = cleaned["mentor_slack_channel"] + if not isinstance(msc, str) or len(msc) > 80: + _skip("mentor_slack_channel", "must be a string <= 80 chars (Slack channel name)") + cleaned.pop("mentor_slack_channel") + return cleaned, skipped From 69438a882a6aff41c5e7ac4923a0ec59494b0c15 Mon Sep 17 00:00:00 2001 From: Greg V Date: Wed, 27 May 2026 21:16:49 -0700 Subject: [PATCH 2/3] [Mentors] Add Accessibility to the judging-readiness rubric The /about/judges page has accessibility as a special-category prize alongside the four main criteria. Mentors still need to coach for it, so it belongs in the same ALLOWED_CRITERIA set the rating endpoint accepts. Also updates the criteria_walkthrough coverage item blurb to list all five criteria so it stays in lockstep with the frontend mentorCoverage.js change in the matching frontend PR. Co-Authored-By: Claude Opus 4.7 --- api/mentors/mentors_service.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/api/mentors/mentors_service.py b/api/mentors/mentors_service.py index 7cb55be..1ad6fc2 100644 --- a/api/mentors/mentors_service.py +++ b/api/mentors/mentors_service.py @@ -39,14 +39,17 @@ {"slug": "repo_health_checked", "label": "GitHub repo reviewed", "blurb": "Commits flowing, structure sensible, README starting to take shape."}, {"slug": "criteria_walkthrough", "label": "Judging criteria walkthrough", - "blurb": "Team has been shown the 4-criterion rubric and knows what judges look for."}, + "blurb": "Team has been shown the judging rubric (Scope, Documentation, Polish, Security, plus Accessibility) and knows what judges look for."}, {"slug": "demo_devpost_reviewed", "label": "Demo video + DevPost reviewed", "blurb": "A mentor has reviewed at least a draft of the demo video and DevPost submission."}, ] MENTOR_COVERAGE_SLUGS = {item["slug"]: item for item in MENTOR_COVERAGE_ITEMS} ALLOWED_FLAG_SEVERITIES = {"needs_attention", "blocked"} -ALLOWED_CRITERIA = {"scope", "documentation", "polish", "security"} +# Five judging criteria. The first four are the main 10-pt rubric on +# /about/judges; "accessibility" is the special-category prize on the same +# page but mentors still coach for it, so it lives in the same set. +ALLOWED_CRITERIA = {"scope", "documentation", "polish", "security", "accessibility"} ALLOWED_SCORES = {"green", "yellow", "red"} MAX_NOTE_LEN = 1000 From 8e43737eeec3f53b3be8375cacca3916425e9ee0 Mon Sep 17 00:00:00 2001 From: Greg V Date: Thu, 28 May 2026 08:45:44 -0700 Subject: [PATCH 3/3] [Perf] Email caching --- services/volunteers_service.py | 282 ++++++++++++++++++++------------- 1 file changed, 171 insertions(+), 111 deletions(-) diff --git a/services/volunteers_service.py b/services/volunteers_service.py index 4b81e37..5b43fe5 100644 --- a/services/volunteers_service.py +++ b/services/volunteers_service.py @@ -1,6 +1,7 @@ from typing import Dict, List, Optional, Union, Any, Tuple import uuid import time +import threading from datetime import datetime import pytz from functools import lru_cache @@ -2451,7 +2452,8 @@ def send_volunteer_message( # Invalidate Resend email list cache so next fetch picks up the new email if delivery_status['email_sent']: - delete_cached("resend:all_emails_index") + delete_cached(_RESEND_INDEX_KEY) + delete_cached(_RESEND_FRESH_KEY) result = { 'success': success, @@ -2621,7 +2623,8 @@ def send_email_to_address( # Invalidate Resend email list cache so next fetch picks up the new email if email_success: - delete_cached("resend:all_emails_index") + delete_cached(_RESEND_INDEX_KEY) + delete_cached(_RESEND_FRESH_KEY) result = { 'success': email_success, @@ -2689,11 +2692,142 @@ def get_resend_email_statuses(email_ids: list) -> Dict[str, Any]: return {'success': False, 'error': str(e)} +# Cache keys for stale-while-revalidate pattern +_RESEND_INDEX_KEY = "resend:all_emails_index" # full data, long TTL (stale) +_RESEND_FRESH_KEY = "resend:all_emails_fresh" # freshness flag, short TTL +_RESEND_LOCK_KEY = "resend:all_emails_refreshing" # background-refresh lock + +_RESEND_STALE_TTL = 3600 # keep data for 1 hour +_RESEND_FRESH_TTL = 300 # re-fetch after 5 minutes +_RESEND_LOCK_TTL = 120 # lock expires after 2 minutes (prevents stampede) + + +def _fetch_and_cache_resend_emails() -> Optional[Dict[str, Any]]: + """ + Fetch all pages from Resend and update both cache keys. + Returns the full payload dict on success, None on failure. + Called either inline (first load) or from a background thread. + """ + resend.api_key = os.environ.get('RESEND_EMAIL_STATUS_KEY') + if not resend.api_key: + return None + + all_emails = [] + max_pages = 100 + max_retries = 2 + params = {"limit": 100} + page_error_occurred = False + truncated = False + + for page in range(max_pages): + retries = 0 + while True: + try: + response = resend.Emails.list(params) + email_list = response.get('data', []) if isinstance(response, dict) else getattr(response, 'data', []) + all_emails.extend(email_list) + info(logger, "Fetched Resend emails page", + page=page, count=len(email_list), total_so_far=len(all_emails)) + + has_more = response.get('has_more', False) if isinstance(response, dict) else getattr(response, 'has_more', False) + if not has_more or len(email_list) == 0: + break + + last_item = email_list[-1] + last_id = last_item.get('id', '') if isinstance(last_item, dict) else getattr(last_item, 'id', '') + if not last_id: + break + params = {"limit": 100, "after": last_id} + break # success — move to next page + + except Exception as page_error: + retries += 1 + if retries <= max_retries: + warning(logger, "Retrying Resend emails page fetch", + page=page, retry=retries, exc_info=page_error) + time.sleep(1 * retries) + continue + error(logger, "Error fetching Resend emails page after retries", + page=page, exc_info=page_error) + page_error_occurred = True + break + if page_error_occurred: + break + else: + truncated = True + warning(logger, "Resend email pagination hit safety cap", + max_pages=max_pages, total_so_far=len(all_emails)) + + if page_error_occurred: + # Release lock so the next request can retry + delete_cached(_RESEND_LOCK_KEY) + return None + + # Build index by recipient email + index: Dict[str, List[Dict]] = {} + for email_data in all_emails: + if isinstance(email_data, dict): + recipients = email_data.get('to', []) + email_id = email_data.get('id', '') + subject = email_data.get('subject', '') + created_at = email_data.get('created_at', '') + last_event = email_data.get('last_event', '') + else: + recipients = getattr(email_data, 'to', []) + email_id = getattr(email_data, 'id', '') + subject = getattr(email_data, 'subject', '') + created_at = getattr(email_data, 'created_at', '') + last_event = getattr(email_data, 'last_event', '') + + if isinstance(recipients, str): + recipients = [recipients] + + entry = {'id': email_id, 'subject': subject, + 'created_at': created_at, 'last_event': last_event} + + for recipient in recipients: + key = recipient.lower().strip() + if key not in index: + index[key] = [] + index[key].append(entry) + + total_fetched = len(all_emails) + info(logger, "Built Resend email index", + total_fetched=total_fetched, unique_recipients=len(index), + truncated=truncated) + + payload = { + 'emails_by_recipient': index, + 'total_fetched': total_fetched, + 'truncated': truncated + } + # Store data with long TTL; store freshness flag with short TTL + set_cached(_RESEND_INDEX_KEY, payload, ttl=_RESEND_STALE_TTL) + set_cached(_RESEND_FRESH_KEY, True, ttl=_RESEND_FRESH_TTL) + delete_cached(_RESEND_LOCK_KEY) + return payload + + +def _background_refresh_resend_emails() -> None: + """Fire-and-forget background refresh wrapped in try/except.""" + try: + _fetch_and_cache_resend_emails() + info(logger, "Background Resend email cache refresh completed") + except Exception as bg_err: + error(logger, "Background Resend email cache refresh failed", exc_info=bg_err) + delete_cached(_RESEND_LOCK_KEY) + + def list_all_resend_emails(filter_emails=None): """ Fetch all sent emails from Resend's List Emails API, cached in Redis. Returns an index of {recipient_email: [{id, subject, created_at, last_event}, ...]}. + Uses a stale-while-revalidate strategy: + - Fresh cache hit → return immediately (fast path) + - Stale cache hit → return stale data immediately + refresh in background + - No cache at all → block on first fetch then return + Args: filter_emails: Optional list of email addresses to filter results for. @@ -2705,11 +2839,28 @@ def list_all_resend_emails(filter_emails=None): if not resend.api_key: return {'success': False, 'error': 'Resend API key not configured'} - cache_key = "resend:all_emails_index" - cached = get_cached(cache_key) + is_fresh = get_cached(_RESEND_FRESH_KEY) is not None + cached = get_cached(_RESEND_INDEX_KEY) + if cached is not None: - info(logger, "Using cached Resend email index", - total_emails=cached.get('total_fetched', 0)) + if not is_fresh: + # Stale — trigger a background refresh if one isn't already running + if get_cached(_RESEND_LOCK_KEY) is None: + set_cached(_RESEND_LOCK_KEY, True, ttl=_RESEND_LOCK_TTL) + t = threading.Thread( + target=_background_refresh_resend_emails, + daemon=True + ) + t.start() + info(logger, "Stale Resend email cache — background refresh triggered", + total_fetched=cached.get('total_fetched', 0)) + else: + info(logger, "Stale Resend email cache — refresh already in progress", + total_fetched=cached.get('total_fetched', 0)) + else: + info(logger, "Using fresh cached Resend email index", + total_emails=cached.get('total_fetched', 0)) + index = cached['emails_by_recipient'] if filter_emails: filter_set = {e.lower() for e in filter_emails} @@ -2719,115 +2870,23 @@ def list_all_resend_emails(filter_emails=None): 'emails_by_recipient': index, 'total_fetched': cached['total_fetched'], 'truncated': cached.get('truncated', False), - 'from_cache': True + 'from_cache': True, + 'stale': not is_fresh } - # Paginate through resend.Emails.list() (requires resend >= 2.5) - # Safety cap of 100 pages (~10,000 emails); for-else detects if we hit it. - all_emails = [] - max_pages = 100 - max_retries = 2 - params = {"limit": 100} - page_error_occurred = False - truncated = False - - for page in range(max_pages): - retries = 0 - while True: - try: - response = resend.Emails.list(params) - email_list = response.get('data', []) if isinstance(response, dict) else getattr(response, 'data', []) - all_emails.extend(email_list) - info(logger, "Fetched Resend emails page", - page=page, count=len(email_list), total_so_far=len(all_emails)) - - has_more = response.get('has_more', False) if isinstance(response, dict) else getattr(response, 'has_more', False) - if not has_more or len(email_list) == 0: - break - - # Use last email ID as cursor for next page - last_item = email_list[-1] - last_id = last_item.get('id', '') if isinstance(last_item, dict) else getattr(last_item, 'id', '') - if not last_id: - break - params = {"limit": 100, "after": last_id} - break # success — move to next page - - except Exception as page_error: - retries += 1 - if retries <= max_retries: - warning(logger, "Retrying Resend emails page fetch", - page=page, retry=retries, exc_info=page_error) - time.sleep(1 * retries) - continue - error(logger, "Error fetching Resend emails page after retries", - page=page, exc_info=page_error) - page_error_occurred = True - break - if page_error_occurred: - break - else: - # Loop exhausted max_pages without a natural break — results are truncated. - truncated = True - warning(logger, "Resend email pagination hit safety cap", - max_pages=max_pages, total_so_far=len(all_emails)) - - # If a page fetch failed, return an error rather than caching partial data. - if page_error_occurred: + # No cached data at all — block on initial fetch + info(logger, "No Resend email cache found — fetching synchronously") + set_cached(_RESEND_LOCK_KEY, True, ttl=_RESEND_LOCK_TTL) + payload = _fetch_and_cache_resend_emails() + if payload is None: return { 'success': False, - 'error': 'Failed to fetch all email pages from Resend', + 'error': 'Failed to fetch email pages from Resend', 'emails_by_recipient': {}, - 'total_fetched': len(all_emails) - } - - # Build index by recipient email - index = {} - for email_data in all_emails: - if isinstance(email_data, dict): - recipients = email_data.get('to', []) - email_id = email_data.get('id', '') - subject = email_data.get('subject', '') - created_at = email_data.get('created_at', '') - last_event = email_data.get('last_event', '') - else: - recipients = getattr(email_data, 'to', []) - email_id = getattr(email_data, 'id', '') - subject = getattr(email_data, 'subject', '') - created_at = getattr(email_data, 'created_at', '') - last_event = getattr(email_data, 'last_event', '') - - if isinstance(recipients, str): - recipients = [recipients] - - entry = { - 'id': email_id, - 'subject': subject, - 'created_at': created_at, - 'last_event': last_event, + 'total_fetched': 0 } - for recipient in recipients: - key = recipient.lower().strip() - if key not in index: - index[key] = [] - index[key].append(entry) - - total_fetched = len(all_emails) - - info(logger, "Built Resend email index", - total_fetched=total_fetched, unique_recipients=len(index), - truncated=truncated) - - # Cache the full index with 300s TTL, including empty results so we - # don't hammer the Resend API on every request when there are no emails. - set_cached(cache_key, { - 'emails_by_recipient': index, - 'total_fetched': total_fetched, - 'truncated': truncated - }, ttl=300) - - # Filter if requested + index = payload['emails_by_recipient'] if filter_emails: filter_set = {e.lower() for e in filter_emails} index = {k: v for k, v in index.items() if k in filter_set} @@ -2835,9 +2894,10 @@ def list_all_resend_emails(filter_emails=None): return { 'success': True, 'emails_by_recipient': index, - 'total_fetched': total_fetched, - 'truncated': truncated, - 'from_cache': False + 'total_fetched': payload['total_fetched'], + 'truncated': payload['truncated'], + 'from_cache': False, + 'stale': False } except Exception as e: