Rebuild as InnungsApp project: replace stadtwerke analysis with full documentation
- PRD: vollständige Produktspezifikation (5 Module, Scope, Akzeptanzkriterien) - ARCHITECTURE: Tech Stack, Ordnerstruktur, Multi-Tenancy, Push, Kosten - DATABASE_SCHEMA: Vollständiges SQL-Schema mit RLS Policies und Views - USER_STORIES: 40+ Stories nach Rolle (Admin, Mitglied, Azubi, Obermeister) - PERSONAS: 5 detaillierte Nutzerprofile mit Alltag, Zitaten und Erwartungen - BUSINESS_MODEL: Preistabellen, Unit Economics, Revenue-Projektionen, Distribution - ROADMAP: 6 Phasen, Sprint-Planung, Meilensteine und KPIs - COMPETITIVE_ANALYSIS: Wettbewerbsmatrix, USPs, Preispositionierung - API_DESIGN: Supabase Query Patterns, Edge Functions, Realtime Subscriptions - ONBOARDING_FLOWS: 7 User Flows von Setup bis Fehlerfall - GTM_STRATEGY: 3-Phasen-Vertrieb, Outreach-Sequenz, Einwandbehandlung - AZUBI_MODULE: Video-Feed, 1-Click-Apply, Chat, Berichtsheft, Quiz - DSGVO_KONZEPT: Rechtsgrundlagen, TOMs, AVV, Minderjährige, Incident Response - FEATURES_BACKLOG: 72 Features nach MoSCoW + Technische Schulden Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
416
DATABASE_SCHEMA.md
Normal file
416
DATABASE_SCHEMA.md
Normal file
@@ -0,0 +1,416 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user