stadtwerke/DATABASE_SCHEMA.md

13 KiB

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

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

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

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

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

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

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

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

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

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

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

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

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