feat(interviews): add stage signals, email sync, and dismiss endpoints to dev-api
This commit is contained in:
parent
60383281fb
commit
690a471575
3 changed files with 245 additions and 2 deletions
87
dev-api.py
87
dev-api.py
|
|
@ -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
1
dev_api.py
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
dev-api.py
|
||||||
159
tests/test_dev_api_interviews.py
Normal file
159
tests/test_dev_api_interviews.py
Normal 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
|
||||||
Loading…
Reference in a new issue