fast fertig
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 *
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user