feat: messaging tab — messages, templates, draft reply (#74)
Some checks failed
CI / Backend (Python) (push) Failing after 1m48s
CI / Frontend (Vue) (push) Failing after 21s
Mirror / mirror (push) Failing after 7s

Merges feature/messaging-tab into main.

Features:
- Migration 008: messages + message_templates tables with 4 built-in templates
- API endpoints: CRUD for messages and templates, draft-reply (BYOK tier gate), approve
- PUT /api/messages/{id} for draft body persistence
- Pinia store (messaging.ts) with full action set
- MessageLogModal: log calls and in-person meetings with backdated timestamps
- MessageTemplateModal: apply (with token substitution + highlight), create, edit
- MessagingView: two-panel job list + UNION timeline (contacts + messages), Osprey easter egg
- Router: /messages route, /contacts redirect, nav renamed Messages
- Integration test suite (8 tests, 766 total passing)
- CRITICAL fix: _get_effective_tier() no longer trusts X-CF-Tier client header
This commit is contained in:
pyr0ball 2026-04-20 20:26:41 -07:00
commit 63334f5278
15 changed files with 2596 additions and 102 deletions

View file

@ -49,6 +49,7 @@ FEATURES: dict[str, str] = {
"company_research": "paid", "company_research": "paid",
"interview_prep": "paid", "interview_prep": "paid",
"survey_assistant": "paid", "survey_assistant": "paid",
"llm_reply_draft": "paid",
# Orchestration / infrastructure — stays gated # Orchestration / infrastructure — stays gated
"email_classifier": "paid", "email_classifier": "paid",
@ -81,6 +82,7 @@ BYOK_UNLOCKABLE: frozenset[str] = frozenset({
"company_research", "company_research",
"interview_prep", "interview_prep",
"survey_assistant", "survey_assistant",
"llm_reply_draft",
}) })
# Demo mode flag — read from environment at module load time. # Demo mode flag — read from environment at module load time.

View file

