ilia 285b094bce chore: replace personal seed data with generic demo profiles
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.
2026-07-26 15:54:12 -04:00

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!");