Release 0.8.0: Cloud-Variante (Supabase, Multi-Studio, Realtime, Web-Deploy)

Rapport ist jetzt dual: lokal (wie bisher) ODER Cloud auf eigenem Supabase-Server.
Beide Modi haben dieselben Funktionen, Cloud zusätzlich Multi-User + Live-Sync.

Storage-Architektur
- src/storage/adapter.js: einheitliche Promise-API, LocalStorage- und SupabaseAdapter
- src/storage/migrations.js: applyMigrations als reine Funktion, für beide Backends
- Konfig-driven: VITE_SUPABASE_URL im Production-Build → automatisch Cloud-Modus

Postgres-Schema (supabase/migrations/0001–0010)
- 29 Tabellen, multi-tenant via studio_id + Row-Level-Security
- Audit-Spalten (created_by/updated_by/at) + Trigger
- Seed-Trigger pro neuem Studio (Rollen, Templates, Absenz-Typen)
- Realtime-Publication für Live-Sync
- RPCs: ensure_profile, create_studio_with_admin (mit Personen-Sharing),
  list_studios, load_persons_for_studio, attach_user_to_studio

Cloud-Features (App)
- BackendChoice.jsx als Erst-Screen «Lokal oder Cloud»
- CloudSetup.jsx: 3-Schritt-Wizard für Erst-Einrichtung
- Login.jsx: Modus-Switcher + Server-URL + Studio-Dropdown + Passwort-Vergessen
- ResetPassword.jsx: empfängt Mail-Link-Klick via PASSWORD_RECOVERY-Event
- Realtime: Änderungen zwischen Browsern ohne Reload sichtbar
- Settings → System: Cloud-Verbindung, Studio-Switcher, weiteres Studio anlegen
- Settings → Team: Mitarbeiter via Email einladen (Admin-Aktion)
- Personen-Sharing: bei neuem Studio Personen aus anderen Studios übernehmen
- Reload-Resume: studio_id in sessionStorage, kein erneuter Login nötig

Web-Deploy
- deploy/docker-compose.yml + nginx.conf: dist/ via nginx-Container, Port 8080
- .env.production.example: Build-time Cloud-URL
- DEPLOY.md: Anleitung für LAN-only und extern via Nginx Proxy Manager

Doku
- README.md: Cloud-Variante prominent erklärt
- ARCHITECTURE.md: Storage-Adapter, Migrations, neue Views in Risiko-Tabelle
- DEPLOY.md: Schritt-für-Schritt für Mac Mini + NPM

