386 lines
13 KiB
TypeScript
386 lines
13 KiB
TypeScript
/**
|
||
* SQL Query Constants
|
||
*
|
||
* All SQL queries used in the application.
|
||
* Repositories reference these by name.
|
||
*
|
||
* All queries use parameterized statements (?) for security.
|
||
* User input NEVER goes into the SQL string.
|
||
*/
|
||
|
||
// ── Holdings Table Queries ───────────────────────────────────────────────────
|
||
|
||
export const HOLDINGS_QUERIES = {
|
||
// Check if any holdings exist for a user
|
||
EXISTS: 'SELECT COUNT(*) AS n FROM holdings WHERE user_id = ?',
|
||
|
||
// Get all holdings for a user, sorted by ticker
|
||
SELECT_ALL: `
|
||
SELECT ticker, shares, cost_basis, type, source
|
||
FROM holdings
|
||
WHERE user_id = ?
|
||
ORDER BY ticker ASC
|
||
`,
|
||
|
||
// Insert or update a holding scoped to a user
|
||
UPSERT: `
|
||
INSERT INTO holdings (ticker, shares, cost_basis, type, source, user_id)
|
||
VALUES (?, ?, ?, ?, ?, ?)
|
||
ON CONFLICT(ticker, user_id) DO UPDATE SET
|
||
shares = excluded.shares,
|
||
cost_basis = excluded.cost_basis,
|
||
type = excluded.type,
|
||
source = excluded.source
|
||
`,
|
||
|
||
// Delete a holding by ticker for a specific user
|
||
DELETE_BY_TICKER: 'DELETE FROM holdings WHERE ticker = ? AND user_id = ?',
|
||
|
||
// Migrate ownerless holdings to admin user (one-time)
|
||
MIGRATE_TO_ADMIN: "UPDATE holdings SET user_id = ? WHERE user_id IS NULL OR user_id = ''",
|
||
};
|
||
|
||
// ── Market Calls Table Queries ───────────────────────────────────────────────
|
||
|
||
export const MARKET_CALLS_QUERIES = {
|
||
// Get all market calls, newest first
|
||
SELECT_ALL: `
|
||
SELECT id, title, quarter, date, thesis, tickers, snapshot, created_at
|
||
FROM market_calls
|
||
ORDER BY created_at DESC
|
||
`,
|
||
|
||
// Get a single market call by ID
|
||
SELECT_BY_ID: `
|
||
SELECT id, title, quarter, date, thesis, tickers, snapshot, created_at
|
||
FROM market_calls
|
||
WHERE id = ?
|
||
`,
|
||
|
||
// Insert a new market call
|
||
INSERT: `
|
||
INSERT INTO market_calls (id, title, quarter, date, thesis, tickers, snapshot, created_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||
`,
|
||
|
||
// Delete a market call by ID
|
||
DELETE_BY_ID: 'DELETE FROM market_calls WHERE id = ?',
|
||
};
|
||
|
||
// ── Migration Queries (for DatabaseInitializer) ──────────────────────────────
|
||
|
||
export const MIGRATION_QUERIES = {
|
||
// Insert holdings during migration
|
||
HOLDINGS_INSERT_OR_IGNORE: `
|
||
INSERT OR IGNORE INTO holdings (ticker, shares, cost_basis, type, source, user_id)
|
||
VALUES (?, ?, ?, ?, ?, ?)
|
||
`,
|
||
|
||
// Insert market calls during migration
|
||
MARKET_CALLS_INSERT_OR_IGNORE: `
|
||
INSERT OR IGNORE INTO market_calls (id, title, quarter, date, thesis, tickers, snapshot, created_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||
`,
|
||
};
|
||
|
||
// ── User Table Queries ───────────────────────────────────────────────────────
|
||
|
||
export const USER_QUERIES = {
|
||
SELECT_BY_EMAIL: `
|
||
SELECT id, email, password_hash, role, created_at, last_login
|
||
FROM users WHERE email = ?
|
||
`,
|
||
|
||
SELECT_BY_ID: `
|
||
SELECT id, email, role, created_at, last_login
|
||
FROM users WHERE id = ?
|
||
`,
|
||
|
||
INSERT: `
|
||
INSERT INTO users (id, email, password_hash, role, created_at)
|
||
VALUES (?, ?, ?, ?, ?)
|
||
`,
|
||
|
||
UPDATE_LAST_LOGIN: `
|
||
UPDATE users SET last_login = ? WHERE id = ?
|
||
`,
|
||
};
|
||
|
||
// ── Password Reset Token Queries ─────────────────────────────────────────────
|
||
|
||
export const RESET_TOKEN_QUERIES = {
|
||
INSERT: `
|
||
INSERT INTO password_reset_tokens (token, user_id, expires_at)
|
||
VALUES (?, ?, ?)
|
||
`,
|
||
FIND: `
|
||
SELECT token, user_id, expires_at, used
|
||
FROM password_reset_tokens
|
||
WHERE token = ?
|
||
`,
|
||
MARK_USED: `
|
||
UPDATE password_reset_tokens SET used = 1 WHERE token = ?
|
||
`,
|
||
// Clean up expired/used tokens older than 24h
|
||
PURGE: `
|
||
DELETE FROM password_reset_tokens
|
||
WHERE used = 1 OR expires_at < ?
|
||
`,
|
||
};
|
||
|
||
// ── Schema Definition (DDL) ──────────────────────────────────────────────────
|
||
|
||
// ── Watchlist Queries ────────────────────────────────────────────────────────
|
||
|
||
export const WATCHLIST_QUERIES = {
|
||
SELECT_ALL: `
|
||
SELECT ticker, pinned_at
|
||
FROM watchlist
|
||
WHERE user_id = ?
|
||
ORDER BY pinned_at DESC
|
||
`,
|
||
INSERT: `
|
||
INSERT OR IGNORE INTO watchlist (ticker, user_id, pinned_at)
|
||
VALUES (?, ?, ?)
|
||
`,
|
||
DELETE: `
|
||
DELETE FROM watchlist WHERE ticker = ? AND user_id = ?
|
||
`,
|
||
EXISTS: `
|
||
SELECT 1 FROM watchlist WHERE ticker = ? AND user_id = ?
|
||
`,
|
||
};
|
||
|
||
// ── Screening Universe Queries (bin/daily-screen.ts) ────────────────────────
|
||
|
||
export const UNIVERSE_QUERIES = {
|
||
// Every ticker pinned by any user
|
||
DISTINCT_WATCHLIST_TICKERS: 'SELECT DISTINCT ticker FROM watchlist ORDER BY ticker',
|
||
|
||
// Every ticker held by any user (crypto excluded — not fundamentally scored)
|
||
DISTINCT_HOLDING_TICKERS: `
|
||
SELECT DISTINCT ticker FROM holdings
|
||
WHERE type != 'crypto'
|
||
ORDER BY ticker
|
||
`,
|
||
|
||
// Every ticker screened recently (snapshot ledger) — part of the news universe
|
||
DISTINCT_SNAPSHOT_TICKERS_SINCE: `
|
||
SELECT DISTINCT ticker FROM signal_snapshots
|
||
WHERE snapshot_date >= ?
|
||
ORDER BY ticker
|
||
`,
|
||
};
|
||
|
||
// ── News Queries (FREE-DATA-STACK §2–5 — free-tier news pipeline) ───────────
|
||
|
||
export const NEWS_QUERIES = {
|
||
// INSERT OR IGNORE — url_hash PK is the first dedupe line (returns 0 changes on dup)
|
||
INSERT_ARTICLE: `
|
||
INSERT OR IGNORE INTO news_articles
|
||
(url_hash, title_hash, ticker_list, headline, body, source, catalyst, url, published_at, created_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
`,
|
||
|
||
// Second dedupe line: same (normalized) title seen recently → syndicated copy
|
||
TITLE_SEEN_SINCE: `
|
||
SELECT 1 FROM news_articles
|
||
WHERE title_hash = ? AND published_at >= ?
|
||
LIMIT 1
|
||
`,
|
||
|
||
INSERT_CATALYST_LINK: `
|
||
INSERT OR IGNORE INTO ticker_catalysts (ticker, day, url_hash)
|
||
VALUES (?, ?, ?)
|
||
`,
|
||
|
||
// Per-ticker daily cap check (FREE-DATA-STACK §4.4)
|
||
COUNT_TICKER_DAY: `
|
||
SELECT COUNT(*) AS n FROM ticker_catalysts
|
||
WHERE ticker = ? AND day = ?
|
||
`,
|
||
|
||
// Stories for one ticker since a given day — what the UI reads (never Yahoo live)
|
||
SELECT_TICKER_NEWS: `
|
||
SELECT a.* FROM ticker_catalysts c
|
||
JOIN news_articles a ON a.url_hash = c.url_hash
|
||
WHERE c.ticker = ? AND c.day >= ?
|
||
ORDER BY a.published_at DESC
|
||
`,
|
||
|
||
SELECT_RECENT: `
|
||
SELECT * FROM news_articles
|
||
ORDER BY published_at DESC
|
||
LIMIT ?
|
||
`,
|
||
|
||
// Retention (FREE-DATA-STACK §5): purge bodies after 90d, drop unreferenced after 18mo
|
||
PURGE_BODIES_BEFORE: `
|
||
UPDATE news_articles SET body = NULL
|
||
WHERE body IS NOT NULL AND published_at < ?
|
||
`,
|
||
|
||
DELETE_UNREFERENCED_BEFORE: `
|
||
DELETE FROM news_articles
|
||
WHERE published_at < ?
|
||
AND url_hash NOT IN (SELECT url_hash FROM ticker_catalysts)
|
||
`,
|
||
};
|
||
|
||
// ── Signal Snapshot Queries (P0.1 — signal track record) ────────────────────
|
||
|
||
export const SIGNAL_SNAPSHOT_QUERIES = {
|
||
// One row per ticker per day — repeated screens the same day keep the latest
|
||
UPSERT: `
|
||
INSERT INTO signal_snapshots (
|
||
ticker, snapshot_date, asset_type, price, signal,
|
||
fundamental_tier, fundamental_score, fundamental_label,
|
||
inflated_tier, inflated_score, inflated_label,
|
||
coverage_active, coverage_total, risk_flags, rate_regime, created_at
|
||
)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
ON CONFLICT(ticker, snapshot_date) DO UPDATE SET
|
||
asset_type = excluded.asset_type,
|
||
price = excluded.price,
|
||
signal = excluded.signal,
|
||
fundamental_tier = excluded.fundamental_tier,
|
||
fundamental_score = excluded.fundamental_score,
|
||
fundamental_label = excluded.fundamental_label,
|
||
inflated_tier = excluded.inflated_tier,
|
||
inflated_score = excluded.inflated_score,
|
||
inflated_label = excluded.inflated_label,
|
||
coverage_active = excluded.coverage_active,
|
||
coverage_total = excluded.coverage_total,
|
||
risk_flags = excluded.risk_flags,
|
||
rate_regime = excluded.rate_regime,
|
||
created_at = excluded.created_at
|
||
`,
|
||
|
||
// Full history for one ticker, oldest first (for trend/backtest views)
|
||
SELECT_BY_TICKER: `
|
||
SELECT * FROM signal_snapshots
|
||
WHERE ticker = ?
|
||
ORDER BY snapshot_date ASC
|
||
`,
|
||
|
||
// All snapshots for one day (for daily diff jobs)
|
||
SELECT_BY_DATE: `
|
||
SELECT * FROM signal_snapshots
|
||
WHERE snapshot_date = ?
|
||
ORDER BY ticker ASC
|
||
`,
|
||
|
||
// Latest snapshot per ticker on or before a given date (for change detection)
|
||
SELECT_LATEST_BEFORE: `
|
||
SELECT s.* FROM signal_snapshots s
|
||
JOIN (
|
||
SELECT ticker, MAX(snapshot_date) AS d
|
||
FROM signal_snapshots
|
||
WHERE snapshot_date < ?
|
||
GROUP BY ticker
|
||
) latest ON latest.ticker = s.ticker AND latest.d = s.snapshot_date
|
||
`,
|
||
};
|
||
|
||
export const DDL = `
|
||
CREATE TABLE IF NOT EXISTS users (
|
||
id TEXT PRIMARY KEY,
|
||
email TEXT UNIQUE NOT NULL,
|
||
password_hash TEXT NOT NULL,
|
||
role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('trader', 'viewer', 'admin')),
|
||
created_at TEXT NOT NULL,
|
||
last_login TEXT
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS holdings (
|
||
ticker TEXT NOT NULL,
|
||
user_id TEXT NOT NULL REFERENCES users(id),
|
||
shares REAL NOT NULL,
|
||
cost_basis REAL NOT NULL DEFAULT 0,
|
||
type TEXT NOT NULL DEFAULT 'stock',
|
||
source TEXT NOT NULL DEFAULT 'Manual',
|
||
PRIMARY KEY (ticker, user_id)
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
||
token TEXT PRIMARY KEY,
|
||
user_id TEXT NOT NULL REFERENCES users(id),
|
||
expires_at TEXT NOT NULL,
|
||
used INTEGER NOT NULL DEFAULT 0
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS market_calls (
|
||
id TEXT PRIMARY KEY,
|
||
title TEXT NOT NULL,
|
||
quarter TEXT NOT NULL,
|
||
date TEXT NOT NULL,
|
||
thesis TEXT NOT NULL,
|
||
tickers TEXT NOT NULL, -- JSON array
|
||
snapshot TEXT NOT NULL, -- JSON object
|
||
created_at TEXT NOT NULL
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS watchlist (
|
||
ticker TEXT NOT NULL,
|
||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||
pinned_at TEXT NOT NULL,
|
||
PRIMARY KEY (ticker, user_id)
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS signal_snapshots (
|
||
ticker TEXT NOT NULL,
|
||
snapshot_date TEXT NOT NULL, -- YYYY-MM-DD
|
||
asset_type TEXT NOT NULL, -- STOCK / ETF / BOND
|
||
price REAL,
|
||
signal TEXT NOT NULL, -- ✅ Strong Buy etc.
|
||
fundamental_tier TEXT NOT NULL, -- PASS / HOLD / REJECT
|
||
fundamental_score REAL,
|
||
fundamental_label TEXT,
|
||
inflated_tier TEXT NOT NULL,
|
||
inflated_score REAL,
|
||
inflated_label TEXT,
|
||
coverage_active INTEGER,
|
||
coverage_total INTEGER,
|
||
risk_flags TEXT, -- JSON array
|
||
rate_regime TEXT,
|
||
created_at TEXT NOT NULL,
|
||
PRIMARY KEY (ticker, snapshot_date)
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_snapshots_date ON signal_snapshots(snapshot_date);
|
||
CREATE INDEX IF NOT EXISTS idx_snapshots_signal ON signal_snapshots(signal, snapshot_date);
|
||
|
||
CREATE TABLE IF NOT EXISTS news_articles (
|
||
url_hash TEXT PRIMARY KEY, -- sha256(url)
|
||
title_hash TEXT NOT NULL, -- sha256(normalized headline) — syndication dedupe
|
||
ticker_list TEXT NOT NULL, -- JSON array of matched universe tickers
|
||
headline TEXT NOT NULL,
|
||
body TEXT, -- nullable; purged after 90 days (retention job)
|
||
source TEXT NOT NULL, -- 'edgar' | 'prwire' | 'yahoo'
|
||
catalyst TEXT, -- 'earnings'|'ma'|'guidance'|'regulatory'|'macro'|NULL
|
||
url TEXT NOT NULL,
|
||
published_at TEXT NOT NULL, -- ISO timestamp
|
||
created_at TEXT NOT NULL
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_news_published ON news_articles(published_at DESC);
|
||
CREATE INDEX IF NOT EXISTS idx_news_title ON news_articles(title_hash, published_at);
|
||
|
||
CREATE TABLE IF NOT EXISTS ticker_catalysts (
|
||
ticker TEXT NOT NULL,
|
||
day TEXT NOT NULL, -- YYYY-MM-DD (published date)
|
||
url_hash TEXT NOT NULL REFERENCES news_articles(url_hash),
|
||
PRIMARY KEY (ticker, day, url_hash)
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_catalysts_ticker ON ticker_catalysts(ticker, day DESC);
|
||
`;
|
||
|
||
// ── Runtime migrations (ALTER TABLE for existing DBs) ────────────────────────
|
||
// These are safe to run repeatedly — they no-op if the column already exists.
|
||
|
||
export const RUNTIME_MIGRATIONS = [
|
||
// Add user_id to holdings if upgrading from pre-auth schema
|
||
`ALTER TABLE holdings ADD COLUMN user_id TEXT NOT NULL DEFAULT '' REFERENCES users(id)`,
|
||
];
|