mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 13:23:17 +00:00
fix: evitar código PRY duplicado al crear proyecto (RLS vs UNIQUE global)
fn_next_project_code contaba solo proyectos visibles del tenant; projects.code es UNIQUE en toda la base. El siguiente PRY-0001 chocaba con otra obra/seed. Secuencia global con SECURITY DEFINER y lock al crear. Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
This commit is contained in:
parent
17c40c66a6
commit
5147027716
4 changed files with 209 additions and 0 deletions
|
|
@ -48,5 +48,6 @@
|
||||||
<include file="changesets/036-fn-work-program-get-curve.sql" relativeToChangelogFile="true"/>
|
<include file="changesets/036-fn-work-program-get-curve.sql" relativeToChangelogFile="true"/>
|
||||||
<include file="changesets/037-fn-work-program-get-curve-vrow.sql" relativeToChangelogFile="true"/>
|
<include file="changesets/037-fn-work-program-get-curve-vrow.sql" relativeToChangelogFile="true"/>
|
||||||
<include file="changesets/038-fn-work-program-vs-cost.sql" relativeToChangelogFile="true"/>
|
<include file="changesets/038-fn-work-program-vs-cost.sql" relativeToChangelogFile="true"/>
|
||||||
|
<include file="changesets/039-fn-next-project-code-global.sql" relativeToChangelogFile="true"/>
|
||||||
|
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|
|
||||||
119
db/core/changesets/039-fn-next-project-code-global.sql
Normal file
119
db/core/changesets/039-fn-next-project-code-global.sql
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
--liquibase formatted sql
|
||||||
|
-- PANELS · core · código PRY global (UNIQUE en projects.code) vs RLS por tenant
|
||||||
|
|
||||||
|
--changeset panel:core-039a-fn-next-project-code-global splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_next_project_code(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_n integer;
|
||||||
|
v_code text;
|
||||||
|
BEGIN
|
||||||
|
-- projects.code es UNIQUE en toda la base. Con RLS el tenant solo ve sus filas
|
||||||
|
-- y MAX+1 puede repetir PRY-0001 de otro tenant → projects_code_key.
|
||||||
|
-- SECURITY DEFINER (dueño del esquema) lee la secuencia global.
|
||||||
|
SELECT COALESCE(MAX(substring(code FROM 5)::integer), 0) + 1
|
||||||
|
INTO v_n
|
||||||
|
FROM projects
|
||||||
|
WHERE code ~ '^(PRY|OBR)-[0-9]+$';
|
||||||
|
v_code := 'PRY-' || lpad(v_n::text, 4, '0');
|
||||||
|
RETURN core.rpc_ok(
|
||||||
|
jsonb_build_object('code', v_code),
|
||||||
|
format('Siguiente código de proyecto asignado: %s', v_code),
|
||||||
|
jsonb_build_object('fn', 'fn_next_project_code', 'sequence', v_n)
|
||||||
|
);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_next_project_code', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-039b-fn-project-create-code-lock splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_project_create(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_tid integer := COALESCE(
|
||||||
|
(payload->>'tenant_id')::integer,
|
||||||
|
NULLIF(current_setting('app.tenant_id', true), '')::integer
|
||||||
|
);
|
||||||
|
v_name text := nullif(btrim(payload->>'name'), '');
|
||||||
|
v_address text := coalesce(nullif(btrim(payload->>'address'), ''), '');
|
||||||
|
v_theme_id text := coalesce(nullif(btrim(payload->>'theme_id'), ''), 'arctec-dos-logos-fold');
|
||||||
|
v_status text := coalesce(nullif(btrim(payload->>'status'), ''), 'activo');
|
||||||
|
v_company_id integer := (payload->>'company_id')::integer;
|
||||||
|
v_contract_amount numeric(14,2) := (payload->>'contract_amount')::numeric(14,2);
|
||||||
|
v_start_date date := nullif(btrim(payload->>'start_date'), '')::date;
|
||||||
|
v_end_date date := nullif(btrim(payload->>'end_date'), '')::date;
|
||||||
|
v_resident_name text := coalesce(nullif(btrim(payload->>'resident_name'), ''), '');
|
||||||
|
v_siroc text := upper(coalesce(nullif(btrim(payload->>'siroc'), ''), ''));
|
||||||
|
v_payroll_tax_pct numeric(5,2) := coalesce((payload->>'payroll_tax_pct')::numeric(5,2), 4);
|
||||||
|
v_code_env jsonb;
|
||||||
|
v_code text;
|
||||||
|
v_row projects%ROWTYPE;
|
||||||
|
BEGIN
|
||||||
|
IF v_name IS NULL THEN
|
||||||
|
RETURN core.rpc_err(
|
||||||
|
'VALIDATION',
|
||||||
|
'fn_project_create: nombre de proyecto obligatorio',
|
||||||
|
jsonb_build_object('fn', 'fn_project_create', 'field', 'name')
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
IF v_company_id IS NULL THEN
|
||||||
|
RETURN core.rpc_err(
|
||||||
|
'VALIDATION',
|
||||||
|
'fn_project_create: company_id es obligatorio',
|
||||||
|
jsonb_build_object('fn', 'fn_project_create', 'field', 'company_id')
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM companies WHERE id = v_company_id) THEN
|
||||||
|
RETURN core.rpc_err(
|
||||||
|
'VALIDATION',
|
||||||
|
format('fn_project_create: empresa id=%s no encontrada', v_company_id),
|
||||||
|
jsonb_build_object('fn', 'fn_project_create', 'company_id', v_company_id)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
IF v_status NOT IN ('activo', 'pausado', 'concluido', 'cancelado') THEN
|
||||||
|
RETURN core.rpc_err(
|
||||||
|
'VALIDATION',
|
||||||
|
format('fn_project_create: estado inválido %s', v_status),
|
||||||
|
jsonb_build_object('fn', 'fn_project_create', 'field', 'status', 'value', v_status)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
IF v_start_date IS NOT NULL AND v_end_date IS NOT NULL AND v_end_date < v_start_date THEN
|
||||||
|
RETURN core.rpc_err(
|
||||||
|
'VALIDATION',
|
||||||
|
'fn_project_create: la fecha de término no puede ser anterior al inicio',
|
||||||
|
jsonb_build_object('fn', 'fn_project_create', 'field', 'end_date')
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
PERFORM pg_advisory_xact_lock(41092001);
|
||||||
|
|
||||||
|
v_code_env := core.fn_next_project_code('{}'::jsonb);
|
||||||
|
IF NOT COALESCE((v_code_env->>'ok')::boolean, false) THEN
|
||||||
|
RETURN v_code_env;
|
||||||
|
END IF;
|
||||||
|
v_code := v_code_env->'data'->>'code';
|
||||||
|
INSERT INTO projects (
|
||||||
|
code, name, address, theme_id, status, company_id, contract_amount,
|
||||||
|
start_date, end_date, resident_name, siroc, payroll_tax_pct, tenant_id
|
||||||
|
) VALUES (
|
||||||
|
v_code, v_name, v_address, v_theme_id, v_status, v_company_id, v_contract_amount,
|
||||||
|
v_start_date, v_end_date, v_resident_name, v_siroc, v_payroll_tax_pct, v_tid
|
||||||
|
)
|
||||||
|
RETURNING * INTO v_row;
|
||||||
|
RETURN core.rpc_created(
|
||||||
|
jsonb_build_object('id', v_row.id, 'code', v_row.code, 'status', v_row.status),
|
||||||
|
format('Proyecto %s (%s) creado', v_row.code, v_row.name),
|
||||||
|
jsonb_build_object('fn', 'fn_project_create', 'id', v_row.id, 'code', v_row.code)
|
||||||
|
);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_project_create', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
45
scripts/verify-project-code-next-run.sql
Normal file
45
scripts/verify-project-code-next-run.sql
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
SET search_path = core;
|
||||||
|
SELECT set_config('app.tenant_id', '2', true);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION fn_next_project_code_old()
|
||||||
|
RETURNS text
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE v_n integer; v_code text;
|
||||||
|
BEGIN
|
||||||
|
SELECT COALESCE(MAX(substring(code FROM 5)::integer), 0) + 1 INTO v_n
|
||||||
|
FROM projects WHERE code ~ '^PRY-[0-9]{4}' OR code ~ '^OBR-[0-9]{4}';
|
||||||
|
RETURN 'PRY-' || lpad(v_n::text, 4, '0');
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION fn_next_project_code_new()
|
||||||
|
RETURNS text
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE v_n integer; v_code text;
|
||||||
|
BEGIN
|
||||||
|
SELECT COALESCE(MAX(substring(code FROM 5)::integer), 0) + 1 INTO v_n
|
||||||
|
FROM projects WHERE code ~ '^(PRY|OBR)-[0-9]+$';
|
||||||
|
RETURN 'PRY-' || lpad(v_n::text, 4, '0');
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE old_code text; new_code text;
|
||||||
|
BEGIN
|
||||||
|
old_code := fn_next_project_code_old();
|
||||||
|
new_code := fn_next_project_code_new();
|
||||||
|
IF old_code <> 'PRY-0001' THEN
|
||||||
|
RAISE EXCEPTION 'RLS bug demo: old should propose PRY-0001, got %', old_code;
|
||||||
|
END IF;
|
||||||
|
IF new_code <> 'PRY-0002' THEN
|
||||||
|
RAISE EXCEPTION 'fix should propose PRY-0002, got %', new_code;
|
||||||
|
END IF;
|
||||||
|
RAISE NOTICE 'OK old=% (conflict) new=% (safe)', old_code, new_code;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
44
scripts/verify-project-code-next.sql
Normal file
44
scripts/verify-project-code-next.sql
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
-- Simula RLS: tenant B no ve PRY-0001 de tenant A pero el código debe ser global.
|
||||||
|
DROP SCHEMA IF EXISTS core CASCADE;
|
||||||
|
CREATE SCHEMA core;
|
||||||
|
SET search_path = core;
|
||||||
|
|
||||||
|
CREATE FUNCTION rpc_assert_message(p_message text) RETURNS void LANGUAGE plpgsql IMMUTABLE AS $$
|
||||||
|
BEGIN IF p_message IS NULL OR btrim(p_message) = '' THEN RAISE EXCEPTION 'empty'; END IF; END; $$;
|
||||||
|
CREATE FUNCTION rpc_ok(p_data jsonb, p_message text, p_ctx jsonb DEFAULT '{}'::jsonb) RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql IMMUTABLE AS $$
|
||||||
|
BEGIN PERFORM rpc_assert_message(p_message);
|
||||||
|
RETURN jsonb_build_object('ok', true, 'data', p_data); END; $$;
|
||||||
|
CREATE FUNCTION rpc_created(p_data jsonb, p_message text, p_ctx jsonb DEFAULT '{}'::jsonb) RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql IMMUTABLE AS $$
|
||||||
|
BEGIN PERFORM rpc_assert_message(p_message);
|
||||||
|
RETURN jsonb_build_object('ok', true, 'data', p_data); END; $$;
|
||||||
|
CREATE FUNCTION rpc_err(p_code text, p_message text, p_ctx jsonb DEFAULT '{}'::jsonb, p_errors jsonb DEFAULT NULL) RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql IMMUTABLE AS $$
|
||||||
|
BEGIN PERFORM rpc_assert_message(p_message);
|
||||||
|
RETURN jsonb_build_object('ok', false, 'message', p_message); END; $$;
|
||||||
|
CREATE FUNCTION rpc_from_exception(p_fn text, p_sqlstate text, p_message text, p_detail text DEFAULT NULL) RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql IMMUTABLE AS $$
|
||||||
|
BEGIN RETURN rpc_err('INTERNAL', format('%s %s', p_fn, p_message), '{}'::jsonb); END; $$;
|
||||||
|
|
||||||
|
CREATE TABLE companies (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name text);
|
||||||
|
CREATE TABLE projects (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
code text NOT NULL UNIQUE,
|
||||||
|
name text NOT NULL,
|
||||||
|
address text DEFAULT '',
|
||||||
|
theme_id text NOT NULL DEFAULT 'arctec-dos-logos-fold',
|
||||||
|
status text DEFAULT 'activo',
|
||||||
|
company_id bigint,
|
||||||
|
tenant_id int
|
||||||
|
);
|
||||||
|
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation ON projects
|
||||||
|
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
|
||||||
|
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer);
|
||||||
|
|
||||||
|
INSERT INTO companies (name) VALUES ('Co A'), ('Co B');
|
||||||
|
|
||||||
|
-- PRY-0001 ya existe (otro tenant / seed)
|
||||||
|
INSERT INTO projects (code, name, theme_id, company_id, tenant_id)
|
||||||
|
VALUES ('PRY-0001', 'Obra seed', 'arctec-dos-logos-fold', 1, 1);
|
||||||
Loading…
Reference in a new issue