diff --git a/CLAUDE.md b/CLAUDE.md index 2e86f74..f46569a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -178,6 +178,13 @@ Config store for the Slack praise-bot (repo `ohack-slack-bot/praise-bot`): the b - `GET /api/praise-bot/config` — bot-facing, authed via `X-Api-Key` against `BACKEND_BOT_CONFIG_TOKEN` (falls back to `BACKEND_PRAISE_TOKEN`) through the shared `common/utils/api_key.py:check_api_key` (hmac.compare_digest; new code should use this instead of the inline checks in messages_views). Returns `configured: false` when the collection is empty → bot uses its env defaults. - `GET/POST /api/praise-bot/admin/config`, `PATCH/DELETE /api/praise-bot/admin/config/` — `volunteer.admin`-gated. Validation is whitelist-per-type (`_ALLOWED_KEYS` — the enforcement point that keeps secrets out of docs); crons validated as 5 fields (bot re-validates with `cron.validate()`); repos normalized to `owner/repo`; `global` is upsert-only (no DELETE); `community` is a singleton (POST 400s if one exists). `source.orgs` is accepted/stored but ignored by the bot until org-watching ships. 15s TTL cache on the assembled config, cleared on mutation. Tests: `api/praisebot/tests/` (mockfirestore, run with `ENVIRONMENT=test`). +## Volunteer job board (`api/jobs/`, `job_listings` + `job_applications` collections, Aug 2026) +Powers the frontend's `/jobs` pages and `/admin/jobs`. Blueprint `api/jobs/jobs_views.py` + `jobs_service.py`. +- `job_listings` doc id = **slug** (immutable after create; POST 409s duplicates). Statuses draft|published|hidden|closed — public list returns published+closed (lean fields), single-get 404s draft/hidden but returns closed (shared links render a closed panel). `posted_at` auto-stamped on first publish. 300s TTL caches (`get_public_listings`/`get_public_listing`) cleared on every admin write. Validators + `ALLOWED_JOB_*`/`JOB_LISTING_ADMIN_KEYS` constants live in `common/utils/validators.py`. +- `POST /api/jobs//apply` is `@auth.require_user` + `@RateLimiter` + recaptcha (imports volunteers_service `verify_recaptcha`, keeps the `FLASK_ENV=development` bypass): validates via `validate_job_application` (visa_ack must be True, work sample ≥ 200 chars — keep in sync with the frontend's `MIN_WORK_SAMPLE_CHARS`), verifies `resume_url` is under the caller's own `job_applications//` CDN prefix and `video_url` is own-CDN (`users//`, the bio-video mint) or an `ALLOWED_VIDEO_LINK_HOSTS` link, then 409s if the user already applied to that listing. Resume mint `POST /api/jobs/apply/resume-upload-url` reuses `common/utils/cdn.generate_signed_upload_url` (PDF only, 10MB, resolves the user via users_service `_resolve_and_ensure_user`). +- Emails (Resend, all behind the local `_notifications_disabled()` mirror): applicant confirmation with the **reply-within-5-days responsiveness ask** (`reply_to: questions@ohack.org`), FYI to questions@ohack.org, and warm accept/reject decision emails via `POST /api/jobs/admin/applications//decision` (`{decision, personal_note?}`; records into `sent_emails` ArrayUnion + `status_history`). Admin routes are `volunteer.admin`-gated; application PATCH allowlist is `status`/`admin_notes` only. +- Seed: `scripts/seed_job_listings.py` (dry-run default, `--apply` writes the three Fall 2026 roles as drafts, **skips existing slugs** so admin edits survive re-runs; validates against `validate_job_listing` so seed/validator drift fails loudly). + ## Public portfolio (profile → portfolio, Aug 2026) The public profile payload (`GET /api/users//profile/public`) is now the "portfolio" payload. Load-bearing contracts: diff --git a/api/__init__.py b/api/__init__.py index 8e75bd8..1652e7f 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -190,6 +190,7 @@ def add_headers(response): from api.surveys import surveys_views from api.feedback import feedback_views from api.praisebot import praisebot_views + from api.jobs import jobs_views app.register_blueprint(messages_views.bp) app.register_blueprint(exception_views.bp) @@ -215,5 +216,6 @@ def add_headers(response): app.register_blueprint(surveys_views.bp) app.register_blueprint(feedback_views.bp) app.register_blueprint(praisebot_views.bp) + app.register_blueprint(jobs_views.bp) return app diff --git a/api/jobs/__init__.py b/api/jobs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/jobs/jobs_service.py b/api/jobs/jobs_service.py new file mode 100644 index 0000000..6262362 --- /dev/null +++ b/api/jobs/jobs_service.py @@ -0,0 +1,704 @@ +"""Volunteer job board: public listings + applications. + +Collections: + job_listings — doc id = slug (immutable after create). Admin CRUD via + /admin/jobs on the frontend; public pages at /jobs. + job_applications — doc id = uuid4. Login-required applications with a work + sample, resume (CDN PDF), and required intro video. + +Applications trigger a confirmation email to the applicant (with a built-in +"reply within 5 days" responsiveness test) and an FYI email to +questions@ohack.org. Admin decisions send a warm templated email. +""" + +import os +import threading +import uuid +from datetime import datetime +from typing import Any, Dict, Optional +from urllib.parse import urlparse + +import pytz +import resend +from cachetools import cached, TTLCache +from ratelimiter import RateLimiter + +from db.db import get_db +from google.cloud import firestore +from common.log import get_logger +from common.utils.slack import send_slack_audit +from common.utils.validators import ( + ALLOWED_JOB_APPLICATION_STATUSES, + sanitize_string, + validate_job_application, + validate_job_listing, + validate_job_listing_partial, +) +from services.volunteers_service import verify_recaptcha + +logger = get_logger("services.jobs_service") + +LISTINGS_COLLECTION = "job_listings" +APPLICATIONS_COLLECTION = "job_applications" + +ALLOWED_RESUME_CONTENT_TYPES = {"application/pdf": "pdf"} +MAX_RESUME_BYTES = 10 * 1024 * 1024 # 10MB + +JOBS_FYI_EMAIL = "questions@ohack.org" +ADMIN_APPLICATIONS_URL = "https://www.ohack.dev/admin/jobs?tab=applications" + +PUBLIC_LISTING_LIST_FIELDS = ( + "slug", "title", "status", "location_type", "location_label", + "hours_per_week_label", "min_hours_per_week", "duration_ask", + "summary", "posted_at", "valid_through", +) +PUBLIC_LISTING_DETAIL_FIELDS = PUBLIC_LISTING_LIST_FIELDS + ( + "description_markdown", "work_sample_prompt", "video_prompts", +) + +APPLICATION_ADMIN_PATCH_KEYS = ("status", "admin_notes") + + +def _notifications_disabled() -> bool: + """Mirror of volunteers_service._notifications_disabled — suppress real + Resend/Slack sends when unit tests run against MockFirestore.""" + return os.environ.get("ENVIRONMENT") == "test" + + +def _now_iso() -> str: + az_timezone = pytz.timezone("US/Arizona") + return datetime.now(az_timezone).isoformat() + + +def _cdn_server() -> str: + return os.getenv("CDN_SERVER", "https://cdn.ohack.dev").rstrip("/") + + +def _project(doc: Dict[str, Any], fields) -> Dict[str, Any]: + return {k: doc.get(k) for k in fields} + + +# --------------------------------------------------------------------------- +# Public listings +# --------------------------------------------------------------------------- + +@cached(cache=TTLCache(maxsize=1, ttl=300), lock=threading.Lock()) +def get_public_listings(): + """Published + closed listings, newest first. Draft/hidden never leak.""" + db = get_db() + results = [] + for doc in db.collection(LISTINGS_COLLECTION).stream(): + doc_dict = doc.to_dict() or {} + doc_dict["slug"] = doc.id + if doc_dict.get("status") in ("published", "closed"): + results.append(_project(doc_dict, PUBLIC_LISTING_LIST_FIELDS)) + results.sort(key=lambda item: item.get("posted_at") or "", reverse=True) + return results + + +@cached(cache=TTLCache(maxsize=50, ttl=300), lock=threading.Lock()) +def get_public_listing(slug: str) -> Optional[Dict[str, Any]]: + """Full public doc for one listing; None for draft/hidden/unknown. + Closed listings still resolve so shared links render a calm closed state.""" + db = get_db() + snap = db.collection(LISTINGS_COLLECTION).document(slug).get() + if snap is None or not snap.exists: + return None + doc_dict = snap.to_dict() or {} + doc_dict["slug"] = snap.id + if doc_dict.get("status") not in ("published", "closed"): + return None + return _project(doc_dict, PUBLIC_LISTING_DETAIL_FIELDS) + + +def _clear_listing_caches(): + get_public_listings.cache_clear() + get_public_listing.cache_clear() + + +# --------------------------------------------------------------------------- +# Admin: listings CRUD +# --------------------------------------------------------------------------- + +def admin_list_listings(): + db = get_db() + results = [] + for doc in db.collection(LISTINGS_COLLECTION).stream(): + doc_dict = doc.to_dict() or {} + doc_dict["slug"] = doc.id + results.append(doc_dict) + results.sort(key=lambda item: item.get("updated_at") or "", reverse=True) + return {"success": True, "listings": results}, 200 + + +def admin_create_listing(json_in: Optional[Dict[str, Any]], actor: Optional[Dict[str, Any]]): + data = json_in or {} + try: + validate_job_listing(data) + except ValueError as e: + return {"success": False, "error": str(e)}, 400 + + slug = data["slug"] + db = get_db() + doc_ref = db.collection(LISTINGS_COLLECTION).document(slug) + if doc_ref.get().exists: + return {"success": False, "error": f"A listing with slug '{slug}' already exists"}, 409 + + now = _now_iso() + doc = { + "title": data["title"], + "status": data.get("status", "draft"), + "location_type": data.get("location_type", "remote"), + "location_label": data.get("location_label", ""), + "hours_per_week_label": data.get("hours_per_week_label", ""), + "min_hours_per_week": data.get("min_hours_per_week", 0), + "duration_ask": data.get("duration_ask", ""), + "summary": data.get("summary", ""), + "description_markdown": data.get("description_markdown", ""), + "work_sample_prompt": data.get("work_sample_prompt", ""), + "video_prompts": data.get("video_prompts", []), + "valid_through": data.get("valid_through", ""), + "posted_at": now if data.get("status") == "published" else "", + "created_at": now, + "updated_at": now, + "created_by": actor, + "last_updated_by": actor, + } + doc_ref.set(doc) + _clear_listing_caches() + send_slack_audit(action="job_listing_create", message=f"Job listing '{slug}' created") + doc["slug"] = slug + return {"success": True, "listing": doc}, 201 + + +def admin_update_listing(slug: str, json_in: Optional[Dict[str, Any]], actor: Optional[Dict[str, Any]]): + db = get_db() + doc_ref = db.collection(LISTINGS_COLLECTION).document(slug) + snap = doc_ref.get() + if not snap.exists: + return {"success": False, "error": "Listing not found"}, 404 + existing = snap.to_dict() or {} + + cleaned, skipped = validate_job_listing_partial(json_in) + if not cleaned: + return {"success": False, "error": "No editable fields in payload", "skipped": skipped}, 400 + + if cleaned.get("status") == "published" and not existing.get("posted_at"): + cleaned["posted_at"] = _now_iso() + cleaned["updated_at"] = _now_iso() + cleaned["last_updated_by"] = actor + + doc_ref.update(cleaned) + _clear_listing_caches() + send_slack_audit(action="job_listing_update", message=f"Job listing '{slug}' updated: {sorted(cleaned)}") + return {"success": True, "skipped": skipped}, 200 + + +def admin_delete_listing(slug: str): + db = get_db() + doc_ref = db.collection(LISTINGS_COLLECTION).document(slug) + if not doc_ref.get().exists: + return {"success": False, "error": "Listing not found"}, 404 + doc_ref.delete() + _clear_listing_caches() + send_slack_audit(action="job_listing_delete", message=f"Job listing '{slug}' deleted") + return {"success": True}, 200 + + +# --------------------------------------------------------------------------- +# Applicant: resume upload + submit +# --------------------------------------------------------------------------- + +def create_resume_upload_url(propel_id: str, content_type: Optional[str], content_length): + """Mint a signed GCS PUT URL for a resume PDF. Returns (payload, status).""" + if content_type not in ALLOWED_RESUME_CONTENT_TYPES: + return {"error": "Resumes must be PDF files"}, 400 + try: + content_length = int(content_length) + except (TypeError, ValueError): + return {"error": "content_length is required"}, 400 + if content_length <= 0 or content_length > MAX_RESUME_BYTES: + return {"error": f"Resume must be under {MAX_RESUME_BYTES // (1024 * 1024)}MB"}, 400 + + from services.users_service import _resolve_and_ensure_user + user, _user_id = _resolve_and_ensure_user(propel_id) + if user is None or not getattr(user, "id", None): + return {"error": "Could not resolve your account"}, 404 + + from common.utils.cdn import generate_signed_upload_url + ext = ALLOWED_RESUME_CONTENT_TYPES[content_type] + filename = f"resume_{uuid.uuid4().hex}.{ext}" + try: + payload = generate_signed_upload_url( + directory=f"job_applications/{user.id}", + filename=filename, + content_type=content_type, + max_bytes=MAX_RESUME_BYTES, + ) + except Exception as e: + logger.exception(f"Failed to generate signed resume upload URL: {e}") + return {"error": "Could not create an upload URL"}, 500 + return payload, 200 + + +def _verify_resume_url(url: str, db_id: str) -> Optional[str]: + """Returns an error string when the resume URL isn't a verified own upload.""" + cdn_prefix = f"{_cdn_server()}/job_applications/{db_id}/" + if not url.startswith(cdn_prefix): + return "resume_url must be a resume uploaded through this form" + blob_path = url[len(_cdn_server()) + 1:] + try: + from common.utils.cdn import get_blob_metadata + meta = get_blob_metadata(blob_path) + except Exception as e: + logger.exception(f"Failed to verify uploaded resume: {e}") + return "Could not verify the uploaded resume" + if not meta.get("exists"): + return "Resume upload not found — did the upload finish?" + if meta.get("content_type") not in ALLOWED_RESUME_CONTENT_TYPES: + return "Uploaded resume is not a PDF" + if (meta.get("size") or 0) > MAX_RESUME_BYTES: + return "Uploaded resume exceeds the size limit" + return None + + +def _verify_video_url(url: str, db_id: str) -> Optional[str]: + """Returns an error string unless the video is an own-CDN upload or an + allowlisted provider link (mirrors the bio-video rules).""" + from services.users_service import ALLOWED_VIDEO_CONTENT_TYPES, ALLOWED_VIDEO_LINK_HOSTS + + cdn_prefix = f"{_cdn_server()}/users/{db_id}/" + if url.startswith(cdn_prefix): + blob_path = url[len(_cdn_server()) + 1:] + try: + from common.utils.cdn import get_blob_metadata + meta = get_blob_metadata(blob_path) + except Exception as e: + logger.exception(f"Failed to verify uploaded video: {e}") + return "Could not verify the uploaded video" + if not meta.get("exists"): + return "Video upload not found — did the upload finish?" + if meta.get("content_type") not in ALLOWED_VIDEO_CONTENT_TYPES: + return "Uploaded file is not an allowed video type" + return None + + host = (urlparse(url).netloc or "").lower().split(":")[0] + if host not in ALLOWED_VIDEO_LINK_HOSTS: + return "Video links must be YouTube, Vimeo, or Loom (or an upload)" + return None + + +def _find_application(db, slug: str, propel_id: str): + docs = db.collection(APPLICATIONS_COLLECTION) \ + .where("listing_slug", "==", slug) \ + .where("user_id", "==", propel_id) \ + .limit(1).stream() + for doc in docs: + return doc + return None + + +@RateLimiter(max_calls=10, period=60) +def submit_application(propel_id: str, ip_address: Optional[str], slug: str, + data: Optional[Dict[str, Any]]): + """Create a job application. Returns (payload, status).""" + data = data or {} + + listing = get_public_listing(slug) + if listing is None: + return {"success": False, "error": "Listing not found"}, 404 + if listing.get("status") != "published": + return {"success": False, "error": "This role is no longer accepting applications"}, 409 + + recaptcha_token = data.get("recaptchaToken") + if not verify_recaptcha(recaptcha_token) and os.environ.get("FLASK_ENV") != "development": + return {"success": False, "error": "reCAPTCHA verification failed"}, 400 + + try: + validate_job_application(data) + except ValueError as e: + return {"success": False, "error": str(e)}, 400 + + from services.users_service import _resolve_and_ensure_user + user, _user_id = _resolve_and_ensure_user(propel_id) + if user is None or not getattr(user, "id", None): + return {"success": False, "error": "Could not resolve your account"}, 404 + + resume_error = _verify_resume_url(data["resume_url"], user.id) + if resume_error: + return {"success": False, "error": resume_error}, 400 + video_error = _verify_video_url(data["video_url"], user.id) + if video_error: + return {"success": False, "error": video_error}, 400 + + db = get_db() + if _find_application(db, slug, propel_id) is not None: + return {"success": False, + "error": "You've already applied for this role — check your email for next steps"}, 409 + + now = _now_iso() + application_id = str(uuid.uuid4()) + application = { + "listing_slug": slug, + "listing_title": listing.get("title", ""), + "user_id": propel_id, + "db_id": user.id, + "name": sanitize_string(data.get("name"), 200), + "email": sanitize_string(data.get("email"), 200), + "pronouns": sanitize_string(data.get("pronouns") or "", 100), + "phone": sanitize_string(data.get("phone") or "", 50), + "location": sanitize_string(data.get("location") or "", 200), + "linkedin_url": sanitize_string(data.get("linkedin_url"), 400), + "resume_url": data["resume_url"], + "video_url": data["video_url"], + "hours_per_week": sanitize_string(data.get("hours_per_week"), 50), + "duration_commitment": sanitize_string(data.get("duration_commitment"), 100), + "preferred_channel": sanitize_string(data.get("preferred_channel") or "", 50), + "slack_member": sanitize_string(data.get("slack_member") or "", 50), + "in_person_ok": bool(data.get("in_person_ok", False)), + "visa_ack": True, + "work_sample_answer": data["work_sample_answer"].strip(), + "why_ohack": sanitize_string(data.get("why_ohack") or "", 2000), + "referral_source": sanitize_string(data.get("referral_source") or "", 200), + "status": "submitted", + "status_history": [{"status": "submitted", "at": now, "by": "applicant"}], + "admin_notes": "", + "sent_emails": [], + "timestamp": now, + "ip_address": ip_address or "", + } + db.collection(APPLICATIONS_COLLECTION).document(application_id).set(application) + logger.info(f"Job application {application_id} created for '{slug}'") + + try: + _send_applicant_confirmation_email(application_id, application, listing) + except Exception as e: + logger.exception(f"Job application confirmation email failed: {e}") + try: + _send_admin_fyi_email(application_id, application) + except Exception as e: + logger.exception(f"Job application FYI email failed: {e}") + + send_slack_audit(action="job_application", + message=f"New application for '{listing.get('title', slug)}' from {application['email']}") + return {"success": True, "application_id": application_id}, 201 + + +def get_my_application(propel_id: str, slug: str): + db = get_db() + doc = _find_application(db, slug, propel_id) + if doc is None: + return {"applied": False}, 200 + doc_dict = doc.to_dict() or {} + return {"applied": True, + "status": doc_dict.get("status", "submitted"), + "timestamp": doc_dict.get("timestamp", "")}, 200 + + +# --------------------------------------------------------------------------- +# Admin: applications +# --------------------------------------------------------------------------- + +def admin_list_applications(listing_slug: Optional[str] = None): + db = get_db() + query = db.collection(APPLICATIONS_COLLECTION) + if listing_slug: + query = query.where("listing_slug", "==", listing_slug) + results = [] + for doc in query.stream(): + doc_dict = doc.to_dict() or {} + doc_dict["id"] = doc.id + results.append(doc_dict) + results.sort(key=lambda item: item.get("timestamp") or "", reverse=True) + return {"success": True, "applications": results}, 200 + + +def admin_update_application(application_id: str, json_in: Optional[Dict[str, Any]], + actor: Optional[Dict[str, Any]]): + db = get_db() + doc_ref = db.collection(APPLICATIONS_COLLECTION).document(application_id) + snap = doc_ref.get() + if not snap.exists: + return {"success": False, "error": "Application not found"}, 404 + existing = snap.to_dict() or {} + + patch = {k: v for k, v in (json_in or {}).items() if k in APPLICATION_ADMIN_PATCH_KEYS} + if not patch: + return {"success": False, "error": "No editable fields in payload"}, 400 + + new_status = patch.get("status") + if new_status is not None: + if new_status not in ALLOWED_JOB_APPLICATION_STATUSES: + return {"success": False, + "error": f"status must be one of {list(ALLOWED_JOB_APPLICATION_STATUSES)}"}, 400 + if new_status != existing.get("status"): + actor_email = (actor or {}).get("email") or "admin" + patch["status_history"] = firestore.ArrayUnion( + [{"status": new_status, "at": _now_iso(), "by": actor_email}] + ) + + if "admin_notes" in patch and not isinstance(patch["admin_notes"], str): + return {"success": False, "error": "admin_notes must be a string"}, 400 + + doc_ref.update(patch) + send_slack_audit(action="job_application_update", + message=f"Application {application_id} updated: {sorted(patch)}") + return {"success": True}, 200 + + +def admin_decide_application(application_id: str, json_in: Optional[Dict[str, Any]], + actor: Optional[Dict[str, Any]]): + """Accept or (kindly) reject an application and send the matching email.""" + data = json_in or {} + decision = data.get("decision") + if decision not in ("accepted", "rejected"): + return {"success": False, "error": "decision must be 'accepted' or 'rejected'"}, 400 + personal_note = sanitize_string(data.get("personal_note") or "", 2000) + + db = get_db() + doc_ref = db.collection(APPLICATIONS_COLLECTION).document(application_id) + snap = doc_ref.get() + if not snap.exists: + return {"success": False, "error": "Application not found"}, 404 + application = snap.to_dict() or {} + + resend_id = None + try: + resend_id = _send_decision_email(application, decision, personal_note) + except Exception as e: + logger.exception(f"Decision email failed for application {application_id}: {e}") + + actor_email = (actor or {}).get("email") or "admin" + patch = { + "status": decision, + "status_history": firestore.ArrayUnion( + [{"status": decision, "at": _now_iso(), "by": actor_email}] + ), + } + if resend_id: + patch["sent_emails"] = firestore.ArrayUnion([{ + "resend_id": resend_id, + "subject": _decision_subject(application, decision), + "timestamp": _now_iso(), + "sent_by": actor_email, + "recipient_type": f"decision_{decision}", + }]) + doc_ref.update(patch) + + send_slack_audit(action="job_application_decision", + message=f"Application {application_id} ({application.get('email', '?')}) marked {decision}") + return {"success": True, "email_sent": bool(resend_id)}, 200 + + +# --------------------------------------------------------------------------- +# Emails (Resend; house style from volunteers_service) +# --------------------------------------------------------------------------- + +_EMAIL_SHELL_TOP = """ +
+
+ Opportunity Hack Logo +
+""" + +_EMAIL_SHELL_BOTTOM = """ +
+

