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;