From 5020144f8da6c1dbb216e132c3515e9001952a90 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 20 Apr 2026 11:48:22 -0700 Subject: [PATCH 01/15] fix: update interview + survey tests for hired_feedback column and async analyze endpoint --- tests/test_dev_api_interviews.py | 3 +- tests/test_dev_api_survey.py | 263 +++++++++++++++++++------------ 2 files changed, 167 insertions(+), 99 deletions(-) diff --git a/tests/test_dev_api_interviews.py b/tests/test_dev_api_interviews.py index 1a3aa64..eeb1eb5 100644 --- a/tests/test_dev_api_interviews.py +++ b/tests/test_dev_api_interviews.py @@ -19,7 +19,8 @@ def tmp_db(tmp_path): 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 + offer_at TEXT, hired_at TEXT, survey_at TEXT, + hired_feedback TEXT ); CREATE TABLE job_contacts ( id INTEGER PRIMARY KEY, diff --git a/tests/test_dev_api_survey.py b/tests/test_dev_api_survey.py index 4a03336..4c201ae 100644 --- a/tests/test_dev_api_survey.py +++ b/tests/test_dev_api_survey.py @@ -1,18 +1,36 @@ -"""Tests for survey endpoints: vision health, analyze, save response, get history.""" +"""Tests for survey endpoints: vision health, async analyze task queue, save response, history.""" +import json +import sqlite3 import pytest from unittest.mock import patch, MagicMock from fastapi.testclient import TestClient +from scripts.db_migrate import migrate_db + @pytest.fixture -def client(): - import sys - sys.path.insert(0, "/Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa") - from dev_api import app - return TestClient(app) +def fresh_db(tmp_path, monkeypatch): + """Isolated DB + dev_api wired to it via _request_db and DB_PATH.""" + db = tmp_path / "test.db" + migrate_db(db) + monkeypatch.setenv("STAGING_DB", str(db)) + import dev_api + monkeypatch.setattr(dev_api, "DB_PATH", str(db)) + monkeypatch.setattr( + dev_api, + "_request_db", + type("CV", (), {"get": lambda self: str(db), "set": lambda *a: None})(), + ) + return db -# ── GET /api/vision/health ─────────────────────────────────────────────────── +@pytest.fixture +def client(fresh_db): + import dev_api + return TestClient(dev_api.app) + + +# ── GET /api/vision/health ──────────────────────────────────────────────────── def test_vision_health_available(client): """Returns available=true when vision service responds 200.""" @@ -32,133 +50,182 @@ def test_vision_health_unavailable(client): assert resp.json() == {"available": False} -# ── POST /api/jobs/{id}/survey/analyze ────────────────────────────────────── +# ── POST /api/jobs/{id}/survey/analyze ─────────────────────────────────────── -def test_analyze_text_quick(client): - """Text mode quick analysis returns output and source=text_paste.""" - mock_router = MagicMock() - mock_router.complete.return_value = "1. B — best option" - mock_router.config.get.return_value = ["claude_code", "vllm"] - with patch("dev_api.LLMRouter", return_value=mock_router): +def test_analyze_queues_task_and_returns_task_id(client): + """POST analyze queues a background task and returns task_id + is_new.""" + with patch("scripts.task_runner.submit_task", return_value=(42, True)) as mock_submit: resp = client.post("/api/jobs/1/survey/analyze", json={ "text": "Q1: Do you prefer teamwork?\nA. Solo B. Together", "mode": "quick", }) assert resp.status_code == 200 data = resp.json() - assert data["source"] == "text_paste" - assert "B" in data["output"] - # System prompt must be passed for text path - call_kwargs = mock_router.complete.call_args[1] - assert "system" in call_kwargs - assert "culture-fit survey" in call_kwargs["system"] + assert data["task_id"] == 42 + assert data["is_new"] is True + # submit_task called with survey_analyze type + call_kwargs = mock_submit.call_args + assert call_kwargs.kwargs["task_type"] == "survey_analyze" + assert call_kwargs.kwargs["job_id"] == 1 + params = json.loads(call_kwargs.kwargs["params"]) + assert params["mode"] == "quick" + assert params["text"] == "Q1: Do you prefer teamwork?\nA. Solo B. Together" -def test_analyze_text_detailed(client): - """Text mode detailed analysis passes correct prompt.""" - mock_router = MagicMock() - mock_router.complete.return_value = "Option A: good for... Option B: better because..." - mock_router.config.get.return_value = [] - with patch("dev_api.LLMRouter", return_value=mock_router): +def test_analyze_silently_attaches_to_existing_task(client): + """is_new=False when task already running for same input.""" + with patch("scripts.task_runner.submit_task", return_value=(7, False)): resp = client.post("/api/jobs/1/survey/analyze", json={ - "text": "Q1: Describe your work style.", - "mode": "detailed", + "text": "Q1: test", "mode": "quick", }) assert resp.status_code == 200 - assert resp.json()["source"] == "text_paste" + assert resp.json()["is_new"] is False -def test_analyze_image(client): - """Image mode routes through vision path with NO system prompt.""" - mock_router = MagicMock() - mock_router.complete.return_value = "1. C — collaborative choice" - mock_router.config.get.return_value = ["vision_service", "claude_code"] - with patch("dev_api.LLMRouter", return_value=mock_router): +def test_analyze_invalid_mode_returns_400(client): + resp = client.post("/api/jobs/1/survey/analyze", json={"text": "Q1: test", "mode": "wrong"}) + assert resp.status_code == 400 + + +def test_analyze_image_mode_passes_image_in_params(client): + """Image payload is forwarded in task params.""" + with patch("scripts.task_runner.submit_task", return_value=(1, True)) as mock_submit: resp = client.post("/api/jobs/1/survey/analyze", json={ "image_b64": "aGVsbG8=", "mode": "quick", }) assert resp.status_code == 200 + params = json.loads(mock_submit.call_args.kwargs["params"]) + assert params["image_b64"] == "aGVsbG8=" + assert params["text"] is None + + +# ── GET /api/jobs/{id}/survey/analyze/task ──────────────────────────────────── + +def test_task_poll_completed_text(client, fresh_db): + """Completed task with text result returns parsed source + output.""" + result_json = json.dumps({"output": "1. B — best option", "source": "text_paste"}) + con = sqlite3.connect(fresh_db) + con.execute( + "INSERT INTO background_tasks (task_type, job_id, status, error) VALUES (?,?,?,?)", + ("survey_analyze", 1, "completed", result_json), + ) + task_id = con.execute("SELECT last_insert_rowid()").fetchone()[0] + con.commit(); con.close() + + resp = client.get(f"/api/jobs/1/survey/analyze/task?task_id={task_id}") + assert resp.status_code == 200 data = resp.json() - assert data["source"] == "screenshot" - # No system prompt on vision path - call_kwargs = mock_router.complete.call_args[1] - assert "system" not in call_kwargs + assert data["status"] == "completed" + assert data["result"]["source"] == "text_paste" + assert "B" in data["result"]["output"] + assert data["message"] is None -def test_analyze_llm_failure(client): - """Returns 500 when LLM raises an exception.""" - mock_router = MagicMock() - mock_router.complete.side_effect = Exception("LLM unavailable") - mock_router.config.get.return_value = [] - with patch("dev_api.LLMRouter", return_value=mock_router): - resp = client.post("/api/jobs/1/survey/analyze", json={ - "text": "Q1: test", - "mode": "quick", - }) - assert resp.status_code == 500 +def test_task_poll_completed_screenshot(client, fresh_db): + """Completed task with image result returns source=screenshot.""" + result_json = json.dumps({"output": "1. C — collaborative", "source": "screenshot"}) + con = sqlite3.connect(fresh_db) + con.execute( + "INSERT INTO background_tasks (task_type, job_id, status, error) VALUES (?,?,?,?)", + ("survey_analyze", 1, "completed", result_json), + ) + task_id = con.execute("SELECT last_insert_rowid()").fetchone()[0] + con.commit(); con.close() + + resp = client.get(f"/api/jobs/1/survey/analyze/task?task_id={task_id}") + assert resp.status_code == 200 + assert resp.json()["result"]["source"] == "screenshot" -# ── POST /api/jobs/{id}/survey/responses ──────────────────────────────────── +def test_task_poll_failed_returns_message(client, fresh_db): + """Failed task returns status=failed with error message.""" + con = sqlite3.connect(fresh_db) + con.execute( + "INSERT INTO background_tasks (task_type, job_id, status, error) VALUES (?,?,?,?)", + ("survey_analyze", 1, "failed", "LLM unavailable"), + ) + task_id = con.execute("SELECT last_insert_rowid()").fetchone()[0] + con.commit(); con.close() + + resp = client.get(f"/api/jobs/1/survey/analyze/task?task_id={task_id}") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "failed" + assert data["message"] == "LLM unavailable" + assert data["result"] is None + + +def test_task_poll_running_returns_stage(client, fresh_db): + """Running task returns status=running with current stage.""" + con = sqlite3.connect(fresh_db) + con.execute( + "INSERT INTO background_tasks (task_type, job_id, status, stage) VALUES (?,?,?,?)", + ("survey_analyze", 1, "running", "analyzing survey"), + ) + task_id = con.execute("SELECT last_insert_rowid()").fetchone()[0] + con.commit(); con.close() + + resp = client.get(f"/api/jobs/1/survey/analyze/task?task_id={task_id}") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "running" + assert data["stage"] == "analyzing survey" + + +def test_task_poll_none_when_no_task(client): + """Returns status=none when no task exists for the job.""" + resp = client.get("/api/jobs/999/survey/analyze/task") + assert resp.status_code == 200 + assert resp.json()["status"] == "none" + + +# ── POST /api/jobs/{id}/survey/responses ───────────────────────────────────── def test_save_response_text(client): - """Save text response writes to DB and returns id.""" - mock_db = MagicMock() - with patch("dev_api._get_db", return_value=mock_db): - with patch("dev_api.insert_survey_response", return_value=42) as mock_insert: - resp = client.post("/api/jobs/1/survey/responses", json={ - "mode": "quick", - "source": "text_paste", - "raw_input": "Q1: test question", - "llm_output": "1. B — good reason", - }) + """Save a text-mode survey response returns an id.""" + resp = client.post("/api/jobs/1/survey/responses", json={ + "survey_name": "Culture Fit", + "mode": "quick", + "source": "text_paste", + "raw_input": "Q1: Teamwork?", + "llm_output": "1. B is best", + "reported_score": "85", + }) assert resp.status_code == 200 - assert resp.json()["id"] == 42 - # received_at generated by backend — not None - call_args = mock_insert.call_args - assert call_args[1]["received_at"] is not None or call_args[0][3] is not None + assert "id" in resp.json() -def test_save_response_with_image(client, tmp_path, monkeypatch): - """Save image response writes PNG file and stores path in DB.""" - monkeypatch.setenv("STAGING_DB", str(tmp_path / "test.db")) - with patch("dev_api.insert_survey_response", return_value=7) as mock_insert: - with patch("dev_api.Path") as mock_path_cls: - mock_path_cls.return_value.__truediv__ = lambda s, o: tmp_path / o - resp = client.post("/api/jobs/1/survey/responses", json={ - "mode": "quick", - "source": "screenshot", - "image_b64": "aGVsbG8=", # valid base64 - "llm_output": "1. B — reason", - }) +def test_save_response_with_image(client): + """Save a screenshot-mode survey response returns an id.""" + resp = client.post("/api/jobs/1/survey/responses", json={ + "survey_name": None, + "mode": "quick", + "source": "screenshot", + "image_b64": "aGVsbG8=", + "llm_output": "1. C collaborative", + "reported_score": None, + }) assert resp.status_code == 200 - assert resp.json()["id"] == 7 + assert "id" in resp.json() -# ── GET /api/jobs/{id}/survey/responses ───────────────────────────────────── - def test_get_history_empty(client): - """Returns empty list when no history exists.""" - with patch("dev_api.get_survey_responses", return_value=[]): - resp = client.get("/api/jobs/1/survey/responses") + """History is empty for a fresh job.""" + resp = client.get("/api/jobs/1/survey/responses") assert resp.status_code == 200 assert resp.json() == [] def test_get_history_populated(client): - """Returns history rows newest first.""" - rows = [ - {"id": 2, "survey_name": "Round 2", "mode": "detailed", "source": "text_paste", - "raw_input": None, "image_path": None, "llm_output": "Option A is best", - "reported_score": "90%", "received_at": "2026-03-21T14:00:00", "created_at": "2026-03-21T14:00:01"}, - {"id": 1, "survey_name": "Round 1", "mode": "quick", "source": "text_paste", - "raw_input": "Q1: test", "image_path": None, "llm_output": "1. B", - "reported_score": None, "received_at": "2026-03-21T12:00:00", "created_at": "2026-03-21T12:00:01"}, - ] - with patch("dev_api.get_survey_responses", return_value=rows): - resp = client.get("/api/jobs/1/survey/responses") + """History returns all saved responses for a job in reverse order.""" + for i in range(2): + client.post("/api/jobs/1/survey/responses", json={ + "survey_name": f"Survey {i}", + "mode": "quick", + "source": "text_paste", + "llm_output": f"Output {i}", + }) + resp = client.get("/api/jobs/1/survey/responses") assert resp.status_code == 200 - data = resp.json() - assert len(data) == 2 - assert data[0]["id"] == 2 - assert data[0]["survey_name"] == "Round 2" + assert len(resp.json()) == 2 From 9eca0c21abeb1c6a7ee362eebbc1f7cd67c33c96 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 20 Apr 2026 11:51:59 -0700 Subject: [PATCH 02/15] =?UTF-8?q?feat:=20migration=20008=20=E2=80=94=20mes?= =?UTF-8?q?sages=20+=20message=5Ftemplates=20tables=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- migrations/008_messaging.sql | 97 ++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 migrations/008_messaging.sql diff --git a/migrations/008_messaging.sql b/migrations/008_messaging.sql new file mode 100644 index 0000000..74a86ea --- /dev/null +++ b/migrations/008_messaging.sql @@ -0,0 +1,97 @@ +-- messages: manual log entries and LLM drafts +CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id INTEGER REFERENCES jobs(id) ON DELETE SET NULL, + job_contact_id INTEGER REFERENCES job_contacts(id) ON DELETE SET NULL, + type TEXT NOT NULL DEFAULT 'email', + direction TEXT, + subject TEXT, + body TEXT, + from_addr TEXT, + to_addr TEXT, + logged_at TEXT NOT NULL DEFAULT (datetime('now')), + approved_at TEXT, + template_id INTEGER REFERENCES message_templates(id) ON DELETE SET NULL, + osprey_call_id TEXT +); + +-- message_templates: built-in seeds and user-created templates +CREATE TABLE IF NOT EXISTS message_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT UNIQUE, + title TEXT NOT NULL, + category TEXT NOT NULL DEFAULT 'custom', + subject_template TEXT, + body_template TEXT NOT NULL, + is_builtin INTEGER NOT NULL DEFAULT 0, + is_community INTEGER NOT NULL DEFAULT 0, + community_source TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +INSERT OR IGNORE INTO message_templates + (key, title, category, subject_template, body_template, is_builtin) +VALUES + ( + 'follow_up', + 'Following up on my application', + 'follow_up', + 'Following up — {{role}} application', + 'Hi {{recruiter_name}}, + +I wanted to follow up on my application for the {{role}} position at {{company}}. I remain very interested in the opportunity and would welcome the chance to discuss my background further. + +Please let me know if there is anything else you need from me. + +Best regards, +{{name}}', + 1 + ), + ( + 'thank_you', + 'Thank you for the interview', + 'thank_you', + 'Thank you — {{role}} interview', + 'Hi {{recruiter_name}}, + +Thank you for taking the time to speak with me about the {{role}} role at {{company}}. I enjoyed learning more about the team and the work you are doing. + +I am very excited about this opportunity and look forward to hearing about the next steps. + +Best regards, +{{name}}', + 1 + ), + ( + 'accommodation_request', + 'Accommodation request', + 'accommodation', + 'Accommodation request — {{role}} interview', + 'Hi {{recruiter_name}}, + +I am writing to request a reasonable accommodation for my upcoming interview for the {{role}} position. Specifically, I would appreciate: + +{{accommodation_details}} + +Please let me know if you need any additional information. I am happy to discuss this further. + +Thank you, +{{name}}', + 1 + ), + ( + 'withdrawal', + 'Withdrawing my application', + 'withdrawal', + 'Application withdrawal — {{role}}', + 'Hi {{recruiter_name}}, + +I am writing to let you know that I would like to withdraw my application for the {{role}} position at {{company}}. + +Thank you for your time and consideration. I wish you and the team all the best. + +Best regards, +{{name}}', + 1 + ) From ea961d6da98242365c66e19d23b630bdce0b336a Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 20 Apr 2026 11:55:43 -0700 Subject: [PATCH 03/15] feat: messaging DB helpers + unit tests (#74) --- scripts/messaging.py | 269 +++++++++++++++++++++++++++ tests/test_messaging.py | 393 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 662 insertions(+) create mode 100644 scripts/messaging.py create mode 100644 tests/test_messaging.py diff --git a/scripts/messaging.py b/scripts/messaging.py new file mode 100644 index 0000000..2d5fb3f --- /dev/null +++ b/scripts/messaging.py @@ -0,0 +1,269 @@ +""" +DB helpers for the messaging feature. + +Messages table: manual log entries and LLM drafts (one row per message). +Message templates table: built-in seeds and user-created templates. + +Conventions (match scripts/db.py): +- All functions take db_path: Path as first argument. +- sqlite3.connect(db_path), row_factory = sqlite3.Row +- Return plain dicts (dict(row)) +- Always close connection in finally +""" +import sqlite3 +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +def _connect(db_path: Path) -> sqlite3.Connection: + con = sqlite3.connect(db_path) + con.row_factory = sqlite3.Row + return con + + +def _now_utc() -> str: + """Return current UTC time as ISO 8601 string.""" + return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + + +# --------------------------------------------------------------------------- +# Messages +# --------------------------------------------------------------------------- + +def create_message( + db_path: Path, + *, + job_id: Optional[int], + job_contact_id: Optional[int], + type: str, + direction: str, + subject: Optional[str], + body: Optional[str], + from_addr: Optional[str], + to_addr: Optional[str], + template_id: Optional[int], +) -> dict: + """Insert a new message row and return it as a dict.""" + con = _connect(db_path) + try: + cur = con.execute( + """ + INSERT INTO messages + (job_id, job_contact_id, type, direction, subject, body, + from_addr, to_addr, template_id) + VALUES + (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + (job_id, job_contact_id, type, direction, subject, body, + from_addr, to_addr, template_id), + ) + con.commit() + row = con.execute( + "SELECT * FROM messages WHERE id = ?", (cur.lastrowid,) + ).fetchone() + return dict(row) + finally: + con.close() + + +def list_messages( + db_path: Path, + *, + job_id: Optional[int] = None, + type: Optional[str] = None, + direction: Optional[str] = None, + limit: int = 100, +) -> list[dict]: + """Return messages, optionally filtered. Ordered by logged_at DESC.""" + conditions: list[str] = [] + params: list = [] + + if job_id is not None: + conditions.append("job_id = ?") + params.append(job_id) + if type is not None: + conditions.append("type = ?") + params.append(type) + if direction is not None: + conditions.append("direction = ?") + params.append(direction) + + where = ("WHERE " + " AND ".join(conditions)) if conditions else "" + params.append(limit) + + con = _connect(db_path) + try: + rows = con.execute( + f"SELECT * FROM messages {where} ORDER BY logged_at DESC LIMIT ?", + params, + ).fetchall() + return [dict(r) for r in rows] + finally: + con.close() + + +def delete_message(db_path: Path, message_id: int) -> None: + """Delete a message by id. Raises KeyError if not found.""" + con = _connect(db_path) + try: + row = con.execute( + "SELECT id FROM messages WHERE id = ?", (message_id,) + ).fetchone() + if row is None: + raise KeyError(f"Message {message_id} not found") + con.execute("DELETE FROM messages WHERE id = ?", (message_id,)) + con.commit() + finally: + con.close() + + +def approve_message(db_path: Path, message_id: int) -> dict: + """Set approved_at to now for the given message. Raises KeyError if not found.""" + con = _connect(db_path) + try: + row = con.execute( + "SELECT id FROM messages WHERE id = ?", (message_id,) + ).fetchone() + if row is None: + raise KeyError(f"Message {message_id} not found") + con.execute( + "UPDATE messages SET approved_at = ? WHERE id = ?", + (_now_utc(), message_id), + ) + con.commit() + updated = con.execute( + "SELECT * FROM messages WHERE id = ?", (message_id,) + ).fetchone() + return dict(updated) + finally: + con.close() + + +# --------------------------------------------------------------------------- +# Templates +# --------------------------------------------------------------------------- + +def list_templates(db_path: Path) -> list[dict]: + """Return all templates ordered by is_builtin DESC, then title ASC.""" + con = _connect(db_path) + try: + rows = con.execute( + "SELECT * FROM message_templates ORDER BY is_builtin DESC, title ASC" + ).fetchall() + return [dict(r) for r in rows] + finally: + con.close() + + +def create_template( + db_path: Path, + *, + title: str, + category: str = "custom", + subject_template: Optional[str] = None, + body_template: str, +) -> dict: + """Insert a new user-defined template and return it as a dict.""" + con = _connect(db_path) + try: + cur = con.execute( + """ + INSERT INTO message_templates + (title, category, subject_template, body_template, is_builtin) + VALUES + (?, ?, ?, ?, 0) + """, + (title, category, subject_template, body_template), + ) + con.commit() + row = con.execute( + "SELECT * FROM message_templates WHERE id = ?", (cur.lastrowid,) + ).fetchone() + return dict(row) + finally: + con.close() + + +def update_template(db_path: Path, template_id: int, **fields) -> dict: + """ + Update allowed fields on a user-defined template. + + Raises PermissionError if the template is a built-in (is_builtin=1). + Raises KeyError if the template is not found. + """ + if not fields: + # Nothing to update — just return current state + con = _connect(db_path) + try: + row = con.execute( + "SELECT * FROM message_templates WHERE id = ?", (template_id,) + ).fetchone() + if row is None: + raise KeyError(f"Template {template_id} not found") + return dict(row) + finally: + con.close() + + _ALLOWED_FIELDS = { + "title", "category", "subject_template", "body_template", + } + invalid = set(fields) - _ALLOWED_FIELDS + if invalid: + raise ValueError(f"Cannot update field(s): {invalid}") + + con = _connect(db_path) + try: + row = con.execute( + "SELECT id, is_builtin FROM message_templates WHERE id = ?", + (template_id,), + ).fetchone() + if row is None: + raise KeyError(f"Template {template_id} not found") + if row["is_builtin"]: + raise PermissionError( + f"Template {template_id} is a built-in and cannot be modified" + ) + + set_clause = ", ".join(f"{col} = ?" for col in fields) + values = list(fields.values()) + [_now_utc(), template_id] + con.execute( + f"UPDATE message_templates SET {set_clause}, updated_at = ? WHERE id = ?", + values, + ) + con.commit() + updated = con.execute( + "SELECT * FROM message_templates WHERE id = ?", (template_id,) + ).fetchone() + return dict(updated) + finally: + con.close() + + +def delete_template(db_path: Path, template_id: int) -> None: + """ + Delete a user-defined template. + + Raises PermissionError if the template is a built-in (is_builtin=1). + Raises KeyError if the template is not found. + """ + con = _connect(db_path) + try: + row = con.execute( + "SELECT id, is_builtin FROM message_templates WHERE id = ?", + (template_id,), + ).fetchone() + if row is None: + raise KeyError(f"Template {template_id} not found") + if row["is_builtin"]: + raise PermissionError( + f"Template {template_id} is a built-in and cannot be deleted" + ) + con.execute("DELETE FROM message_templates WHERE id = ?", (template_id,)) + con.commit() + finally: + con.close() diff --git a/tests/test_messaging.py b/tests/test_messaging.py new file mode 100644 index 0000000..4e69648 --- /dev/null +++ b/tests/test_messaging.py @@ -0,0 +1,393 @@ +""" +Unit tests for scripts/messaging.py — DB helpers for messages and message_templates. + +TDD approach: tests written before implementation. +""" +import sqlite3 +from pathlib import Path + +import pytest + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +def _apply_migration_008(db_path: Path) -> None: + """Apply migration 008 directly so tests run without the full migrate_db stack.""" + migration = ( + Path(__file__).parent.parent / "migrations" / "008_messaging.sql" + ) + sql = migration.read_text(encoding="utf-8") + con = sqlite3.connect(db_path) + try: + # Create jobs table stub so FK references don't break + con.execute(""" + CREATE TABLE IF NOT EXISTS jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT + ) + """) + con.execute(""" + CREATE TABLE IF NOT EXISTS job_contacts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id INTEGER + ) + """) + # Execute migration statements + statements = [s.strip() for s in sql.split(";") if s.strip()] + for stmt in statements: + stripped = "\n".join( + ln for ln in stmt.splitlines() if not ln.strip().startswith("--") + ).strip() + if stripped: + con.execute(stripped) + con.commit() + finally: + con.close() + + +@pytest.fixture() +def db_path(tmp_path: Path) -> Path: + """Temporary SQLite DB with migration 008 applied.""" + path = tmp_path / "test.db" + _apply_migration_008(path) + return path + + +@pytest.fixture() +def job_id(db_path: Path) -> int: + """Insert a dummy job and return its id.""" + con = sqlite3.connect(db_path) + try: + cur = con.execute("INSERT INTO jobs (title) VALUES ('Test Job')") + con.commit() + return cur.lastrowid + finally: + con.close() + + +# --------------------------------------------------------------------------- +# Message tests +# --------------------------------------------------------------------------- + +class TestCreateMessage: + def test_create_returns_dict(self, db_path: Path, job_id: int) -> None: + from scripts.messaging import create_message + + msg = create_message( + db_path, + job_id=job_id, + job_contact_id=None, + type="email", + direction="outbound", + subject="Hello", + body="Body text", + from_addr="me@example.com", + to_addr="them@example.com", + template_id=None, + ) + + assert isinstance(msg, dict) + assert msg["subject"] == "Hello" + assert msg["body"] == "Body text" + assert msg["direction"] == "outbound" + assert msg["type"] == "email" + assert "id" in msg + assert msg["id"] > 0 + + def test_create_persists_to_db(self, db_path: Path, job_id: int) -> None: + from scripts.messaging import create_message + + create_message( + db_path, + job_id=job_id, + job_contact_id=None, + type="email", + direction="outbound", + subject="Persisted", + body="Stored body", + from_addr="a@b.com", + to_addr="c@d.com", + template_id=None, + ) + + con = sqlite3.connect(db_path) + try: + row = con.execute( + "SELECT subject FROM messages WHERE subject='Persisted'" + ).fetchone() + assert row is not None + finally: + con.close() + + +class TestListMessages: + def _make_message( + self, + db_path: Path, + job_id: int, + *, + type: str = "email", + direction: str = "outbound", + subject: str = "Subject", + ) -> dict: + from scripts.messaging import create_message + return create_message( + db_path, + job_id=job_id, + job_contact_id=None, + type=type, + direction=direction, + subject=subject, + body="body", + from_addr="a@b.com", + to_addr="c@d.com", + template_id=None, + ) + + def test_list_returns_all_messages(self, db_path: Path, job_id: int) -> None: + from scripts.messaging import list_messages + + self._make_message(db_path, job_id, subject="First") + self._make_message(db_path, job_id, subject="Second") + + result = list_messages(db_path) + assert len(result) == 2 + + def test_list_filtered_by_job_id(self, db_path: Path, job_id: int) -> None: + from scripts.messaging import list_messages + + # Create a second job + con = sqlite3.connect(db_path) + try: + cur = con.execute("INSERT INTO jobs (title) VALUES ('Other Job')") + con.commit() + other_job_id = cur.lastrowid + finally: + con.close() + + self._make_message(db_path, job_id, subject="For job 1") + self._make_message(db_path, other_job_id, subject="For job 2") + + result = list_messages(db_path, job_id=job_id) + assert len(result) == 1 + assert result[0]["subject"] == "For job 1" + + def test_list_filtered_by_type(self, db_path: Path, job_id: int) -> None: + from scripts.messaging import list_messages + + self._make_message(db_path, job_id, type="email", subject="Email msg") + self._make_message(db_path, job_id, type="sms", subject="SMS msg") + + emails = list_messages(db_path, type="email") + assert len(emails) == 1 + assert emails[0]["type"] == "email" + + def test_list_filtered_by_direction(self, db_path: Path, job_id: int) -> None: + from scripts.messaging import list_messages + + self._make_message(db_path, job_id, direction="outbound") + self._make_message(db_path, job_id, direction="inbound") + + outbound = list_messages(db_path, direction="outbound") + assert len(outbound) == 1 + assert outbound[0]["direction"] == "outbound" + + def test_list_respects_limit(self, db_path: Path, job_id: int) -> None: + from scripts.messaging import list_messages + + for i in range(5): + self._make_message(db_path, job_id, subject=f"Msg {i}") + + result = list_messages(db_path, limit=3) + assert len(result) == 3 + + +class TestDeleteMessage: + def test_delete_removes_message(self, db_path: Path, job_id: int) -> None: + from scripts.messaging import create_message, delete_message, list_messages + + msg = create_message( + db_path, + job_id=job_id, + job_contact_id=None, + type="email", + direction="outbound", + subject="To delete", + body="bye", + from_addr="a@b.com", + to_addr="c@d.com", + template_id=None, + ) + + delete_message(db_path, msg["id"]) + assert list_messages(db_path) == [] + + def test_delete_raises_key_error_when_not_found(self, db_path: Path) -> None: + from scripts.messaging import delete_message + + with pytest.raises(KeyError): + delete_message(db_path, 99999) + + +class TestApproveMessage: + def test_approve_sets_approved_at(self, db_path: Path, job_id: int) -> None: + from scripts.messaging import approve_message, create_message + + msg = create_message( + db_path, + job_id=job_id, + job_contact_id=None, + type="email", + direction="outbound", + subject="Draft", + body="Draft body", + from_addr="a@b.com", + to_addr="c@d.com", + template_id=None, + ) + assert msg.get("approved_at") is None + + updated = approve_message(db_path, msg["id"]) + assert updated["approved_at"] is not None + assert updated["id"] == msg["id"] + + def test_approve_returns_full_dict(self, db_path: Path, job_id: int) -> None: + from scripts.messaging import approve_message, create_message + + msg = create_message( + db_path, + job_id=job_id, + job_contact_id=None, + type="email", + direction="outbound", + subject="Draft", + body="Body here", + from_addr="a@b.com", + to_addr="c@d.com", + template_id=None, + ) + + updated = approve_message(db_path, msg["id"]) + assert updated["body"] == "Body here" + assert updated["subject"] == "Draft" + + def test_approve_raises_key_error_when_not_found(self, db_path: Path) -> None: + from scripts.messaging import approve_message + + with pytest.raises(KeyError): + approve_message(db_path, 99999) + + +# --------------------------------------------------------------------------- +# Template tests +# --------------------------------------------------------------------------- + +class TestListTemplates: + def test_includes_four_builtins(self, db_path: Path) -> None: + from scripts.messaging import list_templates + + templates = list_templates(db_path) + builtin_keys = {t["key"] for t in templates if t["is_builtin"]} + assert builtin_keys == { + "follow_up", + "thank_you", + "accommodation_request", + "withdrawal", + } + + def test_returns_list_of_dicts(self, db_path: Path) -> None: + from scripts.messaging import list_templates + + templates = list_templates(db_path) + assert isinstance(templates, list) + assert all(isinstance(t, dict) for t in templates) + + +class TestCreateTemplate: + def test_create_returns_dict(self, db_path: Path) -> None: + from scripts.messaging import create_template + + tmpl = create_template( + db_path, + title="My Template", + category="custom", + subject_template="Hello {{name}}", + body_template="Dear {{name}}, ...", + ) + + assert isinstance(tmpl, dict) + assert tmpl["title"] == "My Template" + assert tmpl["category"] == "custom" + assert tmpl["is_builtin"] == 0 + assert "id" in tmpl + + def test_create_default_category(self, db_path: Path) -> None: + from scripts.messaging import create_template + + tmpl = create_template( + db_path, + title="No Category", + body_template="Body", + ) + assert tmpl["category"] == "custom" + + def test_create_appears_in_list(self, db_path: Path) -> None: + from scripts.messaging import create_template, list_templates + + create_template(db_path, title="Listed", body_template="Body") + titles = [t["title"] for t in list_templates(db_path)] + assert "Listed" in titles + + +class TestUpdateTemplate: + def test_update_user_template(self, db_path: Path) -> None: + from scripts.messaging import create_template, update_template + + tmpl = create_template(db_path, title="Original", body_template="Old body") + updated = update_template(db_path, tmpl["id"], title="Updated", body_template="New body") + + assert updated["title"] == "Updated" + assert updated["body_template"] == "New body" + + def test_update_returns_persisted_values(self, db_path: Path) -> None: + from scripts.messaging import create_template, list_templates, update_template + + tmpl = create_template(db_path, title="Before", body_template="x") + update_template(db_path, tmpl["id"], title="After") + + templates = list_templates(db_path) + titles = [t["title"] for t in templates] + assert "After" in titles + assert "Before" not in titles + + def test_update_builtin_raises_permission_error(self, db_path: Path) -> None: + from scripts.messaging import list_templates, update_template + + builtin = next(t for t in list_templates(db_path) if t["is_builtin"]) + with pytest.raises(PermissionError): + update_template(db_path, builtin["id"], title="Hacked") + + +class TestDeleteTemplate: + def test_delete_user_template(self, db_path: Path) -> None: + from scripts.messaging import create_template, delete_template, list_templates + + tmpl = create_template(db_path, title="To Delete", body_template="bye") + initial_count = len(list_templates(db_path)) + delete_template(db_path, tmpl["id"]) + assert len(list_templates(db_path)) == initial_count - 1 + + def test_delete_builtin_raises_permission_error(self, db_path: Path) -> None: + from scripts.messaging import delete_template, list_templates + + builtin = next(t for t in list_templates(db_path) if t["is_builtin"]) + with pytest.raises(PermissionError): + delete_template(db_path, builtin["id"]) + + def test_delete_missing_raises_key_error(self, db_path: Path) -> None: + from scripts.messaging import delete_template + + with pytest.raises(KeyError): + delete_template(db_path, 99999) From 091834f1aeee34bc57b7c2c0ae1f89a344339c56 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 20 Apr 2026 12:32:35 -0700 Subject: [PATCH 04/15] test: add missing update_template KeyError test (#74) --- tests/test_messaging.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_messaging.py b/tests/test_messaging.py index 4e69648..9ae3207 100644 --- a/tests/test_messaging.py +++ b/tests/test_messaging.py @@ -369,6 +369,12 @@ class TestUpdateTemplate: with pytest.raises(PermissionError): update_template(db_path, builtin["id"], title="Hacked") + def test_update_missing_raises_key_error(self, db_path): + from scripts.messaging import update_template + + with pytest.raises(KeyError): + update_template(db_path, 9999, title="Ghost") + class TestDeleteTemplate: def test_delete_user_template(self, db_path: Path) -> None: From 715a8aa33ea57aa91ed1652aa68db858b9ffb98e Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 20 Apr 2026 12:36:16 -0700 Subject: [PATCH 05/15] feat: LLM reply draft, tiers BYOK gate, and messaging API endpoints (#74) --- app/wizard/tiers.py | 2 + dev-api.py | 167 +++++++++++++++++++++++++++++++++++++ scripts/llm_reply_draft.py | 42 ++++++++++ 3 files changed, 211 insertions(+) create mode 100644 scripts/llm_reply_draft.py diff --git a/app/wizard/tiers.py b/app/wizard/tiers.py index 2b04ab9..fa86d9a 100644 --- a/app/wizard/tiers.py +++ b/app/wizard/tiers.py @@ -49,6 +49,7 @@ FEATURES: dict[str, str] = { "company_research": "paid", "interview_prep": "paid", "survey_assistant": "paid", + "llm_reply_draft": "paid", # Orchestration / infrastructure — stays gated "email_classifier": "paid", @@ -81,6 +82,7 @@ BYOK_UNLOCKABLE: frozenset[str] = frozenset({ "company_research", "interview_prep", "survey_assistant", + "llm_reply_draft", }) # Demo mode flag — read from environment at module load time. diff --git a/dev-api.py b/dev-api.py index a639b33..06e092e 100644 --- a/dev-api.py +++ b/dev-api.py @@ -4178,3 +4178,170 @@ def wizard_complete(): return {"ok": True} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + + +# ── Messaging models ────────────────────────────────────────────────────────── + +class MessageCreateBody(BaseModel): + job_id: Optional[int] = None + job_contact_id: Optional[int] = None + type: str = "email" + direction: Optional[str] = None + subject: Optional[str] = None + body: Optional[str] = None + from_addr: Optional[str] = None + to_addr: Optional[str] = None + template_id: Optional[int] = None + + +class TemplateCreateBody(BaseModel): + title: str + category: str = "custom" + subject_template: Optional[str] = None + body_template: str + + +class TemplateUpdateBody(BaseModel): + title: Optional[str] = None + category: Optional[str] = None + subject_template: Optional[str] = None + body_template: Optional[str] = None + + +# ── Messaging (MIT) ─────────────────────────────────────────────────────────── + +@app.get("/api/messages") +def get_messages( + job_id: Optional[int] = None, + type: Optional[str] = None, + direction: Optional[str] = None, + limit: int = 100, +): + from scripts.messaging import list_messages + return list_messages( + Path(_request_db.get() or DB_PATH), + job_id=job_id, type=type, direction=direction, limit=limit, + ) + + +@app.post("/api/messages") +def post_message(body: MessageCreateBody): + from scripts.messaging import create_message + return create_message(Path(_request_db.get() or DB_PATH), **body.model_dump()) + + +@app.delete("/api/messages/{message_id}") +def del_message(message_id: int): + from scripts.messaging import delete_message + try: + delete_message(Path(_request_db.get() or DB_PATH), message_id) + return {"ok": True} + except KeyError: + raise HTTPException(404, "message not found") + + +@app.get("/api/message-templates") +def get_templates(): + from scripts.messaging import list_templates + return list_templates(Path(_request_db.get() or DB_PATH)) + + +@app.post("/api/message-templates") +def post_template(body: TemplateCreateBody): + from scripts.messaging import create_template + return create_template(Path(_request_db.get() or DB_PATH), **body.model_dump()) + + +@app.put("/api/message-templates/{template_id}") +def put_template(template_id: int, body: TemplateUpdateBody): + from scripts.messaging import update_template + try: + return update_template( + Path(_request_db.get() or DB_PATH), + template_id, + **body.model_dump(exclude_none=True), + ) + except PermissionError: + raise HTTPException(403, "cannot modify built-in templates") + except KeyError: + raise HTTPException(404, "template not found") + + +@app.delete("/api/message-templates/{template_id}") +def del_template(template_id: int): + from scripts.messaging import delete_template + try: + delete_template(Path(_request_db.get() or DB_PATH), template_id) + return {"ok": True} + except PermissionError: + raise HTTPException(403, "cannot delete built-in templates") + except KeyError: + raise HTTPException(404, "template not found") + + +# ── LLM Reply Draft (BSL 1.1) ───────────────────────────────────────────────── + +def _get_effective_tier(request: Request) -> str: + """Resolve effective tier from request header or environment.""" + header_tier = request.headers.get("X-CF-Tier") + if header_tier: + return header_tier + from app.wizard.tiers import effective_tier + return effective_tier() + + +@app.post("/api/contacts/{contact_id}/draft-reply") +def draft_reply(contact_id: int, request: Request): + """Generate an LLM draft reply for an inbound job_contacts row. Tier-gated.""" + from app.wizard.tiers import can_use, has_configured_llm + from scripts.messaging import create_message + from scripts.llm_reply_draft import generate_draft_reply + + db_path = Path(_request_db.get() or DB_PATH) + tier = _get_effective_tier(request) + if not can_use(tier, "llm_reply_draft", has_byok=has_configured_llm()): + raise HTTPException(402, detail={"error": "tier_required", "min_tier": "free+byok"}) + + con = _get_db() + row = con.execute("SELECT * FROM job_contacts WHERE id=?", (contact_id,)).fetchone() + con.close() + if not row: + raise HTTPException(404, "contact not found") + + profile = _imitate_load_profile() + user_name = getattr(profile, "name", "") or "" + target_role = getattr(profile, "target_role", "") or "" + + cfg_path = db_path.parent / "config" / "llm.yaml" + draft_body = generate_draft_reply( + subject=row["subject"] or "", + from_addr=row["from_addr"] or "", + body=row["body"] or "", + user_name=user_name, + target_role=target_role, + config_path=cfg_path if cfg_path.exists() else None, + ) + msg = create_message( + db_path, + job_id=row["job_id"], + job_contact_id=contact_id, + type="draft", + direction="outbound", + subject=f"Re: {row['subject'] or ''}".strip(), + body=draft_body, + to_addr=row["from_addr"], + template_id=None, + from_addr=None, + ) + return {"message_id": msg["id"]} + + +@app.post("/api/messages/{message_id}/approve") +def approve_message_endpoint(message_id: int): + """Set approved_at=now(). Returns approved body for copy-to-clipboard.""" + from scripts.messaging import approve_message + try: + msg = approve_message(Path(_request_db.get() or DB_PATH), message_id) + return {"body": msg["body"], "approved_at": msg["approved_at"]} + except KeyError: + raise HTTPException(404, "message not found") diff --git a/scripts/llm_reply_draft.py b/scripts/llm_reply_draft.py new file mode 100644 index 0000000..305635f --- /dev/null +++ b/scripts/llm_reply_draft.py @@ -0,0 +1,42 @@ +# BSL 1.1 — see LICENSE-BSL +"""LLM-assisted reply draft generation for inbound job contacts (BSL 1.1).""" +from __future__ import annotations + +from pathlib import Path +from typing import Optional + +_SYSTEM = ( + "You are drafting a professional email reply on behalf of a job seeker. " + "Be concise and professional. Do not fabricate facts. If you are uncertain " + "about a detail, leave a [TODO: fill in] placeholder. " + "Output the reply body only — no subject line, no salutation preamble." +) + + +def _build_prompt(subject: str, from_addr: str, body: str, user_name: str, target_role: str) -> str: + return ( + f"ORIGINAL EMAIL:\n" + f"Subject: {subject}\n" + f"From: {from_addr}\n" + f"Body:\n{body}\n\n" + f"USER PROFILE CONTEXT:\n" + f"Name: {user_name}\n" + f"Target role: {target_role}\n\n" + "Write a concise, professional reply to this email." + ) + + +def generate_draft_reply( + subject: str, + from_addr: str, + body: str, + user_name: str, + target_role: str, + config_path: Optional[Path] = None, +) -> str: + """Return a draft reply body string.""" + from scripts.llm_router import LLMRouter + + router = LLMRouter(config_path=config_path) + prompt = _build_prompt(subject, from_addr, body, user_name, target_role) + return router.complete(system=_SYSTEM, user=prompt).strip() From e11750e0e6f6ca1a084bb6bdae1c73a86cd46efd Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 20 Apr 2026 12:41:45 -0700 Subject: [PATCH 06/15] test: messaging HTTP integration tests (#74) --- tests/test_messaging_integration.py | 196 ++++++++++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 tests/test_messaging_integration.py diff --git a/tests/test_messaging_integration.py b/tests/test_messaging_integration.py new file mode 100644 index 0000000..94eaf48 --- /dev/null +++ b/tests/test_messaging_integration.py @@ -0,0 +1,196 @@ +"""Integration tests for messaging endpoints.""" +import os +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from scripts.db_migrate import migrate_db + + +@pytest.fixture +def fresh_db(tmp_path, monkeypatch): + """Set up a fresh isolated DB wired to dev_api._request_db.""" + db = tmp_path / "test.db" + monkeypatch.setenv("STAGING_DB", str(db)) + migrate_db(db) + import dev_api + monkeypatch.setattr( + dev_api, + "_request_db", + type("CV", (), {"get": lambda self: str(db), "set": lambda *a: None})(), + ) + monkeypatch.setattr(dev_api, "DB_PATH", str(db)) + return db + + +@pytest.fixture +def client(fresh_db): + import dev_api + return TestClient(dev_api.app) + + +# --------------------------------------------------------------------------- +# Messages +# --------------------------------------------------------------------------- + +def test_create_and_list_message(client): + """POST /api/messages creates a row; GET /api/messages?job_id= returns it.""" + payload = { + "job_id": 1, + "type": "email", + "direction": "outbound", + "subject": "Hello recruiter", + "body": "I am very interested in this role.", + "to_addr": "recruiter@example.com", + } + resp = client.post("/api/messages", json=payload) + assert resp.status_code == 200, resp.text + created = resp.json() + assert created["subject"] == "Hello recruiter" + assert created["job_id"] == 1 + + resp = client.get("/api/messages", params={"job_id": 1}) + assert resp.status_code == 200 + messages = resp.json() + assert any(m["id"] == created["id"] for m in messages) + + +def test_delete_message(client): + """DELETE removes the message; subsequent GET no longer returns it.""" + resp = client.post("/api/messages", json={"type": "email", "direction": "outbound", "body": "bye"}) + assert resp.status_code == 200 + msg_id = resp.json()["id"] + + resp = client.delete(f"/api/messages/{msg_id}") + assert resp.status_code == 200 + assert resp.json()["ok"] is True + + resp = client.get("/api/messages") + assert resp.status_code == 200 + ids = [m["id"] for m in resp.json()] + assert msg_id not in ids + + +def test_delete_message_not_found(client): + """DELETE /api/messages/9999 returns 404.""" + resp = client.delete("/api/messages/9999") + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# Templates +# --------------------------------------------------------------------------- + +def test_list_templates_has_builtins(client): + """GET /api/message-templates includes the seeded built-in keys.""" + resp = client.get("/api/message-templates") + assert resp.status_code == 200 + templates = resp.json() + keys = {t["key"] for t in templates} + assert "follow_up" in keys + assert "thank_you" in keys + + +def test_template_create_update_delete(client): + """Full lifecycle: create → update title → delete a user-defined template.""" + # Create + resp = client.post("/api/message-templates", json={ + "title": "My Template", + "category": "custom", + "body_template": "Hello {{name}}", + }) + assert resp.status_code == 200 + tmpl = resp.json() + assert tmpl["title"] == "My Template" + assert tmpl["is_builtin"] == 0 + tmpl_id = tmpl["id"] + + # Update title + resp = client.put(f"/api/message-templates/{tmpl_id}", json={"title": "Updated Title"}) + assert resp.status_code == 200 + assert resp.json()["title"] == "Updated Title" + + # Delete + resp = client.delete(f"/api/message-templates/{tmpl_id}") + assert resp.status_code == 200 + assert resp.json()["ok"] is True + + # Confirm gone + resp = client.get("/api/message-templates") + ids = [t["id"] for t in resp.json()] + assert tmpl_id not in ids + + +def test_builtin_template_put_returns_403(client): + """PUT on a built-in template returns 403.""" + resp = client.get("/api/message-templates") + builtin = next(t for t in resp.json() if t["is_builtin"] == 1) + resp = client.put(f"/api/message-templates/{builtin['id']}", json={"title": "Hacked"}) + assert resp.status_code == 403 + + +def test_builtin_template_delete_returns_403(client): + """DELETE on a built-in template returns 403.""" + resp = client.get("/api/message-templates") + builtin = next(t for t in resp.json() if t["is_builtin"] == 1) + resp = client.delete(f"/api/message-templates/{builtin['id']}") + assert resp.status_code == 403 + + +# --------------------------------------------------------------------------- +# Draft reply (tier gate) +# --------------------------------------------------------------------------- + +def test_draft_without_llm_returns_402(fresh_db, monkeypatch): + """POST /api/contacts/{id}/draft-reply with free tier + no LLM configured returns 402.""" + import sqlite3 + import dev_api + + # Insert a job_contacts row so the contact_id exists + con = sqlite3.connect(fresh_db) + con.execute( + "INSERT INTO job_contacts (job_id, direction, subject, from_addr, body) " + "VALUES (NULL, 'inbound', 'Test subject', 'hr@example.com', 'We would like to schedule...')" + ) + con.commit() + contact_id = con.execute("SELECT last_insert_rowid()").fetchone()[0] + con.close() + + # Ensure has_configured_llm returns False at both import locations + monkeypatch.setattr("app.wizard.tiers.has_configured_llm", lambda *a, **kw: False) + + client = TestClient(dev_api.app) + resp = client.post( + f"/api/contacts/{contact_id}/draft-reply", + headers={"X-CF-Tier": "free"}, + ) + assert resp.status_code == 402 + + +# --------------------------------------------------------------------------- +# Approve +# --------------------------------------------------------------------------- + +def test_approve_message(client): + """POST /api/messages then POST /api/messages/{id}/approve returns body + approved_at.""" + resp = client.post("/api/messages", json={ + "type": "draft", + "direction": "outbound", + "body": "This is my draft reply.", + }) + assert resp.status_code == 200 + msg_id = resp.json()["id"] + assert resp.json()["approved_at"] is None + + resp = client.post(f"/api/messages/{msg_id}/approve") + assert resp.status_code == 200 + data = resp.json() + assert data["body"] == "This is my draft reply." + assert data["approved_at"] is not None + + +def test_approve_message_not_found(client): + """POST /api/messages/9999/approve returns 404.""" + resp = client.post("/api/messages/9999/approve") + assert resp.status_code == 404 From dfcc264abac365291b2c62d9b15e1e6cfaedcb7e Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 20 Apr 2026 12:45:47 -0700 Subject: [PATCH 07/15] test: use db.add_contact helper in integration test fixture Replace raw sqlite3 INSERT in test_draft_without_llm_returns_402 with add_contact() so the fixture stays in sync with schema changes automatically. --- tests/test_messaging_integration.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/test_messaging_integration.py b/tests/test_messaging_integration.py index 94eaf48..fc4dd19 100644 --- a/tests/test_messaging_integration.py +++ b/tests/test_messaging_integration.py @@ -144,18 +144,18 @@ def test_builtin_template_delete_returns_403(client): def test_draft_without_llm_returns_402(fresh_db, monkeypatch): """POST /api/contacts/{id}/draft-reply with free tier + no LLM configured returns 402.""" - import sqlite3 import dev_api + from scripts.db import add_contact - # Insert a job_contacts row so the contact_id exists - con = sqlite3.connect(fresh_db) - con.execute( - "INSERT INTO job_contacts (job_id, direction, subject, from_addr, body) " - "VALUES (NULL, 'inbound', 'Test subject', 'hr@example.com', 'We would like to schedule...')" + # Insert a job_contacts row via the db helper so schema changes stay in sync + contact_id = add_contact( + fresh_db, + job_id=None, + direction="inbound", + subject="Test subject", + from_addr="hr@example.com", + body="We would like to schedule...", ) - con.commit() - contact_id = con.execute("SELECT last_insert_rowid()").fetchone()[0] - con.close() # Ensure has_configured_llm returns False at both import locations monkeypatch.setattr("app.wizard.tiers.has_configured_llm", lambda *a, **kw: False) From 47a40c9e36c79fdee60d5cca9f5eaa11d198dd4b Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 20 Apr 2026 12:48:15 -0700 Subject: [PATCH 08/15] feat: messaging Pinia store (#74) --- web/src/stores/messaging.ts | 161 ++++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 web/src/stores/messaging.ts diff --git a/web/src/stores/messaging.ts b/web/src/stores/messaging.ts new file mode 100644 index 0000000..a118c90 --- /dev/null +++ b/web/src/stores/messaging.ts @@ -0,0 +1,161 @@ +// web/src/stores/messaging.ts +import { ref } from 'vue' +import { defineStore } from 'pinia' +import { useApiFetch } from '../composables/useApi' + +export interface Message { + id: number + job_id: number | null + job_contact_id: number | null + type: 'call_note' | 'in_person' | 'email' | 'draft' + direction: 'inbound' | 'outbound' | null + subject: string | null + body: string | null + from_addr: string | null + to_addr: string | null + logged_at: string + approved_at: string | null + template_id: number | null + osprey_call_id: string | null +} + +export interface MessageTemplate { + id: number + key: string | null + title: string + category: string + subject_template: string | null + body_template: string + is_builtin: number + is_community: number + community_source: string | null + created_at: string + updated_at: string +} + +export const useMessagingStore = defineStore('messaging', () => { + const messages = ref([]) + const templates = ref([]) + const loading = ref(false) + const saving = ref(false) + const error = ref(null) + const draftPending = ref(null) // message_id of pending draft + + async function fetchMessages(jobId: number) { + loading.value = true + error.value = null + const { data, error: fetchErr } = await useApiFetch( + `/api/messages?job_id=${jobId}` + ) + loading.value = false + if (fetchErr) { error.value = 'Could not load messages.'; return } + messages.value = data ?? [] + } + + async function fetchTemplates() { + const { data, error: fetchErr } = await useApiFetch( + '/api/message-templates' + ) + if (!fetchErr) templates.value = data ?? [] + } + + async function createMessage(payload: Omit) { + saving.value = true + error.value = null + const { data, error: fetchErr } = await useApiFetch( + '/api/messages', + { method: 'POST', body: JSON.stringify(payload) } + ) + saving.value = false + if (fetchErr || !data) { error.value = 'Failed to save message.'; return null } + messages.value = [data, ...messages.value] + return data + } + + async function deleteMessage(id: number) { + const { error: fetchErr } = await useApiFetch( + `/api/messages/${id}`, + { method: 'DELETE' } + ) + if (fetchErr) { error.value = 'Failed to delete message.'; return } + messages.value = messages.value.filter(m => m.id !== id) + } + + async function createTemplate(payload: Pick & { subject_template?: string }) { + saving.value = true + const { data, error: fetchErr } = await useApiFetch( + '/api/message-templates', + { method: 'POST', body: JSON.stringify(payload) } + ) + saving.value = false + if (fetchErr || !data) { error.value = 'Failed to create template.'; return null } + templates.value = [...templates.value, data] + return data + } + + async function updateTemplate(id: number, payload: Partial>) { + saving.value = true + const { data, error: fetchErr } = await useApiFetch( + `/api/message-templates/${id}`, + { method: 'PUT', body: JSON.stringify(payload) } + ) + saving.value = false + if (fetchErr || !data) { error.value = 'Failed to update template.'; return null } + templates.value = templates.value.map(t => t.id === id ? data : t) + return data + } + + async function deleteTemplate(id: number) { + const { error: fetchErr } = await useApiFetch( + `/api/message-templates/${id}`, + { method: 'DELETE' } + ) + if (fetchErr) { error.value = 'Failed to delete template.'; return } + templates.value = templates.value.filter(t => t.id !== id) + } + + async function requestDraft(contactId: number) { + loading.value = true + error.value = null + const { data, error: fetchErr } = await useApiFetch<{ message_id: number }>( + `/api/contacts/${contactId}/draft-reply`, + { method: 'POST' } + ) + loading.value = false + if (fetchErr || !data) { + error.value = 'Could not generate draft. Check LLM settings.' + return null + } + draftPending.value = data.message_id + return data.message_id + } + + async function approveDraft(messageId: number): Promise { + const { data, error: fetchErr } = await useApiFetch<{ body: string; approved_at: string }>( + `/api/messages/${messageId}/approve`, + { method: 'POST' } + ) + if (fetchErr || !data) { error.value = 'Approve failed.'; return null } + messages.value = messages.value.map(m => + m.id === messageId ? { ...m, approved_at: data.approved_at } : m + ) + draftPending.value = null + return data.body + } + + function clear() { + messages.value = [] + templates.value = [] + loading.value = false + saving.value = false + error.value = null + draftPending.value = null + } + + return { + messages, templates, loading, saving, error, draftPending, + fetchMessages, fetchTemplates, createMessage, deleteMessage, + createTemplate, updateTemplate, deleteTemplate, + requestDraft, approveDraft, clear, + } +}) From 222eb4a0880fc9e8cf0f828350ff7ff9794cd68b Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 20 Apr 2026 12:50:51 -0700 Subject: [PATCH 09/15] fix: messaging store error handling and Content-Type headers --- web/src/stores/messaging.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/web/src/stores/messaging.ts b/web/src/stores/messaging.ts index a118c90..28f8748 100644 --- a/web/src/stores/messaging.ts +++ b/web/src/stores/messaging.ts @@ -56,7 +56,8 @@ export const useMessagingStore = defineStore('messaging', () => { const { data, error: fetchErr } = await useApiFetch( '/api/message-templates' ) - if (!fetchErr) templates.value = data ?? [] + if (fetchErr) { error.value = 'Could not load templates.'; return } + templates.value = data ?? [] } async function createMessage(payload: Omit) { @@ -64,7 +65,7 @@ export const useMessagingStore = defineStore('messaging', () => { error.value = null const { data, error: fetchErr } = await useApiFetch( '/api/messages', - { method: 'POST', body: JSON.stringify(payload) } + { method: 'POST', body: JSON.stringify(payload), headers: { 'Content-Type': 'application/json' } } ) saving.value = false if (fetchErr || !data) { error.value = 'Failed to save message.'; return null } @@ -83,9 +84,10 @@ export const useMessagingStore = defineStore('messaging', () => { async function createTemplate(payload: Pick & { subject_template?: string }) { saving.value = true + error.value = null const { data, error: fetchErr } = await useApiFetch( '/api/message-templates', - { method: 'POST', body: JSON.stringify(payload) } + { method: 'POST', body: JSON.stringify(payload), headers: { 'Content-Type': 'application/json' } } ) saving.value = false if (fetchErr || !data) { error.value = 'Failed to create template.'; return null } @@ -95,9 +97,10 @@ export const useMessagingStore = defineStore('messaging', () => { async function updateTemplate(id: number, payload: Partial>) { saving.value = true + error.value = null const { data, error: fetchErr } = await useApiFetch( `/api/message-templates/${id}`, - { method: 'PUT', body: JSON.stringify(payload) } + { method: 'PUT', body: JSON.stringify(payload), headers: { 'Content-Type': 'application/json' } } ) saving.value = false if (fetchErr || !data) { error.value = 'Failed to update template.'; return null } @@ -119,7 +122,7 @@ export const useMessagingStore = defineStore('messaging', () => { error.value = null const { data, error: fetchErr } = await useApiFetch<{ message_id: number }>( `/api/contacts/${contactId}/draft-reply`, - { method: 'POST' } + { method: 'POST', headers: { 'Content-Type': 'application/json' } } ) loading.value = false if (fetchErr || !data) { From 8df3297ab6632e935049f7f2ced144697e0027a3 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 20 Apr 2026 12:52:19 -0700 Subject: [PATCH 10/15] feat: MessageLogModal component (#74) --- web/src/components/MessageLogModal.vue | 200 +++++++++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 web/src/components/MessageLogModal.vue diff --git a/web/src/components/MessageLogModal.vue b/web/src/components/MessageLogModal.vue new file mode 100644 index 0000000..71120a3 --- /dev/null +++ b/web/src/components/MessageLogModal.vue @@ -0,0 +1,200 @@ + +