-- Migration: 004_create_einsaetze.sql -- Feature: Einsatzprotokoll (Incident Management) -- Depends on: 001_create_users_table.sql -- Rollback instructions at bottom of file -- --------------------------------------------------------------------------- -- ENUM-LIKE CHECK CONSTRAINTS (as VARCHAR + CHECK for easy extension) -- --------------------------------------------------------------------------- -- --------------------------------------------------------------------------- -- SEQUENCE SUPPORT TABLE for year-based Einsatz-Nr (YYYY-NNN) -- One row per year; nextval equivalent done via UPDATE...RETURNING -- --------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS einsatz_nr_sequence ( year INTEGER PRIMARY KEY, last_nr INTEGER NOT NULL DEFAULT 0 ); -- --------------------------------------------------------------------------- -- FAHRZEUGE TABLE (vehicles — stub so junction table has a valid FK target) -- A full Fahrzeuge feature is covered by 005_create_fahrzeuge.sql. -- We do NOT create the table here; migration 005 owns it. -- The FK in einsatz_fahrzeuge will resolve correctly when 005 runs first -- in the standard sort order (004 runs before 005, so we use DEFERRABLE or -- we simply add the FK as a separate ALTER TABLE at the end of this file, -- after we know 005 may not have run yet). -- -- Resolution strategy: einsatz_fahrzeuge.fahrzeug_id FK is defined as -- DEFERRABLE INITIALLY DEFERRED so it is checked at transaction commit time -- rather than at statement time, allowing this migration to run without -- requiring fahrzeuge to exist yet. -- --------------------------------------------------------------------------- -- --------------------------------------------------------------------------- -- MAIN EINSAETZE TABLE -- --------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS einsaetze ( -- Identity id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), einsatz_nr VARCHAR(12) NOT NULL, -- Format: 2026-001 -- Core timestamps (all TIMESTAMPTZ — never null for alarm_time) alarm_time TIMESTAMPTZ NOT NULL, ausrueck_time TIMESTAMPTZ, ankunft_time TIMESTAMPTZ, einrueck_time TIMESTAMPTZ, -- Classification einsatz_art VARCHAR(30) NOT NULL CONSTRAINT chk_einsatz_art CHECK (einsatz_art IN ( 'Brand', 'THL', 'ABC', 'BMA', 'Hilfeleistung', 'Fehlalarm', 'Brandsicherheitswache' )), einsatz_stichwort VARCHAR(30), -- B1/B2/B3/B4, THL 1/2/3, etc. -- Location strasse VARCHAR(150), hausnummer VARCHAR(20), ort VARCHAR(100), koordinaten POINT, -- (longitude, latitude) -- Narrative bericht_kurz VARCHAR(255), -- short description, visible to all bericht_text TEXT, -- full narrative, restricted to Kommandant+ -- References einsatzleiter_id UUID CONSTRAINT fk_einsaetze_einsatzleiter REFERENCES users(id) ON DELETE SET NULL, -- Operational metadata alarmierung_art VARCHAR(30) NOT NULL DEFAULT 'ILS' CONSTRAINT chk_alarmierung_art CHECK (alarmierung_art IN ( 'ILS', 'DME', 'Telefon', 'Vor_Ort', 'Sonstiges' )), status VARCHAR(20) NOT NULL DEFAULT 'aktiv' CONSTRAINT chk_einsatz_status CHECK (status IN ( 'aktiv', 'abgeschlossen', 'archiviert' )), -- Audit created_by UUID CONSTRAINT fk_einsaetze_created_by REFERENCES users(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- Uniqueness: einsatz_nr is globally unique (year+seq guarantees it) CONSTRAINT uq_einsatz_nr UNIQUE (einsatz_nr) ); -- Performance indexes CREATE INDEX IF NOT EXISTS idx_einsaetze_alarm_time ON einsaetze(alarm_time DESC); CREATE INDEX IF NOT EXISTS idx_einsaetze_einsatz_art ON einsaetze(einsatz_art); CREATE INDEX IF NOT EXISTS idx_einsaetze_status ON einsaetze(status); CREATE INDEX IF NOT EXISTS idx_einsaetze_einsatzleiter ON einsaetze(einsatzleiter_id); -- Note: EXTRACT(YEAR FROM timestamptz) is STABLE (timezone-dependent), not IMMUTABLE, -- so it cannot be used in expression indexes. Use alarm_time range scans for year filtering. CREATE INDEX IF NOT EXISTS idx_einsaetze_alarm_art ON einsaetze(einsatz_art, alarm_time DESC); -- Auto-update updated_at CREATE TRIGGER update_einsaetze_updated_at BEFORE UPDATE ON einsaetze FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); -- --------------------------------------------------------------------------- -- JUNCTION TABLE: einsatz_fahrzeuge -- NOTE: fahrzeug_id FK references fahrzeuge which is created in 005. -- We define the FK as DEFERRABLE INITIALLY DEFERRED so the constraint is -- validated at transaction commit, not statement time. This means the -- migration can run before 005 as long as both run in the same session. -- In production where 005 was already run, the FK resolves immediately. -- --------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS einsatz_fahrzeuge ( einsatz_id UUID NOT NULL CONSTRAINT fk_ef_einsatz REFERENCES einsaetze(id) ON DELETE CASCADE, fahrzeug_id UUID NOT NULL, -- FK added separately below via ALTER TABLE to handle cross-migration dependency -- Vehicle-level timestamps (may differ from main einsatz times) ausrueck_time TIMESTAMPTZ, einrueck_time TIMESTAMPTZ, assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT pk_einsatz_fahrzeuge PRIMARY KEY (einsatz_id, fahrzeug_id) ); -- Add the FK to fahrzeuge only if the fahrzeuge table already exists -- (handles the case where 004 runs after 005 in a fresh deployment). -- If fahrzeuge does not exist yet, the FK will be added by migration 005 -- via an ALTER TABLE at the end of that migration. DO $$ BEGIN IF EXISTS ( SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'fahrzeuge' ) THEN IF NOT EXISTS ( SELECT 1 FROM information_schema.table_constraints WHERE table_name = 'einsatz_fahrzeuge' AND constraint_name = 'fk_ef_fahrzeug' ) THEN ALTER TABLE einsatz_fahrzeuge ADD CONSTRAINT fk_ef_fahrzeug FOREIGN KEY (fahrzeug_id) REFERENCES fahrzeuge(id) ON DELETE CASCADE; END IF; END IF; END $$; CREATE INDEX IF NOT EXISTS idx_ef_fahrzeug_id ON einsatz_fahrzeuge(fahrzeug_id); -- --------------------------------------------------------------------------- -- JUNCTION TABLE: einsatz_personal -- --------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS einsatz_personal ( einsatz_id UUID NOT NULL CONSTRAINT fk_ep_einsatz REFERENCES einsaetze(id) ON DELETE CASCADE, user_id UUID NOT NULL CONSTRAINT fk_ep_user REFERENCES users(id) ON DELETE CASCADE, funktion VARCHAR(50) NOT NULL DEFAULT 'Mannschaft' CONSTRAINT chk_ep_funktion CHECK (funktion IN ( 'Einsatzleiter', 'Gruppenführer', 'Maschinist', 'Atemschutz', 'Sicherheitstrupp', 'Melder', 'Wassertrupp', 'Angriffstrupp', 'Mannschaft', 'Sonstiges' )), -- Personal-level timestamps alarm_time TIMESTAMPTZ, ankunft_time TIMESTAMPTZ, assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT pk_einsatz_personal PRIMARY KEY (einsatz_id, user_id) ); CREATE INDEX IF NOT EXISTS idx_ep_user_id ON einsatz_personal(user_id); CREATE INDEX IF NOT EXISTS idx_ep_funktion ON einsatz_personal(funktion); -- --------------------------------------------------------------------------- -- FUNCTION: generate_einsatz_nr(alarm_time) -- Atomically generates the next Einsatz-Nr for a given year. -- Uses an advisory lock to serialise concurrent inserts for the same year. -- Returns format: '2026-001' -- --------------------------------------------------------------------------- CREATE OR REPLACE FUNCTION generate_einsatz_nr(p_alarm_time TIMESTAMPTZ) RETURNS VARCHAR AS $$ DECLARE v_year INTEGER; v_nr INTEGER; BEGIN v_year := EXTRACT(YEAR FROM p_alarm_time)::INTEGER; -- Upsert the sequence row, then atomically increment and return INSERT INTO einsatz_nr_sequence (year, last_nr) VALUES (v_year, 1) ON CONFLICT (year) DO UPDATE SET last_nr = einsatz_nr_sequence.last_nr + 1 RETURNING last_nr INTO v_nr; RETURN v_year::TEXT || '-' || LPAD(v_nr::TEXT, 3, '0'); END; $$ LANGUAGE plpgsql; -- --------------------------------------------------------------------------- -- MATERIALIZED VIEW: einsatz_statistik -- Pre-aggregated stats used by the dashboard and annual KBI reports. -- Refresh manually after inserts/updates via: REFRESH MATERIALIZED VIEW einsatz_statistik; -- --------------------------------------------------------------------------- CREATE MATERIALIZED VIEW IF NOT EXISTS einsatz_statistik AS SELECT EXTRACT(YEAR FROM alarm_time)::INTEGER AS jahr, EXTRACT(MONTH FROM alarm_time)::INTEGER AS monat, einsatz_art, COUNT(*) AS anzahl, -- Hilfsfrist: median minutes from alarm to arrival (only where ankunft_time is set) ROUND( AVG( EXTRACT(EPOCH FROM (ankunft_time - alarm_time)) / 60.0 ) FILTER (WHERE ankunft_time IS NOT NULL) )::INTEGER AS avg_hilfsfrist_min, -- Total duration (alarm to einrueck) ROUND( AVG( EXTRACT(EPOCH FROM (einrueck_time - alarm_time)) / 60.0 ) FILTER (WHERE einrueck_time IS NOT NULL) )::INTEGER AS avg_dauer_min, COUNT(*) FILTER (WHERE status = 'abgeschlossen') AS anzahl_abgeschlossen, COUNT(*) FILTER (WHERE status = 'aktiv') AS anzahl_aktiv FROM einsaetze WHERE status != 'archiviert' GROUP BY EXTRACT(YEAR FROM alarm_time), EXTRACT(MONTH FROM alarm_time), einsatz_art WITH DATA; CREATE UNIQUE INDEX IF NOT EXISTS idx_einsatz_statistik_pk ON einsatz_statistik(jahr, monat, einsatz_art); CREATE INDEX IF NOT EXISTS idx_einsatz_statistik_jahr ON einsatz_statistik(jahr); -- --------------------------------------------------------------------------- -- ROLLBACK INSTRUCTIONS (run in reverse order to undo): -- -- DROP MATERIALIZED VIEW IF EXISTS einsatz_statistik; -- DROP FUNCTION IF EXISTS generate_einsatz_nr(TIMESTAMPTZ); -- DROP TABLE IF EXISTS einsatz_personal; -- DROP TABLE IF EXISTS einsatz_fahrzeuge; -- DROP TABLE IF EXISTS einsaetze; -- DROP TABLE IF EXISTS fahrzeuge; -- DROP TABLE IF EXISTS einsatz_nr_sequence; -- ---------------------------------------------------------------------------