diff --git a/README.md b/README.md index 0891a46..00d2abc 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# TeamVis — Self-Hosting-Bundle (v0.33.0) +# TeamVis — Self-Hosting-Bundle (v0.46.1) 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.33.0. +Stand: TeamVis 0.46.1. diff --git a/package.json b/package.json index f5781fa..4d84347 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "teamvis-selfhost", - "version": "0.33.0", + "version": "0.46.1", "private": true, "description": "Self-Hosting-Bundle für TeamVis (Installer + Migrationen, ohne App-Quellcode).", "type": "module", diff --git a/supabase/migrations/0057_booking_slot_unique.sql b/supabase/migrations/0057_booking_slot_unique.sql new file mode 100644 index 0000000..dfee8bf --- /dev/null +++ b/supabase/migrations/0057_booking_slot_unique.sql @@ -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'; diff --git a/supabase/migrations/0058_legal_pages.sql b/supabase/migrations/0058_legal_pages.sql new file mode 100644 index 0000000..f5dc9d9 --- /dev/null +++ b/supabase/migrations/0058_legal_pages.sql @@ -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 $$; diff --git a/supabase/migrations/0059_accessibility.sql b/supabase/migrations/0059_accessibility.sql new file mode 100644 index 0000000..4dade55 --- /dev/null +++ b/supabase/migrations/0059_accessibility.sql @@ -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 $$; diff --git a/supabase/migrations/0060_push_subscriptions.sql b/supabase/migrations/0060_push_subscriptions.sql new file mode 100644 index 0000000..393c73c --- /dev/null +++ b/supabase/migrations/0060_push_subscriptions.sql @@ -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; diff --git a/supabase/migrations/0061_portal_api_tokens.sql b/supabase/migrations/0061_portal_api_tokens.sql new file mode 100644 index 0000000..052a93c --- /dev/null +++ b/supabase/migrations/0061_portal_api_tokens.sql @@ -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; diff --git a/supabase/migrations/0062_push_platform.sql b/supabase/migrations/0062_push_platform.sql new file mode 100644 index 0000000..b45ae83 --- /dev/null +++ b/supabase/migrations/0062_push_platform.sql @@ -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) +); diff --git a/supabase/migrations/0063_lead_contact_fields.sql b/supabase/migrations/0063_lead_contact_fields.sql new file mode 100644 index 0000000..7f2eaa1 --- /dev/null +++ b/supabase/migrations/0063_lead_contact_fields.sql @@ -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; diff --git a/supabase/migrations/0064_push_expo_token_index_fix.sql b/supabase/migrations/0064_push_expo_token_index_fix.sql new file mode 100644 index 0000000..6dac42f --- /dev/null +++ b/supabase/migrations/0064_push_expo_token_index_fix.sql @@ -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); diff --git a/supabase/migrations/0065_lead_source_kind_reception.sql b/supabase/migrations/0065_lead_source_kind_reception.sql new file mode 100644 index 0000000..9d49bbc --- /dev/null +++ b/supabase/migrations/0065_lead_source_kind_reception.sql @@ -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'); diff --git a/supabase/migrations/0066_push_preferences.sql b/supabase/migrations/0066_push_preferences.sql new file mode 100644 index 0000000..a60798e --- /dev/null +++ b/supabase/migrations/0066_push_preferences.sql @@ -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;