add features
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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<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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
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) {
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<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';
|
||||
|
||||
const ADMIN_GROUPS = ['dashboard_admin'];
|
||||
const WRITE_GROUPS = ['dashboard_admin', 'dashboard_fahrmeister'];
|
||||
const WRITE_GROUPS = ['dashboard_admin', 'dashboard_fahrmeister', 'dashboard_zeugmeister'];
|
||||
|
||||
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 { 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<void> => {
|
||||
try {
|
||||
@@ -20,6 +21,9 @@ const startServer = async (): Promise<void> => {
|
||||
// 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<void> => {
|
||||
|
||||
// Stop scheduled jobs first
|
||||
stopAuditCleanupJob();
|
||||
stopNotificationJob();
|
||||
|
||||
server.close(async () => {
|
||||
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
|
||||
// =========================================================================
|
||||
|
||||
@@ -110,7 +110,7 @@ class EventsService {
|
||||
/** Returns all event categories ordered by name. */
|
||||
async getKategorien(): Promise<VeranstaltungKategorie[]> {
|
||||
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<VeranstaltungKategorie> {
|
||||
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),
|
||||
};
|
||||
|
||||
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();
|
||||
Reference in New Issue
Block a user