Files
karim 27b1057cd4 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>
2026-05-23 19:08:00 +02:00

762 lines
37 KiB
PL/PgSQL
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- ============================================================================
-- 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
-- ════════════════════════════════════════════════════════════════════════════