Public Access
181 lines
6.6 KiB
TypeScript
181 lines
6.6 KiB
TypeScript
import type Database from "better-sqlite3";
|
|
|
|
export function runMigrations(db: Database.Database): void {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
email TEXT UNIQUE NOT NULL,
|
|
password_hash TEXT NOT NULL,
|
|
display_name TEXT NOT NULL,
|
|
role TEXT NOT NULL DEFAULT 'user',
|
|
email_verified INTEGER DEFAULT 0,
|
|
verify_token TEXT,
|
|
verify_expires INTEGER,
|
|
reset_token TEXT,
|
|
reset_expires INTEGER,
|
|
created_at INTEGER DEFAULT (unixepoch()),
|
|
updated_at INTEGER DEFAULT (unixepoch())
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS saved_exams (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
title TEXT NOT NULL,
|
|
subtitle TEXT,
|
|
exam_json TEXT NOT NULL,
|
|
created_at INTEGER DEFAULT (unixepoch()),
|
|
updated_at INTEGER DEFAULT (unixepoch())
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS exam_results (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
exam_id INTEGER REFERENCES saved_exams(id) ON DELETE SET NULL,
|
|
title TEXT NOT NULL,
|
|
score_points REAL NOT NULL,
|
|
total_points REAL NOT NULL,
|
|
correct_count INTEGER NOT NULL,
|
|
graded_count INTEGER NOT NULL,
|
|
passing_score REAL DEFAULT 60,
|
|
elapsed_ms INTEGER NOT NULL,
|
|
state_json TEXT NOT NULL,
|
|
exam_json TEXT NOT NULL,
|
|
completed_at INTEGER DEFAULT (unixepoch())
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS site_settings (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL,
|
|
updated_at INTEGER DEFAULT (unixepoch())
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS ai_usage_daily (
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
period_date TEXT NOT NULL,
|
|
tokens_used INTEGER NOT NULL DEFAULT 0,
|
|
requests_used INTEGER NOT NULL DEFAULT 0,
|
|
updated_at INTEGER DEFAULT (unixepoch()),
|
|
PRIMARY KEY (user_id, period_date)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_saved_exams_user ON saved_exams(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_exam_results_user ON exam_results(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_ai_usage_daily_user ON ai_usage_daily(user_id);
|
|
`);
|
|
|
|
// ── Add share_token and is_public columns if missing ──
|
|
const examCols = db.prepare(`PRAGMA table_info(saved_exams)`).all() as { name: string }[];
|
|
if (!examCols.some((c) => c.name === 'share_token')) {
|
|
db.exec(`ALTER TABLE saved_exams ADD COLUMN share_token TEXT`);
|
|
}
|
|
if (!examCols.some((c) => c.name === 'is_public')) {
|
|
db.exec(`ALTER TABLE saved_exams ADD COLUMN is_public INTEGER DEFAULT 0`);
|
|
}
|
|
|
|
db.exec(`
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_saved_exams_share ON saved_exams(share_token);
|
|
`);
|
|
|
|
// ── Add role column if missing (existing databases) ──
|
|
const cols = db.prepare(`PRAGMA table_info(users)`).all() as {
|
|
name: string;
|
|
}[];
|
|
if (!cols.some((c) => c.name === "role")) {
|
|
db.exec(`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'`);
|
|
}
|
|
|
|
// ── Add storage_quota_bytes if missing ──
|
|
if (!cols.some((c) => c.name === "storage_quota_bytes")) {
|
|
db.exec(`ALTER TABLE users ADD COLUMN storage_quota_bytes INTEGER NOT NULL DEFAULT 1073741824`);
|
|
}
|
|
|
|
// ── Add plan if missing ──
|
|
if (!cols.some((c) => c.name === "plan")) {
|
|
db.exec(`ALTER TABLE users ADD COLUMN plan TEXT NOT NULL DEFAULT 'free'`);
|
|
}
|
|
|
|
// ── Add is_favorite to saved_exams if missing ──
|
|
const examSavedCols = db.prepare(`PRAGMA table_info(saved_exams)`).all() as { name: string }[];
|
|
if (!examSavedCols.some((c) => c.name === 'is_favorite')) {
|
|
db.exec(`ALTER TABLE saved_exams ADD COLUMN is_favorite INTEGER DEFAULT 0`);
|
|
}
|
|
|
|
// ── Add tags to saved_exams if missing ──
|
|
if (!examSavedCols.some((c) => c.name === 'tags')) {
|
|
db.exec(`ALTER TABLE saved_exams ADD COLUMN tags TEXT DEFAULT '[]'`);
|
|
}
|
|
|
|
// ── Add collection_id to saved_exams if missing ──
|
|
if (!examSavedCols.some((c) => c.name === 'collection_id')) {
|
|
db.exec(`ALTER TABLE saved_exams ADD COLUMN collection_id INTEGER REFERENCES collections(id) ON DELETE SET NULL`);
|
|
}
|
|
|
|
// ── Add share_mode to saved_exams if missing ──
|
|
if (!examSavedCols.some((c) => c.name === 'share_mode')) {
|
|
db.exec(`ALTER TABLE saved_exams ADD COLUMN share_mode TEXT DEFAULT 'clone'`);
|
|
}
|
|
|
|
// ── Add cloned_from_share_token to saved_exams if missing ──
|
|
if (!examSavedCols.some((c) => c.name === 'cloned_from_share_token')) {
|
|
db.exec(`ALTER TABLE saved_exams ADD COLUMN cloned_from_share_token TEXT`);
|
|
}
|
|
|
|
// ── Collections table ──
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS collections (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
name TEXT NOT NULL,
|
|
created_at INTEGER DEFAULT (unixepoch()),
|
|
updated_at INTEGER DEFAULT (unixepoch())
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_collections_user ON collections(user_id);
|
|
`);
|
|
|
|
// ── Promote first user to admin if no admins exist ──
|
|
const adminCount = db
|
|
.prepare(`SELECT COUNT(*) AS cnt FROM users WHERE role = 'admin'`)
|
|
.get() as { cnt: number };
|
|
if (adminCount.cnt === 0) {
|
|
db.prepare(
|
|
`UPDATE users SET role = 'admin' WHERE id = (SELECT MIN(id) FROM users)`,
|
|
).run();
|
|
}
|
|
|
|
// ── Seed default settings ──
|
|
const defaults: Record<string, string> = {
|
|
site_name: "BrainWave",
|
|
max_upload_mb: "5",
|
|
allow_guest_mode: "true",
|
|
registration_enabled: "true",
|
|
default_theme: "light",
|
|
maintenance_mode: "false",
|
|
maintenance_message:
|
|
"We are currently performing maintenance. Please check back soon.",
|
|
smtp_host: "",
|
|
smtp_port: "587",
|
|
smtp_user: "",
|
|
smtp_pass: "",
|
|
smtp_from: "BrainWave <noreply@brainwave.local>",
|
|
smtp_secure: "auto",
|
|
app_url: "http://localhost:5173",
|
|
ai_provider: "",
|
|
ai_model: "gpt-4o-mini",
|
|
ai_api_key: "",
|
|
ai_base_url: "",
|
|
ai_model_hint: "",
|
|
ai_model_generate: "",
|
|
ai_model_grading: "",
|
|
ai_model_tutor: "",
|
|
ai_model_study_coach: "",
|
|
ai_daily_token_limit: "0",
|
|
};
|
|
const upsert = db.prepare(`
|
|
INSERT INTO site_settings (key, value) VALUES (?, ?)
|
|
ON CONFLICT(key) DO NOTHING
|
|
`);
|
|
for (const [key, value] of Object.entries(defaults)) {
|
|
upsert.run(key, value);
|
|
}
|
|
}
|