TeamVis Self-Host-Bundle v0.56.2

This commit is contained in:
TeamVis Release committed 2026-10-10 23:39:23 +02:00
1 parent f53579d3a4
commit 6da3cc2283
11 files changed
+432 -30

No files matched your search

+2 -2
View File
@@ -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.
+53 -3
View File
@@ -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 |
+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."
+64 -18
View File
@@ -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 ─────────────────────
+11 -6
View File
@@ -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
View File
@@ -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",
@@ -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;