fast fertig

This commit is contained in:
2026-07-26 20:27:24 +02:00
parent 1370ad4ff7
commit c2fbb1b86f
19 changed files with 836 additions and 395 deletions
-1
View File
@@ -34,7 +34,6 @@ export default async function AccountPage({
<h3 className="panel-title">Profil</h3>
<dl className="data-list">
<div><dt>Name</dt><dd>{user.name}</dd></div>
<div><dt>E-Mail</dt><dd className="mono">{user.email}</dd></div>
<div><dt>Rolle</dt><dd>{user.role === "admin" ? "Administrator" : "Mitglied"}</dd></div>
</dl>
</div>
+3 -8
View File
@@ -15,7 +15,6 @@ export const runtime = "nodejs";
type UserRow = {
id: number;
name: string;
email: string;
role: UserRole;
created_at: string;
};
@@ -30,7 +29,9 @@ export default async function AdminUsersPage({
const params = await searchParams;
const users = db
.prepare("SELECT id, name, email, role, created_at FROM users ORDER BY name COLLATE NOCASE")
.prepare(
"SELECT id, name, role, created_at FROM users WHERE active = 1 ORDER BY name COLLATE NOCASE",
)
.all() as UserRow[];
const adminCount = users.filter((u) => u.role === "admin").length;
@@ -62,7 +63,6 @@ export default async function AdminUsersPage({
<thead>
<tr>
<th>Name</th>
<th>E-Mail</th>
<th>Rolle</th>
<th>Angelegt</th>
<th></th>
@@ -75,7 +75,6 @@ export default async function AdminUsersPage({
return (
<tr key={row.id}>
<td>{row.name}{isSelf && <span className="badge">Du</span>}</td>
<td className="mono">{row.email}</td>
<td>
<form action={changeUserRoleAction} className="inline-form">
<input type="hidden" name="id" value={row.id} />
@@ -121,10 +120,6 @@ export default async function AdminUsersPage({
Name *
<input name="name" required maxLength={100} />
</label>
<label>
E-Mail *
<input name="email" type="email" required maxLength={254} />
</label>
<label>
Passwort * (min. 10 Zeichen)
<input name="password" type="password" required minLength={10} maxLength={200} />
+39 -4
View File
@@ -5,6 +5,7 @@ import { MotorcycleForm } from "@/components/motorcycle-form";
import { requireSessionUser } from "@/lib/auth";
import { db, initDatabase } from "@/lib/db";
import { queryMessage } from "@/lib/format";
import { motorcycleAccessFilter } from "@/lib/motorcycle-access";
export const runtime = "nodejs";
@@ -17,6 +18,17 @@ type MotorcycleValue = {
vin: string | null;
plate: string | null;
current_km: number;
tire_pressure_front_bar: number | null;
tire_pressure_rear_bar: number | null;
chain_tension_min_mm: number | null;
chain_tension_max_mm: number | null;
oil_type: string | null;
oil_capacity_liters: number | null;
oil_check_temp: "warm" | "cold" | null;
oil_check_run_minutes: number | null;
oil_check_wait_min_minutes: number | null;
oil_check_wait_max_minutes: number | null;
oil_check_method: "dipstick" | "sight_glass" | null;
notes: string | null;
};
@@ -27,16 +39,39 @@ export default async function EditMotorcyclePage({
params: Promise<{ id: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
await requireSessionUser();
const user = await requireSessionUser();
initDatabase();
const { id } = await params;
const motorcycleId = Number(id);
if (!Number.isSafeInteger(motorcycleId) || motorcycleId < 1) notFound();
const visible = motorcycleAccessFilter(user);
const bike = db
.prepare("SELECT * FROM motorcycles WHERE id = ?")
.get(motorcycleId) as MotorcycleValue | undefined;
.prepare(`SELECT m.* FROM motorcycles m WHERE m.id = ? AND ${visible.clause}`)
.get(motorcycleId, ...visible.params) as MotorcycleValue | undefined;
if (!bike) notFound();
const motorcycleForForm: MotorcycleValue = {
id: bike.id,
nickname: bike.nickname,
brand: bike.brand,
model: bike.model,
year: bike.year,
vin: bike.vin,
plate: bike.plate,
current_km: bike.current_km,
tire_pressure_front_bar: bike.tire_pressure_front_bar,
tire_pressure_rear_bar: bike.tire_pressure_rear_bar,
chain_tension_min_mm: bike.chain_tension_min_mm,
chain_tension_max_mm: bike.chain_tension_max_mm,
oil_type: bike.oil_type,
oil_capacity_liters: bike.oil_capacity_liters,
oil_check_temp: bike.oil_check_temp,
oil_check_run_minutes: bike.oil_check_run_minutes,
oil_check_wait_min_minutes: bike.oil_check_wait_min_minutes,
oil_check_wait_max_minutes: bike.oil_check_wait_max_minutes,
oil_check_method: bike.oil_check_method,
notes: bike.notes,
};
const params_ = await searchParams;
@@ -56,7 +91,7 @@ export default async function EditMotorcyclePage({
</div>
</div>
<Flash error={queryMessage(params_, "error")} />
<MotorcycleForm motorcycle={bike} />
<MotorcycleForm motorcycle={motorcycleForForm} />
</>
);
}
+129 -11
View File
@@ -1,6 +1,12 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { deleteMaintenanceAction, deleteMotorcycleAction, deleteTripAction } from "@/app/actions/motorcycles";
import {
deleteMaintenanceAction,
deleteMotorcycleAction,
deleteTripAction,
revokeMotorcycleShareAction,
shareMotorcycleAction,
} from "@/app/actions/motorcycles";
import { Flash } from "@/components/flash";
import { InspectionSticker } from "@/components/inspection-sticker";
import { MaintenanceForm } from "@/components/maintenance-form";
@@ -9,6 +15,7 @@ import { TripForm } from "@/components/trip-form";
import { requireSessionUser } from "@/lib/auth";
import { db, initDatabase } from "@/lib/db";
import { formatDate, formatKm, formatMoney, queryMessage } from "@/lib/format";
import { canManageShares, motorcycleAccessFilter } from "@/lib/motorcycle-access";
import { getDueStatus } from "@/lib/status";
import { maintenanceLabels, type MaintenanceType } from "@/lib/types";
@@ -23,7 +30,20 @@ type MotorcycleRow = {
vin: string | null;
plate: string | null;
current_km: number;
created_by: number;
tire_pressure_front_bar: number | null;
tire_pressure_rear_bar: number | null;
chain_tension_min_mm: number | null;
chain_tension_max_mm: number | null;
oil_type: string | null;
oil_capacity_liters: number | null;
oil_check_temp: "warm" | "cold" | null;
oil_check_run_minutes: number | null;
oil_check_wait_min_minutes: number | null;
oil_check_wait_max_minutes: number | null;
oil_check_method: "dipstick" | "sight_glass" | null;
notes: string | null;
owner_name: string;
};
type EventRow = {
@@ -36,7 +56,7 @@ type EventRow = {
cost_cents: number | null;
due_date: string | null;
due_km: number | null;
creator: string | null;
creator: string;
attachment_id: number | null;
attachment_name: string | null;
};
@@ -44,13 +64,18 @@ type EventRow = {
type TripRow = {
id: number;
trip_date: string;
driver: string | null;
driver: string;
start_km: number;
end_km: number;
purpose: string | null;
notes: string | null;
};
type ShareRow = {
id: number;
name: string;
};
function latestDue(events: EventRow[], type: MaintenanceType) {
const match = events.find((event) => event.type === type && (event.due_date || event.due_km != null));
return { dueDate: match?.due_date ?? null, dueKm: match?.due_km ?? null };
@@ -69,17 +94,24 @@ export default async function MotorcycleDetailPage({
const motorcycleId = Number(id);
if (!Number.isSafeInteger(motorcycleId) || motorcycleId < 1) notFound();
const visible = motorcycleAccessFilter(user);
const bike = db
.prepare("SELECT * FROM motorcycles WHERE id = ?")
.get(motorcycleId) as MotorcycleRow | undefined;
.prepare(
`SELECT m.*, owner.name AS owner_name
FROM motorcycles m
JOIN users owner ON owner.id = m.created_by
WHERE m.id = ? AND ${visible.clause}`,
)
.get(motorcycleId, ...visible.params) as MotorcycleRow | undefined;
if (!bike) notFound();
const userCanManageShares = canManageShares(user, motorcycleId);
const params_ = await searchParams;
const events = db
.prepare(
`SELECT e.id, e.type, e.title, e.description, e.event_date, e.km, e.cost_cents,
e.due_date, e.due_km, u.name AS creator,
e.due_date, e.due_km, COALESCE(u.name, 'Gelöschter Benutzer') AS creator,
a.id AS attachment_id, a.original_name AS attachment_name
FROM maintenance_events e
LEFT JOIN users u ON u.id = e.created_by
@@ -91,7 +123,7 @@ export default async function MotorcycleDetailPage({
const trips = db
.prepare(
`SELECT t.id, t.trip_date, u.name AS driver, t.start_km, t.end_km, t.purpose, t.notes
`SELECT t.id, t.trip_date, COALESCE(u.name, 'Gelöschter Benutzer') AS driver, t.start_km, t.end_km, t.purpose, t.notes
FROM trips t
LEFT JOIN users u ON u.id = t.driver_user_id
WHERE t.motorcycle_id = ?
@@ -99,10 +131,38 @@ export default async function MotorcycleDetailPage({
)
.all(motorcycleId) as TripRow[];
const drivers = db.prepare("SELECT id, name FROM users ORDER BY name COLLATE NOCASE").all() as {
const drivers = db
.prepare("SELECT id, name FROM users WHERE active = 1 ORDER BY name COLLATE NOCASE")
.all() as {
id: number;
name: string;
}[];
const shares = db
.prepare(
`SELECT u.id, u.name
FROM motorcycle_shares ms
JOIN users u ON u.id = ms.user_id
WHERE ms.motorcycle_id = ? AND u.active = 1
ORDER BY u.name COLLATE NOCASE`,
)
.all(motorcycleId) as ShareRow[];
const shareCandidates = userCanManageShares
? (db
.prepare(
`SELECT u.id, u.name
FROM users u
WHERE u.active = 1
AND u.id != ?
AND u.id != ?
AND NOT EXISTS (
SELECT 1
FROM motorcycle_shares ms
WHERE ms.motorcycle_id = ? AND ms.user_id = u.id
)
ORDER BY u.name COLLATE NOCASE`,
)
.all(user.id, bike.created_by, motorcycleId) as ShareRow[])
: [];
const tuev = latestDue(events, "tuev_hu");
const inspection = latestDue(events, "inspektion");
@@ -126,8 +186,9 @@ export default async function MotorcycleDetailPage({
<p className="muted">{bike.plate ?? "Ohne Kennzeichen"}</p>
</div>
<div className="heading-actions">
<Link className="button" href={`/motorcycles/${bike.id}/maintenance`}>Wartungsansicht</Link>
<Link className="button" href={`/motorcycles/${bike.id}/edit`}>Bearbeiten</Link>
{user.role === "admin" && (
{(user.role === "admin" || bike.created_by === user.id) && (
<form
action={deleteMotorcycleAction}
className="inline-form"
@@ -170,11 +231,68 @@ export default async function MotorcycleDetailPage({
<div><dt>Baujahr</dt><dd>{bike.year ?? "—"}</dd></div>
<div><dt>Kennzeichen</dt><dd>{bike.plate ?? "—"}</dd></div>
<div><dt>FIN / VIN</dt><dd className="mono">{bike.vin ?? "—"}</dd></div>
<div><dt>Besitzer</dt><dd>{bike.owner_name}</dd></div>
</dl>
{bike.notes && <p className="notes">{bike.notes}</p>}
</div>
</section>
{userCanManageShares && (
<section className="section-block">
<div className="section-heading">
<div>
<p className="eyebrow">Freigaben</p>
<h2>Moped teilen</h2>
</div>
</div>
<div className="panel table-wrap">
{shares.length === 0 ? (
<p className="muted">Noch mit niemandem geteilt.</p>
) : (
<table className="data-table">
<thead>
<tr>
<th>Name</th>
<th></th>
</tr>
</thead>
<tbody>
{shares.map((share) => (
<tr key={share.id}>
<td>{share.name}</td>
<td>
<form action={revokeMotorcycleShareAction} className="inline-form">
<input type="hidden" name="motorcycle_id" value={bike.id} />
<input type="hidden" name="user_id" value={share.id} />
<button className="link-button danger" type="submit">Entfernen</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<form action={shareMotorcycleAction} className="panel inline-form">
<input type="hidden" name="motorcycle_id" value={bike.id} />
<label>
Benutzer hinzufügen
<select name="username" required defaultValue="">
<option value="" disabled>Bitte wählen</option>
{shareCandidates.map((candidate) => (
<option key={candidate.id} value={candidate.name}>
{candidate.name}
</option>
))}
</select>
</label>
<button className="button button-primary" type="submit" disabled={shareCandidates.length === 0}>
Teilen
</button>
</form>
</section>
)}
<section className="section-block">
<div className="section-heading">
<div>
@@ -201,7 +319,7 @@ export default async function MotorcycleDetailPage({
{(event.due_date || event.due_km != null) && (
<span>Nächste Fälligkeit: {formatDate(event.due_date)} · {formatKm(event.due_km)}</span>
)}
{event.creator && <span>von {event.creator}</span>}
<span>von {event.creator}</span>
{event.attachment_id && (
<a href={`/attachments/${event.attachment_id}`} target="_blank" rel="noreferrer">
📎 {event.attachment_name}
@@ -250,7 +368,7 @@ export default async function MotorcycleDetailPage({
{trips.map((trip) => (
<tr key={trip.id}>
<td>{formatDate(trip.trip_date)}</td>
<td>{trip.driver ?? "—"}</td>
<td>{trip.driver}</td>
<td className="mono">{formatKm(trip.start_km)}</td>
<td className="mono">{formatKm(trip.end_km)}</td>
<td className="mono">{formatKm(trip.end_km - trip.start_km)}</td>
+12 -5
View File
@@ -2,8 +2,10 @@ import Link from "next/link";
import { Flash } from "@/components/flash";
import { InspectionSticker } from "@/components/inspection-sticker";
import { StatusPill } from "@/components/status-pill";
import { requireSessionUser } from "@/lib/auth";
import { db, initDatabase } from "@/lib/db";
import { formatDate, formatKm, queryMessage } from "@/lib/format";
import { motorcycleAccessFilter } from "@/lib/motorcycle-access";
import { getDueStatus } from "@/lib/status";
import { maintenanceLabels, type MaintenanceType } from "@/lib/types";
@@ -39,8 +41,10 @@ export default async function Dashboard({
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const user = await requireSessionUser();
initDatabase();
const params = await searchParams;
const visible = motorcycleAccessFilter(user);
const motorcycles = db
.prepare(
`SELECT m.*,
@@ -60,16 +64,19 @@ export default async function Dashboard({
WHERE e.motorcycle_id = m.id AND e.type = 'inspektion'
AND (e.due_date IS NOT NULL OR e.due_km IS NOT NULL)
ORDER BY e.event_date DESC, e.id DESC LIMIT 1) AS inspection_due_km
FROM motorcycles m ORDER BY m.nickname COLLATE NOCASE`,
FROM motorcycles m
WHERE ${visible.clause}
ORDER BY m.nickname COLLATE NOCASE`,
)
.all() as BikeRow[];
.all(...visible.params) as BikeRow[];
const dueRows = db
.prepare(
`SELECT e.id, e.motorcycle_id, m.nickname, e.type, e.title,
e.due_date, e.due_km, m.current_km
FROM maintenance_events e
JOIN motorcycles m ON m.id = e.motorcycle_id
WHERE (e.due_date IS NOT NULL OR e.due_km IS NOT NULL)
WHERE ${visible.clause}
AND (e.due_date IS NOT NULL OR e.due_km IS NOT NULL)
AND e.id = (
SELECT newer.id FROM maintenance_events newer
WHERE newer.motorcycle_id = e.motorcycle_id
@@ -78,7 +85,7 @@ export default async function Dashboard({
ORDER BY newer.event_date DESC, newer.id DESC LIMIT 1
)`,
)
.all() as DueRow[];
.all(...visible.params) as DueRow[];
const dueItems = dueRows
.map((item) => ({
...item,
@@ -100,7 +107,7 @@ export default async function Dashboard({
<div>
<p className="eyebrow">Werkstattübersicht</p>
<h1>Unsere Motorräder</h1>
<p className="muted">{motorcycles.length} Maschinen im gemeinsamen Fuhrpark</p>
<p className="muted">{motorcycles.length} Maschinen in deinem Fuhrpark & Freigaben</p>
</div>
<Link className="button button-primary" href="/motorcycles/new">Motorrad anlegen</Link>
</div>
+5 -5
View File
@@ -9,24 +9,24 @@ import {
setSessionCookie,
} from "@/lib/auth";
import { db, initDatabase } from "@/lib/db";
import { emailValue, passwordValue } from "@/lib/validation";
import { passwordValue, text } from "@/lib/validation";
function messageUrl(path: string, kind: "error" | "success", message: string) {
return `${path}?${kind}=${encodeURIComponent(message)}`;
}
export async function loginAction(formData: FormData) {
let email: string;
let username: string;
let password: string;
try {
email = emailValue(formData);
username = text(formData, "username", { required: true, max: 100 });
password = String(formData.get("password") ?? "");
} catch (error) {
redirect(messageUrl("/login", "error", error instanceof Error ? error.message : "Ungültige Eingabe."));
}
const user = authenticateUser(email, password);
if (!user) redirect(messageUrl("/login", "error", "E-Mail oder Passwort ist falsch."));
const user = authenticateUser(username, password);
if (!user) redirect(messageUrl("/login", "error", "Benutzername oder Passwort ist falsch."));
await setSessionCookie(user);
redirect("/");
}
+228 -14
View File
@@ -4,11 +4,13 @@ import { randomUUID } from "node:crypto";
import { basename, join } from "node:path";
import { existsSync, unlinkSync, writeFileSync } from "node:fs";
import { redirect, unstable_rethrow } from "next/navigation";
import { requireAdminUser, requireSessionUser } from "@/lib/auth";
import { requireSessionUser } from "@/lib/auth";
import { db, initDatabase, transaction } from "@/lib/db";
import { getUploadDir } from "@/lib/env";
import { canManageShares, ensureMotorcycleAccess } from "@/lib/motorcycle-access";
import {
dateValue,
decimal,
idValue,
integer,
maintenanceType,
@@ -31,11 +33,81 @@ function errorMessage(error: unknown) {
return error instanceof Error ? error.message : "Die Aktion konnte nicht ausgeführt werden.";
}
function motorcycleExists(id: number) {
return Boolean(db.prepare("SELECT 1 FROM motorcycles WHERE id = ?").get(id));
function oilCheckFields(formData: FormData) {
const oilCheckTempRaw = text(formData, "oil_check_temp", { max: 20 });
const oilCheckMethodRaw = text(formData, "oil_check_method", { max: 20 });
const oilCheckTemp =
oilCheckTempRaw === "warm" || oilCheckTempRaw === "cold" ? oilCheckTempRaw : null;
const oilCheckMethod =
oilCheckMethodRaw === "dipstick" || oilCheckMethodRaw === "sight_glass"
? oilCheckMethodRaw
: null;
if (oilCheckTempRaw && !oilCheckTemp) throw new Error("Ölprüfung: Ungültige Temperatur-Angabe.");
if (oilCheckMethodRaw && !oilCheckMethod) throw new Error("Ölprüfung: Ungültige Messmethode.");
const oilCheckRunMinutes = integer(formData, "oil_check_run_minutes", { min: 0, max: 120 });
const oilCheckWaitMinMinutes = integer(formData, "oil_check_wait_min_minutes", { min: 0, max: 120 });
const oilCheckWaitMaxMinutes = integer(formData, "oil_check_wait_max_minutes", { min: 0, max: 120 });
if (oilCheckTemp !== "warm") {
if (oilCheckRunMinutes != null || oilCheckWaitMinMinutes != null || oilCheckWaitMaxMinutes != null) {
throw new Error("Lauf- und Wartezeiten beim Ölstand sind nur bei warmem Motor zulässig.");
}
} else {
if ((oilCheckWaitMinMinutes == null) !== (oilCheckWaitMaxMinutes == null)) {
throw new Error("Bitte Min- und Max-Wartezeit gemeinsam angeben.");
}
if (
oilCheckWaitMinMinutes != null &&
oilCheckWaitMaxMinutes != null &&
oilCheckWaitMinMinutes > oilCheckWaitMaxMinutes
) {
throw new Error("Die Min-Wartezeit darf nicht größer als die Max-Wartezeit sein.");
}
}
return {
oilCheckTemp,
oilCheckRunMinutes,
oilCheckWaitMinMinutes,
oilCheckWaitMaxMinutes,
oilCheckMethod,
};
}
function maintenanceSpecsFields(formData: FormData) {
const oilType = text(formData, "oil_type", { max: 80 }) || null;
const oilCapacityLiters = decimal(formData, "oil_capacity_liters", { min: 0.1, max: 20 });
const oilChecks = oilCheckFields(formData);
const chainTensionMinMm = integer(formData, "chain_tension_min_mm", { min: 1, max: 100 });
const chainTensionMaxMm = integer(formData, "chain_tension_max_mm", { min: 1, max: 100 });
if ((chainTensionMinMm == null) !== (chainTensionMaxMm == null)) {
throw new Error("Bitte Kettenspannung von/bis gemeinsam angeben.");
}
if (chainTensionMinMm != null && chainTensionMaxMm != null && chainTensionMinMm > chainTensionMaxMm) {
throw new Error("Kettenspannung: Von darf nicht größer als Bis sein.");
}
if ((oilType == null) !== (oilCapacityLiters == null)) {
throw new Error("Bitte Öltyp und Ölmenge zusammen angeben.");
}
if ((oilChecks.oilCheckTemp == null) !== (oilChecks.oilCheckMethod == null)) {
throw new Error("Bitte bei der Ölprüfung Temperatur und Methode zusammen angeben.");
}
return {
tirePressureFrontBar: decimal(formData, "tire_pressure_front_bar", { min: 0.5, max: 6 }),
tirePressureRearBar: decimal(formData, "tire_pressure_rear_bar", { min: 0.5, max: 6 }),
chainTensionMinMm,
chainTensionMaxMm,
oilType,
oilCapacityLiters,
...oilChecks,
};
}
function motorcycleFields(formData: FormData) {
const maintenanceSpecs = maintenanceSpecsFields(formData);
return {
nickname: text(formData, "nickname", { required: true, max: 80 }),
brand: text(formData, "brand", { required: true, max: 80 }),
@@ -44,10 +116,50 @@ function motorcycleFields(formData: FormData) {
vin: text(formData, "vin", { max: 50 }) || null,
plate: text(formData, "plate", { max: 30 }) || null,
currentKm: integer(formData, "current_km", { required: true, min: 0, max: 10_000_000 }) ?? 0,
...maintenanceSpecs,
notes: text(formData, "notes", { max: 5000 }) || null,
};
}
export async function updateMotorcycleMaintenanceAction(formData: FormData) {
const user = await requireSessionUser();
initDatabase();
let id = 0;
try {
id = idValue(formData);
ensureMotorcycleAccess(user, id);
const value = maintenanceSpecsFields(formData);
const result = db
.prepare(
`UPDATE motorcycles
SET tire_pressure_front_bar = ?, tire_pressure_rear_bar = ?,
chain_tension_min_mm = ?, chain_tension_max_mm = ?,
oil_type = ?, oil_capacity_liters = ?,
oil_check_temp = ?, oil_check_run_minutes = ?, oil_check_wait_min_minutes = ?, oil_check_wait_max_minutes = ?, oil_check_method = ?
WHERE id = ?`,
)
.run(
value.tirePressureFrontBar,
value.tirePressureRearBar,
value.chainTensionMinMm,
value.chainTensionMaxMm,
value.oilType,
value.oilCapacityLiters,
value.oilCheckTemp,
value.oilCheckRunMinutes,
value.oilCheckWaitMinMinutes,
value.oilCheckWaitMaxMinutes,
value.oilCheckMethod,
id,
);
if (result.changes === 0) throw new Error("Motorrad wurde nicht gefunden.");
redirect(`/motorcycles/${id}/maintenance?success=${encodeURIComponent("Wartungswerte gespeichert.")}`);
} catch (error) {
unstable_rethrow(error);
go(id ? `/motorcycles/${id}/maintenance/edit` : "/", "error", errorMessage(error));
}
}
export async function createMotorcycleAction(formData: FormData) {
const user = await requireSessionUser();
initDatabase();
@@ -56,8 +168,12 @@ export async function createMotorcycleAction(formData: FormData) {
const result = db
.prepare(
`INSERT INTO motorcycles
(nickname, brand, model, year, vin, plate, current_km, notes, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
(nickname, brand, model, year, vin, plate, current_km,
tire_pressure_front_bar, tire_pressure_rear_bar, chain_tension_min_mm, chain_tension_max_mm,
oil_type, oil_capacity_liters,
oil_check_temp, oil_check_run_minutes, oil_check_wait_min_minutes, oil_check_wait_max_minutes, oil_check_method,
notes, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ,
)
.run(
value.nickname,
@@ -67,6 +183,17 @@ export async function createMotorcycleAction(formData: FormData) {
value.vin,
value.plate,
value.currentKm,
value.tirePressureFrontBar,
value.tirePressureRearBar,
value.chainTensionMinMm,
value.chainTensionMaxMm,
value.oilType,
value.oilCapacityLiters,
value.oilCheckTemp,
value.oilCheckRunMinutes,
value.oilCheckWaitMinMinutes,
value.oilCheckWaitMaxMinutes,
value.oilCheckMethod,
value.notes,
user.id,
);
@@ -78,17 +205,23 @@ export async function createMotorcycleAction(formData: FormData) {
}
export async function updateMotorcycleAction(formData: FormData) {
await requireSessionUser();
const user = await requireSessionUser();
initDatabase();
let id = 0;
try {
id = idValue(formData);
ensureMotorcycleAccess(user, id);
const value = motorcycleFields(formData);
const result = db
.prepare(
`UPDATE motorcycles
SET nickname = ?, brand = ?, model = ?, year = ?, vin = ?, plate = ?,
current_km = ?, notes = ?
current_km = ?,
tire_pressure_front_bar = ?, tire_pressure_rear_bar = ?,
chain_tension_min_mm = ?, chain_tension_max_mm = ?,
oil_type = ?, oil_capacity_liters = ?,
oil_check_temp = ?, oil_check_run_minutes = ?, oil_check_wait_min_minutes = ?, oil_check_wait_max_minutes = ?, oil_check_method = ?,
notes = ?
WHERE id = ?`,
)
.run(
@@ -99,6 +232,17 @@ export async function updateMotorcycleAction(formData: FormData) {
value.vin,
value.plate,
value.currentKm,
value.tirePressureFrontBar,
value.tirePressureRearBar,
value.chainTensionMinMm,
value.chainTensionMaxMm,
value.oilType,
value.oilCapacityLiters,
value.oilCheckTemp,
value.oilCheckRunMinutes,
value.oilCheckWaitMinMinutes,
value.oilCheckWaitMaxMinutes,
value.oilCheckMethod,
value.notes,
id,
);
@@ -111,10 +255,17 @@ export async function updateMotorcycleAction(formData: FormData) {
}
export async function deleteMotorcycleAction(formData: FormData) {
await requireAdminUser();
const user = await requireSessionUser();
initDatabase();
try {
const id = idValue(formData);
const owner = db
.prepare("SELECT created_by FROM motorcycles WHERE id = ?")
.get(id) as { created_by: number } | undefined;
if (!owner) throw new Error("Motorrad wurde nicht gefunden.");
if (user.role !== "admin" && owner.created_by !== user.id) {
throw new Error("Du darfst dieses Moped nicht löschen.");
}
const files = db
.prepare(
`SELECT a.stored_name
@@ -163,7 +314,7 @@ export async function createMaintenanceAction(formData: FormData) {
try {
motorcycleId = idValue(formData, "motorcycle_id");
if (!motorcycleExists(motorcycleId)) throw new Error("Motorrad wurde nicht gefunden.");
ensureMotorcycleAccess(user, motorcycleId);
const type = maintenanceType(formData);
const title = text(formData, "title", { required: true, max: 120 });
const description = text(formData, "description", { max: 5000 }) || null;
@@ -245,7 +396,7 @@ export async function createMaintenanceAction(formData: FormData) {
}
export async function deleteMaintenanceAction(formData: FormData) {
await requireSessionUser();
const user = await requireSessionUser();
initDatabase();
let motorcycleId = 0;
try {
@@ -260,6 +411,7 @@ export async function deleteMaintenanceAction(formData: FormData) {
.get(id) as { motorcycle_id: number; stored_name: string | null } | undefined;
if (!row) throw new Error("Wartungseintrag wurde nicht gefunden.");
motorcycleId = row.motorcycle_id;
ensureMotorcycleAccess(user, motorcycleId);
db.prepare("DELETE FROM maintenance_events WHERE id = ?").run(id);
if (row.stored_name) {
const path = join(getUploadDir(), basename(row.stored_name));
@@ -273,17 +425,20 @@ export async function deleteMaintenanceAction(formData: FormData) {
}
export async function createTripAction(formData: FormData) {
await requireSessionUser();
const user = await requireSessionUser();
initDatabase();
let motorcycleId = 0;
try {
motorcycleId = idValue(formData, "motorcycle_id");
if (!motorcycleExists(motorcycleId)) throw new Error("Motorrad wurde nicht gefunden.");
ensureMotorcycleAccess(user, motorcycleId);
const tripDate = dateValue(formData, "trip_date", true);
const driverUserId = integer(formData, "driver_user_id", { required: true, min: 1 });
const startKm = integer(formData, "start_km", { required: true, min: 0, max: 10_000_000 });
const endKm = integer(formData, "end_km", { required: true, min: 0, max: 10_000_000 });
if (driverUserId == null || !db.prepare("SELECT 1 FROM users WHERE id = ?").get(driverUserId)) {
if (
driverUserId == null ||
!db.prepare("SELECT 1 FROM users WHERE id = ? AND active = 1").get(driverUserId)
) {
throw new Error("Fahrer wurde nicht gefunden.");
}
if (startKm == null || endKm == null || endKm < startKm) {
@@ -310,7 +465,7 @@ export async function createTripAction(formData: FormData) {
}
export async function deleteTripAction(formData: FormData) {
await requireSessionUser();
const user = await requireSessionUser();
initDatabase();
let motorcycleId = 0;
try {
@@ -320,6 +475,7 @@ export async function deleteTripAction(formData: FormData) {
| undefined;
if (!row) throw new Error("Fahrt wurde nicht gefunden.");
motorcycleId = row.motorcycle_id;
ensureMotorcycleAccess(user, motorcycleId);
db.prepare("DELETE FROM trips WHERE id = ?").run(id);
redirect(`/motorcycles/${motorcycleId}?success=${encodeURIComponent("Fahrt gelöscht.")}`);
} catch (error) {
@@ -327,3 +483,61 @@ export async function deleteTripAction(formData: FormData) {
go(motorcycleId ? `/motorcycles/${motorcycleId}` : "/", "error", errorMessage(error));
}
}
export async function shareMotorcycleAction(formData: FormData) {
const user = await requireSessionUser();
initDatabase();
let motorcycleId = 0;
try {
motorcycleId = idValue(formData, "motorcycle_id");
const username = text(formData, "username", { required: true, max: 100 });
if (!canManageShares(user, motorcycleId)) {
throw new Error("Du darfst die Freigaben dieses Mopeds nicht verwalten.");
}
const target = db
.prepare("SELECT id, active FROM users WHERE name = ? COLLATE NOCASE")
.all(username) as { id: number; active: number }[];
if (target.length !== 1 || target[0].active !== 1) throw new Error("Benutzer wurde nicht gefunden.");
const targetUserId = target[0].id;
const owner = db
.prepare("SELECT created_by FROM motorcycles WHERE id = ?")
.get(motorcycleId) as { created_by: number } | undefined;
if (!owner) throw new Error("Motorrad wurde nicht gefunden.");
if (owner.created_by === targetUserId) throw new Error("Der Besitzer hat bereits Zugriff.");
db.prepare(
`INSERT OR IGNORE INTO motorcycle_shares (motorcycle_id, user_id, shared_by)
VALUES (?, ?, ?)`,
).run(motorcycleId, targetUserId, user.id);
redirect(`/motorcycles/${motorcycleId}?success=${encodeURIComponent("Freigabe gespeichert.")}`);
} catch (error) {
unstable_rethrow(error);
go(motorcycleId ? `/motorcycles/${motorcycleId}` : "/", "error", errorMessage(error));
}
}
export async function revokeMotorcycleShareAction(formData: FormData) {
const user = await requireSessionUser();
initDatabase();
let motorcycleId = 0;
try {
motorcycleId = idValue(formData, "motorcycle_id");
const targetUserId = integer(formData, "user_id", { required: true, min: 1 });
if (targetUserId == null) throw new Error("Benutzer wurde nicht gefunden.");
if (!canManageShares(user, motorcycleId)) {
throw new Error("Du darfst die Freigaben dieses Mopeds nicht verwalten.");
}
db.prepare("DELETE FROM motorcycle_shares WHERE motorcycle_id = ? AND user_id = ?").run(
motorcycleId,
targetUserId,
);
redirect(`/motorcycles/${motorcycleId}?success=${encodeURIComponent("Freigabe entfernt.")}`);
} catch (error) {
unstable_rethrow(error);
go(motorcycleId ? `/motorcycles/${motorcycleId}` : "/", "error", errorMessage(error));
}
}
+31 -22
View File
@@ -1,10 +1,11 @@
"use server";
import { randomUUID } from "node:crypto";
import { hashSync } from "bcryptjs";
import { redirect, unstable_rethrow } from "next/navigation";
import { requireAdminUser } from "@/lib/auth";
import { db, initDatabase, transaction } from "@/lib/db";
import { emailValue, idValue, passwordValue, roleValue, text } from "@/lib/validation";
import { idValue, passwordValue, roleValue, text } from "@/lib/validation";
import type { UserRole } from "@/lib/types";
function go(kind: "error" | "success", message: string): never {
@@ -13,7 +14,7 @@ function go(kind: "error" | "success", message: string): never {
function errorMessage(error: unknown) {
if (error instanceof Error && error.message.includes("UNIQUE constraint failed")) {
return "Diese E-Mail-Adresse ist bereits vergeben.";
return "Dieser Benutzername ist bereits vergeben.";
}
return error instanceof Error ? error.message : "Die Aktion konnte nicht ausgeführt werden.";
}
@@ -23,12 +24,16 @@ export async function createUserAction(formData: FormData) {
initDatabase();
try {
const name = text(formData, "name", { required: true, max: 100 });
const email = emailValue(formData);
const existing = db
.prepare("SELECT 1 FROM users WHERE name = ? COLLATE NOCASE AND active = 1")
.get(name);
if (existing) throw new Error("Dieser Benutzername ist bereits vergeben.");
const password = passwordValue(formData);
const role = roleValue(formData);
const internalEmail = `user-${name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "konto"}-${randomUUID().slice(0, 8)}@local.invalid`;
db.prepare(
"INSERT INTO users (name, email, password_hash, role) VALUES (?, ?, ?, ?)",
).run(name, email, hashSync(password, 12), role);
).run(name, internalEmail, hashSync(password, 12), role);
go("success", "Benutzer wurde angelegt.");
} catch (error) {
unstable_rethrow(error);
@@ -43,20 +48,20 @@ export async function changeUserRoleAction(formData: FormData) {
const id = idValue(formData);
const role = roleValue(formData);
transaction(() => {
const target = db.prepare("SELECT role FROM users WHERE id = ?").get(id) as
const target = db.prepare("SELECT role FROM users WHERE id = ? AND active = 1").get(id) as
| { role: UserRole }
| undefined;
if (!target) throw new Error("Benutzer wurde nicht gefunden.");
if (target.role === "admin" && role === "member") {
const count = db.prepare("SELECT COUNT(*) AS count FROM users WHERE role = 'admin'").get() as {
count: number;
};
const count = db
.prepare("SELECT COUNT(*) AS count FROM users WHERE role = 'admin' AND active = 1")
.get() as { count: number };
if (count.count <= 1) throw new Error("Der letzte Administrator kann nicht herabgestuft werden.");
if (id === currentUser.id) {
throw new Error("Die eigene Administratorrolle kann nicht herabgestuft werden.");
}
}
db.prepare("UPDATE users SET role = ? WHERE id = ?").run(role, id);
db.prepare("UPDATE users SET role = ? WHERE id = ? AND active = 1").run(role, id);
});
go("success", "Rolle wurde geändert.");
} catch (error) {
@@ -72,26 +77,30 @@ export async function deleteUserAction(formData: FormData) {
const id = idValue(formData);
if (id === currentUser.id) throw new Error("Das aktuell angemeldete Konto kann nicht gelöscht werden.");
transaction(() => {
const target = db.prepare("SELECT role FROM users WHERE id = ?").get(id) as
const target = db
.prepare("SELECT role FROM users WHERE id = ? AND active = 1")
.get(id) as
| { role: UserRole }
| undefined;
if (!target) throw new Error("Benutzer wurde nicht gefunden.");
if (target.role === "admin") {
const count = db.prepare("SELECT COUNT(*) AS count FROM users WHERE role = 'admin'").get() as {
count: number;
};
const count = db
.prepare("SELECT COUNT(*) AS count FROM users WHERE role = 'admin' AND active = 1")
.get() as { count: number };
if (count.count <= 1) throw new Error("Der letzte Administrator kann nicht gelöscht werden.");
}
try {
db.prepare("DELETE FROM users WHERE id = ?").run(id);
} catch (error) {
if (error instanceof Error && error.message.includes("FOREIGN KEY")) {
throw new Error("Benutzer mit vorhandenen Einträgen kann nicht gelöscht werden.");
}
throw error;
}
db.prepare(
`UPDATE users
SET name = name || ' (gelöscht)',
email = ?,
password_hash = ?,
role = 'member',
active = 0,
deleted_at = CURRENT_TIMESTAMP
WHERE id = ? AND active = 1`,
).run(`deleted-${id}-${randomUUID()}@invalid.local`, hashSync(randomUUID(), 12), id);
});
go("success", "Benutzer wurde gelöscht.");
go("success", "Benutzerkonto wurde gelöscht; vorhandene Einträge bleiben erhalten.");
} catch (error) {
unstable_rethrow(error);
go("error", errorMessage(error));
+10 -2
View File
@@ -4,6 +4,7 @@ import type { NextRequest } from "next/server";
import { getSessionUser } from "@/lib/auth";
import { db, initDatabase } from "@/lib/db";
import { getUploadDir } from "@/lib/env";
import { motorcycleAccessFilter } from "@/lib/motorcycle-access";
export const runtime = "nodejs";
@@ -26,9 +27,16 @@ export async function GET(_request: NextRequest, ctx: RouteContext<"/attachments
}
initDatabase();
const access = motorcycleAccessFilter(user, "m");
const row = db
.prepare("SELECT stored_name, original_name, mime_type FROM attachments WHERE id = ?")
.get(attachmentId) as AttachmentRow | undefined;
.prepare(
`SELECT a.stored_name, a.original_name, a.mime_type
FROM attachments a
JOIN maintenance_events e ON e.id = a.maintenance_event_id
JOIN motorcycles m ON m.id = e.motorcycle_id
WHERE a.id = ? AND ${access.clause}`,
)
.get(attachmentId, ...access.params) as AttachmentRow | undefined;
if (!row) {
return new Response("Anhang wurde nicht gefunden.", { status: 404 });
}
+2 -2
View File
@@ -34,8 +34,8 @@ export default async function LoginPage({
<Flash error={queryMessage(params, "error")} />
<form action={loginAction} className="stack-form">
<label>
E-Mail
<input name="email" type="email" autoComplete="email" required autoFocus />
Benutzername
<input name="username" autoComplete="username" required autoFocus />
</label>
<label>
Passwort
+1 -1
View File
@@ -7,7 +7,7 @@ function today() {
export function MaintenanceForm({ motorcycleId }: { motorcycleId: number }) {
return (
<form action={createMaintenanceAction} className="panel form-grid" encType="multipart/form-data">
<form action={createMaintenanceAction} className="panel form-grid">
<input type="hidden" name="motorcycle_id" value={motorcycleId} />
<label>
Art *
+147
View File
@@ -1,3 +1,6 @@
"use client";
import { useState } from "react";
import { createMotorcycleAction, updateMotorcycleAction } from "@/app/actions/motorcycles";
type MotorcycleValue = {
@@ -9,10 +12,25 @@ type MotorcycleValue = {
vin: string | null;
plate: string | null;
current_km: number;
tire_pressure_front_bar: number | null;
tire_pressure_rear_bar: number | null;
chain_tension_min_mm: number | null;
chain_tension_max_mm: number | null;
oil_type: string | null;
oil_capacity_liters: number | null;
oil_check_temp: "warm" | "cold" | null;
oil_check_run_minutes: number | null;
oil_check_wait_min_minutes: number | null;
oil_check_wait_max_minutes: number | null;
oil_check_method: "dipstick" | "sight_glass" | null;
notes: string | null;
};
export function MotorcycleForm({ motorcycle }: { motorcycle?: MotorcycleValue }) {
const [oilCheckTemp, setOilCheckTemp] = useState<"" | "warm" | "cold">(
motorcycle?.oil_check_temp ?? "",
);
return (
<form action={motorcycle ? updateMotorcycleAction : createMotorcycleAction} className="panel form-grid">
{motorcycle && <input type="hidden" name="id" value={motorcycle.id} />}
@@ -57,6 +75,135 @@ export function MotorcycleForm({ motorcycle }: { motorcycle?: MotorcycleValue })
defaultValue={motorcycle?.current_km ?? 0}
/>
</label>
<label>
Reifendruck vorne (bar)
<input
name="tire_pressure_front_bar"
type="number"
min="0.5"
max="6"
step="0.1"
defaultValue={motorcycle?.tire_pressure_front_bar ?? ""}
/>
</label>
<label>
Reifendruck hinten (bar)
<input
name="tire_pressure_rear_bar"
type="number"
min="0.5"
max="6"
step="0.1"
defaultValue={motorcycle?.tire_pressure_rear_bar ?? ""}
/>
</label>
<label>
Kettenspannung von (mm)
<input
name="chain_tension_min_mm"
type="number"
min="1"
max="100"
step="1"
defaultValue={motorcycle?.chain_tension_min_mm ?? ""}
/>
</label>
<label>
Kettenspannung bis (mm)
<input
name="chain_tension_max_mm"
type="number"
min="1"
max="100"
step="1"
defaultValue={motorcycle?.chain_tension_max_mm ?? ""}
/>
</label>
<label>
Benötigtes Öl
<input
name="oil_type"
maxLength={80}
placeholder="z. B. 10W-40, JASO MA2"
defaultValue={motorcycle?.oil_type ?? ""}
/>
</label>
<label>
Ölmenge gesamt (Liter)
<input
name="oil_capacity_liters"
type="number"
min="0.1"
max="20"
step="0.1"
defaultValue={motorcycle?.oil_capacity_liters ?? ""}
/>
</label>
<label>
Öl prüfen: Motorzustand
<select
name="oil_check_temp"
value={oilCheckTemp}
onChange={(event) => setOilCheckTemp(event.target.value as "" | "warm" | "cold")}
>
<option value="">Bitte wählen</option>
<option value="cold">Kalt</option>
<option value="warm">Warm</option>
</select>
</label>
{oilCheckTemp === "warm" && (
<label>
Öl prüfen: Motor vorher laufen (Minuten)
<input
name="oil_check_run_minutes"
type="number"
min="0"
max="120"
step="1"
defaultValue={motorcycle?.oil_check_run_minutes ?? ""}
/>
</label>
)}
{oilCheckTemp === "warm" && (
<label>
Öl prüfen: Wartezeit min (Minuten)
<input
name="oil_check_wait_min_minutes"
type="number"
min="0"
max="120"
step="1"
defaultValue={motorcycle?.oil_check_wait_min_minutes ?? ""}
/>
</label>
)}
{oilCheckTemp === "warm" && (
<label>
Öl prüfen: Wartezeit max (Minuten)
<input
name="oil_check_wait_max_minutes"
type="number"
min="0"
max="120"
step="1"
defaultValue={motorcycle?.oil_check_wait_max_minutes ?? ""}
/>
</label>
)}
<label>
Öl prüfen: Methode
<select name="oil_check_method" defaultValue={motorcycle?.oil_check_method ?? ""}>
<option value="">Bitte wählen</option>
<option value="dipstick">Peilstab</option>
<option value="sight_glass">Schauglas</option>
</select>
</label>
{oilCheckTemp === "warm" && (
<p className="muted span-2">Bei warmem Motor kannst du Laufzeit und ein Min/Max-Wartefenster hinterlegen.</p>
)}
{oilCheckTemp === "cold" && (
<p className="muted span-2">Bei kaltem Motor wird ohne Wartezeit gemessen.</p>
)}
<label className="span-2">
Notizen
<textarea name="notes" rows={5} maxLength={5000} defaultValue={motorcycle?.notes ?? ""} />
+9 -7
View File
@@ -52,7 +52,7 @@ export async function getSessionUser(): Promise<SessionUser | null> {
const payload = result.payload as unknown as SessionPayload;
initDatabase();
const user = db
.prepare("SELECT id, name, email, role FROM users WHERE id = ?")
.prepare("SELECT id, name, email, role FROM users WHERE id = ? AND active = 1")
.get(Number(payload.id)) as
| { id: number; name: string; email: string; role: UserRole }
| undefined;
@@ -74,13 +74,15 @@ export async function requireAdminUser() {
return user;
}
export function authenticateUser(email: string, password: string): SessionUser | null {
export function authenticateUser(username: string, password: string): SessionUser | null {
initDatabase();
const user = db
.prepare("SELECT id, name, email, role, password_hash FROM users WHERE email = ?")
.get(email.trim().toLowerCase()) as
| (SessionUser & { password_hash: string })
| undefined;
const users = db
.prepare(
"SELECT id, name, email, role, password_hash FROM users WHERE name = ? COLLATE NOCASE AND active = 1 ORDER BY id ASC",
)
.all(username.trim()) as (SessionUser & { password_hash: string })[];
if (users.length !== 1) return null;
const user = users[0];
if (!user || !compareSync(password, user.password_hash)) return null;
return { id: user.id, name: user.name, email: user.email, role: user.role };
+149 -8
View File
@@ -1,4 +1,5 @@
import { DatabaseSync } from "node:sqlite";
import { randomUUID } from "node:crypto";
import { hashSync } from "bcryptjs";
import { getDatabasePath, getUploadDir } from "./env";
@@ -27,6 +28,8 @@ function ensureSchema() {
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)),
deleted_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
) STRICT;
@@ -39,11 +42,32 @@ function ensureSchema() {
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,
@@ -87,11 +111,95 @@ function ensureSchema() {
ON maintenance_events(motorcycle_id, type, event_date DESC);
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);
`);
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");
}
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)");
}
function ensureInitialAdmin() {
const row = db.prepare("SELECT COUNT(*) AS count FROM users").get() as
const row = db.prepare("SELECT COUNT(*) AS count FROM users WHERE active = 1").get() as
| { count: number }
| undefined;
@@ -99,13 +207,12 @@ function ensureInitialAdmin() {
return;
}
const email = process.env.ADMIN_EMAIL?.trim().toLowerCase();
const password = process.env.ADMIN_PASSWORD;
const name = process.env.ADMIN_NAME?.trim();
if (!email || !password || !name) {
if (!password || !name) {
throw new Error(
"Keine Benutzer vorhanden. ADMIN_EMAIL, ADMIN_PASSWORD und ADMIN_NAME müssen gesetzt sein.",
"Keine Benutzer vorhanden. ADMIN_PASSWORD und ADMIN_NAME müssen gesetzt sein.",
);
}
@@ -113,18 +220,52 @@ function ensureInitialAdmin() {
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, email, hashSync(password, 12));
).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;
}
getUploadDir();
ensureSchema();
ensureReferencedUsersExist();
ensureInitialAdmin();
globalThis.__mopedDatabaseInitialized = true;
}
+2 -1
View File
@@ -12,7 +12,8 @@ export function getAuthSecret() {
}
export function getDataDir() {
const dataDir = process.env.DATA_DIR ?? path.join(process.cwd(), "data");
const dataDir =
process.env.DATA_DIR ?? path.join(/* turbopackIgnore: true */ process.cwd(), "data");
mkdirSync(dataDir, { recursive: true });
return dataDir;
}
+14
View File
@@ -25,6 +25,20 @@ export function integer(
return value;
}
export function decimal(
formData: FormData,
key: string,
options: { required?: boolean; min?: number; max?: number } = {},
) {
const raw = String(formData.get(key) ?? "").trim().replace(",", ".");
if (!raw && !options.required) return null;
const value = Number(raw);
if (!Number.isFinite(value)) throw new Error(`${key} muss eine Zahl sein.`);
if (options.min != null && value < options.min) throw new Error(`${key} ist zu klein.`);
if (options.max != null && value > options.max) throw new Error(`${key} ist zu groß.`);
return value;
}
export function dateValue(formData: FormData, key: string, required = false) {
const value = text(formData, key, { required, max: 10 });
if (!value) return null;