add features
This commit is contained in:
@@ -80,6 +80,7 @@ import nextcloudRoutes from './routes/nextcloud.routes';
|
|||||||
import atemschutzRoutes from './routes/atemschutz.routes';
|
import atemschutzRoutes from './routes/atemschutz.routes';
|
||||||
import eventsRoutes from './routes/events.routes';
|
import eventsRoutes from './routes/events.routes';
|
||||||
import bookingRoutes from './routes/booking.routes';
|
import bookingRoutes from './routes/booking.routes';
|
||||||
|
import notificationRoutes from './routes/notification.routes';
|
||||||
|
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
app.use('/api/user', userRoutes);
|
app.use('/api/user', userRoutes);
|
||||||
@@ -93,6 +94,7 @@ app.use('/api/atemschutz', atemschutzRoutes);
|
|||||||
app.use('/api/nextcloud/talk', nextcloudRoutes);
|
app.use('/api/nextcloud/talk', nextcloudRoutes);
|
||||||
app.use('/api/events', eventsRoutes);
|
app.use('/api/events', eventsRoutes);
|
||||||
app.use('/api/bookings', bookingRoutes);
|
app.use('/api/bookings', bookingRoutes);
|
||||||
|
app.use('/api/notifications', notificationRoutes);
|
||||||
|
|
||||||
// 404 handler
|
// 404 handler
|
||||||
app.use(notFoundHandler);
|
app.use(notFoundHandler);
|
||||||
|
|||||||
@@ -89,6 +89,28 @@ function getUserId(req: Request): string {
|
|||||||
return req.user!.id;
|
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<boolean> {
|
||||||
|
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 ────────────────────────────────────────────────────────────────
|
// ── Controller ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class EquipmentController {
|
class EquipmentController {
|
||||||
@@ -193,6 +215,12 @@ class EquipmentController {
|
|||||||
});
|
});
|
||||||
return;
|
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));
|
const equipment = await equipmentService.createEquipment(parsed.data, getUserId(req));
|
||||||
res.status(201).json({ success: true, data: equipment });
|
res.status(201).json({ success: true, data: equipment });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -221,6 +249,25 @@ class EquipmentController {
|
|||||||
res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' });
|
res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' });
|
||||||
return;
|
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));
|
const equipment = await equipmentService.updateEquipment(id, parsed.data, getUserId(req));
|
||||||
if (!equipment) {
|
if (!equipment) {
|
||||||
res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' });
|
res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' });
|
||||||
@@ -253,6 +300,19 @@ class EquipmentController {
|
|||||||
});
|
});
|
||||||
return;
|
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(
|
await equipmentService.updateStatus(
|
||||||
id, parsed.data.status, parsed.data.bemerkung, getUserId(req)
|
id, parsed.data.status, parsed.data.bemerkung, getUserId(req)
|
||||||
);
|
);
|
||||||
@@ -302,6 +362,19 @@ class EquipmentController {
|
|||||||
});
|
});
|
||||||
return;
|
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));
|
const entry = await equipmentService.addWartungslog(id, parsed.data, getUserId(req));
|
||||||
res.status(201).json({ success: true, data: entry });
|
res.status(201).json({ success: true, data: entry });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
72
backend/src/controllers/notification.controller.ts
Normal file
72
backend/src/controllers/notification.controller.ts
Normal file
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
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<void> {
|
||||||
|
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();
|
||||||
32
backend/src/database/migrations/020_create_notifications.sql
Normal file
32
backend/src/database/migrations/020_create_notifications.sql
Normal file
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
263
backend/src/jobs/notification-generation.job.ts
Normal file
263
backend/src/jobs/notification-generation.job.ts
Normal file
@@ -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<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Core generation function
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function runNotificationGeneration(): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ export interface AusruestungKategorie {
|
|||||||
name: string;
|
name: string;
|
||||||
kurzname: string;
|
kurzname: string;
|
||||||
sortierung: number;
|
sortierung: number;
|
||||||
|
motorisiert: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Core Entity ───────────────────────────────────────────────────────────────
|
// ── Core Entity ───────────────────────────────────────────────────────────────
|
||||||
@@ -76,6 +77,7 @@ export interface AusruestungListItem {
|
|||||||
updated_at: Date;
|
updated_at: Date;
|
||||||
kategorie_name: string;
|
kategorie_name: string;
|
||||||
kategorie_kurzname: string;
|
kategorie_kurzname: string;
|
||||||
|
kategorie_motorisiert: boolean;
|
||||||
fahrzeug_bezeichnung: string | null;
|
fahrzeug_bezeichnung: string | null;
|
||||||
fahrzeug_kurzname: string | null;
|
fahrzeug_kurzname: string | null;
|
||||||
pruefung_tage_bis_faelligkeit: number | null;
|
pruefung_tage_bis_faelligkeit: number | null;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface VeranstaltungKategorie {
|
|||||||
farbe?: string | null;
|
farbe?: string | null;
|
||||||
icon?: string | null;
|
icon?: string | null;
|
||||||
zielgruppen: string[];
|
zielgruppen: string[];
|
||||||
|
alle_gruppen: boolean;
|
||||||
erstellt_von?: string | null;
|
erstellt_von?: string | null;
|
||||||
erstellt_am: Date;
|
erstellt_am: Date;
|
||||||
aktualisiert_am: Date;
|
aktualisiert_am: Date;
|
||||||
@@ -91,6 +92,7 @@ export const CreateKategorieSchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
icon: z.string().max(100).optional(),
|
icon: z.string().max(100).optional(),
|
||||||
zielgruppen: z.array(z.string()).optional(),
|
zielgruppen: z.array(z.string()).optional(),
|
||||||
|
alle_gruppen: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type CreateKategorieData = z.infer<typeof CreateKategorieSchema>;
|
export type CreateKategorieData = z.infer<typeof CreateKategorieSchema>;
|
||||||
|
|||||||
31
backend/src/models/notification.model.ts
Normal file
31
backend/src/models/notification.model.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { authenticate } from '../middleware/auth.middleware';
|
|||||||
import { requireGroups } from '../middleware/rbac.middleware';
|
import { requireGroups } from '../middleware/rbac.middleware';
|
||||||
|
|
||||||
const ADMIN_GROUPS = ['dashboard_admin'];
|
const ADMIN_GROUPS = ['dashboard_admin'];
|
||||||
const WRITE_GROUPS = ['dashboard_admin', 'dashboard_fahrmeister'];
|
const WRITE_GROUPS = ['dashboard_admin', 'dashboard_fahrmeister', 'dashboard_zeugmeister'];
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
|||||||
13
backend/src/routes/notification.routes.ts
Normal file
13
backend/src/routes/notification.routes.ts
Normal file
@@ -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;
|
||||||
@@ -3,6 +3,7 @@ import environment from './config/environment';
|
|||||||
import logger from './utils/logger';
|
import logger from './utils/logger';
|
||||||
import { testConnection, closePool, runMigrations } from './config/database';
|
import { testConnection, closePool, runMigrations } from './config/database';
|
||||||
import { startAuditCleanupJob, stopAuditCleanupJob } from './jobs/audit-cleanup.job';
|
import { startAuditCleanupJob, stopAuditCleanupJob } from './jobs/audit-cleanup.job';
|
||||||
|
import { startNotificationJob, stopNotificationJob } from './jobs/notification-generation.job';
|
||||||
|
|
||||||
const startServer = async (): Promise<void> => {
|
const startServer = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
@@ -20,6 +21,9 @@ const startServer = async (): Promise<void> => {
|
|||||||
// Start the GDPR IP anonymisation job
|
// Start the GDPR IP anonymisation job
|
||||||
startAuditCleanupJob();
|
startAuditCleanupJob();
|
||||||
|
|
||||||
|
// Start the notification generation job
|
||||||
|
startNotificationJob();
|
||||||
|
|
||||||
// Start the server
|
// Start the server
|
||||||
const server = app.listen(environment.port, () => {
|
const server = app.listen(environment.port, () => {
|
||||||
logger.info('Server started successfully', {
|
logger.info('Server started successfully', {
|
||||||
@@ -35,6 +39,7 @@ const startServer = async (): Promise<void> => {
|
|||||||
|
|
||||||
// Stop scheduled jobs first
|
// Stop scheduled jobs first
|
||||||
stopAuditCleanupJob();
|
stopAuditCleanupJob();
|
||||||
|
stopNotificationJob();
|
||||||
|
|
||||||
server.close(async () => {
|
server.close(async () => {
|
||||||
logger.info('HTTP server closed');
|
logger.info('HTTP server closed');
|
||||||
|
|||||||
@@ -118,6 +118,19 @@ class EquipmentService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getCategoryById(id: string): Promise<AusruestungKategorie | null> {
|
||||||
|
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
|
// CRUD
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ class EventsService {
|
|||||||
/** Returns all event categories ordered by name. */
|
/** Returns all event categories ordered by name. */
|
||||||
async getKategorien(): Promise<VeranstaltungKategorie[]> {
|
async getKategorien(): Promise<VeranstaltungKategorie[]> {
|
||||||
const result = await pool.query(`
|
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
|
FROM veranstaltung_kategorien
|
||||||
ORDER BY name ASC
|
ORDER BY name ASC
|
||||||
`);
|
`);
|
||||||
@@ -121,6 +121,7 @@ class EventsService {
|
|||||||
farbe: row.farbe ?? null,
|
farbe: row.farbe ?? null,
|
||||||
icon: row.icon ?? null,
|
icon: row.icon ?? null,
|
||||||
zielgruppen: row.zielgruppen ?? [],
|
zielgruppen: row.zielgruppen ?? [],
|
||||||
|
alle_gruppen: row.alle_gruppen ?? false,
|
||||||
erstellt_von: row.erstellt_von ?? null,
|
erstellt_von: row.erstellt_von ?? null,
|
||||||
erstellt_am: new Date(row.erstellt_am),
|
erstellt_am: new Date(row.erstellt_am),
|
||||||
aktualisiert_am: new Date(row.aktualisiert_am),
|
aktualisiert_am: new Date(row.aktualisiert_am),
|
||||||
@@ -130,10 +131,10 @@ class EventsService {
|
|||||||
/** Creates a new event category. */
|
/** Creates a new event category. */
|
||||||
async createKategorie(data: CreateKategorieData, userId: string): Promise<VeranstaltungKategorie> {
|
async createKategorie(data: CreateKategorieData, userId: string): Promise<VeranstaltungKategorie> {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`INSERT INTO veranstaltung_kategorien (name, beschreibung, farbe, icon, zielgruppen, erstellt_von)
|
`INSERT INTO veranstaltung_kategorien (name, beschreibung, farbe, icon, zielgruppen, alle_gruppen, erstellt_von)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
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`,
|
||||||
[data.name, data.beschreibung ?? null, data.farbe ?? null, data.icon ?? null, data.zielgruppen ?? [], userId]
|
[data.name, data.beschreibung ?? null, data.farbe ?? null, data.icon ?? null, data.zielgruppen ?? [], data.alle_gruppen ?? false, userId]
|
||||||
);
|
);
|
||||||
const row = result.rows[0];
|
const row = result.rows[0];
|
||||||
return {
|
return {
|
||||||
@@ -143,6 +144,7 @@ class EventsService {
|
|||||||
farbe: row.farbe ?? null,
|
farbe: row.farbe ?? null,
|
||||||
icon: row.icon ?? null,
|
icon: row.icon ?? null,
|
||||||
zielgruppen: row.zielgruppen ?? [],
|
zielgruppen: row.zielgruppen ?? [],
|
||||||
|
alle_gruppen: row.alle_gruppen ?? false,
|
||||||
erstellt_von: row.erstellt_von ?? null,
|
erstellt_von: row.erstellt_von ?? null,
|
||||||
erstellt_am: new Date(row.erstellt_am),
|
erstellt_am: new Date(row.erstellt_am),
|
||||||
aktualisiert_am: new Date(row.aktualisiert_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.farbe !== undefined) { fields.push(`farbe = $${idx++}`); values.push(data.farbe); }
|
||||||
if (data.icon !== undefined) { fields.push(`icon = $${idx++}`); values.push(data.icon); }
|
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.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) {
|
if (fields.length === 0) {
|
||||||
// Nothing to update — return the existing record
|
// Nothing to update — return the existing record
|
||||||
const existing = await pool.query(
|
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`,
|
FROM veranstaltung_kategorien WHERE id = $1`,
|
||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
@@ -173,6 +176,7 @@ class EventsService {
|
|||||||
return {
|
return {
|
||||||
id: row.id, name: row.name, beschreibung: row.beschreibung ?? null,
|
id: row.id, name: row.name, beschreibung: row.beschreibung ?? null,
|
||||||
farbe: row.farbe ?? null, icon: row.icon ?? null, zielgruppen: row.zielgruppen ?? [],
|
farbe: row.farbe ?? null, icon: row.icon ?? null, zielgruppen: row.zielgruppen ?? [],
|
||||||
|
alle_gruppen: row.alle_gruppen ?? false,
|
||||||
erstellt_von: row.erstellt_von ?? null,
|
erstellt_von: row.erstellt_von ?? null,
|
||||||
erstellt_am: new Date(row.erstellt_am), aktualisiert_am: new Date(row.aktualisiert_am),
|
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(
|
const result = await pool.query(
|
||||||
`UPDATE veranstaltung_kategorien SET ${fields.join(', ')}
|
`UPDATE veranstaltung_kategorien SET ${fields.join(', ')}
|
||||||
WHERE id = $${idx}
|
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
|
values
|
||||||
);
|
);
|
||||||
if (result.rows.length === 0) return null;
|
if (result.rows.length === 0) return null;
|
||||||
@@ -192,6 +196,7 @@ class EventsService {
|
|||||||
return {
|
return {
|
||||||
id: row.id, name: row.name, beschreibung: row.beschreibung ?? null,
|
id: row.id, name: row.name, beschreibung: row.beschreibung ?? null,
|
||||||
farbe: row.farbe ?? null, icon: row.icon ?? null, zielgruppen: row.zielgruppen ?? [],
|
farbe: row.farbe ?? null, icon: row.icon ?? null, zielgruppen: row.zielgruppen ?? [],
|
||||||
|
alle_gruppen: row.alle_gruppen ?? false,
|
||||||
erstellt_von: row.erstellt_von ?? null,
|
erstellt_von: row.erstellt_von ?? null,
|
||||||
erstellt_am: new Date(row.erstellt_am), aktualisiert_am: new Date(row.aktualisiert_am),
|
erstellt_am: new Date(row.erstellt_am), aktualisiert_am: new Date(row.aktualisiert_am),
|
||||||
};
|
};
|
||||||
|
|||||||
131
backend/src/services/notification.service.ts
Normal file
131
backend/src/services/notification.service.ts
Normal file
@@ -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<Notification[]> {
|
||||||
|
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<number> {
|
||||||
|
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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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();
|
||||||
@@ -1,139 +1,52 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
import { Avatar, Box, Paper, Typography } from '@mui/material';
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
Avatar,
|
|
||||||
Typography,
|
|
||||||
Box,
|
|
||||||
Chip,
|
|
||||||
} from '@mui/material';
|
|
||||||
import { User } from '../../types/auth.types';
|
import { User } from '../../types/auth.types';
|
||||||
|
|
||||||
interface UserProfileProps {
|
interface UserProfileProps {
|
||||||
user: User;
|
user: User;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UserProfile: React.FC<UserProfileProps> = ({ user }) => {
|
function getGreeting(): string {
|
||||||
// Get first letter of name for avatar
|
const h = new Date().getHours();
|
||||||
const getInitials = (name: string): string => {
|
if (h >= 5 && h <= 10) return 'Guten Morgen';
|
||||||
return name.charAt(0).toUpperCase();
|
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 UserProfile: React.FC<UserProfileProps> = ({ user }) => {
|
||||||
const formatDate = (date?: string): string => {
|
const firstName = user.given_name || user.name.split(' ')[0];
|
||||||
if (!date) return 'Nicht verfügbar';
|
const initials = (user.given_name?.[0] || '') + (user.family_name?.[0] || '') || user.name?.[0] || '?';
|
||||||
return new Date(date).toLocaleDateString('de-DE', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
year: 'numeric',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Paper
|
||||||
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
|
borderRadius: 2,
|
||||||
|
px: 3,
|
||||||
|
py: 1.5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardContent>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
flexDirection: { xs: 'column', sm: 'row' },
|
|
||||||
gap: 3,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Avatar */}
|
|
||||||
<Avatar
|
<Avatar
|
||||||
sx={{
|
sx={{
|
||||||
width: 80,
|
width: 40,
|
||||||
height: 80,
|
height: 40,
|
||||||
bgcolor: 'rgba(255,255,255,0.2)',
|
bgcolor: 'rgba(255,255,255,0.2)',
|
||||||
fontSize: '2rem',
|
fontSize: '1rem',
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{getInitials(user.name)}
|
{initials.toUpperCase()}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 500 }}>
|
||||||
{/* User Info */}
|
{getGreeting()}, {firstName}!
|
||||||
<Box sx={{ flex: 1, textAlign: { xs: 'center', sm: 'left' } }}>
|
|
||||||
<Typography variant="h5" component="div" gutterBottom>
|
|
||||||
Willkommen zurück, {user.given_name || user.name.split(' ')[0]}!
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ opacity: 0.75, mb: 0.5 }}>
|
|
||||||
{user.name}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ opacity: 0.9 }}>
|
|
||||||
{user.email}
|
|
||||||
</Typography>
|
|
||||||
{user.preferred_username && (
|
|
||||||
<Typography variant="body2" sx={{ opacity: 0.9 }}>
|
|
||||||
@{user.preferred_username}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
gap: 1,
|
|
||||||
mt: 2,
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
justifyContent: { xs: 'center', sm: 'flex-start' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Chip
|
|
||||||
label="Aktiv"
|
|
||||||
size="small"
|
|
||||||
sx={{
|
|
||||||
bgcolor: 'rgba(255, 255, 255, 0.2)',
|
|
||||||
color: 'white',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{user.groups && user.groups.length > 0 && (
|
|
||||||
<Chip
|
|
||||||
label={`${user.groups.length} Gruppe${user.groups.length > 1 ? 'n' : ''}`}
|
|
||||||
size="small"
|
|
||||||
sx={{
|
|
||||||
bgcolor: 'rgba(255, 255, 255, 0.2)',
|
|
||||||
color: 'white',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Additional Info */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 1,
|
|
||||||
textAlign: { xs: 'center', sm: 'right' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="caption" sx={{ opacity: 0.8 }}>
|
|
||||||
Letzter Login
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 'medium' }}>
|
|
||||||
Heute
|
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box>
|
</Paper>
|
||||||
<Typography variant="caption" sx={{ opacity: 0.8 }}>
|
|
||||||
Mitglied seit
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 'medium' }}>
|
|
||||||
{formatDate()}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
AppBar,
|
AppBar,
|
||||||
Badge,
|
|
||||||
Toolbar,
|
Toolbar,
|
||||||
Typography,
|
Typography,
|
||||||
IconButton,
|
IconButton,
|
||||||
@@ -21,7 +20,7 @@ import {
|
|||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { atemschutzApi } from '../../services/atemschutz';
|
import NotificationBell from './NotificationBell';
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
onMenuClick: () => void;
|
onMenuClick: () => void;
|
||||||
@@ -31,22 +30,6 @@ function Header({ onMenuClick }: HeaderProps) {
|
|||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(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<HTMLElement>) => {
|
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
|
||||||
setAnchorEl(event.currentTarget);
|
setAnchorEl(event.currentTarget);
|
||||||
@@ -103,18 +86,15 @@ function Header({ onMenuClick }: HeaderProps) {
|
|||||||
|
|
||||||
{user && (
|
{user && (
|
||||||
<>
|
<>
|
||||||
|
<NotificationBell />
|
||||||
|
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={handleMenuOpen}
|
onClick={handleMenuOpen}
|
||||||
size="small"
|
size="small"
|
||||||
aria-label="Benutzerkonto"
|
aria-label="Benutzerkonto"
|
||||||
aria-controls="user-menu"
|
aria-controls="user-menu"
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
>
|
sx={{ ml: 1 }}
|
||||||
<Badge
|
|
||||||
badgeContent={warningCount}
|
|
||||||
color="error"
|
|
||||||
overlap="circular"
|
|
||||||
invisible={warningCount === 0}
|
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
sx={{
|
sx={{
|
||||||
@@ -126,7 +106,6 @@ function Header({ onMenuClick }: HeaderProps) {
|
|||||||
>
|
>
|
||||||
{getInitials()}
|
{getInitials()}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</Badge>
|
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
||||||
<Menu
|
<Menu
|
||||||
@@ -154,11 +133,6 @@ function Header({ onMenuClick }: HeaderProps) {
|
|||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
{user.email}
|
{user.email}
|
||||||
</Typography>
|
</Typography>
|
||||||
{warningCount > 0 && (
|
|
||||||
<Typography variant="caption" color="error.main" sx={{ display: 'block', mt: 0.5 }}>
|
|
||||||
{warningCount} persönliche{warningCount !== 1 ? ' Warnungen' : ' Warnung'}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
<Divider />
|
<Divider />
|
||||||
<MenuItem onClick={handleProfile}>
|
<MenuItem onClick={handleProfile}>
|
||||||
|
|||||||
225
frontend/src/components/shared/NotificationBell.tsx
Normal file
225
frontend/src/components/shared/NotificationBell.tsx
Normal file
@@ -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<Notification[]>([]);
|
||||||
|
const [loadingList, setLoadingList] = useState(false);
|
||||||
|
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||||
|
const pollTimerRef = useRef<ReturnType<typeof setInterval> | 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<HTMLElement>) => {
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<Tooltip title="Benachrichtigungen">
|
||||||
|
<IconButton
|
||||||
|
color="inherit"
|
||||||
|
onClick={handleOpen}
|
||||||
|
aria-label="Benachrichtigungen öffnen"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<Badge badgeContent={unreadCount} color="error" invisible={!hasUnread}>
|
||||||
|
{hasUnread ? <BellIcon /> : <BellEmptyIcon />}
|
||||||
|
</Badge>
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Popover
|
||||||
|
open={open}
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
onClose={handleClose}
|
||||||
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||||
|
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||||
|
PaperProps={{ sx: { width: 360, maxHeight: 500, display: 'flex', flexDirection: 'column' } }}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<Box sx={{ px: 2, py: 1.5, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<Typography variant="subtitle1" fontWeight={600}>
|
||||||
|
Benachrichtigungen
|
||||||
|
</Typography>
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<Button size="small" onClick={handleMarkAllRead} sx={{ fontSize: '0.75rem' }}>
|
||||||
|
Alle als gelesen markieren
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<Box sx={{ overflowY: 'auto', flexGrow: 1 }}>
|
||||||
|
{loadingList ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||||
|
<CircularProgress size={28} />
|
||||||
|
</Box>
|
||||||
|
) : notifications.length === 0 ? (
|
||||||
|
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||||
|
<BellEmptyIcon sx={{ fontSize: 40, color: 'text.disabled', mb: 1 }} />
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Keine Benachrichtigungen
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<List disablePadding>
|
||||||
|
{notifications.map((n, idx) => (
|
||||||
|
<React.Fragment key={n.id}>
|
||||||
|
{idx > 0 && <Divider component="li" />}
|
||||||
|
<ListItem disablePadding>
|
||||||
|
<ListItemButton
|
||||||
|
onClick={() => handleClickNotification(n)}
|
||||||
|
sx={{
|
||||||
|
py: 1.5,
|
||||||
|
px: 2,
|
||||||
|
bgcolor: n.gelesen ? 'transparent' : 'action.hover',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CircleIcon
|
||||||
|
sx={{
|
||||||
|
fontSize: 8,
|
||||||
|
mt: 0.75,
|
||||||
|
flexShrink: 0,
|
||||||
|
color: n.gelesen ? 'transparent' : `${schwerebColor(n.schwere)}.main`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ListItemText
|
||||||
|
primary={
|
||||||
|
<Typography variant="body2" fontWeight={n.gelesen ? 400 : 600}>
|
||||||
|
{n.titel}
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
secondary={
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
{n.nachricht}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.disabled">
|
||||||
|
{formatRelative(n.erstellt_am)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
disableTypography
|
||||||
|
/>
|
||||||
|
</ListItemButton>
|
||||||
|
</ListItem>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Popover>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NotificationBell;
|
||||||
@@ -1,13 +1,27 @@
|
|||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { AusruestungKategorie } from '../types/equipment.types';
|
||||||
|
|
||||||
export function usePermissions() {
|
export function usePermissions() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const groups = user?.groups ?? [];
|
const groups = user?.groups ?? [];
|
||||||
|
|
||||||
|
const isAdmin = groups.includes('dashboard_admin');
|
||||||
|
const isFahrmeister = groups.includes('dashboard_fahrmeister');
|
||||||
|
const isZeugmeister = groups.includes('dashboard_zeugmeister');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isAdmin: groups.includes('dashboard_admin'),
|
isAdmin,
|
||||||
canChangeStatus: groups.includes('dashboard_admin') || groups.includes('dashboard_fahrmeister'),
|
isFahrmeister,
|
||||||
canManageEquipment: groups.includes('dashboard_admin') || groups.includes('dashboard_fahrmeister'),
|
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,
|
groups,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -563,7 +563,7 @@ const WartungTab: React.FC<WartungTabProps> = ({ equipmentId, wartungslog, onAdd
|
|||||||
function AusruestungDetailPage() {
|
function AusruestungDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { isAdmin, canChangeStatus } = usePermissions();
|
const { isAdmin, canManageCategory } = usePermissions();
|
||||||
const notification = useNotification();
|
const notification = useNotification();
|
||||||
|
|
||||||
const [equipment, setEquipment] = useState<AusruestungDetail | null>(null);
|
const [equipment, setEquipment] = useState<AusruestungDetail | null>(null);
|
||||||
@@ -630,6 +630,16 @@ function AusruestungDetailPage() {
|
|||||||
equipment.pruefung_tage_bis_faelligkeit !== null &&
|
equipment.pruefung_tage_bis_faelligkeit !== null &&
|
||||||
equipment.pruefung_tage_bis_faelligkeit < 0;
|
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 = [
|
const subtitle = [
|
||||||
equipment.kategorie_name,
|
equipment.kategorie_name,
|
||||||
equipment.seriennummer ? `SN: ${equipment.seriennummer}` : null,
|
equipment.seriennummer ? `SN: ${equipment.seriennummer}` : null,
|
||||||
@@ -665,7 +675,7 @@ function AusruestungDetailPage() {
|
|||||||
label={AusruestungStatusLabel[equipment.status]}
|
label={AusruestungStatusLabel[equipment.status]}
|
||||||
color={STATUS_CHIP_COLOR[equipment.status]}
|
color={STATUS_CHIP_COLOR[equipment.status]}
|
||||||
/>
|
/>
|
||||||
{canChangeStatus && (
|
{canWrite && (
|
||||||
<Tooltip title="Gerät bearbeiten">
|
<Tooltip title="Gerät bearbeiten">
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
@@ -714,7 +724,7 @@ function AusruestungDetailPage() {
|
|||||||
<UebersichtTab
|
<UebersichtTab
|
||||||
equipment={equipment}
|
equipment={equipment}
|
||||||
onStatusUpdated={fetchEquipment}
|
onStatusUpdated={fetchEquipment}
|
||||||
canChangeStatus={canChangeStatus}
|
canChangeStatus={canWrite}
|
||||||
/>
|
/>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
@@ -723,7 +733,7 @@ function AusruestungDetailPage() {
|
|||||||
equipmentId={equipment.id}
|
equipmentId={equipment.id}
|
||||||
wartungslog={equipment.wartungslog ?? []}
|
wartungslog={equipment.wartungslog ?? []}
|
||||||
onAdded={fetchEquipment}
|
onAdded={fetchEquipment}
|
||||||
canWrite={canChangeStatus}
|
canWrite={canWrite}
|
||||||
/>
|
/>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
|
|||||||
@@ -82,11 +82,11 @@ function toDateInput(iso: string | null | undefined): string {
|
|||||||
function AusruestungForm() {
|
function AusruestungForm() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { canChangeStatus } = usePermissions();
|
const { canManageEquipment } = usePermissions();
|
||||||
const isEditMode = Boolean(id);
|
const isEditMode = Boolean(id);
|
||||||
|
|
||||||
// -- Permission guard: only authorized users may create or edit equipment ----
|
// -- Permission guard: only authorized users may create or edit equipment ----
|
||||||
if (!canChangeStatus) {
|
if (!canManageEquipment) {
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<Container maxWidth="lg">
|
<Container maxWidth="lg">
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ import UpcomingEventsWidget from '../components/dashboard/UpcomingEventsWidget';
|
|||||||
import AtemschutzDashboardCard from '../components/atemschutz/AtemschutzDashboardCard';
|
import AtemschutzDashboardCard from '../components/atemschutz/AtemschutzDashboardCard';
|
||||||
import EquipmentDashboardCard from '../components/equipment/EquipmentDashboardCard';
|
import EquipmentDashboardCard from '../components/equipment/EquipmentDashboardCard';
|
||||||
import VehicleDashboardCard from '../components/vehicles/VehicleDashboardCard';
|
import VehicleDashboardCard from '../components/vehicles/VehicleDashboardCard';
|
||||||
import PersonalWarningsBanner from '../components/dashboard/PersonalWarningsBanner';
|
|
||||||
|
|
||||||
function Dashboard() {
|
function Dashboard() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const canViewAtemschutz = user?.groups?.some(g =>
|
const canViewAtemschutz = user?.groups?.some(g =>
|
||||||
@@ -56,17 +54,6 @@ function Dashboard() {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Personal Warnings Banner — full width, conditionally rendered */}
|
|
||||||
{user && (
|
|
||||||
<Box sx={{ gridColumn: '1 / -1' }}>
|
|
||||||
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '150ms' }}>
|
|
||||||
<Box>
|
|
||||||
<PersonalWarningsBanner user={user} />
|
|
||||||
</Box>
|
|
||||||
</Fade>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Vehicle Status Card */}
|
{/* Vehicle Status Card */}
|
||||||
<Box>
|
<Box>
|
||||||
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '300ms' }}>
|
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '300ms' }}>
|
||||||
|
|||||||
@@ -837,6 +837,19 @@ function VeranstaltungFormDialog({
|
|||||||
}, [open, editingEvent]);
|
}, [open, editingEvent]);
|
||||||
|
|
||||||
const handleChange = (field: keyof CreateVeranstaltungInput, value: unknown) => {
|
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 }));
|
setForm((prev) => ({ ...prev, [field]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ interface KategorieFormData {
|
|||||||
beschreibung: string;
|
beschreibung: string;
|
||||||
farbe: string;
|
farbe: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
|
alle_gruppen: boolean;
|
||||||
zielgruppen: string[];
|
zielgruppen: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +57,7 @@ const EMPTY_FORM: KategorieFormData = {
|
|||||||
beschreibung: '',
|
beschreibung: '',
|
||||||
farbe: '#1976d2',
|
farbe: '#1976d2',
|
||||||
icon: '',
|
icon: '',
|
||||||
|
alle_gruppen: false,
|
||||||
zielgruppen: [],
|
zielgruppen: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -80,7 +82,8 @@ function KategorieDialog({ open, onClose, onSaved, editing, groups }: KategorieD
|
|||||||
beschreibung: editing.beschreibung ?? '',
|
beschreibung: editing.beschreibung ?? '',
|
||||||
farbe: editing.farbe,
|
farbe: editing.farbe,
|
||||||
icon: editing.icon ?? '',
|
icon: editing.icon ?? '',
|
||||||
zielgruppen: editing.zielgruppen ?? [],
|
alle_gruppen: editing.alle_gruppen ?? false,
|
||||||
|
zielgruppen: editing.alle_gruppen ? [] : (editing.zielgruppen ?? []),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setForm({ ...EMPTY_FORM });
|
setForm({ ...EMPTY_FORM });
|
||||||
@@ -112,7 +115,8 @@ function KategorieDialog({ open, onClose, onSaved, editing, groups }: KategorieD
|
|||||||
beschreibung: form.beschreibung.trim() || undefined,
|
beschreibung: form.beschreibung.trim() || undefined,
|
||||||
farbe: form.farbe,
|
farbe: form.farbe,
|
||||||
icon: form.icon.trim() || undefined,
|
icon: form.icon.trim() || undefined,
|
||||||
zielgruppen: form.zielgruppen,
|
alle_gruppen: form.alle_gruppen,
|
||||||
|
zielgruppen: form.alle_gruppen ? [] : form.zielgruppen,
|
||||||
};
|
};
|
||||||
if (editing) {
|
if (editing) {
|
||||||
await eventsApi.updateKategorie(editing.id, payload);
|
await eventsApi.updateKategorie(editing.id, payload);
|
||||||
@@ -188,7 +192,22 @@ function KategorieDialog({ open, onClose, onSaved, editing, groups }: KategorieD
|
|||||||
placeholder="z.B. EmojiEvents"
|
placeholder="z.B. EmojiEvents"
|
||||||
helperText="Name eines MUI Material Icons"
|
helperText="Name eines MUI Material Icons"
|
||||||
/>
|
/>
|
||||||
{/* Group checkboxes */}
|
{/* alle_gruppen toggle */}
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={form.alle_gruppen}
|
||||||
|
onChange={(e) => 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 && (
|
{groups.length > 0 && (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
|
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
|
||||||
@@ -203,6 +222,7 @@ function KategorieDialog({ open, onClose, onSaved, editing, groups }: KategorieD
|
|||||||
checked={form.zielgruppen.includes(group.id)}
|
checked={form.zielgruppen.includes(group.id)}
|
||||||
onChange={() => handleGroupToggle(group.id)}
|
onChange={() => handleGroupToggle(group.id)}
|
||||||
size="small"
|
size="small"
|
||||||
|
disabled={form.alle_gruppen}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
label={group.label}
|
label={group.label}
|
||||||
@@ -435,7 +455,15 @@ export default function VeranstaltungKategorien() {
|
|||||||
{/* Gruppen */}
|
{/* Gruppen */}
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||||
{(kat.zielgruppen ?? []).length === 0
|
{kat.alle_gruppen ? (
|
||||||
|
<Chip
|
||||||
|
label="Alle Mitglieder"
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ fontSize: '0.75rem' }}
|
||||||
|
/>
|
||||||
|
) : (kat.zielgruppen ?? []).length === 0
|
||||||
? <Typography variant="body2" color="text.secondary">—</Typography>
|
? <Typography variant="body2" color="text.secondary">—</Typography>
|
||||||
: (kat.zielgruppen ?? []).map((gId) => {
|
: (kat.zielgruppen ?? []).map((gId) => {
|
||||||
const group = groups.find((g) => g.id === gId);
|
const group = groups.find((g) => g.id === gId);
|
||||||
|
|||||||
@@ -610,6 +610,19 @@ function EventFormDialog({
|
|||||||
}, [open, editingEvent]);
|
}, [open, editingEvent]);
|
||||||
|
|
||||||
const handleChange = (field: keyof CreateVeranstaltungInput, value: unknown) => {
|
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 }));
|
setForm((prev) => ({ ...prev, [field]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
31
frontend/src/services/notifications.ts
Normal file
31
frontend/src/services/notifications.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { api } from './api';
|
||||||
|
import type { Notification } from '../types/notification.types';
|
||||||
|
|
||||||
|
async function unwrap<T>(
|
||||||
|
promise: ReturnType<typeof api.get<{ success: boolean; data: T }>>
|
||||||
|
): Promise<T> {
|
||||||
|
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<Notification[]> {
|
||||||
|
return unwrap(api.get<{ success: boolean; data: Notification[] }>('/api/notifications'));
|
||||||
|
},
|
||||||
|
|
||||||
|
async getUnreadCount(): Promise<number> {
|
||||||
|
const data = await unwrap(api.get<{ success: boolean; data: { count: number } }>('/api/notifications/count'));
|
||||||
|
return data.count;
|
||||||
|
},
|
||||||
|
|
||||||
|
async markRead(id: string): Promise<void> {
|
||||||
|
await api.patch(`/api/notifications/${id}/read`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async markAllRead(): Promise<void> {
|
||||||
|
await api.post('/api/notifications/mark-all-read');
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -25,6 +25,7 @@ export interface AusruestungKategorie {
|
|||||||
name: string;
|
name: string;
|
||||||
kurzname: string;
|
kurzname: string;
|
||||||
sortierung: number;
|
sortierung: number;
|
||||||
|
motorisiert: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── API Response Shapes ──────────────────────────────────────────────────────
|
// ── API Response Shapes ──────────────────────────────────────────────────────
|
||||||
@@ -35,6 +36,7 @@ export interface AusruestungListItem {
|
|||||||
kategorie_id: string;
|
kategorie_id: string;
|
||||||
kategorie_name: string;
|
kategorie_name: string;
|
||||||
kategorie_kurzname: string;
|
kategorie_kurzname: string;
|
||||||
|
kategorie_motorisiert: boolean;
|
||||||
seriennummer: string | null;
|
seriennummer: string | null;
|
||||||
inventarnummer: string | null;
|
inventarnummer: string | null;
|
||||||
hersteller: string | null;
|
hersteller: string | null;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface VeranstaltungKategorie {
|
|||||||
farbe: string; // hex color e.g. '#1976d2'
|
farbe: string; // hex color e.g. '#1976d2'
|
||||||
icon?: string | null; // MUI icon name
|
icon?: string | null; // MUI icon name
|
||||||
zielgruppen: string[];
|
zielgruppen: string[];
|
||||||
|
alle_gruppen: boolean;
|
||||||
erstellt_am: string;
|
erstellt_am: string;
|
||||||
aktualisiert_am: string;
|
aktualisiert_am: string;
|
||||||
}
|
}
|
||||||
|
|||||||
20
frontend/src/types/notification.types.ts
Normal file
20
frontend/src/types/notification.types.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user