stadtwerke/DATABASE_SCHEMA.md

417 lines
13 KiB
Markdown

# 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