since3-portal/lib/supabase/migrations/20260928092016_create_db_schema.sql
2026-09-28 15:53:52 +02:00

543 lines
No EOL
26 KiB
PL/PgSQL
Raw 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.

-- =====================================================================
-- Client-Portal – Datenbankschema (Supabase / PostgreSQL 15+)
-- Voraussetzung: public.profiles existiert bereits
-- (id, email, name, role, created_at; id -> auth.users.id)
-- Falls du bereits einen Trigger auf auth.users hast, VORHER droppen
-- (siehe Abschnitt 3), sonst wird das Profil doppelt angelegt.
-- =====================================================================
-- ---------------------------------------------------------------------
-- 0. Extensions & generische Helfer
-- ---------------------------------------------------------------------
-- Vault wird für verschlüsselte Passwort-Ablage in Abschnitt 5 gebraucht.
create extension if not exists supabase_vault;
create type public.payment_method as enum ('invoice', 'sepa_debit', 'card');
create type public.invoice_status as enum ('pending', 'paid', 'cancelled'); -- "fällig" wird abgeleitet (siehe View)
create type public.product_category as enum ('website', 'email', 'seo', 'other');
create type public.product_status as enum ('online', 'maintenance', 'offline');
create type public.task_status as enum ('in_progress', 'planned', 'done', 'cancelled');
create or replace function public.set_updated_at()
returns trigger language plpgsql set search_path = '' as $$
begin
new.updated_at = now();
return new;
end $$;
-- ---------------------------------------------------------------------
-- 1. Clients (Stammdaten)
-- ---------------------------------------------------------------------
create table public.clients (
id uuid primary key default gen_random_uuid(),
-- Firma
company_name text not null,
legal_form text not null, -- GmbH, UG, GbR, e.K., ...
industry text,
vat_id text, -- USt-IdNr
tax_number text, -- Steuernummer
commercial_register_number text, -- optional
logo_path text, -- Pfad im Storage-Bucket, keine öffentliche URL
-- Firmenadresse
street text not null,
postal_code text not null,
city text not null,
country char(2) not null default 'DE',
-- abweichende Rechnungsadresse (alles NULL = identisch mit Firmenadresse)
billing_street text,
billing_postal_code text,
billing_city text,
billing_country char(2),
-- Ansprechpartner
contact_name text not null,
contact_position text,
contact_email text not null,
contact_phone text,
created_by uuid default auth.uid(),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint billing_address_all_or_nothing check (
(billing_street is null and billing_postal_code is null and billing_city is null and billing_country is null)
or
(billing_street is not null and billing_postal_code is not null and billing_city is not null and billing_country is not null)
)
);
-- Zahlungsdaten separat: sensibler, eigene Rechte später einfach möglich
create table public.client_payment_details (
client_id uuid primary key references public.clients(id) on delete cascade,
payment_method public.payment_method not null default 'invoice',
account_holder text,
iban text,
bic text,
sepa_mandate_ref text,
sepa_mandate_date date,
card_customer_id text, -- NUR Token/Customer-ID des Zahlungsanbieters (z. B. Stripe), NIE Kartennummern
updated_at timestamptz not null default now(),
constraint iban_format check (iban is null or iban ~ '^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$'),
constraint sepa_needs_iban check (
payment_method <> 'sepa_debit' or (iban is not null and account_holder is not null)
)
);
-- ---------------------------------------------------------------------
-- 2. profiles erweitern (Login-User -> Client)
-- ---------------------------------------------------------------------
alter table public.profiles
add column if not exists client_id uuid references public.clients(id) on delete set null,
add column if not exists updated_at timestamptz not null default now();
-- Admins gehören zu keinem Client
alter table public.profiles
add constraint profiles_admin_has_no_client
check ((role = 'admin' and client_id is null) or role = 'client');
-- ---------------------------------------------------------------------
-- 3. Auth-Helfer + Trigger
-- ---------------------------------------------------------------------
create or replace function public.is_admin()
returns boolean language sql stable security definer set search_path = '' as $$
select exists (select 1 from public.profiles where id = auth.uid() and role = 'admin');
$$;
create or replace function public.current_client_id()
returns uuid language sql stable security definer set search_path = '' as $$
select client_id from public.profiles where id = auth.uid();
$$;
-- Gehärteter Trigger: Rolle ist IMMER 'client'. Admins werden manuell per SQL gesetzt.
-- client_id kommt aus raw_app_meta_data (nur per Service-Role/Admin-API setzbar),
-- NICHT aus raw_user_meta_data (die kann ein User beim Signup selbst befüllen).
create or replace function public.handle_new_user()
returns trigger language plpgsql security definer set search_path = '' as $$
begin
insert into public.profiles (id, email, name, role, client_id)
values (
new.id,
new.email,
coalesce(new.raw_user_meta_data->>'name', split_part(new.email, '@', 1)),
'client',
nullif(new.raw_app_meta_data->>'client_id', '')::uuid
);
return new;
end $$;
-- drop trigger if exists <dein_alter_trigger> on auth.users;
drop trigger if exists on_auth_user_created on auth.users;
create trigger on_auth_user_created
after insert on auth.users
for each row execute function public.handle_new_user();
-- ---------------------------------------------------------------------
-- 4. Rechnungen
-- ---------------------------------------------------------------------
-- Produkt-Vorlagen für Rechnungspositionen (nur Admin)
create table public.product_templates (
id uuid primary key default gen_random_uuid(),
name text not null,
description text,
unit_price numeric(12,2) not null check (unit_price >= 0), -- netto
tax_rate numeric(5,2) not null default 19.00 check (tax_rate between 0 and 100),
is_active boolean not null default true,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- Lückenloser Nummernkreis pro Jahr (nur über next_invoice_number() nutzbar)
create table public.invoice_counters (
year int primary key,
last_number int not null default 0
);
alter table public.invoice_counters enable row level security; -- bewusst keine Policies
create or replace function public.next_invoice_number()
returns text language plpgsql security definer set search_path = '' as $$
declare
y int := extract(year from current_date)::int;
n int;
begin
if not public.is_admin() then
raise exception 'forbidden' using errcode = '42501';
end if;
insert into public.invoice_counters as c (year, last_number)
values (y, 1)
on conflict (year) do update set last_number = c.last_number + 1
returning c.last_number into n;
return format('RE-%s-%s', y, lpad(n::text, 4, '0'));
end $$;
create table public.invoices (
id uuid primary key default gen_random_uuid(),
client_id uuid not null references public.clients(id) on delete restrict,
invoice_number text not null unique default public.next_invoice_number(),
invoice_date date not null default current_date,
due_date date not null,
status public.invoice_status not null default 'pending',
paid_at date,
-- Snapshot der Kundendaten zum Rechnungszeitpunkt (werden per Trigger befüllt)
company_name text not null,
vat_id text,
billing_street text not null,
billing_postal_code text not null,
billing_city text not null,
billing_country char(2) not null,
notes text,
created_by uuid references public.profiles(id) on delete set null default auth.uid(),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint due_after_invoice_date check (due_date >= invoice_date),
constraint paid_has_date check (status <> 'paid' or paid_at is not null)
);
create or replace function public.fill_invoice_snapshot()
returns trigger language plpgsql set search_path = '' as $$
declare c public.clients%rowtype;
begin
select * into c from public.clients where id = new.client_id;
new.company_name := coalesce(new.company_name, c.company_name);
new.vat_id := coalesce(new.vat_id, c.vat_id);
new.billing_street := coalesce(new.billing_street, c.billing_street, c.street);
new.billing_postal_code := coalesce(new.billing_postal_code, c.billing_postal_code, c.postal_code);
new.billing_city := coalesce(new.billing_city, c.billing_city, c.city);
new.billing_country := coalesce(new.billing_country, c.billing_country, c.country);
return new;
end $$;
create trigger fill_invoice_snapshot
before insert on public.invoices
for each row execute function public.fill_invoice_snapshot();
create table public.invoice_items (
id uuid primary key default gen_random_uuid(),
invoice_id uuid not null references public.invoices(id) on delete cascade,
position int not null,
product_template_id uuid references public.product_templates(id) on delete set null,
description text not null, -- Produktname (Snapshot aus Vorlage)
quantity numeric(10,2) not null default 1 check (quantity > 0),
unit_price numeric(12,2) not null, -- netto
tax_rate numeric(5,2) not null default 19.00 check (tax_rate between 0 and 100),
unique (invoice_id, position)
);
-- Summen + abgeleiteter Status "Fällig" (kein Cronjob nötig)
create view public.invoices_overview with (security_invoker = true) as
select
i.*,
coalesce(t.net_total, 0) as net_total,
coalesce(t.tax_total, 0) as tax_total,
coalesce(t.net_total, 0) + coalesce(t.tax_total, 0) as gross_total,
case when i.status = 'pending' and i.due_date < current_date
then 'overdue' else i.status::text end as display_status
from public.invoices i
left join lateral (
select sum(round(quantity * unit_price, 2)) as net_total,
sum(round(quantity * unit_price * tax_rate / 100, 2)) as tax_total
from public.invoice_items ii
where ii.invoice_id = i.id
) t on true;
-- ---------------------------------------------------------------------
-- 5. Passwörter (Supabase Vault = verschlüsselt at rest)
-- In der Tabelle liegt NIE das Passwort, nur die Vault-Referenz.
-- ---------------------------------------------------------------------
create table public.credentials (
id uuid primary key default gen_random_uuid(),
client_id uuid not null references public.clients(id) on delete cascade,
service text not null, -- Vercel, Hostinger, ...
username text not null, -- E-Mail / Username
vault_secret_id uuid not null, -- -> vault.secrets
created_by uuid references public.profiles(id) on delete set null default auth.uid(),
updated_by uuid references public.profiles(id) on delete set null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table public.credential_access_log (
id bigint generated always as identity primary key,
credential_id uuid not null, -- bewusst ohne FK: Log bleibt bei Löschung erhalten
user_id uuid,
action text not null default 'reveal',
created_at timestamptz not null default now()
);
create or replace function public.create_credential(
p_client_id uuid, p_service text, p_username text, p_password text
) returns uuid language plpgsql security definer set search_path = '' as $$
declare v_secret uuid; v_id uuid;
begin
if not public.is_admin() then raise exception 'forbidden' using errcode = '42501'; end if;
v_secret := vault.create_secret(p_password, 'cred_' || gen_random_uuid()::text, p_service);
insert into public.credentials (client_id, service, username, vault_secret_id, updated_by)
values (p_client_id, p_service, p_username, v_secret, auth.uid())
returning id into v_id;
return v_id;
end $$;
-- p_password = NULL -> Passwort bleibt unverändert
create or replace function public.update_credential(
p_id uuid, p_service text, p_username text, p_password text default null
) returns void language plpgsql security definer set search_path = '' as $$
declare v_secret uuid;
begin
if not public.is_admin() then raise exception 'forbidden' using errcode = '42501'; end if;
update public.credentials
set service = p_service, username = p_username, updated_by = auth.uid()
where id = p_id
returning vault_secret_id into v_secret;
if not found then raise exception 'credential not found'; end if;
if p_password is not null and p_password <> '' then
perform vault.update_secret(v_secret, p_password);
end if;
end $$;
-- Klartext nur über diese Funktion, jeder Abruf wird protokolliert.
-- Erlaubt: Admins + der zugehörige Client. (Client-Zugriff entfernen: "or v_client = ..." löschen)
create or replace function public.reveal_credential(p_id uuid)
returns text language plpgsql security definer set search_path = '' as $$
declare v_client uuid; v_secret uuid; v_pw text;
begin
select client_id, vault_secret_id into v_client, v_secret
from public.credentials where id = p_id;
if not found then raise exception 'credential not found'; end if;
if not (public.is_admin() or v_client = public.current_client_id()) then
raise exception 'forbidden' using errcode = '42501';
end if;
select decrypted_secret into v_pw from vault.decrypted_secrets where id = v_secret;
insert into public.credential_access_log (credential_id, user_id) values (p_id, auth.uid());
return v_pw;
end $$;
-- Vault-Eintrag aufräumen, wenn Credential (oder ganzer Client) gelöscht wird
create or replace function public.delete_vault_secret()
returns trigger language plpgsql security definer set search_path = '' as $$
begin
delete from vault.secrets where id = old.vault_secret_id;
return old;
end $$;
create trigger credentials_delete_vault_secret
after delete on public.credentials
for each row execute function public.delete_vault_secret();
revoke execute on function public.create_credential(uuid, text, text, text) from public, anon;
revoke execute on function public.update_credential(uuid, text, text, text) from public, anon;
revoke execute on function public.reveal_credential(uuid) from public, anon;
revoke execute on function public.next_invoice_number() from public, anon;
grant execute on function public.create_credential(uuid, text, text, text) to authenticated;
grant execute on function public.update_credential(uuid, text, text, text) to authenticated;
grant execute on function public.reveal_credential(uuid) to authenticated;
grant execute on function public.next_invoice_number() to authenticated;
-- ---------------------------------------------------------------------
-- 6. Dokumente (Metadaten in DB, Dateien in Storage)
-- ---------------------------------------------------------------------
create table public.folders (
id uuid primary key default gen_random_uuid(),
client_id uuid not null references public.clients(id) on delete cascade,
parent_id uuid, -- NULL = oberste Ebene
name text not null,
created_by uuid references public.profiles(id) on delete set null default auth.uid(),
created_at timestamptz not null default now(),
unique (id, client_id),
-- Ordner nur löschbar, wenn leer (kein ON DELETE-Verhalten = NO ACTION)
foreign key (parent_id, client_id) references public.folders (id, client_id),
unique nulls not distinct (client_id, parent_id, name)
);
create table public.documents (
id uuid primary key default gen_random_uuid(),
client_id uuid not null references public.clients(id) on delete cascade,
folder_id uuid, -- NULL = außerhalb von Ordnern
name text not null, -- Originaldateiname
storage_path text not null unique, -- {client_id}/{document_id}/{dateiname}
mime_type text,
size_bytes bigint check (size_bytes >= 0),
uploaded_by uuid references public.profiles(id) on delete set null default auth.uid(),
created_at timestamptz not null default now(),
foreign key (folder_id, client_id) references public.folders (id, client_id)
);
insert into storage.buckets (id, name, public)
values ('client-documents', 'client-documents', false)
on conflict (id) do nothing;
create policy "client_documents_select" on storage.objects for select to authenticated
using (bucket_id = 'client-documents'
and ((select public.is_admin())
or (storage.foldername(name))[1] = (select public.current_client_id())::text));
create policy "client_documents_insert" on storage.objects for insert to authenticated
with check (bucket_id = 'client-documents'
and ((select public.is_admin())
or (storage.foldername(name))[1] = (select public.current_client_id())::text));
create policy "client_documents_delete" on storage.objects for delete to authenticated
using (bucket_id = 'client-documents' and (select public.is_admin()));
-- ---------------------------------------------------------------------
-- 7. Info: Produkte, Vorgänge, interne Notizen
-- ---------------------------------------------------------------------
create table public.client_products (
id uuid primary key default gen_random_uuid(),
client_id uuid not null references public.clients(id) on delete cascade,
name text not null,
description text,
category public.product_category not null default 'website',
url text,
status public.product_status not null default 'online',
uptime_30d numeric(5,2) check (uptime_30d between 0 and 100), -- z. B. 99.98 (aus Monitoring-Tool)
uptime_updated_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (id, client_id)
);
create table public.product_tasks (
id uuid primary key default gen_random_uuid(),
client_id uuid not null references public.clients(id) on delete cascade,
product_id uuid not null,
name text not null,
description text,
status public.task_status not null default 'planned',
since date not null default current_date, -- "seit 3 Tagen" (in_progress) bzw. "ab 6. Okt." (planned)
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (id, client_id),
foreign key (product_id, client_id) references public.client_products (id, client_id) on delete cascade
);
-- Statuswechsel setzt "seit" auf heute, außer der Admin setzt das Datum im selben Update explizit
create or replace function public.reset_task_since()
returns trigger language plpgsql set search_path = '' as $$
begin
if new.status is distinct from old.status and new.since is not distinct from old.since then
new.since := current_date;
end if;
return new;
end $$;
create trigger reset_task_since
before update on public.product_tasks
for each row execute function public.reset_task_since();
-- Eigene Tabelle, damit Clients Notizen nie sehen (RLS arbeitet zeilen-, nicht spaltenweise)
create table public.internal_notes (
id uuid primary key default gen_random_uuid(),
client_id uuid not null references public.clients(id) on delete cascade,
product_id uuid,
task_id uuid,
body text not null,
created_by uuid references public.profiles(id) on delete set null default auth.uid(),
created_at timestamptz not null default now(),
foreign key (product_id, client_id) references public.client_products (id, client_id) on delete cascade,
foreign key (task_id, client_id) references public.product_tasks (id, client_id) on delete cascade
);
-- ---------------------------------------------------------------------
-- 8. Indizes & updated_at-Trigger
-- ---------------------------------------------------------------------
create index on public.profiles (client_id);
create index on public.invoices (client_id, invoice_date desc);
create index on public.invoice_items (invoice_id);
create index on public.credentials (client_id);
create index on public.folders (client_id, parent_id);
create index on public.documents (client_id, folder_id);
create index on public.client_products (client_id);
create index on public.product_tasks (product_id);
create index on public.product_tasks (client_id);
create index on public.internal_notes (client_id);
do $$
declare t text;
begin
foreach t in array array['profiles','clients','client_payment_details','product_templates',
'invoices','credentials','client_products','product_tasks'] loop
execute format('create trigger set_updated_at before update on public.%I
for each row execute function public.set_updated_at()', t);
end loop;
end $$;
-- ---------------------------------------------------------------------
-- 9. Row Level Security
-- ---------------------------------------------------------------------
-- Ohne Policy = kein Zugriff. Alles nur für eingeloggte User (nie anon).
-- 9a) Admin darf alles auf diesen Tabellen
do $$
declare t text;
begin
foreach t in array array['clients','client_payment_details','product_templates','invoice_items',
'folders','documents','client_products','product_tasks','internal_notes'] loop
execute format('alter table public.%I enable row level security', t);
execute format('create policy admin_all on public.%I for all to authenticated
using ((select public.is_admin())) with check ((select public.is_admin()))', t);
end loop;
end $$;
-- 9b) profiles: lesen = eigenes Profil oder Admin; ändern = nur Admin (Rolle nicht selbst änderbar)
alter table public.profiles enable row level security;
create policy profiles_select on public.profiles for select to authenticated
using (id = (select auth.uid()) or (select public.is_admin()));
create policy profiles_admin_update on public.profiles for update to authenticated
using ((select public.is_admin())) with check ((select public.is_admin()));
-- 9c) Rechnungen: Admin darf lesen/anlegen/ändern, aber NICHT hart löschen (-> Status 'cancelled' = Storno)
alter table public.invoices enable row level security;
create policy admin_select on public.invoices for select to authenticated using ((select public.is_admin()));
create policy admin_insert on public.invoices for insert to authenticated with check ((select public.is_admin()));
create policy admin_update on public.invoices for update to authenticated
using ((select public.is_admin())) with check ((select public.is_admin()));
-- 9d) Passwörter: Schreiben nur über die Funktionen (Abschnitt 5), Löschen nur Admin
alter table public.credentials enable row level security;
create policy credentials_select on public.credentials for select to authenticated
using ((select public.is_admin()) or client_id = (select public.current_client_id()));
create policy credentials_admin_delete on public.credentials for delete to authenticated
using ((select public.is_admin()));
alter table public.credential_access_log enable row level security;
create policy access_log_admin_select on public.credential_access_log for select to authenticated
using ((select public.is_admin()));
-- 9e) Clients sehen nur ihre eigenen Daten
create policy client_select on public.clients for select to authenticated
using (id = (select public.current_client_id()));
create policy client_select on public.client_payment_details for select to authenticated
using (client_id = (select public.current_client_id()));
create policy client_select on public.invoices for select to authenticated
using (client_id = (select public.current_client_id()) and status <> 'cancelled');
create policy client_select on public.invoice_items for select to authenticated
using (exists (select 1 from public.invoices i
where i.id = invoice_id
and i.client_id = (select public.current_client_id())
and i.status <> 'cancelled'));
create policy client_select on public.folders for select to authenticated
using (client_id = (select public.current_client_id()));
create policy client_select on public.documents for select to authenticated
using (client_id = (select public.current_client_id()));
create policy client_insert on public.documents for insert to authenticated
with check (client_id = (select public.current_client_id()) and uploaded_by = (select auth.uid()));
create policy client_select on public.client_products for select to authenticated
using (client_id = (select public.current_client_id()));
create policy client_select on public.product_tasks for select to authenticated
using (client_id = (select public.current_client_id()));
-- product_templates, internal_notes: für Clients bewusst keine Policy = unsichtbar