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",
|
||||
}
|
||||
|
||||
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 ───────────────────────────────────────────────────
|
||||
|
|
|
|||
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