The Opportunity Hack Team

+

Website: ohack.dev

+
+
+""" + + +def _resend_ready() -> bool: + if _notifications_disabled(): + logger.info("ENVIRONMENT=test — skipping job email send") + return False + resend_api_key = os.environ.get("RESEND_WELCOME_EMAIL_KEY") + if not resend_api_key: + logger.error("Missing required environment variable RESEND_WELCOME_EMAIL_KEY") + return False + resend.api_key = resend_api_key + return True + + +def _send_and_get_id(params) -> Optional[str]: + email_result = resend.Emails.send(params) + if isinstance(email_result, dict): + return email_result.get("id") + return getattr(email_result, "id", None) + + +def _send_applicant_confirmation_email(application_id: str, application: Dict[str, Any], + listing: Dict[str, Any]) -> Optional[str]: + if not _resend_ready(): + return None + + name = application.get("name") or "there" + title = listing.get("title", "volunteer role") + subject = f"[Application received] {title} — Opportunity Hack" + html = f"""{_EMAIL_SHELL_TOP} +

We got your application!

+

Hi {name},

+

Thanks for applying to be our {title}. We know this application + took real effort — the work sample and video are how we find people who care, and we appreciate you + putting in the time.

+
+

✉️ One more step — reply to confirm

+

+ Reply to this email within 5 days (or join our + Slack and DM us) to confirm your + application is active. This role runs on fast, clear communication — consider this the first task. +

