From bc8174271e8d273076be00b654db36d15bd14d30 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Thu, 19 Mar 2026 10:46:17 -0700 Subject: [PATCH] feat(interviews): add stage signals, email sync, and dismiss endpoints to dev-api --- dev-api.py | 87 ++++++++++++++++- dev_api.py | 1 + tests/test_dev_api_interviews.py | 159 +++++++++++++++++++++++++++++++ 3 files changed, 245 insertions(+), 2 deletions(-) create mode 120000 dev_api.py create mode 100644 tests/test_dev_api_interviews.py diff --git a/dev-api.py b/dev-api.py index 67cddca..f74e7bf 100644 --- a/dev-api.py +++ b/dev-api.py @@ -283,6 +283,8 @@ PIPELINE_STATUSES = { "interview_rejected", } +SIGNAL_EXCLUDED = ("neutral", "unrelated", "digest", "event_rescheduled") + @app.get("/api/interviews") def list_interviews(): db = _get_db() @@ -296,9 +298,90 @@ def list_interviews(): f"ORDER BY match_score DESC NULLS LAST", list(PIPELINE_STATUSES), ).fetchall() + + job_ids = [r["id"] for r in rows] + signals_by_job: dict[int, list] = {r["id"]: [] for r in rows} + + if job_ids: + sig_placeholders = ",".join("?" * len(job_ids)) + excl_placeholders = ",".join("?" * len(SIGNAL_EXCLUDED)) + sig_rows = db.execute( + f"SELECT id, job_id, subject, received_at, stage_signal " + f"FROM job_contacts " + f"WHERE job_id IN ({sig_placeholders}) " + f" AND suggestion_dismissed = 0 " + f" AND stage_signal NOT IN ({excl_placeholders}) " + f" AND stage_signal IS NOT NULL " + f"ORDER BY received_at DESC", + job_ids + list(SIGNAL_EXCLUDED), + ).fetchall() + for sr in sig_rows: + signals_by_job[sr["job_id"]].append({ + "id": sr["id"], + "subject": sr["subject"], + "received_at": sr["received_at"], + "stage_signal": sr["stage_signal"], + }) + db.close() - # Cast is_remote to bool for consistency with other endpoints - return [{**dict(r), "is_remote": bool(r["is_remote"])} for r in rows] + return [ + {**dict(r), "is_remote": bool(r["is_remote"]), "stage_signals": signals_by_job[r["id"]]} + for r in rows + ] + + +# ── POST /api/email/sync ────────────────────────────────────────────────── + +@app.post("/api/email/sync", status_code=202) +def trigger_email_sync(): + db = _get_db() + cursor = db.execute( + "INSERT INTO background_tasks (task_type, job_id, status) VALUES ('email_sync', 0, 'queued')" + ) + db.commit() + task_id = cursor.lastrowid + db.close() + return {"task_id": task_id} + + +# ── GET /api/email/sync/status ──────────────────────────────────────────── + +@app.get("/api/email/sync/status") +def email_sync_status(): + db = _get_db() + row = db.execute( + "SELECT status, finished_at AS last_completed_at " + "FROM background_tasks " + "WHERE task_type = 'email_sync' " + "ORDER BY id DESC LIMIT 1" + ).fetchone() + db.close() + if row is None: + return {"status": "idle", "last_completed_at": None, "error": None} + # background_tasks may not have an error column in staging — guard with dict access + row_dict = dict(row) + return { + "status": row_dict["status"], + "last_completed_at": row_dict["last_completed_at"], + "error": row_dict.get("error"), + } + + +# ── POST /api/stage-signals/{id}/dismiss ───────────────────────────────── + +@app.post("/api/stage-signals/{signal_id}/dismiss") +def dismiss_signal(signal_id: int): + db = _get_db() + result = db.execute( + "UPDATE job_contacts SET suggestion_dismissed = 1 WHERE id = ?", + (signal_id,), + ) + db.commit() + rowcount = result.rowcount + db.close() + if rowcount == 0: + raise HTTPException(404, "Signal not found") + return {"ok": True} # ── POST /api/jobs/{id}/move ─────────────────────────────────────────────────── diff --git a/dev_api.py b/dev_api.py new file mode 120000 index 0000000..6b8c5e9 --- /dev/null +++ b/dev_api.py @@ -0,0 +1 @@ +dev-api.py \ No newline at end of file diff --git a/tests/test_dev_api_interviews.py b/tests/test_dev_api_interviews.py new file mode 100644 index 0000000..3c061b0 --- /dev/null +++ b/tests/test_dev_api_interviews.py @@ -0,0 +1,159 @@ +"""Tests for new dev-api.py endpoints: stage signals, email sync, signal dismiss.""" +import sqlite3 +import tempfile +import os +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture() +def tmp_db(tmp_path): + """Create a minimal staging.db schema in a temp dir.""" + db_path = str(tmp_path / "staging.db") + con = sqlite3.connect(db_path) + con.executescript(""" + CREATE TABLE jobs ( + id INTEGER PRIMARY KEY, + title TEXT, company TEXT, url TEXT, location TEXT, + is_remote INTEGER DEFAULT 0, salary TEXT, + match_score REAL, keyword_gaps TEXT, status TEXT, + interview_date TEXT, rejection_stage TEXT, + applied_at TEXT, phone_screen_at TEXT, interviewing_at TEXT, + offer_at TEXT, hired_at TEXT, survey_at TEXT + ); + CREATE TABLE job_contacts ( + id INTEGER PRIMARY KEY, + job_id INTEGER, + subject TEXT, + received_at TEXT, + stage_signal TEXT, + suggestion_dismissed INTEGER DEFAULT 0 + ); + CREATE TABLE background_tasks ( + id INTEGER PRIMARY KEY, + task_type TEXT, + job_id INTEGER, + status TEXT DEFAULT 'queued', + finished_at TEXT + ); + INSERT INTO jobs (id, title, company, status) VALUES + (1, 'Engineer', 'Acme', 'applied'), + (2, 'Designer', 'Beta', 'phone_screen'); + INSERT INTO job_contacts (id, job_id, subject, received_at, stage_signal, suggestion_dismissed) VALUES + (10, 1, 'Interview confirmed', '2026-03-19T10:00:00', 'interview_scheduled', 0), + (11, 1, 'Old neutral', '2026-03-18T09:00:00', 'neutral', 0), + (12, 2, 'Offer letter', '2026-03-19T11:00:00', 'offer_received', 0), + (13, 1, 'Already dismissed', '2026-03-17T08:00:00', 'positive_response', 1); + """) + con.close() + return db_path + + +@pytest.fixture() +def client(tmp_db, monkeypatch): + monkeypatch.setenv("STAGING_DB", tmp_db) + # Re-import after env var is set so DB_PATH picks it up + import importlib + import dev_api + importlib.reload(dev_api) + return TestClient(dev_api.app) + + +# ── GET /api/interviews — stage signals batched ──────────────────────────── + +def test_interviews_includes_stage_signals(client): + resp = client.get("/api/interviews") + assert resp.status_code == 200 + jobs = {j["id"]: j for j in resp.json()} + + # job 1 should have exactly 1 undismissed non-excluded signal + assert "stage_signals" in jobs[1] + signals = jobs[1]["stage_signals"] + assert len(signals) == 1 + assert signals[0]["stage_signal"] == "interview_scheduled" + assert signals[0]["subject"] == "Interview confirmed" + assert signals[0]["id"] == 10 + + # neutral signal excluded + signal_types = [s["stage_signal"] for s in signals] + assert "neutral" not in signal_types + + # dismissed signal excluded + signal_ids = [s["id"] for s in signals] + assert 13 not in signal_ids + + # job 2 has an offer signal + assert len(jobs[2]["stage_signals"]) == 1 + assert jobs[2]["stage_signals"][0]["stage_signal"] == "offer_received" + + +def test_interviews_empty_signals_for_job_without_contacts(client, tmp_db): + con = sqlite3.connect(tmp_db) + con.execute("INSERT INTO jobs (id, title, company, status) VALUES (3, 'NoContact', 'Corp', 'survey')") + con.commit(); con.close() + resp = client.get("/api/interviews") + jobs = {j["id"]: j for j in resp.json()} + assert jobs[3]["stage_signals"] == [] + + +# ── POST /api/email/sync ─────────────────────────────────────────────────── + +def test_email_sync_returns_202(client): + resp = client.post("/api/email/sync") + assert resp.status_code == 202 + assert "task_id" in resp.json() + + +def test_email_sync_inserts_background_task(client, tmp_db): + client.post("/api/email/sync") + con = sqlite3.connect(tmp_db) + row = con.execute( + "SELECT task_type, job_id, status FROM background_tasks WHERE task_type='email_sync'" + ).fetchone() + con.close() + assert row is not None + assert row[0] == "email_sync" + assert row[1] == 0 # sentinel + assert row[2] == "queued" + + +# ── GET /api/email/sync/status ───────────────────────────────────────────── + +def test_email_sync_status_idle_when_no_tasks(client): + resp = client.get("/api/email/sync/status") + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "idle" + assert body["last_completed_at"] is None + + +def test_email_sync_status_reflects_latest_task(client, tmp_db): + con = sqlite3.connect(tmp_db) + con.execute( + "INSERT INTO background_tasks (task_type, job_id, status, finished_at) VALUES " + "('email_sync', 0, 'completed', '2026-03-19T12:00:00')" + ) + con.commit(); con.close() + resp = client.get("/api/email/sync/status") + body = resp.json() + assert body["status"] == "completed" + assert body["last_completed_at"] == "2026-03-19T12:00:00" + + +# ── POST /api/stage-signals/{id}/dismiss ────────────────────────────────── + +def test_dismiss_signal_sets_flag(client, tmp_db): + resp = client.post("/api/stage-signals/10/dismiss") + assert resp.status_code == 200 + assert resp.json() == {"ok": True} + con = sqlite3.connect(tmp_db) + row = con.execute( + "SELECT suggestion_dismissed FROM job_contacts WHERE id = 10" + ).fetchone() + con.close() + assert row[0] == 1 + + +def test_dismiss_signal_404_for_missing_id(client): + resp = client.post("/api/stage-signals/9999/dismiss") + assert resp.status_code == 404