phase-10.5: market screener ui enhancements

This commit is contained in:
Kazuma
2026-06-09 01:21:02 -04:00
parent 7bc242911e
commit fbadd7fb6e
45 changed files with 3054 additions and 539 deletions
@@ -139,6 +139,24 @@ export class DatabaseConnection {
return txn();
}
/**
* Execute a raw SQL SELECT and return the first row.
* Use only when QueryBuilder is not practical (e.g. auth domain with static queries).
*/
rawGet<T = Record<string, unknown>>(sql: string, params: unknown[] = []): T | undefined {
const stmt = this.getOrCacheStatement(sql);
return stmt.get(...params) as T | undefined;
}
/**
* Execute a raw SQL INSERT/UPDATE/DELETE.
* Use only when QueryBuilder is not practical (e.g. auth domain with static queries).
*/
rawRun(sql: string, params: unknown[] = []): number {
const stmt = this.getOrCacheStatement(sql);
return stmt.run(...params).changes;
}
/**
* Get the raw better-sqlite3 Db instance (for advanced use only).
* Prefer the DatabaseConnection methods.
+87 -35
View File
@@ -4,14 +4,15 @@
* Handles:
* - Creating/opening SQLite database
* - Running DDL schema setup
* - Runtime ALTER TABLE migrations (safe to re-run)
* - Seeding the admin user from ADMIN_EMAIL + ADMIN_PASSWORD env vars
* - Migrating legacy JSON files (one-time)
*/
import BetterSqlite3 from 'better-sqlite3';
import { existsSync, readFileSync, renameSync } from 'fs';
import { randomUUID } from 'crypto';
import { DDL } from './queries.constant';
import { QueryBuilder } from '../utils/QueryBuilder';
import { randomUUID, randomBytes, scryptSync } from 'crypto';
import { DDL, RUNTIME_MIGRATIONS, HOLDINGS_QUERIES, USER_QUERIES } from './queries.constant.js';
export type Db = BetterSqlite3.Database;
@@ -43,85 +44,137 @@ interface LegacyCall {
*
* Steps:
* 1. Create/open database file
* 2. Enable WAL mode (concurrent read safety)
* 3. Enable foreign keys
* 4. Run DDL (create tables if missing)
* 5. Migrate legacy JSON files (one-time)
*
* @param path Path to database file (default: ./market-screener.db)
* @returns Opened database instance (wrap in DatabaseConnection for safe access)
* 2. Enable WAL mode + foreign keys
* 3. Run DDL (create tables if missing)
* 4. Run runtime ALTER TABLE migrations (adds user_id etc. to existing DBs)
* 5. Seed admin user from env vars
* 6. Migrate legacy JSON files (one-time)
*/
export function createDb(path = './market-screener.db'): Db {
const db = new BetterSqlite3(path);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.pragma('foreign_keys = OFF'); // off during schema changes, back on after
db.exec(DDL);
runRuntimeMigrations(db);
db.pragma('foreign_keys = ON');
seedAdmin(db);
// Upgrade any legacy 'viewer' accounts to 'trader' so all users have full access
db.prepare("UPDATE users SET role = 'trader' WHERE role = 'viewer'").run();
migrateJson(db);
return db;
}
// ── Migration Helpers ───────────────────────────────────────────────────────
// ── Runtime migrations ───────────────────────────────────────────────────────
/**
* Migrate legacy JSON files to SQLite (one-time, non-fatal).
* Called automatically during database initialization.
* Run ALTER TABLE statements that bring existing DBs up to the current schema.
* Each statement is wrapped in try/catch — SQLite throws if column already exists.
*/
function runRuntimeMigrations(db: Db): void {
for (const sql of RUNTIME_MIGRATIONS) {
try {
db.exec(sql);
} catch {
// Column already exists — safe to ignore
}
}
}
// ── Admin seeding ────────────────────────────────────────────────────────────
/**
* Create the admin account on first boot if ADMIN_EMAIL + ADMIN_PASSWORD are set.
* No-ops if the admin already exists.
*/
function seedAdmin(db: Db): void {
const email = process.env.ADMIN_EMAIL;
const password = process.env.ADMIN_PASSWORD;
if (!email || !password) return;
const existing = db.prepare(USER_QUERIES.SELECT_BY_EMAIL).get(email);
if (existing) {
// Migrate any ownerless holdings from before auth was added to this admin
const adminRow = existing as { id: string };
db.prepare(HOLDINGS_QUERIES.MIGRATE_TO_ADMIN).run(adminRow.id);
return;
}
// Hash password using the same scrypt approach as AuthService
// (inline here to avoid circular imports with the auth domain)
const salt = randomBytes(16).toString('hex');
const hash = scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 }).toString('hex');
const passwordHash = `${salt}:${hash}`;
const id = randomUUID();
const createdAt = new Date().toISOString();
db.prepare(USER_QUERIES.INSERT).run(id, email, passwordHash, 'admin', createdAt);
// Migrate any ownerless holdings to this new admin
db.prepare(HOLDINGS_QUERIES.MIGRATE_TO_ADMIN).run(id);
}
// ── JSON migration helpers ───────────────────────────────────────────────────
function migrateJson(db: Db): void {
migratePortfolio(db);
migrateCalls(db);
}
/**
* Migrate portfolio.json → holdings table.
* If portfolio.json exists, import all holdings and rename to portfolio.json.migrated.
* If import fails, leave portfolio.json in place (non-fatal).
*/
function migratePortfolio(db: Db): void {
const src = './portfolio.json';
if (!existsSync(src)) return;
// Need admin id to assign migrated holdings
const adminEmail = process.env.ADMIN_EMAIL;
if (!adminEmail) return;
const adminRow = db.prepare(USER_QUERIES.SELECT_BY_EMAIL).get(adminEmail) as
| { id: string }
| undefined;
if (!adminRow) return;
try {
const { holdings } = JSON.parse(readFileSync(src, 'utf8')) as {
holdings: LegacyHolding[];
};
const insertAll = db.transaction((rows: LegacyHolding[]) => {
const stmt = db.prepare(`
INSERT OR IGNORE INTO holdings (ticker, shares, cost_basis, type, source, user_id)
VALUES (?, ?, ?, ?, ?, ?)
`);
for (const h of rows) {
const qb = new QueryBuilder('MIGRATION_QUERIES.HOLDINGS_INSERT_OR_IGNORE', [
stmt.run(
h.ticker.toUpperCase(),
h.shares,
h.costBasis ?? 0,
h.type ?? 'stock',
h.source ?? 'Manual',
]);
db.prepare(qb.sql).run(...qb.queryParams);
adminRow.id,
);
}
});
insertAll(holdings);
renameSync(src, `${src}.migrated`);
} catch {
// Non-fatal: leave portfolio.json in place if migration fails
// Non-fatal
}
}
/**
* Migrate market-calls.json → market_calls table.
* If market-calls.json exists, import all calls and rename to market-calls.json.migrated.
* If import fails, leave market-calls.json in place (non-fatal).
*/
function migrateCalls(db: Db): void {
const src = './market-calls.json';
if (!existsSync(src)) return;
try {
const { calls } = JSON.parse(readFileSync(src, 'utf8')) as {
calls: LegacyCall[];
};
const { calls } = JSON.parse(readFileSync(src, 'utf8')) as { calls: LegacyCall[] };
const insertAll = db.transaction((rows: LegacyCall[]) => {
const stmt = db.prepare(`
INSERT OR IGNORE INTO market_calls (id, title, quarter, date, thesis, tickers, snapshot, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const c of rows) {
const qb = new QueryBuilder('MIGRATION_QUERIES.MARKET_CALLS_INSERT_OR_IGNORE', [
stmt.run(
c.id ?? randomUUID(),
c.title,
c.quarter,
@@ -130,14 +183,13 @@ function migrateCalls(db: Db): void {
JSON.stringify(c.tickers ?? []),
JSON.stringify(c.snapshot ?? {}),
c.createdAt,
]);
db.prepare(qb.sql).run(...qb.queryParams);
);
}
});
insertAll(calls);
renameSync(src, `${src}.migrated`);
} catch {
// Non-fatal: leave market-calls.json in place if migration fails
// Non-fatal
}
}
+94 -16
View File
@@ -2,8 +2,7 @@
* SQL Query Constants
*
* All SQL queries used in the application.
* Repositories reference these by name (e.g., MARKET_CALLS_QUERIES.SELECT_ALL).
* QueryBuilder looks them up and binds parameters.
* Repositories reference these by name.
*
* All queries use parameterized statements (?) for security.
* User input NEVER goes into the SQL string.
@@ -12,25 +11,33 @@
// ── Holdings Table Queries ───────────────────────────────────────────────────
export const HOLDINGS_QUERIES = {
// Check if any holdings exist
EXISTS: 'SELECT COUNT(*) AS n FROM holdings',
// Check if any holdings exist for a user
EXISTS: 'SELECT COUNT(*) AS n FROM holdings WHERE user_id = ?',
// Get all holdings, sorted by ticker
SELECT_ALL: 'SELECT ticker, shares, cost_basis, type, source FROM holdings ORDER BY ticker ASC',
// 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 (UPSERT)
// Insert or update a holding scoped to a user
UPSERT: `
INSERT INTO holdings (ticker, shares, cost_basis, type, source)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(ticker) DO UPDATE SET
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
DELETE_BY_TICKER: 'DELETE FROM holdings WHERE ticker = ?',
// 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 ───────────────────────────────────────────────
@@ -65,8 +72,8 @@ export const MARKET_CALLS_QUERIES = {
export const MIGRATION_QUERIES = {
// Insert holdings during migration
HOLDINGS_INSERT_OR_IGNORE: `
INSERT OR IGNORE INTO holdings (ticker, shares, cost_basis, type, source)
VALUES (?, ?, ?, ?, ?)
INSERT OR IGNORE INTO holdings (ticker, shares, cost_basis, type, source, user_id)
VALUES (?, ?, ?, ?, ?, ?)
`,
// Insert market calls during migration
@@ -76,15 +83,78 @@ export const MIGRATION_QUERIES = {
`,
};
// ── 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) ──────────────────────────────────────────────────
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 PRIMARY KEY,
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'
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 (
@@ -98,3 +168,11 @@ export const DDL = `
created_at TEXT NOT NULL
);
`;
// ── 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)`,
];