CREATE TABLE IF NOT EXISTS admins ( id TEXT PRIMARY KEY, username TEXT NOT NULL, username_norm TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, password_hash TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','disabled')), must_change_password INTEGER NOT NULL DEFAULT 1 CHECK(must_change_password IN (0,1)), auth_version INTEGER NOT NULL DEFAULT 1 CHECK(auth_version >= 1), version INTEGER NOT NULL DEFAULT 1 CHECK(version >= 1), created_at INTEGER NOT NULL, created_by TEXT REFERENCES admins(id) ON DELETE RESTRICT, password_changed_at INTEGER, last_login_at INTEGER, disabled_at INTEGER, disabled_by TEXT REFERENCES admins(id) ON DELETE RESTRICT ) STRICT; CREATE UNIQUE INDEX IF NOT EXISTS admins_username_norm_uq ON admins(username_norm); CREATE TABLE IF NOT EXISTS sessions ( token_hash TEXT PRIMARY KEY, admin_id TEXT NOT NULL REFERENCES admins(id) ON DELETE CASCADE, csrf_hash TEXT NOT NULL, auth_version INTEGER NOT NULL, created_at INTEGER NOT NULL, last_seen_at INTEGER NOT NULL, idle_expires_at INTEGER NOT NULL, absolute_expires_at INTEGER NOT NULL ) STRICT; CREATE INDEX IF NOT EXISTS sessions_admin_idx ON sessions(admin_id); CREATE INDEX IF NOT EXISTS sessions_expiry_idx ON sessions(idle_expires_at); CREATE TABLE IF NOT EXISTS expenses ( id TEXT PRIMARY KEY, paid_at INTEGER NOT NULL, amount_cents INTEGER NOT NULL CHECK(amount_cents > 0 AND amount_cents <= 999999999999), note TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'unreimbursed' CHECK(status IN ('unreimbursed','reimbursed')), version INTEGER NOT NULL DEFAULT 1 CHECK(version >= 1), created_at INTEGER NOT NULL, created_by TEXT NOT NULL REFERENCES admins(id) ON DELETE RESTRICT, updated_at INTEGER NOT NULL, updated_by TEXT NOT NULL REFERENCES admins(id) ON DELETE RESTRICT, reimbursed_at INTEGER, reimbursed_by TEXT REFERENCES admins(id) ON DELETE RESTRICT, deleted_at INTEGER, deleted_by TEXT REFERENCES admins(id) ON DELETE RESTRICT ) STRICT; CREATE INDEX IF NOT EXISTS expenses_list_idx ON expenses(deleted_at, status, paid_at DESC); CREATE TABLE IF NOT EXISTS attachments ( id TEXT PRIMARY KEY, expense_id TEXT NOT NULL REFERENCES expenses(id) ON DELETE CASCADE, kind TEXT NOT NULL CHECK(kind IN ('payment_proof','invoice')), storage_path TEXT NOT NULL UNIQUE, original_name TEXT NOT NULL, mime_type TEXT NOT NULL, size_bytes INTEGER NOT NULL CHECK(size_bytes > 0), sha256 TEXT NOT NULL, created_at INTEGER NOT NULL, created_by TEXT NOT NULL REFERENCES admins(id) ON DELETE RESTRICT ) STRICT; CREATE INDEX IF NOT EXISTS attachments_expense_idx ON attachments(expense_id); CREATE TABLE IF NOT EXISTS audit_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, occurred_at INTEGER NOT NULL, request_id TEXT NOT NULL, actor_admin_id TEXT, actor_username TEXT, action TEXT NOT NULL, target_type TEXT NOT NULL, target_id TEXT, outcome TEXT NOT NULL CHECK(outcome IN ('success','denied','failure')), before_json TEXT CHECK(before_json IS NULL OR json_valid(before_json)), after_json TEXT CHECK(after_json IS NULL OR json_valid(after_json)), metadata_json TEXT CHECK(metadata_json IS NULL OR json_valid(metadata_json)) ) STRICT; CREATE INDEX IF NOT EXISTS audit_time_idx ON audit_events(occurred_at DESC); CREATE INDEX IF NOT EXISTS audit_target_idx ON audit_events(target_type, target_id, occurred_at DESC); CREATE TRIGGER IF NOT EXISTS audit_events_no_update BEFORE UPDATE ON audit_events BEGIN SELECT RAISE(ABORT, 'audit_events are append-only'); END; CREATE TRIGGER IF NOT EXISTS audit_events_no_delete BEFORE DELETE ON audit_events BEGIN SELECT RAISE(ABORT, 'audit_events are append-only'); END; CREATE TABLE IF NOT EXISTS system_settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at INTEGER NOT NULL ) STRICT; CREATE TABLE IF NOT EXISTS export_jobs ( id TEXT PRIMARY KEY, admin_id TEXT NOT NULL REFERENCES admins(id) ON DELETE CASCADE, session_hash TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('queued','building','ready','failed','expired')), selection_json TEXT NOT NULL CHECK(json_valid(selection_json)), snapshot_json TEXT NOT NULL CHECK(json_valid(snapshot_json)), file_path TEXT, file_name TEXT NOT NULL, size_bytes INTEGER, sha256 TEXT, error_message TEXT, created_at INTEGER NOT NULL, ready_at INTEGER, expires_at INTEGER NOT NULL ) STRICT; CREATE INDEX IF NOT EXISTS exports_expiry_idx ON export_jobs(expires_at); CREATE INDEX IF NOT EXISTS exports_session_idx ON export_jobs(session_hash); CREATE TABLE IF NOT EXISTS login_attempts ( key_hash TEXT PRIMARY KEY, window_start INTEGER NOT NULL, failures INTEGER NOT NULL, blocked_until INTEGER ) STRICT; CREATE TABLE IF NOT EXISTS file_deletions ( id TEXT PRIMARY KEY, storage_path TEXT NOT NULL, reason TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','complete','failed')), attempts INTEGER NOT NULL DEFAULT 0, last_error TEXT, created_at INTEGER NOT NULL, completed_at INTEGER ) STRICT; CREATE INDEX IF NOT EXISTS file_deletions_status_idx ON file_deletions(status);