Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ff39ca740 | ||
|
|
43c6a926a2 | ||
|
|
6da3cc2283 | ||
|
|
f53579d3a4 | ||
|
|
243713fe74 | ||
|
|
f1256efc38 |
No files matched your search
@@ -1,4 +1,4 @@
|
||||
# TeamVis — Self-Hosting-Bundle (v0.32.0)
|
||||
# TeamVis — Self-Hosting-Bundle (v0.57.0)
|
||||
|
||||
Dieses Bundle enthält alles zum **Betreiben** von TeamVis auf eigener
|
||||
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) |
|
||||
| `docs/` | Anleitungen |
|
||||
|
||||
Stand: TeamVis 0.32.0.
|
||||
Stand: TeamVis 0.57.0.
|
||||
@@ -35,3 +35,7 @@ TEAMVIS_SMTP_FROM_NAME=TeamVis
|
||||
|
||||
# Optional: zusätzliche next/image-Hosts (i.d.R. leer lassen).
|
||||
SUPABASE_IMAGE_HOSTS=
|
||||
|
||||
# Update-Hinweis im Admin (ab 0.57): ruft höchstens alle 6 Stunden eine
|
||||
# öffentliche Datei bei git.zfx.services ab. Abschalten mit:
|
||||
# TEAMVIS_UPDATE_CHECK=off
|
||||
@@ -195,15 +195,63 @@ ganze Block).
|
||||
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.
|
||||
5. **Backups:** jetzt liegt die DB beim Kunden — siehe „Sicherung und
|
||||
Wiederherstellung“ unten. 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).
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
|
||||
| | Container | RAM (Daumenregel) |
|
||||
@@ -258,3 +306,5 @@ war im localhost-Test nicht aktiv).
|
||||
| `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 |
|
||||
| `backup.sh` | tägliche Sicherung (Datenbank + Storage mit Attributen) |
|
||||
| `restore.sh` | Wiederherstellung in eine leere Instanz |
|
||||
Executable
+63
@@ -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
|
||||
Executable
+68
@@ -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."
|
||||
+105
-27
@@ -5,13 +5,21 @@
|
||||
# Aktualisiert eine bestehende Instanz in einem Rutsch:
|
||||
# 1. Pre-Update-Backup der Datenbank (Modus 2)
|
||||
# 2. `git pull` im Bundle → neue Migrationen + Skripte
|
||||
# 3. NUR die NEU hinzugekommenen Migrationen einspielen (git als Ledger:
|
||||
# welche Dateien seit dem letzten Stand dazukamen)
|
||||
# 3. Fehlende Migrationen einspielen — jede in EINER Transaktion zusammen
|
||||
# mit ihrem Eintrag in public.teamvis_schema_migrations (Modus 2)
|
||||
# 4. neues App-Image ziehen + Container neu starten
|
||||
#
|
||||
# Warum git als Ledger? Die Migrationen sind append-only. `git diff` zwischen
|
||||
# altem und neuem Stand liefert exakt die neuen Migrationsdateien — kein
|
||||
# separater DB-Tracker nötig, kein erneutes Ausführen alter Migrationen.
|
||||
# Warum ein Ledger in der DB? Früher diente `git diff OLD NEW` als Ledger.
|
||||
# Schlug dabei eine Migration fehl, war HEAD trotzdem schon vorgerückt — der
|
||||
# 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]
|
||||
# (ohne Argument: das Instanz-Verzeichnis wird automatisch erkannt, wenn
|
||||
@@ -59,54 +67,124 @@ POSTGRES_DB="${POSTGRES_DB:-postgres}"
|
||||
PGPW="$(grep -E '^POSTGRES_PASSWORD=' "$INSTANCE/.env" 2>/dev/null | cut -d= -f2- || true)"
|
||||
dbsql() { ( cd "$INSTANCE" && docker compose exec -T -e PGPASSWORD="$PGPW" db psql -v ON_ERROR_STOP=1 -U supabase_admin -d "$POSTGRES_DB" "$@" ); }
|
||||
|
||||
# ── 1. Pre-Update-Backup (nur Modus 2 — lokale DB) ────────────────────
|
||||
if [ "$MODE2" = "1" ]; then
|
||||
if [ -n "${TEAMVIS_UPDATE_REEXEC:-}" ]; then
|
||||
# Zweiter Durchlauf nach dem Selbst-Neustart (siehe Ende von Schritt 2):
|
||||
# Sicherung und git pull sind schon erledigt.
|
||||
OLD="$TEAMVIS_UPDATE_OLD"
|
||||
NEW="$(git rev-parse HEAD)"
|
||||
ok "Neue Fassung von update.sh übernommen — weiter ab Schritt 3."
|
||||
else
|
||||
# ── 1. Pre-Update-Backup (nur Modus 2 — lokale DB) ────────────────────
|
||||
if [ "$MODE2" = "1" ]; then
|
||||
head "1) Datenbank-Backup"
|
||||
ts="$(date +%Y%m%d-%H%M%S)"
|
||||
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
|
||||
ok "Backup: $dump ($(wc -l < "$dump") Zeilen)"
|
||||
else
|
||||
warn "Backup fehlgeschlagen — trotzdem fortfahren? (Strg-C zum Abbrechen)"; read -r _ || true
|
||||
fi
|
||||
else
|
||||
else
|
||||
warn "Externe Supabase (Modus 1): Backup macht dein Supabase-Anbieter. Kein lokaler Dump."
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 2. git pull → neue Migrationen/Skripte ────────────────────────────
|
||||
head "2) Bundle aktualisieren (git pull)"
|
||||
OLD="$(git rev-parse HEAD)"
|
||||
git pull --ff-only origin main || die "git pull fehlgeschlagen (abweichende Branches?). Notfalls: git fetch && git reset --hard origin/main"
|
||||
NEW="$(git rev-parse HEAD)"
|
||||
if [ "$OLD" = "$NEW" ]; then
|
||||
# ── 2. git pull → neue Migrationen/Skripte ────────────────────────────
|
||||
head "2) Bundle aktualisieren (git pull)"
|
||||
OLD="$(git rev-parse HEAD)"
|
||||
git pull --ff-only origin main || die "git pull fehlgeschlagen (abweichende Branches?). Notfalls: git fetch && git reset --hard origin/main"
|
||||
NEW="$(git rev-parse HEAD)"
|
||||
if [ "$OLD" = "$NEW" ]; then
|
||||
ok "Schon aktuell (keine neuen Commits)."
|
||||
else
|
||||
else
|
||||
ok "Aktualisiert: ${OLD:0:8} → ${NEW:0:8}"
|
||||
fi
|
||||
|
||||
# Hat der Pull update.sh selbst geändert, läuft ab hier die NEUE Fassung
|
||||
# weiter — sonst griffen Korrekturen am Skript erst beim nächsten Update
|
||||
# (Okt. 2026: Instanzen mussten update.sh deshalb mehrfach starten).
|
||||
if [ "$OLD" != "$NEW" ] && ! git diff --quiet "$OLD" "$NEW" -- deploy/selfhost/update.sh; then
|
||||
say "update.sh wurde mit aktualisiert — die neue Fassung übernimmt."
|
||||
exec env TEAMVIS_UPDATE_REEXEC=1 TEAMVIS_UPDATE_OLD="$OLD" bash "$BUNDLE/deploy/selfhost/update.sh" "$INSTANCE"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 3. NUR neue Migrationen einspielen ────────────────────────────────
|
||||
head "3) Neue Migrationen"
|
||||
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."
|
||||
elif [ "$MODE2" = "1" ]; then
|
||||
for m in "${NEWMIG[@]}"; do
|
||||
say "→ $m"
|
||||
dbsql < "$BUNDLE/$m"
|
||||
# ── 3. Fehlende Migrationen einspielen ────────────────────────────────
|
||||
head "3) Migrationen"
|
||||
if [ "$MODE2" = "1" ]; then
|
||||
LEDGER="public.teamvis_schema_migrations"
|
||||
if [ "$(dbsql -tAc "select to_regclass('$LEDGER') is not null")" != "t" ]; then
|
||||
say "Migrations-Ledger fehlt — wird angelegt und mit dem bisherigen Stand (${OLD:0:8}) befüllt."
|
||||
{
|
||||
echo "begin;"
|
||||
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
|
||||
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
|
||||
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:"
|
||||
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
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 4. Neues Image ziehen + Container neu starten ─────────────────────
|
||||
head "4) App aktualisieren"
|
||||
# Image-Version: Steht in der .env eine feste, ältere Version (frühere
|
||||
# Installer, oder bewusst gepinnt), zöge `docker compose pull` immer wieder
|
||||
# dasselbe alte Image — die App bliebe trotz neuer Migrationen stehen (RZHAS,
|
||||
# 10.10.2026). Daher auf die Version des Bundles heben. `latest` bleibt
|
||||
# `latest`, eine bewusst neuere Version bleibt unangetastet.
|
||||
# (Kein `| head` in diesem Skript: head() ist oben als Überschriften-Funktion
|
||||
# definiert und überdeckt den Befehl — daher grep -m1.)
|
||||
BUNDLE_VERSION="$(grep -m1 -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' "$BUNDLE/package.json" | sed 's/.*"\([^"]*\)"$/\1/')"
|
||||
CUR_VERSION="$(grep -E '^TEAMVIS_VERSION=' "$INSTANCE/.env" 2>/dev/null | cut -d= -f2- || true)"
|
||||
if [ -n "$CUR_VERSION" ] && [ -n "$BUNDLE_VERSION" ] && [ "$CUR_VERSION" != "latest" ] && [ "$CUR_VERSION" != "$BUNDLE_VERSION" ] \
|
||||
&& [ "$(printf '%s\n%s\n' "$CUR_VERSION" "$BUNDLE_VERSION" | sort -V | tail -1)" = "$BUNDLE_VERSION" ]; then
|
||||
sed -i "s/^TEAMVIS_VERSION=.*/TEAMVIS_VERSION=$BUNDLE_VERSION/" "$INSTANCE/.env"
|
||||
ok "Image-Version in .env: $CUR_VERSION → $BUNDLE_VERSION"
|
||||
fi
|
||||
grep -q 'teamvis:\${TEAMVIS_VERSION' "$INSTANCE/docker-compose.yml" \
|
||||
|| warn "Das App-Image steht fest in docker-compose.yml (ohne \${TEAMVIS_VERSION}) — bitte dort von Hand auf ${BUNDLE_VERSION:-die neue Version} setzen."
|
||||
( cd "$INSTANCE" && docker compose pull && docker compose up -d )
|
||||
ok "Container neu gestartet."
|
||||
|
||||
head "Fertig 🎉"
|
||||
SITE_URL="$(grep -E '^SITE_URL=|^NEXT_PUBLIC_SITE_URL=' "$INSTANCE/.env" 2>/dev/null | head -1 | cut -d= -f2-)"
|
||||
SITE_URL="$(grep -m1 -E '^SITE_URL=|^NEXT_PUBLIC_SITE_URL=' "$INSTANCE/.env" 2>/dev/null | cut -d= -f2- || true)"
|
||||
[ -n "$SITE_URL" ] && say " $SITE_URL"
|
||||
say " Bei Problemen Logs prüfen: ( cd $INSTANCE && docker compose logs -f app )"
|
||||
@@ -124,12 +124,17 @@ anlegen. Dauer: wenige Minuten (Image-Download).
|
||||
|
||||
## 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.
|
||||
- **Updates:** im Bundle-Verzeichnis `bash deploy/selfhost/update.sh` — sichert
|
||||
die Datenbank, holt das Bundle, spielt fehlende Migrationen transaktional ein
|
||||
(Protokoll in `public.teamvis_schema_migrations`) und startet die App mit dem
|
||||
neuen Image. Kein Watchtower o. Ä. für den App-Container: Image und
|
||||
Migrationen gehören zusammen.
|
||||
- **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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "teamvis-selfhost",
|
||||
"version": "0.32.0",
|
||||
"version": "0.57.0",
|
||||
"private": true,
|
||||
"description": "Self-Hosting-Bundle für TeamVis (Installer + Migrationen, ohne App-Quellcode).",
|
||||
"type": "module",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"version": "0.57.0",
|
||||
"released": "2026-10-11",
|
||||
"security": false,
|
||||
"summary": "Der Admin-Bereich weist jetzt auf neue TeamVis-Versionen hin. Keine Migration, keine neuen Pflicht-Variablen."
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
-- ====================================================================
|
||||
-- 0056_brand_font — eigene Marken-Schriftart (Branding)
|
||||
-- ====================================================================
|
||||
-- Der Mandant kann eine eigene Schrift hinterlegen (Google-Font-Name,
|
||||
-- kuratierte Auswahl oder Datei-Upload). DSGVO: die Font-Dateien werden
|
||||
-- SELF-HOSTED (im branding-assets-Bucket), NICHT von Google's CDN geladen.
|
||||
-- Angewandt auf die öffentlichen Flächen (--font-sans-Override + @font-face)
|
||||
-- und das QR-Wallpaper.
|
||||
|
||||
alter table public.site_settings
|
||||
add column if not exists brand_font_family text, -- CSS/SVG-Family, z.B. "Inter"
|
||||
add column if not exists brand_font_regular_url text, -- self-hosted TTF (400)
|
||||
add column if not exists brand_font_bold_url text, -- self-hosted TTF (700)
|
||||
add column if not exists brand_font_source text; -- 'google' | 'upload'
|
||||
|
||||
-- anon-Spalten-Grant neu setzen (Muster 0043/0055): die Font-Spalten sind
|
||||
-- KEINE Geheimnisse — die öffentliche Karte liest site_settings via anon und
|
||||
-- braucht sie. Also: Block-Liste = bisherige Geheimnisse (bleiben gesperrt),
|
||||
-- die neuen brand_font_*-Spalten werden dadurch 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,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';
|
||||
@@ -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$;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user