diff --git a/CLAUDE.md b/CLAUDE.md index 8a76845..841b657 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,3 +65,54 @@ Deployed to Fly.io (`fly.toml`, app: `backend-ohack`, region: `sjc`). Uses gunic - Naming: snake_case for variables/functions, PascalCase for classes - Error handling: try/except with specific exceptions - Linting: pylint (`.pylintrc` disables missing-module-docstring, missing-function-docstring, too-few-public-methods) + +## Gotchas (load-bearing — every one of these has bitten us) + +### Python 3.9 — no PEP 604 union syntax +Backend runs on Python 3.9. `def foo() -> X | None:` raises `TypeError` at *import time*, blowing up every endpoint that imports the module. Use `Optional[X]` from `typing`. Audit any new `services/` module before committing. + +### PropelAuth user object — correct attribute names +`auth_user` (`current_user` from `propelauth_flask`) wraps the full PropelAuth `User` class. The attributes are NOT what they look like: + +- `user.org_id_to_org_member_info` — dict of `{org_id: OrgMemberInfo}`. **NOT** `org_id_to_org_info` (which silently returns `None` from `getattr`, leaving every admin check returning `False`). +- Each `OrgMemberInfo` is an *object*, not a dict. Use `.user_permissions` (attribute) or `.user_has_permission(perm)` (method). `org_info.get("user_permissions")` always returns `None`. + +Pattern for "is this user a global admin": +```python +def is_admin(propel_user) -> bool: + if not propel_user or not getattr(propel_user, "user_id", None): + return False + for org_info in (getattr(propel_user, "org_id_to_org_member_info", None) or {}).values(): + if org_info.user_has_permission("volunteer.admin"): + return True + return False +``` + +For most route protection, prefer the existing decorator: `@auth.require_org_member_with_permission("volunteer.admin", req_to_org_id=getOrgId)`. Only roll your own check when combining multiple gates (per-resource editors list, etc.). + +### Firestore compound queries require composite indexes in production +`.where("X", "==", v).order_by("Y")` (where `Y != X`) needs a composite index declared in `firestore.indexes.json` AND deployed via `firebase deploy --only firestore:indexes`. The local Firestore emulator silently allows these queries; production Firestore returns 500 with "The query requires an index" — the route just hangs/errors. + +Two options: +1. **Sort in Python after a single-field where** (preferred when the result set is small): `sorted([... for d in coll.where(...).stream()], key=lambda x: x["pos"])`. No index needed because single-field equality is auto-indexed. +2. **Add the composite index to `firestore.indexes.json`** AND deploy. Don't forget the deploy step — committing to the repo doesn't apply it. + +### Lazy user profile creation in the `users` collection +A user authenticated via PropelAuth may NOT exist in the Firestore `users` collection. The collection is populated lazily — only when someone hits `GET /api/users/profile` or saves profile metadata. Never assume `fetch_users()` includes everyone with a `propel_user_id` referenced elsewhere (assignees, editors, mentions, etc.). + +When resolving propel_id → display profile, fall back to `services.users_service.get_oauth_user_from_propel_user_id(pid)` for IDs not in the cached `fetch_users()` index. Cache the fallback aggressively (5 min minimum) — PropelAuth API calls aren't free. + +### OAuth provider response shapes (Slack vs Google) +`get_oauth_user_from_propel_user_id(propel_id)` returns the raw OAuth userinfo. Provider-specific fields: + +- **Slack**: `https://slack.com/user_id` (e.g. `UC31XTRT5`) is the Slack workspace user ID. `https://slack.com/user_image_192` for avatar. `email` always present. +- **Google**: no Slack ID. `picture` for avatar. `email` always present. +- Detect by presence of `https://slack.com/user_id` — Google responses don't have it. + +To send a Slack DM, pass the Slack user ID as `channel`: `send_slack(message=..., channel="UC31XTRT5")`. `chat.postMessage` opens (or reuses) a DM channel. + +### `User.id` vs `User.user_id` +- `User.id` = Firestore document ID (used by `/api/users/{id}/profile/public` and the frontend `/profile/{id}` route). +- `User.user_id` = PropelAuth user ID (the `propel_id`, what's stored in `assignees[]`, `editors[]`, etc.). + +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. diff --git a/api/__init__.py b/api/__init__.py index 1a41475..cb7e30e 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -184,6 +184,7 @@ def add_headers(response): from api.judging import judging_views from api.llm import llm_views from api.store import store_views + from api.planning import planning_views app.register_blueprint(messages_views.bp) app.register_blueprint(exception_views.bp) @@ -203,5 +204,6 @@ def add_headers(response): app.register_blueprint(llm_views.bp) app.register_blueprint(judging_views.bp) app.register_blueprint(store_views.bp) + app.register_blueprint(planning_views.bp) return app diff --git a/api/messages/messages_service.py b/api/messages/messages_service.py index d051fd8..62ab73d 100644 --- a/api/messages/messages_service.py +++ b/api/messages/messages_service.py @@ -222,6 +222,7 @@ def save_problem_statement_old(json): github = json["github"] references = json["references"] status = json["status"] + rank = json.get("rank") collection = db.collection('problem_statements') @@ -232,7 +233,8 @@ def save_problem_statement_old(json): "first_thought_of": first_thought_of, "github": github, "references": references, - "status": status + "status": status, + "rank": rank }) logger.debug(f"Insert Result: {insert_res}") diff --git a/api/planning/__init__.py b/api/planning/__init__.py new file mode 100644 index 0000000..ac2cff6 --- /dev/null +++ b/api/planning/__init__.py @@ -0,0 +1 @@ +from api.planning.planning_views import bp # noqa: F401 diff --git a/api/planning/planning_views.py b/api/planning/planning_views.py new file mode 100644 index 0000000..284bd16 --- /dev/null +++ b/api/planning/planning_views.py @@ -0,0 +1,1129 @@ +"""Planning board REST API. + +URL prefix: /api/planning + +Firestore layout (subcollections under hackathons/{hid}): + planning_lists/{list_id} + planning_cards/{card_id} + planning_comments/{comment_id} + planning_labels/{label_id} + planning_activity/{aid} (append-only) + planning_pending_digests/{doc_id} (for Slack digest queue) +""" +import hashlib +import json +import logging +import uuid +from datetime import datetime, timezone + +from flask import Blueprint, g, jsonify, request + +from common.auth import auth, auth_user +from common.utils.firebase import get_db, get_hackathon_by_event_id +from model.planning import ( + ALLOWED_BUDGET_BUCKETS, + ALLOWED_BUDGET_STATES, + ALLOWED_CARD_KINDS, + ALLOWED_CARD_STATUSES, + ALLOWED_OUTREACH_STATUSES, + ALLOWED_SPONSOR_TIERS, + MAX_ATTACHMENTS_PER_CARD, + MAX_BUDGET_CENTS, + MAX_CARD_DESCRIPTION_LEN, + MAX_CARD_TITLE_LEN, + MAX_CHECKLIST_ITEMS, + MAX_CHECKLISTS_PER_CARD, + MAX_COMMENT_LEN, + MAX_COMMENTS_PER_USER_PER_MIN, + MAX_PLANNING_CARDS_PER_LIST, + MAX_PLANNING_LABELS, + MAX_PLANNING_LISTS, + PLANNING_FIELD, +) +from services.hackathon_planning_service import ( + can_comment, + is_admin, + load_hackathon_or_404, + require_admin_on_event, + require_logged_in_on_enabled_plan, + require_plan_editor, +) + +logger = logging.getLogger("planning_views") + +bp = Blueprint("planning", __name__, url_prefix="/api/planning") + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +INTERNAL_TOKEN_HEADER = "X-Internal-Token" + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _get_hackathon_ref(hackathon_doc): + """Return the Firestore DocumentReference for the given hackathon dict.""" + db = get_db() + return db.collection("hackathons").document(hackathon_doc["id"]) + + +def _subcol(hackathon_doc, subcollection: str): + return _get_hackathon_ref(hackathon_doc).collection(subcollection) + + +def _record_activity(hackathon_doc, kind: str, summary: str, actor_id: str, **extra): + try: + _subcol(hackathon_doc, "planning_activity").add({ + "kind": kind, + "summary": summary, + "actor": actor_id, + "created_at": _now_iso(), + **extra, + }) + except Exception: + logger.exception("Failed to write activity record") + + +def _check_if_match(doc_dict): + """Return 412 response tuple if If-Match header doesn't match updated_at.""" + expected = request.headers.get("If-Match") + if expected and doc_dict.get("updated_at") != expected: + return jsonify({"error": "Conflict", "updated_at": doc_dict.get("updated_at")}), 412 + return None + + +def _enqueue_slack_digest(hackathon_doc, event_data: dict): + """Append a pending Slack digest event; triggers lazy flush on the next write after deadline.""" + try: + planning = hackathon_doc.get(PLANNING_FIELD) or {} + slack = planning.get("slack") or {} + if not slack.get("notify_on_card_change"): + return + if request.get_json(silent=True, force=True) and request.get_json(silent=True, force=True).get("notify_slack") is False: + return + _subcol(hackathon_doc, "planning_pending_digests").add({ + "created_at": _now_iso(), + **event_data, + }) + except Exception: + logger.exception("Failed to enqueue Slack digest event") + + +def _compute_etag(lists_docs, cards_docs, labels_docs) -> str: + updated_ats = [] + for d in lists_docs + cards_docs + labels_docs: + ua = d.get("updated_at") or d.get("created_at") or "" + updated_ats.append(ua) + updated_ats.sort() + payload = "|".join(updated_ats).encode() + return hashlib.sha1(payload).hexdigest() + + +# --------------------------------------------------------------------------- +# Rate limit (comment spam) — Redis-backed when available, else no-op +# --------------------------------------------------------------------------- + +def _check_comment_rate_limit(user_id: str): + """Return 429 response if user exceeds MAX_COMMENTS_PER_USER_PER_MIN.""" + try: + from common.utils.redis_cache import get_redis_client + rc = get_redis_client() + if rc is None: + return None + key = f"planning:comment_rate:{user_id}" + count = rc.incr(key) + if count == 1: + rc.expire(key, 60) + if count > MAX_COMMENTS_PER_USER_PER_MIN: + return jsonify({"error": "Too many comments"}), 429 + except Exception: + pass + return None + + +# --------------------------------------------------------------------------- +# Board snapshot +# --------------------------------------------------------------------------- + +@bp.route("/", methods=["GET"]) +def get_board(event_id): + """Public board snapshot: lists + cards + labels. ETag-based caching.""" + hackathon = load_hackathon_or_404(event_id) + planning = hackathon.get(PLANNING_FIELD) or {} + if not planning.get("enabled"): + return jsonify({"enabled": False}), 200 + + hid = hackathon["id"] + db = get_db() + href = db.collection("hackathons").document(hid) + + lists_docs = sorted( + [{**d.to_dict(), "id": d.id} for d in href.collection("planning_lists").where("archived", "==", False).stream()], + key=lambda x: x.get("position", ""), + ) + cards_docs = sorted( + [{**d.to_dict(), "id": d.id} for d in href.collection("planning_cards").where("archived", "==", False).stream()], + key=lambda x: x.get("position", ""), + ) + labels_docs = [ + {**d.to_dict(), "id": d.id} + for d in href.collection("planning_labels").stream() + ] + + etag = _compute_etag(lists_docs, cards_docs, labels_docs) + if request.headers.get("If-None-Match") == etag: + return "", 304 + + # Lazy Slack digest flush when past deadline + _maybe_flush_digests_read_driven(hackathon) + + # Resolve display profiles for everyone referenced in cards.assignees + planning.editors + # so the frontend can render avatars without per-user round-trips. + referenced_ids = set() + for c in cards_docs: + referenced_ids.update(c.get("assignees") or []) + referenced_ids.update(planning.get("editors") or []) + users_map = _resolve_public_user_profiles(referenced_ids) + + resp = jsonify({ + "event_id": event_id, + "planning": planning, + "lists": lists_docs, + "cards": cards_docs, + "labels": labels_docs, + "users": users_map, + }) + resp.headers["ETag"] = etag + return resp, 200 + + +# Cache resolved user profiles briefly so the board snapshot doesn't pay for +# fetch_users() on every poll. 60s is short enough that name/avatar changes +# show up quickly while cutting the read load by ~10x for an active board. +_USER_PROFILES_CACHE = {"profiles": None, "expires_at": 0} + + +_PROPEL_FALLBACK_CACHE = {} # propel_id -> {profile dict, expires_at} +_PROPEL_FALLBACK_TTL = 300 + + +def _resolve_public_user_profiles(propel_ids): + """Return {propel_id: {name, profile_image, nickname, db_id}} for the given set. + + Public-safe fields only. db_id is the Firestore document ID — used by the + frontend to link to /profile/{db_id}. None when the user has no profile + record yet (just authenticated, never triggered profile save). + + Falls back to a one-shot PropelAuth OAuth lookup when a propel_id isn't + in the cached Firestore user index — covers users who logged in but + haven't yet been auto-saved to the users collection. + """ + import time + if not propel_ids: + return {} + + now = time.time() + if not _USER_PROFILES_CACHE["profiles"] or _USER_PROFILES_CACHE["expires_at"] < now: + try: + from db.db import fetch_users + all_users = fetch_users() or [] + indexed = {} + for u in all_users: + pid = getattr(u, "user_id", None) + if not pid: + continue + indexed[pid] = { + "name": getattr(u, "name", "") or getattr(u, "nickname", "") or "", + "nickname": getattr(u, "nickname", "") or "", + "profile_image": getattr(u, "profile_image", "") or "", + "db_id": getattr(u, "id", None), + } + _USER_PROFILES_CACHE["profiles"] = indexed + _USER_PROFILES_CACHE["expires_at"] = now + 60 + except Exception: + logger.exception("Failed to resolve user profiles for board snapshot") + return {} + + indexed = _USER_PROFILES_CACHE["profiles"] or {} + out = {} + for pid in propel_ids: + if pid in indexed: + out[pid] = indexed[pid] + continue + # Fallback for users who have never been saved to the users collection. + cached = _PROPEL_FALLBACK_CACHE.get(pid) + if cached and cached["expires_at"] > now: + out[pid] = cached["profile"] + continue + try: + from services.users_service import get_oauth_user_from_propel_user_id + oauth = get_oauth_user_from_propel_user_id(pid) + if oauth: + profile = { + "name": oauth.get("name") or oauth.get("given_name") or "", + "nickname": oauth.get("given_name") or "", + "profile_image": ( + oauth.get("https://slack.com/user_image_192") + or oauth.get("picture") + or "" + ), + "db_id": None, # No Firestore record yet + } + _PROPEL_FALLBACK_CACHE[pid] = {"profile": profile, "expires_at": now + _PROPEL_FALLBACK_TTL} + out[pid] = profile + except Exception: + logger.exception("PropelAuth fallback failed for %s", pid) + return out + + +def _maybe_flush_digests_read_driven(hackathon_doc): + """Best-effort read-driven Slack digest flush (runs on GET board).""" + try: + from services.planning_slack_notifier import flush_digests_if_due + flush_digests_if_due(hackathon_doc) + except Exception: + pass + + +# --------------------------------------------------------------------------- +# Lists +# --------------------------------------------------------------------------- + +@bp.route("//lists", methods=["POST"]) +@require_plan_editor() +def create_list(event_id): + data = request.get_json(silent=True) or {} + title = (data.get("title") or "").strip() + if not title: + return jsonify({"error": "title required"}), 400 + + existing = list(_subcol(g.hackathon, "planning_lists").where("archived", "==", False).stream()) + if len(existing) >= MAX_PLANNING_LISTS: + return jsonify({"error": f"Max {MAX_PLANNING_LISTS} lists reached"}), 400 + + now = _now_iso() + position = data.get("position") or f"p{len(existing):06d}" + doc_ref = _subcol(g.hackathon, "planning_lists").document() + doc = { + "title": title, + "position": position, + "archived": False, + "is_run_of_show": bool(data.get("is_run_of_show", False)), + "created_at": now, + "updated_at": now, + } + doc_ref.set(doc) + _record_activity(g.hackathon, "list_created", f'Created list "{title}"', g.propel_user_id, list_id=doc_ref.id) + return jsonify({"id": doc_ref.id, **doc}), 201 + + +@bp.route("//lists/", methods=["PATCH"]) +@require_plan_editor() +def update_list(event_id, list_id): + list_ref = _subcol(g.hackathon, "planning_lists").document(list_id) + snap = list_ref.get() + if not snap.exists: + return jsonify({"error": "List not found"}), 404 + existing = snap.to_dict() + + conflict = _check_if_match(existing) + if conflict: + return conflict + + data = request.get_json(silent=True) or {} + updates = {"updated_at": _now_iso()} + if "title" in data: + title = (data["title"] or "").strip() + if not title: + return jsonify({"error": "title cannot be empty"}), 400 + updates["title"] = title + if "position" in data: + updates["position"] = data["position"] + if "archived" in data: + updates["archived"] = bool(data["archived"]) + if "is_run_of_show" in data: + updates["is_run_of_show"] = bool(data["is_run_of_show"]) + + list_ref.update(updates) + return jsonify({"id": list_id, **existing, **updates}), 200 + + +# --------------------------------------------------------------------------- +# Cards +# --------------------------------------------------------------------------- + +@bp.route("//cards", methods=["POST"]) +@require_plan_editor() +def create_card(event_id): + data = request.get_json(silent=True) or {} + title = (data.get("title") or "").strip()[:MAX_CARD_TITLE_LEN] + if not title: + return jsonify({"error": "title required"}), 400 + list_id = data.get("list_id") + if not list_id: + return jsonify({"error": "list_id required"}), 400 + + list_snap = _subcol(g.hackathon, "planning_lists").document(list_id).get() + if not list_snap.exists: + return jsonify({"error": "List not found"}), 404 + + existing_cards = list( + _subcol(g.hackathon, "planning_cards") + .where("list_id", "==", list_id) + .where("archived", "==", False) + .stream() + ) + if len(existing_cards) >= MAX_PLANNING_CARDS_PER_LIST: + return jsonify({"error": f"Max {MAX_PLANNING_CARDS_PER_LIST} cards per list reached"}), 400 + + kind = data.get("kind", "freetext") + if kind not in ALLOWED_CARD_KINDS: + return jsonify({"error": f"Invalid kind: {kind}"}), 400 + + now = _now_iso() + position = data.get("position") or f"p{len(existing_cards):06d}" + doc_ref = _subcol(g.hackathon, "planning_cards").document() + doc = { + "list_id": list_id, + "title": title, + "description": (data.get("description") or "")[:MAX_CARD_DESCRIPTION_LEN], + "kind": kind, + "assignees": data.get("assignees") or [], + "labels": data.get("labels") or [], + "due_date": data.get("due_date"), + "position": position, + "archived": False, + "checklists": [], + "attachments": [], + "comment_count": 0, + "created_by": g.propel_user_id, + "created_at": now, + "updated_at": now, + "last_activity_at": now, + # Run of Show fields + "start_time": data.get("start_time"), + "end_time": data.get("end_time"), + "sync_to_countdowns": bool(data.get("sync_to_countdowns", True)), + # Budget field (optional) + "budget": _validate_budget(data.get("budget")), + # Kind-specific fields + "target_count": data.get("target_count"), + "sponsor": _validate_sponsor(data.get("sponsor")) if kind == "sponsor_prospect" else None, + } + doc_ref.set(doc) + _record_activity(g.hackathon, "card_created", f'Created card "{title}"', g.propel_user_id, card_id=doc_ref.id, list_id=list_id) + _enqueue_slack_digest(g.hackathon, {"kind": "card_created", "card_id": doc_ref.id, "card_title": title, "list_id": list_id}) + return jsonify({"id": doc_ref.id, **doc}), 201 + + +def _validate_budget(budget): + if not budget: + return None + if not isinstance(budget, dict): + return None + amount = budget.get("amount_cents") + bucket = budget.get("bucket") + state = budget.get("state", "estimated") + if not isinstance(amount, int) or amount < 0 or amount > MAX_BUDGET_CENTS: + return None + if bucket not in ALLOWED_BUDGET_BUCKETS: + return None + if state not in ALLOWED_BUDGET_STATES: + state = "estimated" + return { + "amount_cents": amount, + "bucket": bucket, + "state": state, + "vendor": (budget.get("vendor") or "")[:200], + "invoice_url": budget.get("invoice_url"), + } + + +def _validate_sponsor(sponsor): + if not sponsor or not isinstance(sponsor, dict): + return None + tier = sponsor.get("tier", "tbd") + if tier not in ALLOWED_SPONSOR_TIERS: + tier = "tbd" + status = sponsor.get("outreach_status", "prospect") + if status not in ALLOWED_OUTREACH_STATUSES: + status = "prospect" + pledge = sponsor.get("pledge_amount_cents", 0) + if not isinstance(pledge, int) or pledge < 0: + pledge = 0 + return { + "company": (sponsor.get("company") or "")[:200], + "logo_url": sponsor.get("logo_url"), + "website": sponsor.get("website"), + "tier": tier, + "outreach_status": status, + "point_of_contact_internal": sponsor.get("point_of_contact_internal"), + "point_of_contact_external": sponsor.get("point_of_contact_external"), + "pledge_amount_cents": pledge, + "last_contacted_at": sponsor.get("last_contacted_at"), + "next_action": (sponsor.get("next_action") or "")[:500], + } + + +@bp.route("//cards/", methods=["GET"]) +def get_card(event_id, card_id): + """Public-read single card — used by the SSR permalink page for OG meta tags. + + Only returns when planning.enabled is true so disabled boards stay invisible + (mirrors get_board's gate). + """ + hackathon = load_hackathon_or_404(event_id) + planning = hackathon.get(PLANNING_FIELD) or {} + if not planning.get("enabled"): + return jsonify({"error": "Planning board not enabled"}), 404 + + db = get_db() + href = db.collection("hackathons").document(hackathon["id"]) + snap = href.collection("planning_cards").document(card_id).get() + if not snap.exists: + return jsonify({"error": "Card not found"}), 404 + data = snap.to_dict() or {} + if data.get("archived"): + return jsonify({"error": "Card not found"}), 404 + return jsonify({"id": snap.id, **data}), 200 + + +@bp.route("//cards/", methods=["PATCH"]) +@require_plan_editor() +def update_card(event_id, card_id): + card_ref = _subcol(g.hackathon, "planning_cards").document(card_id) + snap = card_ref.get() + if not snap.exists: + return jsonify({"error": "Card not found"}), 404 + existing = snap.to_dict() + + conflict = _check_if_match(existing) + if conflict: + return conflict + + data = request.get_json(silent=True) or {} + updates = {"updated_at": _now_iso(), "last_activity_at": _now_iso()} + + if "title" in data: + title = (data["title"] or "").strip()[:MAX_CARD_TITLE_LEN] + if not title: + return jsonify({"error": "title cannot be empty"}), 400 + updates["title"] = title + + if "description" in data: + updates["description"] = (data["description"] or "")[:MAX_CARD_DESCRIPTION_LEN] + + if "list_id" in data: + list_snap = _subcol(g.hackathon, "planning_lists").document(data["list_id"]).get() + if not list_snap.exists: + return jsonify({"error": "Target list not found"}), 404 + updates["list_id"] = data["list_id"] + + if "position" in data: + updates["position"] = data["position"] + + if "archived" in data: + updates["archived"] = bool(data["archived"]) + + if "assignees" in data: + updates["assignees"] = data["assignees"] if isinstance(data["assignees"], list) else [] + + if "labels" in data: + updates["labels"] = data["labels"] if isinstance(data["labels"], list) else [] + + if "due_date" in data: + updates["due_date"] = data["due_date"] + + if "kind" in data: + kind = data["kind"] + if kind not in ALLOWED_CARD_KINDS: + return jsonify({"error": f"Invalid kind: {kind}"}), 400 + updates["kind"] = kind + + if "status" in data: + status = data["status"] + # null/empty clears the status (back to "no status set") + if status in (None, "", "none"): + updates["status"] = None + elif status in ALLOWED_CARD_STATUSES: + updates["status"] = status + else: + return jsonify({"error": f"Invalid status: {status}"}), 400 + + if "checklists" in data: + checklists = data["checklists"] + if not isinstance(checklists, list) or len(checklists) > MAX_CHECKLISTS_PER_CARD: + return jsonify({"error": "Invalid checklists"}), 400 + for cl in checklists: + if not isinstance(cl.get("items"), list) or len(cl["items"]) > MAX_CHECKLIST_ITEMS: + return jsonify({"error": "Invalid checklist items"}), 400 + updates["checklists"] = checklists + + if "attachments" in data: + attachments = data["attachments"] + if not isinstance(attachments, list) or len(attachments) > MAX_ATTACHMENTS_PER_CARD: + return jsonify({"error": f"Max {MAX_ATTACHMENTS_PER_CARD} attachments per card"}), 400 + updates["attachments"] = attachments + + if "budget" in data: + updates["budget"] = _validate_budget(data["budget"]) + + if "sponsor" in data: + updates["sponsor"] = _validate_sponsor(data["sponsor"]) + + if "target_count" in data: + updates["target_count"] = data["target_count"] + + # Run of Show fields + if "start_time" in data: + updates["start_time"] = data["start_time"] + if "end_time" in data: + updates["end_time"] = data["end_time"] + if "sync_to_countdowns" in data: + updates["sync_to_countdowns"] = bool(data["sync_to_countdowns"]) + + card_ref.update(updates) + _enqueue_slack_digest(g.hackathon, { + "kind": "card_updated", + "card_id": card_id, + "card_title": updates.get("title", existing.get("title", "")), + "list_id": updates.get("list_id", existing.get("list_id", "")), + }) + return jsonify({"id": card_id, **existing, **updates}), 200 + + +@bp.route("//cards/", methods=["DELETE"]) +@require_plan_editor() +def archive_card(event_id, card_id): + card_ref = _subcol(g.hackathon, "planning_cards").document(card_id) + snap = card_ref.get() + if not snap.exists: + return jsonify({"error": "Card not found"}), 404 + now = _now_iso() + card_ref.update({"archived": True, "updated_at": now}) + _record_activity(g.hackathon, "card_archived", f'Archived card "{snap.to_dict().get("title", "")}"', g.propel_user_id, card_id=card_id) + return jsonify({"id": card_id, "archived": True}), 200 + + +# --------------------------------------------------------------------------- +# Comments +# --------------------------------------------------------------------------- + +@bp.route("//cards//comments", methods=["GET"]) +def get_comments(event_id, card_id): + hackathon = load_hackathon_or_404(event_id) + planning = hackathon.get(PLANNING_FIELD) or {} + if not planning.get("enabled"): + return jsonify({"error": "Planning board not enabled"}), 404 + + comments = [ + {**d.to_dict(), "id": d.id} + for d in _subcol(hackathon, "planning_comments") + .where("card_id", "==", card_id) + .order_by("created_at") + .stream() + if not d.to_dict().get("deleted_at") + ] + return jsonify({"comments": comments}), 200 + + +@bp.route("//cards//comments", methods=["POST"]) +@require_logged_in_on_enabled_plan() +def create_comment(event_id, card_id): + rate_limit = _check_comment_rate_limit(g.propel_user_id) + if rate_limit: + return rate_limit + + data = request.get_json(silent=True) or {} + body = (data.get("body") or "").strip() + if not body: + return jsonify({"error": "body required"}), 400 + if len(body) > MAX_COMMENT_LEN: + return jsonify({"error": f"body exceeds {MAX_COMMENT_LEN} chars"}), 400 + + card_ref = _subcol(g.hackathon, "planning_cards").document(card_id) + card_snap = card_ref.get() + if not card_snap.exists: + return jsonify({"error": "Card not found"}), 404 + + now = _now_iso() + author_info = {"user_id": g.propel_user_id} + try: + if auth_user: + author_info["name"] = getattr(auth_user, "first_name", "") + " " + getattr(auth_user, "last_name", "") + author_info["name"] = author_info["name"].strip() + except Exception: + pass + + doc_ref = _subcol(g.hackathon, "planning_comments").document() + comment = { + "card_id": card_id, + "body": body, + "author": author_info, + "created_at": now, + "updated_at": now, + "deleted_at": None, + } + doc_ref.set(comment) + + # Increment comment_count + from google.cloud.firestore import Increment + card_ref.update({"comment_count": Increment(1), "last_activity_at": now}) + + _record_activity(g.hackathon, "comment_added", "Added a comment", g.propel_user_id, card_id=card_id) + + # Best-effort @-mention notifications. Failures here must not break the + # comment write; the comment is already persisted above. + try: + from services.planning_mention_notifier import ( + parse_mention_ids, + notify_mentions, + ) + mentioned = parse_mention_ids(body) + if mentioned: + card_data = card_snap.to_dict() or {} + notify_mentions( + mentioned_propel_ids=mentioned, + actor_propel_id=g.propel_user_id, + actor_name=author_info.get("name") or "Someone", + hackathon_event_id=event_id, + card_title=card_data.get("title", "(untitled)"), + card_id=card_id, + comment_body=body, + ) + except Exception: + logger.exception("Mention notification dispatch failed") + + return jsonify({"id": doc_ref.id, **comment}), 201 + + +@bp.route("//comments/", methods=["DELETE"]) +@auth.require_user +def delete_comment(event_id, comment_id): + hackathon = load_hackathon_or_404(event_id) + planning = hackathon.get(PLANNING_FIELD) or {} + if not planning.get("enabled"): + return jsonify({"error": "Planning board not enabled"}), 404 + + comment_ref = _subcol(hackathon, "planning_comments").document(comment_id) + snap = comment_ref.get() + if not snap.exists: + return jsonify({"error": "Comment not found"}), 404 + + comment = snap.to_dict() + author_id = (comment.get("author") or {}).get("user_id") + + # admin OR comment author OR card creator can delete + if not is_admin(auth_user) and auth_user.user_id != author_id: + return jsonify({"error": "Forbidden"}), 403 + + now = _now_iso() + comment_ref.update({"deleted_at": now, "body": "[deleted]"}) + return jsonify({"id": comment_id, "deleted_at": now}), 200 + + +# --------------------------------------------------------------------------- +# Labels +# --------------------------------------------------------------------------- + +@bp.route("//labels", methods=["POST"]) +@require_plan_editor() +def create_label(event_id): + data = request.get_json(silent=True) or {} + name = (data.get("name") or "").strip() + color = (data.get("color") or "#888888").strip() + if not name: + return jsonify({"error": "name required"}), 400 + + existing = list(_subcol(g.hackathon, "planning_labels").stream()) + if len(existing) >= MAX_PLANNING_LABELS: + return jsonify({"error": f"Max {MAX_PLANNING_LABELS} labels reached"}), 400 + + now = _now_iso() + doc_ref = _subcol(g.hackathon, "planning_labels").document() + label = {"name": name, "color": color, "created_at": now, "updated_at": now} + doc_ref.set(label) + return jsonify({"id": doc_ref.id, **label}), 201 + + +@bp.route("//labels/", methods=["PATCH"]) +@require_plan_editor() +def update_label(event_id, label_id): + label_ref = _subcol(g.hackathon, "planning_labels").document(label_id) + snap = label_ref.get() + if not snap.exists: + return jsonify({"error": "Label not found"}), 404 + + data = request.get_json(silent=True) or {} + updates = {"updated_at": _now_iso()} + if "name" in data: + updates["name"] = (data["name"] or "").strip() + if "color" in data: + updates["color"] = (data["color"] or "").strip() + + label_ref.update(updates) + return jsonify({"id": label_id, **snap.to_dict(), **updates}), 200 + + +# --------------------------------------------------------------------------- +# Editors (admin only) +# --------------------------------------------------------------------------- + +@bp.route("//editors", methods=["PATCH"]) +@require_admin_on_event() +def update_editors(event_id): + data = request.get_json(silent=True) or {} + add_ids = data.get("add") or [] + remove_ids = data.get("remove") or [] + + if not isinstance(add_ids, list) or not isinstance(remove_ids, list): + return jsonify({"error": "add and remove must be arrays"}), 400 + + db = get_db() + href = _get_hackathon_ref(g.hackathon) + snap = href.get() + current = snap.to_dict() or {} + planning = current.get(PLANNING_FIELD) or {} + editors = list(planning.get("editors") or []) + + for uid in add_ids: + if isinstance(uid, str) and uid not in editors: + editors.append(uid) + for uid in remove_ids: + if uid in editors: + editors.remove(uid) + + planning["editors"] = editors + href.update({PLANNING_FIELD: planning}) + return jsonify({"editors": editors}), 200 + + +# --------------------------------------------------------------------------- +# Planning config (admin only — enable/disable, slack settings, budget widget) +# --------------------------------------------------------------------------- + +@bp.route("//config", methods=["PATCH"]) +@require_admin_on_event() +def update_planning_config(event_id): + data = request.get_json(silent=True) or {} + db = get_db() + href = _get_hackathon_ref(g.hackathon) + snap = href.get() + current = snap.to_dict() or {} + planning = dict(current.get(PLANNING_FIELD) or {}) + + if "enabled" in data: + planning["enabled"] = bool(data["enabled"]) + if "budget_widget_on_event_page" in data: + planning["budget_widget_on_event_page"] = bool(data["budget_widget_on_event_page"]) + if "slack" in data: + slack = data["slack"] + if isinstance(slack, dict): + current_slack = dict(planning.get("slack") or {}) + if "channel" in slack: + current_slack["channel"] = (slack["channel"] or "").lstrip("#").strip()[:80] + if "notify_on_card_change" in slack: + current_slack["notify_on_card_change"] = bool(slack["notify_on_card_change"]) + planning["slack"] = current_slack + + href.update({PLANNING_FIELD: planning}) + return jsonify({"planning": planning}), 200 + + +# --------------------------------------------------------------------------- +# Seed template (admin only) +# --------------------------------------------------------------------------- + +@bp.route("//seed-template", methods=["POST"]) +@require_admin_on_event() +def seed_template(event_id): + planning = g.hackathon.get(PLANNING_FIELD) or {} + if planning.get("template_seeded"): + return jsonify({"message": "Template already seeded"}), 200 + + from services.planning_template_service import apply_ohack_template + apply_ohack_template(g.hackathon) + + href = _get_hackathon_ref(g.hackathon) + planning["template_seeded"] = True + href.update({PLANNING_FIELD: planning}) + + _record_activity(g.hackathon, "template_seeded", "Applied OHack default template", g.propel_user_id) + return jsonify({"message": "Template applied"}), 200 + + +# --------------------------------------------------------------------------- +# Profile resolution by exact propel_id list — used by the admin editors +# manager to render real names / avatars instead of opaque IDs. Reuses the +# same public-safe resolver as the board snapshot (Firestore index + +# PropelAuth fallback for users who haven't yet triggered profile creation). +# --------------------------------------------------------------------------- + +@bp.route("/_users/by-ids", methods=["GET"]) +@auth.require_user +def resolve_users_by_ids(): + """Return {propel_id: {name, profile_image, nickname, db_id}} for the IDs. + + Any logged-in user. Public-safe fields only (no email, no Slack ID) — + matches the data shape already exposed in the board snapshot's `users` map. + """ + raw = (request.args.get("ids") or "").strip() + if not raw: + return jsonify({"users": {}}), 200 + ids = [p for p in (s.strip() for s in raw.split(",")) if p][:50] + return jsonify({"users": _resolve_public_user_profiles(set(ids))}), 200 + + +# --------------------------------------------------------------------------- +# @-mention search — any logged-in user can search for someone to mention +# in a comment. Returns minimal public-safe fields only (no email, no Slack +# ID), so this can never be used to harvest PII. +# --------------------------------------------------------------------------- + +@bp.route("/_users/mention-search", methods=["GET"]) +@auth.require_user +def mention_search_users(): + """Substring match across cached user list. Public-safe fields only. + + Min 2 chars (avoids dumping the user table). Capped at 10 results. + Reuses the same 60s cache as the board snapshot's user resolver. + """ + q = (request.args.get("q") or "").strip().lower() + if len(q) < 2: + return jsonify({"users": []}), 200 + + # Force a populate of _USER_PROFILES_CACHE if cold by calling with a + # placeholder — cheaper than duplicating the fetch logic. + _resolve_public_user_profiles({"__warm__"}) # noqa + indexed = _USER_PROFILES_CACHE.get("profiles") or {} + + out = [] + for pid, profile in indexed.items(): + name = (profile.get("name") or "").lower() + nickname = (profile.get("nickname") or "").lower() + if q in name or q in nickname: + out.append({ + "user_id": pid, + "name": profile.get("name") or profile.get("nickname") or "", + "profile_image": profile.get("profile_image") or "", + }) + if len(out) >= 10: + break + + return jsonify({"users": out}), 200 + + +# --------------------------------------------------------------------------- +# Admin user search — for the editors picker (no PropelAuth ID memorization) +# --------------------------------------------------------------------------- + +@bp.route("/_users/search", methods=["GET"]) +@auth.require_user +def search_users_for_editor_picker(): + """Substring match across Firestore users by name/email/nickname. + + Admin-only. Returns up to 25 candidates with the propel user_id needed + by the editors[] list. Q is required and at least 2 chars to avoid + dumping the whole user table. + """ + if not is_admin(auth_user): + return jsonify({"error": "Forbidden"}), 403 + + q = (request.args.get("q") or "").strip().lower() + if len(q) < 2: + return jsonify({"users": []}), 200 + + from db.db import fetch_users + try: + all_users = fetch_users() or [] + except Exception: + logger.exception("fetch_users failed") + return jsonify({"users": []}), 200 + + results = [] + for u in all_users: + propel_id = getattr(u, "user_id", None) + if not propel_id: + continue + name = (getattr(u, "name", None) or "").lower() + nickname = (getattr(u, "nickname", None) or "").lower() + email = (getattr(u, "email_address", None) or "").lower() + if q in name or q in nickname or q in email: + results.append({ + "user_id": propel_id, + "name": getattr(u, "name", None) or getattr(u, "nickname", None) or "", + "email": getattr(u, "email_address", None) or "", + "profile_image": getattr(u, "profile_image", None) or "", + }) + if len(results) >= 25: + break + + return jsonify({"users": results}), 200 + + +# --------------------------------------------------------------------------- +# Advisory editing heartbeat (Redis-backed, gracefully degraded) +# --------------------------------------------------------------------------- + +@bp.route("//cards//editing-heartbeat", methods=["POST"]) +@auth.require_user +def editing_heartbeat(event_id, card_id): + hackathon = load_hackathon_or_404(event_id) + planning = hackathon.get(PLANNING_FIELD) or {} + if not planning.get("enabled"): + return jsonify({"error": "Planning board not enabled"}), 404 + + try: + from common.utils.redis_cache import get_redis_client + rc = get_redis_client() + if rc: + key = f"planning:card:editing:{card_id}:{auth_user.user_id}" + rc.set(key, 1, ex=30) + # Collect all editors of this card + pattern = f"planning:card:editing:{card_id}:*" + editors = [k.decode().split(":")[-1] for k in rc.keys(pattern)] + return jsonify({"editors": editors}), 200 + except Exception: + pass + + return jsonify({"editors": []}), 200 + + +# --------------------------------------------------------------------------- +# Slack manual digest +# --------------------------------------------------------------------------- + +@bp.route("//slack/notify", methods=["POST"]) +@require_plan_editor() +def slack_notify(event_id): + try: + from services.planning_slack_notifier import send_manual_digest + ok, msg = send_manual_digest(g.hackathon) + if ok: + return jsonify({"message": msg}), 200 + # 502 — upstream (Slack) said no. Surface the error so the admin + # knows whether it's a config issue vs. a missing channel invite. + return jsonify({"error": msg}), 502 + except Exception as e: + logger.exception("Manual Slack digest failed") + return jsonify({"error": str(e)}), 500 + + +# --------------------------------------------------------------------------- +# Internal cron: flush pending Slack digests +# --------------------------------------------------------------------------- + +@bp.route("/_flush_digests", methods=["POST"]) +def flush_digests_cron(): + """Called by Cloud Scheduler / GitHub Actions cron. Authenticated via X-Internal-Token.""" + import os + expected = os.environ.get("PLANNING_INTERNAL_TOKEN") + # Require the token when set; if not set, only allow requests from localhost + if expected: + if request.headers.get(INTERNAL_TOKEN_HEADER) != expected: + return jsonify({"error": "Forbidden"}), 403 + else: + # No token configured — restrict to loopback to prevent accidental public exposure + remote = request.remote_addr or "" + if remote not in ("127.0.0.1", "::1", "localhost"): + return jsonify({"error": "PLANNING_INTERNAL_TOKEN not configured"}), 403 + + from services.planning_slack_notifier import flush_all_pending_digests + try: + count = flush_all_pending_digests() + return jsonify({"flushed": count}), 200 + except Exception as e: + logger.exception("flush_digests_cron failed") + return jsonify({"error": str(e)}), 500 + + +# --------------------------------------------------------------------------- +# Run of Show — preview + sync +# --------------------------------------------------------------------------- + +# --------------------------------------------------------------------------- +# LexoRank rebalance (editor — triggered when gap < threshold) +# --------------------------------------------------------------------------- + +@bp.route("//lists//rebalance", methods=["POST"]) +@require_plan_editor() +def rebalance_list(event_id, list_id): + """Reissue evenly-spaced positions for all cards in a list (batch write). + + Called when the client detects position gap exhaustion. Each card in the + list gets a new position string; the batch commits atomically so concurrent + readers always see a consistent sort order. + + Cross-event safety: the parent list is fetched from g.hackathon (stashed by + the decorator from the URL's event_id), not from the request body. + """ + db = get_db() + href = _get_hackathon_ref(g.hackathon) + + # Verify the list belongs to this hackathon + list_snap = href.collection("planning_lists").document(list_id).get() + if not list_snap.exists: + return jsonify({"error": "List not found"}), 404 + + # Load all non-archived cards in the list, sorted by current position + cards_query = ( + href.collection("planning_cards") + .where("list_id", "==", list_id) + .where("archived", "==", False) + .order_by("position") + .stream() + ) + cards = [(d.id, d.to_dict()) for d in cards_query] + + if not cards: + return jsonify({"rebalanced": 0}), 200 + + # Issue evenly-spaced positions across [1000, 9999000] with step = 9998000/(n+1) + n = len(cards) + step = max(1, 9998000 // (n + 1)) + now = _now_iso() + + batch = db.batch() + new_positions = [] + for i, (card_id, _) in enumerate(cards): + new_pos = f"p{(step * (i + 1)):07d}" + new_positions.append(new_pos) + card_ref = href.collection("planning_cards").document(card_id) + batch.update(card_ref, {"position": new_pos, "updated_at": now}) + + batch.commit() + + _record_activity( + g.hackathon, + "list_rebalanced", + f"Rebalanced positions in list (n={n})", + g.propel_user_id, + list_id=list_id, + ) + return jsonify({"rebalanced": n, "positions": new_positions}), 200 + + +@bp.route("//run-of-show/preview", methods=["GET"]) +@require_plan_editor() +def ros_preview(event_id): + from services.planning_ros_service import compute_ros_diff + diff = compute_ros_diff(g.hackathon) + return jsonify(diff), 200 + + +@bp.route("//run-of-show/sync", methods=["POST"]) +@require_plan_editor() +def ros_sync(event_id): + from services.planning_ros_service import sync_ros_to_countdowns + result = sync_ros_to_countdowns(g.hackathon, actor_id=g.propel_user_id) + _record_activity( + g.hackathon, + "ros_synced", + f"Synced {result['synced']} Run of Show entries to the public timeline", + g.propel_user_id, + ) + _enqueue_slack_digest(g.hackathon, {"kind": "ros_synced", "count": result["synced"]}) + return jsonify(result), 200 diff --git a/common/utils/validators.py b/common/utils/validators.py index 57a3f9e..aed8191 100644 --- a/common/utils/validators.py +++ b/common/utils/validators.py @@ -159,6 +159,11 @@ def validate_hackathon_data(data): if social_posts is not None: validate_social_posts(social_posts) + # Validate planning subobject if present + planning = data.get("planning") + if planning is not None: + validate_planning_subobject(planning) + ALLOWED_DIETARY_TAGS = { "vegetarian", @@ -276,6 +281,57 @@ def validate_social_posts(posts): raise ValueError(f"social_posts[{i}].caption must be a string") +_SLACK_CHANNEL_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,79}$") + + +def validate_planning_subobject(planning): + """Validate the per-hackathon `planning` subobject persisted on a hackathon doc.""" + if not isinstance(planning, dict): + raise ValueError("planning must be an object") + + enabled = planning.get("enabled", False) + if not isinstance(enabled, bool): + raise ValueError("planning.enabled must be a boolean") + + editors = planning.get("editors", []) + if not isinstance(editors, list): + raise ValueError("planning.editors must be a list of PropelAuth user IDs") + if len(editors) > 200: + raise ValueError("planning.editors may not exceed 200 entries") + seen_editors = set() + for i, ed in enumerate(editors): + if not isinstance(ed, str) or not ed.strip(): + raise ValueError(f"planning.editors[{i}] must be a non-empty string") + if ed in seen_editors: + raise ValueError(f"planning.editors[{i}] duplicated: {ed}") + seen_editors.add(ed) + + slack = planning.get("slack") + if slack is not None: + if not isinstance(slack, dict): + raise ValueError("planning.slack must be an object") + channel = slack.get("channel", "") + if channel: + if not isinstance(channel, str): + raise ValueError("planning.slack.channel must be a string") + normalized = channel.lstrip("#").strip() + if normalized and not _SLACK_CHANNEL_RE.match(normalized): + raise ValueError( + "planning.slack.channel must be lowercase letters, digits, hyphens or underscores (max 80 chars)" + ) + notify = slack.get("notify_on_card_change", False) + if not isinstance(notify, bool): + raise ValueError("planning.slack.notify_on_card_change must be a boolean") + + seeded = planning.get("template_seeded", False) + if not isinstance(seeded, bool): + raise ValueError("planning.template_seeded must be a boolean") + + budget_widget = planning.get("budget_widget_on_event_page", False) + if not isinstance(budget_widget, bool): + raise ValueError("planning.budget_widget_on_event_page must be a boolean") + + if __name__ == "__main__": # Simple tests print(validate_email("test@example.com")) # Should print True diff --git a/db/firestore.py b/db/firestore.py index 282b97b..d64ce18 100644 --- a/db/firestore.py +++ b/db/firestore.py @@ -382,6 +382,8 @@ def insert_problem_statement(self, problem_statement: ProblemStatement): insert_data['references'] = problem_statement.references if hasattr(problem_statement, 'skills'): insert_data['skills'] = problem_statement.skills + if hasattr(problem_statement, 'rank'): + insert_data['rank'] = problem_statement.rank insert_res = collection.document(problem_statement.id).set(insert_data) @@ -415,6 +417,8 @@ def update_problem_statement(self, problem_statement: ProblemStatement): update_data['references'] = problem_statement.references if hasattr(problem_statement, 'skills'): update_data['skills'] = problem_statement.skills + if hasattr(problem_statement, 'rank'): + update_data['rank'] = problem_statement.rank # Use update() instead of set() to only modify specified fields update_res = collection.document(problem_statement.id).update(update_data) diff --git a/db/mem.py b/db/mem.py index 8d79439..bc02377 100644 --- a/db/mem.py +++ b/db/mem.py @@ -212,7 +212,8 @@ def insert_problem_statement(self, problem_statement: ProblemStatement): 'description': problem_statement.description, 'first_thought_of': problem_statement.first_thought_of, 'github': problem_statement.github, - 'profile_image': problem_statement.status} + 'profile_image': problem_statement.status, + 'rank': problem_statement.rank} logger.debug(f'Inserting problem statement\n: {d}') @@ -234,6 +235,7 @@ def update_problem_statement(self, problem_statement: ProblemStatement): d.first_thought_of = problem_statement.first_thought_of d.github = problem_statement.github d.status = problem_statement.status + d.rank = problem_statement.rank self.flush_problem_statements() diff --git a/firestore.indexes.json b/firestore.indexes.json new file mode 100644 index 0000000..45e84e8 --- /dev/null +++ b/firestore.indexes.json @@ -0,0 +1,45 @@ +{ + "indexes": [ + { + "collectionGroup": "planning_lists", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "archived", "order": "ASCENDING" }, + { "fieldPath": "position", "order": "ASCENDING" } + ] + }, + { + "collectionGroup": "planning_cards", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "archived", "order": "ASCENDING" }, + { "fieldPath": "position", "order": "ASCENDING" } + ] + }, + { + "collectionGroup": "planning_cards", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "list_id", "order": "ASCENDING" }, + { "fieldPath": "archived", "order": "ASCENDING" }, + { "fieldPath": "position", "order": "ASCENDING" } + ] + }, + { + "collectionGroup": "planning_pending_digests", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "created_at", "order": "ASCENDING" } + ] + }, + { + "collectionGroup": "planning_comments", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "card_id", "order": "ASCENDING" }, + { "fieldPath": "created_at", "order": "ASCENDING" } + ] + } + ], + "fieldOverrides": [] +} diff --git a/model/planning.py b/model/planning.py new file mode 100644 index 0000000..85b9408 --- /dev/null +++ b/model/planning.py @@ -0,0 +1,51 @@ +"""Schema constants and defaults for the per-hackathon planning board.""" + +PLANNING_FIELD = "planning" + +ALLOWED_BUDGET_BUCKETS = {"food", "prize", "swag"} +ALLOWED_BUDGET_STATES = {"estimated", "committed", "paid"} +ALLOWED_CARD_KINDS = { + "freetext", + "judges", + "mentors", + "hackers", + "nonprofits", + "teams", + "sponsor_prospect", +} +ALLOWED_OUTREACH_STATUSES = { + "prospect", + "contacted", + "in-discussion", + "committed", + "declined", + "no-response", +} +ALLOWED_SPONSOR_TIERS = {"visionary", "transformer", "changemaker", "in-kind", "tbd"} +ALLOWED_CARD_STATUSES = {"planned", "in_progress", "blocked", "completed"} + +MAX_PLANNING_LISTS = 50 +MAX_PLANNING_CARDS_PER_LIST = 200 +MAX_PLANNING_LABELS = 50 +MAX_COMMENT_LEN = 4000 +MAX_ATTACHMENTS_PER_CARD = 20 +MAX_CARD_TITLE_LEN = 200 +MAX_CARD_DESCRIPTION_LEN = 20_000 +MAX_CHECKLISTS_PER_CARD = 20 +MAX_CHECKLIST_ITEMS = 100 +MAX_BUDGET_CENTS = 1_000_000_00 # $1M sanity cap +MAX_COMMENTS_PER_USER_PER_MIN = 10 + + +def default_planning_subobject(): + """Default shape for the per-hackathon planning subobject.""" + return { + "enabled": False, + "editors": [], + "slack": { + "channel": "", + "notify_on_card_change": False, + }, + "template_seeded": False, + "budget_widget_on_event_page": False, + } diff --git a/model/problem_statement.py b/model/problem_statement.py index 5d40ea5..08e2f58 100644 --- a/model/problem_statement.py +++ b/model/problem_statement.py @@ -19,6 +19,7 @@ def __init__(self): self.id = None self.title = '' self.description = '' + self.rank = None self.first_thought_of = None self.github = None self.helping = [] @@ -26,6 +27,7 @@ def __init__(self): self.events = [] # TODO: Breaking change. This used to be called "events" self.status = None self.skills = [] # This is a list of skills, not a string + @classmethod def deserialize(cls, d): @@ -33,6 +35,7 @@ def deserialize(cls, d): p.id = d['id'] p.title = d['title'] p.description = d['description'] if 'description' in d else None + p.rank = d['rank'] if 'rank' in d else None p.first_thought_of = d['first_thought_of'] if 'first_thought_of' in d else None p.github = d['github'] if 'github' in d else None p.status = d['status'] if 'status' in d else None @@ -98,7 +101,7 @@ def serialize(self): d['references'] = [] # Add remaining fields that aren't special cases - for field in ['github', 'status', 'first_thought_of', 'skills']: + for field in ['github', 'status', 'first_thought_of', 'skills', 'rank']: if hasattr(self, field): d[field] = getattr(self, field) diff --git a/services/hackathon_planning_service.py b/services/hackathon_planning_service.py new file mode 100644 index 0000000..81d6853 --- /dev/null +++ b/services/hackathon_planning_service.py @@ -0,0 +1,164 @@ +"""Auth helpers for the per-hackathon planning board. + +Two decorators are exported: +- ``require_plan_editor(event_id_arg)``: gates editor-write routes. Combines + ``@auth.require_user`` with: (a) hackathon doc lookup, (b) ``planning.enabled`` + check, (c) admin-or-editor check. Stashes the loaded doc on + ``flask.g.hackathon`` for handlers. +- ``require_logged_in_on_enabled_plan(event_id_arg)``: lighter wrapper for the + comment-write route — any logged-in user may comment on an enabled board. + +The editor list is intentionally NOT cached. Removal mid-session must take +effect on the next mutation, so every call re-reads the parent hackathon doc. +""" + +import logging +from functools import wraps + +from flask import abort, g + +from common.auth import auth, auth_user +from common.utils.firebase import get_hackathon_by_event_id + +logger = logging.getLogger("hackathon_planning_service") + +ADMIN_PERMISSION = "volunteer.admin" + + +def is_admin(propel_user) -> bool: + """Return True if the authenticated user has the global volunteer.admin permission. + + PropelAuth's User object exposes per-org info on + ``user.org_id_to_org_member_info`` (a dict of {org_id: OrgMemberInfo}). + OHack only has one org so we accept ``volunteer.admin`` in any org membership. + """ + if not propel_user or not getattr(propel_user, "user_id", None): + return False + org_id_to_org_member_info = ( + getattr(propel_user, "org_id_to_org_member_info", None) or {} + ) + for org_info in org_id_to_org_member_info.values(): + try: + if org_info.user_has_permission(ADMIN_PERMISSION): + return True + except Exception: + permissions = getattr(org_info, "user_permissions", None) or [] + if ADMIN_PERMISSION in permissions: + return True + return False + + +def can_write_plan(propel_user, hackathon_doc) -> bool: + """Return True if the user may edit the planning board for this hackathon. + + Truth table: + anonymous -> False + logged-in non-editor non-admin -> False + logged-in editor -> True iff propel_user_id is in planning.editors[] + logged-in admin -> True regardless of editors list + """ + if not propel_user or not getattr(propel_user, "user_id", None): + return False + if is_admin(propel_user): + return True + if not isinstance(hackathon_doc, dict): + return False + planning = hackathon_doc.get("planning") or {} + editors = planning.get("editors") or [] + return propel_user.user_id in editors + + +def can_comment(propel_user) -> bool: + """Any logged-in user can comment on an enabled plan.""" + return bool(propel_user and getattr(propel_user, "user_id", None)) + + +def load_hackathon_or_404(event_id): + """Read the parent hackathon doc by event_id; 404 if missing.""" + if not isinstance(event_id, str) or not event_id.strip(): + abort(404) + doc = get_hackathon_by_event_id(event_id) + if not doc: + abort(404) + return doc + + +def _planning_enabled(hackathon_doc) -> bool: + return bool((hackathon_doc.get("planning") or {}).get("enabled")) + + +def require_plan_editor(event_id_arg="event_id"): + """Decorator: requires logged-in editor (or admin) for the named event. + + ``planning.enabled`` is enforced — disabled boards return 404 (not 403) so + probing for boards is indistinguishable from probing for nonexistent events. + + The loaded hackathon dict is stashed on ``flask.g.hackathon`` so handlers + can construct Firestore paths from ``g.hackathon["id"]`` rather than + trusting parent IDs in the request body. This blocks cross-event mutations + where a card_id from event B is sent against event A's URL. + """ + + def decorator(fn): + @wraps(fn) + @auth.require_user + def inner(*args, **kwargs): + event_id = kwargs.get(event_id_arg) + doc = load_hackathon_or_404(event_id) + if not _planning_enabled(doc): + abort(404) + if not can_write_plan(auth_user, doc): + abort(403) + g.hackathon = doc + g.propel_user_id = auth_user.user_id + return fn(*args, **kwargs) + + return inner + + return decorator + + +def require_logged_in_on_enabled_plan(event_id_arg="event_id"): + """Decorator: requires logged-in user; planning.enabled enforced (404 if off).""" + + def decorator(fn): + @wraps(fn) + @auth.require_user + def inner(*args, **kwargs): + event_id = kwargs.get(event_id_arg) + doc = load_hackathon_or_404(event_id) + if not _planning_enabled(doc): + abort(404) + if not can_comment(auth_user): + abort(403) + g.hackathon = doc + g.propel_user_id = auth_user.user_id + return fn(*args, **kwargs) + + return inner + + return decorator + + +def require_admin_on_event(event_id_arg="event_id"): + """Decorator for admin-only planning routes (editors-list management, seed-template). + + Unlike ``require_plan_editor``, this also enforces global admin. Editors cannot + add other editors or seed templates. + """ + + def decorator(fn): + @wraps(fn) + @auth.require_user + def inner(*args, **kwargs): + event_id = kwargs.get(event_id_arg) + doc = load_hackathon_or_404(event_id) + if not is_admin(auth_user): + abort(403) + g.hackathon = doc + g.propel_user_id = auth_user.user_id + return fn(*args, **kwargs) + + return inner + + return decorator diff --git a/services/hackathons_service.py b/services/hackathons_service.py index 9b77c49..e4f23d3 100644 --- a/services/hackathons_service.py +++ b/services/hackathons_service.py @@ -649,6 +649,9 @@ def save_hackathon(json_data, propel_id): "last_updated_by": propel_id, } + if "planning" in json_data: + hackathon_data["planning"] = json_data["planning"] + if "nonprofits" in json_data: hackathon_data["nonprofits"] = [db.collection("nonprofits").document(npo) for npo in json_data["nonprofits"]] if "teams" in json_data: diff --git a/services/planning_mention_notifier.py b/services/planning_mention_notifier.py new file mode 100644 index 0000000..d5b9ba1 --- /dev/null +++ b/services/planning_mention_notifier.py @@ -0,0 +1,167 @@ +"""Send Slack DM or email when someone is @-mentioned in a planning comment. + +Mention token format (in markdown body): @[Name](propel_user_id) +The propel_user_id is opaque (no PII). The Name is whatever the author +saw in the picker — we re-derive it server-side from the live profile +when sending the notification. + +Notification channel selection: + - Slack AND email when the user authenticated via Slack (we have both + a Slack ID and an email on the OAuth profile). + - Email only when the user authenticated via Google (no Slack ID, but + Google always returns an email). + - Drop and log when neither is available. No retries; mentions are + best-effort. + +The dispatcher is called best-effort from create_comment — failures are +logged but never bubble to the API response so a flaky Slack/email +provider can't break the comment write path. +""" +import logging +import os +import re +from cachetools import TTLCache, cached + +from common.utils.slack import send_slack + +logger = logging.getLogger("planning_mention_notifier") + +# Token: @[Name with spaces & punctuation](propel_user_id) +# Name allows anything but `]`, propel_user_id is alphanumeric + dashes/underscores. +MENTION_RE = re.compile(r"@\[([^\]]{1,80})\]\(([A-Za-z0-9_\-|]{1,64})\)") + + +def parse_mention_ids(text: str): + """Return a deduped set of propel_user_ids referenced in the text.""" + if not text: + return set() + return {m.group(2) for m in MENTION_RE.finditer(text)} + + +# Cache PropelAuth OAuth lookups for 5 minutes — heavy network call, and +# Slack ID / email don't change in that window. +@cached(cache=TTLCache(maxsize=500, ttl=300)) +def _get_oauth_user_cached(propel_id): + try: + from services.users_service import get_oauth_user_from_propel_user_id + return get_oauth_user_from_propel_user_id(propel_id) + except Exception: + logger.exception("Failed to fetch OAuth user for %s", propel_id) + return None + + +def _extract_slack_user_id(oauth_user): + """Slack OAuth response includes 'https://slack.com/user_id' (e.g. UC31XTRT5).""" + if not oauth_user: + return None + return oauth_user.get("https://slack.com/user_id") or None + + +def _extract_email(oauth_user): + if not oauth_user: + return None + return oauth_user.get("email") or None + + +def _send_slack_dm(slack_user_id, message): + """chat.postMessage to a user ID opens (or reuses) a DM channel.""" + try: + send_slack(message=message, channel=slack_user_id) + return True + except Exception: + logger.exception("Slack DM failed for %s", slack_user_id) + return False + + +def _send_email(to_email, subject, html_body): + """Best-effort email via Resend (matches services/email_service.py pattern).""" + try: + import resend + api_key = os.getenv("RESEND_WELCOME_EMAIL_KEY") or os.getenv("RESEND_API_KEY") + if not api_key: + logger.warning("No Resend API key configured; skipping mention email to %s", to_email) + return False + resend.api_key = api_key + from_addr = os.getenv("MENTION_EMAIL_FROM", "Opportunity Hack ") + params = { + "from": from_addr, + "to": to_email, + "subject": subject, + "html": html_body, + } + msg = resend.Emails.SendParams(params) + resend.Emails.send(msg) + return True + except Exception: + logger.exception("Email send failed to %s", to_email) + return False + + +def notify_mentions( + *, + mentioned_propel_ids, + actor_propel_id, + actor_name, + hackathon_event_id, + card_title, + card_id, + comment_body, +): + """Best-effort notification. Skips actor self-mentions silently. + + Returns dict {propel_id: "slack" | "email" | "skipped"}. + """ + results = {} + for pid in mentioned_propel_ids: + if pid == actor_propel_id: + results[pid] = "skipped" + continue + + oauth_user = _get_oauth_user_cached(pid) + slack_id = _extract_slack_user_id(oauth_user) + email = _extract_email(oauth_user) + plan_url = f"https://www.ohack.dev/hack/{hackathon_event_id}/plan" + + if not slack_id and not email: + logger.warning("No Slack ID or email for mentioned user %s; dropping notification", pid) + results[pid] = "no-channel" + continue + + sent = [] + + # Slack DM (when Slack OAuth identity exists). Both Slack-auth and + # Google-auth users have email; only Slack-auth has a Slack user ID. + if slack_id: + preview = MENTION_RE.sub(lambda m: f"@{m.group(1)}", comment_body or "") + preview = preview[:280] + ("…" if len(preview) > 280 else "") + msg = ( + f"📋 *{actor_name}* mentioned you in *{card_title}*\n" + f">{preview}\n" + f"<{plan_url}|Open the planning board>" + ) + if _send_slack_dm(slack_id, msg): + sent.append("slack") + + # Email — sent in addition to Slack so the mention is also durable + # in the recipient's inbox (Slack-only mentions get buried fast). + if email: + preview_html = MENTION_RE.sub(lambda m: f"@{m.group(1)}", comment_body or "") + preview_html = preview_html[:1000] + html = ( + f"

