feat(interviews): add stage signals, email sync, and dismiss endpoints to dev-api

This commit is contained in:
pyr0ball 2026-03-19 10:46:17 -07:00
parent 4abdf21981
commit bc8174271e
3 changed files with 245 additions and 2 deletions

View file

@ -283,6 +283,8 @@ PIPELINE_STATUSES = {
"interview_rejected", "interview_rejected",
} }
SIGNAL_EXCLUDED = ("neutral", "unrelated", "digest", "event_rescheduled")
@app.get("/api/interviews") @app.get("/api/interviews")
def list_interviews(): def list_interviews():
db = _get_db() db = _get_db()
@ -296,9 +298,90 @@ def list_interviews():
f"ORDER BY match_score DESC NULLS LAST", f"ORDER BY match_score DESC NULLS LAST",
list(PIPELINE_STATUSES), list(PIPELINE_STATUSES),
).fetchall() ).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() db.close()
# Cast is_remote to bool for consistency with other endpoints return [
return [{**dict(r), "is_remote": bool(r["is_remote"])} for r in rows] {**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 ─────────────────────────────────────────────────── # ── POST /api/jobs/{id}/move ───────────────────────────────────────────────────

1
dev_api.py Symbolic link
View file

@ -0,0 +1 @@
dev-api.py

View file

@ -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