"""SQLAlchemy ORM models for Resume Matcher. A single declarative ``Base`` backs all tables (doc tables migrated from TinyDB plus the new ``applications`` and ``api_keys`` tables). The facade in ``app/database.py`` converts ORM rows to plain dicts so the rest of the app never sees ORM objects — preserving the TinyDB-era contracts. """ from datetime import datetime, timezone from typing import Any from sqlalchemy import JSON, Boolean, Index, Integer, String, Text, UniqueConstraint, text from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column def _utcnow_iso() -> str: """Return the current UTC time as an ISO-8601 string. Timestamps are stored as strings (not native datetimes) to preserve the TinyDB-era behavior: code compares them lexically and returns them to clients verbatim. """ return datetime.now(timezone.utc).isoformat() class Base(DeclarativeBase): """Declarative base shared by every table.""" class Resume(Base): """A resume document (master or tailored).""" __tablename__ = "resumes" resume_id: Mapped[str] = mapped_column(String, primary_key=True) content: Mapped[str] = mapped_column(Text) content_type: Mapped[str] = mapped_column(String, default="md") filename: Mapped[str | None] = mapped_column(String, nullable=True) is_master: Mapped[bool] = mapped_column(Boolean, default=False) parent_id: Mapped[str | None] = mapped_column(String, nullable=True) processed_data: Mapped[dict | None] = mapped_column(JSON, nullable=True) processing_status: Mapped[str] = mapped_column(String, default="pending") cover_letter: Mapped[str | None] = mapped_column(Text, nullable=True) outreach_message: Mapped[str | None] = mapped_column(Text, nullable=True) interview_prep: Mapped[str | None] = mapped_column(Text, nullable=True) title: Mapped[str | None] = mapped_column(String, nullable=True) # original_markdown has *absence* semantics in the TinyDB era: the key was # omitted entirely when None. The facade reproduces that by only emitting # the key when this column is non-null. original_markdown: Mapped[str | None] = mapped_column(Text, nullable=True) created_at: Mapped[str] = mapped_column(String, default=_utcnow_iso) updated_at: Mapped[str] = mapped_column(String, default=_utcnow_iso) __table_args__ = ( # At most one master resume. Partial unique index enforces the invariant # at the storage layer; ``_master_resume_lock`` remains the primary # (race-free) mechanism in the facade. Index( "ux_resumes_single_master", "is_master", unique=True, sqlite_where=text("is_master = 1"), ), ) class Job(Base): """A job description. Only the stable columns are first-class; everything the pipeline attaches dynamically (``job_keywords``, ``job_keywords_hash``, ``preview_hash``, ``preview_hashes``, ``preview_prompt_id``, ``company``, ``role``) lives in ``metadata_json``. The facade flattens that map to top-level keys on read and merges non-core keys into it on update, reproducing TinyDB semantics. """ __tablename__ = "jobs" job_id: Mapped[str] = mapped_column(String, primary_key=True) content: Mapped[str] = mapped_column(Text) resume_id: Mapped[str | None] = mapped_column(String, nullable=True) created_at: Mapped[str] = mapped_column(String, default=_utcnow_iso) metadata_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict) class Improvement(Base): """A tailoring result linking an original resume, a tailored resume, and a job.""" __tablename__ = "improvements" request_id: Mapped[str] = mapped_column(String, primary_key=True) original_resume_id: Mapped[str] = mapped_column(String) tailored_resume_id: Mapped[str] = mapped_column(String, index=True) job_id: Mapped[str] = mapped_column(String) improvements: Mapped[list] = mapped_column(JSON, default=list) created_at: Mapped[str] = mapped_column(String, default=_utcnow_iso) class Application(Base): """A Kanban application-tracker card.""" __tablename__ = "applications" __table_args__ = ( # Concurrency-safe dedupe: a card is unique per (job, applied resume). # The app-level select-then-insert relies on this to collapse races. UniqueConstraint("job_id", "resume_id", name="uq_application_job_resume"), ) application_id: Mapped[str] = mapped_column(String, primary_key=True) job_id: Mapped[str] = mapped_column(String, index=True) # The applied/tailored resume shown in the modal and opened by "Edit". resume_id: Mapped[str] = mapped_column(String, index=True) # Optional base resume the tailored one descends from (powers "stack" grouping). master_resume_id: Mapped[str | None] = mapped_column(String, nullable=True) status: Mapped[str] = mapped_column(String, default="applied", index=True) company: Mapped[str | None] = mapped_column(String, nullable=True) role: Mapped[str | None] = mapped_column(String, nullable=True) applied_at: Mapped[str | None] = mapped_column(String, nullable=True) notes: Mapped[str | None] = mapped_column(Text, nullable=True) position: Mapped[int] = mapped_column(Integer, default=0) created_at: Mapped[str] = mapped_column(String, default=_utcnow_iso) updated_at: Mapped[str] = mapped_column(String, default=_utcnow_iso) class ApiKey(Base): """An encrypted LLM provider API key. ``provider`` is the *key-store* provider name (e.g. ``google`` for the ``gemini`` LLM provider, via ``_PROVIDER_KEY_MAP``). Only ciphertext is stored; plaintext exists in memory only at call time. """ __tablename__ = "api_keys" provider: Mapped[str] = mapped_column(String, primary_key=True) ciphertext: Mapped[str] = mapped_column(Text) updated_at: Mapped[str] = mapped_column(String, default=_utcnow_iso)