396 lines
16 KiB
TypeScript
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;
|
|
}
|
|
}
|