add features

This commit is contained in:
Matthias Hochmeister
2026-02-27 19:50:14 +01:00
parent c5e8337a69
commit 620bacc6b5
46 changed files with 14095 additions and 1 deletions

View File

@@ -0,0 +1,146 @@
-- =============================================================================
-- Migration 002: Audit Log Table
-- GDPR Art. 5(2) Accountability + Art. 30 Records of Processing Activities
--
-- Design decisions:
-- - UUID primary key (consistent with users table)
-- - user_id is nullable: pre-auth events (LOGIN failures) have no user yet
-- - old_value / new_value are JSONB: flexible, queryable, indexable
-- - ip_address stored as TEXT (supports IPv4 + IPv6); anonymised after 90 days
-- - Table is immutable: a PostgreSQL RULE blocks UPDATE and DELETE at the
-- SQL level, which is simpler than triggers and cannot be bypassed by the
-- application role
-- - Partial index on ip_address covers only recent rows (90-day window) to
-- support efficient anonymisation queries without a full-table scan
-- =============================================================================
-- -------------------------------------------------------
-- 1. Enums — define before the table
-- -------------------------------------------------------
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'audit_action') THEN
CREATE TYPE audit_action AS ENUM (
'CREATE',
'UPDATE',
'DELETE',
'LOGIN',
'LOGOUT',
'EXPORT',
'PERMISSION_DENIED',
'PASSWORD_CHANGE',
'ROLE_CHANGE'
);
END IF;
END
$$;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'audit_resource_type') THEN
CREATE TYPE audit_resource_type AS ENUM (
'MEMBER',
'INCIDENT',
'VEHICLE',
'EQUIPMENT',
'QUALIFICATION',
'USER',
'SYSTEM'
);
END IF;
END
$$;
-- -------------------------------------------------------
-- 2. Core audit_log table
-- -------------------------------------------------------
CREATE TABLE IF NOT EXISTS audit_log (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
user_email VARCHAR(255), -- denormalised snapshot; users can be deleted
action audit_action NOT NULL,
resource_type audit_resource_type NOT NULL,
resource_id VARCHAR(255), -- may be UUID, numeric ID, or 'system'
old_value JSONB, -- state before the operation
new_value JSONB, -- state after the operation
ip_address TEXT, -- anonymised to '[anonymized]' after 90 days
user_agent TEXT,
metadata JSONB DEFAULT '{}', -- any extra context (e.g. export format, reason)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Prevent modification of audit records at the database level.
-- Using RULE rather than a trigger because rules are evaluated before
-- the operation and cannot be disabled without superuser access.
CREATE OR REPLACE RULE audit_log_no_update AS
ON UPDATE TO audit_log DO INSTEAD NOTHING;
CREATE OR REPLACE RULE audit_log_no_delete AS
ON DELETE TO audit_log DO INSTEAD NOTHING;
-- -------------------------------------------------------
-- 3. Indexes
-- -------------------------------------------------------
-- Lookup by actor (admin "what did this user do?")
CREATE INDEX IF NOT EXISTS idx_audit_log_user_id
ON audit_log (user_id)
WHERE user_id IS NOT NULL;
-- Lookup by subject resource (admin "what happened to member X?")
CREATE INDEX IF NOT EXISTS idx_audit_log_resource
ON audit_log (resource_type, resource_id);
-- Time-range queries and retention scans (most common filter)
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at
ON audit_log (created_at DESC);
-- Action-type filter
CREATE INDEX IF NOT EXISTS idx_audit_log_action
ON audit_log (action);
-- Partial index: only rows where IP address is not yet anonymised.
-- The anonymisation job does: WHERE created_at < NOW() - INTERVAL '90 days'
-- AND ip_address != '[anonymized]'
-- This index makes that query O(matched rows) instead of O(table size).
CREATE INDEX IF NOT EXISTS idx_audit_log_ip_retention
ON audit_log (created_at)
WHERE ip_address IS NOT NULL
AND ip_address != '[anonymized]';
-- -------------------------------------------------------
-- 4. OPTIONAL — Range partitioning by month (recommended
-- for high-volume deployments; skip in small setups)
--
-- To use partitioning, replace the CREATE TABLE above with
-- the following DDL *before* the first row is inserted.
-- Partitioning cannot be added to an existing unpartitioned
-- table without a full rewrite (pg_partman can automate this).
--
-- CREATE TABLE audit_log (
-- id UUID NOT NULL DEFAULT uuid_generate_v4(),
-- user_id UUID REFERENCES users(id) ON DELETE SET NULL,
-- user_email VARCHAR(255),
-- action audit_action NOT NULL,
-- resource_type audit_resource_type NOT NULL,
-- resource_id VARCHAR(255),
-- old_value JSONB,
-- new_value JSONB,
-- ip_address TEXT,
-- user_agent TEXT,
-- metadata JSONB DEFAULT '{}',
-- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
-- ) PARTITION BY RANGE (created_at);
--
-- -- Create monthly partitions with pg_partman or manually:
-- CREATE TABLE audit_log_2026_01 PARTITION OF audit_log
-- FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
-- CREATE TABLE audit_log_2026_02 PARTITION OF audit_log
-- FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
-- -- ... and so on. pg_partman automates partition creation.
--
-- With partitioning, old partitions can be DROPped for
-- efficient bulk retention deletion without a full-table scan.
-- -------------------------------------------------------

View File

@@ -0,0 +1,138 @@
-- Migration: 003_create_mitglieder_profile
-- Creates the mitglieder_profile and dienstgrad_verlauf tables for fire department member data.
-- Rollback:
-- DROP TABLE IF EXISTS dienstgrad_verlauf;
-- DROP TABLE IF EXISTS mitglieder_profile;
-- ============================================================
-- mitglieder_profile
-- One-to-one extension of the users table.
-- A user can exist without a profile (profile is created later
-- by a Kommandant). The user_id is both FK and UNIQUE.
-- ============================================================
CREATE TABLE IF NOT EXISTS mitglieder_profile (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Internal member identifier assigned by the Kommandant
mitglieds_nr VARCHAR(32) UNIQUE,
-- Rank (Dienstgrad) with allowed values enforced by CHECK
dienstgrad VARCHAR(64)
CHECK (dienstgrad IS NULL OR dienstgrad IN (
'Feuerwehranwärter',
'Feuerwehrmann',
'Feuerwehrfrau',
'Oberfeuerwehrmann',
'Oberfeuerwehrfrau',
'Hauptfeuerwehrmann',
'Hauptfeuerwehrfrau',
'Löschmeister',
'Oberlöschmeister',
'Hauptlöschmeister',
'Brandmeister',
'Oberbrandmeister',
'Hauptbrandmeister',
'Brandinspektor',
'Oberbrandinspektor',
'Brandoberinspektor',
'Brandamtmann'
)),
dienstgrad_seit DATE,
-- Funktion(en) — a member can hold multiple roles simultaneously
-- Stored as a PostgreSQL TEXT array
funktion TEXT[] DEFAULT '{}',
-- Membership status
status VARCHAR(32) NOT NULL DEFAULT 'aktiv'
CHECK (status IN (
'aktiv',
'passiv',
'ehrenmitglied',
'jugendfeuerwehr',
'anwärter',
'ausgetreten'
)),
-- Important dates
eintrittsdatum DATE,
austrittsdatum DATE,
geburtsdatum DATE, -- sensitive: only shown to Kommandant/Admin
-- Contact information (stored raw, formatted on display)
telefon_mobil VARCHAR(32),
telefon_privat VARCHAR(32),
-- Emergency contact (sensitive: only own record visible to Mitglied)
notfallkontakt_name VARCHAR(255),
notfallkontakt_telefon VARCHAR(32),
-- Driving licenses (e.g. ['B', 'C', 'CE'])
fuehrerscheinklassen TEXT[] DEFAULT '{}',
-- Uniform sizing
tshirt_groesse VARCHAR(8)
CHECK (tshirt_groesse IS NULL OR tshirt_groesse IN (
'XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL'
)),
schuhgroesse VARCHAR(8),
-- Free-text notes (Kommandant only)
bemerkungen TEXT,
-- Profile photo URL (separate from the Authentik profile_picture_url)
bild_url TEXT,
-- Audit timestamps
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Enforce one-to-one relationship with users
CONSTRAINT uq_mitglieder_profile_user_id UNIQUE (user_id)
);
-- ============================================================
-- Indexes for the most common query patterns
-- ============================================================
CREATE INDEX IF NOT EXISTS idx_mitglieder_profile_user_id
ON mitglieder_profile(user_id);
CREATE INDEX IF NOT EXISTS idx_mitglieder_profile_status
ON mitglieder_profile(status);
CREATE INDEX IF NOT EXISTS idx_mitglieder_profile_dienstgrad
ON mitglieder_profile(dienstgrad);
CREATE INDEX IF NOT EXISTS idx_mitglieder_profile_mitglieds_nr
ON mitglieder_profile(mitglieds_nr);
-- ============================================================
-- Auto-update trigger for updated_at
-- Reuses the function already created by migration 001.
-- ============================================================
CREATE TRIGGER update_mitglieder_profile_updated_at
BEFORE UPDATE ON mitglieder_profile
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ============================================================
-- dienstgrad_verlauf
-- Append-only audit log of every rank change.
-- Never deleted; soft history only.
-- ============================================================
CREATE TABLE IF NOT EXISTS dienstgrad_verlauf (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
dienstgrad_neu VARCHAR(64) NOT NULL,
dienstgrad_alt VARCHAR(64), -- NULL on first assignment
datum DATE NOT NULL DEFAULT CURRENT_DATE,
durch_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
bemerkung TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_dienstgrad_verlauf_user_id
ON dienstgrad_verlauf(user_id);
CREATE INDEX IF NOT EXISTS idx_dienstgrad_verlauf_datum
ON dienstgrad_verlauf(datum DESC);

View File

@@ -0,0 +1,270 @@
-- 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);
CREATE INDEX IF NOT EXISTS idx_einsaetze_alarm_year ON einsaetze(EXTRACT(YEAR FROM alarm_time));
CREATE INDEX IF NOT EXISTS idx_einsaetze_alarm_art_year ON einsaetze(einsatz_art, EXTRACT(YEAR FROM alarm_time));
-- 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;
-- ---------------------------------------------------------------------------

View File

@@ -0,0 +1,331 @@
-- Migration 005: Fahrzeugverwaltung (Vehicle Fleet Management)
-- Depends on: 001_create_users_table.sql (uuid-ossp extension, users table)
-- ============================================================
-- TABLE: fahrzeuge
-- ============================================================
CREATE TABLE IF NOT EXISTS fahrzeuge (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
bezeichnung VARCHAR(100) NOT NULL, -- e.g. "LF 20/16", "HLF 10"
kurzname VARCHAR(20), -- e.g. "LF1", "HLF2"
amtliches_kennzeichen VARCHAR(20) UNIQUE, -- e.g. "WN-FW 1"
fahrgestellnummer VARCHAR(50), -- VIN
baujahr INTEGER CHECK (baujahr >= 1950 AND baujahr <= 2100),
hersteller VARCHAR(100), -- e.g. "MAN", "Mercedes-Benz", "Rosenbauer"
typ_schluessel VARCHAR(30), -- DIN 14502 code, e.g. "LF 20/16"
besatzung_soll VARCHAR(10), -- crew config e.g. "1/8", "1/5"
status VARCHAR(40) NOT NULL DEFAULT 'einsatzbereit'
CHECK (status IN (
'einsatzbereit',
'ausser_dienst_wartung',
'ausser_dienst_schaden',
'in_lehrgang'
)),
status_bemerkung TEXT,
standort VARCHAR(100) NOT NULL DEFAULT 'Feuerwehrhaus',
bild_url VARCHAR(500),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_fahrzeuge_status ON fahrzeuge(status);
CREATE INDEX IF NOT EXISTS idx_fahrzeuge_kennzeichen ON fahrzeuge(amtliches_kennzeichen);
-- Auto-update updated_at (reuses function from migration 001)
CREATE TRIGGER update_fahrzeuge_updated_at
BEFORE UPDATE ON fahrzeuge
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ============================================================
-- TABLE: fahrzeug_pruefungen
-- Stores both upcoming scheduled inspections AND completed ones.
-- A row with durchgefuehrt_am = NULL is an open/scheduled inspection.
-- ============================================================
CREATE TABLE IF NOT EXISTS fahrzeug_pruefungen (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
fahrzeug_id UUID NOT NULL REFERENCES fahrzeuge(id) ON DELETE CASCADE,
pruefung_art VARCHAR(30) NOT NULL
CHECK (pruefung_art IN (
'HU', -- Hauptuntersuchung (TÜV), 24-month interval
'AU', -- Abgasuntersuchung, 12-month interval
'UVV', -- Unfallverhütungsvorschrift BGV D29, 12-month
'Leiter', -- Leiternprüfung (DLK), 12-month
'Kran', -- Kranprüfung, 12-month
'Seilwinde', -- Seilwindenprüfung, 12-month
'Sonstiges'
)),
-- faellig_am: the deadline by which this inspection must be completed
faellig_am DATE NOT NULL,
-- durchgefuehrt_am: NULL = not yet done; set when inspection is completed
durchgefuehrt_am DATE,
ergebnis VARCHAR(30)
CHECK (ergebnis IS NULL OR ergebnis IN (
'bestanden',
'bestanden_mit_maengeln',
'nicht_bestanden',
'ausstehend'
)),
-- naechste_faelligkeit: auto-calculated next due date after completion
naechste_faelligkeit DATE,
pruefende_stelle VARCHAR(150), -- e.g. "TÜV Süd Stuttgart", "DEKRA"
kosten DECIMAL(8,2),
dokument_url VARCHAR(500),
bemerkung TEXT,
erfasst_von UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_pruefungen_fahrzeug_id ON fahrzeug_pruefungen(fahrzeug_id);
CREATE INDEX IF NOT EXISTS idx_pruefungen_faellig_am ON fahrzeug_pruefungen(faellig_am);
CREATE INDEX IF NOT EXISTS idx_pruefungen_art ON fahrzeug_pruefungen(pruefung_art);
-- Composite index for the "latest per type" query pattern
CREATE INDEX IF NOT EXISTS idx_pruefungen_fahrzeug_art_faellig
ON fahrzeug_pruefungen(fahrzeug_id, pruefung_art, faellig_am DESC);
-- ============================================================
-- TABLE: fahrzeug_wartungslog
-- Service/maintenance log entries (fuel, repairs, tyres, etc.)
-- ============================================================
CREATE TABLE IF NOT EXISTS fahrzeug_wartungslog (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
fahrzeug_id UUID NOT NULL REFERENCES fahrzeuge(id) ON DELETE CASCADE,
datum DATE NOT NULL,
art VARCHAR(30)
CHECK (art IS NULL OR art IN (
'Inspektion',
'Reparatur',
'Kraftstoff',
'Reifenwechsel',
'Hauptuntersuchung',
'Reinigung',
'Sonstiges'
)),
beschreibung TEXT NOT NULL,
km_stand INTEGER CHECK (km_stand >= 0),
kraftstoff_liter DECIMAL(6,2) CHECK (kraftstoff_liter >= 0),
kosten DECIMAL(8,2) CHECK (kosten >= 0),
externe_werkstatt VARCHAR(150),
erfasst_von UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_wartungslog_fahrzeug_id ON fahrzeug_wartungslog(fahrzeug_id);
CREATE INDEX IF NOT EXISTS idx_wartungslog_datum ON fahrzeug_wartungslog(datum DESC);
-- ============================================================
-- VIEW: fahrzeuge_mit_pruefstatus
-- For each vehicle, joins its LATEST scheduled pruefung per art
-- and computes tage_bis_faelligkeit (negative = overdue).
-- The dashboard alert panel and fleet overview query this view.
-- ============================================================
CREATE OR REPLACE VIEW fahrzeuge_mit_pruefstatus AS
WITH latest_pruefungen AS (
-- For each (fahrzeug, pruefung_art) pair, pick the row with the
-- latest faellig_am that is NOT yet completed (durchgefuehrt_am IS NULL),
-- OR if all are completed, the one with the highest naechste_faelligkeit.
SELECT DISTINCT ON (fahrzeug_id, pruefung_art)
fahrzeug_id,
pruefung_art,
id AS pruefung_id,
faellig_am,
durchgefuehrt_am,
ergebnis,
naechste_faelligkeit,
pruefende_stelle,
CURRENT_DATE - faellig_am::date AS tage_ueberfaellig,
faellig_am::date - CURRENT_DATE AS tage_bis_faelligkeit
FROM fahrzeug_pruefungen
ORDER BY
fahrzeug_id,
pruefung_art,
-- Open inspections (nicht durchgeführt) first, then most recent
(durchgefuehrt_am IS NULL) DESC,
faellig_am DESC
)
SELECT
f.id,
f.bezeichnung,
f.kurzname,
f.amtliches_kennzeichen,
f.fahrgestellnummer,
f.baujahr,
f.hersteller,
f.typ_schluessel,
f.besatzung_soll,
f.status,
f.status_bemerkung,
f.standort,
f.bild_url,
f.created_at,
f.updated_at,
-- HU
hu.pruefung_id AS hu_pruefung_id,
hu.faellig_am AS hu_faellig_am,
hu.tage_bis_faelligkeit AS hu_tage_bis_faelligkeit,
hu.ergebnis AS hu_ergebnis,
-- AU
au.pruefung_id AS au_pruefung_id,
au.faellig_am AS au_faellig_am,
au.tage_bis_faelligkeit AS au_tage_bis_faelligkeit,
au.ergebnis AS au_ergebnis,
-- UVV
uvv.pruefung_id AS uvv_pruefung_id,
uvv.faellig_am AS uvv_faellig_am,
uvv.tage_bis_faelligkeit AS uvv_tage_bis_faelligkeit,
uvv.ergebnis AS uvv_ergebnis,
-- Leiter (DLK only)
leiter.pruefung_id AS leiter_pruefung_id,
leiter.faellig_am AS leiter_faellig_am,
leiter.tage_bis_faelligkeit AS leiter_tage_bis_faelligkeit,
leiter.ergebnis AS leiter_ergebnis,
-- Overall worst tage_bis_faelligkeit across all active inspections
LEAST(
hu.tage_bis_faelligkeit,
au.tage_bis_faelligkeit,
uvv.tage_bis_faelligkeit,
leiter.tage_bis_faelligkeit
) AS naechste_pruefung_tage
FROM
fahrzeuge f
LEFT JOIN latest_pruefungen hu ON hu.fahrzeug_id = f.id AND hu.pruefung_art = 'HU'
LEFT JOIN latest_pruefungen au ON au.fahrzeug_id = f.id AND au.pruefung_art = 'AU'
LEFT JOIN latest_pruefungen uvv ON uvv.fahrzeug_id = f.id AND uvv.pruefung_art = 'UVV'
LEFT JOIN latest_pruefungen leiter ON leiter.fahrzeug_id = f.id AND leiter.pruefung_art = 'Leiter';
-- ============================================================
-- SEED DATA: 3 typical German Feuerwehr vehicles
-- ============================================================
DO $$
DECLARE
v_lf10_id UUID := uuid_generate_v4();
v_hlf20_id UUID := uuid_generate_v4();
v_mtf_id UUID := uuid_generate_v4();
BEGIN
-- Only insert if no vehicles exist yet (idempotent seed)
IF (SELECT COUNT(*) FROM fahrzeuge) = 0 THEN
-- 1) LF 10 Standard Löschgruppenfahrzeug
INSERT INTO fahrzeuge (
id, bezeichnung, kurzname, amtliches_kennzeichen,
fahrgestellnummer, baujahr, hersteller, typ_schluessel,
besatzung_soll, status, standort
) VALUES (
v_lf10_id,
'LF 10',
'LF 1',
'WN-FW 1',
'WDB9634031L123456',
2018,
'Mercedes-Benz Atego',
'LF 10',
'1/8',
'einsatzbereit',
'Feuerwehrhaus'
);
-- LF 10 inspections
INSERT INTO fahrzeug_pruefungen (fahrzeug_id, pruefung_art, faellig_am, durchgefuehrt_am, ergebnis, naechste_faelligkeit, pruefende_stelle) VALUES
(v_lf10_id, 'HU', '2026-03-15', NULL, 'ausstehend', NULL, 'TÜV Süd Stuttgart'),
(v_lf10_id, 'AU', '2026-04-01', NULL, 'ausstehend', NULL, 'TÜV Süd Stuttgart'),
(v_lf10_id, 'UVV', '2025-11-30', '2025-11-28', 'bestanden', '2026-11-28', 'DGUV Prüfer Rems');
-- LF 10 maintenance log
INSERT INTO fahrzeug_wartungslog (fahrzeug_id, datum, art, beschreibung, km_stand, kraftstoff_liter, kosten) VALUES
(v_lf10_id, '2025-11-28', 'Inspektion', 'Jahresinspektion nach Herstellervorgabe, Öl- und Filterwechsel, Bremsenprüfung', 48320, NULL, 420.00),
(v_lf10_id, '2025-10-15', 'Kraftstoff', 'Betankung nach Einsatz Feuerwehr Rems', 48150, 85.4, 145.18),
(v_lf10_id, '2025-09-01', 'Reifenwechsel','Sommerreifen auf Winterreifen gewechselt, alle 4 Reifen erneuert (Continental)', 47800, NULL, 980.00);
-- 2) HLF 20/16 Hilfeleistungslöschgruppenfahrzeug (flagship)
INSERT INTO fahrzeuge (
id, bezeichnung, kurzname, amtliches_kennzeichen,
fahrgestellnummer, baujahr, hersteller, typ_schluessel,
besatzung_soll, status, standort
) VALUES (
v_hlf20_id,
'HLF 20/16',
'HLF 1',
'WN-FW 2',
'WMAN29ZZ3LM654321',
2020,
'MAN TGM / Rosenbauer',
'HLF 20/16',
'1/8',
'einsatzbereit',
'Feuerwehrhaus'
);
-- HLF 20 inspections — all current
INSERT INTO fahrzeug_pruefungen (fahrzeug_id, pruefung_art, faellig_am, durchgefuehrt_am, ergebnis, naechste_faelligkeit, pruefende_stelle) VALUES
(v_hlf20_id, 'HU', '2026-08-20', NULL, 'ausstehend', NULL, 'DEKRA Esslingen'),
(v_hlf20_id, 'AU', '2026-02-01', '2026-01-28', 'bestanden', '2027-01-28', 'DEKRA Esslingen'),
(v_hlf20_id, 'UVV', '2026-01-15', '2026-01-14', 'bestanden', '2027-01-14', 'DGUV Prüfer Rems');
-- HLF 20 maintenance log
INSERT INTO fahrzeug_wartungslog (fahrzeug_id, datum, art, beschreibung, km_stand, kraftstoff_liter, kosten) VALUES
(v_hlf20_id, '2026-01-28', 'Hauptuntersuchung', 'AU bestanden ohne Mängel bei DEKRA Esslingen', 22450, NULL, 185.00),
(v_hlf20_id, '2026-01-14', 'Inspektion', 'UVV-Prüfung bestanden, Licht und Bremsen geprüft', 22430, NULL, 0.00),
(v_hlf20_id, '2025-12-10', 'Kraftstoff', 'Betankung nach Übung', 22300, 120.0, 204.00),
(v_hlf20_id, '2025-10-05', 'Reparatur', 'Hydraulikpumpe für Rettungssatz getauscht', 21980, NULL, 2340.00);
-- 3) MTF Mannschaftstransportfahrzeug
INSERT INTO fahrzeuge (
id, bezeichnung, kurzname, amtliches_kennzeichen,
fahrgestellnummer, baujahr, hersteller, typ_schluessel,
besatzung_soll, status, status_bemerkung, standort
) VALUES (
v_mtf_id,
'MTF',
'MTF 1',
'WN-FW 5',
'WDB9066371S789012',
2015,
'Mercedes-Benz Sprinter',
'MTF',
'1/8',
'ausser_dienst_wartung',
'Geplante Inspektion: Zahnriemenwechsel 60.000 km fällig',
'Feuerwehrhaus'
);
-- MTF inspections — HU overdue (safety-critical test data)
INSERT INTO fahrzeug_pruefungen (fahrzeug_id, pruefung_art, faellig_am, durchgefuehrt_am, ergebnis, naechste_faelligkeit, pruefende_stelle) VALUES
(v_mtf_id, 'HU', '2026-01-31', NULL, 'ausstehend', NULL, 'TÜV Süd Stuttgart'),
(v_mtf_id, 'AU', '2025-06-15', '2025-06-12', 'bestanden', '2026-06-12', 'TÜV Süd Stuttgart'),
(v_mtf_id, 'UVV', '2025-12-01', '2025-11-30', 'bestanden_mit_maengeln', '2026-11-30', 'DGUV Prüfer Rems');
-- MTF maintenance log
INSERT INTO fahrzeug_wartungslog (fahrzeug_id, datum, art, beschreibung, km_stand, kraftstoff_liter, kosten) VALUES
(v_mtf_id, '2025-11-30', 'Inspektion', 'UVV-Prüfung: kleiner Mangel Innenbeleuchtung, nachgebessert', 58920, NULL, 0.00),
(v_mtf_id, '2025-11-01', 'Kraftstoff', 'Betankung regulär', 58700, 65.0, 110.50),
(v_mtf_id, '2025-09-20', 'Reparatur', 'Heckleuchte links defekt, Glühbirne getauscht', 58400, NULL, 12.50);
END IF;
END
$$;
-- ---------------------------------------------------------------------------
-- Cross-migration FK: add fahrzeug_id FK to einsatz_fahrzeuge (created in 004)
-- This runs only if einsatz_fahrzeuge exists but the FK is not yet present.
-- Handles the case where 004 ran before 005 and deferred the FK creation.
-- ---------------------------------------------------------------------------
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'einsatz_fahrzeuge'
)
AND 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;
RAISE NOTICE 'Added fk_ef_fahrzeug FK on einsatz_fahrzeuge';
END IF;
END $$;

