TeamVis Self-Host-Bundle v0.31.0
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
# TeamVis — Self-Hosting-Bundle (v0.31.0)
|
||||
|
||||
Dieses Bundle enthält alles zum **Betreiben** von TeamVis auf eigener
|
||||
Infrastruktur — **keinen** App-Quellcode. Die App selbst kommt als fertiges
|
||||
Container-Image aus der Registry `git.zfx.services` (Image-Pull-Token vom Anbieter,
|
||||
Benutzer `teamvis-pull`). Dieses Bundle selbst ist öffentlich.
|
||||
|
||||
## Loslegen
|
||||
|
||||
```bash
|
||||
docker login git.zfx.services # Benutzer: teamvis-pull · Passwort: <Image-Pull-Token>
|
||||
bash deploy/selfhost/install.sh # bei der DB-Frage Modus 2 = alles mitinstallieren
|
||||
```
|
||||
|
||||
Vollständige Schritt-für-Schritt-Anleitung (von der nackten VM bis live):
|
||||
**`docs/SELFHOST-QUICKSTART.md`**. Hintergrund & Varianten:
|
||||
`deploy/selfhost/README.md` · Detail-Runbook: `docs/NEUKUNDE.md`.
|
||||
|
||||
## Inhalt
|
||||
|
||||
| Pfad | Zweck |
|
||||
|---|---|
|
||||
| `deploy/selfhost/install.sh` | Ein-Schritt-Installer (Modus 1 extern / Modus 2 all-in-one) |
|
||||
| `scripts/`, `deploy/selfhost/gen-keys.mjs` | Schema-Bündelung, Admin-Anlage, Schlüssel |
|
||||
| `supabase/migrations/` | Datenbank-Schema (DDL) |
|
||||
| `docs/` | Anleitungen |
|
||||
|
||||
Stand: TeamVis 0.31.0.
|
||||
@@ -0,0 +1,37 @@
|
||||
# =====================================================================
|
||||
# TeamVis-Zusatzvariablen für die All-in-One-Installation
|
||||
# =====================================================================
|
||||
# Diese Werte gehören in die .env des supabase/docker-Verzeichnisses
|
||||
# (also UNTEN an die bestehende Supabase-.env anhängen). Die Supabase-
|
||||
# eigenen Werte (POSTGRES_PASSWORD, JWT_SECRET, ANON_KEY,
|
||||
# SERVICE_ROLE_KEY, SUPABASE_PUBLIC_URL, SITE_URL) bleiben dort und
|
||||
# werden NICHT dupliziert.
|
||||
#
|
||||
# JWT_SECRET / ANON_KEY / SERVICE_ROLE_KEY werden mit gen-keys.mjs
|
||||
# erzeugt und ersetzen die unsicheren Demo-Defaults der Supabase-.env.
|
||||
|
||||
# Welches TeamVis-Image (= App-Version).
|
||||
TEAMVIS_VERSION=0.12.0
|
||||
|
||||
# App-Session-Cookie. >= 32 Zeichen, pro Instanz EINMALIG erzeugen, z.B.:
|
||||
# openssl rand -base64 48
|
||||
SESSION_SECRET=
|
||||
|
||||
# Öffentliche URLs (müssen zu den Domains im Caddyfile passen):
|
||||
# SITE_URL → App/Karten (https://team.kunde.de)
|
||||
# SUPABASE_PUBLIC_URL → Supabase (https://supabase.kunde.de)
|
||||
# Beide stehen schon in der Supabase-.env — dort auf die echten Domains
|
||||
# setzen (NICHT localhost).
|
||||
|
||||
# --- SMTP der App (eigene Variablen, getrennt von Supabase/GoTrue) ---
|
||||
# Fehlt der Host, landen Magic-Links nur im Container-Log.
|
||||
TEAMVIS_SMTP_HOST=
|
||||
TEAMVIS_SMTP_PORT=587
|
||||
TEAMVIS_SMTP_SECURE=
|
||||
TEAMVIS_SMTP_USER=
|
||||
TEAMVIS_SMTP_PASS=
|
||||
TEAMVIS_SMTP_FROM_EMAIL=noreply@team.kunde.de
|
||||
TEAMVIS_SMTP_FROM_NAME=TeamVis
|
||||
|
||||
# Optional: zusätzliche next/image-Hosts (i.d.R. leer lassen).
|
||||
SUPABASE_IMAGE_HOSTS=
|
||||
@@ -0,0 +1,20 @@
|
||||
# TeamVis All-in-One — TLS-Ingress (Caddy, Auto-HTTPS via Let's Encrypt)
|
||||
#
|
||||
# Voraussetzung: beide (Sub-)Domains zeigen per DNS auf die öffentliche
|
||||
# IP dieses Servers, Ports 80 + 443 sind offen. Caddy holt die
|
||||
# Zertifikate selbst.
|
||||
#
|
||||
# Kopieren nach ./Caddyfile und die zwei Domains anpassen.
|
||||
|
||||
# --- Öffentliche App + Admin + Portal -------------------------------
|
||||
team.kunde.de {
|
||||
reverse_proxy teamvis:3000
|
||||
}
|
||||
|
||||
# --- Supabase (REST + Storage) --------------------------------------
|
||||
# Muss öffentlich erreichbar sein: die Foto-URLs der Karten zeigen
|
||||
# hierher (https://supabase.kunde.de/storage/v1/object/public/...).
|
||||
# = Wert von SUPABASE_PUBLIC_URL in der .env.
|
||||
supabase.kunde.de {
|
||||
reverse_proxy kong:8000
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
# TeamVis — All-in-One Self-Hosting (App + Supabase)
|
||||
|
||||
## Schnellstart: `install.sh` (ein Schritt)
|
||||
|
||||
Für eine neue Instanz gibt es einen interaktiven Installer, der alles in einem
|
||||
Rutsch erledigt und beim Start fragt, woher die Datenbank kommt:
|
||||
|
||||
```bash
|
||||
bash deploy/selfhost/install.sh
|
||||
```
|
||||
|
||||
- **Modus 1 — bestehende/eigene Supabase** (Cloud oder self-hosted): es läuft nur
|
||||
die App (+ optional Caddy/TLS). Entspricht `docs/NEUKUNDE.md`, nur automatisiert.
|
||||
- **Modus 2 — alles mitinstallieren:** schlanker Supabase-Stack als Container
|
||||
(Postgres + PostgREST + Storage) + App + Caddy, **Single-Domain** (App,
|
||||
`/rest/v1`, `/storage/v1` alle unter `https://team.kunde.de` — eine Domain, ein
|
||||
Zertifikat). Kein vorab eingerichtetes Supabase nötig.
|
||||
|
||||
Das Skript erzeugt Schlüssel/Secrets, schreibt `.env` + `docker-compose.yml` +
|
||||
`Caddyfile`, spielt das Schema ein, macht in Modus 2 den **Rollen-Bootstrap +
|
||||
Grants automatisch** (der manuelle Block unter „Variante B" entfällt damit) und
|
||||
legt den ersten Admin an. Voraussetzungen: docker + compose v2, node 20+,
|
||||
openssl, DNS auf den Host, Ports 80/443 frei, `docker login git.zoesch.de`.
|
||||
|
||||
Variablen lassen sich vorab per ENV setzen (überspringt die Abfrage), z. B.
|
||||
`APP_DOMAIN=team.kunde.de ADMIN_EMAIL=chef@kunde.de MODE=2 bash deploy/selfhost/install.sh`.
|
||||
|
||||
> Die folgenden Abschnitte beschreiben die **manuellen** Varianten (zum
|
||||
> Verständnis / für Sonderfälle). Für den Normalfall reicht `install.sh`.
|
||||
|
||||
---
|
||||
|
||||
## Manuell (Hintergrund)
|
||||
|
||||
**Option/Status:** ausgearbeitet **und end-to-end auf zfx-vps getestet**
|
||||
(Lean-Datenebene). Karte, vCard, Storage-Upload/-Abruf, anon-RLS und der
|
||||
Spalten-Revoke (0045) laufen gegen self-hosted Postgres + PostgREST +
|
||||
Storage. Der Testlauf hat drei Bootstrap-Lücken aufgedeckt — alle gelöst,
|
||||
siehe [Befunde](#befunde-aus-dem-testlauf-2026-06-07).
|
||||
|
||||
Diese Variante bringt mit **einem Compose-Stack** sowohl die TeamVis-App als
|
||||
auch eine eigene Supabase-Instanz hoch — der Kunde braucht **kein** vorab
|
||||
eingerichtetes Supabase (weder Cloud noch self-hosted). Gegensatz zum
|
||||
Standard-Onboarding (`docs/NEUKUNDE.md`), das eine **externe** Supabase
|
||||
voraussetzt.
|
||||
|
||||
---
|
||||
|
||||
## Was TeamVis von Supabase wirklich braucht
|
||||
|
||||
Analyse des Codes (`lib/supabase-server.ts`, alle `*.actions.ts`):
|
||||
|
||||
| Supabase-Komponente | Von TeamVis genutzt? | Wofür |
|
||||
|---|---|---|
|
||||
| **Postgres** | ✅ Pflicht | alle Daten |
|
||||
| **PostgREST** (`/rest/v1`) | ✅ Pflicht | sämtliche DB-Reads/Writes (`@supabase/supabase-js`) |
|
||||
| **Storage** (`/storage/v1`) | ✅ Pflicht | Buckets `employee-photos` + Branding, **öffentliche** URLs (`getPublicUrl`) |
|
||||
| GoTrue / Auth (`/auth/v1`) | ❌ | TeamVis nutzt **keine** Supabase-Auth (Admin = bcrypt+iron-session, Portal = Magic-Link) |
|
||||
| Realtime | ❌ | — |
|
||||
| imgproxy (Bild-Transforms) | ❌ | Karten nutzen `next/image`, keine Storage-Transforms |
|
||||
| Studio / Meta | ⚪ optional | bequemes DB-/SQL-UI für den Betreiber |
|
||||
|
||||
**Folge:** technisch genügen **Postgres + PostgREST + Storage + ein
|
||||
Pfad-Router**. Auth/Realtime/imgproxy sind verzichtbar.
|
||||
|
||||
---
|
||||
|
||||
## Architektur
|
||||
|
||||
```
|
||||
┌─────────────── Caddy (Auto-HTTPS) ───────────────┐
|
||||
Browser ── 443 ───▶ │ team.kunde.de → teamvis:3000 │
|
||||
(Karten, Admin) │ supabase.kunde.de → kong:8000 │
|
||||
└───────────────┬──────────────────┬───────────────┘
|
||||
│ │
|
||||
┌──────────▼─────┐ ┌────────▼─────────────────┐
|
||||
│ teamvis (App) │ │ Supabase-Stack (kong → │
|
||||
│ Next.js :3000 │──▶│ rest + storage + db …) │
|
||||
└────────────────┘ └──────────────────────────┘
|
||||
```
|
||||
|
||||
- Die **Foto-URLs der Karten zeigen auf `supabase.kunde.de`** (öffentliches
|
||||
`getPublicUrl`). Deshalb muss diese Domain **öffentlich per HTTPS**
|
||||
erreichbar sein — `next/image` erlaubt nur `https` auf
|
||||
`/storage/v1/object/public/**` (siehe `next.config.ts`, bereits
|
||||
vorbereitet: jeder https-Host auf diesem Pfad ist zulässig).
|
||||
- Das **App-Image ist unverändert** das SaaS-Image — alle Supabase-Werte
|
||||
kommen zur Laufzeit aus ENV (`lib/runtime-env.ts`). Kein Rebuild pro Kunde.
|
||||
|
||||
---
|
||||
|
||||
## Variante A — offizieller Supabase-Stack + Override (empfohlen)
|
||||
|
||||
Lehnt sich an den **getesteten** `supabase/docker`-Stack an und legt TeamVis
|
||||
per Override darüber. Vorteil: korrekte, aufeinander abgestimmte Image-Pins
|
||||
(Postgres/PostgREST/Storage/Kong) ohne eigenes Versions-Risiko.
|
||||
|
||||
### Schritte
|
||||
|
||||
```bash
|
||||
# 0) Voraussetzungen auf dem Server: docker + compose, node 20+, openssl,
|
||||
# DNS für team.kunde.de und supabase.kunde.de auf diese IP, Ports 80/443 frei.
|
||||
|
||||
# 1) Supabase-Stack holen
|
||||
git clone --depth 1 https://github.com/supabase/supabase
|
||||
cd supabase/docker
|
||||
cp .env.example .env
|
||||
|
||||
# 2) TeamVis-Override + Caddy + Zusatz-ENV hineinlegen
|
||||
REPO=/pfad/zum/team-stwhas
|
||||
cp $REPO/deploy/selfhost/docker-compose.override.yml .
|
||||
cp $REPO/deploy/selfhost/Caddyfile.example ./Caddyfile # Domains anpassen!
|
||||
cat $REPO/deploy/selfhost/.env.teamvis.example >> .env # Werte ausfüllen
|
||||
|
||||
# 3) In .env setzen/ersetzen:
|
||||
# POSTGRES_PASSWORD → starkes Passwort
|
||||
# SITE_URL=https://team.kunde.de
|
||||
# SUPABASE_PUBLIC_URL=https://supabase.kunde.de
|
||||
# API_EXTERNAL_URL=https://supabase.kunde.de
|
||||
# SESSION_SECRET → openssl rand -base64 48
|
||||
# (JWT_SECRET/ANON_KEY/SERVICE_ROLE_KEY macht bootstrap.sh in Schritt 4)
|
||||
|
||||
# 4) Bootstrap: Keys + Stack + Schema + erster Admin
|
||||
cp $REPO/deploy/selfhost/bootstrap.sh .
|
||||
TEAMVIS_REPO=$REPO ADMIN_EMAIL=chef@kunde.de bash bootstrap.sh
|
||||
```
|
||||
|
||||
`bootstrap.sh` erledigt: Schlüssel erzeugen (ersetzt die unsicheren
|
||||
Demo-Keys), `docker compose up -d`, auf gesunde DB warten,
|
||||
TeamVis-Schema (Migrations-Bündel) einspielen, ersten Admin anlegen.
|
||||
|
||||
### Optional schlanker (Footprint senken)
|
||||
|
||||
Der Volldownload startet ~10 Container. Nicht genutzte abschalten —
|
||||
in der `.env` bzw. per `docker compose` weglassen: **studio, auth
|
||||
(GoTrue), realtime, imgproxy, analytics/vector, meta, supavisor/pooler**.
|
||||
Übrig bleiben **db, rest, storage, kong** (+ teamvis, caddy). Das ist die
|
||||
empfohlene Ziel-Konfiguration für eine Einzel-Mandanten-Appliance —
|
||||
sollte aber im selben Probelauf mitgetestet werden (Kong-Routen + Storage
|
||||
hängen minimal an Auth-Annahmen).
|
||||
|
||||
---
|
||||
|
||||
## Variante B — komplett schlank (eigener Mini-Stack)
|
||||
|
||||
Nur `supabase/postgres:15.8.1.085` + `postgrest/postgrest:v14.12` +
|
||||
`supabase/storage-api:v1.60.4` + Caddy als Pfad-Router (kein Kong, kein
|
||||
GoTrue/Realtime/imgproxy). **Genau das wurde getestet** (siehe Befunde) und
|
||||
läuft — braucht aber einen Rollen-Bootstrap, den der offizielle Stack
|
||||
automatisch macht. Caddy-Router statt Kong:
|
||||
|
||||
```caddyfile
|
||||
:8000 {
|
||||
handle_path /rest/v1/* { reverse_proxy rest:3000 }
|
||||
handle_path /storage/v1/* { reverse_proxy storage:5000 }
|
||||
}
|
||||
```
|
||||
|
||||
**Pflicht-Bootstrap nach dem ersten Start** (einmalig, als `supabase_admin`,
|
||||
Passwort = `POSTGRES_PASSWORD`):
|
||||
|
||||
```sql
|
||||
-- 1) Rollen-Passwörter (das Image setzt nur supabase_admin auf POSTGRES_PASSWORD)
|
||||
alter role authenticator with login password '<POSTGRES_PASSWORD>';
|
||||
alter role supabase_storage_admin with login password '<POSTGRES_PASSWORD>';
|
||||
alter role supabase_auth_admin with login password '<POSTGRES_PASSWORD>';
|
||||
|
||||
-- 2) service_role-Rechte (auf echtem Supabase automatisch vorhanden)
|
||||
alter role service_role bypassrls;
|
||||
grant all on all tables in schema public to service_role;
|
||||
grant all on all sequences in schema public to service_role;
|
||||
grant all on all routines in schema public to service_role;
|
||||
alter default privileges in schema public grant all on tables to service_role;
|
||||
alter default privileges in schema public grant all on sequences to service_role;
|
||||
```
|
||||
|
||||
Reihenfolge: Passwörter (1) **vor** dem ersten Start von `rest`/`storage`,
|
||||
die Grants (2) **nach** dem Schema-Load. Wegen dieses Mehraufwands ist für
|
||||
die *erste* Kundeninstallation **Variante A empfohlen** (dort entfällt der
|
||||
ganze Block).
|
||||
|
||||
---
|
||||
|
||||
## Stolpersteine (wichtig)
|
||||
|
||||
1. **`SUPABASE_PUBLIC_URL` muss öffentlich + HTTPS sein** — sie steckt in den
|
||||
Foto-URLs der Karten. Interner Hostname (`http://kong:8000`) funktioniert
|
||||
**nicht** für die Bildanzeige.
|
||||
2. **SMTP-Kollision:** der Supabase-Stack belegt `SMTP_*` selbst (GoTrue).
|
||||
Die App liest deshalb `TEAMVIS_SMTP_*` (siehe Override) — nicht
|
||||
vermischen.
|
||||
3. **Reihenfolge:** Schema erst einspielen, wenn Storage einmal gestartet ist
|
||||
(Migration `0044` legt den Bucket in `storage.buckets` an). `bootstrap.sh`
|
||||
wartet darauf.
|
||||
4. **Keys sind JWTs:** `ANON_KEY`/`SERVICE_ROLE_KEY` sind mit `JWT_SECRET`
|
||||
signierte Tokens (`gen-keys.mjs`). Wird `JWT_SECRET` geändert, müssen beide
|
||||
Keys neu erzeugt werden.
|
||||
5. **Backups:** jetzt liegt die DB beim Kunden. Postgres-Volume + Storage-
|
||||
Volume sichern (`pg_dump` + `volumes/storage`). Beim externen Supabase war
|
||||
das deren Sache.
|
||||
6. **Modul-Rollout:** frische Instanz startet minimal (nur Visitenkarten).
|
||||
Danach unter **Admin → Funktionen** gestaffelt freischalten
|
||||
(siehe `docs/NEUKUNDE.md` §5a).
|
||||
|
||||
---
|
||||
|
||||
## Footprint (grob)
|
||||
|
||||
| | Container | RAM (Daumenregel) |
|
||||
|---|---|---|
|
||||
| Variante A voll | ~12 | ~2–3 GB |
|
||||
| Variante A schlank | 6 (db, rest, storage, kong, teamvis, caddy) | ~1–1.5 GB |
|
||||
| Externe Supabase (NEUKUNDE.md) | 2 (teamvis, caddy) | ~0.4 GB |
|
||||
|
||||
---
|
||||
|
||||
## Befunde aus dem Testlauf (2026-06-07)
|
||||
|
||||
Lean-Stack (db + rest + storage + Caddy-Router + app) auf zfx-vps hochgezogen,
|
||||
nur an localhost gebunden. Drei Bootstrap-Lücken gefunden:
|
||||
|
||||
1. **Basis-Schema fehlte** — die Migrationen legen `employees`/`admin_users`
|
||||
nicht an (nur additive `add column if not exists`). `bundle-migrations`
|
||||
bootstrappt eine leere DB also nicht. **Gelöst:** neue Migration
|
||||
`supabase/migrations/0000_base_schema.sql` (idempotent, auf
|
||||
Bestandsinstanzen ein No-Op). Betrifft auch den `NEUKUNDE.md`-Weg.
|
||||
2. **Rollen-Passwörter** — `supabase/postgres` setzt nur `supabase_admin` auf
|
||||
`POSTGRES_PASSWORD`; `authenticator`/`supabase_storage_admin` brauchen es
|
||||
manuell (Variante B). Variante A erledigt das.
|
||||
3. **service_role-Grants** — frisch erzeugte Tabellen geben `service_role`
|
||||
keine Rechte; auf echtem Supabase passiert das automatisch. Grants +
|
||||
`bypassrls` nötig (Variante B). Variante A erledigt das.
|
||||
|
||||
**Nach den Fixes verifiziert (alles grün):**
|
||||
|
||||
| Test | Ergebnis |
|
||||
|---|---|
|
||||
| Schema-Bündel (mit 0000) auf leerer DB | 0 Fehler, 23 Tabellen |
|
||||
| `/api/health` | `ok:true`, `db.ok:true` |
|
||||
| Öffentliche Karte `/<slug>` | HTTP 200 |
|
||||
| vCard `/api/vcard/<slug>` | HTTP 200 |
|
||||
| anon-REST: aktive MA lesen | ✅ (RLS erlaubt) |
|
||||
| anon-REST: Geheim-Spalte `phone_mobile` | `permission denied` ✅ (0045 greift) |
|
||||
| service_role-REST | ✅ (nach Grants) |
|
||||
| Storage-Upload + öffentlicher Abruf | HTTP 200, Inhalt korrekt |
|
||||
|
||||
**Noch offen für „voll kundenreif":** ein Lauf von **Variante A** (offizieller
|
||||
Stack — bestätigt, dass Punkt 2+3 dort automatisch wegfallen) sowie ein Test
|
||||
des Admin-Logins + Portal-Magic-Links über die echten HTTPS-Domains (Caddy/TLS
|
||||
war im localhost-Test nicht aktiv).
|
||||
|
||||
## Dateien hier
|
||||
|
||||
| Datei | Zweck |
|
||||
|---|---|
|
||||
| `docker-compose.override.yml` | TeamVis-App + Caddy über dem Supabase-Stack |
|
||||
| `.env.teamvis.example` | Zusatz-ENV für die Supabase-.env |
|
||||
| `Caddyfile.example` | TLS-Ingress (App- + Supabase-Domain) |
|
||||
| `gen-keys.mjs` | erzeugt JWT_SECRET + ANON_KEY + SERVICE_ROLE_KEY |
|
||||
| `bootstrap.sh` | Keys + Stack + Schema + Admin in einem Rutsch |
|
||||
Executable
+65
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
# =====================================================================
|
||||
# TeamVis All-in-One — Bootstrap (Referenz-Helfer)
|
||||
# =====================================================================
|
||||
# Bringt eine frische All-in-One-Instanz hoch:
|
||||
# 1. Keys erzeugen (falls noch Demo-Defaults in der Supabase-.env)
|
||||
# 2. Stack starten (Supabase + TeamVis + Caddy)
|
||||
# 3. TeamVis-Schema einspielen (Migrations-Bündel → Postgres)
|
||||
# 4. ersten Admin anlegen
|
||||
#
|
||||
# NICHT blind ausführen — Schritt für Schritt lesen. Erwartet:
|
||||
# - dieses Skript liegt im supabase/docker-Verzeichnis (neben der
|
||||
# docker-compose.yml + .env + dem Override)
|
||||
# - das TeamVis-Repo ist unter $TEAMVIS_REPO erreichbar (für die
|
||||
# Skripte bundle-migrations.mjs / create-admin.mjs)
|
||||
# - Node 20+, docker compose, openssl auf dem Host
|
||||
#
|
||||
# Aufruf: TEAMVIS_REPO=/pfad/zum/repo ADMIN_EMAIL=chef@kunde.de ./bootstrap.sh
|
||||
set -euo pipefail
|
||||
|
||||
REPO="${TEAMVIS_REPO:?Bitte TEAMVIS_REPO=/pfad/zum/team-stwhas-repo setzen}"
|
||||
ADMIN_EMAIL="${ADMIN_EMAIL:?Bitte ADMIN_EMAIL=… setzen}"
|
||||
ENV_FILE="./.env"
|
||||
|
||||
# --- 1. Keys ---------------------------------------------------------
|
||||
if grep -q "your-super-secret-jwt-token" "$ENV_FILE" 2>/dev/null; then
|
||||
echo "→ Erzeuge JWT_SECRET + ANON_KEY + SERVICE_ROLE_KEY …"
|
||||
KEYS=$(node "$REPO/deploy/selfhost/gen-keys.mjs")
|
||||
# Demo-Zeilen ersetzen (macOS/BSD-sed-kompatibel über awk).
|
||||
for k in JWT_SECRET ANON_KEY SERVICE_ROLE_KEY; do
|
||||
val=$(printf '%s\n' "$KEYS" | sed -n "s/^$k=//p")
|
||||
awk -v K="$k" -v V="$val" 'BEGIN{done=0}
|
||||
$0 ~ "^"K"=" && !done {print K"="V; done=1; next} {print}' "$ENV_FILE" > "$ENV_FILE.tmp"
|
||||
mv "$ENV_FILE.tmp" "$ENV_FILE"
|
||||
done
|
||||
echo " ok — Keys in $ENV_FILE gesetzt."
|
||||
else
|
||||
echo "→ Keys schon gesetzt (keine Demo-Defaults gefunden) — überspringe."
|
||||
fi
|
||||
|
||||
# --- 2. Stack starten ------------------------------------------------
|
||||
echo "→ Starte Stack …"
|
||||
docker compose up -d
|
||||
echo "→ Warte auf gesunde Datenbank …"
|
||||
until docker compose exec -T db pg_isready -U postgres >/dev/null 2>&1; do sleep 2; done
|
||||
# Storage initialisiert sein storage-Schema beim ersten Start — kurz warten.
|
||||
sleep 8
|
||||
|
||||
# --- 3. Schema einspielen -------------------------------------------
|
||||
echo "→ Spiele TeamVis-Schema ein (Migrations-Bündel) …"
|
||||
node "$REPO/scripts/bundle-migrations.mjs" > /tmp/teamvis-schema.sql
|
||||
docker compose exec -T db psql -v ON_ERROR_STOP=1 -U postgres -d postgres < /tmp/teamvis-schema.sql
|
||||
echo " ok — Schema eingespielt."
|
||||
|
||||
# --- 4. Admin anlegen ------------------------------------------------
|
||||
echo "→ Lege Admin '$ADMIN_EMAIL' an (Passwort wird abgefragt) …"
|
||||
SUPABASE_PUBLIC_URL=$(sed -n 's/^SUPABASE_PUBLIC_URL=//p' "$ENV_FILE")
|
||||
SERVICE_ROLE_KEY=$(sed -n 's/^SERVICE_ROLE_KEY=//p' "$ENV_FILE")
|
||||
NEXT_PUBLIC_SUPABASE_URL="$SUPABASE_PUBLIC_URL" \
|
||||
SUPABASE_SERVICE_ROLE_KEY="$SERVICE_ROLE_KEY" \
|
||||
node "$REPO/scripts/create-admin.mjs" "$ADMIN_EMAIL"
|
||||
|
||||
echo
|
||||
echo "Fertig. App: \$SITE_URL · Supabase: \$SUPABASE_PUBLIC_URL"
|
||||
echo "Danach unter Admin → Funktionen die Module gestaffelt freischalten."
|
||||
@@ -0,0 +1,77 @@
|
||||
# =====================================================================
|
||||
# TeamVis — All-in-One Self-Hosting (Override für den offiziellen
|
||||
# Supabase-Stack)
|
||||
# =====================================================================
|
||||
# Dieses Override legt zwei Dienste ÜBER den unveränderten
|
||||
# supabase/docker-Stack (github.com/supabase/supabase → docker/):
|
||||
# - teamvis : die App (gleiches Image wie SaaS, alles via Runtime-ENV)
|
||||
# - caddy : TLS-Ingress für App- und Supabase-Domain (Auto-HTTPS)
|
||||
#
|
||||
# Datei NEBEN die supabase/docker/docker-compose.yml legen (gleiches
|
||||
# Verzeichnis), dann startet `docker compose up -d` beide zusammen.
|
||||
# Service-Namen db/kong/storage stammen aus dem Supabase-Stack.
|
||||
#
|
||||
# WICHTIG zu SMTP: der Supabase-Stack belegt SMTP_* selbst (GoTrue-Mailer,
|
||||
# das TeamVis NICHT nutzt). Damit sich die beiden nicht in die Quere kommen,
|
||||
# liest die App ihre SMTP-Werte aus TEAMVIS_SMTP_*-Variablen — NICHT aus den
|
||||
# SMTP_*-Werten der Supabase-.env. Deshalb hier KEIN env_file, sondern
|
||||
# explizite environment-Map.
|
||||
|
||||
services:
|
||||
teamvis:
|
||||
image: git.zoesch.de/zfx-services/teamvis:${TEAMVIS_VERSION:-0.12.0}
|
||||
container_name: teamvis-app
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
kong:
|
||||
condition: service_started
|
||||
storage:
|
||||
condition: service_started
|
||||
environment:
|
||||
# Supabase-Anbindung — SUPABASE_PUBLIC_URL muss die ÖFFENTLICHE
|
||||
# HTTPS-URL sein (steht in den Foto-URLs der Karten, next/image
|
||||
# erlaubt nur https auf /storage/v1/object/public/**).
|
||||
SUPABASE_URL: ${SUPABASE_PUBLIC_URL}
|
||||
NEXT_PUBLIC_SUPABASE_URL: ${SUPABASE_PUBLIC_URL}
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${ANON_KEY}
|
||||
SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
|
||||
# App-Session (>= 32 Zeichen, pro Instanz EINMALIG).
|
||||
SESSION_SECRET: ${SESSION_SECRET}
|
||||
# Öffentliche Basis-URL der Karten/QR/vCard.
|
||||
NEXT_PUBLIC_SITE_URL: ${SITE_URL}
|
||||
# SMTP der App (eigene Variablen, s.o.). Fehlt es, landen
|
||||
# Magic-Links nur im Container-Log.
|
||||
SMTP_HOST: ${TEAMVIS_SMTP_HOST:-}
|
||||
SMTP_PORT: ${TEAMVIS_SMTP_PORT:-587}
|
||||
SMTP_SECURE: ${TEAMVIS_SMTP_SECURE:-}
|
||||
SMTP_USER: ${TEAMVIS_SMTP_USER:-}
|
||||
SMTP_PASS: ${TEAMVIS_SMTP_PASS:-}
|
||||
SMTP_FROM_EMAIL: ${TEAMVIS_SMTP_FROM_EMAIL:-noreply@example.com}
|
||||
SMTP_FROM_NAME: ${TEAMVIS_SMTP_FROM_NAME:-TeamVis}
|
||||
# Optional zusätzliche next/image-Hosts (i.d.R. nicht nötig, da
|
||||
# next.config bereits jeden https-Host auf dem public-Storage-Pfad
|
||||
# erlaubt).
|
||||
SUPABASE_IMAGE_HOSTS: ${SUPABASE_IMAGE_HOSTS:-}
|
||||
expose:
|
||||
- "3000"
|
||||
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
container_name: teamvis-caddy
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- teamvis
|
||||
- kong
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy-data:/data
|
||||
- caddy-config:/config
|
||||
|
||||
volumes:
|
||||
caddy-data:
|
||||
caddy-config:
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env node
|
||||
// =====================================================================
|
||||
// Erzeugt die Schlüssel für eine self-gehostete Supabase-Instanz:
|
||||
// - JWT_SECRET (zufällig, >= 40 Zeichen)
|
||||
// - ANON_KEY (HS256-JWT, role=anon, 10 Jahre gültig)
|
||||
// - SERVICE_ROLE_KEY (HS256-JWT, role=service_role, 10 Jahre gültig)
|
||||
//
|
||||
// anon/service-Key sind KEINE Passwörter, sondern JWTs, die mit dem
|
||||
// JWT_SECRET signiert sind — exakt wie bei Supabase Cloud. PostgREST und
|
||||
// Storage validieren sie lokal gegen das Secret (kein GoTrue nötig).
|
||||
//
|
||||
// Nutzung:
|
||||
// node gen-keys.mjs # neues Secret + passende Keys
|
||||
// node gen-keys.mjs <bestehendes-JWT_SECRET> # Keys zu vorhandenem Secret
|
||||
//
|
||||
// Ausgabe ist direkt in die Supabase-.env kopierbar.
|
||||
// Pure Node-crypto, keine Abhängigkeiten.
|
||||
// =====================================================================
|
||||
|
||||
import { createHmac, randomBytes } from "node:crypto";
|
||||
|
||||
function b64url(input) {
|
||||
return Buffer.from(input)
|
||||
.toString("base64")
|
||||
.replace(/=/g, "")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_");
|
||||
}
|
||||
|
||||
function signJwt(payload, secret) {
|
||||
const header = { alg: "HS256", typ: "JWT" };
|
||||
const head = b64url(JSON.stringify(header));
|
||||
const body = b64url(JSON.stringify(payload));
|
||||
const data = `${head}.${body}`;
|
||||
const sig = createHmac("sha256", secret).update(data).digest("base64")
|
||||
.replace(/=/g, "")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_");
|
||||
return `${data}.${sig}`;
|
||||
}
|
||||
|
||||
const secret = process.argv[2] || randomBytes(48).toString("base64url");
|
||||
if (secret.length < 32) {
|
||||
console.error("JWT_SECRET muss mindestens 32 Zeichen lang sein.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const iat = Math.floor(Date.now() / 1000);
|
||||
const exp = iat + 60 * 60 * 24 * 365 * 10; // 10 Jahre
|
||||
|
||||
const anon = signJwt({ role: "anon", iss: "supabase", iat, exp }, secret);
|
||||
const service = signJwt(
|
||||
{ role: "service_role", iss: "supabase", iat, exp },
|
||||
secret,
|
||||
);
|
||||
|
||||
console.log(`JWT_SECRET=${secret}`);
|
||||
console.log(`ANON_KEY=${anon}`);
|
||||
console.log(`SERVICE_ROLE_KEY=${service}`);
|
||||
Executable
+482
@@ -0,0 +1,482 @@
|
||||
#!/usr/bin/env bash
|
||||
# =====================================================================
|
||||
# TeamVis — Ein-Schritt-Installer
|
||||
# =====================================================================
|
||||
# Bringt eine komplette TeamVis-Instanz in einem Rutsch hoch. Beim Start
|
||||
# wählst du, woher die Datenbank kommt:
|
||||
#
|
||||
# [1] Bestehende/eigene Supabase — Cloud-Projekt ODER bereits self-
|
||||
# gehostetes Supabase. Es läuft NUR die App (+ optional Caddy/TLS).
|
||||
#
|
||||
# [2] Alles mitinstallieren — schlanker Supabase-Stack als
|
||||
# Container (Postgres + PostgREST + Storage) + App + Caddy/TLS,
|
||||
# Single-Domain. Kein vorab eingerichtetes Supabase nötig.
|
||||
#
|
||||
# Erledigt automatisch: Schlüssel/Secrets erzeugen, ENV + Compose +
|
||||
# Caddyfile schreiben, Schema einspielen, (Modus 2) Rollen-Bootstrap +
|
||||
# Grants, Container starten, ersten Admin anlegen.
|
||||
#
|
||||
# Aufruf: bash deploy/selfhost/install.sh
|
||||
# Variablen lassen sich vorab per ENV setzen (überspringt die Abfrage),
|
||||
# z. B.: APP_DOMAIN=team.kunde.de ADMIN_EMAIL=chef@kunde.de bash install.sh
|
||||
#
|
||||
# Voraussetzungen: docker + docker compose v2, node 20+, openssl; DNS der
|
||||
# Domain auf diesen Host, Ports 80/443 frei; Login an der Registry
|
||||
# git.zfx.services (docker login) für das App-Image.
|
||||
set -euo pipefail
|
||||
|
||||
# REPO-Wurzel (dieses Skript liegt in deploy/selfhost/).
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
|
||||
# ── Ausgabe-Helfer ────────────────────────────────────────────────────
|
||||
c_bold=$'\033[1m'; c_dim=$'\033[2m'; c_grn=$'\033[32m'; c_red=$'\033[31m'; c_yel=$'\033[33m'; c_rst=$'\033[0m'
|
||||
say() { printf '%s\n' "$*"; }
|
||||
head() { printf '\n%s%s%s\n' "$c_bold" "$*" "$c_rst"; }
|
||||
ok() { printf '%s✓%s %s\n' "$c_grn" "$c_rst" "$*"; }
|
||||
warn() { printf '%s!%s %s\n' "$c_yel" "$c_rst" "$*"; }
|
||||
die() { printf '%s✗ %s%s\n' "$c_red" "$*" "$c_rst" >&2; exit 1; }
|
||||
|
||||
# ask VAR "Frage" "default" → nutzt $VAR falls gesetzt, sonst fragt nach.
|
||||
ask() {
|
||||
local __var="$1" __q="$2" __def="${3:-}" __cur __ans
|
||||
__cur="$(eval "printf '%s' \"\${$__var:-}\"")"
|
||||
if [ -n "$__cur" ]; then return 0; fi
|
||||
if [ -n "$__def" ]; then printf '%s [%s]: ' "$__q" "$__def"; else printf '%s: ' "$__q"; fi
|
||||
read -r __ans || true
|
||||
__ans="${__ans:-$__def}"
|
||||
printf -v "$__var" '%s' "$__ans"
|
||||
}
|
||||
ask_secret() {
|
||||
local __var="$1" __q="$2" __ans __cur
|
||||
__cur="$(eval "printf '%s' \"\${$__var:-}\"")"
|
||||
if [ -n "$__cur" ]; then return 0; fi
|
||||
printf '%s: ' "$__q"; read -rs __ans || true; printf '\n'
|
||||
printf -v "$__var" '%s' "$__ans"
|
||||
}
|
||||
yesno() { # yesno VAR "Frage" default(j/n)
|
||||
local __var="$1" __q="$2" __def="${3:-j}" __ans __cur
|
||||
__cur="$(eval "printf '%s' \"\${$__var:-}\"")"
|
||||
if [ -n "$__cur" ]; then return 0; fi
|
||||
printf '%s (j/n) [%s]: ' "$__q" "$__def"; read -r __ans || true
|
||||
__ans="${__ans:-$__def}"; printf -v "$__var" '%s' "$__ans"
|
||||
}
|
||||
|
||||
# ── Voraussetzungen ───────────────────────────────────────────────────
|
||||
head "TeamVis — Ein-Schritt-Installer"
|
||||
command -v docker >/dev/null || die "docker fehlt."
|
||||
docker compose version >/dev/null 2>&1 || die "docker compose v2 fehlt."
|
||||
command -v node >/dev/null || die "node (20+) fehlt — wird für Keys/Schema/Admin gebraucht."
|
||||
command -v openssl >/dev/null || die "openssl fehlt."
|
||||
|
||||
DEFAULT_VERSION="$(node -p "require('$REPO/package.json').version" 2>/dev/null || echo latest)"
|
||||
# Registry-Host des App-Images (muss zum `docker login` passen). Image-Pfad
|
||||
# bleibt zfx-services/teamvis. Per ENV überschreibbar (z. B. git.zoesch.de).
|
||||
REGISTRY="${REGISTRY:-git.zfx.services}"
|
||||
|
||||
# ── Gemeinsame Abfragen ───────────────────────────────────────────────
|
||||
head "1) Basis-Angaben"
|
||||
ask NAME "Kurzname der Instanz (Verzeichnis/Compose-Projekt)" "teamvis"
|
||||
ask APP_DOMAIN "Domain der App (DNS muss auf diesen Host zeigen)" ""
|
||||
[ -n "$APP_DOMAIN" ] || die "Eine Domain ist nötig."
|
||||
ask TEAMVIS_VERSION "TeamVis-Image-Version" "$DEFAULT_VERSION"
|
||||
ask ADMIN_EMAIL "E-Mail des ersten Admins" ""
|
||||
[ -n "$ADMIN_EMAIL" ] || die "Admin-E-Mail ist nötig."
|
||||
ask ADMIN_NAME "Name des ersten Admins" "Administrator"
|
||||
|
||||
TARGET="${TARGET:-$PWD/$NAME}"
|
||||
ask TARGET "Zielverzeichnis" "$TARGET"
|
||||
if [ -e "$TARGET/docker-compose.yml" ]; then
|
||||
yesno OVERWRITE "Im Zielverzeichnis liegt schon eine Installation — überschreiben?" "n"
|
||||
case "$OVERWRITE" in [jJyY]*) : ;; *) die "Abgebrochen.";; esac
|
||||
fi
|
||||
mkdir -p "$TARGET"
|
||||
|
||||
head "2) SMTP (optional — leer lassen, dann landen Magic-Links nur im Log)"
|
||||
ask SMTP_HOST "SMTP-Host" ""
|
||||
if [ -n "$SMTP_HOST" ]; then
|
||||
ask SMTP_PORT "SMTP-Port" "587"
|
||||
ask SMTP_USER "SMTP-User" ""
|
||||
ask_secret SMTP_PASS "SMTP-Passwort"
|
||||
ask SMTP_FROM_EMAIL "Absender-Adresse" "noreply@$APP_DOMAIN"
|
||||
ask SMTP_FROM_NAME "Absender-Name" "TeamVis"
|
||||
fi
|
||||
SMTP_PORT="${SMTP_PORT:-587}"; SMTP_USER="${SMTP_USER:-}"; SMTP_PASS="${SMTP_PASS:-}"
|
||||
SMTP_FROM_EMAIL="${SMTP_FROM_EMAIL:-noreply@$APP_DOMAIN}"; SMTP_FROM_NAME="${SMTP_FROM_NAME:-TeamVis}"; SMTP_SECURE="${SMTP_SECURE:-}"
|
||||
|
||||
# App-Session-Secret immer frisch (pro Instanz einmalig).
|
||||
SESSION_SECRET="${SESSION_SECRET:-$(openssl rand -base64 48)}"
|
||||
SITE_URL="https://$APP_DOMAIN"
|
||||
|
||||
# ── Modus-Wahl ────────────────────────────────────────────────────────
|
||||
head "3) Datenbank"
|
||||
say " [1] Bestehende/eigene Supabase nutzen (Cloud oder self-hosted)"
|
||||
say " [2] Schlanke Supabase als Container mitinstallieren (kein Setup nötig)"
|
||||
ask MODE "Auswahl" "2"
|
||||
case "$MODE" in 1|2) : ;; *) die "Bitte 1 oder 2.";; esac
|
||||
|
||||
# Repo-Abhängigkeiten für create-admin.mjs sicherstellen.
|
||||
ensure_node_deps() {
|
||||
if [ ! -d "$REPO/node_modules/@supabase/supabase-js" ] || [ ! -d "$REPO/node_modules/bcryptjs" ]; then
|
||||
warn "Node-Abhängigkeiten fehlen — installiere (einmalig) im Repo …"
|
||||
( cd "$REPO" && npm install --no-audit --no-fund )
|
||||
fi
|
||||
}
|
||||
|
||||
# Schema-Bündel erzeugen.
|
||||
build_schema() {
|
||||
node "$REPO/scripts/bundle-migrations.mjs" > "$TARGET/teamvis-schema.sql"
|
||||
ok "Schema-Bündel: $TARGET/teamvis-schema.sql ($(grep -c '^-- ' "$TARGET/teamvis-schema.sql" 2>/dev/null || echo '?') Abschnitte)"
|
||||
}
|
||||
|
||||
write_env() { # write_env "extra lines…" via stdin
|
||||
: > "$TARGET/.env"
|
||||
while IFS= read -r line; do printf '%s\n' "$line" >> "$TARGET/.env"; done
|
||||
chmod 600 "$TARGET/.env"
|
||||
}
|
||||
|
||||
create_admin() {
|
||||
ensure_node_deps
|
||||
head "Ersten Admin anlegen ($ADMIN_EMAIL)"
|
||||
NEXT_PUBLIC_SUPABASE_URL="$1" \
|
||||
SUPABASE_SERVICE_ROLE_KEY="$2" \
|
||||
node "$REPO/scripts/create-admin.mjs" "$ADMIN_EMAIL" --name "$ADMIN_NAME" --role admin
|
||||
}
|
||||
|
||||
#######################################################################
|
||||
# MODUS 1 — Bestehende/eigene Supabase
|
||||
#######################################################################
|
||||
if [ "$MODE" = "1" ]; then
|
||||
head "Modus 1 — bestehende Supabase"
|
||||
ask SUPABASE_URL "Supabase-URL (z. B. https://xxxx.supabase.co)" ""
|
||||
[ -n "$SUPABASE_URL" ] || die "Supabase-URL nötig."
|
||||
ask_secret ANON_KEY "Supabase anon-Key"
|
||||
ask_secret SERVICE_ROLE_KEY "Supabase service_role-Key"
|
||||
[ -n "${ANON_KEY:-}" ] && [ -n "${SERVICE_ROLE_KEY:-}" ] || die "anon- und service_role-Key nötig."
|
||||
yesno WITH_CADDY "Soll Caddy TLS für $APP_DOMAIN übernehmen? (n = du hast einen eigenen Reverse-Proxy)" "j"
|
||||
|
||||
# .env (App liest zur Laufzeit)
|
||||
write_env <<EOF
|
||||
NEXT_PUBLIC_SUPABASE_URL=$SUPABASE_URL
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=$ANON_KEY
|
||||
SUPABASE_SERVICE_ROLE_KEY=$SERVICE_ROLE_KEY
|
||||
SESSION_SECRET=$SESSION_SECRET
|
||||
NEXT_PUBLIC_SITE_URL=$SITE_URL
|
||||
SMTP_HOST=$SMTP_HOST
|
||||
SMTP_PORT=$SMTP_PORT
|
||||
SMTP_SECURE=$SMTP_SECURE
|
||||
SMTP_USER=$SMTP_USER
|
||||
SMTP_PASS=$SMTP_PASS
|
||||
SMTP_FROM_EMAIL=$SMTP_FROM_EMAIL
|
||||
SMTP_FROM_NAME=$SMTP_FROM_NAME
|
||||
TEAMVIS_VERSION=$TEAMVIS_VERSION
|
||||
EOF
|
||||
ok ".env geschrieben."
|
||||
|
||||
# Compose
|
||||
if [ "${WITH_CADDY:0:1}" = "j" ] || [ "${WITH_CADDY:0:1}" = "J" ]; then
|
||||
cat > "$TARGET/Caddyfile" <<EOF
|
||||
$APP_DOMAIN {
|
||||
reverse_proxy app:3000
|
||||
}
|
||||
EOF
|
||||
cat > "$TARGET/docker-compose.yml" <<'YAML'
|
||||
name: teamvis-__NAME__
|
||||
services:
|
||||
app:
|
||||
image: __REGISTRY__/zfx-services/teamvis:${TEAMVIS_VERSION}
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
expose: ["3000"]
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/').then(r=>process.exit(r.status<500?0:1)).catch(()=>process.exit(1))"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
restart: unless-stopped
|
||||
depends_on: [app]
|
||||
ports: ["80:80", "443:443"]
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy-data:/data
|
||||
- caddy-config:/config
|
||||
volumes:
|
||||
caddy-data:
|
||||
caddy-config:
|
||||
YAML
|
||||
else
|
||||
ask APP_PORT "Lokaler Port für deinen Reverse-Proxy" "3001"
|
||||
cat > "$TARGET/docker-compose.yml" <<YAML
|
||||
name: teamvis-__NAME__
|
||||
services:
|
||||
app:
|
||||
image: __REGISTRY__/zfx-services/teamvis:\${TEAMVIS_VERSION}
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
ports: ["127.0.0.1:$APP_PORT:3000"]
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/').then(r=>process.exit(r.status<500?0:1)).catch(()=>process.exit(1))"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
YAML
|
||||
fi
|
||||
sed -i.bak -e "s|__REGISTRY__|$REGISTRY|" -e "s/__NAME__/$NAME/" "$TARGET/docker-compose.yml" && rm -f "$TARGET/docker-compose.yml.bak"
|
||||
ok "docker-compose.yml geschrieben."
|
||||
|
||||
# Schema
|
||||
head "Schema einspielen"
|
||||
build_schema
|
||||
if [ -n "${DATABASE_URL:-}" ]; then
|
||||
say "→ Wende Schema per DATABASE_URL an …"
|
||||
docker run --rm -i postgres:15 psql "$DATABASE_URL" -v ON_ERROR_STOP=1 < "$TARGET/teamvis-schema.sql"
|
||||
ok "Schema eingespielt."
|
||||
else
|
||||
warn "Kein DATABASE_URL gesetzt — Schema bitte EINMAL in Supabase Studio (SQL-Editor) einspielen:"
|
||||
say " $TARGET/teamvis-schema.sql"
|
||||
read -r -p " Enter drücken, sobald das Schema in der DB ist … " _ || true
|
||||
fi
|
||||
|
||||
# Start + Admin
|
||||
head "Container starten"
|
||||
( cd "$TARGET" && docker compose pull && docker compose up -d )
|
||||
ok "App läuft."
|
||||
create_admin "$SUPABASE_URL" "$SERVICE_ROLE_KEY"
|
||||
|
||||
#######################################################################
|
||||
# MODUS 2 — Supabase als Container mitinstallieren (Single-Domain)
|
||||
#######################################################################
|
||||
else
|
||||
head "Modus 2 — schlanke Supabase mitinstallieren"
|
||||
say "Single-Domain: App, /rest/v1 und /storage/v1 laufen alle unter https://$APP_DOMAIN."
|
||||
|
||||
POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-$(openssl rand -base64 30 | tr -d '/+=' | cut -c1-32)}"
|
||||
POSTGRES_DB="${POSTGRES_DB:-postgres}"
|
||||
# JWT_SECRET + ANON_KEY + SERVICE_ROLE_KEY erzeugen (HS256, 10 J. gültig).
|
||||
KEYS="$(node "$REPO/deploy/selfhost/gen-keys.mjs")"
|
||||
JWT_SECRET="$(printf '%s\n' "$KEYS" | sed -n 's/^JWT_SECRET=//p')"
|
||||
ANON_KEY="$(printf '%s\n' "$KEYS" | sed -n 's/^ANON_KEY=//p')"
|
||||
SERVICE_ROLE_KEY="$(printf '%s\n' "$KEYS" | sed -n 's/^SERVICE_ROLE_KEY=//p')"
|
||||
ok "Schlüssel erzeugt (JWT_SECRET, ANON_KEY, SERVICE_ROLE_KEY)."
|
||||
|
||||
write_env <<EOF
|
||||
# ── Postgres ──────────────────────────────────────────
|
||||
POSTGRES_PASSWORD=$POSTGRES_PASSWORD
|
||||
POSTGRES_DB=$POSTGRES_DB
|
||||
# ── Supabase-Keys (mit JWT_SECRET signiert) ───────────
|
||||
JWT_SECRET=$JWT_SECRET
|
||||
ANON_KEY=$ANON_KEY
|
||||
SERVICE_ROLE_KEY=$SERVICE_ROLE_KEY
|
||||
# ── Öffentliche URLs (Single-Domain) ──────────────────
|
||||
SITE_URL=$SITE_URL
|
||||
SUPABASE_PUBLIC_URL=$SITE_URL
|
||||
# ── App-Session ───────────────────────────────────────
|
||||
SESSION_SECRET=$SESSION_SECRET
|
||||
# ── App-SMTP ──────────────────────────────────────────
|
||||
SMTP_HOST=$SMTP_HOST
|
||||
SMTP_PORT=$SMTP_PORT
|
||||
SMTP_SECURE=$SMTP_SECURE
|
||||
SMTP_USER=$SMTP_USER
|
||||
SMTP_PASS=$SMTP_PASS
|
||||
SMTP_FROM_EMAIL=$SMTP_FROM_EMAIL
|
||||
SMTP_FROM_NAME=$SMTP_FROM_NAME
|
||||
# ── App-Image ─────────────────────────────────────────
|
||||
TEAMVIS_VERSION=$TEAMVIS_VERSION
|
||||
EOF
|
||||
ok ".env geschrieben."
|
||||
|
||||
# Caddyfile (Single-Domain: TLS + Pfad-Routing)
|
||||
cat > "$TARGET/Caddyfile" <<EOF
|
||||
$APP_DOMAIN {
|
||||
handle_path /rest/v1/* {
|
||||
reverse_proxy rest:3000
|
||||
}
|
||||
handle_path /storage/v1/* {
|
||||
reverse_proxy storage:5000
|
||||
}
|
||||
handle {
|
||||
reverse_proxy app:3000
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# Compose (db + rest + storage + caddy + app). ${...} bleibt literal
|
||||
# → docker liest die Werte zur Laufzeit aus der .env.
|
||||
cat > "$TARGET/docker-compose.yml" <<'YAML'
|
||||
name: teamvis-__NAME__
|
||||
services:
|
||||
db:
|
||||
image: supabase/postgres:15.8.1.085
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
volumes:
|
||||
- db-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d ${POSTGRES_DB}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
rest:
|
||||
image: postgrest/postgrest:v14.12
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||
PGRST_DB_SCHEMAS: public,storage
|
||||
PGRST_DB_EXTRA_SEARCH_PATH: public
|
||||
PGRST_DB_ANON_ROLE: anon
|
||||
PGRST_DB_MAX_ROWS: "1000"
|
||||
PGRST_JWT_SECRET: ${JWT_SECRET}
|
||||
PGRST_DB_USE_LEGACY_GUCS: "false"
|
||||
|
||||
storage:
|
||||
image: supabase/storage-api:v1.60.4
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
rest:
|
||||
condition: service_started
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://storage:5000/status"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 6
|
||||
start_period: 15s
|
||||
environment:
|
||||
ANON_KEY: ${ANON_KEY}
|
||||
SERVICE_KEY: ${SERVICE_ROLE_KEY}
|
||||
POSTGREST_URL: http://rest:3000
|
||||
AUTH_JWT_SECRET: ${JWT_SECRET}
|
||||
DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||
STORAGE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL}
|
||||
REQUEST_ALLOW_X_FORWARDED_PATH: "true"
|
||||
FILE_SIZE_LIMIT: "52428800"
|
||||
STORAGE_BACKEND: file
|
||||
GLOBAL_S3_BUCKET: stub
|
||||
FILE_STORAGE_BACKEND_PATH: /var/lib/storage
|
||||
TENANT_ID: stub
|
||||
REGION: stub
|
||||
ENABLE_IMAGE_TRANSFORMATION: "false"
|
||||
volumes:
|
||||
- ./volumes/storage:/var/lib/storage
|
||||
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- app
|
||||
- rest
|
||||
- storage
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy-data:/data
|
||||
- caddy-config:/config
|
||||
|
||||
app:
|
||||
image: __REGISTRY__/zfx-services/teamvis:${TEAMVIS_VERSION}
|
||||
restart: unless-stopped
|
||||
# Hairpin-NAT: die App ruft SUPABASE_PUBLIC_URL (= eigene Domain) auch
|
||||
# serverseitig auf → Domain auf den Host (Caddy) zeigen lassen.
|
||||
extra_hosts:
|
||||
- "__APP_DOMAIN__:host-gateway"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
SUPABASE_URL: ${SUPABASE_PUBLIC_URL}
|
||||
NEXT_PUBLIC_SUPABASE_URL: ${SUPABASE_PUBLIC_URL}
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${ANON_KEY}
|
||||
SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
|
||||
SESSION_SECRET: ${SESSION_SECRET}
|
||||
NEXT_PUBLIC_SITE_URL: ${SITE_URL}
|
||||
SMTP_HOST: ${SMTP_HOST:-}
|
||||
SMTP_PORT: ${SMTP_PORT:-587}
|
||||
SMTP_SECURE: ${SMTP_SECURE:-}
|
||||
SMTP_USER: ${SMTP_USER:-}
|
||||
SMTP_PASS: ${SMTP_PASS:-}
|
||||
SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL:-}
|
||||
SMTP_FROM_NAME: ${SMTP_FROM_NAME:-TeamVis}
|
||||
expose:
|
||||
- "3000"
|
||||
|
||||
volumes:
|
||||
db-data:
|
||||
YAML
|
||||
sed -i.bak -e "s|__REGISTRY__|$REGISTRY|" -e "s/__NAME__/$NAME/" -e "s/__APP_DOMAIN__/$APP_DOMAIN/" "$TARGET/docker-compose.yml" && rm -f "$TARGET/docker-compose.yml.bak"
|
||||
ok "docker-compose.yml + Caddyfile geschrieben."
|
||||
|
||||
# psql-Helfer (Superuser, lokaler Socket-Trust im db-Container).
|
||||
dbsql() { ( cd "$TARGET" && docker compose exec -T db psql -v ON_ERROR_STOP=1 -U postgres -d "$POSTGRES_DB" "$@" ); }
|
||||
|
||||
head "Datenbank starten + Rollen-Bootstrap"
|
||||
( cd "$TARGET" && docker compose pull && docker compose up -d db )
|
||||
say "→ Warte auf gesunde Datenbank …"
|
||||
until ( cd "$TARGET" && docker compose exec -T db pg_isready -U postgres -d "$POSTGRES_DB" >/dev/null 2>&1 ); do sleep 2; done
|
||||
# Rollen-Passwörter (das Image setzt nur supabase_admin) — VOR rest/storage.
|
||||
dbsql <<SQL
|
||||
alter role authenticator with login password '$POSTGRES_PASSWORD';
|
||||
alter role supabase_storage_admin with login password '$POSTGRES_PASSWORD';
|
||||
alter role supabase_auth_admin with login password '$POSTGRES_PASSWORD';
|
||||
SQL
|
||||
ok "Rollen-Passwörter gesetzt."
|
||||
|
||||
head "REST + Storage starten"
|
||||
( cd "$TARGET" && docker compose up -d rest storage )
|
||||
say "→ Warte auf Storage (legt sein storage-Schema an) …"
|
||||
for _ in $(seq 1 30); do
|
||||
if ( cd "$TARGET" && docker compose exec -T db psql -U postgres -d "$POSTGRES_DB" -tAc "select 1 from information_schema.schemata where schema_name='storage'" 2>/dev/null | grep -q 1 ); then break; fi
|
||||
sleep 2
|
||||
done
|
||||
ok "Storage initialisiert."
|
||||
|
||||
head "Schema einspielen"
|
||||
build_schema
|
||||
dbsql < "$TARGET/teamvis-schema.sql"
|
||||
ok "TeamVis-Schema eingespielt."
|
||||
|
||||
# service_role-Grants (auf echtem Supabase automatisch) — NACH dem Schema.
|
||||
dbsql <<SQL
|
||||
alter role service_role bypassrls;
|
||||
grant all on all tables in schema public to service_role;
|
||||
grant all on all sequences in schema public to service_role;
|
||||
grant all on all routines in schema public to service_role;
|
||||
alter default privileges in schema public grant all on tables to service_role;
|
||||
alter default privileges in schema public grant all on sequences to service_role;
|
||||
SQL
|
||||
ok "service_role-Grants gesetzt."
|
||||
|
||||
head "App + Caddy starten"
|
||||
( cd "$TARGET" && docker compose up -d )
|
||||
ok "Stack läuft."
|
||||
|
||||
create_admin "$SITE_URL" "$SERVICE_ROLE_KEY"
|
||||
fi
|
||||
|
||||
#######################################################################
|
||||
# Abschluss
|
||||
#######################################################################
|
||||
head "Fertig 🎉"
|
||||
ok "App: $SITE_URL"
|
||||
ok "Admin: $SITE_URL/admin/login ($ADMIN_EMAIL)"
|
||||
say "$c_dim Verzeichnis: $TARGET$c_rst"
|
||||
head "Nächste Schritte"
|
||||
say " 1. DNS für $APP_DOMAIN muss auf diesen Host zeigen (Caddy holt das TLS-Zertifikat dann automatisch)."
|
||||
say " 2. Anmelden, beim ersten Login Passwort setzen."
|
||||
say " 3. Admin → Branding (Logo, Farben, Impressum)."
|
||||
say " 4. Admin → Funktionen: Module gestaffelt freischalten (Start = nur Visitenkarten)."
|
||||
say " 5. Smoke-Test: öffentliche Karte, vCard, Foto-Upload, Magic-Link."
|
||||
[ "$MODE" = "2" ] && say " $c_yel→ Backup nicht vergessen:$c_rst DB-Volume (db-data) + $TARGET/volumes/storage sichern."
|
||||
say ""
|
||||
warn "Schlägt 'docker compose pull' beim App-Image fehl? → 'docker login $REGISTRY' nachholen."
|
||||
@@ -0,0 +1,259 @@
|
||||
# Neukunden-Onboarding (TeamVis-Instanz)
|
||||
|
||||
Reproduzierbares Runbook für eine **frische** TeamVis-Instanz (eigener Mandant:
|
||||
eigene Supabase, eigene Domain). Das Container-Image ist mandanten-neutral —
|
||||
alle Kunden laufen auf demselben Image, unterschieden nur durch ENV + DB +
|
||||
Branding. Dieses Dokument führt von „nichts" bis „live".
|
||||
|
||||
Reihenfolge ist verbindlich: **DB → ENV → Container → erster Admin → Branding → Smoke-Test.**
|
||||
|
||||
---
|
||||
|
||||
## 0. Voraussetzungen
|
||||
|
||||
- Eine **Supabase-Instanz** (Cloud-Projekt _oder_ self-hosted). Du brauchst:
|
||||
- Projekt-URL (`https://xxxx.supabase.co` bzw. `https://supabase.kunde.de`)
|
||||
- `anon`-Key (öffentlich)
|
||||
- `service_role`-Key (geheim, nur Server)
|
||||
- Eine **Domain** (z. B. `team.kunde.de`) mit DNS auf den Docker-Host.
|
||||
- Ein **Docker-Host** mit Zugriff auf die Registry `git.zoesch.de/zfx-services/teamvis`
|
||||
(Registry-Login vorhanden) und einem Reverse-Proxy (Caddy/Traefik/nginx).
|
||||
- Lokal dieses Repo + `node` (für `scripts/`-Helfer).
|
||||
|
||||
---
|
||||
|
||||
## 1. Datenbank-Schema einspielen
|
||||
|
||||
Alle Tabellen, RLS-Policies, Storage-Buckets (`branding-assets` **und**
|
||||
`employee-photos`) und GRANTs kommen aus `supabase/migrations/`. Es gibt
|
||||
zwei Wege:
|
||||
|
||||
> Die Basis-Tabellen `employees` + `admin_users` legt seit `0000_base_schema.sql`
|
||||
> die erste Migration an (`create table if not exists`). Auf einer **leeren**
|
||||
> DB bootstrappt das Bündel damit vollständig; auf einer **bestehenden**
|
||||
> Instanz ist `0000` ein No-Op.
|
||||
|
||||
### Variante A — Schema-Bündel (ohne supabase-CLI/psql-Zugang, empfohlen für Erst-Setup)
|
||||
|
||||
```bash
|
||||
node scripts/bundle-migrations.mjs > /tmp/teamvis-schema.sql
|
||||
```
|
||||
|
||||
Den Inhalt von `/tmp/teamvis-schema.sql` einmal in den **Supabase-Studio
|
||||
SQL-Editor** einfügen und ausführen. Das Bündel enthält alle Migrationen in
|
||||
korrekter Reihenfolge.
|
||||
|
||||
> Wichtig: Das Bündel ist für eine **leere** DB gedacht (Erst-Lauf). Für
|
||||
> spätere Schema-Updates auf einer bestehenden Instanz nur die **neue**
|
||||
> Einzel-Migration ausführen, nicht das ganze Bündel.
|
||||
|
||||
### Variante B — supabase-CLI (wenn DB-Zugang verfügbar)
|
||||
|
||||
```bash
|
||||
supabase link --project-ref <ref>
|
||||
supabase db push
|
||||
```
|
||||
|
||||
### Kontrolle
|
||||
|
||||
In Supabase Studio prüfen, dass existieren:
|
||||
- Tabellen: `employees`, `admin_users`, `admin_invites`, `site_settings`, … (Organigramm-, Compliance-, Lead-, License-Tabellen)
|
||||
- Storage-Buckets: `branding-assets` **und** `employee-photos` (beide public)
|
||||
- `site_settings`: GRANTs sind spaltenweise (Geheimnis-Spalten **nicht** für `anon`, siehe Migration `0043`)
|
||||
|
||||
---
|
||||
|
||||
## 2. ENV-Datei vorbereiten
|
||||
|
||||
Die App liest zur **Laufzeit** (kein Build-Time-Inlining → ein Image für alle
|
||||
Mandanten). Lege auf dem Host `/opt/<kunde>/app/.env` an:
|
||||
|
||||
```env
|
||||
# ── Supabase ───────────────────────────────────────────
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://xxxx.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=<anon-key>
|
||||
SUPABASE_SERVICE_ROLE_KEY=<service-role-key>
|
||||
|
||||
# ── Session (Pflicht, >= 32 Zeichen, pro Instanz EINMALIG erzeugen) ──
|
||||
SESSION_SECRET=<openssl rand -base64 48>
|
||||
|
||||
# ── Öffentliche URL (für QR-Codes / vCard-Links) ───────
|
||||
NEXT_PUBLIC_SITE_URL=https://team.kunde.de
|
||||
|
||||
# ── SMTP (optional; fehlt es, landen Magic-Links nur im Server-Log) ──
|
||||
SMTP_HOST=mail.kunde.de
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=<user>
|
||||
SMTP_PASS=<pass>
|
||||
SMTP_FROM_EMAIL=noreply@team.kunde.de
|
||||
SMTP_FROM_NAME=TeamVis
|
||||
```
|
||||
|
||||
`SESSION_SECRET` erzeugen: `openssl rand -base64 48`. **Niemals** zwischen
|
||||
Mandanten teilen.
|
||||
|
||||
> Self-hosted Supabase: `next/image` lädt Foto-URLs aus
|
||||
> `/storage/v1/object/public/**` jedes HTTPS-Hosts — kein zusätzliches
|
||||
> `SUPABASE_IMAGE_HOSTS` nötig (siehe `next.config.ts`).
|
||||
|
||||
---
|
||||
|
||||
## 3. Container deployen
|
||||
|
||||
`/opt/<kunde>/app/docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
image: git.zoesch.de/zfx-services/teamvis:0.11.5 # aktuelle Version pinnen
|
||||
container_name: <kunde>-teamvis
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
ports:
|
||||
- "127.0.0.1:3001:3000" # Port pro Instanz eindeutig wählen
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "fetch(\"http://127.0.0.1:3000/\").then(r => process.exit(r.status < 500 ? 0 : 1)).catch(() => process.exit(1))"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose up -d
|
||||
docker compose logs -f app # auf "Ready" warten
|
||||
```
|
||||
|
||||
Reverse-Proxy (Caddy-Beispiel) auf den lokalen Port mappen — Caddy holt das
|
||||
TLS-Zertifikat automatisch:
|
||||
|
||||
```
|
||||
team.kunde.de {
|
||||
reverse_proxy 127.0.0.1:3001
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Ersten Admin anlegen
|
||||
|
||||
`admin_invites` setzen einen bestehenden Admin voraus — bei einer frischen
|
||||
Instanz gibt es keinen. Das Bootstrap-Script legt ihn direkt an (bcrypt cost
|
||||
12, identisch zur App-Logik):
|
||||
|
||||
```bash
|
||||
# vom lokalen Repo aus, ENV aus .env.local oder per dotenv-cli:
|
||||
npx dotenv -e .env.local -- node scripts/create-admin.mjs chef@kunde.de --name "Max Chef"
|
||||
# Passwort wird verdeckt abgefragt (min. 10 Zeichen)
|
||||
```
|
||||
|
||||
Alternativ ENV direkt setzen (`NEXT_PUBLIC_SUPABASE_URL`,
|
||||
`SUPABASE_SERVICE_ROLE_KEY` der Kunden-DB) und das Script ohne dotenv aufrufen.
|
||||
Danach Login unter `https://team.kunde.de/admin/login`. Weitere Admins/Viewer
|
||||
dann bequem über **Admin → Einladungen** im Backend.
|
||||
|
||||
---
|
||||
|
||||
## 5. Branding & Inhalte
|
||||
|
||||
Alles White-Label über das Backend, kein Code/Rebuild nötig:
|
||||
|
||||
1. **Admin → Branding** — Firmenname, Logo, Favicon, Farben, Footer/Impressum-Link.
|
||||
2. **Admin → Mitarbeiter** — erste Karten anlegen (Foto, Kontaktdaten, Sichtbarkeits-Flags). Import per CSV möglich.
|
||||
3. Optional: **Organigramm**, **Compliance-Frameworks**, **Telefonanlage**, **Wallet** (Apple/Google) je nach Modul-Bedarf — Freischaltung siehe nächster Abschnitt.
|
||||
|
||||
---
|
||||
|
||||
## 5a. Funktionen gestaffelt freischalten
|
||||
|
||||
Eine frische Instanz startet **bewusst minimal**: nur die Visitenkarten-Basis
|
||||
(`business_cards`) ist aktiv. Alle weiteren Module schaltest du unter
|
||||
**Admin → Funktionen** nach und nach frei. Empfehlung für die Erstinstallation —
|
||||
erst die Basis abnehmen, dann Phase für Phase, damit bei Problemen die
|
||||
Fehlerquelle eng bleibt:
|
||||
|
||||
1. **Phase 1 — Go-Live-Kern:** Organigramm, Compliance (keine externe Abhängigkeit).
|
||||
2. **Phase 2 — Self-Service & Erfassung:** Empfang, KI, Card-Scanner, NFC
|
||||
(braucht SMTP für Magic-Links bzw. einen AI-Provider unter Admin → AI-Provider).
|
||||
3. **Phase 3 — Externe Systeme:** Wallet, Telefonanlage, Webhooks
|
||||
(Zertifikate, 3CX, Webhook-Empfänger — höchstes Debug-Risiko, zuletzt).
|
||||
|
||||
Jedes Modul ist einzeln schaltbar; bei Bedarf feiner vorgehen. Bei gesetzter
|
||||
Lizenz ist diese die Obergrenze — nicht lizenzierte Module bleiben gesperrt.
|
||||
|
||||
> **Hinweis Bestandsinstanzen:** Beim Update **auf** v0.12.0 muss auf bereits
|
||||
> laufenden Instanzen `docs/sql/0.12.0-enable-modules.sql` **vor** dem
|
||||
> Image-Deploy laufen (SQL-first), sonst verschwinden bislang aktive Module.
|
||||
> Neuinstallationen sind nicht betroffen.
|
||||
|
||||
---
|
||||
|
||||
## 6. Smoke-Test (Abnahme)
|
||||
|
||||
- [ ] `https://team.kunde.de/<slug>` zeigt eine Karte (öffentlich, ohne Login)
|
||||
- [ ] vCard-Download `…/api/vcard/<slug>` liefert `.vcf`
|
||||
- [ ] QR-Code auf der Karte zeigt auf die richtige `NEXT_PUBLIC_SITE_URL`
|
||||
- [ ] Admin-Login funktioniert, Mitarbeiterliste lädt
|
||||
- [ ] Self-Service-Portal: Magic-Link wird versendet (oder steht im Log, falls kein SMTP)
|
||||
- [ ] Foto-Upload landet im Bucket `employee-photos` und wird auf der Karte angezeigt
|
||||
- [ ] Security-Header gesetzt: `curl -sI https://team.kunde.de | grep -i x-frame-options`
|
||||
- [ ] Geheimnis-Check: mit dem **anon**-Key liefert eine Abfrage der Geheimnis-Spalten von `site_settings` `permission denied`
|
||||
|
||||
---
|
||||
|
||||
## 7. Updates & Versionsschema
|
||||
|
||||
TeamVis folgt Semantic Versioning (`MAJOR.MINOR.PATCH`):
|
||||
|
||||
| Sprung | Bedeutung | Was zu tun ist |
|
||||
|---|---|---|
|
||||
| **Patch** (`x.y.0 → x.y.1`) | Bugfix, kein Schema-Change | Tag bumpen → `docker compose pull && up -d` |
|
||||
| **Minor** (`x.0 → x.1`) | Feature, Schema-Change möglich | **erst** neue Migration einspielen, **dann** Container-Update |
|
||||
| **Major** (`1 → 2`) | Breaking Change | Migration-Notes lesen, Backup, dann Update |
|
||||
|
||||
Patch + Minor sind DB-rückwärtskompatibel (altes Image läuft mit neuer DB).
|
||||
Image-Tag in Production **immer pinnen** (`teamvis:0.11.6`), nicht `:latest` —
|
||||
sonst zieht ein `compose pull` ungewollt einen Major-Sprung.
|
||||
|
||||
**Standard-Update:**
|
||||
```bash
|
||||
# 1. Backup (siehe Abschnitt 8)
|
||||
# 2. Release-Notes / CHANGELOG.md prüfen
|
||||
# 3. fehlende Migration(en) aus supabase/migrations/ einspielen (nur die neuen!)
|
||||
# 4. Tag im docker-compose.yml bumpen
|
||||
docker compose pull && docker compose up -d --force-recreate
|
||||
```
|
||||
|
||||
**Rollback:** Image-Tag im Compose zurücksetzen + `up -d --force-recreate`.
|
||||
Achtung: war eine nicht-rückwärtskompatible Migration dabei, muss zusätzlich
|
||||
der DB-Dump zurückgespielt werden — Daten zwischen Update und Rollback sind
|
||||
dann verloren. Darum der Pre-Update-Dump.
|
||||
|
||||
---
|
||||
|
||||
## 8. Backup & Aufbewahrung
|
||||
|
||||
Zwei kritische Bereiche: **Postgres-DB** (alle Datensätze, Audit-Log) und der
|
||||
**Storage-Bucket `employee-photos`**.
|
||||
|
||||
- **DB (Supabase Cloud):** tägliche Auto-Backups + PITR ab Pro-Plan. Zusätzlich eigener Dump empfohlen:
|
||||
```bash
|
||||
pg_dump "$DATABASE_URL" --no-owner --no-acl --format=custom > teamvis-$(date +%Y%m%d).dump
|
||||
```
|
||||
- **Storage:** `npx supabase storage cp "ss:///employee-photos" /backup/photos --recursive`
|
||||
(self-hosted: Storage ist ein Verzeichnis → `rsync`/ZFS-Snapshot).
|
||||
- **Audit-Log ist GoBD-relevant (10 Jahre Aufbewahrung):** monatlich per
|
||||
`Verwaltung → Protokoll → Export` als CSV ins unveränderbare Langzeit-Archiv.
|
||||
- **`.env` wird NICHT mitgesichert** — Secrets gehören in einen Passwort-Safe.
|
||||
- **Restore-Test** mindestens jährlich (Dump in Test-DB, Container dagegen
|
||||
starten, Login + Karten prüfen). Ein nie-restored Backup ist kein Backup.
|
||||
|
||||
---
|
||||
|
||||
## Bekannte Lücken / TODO
|
||||
|
||||
- Kein automatischer Migration-Runner gegen self-hosted DBs ohne PG-Passwort →
|
||||
Schema-Bündel via Studio (Variante A) ist der dokumentierte Weg.
|
||||
- Vor aktivem Lizenz-Verkauf (Stripe live) sind Gewerbeanmeldung + USt nötig —
|
||||
privates Impressum trägt nur eine Info-/Showcase-Seite.
|
||||
@@ -0,0 +1,141 @@
|
||||
# TeamVis Self-Hosting — Quickstart (von der nackten VM bis live)
|
||||
|
||||
Diese Anleitung führt von einer **komplett frischen Linux-VM** bis zur
|
||||
laufenden TeamVis-Instanz. Sie nutzt den Ein-Schritt-Installer
|
||||
`deploy/selfhost/install.sh` (Hintergrund & manuelle Varianten:
|
||||
`deploy/selfhost/README.md`, externe Supabase: `docs/NEUKUNDE.md`).
|
||||
|
||||
Zwei Betriebsarten zur Auswahl (fragt der Installer ab):
|
||||
|
||||
- **Modus 2 — alles mitinstallieren** (empfohlen für „nur eine VM"): TeamVis
|
||||
**und** eine schlanke Supabase laufen als Container auf derselben VM, unter
|
||||
**einer Domain**. Kein externes Supabase nötig.
|
||||
- **Modus 1 — eigene/bestehende Supabase**: du hast schon ein Supabase-Projekt
|
||||
(Cloud oder self-hosted); es läuft nur die App.
|
||||
|
||||
Diese Anleitung beschreibt **Modus 2** (der Komplettfall).
|
||||
|
||||
---
|
||||
|
||||
## 0. Voraussetzungen
|
||||
|
||||
**Du brauchst vorab:**
|
||||
|
||||
| Was | Details |
|
||||
|---|---|
|
||||
| **VM** | Ubuntu 22.04+/Debian 12+, **2 GB RAM**+ (Modus 2 ≈ 1–1,5 GB), ~10 GB Disk. Root- bzw. sudo-Zugang. |
|
||||
| **Domain** | z. B. `team.kunde.de`, mit **DNS-A-Record auf die öffentliche IP der VM**. Caddy holt das TLS-Zertifikat dann automatisch. |
|
||||
| **Offene Ports** | eingehend **80 und 443** (Let's Encrypt + Web). |
|
||||
| **Image-Pull-Token** | ein **Token** für die Registry `git.zfx.services` — **vom TeamVis-Anbieter**. Nur fürs **Container-Image**. Das Installer-Bundle ist öffentlich (kein Token). |
|
||||
|
||||
> So ist die Auslieferung getrennt: Das **Bundle** (Installer + Migrationen, kein
|
||||
> Quellcode) liegt in einem **öffentlichen** Repo und ist ohne Login klonbar. Nur
|
||||
> das private **Container-Image** braucht den Pull-Token. Der App-Quellcode bleibt
|
||||
> in jedem Fall unzugänglich. (Anbieter-Details: `docs/DISTRIBUTION.md`.)
|
||||
|
||||
---
|
||||
|
||||
## 1. Grundpakete installieren
|
||||
|
||||
Auf der frischen VM (als root oder mit `sudo`):
|
||||
|
||||
```bash
|
||||
# Docker + Compose-Plugin
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
sudo usermod -aG docker "$USER" # danach einmal aus-/einloggen (oder: newgrp docker)
|
||||
|
||||
# Node 20 + git + openssl
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo bash -
|
||||
sudo apt-get install -y nodejs git openssl
|
||||
|
||||
# Kontrolle
|
||||
docker --version && docker compose version && node --version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. An der Registry anmelden (Token)
|
||||
|
||||
Benutzer ist `teamvis-pull`, Passwort der Token vom Anbieter:
|
||||
|
||||
```bash
|
||||
docker login git.zfx.services
|
||||
# Benutzer: teamvis-pull · Passwort: <Token>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Installer-Bundle holen
|
||||
|
||||
Das Bundle ist **öffentlich** — kein Login nötig:
|
||||
|
||||
```bash
|
||||
git clone https://git.zfx.services/zfx-public/teamvis-selfhost.git
|
||||
cd teamvis-selfhost
|
||||
```
|
||||
|
||||
(Enthält Installer, Helfer-Skripte und Migrationen — **keinen** App-Quellcode.
|
||||
Die App selbst kommt als fertiges Image aus der Registry, wird also nicht
|
||||
gebaut. Kein git? Stattdessen das Release-Tarball des Anbieters entpacken.)
|
||||
|
||||
---
|
||||
|
||||
## 4. Installer ausführen
|
||||
|
||||
```bash
|
||||
bash deploy/selfhost/install.sh
|
||||
```
|
||||
|
||||
Der Installer fragt der Reihe nach:
|
||||
|
||||
1. **Kurzname** der Instanz (z. B. `kunde`) — nur für Verzeichnis/Compose-Name.
|
||||
2. **Domain** der App (`team.kunde.de`).
|
||||
3. **Image-Version** (Default = aktuelle Version, einfach Enter).
|
||||
4. **Admin-E-Mail** + **Name** (der erste Backend-Login).
|
||||
5. **Zielverzeichnis** (Default: `./kunde`).
|
||||
6. **SMTP** (optional — leer lassen ist ok; dann landen Login-/Magic-Links nur
|
||||
im Container-Log statt im Postfach. Lässt sich später in der `.env` nachtragen).
|
||||
7. **Datenbank-Modus** → hier **`2`** wählen.
|
||||
8. Am Ende: **Admin-Passwort** (mind. 10 Zeichen, verdeckt).
|
||||
|
||||
Danach erledigt das Skript automatisch: Schlüssel/Secrets erzeugen, `.env` +
|
||||
`docker-compose.yml` + `Caddyfile` schreiben, Datenbank starten,
|
||||
Rollen-Bootstrap + Rechte, Schema einspielen, App + Caddy starten, ersten Admin
|
||||
anlegen. Dauer: wenige Minuten (Image-Download).
|
||||
|
||||
---
|
||||
|
||||
## 5. Erster Login & Abnahme
|
||||
|
||||
1. `https://team.kunde.de/admin/login` öffnen → mit Admin-E-Mail + Passwort
|
||||
anmelden (beim ersten Login wird ein neues Passwort gesetzt).
|
||||
2. **Admin → Branding**: Firmenname, Logo, Farben, Impressum.
|
||||
3. **Admin → Mitarbeiter**: erste Karte anlegen (oder CSV-Import).
|
||||
4. **Admin → Funktionen**: weitere Module gestaffelt freischalten — eine frische
|
||||
Instanz startet bewusst nur mit den **Visitenkarten**.
|
||||
|
||||
**Smoke-Test:**
|
||||
- [ ] `https://team.kunde.de/<slug>` zeigt eine Karte (ohne Login)
|
||||
- [ ] vCard-Download `…/api/vcard/<slug>` liefert `.vcf`
|
||||
- [ ] Foto-Upload im Backend erscheint auf der Karte
|
||||
- [ ] (mit SMTP) Self-Service-Portal verschickt einen Login-Code
|
||||
|
||||
---
|
||||
|
||||
## 6. Betrieb
|
||||
|
||||
- **Updates:** Image-Tag in `kunde/docker-compose.yml` bumpen, bei Minor-Sprüngen
|
||||
vorher die neue(n) Migration(en) einspielen → `docker compose pull && up -d`.
|
||||
Details: `docs/NEUKUNDE.md` §7.
|
||||
- **Backup (wichtig, jetzt liegt die DB bei dir):** das Postgres-Volume
|
||||
(`db-data`) **und** `kunde/volumes/storage` sichern. Die `.env` gehört in einen
|
||||
Passwort-Safe (enthält alle Secrets), **nicht** ins Backup-Repo.
|
||||
|
||||
---
|
||||
|
||||
## Wenn etwas klemmt
|
||||
|
||||
- `docker compose pull` schlägt fehl → `docker login git.zfx.services` nachholen.
|
||||
- TLS kommt nicht → DNS-A-Record + offene Ports 80/443 prüfen (Caddy braucht
|
||||
beide für Let's Encrypt).
|
||||
- Logs ansehen: `cd kunde && docker compose logs -f app` bzw. `caddy`.
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "teamvis-selfhost",
|
||||
"version": "0.31.0",
|
||||
"private": true,
|
||||
"description": "Self-Hosting-Bundle für TeamVis (Installer + Migrationen, ohne App-Quellcode).",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.104.0",
|
||||
"bcryptjs": "^3.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env node
|
||||
// bundle-migrations.mjs — Bündelt alle SQL-Migrationen in EINE Datei.
|
||||
//
|
||||
// Bei einer frischen Instanz ohne supabase-CLI/psql-Zugang ist das der
|
||||
// schnellste Weg: dieses Bündel einmal in den Supabase-Studio-SQL-Editor
|
||||
// einfügen und ausführen. Die Migrationen sind so geschrieben, dass sie
|
||||
// idempotent genug für einen Erst-Lauf auf leerer DB sind.
|
||||
//
|
||||
// Aufruf:
|
||||
// node scripts/bundle-migrations.mjs > /tmp/teamvis-schema.sql
|
||||
// # dann /tmp/teamvis-schema.sql in Supabase Studio einfügen + ausführen
|
||||
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
|
||||
const dir = new URL("../supabase/migrations/", import.meta.url);
|
||||
const files = readdirSync(dir)
|
||||
.filter((f) => f.endsWith(".sql"))
|
||||
.sort();
|
||||
|
||||
let out = `-- TeamVis Schema-Bündel (${files.length} Migrationen)\n`;
|
||||
out += `-- Erzeugt: ${new Date().toISOString()}\n`;
|
||||
out += `-- Reihenfolge entspricht der Datei-Nummerierung.\n`;
|
||||
|
||||
for (const f of files) {
|
||||
const sql = readFileSync(new URL(f, dir), "utf8");
|
||||
out += `\n\n-- ════════════════════════════════════════════════════════\n`;
|
||||
out += `-- ${f}\n`;
|
||||
out += `-- ════════════════════════════════════════════════════════\n\n`;
|
||||
out += sql.trimEnd() + "\n";
|
||||
}
|
||||
|
||||
process.stdout.write(out);
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env node
|
||||
// create-admin.mjs — Bootstrap für den ERSTEN Admin einer TeamVis-Instanz.
|
||||
//
|
||||
// Die normale Admin-Anlage läuft über Einladungen (admin_invites), die aber
|
||||
// einen bestehenden Admin voraussetzen. Bei einer frischen Kunden-Instanz
|
||||
// gibt es noch keinen — dieses Script legt den ersten direkt an
|
||||
// (admin_users: email + bcrypt-Hash + name + role), identisch zur App-Logik
|
||||
// (activateInvite, bcrypt cost 12).
|
||||
//
|
||||
// Aufruf (Service-Role-Key + URL aus der Umgebung oder .env.local):
|
||||
// node scripts/create-admin.mjs <email> [--name "Vorname Nachname"] [--role admin|viewer]
|
||||
// # Passwort wird sicher abgefragt (oder via ENV ADMIN_PASSWORD)
|
||||
//
|
||||
// Beispiel mit dotenv-cli (liest .env.local):
|
||||
// npx dotenv -e .env.local -- node scripts/create-admin.mjs chef@kunde.de --name "Max Chef"
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createInterface } from "node:readline";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
// ── ENV laden (process.env, sonst .env.local manuell parsen) ──────────
|
||||
function env(key) {
|
||||
if (process.env[key]) return process.env[key];
|
||||
try {
|
||||
const file = readFileSync(new URL("../.env.local", import.meta.url), "utf8");
|
||||
const line = file.split("\n").find((l) => l.startsWith(key + "="));
|
||||
if (line) return line.slice(key.length + 1).trim().replace(/^["']|["']$/g, "");
|
||||
} catch {
|
||||
/* keine .env.local — dann muss die Variable in der Umgebung stehen */
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Achtung: NICHT `URL` als Variablennamen verwenden — das überschattet den
|
||||
// globalen URL-Konstruktor, den env() oben via `new URL()` braucht.
|
||||
const supabaseUrl = env("NEXT_PUBLIC_SUPABASE_URL");
|
||||
const serviceKey = env("SUPABASE_SERVICE_ROLE_KEY");
|
||||
if (!supabaseUrl || !serviceKey) {
|
||||
console.error(
|
||||
"FEHLER: NEXT_PUBLIC_SUPABASE_URL und SUPABASE_SERVICE_ROLE_KEY müssen gesetzt sein\n" +
|
||||
"(per Umgebung oder .env.local). Tipp: npx dotenv -e .env.local -- node scripts/create-admin.mjs …",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Argumente parsen ──────────────────────────────────────────────────
|
||||
const args = process.argv.slice(2);
|
||||
const email = args.find((a) => !a.startsWith("--"));
|
||||
const nameIdx = args.indexOf("--name");
|
||||
const name = nameIdx >= 0 ? args[nameIdx + 1] : null;
|
||||
const roleIdx = args.indexOf("--role");
|
||||
const role = roleIdx >= 0 ? args[roleIdx + 1] : "admin";
|
||||
|
||||
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
console.error("FEHLER: gültige E-Mail als erstes Argument angeben.");
|
||||
console.error('Aufruf: node scripts/create-admin.mjs <email> [--name "…"] [--role admin|viewer]');
|
||||
process.exit(1);
|
||||
}
|
||||
if (role !== "admin" && role !== "viewer") {
|
||||
console.error("FEHLER: --role muss 'admin' oder 'viewer' sein.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Passwort sicher einlesen (verdeckt) oder aus ENV ──────────────────
|
||||
async function readPassword() {
|
||||
if (process.env.ADMIN_PASSWORD) return process.env.ADMIN_PASSWORD;
|
||||
return new Promise((resolve) => {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const stdout = process.stdout;
|
||||
rl.question("Passwort (min. 10 Zeichen): ", (answer) => {
|
||||
rl.close();
|
||||
stdout.write("\n");
|
||||
resolve(answer);
|
||||
});
|
||||
// Eingabe verdecken
|
||||
rl._writeToOutput = () => rl.output.write("*");
|
||||
});
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, serviceKey, {
|
||||
auth: { persistSession: false },
|
||||
});
|
||||
|
||||
// Duplikat-Check
|
||||
const { data: existing } = await supabase
|
||||
.from("admin_users")
|
||||
.select("id")
|
||||
.eq("email", email)
|
||||
.maybeSingle();
|
||||
if (existing) {
|
||||
console.error(`FEHLER: Es existiert bereits ein Admin mit ${email}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const password = await readPassword();
|
||||
if (!password || password.length < 10) {
|
||||
console.error("FEHLER: Passwort muss mindestens 10 Zeichen haben.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
const baseRow = {
|
||||
email,
|
||||
password_hash: passwordHash,
|
||||
name: name?.trim() || null,
|
||||
role,
|
||||
};
|
||||
|
||||
// Temp-Passwort → beim ersten Login erzwungene Änderung (Migration 0047).
|
||||
let { error } = await supabase.from("admin_users").insert({
|
||||
...baseRow,
|
||||
must_change_password: true,
|
||||
});
|
||||
|
||||
// Instanz ohne Migration 0047: PostgREST kennt die Spalte nicht (PGRST204).
|
||||
// Das darf den Admin-Bootstrap nicht killen — ohne Flag erneut anlegen,
|
||||
// die erzwungene Passwortänderung entfällt dort schlicht.
|
||||
if (
|
||||
error &&
|
||||
(error.code === "PGRST204" || /must_change_password/.test(error.message))
|
||||
) {
|
||||
console.warn(
|
||||
"⚠ Migration 0047 fehlt (must_change_password) — Admin wird ohne " +
|
||||
"erzwungene Passwortänderung angelegt.",
|
||||
);
|
||||
({ error } = await supabase.from("admin_users").insert(baseRow));
|
||||
}
|
||||
|
||||
if (error) {
|
||||
console.error("FEHLER beim Anlegen:", error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`✓ Admin angelegt: ${email} (Rolle: ${role})`);
|
||||
console.log(" Login unter /admin/login");
|
||||
@@ -0,0 +1,71 @@
|
||||
-- =====================================================================
|
||||
-- 0000 — Basis-Schema (employees + admin_users)
|
||||
-- =====================================================================
|
||||
-- Die ursprünglichen Kern-Tabellen wurden früher direkt in Supabase Studio
|
||||
-- angelegt und waren NICHT als Migration erfasst — die folgenden
|
||||
-- Migrationen (0001 ff.) setzen sie voraus und erweitern sie nur additiv
|
||||
-- (alle per `add column if not exists`). Auf einer komplett leeren DB fehlten
|
||||
-- sie dadurch, womit `bundle-migrations` allein keine frische Instanz
|
||||
-- bootstrappen konnte.
|
||||
--
|
||||
-- Diese Migration schließt die Lücke. Sie ist bewusst `if not exists`:
|
||||
-- auf bestehenden Instanzen (Prod/Demo) ein No-Op, auf leeren DBs legt sie
|
||||
-- die Basis. Spaltenstand entspricht dem aktuellen, generierten Typ
|
||||
-- (lib/database.types.ts) — alle späteren Migrationen sind additiv und
|
||||
-- bleiben damit No-Ops auf diesen Spalten.
|
||||
|
||||
create table if not exists public.employees (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
slug text not null unique,
|
||||
academic_title text,
|
||||
qualification text,
|
||||
qualification_en text,
|
||||
first_name text not null,
|
||||
last_name text not null,
|
||||
position text not null,
|
||||
position_en text,
|
||||
company text not null,
|
||||
org_unit_id uuid,
|
||||
address text,
|
||||
street text,
|
||||
postal_code text,
|
||||
city text,
|
||||
email text not null,
|
||||
phone_office text not null,
|
||||
phone_mobile text,
|
||||
website text,
|
||||
linkedin_url text,
|
||||
xing_url text,
|
||||
calendar_url text,
|
||||
photo_url text,
|
||||
show_mobile boolean not null default false,
|
||||
show_linkedin boolean not null default false,
|
||||
show_xing boolean not null default false,
|
||||
active boolean not null default true,
|
||||
calendar_embed boolean not null default false,
|
||||
successor_employee_id uuid,
|
||||
successor_note text,
|
||||
location_id uuid,
|
||||
person_id uuid,
|
||||
trusted_token text,
|
||||
trusted_token_expires_at timestamptz,
|
||||
trusted_token_created_at timestamptz,
|
||||
trusted_reveal text[] not null default '{}',
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists public.admin_users (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
email text not null unique,
|
||||
password_hash text not null,
|
||||
created_at timestamptz not null default now(),
|
||||
name text,
|
||||
role text not null default 'admin',
|
||||
invited_by uuid,
|
||||
last_login_at timestamptz,
|
||||
totp_secret text,
|
||||
totp_enabled boolean not null default false,
|
||||
totp_backup_codes text[] not null default '{}',
|
||||
totp_enabled_at timestamptz
|
||||
);
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Row-Level-Security für öffentliche Reads auf employees
|
||||
-- Die App benutzt den anon-Key für öffentliche /[slug]- und /api/vcard-Routes.
|
||||
-- Der service_role-Key (Admin-Pfade) umgeht RLS automatisch.
|
||||
|
||||
alter table public.employees enable row level security;
|
||||
|
||||
drop policy if exists "public_read_active_employees" on public.employees;
|
||||
create policy "public_read_active_employees"
|
||||
on public.employees
|
||||
for select
|
||||
to anon
|
||||
using (active = true);
|
||||
|
||||
-- admin_users darf von anon gar nicht gelesen werden.
|
||||
alter table public.admin_users enable row level security;
|
||||
|
||||
drop policy if exists "no_anon_access_admin_users" on public.admin_users;
|
||||
-- absichtlich KEINE Policy für anon → Default deny.
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Akademischer Abschluss (z. B. "M.Eng. Elektro- und Informationstechnik"),
|
||||
-- separat vom Pronomen-Titel (academic_title: "Dr.", "Prof.", …).
|
||||
|
||||
alter table public.employees
|
||||
add column if not exists qualification text;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Adresse in strukturierte Einzelfelder splitten, damit Format-Inkonsistenzen
|
||||
-- (Komma vs. Zeilenumbruch, PLZ-Position, Ortstrennung) ausgeschlossen sind.
|
||||
--
|
||||
-- Die alte Spalte `address` bleibt erhalten, damit bestehende Einträge nicht
|
||||
-- wegbrechen. Sie kann nach manueller Nach-Pflege der neuen Felder gedroppt
|
||||
-- werden:
|
||||
-- alter table public.employees drop column address;
|
||||
|
||||
alter table public.employees
|
||||
add column if not exists street text,
|
||||
add column if not exists postal_code text,
|
||||
add column if not exists city text;
|
||||
@@ -0,0 +1,38 @@
|
||||
-- Vertraulicher Zusatz-Link ("Trusted-Link") pro Mitarbeiter.
|
||||
--
|
||||
-- Idee: Die öffentliche Karte (/<slug>) zeigt nur Festnetz (wie show_mobile=false
|
||||
-- vorgibt). Wer zusätzlich ein gültiges Token (?k=…) mitbringt, sieht auch die
|
||||
-- Mobilnummer — auf der Karte UND in der vCard. Token kann ablaufen, jeder
|
||||
-- Zugriff mit Token wird geloggt.
|
||||
|
||||
-- 1) Token-Spalten auf employees
|
||||
alter table public.employees
|
||||
add column if not exists trusted_token text,
|
||||
add column if not exists trusted_token_expires_at timestamptz,
|
||||
add column if not exists trusted_token_created_at timestamptz;
|
||||
|
||||
-- Eindeutigkeit, aber nur wenn gesetzt — NULL-Werte dürfen mehrfach vorkommen.
|
||||
drop index if exists public.employees_trusted_token_key;
|
||||
create unique index employees_trusted_token_key
|
||||
on public.employees(trusted_token)
|
||||
where trusted_token is not null;
|
||||
|
||||
-- 2) Audit-Log-Tabelle
|
||||
create table if not exists public.trusted_access_log (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
employee_id uuid not null references public.employees(id) on delete cascade,
|
||||
accessed_at timestamptz not null default now(),
|
||||
route text not null, -- 'card' | 'vcard'
|
||||
ip text,
|
||||
user_agent text
|
||||
);
|
||||
|
||||
create index if not exists trusted_access_log_employee_id_idx
|
||||
on public.trusted_access_log (employee_id, accessed_at desc);
|
||||
|
||||
-- 3) RLS: Tabelle komplett zu für anon. Nur service-role (Admin-Server-Code)
|
||||
-- liest und schreibt.
|
||||
alter table public.trusted_access_log enable row level security;
|
||||
|
||||
drop policy if exists "no_anon_access_trusted_access_log" on public.trusted_access_log;
|
||||
-- Absichtlich KEINE anon-Policy → Default deny. Service-Role umgeht RLS.
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Granulare Kontrolle, welche Felder der Trusted-Link zusätzlich freischaltet.
|
||||
-- Array aus String-Tokens wie 'mobile', 'linkedin', 'xing'. Leer = wie vorher
|
||||
-- (nur implizit Mobilnummer, wird in der Migration unten auf 'mobile' gehoben,
|
||||
-- damit bestehende Trusted-Links ihre bisherige Wirkung behalten).
|
||||
|
||||
alter table public.employees
|
||||
add column if not exists trusted_reveal text[] not null default '{}';
|
||||
|
||||
-- Bestehende aktive Tokens: als "zeigt Mobilnummer" markieren, damit das
|
||||
-- Verhalten vor dem Deploy identisch bleibt.
|
||||
update public.employees
|
||||
set trusted_reveal = array['mobile']
|
||||
where trusted_token is not null
|
||||
and coalesce(array_length(trusted_reveal, 1), 0) = 0;
|
||||
@@ -0,0 +1,74 @@
|
||||
-- Branding-Konfiguration, über /admin/branding per UI editierbar.
|
||||
-- Exakt EINE Row in der Tabelle — Singleton via unique-Index erzwungen.
|
||||
-- Leere/NULL-Werte bedeuten "Fallback auf ENV bzw. Code-Defaults".
|
||||
|
||||
create table if not exists public.site_settings (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
singleton boolean not null default true,
|
||||
|
||||
-- Firma / Öffentlich
|
||||
company_name text,
|
||||
company_short text,
|
||||
claim text,
|
||||
description text,
|
||||
website text,
|
||||
|
||||
-- SEO / Metadaten
|
||||
seo_title text,
|
||||
seo_description text,
|
||||
not_found_suffix text,
|
||||
|
||||
-- Admin-Labels
|
||||
admin_app_name text,
|
||||
admin_app_subtext text,
|
||||
default_company_name text,
|
||||
calendar_placeholder text,
|
||||
|
||||
-- Theme-Farben (Hex)
|
||||
primary_color text,
|
||||
primary_dark text,
|
||||
primary_deep text,
|
||||
secondary_color text,
|
||||
secondary_dark text,
|
||||
secondary_deep text,
|
||||
|
||||
-- Assets (public URLs aus dem branding-assets Storage-Bucket)
|
||||
logo_url text,
|
||||
logo_alt text,
|
||||
favicon_url text,
|
||||
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
-- Exakt eine Row erlaubt: unique on (singleton=true)
|
||||
create unique index if not exists site_settings_singleton_unique
|
||||
on public.site_settings ((true)) where singleton = true;
|
||||
|
||||
-- Initiale Row anlegen, falls noch nicht da
|
||||
insert into public.site_settings (singleton)
|
||||
values (true)
|
||||
on conflict do nothing;
|
||||
|
||||
-- RLS: Branding ist public (Farben, Name, Logo) — anon darf lesen.
|
||||
alter table public.site_settings enable row level security;
|
||||
|
||||
drop policy if exists "public_read_site_settings" on public.site_settings;
|
||||
create policy "public_read_site_settings"
|
||||
on public.site_settings
|
||||
for select to anon
|
||||
using (true);
|
||||
-- Schreibzugriff bleibt bei service-role (Admin-Server-Actions).
|
||||
|
||||
-- Storage-Bucket für Logo / Favicon / Icon. Öffentlich, damit <img> und
|
||||
-- <link rel="icon"> direkt drauf zugreifen können.
|
||||
insert into storage.buckets (id, name, public)
|
||||
values ('branding-assets', 'branding-assets', true)
|
||||
on conflict (id) do nothing;
|
||||
|
||||
-- Jeder darf lesen (Bucket ist public gesetzt, aber ein expliziter Select-
|
||||
-- Policy-Eintrag schadet nicht und macht das Verhalten explizit).
|
||||
drop policy if exists "public_read_branding_assets" on storage.objects;
|
||||
create policy "public_read_branding_assets"
|
||||
on storage.objects for select
|
||||
to anon
|
||||
using (bucket_id = 'branding-assets');
|
||||
@@ -0,0 +1,233 @@
|
||||
-- Organigramm-Modul + Modul-Toggle.
|
||||
-- ====================================================================
|
||||
-- Generisches Datenmodell, das verschiedene Compliance-Regelwerke
|
||||
-- (TSM, ISO 9001, DSGVO, …) abbilden kann. Stellen werden von Personen
|
||||
-- getrennt, damit Vakanzen ("N.N.") und Personalwechsel ohne Layout-
|
||||
-- Bruch funktionieren.
|
||||
--
|
||||
-- Tabellen:
|
||||
-- org_units — Sparte/Bereich (Strom, Gas, Finanzen, …)
|
||||
-- positions — Stelle in der Hierarchie
|
||||
-- position_assignments — Person ⇄ Stelle, mit Zeitraum
|
||||
-- deputies — Stelle ⇄ Stelle (Vertretung), mit Zeitraum
|
||||
-- external_parties — Fremdfirma / Behörde / Dienstleister
|
||||
-- service_relations — Externe ⇄ Pflichtbereich
|
||||
--
|
||||
-- Erweiterung an site_settings:
|
||||
-- enabled_modules — text[]: aktivierte Produkt-Module
|
||||
-- organigram_visibility — public | internal | trusted
|
||||
--
|
||||
-- RLS-Modell: Tabellen sind RLS-aktiv, KEINE anon-Policies. Reads
|
||||
-- laufen ausschließlich über service_role aus der App. Die Sichtbarkeits-
|
||||
-- Logik (public/internal/trusted) prüft der App-Code, nicht die DB.
|
||||
|
||||
-- --------------------------------------------------------------------
|
||||
-- 1) Module-Toggle + Organigramm-Sichtbarkeit auf site_settings
|
||||
-- --------------------------------------------------------------------
|
||||
|
||||
alter table public.site_settings
|
||||
add column if not exists enabled_modules text[] not null
|
||||
default array['business_cards']::text[];
|
||||
|
||||
alter table public.site_settings
|
||||
add column if not exists organigram_visibility text not null
|
||||
default 'internal';
|
||||
|
||||
-- Sichtbarkeitswerte einschränken (idempotent)
|
||||
alter table public.site_settings
|
||||
drop constraint if exists site_settings_organigram_visibility_check;
|
||||
alter table public.site_settings
|
||||
add constraint site_settings_organigram_visibility_check
|
||||
check (organigram_visibility in ('public', 'internal', 'trusted'));
|
||||
|
||||
-- --------------------------------------------------------------------
|
||||
-- 2) org_units — Sparte / Bereich (Hierarchie auch innerhalb möglich)
|
||||
-- --------------------------------------------------------------------
|
||||
|
||||
create table if not exists public.org_units (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
parent_id uuid references public.org_units(id) on delete restrict,
|
||||
slug text not null unique,
|
||||
name text not null,
|
||||
description text,
|
||||
sort_order integer not null default 0,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
constraint org_units_no_self_parent check (parent_id is null or parent_id <> id)
|
||||
);
|
||||
|
||||
create index if not exists org_units_parent_id_idx
|
||||
on public.org_units (parent_id);
|
||||
|
||||
-- --------------------------------------------------------------------
|
||||
-- 3) positions — Stelle (entkoppelt von Person)
|
||||
-- --------------------------------------------------------------------
|
||||
|
||||
create table if not exists public.positions (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
parent_id uuid references public.positions(id) on delete restrict,
|
||||
org_unit_id uuid references public.org_units(id) on delete set null,
|
||||
name text not null,
|
||||
short_name text,
|
||||
description text,
|
||||
-- Anzeigewert wenn keine Person zugewiesen ist (Default: 'N.N.').
|
||||
vacant_label text not null default 'N.N.',
|
||||
sort_order integer not null default 0,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
constraint positions_no_self_parent check (parent_id is null or parent_id <> id)
|
||||
);
|
||||
|
||||
create index if not exists positions_parent_id_idx
|
||||
on public.positions (parent_id);
|
||||
create index if not exists positions_org_unit_id_idx
|
||||
on public.positions (org_unit_id);
|
||||
|
||||
-- --------------------------------------------------------------------
|
||||
-- 4) position_assignments — Person sitzt auf Stelle (mit Zeitraum)
|
||||
-- --------------------------------------------------------------------
|
||||
|
||||
create table if not exists public.position_assignments (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
employee_id uuid not null references public.employees(id) on delete cascade,
|
||||
position_id uuid not null references public.positions(id) on delete cascade,
|
||||
-- Optionaler Zusatz, z.B. "kommissarisch", "i.V."
|
||||
role_label text,
|
||||
is_primary boolean not null default false,
|
||||
valid_from date not null default current_date,
|
||||
valid_to date,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
constraint position_assignments_valid_range
|
||||
check (valid_to is null or valid_to >= valid_from)
|
||||
);
|
||||
|
||||
create index if not exists position_assignments_employee_id_idx
|
||||
on public.position_assignments (employee_id);
|
||||
create index if not exists position_assignments_position_id_idx
|
||||
on public.position_assignments (position_id);
|
||||
-- Schnelle "wer ist gerade aktiv auf Stelle X"-Queries
|
||||
create index if not exists position_assignments_active_idx
|
||||
on public.position_assignments (position_id, valid_from, valid_to);
|
||||
|
||||
-- --------------------------------------------------------------------
|
||||
-- 5) deputies — Stelle wird vertreten von Stelle (mit Zeitraum)
|
||||
-- --------------------------------------------------------------------
|
||||
|
||||
create table if not exists public.deputies (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
position_id uuid not null references public.positions(id) on delete cascade,
|
||||
deputy_position_id uuid not null references public.positions(id) on delete cascade,
|
||||
-- Reihenfolge bei mehreren Vertretungen (1. Vertretung, 2. Vertretung, …)
|
||||
sort_order integer not null default 0,
|
||||
valid_from date not null default current_date,
|
||||
valid_to date,
|
||||
created_at timestamptz not null default now(),
|
||||
constraint deputies_no_self check (position_id <> deputy_position_id),
|
||||
constraint deputies_valid_range
|
||||
check (valid_to is null or valid_to >= valid_from)
|
||||
);
|
||||
|
||||
create index if not exists deputies_position_id_idx
|
||||
on public.deputies (position_id);
|
||||
create index if not exists deputies_deputy_position_id_idx
|
||||
on public.deputies (deputy_position_id);
|
||||
|
||||
-- --------------------------------------------------------------------
|
||||
-- 6) external_parties — Fremdfirma / Behörde / Dienstleister
|
||||
-- --------------------------------------------------------------------
|
||||
|
||||
create table if not exists public.external_parties (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
name text not null,
|
||||
-- Art der Partei: dienstleister, behoerde, kooperationspartner, sonstige
|
||||
kind text not null default 'dienstleister',
|
||||
contact_name text,
|
||||
contact_email text,
|
||||
contact_phone text,
|
||||
website text,
|
||||
address text,
|
||||
notes text,
|
||||
sort_order integer not null default 0,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
constraint external_parties_kind_check
|
||||
check (kind in ('dienstleister', 'behoerde', 'kooperationspartner', 'sonstige'))
|
||||
);
|
||||
|
||||
-- --------------------------------------------------------------------
|
||||
-- 7) service_relations — Externe deckt Pflichtbereich ab
|
||||
-- --------------------------------------------------------------------
|
||||
|
||||
create table if not exists public.service_relations (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
external_party_id uuid not null references public.external_parties(id) on delete cascade,
|
||||
-- Polymorphe Referenz: scope_kind = 'org_unit' | 'position' | 'global'
|
||||
scope_kind text not null,
|
||||
scope_id uuid,
|
||||
-- Klartext, z.B. "Brandschutzbeauftragter", "Betriebsarzt", "Personalwesen"
|
||||
service_label text not null,
|
||||
description text,
|
||||
valid_from date not null default current_date,
|
||||
valid_to date,
|
||||
created_at timestamptz not null default now(),
|
||||
constraint service_relations_scope_kind_check
|
||||
check (scope_kind in ('org_unit', 'position', 'global')),
|
||||
constraint service_relations_scope_id_consistency
|
||||
check (
|
||||
(scope_kind = 'global' and scope_id is null)
|
||||
or (scope_kind in ('org_unit', 'position') and scope_id is not null)
|
||||
),
|
||||
constraint service_relations_valid_range
|
||||
check (valid_to is null or valid_to >= valid_from)
|
||||
);
|
||||
|
||||
create index if not exists service_relations_external_party_id_idx
|
||||
on public.service_relations (external_party_id);
|
||||
create index if not exists service_relations_scope_idx
|
||||
on public.service_relations (scope_kind, scope_id);
|
||||
|
||||
-- --------------------------------------------------------------------
|
||||
-- 8) Trigger: updated_at automatisch aktualisieren
|
||||
-- --------------------------------------------------------------------
|
||||
|
||||
create or replace function public.set_updated_at()
|
||||
returns trigger as $$
|
||||
begin
|
||||
new.updated_at = now();
|
||||
return new;
|
||||
end;
|
||||
$$ language plpgsql;
|
||||
|
||||
drop trigger if exists trg_org_units_updated_at on public.org_units;
|
||||
create trigger trg_org_units_updated_at
|
||||
before update on public.org_units
|
||||
for each row execute function public.set_updated_at();
|
||||
|
||||
drop trigger if exists trg_positions_updated_at on public.positions;
|
||||
create trigger trg_positions_updated_at
|
||||
before update on public.positions
|
||||
for each row execute function public.set_updated_at();
|
||||
|
||||
drop trigger if exists trg_position_assignments_updated_at on public.position_assignments;
|
||||
create trigger trg_position_assignments_updated_at
|
||||
before update on public.position_assignments
|
||||
for each row execute function public.set_updated_at();
|
||||
|
||||
drop trigger if exists trg_external_parties_updated_at on public.external_parties;
|
||||
create trigger trg_external_parties_updated_at
|
||||
before update on public.external_parties
|
||||
for each row execute function public.set_updated_at();
|
||||
|
||||
-- --------------------------------------------------------------------
|
||||
-- 9) RLS — alle neuen Tabellen sind defensive
|
||||
-- --------------------------------------------------------------------
|
||||
-- Reads laufen ausschließlich über service_role (Admin + öffentliche
|
||||
-- Org-Page checken Visibility selbst). KEINE anon-Policies.
|
||||
|
||||
alter table public.org_units enable row level security;
|
||||
alter table public.positions enable row level security;
|
||||
alter table public.position_assignments enable row level security;
|
||||
alter table public.deputies enable row level security;
|
||||
alter table public.external_parties enable row level security;
|
||||
alter table public.service_relations enable row level security;
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Stabsstellen-Flag auf positions.
|
||||
-- ====================================================================
|
||||
-- Stabsstellen (DSB, SiBe, ISB, ImSchBe, Brandschutzbeauftragter,
|
||||
-- Techn. Führungskräfte, Controlling, Ausbilder, …) gehören organisatorisch
|
||||
-- nicht in die Linien-Hierarchie, sondern hängen seitlich am
|
||||
-- Verantwortungsträger (meist GF). Im Datenmodell behalten sie ihren
|
||||
-- parent_id (für die Anbindung), bekommen aber eine andere Edge-Visual
|
||||
-- in der Visualisierung.
|
||||
|
||||
alter table public.positions
|
||||
add column if not exists is_staff_position boolean not null default false;
|
||||
|
||||
create index if not exists positions_is_staff_position_idx
|
||||
on public.positions (is_staff_position) where is_staff_position = true;
|
||||
@@ -0,0 +1,61 @@
|
||||
-- Compliance-Modul.
|
||||
-- ====================================================================
|
||||
-- Frameworks (DVGW G 1000, ISO 9001, DSGVO, …) liegen als YAML im Repo
|
||||
-- (compliance/frameworks/*.yaml). Die DB speichert nur:
|
||||
--
|
||||
-- 1) Welche Frameworks sind in dieser Instanz aktiv (site_settings).
|
||||
-- 2) Welche Position erfuellt welche Framework-Rolle (bindings).
|
||||
--
|
||||
-- Die App rechnet daraus den Gap-Report (welche Rollen unbesetzt sind,
|
||||
-- welche Qualifikationen fehlen, welche Bestellungen ablaufen).
|
||||
|
||||
-- --------------------------------------------------------------------
|
||||
-- 1) Aktive Frameworks auf site_settings
|
||||
-- --------------------------------------------------------------------
|
||||
|
||||
alter table public.site_settings
|
||||
add column if not exists active_compliance_frameworks text[] not null
|
||||
default array[]::text[];
|
||||
|
||||
-- --------------------------------------------------------------------
|
||||
-- 2) compliance_role_bindings — Framework-Rolle ⇄ Stelle
|
||||
-- --------------------------------------------------------------------
|
||||
-- framework_id und role_id sind Strings (ID aus dem YAML), keine FKs:
|
||||
-- die "Quelle" der Rollen liegt im Repo, nicht in der DB. Die App
|
||||
-- validiert beim Schreiben, dass der Wert im aktuell geladenen
|
||||
-- Framework existiert.
|
||||
|
||||
create table if not exists public.compliance_role_bindings (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
framework_id text not null,
|
||||
role_id text not null,
|
||||
position_id uuid not null references public.positions(id) on delete cascade,
|
||||
appointed_on date,
|
||||
appointment_valid_to date,
|
||||
notes text,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
constraint compliance_role_bindings_appointment_range
|
||||
check (appointment_valid_to is null
|
||||
or appointed_on is null
|
||||
or appointment_valid_to >= appointed_on),
|
||||
constraint compliance_role_bindings_unique
|
||||
unique (framework_id, role_id, position_id)
|
||||
);
|
||||
|
||||
create index if not exists compliance_role_bindings_framework_idx
|
||||
on public.compliance_role_bindings (framework_id, role_id);
|
||||
create index if not exists compliance_role_bindings_position_idx
|
||||
on public.compliance_role_bindings (position_id);
|
||||
|
||||
drop trigger if exists trg_compliance_role_bindings_updated_at
|
||||
on public.compliance_role_bindings;
|
||||
create trigger trg_compliance_role_bindings_updated_at
|
||||
before update on public.compliance_role_bindings
|
||||
for each row execute function public.set_updated_at();
|
||||
|
||||
-- --------------------------------------------------------------------
|
||||
-- 3) RLS — service_role only, keine anon-Policies
|
||||
-- --------------------------------------------------------------------
|
||||
|
||||
alter table public.compliance_role_bindings enable row level security;
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Vertretungen können auch Personen sein.
|
||||
-- ====================================================================
|
||||
-- Bisher: deputies.deputy_position_id (NOT NULL) — eine Stelle vertritt
|
||||
-- eine andere Stelle.
|
||||
--
|
||||
-- Erweiterung: deputy_employee_id (nullable) — eine Stelle wird von
|
||||
-- einer konkreten Person vertreten, ohne dass diese Person eine eigene
|
||||
-- Stelle haben muss. Anwendungsfall: Mitarbeiter wie P. Schmid stehen
|
||||
-- offiziell auf "Strom-Netz", vertreten aber die Leitung Strom-Netz.
|
||||
--
|
||||
-- CHECK-Constraint stellt sicher: genau eines von beiden ist gesetzt.
|
||||
|
||||
alter table public.deputies
|
||||
add column if not exists deputy_employee_id uuid
|
||||
references public.employees(id) on delete cascade;
|
||||
|
||||
alter table public.deputies
|
||||
alter column deputy_position_id drop not null;
|
||||
|
||||
alter table public.deputies
|
||||
drop constraint if exists deputies_target_check;
|
||||
|
||||
alter table public.deputies
|
||||
add constraint deputies_target_check
|
||||
check (
|
||||
(deputy_position_id is not null)::int
|
||||
+ (deputy_employee_id is not null)::int = 1
|
||||
);
|
||||
|
||||
create index if not exists deputies_deputy_employee_idx
|
||||
on public.deputies (deputy_employee_id);
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Stabsstellen-Sichtbarkeit im Hauptgraph getrennt steuern.
|
||||
-- ====================================================================
|
||||
-- Bisher: jede is_staff_position landet rechts neben dem Eltern als
|
||||
-- Ellipse im Hauptgraph. Bei vielen Stabsstellen (z.B. GF mit 13 Stab-
|
||||
-- Children) wird das unleserlich.
|
||||
--
|
||||
-- Neu: show_in_chart steuert pro Stelle, ob sie im Hauptgraph
|
||||
-- gerendert wird (Linie ODER Stab-Ellipse). Stabsstellen mit
|
||||
-- show_in_chart=false erscheinen ausschließlich im Panel "Interne
|
||||
-- Dienste" unter dem Graph.
|
||||
--
|
||||
-- Linien-Stellen behalten Default true (sie gehören zwingend ins
|
||||
-- Diagramm, sonst ist die Hierarchie kaputt).
|
||||
|
||||
alter table public.positions
|
||||
add column if not exists show_in_chart boolean not null default true;
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Mitarbeiter ↔ Bereich (org_unit) als direkte Zuordnung.
|
||||
-- ====================================================================
|
||||
-- Bisher: ein Mitarbeiter ist nur dann der org-Struktur zugeordnet,
|
||||
-- wenn er eine position_assignment hat. Das funktioniert für Stellen
|
||||
-- mit klarem Stelleninhaber (Leitung X, Geschäftsführer, …), aber
|
||||
-- nicht für Team-Mitglieder, die zwar zu einem Bereich gehören, aber
|
||||
-- keine eigene Stelle besetzen.
|
||||
--
|
||||
-- Neu: employees.org_unit_id (nullable) — bindet einen Mitarbeiter
|
||||
-- direkt an einen Bereich. Im Org-Chart werden alle Mitarbeiter eines
|
||||
-- Bereichs als Pillen unter der jeweiligen Bereichs-Leitung gerendert,
|
||||
-- ohne dass man sie der Leitungs-Stelle als sekundäre Zuweisung
|
||||
-- anhängen muss.
|
||||
--
|
||||
-- Die position_assignments-Tabelle bleibt für formelle Stellen-
|
||||
-- Inhaberschaft (Leitung, Vertreter, Beauftragte etc.) zuständig.
|
||||
|
||||
alter table public.employees
|
||||
add column if not exists org_unit_id uuid
|
||||
references public.org_units(id) on delete set null;
|
||||
|
||||
create index if not exists employees_org_unit_id_idx
|
||||
on public.employees (org_unit_id);
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Geschäftsleitungs-Cluster: Stellen wie Prokurist:innen werden als
|
||||
-- Mitglied der Geschäftsleitung markiert.
|
||||
-- ====================================================================
|
||||
-- Bisher: Prokurist:innen sind Linien-Children der GF und werden eine
|
||||
-- Layer drunter gerendert. Visuell suggeriert das eine vorgesetzten-
|
||||
-- Beziehung GF→Prokurist, was zwar formal stimmt (Weisungs-Hierarchie),
|
||||
-- aber den kollegialen Charakter der Geschäftsleitung ausblendet.
|
||||
--
|
||||
-- Neu: is_executive=true → Stelle wird im Org-Chart auf gleicher y-
|
||||
-- Höhe wie die GF gerendert (links/rechts daneben), nicht eine Layer
|
||||
-- drunter. Ihre eigenen Subtrees hängen normal darunter.
|
||||
|
||||
alter table public.positions
|
||||
add column if not exists is_executive boolean not null default false;
|
||||
@@ -0,0 +1,31 @@
|
||||
-- K1 fix: Trusted-Token + Reveal-Felder nicht über anon-API auslesbar.
|
||||
-- ====================================================================
|
||||
-- bisher: RLS auf public.employees erlaubte anon-SELECT auf alle
|
||||
-- Spalten (inkl. trusted_token, trusted_reveal). Damit war der gesamte
|
||||
-- Trusted-Link-Mechanismus wirkungslos — jeder konnte den Token via
|
||||
-- /rest/v1/employees auslesen.
|
||||
--
|
||||
-- neu: column-level grants. Die anon-Rolle darf nur safe-Spalten
|
||||
-- selektieren. Postgres lehnt SELECT mit verbotener Spalte direkt ab.
|
||||
-- Die App-Schicht (lib/employees.ts) selektiert deshalb nur die
|
||||
-- safe-Spalten. Token-Validierung läuft ausschließlich server-side
|
||||
-- mit Service-Role-Key.
|
||||
|
||||
-- Policy bleibt unverändert: anon liest nur aktive Mitarbeiter.
|
||||
drop policy if exists "public_read_active_employees" on public.employees;
|
||||
create policy "public_read_active_employees"
|
||||
on public.employees for select
|
||||
to anon
|
||||
using (active = true);
|
||||
|
||||
-- Column-level grants: zuerst alle Rechte entziehen, dann gezielt
|
||||
-- nur die unkritischen Spalten freigeben. Trusted-Token-Felder bleiben
|
||||
-- für anon unzugänglich.
|
||||
revoke all on public.employees from anon;
|
||||
grant select
|
||||
(id, slug, academic_title, qualification, first_name, last_name,
|
||||
position, company, address, street, postal_code, city, email,
|
||||
phone_office, phone_mobile, website, linkedin_url, xing_url,
|
||||
calendar_url, photo_url, show_mobile, show_linkedin, show_xing,
|
||||
active, org_unit_id, created_at, updated_at)
|
||||
on public.employees to anon;
|
||||
@@ -0,0 +1,47 @@
|
||||
-- Verwaiste service_relations entfernen, wenn Bereiche/Stellen
|
||||
-- gelöscht werden.
|
||||
-- ====================================================================
|
||||
-- service_relations.scope_id ist polymorph (org_unit oder position),
|
||||
-- daher kein direkter FK möglich. Stattdessen Trigger, die nach Delete
|
||||
-- auf den Quell-Tabellen die scope_id aufräumen.
|
||||
--
|
||||
-- Strategie: NULL-out (statt cascade-delete), damit die service_relation
|
||||
-- als Datensatz erhalten bleibt — der Admin sieht im Audit, dass die
|
||||
-- Verknüpfung früher existierte und die Quelle weg ist. Die App-UI
|
||||
-- zeigt sie als "(Bereich/Stelle gelöscht)".
|
||||
|
||||
create or replace function public.cleanup_service_relations_org_unit()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
as $$
|
||||
begin
|
||||
update public.service_relations
|
||||
set scope_id = null
|
||||
where scope_kind = 'org_unit' and scope_id = OLD.id;
|
||||
return OLD;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.cleanup_service_relations_position()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
as $$
|
||||
begin
|
||||
update public.service_relations
|
||||
set scope_id = null
|
||||
where scope_kind = 'position' and scope_id = OLD.id;
|
||||
return OLD;
|
||||
end;
|
||||
$$;
|
||||
|
||||
drop trigger if exists trg_cleanup_service_relations_org_unit
|
||||
on public.org_units;
|
||||
create trigger trg_cleanup_service_relations_org_unit
|
||||
before delete on public.org_units
|
||||
for each row execute function public.cleanup_service_relations_org_unit();
|
||||
|
||||
drop trigger if exists trg_cleanup_service_relations_position
|
||||
on public.positions;
|
||||
create trigger trg_cleanup_service_relations_position
|
||||
before delete on public.positions
|
||||
for each row execute function public.cleanup_service_relations_position();
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Pro Mitarbeiter darf maximal eine Stelle als primary markiert sein.
|
||||
-- ====================================================================
|
||||
-- bisher: position_assignments.is_primary konnte mehrfach true sein —
|
||||
-- ein Mitarbeiter hatte dann widersprüchliche "Hauptstellen". Im UI
|
||||
-- wurde immer die zuletzt gefundene gewonnen, was inkonsistent war.
|
||||
--
|
||||
-- Schritt 1: bestehende Mehrfach-Primaries bereinigen — pro Mitarbeiter
|
||||
-- bleibt die mit jüngstem valid_from primary, alle anderen werden auf
|
||||
-- false gesetzt.
|
||||
--
|
||||
-- Schritt 2: partial unique index (employee_id) where is_primary = true
|
||||
-- verhindert künftige Mehrfach-Primaries auf DB-Ebene.
|
||||
|
||||
with ranked as (
|
||||
select id,
|
||||
row_number() over (
|
||||
partition by employee_id
|
||||
order by valid_from desc, created_at desc
|
||||
) as rn
|
||||
from public.position_assignments
|
||||
where is_primary = true
|
||||
)
|
||||
update public.position_assignments pa
|
||||
set is_primary = false
|
||||
from ranked r
|
||||
where pa.id = r.id and r.rn > 1;
|
||||
|
||||
create unique index if not exists position_assignments_one_primary_per_employee
|
||||
on public.position_assignments (employee_id)
|
||||
where is_primary = true;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- Karten-Analytics: Views, vCard-Downloads und QR-Scans pro Karte.
|
||||
-- ====================================================================
|
||||
-- Privacy-by-default: nur das Notwendigste (Datum, Route, optional
|
||||
-- Country aus IP), kein Tracking-Cookie, keine Personen-Identifikation.
|
||||
-- Aufbewahrung: 365 Tage (Trigger / Cron später; keine harte Retention
|
||||
-- aktuell).
|
||||
|
||||
create table if not exists public.card_view_log (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
employee_id uuid not null references public.employees(id) on delete cascade,
|
||||
route text not null check (route in ('card', 'vcard', 'qr')),
|
||||
occurred_at timestamptz not null default now(),
|
||||
-- Optional: nur Country (z.B. "DE") aus dem CF/Edge-Header. Kein PII.
|
||||
country text,
|
||||
-- Referrer-Quelle gekürzt: nur Domain, keine vollständigen URLs.
|
||||
referrer_domain text
|
||||
);
|
||||
|
||||
create index if not exists card_view_log_employee_id_idx
|
||||
on public.card_view_log (employee_id, occurred_at desc);
|
||||
|
||||
create index if not exists card_view_log_occurred_at_idx
|
||||
on public.card_view_log (occurred_at desc);
|
||||
|
||||
alter table public.card_view_log enable row level security;
|
||||
-- Service-Role-only — anon hat keinen Zugriff.
|
||||
@@ -0,0 +1,38 @@
|
||||
-- Lead-Capture: Empfänger einer Visitenkarte teilt seine eigenen
|
||||
-- Kontaktdaten zurück. Der Mitarbeiter sieht das im Admin-Backend.
|
||||
-- ====================================================================
|
||||
-- Privacy: keine PII über Trusted-Token, kein Cookie-Tracking.
|
||||
-- Lead wird mit der Karte verknüpft (employee_id) — der Mitarbeiter
|
||||
-- (oder Admin) sieht in seiner Detail-Page eine Liste der Leads.
|
||||
--
|
||||
-- Spam-Mitigation auf App-Ebene: Honeypot-Feld + Rate-Limit pro IP.
|
||||
-- Hier nur Datenmodell.
|
||||
|
||||
create table if not exists public.card_leads (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
employee_id uuid not null references public.employees(id) on delete cascade,
|
||||
-- Kontaktdaten der Person, die die Karte zurück geteilt hat:
|
||||
first_name text not null,
|
||||
last_name text not null,
|
||||
email text,
|
||||
phone text,
|
||||
company text,
|
||||
position text,
|
||||
message text,
|
||||
-- Metadata:
|
||||
created_at timestamptz not null default now(),
|
||||
source_url text, -- URL der Karte, von der aus geteilt wurde
|
||||
read_at timestamptz, -- "gelesen" markieren im Admin
|
||||
-- DSGVO: explizite Einwilligung muss true sein.
|
||||
consent_given boolean not null default false
|
||||
);
|
||||
|
||||
create index if not exists card_leads_employee_id_idx
|
||||
on public.card_leads (employee_id, created_at desc);
|
||||
|
||||
create index if not exists card_leads_unread_idx
|
||||
on public.card_leads (employee_id) where read_at is null;
|
||||
|
||||
alter table public.card_leads enable row level security;
|
||||
-- Service-Role-only — anon kann zwar inserten (über Server Action mit
|
||||
-- Service-Role), aber nicht lesen.
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Mehrsprachige Karten: Englisch-Übersetzungen für die wichtigsten
|
||||
-- Felder, die typischerweise im internationalen Kontext relevant sind
|
||||
-- (Position und Qualifikation). Default-Sprache der Karte bleibt DE
|
||||
-- — der EN-Switch passiert über `?lang=en` auf der öffentlichen URL.
|
||||
|
||||
alter table public.employees
|
||||
add column if not exists position_en text,
|
||||
add column if not exists qualification_en text;
|
||||
|
||||
-- Anon-Rolle: Spalten freigeben (analog Migration 0014).
|
||||
grant select (position_en, qualification_en) on public.employees to anon;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Calendar-Embed: pro Mitarbeiter wählbar, ob calendar_url als Link
|
||||
-- oder als eingebettetes Iframe auf der Karte erscheint.
|
||||
-- ====================================================================
|
||||
-- Embed nur für vertrauenswürdige Domains (Cal.com, Calendly,
|
||||
-- Microsoft Bookings) — die Liste prüft die App.
|
||||
|
||||
alter table public.employees
|
||||
add column if not exists calendar_embed boolean not null default false;
|
||||
|
||||
grant select (calendar_embed) on public.employees to anon;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Wenn ein/e Mitarbeiter:in das Unternehmen verlässt, wird die Karte
|
||||
-- typischerweise auf active=false gesetzt. Bisher: 404. Neu: optionale
|
||||
-- Nachfolger-Zuordnung — Besucher der alten URL bekommen freundlich
|
||||
-- die neue Ansprechperson angezeigt.
|
||||
-- ====================================================================
|
||||
-- successor_employee_id: Nachfolger:in (eigene Karte)
|
||||
-- successor_note: freier Text falls keine 1:1-Nachfolge
|
||||
-- (z.B. "Bitte wenden Sie sich an die Zentrale.")
|
||||
|
||||
alter table public.employees
|
||||
add column if not exists successor_employee_id uuid
|
||||
references public.employees(id) on delete set null,
|
||||
add column if not exists successor_note text;
|
||||
|
||||
create index if not exists employees_successor_employee_id_idx
|
||||
on public.employees (successor_employee_id);
|
||||
|
||||
grant select (successor_employee_id, successor_note)
|
||||
on public.employees to anon;
|
||||
@@ -0,0 +1,56 @@
|
||||
-- GoBD-konformes Audit-Log: jede MA-Änderung wird mit Hash-Chain
|
||||
-- gespeichert, manipulationssicher, nur INSERT erlaubt.
|
||||
-- ====================================================================
|
||||
-- Format: jede Zeile enthält content_hash = SHA256 über (prev_hash +
|
||||
-- entity + entity_id + actor + timestamp + diff_json). Wenn ein
|
||||
-- Eintrag nachträglich geändert wird, bricht die Kette und das ist
|
||||
-- erkennbar.
|
||||
--
|
||||
-- entity: "employee" | "position" | "org_unit" | …
|
||||
-- entity_id: UUID des betroffenen Records
|
||||
-- action: "create" | "update" | "delete" | "activate" | "deactivate"
|
||||
-- actor: E-Mail des Admin-Users (oder "system" für Bulk/Cron)
|
||||
-- diff: JSON mit { field: { before, after } } — nur geänderte
|
||||
-- Felder
|
||||
--
|
||||
-- Aufbewahrung: 10 Jahre laut GoBD; aktuell keine harte Retention,
|
||||
-- App-Doku verweist auf manuelle Archivierung via CSV-Export.
|
||||
|
||||
create table if not exists public.change_log (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
occurred_at timestamptz not null default now(),
|
||||
entity text not null,
|
||||
entity_id uuid not null,
|
||||
action text not null,
|
||||
actor text not null,
|
||||
diff jsonb not null default '{}'::jsonb,
|
||||
prev_hash text, -- hash des vorherigen eintrags (kette)
|
||||
content_hash text not null -- sha256 dieses eintrags
|
||||
);
|
||||
|
||||
create index if not exists change_log_entity_idx
|
||||
on public.change_log (entity, entity_id, occurred_at desc);
|
||||
create index if not exists change_log_occurred_at_idx
|
||||
on public.change_log (occurred_at desc);
|
||||
|
||||
-- Schreibschutz auf Updates/Deletes — service-role-only, aber
|
||||
-- explizit als Trigger gegen Versehen.
|
||||
create or replace function public.protect_change_log()
|
||||
returns trigger language plpgsql as $$
|
||||
begin
|
||||
raise exception 'change_log ist immutable — keine Updates oder Deletes erlaubt';
|
||||
end;
|
||||
$$;
|
||||
|
||||
drop trigger if exists trg_protect_change_log_update on public.change_log;
|
||||
create trigger trg_protect_change_log_update
|
||||
before update on public.change_log
|
||||
for each row execute function public.protect_change_log();
|
||||
|
||||
drop trigger if exists trg_protect_change_log_delete on public.change_log;
|
||||
create trigger trg_protect_change_log_delete
|
||||
before delete on public.change_log
|
||||
for each row execute function public.protect_change_log();
|
||||
|
||||
alter table public.change_log enable row level security;
|
||||
-- Service-Role-only — anon hat gar keinen Zugriff.
|
||||
@@ -0,0 +1,41 @@
|
||||
-- Multi-Standort: Stadtwerke / Konzerne haben oft mehrere Standorte
|
||||
-- (Werk, Verwaltung, Außenstelle). Mitarbeiter werden einem Standort
|
||||
-- zugeordnet, im Admin-Verzeichnis und Organigramm filterbar.
|
||||
-- ====================================================================
|
||||
-- Optional: ein Standort kann eine eigene Anschrift haben (Adresse,
|
||||
-- PLZ, Stadt). Die Mitarbeiter-Adresse hat aber Vorrang, wenn sie
|
||||
-- gepflegt ist.
|
||||
|
||||
create table if not exists public.locations (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
slug text not null unique,
|
||||
name text not null,
|
||||
short_name text,
|
||||
street text,
|
||||
postal_code text,
|
||||
city text,
|
||||
sort_order integer not null default 0,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists locations_sort_order_idx
|
||||
on public.locations (sort_order, name);
|
||||
|
||||
drop trigger if exists trg_locations_updated_at on public.locations;
|
||||
create trigger trg_locations_updated_at
|
||||
before update on public.locations
|
||||
for each row execute function public.set_updated_at();
|
||||
|
||||
alter table public.employees
|
||||
add column if not exists location_id uuid
|
||||
references public.locations(id) on delete set null;
|
||||
|
||||
create index if not exists employees_location_id_idx
|
||||
on public.employees (location_id);
|
||||
|
||||
grant select (location_id) on public.employees to anon;
|
||||
|
||||
alter table public.locations enable row level security;
|
||||
-- Service-Role-only — anon braucht den Standort nicht direkt; wenn die
|
||||
-- Karte ihn anzeigt, hilft App-Code die Auflösung zu machen.
|
||||
@@ -0,0 +1,47 @@
|
||||
-- Webhook-Subscriptions für externe Integrationen.
|
||||
-- ====================================================================
|
||||
-- Externe Systeme abonnieren Events ("employee.created",
|
||||
-- "employee.updated", "lead.created", etc.) und erhalten POST-Requests
|
||||
-- mit JSON-Payload. Signatur via HMAC-SHA256 mit Secret pro Endpoint.
|
||||
--
|
||||
-- Delivery-Log: jede Zustellung wird protokolliert (für Retry-
|
||||
-- Diagnose und Audit).
|
||||
|
||||
create table if not exists public.webhook_subscriptions (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
name text not null,
|
||||
url text not null,
|
||||
events text[] not null default array[]::text[],
|
||||
secret text not null, -- für HMAC-Signatur
|
||||
active boolean not null default true,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists webhook_subscriptions_active_idx
|
||||
on public.webhook_subscriptions (active) where active = true;
|
||||
|
||||
drop trigger if exists trg_webhook_subscriptions_updated_at
|
||||
on public.webhook_subscriptions;
|
||||
create trigger trg_webhook_subscriptions_updated_at
|
||||
before update on public.webhook_subscriptions
|
||||
for each row execute function public.set_updated_at();
|
||||
|
||||
create table if not exists public.webhook_deliveries (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
subscription_id uuid references public.webhook_subscriptions(id)
|
||||
on delete cascade,
|
||||
event text not null,
|
||||
payload jsonb not null,
|
||||
status_code integer,
|
||||
response_body text,
|
||||
error text,
|
||||
delivered_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists webhook_deliveries_subscription_idx
|
||||
on public.webhook_deliveries (subscription_id, delivered_at desc);
|
||||
|
||||
alter table public.webhook_subscriptions enable row level security;
|
||||
alter table public.webhook_deliveries enable row level security;
|
||||
-- Service-Role-only.
|
||||
@@ -0,0 +1,83 @@
|
||||
-- Mehrere Visitenkarten pro Person.
|
||||
-- ====================================================================
|
||||
-- Use-Case: Geschäftsführer Felix Zösch hat eine Karte als „GF
|
||||
-- Stadtwerk", eine zweite als „BDEW-Ausschuss-Vertreter", eine dritte
|
||||
-- als „VKU-Delegierter". Alle Karten haben eigenen Slug, eigene
|
||||
-- Position, eigene Kontaktdaten — referenzieren aber dieselbe Person
|
||||
-- (gleicher Name, gleiches Foto, gleicher akademischer Titel).
|
||||
--
|
||||
-- Migration:
|
||||
-- 1. persons-Tabelle anlegen
|
||||
-- 2. employees.person_id (nullable FK) ergänzen
|
||||
-- 3. Backfill: pro existierendem Employee eine Person anlegen +
|
||||
-- verknüpfen (id-mäßig 1:1, semantisch erstmal eindeutig)
|
||||
-- 4. Spätere Mehrfach-Karten setzen denselben person_id auf weiteren
|
||||
-- employees-Zeilen
|
||||
--
|
||||
-- Für die Rückwärtskompatibilität bleiben die name- und photo-Felder
|
||||
-- in employees erhalten — sie sind die maßgeblichen Daten für die
|
||||
-- jeweilige Karte. Person-Felder werden für „Person-Übersicht" im
|
||||
-- Verzeichnis genutzt und können in v2 als Default-Quelle für neue
|
||||
-- Karten dienen.
|
||||
|
||||
create table if not exists public.persons (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
academic_title text,
|
||||
first_name text not null,
|
||||
last_name text not null,
|
||||
photo_url text,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists persons_last_name_idx
|
||||
on public.persons (last_name, first_name);
|
||||
|
||||
drop trigger if exists trg_persons_updated_at on public.persons;
|
||||
create trigger trg_persons_updated_at
|
||||
before update on public.persons
|
||||
for each row execute function public.set_updated_at();
|
||||
|
||||
alter table public.employees
|
||||
add column if not exists person_id uuid
|
||||
references public.persons(id) on delete set null;
|
||||
|
||||
create index if not exists employees_person_id_idx
|
||||
on public.employees (person_id);
|
||||
|
||||
-- Backfill: für jeden Employee ohne person_id eine Person anlegen
|
||||
-- und verknüpfen.
|
||||
do $$
|
||||
declare
|
||||
e record;
|
||||
new_person_id uuid;
|
||||
begin
|
||||
for e in
|
||||
select id, academic_title, first_name, last_name, photo_url
|
||||
from public.employees
|
||||
where person_id is null
|
||||
loop
|
||||
insert into public.persons (academic_title, first_name, last_name, photo_url)
|
||||
values (e.academic_title, e.first_name, e.last_name, e.photo_url)
|
||||
returning id into new_person_id;
|
||||
update public.employees
|
||||
set person_id = new_person_id
|
||||
where id = e.id;
|
||||
end loop;
|
||||
end $$;
|
||||
|
||||
-- Anon-Zugriff: person_id darf gelesen werden, damit
|
||||
-- "weitere Karten dieser Person"-Liste auf öffentlicher Seite
|
||||
-- möglich ist.
|
||||
grant select (person_id) on public.employees to anon;
|
||||
|
||||
-- persons-Tabelle: anon darf basics (name, foto) lesen — wird auf
|
||||
-- der "weitere Karten"-Liste der öffentlichen Karte angezeigt.
|
||||
alter table public.persons enable row level security;
|
||||
create policy "public_read_persons"
|
||||
on public.persons for select
|
||||
to anon using (true);
|
||||
revoke all on public.persons from anon;
|
||||
grant select
|
||||
(id, academic_title, first_name, last_name, photo_url)
|
||||
on public.persons to anon;
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Self-Service-Portal: Magic-Link-Auth für Mitarbeiter.
|
||||
-- ====================================================================
|
||||
-- Zweck: Mitarbeiter pflegen Foto, Mobil- & Social-Links eigenständig,
|
||||
-- ohne Admin-Login. Auth läuft über Magic-Link an die Dienst-E-Mail.
|
||||
--
|
||||
-- Flow:
|
||||
-- 1. MA gibt seine Dienst-E-Mail auf /portal ein.
|
||||
-- 2. Server findet Mitarbeiter, erzeugt Token (24 Zeichen URL-safe),
|
||||
-- schreibt Zeile in magic_link_tokens, sendet Mail mit
|
||||
-- https://team.stwhas.de/portal/auth/<token>.
|
||||
-- 3. MA klickt Link → Token wird konsumiert (used_at gesetzt) →
|
||||
-- portal_session-Cookie gesetzt → Redirect /portal/edit.
|
||||
-- 4. Auf /portal/edit darf MA nur das eigene Profil bearbeiten,
|
||||
-- nur die im Form sichtbaren Felder (siehe portal/edit/actions.ts).
|
||||
--
|
||||
-- Sicherheit:
|
||||
-- - Token gültig 30 Min, einmalig konsumierbar.
|
||||
-- - Bei Verdachts-Re-Use des Tokens (used_at != null und erneut
|
||||
-- abgerufen) → Login verweigert + Audit-Log-Eintrag.
|
||||
-- - Tabelle ist anon-blockiert (RLS), nur Service-Role greift zu.
|
||||
|
||||
create table if not exists public.magic_link_tokens (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
employee_id uuid not null references public.employees(id) on delete cascade,
|
||||
token text not null unique,
|
||||
expires_at timestamptz not null,
|
||||
used_at timestamptz,
|
||||
created_at timestamptz not null default now(),
|
||||
ip text
|
||||
);
|
||||
|
||||
create index if not exists magic_link_tokens_token_idx
|
||||
on public.magic_link_tokens (token);
|
||||
|
||||
create index if not exists magic_link_tokens_employee_idx
|
||||
on public.magic_link_tokens (employee_id, created_at desc);
|
||||
|
||||
alter table public.magic_link_tokens enable row level security;
|
||||
-- Keine Policy → anon hat keinen Zugriff. Service-Role umgeht RLS.
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Check-out fuer Empfangs-Besucher.
|
||||
-- ====================================================================
|
||||
-- Wenn ein Besucher das Gebaeude verlaesst, kann der Empfangs-Mitarbeiter
|
||||
-- (oder eine Empfang-Tablet-Kraft) den Besuch als "ausgecheckt" markieren.
|
||||
-- card_leads dient ebenfalls als Empfangs-Buch: source_url = '/admin/empfang'
|
||||
-- (oder '/empfang' aus dem Kiosk-Modus) markiert einen Empfang-Eintrag.
|
||||
-- Die Spalte ist generisch nutzbar und steht jeden Lead zur Verfuegung,
|
||||
-- aber UI-seitig nur fuer Empfangs-Eintraege gepflegt.
|
||||
|
||||
alter table public.card_leads
|
||||
add column if not exists checked_out_at timestamptz;
|
||||
|
||||
-- Index fuer "heute offen" — schnelle Filterung der noch nicht ausgecheckten
|
||||
-- Besuche aus den letzten 24h.
|
||||
create index if not exists card_leads_open_visits_idx
|
||||
on public.card_leads (created_at desc)
|
||||
where checked_out_at is null
|
||||
and source_url in ('/admin/empfang', '/empfang');
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Empfang-Kiosk-Modus: separates Auth-Modell fuer das Tablet am
|
||||
-- Empfangstresen — KEIN Admin-Zugriff.
|
||||
-- ====================================================================
|
||||
-- Idee:
|
||||
-- * Admin legt einen Kiosk an (Name, z.B. "Empfangstresen Hauptgebaeude"),
|
||||
-- bekommt einmalig eine URL `https://teamvis.de/empfang/k/<token>`.
|
||||
-- * Diese URL wird einmal auf dem Tablet geoeffnet → Server validiert
|
||||
-- Token gegen `token_hash`, setzt langlebiges `kiosk_session`-Cookie
|
||||
-- (iron-session, 1 Jahr) und redirected auf `/empfang`.
|
||||
-- * Das Tablet bleibt damit dauerhaft im Empfangs-Modus, kann aber
|
||||
-- ueber das Cookie nichts anderes als die Empfang-Action.
|
||||
-- * Token wird nicht im Klartext gespeichert — bei Verlust einfach
|
||||
-- widerrufen (`revoked_at` setzen) und neuen Kiosk anlegen.
|
||||
|
||||
create table if not exists public.reception_kiosks (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
name text not null,
|
||||
-- Slug zum Anzeigen in Admin-UI / Logs (z.B. "empfang-haupt").
|
||||
-- Eindeutig, klein, ohne Leerzeichen — generiert aus dem Namen.
|
||||
slug text not null unique,
|
||||
-- SHA256-Hex des Tokens. Token selbst (Base64URL ~32B) wird nur
|
||||
-- bei der Erstellung an die Admin-UI zurueckgegeben und sofort
|
||||
-- in die Aktivierungs-URL eingebaut. Danach nicht mehr abrufbar.
|
||||
token_hash text not null,
|
||||
created_at timestamptz not null default now(),
|
||||
-- Wann zuletzt ein Request mit dem Kiosk-Cookie validiert wurde.
|
||||
-- Hilfreich um vergessene/inaktive Tablets zu erkennen.
|
||||
last_used_at timestamptz,
|
||||
-- Soft-Revoke. Eintraege bleiben fuer Audit-Zwecke erhalten;
|
||||
-- ein widerrufener Kiosk akzeptiert kein Cookie und keine URL mehr.
|
||||
revoked_at timestamptz
|
||||
);
|
||||
|
||||
create index if not exists reception_kiosks_token_hash_idx
|
||||
on public.reception_kiosks (token_hash)
|
||||
where revoked_at is null;
|
||||
|
||||
alter table public.reception_kiosks enable row level security;
|
||||
-- Default deny — Admin-Zugriff laeuft ueber Service-Role.
|
||||
@@ -0,0 +1,70 @@
|
||||
-- Empfangsbuch + GoBD-Audit-Trail.
|
||||
-- ====================================================================
|
||||
-- 1. card_leads.kiosk_id ergaenzen — welcher Kiosk hat den Eintrag erzeugt
|
||||
-- (NULL = ueber /admin/empfang von einem Admin-User).
|
||||
-- 2. reception_visit_audit als append-only Event-Log: pro Besuch wird
|
||||
-- jedes Ereignis (created / checked_out / mail_sent / mail_failed /
|
||||
-- deleted / updated) als eigene Zeile geschrieben.
|
||||
-- Loeschen / Aendern von card_leads zerstoert das Audit-Log NICHT
|
||||
-- (ON DELETE SET NULL auf lead_id; payload behaelt Snapshot).
|
||||
-- 3. DB-Trigger verbietet UPDATE und DELETE auf der Audit-Tabelle —
|
||||
-- GoBD-Anforderung "Unveraenderlichkeit nach Abschluss".
|
||||
|
||||
-- 1) Kiosk-Referenz auf card_leads
|
||||
alter table public.card_leads
|
||||
add column if not exists kiosk_id uuid
|
||||
references public.reception_kiosks(id) on delete set null;
|
||||
|
||||
create index if not exists card_leads_kiosk_idx
|
||||
on public.card_leads (kiosk_id, created_at desc)
|
||||
where kiosk_id is not null;
|
||||
|
||||
-- 2) Audit-Tabelle
|
||||
create table if not exists public.reception_visit_audit (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
lead_id uuid references public.card_leads(id) on delete set null,
|
||||
occurred_at timestamptz not null default now(),
|
||||
event_kind text not null check (event_kind in (
|
||||
'created', 'checked_out', 'mail_sent', 'mail_failed',
|
||||
'updated', 'deleted'
|
||||
)),
|
||||
actor_kind text not null check (actor_kind in ('admin','kiosk','system')),
|
||||
actor_id uuid,
|
||||
-- Vollstaendiger Snapshot der relevanten Daten, damit der Audit-Eintrag
|
||||
-- auch nach Loeschung des urspruenglichen Leads aussagekraeftig bleibt.
|
||||
-- Felder typischerweise: visitor_first_name, visitor_last_name,
|
||||
-- visitor_company, visitor_email, visitor_phone, employee_id,
|
||||
-- employee_name, reason, source, kiosk_id, mail_to, error.
|
||||
payload jsonb not null default '{}'::jsonb
|
||||
);
|
||||
|
||||
create index if not exists reception_visit_audit_occurred_idx
|
||||
on public.reception_visit_audit (occurred_at desc);
|
||||
|
||||
create index if not exists reception_visit_audit_lead_idx
|
||||
on public.reception_visit_audit (lead_id)
|
||||
where lead_id is not null;
|
||||
|
||||
create index if not exists reception_visit_audit_kind_idx
|
||||
on public.reception_visit_audit (event_kind, occurred_at desc);
|
||||
|
||||
-- 3) Unveraenderlichkeit: UPDATE und DELETE auf der Audit-Tabelle blocken.
|
||||
create or replace function public.reject_reception_audit_modify()
|
||||
returns trigger as $$
|
||||
begin
|
||||
raise exception 'reception_visit_audit ist append-only — % nicht erlaubt', tg_op;
|
||||
end;
|
||||
$$ language plpgsql;
|
||||
|
||||
drop trigger if exists no_update_reception_audit on public.reception_visit_audit;
|
||||
create trigger no_update_reception_audit
|
||||
before update on public.reception_visit_audit
|
||||
for each row execute function public.reject_reception_audit_modify();
|
||||
|
||||
drop trigger if exists no_delete_reception_audit on public.reception_visit_audit;
|
||||
create trigger no_delete_reception_audit
|
||||
before delete on public.reception_visit_audit
|
||||
for each row execute function public.reject_reception_audit_modify();
|
||||
|
||||
alter table public.reception_visit_audit enable row level security;
|
||||
-- Default deny — Lesezugriff nur ueber Service-Role (Admin-Pfade).
|
||||
@@ -0,0 +1,59 @@
|
||||
-- DSGVO Art. 17 (Recht auf Vergessenwerden) fuer Empfangs-Audit-Daten.
|
||||
-- ====================================================================
|
||||
-- Problem: reception_visit_audit ist per Trigger append-only (GoBD).
|
||||
-- Bei einem Loeschanspruch nach DSGVO muessen aber personenbezogene
|
||||
-- Daten aus dem `payload`-JSON entfernt werden — die strukturelle
|
||||
-- Zeile (id, occurred_at, event_kind, actor_kind, actor_id, lead_id)
|
||||
-- bleibt fuer den Audit-Trail erhalten.
|
||||
--
|
||||
-- Loesung:
|
||||
-- 1. Spalte `redacted_at` markiert anonymisierte Eintraege.
|
||||
-- 2. UPDATE-Trigger erlaubt EINEN spezifischen UPDATE-Pfad: das
|
||||
-- Setzen von `redacted_at` UND gleichzeitiges Nullen / Redacten
|
||||
-- der payload-PII. Alle anderen UPDATEs werden weiterhin geblockt.
|
||||
-- 3. card_leads bekommt auch `redacted_at` — Anonymisierung sowohl
|
||||
-- im Hauptdatensatz als auch im Audit-Log.
|
||||
|
||||
alter table public.reception_visit_audit
|
||||
add column if not exists redacted_at timestamptz;
|
||||
|
||||
alter table public.card_leads
|
||||
add column if not exists redacted_at timestamptz;
|
||||
|
||||
-- Trigger neu definieren: erlaubt UPDATEs die NUR redacted_at + payload
|
||||
-- ändern (Anonymisierungs-Pfad). Aenderungen anderer Spalten bleiben
|
||||
-- blockiert.
|
||||
create or replace function public.reject_reception_audit_modify()
|
||||
returns trigger as $$
|
||||
begin
|
||||
if tg_op = 'DELETE' then
|
||||
raise exception 'reception_visit_audit ist append-only — DELETE nicht erlaubt';
|
||||
end if;
|
||||
-- UPDATE: nur erlauben wenn redacted_at von NULL auf NOT NULL gesetzt
|
||||
-- wird UND keine anderen identitaetsrelevanten Felder geaendert werden.
|
||||
if tg_op = 'UPDATE' then
|
||||
if old.redacted_at is not null then
|
||||
raise exception 'Eintrag wurde bereits anonymisiert — keine weiteren UPDATEs';
|
||||
end if;
|
||||
if new.redacted_at is null then
|
||||
raise exception 'reception_visit_audit-UPDATE nur fuer DSGVO-Anonymisierung erlaubt (redacted_at muss gesetzt werden)';
|
||||
end if;
|
||||
if new.id <> old.id
|
||||
or new.lead_id is distinct from old.lead_id
|
||||
or new.occurred_at <> old.occurred_at
|
||||
or new.event_kind <> old.event_kind
|
||||
or new.actor_kind <> old.actor_kind
|
||||
or new.actor_id is distinct from old.actor_id
|
||||
then
|
||||
raise exception 'reception_visit_audit-Anonymisierung darf nur payload + redacted_at aendern';
|
||||
end if;
|
||||
return new;
|
||||
end if;
|
||||
return new;
|
||||
end;
|
||||
$$ language plpgsql;
|
||||
|
||||
-- Index fuer schnelle Filterung "noch nicht anonymisiert".
|
||||
create index if not exists reception_visit_audit_active_idx
|
||||
on public.reception_visit_audit (occurred_at desc)
|
||||
where redacted_at is null;
|
||||
@@ -0,0 +1,47 @@
|
||||
-- Admin-Self-Service-Onboarding.
|
||||
-- ====================================================================
|
||||
-- Aktuell muessen neue Admins manuell per SQL in admin_users eingefuegt
|
||||
-- werden. Dieser Workflow stellt einen Magic-Link-basierten Onboarding-
|
||||
-- Pfad bereit:
|
||||
--
|
||||
-- 1. Bestehender Admin gibt eine E-Mail-Adresse ein → Eintrag in
|
||||
-- `admin_invites` mit Token (Hash) + Ablauf (48h).
|
||||
-- 2. System schickt Mail mit Aktivierungs-URL `/admin/aktivieren/<token>`.
|
||||
-- 3. Empfaenger oeffnet URL, setzt Passwort, ggf. Anzeigename.
|
||||
-- → Eintrag in `admin_users` wird angelegt, Invite `used_at` gestempelt.
|
||||
--
|
||||
-- Zusatz: `admin_users.role` macht Berechtigungs-Differenzierung
|
||||
-- moeglich. Default = 'admin' (Vollzugriff). Spaeter ist `viewer` denkbar
|
||||
-- (lesen-only), aktuell nicht implementiert.
|
||||
|
||||
alter table public.admin_users
|
||||
add column if not exists name text,
|
||||
add column if not exists role text not null default 'admin'
|
||||
check (role in ('admin', 'viewer')),
|
||||
add column if not exists invited_by uuid references public.admin_users(id) on delete set null,
|
||||
add column if not exists last_login_at timestamptz;
|
||||
|
||||
create table if not exists public.admin_invites (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
email text not null,
|
||||
-- SHA256(token); Klartext-Token nur einmal in der Mail-URL.
|
||||
token_hash text not null,
|
||||
role text not null default 'admin'
|
||||
check (role in ('admin', 'viewer')),
|
||||
invited_by uuid references public.admin_users(id) on delete set null,
|
||||
created_at timestamptz not null default now(),
|
||||
expires_at timestamptz not null,
|
||||
used_at timestamptz,
|
||||
revoked_at timestamptz
|
||||
);
|
||||
|
||||
create index if not exists admin_invites_token_idx
|
||||
on public.admin_invites (token_hash)
|
||||
where used_at is null and revoked_at is null;
|
||||
|
||||
create index if not exists admin_invites_email_idx
|
||||
on public.admin_invites (email)
|
||||
where used_at is null and revoked_at is null;
|
||||
|
||||
alter table public.admin_invites enable row level security;
|
||||
-- Default deny — Zugriff nur ueber Service-Role (Admin-Pfade).
|
||||
@@ -0,0 +1,14 @@
|
||||
-- 2FA fuer Admin-Login (BSI-Mindeststandard, Cyber-Versicherung).
|
||||
-- ====================================================================
|
||||
-- TOTP-Secrets werden im admin_users-Eintrag selbst gespeichert (keine
|
||||
-- separate Tabelle — 1:1-Beziehung, kein Sharding-Sinn). `totp_secret`
|
||||
-- wird Base32 gespeichert (otplib-Konvention), `totp_enabled` markiert
|
||||
-- den Aktivierungs-Status nach erfolgreichem Setup.
|
||||
-- Backup-Codes als Array von SHA256-Hashes — Klartext nur einmal beim
|
||||
-- Setup an den User zurueckgegeben, danach nicht mehr abrufbar.
|
||||
|
||||
alter table public.admin_users
|
||||
add column if not exists totp_secret text,
|
||||
add column if not exists totp_enabled boolean not null default false,
|
||||
add column if not exists totp_backup_codes text[] not null default '{}',
|
||||
add column if not exists totp_enabled_at timestamptz;
|
||||
@@ -0,0 +1,45 @@
|
||||
-- Voranmeldung von Besuchern.
|
||||
-- ====================================================================
|
||||
-- Mitarbeiter koennen im Self-Service-Portal vorab Besucher anmelden,
|
||||
-- bevor diese am Empfang erscheinen. Am Empfangs-Tablet gibt es dann
|
||||
-- eine "Erwartet"-Liste mit Ein-Klick-Eintragen — der Lead-Insert
|
||||
-- erfolgt automatisch, Tipparbeit am Tresen entfaellt.
|
||||
--
|
||||
-- Lifecycle:
|
||||
-- pending → wartet auf Eintreffen
|
||||
-- arrived → wurde am Empfang erfasst (lead_id verknuepft)
|
||||
-- expired → war 24h nach erwarteter Zeit nicht da
|
||||
-- cancelled → vom MA storniert
|
||||
|
||||
create table if not exists public.visitor_preregistrations (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
-- MA der die Voranmeldung erstellt hat
|
||||
employee_id uuid not null references public.employees(id) on delete cascade,
|
||||
-- Daten des erwarteten Besuchers
|
||||
visitor_first_name text not null,
|
||||
visitor_last_name text not null,
|
||||
visitor_company text,
|
||||
visitor_email text,
|
||||
visitor_phone text,
|
||||
reason text,
|
||||
-- Erwarteter Termin (kann null sein bei "irgendwann heute")
|
||||
expected_at timestamptz,
|
||||
-- Lifecycle
|
||||
status text not null default 'pending'
|
||||
check (status in ('pending', 'arrived', 'expired', 'cancelled')),
|
||||
-- Bei status='arrived' Verknuepfung zum entstandenen Lead
|
||||
arrived_lead_id uuid references public.card_leads(id) on delete set null,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists visitor_preregistrations_employee_idx
|
||||
on public.visitor_preregistrations (employee_id, created_at desc);
|
||||
|
||||
-- Index fuer "heute pending" im Empfang-Tablet
|
||||
create index if not exists visitor_preregistrations_pending_idx
|
||||
on public.visitor_preregistrations (expected_at)
|
||||
where status = 'pending';
|
||||
|
||||
alter table public.visitor_preregistrations enable row level security;
|
||||
-- Default deny — Zugriff ueber Service-Role (Admin + Kiosk).
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Pflichtangaben (§ 35a GmbHG) + Bildmarken-Logo.
|
||||
-- ====================================================================
|
||||
-- 1) site_settings erweitern um:
|
||||
-- - legal_form (z.B. "GmbH", "UG (haftungsbeschränkt)", "AG")
|
||||
-- - register_court (z.B. "Amtsgericht Bamberg")
|
||||
-- - register_number (z.B. "HRB 4466")
|
||||
-- - managing_directors (Array, z.B. ["M.Eng. Felix Zösch", "..."])
|
||||
-- - supervisory_board_chair (optional, AG/grosse GmbH)
|
||||
-- - vat_id (z.B. "DE219286701", optional)
|
||||
-- - company_seat (Sitz der Gesellschaft, z.B. "Haßfurt")
|
||||
-- 2) site_settings.logo_mark_url für die reine Bildmarke (Symbol ohne
|
||||
-- Schriftzug) — fuer kompakte Kontexte wie E-Mail-Signaturen.
|
||||
|
||||
alter table public.site_settings
|
||||
add column if not exists legal_form text,
|
||||
add column if not exists register_court text,
|
||||
add column if not exists register_number text,
|
||||
add column if not exists managing_directors text[] not null default '{}',
|
||||
add column if not exists supervisory_board_chair text,
|
||||
add column if not exists vat_id text,
|
||||
add column if not exists company_seat text,
|
||||
add column if not exists logo_mark_url text,
|
||||
add column if not exists logo_mark_alt text;
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Apple Wallet Pass-Konfiguration.
|
||||
-- ====================================================================
|
||||
-- PKPass-Files werden serverseitig generiert und signiert. Dafuer
|
||||
-- braucht's pro Mandant:
|
||||
-- - Pass Type Certificate (.p12 von Apple Developer Portal, enthaelt
|
||||
-- Public + Private Key) → als Base64-String gespeichert.
|
||||
-- - Passphrase fuer den Private Key im .p12.
|
||||
-- - Pass Type Identifier (Reverse-Domain, registriert im Dev Portal).
|
||||
-- - Team Identifier (10-stellig, im Dev Portal sichtbar).
|
||||
--
|
||||
-- Apple WWDR Intermediate-Cert ist oeffentlich und wird mit dem
|
||||
-- Container ausgeliefert (lib/wallet/wwdr.ts) — keine DB-Spalte noetig.
|
||||
--
|
||||
-- Wenn alle vier Felder gesetzt sind, schaltet die Visitenkarte den
|
||||
-- "Zur Apple Wallet hinzufuegen"-Button frei.
|
||||
|
||||
alter table public.site_settings
|
||||
add column if not exists apple_pass_cert_p12 text,
|
||||
add column if not exists apple_pass_passphrase text,
|
||||
add column if not exists apple_pass_type_id text,
|
||||
add column if not exists apple_team_id text;
|
||||
|
||||
-- Hinweis: apple_pass_cert_p12 enthaelt den Private Key. Die Spalte ist
|
||||
-- nur ueber Service-Role lesbar (RLS auf site_settings macht das ohnehin
|
||||
-- so — anon-Reads gehen ueber explizite Spalten-Selects in den Helpers).
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Google Wallet Pass-Konfiguration.
|
||||
-- ====================================================================
|
||||
-- Google Wallet liefert Passes als JWT-encoded "Save to Wallet"-Links.
|
||||
-- Statt einer signierten ZIP-Datei (Apple) wird ein JWT mit dem
|
||||
-- Service-Account-Private-Key signiert, der User klickt darauf, und
|
||||
-- Google generiert auf Basis des JWT-Payloads den Pass.
|
||||
--
|
||||
-- Pro Mandant brauchts:
|
||||
-- - Google Cloud Service Account (JSON-Key mit email + private_key)
|
||||
-- - Issuer ID (vom Google Pay & Wallet Console, numerisch)
|
||||
-- - Class ID Suffix (eigene Wahl, z.B. "teamvis_card")
|
||||
-- → Voll-Class-ID wird zur Laufzeit zusammengesetzt:
|
||||
-- "<issuer_id>.<class_id_suffix>"
|
||||
-- → Class wird inline im JWT mitgesendet (kein Pre-Create noetig).
|
||||
--
|
||||
-- Wenn alle Felder gesetzt sind, schaltet die Visitenkarte den
|
||||
-- "Zur Google Wallet hinzufuegen"-Button frei.
|
||||
|
||||
alter table public.site_settings
|
||||
add column if not exists google_wallet_service_account_json text,
|
||||
add column if not exists google_wallet_issuer_id text,
|
||||
add column if not exists google_wallet_class_suffix text default 'teamvis_card';
|
||||
|
||||
-- Hinweis: google_wallet_service_account_json enthaelt den Private Key.
|
||||
-- Wie bei apple_pass_cert_p12 nur ueber Service-Role lesbar.
|
||||
@@ -0,0 +1,28 @@
|
||||
-- Lead-Inbox: card_leads zum CRM-Lite ausbauen.
|
||||
-- ====================================================================
|
||||
-- Bisher waren Leads reine Eingangs-Eintraege ohne Bearbeitungsstatus.
|
||||
-- Damit MA ihre eingehenden Leads strukturiert abarbeiten koennen
|
||||
-- (Messen, Lead-Capture-Formulare, gescannte Visitenkarten):
|
||||
--
|
||||
-- - lead_status: workflow-state (new/contacted/in_progress/...)
|
||||
-- - notes: freier Notiztext (z.B. "trifft mich naechste Woche an")
|
||||
-- - tags: Kategorien (z.B. "messe-2026", "key-account")
|
||||
-- - follow_up_at: Wiedervorlage-Zeitpunkt
|
||||
-- - source_kind: woher kommt der Lead (form/reception/scan/manual)
|
||||
-- ergaenzt die bestehende source_url
|
||||
|
||||
alter table public.card_leads
|
||||
add column if not exists lead_status text not null default 'new'
|
||||
check (lead_status in ('new', 'contacted', 'in_progress', 'converted', 'dismissed')),
|
||||
add column if not exists notes text,
|
||||
add column if not exists tags text[] not null default '{}',
|
||||
add column if not exists follow_up_at timestamptz,
|
||||
add column if not exists source_kind text not null default 'form'
|
||||
check (source_kind in ('form', 'reception', 'scan', 'manual'));
|
||||
|
||||
-- Index fuer "meine offenen Leads" + "Wiedervorlagen heute".
|
||||
create index if not exists card_leads_employee_status_idx
|
||||
on public.card_leads (employee_id, lead_status, created_at desc);
|
||||
create index if not exists card_leads_follow_up_idx
|
||||
on public.card_leads (follow_up_at)
|
||||
where follow_up_at is not null and lead_status not in ('converted', 'dismissed');
|
||||
@@ -0,0 +1,22 @@
|
||||
-- AI-Provider-Konfiguration fuer den Card-Scanner und kuenftige
|
||||
-- AI-gestuetzte Features.
|
||||
-- ====================================================================
|
||||
-- Statt nur ANTHROPIC_API_KEY in der ENV: pro Mandant via Admin-UI
|
||||
-- konfigurierbar. Drei Provider unterstuetzt:
|
||||
-- - anthropic (Claude — Default)
|
||||
-- - openai (GPT)
|
||||
-- - openrouter (OpenAI-compat API, Multi-Vendor)
|
||||
--
|
||||
-- ai_active_provider gibt an welcher Provider gerade verwendet wird.
|
||||
-- Keys + Modelle werden pro Provider separat gespeichert (Multi-
|
||||
-- Provider-Setup parallel moeglich, switching per Toggle).
|
||||
|
||||
alter table public.site_settings
|
||||
add column if not exists ai_active_provider text not null default 'anthropic'
|
||||
check (ai_active_provider in ('anthropic', 'openai', 'openrouter')),
|
||||
add column if not exists ai_anthropic_key text,
|
||||
add column if not exists ai_anthropic_model text default 'claude-sonnet-4-6',
|
||||
add column if not exists ai_openai_key text,
|
||||
add column if not exists ai_openai_model text default 'gpt-4o-mini',
|
||||
add column if not exists ai_openrouter_key text,
|
||||
add column if not exists ai_openrouter_model text default 'anthropic/claude-sonnet-4.6';
|
||||
@@ -0,0 +1,54 @@
|
||||
-- ====================================================================
|
||||
-- 0039_compliance_employee_bindings — Person-zentrierte Compliance-Bindings
|
||||
-- ====================================================================
|
||||
-- Bisher konnten Compliance-Rollen nur an Stellen (positions) gebunden
|
||||
-- werden. Manche Pflichtrollen sind aber persönliche Bestellungen, die
|
||||
-- nicht an einer eigenen Stelle hängen (z.B. VEFK, Stv. VEFK,
|
||||
-- Brandschutzhelfer:in, einzelne Schaltberechtigte).
|
||||
--
|
||||
-- Diese Migration erweitert compliance_role_bindings um eine optionale
|
||||
-- employee_id. Pro Binding wird entweder eine Position oder ein
|
||||
-- Mitarbeiter referenziert — nie beides, nie keines.
|
||||
-- --------------------------------------------------------------------
|
||||
|
||||
-- position_id wird optional
|
||||
alter table public.compliance_role_bindings
|
||||
alter column position_id drop not null;
|
||||
|
||||
-- Neue Spalte für Personen-Bindings
|
||||
alter table public.compliance_role_bindings
|
||||
add column if not exists employee_id uuid
|
||||
references public.employees(id) on delete cascade;
|
||||
|
||||
-- XOR-Constraint: genau einer von position_id / employee_id muss gesetzt sein
|
||||
alter table public.compliance_role_bindings
|
||||
drop constraint if exists compliance_role_bindings_subject_check;
|
||||
alter table public.compliance_role_bindings
|
||||
add constraint compliance_role_bindings_subject_check
|
||||
check (
|
||||
(position_id is not null and employee_id is null)
|
||||
or (position_id is null and employee_id is not null)
|
||||
);
|
||||
|
||||
-- Unique-Constraint erweitern: jetzt auch für employee-Bindings eindeutig
|
||||
-- (Drop+Recreate, weil die alte Definition nur position_id berücksichtigt
|
||||
-- hat — null-Werte würden mehrfach erlaubt sein.)
|
||||
alter table public.compliance_role_bindings
|
||||
drop constraint if exists compliance_role_bindings_unique;
|
||||
|
||||
create unique index if not exists compliance_role_bindings_unique_position
|
||||
on public.compliance_role_bindings (framework_id, role_id, position_id)
|
||||
where position_id is not null;
|
||||
|
||||
create unique index if not exists compliance_role_bindings_unique_employee
|
||||
on public.compliance_role_bindings (framework_id, role_id, employee_id)
|
||||
where employee_id is not null;
|
||||
|
||||
-- Lookup-Index auf employee_id (analog zu position_id)
|
||||
create index if not exists compliance_role_bindings_employee_idx
|
||||
on public.compliance_role_bindings (employee_id);
|
||||
|
||||
comment on column public.compliance_role_bindings.position_id is
|
||||
'Wenn gesetzt: Rolle hängt an einer Stelle — Inhaber der Stelle ist Compliance-Träger. Sich gegenseitig ausschließend mit employee_id.';
|
||||
comment on column public.compliance_role_bindings.employee_id is
|
||||
'Wenn gesetzt: Rolle ist eine persönliche Bestellung an einen Mitarbeiter (z.B. VEFK, Brandschutzhelfer:in). Sich gegenseitig ausschließend mit position_id.';
|
||||
@@ -0,0 +1,25 @@
|
||||
-- ====================================================================
|
||||
-- 0040_watermark — separates Watermark-Asset pro Mandant
|
||||
-- ====================================================================
|
||||
-- Bisher wurde im Visitenkarten-Hero die Bildmarke (logo_mark_url) als
|
||||
-- Watermark eingeblendet. Die Bildmarke wird aber auch in der Signatur,
|
||||
-- im QR-Generator und an anderen Stellen genutzt — dort braucht sie
|
||||
-- typischerweise einen gefüllten Hintergrund, damit sie gut sichtbar
|
||||
-- ist. Als Watermark im Hero wirkt ein gefülltes Block-Logo dagegen
|
||||
-- als Quadrat-Block, nicht als freistehendes Symbol.
|
||||
--
|
||||
-- Diese Migration fügt ein separates watermark_url + watermark_alt
|
||||
-- hinzu. Wenn das Feld leer ist, fällt die Render-Logik auf
|
||||
-- logo_mark_url zurück (Verhalten wie bisher), dann auf das generische
|
||||
-- Wellen-SVG. Wenn watermark_url gesetzt ist, wird es bevorzugt — der
|
||||
-- Mandant kann dann eine freistehende Outline-Variante hochladen.
|
||||
-- --------------------------------------------------------------------
|
||||
|
||||
alter table public.site_settings
|
||||
add column if not exists watermark_url text,
|
||||
add column if not exists watermark_alt text;
|
||||
|
||||
comment on column public.site_settings.watermark_url is
|
||||
'Optionales SVG/PNG für den Visitenkarten-Hero-Watermark. Sollte freistehend (transparenter Hintergrund) sein. Fallback: logo_mark_url, dann generisches Wellen-SVG.';
|
||||
comment on column public.site_settings.watermark_alt is
|
||||
'Alt-Text für das Watermark (aria-label). Watermark ist standardmäßig dekorativ; das Feld ist optional.';
|
||||
@@ -0,0 +1,40 @@
|
||||
-- ====================================================================
|
||||
-- 0041_phone_integration — Anbindung Telefonanlagen (3CX & generisch)
|
||||
-- ====================================================================
|
||||
-- Generisches Provider-Pattern: pro Mandant ein Telefon-Provider
|
||||
-- (aktuell 3CX im Fokus, weitere Adapter via lib/phone/providers/*.ts
|
||||
-- nachruestbar). Tokens und Geheimnisse liegen in site_settings;
|
||||
-- Endpunkte sind uniform unter /api/phone/*.
|
||||
--
|
||||
-- Phase 1 nutzt phone_lookup_token (Bearer fuer Inbound-Lookup).
|
||||
-- Phase 2 nutzt phone_webhook_secret (HMAC fuer Call-Event-POSTs).
|
||||
-- Phase 3+4 (Click-to-Call / Presence) brauchen phone_api_url +
|
||||
-- phone_api_token, die wir gleich mitanlegen.
|
||||
--
|
||||
-- employees.phone_extension mapping zur Anlage (z.B. "333" als
|
||||
-- 3CX-Extension fuer felix.zoesch). Erforderlich fuer Click-to-Call
|
||||
-- und Presence-Lookup.
|
||||
-- --------------------------------------------------------------------
|
||||
|
||||
alter table public.site_settings
|
||||
add column if not exists phone_provider text,
|
||||
add column if not exists phone_api_url text,
|
||||
add column if not exists phone_api_token text,
|
||||
add column if not exists phone_lookup_token text,
|
||||
add column if not exists phone_webhook_secret text;
|
||||
|
||||
alter table public.employees
|
||||
add column if not exists phone_extension text;
|
||||
|
||||
comment on column public.site_settings.phone_provider is
|
||||
'Telefonanlagen-Adapter: ''3cx'' | ''sipgate'' | ''asterisk'' | ''placetel'' | null. Bestimmt Format der Lookup-Antwort + Webhook-Erwartung.';
|
||||
comment on column public.site_settings.phone_api_url is
|
||||
'Base-URL der Anlagen-API (z.B. https://stwhas.3cx.eu/xapi/v1). Fuer Click-to-Call + Presence-Polling.';
|
||||
comment on column public.site_settings.phone_api_token is
|
||||
'Token fuer ausgehende Calls an die Anlagen-API. Nur Service-Role-lesbar.';
|
||||
comment on column public.site_settings.phone_lookup_token is
|
||||
'Bearer-Token, das die Anlage beim GET /api/phone/lookup mitschicken muss. Frei generierbar im Admin.';
|
||||
comment on column public.site_settings.phone_webhook_secret is
|
||||
'HMAC-Secret zur Validierung eingehender Call-Event-Webhooks (POST /api/phone/call-event). Frei generierbar im Admin.';
|
||||
comment on column public.employees.phone_extension is
|
||||
'Telefon-Extension dieser Mitarbeiterin/dieses Mitarbeiters in der Anlage (z.B. "333"). Optional — nur fuer Click-to-Call + Presence relevant.';
|
||||
@@ -0,0 +1,31 @@
|
||||
-- ====================================================================
|
||||
-- 0042_license — License-Foundation
|
||||
-- ====================================================================
|
||||
-- Speichert pro Instanz/Mandant einen signierten License-JWT. Inhalt:
|
||||
-- - tier (free/starter/business/enterprise)
|
||||
-- - max_employees, ma_overage_price_eur
|
||||
-- - enabled modules + per-module limits
|
||||
-- - hosting (cloud | self-hosted), branding_level
|
||||
-- - exp
|
||||
--
|
||||
-- Der JWT wird im Application-Code per public key validiert (lib/
|
||||
-- license.ts). Bei fehlendem oder ungueltigem Key faellt die Instanz
|
||||
-- auf Free-Tier-Defaults zurueck (10 MA, Core only).
|
||||
--
|
||||
-- license_status ist ein gecachter Wert ('active' | 'grace' | 'expired'
|
||||
-- | 'invalid'), license_checked_at der letzte Pruef-Zeitpunkt. Beides
|
||||
-- nicht-load-bearing — wird beim periodischen Check refresht. Idee:
|
||||
-- Render-Pages koennen ohne JWT-Decode den Status anzeigen.
|
||||
-- --------------------------------------------------------------------
|
||||
|
||||
alter table public.site_settings
|
||||
add column if not exists license_key text,
|
||||
add column if not exists license_status text,
|
||||
add column if not exists license_checked_at timestamptz;
|
||||
|
||||
comment on column public.site_settings.license_key is
|
||||
'Signierter License-JWT (RS256). Wird per lib/license.ts gegen den Public Key validiert. NULL → Free-Tier-Defaults.';
|
||||
comment on column public.site_settings.license_status is
|
||||
'Gecachter Status: active | grace | expired | invalid | null. Wird beim periodischen Check aktualisiert.';
|
||||
comment on column public.site_settings.license_checked_at is
|
||||
'Letzter Validierungs-Zeitpunkt. Wird genutzt um Refresh-Cycles zu drosseln.';
|
||||
@@ -0,0 +1,44 @@
|
||||
-- 0043: site_settings — Spalten-Grant für anon einschränken (SECURITY-FIX)
|
||||
--
|
||||
-- Bisher: Policy `public_read_site_settings` (using true) erlaubt anon das
|
||||
-- Lesen der site_settings-Zeile. RLS-Policies filtern aber KEINE Spalten —
|
||||
-- das macht nur GRANT. site_settings wurde (anders als employees in 0014,
|
||||
-- persons in 0025) nie spaltenweise eingeschränkt. Dadurch konnte jeder mit
|
||||
-- dem öffentlichen anon-Key per Supabase-REST die Geheimnisse auslesen:
|
||||
-- Apple-Wallet-Zertifikat (.p12) + Passphrase, Google-Service-Account-JSON,
|
||||
-- AI-API-Keys (Anthropic/OpenAI/OpenRouter), Phone-Tokens (api/lookup/
|
||||
-- webhook) und der License-Key.
|
||||
--
|
||||
-- Robuste Umsetzung (Block-Liste statt fester Spalten-Liste): anon den
|
||||
-- Vollzugriff entziehen, dann per Katalog-Lookup ALLE existierenden Spalten
|
||||
-- AUSSER den Geheimnis-Spalten freigeben. Funktioniert unabhängig vom
|
||||
-- Schema-Stand der jeweiligen Mandanten-DB (Prod, Demo, künftige Kunden) —
|
||||
-- eine feste Spalten-Liste bricht, sobald eine DB nicht voll migriert ist.
|
||||
--
|
||||
-- WICHTIG (Betrieb): Die bereits exponierten Geheimnisse gelten als
|
||||
-- kompromittiert und müssen rotiert werden (OpenRouter-Key, License-Key,
|
||||
-- Apple-Pass-Zertifikat + Passphrase, ggf. weitere befüllte Keys/Tokens).
|
||||
-- HINWEIS für neue Geheimnis-Spalten: unbedingt in die Block-Liste unten
|
||||
-- aufnehmen, sonst werden sie automatisch anon-lesbar.
|
||||
|
||||
revoke all on public.site_settings from anon;
|
||||
|
||||
do $$
|
||||
declare
|
||||
v_cols text;
|
||||
begin
|
||||
select string_agg(quote_ident(column_name), ', ')
|
||||
into v_cols
|
||||
from information_schema.columns
|
||||
where table_schema = 'public'
|
||||
and table_name = 'site_settings'
|
||||
and column_name not in (
|
||||
-- Geheimnis-Spalten — NUR über Service-Role lesbar:
|
||||
'ai_anthropic_key', 'ai_openai_key', 'ai_openrouter_key',
|
||||
'apple_pass_cert_p12', 'apple_pass_passphrase', 'apple_pass_type_id',
|
||||
'google_wallet_service_account_json',
|
||||
'phone_api_token', 'phone_lookup_token', 'phone_webhook_secret',
|
||||
'license_key'
|
||||
);
|
||||
execute 'grant select (' || v_cols || ') on public.site_settings to anon';
|
||||
end $$;
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Storage-Bucket für Mitarbeiter-Fotos. Bisher musste dieser Bucket bei
|
||||
-- jeder neuen Instanz manuell in Supabase Studio angelegt werden — das war
|
||||
-- die einzige Storage-Ressource ohne Migration (branding-assets kommt aus
|
||||
-- 0006). Damit ein frisches Onboarding rein über die Migrationen läuft,
|
||||
-- legen wir den Bucket hier idempotent an.
|
||||
--
|
||||
-- Öffentlich, weil die Karten-Fotos via next/image direkt aus der
|
||||
-- public-Storage-Route geladen werden (Host in next.config.ts whitelisted).
|
||||
-- Uploads laufen ausschließlich über die Service-Role (Server Action
|
||||
-- saveEmployee / Portal-Upload) und umgehen RLS — daher keine Insert-Policy
|
||||
-- für anon nötig, nur Lesezugriff.
|
||||
|
||||
insert into storage.buckets (id, name, public)
|
||||
values ('employee-photos', 'employee-photos', true)
|
||||
on conflict (id) do nothing;
|
||||
|
||||
drop policy if exists "public_read_employee_photos" on storage.objects;
|
||||
create policy "public_read_employee_photos"
|
||||
on storage.objects for select
|
||||
to anon
|
||||
using (bucket_id = 'employee-photos');
|
||||
@@ -0,0 +1,25 @@
|
||||
-- H2: Sichtbarkeits-Flags auf DB-Ebene durchsetzen.
|
||||
--
|
||||
-- phone_mobile, linkedin_url und xing_url waren spaltenweise an anon
|
||||
-- GRANTed (Migration 0014 ff.). Die Opt-out-Flags show_mobile/show_linkedin/
|
||||
-- show_xing filterten diese Felder aber NUR in der Render-Schicht
|
||||
-- (VisitingCard) und im vCard-Builder — über den öffentlichen anon-Key
|
||||
-- liessen sie sich per Supabase-REST direkt für ALLE aktiven Mitarbeiter
|
||||
-- abgreifen, unabhaengig vom Opt-out. Das ist dieselbe Klasse wie K-1:
|
||||
-- RLS-Policies und Grants filtern KEINE Spalten bedingt.
|
||||
--
|
||||
-- Ab jetzt liest die App diese Felder ausschliesslich serverseitig ueber
|
||||
-- getActiveEmployeeBySlug (auf createServiceClient umgestellt) und wendet
|
||||
-- die show_*-Flags weiterhin im Server-Render/vCard an. anon braucht die
|
||||
-- Felder daher nicht mehr.
|
||||
--
|
||||
-- successor_employee_id / successor_note werden ohnehin nur ueber
|
||||
-- getSuccessorInfoForSlug (service-role) gelesen und sind hier mit
|
||||
-- abgeraeumt (vorher unnoetig anon-lesbar, Low-Befund).
|
||||
--
|
||||
-- REVOKE auf nicht vorhandene Spalten-Grants ist ein No-op (NOTICE, kein
|
||||
-- Fehler) — die Migration ist damit auch auf Instanzen ohne den vollen
|
||||
-- Grant unkritisch.
|
||||
|
||||
revoke select (phone_mobile, linkedin_url, xing_url, successor_employee_id, successor_note)
|
||||
on public.employees from anon;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- =====================================================================
|
||||
-- 0046 — Login-Code fürs Self-Service-Portal (zusätzlich zum Magic-Link)
|
||||
-- =====================================================================
|
||||
-- Der klickbare Magic-Link öffnet im System-Browser, nicht im installierten
|
||||
-- Standalone-PWA — Browser und PWA haben getrennte Cookie-Speicher, die im
|
||||
-- Browser gesetzte Session landet also nie im PWA. Ein 6-stelliger Code, den
|
||||
-- man IM PWA eintippt, verifiziert aus dem PWA-Kontext und setzt das Cookie
|
||||
-- dort, wo es hingehört.
|
||||
--
|
||||
-- `attempts` begrenzt Brute-Force auf den 6-stelligen Code (zusätzlich zum
|
||||
-- Rate-Limit in der Server-Action): nach zu vielen Fehlversuchen wird der
|
||||
-- Token entwertet.
|
||||
|
||||
alter table public.magic_link_tokens
|
||||
add column if not exists code text,
|
||||
add column if not exists attempts integer not null default 0;
|
||||
@@ -0,0 +1,13 @@
|
||||
-- =====================================================================
|
||||
-- 0047 — Erzwungene Passwortänderung beim ersten Login
|
||||
-- =====================================================================
|
||||
-- Frische Admins werden via create-admin.mjs mit einem Temp-Passwort
|
||||
-- angelegt. Bisher konnte dieses Passwort unbegrenzt bestehen bleiben.
|
||||
-- Mit diesem Flag zwingt das Admin-Backend zur Änderung, bevor man
|
||||
-- weiterarbeiten kann (siehe app/admin/layout.tsx). Wird beim erfolgreichen
|
||||
-- Passwortwechsel zurückgesetzt.
|
||||
--
|
||||
-- Default false: bestehende Admins (Prod/Demo) sind NICHT betroffen.
|
||||
|
||||
alter table public.admin_users
|
||||
add column if not exists must_change_password boolean not null default false;
|
||||
@@ -0,0 +1,145 @@
|
||||
-- =====================================================================
|
||||
-- 0048 — Modul „Stellenbeschreibungen"
|
||||
-- =====================================================================
|
||||
-- KI- + textbaustein-gestützte, compliance-konforme Stellenbeschreibungen.
|
||||
-- Hängt an den bestehenden Stellen (positions) des Organigramm-Moduls;
|
||||
-- der compliance-relevante Teil wird datengetrieben aus
|
||||
-- compliance_role_bindings + den Framework-YAMLs gespeist (nicht von der KI).
|
||||
--
|
||||
-- Drei Tabellen:
|
||||
-- job_description_blocks — wiederverwendbare Textbausteine
|
||||
-- job_descriptions — 1 Beschreibung pro Stelle (aktueller Stand)
|
||||
-- job_description_versions — append-only Historie bei jeder Freigabe
|
||||
--
|
||||
-- Alle Tabellen sind admin-only (RLS an, KEINE anon-Policy → Default deny);
|
||||
-- gelesen/geschrieben wird ausschließlich per Service-Role.
|
||||
|
||||
-- ── Textbausteine ────────────────────────────────────────────────────
|
||||
create table if not exists public.job_description_blocks (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
slug text not null unique,
|
||||
category text not null default 'allgemein',
|
||||
title text not null,
|
||||
body text not null default '',
|
||||
-- Steuert die automatische Vorauswahl im Editor.
|
||||
is_default boolean not null default false,
|
||||
-- Herkunft für aus Compliance-Frameworks abgeleitete Bausteine
|
||||
-- (rein informativ; der verbindliche Compliance-Abschnitt kommt live
|
||||
-- aus den Bindings, nicht aus diesen Bausteinen).
|
||||
source_framework_id text,
|
||||
source_role_id text,
|
||||
sort_order integer not null default 0,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists job_description_blocks_category_idx
|
||||
on public.job_description_blocks (category, sort_order);
|
||||
|
||||
-- ── Stellenbeschreibungen (1 pro Stelle) ─────────────────────────────
|
||||
create table if not exists public.job_descriptions (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
position_id uuid not null unique
|
||||
references public.positions(id) on delete cascade,
|
||||
title text not null default '',
|
||||
-- Strukturierte Abschnitte: { zweck, einordnung, aufgaben[],
|
||||
-- befugnisse[], anforderungen[], vertretung, ... } als JSON.
|
||||
content_json jsonb not null default '{}'::jsonb,
|
||||
status text not null default 'draft'
|
||||
check (status in ('draft', 'review', 'approved')),
|
||||
generated_by_ai boolean not null default false,
|
||||
model_used text,
|
||||
version integer not null default 1,
|
||||
approved_by text,
|
||||
approved_at timestamptz,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists job_descriptions_position_idx
|
||||
on public.job_descriptions (position_id);
|
||||
|
||||
-- ── Versions-Historie (Snapshot bei „Freigeben") ─────────────────────
|
||||
create table if not exists public.job_description_versions (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
job_description_id uuid not null
|
||||
references public.job_descriptions(id) on delete cascade,
|
||||
version integer not null,
|
||||
title text not null default '',
|
||||
content_json jsonb not null default '{}'::jsonb,
|
||||
created_by text,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists job_description_versions_jd_idx
|
||||
on public.job_description_versions (job_description_id, version desc);
|
||||
|
||||
-- ── updated_at-Trigger (set_updated_at existiert seit 0023) ───────────
|
||||
drop trigger if exists trg_job_description_blocks_updated_at
|
||||
on public.job_description_blocks;
|
||||
create trigger trg_job_description_blocks_updated_at
|
||||
before update on public.job_description_blocks
|
||||
for each row execute function public.set_updated_at();
|
||||
|
||||
drop trigger if exists trg_job_descriptions_updated_at
|
||||
on public.job_descriptions;
|
||||
create trigger trg_job_descriptions_updated_at
|
||||
before update on public.job_descriptions
|
||||
for each row execute function public.set_updated_at();
|
||||
|
||||
-- ── RLS: admin-only (Default deny, kein anon-Grant) ──────────────────
|
||||
alter table public.job_description_blocks enable row level security;
|
||||
alter table public.job_descriptions enable row level security;
|
||||
alter table public.job_description_versions enable row level security;
|
||||
|
||||
-- Service-Role (umgeht RLS) braucht trotzdem Tabellen-Grants. Auf
|
||||
-- Self-Host-Instanzen sind Default-Privileges nicht garantiert — daher
|
||||
-- explizit. Bewusst NICHT an anon/authenticated (admin-only).
|
||||
grant select, insert, update, delete on
|
||||
public.job_description_blocks,
|
||||
public.job_descriptions,
|
||||
public.job_description_versions
|
||||
to service_role;
|
||||
|
||||
-- ── Modul-Default: „job_descriptions" in enabled_modules aufnehmen ────
|
||||
-- Bestandsinstanzen, die das Modul (noch) nicht kennen, bleiben unberührt
|
||||
-- — der Operator schaltet es unter /admin/funktionen frei. Hier wird nur
|
||||
-- der Spalten-Default für ganz neue Instanzen erweitert.
|
||||
do $$
|
||||
begin
|
||||
if exists (
|
||||
select 1 from information_schema.columns
|
||||
where table_schema = 'public' and table_name = 'site_settings'
|
||||
and column_name = 'enabled_modules'
|
||||
) then
|
||||
alter table public.site_settings
|
||||
alter column enabled_modules
|
||||
set default array['business_cards']::text[];
|
||||
end if;
|
||||
end $$;
|
||||
|
||||
-- ── Seed: generische Standard-Textbausteine ──────────────────────────
|
||||
-- Out-of-the-box-Startset. Idempotent über den slug. Operatoren können
|
||||
-- sie unter /admin/stellenbeschreibungen/bausteine anpassen oder löschen.
|
||||
insert into public.job_description_blocks
|
||||
(slug, category, title, body, is_default, sort_order)
|
||||
values
|
||||
('allgemeine-pflichten', 'Allgemein', 'Allgemeine Pflichten',
|
||||
'Wahrnehmung der übertragenen Aufgaben mit der gebotenen Sorgfalt; Einhaltung der betrieblichen Anweisungen, Richtlinien und gesetzlichen Vorgaben; wirtschaftlicher und schonender Umgang mit Betriebsmitteln.',
|
||||
true, 0),
|
||||
('zusammenarbeit', 'Allgemein', 'Zusammenarbeit & Kommunikation',
|
||||
'Konstruktive Zusammenarbeit mit vor- und nachgelagerten Bereichen; rechtzeitige Information der Vorgesetzten über wesentliche Vorgänge; serviceorientiertes Auftreten gegenüber Kund:innen und Bürger:innen.',
|
||||
true, 1),
|
||||
('arbeitssicherheit', 'Arbeitsschutz', 'Arbeitssicherheit & Gesundheitsschutz',
|
||||
'Einhaltung der Arbeitsschutz- und Unfallverhütungsvorschriften (DGUV); Nutzung der vorgeschriebenen persönlichen Schutzausrüstung; unverzügliche Meldung von Gefährdungen, Beinaheunfällen und Mängeln.',
|
||||
true, 2),
|
||||
('datenschutz', 'Datenschutz', 'Datenschutz-Grundpflichten',
|
||||
'Vertraulicher Umgang mit personenbezogenen Daten gemäß DSGVO und BDSG; Datenverarbeitung ausschließlich im Rahmen der zugewiesenen Aufgaben; Wahrung des Datengeheimnisses auch über das Beschäftigungsverhältnis hinaus.',
|
||||
true, 3),
|
||||
('informationssicherheit', 'Informationssicherheit', 'Informationssicherheit',
|
||||
'Beachtung der Informationssicherheits-Richtlinien (ISMS nach ISO/IEC 27001); sorgsamer Umgang mit Zugangsdaten und Informationswerten; Meldung von Sicherheitsvorfällen an die zuständige Stelle.',
|
||||
false, 4),
|
||||
('vertretungsregelung', 'Allgemein', 'Vertretungsregelung',
|
||||
'Im Verhinderungsfall wird die Stelle durch die benannte Vertretung wahrgenommen. Eine geordnete Übergabe laufender Vorgänge ist sicherzustellen.',
|
||||
false, 5)
|
||||
on conflict (slug) do nothing;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- =====================================================================
|
||||
-- 0049 — Stellenbeschreibungen: Metadaten (Tarif/Entgelt, Gültigkeit)
|
||||
-- =====================================================================
|
||||
-- Erweitert job_descriptions um deutsche Stellenbeschreibungs-Standardfelder:
|
||||
-- Tarifvertrag + Entgeltgruppe (Default-Kontext Versorgungsbetriebe: TV-V),
|
||||
-- Stellenumfang (VZÄ/%), Befristung, sowie Gültigkeit + Review-Zyklus.
|
||||
-- Alle additiv + nullable → Bestandsdaten unberührt.
|
||||
|
||||
alter table public.job_descriptions
|
||||
add column if not exists tarifvertrag text, -- z.B. 'TV-V', 'TVöD', 'AT'
|
||||
add column if not exists entgeltgruppe text, -- z.B. 'EG 9'
|
||||
add column if not exists stellenumfang text, -- z.B. '100 % (Vollzeit)'
|
||||
add column if not exists befristung text, -- z.B. 'unbefristet'
|
||||
add column if not exists valid_from date, -- gültig ab
|
||||
add column if not exists review_due date; -- nächste Überprüfung
|
||||
@@ -0,0 +1,25 @@
|
||||
-- ====================================================================
|
||||
-- 0050_employee_extra_fields — Frei definierbare Zusatzangaben
|
||||
-- ====================================================================
|
||||
-- Mitarbeiter können beliebig viele weitere Telefonnummern und private
|
||||
-- Angaben hinterlegen, jeweils ein-/ausblendbar. Statt vieler fester
|
||||
-- Spalten eine JSONB-Liste:
|
||||
-- [{ "id": "f0", "kind": "phone|mobile|email|address|text",
|
||||
-- "label": "Festnetz privat", "value": "...", "visible": true }, …]
|
||||
--
|
||||
-- Sichtbarkeit wird wie bei show_mobile serverseitig im Render/vCard
|
||||
-- gefiltert (visibleExtraFields). Die öffentliche Karte liest seit 0045
|
||||
-- ohnehin per Service-Role — anon braucht die Spalte nicht und soll sie
|
||||
-- (ausgeblendete, ggf. private Werte) auch nicht lesen können.
|
||||
|
||||
alter table public.employees
|
||||
add column if not exists extra_fields jsonb not null default '[]'::jsonb;
|
||||
|
||||
comment on column public.employees.extra_fields is
|
||||
'Frei definierbare Zusatzangaben (weitere Telefonnummern, private Angaben). '
|
||||
'Liste von {id, kind, label, value, visible}. Sichtbarkeit pro Eintrag; '
|
||||
'serverseitige Filterung im Render/vCard. Siehe lib/extra-fields.ts.';
|
||||
|
||||
-- anon darf die Spalte nicht direkt lesen (könnte ausgeblendete/private
|
||||
-- Einträge enthalten). REVOKE auf nicht vorhandenen Grant ist ein No-op.
|
||||
revoke select (extra_fields) on public.employees from anon;
|
||||
@@ -0,0 +1,38 @@
|
||||
-- ====================================================================
|
||||
-- 0051_organigram_versions — Versionierung des Organigramms
|
||||
-- ====================================================================
|
||||
-- Audit-Anforderung: Prüfer wollen jederzeit die aktuelle, datierte
|
||||
-- Version sehen und ältere Stände nachvollziehen können.
|
||||
--
|
||||
-- Modell (Hybrid):
|
||||
-- 1) organigram_versions: nummerierte, UNVERÄNDERLICHE Snapshots des
|
||||
-- kompletten Organigramm-Stands (JSONB), erzeugt bei „Version
|
||||
-- veröffentlichen". Analog job_description_versions (0048).
|
||||
-- 2) change_log (0022): erfasst zusätzlich jede Einzeländerung an
|
||||
-- positions/org_units/assignments/deputies/external_parties/
|
||||
-- service_relations (Hash-gesichert) — für die lückenlose
|
||||
-- Nachvollziehbarkeit der Änderungen zwischen zwei Versionen.
|
||||
|
||||
create table if not exists public.organigram_versions (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
version_number integer not null,
|
||||
-- Vollständiger OrganigramSnapshot (lib/organigram-tree.ts) als JSON,
|
||||
-- inkl. zum Zeitpunkt der Freigabe aufgelöster Personennamen — so ist
|
||||
-- der Stand reproduzierbar, auch wenn Stammdaten sich später ändern.
|
||||
snapshot jsonb not null,
|
||||
published_at timestamptz not null default now(),
|
||||
published_by text not null,
|
||||
note text,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create unique index if not exists organigram_versions_number_idx
|
||||
on public.organigram_versions (version_number desc);
|
||||
|
||||
-- RLS: admin-only (Default deny, kein anon-Grant) — wie 0048.
|
||||
alter table public.organigram_versions enable row level security;
|
||||
|
||||
-- Service-Role umgeht RLS, braucht auf Self-Host aber explizite Grants.
|
||||
grant select, insert, update, delete on
|
||||
public.organigram_versions
|
||||
to service_role;
|
||||
@@ -0,0 +1,46 @@
|
||||
-- ====================================================================
|
||||
-- 0052_appointment_requests — Terminanfragen (Booking Phase 0)
|
||||
-- ====================================================================
|
||||
-- Native Terminanfrage über die Visitenkarte: Gast schlägt Wunschtermine
|
||||
-- vor → landet als Anfrage beim Mitarbeiter (E-Mail + Portal-Inbox).
|
||||
-- KEINE Kalenderanbindung (das ist Phase 1, Microsoft Graph). Siehe
|
||||
-- docs/booking-modul.md.
|
||||
|
||||
-- Opt-in pro Mitarbeiter: nur wer das aktiviert, zeigt „Termin anfragen".
|
||||
alter table public.employees
|
||||
add column if not exists accept_appointments boolean not null default false;
|
||||
|
||||
comment on column public.employees.accept_appointments is
|
||||
'Wenn true, zeigt die öffentliche Karte ein „Termin anfragen"-Formular.';
|
||||
|
||||
create table if not exists public.appointment_requests (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
employee_id uuid not null references public.employees(id) on delete cascade,
|
||||
guest_name text not null,
|
||||
guest_email text,
|
||||
guest_phone text,
|
||||
company text,
|
||||
subject text,
|
||||
message text,
|
||||
-- Wunschtermine: [{ "date": "2026-07-01", "period": "vormittags" }] (max 3)
|
||||
preferred_slots jsonb not null default '[]'::jsonb,
|
||||
meeting_type text, -- 'vor_ort' | 'telefon' | 'video' | null
|
||||
status text not null default 'new',
|
||||
-- 'new' | 'confirmed' | 'declined' | 'cancelled'
|
||||
consent_given boolean not null default false,
|
||||
source_url text,
|
||||
read_at timestamptz,
|
||||
notes text, -- interne Notiz des Mitarbeiters
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists appointment_requests_employee_idx
|
||||
on public.appointment_requests (employee_id, created_at desc);
|
||||
|
||||
-- RLS: admin-only (Default deny, kein anon-Grant). Öffentliche Inserts
|
||||
-- laufen ausschließlich über die Service-Role-Action (wie card_leads).
|
||||
alter table public.appointment_requests enable row level security;
|
||||
|
||||
grant select, insert, update, delete on
|
||||
public.appointment_requests
|
||||
to service_role;
|
||||
@@ -0,0 +1,83 @@
|
||||
-- ====================================================================
|
||||
-- 0053_booking_calendar — Kalenderbuchung (Booking Phase 1, Microsoft Graph)
|
||||
-- ====================================================================
|
||||
-- Baut auf Phase 0 (0052_appointment_requests) auf. Modell: zentrale
|
||||
-- Azure-AD-App mit *Application Permissions* (IT erteilt EINMAL Admin-
|
||||
-- Consent) + Opt-in pro Mitarbeiter. Kein Pro-MA-OAuth, keine Refresh-
|
||||
-- Token-Speicherung — das App-only-Token kommt per Client-Credentials,
|
||||
-- nur das Client-Secret liegt in der ENV. Siehe docs/booking-modul.md.
|
||||
--
|
||||
-- DSGVO: Der Opt-in-Schalter (calendar_connections.enabled) ist die
|
||||
-- aktive Entscheidung des MA. Zusätzlich beschränkt eine Exchange
|
||||
-- Application Access Policy die App technisch auf eine Sicherheitsgruppe.
|
||||
|
||||
-- ── Pro-MA-Verbindung + Verfügbarkeitsregeln ───────────────────────────
|
||||
create table if not exists public.calendar_connections (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
employee_id uuid not null unique
|
||||
references public.employees(id) on delete cascade,
|
||||
provider text not null default 'microsoft',
|
||||
-- M365-Postfach für die Graph-Calls (UPN/Mailbox, i. d. R. = Dienst-Mail).
|
||||
mailbox_upn text not null,
|
||||
-- Opt-in: nur wenn true, ruft TeamVis Graph für dieses Postfach auf.
|
||||
enabled boolean not null default true,
|
||||
-- Verfügbarkeitsregeln (Slots = Arbeitszeit − Belegt − vergebene Buchungen)
|
||||
timezone text not null default 'Europe/Berlin',
|
||||
slot_minutes integer not null default 30,
|
||||
buffer_minutes integer not null default 0,
|
||||
min_notice_hours integer not null default 24,
|
||||
max_advance_days integer not null default 30,
|
||||
workday_start time not null default '09:00',
|
||||
workday_end time not null default '17:00',
|
||||
-- ISO-Wochentage: 1=Montag … 7=Sonntag
|
||||
workdays integer[] not null default '{1,2,3,4,5}',
|
||||
-- Diagnostik: letzter erfolgreicher „Verbindung testen", letzter Fehler.
|
||||
last_verified_at timestamptz,
|
||||
last_error text,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
comment on table public.calendar_connections is
|
||||
'Opt-in pro Mitarbeiter für die Microsoft-Graph-Kalenderbuchung (Phase 1).';
|
||||
|
||||
-- ── Bestätigte Buchungen (echter Kalendereintrag via Graph) ────────────
|
||||
create table if not exists public.bookings (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
employee_id uuid not null
|
||||
references public.employees(id) on delete cascade,
|
||||
appointment_request_id uuid
|
||||
references public.appointment_requests(id) on delete set null,
|
||||
start_at timestamptz not null,
|
||||
end_at timestamptz not null,
|
||||
time_zone text not null default 'Europe/Berlin',
|
||||
guest_name text not null,
|
||||
guest_email text,
|
||||
guest_phone text,
|
||||
company text,
|
||||
subject text,
|
||||
message text,
|
||||
meeting_type text, -- 'vor_ort' | 'telefon' | 'video' | null
|
||||
status text not null default 'confirmed',
|
||||
-- 'confirmed' | 'cancelled'
|
||||
graph_event_id text, -- Outlook-Event-ID (für Storno/Update)
|
||||
ical_uid text,
|
||||
cancel_token text not null, -- Self-Service-Storno durch den Gast
|
||||
consent_given boolean not null default false,
|
||||
source_url text,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists bookings_employee_idx
|
||||
on public.bookings (employee_id, start_at);
|
||||
create index if not exists bookings_cancel_token_idx
|
||||
on public.bookings (cancel_token);
|
||||
|
||||
-- ── RLS: admin/service-role only, kein anon-Grant ──────────────────────
|
||||
-- Frei/Belegt-Lookup und Insert laufen ausschließlich über serverseitige
|
||||
-- (rate-limitierte) Service-Role-Actions, wie card_leads/appointment_requests.
|
||||
alter table public.calendar_connections enable row level security;
|
||||
alter table public.bookings enable row level security;
|
||||
|
||||
grant select, insert, update, delete on public.calendar_connections to service_role;
|
||||
grant select, insert, update, delete on public.bookings to service_role;
|
||||
@@ -0,0 +1,20 @@
|
||||
-- ====================================================================
|
||||
-- 0054_booking_ical — Buchung per iCal-Einladung (selbsthoster-tauglich)
|
||||
-- ====================================================================
|
||||
-- Macht die Kalenderbuchung zum Default OHNE externe Einrichtung: MA legt
|
||||
-- Verfügbarkeit fest, Gast bucht echte Slots, beide bekommen eine .ics-
|
||||
-- Termineinladung per SMTP. Frei/Belegt kommt aus der eigenen bookings-
|
||||
-- Tabelle, NICHT aus einem Postfach. Die Microsoft-Graph-Anbindung
|
||||
-- (0053) bleibt als optionaler Modus 'microsoft' liegen. Siehe
|
||||
-- docs/booking-modul.md.
|
||||
|
||||
-- Modus pro MA: 'ical' (Default, kein Setup) oder 'microsoft' (Graph).
|
||||
alter table public.calendar_connections
|
||||
add column if not exists mode text not null default 'ical';
|
||||
|
||||
-- mailbox_upn wird nur im 'microsoft'-Modus gebraucht → optional machen.
|
||||
alter table public.calendar_connections
|
||||
alter column mailbox_upn drop not null;
|
||||
|
||||
comment on column public.calendar_connections.mode is
|
||||
'''ical'' = Buchung per .ics-Einladung (kein externes Setup); ''microsoft'' = Microsoft-Graph (optional).';
|
||||
Reference in New Issue
Block a user