#!/usr/bin/env python3 """Job Dashboard API Server — serves the dashboard + Notion sync API. Replaces static http.server. Handles: - Static file serving (dashboard HTML, assets) - REST API for job CRUD (syncs back to Notion) - Auto-refresh from Notion every 5 min """ import json import os import re import subprocess import sys import time import traceback from datetime import datetime, timezone from http.server import HTTPServer, SimpleHTTPRequestHandler from urllib.parse import urlparse, parse_qs # ── Config ─────────────────────────────────────────────────────────────────── PORT = 9099 DASHBOARD_DIR = os.path.expanduser("~/.hermes/dashboard") DRAFTS_DIR = os.path.expanduser("~/.hermes/radar-ref/drafts") DB_ID = "0f90ba2b-8b10-4d02-a62c-6f692d5b1168" ENV_FILE = os.path.expanduser("~/.hermes/.env") CACHE_FILE = os.path.join(DASHBOARD_DIR, "jobs_cache.json") REFRESH_INTERVAL = 300 # 5 min # ── Cache ──────────────────────────────────────────────────────────────────── _jobs_cache = None _last_refresh = 0 def get_notion_key(): with open(ENV_FILE) as f: for line in f: line = line.strip() if line.startswith("NOTION_API_KEY="): return line.split("=", 1)[1] return None def notion_api(method, path, data=None): """Call Notion REST API directly.""" key = get_notion_key() if not key: return {"error": "NOTION_API_KEY not found"} url = f"https://api.notion.com/v1/{path.lstrip('/')}" cmd = ["curl", "-s", "-X", method, url, "-H", f"Authorization: Bearer {key}", "-H", "Notion-Version: 2022-06-28", "-H", "Content-Type: application/json"] if data is not None: cmd += ["-d", json.dumps(data)] result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) return json.loads(result.stdout) def refresh_cache(force=False): """Fetch all jobs from Notion and cache.""" global _jobs_cache, _last_refresh now = time.time() if not force and _jobs_cache and (now - _last_refresh) < REFRESH_INTERVAL: return _jobs_cache all_results = [] has_more = True start_cursor = None while has_more: payload = { "page_size": 100, "sorts": [{"property": "Date Added", "direction": "descending"}], } if start_cursor: payload["start_cursor"] = start_cursor data = notion_api("POST", f"databases/{DB_ID}/query", payload) if "error" in data: print(f"Notion API error: {data['error']}", file=sys.stderr) if _jobs_cache: return _jobs_cache return {"error": data["error"]} all_results.extend(data.get("results", [])) has_more = data.get("has_more", False) start_cursor = data.get("next_cursor") jobs = [] for r in all_results: props = r["properties"] job = { "id": r["id"], "name": "", "role": "", "status": "Backlog", "location": "", "source": "", "strong_match": False, "ai_sourced": False, "date_added": "", "url": "", "salary": None, "notes": "", "priority": "", } if props.get("Name", {}).get("title"): job["name"] = props["Name"]["title"][0].get("plain_text", "") if props.get("Role", {}).get("rich_text"): job["role"] = props["Role"]["rich_text"][0].get("plain_text", "") if props.get("Status", {}).get("select"): job["status"] = props["Status"]["select"]["name"] if props.get("Location", {}).get("select"): job["location"] = props["Location"]["select"]["name"] if props.get("Source", {}).get("select"): job["source"] = props["Source"]["select"]["name"] job["strong_match"] = props.get("Strong Match", {}).get("checkbox", False) job["ai_sourced"] = props.get("AI Sourced", {}).get("checkbox", False) if props.get("Date Added", {}).get("date"): job["date_added"] = props["Date Added"]["date"].get("start", "") if props.get("URL", {}).get("url"): job["url"] = props["URL"]["url"] if props.get("Salary (AUD/yr)", {}).get("number") is not None: job["salary"] = props["Salary (AUD/yr)"]["number"] if props.get("Notes / JD Summary", {}).get("rich_text"): job["notes"] = props["Notes / JD Summary"]["rich_text"][0].get("plain_text", "") if props.get("Priority", {}).get("select"): job["priority"] = props["Priority"]["select"]["name"] if props.get("Closing Date", {}).get("date"): job["closing_date"] = props["Closing Date"]["date"].get("start", "") else: job["closing_date"] = "" # Closing date calculations job["closing_status"] = "none" if job.get("closing_date"): try: from datetime import date cd = datetime.strptime(job["closing_date"], "%Y-%m-%d").date() today = date.today() days_left = (cd - today).days if days_left < 0: job["closing_status"] = "past" elif days_left <= 7: job["closing_status"] = "urgent" elif days_left <= 14: job["closing_status"] = "warning" except ValueError: pass if props.get("Cover Letter", {}).get("url"): job["cover_letter_url"] = props["Cover Letter"]["url"] else: job["cover_letter_url"] = "" if job["date_added"]: try: added = datetime.strptime(job["date_added"], "%Y-%m-%d") now_dt = datetime.now() job["days_since"] = (now_dt - added).days except ValueError: job["days_since"] = 0 else: job["days_since"] = 0 jobs.append(job) _jobs_cache = {"jobs": jobs, "total": len(jobs), "refreshed_at": datetime.now().isoformat()} _last_refresh = now # Also write the cache file try: os.makedirs(DASHBOARD_DIR, exist_ok=True) with open(CACHE_FILE, "w") as f: json.dump(_jobs_cache, f) except Exception: pass return _jobs_cache def update_job_status(job_id, updates): """Update a Notion job page with new property values.""" notion_props = {} if "status" in updates: notion_props["Status"] = {"select": {"name": updates["status"]}} if "strong_match" in updates: notion_props["Strong Match"] = {"checkbox": bool(updates["strong_match"])} if "priority" in updates: notion_props["Priority"] = {"select": {"name": updates["priority"]}} if "notes" in updates: notion_props["Notes / JD Summary"] = { "rich_text": [{"type": "text", "text": {"content": updates["notes"]}}] } if "cover_letter_url" in updates: notion_props["Cover Letter"] = {"url": updates["cover_letter_url"]} if not notion_props: return {"error": "No valid fields to update"} result = notion_api("PATCH", f"pages/{job_id}", {"properties": notion_props}) return result # ── HTTP Handler ───────────────────────────────────────────────────────────── class DashboardHandler(SimpleHTTPRequestHandler): def __init__(self, *args, **kwargs): super().__init__(*args, directory=DASHBOARD_DIR, **kwargs) def do_GET(self): parsed = urlparse(self.path) path = parsed.path # API routes if path == "/api/jobs": return self._json_response(refresh_cache()) elif path == "/api/stats": data = refresh_cache() jobs = data.get("jobs", []) statuses = {} for j in jobs: s = j["status"] statuses[s] = statuses.get(s, 0) + 1 locations = {} for j in jobs: l = j["location"] or "Unknown" locations[l] = locations.get(l, 0) + 1 sources = {} for j in jobs: s = j["source"] or "Unknown" sources[s] = sources.get(s, 0) + 1 salaries = [j["salary"] for j in jobs if j["salary"]] stats = { "total": data["total"], "strong_matches": sum(1 for j in jobs if j["strong_match"]), "ai_sourced": sum(1 for j in jobs if j["ai_sourced"]), "this_week": sum(1 for j in jobs if j["days_since"] <= 7), "stale_14d": sum(1 for j in jobs if j["days_since"] > 14), "closing_past": sum(1 for j in jobs if j.get("closing_status") == "past"), "closing_urgent": sum(1 for j in jobs if j.get("closing_status") == "urgent"), "closing_warning": sum(1 for j in jobs if j.get("closing_status") == "warning"), "has_closing": sum(1 for j in jobs if j.get("closing_date")), "statuses": statuses, "locations": locations, "sources": sources, "salary_min": min(salaries) if salaries else None, "salary_max": max(salaries) if salaries else None, "refreshed_at": data.get("refreshed_at", ""), } return self._json_response(stats) elif path == "/api/refresh": data = refresh_cache(force=True) return self._json_response({"ok": True, "total": data["total"]}) elif path.startswith("/api/jobs/") and path.endswith("/refresh"): data = refresh_cache(force=True) return self._json_response({"ok": True, "total": data["total"]}) # Serve draft files (cover letters) — preserve extension for browser rendering # Support both /drafts/ and /jobs/drafts/ paths (Tailscale Serve uses /jobs/drafts/ from prompt) drafts_path = None if path.startswith("/drafts/"): safe_path = os.path.normpath(path.lstrip("/")) drafts_path = os.path.join(DRAFTS_DIR, os.path.relpath(safe_path, "drafts")) elif path.startswith("/jobs/drafts/"): safe_path = os.path.normpath(path.lstrip("/")) drafts_path = os.path.join(DRAFTS_DIR, os.path.relpath(safe_path, "jobs/drafts")) if drafts_path: if os.path.isfile(drafts_path) and os.path.realpath(drafts_path).startswith(os.path.realpath(DRAFTS_DIR)): ext = os.path.splitext(drafts_path)[1].lower() ctype = "text/plain; charset=utf-8" if ext == ".html": ctype = "text/html; charset=utf-8" elif ext == ".json": ctype = "application/json; charset=utf-8" elif ext == ".docx": ctype = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" self.send_response(200) self.send_header("Content-Type", ctype) self.send_header("Access-Control-Allow-Origin", "*") self.send_header("Content-Disposition", f"inline; filename=\"{os.path.basename(drafts_path)}\"") self.end_headers() if ext == ".docx": with open(drafts_path, "rb") as f: self.wfile.write(f.read()) else: with open(drafts_path) as f: self.wfile.write(f.read().encode("utf-8")) return else: return self._json_response({"error": "Not found"}, 404) # Default: serve static file return super().do_GET() def do_POST(self): parsed = urlparse(self.path) path = parsed.path content_length = int(self.headers.get("Content-Length", 0)) body = self.rfile.read(content_length) if content_length else b"{}" try: payload = json.loads(body) except json.JSONDecodeError: return self._json_response({"error": "Invalid JSON"}, 400) # POST /api/refresh — force refresh from Notion if path == "/api/refresh": data = refresh_cache(force=True) return self._json_response({"ok": True, "total": data["total"], "refreshed_at": data["refreshed_at"]}) # POST /api/jobs//update — update a job match = re.match(r"^/api/jobs/([a-f0-9\-]+)/update$", path) if match: job_id = match.group(1) result = update_job_status(job_id, payload) if "error" in result: return self._json_response(result, 400) # Refresh cache after update refresh_cache(force=True) return self._json_response({"ok": True}) # POST /api/jobs//cover-letter/regenerate — regenerate cover letter with feedback match_cl = re.match(r"^/api/jobs/([a-f0-9\-]+)/cover-letter/regenerate$", path) if match_cl: job_id = match_cl.group(1) feedback = payload.get("feedback", "Make it stronger") script = os.path.expanduser("~/.hermes/scripts/regenerate_cover_letter.py") result = subprocess.run( ["python3", script, job_id, feedback], capture_output=True, text=True, timeout=120 ) try: cl_result = json.loads(result.stdout) if "error" in cl_result: return self._json_response(cl_result, 400) # Update Notion with the new URL update_result = update_job_status(job_id, {"cover_letter_url": cl_result["url"]}) if "error" in update_result: return self._json_response({"error": "Draft saved but Notion update failed", "cl_result": cl_result, "notion_error": update_result}, 400) # Refresh cache refresh_cache(force=True) return self._json_response({"ok": True, "url": cl_result["url"], "file": cl_result["file"]}) except json.JSONDecodeError: return self._json_response({"error": "Regeneration failed", "output": result.stdout[:500]}, 400) # POST /api/jobs/bulk-update — update multiple jobs if path == "/api/jobs/bulk-update": ids = payload.get("ids", []) updates = payload.get("updates", {}) results = [] for jid in ids: r = update_job_status(jid, updates) results.append({"id": jid, "ok": "error" not in r}) refresh_cache(force=True) return self._json_response({"ok": True, "results": results}) return self._json_response({"error": "Not found"}, 404) def do_OPTIONS(self): """CORS preflight.""" self.send_response(204) self.send_header("Access-Control-Allow-Origin", "*") self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") self.send_header("Access-Control-Allow-Headers", "Content-Type") self.end_headers() def _json_response(self, data, status=200): self.send_response(status) self.send_header("Content-Type", "application/json") self.send_header("Access-Control-Allow-Origin", "*") self.send_header("Cache-Control", "no-store") self.end_headers() self.wfile.write(json.dumps(data).encode()) def log_message(self, format, *args): """Quiet logs — skip static file noise.""" msg = format % args if "/api/" in msg: print(f"[API] {msg}", file=sys.stderr) else: # Keep it very quiet for static files pass # ── Main ───────────────────────────────────────────────────────────────────── def main(): # Ensure dashboard dir exists os.makedirs(DASHBOARD_DIR, exist_ok=True) # Pre-warm the cache print("Pre-warming job cache from Notion...", file=sys.stderr) refresh_cache(force=True) print(f"Cache loaded: {_jobs_cache['total']} jobs", file=sys.stderr) server = HTTPServer(("0.0.0.0", PORT), DashboardHandler) print(f"Dashboard server running on http://127.0.0.1:{PORT}", file=sys.stderr) print(f"Dashboard URL: https://hermes.kangaroo-eel.ts.net/jobs", file=sys.stderr) print(f"API: GET /api/jobs /api/stats /api/refresh", file=sys.stderr) print(f"API: POST /api/jobs//update /api/jobs/bulk-update", file=sys.stderr) try: server.serve_forever() except KeyboardInterrupt: print("\nShutting down...", file=sys.stderr) server.server_close() if __name__ == "__main__": main()