Version-Bump auf 0.8.0 in package.json, src-tauri/tauri.conf.json, Cargo.toml.
Changelog-Entry im App.jsx-Modal (Karim sieht ihn beim ersten Start).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-23 19:08:00 +02:00
parent c71feddf63
commit 27b1057cd4
35 changed files with 4668 additions and 151 deletions
+761
View File
@@ -0,0 +1,761 @@
-- ============================================================================
-- RAPPORT — Initial Migration (Draft v1)
-- ============================================================================
-- Zweck: Übersetzt das localStorage-Schema (studio_data_v1) nach Postgres,
-- Multi-Tenant von Anfang an (jede Tabelle hat studio_id),
-- Auth via Supabase (auth.users), Files via Supabase Storage.
--
-- Stand: Draft auf Basis von constants.js + View-Audit (Projects, Quotes,
-- Invoices, Expenses, Employees, Persons, Protocols, Time).
-- Noch NICHT auf eine echte Supabase-Instanz angewendet.
--
-- Konventionen:
-- - snake_case für Spalten, camelCase-Felder aus dem Frontend werden
-- beim Adapter-Mapping übersetzt (z.B. ferienWochen → ferien_wochen).
-- - status/kind/mode als CHECK-Constraints (statt Postgres-ENUM), weil
-- leichter zu erweitern.
-- - Volatile / formverteilte Strukturen als JSONB (z.B. quote.sia,
-- protocol.participants), klassisch normalisiert wo joinable.
-- - RLS-Policies am Ende; bis dahin sind Tabellen offen — für Self-Hosted
-- Supabase muss `alter table … enable row level security` aktiv sein.
-- ============================================================================
-- ─── EXTENSIONS ────────────────────────────────────────────────────────────
create extension if not exists "pgcrypto"; -- gen_random_uuid()
create extension if not exists "citext"; -- case-insensitive email
-- ════════════════════════════════════════════════════════════════════════════
-- TENANT-LAYER: Studios + Membership
-- ════════════════════════════════════════════════════════════════════════════
create table studios (
id uuid primary key default gen_random_uuid(),
name text not null,
slug text unique not null,
created_at timestamptz not null default now()
);
-- Verlinkung User (Supabase auth.users) ↔ Studio mit Rolle pro Studio
create table studio_members (
studio_id uuid not null references studios(id) on delete cascade,
user_id uuid not null references auth.users(id) on delete cascade,
app_role_id text, -- FK später, app_roles ist studio-spezifisch
active boolean not null default true,
joined_at timestamptz not null default now(),
primary key (studio_id, user_id)
);
-- Profil-Erweiterung zu auth.users (Anzeigename etc.) — global, nicht studio-spezifisch
create table profiles (
id uuid primary key references auth.users(id) on delete cascade,
username citext unique not null,
display_name text not null,
created_at timestamptz not null default now()
);
-- ════════════════════════════════════════════════════════════════════════════
-- STUDIO-WEITE STAMMDATEN (Settings, Rollen, Templates)
-- ════════════════════════════════════════════════════════════════════════════
-- Eine Zeile pro Studio (ersetzt das Singleton-`settings`-Objekt)
create table studio_settings (
studio_id uuid primary key references studios(id) on delete cascade,
name text not null default 'Mein Studio',
address text,
street text, zip text, city text, country text default 'CH',
email citext, phone text,
iban text,
iban_type text default 'qr' check (iban_type in ('qr','normal')),
mwst_nr text,
mwst_rate numeric(5,2) default 8.1,
default_hourly_rate numeric(10,2) default 120,
default_wochenstunden numeric(5,2) default 35,
default_ferien_wochen numeric(4,1) default 5,
-- Volatile / formverteilte Konfig als JSONB:
formats jsonb not null default '{}'::jsonb, -- projectNumberFormat, invoiceNumberFormat, …
page_margins jsonb not null default '{}'::jsonb, -- pageMarginTop/Bottom/Left/Right
ui jsonb not null default '{}'::jsonb, -- autoPrint, logoSize, qrNewPage, pdfNameFormat
protokoll_type_abbr jsonb not null default '{}'::jsonb, -- {"Bausitzung":"BS", …}
closed_months int[] default '{}',
block_mai_tag boolean default true,
setup_completed boolean default false,
logo_url text, -- Supabase Storage Pfad
updated_at timestamptz not null default now()
);
-- Rate-Profile (PL/TS/BL/AS) — pro Studio, weil Stundensätze unterschiedlich
create table studio_roles (
studio_id uuid not null references studios(id) on delete cascade,
id text not null, -- "PL", "TS", … (innerhalb Studio eindeutig)
label text not null,
rate numeric(10,2) not null default 0,
sort int default 0,
primary key (studio_id, id)
);
-- App-Rollen (Permissions/Dashboard-Zuordnung) — pro Studio
create table app_roles (
studio_id uuid not null references studios(id) on delete cascade,
id text not null, -- "r-admin", "r-projektleiter", …
name text not null,
permissions text[], -- null = alle; sonst ["dashboard","projects",…]
dashboard_template_id text,
primary key (studio_id, id)
);
create table dashboard_templates (
studio_id uuid not null references studios(id) on delete cascade,
id text not null, -- "tpl-admin", …
name text not null,
is_public boolean default true,
layout jsonb not null, -- Row-/Widget-Struktur
primary key (studio_id, id)
);
alter table app_roles
add constraint app_roles_dashboard_fk
foreign key (studio_id, dashboard_template_id)
references dashboard_templates(studio_id, id) on delete set null;
alter table studio_members
add constraint studio_members_role_fk
foreign key (studio_id, app_role_id)
references app_roles(studio_id, id) on delete set null;
-- Absenz-Typen (Krankheit/Unfall/…) — pro Studio
create table absence_types (
studio_id uuid not null references studios(id) on delete cascade,
id text not null, -- "krankheit", "unfall", …
label text not null,
color text,
primary key (studio_id, id)
);
-- Brieftemplates ("Offerte", "Zahlungserinnerung") — pro Studio
create table letter_templates (
studio_id uuid not null references studios(id) on delete cascade,
id text not null, -- "offer", "reminder"
name text not null,
body text not null,
primary key (studio_id, id)
);
-- Feiertage — pro Studio (kantonal-spezifisch)
create table holidays (
studio_id uuid not null references studios(id) on delete cascade,
date date not null,
label text not null,
half_day boolean default false,
primary key (studio_id, date)
);
-- ════════════════════════════════════════════════════════════════════════════
-- PERSONEN (Kunden + Partner vereint, seit v0.5)
-- ────────────────────────────────────────────────────────────────────────────
-- Zwei Modi pro Person:
-- a) Studio-lokal: studio_id IS NOT NULL → klassisch pro Studio
-- b) Geteilt: studio_id IS NULL → lebt in person_studio_links,
-- sichtbar in allen verlinkten Studios
-- Default ist (a). Umstellung auf (b) ist eine User-Aktion in Stammdaten.
-- ════════════════════════════════════════════════════════════════════════════
create table persons (
id uuid primary key default gen_random_uuid(),
studio_id uuid references studios(id) on delete cascade, -- NULL = geteilt
name text not null,
person_type text, -- Planer-Typ: "Elektroplaner", "HLKSE-Planer", …
is_auftraggeber boolean not null default false,
is_partner boolean not null default false,
street text, zip text, city text, country text default 'CH',
email citext, phone text, website text,
note text,
contacts jsonb not null default '[]'::jsonb, -- [{id,name,position,phone,email}]
honorar_offers jsonb not null default '[]'::jsonb,
created_at timestamptz not null default now()
-- updated_at + created_by/updated_by werden im Audit-Block am Ende ergänzt
);
create index on persons (studio_id);
-- Sichtbarkeit für geteilte Personen (studio_id IS NULL).
-- Nur relevant, wenn ein User die Person später "globalisiert".
create table person_studio_links (
person_id uuid not null references persons(id) on delete cascade,
studio_id uuid not null references studios(id) on delete cascade,
primary_studio boolean default false, -- in welchem Studio wurde sie ursprünglich angelegt
linked_at timestamptz not null default now(),
primary key (person_id, studio_id)
);
create index on person_studio_links (studio_id);
-- ════════════════════════════════════════════════════════════════════════════
-- PROJEKTE
-- ════════════════════════════════════════════════════════════════════════════
create table projects (
id uuid primary key default gen_random_uuid(),
studio_id uuid not null references studios(id) on delete cascade,
number text, -- "2025/03" via Format-Template
name text not null,
client_id uuid references persons(id) on delete set null,
category text, -- "Wettbewerb", "Direktauftrag", … (siehe constants.PROJECT_TYPES)
billing_type text check (billing_type in ('stundensatz','pauschal')),
hourly_rate numeric(10,2),
budget numeric(14,2),
budget_hours numeric(10,2),
status text default 'aktiv' check (status in ('aktiv','pausiert','abgeschlossen')),
description text,
start_date date,
-- Aktivierte SIA-Phasen als Array (z.B. ["31","32","41","51","52","53"])
enabled_phases text[] not null default '{}',
-- Komplexe / formverteilte Strukturen als JSONB (siehe View-Audit):
positions jsonb not null default '[]'::jsonb, -- [{phaseId, …}]
custom_phases jsonb not null default '[]'::jsonb, -- [{id, label}]
project_contacts jsonb not null default '[]'::jsonb, -- [{contactId, personIds[]}]
internal_members jsonb not null default '[]'::jsonb, -- [{userId, role, …}]
created_at timestamptz not null default now(),
unique (studio_id, number)
);
create index on projects (studio_id, status);
create index on projects (client_id);
-- Projekt ↔ Offerten (mit Rolle aus linkedQuotes-Eintrag)
create table project_quote_links (
project_id uuid not null references projects(id) on delete cascade,
quote_id uuid not null, -- FK nach quotes (siehe unten)
role text,
primary key (project_id, quote_id)
);
-- ════════════════════════════════════════════════════════════════════════════
-- OFFERTEN
-- ════════════════════════════════════════════════════════════════════════════
create table quotes (
id uuid primary key default gen_random_uuid(),
studio_id uuid not null references studios(id) on delete cascade,
number text,
client_id uuid references persons(id) on delete set null,
project_id uuid references projects(id) on delete set null,
project_name text, -- snapshot wenn Projekt nicht (mehr) verlinkt
date date,
valid_until date,
mode text check (mode in ('sia','manual','free')),
mwst boolean default true,
notes text,
status text default 'entwurf' check (status in
('entwurf','gesendet','angenommen','abgelehnt','abgelaufen')),
-- Drei Kalkulations-Pfade — je nach mode wird einer befüllt:
sia_config jsonb, -- {baukosten, schwierigkeit, stundenansatz, phases[]}
manual_phases jsonb, -- [{phaseId, …}]
free_items jsonb, -- [{id, desc, qty, price}]
quote_roles jsonb, -- [{id, label, rate}] — Rate-Overrides pro Offerte
created_at timestamptz not null default now(),
unique (studio_id, number)
);
create index on quotes (studio_id, status);
create index on quotes (project_id);
alter table project_quote_links
add constraint project_quote_links_quote_fk
foreign key (quote_id) references quotes(id) on delete cascade;
-- ════════════════════════════════════════════════════════════════════════════
-- RECHNUNGEN
-- ════════════════════════════════════════════════════════════════════════════
create table invoices (
id uuid primary key default gen_random_uuid(),
studio_id uuid not null references studios(id) on delete cascade,
number text,
client_id uuid references persons(id) on delete set null,
contact_id uuid, -- optional: Kontaktperson aus persons.contacts (JSONB)
project_id uuid references projects(id) on delete set null,
quote_id uuid references quotes(id) on delete set null,
date date,
due_date date,
sent_date date,
paid_date date, -- gesetzt wenn status = 'bezahlt'
items jsonb not null default '[]'::jsonb, -- [{id,desc,qty,price,discount}]
mwst boolean default true,
mwst_rate numeric(5,2),
notes text,
status text default 'entwurf' check (status in
('entwurf','gesendet','bezahlt','überfällig')),
invoice_kind text check (invoice_kind in ('akonto','teilrechnung','schluss','voll')),
discount_type text default 'none' check (discount_type in ('none','percent','amount')),
discount_value numeric(14,2) default 0,
discount_label text,
-- Welche Zeit-/Spesen-Einträge in diese Rechnung gehen
entry_selections jsonb not null default '{}'::jsonb,
qr_reference text, -- 27-stellige Schweizer QR-Ref
created_at timestamptz not null default now(),
unique (studio_id, number)
);
create index on invoices (studio_id, status);
create index on invoices (project_id);
create index on invoices (client_id);
-- Mahnungen-Historie. Verifiziert: Frontend speichert inv.reminders[] mit
-- {nr, date, sentDate, daysPast}. Wird hier 1:1 abgebildet, damit jeder mit
-- Buchhaltungs-Zugriff den letzten Mahnungs-Stand sieht (z.B. "3× Mahnung,
-- zuletzt 15.03.2025"). UI-Hinweis-Box ("schick eine Mahnung") bleibt
-- localStorage — wie Dark Mode, per-Device-Setting.
create table invoice_reminders (
id uuid primary key default gen_random_uuid(),
invoice_id uuid not null references invoices(id) on delete cascade,
nr int not null check (nr between 1 and 9), -- 1. Erinnerung, 2./3. Mahnung
date date not null, -- Erstell-/Druckdatum
sent_date date, -- editierbar im Mahnungs-Modal
days_past int, -- Snapshot Tage überfällig zum Zeitpunkt
note text,
created_at timestamptz not null default now()
);
create index on invoice_reminders (invoice_id, nr);
-- ════════════════════════════════════════════════════════════════════════════
-- ZEITERFASSUNG
-- ════════════════════════════════════════════════════════════════════════════
create table time_entries (
id uuid primary key default gen_random_uuid(),
studio_id uuid not null references studios(id) on delete cascade,
employee_id uuid, -- FK nach employees (siehe unten)
project_id uuid references projects(id) on delete set null,
phase_id text, -- SIA-Phase z.B. "32"
position_id text, -- optional, sub-position
date date not null,
minutes int not null,
start_time time,
end_time time,
description text,
created_at timestamptz not null default now()
);
create index on time_entries (studio_id, date);
create index on time_entries (employee_id, date);
create index on time_entries (project_id, date);
-- ════════════════════════════════════════════════════════════════════════════
-- SPESEN & AUSGABEN
-- ════════════════════════════════════════════════════════════════════════════
create table expenses ( -- Mitarbeiterspesen (zur Rückerstattung)
id uuid primary key default gen_random_uuid(),
studio_id uuid not null references studios(id) on delete cascade,
employee_id uuid, -- FK nach employees
project_id uuid references projects(id) on delete set null,
date date not null,
category text, -- aus studio_settings.expense_categories
description text,
amount numeric(14,2) not null,
mwst_rate numeric(5,2),
incl_mwst boolean default true,
status text default 'offen' check (status in
('offen','genehmigt','auf nächsten Lohn','ausbezahlt')),
receipt_url text, -- Supabase Storage Pfad (statt Base64)
receipt_name text,
lohn_entry_id uuid, -- FK nach payroll_entries (gesetzt bei ausbezahlt)
created_at timestamptz not null default now()
);
create index on expenses (studio_id, status);
create index on expenses (employee_id, date);
create table internal_expenses ( -- Studio-Ausgaben (Miete, Software, …)
id uuid primary key default gen_random_uuid(),
studio_id uuid not null references studios(id) on delete cascade,
date date not null,
category text,
description text,
amount numeric(14,2) not null,
mwst_rate numeric(5,2),
incl_mwst boolean default true,
recurring boolean default false,
recurring_interval text check (recurring_interval in ('monatlich','quartalsweise','jährlich')),
receipt_url text,
created_at timestamptz not null default now()
);
create index on internal_expenses (studio_id, date);
-- ════════════════════════════════════════════════════════════════════════════
-- MITARBEITER (HR)
-- ════════════════════════════════════════════════════════════════════════════
create table employees (
id uuid primary key default gen_random_uuid(),
studio_id uuid not null references studios(id) on delete cascade,
name text not null,
personal_nr text,
pensum int check (pensum between 0 and 100), -- in Prozent
wochenstunden numeric(5,2),
ferien_wochen numeric(4,1),
pk_ag_satz numeric(5,2), -- Pensionskasse-AG-Satz
ferien_uebertrag_vorjahr jsonb not null default '{}'::jsonb, -- {year: days}
-- Brücke zu App-Login (optional — nicht jeder Mitarbeiter braucht Cloud-Account)
app_user_id uuid references profiles(id) on delete set null,
active boolean default true,
created_at timestamptz not null default now()
);
create index on employees (studio_id, active);
-- Jetzt die zirkulären FKs nachreichen:
alter table time_entries
add constraint time_entries_employee_fk
foreign key (employee_id) references employees(id) on delete set null;
alter table expenses
add constraint expenses_employee_fk
foreign key (employee_id) references employees(id) on delete set null;
create table absences (
id uuid primary key default gen_random_uuid(),
studio_id uuid not null references studios(id) on delete cascade,
employee_id uuid not null references employees(id) on delete cascade,
type_id text, -- FK auf absence_types(studio_id, id)
date date, -- Einzeltag-Variante
date_from date, -- Mehrtages-Variante
date_to date,
start_time time,
end_time time,
hours int,
minutes int,
note text,
status text default 'pending' check (status in ('pending','approved','rejected')),
created_at timestamptz not null default now(),
constraint absences_type_fk foreign key (studio_id, type_id)
references absence_types(studio_id, id) on delete set null
);
create index on absences (employee_id, date);
create table vacation_entries ( -- ferienEntries
id uuid primary key default gen_random_uuid(),
studio_id uuid not null references studios(id) on delete cascade,
employee_id uuid not null references employees(id) on delete cascade,
date_from date not null,
date_to date not null,
note text,
status text default 'pending' check (status in ('pending','approved','rejected')),
original_data jsonb, -- für pending-Anträge: Snapshot der Eingabe
created_at timestamptz not null default now()
);
create index on vacation_entries (employee_id, date_from);
create table payroll_entries ( -- lohnEntries
id uuid primary key default gen_random_uuid(),
studio_id uuid not null references studios(id) on delete cascade,
employee_id uuid not null references employees(id) on delete cascade,
year int not null,
month int not null check (month between 1 and 12),
brutto numeric(14,2),
ahv numeric(14,2),
alv numeric(14,2),
bvg numeric(14,2),
nbu numeric(14,2),
ktg numeric(14,2),
quellensteuer numeric(14,2),
spesen numeric(14,2),
bonus numeric(14,2),
netto numeric(14,2),
status text default 'entwurf',
paid_at date,
created_at timestamptz not null default now(),
unique (employee_id, year, month)
);
-- expenses.lohn_entry_id zeigt jetzt auf existierende Tabelle:
alter table expenses
add constraint expenses_lohn_entry_fk
foreign key (lohn_entry_id) references payroll_entries(id) on delete set null;
create table overtime_closings ( -- uberstundenAbschluss
id uuid primary key default gen_random_uuid(),
studio_id uuid not null references studios(id) on delete cascade,
employee_id uuid not null references employees(id) on delete cascade,
date date not null,
saldo_hours numeric(8,2),
created_at timestamptz not null default now()
);
-- ════════════════════════════════════════════════════════════════════════════
-- DOKUMENTE: Protokolle, Lieferscheine
-- ════════════════════════════════════════════════════════════════════════════
create table protocols (
id uuid primary key default gen_random_uuid(),
studio_id uuid not null references studios(id) on delete cascade,
number text, -- "2025-BS-01"
type text check (type in (
'Bausitzung','Planungssitzung','Baubesprechung','Jour fixe',
'Interne Sitzung','Kundensitzung','Abnahme','Sonstiges')),
location text,
project_id uuid references projects(id) on delete set null,
project_manual text, -- freie Eingabe falls kein verlinktes Projekt
participants jsonb not null default '[]'::jsonb, -- [{id,name,role,source,status}]
traktanden jsonb not null default '[]'::jsonb, -- [{id,nr,title,items:[{kind,text,…}]}]
next_date date,
verteiler text,
created_at timestamptz not null default now(),
unique (studio_id, number)
);
create index on protocols (studio_id, type);
create index on protocols (project_id);
create table delivery_notes (
id uuid primary key default gen_random_uuid(),
studio_id uuid not null references studios(id) on delete cascade,
number text,
date date,
client_id uuid references persons(id) on delete set null,
project_id uuid references projects(id) on delete set null,
notes text,
created_at timestamptz not null default now(),
unique (studio_id, number)
);
create table delivery_note_items (
id uuid primary key default gen_random_uuid(),
delivery_note_id uuid not null references delivery_notes(id) on delete cascade,
sort int default 0,
description text,
qty numeric(12,3) default 1,
unit text default 'Stk.',
note text
);
create index on delivery_note_items (delivery_note_id);
-- ════════════════════════════════════════════════════════════════════════════
-- PINNWAND (Blog-Posts)
-- ════════════════════════════════════════════════════════════════════════════
create table blog_posts (
id uuid primary key default gen_random_uuid(),
studio_id uuid not null references studios(id) on delete cascade,
author_id uuid references profiles(id) on delete set null,
category text,
title text,
body text,
pinned boolean default false,
created_at timestamptz not null default now()
);
create index on blog_posts (studio_id, created_at desc);
-- ════════════════════════════════════════════════════════════════════════════
-- AUDIT-SPALTEN: created_by / updated_by + Auto-Update via Trigger
-- ----------------------------------------------------------------------------
-- Bewusst nur auf "Daten"-Tabellen, nicht auf Stammdaten-Konfig (studio_roles,
-- app_roles, absence_types, holidays, letter_templates, dashboard_templates) —
-- die ändern sich selten, der Audit-Overhead lohnt nicht. Für Stammdaten
-- reicht `updated_at`.
--
-- Beim Insert/Update wird updated_at = now(), updated_by = auth.uid() gesetzt.
-- created_by wird beim Insert einmalig gesetzt und nie überschrieben.
-- ════════════════════════════════════════════════════════════════════════════
do $$
declare t text;
begin
for t in
select unnest(array[
'studios',
'persons','projects','quotes','invoices','invoice_reminders',
'time_entries','expenses','internal_expenses',
'employees','absences','vacation_entries','payroll_entries','overtime_closings',
'protocols','delivery_notes','delivery_note_items','blog_posts'
])
loop
execute format('alter table %I add column created_by uuid references auth.users(id);', t);
execute format('alter table %I add column updated_by uuid references auth.users(id);', t);
execute format('alter table %I add column updated_at timestamptz not null default now();', t);
end loop;
end$$;
create or replace function set_audit_fields() returns trigger
language plpgsql as $$
begin
if tg_op = 'INSERT' then
new.created_by := coalesce(new.created_by, auth.uid());
new.updated_by := coalesce(new.updated_by, auth.uid());
new.updated_at := now();
elsif tg_op = 'UPDATE' then
new.created_by := old.created_by; -- nie ändern
new.updated_by := auth.uid();
new.updated_at := now();
end if;
return new;
end$$;
do $$
declare t text;
begin
for t in
select unnest(array[
'studios',
'persons','projects','quotes','invoices','invoice_reminders',
'time_entries','expenses','internal_expenses',
'employees','absences','vacation_entries','payroll_entries','overtime_closings',
'protocols','delivery_notes','delivery_note_items','blog_posts'
])
loop
execute format(
'create trigger %I_audit before insert or update on %I
for each row execute function set_audit_fields();',
t, t
);
end loop;
end$$;
-- ════════════════════════════════════════════════════════════════════════════
-- ROW-LEVEL-SECURITY (RLS)
-- ============================================================================
-- Globale Policy: "User darf Zeile lesen/schreiben, wenn er Mitglied im
-- Studio (studio_id) der Zeile ist." Funktioniert für alle Tabellen mit
-- studio_id-Spalte. studios/studio_members/persons brauchen eigene Policies.
-- ════════════════════════════════════════════════════════════════════════════
-- Helper: prüft Mitgliedschaft des aktuellen Users in einem Studio
create or replace function is_studio_member(s_id uuid) returns boolean
language sql stable security definer as $$
select exists (
select 1 from studio_members
where studio_id = s_id
and user_id = auth.uid()
and active = true
);
$$;
-- Alle studio-bezogenen Tabellen
do $$
declare t text;
begin
for t in
select unnest(array[
'studio_settings','studio_roles','app_roles','dashboard_templates',
'absence_types','letter_templates','holidays',
'persons','projects','project_quote_links','quotes',
'invoices','invoice_reminders','time_entries',
'expenses','internal_expenses',
'employees','absences','vacation_entries','payroll_entries','overtime_closings',
'protocols','delivery_notes','delivery_note_items','blog_posts'
])
loop
execute format('alter table %I enable row level security;', t);
end loop;
end$$;
-- Tabellen mit direkter studio_id-Spalte: einheitliche Policy
-- (persons ist hier ausgenommen, weil studio_id NULL sein darf — siehe unten)
do $$
declare t text;
begin
for t in
select unnest(array[
'studio_settings','studio_roles','app_roles','dashboard_templates',
'absence_types','letter_templates','holidays',
'projects','quotes',
'invoices','time_entries',
'expenses','internal_expenses',
'employees','absences','vacation_entries','payroll_entries','overtime_closings',
'protocols','delivery_notes','blog_posts'
])
loop
execute format($f$
create policy %I_member_access on %I
for all
using (is_studio_member(studio_id))
with check (is_studio_member(studio_id));
$f$, t, t);
end loop;
end$$;
-- persons: zwei Sichtbarkeitspfade (studio-lokal ODER via person_studio_links)
alter table person_studio_links enable row level security;
create policy persons_member_access on persons
for all
using (
(studio_id is not null and is_studio_member(studio_id))
or exists (
select 1 from person_studio_links psl
where psl.person_id = persons.id
and is_studio_member(psl.studio_id)
)
)
with check (
(studio_id is not null and is_studio_member(studio_id))
or exists (
select 1 from person_studio_links psl
where psl.person_id = persons.id
and is_studio_member(psl.studio_id)
)
);
create policy person_studio_links_member_access on person_studio_links
for all
using (is_studio_member(studio_id))
with check (is_studio_member(studio_id));
-- Sub-Tabellen ohne eigene studio_id: Zugriff via Parent
create policy project_quote_links_member_access on project_quote_links
for all
using (
exists (select 1 from projects p
where p.id = project_quote_links.project_id
and is_studio_member(p.studio_id))
);
create policy invoice_reminders_member_access on invoice_reminders
for all
using (
exists (select 1 from invoices i
where i.id = invoice_reminders.invoice_id
and is_studio_member(i.studio_id))
);
create policy delivery_note_items_member_access on delivery_note_items
for all
using (
exists (select 1 from delivery_notes dn
where dn.id = delivery_note_items.delivery_note_id
and is_studio_member(dn.studio_id))
);
-- studios: User sieht nur Studios, in denen er Mitglied ist
alter table studios enable row level security;
create policy studios_member_access on studios
for select
using (is_studio_member(id));
-- studio_members: User sieht eigene Mitgliedschaften
alter table studio_members enable row level security;
create policy studio_members_self_access on studio_members
for select
using (user_id = auth.uid() or is_studio_member(studio_id));
-- profiles: Jeder authentifizierte User sieht alle Profile (Anzeigenamen)
alter table profiles enable row level security;
create policy profiles_authenticated_read on profiles
for select
using (auth.role() = 'authenticated');
create policy profiles_self_write on profiles
for update using (id = auth.uid());
-- ════════════════════════════════════════════════════════════════════════════
-- Nächste Migrations:
-- 0002_storage.sql — Supabase Storage Buckets (receipts, logos) + Policies
-- 0003_seed_defaults.sql — Pro neues Studio die Default-Rollen, Absenz-Typen,
-- Letter-Templates, Dashboard-Templates anlegen
-- ════════════════════════════════════════════════════════════════════════════
+73
View File
@@ -0,0 +1,73 @@
-- ============================================================================
-- RAPPORT — Storage Buckets (Supabase Storage / S3)
-- ============================================================================
-- Zweck: Datei-Uploads (Quittungen, Studio-Logos) liegen NICHT als Base64 in
-- der DB, sondern in Supabase Storage. Die DB hält nur den Pfad
-- (z.B. expenses.receipt_url = 'receipts/<studio_id>/2025/abc.pdf').
--
-- Konvention für Pfade: '<studio_id>/<jahr>/<datei>.<ext>'
-- → erste Path-Komponente = studio_id (für RLS)
--
-- Buckets sind PRIVATE — Zugriff nur über signierte URLs (zeitlich begrenzt).
-- ============================================================================
insert into storage.buckets (id, name, public)
values
('receipts', 'receipts', false),
('logos', 'logos', false)
on conflict (id) do nothing;
-- ────────────────────────────────────────────────────────────────────────────
-- RLS-Policies auf storage.objects
-- ────────────────────────────────────────────────────────────────────────────
-- Prinzip: erste Pfad-Komponente ist studio_id; Zugriff nur wenn Member.
-- `(storage.foldername(name))[1]` gibt die erste Pfad-Komponente zurück.
create policy "rapport_storage_read"
on storage.objects for select
using (
bucket_id in ('receipts','logos')
and is_studio_member( (storage.foldername(name))[1]::uuid )
);
create policy "rapport_storage_insert"
on storage.objects for insert
with check (
bucket_id in ('receipts','logos')
and is_studio_member( (storage.foldername(name))[1]::uuid )
);
create policy "rapport_storage_update"
on storage.objects for update
using (
bucket_id in ('receipts','logos')
and is_studio_member( (storage.foldername(name))[1]::uuid )
);
create policy "rapport_storage_delete"
on storage.objects for delete
using (
bucket_id in ('receipts','logos')
and is_studio_member( (storage.foldername(name))[1]::uuid )
);
-- ────────────────────────────────────────────────────────────────────────────
-- Hinweise für den Adapter (kein SQL, nur Doku):
-- ────────────────────────────────────────────────────────────────────────────
-- Upload (Frontend, in SupabaseAdapter):
-- const path = `${studioId}/${year}/${uuid()}.${ext}`
-- await supabase.storage.from('receipts').upload(path, file)
-- // Pfad in expenses.receipt_url speichern
--
-- Anzeige:
-- const { data } = await supabase.storage
-- .from('receipts')
-- .createSignedUrl(receipt_url, 60) // 60 Sekunden gültig
-- // <img src={data.signedUrl} />
--
-- Migration localStorage → Cloud (im Push-Wizard):
-- for jede expense mit receiptData (Base64):
-- blob = base64ToBlob(receiptData)
-- path = upload(blob)
-- row.receipt_url = path; delete row.receiptData
-- ============================================================================
+116
View File
@@ -0,0 +1,116 @@
-- ============================================================================
-- RAPPORT — Default-Stammdaten pro neuem Studio
-- ============================================================================
-- Wenn ein neues Studio angelegt wird (INSERT INTO studios), bekommt es
-- automatisch:
-- - eine studio_settings-Zeile (alle Defaults aus CREATE TABLE)
-- - 4 studio_roles (PL, TS, BL, AS)
-- - 3 dashboard_templates (admin, projektleiter, mitarbeiter)
-- - 3 app_roles (r-admin, r-projektleiter, r-mitarbeiter)
-- - 7 absence_types (Krankheit, Unfall, …)
-- - 2 letter_templates (Offerte, Zahlungserinnerung)
--
-- Quelle der Werte: src/constants.js (`defaultData`) + DEFAULT_ABSENZ_TYPES.
-- Wenn dort etwas geändert wird, hier nachziehen — und umgekehrt.
--
-- SECURITY DEFINER: Funktion läuft mit Postgres-Owner-Rechten, umgeht damit
-- die RLS-Policies (der gerade anlegende User ist noch nicht studio_member,
-- könnte sonst nichts einfügen).
-- ============================================================================
create or replace function seed_studio_defaults(s_id uuid)
returns void
language plpgsql
security definer
as $$
begin
-- 1. studio_settings (1 Zeile, alle Defaults aus CREATE TABLE)
insert into studio_settings (studio_id) values (s_id);
-- 2. studio_roles (Rate-Profile)
insert into studio_roles (studio_id, id, label, rate, sort) values
(s_id, 'PL', 'Projektleiter/in', 140, 1),
(s_id, 'TS', 'Technischer Support', 120, 2),
(s_id, 'BL', 'Bauleiter/in', 135, 3),
(s_id, 'AS', 'Administrativer Support', 120, 4);
-- 3. dashboard_templates (vor app_roles wegen FK)
insert into dashboard_templates (studio_id, id, name, is_public, layout) values
(s_id, 'tpl-admin', 'Administrator', true, $j$[
{"id":"dw-a1","cols":4,"minH":0,"widgets":["kpi-projekte","kpi-stunden","kpi-ausstehend","kpi-umsatz"]},
{"id":"dw-a2","cols":1,"minH":0,"widgets":["warnungen"]},
{"id":"dw-a3","cols":2,"minH":0,"widgets":["aktive-projekte","unverrechnete-stunden"]},
{"id":"dw-a4","cols":2,"minH":0,"widgets":["umsatz-sparkline","offene-offerten"]},
{"id":"dw-a5","cols":1,"minH":0,"widgets":["letzte-zeiteintraege"]}
]$j$::jsonb),
(s_id, 'tpl-projektleiter', 'Projektleiter', true, $j$[
{"id":"dw-p1","cols":2,"minH":0,"widgets":["kpi-projekte","kpi-stunden"]},
{"id":"dw-p2","cols":1,"minH":0,"widgets":["warnungen"]},
{"id":"dw-p3","cols":3,"minH":0,"widgets":["meine-projekte","team-auslastung","offene-offerten"]},
{"id":"dw-p4","cols":1,"minH":0,"widgets":["letzte-zeiteintraege"]}
]$j$::jsonb),
(s_id, 'tpl-mitarbeiter', 'Mitarbeiter', true, $j$[
{"id":"dw-m1","cols":3,"minH":0,"widgets":["kpi-stunden","ueberstunden","meine-ferien"]},
{"id":"dw-m2","cols":2,"minH":0,"widgets":["meine-projekte","stunden-woche"]},
{"id":"dw-m3","cols":1,"minH":0,"widgets":["meine-zeiteintraege"]}
]$j$::jsonb);
-- 4. app_roles (permissions=NULL bedeutet "alle erlaubt")
insert into app_roles (studio_id, id, name, permissions, dashboard_template_id) values
(s_id, 'r-admin', 'Administrator',
null,
'tpl-admin'),
(s_id, 'r-projektleiter', 'Projektleiter',
array['dashboard','projects','time','quotes','personen','mitarbeiter','settings'],
'tpl-projektleiter'),
(s_id, 'r-mitarbeiter', 'Mitarbeiter',
array['dashboard','projects','time','personen','settings'],
'tpl-mitarbeiter');
-- 5. absence_types (aus DEFAULT_ABSENZ_TYPES in constants.js)
insert into absence_types (studio_id, id, label, color) values
(s_id, 'krankheit', 'Krankheit', '#8a1a1a'),
(s_id, 'unfall', 'Unfall', '#b5621e'),
(s_id, 'intern', 'Intern', '#1a4e8a'),
(s_id, 'informatik', 'Informatik', '#555'),
(s_id, 'rechnungswesen', 'Rechnungswesen', '#7a6a00'),
(s_id, 'weiterbildung', 'Weiterbildung', '#2d6a4f'),
(s_id, 'militaer', 'Militär / Zivildienst', '#3d3d38');
-- 6. letter_templates
insert into letter_templates (studio_id, id, name, body) values
(s_id, 'offer', 'Offerte',
$b$Sehr geehrte/r {{client}}
Gerne unterbreiten wir Ihnen die Offerte für das Projekt «{{project}}».
[Leistungsumfang]
Honorar: CHF [Betrag]
Wir freuen uns auf die Zusammenarbeit.
Freundliche Grüsse$b$),
(s_id, 'reminder', 'Zahlungserinnerung',
$b$Sehr geehrte/r {{client}}
Bei einer Überprüfung unserer Buchhaltung stellen wir fest, dass die Rechnung [Nr.] vom [Datum] über CHF [Betrag] noch nicht beglichen ist.
Wir bitten Sie höflich, den offenen Betrag innert 10 Tagen zu überweisen.
Freundliche Grüsse$b$);
end$$;
-- ─── Trigger: bei jedem Studio-Insert die Defaults reinkippen ──────────────
create or replace function trg_studios_seed_defaults()
returns trigger
language plpgsql
as $$
begin
perform seed_studio_defaults(new.id);
return new;
end$$;
create trigger studios_seed_defaults
after insert on studios
for each row execute function trg_studios_seed_defaults();
+39
View File
@@ -0,0 +1,39 @@
-- ============================================================================
-- RAPPORT — Realtime-Subscriptions aktivieren
-- ============================================================================
-- Selfhosted-Supabase hat die `supabase_realtime` Publication standardmäßig
-- leer. Damit das Frontend Live-Updates bekommt (User A ändert → User B sieht
-- es ohne Reload), müssen die zu beobachtenden Tabellen explizit der
-- Publication beitreten.
--
-- Tenant-Layer (studios, studio_members) bewusst ausgenommen: ändert sich
-- selten und braucht keine Live-Sync zwischen Clients.
-- ============================================================================
alter publication supabase_realtime add table
studio_settings,
studio_roles,
app_roles,
dashboard_templates,
absence_types,
letter_templates,
holidays,
persons,
person_studio_links,
projects,
project_quote_links,
quotes,
invoices,
invoice_reminders,
time_entries,
expenses,
internal_expenses,
employees,
absences,
vacation_entries,
payroll_entries,
overtime_closings,
protocols,
delivery_notes,
delivery_note_items,
blog_posts;
@@ -0,0 +1,65 @@
-- ============================================================================
-- RAPPORT — RPC-Funktionen für Sign-Up / Studio-Anlage
-- ============================================================================
-- Zwei SECURITY-DEFINER-Funktionen, die der signUp-Flow im Frontend braucht:
--
-- 1. `ensure_profile(username, display_name)` — legt für den eingeloggten User
-- eine profiles-Zeile an (oder aktualisiert sie). Würde sonst an fehlender
-- INSERT-Policy scheitern.
--
-- 2. `create_studio_with_admin(name, slug)` — legt atomar Studio + Membership
-- als Admin für den eingeloggten User an. Seed-Trigger füllt die Defaults.
-- Liefert die studio_id zurück.
--
-- Beide laufen mit Postgres-Owner-Rechten und sind explizit von einem
-- authentifizierten User aufrufbar (Check via auth.uid()).
-- ============================================================================
create or replace function ensure_profile(p_username text, p_display_name text)
returns uuid
language plpgsql
security definer
as $$
declare
v_user_id uuid := auth.uid();
begin
if v_user_id is null then
raise exception 'Authentication required';
end if;
insert into profiles (id, username, display_name)
values (v_user_id, p_username, p_display_name)
on conflict (id) do update set
username = excluded.username,
display_name = excluded.display_name;
return v_user_id;
end;
$$;
create or replace function create_studio_with_admin(p_name text, p_slug text)
returns uuid
language plpgsql
security definer
as $$
declare
v_studio_id uuid;
v_user_id uuid := auth.uid();
begin
if v_user_id is null then
raise exception 'Authentication required';
end if;
insert into studios (name, slug) values (p_name, p_slug) returning id into v_studio_id;
-- seed_studio_defaults-Trigger läuft hier automatisch und füllt Stammdaten
insert into studio_members (studio_id, user_id, app_role_id)
values (v_studio_id, v_user_id, 'r-admin');
return v_studio_id;
end;
$$;
-- Sichtbarkeit: authentifizierte User dürfen diese Funktionen aufrufen.
-- (`security definer` reicht — der Owner ist `postgres`, der hat überall Rechte.)
grant execute on function ensure_profile(text, text) to authenticated;
grant execute on function create_studio_with_admin(text, text) to authenticated;
+28
View File
@@ -0,0 +1,28 @@
-- ============================================================================
-- RAPPORT — Public Studio-Liste für Login-Dropdown
-- ============================================================================
-- Wenn auf einer Supabase-Instanz mehrere Firmen / Studios gehostet sind,
-- soll der Login-Screen vor Email+Passwort einen Dropdown zeigen: «In welches
-- Studio möchten Sie sich einloggen?». Dafür braucht das Frontend eine Liste
-- aller Studios — ohne dass jemand bereits eingeloggt sein muss.
--
-- RLS verhindert das normalerweise (`studios_member_access` nur für Member).
-- Diese SECURITY-DEFINER-Funktion umgeht RLS und liefert nur die öffentlichen
-- Identitäts-Felder (name, slug). Keine Tenant-Daten, kein Risiko.
--
-- Trade-Off: Studio-Namen sind in dem Sinne «öffentlich» — wer die Server-URL
-- kennt, kann sehen welche Firmen hier hosten. Bei einem Selfhosted-Setup für
-- 1-3 befreundete Studios ist das akzeptabel; bei einer Public-SaaS-Instanz
-- wäre das ein Re-Design wert.
-- ============================================================================
create or replace function list_studios()
returns table(id uuid, name text, slug text)
language sql
security definer
stable
as $$
select id, name, slug from studios order by name;
$$;
grant execute on function list_studios() to anon, authenticated;
@@ -0,0 +1,83 @@
-- ============================================================================
-- RAPPORT — Personen-Sharing beim Studio-Anlegen
-- ============================================================================
-- Erweitert `create_studio_with_admin` um einen optionalen dritten Parameter:
-- eine Liste von Quell-Studio-IDs, deren Personen ins neue Studio übernommen
-- werden sollen.
--
-- Mechanik (siehe 0001 — Persons mit nullable studio_id + person_studio_links):
-- 1. Lokale Personen aus Quell-Studio werden globalisiert (studio_id = NULL)
-- und bekommen einen Link an das Quell-Studio (Sichtbarkeit bleibt erhalten).
-- 2. Alle Personen, die im Quell-Studio sichtbar sind, bekommen zusätzlich
-- einen Link an das neue Studio.
--
-- Security: User muss in allen Quell-Studios Member sein, sonst Exception.
-- ============================================================================
drop function if exists create_studio_with_admin(text, text);
create function create_studio_with_admin(
p_name text,
p_slug text,
p_share_persons_from uuid[] default '{}'
)
returns uuid
language plpgsql
security definer
as $$
declare
v_studio_id uuid;
v_user_id uuid := auth.uid();
v_source_id uuid;
begin
if v_user_id is null then
raise exception 'Authentication required';
end if;
-- Sicherheits-Check: User muss in allen Quell-Studios aktiver Member sein
if array_length(p_share_persons_from, 1) > 0 then
if exists (
select 1 from unnest(p_share_persons_from) src
where not exists (
select 1 from studio_members sm
where sm.user_id = v_user_id
and sm.studio_id = src
and sm.active = true
)
) then
raise exception 'You are not a member of all source studios';
end if;
end if;
-- Studio + Admin-Membership anlegen (seed_studio_defaults-Trigger feuert)
insert into studios (name, slug) values (p_name, p_slug) returning id into v_studio_id;
insert into studio_members (studio_id, user_id, app_role_id)
values (v_studio_id, v_user_id, 'r-admin');
-- Personen-Sharing pro Quell-Studio
if array_length(p_share_persons_from, 1) > 0 then
foreach v_source_id in array p_share_persons_from loop
-- Schritt 1: Lokale Personen des Quell-Studios globalisieren.
-- a) Link an Quell-Studio anlegen (primary_studio = true, weil sie dort ursprünglich entstanden)
insert into person_studio_links (person_id, studio_id, primary_studio)
select id, v_source_id, true
from persons
where studio_id = v_source_id
on conflict (person_id, studio_id) do nothing;
-- b) Personen globalisieren (studio_id auf NULL)
update persons set studio_id = NULL where studio_id = v_source_id;
-- Schritt 2: alle im Quell-Studio sichtbaren Personen auch dem neuen Studio zuordnen
insert into person_studio_links (person_id, studio_id)
select person_id, v_studio_id
from person_studio_links
where studio_id = v_source_id
on conflict (person_id, studio_id) do nothing;
end loop;
end if;
return v_studio_id;
end;
$$;
grant execute on function create_studio_with_admin(text, text, uuid[]) to authenticated;
+27
View File
@@ -0,0 +1,27 @@
-- ============================================================================
-- RAPPORT — Personen-Load mit Sharing-Support
-- ============================================================================
-- Direkter `select * from persons where studio_id = $X` sieht nur lokale
-- Personen. Geteilte Personen haben studio_id = NULL und ihre Sichtbarkeit
-- kommt aus `person_studio_links`. Diese Funktion vereint beide Quellen.
--
-- Kein SECURITY DEFINER — RLS bleibt aktiv, der User sieht nur, was er sehen
-- darf. Die Funktion ist ein bequemer Query-Wrapper, kein Privilege-Escalator.
-- ============================================================================
create or replace function load_persons_for_studio(p_studio_id uuid)
returns setof persons
language sql
stable
as $$
select p.* from persons p
where p.studio_id = p_studio_id
or exists (
select 1 from person_studio_links psl
where psl.person_id = p.id
and psl.studio_id = p_studio_id
)
order by p.name;
$$;
grant execute on function load_persons_for_studio(uuid) to authenticated;
@@ -0,0 +1,61 @@
-- ============================================================================
-- RAPPORT — Mitarbeiter einladen (Admin-Aktion)
-- ============================================================================
-- Two-Step-Flow (vom Frontend orchestriert):
-- 1. Admin ruft `supabase.auth.signUp(email, tempPassword)` mit einem
-- temporären Client (ohne Session-persist), damit Admin-Session nicht
-- "übernommen" wird. → liefert neue user_id.
-- 2. Admin ruft `attach_user_to_studio(user_id, studio_id, role, username, name)`
-- mit seinem eigenen Auth-Token. RPC prüft, dass Caller Admin im
-- Ziel-Studio ist, und legt Profil + Membership an.
--
-- Sicherheit: nur Admins eines Studios können dort Mitglieder hinzufügen.
-- `attach` ist idempotent (ON CONFLICT update), damit der Flow re-runnable ist.
-- ============================================================================
create or replace function attach_user_to_studio(
p_user_id uuid,
p_studio_id uuid,
p_app_role_id text,
p_username text,
p_display_name text
)
returns void
language plpgsql
security definer
as $$
declare
v_caller_id uuid := auth.uid();
begin
if v_caller_id is null then
raise exception 'Authentication required';
end if;
-- Caller muss Admin in Ziel-Studio sein
if not exists (
select 1 from studio_members
where user_id = v_caller_id
and studio_id = p_studio_id
and app_role_id = 'r-admin'
and active = true
) then
raise exception 'Only admins of the studio can invite members';
end if;
-- Profile
insert into profiles (id, username, display_name)
values (p_user_id, p_username, p_display_name)
on conflict (id) do update set
username = excluded.username,
display_name = excluded.display_name;
-- Membership (idempotent)
insert into studio_members (studio_id, user_id, app_role_id)
values (p_studio_id, p_user_id, p_app_role_id)
on conflict (studio_id, user_id) do update set
app_role_id = excluded.app_role_id,
active = true;
end;
$$;
grant execute on function attach_user_to_studio(uuid, uuid, text, text, text) to authenticated;
@@ -0,0 +1,79 @@
-- ============================================================================
-- RAPPORT — Studio-Name aus Init in studio_settings übernehmen
-- ============================================================================
-- Bisher hat `create_studio_with_admin` nur `studios.name` und `studios.slug`
-- gesetzt. `studio_settings.name` wurde vom Seed-Trigger als Default
-- ("Mein Studio") angelegt — was im Frontend dann als Studio-Header und
-- Sidebar-Label erscheint. Discrepanz zur User-Eingabe.
--
-- Fix: nach Seed-Trigger den Studio-Namen in `studio_settings` schreiben und
-- `setup_completed = true` setzen (Frontend nutzt das für Setup-Wizard-Check).
-- ============================================================================
drop function if exists create_studio_with_admin(text, text, uuid[]);
create function create_studio_with_admin(
p_name text,
p_slug text,
p_share_persons_from uuid[] default '{}'
)
returns uuid
language plpgsql
security definer
as $$
declare
v_studio_id uuid;
v_user_id uuid := auth.uid();
v_source_id uuid;
begin
if v_user_id is null then
raise exception 'Authentication required';
end if;
if array_length(p_share_persons_from, 1) > 0 then
if exists (
select 1 from unnest(p_share_persons_from) src
where not exists (
select 1 from studio_members sm
where sm.user_id = v_user_id
and sm.studio_id = src
and sm.active = true
)
) then
raise exception 'You are not a member of all source studios';
end if;
end if;
insert into studios (name, slug) values (p_name, p_slug) returning id into v_studio_id;
insert into studio_members (studio_id, user_id, app_role_id)
values (v_studio_id, v_user_id, 'r-admin');
-- NEU: Studio-Name + setup_completed in die settings übernehmen, damit
-- das Frontend nicht "Mein Studio" anzeigt und der Setup-Wizard nicht
-- erneut triggert.
update studio_settings
set name = p_name, setup_completed = true
where studio_id = v_studio_id;
if array_length(p_share_persons_from, 1) > 0 then
foreach v_source_id in array p_share_persons_from loop
insert into person_studio_links (person_id, studio_id, primary_studio)
select id, v_source_id, true
from persons
where studio_id = v_source_id
on conflict (person_id, studio_id) do nothing;
update persons set studio_id = NULL where studio_id = v_source_id;
insert into person_studio_links (person_id, studio_id)
select person_id, v_studio_id
from person_studio_links
where studio_id = v_source_id
on conflict (person_id, studio_id) do nothing;
end loop;
end if;
return v_studio_id;
end;
$$;
grant execute on function create_studio_with_admin(text, text, uuid[]) to authenticated;