407 lines
17 KiB
Python
407 lines
17 KiB
Python
#!/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/<id>/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/<id>/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/<id>/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()
|