@ -26,7 +26,7 @@ import yaml
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Request, Response, UploadFile from fastapi import FastAPI, HTTPException, Query, Request, Response, UploadFile
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel from pydantic import BaseModel
@ -4178,3 +4178,183 @@ def wizard_complete():
return {"ok": True} return {"ok": True}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(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
logged_at: Optional[str] = None
class MessageUpdateBody(BaseModel):
body: str
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 = Query(default=100, ge=1, le=1000),
):
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.put("/api/messages/{message_id}")
def put_message(message_id: int, body: MessageUpdateBody):
from scripts.messaging import update_message_body
try:
return update_message_body(Path(_request_db.get() or DB_PATH), message_id, body.body)
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() -> str:
"""Resolve effective tier: Heimdall in cloud mode, APP_TIER env var in single-tenant."""
if _CLOUD_MODE:
return _resolve_cloud_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):
"""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()
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")

View file

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

View file

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

285
scripts/messaging.py Normal file
View file

@ -0,0 +1,285 @@
"""
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],
logged_at: Optional[str] = None,
) -> 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, logged_at, template_id)
VALUES
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(job_id, job_contact_id, type, direction, subject, body,
from_addr, to_addr, logged_at or _now_utc(), 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()
def update_message_body(db_path: Path, message_id: int, body: str) -> dict:
"""Update the body text of a draft message before approval. Returns updated row."""
con = _connect(db_path)
try:
row = con.execute("SELECT id FROM messages WHERE id=?", (message_id,)).fetchone()
if not row:
raise KeyError(f"message {message_id} not found")
con.execute("UPDATE messages SET body=? WHERE id=?", (body, message_id))
con.commit()
updated = con.execute("SELECT * FROM messages WHERE id=?", (message_id,)).fetchone()
return dict(updated)
finally:
con.close()

View file

@ -19,7 +19,8 @@ def tmp_db(tmp_path):
match_score REAL, keyword_gaps TEXT, status TEXT, match_score REAL, keyword_gaps TEXT, status TEXT,
interview_date TEXT, rejection_stage TEXT, interview_date TEXT, rejection_stage TEXT,
applied_at TEXT, phone_screen_at TEXT, interviewing_at 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 ( CREATE TABLE job_contacts (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,

View file

@ -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 import pytest
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from scripts.db_migrate import migrate_db
@pytest.fixture @pytest.fixture
def client(): def fresh_db(tmp_path, monkeypatch):
import sys """Isolated DB + dev_api wired to it via _request_db and DB_PATH."""
sys.path.insert(0, "/Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa") db = tmp_path / "test.db"
from dev_api import app migrate_db(db)
return TestClient(app) 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): def test_vision_health_available(client):
"""Returns available=true when vision service responds 200.""" """Returns available=true when vision service responds 200."""
@ -32,133 +50,182 @@ def test_vision_health_unavailable(client):
assert resp.json() == {"available": False} assert resp.json() == {"available": False}
# ── POST /api/jobs/{id}/survey/analyze ────────────────────────────────────── # ── POST /api/jobs/{id}/survey/analyze ──────────────────────────────────────
def test_analyze_text_quick(client): def test_analyze_queues_task_and_returns_task_id(client):
"""Text mode quick analysis returns output and source=text_paste.""" """POST analyze queues a background task and returns task_id + is_new."""
mock_router = MagicMock() with patch("scripts.task_runner.submit_task", return_value=(42, True)) as mock_submit:
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):
resp = client.post("/api/jobs/1/survey/analyze", json={ resp = client.post("/api/jobs/1/survey/analyze", json={
"text": "Q1: Do you prefer teamwork?\nA. Solo B. Together", "text": "Q1: Do you prefer teamwork?\nA. Solo B. Together",
"mode": "quick", "mode": "quick",
}) })
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()
assert data["source"] == "text_paste" assert data["task_id"] == 42
assert "B" in data["output"] assert data["is_new"] is True
# System prompt must be passed for text path # submit_task called with survey_analyze type
call_kwargs = mock_router.complete.call_args[1] call_kwargs = mock_submit.call_args
assert "system" in call_kwargs assert call_kwargs.kwargs["task_type"] == "survey_analyze"
assert "culture-fit survey" in call_kwargs["system"] 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): def test_analyze_silently_attaches_to_existing_task(client):
"""Text mode detailed analysis passes correct prompt.""" """is_new=False when task already running for same input."""
mock_router = MagicMock() with patch("scripts.task_runner.submit_task", return_value=(7, False)):
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):
resp = client.post("/api/jobs/1/survey/analyze", json={ resp = client.post("/api/jobs/1/survey/analyze", json={
"text": "Q1: Describe your work style.", "text": "Q1: test", "mode": "quick",
"mode": "detailed",
}) })
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["source"] == "text_paste" assert resp.json()["is_new"] is False
def test_analyze_image(client): def test_analyze_invalid_mode_returns_400(client):
"""Image mode routes through vision path with NO system prompt.""" resp = client.post("/api/jobs/1/survey/analyze", json={"text": "Q1: test", "mode": "wrong"})
mock_router = MagicMock() assert resp.status_code == 400
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_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={ resp = client.post("/api/jobs/1/survey/analyze", json={
"image_b64": "aGVsbG8=", "image_b64": "aGVsbG8=",
"mode": "quick", "mode": "quick",
}) })
assert resp.status_code == 200 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() data = resp.json()
assert data["source"] == "screenshot" assert data["status"] == "completed"
# No system prompt on vision path assert data["result"]["source"] == "text_paste"
call_kwargs = mock_router.complete.call_args[1] assert "B" in data["result"]["output"]
assert "system" not in call_kwargs assert data["message"] is None
def test_analyze_llm_failure(client): def test_task_poll_completed_screenshot(client, fresh_db):
"""Returns 500 when LLM raises an exception.""" """Completed task with image result returns source=screenshot."""
mock_router = MagicMock() result_json = json.dumps({"output": "1. C — collaborative", "source": "screenshot"})
mock_router.complete.side_effect = Exception("LLM unavailable") con = sqlite3.connect(fresh_db)
mock_router.config.get.return_value = [] con.execute(
with patch("dev_api.LLMRouter", return_value=mock_router): "INSERT INTO background_tasks (task_type, job_id, status, error) VALUES (?,?,?,?)",
resp = client.post("/api/jobs/1/survey/analyze", json={ ("survey_analyze", 1, "completed", result_json),
"text": "Q1: test", )
"mode": "quick", task_id = con.execute("SELECT last_insert_rowid()").fetchone()[0]
}) con.commit(); con.close()
assert resp.status_code == 500
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): def test_save_response_text(client):
"""Save text response writes to DB and returns id.""" """Save a text-mode survey response returns an id."""
mock_db = MagicMock() resp = client.post("/api/jobs/1/survey/responses", json={
with patch("dev_api._get_db", return_value=mock_db): "survey_name": "Culture Fit",
with patch("dev_api.insert_survey_response", return_value=42) as mock_insert: "mode": "quick",
resp = client.post("/api/jobs/1/survey/responses", json={ "source": "text_paste",
"mode": "quick", "raw_input": "Q1: Teamwork?",
"source": "text_paste", "llm_output": "1. B is best",
"raw_input": "Q1: test question", "reported_score": "85",
"llm_output": "1. B — good reason", })
})
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["id"] == 42 assert "id" in resp.json()
# 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
def test_save_response_with_image(client, tmp_path, monkeypatch): def test_save_response_with_image(client):
"""Save image response writes PNG file and stores path in DB.""" """Save a screenshot-mode survey response returns an id."""
monkeypatch.setenv("STAGING_DB", str(tmp_path / "test.db")) resp = client.post("/api/jobs/1/survey/responses", json={
with patch("dev_api.insert_survey_response", return_value=7) as mock_insert: "survey_name": None,
with patch("dev_api.Path") as mock_path_cls: "mode": "quick",
mock_path_cls.return_value.__truediv__ = lambda s, o: tmp_path / o "source": "screenshot",
resp = client.post("/api/jobs/1/survey/responses", json={ "image_b64": "aGVsbG8=",
"mode": "quick", "llm_output": "1. C collaborative",
"source": "screenshot", "reported_score": None,
"image_b64": "aGVsbG8=", # valid base64 })
"llm_output": "1. B — reason",
})
assert resp.status_code == 200 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): def test_get_history_empty(client):
"""Returns empty list when no history exists.""" """History is empty for a fresh job."""
with patch("dev_api.get_survey_responses", return_value=[]): resp = client.get("/api/jobs/1/survey/responses")
resp = client.get("/api/jobs/1/survey/responses")
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json() == [] assert resp.json() == []
def test_get_history_populated(client): def test_get_history_populated(client):
"""Returns history rows newest first.""" """History returns all saved responses for a job in reverse order."""
rows = [ for i in range(2):
{"id": 2, "survey_name": "Round 2", "mode": "detailed", "source": "text_paste", client.post("/api/jobs/1/survey/responses", json={
"raw_input": None, "image_path": None, "llm_output": "Option A is best", "survey_name": f"Survey {i}",
"reported_score": "90%", "received_at": "2026-03-21T14:00:00", "created_at": "2026-03-21T14:00:01"}, "mode": "quick",
{"id": 1, "survey_name": "Round 1", "mode": "quick", "source": "text_paste", "source": "text_paste",
"raw_input": "Q1: test", "image_path": None, "llm_output": "1. B", "llm_output": f"Output {i}",
"reported_score": None, "received_at": "2026-03-21T12:00:00", "created_at": "2026-03-21T12:00:01"}, })
] resp = client.get("/api/jobs/1/survey/responses")
with patch("dev_api.get_survey_responses", return_value=rows):
resp = client.get("/api/jobs/1/survey/responses")
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() assert len(resp.json()) == 2
assert len(data) == 2
assert data[0]["id"] == 2
assert data[0]["survey_name"] == "Round 2"

399
tests/test_messaging.py Normal file
View file

@ -0,0 +1,399 @@
"""
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")
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 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)

View file

@ -0,0 +1,195 @@
"""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 dev_api
from scripts.db import add_contact
# 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...",
)
# Ensure has_configured_llm returns False at both import locations
monkeypatch.setattr("app.wizard.tiers.has_configured_llm", lambda *a, **kw: False)
# Force free tier via the tiers module (not via header — header is no longer trusted)
monkeypatch.setattr("app.wizard.tiers.effective_tier", lambda: "free")
client = TestClient(dev_api.app)
resp = client.post(f"/api/contacts/{contact_id}/draft-reply")
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

View file

