From 717325742df12e85f6c3c7c4a3bc8aaee3a8fd83 Mon Sep 17 00:00:00 2001 From: TeamVis Release Date: Thu, 25 Jun 2026 16:38:31 +0200 Subject: [PATCH] TeamVis Self-Host-Bundle v0.31.0 --- README.md | 28 + deploy/selfhost/.env.teamvis.example | 37 ++ deploy/selfhost/Caddyfile.example | 20 + deploy/selfhost/README.md | 260 ++++++++++ deploy/selfhost/bootstrap.sh | 65 +++ deploy/selfhost/docker-compose.override.yml | 77 +++ deploy/selfhost/gen-keys.mjs | 59 +++ deploy/selfhost/install.sh | 479 ++++++++++++++++++ docs/NEUKUNDE.md | 259 ++++++++++ docs/SELFHOST-QUICKSTART.md | 141 ++++++ package.json | 11 + scripts/bundle-migrations.mjs | 32 ++ scripts/create-admin.mjs | 136 +++++ supabase/migrations/0000_base_schema.sql | 71 +++ supabase/migrations/0001_rls.sql | 18 + .../migrations/0002_add_qualification.sql | 5 + supabase/migrations/0003_split_address.sql | 12 + supabase/migrations/0004_trusted_access.sql | 38 ++ .../migrations/0005_trusted_reveal_fields.sql | 14 + supabase/migrations/0006_site_settings.sql | 74 +++ supabase/migrations/0007_organigramm.sql | 233 +++++++++ supabase/migrations/0008_organigram_staff.sql | 14 + supabase/migrations/0009_compliance.sql | 61 +++ supabase/migrations/0010_deputy_employee.sql | 31 ++ .../0011_position_show_in_chart.sql | 16 + .../migrations/0012_employee_org_unit.sql | 23 + .../migrations/0013_position_is_executive.sql | 14 + .../migrations/0014_employees_public_view.sql | 31 ++ .../0015_service_relations_cleanup.sql | 47 ++ .../0016_assignment_unique_primary.sql | 30 ++ supabase/migrations/0017_card_view_log.sql | 26 + supabase/migrations/0018_card_leads.sql | 38 ++ supabase/migrations/0019_employee_i18n.sql | 11 + supabase/migrations/0020_calendar_embed.sql | 10 + .../migrations/0021_employee_successor.sql | 19 + supabase/migrations/0022_change_log.sql | 56 ++ supabase/migrations/0023_locations.sql | 41 ++ supabase/migrations/0024_webhooks.sql | 47 ++ supabase/migrations/0025_persons.sql | 83 +++ .../migrations/0026_magic_link_tokens.sql | 39 ++ supabase/migrations/0027_lead_checkout.sql | 18 + supabase/migrations/0028_reception_kiosks.sql | 39 ++ supabase/migrations/0029_reception_audit.sql | 70 +++ supabase/migrations/0030_dsgvo_anonymize.sql | 59 +++ supabase/migrations/0031_admin_invites.sql | 47 ++ supabase/migrations/0032_admin_2fa.sql | 14 + .../migrations/0033_visitor_preregister.sql | 45 ++ .../migrations/0034_legal_info_and_mark.sql | 23 + supabase/migrations/0035_apple_wallet.sql | 25 + supabase/migrations/0036_google_wallet.sql | 25 + supabase/migrations/0037_lead_inbox.sql | 28 + supabase/migrations/0038_ai_providers.sql | 22 + .../0039_compliance_employee_bindings.sql | 54 ++ supabase/migrations/0040_watermark.sql | 25 + .../migrations/0041_phone_integration.sql | 40 ++ supabase/migrations/0042_license.sql | 31 ++ .../0043_site_settings_anon_columns.sql | 44 ++ .../0044_employee_photos_bucket.sql | 21 + .../0045_employee_privacy_columns.sql | 25 + .../migrations/0046_portal_login_code.sql | 16 + .../0047_admin_must_change_password.sql | 13 + supabase/migrations/0048_job_descriptions.sql | 145 ++++++ .../0049_job_descriptions_extras.sql | 15 + .../migrations/0050_employee_extra_fields.sql | 25 + .../migrations/0051_organigram_versions.sql | 38 ++ .../migrations/0052_appointment_requests.sql | 46 ++ supabase/migrations/0053_booking_calendar.sql | 83 +++ supabase/migrations/0054_booking_ical.sql | 20 + 68 files changed, 3762 insertions(+) create mode 100644 README.md create mode 100644 deploy/selfhost/.env.teamvis.example create mode 100644 deploy/selfhost/Caddyfile.example create mode 100644 deploy/selfhost/README.md create mode 100755 deploy/selfhost/bootstrap.sh create mode 100644 deploy/selfhost/docker-compose.override.yml create mode 100755 deploy/selfhost/gen-keys.mjs create mode 100755 deploy/selfhost/install.sh create mode 100644 docs/NEUKUNDE.md create mode 100644 docs/SELFHOST-QUICKSTART.md create mode 100644 package.json create mode 100644 scripts/bundle-migrations.mjs create mode 100644 scripts/create-admin.mjs create mode 100644 supabase/migrations/0000_base_schema.sql create mode 100644 supabase/migrations/0001_rls.sql create mode 100644 supabase/migrations/0002_add_qualification.sql create mode 100644 supabase/migrations/0003_split_address.sql create mode 100644 supabase/migrations/0004_trusted_access.sql create mode 100644 supabase/migrations/0005_trusted_reveal_fields.sql create mode 100644 supabase/migrations/0006_site_settings.sql create mode 100644 supabase/migrations/0007_organigramm.sql create mode 100644 supabase/migrations/0008_organigram_staff.sql create mode 100644 supabase/migrations/0009_compliance.sql create mode 100644 supabase/migrations/0010_deputy_employee.sql create mode 100644 supabase/migrations/0011_position_show_in_chart.sql create mode 100644 supabase/migrations/0012_employee_org_unit.sql create mode 100644 supabase/migrations/0013_position_is_executive.sql create mode 100644 supabase/migrations/0014_employees_public_view.sql create mode 100644 supabase/migrations/0015_service_relations_cleanup.sql create mode 100644 supabase/migrations/0016_assignment_unique_primary.sql create mode 100644 supabase/migrations/0017_card_view_log.sql create mode 100644 supabase/migrations/0018_card_leads.sql create mode 100644 supabase/migrations/0019_employee_i18n.sql create mode 100644 supabase/migrations/0020_calendar_embed.sql create mode 100644 supabase/migrations/0021_employee_successor.sql create mode 100644 supabase/migrations/0022_change_log.sql create mode 100644 supabase/migrations/0023_locations.sql create mode 100644 supabase/migrations/0024_webhooks.sql create mode 100644 supabase/migrations/0025_persons.sql create mode 100644 supabase/migrations/0026_magic_link_tokens.sql create mode 100644 supabase/migrations/0027_lead_checkout.sql create mode 100644 supabase/migrations/0028_reception_kiosks.sql create mode 100644 supabase/migrations/0029_reception_audit.sql create mode 100644 supabase/migrations/0030_dsgvo_anonymize.sql create mode 100644 supabase/migrations/0031_admin_invites.sql create mode 100644 supabase/migrations/0032_admin_2fa.sql create mode 100644 supabase/migrations/0033_visitor_preregister.sql create mode 100644 supabase/migrations/0034_legal_info_and_mark.sql create mode 100644 supabase/migrations/0035_apple_wallet.sql create mode 100644 supabase/migrations/0036_google_wallet.sql create mode 100644 supabase/migrations/0037_lead_inbox.sql create mode 100644 supabase/migrations/0038_ai_providers.sql create mode 100644 supabase/migrations/0039_compliance_employee_bindings.sql create mode 100644 supabase/migrations/0040_watermark.sql create mode 100644 supabase/migrations/0041_phone_integration.sql create mode 100644 supabase/migrations/0042_license.sql create mode 100644 supabase/migrations/0043_site_settings_anon_columns.sql create mode 100644 supabase/migrations/0044_employee_photos_bucket.sql create mode 100644 supabase/migrations/0045_employee_privacy_columns.sql create mode 100644 supabase/migrations/0046_portal_login_code.sql create mode 100644 supabase/migrations/0047_admin_must_change_password.sql create mode 100644 supabase/migrations/0048_job_descriptions.sql create mode 100644 supabase/migrations/0049_job_descriptions_extras.sql create mode 100644 supabase/migrations/0050_employee_extra_fields.sql create mode 100644 supabase/migrations/0051_organigram_versions.sql create mode 100644 supabase/migrations/0052_appointment_requests.sql create mode 100644 supabase/migrations/0053_booking_calendar.sql create mode 100644 supabase/migrations/0054_booking_ical.sql diff --git a/README.md b/README.md new file mode 100644 index 0000000..b79fad6 --- /dev/null +++ b/README.md @@ -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.zoesch.de` (Zugangs-Token nötig, Benutzer +`teamvis-pull` — derselbe Token, mit dem dieses Bundle geklont wurde). + +## Loslegen + +```bash +docker login git.zoesch.de # Benutzer: teamvis-pull · Passwort: +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. diff --git a/deploy/selfhost/.env.teamvis.example b/deploy/selfhost/.env.teamvis.example new file mode 100644 index 0000000..641f845 --- /dev/null +++ b/deploy/selfhost/.env.teamvis.example @@ -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= diff --git a/deploy/selfhost/Caddyfile.example b/deploy/selfhost/Caddyfile.example new file mode 100644 index 0000000..9053b57 --- /dev/null +++ b/deploy/selfhost/Caddyfile.example @@ -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 +} diff --git a/deploy/selfhost/README.md b/deploy/selfhost/README.md new file mode 100644 index 0000000..000c38d --- /dev/null +++ b/deploy/selfhost/README.md @@ -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 ''; +alter role supabase_storage_admin with login password ''; +alter role supabase_auth_admin with login password ''; + +-- 2) service_role-Rechte (auf echtem Supabase automatisch vorhanden) +alter role service_role bypassrls; +grant all on all tables in schema public to service_role; +grant all on all sequences in schema public to service_role; +grant all on all routines in schema public to service_role; +alter default privileges in schema public grant all on tables to service_role; +alter default privileges in schema public grant all on sequences to service_role; +``` + +Reihenfolge: Passwörter (1) **vor** dem ersten Start von `rest`/`storage`, +die Grants (2) **nach** dem Schema-Load. Wegen dieses Mehraufwands ist für +die *erste* Kundeninstallation **Variante A empfohlen** (dort entfällt der +ganze Block). + +--- + +## Stolpersteine (wichtig) + +1. **`SUPABASE_PUBLIC_URL` muss öffentlich + HTTPS sein** — sie steckt in den + Foto-URLs der Karten. Interner Hostname (`http://kong:8000`) funktioniert + **nicht** für die Bildanzeige. +2. **SMTP-Kollision:** der Supabase-Stack belegt `SMTP_*` selbst (GoTrue). + Die App liest deshalb `TEAMVIS_SMTP_*` (siehe Override) — nicht + vermischen. +3. **Reihenfolge:** Schema erst einspielen, wenn Storage einmal gestartet ist + (Migration `0044` legt den Bucket in `storage.buckets` an). `bootstrap.sh` + wartet darauf. +4. **Keys sind JWTs:** `ANON_KEY`/`SERVICE_ROLE_KEY` sind mit `JWT_SECRET` + signierte Tokens (`gen-keys.mjs`). Wird `JWT_SECRET` geändert, müssen beide + Keys neu erzeugt werden. +5. **Backups:** jetzt liegt die DB beim Kunden. Postgres-Volume + Storage- + Volume sichern (`pg_dump` + `volumes/storage`). Beim externen Supabase war + das deren Sache. +6. **Modul-Rollout:** frische Instanz startet minimal (nur Visitenkarten). + Danach unter **Admin → Funktionen** gestaffelt freischalten + (siehe `docs/NEUKUNDE.md` §5a). + +--- + +## Footprint (grob) + +| | Container | RAM (Daumenregel) | +|---|---|---| +| Variante A voll | ~12 | ~2–3 GB | +| Variante A schlank | 6 (db, rest, storage, kong, teamvis, caddy) | ~1–1.5 GB | +| Externe Supabase (NEUKUNDE.md) | 2 (teamvis, caddy) | ~0.4 GB | + +--- + +## Befunde aus dem Testlauf (2026-06-07) + +Lean-Stack (db + rest + storage + Caddy-Router + app) auf zfx-vps hochgezogen, +nur an localhost gebunden. Drei Bootstrap-Lücken gefunden: + +1. **Basis-Schema fehlte** — die Migrationen legen `employees`/`admin_users` + nicht an (nur additive `add column if not exists`). `bundle-migrations` + bootstrappt eine leere DB also nicht. **Gelöst:** neue Migration + `supabase/migrations/0000_base_schema.sql` (idempotent, auf + Bestandsinstanzen ein No-Op). Betrifft auch den `NEUKUNDE.md`-Weg. +2. **Rollen-Passwörter** — `supabase/postgres` setzt nur `supabase_admin` auf + `POSTGRES_PASSWORD`; `authenticator`/`supabase_storage_admin` brauchen es + manuell (Variante B). Variante A erledigt das. +3. **service_role-Grants** — frisch erzeugte Tabellen geben `service_role` + keine Rechte; auf echtem Supabase passiert das automatisch. Grants + + `bypassrls` nötig (Variante B). Variante A erledigt das. + +**Nach den Fixes verifiziert (alles grün):** + +| Test | Ergebnis | +|---|---| +| Schema-Bündel (mit 0000) auf leerer DB | 0 Fehler, 23 Tabellen | +| `/api/health` | `ok:true`, `db.ok:true` | +| Öffentliche Karte `/` | HTTP 200 | +| vCard `/api/vcard/` | 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 | diff --git a/deploy/selfhost/bootstrap.sh b/deploy/selfhost/bootstrap.sh new file mode 100755 index 0000000..ba4d551 --- /dev/null +++ b/deploy/selfhost/bootstrap.sh @@ -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." diff --git a/deploy/selfhost/docker-compose.override.yml b/deploy/selfhost/docker-compose.override.yml new file mode 100644 index 0000000..b914377 --- /dev/null +++ b/deploy/selfhost/docker-compose.override.yml @@ -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: diff --git a/deploy/selfhost/gen-keys.mjs b/deploy/selfhost/gen-keys.mjs new file mode 100755 index 0000000..907f728 --- /dev/null +++ b/deploy/selfhost/gen-keys.mjs @@ -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 # 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}`); diff --git a/deploy/selfhost/install.sh b/deploy/selfhost/install.sh new file mode 100755 index 0000000..8ecf517 --- /dev/null +++ b/deploy/selfhost/install.sh @@ -0,0 +1,479 @@ +#!/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.zoesch.de (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)" + +# ── 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 < "$TARGET/Caddyfile" < "$TARGET/docker-compose.yml" <<'YAML' +name: teamvis-__NAME__ +services: + app: + image: git.zoesch.de/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" <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 "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 < "$TARGET/Caddyfile" < "$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: git.zoesch.de/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/__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 </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 < 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 +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//app/.env` an: + +```env +# ── Supabase ─────────────────────────────────────────── +NEXT_PUBLIC_SUPABASE_URL=https://xxxx.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY= +SUPABASE_SERVICE_ROLE_KEY= + +# ── Session (Pflicht, >= 32 Zeichen, pro Instanz EINMALIG erzeugen) ── +SESSION_SECRET= + +# ── Ö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= +SMTP_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//app/docker-compose.yml`: + +```yaml +services: + app: + image: git.zoesch.de/zfx-services/teamvis:0.11.5 # aktuelle Version pinnen + container_name: -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/` zeigt eine Karte (öffentlich, ohne Login) +- [ ] vCard-Download `…/api/vcard/` 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. diff --git a/docs/SELFHOST-QUICKSTART.md b/docs/SELFHOST-QUICKSTART.md new file mode 100644 index 0000000..916251d --- /dev/null +++ b/docs/SELFHOST-QUICKSTART.md @@ -0,0 +1,141 @@ +# TeamVis Self-Hosting — Quickstart (von der nackten VM bis live) + +Diese Anleitung führt von einer **komplett frischen Linux-VM** bis zur +laufenden TeamVis-Instanz. Sie nutzt den Ein-Schritt-Installer +`deploy/selfhost/install.sh` (Hintergrund & manuelle Varianten: +`deploy/selfhost/README.md`, externe Supabase: `docs/NEUKUNDE.md`). + +Zwei Betriebsarten zur Auswahl (fragt der Installer ab): + +- **Modus 2 — alles mitinstallieren** (empfohlen für „nur eine VM"): TeamVis + **und** eine schlanke Supabase laufen als Container auf derselben VM, unter + **einer Domain**. Kein externes Supabase nötig. +- **Modus 1 — eigene/bestehende Supabase**: du hast schon ein Supabase-Projekt + (Cloud oder self-hosted); es läuft nur die App. + +Diese Anleitung beschreibt **Modus 2** (der Komplettfall). + +--- + +## 0. Voraussetzungen + +**Du brauchst vorab:** + +| Was | Details | +|---|---| +| **VM** | Ubuntu 22.04+/Debian 12+, **2 GB RAM**+ (Modus 2 ≈ 1–1,5 GB), ~10 GB Disk. Root- bzw. sudo-Zugang. | +| **Domain** | z. B. `team.kunde.de`, mit **DNS-A-Record auf die öffentliche IP der VM**. Caddy holt das TLS-Zertifikat dann automatisch. | +| **Offene Ports** | eingehend **80 und 443** (Let's Encrypt + Web). | +| **Zugangs-Token** | ein **Token** für `git.zoesch.de` — **vom TeamVis-Anbieter**. Damit ziehst du das Container-Image **und** das Installer-Bundle. **Kein** Quellcode-Zugang. | + +> So ist die Auslieferung getrennt: Mit dem Token bekommst du nur das **Bundle** +> (Installer + Migrationen, kein Quellcode) und das **Container-Image** — der +> eigentliche App-Quellcode bleibt unzugänglich. Ein und derselbe Token (Benutzer +> `teamvis-pull`) deckt beides ab. (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.zoesch.de +# Benutzer: teamvis-pull · Passwort: +``` + +--- + +## 3. Installer-Bundle holen + +Mit demselben Token (im Klon-Link): + +```bash +git clone https://teamvis-pull:@git.zoesch.de/zfx-services/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/` zeigt eine Karte (ohne Login) +- [ ] vCard-Download `…/api/vcard/` 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.zoesch.de` 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`. diff --git a/package.json b/package.json new file mode 100644 index 0000000..9ad38ac --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/scripts/bundle-migrations.mjs b/scripts/bundle-migrations.mjs new file mode 100644 index 0000000..0899468 --- /dev/null +++ b/scripts/bundle-migrations.mjs @@ -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); diff --git a/scripts/create-admin.mjs b/scripts/create-admin.mjs new file mode 100644 index 0000000..22721eb --- /dev/null +++ b/scripts/create-admin.mjs @@ -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 [--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 [--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"); diff --git a/supabase/migrations/0000_base_schema.sql b/supabase/migrations/0000_base_schema.sql new file mode 100644 index 0000000..37e26ec --- /dev/null +++ b/supabase/migrations/0000_base_schema.sql @@ -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 +); diff --git a/supabase/migrations/0001_rls.sql b/supabase/migrations/0001_rls.sql new file mode 100644 index 0000000..3df9534 --- /dev/null +++ b/supabase/migrations/0001_rls.sql @@ -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. diff --git a/supabase/migrations/0002_add_qualification.sql b/supabase/migrations/0002_add_qualification.sql new file mode 100644 index 0000000..b7f37e7 --- /dev/null +++ b/supabase/migrations/0002_add_qualification.sql @@ -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; diff --git a/supabase/migrations/0003_split_address.sql b/supabase/migrations/0003_split_address.sql new file mode 100644 index 0000000..87f0d63 --- /dev/null +++ b/supabase/migrations/0003_split_address.sql @@ -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; diff --git a/supabase/migrations/0004_trusted_access.sql b/supabase/migrations/0004_trusted_access.sql new file mode 100644 index 0000000..1a2b20b --- /dev/null +++ b/supabase/migrations/0004_trusted_access.sql @@ -0,0 +1,38 @@ +-- Vertraulicher Zusatz-Link ("Trusted-Link") pro Mitarbeiter. +-- +-- Idee: Die öffentliche Karte (/) 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. diff --git a/supabase/migrations/0005_trusted_reveal_fields.sql b/supabase/migrations/0005_trusted_reveal_fields.sql new file mode 100644 index 0000000..199beb5 --- /dev/null +++ b/supabase/migrations/0005_trusted_reveal_fields.sql @@ -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; diff --git a/supabase/migrations/0006_site_settings.sql b/supabase/migrations/0006_site_settings.sql new file mode 100644 index 0000000..272f8b5 --- /dev/null +++ b/supabase/migrations/0006_site_settings.sql @@ -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 und +-- 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'); diff --git a/supabase/migrations/0007_organigramm.sql b/supabase/migrations/0007_organigramm.sql new file mode 100644 index 0000000..47674fd --- /dev/null +++ b/supabase/migrations/0007_organigramm.sql @@ -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; diff --git a/supabase/migrations/0008_organigram_staff.sql b/supabase/migrations/0008_organigram_staff.sql new file mode 100644 index 0000000..45781e3 --- /dev/null +++ b/supabase/migrations/0008_organigram_staff.sql @@ -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; diff --git a/supabase/migrations/0009_compliance.sql b/supabase/migrations/0009_compliance.sql new file mode 100644 index 0000000..5813925 --- /dev/null +++ b/supabase/migrations/0009_compliance.sql @@ -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; diff --git a/supabase/migrations/0010_deputy_employee.sql b/supabase/migrations/0010_deputy_employee.sql new file mode 100644 index 0000000..b642820 --- /dev/null +++ b/supabase/migrations/0010_deputy_employee.sql @@ -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); diff --git a/supabase/migrations/0011_position_show_in_chart.sql b/supabase/migrations/0011_position_show_in_chart.sql new file mode 100644 index 0000000..c0fc1f4 --- /dev/null +++ b/supabase/migrations/0011_position_show_in_chart.sql @@ -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; diff --git a/supabase/migrations/0012_employee_org_unit.sql b/supabase/migrations/0012_employee_org_unit.sql new file mode 100644 index 0000000..b576fa4 --- /dev/null +++ b/supabase/migrations/0012_employee_org_unit.sql @@ -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); diff --git a/supabase/migrations/0013_position_is_executive.sql b/supabase/migrations/0013_position_is_executive.sql new file mode 100644 index 0000000..10de9cb --- /dev/null +++ b/supabase/migrations/0013_position_is_executive.sql @@ -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; diff --git a/supabase/migrations/0014_employees_public_view.sql b/supabase/migrations/0014_employees_public_view.sql new file mode 100644 index 0000000..a45e240 --- /dev/null +++ b/supabase/migrations/0014_employees_public_view.sql @@ -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; diff --git a/supabase/migrations/0015_service_relations_cleanup.sql b/supabase/migrations/0015_service_relations_cleanup.sql new file mode 100644 index 0000000..c6eb828 --- /dev/null +++ b/supabase/migrations/0015_service_relations_cleanup.sql @@ -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(); diff --git a/supabase/migrations/0016_assignment_unique_primary.sql b/supabase/migrations/0016_assignment_unique_primary.sql new file mode 100644 index 0000000..230fdbf --- /dev/null +++ b/supabase/migrations/0016_assignment_unique_primary.sql @@ -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; diff --git a/supabase/migrations/0017_card_view_log.sql b/supabase/migrations/0017_card_view_log.sql new file mode 100644 index 0000000..2b69f4e --- /dev/null +++ b/supabase/migrations/0017_card_view_log.sql @@ -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. diff --git a/supabase/migrations/0018_card_leads.sql b/supabase/migrations/0018_card_leads.sql new file mode 100644 index 0000000..f6a7bf7 --- /dev/null +++ b/supabase/migrations/0018_card_leads.sql @@ -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. diff --git a/supabase/migrations/0019_employee_i18n.sql b/supabase/migrations/0019_employee_i18n.sql new file mode 100644 index 0000000..e1ae748 --- /dev/null +++ b/supabase/migrations/0019_employee_i18n.sql @@ -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; diff --git a/supabase/migrations/0020_calendar_embed.sql b/supabase/migrations/0020_calendar_embed.sql new file mode 100644 index 0000000..e7c2931 --- /dev/null +++ b/supabase/migrations/0020_calendar_embed.sql @@ -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; diff --git a/supabase/migrations/0021_employee_successor.sql b/supabase/migrations/0021_employee_successor.sql new file mode 100644 index 0000000..761dc09 --- /dev/null +++ b/supabase/migrations/0021_employee_successor.sql @@ -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; diff --git a/supabase/migrations/0022_change_log.sql b/supabase/migrations/0022_change_log.sql new file mode 100644 index 0000000..a630ad8 --- /dev/null +++ b/supabase/migrations/0022_change_log.sql @@ -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. diff --git a/supabase/migrations/0023_locations.sql b/supabase/migrations/0023_locations.sql new file mode 100644 index 0000000..51c5549 --- /dev/null +++ b/supabase/migrations/0023_locations.sql @@ -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. diff --git a/supabase/migrations/0024_webhooks.sql b/supabase/migrations/0024_webhooks.sql new file mode 100644 index 0000000..0c5bd71 --- /dev/null +++ b/supabase/migrations/0024_webhooks.sql @@ -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. diff --git a/supabase/migrations/0025_persons.sql b/supabase/migrations/0025_persons.sql new file mode 100644 index 0000000..598006a --- /dev/null +++ b/supabase/migrations/0025_persons.sql @@ -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; diff --git a/supabase/migrations/0026_magic_link_tokens.sql b/supabase/migrations/0026_magic_link_tokens.sql new file mode 100644 index 0000000..cd1180d --- /dev/null +++ b/supabase/migrations/0026_magic_link_tokens.sql @@ -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/. +-- 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. diff --git a/supabase/migrations/0027_lead_checkout.sql b/supabase/migrations/0027_lead_checkout.sql new file mode 100644 index 0000000..0d3f9ee --- /dev/null +++ b/supabase/migrations/0027_lead_checkout.sql @@ -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'); diff --git a/supabase/migrations/0028_reception_kiosks.sql b/supabase/migrations/0028_reception_kiosks.sql new file mode 100644 index 0000000..84b6453 --- /dev/null +++ b/supabase/migrations/0028_reception_kiosks.sql @@ -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/`. +-- * 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. diff --git a/supabase/migrations/0029_reception_audit.sql b/supabase/migrations/0029_reception_audit.sql new file mode 100644 index 0000000..4c39bc5 --- /dev/null +++ b/supabase/migrations/0029_reception_audit.sql @@ -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). diff --git a/supabase/migrations/0030_dsgvo_anonymize.sql b/supabase/migrations/0030_dsgvo_anonymize.sql new file mode 100644 index 0000000..0c363df --- /dev/null +++ b/supabase/migrations/0030_dsgvo_anonymize.sql @@ -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; diff --git a/supabase/migrations/0031_admin_invites.sql b/supabase/migrations/0031_admin_invites.sql new file mode 100644 index 0000000..c2cb731 --- /dev/null +++ b/supabase/migrations/0031_admin_invites.sql @@ -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/`. +-- 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). diff --git a/supabase/migrations/0032_admin_2fa.sql b/supabase/migrations/0032_admin_2fa.sql new file mode 100644 index 0000000..322e800 --- /dev/null +++ b/supabase/migrations/0032_admin_2fa.sql @@ -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; diff --git a/supabase/migrations/0033_visitor_preregister.sql b/supabase/migrations/0033_visitor_preregister.sql new file mode 100644 index 0000000..b1079dc --- /dev/null +++ b/supabase/migrations/0033_visitor_preregister.sql @@ -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). diff --git a/supabase/migrations/0034_legal_info_and_mark.sql b/supabase/migrations/0034_legal_info_and_mark.sql new file mode 100644 index 0000000..dff1635 --- /dev/null +++ b/supabase/migrations/0034_legal_info_and_mark.sql @@ -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; diff --git a/supabase/migrations/0035_apple_wallet.sql b/supabase/migrations/0035_apple_wallet.sql new file mode 100644 index 0000000..3d12bf1 --- /dev/null +++ b/supabase/migrations/0035_apple_wallet.sql @@ -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). diff --git a/supabase/migrations/0036_google_wallet.sql b/supabase/migrations/0036_google_wallet.sql new file mode 100644 index 0000000..1cf36b4 --- /dev/null +++ b/supabase/migrations/0036_google_wallet.sql @@ -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: +-- "." +-- → 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. diff --git a/supabase/migrations/0037_lead_inbox.sql b/supabase/migrations/0037_lead_inbox.sql new file mode 100644 index 0000000..8e833b6 --- /dev/null +++ b/supabase/migrations/0037_lead_inbox.sql @@ -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'); diff --git a/supabase/migrations/0038_ai_providers.sql b/supabase/migrations/0038_ai_providers.sql new file mode 100644 index 0000000..be6fc8e --- /dev/null +++ b/supabase/migrations/0038_ai_providers.sql @@ -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'; diff --git a/supabase/migrations/0039_compliance_employee_bindings.sql b/supabase/migrations/0039_compliance_employee_bindings.sql new file mode 100644 index 0000000..1465167 --- /dev/null +++ b/supabase/migrations/0039_compliance_employee_bindings.sql @@ -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.'; diff --git a/supabase/migrations/0040_watermark.sql b/supabase/migrations/0040_watermark.sql new file mode 100644 index 0000000..c1843c3 --- /dev/null +++ b/supabase/migrations/0040_watermark.sql @@ -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.'; diff --git a/supabase/migrations/0041_phone_integration.sql b/supabase/migrations/0041_phone_integration.sql new file mode 100644 index 0000000..3930d62 --- /dev/null +++ b/supabase/migrations/0041_phone_integration.sql @@ -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.'; diff --git a/supabase/migrations/0042_license.sql b/supabase/migrations/0042_license.sql new file mode 100644 index 0000000..71af98b --- /dev/null +++ b/supabase/migrations/0042_license.sql @@ -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.'; diff --git a/supabase/migrations/0043_site_settings_anon_columns.sql b/supabase/migrations/0043_site_settings_anon_columns.sql new file mode 100644 index 0000000..5bc16f4 --- /dev/null +++ b/supabase/migrations/0043_site_settings_anon_columns.sql @@ -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 $$; diff --git a/supabase/migrations/0044_employee_photos_bucket.sql b/supabase/migrations/0044_employee_photos_bucket.sql new file mode 100644 index 0000000..97d5ad0 --- /dev/null +++ b/supabase/migrations/0044_employee_photos_bucket.sql @@ -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'); diff --git a/supabase/migrations/0045_employee_privacy_columns.sql b/supabase/migrations/0045_employee_privacy_columns.sql new file mode 100644 index 0000000..a201c5c --- /dev/null +++ b/supabase/migrations/0045_employee_privacy_columns.sql @@ -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; diff --git a/supabase/migrations/0046_portal_login_code.sql b/supabase/migrations/0046_portal_login_code.sql new file mode 100644 index 0000000..8accca0 --- /dev/null +++ b/supabase/migrations/0046_portal_login_code.sql @@ -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; diff --git a/supabase/migrations/0047_admin_must_change_password.sql b/supabase/migrations/0047_admin_must_change_password.sql new file mode 100644 index 0000000..9bbbe0e --- /dev/null +++ b/supabase/migrations/0047_admin_must_change_password.sql @@ -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; diff --git a/supabase/migrations/0048_job_descriptions.sql b/supabase/migrations/0048_job_descriptions.sql new file mode 100644 index 0000000..560239a --- /dev/null +++ b/supabase/migrations/0048_job_descriptions.sql @@ -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; diff --git a/supabase/migrations/0049_job_descriptions_extras.sql b/supabase/migrations/0049_job_descriptions_extras.sql new file mode 100644 index 0000000..a796969 --- /dev/null +++ b/supabase/migrations/0049_job_descriptions_extras.sql @@ -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 diff --git a/supabase/migrations/0050_employee_extra_fields.sql b/supabase/migrations/0050_employee_extra_fields.sql new file mode 100644 index 0000000..ae424c1 --- /dev/null +++ b/supabase/migrations/0050_employee_extra_fields.sql @@ -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; diff --git a/supabase/migrations/0051_organigram_versions.sql b/supabase/migrations/0051_organigram_versions.sql new file mode 100644 index 0000000..0a5ab72 --- /dev/null +++ b/supabase/migrations/0051_organigram_versions.sql @@ -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; diff --git a/supabase/migrations/0052_appointment_requests.sql b/supabase/migrations/0052_appointment_requests.sql new file mode 100644 index 0000000..f22f22c --- /dev/null +++ b/supabase/migrations/0052_appointment_requests.sql @@ -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; diff --git a/supabase/migrations/0053_booking_calendar.sql b/supabase/migrations/0053_booking_calendar.sql new file mode 100644 index 0000000..3a17470 --- /dev/null +++ b/supabase/migrations/0053_booking_calendar.sql @@ -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; diff --git a/supabase/migrations/0054_booking_ical.sql b/supabase/migrations/0054_booking_ical.sql new file mode 100644 index 0000000..3bc81b5 --- /dev/null +++ b/supabase/migrations/0054_booking_ical.sql @@ -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).';