import { Request, Response } from 'express'; import { z } from 'zod'; import equipmentService from '../services/equipment.service'; import { AusruestungStatus } from '../models/equipment.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 AusruestungStatusEnum = z.enum([ AusruestungStatus.Einsatzbereit, AusruestungStatus.Beschaedigt, AusruestungStatus.InWartung, AusruestungStatus.AusserDienst, ]); 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 uuidString = z.string().regex( /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, 'Ungültige UUID' ); const CreateAusruestungSchema = z.object({ bezeichnung: z.string().min(1).max(200), kategorie_id: uuidString, seriennummer: z.string().max(100).optional(), inventarnummer: z.string().max(50).optional(), hersteller: z.string().max(150).optional(), baujahr: z.number().int().min(1950).max(2100).optional(), status: AusruestungStatusEnum.optional(), status_bemerkung: z.string().max(2000).optional(), ist_wichtig: z.boolean().optional(), fahrzeug_id: uuidString.optional(), standort: z.string().min(1).max(150).optional(), pruef_intervall_monate: z.number().int().min(1).optional(), letzte_pruefung_am: isoDate.optional(), naechste_pruefung_am: isoDate.optional(), bemerkung: z.string().max(2000).optional(), }); const UpdateAusruestungSchema = z.object({ bezeichnung: z.string().min(1).max(200).optional(), kategorie_id: uuidString.optional(), seriennummer: z.string().max(100).nullable().optional(), inventarnummer: z.string().max(50).nullable().optional(), hersteller: z.string().max(150).nullable().optional(), baujahr: z.number().int().min(1950).max(2100).nullable().optional(), status: AusruestungStatusEnum.optional(), status_bemerkung: z.string().max(2000).nullable().optional(), ist_wichtig: z.boolean().optional(), fahrzeug_id: uuidString.nullable().optional(), standort: z.string().min(1).max(150).optional(), pruef_intervall_monate: z.number().int().min(1).nullable().optional(), letzte_pruefung_am: isoDate.nullable().optional(), naechste_pruefung_am: isoDate.nullable().optional(), bemerkung: z.string().max(2000).nullable().optional(), }); const UpdateStatusSchema = z.object({ status: AusruestungStatusEnum, bemerkung: z.string().max(2000).optional().default(''), }); const CreateWartungslogSchema = z.object({ datum: isoDate, art: z.enum(['Prüfung', 'Reparatur', 'Sonstiges']), beschreibung: z.string().min(1).max(2000), ergebnis: z.enum(['bestanden', 'bestanden_mit_maengeln', 'nicht_bestanden']).optional(), kosten: z.number().min(0).optional(), pruefende_stelle: z.string().max(150).optional(), dokument_url: z.string().url().max(500).refine( (url) => /^https?:\/\//i.test(url), 'Nur http/https URLs erlaubt' ).optional(), naechste_pruefung_am: isoDate.optional(), }); const UpdateWartungslogSchema = z.object({ datum: isoDate.optional(), art: z.enum(['Prüfung', 'Reparatur', 'Sonstiges']).optional(), beschreibung: z.string().min(1).max(2000).optional(), ergebnis: z.enum(['bestanden', 'bestanden_mit_maengeln', 'nicht_bestanden']).nullable().optional(), kosten: z.number().min(0).nullable().optional(), pruefende_stelle: z.string().max(150).nullable().optional(), naechste_pruefung_am: isoDate.nullable().optional(), }); // ── Helper ──────────────────────────────────────────────────────────────────── function getUserId(req: Request): string { return req.user!.id; } function getUserGroups(req: Request): string[] { return req.user?.groups ?? []; } /** * Returns true if the user is authorised to write to equipment in the given * category. Admin can write to any category. Fahrmeister can only write to * motorised categories. Zeugmeister can only write to non-motorised categories. */ async function checkCategoryPermission(kategorieId: string, groups: string[]): Promise { if (groups.includes('dashboard_admin')) return true; const result = await equipmentService.getCategoryById(kategorieId); if (!result) return false; // unknown category → deny if (result.motorisiert) { return groups.includes('dashboard_fahrmeister'); } else { return groups.includes('dashboard_zeugmeister'); } } // ── Controller ──────────────────────────────────────────────────────────────── class EquipmentController { async listEquipment(_req: Request, res: Response): Promise { try { const equipment = await equipmentService.getAllEquipment(); res.status(200).json({ success: true, data: equipment }); } catch (error) { logger.error('listEquipment error', { error }); res.status(500).json({ success: false, message: 'Ausrüstung konnte nicht geladen werden' }); } } async getEquipment(req: Request, res: Response): Promise { try { const { id } = req.params as Record; if (!isValidUUID(id)) { res.status(400).json({ success: false, message: 'Ungültige Ausrüstungs-ID' }); return; } const equipment = await equipmentService.getEquipmentById(id); if (!equipment) { res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' }); return; } res.status(200).json({ success: true, data: equipment }); } catch (error) { logger.error('getEquipment error', { error, id: req.params.id }); res.status(500).json({ success: false, message: 'Ausrüstung konnte nicht geladen werden' }); } } async getByVehicle(req: Request, res: Response): Promise { try { const { fahrzeugId } = req.params as Record; if (!isValidUUID(fahrzeugId)) { res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' }); return; } const equipment = await equipmentService.getEquipmentByVehicle(fahrzeugId); res.status(200).json({ success: true, data: equipment }); } catch (error) { logger.error('getByVehicle error', { error, fahrzeugId: req.params.fahrzeugId }); res.status(500).json({ success: false, message: 'Ausrüstung für Fahrzeug konnte nicht geladen werden' }); } } async getCategories(_req: Request, res: Response): Promise { try { const categories = await equipmentService.getCategories(); res.status(200).json({ success: true, data: categories }); } catch (error) { logger.error('getCategories error', { error }); res.status(500).json({ success: false, message: 'Kategorien konnten nicht geladen werden' }); } } async getStats(_req: Request, res: Response): Promise { try { const stats = await equipmentService.getEquipmentStats(); 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 equipmentService.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 getVehicleWarnings(_req: Request, res: Response): Promise { try { const warnings = await equipmentService.getVehicleWarnings(); res.status(200).json({ success: true, data: warnings }); } catch (error) { logger.error('getVehicleWarnings error', { error }); res.status(500).json({ success: false, message: 'Fahrzeug-Warnungen konnten nicht geladen werden' }); } } async createEquipment(req: Request, res: Response): Promise { try { const parsed = CreateAusruestungSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ success: false, message: 'Validierungsfehler', errors: parsed.error.flatten().fieldErrors, }); return; } const groups = getUserGroups(req); const allowed = await checkCategoryPermission(parsed.data.kategorie_id, groups); if (!allowed) { res.status(403).json({ success: false, message: 'Keine Berechtigung für diese Kategorie' }); return; } const equipment = await equipmentService.createEquipment(parsed.data, getUserId(req)); res.status(201).json({ success: true, data: equipment }); } catch (error) { logger.error('createEquipment error', { error }); res.status(500).json({ success: false, message: 'Ausrüstung konnte nicht erstellt werden' }); } } async updateEquipment(req: Request, res: Response): Promise { try { const { id } = req.params as Record; if (!isValidUUID(id)) { res.status(400).json({ success: false, message: 'Ungültige Ausrüstungs-ID' }); return; } const parsed = UpdateAusruestungSchema.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; } // Determine which category to check permissions against const groups = getUserGroups(req); if (!groups.includes('dashboard_admin')) { // Always fetch existing equipment to check old category permission const existing = await equipmentService.getEquipmentById(id); if (!existing) { res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' }); return; } // Check permission against the OLD category (must be allowed to move FROM it) const allowedOld = await checkCategoryPermission(existing.kategorie_id, groups); if (!allowedOld) { res.status(403).json({ success: false, message: 'Keine Berechtigung für diese Kategorie' }); return; } // If kategorie_id is being changed, also check permission against the NEW category if (parsed.data.kategorie_id && parsed.data.kategorie_id !== existing.kategorie_id) { const allowedNew = await checkCategoryPermission(parsed.data.kategorie_id, groups); if (!allowedNew) { res.status(403).json({ success: false, message: 'Keine Berechtigung für die Ziel-Kategorie' }); return; } } } const equipment = await equipmentService.updateEquipment(id, parsed.data, getUserId(req)); if (!equipment) { res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' }); return; } res.status(200).json({ success: true, data: equipment }); } catch (error: any) { if (error?.message === 'No fields to update') { res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' }); return; } logger.error('updateEquipment error', { error, id: req.params.id }); res.status(500).json({ success: false, message: 'Ausrüstung konnte nicht aktualisiert werden' }); } } async updateStatus(req: Request, res: Response): Promise { try { const { id } = req.params as Record; if (!isValidUUID(id)) { res.status(400).json({ success: false, message: 'Ungültige Ausrüstungs-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 groups = getUserGroups(req); if (!groups.includes('dashboard_admin')) { const existing = await equipmentService.getEquipmentById(id); if (!existing) { res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' }); return; } const allowed = await checkCategoryPermission(existing.kategorie_id, groups); if (!allowed) { res.status(403).json({ success: false, message: 'Keine Berechtigung für diese Kategorie' }); return; } } await equipmentService.updateStatus( id, parsed.data.status, parsed.data.bemerkung, getUserId(req) ); res.status(200).json({ success: true, message: 'Status aktualisiert' }); } catch (error: any) { if (error?.message === 'Equipment not found') { res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' }); return; } logger.error('updateStatus error', { error, id: req.params.id }); res.status(500).json({ success: false, message: 'Status konnte nicht aktualisiert werden' }); } } async deleteEquipment(req: Request, res: Response): Promise { try { const { id } = req.params as Record; if (!isValidUUID(id)) { res.status(400).json({ success: false, message: 'Ungültige Ausrüstungs-ID' }); return; } const deleted = await equipmentService.deleteEquipment(id, getUserId(req)); if (!deleted) { res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' }); return; } res.status(200).json({ success: true, message: 'Ausrüstung gelöscht' }); } catch (error) { logger.error('deleteEquipment error', { error, id: req.params.id }); res.status(500).json({ success: false, message: 'Ausrüstung konnte nicht gelöscht 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 Ausrüstungs-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 groups = getUserGroups(req); if (!groups.includes('dashboard_admin')) { const existing = await equipmentService.getEquipmentById(id); if (!existing) { res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' }); return; } const allowed = await checkCategoryPermission(existing.kategorie_id, groups); if (!allowed) { res.status(403).json({ success: false, message: 'Keine Berechtigung für diese Kategorie' }); return; } } const entry = await equipmentService.addWartungslog(id, parsed.data, getUserId(req)); res.status(201).json({ success: true, data: entry }); } catch (error: any) { if (error?.message === 'Equipment not found') { res.status(404).json({ success: false, message: 'Ausrüstung 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 getStatusHistory(req: Request, res: Response): Promise { try { const { id } = req.params as Record; const history = await equipmentService.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 Ausrüstungs-ID' }); return; } const wId = parseInt(wartungId, 10); if (isNaN(wId)) { res.status(400).json({ success: false, message: 'Ungültige Wartungs-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; } if (Object.keys(parsed.data).length === 0) { res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' }); return; } const entry = await equipmentService.updateWartungslog(id, wId, 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: error.message }); 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 createCategory(req: Request, res: Response): Promise { try { const { name, kurzname, sortierung, motorisiert } = req.body; if (!name || typeof name !== 'string' || !name.trim()) { res.status(400).json({ success: false, message: 'Name ist erforderlich' }); return; } if (!kurzname || typeof kurzname !== 'string' || !kurzname.trim()) { res.status(400).json({ success: false, message: 'Kurzname ist erforderlich' }); return; } const category = await equipmentService.createCategory({ name: name.trim(), kurzname: kurzname.trim(), sortierung: sortierung != null ? Number(sortierung) : undefined, motorisiert: motorisiert != null ? Boolean(motorisiert) : undefined, }); res.status(201).json({ success: true, data: category }); } catch (error) { logger.error('createCategory error', { error }); res.status(500).json({ success: false, message: 'Kategorie konnte nicht erstellt werden' }); } } async updateCategory(req: Request, res: Response): Promise { try { const { id } = req.params as Record; if (!isValidUUID(id)) { res.status(400).json({ success: false, message: 'Ungültige Kategorie-ID' }); return; } const { name, kurzname, sortierung, motorisiert } = req.body; const data: Record = {}; if (name !== undefined) data.name = String(name).trim(); if (kurzname !== undefined) data.kurzname = String(kurzname).trim(); if (sortierung !== undefined) data.sortierung = Number(sortierung); if (motorisiert !== undefined) data.motorisiert = Boolean(motorisiert); if (Object.keys(data).length === 0) { res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' }); return; } const category = await equipmentService.updateCategory(id, data as any); if (!category) { res.status(404).json({ success: false, message: 'Kategorie nicht gefunden' }); return; } res.status(200).json({ success: true, data: category }); } catch (error: any) { if (error?.message === 'No fields to update') { res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' }); return; } logger.error('updateCategory error', { error, id: req.params.id }); res.status(500).json({ success: false, message: 'Kategorie konnte nicht aktualisiert werden' }); } } async deleteCategory(req: Request, res: Response): Promise { try { const { id } = req.params as Record; if (!isValidUUID(id)) { res.status(400).json({ success: false, message: 'Ungültige Kategorie-ID' }); return; } const result = await equipmentService.deleteCategory(id); if (!result.deleted) { res.status(result.error === 'Kategorie nicht gefunden' ? 404 : 409).json({ success: false, message: result.error, }); return; } res.status(200).json({ success: true, message: 'Kategorie gelöscht' }); } catch (error) { logger.error('deleteCategory error', { error, id: req.params.id }); res.status(500).json({ success: false, message: 'Kategorie konnte nicht gelöscht 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 equipmentService.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 EquipmentController();