mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 21:41:16 +00:00
543 lines
No EOL
26 KiB
PL/PgSQL
543 lines
No EOL
26 KiB
PL/PgSQL
-- =====================================================================
|
||
-- 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 |