TeamVis Self-Host-Bundle v0.31.0

This commit is contained in:
TeamVis Release
2026-06-25 19:54:40 +02:00
commit 6335367369
68 changed files with 3765 additions and 0 deletions
+28
View File
@@ -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.
+37
View File
@@ -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=
+20
View File
@@ -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
}
+260
View File
@@ -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 | ~23 GB |
| Variante A schlank | 6 (db, rest, storage, kong, teamvis, caddy) | ~11.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 |
+65
View File
@@ -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:
+59
View File
@@ -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}`);
+482
View File
@@ -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."
+259
View File
@@ -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.
+141
View File
@@ -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 ≈ 11,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`.
+11
View File
@@ -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"
}
}
+32
View File
@@ -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);
+136
View File
@@ -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");
+71
View File
@@ -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
);
+18
View File
@@ -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');
+233
View File
@@ -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;
+61
View File
@@ -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.
+38
View File
@@ -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;
+56
View File
@@ -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.
+41
View File
@@ -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.
+47
View File
@@ -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.
+83
View File
@@ -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).
+14
View File
@@ -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;
+25
View File
@@ -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.
+28
View File
@@ -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');
+22
View File
@@ -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.';
+25
View File
@@ -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.';
+31
View File
@@ -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;
+20
View File
@@ -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).';