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
+8
View File
@@ -0,0 +1,8 @@
# Supabase
.branches
.temp
# dotenvx
.env.keys
.env.local
.env.*.local
+415
View File
@@ -0,0 +1,415 @@
# For detailed configuration reference documentation, visit:
# https://supabase.com/docs/guides/local-development/cli/config
# A string used to distinguish different Supabase projects on the same host. Defaults to the
# working directory name when running `supabase init`.
project_id = "APP"
[api]
enabled = true
# Port to use for the API URL.
port = 54321
# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
# endpoints. `public` and `graphql_public` schemas are included by default.
schemas = ["public", "graphql_public"]
# Extra schemas to add to the search_path of every request.
extra_search_path = ["public", "extensions"]
# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
# for accidental or malicious requests.
max_rows = 1000
[api.tls]
# Enable HTTPS endpoints locally using a self-signed certificate.
enabled = false
# Paths to self-signed certificate pair.
# cert_path = "../certs/my-cert.pem"
# key_path = "../certs/my-key.pem"
[db]
# Port to use for the local database URL.
port = 54322
# Port used by db diff command to initialize the shadow database.
shadow_port = 54320
# Maximum amount of time to wait for health check when starting the local database.
health_timeout = "2m"
# The database major version to use. This has to be the same as your remote database's. Run `SHOW
# server_version;` on the remote database to check.
major_version = 17
[db.pooler]
enabled = false
# Port to use for the local connection pooler.
port = 54329
# Specifies when a server connection can be reused by other clients.
# Configure one of the supported pooler modes: `transaction`, `session`.
pool_mode = "transaction"
# How many server connections to allow per user/database pair.
default_pool_size = 20
# Maximum number of client connections allowed.
max_client_conn = 100
# [db.vault]
# secret_key = "env(SECRET_VALUE)"
[db.migrations]
# If disabled, migrations will be skipped during a db push or reset.
enabled = true
# Specifies an ordered list of schema files that describe your database.
# Supports glob patterns relative to supabase directory: "./schemas/*.sql"
schema_paths = []
[db.seed]
# If enabled, seeds the database after migrations during a db reset.
enabled = true
# Specifies an ordered list of seed files to load during db reset.
# Supports glob patterns relative to supabase directory: "./seeds/*.sql"
sql_paths = ["./seed.sql"]
[db.network_restrictions]
# Enable management of network restrictions.
enabled = false
# List of IPv4 CIDR blocks allowed to connect to the database.
# Defaults to allow all IPv4 connections. Set empty array to block all IPs.
allowed_cidrs = ["0.0.0.0/0"]
# List of IPv6 CIDR blocks allowed to connect to the database.
# Defaults to allow all IPv6 connections. Set empty array to block all IPs.
allowed_cidrs_v6 = ["::/0"]
# Uncomment to reject non-secure connections to the database.
# [db.ssl_enforcement]
# enabled = true
[realtime]
enabled = true
# Bind realtime via either IPv4 or IPv6. (default: IPv4)
# ip_version = "IPv6"
# The maximum length in bytes of HTTP request headers. (default: 4096)
# max_header_length = 4096
[studio]
enabled = true
# Port to use for Supabase Studio.
port = 54323
# External URL of the API server that frontend connects to.
api_url = "http://127.0.0.1"
# OpenAI API Key to use for Supabase AI in the Supabase Studio.
openai_api_key = "env(OPENAI_API_KEY)"
# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
# are monitored, and you can view the emails that would have been sent from the web interface.
[inbucket]
enabled = true
# Port to use for the email testing server web interface.
port = 54324
# Uncomment to expose additional ports for testing user applications that send emails.
# smtp_port = 54325
# pop3_port = 54326
# admin_email = "admin@email.com"
# sender_name = "Admin"
[storage]
enabled = true
# The maximum file size allowed (e.g. "5MB", "500KB").
file_size_limit = "50MiB"
# Uncomment to configure local storage buckets
# [storage.buckets.images]
# public = false
# file_size_limit = "50MiB"
# allowed_mime_types = ["image/png", "image/jpeg"]
# objects_path = "./images"
# Allow connections via S3 compatible clients
[storage.s3_protocol]
enabled = true
# Image transformation API is available to Supabase Pro plan.
# [storage.image_transformation]
# enabled = true
# Store analytical data in S3 for running ETL jobs over Iceberg Catalog
# This feature is only available on the hosted platform.
[storage.analytics]
enabled = false
max_namespaces = 5
max_tables = 10
max_catalogs = 2
# Analytics Buckets is available to Supabase Pro plan.
# [storage.analytics.buckets.my-warehouse]
# Store vector embeddings in S3 for large and durable datasets
# This feature is only available on the hosted platform.
[storage.vector]
enabled = false
max_buckets = 10
max_indexes = 5
# Vector Buckets is available to Supabase Pro plan.
# [storage.vector.buckets.documents-openai]
[auth]
enabled = true
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
# in emails.
site_url = "http://localhost:8080"
# The public URL that Auth serves on. Defaults to the API external URL with `/auth/v1` appended.
# external_url = ""
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
additional_redirect_urls = [
"http://localhost:8080",
"http://localhost:8080/",
"http://localhost:5173",
"http://localhost:5173/",
"http://127.0.0.1:8080",
"http://127.0.0.1:5173",
]
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
jwt_expiry = 3600
# JWT issuer URL. If not set, defaults to auth.external_url.
# jwt_issuer = ""
# Path to JWT signing key. DO NOT commit your signing keys file to git.
# signing_keys_path = "./signing_keys.json"
# If disabled, the refresh token will never expire.
enable_refresh_token_rotation = true
# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
# Requires enable_refresh_token_rotation = true.
refresh_token_reuse_interval = 10
# Allow/disallow new user signups to your project.
enable_signup = true
# Allow/disallow anonymous sign-ins to your project.
enable_anonymous_sign_ins = false
# Allow/disallow testing manual linking of accounts
enable_manual_linking = false
# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more.
minimum_password_length = 6
# Passwords that do not meet the following requirements will be rejected as weak. Supported values
# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols`
password_requirements = ""
# Configure passkey sign-ins.
# [auth.passkey]
# enabled = false
# Configure WebAuthn relying party settings (required when passkey is enabled).
# [auth.webauthn]
# rp_display_name = "Supabase"
# rp_id = "localhost"
# rp_origins = ["http://127.0.0.1:3000"]
[auth.rate_limit]
# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled.
email_sent = 2
# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled.
sms_sent = 30
# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true.
anonymous_users = 30
# Number of sessions that can be refreshed in a 5 minute interval per IP address.
token_refresh = 150
# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users).
sign_in_sign_ups = 30
# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address.
token_verifications = 30
# Number of Web3 logins that can be made in a 5 minute interval per IP address.
web3 = 30
# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`.
# [auth.captcha]
# enabled = true
# provider = "hcaptcha"
# secret = ""
[auth.email]
# Allow/disallow new user signups via email to your project.
enable_signup = true
# If enabled, a user will be required to confirm any email change on both the old, and new email
# addresses. If disabled, only the new email is required to confirm.
double_confirm_changes = true
# If enabled, users need to confirm their email address before signing in.
enable_confirmations = false
# If enabled, users will need to reauthenticate or have logged in recently to change their password.
secure_password_change = false
# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email.
max_frequency = "1s"
# Number of characters used in the email OTP.
otp_length = 6
# Number of seconds before the email OTP expires (defaults to 1 hour).
otp_expiry = 3600
# Use a production-ready SMTP server
# [auth.email.smtp]
# enabled = true
# host = "smtp.sendgrid.net"
# port = 587
# user = "apikey"
# pass = "env(SENDGRID_API_KEY)"
# admin_email = "admin@email.com"
# sender_name = "Admin"
# Uncomment to customize email template
# [auth.email.template.invite]
# subject = "You have been invited"
# content_path = "./supabase/templates/invite.html"
# Uncomment to customize notification email template
# [auth.email.notification.password_changed]
# enabled = true
# subject = "Your password has been changed"
# content_path = "./templates/password_changed_notification.html"
[auth.sms]
# Allow/disallow new user signups via SMS to your project.
enable_signup = false
# If enabled, users need to confirm their phone number before signing in.
enable_confirmations = false
# Template for sending OTP to users
template = "Your code is {{ .Code }}"
# Controls the minimum amount of time that must pass before sending another sms otp.
max_frequency = "5s"
# Use pre-defined map of phone number to OTP for testing.
# [auth.sms.test_otp]
# 4152127777 = "123456"
# Configure logged in session timeouts.
# [auth.sessions]
# Force log out after the specified duration.
# timebox = "24h"
# Force log out if the user has been inactive longer than the specified duration.
# inactivity_timeout = "8h"
# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object.
# [auth.hook.before_user_created]
# enabled = true
# uri = "pg-functions://postgres/auth/before-user-created-hook"
# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
# [auth.hook.custom_access_token]
# enabled = true
# uri = "pg-functions://<database>/<schema>/<hook_name>"
# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.
[auth.sms.twilio]
enabled = false
account_sid = ""
message_service_sid = ""
# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead:
auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"
# Multi-factor-authentication is available to Supabase Pro plan.
[auth.mfa]
# Control how many MFA factors can be enrolled at once per user.
max_enrolled_factors = 10
# Control MFA via App Authenticator (TOTP)
[auth.mfa.totp]
enroll_enabled = false
verify_enabled = false
# Configure MFA via Phone Messaging
[auth.mfa.phone]
enroll_enabled = false
verify_enabled = false
otp_length = 6
template = "Your code is {{ .Code }}"
max_frequency = "5s"
# Configure MFA via WebAuthn
# [auth.mfa.web_authn]
# enroll_enabled = true
# verify_enabled = true
# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`,
# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`.
[auth.external.apple]
enabled = false
client_id = ""
# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
# Overrides the default auth callback URL derived from auth.external_url.
redirect_uri = ""
# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
# or any other third-party OIDC providers.
url = ""
# If enabled, the nonce check will be skipped. Required for local sign in with Google auth.
skip_nonce_check = false
# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address.
email_optional = false
# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard.
# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting.
[auth.web3.solana]
enabled = false
# Use Firebase Auth as a third-party provider alongside Supabase Auth.
[auth.third_party.firebase]
enabled = false
# project_id = "my-firebase-project"
# Use Auth0 as a third-party provider alongside Supabase Auth.
[auth.third_party.auth0]
enabled = false
# tenant = "my-auth0-tenant"
# tenant_region = "us"
# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth.
[auth.third_party.aws_cognito]
enabled = false
# user_pool_id = "my-user-pool-id"
# user_pool_region = "us-east-1"
# Use Clerk as a third-party provider alongside Supabase Auth.
[auth.third_party.clerk]
enabled = false
# Obtain from https://clerk.com/setup/supabase
# domain = "example.clerk.accounts.dev"
# OAuth server configuration
[auth.oauth_server]
# Enable OAuth server functionality
enabled = false
# Path for OAuth consent flow UI
authorization_url_path = "/oauth/consent"
# Allow dynamic client registration
allow_dynamic_registration = false
[edge_runtime]
enabled = true
# Supported request policies: `oneshot`, `per_worker`.
# `per_worker` (default) — enables hot reload during local development.
# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks).
policy = "per_worker"
# Port to attach the Chrome inspector for debugging edge functions.
inspector_port = 8083
# The Deno major version to use.
deno_version = 2
# [edge_runtime.secrets]
# secret_key = "env(SECRET_VALUE)"
[analytics]
enabled = true
port = 54327
# Configure one of the supported backends: `postgres`, `bigquery`.
backend = "postgres"
# Experimental features may be deprecated any time
[experimental]
# Configures Postgres storage engine to use OrioleDB (S3)
orioledb_version = ""
# Configures S3 bucket URL, eg. <bucket_name>.s3-<region>.amazonaws.com
s3_host = "env(S3_HOST)"
# Configures S3 bucket region, eg. us-east-1
s3_region = "env(S3_REGION)"
# Configures AWS_ACCESS_KEY_ID for S3 bucket
s3_access_key = "env(S3_ACCESS_KEY)"
# Configures AWS_SECRET_ACCESS_KEY for S3 bucket
s3_secret_key = "env(S3_SECRET_KEY)"
# [experimental.pgdelta]
# When enabled, pg-delta becomes the active engine for supported schema flows.
# enabled = false
# Directory under `supabase/` where declarative files are written.
# declarative_schema_path = "./database"
# JSON string passed through to pg-delta SQL formatting.
# format_options = "{\"keywordCase\":\"upper\",\"indent\":2,\"maxWidth\":80,\"commaStyle\":\"trailing\"}"
+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;