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
17 changes: 17 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ To send a Slack DM, pass the Slack user ID as `channel`: `send_slack(message=...

These are DIFFERENT VALUES. When bundling user data for the frontend, include both: `{user_id: propel_id, db_id: firestore_doc_id, name, profile_image}`. The frontend needs `db_id` to build profile links and `user_id` (propel) for matching against assignees/editors/mentions.

## 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)`, which **lazily creates the `users` doc when missing** (the lazy-creation gotcha). Before this, a user who'd authenticated but never opened their profile had no doc, so `fetch_user_by_user_id` → None → 404 on BOTH read and write — surfaced on the frontend as "Failed to load your volunteer data" and blocked them from starting a session ("not working for some people"). `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.

## Admin Email Templates (`email_templates` collection)
Powers the frontend `/admin/communication` template editor and the volunteer send-email dialogs. Blueprint: `api/email_templates/email_templates_views.py` (`/api/admin/templates*`, all `volunteer.admin`-gated); service: `services/email_templates_service.py`; seed data: `services/email_templates_seed.py`.
- Layout: `email_templates/{slug}` main doc + `email_templates/{slug}/versions/{000N}` append-only content snapshots. `version` on the main doc = current version number; version doc ids are zero-padded for natural ordering.
Expand Down Expand Up @@ -151,3 +154,17 @@ Two scripts live in `scripts/` for diagnosing and backfilling team rosters on `/
`scripts/sync_resend_audience.py --source {all|profiles|volunteers|mentors|judges|sponsors|helpers|leads} --audience "<name>" [--event-id <id>] [--selected-only] [--apply]` — pulls emails from Firestore (`users.email_address`, `volunteers.email` filtered by `volunteer_type`, `leads.email`) and upserts contacts into a Resend audience (creates if missing). Dry-run by default. Re-runnable: lists existing audience contacts first and only POSTs new emails. Needs `RESEND_API_KEY` with audiences scope — the existing `RESEND_WELCOME_EMAIL_KEY` is send-only and will 401. Uses the deprecated `resend.Audiences` SDK class (now an alias for Segments) — fine for now, but if it breaks switch to `resend.Segments`.

The frontend `/hack/<event_id>` page's "Team Members:" list is `teams.users[]` (DocumentReferences). The bug pattern that motivated this: a team's `users[]` only contains the user who created the team on ohack.dev; everyone else registered via Devpost/JotForm and was never linked. Use `audit` first to confirm, then `import ... --csv-type roster` (or `projects` for old Devpost exports) to backfill.

## Event Feedback Surveys (`surveys` collection)
Post-event / live-event feedback, stored in a NEW `surveys` collection — deliberately distinct from the peer-to-peer `feedback` collection. Blueprint `api/surveys/surveys_views.py` + `services` in `api/surveys/surveys_service.py`. Routes (`/api/surveys/<event_id>/...`):
- `GET context` — public (`@auth.optional_user`). Returns `mode` (`live|post|upcoming`), the caller's eligible `roles`, `primary_role`, `requires_captcha`, `already_submitted`, and a light `event` block.
- `POST responses` — public (`@auth.optional_user`). Logged-in volunteers who are eligible for the event are "trusted" and skip CAPTCHA; **everyone else (nonprofit partners — no flag yet — and anonymous) must pass reCAPTCHA** via the shared `verify_recaptcha` from `api.contact.contact_service`.
- `GET responses` / `GET summary` (per-event) and `GET /api/surveys/overview` (cross-event) — `volunteer.admin`-gated. `overview` is **one** scan of the `surveys` collection grouped by `event_id` (`get_cross_event_survey_overview`): per-event `count`/`by_mode`/`by_role`, averages of the two universal scales (`overall_rating`, `would_return`), `first/last_response`, joined with hackathon `title`/dates/tz (lazy-import `get_hackathon_list`). Aggregates only — no per-response data, no PII. Powers the "Compare events" sub-view; static `/surveys/overview` doesn't collide with `/surveys/<event_id>/...`.

`compute_event_mode` is timezone-aware off `start_date`/`end_date` (mirrors the frontend `isHackathonExpired`; missing dates → `live`). Eligible roles come from the `volunteers` collection via `get_user_event_roles` — hacker counts on application, mentor/judge/volunteer/sponsor require `isSelected` — matched 3 ways (propel UUID / email / OAuth user_id) like `handle_get`. **Role scope**: a trusted volunteer may submit ONLY for a role they're selected for; everyone else is restricted to `nonprofit` (`allowed_roles_for`). Enforced in `submit_survey_response` (403 on mismatch) and surfaced as `allowed_roles` in the context response so the frontend can scope its selector. Logged-in responses **upsert** by doc id `{event_id}__{mode}__{propel_user_id}` with a full `set()` (NOT `merge=True`, which would deep-merge the `answers` map and keep cleared keys) carrying `created_at` forward; anonymous responses are random-uuid docs. Heavy/external deps (`get_hackathon_by_event_id`, `verify_recaptcha`, slack) are lazy-imported inside functions so the module imports cheaply (testable; pure date/mode logic covered in `api/surveys/tests/`). Question IDs/catalog live frontend-side; backend stores `answers` verbatim. Live-mode submissions ping the `#feedback` Slack channel; post-mode is audit-only.

