From cb04a9a04cd9bc425388d406440691227a35987c Mon Sep 17 00:00:00 2001 From: Alberto Martinez <349769123@users.noreply.cursor.com> Date: Wed, 16 Sep 2026 20:56:22 +0000 Subject: [PATCH] fix: alta de proyecto sin choque en projects_code_key (PRY duplicado) (#25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Causa El error `duplicate key value violates unique constraint "projects_code_key"` aparece porque: 1. La columna `projects.code` es **UNIQUE en toda la base** (todos los tenants). 2. Al dar de alta, `fn_project_create` llama a `fn_next_project_code`, que hacía `MAX(PRY…)+1` **solo sobre las filas que el tenant ve** (RLS). 3. Si en la base ya existe `PRY-0001` (seed de dev, otra empresa cliente, u otra obra que este tenant no ve), el sistema vuelve a proponer `PRY-0001` → el `INSERT` falla con CONFLICT. No es el nombre del proyecto ni el presupuesto adjunto: es el **código autogenerado**. ## Cambio (Liquibase 039) - `fn_next_project_code`: `SECURITY DEFINER` para leer la secuencia **global** de códigos PRY/OBR. - `fn_project_create`: `pg_advisory_xact_lock` al asignar código (evita duplicado si envían dos veces seguidas). ## Cómo probar 1. Aplicar migraciones (039). 2. Con un tenant que aún no tenga obras (o en un ambiente donde ya exista `PRY-0001` de otro tenant), dar de alta un proyecto nuevo. 3. Debe crearse con `PRY-0002` (o el siguiente libre), sin toast de CONFLICT. Script de demostración RLS: `scripts/verify-project-code-next.sql` + `verify-project-code-next-run.sql`.
Open in Web Open in Cursor 
--- db/core/changelog-master.xml | 1 + .../039-fn-next-project-code-global.sql | 119 ++++++++++++++++++ scripts/verify-project-code-next-run.sql | 45 +++++++ scripts/verify-project-code-next.sql | 44 +++++++ 4 files changed, 209 insertions(+) create mode 100644 db/core/changesets/039-fn-next-project-code-global.sql create mode 100644 scripts/verify-project-code-next-run.sql create mode 100644 scripts/verify-project-code-next.sql diff --git a/db/core/changelog-master.xml b/db/core/changelog-master.xml index 9bf67ee..5a115c5 100644 --- a/db/core/changelog-master.xml +++ b/db/core/changelog-master.xml @@ -48,5 +48,6 @@ + diff --git a/db/core/changesets/039-fn-next-project-code-global.sql b/db/core/changesets/039-fn-next-project-code-global.sql new file mode 100644 index 0000000..d08715f --- /dev/null +++ b/db/core/changesets/039-fn-next-project-code-global.sql @@ -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; +$$; diff --git a/scripts/verify-project-code-next-run.sql b/scripts/verify-project-code-next-run.sql new file mode 100644 index 0000000..31867aa --- /dev/null +++ b/scripts/verify-project-code-next-run.sql @@ -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; +$$; diff --git a/scripts/verify-project-code-next.sql b/scripts/verify-project-code-next.sql new file mode 100644 index 0000000..34ba1f3 --- /dev/null +++ b/scripts/verify-project-code-next.sql @@ -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);