Seeds now insert generic Demo User personas (INSERT OR IGNORE, so existing databases keep their current profile data). Drops the every-boot UPDATE backfills that re-asserted personal values, and removes the personal cron env example templates in favor of the generic scripts/jobber-cron.env.example.
1331 lines
48 KiB
TypeScript
1331 lines
48 KiB
TypeScript
/**
|
|
* Database migration script - creates tables if they don't exist.
|
|
*/
|
|
|
|
import { existsSync, mkdirSync } from "node:fs";
|
|
import { dirname, join } from "node:path";
|
|
import Database from "better-sqlite3";
|
|
import { getDataDir } from "../config/dataDir";
|
|
|
|
// Database path - can be overridden via env for Docker
|
|
const DB_PATH = join(getDataDir(), "jobs.db");
|
|
|
|
// Ensure data directory exists
|
|
const dataDir = dirname(DB_PATH);
|
|
if (!existsSync(dataDir)) {
|
|
mkdirSync(dataDir, { recursive: true });
|
|
}
|
|
|
|
const sqlite = new Database(DB_PATH);
|
|
|
|
function sqlInsertSearchProfileSeed(input: {
|
|
id: string;
|
|
name: string;
|
|
profile: Record<string, unknown>;
|
|
}): string {
|
|
const data = JSON.stringify(input.profile).replace(/'/g, "''");
|
|
const id = input.id.replace(/'/g, "''");
|
|
const name = input.name.replace(/'/g, "''");
|
|
return `INSERT OR IGNORE INTO search_profiles (id, name, data, created_at, updated_at) VALUES ('${id}', '${name}', '${data}', datetime('now'), datetime('now'))`;
|
|
}
|
|
|
|
function sqlJsonLiteral(values: string[]): string {
|
|
return JSON.stringify(values).replace(/'/g, "''");
|
|
}
|
|
|
|
/** Canada / remote — SDET & QA automation title variants for discovery + scoring. */
|
|
const SDET_CA_TARGET_ROLES = [
|
|
"SDET",
|
|
"Senior SDET",
|
|
"Lead SDET",
|
|
"Staff SDET",
|
|
"Principal SDET",
|
|
"Software Development Engineer in Test",
|
|
"Senior Software Development Engineer in Test",
|
|
"Software Engineer in Test",
|
|
"Software Test Engineer",
|
|
"Senior Software Test Engineer",
|
|
"QA Automation Engineer",
|
|
"Senior QA Automation Engineer",
|
|
"Quality Assurance Automation Engineer",
|
|
"Test Automation Engineer",
|
|
"Senior Test Automation Engineer",
|
|
"Automation Test Engineer",
|
|
"Automation Engineer",
|
|
"QA Engineer",
|
|
"Senior QA Engineer",
|
|
"Quality Engineer",
|
|
"Test Automation Lead",
|
|
"QA Automation Lead",
|
|
"Test Lead",
|
|
];
|
|
|
|
/** Job-board search terms — title variants plus common stack/tool pairings. */
|
|
const SDET_CA_KEYWORD_TERMS = [
|
|
...SDET_CA_TARGET_ROLES.map((role) => role.toLowerCase()),
|
|
"playwright SDET",
|
|
"SDET playwright",
|
|
"SDET java",
|
|
"SDET typescript",
|
|
"SDET cypress",
|
|
"SDET selenium",
|
|
"SDET api testing",
|
|
"SDET remote",
|
|
"QA automation remote",
|
|
"contract SDET",
|
|
"contract QA automation",
|
|
"SET",
|
|
"software engineer test",
|
|
];
|
|
|
|
const SDET_US_TARGET_ROLES = [
|
|
...SDET_CA_TARGET_ROLES,
|
|
"Computer Systems Analyst",
|
|
"Quality Assurance Engineer",
|
|
];
|
|
|
|
const SDET_US_KEYWORD_TERMS = [
|
|
...SDET_CA_KEYWORD_TERMS,
|
|
"computer systems analyst",
|
|
"systems analyst",
|
|
"computer systems analyst QA",
|
|
"SDET United States",
|
|
"SDET USA",
|
|
"SDET remote US",
|
|
"QA automation engineer USA",
|
|
"test automation engineer United States",
|
|
"quality assurance engineer automation",
|
|
"TN SDET",
|
|
];
|
|
|
|
/** AI engineering / AI enablement — agents, MCP, RAG, evals, local LLM serving. */
|
|
const AI_ENGINEER_TARGET_ROLES = [
|
|
"AI Engineer",
|
|
"Senior AI Engineer",
|
|
"Staff AI Engineer",
|
|
"AI Automation Engineer",
|
|
"Applied AI Engineer",
|
|
"LLM Engineer",
|
|
"Generative AI Engineer",
|
|
"GenAI Engineer",
|
|
"AI Platform Engineer",
|
|
"AI Solutions Engineer",
|
|
"AI Enablement Lead",
|
|
"AI Enablement Engineer",
|
|
"AI Agent Engineer",
|
|
"ML Engineer",
|
|
"Machine Learning Engineer",
|
|
"Prompt Engineer",
|
|
"RAG Engineer",
|
|
];
|
|
|
|
const AI_ENGINEER_KEYWORD_TERMS = [
|
|
...AI_ENGINEER_TARGET_ROLES.map((role) => role.toLowerCase()),
|
|
"LLM agents",
|
|
"MCP engineer",
|
|
"LangGraph",
|
|
"RAG engineer",
|
|
"prompt engineering",
|
|
"Ollama",
|
|
"AI enablement",
|
|
"AI automation remote",
|
|
"contract AI engineer",
|
|
"GenAI remote",
|
|
"LLM evals",
|
|
"promptfoo",
|
|
"multi-agent",
|
|
];
|
|
|
|
const migrations = [
|
|
`CREATE TABLE IF NOT EXISTS jobs (
|
|
id TEXT PRIMARY KEY,
|
|
source TEXT NOT NULL DEFAULT 'gradcracker',
|
|
source_job_id TEXT,
|
|
job_url_direct TEXT,
|
|
date_posted TEXT,
|
|
job_type TEXT,
|
|
salary_source TEXT,
|
|
salary_interval TEXT,
|
|
salary_min_amount REAL,
|
|
salary_max_amount REAL,
|
|
salary_currency TEXT,
|
|
is_remote INTEGER,
|
|
job_level TEXT,
|
|
job_function TEXT,
|
|
listing_type TEXT,
|
|
emails TEXT,
|
|
company_industry TEXT,
|
|
company_logo TEXT,
|
|
company_url_direct TEXT,
|
|
company_addresses TEXT,
|
|
company_num_employees TEXT,
|
|
company_revenue TEXT,
|
|
company_description TEXT,
|
|
skills TEXT,
|
|
experience_range TEXT,
|
|
company_rating REAL,
|
|
company_reviews_count INTEGER,
|
|
vacancy_count INTEGER,
|
|
work_from_home_type TEXT,
|
|
title TEXT NOT NULL,
|
|
employer TEXT NOT NULL,
|
|
employer_url TEXT,
|
|
job_url TEXT NOT NULL UNIQUE,
|
|
application_link TEXT,
|
|
disciplines TEXT,
|
|
deadline TEXT,
|
|
salary TEXT,
|
|
location TEXT,
|
|
degree_required TEXT,
|
|
starting TEXT,
|
|
job_description TEXT,
|
|
status TEXT NOT NULL DEFAULT 'discovered' CHECK(status IN ('discovered', 'processing', 'ready', 'applied', 'in_progress', 'skipped', 'expired')),
|
|
outcome TEXT,
|
|
closed_at INTEGER,
|
|
suitability_score REAL,
|
|
suitability_reason TEXT,
|
|
tailored_summary TEXT,
|
|
tailored_headline TEXT,
|
|
tailored_skills TEXT,
|
|
selected_project_ids TEXT,
|
|
pdf_path TEXT,
|
|
tracer_links_enabled INTEGER NOT NULL DEFAULT 0,
|
|
discovered_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
processed_at TEXT,
|
|
applied_at TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
)`,
|
|
|
|
`CREATE TABLE IF NOT EXISTS pipeline_runs (
|
|
id TEXT PRIMARY KEY,
|
|
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
completed_at TEXT,
|
|
status TEXT NOT NULL DEFAULT 'running' CHECK(status IN ('running', 'completed', 'failed', 'cancelled')),
|
|
jobs_discovered INTEGER NOT NULL DEFAULT 0,
|
|
jobs_processed INTEGER NOT NULL DEFAULT 0,
|
|
error_message TEXT
|
|
)`,
|
|
|
|
`CREATE TABLE IF NOT EXISTS settings (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
)`,
|
|
|
|
`CREATE TABLE IF NOT EXISTS job_chat_threads (
|
|
id TEXT PRIMARY KEY,
|
|
job_id TEXT NOT NULL,
|
|
title TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
last_message_at TEXT,
|
|
active_root_message_id TEXT,
|
|
FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE
|
|
)`,
|
|
|
|
`CREATE TABLE IF NOT EXISTS job_chat_messages (
|
|
id TEXT PRIMARY KEY,
|
|
thread_id TEXT NOT NULL,
|
|
job_id TEXT NOT NULL,
|
|
role TEXT NOT NULL CHECK(role IN ('system', 'user', 'assistant', 'tool')),
|
|
content TEXT NOT NULL DEFAULT '',
|
|
status TEXT NOT NULL DEFAULT 'partial' CHECK(status IN ('complete', 'partial', 'cancelled', 'failed')),
|
|
tokens_in INTEGER,
|
|
tokens_out INTEGER,
|
|
version INTEGER NOT NULL DEFAULT 1,
|
|
replaces_message_id TEXT,
|
|
parent_message_id TEXT,
|
|
active_child_id TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
FOREIGN KEY (thread_id) REFERENCES job_chat_threads(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE
|
|
)`,
|
|
|
|
`CREATE TABLE IF NOT EXISTS job_chat_runs (
|
|
id TEXT PRIMARY KEY,
|
|
thread_id TEXT NOT NULL,
|
|
job_id TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'running' CHECK(status IN ('running', 'completed', 'cancelled', 'failed')),
|
|
model TEXT,
|
|
provider TEXT,
|
|
error_code TEXT,
|
|
error_message TEXT,
|
|
started_at INTEGER NOT NULL,
|
|
completed_at INTEGER,
|
|
request_id TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
FOREIGN KEY (thread_id) REFERENCES job_chat_threads(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE
|
|
)`,
|
|
|
|
`CREATE TABLE IF NOT EXISTS stage_events (
|
|
id TEXT PRIMARY KEY,
|
|
application_id TEXT NOT NULL,
|
|
title TEXT NOT NULL DEFAULT '',
|
|
group_id TEXT,
|
|
from_stage TEXT,
|
|
to_stage TEXT NOT NULL,
|
|
occurred_at INTEGER NOT NULL,
|
|
metadata TEXT,
|
|
outcome TEXT,
|
|
FOREIGN KEY (application_id) REFERENCES jobs(id) ON DELETE CASCADE
|
|
)`,
|
|
|
|
`CREATE TABLE IF NOT EXISTS tasks (
|
|
id TEXT PRIMARY KEY,
|
|
application_id TEXT NOT NULL,
|
|
type TEXT NOT NULL,
|
|
title TEXT NOT NULL DEFAULT '',
|
|
due_date INTEGER,
|
|
is_completed INTEGER NOT NULL DEFAULT 0,
|
|
notes TEXT,
|
|
FOREIGN KEY (application_id) REFERENCES jobs(id) ON DELETE CASCADE
|
|
)`,
|
|
|
|
`CREATE TABLE IF NOT EXISTS interviews (
|
|
id TEXT PRIMARY KEY,
|
|
application_id TEXT NOT NULL,
|
|
scheduled_at INTEGER NOT NULL,
|
|
duration_mins INTEGER,
|
|
type TEXT NOT NULL,
|
|
outcome TEXT,
|
|
FOREIGN KEY (application_id) REFERENCES jobs(id) ON DELETE CASCADE
|
|
)`,
|
|
|
|
`CREATE TABLE IF NOT EXISTS post_application_integrations (
|
|
id TEXT PRIMARY KEY,
|
|
provider TEXT NOT NULL CHECK(provider IN ('gmail', 'imap')),
|
|
account_key TEXT NOT NULL DEFAULT 'default',
|
|
display_name TEXT,
|
|
status TEXT NOT NULL DEFAULT 'disconnected' CHECK(status IN ('disconnected', 'connected', 'error')),
|
|
credentials TEXT,
|
|
last_connected_at INTEGER,
|
|
last_synced_at INTEGER,
|
|
last_error TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
UNIQUE(provider, account_key)
|
|
)`,
|
|
|
|
`CREATE TABLE IF NOT EXISTS post_application_sync_runs (
|
|
id TEXT PRIMARY KEY,
|
|
provider TEXT NOT NULL CHECK(provider IN ('gmail', 'imap')),
|
|
account_key TEXT NOT NULL DEFAULT 'default',
|
|
integration_id TEXT,
|
|
status TEXT NOT NULL DEFAULT 'running' CHECK(status IN ('running', 'completed', 'failed', 'cancelled')),
|
|
started_at INTEGER NOT NULL,
|
|
completed_at INTEGER,
|
|
messages_discovered INTEGER NOT NULL DEFAULT 0,
|
|
messages_relevant INTEGER NOT NULL DEFAULT 0,
|
|
messages_classified INTEGER NOT NULL DEFAULT 0,
|
|
messages_matched INTEGER NOT NULL DEFAULT 0,
|
|
messages_approved INTEGER NOT NULL DEFAULT 0,
|
|
messages_denied INTEGER NOT NULL DEFAULT 0,
|
|
messages_errored INTEGER NOT NULL DEFAULT 0,
|
|
error_code TEXT,
|
|
error_message TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
FOREIGN KEY (integration_id) REFERENCES post_application_integrations(id) ON DELETE SET NULL
|
|
)`,
|
|
|
|
`CREATE TABLE IF NOT EXISTS post_application_messages (
|
|
id TEXT PRIMARY KEY,
|
|
provider TEXT NOT NULL CHECK(provider IN ('gmail', 'imap')),
|
|
account_key TEXT NOT NULL DEFAULT 'default',
|
|
integration_id TEXT,
|
|
sync_run_id TEXT,
|
|
external_message_id TEXT NOT NULL,
|
|
external_thread_id TEXT,
|
|
from_address TEXT NOT NULL DEFAULT '',
|
|
from_domain TEXT,
|
|
sender_name TEXT,
|
|
subject TEXT NOT NULL DEFAULT '',
|
|
received_at INTEGER NOT NULL,
|
|
snippet TEXT NOT NULL DEFAULT '',
|
|
classification_label TEXT,
|
|
classification_confidence REAL,
|
|
classification_payload TEXT,
|
|
relevance_llm_score REAL,
|
|
relevance_decision TEXT NOT NULL DEFAULT 'needs_llm' CHECK(relevance_decision IN ('relevant', 'not_relevant', 'needs_llm')),
|
|
match_confidence INTEGER,
|
|
message_type TEXT NOT NULL DEFAULT 'other' CHECK(message_type IN ('interview', 'rejection', 'offer', 'update', 'other')),
|
|
stage_event_payload TEXT,
|
|
processing_status TEXT NOT NULL DEFAULT 'pending_user' CHECK(processing_status IN ('auto_linked', 'pending_user', 'manual_linked', 'ignored')),
|
|
matched_job_id TEXT,
|
|
decided_at INTEGER,
|
|
decided_by TEXT,
|
|
error_code TEXT,
|
|
error_message TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
FOREIGN KEY (integration_id) REFERENCES post_application_integrations(id) ON DELETE SET NULL,
|
|
FOREIGN KEY (sync_run_id) REFERENCES post_application_sync_runs(id) ON DELETE SET NULL,
|
|
FOREIGN KEY (matched_job_id) REFERENCES jobs(id) ON DELETE SET NULL,
|
|
UNIQUE(provider, account_key, external_message_id)
|
|
)`,
|
|
|
|
`CREATE TABLE IF NOT EXISTS tracer_links (
|
|
id TEXT PRIMARY KEY,
|
|
token TEXT NOT NULL UNIQUE,
|
|
job_id TEXT NOT NULL,
|
|
source_path TEXT NOT NULL,
|
|
source_label TEXT NOT NULL,
|
|
destination_url TEXT NOT NULL,
|
|
destination_url_hash TEXT NOT NULL,
|
|
is_active INTEGER NOT NULL DEFAULT 1,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE,
|
|
UNIQUE(job_id, source_path, destination_url_hash)
|
|
)`,
|
|
|
|
`CREATE TABLE IF NOT EXISTS tracer_click_events (
|
|
id TEXT PRIMARY KEY,
|
|
tracer_link_id TEXT NOT NULL,
|
|
clicked_at INTEGER NOT NULL,
|
|
request_id TEXT,
|
|
is_likely_bot INTEGER NOT NULL DEFAULT 0,
|
|
device_type TEXT NOT NULL DEFAULT 'unknown',
|
|
ua_family TEXT NOT NULL DEFAULT 'unknown',
|
|
os_family TEXT NOT NULL DEFAULT 'unknown',
|
|
referrer_host TEXT,
|
|
ip_hash TEXT,
|
|
unique_fingerprint_hash TEXT,
|
|
FOREIGN KEY (tracer_link_id) REFERENCES tracer_links(id) ON DELETE CASCADE
|
|
)`,
|
|
|
|
// Rename settings key: webhookUrl -> pipelineWebhookUrl (safe to re-run)
|
|
`INSERT OR REPLACE INTO settings(key, value, created_at, updated_at)
|
|
SELECT 'pipelineWebhookUrl', value, created_at, updated_at FROM settings WHERE key = 'webhookUrl'`,
|
|
`DELETE FROM settings WHERE key = 'webhookUrl'`,
|
|
// Drop legacy settings keys that are no longer read by the app.
|
|
`DELETE FROM settings
|
|
WHERE key IN (
|
|
'jobspyHoursOld',
|
|
'jobspySites',
|
|
'jobspyLinkedinFetchDescription',
|
|
'jobspyIsRemote',
|
|
'openrouterApiKey'
|
|
)`,
|
|
|
|
// Add source column for existing databases (safe to skip if already present)
|
|
`ALTER TABLE jobs ADD COLUMN source TEXT NOT NULL DEFAULT 'gradcracker'`,
|
|
`UPDATE jobs SET source = 'gradcracker' WHERE source IS NULL OR source = ''`,
|
|
|
|
// Add JobSpy columns for existing databases (safe to skip if already present)
|
|
`ALTER TABLE jobs ADD COLUMN source_job_id TEXT`,
|
|
`ALTER TABLE jobs ADD COLUMN job_url_direct TEXT`,
|
|
`ALTER TABLE jobs ADD COLUMN date_posted TEXT`,
|
|
`ALTER TABLE jobs ADD COLUMN job_type TEXT`,
|
|
`ALTER TABLE jobs ADD COLUMN salary_source TEXT`,
|
|
`ALTER TABLE jobs ADD COLUMN salary_interval TEXT`,
|
|
`ALTER TABLE jobs ADD COLUMN salary_min_amount REAL`,
|
|
`ALTER TABLE jobs ADD COLUMN salary_max_amount REAL`,
|
|
`ALTER TABLE jobs ADD COLUMN salary_currency TEXT`,
|
|
`ALTER TABLE jobs ADD COLUMN is_remote INTEGER`,
|
|
`ALTER TABLE jobs ADD COLUMN job_level TEXT`,
|
|
`ALTER TABLE jobs ADD COLUMN job_function TEXT`,
|
|
`ALTER TABLE jobs ADD COLUMN listing_type TEXT`,
|
|
`ALTER TABLE jobs ADD COLUMN emails TEXT`,
|
|
`ALTER TABLE jobs ADD COLUMN company_industry TEXT`,
|
|
`ALTER TABLE jobs ADD COLUMN company_logo TEXT`,
|
|
`ALTER TABLE jobs ADD COLUMN company_url_direct TEXT`,
|
|
`ALTER TABLE jobs ADD COLUMN company_addresses TEXT`,
|
|
`ALTER TABLE jobs ADD COLUMN company_num_employees TEXT`,
|
|
`ALTER TABLE jobs ADD COLUMN company_revenue TEXT`,
|
|
`ALTER TABLE jobs ADD COLUMN company_description TEXT`,
|
|
`ALTER TABLE jobs ADD COLUMN skills TEXT`,
|
|
`ALTER TABLE jobs ADD COLUMN experience_range TEXT`,
|
|
`ALTER TABLE jobs ADD COLUMN company_rating REAL`,
|
|
`ALTER TABLE jobs ADD COLUMN company_reviews_count INTEGER`,
|
|
`ALTER TABLE jobs ADD COLUMN vacancy_count INTEGER`,
|
|
`ALTER TABLE jobs ADD COLUMN work_from_home_type TEXT`,
|
|
`ALTER TABLE jobs ADD COLUMN selected_project_ids TEXT`,
|
|
`ALTER TABLE jobs ADD COLUMN tailored_headline TEXT`,
|
|
`ALTER TABLE jobs ADD COLUMN tailored_skills TEXT`,
|
|
`ALTER TABLE jobs ADD COLUMN tracer_links_enabled INTEGER NOT NULL DEFAULT 0`,
|
|
|
|
// Add sponsor match columns for visa sponsor matching feature
|
|
`ALTER TABLE jobs ADD COLUMN sponsor_match_score REAL`,
|
|
`ALTER TABLE jobs ADD COLUMN sponsor_match_names TEXT`,
|
|
|
|
// Add suitability analysis column for rich fit assessment
|
|
`ALTER TABLE jobs ADD COLUMN suitability_analysis TEXT`,
|
|
|
|
// Add cover letter column for AI-generated cover letters
|
|
`ALTER TABLE jobs ADD COLUMN cover_letter TEXT`,
|
|
|
|
// User notes on listings (must exist before jobs→jobs_new rebuild copies row data)
|
|
`ALTER TABLE jobs ADD COLUMN notes TEXT`,
|
|
|
|
// Create search profiles table for multi-profile support
|
|
`CREATE TABLE IF NOT EXISTS search_profiles (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
data TEXT NOT NULL,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
)`,
|
|
|
|
// Add application tracking columns
|
|
`ALTER TABLE jobs ADD COLUMN outcome TEXT`,
|
|
`ALTER TABLE jobs ADD COLUMN closed_at INTEGER`,
|
|
`ALTER TABLE stage_events ADD COLUMN outcome TEXT`,
|
|
`ALTER TABLE stage_events ADD COLUMN title TEXT NOT NULL DEFAULT ''`,
|
|
`ALTER TABLE stage_events ADD COLUMN group_id TEXT`,
|
|
|
|
// Smart-router columns for existing databases.
|
|
`ALTER TABLE post_application_messages ADD COLUMN match_confidence INTEGER`,
|
|
`ALTER TABLE post_application_messages ADD COLUMN message_type TEXT NOT NULL DEFAULT 'other' CHECK(message_type IN ('interview', 'rejection', 'offer', 'update', 'other'))`,
|
|
`ALTER TABLE post_application_messages ADD COLUMN stage_event_payload TEXT`,
|
|
`ALTER TABLE post_application_messages ADD COLUMN processing_status TEXT NOT NULL DEFAULT 'pending_user' CHECK(processing_status IN ('auto_linked', 'pending_user', 'manual_linked', 'ignored'))`,
|
|
`UPDATE post_application_messages
|
|
SET match_confidence = CAST(round(COALESCE(relevance_llm_score, 0)) AS INTEGER)
|
|
WHERE match_confidence IS NULL`,
|
|
`UPDATE post_application_messages
|
|
SET message_type = CASE
|
|
WHEN lower(COALESCE(classification_label, '')) LIKE '%interview%' THEN 'interview'
|
|
WHEN lower(COALESCE(classification_label, '')) LIKE '%offer%' THEN 'offer'
|
|
WHEN lower(COALESCE(classification_label, '')) LIKE '%reject%' THEN 'rejection'
|
|
WHEN lower(COALESCE(classification_label, '')) IN ('false positive', 'did not apply - inbound request') THEN 'other'
|
|
ELSE 'update'
|
|
END`,
|
|
`UPDATE post_application_messages
|
|
SET processing_status = CASE
|
|
WHEN review_status = 'approved' THEN 'manual_linked'
|
|
WHEN review_status IN ('pending_review', 'no_reliable_match') THEN 'pending_user'
|
|
ELSE 'ignored'
|
|
END`,
|
|
`DROP TABLE IF EXISTS post_application_message_candidates`,
|
|
`DROP TABLE IF EXISTS post_application_message_links`,
|
|
|
|
// Protect child tables (stage_events/tasks/interviews) during parent table rebuilds.
|
|
// Without this, dropping/replacing `jobs` can cascade-delete historical stage data.
|
|
`PRAGMA foreign_keys = OFF`,
|
|
|
|
// Ensure pipeline_runs status supports "cancelled" for existing databases.
|
|
`CREATE TABLE IF NOT EXISTS pipeline_runs_new (
|
|
id TEXT PRIMARY KEY,
|
|
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
completed_at TEXT,
|
|
status TEXT NOT NULL DEFAULT 'running' CHECK(status IN ('running', 'completed', 'failed', 'cancelled')),
|
|
jobs_discovered INTEGER NOT NULL DEFAULT 0,
|
|
jobs_processed INTEGER NOT NULL DEFAULT 0,
|
|
error_message TEXT
|
|
)`,
|
|
`INSERT OR REPLACE INTO pipeline_runs_new (id, started_at, completed_at, status, jobs_discovered, jobs_processed, error_message)
|
|
SELECT id, started_at, completed_at, status, jobs_discovered, jobs_processed, error_message
|
|
FROM pipeline_runs`,
|
|
`DROP TABLE IF EXISTS pipeline_runs`,
|
|
`ALTER TABLE pipeline_runs_new RENAME TO pipeline_runs`,
|
|
|
|
// Ensure jobs status supports "in_progress" for existing databases.
|
|
`CREATE TABLE IF NOT EXISTS jobs_new (
|
|
id TEXT PRIMARY KEY,
|
|
source TEXT NOT NULL DEFAULT 'gradcracker',
|
|
source_job_id TEXT,
|
|
job_url_direct TEXT,
|
|
date_posted TEXT,
|
|
job_type TEXT,
|
|
salary_source TEXT,
|
|
salary_interval TEXT,
|
|
salary_min_amount REAL,
|
|
salary_max_amount REAL,
|
|
salary_currency TEXT,
|
|
is_remote INTEGER,
|
|
job_level TEXT,
|
|
job_function TEXT,
|
|
listing_type TEXT,
|
|
emails TEXT,
|
|
company_industry TEXT,
|
|
company_logo TEXT,
|
|
company_url_direct TEXT,
|
|
company_addresses TEXT,
|
|
company_num_employees TEXT,
|
|
company_revenue TEXT,
|
|
company_description TEXT,
|
|
skills TEXT,
|
|
experience_range TEXT,
|
|
company_rating REAL,
|
|
company_reviews_count INTEGER,
|
|
vacancy_count INTEGER,
|
|
work_from_home_type TEXT,
|
|
title TEXT NOT NULL,
|
|
employer TEXT NOT NULL,
|
|
employer_url TEXT,
|
|
job_url TEXT NOT NULL UNIQUE,
|
|
application_link TEXT,
|
|
disciplines TEXT,
|
|
deadline TEXT,
|
|
salary TEXT,
|
|
location TEXT,
|
|
degree_required TEXT,
|
|
starting TEXT,
|
|
job_description TEXT,
|
|
status TEXT NOT NULL DEFAULT 'discovered' CHECK(status IN ('discovered', 'processing', 'ready', 'applied', 'in_progress', 'skipped', 'expired')),
|
|
outcome TEXT,
|
|
closed_at INTEGER,
|
|
suitability_score REAL,
|
|
suitability_reason TEXT,
|
|
suitability_analysis TEXT,
|
|
cover_letter TEXT,
|
|
tailored_summary TEXT,
|
|
tailored_headline TEXT,
|
|
tailored_skills TEXT,
|
|
selected_project_ids TEXT,
|
|
pdf_path TEXT,
|
|
tracer_links_enabled INTEGER NOT NULL DEFAULT 0,
|
|
sponsor_match_score REAL,
|
|
sponsor_match_names TEXT,
|
|
notes TEXT,
|
|
discovered_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
processed_at TEXT,
|
|
applied_at TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
)`,
|
|
`INSERT OR REPLACE INTO jobs_new (
|
|
id, source, source_job_id, job_url_direct, date_posted, job_type, salary_source, salary_interval,
|
|
salary_min_amount, salary_max_amount, salary_currency, is_remote, job_level, job_function, listing_type,
|
|
emails, company_industry, company_logo, company_url_direct, company_addresses, company_num_employees,
|
|
company_revenue, company_description, skills, experience_range, company_rating, company_reviews_count,
|
|
vacancy_count, work_from_home_type, title, employer, employer_url, job_url, application_link, disciplines,
|
|
deadline, salary, location, degree_required, starting, job_description, status, outcome, closed_at,
|
|
suitability_score, suitability_reason, suitability_analysis, cover_letter, tailored_summary, tailored_headline, tailored_skills,
|
|
selected_project_ids, pdf_path, tracer_links_enabled, sponsor_match_score, sponsor_match_names, notes, discovered_at, processed_at,
|
|
applied_at, created_at, updated_at
|
|
)
|
|
SELECT
|
|
id, source, source_job_id, job_url_direct, date_posted, job_type, salary_source, salary_interval,
|
|
salary_min_amount, salary_max_amount, salary_currency, is_remote, job_level, job_function, listing_type,
|
|
emails, company_industry, company_logo, company_url_direct, company_addresses, company_num_employees,
|
|
company_revenue, company_description, skills, experience_range, company_rating, company_reviews_count,
|
|
vacancy_count, work_from_home_type, title, employer, employer_url, job_url, application_link, disciplines,
|
|
deadline, salary, location, degree_required, starting, job_description, status, outcome, closed_at,
|
|
suitability_score, suitability_reason, suitability_analysis, cover_letter, tailored_summary, tailored_headline, tailored_skills,
|
|
selected_project_ids, pdf_path, tracer_links_enabled, sponsor_match_score, sponsor_match_names, notes, discovered_at, processed_at,
|
|
applied_at, created_at, updated_at
|
|
FROM jobs`,
|
|
`DROP TABLE IF EXISTS jobs`,
|
|
`ALTER TABLE jobs_new RENAME TO jobs`,
|
|
`PRAGMA foreign_keys = ON`,
|
|
|
|
`CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_jobs_discovered_at ON jobs(discovered_at)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_jobs_status_discovered_at ON jobs(status, discovered_at)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_pipeline_runs_started_at ON pipeline_runs(started_at)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_stage_events_application_id ON stage_events(application_id)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_stage_events_occurred_at ON stage_events(occurred_at)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_tasks_application_id ON tasks(application_id)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_tasks_due_date ON tasks(due_date)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_interviews_application_id ON interviews(application_id)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_post_app_sync_runs_provider_account_started_at ON post_application_sync_runs(provider, account_key, started_at)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_post_app_messages_provider_account_processing_status ON post_application_messages(provider, account_key, processing_status)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_job_chat_threads_job_updated ON job_chat_threads(job_id, updated_at)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_job_chat_messages_thread_created ON job_chat_messages(thread_id, created_at)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_job_chat_runs_thread_status ON job_chat_runs(thread_id, status)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_tracer_links_token ON tracer_links(token)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_tracer_links_job_id ON tracer_links(job_id)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_tracer_click_events_tracer_link_id ON tracer_click_events(tracer_link_id)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_tracer_click_events_clicked_at ON tracer_click_events(clicked_at)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_tracer_click_events_is_likely_bot ON tracer_click_events(is_likely_bot)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_tracer_click_events_unique_fingerprint_hash ON tracer_click_events(unique_fingerprint_hash)`,
|
|
// Ensure only one running run per thread; backfill any duplicates first.
|
|
`WITH ranked AS (
|
|
SELECT
|
|
id,
|
|
ROW_NUMBER() OVER (PARTITION BY thread_id ORDER BY started_at DESC, id DESC) AS rank_in_thread
|
|
FROM job_chat_runs
|
|
WHERE status = 'running'
|
|
)
|
|
UPDATE job_chat_runs
|
|
SET
|
|
status = 'failed',
|
|
error_code = COALESCE(error_code, 'CONFLICT'),
|
|
error_message = COALESCE(error_message, 'Recovered duplicate running run during migration'),
|
|
completed_at = COALESCE(completed_at, CAST(strftime('%s', 'now') AS INTEGER)),
|
|
updated_at = datetime('now')
|
|
WHERE id IN (SELECT id FROM ranked WHERE rank_in_thread > 1)`,
|
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_job_chat_runs_thread_running_unique
|
|
ON job_chat_runs(thread_id)
|
|
WHERE status = 'running'`,
|
|
|
|
// Backfill: Create "Applied" events for legacy jobs that have applied_at set but no event entry
|
|
`INSERT INTO stage_events (id, application_id, title, from_stage, to_stage, occurred_at, metadata)
|
|
SELECT
|
|
'backfill-applied-' || id,
|
|
id,
|
|
'Applied',
|
|
NULL,
|
|
'applied',
|
|
CAST(strftime('%s', applied_at) AS INTEGER),
|
|
'{"eventLabel":"Applied","actor":"system"}'
|
|
FROM jobs
|
|
WHERE applied_at IS NOT NULL
|
|
AND id NOT IN (SELECT application_id FROM stage_events WHERE to_stage = 'applied')`,
|
|
|
|
// Backfill: Create "Closed" events for legacy jobs already closed via outcome.
|
|
`INSERT INTO stage_events (id, application_id, title, from_stage, to_stage, occurred_at, metadata, outcome)
|
|
SELECT
|
|
'backfill-closed-' || jobs.id,
|
|
jobs.id,
|
|
'Closed',
|
|
(
|
|
SELECT se.to_stage
|
|
FROM stage_events se
|
|
WHERE se.application_id = jobs.id
|
|
ORDER BY se.occurred_at DESC, se.id DESC
|
|
LIMIT 1
|
|
),
|
|
'closed',
|
|
COALESCE(
|
|
jobs.closed_at,
|
|
CAST(strftime('%s', jobs.applied_at) AS INTEGER),
|
|
CAST(strftime('%s', jobs.updated_at) AS INTEGER),
|
|
CAST(strftime('%s', jobs.discovered_at) AS INTEGER),
|
|
CAST(strftime('%s', 'now') AS INTEGER)
|
|
),
|
|
'{"eventLabel":"Closed","actor":"system"}',
|
|
jobs.outcome
|
|
FROM jobs
|
|
WHERE jobs.outcome IS NOT NULL
|
|
AND jobs.id NOT IN (SELECT application_id FROM stage_events WHERE to_stage = 'closed')`,
|
|
|
|
// Backfill: Sync legacy workflow status from latest stage event.
|
|
`UPDATE jobs
|
|
SET
|
|
status = 'in_progress',
|
|
updated_at = datetime('now')
|
|
WHERE status = 'applied'
|
|
AND COALESCE((
|
|
SELECT se.to_stage
|
|
FROM stage_events se
|
|
WHERE se.application_id = jobs.id
|
|
ORDER BY se.occurred_at DESC, se.id DESC
|
|
LIMIT 1
|
|
), 'applied') IN (
|
|
'recruiter_screen',
|
|
'assessment',
|
|
'hiring_manager_screen',
|
|
'technical_interview',
|
|
'onsite',
|
|
'offer',
|
|
'closed'
|
|
)`,
|
|
|
|
// Branching conversations: add parent_message_id and active_child_id to job_chat_messages
|
|
`ALTER TABLE job_chat_messages ADD COLUMN parent_message_id TEXT`,
|
|
`ALTER TABLE job_chat_messages ADD COLUMN active_child_id TEXT`,
|
|
`ALTER TABLE job_chat_threads ADD COLUMN active_root_message_id TEXT`,
|
|
|
|
// Backfill: link existing messages into a linear chain (each message's parent = its predecessor)
|
|
`UPDATE job_chat_messages
|
|
SET parent_message_id = (
|
|
SELECT prev.id
|
|
FROM job_chat_messages prev
|
|
WHERE prev.thread_id = job_chat_messages.thread_id
|
|
AND prev.created_at < job_chat_messages.created_at
|
|
ORDER BY prev.created_at DESC
|
|
LIMIT 1
|
|
)
|
|
WHERE parent_message_id IS NULL`,
|
|
|
|
// Backfill: for regenerated messages, re-link as siblings (same parent as the message they replaced)
|
|
`UPDATE job_chat_messages
|
|
SET parent_message_id = (
|
|
SELECT orig.parent_message_id
|
|
FROM job_chat_messages orig
|
|
WHERE orig.id = job_chat_messages.replaces_message_id
|
|
)
|
|
WHERE replaces_message_id IS NOT NULL`,
|
|
|
|
// Backfill: set active_child_id on every parent to its newest child
|
|
`UPDATE job_chat_messages
|
|
SET active_child_id = (
|
|
SELECT child.id
|
|
FROM job_chat_messages child
|
|
WHERE child.parent_message_id = job_chat_messages.id
|
|
ORDER BY child.created_at DESC
|
|
LIMIT 1
|
|
)
|
|
WHERE id IN (SELECT DISTINCT parent_message_id FROM job_chat_messages WHERE parent_message_id IS NOT NULL)`,
|
|
|
|
`CREATE INDEX IF NOT EXISTS idx_job_chat_messages_parent ON job_chat_messages(parent_message_id)`,
|
|
|
|
// Backfill: Mark closed applications from latest stage event.
|
|
`UPDATE jobs
|
|
SET
|
|
status = 'in_progress',
|
|
closed_at = (
|
|
SELECT se.occurred_at
|
|
FROM stage_events se
|
|
WHERE se.application_id = jobs.id
|
|
ORDER BY se.occurred_at DESC, se.id DESC
|
|
LIMIT 1
|
|
),
|
|
outcome = COALESCE((
|
|
SELECT se.outcome
|
|
FROM stage_events se
|
|
WHERE se.application_id = jobs.id
|
|
ORDER BY se.occurred_at DESC, se.id DESC
|
|
LIMIT 1
|
|
), outcome),
|
|
updated_at = datetime('now')
|
|
WHERE status IN ('applied', 'in_progress')
|
|
AND COALESCE((
|
|
SELECT se.to_stage
|
|
FROM stage_events se
|
|
WHERE se.application_id = jobs.id
|
|
ORDER BY se.occurred_at DESC, se.id DESC
|
|
LIMIT 1
|
|
), 'applied') = 'closed'`,
|
|
|
|
// Per-profile job ownership: composite uniqueness on (owner_profile_id, job_url).
|
|
`ALTER TABLE jobs ADD COLUMN owner_profile_id TEXT`,
|
|
`UPDATE jobs SET owner_profile_id = '__default__' WHERE owner_profile_id IS NULL OR trim(owner_profile_id) = ''`,
|
|
`PRAGMA foreign_keys = OFF`,
|
|
`DROP TABLE IF EXISTS jobs_reowner_v1`,
|
|
`CREATE TABLE jobs_reowner_v1 (
|
|
id TEXT PRIMARY KEY,
|
|
owner_profile_id TEXT NOT NULL DEFAULT '__default__',
|
|
source TEXT NOT NULL DEFAULT 'gradcracker',
|
|
source_job_id TEXT,
|
|
job_url_direct TEXT,
|
|
date_posted TEXT,
|
|
job_type TEXT,
|
|
salary_source TEXT,
|
|
salary_interval TEXT,
|
|
salary_min_amount REAL,
|
|
salary_max_amount REAL,
|
|
salary_currency TEXT,
|
|
is_remote INTEGER,
|
|
job_level TEXT,
|
|
job_function TEXT,
|
|
listing_type TEXT,
|
|
emails TEXT,
|
|
company_industry TEXT,
|
|
company_logo TEXT,
|
|
company_url_direct TEXT,
|
|
company_addresses TEXT,
|
|
company_num_employees TEXT,
|
|
company_revenue TEXT,
|
|
company_description TEXT,
|
|
skills TEXT,
|
|
experience_range TEXT,
|
|
company_rating REAL,
|
|
company_reviews_count INTEGER,
|
|
vacancy_count INTEGER,
|
|
work_from_home_type TEXT,
|
|
title TEXT NOT NULL,
|
|
employer TEXT NOT NULL,
|
|
employer_url TEXT,
|
|
job_url TEXT NOT NULL,
|
|
application_link TEXT,
|
|
disciplines TEXT,
|
|
deadline TEXT,
|
|
salary TEXT,
|
|
location TEXT,
|
|
degree_required TEXT,
|
|
starting TEXT,
|
|
job_description TEXT,
|
|
status TEXT NOT NULL DEFAULT 'discovered' CHECK(status IN ('discovered', 'processing', 'ready', 'applied', 'in_progress', 'skipped', 'expired')),
|
|
outcome TEXT,
|
|
closed_at INTEGER,
|
|
suitability_score REAL,
|
|
suitability_reason TEXT,
|
|
suitability_analysis TEXT,
|
|
cover_letter TEXT,
|
|
tailored_summary TEXT,
|
|
tailored_headline TEXT,
|
|
tailored_skills TEXT,
|
|
selected_project_ids TEXT,
|
|
pdf_path TEXT,
|
|
tracer_links_enabled INTEGER NOT NULL DEFAULT 0,
|
|
sponsor_match_score REAL,
|
|
sponsor_match_names TEXT,
|
|
notes TEXT,
|
|
discovered_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
processed_at TEXT,
|
|
applied_at TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
UNIQUE(owner_profile_id, job_url)
|
|
)`,
|
|
`INSERT OR REPLACE INTO jobs_reowner_v1 (
|
|
id, owner_profile_id, source, source_job_id, job_url_direct, date_posted, job_type, salary_source, salary_interval,
|
|
salary_min_amount, salary_max_amount, salary_currency, is_remote, job_level, job_function, listing_type,
|
|
emails, company_industry, company_logo, company_url_direct, company_addresses, company_num_employees,
|
|
company_revenue, company_description, skills, experience_range, company_rating, company_reviews_count,
|
|
vacancy_count, work_from_home_type, title, employer, employer_url, job_url, application_link, disciplines,
|
|
deadline, salary, location, degree_required, starting, job_description, status, outcome, closed_at,
|
|
suitability_score, suitability_reason, suitability_analysis, cover_letter, tailored_summary, tailored_headline, tailored_skills,
|
|
selected_project_ids, pdf_path, tracer_links_enabled, sponsor_match_score, sponsor_match_names, notes, discovered_at, processed_at,
|
|
applied_at, created_at, updated_at
|
|
)
|
|
SELECT
|
|
id,
|
|
coalesce(nullif(trim(owner_profile_id), ''), '__default__'),
|
|
source, source_job_id, job_url_direct, date_posted, job_type, salary_source, salary_interval,
|
|
salary_min_amount, salary_max_amount, salary_currency, is_remote, job_level, job_function, listing_type,
|
|
emails, company_industry, company_logo, company_url_direct, company_addresses, company_num_employees,
|
|
company_revenue, company_description, skills, experience_range, company_rating, company_reviews_count,
|
|
vacancy_count, work_from_home_type, title, employer, employer_url, job_url, application_link, disciplines,
|
|
deadline, salary, location, degree_required, starting, job_description, status, outcome, closed_at,
|
|
suitability_score, suitability_reason, suitability_analysis, cover_letter, tailored_summary, tailored_headline, tailored_skills,
|
|
selected_project_ids, pdf_path, tracer_links_enabled, sponsor_match_score, sponsor_match_names, notes, discovered_at, processed_at,
|
|
applied_at, created_at, updated_at
|
|
FROM jobs`,
|
|
`DROP TABLE jobs`,
|
|
`ALTER TABLE jobs_reowner_v1 RENAME TO jobs`,
|
|
`PRAGMA foreign_keys = ON`,
|
|
`CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_jobs_discovered_at ON jobs(discovered_at)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_jobs_status_discovered_at ON jobs(status, discovered_at)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_jobs_owner_profile_id ON jobs(owner_profile_id)`,
|
|
|
|
// Cross-source dedup: store a normalized (employer, title) fingerprint and
|
|
// index it per owner so import-time skip lookups are fast. Backfill happens
|
|
// lazily in the repository on next insert; existing rows just get NULL until
|
|
// they're re-imported or rewritten.
|
|
`ALTER TABLE jobs ADD COLUMN content_fingerprint TEXT`,
|
|
`CREATE INDEX IF NOT EXISTS idx_jobs_owner_profile_content_fingerprint ON jobs(owner_profile_id, content_fingerprint)`,
|
|
|
|
`CREATE TABLE IF NOT EXISTS keyword_sets (
|
|
id TEXT PRIMARY KEY,
|
|
owner_profile_id TEXT NOT NULL DEFAULT '__default__',
|
|
name TEXT NOT NULL,
|
|
terms TEXT NOT NULL,
|
|
is_active INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
UNIQUE(owner_profile_id, name)
|
|
)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_keyword_sets_owner_active ON keyword_sets(owner_profile_id, is_active)`,
|
|
`INSERT INTO keyword_sets (id, owner_profile_id, name, terms, is_active, created_at, updated_at)
|
|
SELECT
|
|
'default-keyword-set',
|
|
'__default__',
|
|
'Default',
|
|
COALESCE((SELECT value FROM settings WHERE key = 'searchTerms'), '["web developer"]'),
|
|
1,
|
|
datetime('now'),
|
|
datetime('now')
|
|
WHERE NOT EXISTS (
|
|
SELECT 1 FROM keyword_sets WHERE owner_profile_id = '__default__'
|
|
)`,
|
|
|
|
// Seed default job-search personas (INSERT OR IGNORE — safe on existing DBs;
|
|
// existing databases keep whatever profile data they already have).
|
|
sqlInsertSearchProfileSeed({
|
|
id: "685b0000-0000-4000-8000-000000000001",
|
|
name: "Demo User",
|
|
profile: {
|
|
targetRoles: SDET_CA_TARGET_ROLES,
|
|
experienceLevel: "Senior",
|
|
mustHaveSkills: [
|
|
"Playwright",
|
|
"TypeScript",
|
|
"Java",
|
|
"API testing",
|
|
"CI/CD",
|
|
"Selenium",
|
|
"Cypress",
|
|
],
|
|
niceToHaveSkills: [
|
|
"Spring Boot",
|
|
"Python",
|
|
".NET",
|
|
"Contract testing",
|
|
"PostgreSQL",
|
|
"JUnit",
|
|
],
|
|
dealBreakers: [],
|
|
preferredWorkArrangement: ["remote", "hybrid"],
|
|
preferredLocations: [
|
|
"Toronto",
|
|
"Ontario",
|
|
"GTA",
|
|
"Greater Toronto Area",
|
|
"Canada",
|
|
"Remote Canada",
|
|
],
|
|
minimumSalary: "",
|
|
industriesToTarget: ["Software", "FinTech", "iGaming", "Enterprise SaaS"],
|
|
industriesToAvoid: [],
|
|
aboutMe:
|
|
"Demo User — Senior SDET / test automation (Canada & remote). Paired login user `demo` activates this profile and local resume demo-user.json (unset JOBOPS_LOCAL_RESUME_PATH so Settings path applies).",
|
|
basicAuthUser: "demo,demo-user",
|
|
resumeLocalPath: "../data/resumes/demo-user.json",
|
|
},
|
|
}),
|
|
`UPDATE keyword_sets
|
|
SET terms = (
|
|
SELECT json_extract(sp.data, '$.targetRoles')
|
|
FROM search_profiles sp
|
|
WHERE sp.id = keyword_sets.owner_profile_id
|
|
),
|
|
updated_at = datetime('now')
|
|
WHERE (terms = '[]' OR terms = '')
|
|
AND owner_profile_id != '__default__'
|
|
AND EXISTS (
|
|
SELECT 1 FROM search_profiles sp
|
|
WHERE sp.id = keyword_sets.owner_profile_id
|
|
AND json_array_length(json_extract(sp.data, '$.targetRoles')) > 0
|
|
)`,
|
|
`INSERT INTO keyword_sets (id, owner_profile_id, name, terms, is_active, created_at, updated_at)
|
|
SELECT
|
|
'demo-sdet-keyword-set',
|
|
'685b0000-0000-4000-8000-000000000001',
|
|
'SDET',
|
|
'${sqlJsonLiteral(SDET_CA_KEYWORD_TERMS)}',
|
|
1,
|
|
datetime('now'),
|
|
datetime('now')
|
|
WHERE NOT EXISTS (
|
|
SELECT 1 FROM keyword_sets
|
|
WHERE owner_profile_id = '685b0000-0000-4000-8000-000000000001'
|
|
AND name = 'SDET'
|
|
)`,
|
|
`DELETE FROM keyword_sets
|
|
WHERE owner_profile_id = '685b0000-0000-4000-8000-000000000001'
|
|
AND name = 'Default'
|
|
AND EXISTS (
|
|
SELECT 1 FROM keyword_sets ks
|
|
WHERE ks.owner_profile_id = '685b0000-0000-4000-8000-000000000001'
|
|
AND ks.name = 'SDET'
|
|
)`,
|
|
`UPDATE keyword_sets
|
|
SET is_active = 1, updated_at = datetime('now')
|
|
WHERE owner_profile_id = '685b0000-0000-4000-8000-000000000001'
|
|
AND name = 'SDET'`,
|
|
`UPDATE keyword_sets
|
|
SET is_active = 0, updated_at = datetime('now')
|
|
WHERE owner_profile_id = '685b0000-0000-4000-8000-000000000001'
|
|
AND name != 'SDET'
|
|
AND is_active = 1
|
|
AND EXISTS (
|
|
SELECT 1 FROM keyword_sets ks
|
|
WHERE ks.owner_profile_id = '685b0000-0000-4000-8000-000000000001'
|
|
AND ks.name = 'SDET'
|
|
AND ks.is_active = 1
|
|
)`,
|
|
`INSERT OR IGNORE INTO keyword_sets (id, owner_profile_id, name, terms, is_active, created_at, updated_at)
|
|
VALUES (
|
|
'demo-caseware',
|
|
'685b0000-0000-4000-8000-000000000001',
|
|
'Caseware',
|
|
'["Caseware","Caseware template developer","Caseware template specialist","Caseware engagement template","Caseware working papers","Caseware Assurance","Caseware consultant","CaseWare"]',
|
|
0,
|
|
datetime('now'),
|
|
datetime('now')
|
|
)`,
|
|
`INSERT OR IGNORE INTO keyword_sets (id, owner_profile_id, name, terms, is_active, created_at, updated_at)
|
|
VALUES (
|
|
'demo-lead',
|
|
'685b0000-0000-4000-8000-000000000001',
|
|
'Lead',
|
|
'["lead SDET","lead QA","test lead","QA manager","engineering manager test"]',
|
|
0,
|
|
datetime('now'),
|
|
datetime('now')
|
|
)`,
|
|
`ALTER TABLE jobs ADD COLUMN sponsorship_signals TEXT`,
|
|
sqlInsertSearchProfileSeed({
|
|
id: "685b0000-0000-4000-8000-000000000002",
|
|
name: "Demo User (QA)",
|
|
profile: {
|
|
targetRoles: [
|
|
"Senior QA Analyst",
|
|
"QA Analyst",
|
|
"Guidewire Tester",
|
|
"Test Analyst",
|
|
"Manual QA Tester",
|
|
"UAT Tester",
|
|
"Quality Assurance Specialist",
|
|
"Accessibility Tester",
|
|
"Integration Tester",
|
|
],
|
|
experienceLevel: "Senior",
|
|
mustHaveSkills: [
|
|
"Guidewire",
|
|
"API testing",
|
|
"Selenium",
|
|
"SQL",
|
|
"Accessibility",
|
|
"UAT",
|
|
],
|
|
niceToHaveSkills: ["JMeter", "Azure DevOps", "Postman"],
|
|
dealBreakers: [],
|
|
preferredWorkArrangement: ["remote", "hybrid", "onsite"],
|
|
preferredLocations: ["Toronto", "Ontario", "GTA", "Canada"],
|
|
minimumSalary: "",
|
|
industriesToTarget: [
|
|
"Insurance",
|
|
"Banking",
|
|
"Public sector",
|
|
"Enterprise software",
|
|
],
|
|
industriesToAvoid: [],
|
|
aboutMe:
|
|
"Demo User (QA) — Senior QA (Guidewire, accessibility, UAT); paired login user `demo-qa` activates this profile and local resume demo-qa.json (unset JOBOPS_LOCAL_RESUME_PATH so Settings path applies).",
|
|
basicAuthUser: "demo-qa",
|
|
resumeLocalPath: "../data/resumes/demo-qa.json",
|
|
},
|
|
}),
|
|
sqlInsertSearchProfileSeed({
|
|
id: "685b0000-0000-4000-8000-000000000003",
|
|
name: "Demo User (US)",
|
|
profile: {
|
|
targetRoles: SDET_US_TARGET_ROLES,
|
|
experienceLevel: "Senior",
|
|
mustHaveSkills: [
|
|
"Playwright",
|
|
"TypeScript",
|
|
"API testing",
|
|
"CI/CD",
|
|
"Java",
|
|
"Integration testing",
|
|
"Selenium",
|
|
],
|
|
niceToHaveSkills: [
|
|
"Cypress",
|
|
"Spring Boot",
|
|
"Python",
|
|
".NET",
|
|
"Contract testing",
|
|
"PostgreSQL",
|
|
],
|
|
dealBreakers: [],
|
|
preferredWorkArrangement: ["remote", "hybrid"],
|
|
preferredLocations: ["United States", "U.S.", "USA", "Remote US", "TN"],
|
|
minimumSalary: "",
|
|
industriesToTarget: [
|
|
"Software",
|
|
"FinTech",
|
|
"Financial services",
|
|
"Enterprise SaaS",
|
|
],
|
|
industriesToAvoid: [],
|
|
aboutMe:
|
|
"Demo User (US) — Senior SDET / QA automation targeting U.S. remote roles. Computer Systems Analyst-aligned framing. Switch to this profile in the Jobs page picker (same login as Canada).",
|
|
basicAuthUser: "demo,demo-user",
|
|
resumeLocalPath: "../data/resumes/demo-user-us.json",
|
|
},
|
|
}),
|
|
`INSERT OR IGNORE INTO keyword_sets (id, owner_profile_id, name, terms, is_active, created_at, updated_at)
|
|
VALUES (
|
|
'demo-sdet-us-keyword-set',
|
|
'685b0000-0000-4000-8000-000000000003',
|
|
'SDET',
|
|
'${sqlJsonLiteral(SDET_US_KEYWORD_TERMS)}',
|
|
1,
|
|
datetime('now'),
|
|
datetime('now')
|
|
)`,
|
|
// Remove duplicate seed profiles left by repository tests (same names, test UUIDs).
|
|
`DELETE FROM keyword_sets
|
|
WHERE owner_profile_id IN (
|
|
'685b0000-0000-4000-8000-000000009901',
|
|
'685b0000-0000-4000-8000-000000009902'
|
|
)`,
|
|
`DELETE FROM search_profiles
|
|
WHERE id IN (
|
|
'685b0000-0000-4000-8000-000000009901',
|
|
'685b0000-0000-4000-8000-000000009902'
|
|
)`,
|
|
// AI engineering profile — agents, MCP, RAG, evals (paired with same login as Canada/US).
|
|
sqlInsertSearchProfileSeed({
|
|
id: "685b0000-0000-4000-8000-000000000004",
|
|
name: "Demo User (AI)",
|
|
profile: {
|
|
targetRoles: AI_ENGINEER_TARGET_ROLES,
|
|
experienceLevel: "Senior",
|
|
mustHaveSkills: [
|
|
"Python",
|
|
"LLM",
|
|
"RAG",
|
|
"MCP",
|
|
"Agents",
|
|
"FastAPI",
|
|
"Prompt engineering",
|
|
],
|
|
niceToHaveSkills: [
|
|
"LangGraph",
|
|
"Ollama",
|
|
"promptfoo",
|
|
"TypeScript",
|
|
"Playwright",
|
|
"Ansible",
|
|
"Docker",
|
|
],
|
|
dealBreakers: [],
|
|
preferredWorkArrangement: ["remote", "hybrid"],
|
|
preferredLocations: [
|
|
"Toronto",
|
|
"Ontario",
|
|
"GTA",
|
|
"Greater Toronto Area",
|
|
"Canada",
|
|
"Remote Canada",
|
|
"Remote",
|
|
],
|
|
minimumSalary: "",
|
|
industriesToTarget: [
|
|
"Software",
|
|
"AI / ML",
|
|
"Enterprise SaaS",
|
|
"FinTech",
|
|
"Developer tools",
|
|
],
|
|
industriesToAvoid: [],
|
|
aboutMe:
|
|
"Demo User (AI) — AI Automation Engineer / LLM agents, MCP, RAG & evals; AI enablement lead experience. Switch to this profile for AI-role discovery (same login as Canada/US). Resume: demo-user-ai.json.",
|
|
basicAuthUser: "demo,demo-user",
|
|
resumeLocalPath: "../data/resumes/demo-user-ai.json",
|
|
},
|
|
}),
|
|
`INSERT OR IGNORE INTO keyword_sets (id, owner_profile_id, name, terms, is_active, created_at, updated_at)
|
|
VALUES (
|
|
'demo-ai-keyword-set',
|
|
'685b0000-0000-4000-8000-000000000004',
|
|
'AI Engineer',
|
|
'${sqlJsonLiteral(AI_ENGINEER_KEYWORD_TERMS)}',
|
|
1,
|
|
datetime('now'),
|
|
datetime('now')
|
|
)`,
|
|
// Also add AI Engineer as an inactive keyword set on the Canada SDET profile
|
|
// (same pattern as Caseware / Lead) so it can be flipped without switching profiles.
|
|
`INSERT OR IGNORE INTO keyword_sets (id, owner_profile_id, name, terms, is_active, created_at, updated_at)
|
|
VALUES (
|
|
'demo-ai',
|
|
'685b0000-0000-4000-8000-000000000001',
|
|
'AI Engineer',
|
|
'${sqlJsonLiteral(AI_ENGINEER_KEYWORD_TERMS)}',
|
|
0,
|
|
datetime('now'),
|
|
datetime('now')
|
|
)`,
|
|
];
|
|
|
|
console.log("🔧 Running database migrations...");
|
|
|
|
sqlite.exec(`
|
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
id TEXT PRIMARY KEY,
|
|
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
)
|
|
`);
|
|
|
|
const jobsReownerApplied = Boolean(
|
|
(
|
|
sqlite
|
|
.prepare(`SELECT 1 AS ok FROM schema_migrations WHERE id = ?`)
|
|
.get("jobs_reowner_v1") as { ok?: number } | undefined
|
|
)?.ok,
|
|
);
|
|
|
|
const jobsTableSql = (
|
|
sqlite
|
|
.prepare(
|
|
`SELECT sql AS sql FROM sqlite_master WHERE type = 'table' AND name = 'jobs'`,
|
|
)
|
|
.get() as { sql?: string } | undefined
|
|
)?.sql;
|
|
const jobsAlreadyReowned =
|
|
jobsReownerApplied ||
|
|
Boolean(jobsTableSql?.includes("UNIQUE(owner_profile_id, job_url)"));
|
|
|
|
const REOWNER_MIGRATION_MARKERS = [
|
|
"DROP TABLE IF EXISTS jobs_reowner_v1",
|
|
"CREATE TABLE jobs_reowner_v1 (",
|
|
"INSERT OR REPLACE INTO jobs_reowner_v1 (",
|
|
"ALTER TABLE jobs_reowner_v1 RENAME TO jobs",
|
|
] as const;
|
|
|
|
for (const migration of migrations) {
|
|
// One-time table rebuild: do not DROP/recreate jobs on every boot.
|
|
if (
|
|
jobsAlreadyReowned &&
|
|
(REOWNER_MIGRATION_MARKERS.some((marker) => migration.includes(marker)) ||
|
|
migration.trim() === "DROP TABLE jobs")
|
|
) {
|
|
console.log("↩️ Migration skipped (jobs already reowned)");
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
sqlite.exec(migration);
|
|
console.log("✅ Migration applied");
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
const isDuplicateColumn =
|
|
(migration.toLowerCase().includes("alter table jobs add column") ||
|
|
migration.toLowerCase().includes("alter table tasks add column") ||
|
|
migration
|
|
.toLowerCase()
|
|
.includes("alter table post_application_messages add column") ||
|
|
migration
|
|
.toLowerCase()
|
|
.includes("alter table stage_events add column") ||
|
|
migration
|
|
.toLowerCase()
|
|
.includes("alter table job_chat_messages add column") ||
|
|
migration
|
|
.toLowerCase()
|
|
.includes("alter table job_chat_threads add column")) &&
|
|
message.toLowerCase().includes("duplicate column name");
|
|
|
|
if (isDuplicateColumn) {
|
|
console.log("↩️ Migration skipped (column already exists)");
|
|
continue;
|
|
}
|
|
|
|
const isLegacyBackfillOnFreshSchema =
|
|
migration.toLowerCase().includes("update post_application_messages") &&
|
|
message.toLowerCase().includes("no such column");
|
|
if (isLegacyBackfillOnFreshSchema) {
|
|
console.log("↩️ Migration skipped (legacy backfill not applicable)");
|
|
continue;
|
|
}
|
|
|
|
// Optional performance-only migration: if this fails we should still boot
|
|
// existing databases and continue without the index.
|
|
const isOptionalOptimizationMigration = migration.includes(
|
|
"idx_jobs_status_discovered_at",
|
|
);
|
|
if (isOptionalOptimizationMigration) {
|
|
console.warn("⚠️ Optional migration skipped:", message);
|
|
continue;
|
|
}
|
|
|
|
console.error("❌ Migration failed:", error);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
if (!jobsReownerApplied) {
|
|
const sqlAfter = (
|
|
sqlite
|
|
.prepare(
|
|
`SELECT sql AS sql FROM sqlite_master WHERE type = 'table' AND name = 'jobs'`,
|
|
)
|
|
.get() as { sql?: string } | undefined
|
|
)?.sql;
|
|
if (sqlAfter?.includes("UNIQUE(owner_profile_id, job_url)")) {
|
|
sqlite
|
|
.prepare(
|
|
`INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, datetime('now'))`,
|
|
)
|
|
.run("jobs_reowner_v1");
|
|
}
|
|
}
|
|
|
|
sqlite.close();
|
|
console.log("🎉 Database migrations complete!");
|