From 5dfaf7db54c450b4c5c6356b0e1aefb777ad5eb4 Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Tue, 3 Mar 2026 14:45:46 +0100 Subject: [PATCH] bug fixes --- .../src/controllers/atemschutz.controller.ts | 12 ++-- backend/src/controllers/events.controller.ts | 18 ++++++ backend/src/routes/atemschutz.routes.ts | 4 +- backend/src/routes/events.routes.ts | 11 ++++ backend/src/services/atemschutz.service.ts | 42 ++++++++++--- backend/src/services/events.service.ts | 18 ++++++ frontend/src/pages/Atemschutz.tsx | 10 ++- frontend/src/pages/Dashboard.tsx | 7 +-- frontend/src/pages/Profile.tsx | 12 ++-- frontend/src/pages/Veranstaltungen.tsx | 62 ++++++++++++++++--- frontend/src/services/events.ts | 5 ++ 11 files changed, 166 insertions(+), 35 deletions(-) diff --git a/backend/src/controllers/atemschutz.controller.ts b/backend/src/controllers/atemschutz.controller.ts index e5c7a8b..72e61ea 100644 --- a/backend/src/controllers/atemschutz.controller.ts +++ b/backend/src/controllers/atemschutz.controller.ts @@ -18,9 +18,11 @@ function getUserId(req: Request): string { // ── Controller ──────────────────────────────────────────────────────────────── class AtemschutzController { - async list(_req: Request, res: Response): Promise { + async list(req: Request, res: Response): Promise { try { - const records = await atemschutzService.getAll(); + const userGroups: string[] = (req.user as any)?.groups ?? []; + const userId = getUserId(req); + const records = await atemschutzService.getAll(userGroups, userId); res.status(200).json({ success: true, data: records }); } catch (error) { logger.error('Atemschutz list error', { error }); @@ -47,9 +49,11 @@ class AtemschutzController { } } - async getStats(_req: Request, res: Response): Promise { + async getStats(req: Request, res: Response): Promise { try { - const stats = await atemschutzService.getStats(); + const userGroups: string[] = (req.user as any)?.groups ?? []; + const userId = getUserId(req); + const stats = await atemschutzService.getStats(userGroups, userId); res.status(200).json({ success: true, data: stats }); } catch (error) { logger.error('Atemschutz getStats error', { error }); diff --git a/backend/src/controllers/events.controller.ts b/backend/src/controllers/events.controller.ts index 30dc960..7dfc4ce 100644 --- a/backend/src/controllers/events.controller.ts +++ b/backend/src/controllers/events.controller.ts @@ -265,6 +265,24 @@ class EventsController { } }; + // ------------------------------------------------------------------------- + // POST /api/events/:id/delete (hard delete) + // ------------------------------------------------------------------------- + deleteEvent = async (req: Request, res: Response): Promise => { + try { + const { id } = req.params as Record; + const deleted = await eventsService.deleteEvent(id); + if (!deleted) { + res.status(404).json({ success: false, message: 'Veranstaltung nicht gefunden' }); + return; + } + res.json({ success: true, message: 'Veranstaltung wurde gelöscht' }); + } catch (error) { + logger.error('deleteEvent error', { error }); + res.status(500).json({ success: false, message: 'Fehler beim Löschen der Veranstaltung' }); + } + }; + // ------------------------------------------------------------------------- // GET /api/events/calendar-token // ------------------------------------------------------------------------- diff --git a/backend/src/routes/atemschutz.routes.ts b/backend/src/routes/atemschutz.routes.ts index 96cdf22..e8ec205 100644 --- a/backend/src/routes/atemschutz.routes.ts +++ b/backend/src/routes/atemschutz.routes.ts @@ -3,8 +3,8 @@ import atemschutzController from '../controllers/atemschutz.controller'; import { authenticate } from '../middleware/auth.middleware'; import { requireGroups } from '../middleware/rbac.middleware'; -const ADMIN_GROUPS = ['dashboard_admin']; -const WRITE_GROUPS = ['dashboard_admin', 'dashboard_atemschutz']; +const ADMIN_GROUPS = ['dashboard_admin', 'dashboard_kommando', 'dashboard_atemschutz', 'dashboard_moderator']; +const WRITE_GROUPS = ['dashboard_admin', 'dashboard_kommando', 'dashboard_atemschutz', 'dashboard_moderator']; const router = Router(); diff --git a/backend/src/routes/events.routes.ts b/backend/src/routes/events.routes.ts index 50a535c..7d0414e 100644 --- a/backend/src/routes/events.routes.ts +++ b/backend/src/routes/events.routes.ts @@ -143,4 +143,15 @@ router.delete( eventsController.cancelEvent.bind(eventsController) ); +/** + * POST /api/events/:id/delete + * Hard-delete an event permanently. Requires admin or moderator. + */ +router.post( + '/:id/delete', + authenticate, + requireGroups(WRITE_GROUPS), + eventsController.deleteEvent.bind(eventsController) +); + export default router; diff --git a/backend/src/services/atemschutz.service.ts b/backend/src/services/atemschutz.service.ts index 587c105..5b30911 100644 --- a/backend/src/services/atemschutz.service.ts +++ b/backend/src/services/atemschutz.service.ts @@ -8,19 +8,31 @@ import { UpdateAtemschutzData, } from '../models/atemschutz.model'; +const ATEMSCHUTZ_PRIVILEGED = ['dashboard_admin', 'dashboard_kommando', 'dashboard_atemschutz', 'dashboard_moderator']; + class AtemschutzService { // ========================================================================= // ÜBERSICHT (ALL RECORDS) // ========================================================================= - async getAll(): Promise { + async getAll(userGroups: string[], userId: string): Promise { + const isPrivileged = userGroups.some(g => ATEMSCHUTZ_PRIVILEGED.includes(g)); try { - const result = await pool.query(` - SELECT * - FROM atemschutz_uebersicht - WHERE mitglied_status IS NULL OR mitglied_status IN ('aktiv', 'anwärter') - ORDER BY user_family_name, user_given_name - `); + let result; + if (isPrivileged) { + result = await pool.query(` + SELECT * + FROM atemschutz_uebersicht + WHERE mitglied_status IS NULL OR mitglied_status IN ('aktiv', 'anwärter') + ORDER BY user_family_name, user_given_name + `); + } else { + result = await pool.query(` + SELECT * + FROM atemschutz_uebersicht + WHERE user_id = $1 + `, [userId]); + } return result.rows.map((row) => ({ ...row, @@ -208,7 +220,21 @@ class AtemschutzService { // DASHBOARD KPI / STATISTIKEN // ========================================================================= - async getStats(): Promise { + async getStats(userGroups: string[], userId: string): Promise { + const isPrivileged = userGroups.some(g => ATEMSCHUTZ_PRIVILEGED.includes(g)); + if (!isPrivileged) { + return { + total: 0, + mitLehrgang: 0, + untersuchungGueltig: 0, + untersuchungAbgelaufen: 0, + untersuchungBaldFaellig: 0, + leistungstestGueltig: 0, + leistungstestAbgelaufen: 0, + leistungstestBaldFaellig: 0, + einsatzbereit: 0, + }; + } try { const result = await pool.query(` SELECT diff --git a/backend/src/services/events.service.ts b/backend/src/services/events.service.ts index fa9e05c..140239f 100644 --- a/backend/src/services/events.service.ts +++ b/backend/src/services/events.service.ts @@ -490,6 +490,24 @@ class EventsService { } } + /** + * Hard-deletes an event (and any recurrence children) from the database. + * Returns true if the event was found and deleted, false if not found. + */ + async deleteEvent(id: string): Promise { + logger.info('Hard-deleting event', { id }); + // Delete recurrence children first (wiederholung_parent_id references) + await pool.query( + `DELETE FROM veranstaltungen WHERE wiederholung_parent_id = $1`, + [id] + ); + const result = await pool.query( + `DELETE FROM veranstaltungen WHERE id = $1`, + [id] + ); + return (result.rowCount ?? 0) > 0; + } + // ------------------------------------------------------------------------- // ICAL TOKEN // ------------------------------------------------------------------------- diff --git a/frontend/src/pages/Atemschutz.tsx b/frontend/src/pages/Atemschutz.tsx index 06e448c..fbbc034 100644 --- a/frontend/src/pages/Atemschutz.tsx +++ b/frontend/src/pages/Atemschutz.tsx @@ -152,7 +152,9 @@ const StatCard: React.FC = ({ label, value, color, bgcolor }) => function Atemschutz() { const notification = useNotification(); const { user } = useAuth(); - const canWrite = user?.groups?.some(g => ['dashboard_admin', 'dashboard_atemschutz'].includes(g)) ?? false; + const ATEMSCHUTZ_PRIVILEGED = ['dashboard_admin', 'dashboard_kommando', 'dashboard_atemschutz', 'dashboard_moderator']; + const canViewAll = user?.groups?.some(g => ATEMSCHUTZ_PRIVILEGED.includes(g)) ?? false; + const canWrite = canViewAll; // Data state const [traeger, setTraeger] = useState([]); @@ -359,7 +361,7 @@ function Atemschutz() { Atemschutzverwaltung - {!loading && stats && ( + {!loading && stats && canViewAll && ( {stats.total} Gesamt @@ -382,7 +384,7 @@ function Atemschutz() { {/* Stats cards */} - {!loading && stats && ( + {!loading && stats && canViewAll && ( + )} {/* Loading state */} {loading && ( diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 3560f95..fe20683 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -33,12 +33,7 @@ function Dashboard() { g.startsWith('dashboard_')); + return ( @@ -93,7 +95,7 @@ function Profile() { {/* Groups/Roles */} - {user.groups && user.groups.length > 0 && ( + {dashboardGroups.length > 0 && ( - {user.groups.map((group) => ( - - ))} + {dashboardGroups.map((group) => { + const name = group.replace(/^dashboard_/, ''); + const label = name.charAt(0).toUpperCase() + name.slice(1); + return ; + })} )} diff --git a/frontend/src/pages/Veranstaltungen.tsx b/frontend/src/pages/Veranstaltungen.tsx index 108f6f3..c6fc394 100644 --- a/frontend/src/pages/Veranstaltungen.tsx +++ b/frontend/src/pages/Veranstaltungen.tsx @@ -49,6 +49,7 @@ import { Today as TodayIcon, IosShare, Event as EventIcon, + Delete as DeleteIcon, } from '@mui/icons-material'; import DashboardLayout from '../components/dashboard/DashboardLayout'; import { useAuth } from '../contexts/AuthContext'; @@ -851,9 +852,10 @@ interface ListViewProps { canWrite: boolean; onEdit: (ev: VeranstaltungListItem) => void; onCancel: (id: string) => void; + onDelete: (id: string) => void; } -function EventListView({ events, canWrite, onEdit, onCancel }: ListViewProps) { +function EventListView({ events, canWrite, onEdit, onCancel, onDelete }: ListViewProps) { if (events.length === 0) { return ( @@ -945,9 +947,16 @@ function EventListView({ events, canWrite, onEdit, onCancel }: ListViewProps) { onEdit(ev)}> - onCancel(ev.id)}> - - + + onCancel(ev.id)}> + + + + + onDelete(ev.id)}> + + + )} @@ -996,6 +1005,10 @@ export default function Veranstaltungen() { const [cancelGrund, setCancelGrund] = useState(''); const [cancelLoading, setCancelLoading] = useState(false); + // Delete dialog + const [deleteId, setDeleteId] = useState(null); + const [deleteLoading, setDeleteLoading] = useState(false); + // iCal dialog const [icalOpen, setIcalOpen] = useState(false); @@ -1100,6 +1113,22 @@ export default function Veranstaltungen() { } }; + const handleDeleteEvent = async () => { + if (!deleteId) return; + setDeleteLoading(true); + try { + await eventsApi.deleteEvent(deleteId); + setDeleteId(null); + loadData(); + notification.showSuccess('Veranstaltung wurde gelöscht'); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : 'Fehler beim Löschen'; + notification.showError(msg); + } finally { + setDeleteLoading(false); + } + }; + // --------------------------------------------------------------------------- // Filtered events for list view // --------------------------------------------------------------------------- @@ -1246,6 +1275,7 @@ export default function Veranstaltungen() { canWrite={canWrite} onEdit={(ev) => { setEditingEvent(ev); setFormOpen(true); }} onCancel={(id) => { setCancelId(id); setCancelGrund(''); }} + onDelete={(id) => setDeleteId(id)} /> )} @@ -1289,16 +1319,16 @@ export default function Veranstaltungen() { maxWidth="xs" fullWidth > - Veranstaltung absagen + Veranstaltung stornieren - Bitte gib einen Grund für die Absage an (mind. 5 Zeichen). + Bitte gib einen Grund für die Stornierung an (mind. 5 Zeichen). setCancelGrund(e.target.value)} autoFocus @@ -1312,7 +1342,23 @@ export default function Veranstaltungen() { onClick={handleCancelEvent} disabled={cancelGrund.trim().length < 5 || cancelLoading} > - {cancelLoading ? : 'Absagen'} + {cancelLoading ? : 'Stornieren'} + + + + + {/* Delete Dialog */} + setDeleteId(null)} maxWidth="xs" fullWidth> + Veranstaltung endgültig löschen + + + Soll diese Veranstaltung wirklich endgültig gelöscht werden? Diese Aktion kann nicht rückgängig gemacht werden. + + + + + diff --git a/frontend/src/services/events.ts b/frontend/src/services/events.ts index 17d9c17..5bbf369 100644 --- a/frontend/src/services/events.ts +++ b/frontend/src/services/events.ts @@ -129,6 +129,11 @@ export const eventsApi = { .then(() => undefined); }, + /** Hard-delete an event permanently */ + deleteEvent(id: string): Promise { + return api.post(`/api/events/${id}/delete`).then(() => undefined); + }, + // ------------------------------------------------------------------------- // iCal // -------------------------------------------------------------------------