Files
myeasycms-v2/apps/web/supabase/migrations/20260401000001_cms_foundation.sql
2026-03-29 19:44:57 +02:00

449 lines
14 KiB
PL/PgSQL

/*
* -------------------------------------------------------
* 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))
);