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

483 lines
18 KiB
PL/PgSQL

/*
* -------------------------------------------------------
* Module Engine Schema
* Phase 2: modules, module_fields, module_permissions,
* module_relations, module_query() RPC
* -------------------------------------------------------
*/
-- =====================================================
-- 1. modules — dynamic module definitions
-- =====================================================
create table if not exists public.modules (
id uuid primary key default gen_random_uuid(),
account_id uuid not null references public.accounts(id) on delete cascade,
name text not null,
display_name text not null,
description text,
icon text default 'table',
table_name text,
primary_key_column text default 'id',
status public.cms_module_status not null default 'active',
sort_order integer not null default 0,
-- Defaults
default_sort_field text,
default_sort_direction text default 'asc' check (default_sort_direction in ('asc', 'desc')),
default_page_size integer default 25,
-- Feature toggles
enable_search boolean not null default true,
enable_filter boolean not null default true,
enable_export boolean not null default true,
enable_import boolean not null default false,
enable_print boolean not null default true,
enable_copy boolean not null default false,
enable_bulk_edit boolean not null default false,
enable_history boolean not null default true,
enable_soft_delete boolean not null default true,
enable_lock boolean not null default false,
-- Extensibility
hooks jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique(account_id, name)
);
comment on table public.modules is 'Dynamic module definitions — replaces legacy m_module';
-- Indexes
create index ix_modules_account on public.modules(account_id);
create index ix_modules_status on public.modules(account_id, status);
-- RLS
alter table public.modules enable row level security;
revoke all on public.modules from authenticated, service_role;
grant select, insert, update, delete on public.modules to authenticated;
grant all on public.modules to service_role;
create policy modules_select on public.modules for select to authenticated
using (public.has_role_on_account(account_id));
create policy modules_insert on public.modules for insert to authenticated
with check (
public.has_permission(auth.uid(), account_id, 'modules.manage'::public.app_permissions)
);
create policy modules_update on public.modules for update to authenticated
using (
public.has_permission(auth.uid(), account_id, 'modules.manage'::public.app_permissions)
);
create policy modules_delete on public.modules for delete to authenticated
using (
public.has_permission(auth.uid(), account_id, 'modules.manage'::public.app_permissions)
);
-- Auto-update timestamp
create trigger trg_modules_updated_at
before update on public.modules
for each row execute function public.update_account_settings_timestamp();
-- =====================================================
-- 2. module_fields — field definitions per module
-- =====================================================
create table if not exists public.module_fields (
id uuid primary key default gen_random_uuid(),
module_id uuid not null references public.modules(id) on delete cascade,
name text not null,
display_name text not null,
field_type public.cms_field_type not null default 'text',
sql_type text default 'text',
-- Constraints
is_required boolean not null default false,
is_unique boolean not null default false,
default_value text,
min_value numeric,
max_value numeric,
max_length integer,
regex_pattern text,
-- Layout
sort_order integer not null default 0,
width text default 'full',
section text default 'default',
row_index integer default 0,
col_index integer default 0,
placeholder text,
help_text text,
-- Visibility
show_in_table boolean not null default true,
show_in_form boolean not null default true,
show_in_search boolean not null default false,
show_in_filter boolean not null default false,
show_in_export boolean not null default true,
show_in_print boolean not null default true,
-- Behavior
is_sortable boolean not null default true,
is_readonly boolean not null default false,
is_encrypted boolean not null default false,
is_copyable boolean not null default true,
validation_fn text,
-- Lookup (foreign key to another module)
lookup_module_id uuid references public.modules(id) on delete set null,
lookup_display_field text,
lookup_value_field text,
-- Select options (for select/radio/checkbox fields)
select_options jsonb,
-- GDPR
is_personal_data boolean not null default false,
gdpr_purpose text,
-- File upload config
allowed_mime_types text[],
max_file_size bigint,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique(module_id, name)
);
comment on table public.module_fields is 'Field definitions per module — replaces legacy m_modulfeld';
-- Indexes
create index ix_module_fields_module on public.module_fields(module_id);
create index ix_module_fields_sort on public.module_fields(module_id, sort_order);
create index ix_module_fields_lookup on public.module_fields(lookup_module_id);
-- RLS — inherits from parent module's account
alter table public.module_fields enable row level security;
revoke all on public.module_fields from authenticated, service_role;
grant select, insert, update, delete on public.module_fields to authenticated;
grant all on public.module_fields to service_role;
create policy module_fields_select on public.module_fields for select to authenticated
using (
exists (
select 1 from public.modules m
where m.id = module_fields.module_id
and public.has_role_on_account(m.account_id)
)
);
create policy module_fields_insert on public.module_fields for insert to authenticated
with check (
exists (
select 1 from public.modules m
where m.id = module_fields.module_id
and public.has_permission(auth.uid(), m.account_id, 'modules.manage'::public.app_permissions)
)
);
create policy module_fields_update on public.module_fields for update to authenticated
using (
exists (
select 1 from public.modules m
where m.id = module_fields.module_id
and public.has_permission(auth.uid(), m.account_id, 'modules.manage'::public.app_permissions)
)
);
create policy module_fields_delete on public.module_fields for delete to authenticated
using (
exists (
select 1 from public.modules m
where m.id = module_fields.module_id
and public.has_permission(auth.uid(), m.account_id, 'modules.manage'::public.app_permissions)
)
);
create trigger trg_module_fields_updated_at
before update on public.module_fields
for each row execute function public.update_account_settings_timestamp();
-- =====================================================
-- 3. module_permissions — per-module role permissions
-- =====================================================
create table if not exists public.module_permissions (
id uuid primary key default gen_random_uuid(),
module_id uuid not null references public.modules(id) on delete cascade,
role varchar(50) not null references public.roles(name),
can_read boolean not null default true,
can_insert boolean not null default false,
can_update boolean not null default false,
can_delete boolean not null default false,
can_lock boolean not null default false,
can_import boolean not null default false,
can_export boolean not null default true,
can_print boolean not null default true,
can_bulk_edit boolean not null default false,
can_manage boolean not null default false,
unique(module_id, role)
);
comment on table public.module_permissions is 'Per-module, per-role permission overrides';
create index ix_module_permissions_module on public.module_permissions(module_id);
alter table public.module_permissions enable row level security;
revoke all on public.module_permissions from authenticated, service_role;
grant select, insert, update, delete on public.module_permissions to authenticated;
grant all on public.module_permissions to service_role;
create policy module_permissions_select on public.module_permissions for select to authenticated
using (
exists (
select 1 from public.modules m
where m.id = module_permissions.module_id
and public.has_role_on_account(m.account_id)
)
);
create policy module_permissions_mutate on public.module_permissions for all to authenticated
using (
exists (
select 1 from public.modules m
where m.id = module_permissions.module_id
and public.has_permission(auth.uid(), m.account_id, 'modules.manage'::public.app_permissions)
)
);
-- =====================================================
-- 4. module_relations — inter-module relationships
-- =====================================================
create table if not exists public.module_relations (
id uuid primary key default gen_random_uuid(),
source_module_id uuid not null references public.modules(id) on delete cascade,
source_field_id uuid not null references public.module_fields(id) on delete cascade,
target_module_id uuid not null references public.modules(id) on delete cascade,
target_field_id uuid references public.module_fields(id) on delete set null,
relation_type text not null check (relation_type in ('lookup', 'parent', 'child', 'many_to_many')),
created_at timestamptz not null default now()
);
comment on table public.module_relations is 'Relationships between modules (lookup, parent-child, M:N)';
create index ix_module_relations_source on public.module_relations(source_module_id);
create index ix_module_relations_target on public.module_relations(target_module_id);
alter table public.module_relations enable row level security;
revoke all on public.module_relations from authenticated, service_role;
grant select, insert, update, delete on public.module_relations to authenticated;
grant all on public.module_relations to service_role;
create policy module_relations_select on public.module_relations for select to authenticated
using (
exists (
select 1 from public.modules m
where m.id = module_relations.source_module_id
and public.has_role_on_account(m.account_id)
)
);
create policy module_relations_mutate on public.module_relations for all to authenticated
using (
exists (
select 1 from public.modules m
where m.id = module_relations.source_module_id
and public.has_permission(auth.uid(), m.account_id, 'modules.manage'::public.app_permissions)
)
);
-- =====================================================
-- 5. module_records — generic record storage
-- =====================================================
create table if not exists public.module_records (
id uuid primary key default gen_random_uuid(),
module_id uuid not null references public.modules(id) on delete cascade,
account_id uuid not null references public.accounts(id) on delete cascade,
data jsonb not null default '{}'::jsonb,
status public.cms_record_status not null default 'active',
locked_by uuid references auth.users(id) on delete set null,
locked_at timestamptz,
created_by uuid references auth.users(id) on delete set null,
updated_by uuid references auth.users(id) on delete set null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
comment on table public.module_records is 'Generic record storage for all dynamic modules';
create index ix_module_records_module on public.module_records(module_id);
create index ix_module_records_account on public.module_records(account_id);
create index ix_module_records_status on public.module_records(module_id, status);
create index ix_module_records_data on public.module_records using gin(data);
create index ix_module_records_created on public.module_records(created_at desc);
alter table public.module_records enable row level security;
revoke all on public.module_records from authenticated, service_role;
grant select, insert, update, delete on public.module_records to authenticated;
grant all on public.module_records to service_role;
create policy module_records_select on public.module_records for select to authenticated
using (public.has_role_on_account(account_id));
create policy module_records_insert on public.module_records for insert to authenticated
with check (
public.has_permission(auth.uid(), account_id, 'modules.insert'::public.app_permissions)
);
create policy module_records_update on public.module_records for update to authenticated
using (
public.has_permission(auth.uid(), account_id, 'modules.write'::public.app_permissions)
);
create policy module_records_delete on public.module_records for delete to authenticated
using (
public.has_permission(auth.uid(), account_id, 'modules.delete'::public.app_permissions)
);
create trigger trg_module_records_updated_at
before update on public.module_records
for each row execute function public.update_account_settings_timestamp();
-- =====================================================
-- 6. module_query() — dynamic query RPC
-- =====================================================
create or replace function public.module_query(
p_module_id uuid,
p_filters jsonb default '[]'::jsonb,
p_sort_field text default null,
p_sort_direction text default 'asc',
p_page integer default 1,
p_page_size integer default 25,
p_search text default null
)
returns jsonb
language plpgsql
security definer
set search_path = ''
as $$
declare
v_account_id uuid;
v_total bigint;
v_offset integer;
v_records jsonb;
v_query text;
v_where text := '';
v_filter jsonb;
v_field text;
v_operator text;
v_value text;
begin
-- Get the module's account_id and verify access
select m.account_id into v_account_id
from public.modules m
where m.id = p_module_id and m.status = 'active';
if v_account_id is null then
raise exception 'Module not found or inactive';
end if;
if not public.has_role_on_account(v_account_id) then
raise exception 'Access denied';
end if;
-- Build filter WHERE clause from JSON filters
-- Each filter: {"field": "name", "operator": "eq", "value": "test"}
if p_filters is not null and jsonb_array_length(p_filters) > 0 then
for v_filter in select * from jsonb_array_elements(p_filters)
loop
v_field := v_filter->>'field';
v_operator := v_filter->>'operator';
v_value := v_filter->>'value';
-- Sanitize field name (only alphanumeric + underscore)
if v_field !~ '^[a-zA-Z_][a-zA-Z0-9_]*$' then
continue;
end if;
case v_operator
when 'eq' then
v_where := v_where || format(' AND data->>%L = %L', v_field, v_value);
when 'neq' then
v_where := v_where || format(' AND data->>%L != %L', v_field, v_value);
when 'gt' then
v_where := v_where || format(' AND (data->>%L)::numeric > %L::numeric', v_field, v_value);
when 'gte' then
v_where := v_where || format(' AND (data->>%L)::numeric >= %L::numeric', v_field, v_value);
when 'lt' then
v_where := v_where || format(' AND (data->>%L)::numeric < %L::numeric', v_field, v_value);
when 'lte' then
v_where := v_where || format(' AND (data->>%L)::numeric <= %L::numeric', v_field, v_value);
when 'like' then
v_where := v_where || format(' AND data->>%L ILIKE %L', v_field, '%' || v_value || '%');
when 'is_null' then
v_where := v_where || format(' AND (data->>%L IS NULL OR data->>%L = '''')', v_field, v_field);
when 'not_null' then
v_where := v_where || format(' AND data->>%L IS NOT NULL AND data->>%L != ''''', v_field, v_field);
else
null; -- skip unknown operators
end case;
end loop;
end if;
-- Text search across all data fields
if p_search is not null and p_search != '' then
v_where := v_where || format(' AND data::text ILIKE %L', '%' || p_search || '%');
end if;
-- Count total
execute format(
'SELECT count(*) FROM public.module_records WHERE module_id = %L AND status != ''deleted'' %s',
p_module_id, v_where
) into v_total;
-- Calculate offset
v_offset := (p_page - 1) * p_page_size;
-- Build sort
if p_sort_field is not null and p_sort_field ~ '^[a-zA-Z_][a-zA-Z0-9_]*$' then
v_query := format(
'SELECT jsonb_agg(row_to_json(r)) FROM (
SELECT id, data, status, locked_by, locked_at, created_by, updated_by, created_at, updated_at
FROM public.module_records
WHERE module_id = %L AND status != ''deleted'' %s
ORDER BY data->>%L %s NULLS LAST
LIMIT %L OFFSET %L
) r',
p_module_id, v_where, p_sort_field,
case when p_sort_direction = 'desc' then 'DESC' else 'ASC' end,
p_page_size, v_offset
);
else
v_query := format(
'SELECT jsonb_agg(row_to_json(r)) FROM (
SELECT id, data, status, locked_by, locked_at, created_by, updated_by, created_at, updated_at
FROM public.module_records
WHERE module_id = %L AND status != ''deleted'' %s
ORDER BY created_at DESC
LIMIT %L OFFSET %L
) r',
p_module_id, v_where, p_page_size, v_offset
);
end if;
execute v_query into v_records;
return jsonb_build_object(
'data', coalesce(v_records, '[]'::jsonb),
'pagination', jsonb_build_object(
'page', p_page,
'pageSize', p_page_size,
'total', v_total,
'totalPages', ceil(v_total::numeric / p_page_size::numeric)::integer
)
);
end;
$$;
grant execute on function public.module_query(uuid, jsonb, text, text, integer, integer, text)
to authenticated, service_role;