+
+

What happens next: we review every application by hand + (typically within a week), then reach out from questions@ohack.org to set up a short call.

+
+

A reminder: this is a volunteer role with a + nonprofit — it is unpaid, and we are unable to sponsor visas. What you get is real, portfolio-worthy + experience, Hearts toward certificates, and LinkedIn recommendations & references from work + that actually shipped.

+
+{_EMAIL_SHELL_BOTTOM}""" + + params = { + "from": "Opportunity Hack ", + "to": [application["email"]], + "reply_to": JOBS_FYI_EMAIL, + "subject": subject, + "html": html, + } + resend_id = _send_and_get_id(params) + logger.info(f"Sent job application confirmation to {application['email']} (resend_id={resend_id})") + if resend_id: + try: + get_db().collection(APPLICATIONS_COLLECTION).document(application_id).update({ + "sent_emails": firestore.ArrayUnion([{ + "resend_id": resend_id, + "subject": subject, + "timestamp": _now_iso(), + "sent_by": "system", + "recipient_type": "application_confirmation", + }]) + }) + except Exception as e: + logger.warning(f"Failed to track confirmation email for {application_id}: {e}") + return resend_id + + +def _send_admin_fyi_email(application_id: str, application: Dict[str, Any]) -> bool: + if not _resend_ready(): + return False + + def _row(label, value): + return (f'{label}' + f'{value}') + + def _link(url, text=None): + return f'{text or url}' if url else "—" + + rows = "".join([ + _row("Name", application.get("name", "—")), + _row("Email", application.get("email", "—")), + _row("Pronouns", application.get("pronouns") or "—"), + _row("Phone", application.get("phone") or "—"), + _row("Location", application.get("location") or "—"), + _row("LinkedIn", _link(application.get("linkedin_url"))), + _row("Resume", _link(application.get("resume_url"), "View resume (PDF)")), + _row("Video", _link(application.get("video_url"), "Watch intro video")), + _row("Hours/week", application.get("hours_per_week", "—")), + _row("Duration", application.get("duration_commitment", "—")), + _row("Preferred channel", application.get("preferred_channel") or "—"), + _row("In Slack already", application.get("slack_member") or "—"), + _row("Heard about us via", application.get("referral_source") or "—"), + ]) + + work_sample = (application.get("work_sample_answer") or "").replace("\n", "
") + why = (application.get("why_ohack") or "").replace("\n", "
") + why_block = (f'

Why Opportunity Hack:

' + f'
{why}
' + if why else "") + + params = { + "from": "Opportunity Hack ", + "to": [JOBS_FYI_EMAIL], + "subject": f"New volunteer application: {application.get('listing_title', '?')} — {application.get('name', '?')}", + "html": f""" +
+