## Admin feedback review (`api/feedback/`)
Read-only admin aggregation for the frontend `/admin/feedback` dashboard. New blueprint `api/feedback/feedback_views.py` (NOT messages_views — frozen) + `api/feedback/feedback_service.py` (the admin READ side; writes still live in `services/feedback_service.py` + `services/onboarding_service.py` + the surveys domain). Both routes `volunteer.admin`-gated, `?limit=` (default 500, cap 2000):
- `GET /api/admin/feedback/peer` — peer-to-peer `feedback` collection, newest first, giver/receiver names resolved via a one-shot `fetch_users()` directory (giver hidden when `is_anonymous`); light `by_relationship`/`by_role` summary.
- `GET /api/admin/feedback/onboarding` — `onboarding_feedbacks` collection, newest first, + rating & ease distributions. Keeps `clientInfo.userAgent`, drops the IP. **Field shape (load-bearing for the admin UI)**: maps `contactForFollowup` → `contact: {willing, firstName, email}` (the form stores `firstName`+`willing`, NOT `name` — don't revert to `name`); `overallRating` 0 = unrated (skipped in the average); timestamps are normalized to ISO, stripping a `__Timestamp__` export sentinel (see `scripts/sync_hackathons_from_csv.py`) if present.
Event surveys reuse the `api/surveys` admin routes (`/responses`, `/summary`, `/overview`) — not duplicated here.
4 changes: 4 additions & 0 deletions api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,8 @@ def add_headers(response):
from api.planning import planning_views
from api.mentors import mentors_views
from api.email_templates import email_templates_views
from api.surveys import surveys_views
from api.feedback import feedback_views

app.register_blueprint(messages_views.bp)
app.register_blueprint(exception_views.bp)
Expand All @@ -209,5 +211,7 @@ def add_headers(response):
app.register_blueprint(planning_views.bp)
app.register_blueprint(mentors_views.bp)
app.register_blueprint(email_templates_views.bp)
app.register_blueprint(surveys_views.bp)
app.register_blueprint(feedback_views.bp)

return app
Empty file added api/feedback/__init__.py
Empty file.
170 changes: 170 additions & 0 deletions api/feedback/feedback_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
"""Admin-side read / aggregation for the feedback-review dashboard.

The WRITE paths for these collections live elsewhere on purpose and are NOT
touched here:
- peer-to-peer `feedback` -> services/feedback_service.py (save_feedback)
- `onboarding_feedbacks` -> services/onboarding_service.py (save_onboarding_feedback)
- event `surveys` -> api/surveys/surveys_service.py (has its own admin reads)

This module is only the admin REVIEW side: list + light aggregate for
`/admin/feedback`. Every caller is `volunteer.admin`-gated at the view layer.
Reads are PII-light: peer feedback hides the giver when the entry is anonymous;
onboarding keeps the user-agent but drops the IP address.
"""
from typing import Any, Dict, Optional

from db.db import get_db, fetch_users
from common.log import get_logger, warning

logger = get_logger(__name__)

PEER_FEEDBACK_COLLECTION = "feedback"
ONBOARDING_COLLECTION = "onboarding_feedbacks"
DEFAULT_LIMIT = 500
# Sentinel some docs carry when seeded from a Firestore CSV export
# (see scripts/sync_hackathons_from_csv.py); strip it so timestamps parse.
_TIMESTAMP_PREFIX = "__Timestamp__"


def _user_directory() -> Dict[str, Dict[str, Any]]:
"""db_id -> light public identity, best-effort.

One users-collection scan. The admin dashboard isn't a hot path, and
peer-feedback giver/receiver ids are arbitrary user doc ids, so a single
map is cheaper than N point reads.
"""
directory: Dict[str, Dict[str, Any]] = {}
try:
for u in fetch_users() or []:
uid = getattr(u, "id", None)
if not uid:
continue
directory[uid] = {
"name": getattr(u, "name", "") or getattr(u, "nickname", "") or "",
"profile_image": getattr(u, "profile_image", None),
}
except Exception as e: # pragma: no cover - defensive
warning(logger, "feedback-admin: user directory build failed", exc_info=e)
return directory


def _resolve(directory: Dict[str, Dict[str, Any]], db_id: Optional[str]):
if not db_id:
return None
info = directory.get(db_id) or {}
return {
"id": db_id,
"name": info.get("name") or "Unknown",
"profile_image": info.get("profile_image"),
}


def list_peer_feedback(limit: Optional[int] = None) -> Dict[str, Any]:
"""All peer-to-peer feedback, newest first, names resolved (giver hidden
when anonymous), plus a light breakdown by relationship and role."""
db = get_db()
limit = limit or DEFAULT_LIMIT
docs = list(db.collection(PEER_FEEDBACK_COLLECTION).stream())
directory = _user_directory()

items = []
by_relationship: Dict[str, int] = {}
by_role: Dict[str, int] = {}
for doc in docs:
d = doc.to_dict() or {}
is_anon = bool(d.get("is_anonymous", False))
fb = d.get("feedback") if isinstance(d.get("feedback"), dict) else {}
role = fb.get("role")
rel = d.get("relationship")
if rel:
by_relationship[rel] = by_relationship.get(rel, 0) + 1
if role:
by_role[role] = by_role.get(role, 0) + 1
items.append({
"id": doc.id,
"receiver": _resolve(directory, d.get("feedback_receiver_id")),
"giver": None if is_anon else _resolve(directory, d.get("feedback_giver_id")),
"is_anonymous": is_anon,
"relationship": rel,
"duration": d.get("duration"),
"confidence_level": d.get("confidence_level"),
"role": role,
"feedback": fb, # nested: role + skill scores (0-100) + text fields
"timestamp": d.get("timestamp"),
})

items.sort(key=lambda x: x.get("timestamp") or "", reverse=True)
items = items[:limit]
return {
"success": True,
"count": len(items),
"summary": {"by_relationship": by_relationship, "by_role": by_role},
"feedback": items,
}


def list_onboarding_feedback(limit: Optional[int] = None) -> Dict[str, Any]:
"""All onboarding feedback, newest first, with a rating distribution.

Drops `clientInfo.ipAddress`; keeps the user-agent for debugging dupes.
"""
db = get_db()
limit = limit or DEFAULT_LIMIT
docs = list(db.collection(ONBOARDING_COLLECTION).stream())

items = []
rating_dist: Dict[str, int] = {}
ease_dist: Dict[str, int] = {}
rating_sum = 0.0
rating_count = 0
for doc in docs:
d = doc.to_dict() or {}
rating = d.get("overallRating")
if isinstance(rating, (int, float)) and rating: # 0 == unrated, don't count
rating_dist[str(int(rating))] = rating_dist.get(str(int(rating)), 0) + 1
rating_sum += rating
rating_count += 1
ease = d.get("easeOfUnderstanding") or ""
if ease:
ease_dist[ease] = ease_dist.get(ease, 0) + 1

ts = d.get("timestamp")
if hasattr(ts, "isoformat"):
ts = ts.isoformat()
elif isinstance(ts, str) and ts.startswith(_TIMESTAMP_PREFIX):
# CSV-export sentinel (see scripts/sync_hackathons_from_csv.py) -> clean ISO
ts = ts[len(_TIMESTAMP_PREFIX):]

# contactForFollowup is {"willing": False} or {"willing": True, "firstName", "email"}
contact = d.get("contactForFollowup") or {}
client = d.get("clientInfo") or {}
items.append({
"id": doc.id,
"overallRating": rating,
"usefulTopics": d.get("usefulTopics") or [],
"missingTopics": d.get("missingTopics") or "",
"easeOfUnderstanding": ease,
"improvements": d.get("improvements") or "",
"additionalFeedback": d.get("additionalFeedback") or "",
"contact": {
"willing": bool(contact.get("willing")),
"firstName": contact.get("firstName") or contact.get("name") or "",
"email": contact.get("email") or "",
},
"userAgent": client.get("userAgent") or "", # IP intentionally dropped
"timestamp": ts,
})

items.sort(key=lambda x: x.get("timestamp") or "", reverse=True)
items = items[:limit]
avg = round(rating_sum / rating_count, 2) if rating_count else None
return {
"success": True,
"count": len(items),
"summary": {
"rating_distribution": rating_dist,
"ease_distribution": ease_dist,
"overall_rating": {"count": rating_count, "average": avg},
},
"onboarding_feedback": items,
}
40 changes: 40 additions & 0 deletions api/feedback/feedback_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from flask import Blueprint, jsonify, request

from common.log import get_logger
from common.auth import auth, getOrgId
from api.feedback.feedback_service import (
list_peer_feedback,
list_onboarding_feedback,
)

logger = get_logger(__name__)
bp = Blueprint("feedback_admin", __name__, url_prefix="/api")


def _limit(default: int = 500, cap: int = 2000) -> int:
try:
return min(int(request.args.get("limit", default)), cap)
except (TypeError, ValueError):
return default


@bp.route("/admin/feedback/peer", methods=["GET"])
@auth.require_org_member_with_permission("volunteer.admin", req_to_org_id=getOrgId)
def admin_peer_feedback():
"""Admin: all peer-to-peer feedback (newest first), names resolved."""
try:
return jsonify(list_peer_feedback(_limit())), 200
except Exception as e:
logger.exception("Error listing peer feedback: %s", str(e))
return jsonify({"success": False, "error": str(e), "feedback": []}), 500


@bp.route("/admin/feedback/onboarding", methods=["GET"])
@auth.require_org_member_with_permission("volunteer.admin", req_to_org_id=getOrgId)
def admin_onboarding_feedback():
"""Admin: all onboarding feedback (newest first) + rating distribution."""
try:
return jsonify(list_onboarding_feedback(_limit())), 200
except Exception as e:
logger.exception("Error listing onboarding feedback: %s", str(e))
return jsonify({"success": False, "error": str(e), "onboarding_feedback": []}), 500
Empty file added api/surveys/__init__.py
Empty file.
Loading
Loading