initial public release
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationFS embed.FS
|
||||
|
||||
// DB wraps the SQLite connection and migrations.
|
||||
type DB struct {
|
||||
conn *sql.DB
|
||||
}
|
||||
|
||||
// NewDB opens or creates the SQLite database and runs migrations.
|
||||
func NewDB(dbPath string) (*DB, error) {
|
||||
conn, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
|
||||
// SQLite writer serialization: one open conn avoids BUSY retries under parallel goroutines.
|
||||
conn.SetMaxOpenConns(1)
|
||||
|
||||
// Test connection
|
||||
if err := conn.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("failed to ping database: %w", err)
|
||||
}
|
||||
|
||||
if _, err := conn.Exec("PRAGMA foreign_keys = ON"); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("enable foreign keys: %w", err)
|
||||
}
|
||||
|
||||
db := &DB{conn: conn}
|
||||
|
||||
// Run migrations
|
||||
if err := db.runMigrations(); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// runMigrations applies all SQL migrations from the migrations/ directory.
|
||||
// Each migration is applied exactly once — tracked via the schema_migrations table.
|
||||
// This is required because ALTER TABLE ADD COLUMN is not idempotent in SQLite.
|
||||
func (db *DB) runMigrations() error {
|
||||
if _, err := db.conn.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
name TEXT PRIMARY KEY,
|
||||
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)`); err != nil {
|
||||
return fmt.Errorf("failed to create schema_migrations: %w", err)
|
||||
}
|
||||
|
||||
applied := make(map[string]bool)
|
||||
rows, err := db.conn.Query(`SELECT name FROM schema_migrations`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read schema_migrations: %w", err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
rows.Close()
|
||||
return fmt.Errorf("failed to scan migration name: %w", err)
|
||||
}
|
||||
applied[name] = true
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
entries, err := fs.ReadDir(migrationFS, "migrations")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read migrations: %w", err)
|
||||
}
|
||||
|
||||
var files []string
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
|
||||
files = append(files, entry.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(files)
|
||||
|
||||
for _, file := range files {
|
||||
if applied[file] {
|
||||
continue
|
||||
}
|
||||
content, err := fs.ReadFile(migrationFS, fmt.Sprintf("migrations/%s", file))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read migration %s: %w", file, err)
|
||||
}
|
||||
tx, err := db.conn.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin tx for %s: %w", file, err)
|
||||
}
|
||||
if _, err := tx.Exec(string(content)); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("failed to execute migration %s: %w", file, err)
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT INTO schema_migrations (name) VALUES (?)`, file); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("failed to record migration %s: %w", file, err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("failed to commit migration %s: %w", file, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the database connection.
|
||||
func (db *DB) Close() error {
|
||||
return db.conn.Close()
|
||||
}
|
||||
|
||||
// Conn returns the underlying sql.DB connection.
|
||||
func (db *DB) Conn() *sql.DB {
|
||||
return db.conn
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
-- Users / Profile
|
||||
CREATE TABLE IF NOT EXISTS profile (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
name TEXT,
|
||||
email TEXT,
|
||||
location TEXT,
|
||||
timezone TEXT,
|
||||
salary_target_min REAL,
|
||||
salary_target_max REAL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Job postings
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
url TEXT UNIQUE NOT NULL,
|
||||
company TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
raw_html TEXT,
|
||||
source TEXT, -- "greenhouse", "ashby", "lever", "indeed", etc.
|
||||
discovered_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
indexed BOOLEAN DEFAULT 0,
|
||||
archived BOOLEAN DEFAULT 0
|
||||
);
|
||||
|
||||
-- Applications / Tracker
|
||||
CREATE TABLE IF NOT EXISTS applications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
job_id INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'Evaluated', -- Evaluated, Applied, Responded, Interview, Offer, Rejected, Discarded, SKIP
|
||||
score REAL, -- 0-5 rating from Claude
|
||||
pdf_generated BOOLEAN DEFAULT 0,
|
||||
applied_at TIMESTAMP,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (job_id) REFERENCES jobs(id)
|
||||
);
|
||||
|
||||
-- Evaluation reports
|
||||
CREATE TABLE IF NOT EXISTS reports (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
application_id INTEGER NOT NULL,
|
||||
report_md TEXT, -- Full markdown report (A-G blocks)
|
||||
report_html TEXT, -- HTML rendering
|
||||
generated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (application_id) REFERENCES applications(id)
|
||||
);
|
||||
|
||||
-- Portal scan history (dedup)
|
||||
CREATE TABLE IF NOT EXISTS scan_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source TEXT NOT NULL, -- portal name
|
||||
query_hash TEXT NOT NULL, -- hash of query params
|
||||
last_scanned TIMESTAMP,
|
||||
jobs_found INTEGER,
|
||||
new_jobs INTEGER,
|
||||
UNIQUE(source, query_hash)
|
||||
);
|
||||
|
||||
-- User voice samples (from DOCX intake)
|
||||
CREATE TABLE IF NOT EXISTS voice_samples (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source_file TEXT, -- DOCX filename uploaded
|
||||
transcription TEXT,
|
||||
extracted_proof_points TEXT, -- JSON: ["point1", "point2", ...]
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Follow-ups
|
||||
CREATE TABLE IF NOT EXISTS follow_ups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
application_id INTEGER NOT NULL,
|
||||
follow_up_at TIMESTAMP,
|
||||
contacted_at TIMESTAMP,
|
||||
notes TEXT,
|
||||
FOREIGN KEY (application_id) REFERENCES applications(id)
|
||||
);
|
||||
|
||||
-- Create indexes for performance
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_source ON jobs(source);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_company ON jobs(company);
|
||||
CREATE INDEX IF NOT EXISTS idx_applications_status ON applications(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_applications_job_id ON applications(job_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_scan_history_source ON scan_history(source);
|
||||
CREATE INDEX IF NOT EXISTS idx_follow_ups_application_id ON follow_ups(application_id);
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Intake sessions
|
||||
CREATE TABLE IF NOT EXISTS intake_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
current_phase INTEGER DEFAULT 0,
|
||||
is_complete BOOLEAN DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Intake answers (one per phase per session)
|
||||
CREATE TABLE IF NOT EXISTS intake_answers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id INTEGER NOT NULL,
|
||||
phase INTEGER NOT NULL,
|
||||
fields_json TEXT, -- JSON: {field_name: value}
|
||||
nested_items_json TEXT, -- JSON: [{field1: val1, field2: val2}, ...]
|
||||
submitted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (session_id) REFERENCES intake_sessions(id),
|
||||
UNIQUE(session_id, phase)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_intake_answers_session_id ON intake_answers(session_id);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Per-URL scan history for dedup against previous scans (beyond jobs.url UNIQUE).
|
||||
-- Tracks every URL ever seen with status so we can skip expired/filtered without re-fetch.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scanned_urls (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
url TEXT UNIQUE NOT NULL,
|
||||
company TEXT NOT NULL,
|
||||
title TEXT,
|
||||
source TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'added',
|
||||
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_scanned_urls_company ON scanned_urls(company);
|
||||
CREATE INDEX IF NOT EXISTS idx_scanned_urls_status ON scanned_urls(status);
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Phase 3: A-G evaluation pipeline.
|
||||
-- Extends applications + reports with block-level detail, legitimacy tier, archetype,
|
||||
-- and per-block markdown segments so the TUI can re-render without re-invoking Claude.
|
||||
|
||||
ALTER TABLE applications ADD COLUMN archetype TEXT;
|
||||
ALTER TABLE applications ADD COLUMN legitimacy TEXT; -- high | caution | suspicious
|
||||
ALTER TABLE applications ADD COLUMN evaluated_at TIMESTAMP;
|
||||
|
||||
ALTER TABLE reports ADD COLUMN seq INTEGER; -- 3-digit sequential number used in filename
|
||||
ALTER TABLE reports ADD COLUMN slug TEXT; -- company slug used in filename
|
||||
ALTER TABLE reports ADD COLUMN legitimacy TEXT; -- mirrors applications.legitimacy
|
||||
ALTER TABLE reports ADD COLUMN score REAL;
|
||||
ALTER TABLE reports ADD COLUMN block_a TEXT;
|
||||
ALTER TABLE reports ADD COLUMN block_b TEXT;
|
||||
ALTER TABLE reports ADD COLUMN block_c TEXT;
|
||||
ALTER TABLE reports ADD COLUMN block_d TEXT;
|
||||
ALTER TABLE reports ADD COLUMN block_e TEXT;
|
||||
ALTER TABLE reports ADD COLUMN block_f TEXT;
|
||||
ALTER TABLE reports ADD COLUMN block_g TEXT;
|
||||
ALTER TABLE reports ADD COLUMN file_path TEXT; -- absolute path to on-disk .md report
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_reports_application ON reports(application_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_reports_seq ON reports(seq);
|
||||
CREATE INDEX IF NOT EXISTS idx_applications_job ON applications(job_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_applications_status ON applications(status);
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Supports EvalModel.loadPending: filters jobs by archived, orders by discovered_at.
|
||||
-- Without this, a table scan of jobs is required every time the Evaluate tab refreshes.
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_archived_discovered
|
||||
ON jobs(archived, discovered_at DESC);
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Phase 5: Application tracker — event log + indexes for state-machine queries.
|
||||
-- Existing applications table already has the status field with the right values.
|
||||
-- This migration adds an immutable event log for audit + follow-up cadence support.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tracker_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
application_id INTEGER NOT NULL,
|
||||
from_status TEXT,
|
||||
to_status TEXT NOT NULL,
|
||||
note TEXT,
|
||||
occurred_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (application_id) REFERENCES applications(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tracker_events_application ON tracker_events(application_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tracker_events_occurred ON tracker_events(occurred_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_applications_applied_at ON applications(applied_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_follow_ups_at ON follow_ups(follow_up_at);
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Phase 3E: Extract STAR stories from Block F into structured data.
|
||||
-- Stores stories as JSON array for querying and UI reconstruction.
|
||||
|
||||
ALTER TABLE reports ADD COLUMN stories TEXT; -- JSON array of STAR story objects
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Phase 4: Pipeline tracking for batch eval jobs.
|
||||
-- Tracks queued, running, and completed batch evaluations.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pipeline (
|
||||
id INTEGER PRIMARY KEY,
|
||||
job_id INTEGER NOT NULL REFERENCES jobs(id),
|
||||
queued_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
started_at DATETIME,
|
||||
completed_at DATETIME
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_pipeline_job ON pipeline(job_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_pipeline_completed ON pipeline(completed_at);
|
||||
Reference in New Issue
Block a user