New application: {application.get('listing_title', '?')}

+ {rows}
+

Work sample answer:

+
{work_sample}
+ {why_block} +

Review in the admin panel

+
+ """, + } + _send_and_get_id(params) + logger.info(f"Sent job application FYI to {JOBS_FYI_EMAIL} for {application_id}") + return True + + +def _decision_subject(application: Dict[str, Any], decision: str) -> str: + title = application.get("listing_title", "volunteer role") + if decision == "accepted": + return f"Let's talk! Your Opportunity Hack application — {title}" + return f"Your Opportunity Hack application — {title}" + + +def _send_decision_email(application: Dict[str, Any], decision: str, + personal_note: str = "") -> Optional[str]: + if not _resend_ready(): + return None + + name = application.get("name") or "there" + title = application.get("listing_title", "volunteer role") + note_block = "" + if personal_note: + note_block = f""" +
+

{personal_note}

+
""" + + if decision == "accepted": + body = f""" +

We'd love to talk!

+

Hi {name},

+

Great news — we'd like to move forward with your application for + {title}. Your work sample and video stood out.

+ {note_block} +

Expect an email from questions@ohack.org shortly to set up a + short call. In the meantime, if you haven't joined our + Slack yet, now is a great time.

""" + else: + body = f""" +

Thank you — truly