View File

@@ -0,0 +1,201 @@
-- =============================================================================
-- Migration 006: Übungsplanung & Dienstkalender
-- Training Schedule & Service Calendar for Feuerwehr Rems Dashboard
-- =============================================================================
-- -----------------------------------------------------------------------------
-- 1. Calendar token table for iCal subscribe URLs (no auth required per-request)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS calendar_tokens (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(64) UNIQUE NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMPTZ
);
CREATE INDEX idx_calendar_tokens_token ON calendar_tokens(token);
CREATE INDEX idx_calendar_tokens_user_id ON calendar_tokens(user_id);
-- -----------------------------------------------------------------------------
-- 2. Main events table
-- -----------------------------------------------------------------------------
CREATE TYPE uebung_typ AS ENUM (
'Übungsabend',
'Lehrgang',
'Sonderdienst',
'Versammlung',
'Gemeinschaftsübung',
'Sonstiges'
);
CREATE TABLE uebungen (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
titel VARCHAR(255) NOT NULL,
beschreibung TEXT,
typ uebung_typ NOT NULL,
datum_von TIMESTAMPTZ NOT NULL,
datum_bis TIMESTAMPTZ NOT NULL,
ort VARCHAR(255),
treffpunkt VARCHAR(255),
pflichtveranstaltung BOOLEAN NOT NULL DEFAULT FALSE,
mindest_teilnehmer INT CHECK (mindest_teilnehmer > 0),
max_teilnehmer INT CHECK (max_teilnehmer > 0),
angelegt_von UUID REFERENCES users(id) ON DELETE SET NULL,
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
abgesagt BOOLEAN NOT NULL DEFAULT FALSE,
absage_grund TEXT,
CONSTRAINT datum_reihenfolge CHECK (datum_bis >= datum_von),
CONSTRAINT max_groesser_min CHECK (
max_teilnehmer IS NULL OR
mindest_teilnehmer IS NULL OR
max_teilnehmer >= mindest_teilnehmer
)
);
CREATE INDEX idx_uebungen_datum_von ON uebungen(datum_von);
CREATE INDEX idx_uebungen_typ ON uebungen(typ);
CREATE INDEX idx_uebungen_pflichtveranstaltung ON uebungen(pflichtveranstaltung) WHERE pflichtveranstaltung = TRUE;
CREATE INDEX idx_uebungen_abgesagt ON uebungen(abgesagt) WHERE abgesagt = FALSE;
-- Compound index for the most common calendar-range query
CREATE INDEX idx_uebungen_datum_von_bis ON uebungen(datum_von, datum_bis);
-- Keep aktualisiert_am in sync via trigger (reuse function from migration 001)
CREATE TRIGGER update_uebungen_aktualisiert_am
BEFORE UPDATE ON uebungen
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- -----------------------------------------------------------------------------
-- 3. Attendance / RSVP table
-- -----------------------------------------------------------------------------
CREATE TYPE teilnahme_status AS ENUM (
'zugesagt',
'abgesagt',
'erschienen',
'entschuldigt',
'unbekannt'
);
CREATE TABLE uebung_teilnahmen (
uebung_id UUID NOT NULL REFERENCES uebungen(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status teilnahme_status NOT NULL DEFAULT 'unbekannt',
antwort_am TIMESTAMPTZ,
erschienen_erfasst_am TIMESTAMPTZ,
erschienen_erfasst_von UUID REFERENCES users(id) ON DELETE SET NULL,
bemerkung VARCHAR(500),
PRIMARY KEY (uebung_id, user_id)
);
CREATE INDEX idx_teilnahmen_uebung_id ON uebung_teilnahmen(uebung_id);
CREATE INDEX idx_teilnahmen_user_id ON uebung_teilnahmen(user_id);
CREATE INDEX idx_teilnahmen_status ON uebung_teilnahmen(status);
-- -----------------------------------------------------------------------------
-- 4. Trigger: auto-create 'unbekannt' rows for all active members on new event
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION fn_create_teilnahmen_for_all_active_members()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO uebung_teilnahmen (uebung_id, user_id, status)
SELECT NEW.id, u.id, 'unbekannt'
FROM users u
WHERE u.is_active = TRUE
ON CONFLICT (uebung_id, user_id) DO NOTHING;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_auto_teilnahmen_after_insert
AFTER INSERT ON uebungen
FOR EACH ROW
EXECUTE FUNCTION fn_create_teilnahmen_for_all_active_members();
-- -----------------------------------------------------------------------------
-- 5. Trigger: when a new member becomes active, add them to all future events
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION fn_add_member_to_future_events()
RETURNS TRIGGER AS $$
BEGIN
-- Only run when is_active transitions FALSE -> TRUE
IF (OLD.is_active = FALSE OR OLD.is_active IS NULL) AND NEW.is_active = TRUE THEN
INSERT INTO uebung_teilnahmen (uebung_id, user_id, status)
SELECT u.id, NEW.id, 'unbekannt'
FROM uebungen u
WHERE u.datum_von > NOW()
AND u.abgesagt = FALSE
ON CONFLICT (uebung_id, user_id) DO NOTHING;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_add_member_to_future_events
AFTER UPDATE OF is_active ON users
FOR EACH ROW
EXECUTE FUNCTION fn_add_member_to_future_events();
-- -----------------------------------------------------------------------------
-- 6. Convenience view: event overview with attendance counts
-- -----------------------------------------------------------------------------
CREATE OR REPLACE VIEW uebung_uebersicht AS
SELECT
u.id,
u.titel,
u.typ,
u.datum_von,
u.datum_bis,
u.ort,
u.treffpunkt,
u.pflichtveranstaltung,
u.mindest_teilnehmer,
u.max_teilnehmer,
u.abgesagt,
u.absage_grund,
u.angelegt_von,
u.erstellt_am,
u.aktualisiert_am,
-- Attendance aggregates
COUNT(t.user_id) AS gesamt_eingeladen,
COUNT(t.user_id) FILTER (WHERE t.status = 'zugesagt') AS anzahl_zugesagt,
COUNT(t.user_id) FILTER (WHERE t.status = 'abgesagt') AS anzahl_abgesagt,
COUNT(t.user_id) FILTER (WHERE t.status = 'erschienen') AS anzahl_erschienen,
COUNT(t.user_id) FILTER (WHERE t.status = 'entschuldigt') AS anzahl_entschuldigt,
COUNT(t.user_id) FILTER (WHERE t.status = 'unbekannt') AS anzahl_unbekannt
FROM uebungen u
LEFT JOIN uebung_teilnahmen t ON t.uebung_id = u.id
GROUP BY u.id;
-- -----------------------------------------------------------------------------
-- 7. View: per-member participation statistics (feeds Tier 3 reporting)
-- -----------------------------------------------------------------------------
CREATE OR REPLACE VIEW member_participation_stats AS
SELECT
usr.id AS user_id,
COALESCE(usr.name, usr.preferred_username, usr.email) AS name,
COUNT(t.uebung_id) AS total_eingeladen,
COUNT(t.uebung_id) FILTER (WHERE t.status = 'erschienen') AS total_erschienen,
COUNT(t.uebung_id) FILTER (
WHERE u.pflichtveranstaltung = TRUE AND t.status = 'erschienen'
) AS pflicht_erschienen,
COUNT(t.uebung_id) FILTER (WHERE u.pflichtveranstaltung = TRUE) AS pflicht_gesamt,
ROUND(
CASE
WHEN COUNT(t.uebung_id) FILTER (WHERE u.typ = 'Übungsabend') = 0 THEN 0
ELSE
COUNT(t.uebung_id) FILTER (
WHERE u.typ = 'Übungsabend' AND t.status = 'erschienen'
)::NUMERIC /
COUNT(t.uebung_id) FILTER (WHERE u.typ = 'Übungsabend') * 100
END, 1
) AS uebungsabend_quote_pct
FROM users usr
JOIN uebung_teilnahmen t ON t.user_id = usr.id
JOIN uebungen u ON u.id = t.uebung_id
WHERE usr.is_active = TRUE
AND u.abgesagt = FALSE
GROUP BY usr.id, usr.name, usr.preferred_username, usr.email;

View File

@@ -0,0 +1,197 @@
-- =============================================================================
-- TEST DATA: 5 sample incidents for development and manual testing
-- Run AFTER all migrations have been applied.
-- Assumes at least one user exists in the users table.
-- Replace the user UUIDs with real values from your dev database.
-- =============================================================================
-- ---------------------------------------------------------------------------
-- Step 1: Insert two sample vehicles (if not already present)
-- Uses column names from 005_create_fahrzeuge.sql
-- ---------------------------------------------------------------------------
INSERT INTO fahrzeuge (id, bezeichnung, kurzname, amtliches_kennzeichen, baujahr, hersteller, typ_schluessel, besatzung_soll, status)
VALUES
('a1000000-0000-0000-0000-000000000001', 'HLF 20 - Hilfeleistungslöschgruppenfahrzeug', 'HLF TEST', 'WN-FW 1234', 2020, 'MAN / Rosenbauer', 'HLF 20/16', '1/8', 'einsatzbereit'),
('a1000000-0000-0000-0000-000000000002', 'TLF 3000 - Tanklöschfahrzeug', 'TLF TEST', 'WN-FW 5678', 2018, 'Mercedes-Benz Atego', 'TLF 3000', '1/5', 'einsatzbereit')
ON CONFLICT (id) DO NOTHING;
-- ---------------------------------------------------------------------------
-- Step 2: Helper — get the first active user ID (replace with real UUID in prod)
-- We use a DO block to make the script re-runnable and dev-environment agnostic.
-- ---------------------------------------------------------------------------
DO $$
DECLARE
v_user_id UUID;
v_einsatz_id1 UUID := 'b1000000-0000-0000-0000-000000000001';
v_einsatz_id2 UUID := 'b1000000-0000-0000-0000-000000000002';
v_einsatz_id3 UUID := 'b1000000-0000-0000-0000-000000000003';
v_einsatz_id4 UUID := 'b1000000-0000-0000-0000-000000000004';
v_einsatz_id5 UUID := 'b1000000-0000-0000-0000-000000000005';
v_fahrzeug1 UUID := 'a1000000-0000-0000-0000-000000000001';
v_fahrzeug2 UUID := 'a1000000-0000-0000-0000-000000000002';
BEGIN
SELECT id INTO v_user_id FROM users WHERE is_active = TRUE LIMIT 1;
IF v_user_id IS NULL THEN
RAISE NOTICE 'No active user found — skipping test data insert.';
RETURN;
END IF;
-- -------------------------------------------------------------------------
-- EINSATZ 1: Wohnungsbrand (Brand B3) — January — abgeschlossen
-- -------------------------------------------------------------------------
INSERT INTO einsaetze (
id, 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 (
v_einsatz_id1,
generate_einsatz_nr('2026-01-14 14:32:00+01'::TIMESTAMPTZ),
'2026-01-14 14:32:00+01',
'2026-01-14 14:35:00+01',
'2026-01-14 14:41:00+01',
'2026-01-14 17:15:00+01',
'Brand', 'B3',
'Hauptstraße', '42', 'Berglen',
'Wohnungsbrand im 2. OG, 1 Person gerettet',
'Alarm um 14:32 Uhr durch ILS. Bei Ankunft stand das zweite Obergeschoss in Flammen. ' ||
'Eine Person wurde über die Drehleiter gerettet und dem Rettungsdienst übergeben. ' ||
'Brandbekämpfung mit zwei C-Rohren. Nachlöscharbeiten bis 17:00 Uhr.',
v_user_id, 'ILS', 'abgeschlossen', v_user_id
) ON CONFLICT (id) DO NOTHING;
-- -------------------------------------------------------------------------
-- EINSATZ 2: Verkehrsunfall (THL 2) — Februar — abgeschlossen
-- -------------------------------------------------------------------------
INSERT INTO einsaetze (
id, 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 (
v_einsatz_id2,
generate_einsatz_nr('2026-02-03 08:17:00+01'::TIMESTAMPTZ),
'2026-02-03 08:17:00+01',
'2026-02-03 08:20:00+01',
'2026-02-03 08:26:00+01',
'2026-02-03 10:45:00+01',
'THL', 'THL 2',
'Landesstraße 1115', NULL, 'Rudersberg',
'Verkehrsunfall mit eingeklemmter Person, PKW gegen Baum',
'PKW frontal gegen Baum. Fahrer eingeklemmt, Beifahrerin konnte selbstständig aussteigen. ' ||
'Technische Rettung mit Spreizer und Schere. Person nach ca. 25 Minuten befreit. ' ||
'Übergabe an den Rettungsdienst und Hubschrauber.',
v_user_id, 'ILS', 'abgeschlossen', v_user_id
) ON CONFLICT (id) DO NOTHING;
-- -------------------------------------------------------------------------
-- EINSATZ 3: Brandmeldeanlage (BMA) — März — abgeschlossen (Fehlalarm)
-- -------------------------------------------------------------------------
INSERT INTO einsaetze (
id, einsatz_nr, alarm_time, ausrueck_time, ankunft_time, einrueck_time,
einsatz_art, einsatz_stichwort, strasse, hausnummer, ort,
bericht_kurz,
einsatzleiter_id, alarmierung_art, status, created_by
) VALUES (
v_einsatz_id3,
generate_einsatz_nr('2026-03-21 11:05:00+01'::TIMESTAMPTZ),
'2026-03-21 11:05:00+01',
'2026-03-21 11:08:00+01',
'2026-03-21 11:12:00+01',
'2026-03-21 11:35:00+01',
'BMA', NULL,
'Gewerbepark Rems', '7', 'Weinstadt',
'BMA ausgelöst durch Staubentwicklung bei Bauarbeiten, kein Feuer',
v_user_id, 'ILS', 'abgeschlossen', v_user_id
) ON CONFLICT (id) DO NOTHING;
-- -------------------------------------------------------------------------
-- EINSATZ 4: Ölspur (Hilfeleistung) — März — abgeschlossen
-- -------------------------------------------------------------------------
INSERT INTO einsaetze (
id, einsatz_nr, alarm_time, ausrueck_time, ankunft_time, einrueck_time,
einsatz_art, einsatz_stichwort, strasse, hausnummer, ort,
bericht_kurz,
einsatzleiter_id, alarmierung_art, status, created_by
) VALUES (
v_einsatz_id4,
generate_einsatz_nr('2026-03-28 16:45:00+01'::TIMESTAMPTZ),
'2026-03-28 16:45:00+01',
'2026-03-28 16:49:00+01',
'2026-03-28 16:54:00+01',
'2026-03-28 18:20:00+01',
'Hilfeleistung', NULL,
'Ortsdurchfahrt B29', NULL, 'Schorndorf',
'Ölspur ca. 600m, LKW verlor Hydraulikflüssigkeit, Reinigung mit Ölbindemittel',
v_user_id, 'ILS', 'abgeschlossen', v_user_id
) ON CONFLICT (id) DO NOTHING;
-- -------------------------------------------------------------------------
-- EINSATZ 5: Flächenbrand (Brand B2) — aktiv (noch laufend heute)
-- -------------------------------------------------------------------------
INSERT INTO einsaetze (
id, einsatz_nr, alarm_time, ausrueck_time, ankunft_time,
einsatz_art, einsatz_stichwort, strasse, hausnummer, ort,
bericht_kurz,
einsatzleiter_id, alarmierung_art, status, created_by
) VALUES (
v_einsatz_id5,
generate_einsatz_nr(NOW()),
NOW() - INTERVAL '45 minutes',
NOW() - INTERVAL '42 minutes',
NOW() - INTERVAL '37 minutes',
'Brand', 'B2',
'Waldweg', NULL, 'Alfdorf',
'Flächenbrand ca. 0,5 ha Unterholz, Wasserversorgung über Pendelverkehr',
v_user_id, 'ILS', 'aktiv', v_user_id
) ON CONFLICT (id) DO NOTHING;
-- -------------------------------------------------------------------------
-- VEHICLES — assign to incidents
-- -------------------------------------------------------------------------
INSERT INTO einsatz_fahrzeuge (einsatz_id, fahrzeug_id, ausrueck_time, einrueck_time)
VALUES
(v_einsatz_id1, v_fahrzeug1, '2026-01-14 14:35:00+01', '2026-01-14 17:15:00+01'),
(v_einsatz_id1, v_fahrzeug2, '2026-01-14 14:36:00+01', '2026-01-14 17:10:00+01'),
(v_einsatz_id2, v_fahrzeug1, '2026-02-03 08:20:00+01', '2026-02-03 10:45:00+01'),
(v_einsatz_id4, v_fahrzeug1, '2026-03-28 16:49:00+01', '2026-03-28 18:20:00+01'),
(v_einsatz_id5, v_fahrzeug1, NOW() - INTERVAL '42 minutes', NULL),
(v_einsatz_id5, v_fahrzeug2, NOW() - INTERVAL '40 minutes', NULL)
ON CONFLICT (einsatz_id, fahrzeug_id) DO NOTHING;
-- -------------------------------------------------------------------------
-- PERSONNEL — assign the single test user to each incident
-- -------------------------------------------------------------------------
INSERT INTO einsatz_personal (einsatz_id, user_id, funktion, alarm_time, ankunft_time)
VALUES
(v_einsatz_id1, v_user_id, 'Einsatzleiter', '2026-01-14 14:32:00+01', '2026-01-14 14:41:00+01'),
(v_einsatz_id2, v_user_id, 'Gruppenführer', '2026-02-03 08:17:00+01', '2026-02-03 08:26:00+01'),
(v_einsatz_id3, v_user_id, 'Einsatzleiter', '2026-03-21 11:05:00+01', '2026-03-21 11:12:00+01'),
(v_einsatz_id4, v_user_id, 'Maschinist', '2026-03-28 16:45:00+01', '2026-03-28 16:54:00+01'),
(v_einsatz_id5, v_user_id, 'Einsatzleiter', NOW() - INTERVAL '45 minutes', NOW() - INTERVAL '37 minutes')
ON CONFLICT (einsatz_id, user_id) DO NOTHING;
RAISE NOTICE 'Test data inserted successfully for user %', v_user_id;
END $$;
-- ---------------------------------------------------------------------------
-- Refresh the materialized view after data load
-- ---------------------------------------------------------------------------
REFRESH MATERIALIZED VIEW einsatz_statistik;
-- ---------------------------------------------------------------------------
-- Verify: quick sanity check
-- ---------------------------------------------------------------------------
SELECT
e.einsatz_nr,
e.einsatz_art,
TO_CHAR(e.alarm_time AT TIME ZONE 'Europe/Berlin', 'DD.MM.YYYY HH24:MI') AS alarm_de,
e.ort,
e.status,
COUNT(ep.user_id) AS kräfte,
COUNT(ef.fahrzeug_id) AS fahrzeuge
FROM einsaetze e
LEFT JOIN einsatz_personal ep ON ep.einsatz_id = e.id
LEFT JOIN einsatz_fahrzeuge ef ON ef.einsatz_id = e.id
GROUP BY e.id, e.einsatz_nr, e.einsatz_art, e.alarm_time, e.ort, e.status
ORDER BY e.alarm_time DESC;