import { Request, Response } from 'express'; import { ZodError } from 'zod'; import bookingService from '../services/booking.service'; import vehicleService from '../services/vehicle.service'; import { permissionService } from '../services/permission.service'; import { CreateBuchungSchema, UpdateBuchungSchema, CancelBuchungSchema, } from '../models/booking.model'; import logger from '../utils/logger'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- 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); } function handleZodError(res: Response, err: ZodError): void { res.status(400).json({ success: false, message: 'Validierungsfehler', errors: err.flatten().fieldErrors, }); } function handleConflictError(res: Response, err: Error): boolean { if (err.message?.includes('außer Dienst')) { res.status(409).json({ success: false, message: err.message, reason: 'out_of_service' }); return true; } if (err.message?.includes('bereits gebucht')) { res.status(409).json({ success: false, message: err.message, reason: 'booking_conflict' }); return true; } return false; } // --------------------------------------------------------------------------- // Controller // --------------------------------------------------------------------------- class BookingController { /** * GET /api/bookings/calendar?from=&to=&fahrzeugId= * Returns all non-cancelled bookings overlapping the given date range. */ async getCalendarRange(req: Request, res: Response): Promise { try { const { from, to, fahrzeugId } = req.query; if (!from || !to) { res.status(400).json({ success: false, message: 'from und to sind erforderlich' }); return; } const fromDate = new Date(from as string); const toDate = new Date(to as string); const [bookings, maintenanceWindows] = await Promise.all([ bookingService.getBookingsByRange(fromDate, toDate, fahrzeugId as string | undefined), vehicleService.getMaintenanceWindows(fromDate, toDate), ]); res.json({ success: true, data: { bookings, maintenanceWindows } }); } catch (error) { logger.error('Booking getCalendarRange error', { error }); res.status(500).json({ success: false, message: 'Buchungen konnten nicht geladen werden' }); } } /** * GET /api/bookings/upcoming?limit= * Returns the next upcoming non-cancelled bookings. */ async getUpcoming(req: Request, res: Response): Promise { try { const limit = parseInt(req.query.limit as string) || 20; const bookings = await bookingService.getUpcoming(limit); res.json({ success: true, data: bookings }); } catch (error) { logger.error('Booking getUpcoming error', { error }); res.status(500).json({ success: false, message: 'Buchungen konnten nicht geladen werden' }); } } /** * GET /api/bookings/availability?fahrzeugId=&from=&to= * Returns { available: true } when the vehicle has no conflicting booking. */ async checkAvailability(req: Request, res: Response): Promise { try { const { fahrzeugId, from, to, excludeId } = req.query; if (!fahrzeugId || !from || !to) { res .status(400) .json({ success: false, message: 'fahrzeugId, from und to sind erforderlich' }); return; } const beginn = new Date(from as string); const ende = new Date(to as string); const outOfService = await bookingService.checkOutOfServiceConflict( fahrzeugId as string, beginn, ende ); if (outOfService) { res.json({ success: true, data: { available: false, reason: 'out_of_service', ausserDienstVon: outOfService.ausser_dienst_von.toISOString(), ausserDienstBis: outOfService.ausser_dienst_bis.toISOString(), }, }); return; } const hasConflict = await bookingService.checkConflict( fahrzeugId as string, beginn, ende, excludeId as string | undefined ); res.json({ success: true, data: { available: !hasConflict } }); } catch (error) { logger.error('Booking checkAvailability error', { error }); res.status(500).json({ success: false, message: 'Verfügbarkeit konnte nicht geprüft werden' }); } } /** * GET /api/bookings/:id * Returns a single booking with all joined fields. */ async getById(req: Request, res: Response): Promise { try { const { id } = req.params as Record; if (!isValidUUID(id)) { res.status(400).json({ success: false, message: 'Ungültige Buchungs-ID' }); return; } const booking = await bookingService.getById(id); if (!booking) { res.status(404).json({ success: false, message: 'Buchung nicht gefunden' }); return; } res.json({ success: true, data: booking }); } catch (error) { logger.error('Booking getById error', { error, id: req.params.id }); res.status(500).json({ success: false, message: 'Buchung konnte nicht geladen werden' }); } } /** * POST /api/bookings * Creates a new vehicle booking. */ async create(req: Request, res: Response): Promise { try { const parsed = CreateBuchungSchema.safeParse(req.body); if (!parsed.success) { handleZodError(res, parsed.error); return; } const booking = await bookingService.create(parsed.data, req.user!.id); res.status(201).json({ success: true, data: booking }); } catch (error: any) { if (handleConflictError(res, error)) return; logger.error('Booking create error', { error }); res.status(500).json({ success: false, message: 'Buchung konnte nicht erstellt werden' }); } } /** * PATCH /api/bookings/:id * Updates the provided fields of an existing booking. */ async update(req: Request, res: Response): Promise { try { const { id } = req.params as Record; if (!isValidUUID(id)) { res.status(400).json({ success: false, message: 'Ungültige Buchungs-ID' }); return; } const parsed = UpdateBuchungSchema.safeParse(req.body); if (!parsed.success) { handleZodError(res, parsed.error); return; } if (Object.keys(parsed.data).length === 0) { res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' }); return; } const booking = await bookingService.update(id, parsed.data); if (!booking) { res.status(404).json({ success: false, message: 'Buchung nicht gefunden' }); return; } res.json({ success: true, data: booking }); } catch (error: any) { if (error?.message === 'No fields to update') { res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' }); return; } if (handleConflictError(res, error)) return; logger.error('Booking update error', { error, id: req.params.id }); res.status(500).json({ success: false, message: 'Buchung konnte nicht aktualisiert werden' }); } } /** * DELETE /api/bookings/:id or PATCH /api/bookings/:id/cancel * Soft-cancels a booking (sets abgesagt=TRUE). * Allowed for booking creator or users with bookings:write permission. */ async cancel(req: Request, res: Response): Promise { try { const { id } = req.params as Record; if (!isValidUUID(id)) { res.status(400).json({ success: false, message: 'Ungültige Buchungs-ID' }); return; } // Check ownership: creator can cancel if they have cancel_own_bookings permission const booking = await bookingService.getById(id); if (!booking) { res.status(404).json({ success: false, message: 'Buchung nicht gefunden' }); return; } const isOwner = booking.gebucht_von === req.user!.id; const groups: string[] = req.user?.groups ?? []; const isAdmin = groups.includes('dashboard_admin'); const canCancelOwn = isAdmin || permissionService.hasPermission(groups, 'kalender:cancel_own_bookings'); const canCancelAny = isAdmin || permissionService.hasPermission(groups, 'kalender:delete_bookings'); if (!(isOwner && canCancelOwn) && !canCancelAny) { res.status(403).json({ success: false, message: 'Keine Berechtigung' }); return; } const parsed = CancelBuchungSchema.safeParse(req.body); if (!parsed.success) { handleZodError(res, parsed.error); return; } await bookingService.cancel(id, parsed.data.abgesagt_grund); res.json({ success: true, message: 'Buchung wurde storniert' }); } catch (error) { logger.error('Booking cancel error', { error, id: req.params.id }); res.status(500).json({ success: false, message: 'Buchung konnte nicht storniert werden' }); } } /** * DELETE /api/bookings/:id/force * Hard-deletes a booking record (admin only). */ async hardDelete(req: Request, res: Response): Promise { try { const { id } = req.params as Record; if (!isValidUUID(id)) { res.status(400).json({ success: false, message: 'Ungültige Buchungs-ID' }); return; } await bookingService.delete(id); res.json({ success: true, message: 'Buchung gelöscht' }); } catch (error) { logger.error('Booking hardDelete error', { error, id: req.params.id }); res.status(500).json({ success: false, message: 'Buchung konnte nicht gelöscht werden' }); } } /** * GET /api/bookings/calendar-token * Returns the user's iCal subscribe token and URL, creating it if needed. */ async getCalendarToken(req: Request, res: Response): Promise { try { const result = await bookingService.getOrCreateIcalToken(req.user!.id); res.json({ success: true, data: result }); } catch (error) { logger.error('Booking getCalendarToken error', { error }); res.status(500).json({ success: false, message: 'Kalender-Token konnte nicht geladen werden' }); } } /** * GET /api/bookings/calendar.ics?token=&fahrzeugId= * Returns an iCal file for the subscriber. No authentication required * (token-based access). */ async getIcalExport(req: Request, res: Response): Promise { try { const { token, fahrzeugId } = req.query; if (!token) { res.status(400).send('Token required'); return; } const ical = await bookingService.getIcalExport( token as string, fahrzeugId as string | undefined ); if (!ical) { res.status(404).send('Invalid token'); return; } res.setHeader('Content-Type', 'text/calendar; charset=utf-8'); res.setHeader('Content-Disposition', 'attachment; filename="fahrzeugbuchungen.ics"'); res.send(ical); } catch (error) { logger.error('Booking getIcalExport error', { error }); res.status(500).send('Internal server error'); } } } export default new BookingController();