diff --git a/README.md b/README.md index 00d2abc..2dddd8d 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# TeamVis — Self-Hosting-Bundle (v0.46.1) +# TeamVis — Self-Hosting-Bundle (v0.56.2) 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.46.1. +Stand: TeamVis 0.56.2. diff --git a/deploy/selfhost/README.md b/deploy/selfhost/README.md index 000c38d..3b3573e 100644 --- a/deploy/selfhost/README.md +++ b/deploy/selfhost/README.md @@ -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//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/` (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//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//db-….dump ~/backups//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 | diff --git a/deploy/selfhost/backup.sh b/deploy/selfhost/backup.sh new file mode 100755 index 0000000..85a48cd --- /dev/null +++ b/deploy/selfhost/backup.sh @@ -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//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 diff --git a/deploy/selfhost/restore.sh b/deploy/selfhost/restore.sh new file mode 100755 index 0000000..f34478b --- /dev/null +++ b/deploy/selfhost/restore.sh @@ -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 `_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 " +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." diff --git a/deploy/selfhost/update.sh b/deploy/selfhost/update.sh index affa469..de5dc9e 100755 --- a/deploy/selfhost/update.sh +++ b/deploy/selfhost/update.sh @@ -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 @@ -64,6 +72,9 @@ 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 @@ -84,21 +95,56 @@ else ok "Aktualisiert: ${OLD:0:8} → ${NEW:0:8}" 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 - warn "Externe Supabase: bitte diese neuen Migrationen EINMAL in Supabase Studio (SQL-Editor) ausführen:" - for m in "${NEWMIG[@]}"; do say " $BUNDLE/$m"; done - read -r -p " Enter drücken, sobald sie eingespielt sind … " _ || true + 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 ───────────────────── diff --git a/docs/SELFHOST-QUICKSTART.md b/docs/SELFHOST-QUICKSTART.md index 8a2fb23..8089507 100644 --- a/docs/SELFHOST-QUICKSTART.md +++ b/docs/SELFHOST-QUICKSTART.md @@ -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. --- diff --git a/package.json b/package.json index 4d84347..8cfe10c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "teamvis-selfhost", - "version": "0.46.1", + "version": "0.56.2", "private": true, "description": "Self-Hosting-Bundle für TeamVis (Installer + Migrationen, ohne App-Quellcode).", "type": "module", diff --git a/supabase/migrations/0067_analytics_privacy.sql b/supabase/migrations/0067_analytics_privacy.sql new file mode 100644 index 0000000..b9e518e --- /dev/null +++ b/supabase/migrations/0067_analytics_privacy.sql @@ -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$; diff --git a/supabase/migrations/0068_dark_theme.sql b/supabase/migrations/0068_dark_theme.sql new file mode 100644 index 0000000..a39dee5 --- /dev/null +++ b/supabase/migrations/0068_dark_theme.sql @@ -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; diff --git a/supabase/migrations/0069_theme_default.sql b/supabase/migrations/0069_theme_default.sql new file mode 100644 index 0000000..d473177 --- /dev/null +++ b/supabase/migrations/0069_theme_default.sql @@ -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; diff --git a/supabase/migrations/0070_dark_brand.sql b/supabase/migrations/0070_dark_brand.sql new file mode 100644 index 0000000..53926c9 --- /dev/null +++ b/supabase/migrations/0070_dark_brand.sql @@ -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;