Files
dashboard/backend/src/services/incident.service.ts
Matthias Hochmeister b7b883649c rework vehicle handling
2026-02-28 14:13:56 +01:00

700 lines
23 KiB
TypeScript

import pool from '../config/database';
import logger from '../utils/logger';
import {
Einsatz,
EinsatzWithDetails,
EinsatzListItem,
EinsatzStats,
EinsatzFahrzeug,
EinsatzPersonal,
MonthlyStatRow,
EinsatzArtStatRow,
EinsatzArt,
CreateEinsatzData,
UpdateEinsatzData,
AssignPersonnelData,
AssignVehicleData,
IncidentFilters,
} from '../models/incident.model';
class IncidentService {
// -------------------------------------------------------------------------
// LIST
// -------------------------------------------------------------------------
/**
* Get a paginated list of incidents with optional filters.
* Returns lightweight EinsatzListItem rows (no bericht_text, no sub-arrays).
*/
async getAllIncidents(
filters: IncidentFilters = { limit: 50, offset: 0 }
): Promise<{ items: EinsatzListItem[]; total: number }> {
try {
const conditions: string[] = ["e.status != 'archiviert'"];
const params: unknown[] = [];
let p = 1;
if (filters.dateFrom) {
conditions.push(`e.alarm_time >= $${p++}`);
params.push(filters.dateFrom);
}
if (filters.dateTo) {
conditions.push(`e.alarm_time <= $${p++}`);
params.push(filters.dateTo);
}
if (filters.einsatzArt) {
conditions.push(`e.einsatz_art = $${p++}`);
params.push(filters.einsatzArt);
}
if (filters.status) {
conditions.push(`e.status = $${p++}`);
params.push(filters.status);
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// Count query
const countResult = await pool.query(
`SELECT COUNT(*)::INTEGER AS total FROM einsaetze e ${where}`,
params
);
const total: number = countResult.rows[0].total;
// Data query — joined with users for einsatzleiter name and personal count
const dataQuery = `
SELECT
e.id,
e.einsatz_nr,
e.alarm_time,
e.einsatz_art,
e.einsatz_stichwort,
e.ort,
e.strasse,
e.status,
COALESCE(
TRIM(CONCAT(u.given_name, ' ', u.family_name)),
u.name,
u.preferred_username
) AS einsatzleiter_name,
ROUND(
EXTRACT(EPOCH FROM (e.ankunft_time - e.alarm_time)) / 60.0
)::INTEGER AS hilfsfrist_min,
ROUND(
EXTRACT(EPOCH FROM (e.einrueck_time - e.alarm_time)) / 60.0
)::INTEGER AS dauer_min,
COALESCE(ep.personal_count, 0)::INTEGER AS personal_count
FROM einsaetze e
LEFT JOIN users u ON u.id = e.einsatzleiter_id
LEFT JOIN (
SELECT einsatz_id, COUNT(*) AS personal_count
FROM einsatz_personal
GROUP BY einsatz_id
) ep ON ep.einsatz_id = e.id
${where}
ORDER BY e.alarm_time DESC
LIMIT $${p++} OFFSET $${p++}
`;
params.push(filters.limit ?? 50, filters.offset ?? 0);
const dataResult = await pool.query(dataQuery, params);
const items: EinsatzListItem[] = dataResult.rows.map((row) => ({
id: row.id,
einsatz_nr: row.einsatz_nr,
alarm_time: row.alarm_time,
einsatz_art: row.einsatz_art,
einsatz_stichwort: row.einsatz_stichwort,
ort: row.ort,
strasse: row.strasse,
status: row.status,
einsatzleiter_name: row.einsatzleiter_name ?? null,
hilfsfrist_min: row.hilfsfrist_min !== null ? Number(row.hilfsfrist_min) : null,
dauer_min: row.dauer_min !== null ? Number(row.dauer_min) : null,
personal_count: Number(row.personal_count),
}));
return { items, total };
} catch (error) {
logger.error('Error fetching incident list', { error, filters });
throw new Error('Failed to fetch incidents');
}
}
// -------------------------------------------------------------------------
// DETAIL
// -------------------------------------------------------------------------
/**
* Get a single incident with full details: personnel, vehicles, computed times.
* NOTE: bericht_text is included here; role-based redaction is applied in
* the controller based on req.user.role.
*/
async getIncidentById(id: string): Promise<EinsatzWithDetails | null> {
try {
const einsatzResult = await pool.query(
`
SELECT
e.*,
COALESCE(
TRIM(CONCAT(u.given_name, ' ', u.family_name)),
u.name,
u.preferred_username
) AS einsatzleiter_name,
ROUND(
EXTRACT(EPOCH FROM (e.ankunft_time - e.alarm_time)) / 60.0
)::INTEGER AS hilfsfrist_min,
ROUND(
EXTRACT(EPOCH FROM (e.einrueck_time - e.alarm_time)) / 60.0
)::INTEGER AS dauer_min
FROM einsaetze e
LEFT JOIN users u ON u.id = e.einsatzleiter_id
WHERE e.id = $1
`,
[id]
);
if (einsatzResult.rows.length === 0) {
return null;
}
const row = einsatzResult.rows[0];
// Fetch assigned personnel
const personalResult = await pool.query(
`
SELECT
ep.einsatz_id,
ep.user_id,
ep.funktion,
ep.alarm_time,
ep.ankunft_time,
ep.assigned_at,
u.name,
u.email,
u.given_name,
u.family_name
FROM einsatz_personal ep
JOIN users u ON u.id = ep.user_id
WHERE ep.einsatz_id = $1
ORDER BY ep.funktion, u.family_name
`,
[id]
);
// Fetch assigned vehicles
const fahrzeugeResult = await pool.query(
`
SELECT
ef.einsatz_id,
ef.fahrzeug_id,
ef.ausrueck_time,
ef.einrueck_time,
ef.assigned_at,
f.amtliches_kennzeichen AS kennzeichen,
f.bezeichnung,
f.typ_schluessel AS fahrzeug_typ
FROM einsatz_fahrzeuge ef
JOIN fahrzeuge f ON f.id = ef.fahrzeug_id
WHERE ef.einsatz_id = $1
ORDER BY f.bezeichnung
`,
[id]
);
const einsatz: EinsatzWithDetails = {
id: row.id,
einsatz_nr: row.einsatz_nr,
alarm_time: row.alarm_time,
ausrueck_time: row.ausrueck_time,
ankunft_time: row.ankunft_time,
einrueck_time: row.einrueck_time,
einsatz_art: row.einsatz_art,
einsatz_stichwort: row.einsatz_stichwort,
strasse: row.strasse,
hausnummer: row.hausnummer,
ort: row.ort,
koordinaten: row.koordinaten,
bericht_kurz: row.bericht_kurz,
bericht_text: row.bericht_text,
einsatzleiter_id: row.einsatzleiter_id,
alarmierung_art: row.alarmierung_art,
status: row.status,
created_by: row.created_by,
created_at: row.created_at,
updated_at: row.updated_at,
einsatzleiter_name: row.einsatzleiter_name ?? null,
hilfsfrist_min: row.hilfsfrist_min !== null ? Number(row.hilfsfrist_min) : null,
dauer_min: row.dauer_min !== null ? Number(row.dauer_min) : null,
fahrzeuge: fahrzeugeResult.rows as EinsatzFahrzeug[],
personal: personalResult.rows as EinsatzPersonal[],
};
return einsatz;
} catch (error) {
logger.error('Error fetching incident by ID', { error, id });
throw new Error('Failed to fetch incident');
}
}
// -------------------------------------------------------------------------
// CREATE
// -------------------------------------------------------------------------
/**
* Create a new incident.
* Einsatz-Nr is generated atomically by the PostgreSQL function
* generate_einsatz_nr() using a per-year sequence table with UPDATE ... RETURNING.
* This is safe under concurrent inserts.
*/
async createIncident(data: CreateEinsatzData, createdBy: string): Promise<Einsatz> {
const client = await pool.connect();
try {
await client.query('BEGIN');
// Generate the Einsatz-Nr atomically inside the transaction
const nrResult = await client.query(
`SELECT generate_einsatz_nr($1::TIMESTAMPTZ) AS einsatz_nr`,
[data.alarm_time]
);
const einsatz_nr: string = nrResult.rows[0].einsatz_nr;
const result = await client.query(
`
INSERT INTO einsaetze (
einsatz_nr, alarm_time, ausrueck_time, ankunft_time, einrueck_time,
einsatz_art, einsatz_stichwort,
strasse, hausnummer, ort,
bericht_kurz, bericht_text,
einsatzleiter_id, alarmierung_art, status,
created_by
) VALUES (
$1, $2, $3, $4, $5,
$6, $7,
$8, $9, $10,
$11, $12,
$13, $14, $15,
$16
)
RETURNING *
`,
[
einsatz_nr,
data.alarm_time,
data.ausrueck_time ?? null,
data.ankunft_time ?? null,
data.einrueck_time ?? null,
data.einsatz_art,
data.einsatz_stichwort ?? null,
data.strasse ?? null,
data.hausnummer ?? null,
data.ort ?? null,
data.bericht_kurz ?? null,
data.bericht_text ?? null,
data.einsatzleiter_id ?? null,
data.alarmierung_art ?? 'ILS',
data.status ?? 'aktiv',
createdBy,
]
);
await client.query('COMMIT');
const einsatz = result.rows[0] as Einsatz;
logger.info('Incident created', {
einsatzId: einsatz.id,
einsatz_nr: einsatz.einsatz_nr,
createdBy,
});
return einsatz;
} catch (error) {
await client.query('ROLLBACK');
logger.error('Error creating incident', { error, createdBy });
throw new Error('Failed to create incident');
} finally {
client.release();
}
}
// -------------------------------------------------------------------------
// UPDATE
// -------------------------------------------------------------------------
async updateIncident(
id: string,
data: UpdateEinsatzData,
updatedBy: string
): Promise<Einsatz> {
try {
const fields: string[] = [];
const values: unknown[] = [];
let p = 1;
const fieldMap: Array<[keyof UpdateEinsatzData, string]> = [
['alarm_time', 'alarm_time'],
['ausrueck_time', 'ausrueck_time'],
['ankunft_time', 'ankunft_time'],
['einrueck_time', 'einrueck_time'],
['einsatz_art', 'einsatz_art'],
['einsatz_stichwort', 'einsatz_stichwort'],
['strasse', 'strasse'],
['hausnummer', 'hausnummer'],
['ort', 'ort'],
['bericht_kurz', 'bericht_kurz'],
['bericht_text', 'bericht_text'],
['einsatzleiter_id', 'einsatzleiter_id'],
['alarmierung_art', 'alarmierung_art'],
['status', 'status'],
];
for (const [key, col] of fieldMap) {
if (key in data) {
fields.push(`${col} = $${p++}`);
values.push((data as Record<string, unknown>)[key] ?? null);
}
}
if (fields.length === 0) {
// Nothing to update — return current state
const current = await this.getIncidentById(id);
if (!current) throw new Error('Incident not found');
return current as Einsatz;
}
// updated_at is handled by the trigger, but we also set it explicitly
// to ensure immediate consistency within the same request cycle
fields.push(`updated_at = NOW()`);
values.push(id);
const result = await pool.query(
`
UPDATE einsaetze
SET ${fields.join(', ')}
WHERE id = $${p}
RETURNING *
`,
values
);
if (result.rows.length === 0) {
throw new Error('Incident not found');
}
const einsatz = result.rows[0] as Einsatz;
logger.info('Incident updated', {
einsatzId: einsatz.id,
einsatz_nr: einsatz.einsatz_nr,
updatedBy,
});
return einsatz;
} catch (error) {
logger.error('Error updating incident', { error, id, updatedBy });
if (error instanceof Error && error.message === 'Incident not found') throw error;
throw new Error('Failed to update incident');
}
}
// -------------------------------------------------------------------------
// SOFT DELETE
// -------------------------------------------------------------------------
/** Soft delete: sets status = 'archiviert'. Hard delete is not exposed. */
async deleteIncident(id: string, deletedBy: string): Promise<void> {
try {
const result = await pool.query(
`
UPDATE einsaetze
SET status = 'archiviert', updated_at = NOW()
WHERE id = $1 AND status != 'archiviert'
RETURNING id
`,
[id]
);
if (result.rows.length === 0) {
throw new Error('Incident not found or already archived');
}
logger.info('Incident archived (soft-deleted)', { einsatzId: id, deletedBy });
} catch (error) {
logger.error('Error archiving incident', { error, id });
if (error instanceof Error && error.message.includes('not found')) throw error;
throw new Error('Failed to archive incident');
}
}
// -------------------------------------------------------------------------
// PERSONNEL
// -------------------------------------------------------------------------
async assignPersonnel(einsatzId: string, data: AssignPersonnelData): Promise<void> {
try {
await pool.query(
`
INSERT INTO einsatz_personal (einsatz_id, user_id, funktion, alarm_time, ankunft_time)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (einsatz_id, user_id) DO UPDATE SET
funktion = EXCLUDED.funktion,
alarm_time = EXCLUDED.alarm_time,
ankunft_time = EXCLUDED.ankunft_time
`,
[
einsatzId,
data.user_id,
data.funktion ?? 'Mannschaft',
data.alarm_time ?? null,
data.ankunft_time ?? null,
]
);
logger.info('Personnel assigned to incident', {
einsatzId,
userId: data.user_id,
funktion: data.funktion,
});
} catch (error) {
logger.error('Error assigning personnel', { error, einsatzId, data });
throw new Error('Failed to assign personnel');
}
}
async removePersonnel(einsatzId: string, userId: string): Promise<void> {
try {
const result = await pool.query(
`DELETE FROM einsatz_personal WHERE einsatz_id = $1 AND user_id = $2 RETURNING user_id`,
[einsatzId, userId]
);
if (result.rows.length === 0) {
throw new Error('Personnel assignment not found');
}
logger.info('Personnel removed from incident', { einsatzId, userId });
} catch (error) {
logger.error('Error removing personnel', { error, einsatzId, userId });
if (error instanceof Error && error.message.includes('not found')) throw error;
throw new Error('Failed to remove personnel');
}
}
// -------------------------------------------------------------------------
// VEHICLES
// -------------------------------------------------------------------------
async assignVehicle(einsatzId: string, data: AssignVehicleData): Promise<void> {
try {
await pool.query(
`
INSERT INTO einsatz_fahrzeuge (einsatz_id, fahrzeug_id, ausrueck_time, einrueck_time)
VALUES ($1, $2, $3, $4)
ON CONFLICT (einsatz_id, fahrzeug_id) DO UPDATE SET
ausrueck_time = EXCLUDED.ausrueck_time,
einrueck_time = EXCLUDED.einrueck_time
`,
[
einsatzId,
data.fahrzeug_id,
data.ausrueck_time ?? null,
data.einrueck_time ?? null,
]
);
logger.info('Vehicle assigned to incident', { einsatzId, fahrzeugId: data.fahrzeug_id });
} catch (error) {
logger.error('Error assigning vehicle', { error, einsatzId, data });
throw new Error('Failed to assign vehicle');
}
}
async removeVehicle(einsatzId: string, fahrzeugId: string): Promise<void> {
try {
const result = await pool.query(
`DELETE FROM einsatz_fahrzeuge WHERE einsatz_id = $1 AND fahrzeug_id = $2 RETURNING fahrzeug_id`,
[einsatzId, fahrzeugId]
);
if (result.rows.length === 0) {
throw new Error('Vehicle assignment not found');
}
logger.info('Vehicle removed from incident', { einsatzId, fahrzeugId });
} catch (error) {
logger.error('Error removing vehicle', { error, einsatzId, fahrzeugId });
if (error instanceof Error && error.message.includes('not found')) throw error;
throw new Error('Failed to remove vehicle');
}
}
// -------------------------------------------------------------------------
// STATISTICS
// -------------------------------------------------------------------------
/**
* Returns aggregated statistics for a given year (defaults to current year).
* Queries live data directly rather than the materialized view so the stats
* page always reflects uncommitted-or-just-committed incidents.
* The materialized view is used for dashboard KPI cards via a separate endpoint.
*/
async getIncidentStats(year?: number): Promise<EinsatzStats> {
try {
const targetYear = year ?? new Date().getFullYear();
const prevYear = targetYear - 1;
// Overall totals for target year
const totalsResult = await pool.query(
`
SELECT
COUNT(*)::INTEGER AS gesamt,
COUNT(*) FILTER (WHERE status = 'abgeschlossen')::INTEGER AS abgeschlossen,
COUNT(*) FILTER (WHERE status = 'aktiv')::INTEGER AS aktiv,
ROUND(
AVG(
EXTRACT(EPOCH FROM (ankunft_time - alarm_time)) / 60.0
) FILTER (WHERE ankunft_time IS NOT NULL)
)::INTEGER AS avg_hilfsfrist_min
FROM einsaetze
WHERE EXTRACT(YEAR FROM alarm_time) = $1
AND status != 'archiviert'
`,
[targetYear]
);
const totals = totalsResult.rows[0] ?? { gesamt: 0, abgeschlossen: 0, aktiv: 0, avg_hilfsfrist_min: null };
// Monthly breakdown — target year
const monthlyResult = await pool.query(
`
SELECT
EXTRACT(MONTH FROM alarm_time)::INTEGER AS monat,
COUNT(*)::INTEGER AS anzahl,
ROUND(
AVG(
EXTRACT(EPOCH FROM (ankunft_time - alarm_time)) / 60.0
) FILTER (WHERE ankunft_time IS NOT NULL)
)::INTEGER AS avg_hilfsfrist_min,
ROUND(
AVG(
EXTRACT(EPOCH FROM (einrueck_time - alarm_time)) / 60.0
) FILTER (WHERE einrueck_time IS NOT NULL)
)::INTEGER AS avg_dauer_min
FROM einsaetze
WHERE EXTRACT(YEAR FROM alarm_time) = $1
AND status != 'archiviert'
GROUP BY EXTRACT(MONTH FROM alarm_time)
ORDER BY monat
`,
[targetYear]
);
// Monthly breakdown — previous year (for chart overlay)
const prevMonthlyResult = await pool.query(
`
SELECT
EXTRACT(MONTH FROM alarm_time)::INTEGER AS monat,
COUNT(*)::INTEGER AS anzahl,
ROUND(
AVG(
EXTRACT(EPOCH FROM (ankunft_time - alarm_time)) / 60.0
) FILTER (WHERE ankunft_time IS NOT NULL)
)::INTEGER AS avg_hilfsfrist_min,
ROUND(
AVG(
EXTRACT(EPOCH FROM (einrueck_time - alarm_time)) / 60.0
) FILTER (WHERE einrueck_time IS NOT NULL)
)::INTEGER AS avg_dauer_min
FROM einsaetze
WHERE EXTRACT(YEAR FROM alarm_time) = $1
AND status != 'archiviert'
GROUP BY EXTRACT(MONTH FROM alarm_time)
ORDER BY monat
`,
[prevYear]
);
// By Einsatzart — target year
const byArtResult = await pool.query(
`
SELECT
einsatz_art,
COUNT(*)::INTEGER AS anzahl,
ROUND(
AVG(
EXTRACT(EPOCH FROM (ankunft_time - alarm_time)) / 60.0
) FILTER (WHERE ankunft_time IS NOT NULL)
)::INTEGER AS avg_hilfsfrist_min
FROM einsaetze
WHERE EXTRACT(YEAR FROM alarm_time) = $1
AND status != 'archiviert'
GROUP BY einsatz_art
ORDER BY anzahl DESC
`,
[targetYear]
);
// Determine most common Einsatzart
const haeufigste_art: EinsatzArt | null =
byArtResult.rows.length > 0 ? (byArtResult.rows[0].einsatz_art as EinsatzArt) : null;
const monthly: MonthlyStatRow[] = (monthlyResult.rows ?? []).map((r) => ({
monat: r.monat,
anzahl: r.anzahl,
avg_hilfsfrist_min: r.avg_hilfsfrist_min !== null ? Number(r.avg_hilfsfrist_min) : null,
avg_dauer_min: r.avg_dauer_min !== null ? Number(r.avg_dauer_min) : null,
}));
const prev_year_monthly: MonthlyStatRow[] = (prevMonthlyResult.rows ?? []).map((r) => ({
monat: r.monat,
anzahl: r.anzahl,
avg_hilfsfrist_min: r.avg_hilfsfrist_min !== null ? Number(r.avg_hilfsfrist_min) : null,
avg_dauer_min: r.avg_dauer_min !== null ? Number(r.avg_dauer_min) : null,
}));
const by_art: EinsatzArtStatRow[] = (byArtResult.rows ?? []).map((r) => ({
einsatz_art: r.einsatz_art as EinsatzArt,
anzahl: r.anzahl,
avg_hilfsfrist_min: r.avg_hilfsfrist_min !== null ? Number(r.avg_hilfsfrist_min) : null,
}));
return {
jahr: targetYear,
gesamt: totals.gesamt ?? 0,
abgeschlossen: totals.abgeschlossen ?? 0,
aktiv: totals.aktiv ?? 0,
avg_hilfsfrist_min:
totals.avg_hilfsfrist_min !== null ? Number(totals.avg_hilfsfrist_min) : null,
haeufigste_art,
monthly,
by_art,
prev_year_monthly,
};
} catch (error) {
logger.error('Error fetching incident statistics', { error, year });
throw new Error('Failed to fetch incident statistics');
}
}
// -------------------------------------------------------------------------
// MATERIALIZED VIEW REFRESH
// -------------------------------------------------------------------------
/** Refresh the einsatz_statistik materialized view. Call after bulk operations. */
async refreshStatistikView(): Promise<void> {
try {
await pool.query('REFRESH MATERIALIZED VIEW CONCURRENTLY einsatz_statistik');
logger.info('einsatz_statistik materialized view refreshed');
} catch (error) {
// CONCURRENTLY requires a unique index — fall back to non-concurrent refresh
try {
await pool.query('REFRESH MATERIALIZED VIEW einsatz_statistik');
logger.info('einsatz_statistik materialized view refreshed (non-concurrent)');
} catch (fallbackError) {
logger.error('Error refreshing einsatz_statistik view', { fallbackError });
throw new Error('Failed to refresh statistics view');
}
}
}
}
export default new IncidentService();