Files
stoat-admin/api/src/db/sqlite.ts
T
2026-03-31 21:28:12 -05:00

115 lines
3.8 KiB
TypeScript

import { existsSync, mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import Database from "better-sqlite3";
import type { AdminUserRecord, InviteRecord } from "./types.js";
// Keep local development aligned with the compose-mounted ./data directory.
const workspaceSqlitePath = resolve(
dirname(fileURLToPath(import.meta.url)),
"..",
"..",
"..",
"data",
"admin.db"
);
function resolveSqlitePath(): string {
if (existsSync("/data")) {
return "/data/admin.db";
}
return workspaceSqlitePath;
}
export const sqlitePath = resolveSqlitePath();
mkdirSync(dirname(sqlitePath), { recursive: true });
export const sqlite = new Database(sqlitePath);
sqlite.pragma("journal_mode = WAL");
sqlite.exec(`
CREATE TABLE IF NOT EXISTS admin_user (
id INTEGER PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS invite_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
email TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT,
accepted_at TEXT,
resend_message_id TEXT
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action TEXT NOT NULL,
target TEXT NOT NULL,
details TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
export const statements = {
getAdminUserByUsername: sqlite.prepare<[string], AdminUserRecord>(
"SELECT id, username, password_hash FROM admin_user WHERE username = ?"
),
getFirstAdminUser: sqlite.prepare<[], AdminUserRecord>(
"SELECT id, username, password_hash FROM admin_user ORDER BY id ASC LIMIT 1"
),
insertAdminUser: sqlite.prepare<[string, string]>(
"INSERT INTO admin_user (id, username, password_hash) VALUES (1, ?, ?)"
),
updateAdminPasswordByUsername: sqlite.prepare<[string, string]>(
"UPDATE admin_user SET password_hash = ? WHERE username = ?"
),
listInviteRecords: sqlite.prepare<[], InviteRecord>(
`SELECT id, code, email, status, created_at, expires_at, accepted_at, resend_message_id
FROM invite_records
ORDER BY created_at DESC`
),
countInviteRecords: sqlite.prepare<[], { count: number }>(
"SELECT COUNT(*) AS count FROM invite_records"
),
countPendingInvites: sqlite.prepare<[], { count: number }>(
"SELECT COUNT(*) AS count FROM invite_records WHERE status = 'pending'"
),
insertInviteRecord: sqlite.prepare<[string, string, string | null]>(
"INSERT INTO invite_records (code, email, status, expires_at) VALUES (?, ?, 'pending', ?)"
),
getInviteRecordByCode: sqlite.prepare<[string], InviteRecord>(
`SELECT id, code, email, status, created_at, expires_at, accepted_at, resend_message_id
FROM invite_records
WHERE code = ?`
),
updateInviteResendMessage: sqlite.prepare<[string | null, string]>(
"UPDATE invite_records SET resend_message_id = ? WHERE code = ?"
),
markInviteRevoked: sqlite.prepare<[string]>(
"UPDATE invite_records SET status = 'revoked' WHERE code = ?"
),
selectPendingInvites: sqlite.prepare<[], InviteRecord>(
`SELECT id, code, email, status, created_at, expires_at, accepted_at, resend_message_id
FROM invite_records
WHERE status = 'pending'
ORDER BY created_at DESC`
),
markInviteAccepted: sqlite.prepare<[string]>(
"UPDATE invite_records SET status = 'accepted', accepted_at = datetime('now') WHERE code = ?"
),
markInviteExpired: sqlite.prepare<[string]>(
"UPDATE invite_records SET status = 'expired' WHERE code = ?"
),
countRecentBans: sqlite.prepare<[], { count: number }>(
"SELECT COUNT(*) AS count FROM audit_log WHERE action = 'user_banned' AND created_at > datetime('now', '-30 days')"
)
};