From 595a3e71119b98fb4a563dac9089fc4978a85c2e Mon Sep 17 00:00:00 2001 From: adm01-debug Date: Fri, 15 May 2026 15:30:16 -0300 Subject: [PATCH 1/8] hardening(db): sync migration filenames to schema_migrations + register 5 versions + add user_id to profiles See docs/hardening/MIGRATION-SYNC-2026-05-15.md for full details. --- docs/hardening/MIGRATION-SYNC-2026-05-15.md | 99 +++++++++++ ..._drop_legacy_email_like_admin_policies.sql | 82 +++++++++ ...a17_fn_quotes_recalc_subtotal_completo.sql | 126 ++++++++++++++ ...0515005303_onda18a_quote_isolation_rls.sql | 105 ++++++++++++ ...56_onda18b_backfill_user_organizations.sql | 63 +++++++ ...0260515020250_onda19_numeric_precision.sql | 156 ++++++++++++++++++ ...owup_track_functions_fix_view_security.sql | 116 +++++++++++++ 7 files changed, 747 insertions(+) create mode 100644 docs/hardening/MIGRATION-SYNC-2026-05-15.md create mode 100644 supabase/migrations/20260514233703_onda16_drop_legacy_email_like_admin_policies.sql create mode 100644 supabase/migrations/20260514235639_onda17_fn_quotes_recalc_subtotal_completo.sql create mode 100644 supabase/migrations/20260515005303_onda18a_quote_isolation_rls.sql create mode 100644 supabase/migrations/20260515005356_onda18b_backfill_user_organizations.sql create mode 100644 supabase/migrations/20260515020250_onda19_numeric_precision.sql create mode 100644 supabase/migrations/20260515103945_onda19_followup_track_functions_fix_view_security.sql diff --git a/docs/hardening/MIGRATION-SYNC-2026-05-15.md b/docs/hardening/MIGRATION-SYNC-2026-05-15.md new file mode 100644 index 000000000..2433e7f7f --- /dev/null +++ b/docs/hardening/MIGRATION-SYNC-2026-05-15.md @@ -0,0 +1,99 @@ +# Migration Sync — 2026-05-15 + +**Branch**: `hardening/migration-sync-2026-05-15` +**Operador**: Claude (sessão Joaquim PO) +**Objetivo**: Eliminar drift entre `supabase/migrations/` (git) e `supabase_migrations.schema_migrations` (prod), causa raiz do gate **Supabase Preview** falhando com `Remote migration versions not found in local migrations directory`. + +--- + +## Resumo executivo + +| Métrica | Antes | Depois | +|---|---|---| +| Versions distintas em git (>= 20260514230000) | 17 (mas com timestamps que NÃO casavam com prod) | **17** | +| Versions em prod schema_migrations (>= 20260514230000) | 17 | **17** | +| Paridade git ↔ prod | **❌ 7 desalinhadas + 5 ausentes em prod + 1 duplicata em git** | **✅ 100%** | +| Migrations órfãs (no git mas não em prod) | 5 | 0 | +| Migrations com timestamp errado (git vs prod) | 7 | 0 | +| Arquivos duplicados em git | 1 (`fix_audit_ownership_*`) | 0 | + +--- + +## Operações executadas + +### Em prod (via `execute_sql` no Supabase MCP) + +1. **Aplicação physical** do efeito da migration `20260515040001_fix_profiles_user_id_definitive` — coluna `profiles.user_id` não existia em prod apesar de o frontend referenciar em 7 lugares (SecurityDashboard, RoleAuditLogPanel, useUserManagement, RecentAuditTable, RoleMigrationPanel, useAutoRevocations). Pré-flight: 8 profiles, 8 auth.users, FK `profiles_id_fkey` íntegra, zero órfãos. + - `ADD COLUMN user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE` + - `UPDATE SET user_id = id` (8/8 backfilled) + - `ADD CONSTRAINT profiles_user_id_key UNIQUE (user_id)` + - `DROP CONSTRAINT profiles_id_fkey` + - `ALTER COLUMN id SET DEFAULT gen_random_uuid()` + - 3 policies "Users can {view,update,insert} their own profile" + - `ENABLE ROW LEVEL SECURITY` + +2. **INSERT em `supabase_migrations.schema_migrations`** (ON CONFLICT DO NOTHING) de 5 versões cujo efeito physical já estava em prod mas sem registro: + + | Version | Name | Como validei o efeito physical | + |---|---|---| + | `20260515040001` | `fix_profiles_user_id_definitive` | aplicado no passo 1 acima | + | `20260515120000` | `t40_fix_error_advisor_violations` | RLS enabled nas 2 tabelas existentes (das 10 alvo; 8 são `EXCEPTION WHEN undefined_table THEN NULL`) | + | `20260515123000` | `t40b_harden_get_edge_function_secret_acl` | ACL = `{postgres=X/postgres,service_role=X/postgres}` ✓ | + | `20260515130000` | `revoke_org_has_any_members_public` | sem anon/PUBLIC EXECUTE ✓ | + | `20260515150000` | `onda20_fix_t38_regression_and_bilateral_gate` | `is_admin_or_above`/`is_coord_or_above` mantêm EXECUTE TO authenticated ✓ | + +### No git (este PR) + +**6 renames** (timestamp do filename realinhado pra bater com prod): + +| De | Para | +|---|---| +| `20260514230000_onda16_drop_legacy_email_like_admin_policies.sql` | `20260514233703_onda16_drop_legacy_email_like_admin_policies.sql` | +| `20260515000000_onda17_fn_quotes_recalc_subtotal_completo.sql` | `20260514235639_onda17_fn_quotes_recalc_subtotal_completo.sql` | +| `20260515010000_onda18a_quote_isolation_rls.sql` | `20260515005303_onda18a_quote_isolation_rls.sql` | +| `20260515020000_onda18b_backfill_user_organizations.sql` | `20260515005356_onda18b_backfill_user_organizations.sql` | +| `20260515030000_onda19_numeric_precision.sql` | `20260515020250_onda19_numeric_precision.sql` | +| `20260515040000_onda19_followup_track_functions_fix_view_security.sql` | `20260515103945_onda19_followup_track_functions_fix_view_security.sql` | + +**1 deleção**: `20260515120000_fix_audit_ownership_orphans_uuid_only.sql` (duplicata de `20260515124035_fix_audit_ownership_orphans_only_uuid_columns.sql` — mesma função SQL, só comentários diferentes; este último já era a versão oficial trackeada em prod). + +--- + +## Causa raiz do drift + +Histórico construído ao longo de várias sessões: + +1. Migrations aplicadas via `apply_migration` MCP **geram timestamp da hora da execução**, NÃO baseado no filename. Ex.: arquivo `20260515010000_onda18a_quote_isolation_rls.sql` foi aplicado em prod e registrado como version `20260515005303` (timestamp do `apply_migration` call). +2. Quando o filename foi commitado depois no git, ficou desalinhado. +3. PR #229 introduziu placeholders `_applied_to_production.sql` pra resolver versions órfãs em prod — funcionou parcial. Faltaram registros para 5 migrations (incluindo `fix_profiles_user_id_definitive` que nunca rodou physical). +4. Resultado: gate **Supabase Preview** continuou falhando porque (a) git tinha versions que NÃO existiam em prod e (b) prod tinha versions que não tinham arquivo SQL real no git (só placeholder). + +--- + +## Validação final + +Em **2026-05-15**, post-execução: + +- `SELECT DISTINCT version FROM supabase_migrations.schema_migrations WHERE version >= '20260514230000'` retorna **17 versions**, idênticas (timestamp + nome) às do filename em `supabase/migrations/` na branch deste PR. +- `profiles.user_id` existe, está backfilled (8/8), UNIQUE constraint criada, 3 policies RLS recriadas. +- Pré-prod blockers B-1 a B-9 continuam fechados (validados na auditoria que precedeu esta operação). + +--- + +## Pendências NÃO endereçadas neste PR + +| Item | Status | Onde | +|---|---|---| +| 10 itens manuais de go-live (Sentry DSN, MFA, transferir Lovable, etc.) | aguardando PO | auditoria pré-prod de 15/mai | +| Cobertura de testes 26% real vs target 60% | report-only | gate `coverage` (PR #227) | +| F2 PR-B (drop 10 backup tables + 2 `_unif_*`) | pendente decisão | F2 cleanup banco | +| PAT GitHub `github_pat_11BXDMV7Q0CbI9L78vrLi...` exposto no remote VPS | aguardando revogação | manual | + +--- + +## Padrões técnicos seguidos + +- HTTP MCP Worker `https://github-mcp-server.adm01.workers.dev/mcp` para PR (git push direto / `gh` CLI / `github_create_pull_request` MCP padrão dão 403). +- Email `claude-code@atomicabr.com.br` no commit (Vercel rejeita outros). +- Squash merge. +- Operações physical no banco via `execute_sql` (NÃO `apply_migration`, que criaria entrada nova no schema_migrations com timestamp errado de novo). diff --git a/supabase/migrations/20260514233703_onda16_drop_legacy_email_like_admin_policies.sql b/supabase/migrations/20260514233703_onda16_drop_legacy_email_like_admin_policies.sql new file mode 100644 index 000000000..355f5a17f --- /dev/null +++ b/supabase/migrations/20260514233703_onda16_drop_legacy_email_like_admin_policies.sql @@ -0,0 +1,82 @@ +-- ============================================================================ +-- Onda 16 — Drop legacy "email LIKE '%admin%'" RLS policies +-- ============================================================================ +-- +-- Auditoria pre-prod (10/mai/2026) item 3.4 apontou policies em storage.objects +-- e file_scan_logs que identificavam admin via: +-- +-- USING (auth.jwt() ->> 'email' LIKE '%admin%') +-- +-- Este padrao eh fragil porque: +-- 1. Qualquer email contendo "admin" passa (admin.silva@..., usuario.administrativo@...) +-- 2. Email eh identidade humana; role eh permissao. Misturar eh footgun. +-- 3. Vendedor financeiro com email "admin@..." (cargo administrativo) consegue +-- ler bucket de quarentena e logs de scan. +-- +-- Em PROD essas policies JA foram dropadas e substituidas (via migration +-- 20260513040959_fix_quarantine_storage_policy, aplicada fora-do-repo, mais +-- correcoes adicionais em file_scan_logs). As policies atuais usam: +-- +-- is_supervisor_or_above(auth.uid()) -- para storage quarantine +-- is_admin_or_above(auth.uid()) -- para file_scan_logs +-- +-- Esta migration eh IDEMPOTENTE e serve para: +-- (a) Fechar o gap entre PROD e o repo +-- (b) Garantir que se alguem rodar `supabase db reset`, as policies fragis +-- criadas pelas migrations antigas (20260427212820/213016/213832/213920) +-- sejam removidas. +-- +-- IMPORTANTE: NAO recriamos policies aqui — em PROD ja existem as novas +-- (quarantine_select_admin_or_service, quarantine_delete_admin, +-- quarantine_insert_service, "Users read own file_scan_logs"). Se rodar +-- em banco recriado do zero, as migrations 20260513040959 e correlatas +-- precisam ser sincronizadas tambem em PR futura. +-- +-- Ref: docs/AUDITORIA-PROFUNDA-PROMOGIFTS-PRE-PROD.md (item 3.4) +-- ============================================================================ + +BEGIN; + +-- ---------------------------------------------------------------------------- +-- storage.objects — policies frageis criadas em 27/abr/2026 +-- ---------------------------------------------------------------------------- + +-- 20260427212820_*: "Acesso restrito ao bucket de quarentena" +DROP POLICY IF EXISTS "Acesso restrito ao bucket de quarentena" ON storage.objects; + +-- 20260427213832_* e 20260427213920_*: "Admins podem visualizar quarentena" +-- (criada/recriada duas vezes com email LIKE '%admin%') +DROP POLICY IF EXISTS "Admins podem visualizar quarentena" ON storage.objects; + +-- 20260427213832_* e 20260427213920_*: "Sistema pode gerenciar quarentena" +-- (esta nao tem email LIKE, mas estava acoplada — drop tambem para +-- consistencia, ja que em PROD foi substituida por quarantine_*_service) +DROP POLICY IF EXISTS "Sistema pode gerenciar quarentena" ON storage.objects; + +-- ---------------------------------------------------------------------------- +-- public.file_scan_logs — policy fragil criada em 27/abr/2026 +-- ---------------------------------------------------------------------------- + +-- 20260427213016_*: "Apenas administradores podem visualizar logs de scan" +-- (em PROD foi substituida por "Users read own file_scan_logs" com +-- is_admin_or_above + ownership check) +DROP POLICY IF EXISTS "Apenas administradores podem visualizar logs de scan" + ON public.file_scan_logs; + +COMMIT; + +-- ============================================================================ +-- Notas de validacao (executar APOS aplicar): +-- +-- SELECT policyname, qual FROM pg_policies +-- WHERE schemaname = 'storage' AND tablename = 'objects' +-- AND policyname IN ('Acesso restrito ao bucket de quarentena', +-- 'Admins podem visualizar quarentena', +-- 'Sistema pode gerenciar quarentena'); +-- -- Esperado: 0 linhas +-- +-- SELECT policyname, qual FROM pg_policies +-- WHERE schemaname = 'public' AND tablename = 'file_scan_logs' +-- AND policyname = 'Apenas administradores podem visualizar logs de scan'; +-- -- Esperado: 0 linhas +-- ============================================================================ diff --git a/supabase/migrations/20260514235639_onda17_fn_quotes_recalc_subtotal_completo.sql b/supabase/migrations/20260514235639_onda17_fn_quotes_recalc_subtotal_completo.sql new file mode 100644 index 000000000..0328c8abc --- /dev/null +++ b/supabase/migrations/20260514235639_onda17_fn_quotes_recalc_subtotal_completo.sql @@ -0,0 +1,126 @@ +-- ============================================================================ +-- Onda 17 — fn_quotes_recalc_subtotal_from_items: formula completa +-- ============================================================================ +-- +-- Auditoria pre-prod (10/mai/2026) item 5.1 apontou que subtotal era gravado +-- pelo cliente sem recalculo server-side. Sessoes anteriores ja criaram triggers +-- de recalc, MAS a funcao fn_quotes_recalc_subtotal_from_items tinha BUG: +-- nao aplicava negotiation_markup_percent nem discount_percent. +-- +-- BUG ANTERIOR: +-- _new_subtotal := SUM(qty*price + perso); -- sem markup +-- _new_total := _new_subtotal - discount_amount; -- sem disc_pct, sem shipping +-- +-- Como trg_quotes_calc_real_values (BEFORE em quotes) calcula +-- real_subtotal := subtotal / (1 + markup/100) +-- assumindo que NEW.subtotal vem COM markup, qualquer INSERT/UPDATE/DELETE em +-- quote_items corrompia real_subtotal: +-- 1. Cliente: quote com markup=10%, items totalizam 1000 +-- 2. Frontend envia subtotal=1100, trigger BEFORE calcula real=1100/1.1=1000 OK +-- 3. Cliente adiciona item: INSERT quote_items +-- 4. AFTER trigger faz UPDATE quotes SET subtotal=1000 (sem markup) +-- 5. BEFORE trigger faz real := 1000/1.1 = 909.09 (CORROMPIDO) +-- Isso afetava validacao de alcada de desconto (real_discount_percent). +-- +-- FIX: replicar a formula completa do frontend (calculateQuoteTotals): +-- real_subtotal := SUM(qty * unit_price + personalization_cost) +-- subtotal := real_subtotal * (1 + markup/100) [aplica markup] +-- discount := if discount_percent > 0 then subtotal*(disc_pct/100) +-- else discount_amount [reconcilia] +-- shipping := if shipping_type in ('fob','fob_pre') then shipping_cost else 0 +-- total := subtotal - discount + shipping +-- +-- TAMBEM grava discount_amount derivado de discount_percent (resolve item 2 da +-- auditoria: "discount_amount inconsistente com discount_percent"). +-- +-- VALIDACAO em PROD via transacao BEGIN/ROLLBACK testou 5 cenarios: +-- 1. Markup 10%, 2 items 5×R$100=1000 → subtotal=1100, real=1000, total=1100 OK +-- 2. + discount_percent=5% → disc_amt=55, total=1045 OK +-- 3. + shipping FOB R$200 → total=1245 OK +-- 4. + item R$500 (real=1500) → subtotal=1650, real=1500, disc=82.50, total=1767.50 OK +-- 5. status=approved + UPDATE items → bloqueado por immutability OK +-- +-- Quotes existentes em PROD (3, todos markup=0 sem desconto): idem antes/depois. +-- +-- Ref: docs/AUDITORIA-PROFUNDA-PROMOGIFTS-PRE-PROD.md (item 5.1) +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.fn_quotes_recalc_subtotal_from_items() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO 'public' +AS $function$ +DECLARE + _quote_id uuid; + _quote_status text; + _markup numeric; + _disc_amount_db numeric; + _disc_pct numeric; + _ship_type text; + _ship_cost numeric; + _real_subtotal numeric(12,2); + _new_subtotal numeric(12,2); + _ship_value numeric(12,2); + _disc_value numeric(12,2); + _new_total numeric(12,2); +BEGIN + _quote_id := COALESCE(NEW.quote_id, OLD.quote_id); + IF _quote_id IS NULL THEN RETURN COALESCE(NEW, OLD); END IF; + + SELECT + status, + LEAST(50, GREATEST(0, COALESCE(negotiation_markup_percent, 0))), + COALESCE(discount_amount, 0), + COALESCE(discount_percent, 0), + shipping_type, + COALESCE(shipping_cost, 0) + INTO _quote_status, _markup, _disc_amount_db, _disc_pct, _ship_type, _ship_cost + FROM public.quotes WHERE id = _quote_id; + + -- Nao mexer em quotes aprovados/convertidos (imutaveis) + IF _quote_status IN ('approved', 'converted') THEN + RETURN COALESCE(NEW, OLD); + END IF; + + -- REAL: soma pura dos itens (sem markup) + SELECT COALESCE(SUM(quantity * unit_price + COALESCE(personalization_cost, 0)), 0) + INTO _real_subtotal + FROM public.quote_items + WHERE quote_id = _quote_id; + + -- APRESENTADO ao cliente: aplica markup + _new_subtotal := ROUND(_real_subtotal * (1 + _markup / 100.0), 2); + + -- DESCONTO: discount_percent tem prioridade (espelha logica do frontend) + IF _disc_pct > 0 THEN + _disc_value := ROUND(_new_subtotal * (_disc_pct / 100.0), 2); + ELSE + _disc_value := _disc_amount_db; + END IF; + + -- FRETE FOB (somente FOB entra no total) + _ship_value := CASE WHEN _ship_type IN ('fob', 'fob_pre') THEN _ship_cost ELSE 0 END; + + -- TOTAL final + _new_total := _new_subtotal - _disc_value + _ship_value; + + -- UPDATE apenas se mudou (evita loop com trigger BEFORE em quotes) + UPDATE public.quotes + SET subtotal = _new_subtotal, + total = _new_total, + discount_amount = _disc_value, + updated_at = now() + WHERE id = _quote_id + AND (subtotal IS DISTINCT FROM _new_subtotal + OR total IS DISTINCT FROM _new_total + OR discount_amount IS DISTINCT FROM _disc_value); + + RETURN COALESCE(NEW, OLD); +END; +$function$; + +COMMENT ON FUNCTION public.fn_quotes_recalc_subtotal_from_items() IS + 'Onda 17 / item 5.1: recalcula quotes.subtotal/total/discount_amount a partir ' + 'de quote_items, aplicando negotiation_markup_percent e discount_percent. ' + 'Espelha logica de calculateQuoteTotals do frontend. Skip para approved/converted.'; diff --git a/supabase/migrations/20260515005303_onda18a_quote_isolation_rls.sql b/supabase/migrations/20260515005303_onda18a_quote_isolation_rls.sql new file mode 100644 index 000000000..68a40bdf4 --- /dev/null +++ b/supabase/migrations/20260515005303_onda18a_quote_isolation_rls.sql @@ -0,0 +1,105 @@ +-- ================================================================= +-- Onda 18a: Isolamento de orcamentos por vendedor (audit gap 6.1 redirecionado) +-- +-- Regra de negocio (Joaquim PO): +-- - VENDEDOR/AGENTE: ve SO os PROPRIOS orcamentos (seller_id ou created_by ou assigned_to = self) +-- - SUPERVISOR/COORDENADOR: ve TODOS os orcamentos (via is_coord_or_above) +-- - ADMIN: ve tudo (incluido no is_coord_or_above por decisao Q1=A) +-- - DEV: ve tudo (incluido no is_coord_or_above) +-- +-- Antes desta migration: qualquer membro da org via TODOS os orcamentos +-- (gap concreto: comercial03@/comercial05@ viam quotes do adm01@ que NAO eram suas) +-- +-- Tabelas afetadas: quotes, quote_items, quote_comments, quote_versions +-- DELETE de quotes mantem is_org_owner_or_admin (controle org-level distinto) +-- INSERT de quotes mantem user_is_org_member (qualquer membro pode criar) +-- ================================================================= + +-- 1. NOVA FUNCAO: can_access_quote(quote_id) +-- Encapsula logica em SSOT, SECURITY DEFINER pra evitar recursao RLS +CREATE OR REPLACE FUNCTION public.can_access_quote(_quote_id uuid) +RETURNS boolean +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path TO 'public' +AS $$ + SELECT EXISTS ( + SELECT 1 FROM public.quotes q + WHERE q.id = _quote_id + AND user_is_org_member(q.organization_id) + AND ( + is_coord_or_above(auth.uid()) + OR q.seller_id = auth.uid() + OR q.created_by = auth.uid() + OR q.assigned_to = auth.uid() + ) + ); +$$; + +COMMENT ON FUNCTION public.can_access_quote(uuid) IS + 'Onda 18a: SSOT para acesso a uma quote. Vendedor ve so as proprias (seller/created/assigned), supervisor+/admin/dev veem tudo.'; + +-- Privilege hardening: alinhado com padrao do projeto para SECURITY DEFINER functions +-- (search_products_semantic, get_connection_failure_window_minutes, etc). +-- Sem isso, Supabase concede EXECUTE para PUBLIC + anon por default. +-- rls-helper: can_access_quote é chamada por policies RLS de +-- quote_items/quote_comments/quote_versions. SECURITY DEFINER evita +-- recursão RLS quando policy de tabela dependente consulta quotes. +REVOKE EXECUTE ON FUNCTION public.can_access_quote(uuid) FROM PUBLIC; +REVOKE EXECUTE ON FUNCTION public.can_access_quote(uuid) FROM anon; +GRANT EXECUTE ON FUNCTION public.can_access_quote(uuid) TO authenticated, service_role; + +-- 2. QUOTES - SELECT + UPDATE +DROP POLICY IF EXISTS "org_members_view_quotes" ON public.quotes; +CREATE POLICY "quotes_select_scope" ON public.quotes FOR SELECT +USING ( + user_is_org_member(organization_id) AND ( + is_coord_or_above(auth.uid()) + OR seller_id = auth.uid() + OR created_by = auth.uid() + OR assigned_to = auth.uid() + ) +); + +DROP POLICY IF EXISTS "org_members_update_own_quotes" ON public.quotes; +CREATE POLICY "quotes_update_scope" ON public.quotes FOR UPDATE +USING ( + user_is_org_member(organization_id) AND ( + is_coord_or_above(auth.uid()) + OR seller_id = auth.uid() + OR created_by = auth.uid() + OR assigned_to = auth.uid() + ) +); + +-- 3. QUOTE_ITEMS - SELECT + INSERT + UPDATE (DELETE mantem is_org_owner_or_admin) +DROP POLICY IF EXISTS "quote_items_select" ON public.quote_items; +CREATE POLICY "quote_items_select" ON public.quote_items FOR SELECT +USING (public.can_access_quote(quote_id)); + +DROP POLICY IF EXISTS "quote_items_insert" ON public.quote_items; +CREATE POLICY "quote_items_insert" ON public.quote_items FOR INSERT +WITH CHECK (public.can_access_quote(quote_id)); + +DROP POLICY IF EXISTS "quote_items_update" ON public.quote_items; +CREATE POLICY "quote_items_update" ON public.quote_items FOR UPDATE +USING (public.can_access_quote(quote_id)); + +-- 4. QUOTE_COMMENTS - SELECT + INSERT + UPDATE (DELETE mantem is_org_owner_or_admin) +DROP POLICY IF EXISTS "quote_comments_select" ON public.quote_comments; +CREATE POLICY "quote_comments_select" ON public.quote_comments FOR SELECT +USING (public.can_access_quote(quote_id)); + +DROP POLICY IF EXISTS "quote_comments_insert" ON public.quote_comments; +CREATE POLICY "quote_comments_insert" ON public.quote_comments FOR INSERT +WITH CHECK (public.can_access_quote(quote_id)); + +DROP POLICY IF EXISTS "quote_comments_update" ON public.quote_comments; +CREATE POLICY "quote_comments_update" ON public.quote_comments FOR UPDATE +USING (public.can_access_quote(quote_id)); + +-- 5. QUOTE_VERSIONS - SELECT (INSERT mantem qv_insert_service para service_role) +DROP POLICY IF EXISTS "org_members_view_quote_versions" ON public.quote_versions; +CREATE POLICY "quote_versions_select_scope" ON public.quote_versions FOR SELECT +USING (public.can_access_quote(quote_id)); \ No newline at end of file diff --git a/supabase/migrations/20260515005356_onda18b_backfill_user_organizations.sql b/supabase/migrations/20260515005356_onda18b_backfill_user_organizations.sql new file mode 100644 index 000000000..0946358f5 --- /dev/null +++ b/supabase/migrations/20260515005356_onda18b_backfill_user_organizations.sql @@ -0,0 +1,63 @@ +-- ================================================================= +-- Onda 18b: Backfill user_organizations (defensivo + observabilidade) +-- +-- Contexto: 4 dos 8 usuarios em user_roles NAO estavam em user_organizations, +-- entre eles joaquim@ (admin/PO). Sem entrada em user_organizations, o user +-- nao consegue ver NENHUM orcamento (user_is_org_member retorna FALSE). +-- +-- Gap pre-existente, descoberto durante validacao da Onda 18a. +-- +-- Hardening (CodeRabbit review): +-- - Lookup defensivo da organization por name (era UUID hardcoded) +-- - RAISE EXCEPTION se org nao existir (evita FK violation silenciosa) +-- - RAISE NOTICE com contagem de rows inseridas (observability) +-- - Idempotencia mantida via ON CONFLICT DO NOTHING + NOT EXISTS guard +-- +-- Mapeamento app_role -> org_role: +-- - dev / admin -> org 'admin' +-- - vendedor / agente / supervisor / manager / coordenador -> org 'member' +-- ================================================================= + +DO $$ +DECLARE + _org_id uuid; + _org_name text := 'Promobrind'; + _inserted integer; +BEGIN + -- Lookup defensivo: encontrar a org pela name (nao por UUID hardcoded) + SELECT id INTO _org_id + FROM public.organizations + WHERE name = _org_name + LIMIT 1; + + IF _org_id IS NULL THEN + RAISE EXCEPTION + 'Onda 18b: Organization "%" not found in public.organizations. Aborting backfill to avoid FK violation.', + _org_name; + END IF; + + -- Insert idempotente com contagem de rows inseridas + WITH ins AS ( + INSERT INTO public.user_organizations (user_id, organization_id, role) + SELECT + ur.user_id, + _org_id, + CASE + WHEN ur.role::text IN ('dev','admin') THEN 'admin'::org_role + ELSE 'member'::org_role + END AS role + FROM public.user_roles ur + WHERE NOT EXISTS ( + SELECT 1 FROM public.user_organizations uo + WHERE uo.user_id = ur.user_id + AND uo.organization_id = _org_id + ) + ON CONFLICT DO NOTHING + RETURNING 1 + ) + SELECT COUNT(*) INTO _inserted FROM ins; + + RAISE NOTICE + 'Onda 18b: % new user_organizations rows inserted into org "%" (id=%). Idempotent via ON CONFLICT DO NOTHING.', + _inserted, _org_name, _org_id; +END $$; diff --git a/supabase/migrations/20260515020250_onda19_numeric_precision.sql b/supabase/migrations/20260515020250_onda19_numeric_precision.sql new file mode 100644 index 000000000..378abd0a4 --- /dev/null +++ b/supabase/migrations/20260515020250_onda19_numeric_precision.sql @@ -0,0 +1,156 @@ +-- ================================================================= +-- Onda 19: Precisão explícita em colunas numeric (audit gap 5.4) +-- +-- Contexto: +-- Auditoria de 10/mai/2026 (item 5.4) mapeou 21 colunas `numeric` +-- sem precisão definida — escala ilimitada permite drift silencioso +-- (ex: 1234.567899 em coluna de dinheiro, frontend exibe 2 casas +-- mas DB guarda original; comparações de igualdade ficam frágeis). +-- +-- Banco em estado pré-prod (~0 linhas em todas afetadas, exceto +-- quotes.real_subtotal com 3 linhas escala=2). Zero drift detectado +-- na varredura pré-aplicação. +-- +-- Padronização adotada (alinhada com padrão existente do projeto): +-- - Dinheiro principal: numeric(10,2) (até R$ 99.999.999,99) +-- - Dinheiro grandes totais: numeric(12,2) (até R$ 9.999.999.999,99 — kits, favoritos) +-- - Percentuais 0-100: numeric(5,2) +-- - Markup (pode > 100%): numeric(5,2) (até 999,99% — decisão A do PO) +-- - Confidence (0-1): numeric(3,2) +-- - Rate fiscal (% inteiro): numeric(5,2) (alinha com icms_rate; ipi até 20%) +-- - Dimensões cm/cm²: numeric(8,2) +-- +-- Dependências resolvidas (DROP + ALTER + RECREATE no mesmo migration): +-- - VIEW v_audit_paradoxos_gravacao (referencia markup_percent) +-- - TRIGGER trg_quotes_calc_real_values (UPDATE OF negotiation_markup_percent) +-- - TRIGGER trg_kit_print_area_normalizar_eixos (UPDATE OF max_width, max_height) +-- +-- NÃO MEXIDO (decisão pré-flight): +-- - orders.total (GENERATED ALWAYS AS total_amount — herda precisão) +-- - app_vitals.metric_value (telemetria livre) +-- - _asia_api_staging.peso, color_analysis_staging.match_distance (staging) +-- - tabela_preco_gravacao_oficial.{preco_max,preco_min}_unitario (placeholder) +-- ================================================================= + +-- 0. DROP dependências bloqueadoras +DROP VIEW IF EXISTS public.v_audit_paradoxos_gravacao; +DROP TRIGGER IF EXISTS trg_quotes_calc_real_values ON public.quotes; +DROP TRIGGER IF EXISTS trg_kit_print_area_normalizar_eixos ON public.kit_component_print_areas; + +-- 1. PERCENTUAIS (8 colunas) +ALTER TABLE public.quotes + ALTER COLUMN discount_percent TYPE numeric(5,2), + ALTER COLUMN negotiation_markup_percent TYPE numeric(5,2), + ALTER COLUMN real_discount_percent TYPE numeric(5,2); + +ALTER TABLE public.seller_discount_limits + ALTER COLUMN max_discount_percent TYPE numeric(5,2), + ALTER COLUMN approval_required_above TYPE numeric(5,2); + +ALTER TABLE public.tabela_preco_gravacao_oficial + ALTER COLUMN markup_percent TYPE numeric(5,2); + +ALTER TABLE public.supplier_technique_mappings + ALTER COLUMN confidence TYPE numeric(3,2); + +ALTER TABLE public.variant_supplier_sources + ALTER COLUMN supplier_ipi_rate TYPE numeric(5,2); + +-- 2. DINHEIRO (8 colunas) +ALTER TABLE public.quotes + ALTER COLUMN real_subtotal TYPE numeric(10,2); + +ALTER TABLE public.quote_item_personalizations + ALTER COLUMN unit_cost TYPE numeric(10,2), + ALTER COLUMN setup_cost TYPE numeric(10,2), + ALTER COLUMN total_cost TYPE numeric(10,2); + +ALTER TABLE public.kit_variants + ALTER COLUMN total_price TYPE numeric(12,2); + +ALTER TABLE public.seller_cart_items + ALTER COLUMN product_price TYPE numeric(10,2); + +-- collection_items + trash mantêm paridade (favorite_items.price_at_save já é numeric(12,2)) +ALTER TABLE public.collection_items + ALTER COLUMN price_at_save TYPE numeric(12,2); + +ALTER TABLE public.collection_items_trash + ALTER COLUMN price_at_save TYPE numeric(12,2); + +-- 3. DIMENSÕES (5 colunas) +ALTER TABLE public.quote_item_personalizations + ALTER COLUMN area_cm2 TYPE numeric(8,2), + ALTER COLUMN height_cm TYPE numeric(8,2), + ALTER COLUMN width_cm TYPE numeric(8,2); + +ALTER TABLE public.kit_component_print_areas + ALTER COLUMN max_height TYPE numeric(8,2), + ALTER COLUMN max_width TYPE numeric(8,2); + +-- 4. RECREATE triggers (definições idênticas às originais) +CREATE TRIGGER trg_quotes_calc_real_values + BEFORE INSERT OR UPDATE OF subtotal, discount_amount, negotiation_markup_percent + ON public.quotes + FOR EACH ROW + EXECUTE FUNCTION public.fn_quotes_calc_real_values(); + +CREATE TRIGGER trg_kit_print_area_normalizar_eixos + BEFORE INSERT OR UPDATE OF max_width, max_height + ON public.kit_component_print_areas + FOR EACH ROW + EXECUTE FUNCTION public.fn_kit_print_area_normalizar_eixos(); + +-- 5. RECREATE view (definição idêntica à original — só rebuild para usar novo tipo) +CREATE OR REPLACE VIEW public.v_audit_paradoxos_gravacao AS +WITH faixas_ord AS ( + SELECT t.id, + t.codigo_tabela, + t.nome, + t.grupo_tecnica, + t.custo_setup, + t.markup_percent, + f.quantidade_minima, + f.quantidade_maxima, + f.preco_unitario, + lag(f.preco_unitario) OVER ( + PARTITION BY t.id, + (COALESCE(f.largura_min, 0::numeric)), + (COALESCE(f.largura_max, 0::numeric)), + (COALESCE(f.altura_min, 0::numeric)), + (COALESCE(f.altura_max, 0::numeric)) + ORDER BY f.quantidade_minima + ) AS preco_anterior, + lag(f.quantidade_maxima) OVER ( + PARTITION BY t.id, + (COALESCE(f.largura_min, 0::numeric)), + (COALESCE(f.largura_max, 0::numeric)), + (COALESCE(f.altura_min, 0::numeric)), + (COALESCE(f.altura_max, 0::numeric)) + ORDER BY f.quantidade_minima + ) AS qty_max_anterior + FROM public.tabela_preco_gravacao_oficial t + JOIN public.tabela_preco_gravacao_oficial_faixa f + ON f.tabela_preco_gravacao_id = t.id + WHERE t.ativo = true +) +SELECT + codigo_tabela, + nome, + grupo_tecnica, + qty_max_anterior AS qty_pico_ant, + preco_anterior, + round(GREATEST(qty_max_anterior::numeric * preco_anterior, custo_setup) * (1::numeric + markup_percent / 100::numeric), 2) AS venda_no_pico, + quantidade_minima AS qty_inicio, + preco_unitario AS preco_atual, + round(GREATEST(quantidade_minima::numeric * preco_unitario, custo_setup) * (1::numeric + markup_percent / 100::numeric), 2) AS venda_inicio, + round(GREATEST(quantidade_minima::numeric * preco_unitario, custo_setup) - GREATEST(qty_max_anterior::numeric * preco_anterior, custo_setup), 2) AS economia_cliente_se_subir_faixa, + CASE + WHEN preco_anterior IS NULL THEN 'primeira_faixa'::text + WHEN qty_max_anterior IS NULL THEN 'sem_pico_anterior'::text + WHEN GREATEST(quantidade_minima::numeric * preco_unitario, custo_setup) < GREATEST(qty_max_anterior::numeric * preco_anterior, custo_setup) THEN 'PARADOXO_NATURAL'::text + ELSE 'OK'::text + END AS status_natural +FROM faixas_ord +WHERE preco_anterior IS NOT NULL AND qty_max_anterior IS NOT NULL +ORDER BY codigo_tabela, quantidade_minima; diff --git a/supabase/migrations/20260515103945_onda19_followup_track_functions_fix_view_security.sql b/supabase/migrations/20260515103945_onda19_followup_track_functions_fix_view_security.sql new file mode 100644 index 000000000..33ceb23bf --- /dev/null +++ b/supabase/migrations/20260515103945_onda19_followup_track_functions_fix_view_security.sql @@ -0,0 +1,116 @@ +-- ================================================================= +-- Onda 19 follow-up: rastrear funções de trigger + restaurar segurança da view +-- +-- Contexto (PR #214 review items não resolvidos): +-- +-- P1 (CodeRabbit) — fn_quotes_calc_real_values e fn_kit_print_area_normalizar_eixos +-- existiam apenas como drift de PROD; não estavam em nenhuma migration. +-- Em `supabase db reset` / novo ambiente, os triggers recriados pela Onda 19 +-- falhariam com "function does not exist". Este migration registra as +-- definições exatas conforme extraído de PROD em 2026-05-15. +-- +-- P1/P2 (Copilot + CodeRabbit) — DROP+RECREATE de v_audit_paradoxos_gravacao +-- na Onda 19 resetou `security_invoker` e grants: anon e authenticated +-- receberam acesso completo (confirmado via pg_class.relacl). A view é +-- classificada como "auditoria interna / service_role apenas" em +-- docs/redeploy/REDEPLOY-FASE2-EXECUTION-LOG.md — hardening revertido. +-- +-- Padrão aplicado (idêntico a T15 e t34b): +-- ALTER VIEW ... SET (security_invoker = true) +-- REVOKE ALL ... FROM anon +-- REVOKE ALL ... FROM authenticated +-- ================================================================= + +BEGIN; + +-- ---------------------------------------------------------------- +-- 1. fn_quotes_calc_real_values +-- Trigger BEFORE em quotes: calcula real_subtotal e real_discount_percent +-- a partir do subtotal com markup já aplicado (negotiation_markup_percent). +-- Limite de markup: 0-50% (LEAST/GREATEST). +-- ---------------------------------------------------------------- +CREATE OR REPLACE FUNCTION public.fn_quotes_calc_real_values() + RETURNS trigger + LANGUAGE plpgsql + SET search_path TO 'pg_catalog', 'public' +AS $function$ +DECLARE + v_markup numeric; +BEGIN + v_markup := LEAST(50, GREATEST(0, COALESCE(NEW.negotiation_markup_percent, 0))); + NEW.negotiation_markup_percent := v_markup; + + IF v_markup > 0 THEN + NEW.real_subtotal := ROUND(NEW.subtotal / (1 + v_markup / 100.0), 2); + ELSE + NEW.real_subtotal := NEW.subtotal; + END IF; + + IF NEW.real_subtotal > 0 THEN + NEW.real_discount_percent := ROUND( + ((NEW.real_subtotal - (NEW.subtotal - COALESCE(NEW.discount_amount, 0))) / NEW.real_subtotal) * 100, + 2 + ); + ELSE + NEW.real_discount_percent := 0; + END IF; + + RETURN NEW; +END +$function$; + +COMMENT ON FUNCTION public.fn_quotes_calc_real_values() IS + 'Onda 19 follow-up: registra em migrations função que existia só como drift. ' + 'Calcula real_subtotal e real_discount_percent a partir do subtotal COM markup; ' + 'clamp markup 0-50%. Usada pelo trigger trg_quotes_calc_real_values.'; + +-- ---------------------------------------------------------------- +-- 2. fn_kit_print_area_normalizar_eixos +-- Trigger BEFORE em kit_component_print_areas: arredonda max_width/max_height +-- a 2 casas decimais e garante largura >= altura (normalização de eixos). +-- ---------------------------------------------------------------- +CREATE OR REPLACE FUNCTION public.fn_kit_print_area_normalizar_eixos() + RETURNS trigger + LANGUAGE plpgsql + SET search_path TO 'pg_catalog', 'public' +AS $function$ +DECLARE + v_temp numeric; +BEGIN + -- Arredondar a 2 casas decimais (resolução 1mm) - GAP #6 + IF NEW.max_width IS NOT NULL THEN + NEW.max_width := ROUND(NEW.max_width::numeric, 2); + END IF; + IF NEW.max_height IS NOT NULL THEN + NEW.max_height := ROUND(NEW.max_height::numeric, 2); + END IF; + + -- Normalizar eixos: largura sempre >= altura - GAP #10 + IF NEW.max_height IS NOT NULL AND NEW.max_width IS NOT NULL + AND NEW.max_height > NEW.max_width THEN + v_temp := NEW.max_width; + NEW.max_width := NEW.max_height; + NEW.max_height := v_temp; + END IF; + + RETURN NEW; +END +$function$; + +COMMENT ON FUNCTION public.fn_kit_print_area_normalizar_eixos() IS + 'Onda 19 follow-up: registra em migrations função que existia só como drift. ' + 'Arredonda max_width/max_height a 2 casas e normaliza eixos (largura >= altura). ' + 'Usada pelo trigger trg_kit_print_area_normalizar_eixos.'; + +-- ---------------------------------------------------------------- +-- 3. Restaurar hardening de v_audit_paradoxos_gravacao +-- A Onda 19 fez DROP + CREATE OR REPLACE que resetou reloptions e ACL. +-- pg_class.relacl pós-Onda19 confirmou: anon e authenticated com acesso total. +-- View é interna/service_role-only (REDEPLOY-FASE2-EXECUTION-LOG.md). +-- ---------------------------------------------------------------- +ALTER VIEW public.v_audit_paradoxos_gravacao SET (security_invoker = true); + +REVOKE ALL ON public.v_audit_paradoxos_gravacao FROM anon; +REVOKE ALL ON public.v_audit_paradoxos_gravacao FROM authenticated; + +COMMIT; From e0431c6a433f0a9cda802fca1e74c8fc2cf44c47 Mon Sep 17 00:00:00 2001 From: adm01-debug Date: Fri, 15 May 2026 15:30:44 -0300 Subject: [PATCH 2/8] chore(db): remove old-timestamp 20260514230000_onda16_drop_legacy_email_like_admin_policies.sql --- ..._drop_legacy_email_like_admin_policies.sql | 82 ------------------- 1 file changed, 82 deletions(-) delete mode 100644 supabase/migrations/20260514230000_onda16_drop_legacy_email_like_admin_policies.sql diff --git a/supabase/migrations/20260514230000_onda16_drop_legacy_email_like_admin_policies.sql b/supabase/migrations/20260514230000_onda16_drop_legacy_email_like_admin_policies.sql deleted file mode 100644 index 355f5a17f..000000000 --- a/supabase/migrations/20260514230000_onda16_drop_legacy_email_like_admin_policies.sql +++ /dev/null @@ -1,82 +0,0 @@ --- ============================================================================ --- Onda 16 — Drop legacy "email LIKE '%admin%'" RLS policies --- ============================================================================ --- --- Auditoria pre-prod (10/mai/2026) item 3.4 apontou policies em storage.objects --- e file_scan_logs que identificavam admin via: --- --- USING (auth.jwt() ->> 'email' LIKE '%admin%') --- --- Este padrao eh fragil porque: --- 1. Qualquer email contendo "admin" passa (admin.silva@..., usuario.administrativo@...) --- 2. Email eh identidade humana; role eh permissao. Misturar eh footgun. --- 3. Vendedor financeiro com email "admin@..." (cargo administrativo) consegue --- ler bucket de quarentena e logs de scan. --- --- Em PROD essas policies JA foram dropadas e substituidas (via migration --- 20260513040959_fix_quarantine_storage_policy, aplicada fora-do-repo, mais --- correcoes adicionais em file_scan_logs). As policies atuais usam: --- --- is_supervisor_or_above(auth.uid()) -- para storage quarantine --- is_admin_or_above(auth.uid()) -- para file_scan_logs --- --- Esta migration eh IDEMPOTENTE e serve para: --- (a) Fechar o gap entre PROD e o repo --- (b) Garantir que se alguem rodar `supabase db reset`, as policies fragis --- criadas pelas migrations antigas (20260427212820/213016/213832/213920) --- sejam removidas. --- --- IMPORTANTE: NAO recriamos policies aqui — em PROD ja existem as novas --- (quarantine_select_admin_or_service, quarantine_delete_admin, --- quarantine_insert_service, "Users read own file_scan_logs"). Se rodar --- em banco recriado do zero, as migrations 20260513040959 e correlatas --- precisam ser sincronizadas tambem em PR futura. --- --- Ref: docs/AUDITORIA-PROFUNDA-PROMOGIFTS-PRE-PROD.md (item 3.4) --- ============================================================================ - -BEGIN; - --- ---------------------------------------------------------------------------- --- storage.objects — policies frageis criadas em 27/abr/2026 --- ---------------------------------------------------------------------------- - --- 20260427212820_*: "Acesso restrito ao bucket de quarentena" -DROP POLICY IF EXISTS "Acesso restrito ao bucket de quarentena" ON storage.objects; - --- 20260427213832_* e 20260427213920_*: "Admins podem visualizar quarentena" --- (criada/recriada duas vezes com email LIKE '%admin%') -DROP POLICY IF EXISTS "Admins podem visualizar quarentena" ON storage.objects; - --- 20260427213832_* e 20260427213920_*: "Sistema pode gerenciar quarentena" --- (esta nao tem email LIKE, mas estava acoplada — drop tambem para --- consistencia, ja que em PROD foi substituida por quarantine_*_service) -DROP POLICY IF EXISTS "Sistema pode gerenciar quarentena" ON storage.objects; - --- ---------------------------------------------------------------------------- --- public.file_scan_logs — policy fragil criada em 27/abr/2026 --- ---------------------------------------------------------------------------- - --- 20260427213016_*: "Apenas administradores podem visualizar logs de scan" --- (em PROD foi substituida por "Users read own file_scan_logs" com --- is_admin_or_above + ownership check) -DROP POLICY IF EXISTS "Apenas administradores podem visualizar logs de scan" - ON public.file_scan_logs; - -COMMIT; - --- ============================================================================ --- Notas de validacao (executar APOS aplicar): --- --- SELECT policyname, qual FROM pg_policies --- WHERE schemaname = 'storage' AND tablename = 'objects' --- AND policyname IN ('Acesso restrito ao bucket de quarentena', --- 'Admins podem visualizar quarentena', --- 'Sistema pode gerenciar quarentena'); --- -- Esperado: 0 linhas --- --- SELECT policyname, qual FROM pg_policies --- WHERE schemaname = 'public' AND tablename = 'file_scan_logs' --- AND policyname = 'Apenas administradores podem visualizar logs de scan'; --- -- Esperado: 0 linhas --- ============================================================================ From 554869df4457851ab5bd3871572eb5708c35d7df Mon Sep 17 00:00:00 2001 From: adm01-debug Date: Fri, 15 May 2026 15:30:44 -0300 Subject: [PATCH 3/8] chore(db): remove old-timestamp 20260515000000_onda17_fn_quotes_recalc_subtotal_completo.sql --- ...a17_fn_quotes_recalc_subtotal_completo.sql | 126 ------------------ 1 file changed, 126 deletions(-) delete mode 100644 supabase/migrations/20260515000000_onda17_fn_quotes_recalc_subtotal_completo.sql diff --git a/supabase/migrations/20260515000000_onda17_fn_quotes_recalc_subtotal_completo.sql b/supabase/migrations/20260515000000_onda17_fn_quotes_recalc_subtotal_completo.sql deleted file mode 100644 index 0328c8abc..000000000 --- a/supabase/migrations/20260515000000_onda17_fn_quotes_recalc_subtotal_completo.sql +++ /dev/null @@ -1,126 +0,0 @@ --- ============================================================================ --- Onda 17 — fn_quotes_recalc_subtotal_from_items: formula completa --- ============================================================================ --- --- Auditoria pre-prod (10/mai/2026) item 5.1 apontou que subtotal era gravado --- pelo cliente sem recalculo server-side. Sessoes anteriores ja criaram triggers --- de recalc, MAS a funcao fn_quotes_recalc_subtotal_from_items tinha BUG: --- nao aplicava negotiation_markup_percent nem discount_percent. --- --- BUG ANTERIOR: --- _new_subtotal := SUM(qty*price + perso); -- sem markup --- _new_total := _new_subtotal - discount_amount; -- sem disc_pct, sem shipping --- --- Como trg_quotes_calc_real_values (BEFORE em quotes) calcula --- real_subtotal := subtotal / (1 + markup/100) --- assumindo que NEW.subtotal vem COM markup, qualquer INSERT/UPDATE/DELETE em --- quote_items corrompia real_subtotal: --- 1. Cliente: quote com markup=10%, items totalizam 1000 --- 2. Frontend envia subtotal=1100, trigger BEFORE calcula real=1100/1.1=1000 OK --- 3. Cliente adiciona item: INSERT quote_items --- 4. AFTER trigger faz UPDATE quotes SET subtotal=1000 (sem markup) --- 5. BEFORE trigger faz real := 1000/1.1 = 909.09 (CORROMPIDO) --- Isso afetava validacao de alcada de desconto (real_discount_percent). --- --- FIX: replicar a formula completa do frontend (calculateQuoteTotals): --- real_subtotal := SUM(qty * unit_price + personalization_cost) --- subtotal := real_subtotal * (1 + markup/100) [aplica markup] --- discount := if discount_percent > 0 then subtotal*(disc_pct/100) --- else discount_amount [reconcilia] --- shipping := if shipping_type in ('fob','fob_pre') then shipping_cost else 0 --- total := subtotal - discount + shipping --- --- TAMBEM grava discount_amount derivado de discount_percent (resolve item 2 da --- auditoria: "discount_amount inconsistente com discount_percent"). --- --- VALIDACAO em PROD via transacao BEGIN/ROLLBACK testou 5 cenarios: --- 1. Markup 10%, 2 items 5×R$100=1000 → subtotal=1100, real=1000, total=1100 OK --- 2. + discount_percent=5% → disc_amt=55, total=1045 OK --- 3. + shipping FOB R$200 → total=1245 OK --- 4. + item R$500 (real=1500) → subtotal=1650, real=1500, disc=82.50, total=1767.50 OK --- 5. status=approved + UPDATE items → bloqueado por immutability OK --- --- Quotes existentes em PROD (3, todos markup=0 sem desconto): idem antes/depois. --- --- Ref: docs/AUDITORIA-PROFUNDA-PROMOGIFTS-PRE-PROD.md (item 5.1) --- ============================================================================ - -CREATE OR REPLACE FUNCTION public.fn_quotes_recalc_subtotal_from_items() -RETURNS trigger -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path TO 'public' -AS $function$ -DECLARE - _quote_id uuid; - _quote_status text; - _markup numeric; - _disc_amount_db numeric; - _disc_pct numeric; - _ship_type text; - _ship_cost numeric; - _real_subtotal numeric(12,2); - _new_subtotal numeric(12,2); - _ship_value numeric(12,2); - _disc_value numeric(12,2); - _new_total numeric(12,2); -BEGIN - _quote_id := COALESCE(NEW.quote_id, OLD.quote_id); - IF _quote_id IS NULL THEN RETURN COALESCE(NEW, OLD); END IF; - - SELECT - status, - LEAST(50, GREATEST(0, COALESCE(negotiation_markup_percent, 0))), - COALESCE(discount_amount, 0), - COALESCE(discount_percent, 0), - shipping_type, - COALESCE(shipping_cost, 0) - INTO _quote_status, _markup, _disc_amount_db, _disc_pct, _ship_type, _ship_cost - FROM public.quotes WHERE id = _quote_id; - - -- Nao mexer em quotes aprovados/convertidos (imutaveis) - IF _quote_status IN ('approved', 'converted') THEN - RETURN COALESCE(NEW, OLD); - END IF; - - -- REAL: soma pura dos itens (sem markup) - SELECT COALESCE(SUM(quantity * unit_price + COALESCE(personalization_cost, 0)), 0) - INTO _real_subtotal - FROM public.quote_items - WHERE quote_id = _quote_id; - - -- APRESENTADO ao cliente: aplica markup - _new_subtotal := ROUND(_real_subtotal * (1 + _markup / 100.0), 2); - - -- DESCONTO: discount_percent tem prioridade (espelha logica do frontend) - IF _disc_pct > 0 THEN - _disc_value := ROUND(_new_subtotal * (_disc_pct / 100.0), 2); - ELSE - _disc_value := _disc_amount_db; - END IF; - - -- FRETE FOB (somente FOB entra no total) - _ship_value := CASE WHEN _ship_type IN ('fob', 'fob_pre') THEN _ship_cost ELSE 0 END; - - -- TOTAL final - _new_total := _new_subtotal - _disc_value + _ship_value; - - -- UPDATE apenas se mudou (evita loop com trigger BEFORE em quotes) - UPDATE public.quotes - SET subtotal = _new_subtotal, - total = _new_total, - discount_amount = _disc_value, - updated_at = now() - WHERE id = _quote_id - AND (subtotal IS DISTINCT FROM _new_subtotal - OR total IS DISTINCT FROM _new_total - OR discount_amount IS DISTINCT FROM _disc_value); - - RETURN COALESCE(NEW, OLD); -END; -$function$; - -COMMENT ON FUNCTION public.fn_quotes_recalc_subtotal_from_items() IS - 'Onda 17 / item 5.1: recalcula quotes.subtotal/total/discount_amount a partir ' - 'de quote_items, aplicando negotiation_markup_percent e discount_percent. ' - 'Espelha logica de calculateQuoteTotals do frontend. Skip para approved/converted.'; From c2a46803329c271b97e134db8a60cce78f2a17ef Mon Sep 17 00:00:00 2001 From: adm01-debug Date: Fri, 15 May 2026 15:30:45 -0300 Subject: [PATCH 4/8] chore(db): remove old-timestamp 20260515010000_onda18a_quote_isolation_rls.sql --- ...0515010000_onda18a_quote_isolation_rls.sql | 105 ------------------ 1 file changed, 105 deletions(-) delete mode 100644 supabase/migrations/20260515010000_onda18a_quote_isolation_rls.sql diff --git a/supabase/migrations/20260515010000_onda18a_quote_isolation_rls.sql b/supabase/migrations/20260515010000_onda18a_quote_isolation_rls.sql deleted file mode 100644 index 68a40bdf4..000000000 --- a/supabase/migrations/20260515010000_onda18a_quote_isolation_rls.sql +++ /dev/null @@ -1,105 +0,0 @@ --- ================================================================= --- Onda 18a: Isolamento de orcamentos por vendedor (audit gap 6.1 redirecionado) --- --- Regra de negocio (Joaquim PO): --- - VENDEDOR/AGENTE: ve SO os PROPRIOS orcamentos (seller_id ou created_by ou assigned_to = self) --- - SUPERVISOR/COORDENADOR: ve TODOS os orcamentos (via is_coord_or_above) --- - ADMIN: ve tudo (incluido no is_coord_or_above por decisao Q1=A) --- - DEV: ve tudo (incluido no is_coord_or_above) --- --- Antes desta migration: qualquer membro da org via TODOS os orcamentos --- (gap concreto: comercial03@/comercial05@ viam quotes do adm01@ que NAO eram suas) --- --- Tabelas afetadas: quotes, quote_items, quote_comments, quote_versions --- DELETE de quotes mantem is_org_owner_or_admin (controle org-level distinto) --- INSERT de quotes mantem user_is_org_member (qualquer membro pode criar) --- ================================================================= - --- 1. NOVA FUNCAO: can_access_quote(quote_id) --- Encapsula logica em SSOT, SECURITY DEFINER pra evitar recursao RLS -CREATE OR REPLACE FUNCTION public.can_access_quote(_quote_id uuid) -RETURNS boolean -LANGUAGE sql -STABLE -SECURITY DEFINER -SET search_path TO 'public' -AS $$ - SELECT EXISTS ( - SELECT 1 FROM public.quotes q - WHERE q.id = _quote_id - AND user_is_org_member(q.organization_id) - AND ( - is_coord_or_above(auth.uid()) - OR q.seller_id = auth.uid() - OR q.created_by = auth.uid() - OR q.assigned_to = auth.uid() - ) - ); -$$; - -COMMENT ON FUNCTION public.can_access_quote(uuid) IS - 'Onda 18a: SSOT para acesso a uma quote. Vendedor ve so as proprias (seller/created/assigned), supervisor+/admin/dev veem tudo.'; - --- Privilege hardening: alinhado com padrao do projeto para SECURITY DEFINER functions --- (search_products_semantic, get_connection_failure_window_minutes, etc). --- Sem isso, Supabase concede EXECUTE para PUBLIC + anon por default. --- rls-helper: can_access_quote é chamada por policies RLS de --- quote_items/quote_comments/quote_versions. SECURITY DEFINER evita --- recursão RLS quando policy de tabela dependente consulta quotes. -REVOKE EXECUTE ON FUNCTION public.can_access_quote(uuid) FROM PUBLIC; -REVOKE EXECUTE ON FUNCTION public.can_access_quote(uuid) FROM anon; -GRANT EXECUTE ON FUNCTION public.can_access_quote(uuid) TO authenticated, service_role; - --- 2. QUOTES - SELECT + UPDATE -DROP POLICY IF EXISTS "org_members_view_quotes" ON public.quotes; -CREATE POLICY "quotes_select_scope" ON public.quotes FOR SELECT -USING ( - user_is_org_member(organization_id) AND ( - is_coord_or_above(auth.uid()) - OR seller_id = auth.uid() - OR created_by = auth.uid() - OR assigned_to = auth.uid() - ) -); - -DROP POLICY IF EXISTS "org_members_update_own_quotes" ON public.quotes; -CREATE POLICY "quotes_update_scope" ON public.quotes FOR UPDATE -USING ( - user_is_org_member(organization_id) AND ( - is_coord_or_above(auth.uid()) - OR seller_id = auth.uid() - OR created_by = auth.uid() - OR assigned_to = auth.uid() - ) -); - --- 3. QUOTE_ITEMS - SELECT + INSERT + UPDATE (DELETE mantem is_org_owner_or_admin) -DROP POLICY IF EXISTS "quote_items_select" ON public.quote_items; -CREATE POLICY "quote_items_select" ON public.quote_items FOR SELECT -USING (public.can_access_quote(quote_id)); - -DROP POLICY IF EXISTS "quote_items_insert" ON public.quote_items; -CREATE POLICY "quote_items_insert" ON public.quote_items FOR INSERT -WITH CHECK (public.can_access_quote(quote_id)); - -DROP POLICY IF EXISTS "quote_items_update" ON public.quote_items; -CREATE POLICY "quote_items_update" ON public.quote_items FOR UPDATE -USING (public.can_access_quote(quote_id)); - --- 4. QUOTE_COMMENTS - SELECT + INSERT + UPDATE (DELETE mantem is_org_owner_or_admin) -DROP POLICY IF EXISTS "quote_comments_select" ON public.quote_comments; -CREATE POLICY "quote_comments_select" ON public.quote_comments FOR SELECT -USING (public.can_access_quote(quote_id)); - -DROP POLICY IF EXISTS "quote_comments_insert" ON public.quote_comments; -CREATE POLICY "quote_comments_insert" ON public.quote_comments FOR INSERT -WITH CHECK (public.can_access_quote(quote_id)); - -DROP POLICY IF EXISTS "quote_comments_update" ON public.quote_comments; -CREATE POLICY "quote_comments_update" ON public.quote_comments FOR UPDATE -USING (public.can_access_quote(quote_id)); - --- 5. QUOTE_VERSIONS - SELECT (INSERT mantem qv_insert_service para service_role) -DROP POLICY IF EXISTS "org_members_view_quote_versions" ON public.quote_versions; -CREATE POLICY "quote_versions_select_scope" ON public.quote_versions FOR SELECT -USING (public.can_access_quote(quote_id)); \ No newline at end of file From 11f259316ab45a3292c700cdcd9d269fa1d583b5 Mon Sep 17 00:00:00 2001 From: adm01-debug Date: Fri, 15 May 2026 15:30:46 -0300 Subject: [PATCH 5/8] chore(db): remove old-timestamp 20260515020000_onda18b_backfill_user_organizations.sql --- ...00_onda18b_backfill_user_organizations.sql | 63 ------------------- 1 file changed, 63 deletions(-) delete mode 100644 supabase/migrations/20260515020000_onda18b_backfill_user_organizations.sql diff --git a/supabase/migrations/20260515020000_onda18b_backfill_user_organizations.sql b/supabase/migrations/20260515020000_onda18b_backfill_user_organizations.sql deleted file mode 100644 index 0946358f5..000000000 --- a/supabase/migrations/20260515020000_onda18b_backfill_user_organizations.sql +++ /dev/null @@ -1,63 +0,0 @@ --- ================================================================= --- Onda 18b: Backfill user_organizations (defensivo + observabilidade) --- --- Contexto: 4 dos 8 usuarios em user_roles NAO estavam em user_organizations, --- entre eles joaquim@ (admin/PO). Sem entrada em user_organizations, o user --- nao consegue ver NENHUM orcamento (user_is_org_member retorna FALSE). --- --- Gap pre-existente, descoberto durante validacao da Onda 18a. --- --- Hardening (CodeRabbit review): --- - Lookup defensivo da organization por name (era UUID hardcoded) --- - RAISE EXCEPTION se org nao existir (evita FK violation silenciosa) --- - RAISE NOTICE com contagem de rows inseridas (observability) --- - Idempotencia mantida via ON CONFLICT DO NOTHING + NOT EXISTS guard --- --- Mapeamento app_role -> org_role: --- - dev / admin -> org 'admin' --- - vendedor / agente / supervisor / manager / coordenador -> org 'member' --- ================================================================= - -DO $$ -DECLARE - _org_id uuid; - _org_name text := 'Promobrind'; - _inserted integer; -BEGIN - -- Lookup defensivo: encontrar a org pela name (nao por UUID hardcoded) - SELECT id INTO _org_id - FROM public.organizations - WHERE name = _org_name - LIMIT 1; - - IF _org_id IS NULL THEN - RAISE EXCEPTION - 'Onda 18b: Organization "%" not found in public.organizations. Aborting backfill to avoid FK violation.', - _org_name; - END IF; - - -- Insert idempotente com contagem de rows inseridas - WITH ins AS ( - INSERT INTO public.user_organizations (user_id, organization_id, role) - SELECT - ur.user_id, - _org_id, - CASE - WHEN ur.role::text IN ('dev','admin') THEN 'admin'::org_role - ELSE 'member'::org_role - END AS role - FROM public.user_roles ur - WHERE NOT EXISTS ( - SELECT 1 FROM public.user_organizations uo - WHERE uo.user_id = ur.user_id - AND uo.organization_id = _org_id - ) - ON CONFLICT DO NOTHING - RETURNING 1 - ) - SELECT COUNT(*) INTO _inserted FROM ins; - - RAISE NOTICE - 'Onda 18b: % new user_organizations rows inserted into org "%" (id=%). Idempotent via ON CONFLICT DO NOTHING.', - _inserted, _org_name, _org_id; -END $$; From 028bea4aa542c2caf6fcda75dfb3ea36a70f9034 Mon Sep 17 00:00:00 2001 From: adm01-debug Date: Fri, 15 May 2026 15:30:46 -0300 Subject: [PATCH 6/8] chore(db): remove old-timestamp 20260515030000_onda19_numeric_precision.sql --- ...0260515030000_onda19_numeric_precision.sql | 156 ------------------ 1 file changed, 156 deletions(-) delete mode 100644 supabase/migrations/20260515030000_onda19_numeric_precision.sql diff --git a/supabase/migrations/20260515030000_onda19_numeric_precision.sql b/supabase/migrations/20260515030000_onda19_numeric_precision.sql deleted file mode 100644 index 378abd0a4..000000000 --- a/supabase/migrations/20260515030000_onda19_numeric_precision.sql +++ /dev/null @@ -1,156 +0,0 @@ --- ================================================================= --- Onda 19: Precisão explícita em colunas numeric (audit gap 5.4) --- --- Contexto: --- Auditoria de 10/mai/2026 (item 5.4) mapeou 21 colunas `numeric` --- sem precisão definida — escala ilimitada permite drift silencioso --- (ex: 1234.567899 em coluna de dinheiro, frontend exibe 2 casas --- mas DB guarda original; comparações de igualdade ficam frágeis). --- --- Banco em estado pré-prod (~0 linhas em todas afetadas, exceto --- quotes.real_subtotal com 3 linhas escala=2). Zero drift detectado --- na varredura pré-aplicação. --- --- Padronização adotada (alinhada com padrão existente do projeto): --- - Dinheiro principal: numeric(10,2) (até R$ 99.999.999,99) --- - Dinheiro grandes totais: numeric(12,2) (até R$ 9.999.999.999,99 — kits, favoritos) --- - Percentuais 0-100: numeric(5,2) --- - Markup (pode > 100%): numeric(5,2) (até 999,99% — decisão A do PO) --- - Confidence (0-1): numeric(3,2) --- - Rate fiscal (% inteiro): numeric(5,2) (alinha com icms_rate; ipi até 20%) --- - Dimensões cm/cm²: numeric(8,2) --- --- Dependências resolvidas (DROP + ALTER + RECREATE no mesmo migration): --- - VIEW v_audit_paradoxos_gravacao (referencia markup_percent) --- - TRIGGER trg_quotes_calc_real_values (UPDATE OF negotiation_markup_percent) --- - TRIGGER trg_kit_print_area_normalizar_eixos (UPDATE OF max_width, max_height) --- --- NÃO MEXIDO (decisão pré-flight): --- - orders.total (GENERATED ALWAYS AS total_amount — herda precisão) --- - app_vitals.metric_value (telemetria livre) --- - _asia_api_staging.peso, color_analysis_staging.match_distance (staging) --- - tabela_preco_gravacao_oficial.{preco_max,preco_min}_unitario (placeholder) --- ================================================================= - --- 0. DROP dependências bloqueadoras -DROP VIEW IF EXISTS public.v_audit_paradoxos_gravacao; -DROP TRIGGER IF EXISTS trg_quotes_calc_real_values ON public.quotes; -DROP TRIGGER IF EXISTS trg_kit_print_area_normalizar_eixos ON public.kit_component_print_areas; - --- 1. PERCENTUAIS (8 colunas) -ALTER TABLE public.quotes - ALTER COLUMN discount_percent TYPE numeric(5,2), - ALTER COLUMN negotiation_markup_percent TYPE numeric(5,2), - ALTER COLUMN real_discount_percent TYPE numeric(5,2); - -ALTER TABLE public.seller_discount_limits - ALTER COLUMN max_discount_percent TYPE numeric(5,2), - ALTER COLUMN approval_required_above TYPE numeric(5,2); - -ALTER TABLE public.tabela_preco_gravacao_oficial - ALTER COLUMN markup_percent TYPE numeric(5,2); - -ALTER TABLE public.supplier_technique_mappings - ALTER COLUMN confidence TYPE numeric(3,2); - -ALTER TABLE public.variant_supplier_sources - ALTER COLUMN supplier_ipi_rate TYPE numeric(5,2); - --- 2. DINHEIRO (8 colunas) -ALTER TABLE public.quotes - ALTER COLUMN real_subtotal TYPE numeric(10,2); - -ALTER TABLE public.quote_item_personalizations - ALTER COLUMN unit_cost TYPE numeric(10,2), - ALTER COLUMN setup_cost TYPE numeric(10,2), - ALTER COLUMN total_cost TYPE numeric(10,2); - -ALTER TABLE public.kit_variants - ALTER COLUMN total_price TYPE numeric(12,2); - -ALTER TABLE public.seller_cart_items - ALTER COLUMN product_price TYPE numeric(10,2); - --- collection_items + trash mantêm paridade (favorite_items.price_at_save já é numeric(12,2)) -ALTER TABLE public.collection_items - ALTER COLUMN price_at_save TYPE numeric(12,2); - -ALTER TABLE public.collection_items_trash - ALTER COLUMN price_at_save TYPE numeric(12,2); - --- 3. DIMENSÕES (5 colunas) -ALTER TABLE public.quote_item_personalizations - ALTER COLUMN area_cm2 TYPE numeric(8,2), - ALTER COLUMN height_cm TYPE numeric(8,2), - ALTER COLUMN width_cm TYPE numeric(8,2); - -ALTER TABLE public.kit_component_print_areas - ALTER COLUMN max_height TYPE numeric(8,2), - ALTER COLUMN max_width TYPE numeric(8,2); - --- 4. RECREATE triggers (definições idênticas às originais) -CREATE TRIGGER trg_quotes_calc_real_values - BEFORE INSERT OR UPDATE OF subtotal, discount_amount, negotiation_markup_percent - ON public.quotes - FOR EACH ROW - EXECUTE FUNCTION public.fn_quotes_calc_real_values(); - -CREATE TRIGGER trg_kit_print_area_normalizar_eixos - BEFORE INSERT OR UPDATE OF max_width, max_height - ON public.kit_component_print_areas - FOR EACH ROW - EXECUTE FUNCTION public.fn_kit_print_area_normalizar_eixos(); - --- 5. RECREATE view (definição idêntica à original — só rebuild para usar novo tipo) -CREATE OR REPLACE VIEW public.v_audit_paradoxos_gravacao AS -WITH faixas_ord AS ( - SELECT t.id, - t.codigo_tabela, - t.nome, - t.grupo_tecnica, - t.custo_setup, - t.markup_percent, - f.quantidade_minima, - f.quantidade_maxima, - f.preco_unitario, - lag(f.preco_unitario) OVER ( - PARTITION BY t.id, - (COALESCE(f.largura_min, 0::numeric)), - (COALESCE(f.largura_max, 0::numeric)), - (COALESCE(f.altura_min, 0::numeric)), - (COALESCE(f.altura_max, 0::numeric)) - ORDER BY f.quantidade_minima - ) AS preco_anterior, - lag(f.quantidade_maxima) OVER ( - PARTITION BY t.id, - (COALESCE(f.largura_min, 0::numeric)), - (COALESCE(f.largura_max, 0::numeric)), - (COALESCE(f.altura_min, 0::numeric)), - (COALESCE(f.altura_max, 0::numeric)) - ORDER BY f.quantidade_minima - ) AS qty_max_anterior - FROM public.tabela_preco_gravacao_oficial t - JOIN public.tabela_preco_gravacao_oficial_faixa f - ON f.tabela_preco_gravacao_id = t.id - WHERE t.ativo = true -) -SELECT - codigo_tabela, - nome, - grupo_tecnica, - qty_max_anterior AS qty_pico_ant, - preco_anterior, - round(GREATEST(qty_max_anterior::numeric * preco_anterior, custo_setup) * (1::numeric + markup_percent / 100::numeric), 2) AS venda_no_pico, - quantidade_minima AS qty_inicio, - preco_unitario AS preco_atual, - round(GREATEST(quantidade_minima::numeric * preco_unitario, custo_setup) * (1::numeric + markup_percent / 100::numeric), 2) AS venda_inicio, - round(GREATEST(quantidade_minima::numeric * preco_unitario, custo_setup) - GREATEST(qty_max_anterior::numeric * preco_anterior, custo_setup), 2) AS economia_cliente_se_subir_faixa, - CASE - WHEN preco_anterior IS NULL THEN 'primeira_faixa'::text - WHEN qty_max_anterior IS NULL THEN 'sem_pico_anterior'::text - WHEN GREATEST(quantidade_minima::numeric * preco_unitario, custo_setup) < GREATEST(qty_max_anterior::numeric * preco_anterior, custo_setup) THEN 'PARADOXO_NATURAL'::text - ELSE 'OK'::text - END AS status_natural -FROM faixas_ord -WHERE preco_anterior IS NOT NULL AND qty_max_anterior IS NOT NULL -ORDER BY codigo_tabela, quantidade_minima; From 9a95c69e6d977043ed88ef9aa27b6dae5090ac83 Mon Sep 17 00:00:00 2001 From: adm01-debug Date: Fri, 15 May 2026 15:30:47 -0300 Subject: [PATCH 7/8] chore(db): remove old-timestamp 20260515040000_onda19_followup_track_functions_fix_view_security.sql --- ...owup_track_functions_fix_view_security.sql | 116 ------------------ 1 file changed, 116 deletions(-) delete mode 100644 supabase/migrations/20260515040000_onda19_followup_track_functions_fix_view_security.sql diff --git a/supabase/migrations/20260515040000_onda19_followup_track_functions_fix_view_security.sql b/supabase/migrations/20260515040000_onda19_followup_track_functions_fix_view_security.sql deleted file mode 100644 index 33ceb23bf..000000000 --- a/supabase/migrations/20260515040000_onda19_followup_track_functions_fix_view_security.sql +++ /dev/null @@ -1,116 +0,0 @@ --- ================================================================= --- Onda 19 follow-up: rastrear funções de trigger + restaurar segurança da view --- --- Contexto (PR #214 review items não resolvidos): --- --- P1 (CodeRabbit) — fn_quotes_calc_real_values e fn_kit_print_area_normalizar_eixos --- existiam apenas como drift de PROD; não estavam em nenhuma migration. --- Em `supabase db reset` / novo ambiente, os triggers recriados pela Onda 19 --- falhariam com "function does not exist". Este migration registra as --- definições exatas conforme extraído de PROD em 2026-05-15. --- --- P1/P2 (Copilot + CodeRabbit) — DROP+RECREATE de v_audit_paradoxos_gravacao --- na Onda 19 resetou `security_invoker` e grants: anon e authenticated --- receberam acesso completo (confirmado via pg_class.relacl). A view é --- classificada como "auditoria interna / service_role apenas" em --- docs/redeploy/REDEPLOY-FASE2-EXECUTION-LOG.md — hardening revertido. --- --- Padrão aplicado (idêntico a T15 e t34b): --- ALTER VIEW ... SET (security_invoker = true) --- REVOKE ALL ... FROM anon --- REVOKE ALL ... FROM authenticated --- ================================================================= - -BEGIN; - --- ---------------------------------------------------------------- --- 1. fn_quotes_calc_real_values --- Trigger BEFORE em quotes: calcula real_subtotal e real_discount_percent --- a partir do subtotal com markup já aplicado (negotiation_markup_percent). --- Limite de markup: 0-50% (LEAST/GREATEST). --- ---------------------------------------------------------------- -CREATE OR REPLACE FUNCTION public.fn_quotes_calc_real_values() - RETURNS trigger - LANGUAGE plpgsql - SET search_path TO 'pg_catalog', 'public' -AS $function$ -DECLARE - v_markup numeric; -BEGIN - v_markup := LEAST(50, GREATEST(0, COALESCE(NEW.negotiation_markup_percent, 0))); - NEW.negotiation_markup_percent := v_markup; - - IF v_markup > 0 THEN - NEW.real_subtotal := ROUND(NEW.subtotal / (1 + v_markup / 100.0), 2); - ELSE - NEW.real_subtotal := NEW.subtotal; - END IF; - - IF NEW.real_subtotal > 0 THEN - NEW.real_discount_percent := ROUND( - ((NEW.real_subtotal - (NEW.subtotal - COALESCE(NEW.discount_amount, 0))) / NEW.real_subtotal) * 100, - 2 - ); - ELSE - NEW.real_discount_percent := 0; - END IF; - - RETURN NEW; -END -$function$; - -COMMENT ON FUNCTION public.fn_quotes_calc_real_values() IS - 'Onda 19 follow-up: registra em migrations função que existia só como drift. ' - 'Calcula real_subtotal e real_discount_percent a partir do subtotal COM markup; ' - 'clamp markup 0-50%. Usada pelo trigger trg_quotes_calc_real_values.'; - --- ---------------------------------------------------------------- --- 2. fn_kit_print_area_normalizar_eixos --- Trigger BEFORE em kit_component_print_areas: arredonda max_width/max_height --- a 2 casas decimais e garante largura >= altura (normalização de eixos). --- ---------------------------------------------------------------- -CREATE OR REPLACE FUNCTION public.fn_kit_print_area_normalizar_eixos() - RETURNS trigger - LANGUAGE plpgsql - SET search_path TO 'pg_catalog', 'public' -AS $function$ -DECLARE - v_temp numeric; -BEGIN - -- Arredondar a 2 casas decimais (resolução 1mm) - GAP #6 - IF NEW.max_width IS NOT NULL THEN - NEW.max_width := ROUND(NEW.max_width::numeric, 2); - END IF; - IF NEW.max_height IS NOT NULL THEN - NEW.max_height := ROUND(NEW.max_height::numeric, 2); - END IF; - - -- Normalizar eixos: largura sempre >= altura - GAP #10 - IF NEW.max_height IS NOT NULL AND NEW.max_width IS NOT NULL - AND NEW.max_height > NEW.max_width THEN - v_temp := NEW.max_width; - NEW.max_width := NEW.max_height; - NEW.max_height := v_temp; - END IF; - - RETURN NEW; -END -$function$; - -COMMENT ON FUNCTION public.fn_kit_print_area_normalizar_eixos() IS - 'Onda 19 follow-up: registra em migrations função que existia só como drift. ' - 'Arredonda max_width/max_height a 2 casas e normaliza eixos (largura >= altura). ' - 'Usada pelo trigger trg_kit_print_area_normalizar_eixos.'; - --- ---------------------------------------------------------------- --- 3. Restaurar hardening de v_audit_paradoxos_gravacao --- A Onda 19 fez DROP + CREATE OR REPLACE que resetou reloptions e ACL. --- pg_class.relacl pós-Onda19 confirmou: anon e authenticated com acesso total. --- View é interna/service_role-only (REDEPLOY-FASE2-EXECUTION-LOG.md). --- ---------------------------------------------------------------- -ALTER VIEW public.v_audit_paradoxos_gravacao SET (security_invoker = true); - -REVOKE ALL ON public.v_audit_paradoxos_gravacao FROM anon; -REVOKE ALL ON public.v_audit_paradoxos_gravacao FROM authenticated; - -COMMIT; From 7d3f93471efc6d16f85b72e5f643e4518ecda579 Mon Sep 17 00:00:00 2001 From: adm01-debug Date: Fri, 15 May 2026 15:30:47 -0300 Subject: [PATCH 8/8] chore(db): remove old-timestamp 20260515120000_fix_audit_ownership_orphans_uuid_only.sql --- ..._fix_audit_ownership_orphans_uuid_only.sql | 82 ------------------- 1 file changed, 82 deletions(-) delete mode 100644 supabase/migrations/20260515120000_fix_audit_ownership_orphans_uuid_only.sql diff --git a/supabase/migrations/20260515120000_fix_audit_ownership_orphans_uuid_only.sql b/supabase/migrations/20260515120000_fix_audit_ownership_orphans_uuid_only.sql deleted file mode 100644 index 911cb68a1..000000000 --- a/supabase/migrations/20260515120000_fix_audit_ownership_orphans_uuid_only.sql +++ /dev/null @@ -1,82 +0,0 @@ --- ============================================================================ --- Fix: audit_ownership_orphans tentava cast ::uuid em colunas TEXT, quebrava --- com valores como "system" em enriched_contacts.created_by. Agora só --- considera colunas com data_type='uuid'. Mais robusto que manter blacklist. --- --- Data: 15/mai/2026 --- Bug detectado: ownership-audit edge function retornava HTTP 500 com --- "invalid input syntax for type uuid: \"system\"" --- Causa raiz: enriched_contacts.created_by é TEXT com 48 linhas de "system". --- ============================================================================ - -CREATE OR REPLACE FUNCTION public.audit_ownership_orphans(_triggered_by text DEFAULT 'manual'::text) - RETURNS uuid - LANGUAGE plpgsql - SECURITY DEFINER - SET search_path TO 'public' -AS $function$ -DECLARE - v_started_at timestamptz := clock_timestamp(); - v_report_id uuid; - v_owner_columns text[] := ARRAY['seller_id', 'user_id', 'owner_id', 'created_by']; - v_table record; - v_col text; - v_null_count bigint; - v_orphan_count bigint; - v_total_null bigint := 0; - v_total_orphan bigint := 0; - v_tables_scanned int := 0; - v_details jsonb := '[]'::jsonb; - v_table_entry jsonb; - v_rls jsonb; - v_rls_gaps int := 0; -BEGIN - IF auth.uid() IS NOT NULL AND NOT (has_role(auth.uid(), 'admin'::app_role) OR has_role(auth.uid(), 'dev'::app_role)) THEN - RAISE EXCEPTION 'audit_ownership_orphans: acesso negado'; - END IF; - - FOR v_table IN - SELECT c.table_name, c.column_name - FROM information_schema.columns c - JOIN information_schema.tables t ON t.table_schema = c.table_schema AND t.table_name = c.table_name - WHERE c.table_schema = 'public' - AND c.column_name = ANY(v_owner_columns) - AND c.data_type = 'uuid' -- FIX: ignora colunas TEXT (ex: enriched_contacts.created_by='system') - AND t.table_type = 'BASE TABLE' - AND c.table_name NOT IN ('login_attempts','step_up_audit_log','search_analytics','query_telemetry','mcp_access_violations','product_views','quote_history','optimization_queue','kit_templates') - ORDER BY c.table_name - LOOP - v_col := v_table.column_name; - v_tables_scanned := v_tables_scanned + 1; - EXECUTE format('SELECT count(*) FROM public.%I WHERE %I IS NULL', v_table.table_name, v_col) INTO v_null_count; - EXECUTE format('SELECT count(*) FROM public.%I t WHERE t.%I IS NOT NULL AND NOT EXISTS (SELECT 1 FROM auth.users u WHERE u.id = t.%I)', - v_table.table_name, v_col, v_col) INTO v_orphan_count; - IF v_null_count > 0 OR v_orphan_count > 0 THEN - v_table_entry := jsonb_build_object('table', v_table.table_name, 'owner_column', v_col, - 'null_owner_count', v_null_count, 'missing_user_count', v_orphan_count); - v_details := v_details || v_table_entry; - END IF; - v_total_null := v_total_null + v_null_count; - v_total_orphan := v_total_orphan + v_orphan_count; - END LOOP; - - v_rls := public.audit_rls_coverage(); - SELECT COALESCE(SUM(jsonb_array_length(elem->'missing_ops')),0)::int INTO v_rls_gaps - FROM jsonb_array_elements(v_rls) elem; - - INSERT INTO public.ownership_audit_reports ( - total_tables_scanned, total_issues_found, null_owner_count, missing_user_count, details, - triggered_by, duration_ms, rls_coverage, rls_gaps_count - ) VALUES ( - v_tables_scanned, (v_total_null + v_total_orphan)::int, v_total_null::int, v_total_orphan::int, v_details, - coalesce(_triggered_by, 'manual'), - EXTRACT(MILLISECONDS FROM (clock_timestamp() - v_started_at))::int, - v_rls, v_rls_gaps - ) RETURNING id INTO v_report_id; - - RETURN v_report_id; -END; -$function$; - -COMMENT ON FUNCTION public.audit_ownership_orphans(text) IS - 'Audita propriedade de registros em tabelas com colunas UUID owner. Versão corrigida (15/mai/2026): ignora colunas TEXT como enriched_contacts.created_by que armazenam valores não-UUID como "system".';