diff --git a/backend/src/app.ts b/backend/src/app.ts index c885cea..430926b 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -80,6 +80,7 @@ import nextcloudRoutes from './routes/nextcloud.routes'; import atemschutzRoutes from './routes/atemschutz.routes'; import eventsRoutes from './routes/events.routes'; import bookingRoutes from './routes/booking.routes'; +import notificationRoutes from './routes/notification.routes'; app.use('/api/auth', authRoutes); app.use('/api/user', userRoutes); @@ -93,6 +94,7 @@ app.use('/api/atemschutz', atemschutzRoutes); app.use('/api/nextcloud/talk', nextcloudRoutes); app.use('/api/events', eventsRoutes); app.use('/api/bookings', bookingRoutes); +app.use('/api/notifications', notificationRoutes); // 404 handler app.use(notFoundHandler); diff --git a/backend/src/controllers/equipment.controller.ts b/backend/src/controllers/equipment.controller.ts index 9b62832..5421b39 100644 --- a/backend/src/controllers/equipment.controller.ts +++ b/backend/src/controllers/equipment.controller.ts @@ -89,6 +89,28 @@ 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 { @@ -193,6 +215,12 @@ class EquipmentController { }); 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) { @@ -221,6 +249,25 @@ class EquipmentController { 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')) { + // If kategorie_id is being changed, check against the new category; otherwise fetch existing + let kategorieId = parsed.data.kategorie_id; + if (!kategorieId) { + const existing = await equipmentService.getEquipmentById(id); + if (!existing) { + res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' }); + return; + } + kategorieId = existing.kategorie_id; + } + const allowed = await checkCategoryPermission(kategorieId, groups); + if (!allowed) { + res.status(403).json({ success: false, message: 'Keine Berechtigung für diese 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' }); @@ -253,6 +300,19 @@ class EquipmentController { }); 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) ); @@ -302,6 +362,19 @@ class EquipmentController { }); 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) { diff --git a/backend/src/controllers/notification.controller.ts b/backend/src/controllers/notification.controller.ts new file mode 100644 index 0000000..441615e --- /dev/null +++ b/backend/src/controllers/notification.controller.ts @@ -0,0 +1,72 @@ +// ============================================================================= +// Notification Controller +// ============================================================================= + +import { Request, Response } from 'express'; +import notificationService from '../services/notification.service'; +import logger from '../utils/logger'; + +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); +} + +class NotificationController { + /** GET /api/notifications — returns all notifications for the authenticated user. */ + async getNotifications(req: Request, res: Response): Promise { + try { + const userId = req.user!.id; + const notifications = await notificationService.getByUser(userId); + res.status(200).json({ success: true, data: notifications }); + } catch (error) { + logger.error('NotificationController.getNotifications error', { error }); + res.status(500).json({ success: false, message: 'Notifications konnten nicht geladen werden' }); + } + } + + /** GET /api/notifications/count — returns unread count for the authenticated user. */ + async getUnreadCount(req: Request, res: Response): Promise { + try { + const userId = req.user!.id; + const count = await notificationService.getUnreadCount(userId); + res.status(200).json({ success: true, data: { count } }); + } catch (error) { + logger.error('NotificationController.getUnreadCount error', { error }); + res.status(500).json({ success: false, message: 'Anzahl konnte nicht geladen werden' }); + } + } + + /** PATCH /api/notifications/:id/read — marks a single notification as read. */ + async markAsRead(req: Request, res: Response): Promise { + try { + const { id } = req.params as Record; + if (!isValidUUID(id)) { + res.status(400).json({ success: false, message: 'Ungültige Notification-ID' }); + return; + } + const userId = req.user!.id; + const updated = await notificationService.markAsRead(id, userId); + if (!updated) { + res.status(404).json({ success: false, message: 'Notification nicht gefunden' }); + return; + } + res.status(200).json({ success: true, message: 'Als gelesen markiert' }); + } catch (error) { + logger.error('NotificationController.markAsRead error', { error }); + res.status(500).json({ success: false, message: 'Notification konnte nicht aktualisiert werden' }); + } + } + + /** POST /api/notifications/mark-all-read — marks all notifications as read. */ + async markAllRead(req: Request, res: Response): Promise { + try { + const userId = req.user!.id; + await notificationService.markAllRead(userId); + res.status(200).json({ success: true, message: 'Alle als gelesen markiert' }); + } catch (error) { + logger.error('NotificationController.markAllRead error', { error }); + res.status(500).json({ success: false, message: 'Notifications konnten nicht aktualisiert werden' }); + } + } +} + +export default new NotificationController(); diff --git a/backend/src/database/migrations/020_create_notifications.sql b/backend/src/database/migrations/020_create_notifications.sql new file mode 100644 index 0000000..b1778bc --- /dev/null +++ b/backend/src/database/migrations/020_create_notifications.sql @@ -0,0 +1,32 @@ +-- Migration 020: Create notifications table +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +CREATE TABLE IF NOT EXISTS notifications ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + typ VARCHAR(50) NOT NULL, + titel VARCHAR(500) NOT NULL, + nachricht TEXT NOT NULL, + schwere VARCHAR(20) NOT NULL DEFAULT 'info' + CHECK (schwere IN ('info', 'warnung', 'fehler')), + gelesen BOOLEAN NOT NULL DEFAULT FALSE, + gelesen_am TIMESTAMPTZ, + link VARCHAR(500), + quell_id VARCHAR(100), + quell_typ VARCHAR(50), + erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Fast lookup for unread badge count +CREATE INDEX IF NOT EXISTS notifications_user_unread_idx + ON notifications (user_id, gelesen) + WHERE NOT gelesen; + +-- Fast lookup for notification list ordered by date +CREATE INDEX IF NOT EXISTS notifications_user_date_idx + ON notifications (user_id, erstellt_am DESC); + +-- Dedup index: one unread notification per (user, source type, source id) +CREATE UNIQUE INDEX IF NOT EXISTS notifications_dedup_idx + ON notifications (user_id, quell_typ, quell_id) + WHERE NOT gelesen AND quell_typ IS NOT NULL AND quell_id IS NOT NULL; diff --git a/backend/src/database/migrations/021_add_motorisiert_to_ausruestung_kategorien.sql b/backend/src/database/migrations/021_add_motorisiert_to_ausruestung_kategorien.sql new file mode 100644 index 0000000..97af496 --- /dev/null +++ b/backend/src/database/migrations/021_add_motorisiert_to_ausruestung_kategorien.sql @@ -0,0 +1,25 @@ +-- Migration 021: Add motorisiert flag to ausruestung_kategorien +-- Motorized equipment (motorisiert = TRUE) → managed by fahrmeister +-- Non-motorized equipment (motorisiert = FALSE) → managed by zeugmeister + +ALTER TABLE ausruestung_kategorien + ADD COLUMN IF NOT EXISTS motorisiert BOOLEAN NOT NULL DEFAULT FALSE; + +-- Recreate the helper view to include the new column +CREATE OR REPLACE VIEW ausruestung_mit_pruefstatus AS +SELECT + a.*, + k.name AS kategorie_name, + k.kurzname AS kategorie_kurzname, + k.motorisiert AS kategorie_motorisiert, + f.bezeichnung AS fahrzeug_bezeichnung, + f.kurzname AS fahrzeug_kurzname, + CASE + WHEN a.naechste_pruefung_am IS NOT NULL + THEN a.naechste_pruefung_am::date - CURRENT_DATE + ELSE NULL + END AS pruefung_tage_bis_faelligkeit +FROM ausruestung a +JOIN ausruestung_kategorien k ON k.id = a.kategorie_id +LEFT JOIN fahrzeuge f ON f.id = a.fahrzeug_id AND f.deleted_at IS NULL +WHERE a.deleted_at IS NULL; diff --git a/backend/src/database/migrations/022_add_alle_gruppen_to_kategorien.sql b/backend/src/database/migrations/022_add_alle_gruppen_to_kategorien.sql new file mode 100644 index 0000000..0ed7853 --- /dev/null +++ b/backend/src/database/migrations/022_add_alle_gruppen_to_kategorien.sql @@ -0,0 +1,6 @@ +-- Migration 022: Add alle_gruppen flag to veranstaltung_kategorien +-- When alle_gruppen = TRUE, selecting this category auto-fills +-- the event's alle_gruppen = TRUE and clears individual zielgruppen. + +ALTER TABLE veranstaltung_kategorien + ADD COLUMN IF NOT EXISTS alle_gruppen BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/backend/src/jobs/notification-generation.job.ts b/backend/src/jobs/notification-generation.job.ts new file mode 100644 index 0000000..659a0c1 --- /dev/null +++ b/backend/src/jobs/notification-generation.job.ts @@ -0,0 +1,263 @@ +/** + * Notification Generation Job + * + * Runs every 15 minutes and generates persistent notifications for: + * 1. Personal atemschutz warnings (untersuchung / leistungstest expiring within 60 days) + * 2. Vehicle issues (for fahrmeister users) + * 3. Equipment issues (for fahrmeister if motorised, zeugmeister if not) + * + * Deduplicates via the unique index on (user_id, quell_typ, quell_id) WHERE NOT gelesen. + * Also cleans up read notifications older than 90 days. + */ + +import pool from '../config/database'; +import notificationService from '../services/notification.service'; +import logger from '../utils/logger'; + +const INTERVAL_MS = 15 * 60 * 1000; // 15 minutes +const ATEMSCHUTZ_THRESHOLD = 60; // days + +let jobInterval: ReturnType | null = null; + +// --------------------------------------------------------------------------- +// Core generation function +// --------------------------------------------------------------------------- + +export async function runNotificationGeneration(): Promise { + try { + await generateAtemschutzNotifications(); + await generateVehicleNotifications(); + await generateEquipmentNotifications(); + await notificationService.deleteOldRead(); + } catch (error) { + logger.error('NotificationGenerationJob: unexpected error', { + error: error instanceof Error ? error.message : String(error), + }); + } +} + +// --------------------------------------------------------------------------- +// 1. Atemschutz personal warnings +// --------------------------------------------------------------------------- + +async function generateAtemschutzNotifications(): Promise { + try { + // Get all atemschutz records with expiring dates + const result = await pool.query(` + SELECT + au.user_id, + au.untersuchung_tage_rest, + au.leistungstest_tage_rest, + u.name AS user_name + FROM atemschutz_uebersicht au + JOIN users u ON u.id = au.user_id + WHERE au.user_id IS NOT NULL + `); + + for (const row of result.rows) { + const userId = row.user_id; + + const untTage = row.untersuchung_tage_rest != null ? parseInt(row.untersuchung_tage_rest, 10) : null; + const leiTage = row.leistungstest_tage_rest != null ? parseInt(row.leistungstest_tage_rest, 10) : null; + + if (untTage !== null && untTage <= ATEMSCHUTZ_THRESHOLD) { + const schwere = untTage < 0 ? 'fehler' : untTage <= 14 ? 'warnung' : 'info'; + await notificationService.createNotification({ + user_id: userId, + typ: 'atemschutz_untersuchung', + titel: 'Atemschutz-Untersuchung fällig', + nachricht: untTage < 0 + ? `Deine Atemschutz-Untersuchung ist seit ${Math.abs(untTage)} Tagen überfällig.` + : `Deine Atemschutz-Untersuchung ist in ${untTage} Tagen fällig.`, + schwere: schwere as any, + link: '/atemschutz', + quell_id: `atemschutz-untersuchung-${userId}`, + quell_typ: 'atemschutz_untersuchung', + }); + } + + if (leiTage !== null && leiTage <= ATEMSCHUTZ_THRESHOLD) { + const schwere = leiTage < 0 ? 'fehler' : leiTage <= 14 ? 'warnung' : 'info'; + await notificationService.createNotification({ + user_id: userId, + typ: 'atemschutz_leistungstest', + titel: 'Atemschutz-Leistungstest fällig', + nachricht: leiTage < 0 + ? `Dein Atemschutz-Leistungstest ist seit ${Math.abs(leiTage)} Tagen überfällig.` + : `Dein Atemschutz-Leistungstest ist in ${leiTage} Tagen fällig.`, + schwere: schwere as any, + link: '/atemschutz', + quell_id: `atemschutz-leistungstest-${userId}`, + quell_typ: 'atemschutz_leistungstest', + }); + } + } + } catch (error) { + logger.error('NotificationGenerationJob: generateAtemschutzNotifications failed', { error }); + } +} + +// --------------------------------------------------------------------------- +// 2. Vehicle issues → fahrmeister users +// --------------------------------------------------------------------------- + +async function generateVehicleNotifications(): Promise { + try { + // Find vehicles with problems (damaged or not operational, or overdue inspection) + const vehiclesResult = await pool.query(` + SELECT id, bezeichnung, kurzname, status, naechste_pruefung_tage + FROM fahrzeuge + WHERE deleted_at IS NULL + AND ( + status IN ('beschaedigt', 'ausser_dienst') + OR (naechste_pruefung_tage IS NOT NULL AND naechste_pruefung_tage::int < 0) + ) + `); + + if (vehiclesResult.rows.length === 0) return; + + // Get all fahrmeister users + const usersResult = await pool.query(` + SELECT id FROM users WHERE is_active = TRUE AND 'dashboard_fahrmeister' = ANY(authentik_groups) + `); + + for (const user of usersResult.rows) { + for (const vehicle of vehiclesResult.rows) { + const label = vehicle.kurzname ? `${vehicle.bezeichnung} (${vehicle.kurzname})` : vehicle.bezeichnung; + const isOverdueInspection = vehicle.naechste_pruefung_tage != null && parseInt(vehicle.naechste_pruefung_tage, 10) < 0; + const isBroken = ['beschaedigt', 'ausser_dienst'].includes(vehicle.status); + + if (isBroken) { + await notificationService.createNotification({ + user_id: user.id, + typ: 'fahrzeug_status', + titel: `Fahrzeug nicht einsatzbereit`, + nachricht: `${label} hat den Status "${vehicle.status}" und ist nicht einsatzbereit.`, + schwere: 'fehler', + link: `/fahrzeuge/${vehicle.id}`, + quell_id: `fahrzeug-status-${vehicle.id}`, + quell_typ: 'fahrzeug_status', + }); + } + + if (isOverdueInspection) { + const tage = Math.abs(parseInt(vehicle.naechste_pruefung_tage, 10)); + await notificationService.createNotification({ + user_id: user.id, + typ: 'fahrzeug_pruefung', + titel: `Fahrzeugprüfung überfällig`, + nachricht: `Die Prüfung von ${label} ist seit ${tage} Tagen überfällig.`, + schwere: 'fehler', + link: `/fahrzeuge/${vehicle.id}`, + quell_id: `fahrzeug-pruefung-${vehicle.id}`, + quell_typ: 'fahrzeug_pruefung', + }); + } + } + } + } catch (error) { + logger.error('NotificationGenerationJob: generateVehicleNotifications failed', { error }); + } +} + +// --------------------------------------------------------------------------- +// 3. Equipment issues → fahrmeister (motorised) or zeugmeister (non-motorised) +// --------------------------------------------------------------------------- + +async function generateEquipmentNotifications(): Promise { + try { + // Find equipment with problems (broken, overdue inspection) + const equipmentResult = await pool.query(` + SELECT + a.id, a.bezeichnung, a.status, + k.motorisiert, + (a.naechste_pruefung_am::date - CURRENT_DATE) AS pruefung_tage + FROM ausruestung a + JOIN ausruestung_kategorien k ON k.id = a.kategorie_id + WHERE a.deleted_at IS NULL + AND ( + a.status IN ('beschaedigt', 'ausser_dienst') + OR (a.naechste_pruefung_am IS NOT NULL AND a.naechste_pruefung_am::date < CURRENT_DATE) + ) + `); + + if (equipmentResult.rows.length === 0) return; + + // Get fahrmeister and zeugmeister users + const [fahrResult, zeugResult] = await Promise.all([ + pool.query(`SELECT id FROM users WHERE is_active = TRUE AND 'dashboard_fahrmeister' = ANY(authentik_groups)`), + pool.query(`SELECT id FROM users WHERE is_active = TRUE AND 'dashboard_zeugmeister' = ANY(authentik_groups)`), + ]); + + const fahrmeisterIds: string[] = fahrResult.rows.map((r: any) => r.id); + const zeugmeisterIds: string[] = zeugResult.rows.map((r: any) => r.id); + + for (const item of equipmentResult.rows) { + const targetUsers: string[] = item.motorisiert ? fahrmeisterIds : zeugmeisterIds; + if (targetUsers.length === 0) continue; + + const isBroken = ['beschaedigt', 'ausser_dienst'].includes(item.status); + const pruefungTage = item.pruefung_tage != null ? parseInt(item.pruefung_tage, 10) : null; + const isOverdueInspection = pruefungTage !== null && pruefungTage < 0; + + for (const userId of targetUsers) { + if (isBroken) { + await notificationService.createNotification({ + user_id: userId, + typ: 'ausruestung_status', + titel: `Ausrüstung nicht einsatzbereit`, + nachricht: `${item.bezeichnung} hat den Status "${item.status}" und ist nicht einsatzbereit.`, + schwere: 'fehler', + link: `/ausruestung/${item.id}`, + quell_id: `ausruestung-status-${item.id}`, + quell_typ: 'ausruestung_status', + }); + } + + if (isOverdueInspection) { + const tage = Math.abs(pruefungTage!); + await notificationService.createNotification({ + user_id: userId, + typ: 'ausruestung_pruefung', + titel: `Ausrüstungsprüfung überfällig`, + nachricht: `Die Prüfung von ${item.bezeichnung} ist seit ${tage} Tagen überfällig.`, + schwere: 'fehler', + link: `/ausruestung/${item.id}`, + quell_id: `ausruestung-pruefung-${item.id}`, + quell_typ: 'ausruestung_pruefung', + }); + } + } + } + } catch (error) { + logger.error('NotificationGenerationJob: generateEquipmentNotifications failed', { error }); + } +} + +// --------------------------------------------------------------------------- +// Job lifecycle +// --------------------------------------------------------------------------- + +export function startNotificationJob(): void { + if (jobInterval !== null) { + logger.warn('Notification generation job already running — skipping duplicate start'); + return; + } + + // Run once on startup, then repeat. + runNotificationGeneration(); + + jobInterval = setInterval(() => { + runNotificationGeneration(); + }, INTERVAL_MS); + + logger.info('Notification generation job scheduled (setInterval, 15min interval)'); +} + +export function stopNotificationJob(): void { + if (jobInterval !== null) { + clearInterval(jobInterval); + jobInterval = null; + logger.info('Notification generation job stopped'); + } +} diff --git a/backend/src/models/equipment.model.ts b/backend/src/models/equipment.model.ts index cb2dd9d..438ec88 100644 --- a/backend/src/models/equipment.model.ts +++ b/backend/src/models/equipment.model.ts @@ -23,10 +23,11 @@ export type AusruestungWartungslogArt = 'Prüfung' | 'Reparatur' | 'Sonstiges'; // ── Lookup Entity ───────────────────────────────────────────────────────────── export interface AusruestungKategorie { - id: string; - name: string; - kurzname: string; + id: string; + name: string; + kurzname: string; sortierung: number; + motorisiert: boolean; } // ── Core Entity ─────────────────────────────────────────────────────────────── @@ -76,6 +77,7 @@ export interface AusruestungListItem { updated_at: Date; kategorie_name: string; kategorie_kurzname: string; + kategorie_motorisiert: boolean; fahrzeug_bezeichnung: string | null; fahrzeug_kurzname: string | null; pruefung_tage_bis_faelligkeit: number | null; diff --git a/backend/src/models/events.model.ts b/backend/src/models/events.model.ts index a97e3fc..a0bad1b 100644 --- a/backend/src/models/events.model.ts +++ b/backend/src/models/events.model.ts @@ -11,6 +11,7 @@ export interface VeranstaltungKategorie { farbe?: string | null; icon?: string | null; zielgruppen: string[]; + alle_gruppen: boolean; erstellt_von?: string | null; erstellt_am: Date; aktualisiert_am: Date; @@ -91,6 +92,7 @@ export const CreateKategorieSchema = z.object({ .optional(), icon: z.string().max(100).optional(), zielgruppen: z.array(z.string()).optional(), + alle_gruppen: z.boolean().optional(), }); export type CreateKategorieData = z.infer; diff --git a/backend/src/models/notification.model.ts b/backend/src/models/notification.model.ts new file mode 100644 index 0000000..27e9b75 --- /dev/null +++ b/backend/src/models/notification.model.ts @@ -0,0 +1,31 @@ +// ============================================================================= +// Notification — Domain Model +// ============================================================================= + +export type NotificationSchwere = 'info' | 'warnung' | 'fehler'; + +export interface Notification { + id: string; + user_id: string; + typ: string; + titel: string; + nachricht: string; + schwere: NotificationSchwere; + gelesen: boolean; + gelesen_am: Date | null; + link: string | null; + quell_id: string | null; + quell_typ: string | null; + erstellt_am: Date; +} + +export interface CreateNotificationData { + user_id: string; + typ: string; + titel: string; + nachricht: string; + schwere?: NotificationSchwere; + link?: string; + quell_id?: string; + quell_typ?: string; +} diff --git a/backend/src/routes/equipment.routes.ts b/backend/src/routes/equipment.routes.ts index da4aa70..b4ea86f 100644 --- a/backend/src/routes/equipment.routes.ts +++ b/backend/src/routes/equipment.routes.ts @@ -4,7 +4,7 @@ import { authenticate } from '../middleware/auth.middleware'; import { requireGroups } from '../middleware/rbac.middleware'; const ADMIN_GROUPS = ['dashboard_admin']; -const WRITE_GROUPS = ['dashboard_admin', 'dashboard_fahrmeister']; +const WRITE_GROUPS = ['dashboard_admin', 'dashboard_fahrmeister', 'dashboard_zeugmeister']; const router = Router(); diff --git a/backend/src/routes/notification.routes.ts b/backend/src/routes/notification.routes.ts new file mode 100644 index 0000000..79e10af --- /dev/null +++ b/backend/src/routes/notification.routes.ts @@ -0,0 +1,13 @@ +import { Router } from 'express'; +import notificationController from '../controllers/notification.controller'; +import { authenticate } from '../middleware/auth.middleware'; + +const router = Router(); + +// All routes require authentication; users only see their own notifications. +router.get('/', authenticate, notificationController.getNotifications.bind(notificationController)); +router.get('/count', authenticate, notificationController.getUnreadCount.bind(notificationController)); +router.patch('/:id/read', authenticate, notificationController.markAsRead.bind(notificationController)); +router.post('/mark-all-read', authenticate, notificationController.markAllRead.bind(notificationController)); + +export default router; diff --git a/backend/src/server.ts b/backend/src/server.ts index d63ef58..a14524b 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -3,6 +3,7 @@ import environment from './config/environment'; import logger from './utils/logger'; import { testConnection, closePool, runMigrations } from './config/database'; import { startAuditCleanupJob, stopAuditCleanupJob } from './jobs/audit-cleanup.job'; +import { startNotificationJob, stopNotificationJob } from './jobs/notification-generation.job'; const startServer = async (): Promise => { try { @@ -20,6 +21,9 @@ const startServer = async (): Promise => { // Start the GDPR IP anonymisation job startAuditCleanupJob(); + // Start the notification generation job + startNotificationJob(); + // Start the server const server = app.listen(environment.port, () => { logger.info('Server started successfully', { @@ -35,6 +39,7 @@ const startServer = async (): Promise => { // Stop scheduled jobs first stopAuditCleanupJob(); + stopNotificationJob(); server.close(async () => { logger.info('HTTP server closed'); diff --git a/backend/src/services/equipment.service.ts b/backend/src/services/equipment.service.ts index 6f8c1dc..c1c3416 100644 --- a/backend/src/services/equipment.service.ts +++ b/backend/src/services/equipment.service.ts @@ -118,6 +118,19 @@ class EquipmentService { } } + async getCategoryById(id: string): Promise { + try { + const result = await pool.query( + `SELECT * FROM ausruestung_kategorien WHERE id = $1`, + [id] + ); + return result.rows.length > 0 ? (result.rows[0] as AusruestungKategorie) : null; + } catch (error) { + logger.error('EquipmentService.getCategoryById failed', { error, id }); + throw new Error('Failed to fetch equipment category'); + } + } + // ========================================================================= // CRUD // ========================================================================= diff --git a/backend/src/services/events.service.ts b/backend/src/services/events.service.ts index 140239f..55c0bbf 100644 --- a/backend/src/services/events.service.ts +++ b/backend/src/services/events.service.ts @@ -110,7 +110,7 @@ class EventsService { /** Returns all event categories ordered by name. */ async getKategorien(): Promise { const result = await pool.query(` - SELECT id, name, beschreibung, farbe, icon, zielgruppen, erstellt_von, erstellt_am, aktualisiert_am + SELECT id, name, beschreibung, farbe, icon, zielgruppen, alle_gruppen, erstellt_von, erstellt_am, aktualisiert_am FROM veranstaltung_kategorien ORDER BY name ASC `); @@ -121,6 +121,7 @@ class EventsService { farbe: row.farbe ?? null, icon: row.icon ?? null, zielgruppen: row.zielgruppen ?? [], + alle_gruppen: row.alle_gruppen ?? false, erstellt_von: row.erstellt_von ?? null, erstellt_am: new Date(row.erstellt_am), aktualisiert_am: new Date(row.aktualisiert_am), @@ -130,10 +131,10 @@ class EventsService { /** Creates a new event category. */ async createKategorie(data: CreateKategorieData, userId: string): Promise { const result = await pool.query( - `INSERT INTO veranstaltung_kategorien (name, beschreibung, farbe, icon, zielgruppen, erstellt_von) - VALUES ($1, $2, $3, $4, $5, $6) - RETURNING id, name, beschreibung, farbe, icon, zielgruppen, erstellt_von, erstellt_am, aktualisiert_am`, - [data.name, data.beschreibung ?? null, data.farbe ?? null, data.icon ?? null, data.zielgruppen ?? [], userId] + `INSERT INTO veranstaltung_kategorien (name, beschreibung, farbe, icon, zielgruppen, alle_gruppen, erstellt_von) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id, name, beschreibung, farbe, icon, zielgruppen, alle_gruppen, erstellt_von, erstellt_am, aktualisiert_am`, + [data.name, data.beschreibung ?? null, data.farbe ?? null, data.icon ?? null, data.zielgruppen ?? [], data.alle_gruppen ?? false, userId] ); const row = result.rows[0]; return { @@ -143,6 +144,7 @@ class EventsService { farbe: row.farbe ?? null, icon: row.icon ?? null, zielgruppen: row.zielgruppen ?? [], + alle_gruppen: row.alle_gruppen ?? false, erstellt_von: row.erstellt_von ?? null, erstellt_am: new Date(row.erstellt_am), aktualisiert_am: new Date(row.aktualisiert_am), @@ -160,11 +162,12 @@ class EventsService { if (data.farbe !== undefined) { fields.push(`farbe = $${idx++}`); values.push(data.farbe); } if (data.icon !== undefined) { fields.push(`icon = $${idx++}`); values.push(data.icon); } if (data.zielgruppen !== undefined) { fields.push(`zielgruppen = $${idx++}`); values.push(data.zielgruppen); } + if (data.alle_gruppen !== undefined) { fields.push(`alle_gruppen = $${idx++}`); values.push(data.alle_gruppen); } if (fields.length === 0) { // Nothing to update — return the existing record const existing = await pool.query( - `SELECT id, name, beschreibung, farbe, icon, zielgruppen, erstellt_von, erstellt_am, aktualisiert_am + `SELECT id, name, beschreibung, farbe, icon, zielgruppen, alle_gruppen, erstellt_von, erstellt_am, aktualisiert_am FROM veranstaltung_kategorien WHERE id = $1`, [id] ); @@ -173,6 +176,7 @@ class EventsService { return { id: row.id, name: row.name, beschreibung: row.beschreibung ?? null, farbe: row.farbe ?? null, icon: row.icon ?? null, zielgruppen: row.zielgruppen ?? [], + alle_gruppen: row.alle_gruppen ?? false, erstellt_von: row.erstellt_von ?? null, erstellt_am: new Date(row.erstellt_am), aktualisiert_am: new Date(row.aktualisiert_am), }; @@ -184,7 +188,7 @@ class EventsService { const result = await pool.query( `UPDATE veranstaltung_kategorien SET ${fields.join(', ')} WHERE id = $${idx} - RETURNING id, name, beschreibung, farbe, icon, zielgruppen, erstellt_von, erstellt_am, aktualisiert_am`, + RETURNING id, name, beschreibung, farbe, icon, zielgruppen, alle_gruppen, erstellt_von, erstellt_am, aktualisiert_am`, values ); if (result.rows.length === 0) return null; @@ -192,6 +196,7 @@ class EventsService { return { id: row.id, name: row.name, beschreibung: row.beschreibung ?? null, farbe: row.farbe ?? null, icon: row.icon ?? null, zielgruppen: row.zielgruppen ?? [], + alle_gruppen: row.alle_gruppen ?? false, erstellt_von: row.erstellt_von ?? null, erstellt_am: new Date(row.erstellt_am), aktualisiert_am: new Date(row.aktualisiert_am), }; diff --git a/backend/src/services/notification.service.ts b/backend/src/services/notification.service.ts new file mode 100644 index 0000000..e8df813 --- /dev/null +++ b/backend/src/services/notification.service.ts @@ -0,0 +1,131 @@ +// ============================================================================= +// Notification Service +// ============================================================================= + +import pool from '../config/database'; +import logger from '../utils/logger'; +import { Notification, CreateNotificationData } from '../models/notification.model'; + +function rowToNotification(row: any): Notification { + return { + id: row.id, + user_id: row.user_id, + typ: row.typ, + titel: row.titel, + nachricht: row.nachricht, + schwere: row.schwere, + gelesen: row.gelesen, + gelesen_am: row.gelesen_am ? new Date(row.gelesen_am) : null, + link: row.link ?? null, + quell_id: row.quell_id ?? null, + quell_typ: row.quell_typ ?? null, + erstellt_am: new Date(row.erstellt_am), + }; +} + +class NotificationService { + /** Returns all notifications for a user (newest first, max 100). */ + async getByUser(userId: string): Promise { + try { + const result = await pool.query( + `SELECT * FROM notifications WHERE user_id = $1 ORDER BY erstellt_am DESC LIMIT 100`, + [userId] + ); + return result.rows.map(rowToNotification); + } catch (error) { + logger.error('NotificationService.getByUser failed', { error, userId }); + throw new Error('Notifications konnten nicht geladen werden'); + } + } + + /** Returns the count of unread notifications for a user. */ + async getUnreadCount(userId: string): Promise { + try { + const result = await pool.query( + `SELECT COUNT(*) AS cnt FROM notifications WHERE user_id = $1 AND gelesen = FALSE`, + [userId] + ); + return parseInt(result.rows[0].cnt, 10); + } catch (error) { + logger.error('NotificationService.getUnreadCount failed', { error, userId }); + throw new Error('Ungelesene Notifications konnten nicht gezählt werden'); + } + } + + /** Marks a single notification as read. Returns false if not found or not owned by user. */ + async markAsRead(id: string, userId: string): Promise { + try { + const result = await pool.query( + `UPDATE notifications + SET gelesen = TRUE, gelesen_am = NOW() + WHERE id = $1 AND user_id = $2 AND gelesen = FALSE + RETURNING id`, + [id, userId] + ); + return (result.rowCount ?? 0) > 0; + } catch (error) { + logger.error('NotificationService.markAsRead failed', { error, id, userId }); + throw new Error('Notification konnte nicht als gelesen markiert werden'); + } + } + + /** Marks all notifications as read for a user. */ + async markAllRead(userId: string): Promise { + try { + await pool.query( + `UPDATE notifications SET gelesen = TRUE, gelesen_am = NOW() + WHERE user_id = $1 AND gelesen = FALSE`, + [userId] + ); + } catch (error) { + logger.error('NotificationService.markAllRead failed', { error, userId }); + throw new Error('Notifications konnten nicht als gelesen markiert werden'); + } + } + + /** + * Creates a notification. If quell_typ + quell_id are provided the insert is + * silently ignored when an identical unread notification already exists + * (dedup via unique index). + */ + async createNotification(data: CreateNotificationData): Promise { + try { + await pool.query( + `INSERT INTO notifications + (user_id, typ, titel, nachricht, schwere, link, quell_id, quell_typ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (user_id, quell_typ, quell_id) WHERE NOT gelesen AND quell_typ IS NOT NULL AND quell_id IS NOT NULL + DO NOTHING`, + [ + data.user_id, + data.typ, + data.titel, + data.nachricht, + data.schwere ?? 'info', + data.link ?? null, + data.quell_id ?? null, + data.quell_typ ?? null, + ] + ); + } catch (error) { + logger.error('NotificationService.createNotification failed', { error }); + // Non-fatal — don't propagate to callers + } + } + + /** Deletes read notifications older than 90 days for all users. */ + async deleteOldRead(): Promise { + try { + const result = await pool.query( + `DELETE FROM notifications WHERE gelesen = TRUE AND gelesen_am < NOW() - INTERVAL '90 days'` + ); + if ((result.rowCount ?? 0) > 0) { + logger.info(`NotificationService.deleteOldRead: removed ${result.rowCount} old notifications`); + } + } catch (error) { + logger.error('NotificationService.deleteOldRead failed', { error }); + } + } +} + +export default new NotificationService(); diff --git a/frontend/src/components/dashboard/UserProfile.tsx b/frontend/src/components/dashboard/UserProfile.tsx index 69427a1..468089d 100644 --- a/frontend/src/components/dashboard/UserProfile.tsx +++ b/frontend/src/components/dashboard/UserProfile.tsx @@ -1,139 +1,52 @@ import React from 'react'; -import { - Card, - CardContent, - Avatar, - Typography, - Box, - Chip, -} from '@mui/material'; +import { Avatar, Box, Paper, Typography } from '@mui/material'; import { User } from '../../types/auth.types'; interface UserProfileProps { user: User; } -const UserProfile: React.FC = ({ user }) => { - // Get first letter of name for avatar - const getInitials = (name: string): string => { - return name.charAt(0).toUpperCase(); - }; +function getGreeting(): string { + const h = new Date().getHours(); + if (h >= 5 && h <= 10) return 'Guten Morgen'; + if (h >= 11 && h <= 13) return 'Mahlzeit'; + if (h >= 14 && h <= 16) return 'Guten Nachmittag'; + if (h >= 17 && h <= 21) return 'Guten Abend'; + return 'Gute Nacht'; +} - // Format date (placeholder until we have actual dates) - const formatDate = (date?: string): string => { - if (!date) return 'Nicht verfügbar'; - return new Date(date).toLocaleDateString('de-DE', { - day: '2-digit', - month: '2-digit', - year: 'numeric', - }); - }; +const UserProfile: React.FC = ({ user }) => { + const firstName = user.given_name || user.name.split(' ')[0]; + const initials = (user.given_name?.[0] || '') + (user.family_name?.[0] || '') || user.name?.[0] || '?'; return ( - - - + - {/* Avatar */} - - {getInitials(user.name)} - - - {/* User Info */} - - - Willkommen zurück, {user.given_name || user.name.split(' ')[0]}! - - - {user.name} - - - {user.email} - - {user.preferred_username && ( - - @{user.preferred_username} - - )} - - - - {user.groups && user.groups.length > 0 && ( - 1 ? 'n' : ''}`} - size="small" - sx={{ - bgcolor: 'rgba(255, 255, 255, 0.2)', - color: 'white', - }} - /> - )} - - - - {/* Additional Info */} - - - - Letzter Login - - - Heute - - - - - Mitglied seit - - - {formatDate()} - - - - - - + {initials.toUpperCase()} + + + {getGreeting()}, {firstName}! + + + ); }; diff --git a/frontend/src/components/shared/Header.tsx b/frontend/src/components/shared/Header.tsx index 5015b69..5aa465c 100644 --- a/frontend/src/components/shared/Header.tsx +++ b/frontend/src/components/shared/Header.tsx @@ -1,7 +1,6 @@ -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import { AppBar, - Badge, Toolbar, Typography, IconButton, @@ -21,7 +20,7 @@ import { } from '@mui/icons-material'; import { useAuth } from '../../contexts/AuthContext'; import { useNavigate } from 'react-router-dom'; -import { atemschutzApi } from '../../services/atemschutz'; +import NotificationBell from './NotificationBell'; interface HeaderProps { onMenuClick: () => void; @@ -31,22 +30,6 @@ function Header({ onMenuClick }: HeaderProps) { const { user, logout } = useAuth(); const navigate = useNavigate(); const [anchorEl, setAnchorEl] = useState(null); - const [warningCount, setWarningCount] = useState(0); - - // Fetch personal warning count for badge - useEffect(() => { - if (!user) return; - atemschutzApi.getMyStatus() - .then((record) => { - if (!record) return; - let count = 0; - const THRESHOLD = 60; - if (record.untersuchung_tage_rest !== null && record.untersuchung_tage_rest <= THRESHOLD) count++; - if (record.leistungstest_tage_rest !== null && record.leistungstest_tage_rest <= THRESHOLD) count++; - setWarningCount(count); - }) - .catch(() => { /* non-critical */ }); - }, [user]); const handleMenuOpen = (event: React.MouseEvent) => { setAnchorEl(event.currentTarget); @@ -103,30 +86,26 @@ function Header({ onMenuClick }: HeaderProps) { {user && ( <> + + - - - {getInitials()} - - + {getInitials()} + {user.email} - {warningCount > 0 && ( - - {warningCount} persönliche{warningCount !== 1 ? ' Warnungen' : ' Warnung'} - - )} diff --git a/frontend/src/components/shared/NotificationBell.tsx b/frontend/src/components/shared/NotificationBell.tsx new file mode 100644 index 0000000..75f7928 --- /dev/null +++ b/frontend/src/components/shared/NotificationBell.tsx @@ -0,0 +1,225 @@ +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { + Badge, + Box, + Button, + CircularProgress, + Divider, + IconButton, + List, + ListItem, + ListItemButton, + ListItemText, + Popover, + Tooltip, + Typography, +} from '@mui/material'; +import { + Notifications as BellIcon, + NotificationsNone as BellEmptyIcon, + Circle as CircleIcon, +} from '@mui/icons-material'; +import { useNavigate } from 'react-router-dom'; +import { notificationsApi } from '../../services/notifications'; +import type { Notification, NotificationSchwere } from '../../types/notification.types'; + +const POLL_INTERVAL_MS = 60_000; // 60 seconds + +function schwerebColor(schwere: NotificationSchwere): 'error' | 'warning' | 'info' { + if (schwere === 'fehler') return 'error'; + if (schwere === 'warnung') return 'warning'; + return 'info'; +} + +function formatRelative(iso: string): string { + const diff = Date.now() - new Date(iso).getTime(); + const minutes = Math.floor(diff / 60_000); + if (minutes < 2) return 'Gerade eben'; + if (minutes < 60) return `vor ${minutes} Min.`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `vor ${hours} Std.`; + const days = Math.floor(hours / 24); + return `vor ${days} Tag${days !== 1 ? 'en' : ''}`; +} + +const NotificationBell: React.FC = () => { + const navigate = useNavigate(); + const [unreadCount, setUnreadCount] = useState(0); + const [notifications, setNotifications] = useState([]); + const [loadingList, setLoadingList] = useState(false); + const [anchorEl, setAnchorEl] = useState(null); + const pollTimerRef = useRef | null>(null); + + const fetchUnreadCount = useCallback(async () => { + try { + const count = await notificationsApi.getUnreadCount(); + setUnreadCount(count); + } catch { + // non-critical + } + }, []); + + // Poll unread count every 60 seconds + useEffect(() => { + fetchUnreadCount(); + pollTimerRef.current = setInterval(fetchUnreadCount, POLL_INTERVAL_MS); + return () => { + if (pollTimerRef.current) clearInterval(pollTimerRef.current); + }; + }, [fetchUnreadCount]); + + const handleOpen = async (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + setLoadingList(true); + try { + const data = await notificationsApi.getNotifications(); + setNotifications(data); + // Refresh count after loading full list + const count = await notificationsApi.getUnreadCount(); + setUnreadCount(count); + } catch { + // non-critical + } finally { + setLoadingList(false); + } + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + const handleClickNotification = async (n: Notification) => { + if (!n.gelesen) { + try { + await notificationsApi.markRead(n.id); + setNotifications((prev) => + prev.map((item) => item.id === n.id ? { ...item, gelesen: true } : item) + ); + setUnreadCount((c) => Math.max(0, c - 1)); + } catch { + // non-critical + } + } + handleClose(); + if (n.link) { + navigate(n.link); + } + }; + + const handleMarkAllRead = async () => { + try { + await notificationsApi.markAllRead(); + setNotifications((prev) => prev.map((n) => ({ ...n, gelesen: true }))); + setUnreadCount(0); + } catch { + // non-critical + } + }; + + const open = Boolean(anchorEl); + const hasUnread = unreadCount > 0; + + return ( + <> + + + + {hasUnread ? : } + + + + + + {/* Header */} + + + Benachrichtigungen + + {unreadCount > 0 && ( + + )} + + + + {/* Body */} + + {loadingList ? ( + + + + ) : notifications.length === 0 ? ( + + + + Keine Benachrichtigungen + + + ) : ( + + {notifications.map((n, idx) => ( + + {idx > 0 && } + + handleClickNotification(n)} + sx={{ + py: 1.5, + px: 2, + bgcolor: n.gelesen ? 'transparent' : 'action.hover', + alignItems: 'flex-start', + gap: 1, + }} + > + + + {n.titel} + + } + secondary={ + + + {n.nachricht} + + + {formatRelative(n.erstellt_am)} + + + } + disableTypography + /> + + + + ))} + + )} + + + + ); +}; + +export default NotificationBell; diff --git a/frontend/src/hooks/usePermissions.ts b/frontend/src/hooks/usePermissions.ts index be7b05f..7e56a9f 100644 --- a/frontend/src/hooks/usePermissions.ts +++ b/frontend/src/hooks/usePermissions.ts @@ -1,13 +1,27 @@ import { useAuth } from '../contexts/AuthContext'; +import { AusruestungKategorie } from '../types/equipment.types'; export function usePermissions() { const { user } = useAuth(); const groups = user?.groups ?? []; + const isAdmin = groups.includes('dashboard_admin'); + const isFahrmeister = groups.includes('dashboard_fahrmeister'); + const isZeugmeister = groups.includes('dashboard_zeugmeister'); + return { - isAdmin: groups.includes('dashboard_admin'), - canChangeStatus: groups.includes('dashboard_admin') || groups.includes('dashboard_fahrmeister'), - canManageEquipment: groups.includes('dashboard_admin') || groups.includes('dashboard_fahrmeister'), + isAdmin, + isFahrmeister, + isZeugmeister, + canChangeStatus: isAdmin || isFahrmeister || isZeugmeister, + canManageEquipment: isAdmin || isFahrmeister || isZeugmeister, + canManageMotorizedEquipment: isAdmin || isFahrmeister, + canManageNonMotorizedEquipment: isAdmin || isZeugmeister, + canManageCategory: (kategorie: AusruestungKategorie | null | undefined): boolean => { + if (isAdmin) return true; + if (!kategorie) return false; + return kategorie.motorisiert ? isFahrmeister : isZeugmeister; + }, groups, }; } diff --git a/frontend/src/pages/AusruestungDetail.tsx b/frontend/src/pages/AusruestungDetail.tsx index e25c362..451a773 100644 --- a/frontend/src/pages/AusruestungDetail.tsx +++ b/frontend/src/pages/AusruestungDetail.tsx @@ -563,7 +563,7 @@ const WartungTab: React.FC = ({ equipmentId, wartungslog, onAdd function AusruestungDetailPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); - const { isAdmin, canChangeStatus } = usePermissions(); + const { isAdmin, canManageCategory } = usePermissions(); const notification = useNotification(); const [equipment, setEquipment] = useState(null); @@ -630,6 +630,16 @@ function AusruestungDetailPage() { equipment.pruefung_tage_bis_faelligkeit !== null && equipment.pruefung_tage_bis_faelligkeit < 0; + // Derive an inline category object so canManageCategory can do the motorisiert check + const equipmentKategorie = { + id: equipment.kategorie_id, + name: equipment.kategorie_name, + kurzname: equipment.kategorie_kurzname, + sortierung: 0, + motorisiert: equipment.kategorie_motorisiert, + }; + const canWrite = canManageCategory(equipmentKategorie); + const subtitle = [ equipment.kategorie_name, equipment.seriennummer ? `SN: ${equipment.seriennummer}` : null, @@ -665,7 +675,7 @@ function AusruestungDetailPage() { label={AusruestungStatusLabel[equipment.status]} color={STATUS_CHIP_COLOR[equipment.status]} /> - {canChangeStatus && ( + {canWrite && ( @@ -723,7 +733,7 @@ function AusruestungDetailPage() { equipmentId={equipment.id} wartungslog={equipment.wartungslog ?? []} onAdded={fetchEquipment} - canWrite={canChangeStatus} + canWrite={canWrite} /> diff --git a/frontend/src/pages/AusruestungForm.tsx b/frontend/src/pages/AusruestungForm.tsx index 6f3694f..323ed84 100644 --- a/frontend/src/pages/AusruestungForm.tsx +++ b/frontend/src/pages/AusruestungForm.tsx @@ -82,11 +82,11 @@ function toDateInput(iso: string | null | undefined): string { function AusruestungForm() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); - const { canChangeStatus } = usePermissions(); + const { canManageEquipment } = usePermissions(); const isEditMode = Boolean(id); // -- Permission guard: only authorized users may create or edit equipment ---- - if (!canChangeStatus) { + if (!canManageEquipment) { return ( diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index cbae5b0..481e846 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -13,8 +13,6 @@ import UpcomingEventsWidget from '../components/dashboard/UpcomingEventsWidget'; import AtemschutzDashboardCard from '../components/atemschutz/AtemschutzDashboardCard'; import EquipmentDashboardCard from '../components/equipment/EquipmentDashboardCard'; import VehicleDashboardCard from '../components/vehicles/VehicleDashboardCard'; -import PersonalWarningsBanner from '../components/dashboard/PersonalWarningsBanner'; - function Dashboard() { const { user } = useAuth(); const canViewAtemschutz = user?.groups?.some(g => @@ -56,17 +54,6 @@ function Dashboard() { )} - {/* Personal Warnings Banner — full width, conditionally rendered */} - {user && ( - - - - - - - - )} - {/* Vehicle Status Card */} diff --git a/frontend/src/pages/Kalender.tsx b/frontend/src/pages/Kalender.tsx index a6ddd43..dbc2c14 100644 --- a/frontend/src/pages/Kalender.tsx +++ b/frontend/src/pages/Kalender.tsx @@ -837,6 +837,19 @@ function VeranstaltungFormDialog({ }, [open, editingEvent]); const handleChange = (field: keyof CreateVeranstaltungInput, value: unknown) => { + if (field === 'kategorie_id' && !editingEvent) { + // Auto-fill zielgruppen / alle_gruppen from the selected category (only for new events) + const kat = kategorien.find((k) => k.id === value); + if (kat) { + setForm((prev) => ({ + ...prev, + kategorie_id: value as string | null, + alle_gruppen: kat.alle_gruppen, + zielgruppen: kat.alle_gruppen ? [] : kat.zielgruppen, + })); + return; + } + } setForm((prev) => ({ ...prev, [field]: value })); }; diff --git a/frontend/src/pages/VeranstaltungKategorien.tsx b/frontend/src/pages/VeranstaltungKategorien.tsx index 9028d20..51dea92 100644 --- a/frontend/src/pages/VeranstaltungKategorien.tsx +++ b/frontend/src/pages/VeranstaltungKategorien.tsx @@ -48,6 +48,7 @@ interface KategorieFormData { beschreibung: string; farbe: string; icon: string; + alle_gruppen: boolean; zielgruppen: string[]; } @@ -56,6 +57,7 @@ const EMPTY_FORM: KategorieFormData = { beschreibung: '', farbe: '#1976d2', icon: '', + alle_gruppen: false, zielgruppen: [], }; @@ -80,7 +82,8 @@ function KategorieDialog({ open, onClose, onSaved, editing, groups }: KategorieD beschreibung: editing.beschreibung ?? '', farbe: editing.farbe, icon: editing.icon ?? '', - zielgruppen: editing.zielgruppen ?? [], + alle_gruppen: editing.alle_gruppen ?? false, + zielgruppen: editing.alle_gruppen ? [] : (editing.zielgruppen ?? []), }); } else { setForm({ ...EMPTY_FORM }); @@ -112,7 +115,8 @@ function KategorieDialog({ open, onClose, onSaved, editing, groups }: KategorieD beschreibung: form.beschreibung.trim() || undefined, farbe: form.farbe, icon: form.icon.trim() || undefined, - zielgruppen: form.zielgruppen, + alle_gruppen: form.alle_gruppen, + zielgruppen: form.alle_gruppen ? [] : form.zielgruppen, }; if (editing) { await eventsApi.updateKategorie(editing.id, payload); @@ -188,7 +192,22 @@ function KategorieDialog({ open, onClose, onSaved, editing, groups }: KategorieD placeholder="z.B. EmojiEvents" helperText="Name eines MUI Material Icons" /> - {/* Group checkboxes */} + {/* alle_gruppen toggle */} + setForm((prev) => ({ + ...prev, + alle_gruppen: e.target.checked, + zielgruppen: e.target.checked ? [] : prev.zielgruppen, + }))} + size="small" + /> + } + label="Alle Mitglieder" + /> + {/* Group checkboxes — disabled when alle_gruppen is set */} {groups.length > 0 && ( @@ -203,6 +222,7 @@ function KategorieDialog({ open, onClose, onSaved, editing, groups }: KategorieD checked={form.zielgruppen.includes(group.id)} onChange={() => handleGroupToggle(group.id)} size="small" + disabled={form.alle_gruppen} /> } label={group.label} @@ -435,7 +455,15 @@ export default function VeranstaltungKategorien() { {/* Gruppen */} - {(kat.zielgruppen ?? []).length === 0 + {kat.alle_gruppen ? ( + + ) : (kat.zielgruppen ?? []).length === 0 ? : (kat.zielgruppen ?? []).map((gId) => { const group = groups.find((g) => g.id === gId); diff --git a/frontend/src/pages/Veranstaltungen.tsx b/frontend/src/pages/Veranstaltungen.tsx index c6fc394..d6ac781 100644 --- a/frontend/src/pages/Veranstaltungen.tsx +++ b/frontend/src/pages/Veranstaltungen.tsx @@ -610,6 +610,19 @@ function EventFormDialog({ }, [open, editingEvent]); const handleChange = (field: keyof CreateVeranstaltungInput, value: unknown) => { + if (field === 'kategorie_id' && !editingEvent) { + // Auto-fill zielgruppen / alle_gruppen from the selected category (only for new events) + const kat = kategorien.find((k) => k.id === value); + if (kat) { + setForm((prev) => ({ + ...prev, + kategorie_id: value as string | null, + alle_gruppen: kat.alle_gruppen, + zielgruppen: kat.alle_gruppen ? [] : kat.zielgruppen, + })); + return; + } + } setForm((prev) => ({ ...prev, [field]: value })); }; diff --git a/frontend/src/services/notifications.ts b/frontend/src/services/notifications.ts new file mode 100644 index 0000000..ff500e0 --- /dev/null +++ b/frontend/src/services/notifications.ts @@ -0,0 +1,31 @@ +import { api } from './api'; +import type { Notification } from '../types/notification.types'; + +async function unwrap( + promise: ReturnType> +): Promise { + const response = await promise; + if (response.data?.data === undefined || response.data?.data === null) { + throw new Error('Invalid API response'); + } + return response.data.data; +} + +export const notificationsApi = { + async getNotifications(): Promise { + return unwrap(api.get<{ success: boolean; data: Notification[] }>('/api/notifications')); + }, + + async getUnreadCount(): Promise { + const data = await unwrap(api.get<{ success: boolean; data: { count: number } }>('/api/notifications/count')); + return data.count; + }, + + async markRead(id: string): Promise { + await api.patch(`/api/notifications/${id}/read`); + }, + + async markAllRead(): Promise { + await api.post('/api/notifications/mark-all-read'); + }, +}; diff --git a/frontend/src/types/equipment.types.ts b/frontend/src/types/equipment.types.ts index f603569..3353bb7 100644 --- a/frontend/src/types/equipment.types.ts +++ b/frontend/src/types/equipment.types.ts @@ -21,10 +21,11 @@ export type AusruestungWartungslogArt = 'Prüfung' | 'Reparatur' | 'Sonstiges'; // ── Lookup Entity ──────────────────────────────────────────────────────────── export interface AusruestungKategorie { - id: string; - name: string; - kurzname: string; + id: string; + name: string; + kurzname: string; sortierung: number; + motorisiert: boolean; } // ── API Response Shapes ────────────────────────────────────────────────────── @@ -35,6 +36,7 @@ export interface AusruestungListItem { kategorie_id: string; kategorie_name: string; kategorie_kurzname: string; + kategorie_motorisiert: boolean; seriennummer: string | null; inventarnummer: string | null; hersteller: string | null; diff --git a/frontend/src/types/events.types.ts b/frontend/src/types/events.types.ts index 0804aaa..971623a 100644 --- a/frontend/src/types/events.types.ts +++ b/frontend/src/types/events.types.ts @@ -16,6 +16,7 @@ export interface VeranstaltungKategorie { farbe: string; // hex color e.g. '#1976d2' icon?: string | null; // MUI icon name zielgruppen: string[]; + alle_gruppen: boolean; erstellt_am: string; aktualisiert_am: string; } diff --git a/frontend/src/types/notification.types.ts b/frontend/src/types/notification.types.ts new file mode 100644 index 0000000..d5084ab --- /dev/null +++ b/frontend/src/types/notification.types.ts @@ -0,0 +1,20 @@ +// --------------------------------------------------------------------------- +// Notification types — mirrors backend model +// --------------------------------------------------------------------------- + +export type NotificationSchwere = 'info' | 'warnung' | 'fehler'; + +export interface Notification { + id: string; + user_id: string; + typ: string; + titel: string; + nachricht: string; + schwere: NotificationSchwere; + gelesen: boolean; + gelesen_am: string | null; + link: string | null; + quell_id: string | null; + quell_typ: string | null; + erstellt_am: string; +}