{actor_name} mentioned you on the " + f"{hackathon_event_id} planning board, in the card " + f"{card_title}:

" + f"
" + f"{preview_html}
" + f"

Open the planning board

" + f"

" + f"You’re getting this because someone mentioned you in a comment on a " + f"public Opportunity Hack planning board.

" + ) + subject = f"[OHack] {actor_name} mentioned you on the {hackathon_event_id} board" + if _send_email(email, subject, html): + sent.append("email") + + results[pid] = "+".join(sent) if sent else "failed" + + return results diff --git a/services/planning_ros_service.py b/services/planning_ros_service.py new file mode 100644 index 0000000..5ce87d8 --- /dev/null +++ b/services/planning_ros_service.py @@ -0,0 +1,103 @@ +"""Run of Show sync: planning board cards → hackathon.countdowns array. + +One-way sync only: board is the source of truth for is_run_of_show lists. +Existing countdowns without source="planning" are preserved as-is. +""" +import logging +from datetime import datetime, timezone + +from common.utils.firebase import get_db + +logger = logging.getLogger("planning_ros_service") + +SOURCE_PLANNING = "planning" +SOURCE_MANUAL = "manual" + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _get_ros_cards(hackathon_doc: dict) -> list: + """Return cards from is_run_of_show lists with sync_to_countdowns=True, sorted by start_time.""" + db = get_db() + href = db.collection("hackathons").document(hackathon_doc["id"]) + + ros_lists = [ + d.id + for d in href.collection("planning_lists") + .where("is_run_of_show", "==", True) + .where("archived", "==", False) + .stream() + ] + + cards = [] + for list_id in ros_lists: + for card_doc in ( + href.collection("planning_cards") + .where("list_id", "==", list_id) + .where("archived", "==", False) + .stream() + ): + data = {**card_doc.to_dict(), "id": card_doc.id} + if data.get("sync_to_countdowns") and data.get("start_time"): + cards.append(data) + + cards.sort(key=lambda c: c.get("start_time", "")) + return cards + + +def _cards_to_countdowns(ros_cards: list) -> list: + return [ + { + "name": card["title"], + "description": card.get("description") or "", + "time": card["start_time"], + "source": SOURCE_PLANNING, + "card_id": card["id"], + } + for card in ros_cards + ] + + +def compute_ros_diff(hackathon_doc: dict) -> dict: + """Return a preview diff of what the sync would do.""" + ros_cards = _get_ros_cards(hackathon_doc) + incoming = _cards_to_countdowns(ros_cards) + + existing_countdowns = hackathon_doc.get("countdowns") or [] + preserved = [c for c in existing_countdowns if c.get("source") != SOURCE_PLANNING] + current_planning = [c for c in existing_countdowns if c.get("source") == SOURCE_PLANNING] + + return { + "incoming_count": len(incoming), + "preserved_count": len(preserved), + "current_planning_count": len(current_planning), + "incoming": incoming, + "preserved": preserved, + "merged": sorted(incoming + preserved, key=lambda c: c.get("time", "")), + } + + +def sync_ros_to_countdowns(hackathon_doc: dict, actor_id: str) -> dict: + """Apply the sync: merge incoming planning entries with preserved manual entries.""" + diff = compute_ros_diff(hackathon_doc) + merged = diff["merged"] + + db = get_db() + href = db.collection("hackathons").document(hackathon_doc["id"]) + href.update({"countdowns": merged}) + + logger.info( + "RoS sync: %d planning + %d preserved = %d total for %s (by %s)", + diff["incoming_count"], + diff["preserved_count"], + len(merged), + hackathon_doc.get("event_id"), + actor_id, + ) + return { + "synced": diff["incoming_count"], + "preserved": diff["preserved_count"], + "total": len(merged), + } diff --git a/services/planning_slack_notifier.py b/services/planning_slack_notifier.py new file mode 100644 index 0000000..5c612e7 --- /dev/null +++ b/services/planning_slack_notifier.py @@ -0,0 +1,326 @@ +"""Slack digest for the planning board. + +Digest delivery is done without a dedicated cron job via three layered mechanisms: +1. Lazy flush: the next mutation after the 60s window reads and sends the prior window. +2. Read-driven flush: every GET /api/planning/{event_id} checks and flushes if past deadline. +3. Optional external cron: POST /api/planning/_flush_digests (authenticated by X-Internal-Token). + +Without Redis or external cron, digests fire on the next page load after the window closes. +""" +import logging +from datetime import datetime, timezone, timedelta +from typing import Optional + +from common.utils.firebase import get_db + +logger = logging.getLogger("planning_slack_notifier") + +DIGEST_WINDOW_SECONDS = 60 +DIGEST_DEADLINE_FIELD = "planning_digest_deadline" +MAX_DIGEST_LINES = 8 + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _now_iso() -> str: + return _now().isoformat() + + +def _get_deadline_key(hackathon_id: str) -> str: + return f"planning:digest_deadline:{hackathon_id}" + + +def _get_or_set_deadline(hackathon_doc: dict) -> Optional[datetime]: + """Return the current digest deadline, or None if no pending events exist.""" + hid = hackathon_doc["id"] + + # Try Redis first (fast path) + try: + from common.utils.redis_cache import get_redis_client + rc = get_redis_client() + if rc: + key = _get_deadline_key(hid) + raw = rc.get(key) + if raw: + return datetime.fromisoformat(raw.decode()) + # No deadline in Redis — set it + deadline = _now() + timedelta(seconds=DIGEST_WINDOW_SECONDS) + rc.set(key, deadline.isoformat(), ex=DIGEST_WINDOW_SECONDS + 30) + return deadline + except Exception: + pass + + # Fallback: read/write deadline on the hackathon doc + db = get_db() + href = db.collection("hackathons").document(hid) + snap = href.get() + data = snap.to_dict() or {} + existing_deadline = data.get(DIGEST_DEADLINE_FIELD) + if existing_deadline: + try: + return datetime.fromisoformat(existing_deadline) + except ValueError: + pass + + deadline = _now() + timedelta(seconds=DIGEST_WINDOW_SECONDS) + href.update({DIGEST_DEADLINE_FIELD: deadline.isoformat()}) + return deadline + + +def _clear_deadline(hackathon_doc: dict) -> None: + hid = hackathon_doc["id"] + try: + from common.utils.redis_cache import get_redis_client + rc = get_redis_client() + if rc: + rc.delete(_get_deadline_key(hid)) + except Exception: + pass + db = get_db() + db.collection("hackathons").document(hid).update({DIGEST_DEADLINE_FIELD: None}) + + +def _collect_and_clear_events(hackathon_doc: dict) -> list: + """Read and delete all pending digest events, returning them sorted by created_at.""" + db = get_db() + href = db.collection("hackathons").document(hackathon_doc["id"]) + pending_ref = href.collection("planning_pending_digests") + docs = list(pending_ref.order_by("created_at").stream()) + events = [{**d.to_dict(), "_doc_id": d.id} for d in docs] + + batch = db.batch() + for d in docs: + batch.delete(pending_ref.document(d.id)) + batch.commit() + + return events + + +def _coalesce_events(events: list) -> list: + """Deduplicate (card_id, kind) groups — keep last event per group.""" + seen = {} + for ev in events: + key = (ev.get("card_id", ""), ev.get("kind", "")) + seen[key] = ev + # Sort back by created_at + return sorted(seen.values(), key=lambda e: e.get("created_at", "")) + + +KIND_TEMPLATES = { + "card_created": "• {actor} added card \"{card_title}\"", + "card_updated": "• {actor} updated \"{card_title}\"", + "card_archived": "• {actor} archived \"{card_title}\"", + "comment_added": "• New comment on \"{card_title}\"", + "list_created": "• Created list \"{list_title}\"", + "ros_synced": "• Run of Show synced to public timeline ({count} entries)", +} + + +def format_planning_digest(events: list, hackathon_doc: dict) -> str: + title = hackathon_doc.get("title", "Planning board") + lines = [f"📋 *{title}* — last 60s"] + + coalesced = _coalesce_events(events)[:MAX_DIGEST_LINES] + overflow = len(_coalesce_events(events)) - MAX_DIGEST_LINES + + for ev in coalesced: + kind = ev.get("kind", "") + template = KIND_TEMPLATES.get(kind, "• Board updated") + actor = ev.get("actor", "Someone") + line = template.format( + actor=actor, + card_title=ev.get("card_title", "a card"), + list_title=ev.get("list_title", "a list"), + count=ev.get("count", ""), + ) + lines.append(line) + + if overflow > 0: + event_id = hackathon_doc.get("event_id", "") + lines.append(f"…and {overflow} more changes ") + else: + event_id = hackathon_doc.get("event_id", "") + lines.append(f"") + + return "\n".join(lines) + + +def _post_to_channel(channel: str, message: str): + """Send a message to a Slack channel. Returns (ok: bool, error: Optional[str]). + + Joins the channel first so chat.postMessage doesn't silently fail with + not_in_channel. The shared common.utils.slack.send_slack helper swallows + SlackApiError and returns nothing — that's why "Send digest now" was + returning 200 with no Slack message. + """ + try: + from common.utils.slack import ( + add_bot_to_channel, + get_channel_id_from_channel_name, + get_client, + is_channel_id, + ) + from slack_sdk.errors import SlackApiError + from slack_sdk.models.blocks import SectionBlock + + channel_id = channel if is_channel_id(channel) else get_channel_id_from_channel_name(channel) + if not channel_id: + return False, f"Slack channel '#{channel}' not found" + + # Best-effort join; OK if already a member. + add_bot_to_channel(channel_id) + + client = get_client() + try: + client.chat_postMessage( + channel=channel_id, + text=message, + blocks=[SectionBlock(text={"type": "mrkdwn", "text": message})], + username="Hackathon Bot", + icon_url="https://cdn.ohack.dev/ohack.dev/logos/OpportunityHack_2Letter_Light_Blue.png", + ) + return True, None + except SlackApiError as e: + err = (e.response or {}).get("error") or str(e) + logger.error("Slack chat.postMessage failed for #%s: %s", channel, err) + if err == "not_in_channel": + return False, ( + f"Bot couldn't post to #{channel} — try inviting the OHack bot " + f"to the channel via /invite @ohack-bot" + ) + return False, f"Slack error: {err}" + except Exception as e: + logger.exception("Unexpected error posting to Slack #%s", channel) + return False, f"Unexpected error: {e}" + + +def _do_flush(hackathon_doc: dict) -> int: + """Flush pending events into a Slack message. Returns count of events sent.""" + events = _collect_and_clear_events(hackathon_doc) + if not events: + _clear_deadline(hackathon_doc) + return 0 + + planning = hackathon_doc.get("planning") or {} + slack = planning.get("slack") or {} + channel = slack.get("channel", "") + if not channel: + _clear_deadline(hackathon_doc) + return 0 + + message = format_planning_digest(events, hackathon_doc) + ok, err = _post_to_channel(channel, message) + if ok: + logger.info("Planning digest sent to #%s (%d events)", channel, len(events)) + else: + logger.error("Planning digest send failed for #%s: %s", channel, err) + + _clear_deadline(hackathon_doc) + return len(events) + + +def flush_digests_if_due(hackathon_doc: dict) -> None: + """Read-driven flush — call from GET /api/planning/{event_id}.""" + planning = hackathon_doc.get("planning") or {} + if not planning.get("notify_on_card_change", False): + slack = planning.get("slack") or {} + if not slack.get("notify_on_card_change"): + return + + db = get_db() + href = db.collection("hackathons").document(hackathon_doc["id"]) + data = href.get().to_dict() or {} + deadline_str = data.get(DIGEST_DEADLINE_FIELD) + if not deadline_str: + return + + try: + deadline = datetime.fromisoformat(deadline_str) + except ValueError: + return + + if _now() >= deadline: + _do_flush(hackathon_doc) + + +def lazy_flush_if_due(hackathon_doc: dict) -> None: + """Lazy flush — call at the start of any mutation route.""" + flush_digests_if_due(hackathon_doc) + + +def send_manual_digest(hackathon_doc: dict): + """Admin 'Send digest now' — posts a current-state snapshot of the board. + + Bypasses the queued-events digest entirely (per the design plan: "post the + current state, one line per non-empty list with card counts"). Returns + (ok: bool, error_or_message: str) so the route handler can surface + success/error to the admin. + """ + planning = hackathon_doc.get("planning") or {} + slack = planning.get("slack") or {} + channel = (slack.get("channel") or "").strip() + if not channel: + return False, "No Slack channel configured for this board" + + event_id = hackathon_doc.get("event_id") or hackathon_doc.get("id") + title = hackathon_doc.get("title") or event_id + + # Compose a current-state digest: list titles + non-archived card counts. + db = get_db() + href = db.collection("hackathons").document(hackathon_doc["id"]) + lists_docs = sorted( + [{**d.to_dict(), "id": d.id} for d in href.collection("planning_lists").where("archived", "==", False).stream()], + key=lambda x: x.get("position", ""), + ) + cards_docs = [ + {**d.to_dict(), "id": d.id} + for d in href.collection("planning_cards").where("archived", "==", False).stream() + ] + counts = {} + for c in cards_docs: + lid = c.get("list_id") + if lid: + counts[lid] = counts.get(lid, 0) + 1 + + plan_url = f"https://www.ohack.dev/hack/{event_id}/plan" + lines = [f"📋 *{title} — planning board snapshot*"] + for lst in lists_docs: + n = counts.get(lst["id"], 0) + if n > 0: + lines.append(f"• *{lst.get('title', '(untitled)')}*: {n} card{'s' if n != 1 else ''}") + if len(lines) == 1: + lines.append("_(no cards yet)_") + lines.append(f"<{plan_url}|Open the board>") + message = "\n".join(lines) + + ok, err = _post_to_channel(channel, message) + if ok: + return True, f"Digest posted to #{channel}" + return False, err or "Slack send failed" + + +def flush_all_pending_digests() -> int: + """Flush all hackathons with pending digests (called by external cron).""" + db = get_db() + # Find hackathons with a non-null planning_digest_deadline + docs = ( + db.collection("hackathons") + .where("planning_digest_deadline", "!=", None) + .stream() + ) + total = 0 + for doc in docs: + data = doc.to_dict() or {} + deadline_str = data.get(DIGEST_DEADLINE_FIELD) + if not deadline_str: + continue + try: + deadline = datetime.fromisoformat(deadline_str) + except ValueError: + continue + if _now() >= deadline: + hackathon_doc = {**data, "id": doc.id} + total += _do_flush(hackathon_doc) + return total diff --git a/services/planning_template_service.py b/services/planning_template_service.py new file mode 100644 index 0000000..3695a59 --- /dev/null +++ b/services/planning_template_service.py @@ -0,0 +1,143 @@ +"""Seed the OHack default planning board template.""" +import logging +from datetime import datetime, timezone + +from common.utils.firebase import get_db + +logger = logging.getLogger("planning_template_service") + +# --------------------------------------------------------------------------- +# OHack default template — 8 lists with seed cards +# --------------------------------------------------------------------------- + +OHACK_PLANNING_TEMPLATE = [ + { + "title": "Nonprofits & Problems", + "is_run_of_show": False, + "cards": [ + {"title": "Confirm 6–8 nonprofit problem statements", "kind": "nonprofits", "target_count": 8}, + {"title": "Schedule nonprofit kickoff calls"}, + {"title": "Publish problem briefs on ohack.dev"}, + ], + }, + { + "title": "Sponsors & Budget", + "is_run_of_show": False, + "cards": [ + {"title": "Set fundraising goal"}, + {"title": "Refresh sponsor deck"}, + {"title": "Send sponsor outreach"}, + ], + }, + { + "title": "Venue & Logistics", + "is_run_of_show": False, + "cards": [ + {"title": "Lock venue and dates"}, + {"title": "Order food (per meal slot)"}, + {"title": "AV / wifi / power"}, + ], + }, + { + "title": "Volunteers & Mentors", + "is_run_of_show": False, + "cards": [ + {"title": "Open mentor applications", "kind": "mentors", "target_count": 20}, + {"title": "Mentor schedule"}, + {"title": "Volunteer task list"}, + ], + }, + { + "title": "Judges & Prizes", + "is_run_of_show": False, + "cards": [ + {"title": "Recruit judges", "kind": "judges", "target_count": 15}, + {"title": "Define judging rubric"}, + {"title": "Order prizes"}, + ], + }, + { + "title": "Marketing & Comms", + "is_run_of_show": False, + "cards": [ + {"title": "Landing page copy"}, + {"title": "Slack channel and invites"}, + {"title": "Social media schedule"}, + ], + }, + { + "title": "Run of Show", + "is_run_of_show": True, + "cards": [ + {"title": "Doors Open", "start_time": "08:00", "sync_to_countdowns": True}, + {"title": "Kickoff", "start_time": "09:00", "sync_to_countdowns": True}, + {"title": "Nonprofit Pitches", "start_time": "09:30", "sync_to_countdowns": True}, + {"title": "Team Formation", "start_time": "10:00", "sync_to_countdowns": True}, + {"title": "Hacking Begins", "start_time": "10:30", "sync_to_countdowns": True, "kind": "hackers", "target_count": 90}, + {"title": "Lunch (Day 1)", "start_time": "12:00", "sync_to_countdowns": True}, + {"title": "Breakfast (Day 2)", "start_time": "07:00", "sync_to_countdowns": True}, + {"title": "Hacking Ends", "start_time": "15:00", "sync_to_countdowns": True}, + {"title": "Judging Begins", "start_time": "15:05", "sync_to_countdowns": True}, + {"title": "Winners Announced", "start_time": "17:30", "sync_to_countdowns": True}, + ], + }, + { + "title": "Post-Event", + "is_run_of_show": False, + "cards": [ + {"title": "Winners announcement"}, + {"title": "Thank-you emails to sponsors and nonprofits"}, + {"title": "Retrospective and lessons learned"}, + ], + }, +] + + +def apply_ohack_template(hackathon_doc: dict) -> None: + """Write the default template lists/cards into the given hackathon's subcollections.""" + db = get_db() + href = db.collection("hackathons").document(hackathon_doc["id"]) + + now = datetime.now(timezone.utc).isoformat() + + for list_position, list_template in enumerate(OHACK_PLANNING_TEMPLATE): + position = f"p{list_position:04d}" + list_ref = href.collection("planning_lists").document() + list_ref.set({ + "title": list_template["title"], + "position": position, + "archived": False, + "is_run_of_show": list_template.get("is_run_of_show", False), + "created_at": now, + "updated_at": now, + }) + + for card_position, card_template in enumerate(list_template.get("cards", [])): + card_position_str = f"p{card_position:04d}" + card_ref = href.collection("planning_cards").document() + card_ref.set({ + "list_id": list_ref.id, + "title": card_template["title"], + "description": "", + "kind": card_template.get("kind", "freetext"), + "assignees": [], + "labels": [], + "due_date": None, + "position": card_position_str, + "archived": False, + "checklists": [], + "attachments": [], + "comment_count": 0, + "created_by": "system", + "created_at": now, + "updated_at": now, + "last_activity_at": now, + "start_time": card_template.get("start_time"), + "end_time": None, + "sync_to_countdowns": card_template.get("sync_to_countdowns", False), + "budget": None, + "target_count": card_template.get("target_count"), + "sponsor": None, + }) + + logger.info("Applied OHack default template to hackathon %s", hackathon_doc.get("event_id"))