diff --git a/db/core/changelog-master.xml b/db/core/changelog-master.xml index 8e48fc8..ecb60c8 100644 --- a/db/core/changelog-master.xml +++ b/db/core/changelog-master.xml @@ -37,5 +37,6 @@ + diff --git a/db/core/changesets/031-project-doc-types-technical.sql b/db/core/changesets/031-project-doc-types-technical.sql new file mode 100644 index 0000000..e8e3882 --- /dev/null +++ b/db/core/changesets/031-project-doc-types-technical.sql @@ -0,0 +1,12 @@ +--liquibase formatted sql +-- PANELS · core · tipos de documento técnico adicionales + +--changeset panel:core-031a-project-doc-types-technical endDelimiter:; splitStatements:true +INSERT INTO core.project_document_types (code, label, required, category) VALUES + ('renders', 'Renders', false, 'tecnico'), + ('modelo_3d', 'Modelo 3D', false, 'tecnico'), + ('sketchup', 'SketchUp', false, 'tecnico') +ON CONFLICT (code) DO NOTHING; + +--changeset panel:core-031b-iam-rpc-created-grant endDelimiter:; splitStatements:true +GRANT EXECUTE ON FUNCTION core.rpc_created(jsonb, text, jsonb) TO panels_iam_app; diff --git a/db/iam/changelog-master.xml b/db/iam/changelog-master.xml index ed397c7..befbd1a 100644 --- a/db/iam/changelog-master.xml +++ b/db/iam/changelog-master.xml @@ -10,5 +10,6 @@ + diff --git a/db/iam/changesets/005-iam-v2-roles-crud.sql b/db/iam/changesets/005-iam-v2-roles-crud.sql new file mode 100644 index 0000000..1ae4e78 --- /dev/null +++ b/db/iam/changesets/005-iam-v2-roles-crud.sql @@ -0,0 +1,972 @@ +--liquibase formatted sql +-- PANELS · iam · IAM v2: permisos CRUD, roles por tenant, usuarios ampliados y RPC + +--changeset panel:iam-005a-permissions-metadata endDelimiter:; splitStatements:true +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='iam' AND table_name='permissions' AND column_name='module' +ALTER TABLE iam.permissions ADD COLUMN module TEXT; +ALTER TABLE iam.permissions ADD COLUMN verb TEXT; +ALTER TABLE iam.permissions ADD COLUMN perm_group TEXT; + +--changeset panel:iam-005b-permissions-v2-seed endDelimiter:; splitStatements:true +INSERT INTO iam.permissions (code, label, module, verb, perm_group) VALUES + ('workers.view', 'Consultar padrón', 'workers', 'view', NULL), + ('workers.create', 'Alta de personal', 'workers', 'create', NULL), + ('workers.update', 'Editar personal', 'workers', 'update', NULL), + ('workers.delete', 'Baja de personal', 'workers', 'delete', NULL), + ('kanban.view', 'Tablero kanban', 'kanban', 'view', NULL), + ('projects.view', 'Consultar obras', 'projects', 'view', NULL), + ('projects.create', 'Alta de obras', 'projects', 'create', NULL), + ('projects.update', 'Editar obras', 'projects', 'update', NULL), + ('companies.view', 'Consultar empresas', 'companies', 'view', NULL), + ('companies.create', 'Alta de empresas', 'companies', 'create', NULL), + ('companies.update', 'Editar empresas', 'companies', 'update', NULL), + ('budget.view', 'Ver presupuesto', 'budget', 'view', NULL), + ('budget.create', 'Alta partidas presupuesto', 'budget', 'create', NULL), + ('budget.update', 'Editar presupuesto', 'budget', 'update', NULL), + ('budget.delete', 'Eliminar partidas', 'budget', 'delete', NULL), + ('work_program.view', 'Ver programa de obra', 'work_program', 'view', NULL), + ('work_program.create', 'Importar/generar programa', 'work_program', 'create', NULL), + ('work_program.update', 'Capturar avances', 'work_program', 'update', NULL), + ('cost_control.view', 'Control de costos', 'cost_control', 'view', NULL), + ('expenses.view', 'Ver gastos', 'expenses', 'view', NULL), + ('expenses.create', 'Capturar gastos', 'expenses', 'create', NULL), + ('expenses.update', 'Editar gastos', 'expenses', 'update', NULL), + ('expenses.delete', 'Eliminar gastos', 'expenses', 'delete', NULL), + ('warehouse.view', 'Ver almacén', 'warehouse', 'view', NULL), + ('warehouse.create', 'Entradas almacén', 'warehouse', 'create', NULL), + ('warehouse.update', 'Salidas/transferencias', 'warehouse', 'update', NULL), + ('payroll.view', 'Ver nómina', 'payroll', 'view', NULL), + ('payroll.create', 'Alta nómina/préstamos', 'payroll', 'create', NULL), + ('payroll.update', 'Armar/pagar nómina', 'payroll', 'update', NULL), + ('payroll.delete', 'Eliminar líneas nómina', 'payroll', 'delete', NULL), + ('documents.view', 'Ver documentos/gafetes', 'documents', 'view', NULL), + ('documents.create', 'Subir documentos/gafetes', 'documents', 'create', NULL), + ('project_docs.tecnico.view', 'Planos y docs técnicos', 'project_docs', 'view', 'tecnico'), + ('project_docs.tecnico.create', 'Subir docs técnicos', 'project_docs', 'create', 'tecnico'), + ('project_docs.contrato.view', 'Docs contrato', 'project_docs', 'view', 'contrato'), + ('project_docs.contrato.create', 'Subir docs contrato', 'project_docs', 'create', 'contrato'), + ('project_docs.permisos.view', 'Docs permisos', 'project_docs', 'view', 'permisos'), + ('project_docs.permisos.create', 'Subir docs permisos', 'project_docs', 'create', 'permisos'), + ('project_docs.ambiental.view', 'Docs ambientales', 'project_docs', 'view', 'ambiental'), + ('project_docs.ambiental.create', 'Subir docs ambientales', 'project_docs', 'create', 'ambiental'), + ('project_docs.imss.view', 'Docs IMSS', 'project_docs', 'view', 'imss'), + ('project_docs.imss.create', 'Subir docs IMSS', 'project_docs', 'create', 'imss'), + ('project_docs.sst.view', 'Docs SST', 'project_docs', 'view', 'sst'), + ('project_docs.sst.create', 'Subir docs SST', 'project_docs', 'create', 'sst'), + ('project_docs.otro.view', 'Otros docs obra', 'project_docs', 'view', 'otro'), + ('project_docs.otro.create', 'Subir otros docs', 'project_docs', 'create', 'otro'), + ('users.view', 'Listar usuarios IAM', 'users', 'view', NULL), + ('users.create', 'Alta usuarios IAM', 'users', 'create', NULL), + ('users.update', 'Editar usuarios IAM', 'users', 'update', NULL), + ('users.delete', 'Baja usuarios IAM', 'users', 'delete', NULL), + ('settings.view', 'Ver configuración', 'settings', 'view', NULL), + ('settings.update', 'Guardar configuración', 'settings', 'update', NULL), + ('reports.view', 'Reportes e inicio', 'reports', 'view', NULL) +ON CONFLICT (code) DO UPDATE SET + label = EXCLUDED.label, + module = EXCLUDED.module, + verb = EXCLUDED.verb, + perm_group = EXCLUDED.perm_group; + +--changeset panel:iam-005c-legacy-perm-map endDelimiter:; splitStatements:true +CREATE TABLE IF NOT EXISTS iam._legacy_perm_map ( + legacy_code TEXT NOT NULL, + v2_code TEXT NOT NULL REFERENCES iam.permissions(code) ON DELETE CASCADE, + PRIMARY KEY (legacy_code, v2_code) +); + +INSERT INTO iam._legacy_perm_map (legacy_code, v2_code) VALUES + ('manage_workers', 'workers.view'), + ('manage_workers', 'workers.create'), + ('manage_workers', 'workers.update'), + ('manage_workers', 'workers.delete'), + ('manage_workers', 'kanban.view'), + ('manage_projects', 'projects.view'), + ('manage_projects', 'projects.create'), + ('manage_projects', 'projects.update'), + ('manage_companies', 'companies.view'), + ('manage_companies', 'companies.create'), + ('manage_companies', 'companies.update'), + ('manage_budget', 'budget.view'), + ('manage_budget', 'budget.create'), + ('manage_budget', 'budget.update'), + ('manage_budget', 'budget.delete'), + ('manage_budget', 'work_program.view'), + ('manage_budget', 'work_program.create'), + ('manage_budget', 'work_program.update'), + ('manage_expenses', 'expenses.view'), + ('manage_expenses', 'expenses.create'), + ('manage_expenses', 'expenses.update'), + ('manage_expenses', 'expenses.delete'), + ('manage_expenses', 'cost_control.view'), + ('view_expenses', 'expenses.view'), + ('view_expenses', 'cost_control.view'), + ('manage_payroll', 'payroll.view'), + ('manage_payroll', 'payroll.create'), + ('manage_payroll', 'payroll.update'), + ('manage_payroll', 'payroll.delete'), + ('manage_documents', 'documents.view'), + ('manage_documents', 'documents.create'), + ('manage_documents', 'project_docs.tecnico.view'), + ('manage_documents', 'project_docs.tecnico.create'), + ('manage_documents', 'project_docs.contrato.view'), + ('manage_documents', 'project_docs.contrato.create'), + ('manage_documents', 'project_docs.permisos.view'), + ('manage_documents', 'project_docs.permisos.create'), + ('manage_users', 'users.view'), + ('manage_users', 'users.create'), + ('manage_users', 'users.update'), + ('manage_users', 'users.delete'), + ('manage_settings', 'settings.view'), + ('manage_settings', 'settings.update'), + ('view_reports', 'reports.view'), + ('view_warehouse', 'warehouse.view'), + ('manage_warehouse', 'warehouse.view'), + ('manage_warehouse', 'warehouse.create'), + ('manage_warehouse', 'warehouse.update'), + ('close_warehouse', 'warehouse.update'), + ('manage_cost_settings', 'settings.update'), + ('manage_cost_settings', 'cost_control.view') +ON CONFLICT DO NOTHING; + +--changeset panel:iam-005d-roles-v2 endDelimiter:; splitStatements:true +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='iam' AND table_name='roles' AND column_name='id' +CREATE TABLE iam.roles_v2 ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + tenant_id INTEGER, + code TEXT NOT NULL, + label TEXT NOT NULL, + is_system BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +INSERT INTO iam.roles_v2 (tenant_id, code, label, is_system) +SELECT NULL, code, label, true +FROM iam.roles +WHERE code = 'tenant_admin'; + +INSERT INTO iam.roles_v2 (tenant_id, code, label, is_system) +SELECT DISTINCT u.tenant_id, 'operativo', 'Operativo', false +FROM iam.users u +WHERE u.role_code = 'user' AND u.tenant_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM iam.roles_v2 r + WHERE r.tenant_id = u.tenant_id AND r.code = 'operativo' + ); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_iam_roles_v2_tenant_code + ON iam.roles_v2 (tenant_id, code) + WHERE tenant_id IS NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_iam_roles_v2_system_code + ON iam.roles_v2 (code) + WHERE tenant_id IS NULL AND is_system; + +--changeset panel:iam-005e-role-permissions-v2 endDelimiter:; splitStatements:true +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='iam' AND table_name='role_permissions' AND column_name='role_id' +CREATE TABLE iam.role_permissions_v2 ( + role_id BIGINT NOT NULL REFERENCES iam.roles_v2(id) ON DELETE CASCADE, + permission_code TEXT NOT NULL REFERENCES iam.permissions(code) ON DELETE CASCADE, + PRIMARY KEY (role_id, permission_code) +); + +INSERT INTO iam.role_permissions_v2 (role_id, permission_code) +SELECT DISTINCT sr.id, lm.v2_code +FROM iam.role_permissions rp +JOIN iam._legacy_perm_map lm ON lm.legacy_code = rp.permission_code +JOIN iam.roles_v2 sr ON sr.code = 'tenant_admin' AND sr.is_system AND sr.tenant_id IS NULL +WHERE rp.role_code = 'tenant_admin' +ON CONFLICT DO NOTHING; + +INSERT INTO iam.role_permissions_v2 (role_id, permission_code) +SELECT sr.id, p.code +FROM iam.roles_v2 sr +CROSS JOIN iam.permissions p +WHERE sr.code = 'tenant_admin' AND sr.is_system AND sr.tenant_id IS NULL + AND p.module IS NOT NULL +ON CONFLICT DO NOTHING; + +INSERT INTO iam.role_permissions_v2 (role_id, permission_code) +SELECT DISTINCT tr.id, lm.v2_code +FROM iam.role_permissions rp +JOIN iam._legacy_perm_map lm ON lm.legacy_code = rp.permission_code +JOIN iam.roles_v2 tr ON tr.code = 'operativo' AND tr.tenant_id IS NOT NULL +WHERE rp.role_code = 'user' +ON CONFLICT DO NOTHING; + +--changeset panel:iam-005f-users-v2-columns endDelimiter:; splitStatements:true +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='iam' AND table_name='users' AND column_name='role_id' +ALTER TABLE iam.users ADD COLUMN role_id BIGINT; +ALTER TABLE iam.users ADD COLUMN status TEXT NOT NULL DEFAULT 'activo'; +ALTER TABLE iam.users ADD COLUMN scope_all_projects BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE iam.users ADD COLUMN warehouse_central BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE iam.users ADD COLUMN warehouse_projects BOOLEAN NOT NULL DEFAULT true; + +ALTER TABLE iam.users ADD CONSTRAINT users_status_check + CHECK (status IN ('activo', 'baja')); + +--changeset panel:iam-005g-users-backfill-role-id endDelimiter:; splitStatements:true +UPDATE iam.users u +SET role_id = sr.id +FROM iam.roles_v2 sr +WHERE u.role_code = 'tenant_admin' + AND sr.code = 'tenant_admin' AND sr.is_system AND sr.tenant_id IS NULL + AND u.role_id IS NULL; + +UPDATE iam.users u +SET role_id = sr.id +FROM iam.roles_v2 sr +WHERE u.role_code = 'user' + AND sr.code = 'operativo' AND sr.tenant_id = u.tenant_id + AND u.role_id IS NULL; + +UPDATE iam.users u +SET role_id = sr.id +FROM iam.roles_v2 sr +WHERE u.role_id IS NULL + AND sr.code = 'operativo' AND sr.tenant_id = u.tenant_id; + +--changeset panel:iam-005h-swap-roles endDelimiter:; splitStatements:true +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='iam' AND table_name='roles' AND column_name='id' +ALTER TABLE iam.users DROP CONSTRAINT IF EXISTS users_role_code_fkey; +ALTER TABLE iam.role_permissions DROP CONSTRAINT IF EXISTS role_permissions_role_code_fkey; +ALTER TABLE iam.role_permissions DROP CONSTRAINT IF EXISTS role_permissions_permission_code_fkey; + +DROP TABLE iam.role_permissions; + +ALTER TABLE iam.users DROP COLUMN IF EXISTS role_code; + +ALTER TABLE iam.role_permissions_v2 RENAME TO role_permissions; +ALTER TABLE iam.roles DROP CONSTRAINT roles_pkey; +DROP TABLE iam.roles; +ALTER TABLE iam.roles_v2 RENAME TO roles; + +ALTER TABLE iam.users + ADD CONSTRAINT users_role_id_fkey FOREIGN KEY (role_id) REFERENCES iam.roles(id); + +ALTER TABLE iam.users ALTER COLUMN role_id SET NOT NULL; + +DO $$ +DECLARE + v_admin_role_id bigint; +BEGIN + SELECT id INTO v_admin_role_id + FROM iam.roles + WHERE code = 'tenant_admin' AND is_system AND tenant_id IS NULL + LIMIT 1; + IF v_admin_role_id IS NOT NULL THEN + EXECUTE format( + 'CREATE UNIQUE INDEX IF NOT EXISTS uq_iam_users_one_tenant_admin_per_tenant ON iam.users (tenant_id) WHERE role_id = %s', + v_admin_role_id + ); + END IF; +END $$; + +--changeset panel:iam-005i-user-projects endDelimiter:; splitStatements:true +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='iam' AND table_name='user_projects' +CREATE TABLE iam.user_projects ( + user_id BIGINT NOT NULL REFERENCES iam.users(id) ON DELETE CASCADE, + project_id INTEGER NOT NULL, + PRIMARY KEY (user_id, project_id) +); + +CREATE INDEX idx_iam_user_projects_user ON iam.user_projects(user_id); + +--changeset panel:iam-005j-rls endDelimiter:; splitStatements:true +ALTER TABLE iam.roles ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_and_system_roles ON iam.roles; +CREATE POLICY tenant_and_system_roles ON iam.roles + USING ( + (tenant_id IS NOT NULL AND tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer) + OR (tenant_id IS NULL AND is_system) + ) + WITH CHECK ( + tenant_id IS NOT NULL + AND tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer + AND NOT is_system + ); + +ALTER TABLE iam.role_permissions ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_role_permissions ON iam.role_permissions; +CREATE POLICY tenant_role_permissions ON iam.role_permissions + USING ( + EXISTS ( + SELECT 1 FROM iam.roles r + WHERE r.id = role_permissions.role_id + AND ( + (r.tenant_id IS NOT NULL AND r.tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer) + OR (r.tenant_id IS NULL AND r.is_system) + ) + ) + ) + WITH CHECK ( + EXISTS ( + SELECT 1 FROM iam.roles r + WHERE r.id = role_permissions.role_id + AND r.tenant_id IS NOT NULL + AND r.tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer + AND NOT r.is_system + ) + ); + +ALTER TABLE iam.user_projects ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_user_projects ON iam.user_projects; +CREATE POLICY tenant_user_projects ON iam.user_projects + USING ( + EXISTS ( + SELECT 1 FROM iam.users u + WHERE u.id = user_projects.user_id + AND u.tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer + ) + ) + WITH CHECK ( + EXISTS ( + SELECT 1 FROM iam.users u + WHERE u.id = user_projects.user_id + AND u.tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer + ) + ); + +--changeset panel:iam-005k-fn-permission-list splitStatements:false +CREATE OR REPLACE FUNCTION iam.fn_permission_list(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = iam +AS $$ +DECLARE + v_rows jsonb; +BEGIN + SELECT COALESCE(jsonb_agg( + jsonb_build_object( + 'code', p.code, + 'label', p.label, + 'module', p.module, + 'verb', p.verb, + 'group', p.perm_group + ) ORDER BY p.module, p.code + ), '[]'::jsonb) INTO v_rows + FROM permissions p + WHERE p.module IS NOT NULL; + RETURN core.rpc_ok( + jsonb_build_object('permissions', v_rows), + format('Catálogo de permisos v2: %s', jsonb_array_length(v_rows)), + jsonb_build_object('fn', 'fn_permission_list') + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_permission_list', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:iam-005l-fn-role-list splitStatements:false +CREATE OR REPLACE FUNCTION iam.fn_role_list(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = iam +AS $$ +DECLARE + v_tid integer := COALESCE( + NULLIF(payload->>'tenant_id', '')::integer, + NULLIF(current_setting('app.tenant_id', true), '')::integer + ); + v_rows jsonb; +BEGIN + SELECT COALESCE(jsonb_agg( + jsonb_build_object( + 'id', r.id, + 'code', r.code, + 'label', r.label, + 'tenant_id', r.tenant_id, + 'is_system', r.is_system, + 'is_editable', NOT r.is_system, + 'created_at', r.created_at + ) ORDER BY r.is_system DESC, r.label + ), '[]'::jsonb) INTO v_rows + FROM roles r + WHERE (r.tenant_id = v_tid) OR (r.tenant_id IS NULL AND r.is_system); + RETURN core.rpc_ok( + jsonb_build_object('roles', v_rows), + format('Roles del tenant %s: %s', v_tid, jsonb_array_length(v_rows)), + jsonb_build_object('fn', 'fn_role_list', 'tenant_id', v_tid) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_role_list', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:iam-005m-fn-role-create splitStatements:false +CREATE OR REPLACE FUNCTION iam.fn_role_create(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = iam +AS $$ +DECLARE + v_tid integer := COALESCE( + NULLIF(payload->>'tenant_id', '')::integer, + NULLIF(current_setting('app.tenant_id', true), '')::integer + ); + v_code text := lower(nullif(btrim(payload->>'code'), '')); + v_label text := nullif(btrim(payload->>'label'), ''); + v_new_id bigint; + v_role jsonb; +BEGIN + IF v_tid IS NULL THEN + RETURN core.rpc_err('VALIDATION', 'fn_role_create: tenant_id es obligatorio', + jsonb_build_object('fn', 'fn_role_create')); + END IF; + IF v_code IS NULL OR v_code !~ '^[a-z][a-z0-9_]{1,63}$' THEN + RETURN core.rpc_err('VALIDATION', 'fn_role_create: code inválido (minúsculas, números, _, 2-64 chars)', + jsonb_build_object('fn', 'fn_role_create', 'field', 'code')); + END IF; + IF v_label IS NULL THEN + RETURN core.rpc_err('VALIDATION', 'fn_role_create: label es obligatorio', + jsonb_build_object('fn', 'fn_role_create', 'field', 'label')); + END IF; + IF v_code IN ('tenant_admin', 'platform_admin', 'user', 'operativo') THEN + RETURN core.rpc_err('VALIDATION', format('fn_role_create: el código %s está reservado', v_code), + jsonb_build_object('fn', 'fn_role_create', 'code', v_code)); + END IF; + INSERT INTO roles (tenant_id, code, label, is_system) + VALUES (v_tid, v_code, v_label, false) + RETURNING id INTO v_new_id; + SELECT to_jsonb(r) INTO v_role FROM roles r WHERE r.id = v_new_id; + RETURN core.rpc_created( + jsonb_build_object('role', v_role), + format('Rol %s creado (id=%s)', v_code, v_new_id), + jsonb_build_object('fn', 'fn_role_create', 'role_id', v_new_id, 'tenant_id', v_tid) + ); +EXCEPTION + WHEN unique_violation THEN + RETURN core.rpc_err('CONFLICT', format('fn_role_create: ya existe el rol %s en el tenant', v_code), + jsonb_build_object('fn', 'fn_role_create', 'code', v_code, 'tenant_id', v_tid)); + WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_role_create', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:iam-005n-fn-role-update splitStatements:false +CREATE OR REPLACE FUNCTION iam.fn_role_update(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = iam +AS $$ +DECLARE + v_tid integer := COALESCE( + NULLIF(payload->>'tenant_id', '')::integer, + NULLIF(current_setting('app.tenant_id', true), '')::integer + ); + v_role_id bigint := NULLIF(payload->>'role_id', '')::bigint; + v_label text := nullif(btrim(payload->>'label'), ''); + v_role record; + v_updated jsonb; +BEGIN + IF v_role_id IS NULL THEN + RETURN core.rpc_err('VALIDATION', 'fn_role_update: role_id es obligatorio', + jsonb_build_object('fn', 'fn_role_update', 'field', 'role_id')); + END IF; + SELECT * INTO v_role FROM roles WHERE id = v_role_id; + IF NOT FOUND THEN + RETURN core.rpc_err('NOT_FOUND', format('fn_role_update: rol id=%s no encontrado', v_role_id), + jsonb_build_object('fn', 'fn_role_update', 'role_id', v_role_id)); + END IF; + IF v_role.is_system OR v_role.tenant_id IS NULL THEN + RETURN core.rpc_err('VALIDATION', 'fn_role_update: no se puede modificar un rol del sistema', + jsonb_build_object('fn', 'fn_role_update', 'role_id', v_role_id)); + END IF; + IF v_role.tenant_id <> v_tid THEN + RETURN core.rpc_err('VALIDATION', format('fn_role_update: rol id=%s no pertenece al tenant %s', v_role_id, v_tid), + jsonb_build_object('fn', 'fn_role_update', 'role_id', v_role_id, 'tenant_id', v_tid)); + END IF; + IF v_label IS NULL THEN + RETURN core.rpc_err('VALIDATION', 'fn_role_update: label es obligatorio', + jsonb_build_object('fn', 'fn_role_update', 'field', 'label')); + END IF; + UPDATE roles SET label = v_label WHERE id = v_role_id; + SELECT to_jsonb(r) INTO v_updated FROM roles r WHERE r.id = v_role_id; + RETURN core.rpc_ok( + jsonb_build_object('role', v_updated), + format('Rol %s actualizado (id=%s)', v_updated->>'code', v_role_id), + jsonb_build_object('fn', 'fn_role_update', 'role_id', v_role_id) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_role_update', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:iam-005o-fn-role-delete splitStatements:false +CREATE OR REPLACE FUNCTION iam.fn_role_delete(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = iam +AS $$ +DECLARE + v_tid integer := COALESCE( + NULLIF(payload->>'tenant_id', '')::integer, + NULLIF(current_setting('app.tenant_id', true), '')::integer + ); + v_role_id bigint := NULLIF(payload->>'role_id', '')::bigint; + v_role record; + v_assigned integer; +BEGIN + IF v_role_id IS NULL THEN + RETURN core.rpc_err('VALIDATION', 'fn_role_delete: role_id es obligatorio', + jsonb_build_object('fn', 'fn_role_delete', 'field', 'role_id')); + END IF; + SELECT * INTO v_role FROM roles WHERE id = v_role_id; + IF NOT FOUND THEN + RETURN core.rpc_err('NOT_FOUND', format('fn_role_delete: rol id=%s no encontrado', v_role_id), + jsonb_build_object('fn', 'fn_role_delete', 'role_id', v_role_id)); + END IF; + IF v_role.is_system OR v_role.tenant_id IS NULL THEN + RETURN core.rpc_err('VALIDATION', 'fn_role_delete: no se puede eliminar un rol del sistema', + jsonb_build_object('fn', 'fn_role_delete', 'role_id', v_role_id)); + END IF; + IF v_role.tenant_id <> v_tid THEN + RETURN core.rpc_err('VALIDATION', format('fn_role_delete: rol id=%s no pertenece al tenant %s', v_role_id, v_tid), + jsonb_build_object('fn', 'fn_role_delete', 'role_id', v_role_id, 'tenant_id', v_tid)); + END IF; + SELECT COUNT(*)::integer INTO v_assigned FROM users WHERE role_id = v_role_id; + IF v_assigned > 0 THEN + RETURN core.rpc_err('CONFLICT', + format('fn_role_delete: rol %s tiene %s usuario(s) asignado(s)', v_role.code, v_assigned), + jsonb_build_object('fn', 'fn_role_delete', 'role_id', v_role_id, 'assigned_users', v_assigned)); + END IF; + DELETE FROM roles WHERE id = v_role_id; + RETURN core.rpc_ok( + jsonb_build_object('role_id', v_role_id, 'code', v_role.code), + format('Rol %s eliminado (id=%s)', v_role.code, v_role_id), + jsonb_build_object('fn', 'fn_role_delete', 'role_id', v_role_id) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_role_delete', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:iam-005p-fn-role-permissions-get splitStatements:false +CREATE OR REPLACE FUNCTION iam.fn_role_permissions_get(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = iam +AS $$ +DECLARE + v_role_id bigint := NULLIF(payload->>'role_id', '')::bigint; + v_role record; + v_rows jsonb; +BEGIN + IF v_role_id IS NULL THEN + RETURN core.rpc_err('VALIDATION', 'fn_role_permissions_get: role_id es obligatorio', + jsonb_build_object('fn', 'fn_role_permissions_get', 'field', 'role_id')); + END IF; + SELECT * INTO v_role FROM roles WHERE id = v_role_id; + IF NOT FOUND THEN + RETURN core.rpc_err('NOT_FOUND', format('fn_role_permissions_get: rol id=%s no encontrado', v_role_id), + jsonb_build_object('fn', 'fn_role_permissions_get', 'role_id', v_role_id)); + END IF; + SELECT COALESCE(jsonb_agg(rp.permission_code ORDER BY rp.permission_code), '[]'::jsonb) INTO v_rows + FROM role_permissions rp WHERE rp.role_id = v_role_id; + RETURN core.rpc_ok( + jsonb_build_object( + 'role_id', v_role_id, + 'role_code', v_role.code, + 'is_editable', NOT v_role.is_system, + 'permissions', v_rows + ), + format('Permisos del rol %s: %s', v_role.code, jsonb_array_length(v_rows)), + jsonb_build_object('fn', 'fn_role_permissions_get', 'role_id', v_role_id) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_role_permissions_get', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:iam-005q-fn-role-permissions-save splitStatements:false +CREATE OR REPLACE FUNCTION iam.fn_role_permissions_save(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = iam +AS $$ +DECLARE + v_role_id bigint := NULLIF(payload->>'role_id', '')::bigint; + v_perms jsonb := COALESCE(payload->'permissions', '[]'::jsonb); + v_role record; + v_perm text; +BEGIN + IF v_role_id IS NULL THEN + RETURN core.rpc_err('VALIDATION', 'fn_role_permissions_save: role_id es obligatorio', + jsonb_build_object('fn', 'fn_role_permissions_save', 'field', 'role_id')); + END IF; + SELECT * INTO v_role FROM roles WHERE id = v_role_id; + IF NOT FOUND THEN + RETURN core.rpc_err('NOT_FOUND', format('fn_role_permissions_save: rol id=%s no encontrado', v_role_id), + jsonb_build_object('fn', 'fn_role_permissions_save', 'role_id', v_role_id)); + END IF; + IF v_role.is_system AND v_role.code = 'tenant_admin' THEN + RETURN core.rpc_err('VALIDATION', 'fn_role_permissions_save: no se puede modificar tenant_admin', + jsonb_build_object('fn', 'fn_role_permissions_save', 'role_id', v_role_id)); + END IF; + IF v_role.is_system OR v_role.tenant_id IS NULL THEN + RETURN core.rpc_err('VALIDATION', 'fn_role_permissions_save: no se puede modificar un rol del sistema', + jsonb_build_object('fn', 'fn_role_permissions_save', 'role_id', v_role_id)); + END IF; + DELETE FROM role_permissions WHERE role_id = v_role_id; + FOR v_perm IN SELECT jsonb_array_elements_text(v_perms) + LOOP + INSERT INTO role_permissions (role_id, permission_code) + VALUES (v_role_id, v_perm) + ON CONFLICT DO NOTHING; + END LOOP; + RETURN core.rpc_ok( + jsonb_build_object('role_id', v_role_id, 'role_code', v_role.code, 'permissions', v_perms), + format('Permisos del rol %s actualizados (%s permiso(s))', v_role.code, jsonb_array_length(v_perms)), + jsonb_build_object('fn', 'fn_role_permissions_save', 'role_id', v_role_id) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_role_permissions_save', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:iam-005r-fn-user-list splitStatements:false +CREATE OR REPLACE FUNCTION iam.fn_user_list(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = iam +AS $$ +DECLARE + v_tid integer := COALESCE( + NULLIF(payload->>'tenant_id', '')::integer, + NULLIF(current_setting('app.tenant_id', true), '')::integer + ); + v_rows jsonb; +BEGIN + SELECT COALESCE(jsonb_agg(row_data ORDER BY row_data->>'display_name'), '[]'::jsonb) INTO v_rows + FROM ( + SELECT jsonb_build_object( + 'id', u.id, + 'username', u.username, + 'display_name', u.display_name, + 'email', u.email, + 'company_id', u.company_id, + 'tenant_id', u.tenant_id, + 'role_id', u.role_id, + 'role_code', r.code, + 'role_label', r.label, + 'status', u.status, + 'scope_all_projects', u.scope_all_projects, + 'warehouse_central', u.warehouse_central, + 'warehouse_projects', u.warehouse_projects, + 'project_ids', COALESCE(( + SELECT jsonb_agg(up.project_id ORDER BY up.project_id) + FROM user_projects up WHERE up.user_id = u.id + ), '[]'::jsonb), + 'is_owner', (r.code = 'tenant_admin' AND r.is_system AND r.tenant_id IS NULL), + 'must_change_password', u.must_change_password, + 'created_at', u.created_at + ) AS row_data + FROM users u + JOIN roles r ON r.id = u.role_id + WHERE u.tenant_id = v_tid + ) sub; + RETURN core.rpc_ok( + jsonb_build_object('users', v_rows), + format('Usuarios del tenant %s: %s cuenta(s)', v_tid, jsonb_array_length(v_rows)), + jsonb_build_object('fn', 'fn_user_list', 'tenant_id', v_tid) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_user_list', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:iam-005s-fn-user-create splitStatements:false +CREATE OR REPLACE FUNCTION iam.fn_user_create(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = iam +AS $$ +DECLARE + v_tid integer := COALESCE( + NULLIF(payload->>'tenant_id', '')::integer, + NULLIF(current_setting('app.tenant_id', true), '')::integer + ); + v_username text := lower(nullif(btrim(payload->>'username'), '')); + v_password_hash text := nullif(btrim(payload->>'password_hash'), ''); + v_display_name text := nullif(btrim(payload->>'display_name'), ''); + v_email text := COALESCE(nullif(btrim(payload->>'email'), ''), ''); + v_role_id bigint := NULLIF(payload->>'role_id', '')::bigint; + v_company_id integer := NULLIF(payload->>'company_id', '')::integer; + v_status text := COALESCE(nullif(btrim(payload->>'status'), ''), 'activo'); + v_scope_all boolean := COALESCE((payload->>'scope_all_projects')::boolean, false); + v_wh_central boolean := COALESCE((payload->>'warehouse_central')::boolean, false); + v_wh_projects boolean := COALESCE((payload->>'warehouse_projects')::boolean, true); + v_role record; + v_new_id bigint; + v_user jsonb; +BEGIN + IF v_tid IS NULL THEN + RETURN core.rpc_err('VALIDATION', 'fn_user_create: tenant_id es obligatorio', + jsonb_build_object('fn', 'fn_user_create')); + END IF; + IF v_username IS NULL OR v_username !~ '^[a-z0-9._-]{3,64}$' THEN + RETURN core.rpc_err('VALIDATION', 'fn_user_create: username inválido', + jsonb_build_object('fn', 'fn_user_create', 'field', 'username')); + END IF; + IF v_password_hash IS NULL THEN + RETURN core.rpc_err('VALIDATION', 'fn_user_create: password_hash es obligatorio', + jsonb_build_object('fn', 'fn_user_create', 'field', 'password_hash')); + END IF; + IF v_display_name IS NULL THEN + RETURN core.rpc_err('VALIDATION', 'fn_user_create: display_name es obligatorio', + jsonb_build_object('fn', 'fn_user_create', 'field', 'display_name')); + END IF; + IF v_role_id IS NULL THEN + RETURN core.rpc_err('VALIDATION', 'fn_user_create: role_id es obligatorio', + jsonb_build_object('fn', 'fn_user_create', 'field', 'role_id')); + END IF; + IF v_status NOT IN ('activo', 'baja') THEN + RETURN core.rpc_err('VALIDATION', 'fn_user_create: status debe ser activo o baja', + jsonb_build_object('fn', 'fn_user_create', 'field', 'status')); + END IF; + SELECT * INTO v_role FROM roles WHERE id = v_role_id; + IF NOT FOUND THEN + RETURN core.rpc_err('NOT_FOUND', format('fn_user_create: rol id=%s no encontrado', v_role_id), + jsonb_build_object('fn', 'fn_user_create', 'role_id', v_role_id)); + END IF; + IF v_role.code = 'tenant_admin' AND v_role.is_system THEN + RETURN core.rpc_err('VALIDATION', 'fn_user_create: use bootstrap para crear tenant_admin', + jsonb_build_object('fn', 'fn_user_create', 'role_id', v_role_id)); + END IF; + IF v_role.tenant_id IS NOT NULL AND v_role.tenant_id <> v_tid THEN + RETURN core.rpc_err('VALIDATION', format('fn_user_create: rol id=%s no pertenece al tenant %s', v_role_id, v_tid), + jsonb_build_object('fn', 'fn_user_create', 'role_id', v_role_id)); + END IF; + INSERT INTO users ( + username, password_hash, display_name, email, company_id, tenant_id, role_id, + status, scope_all_projects, warehouse_central, warehouse_projects, must_change_password + ) VALUES ( + v_username, v_password_hash, v_display_name, v_email, v_company_id, v_tid, v_role_id, + v_status, v_scope_all, v_wh_central, v_wh_projects, COALESCE((payload->>'must_change_password')::boolean, true) + ) + RETURNING id INTO v_new_id; + SELECT to_jsonb(u) INTO v_user FROM users u WHERE u.id = v_new_id; + RETURN core.rpc_created( + jsonb_build_object('user', v_user), + format('Usuario %s creado (id=%s)', v_username, v_new_id), + jsonb_build_object('fn', 'fn_user_create', 'user_id', v_new_id, 'tenant_id', v_tid) + ); +EXCEPTION + WHEN unique_violation THEN + RETURN core.rpc_err('CONFLICT', format('fn_user_create: ya existe el username %s', v_username), + jsonb_build_object('fn', 'fn_user_create', 'username', v_username)); + WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_user_create', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:iam-005t-fn-user-update splitStatements:false +CREATE OR REPLACE FUNCTION iam.fn_user_update(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = iam +AS $$ +DECLARE + v_tid integer := COALESCE( + NULLIF(payload->>'tenant_id', '')::integer, + NULLIF(current_setting('app.tenant_id', true), '')::integer + ); + v_user_id bigint := NULLIF(payload->>'id', '')::bigint; + v_user record; + v_role record; + v_role_id bigint := NULLIF(payload->>'role_id', '')::bigint; + v_updated jsonb; +BEGIN + IF v_user_id IS NULL THEN + RETURN core.rpc_err('VALIDATION', 'fn_user_update: id es obligatorio', + jsonb_build_object('fn', 'fn_user_update', 'field', 'id')); + END IF; + SELECT u.*, r.code AS role_code, r.is_system AS role_is_system, r.tenant_id AS role_tenant_id + INTO v_user + FROM users u + JOIN roles r ON r.id = u.role_id + WHERE u.id = v_user_id; + IF NOT FOUND THEN + RETURN core.rpc_err('NOT_FOUND', format('fn_user_update: usuario id=%s no encontrado', v_user_id), + jsonb_build_object('fn', 'fn_user_update', 'user_id', v_user_id)); + END IF; + IF v_user.tenant_id <> v_tid THEN + RETURN core.rpc_err('VALIDATION', format('fn_user_update: usuario id=%s no pertenece al tenant %s', v_user_id, v_tid), + jsonb_build_object('fn', 'fn_user_update', 'user_id', v_user_id)); + END IF; + IF v_user.role_code = 'tenant_admin' AND v_user.role_is_system AND v_user.role_tenant_id IS NULL THEN + RETURN core.rpc_err('VALIDATION', 'fn_user_update: no se puede modificar el propietario del tenant', + jsonb_build_object('fn', 'fn_user_update', 'user_id', v_user_id)); + END IF; + IF v_role_id IS NOT NULL THEN + SELECT * INTO v_role FROM roles WHERE id = v_role_id; + IF NOT FOUND THEN + RETURN core.rpc_err('NOT_FOUND', format('fn_user_update: rol id=%s no encontrado', v_role_id), + jsonb_build_object('fn', 'fn_user_update', 'role_id', v_role_id)); + END IF; + IF v_role.code = 'tenant_admin' AND v_role.is_system THEN + RETURN core.rpc_err('VALIDATION', 'fn_user_update: no se puede asignar rol tenant_admin', + jsonb_build_object('fn', 'fn_user_update', 'role_id', v_role_id)); + END IF; + IF v_role.tenant_id IS NOT NULL AND v_role.tenant_id <> v_tid THEN + RETURN core.rpc_err('VALIDATION', format('fn_user_update: rol id=%s no pertenece al tenant %s', v_role_id, v_tid), + jsonb_build_object('fn', 'fn_user_update', 'role_id', v_role_id)); + END IF; + END IF; + UPDATE users SET + display_name = COALESCE(nullif(btrim(payload->>'display_name'), ''), display_name), + email = COALESCE(nullif(btrim(payload->>'email'), ''), email), + company_id = COALESCE(NULLIF(payload->>'company_id', '')::integer, company_id), + role_id = COALESCE(v_role_id, role_id), + status = COALESCE(nullif(btrim(payload->>'status'), ''), status), + scope_all_projects = COALESCE((payload->>'scope_all_projects')::boolean, scope_all_projects), + warehouse_central = COALESCE((payload->>'warehouse_central')::boolean, warehouse_central), + warehouse_projects = COALESCE((payload->>'warehouse_projects')::boolean, warehouse_projects) + WHERE id = v_user_id; + SELECT to_jsonb(u) INTO v_updated FROM users u WHERE u.id = v_user_id; + RETURN core.rpc_ok( + jsonb_build_object('user', v_updated), + format('Usuario id=%s actualizado', v_user_id), + jsonb_build_object('fn', 'fn_user_update', 'user_id', v_user_id) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_user_update', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:iam-005u-fn-user-reset-access splitStatements:false +CREATE OR REPLACE FUNCTION iam.fn_user_reset_access(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = iam +AS $$ +DECLARE + v_tid integer := COALESCE( + NULLIF(payload->>'tenant_id', '')::integer, + NULLIF(current_setting('app.tenant_id', true), '')::integer + ); + v_user_id bigint := NULLIF(payload->>'user_id', '')::bigint; + v_password_hash text := nullif(btrim(payload->>'password_hash'), ''); + v_user record; +BEGIN + IF v_user_id IS NULL THEN + RETURN core.rpc_err('VALIDATION', 'fn_user_reset_access: user_id es obligatorio', + jsonb_build_object('fn', 'fn_user_reset_access', 'field', 'user_id')); + END IF; + IF v_password_hash IS NULL THEN + RETURN core.rpc_err('VALIDATION', 'fn_user_reset_access: password_hash es obligatorio', + jsonb_build_object('fn', 'fn_user_reset_access', 'field', 'password_hash')); + END IF; + SELECT u.*, r.code AS role_code, r.is_system AS role_is_system, r.tenant_id AS role_tenant_id + INTO v_user + FROM users u + JOIN roles r ON r.id = u.role_id + WHERE u.id = v_user_id; + IF NOT FOUND THEN + RETURN core.rpc_err('NOT_FOUND', format('fn_user_reset_access: usuario id=%s no encontrado', v_user_id), + jsonb_build_object('fn', 'fn_user_reset_access', 'user_id', v_user_id)); + END IF; + IF v_user.tenant_id <> v_tid THEN + RETURN core.rpc_err('VALIDATION', format('fn_user_reset_access: usuario id=%s no pertenece al tenant %s', v_user_id, v_tid), + jsonb_build_object('fn', 'fn_user_reset_access', 'user_id', v_user_id)); + END IF; + UPDATE users + SET password_hash = v_password_hash, + must_change_password = COALESCE((payload->>'must_change_password')::boolean, true) + WHERE id = v_user_id; + RETURN core.rpc_ok( + jsonb_build_object('user_id', v_user_id), + format('Acceso restablecido para usuario id=%s', v_user_id), + jsonb_build_object('fn', 'fn_user_reset_access', 'user_id', v_user_id) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_user_reset_access', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:iam-005v-fn-user-projects-save splitStatements:false +CREATE OR REPLACE FUNCTION iam.fn_user_projects_save(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = iam +AS $$ +DECLARE + v_tid integer := COALESCE( + NULLIF(payload->>'tenant_id', '')::integer, + NULLIF(current_setting('app.tenant_id', true), '')::integer + ); + v_user_id bigint := NULLIF(payload->>'user_id', '')::bigint; + v_project_ids jsonb := COALESCE(payload->'project_ids', '[]'::jsonb); + v_user record; + v_pid integer; +BEGIN + IF v_user_id IS NULL THEN + RETURN core.rpc_err('VALIDATION', 'fn_user_projects_save: user_id es obligatorio', + jsonb_build_object('fn', 'fn_user_projects_save', 'field', 'user_id')); + END IF; + SELECT u.*, r.code AS role_code, r.is_system AS role_is_system, r.tenant_id AS role_tenant_id + INTO v_user + FROM users u + JOIN roles r ON r.id = u.role_id + WHERE u.id = v_user_id; + IF NOT FOUND THEN + RETURN core.rpc_err('NOT_FOUND', format('fn_user_projects_save: usuario id=%s no encontrado', v_user_id), + jsonb_build_object('fn', 'fn_user_projects_save', 'user_id', v_user_id)); + END IF; + IF v_user.tenant_id <> v_tid THEN + RETURN core.rpc_err('VALIDATION', format('fn_user_projects_save: usuario id=%s no pertenece al tenant %s', v_user_id, v_tid), + jsonb_build_object('fn', 'fn_user_projects_save', 'user_id', v_user_id)); + END IF; + IF v_user.role_code = 'tenant_admin' AND v_user.role_is_system AND v_user.role_tenant_id IS NULL THEN + RETURN core.rpc_err('VALIDATION', 'fn_user_projects_save: no se puede modificar proyectos del propietario', + jsonb_build_object('fn', 'fn_user_projects_save', 'user_id', v_user_id)); + END IF; + DELETE FROM user_projects WHERE user_id = v_user_id; + FOR v_pid IN + SELECT DISTINCT (elem)::integer + FROM jsonb_array_elements_text(v_project_ids) AS elem + WHERE btrim(elem) ~ '^\d+$' + LOOP + INSERT INTO user_projects (user_id, project_id) VALUES (v_user_id, v_pid); + END LOOP; + RETURN core.rpc_ok( + jsonb_build_object('user_id', v_user_id, 'project_ids', v_project_ids), + format('Proyectos del usuario id=%s actualizados (%s)', v_user_id, jsonb_array_length(v_project_ids)), + jsonb_build_object('fn', 'fn_user_projects_save', 'user_id', v_user_id) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_user_projects_save', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:iam-005w-iam-v2-rpc-grants endDelimiter:; splitStatements:true +GRANT EXECUTE ON FUNCTION iam.fn_permission_list(jsonb) TO panels_iam_app; +GRANT EXECUTE ON FUNCTION iam.fn_role_list(jsonb) TO panels_iam_app; +GRANT EXECUTE ON FUNCTION iam.fn_role_create(jsonb) TO panels_iam_app; +GRANT EXECUTE ON FUNCTION iam.fn_role_update(jsonb) TO panels_iam_app; +GRANT EXECUTE ON FUNCTION iam.fn_role_delete(jsonb) TO panels_iam_app; +GRANT EXECUTE ON FUNCTION iam.fn_role_permissions_get(jsonb) TO panels_iam_app; +GRANT EXECUTE ON FUNCTION iam.fn_role_permissions_save(jsonb) TO panels_iam_app; +GRANT EXECUTE ON FUNCTION iam.fn_user_list(jsonb) TO panels_iam_app; +GRANT EXECUTE ON FUNCTION iam.fn_user_create(jsonb) TO panels_iam_app; +GRANT EXECUTE ON FUNCTION iam.fn_user_update(jsonb) TO panels_iam_app; +GRANT EXECUTE ON FUNCTION iam.fn_user_reset_access(jsonb) TO panels_iam_app; +GRANT EXECUTE ON FUNCTION iam.fn_user_projects_save(jsonb) TO panels_iam_app;