Newer
Older
veriq / backend / db.py
from typing import Optional
"""VerIQ — SQLite storage (WAL mode)."""
import json
import os
import secrets
import sqlite3
import time
from pathlib import Path

DB_PATH = Path(os.environ.get("VERIQ_DB_PATH", "/var/lib/veriq/veriq.db"))

_SCHEMA = """
CREATE TABLE IF NOT EXISTS results (
    result_id   TEXT PRIMARY KEY,
    score       INTEGER NOT NULL,
    percentile  REAL NOT NULL,
    bank_version TEXT NOT NULL,
    identity    TEXT,
    completed_at TEXT NOT NULL,
    payload     TEXT NOT NULL,
    signature   TEXT NOT NULL,
    answers     TEXT NOT NULL,
    flagged     INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS events (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    ts REAL NOT NULL,
    kind TEXT NOT NULL,
    meta TEXT
);
CREATE INDEX IF NOT EXISTS idx_events_kind ON events(kind);
CREATE TABLE IF NOT EXISTS accounts (
    account_id  TEXT PRIMARY KEY,
    created_at  TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS identities (
    provider         TEXT NOT NULL,
    provider_user_id TEXT NOT NULL,
    username         TEXT NOT NULL,
    account_id       TEXT NOT NULL REFERENCES accounts(account_id),
    PRIMARY KEY (provider, provider_user_id)
);
"""


def _conn() -> sqlite3.Connection:
    DB_PATH.parent.mkdir(parents=True, exist_ok=True)
    conn = sqlite3.connect(DB_PATH, timeout=10)
    conn.execute("PRAGMA journal_mode=WAL")
    conn.execute("PRAGMA foreign_keys=ON")
    conn.row_factory = sqlite3.Row
    return conn


def init_db() -> None:
    with _conn() as c:
        c.executescript(_SCHEMA)
        # migration: score_json snapshot column (added 2026-10-13, iter 4)
        try:
            c.execute("ALTER TABLE results ADD COLUMN score_json TEXT")
        except sqlite3.OperationalError:
            pass  # column already exists
        # migration: superseded flag (auth-before-test flow, 2026-10-13)
        try:
            c.execute("ALTER TABLE results ADD COLUMN superseded INTEGER DEFAULT 0")
        except sqlite3.OperationalError:
            pass  # column already exists
        # migration: account_id column (multi-identity accounts, 2026-10-13)
        try:
            c.execute("ALTER TABLE results ADD COLUMN account_id TEXT")
        except sqlite3.OperationalError:
            pass  # column already exists
        c.execute("CREATE INDEX IF NOT EXISTS idx_results_account"
                  " ON results(account_id)")
        migrate_results_to_accounts(conn=c)


def new_result_id() -> str:
    """8-char base62 URL-safe id with collision check."""
    alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
    with _conn() as c:
        while True:
            rid = "".join(secrets.choice(alphabet) for _ in range(8))
            row = c.execute("SELECT 1 FROM results WHERE result_id=?", (rid,)).fetchone()
            if row is None:
                return rid


def save_result(result_id: str, score: int, percentile: float,
                bank_version: str, identity: Optional[str], completed_at: str,
                payload: str, signature: str, answers: dict,
                flagged: bool = False, score_json: Optional[str] = None,
                account_id: Optional[str] = None) -> None:
    with _conn() as c:
        c.execute(
            "INSERT INTO results (result_id, score, percentile, bank_version,"
            " identity, completed_at, payload, signature, answers, flagged,"
            " score_json, account_id)"
            " VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
            (result_id, score, percentile, bank_version, identity,
             completed_at, payload, signature, json.dumps(answers),
             int(flagged), score_json, account_id),
        )


def get_result(result_id: str) -> Optional[dict]:
    with _conn() as c:
        row = c.execute("SELECT * FROM results WHERE result_id=?", (result_id,)).fetchone()
        if row is None:
            return None
        return dict(row)


def supersede_identity(identity: str) -> int:
    """Mark all previous active results of this identity as superseded."""
    with _conn() as c:
        cur = c.execute(
            "UPDATE results SET superseded=1 WHERE identity=? AND superseded=0",
            (identity,))
        return cur.rowcount


def log_event(kind: str, meta: Optional[dict] = None) -> None:
    with _conn() as c:
        c.execute("INSERT INTO events (ts, kind, meta) VALUES (?,?,?)",
                  (time.time(), kind, json.dumps(meta or {})))


def event_stats() -> dict:
    with _conn() as c:
        rows = c.execute(
            "SELECT kind, COUNT(*) AS n FROM events GROUP BY kind").fetchall()
        return {r["kind"]: r["n"] for r in rows}


