483 lines
18 KiB
PL/PgSQL
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;
|