Initial commit: job dashboard server with evening stand-down, Notion sync, cover letter generation
This commit is contained in:
406
job_dashboard_server.py
Normal file
406
job_dashboard_server.py
Normal file
@@ -0,0 +1,406 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user