add features

This commit is contained in:
Matthias Hochmeister
2026-03-03 17:01:53 +01:00
parent 92b05726d4
commit 5a6fc85a75
30 changed files with 1104 additions and 198 deletions

View 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');
}
}