Compare commits
7 commits
8e36863a49
...
797032bd97
| Author | SHA1 | Date | |
|---|---|---|---|
| 797032bd97 | |||
| fb8b464dd0 | |||
| ec521e14c5 | |||
| a302049f72 | |||
| 03b9e52301 | |||
| 0e4fce44c4 | |||
| 6599bc6952 |
15 changed files with 1378 additions and 82 deletions
35
.github/workflows/ci.yml
vendored
35
.github/workflows/ci.yml
vendored
|
|
@ -1,35 +0,0 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install system dependencies
|
||||
run: sudo apt-get update -q && sudo apt-get install -y libsqlcipher-dev
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: pip
|
||||
|
||||
- name: Configure git credentials for Forgejo
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
|
||||
run: |
|
||||
git config --global url."https://oauth2:${FORGEJO_TOKEN}@git.opensourcesolarpunk.com/".insteadOf "https://git.opensourcesolarpunk.com/"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r requirements.txt
|
||||
|
||||
- name: Run tests
|
||||
run: pytest tests/ -v --tb=short
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -40,8 +40,11 @@ pytest-output.txt
|
|||
docs/superpowers/
|
||||
|
||||
data/email_score.jsonl
|
||||
data/email_score.jsonl.bad-labels
|
||||
data/email_label_queue.jsonl
|
||||
data/email_compare_sample.jsonl
|
||||
data/.feedback_ratelimit.json
|
||||
data/config/
|
||||
|
||||
config/label_tool.yaml
|
||||
config/server.yaml
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ WORKDIR /app
|
|||
# System deps for companyScraper (beautifulsoup4, fake-useragent, lxml) and PDF gen
|
||||
# libsqlcipher-dev: required to build pysqlcipher3 (SQLCipher AES-256 encryption for cloud mode)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc libffi-dev curl libsqlcipher-dev \
|
||||
gcc libffi-dev curl libsqlcipher-dev git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
|
|
|
|||
359
dev-api.py
359
dev-api.py
|
|
@ -16,7 +16,7 @@ import subprocess
|
|||
import sys
|
||||
import threading
|
||||
from contextvars import ContextVar
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional, List
|
||||
from urllib.parse import urlparse
|
||||
|
|
@ -58,6 +58,20 @@ async def lifespan(app: FastAPI):
|
|||
_load_env(PEREGRINE_ROOT / ".env")
|
||||
from scripts.db_migrate import migrate_db
|
||||
migrate_db(Path(DB_PATH))
|
||||
|
||||
# Cloud mode: sweep all known user DBs at startup so schema changes land
|
||||
# for every user on deploy, not only on their next request.
|
||||
if _CLOUD_MODE and _CLOUD_DATA_ROOT.is_dir():
|
||||
import logging as _log
|
||||
_sweep_log = _log.getLogger("peregrine.startup")
|
||||
for user_db in _CLOUD_DATA_ROOT.glob("*/peregrine/staging.db"):
|
||||
try:
|
||||
migrate_db(user_db)
|
||||
_migrated_db_paths.add(str(user_db))
|
||||
_sweep_log.info("Migrated user DB: %s", user_db)
|
||||
except Exception as exc:
|
||||
_sweep_log.warning("Migration failed for %s: %s", user_db, exc)
|
||||
|
||||
yield
|
||||
|
||||
|
||||
|
|
@ -230,6 +244,28 @@ def _row_to_job(row) -> dict:
|
|||
return d
|
||||
|
||||
|
||||
def _shadow_score(date_posted: str | None, date_found: str | None) -> str | None:
|
||||
"""Return 'shadow', 'stale', or None based on posting age when discovered.
|
||||
|
||||
A job posted >30 days before discovery is a shadow candidate (posted to
|
||||
satisfy legal/HR requirements, likely already filled). 14-30 days is stale.
|
||||
Returns None when dates are unavailable.
|
||||
"""
|
||||
if not date_posted or not date_found:
|
||||
return None
|
||||
try:
|
||||
posted = datetime.fromisoformat(date_posted.replace("Z", "+00:00")).replace(tzinfo=timezone.utc)
|
||||
found = datetime.fromisoformat(date_found.replace("Z", "+00:00")).replace(tzinfo=timezone.utc)
|
||||
days = (found - posted).days
|
||||
if days >= 30:
|
||||
return "shadow"
|
||||
if days >= 14:
|
||||
return "stale"
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
# ── GET /api/jobs ─────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/jobs")
|
||||
|
|
@ -237,7 +273,7 @@ def list_jobs(status: str = "pending", limit: int = 50, fields: str = ""):
|
|||
db = _get_db()
|
||||
rows = db.execute(
|
||||
"SELECT id, title, company, url, source, location, is_remote, salary, "
|
||||
"description, match_score, keyword_gaps, date_found, status, cover_letter "
|
||||
"description, match_score, keyword_gaps, date_found, date_posted, status, cover_letter "
|
||||
"FROM jobs WHERE status = ? ORDER BY match_score DESC NULLS LAST LIMIT ?",
|
||||
(status, limit),
|
||||
).fetchall()
|
||||
|
|
@ -246,7 +282,7 @@ def list_jobs(status: str = "pending", limit: int = 50, fields: str = ""):
|
|||
for r in rows:
|
||||
d = _row_to_job(r)
|
||||
d["has_cover_letter"] = bool(d.get("cover_letter"))
|
||||
# Don't send full cover_letter text in the list view
|
||||
d["shadow_score"] = _shadow_score(d.get("date_posted"), d.get("date_found"))
|
||||
d.pop("cover_letter", None)
|
||||
result.append(d)
|
||||
return result
|
||||
|
|
@ -737,32 +773,17 @@ async def import_resume_endpoint(file: UploadFile, name: str = ""):
|
|||
text = content.decode("utf-8", errors="replace")
|
||||
|
||||
elif ext in (".pdf", ".docx", ".odt"):
|
||||
with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tmp:
|
||||
tmp.write(content)
|
||||
tmp_path = tmp.name
|
||||
try:
|
||||
if ext == ".pdf":
|
||||
import pdfplumber
|
||||
with pdfplumber.open(tmp_path) as pdf:
|
||||
text = "\n".join(p.extract_text() or "" for p in pdf.pages)
|
||||
elif ext == ".docx":
|
||||
from docx import Document
|
||||
doc = Document(tmp_path)
|
||||
text = "\n".join(p.text for p in doc.paragraphs)
|
||||
else:
|
||||
import zipfile
|
||||
from xml.etree import ElementTree as ET
|
||||
with zipfile.ZipFile(tmp_path) as z:
|
||||
xml = z.read("content.xml")
|
||||
ET_root = ET.fromstring(xml)
|
||||
text = "\n".join(
|
||||
el.text or ""
|
||||
for el in ET_root.iter(
|
||||
"{urn:oasis:names:tc:opendocument:xmlns:text:1.0}p"
|
||||
)
|
||||
)
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
from scripts.resume_parser import (
|
||||
extract_text_from_pdf as _extract_pdf,
|
||||
extract_text_from_docx as _extract_docx,
|
||||
extract_text_from_odt as _extract_odt,
|
||||
)
|
||||
if ext == ".pdf":
|
||||
text = _extract_pdf(content)
|
||||
elif ext == ".docx":
|
||||
text = _extract_docx(content)
|
||||
else:
|
||||
text = _extract_odt(content)
|
||||
|
||||
elif ext in (".yaml", ".yml"):
|
||||
import yaml as _yaml
|
||||
|
|
@ -1490,6 +1511,284 @@ def suggest_qa_answer(job_id: int, payload: QASuggestPayload):
|
|||
raise HTTPException(500, f"LLM generation failed: {e}")
|
||||
|
||||
|
||||
# ── POST /api/jobs/:id/hired-feedback ─────────────────────────────────────────
|
||||
|
||||
class HiredFeedbackPayload(BaseModel):
|
||||
what_helped: str = ""
|
||||
factors: list[str] = []
|
||||
|
||||
@app.post("/api/jobs/{job_id}/hired-feedback")
|
||||
def save_hired_feedback(job_id: int, payload: HiredFeedbackPayload):
|
||||
db = _get_db()
|
||||
row = db.execute("SELECT status FROM jobs WHERE id = ?", (job_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Job not found")
|
||||
if row["status"] != "hired":
|
||||
raise HTTPException(400, "Feedback only accepted for hired jobs")
|
||||
db.execute(
|
||||
"UPDATE jobs SET hired_feedback = ? WHERE id = ?",
|
||||
(json.dumps({"what_helped": payload.what_helped, "factors": payload.factors}), job_id),
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── GET /api/contacts ──────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/contacts")
|
||||
def list_contacts(job_id: Optional[int] = None, direction: Optional[str] = None,
|
||||
search: Optional[str] = None, limit: int = 100, offset: int = 0):
|
||||
db = _get_db()
|
||||
query = """
|
||||
SELECT jc.id, jc.job_id, jc.direction, jc.subject, jc.from_addr, jc.to_addr,
|
||||
jc.received_at, jc.stage_signal,
|
||||
j.title AS job_title, j.company AS job_company
|
||||
FROM job_contacts jc
|
||||
LEFT JOIN jobs j ON j.id = jc.job_id
|
||||
WHERE 1=1
|
||||
"""
|
||||
params: list = []
|
||||
if job_id is not None:
|
||||
query += " AND jc.job_id = ?"
|
||||
params.append(job_id)
|
||||
if direction:
|
||||
query += " AND jc.direction = ?"
|
||||
params.append(direction)
|
||||
if search:
|
||||
query += " AND (jc.from_addr LIKE ? OR jc.to_addr LIKE ? OR jc.subject LIKE ?)"
|
||||
like = f"%{search}%"
|
||||
params += [like, like, like]
|
||||
query += " ORDER BY jc.received_at DESC LIMIT ? OFFSET ?"
|
||||
params += [limit, offset]
|
||||
rows = db.execute(query, params).fetchall()
|
||||
total = db.execute(
|
||||
"SELECT COUNT(*) FROM job_contacts" + (" WHERE job_id = ?" if job_id else ""),
|
||||
([job_id] if job_id else []),
|
||||
).fetchone()[0]
|
||||
db.close()
|
||||
return {"total": total, "contacts": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
# ── References ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class ReferencePayload(BaseModel):
|
||||
name: str
|
||||
relationship: str = ""
|
||||
company: str = ""
|
||||
email: str = ""
|
||||
phone: str = ""
|
||||
notes: str = ""
|
||||
tags: list[str] = []
|
||||
|
||||
class PrepEmailPayload(BaseModel):
|
||||
job_id: int
|
||||
|
||||
class RecLetterPayload(BaseModel):
|
||||
job_id: int
|
||||
talking_points: str = ""
|
||||
|
||||
|
||||
@app.get("/api/references")
|
||||
def list_references():
|
||||
db = _get_db()
|
||||
rows = db.execute(
|
||||
"SELECT * FROM references_ ORDER BY name ASC"
|
||||
).fetchall()
|
||||
db.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@app.post("/api/references")
|
||||
def create_reference(payload: ReferencePayload):
|
||||
db = _get_db()
|
||||
cur = db.execute(
|
||||
"""INSERT INTO references_ (name, relationship, company, email, phone, notes, tags)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(payload.name, payload.relationship, payload.company,
|
||||
payload.email, payload.phone, payload.notes,
|
||||
json.dumps(payload.tags)),
|
||||
)
|
||||
db.commit()
|
||||
row = db.execute("SELECT * FROM references_ WHERE id = ?", (cur.lastrowid,)).fetchone()
|
||||
db.close()
|
||||
return dict(row)
|
||||
|
||||
|
||||
@app.patch("/api/references/{ref_id}")
|
||||
def update_reference(ref_id: int, payload: ReferencePayload):
|
||||
db = _get_db()
|
||||
row = db.execute("SELECT id FROM references_ WHERE id = ?", (ref_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Reference not found")
|
||||
db.execute(
|
||||
"""UPDATE references_ SET name=?, relationship=?, company=?, email=?, phone=?,
|
||||
notes=?, tags=?, updated_at=datetime('now') WHERE id=?""",
|
||||
(payload.name, payload.relationship, payload.company,
|
||||
payload.email, payload.phone, payload.notes,
|
||||
json.dumps(payload.tags), ref_id),
|
||||
)
|
||||
db.commit()
|
||||
updated = db.execute("SELECT * FROM references_ WHERE id = ?", (ref_id,)).fetchone()
|
||||
db.close()
|
||||
return dict(updated)
|
||||
|
||||
|
||||
@app.delete("/api/references/{ref_id}")
|
||||
def delete_reference(ref_id: int):
|
||||
db = _get_db()
|
||||
db.execute("DELETE FROM references_ WHERE id = ?", (ref_id,))
|
||||
db.commit()
|
||||
db.close()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/api/references/for-job/{job_id}")
|
||||
def references_for_job(job_id: int):
|
||||
db = _get_db()
|
||||
rows = db.execute(
|
||||
"""SELECT r.*, jr.prep_email, jr.rec_letter, jr.id AS jr_id
|
||||
FROM references_ r
|
||||
JOIN job_references jr ON jr.reference_id = r.id
|
||||
WHERE jr.job_id = ?
|
||||
ORDER BY r.name ASC""",
|
||||
(job_id,),
|
||||
).fetchall()
|
||||
db.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@app.post("/api/references/{ref_id}/link-job")
|
||||
def link_reference_to_job(ref_id: int, body: PrepEmailPayload):
|
||||
db = _get_db()
|
||||
try:
|
||||
db.execute(
|
||||
"INSERT INTO job_references (job_id, reference_id) VALUES (?, ?)",
|
||||
(body.job_id, ref_id),
|
||||
)
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass # already linked
|
||||
db.close()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.delete("/api/references/{ref_id}/unlink-job/{job_id}")
|
||||
def unlink_reference_from_job(ref_id: int, job_id: int):
|
||||
db = _get_db()
|
||||
db.execute(
|
||||
"DELETE FROM job_references WHERE reference_id = ? AND job_id = ?",
|
||||
(ref_id, job_id),
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/references/{ref_id}/prep-email")
|
||||
def generate_prep_email(ref_id: int, payload: PrepEmailPayload):
|
||||
"""Draft a short 'heads up' email to send a reference before they hear from the hiring team."""
|
||||
db = _get_db()
|
||||
ref_row = db.execute("SELECT * FROM references_ WHERE id = ?", (ref_id,)).fetchone()
|
||||
if not ref_row:
|
||||
db.close()
|
||||
raise HTTPException(404, "Reference not found")
|
||||
job_row = db.execute(
|
||||
"SELECT title, company, description FROM jobs WHERE id = ?", (payload.job_id,)
|
||||
).fetchone()
|
||||
if not job_row:
|
||||
db.close()
|
||||
raise HTTPException(404, "Job not found")
|
||||
ref = dict(ref_row)
|
||||
job = dict(job_row)
|
||||
db.close()
|
||||
|
||||
prompt = f"""Draft a short, warm email to send to a professional reference before a job interview.
|
||||
|
||||
Reference: {ref['name']} ({ref['relationship']} at {ref['company']})
|
||||
Role applying for: {job['title']} at {job['company']}
|
||||
Job description excerpt: {(job['description'] or '')[:500]}
|
||||
|
||||
The email should:
|
||||
- Be 3-4 short paragraphs max
|
||||
- Thank them for being a reference
|
||||
- Briefly describe the role and why it's a good fit
|
||||
- Mention 1-2 specific accomplishments they could speak to
|
||||
- Give them a heads-up they may be contacted soon
|
||||
- Be warm and professional, not overly formal
|
||||
|
||||
Return only the email body (no subject line)."""
|
||||
|
||||
try:
|
||||
from scripts.llm_router import LLMRouter
|
||||
router = LLMRouter()
|
||||
email_text = router.complete(prompt)
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"LLM generation failed: {e}")
|
||||
|
||||
# Persist to job_references
|
||||
db = _get_db()
|
||||
db.execute(
|
||||
"""INSERT INTO job_references (job_id, reference_id, prep_email)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(job_id, reference_id) DO UPDATE SET prep_email = excluded.prep_email""",
|
||||
(payload.job_id, ref_id, email_text),
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
return {"prep_email": email_text}
|
||||
|
||||
|
||||
@app.post("/api/references/{ref_id}/rec-letter")
|
||||
def generate_rec_letter(ref_id: int, payload: RecLetterPayload):
|
||||
"""Draft a recommendation letter the reference can edit and send on their letterhead."""
|
||||
db = _get_db()
|
||||
ref_row = db.execute("SELECT * FROM references_ WHERE id = ?", (ref_id,)).fetchone()
|
||||
if not ref_row:
|
||||
db.close()
|
||||
raise HTTPException(404, "Reference not found")
|
||||
job_row = db.execute(
|
||||
"SELECT title, company, description FROM jobs WHERE id = ?", (payload.job_id,)
|
||||
).fetchone()
|
||||
if not job_row:
|
||||
db.close()
|
||||
raise HTTPException(404, "Job not found")
|
||||
ref = dict(ref_row)
|
||||
job = dict(job_row)
|
||||
db.close()
|
||||
|
||||
prompt = f"""Draft a professional recommendation letter that {ref['name']} ({ref['relationship']}) could send on their letterhead for a candidate applying to {job['title']} at {job['company']}.
|
||||
|
||||
Key talking points to highlight: {payload.talking_points or 'general professional excellence, collaboration, initiative'}
|
||||
|
||||
The letter should:
|
||||
- Be addressed generically (Dear Hiring Manager)
|
||||
- Be 3-4 paragraphs
|
||||
- Sound natural — written from the recommender's voice, not the candidate's
|
||||
- Highlight specific, credible observations a {ref['relationship']} would have
|
||||
- Close with strong endorsement and contact offer
|
||||
|
||||
Return only the letter body."""
|
||||
|
||||
try:
|
||||
from scripts.llm_router import LLMRouter
|
||||
router = LLMRouter()
|
||||
letter_text = router.complete(prompt)
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"LLM generation failed: {e}")
|
||||
|
||||
db = _get_db()
|
||||
db.execute(
|
||||
"""INSERT INTO job_references (job_id, reference_id, rec_letter)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(job_id, reference_id) DO UPDATE SET rec_letter = excluded.rec_letter""",
|
||||
(payload.job_id, ref_id, letter_text),
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
return {"rec_letter": letter_text}
|
||||
|
||||
|
||||
# ── GET /api/interviews ────────────────────────────────────────────────────────
|
||||
|
||||
PIPELINE_STATUSES = {
|
||||
|
|
@ -1509,7 +1808,7 @@ def list_interviews():
|
|||
f"SELECT id, title, company, url, location, is_remote, salary, "
|
||||
f"match_score, keyword_gaps, status, "
|
||||
f"interview_date, rejection_stage, "
|
||||
f"applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, survey_at "
|
||||
f"applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, survey_at, hired_feedback "
|
||||
f"FROM jobs WHERE status IN ({placeholders}) "
|
||||
f"ORDER BY match_score DESC NULLS LAST",
|
||||
list(PIPELINE_STATUSES),
|
||||
|
|
|
|||
6
migrations/006_date_posted.sql
Normal file
6
migrations/006_date_posted.sql
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
-- 006_date_posted.sql
|
||||
-- Add date_posted column for shadow listing detection (stale/shadow score feature).
|
||||
-- New DBs already have this column from the CREATE TABLE statement in db.py;
|
||||
-- this migration adds it to existing user DBs.
|
||||
|
||||
ALTER TABLE jobs ADD COLUMN date_posted TEXT;
|
||||
|
|
@ -130,6 +130,32 @@ CREATE TABLE IF NOT EXISTS digest_queue (
|
|||
)
|
||||
"""
|
||||
|
||||
CREATE_REFERENCES = """
|
||||
CREATE TABLE IF NOT EXISTS references_ (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
relationship TEXT,
|
||||
company TEXT,
|
||||
email TEXT,
|
||||
phone TEXT,
|
||||
notes TEXT,
|
||||
tags TEXT DEFAULT '[]',
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
"""
|
||||
|
||||
CREATE_JOB_REFERENCES = """
|
||||
CREATE TABLE IF NOT EXISTS job_references (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
job_id INTEGER NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
|
||||
reference_id INTEGER NOT NULL REFERENCES references_(id) ON DELETE CASCADE,
|
||||
prep_email TEXT,
|
||||
rec_letter TEXT,
|
||||
UNIQUE(job_id, reference_id)
|
||||
);
|
||||
"""
|
||||
|
||||
_MIGRATIONS = [
|
||||
("cover_letter", "TEXT"),
|
||||
("applied_at", "TEXT"),
|
||||
|
|
@ -143,6 +169,8 @@ _MIGRATIONS = [
|
|||
("calendar_event_id", "TEXT"),
|
||||
("optimized_resume", "TEXT"), # ATS-rewritten resume text (paid tier)
|
||||
("ats_gap_report", "TEXT"), # JSON gap report (free tier)
|
||||
("date_posted", "TEXT"), # Original posting date from job board (shadow listing detection)
|
||||
("hired_feedback", "TEXT"), # JSON: optional post-hire "what helped" response
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -176,6 +204,9 @@ def _migrate_db(db_path: Path) -> None:
|
|||
conn.execute("ALTER TABLE background_tasks ADD COLUMN params TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
pass # column already exists
|
||||
# Ensure references tables exist (CREATE IF NOT EXISTS is idempotent)
|
||||
conn.execute(CREATE_REFERENCES)
|
||||
conn.execute(CREATE_JOB_REFERENCES)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
|
@ -189,6 +220,8 @@ def init_db(db_path: Path = DEFAULT_DB) -> None:
|
|||
conn.execute(CREATE_BACKGROUND_TASKS)
|
||||
conn.execute(CREATE_SURVEY_RESPONSES)
|
||||
conn.execute(CREATE_DIGEST_QUEUE)
|
||||
conn.execute(CREATE_REFERENCES)
|
||||
conn.execute(CREATE_JOB_REFERENCES)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
_migrate_db(db_path)
|
||||
|
|
@ -202,8 +235,8 @@ def insert_job(db_path: Path = DEFAULT_DB, job: dict = None) -> Optional[int]:
|
|||
try:
|
||||
cursor = conn.execute(
|
||||
"""INSERT INTO jobs
|
||||
(title, company, url, source, location, is_remote, salary, description, date_found)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(title, company, url, source, location, is_remote, salary, description, date_found, date_posted)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
job.get("title", ""),
|
||||
job.get("company", ""),
|
||||
|
|
@ -214,6 +247,7 @@ def insert_job(db_path: Path = DEFAULT_DB, job: dict = None) -> Optional[int]:
|
|||
job.get("salary", ""),
|
||||
job.get("description", ""),
|
||||
job.get("date_found", ""),
|
||||
job.get("date_posted", "") or "",
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
|
|
|||
|
|
@ -307,6 +307,10 @@ def run_discovery(db_path: Path = DEFAULT_DB, notion_push: bool = False, config_
|
|||
elif job_dict.get("salary_source") and str(job_dict["salary_source"]) not in ("nan", "None", ""):
|
||||
salary_str = str(job_dict["salary_source"])
|
||||
|
||||
_dp = job_dict.get("date_posted")
|
||||
date_posted_str = (
|
||||
_dp.isoformat() if hasattr(_dp, "isoformat") else str(_dp)
|
||||
) if _dp and str(_dp) not in ("nan", "None", "") else ""
|
||||
row = {
|
||||
"url": url,
|
||||
"title": _s(job_dict.get("title")),
|
||||
|
|
@ -316,6 +320,7 @@ def run_discovery(db_path: Path = DEFAULT_DB, notion_push: bool = False, config_
|
|||
"is_remote": bool(job_dict.get("is_remote", False)),
|
||||
"salary": salary_str,
|
||||
"description": _s(job_dict.get("description")),
|
||||
"date_posted": date_posted_str,
|
||||
"_exclude_kw": exclude_kw,
|
||||
}
|
||||
if _insert_if_new(row, _s(job_dict.get("site"))):
|
||||
|
|
|
|||
|
|
@ -96,6 +96,8 @@ import {
|
|||
NewspaperIcon,
|
||||
Cog6ToothIcon,
|
||||
DocumentTextIcon,
|
||||
UsersIcon,
|
||||
IdentificationIcon,
|
||||
} from '@heroicons/vue/24/outline'
|
||||
|
||||
import { useDigestStore } from '../stores/digest'
|
||||
|
|
@ -155,6 +157,8 @@ const navLinks = computed(() => [
|
|||
{ to: '/apply', icon: PencilSquareIcon, label: 'Apply' },
|
||||
{ to: '/resumes', icon: DocumentTextIcon, label: 'Resumes' },
|
||||
{ to: '/interviews', icon: CalendarDaysIcon, label: 'Interviews' },
|
||||
{ to: '/contacts', icon: UsersIcon, label: 'Contacts' },
|
||||
{ to: '/references', icon: IdentificationIcon, label: 'References' },
|
||||
{ to: '/digest', icon: NewspaperIcon, label: 'Digest',
|
||||
badge: digestStore.entries.length || undefined },
|
||||
{ to: '/prep', icon: LightBulbIcon, label: 'Interview Prep' },
|
||||
|
|
|
|||
|
|
@ -191,6 +191,37 @@ const columnColor = computed(() => {
|
|||
}
|
||||
return map[props.job.status] ?? 'var(--color-border)'
|
||||
})
|
||||
|
||||
// ── Hired feedback ─────────────────────────────────────────────────────────────
|
||||
const FEEDBACK_FACTORS = [
|
||||
'Resume match',
|
||||
'Cover letter',
|
||||
'Interview prep',
|
||||
'Company research',
|
||||
'Network / referral',
|
||||
'Salary negotiation',
|
||||
] as const
|
||||
|
||||
const feedbackDismissed = ref(false)
|
||||
const feedbackSaved = ref(!!props.job.hired_feedback)
|
||||
const feedbackText = ref('')
|
||||
const feedbackFactors = ref<string[]>([])
|
||||
const feedbackSaving = ref(false)
|
||||
|
||||
const showFeedbackWidget = computed(() =>
|
||||
props.job.status === 'hired' && !feedbackDismissed.value && !feedbackSaved.value
|
||||
)
|
||||
|
||||
async function saveFeedback() {
|
||||
feedbackSaving.value = true
|
||||
const { error } = await useApiFetch(`/api/jobs/${props.job.id}/hired-feedback`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ what_helped: feedbackText.value, factors: feedbackFactors.value }),
|
||||
})
|
||||
feedbackSaving.value = false
|
||||
if (!error) feedbackSaved.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -307,6 +338,38 @@ const columnColor = computed(() => {
|
|||
@click.stop="sigExpanded = !sigExpanded"
|
||||
>{{ sigExpanded ? '− less' : `+${(job.stage_signals?.length ?? 1) - 1} more` }}</button>
|
||||
</template>
|
||||
|
||||
<!-- Hired feedback widget -->
|
||||
<div v-if="showFeedbackWidget" class="hired-feedback" @click.stop>
|
||||
<div class="hired-feedback__header">
|
||||
<span class="hired-feedback__title">What helped you land this role?</span>
|
||||
<button class="hired-feedback__dismiss" @click="feedbackDismissed = true" aria-label="Dismiss feedback">✕</button>
|
||||
</div>
|
||||
<div class="hired-feedback__factors">
|
||||
<label
|
||||
v-for="factor in FEEDBACK_FACTORS"
|
||||
:key="factor"
|
||||
class="hired-feedback__factor"
|
||||
>
|
||||
<input type="checkbox" :value="factor" v-model="feedbackFactors" />
|
||||
{{ factor }}
|
||||
</label>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="feedbackText"
|
||||
class="hired-feedback__textarea"
|
||||
placeholder="Anything else that made the difference…"
|
||||
rows="2"
|
||||
/>
|
||||
<button
|
||||
class="hired-feedback__save"
|
||||
:disabled="feedbackSaving"
|
||||
@click="saveFeedback"
|
||||
>{{ feedbackSaving ? 'Saving…' : 'Save reflection' }}</button>
|
||||
</div>
|
||||
<div v-else-if="job.status === 'hired' && feedbackSaved" class="hired-feedback hired-feedback--saved">
|
||||
Reflection saved.
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
|
|
@ -533,4 +596,80 @@ const columnColor = computed(() => {
|
|||
background: none; border: none; font-size: 0.75em; color: var(--color-info); cursor: pointer;
|
||||
padding: 4px 12px; text-align: left;
|
||||
}
|
||||
|
||||
/* ── Hired feedback widget ── */
|
||||
.hired-feedback {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-top: 1px solid var(--color-border);
|
||||
background: rgba(39, 174, 96, 0.04);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.hired-feedback--saved {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
}
|
||||
.hired-feedback__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.hired-feedback__title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-success);
|
||||
}
|
||||
.hired-feedback__dismiss {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: var(--text-sm);
|
||||
padding: 2px 4px;
|
||||
}
|
||||
.hired-feedback__factors {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.hired-feedback__factor {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.hired-feedback__textarea {
|
||||
width: 100%;
|
||||
font-size: var(--text-sm);
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.hired-feedback__textarea:focus-visible {
|
||||
outline: 2px solid var(--app-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.hired-feedback__save {
|
||||
align-self: flex-end;
|
||||
padding: var(--space-1) var(--space-3);
|
||||
font-size: var(--text-sm);
|
||||
background: var(--color-success);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.hired-feedback__save:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
}"
|
||||
:aria-label="`${job.title} at ${job.company}`"
|
||||
>
|
||||
<!-- Score badge + remote badge -->
|
||||
<!-- Score badge + remote badge + shadow badge -->
|
||||
<div class="job-card__badges">
|
||||
<span
|
||||
v-if="job.match_score !== null"
|
||||
|
|
@ -18,6 +18,18 @@
|
|||
{{ job.match_score }}%
|
||||
</span>
|
||||
<span v-if="job.is_remote" class="remote-badge">Remote</span>
|
||||
<span
|
||||
v-if="job.shadow_score === 'shadow'"
|
||||
class="shadow-badge shadow-badge--shadow"
|
||||
:title="`Posted 30+ days before discovery — may already be filled`"
|
||||
aria-label="Possible shadow listing: posted long before discovery"
|
||||
>Ghost post</span>
|
||||
<span
|
||||
v-else-if="job.shadow_score === 'stale'"
|
||||
class="shadow-badge shadow-badge--stale"
|
||||
:title="`Posted 14+ days before discovery — listing may be stale`"
|
||||
aria-label="Stale listing: posted over 2 weeks before discovery"
|
||||
>Stale</span>
|
||||
</div>
|
||||
|
||||
<!-- Title + company -->
|
||||
|
|
@ -178,6 +190,28 @@ const formattedDate = computed(() => {
|
|||
color: var(--app-primary);
|
||||
}
|
||||
|
||||
.shadow-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px var(--space-2);
|
||||
border-radius: 999px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.shadow-badge--shadow {
|
||||
background: rgba(99, 99, 99, 0.15);
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid rgba(99, 99, 99, 0.3);
|
||||
}
|
||||
|
||||
.shadow-badge--stale {
|
||||
background: rgba(212, 137, 26, 0.12);
|
||||
color: var(--score-mid);
|
||||
border: 1px solid rgba(212, 137, 26, 0.25);
|
||||
}
|
||||
|
||||
.job-card__title {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-xl);
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ export const router = createRouter({
|
|||
{ path: '/apply/:id', component: () => import('../views/ApplyWorkspaceView.vue') },
|
||||
{ path: '/resumes', component: () => import('../views/ResumesView.vue') },
|
||||
{ path: '/interviews', component: () => import('../views/InterviewsView.vue') },
|
||||
{ path: '/contacts', component: () => import('../views/ContactsView.vue') },
|
||||
{ path: '/references', component: () => import('../views/ReferencesView.vue') },
|
||||
{ path: '/digest', component: () => import('../views/DigestView.vue') },
|
||||
{ path: '/prep', component: () => import('../views/InterviewPrepView.vue') },
|
||||
{ path: '/prep/:id', component: () => import('../views/InterviewPrepView.vue') },
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export interface PipelineJob {
|
|||
offer_at: string | null
|
||||
hired_at: string | null
|
||||
survey_at: string | null
|
||||
hired_feedback: string | null // JSON: { what_helped, factors }
|
||||
stage_signals: StageSignal[] // undismissed signals, newest first
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,19 +3,21 @@ import { ref, computed } from 'vue'
|
|||
import { useApiFetch } from '../composables/useApi'
|
||||
|
||||
export interface Job {
|
||||
id: number
|
||||
title: string
|
||||
company: string
|
||||
url: string
|
||||
source: string | null
|
||||
location: string | null
|
||||
is_remote: boolean
|
||||
salary: string | null
|
||||
description: string | null
|
||||
match_score: number | null
|
||||
keyword_gaps: string | null // JSON-encoded string[]
|
||||
date_found: string
|
||||
status: string
|
||||
id: number
|
||||
title: string
|
||||
company: string
|
||||
url: string
|
||||
source: string | null
|
||||
location: string | null
|
||||
is_remote: boolean
|
||||
salary: string | null
|
||||
description: string | null
|
||||
match_score: number | null
|
||||
keyword_gaps: string | null // JSON-encoded string[]
|
||||
date_found: string
|
||||
date_posted: string | null
|
||||
shadow_score: 'shadow' | 'stale' | null
|
||||
status: string
|
||||
}
|
||||
|
||||
interface UndoEntry {
|
||||
|
|
|
|||
333
web/src/views/ContactsView.vue
Normal file
333
web/src/views/ContactsView.vue
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useApiFetch } from '../composables/useApi'
|
||||
|
||||
interface Contact {
|
||||
id: number
|
||||
job_id: number
|
||||
direction: 'inbound' | 'outbound'
|
||||
subject: string | null
|
||||
from_addr: string | null
|
||||
to_addr: string | null
|
||||
received_at: string | null
|
||||
stage_signal: string | null
|
||||
job_title: string | null
|
||||
job_company: string | null
|
||||
}
|
||||
|
||||
const contacts = ref<Contact[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const search = ref('')
|
||||
const direction = ref<'all' | 'inbound' | 'outbound'>('all')
|
||||
const searchInput = ref('')
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
async function fetchContacts() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
const params = new URLSearchParams({ limit: '100' })
|
||||
if (direction.value !== 'all') params.set('direction', direction.value)
|
||||
if (search.value) params.set('search', search.value)
|
||||
|
||||
const { data, error: fetchErr } = await useApiFetch<{ total: number; contacts: Contact[] }>(
|
||||
`/api/contacts?${params}`
|
||||
)
|
||||
loading.value = false
|
||||
if (fetchErr || !data) {
|
||||
error.value = 'Failed to load contacts.'
|
||||
return
|
||||
}
|
||||
contacts.value = data.contacts
|
||||
total.value = data.total
|
||||
}
|
||||
|
||||
function onSearchInput() {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => {
|
||||
search.value = searchInput.value
|
||||
fetchContacts()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function onDirectionChange() {
|
||||
fetchContacts()
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return '—'
|
||||
return new Date(iso).toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
function displayAddr(contact: Contact): string {
|
||||
return contact.direction === 'inbound'
|
||||
? contact.from_addr ?? '—'
|
||||
: contact.to_addr ?? '—'
|
||||
}
|
||||
|
||||
const signalLabel: Record<string, string> = {
|
||||
interview_scheduled: '📅 Interview',
|
||||
offer_received: '🟢 Offer',
|
||||
rejected: '✖ Rejected',
|
||||
positive_response: '✅ Positive',
|
||||
survey_received: '📋 Survey',
|
||||
}
|
||||
|
||||
onMounted(fetchContacts)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="contacts-view">
|
||||
<header class="contacts-header">
|
||||
<h1 class="contacts-title">Contacts</h1>
|
||||
<span class="contacts-count" v-if="total > 0">{{ total }} total</span>
|
||||
</header>
|
||||
|
||||
<div class="contacts-toolbar">
|
||||
<input
|
||||
v-model="searchInput"
|
||||
class="contacts-search"
|
||||
type="search"
|
||||
placeholder="Search name, email, or subject…"
|
||||
aria-label="Search contacts"
|
||||
@input="onSearchInput"
|
||||
/>
|
||||
<div class="contacts-filter" role="group" aria-label="Filter by direction">
|
||||
<button
|
||||
v-for="opt in (['all', 'inbound', 'outbound'] as const)"
|
||||
:key="opt"
|
||||
class="filter-btn"
|
||||
:class="{ 'filter-btn--active': direction === opt }"
|
||||
@click="direction = opt; onDirectionChange()"
|
||||
>{{ opt === 'all' ? 'All' : opt === 'inbound' ? 'Inbound' : 'Outbound' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="contacts-empty">Loading…</div>
|
||||
<div v-else-if="error" class="contacts-empty contacts-empty--error">{{ error }}</div>
|
||||
<div v-else-if="contacts.length === 0" class="contacts-empty">
|
||||
No contacts found{{ search ? ' for that search' : '' }}.
|
||||
</div>
|
||||
|
||||
<div v-else class="contacts-table-wrap">
|
||||
<table class="contacts-table" aria-label="Contacts">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Contact</th>
|
||||
<th>Subject</th>
|
||||
<th>Job</th>
|
||||
<th>Signal</th>
|
||||
<th>Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="c in contacts"
|
||||
:key="c.id"
|
||||
class="contacts-row"
|
||||
:class="{ 'contacts-row--inbound': c.direction === 'inbound' }"
|
||||
>
|
||||
<td class="contacts-cell contacts-cell--addr">
|
||||
<span class="dir-chip" :class="`dir-chip--${c.direction}`">
|
||||
{{ c.direction === 'inbound' ? '↓' : '↑' }}
|
||||
</span>
|
||||
{{ displayAddr(c) }}
|
||||
</td>
|
||||
<td class="contacts-cell contacts-cell--subject">
|
||||
{{ c.subject ? c.subject.slice(0, 60) + (c.subject.length > 60 ? '…' : '') : '—' }}
|
||||
</td>
|
||||
<td class="contacts-cell contacts-cell--job">
|
||||
<span v-if="c.job_title">
|
||||
{{ c.job_title }}<span v-if="c.job_company" class="job-company"> · {{ c.job_company }}</span>
|
||||
</span>
|
||||
<span v-else class="text-muted">—</span>
|
||||
</td>
|
||||
<td class="contacts-cell contacts-cell--signal">
|
||||
<span v-if="c.stage_signal && signalLabel[c.stage_signal]" class="signal-chip">
|
||||
{{ signalLabel[c.stage_signal] }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="contacts-cell contacts-cell--date">{{ formatDate(c.received_at) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.contacts-view {
|
||||
padding: var(--space-6);
|
||||
max-width: 1000px;
|
||||
}
|
||||
|
||||
.contacts-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.contacts-title {
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.contacts-count {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.contacts-toolbar {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.contacts-search {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.contacts-search:focus-visible {
|
||||
outline: 2px solid var(--app-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.contacts-filter {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: var(--space-1) var(--space-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filter-btn--active {
|
||||
background: var(--app-primary-light);
|
||||
color: var(--app-primary);
|
||||
border-color: var(--app-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.contacts-empty {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-sm);
|
||||
padding: var(--space-8) 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.contacts-empty--error {
|
||||
color: var(--color-error, #c0392b);
|
||||
}
|
||||
|
||||
.contacts-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.contacts-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.contacts-table th {
|
||||
text-align: left;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.contacts-row {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.contacts-row:hover {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.contacts-cell {
|
||||
padding: var(--space-3);
|
||||
vertical-align: top;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.contacts-cell--addr {
|
||||
white-space: nowrap;
|
||||
font-size: var(--text-xs);
|
||||
font-family: var(--font-mono);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.contacts-cell--subject {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.contacts-cell--job {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.job-company {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.contacts-cell--date {
|
||||
white-space: nowrap;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.dir-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dir-chip--inbound {
|
||||
background: rgba(39, 174, 96, 0.15);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.dir-chip--outbound {
|
||||
background: var(--app-primary-light);
|
||||
color: var(--app-primary);
|
||||
}
|
||||
|
||||
.signal-chip {
|
||||
font-size: var(--text-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
469
web/src/views/ReferencesView.vue
Normal file
469
web/src/views/ReferencesView.vue
Normal file
|
|
@ -0,0 +1,469 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useApiFetch } from '../composables/useApi'
|
||||
|
||||
export interface Reference {
|
||||
id: number
|
||||
name: string
|
||||
relationship: string
|
||||
company: string
|
||||
email: string
|
||||
phone: string
|
||||
notes: string
|
||||
tags: string // JSON-encoded string[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
const TAG_OPTIONS = ['technical', 'managerial', 'character', 'academic'] as const
|
||||
|
||||
const references = ref<Reference[]>([])
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const editingId = ref<number | 'new' | null>(null)
|
||||
const deleteConfirmId = ref<number | null>(null)
|
||||
|
||||
const blankForm = () => ({
|
||||
name: '', relationship: '', company: '', email: '', phone: '', notes: '', tags: [] as string[],
|
||||
})
|
||||
const form = ref(blankForm())
|
||||
|
||||
async function fetchRefs() {
|
||||
loading.value = true
|
||||
const { data } = await useApiFetch<Reference[]>('/api/references')
|
||||
loading.value = false
|
||||
if (data) references.value = data
|
||||
}
|
||||
|
||||
function parseTags(raw: string): string[] {
|
||||
try { return JSON.parse(raw) } catch { return [] }
|
||||
}
|
||||
|
||||
function startNew() {
|
||||
form.value = blankForm()
|
||||
editingId.value = 'new'
|
||||
}
|
||||
|
||||
function startEdit(ref: Reference) {
|
||||
form.value = {
|
||||
name: ref.name,
|
||||
relationship: ref.relationship,
|
||||
company: ref.company,
|
||||
email: ref.email,
|
||||
phone: ref.phone,
|
||||
notes: ref.notes,
|
||||
tags: parseTags(ref.tags),
|
||||
}
|
||||
editingId.value = ref.id
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingId.value = null
|
||||
form.value = blankForm()
|
||||
}
|
||||
|
||||
async function saveRef() {
|
||||
if (!form.value.name.trim()) return
|
||||
saving.value = true
|
||||
const isNew = editingId.value === 'new'
|
||||
const url = isNew ? '/api/references' : `/api/references/${editingId.value}`
|
||||
const { data, error } = await useApiFetch<Reference>(url, {
|
||||
method: isNew ? 'POST' : 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form.value),
|
||||
})
|
||||
saving.value = false
|
||||
if (error || !data) return
|
||||
if (isNew) {
|
||||
references.value = [...references.value, data]
|
||||
} else {
|
||||
references.value = references.value.map(r => r.id === data.id ? data : r)
|
||||
}
|
||||
cancelEdit()
|
||||
}
|
||||
|
||||
async function deleteRef(id: number) {
|
||||
await useApiFetch(`/api/references/${id}`, { method: 'DELETE' })
|
||||
references.value = references.value.filter(r => r.id !== id)
|
||||
deleteConfirmId.value = null
|
||||
}
|
||||
|
||||
function tagLabel(tag: string): string {
|
||||
const map: Record<string, string> = {
|
||||
technical: 'Technical', managerial: 'Manager', character: 'Character', academic: 'Academic',
|
||||
}
|
||||
return map[tag] ?? tag
|
||||
}
|
||||
|
||||
onMounted(fetchRefs)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="refs-view">
|
||||
<header class="refs-header">
|
||||
<div class="refs-header__left">
|
||||
<h1 class="refs-title">References</h1>
|
||||
<span v-if="references.length" class="refs-count">{{ references.length }}</span>
|
||||
</div>
|
||||
<button class="btn-add" @click="startNew" :disabled="editingId !== null">
|
||||
+ Add reference
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<p class="refs-hint">
|
||||
Store your references once, associate them with applications, and generate
|
||||
a heads-up email or draft recommendation letter with one click.
|
||||
</p>
|
||||
|
||||
<!-- New / edit form -->
|
||||
<div v-if="editingId !== null" class="ref-form">
|
||||
<h2 class="ref-form__title">{{ editingId === 'new' ? 'Add reference' : 'Edit reference' }}</h2>
|
||||
<div class="ref-form__grid">
|
||||
<label class="ref-form__field ref-form__field--full">
|
||||
<span>Name <span class="required">*</span></span>
|
||||
<input v-model="form.name" class="ref-input" placeholder="Full name" />
|
||||
</label>
|
||||
<label class="ref-form__field">
|
||||
<span>Relationship</span>
|
||||
<input v-model="form.relationship" class="ref-input" placeholder="e.g. Former manager" />
|
||||
</label>
|
||||
<label class="ref-form__field">
|
||||
<span>Company</span>
|
||||
<input v-model="form.company" class="ref-input" placeholder="Where you worked together" />
|
||||
</label>
|
||||
<label class="ref-form__field">
|
||||
<span>Email</span>
|
||||
<input v-model="form.email" class="ref-input" type="email" placeholder="email@example.com" />
|
||||
</label>
|
||||
<label class="ref-form__field">
|
||||
<span>Phone</span>
|
||||
<input v-model="form.phone" class="ref-input" type="tel" placeholder="+1 555 000 0000" />
|
||||
</label>
|
||||
<label class="ref-form__field ref-form__field--full">
|
||||
<span>Notes</span>
|
||||
<textarea v-model="form.notes" class="ref-input ref-textarea"
|
||||
placeholder="Accomplishments they can speak to, context, anything to remind yourself…"
|
||||
rows="2" />
|
||||
</label>
|
||||
<div class="ref-form__field ref-form__field--full">
|
||||
<span>Type</span>
|
||||
<div class="ref-tags">
|
||||
<label v-for="t in TAG_OPTIONS" :key="t" class="ref-tag-option">
|
||||
<input type="checkbox" :value="t" v-model="form.tags" />
|
||||
{{ tagLabel(t) }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ref-form__actions">
|
||||
<button class="btn-save" @click="saveRef" :disabled="saving || !form.name.trim()">
|
||||
{{ saving ? 'Saving…' : editingId === 'new' ? 'Add' : 'Save' }}
|
||||
</button>
|
||||
<button class="btn-cancel" @click="cancelEdit">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-if="!loading && references.length === 0 && editingId === null" class="refs-empty">
|
||||
No references yet. Add someone who can speak to your work.
|
||||
</div>
|
||||
|
||||
<!-- Reference cards -->
|
||||
<ul v-else class="refs-list" aria-label="References">
|
||||
<li
|
||||
v-for="ref in references"
|
||||
:key="ref.id"
|
||||
class="ref-card"
|
||||
:class="{ 'ref-card--editing': editingId === ref.id }"
|
||||
>
|
||||
<div class="ref-card__body">
|
||||
<div class="ref-card__name">{{ ref.name }}</div>
|
||||
<div class="ref-card__meta">
|
||||
<span v-if="ref.relationship">{{ ref.relationship }}</span>
|
||||
<span v-if="ref.relationship && ref.company" class="sep"> · </span>
|
||||
<span v-if="ref.company">{{ ref.company }}</span>
|
||||
</div>
|
||||
<div class="ref-card__contact">
|
||||
<a v-if="ref.email" :href="`mailto:${ref.email}`" class="ref-link">{{ ref.email }}</a>
|
||||
<span v-if="ref.email && ref.phone" class="sep"> · </span>
|
||||
<span v-if="ref.phone">{{ ref.phone }}</span>
|
||||
</div>
|
||||
<div v-if="ref.notes" class="ref-card__notes">{{ ref.notes }}</div>
|
||||
<div v-if="parseTags(ref.tags).length" class="ref-card__tags">
|
||||
<span v-for="t in parseTags(ref.tags)" :key="t" class="tag-chip" :class="`tag-chip--${t}`">
|
||||
{{ tagLabel(t) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ref-card__actions">
|
||||
<button class="btn-ghost" @click="startEdit(ref)" :disabled="editingId !== null">Edit</button>
|
||||
<button
|
||||
v-if="deleteConfirmId !== ref.id"
|
||||
class="btn-ghost btn-ghost--danger"
|
||||
@click="deleteConfirmId = ref.id"
|
||||
:disabled="editingId !== null"
|
||||
>Delete</button>
|
||||
<template v-else>
|
||||
<button class="btn-ghost btn-ghost--danger" @click="deleteRef(ref.id)">Confirm</button>
|
||||
<button class="btn-ghost" @click="deleteConfirmId = null">Cancel</button>
|
||||
</template>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.refs-view {
|
||||
padding: var(--space-6);
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.refs-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.refs-header__left {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.refs-title {
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.refs-count {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.refs-hint {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-muted);
|
||||
margin: 0 0 var(--space-5);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: var(--app-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-add:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* Form */
|
||||
.ref-form {
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
padding: var(--space-5);
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.ref-form__title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
margin: 0 0 var(--space-4);
|
||||
}
|
||||
|
||||
.ref-form__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.ref-form__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.ref-form__field--full { grid-column: 1 / -1; }
|
||||
|
||||
.required { color: var(--color-error, #c0392b); }
|
||||
|
||||
.ref-input {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.ref-input:focus-visible {
|
||||
outline: 2px solid var(--app-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.ref-textarea { resize: vertical; font-family: inherit; }
|
||||
|
||||
.ref-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.ref-tag-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ref-form__actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: var(--app-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-save:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.btn-cancel {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Empty */
|
||||
.refs-empty {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-sm);
|
||||
padding: var(--space-8) 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* List */
|
||||
.refs-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.ref-card {
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
padding: var(--space-4);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.ref-card--editing {
|
||||
border-color: var(--app-primary);
|
||||
box-shadow: 0 0 0 2px var(--app-primary-light);
|
||||
}
|
||||
|
||||
.ref-card__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ref-card__name {
|
||||
font-weight: 600;
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.ref-card__meta, .ref-card__contact {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.ref-card__notes {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 4px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.ref-link {
|
||||
color: var(--app-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.ref-link:hover { text-decoration: underline; }
|
||||
|
||||
.sep { margin: 0 2px; }
|
||||
|
||||
.ref-card__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.tag-chip {
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tag-chip--technical { background: var(--app-primary-light); color: var(--app-primary); }
|
||||
.tag-chip--managerial { background: rgba(39, 174, 96, 0.12); color: var(--color-success); }
|
||||
.tag-chip--character { background: rgba(212, 137, 26, 0.12); color: var(--score-mid); }
|
||||
.tag-chip--academic { background: rgba(103, 58, 183, 0.12); color: #7c3aed; }
|
||||
|
||||
.ref-card__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 3px var(--space-3);
|
||||
font-size: var(--text-xs);
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-ghost:hover { background: var(--color-hover); }
|
||||
.btn-ghost:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.btn-ghost--danger { color: var(--color-error, #c0392b); border-color: rgba(192, 57, 43, 0.3); }
|
||||
.btn-ghost--danger:hover { background: rgba(192, 57, 43, 0.07); }
|
||||
</style>
|
||||
Loading…
Reference in a new issue