def stats_overview() -> dict:
    """Aggregate numbers for the /stats dashboard."""
    with _conn() as c:
        events = {r["kind"]: r["n"] for r in c.execute(
            "SELECT kind, COUNT(*) AS n FROM events GROUP BY kind").fetchall()}
        total, flagged = c.execute(
            "SELECT COUNT(*), COALESCE(SUM(flagged), 0) FROM results").fetchone()
        avg_iq = c.execute("SELECT AVG(score) FROM results").fetchone()[0]
        return {
            "events": events,
            "results_total": total,
            "results_flagged": flagged,
            "avg_iq": round(avg_iq, 1) if avg_iq is not None else None,
        }

def claim_result(result_id: str, identity: str) -> bool:
    """Bind identity to an existing result. False if result_id unknown."""
    with _conn() as c:
        cur = c.execute("UPDATE results SET identity=? WHERE result_id=?",
                        (identity, result_id))
        return cur.rowcount > 0

# --- accounts & identities (multi-identity accounts, 2026-10-13) ---------------

def new_account_id() -> str:
    return "a" + secrets.token_hex(8)


def create_account(conn: Optional[sqlite3.Connection] = None) -> str:
    """Create an account row, return its id."""
    aid = new_account_id()
    sql = "INSERT INTO accounts (account_id, created_at) VALUES (?,?)"
    args = (aid, time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()))
    if conn is not None:
        conn.execute(sql, args)
        return aid
    with _conn() as c:
        c.execute(sql, args)
    return aid


def get_identity(provider: str, provider_user_id: str) -> Optional[dict]:
    with _conn() as c:
        row = c.execute(
            "SELECT * FROM identities WHERE provider=? AND provider_user_id=?",
            (provider, provider_user_id)).fetchone()
        return dict(row) if row else None


def create_identity(provider: str, provider_user_id: str, username: str,
                    account_id: str,
                    conn: Optional[sqlite3.Connection] = None) -> None:
    sql = ("INSERT INTO identities (provider, provider_user_id, username,"
           " account_id) VALUES (?,?,?,?)")
    args = (provider, provider_user_id, username, account_id)
    if conn is not None:
        conn.execute(sql, args)
        return
    with _conn() as c:
        c.execute(sql, args)


def update_identity_username(provider: str, provider_user_id: str,
                             username: str) -> None:
    with _conn() as c:
        c.execute("UPDATE identities SET username=? WHERE provider=?"
                  " AND provider_user_id=?",
                  (username, provider, provider_user_id))


def account_identities(account_id: str) -> list:
    with _conn() as c:
        rows = c.execute(
            "SELECT provider, username FROM identities WHERE account_id=?"
            " ORDER BY provider", (account_id,)).fetchall()
        return [dict(r) for r in rows]


def _migrate_results_to_accounts(c: sqlite3.Connection) -> int:
    rows = c.execute(
        "SELECT DISTINCT identity FROM results"
        " WHERE identity IS NOT NULL AND identity != ''"
        " AND account_id IS NULL").fetchall()
    n = 0
    for r in rows:
        identity = r["identity"]
        if ":" in identity:
            provider, uname = identity.split(":", 1)
            uname = uname.lstrip("@")
        else:
            provider, uname = "x", identity
        pid = identity  # stable unique key (real provider_user_id unknown)
        row = c.execute(
            "SELECT account_id FROM identities"
            " WHERE provider=? AND provider_user_id=?",
            (provider, pid)).fetchone()
        if row:
            account_id = row["account_id"]
        else:
            account_id = new_account_id()
            c.execute("INSERT INTO accounts (account_id, created_at)"
                      " VALUES (?,?)",
                      (account_id,
                       time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())))
            c.execute("INSERT INTO identities (provider, provider_user_id,"
                      " username, account_id) VALUES (?,?,?,?)",
                      (provider, pid, uname, account_id))
        c.execute("UPDATE results SET account_id=? WHERE identity=?"
                  " AND account_id IS NULL", (account_id, identity))
        n += 1
    return n


def migrate_results_to_accounts(conn: Optional[sqlite3.Connection] = None) -> int:
    """One-time migration: give every pre-accounts result an account.

    Old rows only carry identity like "x:@user"; provider_user_id is unknown,
    so the full identity string is used as a stable unique key.
    """
    if conn is not None:
        return _migrate_results_to_accounts(conn)
    with _conn() as c:
        n = _migrate_results_to_accounts(c)
        c.commit()
        return n


def supersede_account(account_id: str) -> int:
    """Mark all previous active results of this account as superseded."""
    with _conn() as c:
        cur = c.execute(
            "UPDATE results SET superseded=1 WHERE account_id=?"
            " AND superseded=0", (account_id,))
        return cur.rowcount


def active_result_for_account(account_id: str) -> Optional[dict]:
    with _conn() as c:
        row = c.execute(
            "SELECT * FROM results WHERE account_id=? AND superseded=0"
            " ORDER BY completed_at DESC LIMIT 1", (account_id,)).fetchone()
        return dict(row) if row else None