+

Hi {name},

+

Thank you for applying for {title}. We know this application + took real time and thought, and we don't take that lightly.

+

After careful review, we've decided to go in a different direction for this + role right now. That's a statement about fit for one specific role — not about you or your abilities.

+ {note_block} +
+

The door is very much open

+

We're a volunteer-run nonprofit and there are many other + ways to make a real impact (and build your portfolio) with us:

+

+ • Mentor, judge, or volunteer at our next hackathon
+ • Contribute to a year-round nonprofit project
+ • Join our Slack community +

+
+

We'd genuinely love to see you apply again for a future role. Thank you for + wanting to use your skills for social good.

""" + + params = { + "from": "Opportunity Hack ", + "to": [application["email"]], + "reply_to": JOBS_FYI_EMAIL, + "subject": _decision_subject(application, decision), + "html": f"{_EMAIL_SHELL_TOP}{body}{_EMAIL_SHELL_BOTTOM}", + } + resend_id = _send_and_get_id(params) + logger.info(f"Sent {decision} email to {application['email']} (resend_id={resend_id})") + return resend_id diff --git a/api/jobs/jobs_views.py b/api/jobs/jobs_views.py new file mode 100644 index 0000000..77b8bd0 --- /dev/null +++ b/api/jobs/jobs_views.py @@ -0,0 +1,157 @@ +"""Volunteer job board routes. + +Public: listing pages at /jobs on the frontend (Google-for-Jobs SEO). +Applicant routes require login (matches the mentor/judge application forms) — +the resume/video signed-upload flow depends on a resolved user doc. +Admin CRUD is volunteer.admin-gated, consumed by /admin/jobs. +""" + +from flask import Blueprint, request + +from common.log import get_logger +from common.auth import auth, auth_user, getOrgId +from api.jobs.jobs_service import ( + get_public_listings, + get_public_listing, + admin_list_listings, + admin_create_listing, + admin_update_listing, + admin_delete_listing, + create_resume_upload_url, + submit_application, + get_my_application, + admin_list_applications, + admin_update_application, + admin_decide_application, +) + +logger = get_logger(__name__) + +bp = Blueprint("jobs", __name__, url_prefix="/api") + + +def _actor_from_request(): + try: + return { + "propel_user_id": auth_user.user_id if auth_user else None, + "email": getattr(auth_user, "email", None) if auth_user else None, + } + except Exception: + return None + + +# --------------------------------------------------------------------------- +# Public +# --------------------------------------------------------------------------- + +@bp.route("/jobs", methods=["GET"]) +def list_jobs(): + return {"listings": get_public_listings()} + + +@bp.route("/jobs/", methods=["GET"]) +def get_job(slug): + listing = get_public_listing(slug) + if listing is None: + return {"error": "Listing not found"}, 404 + return listing + + +# --------------------------------------------------------------------------- +# Applicant (login required) +# --------------------------------------------------------------------------- + +@bp.route("/jobs/apply/resume-upload-url", methods=["POST"]) +@auth.require_user +def resume_upload_url(): + if not (auth_user and auth_user.user_id): + return {"error": "Unauthorized"}, 401 + data = request.get_json() or {} + payload, status = create_resume_upload_url( + auth_user.user_id, data.get("content_type"), data.get("content_length")) + return payload, status + + +@bp.route("/jobs//apply", methods=["POST"]) +@auth.require_user +def apply_for_job(slug): + if not (auth_user and auth_user.user_id): + return {"error": "Unauthorized"}, 401 + logger.info(f"POST /jobs/{slug}/apply called") + payload, status = submit_application( + auth_user.user_id, request.remote_addr, slug, request.get_json()) + return payload, status + + +@bp.route("/jobs//applications/me", methods=["GET"]) +@auth.require_user +def my_application(slug): + if not (auth_user and auth_user.user_id): + return {"error": "Unauthorized"}, 401 + payload, status = get_my_application(auth_user.user_id, slug) + return payload, status + + +# --------------------------------------------------------------------------- +# Admin +# --------------------------------------------------------------------------- + +@bp.route("/jobs/admin/listings", methods=["GET"]) +@auth.require_user +@auth.require_org_member_with_permission("volunteer.admin", req_to_org_id=getOrgId) +def admin_get_listings(): + payload, status = admin_list_listings() + return payload, status + + +@bp.route("/jobs/admin/listings", methods=["POST"]) +@auth.require_user +@auth.require_org_member_with_permission("volunteer.admin", req_to_org_id=getOrgId) +def admin_post_listing(): + logger.info("POST /jobs/admin/listings called") + payload, status = admin_create_listing(request.get_json(), _actor_from_request()) + return payload, status + + +@bp.route("/jobs/admin/listings/", methods=["PATCH"]) +@auth.require_user +@auth.require_org_member_with_permission("volunteer.admin", req_to_org_id=getOrgId) +def admin_patch_listing(slug): + logger.info(f"PATCH /jobs/admin/listings/{slug} called") + payload, status = admin_update_listing(slug, request.get_json(), _actor_from_request()) + return payload, status + + +@bp.route("/jobs/admin/listings/", methods=["DELETE"]) +@auth.require_user +@auth.require_org_member_with_permission("volunteer.admin", req_to_org_id=getOrgId) +def admin_delete_listing_route(slug): + logger.info(f"DELETE /jobs/admin/listings/{slug} called") + payload, status = admin_delete_listing(slug) + return payload, status + + +@bp.route("/jobs/admin/applications", methods=["GET"]) +@auth.require_user +@auth.require_org_member_with_permission("volunteer.admin", req_to_org_id=getOrgId) +def admin_get_applications(): + payload, status = admin_list_applications(request.args.get("listing_slug")) + return payload, status + + +@bp.route("/jobs/admin/applications/", methods=["PATCH"]) +@auth.require_user +@auth.require_org_member_with_permission("volunteer.admin", req_to_org_id=getOrgId) +def admin_patch_application(application_id): + logger.info(f"PATCH /jobs/admin/applications/{application_id} called") + payload, status = admin_update_application(application_id, request.get_json(), _actor_from_request()) + return payload, status + + +@bp.route("/jobs/admin/applications//decision", methods=["POST"]) +@auth.require_user +@auth.require_org_member_with_permission("volunteer.admin", req_to_org_id=getOrgId) +def admin_post_decision(application_id): + logger.info(f"POST /jobs/admin/applications/{application_id}/decision called") + payload, status = admin_decide_application(application_id, request.get_json(), _actor_from_request()) + return payload, status diff --git a/common/utils/validators.py b/common/utils/validators.py index 6890db7..b03829b 100644 --- a/common/utils/validators.py +++ b/common/utils/validators.py @@ -534,6 +534,158 @@ def validate_planning_subobject(planning): raise ValueError("planning.budget_widget_on_event_page must be a boolean") +# --------------------------------------------------------------------------- +# Volunteer job board (job_listings / job_applications collections) +# --------------------------------------------------------------------------- + +ALLOWED_JOB_STATUSES = ("draft", "published", "hidden", "closed") +ALLOWED_JOB_LOCATION_TYPES = ("remote", "phoenix_in_person", "hybrid") +ALLOWED_JOB_APPLICATION_STATUSES = ( + "submitted", "confirmed", "call_scheduled", "accepted", "rejected", "withdrawn", +) +JOB_SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +MAX_JOB_TITLE_LENGTH = 120 +MAX_JOB_SUMMARY_LENGTH = 500 +MAX_JOB_DESCRIPTION_LENGTH = 20000 +MAX_JOB_PROMPT_LENGTH = 2000 +MAX_JOB_VIDEO_PROMPTS = 5 +MIN_JOB_WORK_SAMPLE_LENGTH = 200 +MAX_JOB_WORK_SAMPLE_LENGTH = 10000 +MAX_JOB_FREETEXT_LENGTH = 2000 + +# Fields an admin create/patch may set on a job_listings doc. slug is +# create-only (doc id); status transitions are allowed via patch. +JOB_LISTING_ADMIN_KEYS = ( + "title", "status", "location_type", "location_label", + "hours_per_week_label", "min_hours_per_week", "duration_ask", + "summary", "description_markdown", "work_sample_prompt", "video_prompts", + "valid_through", +) + + +def _validate_job_listing_field(field, value): + """Validate one job-listing field. Raises ValueError on a bad value.""" + if field == "title": + if not isinstance(value, str) or not value.strip(): + raise ValueError("title must be a non-empty string") + if len(value) > MAX_JOB_TITLE_LENGTH: + raise ValueError(f"title must be under {MAX_JOB_TITLE_LENGTH} characters") + elif field == "status": + if value not in ALLOWED_JOB_STATUSES: + raise ValueError(f"status must be one of {list(ALLOWED_JOB_STATUSES)}") + elif field == "location_type": + if value not in ALLOWED_JOB_LOCATION_TYPES: + raise ValueError(f"location_type must be one of {list(ALLOWED_JOB_LOCATION_TYPES)}") + elif field == "min_hours_per_week": + if not isinstance(value, int) or isinstance(value, bool) or value < 0 or value > 40: + raise ValueError("min_hours_per_week must be an integer between 0 and 40") + elif field in ("location_label", "hours_per_week_label", "duration_ask", "valid_through"): + if not isinstance(value, str): + raise ValueError(f"{field} must be a string") + if len(value) > MAX_JOB_TITLE_LENGTH: + raise ValueError(f"{field} must be under {MAX_JOB_TITLE_LENGTH} characters") + elif field == "summary": + if not isinstance(value, str): + raise ValueError("summary must be a string") + if len(value) > MAX_JOB_SUMMARY_LENGTH: + raise ValueError(f"summary must be under {MAX_JOB_SUMMARY_LENGTH} characters") + elif field == "description_markdown": + if not isinstance(value, str): + raise ValueError("description_markdown must be a string") + if len(value) > MAX_JOB_DESCRIPTION_LENGTH: + raise ValueError(f"description_markdown must be under {MAX_JOB_DESCRIPTION_LENGTH} characters") + elif field == "work_sample_prompt": + if not isinstance(value, str): + raise ValueError("work_sample_prompt must be a string") + if len(value) > MAX_JOB_PROMPT_LENGTH: + raise ValueError(f"work_sample_prompt must be under {MAX_JOB_PROMPT_LENGTH} characters") + elif field == "video_prompts": + if not isinstance(value, list): + raise ValueError("video_prompts must be a list of strings") + if len(value) > MAX_JOB_VIDEO_PROMPTS: + raise ValueError(f"video_prompts must have at most {MAX_JOB_VIDEO_PROMPTS} entries") + for i, prompt in enumerate(value): + if not isinstance(prompt, str) or not prompt.strip(): + raise ValueError(f"video_prompts[{i}] must be a non-empty string") + if len(prompt) > MAX_JOB_PROMPT_LENGTH: + raise ValueError(f"video_prompts[{i}] must be under {MAX_JOB_PROMPT_LENGTH} characters") + + +def validate_job_listing(data): + """Strict validation for creating a job listing. Raises ValueError.""" + if not isinstance(data, dict): + raise ValueError("Listing payload must be an object") + + for field in ("slug", "title", "status", "location_type", "summary", "description_markdown"): + if not data.get(field): + raise ValueError(f"Missing required field: {field}") + + slug = data["slug"] + if not isinstance(slug, str) or not JOB_SLUG_RE.match(slug) or len(slug) > 80: + raise ValueError("slug must be lowercase letters/digits separated by hyphens (max 80 chars)") + + for field in JOB_LISTING_ADMIN_KEYS: + if field in data and data[field] is not None: + _validate_job_listing_field(field, data[field]) + + +def validate_job_listing_partial(data): + """Lenient validation for admin partial saves. + + Returns (cleaned_data, skipped_fields); only keys in JOB_LISTING_ADMIN_KEYS + survive, and individually-invalid optional fields are skipped (not fatal). + """ + cleaned = {} + skipped = [] + for field, value in (data or {}).items(): + if field not in JOB_LISTING_ADMIN_KEYS: + skipped.append({"field": field, "reason": "not an editable listing field"}) + continue + if value is None: + cleaned[field] = value + continue + try: + _validate_job_listing_field(field, value) + cleaned[field] = value + except ValueError as e: + skipped.append({"field": field, "reason": str(e)}) + logger.warning("Job listing field '%s' failed validation and will not be saved: %s", field, e) + return cleaned, skipped + + +def validate_job_application(data): + """Strict validation of a job application submit. Raises ValueError.""" + if not isinstance(data, dict): + raise ValueError("Application payload must be an object") + + for field in ("name", "email", "linkedin_url", "hours_per_week", + "duration_commitment", "work_sample_answer", "video_url", "resume_url"): + if not data.get(field): + raise ValueError(f"Missing required field: {field}") + + if data.get("visa_ack") is not True: + raise ValueError("visa_ack must be accepted") + + if not validate_email(data["email"]): + raise ValueError("email is not a valid email address") + + for url_field in ("linkedin_url", "video_url", "resume_url"): + if not validate_url(data[url_field]): + raise ValueError(f"{url_field} is not a valid URL") + + work_sample = data["work_sample_answer"] + if not isinstance(work_sample, str) or len(work_sample.strip()) < MIN_JOB_WORK_SAMPLE_LENGTH: + raise ValueError(f"work_sample_answer must be at least {MIN_JOB_WORK_SAMPLE_LENGTH} characters") + if len(work_sample) > MAX_JOB_WORK_SAMPLE_LENGTH: + raise ValueError(f"work_sample_answer must be under {MAX_JOB_WORK_SAMPLE_LENGTH} characters") + + for field in ("pronouns", "phone", "location", "hours_per_week", "duration_commitment", + "preferred_channel", "referral_source", "why_ohack", "slack_member"): + value = data.get(field) + if value is not None and (not isinstance(value, str) or len(value) > MAX_JOB_FREETEXT_LENGTH): + raise ValueError(f"{field} must be a string under {MAX_JOB_FREETEXT_LENGTH} characters") + + if __name__ == "__main__": # Simple tests print(validate_email("test@example.com")) # Should print True diff --git a/scripts/seed_job_listings.py b/scripts/seed_job_listings.py new file mode 100644 index 0000000..208e3bd --- /dev/null +++ b/scripts/seed_job_listings.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +""" +Seed the volunteer job board (job_listings collection) with the three Fall 2026 +organizer roles: Social Media Manager, Hackathon Operations Lead (Phoenix), and +Mentor Program Lead. + +DRY-RUN BY DEFAULT. Pass --apply to actually write to Firestore. + +Idempotency +----------- +Safe to re-run: a listing whose slug already exists is SKIPPED (never +overwritten), so admin edits made via /admin/jobs are preserved. Listings seed +as status="draft" — publish them from the admin UI. + +Usage +----- + cd backend-ohack.dev + python scripts/seed_job_listings.py # dry run + python scripts/seed_job_listings.py --apply # write drafts +""" + +import argparse +import os +import sys +from datetime import datetime + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from dotenv import load_dotenv +load_dotenv() + +from db.db import get_db +from common.utils.validators import validate_job_listing + +SHARED_WHAT_YOU_GET = """## What you get + +This is a volunteer role, and it is not paid — we are a nonprofit with very limited funds. What we can offer is real: + +- **Real, portfolio-worthy experience** with actual users, constraints, and deadlines — not a side project that never ships +- **Hearts** toward certificates, plus **LinkedIn recommendations and references** from people who watched you deliver +- A leadership title you can put on your resume, backed by work anyone can verify +- The satisfaction of helping nonprofits get software they could never afford + +Everyone who runs Opportunity Hack has a full-time job. We volunteer because we believe tech can do good. Join us. + +## The fine print + +- We are unable to sponsor visas. This is an unpaid volunteer role. +- We run on Slack and email — fast, clear communication is the core skill for every role here. +- We'd love someone who sticks around past Fall 2026, but we'll take all the help we can get.""" + +LISTINGS = [ + { + "slug": "social-media-manager", + "title": "Social Media Manager", + "status": "draft", + "location_type": "remote", + "location_label": "Remote (US time zones preferred)", + "hours_per_week_label": "3–5", + "min_hours_per_week": 3, + "duration_ask": "6+ months preferred — through Fall 2026 and beyond", + "valid_through": "2026-10-31", + "summary": "Own Opportunity Hack's voice. Turn real nonprofit projects and hackathon stories into LinkedIn, Instagram, and Threads posts that bring in volunteers, sponsors, and nonprofits.", + "description_markdown": """## About Opportunity Hack + +We're a nonprofit that connects tech volunteers with nonprofits who need software. Our hackathons bring together developers, designers, and product folks to build real solutions in a weekend — and our projects keep shipping year-round. + +## The role + +Every weekend we generate stories most organizations would kill for: a team of strangers shipping a food-bank inventory system in 48 hours, a student getting their first job offer off hackathon portfolio work, a nonprofit director seeing their spreadsheet nightmare become an app. Almost none of it gets told. That's the job. + +## What you'll do + +- Own our posting cadence on LinkedIn, Instagram, and Threads (2–4 posts/week) +- Mine our blog, project pages, and Slack for stories worth telling — and tell them +- Run the social push before, during, and after the Fall 2026 hackathon (the during part is the fun part) +- Watch what works and double down — you'll have our analytics and full creative latitude +- Recruit: every post is ultimately about bringing in volunteers, nonprofits, and sponsors + +## Who this is for + +- Aspiring social media / content / comms folks who want a real brand to run, not a mock portfolio piece +- Experienced marketers who want to give back with skills they already have +- You write clearly, you ship consistently, and you don't need someone to hand you a content calendar + +""" + SHARED_WHAT_YOU_GET, + "work_sample_prompt": "Pick any project from [ohack.dev/projects](https://www.ohack.dev/projects) and write the LinkedIn post you'd publish about it. Real project, real details — we want to see how you find the story.", + "video_prompts": [ + "In under 2 minutes: who are you, why this role, and why Opportunity Hack?", + "Walk us through the post you wrote in the previous step — why that project, and what were you optimizing for?", + ], + }, + { + "slug": "hackathon-operations-lead", + "title": "Hackathon Operations Lead — Phoenix", + "status": "draft", + "location_type": "phoenix_in_person", + "location_label": "Phoenix / Tempe, AZ (on-site at ASU)", + "hours_per_week_label": "2–6", + "min_hours_per_week": 2, + "duration_ask": "Through the Fall 2026 event — ~2 hrs/week now, 4–6 hrs/week in the final six weeks, plus the full event weekend on-site", + "valid_through": "2026-10-31", + "summary": "Be the person who makes the Fall 2026 hackathon actually run: venue, food, check-in, schedule, and the hundred small saves nobody notices when they go right. On-site in Phoenix/Tempe.", + "description_markdown": """## About Opportunity Hack + +We're a nonprofit that connects tech volunteers with nonprofits who need software. Our flagship hackathon happens each fall at ASU in Tempe — 100+ hackers, a dozen nonprofits, one weekend. + +## The role + +A hackathon is a live event wearing a tech costume. Catering shows up late, the check-in line backs up, a room double-books, the awards ceremony needs to start in ten minutes and the demo laptop won't connect. The Operations Lead is the person who handles all of that so hackers and nonprofits only experience the good parts. + +## What you'll do + +- **Before** (~2 hrs/week, ramping to 4–6 in the final six weeks): help plan the venue layout, food schedule, volunteer shifts, signage, and run-of-show with the core team +- **During** (the full event weekend, on-site): run check-in, keep the schedule honest, direct day-of volunteers, and solve problems in real time +- **After**: a short retro so next year's event starts smarter +- Coordinate over Slack with the core organizing team — we're responsive and we'll have your back + +## Who this is for + +- Event, program, or project coordinators (aspiring or experienced) who want a serious line on their resume +- People who stay calm when the plan meets reality, and communicate clearly while it's happening +- **You must be in the Phoenix/Tempe area and available on-site the full event weekend** — this one can't be done remotely + +""" + SHARED_WHAT_YOU_GET, + "work_sample_prompt": "It's 9am on hackathon Saturday. Catering is 45 minutes late, 60 hungry hackers are asking questions, and the venue contact isn't answering. Walk us through your next 30 minutes — be specific.", + "video_prompts": [ + "In under 2 minutes: who are you, why this role, and what's the best event (any kind) you've helped run?", + "Explain the plan you wrote in the previous step — what's the first thing that could go wrong with it, and what would you do then?", + ], + }, + { + "slug": "mentor-program-lead", + "title": "Mentor Program Lead", + "status": "draft", + "location_type": "hybrid", + "location_label": "Remote-friendly (event weekend hybrid)", + "hours_per_week_label": "3–4", + "min_hours_per_week": 3, + "duration_ask": "Through the Fall 2026 event plus ~3 months — 6+ months welcome", + "valid_through": "2026-10-31", + "summary": "Recruit, prep, and lead the 20+ industry mentors who keep hackathon teams unblocked. You're the multiplier: great mentoring is the difference between demos and shipped software.", + "description_markdown": """## About Opportunity Hack + +We're a nonprofit that connects tech volunteers with nonprofits who need software. At our hackathons, mentors — engineers, designers, PMs from across the industry — are the difference between teams that flounder and teams that ship. + +## The role + +We usually have more teams needing help than mentors proactively giving it. The Mentor Program Lead owns that gap: recruiting good mentors, setting expectations before the event, and running the mentor bench during the weekend so no team sits blocked for hours. + +## What you'll do + +- Recruit mentors from your network, ours, and past events (we have the application system; you drive the pipeline) +- Review mentor applications and set expectations: at OHack, mentoring means proactive help — checking on teams, reviewing code, unblocking — not sitting in a room being available +- Run mentor onboarding before the event (a call + a Slack channel + our existing checklists and tools) +- During the weekend: run the mentor schedule, watch our team-coverage dashboard, and route mentors to teams that are stuck (remote-friendly; being on-site in Tempe is a plus, not a requirement) +- Afterwards: make sure great mentors get recognized (certificates, LinkedIn recommendations) so they come back + +## Who this is for + +- Engineering managers, senior ICs, PMs, or community builders who like making other people effective +- Aspiring leads who want real people-coordination experience with visible outcomes +- You're organized, you follow up without being chased, and you're comfortable nudging busy professionals over Slack + +""" + SHARED_WHAT_YOU_GET, + "work_sample_prompt": "Draft the Slack message you'd send to 20 confirmed mentors on the Monday before the hackathon. Assume half have never mentored a hackathon before.", + "video_prompts": [ + "In under 2 minutes: who are you, why this role, and tell us about a time you helped someone else succeed at something technical.", + "Read us the Slack message you drafted in the previous step — then tell us what you'd change about it for a mentor who went quiet mid-event.", + ], + }, +] + + +def main(): + parser = argparse.ArgumentParser(description="Seed job_listings with the Fall 2026 organizer roles") + parser.add_argument("--apply", action="store_true", help="Actually write to Firestore (default: dry run)") + args = parser.parse_args() + + now = datetime.now().isoformat() + actor = {"propel_user_id": None, "email": "scripts/seed_job_listings.py"} + + db = get_db() + for listing in LISTINGS: + validate_job_listing(listing) # raises on drift between seed and validators + slug = listing["slug"] + doc_ref = db.collection("job_listings").document(slug) + if doc_ref.get().exists: + print(f"SKIP {slug} (already exists — not overwriting)") + continue + doc = dict(listing) + doc.pop("slug") + doc.update({ + "posted_at": "", + "created_at": now, + "updated_at": now, + "created_by": actor, + "last_updated_by": actor, + }) + if args.apply: + doc_ref.set(doc) + print(f"WROTE {slug} (status=draft)") + else: + print(f"DRYRUN {slug} — would write {len(doc['description_markdown'])} chars of description") + + if not args.apply: + print("\nDry run complete. Re-run with --apply to write drafts, then publish via /admin/jobs.") + + +if __name__ == "__main__": + main()