417 lines
13 KiB
Markdown
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
|