add features
This commit is contained in:
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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user