@ -157,7 +157,7 @@ const navLinks = computed(() => [
{ to: '/apply', icon: PencilSquareIcon, label: 'Apply' }, { to: '/apply', icon: PencilSquareIcon, label: 'Apply' },
{ to: '/resumes', icon: DocumentTextIcon, label: 'Resumes' }, { to: '/resumes', icon: DocumentTextIcon, label: 'Resumes' },
{ to: '/interviews', icon: CalendarDaysIcon, label: 'Interviews' }, { to: '/interviews', icon: CalendarDaysIcon, label: 'Interviews' },
{ to: '/contacts', icon: UsersIcon, label: 'Contacts' }, { to: '/messages', icon: UsersIcon, label: 'Messages' },
{ to: '/references', icon: IdentificationIcon, label: 'References' }, { to: '/references', icon: IdentificationIcon, label: 'References' },
{ to: '/digest', icon: NewspaperIcon, label: 'Digest', { to: '/digest', icon: NewspaperIcon, label: 'Digest',
badge: digestStore.entries.length || undefined }, badge: digestStore.entries.length || undefined },

View file

@ -0,0 +1,200 @@
<!-- web/src/components/MessageLogModal.vue -->
<template>
<Teleport to="body">
<div
v-if="show"
class="modal-backdrop"
@click.self="emit('close')"
>
<div
ref="dialogEl"
class="modal-dialog"
role="dialog"
aria-modal="true"
:aria-label="title"
tabindex="-1"
@keydown.esc="emit('close')"
>
<header class="modal-header">
<h2 class="modal-title">{{ title }}</h2>
<button class="modal-close" @click="emit('close')" aria-label="Close"></button>
</header>
<form class="modal-body" @submit.prevent="handleSubmit">
<!-- Direction (not shown for pure notes) -->
<div v-if="type !== 'in_person'" class="field">
<label class="field-label" for="log-direction">Direction</label>
<select id="log-direction" v-model="form.direction" class="field-select">
<option value="">-- not specified --</option>
<option value="inbound">Inbound (they called me)</option>
<option value="outbound">Outbound (I called them)</option>
</select>
</div>
<div class="field">
<label class="field-label" for="log-subject">Subject (optional)</label>
<input id="log-subject" v-model="form.subject" type="text" class="field-input" />
</div>
<div class="field">
<label class="field-label" for="log-body">
Notes <span class="field-required" aria-hidden="true">*</span>
</label>
<textarea
id="log-body"
v-model="form.body"
class="field-textarea"
rows="5"
required
aria-required="true"
/>
</div>
<div class="field">
<label class="field-label" for="log-date">Date/time</label>
<input id="log-date" v-model="form.logged_at" type="datetime-local" class="field-input" />
</div>
<p v-if="error" class="modal-error" role="alert">{{ error }}</p>
<footer class="modal-footer">
<button type="button" class="btn btn--ghost" @click="emit('close')">Cancel</button>
<button type="submit" class="btn btn--primary" :disabled="saving">
{{ saving ? 'Saving…' : 'Save' }}
</button>
</footer>
</form>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import { useMessagingStore } from '../stores/messaging'
const props = defineProps<{
show: boolean
jobId: number
type: 'call_note' | 'in_person'
}>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'saved'): void
}>()
const store = useMessagingStore()
const dialogEl = ref<HTMLElement | null>(null)
const saving = ref(false)
const error = ref<string | null>(null)
const title = computed(() =>
props.type === 'call_note' ? 'Log a call' : 'Log an in-person note'
)
const form = ref({
direction: '',
subject: '',
body: '',
logged_at: '',
})
// Focus the dialog when it opens; compute localNow fresh each time
watch(() => props.show, async (val) => {
if (val) {
const now = new Date()
const localNow = new Date(now.getTime() - now.getTimezoneOffset() * 60000)
.toISOString()
.slice(0, 16)
error.value = null
form.value = { direction: '', subject: '', body: '', logged_at: localNow }
await nextTick()
dialogEl.value?.focus()
}
})
async function handleSubmit() {
if (!form.value.body.trim()) { error.value = 'Notes are required.'; return }
saving.value = true
error.value = null
const result = await store.createMessage({
job_id: props.jobId,
job_contact_id: null,
type: props.type,
direction: form.value.direction || null,
subject: form.value.subject || null,
body: form.value.body,
from_addr: null,
to_addr: null,
template_id: null,
logged_at: form.value.logged_at || undefined,
})
saving.value = false
if (result) emit('saved')
else error.value = store.error ?? 'Save failed.'
}
</script>
<style scoped>
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal-dialog {
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
width: min(480px, 95vw);
max-height: 90vh;
overflow-y: auto;
outline: none;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--color-border-light);
}
.modal-title { font-size: var(--text-lg); font-weight: 600; margin: 0; }
.modal-close {
background: none; border: none; cursor: pointer;
color: var(--color-text-muted); font-size: var(--text-lg);
padding: var(--space-1); border-radius: var(--radius-sm);
min-width: 32px; min-height: 32px;
}
.modal-close:hover { background: var(--color-surface-alt); }
.modal-body { padding: var(--space-4) var(--space-5); display: flex; flex-direction: column; gap: var(--space-4); }
.field { display: flex; flex-direction: column; gap: var(--space-1); }
.field-label { font-size: var(--text-sm); font-weight: 500; color: var(--color-text-muted); }
.field-required { color: var(--app-accent); }
.field-input, .field-select, .field-textarea {
padding: var(--space-2) var(--space-3);
background: var(--color-surface-alt);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text);
font-size: var(--text-sm);
font-family: var(--font-body);
width: 100%;
}
.field-input:focus-visible, .field-select:focus-visible, .field-textarea:focus-visible {
outline: 2px solid var(--app-primary);
outline-offset: 2px;
}
.field-textarea { resize: vertical; }
.modal-error { color: var(--app-accent); font-size: var(--text-sm); margin: 0; }
.modal-footer { display: flex; justify-content: flex-end; gap: var(--space-3); padding-top: var(--space-2); }
.btn { padding: var(--space-2) var(--space-4); border-radius: var(--radius-md); font-size: var(--text-sm); font-weight: 500; cursor: pointer; min-height: 40px; }
.btn--primary { background: var(--app-primary); color: var(--color-surface); border: none; }
.btn--primary:hover:not(:disabled) { opacity: 0.9; }
.btn--primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn--ghost { background: none; border: 1px solid var(--color-border); color: var(--color-text); }
.btn--ghost:hover { background: var(--color-surface-alt); }
</style>

View file

