From 0f8b287523cd869645d18d55e9c395d325d22f3e Mon Sep 17 00:00:00 2001 From: Greg V Date: Fri, 8 May 2026 07:37:50 -0700 Subject: [PATCH 01/14] [Problems] Add rank --- api/messages/messages_service.py | 4 +++- db/firestore.py | 4 ++++ db/mem.py | 4 +++- model/problem_statement.py | 5 ++++- 4 files changed, 14 insertions(+), 3 deletions(-) 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/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/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) From a11627c51db9cd3a5873823eaa8f3e42d626a4f4 Mon Sep 17 00:00:00 2001 From: Greg V Date: Fri, 8 May 2026 13:54:35 -0700 Subject: [PATCH 02/14] =?UTF-8?q?[ClaudeCode]=20Add=20planning=20board=20b?= =?UTF-8?q?ackend=20=E2=80=94=20/api/planning/*?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New package api/planning/planning_views.py — 20 REST endpoints: GET /api/planning/{event_id} board snapshot + ETag POST/PATCH lists, cards, labels require_plan_editor POST/DELETE comments require_logged_in_on_enabled_plan PATCH editors, config; POST seed-template require_admin_on_event POST cards/{id}/rebalance LexoRank batch rebalance POST run-of-show/sync + GET preview preserves source:manual countdowns POST _flush_digests (cron, token-gated) POST slack/notify, editing-heartbeat Auth foundations (services/hackathon_planning_service.py): require_plan_editor decorator — @auth.require_user + doc lookup + planning.enabled check + admin-or-editor check; editor list never cached require_admin_on_event, require_logged_in_on_enabled_plan g.hackathon stash prevents cross-event card mutations Services: planning_template_service.py — 8-list OHack default template with seed cards planning_slack_notifier.py — Firestore-backed pending digest queue; lazy flush (next mutation after 60s window) + read-driven flush (GET board); no cron required; Redis accelerates deadline lookup when available planning_ros_service.py — Run of Show sync: planning cards → countdowns; preserves entries where source != "planning" Schema (model/planning.py): ALLOWED_BUDGET_BUCKETS, ALLOWED_CARD_KINDS, MAX_* constants, default_planning_subobject() validators.py — validate_planning_subobject (editors dedup, Slack channel regex, planning.enabled/template_seeded/budget_widget booleans) hackathons_service.py — persist planning subobject through PATCH api/__init__.py — register planning blueprint firestore.indexes.json — composite indexes for planning_cards, planning_comments, planning_pending_digests subcollections Security: _flush_digests requires PLANNING_INTERNAL_TOKEN or rejects non-loopback All subcollection lookups path through g.hackathon (URL's event_id), preventing cross-event card_id injection planning.enabled === false returns 404 (not 403) on all mutation routes Co-Authored-By: Claude Opus 4.7 --- api/__init__.py | 2 + api/planning/__init__.py | 1 + api/planning/planning_views.py | 877 +++++++++++++++++++++++++ common/utils/validators.py | 56 ++ firestore.indexes.json | 29 + model/planning.py | 50 ++ services/hackathon_planning_service.py | 160 +++++ services/hackathons_service.py | 3 + services/planning_ros_service.py | 103 +++ services/planning_slack_notifier.py | 232 +++++++ services/planning_template_service.py | 143 ++++ 11 files changed, 1656 insertions(+) create mode 100644 api/planning/__init__.py create mode 100644 api/planning/planning_views.py create mode 100644 firestore.indexes.json create mode 100644 model/planning.py create mode 100644 services/hackathon_planning_service.py create mode 100644 services/planning_ros_service.py create mode 100644 services/planning_slack_notifier.py create mode 100644 services/planning_template_service.py 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/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..8da51cd --- /dev/null +++ b/api/planning/planning_views.py @@ -0,0 +1,877 @@ +"""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_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 = [ + {**d.to_dict(), "id": d.id} + for d in href.collection("planning_lists") + .where("archived", "==", False) + .order_by("position") + .stream() + ] + cards_docs = [ + {**d.to_dict(), "id": d.id} + for d in href.collection("planning_cards") + .where("archived", "==", False) + .order_by("position") + .stream() + ] + 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) + + resp = jsonify({ + "event_id": event_id, + "planning": planning, + "lists": lists_docs, + "cards": cards_docs, + "labels": labels_docs, + }) + resp.headers["ETag"] = etag + return resp, 200 + + +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=["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 "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) + 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 + + +# --------------------------------------------------------------------------- +# 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 + send_manual_digest(g.hackathon) + return jsonify({"message": "Digest sent"}), 200 + 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/firestore.indexes.json b/firestore.indexes.json new file mode 100644 index 0000000..b75ef24 --- /dev/null +++ b/firestore.indexes.json @@ -0,0 +1,29 @@ +{ + "indexes": [ + { + "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..2e1dd00 --- /dev/null +++ b/model/planning.py @@ -0,0 +1,50 @@ +"""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"} + +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/services/hackathon_planning_service.py b/services/hackathon_planning_service.py new file mode 100644 index 0000000..cfd9771 --- /dev/null +++ b/services/hackathon_planning_service.py @@ -0,0 +1,160 @@ +"""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 flask SDK exposes per-org info on ``current_user.org_id_to_org_info``. + OHack only has one org ("Opportunity Hack Org") so we accept ``volunteer.admin`` in + any org membership. If the user has no org memberships at all, they are not admin. + """ + if not propel_user or not getattr(propel_user, "user_id", None): + return False + org_id_to_org_info = getattr(propel_user, "org_id_to_org_info", None) or {} + for org_info in org_id_to_org_info.values(): + if not isinstance(org_info, dict): + continue + permissions = org_info.get("user_permissions") 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_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..e328b7f --- /dev/null +++ b/services/planning_slack_notifier.py @@ -0,0 +1,232 @@ +"""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 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) -> datetime | None: + """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 _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) + try: + from common.utils.slack import send_slack + send_slack(message, channel=channel) + logger.info("Planning digest sent to #%s (%d events)", channel, len(events)) + except Exception: + logger.exception("Failed to send planning Slack digest to #%s", channel) + + _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) -> None: + """Admin 'Send digest now' — flushes the current board state immediately.""" + events = _collect_and_clear_events(hackathon_doc) + _do_flush(hackathon_doc) + + +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")) From 0ab39a3279279c84fe2e7e7d5b7ed7ff47db89db Mon Sep 17 00:00:00 2001 From: Greg V Date: Fri, 8 May 2026 19:46:14 -0700 Subject: [PATCH 03/14] =?UTF-8?q?Fix=20planning=20board=20GET=20=E2=80=94?= =?UTF-8?q?=20avoid=20compound=20queries=20requiring=20missing=20Firestore?= =?UTF-8?q?=20indexes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The get_board endpoint queried planning_lists and planning_cards with .where("archived", False).order_by("position") — a compound query that requires composite indexes not yet deployed to production Firestore. Sorts in Python instead to eliminate the index dependency. Also adds the missing (archived, position) composite indexes for both collections to firestore.indexes.json so firebase deploy --only firestore:indexes makes them available in production. Co-Authored-By: Claude Sonnet 4.6 --- api/planning/planning_views.py | 22 ++++++++-------------- firestore.indexes.json | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/api/planning/planning_views.py b/api/planning/planning_views.py index 8da51cd..e444cb7 100644 --- a/api/planning/planning_views.py +++ b/api/planning/planning_views.py @@ -159,20 +159,14 @@ def get_board(event_id): db = get_db() href = db.collection("hackathons").document(hid) - lists_docs = [ - {**d.to_dict(), "id": d.id} - for d in href.collection("planning_lists") - .where("archived", "==", False) - .order_by("position") - .stream() - ] - cards_docs = [ - {**d.to_dict(), "id": d.id} - for d in href.collection("planning_cards") - .where("archived", "==", False) - .order_by("position") - .stream() - ] + 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() diff --git a/firestore.indexes.json b/firestore.indexes.json index b75ef24..45e84e8 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -1,5 +1,21 @@ { "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", From 4435e74547025cccccdbfcfb6e15958edeb78312 Mon Sep 17 00:00:00 2001 From: Greg V Date: Fri, 8 May 2026 19:47:40 -0700 Subject: [PATCH 04/14] Fix is_admin: PropelAuth OrgMemberInfo is an object, not a dict org_info.get("user_permissions") always returned None because OrgMemberInfo uses attribute access. Switch to getattr(org_info, "user_permissions", []) so volunteer.admin permission is correctly detected, unblocking the 403 on seed-template, editors PATCH, and all other admin-only planning routes. Co-Authored-By: Claude Sonnet 4.6 --- services/hackathon_planning_service.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/services/hackathon_planning_service.py b/services/hackathon_planning_service.py index cfd9771..4b0a171 100644 --- a/services/hackathon_planning_service.py +++ b/services/hackathon_planning_service.py @@ -36,9 +36,8 @@ def is_admin(propel_user) -> bool: return False org_id_to_org_info = getattr(propel_user, "org_id_to_org_info", None) or {} for org_info in org_id_to_org_info.values(): - if not isinstance(org_info, dict): - continue - permissions = org_info.get("user_permissions") or [] + # PropelAuth OrgMemberInfo is an object, not a dict + permissions = getattr(org_info, "user_permissions", None) or [] if ADMIN_PERMISSION in permissions: return True return False From d1106f5b7152828887b9d3b0314343779139dda0 Mon Sep 17 00:00:00 2001 From: Greg V Date: Fri, 8 May 2026 19:50:43 -0700 Subject: [PATCH 05/14] Fix is_admin: PropelAuth attribute is org_id_to_org_member_info MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous fix targeted the wrong attribute name (org_id_to_org_info), so getattr(...) silently returned None and the loop never executed — is_admin always returned False, blocking every admin-only planning route (seed-template, editors PATCH, config PATCH) with 403. The actual attribute on PropelAuth's User class is org_id_to_org_member_info. Also use OrgMemberInfo.user_has_permission() helper as the primary check, falling back to direct attribute access. Co-Authored-By: Claude Opus 4.7 --- services/hackathon_planning_service.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/services/hackathon_planning_service.py b/services/hackathon_planning_service.py index 4b0a171..81d6853 100644 --- a/services/hackathon_planning_service.py +++ b/services/hackathon_planning_service.py @@ -28,18 +28,23 @@ def is_admin(propel_user) -> bool: """Return True if the authenticated user has the global volunteer.admin permission. - PropelAuth's flask SDK exposes per-org info on ``current_user.org_id_to_org_info``. - OHack only has one org ("Opportunity Hack Org") so we accept ``volunteer.admin`` in - any org membership. If the user has no org memberships at all, they are not admin. + 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_info = getattr(propel_user, "org_id_to_org_info", None) or {} - for org_info in org_id_to_org_info.values(): - # PropelAuth OrgMemberInfo is an object, not a dict - permissions = getattr(org_info, "user_permissions", None) or [] - if ADMIN_PERMISSION in permissions: - return True + 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 From b07d911b282eae788aa71178349820e91031c4c8 Mon Sep 17 00:00:00 2001 From: Greg V Date: Fri, 8 May 2026 20:23:32 -0700 Subject: [PATCH 06/14] [Planning] Add admin user search endpoint for editors picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/planning/_users/search?q=... — admin-only, substring match across Firestore users by name/email/nickname, returns up to 25 candidates with the propel user_id needed by editors[]. Q must be at least 2 chars to avoid dumping the user table. Powers the new Autocomplete on the admin Planning Board accordion so admins no longer need to know or paste PropelAuth user IDs. Co-Authored-By: Claude Opus 4.7 --- api/planning/planning_views.py | 48 ++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/api/planning/planning_views.py b/api/planning/planning_views.py index e444cb7..7c7d72b 100644 --- a/api/planning/planning_views.py +++ b/api/planning/planning_views.py @@ -710,6 +710,54 @@ def seed_template(event_id): return jsonify({"message": "Template applied"}), 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) # --------------------------------------------------------------------------- From 7478944a6962faf85195bac6a5eb60be260c4c9e Mon Sep 17 00:00:00 2001 From: Greg V Date: Fri, 8 May 2026 23:06:47 -0700 Subject: [PATCH 07/14] Fix planning_slack_notifier import on Python <3.10 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PEP 604 union syntax (X | None) requires Python 3.10+. The OHack backend runs on an older interpreter, so importing the module raised TypeError at parse time and the manual Slack digest button (and every digest flush path) returned 500. Use typing.Optional instead — supported on every interpreter the rest of the backend already targets. Co-Authored-By: Claude Opus 4.7 --- services/planning_slack_notifier.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/services/planning_slack_notifier.py b/services/planning_slack_notifier.py index e328b7f..7b1d4b6 100644 --- a/services/planning_slack_notifier.py +++ b/services/planning_slack_notifier.py @@ -9,6 +9,7 @@ """ import logging from datetime import datetime, timezone, timedelta +from typing import Optional from common.utils.firebase import get_db @@ -31,7 +32,7 @@ def _get_deadline_key(hackathon_id: str) -> str: return f"planning:digest_deadline:{hackathon_id}" -def _get_or_set_deadline(hackathon_doc: dict) -> datetime | None: +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"] From bd2dd9392f1a7b9de8ceeeb006e53dbf955c61c2 Mon Sep 17 00:00:00 2001 From: Greg V Date: Sat, 9 May 2026 12:27:37 -0700 Subject: [PATCH 08/14] [Planning] Add card status field + bundle assignee profiles in board snapshot Status: - Adds ALLOWED_CARD_STATUSES = {planned, in_progress, blocked, completed} to model/planning.py - PATCH /cards/{id} now accepts a status field (null/empty clears it) Assignee profiles: - get_board now includes a `users` map keyed by propel_user_id with {name, nickname, profile_image} for everyone referenced in cards.assignees + planning.editors. Lets the frontend render avatars without per-user round-trips. - 60-second in-process cache on the user-list resolution so polling boards don't pay for fetch_users() on every snapshot. Co-Authored-By: Claude Opus 4.7 --- api/planning/planning_views.py | 61 ++++++++++++++++++++++++++++++++++ model/planning.py | 1 + 2 files changed, 62 insertions(+) diff --git a/api/planning/planning_views.py b/api/planning/planning_views.py index 7c7d72b..19a8633 100644 --- a/api/planning/planning_views.py +++ b/api/planning/planning_views.py @@ -24,6 +24,7 @@ ALLOWED_BUDGET_BUCKETS, ALLOWED_BUDGET_STATES, ALLOWED_CARD_KINDS, + ALLOWED_CARD_STATUSES, ALLOWED_OUTREACH_STATUSES, ALLOWED_SPONSOR_TIERS, MAX_ATTACHMENTS_PER_CARD, @@ -179,17 +180,67 @@ def get_board(event_id): # 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} + + +def _resolve_public_user_profiles(propel_ids): + """Return {propel_id: {name, profile_image, nickname}} for the given set. + + Public-safe fields only. Returns {} when the set is empty so the call + site can skip the work. + """ + 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 "", + } + _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 {} + return {pid: indexed[pid] for pid in propel_ids if pid in indexed} + + def _maybe_flush_digests_read_driven(hackathon_doc): """Best-effort read-driven Slack digest flush (runs on GET board).""" try: @@ -431,6 +482,16 @@ def update_card(event_id, card_id): 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: diff --git a/model/planning.py b/model/planning.py index 2e1dd00..85b9408 100644 --- a/model/planning.py +++ b/model/planning.py @@ -22,6 +22,7 @@ "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 From f563883fd1e4be4e87b0ad4ea4577c04c21d30ea Mon Sep 17 00:00:00 2001 From: Greg V Date: Sat, 9 May 2026 13:14:04 -0700 Subject: [PATCH 09/14] =?UTF-8?q?[Planning]=20@-mentions=20in=20comments?= =?UTF-8?q?=20=E2=80=94=20Slack=20DM=20+=20email=20notifications?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds mention search + notification dispatch: - GET /api/planning/_users/mention-search?q= — any-logged-in endpoint returning {user_id, name, profile_image} only. No email, no Slack ID ever surfaced to clients. Reuses the existing 60s _USER_PROFILES_CACHE so an active board doesn't hit fetch_users() per keystroke. - New services/planning_mention_notifier.py: - parse_mention_ids(body) — finds @[Name](propel_user_id) tokens. - notify_mentions(...) — best-effort dispatch. For each mentioned user we resolve their PropelAuth OAuth identity (cached 5 min via TTLCache) and send: * Slack DM AND email when Slack OAuth → both channels (Slack-buried mentions remain durable in the inbox). * Email only when Google OAuth (no Slack ID). * Drop with warning when neither is available. - Skips actor self-mentions silently. - create_comment hook: parses mentions from the saved body and fires notifications best-effort. Failures are logged but cannot break the comment write — the comment is already persisted by the time the dispatcher runs. Co-Authored-By: Claude Opus 4.7 --- api/planning/planning_views.py | 62 ++++++++++ services/planning_mention_notifier.py | 167 ++++++++++++++++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 services/planning_mention_notifier.py diff --git a/api/planning/planning_views.py b/api/planning/planning_views.py index 19a8633..2b7d15a 100644 --- a/api/planning/planning_views.py +++ b/api/planning/planning_views.py @@ -613,6 +613,29 @@ def create_comment(event_id, card_id): 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 @@ -771,6 +794,45 @@ def seed_template(event_id): return jsonify({"message": "Template applied"}), 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) # --------------------------------------------------------------------------- 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 From 62c8d0189e1dcc45d1d97806bd741758369d4672 Mon Sep 17 00:00:00 2001 From: Greg V Date: Sat, 9 May 2026 14:19:27 -0700 Subject: [PATCH 10/14] [Planning] Resolve assignee profiles even when no Firestore record exists Two changes to _resolve_public_user_profiles: 1. Bundle the Firestore document id (db_id) on each profile so the frontend can build canonical /profile/{db_id} links. 2. Fall back to a one-shot PropelAuth OAuth lookup for any propel_id that isn't in the cached fetch_users() index. Previously, a user who had just authenticated but never triggered profile creation (never visited /profile, never saved metadata) would show as "Unknown ?" on cards they assigned themselves to. The fallback caches per-user for 5 minutes so a hot board with many self-assigners doesn't hammer PropelAuth. Co-Authored-By: Claude Opus 4.7 --- api/planning/planning_views.py | 46 +++++++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/api/planning/planning_views.py b/api/planning/planning_views.py index 2b7d15a..8818b5f 100644 --- a/api/planning/planning_views.py +++ b/api/planning/planning_views.py @@ -206,11 +206,20 @@ def get_board(event_id): _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}} for the given set. + """Return {propel_id: {name, profile_image, nickname, db_id}} for the given set. - Public-safe fields only. Returns {} when the set is empty so the call - site can skip the work. + 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: @@ -230,6 +239,7 @@ def _resolve_public_user_profiles(propel_ids): "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 @@ -238,7 +248,35 @@ def _resolve_public_user_profiles(propel_ids): return {} indexed = _USER_PROFILES_CACHE["profiles"] or {} - return {pid: indexed[pid] for pid in propel_ids if pid in indexed} + 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): From 58f9e759506fd0798dc18fca2bd43ac7f7d8f85f Mon Sep 17 00:00:00 2001 From: Greg V Date: Sat, 9 May 2026 14:24:51 -0700 Subject: [PATCH 11/14] [Docs] CLAUDE.md: capture session learnings as a Gotchas section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Things that bit us hard during planning-board development and would silently re-bite anyone who didn't have this context: - Python 3.9 forbids PEP 604 union syntax (X | None) — use Optional[X] - PropelAuth User attribute is org_id_to_org_member_info (NOT org_id_to_org_info), each value is an OrgMemberInfo object with attribute access (NOT a dict) - Firestore compound queries (where + order_by on different fields) need composite indexes deployed via firebase deploy — emulator allows them silently, prod returns 500 - The users collection is populated lazily on first profile interaction; fetch_users() does NOT cover everyone with a propel_user_id - OAuth provider response shapes differ (Slack has user_id field, Google has picture); detect by presence of https://slack.com/user_id - User.id (Firestore doc) vs User.user_id (propel_id) are different values — bundle both when sending user data to the frontend --- CLAUDE.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) 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. From 6ee2bd9b00b69af85c702f1ac1507cca46f0f0c6 Mon Sep 17 00:00:00 2001 From: Greg V Date: Sat, 9 May 2026 14:31:40 -0700 Subject: [PATCH 12/14] [Planning] Add public GET /cards/{id} for SSR card permalink unfurls The new SSR permalink page (/hack/{event_id}/plan/c/{card_id}) needs to fetch a single card server-side to emit per-card OG meta tags so Slack / Twitter / LinkedIn unfurls show the card title + description preview. Public read; gated on planning.enabled (returns 404 when disabled, same as get_board). Archived cards return 404. Co-Authored-By: Claude Opus 4.7 --- api/planning/planning_views.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/api/planning/planning_views.py b/api/planning/planning_views.py index 8818b5f..5eab1ea 100644 --- a/api/planning/planning_views.py +++ b/api/planning/planning_views.py @@ -468,6 +468,29 @@ def _validate_sponsor(sponsor): } +@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): From c89b95aff66ac4999e5883d1b94090f1d5381e6a Mon Sep 17 00:00:00 2001 From: Greg V Date: Sat, 9 May 2026 14:34:56 -0700 Subject: [PATCH 13/14] [Planning] Add exact-id user resolver endpoint for editors manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/planning/_users/by-ids?ids=p1,p2,... — any logged-in user can batch-resolve up to 50 propel_user_ids to {name, profile_image, db_id}. Replaces a hacky workaround in the admin editors manager that searched for the first 8 chars of the propel_id (which never matched anyone's name, so editors always rendered as "Unknown user"). Reuses _resolve_public_user_profiles, so the PropelAuth fallback added for board snapshots also covers admin-side editor display — a freshly- added editor who has never visited /profile still shows the right name and avatar, not the opaque oauth2|slack|... ID. Co-Authored-By: Claude Opus 4.7 --- api/planning/planning_views.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/api/planning/planning_views.py b/api/planning/planning_views.py index 5eab1ea..5304d8c 100644 --- a/api/planning/planning_views.py +++ b/api/planning/planning_views.py @@ -855,6 +855,28 @@ def seed_template(event_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 From 928e7e90de2137219d696fcbb81edc0fa2c4a3f0 Mon Sep 17 00:00:00 2001 From: Greg V Date: Sat, 9 May 2026 14:42:26 -0700 Subject: [PATCH 14/14] [Planning] Slack digest: join channel + surface failures + actual snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs in the manual 'Send digest now' button — combined effect was that clicking returned 200 but no Slack message ever arrived: 1. send_slack (in common/utils/slack.py) silently swallows SlackApiError and returns nothing. Most importantly, when the bot isn't a member of the target channel Slack returns not_in_channel — the route looked like success but the message was dropped. 2. send_manual_digest called _collect_and_clear_events (which CLEARS the pending queue) and then _do_flush (which collects again from an empty queue and returns 0). Even with Slack working, the manual digest path was a no-op on a board with no pending changes. 3. The route handler returned 200 unconditionally — no failure ever bubbled up to the admin UI, so debugging was opaque. Fixes: - New _post_to_channel() helper joins via add_bot_to_channel() before posting and surfaces the underlying SlackApiError as (ok, error_string). Specifically translates not_in_channel to a friendly hint. - _do_flush uses the new helper. - send_manual_digest rewritten per the design plan: "post the current state, one line per non-empty list with card counts" — returns (ok, message) so the route can surface success/error. - /slack/notify returns 502 with the error message when Slack rejects the post (vs. 500 for unexpected exceptions). Co-Authored-By: Claude Opus 4.7 --- api/planning/planning_views.py | 8 +- services/planning_slack_notifier.py | 111 +++++++++++++++++++++++++--- 2 files changed, 108 insertions(+), 11 deletions(-) diff --git a/api/planning/planning_views.py b/api/planning/planning_views.py index 5304d8c..284bd16 100644 --- a/api/planning/planning_views.py +++ b/api/planning/planning_views.py @@ -1001,8 +1001,12 @@ def editing_heartbeat(event_id, card_id): def slack_notify(event_id): try: from services.planning_slack_notifier import send_manual_digest - send_manual_digest(g.hackathon) - return jsonify({"message": "Digest sent"}), 200 + 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 diff --git a/services/planning_slack_notifier.py b/services/planning_slack_notifier.py index 7b1d4b6..5c612e7 100644 --- a/services/planning_slack_notifier.py +++ b/services/planning_slack_notifier.py @@ -147,6 +147,55 @@ def format_planning_digest(events: list, hackathon_doc: dict) -> str: 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) @@ -162,12 +211,11 @@ def _do_flush(hackathon_doc: dict) -> int: return 0 message = format_planning_digest(events, hackathon_doc) - try: - from common.utils.slack import send_slack - send_slack(message, channel=channel) + ok, err = _post_to_channel(channel, message) + if ok: logger.info("Planning digest sent to #%s (%d events)", channel, len(events)) - except Exception: - logger.exception("Failed to send planning Slack digest to #%s", channel) + else: + logger.error("Planning digest send failed for #%s: %s", channel, err) _clear_deadline(hackathon_doc) return len(events) @@ -202,10 +250,55 @@ def lazy_flush_if_due(hackathon_doc: dict) -> None: flush_digests_if_due(hackathon_doc) -def send_manual_digest(hackathon_doc: dict) -> None: - """Admin 'Send digest now' — flushes the current board state immediately.""" - events = _collect_and_clear_events(hackathon_doc) - _do_flush(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: