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