import { DatabaseSync } from "node:sqlite"; import { randomUUID } from "node:crypto"; import { hashSync } from "bcryptjs"; import { getDatabasePath, getUploadDir } from "./env"; declare global { var __mopedDatabase: DatabaseSync | undefined; var __mopedDatabaseInitialized: boolean | undefined; } export const db = globalThis.__mopedDatabase ?? new DatabaseSync(getDatabasePath(), { enableForeignKeyConstraints: true, }); globalThis.__mopedDatabase = db; function ensureSchema() { db.exec(` PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000; CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, email TEXT NOT NULL COLLATE NOCASE UNIQUE, password_hash TEXT NOT NULL, role TEXT NOT NULL CHECK (role IN ('admin', 'member')), active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)), must_change_password INTEGER NOT NULL DEFAULT 0 CHECK (must_change_password IN (0, 1)), deleted_at TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) STRICT; CREATE TABLE IF NOT EXISTS user_sessions ( id TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_agent TEXT, ip_address TEXT, last_path TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, expires_at TEXT NOT NULL, revoked_at TEXT ) STRICT; CREATE TABLE IF NOT EXISTS motorcycles ( id INTEGER PRIMARY KEY AUTOINCREMENT, nickname TEXT NOT NULL, brand TEXT NOT NULL, model TEXT NOT NULL, year INTEGER, vin TEXT, plate TEXT, current_km INTEGER NOT NULL DEFAULT 0 CHECK (current_km >= 0), tire_pressure_front_bar REAL CHECK (tire_pressure_front_bar IS NULL OR tire_pressure_front_bar > 0), tire_pressure_rear_bar REAL CHECK (tire_pressure_rear_bar IS NULL OR tire_pressure_rear_bar > 0), chain_tension_mm INTEGER CHECK (chain_tension_mm IS NULL OR chain_tension_mm >= 0), chain_tension_min_mm INTEGER CHECK (chain_tension_min_mm IS NULL OR chain_tension_min_mm >= 0), chain_tension_max_mm INTEGER CHECK (chain_tension_max_mm IS NULL OR chain_tension_max_mm >= 0), oil_type TEXT, oil_capacity_liters REAL CHECK (oil_capacity_liters IS NULL OR oil_capacity_liters > 0), oil_check_temp TEXT CHECK (oil_check_temp IS NULL OR oil_check_temp IN ('warm', 'cold')), oil_check_run_minutes INTEGER CHECK (oil_check_run_minutes IS NULL OR oil_check_run_minutes >= 0), oil_check_wait_min_minutes INTEGER CHECK (oil_check_wait_min_minutes IS NULL OR oil_check_wait_min_minutes >= 0), oil_check_wait_max_minutes INTEGER CHECK (oil_check_wait_max_minutes IS NULL OR oil_check_wait_max_minutes >= 0), oil_check_wait_minutes INTEGER CHECK (oil_check_wait_minutes IS NULL OR oil_check_wait_minutes >= 0), oil_check_method TEXT CHECK (oil_check_method IS NULL OR oil_check_method IN ('dipstick', 'sight_glass')), notes TEXT, created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE RESTRICT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) STRICT; CREATE TABLE IF NOT EXISTS motorcycle_shares ( motorcycle_id INTEGER NOT NULL REFERENCES motorcycles(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, shared_by INTEGER NOT NULL REFERENCES users(id) ON DELETE RESTRICT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (motorcycle_id, user_id) ) STRICT; CREATE TABLE IF NOT EXISTS maintenance_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, motorcycle_id INTEGER NOT NULL REFERENCES motorcycles(id) ON DELETE CASCADE, type TEXT NOT NULL CHECK (type IN ('reparatur', 'tuev_hu', 'inspektion', 'reifenwechsel', 'oelwechsel', 'sonstiges')), title TEXT NOT NULL, description TEXT, event_date TEXT NOT NULL, km INTEGER CHECK (km IS NULL OR km >= 0), cost_cents INTEGER CHECK (cost_cents IS NULL OR cost_cents >= 0), due_date TEXT, due_km INTEGER CHECK (due_km IS NULL OR due_km >= 0), created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE RESTRICT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) STRICT; CREATE TABLE IF NOT EXISTS maintenance_event_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, maintenance_event_id INTEGER NOT NULL REFERENCES maintenance_events(id) ON DELETE CASCADE, type TEXT CHECK ( type IS NULL OR type IN ('reparatur', 'tuev_hu', 'inspektion', 'reifenwechsel', 'oelwechsel', 'sonstiges') ), custom_type TEXT, custom_type_key TEXT, cost_cents INTEGER CHECK (cost_cents IS NULL OR cost_cents >= 0), due_date TEXT, due_km INTEGER CHECK (due_km IS NULL OR due_km >= 0), position INTEGER NOT NULL CHECK (position >= 0), CHECK ( (type IS NOT NULL AND custom_type IS NULL AND custom_type_key IS NULL) OR ( type IS NULL AND custom_type IS NOT NULL AND custom_type_key IS NOT NULL AND length(trim(custom_type)) > 0 AND length(custom_type_key) > 0 ) ), UNIQUE (maintenance_event_id, position) ) STRICT; CREATE TABLE IF NOT EXISTS trips ( id INTEGER PRIMARY KEY AUTOINCREMENT, motorcycle_id INTEGER NOT NULL REFERENCES motorcycles(id) ON DELETE CASCADE, trip_date TEXT NOT NULL, driver_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE RESTRICT, start_km INTEGER NOT NULL CHECK (start_km >= 0), end_km INTEGER NOT NULL CHECK (end_km >= start_km), purpose TEXT, notes TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) STRICT; CREATE TABLE IF NOT EXISTS attachments ( id INTEGER PRIMARY KEY AUTOINCREMENT, maintenance_event_id INTEGER NOT NULL UNIQUE REFERENCES maintenance_events(id) ON DELETE CASCADE, stored_name TEXT NOT NULL UNIQUE, original_name TEXT NOT NULL, mime_type TEXT NOT NULL, size_bytes INTEGER NOT NULL CHECK (size_bytes > 0), created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) STRICT; CREATE TABLE IF NOT EXISTS webauthn_credentials ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, credential_id TEXT NOT NULL UNIQUE, public_key TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0 CHECK (counter >= 0), transports TEXT, label TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, last_used_at TEXT ) STRICT; CREATE TABLE IF NOT EXISTS webauthn_challenges ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, challenge TEXT NOT NULL, purpose TEXT NOT NULL CHECK (purpose IN ('registration', 'authentication')), expires_at TEXT NOT NULL, used_at TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) STRICT; CREATE INDEX IF NOT EXISTS idx_maintenance_motorcycle ON maintenance_events(motorcycle_id, event_date DESC, id DESC); CREATE INDEX IF NOT EXISTS idx_maintenance_due ON maintenance_events(motorcycle_id, type, event_date DESC); CREATE INDEX IF NOT EXISTS idx_maintenance_items_event ON maintenance_event_items(maintenance_event_id, position); CREATE INDEX IF NOT EXISTS idx_maintenance_items_due ON maintenance_event_items(type, due_date, due_km); CREATE INDEX IF NOT EXISTS idx_trips_motorcycle ON trips(motorcycle_id, trip_date DESC, id DESC); CREATE INDEX IF NOT EXISTS idx_motorcycle_shares_user ON motorcycle_shares(user_id, motorcycle_id); CREATE INDEX IF NOT EXISTS idx_user_sessions_active ON user_sessions(user_id, revoked_at, expires_at, last_seen_at DESC); CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id); CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_lookup ON webauthn_challenges(user_id, purpose, expires_at, used_at); `); const maintenanceItemColumns = db .prepare("PRAGMA table_info(maintenance_event_items)") .all() as { name: string }[]; if (!maintenanceItemColumns.some((column) => column.name === "custom_type_key")) { db.exec("ALTER TABLE maintenance_event_items ADD COLUMN custom_type_key TEXT"); } db.exec(` INSERT INTO maintenance_event_items (maintenance_event_id, type, custom_type, custom_type_key, cost_cents, due_date, due_km, position) SELECT e.id, e.type, NULL, NULL, e.cost_cents, e.due_date, e.due_km, 0 FROM maintenance_events e WHERE NOT EXISTS ( SELECT 1 FROM maintenance_event_items item WHERE item.maintenance_event_id = e.id ) `); const customMaintenanceItems = db .prepare( `SELECT id, custom_type FROM maintenance_event_items WHERE type IS NULL AND custom_type IS NOT NULL AND (custom_type_key IS NULL OR custom_type_key = '')`, ) .all() as { id: number; custom_type: string }[]; const updateCustomMaintenanceKey = db.prepare( "UPDATE maintenance_event_items SET custom_type_key = ? WHERE id = ?", ); for (const item of customMaintenanceItems) { updateCustomMaintenanceKey.run(item.custom_type.toLocaleLowerCase("de-DE"), item.id); } db.exec( "CREATE INDEX IF NOT EXISTS idx_maintenance_items_custom_due ON maintenance_event_items(custom_type_key, due_date, due_km)", ); const userColumns = db.prepare("PRAGMA table_info(users)").all() as { name: string }[]; if (!userColumns.some((column) => column.name === "active")) { db.exec("ALTER TABLE users ADD COLUMN active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1))"); } if (!userColumns.some((column) => column.name === "deleted_at")) { db.exec("ALTER TABLE users ADD COLUMN deleted_at TEXT"); } if (!userColumns.some((column) => column.name === "must_change_password")) { db.exec( "ALTER TABLE users ADD COLUMN must_change_password INTEGER NOT NULL DEFAULT 0 CHECK (must_change_password IN (0, 1))", ); } const motorcycleColumns = db.prepare("PRAGMA table_info(motorcycles)").all() as { name: string }[]; if (!motorcycleColumns.some((column) => column.name === "tire_pressure_front_bar")) { db.exec("ALTER TABLE motorcycles ADD COLUMN tire_pressure_front_bar REAL"); } if (!motorcycleColumns.some((column) => column.name === "tire_pressure_rear_bar")) { db.exec("ALTER TABLE motorcycles ADD COLUMN tire_pressure_rear_bar REAL"); } if (!motorcycleColumns.some((column) => column.name === "chain_tension_mm")) { db.exec("ALTER TABLE motorcycles ADD COLUMN chain_tension_mm INTEGER"); } if (!motorcycleColumns.some((column) => column.name === "chain_tension_min_mm")) { db.exec("ALTER TABLE motorcycles ADD COLUMN chain_tension_min_mm INTEGER"); } if (!motorcycleColumns.some((column) => column.name === "chain_tension_max_mm")) { db.exec("ALTER TABLE motorcycles ADD COLUMN chain_tension_max_mm INTEGER"); } if (!motorcycleColumns.some((column) => column.name === "oil_type")) { db.exec("ALTER TABLE motorcycles ADD COLUMN oil_type TEXT"); } if (!motorcycleColumns.some((column) => column.name === "oil_capacity_liters")) { db.exec("ALTER TABLE motorcycles ADD COLUMN oil_capacity_liters REAL"); } if (!motorcycleColumns.some((column) => column.name === "oil_check_temp")) { db.exec("ALTER TABLE motorcycles ADD COLUMN oil_check_temp TEXT"); } if (!motorcycleColumns.some((column) => column.name === "oil_check_run_minutes")) { db.exec("ALTER TABLE motorcycles ADD COLUMN oil_check_run_minutes INTEGER"); } if (!motorcycleColumns.some((column) => column.name === "oil_check_wait_min_minutes")) { db.exec("ALTER TABLE motorcycles ADD COLUMN oil_check_wait_min_minutes INTEGER"); } if (!motorcycleColumns.some((column) => column.name === "oil_check_wait_max_minutes")) { db.exec("ALTER TABLE motorcycles ADD COLUMN oil_check_wait_max_minutes INTEGER"); } if (!motorcycleColumns.some((column) => column.name === "oil_check_wait_minutes")) { db.exec("ALTER TABLE motorcycles ADD COLUMN oil_check_wait_minutes INTEGER"); } if (!motorcycleColumns.some((column) => column.name === "oil_check_method")) { db.exec("ALTER TABLE motorcycles ADD COLUMN oil_check_method TEXT"); } const motorcycleColumnsAfter = db.prepare("PRAGMA table_info(motorcycles)").all() as { name: string }[]; if ( motorcycleColumnsAfter.some((column) => column.name === "chain_tension_mm") && motorcycleColumnsAfter.some((column) => column.name === "chain_tension_min_mm") && motorcycleColumnsAfter.some((column) => column.name === "chain_tension_max_mm") ) { db.exec(` UPDATE motorcycles SET chain_tension_min_mm = chain_tension_mm, chain_tension_max_mm = chain_tension_mm WHERE chain_tension_mm IS NOT NULL AND chain_tension_min_mm IS NULL AND chain_tension_max_mm IS NULL `); } if ( motorcycleColumnsAfter.some((column) => column.name === "oil_check_wait_minutes") && motorcycleColumnsAfter.some((column) => column.name === "oil_check_wait_min_minutes") && motorcycleColumnsAfter.some((column) => column.name === "oil_check_wait_max_minutes") ) { db.exec(` UPDATE motorcycles SET oil_check_wait_min_minutes = oil_check_wait_minutes, oil_check_wait_max_minutes = oil_check_wait_minutes WHERE oil_check_wait_minutes IS NOT NULL AND oil_check_wait_min_minutes IS NULL AND oil_check_wait_max_minutes IS NULL `); } db.exec("CREATE INDEX IF NOT EXISTS idx_users_active ON users(active, name COLLATE NOCASE)"); db.prepare("DELETE FROM webauthn_challenges WHERE used_at IS NOT NULL OR expires_at < ?") .run(new Date().toISOString()); } function ensureInitialAdmin() { const row = db.prepare("SELECT COUNT(*) AS count FROM users WHERE active = 1").get() as | { count: number } | undefined; if ((row?.count ?? 0) > 0) { return; } const password = process.env.ADMIN_PASSWORD; const name = process.env.ADMIN_NAME?.trim(); if (!password || !name) { throw new Error( "Keine Benutzer vorhanden. ADMIN_PASSWORD und ADMIN_NAME müssen gesetzt sein.", ); } if (password.length < 10) { throw new Error("ADMIN_PASSWORD muss mindestens 10 Zeichen lang sein."); } const adminSlug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "konto"; db.prepare( "INSERT INTO users (name, email, password_hash, role) VALUES (?, ?, ?, 'admin')", ).run(name, `admin-${adminSlug}@local.invalid`, hashSync(password, 12)); } function ensureReferencedUsersExist() { const missingUserIds = db .prepare( `SELECT DISTINCT refs.user_id AS id FROM ( SELECT created_by AS user_id FROM motorcycles UNION SELECT created_by AS user_id FROM maintenance_events UNION SELECT driver_user_id AS user_id FROM trips ) refs LEFT JOIN users u ON u.id = refs.user_id WHERE refs.user_id IS NOT NULL AND u.id IS NULL ORDER BY refs.user_id`, ) .all() as { id: number }[]; const insertMissingUser = db.prepare( `INSERT INTO users (id, name, email, password_hash, role, active, deleted_at) VALUES (?, ?, ?, ?, 'member', 0, CURRENT_TIMESTAMP)`, ); for (const missing of missingUserIds) { insertMissingUser.run( missing.id, `Gelöschter Benutzer #${missing.id}`, `deleted-${missing.id}-${randomUUID()}@invalid.local`, hashSync(randomUUID(), 12), ); } } export function initDatabase() { getUploadDir(); ensureSchema(); if (globalThis.__mopedDatabaseInitialized) { return; } ensureReferencedUsersExist(); ensureInitialAdmin(); globalThis.__mopedDatabaseInitialized = true; } export function transaction(work: () => T): T { db.exec("BEGIN IMMEDIATE"); try { const result = work(); db.exec("COMMIT"); return result; } catch (error) { db.exec("ROLLBACK"); throw error; } }