Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f53579d3a4 | ||
|
|
243713fe74 | ||
|
|
f1256efc38 | ||
|
|
cc37e7ac6b | ||
|
|
1f6c412ee8 |
@@ -1,4 +1,4 @@
|
|||||||
# TeamVis — Self-Hosting-Bundle (v0.31.0)
|
# TeamVis — Self-Hosting-Bundle (v0.46.1)
|
||||||
|
|
||||||
Dieses Bundle enthält alles zum **Betreiben** von TeamVis auf eigener
|
Dieses Bundle enthält alles zum **Betreiben** von TeamVis auf eigener
|
||||||
Infrastruktur — **keinen** App-Quellcode. Die App selbst kommt als fertiges
|
Infrastruktur — **keinen** App-Quellcode. Die App selbst kommt als fertiges
|
||||||
@@ -12,6 +12,17 @@ docker login git.zfx.services # Benutzer: teamvis-pull · Passwort: <Im
|
|||||||
bash deploy/selfhost/install.sh # bei der DB-Frage Modus 2 = alles mitinstallieren
|
bash deploy/selfhost/install.sh # bei der DB-Frage Modus 2 = alles mitinstallieren
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Aktualisieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash deploy/selfhost/update.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Macht ein DB-Backup, holt neue Migrationen + das aktuelle Image und spielt **nur
|
||||||
|
die neu hinzugekommenen** Migrationen ein. **Wichtig:** Updates immer über
|
||||||
|
`update.sh` laufen lassen — nicht selbst `git pull`, sonst können Migrationen
|
||||||
|
übersprungen werden.
|
||||||
|
|
||||||
Vollständige Schritt-für-Schritt-Anleitung (von der nackten VM bis live):
|
Vollständige Schritt-für-Schritt-Anleitung (von der nackten VM bis live):
|
||||||
**`docs/SELFHOST-QUICKSTART.md`**. Hintergrund & Varianten:
|
**`docs/SELFHOST-QUICKSTART.md`**. Hintergrund & Varianten:
|
||||||
`deploy/selfhost/README.md` · Detail-Runbook: `docs/NEUKUNDE.md`.
|
`deploy/selfhost/README.md` · Detail-Runbook: `docs/NEUKUNDE.md`.
|
||||||
@@ -25,4 +36,4 @@ Vollständige Schritt-für-Schritt-Anleitung (von der nackten VM bis live):
|
|||||||
| `supabase/migrations/` | Datenbank-Schema (DDL) |
|
| `supabase/migrations/` | Datenbank-Schema (DDL) |
|
||||||
| `docs/` | Anleitungen |
|
| `docs/` | Anleitungen |
|
||||||
|
|
||||||
Stand: TeamVis 0.31.0.
|
Stand: TeamVis 0.46.1.
|
||||||
|
|||||||
Executable
+112
@@ -0,0 +1,112 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =====================================================================
|
||||||
|
# TeamVis — Update einer Self-Host-Instanz
|
||||||
|
# =====================================================================
|
||||||
|
# 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)
|
||||||
|
# 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.
|
||||||
|
#
|
||||||
|
# Aufruf: bash deploy/selfhost/update.sh [instanz-verzeichnis]
|
||||||
|
# (ohne Argument: das Instanz-Verzeichnis wird automatisch erkannt, wenn
|
||||||
|
# es genau eines gibt.)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
c_bold=$'\033[1m'; c_grn=$'\033[32m'; c_red=$'\033[31m'; c_yel=$'\033[33m'; c_rst=$'\033[0m'
|
||||||
|
say() { printf '%s\n' "$*"; }
|
||||||
|
head() { printf '\n%s%s%s\n' "$c_bold" "$*" "$c_rst"; }
|
||||||
|
ok() { printf '%s✓%s %s\n' "$c_grn" "$c_rst" "$*"; }
|
||||||
|
warn() { printf '%s!%s %s\n' "$c_yel" "$c_rst" "$*"; }
|
||||||
|
die() { printf '%s✗ %s%s\n' "$c_red" "$*" "$c_rst" >&2; exit 1; }
|
||||||
|
|
||||||
|
BUNDLE="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
cd "$BUNDLE"
|
||||||
|
[ -d .git ] || die "Kein git-Repo in $BUNDLE — Updates laufen über 'git pull'. Bitte das Bundle per 'git clone' beziehen."
|
||||||
|
command -v docker >/dev/null || die "docker fehlt."
|
||||||
|
|
||||||
|
# ── Instanz-Verzeichnis bestimmen (enthält docker-compose.yml + .env) ──
|
||||||
|
INSTANCE="${1:-}"
|
||||||
|
if [ -z "$INSTANCE" ]; then
|
||||||
|
mapfile -t cands < <(
|
||||||
|
for d in "$BUNDLE"/*/; do
|
||||||
|
[ -f "${d}docker-compose.yml" ] && [ -f "${d}.env" ] && printf '%s\n' "${d%/}"
|
||||||
|
done
|
||||||
|
)
|
||||||
|
if [ "${#cands[@]}" -eq 1 ]; then
|
||||||
|
INSTANCE="${cands[0]}"
|
||||||
|
elif [ "${#cands[@]}" -eq 0 ]; then
|
||||||
|
die "Kein Instanz-Verzeichnis gefunden. Bitte als Argument angeben: bash deploy/selfhost/update.sh <verzeichnis>"
|
||||||
|
else
|
||||||
|
say "Mehrere Instanzen gefunden:"; printf ' %s\n' "${cands[@]}"
|
||||||
|
die "Bitte das gewünschte Verzeichnis als Argument angeben."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
[ -f "$INSTANCE/docker-compose.yml" ] || die "In $INSTANCE liegt keine docker-compose.yml."
|
||||||
|
ok "Instanz: $INSTANCE"
|
||||||
|
|
||||||
|
# Modus erkennen: eigener db-Service (Modus 2) oder externe Supabase (Modus 1).
|
||||||
|
MODE2=0
|
||||||
|
grep -qE '^[[:space:]]+db:' "$INSTANCE/docker-compose.yml" && MODE2=1
|
||||||
|
|
||||||
|
POSTGRES_DB="$(grep -E '^POSTGRES_DB=' "$INSTANCE/.env" 2>/dev/null | cut -d= -f2- || true)"
|
||||||
|
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
|
||||||
|
head "1) Datenbank-Backup"
|
||||||
|
ts="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
dump="$INSTANCE/backup-db-$ts.sql"
|
||||||
|
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
|
||||||
|
warn "Externe Supabase (Modus 1): Backup macht dein Supabase-Anbieter. Kein lokaler Dump."
|
||||||
|
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
|
||||||
|
ok "Schon aktuell (keine neuen Commits)."
|
||||||
|
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"
|
||||||
|
done
|
||||||
|
ok "${#NEWMIG[@]} Migration(en) eingespielt."
|
||||||
|
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
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 4. Neues Image ziehen + Container neu starten ─────────────────────
|
||||||
|
head "4) App aktualisieren"
|
||||||
|
( 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-)"
|
||||||
|
[ -n "$SITE_URL" ] && say " $SITE_URL"
|
||||||
|
say " Bei Problemen Logs prüfen: ( cd $INSTANCE && docker compose logs -f app )"
|
||||||
+12
-3
@@ -216,12 +216,21 @@ Patch + Minor sind DB-rückwärtskompatibel (altes Image läuft mit neuer DB).
|
|||||||
Image-Tag in Production **immer pinnen** (`teamvis:0.11.6`), nicht `:latest` —
|
Image-Tag in Production **immer pinnen** (`teamvis:0.11.6`), nicht `:latest` —
|
||||||
sonst zieht ein `compose pull` ungewollt einen Major-Sprung.
|
sonst zieht ein `compose pull` ungewollt einen Major-Sprung.
|
||||||
|
|
||||||
**Standard-Update:**
|
**Standard-Update (Self-Host-Bundle, empfohlen):**
|
||||||
|
```bash
|
||||||
|
# Im Bundle-Verzeichnis (teamvis-selfhost) — erledigt Backup, neue Migrationen
|
||||||
|
# (nur die NEUEN, git als Ledger) und Image-Update in einem Schritt:
|
||||||
|
bash deploy/selfhost/update.sh
|
||||||
|
```
|
||||||
|
> Wichtig: Updates **immer** über `update.sh` — nicht selbst `git pull`, sonst
|
||||||
|
> können Migrationen übersprungen werden.
|
||||||
|
|
||||||
|
**Manuell (externe Supabase ohne psql-Zugang / Sonderfall):**
|
||||||
```bash
|
```bash
|
||||||
# 1. Backup (siehe Abschnitt 8)
|
# 1. Backup (siehe Abschnitt 8)
|
||||||
# 2. Release-Notes / CHANGELOG.md prüfen
|
# 2. Release-Notes / CHANGELOG.md prüfen
|
||||||
# 3. fehlende Migration(en) aus supabase/migrations/ einspielen (nur die neuen!)
|
# 3. NEUE Migration(en) aus supabase/migrations/ in Supabase Studio einspielen
|
||||||
# 4. Tag im docker-compose.yml bumpen
|
# 4. Image-Tag aktualisieren
|
||||||
docker compose pull && docker compose up -d --force-recreate
|
docker compose pull && docker compose up -d --force-recreate
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "teamvis-selfhost",
|
"name": "teamvis-selfhost",
|
||||||
"version": "0.31.0",
|
"version": "0.46.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Self-Hosting-Bundle für TeamVis (Installer + Migrationen, ohne App-Quellcode).",
|
"description": "Self-Hosting-Bundle für TeamVis (Installer + Migrationen, ohne App-Quellcode).",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
-- ====================================================================
|
||||||
|
-- 0055_smtp_settings — SMTP-Konfiguration über die Admin-UI
|
||||||
|
-- ====================================================================
|
||||||
|
-- Bisher kam SMTP ausschließlich aus ENV-Variablen (lib/email.ts →
|
||||||
|
-- process.env.SMTP_*). Self-Host-Kunden mussten dafür die .env editieren +
|
||||||
|
-- Container neu starten. Jetzt pro Mandant in site_settings (Admin → Mail)
|
||||||
|
-- pflegbar — mit ENV-Fallback (bestehende ENV-Setups laufen unverändert).
|
||||||
|
--
|
||||||
|
-- Vorrang in lib/email.ts: ist smtp_host in der DB gesetzt → DB-Konfig,
|
||||||
|
-- sonst ENV. smtp_pass/-user sind Geheimnisse → NICHT für anon lesbar.
|
||||||
|
|
||||||
|
alter table public.site_settings
|
||||||
|
add column if not exists smtp_host text,
|
||||||
|
add column if not exists smtp_port integer,
|
||||||
|
add column if not exists smtp_secure boolean,
|
||||||
|
add column if not exists smtp_user text,
|
||||||
|
add column if not exists smtp_pass text,
|
||||||
|
add column if not exists smtp_from_email text,
|
||||||
|
add column if not exists smtp_from_name text;
|
||||||
|
|
||||||
|
-- anon-Spalten-Grant neu setzen (Muster aus 0043): anon Vollzugriff entziehen,
|
||||||
|
-- dann ALLE Spalten AUSSER der Geheimnis-/SMTP-Block-Liste freigeben.
|
||||||
|
-- WICHTIG: Block-Liste = die Original-Geheimnisse aus 0043 + alle smtp_*,
|
||||||
|
-- sonst würden die in 0043 geschützten Keys wieder anon-lesbar.
|
||||||
|
revoke all on public.site_settings from anon;
|
||||||
|
|
||||||
|
do $$
|
||||||
|
declare
|
||||||
|
v_cols text;
|
||||||
|
begin
|
||||||
|
select string_agg(quote_ident(column_name), ', ')
|
||||||
|
into v_cols
|
||||||
|
from information_schema.columns
|
||||||
|
where table_schema = 'public'
|
||||||
|
and table_name = 'site_settings'
|
||||||
|
and column_name not in (
|
||||||
|
-- Geheimnis-Spalten aus 0043 (weiter geschützt):
|
||||||
|
'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',
|
||||||
|
-- neu: SMTP-Konfiguration (Zugangsdaten + Infrastruktur, kein anon-Bedarf):
|
||||||
|
'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,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;
|
||||||
Reference in New Issue
Block a user