/* * ------------------------------------------------------- * CMS Foundation Schema * Phase 1: Enums, permissions, account_settings, audit_log, * GDPR register, file metadata, storage buckets. * ------------------------------------------------------- */ -- ===================================================== -- 1. New CMS Enums -- ===================================================== create type public.cms_field_type as enum( 'text', 'textarea', 'richtext', 'checkbox', 'radio', 'hidden', 'select', 'password', 'file', 'date', 'time', 'decimal', 'integer', 'email', 'phone', 'url', 'currency', 'iban', 'color', 'computed' ); create type public.cms_module_status as enum( 'active', 'inactive', 'archived' ); create type public.cms_record_status as enum( 'active', 'locked', 'deleted', 'archived' ); create type public.gdpr_legal_basis as enum( 'consent', 'contract', 'legal_obligation', 'vital_interest', 'public_interest', 'legitimate_interest' ); create type public.cms_account_type as enum( 'verein', 'vhs', 'hotel', 'kommune', 'generic' ); create type public.audit_action as enum( 'insert', 'update', 'delete', 'lock' ); -- ===================================================== -- 2. Extend app_permissions with CMS values -- ===================================================== -- ALTER TYPE … ADD VALUE cannot run inside a transaction in -- older Postgres, but Supabase migrations run outside -- explicit transactions by default. alter type public.app_permissions add value if not exists 'modules.read'; alter type public.app_permissions add value if not exists 'modules.write'; alter type public.app_permissions add value if not exists 'modules.delete'; alter type public.app_permissions add value if not exists 'modules.insert'; alter type public.app_permissions add value if not exists 'modules.lock'; alter type public.app_permissions add value if not exists 'modules.import'; alter type public.app_permissions add value if not exists 'modules.export'; alter type public.app_permissions add value if not exists 'modules.print'; alter type public.app_permissions add value if not exists 'modules.manage'; alter type public.app_permissions add value if not exists 'members.read'; alter type public.app_permissions add value if not exists 'members.write'; alter type public.app_permissions add value if not exists 'courses.read'; alter type public.app_permissions add value if not exists 'courses.write'; alter type public.app_permissions add value if not exists 'bookings.read'; alter type public.app_permissions add value if not exists 'bookings.write'; alter type public.app_permissions add value if not exists 'finance.read'; alter type public.app_permissions add value if not exists 'finance.write'; alter type public.app_permissions add value if not exists 'finance.sepa'; alter type public.app_permissions add value if not exists 'documents.generate'; alter type public.app_permissions add value if not exists 'newsletter.send'; -- ===================================================== -- 3. account_settings — extends accounts per tenant -- ===================================================== create table if not exists public.account_settings ( account_id uuid primary key references public.accounts(id) on delete cascade, -- Organisation type account_type public.cms_account_type not null default 'generic', -- Org identity org_name text, org_address text, org_postal_code text, org_city text, org_phone text, org_email text, org_website text, org_chairman text, -- Branding logo_url text, primary_color text default '#0f172a', secondary_color text default '#3b82f6', -- SEPA banking creditor_id text, -- Gläubiger-ID iban text, bic text, -- Email email_sender_name text, email_footer text, -- Feature flags per tenant (jsonb for flexibility) features jsonb not null default '{}'::jsonb, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); comment on table public.account_settings is 'CMS-specific settings per team account (organisation, branding, SEPA, features)'; -- RLS alter table public.account_settings enable row level security; -- Revoke + grant revoke all on public.account_settings from authenticated, service_role; grant select, insert, update on public.account_settings to authenticated; grant all on public.account_settings to service_role; -- SELECT: any member of the account create policy account_settings_select on public.account_settings for select to authenticated using (public.has_role_on_account(account_id)); -- INSERT: settings.manage permission create policy account_settings_insert on public.account_settings for insert to authenticated with check ( public.has_permission(auth.uid(), account_id, 'settings.manage'::public.app_permissions) ); -- UPDATE: settings.manage permission create policy account_settings_update on public.account_settings for update to authenticated using ( public.has_permission(auth.uid(), account_id, 'settings.manage'::public.app_permissions) ); -- Auto-update updated_at create or replace function public.update_account_settings_timestamp() returns trigger language plpgsql security definer set search_path = '' as $$ begin new.updated_at = now(); return new; end; $$; create trigger trg_account_settings_updated_at before update on public.account_settings for each row execute function public.update_account_settings_timestamp(); -- ===================================================== -- 4. audit_log — immutable action log -- ===================================================== create table if not exists public.audit_log ( id bigint generated always as identity primary key, account_id uuid not null references public.accounts(id) on delete cascade, user_id uuid references auth.users(id) on delete set null, table_name text not null, record_id text not null, action public.audit_action not null, old_data jsonb, new_data jsonb, ip_address inet, user_agent text, created_at timestamptz not null default now() ); comment on table public.audit_log is 'Immutable audit trail for CMS data changes'; -- Indexes create index ix_audit_log_account on public.audit_log(account_id); create index ix_audit_log_table_record on public.audit_log(table_name, record_id); create index ix_audit_log_created on public.audit_log(created_at desc); create index ix_audit_log_user on public.audit_log(user_id); -- RLS alter table public.audit_log enable row level security; revoke all on public.audit_log from authenticated, service_role; grant select, insert on public.audit_log to authenticated; grant all on public.audit_log to service_role; -- SELECT: members of the account create policy audit_log_select on public.audit_log for select to authenticated using (public.has_role_on_account(account_id)); -- INSERT: any authenticated member of the account create policy audit_log_insert on public.audit_log for insert to authenticated with check (public.has_role_on_account(account_id)); -- No UPDATE/DELETE — audit log is append-only -- ===================================================== -- 5. gdpr_processing_register -- ===================================================== create table if not exists public.gdpr_processing_register ( id bigint generated always as identity primary key, account_id uuid not null references public.accounts(id) on delete cascade, purpose text not null, legal_basis public.gdpr_legal_basis not null, data_categories text not null, data_subjects text not null, recipients text, retention_period text not null, technical_measures text, responsible_person text, notes text, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); comment on table public.gdpr_processing_register is 'GDPR Art. 30 processing activity register per account'; -- Index create index ix_gdpr_register_account on public.gdpr_processing_register(account_id); -- RLS alter table public.gdpr_processing_register enable row level security; revoke all on public.gdpr_processing_register from authenticated, service_role; grant select, insert, update, delete on public.gdpr_processing_register to authenticated; grant all on public.gdpr_processing_register to service_role; create policy gdpr_register_select on public.gdpr_processing_register for select to authenticated using (public.has_role_on_account(account_id)); create policy gdpr_register_insert on public.gdpr_processing_register for insert to authenticated with check ( public.has_permission(auth.uid(), account_id, 'settings.manage'::public.app_permissions) ); create policy gdpr_register_update on public.gdpr_processing_register for update to authenticated using ( public.has_permission(auth.uid(), account_id, 'settings.manage'::public.app_permissions) ); create policy gdpr_register_delete on public.gdpr_processing_register for delete to authenticated using ( public.has_permission(auth.uid(), account_id, 'settings.manage'::public.app_permissions) ); -- Auto-update updated_at create trigger trg_gdpr_register_updated_at before update on public.gdpr_processing_register for each row execute function public.update_account_settings_timestamp(); -- ===================================================== -- 6. cms_files — file metadata with Storage paths -- ===================================================== create table if not exists public.cms_files ( id bigint generated always as identity primary key, account_id uuid not null references public.accounts(id) on delete cascade, module_name text not null, record_id text not null, field_name text not null, file_name text not null, original_name text not null, mime_type text not null, file_size bigint not null default 0, storage_path text not null, created_at timestamptz not null default now(), created_by uuid references auth.users(id) on delete set null ); comment on table public.cms_files is 'File metadata linking CMS records to Supabase Storage objects'; -- Indexes create index ix_cms_files_account on public.cms_files(account_id); create index ix_cms_files_record on public.cms_files(module_name, record_id); create index ix_cms_files_field on public.cms_files(module_name, record_id, field_name); -- RLS alter table public.cms_files enable row level security; revoke all on public.cms_files from authenticated, service_role; grant select, insert, update, delete on public.cms_files to authenticated; grant all on public.cms_files to service_role; create policy cms_files_select on public.cms_files for select to authenticated using (public.has_role_on_account(account_id)); create policy cms_files_insert on public.cms_files for insert to authenticated with check (public.has_role_on_account(account_id)); create policy cms_files_update on public.cms_files for update to authenticated using (public.has_role_on_account(account_id)); create policy cms_files_delete on public.cms_files for delete to authenticated using (public.has_role_on_account(account_id)); -- ===================================================== -- 7. Storage Buckets -- ===================================================== -- CMS uploads (module file fields) insert into storage.buckets (id, name, public) values ('cms-uploads', 'cms-uploads', false) on conflict (id) do nothing; -- CMS documents (generated PDFs, invoices) insert into storage.buckets (id, name, public) values ('cms-documents', 'cms-documents', false) on conflict (id) do nothing; -- CMS exports (CSV, Excel exports) insert into storage.buckets (id, name, public) values ('cms-exports', 'cms-exports', false) on conflict (id) do nothing; -- Storage RLS: account members can access their own files -- Uses the first path segment as account_id create or replace function kit.get_storage_path_account_id(name text) returns uuid language plpgsql security definer set search_path = '' as $$ begin -- Path format: {account_id}/... return split_part(name, '/', 1)::uuid; exception when others then return null; end; $$; grant execute on function kit.get_storage_path_account_id(text) to authenticated, service_role; -- RLS for cms-uploads bucket create policy cms_uploads_select on storage.objects for select to authenticated using ( bucket_id = 'cms-uploads' and public.has_role_on_account(kit.get_storage_path_account_id(name)) ); create policy cms_uploads_insert on storage.objects for insert to authenticated with check ( bucket_id = 'cms-uploads' and public.has_role_on_account(kit.get_storage_path_account_id(name)) ); create policy cms_uploads_delete on storage.objects for delete to authenticated using ( bucket_id = 'cms-uploads' and public.has_role_on_account(kit.get_storage_path_account_id(name)) ); -- RLS for cms-documents bucket create policy cms_documents_select on storage.objects for select to authenticated using ( bucket_id = 'cms-documents' and public.has_role_on_account(kit.get_storage_path_account_id(name)) ); create policy cms_documents_insert on storage.objects for insert to authenticated with check ( bucket_id = 'cms-documents' and public.has_role_on_account(kit.get_storage_path_account_id(name)) ); -- RLS for cms-exports bucket create policy cms_exports_select on storage.objects for select to authenticated using ( bucket_id = 'cms-exports' and public.has_role_on_account(kit.get_storage_path_account_id(name)) ); create policy cms_exports_insert on storage.objects for insert to authenticated with check ( bucket_id = 'cms-exports' and public.has_role_on_account(kit.get_storage_path_account_id(name)) );