# InnungsApp — Datenbankschema > **Datenbank:** PostgreSQL via Supabase | **Stand:** Februar 2026 --- ## 1. Entity Relationship Diagram (vereinfacht) ``` organizations │ ├── user_roles (N:M mit auth.users) ├── members (1:N) │ └── stellen (1:N) ├── news (1:N) │ └── news_reads (1:N) │ └── news_attachments (1:N) ├── termine (1:N) │ └── termine_anmeldungen (1:N) └── push_tokens (via user_id) ``` --- ## 2. Vollständiges Schema ### organizations — Mandanten ```sql CREATE TABLE organizations ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), name text NOT NULL, -- "Innung Elektrotechnik Stuttgart" slug text UNIQUE NOT NULL, -- "innung-elektro-stuttgart" plan text NOT NULL DEFAULT 'pilot' CHECK (plan IN ('pilot', 'standard', 'pro', 'verband')), logo_url text, -- Supabase Storage URL primary_color text DEFAULT '#1a56db', -- Hex-Farbe für White-Label sparten text[] DEFAULT '{}', -- ['Elektro', 'Sanitär', 'Heizung'] kontakt_email text, kontakt_tel text, website text, adresse text, plz text, ort text, bundesland text, created_at timestamptz DEFAULT now(), updated_at timestamptz DEFAULT now() ); -- Trigger: updated_at automatisch setzen CREATE TRIGGER organizations_updated_at BEFORE UPDATE ON organizations FOR EACH ROW EXECUTE FUNCTION update_updated_at(); ``` --- ### user_roles — Rollen & Mandantenzuordnung ```sql CREATE TYPE user_role AS ENUM ('admin', 'member'); CREATE TABLE user_roles ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), user_id uuid NOT NULL REFERENCES auth.users ON DELETE CASCADE, org_id uuid NOT NULL REFERENCES organizations ON DELETE CASCADE, role user_role NOT NULL DEFAULT 'member', created_at timestamptz DEFAULT now(), UNIQUE(user_id, org_id) ); -- Index für schnelle Rollenlookups CREATE INDEX idx_user_roles_user_id ON user_roles(user_id); CREATE INDEX idx_user_roles_org_id ON user_roles(org_id); ``` --- ### members — Mitgliedsbetriebe ```sql CREATE TYPE member_status AS ENUM ('aktiv', 'ruhend', 'ausgetreten'); CREATE TABLE members ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), org_id uuid NOT NULL REFERENCES organizations ON DELETE CASCADE, user_id uuid REFERENCES auth.users ON DELETE SET NULL, -- null wenn noch kein Login -- Basis-Infos vorname text, nachname text NOT NULL, betrieb text NOT NULL, sparte text, -- muss in organizations.sparten enthalten sein -- Kontakt email text, telefon text, mobil text, website text, -- Adresse strasse text, plz text, ort text, -- Innung status member_status NOT NULL DEFAULT 'aktiv', mitglied_seit int, -- Eintrittsjahr, z.B. 2015 ausbildungsbetrieb boolean DEFAULT false, mitgliedsnummer text, -- Metadaten notizen text, -- interne Admin-Notizen eingeladen_am timestamptz, -- Datum der Einladungsmail created_at timestamptz DEFAULT now(), updated_at timestamptz DEFAULT now() ); CREATE INDEX idx_members_org_id ON members(org_id); CREATE INDEX idx_members_user_id ON members(user_id); CREATE INDEX idx_members_status ON members(org_id, status); CREATE INDEX idx_members_sparte ON members(org_id, sparte); ``` --- ### news — Mitteilungen & Beiträge ```sql CREATE TYPE news_kategorie AS ENUM ('Wichtig', 'Prüfung', 'Förderung', 'Veranstaltung', 'Allgemein'); CREATE TABLE news ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), org_id uuid NOT NULL REFERENCES organizations ON DELETE CASCADE, author_id uuid REFERENCES members(id) ON DELETE SET NULL, -- Inhalt title text NOT NULL, body text NOT NULL, -- Markdown-formatiert kategorie news_kategorie NOT NULL DEFAULT 'Allgemein', -- Sichtbarkeit published_at timestamptz, -- null = Entwurf pinned boolean DEFAULT false, -- Metadaten push_sent boolean DEFAULT false, created_at timestamptz DEFAULT now(), updated_at timestamptz DEFAULT now() ); CREATE INDEX idx_news_org_id ON news(org_id, published_at DESC); CREATE INDEX idx_news_pinned ON news(org_id, pinned) WHERE pinned = true; ``` --- ### news_reads — Lesestatus ```sql CREATE TABLE news_reads ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), news_id uuid NOT NULL REFERENCES news ON DELETE CASCADE, user_id uuid NOT NULL REFERENCES auth.users ON DELETE CASCADE, read_at timestamptz DEFAULT now(), UNIQUE(news_id, user_id) ); CREATE INDEX idx_news_reads_news_id ON news_reads(news_id); CREATE INDEX idx_news_reads_user_id ON news_reads(user_id); ``` --- ### news_attachments — PDF-Anhänge ```sql CREATE TABLE news_attachments ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), news_id uuid NOT NULL REFERENCES news ON DELETE CASCADE, filename text NOT NULL, storage_url text NOT NULL, -- Supabase Storage URL file_size int, -- Bytes created_at timestamptz DEFAULT now() ); ``` --- ### stellen — Ausbildungsstellen ```sql CREATE TABLE stellen ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), org_id uuid NOT NULL REFERENCES organizations ON DELETE CASCADE, member_id uuid NOT NULL REFERENCES members(id) ON DELETE CASCADE, -- Stelle sparte text NOT NULL, berufsbezeichnung text NOT NULL, -- "Elektroniker für Energie- und Gebäudetechnik" stellen_anzahl int NOT NULL DEFAULT 1, -- Vergütung (nach Lehrjahr) verguetung_1 int, -- Brutto in € / Monat verguetung_2 int, verguetung_3 int, verguetung_4 int, -- Ausbildungsdetails ausbildungsstart text, -- "August 2026" oder "sofort" lehrjahr text, -- "1. Lehrjahr" oder "Quereinsteiger" schulabschluss text, -- "Kein" | "Hauptschule" | "Realschule" | "Abitur" -- Kontakt kontakt_name text, kontakt_email text, kontakt_tel text, -- Sichtbarkeit aktiv boolean DEFAULT true, created_at timestamptz DEFAULT now(), updated_at timestamptz DEFAULT now() ); CREATE INDEX idx_stellen_org_id ON stellen(org_id, aktiv); CREATE INDEX idx_stellen_sparte ON stellen(org_id, sparte) WHERE aktiv = true; ``` --- ### termine — Veranstaltungskalender ```sql CREATE TYPE termin_typ AS ENUM ('Prüfung', 'Versammlung', 'Kurs', 'Event', 'Lossprechung', 'Sonstiges'); CREATE TABLE termine ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), org_id uuid NOT NULL REFERENCES organizations ON DELETE CASCADE, -- Details titel text NOT NULL, beschreibung text, typ termin_typ NOT NULL DEFAULT 'Sonstiges', -- Zeit & Ort datum date NOT NULL, uhrzeit_von time, uhrzeit_bis time, ort text, online_link text, -- Zoom/Teams-Link (Post-MVP) -- Anmeldung anmeldung_erforderlich boolean DEFAULT false, max_teilnehmer int, -- null = unbegrenzt anmeldeschluss date, -- Metadaten created_at timestamptz DEFAULT now(), updated_at timestamptz DEFAULT now() ); CREATE INDEX idx_termine_org_id ON termine(org_id, datum); CREATE INDEX idx_termine_upcoming ON termine(org_id, datum) WHERE datum >= CURRENT_DATE; ``` --- ### termine_anmeldungen — Teilnehmerliste ```sql CREATE TABLE termine_anmeldungen ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), termin_id uuid NOT NULL REFERENCES termine ON DELETE CASCADE, member_id uuid NOT NULL REFERENCES members(id) ON DELETE CASCADE, angemeldet_at timestamptz DEFAULT now(), notiz text, -- optionale Teilnehmernotiz UNIQUE(termin_id, member_id) ); CREATE INDEX idx_termine_anmeldungen_termin ON termine_anmeldungen(termin_id); CREATE INDEX idx_termine_anmeldungen_member ON termine_anmeldungen(member_id); ``` --- ### push_tokens — Push Notification Tokens ```sql CREATE TABLE push_tokens ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), user_id uuid NOT NULL REFERENCES auth.users ON DELETE CASCADE, token text NOT NULL UNIQUE, platform text NOT NULL CHECK (platform IN ('ios', 'android')), updated_at timestamptz DEFAULT now(), UNIQUE(user_id, token) ); CREATE INDEX idx_push_tokens_user_id ON push_tokens(user_id); ``` --- ## 3. Row Level Security (RLS) Policies ```sql -- Alle Tabellen: RLS aktivieren ALTER TABLE organizations ENABLE ROW LEVEL SECURITY; ALTER TABLE members ENABLE ROW LEVEL SECURITY; ALTER TABLE news ENABLE ROW LEVEL SECURITY; ALTER TABLE news_reads ENABLE ROW LEVEL SECURITY; ALTER TABLE stellen ENABLE ROW LEVEL SECURITY; ALTER TABLE termine ENABLE ROW LEVEL SECURITY; ALTER TABLE termine_anmeldungen ENABLE ROW LEVEL SECURITY; -- Helper Funktion: Gibt org_id des aktuellen Users zurück CREATE OR REPLACE FUNCTION current_user_org_id() RETURNS uuid AS $$ SELECT org_id FROM user_roles WHERE user_id = auth.uid() LIMIT 1; $$ LANGUAGE sql STABLE SECURITY DEFINER; -- Helper Funktion: Ist aktueller User Admin? CREATE OR REPLACE FUNCTION current_user_is_admin() RETURNS boolean AS $$ SELECT EXISTS ( SELECT 1 FROM user_roles WHERE user_id = auth.uid() AND role = 'admin' ); $$ LANGUAGE sql STABLE SECURITY DEFINER; -- members: Jeder sieht nur seine Innung CREATE POLICY "members_select" ON members FOR SELECT USING (org_id = current_user_org_id()); -- members: Nur Admin darf anlegen/bearbeiten CREATE POLICY "members_insert" ON members FOR INSERT WITH CHECK ( org_id = current_user_org_id() AND current_user_is_admin() ); CREATE POLICY "members_update" ON members FOR UPDATE USING ( org_id = current_user_org_id() AND current_user_is_admin() ); -- news: Alle Mitglieder können lesen (nur veröffentlichte) CREATE POLICY "news_select" ON news FOR SELECT USING ( org_id = current_user_org_id() AND published_at IS NOT NULL AND published_at <= now() ); -- news: Nur Admin kann erstellen/bearbeiten CREATE POLICY "news_admin" ON news FOR ALL USING ( org_id = current_user_org_id() AND current_user_is_admin() ); -- stellen: Öffentlich lesbar (ohne Login) — via Supabase anon key CREATE POLICY "stellen_public_select" ON stellen FOR SELECT USING (aktiv = true); -- stellen: Nur zugehöriges Mitglied und Admins können schreiben CREATE POLICY "stellen_insert" ON stellen FOR INSERT WITH CHECK ( org_id = current_user_org_id() AND ( member_id IN (SELECT id FROM members WHERE user_id = auth.uid()) OR current_user_is_admin() ) ); ``` --- ## 4. Utility Functions ```sql -- Funktion: updated_at Trigger CREATE OR REPLACE FUNCTION update_updated_at() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = now(); RETURN NEW; END; $$ LANGUAGE plpgsql; -- Trigger für alle relevanten Tabellen CREATE TRIGGER members_updated_at BEFORE UPDATE ON members FOR EACH ROW EXECUTE FUNCTION update_updated_at(); CREATE TRIGGER news_updated_at BEFORE UPDATE ON news FOR EACH ROW EXECUTE FUNCTION update_updated_at(); CREATE TRIGGER stellen_updated_at BEFORE UPDATE ON stellen FOR EACH ROW EXECUTE FUNCTION update_updated_at(); CREATE TRIGGER termine_updated_at BEFORE UPDATE ON termine FOR EACH ROW EXECUTE FUNCTION update_updated_at(); -- View: Beitrag mit Leserate CREATE VIEW news_with_stats AS SELECT n.*, COUNT(DISTINCT nr.user_id) AS read_count, COUNT(DISTINCT m.user_id) FILTER (WHERE m.user_id IS NOT NULL) AS total_users FROM news n LEFT JOIN news_reads nr ON nr.news_id = n.id LEFT JOIN members m ON m.org_id = n.org_id AND m.status = 'aktiv' GROUP BY n.id; -- View: Kommende Termine mit Anmeldezahl CREATE VIEW termine_with_counts AS SELECT t.*, COUNT(ta.id) AS anmeldungen_count FROM termine t LEFT JOIN termine_anmeldungen ta ON ta.termin_id = t.id WHERE t.datum >= CURRENT_DATE GROUP BY t.id; ``` --- ## 5. Migrations-Strategie - Migrations mit `supabase db migrations` verwaltet - Jede Migration ist eine `.sql`-Datei in `supabase/migrations/` - Migrations werden in CI/CD vor dem Deploy ausgeführt - Staging-Datenbank bekommt Migrations zuerst (Blue-Green-Prinzip) - Rollbacks: nur additiv — keine destruktiven Changes ohne Backup