Files
Moped-Tracking/src/lib/db.ts
T

396 lines
16 KiB
TypeScript

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<T>(work: () => T): T {
db.exec("BEGIN IMMEDIATE");
try {
const result = work();
db.exec("COMMIT");
return result;
} catch (error) {
db.exec("ROLLBACK");
throw error;
}
}