@ -0,0 +1,289 @@
<!-- web/src/components/MessageTemplateModal.vue -->
<template>
<Teleport to="body">
<div
v-if="show"
class="modal-backdrop"
@click.self="emit('close')"
>
<div
ref="dialogEl"
class="modal-dialog modal-dialog--wide"
role="dialog"
aria-modal="true"
:aria-label="title"
tabindex="-1"
@keydown.esc="emit('close')"
>
<header class="modal-header">
<h2 class="modal-title">{{ title }}</h2>
<button class="modal-close" @click="emit('close')" aria-label="Close"></button>
</header>
<!-- APPLY MODE -->
<div v-if="mode === 'apply'" class="modal-body">
<div class="tpl-list" role="list" aria-label="Available templates">
<button
v-for="tpl in store.templates"
:key="tpl.id"
class="tpl-item"
:class="{ 'tpl-item--selected': selectedId === tpl.id }"
role="listitem"
@click="selectTemplate(tpl)"
>
<span class="tpl-item__icon" aria-hidden="true">
{{ tpl.is_builtin ? '🔒' : '📝' }}
</span>
<span class="tpl-item__title">{{ tpl.title }}</span>
<span class="tpl-item__cat">{{ tpl.category }}</span>
</button>
</div>
<div v-if="preview" class="tpl-preview">
<p class="tpl-preview__subject" v-if="preview.subject">
<strong>Subject:</strong> <span v-html="highlightTokens(preview.subject)" />
</p>
<pre class="tpl-preview__body" v-html="highlightTokens(preview.body)" />
<div class="tpl-preview__actions">
<button class="btn btn--primary" @click="copyPreview">Copy body</button>
<button class="btn btn--ghost" @click="emit('close')">Cancel</button>
</div>
</div>
<p v-else class="tpl-hint">Select a template to preview it with your job details.</p>
</div>
<!-- CREATE / EDIT MODE -->
<form v-else class="modal-body" @submit.prevent="handleSubmit">
<div class="field">
<label class="field-label" for="tpl-title">Title *</label>
<input id="tpl-title" v-model="form.title" type="text" class="field-input" required aria-required="true" />
</div>
<div class="field">
<label class="field-label" for="tpl-category">Category</label>
<select id="tpl-category" v-model="form.category" class="field-select">
<option value="follow_up">Follow-up</option>
<option value="thank_you">Thank you</option>
<option value="accommodation">Accommodation request</option>
<option value="withdrawal">Withdrawal</option>
<option value="custom">Custom</option>
</select>
</div>
<div class="field">
<label class="field-label" for="tpl-subject">Subject template (optional)</label>
<input id="tpl-subject" v-model="form.subject_template" type="text" class="field-input"
placeholder="e.g. Following up — {{role}} application" />
</div>
<div class="field">
<label class="field-label" for="tpl-body">Body template *</label>
<p class="field-hint">Use <code>{{name}}</code>, <code>{{company}}</code>, <code>{{role}}</code>, <code>{{recruiter_name}}</code>, <code>{{date}}</code>, <code>{{accommodation_details}}</code></p>
<textarea id="tpl-body" v-model="form.body_template" class="field-textarea" rows="8"
required aria-required="true" />
</div>
<p v-if="error" class="modal-error" role="alert">{{ error }}</p>
<footer class="modal-footer">
<button type="button" class="btn btn--ghost" @click="emit('close')">Cancel</button>
<button type="submit" class="btn btn--primary" :disabled="store.saving">
{{ store.saving ? 'Saving…' : (mode === 'create' ? 'Create template' : 'Save changes') }}
</button>
</footer>
</form>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import { useMessagingStore, type MessageTemplate } from '../stores/messaging'
const props = defineProps<{
show: boolean
mode: 'apply' | 'create' | 'edit'
jobTokens?: Record<string, string> // { name, company, role, recruiter_name, date }
editTemplate?: MessageTemplate // required when mode='edit'
}>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'saved'): void
(e: 'applied', body: string): void
}>()
const store = useMessagingStore()
const dialogEl = ref<HTMLElement | null>(null)
const selectedId = ref<number | null>(null)
const error = ref<string | null>(null)
const form = ref({
title: '',
category: 'custom',
subject_template: '',
body_template: '',
})
const title = computed(() => ({
apply: 'Use a template',
create: 'Create template',
edit: 'Edit template',
}[props.mode]))
watch(() => props.show, async (val) => {
if (!val) return
error.value = null
selectedId.value = null
if (props.mode === 'edit' && props.editTemplate) {
form.value = {
title: props.editTemplate.title,
category: props.editTemplate.category,
subject_template: props.editTemplate.subject_template ?? '',
body_template: props.editTemplate.body_template,
}
} else {
form.value = { title: '', category: 'custom', subject_template: '', body_template: '' }
}
await nextTick()
dialogEl.value?.focus()
})
function substituteTokens(text: string): string {
const tokens = props.jobTokens ?? {}
return text.replace(/\{\{(\w+)\}\}/g, (_, key) => tokens[key] ?? `{{${key}}}`)
}
function highlightTokens(text: string): string {
// Remaining unresolved tokens are highlighted
const escaped = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
return escaped.replace(
/\{\{(\w+)\}\}/g,
'<mark class="token-unresolved">{{$1}}</mark>'
)
}
interface PreviewData { subject: string; body: string }
const preview = computed<PreviewData | null>(() => {
if (props.mode !== 'apply' || selectedId.value === null) return null
const tpl = store.templates.find(t => t.id === selectedId.value)
if (!tpl) return null
return {
subject: substituteTokens(tpl.subject_template ?? ''),
body: substituteTokens(tpl.body_template),
}
})
function selectTemplate(tpl: MessageTemplate) {
selectedId.value = tpl.id
}
function copyPreview() {
if (!preview.value) return
navigator.clipboard.writeText(preview.value.body)
emit('applied', preview.value.body)
emit('close')
}
async function handleSubmit() {
error.value = null
if (props.mode === 'create') {
const result = await store.createTemplate({
title: form.value.title,
category: form.value.category,
subject_template: form.value.subject_template || undefined,
body_template: form.value.body_template,
})
if (result) emit('saved')
else error.value = store.error ?? 'Save failed.'
} else if (props.mode === 'edit' && props.editTemplate) {
const result = await store.updateTemplate(props.editTemplate.id, {
title: form.value.title,
category: form.value.category,
subject_template: form.value.subject_template || undefined,
body_template: form.value.body_template,
})
if (result) emit('saved')
else error.value = store.error ?? 'Save failed.'
}
}
</script>
<style scoped>
.modal-backdrop {
position: fixed; inset: 0;
background: rgba(0,0,0,0.5);
display: flex; align-items: center; justify-content: center;
z-index: 200;
}
.modal-dialog {
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
width: min(560px, 95vw);
max-height: 90vh;
overflow-y: auto;
outline: none;
}
.modal-dialog--wide { width: min(700px, 95vw); }
.modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--color-border-light);
}
.modal-title { font-size: var(--text-lg); font-weight: 600; margin: 0; }
.modal-close {
background: none; border: none; cursor: pointer;
color: var(--color-text-muted); font-size: var(--text-lg);
padding: var(--space-1); border-radius: var(--radius-sm);
min-width: 32px; min-height: 32px;
}
.modal-close:hover { background: var(--color-surface-alt); }
.modal-body { padding: var(--space-4) var(--space-5); display: flex; flex-direction: column; gap: var(--space-4); }
.tpl-list { display: flex; flex-direction: column; gap: var(--space-1); max-height: 220px; overflow-y: auto; }
.tpl-item {
display: flex; align-items: center; gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border); border-radius: var(--radius-md);
background: var(--color-surface-alt); cursor: pointer;
text-align: left; width: 100%;
transition: border-color 150ms, background 150ms;
}
.tpl-item:hover { border-color: var(--app-primary); background: var(--app-primary-light); }
.tpl-item--selected { border-color: var(--app-primary); background: var(--app-primary-light); font-weight: 600; }
.tpl-item__title { flex: 1; font-size: var(--text-sm); }
.tpl-item__cat { font-size: var(--text-xs); color: var(--color-text-muted); text-transform: capitalize; }
.tpl-preview { border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: var(--space-4); background: var(--color-surface); }
.tpl-preview__subject { margin: 0 0 var(--space-2); font-size: var(--text-sm); }
.tpl-preview__body {
font-size: var(--text-sm); white-space: pre-wrap; font-family: var(--font-body);
margin: 0 0 var(--space-3); max-height: 200px; overflow-y: auto;
}
.tpl-preview__actions { display: flex; gap: var(--space-2); }
.tpl-hint { color: var(--color-text-muted); font-size: var(--text-sm); margin: 0; }
:global(.token-unresolved) {
background: var(--app-accent-light, #fef3c7);
color: var(--app-accent, #d97706);
border-radius: 2px;
padding: 0 2px;
}
.field { display: flex; flex-direction: column; gap: var(--space-1); }
.field-label { font-size: var(--text-sm); font-weight: 500; color: var(--color-text-muted); }
.field-hint { font-size: var(--text-xs); color: var(--color-text-muted); margin: 0; }
.field-input, .field-select, .field-textarea {
padding: var(--space-2) var(--space-3);
background: var(--color-surface-alt);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text); font-size: var(--text-sm); font-family: var(--font-body); width: 100%;
}
.field-input:focus-visible, .field-select:focus-visible, .field-textarea:focus-visible {
outline: 2px solid var(--app-primary); outline-offset: 2px;
}
.field-textarea { resize: vertical; }
.modal-error { color: var(--app-accent); font-size: var(--text-sm); margin: 0; }
.modal-footer { display: flex; justify-content: flex-end; gap: var(--space-3); padding-top: var(--space-2); }
.btn { padding: var(--space-2) var(--space-4); border-radius: var(--radius-md); font-size: var(--text-sm); font-weight: 500; cursor: pointer; min-height: 40px; }
.btn--primary { background: var(--app-primary); color: var(--color-surface); border: none; }
.btn--primary:hover:not(:disabled) { opacity: 0.9; }
.btn--primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn--ghost { background: none; border: 1px solid var(--color-border); color: var(--color-text); }
.btn--ghost:hover { background: var(--color-surface-alt); }
</style>

View file

@ -12,7 +12,8 @@ export const router = createRouter({
{ path: '/apply/:id', component: () => import('../views/ApplyWorkspaceView.vue') }, { path: '/apply/:id', component: () => import('../views/ApplyWorkspaceView.vue') },
{ path: '/resumes', component: () => import('../views/ResumesView.vue') }, { path: '/resumes', component: () => import('../views/ResumesView.vue') },
{ path: '/interviews', component: () => import('../views/InterviewsView.vue') }, { path: '/interviews', component: () => import('../views/InterviewsView.vue') },
{ path: '/contacts', component: () => import('../views/ContactsView.vue') }, { path: '/messages', component: () => import('../views/MessagingView.vue') },
{ path: '/contacts', redirect: '/messages' },
{ path: '/references', component: () => import('../views/ReferencesView.vue') }, { path: '/references', component: () => import('../views/ReferencesView.vue') },
{ path: '/digest', component: () => import('../views/DigestView.vue') }, { path: '/digest', component: () => import('../views/DigestView.vue') },
{ path: '/prep', component: () => import('../views/InterviewPrepView.vue') }, { path: '/prep', component: () => import('../views/InterviewPrepView.vue') },

174
web/src/stores/messaging.ts Normal file
View file

@ -0,0 +1,174 @@
// 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<Message[]>([])
const templates = ref<MessageTemplate[]>([])
const loading = ref(false)
const saving = ref(false)
const error = ref<string | null>(null)
const draftPending = ref<number | null>(null) // message_id of pending draft
async function fetchMessages(jobId: number) {
loading.value = true
error.value = null
const { data, error: fetchErr } = await useApiFetch<Message[]>(
`/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<MessageTemplate[]>(
'/api/message-templates'
)
if (fetchErr) { error.value = 'Could not load templates.'; return }
templates.value = data ?? []
}
async function createMessage(payload: Omit<Message, 'id' | 'approved_at' | 'osprey_call_id'> & { logged_at?: string }) {
saving.value = true
error.value = null
const { data, error: fetchErr } = await useApiFetch<Message>(
'/api/messages',
{ 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 }
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<MessageTemplate, 'title' | 'category' | 'body_template'> & { subject_template?: string }) {
saving.value = true
error.value = null
const { data, error: fetchErr } = await useApiFetch<MessageTemplate>(
'/api/message-templates',
{ 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 }
templates.value = [...templates.value, data]
return data
}
async function updateTemplate(id: number, payload: Partial<Pick<MessageTemplate, 'title' | 'category' | 'subject_template' | 'body_template'>>) {
saving.value = true
error.value = null
const { data, error: fetchErr } = await useApiFetch<MessageTemplate>(
`/api/message-templates/${id}`,
{ 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 }
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', headers: { 'Content-Type': 'application/json' } }
)
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 updateMessageBody(id: number, body: string) {
const { data, error: fetchErr } = await useApiFetch<Message>(
`/api/messages/${id}`,
{ method: 'PUT', body: JSON.stringify({ body }), headers: { 'Content-Type': 'application/json' } }
)
if (fetchErr || !data) { error.value = 'Failed to save edits.'; return null }
messages.value = messages.value.map(m => m.id === id ? { ...m, body: data.body } : m)
return data
}
async function approveDraft(messageId: number): Promise<string | null> {
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, updateMessageBody, clear,
}
})

View file

@ -0,0 +1,562 @@
<!-- web/src/views/MessagingView.vue -->
<template>
<div class="messaging-layout">
<!-- Left panel: job list -->
<aside class="job-panel" role="complementary" aria-label="Jobs with messages">
<div class="job-panel__header">
<h1 class="job-panel__title">Messages</h1>
</div>
<ul class="job-list" role="list" aria-label="Jobs">
<li
v-for="job in jobsWithMessages"
:key="job.id"
class="job-list__item"
:class="{ 'job-list__item--active': selectedJobId === job.id }"
role="listitem"
:aria-label="`${job.company}, ${job.title}`"
>
<button class="job-list__btn" @click="selectJob(job.id)">
<span class="job-list__company">{{ job.company }}</span>
<span class="job-list__role">{{ job.title }}</span>
<span v-if="job.preview" class="job-list__preview">{{ job.preview }}</span>
</button>
</li>
<li v-if="jobsWithMessages.length === 0" class="job-list__empty">
No messages yet. Select a job to log a call or use a template.
</li>
</ul>
</aside>
<!-- Right panel: thread view -->
<main class="thread-panel" aria-label="Message thread">
<div v-if="!selectedJobId" class="thread-panel__empty">
<p>Select a job to view its communication timeline.</p>
</div>
<template v-else>
<!-- Action bar -->
<div class="action-bar" role="toolbar" aria-label="Message actions">
<button class="btn btn--ghost" @click="openLogModal('call_note')">Log call</button>
<button class="btn btn--ghost" @click="openLogModal('in_person')">Log note</button>
<button class="btn btn--ghost" @click="openTemplateModal('apply')">Use template</button>
<button
class="btn btn--primary"
:disabled="store.loading"
@click="requestDraft"
>
{{ store.loading ? 'Drafting…' : 'Draft reply with LLM' }}
</button>
<!-- Osprey (Phase 2 stub) aria-disabled, never hidden -->
<button
class="btn btn--osprey"
aria-disabled="true"
:title="ospreyTitle"
@mouseenter="handleOspreyHover"
@focus="handleOspreyHover"
>
📞 Call via Osprey
</button>
</div>
<!-- Draft pending announcement (screen reader) -->
<div aria-live="polite" aria-atomic="true" class="sr-only">
{{ draftAnnouncement }}
</div>
<!-- Error banner -->
<p v-if="store.error" class="thread-error" role="alert">{{ store.error }}</p>
<!-- Timeline -->
<div v-if="store.loading && timeline.length === 0" class="thread-loading">
Loading messages
</div>
<ul v-else class="timeline" role="list" aria-label="Message timeline">
<li
v-for="item in timeline"
:key="item._key"
class="timeline__item"
:class="[`timeline__item--${item.type}`, item.approved_at === null && item.type === 'draft' ? 'timeline__item--draft-pending' : '']"
role="listitem"
:aria-label="`${typeLabel(item.type)}, ${item.direction || ''}, ${item.logged_at}`"
>
<span class="timeline__icon" aria-hidden="true">{{ typeIcon(item.type) }}</span>
<div class="timeline__content">
<div class="timeline__meta">
<span class="timeline__type-label">{{ typeLabel(item.type) }}</span>
<span v-if="item.direction" class="timeline__direction">{{ item.direction }}</span>
<time class="timeline__time">{{ formatTime(item.logged_at) }}</time>
<span
v-if="item.type === 'draft' && item.approved_at === null"
class="timeline__badge timeline__badge--pending"
>
Pending approval
</span>
<span
v-if="item.type === 'draft' && item.approved_at !== null"
class="timeline__badge timeline__badge--approved"
>
Approved
</span>
</div>
<p v-if="item.subject" class="timeline__subject">{{ item.subject }}</p>
<!-- Draft body is editable before approval -->
<template v-if="item.type === 'draft' && item.approved_at === null">
<textarea
:ref="el => setDraftRef(item.id, el)"
class="timeline__draft-body"
:value="item.body ?? ''"
@input="updateDraftBody(item.id, ($event.target as HTMLTextAreaElement).value)"
rows="6"
aria-label="Edit draft reply before approving"
/>
<div class="timeline__draft-actions">
<button class="btn btn--primary btn--sm" @click="approveDraft(item.id)">
Approve + copy
</button>
<a
v-if="item.to_addr"
:href="`mailto:${item.to_addr}?subject=${encodeURIComponent(item.subject ?? '')}&body=${encodeURIComponent(item.body ?? '')}`"
class="btn btn--ghost btn--sm"
target="_blank"
rel="noopener"
>
Open in email client
</a>
<button class="btn btn--ghost btn--sm btn--danger" @click="confirmDelete(item.id)">
Discard
</button>
</div>
</template>
<template v-else>
<p class="timeline__body">{{ item.body }}</p>
</template>
</div>
</li>
<li v-if="timeline.length === 0" class="timeline__empty">
No messages logged yet for this job.
</li>
</ul>
</template>
</main>
<!-- Modals -->
<MessageLogModal
:show="logModal.show"
:job-id="selectedJobId ?? 0"
:type="logModal.type"
@close="logModal.show = false"
@saved="onLogSaved"
/>
<MessageTemplateModal
:show="tplModal.show"
:mode="tplModal.mode"
:job-tokens="jobTokens"
:edit-template="tplModal.editTemplate"
@close="tplModal.show = false"
@saved="onTemplateSaved"
/>
<!-- Delete confirmation -->
<div v-if="deleteConfirm !== null" class="modal-backdrop" @click.self="deleteConfirm = null">
<div class="modal-dialog modal-dialog--sm" role="dialog" aria-modal="true" aria-label="Confirm delete">
<div class="modal-body">
<p>Are you sure you want to delete this message? This cannot be undone.</p>
<div class="modal-footer">
<button class="btn btn--ghost" @click="deleteConfirm = null">Cancel</button>
<button class="btn btn--danger" @click="doDelete">Delete</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useMessagingStore, type MessageTemplate } from '../stores/messaging'
import { useApiFetch } from '../composables/useApi'
import MessageLogModal from '../components/MessageLogModal.vue'
import MessageTemplateModal from '../components/MessageTemplateModal.vue'
const store = useMessagingStore()
// Jobs list
interface JobSummary { id: number; company: string; title: string; preview?: string }
const allJobs = ref<JobSummary[]>([])
const selectedJobId = ref<number | null>(null)
async function loadJobs() {
const { data } = await useApiFetch<Array<{ id: number; company: string; title: string }>>('/api/jobs?status=applied&limit=200')
allJobs.value = data ?? []
}
const jobsWithMessages = computed(() => allJobs.value)
async function selectJob(id: number) {
selectedJobId.value = id
draftBodyEdits.value = {}
await store.fetchMessages(id)
}
// Timeline: UNION of job_contacts + messages
interface TimelineItem {
_key: string
id: number
type: 'call_note' | 'in_person' | 'email' | 'draft'
direction: string | null
subject: string | null
body: string | null
to_addr: string | null
logged_at: string
approved_at: string | null
}
interface JobContact {
id: number
direction: string | null
subject: string | null
from_addr: string | null
to_addr: string | null
body: string | null
received_at: string | null
}
const jobContacts = ref<JobContact[]>([])
watch(selectedJobId, async (id) => {
if (id === null) { jobContacts.value = []; return }
const { data } = await useApiFetch<JobContact[]>(`/api/contacts?job_id=${id}`)
jobContacts.value = data ?? []
})
const timeline = computed<TimelineItem[]>(() => {
const contactItems: TimelineItem[] = jobContacts.value.map(c => ({
_key: `jc-${c.id}`,
id: c.id,
type: 'email',
direction: c.direction,
subject: c.subject,
body: c.body,
to_addr: c.to_addr,
logged_at: c.received_at ?? '',
approved_at: 'n/a', // contacts are always "approved"
}))
const messageItems: TimelineItem[] = store.messages.map(m => ({
_key: `msg-${m.id}`,
id: m.id,
type: m.type,
direction: m.direction,
subject: m.subject,
body: draftBodyEdits.value[m.id] ?? m.body,
to_addr: m.to_addr,
logged_at: m.logged_at,
approved_at: m.approved_at,
}))
return [...contactItems, ...messageItems].sort(
(a, b) => new Date(b.logged_at).getTime() - new Date(a.logged_at).getTime()
)
})
// Draft body edits (local, before approve)
const draftBodyEdits = ref<Record<number, string>>({})
const draftRefs = ref<Record<number, HTMLTextAreaElement | null>>({})
function setDraftRef(id: number, el: unknown) {
draftRefs.value[id] = el as HTMLTextAreaElement | null
}
function updateDraftBody(id: number, value: string) {
draftBodyEdits.value = { ...draftBodyEdits.value, [id]: value }
}
// LLM draft + approval
const draftAnnouncement = ref('')
async function requestDraft() {
// Find the most recent inbound job_contact for this job
const inbound = jobContacts.value.find(c => c.direction === 'inbound')
if (!inbound) {
store.error = 'No inbound emails found for this job to draft a reply to.'
return
}
const msgId = await store.requestDraft(inbound.id)
if (msgId) {
draftAnnouncement.value = 'Draft reply generated and ready for review.'
await store.fetchMessages(selectedJobId.value!)
setTimeout(() => { draftAnnouncement.value = '' }, 3000)
}
}
async function approveDraft(messageId: number) {
const editedBody = draftBodyEdits.value[messageId]
// Persist edits to DB before approving so history shows final version
if (editedBody !== undefined) {
const updated = await store.updateMessageBody(messageId, editedBody)
if (!updated) return // error already set in store
}
const body = await store.approveDraft(messageId)
if (body) {
const finalBody = editedBody ?? body
await navigator.clipboard.writeText(finalBody)
draftAnnouncement.value = 'Approved and copied to clipboard.'
setTimeout(() => { draftAnnouncement.value = '' }, 3000)
}
}
// Delete confirmation
const deleteConfirm = ref<number | null>(null)
function confirmDelete(id: number) {
deleteConfirm.value = id
}
async function doDelete() {
if (deleteConfirm.value === null) return
await store.deleteMessage(deleteConfirm.value)
deleteConfirm.value = null
}
// Osprey easter egg
const OSPREY_HOVER_KEY = 'peregrine-osprey-hover-count'
const ospreyTitle = ref('Osprey IVR calling — coming in Phase 2')
function handleOspreyHover() {
const count = parseInt(localStorage.getItem(OSPREY_HOVER_KEY) ?? '0', 10) + 1
localStorage.setItem(OSPREY_HOVER_KEY, String(count))
if (count >= 10) {
ospreyTitle.value = "Osprey is still learning to fly... 🦅"
}
}
// Modals
const logModal = ref<{ show: boolean; type: 'call_note' | 'in_person' }>({
show: false, type: 'call_note',
})
function openLogModal(type: 'call_note' | 'in_person') {
logModal.value = { show: true, type }
}
function onLogSaved() {
logModal.value.show = false
if (selectedJobId.value) store.fetchMessages(selectedJobId.value)
}
const tplModal = ref<{
show: boolean
mode: 'apply' | 'create' | 'edit'
editTemplate?: MessageTemplate
}>({ show: false, mode: 'apply' })
function openTemplateModal(mode: 'apply' | 'create' | 'edit', tpl?: MessageTemplate) {
tplModal.value = { show: true, mode, editTemplate: tpl }
}
function onTemplateSaved() {
tplModal.value.show = false
store.fetchTemplates()
}
// Job tokens for template substitution
const jobTokens = computed<Record<string, string>>(() => {
const job = allJobs.value.find(j => j.id === selectedJobId.value)
return {
company: job?.company ?? '',
role: job?.title ?? '',
name: '', // loaded from user profile; left empty user fills in
recruiter_name: '',
date: new Date().toLocaleDateString(),
accommodation_details: '',
}
})
// Helpers
function typeIcon(type: string): string {
return { call_note: '📞', in_person: '🤝', email: '✉️', draft: '📝' }[type] ?? '💬'
}
function typeLabel(type: string): string {
return {
call_note: 'Call note', in_person: 'In-person note',
email: 'Email', draft: 'Draft reply',
}[type] ?? type
}
function formatTime(iso: string): string {
if (!iso) return ''
return new Date(iso).toLocaleString(undefined, {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
})
}
// Lifecycle
onMounted(async () => {
await Promise.all([loadJobs(), store.fetchTemplates()])
})
onUnmounted(() => {
store.clear()
})
</script>
<style scoped>
.messaging-layout {
display: flex;
height: 100%;
min-height: 0;
}
/* ── Left panel ─────────────────────── */
.job-panel {
width: 260px;
min-width: 200px;
flex-shrink: 0;
border-right: 1px solid var(--color-border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.job-panel__header {
padding: var(--space-4);
border-bottom: 1px solid var(--color-border-light);
}
.job-panel__title { font-size: var(--text-lg); font-weight: 600; margin: 0; }
.job-list {
flex: 1; overflow-y: auto;
list-style: none; margin: 0; padding: var(--space-2) 0;
}
.job-list__item { margin: 0; }
.job-list__item--active .job-list__btn {
background: var(--app-primary-light);
color: var(--app-primary);
}
.job-list__btn {
width: 100%; padding: var(--space-3) var(--space-4);
text-align: left; background: none; border: none; cursor: pointer;
display: flex; flex-direction: column; gap: 2px;
transition: background 150ms;
}
.job-list__btn:hover { background: var(--color-surface-alt); }
.job-list__company { font-size: var(--text-sm); font-weight: 600; }
.job-list__role { font-size: var(--text-xs); color: var(--color-text-muted); }
.job-list__preview { font-size: var(--text-xs); color: var(--color-text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 220px; }
.job-list__empty { padding: var(--space-4); font-size: var(--text-sm); color: var(--color-text-muted); }
/* ── Right panel ────────────────────── */
.thread-panel {
flex: 1; min-width: 0;
display: flex; flex-direction: column;
overflow: hidden;
}
.thread-panel__empty {
flex: 1; display: flex; align-items: center; justify-content: center;
color: var(--color-text-muted);
}
.action-bar {
display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border-light);
}
.btn--osprey {
opacity: 0.5; cursor: not-allowed;
background: none; border: 1px dashed var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text-muted); font-size: var(--text-sm);
padding: var(--space-2) var(--space-3); min-height: 36px;
}
.thread-error {
margin: var(--space-2) var(--space-4);
color: var(--app-accent); font-size: var(--text-sm);
}
.thread-loading { padding: var(--space-4); color: var(--color-text-muted); font-size: var(--text-sm); }
.timeline {
flex: 1; overflow-y: auto;
list-style: none; margin: 0; padding: var(--space-4);
display: flex; flex-direction: column; gap: var(--space-3);
}
.timeline__item {
display: flex; gap: var(--space-3);
padding: var(--space-3); border-radius: var(--radius-md);
background: var(--color-surface-alt);
border: 1px solid var(--color-border);
}
.timeline__item--draft-pending {
border-color: var(--app-accent);
background: color-mix(in srgb, var(--app-accent) 8%, var(--color-surface));
}
.timeline__icon { font-size: 1.2rem; flex-shrink: 0; }
.timeline__content { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--space-1); }
.timeline__meta { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; }
.timeline__type-label { font-size: var(--text-sm); font-weight: 600; }
.timeline__direction { font-size: var(--text-xs); color: var(--color-text-muted); text-transform: capitalize; }
.timeline__time { font-size: var(--text-xs); color: var(--color-text-muted); margin-left: auto; }
.timeline__badge {
font-size: var(--text-xs); font-weight: 700;
padding: 1px 6px; border-radius: var(--radius-full);
}
.timeline__badge--pending { background: #fef3c7; color: #d97706; }
.timeline__badge--approved { background: #d1fae5; color: #065f46; }
.timeline__subject { font-size: var(--text-sm); font-weight: 500; margin: 0; }
.timeline__body { font-size: var(--text-sm); white-space: pre-wrap; margin: 0; color: var(--color-text); }
.timeline__draft-body {
width: 100%; font-size: var(--text-sm); font-family: var(--font-body);
padding: var(--space-2); border: 1px solid var(--color-border);
border-radius: var(--radius-md); background: var(--color-surface);
color: var(--color-text); resize: vertical;
}
.timeline__draft-body:focus-visible { outline: 2px solid var(--app-primary); outline-offset: 2px; }
.timeline__draft-actions { display: flex; gap: var(--space-2); flex-wrap: wrap; }
.timeline__empty { color: var(--color-text-muted); font-size: var(--text-sm); padding: var(--space-2); }
/* Buttons */
.btn { padding: var(--space-2) var(--space-3); border-radius: var(--radius-md); font-size: var(--text-sm); font-weight: 500; cursor: pointer; min-height: 36px; }
.btn--sm { padding: var(--space-1) var(--space-3); min-height: 30px; font-size: var(--text-xs); }
.btn--primary { background: var(--app-primary); color: var(--color-surface); border: none; }
.btn--primary:hover:not(:disabled) { opacity: 0.9; }
.btn--primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn--ghost { background: none; border: 1px solid var(--color-border); color: var(--color-text); }
.btn--ghost:hover { background: var(--color-surface-alt); }
.btn--danger { background: var(--app-accent); color: white; border: none; }
.btn--danger:hover { opacity: 0.9; }
/* Modals (delete confirm) */
.modal-backdrop {
position: fixed; inset: 0;
background: rgba(0,0,0,0.5);
display: flex; align-items: center; justify-content: center;
z-index: 200;
}
.modal-dialog {
background: var(--color-surface-raised); border: 1px solid var(--color-border);
border-radius: var(--radius-lg); width: min(400px, 95vw); outline: none;
}
.modal-dialog--sm { width: min(360px, 95vw); }
.modal-body { padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-4); }
.modal-footer { display: flex; justify-content: flex-end; gap: var(--space-3); }
/* Screen-reader only utility */
.sr-only {
position: absolute; width: 1px; height: 1px;
padding: 0; margin: -1px; overflow: hidden;
clip: rect(0,0,0,0); white-space: nowrap; border: 0;
}
/* Responsive: stack panels on narrow screens */
@media (max-width: 700px) {
.messaging-layout { flex-direction: column; }
.job-panel { width: 100%; border-right: none; border-bottom: 1px solid var(--color-border); max-height: 180px; }
}
</style>