rework vehicle handling

This commit is contained in:
Matthias Hochmeister
2026-02-28 13:34:16 +01:00
parent 84cf505511
commit 41fc41bee4
13 changed files with 931 additions and 1228 deletions

View File

@@ -1,9 +1,15 @@
import { Request, Response } from 'express';
import { z } from 'zod';
import vehicleService from '../services/vehicle.service';
import { FahrzeugStatus, PruefungArt } from '../models/vehicle.model';
import { FahrzeugStatus } from '../models/vehicle.model';
import logger from '../utils/logger';
// ── UUID validation ───────────────────────────────────────────────────────────
function isValidUUID(s: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s);
}
// ── Zod Validation Schemas ────────────────────────────────────────────────────
const FahrzeugStatusEnum = z.enum([
@@ -13,19 +19,9 @@ const FahrzeugStatusEnum = z.enum([
FahrzeugStatus.InLehrgang,
]);
const PruefungArtEnum = z.enum([
PruefungArt.HU,
PruefungArt.AU,
PruefungArt.UVV,
PruefungArt.Leiter,
PruefungArt.Kran,
PruefungArt.Seilwinde,
PruefungArt.Sonstiges,
]);
const isoDate = z.string().regex(
/^\d{4}-\d{2}-\d{2}$/,
'Expected ISO date format YYYY-MM-DD'
/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/,
'Erwartet ISO-Datum im Format YYYY-MM-DD'
);
const CreateFahrzeugSchema = z.object({
@@ -39,30 +35,40 @@ const CreateFahrzeugSchema = z.object({
besatzung_soll: z.string().max(10).optional(),
status: FahrzeugStatusEnum.optional(),
status_bemerkung: z.string().max(500).optional(),
standort: z.string().max(100).optional(),
bild_url: z.string().url().max(500).optional(),
standort: z.string().min(1).max(100).optional(),
bild_url: z.string().url().max(500).refine(
(url) => /^https?:\/\//i.test(url),
'Nur http/https URLs erlaubt'
).optional(),
paragraph57a_faellig_am: isoDate.optional(),
naechste_wartung_am: isoDate.optional(),
});
const UpdateFahrzeugSchema = CreateFahrzeugSchema.partial();
const UpdateFahrzeugSchema = z.object({
bezeichnung: z.string().min(1).max(100).optional(),
kurzname: z.string().max(20).nullable().optional(),
amtliches_kennzeichen: z.string().max(20).nullable().optional(),
fahrgestellnummer: z.string().max(50).nullable().optional(),
baujahr: z.number().int().min(1950).max(2100).nullable().optional(),
hersteller: z.string().max(100).nullable().optional(),
typ_schluessel: z.string().max(30).nullable().optional(),
besatzung_soll: z.string().max(10).nullable().optional(),
status: FahrzeugStatusEnum.optional(),
status_bemerkung: z.string().max(500).nullable().optional(),
standort: z.string().min(1).max(100).optional(),
bild_url: z.string().url().max(500).refine(
(url) => /^https?:\/\//i.test(url),
'Nur http/https URLs erlaubt'
).nullable().optional(),
paragraph57a_faellig_am: isoDate.nullable().optional(),
naechste_wartung_am: isoDate.nullable().optional(),
});
const UpdateStatusSchema = z.object({
status: FahrzeugStatusEnum,
bemerkung: z.string().max(500).optional().default(''),
});
const CreatePruefungSchema = z.object({
pruefung_art: PruefungArtEnum,
faellig_am: isoDate,
durchgefuehrt_am: isoDate.optional(),
ergebnis: z.enum(['bestanden', 'bestanden_mit_maengeln', 'nicht_bestanden', 'ausstehend']).optional(),
pruefende_stelle: z.string().max(150).optional(),
kosten: z.number().min(0).optional(),
dokument_url: z.string().url().max(500).optional(),
bemerkung: z.string().max(1000).optional(),
});
const CreateWartungslogSchema = z.object({
datum: isoDate,
art: z.enum(['Inspektion', 'Reparatur', 'Kraftstoff', 'Reifenwechsel', 'Hauptuntersuchung', 'Reinigung', 'Sonstiges']).optional(),
@@ -76,17 +82,12 @@ const CreateWartungslogSchema = z.object({
// ── Helper ────────────────────────────────────────────────────────────────────
function getUserId(req: Request): string {
// req.user is guaranteed by the authenticate middleware
return req.user!.id;
}
// ── Controller ────────────────────────────────────────────────────────────────
class VehicleController {
/**
* GET /api/vehicles
* Fleet overview list with per-vehicle inspection badge data.
*/
async listVehicles(_req: Request, res: Response): Promise<void> {
try {
const vehicles = await vehicleService.getAllVehicles();
@@ -97,10 +98,6 @@ class VehicleController {
}
}
/**
* GET /api/vehicles/stats
* Aggregated KPI counts for the dashboard strip.
*/
async getStats(_req: Request, res: Response): Promise<void> {
try {
const stats = await vehicleService.getVehicleStats();
@@ -111,23 +108,14 @@ class VehicleController {
}
}
/**
* GET /api/vehicles/alerts?daysAhead=30
* Upcoming and overdue inspections — used by the InspectionAlerts dashboard panel.
* Returns alerts sorted by urgency (most overdue / soonest due first).
*/
async getAlerts(req: Request, res: Response): Promise<void> {
try {
const daysAhead = Math.min(
parseInt((req.query.daysAhead as string) || '30', 10),
365 // hard cap — never expose more than 1 year of lookahead
);
if (isNaN(daysAhead) || daysAhead < 0) {
const raw = parseInt((req.query.daysAhead as string) || '30', 10);
if (isNaN(raw) || raw < 0) {
res.status(400).json({ success: false, message: 'Ungültiger daysAhead-Wert' });
return;
}
const daysAhead = Math.min(raw, 365);
const alerts = await vehicleService.getUpcomingInspections(daysAhead);
res.status(200).json({ success: true, data: alerts });
} catch (error) {
@@ -136,20 +124,18 @@ class VehicleController {
}
}
/**
* GET /api/vehicles/:id
* Full vehicle detail with pruefstatus, inspection history, and wartungslog.
*/
async getVehicle(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params as Record<string, string>;
if (!isValidUUID(id)) {
res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' });
return;
}
const vehicle = await vehicleService.getVehicleById(id);
if (!vehicle) {
res.status(404).json({ success: false, message: 'Fahrzeug nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: vehicle });
} catch (error) {
logger.error('getVehicle error', { error, id: req.params.id });
@@ -157,10 +143,6 @@ class VehicleController {
}
}
/**
* POST /api/vehicles
* Create a new vehicle. Requires vehicles:write permission.
*/
async createVehicle(req: Request, res: Response): Promise<void> {
try {
const parsed = CreateFahrzeugSchema.safeParse(req.body);
@@ -172,7 +154,6 @@ class VehicleController {
});
return;
}
const vehicle = await vehicleService.createVehicle(parsed.data, getUserId(req));
res.status(201).json({ success: true, data: vehicle });
} catch (error) {
@@ -181,13 +162,13 @@ class VehicleController {
}
}
/**
* PATCH /api/vehicles/:id
* Update vehicle fields. Requires vehicles:write permission.
*/
async updateVehicle(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params as Record<string, string>;
if (!isValidUUID(id)) {
res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' });
return;
}
const parsed = UpdateFahrzeugSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
@@ -197,7 +178,10 @@ class VehicleController {
});
return;
}
if (Object.keys(parsed.data).length === 0) {
res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' });
return;
}
const vehicle = await vehicleService.updateVehicle(id, parsed.data, getUserId(req));
res.status(200).json({ success: true, data: vehicle });
} catch (error: any) {
@@ -210,20 +194,14 @@ class VehicleController {
}
}
/**
* PATCH /api/vehicles/:id/status
* Live status change — the Socket.IO hook point for Tier 3.
* Requires vehicles:write permission.
*
* The `io` instance is attached to req.app in server.ts (Tier 3):
* app.set('io', io);
* and retrieved here via req.app.get('io').
*/
async updateVehicleStatus(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params as Record<string, string>;
if (!isValidUUID(id)) {
res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' });
return;
}
const parsed = UpdateStatusSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
@@ -232,19 +210,10 @@ class VehicleController {
});
return;
}
// Tier 3: io will be available via req.app.get('io') once Socket.IO is wired up.
// Passing undefined here is safe — the service handles it gracefully.
const io = req.app.get('io') ?? undefined;
await vehicleService.updateVehicleStatus(
id,
parsed.data.status,
parsed.data.bemerkung,
getUserId(req),
io
id, parsed.data.status, parsed.data.bemerkung, getUserId(req), io
);
res.status(200).json({ success: true, message: 'Status aktualisiert' });
} catch (error: any) {
if (error?.message === 'Vehicle not found') {
@@ -256,60 +225,14 @@ class VehicleController {
}
}
// ── Inspections ─────────────────────────────────────────────────────────────
/**
* POST /api/vehicles/:id/pruefungen
* Record an inspection (scheduled or completed). Requires vehicles:write.
*/
async addPruefung(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params as Record<string, string>;
const parsed = CreatePruefungSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
const pruefung = await vehicleService.addPruefung(id, parsed.data, getUserId(req));
res.status(201).json({ success: true, data: pruefung });
} catch (error) {
logger.error('addPruefung error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Prüfung konnte nicht eingetragen werden' });
}
}
/**
* GET /api/vehicles/:id/pruefungen
* Full inspection history for a vehicle.
*/
async getPruefungen(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params as Record<string, string>;
const pruefungen = await vehicleService.getPruefungenForVehicle(id);
res.status(200).json({ success: true, data: pruefungen });
} catch (error) {
logger.error('getPruefungen error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Prüfungshistorie konnte nicht geladen werden' });
}
}
// ── Maintenance Log ──────────────────────────────────────────────────────────
/**
* POST /api/vehicles/:id/wartung
* Add a maintenance log entry. Requires vehicles:write.
*/
async addWartung(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params as Record<string, string>;
if (!isValidUUID(id)) {
res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' });
return;
}
const parsed = CreateWartungslogSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
@@ -318,24 +241,27 @@ class VehicleController {
});
return;
}
const entry = await vehicleService.addWartungslog(id, parsed.data, getUserId(req));
res.status(201).json({ success: true, data: entry });
} catch (error) {
} catch (error: any) {
if (error?.message === 'Vehicle not found') {
res.status(404).json({ success: false, message: 'Fahrzeug nicht gefunden' });
return;
}
logger.error('addWartung error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Wartungseintrag konnte nicht gespeichert werden' });
}
}
/**
* DELETE /api/vehicles/:id
* Delete a vehicle. Requires dashboard_admin group.
*/
async deleteVehicle(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params as Record<string, string>;
if (!isValidUUID(id)) {
res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' });
return;
}
await vehicleService.deleteVehicle(id, getUserId(req));
res.status(200).json({ success: true, message: 'Fahrzeug gelöscht' });
res.status(204).send();
} catch (error: any) {
if (error?.message === 'Vehicle not found') {
res.status(404).json({ success: false, message: 'Fahrzeug nicht gefunden' });
@@ -346,13 +272,13 @@ class VehicleController {
}
}
/**
* GET /api/vehicles/:id/wartung
* Maintenance log for a vehicle.
*/
async getWartung(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params as Record<string, string>;
if (!isValidUUID(id)) {
res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' });
return;
}
const entries = await vehicleService.getWartungslogForVehicle(id);
res.status(200).json({ success: true, data: entries });
} catch (error) {