diff --git a/compose.demo.yml b/compose.demo.yml index c6296c3..7d07d37 100644 --- a/compose.demo.yml +++ b/compose.demo.yml @@ -15,19 +15,21 @@ services: - app: + api: build: . - ports: - - "8504:8501" + command: > + bash -c "uvicorn dev_api:app --host 0.0.0.0 --port 8601" volumes: - ./demo/config:/app/config - - ./demo/data:/app/data - # No /docs mount — demo has no personal documents + - ./demo:/app/demo:ro # seed.sql lives here; read-only + # /app/data is tmpfs — ephemeral, resets on every container start + tmpfs: + - /app/data environment: - DEMO_MODE=true - STAGING_DB=/app/data/staging.db + - DEMO_SEED_FILE=/app/demo/seed.sql - DOCS_DIR=/tmp/demo-docs - - STREAMLIT_SERVER_BASE_URL_PATH=peregrine - PYTHONUNBUFFERED=1 - PYTHONLOGGING=WARNING # No API keys — inference is blocked by DEMO_MODE before any key is needed @@ -37,6 +39,7 @@ services: extra_hosts: - "host.docker.internal:host-gateway" restart: unless-stopped + # No host port — nginx proxies /api/ → api:8601 internally web: build: @@ -45,7 +48,9 @@ services: args: VITE_BASE_PATH: /peregrine/ ports: - - "8507:80" + - "8504:80" # demo.circuitforge.tech/peregrine* → host:8504 + depends_on: + - api restart: unless-stopped searxng: diff --git a/demo/seed.sql b/demo/seed.sql new file mode 100644 index 0000000..935682d --- /dev/null +++ b/demo/seed.sql @@ -0,0 +1,54 @@ +-- jobs +INSERT INTO jobs (title, company, url, source, location, is_remote, salary, match_score, status, date_found, date_posted, cover_letter, applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, interview_date, rejection_stage, hired_feedback) VALUES ('UX Designer', 'Spotify', 'https://www.linkedin.com/jobs/view/1000001', 'linkedin', 'Remote', '1', '$110k–$140k', '94.0', 'approved', '2026-04-14', '2026-04-12', 'Dear Hiring Manager, + +I''m excited to apply for the UX Designer role at Spotify. With five years of +experience designing for music discovery and cross-platform experiences, I''ve +consistently shipped features that make complex audio content feel effortless to +navigate. At my last role I led a redesign of the playlist creation flow that +reduced drop-off by 31%. + +Spotify''s commitment to artist and listener discovery — and its recent push into +audiobooks and podcast tooling — aligns directly with the kind of cross-format +design challenges I''m most energised by. + +I''d love to bring that focus to your product design team. + +Warm regards, +[Your name] +', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL); +INSERT INTO jobs (title, company, url, source, location, is_remote, salary, match_score, status, date_found, date_posted, cover_letter, applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, interview_date, rejection_stage, hired_feedback) VALUES ('Product Designer', 'Duolingo', 'https://www.linkedin.com/jobs/view/1000002', 'linkedin', 'Pittsburgh, PA', '0', '$95k–$120k', '87.0', 'approved', '2026-04-13', '2026-04-10', 'Draft in progress — cover letter generating…', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL); +INSERT INTO jobs (title, company, url, source, location, is_remote, salary, match_score, status, date_found, date_posted, cover_letter, applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, interview_date, rejection_stage, hired_feedback) VALUES ('UX Lead', 'NPR', 'https://www.indeed.com/viewjob?jk=1000003', 'indeed', 'Washington, DC', '1', '$120k–$150k', '81.0', 'approved', '2026-04-12', '2026-04-08', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL); +INSERT INTO jobs (title, company, url, source, location, is_remote, salary, match_score, status, date_found, date_posted, cover_letter, applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, interview_date, rejection_stage, hired_feedback) VALUES ('Senior UX Designer', 'Mozilla', 'https://www.linkedin.com/jobs/view/1000004', 'linkedin', 'Remote', '1', '$105k–$130k', '81.0', 'pending', '2026-04-13', '2026-03-12', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL); +INSERT INTO jobs (title, company, url, source, location, is_remote, salary, match_score, status, date_found, date_posted, cover_letter, applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, interview_date, rejection_stage, hired_feedback) VALUES ('Interaction Designer', 'Figma', 'https://www.indeed.com/viewjob?jk=1000005', 'indeed', 'San Francisco, CA', '1', '$115k–$145k', '78.0', 'pending', '2026-04-11', '2026-04-09', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL); +INSERT INTO jobs (title, company, url, source, location, is_remote, salary, match_score, status, date_found, date_posted, cover_letter, applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, interview_date, rejection_stage, hired_feedback) VALUES ('Product Designer II', 'Notion', 'https://www.linkedin.com/jobs/view/1000006', 'linkedin', 'Remote', '1', '$100k–$130k', '76.0', 'pending', '2026-04-10', '2026-04-07', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL); +INSERT INTO jobs (title, company, url, source, location, is_remote, salary, match_score, status, date_found, date_posted, cover_letter, applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, interview_date, rejection_stage, hired_feedback) VALUES ('UX Designer', 'Stripe', 'https://www.linkedin.com/jobs/view/1000007', 'linkedin', 'Remote', '1', '$120k–$150k', '74.0', 'pending', '2026-04-09', '2026-04-06', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL); +INSERT INTO jobs (title, company, url, source, location, is_remote, salary, match_score, status, date_found, date_posted, cover_letter, applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, interview_date, rejection_stage, hired_feedback) VALUES ('UI/UX Designer', 'Canva', 'https://www.indeed.com/viewjob?jk=1000008', 'indeed', 'Remote', '1', '$90k–$115k', '72.0', 'pending', '2026-04-08', '2026-04-05', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL); +INSERT INTO jobs (title, company, url, source, location, is_remote, salary, match_score, status, date_found, date_posted, cover_letter, applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, interview_date, rejection_stage, hired_feedback) VALUES ('Senior Product Designer', 'Asana', 'https://www.linkedin.com/jobs/view/1000009', 'linkedin', 'San Francisco, CA', '1', '$125k–$155k', '69.0', 'pending', '2026-04-07', '2026-04-04', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL); +INSERT INTO jobs (title, company, url, source, location, is_remote, salary, match_score, status, date_found, date_posted, cover_letter, applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, interview_date, rejection_stage, hired_feedback) VALUES ('UX Researcher', 'Intercom', 'https://www.indeed.com/viewjob?jk=1000010', 'indeed', 'Remote', '1', '$95k–$120k', '67.0', 'pending', '2026-04-06', '2026-04-03', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL); +INSERT INTO jobs (title, company, url, source, location, is_remote, salary, match_score, status, date_found, date_posted, cover_letter, applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, interview_date, rejection_stage, hired_feedback) VALUES ('Product Designer', 'Linear', 'https://www.linkedin.com/jobs/view/1000011', 'linkedin', 'Remote', '1', '$110k–$135k', '65.0', 'pending', '2026-04-05', '2026-04-02', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL); +INSERT INTO jobs (title, company, url, source, location, is_remote, salary, match_score, status, date_found, date_posted, cover_letter, applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, interview_date, rejection_stage, hired_feedback) VALUES ('UX Designer', 'Loom', 'https://www.indeed.com/viewjob?jk=1000012', 'indeed', 'Remote', '1', '$90k–$110k', '62.0', 'pending', '2026-04-04', '2026-04-01', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL); +INSERT INTO jobs (title, company, url, source, location, is_remote, salary, match_score, status, date_found, date_posted, cover_letter, applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, interview_date, rejection_stage, hired_feedback) VALUES ('Senior Product Designer', 'Asana', 'https://www.asana.com/jobs/1000013', 'linkedin', 'San Francisco, CA', '1', '$125k–$155k', '91.0', 'phone_screen', '2026-04-01', '2026-03-30', NULL, '2026-04-08', '2026-04-15', NULL, NULL, NULL, '2026-04-15T14:00:00', NULL, NULL); +INSERT INTO jobs (title, company, url, source, location, is_remote, salary, match_score, status, date_found, date_posted, cover_letter, applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, interview_date, rejection_stage, hired_feedback) VALUES ('Product Designer', 'Notion', 'https://www.notion.so/jobs/1000014', 'indeed', 'Remote', '1', '$100k–$130k', '88.0', 'interviewing', '2026-03-25', '2026-03-23', NULL, '2026-04-01', '2026-04-05', '2026-04-12', NULL, NULL, '2026-04-22T10:00:00', NULL, NULL); +INSERT INTO jobs (title, company, url, source, location, is_remote, salary, match_score, status, date_found, date_posted, cover_letter, applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, interview_date, rejection_stage, hired_feedback) VALUES ('Design Systems Designer', 'Figma', 'https://www.figma.com/jobs/1000015', 'linkedin', 'San Francisco, CA', '1', '$130k–$160k', '96.0', 'hired', '2026-03-01', '2026-02-27', NULL, '2026-03-08', '2026-03-14', '2026-03-21', '2026-04-01', '2026-04-08', NULL, NULL, '{"factors":["clear_scope","great_manager","mission_aligned"],"notes":"Excited about design systems work. Salary met expectations."}'); +INSERT INTO jobs (title, company, url, source, location, is_remote, salary, match_score, status, date_found, date_posted, cover_letter, applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, interview_date, rejection_stage, hired_feedback) VALUES ('UX Designer', 'Slack', 'https://slack.com/jobs/1000016', 'indeed', 'Remote', '1', '$115k–$140k', '79.0', 'applied', '2026-03-18', '2026-03-16', NULL, '2026-03-28', NULL, NULL, NULL, NULL, NULL, NULL, NULL); + +-- job_contacts +INSERT INTO job_contacts (job_id, direction, subject, from_addr, to_addr, received_at, stage_signal) VALUES (1, 'inbound', 'Excited to connect — UX Designer role at Spotify', 'jamie.chen@spotify.com', 'you@example.com', '2026-04-12', 'positive_response'); +INSERT INTO job_contacts (job_id, direction, subject, from_addr, to_addr, received_at, stage_signal) VALUES (1, 'outbound', 'Re: Excited to connect — UX Designer role at Spotify', 'you@example.com', 'jamie.chen@spotify.com', '2026-04-13', NULL); +INSERT INTO job_contacts (job_id, direction, subject, from_addr, to_addr, received_at, stage_signal) VALUES (13, 'inbound', 'Interview Confirmation — Senior Product Designer', 'recruiting@asana.com', 'you@example.com', '2026-04-13', 'interview_scheduled'); +INSERT INTO job_contacts (job_id, direction, subject, from_addr, to_addr, received_at, stage_signal) VALUES (14, 'inbound', 'Your panel interview is confirmed for Apr 22', 'recruiting@notion.so', 'you@example.com', '2026-04-12', 'interview_scheduled'); +INSERT INTO job_contacts (job_id, direction, subject, from_addr, to_addr, received_at, stage_signal) VALUES (14, 'inbound', 'Pre-interview prep resources', 'marcus.webb@notion.so', 'you@example.com', '2026-04-13', 'positive_response'); +INSERT INTO job_contacts (job_id, direction, subject, from_addr, to_addr, received_at, stage_signal) VALUES (15, 'inbound', 'Figma Design Systems — Offer Letter', 'offers@figma.com', 'you@example.com', '2026-04-01', 'offer_received'); +INSERT INTO job_contacts (job_id, direction, subject, from_addr, to_addr, received_at, stage_signal) VALUES (15, 'outbound', 'Re: Figma Design Systems — Offer Letter (acceptance)', 'you@example.com', 'offers@figma.com', '2026-04-05', NULL); +INSERT INTO job_contacts (job_id, direction, subject, from_addr, to_addr, received_at, stage_signal) VALUES (15, 'inbound', 'Welcome to Figma! Onboarding next steps', 'onboarding@figma.com', 'you@example.com', '2026-04-08', NULL); +INSERT INTO job_contacts (job_id, direction, subject, from_addr, to_addr, received_at, stage_signal) VALUES (16, 'inbound', 'Thanks for applying to Slack', 'noreply@slack.com', 'you@example.com', '2026-03-28', NULL); + +-- references_ +INSERT INTO references_ (name, email, role, company, relationship, notes, tags, prep_email) VALUES ('Dr. Priya Nair', 'priya.nair@example.com', 'Director of Design', 'Acme Corp', 'former_manager', 'Managed me for 3 years on the consumer app redesign. Enthusiastic reference.', '["manager","design"]', 'Hi Priya, + +I hope you''re doing well! I''m currently interviewing for a few senior UX roles and would be so grateful if you''d be willing to serve as a reference. + +Thank you! +[Your name]'); +INSERT INTO references_ (name, email, role, company, relationship, notes, tags, prep_email) VALUES ('Sam Torres', 'sam.torres@example.com', 'Senior Product Designer', 'Acme Corp', 'former_colleague', 'Worked together on design systems. Great at speaking to collaborative process.', '["colleague","design_systems"]', NULL); +INSERT INTO references_ (name, email, role, company, relationship, notes, tags, prep_email) VALUES ('Jordan Kim', 'jordan.kim@example.com', 'VP of Product', 'Streamline Inc', 'former_manager', 'Led the product team I was embedded in. Can speak to business impact of design work.', '["manager","product"]', NULL); diff --git a/dev-api.py b/dev-api.py index 401b300..50fa6dd 100644 --- a/dev-api.py +++ b/dev-api.py @@ -46,18 +46,37 @@ DB_PATH = os.environ.get("STAGING_DB", "/devl/job-seeker/staging.db") _CLOUD_MODE = os.environ.get("CLOUD_MODE", "").lower() in ("1", "true") _CLOUD_DATA_ROOT = Path(os.environ.get("CLOUD_DATA_ROOT", "/devl/menagerie-data")) _DIRECTUS_SECRET = os.environ.get("DIRECTUS_JWT_SECRET", "") +IS_DEMO: bool = os.environ.get("DEMO_MODE", "").lower() in ("1", "true", "yes") # Per-request DB path — set by cloud_session_middleware; falls back to DB_PATH _request_db: ContextVar[str | None] = ContextVar("_request_db", default=None) +def _load_demo_seed(db_path: str, seed_file: str) -> None: + """Load seed SQL into the demo DB if it is empty (no jobs rows yet).""" + import sqlite3 as _sqlite3 + seed_path = Path(seed_file) + if not seed_path.exists(): + return + con = _sqlite3.connect(db_path) + try: + count = con.execute("SELECT COUNT(*) FROM jobs").fetchone()[0] + if count == 0: + con.executescript(seed_path.read_text()) + con.commit() + finally: + con.close() + + @asynccontextmanager async def lifespan(app: FastAPI): - """Load .env then run pending SQLite migrations on startup.""" + """Load .env, run migrations, and (in demo mode) seed the demo DB.""" # Load .env before any runtime env reads — safe because lifespan doesn't run # when dev_api is imported by tests (only when uvicorn actually starts). _load_env(PEREGRINE_ROOT / ".env") from scripts.db_migrate import migrate_db migrate_db(Path(DB_PATH)) + if IS_DEMO and (seed_file := os.environ.get("DEMO_SEED_FILE")): + _load_demo_seed(DB_PATH, seed_file) yield @@ -81,6 +100,12 @@ app.include_router(_feedback_router, prefix="/api/feedback") _log = logging.getLogger("peregrine.session") + +def _demo_guard() -> None: + """Raise 403 if running in demo mode. Call at the top of any write endpoint.""" + if IS_DEMO: + raise HTTPException(status_code=403, detail="demo-write-blocked") + def _resolve_cf_user_id(cookie_str: str) -> str | None: """Extract cf_session JWT from Cookie string and return Directus user_id. @@ -296,6 +321,7 @@ def job_counts(): @app.post("/api/jobs/{job_id}/approve") def approve_job(job_id: int): + _demo_guard() db = _get_db() db.execute("UPDATE jobs SET status = 'approved' WHERE id = ?", (job_id,)) db.commit() @@ -307,6 +333,7 @@ def approve_job(job_id: int): @app.post("/api/jobs/{job_id}/reject") def reject_job(job_id: int): + _demo_guard() db = _get_db() db.execute("UPDATE jobs SET status = 'rejected' WHERE id = ?", (job_id,)) db.commit() @@ -396,6 +423,7 @@ def save_cover_letter(job_id: int, body: CoverLetterBody): @app.post("/api/jobs/{job_id}/cover_letter/generate") def generate_cover_letter(job_id: int): + _demo_guard() try: from scripts.task_runner import submit_task task_id, is_new = submit_task( @@ -1520,6 +1548,7 @@ class HiredFeedbackPayload(BaseModel): @app.post("/api/jobs/{job_id}/hired-feedback") def save_hired_feedback(job_id: int, payload: HiredFeedbackPayload): + _demo_guard() db = _get_db() row = db.execute("SELECT status FROM jobs WHERE id = ?", (job_id,)).fetchone() if not row: diff --git a/migrations/006_missing_columns.sql b/migrations/006_missing_columns.sql new file mode 100644 index 0000000..f44759a --- /dev/null +++ b/migrations/006_missing_columns.sql @@ -0,0 +1,22 @@ +-- Migration 006: Add columns and tables present in the live DB but missing from migrations +-- These were added via direct ALTER TABLE after the v0.8.5 baseline was written. + +-- date_posted: used for ghost-post shadow-score detection +ALTER TABLE jobs ADD COLUMN date_posted TEXT; + +-- hired_feedback: JSON blob saved when a job reaches the 'hired' outcome +ALTER TABLE jobs ADD COLUMN hired_feedback TEXT; + +-- references_ table: contacts who can provide references for applications +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, + prep_email TEXT, + role TEXT +); diff --git a/scripts/generate_demo_seed.py b/scripts/generate_demo_seed.py new file mode 100644 index 0000000..9881fe4 --- /dev/null +++ b/scripts/generate_demo_seed.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +""" +Generate demo/seed.sql — committed seed INSERT statements for the demo DB. + +Run whenever seed data needs to change: + conda run -n cf python scripts/generate_demo_seed.py + +Outputs pure INSERT SQL (no DDL). Schema migrations are handled by db_migrate.py +at container startup. The seed SQL is loaded after migrations complete. +""" +from __future__ import annotations + +from datetime import date, timedelta +from pathlib import Path + +OUT_PATH = Path(__file__).parent.parent / "demo" / "seed.sql" + +TODAY = date.today() + + +def _dago(n: int) -> str: + return (TODAY - timedelta(days=n)).isoformat() + + +def _dfrom(n: int) -> str: + return (TODAY + timedelta(days=n)).isoformat() + + +COVER_LETTER_SPOTIFY = """\ +Dear Hiring Manager, + +I'm excited to apply for the UX Designer role at Spotify. With five years of +experience designing for music discovery and cross-platform experiences, I've +consistently shipped features that make complex audio content feel effortless to +navigate. At my last role I led a redesign of the playlist creation flow that +reduced drop-off by 31%. + +Spotify's commitment to artist and listener discovery — and its recent push into +audiobooks and podcast tooling — aligns directly with the kind of cross-format +design challenges I'm most energised by. + +I'd love to bring that focus to your product design team. + +Warm regards, +[Your name] +""" + +SQL_PARTS: list[str] = [] + +# ── Jobs ────────────────────────────────────────────────────────────────────── + +# Columns: title, company, url, source, location, is_remote, salary, +# match_score, status, date_found, date_posted, cover_letter, +# applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, +# interview_date, rejection_stage, hired_feedback +JOBS: list[tuple] = [ + # ---- Review queue (12 jobs — mix of pending + approved) ------------------ + ("UX Designer", + "Spotify", "https://www.linkedin.com/jobs/view/1000001", + "linkedin", "Remote", 1, "$110k–$140k", + 94.0, "approved", _dago(1), _dago(3), COVER_LETTER_SPOTIFY, + None, None, None, None, None, None, None, None), + + ("Product Designer", + "Duolingo", "https://www.linkedin.com/jobs/view/1000002", + "linkedin", "Pittsburgh, PA", 0, "$95k–$120k", + 87.0, "approved", _dago(2), _dago(5), "Draft in progress — cover letter generating…", + None, None, None, None, None, None, None, None), + + ("UX Lead", + "NPR", "https://www.indeed.com/viewjob?jk=1000003", + "indeed", "Washington, DC", 1, "$120k–$150k", + 81.0, "approved", _dago(3), _dago(7), None, + None, None, None, None, None, None, None, None), + + # Ghost post — date_posted 34 days ago → shadow indicator + ("Senior UX Designer", + "Mozilla", "https://www.linkedin.com/jobs/view/1000004", + "linkedin", "Remote", 1, "$105k–$130k", + 81.0, "pending", _dago(2), _dago(34), None, + None, None, None, None, None, None, None, None), + + ("Interaction Designer", + "Figma", "https://www.indeed.com/viewjob?jk=1000005", + "indeed", "San Francisco, CA", 1, "$115k–$145k", + 78.0, "pending", _dago(4), _dago(6), None, + None, None, None, None, None, None, None, None), + + ("Product Designer II", + "Notion", "https://www.linkedin.com/jobs/view/1000006", + "linkedin", "Remote", 1, "$100k–$130k", + 76.0, "pending", _dago(5), _dago(8), None, + None, None, None, None, None, None, None, None), + + ("UX Designer", + "Stripe", "https://www.linkedin.com/jobs/view/1000007", + "linkedin", "Remote", 1, "$120k–$150k", + 74.0, "pending", _dago(6), _dago(9), None, + None, None, None, None, None, None, None, None), + + ("UI/UX Designer", + "Canva", "https://www.indeed.com/viewjob?jk=1000008", + "indeed", "Remote", 1, "$90k–$115k", + 72.0, "pending", _dago(7), _dago(10), None, + None, None, None, None, None, None, None, None), + + ("Senior Product Designer", + "Asana", "https://www.linkedin.com/jobs/view/1000009", + "linkedin", "San Francisco, CA", 1, "$125k–$155k", + 69.0, "pending", _dago(8), _dago(11), None, + None, None, None, None, None, None, None, None), + + ("UX Researcher", + "Intercom", "https://www.indeed.com/viewjob?jk=1000010", + "indeed", "Remote", 1, "$95k–$120k", + 67.0, "pending", _dago(9), _dago(12), None, + None, None, None, None, None, None, None, None), + + ("Product Designer", + "Linear", "https://www.linkedin.com/jobs/view/1000011", + "linkedin", "Remote", 1, "$110k–$135k", + 65.0, "pending", _dago(10), _dago(13), None, + None, None, None, None, None, None, None, None), + + ("UX Designer", + "Loom", "https://www.indeed.com/viewjob?jk=1000012", + "indeed", "Remote", 1, "$90k–$110k", + 62.0, "pending", _dago(11), _dago(14), None, + None, None, None, None, None, None, None, None), + + # ---- Pipeline jobs (applied → hired) ------------------------------------ + ("Senior Product Designer", + "Asana", "https://www.asana.com/jobs/1000013", + "linkedin", "San Francisco, CA", 1, "$125k–$155k", + 91.0, "phone_screen", _dago(14), _dago(16), None, + _dago(7), _dfrom(0), None, None, None, + f"{_dfrom(0)}T14:00:00", None, None), + + ("Product Designer", + "Notion", "https://www.notion.so/jobs/1000014", + "indeed", "Remote", 1, "$100k–$130k", + 88.0, "interviewing", _dago(21), _dago(23), None, + _dago(14), _dago(10), _dago(3), None, None, + f"{_dfrom(7)}T10:00:00", None, None), + + ("Design Systems Designer", + "Figma", "https://www.figma.com/jobs/1000015", + "linkedin", "San Francisco, CA", 1, "$130k–$160k", + 96.0, "hired", _dago(45), _dago(47), None, + _dago(38), _dago(32), _dago(25), _dago(14), _dago(7), + None, None, + '{"factors":["clear_scope","great_manager","mission_aligned"],"notes":"Excited about design systems work. Salary met expectations."}'), + + ("UX Designer", + "Slack", "https://slack.com/jobs/1000016", + "indeed", "Remote", 1, "$115k–$140k", + 79.0, "applied", _dago(28), _dago(30), None, + _dago(18), None, None, None, None, None, None, None), +] + + +def _q(v: object) -> str: + """SQL-quote a Python value.""" + if v is None: + return "NULL" + return "'" + str(v).replace("'", "''") + "'" + + +_JOB_COLS = ( + "title, company, url, source, location, is_remote, salary, " + "match_score, status, date_found, date_posted, cover_letter, " + "applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, " + "interview_date, rejection_stage, hired_feedback" +) + +SQL_PARTS.append("-- jobs") +for job in JOBS: + vals = ", ".join(_q(v) for v in job) + SQL_PARTS.append(f"INSERT INTO jobs ({_JOB_COLS}) VALUES ({vals});") + +# ── Contacts ────────────────────────────────────────────────────────────────── + +# (job_id, direction, subject, from_addr, to_addr, received_at, stage_signal) +CONTACTS: list[tuple] = [ + (1, "inbound", "Excited to connect — UX Designer role at Spotify", + "jamie.chen@spotify.com", "you@example.com", _dago(3), "positive_response"), + (1, "outbound", "Re: Excited to connect — UX Designer role at Spotify", + "you@example.com", "jamie.chen@spotify.com", _dago(2), None), + (13, "inbound", "Interview Confirmation — Senior Product Designer", + "recruiting@asana.com", "you@example.com", _dago(2), "interview_scheduled"), + (14, "inbound", "Your panel interview is confirmed for Apr 22", + "recruiting@notion.so", "you@example.com", _dago(3), "interview_scheduled"), + (14, "inbound", "Pre-interview prep resources", + "marcus.webb@notion.so", "you@example.com", _dago(2), "positive_response"), + (15, "inbound", "Figma Design Systems — Offer Letter", + "offers@figma.com", "you@example.com", _dago(14), "offer_received"), + (15, "outbound", "Re: Figma Design Systems — Offer Letter (acceptance)", + "you@example.com", "offers@figma.com", _dago(10), None), + (15, "inbound", "Welcome to Figma! Onboarding next steps", + "onboarding@figma.com", "you@example.com", _dago(7), None), + (16, "inbound", "Thanks for applying to Slack", + "noreply@slack.com", "you@example.com", _dago(18), None), +] + +SQL_PARTS.append("\n-- job_contacts") +for c in CONTACTS: + job_id, direction, subject, from_addr, to_addr, received_at, stage_signal = c + SQL_PARTS.append( + f"INSERT INTO job_contacts " + f"(job_id, direction, subject, from_addr, to_addr, received_at, stage_signal) " + f"VALUES ({job_id}, {_q(direction)}, {_q(subject)}, {_q(from_addr)}, " + f"{_q(to_addr)}, {_q(received_at)}, {_q(stage_signal)});" + ) + +# ── References ──────────────────────────────────────────────────────────────── + +# (name, email, role, company, relationship, notes, tags, prep_email) +REFERENCES: list[tuple] = [ + ("Dr. Priya Nair", "priya.nair@example.com", "Director of Design", "Acme Corp", + "former_manager", + "Managed me for 3 years on the consumer app redesign. Enthusiastic reference.", + '["manager","design"]', + "Hi Priya,\n\nI hope you're doing well! I'm currently interviewing for a few senior UX roles " + "and would be so grateful if you'd be willing to serve as a reference.\n\nThank you!\n[Your name]"), + + ("Sam Torres", "sam.torres@example.com", "Senior Product Designer", "Acme Corp", + "former_colleague", + "Worked together on design systems. Great at speaking to collaborative process.", + '["colleague","design_systems"]', None), + + ("Jordan Kim", "jordan.kim@example.com", "VP of Product", "Streamline Inc", + "former_manager", + "Led the product team I was embedded in. Can speak to business impact of design work.", + '["manager","product"]', None), +] + +SQL_PARTS.append("\n-- references_") +for ref in REFERENCES: + name, email, role, company, relationship, notes, tags, prep_email = ref + SQL_PARTS.append( + f"INSERT INTO references_ " + f"(name, email, role, company, relationship, notes, tags, prep_email) " + f"VALUES ({_q(name)}, {_q(email)}, {_q(role)}, {_q(company)}, " + f"{_q(relationship)}, {_q(notes)}, {_q(tags)}, {_q(prep_email)});" + ) + +# ── Write output ────────────────────────────────────────────────────────────── + +output = "\n".join(SQL_PARTS) + "\n" +OUT_PATH.write_text(output, encoding="utf-8") +print( + f"Wrote {OUT_PATH} " + f"({len(JOBS)} jobs, {len(CONTACTS)} contacts, {len(REFERENCES)} references)" +) diff --git a/tests/test_demo_guard.py b/tests/test_demo_guard.py new file mode 100644 index 0000000..63cfa78 --- /dev/null +++ b/tests/test_demo_guard.py @@ -0,0 +1,89 @@ +"""IS_DEMO write-block guard tests.""" +import importlib +import os +import sqlite3 + +import pytest +from fastapi.testclient import TestClient + +_SCHEMA = """ +CREATE TABLE jobs ( + id INTEGER PRIMARY KEY, title TEXT, company TEXT, url TEXT, + location TEXT, is_remote INTEGER DEFAULT 0, salary TEXT, + match_score REAL, keyword_gaps TEXT, status TEXT DEFAULT 'pending', + date_found TEXT, cover_letter TEXT, interview_date TEXT, + rejection_stage TEXT, applied_at TEXT, phone_screen_at TEXT, + interviewing_at TEXT, offer_at TEXT, hired_at TEXT, + survey_at TEXT, date_posted TEXT, hired_feedback TEXT +); +CREATE TABLE background_tasks ( + id INTEGER PRIMARY KEY, task_type TEXT, job_id INTEGER, + status TEXT DEFAULT 'queued', finished_at TEXT +); +""" + + +def _make_db(path: str) -> None: + con = sqlite3.connect(path) + con.executescript(_SCHEMA) + con.execute( + "INSERT INTO jobs (id, title, company, url, status) VALUES (1,'UX Designer','Spotify','https://ex.com/1','pending')" + ) + con.execute( + "INSERT INTO jobs (id, title, company, url, status) VALUES (2,'Designer','Figma','https://ex.com/2','hired')" + ) + con.commit() + con.close() + + +@pytest.fixture() +def demo_client(tmp_path, monkeypatch): + db_path = str(tmp_path / "staging.db") + _make_db(db_path) + monkeypatch.setenv("DEMO_MODE", "true") + monkeypatch.setenv("STAGING_DB", db_path) + import dev_api + importlib.reload(dev_api) + return TestClient(dev_api.app) + + +@pytest.fixture() +def normal_client(tmp_path, monkeypatch): + db_path = str(tmp_path / "staging.db") + _make_db(db_path) + monkeypatch.delenv("DEMO_MODE", raising=False) + monkeypatch.setenv("STAGING_DB", db_path) + import dev_api + importlib.reload(dev_api) + return TestClient(dev_api.app) + + +class TestDemoWriteBlock: + def test_approve_blocked_in_demo(self, demo_client): + r = demo_client.post("/api/jobs/1/approve") + assert r.status_code == 403 + assert r.json()["detail"] == "demo-write-blocked" + + def test_reject_blocked_in_demo(self, demo_client): + r = demo_client.post("/api/jobs/1/reject") + assert r.status_code == 403 + assert r.json()["detail"] == "demo-write-blocked" + + def test_cover_letter_generate_blocked_in_demo(self, demo_client): + r = demo_client.post("/api/jobs/1/cover_letter/generate") + assert r.status_code == 403 + assert r.json()["detail"] == "demo-write-blocked" + + def test_hired_feedback_blocked_in_demo(self, demo_client): + r = demo_client.post("/api/jobs/2/hired-feedback", json={"factors": [], "notes": ""}) + assert r.status_code == 403 + assert r.json()["detail"] == "demo-write-blocked" + + def test_approve_allowed_in_normal_mode(self, normal_client): + r = normal_client.post("/api/jobs/1/approve") + assert r.status_code != 403 + + def test_config_reports_is_demo_true(self, demo_client): + r = demo_client.get("/api/config/app") + assert r.status_code == 200 + assert r.json()["isDemo"] is True diff --git a/web/src/App.vue b/web/src/App.vue index fb16f04..d3d7fc4 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -7,10 +7,9 @@ - -
- 👁 Demo mode — changes are not saved and AI features are disabled. -
+ + + @@ -32,6 +31,8 @@ import { useHackerMode, useKonamiCode } from './composables/useEasterEgg' import { useTheme } from './composables/useTheme' import { useToast } from './composables/useToast' import AppNav from './components/AppNav.vue' +import DemoBanner from './components/DemoBanner.vue' +import WelcomeModal from './components/WelcomeModal.vue' import { useAppConfigStore } from './stores/appConfig' import { useDigestStore } from './stores/digest' @@ -128,20 +129,6 @@ body { padding-bottom: 0; } -/* Demo mode banner — sticky top bar */ -.demo-banner { - position: sticky; - top: 0; - z-index: 200; - background: var(--color-warning); - color: #1a1a1a; /* forced dark — warning bg is always light enough */ - text-align: center; - font-size: 0.85rem; - font-weight: 600; - padding: 6px var(--space-4, 16px); - letter-spacing: 0.01em; -} - /* Global toast — bottom-center, above tab bar */ .global-toast { position: fixed; diff --git a/web/src/components/DemoBanner.vue b/web/src/components/DemoBanner.vue new file mode 100644 index 0000000..51c1cd5 --- /dev/null +++ b/web/src/components/DemoBanner.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/web/src/components/HintChip.vue b/web/src/components/HintChip.vue new file mode 100644 index 0000000..dc1621e --- /dev/null +++ b/web/src/components/HintChip.vue @@ -0,0 +1,63 @@ + + + + + diff --git a/web/src/components/JobCardStack.vue b/web/src/components/JobCardStack.vue index 6335fce..27e407e 100644 --- a/web/src/components/JobCardStack.vue +++ b/web/src/components/JobCardStack.vue @@ -216,7 +216,23 @@ watch(() => props.job.id, () => { } }) -defineExpose({ dismissApprove, dismissReject, dismissSkip }) +/** Restore card to its neutral state — used when an action is blocked (e.g. demo guard). */ +function resetCard() { + dx.value = 0 + dy.value = 0 + isExiting.value = false + isHeld.value = false + if (wrapperEl.value) { + wrapperEl.value.style.transition = 'none' + wrapperEl.value.style.transform = '' + wrapperEl.value.style.opacity = '' + requestAnimationFrame(() => { + if (wrapperEl.value) wrapperEl.value.style.transition = '' + }) + } +} + +defineExpose({ dismissApprove, dismissReject, dismissSkip, resetCard }) diff --git a/web/src/components/__tests__/DemoBanner.test.ts b/web/src/components/__tests__/DemoBanner.test.ts new file mode 100644 index 0000000..9075cda --- /dev/null +++ b/web/src/components/__tests__/DemoBanner.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import DemoBanner from '../DemoBanner.vue' + +describe('DemoBanner', () => { + it('renders the demo label', () => { + const w = mount(DemoBanner) + expect(w.text()).toContain('Demo mode') + }) + + it('renders a free key link', () => { + const w = mount(DemoBanner) + expect(w.find('a.demo-banner__cta--primary').exists()).toBe(true) + expect(w.find('a.demo-banner__cta--primary').text()).toContain('free key') + }) + + it('renders a self-host link', () => { + const w = mount(DemoBanner) + expect(w.find('a.demo-banner__cta--secondary').exists()).toBe(true) + expect(w.find('a.demo-banner__cta--secondary').text()).toContain('Self-host') + }) +}) diff --git a/web/src/components/__tests__/HintChip.test.ts b/web/src/components/__tests__/HintChip.test.ts new file mode 100644 index 0000000..ccbbabf --- /dev/null +++ b/web/src/components/__tests__/HintChip.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { mount } from '@vue/test-utils' +import HintChip from '../HintChip.vue' + +beforeEach(() => { localStorage.clear() }) + +const factory = (viewKey = 'home', message = 'Test hint') => + mount(HintChip, { props: { viewKey, message } }) + +describe('HintChip', () => { + it('renders the message', () => { + const w = factory() + expect(w.text()).toContain('Test hint') + }) + + it('is hidden when localStorage key is already set', () => { + localStorage.setItem('peregrine_hint_home', '1') + const w = factory() + expect(w.find('.hint-chip').exists()).toBe(false) + }) + + it('hides and sets localStorage when dismiss button is clicked', async () => { + const w = factory() + await w.find('.hint-chip__dismiss').trigger('click') + expect(w.find('.hint-chip').exists()).toBe(false) + expect(localStorage.getItem('peregrine_hint_home')).toBe('1') + }) +}) diff --git a/web/src/components/__tests__/WelcomeModal.test.ts b/web/src/components/__tests__/WelcomeModal.test.ts new file mode 100644 index 0000000..b64b7da --- /dev/null +++ b/web/src/components/__tests__/WelcomeModal.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { mount } from '@vue/test-utils' +import WelcomeModal from '../WelcomeModal.vue' + +const LS_KEY = 'peregrine_demo_visited' + +beforeEach(() => { + localStorage.clear() +}) + +describe('WelcomeModal', () => { + it('is visible when localStorage key is absent', () => { + const w = mount(WelcomeModal, { global: { stubs: { Teleport: true } } }) + expect(w.find('.welcome-modal').exists()).toBe(true) + }) + + it('is hidden when localStorage key is set', () => { + localStorage.setItem(LS_KEY, '1') + const w = mount(WelcomeModal, { global: { stubs: { Teleport: true } } }) + expect(w.find('.welcome-modal').exists()).toBe(false) + }) + + it('dismisses and sets localStorage on primary CTA click', async () => { + const w = mount(WelcomeModal, { global: { stubs: { Teleport: true } } }) + await w.find('.welcome-modal__explore').trigger('click') + expect(w.find('.welcome-modal').exists()).toBe(false) + expect(localStorage.getItem(LS_KEY)).toBe('1') + }) + + it('emits dismissed event on close', async () => { + const w = mount(WelcomeModal, { global: { stubs: { Teleport: true } } }) + await w.find('.welcome-modal__explore').trigger('click') + expect(w.emitted('dismissed')).toBeTruthy() + }) +}) diff --git a/web/src/composables/useApi.ts b/web/src/composables/useApi.ts index 42bab16..b7acd38 100644 --- a/web/src/composables/useApi.ts +++ b/web/src/composables/useApi.ts @@ -1,6 +1,9 @@ +import { showToast } from './useToast' + export type ApiError = | { kind: 'network'; message: string } | { kind: 'http'; status: number; detail: string } + | { kind: 'demo-blocked' } // Strip trailing slash so '/peregrine/' + '/api/...' → '/peregrine/api/...' const _apiBase = import.meta.env.BASE_URL.replace(/\/$/, '') @@ -12,8 +15,20 @@ export async function useApiFetch( try { const res = await fetch(_apiBase + url, opts) if (!res.ok) { - const detail = await res.text().catch(() => '') - return { data: null, error: { kind: 'http', status: res.status, detail } } + const rawText = await res.text().catch(() => '') + // Demo mode: show toast and swallow the error so callers don't need to handle it + if (res.status === 403) { + try { + const body = JSON.parse(rawText) as { detail?: string } + if (body.detail === 'demo-write-blocked') { + showToast('Demo mode — sign in to save changes') + // Return a truthy error so callers bail early (no optimistic UI update), + // but the toast is already shown so no additional error handling needed. + return { data: null, error: { kind: 'demo-blocked' as const } } + } + } catch { /* not JSON — fall through to normal error */ } + } + return { data: null, error: { kind: 'http', status: res.status, detail: rawText } } } const data = await res.json() as T return { data, error: null } diff --git a/web/src/views/ApplyView.vue b/web/src/views/ApplyView.vue index c1ef7b5..47f3b16 100644 --- a/web/src/views/ApplyView.vue +++ b/web/src/views/ApplyView.vue @@ -1,6 +1,11 @@