2 Commits
Author SHA1 Message Date
TeamVis Release 6da3cc2283 TeamVis Self-Host-Bundle v0.56.2 2026-10-10 23:39:23 +02:00
TeamVis Release f53579d3a4 TeamVis Self-Host-Bundle v0.46.1 2026-08-05 08:44:10 +02:00
21 changed files with 721 additions and 27 deletions

No files matched your search

+2 -2
View File
@@ -1,4 +1,4 @@
# TeamVis — Self-Hosting-Bundle (v0.33.0) # TeamVis — Self-Hosting-Bundle (v0.56.2)
Dieses Bundle enthält alles zum **Betreiben** von TeamVis auf eigener Dieses Bundle enthält alles zum **Betreiben** von TeamVis auf eigener
Infrastruktur — **keinen** App-Quellcode. Die App selbst kommt als fertiges Infrastruktur — **keinen** App-Quellcode. Die App selbst kommt als fertiges
@@ -36,4 +36,4 @@ Vollständige Schritt-für-Schritt-Anleitung (von der nackten VM bis live):
| `supabase/migrations/` | Datenbank-Schema (DDL) | | `supabase/migrations/` | Datenbank-Schema (DDL) |
| `docs/` | Anleitungen | | `docs/` | Anleitungen |
Stand: TeamVis 0.33.0. Stand: TeamVis 0.56.2.
+53 -3
View File
@@ -195,15 +195,63 @@ ganze Block).
4. **Keys sind JWTs:** `ANON_KEY`/`SERVICE_ROLE_KEY` sind mit `JWT_SECRET` 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 signierte Tokens (`gen-keys.mjs`). Wird `JWT_SECRET` geändert, müssen beide
Keys neu erzeugt werden. Keys neu erzeugt werden.
5. **Backups:** jetzt liegt die DB beim Kunden. Postgres-Volume + Storage- 5. **Backups:** jetzt liegt die DB beim Kunden — siehe „Sicherung und
Volume sichern (`pg_dump` + `volumes/storage`). Beim externen Supabase war Wiederherstellung“ unten. Beim externen Supabase war das deren Sache.
das deren Sache.
6. **Modul-Rollout:** frische Instanz startet minimal (nur Visitenkarten). 6. **Modul-Rollout:** frische Instanz startet minimal (nur Visitenkarten).
Danach unter **Admin → Funktionen** gestaffelt freischalten Danach unter **Admin → Funktionen** gestaffelt freischalten
(siehe `docs/NEUKUNDE.md` §5a). (siehe `docs/NEUKUNDE.md` §5a).
--- ---
## Sicherung und Wiederherstellung
Gilt für die Variante mit eigener Datenbank (Variante B, `install.sh`).
Beide Skripte liegen im Instanz-Verzeichnis neben `docker-compose.yml`.
**Sichern — `backup.sh`**, täglich per Cron des Betriebsnutzers:
```bash
15 3 * * * /bin/bash /pfad/instanz/backup.sh >> $HOME/backups/<instanz>/backup.log 2>&1
```
- Datenbank: Schemas `public` (TeamVis) und `storage` (Buckets, Objekt-
Metadaten, Migrationsstand der Storage-API) als `db-JJJJ-MM-TT-HHMM.dump`
(Custom-Format). Das Skript prüft das Inhaltsverzeichnis — ein
abgeschnittener Dump bricht mit Fehler ab.
- Dateien: `volumes/storage` als `storage-….tar.gz` **mit erweiterten
Attributen** (`tar --xattrs`). Die Storage-API speichert Content-Type und
Cache-Control jeder Datei als `user.supabase.*`-Attribut; ohne sie liefert
eine wiederhergestellte Instanz jede Datei mit HTTP 500 aus.
- Ablage `$HOME/backups/<instanz>` (Verzeichnis `700`, Dateien `600`),
30 Tage Aufbewahrung (`KEEP_DAYS`). Optional meldet jede erfolgreiche
Sicherung an einen Push-Monitor (Uptime Kuma): Zeile `PUSH_URL=…` in
`$HOME/backups/<instanz>/push.env`.
- **Nicht** gesichert: die `.env`. Sie gehört in einen Passwort-Safe — ohne
die originalen Secrets (`JWT_SECRET`, Keys, `POSTGRES_PASSWORD`) passen
Dumps und Instanz nicht mehr zusammen.
- Die Sicherungen liegen auf demselben Host. Gegen Hostverlust zusätzlich
an einen zweiten Ort kopieren (VM-Snapshot allein ersetzt das nicht: Er
sichert die laufende Datenbank nur absturzkonsistent).
**Wiederherstellen — `restore.sh`**, nur in eine **leere** Datenbank (neuer
Host, neues Volume, Totalverlust):
```bash
cd /pfad/instanz # docker-compose.yml + .env aus dem Passwort-Safe
bash restore.sh ~/backups/<instanz>/db-….dump ~/backups/<instanz>/storage-….tar.gz
```
Das Skript startet die Datenbank, setzt die Rollen-Passwörter und
`service_role bypassrls` (Cluster-Eigenschaften, nicht im Dump), spielt den
Dump ein (ohne das vom Image angelegte Schema `storage` selbst), entpackt die
Dateien mit Attributen und startet alle Dienste. Es bricht ab, wenn schon
TeamVis-Daten in der Datenbank oder Dateien in `volumes/storage` liegen.
Am 09.10.2026 mit einer echten Instanz (50 Karten, 5 Dateien) in einem
isolierten Wegwerf-Projekt getestet: Zeilenzahlen gleich, PostgREST liest mit
Service- und Anon-Key, alle Dateien über die Storage-API abrufbar und
byte-gleich.
## Footprint (grob) ## Footprint (grob)
| | Container | RAM (Daumenregel) | | | Container | RAM (Daumenregel) |
@@ -258,3 +306,5 @@ war im localhost-Test nicht aktiv).
| `Caddyfile.example` | TLS-Ingress (App- + Supabase-Domain) | | `Caddyfile.example` | TLS-Ingress (App- + Supabase-Domain) |
| `gen-keys.mjs` | erzeugt JWT_SECRET + ANON_KEY + SERVICE_ROLE_KEY | | `gen-keys.mjs` | erzeugt JWT_SECRET + ANON_KEY + SERVICE_ROLE_KEY |
| `bootstrap.sh` | Keys + Stack + Schema + Admin in einem Rutsch | | `bootstrap.sh` | Keys + Stack + Schema + Admin in einem Rutsch |
| `backup.sh` | tägliche Sicherung (Datenbank + Storage mit Attributen) |
| `restore.sh` | Wiederherstellung in eine leere Instanz |
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# Sicherung einer selbst gehosteten TeamVis-Instanz mit eigener Datenbank
# (install.sh, Variante „eigene DB“): die Schemas public + storage als
# pg_dump im Custom-Format und die Storage-Dateien (volumes/storage).
#
# Liegt im Instanz-Verzeichnis (neben docker-compose.yml) und läuft per Cron:
# 15 3 * * * /bin/bash /pfad/instanz/backup.sh >> $HOME/backups/<instanz>/backup.log 2>&1
#
# Nicht gesichert wird die .env: Sie enthält alle Secrets und gehört in einen
# Passwort-Safe. Ohne sie (JWT_SECRET, ANON_KEY, SERVICE_ROLE_KEY,
# POSTGRES_PASSWORD) lässt sich die Instanz aus den Dumps aber nicht
# wiederherstellen. Wiederherstellung: README.md → „Sicherung und
# Wiederherstellung“.
#
# PUSH_URL (optional, Zeile in $BACKUP_DIR/push.env): Push-Monitor in Uptime
# Kuma. Wird nur nach einem vollständigen Lauf aufgerufen — bleibt er aus,
# alarmiert Kuma. So fällt auch ein Cron auf, der gar nicht läuft.
set -euo pipefail
INSTANCE_DIR="${INSTANCE_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}"
NAME="$(basename "$INSTANCE_DIR")"
BACKUP_DIR="${BACKUP_DIR:-$HOME/backups/$NAME}"
KEEP_DAYS="${KEEP_DAYS:-30}"
[ -f "$BACKUP_DIR/push.env" ] && . "$BACKUP_DIR/push.env"
cd "$INSTANCE_DIR"
umask 077
mkdir -p "$BACKUP_DIR"
STAMP="$(date +%F-%H%M)"
DB="$BACKUP_DIR/db-$STAMP.dump"
FILES="$BACKUP_DIR/storage-$STAMP.tar.gz"
trap 'rm -f "$DB.part" "$FILES.part"' EXIT
# public = TeamVis-Daten. storage = Buckets und Objekt-Metadaten, samt der
# Migrationstabelle der Storage-API — so legt die API beim Wiederherstellen
# nichts neu an. auth und vault legt das Postgres-Image selbst an, TeamVis
# nutzt sie nicht.
docker compose exec -T db sh -c \
'PGPASSWORD="$POSTGRES_PASSWORD" pg_dump -h localhost -U supabase_admin -d "$POSTGRES_DB" -Fc -n public -n storage' \
> "$DB.part"
# Ein abgeschnittener Dump fällt beim Lesen des Inhaltsverzeichnisses auf.
TOC="$(docker compose exec -T db pg_restore -l < "$DB.part")"
if ! grep -q "TABLE DATA public employees" <<< "$TOC"; then
echo "$(date -Is) FEHLER: Dump unvollständig" >&2
exit 1
fi
# --xattrs ist Pflicht: Die Storage-API (Datei-Backend) legt Content-Type und
# Cache-Control jeder Datei als erweiterte Attribute (user.supabase.*) ab.
# Ohne sie liefert eine wiederhergestellte Instanz die Dateien mit 500 aus
# (am 09.10.2026 im Wiederherstellungstest gefunden).
tar --xattrs --xattrs-include='user.*' -czf "$FILES.part" -C volumes storage
gzip -t "$FILES.part"
mv "$DB.part" "$DB"
mv "$FILES.part" "$FILES"
find "$BACKUP_DIR" \( -name 'db-*.dump' -o -name 'storage-*.tar.gz' \) -mtime +"$KEEP_DAYS" -delete
echo "$(date -Is) ok $(basename "$DB") $(stat -c %s "$DB") Bytes, $(basename "$FILES") $(stat -c %s "$FILES") Bytes"
if [ -n "${PUSH_URL:-}" ]; then
curl -fsS -m 10 "$PUSH_URL?status=up&msg=OK" >/dev/null || echo "$(date -Is) Push an Uptime Kuma fehlgeschlagen" >&2
fi
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# Wiederherstellung einer selbst gehosteten TeamVis-Instanz (Variante „eigene
# DB“) aus den Sicherungen von backup.sh — in eine LEERE Datenbank: neuer
# Host, neues Volume, Totalverlust.
#
# Im Instanz-Verzeichnis aufrufen (docker-compose.yml + .env aus dem
# Passwort-Safe; ohne die originalen Secrets passen Keys und Passwörter nicht):
# bash restore.sh /pfad/db-JJJJ-MM-TT-HHMM.dump /pfad/storage-JJJJ-MM-TT-HHMM.tar.gz
#
# Bricht ab, wenn die Datenbank schon TeamVis-Daten enthält oder
# volumes/storage nicht leer ist. Über einen Bestand zurückspielen heißt also
# bewusst: `docker compose down`, Volume `<projekt>_db-data` löschen,
# volumes/storage beiseiteschieben — erst dann dieses Skript.
set -euo pipefail
die() { echo "✗ $*" >&2; exit 1; }
[ $# -eq 2 ] || die "Aufruf: restore.sh <db-….dump> <storage-….tar.gz>"
DUMP="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")"
TAR="$(cd "$(dirname "$2")" && pwd)/$(basename "$2")"
[ -f "$DUMP" ] && [ -f "$TAR" ] || die "Dump oder Storage-Archiv fehlt."
[ -f docker-compose.yml ] && [ -f .env ] || die "Kein Instanz-Verzeichnis (docker-compose.yml + .env)."
if [ -d volumes/storage ] && [ -n "$(ls -A volumes/storage)" ]; then
die "volumes/storage ist nicht leer."
fi
# psql/pg_restore im db-Container, als supabase_admin mit dem Passwort aus
# dessen Umgebung — das Passwort taucht so in keiner Kommandozeile auf.
dbsh() { docker compose exec -T db sh -c "$1"; }
ADMIN='PGPASSWORD="$POSTGRES_PASSWORD" psql -h localhost -U supabase_admin -d "$POSTGRES_DB" -v ON_ERROR_STOP=1'
echo "→ Datenbank starten …"
docker compose up -d db
until dbsh 'pg_isready -U postgres -d "$POSTGRES_DB"' >/dev/null 2>&1; do sleep 2; done
# Das Image meldet sich bereit, bevor supabase_admin sein Passwort hat.
until dbsh "$ADMIN -tAc 'select 1'" >/dev/null 2>&1; do sleep 2; done
if [ "$(dbsh "$ADMIN -tAc \"select to_regclass('public.employees') is not null\"")" = "t" ]; then
die "Die Datenbank enthält schon TeamVis-Daten — nur in eine leere einspielen."
fi
echo "→ Rollen (Passwörter, bypassrls) …"
# Cluster-Eigenschaften, nicht im Dump. Wie install.sh: das Image setzt nur
# supabase_admin, die übrigen Dienste melden sich mit POSTGRES_PASSWORD an.
dbsh "$ADMIN -v pw=\"\$POSTGRES_PASSWORD\"" <<'SQL'
alter role authenticator with login password :'pw';
alter role supabase_storage_admin with login password :'pw';
alter role supabase_auth_admin with login password :'pw';
alter role service_role bypassrls;
SQL
echo "→ Dump einspielen …"
# Das Schema storage legt das Image selbst an (mit abhängigen Funktionen) —
# dessen DROP/CREATE aus dem Dump überspringen, alles darin wird ersetzt.
docker compose cp "$DUMP" db:/tmp/restore.dump >/dev/null
dbsh 'pg_restore -l /tmp/restore.dump | grep -v " SCHEMA - storage " > /tmp/restore.list'
dbsh 'PGPASSWORD="$POSTGRES_PASSWORD" pg_restore -h localhost -U supabase_admin -d "$POSTGRES_DB" --clean --if-exists --exit-on-error -L /tmp/restore.list /tmp/restore.dump'
dbsh 'rm -f /tmp/restore.dump /tmp/restore.list'
echo "→ Storage-Dateien …"
mkdir -p volumes
# Mit den erweiterten Attributen (Content-Type je Datei), siehe backup.sh.
tar --xattrs --xattrs-include='user.*' -xzf "$TAR" -C volumes
echo "→ Alle Dienste starten …"
docker compose up -d
echo "✔ Wiederhergestellt: $(dbsh "$ADMIN -tAc \"select count(*) || ' Karten, ' || (select count(*) from storage.objects) || ' Dateien' from public.employees\"")"
echo " Prüfen: Storage gesund (docker compose ps), Karte und Fotos im Browser, Admin-Login."
+61 -15
View File
@@ -5,13 +5,21 @@
# Aktualisiert eine bestehende Instanz in einem Rutsch: # Aktualisiert eine bestehende Instanz in einem Rutsch:
# 1. Pre-Update-Backup der Datenbank (Modus 2) # 1. Pre-Update-Backup der Datenbank (Modus 2)
# 2. `git pull` im Bundle → neue Migrationen + Skripte # 2. `git pull` im Bundle → neue Migrationen + Skripte
# 3. NUR die NEU hinzugekommenen Migrationen einspielen (git als Ledger: # 3. Fehlende Migrationen einspielen — jede in EINER Transaktion zusammen
# welche Dateien seit dem letzten Stand dazukamen) # mit ihrem Eintrag in public.teamvis_schema_migrations (Modus 2)
# 4. neues App-Image ziehen + Container neu starten # 4. neues App-Image ziehen + Container neu starten
# #
# Warum git als Ledger? Die Migrationen sind append-only. `git diff` zwischen # Warum ein Ledger in der DB? Früher diente `git diff OLD NEW` als Ledger.
# altem und neuem Stand liefert exakt die neuen Migrationsdateien — kein # Schlug dabei eine Migration fehl, war HEAD trotzdem schon vorgerückt — der
# separater DB-Tracker nötig, kein erneutes Ausführen alter Migrationen. # nächste Lauf meldete „keine neuen Migrationen", die restlichen fehlten
# dauerhaft, und eine halb angewendete Migration blieb unbemerkt (Review
# 04.10.2026). Jetzt gilt: eingespielt ist, was in der Tabelle steht; eine
# fehlgeschlagene Migration rollt komplett zurück und wird beim nächsten
# Lauf erneut versucht.
#
# Bestandsinstanzen ohne Tabelle: Beim ersten Lauf wird sie angelegt und mit
# allen Migrationen des Stands VOR dem Pull befüllt — genau die galten nach
# der alten Logik als eingespielt.
# #
# Aufruf: bash deploy/selfhost/update.sh [instanz-verzeichnis] # Aufruf: bash deploy/selfhost/update.sh [instanz-verzeichnis]
# (ohne Argument: das Instanz-Verzeichnis wird automatisch erkannt, wenn # (ohne Argument: das Instanz-Verzeichnis wird automatisch erkannt, wenn
@@ -64,6 +72,9 @@ if [ "$MODE2" = "1" ]; then
head "1) Datenbank-Backup" head "1) Datenbank-Backup"
ts="$(date +%Y%m%d-%H%M%S)" ts="$(date +%Y%m%d-%H%M%S)"
dump="$INSTANCE/backup-db-$ts.sql" dump="$INSTANCE/backup-db-$ts.sql"
# Der Dump enthält alle Personendaten — nur für den Eigentümer lesbar
# anlegen (vorher landete er mit 644 im Instanz-Verzeichnis).
install -m 600 /dev/null "$dump"
if ( cd "$INSTANCE" && docker compose exec -T -e PGPASSWORD="$PGPW" db pg_dump -U supabase_admin "$POSTGRES_DB" ) > "$dump" 2>/dev/null; then if ( cd "$INSTANCE" && docker compose exec -T -e PGPASSWORD="$PGPW" db pg_dump -U supabase_admin "$POSTGRES_DB" ) > "$dump" 2>/dev/null; then
ok "Backup: $dump ($(wc -l < "$dump") Zeilen)" ok "Backup: $dump ($(wc -l < "$dump") Zeilen)"
else else
@@ -84,21 +95,56 @@ else
ok "Aktualisiert: ${OLD:0:8} → ${NEW:0:8}" ok "Aktualisiert: ${OLD:0:8} → ${NEW:0:8}"
fi fi
# ── 3. NUR neue Migrationen einspielen ──────────────────────────────── # ── 3. Fehlende Migrationen einspielen ────────────────────────────────
head "3) Neue Migrationen" head "3) Migrationen"
mapfile -t NEWMIG < <(git diff --diff-filter=A --name-only "$OLD" "$NEW" -- 'supabase/migrations/*.sql' 2>/dev/null | sort) if [ "$MODE2" = "1" ]; then
if [ "${#NEWMIG[@]}" -eq 0 ]; then LEDGER="public.teamvis_schema_migrations"
ok "Keine neuen Migrationen." if [ "$(dbsql -tAc "select to_regclass('$LEDGER') is not null")" != "t" ]; then
elif [ "$MODE2" = "1" ]; then say "Migrations-Ledger fehlt — wird angelegt und mit dem bisherigen Stand (${OLD:0:8}) befüllt."
for m in "${NEWMIG[@]}"; do {
say "→ $m" echo "begin;"
dbsql < "$BUNDLE/$m" echo "create table $LEDGER (filename text primary key, applied_at timestamptz not null default now());"
echo "revoke all on $LEDGER from anon, authenticated;"
git ls-tree --name-only "$OLD" -- supabase/migrations/ | grep -E '\.sql$' | sort \
| sed -E "s#^supabase/migrations/(.*)\$#insert into $LEDGER (filename) values ('\\1');#"
echo "commit;"
} | dbsql >/dev/null
ok "Ledger angelegt ($(dbsql -tAc "select count(*) from $LEDGER") Einträge)."
fi
mapfile -t APPLIED < <(dbsql -tAc "select filename from $LEDGER")
declare -A DONE=()
for f in "${APPLIED[@]}"; do [ -n "$f" ] && DONE["$f"]=1; done
PENDING=()
for m in $(ls supabase/migrations/*.sql | sort); do
[ -z "${DONE[$(basename "$m")]:-}" ] && PENDING+=("$m")
done done
ok "${#NEWMIG[@]} Migration(en) eingespielt."
if [ "${#PENDING[@]}" -eq 0 ]; then
ok "Keine offenen Migrationen."
else
for m in "${PENDING[@]}"; do
name="$(basename "$m")"
say "→ $name"
# Migration + Ledger-Eintrag atomar: scheitert die Migration, bleibt
# weder ein halber Schema-Stand noch ein Eintrag zurück.
{ cat "$BUNDLE/$m"; printf "\ninsert into %s (filename) values ('%s');\n" "$LEDGER" "$name"; } \
| dbsql --single-transaction >/dev/null \
|| die "Migration $name fehlgeschlagen und vollständig zurückgerollt. Fehler beheben und update.sh erneut starten — bereits eingespielte Migrationen bleiben erhalten, die App wurde NICHT aktualisiert."
done
ok "${#PENDING[@]} Migration(en) eingespielt."
fi
else else
mapfile -t NEWMIG < <(git diff --diff-filter=A --name-only "$OLD" "$NEW" -- 'supabase/migrations/*.sql' 2>/dev/null | sort)
if [ "${#NEWMIG[@]}" -eq 0 ]; then
ok "Keine neuen Migrationen in diesem Update."
else
warn "Externe Supabase: bitte diese neuen Migrationen EINMAL in Supabase Studio (SQL-Editor) ausführen:" warn "Externe Supabase: bitte diese neuen Migrationen EINMAL in Supabase Studio (SQL-Editor) ausführen:"
for m in "${NEWMIG[@]}"; do say " $BUNDLE/$m"; done for m in "${NEWMIG[@]}"; do say " $BUNDLE/$m"; done
warn "Schlägt eine fehl: nicht weitermachen, sondern Fehler beheben — update.sh zeigt sie beim nächsten Lauf NICHT erneut an."
read -r -p " Enter drücken, sobald sie eingespielt sind … " _ || true read -r -p " Enter drücken, sobald sie eingespielt sind … " _ || true
fi
fi fi
# ── 4. Neues Image ziehen + Container neu starten ───────────────────── # ── 4. Neues Image ziehen + Container neu starten ─────────────────────
+11 -6
View File
@@ -124,12 +124,17 @@ anlegen. Dauer: wenige Minuten (Image-Download).
## 6. Betrieb ## 6. Betrieb
- **Updates:** Image-Tag in `kunde/docker-compose.yml` bumpen, bei Minor-Sprüngen - **Updates:** im Bundle-Verzeichnis `bash deploy/selfhost/update.sh` — sichert
vorher die neue(n) Migration(en) einspielen → `docker compose pull && up -d`. die Datenbank, holt das Bundle, spielt fehlende Migrationen transaktional ein
Details: `docs/NEUKUNDE.md` §7. (Protokoll in `public.teamvis_schema_migrations`) und startet die App mit dem
- **Backup (wichtig, jetzt liegt die DB bei dir):** das Postgres-Volume neuen Image. Kein Watchtower o. Ä. für den App-Container: Image und
(`db-data`) **und** `kunde/volumes/storage` sichern. Die `.env` gehört in einen Migrationen gehören zusammen.
Passwort-Safe (enthält alle Secrets), **nicht** ins Backup-Repo. - **Backup (wichtig, jetzt liegt die DB bei dir):** `backup.sh` aus
`deploy/selfhost/` ins Instanz-Verzeichnis legen und täglich per Cron laufen
lassen — sichert Datenbank und `volumes/storage` (mit Dateiattributen),
Wiederherstellung mit `restore.sh`. Details: `deploy/selfhost/README.md` →
„Sicherung und Wiederherstellung“. Die `.env` gehört in einen Passwort-Safe
(enthält alle Secrets), **nicht** ins Backup.
--- ---
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "teamvis-selfhost", "name": "teamvis-selfhost",
"version": "0.33.0", "version": "0.56.2",
"private": true, "private": true,
"description": "Self-Hosting-Bundle für TeamVis (Installer + Migrationen, ohne App-Quellcode).", "description": "Self-Hosting-Bundle für TeamVis (Installer + Migrationen, ohne App-Quellcode).",
"type": "module", "type": "module",
@@ -0,0 +1,16 @@
-- 0057_booking_slot_unique.sql
--
-- Schutz gegen Doppelbuchung desselben Slots (Race zwischen zwei
-- gleichzeitigen Gaesten). createBooking prueft die Verfuegbarkeit per
-- Check-then-Insert — zwei parallele Requests sehen beide den Slot als frei
-- und inserten beide. Ein partieller Unique-Index auf (employee_id,
-- start_at) fuer bestaetigte Buchungen laesst die DB das Rennen atomar
-- entscheiden; die App faengt den Unique-Violation-Fehler (23505) ab und
-- meldet dem zweiten Gast "Termin nicht mehr verfuegbar".
--
-- Nur 'confirmed' zaehlt — stornierte/abgesagte Buchungen (status !=
-- 'confirmed') duerfen denselben Slot wieder freigeben.
create unique index if not exists bookings_slot_confirmed_uniq
on public.bookings (employee_id, start_at)
where status = 'confirmed';
+56
View File
@@ -0,0 +1,56 @@
-- ====================================================================
-- 0058_legal_pages — Impressum + Datenschutzerklärung (öffentlich)
-- ====================================================================
-- Rechtliche Pflichtseiten: Impressum (§ 5 DDG) rendert die App künftig
-- automatisch aus diesen Stammdaten, die Datenschutzerklärung (Art. 13
-- DSGVO) aus einem pro Instanz editierbaren HTML-Feld (mit Vorlage als
-- Fallback). White-Label: jeder Mandant ist eigener Diensteanbieter und
-- pflegt seine eigenen Angaben.
--
-- Alle Spalten sind KEINE Geheimnisse — die öffentlichen Seiten lesen
-- site_settings via anon. Der Grant unten (Muster 0043/0056) macht sie
-- automatisch anon-lesbar (Block-Liste = bisherige Geheimnisse).
alter table public.site_settings
-- Anschrift des Diensteanbieters (Impressum)
add column if not exists company_street text,
add column if not exists company_postal_code text,
add column if not exists company_city text,
-- Kontakt (Impressum: schnelle elektronische Kontaktaufnahme)
add column if not exists contact_email text,
add column if not exists contact_phone text,
-- Zuständige Aufsichtsbehörde (falls einschlägig)
add column if not exists supervisory_authority text,
-- Datenschutzbeauftragte:r (Art. 37 DSGVO — bei öffentlichen Stellen Pflicht)
add column if not exists dpo_name text,
add column if not exists dpo_contact text,
-- Freitext-HTML: editierbare Datenschutzerklärung + optionaler
-- Impressum-Zusatz (z.B. Haftungsausschluss, Bildnachweise).
add column if not exists privacy_policy_html text,
add column if not exists imprint_extra_html text;
-- anon-Spalten-Grant neu setzen (Muster 0056): die neuen Rechts-Spalten
-- sind KEINE Geheimnisse. Block-Liste bleibt unverändert → neue Spalten
-- werden 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 (
'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',
'smtp_host', 'smtp_port', 'smtp_secure', 'smtp_user', 'smtp_pass',
'smtp_from_email', 'smtp_from_name'
);
execute 'grant select (' || v_cols || ') on public.site_settings to anon';
end $$;
@@ -0,0 +1,35 @@
-- ====================================================================
-- 0059_accessibility — Erklärung zur Barrierefreiheit (öffentlich)
-- ====================================================================
-- Optionale, pro Instanz editierbare Barrierefreiheitserklärung. Anders als
-- Impressum/Datenschutz ist sie NICHT für jede Instanz Pflicht (hängt von der
-- Einordnung des Betreibers ab) → opt-in: /barrierefreiheit + Footer-Link
-- erscheinen nur, wenn ein Text hinterlegt ist.
--
-- Kein Geheimnis — öffentliche Seite liest via anon. Grant-Muster wie 0058.
alter table public.site_settings
add column if not exists accessibility_html text;
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 (
'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',
'smtp_host', 'smtp_port', 'smtp_secure', 'smtp_user', 'smtp_pass',
'smtp_from_email', 'smtp_from_name'
);
execute 'grant select (' || v_cols || ') on public.site_settings to anon';
end $$;
@@ -0,0 +1,25 @@
-- ====================================================================
-- 0060_push_subscriptions — Web-Push-Abos fürs Mitarbeiter-Portal
-- ====================================================================
-- Jede:r Mitarbeiter:in kann im Portal-PWA Push-Benachrichtigungen
-- aktivieren (neue Leads/Terminanfragen/Buchungen). Ein Gerät = ein Abo
-- (endpoint eindeutig). Zugriff ausschließlich über Service-Role (Portal-
-- Actions, employeeId-gescoped) — kein anon-Zugriff.
create table if not exists public.push_subscriptions (
id uuid primary key default gen_random_uuid(),
employee_id uuid not null references public.employees(id) on delete cascade,
endpoint text not null unique,
p256dh text not null,
auth text not null,
user_agent text,
created_at timestamptz not null default now(),
last_used_at timestamptz
);
create index if not exists push_subscriptions_employee_idx
on public.push_subscriptions (employee_id);
-- RLS an, keine anon-Policy → Default-Deny für anon. Service-Role umgeht RLS.
alter table public.push_subscriptions enable row level security;
revoke all on public.push_subscriptions from anon;
@@ -0,0 +1,40 @@
-- ====================================================================
-- 0061_portal_api_tokens — Bearer-Token für die native App (iOS/Android)
-- ====================================================================
-- Die native App (React Native/Expo) kann keine Server Actions und kein
-- iron-session-Cookie nutzen. Sie authentifiziert über den bestehenden
-- Magic-Link-Code-Flow und erhält danach einen langlebigen Bearer-Token,
-- der im Gerät (Expo SecureStore / Keychain) liegt und bei jedem
-- /api/portal/*-Request im Authorization-Header mitgeschickt wird.
--
-- Identität = E-Mail (wie die Portal-Session, lib/portal-session.ts). Eine
-- Person kann mehrere aktive Karten/Profile mit derselben E-Mail haben;
-- active_employee_id hält das zuletzt gewählte Profil (bei Multi-Profil),
-- ist bei genau einem Profil bedeutungslos.
--
-- Sicherheit: Es wird NUR der SHA-256-Hash des Tokens gespeichert (wie bei
-- Admin-Invites) — ein DB-Leak gibt keine gültigen Tokens preis. Zugriff
-- ausschließlich über Service-Role (die Route-Handler), kein anon-Zugriff.
-- Gleitende Gültigkeit: expires_at rückt bei aktiver Nutzung nach vorne
-- (analog zur 90-Tage-Trusted-Portal-Session).
create table if not exists public.portal_api_tokens (
id uuid primary key default gen_random_uuid(),
email text not null,
token_hash text not null unique,
-- Zuletzt gewähltes Profil bei Multi-Profil. NULL = noch nicht gewählt
-- bzw. Single-Profil (dann löst der Server das eine aktive Profil auf).
active_employee_id uuid references public.employees(id) on delete set null,
user_agent text,
created_at timestamptz not null default now(),
last_used_at timestamptz not null default now(),
expires_at timestamptz not null,
revoked_at timestamptz
);
create index if not exists portal_api_tokens_email_idx
on public.portal_api_tokens (email);
-- RLS an, keine anon-Policy → Default-Deny für anon. Service-Role umgeht RLS.
alter table public.portal_api_tokens enable row level security;
revoke all on public.portal_api_tokens from anon;
@@ -0,0 +1,37 @@
-- ====================================================================
-- 0062_push_platform — Push-Abos plattformfähig machen (Web + Expo/nativ)
-- ====================================================================
-- Migration 0060 legte push_subscriptions für Web-Push (VAPID) an:
-- endpoint/p256dh/auth sind Browser-Push-Spezifika und NOT NULL. Die native
-- App nutzt stattdessen den Expo-Push-Service (ein Token, Expo relayed an
-- APNs/FCM) — dort gibt es kein p256dh/auth. Wir erweitern die Tabelle um
-- eine platform-Spalte und einen expo_push_token, und lockern die Web-only-
-- Spalten auf NULLable, damit Expo-Abos ohne sie gespeichert werden können.
--
-- Bestandsdaten: alle vorhandenen Zeilen sind Web-Abos → platform='web'
-- (Default deckt das ab). Der Versand-Fork in lib/push.ts wählt anhand von
-- platform den richtigen Kanal.
alter table public.push_subscriptions
add column if not exists platform text not null default 'web',
add column if not exists expo_push_token text;
-- Web-only-Spalten für Expo-Abos optional machen.
alter table public.push_subscriptions alter column endpoint drop not null;
alter table public.push_subscriptions alter column p256dh drop not null;
alter table public.push_subscriptions alter column auth drop not null;
-- Ein Expo-Push-Token darf nur einmal existieren (ein Gerät = ein Abo),
-- analog zum unique endpoint bei Web. Partieller Index, damit mehrere
-- Web-Zeilen mit NULL-Token nicht kollidieren.
create unique index if not exists push_subscriptions_expo_token_key
on public.push_subscriptions (expo_push_token)
where expo_push_token is not null;
-- Integrität: je nach platform müssen die passenden Felder gesetzt sein.
alter table public.push_subscriptions drop constraint if exists push_subscriptions_platform_fields;
alter table public.push_subscriptions add constraint push_subscriptions_platform_fields check (
(platform = 'web' and endpoint is not null and p256dh is not null and auth is not null)
or
(platform = 'expo' and expo_push_token is not null)
);
@@ -0,0 +1,19 @@
-- Lead-Kontaktfelder: mobile, website, address strukturiert erfassen.
-- ====================================================================
-- Die Karten-OCR (Card-Scanner) warf Mobilnummer, Web-Adresse und
-- Postanschrift bisher unstrukturiert ins `notes`-Freitextfeld — die
-- Nutzer beklagten "Felder nicht korrekt zugeordnet / fehlen Felder".
-- Diese Migration ergaenzt eigene Spalten, sodass der Scanner die
-- Daten sauber getrennt ablegt und sie einzeln editierbar sind:
--
-- - mobile: Mobilnummer (getrennt vom Festnetz-`phone`)
-- - website: Web-Adresse
-- - address: Postanschrift
--
-- Idempotent (`if not exists`), damit ein erneutes Anwenden auf
-- Bestandsinstanzen nicht bricht.
alter table public.card_leads
add column if not exists mobile text,
add column if not exists website text,
add column if not exists address text;
@@ -0,0 +1,22 @@
-- ====================================================================
-- 0064_push_expo_token_index_fix — Expo-Push-Registrierung reparieren
-- ====================================================================
-- Migration 0062 legte den Unique-Index auf expo_push_token als PARTIELLEN
-- Index an (`where expo_push_token is not null`). Postgres kann einen
-- partiellen Index bei `ON CONFLICT (spalte)` aber nur dann per Inferenz
-- treffen, wenn das Statement dasselbe WHERE-Prädikat mitschickt —
-- supabase-js/PostgREST schickt nur den Spaltennamen. Folge: der Upsert in
-- lib/push.ts (saveExpoPushSubscription, onConflict "expo_push_token") lief
-- in Fehler 42P10 „there is no unique or exclusion constraint matching the
-- ON CONFLICT specification". Die native App bekam trotzdem ok:true (der
-- Fehler wurde nicht geprüft), sodass der Schalter an blieb, aber nie ein
-- Abo gespeichert wurde.
--
-- Das Prädikat war überflüssig: NULL-Werte kollidieren in Postgres nie mit
-- anderen NULLs, die Web-Zeilen ohne Token bleiben also auch mit einem
-- normalen Unique-Index erlaubt.
drop index if exists public.push_subscriptions_expo_token_key;
create unique index if not exists push_subscriptions_expo_token_key
on public.push_subscriptions (expo_push_token);
@@ -0,0 +1,16 @@
-- ====================================================================
-- 0065_lead_source_kind_reception — Empfangs-Leads korrekt kennzeichnen
-- ====================================================================
-- `createLead` (öffentliches Lead-Formular UND Empfangstresen) hat
-- source_kind nie gesetzt, also griff der Default 'form' aus Migration 0037.
-- In der Lead-Inbox erschienen Besucher dadurch als „Formular", obwohl der
-- Wert 'reception' im Schema vorgesehen ist. Der Code setzt die Spalte ab
-- sofort mit; diese Migration zieht die Bestandsdaten nach.
--
-- Kriterium ist dieselbe Quelle, an der die Tabelle Empfang und Lead-Capture
-- ohnehin unterscheidet (siehe Kopfkommentar in lib/leads.ts).
update public.card_leads
set source_kind = 'reception'
where source_kind = 'form'
and source_url in ('/empfang', '/admin/empfang');
@@ -0,0 +1,26 @@
-- ====================================================================
-- 0066_push_preferences — Push pro Ereignisart schaltbar
-- ====================================================================
-- Bisher war Push ein Alles-oder-nichts-Schalter pro Gerät: wer ihn
-- aktivierte, bekam Leads, Terminanfragen, Buchungen und Empfangs-Besuche.
-- Diese Tabelle hält pro Mitarbeiter:in, welche Ereignisarten überhaupt
-- eine Mitteilung auslösen — geräteübergreifend (Web-Push wie native App),
-- denn die Auswahl ist eine inhaltliche Entscheidung, keine Geräte-Frage.
--
-- Fehlende Zeile = alles aktiviert. Damit ändert sich für Bestandsnutzer
-- nichts, solange sie nichts abwählen; lib/push.ts liest den Default so.
--
-- Zugriff ausschließlich über Service-Role (Portal-Actions und
-- /api/portal/*, jeweils employeeId-gescoped) — kein anon-Zugriff.
create table if not exists public.push_preferences (
employee_id uuid primary key references public.employees(id) on delete cascade,
leads boolean not null default true,
appointments boolean not null default true,
bookings boolean not null default true,
reception boolean not null default true,
updated_at timestamptz not null default now()
);
alter table public.push_preferences enable row level security;
revoke all on public.push_preferences from anon;
@@ -0,0 +1,103 @@
-- Karten-Analytics: Aufbewahrungsfrist und namentliche Auswertung
-- ====================================================================
-- Zwei Datenschutz-Lücken aus 0017 werden hier geschlossen:
--
-- 1. Die dort angekündigte Aufbewahrung von 365 Tagen war nur ein
-- Kommentar — es gab keine Löschroutine, die Protokolle wuchsen
-- unbegrenzt. Jetzt: konfigurierbare Frist (Default 90 Tage) plus
-- `purge_card_view_log()`, die der Anwendungscode regelmäßig ruft
-- und die zusätzlich per pg_cron laufen kann.
--
-- 2. Die namentliche Auswertung je Beschäftigtem (Rangliste unter
-- /admin/analytics) war ohne Schalter immer aktiv. Sie ist ab
-- jetzt ein bewusstes Opt-in, Default AUS — auch für
-- Bestandsinstanzen. Wo eine Belegschaft vertreten ist, ist die
-- Auswertung mitbestimmungspflichtig (technische Einrichtung,
-- geeignet zur Verhaltens- und Leistungskontrolle); sie darf
-- deshalb nicht stillschweigend voreingestellt sein.
alter table public.site_settings
add column if not exists analytics_employee_breakdown boolean not null default false,
add column if not exists analytics_retention_days integer not null default 90;
comment on column public.site_settings.analytics_employee_breakdown is
'Namentliche Auswertung der Kartenaufrufe je Beschäftigtem im Admin-Backend. Opt-in, mitbestimmungspflichtig.';
comment on column public.site_settings.analytics_retention_days is
'Aufbewahrungsfrist für card_view_log in Tagen. Ältere Zeilen werden gelöscht.';
-- Plausibilitätsgrenzen: mindestens eine Woche (sonst ist die Statistik
-- sinnlos), höchstens zwei Jahre (darüber ist die Erforderlichkeit für
-- eine reine Aufrufzählung nicht mehr begründbar).
alter table public.site_settings
drop constraint if exists site_settings_analytics_retention_days_check;
alter table public.site_settings
add constraint site_settings_analytics_retention_days_check
check (analytics_retention_days between 7 and 730);
-- --------------------------------------------------------------------
-- Löschroutine
-- --------------------------------------------------------------------
-- Ohne Argument gilt die in site_settings hinterlegte Frist. Rückgabe
-- ist die Anzahl gelöschter Zeilen, damit ein Cron-Lauf etwas zu
-- protokollieren hat.
create or replace function public.purge_card_view_log(retention_days integer default null)
returns integer
language plpgsql
security definer
set search_path = public
as $fn$
declare
days integer;
removed integer;
begin
days := coalesce(
retention_days,
(select s.analytics_retention_days from public.site_settings s where s.singleton limit 1),
90
);
delete from public.card_view_log
where occurred_at < now() - make_interval(days => days);
get diagnostics removed = row_count;
return removed;
end;
$fn$;
comment on function public.purge_card_view_log(integer) is
'Löscht Einträge aus card_view_log, die älter als die Aufbewahrungsfrist sind. Ohne Argument gilt site_settings.analytics_retention_days.';
-- Die Funktion fasst personenbeziehbare Protokolle an — kein Zugriff
-- für anon/authenticated.
revoke all on function public.purge_card_view_log(integer) from public;
revoke all on function public.purge_card_view_log(integer) from anon;
revoke all on function public.purge_card_view_log(integer) from authenticated;
grant execute on function public.purge_card_view_log(integer) to service_role;
-- --------------------------------------------------------------------
-- Zeitplan, sofern pg_cron vorhanden ist
-- --------------------------------------------------------------------
-- Die Anwendung löscht ohnehin selbst (siehe lib/analytics.ts), das hier
-- ist der saubere Weg für Instanzen mit pg_cron: die Frist greift dann
-- auch, wenn die Karten längere Zeit nicht aufgerufen werden. Fehlt die
-- Extension oder fehlen die Rechte, bleibt es beim App-seitigen Lauf —
-- die Migration darf daran nicht scheitern.
do $cron$
begin
if exists (select 1 from pg_extension where extname = 'pg_cron') then
begin
perform cron.unschedule('teamvis-purge-card-view-log');
exception when others then
null;
end;
perform cron.schedule(
'teamvis-purge-card-view-log',
'30 3 * * *',
'select public.purge_card_view_log();'
);
end if;
exception when others then
raise notice 'pg_cron-Zeitplan nicht eingerichtet (%). Die Anwendung löscht selbst.', sqlerrm;
end;
$cron$;
+29
View File
@@ -0,0 +1,29 @@
-- ====================================================================
-- 0068_dark_theme — dunkle Darstellung je Mandant (Portal und Karte)
-- ====================================================================
-- Drei optionale Farben. Leer = aus der hellen Palette abgeleitet
-- (lib/branding.ts, darkTheme()):
-- dark_bg Seitengrund Default: Sekundär tief
-- dark_surface Karten, Felder Default: Sekundär dunkel
-- dark_accent Schrift, Links, Fokus, Linien in Markenfarbe
-- Default: Primärfarbe aufgehellt bis 4,5:1 auf dark_surface
-- Die Primärfarbe selbst bleibt im Dunkeln reine Fläche (Buttons, Kopf) —
-- auf dunklem Grund reicht ihr Kontrast als Schrift in der Regel nicht.
--
-- Nur additiv. Anon-Lesezugriff ausschließlich auf die drei neuen Spalten
-- (die öffentliche Karte liest site_settings über den anon-Client); die
-- bestehenden Spalten-Grants bleiben unberührt.
alter table public.site_settings
add column if not exists dark_bg text,
add column if not exists dark_surface text,
add column if not exists dark_accent text;
comment on column public.site_settings.dark_bg is
'Dunkle Darstellung: Seitengrund (Hex). Leer = Sekundär tief.';
comment on column public.site_settings.dark_surface is
'Dunkle Darstellung: Karten und Felder (Hex). Leer = Sekundär dunkel.';
comment on column public.site_settings.dark_accent is
'Dunkle Darstellung: Markenfarbe für Schrift, Links und Linien (Hex). Leer = aufgehellte Primärfarbe.';
grant select (dark_bg, dark_surface, dark_accent) on public.site_settings to anon;
@@ -0,0 +1,21 @@
-- ====================================================================
-- 0069_theme_default — Standard-Darstellung je Mandant
-- ====================================================================
-- Was Portal und Karte zeigen, solange jemand im Portal unter „Darstellung"
-- nichts gewählt hat: 'light' (Vorgabe), 'dark' oder 'system' (folgt der
-- Geräteeinstellung). Eine eigene Wahl (Cookie tv_theme) geht immer vor.
-- Additiv; anon-Lesezugriff nur auf diese Spalte (die Karte liest anon).
alter table public.site_settings
add column if not exists theme_default text not null default 'light';
alter table public.site_settings
drop constraint if exists site_settings_theme_default_check;
alter table public.site_settings
add constraint site_settings_theme_default_check
check (theme_default in ('light', 'dark', 'system'));
comment on column public.site_settings.theme_default is
'Standard-Darstellung von Portal und Karte: light | dark | system.';
grant select (theme_default) on public.site_settings to anon;
+17
View File
@@ -0,0 +1,17 @@
-- ====================================================================
-- 0070_dark_brand — eigene Markenfläche für die dunkle Darstellung
-- ====================================================================
-- Die Primärfarbe trägt auf dunklem Grund oft nicht als Fläche (Stadtwerk:
-- #931041 wirkt auf #11181f fast schwarz). dark_brand ist die Farbe für
-- Buttons, Badges und aktive Markierungen im Dunkeln, immer mit weißer
-- Schrift. Leer = Primärfarbe. Stadtwerk-Festlegung (überarbeitet
-- 26.09.2026): #a5204d.
-- Additiv; anon-Lesezugriff nur auf diese Spalte.
alter table public.site_settings
add column if not exists dark_brand text;
comment on column public.site_settings.dark_brand is
'Dunkle Darstellung: Markenfläche für Buttons und Badges (Hex, weiße Schrift). Leer = Primärfarbe.';
grant select (dark_brand) on public.site_settings to anon;