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