From c15d4a50e0ef9a1c43689e8550f605c014cab738 Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Mon, 16 Mar 2026 15:26:43 +0100 Subject: [PATCH] update --- backend/src/controllers/booking.controller.ts | 22 ++++++- backend/src/middleware/rbac.middleware.ts | 2 +- backend/src/models/booking.model.ts | 1 + backend/src/routes/booking.routes.ts | 6 +- backend/src/services/booking.service.ts | 5 +- backend/src/services/events.service.ts | 1 + frontend/src/components/chat/ChatMessage.tsx | 2 +- .../src/components/chat/ChatMessageView.tsx | 2 + frontend/src/components/chat/ChatPanel.tsx | 53 +++++++++++++++- frontend/src/pages/FahrzeugBuchungen.tsx | 23 ++++--- frontend/src/pages/Kalender.tsx | 62 +++++++++++++------ frontend/src/services/bookings.ts | 4 +- frontend/src/types/booking.types.ts | 2 +- 13 files changed, 142 insertions(+), 43 deletions(-) diff --git a/backend/src/controllers/booking.controller.ts b/backend/src/controllers/booking.controller.ts index 9a431d9..7afd4ae 100644 --- a/backend/src/controllers/booking.controller.ts +++ b/backend/src/controllers/booking.controller.ts @@ -2,6 +2,7 @@ import { Request, Response } from 'express'; import { ZodError } from 'zod'; import bookingService from '../services/booking.service'; import vehicleService from '../services/vehicle.service'; +import { hasPermission, resolveRequestRole } from '../middleware/rbac.middleware'; import { CreateBuchungSchema, UpdateBuchungSchema, @@ -87,7 +88,7 @@ class BookingController { */ async checkAvailability(req: Request, res: Response): Promise { try { - const { fahrzeugId, from, to } = req.query; + const { fahrzeugId, from, to, excludeId } = req.query; if (!fahrzeugId || !from || !to) { res .status(400) @@ -114,7 +115,7 @@ class BookingController { } const hasConflict = await bookingService.checkConflict( - fahrzeugId as string, beginn, ende + fahrzeugId as string, beginn, ende, excludeId as string | undefined ); res.json({ success: true, data: { available: !hasConflict } }); } catch (error) { @@ -204,8 +205,9 @@ class BookingController { } /** - * DELETE /api/bookings/:id + * 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 { @@ -214,6 +216,20 @@ class BookingController { res.status(400).json({ success: false, message: 'Ungültige Buchungs-ID' }); return; } + + // Check ownership: creator can always cancel their own booking + 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 role = resolveRequestRole(req); + if (!isOwner && !hasPermission(role, 'bookings:write')) { + res.status(403).json({ success: false, message: 'Keine Berechtigung' }); + return; + } + const parsed = CancelBuchungSchema.safeParse(req.body); if (!parsed.success) { handleZodError(res, parsed.error); diff --git a/backend/src/middleware/rbac.middleware.ts b/backend/src/middleware/rbac.middleware.ts index 152b90b..63a3297 100644 --- a/backend/src/middleware/rbac.middleware.ts +++ b/backend/src/middleware/rbac.middleware.ts @@ -69,7 +69,7 @@ const PERMISSION_ROLE_MIN: Record = { function roleFromGroups(groups: string[]): AppRole { if (groups.includes('dashboard_admin')) return 'admin'; if (groups.includes('dashboard_kommando')) return 'kommandant'; - if (groups.includes('dashboard_gruppenfuehrer') || groups.includes('dashboard_fahrmeister') || groups.includes('dashboard_zeugmeister')) return 'gruppenfuehrer'; + if (groups.includes('dashboard_gruppenfuehrer') || groups.includes('dashboard_fahrmeister') || groups.includes('dashboard_zeugmeister') || groups.includes('dashboard_chargen')) return 'gruppenfuehrer'; return 'mitglied'; } diff --git a/backend/src/models/booking.model.ts b/backend/src/models/booking.model.ts index ea3ed97..fe9f1af 100644 --- a/backend/src/models/booking.model.ts +++ b/backend/src/models/booking.model.ts @@ -43,6 +43,7 @@ export interface FahrzeugBuchungListItem { beginn: Date; ende: Date; abgesagt: boolean; + gebucht_von: string; gebucht_von_name?: string | null; } diff --git a/backend/src/routes/booking.routes.ts b/backend/src/routes/booking.routes.ts index 2422d2d..236be87 100644 --- a/backend/src/routes/booking.routes.ts +++ b/backend/src/routes/booking.routes.ts @@ -21,9 +21,9 @@ router.get('/calendar-token', authenticate, bookingController.getCalendarToken.b router.post('/', authenticate, bookingController.create.bind(bookingController)); router.patch('/:id', authenticate, requirePermission('bookings:write'), bookingController.update.bind(bookingController)); -// Soft-cancel (sets abgesagt=TRUE) -router.delete('/:id', authenticate, requirePermission('bookings:write'), bookingController.cancel.bind(bookingController)); -router.patch('/:id/cancel', authenticate, requirePermission('bookings:write'), bookingController.cancel.bind(bookingController)); +// Soft-cancel (sets abgesagt=TRUE) — creator or bookings:write +router.delete('/:id', authenticate, bookingController.cancel.bind(bookingController)); +router.patch('/:id/cancel', authenticate, bookingController.cancel.bind(bookingController)); // Hard-delete (admin only) router.delete('/:id/force', authenticate, requirePermission('bookings:delete'), bookingController.hardDelete.bind(bookingController)); diff --git a/backend/src/services/booking.service.ts b/backend/src/services/booking.service.ts index 4a330ad..f9f837d 100644 --- a/backend/src/services/booking.service.ts +++ b/backend/src/services/booking.service.ts @@ -27,6 +27,7 @@ function rowToListItem(row: any): FahrzeugBuchungListItem { beginn: new Date(row.beginn), ende: new Date(row.ende), abgesagt: row.abgesagt, + gebucht_von: row.gebucht_von, gebucht_von_name: row.gebucht_von_name ?? null, }; } @@ -77,7 +78,7 @@ class BookingService { const query = ` SELECT b.id, b.fahrzeug_id, b.titel, b.buchungs_art::text AS buchungs_art, - b.beginn, b.ende, b.abgesagt, + b.beginn, b.ende, b.abgesagt, b.gebucht_von, f.bezeichnung AS fahrzeug_name, f.amtliches_kennzeichen AS fahrzeug_kennzeichen, u.name AS gebucht_von_name FROM fahrzeug_buchungen b @@ -102,7 +103,7 @@ class BookingService { const query = ` SELECT b.id, b.fahrzeug_id, b.titel, b.buchungs_art::text AS buchungs_art, - b.beginn, b.ende, b.abgesagt, + b.beginn, b.ende, b.abgesagt, b.gebucht_von, f.bezeichnung AS fahrzeug_name, f.amtliches_kennzeichen AS fahrzeug_kennzeichen, u.name AS gebucht_von_name FROM fahrzeug_buchungen b diff --git a/backend/src/services/events.service.ts b/backend/src/services/events.service.ts index c515db4..cc89dc9 100644 --- a/backend/src/services/events.service.ts +++ b/backend/src/services/events.service.ts @@ -586,6 +586,7 @@ class EventsService { 'dashboard_jugend': 'Feuerwehrjugend', 'dashboard_kommandant': 'Kommandanten', 'dashboard_moderator': 'Moderatoren', + 'dashboard_chargen': 'Gruppenkommandanten', 'feuerwehr-admin': 'Feuerwehr Admin', 'feuerwehr-kommandant': 'Feuerwehr Kommandant', }; diff --git a/frontend/src/components/chat/ChatMessage.tsx b/frontend/src/components/chat/ChatMessage.tsx index d5a0d70..9d0a66a 100644 --- a/frontend/src/components/chat/ChatMessage.tsx +++ b/frontend/src/components/chat/ChatMessage.tsx @@ -125,7 +125,7 @@ const ChatMessage: React.FC = ({ message, isOwnMessage, isOneT borderRadius: 2, }} > - {!isOwnMessage && ( + {!isOwnMessage && !isOneToOne && ( {message.actorDisplayName} diff --git a/frontend/src/components/chat/ChatMessageView.tsx b/frontend/src/components/chat/ChatMessageView.tsx index 6accbb5..8c4eb9e 100644 --- a/frontend/src/components/chat/ChatMessageView.tsx +++ b/frontend/src/components/chat/ChatMessageView.tsx @@ -137,6 +137,7 @@ const ChatMessageView: React.FC = () => { }, [selectedRoomToken, chatPanelOpen, queryClient, fetchReactions]); const room = rooms.find((r) => r.token === selectedRoomToken); + const isOneToOne = room?.type === 1; const sendMutation = useMutation({ mutationFn: ({ message, replyTo }: { message: string; replyTo?: number }) => @@ -271,6 +272,7 @@ const ChatMessageView: React.FC = () => { key={msg.id} message={msg} isOwnMessage={msg.actorType === 'users' && msg.actorId === loginName} + isOneToOne={isOneToOne} onReplyClick={setReplyToMessage} onReactionToggled={handleReactionToggled} reactionsOverride={reactionsMap.get(msg.id)} diff --git a/frontend/src/components/chat/ChatPanel.tsx b/frontend/src/components/chat/ChatPanel.tsx index fae0c9b..1d924f2 100644 --- a/frontend/src/components/chat/ChatPanel.tsx +++ b/frontend/src/components/chat/ChatPanel.tsx @@ -189,7 +189,58 @@ const ChatPanelInner: React.FC = () => { ) : selectedRoomToken ? ( - + + {/* Compact room sidebar — hidden on mobile */} + + {rooms.map((room) => { + const isSelected = room.token === selectedRoomToken; + return ( + + selectRoom(room.token)} + sx={{ + mx: 'auto', + my: 0.25, + p: 0.5, + }} + > + + + {room.displayName.substring(0, 2).toUpperCase()} + + + + + ); + })} + + + + + ) : ( )} diff --git a/frontend/src/pages/FahrzeugBuchungen.tsx b/frontend/src/pages/FahrzeugBuchungen.tsx index 987ac01..dbf3677 100644 --- a/frontend/src/pages/FahrzeugBuchungen.tsx +++ b/frontend/src/pages/FahrzeugBuchungen.tsx @@ -238,7 +238,8 @@ function FahrzeugBuchungen() { .checkAvailability( form.fahrzeugId, new Date(form.beginn), - new Date(form.ende) + new Date(form.ende), + editingBooking?.id ) .then((result) => { if (!cancelled) setAvailability(result); @@ -249,7 +250,7 @@ function FahrzeugBuchungen() { return () => { cancelled = true; }; - }, [form.fahrzeugId, form.beginn, form.ende]); + }, [form.fahrzeugId, form.beginn, form.ende, editingBooking?.id]); const openCreateDialog = () => { setEditingBooking(null); @@ -693,15 +694,17 @@ function FahrzeugBuchungen() { Von: {detailBooking.gebucht_von_name} )} - {canWrite && ( + {(canWrite || detailBooking.gebucht_von === user?.id) && ( - + {canWrite && ( + + )}