2 Commits
Author SHA1 Message Date
TeamVis Release f53579d3a4 TeamVis Self-Host-Bundle v0.46.1 2026-08-05 08:44:10 +02:00
TeamVis Release 243713fe74 TeamVis Self-Host-Bundle v0.33.0 2026-07-01 17:18:02 +02:00
13 changed files with 336 additions and 3 deletions
+2 -2
View File
@@ -1,4 +1,4 @@
# TeamVis — Self-Hosting-Bundle (v0.32.1) # 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
@@ -36,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.32.1. Stand: TeamVis 0.46.1.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "teamvis-selfhost", "name": "teamvis-selfhost",
"version": "0.32.1", "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",
+41
View File
@@ -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';
+56
View File
@@ -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;