Files
dashboard/backend/src/database/migrations/006_create_uebungen.sql
Matthias Hochmeister 620bacc6b5 add features
2026-02-27 19:50:14 +01:00

202 lines
8.5 KiB
PL/PgSQL

-- =============================================================================
-- 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;