import { Request, Response } from 'express'; import { z } from 'zod'; import vehicleService from '../services/vehicle.service'; import equipmentService from '../services/equipment.service'; 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([ FahrzeugStatus.Einsatzbereit, FahrzeugStatus.AusserDienstWartung, FahrzeugStatus.AusserDienstSchaden, ]); const isoDate = z.string().regex( /^\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({ bezeichnung: z.string().min(1).max(100), kurzname: z.string().max(20).optional(), amtliches_kennzeichen: z.string().max(20).optional(), fahrgestellnummer: z.string().max(50).optional(), baujahr: z.number().int().min(1950).max(2100).optional(), hersteller: z.string().max(100).optional(), typ_schluessel: z.string().max(30).optional(), besatzung_soll: z.string().max(10).optional(), status: FahrzeugStatusEnum.optional(), status_bemerkung: z.string().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 = 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 isoDatetime = z.string().datetime({ offset: true, message: 'Erwartet ISO-8601 Datum mit Zeitzone' }); const UpdateStatusSchema = z.object({ status: FahrzeugStatusEnum, bemerkung: z.string().max(500).optional().default(''), ausserDienstVon: isoDatetime.optional(), ausserDienstBis: isoDatetime.optional(), }).refine( (d) => { const isAusserDienst = d.status === FahrzeugStatus.AusserDienstWartung || d.status === FahrzeugStatus.AusserDienstSchaden; if (!isAusserDienst) return true; return !!d.ausserDienstVon && !!d.ausserDienstBis; }, { message: 'Außer-Dienst-Zeitraum (von + bis) ist bei diesem Status erforderlich', path: ['ausserDienstVon'] } ).refine( (d) => { if (!d.ausserDienstVon || !d.ausserDienstBis) return true; return new Date(d.ausserDienstBis) > new Date(d.ausserDienstVon); }, { message: 'Enddatum muss nach Startdatum liegen', path: ['ausserDienstBis'] } ); const ErgebnisEnum = z.enum(['bestanden', 'bestanden_mit_maengeln', 'nicht_bestanden']); const CreateWartungslogSchema = z.object({ datum: isoDate, art: z.enum(['§57a Prüfung', 'Service', 'Sonstiges']).optional(), beschreibung: z.string().min(1).max(2000), km_stand: z.number().int().min(0).optional(), kraftstoff_liter: z.number().min(0).optional(), kosten: z.number().min(0).optional(), externe_werkstatt: z.string().max(150).optional(), ergebnis: ErgebnisEnum.optional(), naechste_faelligkeit: isoDate.optional(), }); const UpdateWartungslogSchema = z.object({ datum: isoDate, art: z.enum(['§57a Prüfung', 'Service', 'Sonstiges']).optional(), beschreibung: z.string().min(1).max(2000), km_stand: z.number().int().min(0).optional(), externe_werkstatt: z.string().max(150).optional(), ergebnis: ErgebnisEnum.optional(), naechste_faelligkeit: isoDate.optional(), }); // ── Helper ──────────────────────────────────────────────────────────────────── function getUserId(req: Request): string { return req.user!.id; } // ── Controller ──────────────────────────────────────────────────────────────── class VehicleController { async listVehicles(_req: Request, res: Response): Promise { try { const vehicles = await vehicleService.getAllVehicles(); res.status(200).json({ success: true, data: vehicles }); } catch (error) { logger.error('listVehicles error', { error }); res.status(500).json({ success: false, message: 'Fahrzeuge konnten nicht geladen werden' }); } } async getStats(_req: Request, res: Response): Promise { try { const stats = await vehicleService.getVehicleStats(); res.status(200).json({ success: true, data: stats }); } catch (error) { logger.error('getStats error', { error }); res.status(500).json({ success: false, message: 'Statistiken konnten nicht geladen werden' }); } } async getAlerts(req: Request, res: Response): Promise { try { 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) { logger.error('getAlerts error', { error }); res.status(500).json({ success: false, message: 'Prüfungshinweise konnten nicht geladen werden' }); } } async exportAlerts(_req: Request, res: Response): Promise { try { const escape = (v: unknown): string => { if (v === null || v === undefined) return ''; const str = String(v); let safe = str.replace(/"/g, '""'); if (/^[=+@\-]/.test(safe)) safe = "'" + safe; return `"${safe}"`; }; const formatDate = (d: Date | string | null): string => { if (!d) return ''; const date = typeof d === 'string' ? new Date(d) : d; const day = String(date.getDate()).padStart(2, '0'); const month = String(date.getMonth() + 1).padStart(2, '0'); const year = date.getFullYear(); return `${day}.${month}.${year}`; }; const [vehicleAlerts, equipmentAlerts] = await Promise.all([ vehicleService.getUpcomingInspections(365), equipmentService.getUpcomingInspections(365), ]); const header = 'Typ,Bezeichnung,Kurzname,Prüfungsart,Fällig am,Tage verbleibend'; const rows: string[] = []; for (const a of vehicleAlerts) { const pruefungsart = a.type === '57a' ? '§57a Überprüfung' : 'Nächste Wartung'; rows.push([ escape('Fahrzeug'), escape(a.bezeichnung), escape(a.kurzname), escape(pruefungsart), escape(formatDate(a.faelligAm)), escape(a.tage), ].join(',')); } for (const e of equipmentAlerts) { rows.push([ escape('Ausrüstung'), escape(e.bezeichnung), escape(''), escape('Prüfung'), escape(formatDate(e.naechste_pruefung_am)), escape(e.pruefung_tage_bis_faelligkeit), ].join(',')); } const today = new Date(); const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; const csv = '\uFEFF' + header + '\n' + rows.join('\n'); res.setHeader('Content-Type', 'text/csv; charset=utf-8'); res.setHeader('Content-Disposition', `attachment; filename="pruefungen_${dateStr}.csv"`); res.status(200).send(csv); } catch (error) { logger.error('exportAlerts error', { error }); res.status(500).json({ success: false, message: 'Prüfungsexport konnte nicht erstellt werden' }); } } async getVehicle(req: Request, res: Response): Promise { try { const { id } = req.params as Record; 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 }); res.status(500).json({ success: false, message: 'Fahrzeug konnte nicht geladen werden' }); } } async createVehicle(req: Request, res: Response): Promise { try { const parsed = CreateFahrzeugSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ success: false, message: 'Validierungsfehler', errors: parsed.error.flatten().fieldErrors, }); return; } const vehicle = await vehicleService.createVehicle(parsed.data, getUserId(req)); res.status(201).json({ success: true, data: vehicle }); } catch (error) { logger.error('createVehicle error', { error }); res.status(500).json({ success: false, message: 'Fahrzeug konnte nicht erstellt werden' }); } } async updateVehicle(req: Request, res: Response): Promise { try { const { id } = req.params as Record; 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({ success: false, message: 'Validierungsfehler', errors: parsed.error.flatten().fieldErrors, }); 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) { if (error?.message === 'Vehicle not found') { res.status(404).json({ success: false, message: 'Fahrzeug nicht gefunden' }); return; } logger.error('updateVehicle error', { error, id: req.params.id }); res.status(500).json({ success: false, message: 'Fahrzeug konnte nicht aktualisiert werden' }); } } async updateVehicleStatus(req: Request, res: Response): Promise { try { const { id } = req.params as Record; 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, message: 'Validierungsfehler', errors: parsed.error.flatten().fieldErrors, }); return; } const io = req.app.get('io') ?? undefined; const result = await vehicleService.updateVehicleStatus( id, parsed.data.status, parsed.data.bemerkung, getUserId(req), io, parsed.data.ausserDienstVon ? new Date(parsed.data.ausserDienstVon) : null, parsed.data.ausserDienstBis ? new Date(parsed.data.ausserDienstBis) : null, ); res.status(200).json({ success: true, data: result }); } catch (error: any) { if (error?.message === 'Vehicle not found') { res.status(404).json({ success: false, message: 'Fahrzeug nicht gefunden' }); return; } logger.error('updateVehicleStatus error', { error, id: req.params.id }); res.status(500).json({ success: false, message: 'Status konnte nicht aktualisiert werden' }); } } async addWartung(req: Request, res: Response): Promise { try { const { id } = req.params as Record; 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, message: 'Validierungsfehler', errors: parsed.error.flatten().fieldErrors, }); return; } const entry = await vehicleService.addWartungslog(id, parsed.data, getUserId(req)); res.status(201).json({ success: true, data: entry }); } 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' }); } } async deleteVehicle(req: Request, res: Response): Promise { try { const { id } = req.params as Record; if (!isValidUUID(id)) { res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' }); return; } await vehicleService.deleteVehicle(id, getUserId(req)); res.status(204).send(); } catch (error: any) { if (error?.message === 'Vehicle not found') { res.status(404).json({ success: false, message: 'Fahrzeug nicht gefunden' }); return; } logger.error('deleteVehicle error', { error, id: req.params.id }); res.status(500).json({ success: false, message: 'Fahrzeug konnte nicht gelöscht werden' }); } } async getWartung(req: Request, res: Response): Promise { try { const { id } = req.params as Record; 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) { logger.error('getWartung error', { error, id: req.params.id }); res.status(500).json({ success: false, message: 'Wartungslog konnte nicht geladen werden' }); } } async getStatusHistory(req: Request, res: Response): Promise { try { const { id } = req.params as Record; const history = await vehicleService.getStatusHistory(id); res.status(200).json({ success: true, data: history }); } catch (error) { logger.error('getStatusHistory error', { error, id: req.params.id }); res.status(500).json({ success: false, message: 'Status-Historie konnte nicht geladen werden' }); } } async updateWartung(req: Request, res: Response): Promise { try { const { id, wartungId } = req.params as Record; if (!isValidUUID(id)) { res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' }); return; } const parsed = UpdateWartungslogSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ success: false, message: 'Validierungsfehler', errors: parsed.error.flatten().fieldErrors, }); return; } const entry = await vehicleService.updateWartungslog(wartungId, id, parsed.data, getUserId(req)); res.status(200).json({ success: true, data: entry }); } catch (error: any) { if (error?.message === 'Wartungseintrag nicht gefunden') { res.status(404).json({ success: false, message: 'Wartungseintrag nicht gefunden' }); return; } logger.error('updateWartung error', { error, id: req.params.id, wartungId: req.params.wartungId }); res.status(500).json({ success: false, message: 'Wartungseintrag konnte nicht aktualisiert werden' }); } } async uploadWartungFile(req: Request, res: Response): Promise { const { wartungId } = req.params as Record; const id = parseInt(wartungId, 10); if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige Wartungs-ID' }); return; } const file = (req as any).file; if (!file) { res.status(400).json({ success: false, message: 'Keine Datei hochgeladen' }); return; } try { const result = await vehicleService.updateWartungslogFile(id, file.path); res.status(200).json({ success: true, data: result }); } catch (error) { logger.error('uploadWartungFile error', { error, wartungId }); res.status(500).json({ success: false, message: 'Datei konnte nicht hochgeladen werden' }); } } } export default new VehicleController();