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 { 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 { 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 { 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)[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 { 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 { 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 { 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 { 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 { 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 { 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 { 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();