SET statement_timeout = 0; SET lock_timeout = 0; SET idle_in_transaction_session_timeout = 0; SET client_encoding = 'UTF8'; SET standard_conforming_strings = on; SELECT pg_catalog.set_config('search_path', '', false); SET check_function_bodies = false; SET xmloption = content; SET client_min_messages = warning; SET row_security = off; CREATE SCHEMA IF NOT EXISTS "public"; ALTER SCHEMA "public" OWNER TO "pg_database_owner"; COMMENT ON SCHEMA "public" IS 'DeskcommCRM v0.1 - Migration 0001 platform_base applied 2026-04-28'; CREATE OR REPLACE FUNCTION "public"."activate_kb_version"("p_agent_id" "uuid", "p_version_id" "uuid") RETURNS "void" LANGUAGE "plpgsql" SECURITY DEFINER SET "search_path" TO 'public', 'pg_temp' AS $$ declare v_org uuid; v_version_org uuid; begin select organization_id into v_org from public.ai_agents where id = p_agent_id; if v_org is null then raise exception 'agent_not_found' using errcode = 'P0002'; end if; select organization_id into v_version_org from public.ai_knowledge_versions where id = p_version_id and agent_id = p_agent_id; if v_version_org is null or v_version_org <> v_org then raise exception 'kb_version_not_found_or_cross_tenant' using errcode = '42501'; end if; update public.ai_knowledge_versions set is_active = false where agent_id = p_agent_id and id <> p_version_id and is_active = true; update public.ai_knowledge_versions set is_active = true, activated_at = coalesce(activated_at, now()) where id = p_version_id; update public.ai_agents set active_kb_version_id = p_version_id, updated_at = now() where id = p_agent_id; end$$; ALTER FUNCTION "public"."activate_kb_version"("p_agent_id" "uuid", "p_version_id" "uuid") OWNER TO "postgres"; COMMENT ON FUNCTION "public"."activate_kb_version"("p_agent_id" "uuid", "p_version_id" "uuid") IS 'Atomically activate a knowledge_version for an agent. Validates tenant scope.'; CREATE OR REPLACE FUNCTION "public"."emit_event"("p_event_type" "text", "p_entity_kind" "text", "p_entity_id" "uuid", "p_payload" "jsonb" DEFAULT '{}'::"jsonb", "p_metadata" "jsonb" DEFAULT '{}'::"jsonb", "p_organization_id" "uuid" DEFAULT NULL::"uuid") RETURNS "uuid" LANGUAGE "plpgsql" SECURITY DEFINER SET "search_path" TO 'public' AS $$ declare v_org_id uuid; v_event_id uuid; begin v_org_id := p_organization_id; if v_org_id is null then -- Try to resolve from caller's first org (best-effort; trigger callers MUST pass it) select organization_id into v_org_id from public.user_organizations where user_id = auth.uid() and revoked_at is null limit 1; end if; if v_org_id is null then raise exception 'emit_event: organization_id obrigatorio'; end if; insert into public.event_log (organization_id, event_type, entity_kind, entity_id, payload, metadata) values (v_org_id, p_event_type, p_entity_kind, p_entity_id, coalesce(p_payload, '{}'::jsonb), coalesce(p_metadata, '{}'::jsonb) || jsonb_build_object('emitted_at', extract(epoch from now()))) returning id into v_event_id; return v_event_id; end $$; ALTER FUNCTION "public"."emit_event"("p_event_type" "text", "p_entity_kind" "text", "p_entity_id" "uuid", "p_payload" "jsonb", "p_metadata" "jsonb", "p_organization_id" "uuid") OWNER TO "postgres"; CREATE OR REPLACE FUNCTION "public"."fn_audit_log_row"() RETURNS "trigger" LANGUAGE "plpgsql" SECURITY DEFINER SET "search_path" TO 'public' AS $$ declare v_action text; v_org uuid; begin if tg_op = 'INSERT' then v_action := tg_table_name || '.created'; v_org := new.organization_id; elsif tg_op = 'UPDATE' then v_action := tg_table_name || '.updated'; v_org := new.organization_id; elsif tg_op = 'DELETE' then v_action := tg_table_name || '.deleted'; v_org := old.organization_id; end if; insert into public.api_audit_log (organization_id, actor_user_id, action, resource_type, resource_id, metadata) values ( v_org, auth.uid(), v_action, tg_table_name, coalesce(new.id, old.id), case when tg_op = 'UPDATE' then jsonb_build_object('changed_fields', '[diff suppressed in v0.1]') else '{}'::jsonb end ); return coalesce(new, old); end$$; ALTER FUNCTION "public"."fn_audit_log_row"() OWNER TO "postgres"; CREATE OR REPLACE FUNCTION "public"."fn_crm_lead_close_on_stage"() RETURNS "trigger" LANGUAGE "plpgsql" SET "search_path" TO 'public', 'pg_temp' AS $$ declare v_is_won boolean; v_is_lost boolean; begin if tg_op = 'UPDATE' and new.stage_id is not distinct from old.stage_id and new.status is not distinct from old.status then return new; end if; select is_won, is_lost into v_is_won, v_is_lost from public.crm_stages where id = new.stage_id; if v_is_won then new.status := 'won'; new.closed_at := coalesce(new.closed_at, now()); elsif v_is_lost then new.status := 'lost'; new.closed_at := coalesce(new.closed_at, now()); -- #1537: DE QUEM ERA a etapa que este negócio deixou — a perda sem a -- etapa de origem não diz em que momento o funil vazou. Só na transição: -- um card já perdido arrastado entre etapas de perda mantém a origem -- verdadeira (a etapa ABERTA em que morreu), e reabrir não a herda. -- Todo caminho de perda MOVE a etapa (quadro, 0209 em lote, 0263 em lote, -- encerramento) e este gatilho é o único escritor de `status` (P-02), então -- a transição de status SEM troca de etapa não existe para escrever aqui. if tg_op = 'UPDATE' and old.status is distinct from 'lost' then new.lost_from_stage_id := coalesce(new.lost_from_stage_id, old.stage_id); end if; else if tg_op = 'UPDATE' and old.status in ('won','lost') then new.status := 'open'; new.closed_at := null; -- #1537: reabriu — a origem da perda passada não pertence a um negócio -- que voltou a ser aberto. Se morrer de novo, nasce a nova origem. new.lost_from_stage_id := null; end if; end if; return new; end$$; ALTER FUNCTION "public"."fn_crm_lead_close_on_stage"() OWNER TO "postgres"; CREATE OR REPLACE FUNCTION "public"."fn_decrypt_oauth"("ciphertext" "bytea") RETURNS "text" LANGUAGE "plpgsql" SECURITY DEFINER SET "search_path" TO 'public', 'pg_temp' AS $$ declare k text := current_setting('app.nuvemshop_oauth_key', true); begin return pgp_sym_decrypt(ciphertext, k); end$$; ALTER FUNCTION "public"."fn_decrypt_oauth"("ciphertext" "bytea") OWNER TO "postgres"; CREATE OR REPLACE FUNCTION "public"."fn_emit_channel_session_status_changed"() RETURNS "trigger" LANGUAGE "plpgsql" SET "search_path" TO 'public', 'pg_temp' AS $$ begin perform public.fn_log_event( new.organization_id, 'channel_session.status_changed', jsonb_build_object( 'channel_session_id', new.id, 'from_status', old.status, 'to_status', new.status, 'status_reason', new.status_reason, 'phone_number', new.phone_number ) ); return new; end$$; ALTER FUNCTION "public"."fn_emit_channel_session_status_changed"() OWNER TO "postgres"; CREATE OR REPLACE FUNCTION "public"."fn_emit_event_on_lead_change"() RETURNS "trigger" LANGUAGE "plpgsql" SET "search_path" TO 'public', 'pg_temp' AS $$ begin if tg_op = 'INSERT' then perform public.fn_log_event( new.organization_id, 'lead.created', jsonb_build_object('lead_id', new.id, 'pipeline_id', new.pipeline_id, 'stage_id', new.stage_id, 'contact_id', new.contact_id, 'source', new.source) ); return new; end if; if new.stage_id is distinct from old.stage_id then perform public.fn_log_event( new.organization_id, 'lead.stage_changed', jsonb_build_object('lead_id', new.id, 'from_stage_id', old.stage_id, 'to_stage_id', new.stage_id) ); end if; if new.status is distinct from old.status then if new.status = 'won' then perform public.fn_log_event(new.organization_id, 'lead.won', jsonb_build_object('lead_id', new.id, 'value_cents', new.value_cents)); elsif new.status = 'lost' then perform public.fn_log_event(new.organization_id, 'lead.lost', jsonb_build_object('lead_id', new.id, 'lost_reason', new.lost_reason)); elsif new.status = 'open' then perform public.fn_log_event(new.organization_id, 'lead.reopened', jsonb_build_object('lead_id', new.id)); end if; end if; if new.owner_user_id is distinct from old.owner_user_id then perform public.fn_log_event(new.organization_id, 'lead.assigned', jsonb_build_object('lead_id', new.id, 'from_user_id', old.owner_user_id, 'to_user_id', new.owner_user_id)); end if; return new; end$$; ALTER FUNCTION "public"."fn_emit_event_on_lead_change"() OWNER TO "postgres"; CREATE OR REPLACE FUNCTION "public"."fn_emit_message_event"() RETURNS "trigger" LANGUAGE "plpgsql" SET "search_path" TO 'public', 'pg_temp' AS $$ declare v_event text; begin if new.direction = 'inbound' then v_event := 'message.received'; else v_event := case new.status when 'sending' then 'message.sending' when 'sent' then 'message.sent' when 'failed' then 'message.failed' else 'message.outbound' end; end if; perform public.fn_log_event( new.organization_id, v_event, jsonb_build_object( 'message_id', new.id, 'conversation_id', new.conversation_id, 'contact_id', new.contact_id, 'direction', new.direction, 'type', new.type, 'status', new.status, 'external_id', new.external_id, 'channel_session_id', new.channel_session_id, 'body_preview', "left"(new.body, 280) ) ); return new; end$$; ALTER FUNCTION "public"."fn_emit_message_event"() OWNER TO "postgres"; CREATE OR REPLACE FUNCTION "public"."fn_encrypt_oauth"("plaintext" "text") RETURNS "bytea" LANGUAGE "plpgsql" SECURITY DEFINER SET "search_path" TO 'public', 'pg_temp' AS $$ declare k text := current_setting('app.nuvemshop_oauth_key', true); begin if k is null or length(k) < 32 then raise exception 'NUVEMSHOP_OAUTH_ENCRYPTION_KEY ausente'; end if; return pgp_sym_encrypt(plaintext, k, 'cipher-algo=aes256'); end$$; ALTER FUNCTION "public"."fn_encrypt_oauth"("plaintext" "text") OWNER TO "postgres"; CREATE OR REPLACE FUNCTION "public"."fn_is_platform_admin"() RETURNS boolean LANGUAGE "sql" STABLE SECURITY DEFINER SET "search_path" TO 'public' AS $$ select exists ( select 1 from public.platform_admins where user_id = auth.uid() and revoked_at is null ); $$; ALTER FUNCTION "public"."fn_is_platform_admin"() OWNER TO "postgres"; CREATE OR REPLACE FUNCTION "public"."fn_lgpd_cascade_redact_contact"("p_organization_id" "uuid", "p_contact_id" "uuid", "p_request_id" "uuid") RETURNS "jsonb" LANGUAGE "plpgsql" SECURITY DEFINER SET "search_path" TO 'public' AS $$ declare v_already bool; v_counts jsonb := '{}'::jsonb; v_media_paths text[] := '{}'; v_anon_label text; v_count int; begin select is_anonymized into v_already from contacts where id = p_contact_id and organization_id = p_organization_id; if not found then raise exception 'contact not found' using errcode = 'P0002'; end if; if v_already then return jsonb_build_object('already_anonymized', true, 'counts', v_counts, 'media_paths', v_media_paths); end if; v_anon_label := 'Cliente Anonimizado #' || substring(p_contact_id::text from 1 for 8); -- Collect media storage paths (we only delete what we own — media_storage_path) select coalesce(array_agg(distinct media_storage_path) filter (where media_storage_path is not null), '{}') into v_media_paths from messages where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ); -- 1. contacts (irreversible) update contacts set name = v_anon_label, display_name = v_anon_label, email = null, -- email_normalized NÃO entra: é GENERATED ALWAYS AS (lower(trim(email))) -- e o Postgres recusa escrita nela — a linha acima já a zera por derivação. -- Com a atribuição, o cascade INTEIRO abortava e nada era anonimizado. phone_number = null, cpf_encrypted = null, cpf_hash = null, birthdate = null, is_anonymized = true, anonymized_at = now(), consent = '{}'::jsonb, source_metadata = '{}'::jsonb, tags = '{}'::text[], updated_at = now() where id = p_contact_id and organization_id = p_organization_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('contacts', v_count); -- 2. conversations metadata + preview strip update conversations set metadata = '{}'::jsonb, last_message_preview = null, updated_at = now() where contact_id = p_contact_id and organization_id = p_organization_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('conversations', v_count); -- 3. messages: redact body + null media + strip metadata (preserve status/timestamps/conversation_id) update messages set body = '[mensagem anonimizada]', media_url = null, media_mime = null, media_size_bytes = null, media_storage_path = null, metadata = '{}'::jsonb, updated_at = now() where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('messages', v_count); -- 4. crm_lead_activities — strip payload, metadata E reason (migration 0071). -- `reason` é texto livre escrito por LLM sobre a conversa do lead: supor que -- nunca conterá um nome é a suposição que falha. `evidence` NÃO é limpa — -- guarda só ids, e as linhas apontadas são redigidas por conta própria. update crm_lead_activities set payload = '{}'::jsonb, metadata = '{}'::jsonb, reason = null where organization_id = p_organization_id and ( contact_id = p_contact_id or lead_id in ( select lead_id from crm_lead_links where target_kind = 'contact' and target_id = p_contact_id and organization_id = p_organization_id ) or lead_id in ( select id from crm_leads where contact_id = p_contact_id and organization_id = p_organization_id ) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('activities', v_count); -- 5. crm_leads — strip title/description/custom_fields/source_metadata/tags but PRESERVE pipeline/stage/value update crm_leads set title = v_anon_label, description = null, custom_fields = '{}'::jsonb, source_metadata = '{}'::jsonb, tags = '{}'::text[], updated_at = now() where organization_id = p_organization_id and ( contact_id = p_contact_id or id in ( select lead_id from crm_lead_links where target_kind = 'contact' and target_id = p_contact_id and organization_id = p_organization_id ) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('leads', v_count); -- 6. orders — PRESERVE values + status + timestamps. Strip personal fields from payload jsonb -- and replace customer_external_id with null (FK-safe; soft de-link). Keep contact_id null. update orders set payload = (coalesce(payload, '{}'::jsonb)) - 'customer' - 'customer_name' - 'customer_email' - 'customer_phone' - 'shipping_address' - 'billing_address' - 'contact_identification', customer_external_id = null, contact_id = null, is_anonymized = true, updated_at = now() where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('orders', v_count); -- 7. enqueue media for async deletion (idempotent via unique (bucket, object_path)) if array_length(v_media_paths, 1) > 0 then insert into storage_redaction_queue (organization_id, request_id, bucket, object_path) select p_organization_id, p_request_id, 'whatsapp-media', path from unnest(v_media_paths) as path where path is not null and length(path) > 0 on conflict (bucket, object_path) do nothing; end if; -- 8. dense audit row insert into api_audit_log (organization_id, action, actor_user_id, resource_type, resource_id, metadata, bypassed_rls) values ( p_organization_id, 'lgpd.redact_executed', null, 'contact', p_contact_id, jsonb_build_object( 'cascaded_to', v_counts, 'media_queued', coalesce(array_length(v_media_paths, 1), 0), 'request_id', p_request_id ), true ); return jsonb_build_object( 'already_anonymized', false, 'counts', v_counts, 'media_paths', v_media_paths ); end; $$; ALTER FUNCTION "public"."fn_lgpd_cascade_redact_contact"("p_organization_id" "uuid", "p_contact_id" "uuid", "p_request_id" "uuid") OWNER TO "postgres"; CREATE OR REPLACE FUNCTION "public"."fn_log_event"("p_organization_id" "uuid", "p_event_type" "text", "p_payload" "jsonb" DEFAULT '{}'::"jsonb") RETURNS "uuid" LANGUAGE "plpgsql" SECURITY DEFINER SET "search_path" TO 'public' AS $$ declare v_entity_kind text; v_entity_id uuid; begin -- Derive entity_kind from event_type (e.g. 'lead.created' -> 'lead') v_entity_kind := split_part(p_event_type, '.', 1); v_entity_id := (p_payload ->> 'lead_id')::uuid; if v_entity_id is null then v_entity_id := (p_payload ->> (v_entity_kind || '_id'))::uuid; end if; return public.emit_event( p_event_type, v_entity_kind, v_entity_id, p_payload, '{}'::jsonb, p_organization_id ); end $$; ALTER FUNCTION "public"."fn_log_event"("p_organization_id" "uuid", "p_event_type" "text", "p_payload" "jsonb") OWNER TO "postgres"; CREATE OR REPLACE FUNCTION "public"."fn_publish_ai_agent_version"("p_org_id" "uuid", "p_agent_id" "uuid", "p_version_id" "uuid") RETURNS TABLE("agent_id" "uuid", "version_id" "uuid", "previous_version_id" "uuid", "published_at" timestamp with time zone) LANGUAGE "plpgsql" SECURITY DEFINER SET "search_path" TO 'public' AS $$ declare v_agent record; v_version record; v_credential record; v_session record; v_model_count integer; v_previous_version_id uuid; v_published_at timestamptz := now(); begin select a.id, a.organization_id, a.published_version_id, a.archived_at into v_agent from public.ai_agents a where a.id = p_agent_id for update; if not found then raise exception 'agent_not_found' using errcode = 'P0001'; end if; if v_agent.organization_id <> p_org_id then raise exception 'agent_not_found' using errcode = 'P0001'; end if; if v_agent.archived_at is not null then raise exception 'agent_archived' using errcode = 'P0001'; end if; select v.id, v.organization_id, v.agent_id, v.status, v.provider, v.model, v.credential_id, v.channel_session_id into v_version from public.ai_agent_versions v where v.id = p_version_id for update; if not found then raise exception 'version_not_found' using errcode = 'P0001'; end if; if v_version.agent_id <> p_agent_id or v_version.organization_id <> p_org_id then raise exception 'version_not_found' using errcode = 'P0001'; end if; if v_version.status not in ('draft', 'superseded') then raise exception 'version_invalid_state' using errcode = 'P0001'; end if; if v_version.credential_id is null then raise exception 'credential_missing' using errcode = 'P0001'; end if; select c.id, c.organization_id, c.provider, c.is_active, c.validated_at into v_credential from public.ai_provider_credentials c where c.id = v_version.credential_id; if not found or v_credential.organization_id <> p_org_id then raise exception 'credential_not_found' using errcode = 'P0001'; end if; if not v_credential.is_active then raise exception 'credential_inactive' using errcode = 'P0001'; end if; if v_credential.validated_at is null then raise exception 'credential_not_validated' using errcode = 'P0001'; end if; if v_credential.provider <> v_version.provider then raise exception 'credential_provider_mismatch' using errcode = 'P0001'; end if; select s.id, s.organization_id, s.status into v_session from public.channel_sessions s where s.id = v_version.channel_session_id; if not found or v_session.organization_id <> p_org_id then raise exception 'channel_session_not_found' using errcode = 'P0001'; end if; if v_session.status <> 'WORKING' then raise exception 'channel_session_offline' using errcode = 'P0001'; end if; select count(*) into v_model_count from public.ai_models m where m.provider = v_version.provider and m.model_id = v_version.model and m.deprecated_at is null; if v_model_count = 0 then raise exception 'model_not_found' using errcode = 'P0001'; end if; v_previous_version_id := v_agent.published_version_id; if v_previous_version_id is not null and v_previous_version_id <> p_version_id then update public.ai_agent_versions set status = 'superseded', superseded_at = v_published_at where id = v_previous_version_id; end if; update public.ai_agent_versions set status = 'published', published_at = v_published_at, superseded_at = null where id = p_version_id; update public.ai_agents set published_version_id = p_version_id, updated_at = v_published_at where id = p_agent_id; return query select p_agent_id, p_version_id, v_previous_version_id, v_published_at; end; $$; ALTER FUNCTION "public"."fn_publish_ai_agent_version"("p_org_id" "uuid", "p_agent_id" "uuid", "p_version_id" "uuid") OWNER TO "postgres"; COMMENT ON FUNCTION "public"."fn_publish_ai_agent_version"("p_org_id" "uuid", "p_agent_id" "uuid", "p_version_id" "uuid") IS 'EPIC-13 S-13.06 (fixed in 0025): atomic Save/Publish flip. Column refs qualified to avoid ambiguity with RETURNS TABLE OUT params.'; CREATE OR REPLACE FUNCTION "public"."fn_role_at_least"("p_org" "uuid", "p_min" "text") RETURNS boolean LANGUAGE "sql" STABLE SECURITY DEFINER SET "search_path" TO 'public' AS $$ with levels(role, lvl) as ( values ('viewer',1),('agent',2),('manager',3),('admin',4) ) select coalesce( (select user_lvl.lvl >= min_lvl.lvl from levels user_lvl join levels min_lvl on min_lvl.role = p_min where user_lvl.role = public.fn_user_role_in_org(p_org)), false ); $$; ALTER FUNCTION "public"."fn_role_at_least"("p_org" "uuid", "p_min" "text") OWNER TO "postgres"; CREATE OR REPLACE FUNCTION "public"."fn_seed_default_pipeline_for_org"() RETURNS "trigger" LANGUAGE "plpgsql" SET "search_path" TO 'public', 'pg_temp' AS $$ declare v_pipeline_id uuid; v_position numeric := 1000; r record; begin insert into public.crm_pipelines (organization_id, name, slug, is_default, position) values (new.id, 'Pedidos', 'pedidos', true, 1000) returning id into v_pipeline_id; for r in select * from (values ('Carrinho abandonado', 'carrinho_abandonado', false, false), ('Aguardando pagamento', 'aguardando_pagamento', false, false), ('Pago', 'pago', true, false), ('Em separação', 'em_separacao', false, false), ('Enviado', 'enviado', false, false), ('Entregue', 'entregue', false, false), ('Pós-venda', 'pos_venda', false, false), ('Cancelado', 'cancelado', false, true) ) as t(stage_name, stage_slug, won, lost) loop insert into public.crm_stages (organization_id, pipeline_id, name, slug, position, is_won, is_lost) values (new.id, v_pipeline_id, r.stage_name, r.stage_slug, v_position, r.won, r.lost); v_position := v_position + 1000; end loop; return new; end$$; ALTER FUNCTION "public"."fn_seed_default_pipeline_for_org"() OWNER TO "postgres"; CREATE OR REPLACE FUNCTION "public"."fn_set_updated_at"() RETURNS "trigger" LANGUAGE "plpgsql" SET "search_path" TO 'public', 'pg_temp' AS $$ begin new.updated_at := now(); return new; end $$; ALTER FUNCTION "public"."fn_set_updated_at"() OWNER TO "postgres"; CREATE OR REPLACE FUNCTION "public"."fn_touch_updated_at"() RETURNS "trigger" LANGUAGE "plpgsql" SET "search_path" TO 'public', 'pg_temp' AS $$ begin new.updated_at := now(); return new; end $$; ALTER FUNCTION "public"."fn_touch_updated_at"() OWNER TO "postgres"; CREATE OR REPLACE FUNCTION "public"."fn_update_budget_consumption"() RETURNS "trigger" LANGUAGE "plpgsql" SECURITY DEFINER SET "search_path" TO 'public' AS $$ begin insert into public.ai_budgets (organization_id, current_month_consumed_cents) values (NEW.organization_id, coalesce(NEW.cost_cents, 0)) on conflict (organization_id) do update set current_month_consumed_cents = public.ai_budgets.current_month_consumed_cents + coalesce(NEW.cost_cents, 0), updated_at = now(); return NEW; end; $$; ALTER FUNCTION "public"."fn_update_budget_consumption"() OWNER TO "postgres"; CREATE OR REPLACE FUNCTION "public"."fn_update_last_activity_at"() RETURNS "trigger" LANGUAGE "plpgsql" SET "search_path" TO 'public', 'pg_temp' AS $$ begin update public.crm_leads set last_activity_at = greatest(coalesce(last_activity_at, '-infinity'::timestamptz), new.performed_at) where id = new.lead_id; if new.contact_id is not null then update public.contacts set last_activity_at = greatest(coalesce(last_activity_at, '-infinity'::timestamptz), new.performed_at) where id = new.contact_id; end if; return new; end$$; ALTER FUNCTION "public"."fn_update_last_activity_at"() OWNER TO "postgres"; CREATE OR REPLACE FUNCTION "public"."fn_user_org_ids"() RETURNS SETOF "uuid" LANGUAGE "sql" STABLE SECURITY DEFINER SET "search_path" TO 'public' AS $$ select organization_id from public.user_organizations where user_id = auth.uid() and revoked_at is null; $$; ALTER FUNCTION "public"."fn_user_org_ids"() OWNER TO "postgres"; CREATE OR REPLACE FUNCTION "public"."fn_user_role_in"("p_org" "uuid") RETURNS integer LANGUAGE "sql" STABLE SECURITY DEFINER SET "search_path" TO 'public' AS $$ select case public.fn_user_role_in_org(p_org) when 'viewer' then 1 when 'agent' then 2 when 'manager' then 3 when 'admin' then 4 else 0 end; $$; ALTER FUNCTION "public"."fn_user_role_in"("p_org" "uuid") OWNER TO "postgres"; CREATE OR REPLACE FUNCTION "public"."fn_user_role_in_org"("p_org" "uuid") RETURNS "text" LANGUAGE "sql" STABLE SECURITY DEFINER SET "search_path" TO 'public' AS $$ select role from public.user_organizations where user_id = auth.uid() and organization_id = p_org and revoked_at is null limit 1; $$; ALTER FUNCTION "public"."fn_user_role_in_org"("p_org" "uuid") OWNER TO "postgres"; CREATE OR REPLACE FUNCTION "public"."fn_validate_activity_lead_org"() RETURNS "trigger" LANGUAGE "plpgsql" SET "search_path" TO 'public', 'pg_temp' AS $$ declare v_org uuid; begin select organization_id into v_org from public.crm_leads where id = new.lead_id; if v_org is null then raise exception 'lead_not_found' using errcode = '23503'; end if; if v_org <> new.organization_id then raise exception 'lead_org_mismatch' using errcode = '23514'; end if; return new; end$$; ALTER FUNCTION "public"."fn_validate_activity_lead_org"() OWNER TO "postgres"; CREATE OR REPLACE FUNCTION "public"."fn_validate_lost_reason_required"() RETURNS "trigger" LANGUAGE "plpgsql" SET "search_path" TO 'public', 'pg_temp' AS $$ declare v_canonical text[] := array['requested_by_customer','price','no_response','product_unavailable', 'cancelled_by_store','cancelled_by_customer','payment_failed','other']; v_pipeline_extra text[]; begin if new.status = 'lost' then if new.lost_reason is null or length(new.lost_reason) = 0 then raise exception 'lost_reason_required' using errcode = '22023'; end if; select coalesce( array(select jsonb_array_elements_text(settings->'lost_reasons')), '{}'::text[] ) into v_pipeline_extra from public.crm_pipelines where id = new.pipeline_id; if not (new.lost_reason = any (v_canonical) or new.lost_reason = any (v_pipeline_extra)) then raise exception 'lost_reason_invalid: %', new.lost_reason using errcode = '22023'; end if; end if; return new; end$$; ALTER FUNCTION "public"."fn_validate_lost_reason_required"() OWNER TO "postgres"; CREATE OR REPLACE FUNCTION "public"."midpoint"("p_prev" numeric, "p_next" numeric) RETURNS numeric LANGUAGE "sql" IMMUTABLE SET "search_path" TO 'public', 'pg_temp' AS $$ select case when p_prev is null and p_next is null then 1000::numeric when p_prev is null then p_next - 1 when p_next is null then p_prev + 1 else (p_prev + p_next) / 2 end $$; ALTER FUNCTION "public"."midpoint"("p_prev" numeric, "p_next" numeric) OWNER TO "postgres"; CREATE OR REPLACE FUNCTION "public"."retrieve_top_k_chunks"("p_organization_id" "uuid", "p_kb_version_id" "uuid", "p_embedding" "public"."vector", "p_k" integer DEFAULT 5, "p_threshold" real DEFAULT 0.72) RETURNS TABLE("chunk_id" "uuid", "knowledge_source_id" "uuid", "content" "text", "similarity" real, "metadata" "jsonb") LANGUAGE "sql" STABLE SECURITY DEFINER SET "search_path" TO 'public', 'pg_temp' AS $$ select c.id as chunk_id, c.knowledge_source_id, c.content, (1 - (c.embedding <=> p_embedding))::real as similarity, c.metadata from public.ai_chunks c where c.organization_id = p_organization_id and c.kb_version_id = p_kb_version_id and (1 - (c.embedding <=> p_embedding)) >= p_threshold order by c.embedding <=> p_embedding asc limit greatest(p_k, 0); $$; ALTER FUNCTION "public"."retrieve_top_k_chunks"("p_organization_id" "uuid", "p_kb_version_id" "uuid", "p_embedding" "public"."vector", "p_k" integer, "p_threshold" real) OWNER TO "postgres"; COMMENT ON FUNCTION "public"."retrieve_top_k_chunks"("p_organization_id" "uuid", "p_kb_version_id" "uuid", "p_embedding" "public"."vector", "p_k" integer, "p_threshold" real) IS 'Top-K cosine similarity over ai_chunks. SECURITY DEFINER + programmatic org_id filter. Caller must validate p_organization_id matches authenticated tenant.'; -- `rls_auto_enable()` (event trigger candidato para ligar RLS automaticamente em -- toda CREATE TABLE) foi removida em 2026-08-27: nunca existiu um `CREATE EVENT -- TRIGGER ... EXECUTE FUNCTION rls_auto_enable()` em lugar nenhum do baseline ou -- das migrations, então a função nunca foi de fato invocada pelo Postgres — e, -- sendo do tipo `event_trigger`, também não pode ser chamada manualmente via -- SQL. Era uma promessa de proteção automática que o código nunca cumpriu; quem -- lesse o baseline podia concluir, errado, que tabela nova nascia com RLS -- ligada sozinha. A garantia real de isolamento por tabela é comportamental -- (tests/invariants/rls-isolation.test.ts + rls-completude-varredura.test.ts), -- não um event trigger. Ligar o event trigger de verdade é mudança de -- comportamento de runtime do banco e mereceria revisão própria — não esta. SET default_tablespace = ''; SET default_table_access_method = "heap"; CREATE TABLE IF NOT EXISTS "public"."ai_agent_runs" ( "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, "organization_id" "uuid" NOT NULL, "agent_id" "uuid" NOT NULL, "agent_version_id" "uuid" NOT NULL, "conversation_id" "uuid", "contact_id" "uuid", "channel_session_id" "uuid", "inbound_message_id" "uuid", "outbound_message_id" "uuid", "status" "text" DEFAULT 'pending'::"text" NOT NULL, "abort_reason" "text", "error_code" "text", "error_message" "text", "tokens_in" integer DEFAULT 0 NOT NULL, "tokens_out" integer DEFAULT 0 NOT NULL, "cost_cents" numeric(10,4) DEFAULT 0 NOT NULL, "latency_ms" integer, "steps_count" integer DEFAULT 0 NOT NULL, "tool_calls" "jsonb" DEFAULT '[]'::"jsonb" NOT NULL, "is_dry_run" boolean DEFAULT false NOT NULL, "started_at" timestamp with time zone DEFAULT "now"() NOT NULL, "completed_at" timestamp with time zone, "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, CONSTRAINT "ai_agent_runs_status_check" CHECK (("status" = ANY (ARRAY['pending'::"text", 'running'::"text", 'completed'::"text", 'failed'::"text", 'aborted'::"text", 'handoff'::"text"]))) ); ALTER TABLE "public"."ai_agent_runs" OWNER TO "postgres"; CREATE TABLE IF NOT EXISTS "public"."ai_agent_versions" ( "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, "organization_id" "uuid" NOT NULL, "agent_id" "uuid" NOT NULL, "version_number" integer NOT NULL, "system_prompt" "text" NOT NULL, "provider" "text" NOT NULL, "model" "text" NOT NULL, "credential_id" "uuid", "tool_ids" "text"[] DEFAULT '{}'::"text"[] NOT NULL, "trigger_config" "jsonb" DEFAULT "jsonb_build_object"('events', "jsonb_build_array"('message'), 'filters', "jsonb_build_object"('ignore_groups', true, 'ignore_self', true, 'keyword_regex', NULL::"unknown", 'business_hours', NULL::"unknown"), 'concurrency', 'one_per_conversation') NOT NULL, "channel_session_id" "uuid" NOT NULL, "max_steps" integer DEFAULT 10 NOT NULL, "token_budget" integer DEFAULT 50000 NOT NULL, "cost_budget_cents" integer DEFAULT 50 NOT NULL, "history_message_window" integer DEFAULT 20 NOT NULL, "history_token_window" integer DEFAULT 8000 NOT NULL, "handoff_keywords" "text"[] DEFAULT ARRAY['falar com humano'::"text", 'atendente'::"text", 'pessoa real'::"text"] NOT NULL, "handoff_tool_enabled" boolean DEFAULT true NOT NULL, "status" "text" DEFAULT 'draft'::"text" NOT NULL, "published_at" timestamp with time zone, "superseded_at" timestamp with time zone, "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, "created_by" "uuid", CONSTRAINT "ai_agent_versions_cost_budget_cents_check" CHECK ((("cost_budget_cents" >= 1) AND ("cost_budget_cents" <= 10000))), CONSTRAINT "ai_agent_versions_max_steps_check" CHECK ((("max_steps" >= 1) AND ("max_steps" <= 25))), CONSTRAINT "ai_agent_versions_provider_check" CHECK (("provider" = ANY (ARRAY['anthropic'::"text", 'openai'::"text", 'google'::"text"]))), CONSTRAINT "ai_agent_versions_status_check" CHECK (("status" = ANY (ARRAY['draft'::"text", 'published'::"text", 'superseded'::"text", 'archived'::"text"]))), CONSTRAINT "ai_agent_versions_token_budget_check" CHECK ((("token_budget" >= 1000) AND ("token_budget" <= 500000))) ); ALTER TABLE "public"."ai_agent_versions" OWNER TO "postgres"; CREATE TABLE IF NOT EXISTS "public"."ai_agents" ( "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, "organization_id" "uuid" NOT NULL, "name" "text" NOT NULL, "description" "text", "is_active" boolean DEFAULT true NOT NULL, "is_default" boolean DEFAULT false NOT NULL, "model" "text" DEFAULT 'anthropic/claude-sonnet-4-6'::"text" NOT NULL, "system_prompt" "text" NOT NULL, "config" "jsonb" DEFAULT "jsonb_build_object"('temperature', 0.3, 'max_tokens', 1024, 'rag_top_k', 5, 'rag_similarity_threshold', 0.72, 'context_message_window', 20, 'confidence_threshold', 0.55, 'sentiment_threshold', 0.3, 'zero_data_retention', false) NOT NULL, "guardrails" "jsonb" DEFAULT '[]'::"jsonb" NOT NULL, "active_kb_version_id" "uuid", "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, "updated_at" timestamp with time zone DEFAULT "now"() NOT NULL, "created_by" "uuid", "published_version_id" "uuid", "priority" integer DEFAULT 0 NOT NULL, "archived_at" timestamp with time zone, "kind" "text" DEFAULT 'rag_bot'::"text" NOT NULL, CONSTRAINT "ai_agents_kind_check" CHECK (("kind" = ANY (ARRAY['rag_bot'::"text", 'mcp_agent'::"text"]))) ); ALTER TABLE "public"."ai_agents" OWNER TO "postgres"; CREATE TABLE IF NOT EXISTS "public"."ai_budgets" ( "organization_id" "uuid" NOT NULL, "monthly_limit_cents" integer DEFAULT 5000 NOT NULL, "action_at_100pct" "text" DEFAULT 'throttle'::"text" NOT NULL, "alarm_threshold_pct" integer DEFAULT 80 NOT NULL, "current_month_consumed_cents" numeric(12,4) DEFAULT 0 NOT NULL, "current_period_start" "date" DEFAULT ("date_trunc"('month'::"text", "now"()))::"date" NOT NULL, "last_alarm_sent_at" timestamp with time zone, "is_throttled" boolean DEFAULT false NOT NULL, "is_disabled" boolean DEFAULT false NOT NULL, "updated_at" timestamp with time zone DEFAULT "now"() NOT NULL, CONSTRAINT "ai_budgets_action_at_100pct_check" CHECK (("action_at_100pct" = ANY (ARRAY['throttle'::"text", 'disable'::"text"]))), CONSTRAINT "ai_budgets_alarm_threshold_pct_check" CHECK ((("alarm_threshold_pct" >= 50) AND ("alarm_threshold_pct" <= 99))) ); ALTER TABLE "public"."ai_budgets" OWNER TO "postgres"; CREATE TABLE IF NOT EXISTS "public"."ai_chunks" ( "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, "organization_id" "uuid" NOT NULL, "knowledge_source_id" "uuid" NOT NULL, "kb_version_id" "uuid" NOT NULL, "position" integer NOT NULL, "content" "text" NOT NULL, "content_hash" "text" NOT NULL, "token_count" integer NOT NULL, "embedding" "public"."vector"(1536) NOT NULL, "metadata" "jsonb" DEFAULT '{}'::"jsonb" NOT NULL, "created_at" timestamp with time zone DEFAULT "now"() NOT NULL ); ALTER TABLE "public"."ai_chunks" OWNER TO "postgres"; CREATE TABLE IF NOT EXISTS "public"."ai_faq_items" ( "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, "organization_id" "uuid" NOT NULL, "knowledge_source_id" "uuid" NOT NULL, "question" "text" NOT NULL, "answer" "text" NOT NULL, "tags" "text"[] DEFAULT '{}'::"text"[] NOT NULL, "locale" "text" DEFAULT 'pt-BR'::"text" NOT NULL, "position" integer DEFAULT 0 NOT NULL, "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, "updated_at" timestamp with time zone DEFAULT "now"() NOT NULL ); ALTER TABLE "public"."ai_faq_items" OWNER TO "postgres"; CREATE TABLE IF NOT EXISTS "public"."ai_invocations" ( "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, "organization_id" "uuid" NOT NULL, "agent_id" "uuid" NOT NULL, "conversation_id" "uuid", "message_id" "uuid", "invocation_kind" "text" NOT NULL, "model" "text" NOT NULL, "prompt_tokens" integer DEFAULT 0 NOT NULL, "completion_tokens" integer DEFAULT 0 NOT NULL, "total_tokens" integer GENERATED ALWAYS AS (("prompt_tokens" + "completion_tokens")) STORED, "latency_ms" integer NOT NULL, "cost_cents" numeric(10,4) DEFAULT 0 NOT NULL, "finish_reason" "text", "citations" "jsonb" DEFAULT '[]'::"jsonb" NOT NULL, "prompt_blob_path" "text", "response_blob_path" "text", "error_payload" "jsonb", "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, CONSTRAINT "ai_invocations_invocation_kind_check" CHECK (("invocation_kind" = ANY (ARRAY['bot_respond'::"text", 'sentiment_classify'::"text", 'triage_classify'::"text", 'embedding_generate'::"text"]))) ); ALTER TABLE "public"."ai_invocations" OWNER TO "postgres"; CREATE TABLE IF NOT EXISTS "public"."ai_knowledge_sources" ( "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, "organization_id" "uuid" NOT NULL, "agent_id" "uuid" NOT NULL, "source_type" "text" NOT NULL, "source_metadata" "jsonb" DEFAULT '{}'::"jsonb" NOT NULL, "is_active" boolean DEFAULT true NOT NULL, "last_indexed_at" timestamp with time zone, "last_index_status" "text", "last_index_error" "text", "chunks_count" integer DEFAULT 0 NOT NULL, "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, "updated_at" timestamp with time zone DEFAULT "now"() NOT NULL, "name" "text" DEFAULT ''::"text" NOT NULL, "status" "text" DEFAULT 'ready'::"text" NOT NULL, "ingested_at" timestamp with time zone, CONSTRAINT "ai_knowledge_sources_last_index_status_check" CHECK (("last_index_status" = ANY (ARRAY['success'::"text", 'partial'::"text", 'failed'::"text"]))), CONSTRAINT "ai_knowledge_sources_source_type_check" CHECK (("source_type" = ANY (ARRAY['faq'::"text", 'policy'::"text", 'catalog'::"text", 'conversations'::"text", 'conversation'::"text", 'nuvemshop_catalog'::"text"]))), CONSTRAINT "ai_knowledge_sources_status_check" CHECK (("status" = ANY (ARRAY['ready'::"text", 'archived'::"text", 'building'::"text", 'failed'::"text"]))) ); ALTER TABLE "public"."ai_knowledge_sources" OWNER TO "postgres"; CREATE TABLE IF NOT EXISTS "public"."ai_knowledge_versions" ( "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, "organization_id" "uuid" NOT NULL, "agent_id" "uuid" NOT NULL, "version_number" integer NOT NULL, "description" "text", "is_active" boolean DEFAULT false NOT NULL, "sources_snapshot" "jsonb" DEFAULT '[]'::"jsonb" NOT NULL, "total_chunks" integer DEFAULT 0 NOT NULL, "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, "activated_at" timestamp with time zone, "activated_by" "uuid", "status" "text" DEFAULT 'building'::"text", "error_message" "text", "indexed_at" timestamp with time zone, CONSTRAINT "ai_knowledge_versions_status_check" CHECK (("status" = ANY (ARRAY['building'::"text", 'ready'::"text", 'failed'::"text"]))) ); ALTER TABLE "public"."ai_knowledge_versions" OWNER TO "postgres"; CREATE TABLE IF NOT EXISTS "public"."ai_models" ( "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, "provider" "text" NOT NULL, "model_id" "text" NOT NULL, "display_name" "text" NOT NULL, "description" "text", "context_window" integer, "input_price_per_million_cents" integer, "output_price_per_million_cents" integer, "supports_tools" boolean DEFAULT true NOT NULL, "is_default_for_provider" boolean DEFAULT false NOT NULL, "deprecated_at" timestamp with time zone, "released_at" timestamp with time zone, "metadata" "jsonb" DEFAULT '{}'::"jsonb" NOT NULL, CONSTRAINT "ai_models_provider_check" CHECK (("provider" = ANY (ARRAY['anthropic'::"text", 'openai'::"text", 'google'::"text"]))) ); ALTER TABLE "public"."ai_models" OWNER TO "postgres"; CREATE TABLE IF NOT EXISTS "public"."ai_pricing" ( "model" "text" NOT NULL, "prompt_cents_per_million_tokens" numeric(10,4), "completion_cents_per_million_tokens" numeric(10,4), "embedding_cents_per_million_tokens" numeric(10,4), "effective_from" timestamp with time zone DEFAULT "now"() NOT NULL, "superseded_at" timestamp with time zone, "notes" "text" ); ALTER TABLE "public"."ai_pricing" OWNER TO "postgres"; CREATE TABLE IF NOT EXISTS "public"."ai_provider_credentials" ( "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, "organization_id" "uuid" NOT NULL, "provider" "text" NOT NULL, "label" "text" NOT NULL, "api_key_encrypted" "bytea" NOT NULL, "api_key_iv" "bytea" NOT NULL, "api_key_tag" "bytea" NOT NULL, "api_key_last4" "text" NOT NULL, "validated_at" timestamp with time zone, "validation_error" "text", "models_available" "text"[], "is_active" boolean DEFAULT true NOT NULL, "created_by" "uuid", "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, "updated_at" timestamp with time zone DEFAULT "now"() NOT NULL, CONSTRAINT "ai_provider_credentials_provider_check" CHECK (("provider" = ANY (ARRAY['anthropic'::"text", 'openai'::"text", 'google'::"text"]))) ); ALTER TABLE "public"."ai_provider_credentials" OWNER TO "postgres"; -- (migration 0413) `base_url` entra AQUI, antes da view do dump, e não só no -- apêndice: o `update.sh`/modo UPDATE reaplica este bloco num banco em que a -- view já tem `base_url`, e `create or replace view` não remove coluna -- ("cannot drop columns from view"). Com a coluna no fim das duas definições, -- a reaplicação é no-op e o clone antigo ganha a coluna no fim (permitido). ALTER TABLE "public"."ai_provider_credentials" ADD COLUMN IF NOT EXISTS "base_url" "text"; CREATE OR REPLACE VIEW "public"."ai_provider_credentials_safe" WITH ("security_invoker"='true') AS SELECT "id", "organization_id", "provider", "label", "api_key_last4", "validated_at", "validation_error", "models_available", "is_active", "created_by", "created_at", "updated_at", "base_url" FROM "public"."ai_provider_credentials"; ALTER VIEW "public"."ai_provider_credentials_safe" OWNER TO "postgres"; CREATE TABLE IF NOT EXISTS "public"."api_audit_log" ( "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, "organization_id" "uuid", "actor_user_id" "uuid", "actor_api_token_id" "uuid", "acting_as_platform_admin" boolean DEFAULT false NOT NULL, "actor_ip" "inet", "actor_user_agent" "text", "action" "text" NOT NULL, "resource_type" "text", "resource_id" "uuid", "request_id" "text", "bypassed_rls" boolean DEFAULT false NOT NULL, "metadata" "jsonb" DEFAULT '{}'::"jsonb" NOT NULL, "created_at" timestamp with time zone DEFAULT "now"() NOT NULL ); ALTER TABLE "public"."api_audit_log" OWNER TO "postgres"; COMMENT ON TABLE "public"."api_audit_log" IS 'L-10: Append-only. Retencao 5 anos.'; CREATE TABLE IF NOT EXISTS "public"."api_tokens" ( "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, "organization_id" "uuid" NOT NULL, "created_by" "uuid" NOT NULL, "name" "text" NOT NULL, "prefix" "text" NOT NULL, "token_hash" "bytea" NOT NULL, "scopes" "jsonb" DEFAULT '[]'::"jsonb" NOT NULL, "last_used_at" timestamp with time zone, "last_used_ip" "inet", "expires_at" timestamp with time zone, "revoked_at" timestamp with time zone, "revoked_by" "uuid", "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, "updated_at" timestamp with time zone DEFAULT "now"() NOT NULL ); ALTER TABLE "public"."api_tokens" OWNER TO "postgres"; CREATE TABLE IF NOT EXISTS "public"."channel_session_warmup" ( "id" "uuid" DEFAULT "extensions"."uuid_generate_v4"() NOT NULL, "channel_session_id" "uuid" NOT NULL, "organization_id" "uuid" NOT NULL, "day" "date" NOT NULL, "messages_sent" integer DEFAULT 0 NOT NULL, "messages_received" integer DEFAULT 0 NOT NULL, "unique_contacts" integer DEFAULT 0 NOT NULL ); ALTER TABLE "public"."channel_session_warmup" OWNER TO "postgres"; CREATE TABLE IF NOT EXISTS "public"."channel_sessions" ( "id" "uuid" DEFAULT "extensions"."uuid_generate_v4"() NOT NULL, "organization_id" "uuid" NOT NULL, "waha_session_name" "text" NOT NULL, "engine" "text" DEFAULT 'NOWEB'::"text" NOT NULL, "webhook_path_token" "text" DEFAULT "replace"(("extensions"."uuid_generate_v4"())::"text", '-'::"text", ''::"text") NOT NULL, "webhook_secret_encrypted" "bytea" NOT NULL, "status" "text" DEFAULT 'STARTING'::"text" NOT NULL, "status_reason" "text", "phone_number" "text", "display_name" "text", "last_health_check_at" timestamp with time zone, "last_status_change_at" timestamp with time zone DEFAULT "now"() NOT NULL, "consecutive_health_fails" integer DEFAULT 0 NOT NULL, "daily_message_limit" integer DEFAULT 300 NOT NULL, "warmup_started_at" timestamp with time zone, "warmup_completed_at" timestamp with time zone, "is_warmup_complete" boolean GENERATED ALWAYS AS (("warmup_completed_at" IS NOT NULL)) STORED, "metadata" "jsonb" DEFAULT '{}'::"jsonb" NOT NULL, "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, "updated_at" timestamp with time zone DEFAULT "now"() NOT NULL, "created_by" "uuid", CONSTRAINT "channel_sessions_engine_check" CHECK (("engine" = ANY (ARRAY['NOWEB'::"text", 'WEBJS'::"text"]))), CONSTRAINT "channel_sessions_status_check" CHECK (("status" = ANY (ARRAY['STARTING'::"text", 'SCAN_QR_CODE'::"text", 'WORKING'::"text", 'STOPPED'::"text", 'FAILED'::"text"]))) ); ALTER TABLE "public"."channel_sessions" OWNER TO "postgres"; CREATE TABLE IF NOT EXISTS "public"."contacts" ( "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, "organization_id" "uuid" NOT NULL, "name" "text", "display_name" "text", "email" "text", "email_normalized" "text" GENERATED ALWAYS AS ("lower"(TRIM(BOTH FROM "email"))) STORED, "phone_number" "text", "cpf_encrypted" "bytea", "cpf_hash" "text", "birthdate" "date", "is_blocked" boolean DEFAULT false NOT NULL, "blocked_reason" "text", "blocked_at" timestamp with time zone, "is_anonymized" boolean DEFAULT false NOT NULL, "anonymized_at" timestamp with time zone, "is_merged_into" "uuid", "merged_at" timestamp with time zone, "consent" "jsonb" DEFAULT "jsonb_build_object"('marketing', "jsonb_build_object"('granted_at', NULL::"unknown", 'source', NULL::"unknown", 'version', NULL::"unknown"), 'transactional', "jsonb_build_object"('granted_at', NULL::"unknown", 'source', NULL::"unknown", 'version', NULL::"unknown"), 'profiling', "jsonb_build_object"('granted_at', NULL::"unknown", 'source', NULL::"unknown", 'version', NULL::"unknown")) NOT NULL, "tags" "text"[] DEFAULT '{}'::"text"[] NOT NULL, "source" "text" DEFAULT 'manual'::"text" NOT NULL, "source_metadata" "jsonb" DEFAULT '{}'::"jsonb" NOT NULL, "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, "updated_at" timestamp with time zone DEFAULT "now"() NOT NULL, "created_by_user_id" "uuid", "last_activity_at" timestamp with time zone, "force_human" boolean DEFAULT false NOT NULL, CONSTRAINT "contacts_anonymized_locked" CHECK ((("is_anonymized" = false) OR (("is_anonymized" = true) AND ("anonymized_at" IS NOT NULL)))), CONSTRAINT "contacts_cpf_consistency" CHECK ((("cpf_encrypted" IS NULL) = ("cpf_hash" IS NULL))), CONSTRAINT "contacts_email_format" CHECK ((("email" IS NULL) OR ("email" ~* '^[^@\s]+@[^@\s]+\.[^@\s]+$'::"text"))), CONSTRAINT "contacts_phone_e164_format" CHECK ((("phone_number" IS NULL) OR ("phone_number" ~ '^\+\d{8,15}$'::"text"))) ); ALTER TABLE "public"."contacts" OWNER TO "postgres"; COMMENT ON TABLE "public"."contacts" IS 'Pessoa fisica no escopo de um tenant. CPF criptografado at-rest. is_anonymized irreversivel (L-04).'; CREATE TABLE IF NOT EXISTS "public"."conversations" ( "id" "uuid" DEFAULT "extensions"."uuid_generate_v4"() NOT NULL, "organization_id" "uuid" NOT NULL, "contact_id" "uuid" NOT NULL, "channel_session_id" "uuid" NOT NULL, "channel" "text" DEFAULT 'whatsapp'::"text" NOT NULL, "status" "text" DEFAULT 'open'::"text" NOT NULL, "status_changed_at" timestamp with time zone DEFAULT "now"() NOT NULL, "assigned_to_user_id" "uuid", "assigned_at" timestamp with time zone, "last_inbound_at" timestamp with time zone, "last_outbound_at" timestamp with time zone, "last_message_at" timestamp with time zone, "last_message_preview" "text", "unread_count_for_assignee" integer DEFAULT 0 NOT NULL, "is_group" boolean DEFAULT false NOT NULL, "group_chat_id" "text", "metadata" "jsonb" DEFAULT '{}'::"jsonb" NOT NULL, "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, "updated_at" timestamp with time zone DEFAULT "now"() NOT NULL, "bot_silenced_until" timestamp with time zone, "last_handoff_at" timestamp with time zone, "last_handoff_reason" "text", "usable_for_rag" boolean DEFAULT false NOT NULL, "usable_for_rag_marked_at" timestamp with time zone, "usable_for_rag_marked_by" "uuid", "rag_review_status" "text", CONSTRAINT "conversations_channel_check" CHECK (("channel" = 'whatsapp'::"text")), CONSTRAINT "conversations_rag_review_status_check" CHECK ((("rag_review_status" IS NULL) OR ("rag_review_status" = ANY (ARRAY['pending_review'::"text", 'ingested'::"text", 'skipped'::"text"])))), CONSTRAINT "conversations_status_check" CHECK (("status" = ANY (ARRAY['open'::"text", 'pending'::"text", 'resolved'::"text", 'claimed'::"text", 'ai_handling'::"text", 'closed'::"text", 'archived'::"text"]))) ); ALTER TABLE "public"."conversations" OWNER TO "postgres"; COMMENT ON CONSTRAINT "conversations_status_check" ON "public"."conversations" IS 'Accepts both legacy (open/pending/resolved) + EPIC-03 spec (claimed/ai_handling/closed/archived). UI/API normalizes; future migration may consolidate.'; CREATE TABLE IF NOT EXISTS "public"."crm_lead_activities" ( "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, "organization_id" "uuid" NOT NULL, "lead_id" "uuid" NOT NULL, "contact_id" "uuid", "source_module" "text" NOT NULL, "source_id" "uuid", "type" "text" NOT NULL, "payload" "jsonb" DEFAULT '{}'::"jsonb" NOT NULL, "metadata" "jsonb" DEFAULT '{}'::"jsonb" NOT NULL, "performed_at" timestamp with time zone DEFAULT "now"() NOT NULL, "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, "performed_by_user_id" "uuid" ); ALTER TABLE "public"."crm_lead_activities" OWNER TO "postgres"; CREATE TABLE IF NOT EXISTS "public"."crm_lead_links" ( "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, "organization_id" "uuid" NOT NULL, "lead_id" "uuid" NOT NULL, "target_kind" "text" NOT NULL, "target_id" "uuid" NOT NULL, "link_kind" "text" NOT NULL, "metadata" "jsonb" DEFAULT '{}'::"jsonb" NOT NULL, "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, "created_by_user_id" "uuid", CONSTRAINT "crm_lead_links_target_kind_enum" CHECK (("target_kind" = ANY (ARRAY['order'::"text", 'conversation'::"text", 'message'::"text", 'appointment'::"text", 'contact'::"text", 'lead'::"text", 'external'::"text"]))) ); ALTER TABLE "public"."crm_lead_links" OWNER TO "postgres"; CREATE TABLE IF NOT EXISTS "public"."crm_leads" ( "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, "organization_id" "uuid" NOT NULL, "pipeline_id" "uuid" NOT NULL, "stage_id" "uuid" NOT NULL, "contact_id" "uuid", "title" "text" NOT NULL, "description" "text", "status" "text" DEFAULT 'open'::"text" NOT NULL, "lost_reason" "text", "position_in_stage" numeric DEFAULT 1000 NOT NULL, "value_cents" bigint, "currency" "text" DEFAULT 'BRL'::"text", "owner_user_id" "uuid", "assigned_at" timestamp with time zone, "last_activity_at" timestamp with time zone, "expected_close_date" "date", "closed_at" timestamp with time zone, "source" "text" DEFAULT 'manual'::"text" NOT NULL, "source_metadata" "jsonb" DEFAULT '{}'::"jsonb" NOT NULL, "external_id" "text", "custom_fields" "jsonb" DEFAULT '{}'::"jsonb" NOT NULL, "tags" "text"[] DEFAULT '{}'::"text"[] NOT NULL, "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, "updated_at" timestamp with time zone DEFAULT "now"() NOT NULL, "created_by_user_id" "uuid", CONSTRAINT "crm_leads_closed_at_consistency" CHECK (((("status" = 'open'::"text") AND ("closed_at" IS NULL)) OR (("status" = ANY (ARRAY['won'::"text", 'lost'::"text"])) AND ("closed_at" IS NOT NULL)))), CONSTRAINT "crm_leads_currency_iso" CHECK ((("currency" IS NULL) OR ("currency" ~ '^[A-Z]{3}$'::"text"))), CONSTRAINT "crm_leads_lost_reason_required" CHECK ((("status" <> 'lost'::"text") OR (("lost_reason" IS NOT NULL) AND ("length"("lost_reason") > 0)))), CONSTRAINT "crm_leads_status_enum" CHECK (("status" = ANY (ARRAY['open'::"text", 'won'::"text", 'lost'::"text"]))) ); ALTER TABLE "public"."crm_leads" OWNER TO "postgres"; CREATE TABLE IF NOT EXISTS "public"."crm_pipelines" ( "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, "organization_id" "uuid" NOT NULL, "name" "text" NOT NULL, "slug" "text" NOT NULL, "description" "text", "is_default" boolean DEFAULT false NOT NULL, "is_archived" boolean DEFAULT false NOT NULL, "position" numeric DEFAULT 1000 NOT NULL, "vocabulary" "jsonb" DEFAULT "jsonb_build_object"('lead', 'Cliente', 'lead_plural', 'Clientes', 'deal', 'Pedido', 'deal_plural', 'Pedidos', 'won', 'Pago', 'lost', 'Cancelado', 'stage', 'Etapa', 'stage_plural', 'Etapas') NOT NULL, "settings" "jsonb" DEFAULT "jsonb_build_object"('fields', '[]'::"jsonb", 'canonical_tags', '[]'::"jsonb", 'lost_reasons', '[]'::"jsonb", 'identity_resolution', "jsonb_build_object"('fields_in_priority_order', "jsonb_build_array"('cpf', 'phone_e164', 'email'))) NOT NULL, "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, "updated_at" timestamp with time zone DEFAULT "now"() NOT NULL, CONSTRAINT "crm_pipelines_slug_format" CHECK (("slug" ~ '^[a-z0-9_-]{2,40}$'::"text")) ); ALTER TABLE "public"."crm_pipelines" OWNER TO "postgres"; CREATE TABLE IF NOT EXISTS "public"."crm_stages" ( "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, "organization_id" "uuid" NOT NULL, "pipeline_id" "uuid" NOT NULL, "name" "text" NOT NULL, "slug" "text" NOT NULL, "description" "text", "position" numeric NOT NULL, "color" "text", "is_won" boolean DEFAULT false NOT NULL, "is_lost" boolean DEFAULT false NOT NULL, "is_archived" boolean DEFAULT false NOT NULL, "expected_duration_hours" numeric, "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, "updated_at" timestamp with time zone DEFAULT "now"() NOT NULL, "requires_human" boolean DEFAULT false NOT NULL, CONSTRAINT "crm_stages_color_format" CHECK ((("color" IS NULL) OR ("color" ~ '^#[0-9a-fA-F]{6}$'::"text"))), CONSTRAINT "crm_stages_slug_format" CHECK (("slug" ~ '^[a-z0-9_-]{2,40}$'::"text")), CONSTRAINT "crm_stages_won_lost_mutex" CHECK ((NOT ("is_won" AND "is_lost"))) ); ALTER TABLE "public"."crm_stages" OWNER TO "postgres"; CREATE TABLE IF NOT EXISTS "public"."event_log" ( "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, "organization_id" "uuid" NOT NULL, "event_type" "text" NOT NULL, "entity_kind" "text" NOT NULL, "entity_id" "uuid", "payload" "jsonb" DEFAULT '{}'::"jsonb" NOT NULL, "metadata" "jsonb" DEFAULT '{}'::"jsonb" NOT NULL, "consumed_by" "text"[] DEFAULT '{}'::"text"[] NOT NULL, "attempts" smallint DEFAULT 0 NOT NULL, "last_error" "text", "next_attempt_at" timestamp with time zone, "status" "text" DEFAULT 'pending'::"text" NOT NULL, "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, "updated_at" timestamp with time zone DEFAULT "now"() NOT NULL, CONSTRAINT "event_log_status_check" CHECK (("status" = ANY (ARRAY['pending'::"text", 'processing'::"text", 'done'::"text", 'dead'::"text"]))), CONSTRAINT "event_type_format" CHECK (("event_type" ~ '^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$'::"text")) ); ALTER TABLE "public"."event_log" OWNER TO "postgres"; COMMENT ON TABLE "public"."event_log" IS 'Bus interno do CRM. Triggers e ServerActions inserem aqui via emit_event(). Workers consomem.'; CREATE TABLE IF NOT EXISTS "public"."idempotency_keys" ( "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, "organization_id" "uuid" NOT NULL, "key" "text" NOT NULL, "endpoint" "text" NOT NULL, "request_hash" "bytea" NOT NULL, "status_code" integer, "response_body" "jsonb", "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, "expires_at" timestamp with time zone DEFAULT ("now"() + '24:00:00'::interval) NOT NULL ); ALTER TABLE "public"."idempotency_keys" OWNER TO "postgres"; CREATE TABLE IF NOT EXISTS "public"."incidents" ( "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, "organization_id" "uuid", "type" "text" NOT NULL, "severity" "text" NOT NULL, "payload" "jsonb" DEFAULT '{}'::"jsonb" NOT NULL, "status" "text" DEFAULT 'open'::"text" NOT NULL, "acknowledged_at" timestamp with time zone, "acknowledged_by" "uuid", "resolved_at" timestamp with time zone, "resolved_by" "uuid", "resolution_note" "text", "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, "updated_at" timestamp with time zone DEFAULT "now"() NOT NULL, CONSTRAINT "incidents_severity_check" CHECK (("severity" = ANY (ARRAY['info'::"text", 'warning'::"text", 'critical'::"text"]))), CONSTRAINT "incidents_status_check" CHECK (("status" = ANY (ARRAY['open'::"text", 'acknowledged'::"text", 'resolved'::"text"]))) ); ALTER TABLE "public"."incidents" OWNER TO "postgres"; CREATE TABLE IF NOT EXISTS "public"."lgpd_requests" ( "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, "organization_id" "uuid" NOT NULL, "request_type" "text" NOT NULL, "source" "text" NOT NULL, "contact_id" "uuid", "external_customer_id" "text", "status" "text" DEFAULT 'received'::"text" NOT NULL, "attempts" integer DEFAULT 0 NOT NULL, "received_at" timestamp with time zone DEFAULT "now"() NOT NULL, "due_at" timestamp with time zone NOT NULL, "completed_at" timestamp with time zone, "request_payload" "jsonb" DEFAULT '{}'::"jsonb" NOT NULL, "result" "jsonb", "error_message" "text", "cascaded_to" "jsonb", "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, "updated_at" timestamp with time zone DEFAULT "now"() NOT NULL, "emergency" boolean DEFAULT false NOT NULL, "scope" "text" DEFAULT 'contact'::"text" NOT NULL, CONSTRAINT "lgpd_requests_request_type_check" CHECK (("request_type" = ANY (ARRAY['data_request'::"text", 'redact'::"text", 'store_redact'::"text"]))), CONSTRAINT "lgpd_requests_scope_check" CHECK (("scope" = ANY (ARRAY['contact'::"text", 'tenant'::"text"]))), CONSTRAINT "lgpd_requests_source_check" CHECK (("source" = ANY (ARRAY['nuvemshop'::"text", 'manual'::"text", 'api'::"text", 'support'::"text"]))), CONSTRAINT "lgpd_requests_status_check" CHECK (("status" = ANY (ARRAY['received'::"text", 'processing'::"text", 'completed'::"text", 'failed'::"text", 'expired'::"text"]))) ); ALTER TABLE "public"."lgpd_requests" OWNER TO "postgres"; CREATE TABLE IF NOT EXISTS "public"."merge_queue" ( "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, "organization_id" "uuid" NOT NULL, "candidates" "uuid"[] NOT NULL, "reason" "text" NOT NULL, "trigger_payload" "jsonb" DEFAULT '{}'::"jsonb" NOT NULL, "status" "text" DEFAULT 'pending'::"text" NOT NULL, "resolution" "jsonb", "resolved_by_user_id" "uuid", "resolved_at" timestamp with time zone, "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, CONSTRAINT "merge_queue_candidates_min2" CHECK (("array_length"("candidates", 1) >= 2)), CONSTRAINT "merge_queue_status_enum" CHECK (("status" = ANY (ARRAY['pending'::"text", 'resolved'::"text", 'discarded'::"text"]))) ); ALTER TABLE "public"."merge_queue" OWNER TO "postgres"; CREATE TABLE IF NOT EXISTS "public"."messages" ( "id" "uuid" DEFAULT "extensions"."uuid_generate_v4"() NOT NULL, "organization_id" "uuid" NOT NULL, "conversation_id" "uuid" NOT NULL, "channel_session_id" "uuid" NOT NULL, "contact_id" "uuid" NOT NULL, "external_id" "text", "type" "text" NOT NULL, "direction" "text" NOT NULL, "status" "text" DEFAULT 'received'::"text" NOT NULL, "ack" integer, "error_code" "text", "error_message" "text", "body" "text", "media_url" "text", "media_mime" "text", "media_size_bytes" bigint, "media_storage_path" "text", "sent_via" "text" DEFAULT 'crm'::"text" NOT NULL, "sent_by_user_id" "uuid", "sent_at" timestamp with time zone DEFAULT "now"() NOT NULL, "delivered_at" timestamp with time zone, "read_at" timestamp with time zone, "metadata" "jsonb" DEFAULT '{}'::"jsonb" NOT NULL, "activity_id" "uuid", "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, "updated_at" timestamp with time zone DEFAULT "now"() NOT NULL, CONSTRAINT "messages_direction_check" CHECK (("direction" = ANY (ARRAY['inbound'::"text", 'outbound'::"text"]))), CONSTRAINT "messages_sent_via_check" CHECK (("sent_via" = ANY (ARRAY['crm'::"text", 'external_device'::"text", 'automation'::"text", 'ai'::"text", 'user'::"text", 'system'::"text"]))), CONSTRAINT "messages_status_check" CHECK (("status" = ANY (ARRAY['queued'::"text", 'received'::"text", 'sending'::"text", 'sent'::"text", 'delivered'::"text", 'read'::"text", 'failed'::"text"]))), CONSTRAINT "messages_type_check" CHECK (("type" = ANY (ARRAY['text'::"text", 'image'::"text", 'video'::"text", 'audio'::"text", 'document'::"text", 'sticker'::"text", 'location'::"text", 'contact'::"text", 'reaction'::"text", 'system'::"text"]))) ); ALTER TABLE "public"."messages" OWNER TO "postgres"; CREATE TABLE IF NOT EXISTS "public"."nuvemshop_products" ( "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, "organization_id" "uuid" NOT NULL, "external_id" "text" NOT NULL, "title" "text" NOT NULL, "description" "text", "price_cents" bigint NOT NULL, "available_qty" integer DEFAULT 0 NOT NULL, "url" "text", "image_url" "text", "rag_indexed_at" timestamp with time zone, "rag_chunk_count" integer DEFAULT 0 NOT NULL, "payload" "jsonb" DEFAULT '{}'::"jsonb" NOT NULL, "last_updated_at" timestamp with time zone NOT NULL, "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, "updated_at" timestamp with time zone DEFAULT "now"() NOT NULL, CONSTRAINT "nuvemshop_products_price_cents_check" CHECK (("price_cents" >= 0)) ); ALTER TABLE "public"."nuvemshop_products" OWNER TO "postgres"; CREATE TABLE IF NOT EXISTS "public"."orders" ( "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, "organization_id" "uuid" NOT NULL, "external_id" "text" NOT NULL, "external_provider" "text" NOT NULL, "customer_external_id" "text", "contact_id" "uuid", "status" "text" NOT NULL, "total_cents" bigint NOT NULL, "currency" character(3) DEFAULT 'BRL'::"bpchar" NOT NULL, "payment_method" "text", "fulfillment_status" "text", "tracking_code" "text", "payload" "jsonb" DEFAULT '{}'::"jsonb" NOT NULL, "ordered_at" timestamp with time zone NOT NULL, "updated_at_remote" timestamp with time zone, "is_anonymized" boolean DEFAULT false NOT NULL, "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, "updated_at" timestamp with time zone DEFAULT "now"() NOT NULL, CONSTRAINT "orders_external_provider_check" CHECK (("external_provider" = ANY (ARRAY['nuvemshop'::"text", 'vtex'::"text", 'shopify'::"text"]))), CONSTRAINT "orders_fulfillment_status_check" CHECK (("fulfillment_status" = ANY (ARRAY['unpacked'::"text", 'packed'::"text", 'shipped'::"text", 'delivered'::"text"]))), CONSTRAINT "orders_status_check" CHECK (("status" = ANY (ARRAY['pending'::"text", 'paid'::"text", 'cancelled'::"text", 'fulfilled'::"text", 'shipped'::"text", 'delivered'::"text", 'refunded'::"text"]))), CONSTRAINT "orders_total_cents_check" CHECK (("total_cents" >= 0)) ); ALTER TABLE "public"."orders" OWNER TO "postgres"; CREATE TABLE IF NOT EXISTS "public"."organizations" ( "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, "slug" "public"."citext" NOT NULL, "legal_name" "text" NOT NULL, "display_name" "text" NOT NULL, "cnpj" "text", "status" "text" DEFAULT 'active'::"text" NOT NULL, "timezone" "text" DEFAULT 'America/Sao_Paulo'::"text" NOT NULL, "locale" "text" DEFAULT 'pt-BR'::"text" NOT NULL, "rate_limit_rps" integer DEFAULT 100 NOT NULL, "ai_budget_cents" bigint, "media_retention_days" integer DEFAULT 365 NOT NULL, "settings" "jsonb" DEFAULT '{}'::"jsonb" NOT NULL, "dpo_email" "public"."citext", "privacy_policy_url" "text", "onboarded_at" timestamp with time zone, "suspended_at" timestamp with time zone, "redacted_at" timestamp with time zone, "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, "updated_at" timestamp with time zone DEFAULT "now"() NOT NULL, "created_by" "uuid", "onboarding_state" "jsonb" DEFAULT '{}'::"jsonb" NOT NULL, "suspended_reason" "text", "suspended_by" "uuid", CONSTRAINT "organizations_status_check" CHECK (("status" = ANY (ARRAY['active'::"text", 'suspended'::"text", 'redacted'::"text", 'archived'::"text"]))) ); ALTER TABLE "public"."organizations" OWNER TO "postgres"; COMMENT ON TABLE "public"."organizations" IS 'Tenants do DeskcommCRM. Cada linha = 1 e-commerce cliente.'; COMMENT ON COLUMN "public"."organizations"."onboarded_at" IS 'Null = ainda em onboarding; populado quando step 5 completa'; COMMENT ON COLUMN "public"."organizations"."onboarding_state" IS 'Wizard state: { welcome?: {accepted_at, timezone, display_name}, whatsapp?: {session_id, status}, nuvemshop?: {connected_at, store_id}, ai?: {agent_id}, team?: {invites_sent} }'; CREATE TABLE IF NOT EXISTS "public"."platform_admins" ( "user_id" "uuid" NOT NULL, "granted_by" "uuid" NOT NULL, "granted_at" timestamp with time zone DEFAULT "now"() NOT NULL, "scope" "text" DEFAULT 'full'::"text" NOT NULL, "mfa_required" boolean DEFAULT true NOT NULL, "reason" "text" NOT NULL, "revoked_at" timestamp with time zone, "revoked_by" "uuid", "revoke_reason" "text", CONSTRAINT "platform_admins_scope_check" CHECK (("scope" = ANY (ARRAY['full'::"text", 'support_readonly'::"text"]))) ); ALTER TABLE "public"."platform_admins" OWNER TO "postgres"; COMMENT ON TABLE "public"."platform_admins" IS 'Super-admins que cruzam tenants. Modificacao SOMENTE via DBA + double-confirmation. T-04.'; CREATE TABLE IF NOT EXISTS "public"."storage_redaction_queue" ( "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, "organization_id" "uuid" NOT NULL, "request_id" "uuid", "bucket" "text" NOT NULL, "object_path" "text" NOT NULL, "status" "text" DEFAULT 'pending'::"text" NOT NULL, "attempts" integer DEFAULT 0 NOT NULL, "error_message" "text", "enqueued_at" timestamp with time zone DEFAULT "now"() NOT NULL, "processed_at" timestamp with time zone, CONSTRAINT "storage_redaction_queue_status_check" CHECK (("status" = ANY (ARRAY['pending'::"text", 'deleted'::"text", 'failed'::"text", 'skipped'::"text"]))) ); ALTER TABLE "public"."storage_redaction_queue" OWNER TO "postgres"; CREATE TABLE IF NOT EXISTS "public"."tenant_integrations" ( "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, "organization_id" "uuid" NOT NULL, "provider" "text" NOT NULL, "oauth_access_token_encrypted" "bytea" NOT NULL, "oauth_refresh_token_encrypted" "bytea", "scopes" "text"[] DEFAULT ARRAY[]::"text"[] NOT NULL, "expires_at" timestamp with time zone, "status" "text" DEFAULT 'connecting'::"text" NOT NULL, "status_reason" "text", "store_metadata" "jsonb" DEFAULT '{}'::"jsonb" NOT NULL, "webhook_path_token" "text" DEFAULT "encode"("extensions"."gen_random_bytes"(24), 'hex'::"text") NOT NULL, "webhook_secret_encrypted" "bytea" NOT NULL, "webhook_subscriptions" "jsonb" DEFAULT '[]'::"jsonb" NOT NULL, "last_sync_at" timestamp with time zone, "last_health_check_at" timestamp with time zone, "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, "updated_at" timestamp with time zone DEFAULT "now"() NOT NULL, CONSTRAINT "tenant_integrations_provider_check" CHECK (("provider" = ANY (ARRAY['nuvemshop'::"text", 'vtex'::"text", 'shopify'::"text"]))), CONSTRAINT "tenant_integrations_status_check" CHECK (("status" = ANY (ARRAY['connecting'::"text", 'healthy'::"text", 'token_expired'::"text", 'scope_missing'::"text", 'disconnected'::"text", 'rate_limited'::"text", 'error'::"text"]))) ); ALTER TABLE "public"."tenant_integrations" OWNER TO "postgres"; CREATE TABLE IF NOT EXISTS "public"."user_organizations" ( "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, "user_id" "uuid" NOT NULL, "organization_id" "uuid" NOT NULL, "role" "text" NOT NULL, "invited_by" "uuid", "invited_at" timestamp with time zone, "accepted_at" timestamp with time zone, "revoked_at" timestamp with time zone, "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, "updated_at" timestamp with time zone DEFAULT "now"() NOT NULL, CONSTRAINT "user_organizations_role_check" CHECK (("role" = ANY (ARRAY['viewer'::"text", 'agent'::"text", 'manager'::"text", 'admin'::"text"]))) ); ALTER TABLE "public"."user_organizations" OWNER TO "postgres"; COMMENT ON COLUMN "public"."user_organizations"."role" IS '4 roles canônicos: viewer (1) < agent (2) < manager (3) < admin (4). Hierarquia.'; CREATE TABLE IF NOT EXISTS "public"."user_recovery_codes" ( "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, "user_id" "uuid" NOT NULL, "code_hash" "bytea" NOT NULL, "used_at" timestamp with time zone, "used_ip" "inet", "created_at" timestamp with time zone DEFAULT "now"() NOT NULL ); ALTER TABLE "public"."user_recovery_codes" OWNER TO "postgres"; CREATE TABLE IF NOT EXISTS "public"."webhook_events_log" ( "id" "uuid" DEFAULT "extensions"."uuid_generate_v4"() NOT NULL, "organization_id" "uuid", "channel_session_id" "uuid", "provider" "text" DEFAULT 'waha'::"text" NOT NULL, "webhook_path_token" "text", "http_method" "text" DEFAULT 'POST'::"text" NOT NULL, "headers" "jsonb", "raw_body" "text" NOT NULL, "payload_parsed" "jsonb", "signature_header" "text", "valid_signature" boolean, "event_type" "text", "external_id" "text", "status" "text" DEFAULT 'received'::"text" NOT NULL, "attempts" integer DEFAULT 0 NOT NULL, "error_message" "text", "processed_at" timestamp with time zone, "received_at" timestamp with time zone DEFAULT "now"() NOT NULL, "archived_at" timestamp with time zone, CONSTRAINT "webhook_events_log_provider_check" CHECK (("provider" = ANY (ARRAY['waha'::"text", 'nuvemshop'::"text", 'generic'::"text"]))), CONSTRAINT "webhook_events_log_status_check" CHECK (("status" = ANY (ARRAY['received'::"text", 'processed'::"text", 'error'::"text", 'dead'::"text"]))) ); ALTER TABLE "public"."webhook_events_log" OWNER TO "postgres"; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_agent_runs_pkey' AND conrelid = '"public"."ai_agent_runs"'::regclass) AND to_regclass('"public"."ai_agent_runs_pkey"') IS NULL THEN ALTER TABLE ONLY "public"."ai_agent_runs" ADD CONSTRAINT "ai_agent_runs_pkey" PRIMARY KEY ("id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_agent_versions_pkey' AND conrelid = '"public"."ai_agent_versions"'::regclass) AND to_regclass('"public"."ai_agent_versions_pkey"') IS NULL THEN ALTER TABLE ONLY "public"."ai_agent_versions" ADD CONSTRAINT "ai_agent_versions_pkey" PRIMARY KEY ("id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_agent_versions_unique_number' AND conrelid = '"public"."ai_agent_versions"'::regclass) AND to_regclass('"public"."ai_agent_versions_unique_number"') IS NULL THEN ALTER TABLE ONLY "public"."ai_agent_versions" ADD CONSTRAINT "ai_agent_versions_unique_number" UNIQUE ("agent_id", "version_number"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_agents_name_unique' AND conrelid = '"public"."ai_agents"'::regclass) AND to_regclass('"public"."ai_agents_name_unique"') IS NULL THEN ALTER TABLE ONLY "public"."ai_agents" ADD CONSTRAINT "ai_agents_name_unique" UNIQUE ("organization_id", "name"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_agents_pkey' AND conrelid = '"public"."ai_agents"'::regclass) AND to_regclass('"public"."ai_agents_pkey"') IS NULL THEN ALTER TABLE ONLY "public"."ai_agents" ADD CONSTRAINT "ai_agents_pkey" PRIMARY KEY ("id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_budgets_pkey' AND conrelid = '"public"."ai_budgets"'::regclass) AND to_regclass('"public"."ai_budgets_pkey"') IS NULL THEN ALTER TABLE ONLY "public"."ai_budgets" ADD CONSTRAINT "ai_budgets_pkey" PRIMARY KEY ("organization_id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_chunks_pkey' AND conrelid = '"public"."ai_chunks"'::regclass) AND to_regclass('"public"."ai_chunks_pkey"') IS NULL THEN ALTER TABLE ONLY "public"."ai_chunks" ADD CONSTRAINT "ai_chunks_pkey" PRIMARY KEY ("id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_chunks_position_unique' AND conrelid = '"public"."ai_chunks"'::regclass) AND to_regclass('"public"."ai_chunks_position_unique"') IS NULL THEN ALTER TABLE ONLY "public"."ai_chunks" ADD CONSTRAINT "ai_chunks_position_unique" UNIQUE ("knowledge_source_id", "kb_version_id", "position"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_faq_items_pkey' AND conrelid = '"public"."ai_faq_items"'::regclass) AND to_regclass('"public"."ai_faq_items_pkey"') IS NULL THEN ALTER TABLE ONLY "public"."ai_faq_items" ADD CONSTRAINT "ai_faq_items_pkey" PRIMARY KEY ("id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_invocations_pkey' AND conrelid = '"public"."ai_invocations"'::regclass) AND to_regclass('"public"."ai_invocations_pkey"') IS NULL THEN ALTER TABLE ONLY "public"."ai_invocations" ADD CONSTRAINT "ai_invocations_pkey" PRIMARY KEY ("id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_knowledge_sources_pkey' AND conrelid = '"public"."ai_knowledge_sources"'::regclass) AND to_regclass('"public"."ai_knowledge_sources_pkey"') IS NULL THEN ALTER TABLE ONLY "public"."ai_knowledge_sources" ADD CONSTRAINT "ai_knowledge_sources_pkey" PRIMARY KEY ("id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_knowledge_versions_pkey' AND conrelid = '"public"."ai_knowledge_versions"'::regclass) AND to_regclass('"public"."ai_knowledge_versions_pkey"') IS NULL THEN ALTER TABLE ONLY "public"."ai_knowledge_versions" ADD CONSTRAINT "ai_knowledge_versions_pkey" PRIMARY KEY ("id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_models_pkey' AND conrelid = '"public"."ai_models"'::regclass) AND to_regclass('"public"."ai_models_pkey"') IS NULL THEN ALTER TABLE ONLY "public"."ai_models" ADD CONSTRAINT "ai_models_pkey" PRIMARY KEY ("id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_models_unique' AND conrelid = '"public"."ai_models"'::regclass) AND to_regclass('"public"."ai_models_unique"') IS NULL THEN ALTER TABLE ONLY "public"."ai_models" ADD CONSTRAINT "ai_models_unique" UNIQUE ("provider", "model_id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_pricing_pkey' AND conrelid = '"public"."ai_pricing"'::regclass) AND to_regclass('"public"."ai_pricing_pkey"') IS NULL THEN ALTER TABLE ONLY "public"."ai_pricing" ADD CONSTRAINT "ai_pricing_pkey" PRIMARY KEY ("model"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_provider_credentials_pkey' AND conrelid = '"public"."ai_provider_credentials"'::regclass) AND to_regclass('"public"."ai_provider_credentials_pkey"') IS NULL THEN ALTER TABLE ONLY "public"."ai_provider_credentials" ADD CONSTRAINT "ai_provider_credentials_pkey" PRIMARY KEY ("id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_provider_credentials_unique' AND conrelid = '"public"."ai_provider_credentials"'::regclass) AND to_regclass('"public"."ai_provider_credentials_unique"') IS NULL THEN ALTER TABLE ONLY "public"."ai_provider_credentials" ADD CONSTRAINT "ai_provider_credentials_unique" UNIQUE ("organization_id", "provider", "label"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'api_audit_log_pkey' AND conrelid = '"public"."api_audit_log"'::regclass) AND to_regclass('"public"."api_audit_log_pkey"') IS NULL THEN ALTER TABLE ONLY "public"."api_audit_log" ADD CONSTRAINT "api_audit_log_pkey" PRIMARY KEY ("id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'api_tokens_organization_id_prefix_key' AND conrelid = '"public"."api_tokens"'::regclass) AND to_regclass('"public"."api_tokens_organization_id_prefix_key"') IS NULL THEN ALTER TABLE ONLY "public"."api_tokens" ADD CONSTRAINT "api_tokens_organization_id_prefix_key" UNIQUE ("organization_id", "prefix"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'api_tokens_pkey' AND conrelid = '"public"."api_tokens"'::regclass) AND to_regclass('"public"."api_tokens_pkey"') IS NULL THEN ALTER TABLE ONLY "public"."api_tokens" ADD CONSTRAINT "api_tokens_pkey" PRIMARY KEY ("id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'channel_session_warmup_pkey' AND conrelid = '"public"."channel_session_warmup"'::regclass) AND to_regclass('"public"."channel_session_warmup_pkey"') IS NULL THEN ALTER TABLE ONLY "public"."channel_session_warmup" ADD CONSTRAINT "channel_session_warmup_pkey" PRIMARY KEY ("id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'channel_sessions_phone_per_org_unique' AND conrelid = '"public"."channel_sessions"'::regclass) AND to_regclass('"public"."channel_sessions_phone_per_org_unique"') IS NULL THEN ALTER TABLE ONLY "public"."channel_sessions" ADD CONSTRAINT "channel_sessions_phone_per_org_unique" UNIQUE ("organization_id", "phone_number") DEFERRABLE INITIALLY DEFERRED; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'channel_sessions_pkey' AND conrelid = '"public"."channel_sessions"'::regclass) AND to_regclass('"public"."channel_sessions_pkey"') IS NULL THEN ALTER TABLE ONLY "public"."channel_sessions" ADD CONSTRAINT "channel_sessions_pkey" PRIMARY KEY ("id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'channel_sessions_waha_session_name_unique' AND conrelid = '"public"."channel_sessions"'::regclass) AND to_regclass('"public"."channel_sessions_waha_session_name_unique"') IS NULL THEN ALTER TABLE ONLY "public"."channel_sessions" ADD CONSTRAINT "channel_sessions_waha_session_name_unique" UNIQUE ("waha_session_name"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'channel_sessions_webhook_path_token_unique' AND conrelid = '"public"."channel_sessions"'::regclass) AND to_regclass('"public"."channel_sessions_webhook_path_token_unique"') IS NULL THEN ALTER TABLE ONLY "public"."channel_sessions" ADD CONSTRAINT "channel_sessions_webhook_path_token_unique" UNIQUE ("webhook_path_token"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'contacts_pkey' AND conrelid = '"public"."contacts"'::regclass) AND to_regclass('"public"."contacts_pkey"') IS NULL THEN ALTER TABLE ONLY "public"."contacts" ADD CONSTRAINT "contacts_pkey" PRIMARY KEY ("id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'conversations_pkey' AND conrelid = '"public"."conversations"'::regclass) AND to_regclass('"public"."conversations_pkey"') IS NULL THEN ALTER TABLE ONLY "public"."conversations" ADD CONSTRAINT "conversations_pkey" PRIMARY KEY ("id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'conversations_unique_per_contact_session' AND conrelid = '"public"."conversations"'::regclass) AND to_regclass('"public"."conversations_unique_per_contact_session"') IS NULL THEN ALTER TABLE ONLY "public"."conversations" ADD CONSTRAINT "conversations_unique_per_contact_session" UNIQUE ("organization_id", "contact_id", "channel_session_id", "group_chat_id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'crm_lead_activities_pkey' AND conrelid = '"public"."crm_lead_activities"'::regclass) AND to_regclass('"public"."crm_lead_activities_pkey"') IS NULL THEN ALTER TABLE ONLY "public"."crm_lead_activities" ADD CONSTRAINT "crm_lead_activities_pkey" PRIMARY KEY ("id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'crm_lead_links_pkey' AND conrelid = '"public"."crm_lead_links"'::regclass) AND to_regclass('"public"."crm_lead_links_pkey"') IS NULL THEN ALTER TABLE ONLY "public"."crm_lead_links" ADD CONSTRAINT "crm_lead_links_pkey" PRIMARY KEY ("id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'crm_leads_pkey' AND conrelid = '"public"."crm_leads"'::regclass) AND to_regclass('"public"."crm_leads_pkey"') IS NULL THEN ALTER TABLE ONLY "public"."crm_leads" ADD CONSTRAINT "crm_leads_pkey" PRIMARY KEY ("id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'crm_pipelines_pkey' AND conrelid = '"public"."crm_pipelines"'::regclass) AND to_regclass('"public"."crm_pipelines_pkey"') IS NULL THEN ALTER TABLE ONLY "public"."crm_pipelines" ADD CONSTRAINT "crm_pipelines_pkey" PRIMARY KEY ("id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'crm_stages_pkey' AND conrelid = '"public"."crm_stages"'::regclass) AND to_regclass('"public"."crm_stages_pkey"') IS NULL THEN ALTER TABLE ONLY "public"."crm_stages" ADD CONSTRAINT "crm_stages_pkey" PRIMARY KEY ("id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'event_log_pkey' AND conrelid = '"public"."event_log"'::regclass) AND to_regclass('"public"."event_log_pkey"') IS NULL THEN ALTER TABLE ONLY "public"."event_log" ADD CONSTRAINT "event_log_pkey" PRIMARY KEY ("id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'idempotency_keys_organization_id_key_endpoint_key' AND conrelid = '"public"."idempotency_keys"'::regclass) AND to_regclass('"public"."idempotency_keys_organization_id_key_endpoint_key"') IS NULL THEN ALTER TABLE ONLY "public"."idempotency_keys" ADD CONSTRAINT "idempotency_keys_organization_id_key_endpoint_key" UNIQUE ("organization_id", "key", "endpoint"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'idempotency_keys_pkey' AND conrelid = '"public"."idempotency_keys"'::regclass) AND to_regclass('"public"."idempotency_keys_pkey"') IS NULL THEN ALTER TABLE ONLY "public"."idempotency_keys" ADD CONSTRAINT "idempotency_keys_pkey" PRIMARY KEY ("id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'incidents_pkey' AND conrelid = '"public"."incidents"'::regclass) AND to_regclass('"public"."incidents_pkey"') IS NULL THEN ALTER TABLE ONLY "public"."incidents" ADD CONSTRAINT "incidents_pkey" PRIMARY KEY ("id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'lgpd_requests_pkey' AND conrelid = '"public"."lgpd_requests"'::regclass) AND to_regclass('"public"."lgpd_requests_pkey"') IS NULL THEN ALTER TABLE ONLY "public"."lgpd_requests" ADD CONSTRAINT "lgpd_requests_pkey" PRIMARY KEY ("id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'merge_queue_pkey' AND conrelid = '"public"."merge_queue"'::regclass) AND to_regclass('"public"."merge_queue_pkey"') IS NULL THEN ALTER TABLE ONLY "public"."merge_queue" ADD CONSTRAINT "merge_queue_pkey" PRIMARY KEY ("id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'messages_org_external_id_unique' AND conrelid = '"public"."messages"'::regclass) AND to_regclass('"public"."messages_org_external_id_unique"') IS NULL THEN ALTER TABLE ONLY "public"."messages" ADD CONSTRAINT "messages_org_external_id_unique" UNIQUE ("organization_id", "external_id") DEFERRABLE INITIALLY DEFERRED; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'messages_pkey' AND conrelid = '"public"."messages"'::regclass) AND to_regclass('"public"."messages_pkey"') IS NULL THEN ALTER TABLE ONLY "public"."messages" ADD CONSTRAINT "messages_pkey" PRIMARY KEY ("id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'nuvemshop_products_organization_id_external_id_key' AND conrelid = '"public"."nuvemshop_products"'::regclass) AND to_regclass('"public"."nuvemshop_products_organization_id_external_id_key"') IS NULL THEN ALTER TABLE ONLY "public"."nuvemshop_products" ADD CONSTRAINT "nuvemshop_products_organization_id_external_id_key" UNIQUE ("organization_id", "external_id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'nuvemshop_products_pkey' AND conrelid = '"public"."nuvemshop_products"'::regclass) AND to_regclass('"public"."nuvemshop_products_pkey"') IS NULL THEN ALTER TABLE ONLY "public"."nuvemshop_products" ADD CONSTRAINT "nuvemshop_products_pkey" PRIMARY KEY ("id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'orders_organization_id_external_provider_external_id_key' AND conrelid = '"public"."orders"'::regclass) AND to_regclass('"public"."orders_organization_id_external_provider_external_id_key"') IS NULL THEN ALTER TABLE ONLY "public"."orders" ADD CONSTRAINT "orders_organization_id_external_provider_external_id_key" UNIQUE ("organization_id", "external_provider", "external_id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'orders_pkey' AND conrelid = '"public"."orders"'::regclass) AND to_regclass('"public"."orders_pkey"') IS NULL THEN ALTER TABLE ONLY "public"."orders" ADD CONSTRAINT "orders_pkey" PRIMARY KEY ("id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'organizations_cnpj_key' AND conrelid = '"public"."organizations"'::regclass) AND to_regclass('"public"."organizations_cnpj_key"') IS NULL THEN ALTER TABLE ONLY "public"."organizations" ADD CONSTRAINT "organizations_cnpj_key" UNIQUE ("cnpj"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'organizations_pkey' AND conrelid = '"public"."organizations"'::regclass) AND to_regclass('"public"."organizations_pkey"') IS NULL THEN ALTER TABLE ONLY "public"."organizations" ADD CONSTRAINT "organizations_pkey" PRIMARY KEY ("id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'organizations_slug_key' AND conrelid = '"public"."organizations"'::regclass) AND to_regclass('"public"."organizations_slug_key"') IS NULL THEN ALTER TABLE ONLY "public"."organizations" ADD CONSTRAINT "organizations_slug_key" UNIQUE ("slug"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'platform_admins_pkey' AND conrelid = '"public"."platform_admins"'::regclass) AND to_regclass('"public"."platform_admins_pkey"') IS NULL THEN ALTER TABLE ONLY "public"."platform_admins" ADD CONSTRAINT "platform_admins_pkey" PRIMARY KEY ("user_id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'storage_redaction_queue_bucket_object_path_key' AND conrelid = '"public"."storage_redaction_queue"'::regclass) AND to_regclass('"public"."storage_redaction_queue_bucket_object_path_key"') IS NULL THEN ALTER TABLE ONLY "public"."storage_redaction_queue" ADD CONSTRAINT "storage_redaction_queue_bucket_object_path_key" UNIQUE ("bucket", "object_path"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'storage_redaction_queue_pkey' AND conrelid = '"public"."storage_redaction_queue"'::regclass) AND to_regclass('"public"."storage_redaction_queue_pkey"') IS NULL THEN ALTER TABLE ONLY "public"."storage_redaction_queue" ADD CONSTRAINT "storage_redaction_queue_pkey" PRIMARY KEY ("id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tenant_integrations_organization_id_provider_key' AND conrelid = '"public"."tenant_integrations"'::regclass) AND to_regclass('"public"."tenant_integrations_organization_id_provider_key"') IS NULL THEN ALTER TABLE ONLY "public"."tenant_integrations" ADD CONSTRAINT "tenant_integrations_organization_id_provider_key" UNIQUE ("organization_id", "provider"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tenant_integrations_pkey' AND conrelid = '"public"."tenant_integrations"'::regclass) AND to_regclass('"public"."tenant_integrations_pkey"') IS NULL THEN ALTER TABLE ONLY "public"."tenant_integrations" ADD CONSTRAINT "tenant_integrations_pkey" PRIMARY KEY ("id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'user_organizations_pkey' AND conrelid = '"public"."user_organizations"'::regclass) AND to_regclass('"public"."user_organizations_pkey"') IS NULL THEN ALTER TABLE ONLY "public"."user_organizations" ADD CONSTRAINT "user_organizations_pkey" PRIMARY KEY ("id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'user_organizations_user_id_organization_id_key' AND conrelid = '"public"."user_organizations"'::regclass) AND to_regclass('"public"."user_organizations_user_id_organization_id_key"') IS NULL THEN ALTER TABLE ONLY "public"."user_organizations" ADD CONSTRAINT "user_organizations_user_id_organization_id_key" UNIQUE ("user_id", "organization_id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'user_recovery_codes_pkey' AND conrelid = '"public"."user_recovery_codes"'::regclass) AND to_regclass('"public"."user_recovery_codes_pkey"') IS NULL THEN ALTER TABLE ONLY "public"."user_recovery_codes" ADD CONSTRAINT "user_recovery_codes_pkey" PRIMARY KEY ("id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'warmup_session_day_unique' AND conrelid = '"public"."channel_session_warmup"'::regclass) AND to_regclass('"public"."warmup_session_day_unique"') IS NULL THEN ALTER TABLE ONLY "public"."channel_session_warmup" ADD CONSTRAINT "warmup_session_day_unique" UNIQUE ("channel_session_id", "day"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'webhook_events_log_pkey' AND conrelid = '"public"."webhook_events_log"'::regclass) AND to_regclass('"public"."webhook_events_log_pkey"') IS NULL THEN ALTER TABLE ONLY "public"."webhook_events_log" ADD CONSTRAINT "webhook_events_log_pkey" PRIMARY KEY ("id"); END IF; END $baseline_guard$; CREATE INDEX IF NOT EXISTS "ai_agent_runs_agent_idx" ON "public"."ai_agent_runs" USING "btree" ("agent_id", "started_at" DESC); CREATE UNIQUE INDEX IF NOT EXISTS "ai_agent_runs_one_running_per_conv" ON "public"."ai_agent_runs" USING "btree" ("conversation_id") WHERE (("status" = 'running'::"text") AND ("is_dry_run" = false)); CREATE INDEX IF NOT EXISTS "ai_agent_runs_org_started_idx" ON "public"."ai_agent_runs" USING "btree" ("organization_id", "started_at" DESC); CREATE INDEX IF NOT EXISTS "ai_agent_runs_status_idx" ON "public"."ai_agent_runs" USING "btree" ("status", "started_at") WHERE ("status" = ANY (ARRAY['pending'::"text", 'running'::"text"])); CREATE INDEX IF NOT EXISTS "ai_agent_versions_agent_idx" ON "public"."ai_agent_versions" USING "btree" ("agent_id", "version_number" DESC); CREATE UNIQUE INDEX IF NOT EXISTS "ai_agents_one_default_per_org" ON "public"."ai_agents" USING "btree" ("organization_id") WHERE "is_default"; CREATE INDEX IF NOT EXISTS "ai_agents_org_active_idx" ON "public"."ai_agents" USING "btree" ("organization_id") WHERE "is_active"; CREATE INDEX IF NOT EXISTS "ai_agents_published_idx" ON "public"."ai_agents" USING "btree" ("organization_id", "priority" DESC) WHERE (("published_version_id" IS NOT NULL) AND ("archived_at" IS NULL)); CREATE INDEX IF NOT EXISTS "ai_chunks_embedding_ivfflat_idx" ON "public"."ai_chunks" USING "ivfflat" ("embedding" "public"."vector_cosine_ops") WITH ("lists"='100'); CREATE INDEX IF NOT EXISTS "ai_chunks_metadata_gin_idx" ON "public"."ai_chunks" USING "gin" ("metadata"); CREATE INDEX IF NOT EXISTS "ai_chunks_org_kbv_idx" ON "public"."ai_chunks" USING "btree" ("organization_id", "kb_version_id"); CREATE INDEX IF NOT EXISTS "ai_chunks_source_idx" ON "public"."ai_chunks" USING "btree" ("knowledge_source_id"); CREATE INDEX IF NOT EXISTS "ai_faq_items_org_idx" ON "public"."ai_faq_items" USING "btree" ("organization_id"); CREATE INDEX IF NOT EXISTS "ai_faq_items_source_idx" ON "public"."ai_faq_items" USING "btree" ("knowledge_source_id", "position"); CREATE INDEX IF NOT EXISTS "ai_invocations_agent_kind_idx" ON "public"."ai_invocations" USING "btree" ("agent_id", "invocation_kind"); CREATE INDEX IF NOT EXISTS "ai_invocations_conversation_idx" ON "public"."ai_invocations" USING "btree" ("conversation_id") WHERE ("conversation_id" IS NOT NULL); CREATE INDEX IF NOT EXISTS "ai_invocations_org_created_idx" ON "public"."ai_invocations" USING "btree" ("organization_id", "created_at" DESC); CREATE INDEX IF NOT EXISTS "ai_knowledge_sources_agent_idx" ON "public"."ai_knowledge_sources" USING "btree" ("agent_id", "is_active"); CREATE UNIQUE INDEX IF NOT EXISTS "ai_models_one_default_per_provider" ON "public"."ai_models" USING "btree" ("provider") WHERE "is_default_for_provider"; CREATE INDEX IF NOT EXISTS "ai_provider_credentials_org_provider_idx" ON "public"."ai_provider_credentials" USING "btree" ("organization_id", "provider") WHERE "is_active"; CREATE INDEX IF NOT EXISTS "conversations_bot_silenced_idx" ON "public"."conversations" USING "btree" ("bot_silenced_until") WHERE ("bot_silenced_until" IS NOT NULL); CREATE INDEX IF NOT EXISTS "conversations_usable_rag_idx" ON "public"."conversations" USING "btree" ("organization_id", "usable_for_rag", "usable_for_rag_marked_at") WHERE ("usable_for_rag" = true); CREATE INDEX IF NOT EXISTS "event_log_consumed_by_gin" ON "public"."event_log" USING "gin" ("consumed_by"); CREATE INDEX IF NOT EXISTS "event_log_dead_idx" ON "public"."event_log" USING "btree" ("organization_id", "created_at" DESC) WHERE ("status" = 'dead'::"text"); CREATE INDEX IF NOT EXISTS "event_log_entity_idx" ON "public"."event_log" USING "btree" ("entity_kind", "entity_id", "created_at" DESC); CREATE INDEX IF NOT EXISTS "event_log_org_type_idx" ON "public"."event_log" USING "btree" ("organization_id", "event_type", "created_at" DESC); CREATE INDEX IF NOT EXISTS "event_log_pending_idx" ON "public"."event_log" USING "btree" ("organization_id", "created_at") WHERE ("status" = 'pending'::"text"); CREATE INDEX IF NOT EXISTS "idx_api_tokens_hash" ON "public"."api_tokens" USING "btree" ("token_hash") WHERE ("revoked_at" IS NULL); CREATE INDEX IF NOT EXISTS "idx_api_tokens_org" ON "public"."api_tokens" USING "btree" ("organization_id") WHERE ("revoked_at" IS NULL); CREATE INDEX IF NOT EXISTS "idx_audit_action_time" ON "public"."api_audit_log" USING "btree" ("action", "created_at" DESC); CREATE INDEX IF NOT EXISTS "idx_audit_actor_time" ON "public"."api_audit_log" USING "btree" ("actor_user_id", "created_at" DESC); CREATE INDEX IF NOT EXISTS "idx_audit_org_time" ON "public"."api_audit_log" USING "btree" ("organization_id", "created_at" DESC); CREATE INDEX IF NOT EXISTS "idx_audit_request" ON "public"."api_audit_log" USING "btree" ("request_id"); CREATE INDEX IF NOT EXISTS "idx_audit_resource" ON "public"."api_audit_log" USING "btree" ("resource_type", "resource_id"); CREATE INDEX IF NOT EXISTS "idx_channel_sessions_health" ON "public"."channel_sessions" USING "btree" ("last_health_check_at") WHERE ("status" = 'WORKING'::"text"); CREATE INDEX IF NOT EXISTS "idx_channel_sessions_org_status" ON "public"."channel_sessions" USING "btree" ("organization_id", "status"); CREATE INDEX IF NOT EXISTS "idx_contacts_consent_gin" ON "public"."contacts" USING "gin" ("consent" "jsonb_path_ops"); CREATE INDEX IF NOT EXISTS "idx_contacts_org_blocked" ON "public"."contacts" USING "btree" ("organization_id") WHERE ("is_blocked" = true); CREATE INDEX IF NOT EXISTS "idx_contacts_org_last_activity" ON "public"."contacts" USING "btree" ("organization_id", "last_activity_at" DESC NULLS LAST); CREATE INDEX IF NOT EXISTS "idx_contacts_org_name_trgm" ON "public"."contacts" USING "gin" ("name" "public"."gin_trgm_ops"); CREATE INDEX IF NOT EXISTS "idx_contacts_tags_gin" ON "public"."contacts" USING "gin" ("tags"); CREATE INDEX IF NOT EXISTS "idx_conversations_assigned" ON "public"."conversations" USING "btree" ("assigned_to_user_id", "status") WHERE ("assigned_to_user_id" IS NOT NULL); CREATE INDEX IF NOT EXISTS "idx_conversations_open_unassigned" ON "public"."conversations" USING "btree" ("organization_id", "last_inbound_at" DESC) WHERE (("status" = 'open'::"text") AND ("assigned_to_user_id" IS NULL)); CREATE INDEX IF NOT EXISTS "idx_conversations_org_last_msg" ON "public"."conversations" USING "btree" ("organization_id", "last_message_at" DESC NULLS LAST); CREATE INDEX IF NOT EXISTS "idx_crm_lead_links_org_target" ON "public"."crm_lead_links" USING "btree" ("organization_id", "target_kind", "target_id"); CREATE INDEX IF NOT EXISTS "idx_crm_leads_custom_fields_gin" ON "public"."crm_leads" USING "gin" ("custom_fields" "jsonb_path_ops"); CREATE INDEX IF NOT EXISTS "idx_crm_leads_org_contact" ON "public"."crm_leads" USING "btree" ("organization_id", "contact_id"); CREATE INDEX IF NOT EXISTS "idx_crm_leads_org_expected_close_overdue" ON "public"."crm_leads" USING "btree" ("organization_id", "expected_close_date") WHERE (("status" = 'open'::"text") AND ("expected_close_date" IS NOT NULL)); CREATE INDEX IF NOT EXISTS "idx_crm_leads_org_last_activity" ON "public"."crm_leads" USING "btree" ("organization_id", "last_activity_at" DESC NULLS LAST); CREATE INDEX IF NOT EXISTS "idx_crm_leads_org_owner_status" ON "public"."crm_leads" USING "btree" ("organization_id", "owner_user_id", "status") WHERE ("status" = 'open'::"text"); CREATE INDEX IF NOT EXISTS "idx_crm_leads_org_pipeline_status" ON "public"."crm_leads" USING "btree" ("organization_id", "pipeline_id", "status"); CREATE INDEX IF NOT EXISTS "idx_crm_leads_org_stage_position" ON "public"."crm_leads" USING "btree" ("organization_id", "stage_id", "position_in_stage"); CREATE INDEX IF NOT EXISTS "idx_crm_leads_tags_gin" ON "public"."crm_leads" USING "gin" ("tags"); CREATE INDEX IF NOT EXISTS "idx_crm_pipelines_org_position" ON "public"."crm_pipelines" USING "btree" ("organization_id", "position") WHERE ("is_archived" = false); CREATE INDEX IF NOT EXISTS "idx_crm_stages_pipeline_position" ON "public"."crm_stages" USING "btree" ("pipeline_id", "position") WHERE ("is_archived" = false); CREATE INDEX IF NOT EXISTS "idx_idem_expiry" ON "public"."idempotency_keys" USING "btree" ("expires_at"); CREATE INDEX IF NOT EXISTS "idx_idem_lookup" ON "public"."idempotency_keys" USING "btree" ("organization_id", "key", "endpoint"); CREATE INDEX IF NOT EXISTS "idx_lead_activities_org_contact" ON "public"."crm_lead_activities" USING "btree" ("organization_id", "contact_id", "performed_at" DESC); CREATE INDEX IF NOT EXISTS "idx_lead_activities_org_lead_perf" ON "public"."crm_lead_activities" USING "btree" ("organization_id", "lead_id", "performed_at" DESC); CREATE INDEX IF NOT EXISTS "idx_lead_activities_org_type_perf" ON "public"."crm_lead_activities" USING "btree" ("organization_id", "type", "performed_at" DESC); CREATE INDEX IF NOT EXISTS "idx_lead_activities_payload_gin" ON "public"."crm_lead_activities" USING "gin" ("payload" "jsonb_path_ops"); CREATE INDEX IF NOT EXISTS "idx_merge_queue_org_status" ON "public"."merge_queue" USING "btree" ("organization_id", "status", "created_at"); CREATE INDEX IF NOT EXISTS "idx_messages_conversation_sent" ON "public"."messages" USING "btree" ("conversation_id", "sent_at" DESC); CREATE INDEX IF NOT EXISTS "idx_messages_external_lookup" ON "public"."messages" USING "btree" ("organization_id", "external_id") WHERE ("external_id" IS NOT NULL); CREATE INDEX IF NOT EXISTS "idx_messages_org_status_created" ON "public"."messages" USING "btree" ("organization_id", "status", "created_at") WHERE ("status" = ANY (ARRAY['sending'::"text", 'failed'::"text"])); CREATE INDEX IF NOT EXISTS "idx_organizations_pending_onboarding" ON "public"."organizations" USING "btree" ("id") WHERE ("onboarded_at" IS NULL); CREATE INDEX IF NOT EXISTS "idx_orgs_slug" ON "public"."organizations" USING "btree" ("slug"); CREATE INDEX IF NOT EXISTS "idx_orgs_status" ON "public"."organizations" USING "btree" ("status") WHERE ("status" = 'active'::"text"); CREATE UNIQUE INDEX IF NOT EXISTS "idx_recovery_unique" ON "public"."user_recovery_codes" USING "btree" ("user_id", "code_hash"); CREATE INDEX IF NOT EXISTS "idx_recovery_user" ON "public"."user_recovery_codes" USING "btree" ("user_id") WHERE ("used_at" IS NULL); CREATE INDEX IF NOT EXISTS "idx_user_orgs_org_role" ON "public"."user_organizations" USING "btree" ("organization_id", "role") WHERE ("revoked_at" IS NULL); CREATE INDEX IF NOT EXISTS "idx_user_orgs_user" ON "public"."user_organizations" USING "btree" ("user_id") WHERE ("revoked_at" IS NULL); CREATE INDEX IF NOT EXISTS "idx_warmup_org_day" ON "public"."channel_session_warmup" USING "btree" ("organization_id", "day" DESC); CREATE INDEX IF NOT EXISTS "idx_webhook_events_external_id" ON "public"."webhook_events_log" USING "btree" ("organization_id", "provider", "external_id") WHERE ("external_id" IS NOT NULL); CREATE INDEX IF NOT EXISTS "idx_webhook_events_org_received" ON "public"."webhook_events_log" USING "btree" ("organization_id", "received_at" DESC); CREATE INDEX IF NOT EXISTS "idx_webhook_events_status_received" ON "public"."webhook_events_log" USING "btree" ("status", "received_at") WHERE ("status" = ANY (ARRAY['received'::"text", 'error'::"text"])); CREATE INDEX IF NOT EXISTS "incidents_org_idx" ON "public"."incidents" USING "btree" ("organization_id", "created_at" DESC); CREATE INDEX IF NOT EXISTS "incidents_severity_idx" ON "public"."incidents" USING "btree" ("severity", "status"); CREATE INDEX IF NOT EXISTS "incidents_status_idx" ON "public"."incidents" USING "btree" ("status", "created_at" DESC) WHERE ("status" <> 'resolved'::"text"); CREATE INDEX IF NOT EXISTS "lgpd_requests_contact_idx" ON "public"."lgpd_requests" USING "btree" ("contact_id") WHERE ("contact_id" IS NOT NULL); CREATE INDEX IF NOT EXISTS "lgpd_requests_emergency_idx" ON "public"."lgpd_requests" USING "btree" ("organization_id", "emergency", "due_at") WHERE ("emergency" = true); CREATE INDEX IF NOT EXISTS "lgpd_requests_org_due_idx" ON "public"."lgpd_requests" USING "btree" ("organization_id", "due_at") WHERE ("status" = ANY (ARRAY['received'::"text", 'processing'::"text"])); CREATE INDEX IF NOT EXISTS "lgpd_requests_org_status_idx" ON "public"."lgpd_requests" USING "btree" ("organization_id", "status"); CREATE INDEX IF NOT EXISTS "nuvemshop_products_org_idx" ON "public"."nuvemshop_products" USING "btree" ("organization_id"); CREATE INDEX IF NOT EXISTS "nuvemshop_products_rag_pending_idx" ON "public"."nuvemshop_products" USING "btree" ("organization_id") WHERE ("rag_indexed_at" IS NULL); CREATE INDEX IF NOT EXISTS "nuvemshop_products_title_trgm" ON "public"."nuvemshop_products" USING "gin" ("title" "public"."gin_trgm_ops"); CREATE INDEX IF NOT EXISTS "orders_contact_idx" ON "public"."orders" USING "btree" ("contact_id") WHERE ("contact_id" IS NOT NULL); CREATE INDEX IF NOT EXISTS "orders_customer_external_idx" ON "public"."orders" USING "btree" ("organization_id", "external_provider", "customer_external_id"); CREATE INDEX IF NOT EXISTS "orders_org_ordered_idx" ON "public"."orders" USING "btree" ("organization_id", "ordered_at" DESC); CREATE INDEX IF NOT EXISTS "orders_payload_gin" ON "public"."orders" USING "gin" ("payload" "jsonb_path_ops"); CREATE INDEX IF NOT EXISTS "orders_status_idx" ON "public"."orders" USING "btree" ("organization_id", "status"); CREATE INDEX IF NOT EXISTS "storage_redaction_queue_org_idx" ON "public"."storage_redaction_queue" USING "btree" ("organization_id"); CREATE INDEX IF NOT EXISTS "storage_redaction_queue_status_idx" ON "public"."storage_redaction_queue" USING "btree" ("status", "enqueued_at") WHERE ("status" = 'pending'::"text"); CREATE INDEX IF NOT EXISTS "tenant_integrations_expires_idx" ON "public"."tenant_integrations" USING "btree" ("expires_at") WHERE ("expires_at" IS NOT NULL); CREATE INDEX IF NOT EXISTS "tenant_integrations_org_idx" ON "public"."tenant_integrations" USING "btree" ("organization_id"); CREATE UNIQUE INDEX IF NOT EXISTS "tenant_integrations_path_token_idx" ON "public"."tenant_integrations" USING "btree" ("webhook_path_token"); CREATE INDEX IF NOT EXISTS "tenant_integrations_status_idx" ON "public"."tenant_integrations" USING "btree" ("status") WHERE ("status" = ANY (ARRAY['token_expired'::"text", 'error'::"text"])); CREATE UNIQUE INDEX IF NOT EXISTS "uniq_contacts_org_cpf" ON "public"."contacts" USING "btree" ("organization_id", "cpf_hash") WHERE (("cpf_hash" IS NOT NULL) AND ("is_merged_into" IS NULL)); CREATE UNIQUE INDEX IF NOT EXISTS "uniq_contacts_org_email" ON "public"."contacts" USING "btree" ("organization_id", "email_normalized") WHERE (("email_normalized" IS NOT NULL) AND ("is_merged_into" IS NULL)); CREATE UNIQUE INDEX IF NOT EXISTS "uniq_contacts_org_phone" ON "public"."contacts" USING "btree" ("organization_id", "phone_number") WHERE (("phone_number" IS NOT NULL) AND ("is_merged_into" IS NULL)); CREATE UNIQUE INDEX IF NOT EXISTS "uniq_crm_lead_links_lead_target_link" ON "public"."crm_lead_links" USING "btree" ("lead_id", "target_kind", "target_id", "link_kind"); CREATE UNIQUE INDEX IF NOT EXISTS "uniq_crm_leads_org_source_external" ON "public"."crm_leads" USING "btree" ("organization_id", "source", "external_id") WHERE ("external_id" IS NOT NULL); CREATE UNIQUE INDEX IF NOT EXISTS "uniq_crm_pipelines_org_default" ON "public"."crm_pipelines" USING "btree" ("organization_id") WHERE ("is_default" = true); CREATE UNIQUE INDEX IF NOT EXISTS "uniq_crm_pipelines_org_slug" ON "public"."crm_pipelines" USING "btree" ("organization_id", "slug"); CREATE UNIQUE INDEX IF NOT EXISTS "uniq_crm_stages_pipeline_lost" ON "public"."crm_stages" USING "btree" ("pipeline_id") WHERE (("is_lost" = true) AND ("is_archived" = false)); CREATE UNIQUE INDEX IF NOT EXISTS "uniq_crm_stages_pipeline_slug" ON "public"."crm_stages" USING "btree" ("pipeline_id", "slug"); CREATE UNIQUE INDEX IF NOT EXISTS "uniq_crm_stages_pipeline_won" ON "public"."crm_stages" USING "btree" ("pipeline_id") WHERE (("is_won" = true) AND ("is_archived" = false)); CREATE INDEX IF NOT EXISTS "webhook_events_log_dlq_idx" ON "public"."webhook_events_log" USING "btree" ("organization_id", "provider") WHERE ("status" = 'dead'::"text"); CREATE INDEX IF NOT EXISTS "webhook_events_log_lgpd_idx" ON "public"."webhook_events_log" USING "btree" ("organization_id", "provider", "event_type", "received_at" DESC) WHERE ("event_type" = ANY (ARRAY['customer/redact'::"text", 'customer/data_request'::"text", 'store/redact'::"text"])); CREATE OR REPLACE TRIGGER "ai_faq_items_updated_at" BEFORE UPDATE ON "public"."ai_faq_items" FOR EACH ROW EXECUTE FUNCTION "public"."fn_set_updated_at"(); CREATE OR REPLACE TRIGGER "incidents_updated_at" BEFORE UPDATE ON "public"."incidents" FOR EACH ROW EXECUTE FUNCTION "public"."fn_set_updated_at"(); CREATE OR REPLACE TRIGGER "trg_ai_agent_runs_audit" AFTER INSERT OR DELETE OR UPDATE ON "public"."ai_agent_runs" FOR EACH ROW EXECUTE FUNCTION "public"."fn_audit_log_row"(); CREATE OR REPLACE TRIGGER "trg_ai_agent_versions_audit" AFTER INSERT OR DELETE OR UPDATE ON "public"."ai_agent_versions" FOR EACH ROW EXECUTE FUNCTION "public"."fn_audit_log_row"(); CREATE OR REPLACE TRIGGER "trg_ai_agents_audit" AFTER INSERT OR DELETE OR UPDATE ON "public"."ai_agents" FOR EACH ROW EXECUTE FUNCTION "public"."fn_audit_log_row"(); CREATE OR REPLACE TRIGGER "trg_ai_agents_updated_at" BEFORE UPDATE ON "public"."ai_agents" FOR EACH ROW EXECUTE FUNCTION "public"."fn_set_updated_at"(); CREATE OR REPLACE TRIGGER "trg_ai_budgets_updated_at" BEFORE UPDATE ON "public"."ai_budgets" FOR EACH ROW EXECUTE FUNCTION "public"."fn_set_updated_at"(); CREATE OR REPLACE TRIGGER "trg_ai_invocations_budget" AFTER INSERT ON "public"."ai_invocations" FOR EACH ROW EXECUTE FUNCTION "public"."fn_update_budget_consumption"(); CREATE OR REPLACE TRIGGER "trg_ai_knowledge_sources_updated_at" BEFORE UPDATE ON "public"."ai_knowledge_sources" FOR EACH ROW EXECUTE FUNCTION "public"."fn_set_updated_at"(); CREATE OR REPLACE TRIGGER "trg_ai_provider_credentials_audit" AFTER INSERT OR DELETE OR UPDATE ON "public"."ai_provider_credentials" FOR EACH ROW EXECUTE FUNCTION "public"."fn_audit_log_row"(); CREATE OR REPLACE TRIGGER "trg_api_tokens_touch" BEFORE UPDATE ON "public"."api_tokens" FOR EACH ROW EXECUTE FUNCTION "public"."fn_touch_updated_at"(); CREATE OR REPLACE TRIGGER "trg_channel_sessions_status_audit" AFTER UPDATE OF "status" ON "public"."channel_sessions" FOR EACH ROW WHEN (("old"."status" IS DISTINCT FROM "new"."status")) EXECUTE FUNCTION "public"."fn_emit_channel_session_status_changed"(); CREATE OR REPLACE TRIGGER "trg_channel_sessions_updated_at" BEFORE UPDATE ON "public"."channel_sessions" FOR EACH ROW EXECUTE FUNCTION "public"."fn_set_updated_at"(); CREATE OR REPLACE TRIGGER "trg_contacts_updated_at" BEFORE UPDATE ON "public"."contacts" FOR EACH ROW EXECUTE FUNCTION "public"."fn_set_updated_at"(); CREATE OR REPLACE TRIGGER "trg_conversations_updated_at" BEFORE UPDATE ON "public"."conversations" FOR EACH ROW EXECUTE FUNCTION "public"."fn_set_updated_at"(); CREATE OR REPLACE TRIGGER "trg_crm_lead_close_on_stage" BEFORE INSERT OR UPDATE OF "stage_id" ON "public"."crm_leads" FOR EACH ROW EXECUTE FUNCTION "public"."fn_crm_lead_close_on_stage"(); CREATE OR REPLACE TRIGGER "trg_crm_leads_updated_at" BEFORE UPDATE ON "public"."crm_leads" FOR EACH ROW EXECUTE FUNCTION "public"."fn_set_updated_at"(); CREATE OR REPLACE TRIGGER "trg_crm_pipelines_updated_at" BEFORE UPDATE ON "public"."crm_pipelines" FOR EACH ROW EXECUTE FUNCTION "public"."fn_set_updated_at"(); CREATE OR REPLACE TRIGGER "trg_crm_stages_updated_at" BEFORE UPDATE ON "public"."crm_stages" FOR EACH ROW EXECUTE FUNCTION "public"."fn_set_updated_at"(); CREATE OR REPLACE TRIGGER "trg_emit_event_on_lead_change" AFTER INSERT OR UPDATE ON "public"."crm_leads" FOR EACH ROW EXECUTE FUNCTION "public"."fn_emit_event_on_lead_change"(); CREATE OR REPLACE TRIGGER "trg_event_log_touch" BEFORE UPDATE ON "public"."event_log" FOR EACH ROW EXECUTE FUNCTION "public"."fn_touch_updated_at"(); CREATE OR REPLACE TRIGGER "trg_lgpd_requests_updated_at" BEFORE UPDATE ON "public"."lgpd_requests" FOR EACH ROW EXECUTE FUNCTION "public"."fn_set_updated_at"(); CREATE OR REPLACE TRIGGER "trg_messages_emit_event" AFTER INSERT ON "public"."messages" FOR EACH ROW EXECUTE FUNCTION "public"."fn_emit_message_event"(); CREATE OR REPLACE TRIGGER "trg_messages_updated_at" BEFORE UPDATE ON "public"."messages" FOR EACH ROW EXECUTE FUNCTION "public"."fn_set_updated_at"(); CREATE OR REPLACE TRIGGER "trg_nuvemshop_products_updated_at" BEFORE UPDATE ON "public"."nuvemshop_products" FOR EACH ROW EXECUTE FUNCTION "public"."fn_set_updated_at"(); CREATE OR REPLACE TRIGGER "trg_orders_updated_at" BEFORE UPDATE ON "public"."orders" FOR EACH ROW EXECUTE FUNCTION "public"."fn_set_updated_at"(); CREATE OR REPLACE TRIGGER "trg_organizations_touch" BEFORE UPDATE ON "public"."organizations" FOR EACH ROW EXECUTE FUNCTION "public"."fn_touch_updated_at"(); CREATE OR REPLACE TRIGGER "trg_seed_default_pipeline_for_org" AFTER INSERT ON "public"."organizations" FOR EACH ROW EXECUTE FUNCTION "public"."fn_seed_default_pipeline_for_org"(); CREATE OR REPLACE TRIGGER "trg_tenant_integrations_updated_at" BEFORE UPDATE ON "public"."tenant_integrations" FOR EACH ROW EXECUTE FUNCTION "public"."fn_set_updated_at"(); CREATE OR REPLACE TRIGGER "trg_update_last_activity_at" AFTER INSERT ON "public"."crm_lead_activities" FOR EACH ROW EXECUTE FUNCTION "public"."fn_update_last_activity_at"(); CREATE OR REPLACE TRIGGER "trg_user_orgs_touch" BEFORE UPDATE ON "public"."user_organizations" FOR EACH ROW EXECUTE FUNCTION "public"."fn_touch_updated_at"(); CREATE OR REPLACE TRIGGER "trg_validate_activity_lead_org" BEFORE INSERT ON "public"."crm_lead_activities" FOR EACH ROW EXECUTE FUNCTION "public"."fn_validate_activity_lead_org"(); CREATE OR REPLACE TRIGGER "trg_validate_lost_reason_required" BEFORE INSERT OR UPDATE OF "status", "lost_reason" ON "public"."crm_leads" FOR EACH ROW EXECUTE FUNCTION "public"."fn_validate_lost_reason_required"(); DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_agent_runs_agent_id_fkey' AND conrelid = '"public"."ai_agent_runs"'::regclass) AND to_regclass('"public"."ai_agent_runs_agent_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."ai_agent_runs" ADD CONSTRAINT "ai_agent_runs_agent_id_fkey" FOREIGN KEY ("agent_id") REFERENCES "public"."ai_agents"("id") ON DELETE RESTRICT; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_agent_runs_agent_version_id_fkey' AND conrelid = '"public"."ai_agent_runs"'::regclass) AND to_regclass('"public"."ai_agent_runs_agent_version_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."ai_agent_runs" ADD CONSTRAINT "ai_agent_runs_agent_version_id_fkey" FOREIGN KEY ("agent_version_id") REFERENCES "public"."ai_agent_versions"("id") ON DELETE RESTRICT; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_agent_runs_channel_session_id_fkey' AND conrelid = '"public"."ai_agent_runs"'::regclass) AND to_regclass('"public"."ai_agent_runs_channel_session_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."ai_agent_runs" ADD CONSTRAINT "ai_agent_runs_channel_session_id_fkey" FOREIGN KEY ("channel_session_id") REFERENCES "public"."channel_sessions"("id") ON DELETE SET NULL; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_agent_runs_contact_id_fkey' AND conrelid = '"public"."ai_agent_runs"'::regclass) AND to_regclass('"public"."ai_agent_runs_contact_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."ai_agent_runs" ADD CONSTRAINT "ai_agent_runs_contact_id_fkey" FOREIGN KEY ("contact_id") REFERENCES "public"."contacts"("id") ON DELETE SET NULL; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_agent_runs_conversation_id_fkey' AND conrelid = '"public"."ai_agent_runs"'::regclass) AND to_regclass('"public"."ai_agent_runs_conversation_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."ai_agent_runs" ADD CONSTRAINT "ai_agent_runs_conversation_id_fkey" FOREIGN KEY ("conversation_id") REFERENCES "public"."conversations"("id") ON DELETE SET NULL; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_agent_runs_inbound_message_id_fkey' AND conrelid = '"public"."ai_agent_runs"'::regclass) AND to_regclass('"public"."ai_agent_runs_inbound_message_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."ai_agent_runs" ADD CONSTRAINT "ai_agent_runs_inbound_message_id_fkey" FOREIGN KEY ("inbound_message_id") REFERENCES "public"."messages"("id") ON DELETE SET NULL; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_agent_runs_organization_id_fkey' AND conrelid = '"public"."ai_agent_runs"'::regclass) AND to_regclass('"public"."ai_agent_runs_organization_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."ai_agent_runs" ADD CONSTRAINT "ai_agent_runs_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE CASCADE; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_agent_runs_outbound_message_id_fkey' AND conrelid = '"public"."ai_agent_runs"'::regclass) AND to_regclass('"public"."ai_agent_runs_outbound_message_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."ai_agent_runs" ADD CONSTRAINT "ai_agent_runs_outbound_message_id_fkey" FOREIGN KEY ("outbound_message_id") REFERENCES "public"."messages"("id") ON DELETE SET NULL; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_agent_versions_agent_id_fkey' AND conrelid = '"public"."ai_agent_versions"'::regclass) AND to_regclass('"public"."ai_agent_versions_agent_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."ai_agent_versions" ADD CONSTRAINT "ai_agent_versions_agent_id_fkey" FOREIGN KEY ("agent_id") REFERENCES "public"."ai_agents"("id") ON DELETE CASCADE; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_agent_versions_channel_session_id_fkey' AND conrelid = '"public"."ai_agent_versions"'::regclass) AND to_regclass('"public"."ai_agent_versions_channel_session_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."ai_agent_versions" ADD CONSTRAINT "ai_agent_versions_channel_session_id_fkey" FOREIGN KEY ("channel_session_id") REFERENCES "public"."channel_sessions"("id") ON DELETE RESTRICT; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_agent_versions_created_by_fkey' AND conrelid = '"public"."ai_agent_versions"'::regclass) AND to_regclass('"public"."ai_agent_versions_created_by_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."ai_agent_versions" ADD CONSTRAINT "ai_agent_versions_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "auth"."users"("id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_agent_versions_credential_id_fkey' AND conrelid = '"public"."ai_agent_versions"'::regclass) AND to_regclass('"public"."ai_agent_versions_credential_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."ai_agent_versions" ADD CONSTRAINT "ai_agent_versions_credential_id_fkey" FOREIGN KEY ("credential_id") REFERENCES "public"."ai_provider_credentials"("id") ON DELETE RESTRICT; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_agent_versions_organization_id_fkey' AND conrelid = '"public"."ai_agent_versions"'::regclass) AND to_regclass('"public"."ai_agent_versions_organization_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."ai_agent_versions" ADD CONSTRAINT "ai_agent_versions_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE CASCADE; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_agents_created_by_fkey' AND conrelid = '"public"."ai_agents"'::regclass) AND to_regclass('"public"."ai_agents_created_by_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."ai_agents" ADD CONSTRAINT "ai_agents_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "auth"."users"("id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_agents_organization_id_fkey' AND conrelid = '"public"."ai_agents"'::regclass) AND to_regclass('"public"."ai_agents_organization_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."ai_agents" ADD CONSTRAINT "ai_agents_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE CASCADE; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_agents_published_version_id_fkey' AND conrelid = '"public"."ai_agents"'::regclass) AND to_regclass('"public"."ai_agents_published_version_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."ai_agents" ADD CONSTRAINT "ai_agents_published_version_id_fkey" FOREIGN KEY ("published_version_id") REFERENCES "public"."ai_agent_versions"("id") ON DELETE SET NULL; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_budgets_organization_id_fkey' AND conrelid = '"public"."ai_budgets"'::regclass) AND to_regclass('"public"."ai_budgets_organization_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."ai_budgets" ADD CONSTRAINT "ai_budgets_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE CASCADE; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_chunks_knowledge_source_id_fkey' AND conrelid = '"public"."ai_chunks"'::regclass) AND to_regclass('"public"."ai_chunks_knowledge_source_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."ai_chunks" ADD CONSTRAINT "ai_chunks_knowledge_source_id_fkey" FOREIGN KEY ("knowledge_source_id") REFERENCES "public"."ai_knowledge_sources"("id") ON DELETE CASCADE; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_chunks_organization_id_fkey' AND conrelid = '"public"."ai_chunks"'::regclass) AND to_regclass('"public"."ai_chunks_organization_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."ai_chunks" ADD CONSTRAINT "ai_chunks_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE CASCADE; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_faq_items_knowledge_source_id_fkey' AND conrelid = '"public"."ai_faq_items"'::regclass) AND to_regclass('"public"."ai_faq_items_knowledge_source_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."ai_faq_items" ADD CONSTRAINT "ai_faq_items_knowledge_source_id_fkey" FOREIGN KEY ("knowledge_source_id") REFERENCES "public"."ai_knowledge_sources"("id") ON DELETE CASCADE; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_faq_items_organization_id_fkey' AND conrelid = '"public"."ai_faq_items"'::regclass) AND to_regclass('"public"."ai_faq_items_organization_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."ai_faq_items" ADD CONSTRAINT "ai_faq_items_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE CASCADE; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_invocations_agent_id_fkey' AND conrelid = '"public"."ai_invocations"'::regclass) AND to_regclass('"public"."ai_invocations_agent_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."ai_invocations" ADD CONSTRAINT "ai_invocations_agent_id_fkey" FOREIGN KEY ("agent_id") REFERENCES "public"."ai_agents"("id") ON DELETE CASCADE; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_invocations_conversation_id_fkey' AND conrelid = '"public"."ai_invocations"'::regclass) AND to_regclass('"public"."ai_invocations_conversation_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."ai_invocations" ADD CONSTRAINT "ai_invocations_conversation_id_fkey" FOREIGN KEY ("conversation_id") REFERENCES "public"."conversations"("id") ON DELETE SET NULL; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_invocations_message_id_fkey' AND conrelid = '"public"."ai_invocations"'::regclass) AND to_regclass('"public"."ai_invocations_message_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."ai_invocations" ADD CONSTRAINT "ai_invocations_message_id_fkey" FOREIGN KEY ("message_id") REFERENCES "public"."messages"("id") ON DELETE SET NULL; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_invocations_organization_id_fkey' AND conrelid = '"public"."ai_invocations"'::regclass) AND to_regclass('"public"."ai_invocations_organization_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."ai_invocations" ADD CONSTRAINT "ai_invocations_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE CASCADE; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_knowledge_sources_agent_id_fkey' AND conrelid = '"public"."ai_knowledge_sources"'::regclass) AND to_regclass('"public"."ai_knowledge_sources_agent_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."ai_knowledge_sources" ADD CONSTRAINT "ai_knowledge_sources_agent_id_fkey" FOREIGN KEY ("agent_id") REFERENCES "public"."ai_agents"("id") ON DELETE CASCADE; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_knowledge_sources_organization_id_fkey' AND conrelid = '"public"."ai_knowledge_sources"'::regclass) AND to_regclass('"public"."ai_knowledge_sources_organization_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."ai_knowledge_sources" ADD CONSTRAINT "ai_knowledge_sources_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE CASCADE; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_knowledge_versions_activated_by_fkey' AND conrelid = '"public"."ai_knowledge_versions"'::regclass) AND to_regclass('"public"."ai_knowledge_versions_activated_by_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."ai_knowledge_versions" ADD CONSTRAINT "ai_knowledge_versions_activated_by_fkey" FOREIGN KEY ("activated_by") REFERENCES "auth"."users"("id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_knowledge_versions_agent_id_fkey' AND conrelid = '"public"."ai_knowledge_versions"'::regclass) AND to_regclass('"public"."ai_knowledge_versions_agent_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."ai_knowledge_versions" ADD CONSTRAINT "ai_knowledge_versions_agent_id_fkey" FOREIGN KEY ("agent_id") REFERENCES "public"."ai_agents"("id") ON DELETE CASCADE; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_knowledge_versions_organization_id_fkey' AND conrelid = '"public"."ai_knowledge_versions"'::regclass) AND to_regclass('"public"."ai_knowledge_versions_organization_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."ai_knowledge_versions" ADD CONSTRAINT "ai_knowledge_versions_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE CASCADE; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_provider_credentials_created_by_fkey' AND conrelid = '"public"."ai_provider_credentials"'::regclass) AND to_regclass('"public"."ai_provider_credentials_created_by_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."ai_provider_credentials" ADD CONSTRAINT "ai_provider_credentials_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "auth"."users"("id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_provider_credentials_organization_id_fkey' AND conrelid = '"public"."ai_provider_credentials"'::regclass) AND to_regclass('"public"."ai_provider_credentials_organization_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."ai_provider_credentials" ADD CONSTRAINT "ai_provider_credentials_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE CASCADE; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'api_audit_log_actor_api_token_id_fkey' AND conrelid = '"public"."api_audit_log"'::regclass) AND to_regclass('"public"."api_audit_log_actor_api_token_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."api_audit_log" ADD CONSTRAINT "api_audit_log_actor_api_token_id_fkey" FOREIGN KEY ("actor_api_token_id") REFERENCES "public"."api_tokens"("id") ON DELETE SET NULL; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'api_audit_log_actor_user_id_fkey' AND conrelid = '"public"."api_audit_log"'::regclass) AND to_regclass('"public"."api_audit_log_actor_user_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."api_audit_log" ADD CONSTRAINT "api_audit_log_actor_user_id_fkey" FOREIGN KEY ("actor_user_id") REFERENCES "auth"."users"("id") ON DELETE SET NULL; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'api_audit_log_organization_id_fkey' AND conrelid = '"public"."api_audit_log"'::regclass) AND to_regclass('"public"."api_audit_log_organization_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."api_audit_log" ADD CONSTRAINT "api_audit_log_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE SET NULL; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'api_tokens_created_by_fkey' AND conrelid = '"public"."api_tokens"'::regclass) AND to_regclass('"public"."api_tokens_created_by_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."api_tokens" ADD CONSTRAINT "api_tokens_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "auth"."users"("id") ON DELETE RESTRICT; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'api_tokens_organization_id_fkey' AND conrelid = '"public"."api_tokens"'::regclass) AND to_regclass('"public"."api_tokens_organization_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."api_tokens" ADD CONSTRAINT "api_tokens_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE CASCADE; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'api_tokens_revoked_by_fkey' AND conrelid = '"public"."api_tokens"'::regclass) AND to_regclass('"public"."api_tokens_revoked_by_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."api_tokens" ADD CONSTRAINT "api_tokens_revoked_by_fkey" FOREIGN KEY ("revoked_by") REFERENCES "auth"."users"("id") ON DELETE SET NULL; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'channel_session_warmup_channel_session_id_fkey' AND conrelid = '"public"."channel_session_warmup"'::regclass) AND to_regclass('"public"."channel_session_warmup_channel_session_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."channel_session_warmup" ADD CONSTRAINT "channel_session_warmup_channel_session_id_fkey" FOREIGN KEY ("channel_session_id") REFERENCES "public"."channel_sessions"("id") ON DELETE CASCADE; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'channel_session_warmup_organization_id_fkey' AND conrelid = '"public"."channel_session_warmup"'::regclass) AND to_regclass('"public"."channel_session_warmup_organization_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."channel_session_warmup" ADD CONSTRAINT "channel_session_warmup_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE CASCADE; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'channel_sessions_created_by_fkey' AND conrelid = '"public"."channel_sessions"'::regclass) AND to_regclass('"public"."channel_sessions_created_by_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."channel_sessions" ADD CONSTRAINT "channel_sessions_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "auth"."users"("id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'channel_sessions_organization_id_fkey' AND conrelid = '"public"."channel_sessions"'::regclass) AND to_regclass('"public"."channel_sessions_organization_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."channel_sessions" ADD CONSTRAINT "channel_sessions_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE CASCADE; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'contacts_is_merged_into_fkey' AND conrelid = '"public"."contacts"'::regclass) AND to_regclass('"public"."contacts_is_merged_into_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."contacts" ADD CONSTRAINT "contacts_is_merged_into_fkey" FOREIGN KEY ("is_merged_into") REFERENCES "public"."contacts"("id") ON DELETE SET NULL; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'contacts_organization_id_fkey' AND conrelid = '"public"."contacts"'::regclass) AND to_regclass('"public"."contacts_organization_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."contacts" ADD CONSTRAINT "contacts_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE CASCADE; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'conversations_assigned_to_user_id_fkey' AND conrelid = '"public"."conversations"'::regclass) AND to_regclass('"public"."conversations_assigned_to_user_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."conversations" ADD CONSTRAINT "conversations_assigned_to_user_id_fkey" FOREIGN KEY ("assigned_to_user_id") REFERENCES "auth"."users"("id") ON DELETE SET NULL; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'conversations_channel_session_id_fkey' AND conrelid = '"public"."conversations"'::regclass) AND to_regclass('"public"."conversations_channel_session_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."conversations" ADD CONSTRAINT "conversations_channel_session_id_fkey" FOREIGN KEY ("channel_session_id") REFERENCES "public"."channel_sessions"("id") ON DELETE RESTRICT; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'conversations_contact_id_fkey' AND conrelid = '"public"."conversations"'::regclass) AND to_regclass('"public"."conversations_contact_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."conversations" ADD CONSTRAINT "conversations_contact_id_fkey" FOREIGN KEY ("contact_id") REFERENCES "public"."contacts"("id") ON DELETE RESTRICT; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'conversations_organization_id_fkey' AND conrelid = '"public"."conversations"'::regclass) AND to_regclass('"public"."conversations_organization_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."conversations" ADD CONSTRAINT "conversations_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE CASCADE; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'conversations_usable_for_rag_marked_by_fkey' AND conrelid = '"public"."conversations"'::regclass) AND to_regclass('"public"."conversations_usable_for_rag_marked_by_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."conversations" ADD CONSTRAINT "conversations_usable_for_rag_marked_by_fkey" FOREIGN KEY ("usable_for_rag_marked_by") REFERENCES "auth"."users"("id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'crm_lead_activities_contact_id_fkey' AND conrelid = '"public"."crm_lead_activities"'::regclass) AND to_regclass('"public"."crm_lead_activities_contact_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."crm_lead_activities" ADD CONSTRAINT "crm_lead_activities_contact_id_fkey" FOREIGN KEY ("contact_id") REFERENCES "public"."contacts"("id") ON DELETE SET NULL; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'crm_lead_activities_lead_id_fkey' AND conrelid = '"public"."crm_lead_activities"'::regclass) AND to_regclass('"public"."crm_lead_activities_lead_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."crm_lead_activities" ADD CONSTRAINT "crm_lead_activities_lead_id_fkey" FOREIGN KEY ("lead_id") REFERENCES "public"."crm_leads"("id") ON DELETE CASCADE; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'crm_lead_activities_organization_id_fkey' AND conrelid = '"public"."crm_lead_activities"'::regclass) AND to_regclass('"public"."crm_lead_activities_organization_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."crm_lead_activities" ADD CONSTRAINT "crm_lead_activities_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE CASCADE; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'crm_lead_links_lead_id_fkey' AND conrelid = '"public"."crm_lead_links"'::regclass) AND to_regclass('"public"."crm_lead_links_lead_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."crm_lead_links" ADD CONSTRAINT "crm_lead_links_lead_id_fkey" FOREIGN KEY ("lead_id") REFERENCES "public"."crm_leads"("id") ON DELETE CASCADE; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'crm_lead_links_organization_id_fkey' AND conrelid = '"public"."crm_lead_links"'::regclass) AND to_regclass('"public"."crm_lead_links_organization_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."crm_lead_links" ADD CONSTRAINT "crm_lead_links_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE CASCADE; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'crm_leads_contact_id_fkey' AND conrelid = '"public"."crm_leads"'::regclass) AND to_regclass('"public"."crm_leads_contact_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."crm_leads" ADD CONSTRAINT "crm_leads_contact_id_fkey" FOREIGN KEY ("contact_id") REFERENCES "public"."contacts"("id") ON DELETE SET NULL; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'crm_leads_organization_id_fkey' AND conrelid = '"public"."crm_leads"'::regclass) AND to_regclass('"public"."crm_leads_organization_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."crm_leads" ADD CONSTRAINT "crm_leads_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE CASCADE; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'crm_leads_pipeline_id_fkey' AND conrelid = '"public"."crm_leads"'::regclass) AND to_regclass('"public"."crm_leads_pipeline_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."crm_leads" ADD CONSTRAINT "crm_leads_pipeline_id_fkey" FOREIGN KEY ("pipeline_id") REFERENCES "public"."crm_pipelines"("id") ON DELETE RESTRICT; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'crm_leads_stage_id_fkey' AND conrelid = '"public"."crm_leads"'::regclass) AND to_regclass('"public"."crm_leads_stage_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."crm_leads" ADD CONSTRAINT "crm_leads_stage_id_fkey" FOREIGN KEY ("stage_id") REFERENCES "public"."crm_stages"("id") ON DELETE RESTRICT; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'crm_pipelines_organization_id_fkey' AND conrelid = '"public"."crm_pipelines"'::regclass) AND to_regclass('"public"."crm_pipelines_organization_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."crm_pipelines" ADD CONSTRAINT "crm_pipelines_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE CASCADE; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'crm_stages_organization_id_fkey' AND conrelid = '"public"."crm_stages"'::regclass) AND to_regclass('"public"."crm_stages_organization_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."crm_stages" ADD CONSTRAINT "crm_stages_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE CASCADE; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'crm_stages_pipeline_id_fkey' AND conrelid = '"public"."crm_stages"'::regclass) AND to_regclass('"public"."crm_stages_pipeline_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."crm_stages" ADD CONSTRAINT "crm_stages_pipeline_id_fkey" FOREIGN KEY ("pipeline_id") REFERENCES "public"."crm_pipelines"("id") ON DELETE CASCADE; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'event_log_organization_id_fkey' AND conrelid = '"public"."event_log"'::regclass) AND to_regclass('"public"."event_log_organization_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."event_log" ADD CONSTRAINT "event_log_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE CASCADE; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'idempotency_keys_organization_id_fkey' AND conrelid = '"public"."idempotency_keys"'::regclass) AND to_regclass('"public"."idempotency_keys_organization_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."idempotency_keys" ADD CONSTRAINT "idempotency_keys_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE CASCADE; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'incidents_acknowledged_by_fkey' AND conrelid = '"public"."incidents"'::regclass) AND to_regclass('"public"."incidents_acknowledged_by_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."incidents" ADD CONSTRAINT "incidents_acknowledged_by_fkey" FOREIGN KEY ("acknowledged_by") REFERENCES "auth"."users"("id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'incidents_organization_id_fkey' AND conrelid = '"public"."incidents"'::regclass) AND to_regclass('"public"."incidents_organization_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."incidents" ADD CONSTRAINT "incidents_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE CASCADE; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'incidents_resolved_by_fkey' AND conrelid = '"public"."incidents"'::regclass) AND to_regclass('"public"."incidents_resolved_by_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."incidents" ADD CONSTRAINT "incidents_resolved_by_fkey" FOREIGN KEY ("resolved_by") REFERENCES "auth"."users"("id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'lgpd_requests_contact_id_fkey' AND conrelid = '"public"."lgpd_requests"'::regclass) AND to_regclass('"public"."lgpd_requests_contact_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."lgpd_requests" ADD CONSTRAINT "lgpd_requests_contact_id_fkey" FOREIGN KEY ("contact_id") REFERENCES "public"."contacts"("id") ON DELETE SET NULL; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'lgpd_requests_organization_id_fkey' AND conrelid = '"public"."lgpd_requests"'::regclass) AND to_regclass('"public"."lgpd_requests_organization_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."lgpd_requests" ADD CONSTRAINT "lgpd_requests_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE CASCADE; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'merge_queue_organization_id_fkey' AND conrelid = '"public"."merge_queue"'::regclass) AND to_regclass('"public"."merge_queue_organization_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."merge_queue" ADD CONSTRAINT "merge_queue_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE CASCADE; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'messages_activity_id_fkey' AND conrelid = '"public"."messages"'::regclass) AND to_regclass('"public"."messages_activity_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."messages" ADD CONSTRAINT "messages_activity_id_fkey" FOREIGN KEY ("activity_id") REFERENCES "public"."crm_lead_activities"("id") ON DELETE SET NULL; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'messages_channel_session_id_fkey' AND conrelid = '"public"."messages"'::regclass) AND to_regclass('"public"."messages_channel_session_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."messages" ADD CONSTRAINT "messages_channel_session_id_fkey" FOREIGN KEY ("channel_session_id") REFERENCES "public"."channel_sessions"("id") ON DELETE RESTRICT; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'messages_contact_id_fkey' AND conrelid = '"public"."messages"'::regclass) AND to_regclass('"public"."messages_contact_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."messages" ADD CONSTRAINT "messages_contact_id_fkey" FOREIGN KEY ("contact_id") REFERENCES "public"."contacts"("id") ON DELETE RESTRICT; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'messages_conversation_id_fkey' AND conrelid = '"public"."messages"'::regclass) AND to_regclass('"public"."messages_conversation_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."messages" ADD CONSTRAINT "messages_conversation_id_fkey" FOREIGN KEY ("conversation_id") REFERENCES "public"."conversations"("id") ON DELETE CASCADE; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'messages_organization_id_fkey' AND conrelid = '"public"."messages"'::regclass) AND to_regclass('"public"."messages_organization_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."messages" ADD CONSTRAINT "messages_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE CASCADE; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'messages_sent_by_user_id_fkey' AND conrelid = '"public"."messages"'::regclass) AND to_regclass('"public"."messages_sent_by_user_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."messages" ADD CONSTRAINT "messages_sent_by_user_id_fkey" FOREIGN KEY ("sent_by_user_id") REFERENCES "auth"."users"("id") ON DELETE SET NULL; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'nuvemshop_products_organization_id_fkey' AND conrelid = '"public"."nuvemshop_products"'::regclass) AND to_regclass('"public"."nuvemshop_products_organization_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."nuvemshop_products" ADD CONSTRAINT "nuvemshop_products_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE CASCADE; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'orders_contact_id_fkey' AND conrelid = '"public"."orders"'::regclass) AND to_regclass('"public"."orders_contact_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."orders" ADD CONSTRAINT "orders_contact_id_fkey" FOREIGN KEY ("contact_id") REFERENCES "public"."contacts"("id") ON DELETE SET NULL; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'orders_organization_id_fkey' AND conrelid = '"public"."orders"'::regclass) AND to_regclass('"public"."orders_organization_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."orders" ADD CONSTRAINT "orders_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE CASCADE; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'organizations_created_by_fkey' AND conrelid = '"public"."organizations"'::regclass) AND to_regclass('"public"."organizations_created_by_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."organizations" ADD CONSTRAINT "organizations_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "auth"."users"("id") ON DELETE SET NULL; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'organizations_suspended_by_fkey' AND conrelid = '"public"."organizations"'::regclass) AND to_regclass('"public"."organizations_suspended_by_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."organizations" ADD CONSTRAINT "organizations_suspended_by_fkey" FOREIGN KEY ("suspended_by") REFERENCES "auth"."users"("id"); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'platform_admins_granted_by_fkey' AND conrelid = '"public"."platform_admins"'::regclass) AND to_regclass('"public"."platform_admins_granted_by_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."platform_admins" ADD CONSTRAINT "platform_admins_granted_by_fkey" FOREIGN KEY ("granted_by") REFERENCES "auth"."users"("id") ON DELETE RESTRICT; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'platform_admins_revoked_by_fkey' AND conrelid = '"public"."platform_admins"'::regclass) AND to_regclass('"public"."platform_admins_revoked_by_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."platform_admins" ADD CONSTRAINT "platform_admins_revoked_by_fkey" FOREIGN KEY ("revoked_by") REFERENCES "auth"."users"("id") ON DELETE SET NULL; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'platform_admins_user_id_fkey' AND conrelid = '"public"."platform_admins"'::regclass) AND to_regclass('"public"."platform_admins_user_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."platform_admins" ADD CONSTRAINT "platform_admins_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE CASCADE; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'storage_redaction_queue_organization_id_fkey' AND conrelid = '"public"."storage_redaction_queue"'::regclass) AND to_regclass('"public"."storage_redaction_queue_organization_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."storage_redaction_queue" ADD CONSTRAINT "storage_redaction_queue_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE CASCADE; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'storage_redaction_queue_request_id_fkey' AND conrelid = '"public"."storage_redaction_queue"'::regclass) AND to_regclass('"public"."storage_redaction_queue_request_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."storage_redaction_queue" ADD CONSTRAINT "storage_redaction_queue_request_id_fkey" FOREIGN KEY ("request_id") REFERENCES "public"."lgpd_requests"("id") ON DELETE SET NULL; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tenant_integrations_organization_id_fkey' AND conrelid = '"public"."tenant_integrations"'::regclass) AND to_regclass('"public"."tenant_integrations_organization_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."tenant_integrations" ADD CONSTRAINT "tenant_integrations_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE CASCADE; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'user_organizations_invited_by_fkey' AND conrelid = '"public"."user_organizations"'::regclass) AND to_regclass('"public"."user_organizations_invited_by_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."user_organizations" ADD CONSTRAINT "user_organizations_invited_by_fkey" FOREIGN KEY ("invited_by") REFERENCES "auth"."users"("id") ON DELETE SET NULL; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'user_organizations_organization_id_fkey' AND conrelid = '"public"."user_organizations"'::regclass) AND to_regclass('"public"."user_organizations_organization_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."user_organizations" ADD CONSTRAINT "user_organizations_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE CASCADE; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'user_organizations_user_id_fkey' AND conrelid = '"public"."user_organizations"'::regclass) AND to_regclass('"public"."user_organizations_user_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."user_organizations" ADD CONSTRAINT "user_organizations_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE CASCADE; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'user_recovery_codes_user_id_fkey' AND conrelid = '"public"."user_recovery_codes"'::regclass) AND to_regclass('"public"."user_recovery_codes_user_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."user_recovery_codes" ADD CONSTRAINT "user_recovery_codes_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE CASCADE; END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'webhook_events_log_channel_session_id_fkey' AND conrelid = '"public"."webhook_events_log"'::regclass) AND to_regclass('"public"."webhook_events_log_channel_session_id_fkey"') IS NULL THEN ALTER TABLE ONLY "public"."webhook_events_log" ADD CONSTRAINT "webhook_events_log_channel_session_id_fkey" FOREIGN KEY ("channel_session_id") REFERENCES "public"."channel_sessions"("id") ON DELETE SET NULL; END IF; END $baseline_guard$; ALTER TABLE "public"."ai_agent_runs" ENABLE ROW LEVEL SECURITY; ALTER TABLE "public"."ai_agent_versions" ENABLE ROW LEVEL SECURITY; ALTER TABLE "public"."ai_agents" ENABLE ROW LEVEL SECURITY; ALTER TABLE "public"."ai_budgets" ENABLE ROW LEVEL SECURITY; ALTER TABLE "public"."ai_chunks" ENABLE ROW LEVEL SECURITY; ALTER TABLE "public"."ai_faq_items" ENABLE ROW LEVEL SECURITY; ALTER TABLE "public"."ai_invocations" ENABLE ROW LEVEL SECURITY; ALTER TABLE "public"."ai_knowledge_sources" ENABLE ROW LEVEL SECURITY; ALTER TABLE "public"."ai_knowledge_versions" ENABLE ROW LEVEL SECURITY; ALTER TABLE "public"."ai_models" ENABLE ROW LEVEL SECURITY; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'ai_models_read_all' AND polrelid = '"public"."ai_models"'::regclass) THEN CREATE POLICY "ai_models_read_all" ON "public"."ai_models" FOR SELECT USING (true); END IF; END $baseline_guard$; ALTER TABLE "public"."ai_pricing" ENABLE ROW LEVEL SECURITY; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'ai_pricing_public_read' AND polrelid = '"public"."ai_pricing"'::regclass) THEN CREATE POLICY "ai_pricing_public_read" ON "public"."ai_pricing" FOR SELECT USING (true); END IF; END $baseline_guard$; ALTER TABLE "public"."ai_provider_credentials" ENABLE ROW LEVEL SECURITY; ALTER TABLE "public"."api_audit_log" ENABLE ROW LEVEL SECURITY; ALTER TABLE "public"."api_tokens" ENABLE ROW LEVEL SECURITY; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'api_tokens_admin_only' AND polrelid = '"public"."api_tokens"'::regclass) THEN CREATE POLICY "api_tokens_admin_only" ON "public"."api_tokens" USING (("public"."fn_role_at_least"("organization_id", 'admin'::"text") OR "public"."fn_is_platform_admin"())) WITH CHECK (("public"."fn_role_at_least"("organization_id", 'admin'::"text") OR "public"."fn_is_platform_admin"())); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'audit_log_insert_tenant_member' AND polrelid = '"public"."api_audit_log"'::regclass) THEN CREATE POLICY "audit_log_insert_tenant_member" ON "public"."api_audit_log" FOR INSERT TO "authenticated" WITH CHECK ((("organization_id" IS NULL) OR ("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) OR "public"."fn_is_platform_admin"())); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'audit_log_select' AND polrelid = '"public"."api_audit_log"'::regclass) THEN CREATE POLICY "audit_log_select" ON "public"."api_audit_log" FOR SELECT USING (("public"."fn_is_platform_admin"() OR (("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) AND "public"."fn_role_at_least"("organization_id", 'admin'::"text")))); END IF; END $baseline_guard$; ALTER TABLE "public"."channel_session_warmup" ENABLE ROW LEVEL SECURITY; ALTER TABLE "public"."channel_sessions" ENABLE ROW LEVEL SECURITY; ALTER TABLE "public"."contacts" ENABLE ROW LEVEL SECURITY; ALTER TABLE "public"."conversations" ENABLE ROW LEVEL SECURITY; ALTER TABLE "public"."crm_lead_activities" ENABLE ROW LEVEL SECURITY; ALTER TABLE "public"."crm_lead_links" ENABLE ROW LEVEL SECURITY; ALTER TABLE "public"."crm_leads" ENABLE ROW LEVEL SECURITY; ALTER TABLE "public"."crm_pipelines" ENABLE ROW LEVEL SECURITY; ALTER TABLE "public"."crm_stages" ENABLE ROW LEVEL SECURITY; ALTER TABLE "public"."event_log" ENABLE ROW LEVEL SECURITY; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'event_log_select' AND polrelid = '"public"."event_log"'::regclass) THEN CREATE POLICY "event_log_select" ON "public"."event_log" FOR SELECT USING ((("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) OR "public"."fn_is_platform_admin"())); END IF; END $baseline_guard$; ALTER TABLE "public"."idempotency_keys" ENABLE ROW LEVEL SECURITY; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'idempotency_tenant' AND polrelid = '"public"."idempotency_keys"'::regclass) THEN CREATE POLICY "idempotency_tenant" ON "public"."idempotency_keys" USING (("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids"))) WITH CHECK (("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids"))); END IF; END $baseline_guard$; ALTER TABLE "public"."incidents" ENABLE ROW LEVEL SECURITY; ALTER TABLE "public"."lgpd_requests" ENABLE ROW LEVEL SECURITY; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'lgpd_requests_admin_select' AND polrelid = '"public"."lgpd_requests"'::regclass) THEN CREATE POLICY "lgpd_requests_admin_select" ON "public"."lgpd_requests" FOR SELECT USING (("public"."fn_is_platform_admin"() OR (("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) AND "public"."fn_role_at_least"("organization_id", 'admin'::"text")))); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'lgpd_requests_admin_write' AND polrelid = '"public"."lgpd_requests"'::regclass) THEN CREATE POLICY "lgpd_requests_admin_write" ON "public"."lgpd_requests" USING (("public"."fn_is_platform_admin"() OR (("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) AND "public"."fn_role_at_least"("organization_id", 'admin'::"text")))) WITH CHECK (("public"."fn_is_platform_admin"() OR (("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) AND "public"."fn_role_at_least"("organization_id", 'admin'::"text")))); END IF; END $baseline_guard$; ALTER TABLE "public"."merge_queue" ENABLE ROW LEVEL SECURITY; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'merge_queue_manager_select' AND polrelid = '"public"."merge_queue"'::regclass) THEN CREATE POLICY "merge_queue_manager_select" ON "public"."merge_queue" FOR SELECT USING (("public"."fn_is_platform_admin"() OR (("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) AND "public"."fn_role_at_least"("organization_id", 'manager'::"text")))); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'merge_queue_manager_write' AND polrelid = '"public"."merge_queue"'::regclass) THEN CREATE POLICY "merge_queue_manager_write" ON "public"."merge_queue" USING (("public"."fn_is_platform_admin"() OR (("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) AND "public"."fn_role_at_least"("organization_id", 'manager'::"text")))) WITH CHECK (("public"."fn_is_platform_admin"() OR (("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) AND "public"."fn_role_at_least"("organization_id", 'manager'::"text")))); END IF; END $baseline_guard$; ALTER TABLE "public"."messages" ENABLE ROW LEVEL SECURITY; ALTER TABLE "public"."nuvemshop_products" ENABLE ROW LEVEL SECURITY; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'nuvemshop_products_tenant' AND polrelid = '"public"."nuvemshop_products"'::regclass) THEN CREATE POLICY "nuvemshop_products_tenant" ON "public"."nuvemshop_products" USING ((("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) OR "public"."fn_is_platform_admin"())) WITH CHECK ((("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) OR "public"."fn_is_platform_admin"())); END IF; END $baseline_guard$; ALTER TABLE "public"."orders" ENABLE ROW LEVEL SECURITY; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'orders_tenant_select' AND polrelid = '"public"."orders"'::regclass) THEN CREATE POLICY "orders_tenant_select" ON "public"."orders" FOR SELECT USING ((("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) OR "public"."fn_is_platform_admin"())); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'orders_tenant_write' AND polrelid = '"public"."orders"'::regclass) THEN CREATE POLICY "orders_tenant_write" ON "public"."orders" USING ((("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) OR "public"."fn_is_platform_admin"())) WITH CHECK ((("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) OR "public"."fn_is_platform_admin"())); END IF; END $baseline_guard$; ALTER TABLE "public"."organizations" ENABLE ROW LEVEL SECURITY; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'orgs_select' AND polrelid = '"public"."organizations"'::regclass) THEN CREATE POLICY "orgs_select" ON "public"."organizations" FOR SELECT USING ((("id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) OR "public"."fn_is_platform_admin"())); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'orgs_write_platform_admin' AND polrelid = '"public"."organizations"'::regclass) THEN CREATE POLICY "orgs_write_platform_admin" ON "public"."organizations" USING ("public"."fn_is_platform_admin"()) WITH CHECK ("public"."fn_is_platform_admin"()); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'platform_admin_only_incidents' AND polrelid = '"public"."incidents"'::regclass) THEN CREATE POLICY "platform_admin_only_incidents" ON "public"."incidents" USING ("public"."fn_is_platform_admin"()) WITH CHECK ("public"."fn_is_platform_admin"()); END IF; END $baseline_guard$; ALTER TABLE "public"."platform_admins" ENABLE ROW LEVEL SECURITY; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'platform_admins_self' AND polrelid = '"public"."platform_admins"'::regclass) THEN CREATE POLICY "platform_admins_self" ON "public"."platform_admins" FOR SELECT USING ("public"."fn_is_platform_admin"()); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'recovery_codes_self' AND polrelid = '"public"."user_recovery_codes"'::regclass) THEN CREATE POLICY "recovery_codes_self" ON "public"."user_recovery_codes" USING (("user_id" = "auth"."uid"())) WITH CHECK (("user_id" = "auth"."uid"())); END IF; END $baseline_guard$; ALTER TABLE "public"."storage_redaction_queue" ENABLE ROW LEVEL SECURITY; ALTER TABLE "public"."tenant_integrations" ENABLE ROW LEVEL SECURITY; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'tenant_integrations_admin_write' AND polrelid = '"public"."tenant_integrations"'::regclass) THEN CREATE POLICY "tenant_integrations_admin_write" ON "public"."tenant_integrations" USING (("public"."fn_is_platform_admin"() OR (("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) AND "public"."fn_role_at_least"("organization_id", 'manager'::"text")))) WITH CHECK (("public"."fn_is_platform_admin"() OR (("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) AND "public"."fn_role_at_least"("organization_id", 'manager'::"text")))); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'tenant_integrations_select' AND polrelid = '"public"."tenant_integrations"'::regclass) THEN CREATE POLICY "tenant_integrations_select" ON "public"."tenant_integrations" FOR SELECT USING ((("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) OR "public"."fn_is_platform_admin"())); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'tenant_isolation_ai_agent_runs_all' AND polrelid = '"public"."ai_agent_runs"'::regclass) THEN CREATE POLICY "tenant_isolation_ai_agent_runs_all" ON "public"."ai_agent_runs" USING (("organization_id" IN ( SELECT "fn_user_org_ids"."fn_user_org_ids" FROM "public"."fn_user_org_ids"() "fn_user_org_ids"("fn_user_org_ids")))) WITH CHECK (("organization_id" IN ( SELECT "fn_user_org_ids"."fn_user_org_ids" FROM "public"."fn_user_org_ids"() "fn_user_org_ids"("fn_user_org_ids")))); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'tenant_isolation_ai_invocations_all' AND polrelid = '"public"."ai_invocations"'::regclass) THEN CREATE POLICY "tenant_isolation_ai_invocations_all" ON "public"."ai_invocations" USING ((("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) OR "public"."fn_is_platform_admin"())) WITH CHECK ((("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) OR "public"."fn_is_platform_admin"())); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'tenant_isolation_ai_provider_credentials_select' AND polrelid = '"public"."ai_provider_credentials"'::regclass) THEN CREATE POLICY "tenant_isolation_ai_provider_credentials_select" ON "public"."ai_provider_credentials" FOR SELECT USING (("organization_id" IN ( SELECT "fn_user_org_ids"."fn_user_org_ids" FROM "public"."fn_user_org_ids"() "fn_user_org_ids"("fn_user_org_ids")))); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'tenant_isolation_contacts_all' AND polrelid = '"public"."contacts"'::regclass) THEN CREATE POLICY "tenant_isolation_contacts_all" ON "public"."contacts" USING ((("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) OR "public"."fn_is_platform_admin"())) WITH CHECK ((("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) OR "public"."fn_is_platform_admin"())); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'tenant_isolation_storage_redaction_queue_all' AND polrelid = '"public"."storage_redaction_queue"'::regclass) THEN CREATE POLICY "tenant_isolation_storage_redaction_queue_all" ON "public"."storage_redaction_queue" USING (("organization_id" IN ( SELECT "fn_user_org_ids"."fn_user_org_ids" FROM "public"."fn_user_org_ids"() "fn_user_org_ids"("fn_user_org_ids")))) WITH CHECK (("organization_id" IN ( SELECT "fn_user_org_ids"."fn_user_org_ids" FROM "public"."fn_user_org_ids"() "fn_user_org_ids"("fn_user_org_ids")))); END IF; END $baseline_guard$; ALTER TABLE "public"."user_organizations" ENABLE ROW LEVEL SECURITY; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'user_orgs_delete' AND polrelid = '"public"."user_organizations"'::regclass) THEN CREATE POLICY "user_orgs_delete" ON "public"."user_organizations" FOR DELETE USING (("public"."fn_role_at_least"("organization_id", 'admin'::"text") OR "public"."fn_is_platform_admin"())); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'user_orgs_insert' AND polrelid = '"public"."user_organizations"'::regclass) THEN CREATE POLICY "user_orgs_insert" ON "public"."user_organizations" FOR INSERT WITH CHECK (("public"."fn_role_at_least"("organization_id", 'admin'::"text") OR "public"."fn_is_platform_admin"())); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'user_orgs_select' AND polrelid = '"public"."user_organizations"'::regclass) THEN CREATE POLICY "user_orgs_select" ON "public"."user_organizations" FOR SELECT USING ((("user_id" = "auth"."uid"()) OR "public"."fn_role_at_least"("organization_id", 'admin'::"text") OR "public"."fn_is_platform_admin"())); END IF; END $baseline_guard$; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'user_orgs_update' AND polrelid = '"public"."user_organizations"'::regclass) THEN CREATE POLICY "user_orgs_update" ON "public"."user_organizations" FOR UPDATE USING (("public"."fn_role_at_least"("organization_id", 'admin'::"text") OR "public"."fn_is_platform_admin"())); END IF; END $baseline_guard$; ALTER TABLE "public"."user_recovery_codes" ENABLE ROW LEVEL SECURITY; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'warmup_tenant_isolation_all' AND polrelid = '"public"."channel_session_warmup"'::regclass) THEN CREATE POLICY "warmup_tenant_isolation_all" ON "public"."channel_session_warmup" USING ((("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) OR "public"."fn_is_platform_admin"())) WITH CHECK ((("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids")) OR "public"."fn_is_platform_admin"())); END IF; END $baseline_guard$; ALTER TABLE "public"."webhook_events_log" ENABLE ROW LEVEL SECURITY; DO $baseline_guard$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'webhook_events_log_tenant_read' AND polrelid = '"public"."webhook_events_log"'::regclass) THEN CREATE POLICY "webhook_events_log_tenant_read" ON "public"."webhook_events_log" FOR SELECT USING (("public"."fn_is_platform_admin"() OR (("organization_id" IS NOT NULL) AND ("organization_id" IN ( SELECT "public"."fn_user_org_ids"() AS "fn_user_org_ids"))))); END IF; END $baseline_guard$; GRANT USAGE ON SCHEMA "public" TO "postgres"; GRANT USAGE ON SCHEMA "public" TO "anon"; GRANT USAGE ON SCHEMA "public" TO "authenticated"; GRANT USAGE ON SCHEMA "public" TO "service_role"; REVOKE ALL ON FUNCTION "public"."activate_kb_version"("p_agent_id" "uuid", "p_version_id" "uuid") FROM PUBLIC; GRANT ALL ON FUNCTION "public"."activate_kb_version"("p_agent_id" "uuid", "p_version_id" "uuid") TO "service_role"; GRANT ALL ON FUNCTION "public"."emit_event"("p_event_type" "text", "p_entity_kind" "text", "p_entity_id" "uuid", "p_payload" "jsonb", "p_metadata" "jsonb", "p_organization_id" "uuid") TO "authenticated"; GRANT ALL ON FUNCTION "public"."emit_event"("p_event_type" "text", "p_entity_kind" "text", "p_entity_id" "uuid", "p_payload" "jsonb", "p_metadata" "jsonb", "p_organization_id" "uuid") TO "service_role"; GRANT ALL ON FUNCTION "public"."fn_audit_log_row"() TO "service_role"; GRANT ALL ON FUNCTION "public"."fn_crm_lead_close_on_stage"() TO "anon"; GRANT ALL ON FUNCTION "public"."fn_crm_lead_close_on_stage"() TO "authenticated"; GRANT ALL ON FUNCTION "public"."fn_crm_lead_close_on_stage"() TO "service_role"; REVOKE ALL ON FUNCTION "public"."fn_decrypt_oauth"("ciphertext" "bytea") FROM PUBLIC; GRANT ALL ON FUNCTION "public"."fn_decrypt_oauth"("ciphertext" "bytea") TO "service_role"; GRANT ALL ON FUNCTION "public"."fn_emit_channel_session_status_changed"() TO "anon"; GRANT ALL ON FUNCTION "public"."fn_emit_channel_session_status_changed"() TO "authenticated"; GRANT ALL ON FUNCTION "public"."fn_emit_channel_session_status_changed"() TO "service_role"; GRANT ALL ON FUNCTION "public"."fn_emit_event_on_lead_change"() TO "anon"; GRANT ALL ON FUNCTION "public"."fn_emit_event_on_lead_change"() TO "authenticated"; GRANT ALL ON FUNCTION "public"."fn_emit_event_on_lead_change"() TO "service_role"; GRANT ALL ON FUNCTION "public"."fn_emit_message_event"() TO "anon"; GRANT ALL ON FUNCTION "public"."fn_emit_message_event"() TO "authenticated"; GRANT ALL ON FUNCTION "public"."fn_emit_message_event"() TO "service_role"; REVOKE ALL ON FUNCTION "public"."fn_encrypt_oauth"("plaintext" "text") FROM PUBLIC; GRANT ALL ON FUNCTION "public"."fn_encrypt_oauth"("plaintext" "text") TO "service_role"; GRANT ALL ON FUNCTION "public"."fn_is_platform_admin"() TO "authenticated"; GRANT ALL ON FUNCTION "public"."fn_is_platform_admin"() TO "service_role"; REVOKE ALL ON FUNCTION "public"."fn_lgpd_cascade_redact_contact"("p_organization_id" "uuid", "p_contact_id" "uuid", "p_request_id" "uuid") FROM PUBLIC; GRANT ALL ON FUNCTION "public"."fn_lgpd_cascade_redact_contact"("p_organization_id" "uuid", "p_contact_id" "uuid", "p_request_id" "uuid") TO "service_role"; GRANT ALL ON FUNCTION "public"."fn_log_event"("p_organization_id" "uuid", "p_event_type" "text", "p_payload" "jsonb") TO "authenticated"; GRANT ALL ON FUNCTION "public"."fn_log_event"("p_organization_id" "uuid", "p_event_type" "text", "p_payload" "jsonb") TO "service_role"; GRANT ALL ON FUNCTION "public"."fn_publish_ai_agent_version"("p_org_id" "uuid", "p_agent_id" "uuid", "p_version_id" "uuid") TO "service_role"; GRANT ALL ON FUNCTION "public"."fn_role_at_least"("p_org" "uuid", "p_min" "text") TO "authenticated"; GRANT ALL ON FUNCTION "public"."fn_role_at_least"("p_org" "uuid", "p_min" "text") TO "service_role"; GRANT ALL ON FUNCTION "public"."fn_seed_default_pipeline_for_org"() TO "anon"; GRANT ALL ON FUNCTION "public"."fn_seed_default_pipeline_for_org"() TO "authenticated"; GRANT ALL ON FUNCTION "public"."fn_seed_default_pipeline_for_org"() TO "service_role"; GRANT ALL ON FUNCTION "public"."fn_set_updated_at"() TO "anon"; GRANT ALL ON FUNCTION "public"."fn_set_updated_at"() TO "authenticated"; GRANT ALL ON FUNCTION "public"."fn_set_updated_at"() TO "service_role"; GRANT ALL ON FUNCTION "public"."fn_touch_updated_at"() TO "anon"; GRANT ALL ON FUNCTION "public"."fn_touch_updated_at"() TO "authenticated"; GRANT ALL ON FUNCTION "public"."fn_touch_updated_at"() TO "service_role"; REVOKE ALL ON FUNCTION "public"."fn_update_budget_consumption"() FROM PUBLIC; GRANT ALL ON FUNCTION "public"."fn_update_budget_consumption"() TO "service_role"; GRANT ALL ON FUNCTION "public"."fn_update_last_activity_at"() TO "anon"; GRANT ALL ON FUNCTION "public"."fn_update_last_activity_at"() TO "authenticated"; GRANT ALL ON FUNCTION "public"."fn_update_last_activity_at"() TO "service_role"; GRANT ALL ON FUNCTION "public"."fn_user_org_ids"() TO "authenticated"; GRANT ALL ON FUNCTION "public"."fn_user_org_ids"() TO "service_role"; GRANT ALL ON FUNCTION "public"."fn_user_role_in"("p_org" "uuid") TO "authenticated"; GRANT ALL ON FUNCTION "public"."fn_user_role_in"("p_org" "uuid") TO "service_role"; GRANT ALL ON FUNCTION "public"."fn_user_role_in_org"("p_org" "uuid") TO "authenticated"; GRANT ALL ON FUNCTION "public"."fn_user_role_in_org"("p_org" "uuid") TO "service_role"; GRANT ALL ON FUNCTION "public"."fn_validate_activity_lead_org"() TO "anon"; GRANT ALL ON FUNCTION "public"."fn_validate_activity_lead_org"() TO "authenticated"; GRANT ALL ON FUNCTION "public"."fn_validate_activity_lead_org"() TO "service_role"; GRANT ALL ON FUNCTION "public"."fn_validate_lost_reason_required"() TO "anon"; GRANT ALL ON FUNCTION "public"."fn_validate_lost_reason_required"() TO "authenticated"; GRANT ALL ON FUNCTION "public"."fn_validate_lost_reason_required"() TO "service_role"; GRANT ALL ON FUNCTION "public"."midpoint"("p_prev" numeric, "p_next" numeric) TO "anon"; GRANT ALL ON FUNCTION "public"."midpoint"("p_prev" numeric, "p_next" numeric) TO "authenticated"; GRANT ALL ON FUNCTION "public"."midpoint"("p_prev" numeric, "p_next" numeric) TO "service_role"; REVOKE ALL ON FUNCTION "public"."retrieve_top_k_chunks"("p_organization_id" "uuid", "p_kb_version_id" "uuid", "p_embedding" "public"."vector", "p_k" integer, "p_threshold" real) FROM PUBLIC; GRANT ALL ON FUNCTION "public"."retrieve_top_k_chunks"("p_organization_id" "uuid", "p_kb_version_id" "uuid", "p_embedding" "public"."vector", "p_k" integer, "p_threshold" real) TO "authenticated"; GRANT ALL ON FUNCTION "public"."retrieve_top_k_chunks"("p_organization_id" "uuid", "p_kb_version_id" "uuid", "p_embedding" "public"."vector", "p_k" integer, "p_threshold" real) TO "service_role"; GRANT ALL ON TABLE "public"."ai_agent_runs" TO "authenticated"; GRANT ALL ON TABLE "public"."ai_agent_runs" TO "service_role"; GRANT ALL ON TABLE "public"."ai_agent_versions" TO "authenticated"; GRANT ALL ON TABLE "public"."ai_agent_versions" TO "service_role"; GRANT ALL ON TABLE "public"."ai_agents" TO "anon"; GRANT ALL ON TABLE "public"."ai_agents" TO "authenticated"; GRANT ALL ON TABLE "public"."ai_agents" TO "service_role"; GRANT ALL ON TABLE "public"."ai_budgets" TO "anon"; GRANT ALL ON TABLE "public"."ai_budgets" TO "authenticated"; GRANT ALL ON TABLE "public"."ai_budgets" TO "service_role"; GRANT ALL ON TABLE "public"."ai_chunks" TO "anon"; GRANT ALL ON TABLE "public"."ai_chunks" TO "authenticated"; GRANT ALL ON TABLE "public"."ai_chunks" TO "service_role"; GRANT ALL ON TABLE "public"."ai_faq_items" TO "anon"; GRANT ALL ON TABLE "public"."ai_faq_items" TO "authenticated"; GRANT ALL ON TABLE "public"."ai_faq_items" TO "service_role"; GRANT ALL ON TABLE "public"."ai_invocations" TO "anon"; GRANT ALL ON TABLE "public"."ai_invocations" TO "authenticated"; GRANT ALL ON TABLE "public"."ai_invocations" TO "service_role"; GRANT ALL ON TABLE "public"."ai_knowledge_sources" TO "anon"; GRANT ALL ON TABLE "public"."ai_knowledge_sources" TO "authenticated"; GRANT ALL ON TABLE "public"."ai_knowledge_sources" TO "service_role"; GRANT ALL ON TABLE "public"."ai_knowledge_versions" TO "anon"; GRANT ALL ON TABLE "public"."ai_knowledge_versions" TO "authenticated"; GRANT ALL ON TABLE "public"."ai_knowledge_versions" TO "service_role"; GRANT ALL ON TABLE "public"."ai_models" TO "anon"; GRANT ALL ON TABLE "public"."ai_models" TO "authenticated"; GRANT ALL ON TABLE "public"."ai_models" TO "service_role"; GRANT ALL ON TABLE "public"."ai_pricing" TO "anon"; GRANT ALL ON TABLE "public"."ai_pricing" TO "authenticated"; GRANT ALL ON TABLE "public"."ai_pricing" TO "service_role"; GRANT ALL ON TABLE "public"."ai_provider_credentials" TO "authenticated"; GRANT ALL ON TABLE "public"."ai_provider_credentials" TO "service_role"; GRANT ALL ON TABLE "public"."ai_provider_credentials_safe" TO "authenticated"; GRANT ALL ON TABLE "public"."ai_provider_credentials_safe" TO "service_role"; GRANT SELECT,INSERT,REFERENCES,TRIGGER,TRUNCATE ON TABLE "public"."api_audit_log" TO "anon"; GRANT SELECT,INSERT,REFERENCES,TRIGGER,TRUNCATE ON TABLE "public"."api_audit_log" TO "authenticated"; GRANT SELECT,INSERT,REFERENCES,TRIGGER,TRUNCATE ON TABLE "public"."api_audit_log" TO "service_role"; GRANT ALL ON TABLE "public"."api_tokens" TO "anon"; GRANT ALL ON TABLE "public"."api_tokens" TO "authenticated"; GRANT ALL ON TABLE "public"."api_tokens" TO "service_role"; GRANT ALL ON TABLE "public"."channel_session_warmup" TO "anon"; GRANT ALL ON TABLE "public"."channel_session_warmup" TO "authenticated"; GRANT ALL ON TABLE "public"."channel_session_warmup" TO "service_role"; GRANT ALL ON TABLE "public"."channel_sessions" TO "anon"; GRANT ALL ON TABLE "public"."channel_sessions" TO "authenticated"; GRANT ALL ON TABLE "public"."channel_sessions" TO "service_role"; GRANT ALL ON TABLE "public"."contacts" TO "anon"; GRANT ALL ON TABLE "public"."contacts" TO "authenticated"; GRANT ALL ON TABLE "public"."contacts" TO "service_role"; GRANT ALL ON TABLE "public"."conversations" TO "anon"; GRANT ALL ON TABLE "public"."conversations" TO "authenticated"; GRANT ALL ON TABLE "public"."conversations" TO "service_role"; GRANT SELECT,INSERT,REFERENCES,TRIGGER,TRUNCATE ON TABLE "public"."crm_lead_activities" TO "anon"; GRANT SELECT,INSERT,REFERENCES,TRIGGER,TRUNCATE ON TABLE "public"."crm_lead_activities" TO "authenticated"; GRANT ALL ON TABLE "public"."crm_lead_activities" TO "service_role"; GRANT ALL ON TABLE "public"."crm_lead_links" TO "anon"; GRANT ALL ON TABLE "public"."crm_lead_links" TO "authenticated"; GRANT ALL ON TABLE "public"."crm_lead_links" TO "service_role"; GRANT ALL ON TABLE "public"."crm_leads" TO "anon"; GRANT ALL ON TABLE "public"."crm_leads" TO "authenticated"; GRANT ALL ON TABLE "public"."crm_leads" TO "service_role"; GRANT ALL ON TABLE "public"."crm_pipelines" TO "anon"; GRANT ALL ON TABLE "public"."crm_pipelines" TO "authenticated"; GRANT ALL ON TABLE "public"."crm_pipelines" TO "service_role"; GRANT ALL ON TABLE "public"."crm_stages" TO "anon"; GRANT ALL ON TABLE "public"."crm_stages" TO "authenticated"; GRANT ALL ON TABLE "public"."crm_stages" TO "service_role"; GRANT SELECT,REFERENCES,TRIGGER,TRUNCATE ON TABLE "public"."event_log" TO "anon"; GRANT SELECT,REFERENCES,TRIGGER,TRUNCATE ON TABLE "public"."event_log" TO "authenticated"; GRANT ALL ON TABLE "public"."event_log" TO "service_role"; GRANT ALL ON TABLE "public"."idempotency_keys" TO "anon"; GRANT ALL ON TABLE "public"."idempotency_keys" TO "authenticated"; GRANT ALL ON TABLE "public"."idempotency_keys" TO "service_role"; GRANT ALL ON TABLE "public"."incidents" TO "anon"; GRANT ALL ON TABLE "public"."incidents" TO "authenticated"; GRANT ALL ON TABLE "public"."incidents" TO "service_role"; GRANT ALL ON TABLE "public"."lgpd_requests" TO "anon"; GRANT ALL ON TABLE "public"."lgpd_requests" TO "authenticated"; GRANT ALL ON TABLE "public"."lgpd_requests" TO "service_role"; GRANT ALL ON TABLE "public"."merge_queue" TO "anon"; GRANT ALL ON TABLE "public"."merge_queue" TO "authenticated"; GRANT ALL ON TABLE "public"."merge_queue" TO "service_role"; GRANT ALL ON TABLE "public"."messages" TO "anon"; GRANT ALL ON TABLE "public"."messages" TO "authenticated"; GRANT ALL ON TABLE "public"."messages" TO "service_role"; GRANT ALL ON TABLE "public"."nuvemshop_products" TO "anon"; GRANT ALL ON TABLE "public"."nuvemshop_products" TO "authenticated"; GRANT ALL ON TABLE "public"."nuvemshop_products" TO "service_role"; GRANT ALL ON TABLE "public"."orders" TO "anon"; GRANT ALL ON TABLE "public"."orders" TO "authenticated"; GRANT ALL ON TABLE "public"."orders" TO "service_role"; GRANT ALL ON TABLE "public"."organizations" TO "anon"; GRANT ALL ON TABLE "public"."organizations" TO "authenticated"; GRANT ALL ON TABLE "public"."organizations" TO "service_role"; GRANT ALL ON TABLE "public"."platform_admins" TO "anon"; GRANT ALL ON TABLE "public"."platform_admins" TO "authenticated"; GRANT ALL ON TABLE "public"."platform_admins" TO "service_role"; GRANT ALL ON TABLE "public"."storage_redaction_queue" TO "authenticated"; GRANT ALL ON TABLE "public"."storage_redaction_queue" TO "service_role"; GRANT ALL ON TABLE "public"."tenant_integrations" TO "anon"; GRANT ALL ON TABLE "public"."tenant_integrations" TO "authenticated"; GRANT ALL ON TABLE "public"."tenant_integrations" TO "service_role"; GRANT ALL ON TABLE "public"."user_organizations" TO "anon"; GRANT ALL ON TABLE "public"."user_organizations" TO "authenticated"; GRANT ALL ON TABLE "public"."user_organizations" TO "service_role"; GRANT ALL ON TABLE "public"."user_recovery_codes" TO "anon"; GRANT ALL ON TABLE "public"."user_recovery_codes" TO "authenticated"; GRANT ALL ON TABLE "public"."user_recovery_codes" TO "service_role"; GRANT SELECT,REFERENCES,TRIGGER,TRUNCATE ON TABLE "public"."webhook_events_log" TO "anon"; GRANT SELECT,REFERENCES,TRIGGER,TRUNCATE ON TABLE "public"."webhook_events_log" TO "authenticated"; GRANT ALL ON TABLE "public"."webhook_events_log" TO "service_role"; ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON SEQUENCES TO "postgres"; ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON SEQUENCES TO "anon"; ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON SEQUENCES TO "authenticated"; ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON SEQUENCES TO "service_role"; ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON FUNCTIONS TO "postgres"; ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON FUNCTIONS TO "anon"; ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON FUNCTIONS TO "authenticated"; ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON FUNCTIONS TO "service_role"; ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON TABLES TO "postgres"; ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON TABLES TO "anon"; ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON TABLES TO "authenticated"; ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON TABLES TO "service_role"; -- ============================================================================ -- COMPLEMENTO DO BASELINE (não capturado pelo dump --schema public): -- storage buckets + policies (migrations 0014/0017) e realtime publication. -- Aplicar DEPOIS do schema public (dependem de public.user_organizations). -- ============================================================================ -- ---- storage: bucket ai-policy + policies (migration 0014) ---- insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types) values ( 'ai-policy', 'ai-policy', false, 20971520, array['application/pdf', 'text/markdown', 'text/x-markdown', 'text/plain'] ) on conflict (id) do nothing; drop policy if exists "tenant_read_ai_policy" on storage.objects; create policy "tenant_read_ai_policy" on storage.objects for select using ( bucket_id = 'ai-policy' and exists ( select 1 from public.user_organizations uo where uo.user_id = auth.uid() and uo.revoked_at is null and uo.organization_id = (split_part(name, '/', 1))::uuid ) ); drop policy if exists "tenant_write_ai_policy" on storage.objects; create policy "tenant_write_ai_policy" on storage.objects for insert with check ( bucket_id = 'ai-policy' and exists ( select 1 from public.user_organizations uo where uo.user_id = auth.uid() and uo.revoked_at is null and uo.organization_id = (split_part(name, '/', 1))::uuid ) ); drop policy if exists "tenant_delete_ai_policy" on storage.objects; create policy "tenant_delete_ai_policy" on storage.objects for delete using ( bucket_id = 'ai-policy' and exists ( select 1 from public.user_organizations uo where uo.user_id = auth.uid() and uo.revoked_at is null and uo.organization_id = (split_part(name, '/', 1))::uuid ) ); -- ---- storage: bucket lgpd-exports + policy (migration 0017) ---- insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types) values ( 'lgpd-exports', 'lgpd-exports', false, 52428800, array['application/pdf', 'application/json'] ) on conflict (id) do nothing; drop policy if exists "tenant_read_lgpd_exports" on storage.objects; create policy "tenant_read_lgpd_exports" on storage.objects for select using ( bucket_id = 'lgpd-exports' and exists ( select 1 from public.user_organizations uo where uo.user_id = auth.uid() and uo.revoked_at is null and uo.organization_id = (split_part(name, '/', 1))::uuid ) ); -- ---- bucket de assets de skills (migration 0068) ---- insert into storage.buckets (id, name, public, file_size_limit) values ('skill-assets', 'skill-assets', false, 5242880) on conflict (id) do nothing; -- Leitura por org (path {org_id}/...) OU plataforma (path platform/...) por qualquer -- usuário autenticado (assets de plataforma são públicos p/ tenants; conteúdo é curado). drop policy if exists "skill_assets_read" on storage.objects; create policy "skill_assets_read" on storage.objects for select to authenticated using ( bucket_id = 'skill-assets' and ( split_part(name, '/', 1) = 'platform' or exists ( select 1 from public.user_organizations uo where uo.user_id = auth.uid() and uo.revoked_at is null and uo.organization_id = (split_part(name, '/', 1))::uuid ) ) ); -- Escrita/DELETE de assets é sempre via service role (rota de import) — sem policy de write. -- ---- realtime: inbox (messages/conversations), kanban (crm_leads) e IA ---- do $$ begin if not exists (select 1 from pg_publication where pubname='supabase_realtime') then create publication supabase_realtime; end if; end $$; do $$ declare t text; begin -- crm_lead_activities (migration 0071): o dossiê assina a timeline filtrada -- por lead_id (§3.5). O board não assina esta tabela — ele escuta crm_leads -- por pipeline_id, e toda atividade toca o lead via fn_update_last_activity_at. foreach t in array array['messages','conversations','crm_leads','ai_agents','ai_agent_runs','ai_knowledge_sources','crm_lead_activities'] loop if not exists ( select 1 from pg_publication_tables where pubname='supabase_realtime' and schemaname='public' and tablename=t ) then execute format('alter publication supabase_realtime add table public.%I', t); end if; end loop; end $$; -- ---- ai_models: catálogo curado global (migration 0023, §Seed Spec 10 §2.2) ---- -- Também não capturado pelo dump --schema-only. Sem isto, /api/v1/ai/providers/:p/models -- devolve lista vazia pra todo provedor e o seletor de modelo do agente fica sem opções. insert into public.ai_models (provider, model_id, display_name, description, context_window, input_price_per_million_cents, output_price_per_million_cents, supports_tools, is_default_for_provider) values ('anthropic', 'claude-opus-4-7', 'Claude Opus 4.7', 'Flagship Anthropic — raciocínio complexo', 200000, 1500, 7500, true, false), ('anthropic', 'claude-sonnet-4-6', 'Claude Sonnet 4.6', 'Default recomendado — equilíbrio custo/qualidade', 200000, 300, 1500, true, true), ('anthropic', 'claude-haiku-4-5', 'Claude Haiku 4.5', 'Cheap/fast — atendimentos curtos e classificação', 200000, 100, 500, true, false), ('openai', 'gpt-5', 'GPT-5', 'Flagship OpenAI', 400000, 500, 4000, true, false), ('openai', 'gpt-5-mini', 'GPT-5 Mini', 'Cheap/fast OpenAI', 400000, 150, 600, true, true), ('openai', 'gpt-4o', 'GPT-4o (legacy)', 'Compat — uso legado', 128000, 250, 1000, true, false), ('google', 'gemini-2.5-pro', 'Gemini 2.5 Pro', 'Flagship Google', 1000000, 125, 500, true, false), ('google', 'gemini-2.5-flash', 'Gemini 2.5 Flash', 'Cheap/fast Google', 1000000, 30, 120, true, true) on conflict (provider, model_id) do nothing; -- ---- WhatsApp: unificação de conversas por contato (migration 0027) ---- -- O dump --schema-only não traz mudanças pós-snapshot. Sem este bloco, clones -- (install.sh) e clones atualizando (update.sh, que re-aplica baseline.sql) -- ficam com o bug: 1 pessoa vira N contatos/conversas (WAHA emite -- message+message.any por mensagem; contatos @lid sem unique + check-then-act). -- Idempotente e AUTO-CURATIVO: em banco novo o dedup é no-op; em clone já bugado -- ele deduplica o histórico ANTES de criar as constraints. Ver a migration -- 20260706210000_0027_whatsapp_conversation_unification.sql para o detalhe. -- A. Identidade canônica (generated) alter table public.contacts add column if not exists wa_identity text generated always as ( case when phone_number is not null then 'phone:' || phone_number when source_metadata->>'waha_lid' is not null then 'lid:' || regexp_replace(source_metadata->>'waha_lid', '@.*$', '') else null end ) stored; -- B1. Merge de contatos duplicados (usa is_merged_into como mapa; sem temp tables) with ranked as ( select id, first_value(id) over (partition by organization_id, wa_identity order by created_at asc, id asc) as canonical_id from public.contacts where wa_identity is not null and is_merged_into is null ) update public.contacts c set is_merged_into = r.canonical_id, merged_at = now() from ranked r where c.id = r.id and r.id <> r.canonical_id; update public.conversations t set contact_id = c.is_merged_into from public.contacts c where t.contact_id = c.id and c.is_merged_into is not null; update public.messages t set contact_id = c.is_merged_into from public.contacts c where t.contact_id = c.id and c.is_merged_into is not null; update public.ai_agent_runs t set contact_id = c.is_merged_into from public.contacts c where t.contact_id = c.id and c.is_merged_into is not null; update public.crm_lead_activities t set contact_id = c.is_merged_into from public.contacts c where t.contact_id = c.id and c.is_merged_into is not null; update public.crm_leads t set contact_id = c.is_merged_into from public.contacts c where t.contact_id = c.id and c.is_merged_into is not null; update public.lgpd_requests t set contact_id = c.is_merged_into from public.contacts c where t.contact_id = c.id and c.is_merged_into is not null; update public.orders t set contact_id = c.is_merged_into from public.contacts c where t.contact_id = c.id and c.is_merged_into is not null; update public.contacts can set display_name = better.name from ( select coalesce(c.is_merged_into, c.id) as canonical_id, (array_agg(c.display_name order by (c.display_name ~ '^Contato ') asc, c.created_at asc) filter (where c.display_name is not null and c.display_name <> ''))[1] as name from public.contacts c where coalesce(c.is_merged_into, c.id) in (select is_merged_into from public.contacts where is_merged_into is not null) group by 1 ) better where can.id = better.canonical_id and better.name is not null and (can.display_name is null or can.display_name = '' or can.display_name ~ '^Contato '); -- B2. Merge de conversas 1:1 duplicadas update public.messages t set conversation_id = canon.canonical_id from (select id, first_value(id) over (partition by organization_id, contact_id, channel_session_id order by created_at asc, id asc) as canonical_id from public.conversations where is_group = false) canon where t.conversation_id = canon.id and canon.id <> canon.canonical_id; update public.ai_agent_runs t set conversation_id = canon.canonical_id from (select id, first_value(id) over (partition by organization_id, contact_id, channel_session_id order by created_at asc, id asc) as canonical_id from public.conversations where is_group = false) canon where t.conversation_id = canon.id and canon.id <> canon.canonical_id; update public.ai_invocations t set conversation_id = canon.canonical_id from (select id, first_value(id) over (partition by organization_id, contact_id, channel_session_id order by created_at asc, id asc) as canonical_id from public.conversations where is_group = false) canon where t.conversation_id = canon.id and canon.id <> canon.canonical_id; delete from public.conversations d using (select id, first_value(id) over (partition by organization_id, contact_id, channel_session_id order by created_at asc, id asc) as canonical_id from public.conversations where is_group = false) canon where d.id = canon.id and canon.id <> canon.canonical_id; -- C. Constraints anti-reduplicação create unique index if not exists uniq_contacts_org_wa_identity on public.contacts (organization_id, wa_identity) where wa_identity is not null and is_merged_into is null; create unique index if not exists uniq_conversations_1to1_per_contact_session on public.conversations (organization_id, contact_id, channel_session_id) where is_group = false; -- D. Upsert atômico (a aplicação usa via lib/waha/ingest.ts) create or replace function public.fn_upsert_wa_contact( p_org uuid, p_kind text, p_phone text, p_lid text, p_chat_id text, p_notify text ) returns uuid language plpgsql security definer set search_path = public as $$ declare v_id uuid; begin insert into public.contacts (organization_id, phone_number, source, consent, tags, source_metadata, display_name) values (p_org, case when p_kind = 'phone' then p_phone end, 'whatsapp', '{}'::jsonb, '{}'::text[], case when p_kind = 'lid' then jsonb_build_object('waha_lid', p_lid, 'notify_name', nullif(p_notify, '')) else jsonb_build_object('waha_chat_id', p_chat_id, 'notify_name', nullif(p_notify, '')) end, nullif(p_notify, '')) on conflict (organization_id, wa_identity) where wa_identity is not null and is_merged_into is null do update set display_name = coalesce(contacts.display_name, excluded.display_name), updated_at = now() returning id into v_id; return v_id; end; $$; create or replace function public.fn_upsert_wa_conversation( p_org uuid, p_contact uuid, p_session uuid ) returns uuid language plpgsql security definer set search_path = public as $$ declare v_id uuid; begin insert into public.conversations (organization_id, contact_id, channel_session_id, channel, status, is_group, unread_count_for_assignee, metadata) values (p_org, p_contact, p_session, 'whatsapp', 'open', false, 0, '{}'::jsonb) on conflict (organization_id, contact_id, channel_session_id) where is_group = false do update set updated_at = now() returning id into v_id; return v_id; end; $$; create or replace function public.fn_mark_conversation_message( p_conv uuid, p_direction text, p_preview text, p_at timestamptz ) returns void language plpgsql security definer set search_path = public as $$ begin update public.conversations set last_message_at = p_at, last_message_preview = p_preview, last_inbound_at = case when p_direction = 'inbound' then p_at else last_inbound_at end, last_outbound_at = case when p_direction = 'outbound' then p_at else last_outbound_at end, unread_count_for_assignee = case when p_direction = 'inbound' then unread_count_for_assignee + 1 when p_direction = 'outbound' then 0 else unread_count_for_assignee end, updated_at = now() where id = p_conv; end; $$; revoke all on function public.fn_upsert_wa_contact(uuid, text, text, text, text, text) from public; revoke all on function public.fn_upsert_wa_conversation(uuid, uuid, uuid) from public; revoke all on function public.fn_mark_conversation_message(uuid, text, text, timestamptz) from public; grant execute on function public.fn_upsert_wa_contact(uuid, text, text, text, text, text) to service_role; grant execute on function public.fn_upsert_wa_conversation(uuid, uuid, uuid) to service_role; grant execute on function public.fn_mark_conversation_message(uuid, text, text, timestamptz) to service_role; -- ---- RLS por role em tabelas de config + viewer read-only (migration 0030) ---- -- G2-03: spec 13 §4 — pipelines/stages (config) write manager+; conversations -- write agent+ (viewer read-only). SELECT permanece org-flat (escopo own é G4). -- Idempotente: drop if exists + create (auto-curativo no update.sh de clones). -- Em conversations este bloco só DERRUBA a policy ampla: o SELECT e a escrita -- que valem hoje nascem na 0035. Recriar aqui a versão intermediária (SELECT -- org-flat, escrita agent+ FOR ALL) fazia cada update.sh reabri-la até a 0035. drop policy if exists "tenant_isolation_crm_pipelines_all" on public.crm_pipelines; drop policy if exists "crm_pipelines_select" on public.crm_pipelines; drop policy if exists "crm_pipelines_manager_write" on public.crm_pipelines; create policy "crm_pipelines_select" on public.crm_pipelines for select using ( (organization_id in (select public.fn_user_org_ids())) or public.fn_is_platform_admin() ); create policy "crm_pipelines_manager_write" on public.crm_pipelines using ( public.fn_is_platform_admin() or ((organization_id in (select public.fn_user_org_ids())) and public.fn_role_at_least(organization_id, 'manager')) ) with check ( public.fn_is_platform_admin() or ((organization_id in (select public.fn_user_org_ids())) and public.fn_role_at_least(organization_id, 'manager')) ); drop policy if exists "tenant_isolation_crm_stages_all" on public.crm_stages; drop policy if exists "crm_stages_select" on public.crm_stages; drop policy if exists "crm_stages_manager_write" on public.crm_stages; create policy "crm_stages_select" on public.crm_stages for select using ( (organization_id in (select public.fn_user_org_ids())) or public.fn_is_platform_admin() ); create policy "crm_stages_manager_write" on public.crm_stages using ( public.fn_is_platform_admin() or ((organization_id in (select public.fn_user_org_ids())) and public.fn_role_at_least(organization_id, 'manager')) ) with check ( public.fn_is_platform_admin() or ((organization_id in (select public.fn_user_org_ids())) and public.fn_role_at_least(organization_id, 'manager')) ); drop policy if exists "conversations_tenant_isolation_all" on public.conversations; drop policy if exists "conversations_agent_write" on public.conversations; -- ---- Auditoria de atribuição de conversas + fn_conversation_assign (migration 0031) ---- -- G3-01 (gov-loop): toda mudança de dono de conversa vira evento estruturado -- (spec 13 §3.1) e as rotas de claim/transfer/release passam a mudar o dono via -- fn_conversation_assign — UPDATE condicional + INSERT do evento na MESMA -- transação (spec 04 §9; 0 rows = optimistic lock perdeu → 409). Idempotente: -- em clone atualizado é no-op; sem dados a corrigir. create table if not exists public.conversation_assignment_events ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, conversation_id uuid not null references public.conversations(id) on delete cascade, from_user_id uuid references auth.users(id) on delete set null, to_user_id uuid references auth.users(id) on delete set null, changed_by uuid references auth.users(id) on delete set null, reason text not null check (reason in ('claim','transfer','release','routing','handoff')), created_at timestamptz not null default now() ); create index if not exists idx_cae_conversation on public.conversation_assignment_events (conversation_id, created_at desc); alter table public.conversation_assignment_events enable row level security; -- cae_select nasce na 0173, com o escopo da conversa. A versão org-flat que -- vivia aqui era reinstalada a cada update.sh e valia até aquele bloco. -- `cae_insert` não nasce mais aqui, e nem é derrubada aqui: desde a 0279 a -- tabela é server-only, e o INSERT legítimo é feito por dentro de -- `fn_conversation_assign`, que é `security definer`. Quem a derruba — no clone -- que já a tem — é o bloco da 0279, lá embaixo, junto do revoke. Criar aqui para -- derrubar lá faria a regra antiga valer no meio de cada instalação -- (tests/unit/baseline-nao-constroi-o-que-derruba.test.ts); derrubar aqui deixaria -- a tabela sem policy nenhuma por 20 mil linhas de DDL, que num `update.sh` sobre -- banco vivo é uma janela de leitura vazia na tela. revoke all on public.conversation_assignment_events from anon; create or replace function public.fn_conversation_assign( p_organization_id uuid, p_conversation_id uuid, p_to_user_id uuid, p_reason text, p_expected_assignee uuid default null, p_enforce_expected boolean default false ) returns setof public.conversations language plpgsql set search_path = public as $$ declare v_from uuid; v_conv public.conversations%rowtype; begin select assigned_to_user_id into v_from from public.conversations where id = p_conversation_id and organization_id = p_organization_id for update; if not found then return; end if; if p_enforce_expected and v_from is distinct from p_expected_assignee then return; end if; update public.conversations set assigned_to_user_id = p_to_user_id, assigned_at = case when p_to_user_id is null then null else now() end, status = case when p_to_user_id is null then 'open' else 'claimed' end, status_changed_at = now(), unread_count_for_assignee = 0, updated_at = now() where id = p_conversation_id returning * into v_conv; insert into public.conversation_assignment_events (organization_id, conversation_id, from_user_id, to_user_id, changed_by, reason) values (p_organization_id, p_conversation_id, v_from, p_to_user_id, auth.uid(), p_reason); return next v_conv; end; $$; revoke all on function public.fn_conversation_assign(uuid, uuid, uuid, text, uuid, boolean) from public; grant execute on function public.fn_conversation_assign(uuid, uuid, uuid, text, uuid, boolean) to authenticated, service_role; -- ---- assignee_kind + guard de membership na fn_conversation_assign (migration 0032) ---- -- G3-02 (gov-loop): IA como assignee de 1ª classe (spec 13 §3.2). Coluna -- conversations.assignee_kind ('user'|'ai') + CHECK de coerência em forma de -- implicação (kind='user' ⇒ dono humano; kind='ai' ⇒ sem dono; kind null livre -- pra escritas legadas). Backfill ANTES da constraint (auto-curativo em clones). -- Forward-fix INB-06a: fn_conversation_assign valida DENTRO da função que o -- destino é membro ativo agent+ da org (via fn_member_role_in_org, SECURITY -- DEFINER) e mantém assignee_kind coerente em claim/transfer/release/handoff. -- fn_member_role_in_org é executável APENAS por authenticated (responde só a -- membro ativo da org) e service_role (auth.uid() null); anon tem EXECUTE -- revogado EXPLICITAMENTE — o default privilege do Supabase concede EXECUTE a -- anon em toda função nova e o JWT anon também tem uid null. alter table public.conversations add column if not exists assignee_kind text check (assignee_kind in ('user','ai')); update public.conversations set assignee_kind = 'user' where assigned_to_user_id is not null and assignee_kind is distinct from 'user'; update public.conversations set assignee_kind = null where assigned_to_user_id is null and assignee_kind = 'user'; update public.conversations set assignee_kind = 'ai' where status = 'ai_handling' and assigned_to_user_id is null and assignee_kind is distinct from 'ai'; alter table public.conversations drop constraint if exists conversations_assignee_kind_coherence; alter table public.conversations add constraint conversations_assignee_kind_coherence check ( (assignee_kind = 'user' and assigned_to_user_id is not null) or (assignee_kind = 'ai' and assigned_to_user_id is null) or (assignee_kind is null) ); create or replace function public.fn_member_role_in_org(p_user uuid, p_org uuid) returns text language sql stable security definer set search_path = public as $$ select uo.role from public.user_organizations uo where uo.user_id = p_user and uo.organization_id = p_org and uo.revoked_at is null and ( auth.uid() is null or exists ( select 1 from public.user_organizations me where me.user_id = auth.uid() and me.organization_id = p_org and me.revoked_at is null ) ) limit 1; $$; revoke all on function public.fn_member_role_in_org(uuid, uuid) from public; -- O revoke from public NÃO cobre o grant DIRETO que anon carrega via -- ALTER DEFAULT PRIVILEGES ... GRANT ALL ON FUNCTIONS TO anon (padrão -- Supabase). Sem esta linha, o PostgREST expõe a função como RPC pública -- (anon key vai pro browser) e o ramo auth.uid() null responde a request -- anônimo — enumeração de membership/role de qualquer tenant. revoke execute on function public.fn_member_role_in_org(uuid, uuid) from anon; grant execute on function public.fn_member_role_in_org(uuid, uuid) to authenticated, service_role; create or replace function public.fn_conversation_assign( p_organization_id uuid, p_conversation_id uuid, p_to_user_id uuid, p_reason text, p_expected_assignee uuid default null, p_enforce_expected boolean default false ) returns setof public.conversations language plpgsql set search_path = public as $$ declare v_from uuid; v_conv public.conversations%rowtype; begin if p_to_user_id is not null then if coalesce(public.fn_member_role_in_org(p_to_user_id, p_organization_id), 'none') not in ('agent','manager','admin') then raise exception 'assignee_not_eligible_member' using hint = 'target must be an active agent+ member of the organization'; end if; end if; select assigned_to_user_id into v_from from public.conversations where id = p_conversation_id and organization_id = p_organization_id for update; if not found then return; end if; if p_enforce_expected and v_from is distinct from p_expected_assignee then return; end if; update public.conversations set assigned_to_user_id = p_to_user_id, assigned_at = case when p_to_user_id is null then null else now() end, assignee_kind = case when p_to_user_id is null then null else 'user' end, status = case when p_to_user_id is null then 'open' else 'claimed' end, status_changed_at = now(), unread_count_for_assignee = 0, updated_at = now() where id = p_conversation_id returning * into v_conv; insert into public.conversation_assignment_events (organization_id, conversation_id, from_user_id, to_user_id, changed_by, reason) values (p_organization_id, p_conversation_id, v_from, p_to_user_id, auth.uid(), p_reason); return next v_conv; end; $$; revoke all on function public.fn_conversation_assign(uuid, uuid, uuid, text, uuid, boolean) from public; grant execute on function public.fn_conversation_assign(uuid, uuid, uuid, text, uuid, boolean) to authenticated, service_role; -- ---- conversation tags (migration 0033) ---- -- G3-05 (gov-loop): eixo 7 — tags de conversa (spec 13 §3.3). Mesmo shape de -- contacts.tags/crm_leads.tags (text[] + GIN). Vocabulário canônico em -- organizations.settings.canonical_conversation_tags (org-scoped), semeado só -- onde ausente. Idempotente/auto-curativo. alter table public.conversations add column if not exists tags text[] not null default '{}'; create index if not exists idx_conversations_tags_gin on public.conversations using gin (tags); update public.organizations set settings = coalesce(settings, '{}'::jsonb) || jsonb_build_object( 'canonical_conversation_tags', jsonb_build_array( 'dúvida', 'reclamação', 'troca', 'devolução', 'elogio', 'orçamento', 'pós-venda', 'urgente' ) ) where not (coalesce(settings, '{}'::jsonb) ? 'canonical_conversation_tags'); -- ---- revoke anon EXECUTE em SECURITY DEFINER de escrita (migration 0034) ---- -- G4-00 (gov-loop): defesa em profundidade (INB-07). Duas origens de EXECUTE a -- anon: (A) grant DIRETO do ALTER DEFAULT PRIVILEGES ... TO anon acima (funções -- criadas depois dele, já sem grant a PUBLIC) → revoke anon; (B) grant via -- PUBLIC (funções criadas ANTES do ALTER e nunca revogadas de public) → revoke -- public + re-afirma authenticated/service_role (call sites legítimos). Nenhum -- fluxo anônimo depende delas. Idempotente/auto-curativo. revoke execute on function public.fn_upsert_wa_contact(uuid, text, text, text, text, text) from anon; revoke execute on function public.fn_upsert_wa_conversation(uuid, uuid, uuid) from anon; revoke execute on function public.fn_mark_conversation_message(uuid, text, text, timestamptz) from anon; revoke execute on function public.emit_event(text, text, uuid, jsonb, jsonb, uuid) from public; revoke execute on function public.emit_event(text, text, uuid, jsonb, jsonb, uuid) from anon; grant execute on function public.emit_event(text, text, uuid, jsonb, jsonb, uuid) to authenticated, service_role; revoke execute on function public.fn_log_event(uuid, text, jsonb) from public; revoke execute on function public.fn_log_event(uuid, text, jsonb) from anon; grant execute on function public.fn_log_event(uuid, text, jsonb) to authenticated, service_role; revoke execute on function public.fn_audit_log_row() from public; revoke execute on function public.fn_audit_log_row() from anon; grant execute on function public.fn_audit_log_row() to service_role; -- ---- visibility_mode: RLS de conversas/mensagens por atendente (migration 0035) ---- -- G4-01 (gov-loop): eixo 5 (spec 13 §3.5 + §4). organizations.settings.visibility_mode -- ('all'|'own_and_unassigned'|'own', default 'own_and_unassigned' — G1-06a) restringe o -- SELECT de conversations/messages APENAS para o role agent; viewer/manager/admin seguem -- org-wide read. fn_can_view_conversation recebe os campos da ROW (evita lookup/recursão -- por-row); DEFINER + search_path blindado + revoke anon/public (lição G4-00). A escrita -- 0030 era FOR ALL, cujo USING também governa SELECT (policies OR-adas) — por isso é -- re-expressa por-comando (mesmo agent+/org; quem escreve não muda), removendo só o grant -- implícito de SELECT. messages SELECT herda o escopo da conversa via exists(). Idempotente, -- auto-curativo. Escrita não restringida; ingestão/outbound via service_role bypassa RLS. create or replace function public.fn_can_view_conversation( p_org uuid, p_assigned_to_user_id uuid ) returns boolean language sql stable security definer set search_path = public as $$ select case when public.fn_is_platform_admin() then true when public.fn_user_role_in_org(p_org) is null then false when public.fn_user_role_in_org(p_org) in ('viewer','manager','admin') then true when p_assigned_to_user_id = auth.uid() then true else case coalesce( (select settings->>'visibility_mode' from public.organizations where id = p_org), 'own_and_unassigned') when 'all' then true when 'own_and_unassigned' then p_assigned_to_user_id is null else false end end; $$; revoke all on function public.fn_can_view_conversation(uuid, uuid) from public; revoke execute on function public.fn_can_view_conversation(uuid, uuid) from anon; grant execute on function public.fn_can_view_conversation(uuid, uuid) to authenticated, service_role; drop policy if exists "conversations_select" on public.conversations; create policy "conversations_select" on public.conversations for select using ( public.fn_can_view_conversation(organization_id, assigned_to_user_id) ); drop policy if exists "conversations_agent_write" on public.conversations; drop policy if exists "conversations_agent_insert" on public.conversations; drop policy if exists "conversations_agent_update" on public.conversations; drop policy if exists "conversations_agent_delete" on public.conversations; create policy "conversations_agent_insert" on public.conversations for insert with check ( public.fn_is_platform_admin() or ((organization_id in (select public.fn_user_org_ids())) and public.fn_role_at_least(organization_id, 'agent')) ); create policy "conversations_agent_update" on public.conversations for update using ( public.fn_is_platform_admin() or ((organization_id in (select public.fn_user_org_ids())) and public.fn_role_at_least(organization_id, 'agent')) ) with check ( public.fn_is_platform_admin() or ((organization_id in (select public.fn_user_org_ids())) and public.fn_role_at_least(organization_id, 'agent')) ); create policy "conversations_agent_delete" on public.conversations for delete using ( public.fn_is_platform_admin() or ((organization_id in (select public.fn_user_org_ids())) and public.fn_role_at_least(organization_id, 'agent')) ); drop policy if exists "messages_tenant_isolation_all" on public.messages; drop policy if exists "messages_select" on public.messages; drop policy if exists "messages_insert" on public.messages; drop policy if exists "messages_update" on public.messages; drop policy if exists "messages_delete" on public.messages; create policy "messages_select" on public.messages for select using ( public.fn_is_platform_admin() or exists ( select 1 from public.conversations c where c.id = messages.conversation_id ) ); create policy "messages_insert" on public.messages for insert with check ( (organization_id in (select public.fn_user_org_ids())) or public.fn_is_platform_admin() ); create policy "messages_update" on public.messages for update using ( (organization_id in (select public.fn_user_org_ids())) or public.fn_is_platform_admin() ) with check ( (organization_id in (select public.fn_user_org_ids())) or public.fn_is_platform_admin() ); create policy "messages_delete" on public.messages for delete using ( (organization_id in (select public.fn_user_org_ids())) or public.fn_is_platform_admin() ); -- Forward-fix do G4-01: fn_conversation_assign (0031/0032) passa a SECURITY -- DEFINER. Com o SELECT de conversations visibility-aware, o `update ... returning -- *` re-aplica a policy de SELECT à NOVA linha — numa transferência o dono passa a -- ser outro atendente, invisível ao autor, e o RETURNING falharia. DEFINER bypassa -- a RLS na escrita interna; a autorização do caller (antes garantida pela RLS -- INVOKER) é re-afirmada dentro da função: agent+ ativo da MESMA org (service_role -- com auth.uid() null é dispensado). Corpo idêntico ao 0032 fora o guard. create or replace function public.fn_conversation_assign( p_organization_id uuid, p_conversation_id uuid, p_to_user_id uuid, p_reason text, p_expected_assignee uuid default null, p_enforce_expected boolean default false ) returns setof public.conversations language plpgsql security definer set search_path = public as $$ declare v_from uuid; v_conv public.conversations%rowtype; begin if auth.uid() is not null and not public.fn_role_at_least(p_organization_id, 'agent') then raise exception 'caller_not_authorized_for_org' using hint = 'caller must be an active agent+ member of the organization'; end if; if p_to_user_id is not null then if coalesce(public.fn_member_role_in_org(p_to_user_id, p_organization_id), 'none') not in ('agent','manager','admin') then raise exception 'assignee_not_eligible_member' using hint = 'target must be an active agent+ member of the organization'; end if; end if; select assigned_to_user_id into v_from from public.conversations where id = p_conversation_id and organization_id = p_organization_id for update; if not found then return; end if; if p_enforce_expected and v_from is distinct from p_expected_assignee then return; end if; update public.conversations set assigned_to_user_id = p_to_user_id, assigned_at = case when p_to_user_id is null then null else now() end, assignee_kind = case when p_to_user_id is null then null else 'user' end, status = case when p_to_user_id is null then 'open' else 'claimed' end, status_changed_at = now(), unread_count_for_assignee = 0, updated_at = now() where id = p_conversation_id returning * into v_conv; insert into public.conversation_assignment_events (organization_id, conversation_id, from_user_id, to_user_id, changed_by, reason) values (p_organization_id, p_conversation_id, v_from, p_to_user_id, auth.uid(), p_reason); return next v_conv; end; $$; revoke all on function public.fn_conversation_assign(uuid, uuid, uuid, text, uuid, boolean) from public; revoke execute on function public.fn_conversation_assign(uuid, uuid, uuid, text, uuid, boolean) from anon; grant execute on function public.fn_conversation_assign(uuid, uuid, uuid, text, uuid, boolean) to authenticated, service_role; -- ---- visibility_mode: RLS de crm_leads (kanban) por atendente (migration 0036) ---- -- G4-03 (gov-loop): eixo 5 (spec 13 §4 linha 220). Espelha a G4-01 (conversations, -- 0035) para crm_leads — "dono" do lead = owner_user_id (não assigned_to). REUSE do -- mesmo organizations.settings.visibility_mode ('all'|'own_and_unassigned'|'own', -- default 'own_and_unassigned' — G1-06a; a matriz diz "mesmo escopo"). Só o role -- agent é restrito; viewer/manager/admin org-wide read; platform_admin tudo. -- fn_can_view_lead recebe os campos da ROW (sem lookup/recursão); DEFINER + -- search_path blindado + revoke anon/public (lição G4-00). A FOR ALL org-flat -- `tenant_isolation_crm_leads_all` governava SELECT junto (USING OR-ado) — dropada e -- re-expressa por-comando: SELECT visibility-aware + escrita por-role (agent=own-scope -- via a mesma fn, manager+=org-wide, viewer=none via piso 'agent'). Drag-and-drop de -- lead próprio (UPDATE de stage/position sem mudar owner) passa; lead de outro agent -- bloqueado; bulk assign (G3-04, ≥manager) intacto. Idempotente, auto-curativo. create or replace function public.fn_can_view_lead( p_org uuid, p_owner_user_id uuid ) returns boolean language sql stable security definer set search_path = public as $$ select case when public.fn_is_platform_admin() then true when public.fn_user_role_in_org(p_org) is null then false when public.fn_user_role_in_org(p_org) in ('viewer','manager','admin') then true when p_owner_user_id = auth.uid() then true else case coalesce( (select settings->>'visibility_mode' from public.organizations where id = p_org), 'own_and_unassigned') when 'all' then true when 'own_and_unassigned' then p_owner_user_id is null else false end end; $$; revoke all on function public.fn_can_view_lead(uuid, uuid) from public; revoke execute on function public.fn_can_view_lead(uuid, uuid) from anon; grant execute on function public.fn_can_view_lead(uuid, uuid) to authenticated, service_role; drop policy if exists "tenant_isolation_crm_leads_all" on public.crm_leads; drop policy if exists "crm_leads_select" on public.crm_leads; drop policy if exists "crm_leads_insert" on public.crm_leads; drop policy if exists "crm_leads_update" on public.crm_leads; drop policy if exists "crm_leads_delete" on public.crm_leads; create policy "crm_leads_select" on public.crm_leads for select using ( public.fn_can_view_lead(organization_id, owner_user_id) ); create policy "crm_leads_insert" on public.crm_leads for insert with check ( public.fn_is_platform_admin() or ((organization_id in (select public.fn_user_org_ids())) and public.fn_role_at_least(organization_id, 'agent') and (public.fn_role_at_least(organization_id, 'manager') or public.fn_can_view_lead(organization_id, owner_user_id))) ); create policy "crm_leads_update" on public.crm_leads for update using ( public.fn_is_platform_admin() or ((organization_id in (select public.fn_user_org_ids())) and public.fn_role_at_least(organization_id, 'agent') and (public.fn_role_at_least(organization_id, 'manager') or public.fn_can_view_lead(organization_id, owner_user_id))) ) with check ( public.fn_is_platform_admin() or ((organization_id in (select public.fn_user_org_ids())) and public.fn_role_at_least(organization_id, 'agent') and (public.fn_role_at_least(organization_id, 'manager') or public.fn_can_view_lead(organization_id, owner_user_id))) ); create policy "crm_leads_delete" on public.crm_leads for delete using ( public.fn_is_platform_admin() or ((organization_id in (select public.fn_user_org_ids())) and public.fn_role_at_least(organization_id, 'agent') and (public.fn_role_at_least(organization_id, 'manager') or public.fn_can_view_lead(organization_id, owner_user_id))) ); -- ---- métricas por responsável: índices + fn_attendant_metrics (migration 0037) ---- -- spec 13 §6. Índices dedicados (won/lost por owner na janela de closed_at; -- conversas por assignee org-leading) + agregação SECURITY INVOKER (a RLS de -- crm_leads/conversations define o escopo por atendente). Idempotente. create index if not exists idx_crm_leads_org_status_closed_owner on public.crm_leads (organization_id, status, closed_at, owner_user_id) where closed_at is not null; create index if not exists idx_conversations_org_assignee_assigned on public.conversations (organization_id, assigned_to_user_id, assigned_at) where assigned_to_user_id is not null; create or replace function public.fn_attendant_metrics( p_org uuid, p_from timestamptz, p_to timestamptz, p_owner uuid default null ) returns jsonb language sql stable set search_path = public as $$ with lead_agg as ( select owner_user_id as user_id, count(*) filter (where status = 'won') as won, count(*) filter (where status = 'lost') as lost from public.crm_leads where organization_id = p_org and status in ('won', 'lost') and closed_at >= p_from and closed_at < p_to and owner_user_id is not null and (p_owner is null or owner_user_id = p_owner) group by owner_user_id ), conv_agg as ( select assigned_to_user_id as user_id, count(*) as conversations_handled from public.conversations where organization_id = p_org and assigned_to_user_id is not null and assigned_at >= p_from and assigned_at < p_to and (p_owner is null or assigned_to_user_id = p_owner) group by assigned_to_user_id ), ttfr as ( select c.assigned_to_user_id as user_id, avg(extract(epoch from (fr.first_human_out - fr.first_in))) as avg_first_response_seconds from public.conversations c cross join lateral ( select min(m.sent_at) filter (where m.direction = 'inbound') as first_in, min(m.sent_at) filter ( where m.direction = 'outbound' and m.sent_by_user_id is not null ) as first_human_out from public.messages m where m.conversation_id = c.id ) fr where c.organization_id = p_org and c.assigned_to_user_id is not null and (p_owner is null or c.assigned_to_user_id = p_owner) and fr.first_in is not null and fr.first_human_out is not null and fr.first_human_out > fr.first_in and fr.first_human_out >= p_from and fr.first_human_out < p_to group by c.assigned_to_user_id ), attendant_ids as ( select user_id from lead_agg union select user_id from conv_agg union select user_id from ttfr ) select jsonb_build_object( 'funnel', coalesce(( select jsonb_agg( jsonb_build_object( 'stage_id', s.id, 'stage_name', s.name, 'position', s.position, 'count', coalesce(l.cnt, 0) ) order by s.position, s.name ) from public.crm_stages s left join ( select stage_id, count(*) as cnt from public.crm_leads where organization_id = p_org and status = 'open' and (p_owner is null or owner_user_id = p_owner) group by stage_id ) l on l.stage_id = s.id where s.organization_id = p_org and s.is_archived = false ), '[]'::jsonb), 'attendants', coalesce(( select jsonb_agg( jsonb_build_object( 'user_id', a.user_id, 'won', coalesce(la.won, 0), 'lost', coalesce(la.lost, 0), 'conversations_handled', coalesce(ca.conversations_handled, 0), 'avg_first_response_seconds', tf.avg_first_response_seconds ) order by coalesce(la.won, 0) desc, a.user_id ) from attendant_ids a left join lead_agg la on la.user_id = a.user_id left join conv_agg ca on ca.user_id = a.user_id left join ttfr tf on tf.user_id = a.user_id ), '[]'::jsonb) ); $$; revoke all on function public.fn_attendant_metrics(uuid, timestamptz, timestamptz, uuid) from public; revoke execute on function public.fn_attendant_metrics(uuid, timestamptz, timestamptz, uuid) from anon; grant execute on function public.fn_attendant_metrics(uuid, timestamptz, timestamptz, uuid) to authenticated, service_role; -- ---- webhooks universais + motor de regras (migration 0038) ---- -- Spec: docs/superpowers/specs/2026-07-17-webhooks-design.md. Idempotente -- (create if not exists / drop policy if exists) — auto-curativo no update.sh. create table if not exists public.webhook_sources ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, name text not null, path_token text not null unique, secret text, kind text not null default 'lead_capture' check (kind in ('lead_capture')), default_pipeline_id uuid not null references public.crm_pipelines(id) on delete cascade, default_stage_id uuid not null references public.crm_stages(id) on delete cascade, field_map jsonb not null default '{}'::jsonb, redirect_to text, is_active boolean not null default true, last_received_at timestamptz, created_by_user_id uuid, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create table if not exists public.automation_rules ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, name text not null, trigger_event text not null check (trigger_event ~ '^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$'), conditions jsonb not null default '[]'::jsonb, actions jsonb not null default '[]'::jsonb, is_active boolean not null default false, last_run_at timestamptz, run_count integer not null default 0, created_by_user_id uuid, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create index if not exists idx_automation_rules_org_trigger on public.automation_rules (organization_id, trigger_event) where is_active; create table if not exists public.automation_rule_runs ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, rule_id uuid not null references public.automation_rules(id) on delete cascade, event_id uuid references public.event_log(id) on delete set null, status text not null check (status in ('success', 'partial', 'failed')), actions_result jsonb not null default '[]'::jsonb, error text, created_at timestamptz not null default now() ); create index if not exists idx_automation_rule_runs_org_created on public.automation_rule_runs (organization_id, created_at desc); create index if not exists idx_automation_rule_runs_rule on public.automation_rule_runs (rule_id, created_at desc); alter table public.webhook_sources enable row level security; alter table public.automation_rules enable row level security; alter table public.automation_rule_runs enable row level security; drop policy if exists "webhook_sources_select" on public.webhook_sources; drop policy if exists "webhook_sources_manager_write" on public.webhook_sources; create policy "webhook_sources_select" on public.webhook_sources for select using ( (organization_id in (select public.fn_user_org_ids())) or public.fn_is_platform_admin() ); create policy "webhook_sources_manager_write" on public.webhook_sources using ( public.fn_is_platform_admin() or ((organization_id in (select public.fn_user_org_ids())) and public.fn_role_at_least(organization_id, 'manager')) ) with check ( public.fn_is_platform_admin() or ((organization_id in (select public.fn_user_org_ids())) and public.fn_role_at_least(organization_id, 'manager')) ); drop policy if exists "automation_rules_select" on public.automation_rules; drop policy if exists "automation_rules_manager_write" on public.automation_rules; create policy "automation_rules_select" on public.automation_rules for select using ( (organization_id in (select public.fn_user_org_ids())) or public.fn_is_platform_admin() ); create policy "automation_rules_manager_write" on public.automation_rules using ( public.fn_is_platform_admin() or ((organization_id in (select public.fn_user_org_ids())) and public.fn_role_at_least(organization_id, 'manager')) ) with check ( public.fn_is_platform_admin() or ((organization_id in (select public.fn_user_org_ids())) and public.fn_role_at_least(organization_id, 'manager')) ); drop policy if exists "automation_rule_runs_select" on public.automation_rule_runs; create policy "automation_rule_runs_select" on public.automation_rule_runs for select using ( (organization_id in (select public.fn_user_org_ids())) or public.fn_is_platform_admin() ); -- ---- disponibilidade/horário por atendente: attendant_availability (migration 0039) ---- -- spec 13 §3.4/§5. Persiste o (spec 04 §8): is_available, -- capacity (>0), schedule jsonb tz-aware, last_heartbeat_at (AT-08 auto-offline -- 15min via worker TS). RLS por-comando (nunca FOR ALL): SELECT org-wide; -- INSERT/UPDATE/DELETE = própria linha OU manager+. Idempotente. create table if not exists public.attendant_availability ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, user_id uuid not null references auth.users(id) on delete cascade, is_available boolean not null default false, capacity integer not null default 5 check (capacity > 0), schedule jsonb not null default '{}', last_heartbeat_at timestamptz, updated_at timestamptz not null default now(), unique (organization_id, user_id) ); create index if not exists idx_attendant_availability_available on public.attendant_availability (organization_id) where is_available; alter table public.attendant_availability enable row level security; drop policy if exists "attendant_availability_select" on public.attendant_availability; create policy "attendant_availability_select" on public.attendant_availability for select using ( public.fn_is_platform_admin() or organization_id in (select public.fn_user_org_ids()) ); drop policy if exists "attendant_availability_insert" on public.attendant_availability; create policy "attendant_availability_insert" on public.attendant_availability for insert with check ( public.fn_is_platform_admin() or (organization_id in (select public.fn_user_org_ids()) and (user_id = auth.uid() or public.fn_role_at_least(organization_id, 'manager'))) ); drop policy if exists "attendant_availability_update" on public.attendant_availability; create policy "attendant_availability_update" on public.attendant_availability for update using ( public.fn_is_platform_admin() or (organization_id in (select public.fn_user_org_ids()) and (user_id = auth.uid() or public.fn_role_at_least(organization_id, 'manager'))) ) with check ( public.fn_is_platform_admin() or (organization_id in (select public.fn_user_org_ids()) and (user_id = auth.uid() or public.fn_role_at_least(organization_id, 'manager'))) ); drop policy if exists "attendant_availability_delete" on public.attendant_availability; create policy "attendant_availability_delete" on public.attendant_availability for delete using ( public.fn_is_platform_admin() or (organization_id in (select public.fn_user_org_ids()) and (user_id = auth.uid() or public.fn_role_at_least(organization_id, 'manager'))) ); -- ---- roteamento: disponibilidade/horário por atendente (migration 0039) ---- -- spec 13 §3.4/§5. attendant_availability (1 linha por org×user): toggle -- online/offline + capacity ajustável + schedule tz-aware + last_heartbeat_at -- (AT-08). RLS por-comando (nunca FOR ALL): SELECT org-wide; WRITE própria linha -- OU manager+. settings.routing (§3.5) fica no jsonb organizations.settings, -- validado por Zod (lib/schemas/routing.ts) — sem coluna nova. Idempotente. create table if not exists public.attendant_availability ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, user_id uuid not null references auth.users(id) on delete cascade, is_available boolean not null default false, capacity integer not null default 5 check (capacity > 0), schedule jsonb not null default '{}', last_heartbeat_at timestamptz, updated_at timestamptz not null default now(), unique (organization_id, user_id) ); create index if not exists idx_attendant_availability_available on public.attendant_availability (organization_id) where is_available; alter table public.attendant_availability enable row level security; drop policy if exists "attendant_availability_select" on public.attendant_availability; create policy "attendant_availability_select" on public.attendant_availability for select using ( public.fn_is_platform_admin() or organization_id in (select public.fn_user_org_ids()) ); drop policy if exists "attendant_availability_insert" on public.attendant_availability; create policy "attendant_availability_insert" on public.attendant_availability for insert with check ( public.fn_is_platform_admin() or (organization_id in (select public.fn_user_org_ids()) and (user_id = auth.uid() or public.fn_role_at_least(organization_id, 'manager'))) ); drop policy if exists "attendant_availability_update" on public.attendant_availability; create policy "attendant_availability_update" on public.attendant_availability for update using ( public.fn_is_platform_admin() or (organization_id in (select public.fn_user_org_ids()) and (user_id = auth.uid() or public.fn_role_at_least(organization_id, 'manager'))) ) with check ( public.fn_is_platform_admin() or (organization_id in (select public.fn_user_org_ids()) and (user_id = auth.uid() or public.fn_role_at_least(organization_id, 'manager'))) ); drop policy if exists "attendant_availability_delete" on public.attendant_availability; create policy "attendant_availability_delete" on public.attendant_availability for delete using ( public.fn_is_platform_admin() or (organization_id in (select public.fn_user_org_ids()) and (user_id = auth.uid() or public.fn_role_at_least(organization_id, 'manager'))) ); -- ---- roteamento: emissão de conversation.routing_requested (migration 0040) ---- -- AT-03: a ENTRADA de uma conversa na fila emite o evento; o worker (cron TS -- lib/routing/worker.ts) consome e distribui. Trigger NUNCA faz HTTP — só -- emit_event. ANTI-ECO: AFTER INSERT APENAS + WHEN sem-dono numa fila aberta; -- não há trigger de UPDATE, então o UPDATE de atribuição do worker NUNCA re-emite -- (sem isso ⇒ loop infinito). Idempotente (create or replace + drop if exists). create or replace function public.fn_emit_conversation_routing() returns trigger language plpgsql security definer set search_path = public as $$ begin perform public.emit_event( 'conversation.routing_requested', 'conversation', new.id, jsonb_build_object('conversation_id', new.id, 'organization_id', new.organization_id), '{}'::jsonb, new.organization_id ); return null; end; $$; alter function public.fn_emit_conversation_routing() owner to postgres; drop trigger if exists trg_conversation_routing_requested on public.conversations; create trigger trg_conversation_routing_requested after insert on public.conversations for each row when (new.assigned_to_user_id is null and new.status in ('open', 'pending')) execute function public.fn_emit_conversation_routing(); -- ---- cifragem at-rest dos secrets de webhooks (migration 0041) ---- -- Idempotente e auto-curativo (ver migrations/20260718150000_0041). Chave em -- private.app_secrets (GUC como override); sem chave, plaintext é descartado com WARNING. -- Forward-fix de raiz: fn_encrypt_oauth/fn_decrypt_oauth fixavam -- search_path='public', mas pgcrypto vive no schema `extensions` no Supabase -- (e faltava no baseline) — pgp_sym_* NUNCA resolvia. Garante a extensão e -- recria as funções com o search_path correto. create schema if not exists extensions; create extension if not exists pgcrypto with schema extensions; -- Fonte da chave: Supabase cloud NÃO permite ALTER DATABASE/ROLE SET de GUC -- custom (42501) — GUC-only nunca funcionaria lá. A chave vive em -- private.app_secrets (schema sem grants; só as SECURITY DEFINER leem); -- a GUC, quando setada (VPS/psql/testes), tem precedência como override. create schema if not exists private; create table if not exists private.app_secrets ( name text primary key, value text not null, updated_at timestamptz not null default now() ); revoke all on schema private from public; revoke all on all tables in schema private from public; create or replace function private.fn_oauth_key() returns text language sql security definer set search_path to 'private', 'pg_temp' as $$ select coalesce( nullif(current_setting('app.nuvemshop_oauth_key', true), ''), (select value from private.app_secrets where name = 'nuvemshop_oauth_key') ); $$; revoke all on function private.fn_oauth_key() from public; create or replace function public.fn_encrypt_oauth(plaintext text) returns bytea language plpgsql security definer set search_path to 'public', 'private', 'extensions', 'pg_temp' as $$ declare k text := private.fn_oauth_key(); begin if k is null or length(k) < 32 then raise exception 'NUVEMSHOP_OAUTH_ENCRYPTION_KEY ausente'; end if; return pgp_sym_encrypt(plaintext, k, 'cipher-algo=aes256'); end$$; create or replace function public.fn_decrypt_oauth(ciphertext bytea) returns text language plpgsql security definer set search_path to 'public', 'private', 'extensions', 'pg_temp' as $$ declare k text := private.fn_oauth_key(); begin return pgp_sym_decrypt(ciphertext, k); end$$; revoke all on function public.fn_encrypt_oauth(text) from public; revoke all on function public.fn_decrypt_oauth(bytea) from public; grant execute on function public.fn_encrypt_oauth(text) to service_role; grant execute on function public.fn_decrypt_oauth(bytea) to service_role; alter table public.webhook_sources add column if not exists secret_encrypted bytea; do $$ declare k text := current_setting('app.nuvemshop_oauth_key', true); has_plain boolean; n_dropped int; begin select exists ( select 1 from information_schema.columns where table_schema = 'public' and table_name = 'webhook_sources' and column_name = 'secret' ) into has_plain; if not has_plain then return; -- já migrado end if; if k is not null and length(k) >= 32 then update public.webhook_sources set secret_encrypted = public.fn_encrypt_oauth(secret) where secret is not null and secret_encrypted is null; else select count(*) into n_dropped from public.webhook_sources where secret is not null; if n_dropped > 0 then raise warning 'webhook_sources: % secret(s) plaintext descartado(s) — GUC app.nuvemshop_oauth_key ausente; re-configure os secrets pela UI', n_dropped; end if; end if; alter table public.webhook_sources drop column secret; end$$; -- automation_rules: reescreve configs de call_webhook trocando secret -> secret_enc do $$ declare k text := current_setting('app.nuvemshop_oauth_key', true); r record; new_actions jsonb; a jsonb; n_dropped int := 0; begin for r in select id, actions from public.automation_rules where actions::text like '%"secret"%' loop new_actions := '[]'::jsonb; for a in select * from jsonb_array_elements(r.actions) loop if a->>'type' = 'call_webhook' and (a->'config') ? 'secret' then if k is not null and length(k) >= 32 then a := jsonb_set( a #- '{config,secret}', '{config,secret_enc}', to_jsonb(encode(public.fn_encrypt_oauth(a#>>'{config,secret}'), 'hex')) ); else a := a #- '{config,secret}'; n_dropped := n_dropped + 1; end if; end if; new_actions := new_actions || jsonb_build_array(a); end loop; update public.automation_rules set actions = new_actions, updated_at = now() where id = r.id; end loop; if n_dropped > 0 then raise warning 'automation_rules: % secret(s) de call_webhook descartado(s) — GUC app.nuvemshop_oauth_key ausente; re-configure pela UI', n_dropped; end if; end$$; -- ---- trigger de leads sem duplicatas de evento (migration 0043) ---- -- Idempotente (create or replace + drop/create trigger). Ver migrations/20260718160001_0043. create or replace function public.fn_emit_event_on_lead_change() returns trigger language plpgsql set search_path to 'public', 'pg_temp' as $$ begin if tg_op = 'INSERT' then -- lead.created é emitido pelo createLeadHandler (entity_kind='crm_lead'). return new; end if; -- lead.stage_changed é emitido pelo moveLeadHandler (entity_kind='crm_lead'). if new.status is distinct from old.status then if new.status = 'won' then perform public.fn_log_event(new.organization_id, 'lead.won', jsonb_build_object('lead_id', new.id, 'value_cents', new.value_cents)); elsif new.status = 'lost' then perform public.fn_log_event(new.organization_id, 'lead.lost', jsonb_build_object('lead_id', new.id, 'lost_reason', new.lost_reason)); elsif new.status = 'open' then perform public.fn_log_event(new.organization_id, 'lead.reopened', jsonb_build_object('lead_id', new.id)); end if; end if; if new.owner_user_id is distinct from old.owner_user_id then perform public.fn_log_event(new.organization_id, 'lead.assigned', jsonb_build_object('lead_id', new.id, 'from_user_id', old.owner_user_id, 'to_user_id', new.owner_user_id)); end if; return new; end$$; -- INSERT não emite mais nada — dispara só em UPDATE. drop trigger if exists trg_emit_event_on_lead_change on public.crm_leads; create trigger trg_emit_event_on_lead_change after update on public.crm_leads for each row execute function public.fn_emit_event_on_lead_change(); -- Backlog morto: duplicatas antigas do trigger nunca terão consumer. update public.event_log set status = 'done', updated_at = now() where status = 'pending' and entity_kind = 'lead' and event_type in ('lead.created', 'lead.stage_changed'); -- ---- RLS por role em crm_lead_activities/crm_lead_links (migration 0042) ---- -- G6-00 (INB-10): timeline/vínculos de lead seguiam org-flat no SELECT — agent em -- modo 'own' não via o lead (0036) mas lia as activities/links dele por query direta. -- FIX: SELECT das tabelas-filhas HERDA a visibilidade do lead-pai via a MESMA -- fn_can_view_lead (0036), por EXISTS no lead_id (NÃO scalar de owner — lição G4-01: -- scalar devolveria NULL pro lead oculto e own_and_unassigned trataria como fila ⇒ -- vazamento; o EXISTS fecha). WRITE fica org-scope IDÊNTICO ao de hoje (defesa em -- profundidade, não o vetor: todo escritor real usa service role e bypassa RLS; -- activities é append-only). crm_lead_links era FOR ALL (USING governa SELECT via OR, -- a armadilha G4-01) — dropada e re-expressa POR-COMANDO. Idempotente, auto-curativo. drop policy if exists "tenant_isolation_crm_lead_activities_select" on public.crm_lead_activities; drop policy if exists "tenant_isolation_crm_lead_activities_insert" on public.crm_lead_activities; drop policy if exists "crm_lead_activities_select" on public.crm_lead_activities; drop policy if exists "crm_lead_activities_insert" on public.crm_lead_activities; create policy "crm_lead_activities_select" on public.crm_lead_activities for select using ( exists ( select 1 from public.crm_leads l where l.id = crm_lead_activities.lead_id and public.fn_can_view_lead(l.organization_id, l.owner_user_id) ) ); create policy "crm_lead_activities_insert" on public.crm_lead_activities for insert with check ( (organization_id in (select public.fn_user_org_ids())) or public.fn_is_platform_admin() ); drop policy if exists "tenant_isolation_crm_lead_links_all" on public.crm_lead_links; drop policy if exists "crm_lead_links_select" on public.crm_lead_links; drop policy if exists "crm_lead_links_insert" on public.crm_lead_links; drop policy if exists "crm_lead_links_update" on public.crm_lead_links; drop policy if exists "crm_lead_links_delete" on public.crm_lead_links; create policy "crm_lead_links_select" on public.crm_lead_links for select using ( exists ( select 1 from public.crm_leads l where l.id = crm_lead_links.lead_id and public.fn_can_view_lead(l.organization_id, l.owner_user_id) ) ); create policy "crm_lead_links_insert" on public.crm_lead_links for insert with check ( (organization_id in (select public.fn_user_org_ids())) or public.fn_is_platform_admin() ); create policy "crm_lead_links_update" on public.crm_lead_links for update using ( (organization_id in (select public.fn_user_org_ids())) or public.fn_is_platform_admin() ) with check ( (organization_id in (select public.fn_user_org_ids())) or public.fn_is_platform_admin() ); create policy "crm_lead_links_delete" on public.crm_lead_links for delete using ( (organization_id in (select public.fn_user_org_ids())) or public.fn_is_platform_admin() ); -- ---- user_organizations SELECT org-wide para manager+ (migration 0044) ---- -- G6-06 (INB-14): manager passa a ler todo o roster da org (matriz spec 13 §4: -- team=org:read a manager). Antes: só admin org-wide, manager caía no self-read -- e GET /api/v1/team devolvia 1 linha. Self-read preservado p/ todos; WRITE -- inalterado (insert/update/delete = admin). Idempotente e auto-curativo. drop policy if exists "user_orgs_select" on public.user_organizations; create policy "user_orgs_select" on public.user_organizations for select using ( (user_id = auth.uid()) or public.fn_role_at_least(organization_id, 'manager') or public.fn_is_platform_admin() ); -- ============================================================================ -- Dumps do Supabase zeram o search_path (set_config('search_path','',false)); -- os apêndices da fusão criam objetos NÃO-qualificados — restaura o público. select pg_catalog.set_config('search_path', 'public, extensions', false); -- APÊNDICE 0050_agent_harness (fusão Vendaval) — idempotente, espelho exato da -- migration 20260719000000 (kit self-host aplica via install.sh/update.sh). -- ============================================================================ -- 0050_agent_harness — schema do motor SDR (harness) portado do Vendaval para o -- banco do CRM (fusão). Mapeamento canônico (lib/agent-engine/PORT-NOTES.md): -- tenants → organizations · tenant_id → organization_id · leads → contacts · -- lead_id → contact_id · channel_session_id → FK real p/ channel_sessions(id). -- Mortos no porte: tenants/leads (espelhos — o CRM é o mesmo banco agora), -- event_inbox (o drain lê event_log direto), org_llm_credentials (BYOK do CRM = -- ai_provider_credentials), colunas LGPD/handoff de leads (contacts.consent / -- is_anonymized / conversations.bot_silenced_until já existem). -- Idempotente (if not exists / or replace / do $$); SEM begin/commit; psql puro. -- ============================================================================ -- Escalação humana do RUNTIME (ex-inbox_items do Vendaval; a UI lê daqui). -- organization_id NULL = plataforma (ex.: infra) — visível só ao service role. -- Kind já inclui 'judge_unaligned' (extensão da 0025 do Vendaval, embutida). -- ============================================================================ create table if not exists agent_inbox_items ( id uuid primary key default gen_random_uuid(), organization_id uuid references organizations(id) on delete cascade, kind text not null check (kind in ('qr_rescan','job_dead','event_dead','budget_exceeded','handoff', 'promotion_review','judge_unaligned','other')), severity text not null default 'warn' check (severity in ('info','warn','critical')), title text not null, body text, ref_kind text, ref_id uuid, status text not null default 'open' check (status in ('open','ack','resolved')), created_at timestamptz not null default now() ); create index if not exists idx_agent_inbox_items_open on agent_inbox_items (organization_id, created_at desc) where status = 'open'; -- ============================================================================ -- 0002 — fila durável FOR UPDATE SKIP LOCKED com lane por contact_id. -- ============================================================================ create table if not exists job_queue ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references organizations(id) on delete cascade, contact_id uuid references contacts(id) on delete cascade, -- NULL para watchdog/flywheel (jobs sem contato) kind text not null check (kind in ('inbound_turn','followup_turn','watchdog','flywheel')), source_event_id uuid, -- event_log.id (CRM, mesmo banco) que originou o job — dedup evento→job payload jsonb not null default '{}', status text not null default 'pending' check (status in ('pending','running','done','failed','dead')), priority smallint not null default 100, run_after timestamptz not null default now(), attempts smallint not null default 0, max_attempts smallint not null default 5, last_error text, -- normalizado/truncado no código — nunca conteúdo de mensagem (PII) locked_by text, locked_at timestamptz, created_at timestamptz not null default now(), -- jobs de turno TÊM contato; watchdog/flywheel NÃO — o schema força a coerência check ((kind in ('inbound_turn','followup_turn')) = (contact_id is not null)) ); create index if not exists idx_job_queue_claim on job_queue (status, run_after) where status = 'pending'; -- INVARIANTE (lane): 1 job 'running' por contato por vez; paralelismo entre contatos. -- É o CINTO — o claim em duas etapas evita chegar aqui; na corrida residual o 23505 -- é capturado e o claim perde só a rodada. create unique index if not exists uniq_job_queue_one_running_per_contact on job_queue (contact_id) where status = 'running' and contact_id is not null; -- DEDUP evento→job: o handoff é at-least-once; evento re-entregue não vira 2º turno. create unique index if not exists uniq_job_queue_source_event on job_queue (organization_id, source_event_id) where source_event_id is not null; -- ============================================================================ -- 0003 — ledger de envio idempotente. Uma linha por mensagem `seq` do turno; `id` -- É a idempotency_key da tentativa LÓGICA (re-attempt após 'failed' rotaciona o id). -- ============================================================================ create table if not exists send_ledger ( id uuid primary key default gen_random_uuid(), -- a idempotency_key da tentativa lógica corrente organization_id uuid not null references organizations(id) on delete cascade, contact_id uuid references contacts(id) on delete cascade, job_id uuid not null references job_queue(id) on delete cascade, seq smallint not null, -- sha256 hex do corpo — PII (o corpo em si) NUNCA entra no ledger nem em log. body_hash text not null, -- requested: inserido imediatamente antes do envio (crash aqui → retry re-envia a MESMA key) -- accepted: envio confirmado ('sent') — retry pula -- queued: aceito e retido (sessão ≠ WORKING / waha_not_configured) -- vetoed: is_blocked — veto permanente de negócio (irrevogável) -- failed: 'failed' (sem telefone / erro WAHA) — retry = tentativa lógica nova status text not null default 'requested' check (status in ('requested','accepted','queued','vetoed','failed')), crm_message_id uuid, -- messages.id (mesmo banco; vem na resposta do handler de envio) last_error text, -- normalizado/truncado no código — nunca corpo de mensagem (PII) created_at timestamptz not null default now(), updated_at timestamptz not null default now(), -- 1 linha por mensagem do turno — a base do "intenção exactly-once". unique (job_id, seq) ); -- O throttle/spinning da cadeia before_send consulta envios recentes por org. create index if not exists idx_send_ledger_recent on send_ledger (organization_id, created_at desc); -- ============================================================================ -- Imutabilidade compartilhada das tabelas *_versions: conteúdo publicado é -- imutável — mudança = versão nova; rollback = mover o ponteiro. DELETE fica de -- fora de propósito (o cascade de organizations precisa passar; versão apontada -- é protegida pelo FK do ponteiro correspondente). -- ============================================================================ create or replace function fn_agent_versions_immutable() returns trigger language plpgsql as $fn$ begin -- `skill_versions.pointer_id` só existe em instalações legadas. Quando o -- ponteiro é apagado, a FK o limpa para preservar o histórico; nenhum outro -- campo pode mudar. Nas demais tabelas de versões este ramo nem é avaliado. if tg_table_name = 'skill_versions' and (to_jsonb(old) ->> 'pointer_id') is not null and (to_jsonb(new) ->> 'pointer_id') is null and (to_jsonb(old) - 'pointer_id') is not distinct from (to_jsonb(new) - 'pointer_id') then return new; end if; raise exception '% é imutável: mudança = versão nova; rollback = mover o ponteiro (%)', tg_table_name, replace(tg_table_name, '_versions', '_pointers'); end; $fn$; -- Função exclusiva de trigger: nenhuma sessão deve chamá-la como RPC. revoke execute on function public.fn_agent_versions_immutable() from public, anon, authenticated, service_role; -- ============================================================================ -- 0004 — playbook em camadas versionado + carga por ponteiro. 1 linha por CAMADA -- (platform|tenant|campaign); o runtime carrega por ponteiro no início de cada -- run: trocar versão/rollback = mover ponteiro, sem restart. Camada platform é -- global (organization_id NULL); tenant/campaign pertencem a uma org. -- ============================================================================ create table if not exists playbook_versions ( id uuid primary key default gen_random_uuid(), organization_id uuid references organizations(id) on delete cascade, -- NULL = plataforma (global) layer text not null check (layer in ('platform', 'tenant', 'campaign')), -- Markdown com seções nomeadas (## ...), máx. 200 linhas por camada — validado no insert. content text not null, created_at timestamptz not null default now(), -- platform é global; tenant/campaign SEMPRE têm dono — o schema força a coerência check ((layer = 'platform') = (organization_id is null)) ); drop trigger if exists trg_playbook_versions_immutable on playbook_versions; create trigger trg_playbook_versions_immutable before update on playbook_versions for each row execute function fn_agent_versions_immutable(); -- Ponteiro → versão ativa por escopo. SEM cascade no version_id: versão apontada -- não pode sumir debaixo do ponteiro. create table if not exists playbook_pointers ( organization_id uuid references organizations(id) on delete cascade, -- NULL = plataforma (global) layer text not null check (layer in ('platform', 'tenant', 'campaign')), version_id uuid not null references playbook_versions(id), updated_at timestamptz not null default now(), check ((layer = 'platform') = (organization_id is null)) ); -- Unicidade do escopo (PK não serve: organization_id é NULL na plataforma). create unique index if not exists uniq_playbook_pointers_org on playbook_pointers (organization_id, layer) where organization_id is not null; create unique index if not exists uniq_playbook_pointers_platform on playbook_pointers (layer) where organization_id is null; -- ============================================================================ -- 0005 + 0012 — espelho de saúde da sessão WAHA + circuito de saúde do número. -- status_changed_at só avança quando o status MUDA (métrica "tempo no estado"). -- Os holds de status e de saúde coexistem — job retido sob QUALQUER hold. -- ============================================================================ create table if not exists channel_session_health ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references organizations(id) on delete cascade, channel_session_id uuid not null references channel_sessions(id) on delete cascade, status text not null, status_changed_at timestamptz not null default now(), -- Status já escalado (agent_inbox_items kind='qr_rescan') no EPISÓDIO corrente — -- dedup do "exatamente 1×". Volta a null quando a sessão volta a WORKING. escalated_status text, -- Circuito de saúde (0012): default false — linhas criadas pelo watchdog NÃO -- nascem health-held; o "nasce em hold" (fail-safe de go-live) é decidido pelo -- tick de saúde quando health_released_at is null, nunca pelo default. health_hold_active boolean not null default false, health_hold_reason text, -- 'go_live' | 'block_rate' | 'response_rate' health_held_at timestamptz, -- início do episódio de hold (base do cool-down) -- Liberação explícita inicial (go-live). NULL = número novo, nunca liberado → -- nasce em hold (fail-safe). Uma vez setado, permanece. health_released_at timestamptz, updated_at timestamptz not null default now(), unique (organization_id, channel_session_id) ); -- Cursor durável de consumo do event_log do CRM por consumidor do harness (o -- watchdog é o 1º). Tabela de PLATAFORMA (sem org): RLS habilitada sem policy — -- só o service role (worker) lê/escreve. create table if not exists watchdog_cursors ( consumer text primary key, last_created_at timestamptz not null default 'epoch', last_event_id uuid not null default '00000000-0000-0000-0000-000000000000', updated_at timestamptz not null default now() ); -- ============================================================================ -- 0006 — toda chamada de modelo (custo, cache, atribuição); agregado mensal = -- enforcement do budget. Credenciais BYOK são do CRM (ai_provider_credentials) — -- org_llm_credentials do Vendaval NÃO foi portada. -- ============================================================================ create table if not exists llm_calls ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references organizations(id) on delete cascade, contact_id uuid references contacts(id) on delete set null, job_id uuid references job_queue(id) on delete set null, variant_id uuid, -- experiment_variants (flywheel); nasce p/ atribuição purpose text not null default 'agent_turn', -- 'agent_turn' | 'classifier' | 'compaction' | 'connection_test' provider text not null, model text not null, input_tokens int not null default 0, output_tokens int not null default 0, cache_read_tokens int not null default 0, -- métrica de 1ª classe cache_write_tokens int not null default 0, cost_cents numeric, -- null = preço desconhecido — nunca inventar 0 latency_ms int, created_at timestamptz not null default now() ); create index if not exists idx_llm_calls_org_time on llm_calls (organization_id, created_at); -- ============================================================================ -- 0007 — artefato durável do loop do agente: cada run fecha escrevendo um -- checkpoint; o run seguinte do MESMO contato abre lendo o mais recente — -- sessões descartáveis, artefatos duráveis. Conteúdo validado por Zod no handler. -- ============================================================================ create table if not exists lead_checkpoints ( id uuid primary key default gen_random_uuid(), -- ordem de escrita estrita (created_at pode empatar) — abertura lê por seq. seq bigint generated always as identity, organization_id uuid not null references organizations(id) on delete cascade, contact_id uuid not null references contacts(id) on delete cascade, job_id uuid references job_queue(id) on delete set null, -- o run É o job commitments jsonb not null default '[]', -- string[] — compromissos assumidos no turno objections jsonb not null default '[]', -- string[] — objeções levantadas next_action text, rolling_summary text not null default '', created_at timestamptz not null default now() ); create index if not exists idx_lead_checkpoints_latest on lead_checkpoints (organization_id, contact_id, seq desc); -- ============================================================================ -- 0008 — estado do funil por contato. O modelo MARCA avanços via tool; quem -- valida a transição é a máquina de estados NO CÓDIGO — o CHECK é backstop. -- ============================================================================ create table if not exists lead_state ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references organizations(id) on delete cascade, contact_id uuid not null references contacts(id) on delete cascade, stage text not null default 'new' check (stage in ('new','contacted','qualifying','qualified','negotiating','won','lost')), -- qualificação whitelisted (BANT) — Zod .strict() rejeita outras chaves antes daqui. qualification jsonb not null default '{}', next_action text, updated_at timestamptz not null default now(), unique (organization_id, contact_id) ); -- Histórico append-only de transições — auditoria/diffabilidade do funil. create table if not exists lead_state_transitions ( id uuid primary key default gen_random_uuid(), seq bigint generated always as identity, organization_id uuid not null references organizations(id) on delete cascade, contact_id uuid not null references contacts(id) on delete cascade, job_id uuid references job_queue(id) on delete set null, from_stage text not null, to_stage text not null, reason text, created_at timestamptz not null default now() ); create index if not exists idx_lead_state_transitions_contact on lead_state_transitions (organization_id, contact_id, seq desc); -- ============================================================================ -- 0009 — métricas de 1ª classe persistidas. Labels SÓ com ids/contagens — PII -- jamais entra. organization_id NULL = plataforma. -- ============================================================================ create table if not exists metrics ( id uuid primary key default gen_random_uuid(), organization_id uuid references organizations(id) on delete cascade, -- null = plataforma name text not null, labels jsonb not null default '{}', value double precision not null, created_at timestamptz not null default now() ); create index if not exists idx_metrics_name_time on metrics (name, created_at desc); create index if not exists idx_metrics_org_name_time on metrics (organization_id, name, created_at desc); -- ============================================================================ -- 0010 + 0011 + 0012 — knobs anti-ban por número/sessão + ledger de pacing. -- Coluna NULL = default conservador no código (knobs, nunca constantes). O cap -- diário ABSOLUTO não mora aqui: fonte única é channel_sessions.daily_message_limit. -- ============================================================================ create table if not exists channel_knobs ( organization_id uuid not null references organizations(id) on delete cascade, channel_session_id uuid not null references channel_sessions(id) on delete cascade, throttle_ms integer, -- intervalo mínimo entre envios do número jitter_max_ms integer, -- teto do jitter randômico somado ao throttle window_start_hour smallint, -- janela [start, end) na hora local da org window_end_hour smallint, allow_sunday boolean, -- NULL = default do código (hoje: enviar) timezone text, -- IANA tz da org (a janela é avaliada nela) -- degraus [{"minAgeDays":N,"cap":M|null}, ...]; CHECK (array NÃO-VAZIO) + -- validação de shape no load — NULL cai no default; `[]` é rejeitado. warmup_daily_caps jsonb constraint channel_knobs_warmup_caps_is_array check ( warmup_daily_caps is null or (jsonb_typeof(warmup_daily_caps) = 'array' and jsonb_array_length(warmup_daily_caps) > 0) ), -- knobs de spinning / saúde (0011/0012): CHECK só garante "é objeto"; campo a -- campo é validado no load. NULL ou shape inválido → defaults conservadores. spinning_knobs jsonb constraint channel_knobs_spinning_is_object check (spinning_knobs is null or jsonb_typeof(spinning_knobs) = 'object'), health_knobs jsonb constraint channel_knobs_health_is_object check (health_knobs is null or jsonb_typeof(health_knobs) = 'object'), number_activated_at timestamptz not null default now(), -- idade do número p/ warm-up created_at timestamptz not null default now(), updated_at timestamptz not null default now(), primary key (organization_id, channel_session_id) ); -- Ledger de envios efetivados por número — estado durável do throttle e dos caps -- diários (na tz da org). create table if not exists pacing_ledger ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references organizations(id) on delete cascade, channel_session_id uuid not null references channel_sessions(id) on delete cascade, sent_at timestamptz not null default now() ); create index if not exists idx_pacing_ledger_session on pacing_ledger (organization_id, channel_session_id, sent_at desc); -- 0011 — janela deslizante de copies enviadas (gate anti-template-idêntico): -- copy NORMALIZADA das últimas outbound por NÚMERO (across contatos). create table if not exists outbound_copies ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references organizations(id) on delete cascade, channel_session_id uuid not null references channel_sessions(id) on delete cascade, normalized_text text not null, -- copy normalizada (lower/trim/whitespace) p/ similaridade normalized_hash text not null, -- sha256 do normalizado p/ igualdade exata sent_at timestamptz not null default now() ); create index if not exists idx_outbound_copies_session on outbound_copies (organization_id, channel_session_id, sent_at desc); -- ============================================================================ -- 0013 — cron persistente POR CONTATO. Irmão da fila: a fila processa AGORA, o -- cron AGENDA e, no disparo, ENFILEIRA um job em job_queue. Sobrevive a restart -- porque TODO o estado mora aqui. -- ============================================================================ create table if not exists cron_jobs ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references organizations(id) on delete cascade, contact_id uuid not null references contacts(id) on delete cascade, kind text not null check (kind in ('at','every','cron')), -- 'at' → one-shot: next_run_at guarda o instante; dispara e desabilita. -- 'every'→ recorrência fixa: interval_ms é o período (ms). -- 'cron' → expressão 5-campos avaliada em tz (IANA). interval_ms bigint check (interval_ms is null or interval_ms > 0), cron_expr text, tz text not null default 'UTC', -- o que enfileirar quando disparar; coerência kind⇔contato é do CHECK de -- job_queue no enqueue — cron mal-configurado falha PERMANENTE (23514), nunca -- silenciosamente. job_kind text not null default 'followup_turn' check (job_kind in ('inbound_turn','followup_turn','watchdog','flywheel')), payload jsonb not null default '{}', -- próximo disparo — JÁ com o offset de stagger determinístico (anti-rajada). next_run_at timestamptz not null, enabled boolean not null default true, -- retry do disparo CORRENTE: transiente incrementa + adia (backoff); esgotar -- max_attempts desabilita + agent_inbox_items. attempts smallint not null default 0, max_attempts smallint not null default 5, last_error text, -- normalizado/truncado — nunca PII created_at timestamptz not null default now(), updated_at timestamptz not null default now(), check (kind <> 'every' or interval_ms is not null), check (kind <> 'cron' or cron_expr is not null) ); create index if not exists idx_cron_jobs_due on cron_jobs (next_run_at) where enabled = true; -- ============================================================================ -- 0014 — templates de re-entrada versionados + ponteiro. Uma versão guarda N -- VARIANTES pt-br de spinning; a re-entrada determinística envia a variante -- DIRETO pela cadeia de guardrails, sem LLM — custo $0. -- ============================================================================ create table if not exists reentry_template_versions ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references organizations(id) on delete cascade, variants text[] not null check (array_length(variants, 1) >= 1), created_at timestamptz not null default now() ); drop trigger if exists trg_reentry_template_versions_immutable on reentry_template_versions; create trigger trg_reentry_template_versions_immutable before update on reentry_template_versions for each row execute function fn_agent_versions_immutable(); create table if not exists reentry_template_pointers ( organization_id uuid primary key references organizations(id) on delete cascade, version_id uuid not null references reentry_template_versions(id), updated_at timestamptz not null default now() ); -- ============================================================================ -- 0015 + 0016 — memória durável por contato. O ÍNDICE (headlines) é injetado no -- sufixo do prompt com orçamento fixo; o CORPO vem sob demanda. Hard cap imposto -- na ESCRITA (recusa nota que estouraria) — sem truncamento silencioso. -- Nota de um contato NUNCA aparece em run de outro (query sempre filtra -- organization_id + contact_id de fonte confiável). -- ============================================================================ create table if not exists lead_notes ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references organizations(id) on delete cascade, contact_id uuid not null references contacts(id) on delete cascade, headline text not null check (length(headline) > 0), -- a LINHA do índice body text not null check (length(body) > 0), -- corpo sob demanda -- 0016: vetor derivado p/ recall híbrido. jsonb (array de floats), não pgvector: -- a DIMENSÃO é do provedor (BYOK agnóstico) e o conjunto por contato é pequeno -- (hard cap) ⇒ cosseno exato em app, sem índice ANN. Populado preguiçosamente; -- notas são write-once ⇒ o embedding cacheado nunca fica stale. embedding jsonb, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create index if not exists idx_lead_notes_contact on lead_notes (organization_id, contact_id, created_at); -- ============================================================================ -- 0017 — playbooks SITUACIONAIS como skills versionadas com disclosure -- progressivo: só name+description (o ÍNDICE) reside no prompt; o body carrega -- SÓ quando o matcher if-then DETERMINÍSTICO dispara. platform = global -- (organization_id NULL, ex.: "STOP ambíguo"/compliance). -- ============================================================================ create table if not exists skill_versions ( id uuid primary key default gen_random_uuid(), organization_id uuid references organizations(id) on delete cascade, -- NULL = plataforma (global) name text not null check (length(name) > 0), description text not null check (length(description) > 0), body text not null check (length(body) > 0), -- markdown ≤200 linhas; carrega SÓ no match -- { "any_keywords": string[], "probe_keywords"?: string[] } — shape validado no código. matcher jsonb not null default '{}'::jsonb, created_at timestamptz not null default now() ); drop trigger if exists trg_skill_versions_immutable on skill_versions; create trigger trg_skill_versions_immutable before update on skill_versions for each row execute function fn_agent_versions_immutable(); create table if not exists skill_pointers ( organization_id uuid references organizations(id) on delete cascade, -- NULL = plataforma (global) name text not null check (length(name) > 0), version_id uuid not null references skill_versions(id), updated_at timestamptz not null default now() ); create unique index if not exists uniq_skill_pointers_org on skill_pointers (organization_id, name) where organization_id is not null; create unique index if not exists uniq_skill_pointers_platform on skill_pointers (name) where organization_id is null; -- ============================================================================ -- 0018 — tabela de preços/promessas versionada por ponteiro (anti-"vendo por -- R$1"): o gate before_send carrega por ponteiro sob o lock de cada tentativa. -- ============================================================================ create table if not exists promise_table_versions ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references organizations(id) on delete cascade, -- { minPriceCents?, maxDiscountPercent?, maxInstallments? } — shape validado no -- insert. Campo ausente = dimensão não fiscalizada. values jsonb not null, created_at timestamptz not null default now() ); drop trigger if exists trg_promise_table_versions_immutable on promise_table_versions; create trigger trg_promise_table_versions_immutable before update on promise_table_versions for each row execute function fn_agent_versions_immutable(); create table if not exists promise_table_pointers ( organization_id uuid not null references organizations(id) on delete cascade, version_id uuid not null references promise_table_versions(id), updated_at timestamptz not null default now() ); create unique index if not exists uniq_promise_table_pointers_org on promise_table_pointers (organization_id); -- ============================================================================ -- 0019 — template de disclosure "assistente virtual" versionado por ponteiro -- (disclosure by design — CDC hoje / PL 2338 amanhã). Injetado na 1ª mensagem -- (modo inject) ou exigido do modelo (modo veto). -- ============================================================================ create table if not exists disclosure_template_versions ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references organizations(id) on delete cascade, body text not null, -- texto pt-br do disclosure created_at timestamptz not null default now() ); drop trigger if exists trg_disclosure_template_versions_immutable on disclosure_template_versions; create trigger trg_disclosure_template_versions_immutable before update on disclosure_template_versions for each row execute function fn_agent_versions_immutable(); create table if not exists disclosure_template_pointers ( organization_id uuid not null references organizations(id) on delete cascade, version_id uuid not null references disclosure_template_versions(id), updated_at timestamptz not null default now() ); create unique index if not exists uniq_disclosure_template_pointers_org on disclosure_template_pointers (organization_id); -- ============================================================================ -- 0021 — trace de auditoria da cadeia before_send por tentativa: array de gates -- avaliados + gate/código do veto (null = passou). Escrita autônoma (fora da tx -- serializada) — a auditoria do veto SOBREVIVE ao rollback. PII fora: só -- gate/verdict/code/detail — o CORPO da mensagem NUNCA entra aqui. -- ============================================================================ create table if not exists before_send_traces ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references organizations(id) on delete cascade, job_id uuid not null references job_queue(id) on delete cascade, -- RUN = job_queue.id contact_id uuid references contacts(id) on delete cascade, channel_session_id uuid not null references channel_sessions(id) on delete cascade, -- GateTraceEntry[]: [{ gate, verdict, code?, detail? }, ...] — sem PII. trace jsonb not null, vetoed_gate text, vetoed_code text, created_at timestamptz not null default now() ); create index if not exists idx_before_send_traces_run on before_send_traces (organization_id, job_id, created_at); -- ============================================================================ -- 0023 — vereditos dos judges em produção, batch offline (NUNCA inline por -- mensagem). Idempotente/resumível: unique (dataset, trace_id, dimension) + -- on conflict do nothing. PII fora do DB: só metadata/proveniência anonimizada. -- ============================================================================ create table if not exists flywheel_judge_verdicts ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references organizations(id) on delete cascade, dataset text not null, -- namespace da proveniência (replay) trace_id text not null, dimension text not null, verdict text not null check (verdict in ('yes', 'no', 'unknown')), option_order text not null, -- auditoria da mitigação de position bias judge_family text not null, model text not null, -- ORIGEM do trace: proveniência do dataset (replay) ou playbook_version (live). provenance jsonb not null default '{}', run_id uuid not null, -- agrupa uma RODADA de batch judged_at timestamptz not null default now() ); create unique index if not exists uq_flywheel_judge_verdicts_key on flywheel_judge_verdicts (dataset, trace_id, dimension); create index if not exists idx_flywheel_judge_verdicts_run on flywheel_judge_verdicts (organization_id, run_id); create index if not exists idx_flywheel_judge_verdicts_dataset on flywheel_judge_verdicts (dataset, dimension); -- ============================================================================ -- 0024 — CANDIDATOS de melhoria propostos pelo distiller isolado. NUNCA aplica: -- aplicar é o merge sob gate humano. Este é o ÚNICO store de escrita do distiller -- (anti "curator-takeover"). Cada proposta REFERENCIA a evidência que a motivou. -- ============================================================================ create table if not exists flywheel_distiller_proposals ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references organizations(id) on delete cascade, run_id uuid not null, dataset text not null, type text not null check (type in ('playbook_bullet', 'golden_case', 'reentry_trigger')), target text not null, -- camada de playbook / arquivo golden / família de gatilho content text not null check (length(content) > 0), -- texto proposto, pt-br, sem PII evidence jsonb not null, -- trace_ids + run_ids + taxa/amostra proposed_at timestamptz not null default now() ); create index if not exists idx_flywheel_distiller_proposals_run on flywheel_distiller_proposals (organization_id, run_id); create index if not exists idx_flywheel_distiller_proposals_dataset on flywheel_distiller_proposals (dataset, type); -- ============================================================================ -- 0025 — MANUTENÇÃO do judge: rotaciona casos frescos julgados em produção para -- um POOL de alinhamento (candidatos a novo lote de labels humanos no drift). -- A unique é o DEDUP da rotação. (A extensão de kind 'judge_unaligned' já está -- embutida no CHECK de agent_inbox_items acima.) -- ============================================================================ create table if not exists judge_alignment_pool ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references organizations(id) on delete cascade, dataset text not null, trace_id text not null, dimension text not null, added_at timestamptz not null default now() ); create unique index if not exists uq_judge_alignment_pool_key on judge_alignment_pool (dataset, trace_id, dimension); create index if not exists idx_judge_alignment_pool_dim on judge_alignment_pool (organization_id, dimension); -- ============================================================================ -- 0026 — knobs de re-entrada (timing de follow-up + segmentação) versionados + -- ponteiro. O 1º alvo concreto do flywheel: timing não é constante nem env — -- é config versionada por org, otimizável e rollbackável pelo ponteiro. -- ============================================================================ create table if not exists reentry_knob_versions ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references organizations(id) on delete cascade, -- { follow_up_window_hours: number>0, enabled_segments: string[] } — shape -- revalidado no insert. knobs jsonb not null, created_at timestamptz not null default now() ); drop trigger if exists trg_reentry_knob_versions_immutable on reentry_knob_versions; create trigger trg_reentry_knob_versions_immutable before update on reentry_knob_versions for each row execute function fn_agent_versions_immutable(); create table if not exists reentry_knob_pointers ( organization_id uuid primary key references organizations(id) on delete cascade, version_id uuid not null references reentry_knob_versions(id), updated_at timestamptz not null default now() ); -- ============================================================================ -- RLS — padrão do repo: tenant_isolation__all via fn_user_org_ids() + -- revoke de anon. Nas tabelas com organization_id nullable (agent_inbox_items, -- playbook_versions/pointers, skill_versions/pointers, metrics) a MESMA policy -- serve: `null in (...)` nunca é true ⇒ linhas de plataforma são visíveis só ao -- service role (que bypassa RLS). -- -- A enumeração que morava AQUI virou a função sem parâmetro logo abaixo -- (migration 0325) — ela é chamada em cada um dos três pontos onde havia laço, -- e de novo no fim do arquivo. -- ============================================================================ -- ---- proteções de tabela de organização: o laço vira função (migration 0325) ---- -- -- ADR-0002, D5: "Essas rotinas saem do laço do baseline para funções sem -- parâmetro, chamadas pelo baseline e pela provisionadora." É a irmã da 0274 — -- lá foram as travas do suporte, aqui são RLS ligada, `revoke all … from anon` e -- o isolamento por organização. -- -- A RÉGUA É `not relrowsecurity`, e não "toda tabela com organization_id". -- Medido no baseline de ed42ad119 (pg17 descartável, ON_ERROR_STOP=1): das 119 -- tabelas de organização, 0 estão sem RLS — então esta varredura é no-op aqui —, -- mas 49 ainda têm privilégio de `anon`, 8 são server-only (RLS ligada e ZERO -- policies, de propósito) e 66 não têm a policy ampla. Varrer as 119 abriria as -- 8 e atropelaria as policies por papel das 66. Já uma tabela recém-criada — o -- que a provisionadora de um módulo produz — nasce com RLS desligada, e é -- exatamente ela que esta régua pega. O racional inteiro está no cabeçalho da -- migration 20260919153000_0325_*.sql. -- -- Idempotente: `drop policy if exists` antes do `create policy`; reaplicar -- converge. A definição fica AQUI, antes da varredura de anon (que é, de -- propósito, quem cura o `anon` de toda função nova); a CHAMADA fica no fim do -- arquivo, junto com a da 0274. create or replace function public.fn_proteger_tabelas_de_organizacao() returns void language plpgsql set search_path = public as $f$ declare r record; begin for r in select c.relname from pg_class c join pg_namespace n on n.oid = c.relnamespace where n.nspname = 'public' and c.relkind = 'r' and not c.relrowsecurity and exists ( select 1 from pg_attribute a where a.attrelid = c.oid and a.attname = 'organization_id' and a.attnum > 0 and not a.attisdropped) order by c.relname loop execute format('alter table public.%I enable row level security', r.relname); execute format('revoke all on public.%I from anon', r.relname); execute format('drop policy if exists tenant_isolation_%s_all on public.%I', r.relname, r.relname); execute format( 'create policy tenant_isolation_%s_all on public.%I for all using (organization_id in (select * from public.fn_user_org_ids())) with check (organization_id in (select * from public.fn_user_org_ids()))', r.relname, r.relname); end loop; end $f$; -- O ponto de entrada que a provisionadora de um módulo chama no FIM do corpo, -- na MESMA transação em que criou as tabelas. A ORDEM importa: as travas do -- suporte (0274) leem o privilégio de `authenticated` de cada tabela para -- decidir entre as três policies restritivas e o contrato server-only, então -- vêm DEPOIS de a RLS e o isolamento estarem no lugar. create or replace function public.fn_proteger_modulo_provisionado() returns void language plpgsql set search_path = public as $f$ begin perform public.fn_proteger_tabelas_de_organizacao(); perform public.fn_aplicar_travas_de_suporte(); end $f$; revoke execute on function public.fn_proteger_tabelas_de_organizacao() from public, anon, authenticated, service_role; revoke execute on function public.fn_proteger_modulo_provisionado() from public, anon, authenticated, service_role; -- A rotina da migration 0325 no lugar do laço enumerado: ela varre o catálogo -- procurando tabela de organização com RLS DESLIGADA, que neste ponto do arquivo -- é exatamente o conjunto que a lista enumerava (medido, tabela a tabela). do $$ begin perform public.fn_proteger_tabelas_de_organizacao(); end $$; -- watchdog_cursors não tem organization_id (infra de plataforma): RLS habilitada -- SEM policy ⇒ só o service role acessa. alter table watchdog_cursors enable row level security; revoke all on watchdog_cursors from anon; -- APÊNDICE 0051_agent_version_immutability (fusão Fase 2B) — espelho exato da migration. -- 0051_agent_version_immutability — Fase 2B da fusão Vendaval. -- -- ai_agent_versions passa a ser a fonte de config que o agent-engine LÊ POR -- PONTEIRO no início de cada turno (published_version_id). Uma versão publicada -- precisa ser imutável NO BANCO (não só por convenção de app): editar = criar -- versão draft nova; rollback = revert (clona + publica). Mesmo princípio do -- fn_agent_versions_immutable do harness (0050), adaptado ao lifecycle desta -- tabela — o UPDATE de CONTEÚDO é vetado fora de status='draft'; as transições -- de lifecycle (draft→published→superseded→archived + timestamps) continuam -- livres (é o que o RPC fn_publish_ai_agent_version faz). -- Idempotente; sem BEGIN/COMMIT; psql puro. create or replace function fn_ai_agent_version_content_immutable() returns trigger language plpgsql as $fn$ begin -- Conteúdo congelado fora de draft. Campos de lifecycle ficam de fora do -- veto de propósito: status/published_at/superseded_at mudam no publish. if old.status <> 'draft' and ( new.system_prompt is distinct from old.system_prompt or new.provider is distinct from old.provider or new.model is distinct from old.model or new.credential_id is distinct from old.credential_id or new.tool_ids is distinct from old.tool_ids or new.trigger_config is distinct from old.trigger_config or new.channel_session_id is distinct from old.channel_session_id or new.max_steps is distinct from old.max_steps or new.token_budget is distinct from old.token_budget or new.cost_budget_cents is distinct from old.cost_budget_cents or new.history_message_window is distinct from old.history_message_window or new.history_token_window is distinct from old.history_token_window or new.handoff_keywords is distinct from old.handoff_keywords or new.handoff_tool_enabled is distinct from old.handoff_tool_enabled or new.version_number is distinct from old.version_number or new.agent_id is distinct from old.agent_id or new.organization_id is distinct from old.organization_id ) then raise exception 'ai_agent_versions % é imutável (status=%): mudança de conteúdo = versão draft nova; rollback = revert (clona + publica)', old.id, old.status; end if; return new; end; $fn$; drop trigger if exists trg_ai_agent_versions_content_immutable on public.ai_agent_versions; create trigger trg_ai_agent_versions_content_immutable before update on public.ai_agent_versions for each row execute function fn_ai_agent_version_content_immutable(); -- APÊNDICE 0052_republish_fn_uppercase_fix — re-assenta a fn de publish correta (anti-drift). -- 0052_republish_fn_uppercase_fix — forward-fix de DRIFT de função. -- -- Sintoma (Fase 2B da fusão): publish na tela falhava com channel_session_offline -- mesmo com a sessão WORKING. Diagnóstico no banco hospedado: a função -- fn_publish_ai_agent_version deployada continha `v_session.status <> 'working'` -- (minúsculo) — a versão PRÉ-0026 — apesar de 20260706200000_0026 constar como -- aplicada em schema_migrations. Ou seja: algo re-aplicou a definição antiga por -- FORA do fluxo de migrations depois da 0026 (drift). -- Conserto: re-assentar a definição correta da 0026 como migration NOVA (forward- -- fix; migração aplicada nunca é editada). Idempotente por natureza (or replace). create or replace function public.fn_publish_ai_agent_version( p_org_id uuid, p_agent_id uuid, p_version_id uuid ) returns table ( agent_id uuid, version_id uuid, previous_version_id uuid, published_at timestamptz ) language plpgsql security definer set search_path to 'public' as $$ declare v_agent record; v_version record; v_credential record; v_session record; v_model_count integer; v_previous_version_id uuid; v_published_at timestamptz := now(); begin select a.id, a.organization_id, a.published_version_id, a.archived_at into v_agent from public.ai_agents a where a.id = p_agent_id for update; if not found then raise exception 'agent_not_found' using errcode = 'P0001'; end if; if v_agent.organization_id <> p_org_id then raise exception 'agent_not_found' using errcode = 'P0001'; end if; if v_agent.archived_at is not null then raise exception 'agent_archived' using errcode = 'P0001'; end if; select v.id, v.organization_id, v.agent_id, v.status, v.provider, v.model, v.credential_id, v.channel_session_id, v.provisioning_origin into v_version from public.ai_agent_versions v where v.id = p_version_id for update; if not found then raise exception 'version_not_found' using errcode = 'P0001'; end if; if v_version.agent_id <> p_agent_id or v_version.organization_id <> p_org_id then raise exception 'version_not_found' using errcode = 'P0001'; end if; if p_expected_provenance is not null and ( p_expected_provenance not in('onboarding','legacy_reconciliation') or v_version.provisioning_origin is distinct from p_expected_provenance or (select count(*) from public.ai_agent_versions own_version where own_version.organization_id=p_org_id and own_version.agent_id=p_agent_id)<>1 ) then raise exception 'existing_version_requires_review' using errcode='P0001';end if; if v_version.status not in ('draft', 'superseded') then raise exception 'version_invalid_state' using errcode = 'P0001'; end if; if v_version.credential_id is null then raise exception 'credential_missing' using errcode = 'P0001'; end if; select c.id, c.organization_id, c.provider, c.is_active, c.validated_at into v_credential from public.ai_provider_credentials c where c.id = v_version.credential_id; if not found or v_credential.organization_id <> p_org_id then raise exception 'credential_not_found' using errcode = 'P0001'; end if; if not v_credential.is_active then raise exception 'credential_inactive' using errcode = 'P0001'; end if; if v_credential.validated_at is null then raise exception 'credential_not_validated' using errcode = 'P0001'; end if; if v_credential.provider <> v_version.provider then raise exception 'credential_provider_mismatch' using errcode = 'P0001'; end if; select s.id, s.organization_id, s.status into v_session from public.channel_sessions s where s.id = v_version.channel_session_id; if not found or v_session.organization_id <> p_org_id then raise exception 'channel_session_not_found' using errcode = 'P0001'; end if; if v_session.status <> 'WORKING' then raise exception 'channel_session_offline' using errcode = 'P0001'; end if; select count(*) into v_model_count from public.ai_models m where m.provider = v_version.provider and m.model_id = v_version.model and m.deprecated_at is null; if v_model_count = 0 then raise exception 'model_not_found' using errcode = 'P0001'; end if; v_previous_version_id := v_agent.published_version_id; if v_previous_version_id is not null and v_previous_version_id <> p_version_id then update public.ai_agent_versions set status = 'superseded', superseded_at = v_published_at where id = v_previous_version_id; end if; update public.ai_agent_versions set status = 'published', published_at = v_published_at, superseded_at = null where id = p_version_id; update public.ai_agents set published_version_id = p_version_id, updated_at = v_published_at where id = p_agent_id; return query select p_agent_id, p_version_id, v_previous_version_id, v_published_at; end; $$; comment on function public.fn_publish_ai_agent_version(uuid, uuid, uuid) is 'EPIC-13 S-13.06 (fixed in 0026): compares channel_sessions.status against WORKING (uppercase), matching channel_sessions_status_check. 0024/0025 compared against lowercase working and always raised channel_session_offline.'; -- ============================================================================ -- 0053 — Operação Visível F3: rastro de aplicação de proposta do flywheel -- (applied_at/applied_version_id/applied_by; null = pendente). Idempotente. -- ============================================================================ alter table flywheel_distiller_proposals add column if not exists applied_at timestamptz, add column if not exists applied_version_id uuid references ai_agent_versions(id) on delete set null, add column if not exists applied_by uuid; -- ---- followup flows (migration 0054) ---- create table if not exists followup_flow_versions ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references organizations(id) on delete cascade, graph jsonb not null, created_by uuid references auth.users(id) on delete set null, created_at timestamptz not null default now() ); create table if not exists followup_flow_pointers ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references organizations(id) on delete cascade, name text not null, status text not null default 'draft' check (status in ('draft','active','disabled')), active_version_id uuid references followup_flow_versions(id), draft_graph jsonb, handoff_policy text not null default 'pause' check (handoff_policy in ('pause','cancel','allow')), trigger_config jsonb not null default '{"kind":"manual"}', created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique (organization_id, name) ); create table if not exists followup_enrollments ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references organizations(id) on delete cascade, pointer_id uuid not null references followup_flow_pointers(id) on delete cascade, version_id uuid not null references followup_flow_versions(id), contact_id uuid not null references contacts(id) on delete cascade, conversation_id uuid references conversations(id) on delete set null, current_node_id text not null, status text not null default 'active' check (status in ('active','waiting_reply','paused_handoff','completed','cancelled','dead')), next_eval_at timestamptz, claimed_until timestamptz, attempts smallint not null default 0, max_attempts smallint not null default 5, last_error text, steps_taken smallint not null default 0, outcome text check (outcome in ('converted','replied','exhausted','opted_out','handoff')), cancel_reason text, started_at timestamptz not null default now(), completed_at timestamptz, updated_at timestamptz not null default now(), -- estados com relógio TÊM next_eval_at; pausados/terminais NÃO — coerência no schema check ( (status in ('active','waiting_reply') and next_eval_at is not null) or (status in ('paused_handoff','completed','cancelled','dead')) ) ); create index if not exists idx_followup_enrollments_due on followup_enrollments (next_eval_at) where status in ('active','waiting_reply'); create unique index if not exists idx_followup_enrollments_one_live on followup_enrollments (pointer_id, contact_id) where status in ('active','waiting_reply','paused_handoff'); create index if not exists idx_followup_enrollments_contact on followup_enrollments (organization_id, contact_id); create table if not exists followup_enrollment_events ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references organizations(id) on delete cascade, enrollment_id uuid not null references followup_enrollments(id) on delete cascade, node_id text, event_type text not null, payload jsonb not null default '{}', idempotency_key text, created_at timestamptz not null default now() ); create unique index if not exists idx_followup_events_idem on followup_enrollment_events (enrollment_id, idempotency_key) where idempotency_key is not null; -- RLS (padrão fn_user_org_ids) alter table followup_flow_versions enable row level security; alter table followup_flow_pointers enable row level security; alter table followup_enrollments enable row level security; alter table followup_enrollment_events enable row level security; -- As policies `for all` das quatro tabelas saíram daqui: followup_flow_pointers e -- followup_enrollments na migration 0489 (issue #1913), followup_flow_versions e -- followup_enrollment_events na 0490 (issue #1915). As policies por operação estão nos -- apêndices delas. -- Claim atômico do worker (SKIP LOCKED) — service role only create or replace function fn_claim_due_followup_enrollments(p_limit int, p_lease_seconds int) returns setof followup_enrollments language sql security definer set search_path = public as $$ update followup_enrollments e set claimed_until = now() + make_interval(secs => p_lease_seconds), updated_at = now() where e.id in ( select id from followup_enrollments where status in ('active','waiting_reply') and next_eval_at <= now() and (claimed_until is null or claimed_until < now()) order by next_eval_at limit p_limit for update skip locked ) returning e.*; $$; revoke all on function fn_claim_due_followup_enrollments(int, int) from public, anon, authenticated; -- ---- followup version lineage + atomic publish (migration 0056) ---- alter table followup_flow_versions add column if not exists pointer_id uuid references followup_flow_pointers(id) on delete cascade; update followup_flow_versions v set pointer_id = p.id from followup_flow_pointers p where p.active_version_id = v.id and v.pointer_id is null; create index if not exists idx_followup_versions_pointer on followup_flow_versions (pointer_id); create or replace function fn_publish_followup_flow_version( p_org uuid, p_pointer uuid, p_graph jsonb, p_created_by uuid ) returns uuid language plpgsql security definer set search_path = public as $$ declare v_pointer record; v_version_id uuid; begin select p.id, p.organization_id into v_pointer from followup_flow_pointers p where p.id = p_pointer for update; if not found or v_pointer.organization_id <> p_org then raise exception 'pointer_not_found' using errcode = 'P0001'; end if; insert into followup_flow_versions (organization_id, pointer_id, graph, created_by) values (p_org, p_pointer, p_graph, p_created_by) returning id into v_version_id; update followup_flow_pointers set active_version_id = v_version_id, status = 'active', updated_at = now() where id = p_pointer; return v_version_id; end; $$; revoke all on function fn_publish_followup_flow_version(uuid, uuid, jsonb, uuid) from public, anon, authenticated; -- ---- agent_inbox_items: kind 'followup_dead' (migration 0057) ---- -- A constraint NÃO é reconstruída aqui: o vocabulário desta migration já está -- contido no bloco único do fim deste apêndice. Reconstruí-la com a lista da -- época quebrava o update.sh de quem já tem linha com kind mais novo (era o -- caso deste bloco: 'snooze_expired' e os 4 seguintes ainda não existiam). -- ---- agent editor: seletor de fluxo de follow-up (migration 0061) ---- alter table ai_agent_versions add column if not exists followup jsonb not null default '{"enabled": false, "flow_pointer_ids": []}'::jsonb; create or replace function fn_ai_agent_version_content_immutable() returns trigger language plpgsql as $fn$ begin if old.status <> 'draft' and ( new.system_prompt is distinct from old.system_prompt or new.provider is distinct from old.provider or new.model is distinct from old.model or new.credential_id is distinct from old.credential_id or new.tool_ids is distinct from old.tool_ids or new.trigger_config is distinct from old.trigger_config or new.channel_session_id is distinct from old.channel_session_id or new.max_steps is distinct from old.max_steps or new.token_budget is distinct from old.token_budget or new.cost_budget_cents is distinct from old.cost_budget_cents or new.history_message_window is distinct from old.history_message_window or new.history_token_window is distinct from old.history_token_window or new.handoff_keywords is distinct from old.handoff_keywords or new.handoff_tool_enabled is distinct from old.handoff_tool_enabled or new.followup is distinct from old.followup or new.version_number is distinct from old.version_number or new.agent_id is distinct from old.agent_id or new.organization_id is distinct from old.organization_id ) then raise exception 'ai_agent_versions % é imutável (status=%): mudança de conteúdo = versão draft nova; rollback = revert (clona + publica)', old.id, old.status; end if; return new; end; $fn$; drop trigger if exists trg_ai_agent_versions_content_immutable on public.ai_agent_versions; create trigger trg_ai_agent_versions_content_immutable before update on public.ai_agent_versions for each row execute function fn_ai_agent_version_content_immutable(); -- ---- followup enrollment: 1 vivo por lead ORG-WIDE + agent_id (migration 0064) ---- -- Dedup ANTES de trocar o índice (self-host-safe: o update.sh re-aplica sem -- ON_ERROR_STOP, então o dado sujo tem que ser curado antes da constraint). with ranked as ( select id, row_number() over ( partition by organization_id, contact_id order by started_at desc, id desc ) as rn from followup_enrollments where status in ('active', 'waiting_reply', 'paused_handoff') ) update followup_enrollments e set status = 'cancelled', cancel_reason = 'exclusivity_backfill', next_eval_at = null, updated_at = now() from ranked where e.id = ranked.id and ranked.rn > 1; -- Só derruba a versão por `pointer_id` (issue #1041): numa reaplicação o índice -- já é por (organization_id, contact_id), e derrubá-lo aqui o reconstruiria para -- a 0145 derrubar e reconstruir de novo adiante. do $$ begin if exists ( select 1 from pg_indexes where schemaname = 'public' and indexname = 'idx_followup_enrollments_one_live' and indexdef not ilike '%(organization_id, contact_id)%' ) then execute 'drop index public.idx_followup_enrollments_one_live'; end if; end $$; create unique index if not exists idx_followup_enrollments_one_live on followup_enrollments (organization_id, contact_id) where status in ('active', 'waiting_reply', 'paused_handoff'); alter table followup_enrollments add column if not exists agent_id uuid references ai_agents(id) on delete set null; create index if not exists idx_followup_enrollments_agent on followup_enrollments (agent_id); -- ---- bucket whatsapp-media (migration 0055) ---- insert into storage.buckets (id, name, public, file_size_limit) values ('whatsapp-media', 'whatsapp-media', false, 52428800) on conflict (id) do update set file_size_limit = excluded.file_size_limit; -- ---- media multimodal: derivado + flags (migration 0058) ---- alter table messages add column if not exists media_derived_text text, add column if not exists media_derived_status text; alter table ai_agent_versions add column if not exists multimodal_input boolean not null default true, add column if not exists video_frames_enabled boolean not null default false; -- ---- split de mensagens por-agente (migration 0059) ---- alter table ai_agent_versions add column if not exists split_messages boolean not null default false, add column if not exists split_max_chars integer not null default 600; -- ---- templates de script do vendedor (migration 0060) ---- create table if not exists message_templates ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references organizations(id) on delete cascade, owner_user_id uuid references auth.users(id) on delete cascade, title text not null, body text not null, shortcut text, created_by_user_id uuid references auth.users(id) on delete set null, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create index if not exists idx_message_templates_org on message_templates (organization_id); alter table message_templates enable row level security; drop policy if exists "message_templates_select" on message_templates; create policy "message_templates_select" on message_templates for select using ( ( organization_id in (select fn_user_org_ids()) and (owner_user_id is null or owner_user_id = auth.uid()) ) or fn_is_platform_admin() ); drop policy if exists "message_templates_write" on message_templates; create policy "message_templates_write" on message_templates for all using ( organization_id in (select fn_user_org_ids()) and ( (owner_user_id = auth.uid() and fn_role_at_least(organization_id, 'agent')) or (owner_user_id is null and fn_role_at_least(organization_id, 'manager')) ) ) with check ( organization_id in (select fn_user_org_ids()) and ( (owner_user_id = auth.uid() and fn_role_at_least(organization_id, 'agent')) or (owner_user_id is null and fn_role_at_least(organization_id, 'manager')) ) ); -- ---- snooze por conversa (migration 0062) ---- alter table conversations add column if not exists snooze_until timestamptz, add column if not exists snoozed_by_user_id uuid references auth.users(id) on delete set null, add column if not exists snoozed_at timestamptz; create index if not exists idx_conversations_snooze_until on conversations (snooze_until) where snooze_until is not null; -- (constraint agent_inbox_items_kind_check: definida uma vez só, no fim deste -- apêndice — ver "vocabulário completo". 'snooze_expired' está lá.) -- ---- notas internas de conversa (migration 0063) ---- create table if not exists conversation_notes ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references organizations(id) on delete cascade, conversation_id uuid not null references conversations(id) on delete cascade, body text not null, created_by_user_id uuid references auth.users(id) on delete set null, created_by_name text, created_at timestamptz not null default now() ); create index if not exists idx_conversation_notes_conversation on conversation_notes (conversation_id, created_at); alter table conversation_notes enable row level security; -- As policies de `conversation_notes` foram para o apêndice da 0478: deixar a -- definição aqui embaixo (versão antiga, só-organização) significaria que cada -- `update.sh` instala a política vazante ANTES da final — janela em que a nota -- vaza para quem não pode ver a conversa. O `drop policy if exists` do -- apêndice já cuida do clone antigo; é o que o gate -- `baseline-nao-constroi-o-que-derruba` exige ("tire a intermediária"). -- ---- human cases (migration 0066) ---- create table if not exists agent_cases ( id uuid primary key default uuid_generate_v4(), organization_id uuid not null references organizations(id) on delete cascade, conversation_id uuid not null references conversations(id) on delete cascade, lead_id uuid references crm_leads(id) on delete set null, agent_id uuid references ai_agents(id) on delete set null, status text not null default 'awaiting_human' check (status in ('awaiting_human','awaiting_lead','resolved','escalated','cancelled')), title text not null, summary text not null, blocker text not null, context_snapshot jsonb not null default '{}'::jsonb, source text not null default 'agent' check (source in ('agent','guardrail_autofallback')), followup_attempts smallint not null default 0, opened_at timestamptz not null default now(), closed_at timestamptz, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create index if not exists agent_cases_open_idx on agent_cases (organization_id, status) where status in ('awaiting_human','awaiting_lead'); create index if not exists agent_cases_lead_idx on agent_cases (organization_id, lead_id); create index if not exists agent_cases_conv_idx on agent_cases (organization_id, conversation_id); create table if not exists agent_case_events ( id uuid primary key default uuid_generate_v4(), organization_id uuid not null references organizations(id) on delete cascade, case_id uuid not null references agent_cases(id) on delete cascade, kind text not null check (kind in ('opened','human_replied','lead_asked','lead_provided','lead_unresponsive','resolved','escalated','cancelled')), actor_kind text not null check (actor_kind in ('agent','human','system','lead')), actor_user_id uuid references auth.users(id) on delete set null, human_action text check (human_action in ('resolved','need_lead_info','escalate')), body text, metadata jsonb not null default '{}'::jsonb, created_at timestamptz not null default now() ); create index if not exists agent_case_events_case_idx on agent_case_events (case_id, created_at); alter table ai_agent_versions add column if not exists cases_enabled boolean not null default false; alter table agent_cases enable row level security; alter table agent_case_events enable row level security; -- A policy `for all` não nasce mais aqui desde a 0279: quem escreve caso é o -- motor, e a leitura ganhou policy própria (`tenant_isolation_agent_cases_select`). -- O par drop+create mora no bloco da 0279, colado, pelo motivo escrito acima. drop policy if exists tenant_isolation_agent_case_events_select on agent_case_events; create policy tenant_isolation_agent_case_events_select on agent_case_events for select using (organization_id in (select fn_user_org_ids())); -- A policy de INSERT não nasce mais aqui desde a 0279 (mesmo motivo da tabela -- mãe); quem a derruba no clone que já a tem é o bloco da 0279. A de SELECT, -- logo acima, fica: a tela e o MCP leem. -- estender CHECKs de job_queue (kind + coerência kind⇔contato) p/ case_reply_turn -- nomes reais conferidos no banco linkado: job_queue_kind_check (named) e -- job_queue_check (anônimo, gerado pelo Postgres) para o CHECK de coerência. alter table job_queue drop constraint if exists job_queue_kind_check; alter table job_queue add constraint job_queue_kind_check -- 'operator_turn' (migration 0111, spec 16 §3.2) entra NESTE bloco, não num -- novo no fim: reconstruir a mesma constraint em N blocos quebra o update.sh -- de todo clone que já tenha uma linha de vocabulário posterior — os blocos -- antigos rodam antes e falham em cadeia. Vigiado por -- tests/unit/baseline-constraint-reconstruida.test.ts. -- 'transactional_delivery' (0226) segue a mesma consolidação de vocabulário. check (kind in ('inbound_turn','followup_turn','watchdog','flywheel','case_reply_turn','operator_turn','transactional_delivery','approved_reply')); alter table job_queue drop constraint if exists job_queue_turn_needs_contact; do $$ declare c text; begin select conname into c from pg_constraint where conrelid = 'job_queue'::regclass and contype='c' and pg_get_constraintdef(oid) ilike '%contact_id is not null%'; if c is not null then execute format('alter table job_queue drop constraint %I', c); end if; end $$; alter table job_queue add constraint job_queue_turn_needs_contact check ((kind in ('inbound_turn','followup_turn','case_reply_turn','operator_turn','transactional_delivery','approved_reply')) = (contact_id is not null)); alter table cron_jobs drop constraint if exists cron_jobs_job_kind_check; alter table cron_jobs add constraint cron_jobs_job_kind_check check (job_kind in ('inbound_turn','followup_turn','watchdog','flywheel','case_reply_turn')); -- ---- agent_inbox_items: reconcilia kind check followup_dead+snooze_expired (migration 0065) ---- -- (constraint agent_inbox_items_kind_check: definida uma vez só, no fim deste -- apêndice — ver "vocabulário completo". Os dois valores desta migration -- estão lá.) -- ---- memória geral da org: org_memory_versions/pointers/entries (migration 0067) ---- -- 0067: Memória Geral da Org (Fase 1 do épico harness — spec 2026-07-23). -- Doc-mãe versionado (padrão versões-imutáveis+ponteiro do playbook 0004/0050) -- + entradas de aprendizado individuais (manual | flywheel com aprovação humana). create table if not exists org_memory_versions ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references organizations(id) on delete cascade, version_number int not null, content text not null, created_by uuid, created_at timestamptz not null default now(), unique (organization_id, version_number) ); drop trigger if exists trg_org_memory_versions_immutable on org_memory_versions; create trigger trg_org_memory_versions_immutable before update on org_memory_versions for each row execute function fn_agent_versions_immutable(); create table if not exists org_memory_pointers ( organization_id uuid not null unique references organizations(id) on delete cascade, version_id uuid not null references org_memory_versions(id), updated_at timestamptz not null default now() ); create table if not exists org_memory_entries ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references organizations(id) on delete cascade, title text not null check (length(title) > 0), body text not null check (length(body) > 0), source text not null check (source in ('manual', 'flywheel')), status text not null default 'active' check (status in ('proposed', 'active', 'archived')), proposal_id uuid references flywheel_distiller_proposals(id) on delete set null, created_by uuid, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create index if not exists idx_org_memory_entries_org_status on org_memory_entries (organization_id, status, created_at); -- Flywheel: novo destino de proposta (entry de memória da org). alter table flywheel_distiller_proposals drop constraint if exists flywheel_distiller_proposals_type_check; alter table flywheel_distiller_proposals add constraint flywheel_distiller_proposals_type_check check (type in ('playbook_bullet', 'golden_case', 'reentry_trigger', 'org_memory_entry')); -- RLS (mesmo shape do loop tenant_isolation_* do baseline). -- A rotina da migration 0325 no lugar do laço enumerado: ela varre o catálogo -- procurando tabela de organização com RLS DESLIGADA, que neste ponto do arquivo -- é exatamente o conjunto que a lista enumerava (medido, tabela a tabela). do $$ begin perform public.fn_proteger_tabelas_de_organizacao(); end $$; -- ---- skills instaláveis: manifest + skill_activations + catálogo (migration 0068) ---- -- 0068: Skills instaláveis + marketplace (Fase 2 do épico harness — spec 2026-07-23). -- Manifest de arquivos na versão de skill + telemetria de ativação + bucket de -- assets + leitura do catálogo de plataforma por clientes user-scoped. alter table skill_versions add column if not exists manifest jsonb not null default '[]'::jsonb; alter table skill_versions add column if not exists forked_from_version_id uuid references skill_versions(id) on delete set null; create table if not exists skill_activations ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references organizations(id) on delete cascade, skill_name text not null, skill_version_id uuid references skill_versions(id) on delete set null, trigger text not null check (trigger in ('hard', 'probe')), job_id uuid, created_at timestamptz not null default now() ); create index if not exists idx_skill_activations_org_created on skill_activations (organization_id, created_at); create index if not exists idx_skill_activations_skill on skill_activations (organization_id, skill_name, created_at); -- RLS das tabelas org-scoped novas (skill_activations). skill_versions/pointers já -- estão no loop tenant_isolation do baseline; a leitura de catálogo é policy extra abaixo. -- A rotina da migration 0325 no lugar do laço enumerado: ela varre o catálogo -- procurando tabela de organização com RLS DESLIGADA, que neste ponto do arquivo -- é exatamente o conjunto que a lista enumerava (medido, tabela a tabela). do $$ begin perform public.fn_proteger_tabelas_de_organizacao(); end $$; -- Catálogo do marketplace: qualquer usuário autenticado LÊ as skills de plataforma -- (organization_id null). Só SELECT; escrita de plataforma continua service-role. drop policy if exists catalog_read_skill_versions on skill_versions; create policy catalog_read_skill_versions on skill_versions for select to authenticated using (organization_id is null); drop policy if exists catalog_read_skill_pointers on skill_pointers; create policy catalog_read_skill_pointers on skill_pointers for select to authenticated using (organization_id is null); -- ---- seed de skills de plataforma: catálogo inicial do marketplace (migration 0069) ---- -- 0069: seed de skills de plataforma (organization_id null) — catálogo inicial do -- marketplace de skills (Fase 2 do épico harness). Duas skills de fábrica, qualidade -- sobre quantidade: `objecao-preco` (vendas/genérico) e `agendamento` (clínicas/ -- serviços). Visíveis em toda org via a policy catalog_read_* acima. -- -- Idempotente: cada bloco só insere versão+ponteiro se o ponteiro de plataforma -- ainda não existir pra aquele nome — evita versão órfã (skill_versions é imutável, -- sem UPDATE possível) e respeita o unique index uniq_skill_pointers_platform em -- re-run. do $seed$ declare v_id uuid; begin if not exists ( select 1 from skill_pointers where organization_id is null and name = 'objecao-preco' ) then insert into skill_versions (organization_id, name, description, body, matcher) values ( null, 'objecao-preco', 'Playbook pra contornar objeção de preço no WhatsApp — diagnostica o motivo real por trás do "caro" antes de reagir, sem ceder desconto não autorizado.', $body$# Playbook: contornar objeção de preço ## Quando usar O lead reagiu ao preço/valor com resistência — direta ("tá caro") ou indireta (pediu desconto, comparou com concorrente, sumiu depois de saber o valor). Objetivo: entender a objeção real por trás do "caro" antes de reagir, e nunca ceder desconto que a organização não autorizou. ## Diagnóstico primeiro — "caro" quase nunca é sobre o número Antes de responder, identifique QUAL objeção está por trás: 1. **Orçamento real insuficiente** — "não tenho esse valor agora", "tá fora do meu orçamento" 2. **Não enxergou o valor ainda** — "por que custa isso?", silêncio após o preço, comparação vaga 3. **Comparação com concorrente/opção mais barata** — "vi mais barato em [X]", "achei um mais em conta" 4. **Tática de negociação** — pede desconto de cara, sem ter perguntado nada sobre o produto antes 5. **Timing** — "vou pensar", "deixa eu ver com [sócio/cônjuge]" disfarçado de objeção de preço Se não der pra diagnosticar pela mensagem, PERGUNTE antes de argumentar: "Só pra eu te ajudar melhor — é o valor em si, ou você tava esperando algo diferente do que ofereci?" ## If-then por diagnóstico **SE orçamento real insuficiente:** - Não insista no preço cheio. Ofereça: parcelamento, plano de entrada, versão reduzida — SÓ o que já estiver documentado como opção legítima na base de conhecimento do tenant. - NUNCA invente parcelamento ou desconto que não está documentado — se não souber a política, faça handoff. - Não deprecie o lead por não ter orçamento. Trate como informação, não como recusa. **SE não enxergou valor ainda:** - Não repita o preço. Reforce o resultado concreto que o cliente ganha (não a lista de features). - Use um número ou prova social real se a base de conhecimento tiver ("cliente X reduziu Y em Z semanas"). - Pergunta de reengajamento: "Faz sentido pra você o que isso resolve, ou ficou alguma dúvida sobre o que está incluso?" **SE comparação com concorrente:** - Não ataque o concorrente. Pergunte o que ele viu de diferente ("o que tinha nessa outra opção?") — geralmente revela se é preço mesmo ou outro critério (prazo, suporte, garantia). - Destaque o diferencial real do tenant (o que a base de conhecimento tiver de posicionamento), não genérico. **SE tática de negociação (pediu desconto sem contexto):** - Não ceda automaticamente. Pergunte o que faria sentido fechar hoje — muitas vezes revela o número real que o lead tem em mente. - Desconto SÓ se a organização tiver uma política documentada na base de conhecimento (RAG) pra esse cenário. Sem isso, handoff — decisão de preço fora do script é gate humano. **SE for timing disfarçado ("vou pensar"):** - Não pressione. Pergunte objetivamente o que falta pra decidir ("o que te ajudaria a decidir com mais segurança agora?"). - Agende um follow-up explícito (data/hora), não deixe em aberto — lead que "vai pensar" sem follow-up marcado esfria. ## Regras duras - Nunca prometa desconto, brinde ou condição especial que não esteja na base de conhecimento do tenant (RAG) ou explicitamente configurada no agente. - Nunca minta sobre "promoção que acaba hoje" ou crie urgência falsa. - Se o lead ficar hostil, ameaçar cancelar ou pedir falar com humano — handoff imediato, sem insistir mais uma vez. - Se depois de 2 trocas de mensagem a objeção não resolver, ofereça handoff explicitamente: "Quer que eu chame alguém do time pra fechar os detalhes com você?" ## Exemplos de resposta (tom, não copiar literal) - "Entendo — antes de eu te passar mais opção, me conta: é o valor em si ou esperava algo diferente do que te mostrei?" - "Faz sentido. Sobre o valor, hoje temos [opção documentada]. Isso ajudaria a caber no seu momento?" - "Show, deixa eu confirmar contigo: o que faria sentido fechar hoje pra você?" ## O que NÃO fazer - Não despeje a lista de preços de novo sem contexto. - Não ignore a objeção e mude de assunto. - Não use frases de pressão tipo "só até hoje" sem essa condição existir de verdade. $body$, '{"any_keywords": ["caro", "tá caro", "está caro", "muito caro", "desconto", "abaixar o preço", "mais barato", "achei mais barato", "fora do meu orçamento", "não cabe no orçamento", "valor alto", "preço alto"], "probe_keywords": ["quanto custa", "qual o valor", "quanto é", "parcelamento", "condições de pagamento", "forma de pagamento"]}'::jsonb ) returning id into v_id; insert into skill_pointers (organization_id, name, version_id) values (null, 'objecao-preco', v_id); end if; end $seed$; do $seed$ declare v_id uuid; begin if not exists ( select 1 from skill_pointers where organization_id is null and name = 'agendamento' ) then insert into skill_versions (organization_id, name, description, body, matcher) values ( null, 'agendamento', 'Playbook pra marcar/remarcar horário (consulta, visita, sessão) — oferece opções concretas de agenda real, nunca inventa disponibilidade, confirma por escrito antes de fechar.', $body$# Playbook: marcar horário/agendamento ## Quando usar O lead pede pra marcar um horário, consulta, visita, demonstração ou sessão — qualquer compromisso com data/hora. Comum em clínicas, imobiliárias (visitas), serviços e consultorias. ## Regra de ouro: nunca invente disponibilidade Se o agente não tiver acesso confirmado à agenda real do tenant (integração/consulta de disponibilidade), NÃO ofereça horário específico. Diga que vai confirmar e faça handoff, ou pergunte a preferência do lead e sinalize que a confirmação virá em seguida. Prometer um horário que depois não existe quebra confiança e gera reagendamento forçado. ## Fluxo padrão (if-then) **1. Identifique o serviço/motivo antes de oferecer horário** - SE o lead só disse "quero agendar" sem contexto → pergunte o motivo/serviço primeiro. Agendar sem saber o quê gera erro de encaixe (ex.: consulta de 20min marcada num slot de 1h de procedimento). **2. Ofereça opções fechadas, não uma pergunta aberta** - SE tiver acesso à agenda real → ofereça 2-3 horários concretos ("tenho terça 14h ou quarta 10h, qual funciona?"). Pergunta aberta tipo "qual horário você prefere?" gera ida e volta desnecessária e trava a conversa. - SE não tiver acesso à agenda → não invente. Diga algo como "vou confirmar a disponibilidade e te retorno em instantes" e sinalize handoff/task pra quem tem acesso. **3. Colete os dados obrigatórios antes de confirmar** - Nome completo do lead (ou confirme o que já está no CRM). - Serviço/motivo específico. - Unidade/local, se o tenant tiver mais de uma (clínica com filiais, imobiliária com múltiplos imóveis). - Se for reagendamento, o horário anterior a ser substituído. **4. Confirme por escrito antes de encerrar** - SE o lead aceitar um horário → repita de volta por escrito: "Confirmado: [serviço] dia [data] às [hora], em [local]. Confirma pra mim?" - Só considere o agendamento fechado depois do "sim"/confirmação explícita do lead — silêncio ou "ok" vago não é confirmação suficiente pra compromissos com custo de no-show alto (ex. consulta médica, visita a imóvel). **5. Reagendamento e cancelamento** - SE o lead pedir pra remarcar → trate como novo agendamento: pergunte novo horário disponível, e cancele/substitua o anterior explicitamente (não deixe os dois marcados). - SE o lead pedir pra cancelar → confirme o cancelamento e pergunte se quer remarcar pra outra data, sem pressionar. **6. Risco de no-show** - Se o negócio tiver política de confirmação D-1 documentada na base de conhecimento, siga-a (ex.: mensagem de lembrete automática). Se não houver, não invente política — apenas confirme o agendamento normalmente. ## Regras duras - Nunca confirme horário sem ter checado disponibilidade real (ou sem sinalizar que ainda vai confirmar). - Nunca marque dois compromissos conflitantes pro mesmo lead sem avisar. - Se o lead pedir um horário fora do funcionamento do negócio (ex. domingo, madrugada) e isso não estiver nas regras do tenant, não confirme — explique a janela real de atendimento. - Dado sensível (endereço completo, documento) só é coletado se o fluxo do tenant realmente exigir — não peça informação a mais que o agendamento precisa. ## Exemplos de resposta (tom, não copiar literal) - "Pra eu te encaixar certo: é pra qual serviço/motivo?" - "Tenho quinta às 15h ou sexta às 9h — qual fica melhor pra você?" - "Confirmado: consulta dia 28/07 às 15h, na unidade Centro. Pode confirmar pra mim?" ## O que NÃO fazer - Não pergunte "qual horário você prefere?" sem oferecer opções concretas quando você tem a agenda. - Não confirme agendamento sem resposta explícita do lead. - Não invente disponibilidade que você não checou. $body$, '{"any_keywords": ["agendar", "marcar horário", "marcar consulta", "marcar uma visita", "agenda", "que horas vocês", "horário disponível", "remarcar", "reagendar", "cancelar o horário", "desmarcar"], "probe_keywords": ["que horas", "qual dia", "tem vaga", "disponibilidade"]}'::jsonb ) returning id into v_id; insert into skill_pointers (organization_id, name, version_id) values (null, 'agendamento', v_id); end if; end $seed$; -- ---- ai_pricing backfill (migration 0113, renumerada de 0068) ---- -- O NNNN original colidia com `0068_skills_marketplace`. O arquivo foi renomeado -- (o timestamp `20260725150000` NAO mudou, entao a version do Supabase e a mesma e -- ninguem re-aplica). As strings `notes` abaixo continuam dizendo "backfill 0068" -- DE PROPOSITO: sao dado ja gravado nos bancos existentes, e reescrever dado para -- acompanhar renumeracao de arquivo criaria divergencia entre clone antigo e novo -- sem ganho nenhum. O guard `not exists` casa por `model`, nunca por `notes`. -- BUG: ai_pricing nascia VAZIA em toda instalação nova. Os seeds existem só na -- migration 0010, mas a cadeia fresh não sobe (as 10 primeiras são stubs -- `SELECT 1;`) e quem instala aplica este baseline, que semeia ai_models mas -- não ai_pricing. Com a tabela vazia, computeCost() devolve 0 sem log e o teto -- de ai_budgets nunca dispara. Derivado de ai_models: idempotente e -- auto-curativo, cobre qualquer modelo futuro do catálogo. -- -- `distinct on (m.model_id)`: ai_models é único por (provider, model_id), então -- o MESMO model_id pode existir sob dois provedores (ex.: openrouter e requesty) -- e, sem a deduplicação, o INSERT gerava DUAS linhas iguais dentro da mesma -- passada e a PK `ai_pricing_pkey` (só `model`) recusava com -- `duplicate key ... ai_pricing_pkey`. O `not exists` abaixo não resolve: os -- duplicados estão dentro do MESMO select. `distinct on` devolve UMA linha por -- model_id, e o `order by m.model_id, m.input_price_per_million_cents asc` -- escolhe o provedor de MENOR preço de entrada; empate por saída e depois por -- provedor, para a escolha ser determinística. insert into public.ai_pricing (model, prompt_cents_per_million_tokens, completion_cents_per_million_tokens, notes) select distinct on (m.model_id) m.model_id, m.input_price_per_million_cents, m.output_price_per_million_cents, 'backfill 0068 a partir de ai_models' from public.ai_models m where m.deprecated_at is null and m.input_price_per_million_cents is not null and m.output_price_per_million_cents is not null and not exists ( select 1 from public.ai_pricing p where p.model = m.model_id and p.superseded_at is null ) order by m.model_id, m.input_price_per_million_cents asc, m.output_price_per_million_cents asc, m.provider asc; -- Embedding do RAG — não vive em ai_models. insert into public.ai_pricing (model, embedding_cents_per_million_tokens, notes) select 'openai/text-embedding-3-small', 20, 'backfill 0068 (seed original da 0010)' where not exists ( select 1 from public.ai_pricing p where p.model = 'openai/text-embedding-3-small' and p.superseded_at is null ); -- ---- crm_leads owner_kind/owner_agent_id (migration 0070) ---- -- CRM Vivo · Wave 1 (CORE 1): a IA é dona do NEGÓCIO, não só da conversa. -- Mesmo padrão da 0032 (conversations.assignee_kind): backfill ANTES da -- constraint, CHECK de coerência em forma de implicação, drop+add re-aplicável. -- owner_agent_id aponta para ai_agents (identidade), NUNCA ai_agent_versions — -- o tooltip "Nome · vN" resolve a versão publicada por join na hora de exibir. alter table public.crm_leads add column if not exists owner_kind text check (owner_kind in ('user','ai')); alter table public.crm_leads add column if not exists owner_agent_id uuid references public.ai_agents(id) on delete set null; update public.crm_leads set owner_kind = 'user' where owner_user_id is not null and owner_kind is distinct from 'user'; update public.crm_leads set owner_kind = null where owner_user_id is null and owner_agent_id is null and owner_kind = 'user'; update public.crm_leads set owner_kind = 'ai' where owner_agent_id is not null and owner_kind is distinct from 'ai'; alter table public.crm_leads drop constraint if exists crm_leads_owner_kind_coherence; alter table public.crm_leads add constraint crm_leads_owner_kind_coherence check ( (owner_kind = 'user' and owner_user_id is not null and owner_agent_id is null) or (owner_kind = 'ai' and owner_agent_id is not null and owner_user_id is null) or (owner_kind is null) ); create index if not exists idx_crm_leads_owner_agent on public.crm_leads (organization_id, owner_agent_id) where owner_agent_id is not null; -- lead.assigned passa a cobrir o dono agente (corpo da 0043 + ramo do agente). create or replace function public.fn_emit_event_on_lead_change() returns trigger language plpgsql set search_path to 'public', 'pg_temp' as $$ begin if tg_op = 'INSERT' then return new; end if; if new.status is distinct from old.status then if new.status = 'won' then perform public.fn_log_event(new.organization_id, 'lead.won', jsonb_build_object('lead_id', new.id, 'value_cents', new.value_cents)); elsif new.status = 'lost' then perform public.fn_log_event(new.organization_id, 'lead.lost', jsonb_build_object('lead_id', new.id, 'lost_reason', new.lost_reason)); elsif new.status = 'open' then perform public.fn_log_event(new.organization_id, 'lead.reopened', jsonb_build_object('lead_id', new.id)); end if; end if; if new.owner_user_id is distinct from old.owner_user_id or new.owner_agent_id is distinct from old.owner_agent_id then perform public.fn_log_event(new.organization_id, 'lead.assigned', jsonb_build_object( 'lead_id', new.id, 'from_user_id', old.owner_user_id, 'to_user_id', new.owner_user_id, 'from_agent_id', old.owner_agent_id, 'to_agent_id', new.owner_agent_id, 'owner_kind', new.owner_kind)); end if; return new; end$$; -- ---- crm_lead_activities: barramento único da vida do lead (migration 0071) ---- -- Wave 3, bloco 1 do CRM Vivo. actor_kind/actor_agent_id/reason/evidence + -- stage_changed_at em crm_leads. Realtime desta tabela entra pelo array do loop -- de publicação, acima. -- -- FRONTEIRA DIRC: source_module/source_id = O QUE ORIGINOU (um ponteiro); -- evidence = O QUE SUSTENTA (N referências). evidence nunca repete o source_id. -- -- Idempotente e AUTO-CURATIVO: o backfill lê actor_kind/reason de metadata (onde -- o orquestrador de handoff já os grava hoje) ANTES de a constraint existir, e -- degrada para 'system' a linha marcada como 'ai' sem lastro nenhum — senão o -- update.sh de um clone quebraria ao criar a constraint. -- --------------------------------------------------------------------------- -- A. Colunas do barramento -- --------------------------------------------------------------------------- -- 'contact' é a PESSOA do outro lado — não 'lead': deste lado da casa lead é o -- NEGÓCIO (crm_leads), então 'lead' diria "o negócio falou". Também não -- adotamos 'agent'/'human' de agent_case_events: aqui 'agent' já é papel humano -- de RBAC (viewer < agent < manager < admin) e colidiria. alter table public.crm_lead_activities add column if not exists actor_kind text check (actor_kind in ('user','ai','system','rule','contact')); alter table public.crm_lead_activities add column if not exists actor_agent_id uuid references public.ai_agents(id) on delete set null; -- O PORQUÊ em texto legível por humano — é o que a timeline mostra embaixo da -- linha, e o que torna a decisão da IA discutível em vez de mágica. alter table public.crm_lead_activities add column if not exists reason text; -- O LASTRO: {"run_ids": [...], "trace_ids": [...]} — mesmo formato de -- flywheel_distiller_proposals.evidence. alter table public.crm_lead_activities add column if not exists evidence jsonb; comment on column public.crm_lead_activities.actor_kind is 'Quem agiu: user (humano do time) | ai (agente) | system (o produto) | rule (automação) | contact (a pessoa atendida). NUNCA "lead": lead aqui é o negócio.'; comment on column public.crm_lead_activities.evidence is 'O que SUSTENTA a atividade: {"run_ids":[],"trace_ids":[]} (N referências). Não confundir com source_module/source_id, que é O QUE ORIGINOU (um ponteiro). evidence nunca repete o source_id — origem não é prova.'; comment on column public.crm_lead_activities.reason is 'Por que esta atividade existe, em texto legível. Sem PII: é exibido na timeline e exportado no LGPD.'; -- --------------------------------------------------------------------------- -- B. Backfill A PARTIR DO JSONB — antes de qualquer default e antes da -- constraint (doutrina de migrations §8). -- -- actor_kind e reason JÁ são gravados hoje dentro de metadata -- (lib/ai/handoff/orchestrator.ts). Backfillar tudo como 'system' apagaria -- informação que já existe — seria perda de dado disfarçada de migration. -- --------------------------------------------------------------------------- -- ORDEM IMPORTA: o lastro sobe ANTES do ator. Promover para 'ai' e degradar -- depois funciona na primeira aplicação (a constraint ainda não existe) e -- QUEBRA no update.sh de um clone, onde ela já existe e recusa a linha no ato. -- Aqui nenhum estado intermediário inválido chega a existir. -- 1. Lastro que já existe em metadata sobe para a coluna (nunca inventado). update public.crm_lead_activities set evidence = jsonb_strip_nulls( jsonb_build_object( 'run_ids', metadata->'run_ids', 'trace_ids', metadata->'trace_ids' )) where evidence is null and (jsonb_typeof(metadata->'run_ids') = 'array' or jsonb_typeof(metadata->'trace_ids') = 'array'); -- 2. Atores que não são a IA: promoção direta. update public.crm_lead_activities set actor_kind = metadata->>'actor_kind' where actor_kind is null and metadata->>'actor_kind' in ('user','system','rule','contact'); -- 3. 'ai' só quando há execução que sustente a afirmação. update public.crm_lead_activities set actor_kind = 'ai' where actor_kind is null and metadata->>'actor_kind' = 'ai' and (coalesce(jsonb_array_length(evidence->'run_ids'), 0) > 0 or coalesce(jsonb_array_length(evidence->'trace_ids'), 0) > 0); -- 4. 'ai' sem lastro nenhum vira 'system': o registro continua inteiro (o -- reason é preservado); o que se recusa a afirmar é a AUTORIA da IA, porque -- não há execução que a sustente. update public.crm_lead_activities set actor_kind = 'system' where actor_kind is null and metadata->>'actor_kind' = 'ai'; update public.crm_lead_activities set reason = metadata->>'reason' where reason is null and nullif(metadata->>'reason', '') is not null; -- 5. Quem tem autor humano registrado é 'user' — o dado está na coluna, só não -- estava nomeado. update public.crm_lead_activities set actor_kind = 'user' where actor_kind is null and performed_by_user_id is not null; -- 6. Cura de banco onde a constraint ainda não existia e uma linha 'ai' entrou -- sem lastro (não alcançável depois que a constraint existe — por isso vem -- por último e é no-op no caminho feliz). update public.crm_lead_activities set actor_kind = 'system' where actor_kind = 'ai' and coalesce(jsonb_array_length(evidence->'run_ids'), 0) = 0 and coalesce(jsonb_array_length(evidence->'trace_ids'), 0) = 0; -- --------------------------------------------------------------------------- -- C. Constraint de lastro (drop+add — re-aplicável) -- -- A doutrina do CORE 3 ("número sem porquê não é gravado") aplicada uma wave -- antes: se a IA afirma algo na timeline, existe run_id ou trace_id que -- sustente. `jsonb_array_length(...) > 0`, NÃO `evidence ? 'run_ids'` — a -- segunda passa com array VAZIO, e lastro vazio não sustenta nada. -- --------------------------------------------------------------------------- alter table public.crm_lead_activities drop constraint if exists crm_lead_activities_ai_needs_evidence; -- A constraint NÃO é recriada aqui, e sim uma vez só mais abaixo, na versão que -- também aceita `llm_call_ids`. Recriá-la com a lista da época derrubava o -- update.sh de quem já tem atividade de IA cuja evidência é só `llm_call_ids`. -- Timeline por ator (o dossiê filtra "só o que a IA fez"), parcial porque a -- maioria das linhas não é de agente. create index if not exists idx_lead_activities_org_actor_agent on public.crm_lead_activities (organization_id, actor_agent_id, performed_at desc) where actor_agent_id is not null; -- --------------------------------------------------------------------------- -- D. stage_changed_at — de carona, porque esta wave passa a emitir atividade na -- mudança de estágio. Sem a coluna, "3d em Negociação" no card continua -- medindo tempo SEM RESPOSTA (last_activity_at) e mente sobre o estágio. -- Trigger puro: carimba a coluna, sem HTTP (doutrina — trigger nunca faz rede). -- --------------------------------------------------------------------------- alter table public.crm_leads add column if not exists stage_changed_at timestamptz; -- Bancos existentes: o melhor palito honesto é a criação do lead — nunca -- inventar uma data de entrada no estágio que ninguém registrou. update public.crm_leads set stage_changed_at = created_at where stage_changed_at is null; alter table public.crm_leads alter column stage_changed_at set default now(); create or replace function public.fn_stamp_stage_changed_at() returns trigger language plpgsql set search_path to 'public', 'pg_temp' as $$ begin if tg_op = 'INSERT' then new.stage_changed_at := coalesce(new.stage_changed_at, now()); elsif new.stage_id is distinct from old.stage_id then new.stage_changed_at := now(); end if; return new; end$$; drop trigger if exists trg_stamp_stage_changed_at on public.crm_leads; create trigger trg_stamp_stage_changed_at before insert or update on public.crm_leads for each row execute function public.fn_stamp_stage_changed_at(); comment on column public.crm_leads.stage_changed_at is 'Quando o lead entrou no estágio atual. Carimbado por trigger. É o relógio de "tempo no estágio" do card — distinto de last_activity_at, que é "tempo sem resposta".'; -- ---- evidence: lastro pode apontar para llm_calls (migration 0072) ---- -- Só AFROUXA a constraint (acrescenta uma terceira forma de lastro), então -- nenhuma linha existente passa a violá-la e o update.sh de clone não quebra. -- Idempotente por drop+add. alter table public.crm_lead_activities drop constraint if exists crm_lead_activities_ai_needs_evidence; alter table public.crm_lead_activities add constraint crm_lead_activities_ai_needs_evidence check ( actor_kind <> 'ai' or coalesce(jsonb_array_length(evidence->'run_ids'), 0) > 0 or coalesce(jsonb_array_length(evidence->'trace_ids'), 0) > 0 or coalesce(jsonb_array_length(evidence->'llm_call_ids'), 0) > 0 ); comment on column public.crm_lead_activities.evidence is 'O que SUSTENTA a atividade (N referências), cada chave apontando para UMA tabela: run_ids→ai_agent_runs, trace_ids→o trace do turno, llm_call_ids→llm_calls. Não confundir com source_module/source_id, que é O QUE ORIGINOU (um ponteiro). evidence nunca repete o source_id — origem não é prova.'; -- ---- identidade da próxima ação + caixa para o caso ambíguo (migration 0073) ---- -- Duas mudanças independentes, ambas idempotentes e auto-curativas. -- -- `next_action_seq` distingue "a mesma proposta" de "a mesma frase": o agente -- pode reescrever o mesmo texto significando outra coisa, e a autorização -- humana precisa saber QUAL proposta foi lida. Default 0 para as linhas que já -- existem — o primeiro reescrever leva a 1, que é o correto: a proposta que -- estava lá antes desta coluna nunca foi autorizada por ninguém. alter table public.lead_state add column if not exists next_action_seq bigint not null default 0; comment on column public.lead_state.next_action_seq is 'Identidade da proposta corrente. Incrementa a CADA escrita de next_action, inclusive quando o texto novo é idêntico ao anterior — é o que distingue "a mesma proposta" de "a mesma frase". A autorização humana carrega este número; a execução o compara. Nunca usar updated_at no lugar: ele se move por outras escritas do estado.'; -- Só ACRESCENTA um kind, então nenhuma linha existente passa a violar a -- constraint e o update.sh de um clone não quebra. Idempotente por drop+add. -- `followup_dead` está aqui porque a lista é a do BASELINE, não a do banco de -- dev: os dois divergiram, e o dev está com uma versão ANTERIOR da constraint -- (sem esse valor) enquanto lib/followup/engine.ts insere exatamente esse kind. -- Reconstruir a partir do banco apagaria o valor e mataria, em silêncio, o -- aviso de enrollment morto. A fonte de verdade é o arquivo versionado. -- (constraint agent_inbox_items_kind_check: definida uma vez só, no fim deste -- apêndice — ver "vocabulário completo". 'next_action_ambiguous' está lá.) -- ---- score de probabilidade com evidência, em tabela própria (migrations 0074+0075) ---- -- O baseline salta o passo intermediário de propósito: quem instala do zero não -- deve ganhar as colunas em `crm_leads` para perdê-las na linha seguinte. Para -- quem ATUALIZA (update.sh) o bloco continua correto — o `drop column if exists` -- e a migração de dados abaixo cuidam de um clone que já aplicou a 0074. create table if not exists public.crm_lead_scores ( lead_id uuid primary key references public.crm_leads(id) on delete cascade, organization_id uuid not null references public.organizations(id) on delete cascade, ai_probability numeric(5, 2), ai_probability_reason text, ai_probability_evidence jsonb not null default '{}'::jsonb, ai_probability_at timestamptz, ai_probability_band text, ai_probability_band_since timestamptz, updated_at timestamptz not null default now() ); -- `primary key (lead_id)` já garante o 1:1 — um lead tem no máximo uma linha de -- score. FK com `on delete cascade`: score é sobre o negócio, e sem o negócio -- não significa nada (não é histórico, é estado corrente). comment on table public.crm_lead_scores is 'Score de probabilidade por lead, FORA de crm_leads de propósito. Ver o cabeçalho da migration 0075: trazer estes campos de volta reintroduz o pulso que mente (board assina crm_leads) e o 409 fantasma (trava otimista do move + trigger de updated_at). Fica FORA da publicação supabase_realtime — recálculo é telemetria e não deve pintar card; quem pinta é a atividade emitida na travessia de faixa.'; -- ---- migra o que existir (clones que já aplicaram a 0074) ---- -- SQL DINÂMICO de propósito: numa instalação NOVA as colunas nunca existiram em -- `crm_leads`, e o Postgres faz o parse do comando ANTES de avaliar qualquer -- guarda — `where exists (select from information_schema...)` não salva, porque -- o erro é de parse, não de execução. Só `execute` adia a resolução do nome. do $$ begin if exists ( select 1 from information_schema.columns where table_schema = 'public' and table_name = 'crm_leads' and column_name = 'ai_probability' ) then execute $mig$ insert into public.crm_lead_scores ( lead_id, organization_id, ai_probability, ai_probability_reason, ai_probability_evidence, ai_probability_at, ai_probability_band, ai_probability_band_since ) select l.id, l.organization_id, l.ai_probability, l.ai_probability_reason, coalesce(l.ai_probability_evidence, '{}'::jsonb), l.ai_probability_at, l.ai_probability_band, l.ai_probability_band_since from public.crm_leads l where l.ai_probability is not null on conflict (lead_id) do nothing $mig$; end if; end $$; alter table public.crm_leads drop constraint if exists crm_leads_score_needs_reason, drop constraint if exists crm_leads_score_range, drop constraint if exists crm_leads_score_band_check, drop constraint if exists crm_leads_score_band_coherence; alter table public.crm_leads drop column if exists ai_probability, drop column if exists ai_probability_reason, drop column if exists ai_probability_evidence, drop column if exists ai_probability_at, drop column if exists ai_probability_band, drop column if exists ai_probability_band_since; -- ---- as mesmas garantias, agora na tabela certa ---- alter table public.crm_lead_scores drop constraint if exists crm_lead_scores_needs_reason; -- ---- evidência do score: FONTE ÚNICA (migrations 0076+0077) ---- -- ---- limpeza ANTES da constraint ---- -- Hoje são 0 linhas de 2, mas o CHECK nunca exigiu âncora DENTRO do fator: um -- clone pode ter `factors` sem âncora nenhuma, e essa linha passa hoje e -- reprovaria depois. Apaga o SCORE — não inventa âncora, porque âncora -- fabricada aponta para um registro que não sustenta nada e é indistinguível -- da verdadeira. update public.crm_lead_scores set ai_probability = null, ai_probability_reason = null, ai_probability_at = null, ai_probability_band = null, ai_probability_band_since = null, updated_at = now() where ai_probability is not null and ( coalesce(jsonb_array_length(ai_probability_evidence -> 'factors'), 0) = 0 or not (ai_probability_evidence @? '$.factors[*].ancora') ); alter table public.crm_lead_scores drop constraint if exists crm_lead_scores_needs_reason; alter table public.crm_lead_scores add constraint crm_lead_scores_needs_reason check ( ai_probability is null or ( ai_probability_reason is not null and btrim(ai_probability_reason) <> '' -- LEGÍVEL: o que o hover revela. and coalesce(jsonb_array_length(ai_probability_evidence -> 'factors'), 0) > 0 -- RASTREÁVEL: para onde o clique leva. `@?` com jsonpath em vez de -- subconsulta, que CHECK não aceita — e é o que permite exigir a âncora -- DENTRO do fator, mantendo a fonte única. and ai_probability_evidence @? '$.factors[*].ancora' ) ); comment on column public.crm_lead_scores.ai_probability_evidence is 'O QUE SUSTENTA o score, em FONTE ÚNICA: `factors` — cada parcela com `pontos` (com sinal), `frase` legível e, quando há ponto no tempo, `ancora` {kind,id}. A constraint exige factors não-vazio E pelo menos um fator com âncora: legível sem rastreável é adjetivo, rastreável sem legível é um id que ninguém entende. NÃO unificar com o formato de crm_lead_activities.evidence (arrays de ids por tabela): a diferença é deliberada e está explicada na migration 0077 — atividade cita FATOS de N tabelas, score cita PARCELAS de um cálculo. Unificar reintroduz as duas listas que já divergiram uma vez (0076), com o banco cobrando uma chave e a tela lendo outra.'; alter table public.crm_lead_scores drop constraint if exists crm_lead_scores_range; alter table public.crm_lead_scores add constraint crm_lead_scores_range check ( ai_probability is null or (ai_probability >= 0 and ai_probability <= 100) ); alter table public.crm_lead_scores drop constraint if exists crm_lead_scores_band_check; alter table public.crm_lead_scores add constraint crm_lead_scores_band_check check ( ai_probability_band is null or ai_probability_band = any (array['frio', 'morno', 'quente']::text[]) ); alter table public.crm_lead_scores drop constraint if exists crm_lead_scores_band_coherence; alter table public.crm_lead_scores add constraint crm_lead_scores_band_coherence check ( ai_probability_band is null or ai_probability is null or (ai_probability_band = 'quente' and ai_probability >= 65) or (ai_probability_band = 'morno' and ai_probability >= 35 and ai_probability <= 75) or (ai_probability_band = 'frio' and ai_probability <= 45) ); comment on column public.crm_lead_scores.ai_probability is 'Probabilidade 0-100 por FÓRMULA determinística sobre sinais que já existem — nunca chamada de modelo. Com fórmula, o reason é DERIVADO do cálculo e "número sem porquê" é impossível por construção; com modelo, a frase é gerada ao lado do número e a lei só pareceria cumprida. null = sinal insuficiente, e é estado legítimo: nunca zero.'; comment on column public.crm_lead_scores.ai_probability_reason is 'O PORQUÊ em português, obrigatório por constraint quando há score. Existe para o humano poder DISCORDAR: sem razão citável o número é opinião sem apelação.'; comment on column public.crm_lead_scores.ai_probability_evidence is 'O QUE SUSTENTA (N referências): activity_ids→crm_lead_activities, message_ids→messages, checkpoint_ids→lead_checkpoints. A constraint exige pelo menos uma — razão sem referência é adjetivo.'; comment on column public.crm_lead_scores.ai_probability_band is 'Faixa exibida. Persistida porque histerese precisa da faixa anterior; o CHECK de coerência torna divergir do score IMPOSSÍVEL de gravar, não só improvável. Cortes em FAIXA_LIMITES (lib/kanban/score-band.ts), fonte única do CHECK, do emissor e da UI.'; -- ---- tenancy ---- alter table public.crm_lead_scores enable row level security; drop policy if exists tenant_isolation_crm_lead_scores_all on public.crm_lead_scores; create policy tenant_isolation_crm_lead_scores_all on public.crm_lead_scores for all using (organization_id in (select fn_user_org_ids())) with check (organization_id in (select fn_user_org_ids())); create index if not exists idx_crm_lead_scores_org_band on public.crm_lead_scores (organization_id, ai_probability_band); -- FORA da publicação de realtime — é o ponto inteiro desta migration. Remover -- é defensivo: se um clone tiver a tabela publicada por engano, isto corrige. do $$ begin if exists ( select 1 from pg_publication_tables where pubname = 'supabase_realtime' and schemaname = 'public' and tablename = 'crm_lead_scores' ) then execute 'alter publication supabase_realtime drop table public.crm_lead_scores'; end if; end $$; -- ---- estado de risco do negócio (migration 0078) ---- -- 0078 — "esfriando" deixa de ser adjetivo calculado e vira ESTADO do negócio -- -- O QUE ESTAVA ERRADO: `classifyRisk` é função pura recalculada a cada leitura, -- e os únicos chamadores são rotas de LEITURA. Nenhum worker, nenhum emissor. -- Consequência medida: "esfriando" não existia até alguém abrir a tela, não -- tinha tipo de atividade (o vocabulário não sabia dizer "esfriou" nem -- "voltou"), e — o pior — não era RETIDO: não havia como responder "há quanto -- tempo está esfriando" nem "quantas vezes já esfriou e voltou". -- -- A ironia que motivou a wave: o cabeçalho de `lib/leads/risk-radar.ts` declara -- ser o desilhamento C1 da doutrina do sistema vivo — "uma demanda que esfriou -- e não tem próximo passo garantido está morrendo sem ninguém ver; o radar a -- torna visível". Mas tornar visível numa tela que ninguém é obrigado a abrir -- não é mecanismo anti-morte: é a mesma morte, com testemunha opcional. -- -- ⚠️ POR QUE FORA DE `crm_leads` — os dois motivos são os MESMOS da 0075 e -- valem palavra por palavra aqui; leia aquele cabeçalho antes de "simplificar" -- isto para dentro do lead: -- 1. o PULSO QUE MENTE — o board assina `crm_leads`; uma varredura de risco -- em lote faria dezenas de cards piscarem sem novidade nenhuma; -- 2. o 409 FANTASMA — `trg_crm_leads_updated_at` invalida a trava otimista do -- arrasto em voo, e o usuário recebe "alguém editou este lead" quando -- ninguém editou. -- -- ⚠️ MAS ESTA TABELA FICA **DENTRO** DA PUBLICAÇÃO DE REALTIME, ao contrário da -- `crm_lead_scores`. Isso NÃO contradiz a 0075 — é a mesma regra aplicada: -- "silêncio para telemetria, pulso para mudança de estado". Score é telemetria -- (número que se move sozinho o tempo todo); risco é transição discreta e rara -- que EXIGE ação humana. É por aqui que a borda de aviso aparece sem reload, -- sem tocar o lead — e é justamente não tocar o lead que preserva 1 e 2. -- -- A CONTRAPARTIDA, que vive no escritor e não dá para o banco garantir: só -- escreva quando o BUCKET MUDAR. Um `update` que só refresca `detected_at` -- publicaria evento de realtime sem mudança de estado, e o board voltaria a -- piscar à toa — o defeito que esta separação toda existe para impedir. create table if not exists public.crm_lead_risk_states ( lead_id uuid primary key references public.crm_leads(id) on delete cascade, organization_id uuid not null references public.organizations(id) on delete cascade, bucket text not null, -- QUANDO O NEGÓCIO ENTROU NESTE ESTADO, que não é quando o sistema percebeu. -- A distinção é o que torna o acervo honesto: os 48 negócios já frios no dia -- da estreia entram com `since` no passado (o instante em que de fato -- esfriaram) e `detected_at` em now. Sem os dois campos, o histórico diria -- que todos esfriaram no mesmo minuto — e diria isso para sempre. since timestamptz not null, detected_at timestamptz not null default now(), -- A janela do estágio usada na decisão, gravada JUNTO. Sem ela, mudar -- `expected_duration_hours` reescreve retroativamente o significado de todo -- estado já gravado, e ninguém consegue explicar por que aquele negócio -- esfriou "às 24h" se hoje o estágio diz 72h. cold_hours numeric not null, updated_at timestamptz not null default now() ); comment on table public.crm_lead_risk_states is 'Estado de risco por negócio (wave 7 — o ciclo). FORA de crm_leads pelos motivos da 0075 (pulso que mente, 409 fantasma), mas DENTRO da publicação supabase_realtime, ao contrário de crm_lead_scores: risco é mudança de estado, não telemetria. O escritor só grava quando o bucket muda.'; alter table public.crm_lead_risk_states drop constraint if exists crm_lead_risk_states_bucket_check; alter table public.crm_lead_risk_states add constraint crm_lead_risk_states_bucket_check check ( bucket = any (array['em_dia', 'em_voo', 'em_risco', 'critico']::text[]) ); -- Estado não começa no futuro. Trava o erro de gravar `since = now + janela` -- (o instante em que VAI esfriar) em vez de `last_activity_at + janela`. alter table public.crm_lead_risk_states drop constraint if exists crm_lead_risk_states_since_no_passado; alter table public.crm_lead_risk_states add constraint crm_lead_risk_states_since_no_passado check (since <= detected_at); alter table public.crm_lead_risk_states drop constraint if exists crm_lead_risk_states_cold_hours_positivo; alter table public.crm_lead_risk_states add constraint crm_lead_risk_states_cold_hours_positivo check (cold_hours > 0); alter table public.crm_lead_risk_states enable row level security; drop policy if exists tenant_isolation_crm_lead_risk_states_all on public.crm_lead_risk_states; create policy tenant_isolation_crm_lead_risk_states_all on public.crm_lead_risk_states for all using (organization_id in (select fn_user_org_ids())) with check (organization_id in (select fn_user_org_ids())); -- O radar lê "quem está em risco nesta org", nesta ordem. create index if not exists idx_crm_lead_risk_states_org_bucket on public.crm_lead_risk_states (organization_id, bucket, since); -- DENTRO da publicação — ver o cabeçalho. Idempotente: só adiciona se faltar. do $$ begin if not exists ( select 1 from pg_publication_tables where pubname = 'supabase_realtime' and schemaname = 'public' and tablename = 'crm_lead_risk_states' ) then execute 'alter publication supabase_realtime add table public.crm_lead_risk_states'; end if; end $$; -- ---- relógio do silêncio só conta interação (migration 0079) ---- -- 0079 — o relógio do silêncio para de ser zerado pela constatação do silêncio -- -- O DEFEITO, medido antes de escrever: `fn_update_last_activity_at` carimba -- `crm_leads.last_activity_at` para QUALQUER atividade, sem filtro de tipo. E -- `last_activity_at` é exatamente o relógio que decide o esfriamento. Então o -- produtor do estado apagaria o próprio estado ao registrá-lo: o negócio esfria, -- o sistema registra "esfriou", o trigger zera o relógio, e o negócio volta a -- "em dia" no mesmo instante. Vinte e quatro horas depois, de novo — uma linha -- de timeline por janela, para sempre, sem ninguém ter feito nada. -- -- Provado em transação revertida (lead 08b70b48, o mais frio com relógio -- não-nulo): 484h de silêncio, bucket CRÍTICO → insere uma atividade → 0h, -- bucket "em dia". -- -- A regra geral: CONSTATAR O SILÊNCIO NÃO É QUEBRAR O SILÊNCIO. Toda métrica do -- tipo "tempo desde o último X" é aniquilada por registrar observação sobre ela, -- se o registro contar como X. -- -- ⚠️ POR QUE LISTA POSITIVA E NÃO LISTA DE EXCEÇÕES — a assimetria é o ponto -- inteiro, e inverter parece inofensivo: -- -- com lista de exceções ("ignore lead_cooled"), um tipo NOVO de observação de -- sistema, daqui a seis meses, volta a carimbar o relógio. O negócio parece -- vivo estando morto: morte silenciosa, que é a doença que esta wave existe -- para curar; -- -- com lista positiva, um tipo novo de interação REAL fica de fora e o negócio -- parece frio estando quente: alarme falso, visível, alguém reclama e conserta. -- -- O default para o que ainda não existe tem de ser o erro BARULHENTO. -- -- AS ESCOLHAS DE FORA, cada uma com sua razão — revisáveis, mas não por -- distração: -- send_vetoed o envio não chegou ao cliente. Se contasse, um negócio em -- que a IA tenta e é barrada em looping pareceria vivo -- estando travado; -- handoff_triggered passar para humano é PROMESSA de atendimento, não -- atendimento. Se contasse, o negócio transferido e nunca -- atendido ficaria mascarado justamente na janela em que -- alguém deveria notar; -- next_action_dismissed o humano decidiu NÃO agir. O negócio fica sem próximo -- passo, que é a definição de risco na doutrina — deveria -- esfriar mais rápido, não menos. create or replace function public.fn_update_last_activity_at() returns trigger language plpgsql set search_path to 'public', 'pg_temp' as $function$ begin -- LISTA POSITIVA: só isto conta como "alguém tocou este negócio". Tipo que -- não está aqui NÃO quebra o silêncio — inclusive tipo que ainda não existe. -- Ver o cabeçalho da 0079 antes de acrescentar linha nesta lista. if new.type not in ( 'ai_turn', -- a IA falou com o cliente 'note', -- alguém registrou trabalho no negócio 'lead_edited', -- humano mexeu nos dados 'stage_changed', -- humano moveu o negócio 'next_action_approved' -- humano decidiu agir ) then return new; end if; update public.crm_leads set last_activity_at = greatest(coalesce(last_activity_at, '-infinity'::timestamptz), new.performed_at) where id = new.lead_id; if new.contact_id is not null then update public.contacts set last_activity_at = greatest(coalesce(last_activity_at, '-infinity'::timestamptz), new.performed_at) where id = new.contact_id; end if; return new; end$function$; comment on function public.fn_update_last_activity_at() is 'Carimba last_activity_at SÓ para tipos que contam como interação (lista positiva — ver migration 0079). Constatar o silêncio não é quebrar o silêncio: sem este filtro, a atividade que registra "este negócio esfriou" zera o próprio relógio que produziu o estado.'; -- ---- kind de caixa para o acervo de risco (migration 0080) ---- -- 0080 — o acervo de negócios já frios ganha UM item de caixa, com ação nomeada -- -- POR QUE ISTO EXISTE: quando o estado de risco (0078) começa a ser gravado, os -- negócios que JÁ estavam frios entram todos de uma vez. Medido no banco de -- desenvolvimento: 48 críticos e 2 em risco, de 66 abertos. -- -- Eles NÃO podem emitir atividade de timeline ("esfriou agora" seria falso: eles -- esfriaram há dias) e não podem entrar em silêncio, porque aí ficariam -- absolvidos por decreto de migração — cinquenta demandas abertas que ninguém -- decidiu abandonar e ninguém vai revisar. O `event_log` sozinho não resolve: -- é rastro de máquina, e não coloca ninguém para agir. -- -- Daí UM item agregado (não cinquenta) com dono e AÇÃO NOMEADA. Item de caixa -- sem ação nomeada é o ruído que a própria doutrina proíbe: "revise os 48 e -- decida quais encerrar" é trabalho; "48 negócios em risco" é um número. -- -- ⚠️ O `InboxKind` em `lib/agent-engine/db/repository.ts` é a outra ponta deste -- CHECK e JÁ FICOU TRÊS VALORES ATRÁS DO BANCO sem nada falhar. Kind novo aqui -- = kind novo lá, na mesma mudança. Está sendo feito neste commit. -- (constraint agent_inbox_items_kind_check: definida uma vez só, no fim deste -- apêndice — ver "vocabulário completo". 'risk_backlog_seeded' está lá.) -- ---- detected_at é carimbo do banco (migration 0081) ---- -- 0081 — `detected_at` deixa de ser dado do cliente e vira CARIMBO do banco -- -- O DEFEITO, encontrado rodando o observador de travessia (peça 5) e não por -- inspeção: `since` deriva de `last_activity_at`, que o trigger carimba com o -- `now()` do BANCO. `detected_at` vinha do processo Node. Medido nesta máquina: -- **o banco está 2 segundos à frente**. Um negócio tocado no instante anterior à -- passada do worker produzia `since > detected_at`, violava -- `crm_lead_risk_states_since_no_passado`, e o worker INTEIRO abortava. -- -- Omitir a coluna no `upsert` NÃO resolve, e é o detalhe que engana: o default -- só se aplica no INSERT. No UPDATE — que é o caminho de toda travessia depois -- da primeira — a coluna mantém o valor ANTIGO, e aí o `since` novo fica maior -- que um `detected_at` de dias atrás. Pior que o caso do relógio: acontece -- SEMPRE, não só na janela de dois segundos. -- -- A constraint estava certa e pegou o que eu não teria visto. O conserto não é -- afrouxá-la: é tirar do cliente a chance de errar. `detected_at` passa a ser -- carimbado pelo banco em TODA escrita, como `updated_at` — quem escreve não -- decide quando percebeu, o banco decide. -- -- ⚠️ A LIÇÃO É MAIOR QUE A COLUNA: `since` e `detected_at` são comparados por um -- CHECK, então TÊM de vir do mesmo relógio. O relógio do processo continua -- classificando (`classifyRisk` compara janelas de HORAS, onde segundos não -- mudam bucket); o CHECK compara INSTANTES, onde mudam. Grandezas diferentes -- toleram precisões diferentes, e confundir as duas foi exatamente o defeito. create or replace function public.fn_carimba_detected_at() returns trigger language plpgsql set search_path to 'public', 'pg_temp' as $function$ begin new.detected_at := now(); new.updated_at := now(); return new; end$function$; comment on function public.fn_carimba_detected_at() is 'detected_at é quando o BANCO percebeu, nunca quando o processo achou que percebeu. Ver migration 0081: com o valor vindo do cliente, a deriva de relógio violava o CHECK since <= detected_at e derrubava o worker inteiro.'; drop trigger if exists trg_crm_lead_risk_states_detected_at on public.crm_lead_risk_states; create trigger trg_crm_lead_risk_states_detected_at before insert or update on public.crm_lead_risk_states for each row execute function public.fn_carimba_detected_at(); -- ---- proposta de reativação com prazo (migration 0082) ---- -- 0082 — a proposta de reativação, com PRAZO e destino -- -- O cenário 23 fecha o ciclo da wave 7: o negócio esfria (0078-0081), alguém -- decide reativá-lo, o agente envia, a atividade fica registrada e o estado -- volta ao normal. -- -- ⚠️ O BLOCO OBRIGATÓRIO, e ele é RECURSIVO: a wave existe para "esfriando" -- virar DEMANDA, e a demanda que ela cria TAMBÉM PODE MORRER. Proposta de -- reativação que ninguém decide fica pendente para sempre, e o negócio volta a -- ser card parado AGORA COM UM BOTÃO EM CIMA — que é pior que antes: card -- parado sem nada se lê como abandono; com proposta pendente SIMULA ATENÇÃO, e -- simulação de atendimento ADIA a intervenção humana em vez de provocá-la. -- -- Daí `expires_at` ser NOT NULL: não existe proposta sem prazo nesta tabela, e -- é o banco que garante. No vencimento ela sai do card e vira item de caixa — -- demanda sem dono não mora no Kanban. -- -- ⚠️ POR QUE NÃO REUSAR `lead_state.next_action`: ela é por CONTATO (unique -- organization_id, contact_id) e o risco é por NEGÓCIO — um contato com dois -- negócios, um esfriando e outro quente, teria uma proposta só para os dois. E -- é texto livre, sem estado nem prazo. Caberia à força, distorcendo as duas -- coisas; a decisão é registrada aqui para ninguém "simplificar" depois. -- -- ⚠️ O ENVIO NÃO NASCE AQUI. Aceitar a proposta dispara o caminho que já existe -- (`cron_jobs` + o motor de follow-up). Esta tabela guarda a DECISÃO, não a -- mensagem — criar um segundo caminho de envio seria o mesmo erro de ter duas -- definições de "esfriando". create table if not exists public.crm_lead_reactivations ( id uuid primary key default gen_random_uuid(), lead_id uuid not null references public.crm_leads(id) on delete cascade, organization_id uuid not null references public.organizations(id) on delete cascade, status text not null default 'pending', -- Carimbados pelo BANCO, nunca pelo processo: a 0081 custou um worker -- abortando inteiro porque `since` vinha do banco e `detected_at` do Node, -- com 2 segundos de deriva entre eles. Instantes comparados entre si vêm do -- mesmo relógio. proposed_at timestamptz not null default now(), expires_at timestamptz not null, -- O texto que o agente enviaria. É proposta do AGENTE — texto de máquina —, -- não campo do negócio: vale a mesma regra do `reason` da timeline, e nenhum -- dado do lead entra aqui por cópia. draft text, decided_at timestamptz, decided_by_user_id uuid references auth.users(id) on delete set null, updated_at timestamptz not null default now() ); comment on table public.crm_lead_reactivations is 'Proposta de reativação de negócio esfriado (wave 7, cenário 23). SEMPRE com prazo: proposta que ninguém decide vira card parado com botão em cima, que simula atenção e adia a intervenção humana. No vencimento sai do card e vira item de caixa.'; alter table public.crm_lead_reactivations drop constraint if exists crm_lead_reactivations_status_check; alter table public.crm_lead_reactivations add constraint crm_lead_reactivations_status_check check ( status = any (array['pending', 'accepted', 'dismissed', 'expired']::text[]) ); -- Prazo no futuro em relação à proposta. Trava o erro de nascer vencida — que -- criaria um item de caixa no primeiro tick e ninguém entenderia de onde veio. alter table public.crm_lead_reactivations drop constraint if exists crm_lead_reactivations_prazo_no_futuro; alter table public.crm_lead_reactivations add constraint crm_lead_reactivations_prazo_no_futuro check (expires_at > proposed_at); -- Decisão e decisor andam juntos: status decidido SEM `decided_at` é registro -- que não sabe dizer quando aconteceu, e a timeline depende dessa resposta. alter table public.crm_lead_reactivations drop constraint if exists crm_lead_reactivations_decisao_datada; alter table public.crm_lead_reactivations add constraint crm_lead_reactivations_decisao_datada check ( (status = 'pending' and decided_at is null) or (status <> 'pending' and decided_at is not null) ); -- UMA proposta viva por negócio. Índice parcial: propostas já decididas ficam -- como histórico e não bloqueiam a próxima — o negócio pode esfriar de novo, e -- impedir isso deixaria o segundo esfriamento sem proposta nenhuma. create unique index if not exists uq_crm_lead_reactivations_uma_viva on public.crm_lead_reactivations (lead_id) where status = 'pending'; -- O worker de vencimento varre por aqui. create index if not exists idx_crm_lead_reactivations_vencendo on public.crm_lead_reactivations (organization_id, expires_at) where status = 'pending'; alter table public.crm_lead_reactivations enable row level security; drop policy if exists tenant_isolation_crm_lead_reactivations_all on public.crm_lead_reactivations; create policy tenant_isolation_crm_lead_reactivations_all on public.crm_lead_reactivations for all using (organization_id in (select fn_user_org_ids())) with check (organization_id in (select fn_user_org_ids())); -- `proposed_at` e `updated_at` são do banco, como na 0081. create or replace function public.fn_carimba_reativacao() returns trigger language plpgsql set search_path to 'public', 'pg_temp' as $function$ begin if tg_op = 'INSERT' then new.proposed_at := now(); end if; new.updated_at := now(); return new; end$function$; drop trigger if exists trg_crm_lead_reactivations_carimbo on public.crm_lead_reactivations; create trigger trg_crm_lead_reactivations_carimbo before insert or update on public.crm_lead_reactivations for each row execute function public.fn_carimba_reativacao(); -- DENTRO da publicação de realtime, pela mesma regra da 0078: proposta nascendo -- ou vencendo é MUDANÇA DE ESTADO que o card precisa mostrar sem reload — -- não é telemetria. do $$ begin if not exists ( select 1 from pg_publication_tables where pubname = 'supabase_realtime' and schemaname = 'public' and tablename = 'crm_lead_reactivations' ) then execute 'alter publication supabase_realtime add table public.crm_lead_reactivations'; end if; end $$; -- ---- kind de caixa para reativação vencida (migration 0083) ---- -- 0083 — a proposta de reativação vencida tem PARA ONDE IR -- -- Sem este kind, o vencimento seria uma linha de banco e nada mais: a proposta -- sai do card e desaparece. "Some do card" resolve a simulação de atenção e -- cria o problema anterior de volta — o negócio parado sem ninguém sabendo. -- -- A demanda que a wave criou não pode morrer por silêncio, e é EXATAMENTE a -- mesma forma da promessa cujo prazo depende de terceiro: sem fallback -- declarado, ela não é quebrada por decisão — ELA EXPIRA SOZINHA E NINGUÉM -- PERCEBE QUE DECIDIU. O item de caixa é o fallback, e ele tem dono e ação -- nomeada porque item sem ação é o ruído que a doutrina proíbe. -- -- ⚠️ O `InboxKind` em `lib/agent-engine/db/repository.ts` e o -- `Record` em `lib/ai/agent-inbox-copy.ts` são as outras -- pontas deste CHECK. Kind novo aqui = kind novo nos dois, no mesmo commit — -- e agora o invariante `vocabulario-banco-x-typescript` LÊ o arquivo de -- verdade, então esquecer não passa mais em silêncio. -- (constraint agent_inbox_items_kind_check: definida uma vez só, no fim deste -- apêndice — ver "vocabulário completo". 'reactivation_expired' está lá.) -- ---- agent_stage_hint (migration 0084) ---- -- 0084 — o funil do AGENTE aprende a falar o vocabulário do TENANT -- -- Dois vocabulários que hoje não se conhecem: -- -- AGENTE `lead_state.stage` — SETE valores fixos: new, contacted, -- qualifying, qualified, negotiating, won, lost; -- PIPELINE `crm_stages` — arbitrários por tenant. Medidos neste banco: -- clínica → Primeiro contato, Avaliação, Proposta enviada, -- Negociação, Tratamento fechado, Perdido -- e-commerce → Carrinho abandonado, Aguardando pagamento, Pago, -- Em separação, Enviado, Entregue, Pós-venda, Cancelado -- -- Sem ponte, o agente que avança o próprio funil não move o card — e o board -- mostra um negócio parado num estágio que já não é verdade. -- -- ⚠️ E A PONTE JÁ EXISTE PELA METADE: `crm_stages` tem `is_won` e `is_lost`. -- Dois dos sete já estão mapeados, por colunas booleanas. Esta migration NÃO -- cria um mecanismo novo — GENERALIZA um que existe incompleto. A consequência -- é o CHECK de coerência abaixo: sem ele, `is_won` e `agent_stage_hint` -- passariam a ser DUAS FONTES capazes de dizer coisas diferentes sobre o mesmo -- estágio, que é a família de defeito que esta entrega inteira encontrou seis -- vezes ("um lado mudou e o outro não acompanhou"). -- -- `null` é estado LEGÍTIMO e comum: "Em separação", "Pós-venda" e "Carrinho -- abandonado" não têm equivalente no funil do agente, e forçar um mapeamento -- seria inventar semântica que o tenant não declarou. alter table public.crm_stages add column if not exists agent_stage_hint text; comment on column public.crm_stages.agent_stage_hint is 'A que passo do funil do AGENTE este estágio corresponde (lead_state.stage). NULL = não corresponde a nenhum, que é legítimo. Coerente com is_won/is_lost por CHECK — ver migration 0084.'; alter table public.crm_stages drop constraint if exists crm_stages_agent_stage_hint_check; alter table public.crm_stages add constraint crm_stages_agent_stage_hint_check check ( agent_stage_hint is null or agent_stage_hint = any (array[ 'new', 'contacted', 'qualifying', 'qualified', 'negotiating', 'won', 'lost' ]::text[]) ); -- ⚠️ A COERÊNCIA COM O QUE JÁ EXISTIA. Um estágio marcado `is_won` que se -- anuncia como 'qualifying' faria o agente e o board discordarem sobre o mesmo -- lugar — e cada um estaria "certo" pela sua própria fonte. O CHECK torna a -- divergência IMPOSSÍVEL em vez de improvável. -- -- Nos dois sentidos, de propósito: `is_won` sem hint é o estado de hoje (válido, -- e é como todos os clones começam), mas hint='won' num estágio que não é de -- ganho seria mentira na direção oposta. alter table public.crm_stages drop constraint if exists crm_stages_hint_coerente_com_won_lost; alter table public.crm_stages add constraint crm_stages_hint_coerente_com_won_lost check ( (agent_stage_hint <> 'won' or is_won) and (agent_stage_hint <> 'lost' or is_lost) and (not is_won or agent_stage_hint is null or agent_stage_hint = 'won') and (not is_lost or agent_stage_hint is null or agent_stage_hint = 'lost') ); -- ⚠️ UM ESTÁGIO POR HINT, POR PIPELINE — e este índice é UNIQUE de propósito. -- -- Eu ia tratar a ambiguidade no resolvedor ("dois estágios com o mesmo hint → -- recuse mover"). O schema já respondeu melhor: `uniq_crm_stages_pipeline_won` e -- `uniq_crm_stages_pipeline_lost` JÁ EXISTEM, com o mesmo desenho — parcial, e -- excluindo arquivados. O produto já decidiu que "dois lugares de ganho no mesmo -- funil" é impossível, não improvável; não havia razão para os outros cinco -- passos serem tratados com menos rigor que os dois. -- -- E a diferença é grande: com o UNIQUE, o tenant DESCOBRE o erro ao configurar -- — o banco recusa na hora, com o estágio na frente dele. Com tratamento no -- resolvedor, ele descobriria meses depois, quando um negócio não se movesse e -- ninguém soubesse dizer por quê. -- -- `is_archived = false` acompanha o precedente: estágio arquivado é histórico e -- não disputa o mapeamento com o que está em uso. create unique index if not exists uniq_crm_stages_pipeline_hint on public.crm_stages (pipeline_id, agent_stage_hint) where agent_stage_hint is not null and is_archived = false; -- ---- backfill do que JÁ ESTÁ DECIDIDO, e só dele ---- -- `is_won`/`is_lost` são declaração explícita do tenant sobre aquele estágio; -- copiá-los para o hint não inventa nada. NENHUM outro estágio é adivinhado: -- inferir 'qualifying' de um nome como "Avaliação" seria o sistema decidindo -- semântica por semelhança de palavra, e erraria em português de outro nicho. update public.crm_stages set agent_stage_hint = 'won' where is_won and agent_stage_hint is null; update public.crm_stages set agent_stage_hint = 'lost' where is_lost and agent_stage_hint is null; -- ANALYZE: `ALTER TABLE` deixa o planner sem estatística e ele passa a errar a -- escolha de índice em consultas de crm_leads (medido no G4-04). Custa -- milissegundos numa tabela vazia. analyze public.crm_leads; -- ---- intent router: ai_routers/members/decisions + stickiness (migration 0085) ---- -- 0085: Intent Router (Fase 3 do épico harness — spec 2026-07-23). -- Um router pluga num channel_session e roteia a conversa para o agente cuja -- intenção declarada casa com a mensagem. Tabelas EDITÁVEIS (não versão+ponteiro): -- mutação é auditada por trigger, como ai_agents. create table if not exists ai_routers ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references organizations(id) on delete cascade, name text not null check (length(name) > 0), channel_session_id uuid not null references channel_sessions(id) on delete cascade, is_active boolean not null default true, config jsonb not null default jsonb_build_object( 'classifier_model', 'claude-haiku-4-5', 'sticky', true, 'min_confidence', 0.6 ), fallback_agent_id uuid references ai_agents(id) on delete set null, created_by uuid, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); -- Um router ativo por sessão de canal (dois routers disputando o mesmo número -- seria ambiguidade de roteamento — o índice parcial impede). create unique index if not exists uniq_ai_routers_active_session on ai_routers (channel_session_id) where is_active; create table if not exists ai_router_members ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references organizations(id) on delete cascade, router_id uuid not null references ai_routers(id) on delete cascade, agent_id uuid not null references ai_agents(id) on delete cascade, intent_name text not null check (length(intent_name) > 0), intent_description text not null check (length(intent_description) > 0), examples text[] not null default '{}', position integer not null default 0, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique (router_id, intent_name) ); create index if not exists idx_ai_router_members_router on ai_router_members (router_id, position); -- Telemetria de decisão (append-only, SEM PII — o texto do lead nunca entra aqui). create table if not exists ai_router_decisions ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references organizations(id) on delete cascade, router_id uuid references ai_routers(id) on delete set null, conversation_id uuid, intent_name text, confidence numeric(4,3), agent_id uuid references ai_agents(id) on delete set null, outcome text not null check (outcome in ('classified', 'sticky', 'reclassified', 'fallback', 'no_match', 'classifier_failed')), job_id uuid, created_at timestamptz not null default now() ); create index if not exists idx_ai_router_decisions_org_created on ai_router_decisions (organization_id, created_at); create index if not exists idx_ai_router_decisions_router on ai_router_decisions (router_id, created_at); -- Stickiness por conversa: qual agente o router entregou e qual intenção. alter table conversations add column if not exists active_ai_agent_id uuid references ai_agents(id) on delete set null; alter table conversations add column if not exists active_intent text; alter table conversations add column if not exists active_agent_set_at timestamptz; -- Triggers: audit de mutação + updated_at (padrão de ai_agents). drop trigger if exists trg_ai_routers_audit on ai_routers; create trigger trg_ai_routers_audit after insert or update or delete on ai_routers for each row execute function fn_audit_log_row(); drop trigger if exists trg_ai_routers_updated_at on ai_routers; create trigger trg_ai_routers_updated_at before update on ai_routers for each row execute function fn_set_updated_at(); drop trigger if exists trg_ai_router_members_audit on ai_router_members; create trigger trg_ai_router_members_audit after insert or update or delete on ai_router_members for each row execute function fn_audit_log_row(); drop trigger if exists trg_ai_router_members_updated_at on ai_router_members; create trigger trg_ai_router_members_updated_at before update on ai_router_members for each row execute function fn_set_updated_at(); -- RLS ligada e `anon` revogado nas três tabelas; as policies vêm logo abaixo -- (ai_router_decisions) e na 0150 (ai_routers, ai_router_members). do $$ declare t text; begin foreach t in array array['ai_routers', 'ai_router_members', 'ai_router_decisions'] loop execute format('alter table public.%I enable row level security', t); execute format('revoke all on public.%I from anon', t); end loop; end $$; -- A policy ampla (só "é da organização") fica só para ai_router_decisions. -- ai_routers e ai_router_members têm policies por papel desde a 0150, que -- derruba a ampla delas; recriá-la aqui fazia cada update.sh (em autocommit) -- reabrir escrita a qualquer membro da organização até aquele drop. drop policy if exists tenant_isolation_ai_router_decisions_all on public.ai_router_decisions; create policy tenant_isolation_ai_router_decisions_all on public.ai_router_decisions for all using (organization_id in (select * from public.fn_user_org_ids())) with check (organization_id in (select * from public.fn_user_org_ids())); -- ---- knowledge_searches: telemetria de busca de conhecimento (migration 0086) ---- -- 0086 — telemetria de busca de conhecimento (Fase 4 do épico do Harness) -- -- POR QUE UMA TABELA E NÃO `metrics`: a pergunta que o painel precisa responder -- é "quantas buscas QUASE acertaram", e ela exige o `top_score` da busca ao lado -- do `threshold` que estava valendo naquele momento. Métrica agregada perde -- exatamente essa distância, que é o número que vira ação. -- -- SEM PII, pelo mesmo contrato de `ai_router_decisions` (0085): não gravamos o -- texto da pergunta. `hits`/`top_score` respondem à pergunta do painel sem -- carregar conteúdo de conversa para uma tabela de telemetria de retenção longa. create table if not exists knowledge_searches ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references organizations(id) on delete cascade, job_id uuid, kb_version_id uuid, -- Quantos chunks passaram do limiar. 0 = o agente perguntou e a base não tinha. hits int not null default 0, -- Similaridade do MELHOR candidato, mesmo que abaixo do limiar. É o que -- distingue "a base não tem isso" (top_score baixo) de "a base tem e o limiar -- cortou" (top_score logo abaixo do threshold). top_score numeric, -- O limiar vigente na busca. Guardado junto porque ele é configurável por -- agente: comparar `top_score` com o limiar de HOJE mentiria sobre buscas de -- ontem. threshold numeric not null, created_at timestamptz not null default now() ); create index if not exists idx_knowledge_searches_org_created on knowledge_searches (organization_id, created_at desc); alter table knowledge_searches enable row level security; drop policy if exists tenant_isolation_knowledge_searches_all on knowledge_searches; create policy tenant_isolation_knowledge_searches_all on knowledge_searches for all using (organization_id in (select fn_user_org_ids())) with check (organization_id in (select fn_user_org_ids())); -- Defesa em profundidade, mesmo contrato da 0085: a policy já devolve zero linha -- para JWT anônimo (auth.uid() null => fn_user_org_ids() vazio), mas o grant que -- o Supabase concede por default privilege não tem razão de existir aqui — esta -- tabela nunca é lida sem sessão. Idempotente: revogar o que não está lá é no-op. revoke all on public.knowledge_searches from anon; -- ---- atualização self-service pela UI (migration 0089) ---- -- -- Duas tabelas de INSTÂNCIA (sem organization_id): descrevem o servidor, não o -- inquilino. Sem policy de RLS de propósito — com RLS habilitada e zero policy, -- `anon` e `authenticated` não leem nada pelo PostgREST; o acesso passa só pelas -- rotas /api/v1/system/*, que usam service role e checam is_platform_admin. create table if not exists public.system_version ( id smallint primary key default 1 check (id = 1), current_version text not null default '', current_sha text not null default '', off_release boolean not null default false, latest_version text not null default '', changelog_raw text not null default '', agent_last_seen_at timestamptz, update_requested_at timestamptz, update_requested_by uuid references auth.users(id) on delete set null, updated_at timestamptz not null default now() ); comment on table public.system_version is 'Singleton: versão instalada e disponível desta instância. Escrito pelo agente do host.'; insert into public.system_version (id) values (1) on conflict (id) do nothing; create table if not exists public.system_update_runs ( id uuid primary key default gen_random_uuid(), from_version text not null default '', to_version text not null default '', status text not null default 'dispatched' check (status in ('dispatched','success','failed','failed_rolled_back')), last_step text check (last_step in ('backup','codigo','banco')), requested_by uuid references auth.users(id) on delete set null, dispatched_at timestamptz not null default now(), finished_at timestamptz, log_tail text not null default '' ); comment on table public.system_update_runs is 'Histórico append de atualizações disparadas pela UI. status/last_step espelham RunStatus/RunStep em lib/system/update-run.ts.'; create index if not exists idx_system_update_runs_dispatched on public.system_update_runs (dispatched_at desc); alter table public.system_version enable row level security; alter table public.system_update_runs enable row level security; -- ---- índice único parcial: no máximo 1 run "dispatched" por vez (migration 0090) ---- -- -- Dedup defensivo ANTES da constraint (clone com dado inconsistente não pode -- quebrar o update.sh): mantém só a linha "dispatched" mais recente, marca -- as demais como failed. with ranked as ( select id, row_number() over (order by dispatched_at desc) as rn from public.system_update_runs where status = 'dispatched' ) update public.system_update_runs set status = 'failed', finished_at = coalesce(finished_at, now()) where id in (select id from ranked where rn > 1); create unique index if not exists uniq_system_update_runs_dispatched on public.system_update_runs (status) where status = 'dispatched'; -- ── A rodada conta o que aconteceu com o banco (migration 0276) ───────────── -- Disputa de lock com o sistema no ar, quantas retentativas, em qual passada o -- banco fechou. Nulo = o caminho não passou pelo banco (não medido, e a tela -- não inventa texto para isso). alter table public.system_update_runs add column if not exists disputa_de_banco boolean, add column if not exists retentativas_do_banco integer, add column if not exists passada_do_banco integer; comment on column public.system_update_runs.disputa_de_banco is 'Se a rodada do banco enfrentou disputa de lock com o sistema no ar. Nulo = o caminho não passou pelo banco.'; comment on column public.system_update_runs.retentativas_do_banco is 'Quantas retentativas a rodada do banco gastou antes de fechar (0 = fechou na primeira passada). Nulo = o caminho não passou pelo banco.'; comment on column public.system_update_runs.passada_do_banco is 'Em qual passada a rodada do banco fechou (1 = primeira). Nulo = o caminho não passou pelo banco.'; alter table public.system_update_runs drop constraint if exists system_update_runs_rodada_do_banco_coerente; alter table public.system_update_runs add constraint system_update_runs_rodada_do_banco_coerente check ( ( disputa_de_banco is null and retentativas_do_banco is null and passada_do_banco is null ) or ( disputa_de_banco is not null and retentativas_do_banco is not null and retentativas_do_banco >= 0 and passada_do_banco is not null and passada_do_banco >= 1 and passada_do_banco >= retentativas_do_banco + 1 ) ); -- ---- acentos nas etapas padrão do funil (migration 0092) ---- -- O seed do funil "Pedidos" criava "Em separacao" e "Pos-venda" sem acento — -- nomes visíveis no quadro principal, a tela mais usada do CRM. O seed acima já -- nasce corrigido; este bloco cura quem instalou antes. Idempotente e seguro: -- só casa com o nome padrão intacto, então tenant que renomeou a etapa não é -- tocado. update public.crm_stages set name = 'Em separação' where name = 'Em separacao'; update public.crm_stages set name = 'Pós-venda' where name = 'Pos-venda'; -- ---- channel provider (migration 0087) ---- -- O canal deixa de ser suposto. Até aqui o sistema INTEIRO supunha WAHA (o -- handler de envio chamava `getAdapter("waha")` com literal; o ctx de produção -- do `before_send` fixava `provider: 'waha'`), e supor o canal é o que impede o -- seam de existir. -- -- Tagged union, não flag: `provider` sozinho aceitaria uma sessão `meta_cloud` -- sem `meta_phone_number_id` e uma `waha` sem `waha_session_name` — as duas -- irresolvíveis na hora do envio, descobertas em runtime com a mensagem do -- cliente já aceita. O CHECK move a descoberta para o INSERT. -- -- `waha_session_name` perde o NOT NULL porque ele É o identificador de um dos -- ramos da união; obrigatório, `meta_cloud` seria inexprimível. A UNIQUE dele -- continua valendo (NULLs são distintos no Postgres). -- -- NÃO cria índice único de (organization_id, phone_number): a trava já existe -- desde o snapshot — `channel_sessions_phone_per_org_unique ... DEFERRABLE -- INITIALLY DEFERRED` — e já responde a "um número vive em UM provider", porque -- não olha o provider. Duplicá-la custaria checagem em toda escrita e colocaria -- uma trava NÃO-deferível ao lado de uma deferível, quebrando no meio qualquer -- transação que hoje troca números entre sessões. -- -- Auto-curativo para o `update.sh` de clone: o default preenche as linhas -- existentes no mesmo ALTER e `waha_session_name` era NOT NULL antes desta -- mudança — então TODA linha pré-existente já satisfaz o ramo 'waha' quando o -- CHECK nasce. Não há dado a deduplicar antes da constraint. alter table public.channel_sessions add column if not exists provider text not null default 'waha', add column if not exists meta_phone_number_id text, add column if not exists meta_waba_id text, add column if not exists meta_token_encrypted bytea; alter table public.channel_sessions alter column waha_session_name drop not null; -- (constraints channel_sessions_provider_check e channel_sessions_provider_ref_check: -- definidas uma vez só, no fim deste arquivo, com o vocabulário FINAL — regra de -- `tests/unit/baseline-constraint-reconstruida.test.ts`. Reconstruí-las aqui com a -- lista de dois providers faria o `update.sh` de um clone que já tem o terceiro -- falhar ao re-aplicar, e deixaria a tabela sem constraint entre o drop e o add -- que funciona.) -- ---- vocabulário do terceiro canal (migration 0131) ---- -- Espelho idempotente da 0116. Racional completo no arquivo da migration; o que -- importa aqui é POR QUE os dois CHECKs são recriados em vez de criados com -- `exception when duplicate_object`: os blocos acima já os criaram na versão de -- DOIS providers, e num clone que roda `update.sh` eles JÁ EXISTEM. O -- `duplicate_object` engoliria a versão nova em silêncio e o banco ficaria -- recusando a sessão do canal novo com o script tendo passado verde — a -- falha-em-verde que a doutrina do self-host proíbe. -- -- Ordem importa: a coluna nasce ANTES do CHECK que a referencia, e nullable, -- então nenhuma linha existente a viola. Toda linha pré-existente tem provider -- 'waha' ou 'meta_cloud' e já satisfaz o ramo correspondente — nada a -- deduplicar antes das constraints. alter table public.channel_sessions add column if not exists zernio_account_id text; -- wacalls (migration 0233, chamada de voz) — colunas do quarto provider, -- precisam existir antes das constraints abaixo referenciá-las. alter table public.channel_sessions add column if not exists wacalls_session_id text, add column if not exists wacalls_jid text, add column if not exists wacalls_paired_at timestamptz; -- datafy (migration 0387, canal Datafy — recorte do #1130) — colunas do provider -- que espelha a Cloud API, nullable e antes das constraints que as referenciam. alter table public.channel_sessions add column if not exists datafy_phone_number_id text, add column if not exists datafy_waba_id text, add column if not exists datafy_token_encrypted bytea; alter table public.channel_sessions drop constraint if exists channel_sessions_provider_check; alter table public.channel_sessions add constraint channel_sessions_provider_check -- 'wacalls' (0233), 'zernio_social' (0368) e 'datafy' (0387) somados AQUI — -- UM bloco só por constraint (não duplicar drop+add por migration). check (provider = any (array['waha'::text, 'meta_cloud'::text, 'zernio'::text, 'wacalls'::text, 'zernio_social'::text, 'datafy'::text])); alter table public.channel_sessions drop constraint if exists channel_sessions_provider_ref_check; alter table public.channel_sessions add constraint channel_sessions_provider_ref_check check ( (provider = 'waha' and waha_session_name is not null) or (provider = 'meta_cloud' and meta_phone_number_id is not null) or -- 'zernio_social' (migration 0368) endereça pelo MESMO `zernio_account_id`: -- é o mesmo intermediário, com outra superfície de canal. (provider in ('zernio', 'zernio_social') and zernio_account_id is not null) or (provider = 'wacalls' and wacalls_session_id is not null) or (provider = 'datafy' and datafy_phone_number_id is not null) ); comment on column public.channel_sessions.zernio_account_id is 'Identificador da conta conectada NO INTERMEDIÁRIO (accountId), não o phone_number_id da Meta. É o que endereça envio e webhook. Espelhado em lib/channels/session-ref.ts.'; -- ---- o que falta para o terceiro canal ENVIAR (migration 0132) ---- -- Espelho idempotente da 0117. Racional completo no arquivo da migration. -- -- `provider_conversation_id`: os dois canais existentes DERIVAM o destinatário -- do contato (chatId ou E.164). Este não — quem endereça é um id de 24 hex que -- o intermediário inventa e devolve pelo webhook. Sem guardá-lo não há como -- responder dentro da janela de 24h, porque o endpoint que aceita telefone -- exige template. Nome genérico: é o mesmo conceito para qualquer provider que -- enderece por thread própria, e carimbar nome de provider numa tabela que hoje -- não tem nenhum seria dívida gratuita. -- -- As duas colunas nascem NULLABLE e sem constraint nova: nenhuma linha -- existente as viola, então não há dado a corrigir antes — o `update.sh` de um -- clone com dados aplica isto sem tocar em nada. alter table public.conversations add column if not exists provider_conversation_id text; comment on column public.conversations.provider_conversation_id is 'Id que o PROVIDER dá a esta thread, quando ele endereça por thread própria em vez de por telefone. Chega pelo webhook de mensagem recebida. NULL = provider endereça por telefone (WAHA, oficial) ou ainda não houve primeiro contato.'; create index if not exists idx_conversations_provider_conversation_id on public.conversations (organization_id, provider_conversation_id) where provider_conversation_id is not null; alter table public.channel_sessions add column if not exists zernio_token_encrypted bytea; comment on column public.channel_sessions.zernio_token_encrypted is 'API key do intermediário, cifrada por fn_encrypt_oauth. Por SESSÃO (não por instalação) — mesma decisão da 0087 para o canal oficial.'; -- ---- carimbo do lookup de telefone (migration 0119) ---- -- Espelho idempotente da 0119. Racional completo no arquivo da migration. -- -- O canal identifica quem escreve por id opaco, e a tradução para telefone é -- povoada por ATIVIDADE — hoje não sabe, semana que vem talvez. Sem carimbar a -- tentativa, a varredura reprocessaria sempre os mesmos primeiros N e os do fim -- da fila nunca seriam perguntados. -- -- NULLABLE de propósito: NULL = nunca perguntado; com valor e telefone ainda -- nulo = o canal não sabia na ocasião. Um `not null default now()` colapsaria -- os dois e faria contato novo nascer como "já tentado". alter table public.contacts add column if not exists phone_lookup_at timestamptz; comment on column public.contacts.phone_lookup_at is 'Última vez que se PERGUNTOU ao canal o telefone por trás da identidade opaca. NULL = nunca perguntado. Com valor e phone_number ainda null = o canal não sabia na ocasião.'; create index if not exists idx_contacts_phone_lookup_pendente on public.contacts (organization_id, phone_lookup_at nulls first) where phone_number is null; comment on column public.channel_sessions.provider is 'Canal desta sessão. Vocabulário espelhado em lib/channels/types.ts → ChannelProvider (cobrado por tests/invariants/vocabulario-banco-x-typescript.test.ts).'; -- ---- meta templates (migration 0088) ---- -- Espelho idempotente da migration 0088. Racional completo no arquivo da -- migration; aqui fica o que o install.sh/update.sh precisa executar. create table if not exists public.meta_templates ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, waba_id text not null, name text not null, language text not null, status text not null, -- APPROVED | PENDING | REJECTED | PAUSED | DISABLED category text, rejected_reason text, quality_score text, -- Payload de `components` como a Meta o devolveu. É a ENTRADA de -- deriveTemplateContract; guardar o derivado seria a segunda fonte da verdade -- que esta fase inteira existe para eliminar. components jsonb not null, -- sha256 do contrato DERIVADO (não do jsonb cru): muda quando parâmetro muda, -- não muda quando alguém corrige uma vírgula no texto. contract_hash text not null, parameter_format text not null default 'POSITIONAL', synced_at timestamptz not null default now(), created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); do $$ begin alter table public.meta_templates add constraint meta_templates_parameter_format_check check (parameter_format in ('POSITIONAL', 'NAMED')); exception when duplicate_object then null; end $$; -- COMMENTs ficam no banco: aparecem em `\d+` e no Supabase Studio, onde quem -- inspeciona a tabela não tem este arquivo à mão. comment on table public.meta_templates is 'Espelho local dos templates hospedados na Meta (migration 0088). Derivado, nunca autoritativo: o schema vive na Meta. contract_hash sai de lib/channels/meta/contract-hash.ts e é a âncora da trava por obsolescência.'; comment on column public.meta_templates.status is 'Vocabulário ABERTO da Meta — deliberadamente SEM CHECK (ela cria estado novo sem avisar; CHECK quebraria o update.sh do clone). Espelhado em lib/channels/meta/template-sync.ts.'; comment on column public.meta_templates.contract_hash is 'SHA-256 do contrato DERIVADO (slots + parameter_format), não do JSON cru. Config de disparo guarda este hash; divergência = config obsoleta.'; comment on column public.meta_templates.parameter_format is 'Valor NORMALIZADO por deriveTemplateContract, não o cru da Meta — por isso TEM CHECK, ao contrário de status.'; create unique index if not exists meta_templates_org_waba_name_lang_uniq on public.meta_templates (organization_id, waba_id, name, language); -- `name` no fim serve a listagem ordenada da tela sem sort extra (índice dele, -- superset do meu — combinado em vez de escolhido). create index if not exists meta_templates_org_status_idx on public.meta_templates (organization_id, status, name); alter table public.meta_templates enable row level security; drop policy if exists tenant_isolation_meta_templates_all on public.meta_templates; create policy tenant_isolation_meta_templates_all on public.meta_templates for all using (organization_id in (select public.fn_user_org_ids())) with check (organization_id in (select public.fn_user_org_ids())); -- ---- message type: template (migration 0091) ---- -- Espelho idempotente. Racional completo no arquivo da migration: `template` NAO -- podia ser gravado como 'text' porque o tipo e a unica coluna que carrega custo -- (template e cobrado por entrega), conformidade de janela, e o que o contato viu. -- Backfill: nenhum por construcao — o conjunto antigo e subconjunto do novo. do $$ begin alter table public.messages drop constraint if exists messages_type_check; alter table public.messages add constraint messages_type_check check (type = any (array[ 'text', 'image', 'video', 'audio', 'document', 'sticker', 'location', 'contact', 'reaction', 'system', -- novo: envio de template aprovado (canal oficial, fora da janela de 24h) 'template' ])); end $$; -- Nome do template disparado. Fica em coluna, não só em `metadata`, porque é o que -- responde "quanto gastei com o template X?" sem varrer jsonb — e porque `metadata` -- é vocabulário aberto por desenho, o que tornaria a consulta uma aposta. alter table public.messages add column if not exists template_name text, add column if not exists template_language text; comment on column public.messages.template_name is 'Nome do template da Meta quando type = template. Null nos demais tipos. Em coluna (não em metadata) porque é a chave de custo e de auditoria de janela.'; create index if not exists messages_template_idx on public.messages (organization_id, template_name) where template_name is not null; -- ---- "não consegui comparar" não é "está em dia" (migration 0093) ---- -- -- Sem esta coluna, o agente que falha ao comparar (clone raso sem conseguir -- completar a história) simplesmente não anuncia versão nova, e a tela lê a -- ausência como boa notícia — informando "é a mais recente" a uma instalação -- atrasada. Idempotente: `add column if not exists` com default. alter table public.system_version add column if not exists compare_failed boolean not null default false; comment on column public.system_version.compare_failed is 'true quando o agente do host não conseguiu comparar a versão instalada com a última publicada (ex.: clone raso sem conseguir completar a história). A tela mostra "não consegui checar", nunca "você está em dia".'; -- ---- distingue "à frente da publicada" de "nunca houve publicada" (migration 0094) ---- -- -- Sem esta coluna, um fork sem nenhuma tag `v*` recebia a MESMA combinação -- (off_release=true, latest_version='', compare_failed=false) de uma -- instalação que já contém a última tag publicada — e a tela afirmava "você -- está à frente da versão publicada" sem versão publicada nenhuma existir. -- Default `true` preserva o comportamento anterior para agentes antigos. alter table public.system_version add column if not exists has_known_release boolean not null default true; comment on column public.system_version.has_known_release is 'false quando o agente do host nunca viu nenhuma tag v* no repositório (fork sem releases). Default true preserva o comportamento anterior para agentes antigos que ainda não enviam este campo.'; -- ---- orçamento de IA conta o runtime real (migration 0095) ---- -- O gatilho de consumo existia só em ai_invocations (workers legados); o -- agent-engine grava em llm_calls, então o contador ficava zerado e o alarme -- de 80% / pausa em 100% nunca disparavam. Idempotente. drop trigger if exists trg_llm_calls_budget on public.llm_calls; create trigger trg_llm_calls_budget after insert on public.llm_calls for each row execute function public.fn_update_budget_consumption(); -- ESTE RECOMPUTO NÃO É O QUE VALE, e não dá para consertá-lo aqui. Desde a 0130 -- as linhas de `ai_invocations` são copiadas para `llm_calls`, então somar as -- duas tabelas inteiras conta a MESMA linha duas vezes; a correção precisa da -- coluna `legacy_invocation_id`, que só nasce lá embaixo, no bloco da 0130 — -- referenciá-la aqui derruba o install com `column c2.legacy_invocation_id does -- not exist` (medido). Quem dá a última palavra é o bloco da migration 0140, -- depois do backfill: ele ATRIBUI o gasto real do mês, contando cada linha uma -- vez só, e o valor deste bloco é sobrescrito. insert into public.ai_budgets (organization_id, current_month_consumed_cents) select o.id, coalesce((select sum(cost_cents) from public.llm_calls c where c.organization_id = o.id and c.created_at >= date_trunc('month', now())), 0) + coalesce((select sum(cost_cents) from public.ai_invocations i where i.organization_id = o.id and i.created_at >= date_trunc('month', now())), 0) from public.organizations o on conflict (organization_id) do update set current_month_consumed_cents = excluded.current_month_consumed_cents, updated_at = now(); -- ---- modelo de LLM padrão da organização (migration 0096) ---- -- Sem isto o caminho GENÉRICO do turno (documentado em resolve-turn-agent.ts) -- fica sem modelo e o turno morre com 'modelo LLM não definido'. Idempotente. update public.organizations o set settings = jsonb_set( coalesce(o.settings, '{}'::jsonb), '{llm}', coalesce(o.settings->'llm', '{}'::jsonb) || jsonb_build_object( 'provider', coalesce(o.settings->'llm'->>'provider', 'anthropic'), 'default_model', coalesce( (select m.model_id from public.ai_models m where m.provider = coalesce(o.settings->'llm'->>'provider', 'anthropic') and m.is_default_for_provider and m.deprecated_at is null limit 1), 'claude-sonnet-4-6' ) ), true ) where coalesce(o.settings->'llm'->>'default_model', '') = ''; -- Organização nova já nasce configurada: o mesmo seed que cria o funil padrão -- passa a semear o modelo. CREATE OR REPLACE FUNCTION "public"."fn_seed_org_llm_defaults"() RETURNS "trigger" LANGUAGE "plpgsql" SET "search_path" TO 'public', 'pg_temp' AS $$ begin if coalesce(new.settings->'llm'->>'default_model', '') = '' then new.settings := jsonb_set( coalesce(new.settings, '{}'::jsonb), '{llm}', coalesce(new.settings->'llm', '{}'::jsonb) || jsonb_build_object( 'provider', 'anthropic', 'default_model', coalesce( (select m.model_id from public.ai_models m where m.provider = 'anthropic' and m.is_default_for_provider and m.deprecated_at is null limit 1), 'claude-sonnet-4-6' ) ), true ); end if; return new; end; $$; drop trigger if exists trg_seed_org_llm_defaults on public.organizations; create trigger trg_seed_org_llm_defaults before insert on public.organizations for each row execute function public.fn_seed_org_llm_defaults(); -- ---- limiar do RAG calibrado (migration 0097) ---- -- 0.72 descartava toda parafrase; medido: relevante 0.49-0.85, irrelevante 0.27. alter table public.ai_agents alter column config set default jsonb_build_object( 'temperature', 0.3, 'max_tokens', 1024, 'rag_top_k', 5, 'rag_similarity_threshold', 0.40, 'context_message_window', 20, 'confidence_threshold', 0.55, 'sentiment_threshold', 0.3, 'zero_data_retention', false); -- Cura quem está com o padrão antigo INTACTO. Quem já ajustou o valor na mão -- não é tocado. update public.ai_agents set config = jsonb_set(config, '{rag_similarity_threshold}', '0.40'::jsonb) where (config->>'rag_similarity_threshold')::numeric = 0.72; -- Default da função de busca, para quem chama sem passar o limiar. CREATE OR REPLACE FUNCTION "public"."retrieve_top_k_chunks"("p_organization_id" "uuid", "p_kb_version_id" "uuid", "p_embedding" "public"."vector", "p_k" integer DEFAULT 5, "p_threshold" real DEFAULT 0.40) RETURNS TABLE("chunk_id" "uuid", "knowledge_source_id" "uuid", "content" "text", "similarity" real, "metadata" "jsonb") LANGUAGE "sql" STABLE SECURITY DEFINER SET "search_path" TO 'public', 'pg_temp' AS $$ select c.id as chunk_id, c.knowledge_source_id, c.content, (1 - (c.embedding <=> p_embedding))::real as similarity, c.metadata from public.ai_chunks c where c.organization_id = p_organization_id and c.kb_version_id = p_kb_version_id and (1 - (c.embedding <=> p_embedding)) >= p_threshold order by c.embedding <=> p_embedding asc limit greatest(p_k, 0); $$; -- ---- idioma do contato (migration 0098) ---- -- O ai-response-worker seleciona contacts.locale e o prompt usa {{contact_locale}}, -- mas a coluna nunca existiu no snapshot: em toda instalação self-host o PostgREST -- respondia "column contacts_1.locale does not exist" e o worker pulava TODA -- conversa, com o erro escondido num log de nível info. -- NULL = herda o padrão da organização (o código resolve com fallback pt-BR). -- Sem CHECK: locale é vocabulário aberto; constraint aqui quebraria o update.sh -- de clones com valores legados. alter table public.contacts add column if not exists locale text; comment on column public.contacts.locale is 'Idioma preferido do contato (ex.: pt-BR, es-PY). NULL = herda o padrão da organização; o código resolve com fallback pt-BR.'; -- ---- foto de perfil do contato (migration 0099) ---- -- O WAHA devolve a foto como URL assinada do CDN do WhatsApp, com validade de -- ~9 dias (medido). Guardar a URL crua faria todo avatar quebrar em uma semana, -- em silêncio. Por isso o arquivo vai para o bucket whatsapp-media e aqui fica -- só o CAMINHO — mesmo padrão de messages.media_storage_path. É também o que -- torna a LGPD cumprível: foto é dado pessoal e some na anonimização, o que só -- se garante sobre arquivo próprio. alter table public.contacts add column if not exists avatar_storage_path text, add column if not exists avatar_updated_at timestamptz; comment on column public.contacts.avatar_storage_path is 'Caminho da foto de perfil no bucket whatsapp-media. NULL = sem foto. Guardamos o arquivo, não a URL do WhatsApp, que expira em ~9 dias.'; comment on column public.contacts.avatar_updated_at is 'Quando a foto foi buscada pela última vez. NULL = nunca tentado. Usado pelo cron de refresh para escolher quem revisitar.'; -- Índice PARCIAL, e não composto liderado por organization_id: a varredura do -- cron não filtra organização nenhuma (varre a plataforma inteira), então com a -- coluna líder irrestrita o planner não percorre em ordem de avatar_updated_at e -- cai em seq scan + top-N sort. Medido em pg17 com 20.000 contatos, 17.665 -- elegíveis, melhor de 3: composto 10,272 ms · parcial 0,090 ms (114x), e o -- parcial ocupa 160 kB porque só indexa quem o cron pode escolher. -- O predicado de data fica fora do WHERE: now() não é imutável e o Postgres -- recusa. Não faz falta — os NULL vêm primeiro e o Index Scan para nas 25. -- O drop é auto-curativo e só dispara em quem tenha a versão composta: em banco -- novo, e na re-aplicação do update.sh, o bloco é no-op (nada é reconstruído). do $$ begin if exists ( select 1 from pg_indexes where schemaname = 'public' and indexname = 'idx_contacts_avatar_refresh' and indexdef ilike '%organization_id%' ) then execute 'drop index public.idx_contacts_avatar_refresh'; end if; end $$; create index if not exists idx_contacts_avatar_refresh on public.contacts (avatar_updated_at nulls first) where wa_identity is not null and is_anonymized = false; -- ---- autoria da configuração da operação (migration 0101) ---- -- Quem mexeu na CONFIGURAÇÃO, ao lado do estado que mudou. -- -- ⚠️ POR QUE EXISTE. Até o agente de IA ganhar mãos sobre a operação (épico IA -- 360), toda mudança em etapa de funil, entrada automática de contatos e regra -- automática vinha de uma pessoa `manager+` — quem olhava a tela era, por -- construção, quem tinha mudado. Uma regra automática ligada pelo assistente -- muda o comportamento do sistema quando ninguém está olhando: sem esta coluna, -- a tela mostra "Ativa" e não diz mais nada. O `api_audit_log` registra, mas -- nenhuma tela de configuração o lê — e log que não aparece é log morto -- (docs/doctrine/sistema-vivo.md, invariante 3). -- -- ⚠️ NÃO HÁ COLUNA DE "QUAL AGENTE", E É DELIBERADO: `Actor.id` para `ai_agent` -- ainda não é chave estável de agente nos três caminhos — `lib/mcp/auth.ts` -- devolve o id do RUN ou do TOKEN no caminho do cliente MCP externo —, então uma -- FK para `ai_agents(id)` recusaria a escrita com 23503 justamente ali. -- -- Idempotente e auto-curativo: colunas nullable, sem backfill (linha antiga fica -- com autoria desconhecida, que é a verdade sobre ela). O CHECK viaja inline no -- `add column if not exists` — em banco que já tem a coluna o comando inteiro é -- no-op, que é o que o `update.sh` do clone precisa. alter table public.crm_stages add column if not exists last_change_actor_kind text check (last_change_actor_kind in ('user','ai','system')); alter table public.crm_stages add column if not exists last_change_at timestamptz; alter table public.webhook_sources add column if not exists last_change_actor_kind text check (last_change_actor_kind in ('user','ai','system')); alter table public.webhook_sources add column if not exists last_change_at timestamptz; alter table public.automation_rules add column if not exists last_change_actor_kind text check (last_change_actor_kind in ('user','ai','system')); alter table public.automation_rules add column if not exists last_change_at timestamptz; comment on column public.crm_stages.last_change_actor_kind is 'Espécie de quem fez a última mudança de configuração desta etapa: user | ai | system. NULL = anterior à 0101.'; comment on column public.webhook_sources.last_change_actor_kind is 'Espécie de quem fez a última mudança nesta entrada automática de contatos: user | ai | system. NULL = anterior à 0101.'; comment on column public.automation_rules.last_change_actor_kind is 'Espécie de quem ligou/desligou/editou esta regra por último: user | ai | system. NULL = anterior à 0101.'; notify pgrst, 'reload schema'; -- ---- uso das capacidades do agente (migration 0103) ---- -- Toda chamada de tool do agente já era auditada em api_audit_log -- (action='mcp.tool_called') e NENHUMA tela lia — log invisível é log morto -- (invariante 3 da doutrina do sistema vivo). Esta função é o leitor. -- -- Vive no banco porque não há FK entre api_audit_log e ai_agent_runs: amarrar os -- dois no Node exigiria mandar de volta os ids de ~9.000 runs mensais de um -- tenant PME num in(...). O elo é api_audit_log.request_id = ai_agent_runs.id (o -- runtime usa o id do run como requestId do McpContext); request_id é text, daí -- o cast. -- -- A janela é aplicada nos DOIS lados (r.started_at e a.created_at): os runs saem -- de ai_agent_runs_agent_idx, e a data no audit deixa o planner cortar por -- idx_audit_action_time em vez de varrer uma tabela que retém 5 anos. Medido em -- pg17 com 708.020 linhas de audit (10,2% tool calls) e 36.000 runs, melhor de -- 3: sem a janela no audit 345,7 ms · com a janela 224,0 ms · com um índice -- parcial dedicado 165,0 ms — o índice NÃO foi adotado, porque api_audit_log é -- append-only de escrita altíssima e 60 ms numa aba não pagam manutenção de -- índice em todo INSERT. -- -- em_teste separa o que veio de execução de teste (is_dry_run): sem isso a tela -- diria "usada 4 vezes" quando as 4 foram o dono clicando em Testar. -- -- security invoker: pelo service role (rota já resolve a org do cookie) a RLS não -- se aplica; por usuário autenticado, audit_log_select continua exigindo admin. create or replace function public.fn_agent_tool_usage( p_organization_id uuid, p_agent_id uuid, p_since timestamptz ) returns table ( tool_name text, total bigint, falhas bigint, em_teste bigint, ultima_vez timestamptz ) language sql stable security invoker set search_path = public as $$ select a.metadata->>'tool_name' as tool_name, count(*)::bigint as total, count(*) filter (where a.metadata->>'success' = 'false')::bigint as falhas, count(*) filter (where r.is_dry_run)::bigint as em_teste, max(a.created_at) as ultima_vez from public.ai_agent_runs r join public.api_audit_log a on a.request_id = r.id::text and a.action = 'mcp.tool_called' and a.organization_id = p_organization_id and a.created_at >= p_since where r.organization_id = p_organization_id and r.agent_id = p_agent_id and r.started_at >= p_since and a.metadata->>'tool_name' is not null group by 1 $$; comment on function public.fn_agent_tool_usage(uuid, uuid, timestamptz) is 'Uso das capacidades (tools MCP) de um agente: total, falhas, quantos vieram de execução de teste e a última vez. Elo audit<->run é api_audit_log.request_id = ai_agent_runs.id.'; grant execute on function public.fn_agent_tool_usage(uuid, uuid, timestamptz) to authenticated, service_role; -- ---- retorno cancelado ≠ retorno disparado (migration 0102) ---------------- -- `cron_jobs.enabled = false` significa DUAS coisas: o one-shot disparou ou -- alguém desmarcou. Enquanto forem a mesma linha no banco, o agente não sabe, ao -- retomar, que o humano cancelou o retorno — o invariante 2 da doutrina -- (continuidade humano→IA) fica pela metade — e a fila mostra "concluída" para -- um retorno que ninguém executou. -- -- Sem backfill: as linhas antigas ficam com `cancelled_at` nulo porque essa é a -- verdade disponível. Não se sabe quais foram canceladas antes desta coluna -- existir, e chutar seria gravar ficção em histórico. alter table public.cron_jobs add column if not exists cancelled_at timestamptz, add column if not exists cancel_reason text; comment on column public.cron_jobs.cancelled_at is 'Quando o retorno foi desmarcado. NULL = nunca cancelado (disparou ou ainda vai disparar). Distingue cancelado de disparado, que enabled=false sozinho não distingue.'; comment on column public.cron_jobs.cancel_reason is 'Por que foi desmarcado, em texto curto e sem PII. Mesmo vocabulário de followup_enrollments.cancel_reason.'; create index if not exists idx_cron_jobs_retorno_vivo on public.cron_jobs (organization_id, contact_id, next_run_at) where enabled = true and job_kind = 'followup_turn'; -- ---- agent_case_events.kind ganha 'agent_noted' (migration 0100) ---- -- O agente conseguia ABRIR um chamado e nada mais: não havia valor honesto no -- CHECK para "o agente registrou o que aconteceu depois" ('lead_provided' é a -- informação que o LEAD deu, 'human_replied' é a pessoa). Sem esse registro, o -- atendente seguinte que abre o chamado começa do zero. -- Idempotente e auto-curativo: a lista só CRESCE, então nenhuma linha existente -- viola a constraint nova e não há dado a corrigir antes de criá-la. alter table public.agent_case_events drop constraint if exists agent_case_events_kind_check; alter table public.agent_case_events add constraint agent_case_events_kind_check check (kind in ( 'opened', 'human_replied', 'lead_asked', 'lead_provided', 'lead_unresponsive', 'resolved', 'escalated', 'cancelled', 'agent_noted', -- (migration 0292) A equipe foi avisada no WhatsApp de que este caso -- abriu. UM valor só: a FALHA do aviso vai para a Central, e dois kinds -- seriam vocabulário para uma superfície que não existe. 'alert_sent' )); -- ---- catálogo de modelos atualizado (migration 0104) ---- -- O catálogo curado estava duas gerações atrás e o kit self-host aplica SÓ o -- baseline: sem este apêndice, quem instala numa VPS continua escolhendo entre -- modelos velhos e pagando mais caro por pior. Ids verificados no provedor -- (GET /v1/models) para Anthropic e OpenAI; os do Google seguem a convenção e -- NÃO foram verificados — ver o cabeçalho da migration. Idempotente por -- `on conflict do update`. -- --------------------------------------------------------------------------- -- 1. catálogo curado (o que a tela oferece) -- --------------------------------------------------------------------------- insert into public.ai_models (provider, model_id, display_name, description, input_price_per_million_cents, output_price_per_million_cents, supports_tools) values -- Anthropic ('anthropic', 'claude-opus-5', 'Claude Opus 5', 'O mais capaz da Anthropic para trabalho agêntico complexo.', 500, 2500, true), ('anthropic', 'claude-sonnet-5', 'Claude Sonnet 5', 'Alto desempenho para atendimento e agentes. Preço de introdução ($2/$10 por milhão) até 31/08/2026; depois volta a $3/$15 — reveja este preço nessa data.', 200, 1000, true), ('anthropic', 'claude-opus-4-8', 'Claude Opus 4.8', 'Geração anterior do Opus.', 500, 2500, true), -- OpenAI ('openai', 'gpt-5.6-sol', 'GPT-5.6 Sol', 'O mais capaz da linha 5.6.', 500, 3000, true), ('openai', 'gpt-5.6-terra', 'GPT-5.6 Terra', 'Equilíbrio de custo e capacidade da linha 5.6.', 200, 1200, true), ('openai', 'gpt-5.6-luna', 'GPT-5.6 Luna', 'O mais barato da linha 5.6, para classificação e tarefas simples.', 20, 120, true), ('openai', 'gpt-5.5', 'GPT-5.5', null, 500, 3000, true), ('openai', 'gpt-5.5-pro', 'GPT-5.5 Pro', 'Raciocínio estendido; custo alto.', 3000, 18000, true), ('openai', 'gpt-5.4', 'GPT-5.4', null, 250, 1500, true), ('openai', 'gpt-5.4-mini', 'GPT-5.4 Mini', null, 75, 450, true), ('openai', 'gpt-5.4-nano', 'GPT-5.4 Nano', null, 20, 125, true), ('openai', 'gpt-5.4-pro', 'GPT-5.4 Pro', 'Raciocínio estendido; custo alto.', 3000, 18000, true), -- Google (ids NÃO verificados — ver cabeçalho) ('google', 'gemini-3.1-pro-preview', 'Gemini 3.1 Pro (Preview)', 'Prévia; preço sobe para $4/$18 por milhão acima de 200 mil tokens de entrada.', 200, 1200, true), ('google', 'gemini-3.5-flash', 'Gemini 3.5 Flash', null, 150, 900, true), ('google', 'gemini-2.5-flash-lite', 'Gemini 2.5 Flash-Lite', 'O mais barato da linha Gemini.', 10, 40, true), ('google', 'gemini-2.0-flash', 'Gemini 2.0 Flash', null, 10, 40, true) on conflict (provider, model_id) do update set display_name = excluded.display_name, description = excluded.description, input_price_per_million_cents = excluded.input_price_per_million_cents, output_price_per_million_cents = excluded.output_price_per_million_cents, supports_tools = excluded.supports_tools; -- Correção de preço nos que JÁ existiam e estavam errados: a saída do -- gemini-2.5-pro é $10 (não $5) e a do gemini-2.5-flash é $2,50 (não $1,20). -- Preço errado no catálogo vira orçamento errado na tela do cliente. update public.ai_models set output_price_per_million_cents = 1000 where provider = 'google' and model_id = 'gemini-2.5-pro'; update public.ai_models set output_price_per_million_cents = 250 where provider = 'google' and model_id = 'gemini-2.5-flash'; -- --------------------------------------------------------------------------- -- 2. padrão por provedor -- -- O índice `ai_models_one_default_per_provider` é UNIQUE parcial e IMEDIATO: -- limpar o padrão anterior tem de vir ANTES de marcar o novo, senão a migration -- quebra no meio. -- --------------------------------------------------------------------------- update public.ai_models set is_default_for_provider = false where provider in ('anthropic', 'openai', 'google') and is_default_for_provider; update public.ai_models set is_default_for_provider = true where (provider = 'anthropic' and model_id = 'claude-sonnet-5') or (provider = 'openai' and model_id = 'gpt-5.6-terra') or (provider = 'google' and model_id = 'gemini-3.5-flash'); -- --------------------------------------------------------------------------- -- 3. contabilidade de custo — a MESMA lista, senão o gasto é calculado com -- preço de outro modelo (ou não é calculado, que é pior: some do orçamento). -- --------------------------------------------------------------------------- insert into public.ai_pricing (model, prompt_cents_per_million_tokens, completion_cents_per_million_tokens, notes) values ('claude-opus-5', 500, 2500, 'catálogo 0101'), ('claude-sonnet-5', 200, 1000, 'catálogo 0101 — introdução até 31/08/2026; depois 300/1500'), ('claude-opus-4-8', 500, 2500, 'catálogo 0101'), ('gpt-5.6-sol', 500, 3000, 'catálogo 0101'), ('gpt-5.6-terra', 200, 1200, 'catálogo 0101'), ('gpt-5.6-luna', 20, 120, 'catálogo 0101'), ('gpt-5.5', 500, 3000, 'catálogo 0101'), ('gpt-5.5-pro', 3000, 18000, 'catálogo 0101'), ('gpt-5.4', 250, 1500, 'catálogo 0101'), ('gpt-5.4-mini', 75, 450, 'catálogo 0101'), ('gpt-5.4-nano', 20, 125, 'catálogo 0101'), ('gpt-5.4-pro', 3000, 18000, 'catálogo 0101'), ('gemini-3.1-pro-preview', 200, 1200, 'catálogo 0101 — sobe acima de 200k tokens de entrada'), ('gemini-3.5-flash', 150, 900, 'catálogo 0101'), ('gemini-2.5-flash-lite', 10, 40, 'catálogo 0101'), ('gemini-2.0-flash', 10, 40, 'catálogo 0101'), ('gemini-2.5-pro', 125, 1000, 'catálogo 0101 — saída corrigida de 500 para 1000'), ('gemini-2.5-flash', 30, 250, 'catálogo 0101 — saída corrigida de 120 para 250') on conflict (model) do update set prompt_cents_per_million_tokens = excluded.prompt_cents_per_million_tokens, completion_cents_per_million_tokens = excluded.completion_cents_per_million_tokens, notes = excluded.notes, superseded_at = null; -- ---- as DUAS tabelas de preço do OpenAI (migration 0386, issue #1490) ---- -- -- O bloco da 0104 acima semeia 500/3000 para o gpt-5.6-sol, a versão não -- promocional do catálogo 0101. O pricing.ts (custo gravado em llm_calls) -- cobra 400/2000 — preço promocional medido na fonte oficial em 2026-09-23 -- (developers.openai.com/api/docs/pricing; a promoção vale ao menos até -- 21/11/2026). Quem instala pelo kit nasce com as duas tabelas dizendo o -- preço medido, e o invariante catálogo × conta continua com os DOIS lados -- iguais. Idempotente: update com guarda de divergência + insert em conflict. update public.ai_models set input_price_per_million_cents = 400, output_price_per_million_cents = 2000 where provider = 'openai' and model_id = 'gpt-5.6-sol' and (input_price_per_million_cents <> 400 or output_price_per_million_cents <> 2000); insert into public.ai_pricing (model, prompt_cents_per_million_tokens, completion_cents_per_million_tokens, notes) values ('gpt-5.6-sol', 400, 2000, 'catálogo 0386 — preço promocional medido na fonte em 2026-09-23 (developers.openai.com/api/docs/pricing); promoção vale ao menos até 21/11/2026'), ('gpt-4o', 250, 1000, 'catálogo 0386 — linha que o pricing.ts já cobrava e a tabela não tinha; preço medido na fonte em 2026-09-23'), ('gpt-4o-mini', 15, 60, 'catálogo 0386 — linha que o pricing.ts já cobrava e a tabela não tinha; preço medido na fonte em 2026-09-23'), ('gpt-4o-2024-05-13', 500, 1500, 'catálogo 0386 — snapshot com preço próprio; linha que o pricing.ts já cobrava e a tabela não tinha; medido em 2026-09-23') on conflict (model) do update set prompt_cents_per_million_tokens = excluded.prompt_cents_per_million_tokens, completion_cents_per_million_tokens = excluded.completion_cents_per_million_tokens, notes = excluded.notes, superseded_at = null; -- ---- agent_inbox_items.kind ganha 'capabilities_missing' (migration 0105capabilities_missing -- Quando o turno não consegue montar as capacidades configuradas na tela, ele -- segue sem elas (a conversa do cliente não pode morrer por uma tool extra) — -- mas o aviso ia só para o log do worker, que numa VPS ninguém abre. Este kind -- é o que faz o defeito aparecer na Central de avisos. Idempotente: a lista só -- cresce, nenhuma linha existente viola a constraint nova. -- -- ESTE É O BLOCO ÚNICO desta constraint, e a migration 0139 não acrescenta -- outro DE PROPÓSITO. A 0129 reconstruiu a constraint na CADEIA DE MIGRATIONS -- com 15 valores enquanto esta lista já tinha 18, apagando lá (e só lá) -- 'contact_proposal_expired', 'promise_unfulfilled' e 'other'. Quem instala -- pelo kit nunca viu o defeito — recebe este arquivo, que está correto —, e é -- por isso que a 0139 é uma migration SEM apêndice: um segundo bloco aqui seria -- exatamente o padrão da issue #159 que `baseline-constraint-reconstruida.test.ts` -- proíbe. Quem acrescentar um `kind` mexe em DOIS lugares: esta lista e a última -- migration que reconstrói a constraint. `kind-check-migration-x-baseline.test.ts` -- reprova quando as duas divergem. alter table public.agent_inbox_items drop constraint if exists agent_inbox_items_kind_check; alter table public.agent_inbox_items add constraint agent_inbox_items_kind_check check (kind in ( 'appointment_outcome_required', 'appointment_recovery_review', 'qr_rescan', 'routing_unassigned', 'job_dead', 'event_dead', 'budget_exceeded', 'handoff', 'promotion_review', 'judge_unaligned', 'followup_dead', 'snooze_expired', 'next_action_ambiguous', 'risk_backlog_seeded', 'reactivation_expired', 'capabilities_missing', -- (migration 0109, issue #129) Mensagem outbound nasce `sending` e, quando o -- envio nunca acontece, fica `sending` para sempre — o self-hoster vê uma -- mensagem eternamente "enviando", sinal de progresso para algo que não vai -- acontecer. O cron `recover-stuck-messages` marca `failed` e usa este kind -- para o defeito APARECER na Central de avisos. -- -- Entra NESTA lista, e não num bloco novo no fim do arquivo: o #159 do @jmpo -- mostrou que reconstruir a mesma constraint em N blocos quebra o -- `update.sh` de todo clone que já tenha uma linha de vocabulário posterior -- — os blocos antigos rodam antes e falham em cadeia. Um bloco por -- constraint, vigiado por tests/unit/baseline-constraint-reconstruida.test.ts. 'message_send_stuck', -- (migration 0129) O cliente manda foto/áudio e o agente age como se nada -- tivesse chegado. Acontece quando o modelo configurado não enxerga imagem, -- ou quando falta a chave de transcrição — e antes disto a derivação -- devolvia string vazia EM SILÊNCIO: nenhum erro, nenhum log, e o operador -- concluindo que o agente ignorou o cliente de propósito. 'midia_nao_lida', 'channel_template_review', 'channel_number_alert', -- (migration 0111, spec 16 §3.2) O papel Operador declara promessa em aberto: -- o assistente prometeu algo ao cliente e o cumprimento não foi registrado. -- A invariante sagrada da spec é "nenhuma promessa deixa de ser cumprida", e -- uma promessa sem dono precisa aparecer onde o humano olha — não no log do -- worker. Entra NESTA lista pela mesma razão que a de cima. 'promise_unfulfilled', -- (migration 0124, spec 17 §4b) Dado que o assistente ouviu na conversa e -- ninguém confirmou até o prazo. `info`, não `warn`: nada quebrou — uma -- informação não foi aproveitada, e tratar isso como falha ensinaria a -- ignorar os avisos que são falha de verdade. Entra NESTA lista pela mesma -- razão das de cima (bloco único por constraint, #159). 'contact_proposal_expired', -- (migration 0159) O gasto passou do aviso que a pessoa definiu e a IA -- CONTINUA respondendo — `warn`, nunca `critical`, e um kind SEPARADO de -- `budget_exceeded`: colapsar os dois faria o alerta de "parou" perder o -- significado. É este kind que torna possível a condição do gate "ninguém é -- bloqueado sem ter sido avisado no mês" — sem ele, o salto de 79% para 101% -- entre duas chamadas calaria a IA sem nenhum sinal anterior. -- -- Entra NESTA lista, e AQUI no fim, por duas razões distintas: bloco único -- por constraint (#159), e porque `tests/unit/midia-nao-lida.test.ts` procura -- `'midia_nao_lida'` nos primeiros 2000 caracteres a partir do `add -- constraint` — um valor comentado inserido ACIMA dele empurra-o para fora da -- janela e reprova um teste que não tem nada a ver com o kind novo (medido: -- offset 1532 -> 2275). Kind novo entra no fim da lista. 'budget_warning', -- (migration 0181) O material que a pessoa enviou não entrou na base: falta -- chave de embedding, a extração do arquivo falhou, ou nenhum trecho foi -- gravado. Antes disto o worker devolvia `skipped` para o próprio log, o drain -- tratava `skipped` como sucesso, e a linha da fonte seguia dizendo `ready`. -- Irmão direto de `midia_nao_lida`: mesma chave, mesmo silêncio. 'conhecimento_nao_indexado', -- (migration 0206, spec 18) Chamada de voz WhatsApp (WaCalls) recebida que -- nunca teve answered_at — o "chamou e ninguém atendeu" precisa de dono, -- mesma razão de midia_nao_lida/conhecimento_nao_indexado. Entra NESTA -- lista, não em bloco novo (#159, bloco único por constraint). 'voice_call_missed', 'case_stale', -- (migration 0292) O aviso de caso não chegou ao WhatsApp da equipe, -- em definitivo. Nasce com `ref_kind='agent_case'` para levar AO CASO — -- que continua esperando — e não a uma tela genérica. A fonte da verdade -- sobre "o aviso saiu?" continua sendo `entregas_de_aviso_de_caso`: -- qualquer membro apaga um item da Central pelo PostgREST hoje. 'aviso_de_caso_nao_entregue', -- (migration 0312) O fluxo de follow-up publicado que NUNCA vai disparar: -- gatilho automático (silêncio, etapa, caso, falta) só cria inscrição se -- algum agente publicado arma o ponteiro, e sem esse vínculo os produtores -- saem por `pointers_armados = 0` em silêncio — `active` na tela, morto no -- motor. Entra NESTA lista e no FIM dela, pelas duas razões de sempre -- (bloco único por constraint, #159; e a janela de 2000 caracteres que -- `tests/unit/midia-nao-lida.test.ts` varre a partir do `add constraint`). 'followup_sem_agente', -- (migration 0339, doc 11 decisão B) O canal de WhatsApp em modo de teste -- SEM número autorizado não responde a ninguém — e o esquecimento é o -- defeito: as mensagens chegam no Inbox e a IA nunca fala, então quem -- instalou conclui que o produto está quebrado. O cron canal-mudo-watcher -- abre este aviso depois de 3 dias e o FECHA quando deixa de valer. -- -- Entra NESTA lista, e não num bloco novo no fim do arquivo: reconstruir a -- mesma constraint em N blocos quebra o `update.sh` de todo clone com -- vocabulário posterior (lição do #159). 'canal_mudo_sem_numero', -- (migration 0464) a proposta comercial: vencimento sem decisão, queda da taxa -- de aceite e promessa de proposta que não virou proposta. 'proposal_expired_notice', 'proposal_acceptance_rate_drop', 'proposal_promised_not_created', -- (migration 0466, D3) proposta presa em 'enviando' há mais de 5min — o -- mesmo padrão do 'message_send_stuck', cron próprio (proposta-travada). 'proposta_travada', -- (migration 0475) a IA rascunhou uma proposta e falta confirmar o modelo -- sugerido (plano N1) ou falta preço de catálogo — a Central acompanha -- até as duas pendências sumirem, ou até a proposta ser enviada/descartada. 'proposta_pronta_para_revisao', -- (migration 0501) a organização voltou de uma suspensão e há conversas que -- receberam mensagem enquanto ela estava parada: a IA não respondeu nem vai -- responder sozinha. Um item por reativação, aberto por fn_reativar_organizacao. 'org_reativada', -- (migration 0500) O Jev percebeu, numa mensagem em que a regra de hoje não -- viu nada, um pedido para falar com uma pessoa ou para parar de receber -- mensagens, e a empresa escolheu "Avisar a equipe". Um kind por pedido, e -- não `other`: a Central dá rótulo e destino por kind, e o `other` não leva -- a uma conversa (lib/ai/inbox-destino.ts); e o aviso é um por CONVERSA e -- pedido. O Jev só abre o aviso — quem passa a conversa é a regra de hoje -- ou uma pessoa, e quem bloqueia é só o STOP do próprio cliente. NESTA -- lista pelas razões de sempre (#159; a janela do `midia-nao-lida.test.ts`). 'jev_pedido_de_humano', 'jev_parar_de_receber', 'other' )); -- ---- índice do watcher de follow-up sem agente (migration 0312) ---- create index if not exists agent_inbox_items_followup_sem_agente_aberto_idx on public.agent_inbox_items (organization_id, ref_id) where kind = 'followup_sem_agente' and status = 'open'; notify pgrst, 'reload schema'; -- ---- channel_sessions.archived_at (migration 0106) ---- -- Arquivar em vez de apagar: conversations/messages referenciam -- channel_sessions com ON DELETE RESTRICT, então canal com histórico não pode -- ser removido — some da UI e a linha fica como âncora das FKs. alter table public.channel_sessions add column if not exists archived_at timestamptz; create index if not exists channel_sessions_org_active_idx on public.channel_sessions (organization_id, created_at) where archived_at is null; notify pgrst, 'reload schema'; -- ---- número único só entre canais ATIVOS (migration 0107) ---- -- A trava `channel_sessions_phone_per_org_unique` é do snapshot e não sabe o que -- é arquivamento: a linha arquivada seguia ocupando o par (org, número), e -- reparear o MESMO número estourava 23505 na linha nova. O invariante real é "um -- número vive em UM canal ATIVO" — vira índice parcial `where archived_at is -- null`, com o MESMO NOME (o invariante do repo cobra o nome dentro da mensagem -- de erro). Perde o DEFERRABLE: medido, nenhum caminho escreve -- channel_sessions.phone_number com violação transitória. -- -- Auto-curativo: a constraint antiga é ESTRITAMENTE mais forte que o índice novo -- (todas as linhas vs. um subconjunto), então nenhum banco que a satisfazia pode -- violar o índice — não há dado a deduplicar antes de criá-lo. do $$ begin if exists ( select 1 from pg_constraint where conrelid = 'public.channel_sessions'::regclass and conname = 'channel_sessions_phone_per_org_unique' ) then alter table public.channel_sessions drop constraint channel_sessions_phone_per_org_unique; end if; end $$; create unique index if not exists channel_sessions_phone_per_org_unique on public.channel_sessions (organization_id, phone_number) where archived_at is null; -- ---- SECURITY DEFINER exposta a anon/authenticated (migration 0108) ---- -- Issue #128. O `ALTER DEFAULT PRIVILEGES ... GRANT ALL ON FUNCTIONS TO anon` -- (e a irmã TO authenticated) lá em cima vale para toda função criada DEPOIS -- dele — isto é, para TODO apêndice deste arquivo, que sempre nasce no fim — e -- concede grant DIRETO, que `revoke all ... from public` não remove. Copiar as -- duas linhas padrão de uma função antiga produz função exposta. -- -- Medido com o baseline da main aplicado: das 25 `security definer` de public, -- 8 tinham EXECUTE para anon — incluindo `fn_publish_ai_agent_version`, que -- ESCREVE e recebe o org por argumento sem checar membership. -- -- REGRA (vigiada por tests/invariants/hardening-definer-varredura.test.ts): -- anon → nenhuma definer de public executável, sem exceção; -- authenticated → definer VOLÁTIL só continua executável com call site de -- sessão de usuário (emit_event, fn_conversation_assign, -- fn_log_event). As demais só são chamadas pelo client de -- service role, e o grant era escrita cross-tenant à toa. -- Idempotente e auto-curativo: revoke de privilégio ausente é no-op. -- ---- anon: nenhuma SECURITY DEFINER de public ---- -- Duas origens de EXECUTE, e cada uma pede um revoke diferente — medir o ACL -- real (`proacl`) foi o que mostrou isso: `{=X/postgres,...}` é grant a PUBLIC, -- que `revoke ... from anon` NÃO remove. As duas linhas juntas cobrem os dois -- caminhos, e o re-grant explícito devolve quem de fato precisa. revoke execute on function public.fn_is_platform_admin() from public, anon; revoke execute on function public.fn_user_org_ids() from public, anon; revoke execute on function public.fn_user_role_in_org(uuid) from public, anon; revoke execute on function public.fn_user_role_in(uuid) from public, anon; revoke execute on function public.fn_role_at_least(uuid, text) from public, anon; revoke execute on function public.fn_publish_ai_agent_version(uuid, uuid, uuid) from public, anon; revoke execute on function public.fn_emit_conversation_routing() from public, anon; -- ---- authenticated: definer volátil sem call site de sessão de usuário ---- revoke execute on function public.fn_upsert_wa_contact(uuid, text, text, text, text, text) from authenticated; revoke execute on function public.fn_upsert_wa_conversation(uuid, uuid, uuid) from authenticated; revoke execute on function public.fn_mark_conversation_message(uuid, text, text, timestamptz) from authenticated; revoke execute on function public.fn_publish_ai_agent_version(uuid, uuid, uuid) from authenticated; revoke execute on function public.activate_kb_version(uuid, uuid) from authenticated; -- Funções de TRIGGER: ninguém as chama por RPC, e o disparo do trigger não -- consulta EXECUTE. O grant só existia por herança dos padrões do Postgres. revoke execute on function public.fn_emit_conversation_routing() from authenticated; -- ---- re-grant explícito: quem precisa continua podendo (probe positivo) ---- grant execute on function public.fn_upsert_wa_contact(uuid, text, text, text, text, text) to service_role; grant execute on function public.fn_upsert_wa_conversation(uuid, uuid, uuid) to service_role; grant execute on function public.fn_mark_conversation_message(uuid, text, text, timestamptz) to service_role; grant execute on function public.fn_publish_ai_agent_version(uuid, uuid, uuid) to service_role; grant execute on function public.activate_kb_version(uuid, uuid) to service_role; grant execute on function public.fn_emit_conversation_routing() to service_role; -- Helpers de RLS: as policies são avaliadas com o papel de quem consulta, então -- `authenticated` PRECISA de EXECUTE — sem isto toda leitura logada quebra. grant execute on function public.fn_is_platform_admin() to authenticated, service_role; grant execute on function public.fn_user_org_ids() to authenticated, service_role; grant execute on function public.fn_user_role_in_org(uuid) to authenticated, service_role; grant execute on function public.fn_user_role_in(uuid) to authenticated, service_role; grant execute on function public.fn_role_at_least(uuid, text) to authenticated, service_role; -- ---- ai_invocations.agent_id aceita NULL (migration 0114) ---- -- Issue #160 (@jmpo, medindo a própria VPS): o classificador de sentimento roda -- mesmo sem agente ativo — lê o agente só para o threshold e cai no default — -- mas auditava com `agent_id: agent?.id ?? ""` numa coluna `uuid NOT NULL`. O -- insert é fire-and-forget, então o erro só aparecia como `warn` no log do -- contêiner: `ai_invocations` ficava VAZIA numa instalação com tráfego real, e -- as telas de consumo e custo de IA (que leem dela) mostravam zero enquanto o -- provider era pago. "Sem agente ativo" é o estado normal de quem ainda não -- publicou o agente. -- Idempotente: `drop not null` em coluna que já aceita null é no-op. alter table public.ai_invocations alter column agent_id drop not null; comment on column public.ai_invocations.agent_id is 'Agente que originou a invocação. NULL = invocação de IA sem agente dono ' '(ex.: classificador de sentimento numa org sem agente publicado). O custo ' 'existe e precisa aparecer nas telas de consumo — ver issue #160.'; -- ---- Índice de Atrito + DEMANDAS (migrations 0116–0120) ---- -- Spec 17 + doutrina cap. 5. `demandas` é a unidade do PROPÓSITO: contato é -- quem pede, conversa é por onde se fala, demanda é o que precisa acabar. -- O índice usa demandas como denominador (0137) e publica o invariante 4 como -- número (demandas abertas sem próximo passo). Idempotente. create index if not exists idx_conversations_org_silencio on public.conversations (organization_id, last_outbound_at) where last_outbound_at is not null; /** * Jaccard de tokens entre dois textos. Tokens com 3+ caracteres (artigos e * preposições curtas só somam ruído), sem acento-folding: reformulação real * varia palavra, não acento. */ create or replace function public.fn_atrito_jaccard(a text, b text) returns float8 language sql immutable set search_path = public as $$ with ta as ( select distinct token from unnest( string_to_array(lower(regexp_replace(coalesce(a, ''), '[^[:alnum:][:space:]]', ' ', 'g')), ' ') ) as token where length(token) >= 3 ), tb as ( select distinct token from unnest( string_to_array(lower(regexp_replace(coalesce(b, ''), '[^[:alnum:][:space:]]', ' ', 'g')), ' ') ) as token where length(token) >= 3 ) select case when (select count(*) from ta) = 0 or (select count(*) from tb) = 0 then 0::float8 else (select count(*) from (select token from ta intersect select token from tb) i)::float8 / nullif((select count(*) from (select token from ta union select token from tb) u), 0)::float8 end; $$; revoke all on function public.fn_atrito_jaccard(text, text) from public; revoke execute on function public.fn_atrito_jaccard(text, text) from anon; grant execute on function public.fn_atrito_jaccard(text, text) to authenticated, service_role; create table if not exists public.demandas ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, -- SOLICITANTE: quem tem o problema (não necessariamente quem escreveu). contact_id uuid not null references public.contacts(id) on delete cascade, -- Vínculo com o negócio, quando houver. Uma demanda de suporte não tem lead, -- e isso é desfecho legítimo — não pendência. lead_id uuid references public.crm_leads(id) on delete set null, -- Ponteiro para o caso de escalada que originou a demanda, quando houve. -- Sem ele, as métricas de toque humano (que vivem em `agent_case_events`) -- perderiam a ligação com a demanda ao trocar o denominador do índice. agent_case_id uuid references public.agent_cases(id) on delete set null, aberta_em timestamptz not null default now(), origem text not null default 'inbound' check (origem in ('inbound', 'handoff', 'followup', 'manual', 'derivada')), assunto text, estado text not null default 'aberta' check (estado in ('aberta', 'em_atendimento', 'aguardando_cliente', 'resolvida', 'encerrada')), -- DONO NUNCA VAZIO (cap. 5 §5.3). Demanda sem dono é a definição operacional -- de "vai morrer". Se ninguém assumiu, o dono é a automação — e isso é uma -- decisão registrada, não um vazio que ninguém nota. dono_kind text not null default 'ia' check (dono_kind in ('ia', 'humano')), dono_user_id uuid references auth.users(id) on delete set null, -- PRÓXIMO PASSO é CAMPO, não derivação (cap. 5 §5.3): derivado, ele -- desapareceria nos casos em que a derivação falha — que são exatamente os -- casos em que ele importa. É aqui que o invariante 4 vira verificável. proximo_passo text, proximo_passo_em timestamptz, prazo_em timestamptz, -- Desfecho ENUMERADO e terminal. Inclui os que não são vitória: o sistema não -- pode ser o único a decidir que uma demanda acabou, senão fecharia por -- conveniência (encerrar por inatividade melhora todo número sem melhorar -- nada). `expirada_sem_resposta` é desfecho legítimo e RUIM — contável e -- vigiado; organização onde ele é zero está mal instrumentada, não saudável. desfecho text check (desfecho in ( 'resolvida', 'convertida', 'nao_procede', 'encerrada_pelo_cliente', 'perdida', 'expirada_sem_resposta' )), fechada_em timestamptz, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), -- Desfecho e fechamento andam juntos: um sem o outro é linha meio-fechada, -- que nenhuma consulta de "abertas" nem de "encerradas" pegaria. constraint demandas_desfecho_coerente check ((desfecho is null) = (fechada_em is null)), -- Dono humano exige QUEM. `dono_kind='humano'` com user nulo seria dono vazio -- com aparência de dono preenchido. constraint demandas_dono_humano_tem_user check (dono_kind <> 'humano' or dono_user_id is not null) ); -- Uma demanda atravessa VÁRIOS canais e uma conversa carrega VÁRIAS demandas -- (cap. 5 §5.4). Resistir a este muitos-para-muitos é a fonte de metade dos -- problemas de modelagem neste domínio: um-para-um obriga a escolher entre -- perder o problema que muda de canal e perder o segundo problema da conversa. create table if not exists public.demanda_conversas ( organization_id uuid not null references public.organizations(id) on delete cascade, demanda_id uuid not null references public.demandas(id) on delete cascade, conversation_id uuid not null references public.conversations(id) on delete cascade, vinculada_em timestamptz not null default now(), primary key (demanda_id, conversation_id) ); create index if not exists idx_demandas_org_abertas on public.demandas (organization_id, aberta_em) where fechada_em is null; create index if not exists idx_demandas_org_fechadas on public.demandas (organization_id, fechada_em) where fechada_em is not null; create index if not exists idx_demandas_caso on public.demandas (organization_id, agent_case_id) where agent_case_id is not null; create index if not exists idx_demandas_contato on public.demandas (organization_id, contact_id); -- O invariante 4 em forma de índice: demanda aberta SEM próximo passo é o -- vazamento que a doutrina proíbe, e precisa ser barato de enumerar. create index if not exists idx_demandas_sem_proximo_passo on public.demandas (organization_id, aberta_em) where fechada_em is null and proximo_passo is null; create index if not exists idx_demanda_conversas_conv on public.demanda_conversas (organization_id, conversation_id); alter table public.demandas enable row level security; alter table public.demanda_conversas enable row level security; drop policy if exists tenant_isolation_demandas_all on public.demandas; create policy tenant_isolation_demandas_all on public.demandas for all using (organization_id in (select * from public.fn_user_org_ids())) with check (organization_id in (select * from public.fn_user_org_ids())); drop policy if exists tenant_isolation_demanda_conversas_all on public.demanda_conversas; create policy tenant_isolation_demanda_conversas_all on public.demanda_conversas for all using (organization_id in (select * from public.fn_user_org_ids())) with check (organization_id in (select * from public.fn_user_org_ids())); -- --------------------------------------------------------------------------- -- Passo 2 de 4: derivar o passado por REGRA EXPLÍCITA, nunca por adivinhação. -- -- A regra fica escrita porque histórico derivado por regra é honesto e -- histórico derivado por heurística contamina toda comparação futura — e -- ninguém vai lembrar disso daqui a seis meses, comparando dois trimestres. -- -- R1. Todo `agent_cases` vira uma demanda (origem 'handoff'). O mapeamento de -- status é 1:1 e sem interpretação. -- R2. Toda conversa SEM agent_case vira uma demanda (origem 'derivada'), -- porque houve uma pessoa com um assunto ali. `assunto` fica NULO — não -- inventamos o que a conversa tratava. -- -- Idempotente por `where not exists`: re-aplicar não duplica. -- --------------------------------------------------------------------------- -- R1 — a partir dos casos de escalada. insert into public.demandas (organization_id, contact_id, lead_id, agent_case_id, aberta_em, origem, assunto, estado, dono_kind, desfecho, fechada_em) select c.organization_id, cv.contact_id, c.lead_id, c.id, c.opened_at, 'handoff', c.title, case c.status when 'awaiting_human' then 'em_atendimento' when 'awaiting_lead' then 'aguardando_cliente' when 'resolved' then 'resolvida' when 'escalated' then 'em_atendimento' when 'cancelled' then 'encerrada' else 'aberta' end, 'ia', case c.status when 'resolved' then 'resolvida' when 'cancelled' then 'nao_procede' else null end, case when c.status in ('resolved', 'cancelled') then c.closed_at else null end from public.agent_cases c join public.conversations cv on cv.id = c.conversation_id where not exists ( select 1 from public.demandas d where d.organization_id = c.organization_id and d.contact_id = cv.contact_id and d.origem = 'handoff' and d.aberta_em = c.opened_at ); -- Vínculo N:N das demandas derivadas de caso. insert into public.demanda_conversas (organization_id, demanda_id, conversation_id) select d.organization_id, d.id, c.conversation_id from public.demandas d join public.agent_cases c on c.id = d.agent_case_id where d.agent_case_id is not null and not exists ( select 1 from public.demanda_conversas dc where dc.demanda_id = d.id and dc.conversation_id = c.conversation_id ); -- AUTO-CURA (migration 0392): apaga a duplicata que a versão anterior deste -- apêndice criou. O guard de R2 abaixo era idempotente só CONTRA SI MESMO — -- procurava outra 'derivada' com o mesmo `aberta_em` — e não enxergava a -- demanda 'inbound' que o trigger da 0138 cria na entrada. Em quem já rodava, -- cada `update.sh` derivava uma segunda demanda para cada conversa nova, -- dobrando `demandas_sem_proximo_passo` (o invariante 4) e `escopo.demandas`. -- -- Só sai a 'derivada' que tem a assinatura da duplicata e mais nada: -- * INTOCADA — sem próximo passo, sem lead, sem dono humano, sem caso; -- * ligada a UMA conversa só — então apagá-la não deixa conversa nenhuma sem -- demanda (a cascata leva só esse vínculo); -- * nascida DEPOIS de outra demanda de origem real na mesma conversa. É -- isso que separa a duplicata da derivada legítima: a do backfill original -- é anterior ao trigger, e a 'inbound' que chegou depois dela, numa conversa -- reaberta, é mais NOVA — essa derivada é histórico e fica; -- * e que NINGUÉM referencia. O backfill da 0222 (mais abaixo) escolhe a -- vigente pelo maior `aberta_em`, e a 'inbound' tem o `sent_at` do WAHA -- (segundos, anterior ao insert da conversa) — a duplicata costuma vencer, -- virar `current_demanda_id` e ser carimbada em `messages.demanda_id`. -- Apagá-la zeraria essas referências (`on delete set null`): a próxima -- entrada abriria outra demanda e o acompanhamento com fronteira nela seria -- cancelado como vencido. Duplicata vigente segue contando dobrado; é o -- preço menor. -- -- Dentro de `do` porque essas colunas nascem no bloco da 0222, mais abaixo: no -- install ainda não existem (e não há demanda nenhuma para curar). O PL/pgSQL -- só analisa o `delete` quando o executa, então o `if` basta. do $$ begin if (select count(*) from information_schema.columns where table_schema = 'public' and (table_name, column_name) in (('conversations', 'current_demanda_id'), ('messages', 'demanda_id'), ('lead_checkpoints', 'demanda_id'))) = 3 then delete from public.demandas d where d.origem = 'derivada' and d.agent_case_id is null and d.lead_id is null and d.dono_user_id is null and d.proximo_passo is null and (select count(*) from public.demanda_conversas v where v.demanda_id = d.id) = 1 and not exists (select 1 from public.conversations c where c.current_demanda_id = d.id) and not exists (select 1 from public.messages m where m.demanda_id = d.id) and not exists (select 1 from public.lead_checkpoints k where k.demanda_id = d.id) and exists ( select 1 from public.demanda_conversas dc join public.demanda_conversas outra on outra.conversation_id = dc.conversation_id and outra.demanda_id <> d.id join public.demandas d2 on d2.id = outra.demanda_id where dc.demanda_id = d.id and d2.origem <> 'derivada' and d2.created_at < d.created_at ); end if; end $$; -- R2 — conversas que nunca escalaram também são demandas. insert into public.demandas (organization_id, contact_id, aberta_em, origem, estado, dono_kind, desfecho, fechada_em) select cv.organization_id, cv.contact_id, cv.created_at, 'derivada', case cv.status when 'resolved' then 'resolvida' when 'closed' then 'encerrada' else 'aberta' end, 'ia', case when cv.status in ('resolved', 'closed') then 'resolvida' else null end, case when cv.status in ('resolved', 'closed') then cv.status_changed_at else null end from public.conversations cv where not exists ( select 1 from public.agent_cases c where c.conversation_id = cv.id ) and not exists ( select 1 from public.demanda_conversas dc where dc.conversation_id = cv.id ) -- O guard que faltava (migration 0392): derivar o PASSADO só vale para a -- conversa que não tem demanda NENHUMA. Sem esta linha, toda conversa que o -- trigger da 0138 já cobriu ganha uma segunda demanda no `update.sh` seguinte. and not exists ( select 1 from public.demandas d where d.organization_id = cv.organization_id and d.contact_id = cv.contact_id and d.origem = 'derivada' and d.aberta_em = cv.created_at ); insert into public.demanda_conversas (organization_id, demanda_id, conversation_id) select d.organization_id, d.id, cv.id from public.demandas d join public.conversations cv on cv.organization_id = d.organization_id and cv.contact_id = d.contact_id and cv.created_at = d.aberta_em where d.origem = 'derivada' and not exists ( select 1 from public.demanda_conversas dc where dc.demanda_id = d.id and dc.conversation_id = cv.id ); comment on table public.demandas is 'A unidade do PROPÓSITO (doutrina cap. 5): uma coisa a ser resolvida. ' 'Contato é quem pede; conversa é por onde se fala; demanda é o que precisa ' 'acabar. Dono nunca vazio; próximo passo é campo, não derivação.'; drop function if exists public.fn_atrito_metrics(uuid, timestamptz, timestamptz, int, float8, int); create or replace function public.fn_atrito_metrics( p_org uuid, p_from timestamptz, p_to timestamptz, p_abandono_horas int default 72, p_repeticao_min float8 default 0.7, p_espera_horas int default 4 ) returns jsonb language sql stable set search_path = public as $$ with -- DENOMINADOR DEFINITIVO: demandas encerradas na janela. Não mais os casos. demandas_j as ( select d.id, d.agent_case_id, d.aberta_em, d.fechada_em, d.desfecho from public.demandas d where d.organization_id = p_org and d.fechada_em is not null and d.fechada_em >= p_from and d.fechada_em < p_to ), -- Turnos: mensagens de TODAS as conversas da demanda (N:N), dentro da vida -- dela. Uma demanda que atravessou dois canais soma os dois. turnos as ( select d.id, (select count(*) from public.demanda_conversas dc join public.messages m on m.conversation_id = dc.conversation_id and m.organization_id = p_org and m.sent_at >= d.aberta_em and m.sent_at < d.fechada_em where dc.demanda_id = d.id) as n from demandas_j d ), -- Insistência: só existe onde houve caso. O payload declara o denominador -- próprio (`demandas_com_caso`) para o número não ser lido como se fosse -- sobre o total. insistencia as ( select avg(c.followup_attempts)::float8 as media, max(c.followup_attempts) as maximo, count(*) as base from demandas_j d join public.agent_cases c on c.id = d.agent_case_id ), humano as ( select e.case_id, count(*) as intervencoes, min(e.created_at) as primeiro_toque from public.agent_case_events e join demandas_j d on d.agent_case_id = e.case_id where e.organization_id = p_org and e.actor_kind = 'human' group by e.case_id ), espera_fila as ( select extract(epoch from (h.primeiro_toque - d.aberta_em)) as segundos from demandas_j d join humano h on h.case_id = d.agent_case_id where h.primeiro_toque > d.aberta_em ), retrabalho as ( select count(distinct e.case_id) as n from public.agent_case_events e join demandas_j d on d.agent_case_id = e.case_id where e.organization_id = p_org and (e.kind = 'escalated' or e.human_action = 'escalate') ), abandono as ( select count(*) filter ( where cv.last_outbound_at >= p_from and cv.last_outbound_at < p_to and (cv.last_inbound_at is null or cv.last_outbound_at > cv.last_inbound_at) and cv.last_outbound_at < now() - make_interval(hours => p_abandono_horas) and cv.status not in ('resolved', 'closed') ) as abandonadas, count(*) filter ( where cv.last_outbound_at >= p_from and cv.last_outbound_at < p_to ) as com_fala_nossa from public.conversations cv where cv.organization_id = p_org and cv.last_outbound_at is not null ), -- INVARIANTE 4, agora VERIFICÁVEL: demanda aberta sem próximo passo é o -- vazamento que a doutrina proíbe. Antes da 0119 isto não era enumerável. sem_proximo_passo as ( select count(*) as n from public.demandas d where d.organization_id = p_org and d.fechada_em is null and d.proximo_passo is null ), demandas_abertas as ( select count(*) as n from public.demandas d where d.organization_id = p_org and d.fechada_em is null ), inbounds as ( select m.conversation_id, m.sent_at, m.body, lag(m.body) over (partition by m.conversation_id order by m.sent_at) as body_anterior, lag(m.sent_at) over (partition by m.conversation_id order by m.sent_at) as sent_at_anterior from public.messages m where m.organization_id = p_org and m.direction = 'inbound' and m.body is not null and m.sent_at >= p_from and m.sent_at < p_to ), repeticao as ( select count(*) filter ( where i.body_anterior is not null and exists (select 1 from public.messages o where o.organization_id = p_org and o.conversation_id = i.conversation_id and o.direction = 'outbound' and o.sent_at > i.sent_at_anterior and o.sent_at < i.sent_at) and public.fn_atrito_jaccard(i.body, i.body_anterior) >= p_repeticao_min ) as repetidas, count(*) filter ( where i.body_anterior is not null and exists (select 1 from public.messages o where o.organization_id = p_org and o.conversation_id = i.conversation_id and o.direction = 'outbound' and o.sent_at > i.sent_at_anterior and o.sent_at < i.sent_at) ) as com_resposta_no_meio from inbounds i ), espera_calada as ( select count(*) filter (where prox.espera_s > p_espera_horas * 3600) as caladas, count(*) as com_resposta, percentile_cont(0.9) within group (order by prox.espera_s) as p90_s from ( select extract(epoch from ( (select min(o.sent_at) from public.messages o where o.organization_id = p_org and o.conversation_id = m.conversation_id and o.direction = 'outbound' and o.sent_at > m.sent_at) - m.sent_at)) as espera_s from public.messages m where m.organization_id = p_org and m.direction = 'inbound' and m.sent_at >= p_from and m.sent_at < p_to ) prox where prox.espera_s is not null ), envios as ( select count(*) filter (where m.sent_via = 'ai') as por_ia, count(*) filter (where m.sent_via = 'user') as por_humano_no_sistema, count(*) filter (where m.sent_via = 'external_device') as por_humano_fora from public.messages m where m.organization_id = p_org and m.direction = 'outbound' and m.sent_at >= p_from and m.sent_at < p_to ), vetos as ( select count(*) filter (where t.vetoed_gate is not null) as vetados, count(distinct t.job_id) as execucoes from public.before_send_traces t where t.organization_id = p_org and t.created_at >= p_from and t.created_at < p_to ), descadastros as ( select count(*) as n from public.contacts c where c.organization_id = p_org and c.blocked_at is not null and c.blocked_at >= p_from and c.blocked_at < p_to ), pedidos_humano as ( select count(*) as n from public.crm_lead_activities a where a.organization_id = p_org and a.type = 'handoff_triggered' and a.performed_at >= p_from and a.performed_at < p_to ), eficiencia as ( select count(*) filter (where status = 'won') as ganhos, count(*) filter (where status = 'lost') as perdidos from public.crm_leads where organization_id = p_org and status in ('won', 'lost') and closed_at >= p_from and closed_at < p_to ) select jsonb_build_object( 'escopo', jsonb_build_object( 'demandas', (select count(*) from demandas_j), 'demandas_com_caso', (select base from insistencia), 'demandas_abertas', (select n from demandas_abertas), 'de', p_from, 'ate', p_to, 'abandono_horas', p_abandono_horas, 'repeticao_min', p_repeticao_min, 'espera_horas', p_espera_horas, -- Marca a régua do denominador: quem comparar dois períodos precisa saber -- se foram medidos sobre casos ou sobre demandas. 'denominador', 'demandas' ), 'cliente', jsonb_build_object( 'turnos_p50', (select percentile_cont(0.5) within group (order by n) from turnos), 'turnos_p90', (select percentile_cont(0.9) within group (order by n) from turnos), 'insistencia_media', (select media from insistencia), 'insistencia_max', (select maximo from insistencia), 'pedidos_de_humano', (select n from pedidos_humano), 'descadastros', (select n from descadastros), 'abandonos', (select abandonadas from abandono), 'conversas_com_fala_nossa', (select com_fala_nossa from abandono), 'reperguntas', (select repetidas from repeticao), 'perguntas_com_resposta', (select com_resposta_no_meio from repeticao), 'esperas_caladas', (select caladas from espera_calada), 'esperas_medidas', (select com_resposta from espera_calada), 'espera_resposta_p90_s', (select p90_s from espera_calada) ), 'empresa', jsonb_build_object( 'intervencoes_por_demanda', (select avg(coalesce(h.intervencoes, 0))::float8 from demandas_j d left join humano h on h.case_id = d.agent_case_id), 'espera_humana_p50_s', (select percentile_cont(0.5) within group (order by segundos) from espera_fila), 'espera_humana_p90_s', (select percentile_cont(0.9) within group (order by segundos) from espera_fila), 'retrabalho', (select n from retrabalho), 'vetos', (select vetados from vetos), 'execucoes_medidas', (select execucoes from vetos), 'envios_por_ia', (select por_ia from envios), 'envios_humano_no_sistema', (select por_humano_no_sistema from envios), 'envios_humano_fora', (select por_humano_fora from envios), -- O invariante 4 vira NÚMERO na tela: demanda aberta sem próximo passo é -- vazamento, e vazamento invisível é o que a doutrina inteira combate. 'demandas_sem_proximo_passo', (select n from sem_proximo_passo) ), 'eficiencia', jsonb_build_object( 'ganhos', (select ganhos from eficiencia), 'perdidos', (select perdidos from eficiencia) ) ); $$; revoke all on function public.fn_atrito_metrics(uuid, timestamptz, timestamptz, int, float8, int) from public; revoke execute on function public.fn_atrito_metrics(uuid, timestamptz, timestamptz, int, float8, int) from anon; grant execute on function public.fn_atrito_metrics(uuid, timestamptz, timestamptz, int, float8, int) to authenticated, service_role; -- ---- demanda nasce no ponto de entrada (migration 0138) ---- -- Sem isto `demandas` só teria o passado derivado: peça que só recebe é ilha -- pelo invariante 1. Trigger SQL puro, sem I/O externo — a proibição da -- doutrina é HTTP dentro da transação, e `trg_messages_emit_event` já usa este -- mesmo mecanismo nesta mesma tabela. create or replace function public.fn_demanda_abre_no_inbound() returns trigger language plpgsql security definer set search_path = public as $$ declare v_demanda uuid; begin if new.direction <> 'inbound' then return new; end if; select d.id into v_demanda from public.demandas d where d.organization_id = new.organization_id and d.contact_id = new.contact_id and d.fechada_em is null order by d.aberta_em desc limit 1; if v_demanda is null then insert into public.demandas (organization_id, contact_id, aberta_em, origem, estado, dono_kind) values (new.organization_id, new.contact_id, new.sent_at, 'inbound', 'aberta', 'ia') returning id into v_demanda; end if; -- O vínculo é por conversa: a mesma demanda acumula os canais por onde a -- pessoa falou (cap. 5 §5.4). `on conflict do nothing` porque a chave é o par. insert into public.demanda_conversas (organization_id, demanda_id, conversation_id) values (new.organization_id, v_demanda, new.conversation_id) on conflict do nothing; return new; end; $$; -- SECURITY DEFINER porque o trigger roda no INSERT do webhook (service role) e -- também no de sessão; sem definer, a RLS de `demandas` recusaria a escrita em -- um dos caminhos e a demanda sumiria justamente na entrada real. O -- organization_id vem SEMPRE de `new`, nunca de parâmetro — não há superfície -- para escolher org alheia. revoke all on function public.fn_demanda_abre_no_inbound() from public; revoke execute on function public.fn_demanda_abre_no_inbound() from anon, authenticated; drop trigger if exists trg_demanda_abre_no_inbound on public.messages; create trigger trg_demanda_abre_no_inbound after insert on public.messages for each row execute function public.fn_demanda_abre_no_inbound(); -- --------------------------------------------------------------------------- -- Fechamento. Sem ele a demanda nunca termina e o denominador do índice (que -- conta FECHADAS) ficaria vazio para sempre — a métrica morreria em silêncio no -- exato momento em que a entidade passou a crescer. -- --------------------------------------------------------------------------- create or replace function public.fn_demanda_fecha_com_conversa() returns trigger language plpgsql security definer set search_path = public as $$ begin if new.status not in ('resolved', 'closed') or old.status = new.status then return new; end if; update public.demandas d set estado = 'resolvida', desfecho = 'resolvida', fechada_em = now(), updated_at = now() where d.organization_id = new.organization_id and d.fechada_em is null and exists ( select 1 from public.demanda_conversas dc where dc.demanda_id = d.id and dc.conversation_id = new.id ) -- Só fecha se TODAS as conversas da demanda estiverem encerradas: uma -- demanda que atravessou dois canais não acabou porque um deles fechou. and not exists ( select 1 from public.demanda_conversas dc2 join public.conversations c2 on c2.id = dc2.conversation_id where dc2.demanda_id = d.id and c2.id <> new.id and c2.status not in ('resolved', 'closed') ); return new; end; $$; revoke all on function public.fn_demanda_fecha_com_conversa() from public; revoke execute on function public.fn_demanda_fecha_com_conversa() from anon, authenticated; drop trigger if exists trg_demanda_fecha_com_conversa on public.conversations; create trigger trg_demanda_fecha_com_conversa after update of status on public.conversations for each row execute function public.fn_demanda_fecha_com_conversa(); notify pgrst, 'reload schema'; -- ---- lead_checkpoints.declaracao: a fronteira FALAR/OPERAR (migration 0110) ---- -- Spec 16 §5. NULLABLE de propósito: NULL = o modelo não declarou; -- {"nada_a_declarar":true} = avaliou e não havia nada. Colapsar os dois num -- default apagaria o esquecimento, que é o que o invariante 4 manda mostrar. alter table lead_checkpoints add column if not exists declaracao jsonb; comment on column lead_checkpoints.declaracao is 'Declaração do turno (spec 16 §5): {intencoes[], promessas[], nada_a_declarar}. ' 'NULL = o modelo não declarou; {"nada_a_declarar":true} = avaliou e não havia nada. ' 'Os dois estados são distintos por desenho.'; notify pgrst, 'reload schema'; -- ---- turno do OPERADOR: config por versão (migration 0111) ---- -- Spec 16 §3.2. O papel que mexe no sistema e nunca fala com o lead; disparo -- imposto pelo runtime, por evento. -- -- Os DOIS CHECKs de `job_queue` (kind + coerência kind⇔contato) NÃO estão aqui: -- eles vivem no bloco único lá em cima, já com 'operator_turn'. Reconstruí-los -- aqui criaria o segundo bloco que quebra o update.sh do clone. alter table ai_agent_versions add column if not exists operator_enabled boolean not null default false; alter table ai_agent_versions add column if not exists operator_model text; -- (migration 0112) Ferramentas do papel Operador — coluna PRÓPRIA, não reuso de -- `tool_ids`: se os dois papéis lessem a mesma lista, a seção "Operador" da tela -- estaria configurando o que o Conversador executa. Default vazio: o papel nasce -- sem mão, e herdar as do Conversador em silêncio daria 20 capacidades a quem -- não escolheu nenhuma. alter table ai_agent_versions add column if not exists operator_tool_ids text[] not null default '{}'::text[]; comment on column ai_agent_versions.operator_tool_ids is 'Spec 16 §6: capacidades do papel Operador, independentes de `tool_ids` (do ' 'Conversador). Vazio = o papel roda mas não tem mão — estado legítimo: ele ' 'ainda registra promessa em aberto na Central.'; comment on column ai_agent_versions.operator_enabled is 'Spec 16 §3.2: o papel Operador roda após o turno do Conversador. false = o ' 'registro básico segue por código determinístico (estado, follow-up prometido, ' 'timeline); o que se perde é o julgamento sobre as capacidades do catálogo.'; comment on column ai_agent_versions.operator_model is 'Modelo do papel Operador. NULL = herda o modelo do agente.'; notify pgrst, 'reload schema'; -- 0115 — duas entidades que não se conseguia apagar. -- -- Achados ao remover as fixtures de E2E da produção em 2026-08-06. Os dois são -- da mesma família: uma escrita AUTOMÁTICA (trigger/FK) reagindo ao DELETE e -- violando uma regra que vale para o estado normal, mas não para a remoção. -- -- ═══ DEFEITO 1 · não era possível apagar uma ORGANIZAÇÃO ═══ -- -- ERROR: insert or update on table "api_audit_log" violates foreign key -- constraint "api_audit_log_organization_id_fkey" -- DETAIL: Key (organization_id)=(…) is not present in table "organizations". -- -- O cascade apaga os filhos, o trigger de audit de cada um insere em -- `api_audit_log` com o `organization_id` — e a organização já não existe. Só -- funcionava apagando os filhos à mão ANTES, com o pai vivo. -- -- Conserto: no DELETE, o audit é pulado quando a organização já não existe. Não -- se perde auditoria: a linha que ele escreveria seria apagada pelo cascade da -- própria organização um instante depois. E a checagem fica SÓ no ramo DELETE — -- pôr um `exists` no INSERT/UPDATE cobraria um SELECT em todo hot path de -- escrita para proteger de um caso que não acontece lá. -- -- ═══ DEFEITO 2 · não era possível apagar um AGENTE que já atendeu ═══ -- -- ERROR: new row for relation "crm_leads" violates check constraint -- "crm_leads_owner_kind_coherence" -- -- `crm_leads_owner_agent_id_fkey` é ON DELETE SET NULL; o CHECK exige -- `owner_agent_id not null` quando `owner_kind = 'ai'`. O SET NULL zera um lado -- e deixa o outro — estado que a constraint proíbe, com razão. -- -- Conserto: um BEFORE DELETE em `ai_agents` desfaz a atribuição INTEIRA (os dois -- campos), antes de a FK agir. O lead fica sem dono (`owner_kind is null`, que o -- CHECK aceita) em vez de ficar num estado meio-atribuído. -- -- Não se enfraquece o CHECK para tolerar `'ai'` sem agente: ele descreve um -- invariante verdadeiro, e afrouxá-lo para acomodar uma operação rara trocaria -- um erro barulhento por dados incoerentes em silêncio. -- ── 1 · o audit não persegue uma organização que está sendo removida ──────── create or replace function public.fn_audit_log_row() returns trigger language plpgsql security definer set search_path to 'public' as $$ declare v_action text; v_org uuid; begin if tg_op = 'INSERT' then v_action := tg_table_name || '.created'; v_org := new.organization_id; elsif tg_op = 'UPDATE' then v_action := tg_table_name || '.updated'; v_org := new.organization_id; elsif tg_op = 'DELETE' then v_action := tg_table_name || '.deleted'; v_org := old.organization_id; -- A organização está indo embora (cascade em curso). Registrar a exclusão -- de um filho num tenant que deixa de existir não tem consumidor: a linha -- seria apagada pelo cascade em seguida — e tentar escrevê-la aborta a -- transação inteira, que era o defeito. -- -- SÓ no ramo DELETE: um `exists` no INSERT/UPDATE cobraria um SELECT em -- todo hot path de escrita para cobrir um caso que não ocorre lá. if v_org is not null and not exists (select 1 from public.organizations where id = v_org) then return old; end if; end if; insert into public.api_audit_log (organization_id, actor_user_id, action, resource_type, resource_id, metadata) values ( v_org, auth.uid(), v_action, tg_table_name, coalesce(new.id, old.id), case when tg_op = 'UPDATE' then jsonb_build_object('changed_fields', '[diff suppressed in v0.1]') else '{}'::jsonb end ); return coalesce(new, old); end $$; -- ── 2 · apagar um agente desfaz a atribuição inteira, não metade dela ─────── create or replace function public.fn_liberar_leads_do_agente() returns trigger language plpgsql security definer set search_path to 'public' as $$ begin -- ANTES de a FK aplicar seu SET NULL. Zera os DOIS campos: deixar -- `owner_kind = 'ai'` com o agente nulo é exatamente o estado que -- `crm_leads_owner_kind_coherence` proíbe. update public.crm_leads set owner_agent_id = null, owner_kind = null where owner_agent_id = old.id; return old; end $$; -- As TRÊS origens de EXECUTE (CLAUDE.md, doutrina de migrations): -- `public` — o grant que o Postgres dá a toda função ao criá-la; -- `anon` — o ALTER DEFAULT PRIVILEGES do baseline, que alcança toda -- função criada depois dele; -- `authenticated` — idem, e é o que a varredura de hardening cobra. -- -- Revogar de todas é seguro AQUI porque o único call site é o TRIGGER, e o -- Postgres não exige EXECUTE do usuário para invocar função de trigger. Nenhuma -- sessão chama esta função diretamente. revoke execute on function public.fn_liberar_leads_do_agente() from public, anon, authenticated; grant execute on function public.fn_liberar_leads_do_agente() to service_role; drop trigger if exists trg_liberar_leads_do_agente on public.ai_agents; create trigger trg_liberar_leads_do_agente before delete on public.ai_agents for each row execute function public.fn_liberar_leads_do_agente(); comment on function public.fn_liberar_leads_do_agente() is 'Migration 0115: desfaz a atribuição de leads antes de o agente ser apagado. ' 'Sem isto o SET NULL da FK zera owner_agent_id e deixa owner_kind=''ai'', ' 'violando crm_leads_owner_kind_coherence — e um agente que já atendeu alguém ' 'não podia ser removido.'; -- ---- ai_purpose_bindings: qual modelo cada ponto usa (migration 0126) ---- -- Onde a escolha de modelo de cada ponto do sistema que usa IA passa a morar. -- Uma linha por (organização, ponto); ausência de linha = comportamento -- anterior preservado, então re-aplicar num clone não muda o funcionamento de -- nada. `provider` sem CHECK de propósito: é vocabulário aberto (os três CHECKs -- de provider que já existem são o que trava a entrada da OpenRouter, e um -- quarto repetiria o erro). `base_url` nasce para endpoint compatível com a API -- da OpenAI — OpenRouter hoje, modelo local depois. create table if not exists public.ai_purpose_bindings ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, purpose text not null, provider text not null, credential_id uuid references public.ai_provider_credentials(id) on delete cascade, model_id text not null, base_url text, is_enabled boolean not null default true, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); -- Deduplicar ANTES da constraint: um clone que tenha rodado uma versão -- intermediária desta frente pode ter duas linhas para o mesmo ponto, e aí o -- update.sh (que roda SEM ON_ERROR_STOP) morreria aqui em silêncio. Fica a -- mais recente, que é a última escolha do operador. delete from public.ai_purpose_bindings a using public.ai_purpose_bindings b where a.organization_id = b.organization_id and a.purpose = b.purpose and (a.updated_at, a.id) < (b.updated_at, b.id); do $$ begin if not exists ( select 1 from pg_constraint where conname = 'ai_purpose_bindings_org_purpose_unique' and conrelid = 'public.ai_purpose_bindings'::regclass ) then alter table public.ai_purpose_bindings add constraint ai_purpose_bindings_org_purpose_unique unique (organization_id, purpose); end if; end $$; create index if not exists ai_purpose_bindings_org_idx on public.ai_purpose_bindings (organization_id); create index if not exists ai_purpose_bindings_lookup_idx on public.ai_purpose_bindings (organization_id, purpose) where is_enabled; create index if not exists ai_purpose_bindings_credential_idx on public.ai_purpose_bindings (credential_id) where credential_id is not null; -- (migration 0141) A FK da credencial nasceu `on delete cascade`, e isso fazia -- rotacionar uma chave — apagar a antiga, cadastrar a nova — APAGAR a linha -- inteira do binding, levando junto provider, model_id e base_url. A tela -- passava a dizer "Usando o padrão da organização", frase verdadeira sobre um -- estado que ninguém escolheu. `set null` desvincula sem apagar: NULL já -- significa "use a chave da instalação", que é como todo binding nasce. -- Reescrita incondicional (drop + add) para o clone que já tem o CASCADE. alter table public.ai_purpose_bindings drop constraint if exists ai_purpose_bindings_credential_id_fkey; alter table public.ai_purpose_bindings add constraint ai_purpose_bindings_credential_id_fkey foreign key (credential_id) references public.ai_provider_credentials(id) on delete set null; alter table public.ai_purpose_bindings enable row level security; drop policy if exists tenant_isolation_ai_purpose_bindings_all on public.ai_purpose_bindings; drop trigger if exists ai_purpose_bindings_updated_at on public.ai_purpose_bindings; create trigger ai_purpose_bindings_updated_at before update on public.ai_purpose_bindings for each row execute function public.fn_set_updated_at(); comment on table public.ai_purpose_bindings is 'Migration 0126: qual provedor/credencial/modelo cada ponto do sistema que usa IA deve usar, por organização. O catálogo dos pontos vive em lib/ai/pontos/registro.ts e o par é vigiado por tests/unit/pontos-de-ia-completude.test.ts.'; -- ---- provider vira vocabulário aberto + catálogo sincronizável (migration 0127) ---- -- Os três CHECKs de provider travavam anthropic|openai|google, o que torna -- impossível cadastrar uma chave da OpenRouter (ou de qualquer provedor novo, ou -- de um modelo local) — o INSERT viola constraint antes de qualquer código rodar. -- Vocabulário ABERTO por doutrina: quem recusa provider desconhecido é o registry, -- com erro tipado, não uma constraint que faria o update.sh do clone quebrar. alter table public.ai_agent_versions drop constraint if exists ai_agent_versions_provider_check; alter table public.ai_models drop constraint if exists ai_models_provider_check; alter table public.ai_provider_credentials drop constraint if exists ai_provider_credentials_provider_check; -- Aberto não é livre: string vazia seria linha que nenhum registry resolve e -- nenhuma tela exibe. do $$ begin if not exists (select 1 from pg_constraint where conname = 'ai_models_provider_nao_vazio') then alter table public.ai_models add constraint ai_models_provider_nao_vazio check (length(btrim(provider)) > 0); end if; if not exists (select 1 from pg_constraint where conname = 'ai_provider_credentials_provider_nao_vazio') then alter table public.ai_provider_credentials add constraint ai_provider_credentials_provider_nao_vazio check (length(btrim(provider)) > 0); end if; if not exists (select 1 from pg_constraint where conname = 'ai_agent_versions_provider_nao_vazio') then alter table public.ai_agent_versions add constraint ai_agent_versions_provider_nao_vazio check (length(btrim(provider)) > 0); end if; end $$; alter table public.ai_models add column if not exists source text not null default 'manual'; alter table public.ai_models add column if not exists synced_at timestamptz; alter table public.ai_models add column if not exists supports_vision boolean not null default false; -- Deduplicar ANTES do índice único (o update.sh roda sem ON_ERROR_STOP: índice -- que falha é pulado em silêncio e o upsert do sincronizador volta a duplicar). delete from public.ai_models a using public.ai_models b where a.provider = b.provider and a.model_id = b.model_id and ( (a.input_price_per_million_cents is null and b.input_price_per_million_cents is not null) or ( (a.input_price_per_million_cents is null) = (b.input_price_per_million_cents is null) and a.id < b.id ) ); -- O índice da 0127 só nasce onde a unicidade FALTA. A constraint -- `ai_models_unique (provider, model_id)` vem do schema original (0023) e já -- garante o upsert do sincronizador; criar o índice ao lado dela era construir -- à toa uma cópia que o bloco da 0259 derruba no fim deste arquivo. Onde a -- constraint não existe (removida à mão, ou recusada acima pelo dump porque -- havia duplicata — o delete logo acima acabou de limpá-la), o índice é a única -- garantia, e continua sendo criado. do $$ begin if not exists ( select 1 from pg_constraint where conname = 'ai_models_unique' and conrelid = 'public.ai_models'::regclass ) then create unique index if not exists ai_models_provider_model_unique on public.ai_models (provider, model_id); end if; end $$; create index if not exists ai_models_source_idx on public.ai_models (source) where deprecated_at is null; comment on column public.ai_models.source is 'Migration 0127: ''manual'' ou o nome do sincronizador (ex.: ''openrouter''). O sincronizador só mexe nas linhas da PRÓPRIA origem — apagar o que um humano cadastrou seria perder configuração sem aviso.'; comment on column public.ai_models.synced_at is 'Migration 0127: quando a origem confirmou este modelo pela última vez. Modelo que some recebe deprecated_at, nunca DELETE: a linha ainda é referenciada pelo histórico de custo.'; -- ---- llm_calls registra a FALHA, não só o sucesso (migration 0128) ---- -- A tabela gravava uma linha por chamada de modelo e só quando dava certo: o -- INSERT vivia depois do generateText, sem try em volta. Provedor recusando a -- chave, modelo inexistente, conta sem saldo — a exceção subia e nada ficava -- gravado. A tabela que deveria explicar era justamente a que ficava vazia no -- caso que precisa de explicação, e é a causa direta de "o agente não responde -- e não aparece erro em lugar nenhum". alter table public.llm_calls add column if not exists status text not null default 'ok'; alter table public.llm_calls add column if not exists error_code text; alter table public.llm_calls add column if not exists error_message text; alter table public.llm_calls add column if not exists http_status int; alter table public.llm_calls add column if not exists origem_da_escolha text; -- Corrigir os dados ANTES da constraint: o update.sh roda sem ON_ERROR_STOP, e -- um CHECK que falhasse seria pulado em silêncio, deixando o clone sem guarda. update public.llm_calls set status = 'ok' where status is null or status not in ('ok', 'erro'); do $$ begin if not exists (select 1 from pg_constraint where conname = 'llm_calls_status_check') then alter table public.llm_calls add constraint llm_calls_status_check check (status in ('ok', 'erro')); end if; end $$; create index if not exists llm_calls_erros_idx on public.llm_calls (organization_id, created_at desc) where status = 'erro'; create index if not exists llm_calls_purpose_idx on public.llm_calls (organization_id, purpose, created_at desc); comment on column public.llm_calls.status is 'Migration 0128: ''ok'' | ''erro''. Antes desta migration a tabela só registrava sucesso.'; comment on column public.llm_calls.error_message is 'Migration 0128: texto do provedor, truncado. NUNCA prompt, resposta ou chave.'; comment on column public.llm_calls.origem_da_escolha is 'Migration 0128: quem decidiu usar este modelo. Transforma o log de "o que aconteceu" em "por que aconteceu".'; -- ---- uma tabela de telemetria de IA, não duas (migration 0130) ---- -- `agent_id` NÃO existia em llm_calls, e sem ele a unificação jogaria fora a -- atribuição de custo por agente — junto com o filtro por agente da tela de uso, -- que é como o operador descobre qual agente está consumindo a conta. Perder uma -- capacidade em nome de unificar seria trocar um problema por outro. alter table public.llm_calls add column if not exists agent_id uuid references public.ai_agents(id) on delete set null; create index if not exists llm_calls_agent_idx on public.llm_calls (organization_id, agent_id, created_at desc) where agent_id is not null; alter table public.llm_calls add column if not exists legacy_invocation_id uuid; create unique index if not exists llm_calls_legacy_invocation_unique on public.llm_calls (legacy_invocation_id) where legacy_invocation_id is not null; comment on column public.llm_calls.legacy_invocation_id is 'Migration 0130: id da linha de ai_invocations que originou esta. Existe para o backfill ser ' 'idempotente — o update.sh re-aplica o baseline a cada atualização, e sem esta marca o custo ' 'histórico cresceria sozinho a cada execução.'; -- O backfill. `on conflict do nothing` sobre o índice único faz a re-execução -- ser inócua. `purpose` recebe o `invocation_kind` porque é o mesmo eixo com -- nomes diferentes; o vocabulário de ambos já está no registro de pontos. insert into public.llm_calls ( organization_id, agent_id, contact_id, job_id, purpose, provider, model, input_tokens, output_tokens, cost_cents, latency_ms, created_at, status, error_code, legacy_invocation_id ) select i.organization_id, i.agent_id, null, -- ai_invocations guarda conversation/message, não contato null, i.invocation_kind, -- O provider não era guardado; deriva-se do prefixo do modelo, e quando não -- dá para saber vai 'desconhecido' em vez de um chute que viraria estatística. case when i.model like 'anthropic/%' then 'anthropic' when i.model like 'openai/%' then 'openai' when i.model like 'google/%' then 'google' when i.model like 'claude%' then 'anthropic' when i.model like 'gpt%' then 'openai' when i.model like 'gemini%' then 'google' else 'desconhecido' end, i.model, i.prompt_tokens, i.completion_tokens, i.cost_cents, i.latency_ms, i.created_at, case when i.error_payload is not null then 'erro' else 'ok' end, case when i.error_payload is not null then 'erro_legado' else null end, i.id from public.ai_invocations i where not exists ( select 1 from public.llm_calls c where c.legacy_invocation_id = i.id ) on conflict do nothing; comment on table public.ai_invocations is 'DEPRECIADA na migration 0130 — a telemetria de IA vive em llm_calls. Mantida como histórico ' '(a doutrina do repo é depreciar, não deletar) e porque as linhas antigas são a prova do que foi ' 'gasto. Nada escreve mais aqui; leituras novas usam llm_calls.'; -- ---- o orçamento do mês não conta o backfill como gasto novo (migration 0140) ---- -- -- Este bloco tem de vir DEPOIS do backfill da 0130, e é por isso que ele está -- aqui e não junto do trigger da 0095. `fn_update_budget_consumption` soma -- `NEW.cost_cents` sem olhar a data, e o backfill é um INSERT: cada linha -- migrada — inclusive as de meses passados — era somada ao consumo do mês -- corrente. Medido em pg17: gasto real do mês 1600, contador em 3000 depois de -- um `update.sh` e estabilizando em 2600, nunca em 1600. Numa organização sem -- gasto no mês, o contador saltava de 0 para o histórico inteiro — 200% do -- limite padrão no caso medido — e a IA do clone podia parar sem nenhuma -- chamada nova. -- -- A correção é dar a ÚLTIMA PALAVRA a um recomputo que ATRIBUI (não incrementa) -- o gasto real do mês, contando cada linha uma vez só. Vale qualquer que tenha -- sido o estado deixado pelo trigger, e a re-aplicação chega no mesmo número. -- Racional completo em supabase/migrations/20260808050000_0140_*.sql. insert into public.ai_budgets (organization_id, current_month_consumed_cents) select o.id, coalesce((select sum(c.cost_cents) from public.llm_calls c where c.organization_id = o.id and c.created_at >= date_trunc('month', now())), 0) + coalesce((select sum(i.cost_cents) from public.ai_invocations i where i.organization_id = o.id and i.created_at >= date_trunc('month', now()) and not exists ( select 1 from public.llm_calls c2 where c2.legacy_invocation_id = i.id )), 0) from public.organizations o on conflict (organization_id) do update set current_month_consumed_cents = excluded.current_month_consumed_cents, updated_at = now(); -- ---- telefone do contato @lid (migration 0122) ---- -- O kit self-host aplica SÓ este arquivo — no install (banco novo, ON_ERROR_STOP) -- e no update (banco existente, SEM a flag). Tudo abaixo é idempotente e -- auto-curativo: a dedup por lid roda ANTES do índice único, senão o update.sh -- de um clone com contatos duplicados quebra no meio. -- -- Racional medido em supabase/migrations/20260807060000_0122_telefone_do_lid.sql. -- Em uma linha: 76 de 76 payloads @lid trazem o telefone em -- `_data.key.remoteJidAlt` e ninguém lia; e gravar esse telefone mudava a -- `wa_identity` GERADA, quebrava o reencontro pelo `on conflict` e duplicava o -- contato — por isso a correlação passa a ter coluna própria (`wa_lid`). -- ---- 1 · wa_lid: a correlação que sobrevive ao telefone ---- -- Gerada, e não escrita à mão, pelo mesmo motivo de `wa_identity`: valor -- derivado que alguém precisa lembrar de atualizar é valor que diverge. O -- `nullif` no fim evita que contato sem lid vire string vazia e colida no índice -- único com todos os outros contatos sem lid. alter table public.contacts add column if not exists wa_lid text generated always as ( nullif(regexp_replace(coalesce(source_metadata->>'waha_lid', ''), '@.*$', ''), '') ) stored; -- ---- 2 · deduplicar ANTES da constraint (auto-curativo) ---- -- Um clone pode ter dois contatos com o mesmo lid — nasceram antes da 0027, ou -- de uma janela em que o upsert ainda fazia check-then-act. Criar o índice único -- sem tratar isso quebraria o `update.sh` do clone, que é exatamente o que a -- doutrina de migrations proíbe. -- -- O sobrevivente é o mais ANTIGO (é dele o histórico); os outros são marcados -- como fundidos e suas referências repontadas — mesma mecânica do bloco B1 da -- 0027, que já existe no baseline. with ranked as ( select id, first_value(id) over ( partition by organization_id, nullif(regexp_replace(coalesce(source_metadata->>'waha_lid', ''), '@.*$', ''), '') order by created_at asc, id asc ) as canonical_id from public.contacts where is_merged_into is null and nullif(regexp_replace(coalesce(source_metadata->>'waha_lid', ''), '@.*$', ''), '') is not null ) update public.contacts c set is_merged_into = r.canonical_id, merged_at = now() from ranked r where c.id = r.id and r.id <> r.canonical_id; update public.conversations t set contact_id = c.is_merged_into from public.contacts c where t.contact_id = c.id and c.is_merged_into is not null; update public.messages t set contact_id = c.is_merged_into from public.contacts c where t.contact_id = c.id and c.is_merged_into is not null; update public.ai_agent_runs t set contact_id = c.is_merged_into from public.contacts c where t.contact_id = c.id and c.is_merged_into is not null; update public.crm_lead_activities t set contact_id = c.is_merged_into from public.contacts c where t.contact_id = c.id and c.is_merged_into is not null; update public.crm_leads t set contact_id = c.is_merged_into from public.contacts c where t.contact_id = c.id and c.is_merged_into is not null; update public.lgpd_requests t set contact_id = c.is_merged_into from public.contacts c where t.contact_id = c.id and c.is_merged_into is not null; update public.orders t set contact_id = c.is_merged_into from public.contacts c where t.contact_id = c.id and c.is_merged_into is not null; create unique index if not exists uniq_contacts_org_wa_lid on public.contacts (organization_id, wa_lid) where wa_lid is not null and is_merged_into is null; -- ---- 3 · o upsert passa a reencontrar por LID, e a completar o que falta ---- -- A versão de 6 parâmetros tinha DOIS buracos, além do telefone: -- (a) no conflito só mexia em `display_name`, com `coalesce(existente, novo)` — -- um nome ruim gravado uma vez congelava para sempre e nenhum dado -- descoberto depois entrava; -- (b) casava só por `wa_identity`, então não reencontrava o contato cuja -- identidade mudou. -- -- A regra nova é "completar, nunca sobrescrever": o que já está preenchido -- vence, o que está vazio é preenchido. Assim um telefone descoberto no 5º -- webhook entra, e um nome que o atendente corrigiu à mão não é desfeito pelo -- pushName do WhatsApp. create or replace function public.fn_upsert_wa_contact( p_org uuid, p_kind text, p_phone text, p_lid text, p_chat_id text, p_notify text ) returns uuid language plpgsql security definer set search_path = public as $$ declare v_id uuid; v_conflito text; v_lid text := nullif(regexp_replace(coalesce(p_lid, ''), '@.*$', ''), ''); v_phone text := nullif(p_phone, ''); begin -- ⚠️ A ASSINATURA NÃO MUDA, e não é economia de digitação. -- -- A primeira versão desta migration acrescentava um 7º parâmetro -- (`p_phone_alt`) para o telefone vindo de `_data.key.remoteJidAlt`. Isso -- criava uma função nova aos olhos do Postgres, obrigava a dropar a de 6 e -- forçava a edição de DOIS invariantes de hardening que citam a assinatura — -- que o hook do repo (com razão) congela. -- -- Quem sabe QUAL telefone usar é o chamador: `lib/waha/ingest.ts` já resolve o -- chatId e agora também lê o `remoteJidAlt`. Ele manda um telefone só, em -- `p_phone`. Menos superfície, mesma capacidade, e os grants existentes -- continuam valendo — a catraca levou ao desenho menor. -- 1 · pela correlação do WhatsApp, que NÃO depende do telefone. -- `wa_identity` é gerada com o telefone na frente do lid: um contato @lid -- que ganha número passa a valer `phone:+Y` e o `on conflict` antigo -- deixava de reencontrá-lo — nascia um contato por mensagem. if v_lid is not null then select id into v_id from public.contacts where organization_id = p_org and wa_lid = v_lid and is_merged_into is null limit 1; end if; -- 2 · pelo telefone — é aqui que a pessoa que já existia por número (import, -- formulário, pedido) deixa de virar um segundo contato ao escrever no -- WhatsApp. Sem este passo, descobrir o telefone criaria o gêmeo em vez de -- evitá-lo. if v_id is null and v_phone is not null then select id into v_id from public.contacts where organization_id = p_org and phone_number = v_phone and is_merged_into is null limit 1; end if; -- 3 · COMPLETA o que falta, nunca sobrescreve. -- A versão anterior só mexia em `display_name` no conflito, com -- `coalesce(existente, novo)`: um nome ruim gravado uma vez congelava para -- sempre, e telefone ou lid descobertos depois NUNCA entravam. -- O telefone descoberto só sobe para a coluna ÚNICA se ainda não for de outro -- contato vivo da org. Sem esta guarda o caso "contato @lid sem telefone + a -- mesma pessoa já cadastrada por número" (import, pedido, formulário) estoura -- `uniq_contacts_org_phone`; `lib/waha/ingest.ts:343` transforma a exceção em -- `return null` e `:459` descarta a mensagem com o webhook respondendo 200 — a -- mensagem do cliente some, e some de novo a cada mensagem seguinte daquele -- contato. Medido na triagem; não acontece na `main`, é regressão desta -- migration. A etapa 2 (busca por telefone) não protege: ela só roda quando a -- etapa 1 NÃO achou. -- -- Fundir os dois contatos seria o desfecho semanticamente certo — é a mesma -- pessoa, e o `remoteJidAlt` é justamente quem afirma isso. Mas fusão é -- IRREVERSÍVEL, e a regra do tempo da doutrina proíbe consumar irreversível no -- tempo da máquina, dentro de um webhook. Aqui o dado não se perde: vai para -- `source_metadata.telefone_em_conflito`, que não é único, e a decisão de -- fundir fica para quem opera. if v_id is not null and v_phone is not null and exists ( select 1 from public.contacts where organization_id = p_org and phone_number = v_phone and is_merged_into is null and id <> v_id ) then v_conflito := v_phone; v_phone := null; end if; if v_id is not null then update public.contacts set phone_number = coalesce(phone_number, v_phone), display_name = coalesce(display_name, nullif(p_notify, '')), source_metadata = source_metadata || case when v_lid is not null then jsonb_build_object('waha_lid', v_lid) else '{}'::jsonb end || case when p_chat_id is not null then jsonb_build_object('waha_chat_id', p_chat_id) else '{}'::jsonb end || case when nullif(p_notify, '') is not null then jsonb_build_object('notify_name', p_notify) else '{}'::jsonb end || case when v_conflito is not null then jsonb_build_object('telefone_em_conflito', v_conflito) else '{}'::jsonb end, updated_at = now() where id = v_id; return v_id; end if; insert into public.contacts (organization_id, phone_number, source, consent, tags, source_metadata, display_name) values (p_org, v_phone, 'whatsapp', '{}'::jsonb, '{}'::text[], case when v_lid is not null then jsonb_build_object('waha_lid', v_lid, 'waha_chat_id', p_chat_id, 'notify_name', nullif(p_notify, '')) else jsonb_build_object('waha_chat_id', p_chat_id, 'notify_name', nullif(p_notify, '')) end, nullif(p_notify, '')) returning id into v_id; return v_id; end; $$; -- Os grants da assinatura de 6 já existem desde a 0027 e continuam valendo — por -- isso não há `drop function` aqui, e por isso os invariantes de hardening não -- precisaram ser tocados. -- ---- 4 · o rótulo técnico legado sai ---- -- Medido na produção: 3 linhas com `Contato 543134@lid` e `Contato 900928` — -- duas formas, porque duas versões do código antigo os escreveram. Nenhum código -- vivo produz isso hoje (o produtor morreu no commit c890b403); é resíduo, e o -- passo seguinte da spec 17 vai LER o nome do contato para o título do card, o -- que faria o resíduo vazar para o kanban. -- -- ⚠️ `and is_anonymized = false` NÃO é zelo: `Contato Anonimizado #` também -- começa com "Contato " e é gravado deliberadamente pela rota de LGPD. Sem esta -- guarda, o backfill REVERTERIA anonimizações — violação direta da regra L-04, -- cuja exceção é "Nenhuma". -- -- Vira NULL, e não um rótulo novo: quem decide o que mostrar quando não há nome -- é a tela. Gravar texto de exibição no banco foi o que criou este problema. update public.contacts set display_name = null, updated_at = now() where display_name ~ '^Contato [0-9]+(@lid)?$' and is_anonymized = false; -- ---- fila de confirmação de dado do contato (migration 0123) ---- -- O Operador PROPÕE, um humano CONFIRMA — o dado que o cliente diz na conversa -- não é gravado direto (spec 17 §4b). Forma copiada de `crm_lead_reactivations`, -- que já é uma fila de proposta com prazo, decisão datada e idempotência por -- índice parcial; a chave aqui é o CONTATO + campo, porque a proposta é sobre a -- pessoa. Racional completo na migration. -- -- Idempotente e auto-curativo: `create table if not exists`, constraints com -- `drop ... if exists` antes, `create or replace function`. create table if not exists public.contact_field_proposals ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, contact_id uuid not null references public.contacts(id) on delete cascade, -- QUAL campo. Vocabulário FECHADO por CHECK: o que entra aqui vira escrita em -- `contacts`, e campo livre deixaria a IA propor qualquer coluna. campo text not null, -- O valor proposto e o que existia quando a proposta nasceu. O segundo é o -- `from` que a regra L-06 exige — e existe ANTES da confirmação justamente -- para que a decisão seja tomada com os dois lados à vista. valor_proposto text not null, valor_anterior text, -- DE ONDE veio, para quem decide poder conferir em vez de acreditar. -- `trecho` é o que a pessoa escreveu; sem ele a confirmação é um ato de fé. conversation_id uuid references public.conversations(id) on delete set null, message_id uuid references public.messages(id) on delete set null, trecho text, proposed_by_agent_id uuid references public.ai_agents(id) on delete set null, status text not null default 'pending', -- Carimbados pelo BANCO, nunca pelo processo — mesma razão da 0081: instantes -- comparados entre si vêm do mesmo relógio. proposed_at timestamptz not null default now(), expires_at timestamptz not null, decided_at timestamptz, decided_by_user_id uuid references auth.users(id) on delete set null, -- Por que foi recusada. É o LAÇO DE RETORNO (invariante 7): proposta que o -- humano rejeita diz onde a IA erra, e sem o motivo o sinal é só um número. motivo_recusa text, updated_at timestamptz not null default now() ); comment on table public.contact_field_proposals is 'Dado do contato que a IA ouviu na conversa e propôs — aguardando confirmação humana (spec 17 §4b). SEMPRE com prazo: proposta que ninguém decide vira badge permanente, que simula atenção e adia a decisão. No vencimento sai da tela e vira item de caixa.'; -- 0412 (issue #1546): `birthdate` entra AQUI, no bloco Único desta constraint — -- o apêndice de uma migration que só amplia vocabulário NÃO reconstrói a -- constraint (uma constraint, um bloco, `baseline-constraint-reconstruida`): -- dois blocos fariam o primeiro falhar no `update.sh` de um clone cuja fila já -- tenha uma proposta de nascimento, deixando a tabela SEM constraint entre o -- drop e o add que funciona. alter table public.contact_field_proposals drop constraint if exists contact_field_proposals_campo_check; alter table public.contact_field_proposals add constraint contact_field_proposals_campo_check check ( campo = any (array['email', 'name', 'phone_number', 'birthdate']::text[]) ); alter table public.contact_field_proposals drop constraint if exists contact_field_proposals_status_check; alter table public.contact_field_proposals add constraint contact_field_proposals_status_check check ( status = any (array['pending', 'accepted', 'dismissed', 'expired']::text[]) ); -- Prazo no futuro: proposta que nasce vencida vira item de caixa no primeiro -- tick e ninguém entende de onde veio. alter table public.contact_field_proposals drop constraint if exists contact_field_proposals_prazo_no_futuro; alter table public.contact_field_proposals add constraint contact_field_proposals_prazo_no_futuro check (expires_at > proposed_at); -- Decisão e decisor andam juntos. Status decidido sem `decided_at` é registro -- que não sabe dizer quando aconteceu — e é essa a pergunta que a auditoria faz. alter table public.contact_field_proposals drop constraint if exists contact_field_proposals_decisao_datada; alter table public.contact_field_proposals add constraint contact_field_proposals_decisao_datada check ( (status = 'pending' and decided_at is null) or (status <> 'pending' and decided_at is not null) ); -- ⚠️ ESTE ÍNDICE É A IDEMPOTÊNCIA — não é otimização. -- -- A IA vai ouvir o mesmo e-mail em dez mensagens seguidas. Sem ele, dez -- propostas idênticas viram dez linhas e a tela do humano vira uma coluna de -- repetições. `where not exists` no código NÃO substitui: é check-then-act, e -- dois turnos concorrentes passam pela janela — o mesmo defeito que a 0027 veio -- matar nos contatos. -- -- PARCIAL: propostas decididas ficam como histórico e não bloqueiam a próxima. O -- cliente pode corrigir o e-mail que ele mesmo deu errado, e impedir isso -- deixaria a correção sem caminho. create unique index if not exists uq_contact_field_proposals_uma_viva on public.contact_field_proposals (organization_id, contact_id, campo) where status = 'pending'; -- O worker de vencimento varre por aqui. create index if not exists idx_contact_field_proposals_vencendo on public.contact_field_proposals (organization_id, expires_at) where status = 'pending'; alter table public.contact_field_proposals enable row level security; drop policy if exists tenant_isolation_contact_field_proposals_all on public.contact_field_proposals; create policy tenant_isolation_contact_field_proposals_all on public.contact_field_proposals for all using (organization_id in (select public.fn_user_org_ids())) with check (organization_id in (select public.fn_user_org_ids())); revoke all on public.contact_field_proposals from anon; -- `proposed_at` e `updated_at` vêm do banco. create or replace function public.fn_carimba_proposta_de_dado() returns trigger language plpgsql set search_path to 'public', 'pg_temp' as $$ begin if tg_op = 'INSERT' then new.proposed_at := now(); end if; new.updated_at := now(); return new; end$$; revoke all on function public.fn_carimba_proposta_de_dado() from public, anon; drop trigger if exists trg_contact_field_proposals_carimbo on public.contact_field_proposals; create trigger trg_contact_field_proposals_carimbo before insert or update on public.contact_field_proposals for each row execute function public.fn_carimba_proposta_de_dado(); -- ---- LGPD: anonimizar o contato apaga as propostas dele ---- -- -- Sem isto, anonimizar um contato deixaria o e-mail dele VIVO dentro de uma -- proposta pendente — PII sobrevivendo ao direito de esquecimento numa tabela -- que ninguém lembraria de olhar. -- -- ⚠️ TRIGGER NO ESTADO, não chamada dentro do cascade — e a escolha importa. -- Há mais de um caminho que anonimiza: `fn_lgpd_cascade_redact_contact` (o -- cascade completo) e `/api/v1/lgpd/anonymize` (a rota direta), e amanhã pode -- haver um DBA fazendo à mão. Pendurar a limpeza em UM deles deixaria os outros -- vazando; pendurar no FATO (`is_anonymized` virou true) cobre todos, inclusive -- os que ainda não existem. É também a diferença entre editar uma função de 180 -- linhas vinda de dump — com o risco que isso traz — e acrescentar 10. -- -- As propostas são APAGADAS, não redigidas: diferente da timeline, aqui não há -- histórico a preservar (proposta não decidida nunca virou fato) e o conteúdo é -- integralmente dado pessoal. create or replace function public.fn_apaga_propostas_de_contato_anonimizado() returns trigger language plpgsql security definer set search_path to 'public', 'pg_temp' as $$ begin delete from public.contact_field_proposals where contact_id = new.id; return new; end$$; revoke all on function public.fn_apaga_propostas_de_contato_anonimizado() from public; revoke execute on function public.fn_apaga_propostas_de_contato_anonimizado() from anon; revoke execute on function public.fn_apaga_propostas_de_contato_anonimizado() from authenticated; drop trigger if exists trg_contacts_anonimizado_limpa_propostas on public.contacts; create trigger trg_contacts_anonimizado_limpa_propostas after update of is_anonymized on public.contacts for each row when (new.is_anonymized = true and coalesce(old.is_anonymized, false) = false) execute function public.fn_apaga_propostas_de_contato_anonimizado(); -- ---- escopo de funil do agente (migration 0125) ---- -- O agente só ESCREVE nos funis marcados; vazio = NENHUM (falha fechada). -- Medido: uma organização com 4 funis e 5 agentes de negócios diferentes, todos -- alcançando todos. A coluna vive na VERSÃO para a permissão subir junto com o -- resto quando alguém publica — escopo fora do ciclo rascunho→publicar muda o -- alcance do agente sem ninguém ter publicado nada. -- -- Traz junto o conserto do trigger de imutabilidade, que parava no `followup` e -- ignorava as NOVE colunas posteriores: sem isso, um escopo de PERMISSÃO seria -- editável numa versão publicada sem virar versão nova — a própria ausência de -- escopo, com aparência de controle. Racional completo na migration. alter table public.ai_agent_versions add column if not exists pipeline_ids uuid[] not null default '{}'::uuid[]; comment on column public.ai_agent_versions.pipeline_ids is 'Funis em que ESTE agente pode escrever (mover, editar, encerrar, taguear). Vazio = NENHUM: falha fechada. Escopo de ESCRITA; leitura não é filtrada por aqui (declarado na spec 17 §5).'; -- ---- backfill: o que JÁ funcionava continua funcionando ---- -- -- "Agente novo nasce fechado" e "agente existente vira fechado retroativamente" -- são coisas MUITO diferentes. Sem este bloco, no dia do deploy todo agente em -- produção pararia de mexer em card — de uma vez, e em silêncio. -- -- O escopo inicial é DERIVADO do que cada agente realmente fez: os funis onde -- ele já registrou atividade. Isso respeita o que funcionava E fecha os funis -- que ele nunca tocou, que é o objetivo. -- -- Medido antes de escrever: na produção deste projeto, apenas 1 dos 8 agentes -- tem histórico (o SDR, no funil "Pedidos"). Os outros 7 nascem fechados sem -- quebrar nada, porque nunca moveram card nenhum. -- -- Só para versões PUBLICADAS/rascunho que ainda estão vazias — re-aplicar não -- reabre escopo que alguém tenha fechado à mão depois. update public.ai_agent_versions v set pipeline_ids = sub.funis from ( select a.actor_agent_id as agent_id, array_agg(distinct l.pipeline_id) as funis from public.crm_lead_activities a join public.crm_leads l on l.id = a.lead_id where a.actor_agent_id is not null group by a.actor_agent_id ) sub where v.agent_id = sub.agent_id and v.pipeline_ids = '{}'::uuid[]; -- ---- o trigger de imutabilidade para de ignorar metade da configuração ---- -- -- ⚠️ CONSERTO OBRIGATÓRIO NO MESMO ARQUIVO, e não uma limpeza de brinde. -- -- `fn_ai_agent_version_content_immutable` parava no campo `followup` e não -- conhecia NENHUMA das nove colunas acrescentadas depois dele. Numa versão já -- PUBLICADA era possível trocar o modelo do Operador, as ferramentas dele, o -- corte de mensagens — sem virar versão nova e sem deixar trilha. -- -- Acrescentar `pipeline_ids` sem consertar isso seria pior que não acrescentar: -- um escopo de PERMISSÃO editável em produção sem publicar nada é a própria -- ausência de escopo, com aparência de controle. create or replace function fn_ai_agent_version_content_immutable() returns trigger language plpgsql as $fn$ begin if old.status <> 'draft' and ( new.system_prompt is distinct from old.system_prompt or new.provider is distinct from old.provider or new.model is distinct from old.model or new.credential_id is distinct from old.credential_id or new.tool_ids is distinct from old.tool_ids or new.trigger_config is distinct from old.trigger_config or new.channel_session_id is distinct from old.channel_session_id or new.max_steps is distinct from old.max_steps or new.token_budget is distinct from old.token_budget or new.cost_budget_cents is distinct from old.cost_budget_cents or new.history_message_window is distinct from old.history_message_window or new.history_token_window is distinct from old.history_token_window or new.handoff_keywords is distinct from old.handoff_keywords or new.handoff_tool_enabled is distinct from old.handoff_tool_enabled or new.followup is distinct from old.followup -- ↓ as nove que o trigger nunca cobriu, mais a desta migration or new.multimodal_input is distinct from old.multimodal_input or new.video_frames_enabled is distinct from old.video_frames_enabled or new.split_messages is distinct from old.split_messages or new.split_max_chars is distinct from old.split_max_chars or new.cases_enabled is distinct from old.cases_enabled or new.operator_enabled is distinct from old.operator_enabled or new.operator_model is distinct from old.operator_model or new.operator_tool_ids is distinct from old.operator_tool_ids or new.pipeline_ids is distinct from old.pipeline_ids or new.version_number is distinct from old.version_number or new.agent_id is distinct from old.agent_id or new.organization_id is distinct from old.organization_id ) then raise exception 'ai_agent_versions % é imutável (status=%): mudança de conteúdo = versão draft nova; rollback = revert (clona + publica)', old.id, old.status; end if; return new; end; $fn$; drop trigger if exists trg_ai_agent_versions_content_immutable on public.ai_agent_versions; create trigger trg_ai_agent_versions_content_immutable before update on public.ai_agent_versions for each row execute function fn_ai_agent_version_content_immutable(); notify pgrst, 'reload schema'; -- ---- camadas de segurança por organização (migration 0142) ---- -- -- As duas verificações que consultam um modelo (e por isso custam por mensagem) -- passam a ser escolha da organização, na tela do agente, em vez de variável de -- ambiente do worker — que é por PROCESSO e só alcançável por quem edita o .env -- da VPS e reinicia o contêiner. -- -- AUSÊNCIA DE LINHA NÃO É "DESLIGADO": sem linha, vale o ambiente. É o que -- mantém intacta a instalação que já decidiu isso no .env — aplicar este bloco -- não muda o comportamento de ninguém, só cria a porta. -- -- `layer` sem CHECK, de propósito (vocabulário ABERTO, CLAUDE.md): um clone com -- valor que este build não conhece quebraria o update.sh. O vocabulário vive no -- TypeScript. -- -- Idempotente e auto-curativo: `create table if not exists` + `drop policy if -- exists` antes do `create policy`. -- -- TABELA NOVA NASCE CONCEDIDA, e não só função: o `ALTER DEFAULT PRIVILEGES ... -- GRANT ALL ON TABLES TO anon/authenticated` deste mesmo baseline vale para toda -- tabela criada depois dele. A primeira versão deste bloco dizia "nenhuma função -- nova, então não há grant a revogar" — leitura errada da doutrina, que fala de -- FUNÇÃO. O efeito medido está no cabeçalho da migration 0142. create table if not exists public.org_guardrail_layers ( organization_id uuid not null references public.organizations(id) on delete cascade, layer text not null, enabled boolean not null, updated_at timestamptz not null default now(), primary key (organization_id, layer) ); alter table public.org_guardrail_layers enable row level security; -- ---- escrita de guardrail exige admin (migration 0143) ---- -- -- Leitura org-flat, escrita com gate de PAPEL no banco (forma canônica do repo: -- ver `crm_stages_select` / `crm_stages_manager_write` acima). O `admin` da rota -- não é fronteira — com a anon key e o próprio JWT, um `viewer` desligava a camada -- anti-jailbreak da organização pelo PostgREST, sem auditoria. Medido: UPDATE 1 + -- INSERT 1 num pg17 do zero. -- -- Auto-curativo: derruba a policy da 0142 por nome antes de criar as duas novas, -- então o `update.sh` de um clone que parou na 0142 fica correto sem passo manual. drop policy if exists tenant_isolation_org_guardrail_layers_all on public.org_guardrail_layers; drop policy if exists org_guardrail_layers_select on public.org_guardrail_layers; drop policy if exists org_guardrail_layers_admin_write on public.org_guardrail_layers; create policy org_guardrail_layers_select on public.org_guardrail_layers for select using ( (organization_id in (select public.fn_user_org_ids())) or public.fn_is_platform_admin() ); create policy org_guardrail_layers_admin_write on public.org_guardrail_layers using ( public.fn_is_platform_admin() or ((organization_id in (select public.fn_user_org_ids())) and public.fn_role_at_least(organization_id, 'admin')) ) with check ( public.fn_is_platform_admin() or ((organization_id in (select public.fn_user_org_ids())) and public.fn_role_at_least(organization_id, 'admin')) ); revoke all on public.org_guardrail_layers from anon; -- ---- plano de tempo do follow-up (migration 0144) ---- -- -- O modo "Adaptativo (min–max)" do nó de espera existia na tela e não existia no -- motor: o fluxo esperava SEMPRE o máximo. Esta coluna guarda o plano decidido -- uma vez no acionamento, para todas as esperas adaptativas de uma vez. -- -- Sem CHECK e sem NOT NULL de propósito: `null` é "ainda não planejado" e também -- o estado de todo enrollment anterior — os dois caem no comportamento antigo, e -- não há dado a corrigir antes de criar a coluna. Um CHECK de shape sobre jsonb -- quebraria o `update.sh` de um clone que já tivesse gravado algo aqui; quem -- valida é `lib/followup/timing-plan.ts`, que degrada para o máximo diante de -- plano ilegível em vez de derrubar o tick. alter table followup_enrollments add column if not exists timing_plan jsonb; comment on column followup_enrollments.timing_plan is 'Plano de tempo das esperas adaptativas, decidido uma vez no acionamento do fluxo. null = sem plano (cai no max_ms de cada espera). Ver lib/followup/timing-plan.ts.'; notify pgrst, 'reload schema'; -- ---- o tick do follow-up para de servir uma organização de cada vez (migration 0146) ---- -- -- O claim levava os 20 vencidos MAIS ANTIGOS globalmente. Quem acumulou fila -- tem, por construção, os mais antigos — então uma organização atrasada ocupa o -- lote inteiro. Medido em pg17 descartável, teto 20: com 25 vencidos na grande e -- 1 na pequena, o tick 1 leva 20 da grande e ZERO da pequena; com 300 na grande, -- a pequena só é atendida no TICK 16 (≈16 min, com o cron de minuto em minuto). -- Não é inanição eterna — o lease empurra o ponteiro e ela entra em teto(K/20) -- ticks — mas o atraso não tem limite superior e cresce com a fila do vizinho. -- -- Passa a ser rodízio: o mais antigo de CADA organização, depois o segundo de -- cada. Com UMA organização o resultado é idêntico ao de antes (os 20 mais -- antigos, na mesma ordem), então a instalação de operador único não muda. -- -- O `limit p_limit` dentro do lateral faz o custo depender do número de -- organizações com fila, não do tamanho da fila. E `for update skip locked` vira -- CTE própria porque o Postgres não o aceita junto de window function. -- -- Só isso NÃO preserva "dois workers nunca pegam a mesma linha": as duas conexões -- materializam a MESMA lista de candidatos antes de qualquer lock existir, e a -- segunda, ao esperar o lock da primeira, reavalia apenas o WHERE do UPDATE -- (READ COMMITTED). Medido: interseção de 5 em 5 no invariante de concorrência. -- Por isso a condição de lease está REPETIDA no WHERE do UPDATE — é ela que faz -- a segunda conexão enxergar o lease recém-gravado e desistir da linha. create index if not exists idx_followup_enrollments_due_por_org on followup_enrollments (organization_id, next_eval_at) where status in ('active','waiting_reply'); create or replace function fn_claim_due_followup_enrollments(p_limit int, p_lease_seconds int) returns setof followup_enrollments language sql security definer set search_path = public as $$ with orgs as ( -- Sem a condição de claim aqui de propósito: o lateral abaixo a aplica, e uma -- organização cujos vencidos estão todos com lease apenas devolve zero linhas. select distinct organization_id from followup_enrollments where status in ('active','waiting_reply') and next_eval_at <= now() ), fila as ( select f.id, f.next_eval_at, f.posicao_na_org from orgs cross join lateral ( select d.id, d.next_eval_at, row_number() over (order by d.next_eval_at) as posicao_na_org from followup_enrollments d where d.organization_id = orgs.organization_id and d.status in ('active','waiting_reply') and d.next_eval_at <= now() and (d.claimed_until is null or d.claimed_until < now()) order by d.next_eval_at limit p_limit ) f ), escolhidos as ( -- O rodízio: posição 1 de todas as organizações, depois a 2 de todas, etc. -- Empate na mesma posição vai para quem esperou mais. select id from fila order by posicao_na_org, next_eval_at limit p_limit ), travados as ( select e.id from followup_enrollments e where e.id in (select id from escolhidos) for update skip locked ) update followup_enrollments e set claimed_until = now() + make_interval(secs => p_lease_seconds), updated_at = now() where e.id in (select id from travados) -- A condição de lease É REPETIDA AQUI, e não é redundante com a CTE `fila`. -- Sem ela, duas conexões simultâneas reclamam as MESMAS linhas: a segunda -- espera o lock da primeira, e quando ele sai o Postgres (READ COMMITTED) -- reavalia só o WHERE do UPDATE — que não olhava `claimed_until` — e grava -- por cima. O `skip locked` da CTE não salva: as duas materializam a mesma -- lista antes de qualquer lock existir. Medido: interseção de 5 em 5 no -- invariante de concorrência (followup-schema.test.ts). and (e.claimed_until is null or e.claimed_until < now()) returning e.*; $$; revoke execute on function fn_claim_due_followup_enrollments(int, int) from public, anon, authenticated; -- ---- o dossiê do follow-up: tempo escolhido pela IA + pausa manual (migration 0145) ---- -- -- Ver o cabeçalho de `supabase/migrations/20260810120000_0145_dossie_do_followup.sql` -- para o porquê de cada peça. Aqui vale a nota de re-aplicação: tudo é -- auto-curativo. O CHECK só ACRESCENTA um valor ao conjunto aceito e o predicado -- novo do índice cobre as mesmas linhas do antigo (nenhum banco tem -- `paused_manual` antes desta migration) — nada a deduplicar antes. -- A coluna `timing_plan` NÃO é recriada aqui: ela pertence ao apêndice da -- migration 0144 (acima). Duas criações da mesma coluna são idempotentes, mas os -- dois `comment on column` competem e o último vence — duplicação com dois donos -- e nenhuma fonte da verdade. Resolvido na integração: 0144 cria e descreve; 0145 -- consome. -- Os dois CHECKs saem pelo CATÁLOGO, não pelo nome: num clone que passou por -- dump/restore o nome gerado pode não ser o deste repo, e dropar por nome fixo -- falharia em silêncio — o `add constraint` tropeçaria no duplicado, o -- `exception when duplicate_object` engoliria, e o banco ficaria com o CHECK -- ANTIGO recusando `paused_manual` num INSERT que a aplicação considera válido. do $$ declare c record; begin for c in select con.conname from pg_constraint con join pg_class rel on rel.oid = con.conrelid join pg_namespace ns on ns.oid = rel.relnamespace where ns.nspname = 'public' and rel.relname = 'followup_enrollments' and con.contype = 'c' and pg_get_constraintdef(con.oid) like '%paused_handoff%' -- A versão EM VIGOR tem 'coletando' (0394). Qualquer uma sem ele — a de -- antes da 'dormente' ou a de antes da 0394 — sai aqui e é recriada abaixo. and pg_get_constraintdef(con.oid) not like '%coletando%' loop execute format('alter table public.followup_enrollments drop constraint %I', c.conname); end loop; end $$; do $$ begin alter table public.followup_enrollments add constraint followup_enrollments_status_valido check (status in ('active','waiting_reply','dormente','paused_handoff','paused_manual','coletando','completed','cancelled','dead')); exception when duplicate_object then null; end $$; do $$ begin alter table public.followup_enrollments add constraint followup_enrollments_relogio_coerente check ( (status in ('active','waiting_reply','dormente') and next_eval_at is not null) or (status in ('paused_handoff','paused_manual','coletando','completed','cancelled','dead')) ); exception when duplicate_object then null; end $$; -- 'coletando' (0394): a execução de um roteiro de atendimento, conduzida pelo -- TURNO e não pelo relógio — por isso no grupo sem `next_eval_at`, e por isso -- fora do `idx_followup_enrollments_one_live` logo abaixo. Blocos ÚNICOS dos dois -- CHECKs; a 0394 não os reconstrói no apêndice. -- ⚠️ AS COLUNAS SÃO (organization_id, contact_id), NÃO (pointer_id, contact_id). -- -- A DDL original da tabela (bem acima neste arquivo) cria este índice por -- `pointer_id`; o apêndice da migration 0062 o DERRUBA e recria por -- `organization_id`, e é essa a definição em vigor: **um follow-up vivo por lead -- na organização inteira**, não um por fluxo. É o guard anti-empilhamento — sem -- ele o mesmo contato entra em N sequências ao mesmo tempo e leva N mensagens, -- que é o bug de spam que a doutrina anti-banimento existe para impedir. O -- `silence-sweep.ts` e o produtor do gatilho de etapa dependem dele: os dois -- tratam o `23505` como skip silencioso, e é ele que garante que não há laço. -- -- Quem precisa MEXER no predicado (como aqui, para incluir `paused_manual`) tem -- de copiar a definição EM VIGOR, não a da DDL original — recriar a partir da -- linha errada reverte a garantia sem conflito de merge e sem sintoma imediato. -- Corrigido na integração; ver a nota no MANIFEST da 0145. -- Só derruba a versão sem `paused_manual` (issue #1041): numa reaplicação o -- índice já está na versão final, e reconstruí-lo deixaria a trava de "um -- follow-up vivo por contato" ausente durante o build, com o app no ar. do $$ begin if exists ( select 1 from pg_indexes where schemaname = 'public' and indexname = 'idx_followup_enrollments_one_live' and indexdef not ilike '%paused_manual%' ) then execute 'drop index public.idx_followup_enrollments_one_live'; end if; end $$; create unique index if not exists idx_followup_enrollments_one_live on public.followup_enrollments (organization_id, contact_id) where status in ('active','waiting_reply','paused_handoff','paused_manual'); create index if not exists idx_followup_events_enrollment_tempo on public.followup_enrollment_events (enrollment_id, created_at); notify pgrst, 'reload schema'; -- ⚠️ ESTE BLOCO FICA ACIMA DA VARREDURA DE ANON DE PROPÓSITO, e a posição é -- parte do conserto. O corpo do baseline traz um `alter default privileges … -- grant all on functions to anon`, então TODA função nova nasce alcançável -- pela chave anônima — que vai para o browser. O bloco de varredura no fim do -- arquivo cura isso, mas só para o que veio ANTES dele: um apêndice colocado -- depois fica exposto COM os `revoke` escritos e parecendo corretos. Defesa -- certa na ordem errada é exposição com gate verde. -- Vigiado por `tests/unit/varredura-anon-e-o-ultimo-bloco.test.ts`. -- ---- relógio do banco para o agendamento do follow-up (migration 0147) ---- -- Quem AGENDA gravava `next_eval_at` com o relógio do PROCESSO; quem RECLAMA -- compara com `now()` do POSTGRES. Medido: o banco fica 17–34 ms atrás, então o -- "agora" do processo ainda é FUTURO para o claim — o tick seguinte não reclama -- e o enrollment espera o tick DEPOIS (até 60 s, cron de minuto em minuto). -- Corrigir por margem seria número mágico que envelhece; a correção é os dois -- lados usarem o mesmo relógio. -- -- ADITIVO E RETROCOMPATÍVEL: `default` só age na AUSÊNCIA da coluna, então todo -- insert que já passa `next_eval_at` explicitamente continua idêntico. Nada a -- corrigir nos dados antes — não há constraint nova. -- -- O QUE O DEFAULT CUSTA, decidido e não descoberto depois: hoje inserir um -- enrollment ativo SEM `next_eval_at` falha ALTO (o CHECK recusa); com o -- default, esquecer o campo passa a ser SILENCIOSO e significa "vencido agora". -- Troca de falha barulhenta por plausível — aceita porque os dois produtores de -- nascimento significam "agora", quem quiser outro instante continua passando -- valor explícito, e a regra está escrita no `comment on column` abaixo. alter table public.followup_enrollments alter column next_eval_at set default now(); comment on column public.followup_enrollments.next_eval_at is 'Quando este enrollment vence. DEFAULT now() do BANCO (migration 0147): quem agenda "para agora" deve OMITIR a coluna, porque o claim compara com now() do Postgres e o relógio do processo fica milissegundos à frente — o suficiente para o enrollment perder um tick inteiro (60s). Agendamento para o FUTURO continua passando valor explícito.'; -- Para o caso em que `default` não alcança: UPDATE. O supabase-js grava VALOR, -- nunca EXPRESSÃO, e o PostgREST só expõe tabela e função — sem isto o worker -- agendaria com o relógio do próprio processo. create or replace function public.fn_agora() returns timestamptz language sql stable set search_path to 'public', 'pg_temp' as $fn$ select now() $fn$; comment on function public.fn_agora() is 'O relógio do BANCO, para quem precisa gravar um instante que será comparado com now() (migration 0147).'; -- AS DUAS ORIGENS DE EXECUTE (CLAUDE.md, doutrina de migrations, item 9): o -- grant direto a anon do `alter default privileges` do baseline, que -- `revoke from public` não remove; e o grant a PUBLIC que o Postgres dá na -- criação, que `revoke from anon` não remove. revoke all on function public.fn_agora() from public; revoke execute on function public.fn_agora() from anon, authenticated; grant execute on function public.fn_agora() to service_role; -- ---- o caso anuncia abertura e fechamento no barramento (migration 0148) ---- -- -- `agent_cases` é a entidade de escalação e não emitia nada no `event_log`, então -- nenhum consumidor podia reagir a um caso. TRIGGER e não emissor em código porque -- o FECHAMENTO tem cinco escritores: caçar emissor deixa a garantia dependendo de -- alguém lembrar, e o próximo caminho nasce mudo. SQL puro, sem I/O externo — a -- proibição da doutrina é HTTP dentro da transação, e `fn_emit_conversation_routing` -- já usa este mesmo mecanismo. Idempotente: `create or replace` + `drop trigger if -- exists`, então o `update.sh` de um clone re-aplica sem efeito duplo. create or replace function public.fn_emit_agent_case_event() returns trigger language plpgsql security definer set search_path = public as $$ declare v_contact_id uuid; v_tipo text; begin v_tipo := case when tg_op = 'INSERT' then 'ai.case_opened' else 'ai.case_closed' end; -- O contato viaja no PAYLOAD porque ele sempre existe por schema -- (`agent_cases.conversation_id` é not null e `conversations.contact_id` é -- not null) e porque poupa o consumidor de uma ida ao banco. O consumidor -- mantém o fallback de buscar, para não confiar em convenção. select c.contact_id into v_contact_id from public.conversations c where c.id = new.conversation_id; perform public.emit_event( v_tipo, 'agent_case', new.id, jsonb_build_object( 'case_id', new.id, 'conversation_id', new.conversation_id, 'contact_id', v_contact_id, 'lead_id', new.lead_id, 'agent_id', new.agent_id, 'source', new.source, 'status', new.status ), '{}'::jsonb, new.organization_id -- SEMPRE de `new`, nunca de parâmetro: é o filtro de tenant ); return null; -- AFTER trigger: o retorno é ignorado end; $$; alter function public.fn_emit_agent_case_event() owner to postgres; -- ⚠️ AS DUAS ORIGENS DE EXECUTE (doutrina de migrations, item 9). Tratar só uma -- deixa a função exposta com o gate verde: (A) o grant a PUBLIC que o Postgres -- dá a qualquer função ao criá-la, que `revoke from anon` não remove; (B) o -- `alter default privileges ... to anon` do baseline, que vale para toda função -- criada depois dele e que `revoke from public` não remove. revoke all on function public.fn_emit_agent_case_event() from public; revoke execute on function public.fn_emit_agent_case_event() from anon, authenticated; -- ABERTURA: só os dois status que o código considera aberto -- (`OPEN_STATUSES` em lib/agent-engine/agent/human-cases.ts:75). drop trigger if exists trg_agent_case_opened on public.agent_cases; create trigger trg_agent_case_opened after insert on public.agent_cases for each row when (new.status in ('awaiting_human','awaiting_lead')) execute function public.fn_emit_agent_case_event(); -- FECHAMENTO: os três status terminais. `escalated` entra porque o caso deixou -- de esperar o cliente — seguir cobrando quem já foi passado adiante é o mesmo -- defeito de cobrar quem já foi resolvido. drop trigger if exists trg_agent_case_closed on public.agent_cases; create trigger trg_agent_case_closed after update of status on public.agent_cases for each row when (old.status is distinct from new.status and new.status in ('resolved','escalated','cancelled')) execute function public.fn_emit_agent_case_event(); notify pgrst, 'reload schema'; -- ---- definer valida a organização de quem chamou (migration 0149) ---- -- -- Relatório de segurança da comunidade, auditando a tag v1.0.0. A metade sobre -- ACL ("definer executáveis por anon") já estava fechada pela 0108/0116 — 0 de -- 31 hoje. Mas ACL e MEMBERSHIP são defeitos independentes: `emit_event` e -- `retrieve_top_k_chunks` continuavam usando o `p_organization_id` do ARGUMENTO -- como único filtro de tenant, e ambas são (corretamente) executáveis por -- `authenticated`. -- -- Medido num pg17 com este baseline, usuário papel `viewer` membro só da org A, -- rodando como role `authenticated` com o `sub` dele em request.jwt.claims: -- -- INSERT direto em event_log da org B -> permission denied (a RLS vale) -- SELECT direto em ai_chunks da org B -> 0 linhas (a RLS vale) -- emit_event(..., org => B) -> GRAVOU na org B ← furo -- retrieve_top_k_chunks(B, kbv) -> devolveu o conteúdo ← furo -- -- Depois deste bloco, os dois furos devolvem `caller_not_authorized_for_org`, e -- os dois controles positivos seguem verdes: emitir na PRÓPRIA org funciona, e -- o worker com `service_role` (sem JWT, auth.uid() null) funciona. -- -- `fn_log_event` delega a `emit_event` e herda o guard — não ganha cópia da regra. create or replace function public.emit_event( p_event_type text, p_entity_kind text, p_entity_id uuid, p_payload jsonb default '{}'::jsonb, p_metadata jsonb default '{}'::jsonb, p_organization_id uuid default null ) returns uuid language plpgsql security definer set search_path to 'public' as $$ declare v_org_id uuid; v_event_id uuid; begin v_org_id := p_organization_id; if v_org_id is null then select organization_id into v_org_id from public.user_organizations where user_id = auth.uid() and revoked_at is null limit 1; end if; if v_org_id is null then raise exception 'emit_event: organization_id obrigatorio'; end if; if auth.uid() is not null and not public.fn_role_at_least(v_org_id, 'viewer') then raise exception 'caller_not_authorized_for_org' using hint = 'emit_event: caller must be an active member of the organization'; end if; insert into public.event_log (organization_id, event_type, entity_kind, entity_id, payload, metadata) values (v_org_id, p_event_type, p_entity_kind, p_entity_id, coalesce(p_payload, '{}'::jsonb), coalesce(p_metadata, '{}'::jsonb) || jsonb_build_object('emitted_at', extract(epoch from now()))) returning id into v_event_id; return v_event_id; end $$; -- Os nomes de parâmetro e das colunas de retorno abaixo são os que estão no -- banco (`p_embedding`, `p_threshold` default 0.40, coluna `knowledge_source_id`): -- `create or replace` recusa renomear qualquer um dos dois, e um clone que -- receba nomes diferentes ganharia uma SOBRECARGA nova, deixando a versão sem -- guard viva. O `do $$` no fim deste bloco avisa se isso acontecer. create or replace function public.retrieve_top_k_chunks( p_organization_id uuid, p_kb_version_id uuid, p_embedding public.vector, p_k integer default 5, p_threshold real default 0.40 ) returns table ( chunk_id uuid, knowledge_source_id uuid, content text, similarity real, metadata jsonb ) language plpgsql stable security definer set search_path to 'public' as $$ begin if auth.uid() is not null and not public.fn_role_at_least(p_organization_id, 'viewer') then raise exception 'caller_not_authorized_for_org' using hint = 'retrieve_top_k_chunks: caller must be an active member of the organization'; end if; return query select c.id as chunk_id, c.knowledge_source_id, c.content, (1 - (c.embedding <=> p_embedding))::real as similarity, c.metadata from public.ai_chunks c where c.organization_id = p_organization_id and c.kb_version_id = p_kb_version_id and (1 - (c.embedding <=> p_embedding)) >= p_threshold order by c.embedding <=> p_embedding asc limit greatest(p_k, 0); end $$; revoke execute on function public.emit_event(text, text, uuid, jsonb, jsonb, uuid) from public, anon; grant execute on function public.emit_event(text, text, uuid, jsonb, jsonb, uuid) to authenticated, service_role; revoke execute on function public.retrieve_top_k_chunks(uuid, uuid, public.vector, integer, real) from public, anon; grant execute on function public.retrieve_top_k_chunks(uuid, uuid, public.vector, integer, real) to authenticated, service_role; do $$ declare v_extra text; begin select string_agg(p.oid::regprocedure::text, ', ') into v_extra from pg_proc p join pg_namespace n on n.oid = p.pronamespace where n.nspname = 'public' and p.proname = 'retrieve_top_k_chunks' and p.oid::regprocedure::text <> 'retrieve_top_k_chunks(uuid,uuid,vector,integer,real)'; if v_extra is not null then raise warning '0149: sobrecarga inesperada de retrieve_top_k_chunks sem o guard de membership: %', v_extra; end if; end $$; notify pgrst, 'reload schema'; -- ---- RBAC na configuração de IA e canais (migration 0150) ---- -- -- Segundo achado do relatório de segurança da comunidade, e o mais consistente -- dele. Medido no baseline da main: das 82 policies `ALL` de `public`, **71** -- não citam `fn_role_at_least` — só tenancy, via `fn_user_org_ids()`, que -- devolve organizações e nada mais. `authenticated` tem -- SELECT/INSERT/UPDATE/DELETE nessas tabelas. -- -- Isso importa porque o `requireRole()` das rotas Next NÃO é a única porta: o -- PostgREST do Supabase é exposto ao browser por construção (a `anon key` e a -- URL vão no bundle), e um usuário logado fala com ele DIRETO, com o próprio -- JWT. Provado num pg17 com este baseline, membro papel `viewer`, rodando como -- role `authenticated` com o `sub` dele: derrubou `channel_sessions` (o canal de -- WhatsApp), reescreveu `ai_agents.system_prompt` (o texto que o bot fala com -- cliente real), subiu `ai_budgets.monthly_limit_cents` de 5.000 para -- 99.999.999 e DELETOU a linha de `ai_provider_credentials` (mata a IA da org). -- Controle no mesmo probe: o viewer NÃO alcança a organização vizinha — a -- tenancy vale, o que falta é o papel. -- -- ESCOPO DELIBERADO: só as tabelas de CONFIGURAÇÃO de IA e canais, onde o dano -- é inequívoco e onde a rota Next já exige `admin` hoje (channel-sessions -- route.ts:61, ai/agents route.ts:67, ai/budget route.ts:46) — a policy passa a -- espelhar a API, em vez de ficar três níveis mais frouxa que ela. As outras ~63 -- ficam para depois, de propósito: `job_queue`, `llm_calls`, `send_ledger`, -- `metrics` e afins são escritas pelo motor, e apertá-las no mesmo fôlego -- trocaria um furo de segurança por uma parada de produção. O gate que impede a -- lista de crescer vem em `tests/invariants/rbac-config-ia-canais.test.ts`. -- -- FORMA: cada tabela vira PAR — SELECT só-tenancy (todo membro continua LENDO, -- inclusive o viewer, senão a tela quebra) + escrita com `fn_role_at_least`. -- Onde a policy atual tem `or fn_is_platform_admin()`, o par PRESERVA os dois -- lados: sem isso o super-admin de plataforma perde acesso e o suporte cega. -- -- O worker não entra nesta conta: usa `service_role`, que é `bypassrls`. -- ---- canais ---- drop policy if exists channel_sessions_tenant_isolation_all on public.channel_sessions; drop policy if exists channel_sessions_tenant_select on public.channel_sessions; create policy channel_sessions_tenant_select on public.channel_sessions for select using ( organization_id in (select public.fn_user_org_ids()) or public.fn_is_platform_admin() ); drop policy if exists channel_sessions_tenant_write on public.channel_sessions; create policy channel_sessions_tenant_write on public.channel_sessions for all using ( (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'admin')) or public.fn_is_platform_admin() ) with check ( (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'admin')) or public.fn_is_platform_admin() ); -- ---- agentes de IA ---- drop policy if exists tenant_isolation_ai_agents_all on public.ai_agents; drop policy if exists tenant_isolation_ai_agents_select on public.ai_agents; create policy tenant_isolation_ai_agents_select on public.ai_agents for select using ( organization_id in (select public.fn_user_org_ids()) or public.fn_is_platform_admin() ); drop policy if exists tenant_isolation_ai_agents_write on public.ai_agents; create policy tenant_isolation_ai_agents_write on public.ai_agents for all using ( (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'admin')) or public.fn_is_platform_admin() ) with check ( (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'admin')) or public.fn_is_platform_admin() ); -- ---- versões de agente ---- drop policy if exists tenant_isolation_ai_agent_versions_all on public.ai_agent_versions; drop policy if exists tenant_isolation_ai_agent_versions_select on public.ai_agent_versions; create policy tenant_isolation_ai_agent_versions_select on public.ai_agent_versions for select using (organization_id in (select public.fn_user_org_ids())); drop policy if exists tenant_isolation_ai_agent_versions_write on public.ai_agent_versions; create policy tenant_isolation_ai_agent_versions_write on public.ai_agent_versions for all using ( organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'admin') ) with check ( organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'admin') ); -- ---- orçamento de IA ---- drop policy if exists tenant_isolation_ai_budgets_all on public.ai_budgets; drop policy if exists tenant_isolation_ai_budgets_select on public.ai_budgets; create policy tenant_isolation_ai_budgets_select on public.ai_budgets for select using ( organization_id in (select public.fn_user_org_ids()) or public.fn_is_platform_admin() ); drop policy if exists tenant_isolation_ai_budgets_write on public.ai_budgets; create policy tenant_isolation_ai_budgets_write on public.ai_budgets for all using ( (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'admin')) or public.fn_is_platform_admin() ) with check ( (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'admin')) or public.fn_is_platform_admin() ); -- ---- roteadores de IA ---- drop policy if exists tenant_isolation_ai_routers_all on public.ai_routers; drop policy if exists tenant_isolation_ai_routers_select on public.ai_routers; create policy tenant_isolation_ai_routers_select on public.ai_routers for select using (organization_id in (select public.fn_user_org_ids())); drop policy if exists tenant_isolation_ai_routers_write on public.ai_routers; create policy tenant_isolation_ai_routers_write on public.ai_routers for all using ( organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'admin') ) with check ( organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'admin') ); drop policy if exists tenant_isolation_ai_router_members_all on public.ai_router_members; drop policy if exists tenant_isolation_ai_router_members_select on public.ai_router_members; create policy tenant_isolation_ai_router_members_select on public.ai_router_members for select using (organization_id in (select public.fn_user_org_ids())); drop policy if exists tenant_isolation_ai_router_members_write on public.ai_router_members; create policy tenant_isolation_ai_router_members_write on public.ai_router_members for all using ( organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'admin') ) with check ( organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'admin') ); drop policy if exists tenant_isolation_ai_purpose_bindings_all on public.ai_purpose_bindings; drop policy if exists tenant_isolation_ai_purpose_bindings_select on public.ai_purpose_bindings; create policy tenant_isolation_ai_purpose_bindings_select on public.ai_purpose_bindings for select using (organization_id in (select public.fn_user_org_ids())); drop policy if exists tenant_isolation_ai_purpose_bindings_write on public.ai_purpose_bindings; create policy tenant_isolation_ai_purpose_bindings_write on public.ai_purpose_bindings for all using ( organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'admin') ) with check ( organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'admin') ); -- ---- credenciais de provedor de IA: remover a superfície, não negociá-la ---- -- -- Aqui a policy não é o remédio suficiente. NENHUM caminho de browser precisa -- desta tabela: o servidor lê as colunas cifradas com `service_role` -- (lib/ai/credentials.ts, lib/ai/gateway-binding.ts) e a TELA já consome a view -- `ai_provider_credentials_safe`, que existe justamente para não expor -- `api_key_encrypted`/`iv`/`tag`. Então o SELECT de `authenticated` sai inteiro -- em vez de continuar ali sob a promessa de que o ciphertext basta. -- -- (O ciphertext DE FATO protege a chave — é AES com iv+tag, e um viewer leria -- bytes inúteis. O que ele não protege é o resto: `provider`, `label`, -- `api_key_last4`, `validation_error`. E, sobretudo, a linha continuava -- DELETÁVEL, que é o dano real.) drop policy if exists tenant_isolation_ai_provider_credentials_select on public.ai_provider_credentials; drop policy if exists tenant_isolation_ai_provider_credentials_modify on public.ai_provider_credentials; drop policy if exists tenant_isolation_ai_provider_credentials_write on public.ai_provider_credentials; create policy tenant_isolation_ai_provider_credentials_write on public.ai_provider_credentials for all using ( organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'admin') ) with check ( organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'admin') ); -- O SELECT sai por COLUNA, não pela tabela inteira, e a razão é a view: -- `ai_provider_credentials_safe` é `security_invoker=true` — de propósito, para -- que a RLS da tabela base valha para o usuário que a consulta. Revogar o SELECT -- da tabela inteira quebraria a view (o invoker não tem privilégio para ler a -- base) e, com ela, a tela de provedores. Torná-la `security_invoker=false` para -- contornar isso seria trocar um furo pequeno por um grande: a RLS pararia de se -- aplicar e a view passaria a devolver linha de qualquer organização. -- -- Com grant por coluna, as três colunas do segredo ficam inalcançáveis pelo -- PostgREST e a view — que só lê as outras doze — continua funcionando. Medido -- por controle positivo em tests/invariants/rbac-config-ia-canais.test.ts: a -- primeira versão desta migration revogava a tabela toda, e foi esse controle -- que reprovou. revoke select on public.ai_provider_credentials from authenticated, anon; grant select ( id, organization_id, provider, label, api_key_last4, validated_at, validation_error, models_available, is_active, created_by, created_at, updated_at ) on public.ai_provider_credentials to authenticated; grant select on public.ai_provider_credentials_safe to authenticated; -- O PostgREST guarda o schema em cache; sem isto as policies novas só valem no -- próximo reload dele. notify pgrst, 'reload schema'; -- ---- credenciais de IA voltam a ser LIDAS por quem não é admin (migration 0207) ---- -- A 0150 (bloco acima) deixou `..._write` como ÚNICA policy da tabela. `FOR ALL` -- cobre o SELECT, então a leitura passou a exigir admin — e a view -- `ai_provider_credentials_safe` é `security_invoker=true`, então um `manager` -- passava na autorização da aplicação e era filtrado para ZERO LINHAS na base. -- A tela respondia 200 com `[]`, e a pessoa concluía que não havia credencial. -- -- O par que o cabeçalho da 0150 promete: escrita de admin, leitura por tenancy. -- O segredo segue protegido pelo GRANT POR COLUNA logo acima — é ele, e não a -- RLS, que esconde `api_key_encrypted/iv/tag`. (issue #292) -- -- Este bloco vem DEPOIS do da 0150 de propósito: lá em cima há um -- `drop policy if exists ..._select`, e inverter a ordem apagaria este conserto. drop policy if exists tenant_isolation_ai_provider_credentials_select on public.ai_provider_credentials; create policy tenant_isolation_ai_provider_credentials_select on public.ai_provider_credentials for select using (organization_id in (select public.fn_user_org_ids())); notify pgrst, 'reload schema'; -- ---- o quadro de clientes montado no onboarding (migration 0156) ---- -- O gatilho `trg_seed_default_pipeline_for_org` semeia um funil de e-commerce em -- TODA organização, e o passo do onboarding troca esse quadro por um do ramo do -- negócio. A troca é DELETE + INSERT das etapas, e o cliente JS não tem -- transação: pelo cliente, um DELETE que passa e um INSERT que falha deixariam o -- funil sem coluna nenhuma. Aqui os dois vivem na mesma transação da função. -- -- As duas recusas, e a segunda é a silenciosa: `crm_leads_stage_id_fkey` é -- RESTRICT (o DELETE falharia), mas `webhook_sources.default_stage_id` é -- **CASCADE** — trocar as colunas apagaria a fonte de webhook inteira sem erro -- nenhum. Ver o cabeçalho da 0156 para a medição que motivou o passo. -- -- ⚠️ ESTE BLOCO FICA ACIMA DA VARREDURA DE `anon`, e não é arbitrário: o -- `ALTER DEFAULT PRIVILEGES` do corpo do baseline faz toda função nova nascer -- com EXECUTE para `anon`, e quem cura isso é a varredura — que só alcança o -- que veio ANTES dela. É o que `tests/unit/varredura-anon-e-o-ultimo-bloco` -- cobra, e foi ele que pegou este apêndice no lugar errado. create or replace function public.fn_aplicar_quadro_do_onboarding( p_organization_id uuid, p_pipeline_id uuid, p_nome text, p_slug text, p_etapas jsonb ) returns jsonb language plpgsql security definer set search_path to 'public', 'pg_temp' as $$ declare v_negocios bigint; v_fontes bigint; v_criadas bigint; begin -- O funil é DESTA organização? A função roda como `postgres` e passa por cima -- da RLS; o filtro de tenant é responsabilidade dela. perform 1 from public.crm_pipelines where id = p_pipeline_id and organization_id = p_organization_id; if not found then return jsonb_build_object('ok', false, 'motivo', 'funil_nao_encontrado'); end if; select count(*) into v_negocios from public.crm_leads where pipeline_id = p_pipeline_id and organization_id = p_organization_id; if v_negocios > 0 then return jsonb_build_object('ok', false, 'motivo', 'funil_com_negocios', 'quantos', v_negocios); end if; -- ON DELETE CASCADE: sem esta recusa, trocar as colunas apaga a fonte inteira. select count(*) into v_fontes from public.webhook_sources w join public.crm_stages s on s.id = w.default_stage_id where s.pipeline_id = p_pipeline_id; if v_fontes > 0 then return jsonb_build_object('ok', false, 'motivo', 'etapa_em_uso_por_webhook', 'quantos', v_fontes); end if; delete from public.crm_stages where pipeline_id = p_pipeline_id and organization_id = p_organization_id; insert into public.crm_stages (organization_id, pipeline_id, name, slug, position, is_won, is_lost, agent_stage_hint) select p_organization_id, p_pipeline_id, e->>'nome', e->>'slug', (e->>'position')::numeric, coalesce((e->>'is_won')::boolean, false), coalesce((e->>'is_lost')::boolean, false), nullif(e->>'agent_stage_hint', '') from jsonb_array_elements(p_etapas) as e; get diagnostics v_criadas = row_count; update public.crm_pipelines set name = p_nome, slug = p_slug, updated_at = now() where id = p_pipeline_id and organization_id = p_organization_id; return jsonb_build_object('ok', true, 'etapas', v_criadas); end$$; -- TRÊS origens de EXECUTE, e medi as três antes de escrever esta lista — com o -- revoke de `public, anon` apenas, `has_function_privilege` ainda respondia -- `authenticated, service_role`: -- -- (A) o grant que o Postgres dá a PUBLIC ao criar qualquer função; -- (B) `ALTER DEFAULT PRIVILEGES ... GRANT ALL ON FUNCTIONS TO anon` (baseline); -- (C) a irmã dela, `... TO authenticated` (baseline, linha seguinte). -- -- Nenhum dos revokes remove os outros dois. E aqui (C) é a perigosa, não (B): -- esta função é SECURITY DEFINER, roda como `postgres` por cima da RLS e recebe -- `p_organization_id` como ARGUMENTO. Executável por `authenticated`, qualquer -- usuário logado de qualquer tenant poderia reescrever o funil de OUTRA -- organização passando o id dela — exatamente a classe de furo que a 0149 -- fechou. O invariante `hardening-definer-varredura` reprova definer volátil -- alcançável por `authenticated` fora da allowlist, e esta não entra nela. revoke execute on function public.fn_aplicar_quadro_do_onboarding(uuid, uuid, text, text, jsonb) from public, anon, authenticated; -- Só o service role: o único chamador é a Server Action do onboarding, que já -- resolveu a organização do cookie de sessão. Quem não precisa não recebe. grant execute on function public.fn_aplicar_quadro_do_onboarding(uuid, uuid, text, text, jsonb) to service_role; -- ---- marca por organização (migration 0157) ---- -- -- A MARCA DO CLIENTE FINAL SE GRAVA EM UMA INSTRUÇÃO SÓ. -- -- `organizations.settings` tem vários donos com gates diferentes, e cada um -- faz read-modify-write do jsonb INTEIRO, em round-trips HTTP separados. Quem -- são hoje, no código: `git grep -n "update({ settings" -- app lib workers` (a -- aba Organização saiu dessa lista no PR #1209). A -- perda é medida, não deduzida: `visibility_mode` volta de 'own' para 'all' sem -- erro em lugar nenhum — e essa chave é lida DIRETO pela RLS, dentro de -- `fn_can_view_conversation`/`fn_can_view_lead`. Um write de COR reverteria, em -- silêncio, uma decisão de exposição de dado de cliente. Um quarto escritor com -- o mesmo padrão é inaceitável, então esta escrita passa por função. -- -- Devolve `integer` (linhas afetadas) porque a única policy de escrita de -- `organizations` é `orgs_write_platform_admin`: pelo client de sessão o UPDATE -- de um admin de TENANT casa 0 linhas e o PostgREST responde 204 — a tela diz -- "salvo" e nada foi gravado (issue #144). O `row_count` é o que permite ao -- chamador distinguir os dois casos. -- -- A autorização é REPETIDA aqui (o gate da Server Action usa o snapshot de -- membership de `loadAuthUser`, não o banco): é o que faz a regra valer para -- qualquer chamador futuro e o que impede escalação se o EXECUTE escapar um dia. -- -- Idempotente e auto-curativo: `create or replace function`, revoke/grant -- declarativos, e nenhuma constraint nova sobre dado existente — não há o que -- deduplicar antes. Termina com `notify pgrst` próprio, como os blocos -- vizinhos — o PostgREST guarda o schema em cache e não veria a função nova. -- -- ⚠️ E entra ANTES do bloco da VARREDURA anon, que é de propósito o último do -- arquivo: ela mede o privilégio EFETIVO de `authenticated`/`service_role` antes -- de revogar e o devolve depois, então os revokes acima só sobrevivem porque -- rodam ANTES dela. Colar no fim do arquivo — o movimento natural de quem -- adiciona migration — desarmaria a cura para tudo que viesse depois. Vigiado -- por `tests/unit/varredura-anon-e-o-ultimo-bloco.test.ts`. create or replace function public.fn_definir_marca_da_organizacao( p_org uuid, p_actor uuid, p_marca jsonb ) returns integer language plpgsql volatile security definer set search_path to 'public', 'pg_temp' as $$ declare v_linhas integer; v_hex text; v_limpar boolean; begin if p_org is null or p_actor is null then raise exception 'marca_da_organizacao_argumento_nulo' using errcode = '22023'; end if; -- "Apague a marca" chega por DUAS formas — SQL NULL e o jsonb `'null'` — e as -- duas significam a mesma coisa. NÃO MEDIDO qual delas o PostgREST produz para -- `{"p_marca": null}`; tratar só uma deixaria a limpeza levantando 22023 num -- dos dois transportes. v_limpar := p_marca is null or jsonb_typeof(p_marca) = 'null'; if not v_limpar and jsonb_typeof(p_marca) <> 'object' then raise exception 'marca_da_organizacao_forma_invalida: %', jsonb_typeof(p_marca) using errcode = '22023'; end if; -- MESMA regex do CHECK `platform_branding_accent_hex` — que é a forma que -- `normalizarHex` emite. Aceitar `#FFF` criaria duas grafias da mesma cor e a -- pergunta "mudou?" passaria a mentir. Dentro de jsonb não cabe CHECK de -- coluna, então a regra é da função. v_hex := nullif(p_marca ->> 'accent_hex', ''); if v_hex is not null and v_hex !~ '^#[0-9a-f]{6}$' then raise exception 'marca_da_organizacao_accent_hex_invalido' using errcode = '22023'; end if; -- Papel insuficiente falha ALTO (42501) em vez de devolver 0: 0 já significa -- "a organização não existe", e colapsar os dois deixaria o chamador sem saber -- se o problema é papel ou id. if not exists ( select 1 from public.user_organizations uo where uo.user_id = p_actor and uo.organization_id = p_org and uo.role = 'admin' and uo.revoked_at is null ) and not exists ( select 1 from public.platform_admins pa where pa.user_id = p_actor and pa.revoked_at is null ) then raise exception 'marca_da_organizacao_sem_permissao' using errcode = '42501'; end if; update public.organizations o set settings = case when v_limpar then coalesce(o.settings, '{}'::jsonb) - 'branding' else jsonb_set(coalesce(o.settings, '{}'::jsonb), '{branding}', p_marca, true) end where o.id = p_org; get diagnostics v_linhas = row_count; return v_linhas; end; $$; comment on function public.fn_definir_marca_da_organizacao(uuid, uuid, jsonb) is 'Grava organizations.settings.branding com merge ATÔMICO (jsonb_set), sem tocar nas demais chaves do jsonb (llm, routing, visibility_mode, atrito, ai_dispatch_mode, canonical_conversation_tags, lost_reasons_extra, plan). Devolve linhas afetadas: 0 = a organização não existe. Papel insuficiente levanta 42501. Chamador: app/actions/settings/updateMarcaDaOrganizacao.ts.'; -- OS DOIS REVOKES (CLAUDE.md, item 9) — origens DISTINTAS de EXECUTE, e tratar -- só uma deixa a função exposta com o gate verde: -- (A) `from public` — o grant que o Postgres dá a PUBLIC ao criar qualquer -- função; `revoke ... from anon` não o remove. -- (B) `from anon` — o grant DIRETO do `ALTER DEFAULT PRIVILEGES ... GRANT -- ALL ON FUNCTIONS TO anon` (linha ~3972 deste arquivo), que vale para -- toda função criada DEPOIS dele — isto é, para todo apêndice, que por -- construção nasce no fim. `revoke ... from public` não o remove. -- `from authenticated` pelo motivo de (B) e mais um: esta função é VOLÁTIL. -- Definer volátil alcançável por qualquer usuário logado é escrita cross-tenant. revoke execute on function public.fn_definir_marca_da_organizacao(uuid, uuid, jsonb) from public, anon, authenticated; grant execute on function public.fn_definir_marca_da_organizacao(uuid, uuid, jsonb) to service_role; notify pgrst, 'reload schema'; -- ---- logo da marca: as FUNÇÕES (migration 0158) ---- -- -- O LOGO SAI DA CAIXA DE TEXTO E VIRA ARQUIVO — a metade que cria função. -- -- ⚠️ POR QUE A 0158 ENTRA NO APÊNDICE EM DOIS PEDAÇOS, E NÃO EM UM. -- -- `tests/unit/varredura-anon-e-o-ultimo-bloco.test.ts` proíbe `create function` e -- `grant ... to anon` DEPOIS do bloco da VARREDURA anon (logo abaixo). Mas -- `platform_branding` — a tabela que ganha a coluna `logo_path` — é criada no -- bloco da 0155, que vem DEPOIS da varredura, no fim do arquivo. Um bloco único -- quebraria uma das duas coisas: colado aqui, o `alter table -- public.platform_branding` rodaria sobre tabela inexistente e o `install.sh` -- (que usa `ON_ERROR_STOP=1`) abortaria a instalação inteira; colado no fim, as -- duas funções abaixo nasceriam com EXECUTE para `anon` em todo clone que -- ATUALIZA, que é o buraco que a varredura existe para fechar. -- -- Então: FUNÇÕES aqui (antes da varredura), BUCKET e COLUNA no fim do arquivo -- (depois da 0155). Os dois blocos são idempotentes e independentes na ordem — -- nenhuma das funções abaixo lê `platform_branding`. -- -- ─── Por que uma função PRÓPRIA para o logo ───────────────────────────────── -- -- `fn_definir_marca_da_organizacao` (0157) faz `jsonb_set(settings, '{branding}', -- p_marca)` — substitui o objeto INTEIRO. Gravar o logo por ela faria "salvar o -- nome" apagar o logo, em silêncio, com a tela dizendo "salvo". Duas escritas -- independentes precisam de duas funções que façam merge cada uma no seu campo. -- -- ─── E por que a 0157 é RECRIADA aqui (forward-fix) ───────────────────────── -- -- Pelo mesmo motivo, de volta: ela precisa PRESERVAR `logo_path` ao substituir o -- objeto. Sem isso, a ordem natural de quem configura a marca ("sobe o logo, -- depois troca o nome") perde o logo. -- -- Idempotente: `create or replace function`, revokes e grants declarativos. create or replace function public.fn_definir_logo_da_organizacao( p_org uuid, p_actor uuid, p_path text ) returns integer language plpgsql volatile security definer set search_path to 'public', 'pg_temp' as $$ declare v_linhas integer; v_path text; begin if p_org is null or p_actor is null then raise exception 'logo_da_organizacao_argumento_nulo' using errcode = '22023'; end if; v_path := nullif(btrim(coalesce(p_path, '')), ''); -- O PREFIXO ASSEVERADO DENTRO DO BANCO — o gate que sobrevive ao segundo -- chamador. A rota monta o caminho a partir da organização resolvida do -- cookie, mas "a rota monta certo" é promessa de UM chamador. Sem esta linha, -- um caminho de outro escopo (o `platform/...` que qualquer pessoa lê no HTML -- da tela de login) entraria como logo da organização — e o delete-on-replace -- da rota, rodando como `service_role`, apagaria o logo da instalação inteira -- na troca seguinte. if v_path is not null and v_path !~ ('^' || p_org::text || '/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(png|jpg)$') then raise exception 'logo_da_organizacao_caminho_fora_do_escopo' using errcode = '22023'; end if; if not exists ( select 1 from public.user_organizations uo where uo.user_id = p_actor and uo.organization_id = p_org and uo.role = 'admin' and uo.revoked_at is null ) and not exists ( select 1 from public.platform_admins pa where pa.user_id = p_actor and pa.revoked_at is null ) then raise exception 'logo_da_organizacao_sem_permissao' using errcode = '42501'; end if; -- Merge no CAMPO. `jsonb_set` direto em '{branding,logo_path}' NÃO serviria: -- com `branding` ausente, `create_missing` só cria a ÚLTIMA chave e o caminho -- intermediário faltando devolve o jsonb original intocado — silenciosamente. update public.organizations o set settings = case when v_path is null then jsonb_set( coalesce(o.settings, '{}'::jsonb), '{branding}', coalesce(o.settings -> 'branding', '{}'::jsonb) - 'logo_path', true) else jsonb_set( coalesce(o.settings, '{}'::jsonb), '{branding}', coalesce(o.settings -> 'branding', '{}'::jsonb) || jsonb_build_object('logo_path', v_path), true) end where o.id = p_org; get diagnostics v_linhas = row_count; return v_linhas; end; $$; comment on function public.fn_definir_logo_da_organizacao(uuid, uuid, text) is 'Grava (ou apaga) organizations.settings.branding.logo_path com merge no CAMPO — não toca em app_name, accent_hex nem nas demais chaves de settings. Assevera que o caminho começa pelo proprio organization_id: caminho de outro escopo levanta 22023. Papel insuficiente levanta 42501. Devolve linhas afetadas: 0 = a organização não existe. Chamador: app/api/v1/marca/logo/route.ts.'; -- ── O FORWARD-FIX DA 0157 ─────────────────────────────────────────────────── -- -- As três linhas de `logo_path` no `case` abaixo são a razão de a 0157 aparecer -- de novo. Sem elas, salvar nome/cor pela tela apaga o logo da organização, em -- silêncio. Vigiado por `tests/invariants/marca-logo.test.ts`. create or replace function public.fn_definir_marca_da_organizacao( p_org uuid, p_actor uuid, p_marca jsonb ) returns integer language plpgsql volatile security definer set search_path to 'public', 'pg_temp' as $$ declare v_linhas integer; v_hex text; v_limpar boolean; begin if p_org is null or p_actor is null then raise exception 'marca_da_organizacao_argumento_nulo' using errcode = '22023'; end if; v_limpar := p_marca is null or jsonb_typeof(p_marca) = 'null'; if not v_limpar and jsonb_typeof(p_marca) <> 'object' then raise exception 'marca_da_organizacao_forma_invalida: %', jsonb_typeof(p_marca) using errcode = '22023'; end if; v_hex := nullif(p_marca ->> 'accent_hex', ''); if v_hex is not null and v_hex !~ '^#[0-9a-f]{6}$' then raise exception 'marca_da_organizacao_accent_hex_invalido' using errcode = '22023'; end if; if not exists ( select 1 from public.user_organizations uo where uo.user_id = p_actor and uo.organization_id = p_org and uo.role = 'admin' and uo.revoked_at is null ) and not exists ( select 1 from public.platform_admins pa where pa.user_id = p_actor and pa.revoked_at is null ) then raise exception 'marca_da_organizacao_sem_permissao' using errcode = '42501'; end if; update public.organizations o set settings = case -- "Limpar" com logo gravado NÃO apaga o logo: o campo tem controle -- próprio na tela, e limpar nome+cor responde a OUTRA pergunta. when v_limpar and coalesce(o.settings #>> '{branding,logo_path}', '') = '' then coalesce(o.settings, '{}'::jsonb) - 'branding' when v_limpar then jsonb_set( coalesce(o.settings, '{}'::jsonb), '{branding}', jsonb_build_object('logo_path', o.settings #> '{branding,logo_path}'), true) -- `p_marca || preservado`: o lado DIREITO vence em `||`, então o -- `logo_path` gravado sobrevive à substituição do objeto. -- `jsonb_strip_nulls` SÓ no fragmento preservado — nunca em `p_marca`, -- cujo `app_name: null` é um valor com significado. else jsonb_set( coalesce(o.settings, '{}'::jsonb), '{branding}', p_marca || jsonb_strip_nulls( jsonb_build_object('logo_path', o.settings #> '{branding,logo_path}')), true) end where o.id = p_org; get diagnostics v_linhas = row_count; return v_linhas; end; $$; comment on function public.fn_definir_marca_da_organizacao(uuid, uuid, jsonb) is 'Grava organizations.settings.branding com merge ATÔMICO (jsonb_set), sem tocar nas demais chaves do jsonb (llm, routing, visibility_mode, atrito, ai_dispatch_mode, canonical_conversation_tags, lost_reasons_extra, plan) e PRESERVANDO branding.logo_path, que tem escritor próprio (fn_definir_logo_da_organizacao, migration 0158). Devolve linhas afetadas: 0 = a organização não existe. Papel insuficiente levanta 42501. Chamador: app/actions/settings/updateMarcaDaOrganizacao.ts.'; -- OS DOIS REVOKES EM CADA FUNÇÃO (CLAUDE.md, item 9) — origens DISTINTAS de -- EXECUTE, e tratar uma só deixa a função exposta com o gate verde: -- (A) `from public` — o grant que o Postgres dá a PUBLIC ao criar qualquer -- função; `revoke ... from anon` não o remove. -- (B) `from anon` — o grant DIRETO do `ALTER DEFAULT PRIVILEGES ... GRANT -- ALL ON FUNCTIONS TO anon` (linha ~3972 deste arquivo), que vale para -- toda função criada DEPOIS dele — isto é, para todo apêndice. -- `from authenticated` pelo motivo de (B) e mais um: as duas são VOLÁTEIS. -- Definer volátil alcançável por qualquer usuário logado é escrita cross-tenant. revoke execute on function public.fn_definir_logo_da_organizacao(uuid, uuid, text) from public, anon, authenticated; grant execute on function public.fn_definir_logo_da_organizacao(uuid, uuid, text) to service_role; revoke execute on function public.fn_definir_marca_da_organizacao(uuid, uuid, jsonb) from public, anon, authenticated; grant execute on function public.fn_definir_marca_da_organizacao(uuid, uuid, jsonb) to service_role; notify pgrst, 'reload schema'; -- ---- gasto de IA do mês: uma régua só (migration 0159) ---- -- -- O NÚMERO EXIBIDO PASSA A SER O NÚMERO QUE DECIDE. -- -- Antes desta função havia duas contagens de gasto no produto e elas divergiam: -- -- * a query inline de `assertBudget` (`lib/agent-engine/edge/llm/run-model-call.ts`), -- que soma `llm_calls` do mês corrente — é ela que barrava a chamada; -- * `ai_budgets.current_month_consumed_cents`, que é o que a TELA mostra — um -- contador materializado pelo gatilho `fn_update_budget_consumption`, que soma -- `NEW.cost_cents` SEM olhar a data e nunca zera (o `runBudgetReset` jamais foi -- agendado). O único recomputo em produção é o apêndice da 0140, que só roda -- no `install.sh`/`update.sh` — numa instalação que não atualiza há três meses, -- o card compara três meses de gasto contra um teto MENSAL. -- -- Armar uma proteção contra um número que não é o número que decide é pedir para -- a pessoa proteger-se de uma mentira. Uma régua só, e ela é esta. -- -- `security invoker`, NÃO `definer`: a função recebe a organização por argumento -- e não valida membership. Uma definer alcançável por `authenticated` seria -- leitura de gasto cross-tenant. Quem a chama já tem o `organization_id` de fonte -- confiável — o `pg.Pool` do engine (dono do schema) e o admin client via -- PostgREST (`service_role`). -- -- ⚠️ E POR SER INVOKER ELA DEPENDE INTEIRAMENTE DOS PRÓPRIOS REVOKES: o bloco -- `VARREDURA anon` logo abaixo percorre só `p.prosecdef`, então ele NÃO cura -- função invoker. São duas origens distintas de EXECUTE (CLAUDE.md, item 9): -- (A) o grant que o Postgres dá a PUBLIC ao criar qualquer função — que -- `revoke ... from anon` não remove; -- (B) o grant DIRETO a anon do `ALTER DEFAULT PRIVILEGES ... GRANT ALL ON -- FUNCTIONS TO anon` do corpo deste arquivo (linha ~3972), que vale para -- toda função criada depois dele (isto é, para todo apêndice) — que -- `revoke ... from public` não remove. -- Tratar só uma deixa a função servida como RPC pela anon key, que vai ao browser. -- `authenticated` sai pelo motivo de (B) e mais um: é ele que carrega o JWT de -- qualquer pessoa logada, e a organização vem por argumento. -- -- ⚠️ E ENTRA ANTES DO BLOCO DA VARREDURA anon, que é de propósito o último do -- arquivo. `tests/unit/varredura-anon-e-o-ultimo-bloco.test.ts` proíbe QUALQUER -- `create function` ancorado em início de linha depois dele, e o regex não -- distingue definer de invoker. É a mesma dança em dois blocos que a 0158 teve de -- fazer: a função aqui, o resto da migration no fim do arquivo. create or replace function public.fn_gasto_de_ia_do_mes(p_org uuid) returns numeric language sql stable security invoker set search_path to 'public', 'pg_temp' as $$ select coalesce(sum(cost_cents), 0)::numeric from public.llm_calls where organization_id = p_org and created_at >= date_trunc('month', now()); $$; comment on function public.fn_gasto_de_ia_do_mes(uuid) is 'Gasto de IA da organização no mês corrente, em centavos de DÓLAR (llm_calls.cost_cents vem de pricing.ts, que calcula em USD). É a ÚNICA definição de gasto do produto: o gate a chama dentro de SQL_ORCAMENTO (lib/agent-engine/edge/llm/orcamento.ts), a tela a chama por RPC e o painel de saúde por tenant a chama. O dashboard de plataforma (app/api/v1/admin/dashboard/kpis) AINDA lê ai_budgets.current_month_consumed_cents, um contador acumulado que nada zera, e por isso pode divergir — a divergência está declarada naquele arquivo e o alerta de lá nunca é critical. Query inline de sum(cost_cents) em outro lugar é uma segunda régua, e a segunda régua sempre diverge — vigiado por tests/unit/orcamento-uma-regua-de-gasto.test.ts. security invoker: recebe a organização por argumento e não valida membership, então definer aqui seria leitura cross-tenant.'; revoke execute on function public.fn_gasto_de_ia_do_mes(uuid) from public, anon, authenticated; grant execute on function public.fn_gasto_de_ia_do_mes(uuid) to service_role; notify pgrst, 'reload schema'; -- ---- outbound zera unread na fila (migration 0161) ---- create or replace function public.fn_mark_conversation_message( p_conv uuid, p_direction text, p_preview text, p_at timestamptz ) returns void language plpgsql security definer set search_path = public as $$ begin update public.conversations set last_message_at = p_at, last_message_preview = p_preview, last_inbound_at = case when p_direction = 'inbound' then p_at else last_inbound_at end, last_outbound_at = case when p_direction = 'outbound' then p_at else last_outbound_at end, unread_count_for_assignee = case when p_direction = 'inbound' then unread_count_for_assignee + 1 when p_direction = 'outbound' then 0 else unread_count_for_assignee end, updated_at = now() where id = p_conv; end; $$; comment on function public.fn_mark_conversation_message is 'Atualiza agregados da conversa: inbound incrementa unread; outbound zera (respondido).'; -- Corrige contadores stale: inbound desde a última resposta do atendente/IA. update public.conversations c set unread_count_for_assignee = coalesce(( select count(*)::integer from public.messages m where m.conversation_id = c.id and m.direction = 'inbound' and m.sent_at > coalesce(c.last_outbound_at, '-infinity'::timestamptz) ), 0) where unread_count_for_assignee <> coalesce(( select count(*)::integer from public.messages m where m.conversation_id = c.id and m.direction = 'inbound' and m.sent_at > coalesce(c.last_outbound_at, '-infinity'::timestamptz) ), 0); notify pgrst, 'reload schema'; -- ---- contato: última atividade carimbada por mensagem (migration 0162) ---- -- ============================================================================ -- 0162 — Mensagem de conversa carimba `contacts.last_activity_at`. -- -- A lista /app/contacts mostra "Última atividade" de `contacts.last_activity_at`, -- denormalizado hoje só pelo trigger de `crm_lead_activities`. Mensagens de -- WhatsApp/Meta/Zernio passam por `fn_mark_conversation_message` e atualizam -- `conversations.last_message_at` — mas o contato ficava parado (— ou data velha). -- -- O relógio do LEAD continua na lista positiva da 0079; aqui só o contato. -- ============================================================================ create or replace function public.fn_mark_conversation_message( p_conv uuid, p_direction text, p_preview text, p_at timestamptz ) returns void language plpgsql security definer set search_path = public as $$ begin update public.conversations set last_message_at = p_at, last_message_preview = p_preview, last_inbound_at = case when p_direction = 'inbound' then p_at else last_inbound_at end, last_outbound_at = case when p_direction = 'outbound' then p_at else last_outbound_at end, unread_count_for_assignee = case when p_direction = 'inbound' then unread_count_for_assignee + 1 when p_direction = 'outbound' then 0 else unread_count_for_assignee end, updated_at = now() where id = p_conv; update public.contacts c set last_activity_at = greatest(coalesce(c.last_activity_at, '-infinity'::timestamptz), p_at) from public.conversations v where v.id = p_conv and c.id = v.contact_id; end; $$; comment on function public.fn_mark_conversation_message is 'Atualiza agregados da conversa (inbound incrementa unread; outbound zera) e carimba contacts.last_activity_at.'; -- Contatos que já conversaram mas nunca tiveram atividade de lead. update public.contacts c set last_activity_at = sub.max_at from ( select contact_id, max(last_message_at) as max_at from public.conversations where last_message_at is not null group by contact_id ) sub where c.id = sub.contact_id and coalesce(c.last_activity_at, '-infinity'::timestamptz) < sub.max_at; notify pgrst, 'reload schema'; -- ---- atribuição de anúncio: de qual campanha um contato do WhatsApp veio (migration 0164) ---- -- -- ⚠️ ENTRA ANTES DO BLOCO DA VARREDURA anon, pelo mesmo motivo das funções -- acima: `tests/unit/varredura-anon-e-o-ultimo-bloco.test.ts` proíbe `create -- function` depois dele. -- ⚠️ A ORGANIZAÇÃO É PARÂMETRO OBRIGATÓRIO (issue #1248, migration 0344): o -- único limite era o `p_contact` que o CHAMADOR mandava, e a função é security -- definer — chamada de service_role com o contato de OUTRA organização estampava -- o anúncio lá dentro. O `where` casa `organization_id = p_org` e a organização -- alheia casa zero linhas, silenciosamente, como o primeiro-toque. -- A assinatura antiga de TRÊS argumentos é derrubada ANTES do create: com as duas -- no catálogo, a chamada de três chaves resolveria na ANTIGA (mesmo defeito -- medido na 0336) e a organização nunca chegaria ao `where`. drop function if exists public.fn_estampar_atribuicao_de_anuncio(uuid, text, jsonb); create or replace function public.fn_estampar_atribuicao_de_anuncio( p_org uuid, p_contact uuid, p_platform text, p_metadata jsonb ) returns void language plpgsql security definer set search_path to 'public' as $$ begin update public.contacts set source = p_platform, source_metadata = source_metadata || p_metadata, updated_at = now() where id = p_contact and organization_id = p_org and source_metadata->>'ad_platform' is null; end; $$; comment on function public.fn_estampar_atribuicao_de_anuncio(uuid, uuid, text, jsonb) is 'Grava de qual anúncio (Meta Ads / Google Ads / site) um contato veio — só na primeira vez. `source_metadata = source_metadata || p_metadata` faz merge, nunca sobrescreve o que fn_upsert_wa_contact já gravou (waha_lid, waha_chat_id, notify_name). A guarda `source_metadata->>''ad_platform'' is null` é o primeiro-toque: clicar em outro anúncio meses depois, numa conversa já aberta, não reescreve de onde a pessoa veio originalmente — o UPDATE casa zero linhas, silenciosamente. `organization_id = p_org` (issue #1248): a organização é obrigatória e o contato de OUTRA organização casa zero linhas — escrita cross-tenant barrada no `where`, não no chamador. security definer + revoke de anon/authenticated: só o backend (admin client no ingest de canal) chama isto.'; revoke execute on function public.fn_estampar_atribuicao_de_anuncio(uuid, uuid, text, jsonb) from public, anon, authenticated; grant execute on function public.fn_estampar_atribuicao_de_anuncio(uuid, uuid, text, jsonb) to service_role; notify pgrst, 'reload schema'; -- ---- poda da fila e expurgo do audit (migration 0167) ---- -- -- Nada no produto apagava job terminal (`grep -rn "from job_queue" … | grep -i -- delete` devolvia zero linhas), e a retenção de 5 anos do `api_audit_log` -- existia só no COMMENT e na documentação — sem expurgo e sem o "cold storage -- S3" que seis documentos prometiam. As duas tabelas cresciam desde a -- instalação, e são as candidatas naturais a estourar os 500 MB do plano free -- do Supabase antes de qualquer tabela de negócio. -- -- O QUE TEM DONO NÃO SAI. `pending` (trabalho que ainda vai sair) e `running` -- (com worker agora; o reaper o devolve se o worker morrer) NUNCA são tocados — -- terminais são só `done`, `failed` e `dead`. E `dead` com AVISO ABERTO na -- Central também tem dono: um humano que ainda não olhou. O `not exists` fica -- ANTES do `limit` de propósito — filtrar depois faria um lote inteiro de jobs -- protegidos devolver 0, o laço do cron pararia achando que acabou, e a poda -- morreria de fome com backlog na frente. -- -- CASCATA DECLARADA: apagar um job leva junto `send_ledger` e -- `before_send_traces` daquele run (FK `on delete cascade`) — as duas também -- crescem sem poda, então isso é parte do conserto. `llm_calls`, -- `lead_checkpoints` e `lead_state_transitions` são `set null`: o histórico -- fica, só perde o ponteiro. Os dois consumidores de `send_ledger` sem janela -- (`countPriorAcceptedSends` → disclosure de IA e gate LGPD de 1º toque) falham -- FECHADO quando a linha some: disclosure a mais e veto a mais, nunca a menos. -- -- POR QUE `security definer` NO EXPURGO DO AUDIT, E POR QUE NÃO É UMA PORTA: a -- tabela é append-only NO SCHEMA (o baseline não concede DELETE/UPDATE a -- ninguém, nem a service_role), então o expurgo não sai pelo admin client. A -- função (a) não tem seletor de linha — nenhum parâmetro de org, ator, ação ou -- id, e o único predicado é `created_at < now() - N dias`, ou seja ela só sabe -- apagar pela ponta mais velha; (b) carrega o PISO de 90 dias dentro do corpo, -- então nem quem tem a service key remove rastro recente; (c) é revogada das -- duas origens de EXECUTE e concedida só a service_role; (d) não amplia o raio -- de quem já tem a chave (service_role já tem TRUNCATE nesta tabela) — dá forma -- estreita e auditável a um poder que já existia; (e) registra a própria erosão, -- porque o cron grava `retention.sweep_run` com a contagem, e essa linha é nova -- demais para a chamada seguinte alcançar. -- -- Idempotente e auto-curativo: `create or replace`, `create index if not -- exists`, `revoke` (no-op quando o privilégio já não existe). Sem constraint -- nova ⇒ sem dado a deduplicar antes. create index if not exists idx_job_queue_poda on public.job_queue (created_at) where status in ('done', 'failed', 'dead'); -- Nome próprio da poda de propósito: `create index if not exists` casa por NOME, -- e um nome genérico (`idx_audit_created_at`) poderia já existir num clone com -- outra definição e virar no-op silencioso. Nenhum dos cinco índices que a -- tabela já tem começa por `created_at`. create index if not exists idx_audit_expurgo_created_at on public.api_audit_log (created_at); create index if not exists idx_agent_inbox_items_ref_aberto on public.agent_inbox_items (ref_kind, ref_id) where status = 'open'; create or replace function public.fn_podar_fila_de_jobs( p_retencao_dias int default null, p_limite int default null ) returns int language plpgsql security definer set search_path = public, pg_temp as $$ declare -- Piso de 7 dias: abaixo disso a cascata em `send_ledger` mexeria no horizonte -- em que "1º outbound" ainda diz algo sobre um lead vivo. v_dias int := greatest(coalesce(p_retencao_dias, 90), 7); v_limite int := least(greatest(coalesce(p_limite, 1000), 1), 10000); v_apagados int; begin with candidatos as ( select j.id from public.job_queue j where j.status in ('done', 'failed', 'dead') and j.created_at < now() - make_interval(days => v_dias) and not exists ( select 1 from public.agent_inbox_items i where i.ref_kind = 'job_queue' and i.ref_id = j.id and i.status = 'open' ) order by j.created_at limit v_limite ) delete from public.job_queue j using candidatos c where j.id = c.id; get diagnostics v_apagados = row_count; return v_apagados; end; $$; create or replace function public.fn_expurgar_auditoria_vencida( p_retencao_dias int default null, p_limite int default null ) returns int language plpgsql security definer set search_path = public, pg_temp as $$ declare -- 1825 dias = os 5 anos da regra L-10; o piso de 90 impede que o knob vire -- apagador de rastro recente. v_dias int := greatest(coalesce(p_retencao_dias, 1825), 90); v_limite int := least(greatest(coalesce(p_limite, 1000), 1), 10000); v_apagadas int; begin with vencidas as ( select a.id from public.api_audit_log a where a.created_at < now() - make_interval(days => v_dias) order by a.created_at limit v_limite ) delete from public.api_audit_log a using vencidas v where a.id = v.id; get diagnostics v_apagadas = row_count; return v_apagadas; end; $$; revoke execute on function public.fn_podar_fila_de_jobs(int, int) from public, anon, authenticated; grant execute on function public.fn_podar_fila_de_jobs(int, int) to service_role; revoke execute on function public.fn_expurgar_auditoria_vencida(int, int) from public, anon, authenticated; grant execute on function public.fn_expurgar_auditoria_vencida(int, int) to service_role; comment on table public.api_audit_log is 'L-10: append-only (sem GRANT de UPDATE/DELETE a ninguém). Retenção default 5 anos, ' 'expurgada por public.fn_expurgar_auditoria_vencida (piso de 90 dias) a partir do cron ' 'app/api/v1/cron/data-retention. Não há camada cold/S3.'; notify pgrst, 'reload schema'; -- ---- quem manda na conversa: "Assumir" cala o automático (migration 0173) ---- -- -- ⚠️ ENTRA ANTES DO BLOCO DA VARREDURA anon, que é de propósito o último do -- arquivo: `fn_conversation_assign` é recriada aqui e função criada depois da -- varredura nasce com EXECUTE para anon sem ninguém curar. Os revokes explícitos -- no fim deste bloco já fecham as duas origens, mas a ordem é a rede que pega o -- próximo que esquecer. Vigiado por `tests/unit/varredura-anon-e-o-ultimo-bloco.test.ts`. -- -- Medido no HEAD 927dfa51: `lib/agent-engine/` NUNCA lê `assignee_kind` nem -- `assigned_to_user_id` (grep → rc=1), e `fn_conversation_assign` nunca tocou -- `bot_silenced_until`. Um atendente clicava "Assumir" e o automático continuava -- respondendo o MESMO cliente; ele só calava 5 minutos deslizantes quando a pessoa -- ENVIAVA (`extendBotSilence`). Dois atores atendendo a mesma pessoa é o defeito, e -- qualquer selo de "você está no comando" em cima disso seria mentira. -- -- O conserto entra na função de atribuição e não no motor porque -- `bot_silenced_until` é o gate que o motor JÁ lê — nenhuma linha do motor muda. A -- alternativa (ensinar o motor a ler `assignee_kind`) foi medida e REPROVADA: -- `Fechar` não solta o dono, de propósito, então o fim NORMAL de um atendimento -- deixaria `assignee_kind='user'` pendurado e o automático mudo para sempre naquele -- contato — e aquele gate é por CONTATO, então calaria conversa NOVA de outro número. -- -- O braço do rodízio é o que impede a regressão silenciosa: -- * `p_reason='routing'` → NÃO MEXE. Distribuir não é assumir. -- `trg_conversation_routing_requested` dispara em TODA conversa nova e o worker -- roda 1×/min: sem a ressalva, uma org em `round_robin` ficaria com o automático -- calado na primeira mensagem da vida de cada cliente. -- * destino humano (claim/transfer) → 'infinity'. * destino nulo (release) → null. -- -- Assinatura IDÊNTICA de 6 args de propósito: parâmetro novo criaria OVERLOAD (o -- `create or replace` não substitui assinatura diferente) e as cinco chamadas por -- nome passariam a falhar com `is not unique`. Idempotente; sem dados a corrigir. -- -- A limpeza do silêncio ao FECHAR mora na rota (`close/route.ts`): fechar não passa -- por aqui, e sem ela o silêncio vazaria para o próximo episódio — a ingestão reusa -- a MESMA linha de conversa (`on conflict do update`). create or replace function public.fn_conversation_assign( p_organization_id uuid, p_conversation_id uuid, p_to_user_id uuid, p_reason text, p_expected_assignee uuid default null, p_enforce_expected boolean default false ) returns setof public.conversations language plpgsql security definer set search_path = public as $$ declare v_from uuid; v_conv public.conversations%rowtype; begin if auth.uid() is not null and not public.fn_role_at_least(p_organization_id, 'agent') then raise exception 'caller_not_authorized_for_org' using hint = 'caller must be an active agent+ member of the organization'; end if; if p_to_user_id is not null then if coalesce(public.fn_member_role_in_org(p_to_user_id, p_organization_id), 'none') not in ('agent','manager','admin') then raise exception 'assignee_not_eligible_member' using hint = 'target must be an active agent+ member of the organization'; end if; end if; select assigned_to_user_id into v_from from public.conversations where id = p_conversation_id and organization_id = p_organization_id for update; if not found then return; end if; if p_enforce_expected and v_from is distinct from p_expected_assignee then return; end if; update public.conversations set assigned_to_user_id = p_to_user_id, assigned_at = case when p_to_user_id is null then null else now() end, assignee_kind = case when p_to_user_id is null then null else 'user' end, status = case when p_to_user_id is null then 'open' else 'claimed' end, status_changed_at = now(), unread_count_for_assignee = 0, -- A trava só é solta por quem a pôs. `last_handoff_at` é o discriminador -- que já existe: uma ESCALAÇÃO o carimba (`performHumanHandoff` e -- `triggerHandoff`), um humano ASSUMINDO não. Sem esta condição, o -- release apagaria o silêncio de uma conversa que a IA escalou — e o -- caminho legado (`triggerHandoff`, usado pelo MCP, pelo handler de -- sentimento, pelo worker e pelo teto de gasto) NÃO grava -- `contacts.force_human`, então ali o silêncio é a ÚNICA trava. Medido: -- `grep -n force_human lib/ai/handoff/orchestrator.ts` → rc=1. -- Soltar de propósito é o botão "Devolver ao automático" -- (`devolverAtendimentoAoAgente`), que limpa as três travas de uma vez. bot_silenced_until = case when p_reason = 'routing' then bot_silenced_until when p_to_user_id is null then (case when last_handoff_at is null then null else bot_silenced_until end) else 'infinity'::timestamptz end, updated_at = now() where id = p_conversation_id returning * into v_conv; insert into public.conversation_assignment_events (organization_id, conversation_id, from_user_id, to_user_id, changed_by, reason) values (p_organization_id, p_conversation_id, v_from, p_to_user_id, auth.uid(), p_reason); return next v_conv; end; $$; -- As DUAS origens de EXECUTE (doutrina, item 9): `revoke from public` não remove o -- grant direto que `anon` carrega via ALTER DEFAULT PRIVILEGES, e `revoke from anon` -- não remove o grant a PUBLIC dado na criação. Re-asseridas: é SECURITY DEFINER que -- reatribui conversa. revoke all on function public.fn_conversation_assign(uuid, uuid, uuid, text, uuid, boolean) from public; revoke execute on function public.fn_conversation_assign(uuid, uuid, uuid, text, uuid, boolean) from anon; grant execute on function public.fn_conversation_assign(uuid, uuid, uuid, text, uuid, boolean) to authenticated, service_role; -- ---- o histórico de atribuição herda o escopo da conversa (migration 0173) ---- -- Medido: org em `visibility_mode='own'`, agent que não é dono → `select` em -- `conversations` devolve 0 linhas e `select` em `conversation_assignment_events` da -- MESMA conversa devolve 1. A policy era membership de org pura enquanto -- `conversations_select` passa por `fn_can_view_conversation`. A tabela vive no -- schema `public`, então isso é alcançável pelo PostgREST com a anon key + o JWT do -- usuário — não depende de existir rota nossa. -- -- Molde do `messages_select`: o `exists` sobre `conversations` já aplica a RLS de -- `conversations`, então o escopo é HERDADO em vez de reescrito — duas cópias da -- mesma regra divergem na primeira mudança de uma delas. drop policy if exists cae_select on public.conversation_assignment_events; create policy cae_select on public.conversation_assignment_events for select using ( public.fn_is_platform_admin() or ( -- O filtro de org fica, mesmo com o `exists` ao lado. Os dois predicados -- respondem perguntas DIFERENTES: o `exists` diz "você enxerga esta -- conversa?", e este diz "esta LINHA é da sua organização?". A policy de -- INSERT (intocada) permite gravar uma linha com o `organization_id` de um -- tenant e o `conversation_id` de outro; sem esta metade, quem enxerga a -- conversa apontada leria a linha do tenant vizinho. organization_id in (select public.fn_user_org_ids()) and exists ( select 1 from public.conversations c where c.id = conversation_assignment_events.conversation_id ) ) ); -- ---- LGPD alcança o histórico de captação: função + trigger (migration 0174) ---- -- -- A PRIMEIRA metade da 0174. Está aqui, e não no fim do arquivo, porque cria -- FUNÇÃO — e o bloco da VARREDURA anon (logo abaixo) proíbe qualquer -- `create function` depois dele: a função nasceria com EXECUTE para `anon` em -- quem ATUALIZA, sem nada mais adiante para tirar. A tabela vai no bloco do -- fim, e a ordem entre os dois não importa: o corpo de uma plpgsql só resolve -- os nomes na execução, e o trigger é de UPDATE (nada dispara durante o -- baseline). -- -- `fn_lgpd_cascade_redact_contact` tem 180 linhas; acrescentar um 9º passo -- exigiria reescrevê-la inteira aqui, e a partir daí existiriam duas cópias — -- a do dump e a do apêndice — que divergem no primeiro conserto que alguém -- fizer na de cima. O gancho é a transição `is_anonymized false → true` na -- própria `contacts`, que é o último fato da anonimização e roda na MESMA -- transação do cascade. E alcança mais que o 9º passo alcançaria: qualquer -- caminho que anonimize um contato passa por este UPDATE. create or replace function public.fn_redigir_captacoes_do_contato_anonimizado() returns trigger language plpgsql security definer set search_path = public, pg_temp as $$ begin update public.webhook_lead_captures set captured_name = null, captured_phone = null, captured_email = null, fields = '{}'::jsonb, utm = '{}'::jsonb, remote_ip = null, user_agent = null where organization_id = new.organization_id and contact_id = new.id; return new; end; $$; revoke execute on function public.fn_redigir_captacoes_do_contato_anonimizado() from public, anon, authenticated; grant execute on function public.fn_redigir_captacoes_do_contato_anonimizado() to service_role; notify pgrst, 'reload schema'; -- ---- nono dígito canônico (migration 0198) ---- -- 0198 — celular BR canônico COM o nono dígito -- -- +553284793302 e +5532984793302 são a MESMA pessoa. O CRM passa a GRAVAR e -- MOSTRAR a forma com o 9; o WhatsApp/WAHA continuam podendo endereçar sem ele -- (check-exists tenta as duas grafias). -- -- A busca por variantes já existia em TypeScript. Sem a RPC e sem o backfill, -- o webhook que chega sem o 9 ainda nascia um segundo contato. -- -- Idempotente: create or replace + updates que na segunda passada casam zero -- linhas. Sem constraint nova. -- 1 · a RPC reencontra pelas duas grafias e GRAVA a canônica. create or replace function public.fn_upsert_wa_contact( p_org uuid, p_kind text, p_phone text, p_lid text, p_chat_id text, p_notify text ) returns uuid language plpgsql security definer set search_path = public as $$ declare v_id uuid; v_conflito text; v_lid text := nullif(regexp_replace(coalesce(p_lid, ''), '@.*$', ''), ''); v_phone text := nullif(p_phone, ''); v_digits text; v_alt text; begin -- Celular BR de 12 dígitos (local 6–9) ganha o nono. A grafia sem o 9 fica -- em v_alt só para a BUSCA — não se escreve mais. if v_phone is not null then v_digits := regexp_replace(v_phone, '\D', '', 'g'); if v_digits ~ '^55[1-9][0-9][6-9][0-9]{7}$' then v_alt := '+' || v_digits; v_phone := '+55' || substring(v_digits from 3 for 2) || '9' || substring(v_digits from 5); elsif v_digits ~ '^55[1-9][0-9]9[6-9][0-9]{7}$' then v_phone := '+' || v_digits; v_alt := '+55' || substring(v_digits from 3 for 2) || substring(v_digits from 6); end if; end if; if v_lid is not null then select id into v_id from public.contacts where organization_id = p_org and wa_lid = v_lid and is_merged_into is null limit 1; end if; if v_id is null and v_phone is not null then select id into v_id from public.contacts where organization_id = p_org and is_merged_into is null and phone_number in (v_phone, v_alt) order by case when phone_number = v_phone then 0 else 1 end limit 1; end if; if v_id is not null and v_phone is not null and exists ( select 1 from public.contacts where organization_id = p_org and phone_number = v_phone and is_merged_into is null and id <> v_id ) then v_conflito := v_phone; v_phone := null; end if; if v_id is not null then update public.contacts set -- Promove 12→13 quando é a MESMA pessoa e o canônico está livre. -- Outro número (pessoa diferente) continua intocável. phone_number = case when v_phone is not null and (phone_number is null or phone_number = v_alt) then v_phone else phone_number end, display_name = coalesce(display_name, nullif(p_notify, '')), source_metadata = source_metadata || case when v_lid is not null then jsonb_build_object('waha_lid', v_lid) else '{}'::jsonb end || case when p_chat_id is not null then jsonb_build_object('waha_chat_id', p_chat_id) else '{}'::jsonb end || case when nullif(p_notify, '') is not null then jsonb_build_object('notify_name', p_notify) else '{}'::jsonb end || case when v_conflito is not null then jsonb_build_object('telefone_em_conflito', v_conflito) else '{}'::jsonb end, updated_at = now() where id = v_id; return v_id; end if; begin insert into public.contacts (organization_id, phone_number, source, consent, tags, source_metadata, display_name) values (p_org, v_phone, 'whatsapp', '{}'::jsonb, '{}'::text[], case when v_lid is not null then jsonb_build_object('waha_lid', v_lid, 'waha_chat_id', p_chat_id, 'notify_name', nullif(p_notify, '')) else jsonb_build_object('waha_chat_id', p_chat_id, 'notify_name', nullif(p_notify, '')) end, nullif(p_notify, '')) returning id into v_id; return v_id; exception when unique_violation then select id into v_id from public.contacts where organization_id = p_org and is_merged_into is null and ( (v_phone is not null and phone_number = v_phone) or (v_alt is not null and phone_number = v_alt) or (v_lid is not null and wa_lid = v_lid) ) order by case when phone_number = v_phone then 0 else 1 end limit 1; return v_id; end; end; $$; -- 2 · pares 12+13: funde o de 12 no de 13 (canônico). Conversas 1:1 no mesmo -- canal são fundidas ANTES de remarcarmos o contact_id, senão -- uniq_conversations_1to1_per_contact_session estoura. with pares as ( select sem.id as loser_id, com.id as winner_id from public.contacts sem join public.contacts com on com.organization_id = sem.organization_id and com.is_merged_into is null and sem.is_merged_into is null and sem.id <> com.id and sem.is_anonymized = false and com.is_anonymized = false and regexp_replace(coalesce(sem.phone_number, ''), '\D', '', 'g') ~ '^55[1-9][0-9][6-9][0-9]{7}$' and com.phone_number = '+55' || substring(regexp_replace(sem.phone_number, '\D', '', 'g') from 3 for 2) || '9' || substring(regexp_replace(sem.phone_number, '\D', '', 'g') from 5) ) , conv_pares as ( select loser.id as loser_conv, winner.id as winner_conv, p.winner_id from pares p join public.conversations loser on loser.contact_id = p.loser_id and loser.is_group = false join public.conversations winner on winner.contact_id = p.winner_id and winner.channel_session_id = loser.channel_session_id and winner.organization_id = loser.organization_id and winner.is_group = false and winner.id <> loser.id ) update public.messages m set conversation_id = cp.winner_conv, contact_id = cp.winner_id from conv_pares cp where m.conversation_id = cp.loser_conv; with pares as ( select sem.id as loser_id, com.id as winner_id from public.contacts sem join public.contacts com on com.organization_id = sem.organization_id and com.is_merged_into is null and sem.is_merged_into is null and sem.id <> com.id and sem.is_anonymized = false and com.is_anonymized = false and regexp_replace(coalesce(sem.phone_number, ''), '\D', '', 'g') ~ '^55[1-9][0-9][6-9][0-9]{7}$' and com.phone_number = '+55' || substring(regexp_replace(sem.phone_number, '\D', '', 'g') from 3 for 2) || '9' || substring(regexp_replace(sem.phone_number, '\D', '', 'g') from 5) ) , conv_pares as ( select loser.id as loser_conv, winner.id as winner_conv from pares p join public.conversations loser on loser.contact_id = p.loser_id and loser.is_group = false join public.conversations winner on winner.contact_id = p.winner_id and winner.channel_session_id = loser.channel_session_id and winner.organization_id = loser.organization_id and winner.is_group = false and winner.id <> loser.id ) update public.ai_agent_runs t set conversation_id = cp.winner_conv from conv_pares cp where t.conversation_id = cp.loser_conv; with pares as ( select sem.id as loser_id, com.id as winner_id from public.contacts sem join public.contacts com on com.organization_id = sem.organization_id and com.is_merged_into is null and sem.is_merged_into is null and sem.id <> com.id and sem.is_anonymized = false and com.is_anonymized = false and regexp_replace(coalesce(sem.phone_number, ''), '\D', '', 'g') ~ '^55[1-9][0-9][6-9][0-9]{7}$' and com.phone_number = '+55' || substring(regexp_replace(sem.phone_number, '\D', '', 'g') from 3 for 2) || '9' || substring(regexp_replace(sem.phone_number, '\D', '', 'g') from 5) ) , conv_pares as ( select loser.id as loser_conv, winner.id as winner_conv from pares p join public.conversations loser on loser.contact_id = p.loser_id and loser.is_group = false join public.conversations winner on winner.contact_id = p.winner_id and winner.channel_session_id = loser.channel_session_id and winner.organization_id = loser.organization_id and winner.is_group = false and winner.id <> loser.id ) update public.ai_invocations t set conversation_id = cp.winner_conv from conv_pares cp where t.conversation_id = cp.loser_conv; with pares as ( select sem.id as loser_id, com.id as winner_id from public.contacts sem join public.contacts com on com.organization_id = sem.organization_id and com.is_merged_into is null and sem.is_merged_into is null and sem.id <> com.id and sem.is_anonymized = false and com.is_anonymized = false and regexp_replace(coalesce(sem.phone_number, ''), '\D', '', 'g') ~ '^55[1-9][0-9][6-9][0-9]{7}$' and com.phone_number = '+55' || substring(regexp_replace(sem.phone_number, '\D', '', 'g') from 3 for 2) || '9' || substring(regexp_replace(sem.phone_number, '\D', '', 'g') from 5) ) , conv_pares as ( select loser.id as loser_conv, winner.id as winner_conv from pares p join public.conversations loser on loser.contact_id = p.loser_id and loser.is_group = false join public.conversations winner on winner.contact_id = p.winner_id and winner.channel_session_id = loser.channel_session_id and winner.organization_id = loser.organization_id and winner.is_group = false and winner.id <> loser.id ) update public.conversation_notes t set conversation_id = cp.winner_conv from conv_pares cp where t.conversation_id = cp.loser_conv; with pares as ( select sem.id as loser_id, com.id as winner_id from public.contacts sem join public.contacts com on com.organization_id = sem.organization_id and com.is_merged_into is null and sem.is_merged_into is null and sem.id <> com.id and sem.is_anonymized = false and com.is_anonymized = false and regexp_replace(coalesce(sem.phone_number, ''), '\D', '', 'g') ~ '^55[1-9][0-9][6-9][0-9]{7}$' and com.phone_number = '+55' || substring(regexp_replace(sem.phone_number, '\D', '', 'g') from 3 for 2) || '9' || substring(regexp_replace(sem.phone_number, '\D', '', 'g') from 5) ) , conv_pares as ( select loser.id as loser_conv, winner.id as winner_conv from pares p join public.conversations loser on loser.contact_id = p.loser_id and loser.is_group = false join public.conversations winner on winner.contact_id = p.winner_id and winner.channel_session_id = loser.channel_session_id and winner.organization_id = loser.organization_id and winner.is_group = false and winner.id <> loser.id ) update public.conversation_assignment_events t set conversation_id = cp.winner_conv from conv_pares cp where t.conversation_id = cp.loser_conv; with pares as ( select sem.id as loser_id, com.id as winner_id from public.contacts sem join public.contacts com on com.organization_id = sem.organization_id and com.is_merged_into is null and sem.is_merged_into is null and sem.id <> com.id and sem.is_anonymized = false and com.is_anonymized = false and regexp_replace(coalesce(sem.phone_number, ''), '\D', '', 'g') ~ '^55[1-9][0-9][6-9][0-9]{7}$' and com.phone_number = '+55' || substring(regexp_replace(sem.phone_number, '\D', '', 'g') from 3 for 2) || '9' || substring(regexp_replace(sem.phone_number, '\D', '', 'g') from 5) ) , conv_pares as ( select loser.id as loser_conv, winner.id as winner_conv from pares p join public.conversations loser on loser.contact_id = p.loser_id and loser.is_group = false join public.conversations winner on winner.contact_id = p.winner_id and winner.channel_session_id = loser.channel_session_id and winner.organization_id = loser.organization_id and winner.is_group = false and winner.id <> loser.id ) update public.agent_cases t set conversation_id = cp.winner_conv from conv_pares cp where t.conversation_id = cp.loser_conv; with pares as ( select sem.id as loser_id, com.id as winner_id from public.contacts sem join public.contacts com on com.organization_id = sem.organization_id and com.is_merged_into is null and sem.is_merged_into is null and sem.id <> com.id and sem.is_anonymized = false and com.is_anonymized = false and regexp_replace(coalesce(sem.phone_number, ''), '\D', '', 'g') ~ '^55[1-9][0-9][6-9][0-9]{7}$' and com.phone_number = '+55' || substring(regexp_replace(sem.phone_number, '\D', '', 'g') from 3 for 2) || '9' || substring(regexp_replace(sem.phone_number, '\D', '', 'g') from 5) ) , conv_pares as ( select loser.id as loser_conv, winner.id as winner_conv from pares p join public.conversations loser on loser.contact_id = p.loser_id and loser.is_group = false join public.conversations winner on winner.contact_id = p.winner_id and winner.channel_session_id = loser.channel_session_id and winner.organization_id = loser.organization_id and winner.is_group = false and winner.id <> loser.id ) update public.followup_enrollments t set conversation_id = cp.winner_conv from conv_pares cp where t.conversation_id = cp.loser_conv; with pares as ( select sem.id as loser_id, com.id as winner_id from public.contacts sem join public.contacts com on com.organization_id = sem.organization_id and com.is_merged_into is null and sem.is_merged_into is null and sem.id <> com.id and sem.is_anonymized = false and com.is_anonymized = false and regexp_replace(coalesce(sem.phone_number, ''), '\D', '', 'g') ~ '^55[1-9][0-9][6-9][0-9]{7}$' and com.phone_number = '+55' || substring(regexp_replace(sem.phone_number, '\D', '', 'g') from 3 for 2) || '9' || substring(regexp_replace(sem.phone_number, '\D', '', 'g') from 5) ) , conv_pares as ( select loser.id as loser_conv, winner.id as winner_conv from pares p join public.conversations loser on loser.contact_id = p.loser_id and loser.is_group = false join public.conversations winner on winner.contact_id = p.winner_id and winner.channel_session_id = loser.channel_session_id and winner.organization_id = loser.organization_id and winner.is_group = false and winner.id <> loser.id ) update public.contact_field_proposals t set conversation_id = cp.winner_conv from conv_pares cp where t.conversation_id = cp.loser_conv; with pares as ( select sem.id as loser_id, com.id as winner_id from public.contacts sem join public.contacts com on com.organization_id = sem.organization_id and com.is_merged_into is null and sem.is_merged_into is null and sem.id <> com.id and sem.is_anonymized = false and com.is_anonymized = false and regexp_replace(coalesce(sem.phone_number, ''), '\D', '', 'g') ~ '^55[1-9][0-9][6-9][0-9]{7}$' and com.phone_number = '+55' || substring(regexp_replace(sem.phone_number, '\D', '', 'g') from 3 for 2) || '9' || substring(regexp_replace(sem.phone_number, '\D', '', 'g') from 5) ) , conv_pares as ( select loser.id as loser_conv, winner.id as winner_conv from pares p join public.conversations loser on loser.contact_id = p.loser_id and loser.is_group = false join public.conversations winner on winner.contact_id = p.winner_id and winner.channel_session_id = loser.channel_session_id and winner.organization_id = loser.organization_id and winner.is_group = false and winner.id <> loser.id ) delete from public.demanda_conversas d using conv_pares cp where d.conversation_id = cp.loser_conv and exists ( select 1 from public.demanda_conversas w where w.demanda_id = d.demanda_id and w.conversation_id = cp.winner_conv ); with pares as ( select sem.id as loser_id, com.id as winner_id from public.contacts sem join public.contacts com on com.organization_id = sem.organization_id and com.is_merged_into is null and sem.is_merged_into is null and sem.id <> com.id and sem.is_anonymized = false and com.is_anonymized = false and regexp_replace(coalesce(sem.phone_number, ''), '\D', '', 'g') ~ '^55[1-9][0-9][6-9][0-9]{7}$' and com.phone_number = '+55' || substring(regexp_replace(sem.phone_number, '\D', '', 'g') from 3 for 2) || '9' || substring(regexp_replace(sem.phone_number, '\D', '', 'g') from 5) ) , conv_pares as ( select loser.id as loser_conv, winner.id as winner_conv from pares p join public.conversations loser on loser.contact_id = p.loser_id and loser.is_group = false join public.conversations winner on winner.contact_id = p.winner_id and winner.channel_session_id = loser.channel_session_id and winner.organization_id = loser.organization_id and winner.is_group = false and winner.id <> loser.id ) update public.demanda_conversas t set conversation_id = cp.winner_conv from conv_pares cp where t.conversation_id = cp.loser_conv; with pares as ( select sem.id as loser_id, com.id as winner_id from public.contacts sem join public.contacts com on com.organization_id = sem.organization_id and com.is_merged_into is null and sem.is_merged_into is null and sem.id <> com.id and sem.is_anonymized = false and com.is_anonymized = false and regexp_replace(coalesce(sem.phone_number, ''), '\D', '', 'g') ~ '^55[1-9][0-9][6-9][0-9]{7}$' and com.phone_number = '+55' || substring(regexp_replace(sem.phone_number, '\D', '', 'g') from 3 for 2) || '9' || substring(regexp_replace(sem.phone_number, '\D', '', 'g') from 5) ) , conv_pares as ( select loser.id as loser_conv, winner.id as winner_conv from pares p join public.conversations loser on loser.contact_id = p.loser_id and loser.is_group = false join public.conversations winner on winner.contact_id = p.winner_id and winner.channel_session_id = loser.channel_session_id and winner.organization_id = loser.organization_id and winner.is_group = false and winner.id <> loser.id ) delete from public.conversations d using conv_pares cp where d.id = cp.loser_conv; -- Marca os de 12 dígitos como fundidos no irmão de 13. with pares as ( select sem.id as loser_id, com.id as winner_id from public.contacts sem join public.contacts com on com.organization_id = sem.organization_id and com.is_merged_into is null and sem.is_merged_into is null and sem.id <> com.id and sem.is_anonymized = false and com.is_anonymized = false and regexp_replace(coalesce(sem.phone_number, ''), '\D', '', 'g') ~ '^55[1-9][0-9][6-9][0-9]{7}$' and com.phone_number = '+55' || substring(regexp_replace(sem.phone_number, '\D', '', 'g') from 3 for 2) || '9' || substring(regexp_replace(sem.phone_number, '\D', '', 'g') from 5) ) update public.contacts c set is_merged_into = p.winner_id, merged_at = now() from pares p where c.id = p.loser_id; -- lead_state é unique (org, contact): apaga o perdedor se o vencedor já tem linha. delete from public.lead_state l using public.contacts c where l.contact_id = c.id and c.is_merged_into is not null and exists ( select 1 from public.lead_state w where w.contact_id = c.is_merged_into and w.organization_id = l.organization_id ); update public.followup_enrollments e set status = 'cancelled', cancel_reason = 'nono_digito_merge', next_eval_at = null, updated_at = now() from public.contacts c where e.contact_id = c.id and c.is_merged_into is not null and e.status in ('active', 'waiting_reply', 'paused_handoff') and exists ( select 1 from public.followup_enrollments w where w.organization_id = e.organization_id and w.contact_id = c.is_merged_into and w.status in ('active', 'waiting_reply', 'paused_handoff') ); update public.conversations t set contact_id = c.is_merged_into from public.contacts c where t.contact_id = c.id and c.is_merged_into is not null; update public.messages t set contact_id = c.is_merged_into from public.contacts c where t.contact_id = c.id and c.is_merged_into is not null; update public.ai_agent_runs t set contact_id = c.is_merged_into from public.contacts c where t.contact_id = c.id and c.is_merged_into is not null; update public.crm_lead_activities t set contact_id = c.is_merged_into from public.contacts c where t.contact_id = c.id and c.is_merged_into is not null; update public.crm_leads t set contact_id = c.is_merged_into from public.contacts c where t.contact_id = c.id and c.is_merged_into is not null; update public.lgpd_requests t set contact_id = c.is_merged_into from public.contacts c where t.contact_id = c.id and c.is_merged_into is not null; update public.orders t set contact_id = c.is_merged_into from public.contacts c where t.contact_id = c.id and c.is_merged_into is not null; update public.job_queue t set contact_id = c.is_merged_into from public.contacts c where t.contact_id = c.id and c.is_merged_into is not null and not (t.status = 'running' and exists ( select 1 from public.job_queue w where w.contact_id = c.is_merged_into and w.status = 'running' )); update public.send_ledger t set contact_id = c.is_merged_into from public.contacts c where t.contact_id = c.id and c.is_merged_into is not null; update public.llm_calls t set contact_id = c.is_merged_into from public.contacts c where t.contact_id = c.id and c.is_merged_into is not null; update public.lead_checkpoints t set contact_id = c.is_merged_into from public.contacts c where t.contact_id = c.id and c.is_merged_into is not null; update public.lead_state t set contact_id = c.is_merged_into from public.contacts c where t.contact_id = c.id and c.is_merged_into is not null; update public.lead_state_transitions t set contact_id = c.is_merged_into from public.contacts c where t.contact_id = c.id and c.is_merged_into is not null; update public.cron_jobs t set contact_id = c.is_merged_into from public.contacts c where t.contact_id = c.id and c.is_merged_into is not null; update public.lead_notes t set contact_id = c.is_merged_into from public.contacts c where t.contact_id = c.id and c.is_merged_into is not null; update public.before_send_traces t set contact_id = c.is_merged_into from public.contacts c where t.contact_id = c.id and c.is_merged_into is not null; -- Roteiro de atendimento vivo ('coletando', 0394): UM por contato -- (`idx_followup_enrollments_um_roteiro_coletando`). Reapontar sem deduplicar -- daria 23505 quando os dois contatos fundidos têm roteiro vivo — e este bloco -- roda de novo a cada `update.sh`. Fica o mais NOVO; o excedente é encerrado, -- com o evento na trilha. Idempotente: encerrado deixa de ser 'coletando'. with vivos as ( select e.id, e.organization_id, e.current_node_id, row_number() over ( partition by e.organization_id, coalesce(c.is_merged_into, c.id) order by e.started_at desc, e.id desc ) as posicao from public.followup_enrollments e join public.contacts c on c.id = e.contact_id and c.organization_id = e.organization_id where e.status = 'coletando' ), encerrados as ( update public.followup_enrollments e set status = 'cancelled', cancel_reason = 'nono_digito_merge', completed_at = now(), updated_at = now() from vivos v where e.id = v.id and v.posicao > 1 returning e.id, e.organization_id, e.current_node_id ) insert into public.followup_enrollment_events (organization_id, enrollment_id, node_id, event_type, payload, idempotency_key) select organization_id, id, current_node_id, 'roteiro_cancelado', '{"motivo":"nono_digito_merge"}'::jsonb, 'roteiro_cancelado:nono_digito_merge' from encerrados on conflict do nothing; update public.followup_enrollments t set contact_id = c.is_merged_into from public.contacts c where t.contact_id = c.id and c.is_merged_into is not null; update public.demandas t set contact_id = c.is_merged_into from public.contacts c where t.contact_id = c.id and c.is_merged_into is not null; update public.contact_field_proposals t set contact_id = c.is_merged_into from public.contacts c where t.contact_id = c.id and c.is_merged_into is not null; -- 3 · quem só tinha a grafia de 12 dígitos ganha o nono. Pula se o canônico -- já pertence a outro contato vivo (o passo 2 deveria ter fundido; isto é o -- piso de segurança para o unique). update public.contacts set phone_number = '+55' || substring(regexp_replace(phone_number, '\D', '', 'g') from 3 for 2) || '9' || substring(regexp_replace(phone_number, '\D', '', 'g') from 5), updated_at = now() where is_merged_into is null and is_anonymized = false and regexp_replace(coalesce(phone_number, ''), '\D', '', 'g') ~ '^55[1-9][0-9][6-9][0-9]{7}$' and not exists ( select 1 from public.contacts o where o.organization_id = contacts.organization_id and o.is_merged_into is null and o.id <> contacts.id and o.phone_number = '+55' || substring(regexp_replace(contacts.phone_number, '\D', '', 'g') from 3 for 2) || '9' || substring(regexp_replace(contacts.phone_number, '\D', '', 'g') from 5) ); -- ---- agenda: o cascade que o vínculo polimórfico não tem — função (migration 0177) ---- -- -- A PRIMEIRA metade da 0177. Está aqui, e não no fim do arquivo, porque cria -- FUNÇÃO — e o bloco da VARREDURA anon (logo abaixo) proíbe qualquer -- `create function` depois dele: a função nasceria com EXECUTE para `anon` em -- quem ATUALIZA, sem nada mais adiante para tirar. A tabela, o trigger e a RLS -- vão no bloco do fim, e a ordem entre os dois não importa: o corpo de uma -- plpgsql só resolve nomes na execução, e o trigger é de DELETE (nada dispara -- durante o baseline). -- -- O agendamento se liga ao lead por `crm_lead_links` (target_kind='appointment', -- valor que o CHECK daquela tabela já aceitava antes desta migration), e -- `target_id` é polimórfico: não pode ter FK, logo não tem ON DELETE. Apagar um -- agendamento deixaria o vínculo apontando para o nada. O caminho normal é que -- agendamento não se apague — se cancele —, mas isso é prosa, e prosa não é -- guarda. -- ──────────────────────────────────────────────────────────────────────────── -- 8 · o cascade que o polimórfico não tem -- ──────────────────────────────────────────────────────────────────────────── -- O vínculo com o lead vai por `crm_lead_links` (target_kind='appointment'), e -- `target_id` é polimórfico: não pode ter FK, logo não tem ON DELETE. Apagar -- um agendamento deixaria o vínculo apontando para o nada. -- -- O caminho NORMAL é que agendamento não se apague: cancela-se (`status` -- 'cancelled'), porque o cancelamento é informação de negócio e a aba -- Histórico existe para mostrá-lo. Mas "ninguém deveria apagar" é prosa, e -- prosa não é guarda. Este trigger é o mecanismo. create or replace function public.fn_limpar_vinculos_do_agendamento() returns trigger language plpgsql security definer set search_path = public, pg_temp as $$ begin delete from public.crm_lead_links where organization_id = old.organization_id and target_kind = 'appointment' and target_id = old.id; return old; end; $$; -- Função de trigger não exige EXECUTE de quem dispara o DELETE, então revogar -- das três origens não a quebra — e mantém a função fora da lista de exceções -- do invariante de hardening, que é congelada. revoke execute on function public.fn_limpar_vinculos_do_agendamento() from public, anon, authenticated; grant execute on function public.fn_limpar_vinculos_do_agendamento() to service_role; -- ---- LGPD alcança a agenda: função (migration 0184) ---- -- -- A PRIMEIRA metade da 0184. Está aqui, e não no fim, porque cria FUNÇÃO — e a -- VARREDURA anon (logo abaixo) proíbe `create function` depois dela: a função -- nasceria com EXECUTE para `anon` em quem ATUALIZA, sem nada adiante para tirar. -- O trigger vai no bloco do fim; a ordem não importa, porque o corpo de uma -- plpgsql só resolve nomes na execução e nada dispara UPDATE durante o baseline. -- -- `fn_lgpd_cascade_redact_contact` percorre uma lista escrita à mão e -- `calendar_appointments` não estava nela — e a tabela guarda `title`, -- `description`, `notes` (numa clínica, queixa clínica), `location_details` e -- `cancellation_reason`. A função reportava sucesso, a rota reportava sucesso, o -- SLA de D+15 era marcado como cumprido, e a queixa continuava legível. -- -- Trigger e não passo dentro da função: ela vem do dump com ~180 linhas, e um -- passo novo exigiria carregar uma CÓPIA inteira dela aqui — duas cópias que -- divergem no primeiro conserto. E o trigger escuta a COLUNA, não o chamador, -- então alcança qualquer caminho de anonimização. create or replace function public.fn_redigir_agenda_do_contato_anonimizado() returns trigger language plpgsql security definer set search_path = public, pg_temp as $$ begin update public.calendar_appointments set title = 'Compromisso anonimizado', description = null, notes = null, location_details = null, meeting_url = null, cancellation_reason = null where organization_id = new.organization_id and contact_id = new.id; return new; end; $$; -- Função de trigger não exige EXECUTE de quem dispara o UPDATE, então revogar -- das três origens não a quebra — e a mantém fora da lista de exceções do -- invariante de hardening, que é congelada. revoke execute on function public.fn_redigir_agenda_do_contato_anonimizado() from public, anon, authenticated; grant execute on function public.fn_redigir_agenda_do_contato_anonimizado() to service_role; -- ---- a agenda nasce com o que marcar: funções (migration 0185) ---- -- -- A PRIMEIRA metade da 0185, aqui porque cria FUNÇÃO e a VARREDURA anon (logo -- abaixo) proíbe `create function` depois dela. -- -- Zero INSERT em `calendar_event_types` em todo o repo, medido com controle -- positivo. Instalação fresca abria a Agenda numa semana em branco, sem nada -- para clicar e sem mensagem — e grade vazia é indistinguível de "ninguém marcou -- hoje". É o P0 da doutrina de QA Visual. -- -- NEUTRO de propósito: o nicho não é persistido em lugar nenhum (a inferência -- roda em memória no passo do funil e morre lá), e o trigger dispara no INSERT -- da organização, antes de existir qualquer texto para inferir. Este é o PISO; -- o enriquecimento por nicho vive onde o nicho existe, no passo do onboarding. create or replace function public.fn_semear_tipos_de_agendamento(p_organization_id uuid) returns integer language plpgsql set search_path = public, pg_temp as $$ declare v_criados integer := 0; r record; begin for r in select * from (values ('Consulta', 'consulta', 'consulta', 30, 1000::numeric), ('Reunião', 'reuniao', 'reuniao', 30, 2000::numeric), ('Atendimento', 'atendimento', 'outro', 30, 3000::numeric) ) as t(nome, slug, categoria, duracao, posicao) loop insert into public.calendar_event_types (organization_id, name, slug, category, duration_minutes, position) values (p_organization_id, r.nome, r.slug, r.categoria, r.duracao, r.posicao) on conflict (organization_id, slug) do nothing; if found then v_criados := v_criados + 1; end if; end loop; return v_criados; end; $$; create or replace function public.fn_semear_tipos_de_agendamento_na_org_nova() returns trigger language plpgsql set search_path = public, pg_temp as $$ begin perform public.fn_semear_tipos_de_agendamento(new.id); return new; end; $$; -- Função de trigger não exige EXECUTE de quem dispara o INSERT, e a de seed é -- chamada por ela e pelo backfill — nenhum dos dois passa pelo PostgREST. revoke execute on function public.fn_semear_tipos_de_agendamento(uuid) from public, anon, authenticated; revoke execute on function public.fn_semear_tipos_de_agendamento_na_org_nova() from public, anon, authenticated; grant execute on function public.fn_semear_tipos_de_agendamento(uuid) to service_role; grant execute on function public.fn_semear_tipos_de_agendamento_na_org_nova() to service_role; -- ---- o espelho do Google é cache com prazo: função (migration 0187) ---- -- -- A PRIMEIRA metade da 0187, aqui porque cria FUNÇÃO e a VARREDURA anon proíbe -- `create function` depois dela. -- -- `calendar_external_events` ficou fora da cascata de LGPD (0184) por não ter -- `contact_id`. A decisão foi declarar ESPELHO — a fonte da verdade é a agenda do -- Google do próprio cliente. Mas essa declaração só é honesta com três -- propriedades, e faltava a terceira: PRAZO. Sem ele, "espelho" é um nome mais -- simpático para arquivo permanente de compromissos de terceiros. -- -- Corta por `ends_at`, nunca por `created_at`: compromisso futuro não envelhece, -- e apagá-lo faria a agenda marcar em cima de hora ocupada. Piso de 7 dias e não -- 90 como o da auditoria — auditoria é rastro que precisa sobreviver a um -- incidente; isto é cache que o sync repõe. create or replace function public.fn_expurgar_espelho_da_agenda( p_retencao_dias int default null, p_limite int default null ) returns int language plpgsql security definer set search_path = public, pg_temp as $$ declare -- 90 dias de passado visível; piso de 7 porque isto é cache reconstruível pelo -- sync, e não rastro que precise sobreviver a um incidente. v_dias int := greatest(coalesce(p_retencao_dias, 90), 7); v_limite int := least(greatest(coalesce(p_limite, 1000), 1), 10000); v_apagadas int; begin with vencidos as ( select e.id from public.calendar_external_events e -- `ends_at` e não `created_at`: um compromisso futuro não envelhece, e -- apagá-lo faria a agenda marcar em cima de hora ocupada. where e.ends_at < now() - make_interval(days => v_dias) order by e.ends_at limit v_limite ) delete from public.calendar_external_events e using vencidos v where e.id = v.id; get diagnostics v_apagadas = row_count; return v_apagadas; end; $$; revoke execute on function public.fn_expurgar_espelho_da_agenda(int, int) from public, anon, authenticated; grant execute on function public.fn_expurgar_espelho_da_agenda(int, int) to service_role; -- ---- mensagem editada e mensagem apagada (migration 0153) ---- -- O cliente edita ou apaga no aplicativo e o CRM seguia mostrando a versão -- velha — sem erro em lugar nenhum. Combinar preço ou endereço a partir de um -- texto que o cliente já corrigiu gera um erro que ninguém rastreia depois. -- Duas colunas e não um estado: editada continua valendo (o texto novo conta), -- apagada deixou de valer (o texto não pode mais aparecer). Timestamp e não -- booleano porque a pergunta seguinte é "quando?". A linha apagada NÃO some: a -- remoção levaria junto o contexto das vizinhas e o histórico de quem atendeu. alter table public.messages add column if not exists edited_at timestamptz; alter table public.messages add column if not exists revoked_at timestamptz; -- ---- definição sabe de qual conexão é (migration 0154) ---- -- `meta_templates` nasceu para um canal só: a única marca de origem é -- `waba_id`, o id da conta na plataforma da Meta. Um segundo canal não tem onde -- entrar sem mentir sobre o que aquele campo significa — e o endpoint, que -- resolve a sessão por `metaSessionForOrg`, devolvia lista VAZIA numa -- instalação que só tem o canal intermediado. A conexão, e não um `provider`: -- dois números do mesmo provider têm definições diferentes. `set null` no -- delete porque apagar a conexão não pode apagar o registro do que a -- plataforma aprovou — ela continua existindo lá. alter table public.meta_templates add column if not exists channel_session_id uuid references public.channel_sessions(id) on delete set null; create index if not exists meta_templates_sessao_idx on public.meta_templates (channel_session_id, status) where channel_session_id is not null; -- ---- o arquivo do webhook aceita os canais novos (migration 0151) ---- -- `webhook_events_log` guarda o corpo CRU do que o provedor mandou — é o único -- lugar onde ele fica. O CHECK do dump conhecia três provedores e nenhum dos -- canais do seam, então a rota genérica de canal não tinha como gravar sem -- mentir sobre a origem ('generic' para um canal que se sabe qual é). -- -- Este é o BLOCO ÚNICO desta constraint (regra da issue #159): canal novo edita -- ESTA lista, e não acrescenta um segundo bloco — dois blocos fazem o -- `update.sh` de um clone com dados falhar no primeiro e deixar a tabela sem -- constraint entre o `drop` e o `add` que funciona. -- -- Alargamento puro: um CHECK que aceita MAIS valores não pode ser violado por -- linha que já passava pelo antigo, então não precisa de backfill antes. alter table public.webhook_events_log drop constraint if exists webhook_events_log_provider_check; alter table public.webhook_events_log add constraint webhook_events_log_provider_check check (provider in ( 'waha', 'nuvemshop', 'generic', 'meta_cloud', 'zernio', 'datafy' )); -- ---- a marca da instalação sai do .env e vai para o banco (migration 0155) ---- -- -- Nome, logo e cor viviam só em `APP_NAME`/`APP_LOGO_URL`/`APP_ACCENT_HEX`: -- trocar qualquer um exigia SSH na VPS e reiniciar a stack. Para quem compra -- hospedagem e instala sozinho, isso é o mesmo que não ser configurável. -- -- O `.env` CONTINUA sendo escrito, e não é redundância: o `agent.sh` do kit, em -- falha de update, reverte só o `APP_IMAGE` — não o schema, não o `git -- checkout`. E o `update.sh` aplica ESTE arquivo ANTES de puxar a imagem. Ou -- seja, o rollback põe código antigo sobre banco novo por construção, e código -- antigo não conhece esta tabela. Com o `.env` intacto a marca degrada para o -- valor da instalação em vez de sumir no meio de um rollback. -- -- ── RLS LIGADA COM ZERO POLICIES + REVOKE EXPLÍCITO ───────────────────────── -- -- As duas coisas, e nenhuma substitui a outra: -- -- (1) `enable row level security` sem NENHUMA policy é a forma explícita de -- dizer "o PostgREST nunca serve isto". A tabela é lida e escrita só -- server-side, pelo admin client (`service_role`, que é `bypassrls`). -- -- (2) O `revoke` abaixo é O ANÁLOGO, PARA TABELA, DA REGRA DE `security -- definer` DO ITEM 9 DO CLAUDE.md — e isso não está documentado em lugar -- nenhum hoje, e é o furo que a próxima tabela de apêndice repetiria. -- Este mesmo arquivo traz `ALTER DEFAULT PRIVILEGES ... GRANT ALL ON -- TABLES TO anon` (linha ~3972) e `... TO authenticated` (~3973), e eles -- valem para TODA tabela criada DEPOIS deles — isto é, para todo apêndice -- novo. TABELA NOVA NASCE CONCEDIDA. Foi exatamente assim que nasceu a -- vulnerabilidade que a 0143 consertou em `org_guardrail_layers` (medido: -- um `viewer` desligava a camada anti-jailbreak pelo PostgREST — UPDATE 1 -- + INSERT 1), depois de a 0142 ter escrito "nenhuma função nova, então -- não há grant a revogar": leitura de uma doutrina que fala de FUNÇÃO. -- -- Aqui revoga-se de `authenticated` também (a 0143 revogou só de `anon`), -- porque nenhuma tela lê esta tabela pelo client de sessão — quem lê é o -- `app/layout.tsx`, no servidor. O privilégio é a camada que sobra no dia -- em que alguém acrescentar "só uma policy de leitura". -- -- ⚠️ `accent_hex` tem CHECK de REGEX, não de conjunto: ela NÃO entra na lista -- `PARES` de `tests/invariants/vocabulario-banco-x-typescript.test.ts`, cujo -- extrator só reconhece `= ANY (ARRAY[...])`. A doutrina "coluna nova com CHECK -- → uma linha ali" vale para CHECK de CONJUNTO. -- -- Sem `event_log`: nenhum dos 12 handlers de `lib/event-log/register-handlers.ts` -- cobriria um tipo `platform_branding.*`, e o drain deixa evento sem handler -- intocado — a linha nasceria `pending` para sempre em todo clone (anti-pattern -- nº 3). O registro é `audit()`, com consumidor real. -- -- Idempotente e auto-curativo: `create table if not exists` + `drop trigger if -- exists` antes do `create trigger`; grants e revokes são declarativos e podem -- ser reaplicados. Nenhuma constraint nova sobre dado existente (a tabela nasce -- vazia), então não há o que deduplicar antes. create table if not exists public.platform_branding ( id smallint primary key default 1, app_name text, logo_url text, accent_hex text, show_powered_by boolean not null default true, seeded_from_env boolean not null default false, -- Estado, não configuração: é o que torna a falha OBSERVÁVEL (invariante 6 da -- doutrina Sistema Vivo). Sem estas duas, o degrade ("o produto ficou com a -- cor dele") é indistinguível de "a feature nunca foi instalada". fallback_at timestamptz, fallback_reason text, updated_at timestamptz not null default now(), updated_by uuid, constraint platform_branding_singleton check (id = 1), constraint platform_branding_accent_hex check (accent_hex is null or accent_hex ~ '^#[0-9a-f]{6}$') ); comment on table public.platform_branding is 'Marca da INSTALAÇÃO (login, e-mail, 500) — linha única id=1. Semeada do .env na primeira leitura; para NOME e LOGO o .env continua sendo a rede de segurança de rollback (o agent.sh reverte a imagem, não o banco). Para COR não há rede: APP_ACCENT_HEX nasceu junto com esta tabela e o install.sh não o grava — nenhuma versão que desconheça platform_branding pinta accent. Lida/escrita só server-side (service_role). Ver lib/branding/instalacao.ts.'; comment on column public.platform_branding.seeded_from_env is 'true = os valores vieram do .env e ninguém os editou pela tela. A escrita humana zera isto, e é o que impede a semeadura de reescrever o que uma pessoa apagou de propósito.'; comment on column public.platform_branding.fallback_at is 'Quando a cor configurada foi RECUSADA e o produto caiu na cor dele. NULL = nenhuma recusa em vigor.'; comment on column public.platform_branding.fallback_reason is 'Códigos de recusa (FORMA, nunca o hex da marca). Escrito e limpo por lib/branding/instalacao.ts.'; alter table public.platform_branding enable row level security; -- ZERO POLICIES, DE PROPÓSITO — ver o bloco acima. revoke all on public.platform_branding from anon, authenticated; grant select, insert, update on public.platform_branding to service_role; drop trigger if exists trg_platform_branding_touch on public.platform_branding; create trigger trg_platform_branding_touch before update on public.platform_branding for each row execute function public.fn_touch_updated_at(); notify pgrst, 'reload schema'; -- ---- logo da marca: BUCKET e COLUNA (migration 0158) ---- -- -- A segunda metade da 0158. As funções estão ANTES do bloco da VARREDURA anon, -- e a razão de a migration entrar em dois pedaços está escrita lá: este pedaço -- depende de `platform_branding`, criada no bloco da 0155, que é o último do -- arquivo — e aquele pedaço cria função, o que a varredura proíbe depois dela. -- -- ─── Por que o bucket é PÚBLICO — o primeiro do repositório ───────────────── -- -- Os quatro que já existiam (`ai-policy`, `lgpd-exports`, `skill-assets`, -- `whatsapp-media`) nascem `public = false`. Este não, e a razão é medida: o logo -- é renderizado num `` da tela de LOGIN (`app/(public)/layout.tsx`), servida -- a quem NÃO tem sessão. URL assinada exige um segredo por requisição e VENCE — a -- marca da instalação sumiria da fachada no dia do vencimento, sem ninguém tocar -- em nada, e "o logo sumiu" não apontaria para a causa. -- -- O que mantém a exceção contida, e o que `tests/invariants/marca-logo.test.ts` -- mede: -- * bucket EXCLUSIVO de logo — nada de conversa, export ou base de conhecimento -- mora aqui, então "público" não vaza histórico de cliente nenhum; -- * ZERO policy em `storage.objects` para ele. `public = true` no Supabase abre -- a LEITURA pelo endpoint `/object/public/...`; não abre INSERT nem DELETE, -- que continuam só pelo `service_role`, pela rota, depois dos gates; -- * caminho não-enumerável (`/.`); -- * `allowed_mime_types` é BACKSTOP, não a defesa — o Storage compara com o -- header que QUEM SOBE escolheu. Quem decide é o farejador de bytes em -- `lib/branding/logo-arquivo.ts`. -- -- Registrado em `docs/threat-model.md` ao lado da linha de `whatsapp-media`. -- -- ─── Por que 512 KB ──────────────────────────────────────────────────────── -- -- `next.config.ts` roda com `images.unoptimized` e os dois renders do logo usam -- `` cru (a URL é de quem hospeda; `next/image` exige allowlist fechada em -- BUILD e a imagem é pré-buildada). O arquivo vai INTEIRO para o navegador em -- toda página. E a cota do Supabase é do CLIENTE — 1 GB no plano gratuito, -- compartilhado com `whatsapp-media`, que não tem poda. -- -- Idempotente e auto-curativo: `on conflict do update` no bucket (o `update.sh` -- de um clone precisa CONVERGIR, não só criar), `add column if not exists`, e o -- BACKFILL vem antes da constraint — o `update.sh` roda SEM `ON_ERROR_STOP`, e -- uma constraint que estourasse deixaria a coluna sem validação em silêncio. insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types) values ('brand-logos', 'brand-logos', true, 524288, array['image/png', 'image/jpeg']) on conflict (id) do update set public = excluded.public, file_size_limit = excluded.file_size_limit, allowed_mime_types = excluded.allowed_mime_types; alter table public.platform_branding add column if not exists logo_path text; comment on column public.platform_branding.logo_path is 'Caminho do arquivo de logo em storage/brand-logos, sempre platform/.. Caminho e NÃO url: a url é função determinística do caminho + host do projeto (DIRC-C), e gravá-la amarraria a marca ao host de hoje. Vence logo_url, que continua como rede de rollback do .env. Escrito por app/api/v1/marca/logo/route.ts.'; update public.platform_branding set logo_path = null where logo_path is not null and logo_path !~ '^platform/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(png|jpg)$'; -- `drop if exists` + `add`, e não `add ... if not exists` (que o Postgres não tem -- para constraint): é o que torna a REGRA idempotente, e não só a criação. alter table public.platform_branding drop constraint if exists platform_branding_logo_path; alter table public.platform_branding add constraint platform_branding_logo_path check ( logo_path is null or logo_path ~ '^platform/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(png|jpg)$' ); -- ⚠️ CHECK de REGEX, não de conjunto: fica FORA da lista `PARES` de -- `tests/invariants/vocabulario-banco-x-typescript.test.ts`, cujo extrator só -- reconhece `= ANY (ARRAY[...])` e estoura sobre regex. Mesma razão de -- `platform_branding_accent_hex`. notify pgrst, 'reload schema'; -- ---- o teto de IA que vincula (migration 0159) ---- -- -- ⚠️ ESTE BLOCO EXISTE EM DOIS ARQUIVOS, PALAVRA POR PALAVRA: -- `supabase/migrations/20260814210000_0159_o_teto_que_vincula.sql` (o que o -- Supabase CLI aplica) e o FIM de `supabase/baseline.sql` (o que o kit self-host -- aplica, no `install.sh` e no `update.sh`). -- `tests/unit/migracao-nao-arma-ninguem.test.ts` compara os dois textos: divergir -- significa que o self-hoster recebe um SQL diferente do que a migration afirma, -- e é justamente o par que ninguém confere lendo só um dos dois. -- (1) DDL. Idempotente; re-aplicar é no-op. A linha que já existe recebe 'off' -- pelo próprio ALTER — não há UPDATE nenhum aqui, e é essa ausência que -- torna impossível esta migration armar alguém. alter table public.ai_budgets add column if not exists enforcement_mode text not null default 'off'; alter table public.ai_budgets add column if not exists enforcement_effective_at timestamptz; comment on column public.ai_budgets.enforcement_mode is 'A INTENÇÃO, declarada por um admin — nunca inferida do valor do teto. off = só acompanhar (a IA nunca para por gasto); avisar = abre budget_warning ao passar do limiar e SEGUE; bloquear = recusa a chamada quando o gasto atinge o teto. Nasce off por DEFAULT do ALTER, e é por isso que ligar o teto no gate não estrangula quem herdou o DEFAULT 5000 de monthly_limit_cents. Escrito só por PATCH /api/v1/ai/budget (admin, auditado); lido por lib/agent-engine/edge/llm/credentials.ts.'; comment on column public.ai_budgets.enforcement_effective_at is 'Carência: a partir de quando bloquear passa a valer de fato (now()+72h ao armar pela tela). Nasce NULL, e null <= now() é null — nunca verdadeiro —, então modo bloquear sem esta data ainda não bloqueia. Existe para que armar a proteção não seja um interruptor que corta o WhatsApp do negócio no mesmo instante, sem ninguém ver o aviso antes.'; -- (2) DADOS — RESGATE B->A. -- -- >>> RESGATE B->A: INICIO <<< -- -- O ÚNICO bloco desta migration que escreve 'bloquear', e o único que escreve -- `monthly_limit_cents`. Ele preserva o comportamento de HOJE para a única -- população que hoje PODE ser bloqueada: quem tem -- `organizations.settings.llm.monthly_budget_cents` com um número vigente. -- -- Sem carência (`now()`, não `now()+72h`): essa organização JÁ está capada nesse -- número, e dar 72h de folga AFROUXARIA o que ela apertou de propósito. -- -- Garante a linha ANTES do update, porque nenhum gatilho de `organizations` -- semeia `ai_budgets` — os produtores são o gatilho de `llm_calls`, os dois -- backfills do baseline e o PATCH. Sem o insert, uma organização com teto vigente -- e sem linha perderia o bloqueio no instante em que a chave jsonb saísse em (3). insert into public.ai_budgets (organization_id) select o.id from public.organizations o where jsonb_typeof(o.settings->'llm'->'monthly_budget_cents') = 'number' and (o.settings->'llm'->>'monthly_budget_cents')::numeric >= 100 and (o.settings->'llm'->>'monthly_budget_cents')::numeric <= 2147483647 on conflict (organization_id) do nothing; update public.ai_budgets b set monthly_limit_cents = (o.settings->'llm'->>'monthly_budget_cents')::numeric::integer, enforcement_mode = 'bloquear', enforcement_effective_at = now(), updated_at = now() from public.organizations o where o.id = b.organization_id and jsonb_typeof(o.settings->'llm'->'monthly_budget_cents') = 'number' and (o.settings->'llm'->>'monthly_budget_cents')::numeric >= 100 and (o.settings->'llm'->>'monthly_budget_cents')::numeric <= 2147483647; -- -- As três condições, e cada uma existe para não derrubar o `update.sh` de um -- clone ou para não apertar quem ninguém apertou: -- -- * `jsonb_typeof = 'number'` e NÃO `is not null`: o jsonb `'null'` e um valor -- com forma errada (string) caem fora. `('"700"'::jsonb->>...)::numeric` -- funcionaria, mas `'abc'` levantaria 22P02 dentro do `update.sh` de um clone, -- e a doutrina proíbe migration que quebra. Espelha exatamente o `.catch(null)` -- do Zod em `credentials.ts`: valor com forma errada JÁ é `null` (ilimitado) -- hoje, então não resgatar é PRESERVAR. -- * `>= 100` deixa fora o `0` (artefato de `scripts/smoke-llm.ts`, que grava '0' -- e NÃO restaura) e o implausível. Um `0` ali bloqueia 100% das chamadas com -- gasto zero — a inversão perfeita —, e trazê-lo DESARMADO conserta. É a única -- vez que esta migration muda comportamento, e é na direção que AFROUXA. -- * `<= 2147483647` porque `monthly_limit_cents` é `integer`. Medido em pg17: -- `('{"a":1e20}'::jsonb->>'a')::numeric::integer` levanta `22003 integer out of -- range`, e `jsonb_typeof` daquilo é 'number'. É jsonb LIVRE, editável por -- qualquer acesso privilegiado ao banco; sem este corte, uma linha assim -- abortaria o statement dentro de um `update.sh` sem `ON_ERROR_STOP` — erro -- engolido, resgate não feito, exit 0. Fora do intervalo não é orçamento, é -- erro de unidade, e erro de unidade não pode calar a IA nem quebrar o kit. -- -- >>> RESGATE B->A: FIM <<< -- (3) A duplicata some, para não haver duas verdades. Uma instrução, sem -- read-modify-write de aplicação — o padrão que a 0157 curou depois de medir -- perda real de chave irmã em `organizations.settings` (`visibility_mode` -- voltando de 'own' para 'all' em silêncio, e ele é lido DIRETO pela RLS). update public.organizations set settings = jsonb_set(settings, '{llm}', (settings->'llm') - 'monthly_budget_cents') where jsonb_typeof(settings->'llm') = 'object' and settings->'llm' ? 'monthly_budget_cents'; -- Idempotência: a segunda passada casa 0 linhas (a chave já saiu), o que também -- torna (2) idempotente sem precisar de guarda de catálogo. -- (4) SANEAMENTO. `is_throttled` só teve escritor no cron morto -- (`workers/ai-budget-checker.cron.ts`, sem rota e sem linha no -- `docker/scheduler/entrypoint.sh`), então qualquer `true` é estado preso. -- `is_disabled` NÃO é tocado: significaria "um admin desligou", e limpá-lo -- religaria IA que alguém desligou de propósito. update public.ai_budgets set is_throttled = false where is_throttled; -- (5) CONSTRAINT — depois dos dados, sempre (doutrina de migrations, item 8). O -- `update.sh` roda SEM `ON_ERROR_STOP` e engoliria um 23514, deixando a -- coluna sem validação em silêncio. `drop if exists` + `add`, e não -- `add ... if not exists` (que o Postgres não tem para constraint): é o que -- torna a REGRA idempotente, e não só a criação. alter table public.ai_budgets drop constraint if exists ai_budgets_enforcement_mode_check; alter table public.ai_budgets add constraint ai_budgets_enforcement_mode_check check (enforcement_mode in ('off', 'avisar', 'bloquear')); alter table public.ai_budgets drop constraint if exists ai_budgets_bloquear_precisa_de_teto; alter table public.ai_budgets add constraint ai_budgets_bloquear_precisa_de_teto check (enforcement_mode <> 'bloquear' or monthly_limit_cents >= 100); -- Os dados já satisfazem: 'bloquear' só foi escrito em (2), onde o jsonb era -- >= 100. O CHECK é o backstop de "armado sem valor útil" tentando renascer pela -- porta da frente — a régua da vez é o 422 da rota, não ele. -- -- ⚠️ SÓ `ai_budgets_bloquear_precisa_de_teto` é CHECK cross-coluna / de domínio, -- e por isso fica FORA da lista `PARES` de -- `tests/invariants/vocabulario-banco-x-typescript.test.ts` — mesma classificação -- que os CHECKs de regex da 0155/0157/0158. -- -- `ai_budgets_enforcement_mode_check` É de vocabulário: um conjunto fechado com -- par em TypeScript (`ModoDeOrcamento`, em -- `lib/agent-engine/edge/llm/orcamento.ts`), lido no caminho quente. Ele ESTÁ em -- `PARES`. Classificá-lo como domínio — o que este comentário e o MANIFEST -- fizeram — deixava a coluna fora do único gate que pega a classe: um valor novo -- entra num lado só, passa em typecheck/lint/unit, e aparece como 23514 em -- produção. -- (6) INFORMAÇÃO, nunca alarme. Item `info` para as organizações cujo -- `is_disabled` foi posto à mão (HIPÓTESE: conjunto vazio — nenhum escritor -- vivo jamais rodou): a flag para de agir quando o guard legado de -- `workers/ai-response-worker.ts` é repontado para a regra canônica. Mudança -- real, declarada, não escondida — e `info` porque nada quebrou. insert into public.agent_inbox_items (organization_id, kind, severity, title, body, ref_kind, ref_id) select b.organization_id, 'budget_warning', 'info', 'A pausa antiga de IA por gasto foi desligada', 'Esta organização estava marcada como desabilitada por gasto num mecanismo ' 'que nunca teve como ser reativado. Para voltar a parar a IA no limite, use ' 'Uso de IA › Orçamento e escolha "Parar a IA ao chegar no limite".', 'ai_budget', b.organization_id from public.ai_budgets b where b.is_disabled and not exists ( select 1 from public.agent_inbox_items i where i.organization_id = b.organization_id and i.kind = 'budget_warning' and i.status = 'open' ); notify pgrst, 'reload schema'; -- ---- ai_budgets só se escreve pela rota (migration 0160) ---- -- -- A 0159 pôs em `ai_budgets` os dois campos que decidem se (e quando) a IA para -- de responder. Toda a regra que os protege — escada `off → avisar → bloquear`, -- carência de 72h, piso de US$ 1,00 e linha em `api_audit_log` — mora na rota -- `PATCH /api/v1/ai/budget`, que usa service role. Mas o corpo deste dump traz -- `GRANT ALL ON TABLE public.ai_budgets TO anon` e `TO authenticated`, e a 0159 -- termina com `notify pgrst, 'reload schema'`: as colunas novas passaram a ser -- SERVIDAS pelo PostgREST para a chave anon, que vai ao browser. Um PATCH direto -- na REST do Supabase, com o JWT de um admin do tenant, armava a parada sem -- escada, sem carência, sem piso e sem auditoria — o comentário da coluna dizia -- "escrito só por PATCH /api/v1/ai/budget" e era verdade sobre o CÓDIGO, falso -- sobre o SCHEMA. -- -- Medido antes de revogar: TODO escritor de `ai_budgets` no repositório usa -- service role (a rota, `lib/ai/budget/check.ts`, os painéis de admin, os -- workers e `scripts/qa-wave-11.ts`). Nenhum caminho de produto escreve esta -- tabela com o JWT do usuário. -- -- SELECT fica: ler o próprio orçamento pelo PostgREST continua escopado pela -- policy de SELECT da 0150. `revoke` é idempotente por natureza — este bloco -- pode ser re-aplicado à vontade pelo `update.sh`. revoke insert, update, delete on table public.ai_budgets from authenticated, anon; -- ---- o arquivo do webhook pode perder o corpo (migration 0163) ---- -- -- `webhook_events_log` guarda o payload cru de todo webhook e NUNCA era podado. -- Medido numa instalação real em 20/08/2026: o banco inteiro em 545 MB, dos -- quais 468 MB (86%) eram esta tabela — contra 3,2 MB de `messages`. Nenhuma -- linha com mais de 30 dias: as 56.291 eram de 20 dias. Cresce ~23 MB/dia, e o -- teto do plano gratuito do Supabase é 500 MB, que é onde a maioria dos clones -- vive. -- -- ESVAZIAR o corpo, e não apagar a linha: as três colunas pesadas são ~97% do -- peso, e a linha sem elas custa ~200 B. Assim o índice forense inteiro -- (provider, tipo, id externo, horário, assinatura, desfecho) sobrevive por -- ~11 MB — e é ele que responde as perguntas de depois do incidente. -- -- `raw_body` precisa aceitar NULL para que "descartado" não se confunda com -- "corpo vazio", que é caso real (webhook de ping). Medido antes de afrouxar: -- nenhum leitor consulta essa coluna no repositório inteiro. -- -- `archived_at` já existia na tabela e não tinha NENHUM dono (0 linhas com -- valor em 56.350) — promessa de esqueleto, anti-pattern nº 3. Ganha dono aqui -- em vez de nascer uma coluna nova com o mesmo significado. alter table public.webhook_events_log alter column raw_body drop not null; comment on column public.webhook_events_log.raw_body is 'Corpo cru como o provedor mandou. NULL = existiu e foi descartado pela retenção; `archived_at` diz quando.'; comment on column public.webhook_events_log.archived_at is 'Quando as colunas pesadas (raw_body, payload_parsed, headers) foram descartadas pela retenção. NULL = a linha ainda tem o corpo.'; -- PARCIAL: a varredura procura "velha e ainda com corpo", e o índice encolhe -- sozinho conforme a poda avança — o oposto de um índice sobre a tabela toda, -- na única tabela que este bloco existe para impedir que cresça. create index if not exists webhook_events_log_a_esvaziar_idx on public.webhook_events_log (received_at) where archived_at is null; notify pgrst, 'reload schema'; -- ---- índice do cap global do claim da fila (migration 0166) ---- -- -- O QUÊ: um índice parcial em `job_queue (status) where status = 'running'`. -- -- POR QUÊ: `claimJobs` (lib/agent-engine/queue/queue.ts) abre TODA rodada com -- `select count(*) from job_queue where status = 'running'` — o cap global de -- concorrência — e nenhum dos quatro índices da tabela serve esse predicado. O -- parcial das lanes (`uniq_job_queue_one_running_per_contact`) chega perto e não -- vale: o predicado dele é mais ESTREITO (exclui `contact_id is null`, que é -- todo `watchdog`/`flywheel`), então o planejador não pode responder por ele. -- Medido em pg17 com este baseline, 50.000 linhas `done` + 4 `running`: -- Seq Scan / 715 buffers → Index Only Scan / 3 buffers. E o custo NÃO depende de -- linha viva: com as 50.004 apagadas na mesma transação o Seq Scan ainda lê os -- mesmos 715 buffers, porque ele visita página e não tupla. Fila é escrita o -- tempo todo e nada no produto a poda. -- -- O bloco `do $$` existe porque `create index if not exists` casa por NOME e não -- por definição — medido em pg17, um homônimo com outra definição vira `NOTICE: -- ... already exists, skipping`, que nem chega ao filtro `ERROR|FATAL` do -- `update.sh`. Homônimo NOSSO em `job_queue` é derrubado e recriado; homônimo em -- outro objeto NÃO é apagado (não é nosso) — a atualização grita com a razão, o -- que é o comportamento certo num script que roda sem `ON_ERROR_STOP`. -- -- O `comment on index` é o DELATOR: índice ausente levanta `relation ... does -- not exist`, texto que NÃO casa com nenhum termo da lista benigna do -- `update.sh` e portanto aparece ao operador — enquanto `already exists` seria -- engolido. Aditivo e idempotente: sem constraint, sem backfill, sem dado tocado. do $$ declare v_relkind "char"; v_def text; v_tabela text; begin select c.relkind, case when c.relkind in ('i', 'I') then pg_get_indexdef(c.oid) end, t.relname into v_relkind, v_def, v_tabela from pg_class c join pg_namespace n on n.oid = c.relnamespace left join pg_index i on i.indexrelid = c.oid left join pg_class t on t.oid = i.indrelid where n.nspname = 'public' and c.relname = 'idx_job_queue_running'; if v_relkind is null then return; end if; if v_relkind not in ('i', 'I') or v_tabela is distinct from 'job_queue' then raise exception 'o nome idx_job_queue_running já está tomado em public (relkind=%, tabela=%). ' 'Nome de índice é único por SCHEMA, então o create index if not exists desta ' 'atualização vira no-op silencioso e o claim da fila continua varrendo a tabela ' 'inteira a cada rodada. Não apago o objeto porque ele não é nosso: renomeie-o e ' 'rode a atualização de novo.', v_relkind, coalesce(v_tabela, '(nenhuma)'); end if; if v_def !~ 'USING btree \(status\)' or v_def !~ 'WHERE \(status = ''running''' then execute 'drop index public.idx_job_queue_running'; end if; end $$; create index if not exists idx_job_queue_running on job_queue (status) where status = 'running'; comment on index idx_job_queue_running is 'Cap global do claim: select count(*) from job_queue where status = ''running'' ' '(lib/agent-engine/queue/queue.ts). Sem ele o claim faz Seq Scan a cada rodada — ' '715 buffers com 50 mil linhas, e o mesmo custo com zero linha viva, porque Seq ' 'Scan visita página e não tupla. Este COMMENT é o delator do bloco: índice ausente ' 'vira "relation does not exist", que NÃO casa com o filtro benigno do update.sh e ' 'chega ao operador; "already exists" seria engolido.'; -- ---- identificador de canal único entre os ATIVOS (migration 0165) ---- -- -- O QUÊ: dois índices únicos PARCIAIS — um em `meta_phone_number_id`, outro em -- `zernio_account_id` —, ambos com `where archived_at is null`, precedidos da -- deduplicação dos dados que os violariam. -- -- POR QUÊ: `waha_session_name` e `webhook_path_token` são UNIQUE desde o -- snapshot; os dois identificadores que chegaram depois (0087 e 0131) nasceram -- sem trava, e é por eles que o código resolve credencial de envio e a -- ORGANIZAÇÃO dona de uma mensagem que acabou de entrar. Com duas linhas -- casando, o PostgREST devolve `data: null` com `PGRST116` (não "a primeira -- linha"), e o `error` era descartado nos três sítios: os dois resolvedores -- caíam no fallback do `.env` — a mensagem saía pela conta de OUTRA instalação -- — e a ingestão do canal oficial descartava a mensagem recebida para as DUAS -- organizações, respondendo 200 (issue #236). A colisão é atingível por -- CONFIGURAÇÃO LEGÍTIMA (agência, migração de conta entre organizações), não só -- por abuso. -- -- PARCIAL, e não total, pelo precedente da 0107: canal arquivado é canal -- excluído pelo usuário e a linha só sobrevive como âncora das FKs RESTRICT. -- Trava total impediria reconectar o mesmo número depois de excluí-lo. O recorte -- é o MESMO que as consultas de `lib/channels/` passam a usar. -- -- AUTO-CURATIVO: o `update.sh` de um clone roda SEM `ON_ERROR_STOP` e engoliria -- o 23505 da criação do índice, deixando o clone sem trava e sem aviso. Por isso -- a deduplicação vem ANTES. Ela NÃO apaga nem arquiva a linha perdedora — apagar -- sessão de canal de um cliente é destrutivo, arquivar faria o canal sumir da -- tela sem ninguém pedir: ela RENOMEIA o identificador da perdedora para -- `-conflito-`. A linha continua visível, e como o -- identificador novo não existe no provider, a varredura de saúde -- (`app/api/v1/cron/channel-health`) grava `FAILED`/`STOPPED` na próxima passada -- e ABRE aviso na Central (os três estados estão em `STATUS_QUE_AVISAM`), que é -- o operador sendo avisado em vez de descobrir pelo cliente que não recebeu. -- Fica com o identificador a sessão ativa MAIS RECENTE: criá-la exigiu provar -- posse da conta na tela de conexão, então é a intenção mais recente. -- -- IDEMPOTENTE: o sufixo carrega o `id` da sessão (único), então depois da -- primeira passada não sobra duplicata e a segunda casa zero linhas — não há -- como sufixar duas vezes. Os nomes dos índices são novos neste arquivo, então -- o `if not exists` (que casa por NOME) não vira no-op em cima de um homônimo. with ativos as ( select id, row_number() over ( partition by meta_phone_number_id order by created_at desc nulls last, id desc ) as posicao from public.channel_sessions where archived_at is null and meta_phone_number_id is not null ) update public.channel_sessions s set meta_phone_number_id = s.meta_phone_number_id || '-conflito-' || s.id::text from ativos a where a.id = s.id and a.posicao > 1; create unique index if not exists channel_sessions_meta_phone_number_id_ativo_unique on public.channel_sessions (meta_phone_number_id) where archived_at is null and meta_phone_number_id is not null; with ativos as ( select id, row_number() over ( partition by zernio_account_id order by created_at desc nulls last, id desc ) as posicao from public.channel_sessions where archived_at is null and zernio_account_id is not null ) update public.channel_sessions s set zernio_account_id = s.zernio_account_id || '-conflito-' || s.id::text from ativos a where a.id = s.id and a.posicao > 1; create unique index if not exists channel_sessions_zernio_account_id_ativo_unique on public.channel_sessions (zernio_account_id) where archived_at is null and zernio_account_id is not null; -- ---- superfície do pointer de follow-up (migration 0196) ---- -- IA vs automação CRM: um motor, duas listas. Default 'followup' deixa toda -- linha já existente na superfície de IA. CHECK de conjunto (PARES). alter table public.followup_flow_pointers add column if not exists surface text not null default 'followup'; alter table public.followup_flow_pointers drop constraint if exists followup_flow_pointers_surface_check; alter table public.followup_flow_pointers add constraint followup_flow_pointers_surface_check check (surface in ('followup', 'crm_automation', 'atendimento')); -- 'atendimento' entrou na migration 0394 (roteiro de perguntas no turno, #1130). -- Bloco ÚNICO desta constraint: a 0394 não a reconstrói no apêndice. comment on column public.followup_flow_pointers.surface is 'Onde o fluxo aparece: followup = /app/ai/followups; crm_automation = CRM Automação; ' 'atendimento = roteiro de perguntas conduzido no turno do agente (módulo opcional, 0394). ' 'Vocabulário cobrado por tests/invariants/vocabulario-banco-x-typescript.test.ts.'; -- ---- inscrição Web Push (migrations 0197 e 0199) ---- -- Bandeja do sistema com a aba fechada. A policy abaixo já é a da 0199 (o -- forward-fix de RBAC): quem atualiza recebe as duas de uma vez, e quem -- instala do zero nunca chega a existir sem o `fn_role_at_least`. create table if not exists public.push_subscriptions ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, user_id uuid not null references auth.users(id) on delete cascade, endpoint text not null unique, p256dh text not null, auth text not null, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create index if not exists push_subscriptions_org_idx on public.push_subscriptions (organization_id); alter table public.push_subscriptions enable row level security; drop policy if exists push_subscriptions_own on public.push_subscriptions; create policy push_subscriptions_own on public.push_subscriptions for all using ( organization_id in (select public.fn_user_org_ids()) and user_id = auth.uid() and public.fn_role_at_least(organization_id, 'viewer') ) with check ( organization_id in (select public.fn_user_org_ids()) and user_id = auth.uid() and public.fn_role_at_least(organization_id, 'viewer') ); revoke all on public.push_subscriptions from anon, public; grant select, insert, update, delete on public.push_subscriptions to authenticated; comment on table public.push_subscriptions is 'Inscrição Web Push por navegador. Envio é service role; a sessão só vê a própria linha.'; -- ---- a mensagem que responde outra (migration 0168) ---- -- O canal intermediado aceita citação (`replyTo` no envio, com o `wamid` da -- citada) e o WhatsApp mostra a resposta pendurada na original. Sem guardar -- QUEM foi citado, o CRM manda a citação para o cliente e não a mostra de volta -- na própria tela: o atendente vê frases soltas onde o cliente vê um fio. -- -- FK e não o wamid solto: a pergunta da tela é "qual mensagem NOSSA foi -- citada?", e a resposta é uma linha desta tabela — inclusive quando ela ainda -- não tem `external_id` (a nossa, enquanto está `queued`). O id que o provider -- recebe sai da linha apontada, no envio. -- -- `set null` no delete: apagar a citada não pode levar junto a resposta, que é -- conteúdo próprio. Perder o fio é aceitável; perder a resposta é apagar -- histórico por causa de um ponteiro. alter table public.messages add column if not exists reply_to_message_id uuid references public.messages(id) on delete set null; create index if not exists messages_reply_to_idx on public.messages (reply_to_message_id) where reply_to_message_id is not null; notify pgrst, 'reload schema'; -- ---- histórico DURÁVEL de leads captados: tabela (migration 0174) ---- -- -- A SEGUNDA metade da 0174. A função e o trigger de LGPD estão ANTES do bloco -- da VARREDURA anon, e a razão está escrita lá. -- -- Quem publica uma landing page precisa responder depois: "chegou alguém?", -- "com que dados?" e "de onde?". A única coisa que existia era o ARQUIVO -- FORENSE (`webhook_events_log`), que é DESCARTÁVEL por desenho: o cron -- `webhook-log-retention` zera `raw_body`/`payload_parsed`/`headers` em D+7 e -- apaga a linha em D+90 (migration 0163). Foi a decisão certa — ele era 468 MB -- de um banco de 545 MB numa instalação real — mas transforma qualquer -- histórico construído sobre ele numa tela que MENTE a partir do sétimo dia. -- -- O que esta tabela guarda e o arquivo não guardava: o IP em coluna tipada (lá -- ele só existia solto dentro de `headers`, que é podado); o DESFECHO (o -- arquivo registra "chegou um POST" e não sabe se virou lead, se caiu na -- deduplicação, ou se foi RECUSADO — que é justamente o caso em que a pessoa -- não vê nada hoje); e o nome da fonte NO MOMENTO da captação. -- -- RLS exige `manager`: `fields` carrega o formulário como a pessoa preencheu, e -- a policy de `webhook_events_log` é org-flat sem gate de papel — qualquer -- `viewer` lê aquela PII pelo PostgREST, mesmo com a rota HTTP exigindo -- manager. Não repetir o buraco. Sem policy de escrita: só o service role -- escreve (a rota pública de captação), e ele bypassa RLS. create table if not exists public.webhook_lead_captures ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, webhook_source_id uuid references public.webhook_sources(id) on delete set null, source_name text not null, lead_id uuid references public.crm_leads(id) on delete set null, contact_id uuid references public.contacts(id) on delete set null, outcome text not null check (outcome in ('criado', 'duplicado', 'recusado')), reject_reason text, captured_name text, captured_phone text, captured_email text, fields jsonb not null default '{}'::jsonb, utm jsonb not null default '{}'::jsonb, remote_ip inet, user_agent text, origin text, request_id uuid, received_at timestamptz not null default now() ); create index if not exists webhook_lead_captures_org_recebido_idx on public.webhook_lead_captures (organization_id, received_at desc, id desc); create index if not exists webhook_lead_captures_fonte_idx on public.webhook_lead_captures (webhook_source_id, received_at desc) where webhook_source_id is not null; create index if not exists webhook_lead_captures_lead_idx on public.webhook_lead_captures (lead_id) where lead_id is not null; create index if not exists webhook_lead_captures_poda_idx on public.webhook_lead_captures (received_at); comment on table public.webhook_lead_captures is 'Histórico DURÁVEL de leads captados por formulário/webhook: o que chegou, quando, de onde (IP, página, UTM) e no que deu. ' 'Distinto de webhook_events_log, que é arquivo forense e é PODADO (corpo em D+7, linha em D+90).'; comment on column public.webhook_lead_captures.remote_ip is 'IP de origem do POST, lido de x-forwarded-for/x-real-ip. Informativo — forjável, nada no produto decide com base nele. NULL = não havia proxy à frente.'; comment on column public.webhook_lead_captures.outcome is 'criado = virou lead novo; duplicado = mesmo external_id já capturado antes (retry da ferramenta); recusado = não entrou (reject_reason diz por quê).'; comment on column public.webhook_lead_captures.source_name is 'Nome da fonte NO MOMENTO da captação. Cópia deliberada: a fonte pode ser renomeada ou excluída, e o histórico responde de onde o contato veio.'; alter table public.webhook_lead_captures enable row level security; drop policy if exists "webhook_lead_captures_manager_read" on public.webhook_lead_captures; create policy "webhook_lead_captures_manager_read" on public.webhook_lead_captures for select using ( public.fn_is_platform_admin() or ((organization_id in (select public.fn_user_org_ids())) and public.fn_role_at_least(organization_id, 'manager')) ); -- O trigger vive aqui (e não com a função, no bloco de cima) porque só faz -- sentido depois que a tabela existe. drop trigger if exists trg_redigir_captacoes_ao_anonimizar on public.contacts; create trigger trg_redigir_captacoes_ao_anonimizar after update of is_anonymized on public.contacts for each row when (new.is_anonymized is true and old.is_anonymized is distinct from true) execute function public.fn_redigir_captacoes_do_contato_anonimizado(); -- ---- a automação precisa poder dizer "ainda não" (migration 0175) ---- -- -- `automation_rule_runs.status` aceitava success/partial/failed. Faltava o -- quarto estado que o motor JÁ produz: quando uma ação de envio pede adiamento -- (fora da janela do número, cap diário), `runAutomationForEvent` devolve -- `retry` e sai SEM GRAVAR LINHA NENHUMA — e a aba Atividade não mostra nada -- enquanto isso. Para quem montou a regra, "não apareceu nada" e "não rodou" -- são a mesma tela. -- -- Valor novo em vez de reusar `partial`: `partial` é "algumas ações falharam" e -- a tela pinta de amarelo com esse texto; adiamento não é falha nenhuma. -- -- CHECK reconstruído em UM bloco só (lição do #159, a mesma de -- `agent_inbox_items_kind_check`): N blocos quebram o `update.sh` de um clone -- com vocabulário posterior. Aditiva — só alarga o conjunto, nada a corrigir -- antes. alter table public.automation_rule_runs drop constraint if exists automation_rule_runs_status_check; alter table public.automation_rule_runs add constraint automation_rule_runs_status_check check (status in ( 'success', 'partial', 'failed', 'adiado' )); comment on column public.automation_rule_runs.status is 'success = todas as ações funcionaram; partial = algumas falharam; failed = todas falharam; ' 'adiado = nada chegou ao cliente e ainda pode chegar — a regra espera a janela de envio do ' 'número, ou a mensagem ficou na fila do canal.'; notify pgrst, 'reload schema'; -- ---- agenda: o compromisso marcado — tabelas, cor do membro, RLS (migration 0177) ---- -- -- A SEGUNDA metade da 0177. A função de limpeza do vínculo está ANTES do bloco -- da VARREDURA anon, e a razão está escrita lá. -- -- O produto sabia agendar um RETORNO — o sistema volta a falar com o lead -- daqui a X, em `cron_jobs` (kind='at', job_kind='followup_turn') — e não sabia -- guardar o oposto: DUAS PESSOAS COMBINARAM ESTAR JUNTAS ÀS 14h DE QUINTA. -- Retorno é decisão interna, não ocupa agenda de ninguém e o cliente não sabe; -- compromisso tem hora, tem dono, ocupa a agenda de um atendente e foi -- combinado com o cliente. Ficam separados, e a aresta entre eles é o índice -- `calendar_appointments_org_vivos_idx`: quem tem consulta marcada não é lead -- parado e não pode ser cobrado com "ainda tem interesse?". -- -- NENHUMA tabela de jornada semanal: ela já existe em -- `attendant_availability.schedule`, lida pelo roteamento de conversa e com -- tela própria. A agenda lê aquela coluna com OUTRA régua (`windows` vazio ali -- é 24/7; aqui é "não publicou horário" ⇒ zero slots) e não escreve outra. -- A única tabela nova de disponibilidade é a de EXCEÇÃO por data, que é -- informação que o jsonb não sabe dar. -- -- Sem constraint de sobreposição de horário: exigiria `btree_gist`, que não -- existe neste baseline nem no prelude do harness de teste — quebraria o -- install de todo clone. -- -- `calendar_connections` é a única com gate de papel, porque guarda token -- OAuth: lê o DONO da conexão ou `manager`+. Os tokens são `bytea` cifrado por -- `fn_encrypt_oauth`, cuja decifragem só `service_role` executa. -- -- Aditiva e idempotente: seis tabelas novas e uma coluna nova e nullable numa -- tabela existente. Nenhuma linha atual passa a violar nada. -- ──────────────────────────────────────────────────────────────────────────── -- 1 · o molde: que tipos de compromisso esta organização marca -- ──────────────────────────────────────────────────────────────────────────── create table if not exists public.calendar_event_types ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, name text not null, -- Identificador legível e estável. NÃO é para URL pública (auto-agendamento -- ficou fora do escopo): serve para (a) impedir dois "Consulta" iguais na -- mesma organização e (b) dar à IA um handle que ela não alucina, ao -- contrário de um uuid. Renomear o tipo não muda o slug. slug text not null, description text, category text not null default 'outro', duration_minutes int not null default 30, buffer_before_minutes int not null default 0, buffer_after_minutes int not null default 0, minimum_notice_minutes int not null default 120, -- null = a grade anda de duração em duração. Preenchido, permite oferecer -- 09:00/09:15/09:30 para um serviço de 30min. slot_interval_minutes int, booking_window_days int not null default 60, color text, location_kind text not null default 'in_person', location_details text, requires_confirmation boolean not null default false, is_active boolean not null default true, -- ─── o lembrete, e por que ele é COLUNA e não detalhe de implementação ─── -- Em canal oficial (meta_cloud, zernio) texto livre fora da janela de 24h é -- recusado — e o lembrete cai exatamente aí: a pessoa marca na terça, o -- lembrete sai na quinta, e ela não mandou mensagem desde então, que é o -- normal de quem já marcou. Medido em lib/channels/capabilities.ts: -- `freeformOutsideWindow` é true só para `waha`; meta_cloud e zernio exigem -- template, e o gate de envio (guardrails/before-send.ts) só abre a porta -- para `isTemplate === true`. -- -- ⚠️ O que torna isto grave não é a recusa, é a FORMA dela: a API responde -- 200 com wamid e a Meta recusa a ENTREGA depois, pelo webhook (131047, -- re-engagement). Quem lê o 200 como "enviado" acha que funcionou. Sem esta -- coluna, num canal oficial, o lembrete NÃO SAI e o sistema ACHA QUE SAIU — -- o cliente falta à consulta e não há erro nenhum para investigar. -- -- O mecanismo de mandar template já existe (`sendTemplateForSession`). O que -- não existia é o DADO que diz qual template este tipo de compromisso usa. reminder_enabled boolean not null default true, -- 1440 = 24h antes. É quanto tempo ANTES do compromisso o lembrete sai. reminder_minutes_before int not null default 1440, -- NULL = texto livre, que basta em WAHA. Preenchido, é o nome do template -- aprovado no provedor oficial. Cadastro e escolha de template são tela de -- outra wave; o que não podia era a coluna faltar. reminder_template_name text, -- `numeric`, NUNCA `int`: a lista é arrastável e o repo usa fractional -- indexing (CLAUDE.md § Modelagem, mesma razão de crm_leads.position_in_stage). position numeric not null default 1000, default_owner_user_id uuid references auth.users(id) on delete set null, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), constraint calendar_event_types_category_check check (category in ( 'consulta','procedimento','retorno','visita','vistoria', 'reuniao','call','orcamento','demonstracao','outro' )), constraint calendar_event_types_location_kind_check check (location_kind in ( 'in_person','phone','whatsapp','video_link','google_meet' )), -- Mesma forma de crm_stages_color_format, e a mesma tolerância a maiúscula. -- (platform_branding.accent_hex exige minúscula; é cor de MARCA, outra régua.) constraint calendar_event_types_color_format check (color is null or color ~ '^#[0-9a-fA-F]{6}$'), constraint calendar_event_types_duracao_sensata check (duration_minutes between 5 and 1440), constraint calendar_event_types_intervalo_sensato check (slot_interval_minutes is null or slot_interval_minutes between 5 and 1440), constraint calendar_event_types_lembrete_sensato check (reminder_minutes_before between 0 and 43200), constraint calendar_event_types_buffers_nao_negativos check (buffer_before_minutes >= 0 and buffer_after_minutes >= 0 and minimum_notice_minutes >= 0 and booking_window_days > 0) ); create unique index if not exists calendar_event_types_org_slug_key on public.calendar_event_types (organization_id, slug); create index if not exists calendar_event_types_org_ativos_idx on public.calendar_event_types (organization_id, position) where is_active; comment on table public.calendar_event_types is 'O MOLDE de um compromisso: quanto dura, com que folga, com que antecedência mínima se marca. Distinto de calendar_appointments, que é o compromisso marcado — mudar o molde não reescreve o que já foi combinado.'; comment on column public.calendar_event_types.slug is 'Handle estável e legível dentro da organização. Não é URL pública: serve para a IA referenciar o tipo sem inventar uuid, e para impedir dois tipos com o mesmo nome.'; comment on column public.calendar_event_types.minimum_notice_minutes is 'Antecedência mínima para marcar. 120 = ninguém marca para daqui a meia hora. É o que impede a agenda de aceitar um encaixe que o atendente não tem como cumprir.'; comment on column public.calendar_event_types.slot_interval_minutes is 'De quanto em quanto tempo a grade oferece horário. NULL = de duração em duração.'; comment on column public.calendar_event_types.reminder_template_name is 'Nome do template aprovado no provedor, para o lembrete. NULL = texto livre, que basta em WAHA. Em canal oficial (meta_cloud, zernio) texto livre fora da janela de 24h é aceito com 200 e tem a ENTREGA recusada depois pelo webhook — sem template, o lembrete não sai e o sistema acha que saiu.'; comment on column public.calendar_event_types.reminder_minutes_before is 'Quantos minutos ANTES do compromisso o lembrete sai. 1440 = 24h.'; comment on column public.calendar_event_types.category is 'consulta/procedimento/retorno = clínica; visita/vistoria = imobiliária; reuniao/call = serviços e agência; orcamento = obra e serviço; demonstracao = loja e software; outro = qualquer. Espelha os nichos de lib/onboarding/pacotes-de-funil.ts.'; -- ──────────────────────────────────────────────────────────────────────────── -- 2 · o compromisso marcado -- ──────────────────────────────────────────────────────────────────────────── create table if not exists public.calendar_appointments ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, -- `set null`: apagar o molde não pode apagar o histórico do que já aconteceu. event_type_id uuid references public.calendar_event_types(id) on delete set null, title text not null, description text, starts_at timestamptz not null, ends_at timestamptz not null, -- O fuso em que a pessoa MARCOU. Guardado porque "quinta às 14h" é o que foi -- combinado; o instante UTC sozinho não sabe dizer isso depois de uma virada -- de horário de verão. Sem CHECK: a validação de fuso é do Intl, e o repo já -- tem o lugar dela — `fusoValido` em lib/tempo/fusos.ts, aplicado no Zod. time_zone text not null default 'America/Sao_Paulo', status text not null default 'confirmed', -- O ATENDENTE dono. `set null` e não cascade: o compromisso aconteceu mesmo -- que a pessoa saia da empresa depois. owner_user_id uuid references auth.users(id) on delete set null, -- QUEM VAI SER ATENDIDO. `restrict` acompanha conversations.contact_id e -- messages.contact_id — as duas únicas FKs RESTRICT do schema, e existem -- pela mesma razão: apagar um contato não pode apagar o histórico dele. Na -- prática a LGPD deste produto anonimiza em vez de apagar (CLAUDE.md § LGPD), -- então o RESTRICT nunca é o caminho normal — é o cinto. contact_id uuid references public.contacts(id) on delete restrict, conversation_id uuid references public.conversations(id) on delete set null, location_kind text not null default 'in_person', location_details text, meeting_url text, notes text, cancellation_reason text, cancelled_at timestamptz, -- A cadeia de remarcações. `set null` porque a remarcação sobrevive ao -- sumiço do compromisso original. rescheduled_from_id uuid references public.calendar_appointments(id) on delete set null, -- QUEM MARCOU. O par `created_by_*` espelha `created_by_user_id`, que já -- existe em crm_leads e crm_lead_links. -- ⚠️ Os VALORES seguem crm_lead_activities.actor_kind ('user','ai','system', -- 'rule','contact') e não o par 'human'/'agent' que a outra tabela usa, -- porque é na timeline do lead que esta autoria vai ser RENDERIZADA: gravar -- 'human' aqui e 'user' lá faria a tela mostrar duas palavras para a mesma -- pessoa. 'sync' é o único acréscimo, e não é ator do produto: significa que -- a linha nasceu de um evento que já existia na agenda externa. created_by_kind text not null default 'user', created_by_user_id uuid references auth.users(id) on delete set null, created_by_agent_id uuid references public.ai_agents(id) on delete set null, source text not null default 'ui', -- Lembrete: idempotência do lado do dado. Quem DISPARA é a fila -- (`cron_jobs` kind='at' agenda; `job_queue` executa), e o envio passa pela -- MESMA cadeia de saída do produto — janela horária, espaçamento, opt-out. -- Nenhum caminho novo de saída: esta base já pagou por uma automação com -- janela paralela. reminder_sent_at timestamptz, -- Espelho do Google. 1:1 e por isso mora aqui (DIRC: duplicar). google_connection_id uuid, google_calendar_id text, google_event_id text, google_ical_uid text, google_sequence int not null default 0, google_synced_at timestamptz, google_sync_error text, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), constraint calendar_appointments_status_check check (status in ( 'pending','confirmed','cancelled','completed','no_show' )), constraint calendar_appointments_location_kind_check check (location_kind in ( 'in_person','phone','whatsapp','video_link','google_meet' )), constraint calendar_appointments_created_by_kind_check check (created_by_kind in ( 'user','ai','system','contact','sync' )), constraint calendar_appointments_source_check check (source in ( 'ui','mcp','google_sync','public_page' )), constraint calendar_appointments_periodo_valido check (ends_at > starts_at), -- Regra de negócio em constraint SEPARADA da de vocabulário, de propósito: -- duas constraints casando `col in (...)` na mesma coluna fazem o extrator -- do invariante de vocabulário se recusar a escolher. É a mesma convivência -- de crm_leads.status com crm_leads_closed_at_consistency. constraint calendar_appointments_cancelamento_coerente check ( (status <> 'cancelled' and cancelled_at is null) or (status = 'cancelled' and cancelled_at is not null) ) ); -- A grade: "o que há entre terça e domingo". create index if not exists calendar_appointments_org_periodo_idx on public.calendar_appointments (organization_id, starts_at); -- O filtro por pessoa, que é o requisito explícito da tela. create index if not exists calendar_appointments_org_dono_idx on public.calendar_appointments (organization_id, owner_user_id, starts_at) where owner_user_id is not null; -- A ARESTA COM O FOLLOW-UP (e com o Radar de Risco): "este lead tem consulta -- marcada?". Quem tem compromisso vivo no futuro NÃO é lead parado, e cobrar -- "ainda tem interesse?" de quem marcou para amanhã é o tipo de erro que faz -- desinstalar o produto. Parcial nos dois estados vivos porque cancelado e -- realizado não seguram ninguém. A consulta canônica, já que o vínculo com o -- lead é polimórfico: -- select 1 from crm_lead_links l join calendar_appointments a on a.id = l.target_id -- where l.lead_id = $1 and l.target_kind = 'appointment' -- and a.organization_id = $2 and a.status in ('pending','confirmed') -- and a.starts_at > now(); create index if not exists calendar_appointments_org_vivos_idx on public.calendar_appointments (organization_id, starts_at) where status in ('pending','confirmed'); create index if not exists calendar_appointments_contato_idx on public.calendar_appointments (contact_id, starts_at desc) where contact_id is not null; -- Idempotência do sync: o mesmo evento do Google não vira dois agendamentos. -- O parceiro disto no código é a captura de `23505` no INSERT (CLAUDE.md -- § Idempotência), não um SELECT-antes-de-inserir. create unique index if not exists calendar_appointments_google_evento_key on public.calendar_appointments (organization_id, google_connection_id, google_event_id) where google_event_id is not null; comment on table public.calendar_appointments is 'O compromisso COMBINADO: hora marcada, com alguém, ocupando a agenda de um atendente. Distinto do RETORNO agendado (cron_jobs kind=at, job_kind=followup_turn), que é decisão interna do sistema, não ocupa agenda de ninguém e o cliente não sabe.'; comment on column public.calendar_appointments.time_zone is 'O fuso em que foi marcado. "Quinta às 14h" é o que se combinou — o instante UTC sozinho não reconstrói isso depois de uma virada de horário de verão.'; comment on column public.calendar_appointments.created_by_kind is 'user = pessoa da equipe pela tela; ai = agente de IA; system = o próprio produto; contact = o cliente (auto-agendamento, quando existir); sync = a linha nasceu de evento que já estava na agenda externa. Valores alinhados a crm_lead_activities.actor_kind, que é onde esta autoria aparece na tela.'; comment on column public.calendar_appointments.reminder_sent_at is 'Carimbo de que o lembrete SAIU — idempotência do lado do dado, para remarcação ou reprocesso não avisarem duas vezes. Quem agenda o disparo é cron_jobs (kind=at); quem envia é a cadeia de saída do produto, com janela, espaçamento e opt-out.'; comment on column public.calendar_appointments.google_sequence is 'O `sequence` do evento no Google. Ele exige que uma atualização venha com sequence >= o que está lá; guardar o nosso evita sobrescrever uma edição feita do lado de lá.'; -- ──────────────────────────────────────────────────────────────────────────── -- 3 · a exceção por data — a ÚNICA tabela nova de disponibilidade -- ──────────────────────────────────────────────────────────────────────────── -- `attendant_availability.schedule` sabe dizer "atendo de segunda a sexta, das -- 9h às 18h". Não sabe dizer "no dia 12 eu não atendo" nem "neste sábado, das -- 9h ao meio-dia, atendo". Isso é informação NOVA — inflar o jsonb com ela é -- que seria o lock-in do anti-pattern nº 6. create table if not exists public.calendar_availability_exceptions ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, user_id uuid not null references auth.users(id) on delete cascade, exception_date date not null, -- true = este pedaço do dia NÃO tem atendimento (o caso comum: feriado, -- férias, congresso). false = tem atendimento AQUI mesmo que a jornada -- semanal diga que não (o sábado excepcional). is_unavailable boolean not null default true, -- Minutos desde 00:00, NO MESMO FUSO da jornada da pessoa -- (`attendant_availability.schedule.timezone`). Minutos inteiros e não -- `time`: elimina a classe inteira de bug de fuso que um `time` carrega. -- -- ⚠️ NOT NULL com default, e não nullable, e a razão é uma armadilha de -- Postgres: numa UNIQUE, NULL não colide com NULL. Com `start_minute` -- nullable, dois "dia 12 bloqueado o dia todo" para a mesma pessoa passariam -- os dois, em silêncio, e a tela mostraria a exceção duplicada. Dia inteiro -- é (0, 1440) — que é a mesma coisa e colide como deve. start_minute int not null default 0, end_minute int not null default 1440, reason text, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), constraint calendar_exceptions_faixa_valida check (start_minute >= 0 and end_minute <= 1440 and end_minute > start_minute) ); create unique index if not exists calendar_exceptions_pessoa_dia_faixa_key on public.calendar_availability_exceptions (organization_id, user_id, exception_date, start_minute); create index if not exists calendar_exceptions_org_dia_idx on public.calendar_availability_exceptions (organization_id, exception_date); comment on table public.calendar_availability_exceptions is 'O que a jornada semanal não sabe dizer: "neste dia não atendo" e "neste sábado atendo". A jornada continua morando em attendant_availability.schedule — esta tabela não a duplica, a excepciona.'; comment on column public.calendar_availability_exceptions.start_minute is 'Minutos desde 00:00 no fuso da JORNADA da pessoa (attendant_availability.schedule.timezone), não em UTC. Dia inteiro = 0..1440.'; comment on column public.calendar_availability_exceptions.is_unavailable is 'true = bloqueia esta faixa; false = ABRE esta faixa mesmo fora da jornada semanal.'; -- ──────────────────────────────────────────────────────────────────────────── -- 4 · a agenda conectada (BYO) — uma por PESSOA, não por organização -- ──────────────────────────────────────────────────────────────────────────── -- `tenant_integrations` foi desenhada para OAuth com refresh e serviria — não -- fosse a cardinalidade: ela tem UNIQUE (organization_id, provider), uma -- conexão por organização. A agenda do Google é de cada atendente. Mudar -- aquela unique reescreveria o contrato de uma tabela viva para servir outro -- caso. -- -- O que É reusado dela, porque é mecanismo e não modelo: a cifra -- (`fn_encrypt_oauth`/`fn_decrypt_oauth`, pgp_sym AES-256 com a chave em -- `private.fn_oauth_key()`, EXECUTE só para service_role), os nomes das -- colunas de token, e o vocabulário de `status` — os SETE valores de -- `tenant_integrations_status_check`, incluindo `rate_limited`, que é -- justamente o estado que uma API de calendário mais produz. create table if not exists public.calendar_connections ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, user_id uuid not null references auth.users(id) on delete cascade, provider text not null default 'google_calendar', account_email text not null, -- `bytea`, como as nove colunas cifradas do repo. Passe o valor CRU de -- fn_encrypt_oauth (com o `\x`); tirar o prefixo é regra de quem guarda -- cifrado dentro de jsonb, e aqui não é o caso. oauth_access_token_encrypted bytea, oauth_refresh_token_encrypted bytea, token_expires_at timestamptz, scopes text[] not null default array[]::text[], status text not null default 'connecting', last_sync_at timestamptz, last_sync_error text, -- Sync incremental da CONTA. O do calendário individual mora na tabela de -- baixo, porque o Google versiona por calendário. sync_token text, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), constraint calendar_connections_provider_check check (provider in ('google_calendar')), constraint calendar_connections_status_check check (status in ( 'connecting','healthy','token_expired','scope_missing','disconnected','rate_limited','error' )) ); create unique index if not exists calendar_connections_conta_key on public.calendar_connections (organization_id, user_id, provider, account_email); -- A varredura do worker de renovação: quem está para vencer. Parcial porque -- conexão desconectada não se renova. create index if not exists calendar_connections_renovacao_idx on public.calendar_connections (token_expires_at) where status in ('healthy','rate_limited') and token_expires_at is not null; -- `calendar_connections_org_pessoa_idx (organization_id, user_id)` nascia aqui e -- saiu na migration 0259: é prefixo de `calendar_connections_conta_key`, logo -- acima. Não se cria para derrubar no fim do arquivo (ver o bloco da 0259). comment on table public.calendar_connections is 'A conta de agenda externa que UMA PESSOA conectou. Uma por atendente, e por isso não cabe em tenant_integrations, que é uma por organização e por provedor.'; comment on column public.calendar_connections.status is 'connecting = o OAuth começou e ainda não voltou; healthy = renovando e sincronizando; token_expired = o refresh falhou com invalid_grant e SÓ a pessoa resolve, reconectando; scope_missing = conectou sem a permissão de calendário; rate_limited = o Google recusou por volume e vale tentar depois; disconnected = a pessoa desligou; error = falha que não se encaixa nas anteriores. Vocabulário idêntico ao de tenant_integrations.status — mesma pergunta, mesma palavra.'; comment on column public.calendar_connections.oauth_access_token_encrypted is 'Cifrado por public.fn_encrypt_oauth (pgp_sym AES-256). NUNCA em claro. A chave vive em private.fn_oauth_key() e só service_role executa a decifragem.'; comment on column public.calendar_connections.token_expires_at is 'Quando o access_token vence (~1h no Google). É o que o worker de renovação varre. Sem esse worker a integração morre em uma hora — e é por isso que o índice calendar_connections_renovacao_idx existe desde o primeiro dia, e não depois.'; -- ──────────────────────────────────────────────────────────────────────────── -- 5 · quais agendas daquela conta contam -- ──────────────────────────────────────────────────────────────────────────── create table if not exists public.calendar_connection_calendars ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, connection_id uuid not null references public.calendar_connections(id) on delete cascade, external_calendar_id text not null, name text not null, is_primary boolean not null default false, -- O que este produto pergunta a cada agenda de fora: "você ocupa o horário -- desta pessoa?" e "você recebe o que eu marcar?". São perguntas diferentes: -- a agenda de aniversários ocupa nada e recebe nada; a pessoal ocupa e não -- recebe; a de trabalho faz as duas. counts_for_conflicts boolean not null default true, is_destination boolean not null default false, sync_token text, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create unique index if not exists calendar_connection_calendars_key on public.calendar_connection_calendars (organization_id, connection_id, external_calendar_id); -- Só UM destino por conexão: se dois calendários recebessem, o mesmo -- compromisso apareceria duas vezes na agenda da pessoa. create unique index if not exists calendar_connection_calendars_um_destino_key on public.calendar_connection_calendars (connection_id) where is_destination; comment on table public.calendar_connection_calendars is 'As agendas dentro de uma conta conectada, e o que cada uma faz por nós: ocupar horário (counts_for_conflicts) e/ou receber o que marcamos (is_destination). São perguntas independentes.'; -- ──────────────────────────────────────────────────────────────────────────── -- 6 · o que veio de fora e ocupa a hora -- ──────────────────────────────────────────────────────────────────────────── create table if not exists public.calendar_external_events ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, connection_id uuid not null references public.calendar_connections(id) on delete cascade, external_calendar_id text not null, external_event_id text not null, title text, starts_at timestamptz not null, ends_at timestamptz not null, is_all_day boolean not null default false, status text not null default 'confirmed', -- O vocabulário é do próprio Google: `opaque` ocupa o horário, `transparent` -- não. Um evento marcado como livre lá não pode bloquear horário aqui. transparency text not null default 'opaque', external_updated_at timestamptz, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), constraint calendar_external_events_status_check check (status in ( 'confirmed','tentative','cancelled' )), constraint calendar_external_events_transparency_check check (transparency in ( 'opaque','transparent' )), constraint calendar_external_events_periodo_valido check (ends_at > starts_at) ); create unique index if not exists calendar_external_events_key on public.calendar_external_events (organization_id, connection_id, external_calendar_id, external_event_id); -- A pergunta do motor de slots: "o que ocupa esta janela?". Parcial, porque -- evento cancelado ou marcado como livre não ocupa nada e só engordaria o -- índice. create index if not exists calendar_external_events_ocupam_idx on public.calendar_external_events (organization_id, external_calendar_id, starts_at) where status <> 'cancelled' and transparency = 'opaque'; comment on table public.calendar_external_events is 'Espelho, somente-leitura, do que já existe na agenda conectada. Ocupa horário e aparece na grade, mas não é compromisso NOSSO: não tem lead, não tem estado de atendimento e nunca é reescrito por nós.'; comment on column public.calendar_external_events.transparency is 'opaque = ocupa o horário; transparent = a pessoa marcou como livre lá, e não bloqueia nada aqui. É o vocabulário do próprio Google.'; -- ──────────────────────────────────────────────────────────────────────────── -- 7 · a cor da pessoa, na tabela de membros -- ──────────────────────────────────────────────────────────────────────────── -- A cor é DA PESSOA NAQUELA ORGANIZAÇÃO, e por isso mora em user_organizations -- e não em auth.users: quem trabalha em duas organizações pode ser verde numa -- e azul na outra, e a cor de uma não vaza para a outra. -- ⚠️ A 0186, adiante, troca esta coluna por `calendar_trilha` e a DERRUBA. Sem a -- guarda, toda reaplicação do baseline recriaria a coluna e o CHECK aqui para a -- 0186 derrubar em seguida: quatro travas exclusivas numa tabela que toda policy -- de RLS consulta, e um número de coluna gasto que não volta (issue #1041). -- `calendar_trilha` é o sinal de que a 0186 já passou por este banco. do $$ begin if not exists ( select 1 from pg_attribute where attrelid = 'public.user_organizations'::regclass and attname = 'calendar_trilha' and not attisdropped ) then alter table public.user_organizations add column if not exists calendar_color text; alter table public.user_organizations drop constraint if exists user_organizations_calendar_color_format; alter table public.user_organizations add constraint user_organizations_calendar_color_format check (calendar_color is null or calendar_color ~ '^#[0-9a-fA-F]{6}$'); comment on column public.user_organizations.calendar_color is 'Cor desta pessoa na grade da Agenda, nesta organização. NULL = a tela deriva uma cor estável do user_id, para ninguém nascer sem cor. ⚠️ A policy de SELECT desta tabela é self-OU-manager+: um `agent` NÃO lê a linha dos colegas pelo PostgREST. A tela recebe as cores pela rota que já monta o roster com service role (GET /api/v1/team), não por leitura direta.'; end if; end $$; drop trigger if exists trg_limpar_vinculos_do_agendamento on public.calendar_appointments; create trigger trg_limpar_vinculos_do_agendamento after delete on public.calendar_appointments for each row execute function public.fn_limpar_vinculos_do_agendamento(); -- ──────────────────────────────────────────────────────────────────────────── -- 9 · updated_at -- ──────────────────────────────────────────────────────────────────────────── do $$ declare t text; begin foreach t in array array[ 'calendar_event_types','calendar_appointments','calendar_availability_exceptions', 'calendar_connections','calendar_connection_calendars','calendar_external_events' ] loop execute format('drop trigger if exists trg_%s_updated_at on public.%I', t, t); execute format( 'create trigger trg_%s_updated_at before update on public.%I for each row execute function public.fn_set_updated_at()', t, t); end loop; end $$; -- ──────────────────────────────────────────────────────────────────────────── -- 10 · tenancy E PAPEL -- ──────────────────────────────────────────────────────────────────────────── -- A primeira versão deste bloco dava `for all` só-tenancy às cinco tabelas de -- agenda, e o invariante `rbac-config-ia-canais` reprovou as cinco. Ele estava -- certo, e não é allowlist: `DIVIDA_RBAC_CONHECIDA` é uma CATRACA — a lista -- congelada das tabelas que JÁ nasceram só-tenancy antes da migration 0150. O -- teste se chama "a dívida de RBAC não cresce", e pôr tabela nova ali é -- exatamente o movimento que ele existe para impedir. -- -- A razão de fundo (migration 0150): `requireRole()` na rota Next NÃO é a única -- porta. O PostgREST é exposto ao browser por construção — URL e anon key vão no -- bundle — e um usuário logado fala com ele direto, com o próprio JWT. Uma -- policy `for all` só-tenancy significa que o papel mais fraco do tenant escreve -- tudo o que a organização tem. -- -- Por tabela, e cada uma tem uma razão diferente: -- -- event_types lê membro · escreve manager+ é CONFIGURAÇÃO do -- negócio: quanto dura uma consulta, que folga tem, quando se pode marcar. -- O atendente usa; quem define é quem responde pelo negócio. -- -- appointments lê membro · escreve agent+ é a OPERAÇÃO do dia. -- Marcar, remarcar e cancelar é o trabalho do atendente. O `viewer` vê a -- agenda e não mexe nela. -- -- availability_exceptions lê membro · escreve o DONO ou manager+ -- "No dia 12 eu não atendo" é da pessoa. Ela mesma escreve a sua, sem -- depender de ninguém; manager+ escreve a dos outros porque escala é -- trabalho de quem coordena. -- -- connection_calendars acompanha a conexão · escreve ninguém -- Ele é filho de `calendar_connections` e herda o escopo dela, como -- `crm_lead_links` herda o do lead. Quem escreve é o callback do OAuth. -- -- external_events lê membro · escreve NINGUÉM além de service_role -- Vem do sync e é espelho. Escrita humana aqui só teria um caso de uso: -- corromper a fonte de conflito, fazendo a agenda marcar em cima de -- compromisso real. Ausência de policy de escrita é a decisão. -- -- Nenhuma leva `for all` só-tenancy, e por isso nenhuma precisa entrar na -- catraca. -- ─── os tipos de agendamento: configuração do negócio ───────────────────── alter table public.calendar_event_types enable row level security; drop policy if exists tenant_isolation_calendar_event_types_all on public.calendar_event_types; drop policy if exists calendar_event_types_select on public.calendar_event_types; create policy calendar_event_types_select on public.calendar_event_types for select using ( (organization_id in (select public.fn_user_org_ids())) or public.fn_is_platform_admin() ); drop policy if exists calendar_event_types_write on public.calendar_event_types; create policy calendar_event_types_write on public.calendar_event_types using ( public.fn_is_platform_admin() or ((organization_id in (select public.fn_user_org_ids())) and public.fn_role_at_least(organization_id, 'manager')) ) with check ( public.fn_is_platform_admin() or ((organization_id in (select public.fn_user_org_ids())) and public.fn_role_at_least(organization_id, 'manager')) ); revoke all on public.calendar_event_types from anon; -- ─── os compromissos: a operação do dia ─────────────────────────────────── alter table public.calendar_appointments enable row level security; drop policy if exists tenant_isolation_calendar_appointments_all on public.calendar_appointments; drop policy if exists calendar_appointments_select on public.calendar_appointments; create policy calendar_appointments_select on public.calendar_appointments for select using ( (organization_id in (select public.fn_user_org_ids())) or public.fn_is_platform_admin() ); drop policy if exists calendar_appointments_write on public.calendar_appointments; create policy calendar_appointments_write on public.calendar_appointments using ( public.fn_is_platform_admin() or ((organization_id in (select public.fn_user_org_ids())) and public.fn_role_at_least(organization_id, 'agent')) ) with check ( public.fn_is_platform_admin() or ((organization_id in (select public.fn_user_org_ids())) and public.fn_role_at_least(organization_id, 'agent')) ); revoke all on public.calendar_appointments from anon; -- ─── as exceções: a agenda é de quem a vive ─────────────────────────────── alter table public.calendar_availability_exceptions enable row level security; drop policy if exists tenant_isolation_calendar_availability_exceptions_all on public.calendar_availability_exceptions; drop policy if exists calendar_availability_exceptions_select on public.calendar_availability_exceptions; create policy calendar_availability_exceptions_select on public.calendar_availability_exceptions for select using ( (organization_id in (select public.fn_user_org_ids())) or public.fn_is_platform_admin() ); drop policy if exists calendar_availability_exceptions_write on public.calendar_availability_exceptions; create policy calendar_availability_exceptions_write on public.calendar_availability_exceptions using ( public.fn_is_platform_admin() or ((organization_id in (select public.fn_user_org_ids())) and (user_id = auth.uid() or public.fn_role_at_least(organization_id, 'manager'))) ) with check ( public.fn_is_platform_admin() or ((organization_id in (select public.fn_user_org_ids())) and (user_id = auth.uid() or public.fn_role_at_least(organization_id, 'manager'))) ); revoke all on public.calendar_availability_exceptions from anon; -- ─── os calendários da conexão: herdam o escopo do pai ──────────────────── alter table public.calendar_connection_calendars enable row level security; drop policy if exists tenant_isolation_calendar_connection_calendars_all on public.calendar_connection_calendars; drop policy if exists calendar_connection_calendars_select on public.calendar_connection_calendars; create policy calendar_connection_calendars_select on public.calendar_connection_calendars for select using ( public.fn_is_platform_admin() or ((organization_id in (select public.fn_user_org_ids())) and exists ( select 1 from public.calendar_connections c where c.id = connection_id and (c.user_id = auth.uid() or public.fn_role_at_least(c.organization_id, 'manager')) )) ); revoke all on public.calendar_connection_calendars from anon; -- ─── o espelho do Google: leitura de todos, escrita de ninguém ──────────── alter table public.calendar_external_events enable row level security; drop policy if exists tenant_isolation_calendar_external_events_all on public.calendar_external_events; drop policy if exists calendar_external_events_select on public.calendar_external_events; create policy calendar_external_events_select on public.calendar_external_events for select using ( (organization_id in (select public.fn_user_org_ids())) or public.fn_is_platform_admin() ); revoke all on public.calendar_external_events from anon; alter table public.calendar_connections enable row level security; drop policy if exists tenant_isolation_calendar_connections_all on public.calendar_connections; drop policy if exists calendar_connections_dono_ou_manager_read on public.calendar_connections; create policy calendar_connections_dono_ou_manager_read on public.calendar_connections for select using ( public.fn_is_platform_admin() or ((organization_id in (select public.fn_user_org_ids())) and (user_id = auth.uid() or public.fn_role_at_least(organization_id, 'manager'))) ); -- Escrita não tem policy: quem conecta e desconecta é o callback do OAuth e o -- worker de renovação, ambos com service role, e ambos filtram organization_id -- de fonte confiável. Uma policy de escrita aqui só abriria caminho para -- gravar token pelo PostgREST. revoke all on public.calendar_connections from anon; notify pgrst, 'reload schema'; -- ---- dois cliques não marcam duas vezes (migration 0182) ---- -- -- Entre a validação do motor de slots e o INSERT há uma janela em que o banco -- não repete a pergunta: dois POSTs simultâneos para o mesmo horário passam OS -- DOIS na checagem e criam dois agendamentos. É o duplo clique, e é a corrida -- entre duas pessoas marcando o mesmo slot. -- -- Não é a constraint de sobreposição que a 0177 recusou, e a distinção importa: -- `exclude using gist` proibiria SOBREPOSIÇÃO (14h-15h contra 14h30-15h30, que é -- o encaixe legítimo) e exigiria `btree_gist`, ausente deste baseline. Índice -- único parcial é btree PURO e proíbe só a COINCIDÊNCIA EXATA de instante para o -- mesmo dono. -- -- ⚠️ `owner_user_id is not null` NÃO conserta buraco nenhum, e a primeira versão -- deste comentário dizia que sim. Medido num pg17 descartável: com a condição e -- sem ela, duas linhas com dono NULL no mesmo instante entram IGUAL — `NULL` -- nunca colide com `NULL` numa UNIQUE, esteja a linha dentro ou fora do índice. -- A condição fica porque mantém fora do índice o que nunca colidiria e porque -- DECLARA o alcance da guarda (ela é sobre a agenda de uma PESSOA), não porque -- proteja. Comentário que promete guarda inexistente é pior que nenhum: quem lê -- para de procurar. -- -- Custo declarado: proíbe dois compromissos do mesmo atendente no MESMO -- instante, que numa clínica às vezes é o encaixe deliberado. A troca é -- consciente, e a contrapartida é a rota devolver 409 dizendo QUAL compromisso -- está ali — senão troca-se uma corrida rara por uma parede diária. -- -- Deduplica ANTES da constraint, e deslocando em vez de apagar: cancelar a -- duplicata destruiria um compromisso combinado com uma pessoa real, e isso uma -- migration não faz. Nenhum clone tem linha nesta tabela hoje (nasceu na 0177 e -- a rota que grava não existe); o bloco é para o clone que vier a ter. -- ─── 1 · deduplicar deslocando, sem perder nenhum compromisso ────────────── -- Empurra a 2ª, 3ª… ocorrência em 1 segundo cada, levando `ends_at` junto para -- a duração não mudar. Em laço porque um deslocamento pode cair em cima de -- outro instante já ocupado; 10 passadas cobrem qualquer caso real e o teto -- impede laço infinito num dado patológico. do $$ declare mexidas integer; passada integer := 0; begin loop with duplicadas as ( select id, row_number() over ( partition by organization_id, owner_user_id, starts_at order by created_at, id ) - 1 as posicao from public.calendar_appointments where status in ('pending', 'confirmed') and owner_user_id is not null ) update public.calendar_appointments a set starts_at = a.starts_at + (d.posicao * interval '1 second'), ends_at = a.ends_at + (d.posicao * interval '1 second') from duplicadas d where d.id = a.id and d.posicao > 0; get diagnostics mexidas = row_count; passada := passada + 1; exit when mexidas = 0 or passada >= 10; end loop; end $$; -- ─── 2 · a guarda ───────────────────────────────────────────────────────── create unique index if not exists calendar_appointments_sem_duplicata_idx on public.calendar_appointments (organization_id, owner_user_id, starts_at) where status in ('pending', 'confirmed') and owner_user_id is not null; comment on index public.calendar_appointments_sem_duplicata_idx is 'Fecha a janela entre a validação do motor de slots e o INSERT: dois POSTs simultâneos para o mesmo instante e o mesmo atendente não viram dois compromissos. Parcial em (pending, confirmed) porque cancelado e realizado não ocupam ninguém, e em owner_user_id não nulo porque NULL não colide com NULL numa UNIQUE — e porque agendamento sem atendente não ocupa agenda. Quem captura o 23505 é a rota, que devolve 409 dizendo qual compromisso está ali.'; notify pgrst, 'reload schema'; -- ---- a grade da agenda não se move sozinha (migration 0183) ---- -- -- `calendar_appointments` não estava na publicação: o `.channel()` sobe, o -- `subscribe` devolve SUBSCRIBED, nenhum erro em lugar nenhum, e nenhum evento -- chega nunca. Duas pessoas com a agenda aberta não veem o que a outra marcou. -- Agravante de diagnóstico: nesta base o canal já morre calado por outro motivo -- quando o token não chega ao socket, então quem investigar vai para o `setAuth` -- e não para a publicação — dois defeitos com o MESMO sintoma. -- -- SÓ ela entra, e a doutrina julga tabela a tabela: `calendar_external_events` é -- espelho reescrito em lote pelo sync (200 eventos = 200 pulsos: o "pulso que -- mente" da 0075 em forma de calendário); `calendar_connections` guarda token; as -- de configuração mudam com quem já está na tela. -- -- ⚠️ NÃO resolve o DELETE: `replica identity` tem zero ocorrência neste schema, -- então o payload de DELETE traz só o `id` e um canal com `filter` por -- `owner_user_id` não o recebe — o card apagado fica na tela até o F5. Não ligo -- `replica identity full` aqui porque hoje não há assinante nenhum, e o custo em -- WAL seria para servir um consumidor que não existe. do $$ begin if not exists ( select 1 from pg_publication where pubname = 'supabase_realtime' ) then create publication supabase_realtime; end if; if not exists ( select 1 from pg_publication_tables where pubname = 'supabase_realtime' and schemaname = 'public' and tablename = 'calendar_appointments' ) then execute 'alter publication supabase_realtime add table public.calendar_appointments'; end if; end $$; comment on table public.calendar_appointments is 'O compromisso COMBINADO: hora marcada, com alguém, ocupando a agenda de um atendente. Distinto do RETORNO agendado (cron_jobs kind=at, job_kind=followup_turn), que é decisão interna do sistema, não ocupa agenda de ninguém e o cliente não sabe. ESTÁ na publicação supabase_realtime (migration 0183) porque marcar e cancelar é mudança de estado, não telemetria — mas o DELETE só traz o id, então um canal com filter por owner_user_id não o recebe.'; notify pgrst, 'reload schema'; -- ---- LGPD alcança a agenda: trigger (migration 0184) ---- -- -- A SEGUNDA metade da 0184. A função está ANTES do bloco da VARREDURA anon, e a -- razão está escrita lá. -- -- Redige o texto livre e PRESERVA `starts_at`, `ends_at`, `status` e -- `owner_user_id`: a clínica precisa responder "quantos atendimentos houve em -- março" depois de anonimizar. O QUE aconteceu e QUANDO fica; COM QUEM e SOBRE O -- QUÊ sai. -- -- `calendar_external_events` fica de fora e NÃO por esquecimento: ela não tem -- `contact_id`, e o único vínculo com a pessoa é o `title` copiado do Google. -- Alcançá-la exige uma decisão de produto — declarar que é espelho de terceiro, -- ou apagar a janela da conexão. drop trigger if exists trg_redigir_agenda_ao_anonimizar on public.contacts; create trigger trg_redigir_agenda_ao_anonimizar after update of is_anonymized on public.contacts for each row when (new.is_anonymized is true and old.is_anonymized is distinct from true) execute function public.fn_redigir_agenda_do_contato_anonimizado(); comment on column public.calendar_appointments.notes is 'Anotação livre do atendimento — numa clínica, queixa clínica. É dado pessoal: o trigger trg_redigir_agenda_ao_anonimizar (migration 0184) a apaga quando o contato é anonimizado, junto com title, description, location_details, meeting_url e cancellation_reason. Horário, status e dono são PRESERVADOS: o que aconteceu e quando é registro de operação.'; notify pgrst, 'reload schema'; -- ---- a agenda nasce com o que marcar: trigger e backfill (migration 0185) ---- -- -- A SEGUNDA metade da 0185. As funções estão ANTES do bloco da VARREDURA anon. -- -- TRIGGER e BACKFILL, e não um só: o baseline nunca semeia organização que ainda -- não existe (só faz backfill das de hoje), e o único mecanismo que alcança -- organização FUTURA é trigger em `organizations`. Só backfill deixaria a -- segunda organização do dono vazia; só trigger deixaria sem nada todo clone que -- já instalou. -- -- `on conflict do nothing` e nunca `do update`: o `update.sh` re-aplica este -- arquivo a cada atualização, e `do update` sobrescreveria em silêncio o tipo -- que o dono já editou. É a diferença entre semear e mandar. drop trigger if exists trg_semear_tipos_de_agendamento on public.organizations; create trigger trg_semear_tipos_de_agendamento after insert on public.organizations for each row execute function public.fn_semear_tipos_de_agendamento_na_org_nova(); -- Backfill: os clones que JÁ instalaram. Guardado por `not exists` para o -- `update.sh` poder re-aplicar sem duplicar e sem tocar em quem já editou. do $$ declare o record; begin for o in select id from public.organizations where not exists ( select 1 from public.calendar_event_types t where t.organization_id = organizations.id ) loop perform public.fn_semear_tipos_de_agendamento(o.id); end loop; end $$; comment on function public.fn_semear_tipos_de_agendamento(uuid) is 'O PISO da agenda: três tipos neutros (Consulta, Reunião, Atendimento) para que instalação fresca tenha o que marcar. Não é o teto — o enriquecimento por nicho vive no passo do funil do onboarding, onde o nicho existe. `on conflict do nothing` para nunca sobrescrever o que o dono editou.'; notify pgrst, 'reload schema'; -- ---- a cor da pessoa é uma trilha, e a do tipo não existe (migration 0186) ---- -- -- A 0177 criou duas colunas de cor guardando hex. As duas estavam erradas, e o -- argumento é do @VPS: hex guardado é "um segundo lugar para a mesma verdade, e -- o tema escuro fica de fora". Medido: as cores vivem em `--agenda-pessoa-1..8` -- no globals.css, em TRÊS blocos de tema, e a mesma trilha tem hex diferente em -- cada um. -- -- `calendar_color` VIRA TRILHA e a escolha manual FICA: a derivação a partir do -- `user_id` é estável mas COLIDE (oito trilhas, mais de oito pessoas), e quem -- administra vai querer desempatar. NULL = use a derivada. -- -- `calendar_event_types.color` SAI: há UM pixel por compromisso na grade, e duas -- colorações competindo pelo mesmo lugar significam que uma delas mente. O pedido -- é cor POR PESSOA. Se voltar um dia, volta como trilha, com alternador. -- -- ⚠️ DROP COLUMN é destrutivo. O que autoriza: as colunas nasceram na 0177 hoje, -- o seed da 0185 não preenche nenhuma, e a varredura por consumidor devolveu zero -- com controle positivo. Não há dado de cliente a perder porque não há caminho -- que grave. -- ─── 1 · a cor da pessoa vira trilha ────────────────────────────────────── alter table public.user_organizations add column if not exists calendar_trilha smallint; alter table public.user_organizations drop constraint if exists user_organizations_calendar_trilha_valida; alter table public.user_organizations add constraint user_organizations_calendar_trilha_valida check (calendar_trilha is null or calendar_trilha between 1 and 8); comment on column public.user_organizations.calendar_trilha is 'A trilha de cor desta pessoa na grade da Agenda, nesta organização (1..8). NULL = use a derivada de trilhaPadraoDoMembro(user_id), que é estável mas colide para alguns pares — esta coluna existe para quem administra desempatar. A COR de cada trilha vive em app/globals.css (--agenda-pessoa-N) e muda com o tema; guardar hex aqui seria um segundo lugar para a mesma verdade, sem tema escuro. ⚠️ A policy de SELECT desta tabela é self-OU-manager+: um `agent` não lê a linha dos colegas pelo PostgREST, então as trilhas chegam à tela pela rota que monta o roster com service role.'; -- Só age quando a coluna existe (issue #1041): `drop ... if exists` sem efeito -- ainda pede trava exclusiva sobre user_organizations. do $$ begin if exists ( select 1 from pg_attribute where attrelid = 'public.user_organizations'::regclass and attname = 'calendar_color' and not attisdropped ) then alter table public.user_organizations drop constraint if exists user_organizations_calendar_color_format; alter table public.user_organizations drop column calendar_color; end if; end $$; -- ─── 2 · a cor do tipo de agendamento sai ───────────────────────────────── alter table public.calendar_event_types drop constraint if exists calendar_event_types_color_format; alter table public.calendar_event_types drop column if exists color; notify pgrst, 'reload schema'; -- ---- o espelho do Google é cache com prazo (migration 0187) ---- -- -- A SEGUNDA metade da 0187. A função está ANTES do bloco da VARREDURA anon. comment on table public.calendar_external_events is 'ESPELHO, somente-leitura, do que já existe na agenda conectada. Ocupa horário e aparece na grade, mas NÃO é compromisso nosso: não tem lead, não tem estado de atendimento e nunca é reescrito por nós. É CACHE — reconstruível pelo sync, apagado em cascata quando a conexão sai, e com prazo (fn_expurgar_espelho_da_agenda, migration 0187). Fica FORA da cascata de LGPD por não ter contact_id: o único vínculo com a pessoa é o title copiado do Google, e a fonte da verdade daquele dado é a agenda do próprio cliente, onde o titular exerce o direito com o controlador de lá. A mira de verdade só nasce com o escritor do sync, que terá o ical_uid para ligar — decisão de QUANDO, não de SE.'; create index if not exists calendar_external_events_poda_idx on public.calendar_external_events (ends_at); notify pgrst, 'reload schema'; -- ---- o espelho não se limpa sozinho (migration 0189) ---- -- -- A 0187 deu prazo ao espelho e o comentário passou a dizer "cache com prazo". -- Está certo e é insuficiente: quem ler aquilo conclui que ele se limpa sozinho. -- -- O caso que a poda NÃO alcança: evento com `ends_at` no FUTURO, de conexão -- VIVA, apagado no Google. Nunca envelhece — o corte é `ends_at < now() - N`, e -- futuro não vence. Fica aqui para sempre, ocupando horário que na agenda do -- cliente já está livre, e fazendo a agenda RECUSAR hora que existe. Quem limpa -- é a RECONCILIAÇÃO do sync, que é de outra frente e não existe hoje. -- -- Merece migration e não linha de doc porque `comment on table` é o que se lê no -- `\d+` e é a única declaração que viaja com o schema para todo clone. Ressalva -- que fica no briefing morre com a entrega. comment on table public.calendar_external_events is 'ESPELHO, somente-leitura, do que já existe na agenda conectada. Ocupa horário e aparece na grade, mas NÃO é compromisso nosso: não tem lead, não tem estado de atendimento e nunca é reescrito por nós. ' 'É CACHE — reconstruível pelo sync, apagado em cascata quando a conexão sai, e com prazo para o PASSADO (fn_expurgar_espelho_da_agenda, migration 0187). ' '⚠️ O PRAZO NÃO LIMPA O FANTASMA: evento com ends_at no FUTURO, de conexão viva, apagado lá no Google, nunca envelhece e fica aqui para sempre — ocupando um horário que na agenda do cliente já está livre, e fazendo a agenda RECUSAR hora que existe. Quem limpa isso é a RECONCILIAÇÃO do sync (remover o que não veio na resposta da janela), que é da frente do Google e não existe hoje. ' 'Fica FORA da cascata de LGPD por não ter contact_id: o único vínculo com a pessoa é o title copiado do Google, e a fonte da verdade daquele dado é a agenda do próprio cliente, onde o titular exerce o direito com o controlador de lá. A mira de verdade só nasce com o escritor do sync, que terá o ical_uid para ligar — decisão de QUANDO, não de SE.'; notify pgrst, 'reload schema'; -- ---- a volta do Google precisa de identidade (migration 0188) ---- -- `calendar_appointments.google_ical_uid` existe desde a 0177 e diz qual evento -- do Google é nosso. A linha de VOLTA não tinha equivalente, e sem chave entre -- os dois o mesmo compromisso movido no Google passa a bloquear DOIS horários — -- o novo, pela linha externa, e o antigo, pelo agendamento — sem nada que os -- ligue para desfazer. Aditiva e idempotente. alter table public.calendar_external_events add column if not exists ical_uid text; create index if not exists calendar_external_events_ical_uid_idx on public.calendar_external_events (organization_id, ical_uid) where ical_uid is not null; -- ---- o mesmo state do Google valia duas vezes (migration 0190) ---- -- Tabela de nonces queimados. Postgres e nao Redis porque o Upstash e opcional -- no self-host, e propriedade de seguranca que degrada em silencio onde a -- dependencia falta e pior que propriedade nenhuma. Aditiva e idempotente. create table if not exists public.calendar_oauth_nonces ( nonce text primary key, organization_id uuid not null references public.organizations(id) on delete cascade, user_id uuid not null references auth.users(id) on delete cascade, -- O prazo do próprio `state`. Depois dele a linha não serve para mais nada: -- um `state` vencido já é recusado pela assinatura, antes de chegar aqui. expira_em timestamptz not null, usado_em timestamptz not null default now() ); comment on table public.calendar_oauth_nonces is 'Nonces de state do OAuth do Google já usados. A chave primária é o próprio nonce: a segunda tentativa viola a unicidade, e é assim que o replay é recusado.'; create index if not exists calendar_oauth_nonces_expiracao_idx on public.calendar_oauth_nonces (expira_em); alter table public.calendar_oauth_nonces enable row level security; -- Sem policy nenhuma, e é deliberado: quem escreve é o callback do OAuth, com -- service role, e ninguém precisa LER isto pela API. Policy aqui só abriria -- caminho para enumerar tentativas de conexão pelo PostgREST. revoke all on public.calendar_oauth_nonces from anon, authenticated; -- Negação ESCRITA (migration 0192). RLS ligada sem policy já nega tudo, mas no -- catálogo negação deliberada e negação esquecida são indistinguíveis — e é -- disso que o invariante de completude reclama, com razão. Não abre nada. drop policy if exists tenant_isolation_calendar_oauth_nonces_all on public.calendar_oauth_nonces; drop policy if exists calendar_oauth_nonces_ninguem_le on public.calendar_oauth_nonces; create policy calendar_oauth_nonces_ninguem_le on public.calendar_oauth_nonces for select using (false); -- A quarta poda do `data-retention`. Assinatura idêntica às três irmãs -- (`p_retencao_dias`, `p_limite`) para o mesmo laço de lotes servir sem caso -- especial — e os NOMES são o contrato: o PostgREST resolve sobrecarga pelo -- nome do argumento, e é assim que o cron manda -- (`app/api/v1/cron/data-retention/route.ts:163`). Nascida na 0190 como -- (`p_dias`, `p_lote`), esta poda não achava sobrecarga nenhuma: `PGRST202` -- todos os dias, `calendar_oauth_nonces` crescendo para sempre (issue #966). -- -- O drop abaixo é o que faz a ATUALIZAÇÃO receber o conserto: `create or -- replace` NÃO troca nome de parâmetro de entrada — o Postgres recusa com -- "cannot change name of input parameter", porque o nome faz parte da -- identidade da função para quem chama por nome. Sem ele, a instalação que já -- existe (e que reaplica este arquivo inteiro pelo `update.sh`) ficaria com a -- função antiga. A assinatura `(int, int)` não muda, e o drop leva os ACLs -- junto: por isso o `revoke`/`grant` da 0192 se reaplica logo abaixo. O mesmo -- conserto, em forma de migration, é a 0364. drop function if exists public.fn_expurgar_nonces_de_oauth(int, int); create or replace function public.fn_expurgar_nonces_de_oauth( p_retencao_dias int default null, p_limite int default null ) returns int language plpgsql security definer set search_path to 'public', 'pg_temp' as $$ declare v_removidas int; begin -- Piso no CORPO, como as irmãs: um chamador que passe 0 não apaga nonce que -- ainda protege. O prazo do state é de 10 minutos, então um dia já é folga -- de duas ordens de grandeza. if p_retencao_dias is null or p_retencao_dias < 1 then p_retencao_dias := 1; end if; with alvo as ( select nonce from public.calendar_oauth_nonces where expira_em < now() - make_interval(days => p_retencao_dias) -- 500 era o default DECLARADO na 0190; agora mora no corpo, como nas -- irmãs, e o efeito de quem omite o argumento é o mesmo. limit greatest(coalesce(p_limite, 500), 1) ) delete from public.calendar_oauth_nonces n using alvo where n.nonce = alvo.nonce; get diagnostics v_removidas = row_count; return v_removidas; end$$; -- Função nova em `public` nasce EXPOSTA — as DUAS origens de EXECUTE. -- `authenticated` entra aqui pela migration 0192: as duas irmãs de assinatura -- idêntica já o revogavam, e o grant vem do `ALTER DEFAULT PRIVILEGES` do -- corpo deste arquivo — omissão que aparece como linha AUSENTE, não errada. -- O `drop function` logo acima, da 0364, derrubou a função COM os ACLs dela: -- este par é o que repõe o estado que a 0192 deixou, e não redundância com ela. revoke execute on function public.fn_expurgar_nonces_de_oauth(int, int) from public, anon, authenticated; grant execute on function public.fn_expurgar_nonces_de_oauth(int, int) to service_role; -- ---- playbook `agendamento` v2: cita as ferramentas de agenda (migration 0191) ---- do $pub$ declare -- md5 do corpo abaixo. Conferido logo após o insert — ver item 2 do cabeçalho. v_md5 constant text := 'c2022f05f5b5d9451e727cf0f4187f8a'; v_id uuid; begin select id into v_id from skill_versions where organization_id is null and name = 'agendamento' and md5(body) = v_md5 limit 1; if v_id is null then insert into skill_versions (organization_id, name, description, body, matcher) values ( null, 'agendamento', 'Playbook pra marcar/remarcar horário (consulta, visita, sessão) — consulta a agenda real pelas ferramentas quando elas existem, nunca inventa disponibilidade, e confirma por escrito antes de fechar.', $body$# Playbook: marcar horário/agendamento ## Quando usar O lead pede pra marcar um horário, consulta, visita, demonstração ou sessão — qualquer compromisso com data/hora. Comum em clínicas, imobiliárias (visitas), serviços e consultorias. ## Regra de ouro: consulte a agenda, não adivinhe Você tem acesso à agenda **se, e somente se**, a ferramenta `crm_find_free_slots` estiver disponível para você. Não julgue isso por intuição — chame e leia a resposta. - Voltou com horários → ofereça 2 ou 3 deles, concretos. - Voltou `publicou_horarios: false` → o atendente ainda não publicou os horários de trabalho dele. Isso NÃO é "está lotado" e NÃO é "não tem vaga": não invente horário, não diga que a agenda está cheia, e avise que alguém da equipe confirma. - Voltou com `motivo` → leia a `mensagem` e faça o que ela manda. Ela foi escrita para o cliente ouvir. - Voltou `fuso_suposto: true` → o fuso da agenda veio do padrão e ninguém confirmou. Ofereça pedindo confirmação — "consigo terça às 14h; confere se esse horário bate aí pra você?" — em vez de afirmar. - Você não tem essa ferramenta → aí sim: não ofereça horário nenhum, diga que vai confirmar a disponibilidade e sinalize handoff para quem tem acesso. Prometer um horário que depois não existe quebra confiança e gera reagendamento forçado. Inventar é pior do que demorar um instante a mais para responder. ## Fluxo padrão (if-then) **1. Identifique o serviço/motivo antes de oferecer horário** - SE o lead só disse "quero agendar" sem contexto → pergunte o motivo/serviço primeiro. Agendar sem saber o quê gera erro de encaixe (ex.: consulta de 20min marcada num slot de 1h de procedimento). **2. Ofereça opções fechadas, não uma pergunta aberta** - SE `crm_find_free_slots` respondeu com horários → ofereça 2-3 concretos ("tenho terça 14h ou quarta 10h, qual funciona?"). Pergunta aberta tipo "qual horário você prefere?" gera ida e volta desnecessária e trava a conversa. - SE você não tem a ferramenta → não invente. Diga algo como "vou confirmar a disponibilidade e te retorno em instantes" e sinalize handoff/task pra quem tem acesso. **3. Colete os dados obrigatórios antes de confirmar** - Nome completo do lead (ou confirme o que já está no CRM). - Serviço/motivo específico. - Unidade/local, se o tenant tiver mais de uma (clínica com filiais, imobiliária com múltiplos imóveis). - Se for reagendamento, o horário anterior a ser substituído. **4. Confirme por escrito antes de encerrar** - SE o lead aceitar um horário → repita de volta por escrito: "Confirmado: [serviço] dia [data] às [hora], em [local]. Confirma pra mim?" - Só considere o agendamento fechado depois do "sim"/confirmação explícita do lead — silêncio ou "ok" vago não é confirmação suficiente pra compromissos com custo de no-show alto (ex. consulta médica, visita a imóvel). **5. Reagendamento e cancelamento** - SE o lead pedir pra remarcar E você tem `crm_reschedule_appointment` → use ela. NÃO cancele e marque de novo: é o MESMO compromisso mudando de hora. O histórico continua um só e o lembrete é refeito sozinho para o horário novo. - SE o lead pedir pra remarcar e você NÃO tem essa ferramenta → então cancelar e marcar de novo é o único caminho, e ele tem um custo que você precisa administrar: o cliente pode receber dois avisos seguidos e contraditórios ("desmarcado" e depois "marcado"). Antes de fazer, diga a ele em uma frase o que vai acontecer — "vou desmarcar o horário antigo e já marcar o novo, você pode receber dois avisos" — e nunca deixe os dois compromissos de pé ao mesmo tempo. - SE o lead pedir pra cancelar → use `crm_cancel_appointment` se você a tiver, informe o motivo, e pergunte se quer remarcar pra outra data, sem pressionar. Cancelar libera aquele horário para outra pessoa e não dá para desfazer: confirme antes. **6. Risco de no-show** - Se o negócio tiver política de confirmação D-1 documentada na base de conhecimento, siga-a (ex.: mensagem de lembrete automática). Se não houver, não invente política — apenas confirme o agendamento normalmente. ## Regras duras - Nunca confirme horário sem ter checado disponibilidade real (ou sem sinalizar que ainda vai confirmar). - Nunca marque dois compromissos conflitantes pro mesmo lead sem avisar. - Se o lead pedir um horário fora do funcionamento do negócio (ex. domingo, madrugada) e isso não estiver nas regras do tenant, não confirme — explique a janela real de atendimento. - Dado sensível (endereço completo, documento) só é coletado se o fluxo do tenant realmente exigir — não peça informação a mais que o agendamento precisa. - Marcar consulta e agendar retorno são coisas DIFERENTES. `crm_book_appointment` é para hora combinada COM o cliente, que ele reservou e vai comparecer — alguém espera por ele. `crm_schedule_followup` é decisão interna nossa de voltar a falar: o cliente não fica sabendo e nada é reservado na agenda de ninguém. Se ele ESCOLHEU um horário para ser atendido, é a primeira. ## Exemplos de resposta (tom, não copiar literal) - "Pra eu te encaixar certo: é pra qual serviço/motivo?" - "Tenho quinta às 15h ou sexta às 9h — qual fica melhor pra você?" - "Confirmado: consulta dia 28/07 às 15h, na unidade Centro. Pode confirmar pra mim?" ## O que NÃO fazer - Não pergunte "qual horário você prefere?" sem oferecer opções concretas quando você tem a agenda. - Não confirme agendamento sem resposta explícita do lead. - Não invente disponibilidade que você não checou.$body$, '{"any_keywords": ["agendar", "marcar horário", "marcar consulta", "marcar uma visita", "agenda", "que horas vocês", "horário disponível", "remarcar", "reagendar", "cancelar o horário", "desmarcar"], "probe_keywords": ["que horas", "qual dia", "tem vaga", "disponibilidade"]}'::jsonb ) returning id into v_id; if (select md5(body) from skill_versions where id = v_id) is distinct from v_md5 then raise exception 'playbook agendamento: o md5 declarado (%) nao corresponde ao corpo inserido. Recalcule antes de publicar.', v_md5; end if; end if; -- Repointe SEMPRE. O ponteiro global e unico por nome (uniq_skill_pointers_platform, -- parcial em organization_id is null), entao update-senao-insert e seguro e nao depende -- de inferencia de conflito sobre indice parcial. update skill_pointers set version_id = v_id, updated_at = now() where organization_id is null and name = 'agendamento'; if not found then insert into skill_pointers (organization_id, name, version_id) values (null, 'agendamento', v_id); end if; end $pub$; -- ---- ⚠️ RESTAURADA AO FIM (2026-08-27) ---- -- -- Este bloco dizia de si mesmo que era o ÚLTIMO do arquivo, e havia 24 apêndices -- depois dele. A cura deixou de alcançar tudo que veio no meio, e o gate -- `tests/unit/varredura-anon-e-o-ultimo-bloco.test.ts` só reprova quando um -- desses blocos CRIA FUNÇÃO — o que levou 24 blocos para acontecer, com -- `fn_expurgar_nonces_de_oauth` (commit 75383e5a). -- -- Movido em vez de remendado: mover o bloco novo para cima resolveria a -- INSTÂNCIA e deixaria a armadilha armada para o próximo. Mover a varredura -- para o fim resolve a CLASSE e restaura o que o texto dela já prometia. -- -- Seguro porque a varredura preserva o que encontra: ela lê -- `has_function_privilege` de `authenticated` e `service_role` ANTES do -- revoke e regrava os dois. Rodar mais tarde só faz alcançar mais funções. -- ---- FK e fuso da conexão do Google (migration 0193) ---- -- ⚠️ ENTRA ANTES DO BLOCO DA VARREDURA anon, depois do qual nenhuma função é criada. -- Este bloco não cria função, então a varredura não o cura nem precisa curar — mas pôr -- apêndice DEPOIS dela recria a erosão que a 0192 acabou de consertar. alter table public.calendar_appointments add column if not exists google_connection_id uuid; -- Backfill ANTES da constraint: a coluna é nova e nada escreve nela hoje, mas um clone -- adiantado poderia ter linha com ponteiro morto — e constraint criada sobre dado que a -- viola quebra o `update.sh` do clone, que roda SEM ON_ERROR_STOP e falharia no meio. update public.calendar_appointments a set google_connection_id = null where a.google_connection_id is not null and not exists (select 1 from public.calendar_connections c where c.id = a.google_connection_id); do $fk$ begin if not exists ( select 1 from pg_constraint where conrelid = 'public.calendar_appointments'::regclass and conname = 'calendar_appointments_google_connection_id_fkey' ) then alter table public.calendar_appointments add constraint calendar_appointments_google_connection_id_fkey foreign key (google_connection_id) references public.calendar_connections(id) on delete set null; end if; end $fk$; comment on column public.calendar_appointments.google_connection_id is 'Conexão do Google que espelha este compromisso. `set null`: conexão revogada não apaga compromisso — ele é do CRM, não da integração.'; alter table public.calendar_connection_calendars add column if not exists time_zone text; comment on column public.calendar_connection_calendars.time_zone is 'Fuso IANA do calendário, como o Google devolve (`timeZone`). NULL = ainda não sincronizado; quem lê deve tratar NULL como "não sei", nunca como UTC — foi o `?? UTC` que fez evento de dia inteiro vazar a noite anterior.'; -- ---- lembrete nasce desligado (migrations 0194 + 0255) ---- -- ⚠️ ENTRA ANTES DO BLOCO DA VARREDURA anon, pelo mesmo motivo da 0193. -- -- A 0194 corrigiu o histórico junto com o default, e o raciocínio dela valia -- naquele dia: "com zero leitores e zero disparador, nada depende do valor -- atual". Ela própria avisou o que viria depois — *"Depois do disparador, isto -- seria apagar a escolha de um operador"*. -- -- O disparador nasceu (`agenda-reminder`), e o `update.sh` re-aplica este -- arquivo INTEIRO a cada atualização. Sem guarda, toda atualização desligava o -- lembrete de todo tipo em que alguém o tinha ligado — sem erro, sem log, com a -- tela mostrando o controle desmarcado como se ninguém o tivesse marcado. -- -- A guarda é o `column_default`, porque pelo VALOR da coluna é impossível -- distinguir "linha antiga que ninguém escolheu" de "linha que o operador -- acabou de ligar": as duas são `true`. O default só é diferente de `false` -- ANTES da primeira aplicação da 0194 neste banco, que é o único momento em que -- corrigir o histórico é certo. -- -- ⚠️ LER O DEFAULT ANTES DE GRAVÁ-LO. Invertido, a condição seria sempre falsa e -- um clone pré-0194 nunca receberia a correção que a 0194 existe para fazer. do $$ declare v_default text; begin select column_default into v_default from information_schema.columns where table_schema = 'public' and table_name = 'calendar_event_types' and column_name = 'reminder_enabled'; -- `is distinct from`: num banco sem a coluna a consulta devolve NULL, e -- `NULL <> 'false'` seria NULL — pulando a correção em silêncio. if v_default is distinct from 'false' then update public.calendar_event_types set reminder_enabled = false where reminder_enabled is true; end if; end $$; alter table public.calendar_event_types alter column reminder_enabled set default false; comment on column public.calendar_event_types.reminder_enabled is 'Lembrete automático deste tipo. Nasce DESLIGADO: enviar mensagem é irreversível. O histórico foi corrigido UMA vez, na primeira aplicação da 0194 em cada banco (a 0255 guarda isso pelo column_default) — depois disso, true significa que alguém ligou, e atualizar o CRM não desliga mais.'; -- ---- tipo semeado adota dono no primeiro membro (migration 0195) ---- -- ⚠️ ENTRA ANTES DO BLOCO DA VARREDURA anon: aqui é OBRIGATÓRIO, não preferência — -- este bloco CRIA FUNÇÃO, e função nova em `public` nasce exposta a `anon` pelo -- ALTER DEFAULT PRIVILEGES. Depois da varredura, ela ficaria sem a cura. create or replace function public.fn_adotar_tipos_de_agendamento_sem_dono() returns trigger language plpgsql security definer set search_path = public as $fn$ begin -- Só o primeiro membro ATIVO da organização. -- -- ⚠️ As duas condições nasceram de um caso que a predição pegou antes do commit: sem -- `new.revoked_at is null`, uma linha que JÁ nasce revogada adota os tipos e o dono padrão -- da agenda vira alguém que nunca esteve lá. E contar TODOS em vez de só os ativos criaria -- o furo simétrico: numa org com um ex-membro, o primeiro membro de verdade veria contagem -- 2 e não adotaria nada — a org ficaria órfã para sempre. -- -- `= 1` e não `> 0`: neste ponto a linha nova já está na tabela, então o primeiro ativo -- vê contagem 1. if new.revoked_at is null and (select count(*) from public.user_organizations u where u.organization_id = new.organization_id and u.revoked_at is null) = 1 then update public.calendar_event_types set default_owner_user_id = new.user_id where organization_id = new.organization_id and default_owner_user_id is null; end if; return new; end $fn$; revoke execute on function public.fn_adotar_tipos_de_agendamento_sem_dono() from public, anon, authenticated; grant execute on function public.fn_adotar_tipos_de_agendamento_sem_dono() to service_role; drop trigger if exists trg_adotar_tipos_de_agendamento_sem_dono on public.user_organizations; create trigger trg_adotar_tipos_de_agendamento_sem_dono after insert on public.user_organizations for each row execute function public.fn_adotar_tipos_de_agendamento_sem_dono(); -- Backfill: organizações que JÁ nasceram com os tipos órfãos e já têm membro. Adota o -- membro ATIVO mais antigo — o mesmo que o trigger teria escolhido se existisse na época. -- -- ⚠️ `revoked_at is null` nas DUAS metades, e não é detalhe: `user_organizations` guarda o -- ex-membro em vez de apagá-lo. Sem o filtro, o backfill adotaria como dono padrão da agenda -- alguém que já saiu da empresa — e o `exists` sem filtro faria pior, deixando o tipo órfão -- numa org que só tem ex-membros parecer "já resolvido" por ter alguém na tabela. update public.calendar_event_types t set default_owner_user_id = ( select u.user_id from public.user_organizations u where u.organization_id = t.organization_id and u.revoked_at is null order by u.created_at, u.user_id limit 1) where t.default_owner_user_id is null and exists (select 1 from public.user_organizations u where u.organization_id = t.organization_id and u.revoked_at is null); -- ---- o acervo é da organização; o agente escolhe o que lê (migration 0181) ---- -- -- A base de conhecimento PERTENCIA a um agente (`ai_knowledge_sources.agent_id` -- NOT NULL, FK CASCADE) e o acervo do agente era UMA versão monolítica. Daí -- saíam, em cadeia: material impossível de compartilhar; UM documento por -- categoria por agente (índice único `(agent_id, source_type) WHERE is_active`, -- e todo arquivo enviado virava `policy`, então o SEGUNDO PDF colidia); -- pipelines competindo pelo mesmo ponteiro (a ingestão de conversas ativava a -- versão dela e DESATIVAVA a de FAQ do mesmo agente, e vice-versa); e apagar o -- agente apagava a base junto. -- -- Agora a fonte é da ORGANIZAÇÃO. `agent_id` fica como histórico (nullable, ON -- DELETE SET NULL). Quem lê o quê é `ai_agent_versions.knowledge_source_ids`, -- molde exato de `pipeline_ids` (0125) e pela mesma razão: escopo fora do ciclo -- rascunho→publicar muda o alcance do agente sem ninguém publicar nada. Coluna -- `uuid[]` na versão e não junção, porque o runtime lê a config em UMA query -- sem cache — junção custaria uma query a mais por turno atendido. -- -- O ponteiro de índice vira POR FONTE (`ai_knowledge_sources.active_kb_version_id`). -- As versões legadas continuam válidas sem serem quebradas: a busca casa -- `(kb_version_id, knowledge_source_id)`, então uma versão compartilhada devolve -- para cada fonte exatamente os chunks daquela fonte. -- -- Racional completo no cabeçalho da migration. Idempotente e auto-curativo. drop index if exists public.ai_knowledge_sources_unique_per_agent; alter table public.ai_knowledge_sources alter column agent_id drop not null; alter table public.ai_knowledge_sources drop constraint if exists ai_knowledge_sources_agent_id_fkey; alter table public.ai_knowledge_sources add constraint ai_knowledge_sources_agent_id_fkey foreign key (agent_id) references public.ai_agents(id) on delete set null; comment on column public.ai_knowledge_sources.agent_id is 'HISTÓRICO: o agente a partir do qual a fonte foi criada. NÃO é dono — desde a 0181 quem lê o quê é `ai_agent_versions.knowledge_source_ids`. Nullable e ON DELETE SET NULL de propósito.'; -- A VERSÃO DE ÍNDICE TAMBÉM DEIXA DE PERTENCER A UM AGENTE. -- -- `agent_id` era NOT NULL aqui, e um material da organização (sem agente -- nenhum) não tinha como ser indexado: `createKnowledgeVersion` batia em -- "null value in column agent_id violates not-null constraint" — medido na -- prova de tela, com o material parado em `indexando` para sempre. -- -- E o CASCADE sai junto, por uma razão pior: os chunks apontam para a VERSÃO -- (`ai_chunks.kb_version_id ... on delete cascade`), então apagar o agente -- levava a versão, e a versão levava os trechos — o material da EMPRESA sumia -- porque alguém apagou um assistente. `SET NULL`: a versão pertence à fonte. alter table public.ai_knowledge_versions alter column agent_id drop not null; alter table public.ai_knowledge_versions drop constraint if exists ai_knowledge_versions_agent_id_fkey; alter table public.ai_knowledge_versions add constraint ai_knowledge_versions_agent_id_fkey foreign key (agent_id) references public.ai_agents(id) on delete set null; comment on column public.ai_knowledge_versions.agent_id is 'HISTÓRICO: o agente a partir do qual esta indexação foi disparada. Nullable desde a 0181 — a versão pertence à FONTE, e o acervo é da organização.'; -- Vocabulário ABERTO (precedente da 0127): o CHECK tinha 6 valores com dois -- pares de sinônimos e nenhum valor para "documento avulso". alter table public.ai_knowledge_sources drop constraint if exists ai_knowledge_sources_source_type_check; update public.ai_knowledge_sources set source_type = case source_type when 'policy' then 'documento' when 'conversation' then 'conversas' when 'nuvemshop_catalog' then 'catalogo' when 'catalog' then 'catalogo' else source_type end where source_type in ('policy', 'conversation', 'nuvemshop_catalog', 'catalog'); comment on column public.ai_knowledge_sources.source_type is 'Vocabulário ABERTO (sem CHECK, precedente da 0127). A lista que a tela oferece vive em lib/ai/rag/tipos-de-fonte.ts: faq | documento | conversas | catalogo.'; -- Nome vira identidade: batizar o que está sem nome e desempatar homônimos -- ANTES do índice único, senão o `update.sh` do clone morre aqui. update public.ai_knowledge_sources set name = case source_type when 'faq' then 'Perguntas frequentes' when 'documento' then 'Documento' when 'conversas' then 'Conversas anteriores' when 'catalogo' then 'Catálogo de produtos' else 'Material' end || ' ' || left(id::text, 8) where coalesce(btrim(name), '') = ''; with duplicados as ( select id, row_number() over ( partition by organization_id, lower(btrim(name)) order by created_at, id ) as n from public.ai_knowledge_sources where is_active ) update public.ai_knowledge_sources s set name = s.name || ' (' || left(s.id::text, 4) || ')' from duplicados d where d.id = s.id and d.n > 1; create unique index if not exists ai_knowledge_sources_nome_unico_por_org on public.ai_knowledge_sources (organization_id, lower(btrim(name))) where is_active; -- Arquivar desliga de verdade: nenhuma linha do repo jamais escreveu -- `is_active = false`, e com o índice único antigo isso deixava o "slot" -- ocupado por uma fonte arquivada para sempre. update public.ai_knowledge_sources set is_active = false where status = 'archived' and is_active; alter table public.ai_knowledge_sources drop constraint if exists ai_knowledge_sources_arquivada_nao_e_ativa; alter table public.ai_knowledge_sources add constraint ai_knowledge_sources_arquivada_nao_e_ativa check (not is_active or status <> 'archived'); -- Os dois estados que o produto JÁ produz e a tela não sabia mostrar. -- Reconstruído em UM bloco só (lição do #159). Aditivo. alter table public.ai_knowledge_sources drop constraint if exists ai_knowledge_sources_last_index_status_check; alter table public.ai_knowledge_sources add constraint ai_knowledge_sources_last_index_status_check check (last_index_status is null or last_index_status = any (array[ 'success', 'partial', 'failed', 'indexando', 'sem_credencial' ])); alter table public.ai_knowledge_sources add column if not exists active_kb_version_id uuid; alter table public.ai_knowledge_versions add column if not exists knowledge_source_id uuid; alter table public.ai_knowledge_versions add column if not exists embedding_model text; alter table public.ai_knowledge_versions add column if not exists embedding_dims integer; comment on column public.ai_knowledge_versions.knowledge_source_id is 'A fonte que esta versão indexa. NULL nas versões anteriores à 0181, que continham chunks de várias fontes — e continuam válidas: a busca casa (kb_version_id, knowledge_source_id) por fonte.'; comment on column public.ai_knowledge_versions.embedding_model is 'Modelo com que os vetores desta versão foram calculados. NULL = anterior à 0181. A busca recusa a fonte cuja versão foi indexada com outro modelo — recall quebrado em silêncio é pior que fonte de fora.'; -- Ponteiros pendurados saem ANTES das FKs. Um `active_kb_version_id` apontando -- para versão apagada é hoje indistinguível de "base vazia": zero chunk, zero erro. update public.ai_agents a set active_kb_version_id = null where a.active_kb_version_id is not null and not exists (select 1 from public.ai_knowledge_versions v where v.id = a.active_kb_version_id); delete from public.ai_chunks c where not exists (select 1 from public.ai_knowledge_versions v where v.id = c.kb_version_id); alter table public.ai_agents drop constraint if exists ai_agents_active_kb_version_id_fkey; alter table public.ai_agents add constraint ai_agents_active_kb_version_id_fkey foreign key (active_kb_version_id) references public.ai_knowledge_versions(id) on delete set null; alter table public.ai_chunks drop constraint if exists ai_chunks_kb_version_id_fkey; alter table public.ai_chunks add constraint ai_chunks_kb_version_id_fkey foreign key (kb_version_id) references public.ai_knowledge_versions(id) on delete cascade; alter table public.ai_knowledge_sources drop constraint if exists ai_knowledge_sources_active_kb_version_id_fkey; alter table public.ai_knowledge_sources add constraint ai_knowledge_sources_active_kb_version_id_fkey foreign key (active_kb_version_id) references public.ai_knowledge_versions(id) on delete set null; alter table public.ai_knowledge_versions drop constraint if exists ai_knowledge_versions_knowledge_source_id_fkey; alter table public.ai_knowledge_versions add constraint ai_knowledge_versions_knowledge_source_id_fkey foreign key (knowledge_source_id) references public.ai_knowledge_sources(id) on delete cascade; -- Cada fonte herda a versão ATIVA do agente dela, mas SÓ se aquela versão -- realmente contiver chunks daquela fonte. update public.ai_knowledge_sources s set active_kb_version_id = v.id from public.ai_knowledge_versions v where v.agent_id = s.agent_id and v.organization_id = s.organization_id and v.is_active and s.active_kb_version_id is null and exists ( select 1 from public.ai_chunks c where c.kb_version_id = v.id and c.knowledge_source_id = s.id ); drop index if exists public.ai_kbv_one_active_per_agent; create unique index if not exists ai_kbv_uma_ativa_por_fonte on public.ai_knowledge_versions (knowledge_source_id) where is_active and knowledge_source_id is not null; create index if not exists ai_knowledge_sources_org_idx on public.ai_knowledge_sources (organization_id, is_active); create index if not exists ai_knowledge_versions_org_idx on public.ai_knowledge_versions (organization_id, knowledge_source_id); alter table public.ai_agent_versions add column if not exists knowledge_source_ids uuid[] not null default '{}'::uuid[]; comment on column public.ai_agent_versions.knowledge_source_ids is 'Materiais que ESTE agente consulta. Vazio = NENHUM (falha fechada): ele conversa normalmente e a ferramenta de busca some do turno. Molde e racional de `pipeline_ids` (0125): escopo mora na versão publicada.'; update public.ai_agent_versions v set knowledge_source_ids = sub.fontes from ( select agent_id, array_agg(id order by created_at) as fontes from public.ai_knowledge_sources where is_active and agent_id is not null group by agent_id ) sub where v.agent_id = sub.agent_id and v.knowledge_source_ids = '{}'::uuid[]; -- CONSERTO OBRIGATÓRIO no mesmo bloco: escopo de leitura editável numa versão -- PUBLICADA sem virar versão nova é a própria ausência de escopo, com aparência -- de controle. create or replace function fn_ai_agent_version_content_immutable() returns trigger language plpgsql as $fn$ begin if old.status <> 'draft' and ( new.system_prompt is distinct from old.system_prompt or new.provider is distinct from old.provider or new.model is distinct from old.model or new.credential_id is distinct from old.credential_id or new.tool_ids is distinct from old.tool_ids or new.trigger_config is distinct from old.trigger_config or new.channel_session_id is distinct from old.channel_session_id or new.max_steps is distinct from old.max_steps or new.token_budget is distinct from old.token_budget or new.cost_budget_cents is distinct from old.cost_budget_cents or new.history_message_window is distinct from old.history_message_window or new.history_token_window is distinct from old.history_token_window or new.handoff_keywords is distinct from old.handoff_keywords or new.handoff_tool_enabled is distinct from old.handoff_tool_enabled or new.followup is distinct from old.followup or new.multimodal_input is distinct from old.multimodal_input or new.video_frames_enabled is distinct from old.video_frames_enabled or new.split_messages is distinct from old.split_messages or new.split_max_chars is distinct from old.split_max_chars or new.cases_enabled is distinct from old.cases_enabled or new.operator_enabled is distinct from old.operator_enabled or new.operator_model is distinct from old.operator_model or new.operator_tool_ids is distinct from old.operator_tool_ids or new.pipeline_ids is distinct from old.pipeline_ids or new.knowledge_source_ids is distinct from old.knowledge_source_ids or new.version_number is distinct from old.version_number or new.agent_id is distinct from old.agent_id or new.organization_id is distinct from old.organization_id ) then raise exception 'ai_agent_versions % é imutável (status=%): mudança de conteúdo = versão draft nova; rollback = revert (clona + publica)', old.id, old.status; end if; return new; end; $fn$; drop trigger if exists trg_ai_agent_versions_content_immutable on public.ai_agent_versions; create trigger trg_ai_agent_versions_content_immutable before update on public.ai_agent_versions for each row execute function fn_ai_agent_version_content_immutable(); -- A busca que aceita VÁRIAS fontes. A antiga (`retrieve_top_k_chunks`) continua -- existindo: o worker legado e a capacidade MCP a chamam. -- -- Preserva as duas decisões de que o chamador depende: quem corta pelo limiar é -- o TypeScript (o caller passa o piso −1, para enxergar o melhor candidato -- REPROVADO), e o gate de membership só morde quando há `auth.uid()` — o engine -- roda com role `bypassrls` e para ele o isolamento é o `organization_id = $1`. create or replace function public.fn_buscar_trechos_das_fontes( p_organization_id uuid, p_source_ids uuid[], p_embedding public.vector, p_k integer default 5, p_threshold real default 0.40, p_embedding_model text default null ) returns table( chunk_id uuid, knowledge_source_id uuid, source_name text, content text, similarity real, metadata jsonb ) language plpgsql stable security definer set search_path to 'public' as $$ begin if auth.uid() is not null and not public.fn_role_at_least(p_organization_id, 'viewer') then raise exception 'caller_not_authorized_for_org' using hint = 'fn_buscar_trechos_das_fontes: caller must be an active member of the organization'; end if; return query select c.id as chunk_id, c.knowledge_source_id, s.name as source_name, c.content, (1 - (c.embedding <=> p_embedding))::real as similarity, c.metadata from public.ai_chunks c join public.ai_knowledge_sources s on s.id = c.knowledge_source_id and s.organization_id = c.organization_id join public.ai_knowledge_versions v on v.id = c.kb_version_id where c.organization_id = p_organization_id and s.id = any(p_source_ids) and s.is_active and s.status = 'ready' and c.kb_version_id = s.active_kb_version_id and ( p_embedding_model is null or v.embedding_model is null or v.embedding_model = p_embedding_model ) and (1 - (c.embedding <=> p_embedding)) >= p_threshold order by c.embedding <=> p_embedding asc limit greatest(p_k, 0); end $$; comment on function public.fn_buscar_trechos_das_fontes(uuid, uuid[], public.vector, integer, real, text) is 'Top-K por similaridade de cosseno sobre os materiais que o agente pode ler (0181). SECURITY DEFINER + filtro programático de organização — quem chama valida o tenant.'; revoke execute on function public.fn_buscar_trechos_das_fontes(uuid, uuid[], public.vector, integer, real, text) from public, anon; grant execute on function public.fn_buscar_trechos_das_fontes(uuid, uuid[], public.vector, integer, real, text) to authenticated, service_role; -- `ai_models` não tinha NENHUM modelo de embedding, e é por isso que o painel de -- provedores não conseguia oferecer chave para `embedding_indexar` e -- `embedding_consultar`: não havia o que listar. alter table public.ai_models add column if not exists supports_embedding boolean not null default false; alter table public.ai_models add column if not exists embedding_dims integer; insert into public.ai_models (provider, model_id, display_name, description, input_price_per_million_cents, output_price_per_million_cents, supports_tools, supports_embedding, embedding_dims) values ('openai', 'text-embedding-3-small', 'Text Embedding 3 Small', 'O modelo que indexa e consulta o seu material. Trocar exige reindexar tudo de uma vez.', 2, 0, false, true, 1536) on conflict (provider, model_id) do update set supports_embedding = excluded.supports_embedding, embedding_dims = excluded.embedding_dims, supports_tools = excluded.supports_tools; -- RBAC nas quatro tabelas de RAG (formato da 0150). Elas ficaram de fora do -- aperto e ainda estão como o relatório de segurança da comunidade descreveu: -- policy `ALL` só-tenancy mais `GRANT ALL ... TO anon`. Um membro papel `viewer` -- DELETA a base de conhecimento da própria organização falando direto com o -- PostgREST, com o JWT dele. drop policy if exists tenant_isolation_ai_knowledge_sources_all on public.ai_knowledge_sources; drop policy if exists tenant_isolation_ai_knowledge_sources_select on public.ai_knowledge_sources; create policy tenant_isolation_ai_knowledge_sources_select on public.ai_knowledge_sources for select using ( organization_id in (select public.fn_user_org_ids()) or public.fn_is_platform_admin() ); drop policy if exists tenant_isolation_ai_knowledge_sources_write on public.ai_knowledge_sources; create policy tenant_isolation_ai_knowledge_sources_write on public.ai_knowledge_sources for all using ( (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'manager')) or public.fn_is_platform_admin() ) with check ( (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'manager')) or public.fn_is_platform_admin() ); drop policy if exists tenant_isolation_ai_faq_items_all on public.ai_faq_items; drop policy if exists tenant_isolation_ai_faq_items_select on public.ai_faq_items; create policy tenant_isolation_ai_faq_items_select on public.ai_faq_items for select using (organization_id in (select public.fn_user_org_ids())); drop policy if exists tenant_isolation_ai_faq_items_write on public.ai_faq_items; create policy tenant_isolation_ai_faq_items_write on public.ai_faq_items for all using ( organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'manager') ) with check ( organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'manager') ); drop policy if exists tenant_isolation_ai_kbv_all on public.ai_knowledge_versions; drop policy if exists tenant_isolation_ai_kbv_select on public.ai_knowledge_versions; create policy tenant_isolation_ai_kbv_select on public.ai_knowledge_versions for select using ( organization_id in (select public.fn_user_org_ids()) or public.fn_is_platform_admin() ); drop policy if exists tenant_isolation_ai_kbv_write on public.ai_knowledge_versions; create policy tenant_isolation_ai_kbv_write on public.ai_knowledge_versions for all using ( (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'admin')) or public.fn_is_platform_admin() ) with check ( (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'admin')) or public.fn_is_platform_admin() ); drop policy if exists tenant_isolation_ai_chunks_all on public.ai_chunks; drop policy if exists tenant_isolation_ai_chunks_select on public.ai_chunks; create policy tenant_isolation_ai_chunks_select on public.ai_chunks for select using ( organization_id in (select public.fn_user_org_ids()) or public.fn_is_platform_admin() ); drop policy if exists tenant_isolation_ai_chunks_write on public.ai_chunks; create policy tenant_isolation_ai_chunks_write on public.ai_chunks for all using ( (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'admin')) or public.fn_is_platform_admin() ) with check ( (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'admin')) or public.fn_is_platform_admin() ); revoke all on table public.ai_knowledge_sources from anon; revoke all on table public.ai_knowledge_versions from anon; revoke all on table public.ai_chunks from anon; revoke all on table public.ai_faq_items from anon; drop trigger if exists trg_ai_knowledge_sources_audit on public.ai_knowledge_sources; create trigger trg_ai_knowledge_sources_audit after insert or update or delete on public.ai_knowledge_sources for each row execute function public.fn_audit_log_row(); notify pgrst, 'reload schema'; -- --------------------------------------------------------------------------- -- 12. A telemetria de busca aprende QUEM perguntou e SOBRE O QUÊ -- --------------------------------------------------------------------------- -- -- `knowledge_searches` registrava organização, job, versão de índice, número de -- acertos, melhor nota e limiar. Faltava o que a torna acionável: QUAL -- assistente perguntou e em QUAIS materiais. Sem isso, "o recall está ruim" não -- tem como virar "o recall está ruim NAQUELE material", que é o conserto. -- -- `kb_version_id` passa a aceitar NULL porque a busca deixou de ser sobre UMA -- versão: ela é sobre um conjunto de materiais, cada um com o índice dele. -- -- A decisão declarada no cabeçalho da 0086 continua valendo: esta tabela NÃO -- guarda o texto da pergunta. Acrescentar ids é compatível com ela; acrescentar -- a pergunta seria PII contra a decisão. alter table public.knowledge_searches alter column kb_version_id drop not null; alter table public.knowledge_searches add column if not exists agent_id uuid references public.ai_agents(id) on delete set null; alter table public.knowledge_searches add column if not exists knowledge_source_ids uuid[] not null default '{}'::uuid[]; comment on column public.knowledge_searches.knowledge_source_ids is 'Materiais consultados nesta busca. Vazio nas linhas anteriores à 0181, quando a busca era sobre uma única versão de índice.'; notify pgrst, 'reload schema'; -- ---- o que ainda não foi ao Google (migration 0200) ---- -- O worker `agenda-google-push` pedia os pendentes com -- `.or("google_synced_at.is.null,updated_at.gt.google_synced_at")`, e o PostgREST -- trata o lado DIREITO de `gt.` como VALOR LITERAL: ele tentava converter a -- string "google_synced_at" em `timestamptz` e recusava a consulta INTEIRA. Em -- produção isso é um `warn` a cada 5 minutos desde o deploy da v1.7.0 e ZERO -- compromissos empurrados — a ida ao Google nunca aconteceu em instalação -- nenhuma. A coluna derivada é o que dá ao PostgREST um filtro que ele sabe -- fazer (`.eq("needs_google_push", true)`). -- -- ⚠️ O TRIGGER NÃO É ENFEITE — sem ele o conserto troca "nunca empurra" por -- "empurra para sempre". `updated_at` vem do `now()` do POSTGRES (trigger) e -- `google_synced_at` vinha do `new Date()` do NODE, calculado antes de a -- requisição sair: o do Node é sempre ANTERIOR, então `updated_at > -- google_synced_at` continuava verdadeiro logo depois de uma sincronização -- bem-sucedida e a linha voltava à fila na rodada seguinte, para sempre. O -- trigger faz o carimbo sair do MESMO relógio dos dois lados. Ele não carimba -- quando o valor novo é NULL: zerar a coluna é como se força re-sincronização de -- propósito. -- -- Aditiva e idempotente: `add column if not exists` sobre coluna GERADA é no-op -- quando ela já existe, `create or replace function` e `drop trigger if exists` -- fazem o resto. Não há dado a curar — o valor é derivado das duas colunas que -- já estão lá e nasce correto para o histórico inteiro. -- -- ⚠️ CRIA FUNÇÃO, então entra ANTES da varredura de `anon` logo abaixo: função -- nova em `public` nasce exposta pelo ALTER DEFAULT PRIVILEGES, e depois da -- varredura ela ficaria sem a cura. Os `revoke` explícitos abaixo já a fecham -- nas duas origens; a varredura é a rede, não a trava. alter table public.calendar_appointments add column if not exists needs_google_push boolean generated always as (google_synced_at is null or updated_at > google_synced_at) stored; comment on column public.calendar_appointments.needs_google_push is 'Derivada: a linha ainda não foi ao Google, ou mudou depois da última ida. Existe porque o PostgREST não compara coluna com coluna — o filtro do worker de push é `.eq("needs_google_push", true)`.'; create or replace function public.fn_carimbar_ida_ao_google() returns trigger language plpgsql set search_path = public, pg_temp as $fn$ begin -- `now()` e não `new.updated_at`: os dois são o instante de início da -- transação, então o valor é o mesmo — e usar `now()` remove a dependência de -- ORDEM entre este trigger e o de `updated_at`. if new.google_synced_at is not null and (tg_op = 'INSERT' or new.google_synced_at is distinct from old.google_synced_at) then new.google_synced_at := now(); end if; return new; end $fn$; revoke execute on function public.fn_carimbar_ida_ao_google() from public, anon, authenticated; grant execute on function public.fn_carimbar_ida_ao_google() to service_role; drop trigger if exists trg_calendar_appointments_carimbo_do_google on public.calendar_appointments; create trigger trg_calendar_appointments_carimbo_do_google before insert or update on public.calendar_appointments for each row execute function public.fn_carimbar_ida_ao_google(); create index if not exists calendar_appointments_pendente_no_google_idx on public.calendar_appointments (starts_at) where needs_google_push and owner_user_id is not null; -- ---- credencial do Google pela tela (migration 0201) ---- -- Conectar o Google exigia SSH na VPS e editar o `.env`. O produto é self-host -- para quem NÃO programa: nomear variáveis de ambiente para essa pessoa é o -- mesmo que dizer que a funcionalidade não existe. -- -- Singleton de INSTALAÇÃO, clone do molde de `platform_branding` (0155): o -- `redirect_uri` sai de `NEXT_PUBLIC_APP_URL` e o app OAuth é registrado no -- console do Google pelo dono da instalação — a credencial pareia 1:1 com ela. -- -- ⚠️ RLS LIGADA COM ZERO POLICIES é o desenho, não descuido. A anon key vai para -- o browser; tabela servida pelo PostgREST e "protegida por policy" depende de a -- policy estar certa, esta simplesmente não é servida. O `client_secret` permite -- trocar códigos e refresh tokens em nome da instalação — ou seja, ler a agenda -- de todos os atendentes que conectaram. -- -- Não cria função: usa `fn_encrypt_oauth`/`fn_decrypt_oauth` da 0041, a mesma -- cifra que o callback do Google já usa para os tokens. Entra antes da varredura -- de `anon` pela regra do arquivo, não por necessidade. -- -- Aditiva e idempotente: `create table if not exists`, `revoke`/`grant` que -- reafirmam, `drop trigger if exists` antes de recriar. Sem dado a curar. create table if not exists public.platform_google_oauth ( id smallint primary key default 1, client_id text, client_secret_encrypted bytea, updated_at timestamptz not null default now(), updated_by uuid, constraint platform_google_oauth_singleton check (id = 1) ); comment on table public.platform_google_oauth is 'O app OAuth do Google DESTA INSTALAÇÃO (singleton). Server-side only: RLS ligada sem policies e grants revogados de anon/authenticated — o PostgREST não a serve. O segredo nunca volta ao browser; a tela devolve apenas se existe.'; comment on column public.platform_google_oauth.client_secret_encrypted is 'Cifrado por fn_encrypt_oauth (pgp_sym_encrypt/aes256), a mesma cifra dos tokens em calendar_connections. Nunca gravar em claro: sem a chave mestra o save recusa.'; alter table public.platform_google_oauth enable row level security; revoke all on public.platform_google_oauth from anon, authenticated; grant select, insert, update on public.platform_google_oauth to service_role; drop trigger if exists trg_platform_google_oauth_updated_at on public.platform_google_oauth; create trigger trg_platform_google_oauth_updated_at before update on public.platform_google_oauth for each row execute function public.fn_set_updated_at(); -- ---- desnormaliza assigned_to_user_name em conversations (migration 0202) ---- -- -- GET /api/v1/conversations resolvia o nome do atendente via N chamadas ao -- GoTrue Admin API (uma por atendente único da página, medido em -- lib/users/nome-do-atendente.ts: ~1,2s para 50 atendentes). Toda atribuição -- de conversa passa por fn_conversation_assign (claim/release/transfer e o -- roteamento automático) — grava o nome ali, uma vez, no mesmo UPDATE que -- grava o id, em vez de replicar a resolução em 4 call sites TS. alter table public.conversations add column if not exists assigned_to_user_name text; comment on column public.conversations.assigned_to_user_name is 'Cópia do nome de quem atende (auth.users.raw_user_meta_data->>''full_name''), escrita por fn_conversation_assign no mesmo UPDATE que grava assigned_to_user_id, e zerada junto quando a atribuição é removida. Existe para evitar 1 chamada HTTP ao GoTrue Admin API por atendente único na listagem do Inbox — ver lib/users/nome-do-atendente.ts. NULL quando a conversa não está atribuída, ou quando o atendente não tem full_name em user_metadata.'; -- Backfill: só linhas já atribuídas, e só quando o nome ainda não está -- presente — não sobrescreve dado que uma reaplicação já preencheu. update public.conversations c set assigned_to_user_name = u.raw_user_meta_data ->> 'full_name' from auth.users u where c.assigned_to_user_id = u.id and c.assigned_to_user_name is null; create or replace function public.fn_conversation_assign( p_organization_id uuid, p_conversation_id uuid, p_to_user_id uuid, p_reason text, p_expected_assignee uuid default null, p_enforce_expected boolean default false ) returns setof public.conversations language plpgsql security definer set search_path = public as $$ declare v_from uuid; v_conv public.conversations%rowtype; begin if auth.uid() is not null and not public.fn_role_at_least(p_organization_id, 'agent') then raise exception 'caller_not_authorized_for_org' using hint = 'caller must be an active agent+ member of the organization'; end if; if p_to_user_id is not null then if coalesce(public.fn_member_role_in_org(p_to_user_id, p_organization_id), 'none') not in ('agent','manager','admin') then raise exception 'assignee_not_eligible_member' using hint = 'target must be an active agent+ member of the organization'; end if; end if; select assigned_to_user_id into v_from from public.conversations where id = p_conversation_id and organization_id = p_organization_id for update; if not found then return; end if; if p_enforce_expected and v_from is distinct from p_expected_assignee then return; end if; update public.conversations set assigned_to_user_id = p_to_user_id, -- Desnormalizado JUNTO com o dono, na mesma transação: nunca existe -- uma janela em que id e nome discordam. NULL junto com o id quando -- a atribuição é removida (release) — nunca sobra um nome órfão de -- dono nenhum. Lido de auth.users porque quem chama esta função -- (RPC) não necessariamente tem acesso ao Admin API — a definer -- resolve por dentro. assigned_to_user_name = case when p_to_user_id is null then null else (select raw_user_meta_data ->> 'full_name' from auth.users where id = p_to_user_id) end, assigned_at = case when p_to_user_id is null then null else now() end, assignee_kind = case when p_to_user_id is null then null else 'user' end, status = case when p_to_user_id is null then 'open' else 'claimed' end, status_changed_at = now(), unread_count_for_assignee = 0, bot_silenced_until = case when p_reason = 'routing' then bot_silenced_until when p_to_user_id is null then (case when last_handoff_at is null then null else bot_silenced_until end) else 'infinity'::timestamptz end, updated_at = now() where id = p_conversation_id returning * into v_conv; insert into public.conversation_assignment_events (organization_id, conversation_id, from_user_id, to_user_id, changed_by, reason) values (p_organization_id, p_conversation_id, v_from, p_to_user_id, auth.uid(), p_reason); return next v_conv; end; $$; revoke all on function public.fn_conversation_assign(uuid, uuid, uuid, text, uuid, boolean) from public; revoke execute on function public.fn_conversation_assign(uuid, uuid, uuid, text, uuid, boolean) from anon; grant execute on function public.fn_conversation_assign(uuid, uuid, uuid, text, uuid, boolean) to authenticated, service_role; -- ---- o banco passa a saber quem manda na conversa (migration 0203) ---- -- -- ## O defeito, medido na VPS do dono em 2026-08-30 -- -- As abas da Inbox descreviam QUEM MANDA lendo `conversations.status` cru. O -- motor de IA nunca lê essa coluna. Na base real: -- -- aba "IA" (?status=ai_handling) -> 2 conversas -- aba "Fila" (sem dono + status open|pending) -> 83 conversas -- o motor realmente atenderia -> 49 conversas -- -- `ai_handling` só é escrito por UM caminho em produção (a volta pelo botão -- "Devolver ao automático"), então a aba da IA ficava quase vazia enquanto o robô -- atendia quase tudo, e a Fila chamava de "aguardando atendente" o que o robô -- estava atendendo naquele instante. -- -- ## Por que a regra desce para o banco -- -- O filtro precisa de `contacts.force_human` e `contacts.is_blocked`, que moram em -- OUTRA tabela — reescrevê-lo no construtor de query seria a regra em duas -- encarnações, que é exatamente o defeito que o worker legado acabou de pagar -- (comparava `new Date('infinity')`, que é NaN, e a guarda nunca disparava). -- -- Como campo calculado, o predicado vira UM só: a lista, os contadores, o painel -- do gerente e o "você é o Nº da fila" que o cliente ouve pelo WhatsApp passam a -- perguntar a mesma coisa ao mesmo lugar, e o cursor de paginação continua -- intacto (filtrar em memória o quebraria). -- -- ## O que impede TS e SQL de divergirem -- -- `tests/invariants/comando-da-conversa-espelha-o-ts.test.ts` — produto cartesiano -- do espaço de entrada inteiro (o domínio de `status` é lido de `pg_constraint`, -- não digitado), comparado caso a caso com `comandoDaConversa()` de -- `lib/inbox/comando-da-conversa.ts`. Status novo no CHECK que o corpus não cubra -- REPROVA, em vez de sair da conta em silêncio. -- -- ## Duas funções, e a separação tem motivo -- -- `fn_comando_da_conversa` é a REGRA: `immutable`, sem tocar em tabela, com -- `p_agora` como parâmetro — é o que o teste de espelho consegue chamar com um -- relógio fixo, e sem isso o gate teria dois relógios e falharia de vez em quando -- sozinho. `comando_da_conversa(conversations)` é a EXPOSIÇÃO: resolve o contato e -- carimba `now()`; a assinatura de um argumento do tipo da tabela é o que faz o -- PostgREST publicá-la como campo calculado (medido no PostgREST 14.10: aparece em -- `?select=` e FILTRA em `?comando_da_conversa=in.(...)`). create or replace function public.fn_comando_da_conversa( p_status text, p_assigned_to_user_id uuid, p_bot_silenced_until timestamptz, p_force_human boolean, p_is_blocked boolean, p_agora timestamptz ) returns text language sql immutable set search_path = public as $fn_comando$ select case -- A ordem é a mesma de `comandoDaConversa`, e ela é o contrato: dono primeiro -- (a aba "Fechadas" precisa continuar dizendo QUEM atendeu), encerrada depois, -- e só então as travas. when p_assigned_to_user_id is not null then 'humano' when p_status in ('closed', 'archived', 'resolved') then 'encerrada' when p_force_human is true or p_is_blocked is true or (p_bot_silenced_until is not null and p_bot_silenced_until > p_agora) then 'aguardando' else 'automatico' end; $fn_comando$; comment on function public.fn_comando_da_conversa(text, uuid, timestamptz, boolean, boolean, timestamptz) is 'Quem manda na conversa. Espelho SQL de comandoDaConversa() (lib/inbox/comando-da-conversa.ts); as duas são casadas por tests/invariants/comando-da-conversa-espelha-o-ts.test.ts.'; create or replace function public.comando_da_conversa(c public.conversations) returns text language sql stable set search_path = public as $comando$ select public.fn_comando_da_conversa( c.status, c.assigned_to_user_id, c.bot_silenced_until, -- `coalesce` porque `contact_id` é anulável no schema: contato ausente não pode -- virar `null` e derrubar a linha inteira para fora de todo filtro — o efeito -- seria uma conversa invisível em TODAS as abas. coalesce((select ct.force_human from public.contacts ct where ct.id = c.contact_id), false), coalesce((select ct.is_blocked from public.contacts ct where ct.id = c.contact_id), false), now() ); $comando$; comment on function public.comando_da_conversa(public.conversations) is 'Campo calculado exposto pelo PostgREST: ?select=comando_da_conversa e ?comando_da_conversa=in.(...). Resolve o contato e carimba now(); a regra em si é fn_comando_da_conversa.'; -- Doutrina de migrations, regra 9: função nova em `public` nasce EXPOSTA, e são -- DUAS origens de EXECUTE. A varredura auto-curativa no fim deste arquivo NÃO -- alcança estas duas — o laço dela percorre só `p.prosecdef` (security definer), e -- estas são invoker de propósito (o campo calculado tem de respeitar a RLS de quem -- pergunta). Então a revogação é explícita aqui. -- (A metade `comando_da_conversa` foi para SECURITY DEFINER na 0404 — issue -- #1571, contagem das abas da Inbox a 823–846 ms reavaliando a RLS de `contacts` -- 2x por conversa —, com parâmetro SEM NOME para a PostgREST não a expor em -- `/rpc`. Quem aplica a virada é o APÊNDICE no fim deste arquivo; a varredura -- anon passou a alcançá-la e preserva os grants de `authenticated` e -- `service_role`. `fn_comando_da_conversa` segue fora da varredura: imutável, -- sem tocar em tabela, continua invoker.) revoke execute on function public.fn_comando_da_conversa(text, uuid, timestamptz, boolean, boolean, timestamptz) from public, anon; revoke execute on function public.comando_da_conversa(public.conversations) from public, anon; grant execute on function public.fn_comando_da_conversa(text, uuid, timestamptz, boolean, boolean, timestamptz) to authenticated, service_role; grant execute on function public.comando_da_conversa(public.conversations) to authenticated, service_role; -- Sem isto o campo existe no banco e o PostgREST segue servindo o schema velho: -- `?comando_da_conversa=...` volta 400 e a Inbox inteira fica vazia até alguém -- reiniciar o serviço à mão — que é justamente o passo manual que a doutrina de -- packaging proíbe pedir a quem opera uma VPS. notify pgrst, 'reload schema'; -- ---- o catálogo de produtos da loja (migration 0204) ---- create table if not exists public.catalog_products ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, -- O código do dono da loja (SKU, código interno). É por ele que a importação -- de planilha reconhece "isto é o mesmo produto, atualize" em vez de duplicar. codigo text not null, nome text not null, descricao text, marca text, categoria text, -- `_cents` + `moeda`, a regra do CLAUDE.md. `nuvemshop_products` não tem -- moeda e é a exceção errada, não o padrão: `orders` e `crm_leads` têm. preco_cents bigint not null, moeda text not null default 'BRL', -- O que a loja pagou. Existe para a regra de desconto do agente ter piso: sem -- custo, "pode dar 10%" é um número que ninguém sabe se cabe. Opcional porque -- muita loja não quer essa informação no sistema. custo_cents bigint, -- ⚠️ `controla_estoque` NÃO é firula, é o conserto de uma armadilha medida na -- tool antiga: ela filtra `available_qty > 0` por default, então uma loja que -- não conta estoque (decant de perfume, item sob encomenda) teria o catálogo -- INTEIRO invisível para o agente. Com este campo, quem não controla estoque -- continua aparecendo. controla_estoque boolean not null default true, quantidade integer not null default 0, ativo boolean not null default true, -- 'manual' | 'planilha' | 'nuvemshop'. Vocabulário ABERTO de propósito (sem -- CHECK): um clone com origem legada quebraria o `update.sh`, e a doutrina de -- migrations proíbe. Quem escreve usa a constante de `lib/catalogo/tipos.ts`. origem text not null default 'manual', imagem_url text, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), constraint catalog_products_preco_nao_negativo check (preco_cents >= 0), constraint catalog_products_custo_nao_negativo check (custo_cents is null or custo_cents >= 0), constraint catalog_products_quantidade_nao_negativa check (quantidade >= 0), constraint catalog_products_moeda_iso check (moeda ~ '^[A-Z]{3}$') ); -- O código é a identidade dentro da organização: é ele que a planilha reusa. create unique index if not exists catalog_products_org_codigo_key on public.catalog_products (organization_id, codigo); -- A lista da tela: ativos primeiro, depois por nome. create index if not exists catalog_products_org_ativos_idx on public.catalog_products (organization_id, ativo, nome); -- ⚠️ O ÍNDICE QUE FAZ A BUSCA DO AGENTE FUNCIONAR. -- -- O cliente escreve "ifone 15 pro 256", e o catálogo diz "iPhone 15 Pro 256GB". -- Medido em 20 mil títulos: `ilike '%ifone 15%'` devolve ZERO linhas, e a -- similaridade da frase inteira não separa 128GB de 256GB — que é exatamente -- onde o preço erra. A busca é por TOKEN (ver `lib/catalogo/busca.ts`), e o -- trigrama serve a parte difusa dela. create index if not exists catalog_products_nome_trgm on public.catalog_products using gin (nome public.gin_trgm_ops); alter table public.catalog_products enable row level security; -- Leitura para a organização; ESCRITA só de `manager` para cima. É o molde da -- 0177 (`calendar_event_types`), e é o que a tabela da Nuvemshop não tem: preço -- de venda não se altera com papel de leitura. drop policy if exists catalog_products_select on public.catalog_products; create policy catalog_products_select on public.catalog_products for select using ( (organization_id in (select public.fn_user_org_ids())) or public.fn_is_platform_admin() ); drop policy if exists catalog_products_write on public.catalog_products; create policy catalog_products_write on public.catalog_products using ( public.fn_is_platform_admin() or ((organization_id in (select public.fn_user_org_ids())) and public.fn_role_at_least(organization_id, 'manager')) ) with check ( public.fn_is_platform_admin() or ((organization_id in (select public.fn_user_org_ids())) and public.fn_role_at_least(organization_id, 'manager')) ); -- `ALTER DEFAULT PRIVILEGES ... GRANT ALL ON TABLES TO anon` do baseline alcança -- TODA tabela criada depois dele — inclusive esta. Sem o revoke, o catálogo -- inteiro fica legível pela anon key, que vai para o browser. revoke all on public.catalog_products from anon; grant select, insert, update, delete on public.catalog_products to authenticated; grant all on public.catalog_products to service_role; drop trigger if exists trg_catalog_products_updated_at on public.catalog_products; create trigger trg_catalog_products_updated_at before update on public.catalog_products for each row execute function public.fn_set_updated_at(); comment on table public.catalog_products is 'O catálogo que a LOJA possui — uma linha por item vendável, com o preço que o agente de IA responde. Distinto de nuvemshop_products, que é ESPELHO de uma loja remota: aqui a loja é a fonte da verdade.'; comment on column public.catalog_products.codigo is 'Código interno do dono (SKU). É a identidade que a importação de planilha reusa para atualizar em vez de duplicar.'; comment on column public.catalog_products.custo_cents is 'O que a loja pagou. Existe para a regra de desconto do agente ter piso — sem custo, um teto de desconto é um número que ninguém sabe se cabe.'; comment on column public.catalog_products.controla_estoque is 'false = item que não se conta (decant, sob encomenda). A busca do agente não o esconde por quantidade zero.'; -- ---- versão de acervo conta por MATERIAL, não por agente (migration 0205) ---- -- -- O índice `ai_kbv_version_unique` era `(agent_id, version_number)`, mas desde a -- 0181 o número é contado por `knowledge_source_id`. Toda fonte nova nasce com -- `version_number = 1`, então a SEGUNDA fonte do mesmo agente colidia com a -- primeira e nunca indexava — a tela dizia "pronto" e `chunks_count` ficava 0. -- Determinístico, não corrida. Medido em produção: 5 materiais, 1 indexou. -- -- Dois índices parciais porque há dois regimes: versões anteriores à 0181 têm -- `knowledge_source_id` NULL e guardam o invariante antigo (por agente); sem o -- segundo índice elas ficariam sem restrição, já que NULL não colide com NULL. delete from public.ai_knowledge_versions v where v.knowledge_source_id is not null and exists ( select 1 from public.ai_knowledge_versions o where o.knowledge_source_id = v.knowledge_source_id and o.version_number = v.version_number and o.id < v.id ); alter table public.ai_knowledge_versions drop constraint if exists ai_kbv_version_unique; drop index if exists public.ai_kbv_version_unique; create unique index if not exists ai_kbv_version_por_fonte on public.ai_knowledge_versions (knowledge_source_id, version_number) where knowledge_source_id is not null; create unique index if not exists ai_kbv_version_por_agente_legado on public.ai_knowledge_versions (agent_id, version_number) where knowledge_source_id is null; -- ---- a moeda da organização deixa de ser presumida (migration 0208) ---- -- -- O produto inteiro presumia real, e a presunção não morava em lugar nenhum -- que alguém pudesse mudar: `catalog_products.moeda` nasce 'BRL' e nenhuma -- tela oferece outra coisa (o formulário de produto não tem o campo, a -- planilha não tem a coluna). Uma loja no México cadastrava em pesos, o banco -- guardava 'BRL', e o agente cotava o número com o símbolo errado. -- -- Coluna e não `settings` jsonb: é a mesma classe de `locale` e `timezone`, -- que já são colunas desta tabela. Nome `currency` e não `moeda` porque é o -- que a doutrina manda (`_cents` + `currency`) e o que `crm_leads` e `orders` -- já usam — `catalog_products.moeda` é o desvio, e renomear coluna já -- distribuída quebraria o update.sh de quem instalou. -- -- O CHECK é de FORMA (ISO-4217), não de vocabulário fechado: por isso fica -- fora do invariante vocabulario-banco-x-typescript, como o irmão -- `catalog_products_moeda_iso`. alter table public.organizations add column if not exists currency text not null default 'BRL'; -- Auto-curativo e ANTES da constraint: num clone onde a coluna já exista nula -- ou com lixo, criar o CHECK primeiro quebraria o update.sh no meio. update public.organizations set currency = 'BRL' where currency is null or currency !~ '^[A-Z]{3}$'; alter table public.organizations alter column currency set default 'BRL'; alter table public.organizations alter column currency set not null; do $$ begin if not exists ( select 1 from pg_constraint where conname = 'organizations_currency_iso' and conrelid = 'public.organizations'::regclass ) then alter table public.organizations add constraint organizations_currency_iso check (currency ~ '^[A-Z]{3}$'); end if; end $$; comment on column public.organizations.currency is 'Moeda do negócio desta organização, ISO-4217. CONTRATO: é a fonte na ESCRITA — o produto herda esta moeda no cadastro, e a moeda que venha no corpo da requisição não decide (corpo não decide unidade, como não decide escopo). A linha do produto guarda a moeda com que nasceu: pedido pago em BRL não vira MXN depois.'; -- ---- elegibilidade da IA por origem do lead (migration 0206) ---- -- -- Gate OPT-IN por canal (`channel_sessions.metadata.ai_gate = 'allowlist'`): -- ausente / 'open' = comportamento de hoje (a IA responde todo inbound quando há -- agente publicado), nenhum self-hoster afetado. Com 'allowlist', a IA só -- responde quando o CONTATO está autorizado, e é isto que estas colunas guardam. -- Contact-level como `force_human` (a trava oposta). Aditiva e idempotente: -- colunas anuláveis, sem default, sem constraint — nenhuma linha existente viola -- nada. RLS de `contacts` já cobre (row-level); coluna nova não precisa policy. alter table public.contacts add column if not exists ai_authorized_at timestamptz; alter table public.contacts add column if not exists ai_authorized_reason text; comment on column public.contacts.ai_authorized_at is 'Elegibilidade da IA (gate opt-in channel_sessions.metadata.ai_gate=allowlist): quando o contato foi autorizado a ser atendido automaticamente. NULL = não autorizado, a IA não responde. Renovado a cada turno autorizado enquanto a conversa está viva.'; comment on column public.contacts.ai_authorized_reason is 'Origem da autorização de IA: respondi:
: | campanha: | automacao: | retomada_manual.'; notify pgrst, 'reload schema'; -- ---- Configuração atômica do pré-go-live (migration 0218) ---- -- 0218 · Configuração atômica do pré-go-live do canal -- -- `channel_sessions.metadata` também guarda dados de transporte e de operação. -- Ler o jsonb no servidor, espalhar em memória e gravar o objeto inteiro faria -- dois salvamentos concorrentes apagarem a alteração um do outro. Esta função -- muda somente as três chaves que pertencem ao pré-go-live, numa instrução. create or replace function public.fn_configurar_pre_go_live_canal( p_org uuid, p_canal uuid, p_modo text, p_numeros text[] ) returns integer language plpgsql security invoker set search_path = '' as $$ declare v_linhas integer; v_gate text; begin if p_modo is null or p_modo not in ('open', 'pre_go_live') then raise exception 'modo de acesso da IA inválido' using errcode = '22023'; end if; if p_numeros is null or exists ( select 1 from unnest(p_numeros) as n(numero) where numero is null or numero !~ '^\+[1-9][0-9]{7,14}$' ) then raise exception 'lista de telefones de teste inválida' using errcode = '22023'; end if; v_gate := case when p_modo = 'pre_go_live' then 'allowlist' else 'open' end; update public.channel_sessions set metadata = jsonb_set( jsonb_set( jsonb_set(coalesce(metadata, '{}'::jsonb), '{ai_gate}', to_jsonb(v_gate), true), '{ai_gate_mode}', to_jsonb('pre_go_live'::text), true ), '{ai_test_phone_numbers}', to_jsonb(p_numeros), true ) where organization_id = p_org and id = p_canal and archived_at is null; get diagnostics v_linhas = row_count; return v_linhas; end; $$; revoke execute on function public.fn_configurar_pre_go_live_canal(uuid, uuid, text, text[]) from public, anon, authenticated; grant execute on function public.fn_configurar_pre_go_live_canal(uuid, uuid, text, text[]) to service_role; notify pgrst, 'reload schema'; -- ---- mover em lote sem colidir posição (migration 0209) ---- -- -- A barra de ações em lote mandava UM `position_in_stage` para o lote inteiro, e -- o handler o gravava em N linhas: trinta cards movidos terminavam com o MESMO -- número na etapa de destino. `midpoint(prev, next)` devolve NaN quando os dois -- vizinhos são iguais (`lib/kanban/fractional-indexing.ts`) — então o primeiro -- arrasto para ENTRE dois cards do lote mandava NaN como posição, e antes disso -- a ordem entre eles já era indefinida. -- -- Esta função dá a cada card do lote uma posição DISTINTA (piso da etapa de -- destino + 1000 por card, na ordem em que estavam no quadro) num único -- `update` — o que também torna o lote atômico: move todos ou nenhum. -- -- `security INVOKER`: a RLS de crm_leads é o piso. `p_organization_id` é o -- escopo explícito que a doutrina exige (org do cookie, nunca do body). -- Idempotente: `create or replace`, nenhuma coluna, nenhum dado da instalação -- tocado. drop function if exists public.fn_mover_leads_em_lote(uuid, uuid[], uuid); create or replace function public.fn_mover_leads_em_lote( p_organization_id uuid, p_lead_ids uuid[], p_stage_id uuid, p_lost_reason text default null ) returns table (lead_id uuid, from_stage_id uuid, pipeline_id uuid) language plpgsql set search_path = public as $$ declare v_piso numeric; -- Motivo em branco é ausência de motivo, nunca um motivo de uma letra. v_motivo text := nullif(btrim(coalesce(p_lost_reason, '')), ''); v_coluna_motivo text := ''; begin -- `coalesce(..., 0)` cobre a etapa vazia; o DEFAULT da coluna é 1000, então -- o primeiro card de um lote para uma etapa vazia cai em 1000, como um card -- criado à mão. select coalesce(max(l.position_in_stage), 0) into v_piso from public.crm_leads l where l.organization_id = p_organization_id and l.stage_id = p_stage_id and not (l.id = any(p_lead_ids)); -- Só com motivo a gravar a coluna entra na escrita (ver o cabeçalho). if v_motivo is not null then v_coluna_motivo := ', lost_reason = $4'; end if; return query execute format($f$ with alvo as ( select l.id, l.stage_id as from_stage_id, l.pipeline_id as pipeline_id, -- A ordem do lote no destino é a ordem em que ele estava no quadro: -- etapa, depois posição. `id` só desempata para o resultado ser -- determinístico (dois cards podem legitimamente empatar hoje — -- é justamente o estado que a migration 0209 deixa de produzir). row_number() over (order by l.stage_id, l.position_in_stage, l.id) as ordem from public.crm_leads l where l.organization_id = $1 and l.id = any($2) ), movidos as ( update public.crm_leads l set stage_id = $3, position_in_stage = $5 + (a.ordem * 1000), updated_at = now()%s from alvo a where l.id = a.id and l.organization_id = $1 returning l.id, a.from_stage_id, a.pipeline_id ) select m.id, m.from_stage_id, m.pipeline_id from movidos m $f$, v_coluna_motivo) using p_organization_id, p_lead_ids, p_stage_id, v_motivo, v_piso; end; $$; comment on function public.fn_mover_leads_em_lote(uuid, uuid[], uuid, text) is 'Move um lote de leads para uma etapa dando a cada um posição DISTINTA (piso da etapa de destino + 1000 por card, na ordem em que estavam no quadro). Existe porque gravar a mesma position_in_stage em N linhas quebra o midpoint() do arrasto seguinte (prev === next → NaN) e deixa a ordem do quadro indefinida. `p_lost_reason` (0263, issue #917) grava o motivo da perda na MESMA escrita quando a etapa de destino é de perda — sem ele a CHECK crm_leads_lost_reason_required recusava o lote inteiro com 23514; a coluna só entra na escrita quando há motivo, para não revalidar o valor que já estava na linha. Devolve uma linha por card movido, com a etapa de ORIGEM, para o handler emitir a atividade de timeline de cada um.'; revoke all on function public.fn_mover_leads_em_lote(uuid, uuid[], uuid, text) from public; revoke execute on function public.fn_mover_leads_em_lote(uuid, uuid[], uuid, text) from anon; grant execute on function public.fn_mover_leads_em_lote(uuid, uuid[], uuid, text) to authenticated, service_role; -- ---- juntar contatos duplicados (migration 0215) ---- -- Apêndice DERIVADO do arquivo da migration, não copiado à mão: o corpo abaixo é -- `supabase/migrations/20260904190000_0215_juntar_contatos_duplicados.sql` na -- íntegra. Ele já é idempotente e auto-curativo (`create or replace function` + -- revoke/grant), então re-aplicar num clone pelo `update.sh` é seguro. -- 0215 — juntar contatos duplicados sem perder histórico. -- -- ─── O que estava faltando ────────────────────────────────────────────────── -- A coluna `contacts.is_merged_into` existe desde a 0003 e é o que faz os três -- índices únicos parciais (telefone, e-mail, CPF) tolerarem o registro perdedor. -- Quem a escreve, hoje, é UMA data migration de mão única no apêndice do -- baseline (a dedup por `wa_identity` da 0027) — não há caminho para quem opera -- fundir dois cadastros. `merge_queue` está no schema desde a 0003 sem nenhum -- produtor, `contact.merged` está no vocabulário de auditoria sem nenhum -- emissor, e `components/contacts/MergeDialog.tsx` diz ao operador, na tela, -- "mesclar via SQL". Esta migration é o produtor que faltava para os três. -- -- A própria `fn_upsert_wa_contact` (apêndice da 0164) documenta a lacuna: quando -- o webhook descobre que o contato @lid tem um telefone que já é de outro -- contato vivo, ela NÃO funde — "fusão é IRREVERSÍVEL, e a regra do tempo da -- doutrina proíbe consumar irreversível no tempo da máquina, dentro de um -- webhook (...) a decisão de fundir fica para quem opera". Ela parkou o número -- em `source_metadata.telefone_em_conflito` esperando exatamente por isto. -- -- ─── O cuidado central: repontar FK pelo CATÁLOGO, não por lista ──────────── -- A doutrina de migrations manda "repointe FKs conferindo o catálogo -- (information_schema FK map) para não perder histórico". Uma lista de tabelas -- escrita à mão envelhece em silêncio: a tabela que alguém criar amanhã -- apontando para `contacts` não entra nela, e o histórico dela fica pendurado no -- perdedor sem ninguém perceber. Aqui a lista é DERIVADA de `pg_constraint` a -- cada execução — a fusão de amanhã já conhece a tabela de amanhã. -- -- O ponteiro POLIMÓRFICO é a exceção que o catálogo não enxerga -- (`crm_lead_links.target_id` com `target_kind='contact'` não é FK), e por isso -- entra explicitamente na mesma lista, com filtro próprio. É o preço do -- anti-pattern nº 8 já pago pelo schema; o que não se pode é fingir que não há. -- -- ─── Por que o perdedor NÃO é apagado ─────────────────────────────────────── -- Ele vira LÁPIDE: `is_merged_into` + `merged_at`. Duas consequências que um -- `delete` não tem: (1) nenhuma FK fica órfã mesmo que alguma linha não consiga -- ser repontada, porque a linha apontada continua existindo; (2) os índices -- únicos parciais liberam telefone/e-mail/CPF para o vencedor, que é o que -- permite completar os buracos dele logo em seguida. -- -- ─── Vocabulário ──────────────────────────────────────────────────────────── -- Nada aqui conhece nicho. Contato é contato em e-commerce, clínica, -- imobiliária e infoproduto; o `vocabulary` do funil renomeia lead/deal, não -- pessoa. create or replace function public.fn_mesclar_contatos( p_organization_id uuid, p_contato_principal uuid, p_contatos_secundarios uuid[] ) returns jsonb language plpgsql security definer set search_path = '' as $$ declare v_principal public.contacts%rowtype; v_esperado integer; v_achado integer; v_alvo record; v_linha record; v_movidas integer; v_pulados integer; v_repontado jsonb := '{}'::jsonb; v_nao_repontado jsonb := '{}'::jsonb; v_nome text; v_apelido text; v_nascimento date; v_email text; v_telefone text; v_lid text; v_tags text[]; v_leads integer := 0; begin -- 1 · Autorização. Fundir é destrutivo na prática: `manager`, o mesmo piso das -- policies de `merge_queue`. Sessão de service role (auth.uid() nulo) não -- passa por aqui — quem resolve a org nesse caminho é a rota, de fonte -- confiável, nunca do body. if auth.uid() is not null and not public.fn_role_at_least(p_organization_id, 'manager') then raise exception using errcode = '42501', message = 'insufficient_role'; end if; if p_contato_principal is null or p_contatos_secundarios is null or cardinality(p_contatos_secundarios) = 0 or p_contato_principal = any(p_contatos_secundarios) then raise exception using errcode = '22023', message = 'selecao_de_mesclagem_invalida'; end if; select count(distinct id)::integer into v_esperado from unnest(p_contatos_secundarios) as ids(id); if v_esperado <> cardinality(p_contatos_secundarios) then raise exception using errcode = '22023', message = 'secundario_repetido'; end if; -- 2 · O principal existe, é desta org, está vivo — e trava até o fim. select * into v_principal from public.contacts where id = p_contato_principal and organization_id = p_organization_id and is_merged_into is null and is_anonymized = false for update; if not found then raise exception using errcode = 'P0002', message = 'contato_principal_indisponivel'; end if; -- 3 · Os secundários também. `is_anonymized = false` não é zelo: L-04 é -- irreversível, e reencaixar a linha anonimizada num contato ativo a -- traria de volta ao atendimento pela porta dos fundos. perform 1 from public.contacts where id = any(p_contatos_secundarios) and organization_id = p_organization_id and is_merged_into is null and is_anonymized = false for update; get diagnostics v_achado = row_count; if v_achado <> v_esperado then raise exception using errcode = 'P0002', message = 'contato_secundario_indisponivel'; end if; -- 4 · A LÁPIDE VEM ANTES de tudo. É ela que solta telefone/e-mail/CPF dos -- índices únicos parciais para o vencedor poder herdá-los no passo 6. update public.contacts set is_merged_into = p_contato_principal, merged_at = now(), updated_at = now() where organization_id = p_organization_id and id = any(p_contatos_secundarios); -- Cadeia: quem já tinha sido mesclado NUM dos secundários passa a apontar para -- o vencedor. Sem isto, `is_merged_into` vira uma corrente que a leitura teria -- de percorrer, e ninguém percorre. update public.contacts set is_merged_into = p_contato_principal where organization_id = p_organization_id and is_merged_into = any(p_contatos_secundarios); -- 5 · Reponta TODO ponteiro para os perdedores. A lista sai do catálogo; o -- polimórfico entra à mão porque catálogo nenhum o conhece. for v_alvo in select n.nspname as esquema, c.relname as tabela, a.attname as coluna, ''::text as filtro from pg_catalog.pg_constraint co join pg_catalog.pg_class c on c.oid = co.conrelid join pg_catalog.pg_namespace n on n.oid = c.relnamespace join pg_catalog.pg_attribute a on a.attrelid = co.conrelid and a.attnum = co.conkey[1] where co.contype = 'f' and co.confrelid = 'public.contacts'::regclass and co.conrelid <> 'public.contacts'::regclass and array_length(co.conkey, 1) = 1 and c.relkind = 'r' and n.nspname = 'public' union all select 'public', 'crm_lead_links', 'target_id', ' and target_kind = ''contact''' where to_regclass('public.crm_lead_links') is not null order by 2, 3 loop v_pulados := 0; begin execute format( 'update %I.%I set %I = $1 where %I = any($2)%s', v_alvo.esquema, v_alvo.tabela, v_alvo.coluna, v_alvo.coluna, v_alvo.filtro ) using p_contato_principal, p_contatos_secundarios; get diagnostics v_movidas = row_count; exception when unique_violation or exclusion_violation then -- Colisão REAL e esperada: `uniq_job_queue_one_running_per_contact` deixa -- um job 'running' por contato, e os dois lados podem ter um. Em vez de -- abortar a fusão inteira por causa de estado efêmero de runtime, reponta -- linha a linha e conta quem ficou. Quem fica NÃO vira FK órfã — continua -- apontando para a lápide, que existe. v_movidas := 0; for v_linha in execute format( 'select ctid as tid from %I.%I where %I = any($1)%s', v_alvo.esquema, v_alvo.tabela, v_alvo.coluna, v_alvo.filtro ) using p_contatos_secundarios loop begin execute format( 'update %I.%I set %I = $1 where ctid = $2', v_alvo.esquema, v_alvo.tabela, v_alvo.coluna ) using p_contato_principal, v_linha.tid; v_movidas := v_movidas + 1; exception when unique_violation or exclusion_violation then v_pulados := v_pulados + 1; end; end loop; end; if v_movidas > 0 then v_repontado := v_repontado || jsonb_build_object(v_alvo.tabela || '.' || v_alvo.coluna, v_movidas); end if; if v_pulados > 0 then v_nao_repontado := v_nao_repontado || jsonb_build_object(v_alvo.tabela || '.' || v_alvo.coluna, v_pulados); end if; end loop; -- 6 · O principal MANDA; o que ele não tem, vem dos perdedores. Nunca o -- contrário: sobrescrever o que o atendente digitou seria fusão com -- surpresa, e fusão não tem desfazer. select c.name into v_nome from public.contacts c where c.id = any(p_contatos_secundarios) and c.name is not null order by c.created_at, c.id limit 1; select c.display_name into v_apelido from public.contacts c where c.id = any(p_contatos_secundarios) and c.display_name is not null order by c.created_at, c.id limit 1; select c.birthdate into v_nascimento from public.contacts c where c.id = any(p_contatos_secundarios) and c.birthdate is not null order by c.created_at, c.id limit 1; select c.email into v_email from public.contacts c where c.id = any(p_contatos_secundarios) and c.email is not null order by c.created_at, c.id limit 1; select c.phone_number into v_telefone from public.contacts c where c.id = any(p_contatos_secundarios) and c.phone_number is not null order by c.created_at, c.id limit 1; -- `wa_identity`/`wa_lid` são GERADAS: o que se herda é a origem delas. Sem -- isto o WhatsApp do perdedor fica órfão — `fn_upsert_wa_contact` filtra -- `is_merged_into is null`, não acharia mais ninguém e criaria um contato -- novo na mensagem seguinte, refazendo a duplicata que acabou de ser desfeita. select c.source_metadata->>'waha_lid' into v_lid from public.contacts c where c.id = any(p_contatos_secundarios) and c.source_metadata->>'waha_lid' is not null order by c.created_at, c.id limit 1; -- Guardas de unicidade. A lápide já tirou os perdedores dos índices parciais, -- então o que sobrar aqui é conflito com um TERCEIRO contato vivo — e nesse -- caso o vencedor simplesmente não herda o campo. Falhar a fusão inteira por -- causa de um e-mail seria perder o repontamento que já valeu a pena. if v_email is not null and exists ( select 1 from public.contacts o where o.organization_id = p_organization_id and o.is_merged_into is null and o.id <> p_contato_principal and o.email_normalized = lower(btrim(v_email)) ) then v_email := null; end if; if v_telefone is not null and exists ( select 1 from public.contacts o where o.organization_id = p_organization_id and o.is_merged_into is null and o.id <> p_contato_principal and o.phone_number = v_telefone ) then v_telefone := null; end if; if v_lid is not null and exists ( select 1 from public.contacts o where o.organization_id = p_organization_id and o.is_merged_into is null and o.id <> p_contato_principal and o.wa_lid = v_lid ) then v_lid := null; end if; select coalesce(array_agg(distinct t), '{}'::text[]) into v_tags from ( select unnest(c.tags) as t from public.contacts c where c.organization_id = p_organization_id and (c.id = p_contato_principal or c.id = any(p_contatos_secundarios)) ) as todas; -- CPF e `consent` NÃO são herdados, de propósito. CPF é um PAR -- (`cpf_encrypted` + `cpf_hash`) preso por check constraint e criptografado -- com a chave da instalação — mover metade quebra a linha. `consent` é -- registro legal do que AQUELA pessoa autorizou; herdar um "granted_at" de -- outro cadastro fabricaria consentimento. Falha fechada nos dois. update public.contacts set name = coalesce(name, v_nome), display_name = coalesce(display_name, v_apelido), birthdate = coalesce(birthdate, v_nascimento), email = coalesce(email, v_email), phone_number = coalesce(phone_number, v_telefone), tags = v_tags, last_activity_at = greatest( last_activity_at, (select max(c.last_activity_at) from public.contacts c where c.id = any(p_contatos_secundarios)) ), source_metadata = ( case when source_metadata->>'waha_lid' is null and v_lid is not null then source_metadata || jsonb_build_object('waha_lid', v_lid) else source_metadata end ) - case when coalesce(phone_number, v_telefone) is not null then 'telefone_em_conflito' else '' end || jsonb_build_object( 'mesclado_de', coalesce(source_metadata->'mesclado_de', '[]'::jsonb) || to_jsonb(p_contatos_secundarios), 'mesclado_em', to_jsonb(now()) ), updated_at = now() where id = p_contato_principal and organization_id = p_organization_id; -- 7 · A fusão aparece na timeline de cada negócio que o vencedor passou a ter. -- `crm_lead_activities.lead_id` é NOT NULL — contato sem negócio nenhum -- não tem onde escrever, e para esse caso quem guarda o rastro é o -- `api_audit_log` que a rota emite, sempre. insert into public.crm_lead_activities (organization_id, lead_id, contact_id, source_module, source_id, type, payload, metadata, performed_at, performed_by_user_id) select p_organization_id, l.id, p_contato_principal, 'crm', p_contato_principal, 'contacts_merged', jsonb_build_object( 'contatos_mesclados', to_jsonb(p_contatos_secundarios), 'repontado', v_repontado, 'nao_repontado', v_nao_repontado ), '{}'::jsonb, now(), auth.uid() from public.crm_leads l where l.organization_id = p_organization_id and l.contact_id = p_contato_principal; get diagnostics v_leads = row_count; return jsonb_build_object( 'contato_id', p_contato_principal, 'contatos_mesclados', to_jsonb(p_contatos_secundarios), 'repontado', v_repontado, 'nao_repontado', v_nao_repontado, 'atividades_emitidas', v_leads ); end; $$; -- Função nova em `public` nasce EXPOSTA por DUAS origens (o `ALTER DEFAULT -- PRIVILEGES ... TO anon` do baseline e o grant a PUBLIC que o Postgres dá a -- toda função). Revogar só uma deixa a RPC alcançável pela anon key do browser. revoke execute on function public.fn_mesclar_contatos(uuid, uuid, uuid[]) from public, anon; grant execute on function public.fn_mesclar_contatos(uuid, uuid, uuid[]) to authenticated, service_role; notify pgrst, 'reload schema'; -- ---- tarefas do CRM (migration 0210) ---- -- -- Lembrete de trabalho interno com prazo. O racional inteiro — por que a -- Agenda (0177) não serve, por que `due_date` é nullable e por que são DUAS -- policies em vez de uma `for all` — está no cabeçalho da migration 0210. -- Idempotente: `if not exists` em tabela e índices, `drop ... if exists` -- antes de cada policy e do trigger, para o `update.sh` de um clone poder -- reaplicar este arquivo inteiro sem erro. create table if not exists public.crm_tasks ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, title text not null, description text, -- Nula = "sem prazo". Ver o cabeçalho: forçar data envenena a lista de -- atrasadas, que é a única razão de a coluna existir. due_date timestamptz, priority text not null default 'medium', status text not null default 'pending', -- Os dois vínculos são opcionais: tarefa solta ("revisar os textos do -- agente") é caso real, e negá-la obrigaria a inventar um lead. lead_id uuid references public.crm_leads(id) on delete set null, contact_id uuid references public.contacts(id) on delete set null, -- FK de verdade, não texto. Anti-pattern nº 1 do CLAUDE.md: `owner_email text` -- vira inferência por nome no dia em que alguém troca de e-mail. assigned_to uuid references auth.users(id) on delete set null, created_by uuid references auth.users(id) on delete set null, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), constraint crm_tasks_titulo_nao_vazio check (length(btrim(title)) > 0), constraint crm_tasks_priority_check check (priority in ('low','medium','high','urgent')), constraint crm_tasks_status_check check (status in ('pending','in_progress','done','cancelled')) ); -- A consulta da tela: "o que vence, na minha organização, em ordem de prazo". create index if not exists crm_tasks_org_due_idx on public.crm_tasks (organization_id, due_date); -- O filtro que a lista aplica antes de tudo: só o que ainda está em aberto. create index if not exists crm_tasks_org_status_idx on public.crm_tasks (organization_id, status); -- As tarefas de UM negócio, para o painel do lead. Parcial porque a maioria das -- linhas não tem lead, e indexar NULL aqui só engorda o índice. create index if not exists crm_tasks_lead_idx on public.crm_tasks (lead_id) where lead_id is not null; alter table public.crm_tasks enable row level security; drop policy if exists crm_tasks_select on public.crm_tasks; create policy crm_tasks_select on public.crm_tasks for select using ( (organization_id in (select public.fn_user_org_ids())) or public.fn_is_platform_admin() ); drop policy if exists crm_tasks_write on public.crm_tasks; create policy crm_tasks_write on public.crm_tasks using ( public.fn_is_platform_admin() or ((organization_id in (select public.fn_user_org_ids())) and public.fn_role_at_least(organization_id, 'agent')) ) with check ( public.fn_is_platform_admin() or ((organization_id in (select public.fn_user_org_ids())) and public.fn_role_at_least(organization_id, 'agent')) ); -- `ALTER DEFAULT PRIVILEGES ... GRANT ALL ON TABLES TO anon` do baseline alcança -- TODA tabela criada depois dele — inclusive esta. Sem o revoke, as tarefas da -- organização ficam legíveis pela anon key, que vai para o browser. revoke all on public.crm_tasks from anon; grant select, insert, update, delete on public.crm_tasks to authenticated; grant all on public.crm_tasks to service_role; drop trigger if exists trg_crm_tasks_updated_at on public.crm_tasks; create trigger trg_crm_tasks_updated_at before update on public.crm_tasks for each row execute function public.fn_set_updated_at(); comment on table public.crm_tasks is 'Lembrete de trabalho INTERNO com prazo — "ligar de volta na terça". Distinto de calendar_appointments (0177), que é compromisso COM o cliente, com horário, local e confirmação.'; comment on column public.crm_tasks.due_date is 'Nula = sem prazo. Forçar data faria o operador inventar uma, e um prazo inventado envenena a lista de atrasadas.'; comment on column public.crm_tasks.lead_id is 'set null, não cascade: apagar o funil não pode apagar o que a pessoa escreveu para si mesma.'; -- ───────────────────────────────────────────────────────────────────────────── -- LGPD: a anonimização do contato alcança as tarefas dele -- -- `crm_tasks` tem FK para `contacts` e guarda `title` — texto livre que, na -- prática, é "Ligar para Fulano confirmar o orçamento". Sem isto, anonimizar um -- contato devolveria SUCESSO, a contagem por tabela fecharia, o SLA de D+15 -- seria marcado como cumprido, e o nome de quem exerceu o direito de -- apagamento continuaria legível. Nada erra e nada loga — é o modo de falha que -- `tests/invariants/lgpd-cascata-alcanca-quem-guarda-pessoa.test.ts` existe -- para pegar, e foi ELE que pegou esta tabela. -- -- TRIGGER e não um passo dentro de `fn_lgpd_cascade_redact_contact`, pelo mesmo -- motivo escrito nas migrations 0174 e 0184: aquela função vem do dump com ~180 -- linhas, e acrescentar um passo obrigaria a carregar uma CÓPIA inteira dela no -- apêndice do baseline — duas cópias que divergem no primeiro conserto. O -- gancho é a transição `is_anonymized false → true` na própria `contacts`, que -- é o último fato da anonimização, roda na MESMA transação, e alcança QUALQUER -- caminho que anonimize um contato, não só o cascade. -- -- O que é PRESERVADO: prazo, situação, prioridade e o vínculo com o negócio. -- Que houve uma tarefa, e quando ela venceu, é registro de operação — não é -- dado da pessoa. create or replace function public.fn_redigir_tarefas_do_contato_anonimizado() returns trigger language plpgsql security definer set search_path = public, pg_temp as $$ begin update public.crm_tasks set title = 'Tarefa anonimizada', description = null where organization_id = new.organization_id and contact_id = new.id; return new; end; $$; -- Função de trigger não exige EXECUTE de quem dispara o UPDATE, então revogar -- das três origens não a quebra — e a mantém fora da lista de exceções do -- invariante de hardening, que é congelada. revoke execute on function public.fn_redigir_tarefas_do_contato_anonimizado() from public, anon, authenticated; grant execute on function public.fn_redigir_tarefas_do_contato_anonimizado() to service_role; drop trigger if exists trg_redigir_tarefas_ao_anonimizar on public.contacts; create trigger trg_redigir_tarefas_ao_anonimizar after update of is_anonymized on public.contacts for each row when (new.is_anonymized is true and old.is_anonymized is distinct from true) execute function public.fn_redigir_tarefas_do_contato_anonimizado(); comment on column public.crm_tasks.title is 'Texto livre do operador — "Ligar para Fulano confirmar o orçamento". É dado pessoal quando a tarefa aponta para um contato: o trigger trg_redigir_tarefas_ao_anonimizar o substitui por "Tarefa anonimizada" e apaga a descrição quando o contato é anonimizado. Prazo, situação e prioridade são PRESERVADOS — que houve tarefa e quando ela venceu é registro de operação.'; -- ---- Relatório de atividades (migration 0217) ---- -- Leitura de `crm_lead_activities` no eixo do PERÍODO. Até aqui o barramento só -- era lido por negócio e por contato: "o que a equipe fez esta semana" obrigava -- a abrir negócio por negócio. A agregação roda no Postgres de propósito — -- trazer a janela inteira para contar em JavaScript é o que derruba uma VPS -- pequena. SECURITY INVOKER: o escopo é a RLS de 0042, não uma segunda checagem. -- Idempotente e auto-curativo (create index if not exists / create or replace). -- A janela é (organization_id, performed_at): os três índices existentes lideram -- por org mas seguem com contact_id/lead_id/type, então nenhum deles serve a um -- recorte de período org-wide sem varrer a org inteira. create index if not exists idx_lead_activities_org_perf on public.crm_lead_activities (organization_id, performed_at desc); -- Agregação única (total + por ator + por tipo + série diária + as N linhas mais -- recentes) → jsonb. `stable`: só lê. Janela semiaberta [p_from, p_to). -- -- NOMES não saem daqui: `auth.users` não é legível por `authenticated`, e o -- enriquecimento (nome da pessoa, nome do agente) já tem caminho na rota. A -- função devolve IDENTIFICADORES; quem sabe traduzir identificador em nome é a -- camada que também sabe degradar quando o nome falta. create or replace function public.fn_activity_report( p_org uuid, p_from timestamptz, p_to timestamptz, p_tz text default 'UTC', p_limit int default 200 ) returns jsonb language sql stable set search_path = public as $$ with janela as ( select a.id, a.type, a.actor_kind, a.performed_by_user_id, a.actor_agent_id, a.performed_at, a.reason, a.lead_id, a.contact_id from public.crm_lead_activities a where a.organization_id = p_org and a.performed_at >= p_from and a.performed_at < p_to ) select jsonb_build_object( 'total', (select count(*) from janela), -- QUEM fez. Agrupa pela tripla (tipo de ator, pessoa, agente) porque duas -- pessoas diferentes com o mesmo `actor_kind` são duas linhas, não uma. 'by_actor', coalesce(( select jsonb_agg( jsonb_build_object( 'actor_kind', g.actor_kind, 'user_id', g.performed_by_user_id, 'agent_id', g.actor_agent_id, 'count', g.c ) order by g.c desc, coalesce(g.actor_kind, 'zzz') ) from ( select actor_kind, performed_by_user_id, actor_agent_id, count(*) as c from janela group by 1, 2, 3 ) g ), '[]'::jsonb), -- O QUE foi feito. O tipo cru; o rótulo legível é do TypeScript -- (`ACTIVITY_LABELS`), fonte única de escrita e leitura. 'by_type', coalesce(( select jsonb_agg( jsonb_build_object('type', g.type, 'count', g.c) order by g.c desc, g.type ) from (select type, count(*) as c from janela group by 1) g ), '[]'::jsonb), -- QUANDO. Dias sem atividade entram com zero — um buraco no gráfico é a -- informação (a operação parou), e omitir a linha esconde justamente isso. 'daily', coalesce(( select jsonb_agg( jsonb_build_object('date', to_char(d.dia, 'YYYY-MM-DD'), 'count', coalesce(c.n, 0)) order by d.dia ) from generate_series( date_trunc('day', p_from at time zone p_tz), date_trunc('day', (p_to - interval '1 microsecond') at time zone p_tz), interval '1 day' ) as d(dia) left join ( select date_trunc('day', performed_at at time zone p_tz) as dia, count(*) as n from janela group by 1 ) c on c.dia = d.dia ), '[]'::jsonb), -- A LISTA, limitada. O relatório não é a timeline: quem quer o histórico -- inteiro de um negócio abre o negócio, e é para lá que cada linha aponta. 'items', coalesce(( select jsonb_agg( jsonb_build_object( 'id', i.id, 'type', i.type, 'performed_at', i.performed_at, 'actor_kind', i.actor_kind, 'user_id', i.performed_by_user_id, 'agent_id', i.actor_agent_id, 'reason', i.reason, 'lead_id', i.lead_id, 'lead_title', i.lead_title, 'contact_id', i.contact_id, -- Os TRÊS campos crus, não um rótulo pronto: como esta pessoa se chama -- na tela é decisão de `lib/contacts/rotulo-do-contato.ts`, e a cadeia -- já divergiu em seis arquivos uma vez. 'contact_display_name', i.contact_display_name, 'contact_name', i.contact_name, 'contact_phone', i.contact_phone ) order by i.performed_at desc, i.id ) from ( select j.*, l.title as lead_title, ct.display_name as contact_display_name, ct.name as contact_name, ct.phone_number as contact_phone from janela j left join public.crm_leads l on l.id = j.lead_id left join public.contacts ct on ct.id = j.contact_id order by j.performed_at desc, j.id limit greatest(p_limit, 0) ) i ), '[]'::jsonb), -- A lista foi cortada? Sem isto, um período movimentado pareceria calmo. 'items_truncated', (select count(*) from janela) > greatest(p_limit, 0) ); $$; -- Função nova em `public` nasce EXPOSTA por DUAS origens (CLAUDE.md, doutrina de -- migrations §9): o `GRANT ALL ON FUNCTIONS TO anon` do baseline e o grant a -- PUBLIC que o Postgres dá a toda função. Tratar só uma deixa a RPC alcançável -- pela anon key, que vai para o browser. revoke all on function public.fn_activity_report(uuid, timestamptz, timestamptz, text, int) from public; revoke execute on function public.fn_activity_report(uuid, timestamptz, timestamptz, text, int) from anon; grant execute on function public.fn_activity_report(uuid, timestamptz, timestamptz, text, int) to authenticated, service_role; -- ---- campos personalizados do contato, e a anonimização que os alcança (migration 0211) ---- -- 0211 — campos personalizados no CONTATO, e a anonimização que os alcança. -- -- O contato já tinha `tags` e `source_metadata`, mas nada onde o operador -- guardasse o que o NICHO dele pede — matrícula, convênio, número do processo. -- A definição continua declarativa em `crm_pipelines.settings.fields[]`, a mesma -- fonte que `crm_leads.custom_fields` já usa; o que entra aqui é só o VALOR. -- -- ── A segunda metade não é opcional ─────────────────────────────────────────── -- -- Campo livre num registro de pessoa física recebe CPF. Não é hipótese: é o -- primeiro uso que um operador de clínica ou de escritório dá a um campo -- chamado "documento". Uma coluna de PII que a anonimização não alcança faz o -- sistema responder "anonimizado" a um pedido do titular com o CPF dele intacto -- no banco — e o SLA de D+15 marcado como cumprido. -- -- ── Por que TRIGGER NO ESTADO, e não uma linha no cascade ───────────────────── -- -- A escolha é a mesma que o bloco `trg_contacts_anonimizado_limpa_propostas` -- já registrou neste baseline, e vale pelo mesmo motivo: há MAIS DE UM caminho -- que anonimiza um contato. -- -- fn_lgpd_cascade_redact_contact o cascade completo -- app/api/v1/lgpd/anonymize/route.ts:104 a rota direta, que faz um UPDATE -- próprio e nem sequer limpa -- `consent`/`tags`/`source_metadata` -- -- Acrescentar a linha só ao cascade deixaria a rota direta vazando. Pendurar no -- FATO (`is_anonymized` virou true) cobre os dois, e cobre o DBA que amanhã -- fizer à mão. É também a diferença entre editar uma função de 180 linhas vinda -- de dump e acrescentar dez. -- -- BEFORE, e não AFTER: o alvo é uma coluna da PRÓPRIA linha. Em `after` seria -- preciso um segundo UPDATE, com o risco de recursão que ele traz. alter table public.contacts add column if not exists custom_fields jsonb not null default '{}'::jsonb; comment on column public.contacts.custom_fields is 'Valores de campos personalizados do contato. As definições são declaradas em crm_pipelines.settings.fields[]. Limpo pela anonimização (trg_contacts_anonimizado_limpa_custom_fields).'; -- Dados ANTES da constraint: em banco de clone a coluna pode ter chegado por -- outro caminho com valor não-objeto, e o `update.sh` roda SEM `ON_ERROR_STOP` — -- um 23514 aqui seria engolido e a constraint ficaria fora, em silêncio. update public.contacts set custom_fields = '{}'::jsonb where custom_fields is null or jsonb_typeof(custom_fields) <> 'object'; alter table public.contacts drop constraint if exists contacts_custom_fields_object; alter table public.contacts add constraint contacts_custom_fields_object check (jsonb_typeof(custom_fields) = 'object'); create or replace function public.fn_contato_anonimizado_limpa_campos_personalizados() returns trigger language plpgsql as $$ begin -- Anonimização é irreversível (L-04): não há o que preservar aqui. new.custom_fields := '{}'::jsonb; return new; end$$; -- As DUAS origens de EXECUTE (item 9 do CLAUDE.md). Função de gatilho não é -- alcançável pela REST, mas o `ALTER DEFAULT PRIVILEGES ... TO anon` do corpo -- deste arquivo vale para toda função criada depois dele, e `revoke from public` -- não remove um grant nominal a `anon`. revoke all on function public.fn_contato_anonimizado_limpa_campos_personalizados() from public; revoke execute on function public.fn_contato_anonimizado_limpa_campos_personalizados() from anon; revoke execute on function public.fn_contato_anonimizado_limpa_campos_personalizados() from authenticated; drop trigger if exists trg_contacts_anonimizado_limpa_custom_fields on public.contacts; create trigger trg_contacts_anonimizado_limpa_custom_fields before update of is_anonymized on public.contacts for each row when (new.is_anonymized = true and coalesce(old.is_anonymized, false) = false) execute function public.fn_contato_anonimizado_limpa_campos_personalizados(); -- ---- Organização e acesso atômicos (migration 0231; timestamp preservado 20260905120000) ---- -- Criação administrativa atômica; chave existente com endpoint por ator, sem tokens. -- Apenas service_role: identidade/plataforma/MFA são verificadas pelo handler. create or replace function public.fn_create_tenant_with_owner( p_actor uuid, p_key uuid, p_request jsonb, p_hash text ) returns jsonb language plpgsql security definer set search_path = public, pg_temp as $$ declare prior public.idempotency_keys%rowtype; org public.organizations%rowtype; result jsonb; begin if not exists (select 1 from public.platform_admins where user_id = p_actor and revoked_at is null and scope = 'full') then raise exception 'platform_admin_required' using errcode = '42501'; end if; perform pg_advisory_xact_lock(hashtextextended(p_actor::text || ':' || p_key::text, 0)); select * into prior from public.idempotency_keys where key = p_key::text and endpoint = '/api/v1/admin/tenants:' || p_actor::text and expires_at > now(); if found then if prior.request_hash <> decode(p_hash, 'hex') then raise exception 'idempotency_conflict' using errcode = '22023'; end if; return prior.response_body || jsonb_build_object('created', false); end if; insert into public.organizations(display_name, slug, legal_name, cnpj, status, settings, created_by) values (p_request->>'display_name', p_request->>'slug', coalesce(nullif(p_request->>'legal_name', ''), p_request->>'display_name'), p_request->>'cnpj', 'active', jsonb_build_object('plan', p_request->>'plan'), p_actor) returning * into org; insert into public.user_organizations(organization_id, user_id, role, accepted_at) values (org.id, p_actor, 'admin', now()); result := jsonb_build_object('id', org.id, 'slug', org.slug, 'display_name', org.display_name, 'invite_id', gen_random_uuid(), 'issued_at', floor(extract(epoch from now()))::bigint); insert into public.idempotency_keys(organization_id, key, endpoint, request_hash, status_code, response_body) values (org.id, p_key::text, '/api/v1/admin/tenants:' || p_actor::text, decode(p_hash, 'hex'), 201, result); return result || jsonb_build_object('created', true); end $$; revoke all on function public.fn_create_tenant_with_owner(uuid, uuid, jsonb, text) from public, anon, authenticated; grant execute on function public.fn_create_tenant_with_owner(uuid, uuid, jsonb, text) to service_role; -- O token HMAC/e-mail são verificados no servidor. Serialização impede duas -- aceitações concorrentes de reescrever o vínculo. Replay ativo não muda nada; -- revogado exige convite NOVO com iat posterior à revogação (legado é negado). create or replace function public.fn_accept_team_invite( p_user uuid, p_org uuid, p_role text, p_invited_by uuid, p_issued_at timestamptz, p_invited_at timestamptz ) returns jsonb language plpgsql security definer set search_path = public, pg_temp as $$ declare m public.user_organizations%rowtype; begin if p_role not in ('viewer','agent','manager','admin') then raise exception 'invalid_role' using errcode = '22023'; end if; perform pg_advisory_xact_lock(hashtextextended(p_user::text || ':' || p_org::text, 0)); if not exists(select 1 from public.organizations where id = p_org and status = 'active') then raise exception 'organization_unavailable' using errcode = '42501'; end if; select * into m from public.user_organizations where organization_id = p_org and user_id = p_user for update; if found and m.revoked_at is null and m.accepted_at is not null then return jsonb_build_object('id', m.id, 'changed', false); end if; if found and m.revoked_at is not null and (p_issued_at is null or p_issued_at <= m.revoked_at) then raise exception 'invite_revoked' using errcode = '42501'; end if; if m.id is not null then update public.user_organizations set role = p_role, revoked_at = null, invited_by = coalesce(p_invited_by, invited_by), invited_at = p_invited_at, accepted_at = now(), updated_at = now() where organization_id = p_org and id = m.id returning * into m; else insert into public.user_organizations(organization_id, user_id, role, invited_by, invited_at, accepted_at) values (p_org, p_user, p_role, p_invited_by, p_invited_at, now()) returning * into m; end if; return jsonb_build_object('id', m.id, 'changed', true); end $$; revoke all on function public.fn_accept_team_invite(uuid, uuid, text, uuid, timestamptz, timestamptz) from public, anon, authenticated; grant execute on function public.fn_accept_team_invite(uuid, uuid, text, uuid, timestamptz, timestamptz) to service_role; -- ---- Recibo de criação confiável (migration 0219) ---- -- Recibo que será autoridade de assinatura não pode ser escrito por membro. -- DEFAULT false marca TODA linha anterior como não confiável, inclusive forjada. -- Não fazemos backfill nem apagamos recibos: só a RPC abaixo produz confiança. -- Os namespaces LGPD/MCP continuam com o contrato de leitura/escrita original. alter table public.idempotency_keys add column if not exists tenant_creation_trusted boolean not null default false; drop policy if exists idempotency_platform_creation_server_only on public.idempotency_keys; create policy idempotency_platform_creation_server_only on public.idempotency_keys as restrictive for all to anon, authenticated using (endpoint not like '/api/v1/admin/tenants:%' and not tenant_creation_trusted) with check (endpoint not like '/api/v1/admin/tenants:%' and not tenant_creation_trusted); -- TRUNCATE ignora RLS; nenhum consumidor de idempotência precisa dele. revoke truncate on public.idempotency_keys from public, anon, authenticated; -- Criação administrativa atômica; chave existente com endpoint por ator, sem tokens. -- Apenas service_role: identidade/plataforma/MFA são verificadas pelo handler. create or replace function public.fn_create_tenant_with_owner( p_actor uuid, p_key uuid, p_request jsonb, p_hash text ) returns jsonb language plpgsql security definer set search_path = public, pg_temp as $$ declare prior public.idempotency_keys%rowtype; org public.organizations%rowtype; result jsonb; begin if not exists (select 1 from public.platform_admins where user_id = p_actor and revoked_at is null and scope = 'full') then raise exception 'platform_admin_required' using errcode = '42501'; end if; perform pg_advisory_xact_lock(hashtextextended(p_actor::text || ':' || p_key::text, 0)); select * into prior from public.idempotency_keys where key = p_key::text and endpoint = '/api/v1/admin/tenants:' || p_actor::text and expires_at > now() and tenant_creation_trusted; if found then if prior.request_hash <> decode(p_hash, 'hex') then raise exception 'idempotency_conflict' using errcode = '22023'; end if; if prior.response_body->>'id' is distinct from prior.organization_id::text or not exists (select 1 from public.organizations where id = prior.organization_id and created_by = p_actor) then raise exception 'idempotency_provenance_invalid' using errcode = '22023'; end if; return prior.response_body || jsonb_build_object('created', false); end if; insert into public.organizations(display_name, slug, legal_name, cnpj, status, settings, created_by) values (p_request->>'display_name', p_request->>'slug', coalesce(nullif(p_request->>'legal_name', ''), p_request->>'display_name'), p_request->>'cnpj', 'active', jsonb_build_object('plan', p_request->>'plan'), p_actor) returning * into org; insert into public.user_organizations(organization_id, user_id, role, accepted_at) values (org.id, p_actor, 'admin', now()); result := jsonb_build_object('id', org.id, 'slug', org.slug, 'display_name', org.display_name, 'invite_id', gen_random_uuid(), 'issued_at', floor(extract(epoch from now()))::bigint); insert into public.idempotency_keys(organization_id, key, endpoint, request_hash, status_code, response_body, tenant_creation_trusted) values (org.id, p_key::text, '/api/v1/admin/tenants:' || p_actor::text, decode(p_hash, 'hex'), 201, result, true); return result || jsonb_build_object('created', true); end $$; revoke all on function public.fn_create_tenant_with_owner(uuid, uuid, jsonb, text) from public, anon, authenticated; grant execute on function public.fn_create_tenant_with_owner(uuid, uuid, jsonb, text) to service_role; -- ---- Suporte temporário por sessão (migration 0220) ---- -- Suporte temporário por sessão Supabase; não cria membership nem troca identidade. -- O banco decide validade/modo. Sessão vencida continua identificável até saída. create table if not exists public.platform_support_sessions ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, actor_user_id uuid not null references auth.users(id) on delete cascade, auth_session_id uuid not null, access_mode text not null check (access_mode in ('full','support_readonly')), previous_organization_id uuid references public.organizations(id) on delete set null, created_at timestamptz not null default now(), expires_at timestamptz not null, ended_at timestamptz ); -- Referência histórica ao Auth: CASCADE apagaria o bloqueio ao revogar a sessão; -- RESTRICT impediria logout. O início valida auth.sessions sob lock. alter table public.platform_support_sessions drop constraint if exists platform_support_sessions_auth_session_id_fkey; create unique index if not exists platform_support_sessions_open_session on public.platform_support_sessions(auth_session_id) where ended_at is null; alter table public.platform_support_sessions enable row level security; revoke all on public.platform_support_sessions from public, anon, authenticated; grant select, insert, update, delete on public.platform_support_sessions to service_role; create or replace function public.fn_support_context() returns jsonb language sql stable security definer set search_path = public as $f$ select jsonb_build_object('id', s.id, 'organization_id', s.organization_id, 'actor_user_id', s.actor_user_id, 'auth_session_id', s.auth_session_id, 'previous_organization_id', s.previous_organization_id, 'expires_at', s.expires_at, 'name', o.display_name, 'locale', o.locale, 'access_mode', case when s.access_mode = 'support_readonly' or p.scope <> 'full' then 'support_readonly' else 'full' end, 'status', case when s.expires_at <= now() then 'expired' when p.user_id is null or a.id is null or (a.not_after is not null and a.not_after <= now()) or o.status <> 'active' then 'revoked' when (p.mfa_required or exists(select 1 from auth.mfa_factors f where f.user_id=s.actor_user_id and f.status='verified')) and coalesce(auth.jwt()->>'aal','aal1') <> 'aal2' then 'revoked' else 'active' end) from public.platform_support_sessions s join public.organizations o on o.id=s.organization_id left join public.platform_admins p on p.user_id=s.actor_user_id and p.revoked_at is null left join auth.sessions a on a.id=s.auth_session_id and a.user_id=s.actor_user_id where s.actor_user_id=auth.uid() and s.auth_session_id=nullif(auth.jwt()->>'session_id','')::uuid and s.ended_at is null limit 1; $f$; revoke all on function public.fn_support_context() from public, anon; grant execute on function public.fn_support_context() to authenticated, service_role; create or replace function public.fn_support_write_allowed(p_org uuid) returns boolean language sql stable security definer set search_path = public as $f$ select coalesce((select case when (s->>'organization_id')::uuid is distinct from p_org then true else s->>'status'='active' and s->>'access_mode'='full' end from (select public.fn_support_context() s) c where s is not null),true); $f$; revoke all on function public.fn_support_write_allowed(uuid) from public, anon; grant execute on function public.fn_support_write_allowed(uuid) to authenticated, service_role; create or replace function public.fn_user_org_ids() returns setof uuid language sql stable security definer set search_path = public as $f$ select organization_id from public.user_organizations where user_id=auth.uid() and revoked_at is null union select (s->>'organization_id')::uuid from (select public.fn_support_context() s) c where s->>'status'='active'; $f$; create or replace function public.fn_user_role_in_org(p_org uuid) returns text language sql stable security definer set search_path = public as $f$ select case when s->>'status'='active' and (s->>'organization_id')::uuid=p_org then case when s->>'access_mode'='full' then 'admin' else 'viewer' end else (select role from public.user_organizations where user_id=auth.uid() and organization_id=p_org and revoked_at is null limit 1) end from (select public.fn_support_context() s) c; $f$; -- Somente backend autenticado chama o início/fim. O corpo reconfirma sessão, -- autoridade e MFA reais, e limita TTL mesmo que quem chama peça mais. create or replace function public.fn_start_support(p_actor uuid, p_session uuid, p_org uuid, p_previous uuid, p_mode text default 'full', p_ttl integer default 3600) returns uuid language plpgsql security definer set search_path = public as $f$ declare v_id uuid; v_scope text; begin perform 1 from auth.sessions where id=p_session and user_id=p_actor and (not_after is null or not_after>now()) for update; if not found then raise exception 'support_session_invalid'; end if; select scope into v_scope from public.platform_admins p where user_id=p_actor and revoked_at is null and (not (p.mfa_required or exists(select 1 from auth.mfa_factors f where f.user_id=p_actor and f.status='verified')) or exists(select 1 from auth.sessions a where a.id=p_session and a.aal='aal2')); if not found then raise exception 'support_authority_required'; end if; if p_mode not in ('full','support_readonly') or p_ttl is null or p_ttl<1 then raise exception 'support_invalid_input'; end if; if not exists(select 1 from organizations where id=p_org and status='active') then raise exception 'support_target_unavailable'; end if; if exists(select 1 from platform_support_sessions where auth_session_id=p_session and ended_at is null) then raise exception 'support_exit_required'; end if; if p_previous is not null and not exists(select 1 from user_organizations where user_id=p_actor and organization_id=p_previous and revoked_at is null) then raise exception 'support_previous_invalid'; end if; insert into platform_support_sessions(organization_id,actor_user_id,auth_session_id,access_mode,previous_organization_id,expires_at) values(p_org,p_actor,p_session,case when v_scope='full' then p_mode else 'support_readonly' end,p_previous,now()+make_interval(secs=>least(p_ttl,3600))) returning id into v_id; return v_id; end $f$; create or replace function public.fn_end_support(p_actor uuid,p_session uuid) returns jsonb language plpgsql security definer set search_path = public as $f$ declare v_row public.platform_support_sessions; begin -- Sair depende somente da posse da sessão, nunca da autoridade/TTL. update public.platform_support_sessions set ended_at=now() where actor_user_id=p_actor and auth_session_id=p_session and ended_at is null returning * into v_row; return to_jsonb(v_row); end $f$; revoke all on function public.fn_start_support(uuid,uuid,uuid,uuid,text,integer), public.fn_end_support(uuid,uuid) from public,anon,authenticated; grant execute on function public.fn_start_support(uuid,uuid,uuid,uuid,text,integer), public.fn_end_support(uuid,uuid) to service_role; -- As políticas restritivas `support_write_{insert,update,delete}` das tabelas de -- `public` (restritiva derrota as permissivas OR plataforma, inclusive membership -- admin B) NÃO são plantadas aqui. Uma enumeração do catálogo só alcança as tabelas -- que já existem quando ela roda, e este arquivo cria tabela até o fim. Quem as -- planta é `public.fn_aplicar_travas_de_suporte()` (migration 0274): definida antes -- da varredura de anon e CHAMADA no último bloco do arquivo, depois de toda tabela. CREATE OR REPLACE FUNCTION public.emit_event(p_event_type text, p_entity_kind text, p_entity_id uuid, p_payload jsonb DEFAULT '{}'::jsonb, p_metadata jsonb DEFAULT '{}'::jsonb, p_organization_id uuid DEFAULT NULL::uuid) RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public' AS $function$ declare v_org_id uuid; v_event_id uuid; begin v_org_id := coalesce(p_organization_id, (public.fn_support_context()->>'organization_id')::uuid); if v_org_id is null then select organization_id into v_org_id from public.user_organizations where user_id = auth.uid() and revoked_at is null limit 1; end if; if v_org_id is null then raise exception 'emit_event: organization_id obrigatorio'; end if; if auth.uid() is not null and not public.fn_role_at_least(v_org_id, 'viewer') then raise exception 'caller_not_authorized_for_org' using hint = 'emit_event: caller must be an active member of the organization'; end if; if not public.fn_support_write_allowed(v_org_id) then raise exception 'support_readonly' using errcode='42501'; end if; insert into public.event_log (organization_id, event_type, entity_kind, entity_id, payload, metadata) values (v_org_id, p_event_type, p_entity_kind, p_entity_id, coalesce(p_payload, '{}'::jsonb), coalesce(p_metadata, '{}'::jsonb) || jsonb_build_object('emitted_at', extract(epoch from now()))) returning id into v_event_id; return v_event_id; end $function$ ; CREATE OR REPLACE FUNCTION public.fn_conversation_assign(p_organization_id uuid, p_conversation_id uuid, p_to_user_id uuid, p_reason text, p_expected_assignee uuid DEFAULT NULL::uuid, p_enforce_expected boolean DEFAULT false) RETURNS SETOF conversations LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public' AS $function$ declare v_from uuid; v_conv public.conversations%rowtype; begin if not public.fn_support_write_allowed(p_organization_id) then raise exception 'support_readonly' using errcode='42501'; end if; if auth.uid() is not null and not public.fn_role_at_least(p_organization_id, 'agent') then raise exception 'caller_not_authorized_for_org' using hint = 'caller must be an active agent+ member of the organization'; end if; if p_to_user_id is not null then if coalesce(public.fn_member_role_in_org(p_to_user_id, p_organization_id), 'none') not in ('agent','manager','admin') then raise exception 'assignee_not_eligible_member' using hint = 'target must be an active agent+ member of the organization'; end if; end if; select assigned_to_user_id into v_from from public.conversations where id = p_conversation_id and organization_id = p_organization_id for update; if not found then return; end if; if p_enforce_expected and v_from is distinct from p_expected_assignee then return; end if; update public.conversations set assigned_to_user_id = p_to_user_id, -- Desnormalizado JUNTO com o dono, na mesma transação: nunca existe -- uma janela em que id e nome discordam. NULL junto com o id quando -- a atribuição é removida (release) — nunca sobra um nome órfão de -- dono nenhum. Lido de auth.users porque quem chama esta função -- (RPC) não necessariamente tem acesso ao Admin API — a definer -- resolve por dentro. assigned_to_user_name = case when p_to_user_id is null then null else (select raw_user_meta_data ->> 'full_name' from auth.users where id = p_to_user_id) end, assigned_at = case when p_to_user_id is null then null else now() end, assignee_kind = case when p_to_user_id is null then null else 'user' end, status = case when p_to_user_id is null then 'open' else 'claimed' end, status_changed_at = now(), unread_count_for_assignee = 0, bot_silenced_until = case when p_reason = 'routing' then bot_silenced_until when p_to_user_id is null then (case when last_handoff_at is null then null else bot_silenced_until end) else 'infinity'::timestamptz end, updated_at = now() where id = p_conversation_id returning * into v_conv; insert into public.conversation_assignment_events (organization_id, conversation_id, from_user_id, to_user_id, changed_by, reason) values (p_organization_id, p_conversation_id, v_from, p_to_user_id, auth.uid(), p_reason); return next v_conv; end; $function$ ; CREATE OR REPLACE FUNCTION public.fn_mesclar_contatos(p_organization_id uuid, p_contato_principal uuid, p_contatos_secundarios uuid[]) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $function$ declare v_principal public.contacts%rowtype; v_esperado integer; v_achado integer; v_alvo record; v_linha record; v_movidas integer; v_pulados integer; v_repontado jsonb := '{}'::jsonb; v_nao_repontado jsonb := '{}'::jsonb; v_nome text; v_apelido text; v_nascimento date; v_email text; v_telefone text; v_lid text; v_tags text[]; v_leads integer := 0; begin if not public.fn_support_write_allowed(p_organization_id) then raise exception 'support_readonly' using errcode='42501'; end if; -- 1 · Autorização. Fundir é destrutivo na prática: `manager`, o mesmo piso das -- policies de `merge_queue`. Sessão de service role (auth.uid() nulo) não -- passa por aqui — quem resolve a org nesse caminho é a rota, de fonte -- confiável, nunca do body. if auth.uid() is not null and not public.fn_role_at_least(p_organization_id, 'manager') then raise exception using errcode = '42501', message = 'insufficient_role'; end if; if p_contato_principal is null or p_contatos_secundarios is null or cardinality(p_contatos_secundarios) = 0 or p_contato_principal = any(p_contatos_secundarios) then raise exception using errcode = '22023', message = 'selecao_de_mesclagem_invalida'; end if; select count(distinct id)::integer into v_esperado from unnest(p_contatos_secundarios) as ids(id); if v_esperado <> cardinality(p_contatos_secundarios) then raise exception using errcode = '22023', message = 'secundario_repetido'; end if; -- 2 · O principal existe, é desta org, está vivo — e trava até o fim. select * into v_principal from public.contacts where id = p_contato_principal and organization_id = p_organization_id and is_merged_into is null and is_anonymized = false for update; if not found then raise exception using errcode = 'P0002', message = 'contato_principal_indisponivel'; end if; -- 3 · Os secundários também. `is_anonymized = false` não é zelo: L-04 é -- irreversível, e reencaixar a linha anonimizada num contato ativo a -- traria de volta ao atendimento pela porta dos fundos. perform 1 from public.contacts where id = any(p_contatos_secundarios) and organization_id = p_organization_id and is_merged_into is null and is_anonymized = false for update; get diagnostics v_achado = row_count; if v_achado <> v_esperado then raise exception using errcode = 'P0002', message = 'contato_secundario_indisponivel'; end if; -- 4 · A LÁPIDE VEM ANTES de tudo. É ela que solta telefone/e-mail/CPF dos -- índices únicos parciais para o vencedor poder herdá-los no passo 6. update public.contacts set is_merged_into = p_contato_principal, merged_at = now(), updated_at = now() where organization_id = p_organization_id and id = any(p_contatos_secundarios); -- Cadeia: quem já tinha sido mesclado NUM dos secundários passa a apontar para -- o vencedor. Sem isto, `is_merged_into` vira uma corrente que a leitura teria -- de percorrer, e ninguém percorre. update public.contacts set is_merged_into = p_contato_principal where organization_id = p_organization_id and is_merged_into = any(p_contatos_secundarios); -- 5 · Reponta TODO ponteiro para os perdedores. A lista sai do catálogo; o -- polimórfico entra à mão porque catálogo nenhum o conhece. for v_alvo in select n.nspname as esquema, c.relname as tabela, a.attname as coluna, ''::text as filtro from pg_catalog.pg_constraint co join pg_catalog.pg_class c on c.oid = co.conrelid join pg_catalog.pg_namespace n on n.oid = c.relnamespace join pg_catalog.pg_attribute a on a.attrelid = co.conrelid and a.attnum = co.conkey[1] where co.contype = 'f' and co.confrelid = 'public.contacts'::regclass and co.conrelid <> 'public.contacts'::regclass and array_length(co.conkey, 1) = 1 and c.relkind = 'r' and n.nspname = 'public' union all select 'public', 'crm_lead_links', 'target_id', ' and target_kind = ''contact''' where to_regclass('public.crm_lead_links') is not null order by 2, 3 loop v_pulados := 0; begin execute format( 'update %I.%I set %I = $1 where %I = any($2)%s', v_alvo.esquema, v_alvo.tabela, v_alvo.coluna, v_alvo.coluna, v_alvo.filtro ) using p_contato_principal, p_contatos_secundarios; get diagnostics v_movidas = row_count; exception when unique_violation or exclusion_violation then -- Colisão REAL e esperada: `uniq_job_queue_one_running_per_contact` deixa -- um job 'running' por contato, e os dois lados podem ter um. Em vez de -- abortar a fusão inteira por causa de estado efêmero de runtime, reponta -- linha a linha e conta quem ficou. Quem fica NÃO vira FK órfã — continua -- apontando para a lápide, que existe. v_movidas := 0; for v_linha in execute format( 'select ctid as tid from %I.%I where %I = any($1)%s', v_alvo.esquema, v_alvo.tabela, v_alvo.coluna, v_alvo.filtro ) using p_contatos_secundarios loop begin execute format( 'update %I.%I set %I = $1 where ctid = $2', v_alvo.esquema, v_alvo.tabela, v_alvo.coluna ) using p_contato_principal, v_linha.tid; v_movidas := v_movidas + 1; exception when unique_violation or exclusion_violation then v_pulados := v_pulados + 1; end; end loop; end; if v_movidas > 0 then v_repontado := v_repontado || jsonb_build_object(v_alvo.tabela || '.' || v_alvo.coluna, v_movidas); end if; if v_pulados > 0 then v_nao_repontado := v_nao_repontado || jsonb_build_object(v_alvo.tabela || '.' || v_alvo.coluna, v_pulados); end if; end loop; -- 6 · O principal MANDA; o que ele não tem, vem dos perdedores. Nunca o -- contrário: sobrescrever o que o atendente digitou seria fusão com -- surpresa, e fusão não tem desfazer. select c.name into v_nome from public.contacts c where c.id = any(p_contatos_secundarios) and c.name is not null order by c.created_at, c.id limit 1; select c.display_name into v_apelido from public.contacts c where c.id = any(p_contatos_secundarios) and c.display_name is not null order by c.created_at, c.id limit 1; select c.birthdate into v_nascimento from public.contacts c where c.id = any(p_contatos_secundarios) and c.birthdate is not null order by c.created_at, c.id limit 1; select c.email into v_email from public.contacts c where c.id = any(p_contatos_secundarios) and c.email is not null order by c.created_at, c.id limit 1; select c.phone_number into v_telefone from public.contacts c where c.id = any(p_contatos_secundarios) and c.phone_number is not null order by c.created_at, c.id limit 1; -- `wa_identity`/`wa_lid` são GERADAS: o que se herda é a origem delas. Sem -- isto o WhatsApp do perdedor fica órfão — `fn_upsert_wa_contact` filtra -- `is_merged_into is null`, não acharia mais ninguém e criaria um contato -- novo na mensagem seguinte, refazendo a duplicata que acabou de ser desfeita. select c.source_metadata->>'waha_lid' into v_lid from public.contacts c where c.id = any(p_contatos_secundarios) and c.source_metadata->>'waha_lid' is not null order by c.created_at, c.id limit 1; -- Guardas de unicidade. A lápide já tirou os perdedores dos índices parciais, -- então o que sobrar aqui é conflito com um TERCEIRO contato vivo — e nesse -- caso o vencedor simplesmente não herda o campo. Falhar a fusão inteira por -- causa de um e-mail seria perder o repontamento que já valeu a pena. if v_email is not null and exists ( select 1 from public.contacts o where o.organization_id = p_organization_id and o.is_merged_into is null and o.id <> p_contato_principal and o.email_normalized = lower(btrim(v_email)) ) then v_email := null; end if; if v_telefone is not null and exists ( select 1 from public.contacts o where o.organization_id = p_organization_id and o.is_merged_into is null and o.id <> p_contato_principal and o.phone_number = v_telefone ) then v_telefone := null; end if; if v_lid is not null and exists ( select 1 from public.contacts o where o.organization_id = p_organization_id and o.is_merged_into is null and o.id <> p_contato_principal and o.wa_lid = v_lid ) then v_lid := null; end if; select coalesce(array_agg(distinct t), '{}'::text[]) into v_tags from ( select unnest(c.tags) as t from public.contacts c where c.organization_id = p_organization_id and (c.id = p_contato_principal or c.id = any(p_contatos_secundarios)) ) as todas; -- CPF e `consent` NÃO são herdados, de propósito. CPF é um PAR -- (`cpf_encrypted` + `cpf_hash`) preso por check constraint e criptografado -- com a chave da instalação — mover metade quebra a linha. `consent` é -- registro legal do que AQUELA pessoa autorizou; herdar um "granted_at" de -- outro cadastro fabricaria consentimento. Falha fechada nos dois. update public.contacts set name = coalesce(name, v_nome), display_name = coalesce(display_name, v_apelido), birthdate = coalesce(birthdate, v_nascimento), email = coalesce(email, v_email), phone_number = coalesce(phone_number, v_telefone), tags = v_tags, last_activity_at = greatest( last_activity_at, (select max(c.last_activity_at) from public.contacts c where c.id = any(p_contatos_secundarios)) ), source_metadata = ( case when source_metadata->>'waha_lid' is null and v_lid is not null then source_metadata || jsonb_build_object('waha_lid', v_lid) else source_metadata end ) - case when coalesce(phone_number, v_telefone) is not null then 'telefone_em_conflito' else '' end || jsonb_build_object( 'mesclado_de', coalesce(source_metadata->'mesclado_de', '[]'::jsonb) || to_jsonb(p_contatos_secundarios), 'mesclado_em', to_jsonb(now()) ), updated_at = now() where id = p_contato_principal and organization_id = p_organization_id; -- 7 · A fusão aparece na timeline de cada negócio que o vencedor passou a ter. -- `crm_lead_activities.lead_id` é NOT NULL — contato sem negócio nenhum -- não tem onde escrever, e para esse caso quem guarda o rastro é o -- `api_audit_log` que a rota emite, sempre. insert into public.crm_lead_activities (organization_id, lead_id, contact_id, source_module, source_id, type, payload, metadata, performed_at, performed_by_user_id) select p_organization_id, l.id, p_contato_principal, 'crm', p_contato_principal, 'contacts_merged', jsonb_build_object( 'contatos_mesclados', to_jsonb(p_contatos_secundarios), 'repontado', v_repontado, 'nao_repontado', v_nao_repontado ), '{}'::jsonb, now(), auth.uid() from public.crm_leads l where l.organization_id = p_organization_id and l.contact_id = p_contato_principal; get diagnostics v_leads = row_count; return jsonb_build_object( 'contato_id', p_contato_principal, 'contatos_mesclados', to_jsonb(p_contatos_secundarios), 'repontado', v_repontado, 'nao_repontado', v_nao_repontado, 'atividades_emitidas', v_leads ); end; $function$ ; -- Storage usa organização no primeiro segmento. Segmentos de plataforma não -- recebem concessão nova; esta cerca só restringe os paths do alvo. create or replace function public.fn_support_storage_write_allowed(p_name text) returns boolean language sql stable security definer set search_path = public as $f$ select case when split_part(p_name,'/',1) ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' then public.fn_support_write_allowed(split_part(p_name,'/',1)::uuid) else true end; $f$; revoke all on function public.fn_support_storage_write_allowed(text) from public,anon; grant execute on function public.fn_support_storage_write_allowed(text) to authenticated,service_role; do $f$ begin if to_regclass('storage.objects') is not null then execute 'drop policy if exists support_write_insert on storage.objects'; execute 'create policy support_write_insert on storage.objects as restrictive for insert to authenticated with check (public.fn_support_storage_write_allowed(name))'; execute 'drop policy if exists support_write_update on storage.objects'; execute 'create policy support_write_update on storage.objects as restrictive for update to authenticated using (public.fn_support_storage_write_allowed(name)) with check (public.fn_support_storage_write_allowed(name))'; execute 'drop policy if exists support_write_delete on storage.objects'; execute 'create policy support_write_delete on storage.objects as restrictive for delete to authenticated using (public.fn_support_storage_write_allowed(name))'; end if; end $f$; notify pgrst, 'reload schema'; -- Callback OAuth não recebe JWT Strict. O state assinado liga sessão e ator. -- Legado sem sessão falha conservadoramente só se houver suporte restrito no alvo. create or replace function public.fn_support_callback_write_allowed(p_org uuid,p_actor uuid default null,p_session uuid default null) returns boolean language sql stable security definer set search_path=public as $f$ select not exists( select 1 from platform_support_sessions s left join platform_admins p on p.user_id=s.actor_user_id and p.revoked_at is null left join auth.sessions a on a.id=s.auth_session_id and a.user_id=s.actor_user_id join organizations o on o.id=s.organization_id where s.organization_id=p_org and s.ended_at is null and (p_actor is null or s.actor_user_id=p_actor) and (p_session is null or s.auth_session_id=p_session) and (s.access_mode<>'full' or p.scope<>'full' or p.user_id is null or s.expires_at<=now() or a.id is null or (a.not_after is not null and a.not_after<=now()) or o.status<>'active' or ((p.mfa_required or exists(select 1 from auth.mfa_factors f where f.user_id=s.actor_user_id and f.status='verified')) and coalesce(a.aal::text,'aal1')<>'aal2'))); $f$; revoke all on function public.fn_support_callback_write_allowed(uuid,uuid,uuid) from public,anon,authenticated; grant execute on function public.fn_support_callback_write_allowed(uuid,uuid,uuid) to service_role; -- ---- Interface por vínculo (0221) ---- -- Apresentação por membership, nunca autorização. IDs evoluem no catálogo TS. -- Default segura para clones; nenhuma linha legada é reinterpretada como bloqueio. alter table public.user_organizations add column if not exists interface_settings jsonb not null default '{"preset":"completa"}'::jsonb; do $$ begin if not exists(select 1 from pg_constraint where conrelid='public.user_organizations'::regclass and conname='user_organizations_interface_shape') then alter table public.user_organizations add constraint user_organizations_interface_shape check ( jsonb_typeof(interface_settings) = 'object' and interface_settings ? 'preset' and interface_settings->>'preset' in ('completa','simplificada') and (not interface_settings ? 'destinos' or (jsonb_typeof(interface_settings->'destinos')='array' and interface_settings->'destinos' <> '[]'::jsonb)) ); end if; if exists(select 1 from pg_publication where pubname='supabase_realtime') and not exists( select 1 from pg_publication_tables where pubname='supabase_realtime' and schemaname='public' and tablename='user_organizations') then alter publication supabase_realtime add table public.user_organizations; end if; end $$; -- INSERT/reativação aplica escolha assinada; replay ativo retorna antes da escrita. create or replace function public.fn_accept_team_invite( p_user uuid, p_org uuid, p_role text, p_invited_by uuid, p_issued_at timestamptz, p_invited_at timestamptz, p_interface_settings jsonb ) returns jsonb language plpgsql security definer set search_path = public, pg_temp as $$ declare m public.user_organizations%rowtype; begin if p_role not in ('viewer','agent','manager','admin') then raise exception 'invalid_role' using errcode = '22023'; end if; perform pg_advisory_xact_lock(hashtextextended(p_user::text || ':' || p_org::text, 0)); if not exists(select 1 from public.organizations where id = p_org and status = 'active') then raise exception 'organization_unavailable' using errcode = '42501'; end if; select * into m from public.user_organizations where organization_id = p_org and user_id = p_user for update; if found and m.revoked_at is null and m.accepted_at is not null then return jsonb_build_object('id', m.id, 'changed', false); end if; if found and m.revoked_at is not null and (p_issued_at is null or p_issued_at <= m.revoked_at) then raise exception 'invite_revoked' using errcode = '42501'; end if; if m.id is not null then update public.user_organizations set role = p_role, revoked_at = null, interface_settings = p_interface_settings, invited_by = coalesce(p_invited_by, invited_by), invited_at = p_invited_at, accepted_at = now(), updated_at = now() where organization_id = p_org and id = m.id returning * into m; else insert into public.user_organizations(organization_id, user_id, role, invited_by, invited_at, accepted_at, interface_settings) values (p_org, p_user, p_role, p_invited_by, p_invited_at, now(), p_interface_settings) returning * into m; end if; -- O DONO ASSUMIU. Só o vínculo MARCADO como provisório sai, e só ele. if p_role = 'admin' then delete from public.attendant_availability av where av.organization_id = p_org and av.user_id <> p_user and exists (select 1 from public.user_organizations uo where uo.organization_id = p_org and uo.user_id = av.user_id and uo.provisional_until_handover); delete from public.user_organizations uo where uo.organization_id = p_org and uo.provisional_until_handover and uo.user_id <> p_user; end if; return jsonb_build_object('id', m.id, 'changed', true); end $$; revoke all on function public.fn_accept_team_invite(uuid, uuid, text, uuid, timestamptz, timestamptz, jsonb) from public, anon, authenticated; grant execute on function public.fn_accept_team_invite(uuid, uuid, text, uuid, timestamptz, timestamptz, jsonb) to service_role; create or replace function public.fn_accept_team_invite( p_user uuid, p_org uuid, p_role text, p_invited_by uuid, p_issued_at timestamptz, p_invited_at timestamptz ) returns jsonb language sql security definer set search_path = public, pg_temp as $$ select public.fn_accept_team_invite(p_user,p_org,p_role,p_invited_by,p_issued_at,p_invited_at,'{"preset":"completa"}'::jsonb); $$; revoke all on function public.fn_accept_team_invite(uuid,uuid,text,uuid,timestamptz,timestamptz) from public,anon,authenticated; grant execute on function public.fn_accept_team_invite(uuid,uuid,text,uuid,timestamptz,timestamptz) to service_role; -- Recibos confiáveis e fingerprint do request inteiro preservados. create or replace function public.fn_create_tenant_with_owner( p_actor uuid, p_key uuid, p_request jsonb, p_hash text ) returns jsonb language plpgsql security definer set search_path = public, pg_temp as $$ declare prior public.idempotency_keys%rowtype; org public.organizations%rowtype; result jsonb; dono_e_outra_pessoa boolean; begin if not exists (select 1 from public.platform_admins where user_id = p_actor and revoked_at is null and scope = 'full') then raise exception 'platform_admin_required' using errcode = '42501'; end if; perform pg_advisory_xact_lock(hashtextextended(p_actor::text || ':' || p_key::text, 0)); select * into prior from public.idempotency_keys where key = p_key::text and endpoint = '/api/v1/admin/tenants:' || p_actor::text and expires_at > now() and tenant_creation_trusted; if found then if prior.request_hash <> decode(p_hash, 'hex') then raise exception 'idempotency_conflict' using errcode = '22023'; end if; if prior.response_body->>'id' is distinct from prior.organization_id::text or not exists (select 1 from public.organizations where id = prior.organization_id and created_by = p_actor) then raise exception 'idempotency_provenance_invalid' using errcode = '22023'; end if; return prior.response_body || jsonb_build_object('created', false); end if; -- A MESMA comparação que já decidia `interface_settings`, agora com nome e -- guardada. Era ela que sabia a resposta e não a anotava em lugar nenhum. dono_e_outra_pessoa := lower(p_request->>'owner_email') is distinct from (select lower(email) from auth.users where id = p_actor); insert into public.organizations(display_name, slug, legal_name, cnpj, status, settings, created_by) values (p_request->>'display_name', p_request->>'slug', coalesce(nullif(p_request->>'legal_name', ''), p_request->>'display_name'), p_request->>'cnpj', 'active', jsonb_build_object('plan', p_request->>'plan'), p_actor) returning * into org; insert into public.user_organizations(organization_id, user_id, role, accepted_at, interface_settings, provisional_until_handover) values (org.id, p_actor, 'admin', now(), case when dono_e_outra_pessoa then '{"preset":"completa"}'::jsonb else coalesce(p_request->'owner_interface_settings', '{"preset":"completa"}'::jsonb) end, dono_e_outra_pessoa); result := jsonb_build_object('id', org.id, 'slug', org.slug, 'display_name', org.display_name, 'invite_id', gen_random_uuid(), 'issued_at', floor(extract(epoch from now()))::bigint); insert into public.idempotency_keys(organization_id, key, endpoint, request_hash, status_code, response_body, tenant_creation_trusted) values (org.id, p_key::text, '/api/v1/admin/tenants:' || p_actor::text, decode(p_hash, 'hex'), 201, result, true); return result || jsonb_build_object('created', true); end $$; revoke all on function public.fn_create_tenant_with_owner(uuid, uuid, jsonb, text) from public, anon, authenticated; grant execute on function public.fn_create_tenant_with_owner(uuid, uuid, jsonb, text) to service_role; notify pgrst, 'reload schema'; -- ---- Fronteira do atendimento (migration 0222) ---- -- 0222 — conversa encerra atendimento; demanda exige desfecho explícito. -- Mutex: advisory(org, contato) -> conversa NO KEY UPDATE -> demanda. -- NO KEY UPDATE é deliberado: INSERT messages já detém KEY SHARE pela FK. -- Jamais adquirir FOR UPDATE aqui: dois INSERTs podem deter KEY SHARE juntos. alter table public.conversations add column if not exists service_revision bigint not null default 1; alter table public.conversations add column if not exists service_closed_at timestamptz; alter table public.conversations add column if not exists service_started_at timestamptz; alter table public.conversations add column if not exists current_demanda_id uuid references public.demandas(id) on delete set null; alter table public.demandas add column if not exists revision bigint not null default 1; alter table public.demandas add column if not exists encerrada_por uuid references auth.users(id) on delete set null; alter table public.demanda_conversas add column if not exists service_revision bigint; alter table public.messages add column if not exists service_revision bigint; alter table public.messages add column if not exists demanda_id uuid references public.demandas(id) on delete set null; alter table public.messages add column if not exists demanda_revision bigint; alter table public.lead_checkpoints add column if not exists conversation_id uuid references public.conversations(id) on delete set null; alter table public.lead_checkpoints add column if not exists service_revision bigint; alter table public.lead_checkpoints add column if not exists demanda_id uuid references public.demandas(id) on delete set null; alter table public.lead_checkpoints add column if not exists demanda_revision bigint; -- Só carimbos observados: não inventar assunto/provenance para trabalho legado. update public.conversations set service_closed_at = status_changed_at where status in ('closed','resolved','archived') and service_closed_at is null; drop trigger if exists trg_demanda_fecha_com_conversa on public.conversations; create or replace function public.fn_service_lock(p_org uuid, p_contact uuid) returns void language sql set search_path = public as $$ select pg_advisory_xact_lock(hashtextextended(p_org::text || ':' || p_contact::text, 222)); $$; revoke execute on function public.fn_service_lock(uuid,uuid) from public, anon, authenticated; grant execute on function public.fn_service_lock(uuid,uuid) to service_role; -- Canônica: só recebe ID de mensagem persistida. Tenant/FKs são reconferidos. create or replace function public.fn_service_inbound(p_message uuid) returns void language plpgsql security definer set search_path = public as $$ declare m public.messages; c public.conversations; d public.demandas; reopened boolean; pre_contact uuid; begin select * into m from public.messages where id = p_message; if not found or m.direction <> 'inbound' or m.service_revision is not null then return; end if; select * into c from public.conversations where id = m.conversation_id; if not found or c.organization_id is distinct from m.organization_id or c.channel_session_id is distinct from m.channel_session_id or not exists(select 1 from public.channel_sessions where id=m.channel_session_id and organization_id=m.organization_id) then raise exception 'service_scope_mismatch' using errcode='23503'; end if; if c.is_group or coalesce(c.group_chat_id,'') like '%@g.us' then return; end if; if c.contact_id is distinct from m.contact_id or not exists(select 1 from public.contacts where id=m.contact_id and organization_id=m.organization_id) then raise exception 'service_scope_mismatch' using errcode='23503'; end if; pre_contact:=c.contact_id; perform public.fn_service_lock(c.organization_id,c.contact_id); select * into c from public.conversations where id=m.conversation_id and organization_id=m.organization_id for no key update; if c.contact_id is distinct from pre_contact then raise exception 'service_contact_changed' using errcode='40001'; end if; if m.sent_at <= c.service_closed_at then return; end if; reopened := c.status in ('closed','resolved','archived'); if not reopened then select x.* into d from public.demandas x join public.demanda_conversas dc on dc.demanda_id=x.id where x.id=c.current_demanda_id and x.organization_id=c.organization_id and x.contact_id=c.contact_id and dc.organization_id=c.organization_id and dc.conversation_id=c.id and dc.service_revision=c.service_revision and x.fechada_em is null; end if; if d.id is null then insert into public.demandas(organization_id,contact_id,aberta_em,origem,estado,dono_kind,proximo_passo) values(c.organization_id,c.contact_id,m.sent_at,'inbound','aberta','ia','Responder à nova mensagem do cliente') returning * into d; end if; if reopened then update public.conversations set status='open', status_changed_at=clock_timestamp(), service_revision=service_revision+1,service_started_at=m.sent_at, assigned_to_user_id=null,assigned_at=null,assignee_kind=null,active_ai_agent_id=null, current_demanda_id=d.id where id=c.id and organization_id=c.organization_id returning * into c; else update public.conversations set service_revision=service_revision+case when current_demanda_id is not null and current_demanda_id<>d.id then 1 else 0 end, service_started_at=case when current_demanda_id is not null and current_demanda_id<>d.id then m.sent_at else coalesce(service_started_at,m.sent_at) end, current_demanda_id=d.id where id=c.id and organization_id=c.organization_id returning * into c; end if; insert into public.demanda_conversas(organization_id,demanda_id,conversation_id,service_revision) values(c.organization_id,d.id,c.id,c.service_revision) on conflict(demanda_id,conversation_id) do update set service_revision=excluded.service_revision; update public.messages set service_revision=c.service_revision,demanda_id=d.id,demanda_revision=d.revision where id=m.id and organization_id=c.organization_id; end; $$; revoke execute on function public.fn_service_inbound(uuid) from public,anon,authenticated; grant execute on function public.fn_service_inbound(uuid) to service_role; create or replace function public.fn_demanda_abre_no_inbound() returns trigger language plpgsql security definer set search_path=public as $$ begin perform public.fn_service_inbound(new.id); return new; end; $$; revoke execute on function public.fn_demanda_abre_no_inbound() from public,anon,authenticated; -- Comando de status compartilhado pelas duas portas API. Só service_role; o -- handler verifica RBAC/escopo antes da chamada. CAS nunca regrava um desfecho. create or replace function public.fn_service_status(p_org uuid,p_conversation uuid,p_status text,p_expected bigint default null) returns public.conversations language plpgsql security definer set search_path=public as $$ declare c public.conversations; terminal boolean; pre_contact uuid; begin if p_status not in ('closed','resolved','archived','open','pending','ai_handling','claimed') then raise exception 'invalid_status' using errcode='22023'; end if; select * into c from public.conversations where id=p_conversation and organization_id=p_org; if not found then raise exception 'service_not_found' using errcode='P0002'; end if; pre_contact:=c.contact_id; perform public.fn_service_lock(p_org,c.contact_id); select * into c from public.conversations where id=p_conversation and organization_id=p_org for no key update; if c.contact_id is distinct from pre_contact then raise exception 'service_contact_changed' using errcode='40001'; end if; if p_expected is not null and c.service_revision<>p_expected then raise exception 'service_stale' using errcode='40001'; end if; if c.status=p_status then return c; end if; terminal := p_status in ('closed','resolved','archived'); update public.conversations set status=p_status,status_changed_at=clock_timestamp(), service_revision=service_revision+case when terminal or c.status in ('closed','resolved','archived') then 1 else 0 end, service_closed_at=case when terminal then clock_timestamp() else service_closed_at end, service_started_at=case when c.status in ('closed','resolved','archived') and not terminal then clock_timestamp() else service_started_at end, bot_silenced_until=case when terminal and last_handoff_at is null then null else bot_silenced_until end, current_demanda_id=case when c.status in ('closed','resolved','archived') and not terminal then null else current_demanda_id end where id=c.id and organization_id=p_org returning * into c; if terminal then update public.demandas set proximo_passo=coalesce(proximo_passo,'Revisar atendimento e registrar o desfecho da demanda') where organization_id=p_org and id=c.current_demanda_id and fechada_em is null; end if; return c; end; $$; revoke execute on function public.fn_service_status(uuid,uuid,text,bigint) from public,anon,authenticated; grant execute on function public.fn_service_status(uuid,uuid,text,bigint) to service_role; create or replace function public.fn_demanda_encerrar(p_org uuid,p_demanda uuid,p_expected bigint,p_desfecho text,p_actor uuid) returns public.demandas language plpgsql security definer set search_path=public as $$ declare d public.demandas; pre_contact uuid; begin if p_desfecho not in ('resolvida','convertida','nao_procede','encerrada_pelo_cliente','perdida','expirada_sem_resposta') then raise exception 'invalid_desfecho' using errcode='22023'; end if; select * into d from public.demandas where id=p_demanda and organization_id=p_org; if not found then raise exception 'demanda_not_found' using errcode='P0002'; end if; pre_contact:=d.contact_id; perform public.fn_service_lock(p_org,d.contact_id); select * into d from public.demandas where id=p_demanda and organization_id=p_org for no key update; if d.contact_id is distinct from pre_contact then raise exception 'service_contact_changed' using errcode='40001'; end if; if d.revision<>p_expected or d.fechada_em is not null then raise exception 'demanda_stale' using errcode='40001'; end if; update public.demandas set revision=revision+1,desfecho=p_desfecho,fechada_em=clock_timestamp(), encerrada_por=p_actor,estado=case when p_desfecho in ('resolvida','convertida') then 'resolvida' else 'encerrada' end, proximo_passo=null,proximo_passo_em=null,updated_at=clock_timestamp() where id=p_demanda and organization_id=p_org returning * into d; insert into public.crm_lead_activities(organization_id,lead_id,contact_id,source_module,source_id,type,payload,performed_at,performed_by_user_id) select p_org,l.id,d.contact_id,'crm',d.id,'demand_closed',jsonb_build_object('demanda_id',d.id,'desfecho',p_desfecho),clock_timestamp(),p_actor from public.crm_leads l where l.organization_id=p_org and l.contact_id=d.contact_id; return d; end; $$; revoke execute on function public.fn_demanda_encerrar(uuid,uuid,bigint,text,uuid) from public,anon,authenticated; grant execute on function public.fn_demanda_encerrar(uuid,uuid,bigint,text,uuid) to service_role; notify pgrst,'reload schema'; -- Só origem demonstrável. Cron legado/job antigo fica sem provenance e o -- consumidor o encerra stale; nunca carimbar no claim com a conversa de agora. create or replace function public.fn_job_service_boundary() returns trigger language plpgsql security definer set search_path=public as $$ declare b jsonb; begin if new.kind not in ('inbound_turn','followup_turn','case_reply_turn','operator_turn') then return new; end if; if new.payload ? 'inbound_message_id' then select jsonb_build_object('organization_id',m.organization_id,'contact_id',m.contact_id, 'conversation_id',m.conversation_id,'service_revision',m.service_revision, 'demanda_id',m.demanda_id,'demanda_revision',m.demanda_revision) into b from public.messages m where m.id::text=new.payload->>'inbound_message_id' and m.organization_id=new.organization_id and m.contact_id=new.contact_id and m.service_revision is not null; elsif new.payload ? 'origin_job_id' then select j.payload->'service_boundary' into b from public.job_queue j where j.id::text=new.payload->>'origin_job_id' and j.organization_id=new.organization_id and j.contact_id=new.contact_id; elsif new.payload ? 'case_id' then select ac.context_snapshot->'service_boundary' into b from public.agent_cases ac join public.conversations c on c.id=ac.conversation_id and c.organization_id=ac.organization_id where ac.id::text=new.payload->>'case_id' and ac.organization_id=new.organization_id and c.contact_id=new.contact_id; else b:=new.payload->'service_boundary'; end if; new.payload := (new.payload - 'service_boundary') || jsonb_build_object('service_boundary',b); return new; end; $$; revoke execute on function public.fn_job_service_boundary() from public,anon,authenticated; drop trigger if exists trg_job_service_boundary on public.job_queue; create trigger trg_job_service_boundary before insert on public.job_queue for each row execute function public.fn_job_service_boundary(); -- Defesa para escritores legados de status: só a linha já bloqueada, nenhum -- advisory/lock de demanda adquirido DEPOIS dela. As APIs usam fn_service_status. create or replace function public.fn_service_stamp_status() returns trigger language plpgsql set search_path=public as $$ begin if old.status is distinct from new.status and (new.status in ('closed','resolved','archived') or old.status in ('closed','resolved','archived')) then new.service_revision:=old.service_revision+1; if new.status in ('closed','resolved','archived') then new.service_closed_at:=clock_timestamp(); if old.last_handoff_at is null then new.bot_silenced_until:=null; end if; else if new.service_started_at is not distinct from old.service_started_at then new.service_started_at:=clock_timestamp(); end if; if new.current_demanda_id is not distinct from old.current_demanda_id then new.current_demanda_id:=null; end if; if new.status<>'claimed' then new.assigned_to_user_id:=null; new.assigned_to_user_name:=null; new.assigned_at:=null; new.assignee_kind:=null; new.active_ai_agent_id:=null; end if; end if; end if; return new; end; $$; revoke execute on function public.fn_service_stamp_status() from public,anon,authenticated; drop trigger if exists trg_service_stamp_status on public.conversations; create trigger trg_service_stamp_status before update of status on public.conversations for each row execute function public.fn_service_stamp_status(); -- Caso guarda sua origem no snapshot já existente. Resposta humana não cria -- um atendimento novo nem herda a revisão que houver quando for respondido. create or replace function public.fn_case_service_boundary() returns trigger language plpgsql security definer set search_path=public as $$ declare b jsonb; begin select jsonb_build_object('organization_id',c.organization_id,'contact_id',c.contact_id,'conversation_id',c.id, 'service_revision',c.service_revision,'demanda_id',c.current_demanda_id,'demanda_revision',d.revision) into b from public.conversations c left join public.demandas d on d.id=c.current_demanda_id and d.organization_id=c.organization_id where c.id=new.conversation_id and c.organization_id=new.organization_id; new.context_snapshot:=coalesce(new.context_snapshot,'{}'::jsonb)||jsonb_build_object('service_boundary',coalesce(new.context_snapshot->'service_boundary',b)); return new; end; $$; revoke execute on function public.fn_case_service_boundary() from public,anon,authenticated; drop trigger if exists trg_case_service_boundary on public.agent_cases; create trigger trg_case_service_boundary before insert on public.agent_cases for each row execute function public.fn_case_service_boundary(); -- Agregados não podem tornar mensagem atrasada um sinal operacional recente. -- Mesma ordem de mutex da transição, depois conversa e contato. create or replace function public.fn_mark_conversation_message(p_conv uuid,p_direction text,p_preview text,p_at timestamptz) returns void language plpgsql security definer set search_path=public as $$ declare c public.conversations; pre_contact uuid; begin select * into c from public.conversations where id=p_conv; if not found then return; end if; pre_contact:=c.contact_id; perform public.fn_service_lock(c.organization_id,c.contact_id); select * into c from public.conversations where id=p_conv for no key update; if c.contact_id is distinct from pre_contact then raise exception 'service_contact_changed' using errcode='40001'; end if; if p_direction='inbound' and p_at<=c.service_closed_at then return; end if; update public.conversations set last_message_at=greatest(last_message_at,p_at), last_message_preview=case when last_message_at is null or p_at>=last_message_at then p_preview else last_message_preview end, last_inbound_at=case when p_direction='inbound' then greatest(last_inbound_at,p_at) else last_inbound_at end, last_outbound_at=case when p_direction='outbound' then greatest(last_outbound_at,p_at) else last_outbound_at end, unread_count_for_assignee=case when p_direction='inbound' then unread_count_for_assignee+1 when p_direction='outbound' then 0 else unread_count_for_assignee end where id=p_conv and organization_id=c.organization_id; update public.contacts set last_activity_at=greatest(last_activity_at,p_at) where id=c.contact_id and organization_id=c.organization_id; end; $$; revoke execute on function public.fn_mark_conversation_message(uuid,text,text,timestamptz) from public,anon,authenticated; grant execute on function public.fn_mark_conversation_message(uuid,text,text,timestamptz) to service_role; -- Reentrada em fila tem consumidor real em lib/routing/worker.ts. Só a -- transição terminal->fila emite; atribuições subsequentes não produzem eco. drop trigger if exists trg_service_reopened_routing on public.conversations; create trigger trg_service_reopened_routing after update of status on public.conversations for each row when (old.status in ('closed','resolved','archived') and new.status in ('open','pending') and new.assigned_to_user_id is null) execute function public.fn_emit_conversation_routing(); create or replace function public.fn_demanda_revision() returns trigger language plpgsql set search_path=public as $$ begin if new.revision=old.revision and (new.proximo_passo,new.proximo_passo_em,new.estado,new.desfecho,new.fechada_em) is distinct from (old.proximo_passo,old.proximo_passo_em,old.estado,old.desfecho,old.fechada_em) then new.revision:=old.revision+1; end if; return new; end; $$; revoke execute on function public.fn_demanda_revision() from public,anon,authenticated; drop trigger if exists trg_demanda_revision on public.demandas; create trigger trg_demanda_revision before update on public.demandas for each row execute function public.fn_demanda_revision(); -- Snapshot atômico usado tanto pela origem quanto pelo sink. create or replace function public.fn_service_boundary(p_org uuid,p_conversation uuid) returns jsonb language plpgsql security definer set search_path=public as $$ declare c public.conversations; d public.demandas; begin select * into c from public.conversations where organization_id=p_org and id=p_conversation; if not found then return null; end if; if c.current_demanda_id is not null then select * into d from public.demandas where organization_id=p_org and contact_id=c.contact_id and id=c.current_demanda_id; if not found then raise exception 'service_scope_mismatch' using errcode='23503'; end if; end if; return jsonb_build_object('organization_id',c.organization_id,'contact_id',c.contact_id,'conversation_id',c.id, 'service_revision',c.service_revision,'demanda_id',d.id,'demanda_revision',d.revision, 'status',c.status,'demanda_fechada_em',d.fechada_em,'service_started_at',c.service_started_at); end; $$; revoke execute on function public.fn_service_boundary(uuid,uuid) from public,anon,authenticated; grant execute on function public.fn_service_boundary(uuid,uuid) to service_role; -- Nova iniciativa autorizada (humano/MCP/regra), chamada NA ORIGEM, nunca no -- firing. Uma conversa sem demanda é legítima; não inventa assunto do cliente. drop function if exists public.fn_service_begin(uuid,uuid,uuid); drop function if exists public.fn_service_begin(uuid,uuid,uuid,jsonb); create or replace function public.fn_service_begin(p_org uuid,p_contact uuid,p_session uuid default null,p_observed jsonb default null) returns jsonb language plpgsql security definer set search_path=public as $$ declare c public.conversations; sid uuid; begin perform public.fn_service_lock(p_org,p_contact); if not exists(select 1 from public.contacts where id=p_contact and organization_id=p_org and not is_anonymized and is_merged_into is null) then raise exception 'service_contact_not_found' using errcode='P0002'; end if; select * into c from public.conversations where organization_id=p_org and contact_id=p_contact and not is_group and (p_session is null or channel_session_id=p_session) order by last_message_at desc nulls last,created_at desc limit 1 for no key update; if p_observed is not null then if p_observed->>'organization_id' is distinct from p_org::text or p_observed->>'contact_id' is distinct from p_contact::text then raise exception 'service_scope_mismatch' using errcode='23503'; end if; if c.id is null then if p_observed->>'absent' is distinct from 'true' then raise exception 'service_stale' using errcode='40001'; end if; elsif public.fn_service_boundary(p_org,c.id) is distinct from p_observed then raise exception 'service_stale' using errcode='40001'; end if; end if; if c.id is not null then if c.status in ('closed','resolved','archived') then c:=public.fn_service_status(p_org,c.id,'open',c.service_revision); end if; if exists(select 1 from public.demandas where id=c.current_demanda_id and organization_id=p_org and fechada_em is not null) then update public.conversations set service_revision=service_revision+1,current_demanda_id=null,service_started_at=clock_timestamp() where id=c.id and organization_id=p_org returning * into c; end if; if c.service_started_at is null then update public.conversations set service_revision=service_revision+1,service_started_at=clock_timestamp() where id=c.id and organization_id=p_org returning * into c; end if; return public.fn_service_boundary(p_org,c.id); end if; select id into sid from public.channel_sessions where organization_id=p_org and archived_at is null and (p_session is null or id=p_session) order by (status='WORKING') desc,created_at limit 1; if sid is null then raise exception 'service_channel_not_found' using errcode='P0002'; end if; insert into public.conversations(organization_id,contact_id,channel_session_id,status,is_group,channel,service_started_at) values(p_org,p_contact,sid,'open',false,'whatsapp',clock_timestamp()) returning * into c; return public.fn_service_boundary(p_org,c.id); end; $$; revoke execute on function public.fn_service_begin(uuid,uuid,uuid,jsonb) from public,anon,authenticated; grant execute on function public.fn_service_begin(uuid,uuid,uuid,jsonb) to service_role; alter table public.followup_enrollments add column if not exists service_boundary jsonb; -- BEFORE só mutex, nunca transição: precede KEY SHARE implícito das FKs do -- INSERT. Sem isto merge poderia segurar advisory esperando contact FOR UPDATE, -- enquanto o inbound segura contact KEY SHARE esperando o mesmo advisory. create or replace function public.fn_message_service_lock() returns trigger language plpgsql security definer set search_path=public as $$ begin if new.direction='inbound' and new.contact_id is not null then perform public.fn_service_lock(new.organization_id,new.contact_id); end if; return new; end; $$; revoke execute on function public.fn_message_service_lock() from public,anon,authenticated; drop trigger if exists trg_message_service_lock on public.messages; create trigger trg_message_service_lock before insert on public.messages for each row execute function public.fn_message_service_lock(); -- Mescla preserva autorização/support gate e adota a mesma ordem de mutex. CREATE OR REPLACE FUNCTION public.fn_mesclar_contatos(p_organization_id uuid, p_contato_principal uuid, p_contatos_secundarios uuid[]) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $function$ declare v_principal public.contacts%rowtype; v_esperado integer; v_achado integer; v_alvo record; v_linha record; v_movidas integer; v_pulados integer; v_repontado jsonb := '{}'::jsonb; v_nao_repontado jsonb := '{}'::jsonb; v_nome text; v_apelido text; v_nascimento date; v_email text; v_telefone text; v_lid text; v_tags text[]; v_leads integer := 0; v_service_contact uuid; begin if not public.fn_support_write_allowed(p_organization_id) then raise exception 'support_readonly' using errcode='42501'; end if; -- 1 · Autorização. Fundir é destrutivo na prática: `manager`, o mesmo piso das -- policies de `merge_queue`. Sessão de service role (auth.uid() nulo) não -- passa por aqui — quem resolve a org nesse caminho é a rota, de fonte -- confiável, nunca do body. if auth.uid() is not null and not public.fn_role_at_least(p_organization_id, 'manager') then raise exception using errcode = '42501', message = 'insufficient_role'; end if; if p_contato_principal is null or p_contatos_secundarios is null or cardinality(p_contatos_secundarios) = 0 or p_contato_principal = any(p_contatos_secundarios) then raise exception using errcode = '22023', message = 'selecao_de_mesclagem_invalida'; end if; select count(distinct id)::integer into v_esperado from unnest(p_contatos_secundarios) as ids(id); if v_esperado <> cardinality(p_contatos_secundarios) then raise exception using errcode = '22023', message = 'secundario_repetido'; end if; -- Mesmo mutex dos atendimentos, ANTES de qualquer row lock. for v_service_contact in select distinct id from unnest(array[p_contato_principal]||p_contatos_secundarios) ids(id) order by id loop perform public.fn_service_lock(p_organization_id,v_service_contact); end loop; perform 1 from public.conversations where organization_id=p_organization_id and contact_id=any(array[p_contato_principal]||p_contatos_secundarios) order by id for no key update; -- Conversa colidente NÃO aborta a fusão. Duas conversas no mesmo -- `channel_session_id` é exatamente COMO a duplicata de WhatsApp nasce (dois -- cadastros, dois números, o mesmo número de atendimento), então recusar aqui -- fecharia o caminho dominante do recurso — medido: o caso ordinário do -- `tests/e2e/juntar-contatos-duplicados.spec.ts` virava 409. -- Quem trata a colisão é o passo 5: `uniq_conversations_1to1_per_contact_session` -- levanta unique_violation, o repontamento cai para linha a linha, a conversa -- que não coube FICA na lápide e sai contada em `nao_repontado` — que a rota -- devolve e a tela anuncia ("N registro(s) continuaram no cadastro antigo"). -- Mensagem não se perde: `messages.contact_id` não tem índice único por -- contato e passa inteira para o vencedor. -- 2 · O principal existe, é desta org, está vivo — e trava até o fim. select * into v_principal from public.contacts where id = p_contato_principal and organization_id = p_organization_id and is_merged_into is null and is_anonymized = false for update; if not found then raise exception using errcode = 'P0002', message = 'contato_principal_indisponivel'; end if; -- 3 · Os secundários também. `is_anonymized = false` não é zelo: L-04 é -- irreversível, e reencaixar a linha anonimizada num contato ativo a -- traria de volta ao atendimento pela porta dos fundos. perform 1 from public.contacts where id = any(p_contatos_secundarios) and organization_id = p_organization_id and is_merged_into is null and is_anonymized = false for update; get diagnostics v_achado = row_count; if v_achado <> v_esperado then raise exception using errcode = 'P0002', message = 'contato_secundario_indisponivel'; end if; -- 4 · A LÁPIDE VEM ANTES de tudo. É ela que solta telefone/e-mail/CPF dos -- índices únicos parciais para o vencedor poder herdá-los no passo 6. update public.contacts set is_merged_into = p_contato_principal, merged_at = now(), updated_at = now() where organization_id = p_organization_id and id = any(p_contatos_secundarios); -- Cadeia: quem já tinha sido mesclado NUM dos secundários passa a apontar para -- o vencedor. Sem isto, `is_merged_into` vira uma corrente que a leitura teria -- de percorrer, e ninguém percorre. update public.contacts set is_merged_into = p_contato_principal where organization_id = p_organization_id and is_merged_into = any(p_contatos_secundarios); -- 5 · Reponta TODO ponteiro para os perdedores. A lista sai do catálogo; o -- polimórfico entra à mão porque catálogo nenhum o conhece. for v_alvo in select n.nspname as esquema, c.relname as tabela, a.attname as coluna, ''::text as filtro from pg_catalog.pg_constraint co join pg_catalog.pg_class c on c.oid = co.conrelid join pg_catalog.pg_namespace n on n.oid = c.relnamespace join pg_catalog.pg_attribute a on a.attrelid = co.conrelid and a.attnum = co.conkey[1] where co.contype = 'f' and co.confrelid = 'public.contacts'::regclass and co.conrelid <> 'public.contacts'::regclass and array_length(co.conkey, 1) = 1 and c.relkind = 'r' and n.nspname = 'public' union all select 'public', 'crm_lead_links', 'target_id', ' and target_kind = ''contact''' where to_regclass('public.crm_lead_links') is not null order by 2, 3 loop v_pulados := 0; begin execute format( 'update %I.%I set %I = $1 where %I = any($2)%s', v_alvo.esquema, v_alvo.tabela, v_alvo.coluna, v_alvo.coluna, v_alvo.filtro ) using p_contato_principal, p_contatos_secundarios; get diagnostics v_movidas = row_count; exception when unique_violation or exclusion_violation then -- Colisão REAL e esperada: `uniq_job_queue_one_running_per_contact` deixa -- um job 'running' por contato, e os dois lados podem ter um. Em vez de -- abortar a fusão inteira por causa de estado efêmero de runtime, reponta -- linha a linha e conta quem ficou. Quem fica NÃO vira FK órfã — continua -- apontando para a lápide, que existe. v_movidas := 0; for v_linha in execute format( 'select ctid as tid from %I.%I where %I = any($1)%s', v_alvo.esquema, v_alvo.tabela, v_alvo.coluna, v_alvo.filtro ) using p_contatos_secundarios loop begin execute format( 'update %I.%I set %I = $1 where ctid = $2', v_alvo.esquema, v_alvo.tabela, v_alvo.coluna ) using p_contato_principal, v_linha.tid; v_movidas := v_movidas + 1; exception when unique_violation or exclusion_violation then v_pulados := v_pulados + 1; end; end loop; end; if v_movidas > 0 then v_repontado := v_repontado || jsonb_build_object(v_alvo.tabela || '.' || v_alvo.coluna, v_movidas); end if; if v_pulados > 0 then v_nao_repontado := v_nao_repontado || jsonb_build_object(v_alvo.tabela || '.' || v_alvo.coluna, v_pulados); end if; end loop; -- 6 · O principal MANDA; o que ele não tem, vem dos perdedores. Nunca o -- contrário: sobrescrever o que o atendente digitou seria fusão com -- surpresa, e fusão não tem desfazer. select c.name into v_nome from public.contacts c where c.id = any(p_contatos_secundarios) and c.name is not null order by c.created_at, c.id limit 1; select c.display_name into v_apelido from public.contacts c where c.id = any(p_contatos_secundarios) and c.display_name is not null order by c.created_at, c.id limit 1; select c.birthdate into v_nascimento from public.contacts c where c.id = any(p_contatos_secundarios) and c.birthdate is not null order by c.created_at, c.id limit 1; select c.email into v_email from public.contacts c where c.id = any(p_contatos_secundarios) and c.email is not null order by c.created_at, c.id limit 1; select c.phone_number into v_telefone from public.contacts c where c.id = any(p_contatos_secundarios) and c.phone_number is not null order by c.created_at, c.id limit 1; -- `wa_identity`/`wa_lid` são GERADAS: o que se herda é a origem delas. Sem -- isto o WhatsApp do perdedor fica órfão — `fn_upsert_wa_contact` filtra -- `is_merged_into is null`, não acharia mais ninguém e criaria um contato -- novo na mensagem seguinte, refazendo a duplicata que acabou de ser desfeita. select c.source_metadata->>'waha_lid' into v_lid from public.contacts c where c.id = any(p_contatos_secundarios) and c.source_metadata->>'waha_lid' is not null order by c.created_at, c.id limit 1; -- Guardas de unicidade. A lápide já tirou os perdedores dos índices parciais, -- então o que sobrar aqui é conflito com um TERCEIRO contato vivo — e nesse -- caso o vencedor simplesmente não herda o campo. Falhar a fusão inteira por -- causa de um e-mail seria perder o repontamento que já valeu a pena. if v_email is not null and exists ( select 1 from public.contacts o where o.organization_id = p_organization_id and o.is_merged_into is null and o.id <> p_contato_principal and o.email_normalized = lower(btrim(v_email)) ) then v_email := null; end if; if v_telefone is not null and exists ( select 1 from public.contacts o where o.organization_id = p_organization_id and o.is_merged_into is null and o.id <> p_contato_principal and o.phone_number = v_telefone ) then v_telefone := null; end if; if v_lid is not null and exists ( select 1 from public.contacts o where o.organization_id = p_organization_id and o.is_merged_into is null and o.id <> p_contato_principal and o.wa_lid = v_lid ) then v_lid := null; end if; select coalesce(array_agg(distinct t), '{}'::text[]) into v_tags from ( select unnest(c.tags) as t from public.contacts c where c.organization_id = p_organization_id and (c.id = p_contato_principal or c.id = any(p_contatos_secundarios)) ) as todas; -- CPF e `consent` NÃO são herdados, de propósito. CPF é um PAR -- (`cpf_encrypted` + `cpf_hash`) preso por check constraint e criptografado -- com a chave da instalação — mover metade quebra a linha. `consent` é -- registro legal do que AQUELA pessoa autorizou; herdar um "granted_at" de -- outro cadastro fabricaria consentimento. Falha fechada nos dois. update public.contacts set name = coalesce(name, v_nome), display_name = coalesce(display_name, v_apelido), birthdate = coalesce(birthdate, v_nascimento), email = coalesce(email, v_email), phone_number = coalesce(phone_number, v_telefone), tags = v_tags, last_activity_at = greatest( last_activity_at, (select max(c.last_activity_at) from public.contacts c where c.id = any(p_contatos_secundarios)) ), source_metadata = ( case when source_metadata->>'waha_lid' is null and v_lid is not null then source_metadata || jsonb_build_object('waha_lid', v_lid) else source_metadata end ) - case when coalesce(phone_number, v_telefone) is not null then 'telefone_em_conflito' else '' end || jsonb_build_object( 'mesclado_de', coalesce(source_metadata->'mesclado_de', '[]'::jsonb) || to_jsonb(p_contatos_secundarios), 'mesclado_em', to_jsonb(now()) ), updated_at = now() where id = p_contato_principal and organization_id = p_organization_id; -- 7 · A fusão aparece na timeline de cada negócio que o vencedor passou a ter. -- `crm_lead_activities.lead_id` é NOT NULL — contato sem negócio nenhum -- não tem onde escrever, e para esse caso quem guarda o rastro é o -- `api_audit_log` que a rota emite, sempre. insert into public.crm_lead_activities (organization_id, lead_id, contact_id, source_module, source_id, type, payload, metadata, performed_at, performed_by_user_id) select p_organization_id, l.id, p_contato_principal, 'crm', p_contato_principal, 'contacts_merged', jsonb_build_object( 'contatos_mesclados', to_jsonb(p_contatos_secundarios), 'repontado', v_repontado, 'nao_repontado', v_nao_repontado ), '{}'::jsonb, now(), auth.uid() from public.crm_leads l where l.organization_id = p_organization_id and l.contact_id = p_contato_principal; get diagnostics v_leads = row_count; return jsonb_build_object( 'contato_id', p_contato_principal, 'contatos_mesclados', to_jsonb(p_contatos_secundarios), 'repontado', v_repontado, 'nao_repontado', v_nao_repontado, 'atividades_emitidas', v_leads ); end; $function$; -- Observação da porta de comando. Captura até ausência/terminal sem abrir uma -- conversa só porque um card mudou de etapa; consumidor autorizado usa CAS. create or replace function public.fn_service_observe(p_org uuid,p_contact uuid) returns jsonb language plpgsql security definer set search_path=public as $$ declare cid uuid; begin select id into cid from public.conversations where organization_id=p_org and contact_id=p_contact and not is_group order by last_message_at desc nulls last,created_at desc limit 1; if cid is null then return jsonb_build_object('absent',true,'organization_id',p_org,'contact_id',p_contact); end if; return public.fn_service_boundary(p_org,cid); end; $$; revoke execute on function public.fn_service_observe(uuid,uuid) from public,anon,authenticated; grant execute on function public.fn_service_observe(uuid,uuid) to service_role; -- ---- Backfill de continuidade da fronteira (migration 0222) ---- -- As colunas acima nascem NULAS, e o consumidor lê AUSÊNCIA DE CARIMBO como -- "fronteira vencida". Numa instalação que já roda, isso não é uma degradação -- discreta: no primeiro tick depois do `update.sh` todo acompanhamento em -- curso é cancelado com "Atendimento encerrado ou substituído", a varredura de -- silêncio fica cega justamente para quem não manda mensagem nova, e o próximo -- inbound abre uma SEGUNDA demanda aberta na mesma conversa. -- Carimbamos só o que já é OBSERVÁVEL no trabalho legado — nunca um assunto -- novo. Idempotente: cada passo toca apenas linha ainda sem carimbo. -- 1 · Toda conversa tem um começo. Sem ele o histórico de saída some do -- contexto do agente (`messages.sent_at >= c.service_started_at`). update public.conversations set service_started_at = created_at where service_started_at is null; -- 2 · A demanda aberta que já estava vinculada à conversa segue sendo a -- vigente. Sem isto `fn_service_inbound` não acha nada em -- `x.id = c.current_demanda_id` e abre outra. with vigente as ( select distinct on (dc.conversation_id) dc.conversation_id, dc.demanda_id from public.demanda_conversas dc join public.demandas d on d.id = dc.demanda_id and d.organization_id = dc.organization_id where d.fechada_em is null order by dc.conversation_id, d.aberta_em desc, d.id ) update public.conversations c set current_demanda_id = v.demanda_id from vigente v where v.conversation_id = c.id and c.current_demanda_id is null and c.status not in ('closed','resolved','archived'); -- 3 · O vínculo carrega a revisão da conversa; o reaproveitamento exige -- `dc.service_revision = c.service_revision`. update public.demanda_conversas dc set service_revision = c.service_revision from public.conversations c where c.id = dc.conversation_id and c.organization_id = dc.organization_id and dc.service_revision is null; -- 4 · Mensagem legada pertence ao atendimento vigente da sua conversa. -- `trg_appointment_inbound` (migration posterior) trata o carimbo como -- EVENTO de entrada: sem pausá-lo, o backfill replicaria recuperação de -- agenda para o histórico inteiro. É um `do` único de propósito — sob o -- autocommit do `update.sh`, ou tudo entra e o gatilho volta, ou nada -- entra. Se faltar privilégio para pausar, o backfill segue mesmo assim -- (carimbar tarde é melhor que não carimbar) e o notice registra. do $$ declare v_pausado boolean := false; v_linhas bigint := 0; v_restantes bigint := 0; begin begin if exists (select 1 from pg_trigger where tgrelid = 'public.messages'::regclass and tgname = 'trg_appointment_inbound' and not tgisinternal) then execute 'alter table public.messages disable trigger trg_appointment_inbound'; v_pausado := true; end if; exception when others then v_pausado := false; -- `warning` e não `notice`: o dump do baseline abre com -- `set client_min_messages = warning`, então notice NUNCA chega ao operador. raise warning '0222 backfill: nao foi possivel pausar trg_appointment_inbound (%)', sqlerrm; end; update public.messages m set service_revision = c.service_revision, demanda_id = c.current_demanda_id, demanda_revision = d.revision from public.conversations c left join public.demandas d on d.id = c.current_demanda_id and d.organization_id = c.organization_id where c.id = m.conversation_id and c.organization_id = m.organization_id and m.direction = 'inbound' and m.service_revision is null; get diagnostics v_linhas = row_count; if v_pausado then execute 'alter table public.messages enable trigger trg_appointment_inbound'; end if; -- O operador precisa ver o que a atualização mexeu, e este notice é também -- o controle positivo de que o gatilho foi de fato pausado durante o carimbo. if v_linhas > 0 then raise warning '0222 backfill: % mensagem(ns) carimbada(s) (gatilho de agenda pausado: %)', v_linhas, v_pausado; end if; -- O RESIDUO, e por que ele e a rede de seguranca CERTA. -- -- O `update.sh` roda o baseline SEM `ON_ERROR_STOP`, entao este passo pode -- morrer calado depois de o passo 1 ja ter entrado. A instalacao fica com -- `service_started_at` carimbado e mensagens sem carimbo — e a varredura de -- silencio, que EXIGE procedencia, ignora essas linhas: o acompanhamento -- para de achar quem esta calado, sem nada na tela. -- -- Ja houve aqui um cinto no CONSUMIDOR (degradar para `last_inbound_at` -- quando faltasse carimbo). Ele foi removido porque a falta de carimbo nao -- e sinal de legado: e NORMAL em duas classes, e nas duas o cinto inscrevia -- gente que nao devia — conversa de GRUPO e mensagem entregue FORA DE ORDEM -- depois de um fechamento, as duas com saida cedo em `fn_service_inbound`. -- Sao exatamente as duas que este `where` exclui: o que sobra so pode ser -- passo 4 que nao terminou. select count(*) into v_restantes from public.messages m join public.conversations c on c.id = m.conversation_id and c.organization_id = m.organization_id where m.direction = 'inbound' and m.service_revision is null and not c.is_group and coalesce(c.group_chat_id, '') not like '%@g.us' and (c.service_closed_at is null or m.sent_at > c.service_closed_at); if v_restantes > 0 then raise warning '0222 backfill: % mensagem(ns) inbound seguem SEM carimbo — a varredura de silencio ignora essas linhas. Re-rode o update.sh; se persistir, aplique o passo 4 a mao e abra issue.', v_restantes; end if; end $$; -- 5 · Acompanhamento em curso mantém a fronteira da conversa a que já -- pertence. Linha a linha: `trg_followup_revision` pode recusar a linha de -- recuperação de agenda cuja recibo já não vale, e uma recusa dessas não -- pode derrubar o backfill das outras. do $$ declare r record; begin for r in select e.id, e.organization_id, c.id as conversation_id, jsonb_build_object( 'organization_id', c.organization_id, 'contact_id', c.contact_id, 'conversation_id', c.id, 'service_revision', c.service_revision, 'demanda_id', c.current_demanda_id, 'demanda_revision', d.revision) as fronteira from public.followup_enrollments e join public.conversations c on c.organization_id = e.organization_id and c.contact_id = e.contact_id and c.id = coalesce(e.conversation_id, ( select c2.id from public.conversations c2 where c2.organization_id = e.organization_id and c2.contact_id = e.contact_id and not c2.is_group and c2.status not in ('closed','resolved','archived') order by c2.last_message_at desc nulls last, c2.created_at desc limit 1)) left join public.demandas d on d.id = c.current_demanda_id and d.organization_id = c.organization_id where e.service_boundary is null and e.status not in ('completed','cancelled','dead') and c.status not in ('closed','resolved','archived') loop begin update public.followup_enrollments set service_boundary = r.fronteira, conversation_id = r.conversation_id where id = r.id and organization_id = r.organization_id and service_boundary is null; exception when others then raise warning '0222 backfill: acompanhamento % segue sem fronteira (%)', r.id, sqlerrm; end; end loop; end $$; -- ---- origem imutável do evento (0223) ---- -- 0223 — Uma resolução imutável da origem por evento/destino, compartilhada entre -- automação e gatilho de etapa e entre retries. Não certifica legado. -- DIRC: recibo por destino do evento; sem payload operacional/memória nova. Retenção -- acompanha event_log; somente UUIDs/revisões, sem cópia de PII. create table if not exists public.event_service_origins ( event_id uuid not null references public.event_log(id) on delete cascade, channel_session_id uuid not null references public.channel_sessions(id) on delete cascade, organization_id uuid not null references public.organizations(id) on delete cascade, service_boundary jsonb not null, primary key(event_id,channel_session_id) ); alter table public.event_service_origins enable row level security; revoke all on public.event_service_origins from public,anon,authenticated,service_role; grant select on public.event_service_origins to service_role; CREATE OR REPLACE FUNCTION public.emit_event(p_event_type text, p_entity_kind text, p_entity_id uuid, p_payload jsonb DEFAULT '{}'::jsonb, p_metadata jsonb DEFAULT '{}'::jsonb, p_organization_id uuid DEFAULT NULL::uuid) RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public' AS $function$ declare v_org_id uuid; v_event_id uuid; begin -- message.received nasce somente do INSERT inbound interno. Um chamador -- público não pode reapresentar uma mensagem existente como evento novo. if auth.uid() is not null and p_event_type = 'message.received' then raise exception 'reserved_message_received' using errcode='42501'; end if; -- Estes campos autorizam efeitos operacionais; não são payload público. if auth.uid() is not null and ( coalesce(p_payload,'{}'::jsonb) ?| array['service_origin','service_boundary'] or coalesce(p_metadata,'{}'::jsonb) ?| array['service_origin','service_boundary'] ) then raise exception 'reserved_service_origin' using errcode='42501'; end if; v_org_id := coalesce(p_organization_id, (public.fn_support_context()->>'organization_id')::uuid); if v_org_id is null then select organization_id into v_org_id from public.user_organizations where user_id = auth.uid() and revoked_at is null limit 1; end if; if v_org_id is null then raise exception 'emit_event: organization_id obrigatorio'; end if; if auth.uid() is not null and not public.fn_role_at_least(v_org_id, 'viewer') then raise exception 'caller_not_authorized_for_org' using hint = 'emit_event: caller must be an active member of the organization'; end if; if not public.fn_support_write_allowed(v_org_id) then raise exception 'support_readonly' using errcode='42501'; end if; insert into public.event_log (organization_id, event_type, entity_kind, entity_id, payload, metadata) values (v_org_id, p_event_type, p_entity_kind, p_entity_id, coalesce(p_payload, '{}'::jsonb), coalesce(p_metadata, '{}'::jsonb) || jsonb_build_object('emitted_at', extract(epoch from now()))) returning id into v_event_id; return v_event_id; end $function$; -- Um único snapshot SQL: inclui a ausência de conversa em cada sessão permitida. create or replace function public.fn_service_observe_command(p_org uuid,p_contact uuid) returns jsonb language sql stable security definer set search_path=public as $$ with destinations as ( select s.id sid,c.id cid,c.last_message_at,c.created_at conversation_created,s.created_at session_created,s.status, jsonb_build_object('channel_session_id',s.id,'observed',case when c.id is null then jsonb_build_object('organization_id',p_org,'contact_id',p_contact,'absent',true) else jsonb_build_object('organization_id',c.organization_id,'contact_id',c.contact_id,'conversation_id',c.id, 'service_revision',c.service_revision,'demanda_id',d.id,'demanda_revision',d.revision, 'status',c.status,'demanda_fechada_em',d.fechada_em,'service_started_at',c.service_started_at) end) snapshot from public.channel_sessions s left join public.conversations c on c.organization_id=s.organization_id and c.channel_session_id=s.id and c.contact_id=p_contact and not c.is_group left join public.demandas d on d.organization_id=c.organization_id and d.contact_id=c.contact_id and d.id=c.current_demanda_id where s.organization_id=p_org and s.archived_at is null and exists(select 1 from public.contacts where organization_id=p_org and id=p_contact and not is_anonymized and is_merged_into is null) ) select jsonb_build_object('organization_id',p_org,'contact_id',p_contact, 'default_session_id',(select sid from destinations order by (cid is not null) desc,last_message_at desc nulls last,conversation_created desc nulls last,(status='WORKING') desc,session_created limit 1), 'destinations',coalesce((select jsonb_agg(snapshot) from destinations),'[]'::jsonb)); $$; revoke all on function public.fn_service_observe_command(uuid,uuid) from public,anon,authenticated; grant execute on function public.fn_service_observe_command(uuid,uuid) to service_role; create or replace function public.fn_service_event_origin(p_org uuid,p_event uuid,p_contact uuid,p_session uuid default null) returns jsonb language plpgsql security definer set search_path=public as $$ declare e public.event_log; origin jsonb; boundary jsonb; current_boundary jsonb; entity_contact uuid; cid uuid; sid uuid; observed jsonb; root_event uuid:=p_event; visited uuid[]:=array[]::uuid[]; begin -- O drain faz claim otimista em outra transação; não conserva row lock. -- Não travar event_log: advisory contato antecede os locks de conversa/FKs. perform public.fn_service_lock(p_org,p_contact); loop if root_event = any(visited) or cardinality(visited)>=32 then raise exception 'service_origin_cycle' using errcode='40001'; end if; visited:=array_append(visited,root_event); boundary:=null; entity_contact:=null; select * into e from public.event_log where organization_id=p_org and id=root_event; if not found then raise exception 'service_event_not_found' using errcode='P0002'; end if; if e.event_type in ('lead.created','lead.stage_changed','lead.tag_added') and e.entity_kind='crm_lead' then select contact_id into entity_contact from public.crm_leads where organization_id=p_org and id=e.entity_id; elsif e.event_type='contact.tag_added' and e.entity_kind='contact' then select id into entity_contact from public.contacts where organization_id=p_org and id=e.entity_id; elsif e.event_type='message.received' and e.entity_kind='message' then select contact_id,jsonb_build_object('organization_id',organization_id,'contact_id',contact_id, 'conversation_id',conversation_id,'service_revision',service_revision,'demanda_id',demanda_id,'demanda_revision',demanda_revision) into entity_contact,boundary from public.messages where organization_id=p_org and id=e.entity_id and direction='inbound'; else raise exception 'service_event_origin_unsupported' using errcode='40001'; end if; if entity_contact is distinct from p_contact or not exists(select 1 from public.contacts where organization_id=p_org and id=p_contact and not is_anonymized and is_merged_into is null) then raise exception 'service_scope_mismatch' using errcode='23503'; end if; origin:=e.payload->'service_origin'; if origin->>'kind'='event' then if origin->>'organization_id' is distinct from p_org::text or origin->>'contact_id' is distinct from p_contact::text then raise exception 'service_scope_mismatch' using errcode='23503'; end if; root_event:=(origin->>'event_id')::uuid; if root_event is null then raise exception 'service_stale' using errcode='40001'; end if; continue; end if; exit; end loop; if boundary is not null or origin->>'kind'='continuation' then boundary:=coalesce(boundary,origin->'boundary'); select channel_session_id into sid from public.conversations where organization_id=p_org and contact_id=p_contact and id=(boundary->>'conversation_id')::uuid; if p_session is not null and p_session is distinct from sid then raise exception 'service_channel_mismatch' using errcode='23503'; end if; elsif origin->>'kind'='command' then observed:=origin->'observed'; if observed->>'organization_id' is distinct from p_org::text or observed->>'contact_id' is distinct from p_contact::text then raise exception 'service_scope_mismatch' using errcode='23503'; end if; if jsonb_typeof(observed->'destinations')='array' then sid:=coalesce(p_session,(observed->>'default_session_id')::uuid); select item->'observed' into observed from jsonb_array_elements(observed->'destinations') item where item->>'channel_session_id'=sid::text; else -- Compatibilidade com snapshot anterior: prova somente sua conversa, nunca ausência de outro canal. select channel_session_id into sid from public.conversations where organization_id=p_org and contact_id=p_contact and id=(observed->>'conversation_id')::uuid; if p_session is not null and p_session is distinct from sid then raise exception 'service_channel_mismatch' using errcode='23503'; end if; end if; else raise exception 'service_stale' using errcode='40001'; end if; if sid is null then raise exception 'service_stale' using errcode='40001'; end if; if not exists(select 1 from public.channel_sessions where id=sid and organization_id=p_org and archived_at is null) then raise exception 'service_channel_mismatch' using errcode='23503'; end if; if boundary is null and observed is null then raise exception 'service_stale' using errcode='40001'; end if; select service_boundary into current_boundary from public.event_service_origins where organization_id=p_org and event_id=root_event and channel_session_id=sid; if found then boundary:=current_boundary; elsif boundary is null then -- PARA UM EVENTO, `absent` E PROCEDENCIA — NAO REIVINDICACAO DE ESTADO. -- -- O CAS de `fn_service_begin` existe para que dois ATORES com a mesma -- observacao "ausente" nao ajam os dois: o segundo tem de perder, e o -- invariante de `fn_service_begin` guarda isso. Um evento e outra coisa: o -- retrato `absent` diz "quando este evento foi EMITIDO nao havia -- atendimento", e a resolucao de cada evento ja e idempotente pelo memo -- `event_service_origins` logo acima — nao ha corrida a arbitrar aqui. -- -- Sem esta distincao o caminho ORDINARIO morria: um lead criado e depois -- movido de etapa gera DOIS eventos, cada um com seu retrato `absent`; -- resolver o primeiro cria a conversa e o segundo levantava 40001 — que -- `serviceForEvent` engole como `stale_origin`, entao o follow-up de etapa -- simplesmente nao nascia, sem erro em lugar nenhum. -- -- Zerar `observed` so quando a conversa JA existe mantem o CAS de pe para o -- retrato que descreve uma fronteira concreta (esse continua sendo conferido -- contra a vigente) e para todo chamador direto de `fn_service_begin`. if observed->>'absent' = 'true' and exists( select 1 from public.conversations where organization_id=p_org and contact_id=p_contact and channel_session_id=sid and not is_group) then observed:=null; end if; boundary:=public.fn_service_begin(p_org,p_contact,sid,observed) - 'status' - 'demanda_fechada_em' - 'service_started_at'; end if; if boundary->>'organization_id' is distinct from p_org::text or boundary->>'contact_id' is distinct from p_contact::text then raise exception 'service_scope_mismatch' using errcode='23503'; end if; cid:=(boundary->>'conversation_id')::uuid; if p_session is not null and not exists(select 1 from public.conversations where organization_id=p_org and id=cid and contact_id=p_contact and channel_session_id=p_session) then raise exception 'service_channel_mismatch' using errcode='23503'; end if; current_boundary:=public.fn_service_boundary(p_org,cid); if current_boundary is null or current_boundary->>'status' in ('closed','resolved','archived') or current_boundary->>'demanda_fechada_em' is not null or (current_boundary - 'status' - 'demanda_fechada_em' - 'service_started_at') is distinct from boundary then raise exception 'service_stale' using errcode='40001'; end if; insert into public.event_service_origins(event_id,channel_session_id,organization_id,service_boundary) values(root_event,sid,p_org,boundary) on conflict(event_id,channel_session_id) do nothing; return boundary; end; $$; revoke all on function public.fn_service_event_origin(uuid,uuid,uuid,uuid) from public,anon,authenticated; grant execute on function public.fn_service_event_origin(uuid,uuid,uuid,uuid) to service_role; -- ---- Presença e recuperação (migration 0224) ---- -- 0224 — Presença é declaração humana; o relógio só pede confirmação. -- DIRC: vínculos/estado/horário reutilizados; revisão mede intenção, não sync. -- Recibo privado é decisão terminal, sem fila, e sobrevive ao expurgo do evento. alter table public.calendar_appointments add column if not exists revision bigint not null default 1, add column if not exists revision_started_at timestamptz not null default now(), add column if not exists outcome_source_kind text, add column if not exists outcome_user_id uuid references auth.users(id) on delete set null, add column if not exists outcome_message_id uuid references public.messages(id) on delete set null, add column if not exists outcome_recorded_at timestamptz, add column if not exists confirmation_next_at timestamptz; alter table public.followup_enrollments add column if not exists appointment_id uuid references public.calendar_appointments(id) on delete set null, add column if not exists appointment_revision bigint, add column if not exists revision bigint not null default 1; create unique index if not exists followup_appointment_revision_unique on public.followup_enrollments(organization_id,pointer_id,appointment_id,appointment_revision) where appointment_id is not null; create table if not exists public.appointment_recovery_receipts ( organization_id uuid not null references public.organizations(id) on delete cascade, appointment_id uuid not null references public.calendar_appointments(id) on delete cascade, appointment_revision bigint not null, source_event_id uuid references public.event_log(id) on delete set null, result text not null check(result in ('started','other_flow','ambiguous','not_configured','stale','no_contact')), pointer_id uuid references public.followup_flow_pointers(id) on delete set null, enrollment_id uuid references public.followup_enrollments(id) on delete set null, recorded_at timestamptz not null default now(), invalidated_at timestamptz, primary key(organization_id,appointment_id,appointment_revision) ); alter table public.appointment_recovery_receipts enable row level security; revoke all on public.appointment_recovery_receipts from public,anon,authenticated,service_role; grant select on public.appointment_recovery_receipts to service_role; -- Um aviso por revisão, inclusive depois de resolvido. Identidade não depende -- de SELECT seguido de INSERT, nem da duração de um lease do cron. alter table public.agent_inbox_items add column if not exists appointment_revision bigint; create unique index if not exists inbox_appointment_revision_unique on public.agent_inbox_items(organization_id,ref_id,appointment_revision,kind) where ref_kind='appointment' and appointment_revision is not null; create or replace function public.fn_appointment_stamp() returns trigger language plpgsql security definer set search_path=public as $$ declare changed boolean; actor uuid; begin if new.contact_id is not null and not exists(select 1 from public.contacts where id=new.contact_id and organization_id=new.organization_id) then raise exception 'appointment_contact_scope' using errcode='23503'; end if; if new.conversation_id is not null and not exists(select 1 from public.conversations where id=new.conversation_id and organization_id=new.organization_id and contact_id=new.contact_id and not is_group and (auth.uid() is null or public.fn_can_view_conversation(organization_id,assigned_to_user_id))) then raise exception 'appointment_conversation_scope' using errcode='23503'; end if; if tg_op='INSERT' then new.revision:=1; -- Legado importado sem autoria não vira fato certificado. new.outcome_source_kind:=null; new.outcome_user_id:=null; new.outcome_message_id:=null; new.outcome_recorded_at:=null; return new; end if; changed:=row(new.starts_at,new.ends_at,new.status,new.contact_id,new.conversation_id) is distinct from row(old.starts_at,old.ends_at,old.status,old.contact_id,old.conversation_id); new.revision:=old.revision+case when changed then 1 else 0 end; new.revision_started_at:=case when changed then clock_timestamp() else old.revision_started_at end; if changed then new.confirmation_next_at:=null; end if; if new.status is distinct from old.status and new.status in ('completed','no_show') then actor:=auth.uid(); if actor is null or not public.fn_role_at_least(new.organization_id,'agent') or not public.fn_support_write_allowed(new.organization_id) then raise exception 'appointment_human_confirmation_required' using errcode='42501'; end if; if new.starts_at>now() then raise exception 'appointment_not_started' using errcode='22023'; end if; new.outcome_user_id:=actor; new.outcome_recorded_at:=clock_timestamp(); new.outcome_source_kind:=case when new.outcome_message_id is null then 'user' else 'contact_message' end; if new.outcome_message_id is not null and not exists( select 1 from public.messages m join public.conversations c on c.id=m.conversation_id and c.organization_id=m.organization_id where m.id=new.outcome_message_id and m.organization_id=new.organization_id and m.contact_id=new.contact_id and (new.conversation_id is null or m.conversation_id=new.conversation_id) and m.direction='inbound' and m.service_revision is not null and m.service_revision=c.service_revision and m.demanda_id is not distinct from c.current_demanda_id and m.created_at>=old.revision_started_at and not c.is_group and public.fn_can_view_conversation(c.organization_id,c.assigned_to_user_id) ) then raise exception 'appointment_message_not_evidence' using errcode='42501'; end if; elsif changed then new.outcome_source_kind:=null; new.outcome_user_id:=null; new.outcome_message_id:=null; new.outcome_recorded_at:=null; else new.outcome_source_kind:=old.outcome_source_kind; -- SET NULL por retenção da FK é erosão de referência, não nova autoria. new.outcome_user_id:=case when new.outcome_user_id is null and not exists(select 1 from auth.users where id=old.outcome_user_id) then null else old.outcome_user_id end; new.outcome_message_id:=case when new.outcome_message_id is null and not exists(select 1 from public.messages where id=old.outcome_message_id and organization_id=old.organization_id) then null else old.outcome_message_id end; new.outcome_recorded_at:=old.outcome_recorded_at; end if; return new; end; $$; revoke all on function public.fn_appointment_stamp() from public,anon,authenticated; drop trigger if exists trg_appointment_stamp on public.calendar_appointments; create trigger trg_appointment_stamp before insert or update on public.calendar_appointments for each row execute function public.fn_appointment_stamp(); -- A mudança observada e o evento estão no mesmo commit. Sem janela em que -- o desfecho ficou gravado e a recuperação nunca soube dele. create or replace function public.fn_appointment_change(p_org uuid,p_id uuid,p_revision bigint,p_patch jsonb) returns jsonb language plpgsql security definer set search_path=public as $$ declare a public.calendar_appointments; contact uuid; origin jsonb; event_id uuid; begin if auth.uid() is not null and (not public.fn_role_at_least(p_org,'agent') or not public.fn_support_write_allowed(p_org)) then raise exception 'appointment_forbidden' using errcode='42501'; end if; select contact_id into contact from public.calendar_appointments where organization_id=p_org and id=p_id; if not found then raise exception 'appointment_not_found' using errcode='P0002'; end if; if contact is not null then perform public.fn_service_lock(p_org,contact); end if; select * into a from public.calendar_appointments where organization_id=p_org and id=p_id for update; if a.contact_id is distinct from contact or a.revision is distinct from p_revision then raise exception 'appointment_stale' using errcode='40001'; end if; if a.status='cancelled' then raise exception 'appointment_cancelled' using errcode='22023'; end if; if contact is not null then origin:=jsonb_build_object('kind','command','observed',public.fn_service_observe_command(p_org,contact)); end if; update public.calendar_appointments set starts_at=case when p_patch?'starts_at' then (p_patch->>'starts_at')::timestamptz else starts_at end, ends_at=case when p_patch?'ends_at' then (p_patch->>'ends_at')::timestamptz else ends_at end, time_zone=coalesce(p_patch->>'time_zone',time_zone), status=coalesce(p_patch->>'status',status), cancelled_at=case when p_patch->>'status'='cancelled' then now() else cancelled_at end, cancellation_reason=case when p_patch?'cancellation_reason' then p_patch->>'cancellation_reason' else cancellation_reason end, notes=case when p_patch?'notes' then p_patch->>'notes' else notes end, guest_email=case when p_patch?'guest_email' then p_patch->>'guest_email' else guest_email end, outcome_message_id=case when p_patch?'outcome_message_id' then (p_patch->>'outcome_message_id')::uuid else null end, confirmation_next_at=case when p_patch?'confirmation_next_at' then (p_patch->>'confirmation_next_at')::timestamptz else confirmation_next_at end where organization_id=p_org and id=p_id returning * into a; if p_patch?'confirmation_next_at' and (a.confirmation_next_at<=now() or a.confirmation_next_at>now()+interval '24 hours') then raise exception 'appointment_invalid_snooze' using errcode='22023'; end if; update public.followup_enrollments set status='cancelled',cancel_reason='O compromisso mudou. Revise o próximo passo.',completed_at=now(),next_eval_at=null,claimed_until=null where organization_id=p_org and appointment_id=p_id and appointment_revision<>a.revision and status in ('active','waiting_reply','paused_handoff','paused_manual'); update public.agent_inbox_items set status='resolved',resolved_at=now() where organization_id=p_org and ref_kind='appointment' and ref_id=p_id and status='open' and (appointment_revision<>a.revision or a.status in ('completed','no_show','cancelled') or p_patch?'confirmation_next_at'); if contact is not null and a.status='no_show' and a.outcome_recorded_at is not null and a.revision<>p_revision then insert into public.event_log(organization_id,event_type,entity_kind,entity_id,payload) values(p_org,'appointment.outcome_confirmed','appointment',p_id, jsonb_build_object('appointment_revision',a.revision,'service_origin',origin)) returning id into event_id; end if; return to_jsonb(a); end; $$; revoke all on function public.fn_appointment_change(uuid,uuid,bigint,jsonb) from public,anon,authenticated; grant execute on function public.fn_appointment_change(uuid,uuid,bigint,jsonb) to authenticated,service_role; -- Certificação ocorre sob o mutex ANTES dos locks de mensagens/FKs (Task4). -- A ordem é a do lock, não created_at (now() mede início da transação). -- Identidade é a nova mensagem persistida. Timestamp externo anterior ao -- segundo do desfecho é histórico; igualdade de segundo conta. Sem timestamp, -- o fallback de ingestão não permite distinguir histórico de entrada live. create or replace function public.fn_appointment_inbound() returns trigger language plpgsql security definer set search_path=public as $$ declare a record; begin if auth.uid() is null and old.service_revision is null and new.service_revision is not null and new.direction='inbound' then perform public.fn_service_lock(new.organization_id,new.contact_id); for a in select * from public.calendar_appointments where organization_id=new.organization_id and contact_id=new.contact_id and status='no_show' and outcome_recorded_at is not null and new.sent_at>=date_trunc('second',outcome_recorded_at) for update loop insert into public.appointment_recovery_receipts(organization_id,appointment_id,appointment_revision,result,invalidated_at) values(a.organization_id,a.id,a.revision,'stale',clock_timestamp()) on conflict(organization_id,appointment_id,appointment_revision) do update set invalidated_at=excluded.invalidated_at; update public.followup_enrollments set status='cancelled',cancel_reason='O cliente respondeu. Revise o próximo passo.',completed_at=now(),next_eval_at=null,claimed_until=null where organization_id=new.organization_id and contact_id=new.contact_id and appointment_id=a.id and appointment_revision=a.revision and status in ('active','waiting_reply','paused_handoff','paused_manual'); end loop; end if; return new; end; $$; revoke all on function public.fn_appointment_inbound() from public,anon,authenticated; drop trigger if exists trg_appointment_inbound on public.messages; create trigger trg_appointment_inbound after update of service_revision on public.messages for each row execute function public.fn_appointment_inbound(); create or replace function public.fn_followup_revision() returns trigger language plpgsql security definer set search_path=public as $$ begin -- Origem da recuperação é imutável, inclusive para quem pode editar o fluxo. -- Só o desaparecimento real da FK permite apagar a referência, cancelando-o. if old.appointment_revision is not null then new.appointment_revision:=old.appointment_revision; if new.appointment_id is null and not exists(select 1 from public.calendar_appointments where organization_id=old.organization_id and id=old.appointment_id) then new.status:='cancelled';new.cancel_reason:='O compromisso foi removido.';new.next_eval_at:=null;new.claimed_until:=null;new.completed_at:=now(); else new.appointment_id:=old.appointment_id; end if; if new.contact_id is distinct from old.contact_id then new.status:='cancelled';new.cancel_reason:='O contato do compromisso mudou.';new.next_eval_at:=null;new.claimed_until:=null;new.completed_at:=now(); end if; end if; if new.appointment_revision is not null and new.status in ('active','waiting_reply','paused_handoff','paused_manual') then -- P0001, não 40001: 40001 é serialization_failure e o cliente retenta para -- sempre. followup_stale é permanente. Medido: 6000 erros/min, CPU 100%, 24h. if old.status not in ('active','waiting_reply','paused_handoff','paused_manual') or not exists( select 1 from public.calendar_appointments a join public.appointment_recovery_receipts r on r.organization_id=a.organization_id and r.appointment_id=a.id and r.appointment_revision=a.revision where a.organization_id=new.organization_id and a.id=new.appointment_id and a.revision=new.appointment_revision and a.status='no_show' and a.contact_id=new.contact_id and r.result='started' and r.invalidated_at is null ) then raise exception 'followup_stale' using errcode='P0001'; end if; end if; new.revision:=old.revision+1; return new; end; $$; revoke all on function public.fn_followup_revision() from public,anon,authenticated; drop trigger if exists trg_followup_revision on public.followup_enrollments; create trigger trg_followup_revision before update on public.followup_enrollments for each row execute function public.fn_followup_revision(); -- Defaults também são validados no schema TS. Valores corrompidos de clone -- degradam para 10min/24h; não passam cast inseguro no sweep de toda instalação. create or replace function public.fn_agenda_minutes(p_settings jsonb,p_key text,p_default int) returns int language plpgsql immutable set search_path=public as $$ declare cfg jsonb:=p_settings->'agenda'; delay int; horizon int; begin if jsonb_typeof(cfg) is distinct from 'object' or jsonb_typeof(cfg->'confirmation_delay_minutes') is distinct from 'number' or jsonb_typeof(cfg->'unknown_protection_minutes') is distinct from 'number' or (cfg->>'confirmation_delay_minutes' ~ '^[0-9]{1,5}$') is not true or (cfg->>'unknown_protection_minutes' ~ '^[0-9]{1,5}$') is not true then return p_default; end if; delay:=(cfg->>'confirmation_delay_minutes')::int; horizon:=(cfg->>'unknown_protection_minutes')::int; if delay not between 1 and 10080 or horizon not between delay and 10080 or (cfg-'confirmation_delay_minutes'-'unknown_protection_minutes')<>'{}'::jsonb then return p_default; end if; return case p_key when 'confirmation_delay_minutes' then delay when 'unknown_protection_minutes' then horizon else p_default end; end; $$; revoke all on function public.fn_agenda_minutes(jsonb,text,int) from public,anon,authenticated; grant execute on function public.fn_agenda_minutes(jsonb,text,int) to service_role; create or replace function public.fn_appointment_confirmation_sweep(p_limit int default 100,p_now timestamptz default now()) returns int language plpgsql security definer set search_path=public as $$ declare a record; n int:=0; expired boolean; begin for a in select c.*,o.settings from public.calendar_appointments c join public.organizations o on o.id=c.organization_id where c.status in ('pending','confirmed') and c.ends_at+make_interval(mins=>public.fn_agenda_minutes(o.settings,'confirmation_delay_minutes',10))<=p_now and (c.confirmation_next_at is null or c.confirmation_next_at<=p_now) order by c.ends_at limit greatest(1,least(p_limit,500)) for update of c skip locked loop expired:=a.ends_at+make_interval(mins=>public.fn_agenda_minutes(a.settings,'unknown_protection_minutes',1440))<=p_now; insert into public.agent_inbox_items(organization_id,kind,severity,title,body,ref_kind,ref_id,appointment_revision) values(a.organization_id,'appointment_outcome_required',case when expired then 'critical' else 'warn' end, case when expired then 'Presença sem confirmação há mais tempo' else 'Confirme a presença no compromisso' end, 'Compromisso: '||a.title||'. Abra e registre se a pessoa compareceu, faltou ou cancelou. O horário sozinho não confirma falta.', 'appointment',a.id,a.revision) on conflict(organization_id,ref_id,appointment_revision,kind) where ref_kind='appointment' and appointment_revision is not null do update set status='open',resolved_at=null,severity=excluded.severity,title=excluded.title; update public.calendar_appointments set confirmation_next_at=case when expired then p_now+interval '24 hours' else least(p_now+interval '24 hours',a.ends_at+make_interval(mins=>public.fn_agenda_minutes(a.settings,'unknown_protection_minutes',1440))) end where id=a.id and organization_id=a.organization_id; n:=n+1; end loop; return n; end; $$; revoke all on function public.fn_appointment_confirmation_sweep(int,timestamptz) from public,anon,authenticated; grant execute on function public.fn_appointment_confirmation_sweep(int,timestamptz) to service_role; -- Um único comando escolhe (ou recusa) o fluxo, guarda o recibo e inscreve. create or replace function public.fn_appointment_recover(p_org uuid,p_event uuid) returns jsonb language plpgsql security definer set search_path=public as $$ declare e public.event_log; a public.calendar_appointments; r public.appointment_recovery_receipts; contact uuid; rev bigint; result text; candidates uuid[]; pointer uuid; agent uuid; version uuid; node text; boundary jsonb; enrollment uuid; begin select * into e from public.event_log where organization_id=p_org and id=p_event and event_type='appointment.outcome_confirmed' and entity_kind='appointment'; if not found then raise exception 'appointment_source_event_missing' using errcode='P0002'; end if; rev:=(e.payload->>'appointment_revision')::bigint; select contact_id into contact from public.calendar_appointments where organization_id=p_org and id=e.entity_id; if not found then raise exception 'appointment_not_found' using errcode='P0002'; end if; if contact is not null then perform public.fn_service_lock(p_org,contact); end if; select * into a from public.calendar_appointments where organization_id=p_org and id=e.entity_id for update; if a.contact_id is distinct from contact then raise exception 'appointment_stale' using errcode='40001'; end if; select * into r from public.appointment_recovery_receipts where organization_id=p_org and appointment_id=a.id and appointment_revision=rev; if found then return to_jsonb(r); end if; result:=case when contact is null then 'no_contact' when a.revision<>rev or a.status<>'no_show' or a.outcome_recorded_at is null or not exists(select 1 from public.contacts where organization_id=p_org and id=contact and not is_anonymized and is_merged_into is null and not is_blocked) then 'stale' else null end; if result is null then select array_agg(p.id) into candidates from public.followup_flow_pointers p where p.organization_id=p_org and p.status='active' and p.active_version_id is not null and p.trigger_config->>'kind'='appointment_no_show' and (coalesce(jsonb_array_length(p.trigger_config->'params'->'event_type_ids'),0)=0 or p.trigger_config->'params'->'event_type_ids' ? a.event_type_id::text) and exists(select 1 from public.ai_agent_versions v where v.organization_id=p_org and v.status='published' and v.followup->'enabled'='true'::jsonb and v.followup->'flow_pointer_ids' ? p.id::text); result:=case when coalesce(cardinality(candidates),0)=0 then 'not_configured' when cardinality(candidates)>1 then 'ambiguous' else null end; end if; if result is null and exists(select 1 from public.followup_enrollments where organization_id=p_org and contact_id=contact and status in ('active','waiting_reply','paused_handoff','paused_manual')) then result:='other_flow'; end if; if result is null then pointer:=candidates[1]; select active_version_id into version from public.followup_flow_pointers where organization_id=p_org and id=pointer and status='active' for share; -- Precedência de AGENTES já canônica em resolveAgentForAutomaticTrigger. select agent_id into agent from public.ai_agent_versions where organization_id=p_org and status='published' and followup->'enabled'='true'::jsonb and followup->'flow_pointer_ids' ? pointer::text order by agent_id limit 1; select n->>'id' into node from public.followup_flow_versions v cross join lateral jsonb_array_elements(v.graph->'nodes') n where v.organization_id=p_org and v.id=version and n->>'type'='trigger'; if version is null or agent is null or node is null then raise exception 'appointment_flow_changed' using errcode='40001'; end if; begin boundary:=public.fn_service_event_origin(p_org,p_event,contact, (select channel_session_id from public.conversations where organization_id=p_org and id=a.conversation_id and contact_id=contact)); exception when serialization_failure then result:='stale'; end; if result is null then begin insert into public.followup_enrollments(organization_id,pointer_id,version_id,contact_id,conversation_id,agent_id,current_node_id,service_boundary, appointment_id,appointment_revision) values(p_org,pointer,version,contact,(boundary->>'conversation_id')::uuid,agent,node,boundary,a.id,a.revision) returning id into enrollment; insert into public.followup_enrollment_events(organization_id,enrollment_id,node_id,event_type,payload,idempotency_key) values(p_org,enrollment,node,'enrolled',jsonb_build_object('trigger_kind','appointment_no_show','appointment_id',a.id,'appointment_revision',a.revision),'appointment:'||a.id||':'||a.revision); result:='started'; exception when unique_violation then result:='other_flow'; end; end if; end if; insert into public.appointment_recovery_receipts(organization_id,appointment_id,appointment_revision,source_event_id,result,pointer_id,enrollment_id) values(p_org,a.id,rev,p_event,result,pointer,enrollment) returning * into r; if result<>'started' then insert into public.agent_inbox_items(organization_id,kind,severity,title,body,ref_kind,ref_id,appointment_revision) values(p_org,'appointment_recovery_review','warn','A recuperação não foi iniciada', case result when 'other_flow' then 'Este contato já tem outro acompanhamento. Revise o próximo passo; nenhuma recuperação ficou aguardando vaga.' when 'ambiguous' then 'Mais de um fluxo atende a esta falta. Deixe apenas um configurado ou escolha manualmente o próximo passo.' when 'not_configured' then 'Configure um fluxo de recuperação e habilite-o em um assistente publicado. Esta falta não será iniciada retroativamente.' else 'O contexto mudou ou não há contato vinculado. Abra o compromisso e escolha o próximo passo.' end,'appointment',a.id,rev) on conflict(organization_id,ref_id,appointment_revision,kind) where ref_kind='appointment' and appointment_revision is not null do nothing; end if; return to_jsonb(r); end; $$; revoke all on function public.fn_appointment_recover(uuid,uuid) from public,anon,authenticated; grant execute on function public.fn_appointment_recover(uuid,uuid) to service_role; create or replace function public.fn_agenda_settings(p_org uuid,p_config jsonb) returns jsonb language plpgsql security definer set search_path=public as $$ begin if auth.uid() is null or not public.fn_role_at_least(p_org,'manager') or not public.fn_support_write_allowed(p_org) then raise exception 'agenda_settings_forbidden' using errcode='42501'; end if; if jsonb_typeof(p_config->'confirmation_delay_minutes') is distinct from 'number' or jsonb_typeof(p_config->'unknown_protection_minutes') is distinct from 'number' or (p_config-'confirmation_delay_minutes'-'unknown_protection_minutes')<>'{}'::jsonb or (p_config->>'confirmation_delay_minutes' ~ '^[0-9]{1,5}$') is not true or (p_config->>'unknown_protection_minutes' ~ '^[0-9]{1,5}$') is not true or (p_config->>'confirmation_delay_minutes')::int not between 1 and 10080 or (p_config->>'unknown_protection_minutes')::int not between 1 and 10080 or (p_config->>'unknown_protection_minutes')::int < (p_config->>'confirmation_delay_minutes')::int then raise exception 'agenda_settings_invalid' using errcode='22023'; end if; update public.organizations set settings=jsonb_set(coalesce(settings,'{}'::jsonb),'{agenda}',p_config,true) where id=p_org; if not found then raise exception 'organization_not_found' using errcode='P0002'; end if; return p_config; end; $$; revoke all on function public.fn_agenda_settings(uuid,jsonb) from public,anon,authenticated; grant execute on function public.fn_agenda_settings(uuid,jsonb) to authenticated; create or replace function public.fn_appointment_enrollment_current(p_org uuid,p_id uuid,p_node text default null) returns boolean language sql stable security definer set search_path=public as $$ select exists(select 1 from public.followup_enrollments e where e.organization_id=p_org and e.id=p_id and e.status in ('active','waiting_reply') and (p_node is null or e.current_node_id=p_node) and (e.appointment_revision is null or exists(select 1 from public.calendar_appointments a where a.organization_id=p_org and a.id=e.appointment_id and a.revision=e.appointment_revision and a.status='no_show' and a.outcome_recorded_at is not null and a.contact_id=e.contact_id and exists(select 1 from public.appointment_recovery_receipts r where r.organization_id=p_org and r.appointment_id=a.id and r.appointment_revision=a.revision and r.result='started' and r.invalidated_at is null)))); $$; revoke all on function public.fn_appointment_enrollment_current(uuid,uuid,text) from public,anon,authenticated; grant execute on function public.fn_appointment_enrollment_current(uuid,uuid,text) to service_role; -- DIRC: geração já existe na chave nó:steps do evento de enqueue. Rechecks -- incrementam steps_taken, portanto igualdade com o contador atual seria falsa. -- Só o produtor interno grava essa chave no job; retenção sem evento falha fechado. create or replace function public.fn_followup_generation_write() returns trigger language plpgsql security definer set search_path=public as $$ begin -- #1862 — DELETE que chega em CASCATA não é escrita de follow-up. Este gatilho -- é BEFORE ROW: o DELETE vindo de `on delete cascade` roda sob o gatilho da -- chave estrangeira, com `pg_trigger_depth() > 1`. Passa QUALQUER cascata, não -- só a da ficha: apagar o contato, a inscrição (followup_enrollments), o fluxo -- (followup_flow_pointers) ou a organização leva junto os registros internos. -- O turno que sobra sem inscrição/evento falha fechado em -- fn_followup_job_current. A profundidade não distingue cascata de DELETE -- feito por outro gatilho: hoje nenhum gatilho apaga nestas duas tabelas, e -- quem criar um herda esta passagem. O DELETE DIRETO (profundidade 1, com -- `auth.uid()`) continua caindo na recusa abaixo — a 42501 não afrouxa. if tg_op='DELETE' and pg_trigger_depth()>1 then return old; end if; if tg_table_name='job_queue' then if auth.uid() is not null and ((tg_op<>'DELETE' and new.kind='followup_turn') or (tg_op<>'INSERT' and old.kind='followup_turn')) then raise exception 'followup_job_internal' using errcode='42501'; end if; if tg_op='UPDATE' and old.kind='followup_turn' then if new.organization_id<>old.organization_id or new.contact_id is distinct from old.contact_id or new.kind<>old.kind or new.payload->'followup_enrollment_id' is distinct from old.payload->'followup_enrollment_id' or new.payload->'node_id' is distinct from old.payload->'node_id' or new.payload->'source_step_key' is distinct from old.payload->'source_step_key' then raise exception 'followup_job_origin_immutable' using errcode='42501'; end if; end if; elsif auth.uid() is not null and ((tg_op<>'DELETE' and new.idempotency_key ~ ':[0-9]+$') or (tg_op<>'INSERT' and old.idempotency_key ~ ':[0-9]+$')) then raise exception 'followup_step_internal' using errcode='42501'; end if; if tg_op='DELETE' then return old; end if; return new; end; $$; revoke all on function public.fn_followup_generation_write() from public,anon,authenticated; drop trigger if exists trg_followup_generation_job on public.job_queue; create trigger trg_followup_generation_job before insert or update or delete on public.job_queue for each row execute function public.fn_followup_generation_write(); drop trigger if exists trg_followup_generation_event on public.followup_enrollment_events; create trigger trg_followup_generation_event before insert or update or delete on public.followup_enrollment_events for each row execute function public.fn_followup_generation_write(); create or replace function public.fn_followup_job_current(p_org uuid,p_job uuid,p_enrollment uuid,p_node text) returns boolean language sql stable security definer set search_path=public as $$ select exists(select 1 from public.job_queue j join public.followup_enrollments e on e.id=p_enrollment and e.organization_id=j.organization_id and e.contact_id=j.contact_id join public.followup_enrollment_events origin on origin.organization_id=e.organization_id and origin.enrollment_id=e.id and origin.node_id=p_node and origin.idempotency_key=j.payload->>'source_step_key' and origin.event_type in ('turn_enqueued','classify_enqueued') where j.id=p_job and j.organization_id=p_org and j.kind='followup_turn' and j.status in ('pending','running') and j.payload->>'followup_enrollment_id'=p_enrollment::text and j.payload->>'node_id'=p_node and origin.idempotency_key = origin.node_id||':'||substring(origin.idempotency_key from ':([0-9]+)$') and public.fn_appointment_enrollment_current(p_org,p_enrollment,p_node) and not exists(select 1 from public.followup_enrollment_events later where later.organization_id=p_org and later.enrollment_id=e.id and later.idempotency_key=later.node_id||':'||substring(later.idempotency_key from ':([0-9]+)$') and substring(later.idempotency_key from ':([0-9]+)$')::numeric > substring(origin.idempotency_key from ':([0-9]+)$')::numeric and not (later.node_id=p_node and later.event_type='action_recheck'))); $$; revoke all on function public.fn_followup_job_current(uuid,uuid,uuid,text) from public,anon,authenticated; grant execute on function public.fn_followup_job_current(uuid,uuid,uuid,text) to service_role; -- Identidade ORIGINAL da aquisição. locked_at::text sai do claim PG sem perda -- dos microssegundos; heartbeat não altera locked_at. Reclaim do mesmo worker -- não reautoriza a execução anterior. Sem novo contador ou coluna de autoridade. create or replace function public.fn_followup_claim_current(p_org uuid,p_job uuid,p_worker text,p_acquired_at timestamptz) returns boolean language sql stable security definer set search_path=public as $$ select exists(select 1 from public.job_queue where organization_id=p_org and id=p_job and kind='followup_turn' and status='running' and locked_by=p_worker and locked_at=p_acquired_at); $$; revoke all on function public.fn_followup_claim_current(uuid,uuid,text,timestamptz) from public,anon,authenticated; grant execute on function public.fn_followup_claim_current(uuid,uuid,text,timestamptz) to service_role; -- CAS de todo update tardio: estado, nó e lease compartilham a revisão. create or replace function public.fn_followup_patch(p_org uuid,p_id uuid,p_revision bigint,p_patch jsonb) returns bigint language plpgsql security definer set search_path=public as $$ declare current public.followup_enrollments; patched public.followup_enrollments; contact uuid; begin select contact_id into contact from public.followup_enrollments where id=p_id and organization_id=p_org; if not found then raise exception 'followup_stale' using errcode='P0001'; end if; perform public.fn_service_lock(p_org,contact); select * into current from public.followup_enrollments where id=p_id and organization_id=p_org for update; if current.contact_id is distinct from contact or current.revision is distinct from p_revision then raise exception 'followup_stale' using errcode='P0001'; end if; if p_patch->>'status' in ('active','waiting_reply') and current.appointment_revision is not null and not public.fn_appointment_enrollment_current(p_org,p_id,current.current_node_id) then raise exception 'followup_stale' using errcode='P0001'; end if; select * into patched from jsonb_populate_record(current,p_patch); update public.followup_enrollments set status=patched.status,current_node_id=patched.current_node_id,next_eval_at=patched.next_eval_at, claimed_until=patched.claimed_until,attempts=patched.attempts,last_error=patched.last_error,steps_taken=patched.steps_taken, outcome=patched.outcome,cancel_reason=patched.cancel_reason,completed_at=patched.completed_at,timing_plan=patched.timing_plan where organization_id=p_org and id=p_id returning revision into p_revision; return p_revision; end; $$; revoke all on function public.fn_followup_patch(uuid,uuid,bigint,jsonb) from public,anon,authenticated; grant execute on function public.fn_followup_patch(uuid,uuid,bigint,jsonb) to service_role; -- Estende o produtor permitido mantendo a origem imutável da 0223. CREATE OR REPLACE FUNCTION public.emit_event(p_event_type text, p_entity_kind text, p_entity_id uuid, p_payload jsonb DEFAULT '{}'::jsonb, p_metadata jsonb DEFAULT '{}'::jsonb, p_organization_id uuid DEFAULT NULL::uuid) RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public' AS $function$ declare v_org_id uuid; v_event_id uuid; v_contact uuid; v_origin jsonb; begin -- message.received nasce somente do INSERT inbound interno. Um chamador -- público não pode reapresentar uma mensagem existente como evento novo. if auth.uid() is not null and p_event_type in ('message.received','appointment.outcome_confirmed') then raise exception 'reserved_message_received' using errcode='42501'; end if; -- Estes campos autorizam efeitos operacionais; não são payload público. if auth.uid() is not null and ( coalesce(p_payload,'{}'::jsonb) ?| array['service_origin','service_boundary'] or coalesce(p_metadata,'{}'::jsonb) ?| array['service_origin','service_boundary'] ) then raise exception 'reserved_service_origin' using errcode='42501'; end if; v_org_id := coalesce(p_organization_id, (public.fn_support_context()->>'organization_id')::uuid); if v_org_id is null then select organization_id into v_org_id from public.user_organizations where user_id = auth.uid() and revoked_at is null limit 1; end if; if v_org_id is null then raise exception 'emit_event: organization_id obrigatorio'; end if; if auth.uid() is not null and not public.fn_role_at_least(v_org_id, 'viewer') then raise exception 'caller_not_authorized_for_org' using hint = 'emit_event: caller must be an active member of the organization'; end if; if not public.fn_support_write_allowed(v_org_id) then raise exception 'support_readonly' using errcode='42501'; end if; -- A ORIGEM E RESERVADA AO SERVIDOR — ENTAO O SERVIDOR TEM DE ESCREVE-LA. -- -- O bloco acima recusa `service_origin` vindo de chamador autenticado (42501, -- e com razao: e o campo que AUTORIZA efeito operacional, nao payload -- publico). So que ninguem o escrevia no lugar dele. Efeito medido: quem move -- o negocio pela IA carimba a origem no servidor (`agent-stage-sync`, -- `appointment-stage-move`, `handoff-stage-move`) e o follow-up nasce; quem -- move PELO QUADRO — o operador, pela rota HTTP autenticada — emitia um -- evento SEM origem, `fn_service_event_origin` caia no `service_stale` final -- (40001), `serviceForEvent` engolia como `stale_origin` e o follow-up nunca -- nascia. Sem erro em lugar nenhum: o gatilho de etapa era inalcancavel pelo -- caminho que o produto oferece na tela. -- -- O retrato e tirado AQUI, no instante da emissao, que e exatamente a -- semantica de procedencia que a 0223 quer: "quando este evento nasceu, o -- atendimento estava assim". A resolucao do contato repete a mesma regra de -- `fn_service_event_origin` — se ela nao souber resolver o tipo, nao ha o que -- carimbar e o evento segue sem origem, como antes. if not (coalesce(p_payload,'{}'::jsonb) ? 'service_origin') and not (coalesce(p_metadata,'{}'::jsonb) ? 'service_origin') then if p_event_type in ('lead.created','lead.stage_changed','lead.tag_added') and p_entity_kind='crm_lead' then select contact_id into v_contact from public.crm_leads where organization_id=v_org_id and id=p_entity_id; elsif p_event_type='contact.tag_added' and p_entity_kind='contact' then select id into v_contact from public.contacts where organization_id=v_org_id and id=p_entity_id; end if; if v_contact is not null and exists(select 1 from public.contacts where organization_id=v_org_id and id=v_contact and not is_anonymized and is_merged_into is null) then v_origin := jsonb_build_object('kind','command', 'observed', public.fn_service_observe_command(v_org_id, v_contact)); end if; end if; insert into public.event_log (organization_id, event_type, entity_kind, entity_id, payload, metadata) values (v_org_id, p_event_type, p_entity_kind, p_entity_id, coalesce(p_payload, '{}'::jsonb) || case when v_origin is null then '{}'::jsonb else jsonb_build_object('service_origin', v_origin) end, coalesce(p_metadata, '{}'::jsonb) || jsonb_build_object('emitted_at', extract(epoch from now()))) returning id into v_event_id; return v_event_id; end $function$; create or replace function public.fn_service_event_origin(p_org uuid,p_event uuid,p_contact uuid,p_session uuid default null) returns jsonb language plpgsql security definer set search_path=public as $$ declare e public.event_log; origin jsonb; boundary jsonb; current_boundary jsonb; entity_contact uuid; cid uuid; sid uuid; observed jsonb; root_event uuid:=p_event; visited uuid[]:=array[]::uuid[]; begin -- O drain faz claim otimista em outra transação; não conserva row lock. -- Não travar event_log: advisory contato antecede os locks de conversa/FKs. perform public.fn_service_lock(p_org,p_contact); loop if root_event = any(visited) or cardinality(visited)>=32 then raise exception 'service_origin_cycle' using errcode='40001'; end if; visited:=array_append(visited,root_event); boundary:=null; entity_contact:=null; select * into e from public.event_log where organization_id=p_org and id=root_event; if not found then raise exception 'service_event_not_found' using errcode='P0002'; end if; if e.event_type in ('lead.created','lead.stage_changed','lead.tag_added') and e.entity_kind='crm_lead' then select contact_id into entity_contact from public.crm_leads where organization_id=p_org and id=e.entity_id; elsif e.event_type='contact.tag_added' and e.entity_kind='contact' then select id into entity_contact from public.contacts where organization_id=p_org and id=e.entity_id; elsif e.event_type='appointment.outcome_confirmed' and e.entity_kind='appointment' then select contact_id into entity_contact from public.calendar_appointments where organization_id=p_org and id=e.entity_id and revision=(e.payload->>'appointment_revision')::bigint and status='no_show' and outcome_recorded_at is not null; elsif e.event_type='message.received' and e.entity_kind='message' then select contact_id,jsonb_build_object('organization_id',organization_id,'contact_id',contact_id, 'conversation_id',conversation_id,'service_revision',service_revision,'demanda_id',demanda_id,'demanda_revision',demanda_revision) into entity_contact,boundary from public.messages where organization_id=p_org and id=e.entity_id and direction='inbound'; else raise exception 'service_event_origin_unsupported' using errcode='40001'; end if; if entity_contact is distinct from p_contact or not exists(select 1 from public.contacts where organization_id=p_org and id=p_contact and not is_anonymized and is_merged_into is null) then raise exception 'service_scope_mismatch' using errcode='23503'; end if; origin:=e.payload->'service_origin'; if origin->>'kind'='event' then if origin->>'organization_id' is distinct from p_org::text or origin->>'contact_id' is distinct from p_contact::text then raise exception 'service_scope_mismatch' using errcode='23503'; end if; root_event:=(origin->>'event_id')::uuid; if root_event is null then raise exception 'service_stale' using errcode='40001'; end if; continue; end if; exit; end loop; if boundary is not null or origin->>'kind'='continuation' then boundary:=coalesce(boundary,origin->'boundary'); select channel_session_id into sid from public.conversations where organization_id=p_org and contact_id=p_contact and id=(boundary->>'conversation_id')::uuid; if p_session is not null and p_session is distinct from sid then raise exception 'service_channel_mismatch' using errcode='23503'; end if; elsif origin->>'kind'='command' then observed:=origin->'observed'; if observed->>'organization_id' is distinct from p_org::text or observed->>'contact_id' is distinct from p_contact::text then raise exception 'service_scope_mismatch' using errcode='23503'; end if; if jsonb_typeof(observed->'destinations')='array' then sid:=coalesce(p_session,(observed->>'default_session_id')::uuid); select item->'observed' into observed from jsonb_array_elements(observed->'destinations') item where item->>'channel_session_id'=sid::text; else -- Compatibilidade com snapshot anterior: prova somente sua conversa, nunca ausência de outro canal. select channel_session_id into sid from public.conversations where organization_id=p_org and contact_id=p_contact and id=(observed->>'conversation_id')::uuid; if p_session is not null and p_session is distinct from sid then raise exception 'service_channel_mismatch' using errcode='23503'; end if; end if; else raise exception 'service_stale' using errcode='40001'; end if; if sid is null then raise exception 'service_stale' using errcode='40001'; end if; if not exists(select 1 from public.channel_sessions where id=sid and organization_id=p_org and archived_at is null) then raise exception 'service_channel_mismatch' using errcode='23503'; end if; if boundary is null and observed is null then raise exception 'service_stale' using errcode='40001'; end if; select service_boundary into current_boundary from public.event_service_origins where organization_id=p_org and event_id=root_event and channel_session_id=sid; if found then boundary:=current_boundary; elsif boundary is null then -- PARA UM EVENTO, `absent` E PROCEDENCIA — NAO REIVINDICACAO DE ESTADO. -- -- O CAS de `fn_service_begin` existe para que dois ATORES com a mesma -- observacao "ausente" nao ajam os dois: o segundo tem de perder, e o -- invariante de `fn_service_begin` guarda isso. Um evento e outra coisa: o -- retrato `absent` diz "quando este evento foi EMITIDO nao havia -- atendimento", e a resolucao de cada evento ja e idempotente pelo memo -- `event_service_origins` logo acima — nao ha corrida a arbitrar aqui. -- -- Sem esta distincao o caminho ORDINARIO morria: um lead criado e depois -- movido de etapa gera DOIS eventos, cada um com seu retrato `absent`; -- resolver o primeiro cria a conversa e o segundo levantava 40001 — que -- `serviceForEvent` engole como `stale_origin`, entao o follow-up de etapa -- simplesmente nao nascia, sem erro em lugar nenhum. -- -- Zerar `observed` so quando a conversa JA existe mantem o CAS de pe para o -- retrato que descreve uma fronteira concreta (esse continua sendo conferido -- contra a vigente) e para todo chamador direto de `fn_service_begin`. if observed->>'absent' = 'true' and exists( select 1 from public.conversations where organization_id=p_org and contact_id=p_contact and channel_session_id=sid and not is_group) then observed:=null; end if; boundary:=public.fn_service_begin(p_org,p_contact,sid,observed) - 'status' - 'demanda_fechada_em' - 'service_started_at'; end if; if boundary->>'organization_id' is distinct from p_org::text or boundary->>'contact_id' is distinct from p_contact::text then raise exception 'service_scope_mismatch' using errcode='23503'; end if; cid:=(boundary->>'conversation_id')::uuid; if p_session is not null and not exists(select 1 from public.conversations where organization_id=p_org and id=cid and contact_id=p_contact and channel_session_id=p_session) then raise exception 'service_channel_mismatch' using errcode='23503'; end if; current_boundary:=public.fn_service_boundary(p_org,cid); if current_boundary is null or current_boundary->>'status' in ('closed','resolved','archived') or current_boundary->>'demanda_fechada_em' is not null or (current_boundary - 'status' - 'demanda_fechada_em' - 'service_started_at') is distinct from boundary then raise exception 'service_stale' using errcode='40001'; end if; insert into public.event_service_origins(event_id,channel_session_id,organization_id,service_boundary) values(root_event,sid,p_org,boundary) on conflict(event_id,channel_session_id) do nothing; return boundary; end; $$; revoke all on function public.fn_service_event_origin(uuid,uuid,uuid,uuid) from public,anon,authenticated; grant execute on function public.fn_service_event_origin(uuid,uuid,uuid,uuid) to service_role; drop function if exists public.fn_followup_inline_settle(uuid,uuid,text,boolean,text,timestamptz,boolean); -- Atalho inline: estado do job e aviso no mesmo commit; lease antigo não conclui. create or replace function public.fn_followup_inline_settle(p_org uuid,p_id uuid,p_worker text,p_done boolean,p_error text default null,p_retry_at timestamptz default null,p_hold boolean default false,p_acquired_at timestamptz default null) returns boolean language plpgsql security definer set search_path=public as $$ declare j public.job_queue; begin update public.job_queue set status=case when p_done then 'done' when p_hold then 'pending' when attempts>=max_attempts then 'dead' else 'pending' end, attempts=case when p_hold then greatest(0,attempts-1) else attempts end, run_after=coalesce(p_retry_at,now()+interval '1 minute'),locked_by=null,locked_at=null,last_error=left(p_error,400) where organization_id=p_org and id=p_id and kind='followup_turn' and status='running' and locked_by=p_worker and locked_at=p_acquired_at returning * into j; if not found then return false; end if; if j.status='dead' then insert into public.agent_inbox_items(organization_id,kind,severity,title,body,ref_kind,ref_id) values(p_org,'job_dead','critical','O acompanhamento não conseguiu enviar a mensagem', 'Abra o acompanhamento e confira o canal. Motivo: '||coalesce(j.last_error,'envio indisponível'),'job_queue',j.id); end if; return true; end; $$; revoke all on function public.fn_followup_inline_settle(uuid,uuid,text,boolean,text,timestamptz,boolean,timestamptz) from public,anon,authenticated; grant execute on function public.fn_followup_inline_settle(uuid,uuid,text,boolean,text,timestamptz,boolean,timestamptz) to service_role; -- Callback grava o passo e sua progressão juntos. Um CAS recusado não deixa -- idempotency_key órfã que impediria a próxima tentativa legítima. create or replace function public.fn_followup_apply_step(p_org uuid,p_id uuid,p_revision bigint,p_patch jsonb,p_event jsonb) returns bigint language plpgsql security definer set search_path=public as $$ declare revision bigint; contact uuid; begin if p_event ? 'job_id' then select contact_id into contact from public.followup_enrollments where organization_id=p_org and id=p_id; perform public.fn_service_lock(p_org,contact); perform 1 from public.job_queue where id=(p_event->>'job_id')::uuid and organization_id=p_org for update; if not public.fn_followup_claim_current(p_org,(p_event->>'job_id')::uuid,p_event->'job_claim'->>'worker_id',(p_event->'job_claim'->>'acquired_at')::timestamptz) or not public.fn_followup_job_current(p_org,(p_event->>'job_id')::uuid,p_id,p_event->>'node_id') then raise exception 'followup_job_stale' using errcode='40001'; end if; end if; revision:=public.fn_followup_patch(p_org,p_id,p_revision,p_patch); insert into public.followup_enrollment_events(organization_id,enrollment_id,node_id,event_type,payload,idempotency_key) values(p_org,p_id,p_event->>'node_id',p_event->>'event_type',coalesce(p_event->'payload','{}'::jsonb),p_event->>'idempotency_key'); return revision; end; $$; revoke all on function public.fn_followup_apply_step(uuid,uuid,bigint,jsonb,jsonb) from public,anon,authenticated; grant execute on function public.fn_followup_apply_step(uuid,uuid,bigint,jsonb,jsonb) to service_role; notify pgrst,'reload schema'; -- ---- Google reconciliação (migration 0225) ---- -- 0225 — Google compartilha o compromisso; presença continua humana (0224). -- DIRC: mesma tupla, crons, mutex e revisão de domínio. Checkpoints outbound -- são hashes; pending_write é um slot de intenção histórica, nunca recibo remoto. alter table public.calendar_appointments add column if not exists google_local_revision bigint not null default 1, add column if not exists google_synced_local_revision bigint not null default 0, add column if not exists google_etag text, add column if not exists google_base_projection jsonb, add column if not exists google_conflict jsonb, add column if not exists google_pending_write jsonb, add column if not exists google_claim_token uuid, add column if not exists google_claim_epoch bigint not null default 0, add column if not exists google_claim_until timestamptz, add column if not exists google_next_attempt_at timestamptz not null default now(); alter table public.calendar_connections add column if not exists calendar_selection_revision bigint not null default 0; alter table public.calendar_connection_calendars add column if not exists access_role text, add column if not exists available boolean not null default true, add column if not exists catalog_checked_at timestamptz, add column if not exists sync_claim_token uuid, add column if not exists sync_claim_epoch bigint not null default 0, add column if not exists sync_claim_until timestamptz, add column if not exists sync_next_attempt_at timestamptz not null default now(), add column if not exists last_sync_at timestamptz, add column if not exists sync_error text, add column if not exists sync_cursor jsonb, add column if not exists sync_coverage jsonb; alter table public.calendar_external_events add column if not exists seen_generation uuid, add column if not exists recurring_event_id text, add column if not exists original_start_time jsonb; alter table public.calendar_external_events alter column starts_at drop not null; alter table public.calendar_external_events alter column ends_at drop not null; -- Os três blocos abaixo só agem quando o banco ainda não chegou à versão da 0225 -- (issue #1041). Sem a guarda, toda reaplicação revalidava o CHECK varrendo a -- tabela, reconstruía o índice único e reescrevia calendar_appointments inteira -- para recriar a coluna gerada, tudo sob trava exclusiva e com o app no ar. do $$ begin if not exists ( select 1 from pg_constraint where conname = 'calendar_external_events_periodo_valido' and conrelid = 'public.calendar_external_events'::regclass and pg_get_constraintdef(oid) ilike '%cancelled%' ) then alter table public.calendar_external_events drop constraint if exists calendar_external_events_periodo_valido; alter table public.calendar_external_events add constraint calendar_external_events_periodo_valido check(status='cancelled' or (starts_at is not null and ends_at is not null and ends_at>starts_at)); end if; end $$; do $$ begin if exists ( select 1 from pg_indexes where schemaname = 'public' and indexname = 'calendar_appointments_google_evento_key' and indexdef not ilike '%google_calendar_id%' ) then execute 'drop index public.calendar_appointments_google_evento_key'; end if; end $$; create unique index if not exists calendar_appointments_google_evento_key on public.calendar_appointments(organization_id,google_connection_id,google_calendar_id,google_event_id) where google_event_id is not null; -- Nenhum legado é declarado sincronizado sem GET/base. Tupla ambígua fica -- preservada e visível; não se adivinha calendário de outra conta/conexão. -- A view é `select a.*` da tabela: ela só precisa sair quando a coluna for -- trocada, e volta adiante pelo `create or replace view`. do $$ begin if not exists ( select 1 from pg_attribute a join pg_attrdef d on d.adrelid = a.attrelid and d.adnum = a.attnum where a.attrelid = 'public.calendar_appointments'::regclass and a.attname = 'needs_google_push' and not a.attisdropped and pg_get_expr(d.adbin, d.adrelid) ilike '%google_local_revision%' ) then drop index if exists public.calendar_appointments_pendente_no_google_idx; drop view if exists public.calendar_google_reconcilable_appointments; alter table public.calendar_appointments drop column if exists needs_google_push; alter table public.calendar_appointments add column needs_google_push boolean generated always as (google_local_revision>google_synced_local_revision and google_conflict is null) stored; end if; if exists ( select 1 from pg_indexes where schemaname = 'public' and indexname = 'calendar_appointments_pendente_no_google_idx' and indexdef not ilike '%(google_next_attempt_at)%' ) then execute 'drop index public.calendar_appointments_pendente_no_google_idx'; end if; end $$; create index if not exists calendar_appointments_pendente_no_google_idx on public.calendar_appointments(google_next_attempt_at) where needs_google_push and owner_user_id is not null; create or replace function public.fn_google_projection_stamp() returns trigger language plpgsql security definer set search_path=public as $$ declare changed boolean; inbound boolean; decision boolean; redacted boolean; begin redacted:=new.contact_id is not null and exists(select 1 from public.contacts where organization_id=new.organization_id and id=new.contact_id and is_anonymized); if redacted then new.google_base_projection:=null;new.google_conflict:=null;new.google_pending_write:=null;new.google_claim_token:=null;new.google_claim_until:=null;new.google_etag:=null;new.guest_email:=null; if tg_op='UPDATE' then new.google_claim_epoch:=old.google_claim_epoch+1;new.google_local_revision:=old.google_local_revision;new.google_synced_local_revision:=old.google_local_revision;end if; return new; end if; if tg_op='INSERT' then new.google_local_revision:=1;new.google_synced_local_revision:=0; if auth.uid() is not null then new.google_base_projection:=null;new.google_etag:=null;new.google_pending_write:=null;new.google_conflict:=null; new.google_claim_token:=null;new.google_claim_epoch:=0;new.google_claim_until:=null; new.google_connection_id:=null;new.google_calendar_id:=null;new.google_event_id:=null; end if; return new; end if; decision:=auth.uid()=old.owner_user_id and public.fn_role_at_least(new.organization_id,'agent') and public.fn_support_write_allowed(new.organization_id) and old.google_conflict is not null and new.google_conflict-'resolution'=old.google_conflict-'resolution' and new.google_conflict->'resolution'->>'actor_id'=auth.uid()::text and new.google_conflict->'resolution'->>'choice' in ('google','local','preserve_remote') and old.google_conflict->>'revision'=old.revision::text and old.google_conflict->>'local_revision'=old.google_local_revision::text and old.google_conflict->>'etag' is not distinct from old.google_etag; if auth.uid() is not null and (row(new.google_synced_at,new.google_sync_error) is distinct from row(old.google_synced_at,old.google_sync_error) or (new.google_next_attempt_at is distinct from old.google_next_attempt_at and not coalesce(auth.uid()=old.owner_user_id and public.fn_role_at_least(new.organization_id,'agent') and public.fn_support_write_allowed(new.organization_id) and new.google_next_attempt_at<=clock_timestamp() and (old.google_conflict is null or decision),false))) then raise exception 'google_metadata_private' using errcode='42501';end if; if auth.uid() is not null and ((new.google_conflict is distinct from old.google_conflict and not coalesce(decision,false)) or row(new.google_base_projection,new.google_pending_write,new.google_claim_token,new.google_claim_epoch,new.google_claim_until,new.google_synced_local_revision,new.google_etag,new.google_connection_id,new.google_calendar_id,new.google_event_id) is distinct from row(old.google_base_projection,old.google_pending_write,old.google_claim_token,old.google_claim_epoch,old.google_claim_until,old.google_synced_local_revision,old.google_etag,old.google_connection_id,old.google_calendar_id,old.google_event_id)) then raise exception 'google_metadata_private' using errcode='42501'; end if; changed:=row(new.starts_at,new.ends_at,new.time_zone,new.status='cancelled',new.title,new.description,new.location_kind,new.location_details,new.guest_email) is distinct from row(old.starts_at,old.ends_at,old.time_zone,old.status='cancelled',old.title,old.description,old.location_kind,old.location_details,old.guest_email); -- Única entrada que modifica base e domínio juntos é o núcleo service-only. -- Não há GUC ou flag no body público que suprima revisão. inbound:=row(new.title,new.description,new.location_kind,new.location_details,new.guest_email) is not distinct from row(old.title,old.description,old.location_kind,old.location_details,old.guest_email) and auth.uid() is null and new.google_base_projection is distinct from old.google_base_projection and (new.google_base_projection->'shared'->>'starts_at')::timestamptz=new.starts_at and (new.google_base_projection->'shared'->>'ends_at')::timestamptz=new.ends_at and new.google_base_projection->'shared'->>'time_zone'=new.time_zone and (new.google_base_projection->'shared'->>'cancelled')::boolean=(new.status='cancelled'); new.google_local_revision:=old.google_local_revision+case when changed and not coalesce(inbound,false) then 1 else 0 end; if changed then new.google_next_attempt_at:=now(); end if; return new; end;$$; revoke all on function public.fn_google_projection_stamp() from public,anon,authenticated; drop trigger if exists trg_google_projection_stamp on public.calendar_appointments; create trigger trg_google_projection_stamp before insert or update on public.calendar_appointments for each row execute function public.fn_google_projection_stamp(); create or replace function public.fn_appointment_change_core(p_org uuid,p_id uuid,p_revision bigint,p_patch jsonb,p_remote boolean,p_base jsonb) returns jsonb language plpgsql security definer set search_path=public as $$ declare a public.calendar_appointments; contact uuid; origin jsonb; event_id uuid; begin if p_remote and (auth.uid() is not null or (p_patch-'starts_at'-'ends_at'-'time_zone'-'status'-'cancellation_reason')<>'{}'::jsonb or coalesce(p_patch->>'status','cancelled')<>'cancelled') then raise exception 'google_patch_forbidden' using errcode='42501';end if; if auth.uid() is not null and (not public.fn_role_at_least(p_org,'agent') or not public.fn_support_write_allowed(p_org)) then raise exception 'appointment_forbidden' using errcode='42501'; end if; select contact_id into contact from public.calendar_appointments where organization_id=p_org and id=p_id; if not found then raise exception 'appointment_not_found' using errcode='P0002'; end if; if contact is not null then perform public.fn_service_lock(p_org,contact); end if; select * into a from public.calendar_appointments where organization_id=p_org and id=p_id for update; if a.contact_id is distinct from contact or a.revision is distinct from p_revision then raise exception 'appointment_stale' using errcode='40001'; end if; if p_remote and a.status not in ('pending','confirmed') then raise exception 'google_outcome_protected' using errcode='40001';end if; if a.status='cancelled' then raise exception 'appointment_cancelled' using errcode='22023'; end if; if contact is not null then origin:=jsonb_build_object('kind','command','observed',public.fn_service_observe_command(p_org,contact)); end if; update public.calendar_appointments set google_base_projection=case when p_remote then p_base else google_base_projection end, starts_at=case when p_patch?'starts_at' then (p_patch->>'starts_at')::timestamptz else starts_at end, ends_at=case when p_patch?'ends_at' then (p_patch->>'ends_at')::timestamptz else ends_at end, time_zone=coalesce(p_patch->>'time_zone',time_zone), status=coalesce(p_patch->>'status',status), cancelled_at=case when p_patch->>'status'='cancelled' then now() else cancelled_at end, cancellation_reason=case when p_patch?'cancellation_reason' then p_patch->>'cancellation_reason' else cancellation_reason end, notes=case when p_patch?'notes' then p_patch->>'notes' else notes end, guest_email=case when p_patch?'guest_email' then p_patch->>'guest_email' else guest_email end, outcome_message_id=case when p_patch?'outcome_message_id' then (p_patch->>'outcome_message_id')::uuid else null end, confirmation_next_at=case when p_patch?'confirmation_next_at' then (p_patch->>'confirmation_next_at')::timestamptz else confirmation_next_at end where organization_id=p_org and id=p_id returning * into a; if p_patch?'confirmation_next_at' and (a.confirmation_next_at<=now() or a.confirmation_next_at>now()+interval '24 hours') then raise exception 'appointment_invalid_snooze' using errcode='22023'; end if; update public.followup_enrollments set status='cancelled',cancel_reason='O compromisso mudou. Revise o próximo passo.',completed_at=now(),next_eval_at=null,claimed_until=null where organization_id=p_org and appointment_id=p_id and appointment_revision<>a.revision and status in ('active','waiting_reply','paused_handoff','paused_manual'); update public.agent_inbox_items set status='resolved',resolved_at=now() where organization_id=p_org and ref_kind='appointment' and ref_id=p_id and status='open' and (appointment_revision<>a.revision or a.status in ('completed','no_show','cancelled') or p_patch?'confirmation_next_at'); if contact is not null and a.status='no_show' and a.outcome_recorded_at is not null and a.revision<>p_revision then insert into public.event_log(organization_id,event_type,entity_kind,entity_id,payload) values(p_org,'appointment.outcome_confirmed','appointment',p_id, jsonb_build_object('appointment_revision',a.revision,'service_origin',origin)) returning id into event_id; end if; return to_jsonb(a); end; $$; revoke all on function public.fn_appointment_change_core(uuid,uuid,bigint,jsonb,boolean,jsonb) from public,anon,authenticated; create or replace function public.fn_appointment_change(p_org uuid,p_id uuid,p_revision bigint,p_patch jsonb) returns jsonb language sql security definer set search_path=public as $$ select public.fn_appointment_change_core(p_org,p_id,p_revision,p_patch,false,null); $$; revoke all on function public.fn_appointment_change(uuid,uuid,bigint,jsonb) from public,anon; grant execute on function public.fn_appointment_change(uuid,uuid,bigint,jsonb) to authenticated,service_role; -- Helpers de fencing só internos. Epoch identifica aquisição; geração pertence -- ao ciclo de paginação e não muda durante heartbeat/reclaim. create or replace function public.fn_google_calendar_fence(p_org uuid,p_id uuid,p_claim jsonb,p_cursor jsonb default null) returns void language plpgsql security definer set search_path=public as $$ declare c public.calendar_connection_calendars; begin select * into c from public.calendar_connection_calendars where organization_id=p_org and id=p_id for update; if not found or not c.available or c.access_role not in ('owner','writer','reader','writerWithoutPrivateAccess') or c.sync_claim_token is distinct from (p_claim->>'token')::uuid or c.sync_claim_epoch::text is distinct from p_claim->>'epoch' or c.sync_claim_until<=clock_timestamp() or c.sync_claim_until is null or (p_cursor is not null and c.sync_cursor is distinct from p_cursor) then raise exception 'google_stale' using errcode='40001'; end if; if not exists(select 1 from public.calendar_connections x join public.user_organizations m on m.organization_id=x.organization_id and m.user_id=x.user_id where x.organization_id=p_org and x.id=c.connection_id and m.revoked_at is null and x.status='healthy') then raise exception 'google_connection_unavailable' using errcode='42501';end if; end;$$; revoke all on function public.fn_google_calendar_fence(uuid,uuid,jsonb,jsonb) from public,anon,authenticated; create or replace function public.fn_google_appointment(p_org uuid,p_id uuid,p_action text,p_args jsonb default '{}') returns jsonb language plpgsql security definer set search_path=public as $$ declare a public.calendar_appointments; c public.calendar_connection_calendars; conn public.calendar_connections; contact uuid; claim jsonb:=p_args->'claim'; result jsonb; b jsonb; changed boolean; remote jsonb; begin select contact_id into contact from public.calendar_appointments where organization_id=p_org and id=p_id; if not found then raise exception 'appointment_not_found' using errcode='P0002';end if; if contact is not null then perform public.fn_service_lock(p_org,contact);end if; -- Seleção/reserva compartilham membership antes dos locks de calendário/appointment. perform 1 from public.user_organizations m join public.calendar_appointments x on x.organization_id=m.organization_id and x.owner_user_id=m.user_id where x.organization_id=p_org and x.id=p_id for update of m; if p_args?'calendar_fence' then perform public.fn_google_calendar_fence(p_org,(p_args->'calendar_fence'->>'id')::uuid,p_args->'calendar_fence'->'claim',p_args->'calendar_fence'->'cursor'); end if; select * into a from public.calendar_appointments where organization_id=p_org and id=p_id for update; if a.contact_id is distinct from contact then raise exception 'appointment_stale' using errcode='40001';end if; if contact is not null and exists(select 1 from public.contacts where organization_id=p_org and id=contact and is_anonymized) then if p_action='claim' then return jsonb_build_object('terminal','redacted');end if; raise exception 'google_contact_redacted' using errcode='42501';end if; if not exists(select 1 from public.user_organizations where organization_id=p_org and user_id=a.owner_user_id and revoked_at is null) then raise exception 'google_owner_unavailable' using errcode='42501';end if; if p_action='claim' then if a.google_claim_until>clock_timestamp() then return null;end if; if a.google_event_id is null and a.status<>'cancelled' then select k.* into c from public.calendar_connection_calendars k join public.calendar_connections x on x.id=k.connection_id and x.organization_id=k.organization_id where k.organization_id=p_org and x.user_id=a.owner_user_id and k.is_destination; if not found or (select count(*) from public.calendar_connection_calendars k join public.calendar_connections x on x.id=k.connection_id and x.organization_id=k.organization_id where k.organization_id=p_org and x.user_id=a.owner_user_id and k.is_destination)<>1 then update public.calendar_appointments set google_sync_error='Escolha uma agenda de destino nas configurações.',google_next_attempt_at=now()+interval '15 minutes' where organization_id=p_org and id=p_id;return null; end if; if not c.available or c.access_role not in ('owner','writer') then update public.calendar_appointments set google_sync_error='A agenda de destino não permite publicação. Confira o acesso nas configurações.',google_next_attempt_at=now()+interval '15 minutes' where organization_id=p_org and id=p_id;return null;end if; update public.calendar_appointments set google_connection_id=c.connection_id,google_calendar_id=c.external_calendar_id, google_event_id='deskcommapp'||replace(id::text,'-',''),google_pending_write='{"reservation":true}'::jsonb where organization_id=p_org and id=p_id returning * into a; end if; update public.calendar_appointments set google_claim_token=gen_random_uuid(),google_claim_epoch=google_claim_epoch+1, google_claim_until=clock_timestamp()+interval '90 seconds' where organization_id=p_org and id=p_id returning * into a; else if a.google_claim_token is distinct from (claim->>'token')::uuid or a.google_claim_epoch::text is distinct from claim->>'epoch' or a.google_claim_until is null or a.google_claim_until<=clock_timestamp() then raise exception 'google_stale' using errcode='40001';end if; if p_action='renew' then if a.revision::text is distinct from p_args->>'revision' or a.google_local_revision::text is distinct from p_args->>'local_revision' then raise exception 'google_stale' using errcode='40001';end if; if not exists(select 1 from public.calendar_connections x join public.calendar_connection_calendars k on k.organization_id=x.organization_id and k.connection_id=x.id where x.organization_id=p_org and x.id=a.google_connection_id and x.user_id=a.owner_user_id and x.status='healthy' and k.external_calendar_id=a.google_calendar_id and k.available and k.access_role in ('writer','owner')) then raise exception 'google_connection_unavailable' using errcode='42501';end if; update public.calendar_appointments set google_claim_until=clock_timestamp()+interval '90 seconds' where organization_id=p_org and id=p_id returning * into a; elsif p_action='release' then update public.calendar_appointments set google_claim_token=null,google_claim_until=null where organization_id=p_org and id=p_id;return 'true'; else if a.revision::text is distinct from p_args->>'revision' or a.google_local_revision::text is distinct from p_args->>'local_revision' or a.google_event_id is distinct from p_args->>'event_id' or a.google_connection_id::text is distinct from p_args->>'connection_id' or a.google_calendar_id is distinct from p_args->>'calendar_id' then raise exception 'google_stale' using errcode='40001';end if; if p_action='error' then update public.calendar_appointments set google_sync_error=left(p_args->>'message',200),google_next_attempt_at=now()+interval '15 minutes' where organization_id=p_org and id=p_id;return 'true';end if; if a.google_event_id is not null then select * into conn from public.calendar_connections where organization_id=p_org and id=a.google_connection_id and user_id=a.owner_user_id; select * into c from public.calendar_connection_calendars where organization_id=p_org and connection_id=a.google_connection_id and external_calendar_id=a.google_calendar_id; if conn.id is null or conn.status<>'healthy' or c.id is null or not c.available then raise exception 'google_connection_unavailable' using errcode='42501';end if; end if; if p_action='prepare' then if c.access_role not in ('owner','writer') or (a.google_pending_write is not null and a.google_pending_write<>'{"reservation":true}'::jsonb) or a.google_conflict is not null then raise exception 'google_write_unavailable' using errcode='40001';end if; update public.calendar_appointments set google_pending_write=p_args->'operation' where organization_id=p_org and id=p_id;return 'true'; elsif p_action='idle' then update public.calendar_appointments set google_next_attempt_at=now()+interval '15 minutes' where organization_id=p_org and id=p_id;return 'true'; elsif p_action='commit' then result:=p_args->'result'; b:=result->'base';remote:=result->'remote'; if result?'operation_id' and a.google_pending_write->>'operation_id' is distinct from result->>'operation_id' then raise exception 'google_stale' using errcode='40001';end if; if result?'apply_remote' then if a.status not in ('pending','confirmed') then raise exception 'google_outcome_protected' using errcode='40001';end if; if not coalesce((remote->>'cancelled')::boolean,false) and exists(select 1 from public.calendar_appointments other where other.organization_id=p_org and other.owner_user_id=a.owner_user_id and other.id<>a.id and other.status in ('pending','confirmed') and other.starts_at<(remote->>'ends_at')::timestamptz and other.ends_at>(remote->>'starts_at')::timestamptz) then return jsonb_build_object('overlap',true);end if; changed:=row(a.starts_at,a.ends_at,a.time_zone,a.status='cancelled') is distinct from row((remote->>'starts_at')::timestamptz,(remote->>'ends_at')::timestamptz,remote->>'time_zone',(remote->>'cancelled')::boolean); perform public.fn_appointment_change_core(p_org,p_id,a.revision, jsonb_build_object('starts_at',remote->>'starts_at','ends_at',remote->>'ends_at','time_zone',remote->>'time_zone')|| case when (remote->>'cancelled')::boolean then '{"status":"cancelled","cancellation_reason":"Cancelado no Google"}'::jsonb else '{}'::jsonb end,true,b); if changed then insert into public.crm_lead_activities(organization_id,lead_id,contact_id,type,source_module,source_id,actor_kind,reason,payload) select p_org,l.lead_id,a.contact_id,case when (remote->>'cancelled')::boolean then 'appointment_cancelled' else 'appointment_rescheduled' end, 'agenda',p_id,'system',case when (remote->>'cancelled')::boolean then 'Cancelado no Google' else 'Remarcado no Google' end,jsonb_build_object('origin','google','appointment_id',p_id,'resolution_actor_id',a.google_conflict->'resolution'->>'actor_id') from public.crm_lead_links l where l.organization_id=p_org and l.target_id=p_id and l.target_kind='appointment' group by l.lead_id; end if; end if; update public.calendar_appointments set google_base_projection=case when result?'base' then b else google_base_projection end, google_etag=case when result?'etag' then result->>'etag' else google_etag end, google_conflict=case when result?'conflict' then nullif(result->'conflict','null'::jsonb) else google_conflict end, google_pending_write=case when coalesce((result->>'retry_creation')::boolean,false) and a.google_base_projection is null and a.google_pending_write->>'method'='POST' then '{"reservation":true}'::jsonb when coalesce((result->>'clear_pending')::boolean,false) then null else google_pending_write end, google_synced_local_revision=case when coalesce((result->>'ack')::boolean,false) then a.google_local_revision else google_synced_local_revision end, google_synced_at=case when coalesce((result->>'ack')::boolean,false) then now() else google_synced_at end, google_sync_error=null,google_next_attempt_at=now()+interval '5 minutes' where organization_id=p_org and id=p_id returning * into a; else raise exception 'google_action_invalid' using errcode='22023';end if; end if; end if; return to_jsonb(a)||jsonb_build_object('revision',a.revision::text,'google_local_revision',a.google_local_revision::text, 'google_synced_local_revision',a.google_synced_local_revision::text,'claim',jsonb_build_object('token',a.google_claim_token,'epoch',a.google_claim_epoch::text,'lease_until',a.google_claim_until)); end;$$; revoke all on function public.fn_google_appointment(uuid,uuid,text,jsonb) from public,anon,authenticated; grant execute on function public.fn_google_appointment(uuid,uuid,text,jsonb) to service_role; create or replace function public.fn_google_calendar(p_org uuid,p_id uuid,p_action text,p_args jsonb default '{}') returns jsonb language plpgsql security definer set search_path=public as $$ declare c public.calendar_connection_calendars; cur jsonb; it jsonb; gen uuid; rebuild_full boolean; token text; begin select * into c from public.calendar_connection_calendars where organization_id=p_org and id=p_id for update; if not found then raise exception 'google_calendar_not_found' using errcode='P0002';end if; if not exists(select 1 from public.calendar_connections x join public.user_organizations m on m.organization_id=x.organization_id and m.user_id=x.user_id where x.organization_id=p_org and x.id=c.connection_id and m.revoked_at is null and x.status='healthy') then raise exception 'google_connection_unavailable' using errcode='42501';end if; if p_action='claim' then if c.sync_claim_until>clock_timestamp() or not c.available then return null;end if; cur:=c.sync_cursor; if cur is null then rebuild_full:=c.sync_token is null or c.sync_coverage is null or (c.sync_coverage->>'completed_at')::timestamptz>'window_start')::timestamptz end, 'window_end',case when rebuild_full then now()+interval '90 days' else (c.sync_coverage->>'window_end')::timestamptz end); end if; update public.calendar_connection_calendars set sync_claim_token=gen_random_uuid(),sync_claim_epoch=sync_claim_epoch+1, sync_claim_until=clock_timestamp()+interval '90 seconds',sync_cursor=cur where organization_id=p_org and id=p_id returning * into c; else perform public.fn_google_calendar_fence(p_org,p_id,p_args->'claim',p_args->'cursor'); if p_action='renew' then update public.calendar_connection_calendars set sync_claim_until=clock_timestamp()+interval '90 seconds' where organization_id=p_org and id=p_id returning * into c; elsif p_action='release' then update public.calendar_connection_calendars set sync_claim_token=null,sync_claim_until=null where organization_id=p_org and id=p_id;return 'true'; elsif p_action='error' then update public.calendar_connection_calendars set sync_error=left(p_args->>'message',200),sync_next_attempt_at=now()+interval '15 minutes' where organization_id=p_org and id=p_id;return 'true'; elsif p_action='reset' then update public.calendar_connection_calendars set sync_token=null,sync_cursor=null,sync_error='A ocupação está desatualizada. Reconstruindo a leitura.',sync_next_attempt_at=now() where organization_id=p_org and id=p_id;return 'true'; elsif p_action='item' then it:=p_args->'item';gen:=(c.sync_cursor->>'generation')::uuid; -- O vínculo é resolvido antes do cache/anti-eco, também quando só chega id. if exists(select 1 from public.calendar_appointments where organization_id=p_org and google_connection_id=c.connection_id and google_calendar_id=c.external_calendar_id and google_event_id=it->>'external_event_id') then delete from public.calendar_external_events where organization_id=p_org and connection_id=c.connection_id and external_calendar_id=c.external_calendar_id and external_event_id=it->>'external_event_id'; return 'true'; end if; insert into public.calendar_external_events(organization_id,connection_id,external_calendar_id,external_event_id,title,starts_at,ends_at,status,transparency,is_all_day,seen_generation,recurring_event_id,original_start_time) values(p_org,c.connection_id,c.external_calendar_id,it->>'external_event_id',null,(it->>'starts_at')::timestamptz,(it->>'ends_at')::timestamptz, it->>'status',coalesce(it->>'transparency','opaque'),coalesce((it->>'is_all_day')::boolean,false),gen,it->>'recurring_event_id',it->'original_start_time') on conflict(organization_id,connection_id,external_calendar_id,external_event_id) do update set title=null,starts_at=excluded.starts_at,ends_at=excluded.ends_at, status=excluded.status,transparency=excluded.transparency,is_all_day=excluded.is_all_day,seen_generation=excluded.seen_generation, recurring_event_id=coalesce(excluded.recurring_event_id,calendar_external_events.recurring_event_id),original_start_time=coalesce(excluded.original_start_time,calendar_external_events.original_start_time); return 'true'; elsif p_action='page' then token:=p_args->>'next_page_token'; if token is not null and token=c.sync_cursor->>'page_token' then raise exception 'google_cursor_no_progress' using errcode='22023';end if; if token is null then if coalesce(p_args->>'next_sync_token','')='' then raise exception 'google_checkpoint_missing' using errcode='22023';end if; if c.sync_cursor->>'mode'='full' then delete from public.calendar_external_events where organization_id=p_org and connection_id=c.connection_id and external_calendar_id=c.external_calendar_id and starts_at<(c.sync_cursor->>'window_end')::timestamptz and ends_at>(c.sync_cursor->>'window_start')::timestamptz and seen_generation is distinct from (c.sync_cursor->>'generation')::uuid and status<>'cancelled'; end if; update public.calendar_connection_calendars set sync_token=p_args->>'next_sync_token',sync_cursor=null,last_sync_at=now(),sync_error=null, sync_coverage=case when c.sync_cursor->>'mode'='full' then c.sync_cursor-'page_token'-'base_sync_token'-'mode'||jsonb_build_object('completed_at',now()) else c.sync_coverage end, sync_next_attempt_at=now()+interval '15 minutes' where organization_id=p_org and id=p_id returning * into c; else update public.calendar_connection_calendars set sync_cursor=jsonb_set(sync_cursor,'{page_token}',to_jsonb(token)),sync_next_attempt_at=now() where organization_id=p_org and id=p_id returning * into c; end if; else raise exception 'google_action_invalid' using errcode='22023';end if; end if; return to_jsonb(c)||jsonb_build_object('claim',jsonb_build_object('token',c.sync_claim_token,'epoch',c.sync_claim_epoch::text,'lease_until',c.sync_claim_until)); end;$$; revoke all on function public.fn_google_calendar(uuid,uuid,text,jsonb) from public,anon,authenticated; grant execute on function public.fn_google_calendar(uuid,uuid,text,jsonb) to service_role; -- Catálogo completo: ausências somente depois de todas as páginas recebidas. -- Uma mesma membership cerca seleção, catálogo e primeira reserva. create or replace function public.fn_google_catalog(p_org uuid,p_connection uuid,p_items jsonb,p_revision text) returns void language plpgsql security definer set search_path=public as $$ declare conn public.calendar_connections; it jsonb; fresh boolean; begin select * into conn from public.calendar_connections where organization_id=p_org and id=p_connection; if not found then raise exception 'google_connection_unavailable' using errcode='P0002';end if; perform 1 from public.user_organizations where organization_id=p_org and user_id=conn.user_id and revoked_at is null for update; if not found then raise exception 'google_owner_unavailable' using errcode='42501';end if; select * into conn from public.calendar_connections where organization_id=p_org and id=p_connection; if conn.status<>'healthy' then raise exception 'google_connection_unavailable' using errcode='42501';end if; if conn.calendar_selection_revision::text is distinct from p_revision then raise exception 'google_selection_stale' using errcode='40001';end if; fresh:=(select count(*) from public.calendar_connections where organization_id=p_org and user_id=conn.user_id and provider='google_calendar')=1 and conn.calendar_selection_revision=0 and not exists(select 1 from public.calendar_connection_calendars k join public.calendar_connections x on x.organization_id=k.organization_id and x.id=k.connection_id where k.organization_id=p_org and x.user_id=conn.user_id and k.is_destination); for it in select value from jsonb_array_elements(p_items) loop insert into public.calendar_connection_calendars(organization_id,connection_id,external_calendar_id,name,time_zone,is_primary,access_role,available,catalog_checked_at,counts_for_conflicts,is_destination) values(p_org,p_connection,it->>'id',coalesce(it->>'summaryOverride',it->>'summary',it->>'id'),it->>'timeZone',coalesce((it->>'primary')::boolean,false),it->>'accessRole',not coalesce((it->>'deleted')::boolean,false),now(), fresh and coalesce((it->>'primary')::boolean,false),false) on conflict(organization_id,connection_id,external_calendar_id) do update set name=excluded.name,time_zone=excluded.time_zone,is_primary=excluded.is_primary, access_role=excluded.access_role,available=excluded.available,catalog_checked_at=excluded.catalog_checked_at,sync_next_attempt_at=now(); end loop; update public.calendar_connection_calendars set available=false,catalog_checked_at=now() where organization_id=p_org and connection_id=p_connection and not exists(select 1 from jsonb_array_elements(p_items) v where v->>'id'=external_calendar_id); if fresh then update public.calendar_connection_calendars set is_destination=true where organization_id=p_org and connection_id=p_connection and is_primary and available and access_role in ('owner','writer'); end if; update public.calendar_connections set calendar_selection_revision=calendar_selection_revision+1 where organization_id=p_org and id=p_connection; end;$$; revoke all on function public.fn_google_catalog(uuid,uuid,jsonb,text) from public,anon,authenticated; grant execute on function public.fn_google_catalog(uuid,uuid,jsonb,text) to service_role; create or replace function public.fn_google_selection(p_org uuid,p_revisions jsonb,p_sources uuid[],p_destination uuid) returns void language plpgsql security definer set search_path=public as $$ declare actor uuid:=auth.uid(); expected jsonb; actual jsonb; begin if actor is null or not public.fn_role_at_least(p_org,'agent') or not public.fn_support_write_allowed(p_org) then raise exception 'google_selection_forbidden' using errcode='42501';end if; perform 1 from public.user_organizations where organization_id=p_org and user_id=actor and revoked_at is null for update; if not found then raise exception 'google_owner_unavailable' using errcode='42501';end if; select jsonb_agg(value order by value->>'connection_id') into expected from jsonb_array_elements(p_revisions); select jsonb_agg(jsonb_build_object('connection_id',id,'revision',calendar_selection_revision::text) order by id::text) into actual from public.calendar_connections where organization_id=p_org and user_id=actor and provider='google_calendar'; if actual is distinct from expected then raise exception 'google_selection_stale' using errcode='40001';end if; if not exists(select 1 from public.calendar_connection_calendars k join public.calendar_connections c on c.id=k.connection_id and c.organization_id=k.organization_id where k.organization_id=p_org and k.id=p_destination and c.user_id=actor and c.status='healthy' and k.available and k.access_role in ('owner','writer')) then raise exception 'google_destination_unavailable' using errcode='42501';end if; if exists(select 1 from unnest(p_sources) selected(id) where not exists(select 1 from public.calendar_connection_calendars k join public.calendar_connections c on c.id=k.connection_id and c.organization_id=k.organization_id where k.organization_id=p_org and k.id=selected.id and c.user_id=actor and c.status='healthy' and k.available and k.access_role in ('owner','writer','reader','writerWithoutPrivateAccess'))) then raise exception 'google_source_unavailable' using errcode='42501';end if; update public.calendar_connection_calendars k set is_destination=false from public.calendar_connections c where k.organization_id=p_org and c.organization_id=p_org and k.connection_id=c.id and c.user_id=actor; update public.calendar_connection_calendars k set is_destination=k.id=p_destination,counts_for_conflicts=k.id=any(p_sources),sync_next_attempt_at=now() from public.calendar_connections c where k.organization_id=p_org and c.organization_id=p_org and k.connection_id=c.id and c.user_id=actor; update public.calendar_connections set calendar_selection_revision=calendar_selection_revision+1 where organization_id=p_org and user_id=actor and provider='google_calendar'; end;$$; revoke all on function public.fn_google_selection(uuid,jsonb,uuid[],uuid) from public,anon; grant execute on function public.fn_google_selection(uuid,jsonb,uuid[],uuid) to authenticated; -- Leitura derivada: seleção vale nos três leitores, mesmo com cache antigo. create or replace function public.fn_google_counts_for_conflicts(p_org uuid,p_connection uuid,p_calendar text) returns boolean language sql stable security definer set search_path=public as $$ -- ⚠️ FALHA ABERTO na AUSÊNCIA de catálogo, e a direção é deliberada. -- A forma `exists(... and counts_for_conflicts)` exigia linha em -- calendar_connection_calendars para o evento contar. Antes desta migration os -- três leitores (grade, semente da página e o motor de horários livres) liam -- `calendar_external_events` DIRETO: toda ocupação contava. Numa conexão cujo -- catálogo ainda não foi montado — ou cujo calendário saiu do catálogo com os -- eventos ainda gravados — a ocupação sumia da grade E deixava de bloquear o -- horário. O erro barato é mostrar "Ocupado" a mais; o caro é marcar por cima -- de uma consulta que existe. A negativa só vale quando alguém a declarou. select (auth.uid() is null or p_org in (select public.fn_user_org_ids())) and not exists( select 1 from public.calendar_connection_calendars where organization_id=p_org and connection_id=p_connection and external_calendar_id=p_calendar and not counts_for_conflicts); $$; revoke all on function public.fn_google_counts_for_conflicts(uuid,uuid,text) from public,anon; grant execute on function public.fn_google_counts_for_conflicts(uuid,uuid,text) to authenticated,service_role; -- A view nasceu como `select e.*` — com o `title` dentro —, e a lista EXPLÍCITA -- abaixo é o conserto da 0261: o membro lê a ocupação do colega, não o texto do -- compromisso pessoal dele. `e.*` é como a próxima coluna do espelho nasceria -- exposta a quem só precisa saber se o horário está ocupado. -- -- O `drop` daqui é CONDICIONAL, e existe por um motivo só: `create or replace -- view` não remove nem renomeia coluna, então sobre um clone que ainda tem a -- forma antiga — a que sobra é o `title` — ele responde `cannot drop columns -- from view` (medido: 16.15) e derruba o run. Quem já está na forma alvo NÃO cai -- — passa direto pelo `create or replace` logo abaixo, que PRESERVA o OID. Este -- arquivo é reaplicado a cada instalação e a cada update (`test:db`, job -- `invariants`), e derrubar + recriar o objeto a cada passada era o defeito da -- issue #1086: o que quebrava a segunda passada era o `create view` sobre o -- objeto ainda existente, não a falta do `drop`. -- -- A lista desta guarda anda JUNTA com a do `create or replace` (aqui e na 0261): -- coluna nova na view entra nas duas, senão a passada seguinte derruba uma view -- que já estava certa — e `scripts/test-update-com-dados.sh` fica vermelho nesse -- caso, pelo OID. do $$ begin if exists ( select 1 from pg_attribute a join pg_class c on c.oid = a.attrelid join pg_namespace n on n.oid = c.relnamespace where n.nspname = 'public' and c.relname = 'calendar_selected_external_events' and c.relkind = 'v' and a.attnum > 0 and not a.attisdropped and a.attname not in ( 'id','organization_id','connection_id','external_calendar_id','external_event_id', 'starts_at','ends_at','is_all_day','status','transparency','external_updated_at', 'created_at','updated_at','ical_uid','seen_generation','recurring_event_id','original_start_time' ) ) then drop view if exists public.calendar_selected_external_events; end if; end $$; create or replace view public.calendar_selected_external_events with (security_invoker=true) as select e.id,e.organization_id,e.connection_id,e.external_calendar_id,e.external_event_id, e.starts_at,e.ends_at,e.is_all_day,e.status,e.transparency,e.external_updated_at, e.created_at,e.updated_at,e.ical_uid,e.seen_generation,e.recurring_event_id,e.original_start_time from public.calendar_external_events e where e.status<>'cancelled' and public.fn_google_counts_for_conflicts(e.organization_id,e.connection_id,e.external_calendar_id); revoke all on public.calendar_selected_external_events from public,anon; grant select on public.calendar_selected_external_events to authenticated,service_role; -- Anonimização e commit disputam a MESMA linha de appointment. Quem chegar -- depois vê redação ou tem o resultado apagado; não reidrata snapshot tardio. create or replace function public.fn_google_redact_contact() returns trigger language plpgsql security definer set search_path=public as $$ begin if new.is_anonymized then update public.calendar_appointments set google_base_projection=null,google_conflict=null,google_pending_write=null, google_claim_epoch=google_claim_epoch+1,google_claim_token=null,google_claim_until=null,google_etag=null, google_sync_error='Contato anonimizado. Sincronização interrompida.',guest_email=null where organization_id=new.organization_id and contact_id=new.id; end if;return new; end;$$; revoke all on function public.fn_google_redact_contact() from public,anon,authenticated; drop trigger if exists trg_google_redact_contact on public.contacts; create trigger trg_google_redact_contact after update of is_anonymized on public.contacts for each row when(new.is_anonymized is true) execute function public.fn_google_redact_contact(); -- Backlog sem consumer não era entrega. A revisão durável é a única pendência. update public.event_log set status='done',updated_at=now(),last_error='superseded: Google acompanha a revisão atual do compromisso' where event_type='agenda.appointment.push_to_google' and status in ('pending','processing'); notify pgrst,'reload schema'; create or replace function public.fn_google_resolve(p_org uuid,p_id uuid,p_revision text,p_local_revision text,p_etag text,p_choice text) returns void language plpgsql security definer set search_path=public as $$ declare a public.calendar_appointments; contact uuid; begin if auth.uid() is null or not public.fn_role_at_least(p_org,'agent') or not public.fn_support_write_allowed(p_org) then raise exception 'google_resolution_forbidden' using errcode='42501';end if; select contact_id into contact from public.calendar_appointments where organization_id=p_org and id=p_id; if contact is not null then perform public.fn_service_lock(p_org,contact);end if; select * into a from public.calendar_appointments where organization_id=p_org and id=p_id for update; if not found or a.owner_user_id is distinct from auth.uid() then raise exception 'google_resolution_forbidden' using errcode='42501';end if; if a.revision::text is distinct from p_revision or a.google_local_revision::text is distinct from p_local_revision or a.google_etag is distinct from p_etag then raise exception 'google_stale' using errcode='40001';end if; if p_choice='retry' then if a.google_conflict is not null then raise exception 'google_conflict_requires_choice' using errcode='40001';end if; update public.calendar_appointments set google_next_attempt_at=now() where organization_id=p_org and id=p_id; else if p_choice not in ('google','local','preserve_remote') or a.google_conflict is null then raise exception 'google_choice_invalid' using errcode='22023';end if; -- O trigger reconhece somente esta forma autenticada: o corpo da comparação -- e as revisões não mudam, actor_id é auth.uid(), não input do browser. update public.calendar_appointments set google_conflict=google_conflict||jsonb_build_object('resolution',jsonb_build_object('choice',p_choice,'actor_id',auth.uid())),google_next_attempt_at=now() where organization_id=p_org and id=p_id; end if; end;$$; revoke all on function public.fn_google_resolve(uuid,uuid,text,text,text,text) from public,anon; grant execute on function public.fn_google_resolve(uuid,uuid,text,text,text,text) to authenticated; notify pgrst,'reload schema'; create or replace function public.fn_google_coverage(p_org uuid,p_owner uuid,p_start timestamptz,p_end timestamptz) returns boolean language sql stable security definer set search_path=public as $$ select case when auth.uid() is not null and p_org not in(select public.fn_user_org_ids()) then true else exists( select 1 from public.calendar_connection_calendars k join public.calendar_connections c on c.organization_id=k.organization_id and c.id=k.connection_id where k.organization_id=p_org and c.user_id=p_owner and k.counts_for_conflicts and ( not k.available or c.status<>'healthy' or k.access_role not in ('owner','writer','reader','writerWithoutPrivateAccess') or k.sync_coverage is null or k.sync_error is not null or k.last_sync_at is null or k.last_sync_at>'window_start')::timestamptz>p_start or (k.sync_coverage->>'window_end')::timestamptz>'organization_id' and c.contact_id::text=b->>'contact_id' and c.id::text=b->>'conversation_id' and c.service_revision::text=b->>'service_revision' and c.current_demanda_id::text is not distinct from b->>'demanda_id' and d.revision::text is not distinct from b->>'demanda_revision' and d.fechada_em is null and not c.is_group and c.status not in ('closed','resolved','archived')),false); $$; revoke all on function public.fn_meet_boundary_current(jsonb) from public,anon,authenticated; grant execute on function public.fn_meet_boundary_current(jsonb) to service_role; -- INVOKER: distingue escrita direta authenticated de chamadas pelas RPCs definer -- que reconferem ator/claim. Não usa flag/GUC fornecida pelo cliente como bypass. create or replace function public.fn_meet_stamp() returns trigger language plpgsql set search_path=public as $$ declare redacted boolean; j public.job_queue; b jsonb; begin if current_user in ('authenticated','anon') then if tg_op='INSERT' then if new.meeting_delivery<>'{"state":"none"}'::jsonb or new.meeting_request_id is not null or new.meeting_url is not null or new.meeting_state<>'not_requested' or new.meeting_requested_at is not null or new.meeting_received_at is not null or new.meeting_ready_at is not null or new.meeting_attempts<>0 or new.meeting_last_error is not null or new.meeting_next_attempt_at is not null or new.meeting_delivery_job_id is not null then raise exception 'meet_metadata_private' using errcode='42501';end if; elsif row(new.meeting_state,new.meeting_request_id,new.meeting_requested_at,new.meeting_received_at,new.meeting_ready_at,new.meeting_attempts,new.meeting_last_error,new.meeting_next_attempt_at,new.meeting_delivery,new.meeting_delivery_job_id,new.meeting_url) is distinct from row(old.meeting_state,old.meeting_request_id,old.meeting_requested_at,old.meeting_received_at,old.meeting_ready_at,old.meeting_attempts,old.meeting_last_error,old.meeting_next_attempt_at,old.meeting_delivery,old.meeting_delivery_job_id,old.meeting_url) then raise exception 'meet_metadata_private' using errcode='42501'; end if; end if; select is_anonymized into redacted from public.contacts where id=new.contact_id and organization_id=new.organization_id; if (redacted or new.status='cancelled' or new.location_kind<>'google_meet') and (new.location_kind='google_meet' or new.meeting_state<>'not_requested') then if new.location_kind='google_meet' or new.meeting_state<>'not_requested' or new.meeting_requested_at is not null or new.meeting_received_at is not null or new.meeting_ready_at is not null or new.meeting_attempts<>0 or new.meeting_last_error is not null or new.meeting_next_attempt_at is not null or new.meeting_delivery_job_id is not null then new.meeting_state:='cancelled';end if; new.meeting_request_id:=null;new.meeting_url:=null;new.meeting_last_error:=null;new.meeting_next_attempt_at:=null; new.meeting_delivery:=jsonb_build_object('state',case when redacted then 'blocked' else 'stale' end); elsif new.location_kind='google_meet' and new.meeting_state='not_requested' then new.meeting_state:='pending';new.meeting_request_id:=gen_random_uuid();new.meeting_next_attempt_at:=now(); end if; if tg_op='INSERT' and new.meeting_delivery ? 'booking_claim' then perform public.fn_service_lock(new.organization_id,new.contact_id); select * into j from public.job_queue where organization_id=new.organization_id and id=(new.meeting_delivery->>'source_operation_id')::uuid for update; b:=new.meeting_delivery->'service_boundary'; if j.contact_id is distinct from new.contact_id or j.status is distinct from 'running' or j.kind not in ('inbound_turn','followup_turn','case_reply_turn','operator_turn') or j.locked_by is distinct from new.meeting_delivery->'booking_claim'->>'worker_id' or j.locked_at is distinct from (new.meeting_delivery->'booking_claim'->>'acquired_at')::timestamptz or j.payload->'service_boundary' is distinct from b or not public.fn_meet_boundary_current(b) or b->>'conversation_id' is distinct from new.conversation_id::text then raise exception 'meet_booking_stale' using errcode='40001';end if; if new.meeting_delivery->'authorized_by'->>'kind' is distinct from 'ai_agent' then raise exception 'meet_booking_origin_invalid' using errcode='42501';end if; new.meeting_delivery:=new.meeting_delivery-'booking_claim'; end if; if new.meeting_delivery ? 'generation' and (tg_op='INSERT' or new.meeting_delivery->>'generation' is distinct from old.meeting_delivery->>'generation') then new.meeting_delivery:=new.meeting_delivery||jsonb_build_object('channel_session_id',(select channel_session_id from public.conversations where organization_id=new.organization_id and contact_id=new.contact_id and id::text=new.meeting_delivery->'service_boundary'->>'conversation_id')); end if; if new.meeting_state='pending' and new.meeting_next_attempt_at is not null then new.google_next_attempt_at:=least(new.google_next_attempt_at,new.meeting_next_attempt_at);end if; return new; end;$$; revoke all on function public.fn_meet_stamp() from public,anon,authenticated; drop trigger if exists trg_zz_meet_stamp on public.calendar_appointments; create trigger trg_zz_meet_stamp before insert or update on public.calendar_appointments for each row execute function public.fn_meet_stamp(); create or replace function public.fn_meet_observe(p_org uuid,p_id uuid,p_args jsonb) returns void language plpgsql security definer set search_path=public as $$ declare a public.calendar_appointments; r jsonb:=p_args->'result'; begin select * into a from public.calendar_appointments where organization_id=p_org and id=p_id for update; if not found or a.status='cancelled' or a.location_kind<>'google_meet' or a.meeting_request_id is distinct from (p_args->>'meeting_request_id')::uuid or a.google_claim_token is distinct from (p_args->'claim'->>'token')::uuid or a.google_claim_epoch::text is distinct from p_args->'claim'->>'epoch' or a.google_claim_until<=clock_timestamp() or a.google_claim_until is null or a.revision::text is distinct from p_args->>'revision' or a.google_local_revision::text is distinct from p_args->>'local_revision' then raise exception 'meet_stale' using errcode='40001';end if; if r->>'state' is null or r->>'state' not in ('pending','ready','failed') or (r->>'error' is not null and r->>'error' not in ('google_failure','unsupported','unknown','invalid')) then raise exception 'meet_invalid' using errcode='22023';end if; if r->>'state'='ready' and (r->>'url' is null or r->>'url' !~ '^https://meet[.]google[.]com/[a-zA-Z0-9-]+/?$') then raise exception 'meet_invalid_url' using errcode='22023';end if; update public.calendar_appointments set meeting_state=case when r->>'state'='pending' and meeting_attempts>=19 then 'failed' else r->>'state' end, meeting_url=case when r->>'state'='ready' then r->>'url' else null end, meeting_last_error=case when r->>'state'='pending' and meeting_attempts>=19 then 'unknown' else r->>'error' end, meeting_received_at=case when coalesce((r->>'received')::boolean,false) then coalesce(meeting_received_at,now()) else meeting_received_at end, meeting_ready_at=case when r->>'state'='ready' then coalesce(meeting_ready_at,now()) else null end, meeting_attempts=meeting_attempts+1, meeting_next_attempt_at=now()+make_interval(secs=>least(900,15*power(2,least(meeting_attempts,6)))::double precision+floor(random()*5)), google_etag=coalesce(r->>'etag',google_etag) where organization_id=p_org and id=p_id; end;$$; revoke all on function public.fn_meet_observe(uuid,uuid,jsonb) from public,anon,authenticated; revoke all on function public.fn_meet_observe(uuid,uuid,jsonb) from service_role; create or replace function public.fn_google_appointment(p_org uuid,p_id uuid,p_action text,p_args jsonb default '{}') returns jsonb language plpgsql security definer set search_path=public as $$ declare a public.calendar_appointments; c public.calendar_connection_calendars; conn public.calendar_connections; contact uuid; claim jsonb:=p_args->'claim'; result jsonb; b jsonb; changed boolean; remote jsonb; begin select contact_id into contact from public.calendar_appointments where organization_id=p_org and id=p_id; if not found then raise exception 'appointment_not_found' using errcode='P0002';end if; if contact is not null then perform public.fn_service_lock(p_org,contact);end if; -- Seleção/reserva compartilham membership antes dos locks de calendário/appointment. perform 1 from public.user_organizations m join public.calendar_appointments x on x.organization_id=m.organization_id and x.owner_user_id=m.user_id where x.organization_id=p_org and x.id=p_id for update of m; if p_args?'calendar_fence' then perform public.fn_google_calendar_fence(p_org,(p_args->'calendar_fence'->>'id')::uuid,p_args->'calendar_fence'->'claim',p_args->'calendar_fence'->'cursor'); end if; select * into a from public.calendar_appointments where organization_id=p_org and id=p_id for update; if a.contact_id is distinct from contact then raise exception 'appointment_stale' using errcode='40001';end if; if contact is not null and exists(select 1 from public.contacts where organization_id=p_org and id=contact and is_anonymized) then if p_action='claim' then return jsonb_build_object('terminal','redacted');end if; raise exception 'google_contact_redacted' using errcode='42501';end if; if not exists(select 1 from public.user_organizations where organization_id=p_org and user_id=a.owner_user_id and revoked_at is null) then raise exception 'google_owner_unavailable' using errcode='42501';end if; if p_action='claim' then if a.google_claim_until>clock_timestamp() then return null;end if; if a.google_event_id is null and a.status<>'cancelled' then select k.* into c from public.calendar_connection_calendars k join public.calendar_connections x on x.id=k.connection_id and x.organization_id=k.organization_id where k.organization_id=p_org and x.user_id=a.owner_user_id and k.is_destination; if not found or (select count(*) from public.calendar_connection_calendars k join public.calendar_connections x on x.id=k.connection_id and x.organization_id=k.organization_id where k.organization_id=p_org and x.user_id=a.owner_user_id and k.is_destination)<>1 then update public.calendar_appointments set google_sync_error='Escolha uma agenda de destino nas configurações.',google_next_attempt_at=now()+interval '15 minutes' where organization_id=p_org and id=p_id;return null; end if; if not c.available or c.access_role not in ('owner','writer') then update public.calendar_appointments set google_sync_error='A agenda de destino não permite publicação. Confira o acesso nas configurações.',google_next_attempt_at=now()+interval '15 minutes' where organization_id=p_org and id=p_id;return null;end if; update public.calendar_appointments set google_connection_id=c.connection_id,google_calendar_id=c.external_calendar_id, google_event_id='deskcommapp'||replace(id::text,'-',''),google_pending_write='{"reservation":true}'::jsonb where organization_id=p_org and id=p_id returning * into a; end if; update public.calendar_appointments set google_claim_token=gen_random_uuid(),google_claim_epoch=google_claim_epoch+1, google_claim_until=clock_timestamp()+interval '90 seconds' where organization_id=p_org and id=p_id returning * into a; else if a.google_claim_token is distinct from (claim->>'token')::uuid or a.google_claim_epoch::text is distinct from claim->>'epoch' or a.google_claim_until is null or a.google_claim_until<=clock_timestamp() then raise exception 'google_stale' using errcode='40001';end if; if p_action='renew' then if a.revision::text is distinct from p_args->>'revision' or a.google_local_revision::text is distinct from p_args->>'local_revision' then raise exception 'google_stale' using errcode='40001';end if; if not exists(select 1 from public.calendar_connections x join public.calendar_connection_calendars k on k.organization_id=x.organization_id and k.connection_id=x.id where x.organization_id=p_org and x.id=a.google_connection_id and x.user_id=a.owner_user_id and x.status='healthy' and k.external_calendar_id=a.google_calendar_id and k.available and k.access_role in ('writer','owner')) then raise exception 'google_connection_unavailable' using errcode='42501';end if; update public.calendar_appointments set google_claim_until=clock_timestamp()+interval '90 seconds' where organization_id=p_org and id=p_id returning * into a; elsif p_action='release' then update public.calendar_appointments set google_claim_token=null,google_claim_until=null where organization_id=p_org and id=p_id;return 'true'; else if a.revision::text is distinct from p_args->>'revision' or a.google_local_revision::text is distinct from p_args->>'local_revision' or a.google_event_id is distinct from p_args->>'event_id' or a.google_connection_id::text is distinct from p_args->>'connection_id' or a.google_calendar_id is distinct from p_args->>'calendar_id' then raise exception 'google_stale' using errcode='40001';end if; if p_action='error' then update public.calendar_appointments set google_sync_error=left(p_args->>'message',200),google_next_attempt_at=now()+interval '15 minutes', meeting_state=case when meeting_state='pending' and meeting_attempts>=19 then 'failed' else meeting_state end, meeting_last_error=case when meeting_state='pending' then 'unknown' else meeting_last_error end, meeting_attempts=meeting_attempts+case when meeting_state='pending' then 1 else 0 end, meeting_next_attempt_at=case when meeting_state='pending' then now()+make_interval(secs=>least(900,15*power(2,least(meeting_attempts,6)))::double precision+floor(random()*5)) else meeting_next_attempt_at end where organization_id=p_org and id=p_id;return 'true';end if; if a.google_event_id is not null then select * into conn from public.calendar_connections where organization_id=p_org and id=a.google_connection_id and user_id=a.owner_user_id; select * into c from public.calendar_connection_calendars where organization_id=p_org and connection_id=a.google_connection_id and external_calendar_id=a.google_calendar_id; if conn.id is null or conn.status<>'healthy' or c.id is null or not c.available then raise exception 'google_connection_unavailable' using errcode='42501';end if; end if; if p_action='meet' then perform public.fn_meet_observe(p_org,p_id,p_args); select * into a from public.calendar_appointments where organization_id=p_org and id=p_id; elsif p_action='prepare' then if c.access_role not in ('owner','writer') or (a.google_pending_write is not null and a.google_pending_write<>'{"reservation":true}'::jsonb) or a.google_conflict is not null then raise exception 'google_write_unavailable' using errcode='40001';end if; if p_args->'operation'?'conference_request_id' and (a.meeting_request_id is distinct from (p_args->'operation'->>'conference_request_id')::uuid or a.meeting_state<>'pending' or a.meeting_received_at is not null or a.status='cancelled') then raise exception 'meet_stale' using errcode='40001';end if; update public.calendar_appointments set meeting_requested_at=case when p_args->'operation'?'conference_request_id' then coalesce(meeting_requested_at,now()) else meeting_requested_at end,google_pending_write=p_args->'operation' where organization_id=p_org and id=p_id;return 'true'; elsif p_action='idle' then update public.calendar_appointments set google_next_attempt_at=now()+interval '15 minutes' where organization_id=p_org and id=p_id;return 'true'; elsif p_action='commit' then result:=p_args->'result'; b:=result->'base';remote:=result->'remote'; if result?'operation_id' and a.google_pending_write->>'operation_id' is distinct from result->>'operation_id' then raise exception 'google_stale' using errcode='40001';end if; if result?'apply_remote' then if a.status not in ('pending','confirmed') then raise exception 'google_outcome_protected' using errcode='40001';end if; if not coalesce((remote->>'cancelled')::boolean,false) and exists(select 1 from public.calendar_appointments other where other.organization_id=p_org and other.owner_user_id=a.owner_user_id and other.id<>a.id and other.status in ('pending','confirmed') and other.starts_at<(remote->>'ends_at')::timestamptz and other.ends_at>(remote->>'starts_at')::timestamptz) then return jsonb_build_object('overlap',true);end if; changed:=row(a.starts_at,a.ends_at,a.time_zone,a.status='cancelled') is distinct from row((remote->>'starts_at')::timestamptz,(remote->>'ends_at')::timestamptz,remote->>'time_zone',(remote->>'cancelled')::boolean); perform public.fn_appointment_change_core(p_org,p_id,a.revision, jsonb_build_object('starts_at',remote->>'starts_at','ends_at',remote->>'ends_at','time_zone',remote->>'time_zone')|| case when (remote->>'cancelled')::boolean then '{"status":"cancelled","cancellation_reason":"Cancelado no Google"}'::jsonb else '{}'::jsonb end,true,b); if changed then insert into public.crm_lead_activities(organization_id,lead_id,contact_id,type,source_module,source_id,actor_kind,reason,payload) select p_org,l.lead_id,a.contact_id,case when (remote->>'cancelled')::boolean then 'appointment_cancelled' else 'appointment_rescheduled' end, 'agenda',p_id,'system',case when (remote->>'cancelled')::boolean then 'Cancelado no Google' else 'Remarcado no Google' end,jsonb_build_object('origin','google','appointment_id',p_id,'resolution_actor_id',a.google_conflict->'resolution'->>'actor_id') from public.crm_lead_links l where l.organization_id=p_org and l.target_id=p_id and l.target_kind='appointment' group by l.lead_id; end if; end if; update public.calendar_appointments set google_base_projection=case when result?'base' then b else google_base_projection end, google_etag=case when result?'etag' then result->>'etag' else google_etag end, google_conflict=case when result?'conflict' then nullif(result->'conflict','null'::jsonb) else google_conflict end, google_pending_write=case when coalesce((result->>'retry_creation')::boolean,false) and a.google_base_projection is null and a.google_pending_write->>'method'='POST' then '{"reservation":true}'::jsonb when coalesce((result->>'clear_pending')::boolean,false) then null else google_pending_write end, google_synced_local_revision=case when coalesce((result->>'ack')::boolean,false) then a.google_local_revision else google_synced_local_revision end, google_synced_at=case when coalesce((result->>'ack')::boolean,false) then now() else google_synced_at end, google_sync_error=null,google_next_attempt_at=now()+interval '5 minutes' where organization_id=p_org and id=p_id returning * into a; else raise exception 'google_action_invalid' using errcode='22023';end if; end if; end if; return to_jsonb(a)||jsonb_build_object('revision',a.revision::text,'google_local_revision',a.google_local_revision::text, 'google_synced_local_revision',a.google_synced_local_revision::text,'meeting_allowed_types',(select allowed_conference_types from public.calendar_connection_calendars where organization_id=p_org and connection_id=a.google_connection_id and external_calendar_id=a.google_calendar_id),'claim',jsonb_build_object('token',a.google_claim_token,'epoch',a.google_claim_epoch::text,'lease_until',a.google_claim_until)); end;$$; revoke all on function public.fn_google_appointment(uuid,uuid,text,jsonb) from public,anon,authenticated; grant execute on function public.fn_google_appointment(uuid,uuid,text,jsonb) to service_role; create or replace function public.fn_google_catalog(p_org uuid,p_connection uuid,p_items jsonb,p_revision text) returns void language plpgsql security definer set search_path=public as $$ declare conn public.calendar_connections; it jsonb; fresh boolean; begin select * into conn from public.calendar_connections where organization_id=p_org and id=p_connection; if not found then raise exception 'google_connection_unavailable' using errcode='P0002';end if; perform 1 from public.user_organizations where organization_id=p_org and user_id=conn.user_id and revoked_at is null for update; if not found then raise exception 'google_owner_unavailable' using errcode='42501';end if; select * into conn from public.calendar_connections where organization_id=p_org and id=p_connection; if conn.status<>'healthy' then raise exception 'google_connection_unavailable' using errcode='42501';end if; if conn.calendar_selection_revision::text is distinct from p_revision then raise exception 'google_selection_stale' using errcode='40001';end if; fresh:=(select count(*) from public.calendar_connections where organization_id=p_org and user_id=conn.user_id and provider='google_calendar')=1 and conn.calendar_selection_revision=0 and not exists(select 1 from public.calendar_connection_calendars k join public.calendar_connections x on x.organization_id=k.organization_id and x.id=k.connection_id where k.organization_id=p_org and x.user_id=conn.user_id and k.is_destination); for it in select value from jsonb_array_elements(p_items) loop insert into public.calendar_connection_calendars(organization_id,connection_id,external_calendar_id,name,time_zone,is_primary,access_role,available,catalog_checked_at,counts_for_conflicts,is_destination,allowed_conference_types) values(p_org,p_connection,it->>'id',coalesce(it->>'summaryOverride',it->>'summary',it->>'id'),it->>'timeZone',coalesce((it->>'primary')::boolean,false),it->>'accessRole',not coalesce((it->>'deleted')::boolean,false),now(), fresh and coalesce((it->>'primary')::boolean,false),false,case when jsonb_typeof(it->'conferenceProperties'->'allowedConferenceSolutionTypes')='array' then array(select jsonb_array_elements_text(it->'conferenceProperties'->'allowedConferenceSolutionTypes')) else null end) on conflict(organization_id,connection_id,external_calendar_id) do update set name=excluded.name,time_zone=excluded.time_zone,is_primary=excluded.is_primary, allowed_conference_types=excluded.allowed_conference_types,access_role=excluded.access_role,available=excluded.available,catalog_checked_at=excluded.catalog_checked_at,sync_next_attempt_at=now(); end loop; update public.calendar_connection_calendars set available=false,catalog_checked_at=now() where organization_id=p_org and connection_id=p_connection and not exists(select 1 from jsonb_array_elements(p_items) v where v->>'id'=external_calendar_id); if fresh then update public.calendar_connection_calendars set is_destination=true where organization_id=p_org and connection_id=p_connection and is_primary and available and access_role in ('owner','writer'); end if; update public.calendar_connections set calendar_selection_revision=calendar_selection_revision+1 where organization_id=p_org and id=p_connection; end;$$; revoke all on function public.fn_google_catalog(uuid,uuid,jsonb,text) from public,anon,authenticated; grant execute on function public.fn_google_catalog(uuid,uuid,jsonb,text) to service_role; create or replace function public.fn_meet_notice(p_org uuid,p_id uuid,p_reason text) returns void language plpgsql security definer set search_path=public as $$ begin insert into public.agent_inbox_items(organization_id,kind,severity,title,body,ref_kind,ref_id,appointment_revision) select p_org,'other','warn','Link da reunião precisa de atenção', 'Abra o compromisso na Agenda para verificar o link ou autorizar uma nova entrega.','appointment',id,revision from public.calendar_appointments where organization_id=p_org and id=p_id on conflict(organization_id,ref_id,appointment_revision,kind) where ref_kind='appointment' and appointment_revision is not null do update set status='open',resolved_at=null,body=excluded.body; end;$$; revoke all on function public.fn_meet_notice(uuid,uuid,text) from public,anon,authenticated; grant execute on function public.fn_meet_notice(uuid,uuid,text) to service_role; create or replace function public.fn_meet_delivery_enqueue() returns trigger language plpgsql security definer set search_path=public as $$ declare jid uuid; b jsonb; begin -- A MESMA ORDEM DE TRAVA das ~20 irmãs: contato PRIMEIRO, job_queue depois. -- Sem esta linha, este gatilho já segurava a linha do compromisso (é BEFORE/ -- AFTER na própria calendar_appointments) e ia travar job_queue sem o mutex do -- contato, enquanto fn_meet_redact_contact (0229) pega o mutex do contato e só -- então mexe em job_queue. Duas ordens opostas sobre os mesmos dois recursos = -- deadlock (40P01) sob concorrência, e quem paga é o cliente com anonimização -- LGPD acontecendo enquanto um link de reunião é entregue. perform public.fn_service_lock(new.organization_id,new.contact_id); if new.meeting_state='cancelled' or new.meeting_delivery->>'state' in ('blocked','stale') then update public.job_queue set status='failed',locked_at=null,locked_by=null,payload='{}',last_error='meet_delivery_stale' where organization_id=new.organization_id and id=new.meeting_delivery_job_id and kind='transactional_delivery' and status in ('pending','running'); return new; end if; if new.meeting_state='failed' then perform public.fn_meet_notice(new.organization_id,new.id,'meeting_failed');end if; if new.meeting_state<>'ready' or new.meeting_delivery->>'state'<>'waiting_for_link' then return new;end if; b:=new.meeting_delivery->'service_boundary'; if not public.fn_meet_boundary_current(b) then update public.calendar_appointments set meeting_delivery=meeting_delivery||'{"state":"stale","error":"service_boundary_stale"}' where organization_id=new.organization_id and id=new.id; perform public.fn_meet_notice(new.organization_id,new.id,'service_boundary_stale');return new; end if; jid:=gen_random_uuid(); insert into public.job_queue(id,organization_id,contact_id,kind,payload,run_after) values(jid,new.organization_id,new.contact_id,'transactional_delivery',jsonb_build_object('appointment_id',new.id,'meeting_request_id',new.meeting_request_id, 'delivery_generation',new.meeting_delivery->>'generation','service_boundary',b),now()); update public.calendar_appointments set meeting_delivery_job_id=jid,meeting_delivery=meeting_delivery||'{"state":"queued"}' where organization_id=new.organization_id and id=new.id; return new; end;$$; revoke all on function public.fn_meet_delivery_enqueue() from public,anon,authenticated; drop trigger if exists trg_meet_delivery_enqueue on public.calendar_appointments; create trigger trg_meet_delivery_enqueue after insert or update on public.calendar_appointments for each row execute function public.fn_meet_delivery_enqueue(); create or replace function public.fn_meet_delivery_current(p_org uuid,p_job uuid,p_worker text,p_acquired_at timestamptz) returns boolean language sql stable security definer set search_path=public as $$ select exists(select 1 from public.job_queue j join public.calendar_appointments a on a.organization_id=j.organization_id and a.id::text=j.payload->>'appointment_id' join public.contacts c on c.organization_id=a.organization_id and c.id=a.contact_id join public.conversations v on v.organization_id=a.organization_id and v.contact_id=a.contact_id and v.id::text=j.payload->'service_boundary'->>'conversation_id' join public.channel_sessions cs on cs.organization_id=v.organization_id and cs.id=v.channel_session_id join public.organizations o on o.id=a.organization_id and o.status='active' where cs.archived_at is null and a.meeting_delivery->>'channel_session_id'=cs.id::text and j.organization_id=p_org and j.id=p_job and j.kind='transactional_delivery' and j.status='running' and j.locked_by=p_worker and j.locked_at=p_acquired_at and a.contact_id=j.contact_id and not c.is_anonymized and not c.is_blocked and a.status<>'cancelled' and a.meeting_state='ready' and a.meeting_url is not null and a.meeting_request_id::text=j.payload->>'meeting_request_id' and a.meeting_delivery->>'generation'=j.payload->>'delivery_generation' and a.meeting_delivery_job_id=j.id and a.meeting_delivery->>'state'='queued' and exists(select 1 from public.user_organizations where organization_id=p_org and user_id=a.owner_user_id and revoked_at is null) and (a.meeting_delivery->'authorized_by'->>'kind'='ai_agent' or (a.meeting_delivery->'authorized_by'->>'kind'='user' and a.meeting_delivery->'authorized_by'->>'id'=a.owner_user_id::text and exists( select 1 from public.user_organizations u where u.organization_id=p_org and u.user_id=a.owner_user_id and u.revoked_at is null and u.role in ('agent','manager','admin') and (u.role in ('manager','admin') or v.assigned_to_user_id=u.user_id or o.settings->>'visibility_mode'='all' or (coalesce(o.settings->>'visibility_mode','own_and_unassigned')='own_and_unassigned' and v.assigned_to_user_id is null))))) and a.meeting_delivery->'service_boundary'=j.payload->'service_boundary' and public.fn_meet_boundary_current(j.payload->'service_boundary')); $$; revoke all on function public.fn_meet_delivery_current(uuid,uuid,text,timestamptz) from public,anon,authenticated; grant execute on function public.fn_meet_delivery_current(uuid,uuid,text,timestamptz) to service_role; -- Política privada: sempre relida por aquisição original, inclusive no sink. -- A origem humana vem somente do recibo protegido, nunca de payload do caller. create or replace function public.fn_meet_delivery_policy(p_org uuid,p_job uuid,p_worker text,p_acquired_at timestamptz) returns jsonb language plpgsql stable security definer set search_path=public as $$ declare r record; begin select a.meeting_delivery,a.contact_id,v.channel_session_id,c.is_blocked,c.is_anonymized,c.force_human,c.ai_authorized_at,v.assignee_kind,v.bot_silenced_until,cs.metadata,cs.archived_at, public.fn_meet_delivery_current(p_org,p_job,p_worker,p_acquired_at) as current into r from public.job_queue j join public.calendar_appointments a on a.organization_id=j.organization_id and a.id::text=j.payload->>'appointment_id' join public.contacts c on c.organization_id=a.organization_id and c.id=a.contact_id join public.conversations v on v.organization_id=a.organization_id and v.contact_id=a.contact_id and v.id::text=j.payload->'service_boundary'->>'conversation_id' join public.channel_sessions cs on cs.organization_id=v.organization_id and cs.id=v.channel_session_id where j.organization_id=p_org and j.id=p_job and j.kind='transactional_delivery' and j.status='running' and j.locked_by=p_worker and j.locked_at=p_acquired_at and a.meeting_delivery_job_id=j.id and a.meeting_delivery->>'generation'=j.payload->>'delivery_generation'; if not found then return jsonb_build_object('current',false,'reason','stale');end if; if not r.current then return jsonb_build_object('current',false,'reason',case when r.is_anonymized then 'lgpd' when r.is_blocked then 'opt_out' when r.archived_at is not null then 'channel' else 'access_or_stale' end);end if; return jsonb_build_object('current',true,'contact_id',r.contact_id,'channel_session_id',r.channel_session_id,'human_command',r.meeting_delivery->'authorized_by'->>'kind'='user', 'force_human',r.force_human,'ai_gate',r.metadata->>'ai_gate','ai_authorized_at',r.ai_authorized_at,'assignee_kind',r.assignee_kind,'bot_silenced_until',r.bot_silenced_until); end;$$; revoke all on function public.fn_meet_delivery_policy(uuid,uuid,text,timestamptz) from public,anon,authenticated; grant execute on function public.fn_meet_delivery_policy(uuid,uuid,text,timestamptz) to service_role; -- Fencing também no callback/settle. O sink tem sua própria revalidação; o -- transporte já aceito não é desfeito, mas callback velho não reidrata estado. create or replace function public.fn_meet_delivery_settle(p_org uuid,p_job uuid,p_worker text,p_acquired_at timestamptz,p_state text,p_retry_at timestamptz default null) returns boolean language plpgsql security definer set search_path=public as $$ declare j public.job_queue; a public.calendar_appointments; contact uuid; current_intent boolean; reason text; begin select contact_id into contact from public.job_queue where organization_id=p_org and id=p_job; if contact is null then return false;end if; perform public.fn_service_lock(p_org,contact); select * into j from public.job_queue where organization_id=p_org and id=p_job for update; if not found or j.kind<>'transactional_delivery' or j.status<>'running' or j.locked_by is distinct from p_worker or j.locked_at is distinct from p_acquired_at then return false;end if; select * into a from public.calendar_appointments where organization_id=p_org and id::text=j.payload->>'appointment_id' for update; current_intent:=a.meeting_delivery_job_id=j.id and a.meeting_delivery->>'generation'=j.payload->>'delivery_generation'; if p_state like 'blocked:%' then reason:=substring(p_state from 9); if reason not in ('opt_out','lgpd','channel','access_or_stale','force_human','conversa_silenciada','conversa_de_humano','sem_autorizacao','autorizacao_expirada','limits','guardrail') then raise exception 'meet_reason_invalid' using errcode='22023';end if; p_state:='blocked'; end if; if p_state not in ('sent','queued','retry','failed','blocked','stale') then raise exception 'meet_state_invalid' using errcode='22023';end if; if p_state in ('sent','queued','retry') and not public.fn_meet_delivery_current(p_org,p_job,p_worker,p_acquired_at) then p_state:='stale';end if; if p_state='sent' and not exists(select 1 from public.send_ledger where organization_id=p_org and job_id=p_job and seq=1 and status='accepted') then raise exception 'meet_delivery_not_accepted' using errcode='40001';end if; if p_state='retry' and j.attempts>=j.max_attempts then p_state:='failed';end if; if p_state in ('queued','retry') then update public.job_queue set status='pending',locked_by=null,locked_at=null,attempts=case when p_state='queued' then greatest(0,attempts-1) else attempts end,run_after=coalesce(p_retry_at,now()+interval '1 minute'),last_error='meet_delivery_waiting' where id=p_job and organization_id=p_org; else update public.job_queue set status=case when p_state='sent' then 'done' else 'failed' end,locked_by=null,locked_at=null,last_error=case when p_state='sent' then null else coalesce(reason,'meet_delivery_'||p_state) end where id=p_job and organization_id=p_org; if current_intent then update public.calendar_appointments set meeting_delivery=meeting_delivery||jsonb_build_object('state',p_state,'error',case when p_state='sent' then null else coalesce(reason,'meet_delivery_'||p_state) end,'settled_at',now()) where id=a.id and organization_id=p_org; if p_state<>'sent' then perform public.fn_meet_notice(p_org,a.id,p_state); else update public.agent_inbox_items set status='resolved',resolved_at=now() where organization_id=p_org and ref_kind='appointment' and ref_id=a.id and kind='other' and status='open';end if; end if; end if; return true; end;$$; revoke all on function public.fn_meet_delivery_settle(uuid,uuid,text,timestamptz,text,timestamptz) from public,anon,authenticated; grant execute on function public.fn_meet_delivery_settle(uuid,uuid,text,timestamptz,text,timestamptz) to service_role; -- Prova de sessão, independente da política de CADASTRO obrigatório de MFA. create or replace function public.fn_session_mfa_proven() returns boolean language sql stable security definer set search_path=public as $$ select auth.uid() is not null and (coalesce(auth.jwt()->>'aal','aal1')='aal2' or not exists( select 1 from auth.mfa_factors where user_id=auth.uid() and factor_type='totp' and status='verified')); $$; revoke all on function public.fn_session_mfa_proven() from public,anon,authenticated; create or replace function public.fn_meet_action(p_org uuid,p_id uuid,p_revision text,p_request uuid,p_action text,p_conversation uuid default null) returns boolean language plpgsql security definer set search_path=public as $$ declare a public.calendar_appointments; contact uuid; b jsonb; destination_channel uuid; begin if auth.uid() is null or not public.fn_role_at_least(p_org,'agent') or not public.fn_support_write_allowed(p_org) then raise exception 'meet_forbidden' using errcode='42501';end if; if not public.fn_session_mfa_proven() then raise exception 'meet_mfa_required' using errcode='42501';end if; select contact_id into contact from public.calendar_appointments where organization_id=p_org and id=p_id; if contact is not null then perform public.fn_service_lock(p_org,contact);end if; select * into a from public.calendar_appointments where organization_id=p_org and id=p_id for update; if not found or a.owner_user_id is distinct from auth.uid() or not exists(select 1 from public.user_organizations where organization_id=p_org and user_id=auth.uid() and revoked_at is null) then raise exception 'meet_forbidden' using errcode='42501';end if; if a.revision::text is distinct from p_revision or a.meeting_request_id is distinct from p_request or a.status='cancelled' or a.location_kind<>'google_meet' or exists(select 1 from public.contacts where id=a.contact_id and organization_id=p_org and is_anonymized) then raise exception 'meet_stale' using errcode='40001';end if; if p_action='retry' then if a.google_conflict is not null then raise exception 'google_conflict_requires_choice' using errcode='40001';end if; if a.meeting_state='ready' then return false;end if; if a.meeting_state<>'failed' then update public.calendar_appointments set meeting_next_attempt_at=now(),google_next_attempt_at=now() where organization_id=p_org and id=p_id;return true; end if; -- Tempo/timeout não provam rejeição. Somente failure recebido gira solicitação. update public.calendar_appointments set meeting_request_id=case when meeting_last_error='google_failure' and meeting_received_at is not null then gen_random_uuid() else meeting_request_id end, meeting_requested_at=case when meeting_last_error='google_failure' and meeting_received_at is not null then null else meeting_requested_at end, meeting_received_at=case when meeting_last_error='google_failure' then null else meeting_received_at end, meeting_state='pending',meeting_attempts=0,meeting_last_error=null,meeting_next_attempt_at=now(),google_next_attempt_at=now() where organization_id=p_org and id=p_id; elsif p_action='deliver' then if a.contact_id is null then raise exception 'meet_conversation_unavailable' using errcode='42501';end if; select channel_session_id into destination_channel from public.conversations where organization_id=p_org and id=p_conversation and contact_id=a.contact_id and not is_group and public.fn_can_view_conversation(organization_id,assigned_to_user_id) for update; if not found then raise exception 'meet_conversation_unavailable' using errcode='42501';end if; b:=public.fn_service_boundary(p_org,p_conversation)-'status'-'demanda_fechada_em'-'service_started_at'; if not public.fn_meet_boundary_current(b) then raise exception 'meet_conversation_stale' using errcode='40001';end if; if a.meeting_delivery->'service_boundary'=b and a.meeting_delivery->>'channel_session_id'=destination_channel::text then if a.meeting_delivery->>'state' in ('waiting_for_link','sent') then return false;end if; if a.meeting_delivery->>'state'='queued' and a.meeting_delivery_job_id is not null then -- Recuperação humana de job morto conserva ledger/identidade. Não duplicar -- uma mensagem aceita antes do crash nem reconstruir fronteira antiga. update public.job_queue set status='pending',locked_by=null,locked_at=null,attempts=0,run_after=now(),last_error=null where organization_id=p_org and id=a.meeting_delivery_job_id and kind='transactional_delivery' and status in ('dead','failed','done'); return found; end if; end if; update public.job_queue set status='failed',locked_by=null,locked_at=null,last_error='meet_delivery_superseded' where organization_id=p_org and id=a.meeting_delivery_job_id and kind='transactional_delivery' and status in ('pending','running'); update public.calendar_appointments set meeting_delivery=jsonb_build_object('state','waiting_for_link','generation',gen_random_uuid(),'service_boundary',b,'authorized_by',jsonb_build_object('kind','user','id',auth.uid()),'source_operation_id',gen_random_uuid()),meeting_delivery_job_id=null where organization_id=p_org and id=p_id; else raise exception 'meet_action_invalid' using errcode='22023';end if; return true; end;$$; revoke all on function public.fn_meet_action(uuid,uuid,text,uuid,text,uuid) from public,anon; grant execute on function public.fn_meet_action(uuid,uuid,text,uuid,text,uuid) to authenticated; -- Backfill operacional, sem assumir que URL legada é resposta validada Google. update public.calendar_appointments set meeting_state='not_requested' where location_kind='google_meet' and meeting_state='not_requested' and status<>'cancelled'; -- Resultados/contextos derivados não são um segundo cofre de URL. A resposta -- autorizada continua funcional em memória e a mensagem em messages.body. create or replace function public.fn_meet_minimize_runtime() returns trigger language plpgsql set search_path=public as $$ begin new:=jsonb_populate_record(new,regexp_replace(to_jsonb(new)::text,'https://meet[.]google[.]com/[a-zA-Z0-9-]+',case when tg_table_name='outbound_copies' then '[meet-link]' else '[link da reunião disponível na Agenda]' end,'g')::jsonb); return new; end;$$; revoke all on function public.fn_meet_minimize_runtime() from public,anon,authenticated; do $$ declare tab text;begin foreach tab in array array['lead_checkpoints','lead_state','lead_state_transitions','agent_cases','outbound_copies','conversations'] loop execute format('drop trigger if exists trg_meet_minimize_runtime on public.%I',tab); execute format('create trigger trg_meet_minimize_runtime before insert or update on public.%I for each row execute function public.fn_meet_minimize_runtime()',tab); end loop; end;$$; create or replace function public.fn_meet_redact_contact() returns trigger language plpgsql security definer set search_path=public as $$ begin perform public.fn_service_lock(new.organization_id,new.id); update public.job_queue set payload='{}',status=case when status in ('pending','running') then 'failed' else status end, locked_by=null,locked_at=null,last_error='meet_contact_redacted' where organization_id=new.organization_id and contact_id=new.id and kind='transactional_delivery'; update public.agent_inbox_items set status='resolved',resolved_at=now(),body='Contato anonimizado.',ref_id=null where organization_id=new.organization_id and ref_kind='appointment' and ref_id in(select id from public.calendar_appointments where organization_id=new.organization_id and contact_id=new.id) and kind='other'; update public.calendar_appointments set meeting_url=null,meeting_request_id=null,meeting_requested_at=null,meeting_received_at=null,meeting_last_error=null, meeting_next_attempt_at=null,meeting_delivery='{"state":"blocked"}',meeting_delivery_job_id=null where organization_id=new.organization_id and contact_id=new.id; return new; end;$$; revoke all on function public.fn_meet_redact_contact() from public,anon,authenticated; drop trigger if exists trg_meet_redact_contact on public.contacts; create trigger trg_meet_redact_contact after update of is_anonymized on public.contacts for each row when(new.is_anonymized is true) execute function public.fn_meet_redact_contact(); notify pgrst,'reload schema'; -- ---- autonomia e respostas revisadas (migration 0227) ---- -- Task9: publication is configuration; pause/mode are operation. Reply revisions -- guard ABA without changing ServiceBoundary. Row-local triggers never acquire -- the service advisory after a row lock. Draft snapshots precede model work. alter table public.ai_agents add column if not exists operation_mode text not null default 'automatic'; alter table public.ai_agents add column if not exists paused_at timestamptz; alter table public.ai_agents add column if not exists operation_revision bigint not null default 1; alter table public.conversations add column if not exists reply_context_revision bigint not null default 1; alter table public.ai_agents drop constraint if exists ai_agents_operation_mode_check; alter table public.ai_agents add constraint ai_agents_operation_mode_check check(operation_mode in ('automatic','assisted')); create or replace function public.fn_reply_agent_revision() returns trigger language plpgsql set search_path=public as $$ begin new.operation_revision:=old.operation_revision+case when row(new.operation_mode,new.paused_at,new.published_version_id,new.archived_at,new.config,new.active_kb_version_id) is distinct from row(old.operation_mode,old.paused_at,old.published_version_id,old.archived_at,old.config,old.active_kb_version_id) then 1 else 0 end; return new; end;$$; revoke all on function public.fn_reply_agent_revision() from public,anon,authenticated; drop trigger if exists trg_reply_agent_revision on public.ai_agents; create trigger trg_reply_agent_revision before update on public.ai_agents for each row execute function public.fn_reply_agent_revision(); -- A unique persisted inbound changes the response context, independent of its -- provider timestamp. Duplicate deliveries never INSERT, hence never increment. -- This observer does not dispatch a turn: historical import is not live inbound. create or replace function public.fn_reply_inbound_revision() returns trigger language plpgsql security definer set search_path=public as $$ begin if new.direction='inbound' then update public.conversations set reply_context_revision=reply_context_revision+1 where organization_id=new.organization_id and id=new.conversation_id and contact_id=new.contact_id; end if; return new; end;$$; revoke all on function public.fn_reply_inbound_revision() from public,anon,authenticated; -- Preserve explicit increments from the inbound observer; ordinary callers -- cannot manufacture validity because snapshots originate in the command below. create or replace function public.fn_reply_conversation_revision() returns trigger language plpgsql set search_path=public as $$ begin new.reply_context_revision:=greatest(old.reply_context_revision,new.reply_context_revision)+case when row(new.assigned_to_user_id,new.assignee_kind,new.active_ai_agent_id,new.channel_session_id,new.bot_silenced_until,new.status,new.service_revision,new.current_demanda_id) is distinct from row(old.assigned_to_user_id,old.assignee_kind,old.active_ai_agent_id,old.channel_session_id,old.bot_silenced_until,old.status,old.service_revision,old.current_demanda_id) then 1 else 0 end; return new; end;$$; revoke all on function public.fn_reply_conversation_revision() from public,anon,authenticated; drop trigger if exists trg_reply_conversation_revision on public.conversations; create trigger trg_reply_conversation_revision before update on public.conversations for each row execute function public.fn_reply_conversation_revision(); drop trigger if exists trg_reply_inbound_revision on public.messages; create trigger trg_reply_inbound_revision after insert on public.messages for each row execute function public.fn_reply_inbound_revision(); create or replace function public.fn_reply_channel_revision() returns trigger language plpgsql security definer set search_path=public as $$ begin if row(new.status,new.archived_at,new.metadata,new.provider,new.daily_message_limit) is distinct from row(old.status,old.archived_at,old.metadata,old.provider,old.daily_message_limit) then update public.conversations set reply_context_revision=reply_context_revision+1 where organization_id=new.organization_id and channel_session_id=new.id; end if;return new; end;$$; revoke all on function public.fn_reply_channel_revision() from public,anon,authenticated; drop trigger if exists trg_reply_channel_revision on public.channel_sessions; create trigger trg_reply_channel_revision after update on public.channel_sessions for each row execute function public.fn_reply_channel_revision(); create table if not exists public.ai_reply_drafts( id uuid primary key default gen_random_uuid(),organization_id uuid not null references public.organizations(id) on delete cascade, conversation_id uuid not null references public.conversations(id) on delete cascade, contact_id uuid not null references public.contacts(id) on delete cascade, agent_id uuid not null references public.ai_agents(id) on delete cascade, agent_version_id uuid not null references public.ai_agent_versions(id), channel_session_id uuid not null references public.channel_sessions(id), service_boundary jsonb not null,context_revision bigint not null,operation_revision bigint not null, generation_token uuid not null default gen_random_uuid(),revision bigint not null default 1,status text not null default 'generating' check(status in('generating','pending','approved','sending','sent','dismissed','stale','failed')), original_body text,edited_body text,approved_body text,proposals jsonb not null default '[]',trace jsonb not null default '[]',feedback jsonb, approved_by uuid references auth.users(id),approved_at timestamptz,approved_support_session_id uuid references public.platform_support_sessions(id),send_job_id uuid unique references public.job_queue(id),message_id uuid references public.messages(id), error_code text,created_at timestamptz not null default now(),updated_at timestamptz not null default now(), unique(organization_id,conversation_id,agent_id,context_revision,operation_revision) ); alter table public.ai_reply_drafts enable row level security; revoke all on public.ai_reply_drafts from anon,authenticated; grant select on public.ai_reply_drafts to authenticated; grant all on public.ai_reply_drafts to service_role; drop policy if exists tenant_isolation_ai_reply_drafts_all on public.ai_reply_drafts; create policy tenant_isolation_ai_reply_drafts_all on public.ai_reply_drafts for select to authenticated using(organization_id in(select public.fn_user_org_ids()) and exists(select 1 from public.conversations c where c.organization_id=ai_reply_drafts.organization_id and c.id=conversation_id and public.fn_can_view_conversation(c.organization_id,c.assigned_to_user_id))); create index if not exists ai_reply_drafts_conversation on public.ai_reply_drafts(organization_id,conversation_id,created_at desc); create or replace function public.fn_reply_begin(p_org uuid,p_conversation uuid,p_agent uuid,p_version uuid,p_token uuid) returns public.ai_reply_drafts language plpgsql security definer set search_path=public as $$ declare c public.conversations;a public.ai_agents;d public.ai_reply_drafts;contact uuid;b jsonb; begin select contact_id into contact from public.conversations where organization_id=p_org and id=p_conversation; if contact is null then raise exception 'reply_context_unavailable' using errcode='42501';end if; perform public.fn_service_lock(p_org,contact); select * into a from public.ai_agents where organization_id=p_org and id=p_agent and archived_at is null for share; if not found or a.published_version_id is distinct from p_version then raise exception 'reply_agent_stale' using errcode='40001';end if; select * into c from public.conversations where organization_id=p_org and id=p_conversation and contact_id=contact for no key update; if c.active_ai_agent_id is not null and c.active_ai_agent_id<>p_agent then raise exception 'reply_agent_stale' using errcode='40001';end if; b:=public.fn_service_boundary(p_org,p_conversation)-'status'-'demanda_fechada_em'-'service_started_at'; if not public.fn_meet_boundary_current(b) or exists(select 1 from public.contacts where organization_id=p_org and id=contact and (is_blocked or is_anonymized)) then raise exception 'reply_context_unavailable' using errcode='42501';end if; insert into public.ai_reply_drafts(organization_id,conversation_id,contact_id,agent_id,agent_version_id,channel_session_id,service_boundary,context_revision,operation_revision,generation_token) values(p_org,p_conversation,contact,p_agent,p_version,c.channel_session_id,b,c.reply_context_revision,a.operation_revision,p_token) on conflict(organization_id,conversation_id,agent_id,context_revision,operation_revision) do update set generation_token=case when (ai_reply_drafts.status='failed' or (ai_reply_drafts.status='generating' and ai_reply_drafts.updated_at'pending' or not public.fn_reply_context_current(p_org,p_id) then raise exception 'reply_stale' using errcode='40001';end if; if p_action='reject' then update public.ai_reply_drafts set status='dismissed',feedback=jsonb_build_object('decision','rejected','reason',left(p_feedback,1000)),revision=revision+1,updated_at=now() where id=p_id and organization_id=p_org;return null; elsif p_action='approve' then if p_body is null or length(trim(p_body))=0 or length(p_body)>12000 then raise exception 'reply_body_invalid' using errcode='22023';end if; jid:=gen_random_uuid(); insert into public.job_queue(id,organization_id,contact_id,kind,payload,run_after) values(jid,p_org,contact,'approved_reply',jsonb_build_object('draft_id',d.id,'service_boundary',d.service_boundary),now()); update public.ai_reply_drafts set status='approved',edited_body=p_body,approved_body=p_body,approved_by=auth.uid(),approved_at=now(),approved_support_session_id=case when public.fn_support_context()->>'organization_id'=p_org::text then (public.fn_support_context()->>'id')::uuid else null end,send_job_id=jid, feedback=jsonb_build_object('decision',case when p_body is distinct from original_body then 'edited' else 'approved' end,'reason',left(p_feedback,1000),'correction',case when p_body is distinct from original_body then p_body else null end),revision=revision+1,updated_at=now() where id=p_id and organization_id=p_org;return jid; end if; raise exception 'reply_action_invalid' using errcode='22023'; end;$$; revoke all on function public.fn_reply_action(uuid,uuid,text,text,text,text) from public,anon; grant execute on function public.fn_reply_action(uuid,uuid,text,text,text,text) to authenticated; create or replace function public.fn_reply_delivery_policy(p_org uuid,p_job uuid,p_worker text,p_acquired_at timestamptz) returns jsonb language sql stable security definer set search_path=public as $$ select coalesce((select jsonb_build_object('current',true,'context_current',public.fn_reply_context_current(p_org,d.id), 'contact_id',d.contact_id,'conversation_id',d.conversation_id,'channel_session_id',d.channel_session_id,'draft_id',d.id,'body',d.approved_body,'agent_id',d.agent_id) from public.job_queue j join public.ai_reply_drafts d on d.organization_id=j.organization_id and d.send_job_id=j.id and d.id::text=j.payload->>'draft_id' join public.conversations c on c.organization_id=d.organization_id and c.id=d.conversation_id and c.contact_id=d.contact_id join public.contacts p on p.organization_id=d.organization_id and p.id=d.contact_id join public.channel_sessions s on s.organization_id=d.organization_id and s.id=d.channel_session_id left join public.user_organizations u on u.organization_id=d.organization_id and u.user_id=d.approved_by and u.revoked_at is null and u.role in('agent','manager','admin') left join public.platform_support_sessions ss on ss.id=d.approved_support_session_id and ss.organization_id=d.organization_id and ss.actor_user_id=d.approved_by and ss.access_mode='full' and ss.ended_at is null and ss.expires_at>now() left join public.platform_admins pa on pa.user_id=ss.actor_user_id and pa.revoked_at is null and pa.scope='full' left join auth.sessions au on au.id=ss.auth_session_id and au.user_id=ss.actor_user_id and (au.not_after is null or au.not_after>now()) join public.organizations o on o.id=d.organization_id and o.status='active' where j.organization_id=p_org and j.id=p_job and j.kind='approved_reply' and j.status='running' and j.locked_by=p_worker and j.locked_at=p_acquired_at and j.contact_id=d.contact_id and d.status in('approved','sending') and d.approved_body is not null and d.service_boundary=j.payload->'service_boundary' and not p.is_blocked and not p.is_anonymized and s.archived_at is null and c.channel_session_id=d.channel_session_id and public.fn_meet_boundary_current(d.service_boundary) and((d.approved_support_session_id is not null and ss.id is not null and pa.user_id is not null and au.id is not null and (not(pa.mfa_required or exists(select 1 from auth.mfa_factors mf where mf.user_id=ss.actor_user_id and mf.status='verified')) or au.aal='aal2')) or(d.approved_support_session_id is null and u.user_id is not null and(u.role in('manager','admin') or c.assigned_to_user_id=u.user_id or o.settings->>'visibility_mode'='all' or(coalesce(o.settings->>'visibility_mode','own_and_unassigned')='own_and_unassigned' and c.assigned_to_user_id is null))))),'{"current":false}'::jsonb); $$; revoke all on function public.fn_reply_delivery_policy(uuid,uuid,text,timestamptz) from public,anon,authenticated; grant execute on function public.fn_reply_delivery_policy(uuid,uuid,text,timestamptz) to service_role; create or replace function public.fn_reply_receipt_policy(p_org uuid,p_job uuid,p_worker text,p_acquired_at timestamptz) returns jsonb language sql stable security definer set search_path=public as $$ select coalesce((select jsonb_build_object('current',true,'context_current',false,'contact_id',d.contact_id,'conversation_id',d.conversation_id,'channel_session_id',d.channel_session_id,'draft_id',d.id,'body',d.approved_body,'agent_id',d.agent_id) from public.job_queue j join public.ai_reply_drafts d on d.organization_id=j.organization_id and d.send_job_id=j.id and d.id::text=j.payload->>'draft_id' join public.contacts p on p.organization_id=d.organization_id and p.id=d.contact_id and not p.is_anonymized join public.conversations c on c.organization_id=d.organization_id and c.id=d.conversation_id and c.contact_id=d.contact_id where j.organization_id=p_org and j.id=p_job and j.kind='approved_reply' and j.contact_id=d.contact_id and j.status='running' and j.locked_by=p_worker and j.locked_at=p_acquired_at and d.status in('approved','sending') and d.approved_body is not null and d.service_boundary=j.payload->'service_boundary'),'{"current":false}'::jsonb); $$; revoke all on function public.fn_reply_receipt_policy(uuid,uuid,text,timestamptz) from public,anon,authenticated; grant execute on function public.fn_reply_receipt_policy(uuid,uuid,text,timestamptz) to service_role; alter table public.agent_inbox_items add column if not exists legacy_recovery_code text check(legacy_recovery_code in('sem_canal','sem_credencial','sem_modelo','modelo_ambiguo','sem_versao','migracao_falhou','pronto')); -- Referência tipada abre o agente; causa estruturada registra a última transição. create or replace function public.fn_agent_legacy_notice(p_org uuid,p_agent uuid,p_code text,p_title text,p_body text) returns boolean language plpgsql security definer set search_path=public as $$ declare prior text;a public.ai_agents; begin if p_code not in('sem_canal','sem_credencial','sem_modelo','modelo_ambiguo','sem_versao','migracao_falhou','pronto') then raise exception 'invalid_legacy_state';end if; select * into a from public.ai_agents where organization_id=p_org and id=p_agent and kind='rag_bot' for update; if not found or a.archived_at is not null then return false;end if; -- Revalidar a observação anterior do worker, dentro da mesma serialização. if p_code<>'pronto' and(a.published_version_id is not null or a.paused_at is not null or not a.is_active) then return false;end if; if p_code='pronto' and a.published_version_id is null then return false;end if; select legacy_recovery_code into prior from public.agent_inbox_items where organization_id=p_org and ref_id=p_agent and ref_kind='ai_agent' and legacy_recovery_code is not null order by created_at desc,id desc limit 1; if prior=p_code then return false;end if; if p_code='pronto' and prior is null then return false;end if; if p_code='sem_versao' and prior is not null and prior<>'pronto' then return false;end if; update public.agent_inbox_items set status='resolved',resolved_at=now() where organization_id=p_org and ref_id=p_agent and ref_kind='ai_agent' and legacy_recovery_code is not null and status in('open','ack'); insert into public.agent_inbox_items(organization_id,kind,severity,title,body,ref_kind,ref_id,legacy_recovery_code,status,created_at,resolved_at) values(p_org,'other',case when p_code='pronto' then 'info' else 'warn' end,left(p_title,200),left(p_body,1500),'ai_agent',p_agent,p_code,case when p_code='pronto' then 'resolved' else 'open' end,clock_timestamp(),case when p_code='pronto' then now() else null end); return true; end;$$; revoke all on function public.fn_agent_legacy_notice(uuid,uuid,text,text,text) from public,anon,authenticated; grant execute on function public.fn_agent_legacy_notice(uuid,uuid,text,text,text) to service_role; create or replace function public.fn_agent_legacy_published() returns trigger language plpgsql security definer set search_path=public as $$ begin perform public.fn_agent_legacy_notice(new.organization_id,new.id,'pronto','Agente recuperado','A configuração foi recuperada. As respostas usam a versão publicada.'); return new; end;$$; revoke all on function public.fn_agent_legacy_published() from public,anon,authenticated; drop trigger if exists trg_agent_legacy_published on public.ai_agents; create trigger trg_agent_legacy_published after update of published_version_id on public.ai_agents for each row when(new.kind='rag_bot' and new.published_version_id is not null and new.published_version_id is distinct from old.published_version_id) execute function public.fn_agent_legacy_published(); -- Reconhecimento é atômico com as escritas locais. Não exige autoridade para -- um novo envio, mas mantém identidade, lease e redação até o commit. create or replace function public.fn_reply_record_receipt(p_org uuid,p_job uuid,p_worker text,p_acquired_at timestamptz,p_message uuid,p_external text,p_echo_ids text[] default '{}') returns jsonb language plpgsql security definer set search_path=public as $$ declare contact uuid;d public.ai_reply_drafts;m public.messages; begin select contact_id into contact from public.job_queue where organization_id=p_org and id=p_job; if contact is null then return null;end if; perform public.fn_service_lock(p_org,contact); perform 1 from public.contacts where organization_id=p_org and id=contact and not is_anonymized for share; if not found then return null;end if; select * into d from public.ai_reply_drafts where organization_id=p_org and send_job_id=p_job; if not found then return null;end if; perform 1 from public.conversations where organization_id=p_org and id=d.conversation_id and contact_id=contact for no key update; if not found then return null;end if; perform 1 from public.job_queue where organization_id=p_org and id=p_job for update; perform 1 from public.ai_reply_drafts where organization_id=p_org and id=d.id for update; if public.fn_reply_receipt_policy(p_org,p_job,p_worker,p_acquired_at)->>'current'<>'true' then return null;end if; select * into m from public.messages where organization_id=p_org and id=p_message and conversation_id=d.conversation_id and contact_id=d.contact_id and channel_session_id=d.channel_session_id and direction='outbound' and type='text' and body=d.approved_body and exists(select 1 from public.send_ledger l where l.organization_id=p_org and l.job_id=p_job and l.seq=1 and l.id::text=messages.metadata->>'idempotency_key') for update; if not found then return null;end if; delete from public.messages where organization_id=p_org and conversation_id=d.conversation_id and sent_via='external_device' and external_id=any(p_echo_ids) and id<>p_message; update public.messages set status='sent',external_id=p_external,ack=0 where organization_id=p_org and id=p_message returning * into m; update public.send_ledger set status='accepted',crm_message_id=p_message,updated_at=now(),last_error=null where organization_id=p_org and job_id=p_job and seq=1 and id::text=m.metadata->>'idempotency_key'; -- A resposta aprovada é uma SAÍDA: responde tudo até aqui, e a régua da Fila -- (issue #990, migration 0267) volta ao `last_inbound_at` — "não há mensagem do -- cliente sem resposta". Sem esta coluna o valor antigo ficaria congelado e a -- conversa continuaria contando a espera que esta resposta acabou de encerrar. update public.conversations set last_outbound_at=now(),last_message_at=now(),last_message_preview=left(d.approved_body,280),unread_count_for_assignee=0,awaiting_since=last_inbound_at where organization_id=p_org and id=d.conversation_id; update public.contacts set last_activity_at=now() where organization_id=p_org and id=contact; return to_jsonb(m); end;$$; revoke all on function public.fn_reply_record_receipt(uuid,uuid,text,timestamptz,uuid,text,text[]) from public,anon,authenticated; grant execute on function public.fn_reply_record_receipt(uuid,uuid,text,timestamptz,uuid,text,text[]) to service_role; create or replace function public.fn_reply_settle(p_org uuid,p_job uuid,p_worker text,p_acquired_at timestamptz,p_state text,p_error text default null) returns boolean language plpgsql security definer set search_path=public as $$ declare j public.job_queue;d public.ai_reply_drafts;contact uuid;pol jsonb; begin select contact_id into contact from public.job_queue where organization_id=p_org and id=p_job; if contact is null then return false;end if;perform public.fn_service_lock(p_org,contact); select * into j from public.job_queue where organization_id=p_org and id=p_job for update; if not found or j.kind<>'approved_reply' or j.status<>'running' or j.locked_by is distinct from p_worker or j.locked_at is distinct from p_acquired_at then return false;end if; select * into d from public.ai_reply_drafts where organization_id=p_org and send_job_id=p_job for update; if not found then return false;end if; pol:=public.fn_reply_delivery_policy(p_org,p_job,p_worker,p_acquired_at); if p_state='sent' then if not exists(select 1 from public.send_ledger where organization_id=p_org and job_id=p_job and seq=1 and status='accepted') then p_state:='stale';end if; elsif p_state in('queued','retry') and (pol->>'current'<>'true' or pol->>'context_current'<>'true') then p_state:='stale';end if; if p_state in('queued','retry') and j.attempts>'current'<>'true' or pol->>'context_current'<>'true' then return false;end if; update public.ai_reply_drafts set status='sending',updated_at=now() where organization_id=p_org and id=d.id; return true; end;$$; revoke all on function public.fn_reply_prepare(uuid,uuid,text,timestamptz) from public,anon,authenticated; grant execute on function public.fn_reply_prepare(uuid,uuid,text,timestamptz) to service_role; notify pgrst,'reload schema'; create or replace function public.fn_publish_ai_agent_version( p_org_id uuid, p_agent_id uuid, p_version_id uuid, p_platform_credential_verified boolean, p_expected_provenance text ) returns table ( agent_id uuid, version_id uuid, previous_version_id uuid, published_at timestamptz ) language plpgsql security definer set search_path to 'public' as $$ declare v_agent record; v_version record; v_credential record; v_session record; v_model_count integer; v_previous_version_id uuid; v_published_at timestamptz := now(); begin select a.id, a.organization_id, a.published_version_id, a.archived_at into v_agent from public.ai_agents a where a.id = p_agent_id for update; if not found then raise exception 'agent_not_found' using errcode = 'P0001'; end if; if v_agent.organization_id <> p_org_id then raise exception 'agent_not_found' using errcode = 'P0001'; end if; if v_agent.archived_at is not null then raise exception 'agent_archived' using errcode = 'P0001'; end if; select v.id, v.organization_id, v.agent_id, v.status, v.provider, v.model, v.credential_id, v.channel_session_id, v.provisioning_origin into v_version from public.ai_agent_versions v where v.id = p_version_id for update; if not found then raise exception 'version_not_found' using errcode = 'P0001'; end if; if v_version.agent_id <> p_agent_id or v_version.organization_id <> p_org_id then raise exception 'version_not_found' using errcode = 'P0001'; end if; if p_expected_provenance is not null and ( p_expected_provenance not in('onboarding','legacy_reconciliation') or v_version.provisioning_origin is distinct from p_expected_provenance or (select count(*) from public.ai_agent_versions own_version where own_version.organization_id=p_org_id and own_version.agent_id=p_agent_id)<>1 ) then raise exception 'existing_version_requires_review' using errcode='P0001';end if; if v_version.status not in ('draft', 'superseded') then raise exception 'version_invalid_state' using errcode = 'P0001'; end if; if v_version.credential_id is null and p_platform_credential_verified is not true then raise exception 'credential_missing' using errcode = 'P0001'; end if; if v_version.credential_id is not null then select c.id, c.organization_id, c.provider, c.is_active, c.validated_at into v_credential from public.ai_provider_credentials c where c.id = v_version.credential_id; if not found or v_credential.organization_id <> p_org_id then raise exception 'credential_not_found' using errcode = 'P0001'; end if; if not v_credential.is_active then raise exception 'credential_inactive' using errcode = 'P0001'; end if; if v_credential.validated_at is null then raise exception 'credential_not_validated' using errcode = 'P0001'; end if; if v_credential.provider <> v_version.provider then raise exception 'credential_provider_mismatch' using errcode = 'P0001'; end if; end if; select s.id, s.organization_id, s.status into v_session from public.channel_sessions s where s.id = v_version.channel_session_id; if not found or v_session.organization_id <> p_org_id then raise exception 'channel_session_not_found' using errcode = 'P0001'; end if; if v_session.status <> 'WORKING' then raise exception 'channel_session_offline' using errcode = 'P0001'; end if; select count(*) into v_model_count from public.ai_models m where m.provider = v_version.provider and m.model_id = v_version.model and m.deprecated_at is null; if v_model_count = 0 then raise exception 'model_not_found' using errcode = 'P0001'; end if; v_previous_version_id := v_agent.published_version_id; if v_previous_version_id is not null and v_previous_version_id <> p_version_id then update public.ai_agent_versions set status = 'superseded', superseded_at = v_published_at where id = v_previous_version_id; end if; update public.ai_agent_versions set status = 'published', published_at = v_published_at, superseded_at = null where id = p_version_id; update public.ai_agents set published_version_id = p_version_id, updated_at = v_published_at where id = p_agent_id; return query select p_agent_id, p_version_id, v_previous_version_id, v_published_at; end; $$; revoke all on function public.fn_publish_ai_agent_version(uuid,uuid,uuid,boolean,text) from public,anon,authenticated; grant execute on function public.fn_publish_ai_agent_version(uuid,uuid,uuid,boolean,text) to service_role; create or replace function public.fn_publish_ai_agent_version(p_org_id uuid,p_agent_id uuid,p_version_id uuid,p_platform_credential_verified boolean) returns table(agent_id uuid,version_id uuid,previous_version_id uuid,published_at timestamptz) language sql security definer set search_path=public as $$ select * from public.fn_publish_ai_agent_version(p_org_id,p_agent_id,p_version_id,p_platform_credential_verified,null); $$; revoke all on function public.fn_publish_ai_agent_version(uuid,uuid,uuid,boolean) from public,anon,authenticated; grant execute on function public.fn_publish_ai_agent_version(uuid,uuid,uuid,boolean) to service_role; create or replace function public.fn_publish_ai_agent_version(p_org_id uuid,p_agent_id uuid,p_version_id uuid) returns table(agent_id uuid,version_id uuid,previous_version_id uuid,published_at timestamptz) language sql security definer set search_path=public as $$ select * from public.fn_publish_ai_agent_version(p_org_id,p_agent_id,p_version_id,false); $$; alter table public.ai_agent_versions add column if not exists provisioning_origin text check(provisioning_origin in('onboarding','legacy_reconciliation')); create or replace function public.fn_agent_provisioning_origin() returns trigger language plpgsql set search_path=public as $$ begin if tg_op='INSERT' then if current_user not in('postgres','service_role') then new.provisioning_origin:=null;end if; else new.provisioning_origin:=old.provisioning_origin; if(to_jsonb(new)-array['status','published_at','superseded_at','updated_at','provisioning_origin']) is distinct from(to_jsonb(old)-array['status','published_at','superseded_at','updated_at','provisioning_origin']) then new.provisioning_origin:=null;end if; end if;return new; end;$$; revoke all on function public.fn_agent_provisioning_origin() from public,anon,authenticated; drop trigger if exists trg_agent_provisioning_origin on public.ai_agent_versions; create trigger trg_agent_provisioning_origin before insert or update on public.ai_agent_versions for each row execute function public.fn_agent_provisioning_origin(); -- ---- conversões de anúncio: conexão + livro-razão (migration 0213) ---- -- Idempotente e auto-curativo, como o kit exige: `update.sh` re-aplica este -- arquivo inteiro num banco existente e sem `ON_ERROR_STOP`. create table if not exists public.ad_platform_connections ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, platform text not null, dataset_id text, access_token_encrypted bytea, test_event_code text, enabled boolean not null default false, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), updated_by uuid, constraint ad_platform_connections_platform_conhecida check (platform in ('meta_ads', 'google_ads')) ); create unique index if not exists ad_platform_connections_org_platform_uk on public.ad_platform_connections (organization_id, platform); alter table public.ad_platform_connections enable row level security; revoke all on public.ad_platform_connections from anon, authenticated; grant select, insert, update, delete on public.ad_platform_connections to service_role; drop trigger if exists trg_ad_platform_connections_updated_at on public.ad_platform_connections; create trigger trg_ad_platform_connections_updated_at before update on public.ad_platform_connections for each row execute function public.fn_set_updated_at(); create table if not exists public.ad_conversion_dispatches ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, lead_id uuid not null references public.crm_leads(id) on delete cascade, platform text not null, event_name text not null, status text not null, reason text, event_id text, value_cents bigint, currency text, detail text, attempted_at timestamptz not null default now(), created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); -- Dedup ANTES do índice único (doutrina de migrations §8): num clone que já -- tenha rodado uma versão sem o índice, duas linhas para o mesmo par fariam o -- `update.sh` quebrar aqui. Mantém a mais recente, que é o estado atual. delete from public.ad_conversion_dispatches a using public.ad_conversion_dispatches b where a.organization_id = b.organization_id and a.lead_id = b.lead_id and a.event_name = b.event_name and a.attempted_at < b.attempted_at; create unique index if not exists ad_conversion_dispatches_lead_event_uk on public.ad_conversion_dispatches (organization_id, lead_id, event_name); create index if not exists ad_conversion_dispatches_org_status_idx on public.ad_conversion_dispatches (organization_id, status, attempted_at desc); alter table public.ad_conversion_dispatches enable row level security; revoke all on public.ad_conversion_dispatches from anon, authenticated; grant select, insert, update, delete on public.ad_conversion_dispatches to service_role; drop trigger if exists trg_ad_conversion_dispatches_updated_at on public.ad_conversion_dispatches; create trigger trg_ad_conversion_dispatches_updated_at before update on public.ad_conversion_dispatches for each row execute function public.fn_set_updated_at(); -- ---- o e-mail do convidado no compromisso (migration 0212) ---- -- -- Aditiva e idempotente. Nula = evento sem `attendees`, que é o comportamento de -- 100% das linhas existentes: nada a curar antes, nada a migrar depois. Sem -- CHECK de formato de propósito — a validação de forma é do Zod na rota, onde a -- recusa vira mensagem para quem digitou em vez de erro de constraint. -- -- A coluna gerada `needs_google_push` (migration 0200) continua valendo: editar -- o convidado bumpa `updated_at` pelo trigger que já existe, e a linha volta a -- ser candidata do worker de push na batida seguinte. alter table public.calendar_appointments add column if not exists guest_email text; comment on column public.calendar_appointments.guest_email is 'E-mail de um convidado externo, digitado por quem marca. Quando presente vira `attendees` no evento do Google e o convite sai por e-mail (`sendUpdates=all` na chamada). Nulo = evento sem convidado, que é o comportamento anterior.'; -- ---- credencial de LEITURA da conta de anúncios (migration 0214) ---- -- -- Idempotente e auto-curativo, como o kit exige: o `update.sh` re-aplica este -- arquivo inteiro num banco existente e SEM `ON_ERROR_STOP`. -- -- Tabela separada de `ad_platform_connections` de propósito — o cabeçalho da -- migration 0214 tem as quatro razões; a decisiva é que o índice único da 0213 é -- `(organization_id, platform)` e os dois tokens têm escopos DIFERENTES na Meta -- (escrita no dataset de conversões vs. `ads_read`). Não são o mesmo segredo. -- -- RLS ligada com ZERO policies e grants revogados de anon/authenticated, o mesmo -- desenho de `platform_google_oauth` (0201) e da 0213, pelo mesmo motivo: a anon -- key VAI PARA O BROWSER, e tabela com RLS ligada, sem policy nenhuma e sem -- grant não é servida pelo PostgREST de jeito nenhum — só o `service_role`, que -- vive no servidor. É mais restritivo que uma policy de tenant, não menos: -- não há regra para errar. Medido por -- `tests/invariants/credencial-de-anuncios-e-server-side.test.ts`. create table if not exists public.ad_insights_connections ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, platform text not null, access_token_encrypted bytea not null, default_account_id text, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), updated_by uuid, constraint ad_insights_connections_platform_conhecida check (platform in ('meta_ads', 'google_ads')) ); comment on table public.ad_insights_connections is 'Credencial de LEITURA da conta de anúncios da organização, para o painel /app/ads/meta. Separada de ad_platform_connections de propósito: escopo de token diferente (ads_read), ciclo de vida diferente e nenhum risco de derrubar o envio de conversões. Server-side only: RLS ligada sem policies e grants revogados de anon/authenticated. O token nunca volta ao browser.'; comment on column public.ad_insights_connections.access_token_encrypted is 'Cifrado por fn_encrypt_oauth (pgp_sym/aes256), a mesma cifra de calendar_connections, channel_sessions e ad_platform_connections. NOT NULL: uma linha sem token não descreve conexão nenhuma.'; comment on column public.ad_insights_connections.default_account_id is 'act_ que a tela abre por padrão. Sem FK: o identificador é da Meta e a conta pode sair do alcance do token sem aviso.'; -- Dedup ANTES do índice único (doutrina de migrations §8). A tabela é nova, mas -- o `update.sh` roda sem `ON_ERROR_STOP` num banco que pode ter passado por uma -- versão intermediária deste apêndice: duas linhas para o mesmo par fariam a -- criação do índice falhar e o resto do arquivo seguir pela metade. Mantém a -- mais recente, que é o estado que a tela gravou por último. delete from public.ad_insights_connections a using public.ad_insights_connections b where a.organization_id = b.organization_id and a.platform = b.platform and a.updated_at < b.updated_at; create unique index if not exists ad_insights_connections_org_platform_uk on public.ad_insights_connections (organization_id, platform); alter table public.ad_insights_connections enable row level security; revoke all on public.ad_insights_connections from anon, authenticated; grant select, insert, update, delete on public.ad_insights_connections to service_role; drop trigger if exists trg_ad_insights_connections_updated_at on public.ad_insights_connections; create trigger trg_ad_insights_connections_updated_at before update on public.ad_insights_connections for each row execute function public.fn_set_updated_at(); -- O PostgREST guarda o schema em cache; sem isto a tabela nova só apareceria no -- próximo restart do serviço, e a doutrina de packaging proíbe pedir a quem -- opera uma VPS que reinicie nada depois de um `update.sh`. notify pgrst, 'reload schema'; -- ---- agent_inbox_items.resolved_at (migration 0216) ---- -- `pacing/aviso-de-janela.ts` resolve o aviso de "janela de envio fechada" -- gravando `resolved_at = now()`, e a coluna nunca existiu — o UPDATE falhava -- em produção (engolido, fire-and-forget), e o aviso ficava aberto pra sempre. alter table public.agent_inbox_items add column if not exists resolved_at timestamptz; -- ---- roteamento por canal e reservas (migration 0228) ---- -- 0228 — responsáveis por canal, claim automático serializado e conexão recuperável. -- Independente da 0227: assignment dispara os triggers vigentes, nunca escreve drafts. -- Ordem: mutex de serviço org+contato -> slot org+candidato -> canal SHARE -> conversa NO KEY UPDATE. -- Escrita humana de policy ocorre somente pela RPC (RBAC + MFA + suporte), evitando -- que DML direto burle a transação de substituição/locks. Leitura segue RLS por org. create unique index if not exists channel_sessions_org_id_unique on public.channel_sessions(organization_id,id); create table if not exists public.channel_routing_policies ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, channel_session_id uuid not null, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique(organization_id,channel_session_id), unique(organization_id,id), foreign key(organization_id,channel_session_id) references public.channel_sessions(organization_id,id) on delete cascade ); create table if not exists public.channel_routing_responsibles ( organization_id uuid not null references public.organizations(id) on delete cascade, policy_id uuid not null, user_id uuid not null, created_at timestamptz not null default now(), primary key(policy_id,user_id), foreign key(organization_id,policy_id) references public.channel_routing_policies(organization_id,id) on delete cascade, foreign key(organization_id,user_id) references public.user_organizations(organization_id,user_id) on delete cascade ); create index if not exists channel_routing_responsibles_org_user on public.channel_routing_responsibles(organization_id,user_id); alter table public.channel_routing_policies enable row level security; alter table public.channel_routing_responsibles enable row level security; revoke all on public.channel_routing_policies,public.channel_routing_responsibles from public,anon,authenticated,service_role; grant select on public.channel_routing_policies,public.channel_routing_responsibles to authenticated,service_role; drop policy if exists tenant_isolation_channel_routing_policies_select on public.channel_routing_policies; create policy tenant_isolation_channel_routing_policies_select on public.channel_routing_policies for select to authenticated using(organization_id in(select public.fn_user_org_ids()) or public.fn_is_platform_admin()); drop policy if exists tenant_isolation_channel_routing_responsibles_select on public.channel_routing_responsibles; create policy tenant_isolation_channel_routing_responsibles_select on public.channel_routing_responsibles for select to authenticated using(organization_id in(select public.fn_user_org_ids()) or public.fn_is_platform_admin()); -- Preserva história: apenas o evento pendente duplicado deixa de disputar consumo. with ranked as ( select id,row_number() over(partition by organization_id,entity_id order by created_at,id) n from public.event_log where event_type='conversation.routing_requested' and status in('pending','processing') and entity_id is not null ) update public.event_log e set status='done',metadata=e.metadata||'{"routing_duplicate_recovered":true}'::jsonb from ranked r where e.id=r.id and r.n>1; create unique index if not exists event_log_routing_active_unique on public.event_log(organization_id,entity_id) where event_type='conversation.routing_requested' and status in('pending','processing'); create or replace function public.fn_request_channel_routing(p_org uuid,p_conversation uuid) returns void language plpgsql security definer set search_path=public as $$ declare c public.conversations; begin select * into c from public.conversations where organization_id=p_org and id=p_conversation; if not found or c.assigned_to_user_id is not null or c.status not in('open','pending','claimed','ai_handling') then return;end if; insert into public.event_log(organization_id,event_type,entity_kind,entity_id,payload) values(p_org,'conversation.routing_requested','conversation',c.id, jsonb_build_object('organization_id',p_org,'conversation_id',c.id,'channel_session_id',c.channel_session_id)) on conflict(organization_id,entity_id) where event_type='conversation.routing_requested' and status in('pending','processing') do update set next_attempt_at=case when event_log.status='pending' then now() else event_log.next_attempt_at end; end; $$; revoke all on function public.fn_request_channel_routing(uuid,uuid) from public,anon,authenticated; grant execute on function public.fn_request_channel_routing(uuid,uuid) to service_role; create or replace function public.fn_emit_conversation_routing() returns trigger language plpgsql security definer set search_path=public as $$ begin perform public.fn_request_channel_routing(new.organization_id,new.id); return null; end; $$; revoke all on function public.fn_emit_conversation_routing() from public,anon,authenticated; -- Sem lock de conversa/advisory: este helper só agenda eventos, nunca atribui. create or replace function public.fn_wake_channel_routing(p_org uuid,p_channel uuid default null) returns void language plpgsql security definer set search_path=public as $$ declare cid uuid; begin for cid in select id from public.conversations where organization_id=p_org and (p_channel is null or channel_session_id=p_channel) and assigned_to_user_id is null and status in('open','pending','claimed','ai_handling') order by id loop perform public.fn_request_channel_routing(p_org,cid);end loop; end; $$; revoke all on function public.fn_wake_channel_routing(uuid,uuid) from public,anon,authenticated; grant execute on function public.fn_wake_channel_routing(uuid,uuid) to service_role; create or replace function public.fn_set_channel_routing(p_org uuid,p_channel uuid,p_users uuid[],p_reset boolean default false) returns jsonb language plpgsql security definer set search_path=public as $$ declare v_policy_id uuid; requested_count integer; found_count integer; begin if auth.uid() is null or not public.fn_role_at_least(p_org,'manager') or not public.fn_support_write_allowed(p_org) then raise exception 'routing_forbidden' using errcode='42501';end if; if not public.fn_session_mfa_proven() then raise exception 'routing_mfa_required' using errcode='42501';end if; if p_users is null or cardinality(p_users)>1000 or array_position(p_users,null) is not null then raise exception 'routing_invalid_members' using errcode='22023';end if; -- Leitores de claim usam SHARE nesta identidade, inclusive na ausência de policy. perform 1 from public.channel_sessions where organization_id=p_org and id=p_channel and archived_at is null for update; if not found then raise exception 'routing_channel_not_found' using errcode='P0002';end if; if p_reset then delete from public.channel_routing_policies where organization_id=p_org and channel_session_id=p_channel; else select count(distinct x) into requested_count from unnest(p_users) x; perform 1 from public.user_organizations where organization_id=p_org and user_id=any(p_users) and revoked_at is null and role in('agent','manager','admin') order by user_id for share; get diagnostics found_count=row_count; if found_count<>requested_count then raise exception 'routing_invalid_members' using errcode='22023';end if; insert into public.channel_routing_policies(organization_id,channel_session_id) values(p_org,p_channel) on conflict(organization_id,channel_session_id) do update set updated_at=now() returning id into v_policy_id; delete from public.channel_routing_responsibles where organization_id=p_org and channel_routing_responsibles.policy_id=v_policy_id; insert into public.channel_routing_responsibles(organization_id,policy_id,user_id) select p_org,v_policy_id,x from(select distinct unnest(p_users) x) users; end if; perform public.fn_wake_channel_routing(p_org,p_channel); return jsonb_build_object('channel_session_id',p_channel,'policy_id',v_policy_id, 'mode',case when p_reset then 'legacy_unconfigured' when cardinality(p_users)=0 then 'restricted_empty' else 'restricted' end, 'user_ids',case when p_reset then '[]'::jsonb else to_jsonb(p_users) end); end; $$; revoke all on function public.fn_set_channel_routing(uuid,uuid,uuid[],boolean) from public,anon; grant execute on function public.fn_set_channel_routing(uuid,uuid,uuid[],boolean) to authenticated; -- Revogação é UPDATE, não DELETE: cascade sozinho não remove elegibilidade, -- nem desatribui conversas abertas (#1562). create or replace function public.fn_routing_member_revoked() returns trigger language plpgsql security definer set search_path=public as $$ declare v_conv record; begin if new.revoked_at is not null or new.role not in('agent','manager','admin') then delete from public.channel_routing_responsibles where organization_id=new.organization_id and user_id=new.user_id; for v_conv in select id from public.conversations where organization_id=new.organization_id and assigned_to_user_id=new.user_id and status in('open','pending','claimed','ai_handling') order by id loop update public.conversations set assigned_to_user_id=null, assigned_to_user_name=null, assigned_at=null, assignee_kind=null, status='open', status_changed_at=now(), unread_count_for_assignee=0, -- Mesma regra do release de fn_conversation_assign: a conversa que a IA -- passou a um humano (last_handoff_at) continua com a IA calada. bot_silenced_until=case when last_handoff_at is null then null else bot_silenced_until end, updated_at=now() where id=v_conv.id; insert into public.conversation_assignment_events (organization_id,conversation_id,from_user_id,to_user_id,changed_by,reason) values (new.organization_id,v_conv.id,new.user_id,null,auth.uid(),'member_revoked'); end loop; end if; perform public.fn_wake_channel_routing(new.organization_id); return new; end; $$; revoke all on function public.fn_routing_member_revoked() from public,anon,authenticated; drop trigger if exists trg_routing_member_revoked on public.user_organizations; create trigger trg_routing_member_revoked after update of revoked_at,role on public.user_organizations for each row when(old.revoked_at is distinct from new.revoked_at or old.role is distinct from new.role) execute function public.fn_routing_member_revoked(); create or replace function public.fn_routing_availability_changed() returns trigger language plpgsql security definer set search_path=public as $$ begin perform public.fn_wake_channel_routing(new.organization_id);return new;end; $$; revoke all on function public.fn_routing_availability_changed() from public,anon,authenticated; drop trigger if exists trg_routing_availability_changed on public.attendant_availability; create trigger trg_routing_availability_changed after insert or update of is_available,capacity,schedule on public.attendant_availability for each row execute function public.fn_routing_availability_changed(); -- Um aviso por conversa, com histórico preservado entre acknowledge e resolução. create unique index if not exists agent_inbox_routing_unique on public.agent_inbox_items(organization_id,ref_id,kind) where kind='routing_unassigned'; create or replace function public.fn_routing_unassigned_notice(p_org uuid,p_conversation uuid,p_reason text) returns void language plpgsql security definer set search_path=public as $$ begin if not exists(select 1 from public.conversations where organization_id=p_org and id=p_conversation and assigned_to_user_id is null and status in('open','pending','claimed','ai_handling')) then return;end if; insert into public.agent_inbox_items(organization_id,kind,severity,title,body,ref_kind,ref_id) values(p_org,'routing_unassigned','warn','Uma conversa aguarda um responsável', case when p_reason='invalid_channel' then 'Confira o canal de origem desta conversa nas Conexões.' else 'Confira os responsáveis do canal em Configurações → Atendimento e a disponibilidade da equipe. A distribuição continuará tentando.' end, 'conversation',p_conversation) on conflict(organization_id,ref_id,kind) where kind='routing_unassigned' do update set status='open',body=excluded.body; end; $$; revoke all on function public.fn_routing_unassigned_notice(uuid,uuid,text) from public,anon,authenticated; grant execute on function public.fn_routing_unassigned_notice(uuid,uuid,text) to service_role; create or replace function public.fn_routing_assignment_changed() returns trigger language plpgsql security definer set search_path=public as $$ begin if new.assigned_to_user_id is not null or new.status not in('open','pending','claimed','ai_handling') then update public.agent_inbox_items set status='resolved' where organization_id=new.organization_id and kind='routing_unassigned' and ref_id=new.id and status<>'resolved'; elsif old.assigned_to_user_id is not null or old.status not in('open','pending','claimed','ai_handling') then perform public.fn_request_channel_routing(new.organization_id,new.id); end if; return new; end; $$; revoke all on function public.fn_routing_assignment_changed() from public,anon,authenticated; drop trigger if exists trg_routing_assignment_changed on public.conversations; create trigger trg_routing_assignment_changed after update of assigned_to_user_id,status on public.conversations for each row execute function public.fn_routing_assignment_changed(); -- Mesma porta pública e contrato; compatível com KEY SHARE das FKs inbound. CREATE OR REPLACE FUNCTION public.fn_conversation_assign(p_organization_id uuid, p_conversation_id uuid, p_to_user_id uuid, p_reason text, p_expected_assignee uuid DEFAULT NULL::uuid, p_enforce_expected boolean DEFAULT false) RETURNS SETOF conversations LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public' AS $function$ declare v_from uuid; v_conv public.conversations%rowtype; begin if not public.fn_support_write_allowed(p_organization_id) then raise exception 'support_readonly' using errcode='42501'; end if; if auth.uid() is not null and not public.fn_role_at_least(p_organization_id, 'agent') then raise exception 'caller_not_authorized_for_org' using hint = 'caller must be an active agent+ member of the organization'; end if; if p_to_user_id is not null then if coalesce(public.fn_member_role_in_org(p_to_user_id, p_organization_id), 'none') not in ('agent','manager','admin') then raise exception 'assignee_not_eligible_member' using hint = 'target must be an active agent+ member of the organization'; end if; end if; select assigned_to_user_id into v_from from public.conversations where id = p_conversation_id and organization_id = p_organization_id for no key update; if not found then return; end if; if p_enforce_expected and v_from is distinct from p_expected_assignee then return; end if; update public.conversations set assigned_to_user_id = p_to_user_id, -- Desnormalizado JUNTO com o dono, na mesma transação: nunca existe -- uma janela em que id e nome discordam. NULL junto com o id quando -- a atribuição é removida (release) — nunca sobra um nome órfão de -- dono nenhum. Lido de auth.users porque quem chama esta função -- (RPC) não necessariamente tem acesso ao Admin API — a definer -- resolve por dentro. assigned_to_user_name = case when p_to_user_id is null then null else (select raw_user_meta_data ->> 'full_name' from auth.users where id = p_to_user_id) end, assigned_at = case when p_to_user_id is null then null else now() end, assignee_kind = case when p_to_user_id is null then null else 'user' end, status = case when p_to_user_id is null then 'open' else 'claimed' end, status_changed_at = now(), unread_count_for_assignee = 0, bot_silenced_until = case when p_reason = 'routing' then bot_silenced_until when p_to_user_id is null then (case when last_handoff_at is null then null else bot_silenced_until end) else 'infinity'::timestamptz end, updated_at = now() where id = p_conversation_id returning * into v_conv; insert into public.conversation_assignment_events (organization_id, conversation_id, from_user_id, to_user_id, changed_by, reason) values (p_organization_id, p_conversation_id, v_from, p_to_user_id, auth.uid(), p_reason); return next v_conv; end; $function$; create or replace function public.fn_channel_routing_claim(p_org uuid,p_conversation uuid,p_channel uuid,p_user uuid,p_schedule jsonb default null,p_reason text default 'routing') returns text language plpgsql security definer set search_path=public as $$ declare c public.conversations; pre_contact uuid; member_id uuid; v_policy_id uuid; availability public.attendant_availability; current_load integer; begin if p_reason not in('routing','handoff') then raise exception 'routing_reason_invalid' using errcode='22023';end if; select contact_id into pre_contact from public.conversations where organization_id=p_org and id=p_conversation; if not found then return 'conversation_changed';end if; perform public.fn_service_lock(p_org,pre_contact); perform pg_advisory_xact_lock(hashtextextended(p_org::text||':'||p_user::text,228)); -- Task9: o trigger de status do canal toca conversas; ordem comum channel -> conversation. perform 1 from public.channel_sessions where organization_id=p_org and id=p_channel for share; if not found then return 'conversation_changed';end if; select * into c from public.conversations where organization_id=p_org and id=p_conversation for no key update; if not found or c.contact_id is distinct from pre_contact or c.channel_session_id is distinct from p_channel or c.status not in('open','pending','claimed','ai_handling') then return 'conversation_changed';end if; if c.assigned_to_user_id is not null then return 'already_assigned';end if; select id into member_id from public.user_organizations where organization_id=p_org and user_id=p_user and revoked_at is null and role in('agent','manager','admin') for share; if not found then return 'candidate_revoked';end if; select id into v_policy_id from public.channel_routing_policies where organization_id=p_org and channel_session_id=p_channel; if found and not exists(select 1 from public.channel_routing_responsibles r where r.organization_id=p_org and r.policy_id=v_policy_id and r.user_id=p_user) then return 'candidate_not_allowed';end if; select * into availability from public.attendant_availability where organization_id=p_org and user_id=p_user for share; if not found or not availability.is_available or (p_schedule is not null and availability.schedule is distinct from p_schedule) then return 'capacity_changed';end if; select count(*) into current_load from public.conversations where organization_id=p_org and assigned_to_user_id=p_user and status in('open','pending','claimed','ai_handling'); if current_load>=availability.capacity then return 'capacity_changed';end if; perform public.fn_conversation_assign(p_org,p_conversation,p_user,p_reason,null,true); return 'assigned'; end; $$; revoke all on function public.fn_channel_routing_claim(uuid,uuid,uuid,uuid,jsonb,text) from public,anon,authenticated; grant execute on function public.fn_channel_routing_claim(uuid,uuid,uuid,uuid,jsonb,text) to service_role; -- Recibo privado: fatos da operação e lease; a representação do canal continua -- em channel_sessions. TTL vale para replay concluído, nunca apaga reparo pendente. create table if not exists public.channel_connection_requests ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, idempotency_key uuid not null, request_hash text not null, channel_session_id uuid, state text not null default 'processing' check(state in('processing','succeeded','failed')), lease_token uuid not null default gen_random_uuid(), lease_until timestamptz not null default now()+interval '5 minutes', remote_created boolean not null default false, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique(organization_id,idempotency_key), foreign key(organization_id,channel_session_id) references public.channel_sessions(organization_id,id) on delete set null(channel_session_id) ); alter table public.channel_connection_requests enable row level security; revoke all on public.channel_connection_requests from public,anon,authenticated,service_role; grant select on public.channel_connection_requests to service_role; -- Sem policy authenticated: contém lease de execução, não é uma tabela de UI. create or replace function public.fn_reserve_channel_connection(p_org uuid,p_key uuid,p_hash text,p_display_name text default null,p_onboarding boolean default false) returns jsonb language plpgsql security definer set search_path=public as $$ declare receipt public.channel_connection_requests; channel public.channel_sessions; token uuid:=gen_random_uuid(); begin if auth.uid() is null or not public.fn_role_at_least(p_org,'admin') or not public.fn_support_write_allowed(p_org) then raise exception 'connection_forbidden' using errcode='42501';end if; if not public.fn_session_mfa_proven() then raise exception 'connection_mfa_required' using errcode='42501';end if; if p_key is null or p_hash is null or length(p_hash)<>64 or length(coalesce(p_display_name,''))>100 then raise exception 'connection_invalid_request' using errcode='22023';end if; perform pg_advisory_xact_lock(hashtextextended(p_org::text,2281)); delete from public.channel_connection_requests where organization_id=p_org and idempotency_key=p_key and state='succeeded' and updated_atp_hash then raise exception 'idempotency_conflict' using errcode='22023';end if; if receipt.state='succeeded' then select * into channel from public.channel_sessions where organization_id=p_org and id=receipt.channel_session_id; return jsonb_build_object('replay',true,'channel',to_jsonb(channel),'receipt_id',receipt.id); end if; if receipt.state='processing' and receipt.lease_until>now() then raise exception 'connection_in_progress' using errcode='55P03';end if; select * into channel from public.channel_sessions where organization_id=p_org and id=receipt.channel_session_id for update; if not found then raise exception 'connection_reservation_missing' using errcode='P0002';end if; else if p_onboarding then select * into channel from public.channel_sessions where organization_id=p_org and provider='waha' and (metadata->>'onboarding'='true' or waha_session_name='org_'||left(p_org::text,8)) order by created_at limit 1 for update; end if; if channel.id is null then insert into public.channel_sessions(organization_id,waha_session_name,display_name,engine,webhook_path_token, webhook_secret_encrypted,status,last_status_change_at,consecutive_health_fails,daily_message_limit,metadata) values(p_org,'org_'||replace(p_org::text,'-','')||'_'||replace(gen_random_uuid()::text,'-',''),p_display_name,'NOWEB', replace(gen_random_uuid()::text,'-',''),'\x00'::bytea,'STARTING',now(),0,250, case when p_onboarding then '{"onboarding":true}'::jsonb else '{}'::jsonb end) returning * into channel; end if; if exists(select 1 from public.channel_connection_requests where organization_id=p_org and channel_session_id=channel.id and (state='processing' and lease_until>now())) then raise exception 'connection_in_progress' using errcode='55P03';end if; insert into public.channel_connection_requests(organization_id,idempotency_key,request_hash,channel_session_id) values(p_org,p_key,p_hash,channel.id) returning * into receipt; end if; if exists(select 1 from public.channel_connection_requests where organization_id=p_org and channel_session_id=channel.id and id<>receipt.id and (state='processing' and lease_until>now())) then raise exception 'connection_in_progress' using errcode='55P03';end if; update public.channel_connection_requests set state='processing',lease_token=token,lease_until=now()+interval '5 minutes', remote_created=false,updated_at=now() where organization_id=p_org and id=receipt.id; -- Não ressuscita antes da pós-condição remota. Arquivado permanece invisível -- até finish; falha conserva identidade e estado FAILED para reparo. update public.channel_sessions set status='STARTING',status_reason='connection_pending',last_status_change_at=now() where organization_id=p_org and id=channel.id returning * into channel; return jsonb_build_object('replay',false,'channel',to_jsonb(channel),'receipt_id',receipt.id,'lease_token',token); end; $$; revoke all on function public.fn_reserve_channel_connection(uuid,uuid,text,text,boolean) from public,anon; grant execute on function public.fn_reserve_channel_connection(uuid,uuid,text,text,boolean) to authenticated; create or replace function public.fn_finish_channel_connection(p_org uuid,p_receipt uuid,p_lease uuid,p_status text,p_reason text default null,p_created boolean default false) returns jsonb language plpgsql security definer set search_path=public as $$ declare receipt public.channel_connection_requests; channel public.channel_sessions; begin select * into receipt from public.channel_connection_requests where organization_id=p_org and id=p_receipt for update; if not found or receipt.state<>'processing' or receipt.lease_token<>p_lease or receipt.lease_until<=now() then raise exception 'connection_lease_lost' using errcode='55P03';end if; if p_status='remote_created' then update public.channel_connection_requests set remote_created=true,updated_at=now() where organization_id=p_org and id=p_receipt; return '{}'::jsonb; end if; if p_status not in('STARTING','SCAN_QR_CODE','WORKING','FAILED') then raise exception 'connection_invalid_status' using errcode='22023';end if; update public.channel_sessions set status=p_status,status_reason=left(p_reason,200),last_status_change_at=now(), consecutive_health_fails=case when p_status='FAILED' then consecutive_health_fails else 0 end, archived_at=case when p_status<>'FAILED' then null else archived_at end, phone_number=case when archived_at is not null and p_status<>'FAILED' then null else phone_number end where organization_id=p_org and id=receipt.channel_session_id returning * into channel; if not found then raise exception 'connection_reservation_missing' using errcode='P0002';end if; update public.channel_connection_requests set state=case when p_status='FAILED' then 'failed' else 'succeeded' end, remote_created=remote_created or p_created,updated_at=now() where organization_id=p_org and id=p_receipt; return to_jsonb(channel); end; $$; revoke all on function public.fn_finish_channel_connection(uuid,uuid,uuid,text,text,boolean) from public,anon,authenticated; grant execute on function public.fn_finish_channel_connection(uuid,uuid,uuid,text,text,boolean) to service_role; -- ---- MFA e LGPD da agenda (migration 0229) ---- -- 0229 — MFA das ações humanas e ordem LGPD/agenda. -- Forward independente de 0227/0228; helpers privados de 0222/0226 preservados. create or replace function public.fn_appointment_change_core(p_org uuid,p_id uuid,p_revision bigint,p_patch jsonb,p_remote boolean,p_base jsonb) returns jsonb language plpgsql security definer set search_path=public as $$ declare a public.calendar_appointments; contact uuid; origin jsonb; event_id uuid; begin if p_remote and (auth.uid() is not null or (p_patch-'starts_at'-'ends_at'-'time_zone'-'status'-'cancellation_reason')<>'{}'::jsonb or coalesce(p_patch->>'status','cancelled')<>'cancelled') then raise exception 'google_patch_forbidden' using errcode='42501';end if; if auth.uid() is not null and (not public.fn_role_at_least(p_org,'agent') or not public.fn_support_write_allowed(p_org)) then raise exception 'appointment_forbidden' using errcode='42501'; end if; if auth.uid() is not null and not public.fn_session_mfa_proven() then raise exception 'appointment_mfa_required' using errcode='42501';end if; select contact_id into contact from public.calendar_appointments where organization_id=p_org and id=p_id; if not found then raise exception 'appointment_not_found' using errcode='P0002'; end if; if contact is not null then perform public.fn_service_lock(p_org,contact); end if; select * into a from public.calendar_appointments where organization_id=p_org and id=p_id for update; if a.contact_id is distinct from contact or a.revision is distinct from p_revision then raise exception 'appointment_stale' using errcode='40001'; end if; if p_remote and a.status not in ('pending','confirmed') then raise exception 'google_outcome_protected' using errcode='40001';end if; if a.status='cancelled' then raise exception 'appointment_cancelled' using errcode='22023'; end if; if contact is not null then origin:=jsonb_build_object('kind','command','observed',public.fn_service_observe_command(p_org,contact)); end if; update public.calendar_appointments set google_base_projection=case when p_remote then p_base else google_base_projection end, starts_at=case when p_patch?'starts_at' then (p_patch->>'starts_at')::timestamptz else starts_at end, ends_at=case when p_patch?'ends_at' then (p_patch->>'ends_at')::timestamptz else ends_at end, time_zone=coalesce(p_patch->>'time_zone',time_zone), status=coalesce(p_patch->>'status',status), cancelled_at=case when p_patch->>'status'='cancelled' then now() else cancelled_at end, cancellation_reason=case when p_patch?'cancellation_reason' then p_patch->>'cancellation_reason' else cancellation_reason end, notes=case when p_patch?'notes' then p_patch->>'notes' else notes end, guest_email=case when p_patch?'guest_email' then p_patch->>'guest_email' else guest_email end, outcome_message_id=case when p_patch?'outcome_message_id' then (p_patch->>'outcome_message_id')::uuid else null end, confirmation_next_at=case when p_patch?'confirmation_next_at' then (p_patch->>'confirmation_next_at')::timestamptz else confirmation_next_at end where organization_id=p_org and id=p_id returning * into a; if p_patch?'confirmation_next_at' and (a.confirmation_next_at<=now() or a.confirmation_next_at>now()+interval '24 hours') then raise exception 'appointment_invalid_snooze' using errcode='22023'; end if; update public.followup_enrollments set status='cancelled',cancel_reason='O compromisso mudou. Revise o próximo passo.',completed_at=now(),next_eval_at=null,claimed_until=null where organization_id=p_org and appointment_id=p_id and appointment_revision<>a.revision and status in ('active','waiting_reply','paused_handoff','paused_manual'); update public.agent_inbox_items set status='resolved',resolved_at=now() where organization_id=p_org and ref_kind='appointment' and ref_id=p_id and status='open' and (appointment_revision<>a.revision or a.status in ('completed','no_show','cancelled') or p_patch?'confirmation_next_at'); if contact is not null and a.status='no_show' and a.outcome_recorded_at is not null and a.revision<>p_revision then insert into public.event_log(organization_id,event_type,entity_kind,entity_id,payload) values(p_org,'appointment.outcome_confirmed','appointment',p_id, jsonb_build_object('appointment_revision',a.revision,'service_origin',origin)) returning id into event_id; end if; return to_jsonb(a); end; $$; create or replace function public.fn_agenda_settings(p_org uuid,p_config jsonb) returns jsonb language plpgsql security definer set search_path=public as $$ begin if auth.uid() is null or not public.fn_role_at_least(p_org,'manager') or not public.fn_support_write_allowed(p_org) then raise exception 'agenda_settings_forbidden' using errcode='42501'; end if; if not public.fn_session_mfa_proven() then raise exception 'agenda_mfa_required' using errcode='42501';end if; if jsonb_typeof(p_config->'confirmation_delay_minutes') is distinct from 'number' or jsonb_typeof(p_config->'unknown_protection_minutes') is distinct from 'number' or (p_config-'confirmation_delay_minutes'-'unknown_protection_minutes')<>'{}'::jsonb or (p_config->>'confirmation_delay_minutes' ~ '^[0-9]{1,5}$') is not true or (p_config->>'unknown_protection_minutes' ~ '^[0-9]{1,5}$') is not true or (p_config->>'confirmation_delay_minutes')::int not between 1 and 10080 or (p_config->>'unknown_protection_minutes')::int not between 1 and 10080 or (p_config->>'unknown_protection_minutes')::int < (p_config->>'confirmation_delay_minutes')::int then raise exception 'agenda_settings_invalid' using errcode='22023'; end if; update public.organizations set settings=jsonb_set(coalesce(settings,'{}'::jsonb),'{agenda}',p_config,true) where id=p_org; if not found then raise exception 'organization_not_found' using errcode='P0002'; end if; return p_config; end; $$; create or replace function public.fn_google_selection(p_org uuid,p_revisions jsonb,p_sources uuid[],p_destination uuid) returns void language plpgsql security definer set search_path=public as $$ declare actor uuid:=auth.uid(); expected jsonb; actual jsonb; begin if actor is null or not public.fn_role_at_least(p_org,'agent') or not public.fn_support_write_allowed(p_org) then raise exception 'google_selection_forbidden' using errcode='42501';end if; if not public.fn_session_mfa_proven() then raise exception 'google_mfa_required' using errcode='42501';end if; perform 1 from public.user_organizations where organization_id=p_org and user_id=actor and revoked_at is null for update; if not found then raise exception 'google_owner_unavailable' using errcode='42501';end if; select jsonb_agg(value order by value->>'connection_id') into expected from jsonb_array_elements(p_revisions); select jsonb_agg(jsonb_build_object('connection_id',id,'revision',calendar_selection_revision::text) order by id::text) into actual from public.calendar_connections where organization_id=p_org and user_id=actor and provider='google_calendar'; if actual is distinct from expected then raise exception 'google_selection_stale' using errcode='40001';end if; if not exists(select 1 from public.calendar_connection_calendars k join public.calendar_connections c on c.id=k.connection_id and c.organization_id=k.organization_id where k.organization_id=p_org and k.id=p_destination and c.user_id=actor and c.status='healthy' and k.available and k.access_role in ('owner','writer')) then raise exception 'google_destination_unavailable' using errcode='42501';end if; if exists(select 1 from unnest(p_sources) selected(id) where not exists(select 1 from public.calendar_connection_calendars k join public.calendar_connections c on c.id=k.connection_id and c.organization_id=k.organization_id where k.organization_id=p_org and k.id=selected.id and c.user_id=actor and c.status='healthy' and k.available and k.access_role in ('owner','writer','reader','writerWithoutPrivateAccess'))) then raise exception 'google_source_unavailable' using errcode='42501';end if; update public.calendar_connection_calendars k set is_destination=false from public.calendar_connections c where k.organization_id=p_org and c.organization_id=p_org and k.connection_id=c.id and c.user_id=actor; update public.calendar_connection_calendars k set is_destination=k.id=p_destination,counts_for_conflicts=k.id=any(p_sources),sync_next_attempt_at=now() from public.calendar_connections c where k.organization_id=p_org and c.organization_id=p_org and k.connection_id=c.id and c.user_id=actor; update public.calendar_connections set calendar_selection_revision=calendar_selection_revision+1 where organization_id=p_org and user_id=actor and provider='google_calendar'; end;$$; create or replace function public.fn_google_resolve(p_org uuid,p_id uuid,p_revision text,p_local_revision text,p_etag text,p_choice text) returns void language plpgsql security definer set search_path=public as $$ declare a public.calendar_appointments; contact uuid; begin if auth.uid() is null or not public.fn_role_at_least(p_org,'agent') or not public.fn_support_write_allowed(p_org) then raise exception 'google_resolution_forbidden' using errcode='42501';end if; if not public.fn_session_mfa_proven() then raise exception 'google_mfa_required' using errcode='42501';end if; select contact_id into contact from public.calendar_appointments where organization_id=p_org and id=p_id; if contact is not null then perform public.fn_service_lock(p_org,contact);end if; select * into a from public.calendar_appointments where organization_id=p_org and id=p_id for update; if not found or a.owner_user_id is distinct from auth.uid() then raise exception 'google_resolution_forbidden' using errcode='42501';end if; if a.revision::text is distinct from p_revision or a.google_local_revision::text is distinct from p_local_revision or a.google_etag is distinct from p_etag then raise exception 'google_stale' using errcode='40001';end if; if p_choice='retry' then if a.google_conflict is not null then raise exception 'google_conflict_requires_choice' using errcode='40001';end if; update public.calendar_appointments set google_next_attempt_at=now() where organization_id=p_org and id=p_id; else if p_choice not in ('google','local','preserve_remote') or a.google_conflict is null then raise exception 'google_choice_invalid' using errcode='22023';end if; -- O trigger reconhece somente esta forma autenticada: o corpo da comparação -- e as revisões não mudam, actor_id é auth.uid(), não input do browser. update public.calendar_appointments set google_conflict=google_conflict||jsonb_build_object('resolution',jsonb_build_object('choice',p_choice,'actor_id',auth.uid())),google_next_attempt_at=now() where organization_id=p_org and id=p_id; end if; end;$$; -- Assinaturas e concessões das portas existentes não mudam. revoke all on function public.fn_appointment_change_core(uuid,uuid,bigint,jsonb,boolean,jsonb) from public,anon,authenticated; revoke all on function public.fn_agenda_settings(uuid,jsonb) from public,anon,authenticated; grant execute on function public.fn_agenda_settings(uuid,jsonb) to authenticated; revoke all on function public.fn_google_selection(uuid,jsonb,uuid[],uuid) from public,anon; grant execute on function public.fn_google_selection(uuid,jsonb,uuid[],uuid) to authenticated; revoke all on function public.fn_google_resolve(uuid,uuid,text,text,text,text) from public,anon; grant execute on function public.fn_google_resolve(uuid,uuid,text,text,text,text) to authenticated; create or replace function public.fn_meet_redact_contact() returns trigger language plpgsql security definer set search_path=public as $$ begin perform public.fn_service_lock(new.organization_id,new.id); update public.job_queue set payload='{}',status=case when status in ('pending','running') then 'failed' else status end, locked_by=null,locked_at=null,last_error='meet_contact_redacted' where organization_id=new.organization_id and contact_id=new.id and kind='transactional_delivery'; update public.agent_inbox_items set status='resolved',resolved_at=now(),body='Contato anonimizado.',ref_id=null where organization_id=new.organization_id and ref_kind='appointment' and ref_id in(select id from public.calendar_appointments where organization_id=new.organization_id and contact_id=new.id) and kind in ('other','appointment_outcome_required','appointment_recovery_review'); update public.calendar_appointments set meeting_url=null,meeting_request_id=null,meeting_requested_at=null,meeting_received_at=null,meeting_last_error=null, meeting_next_attempt_at=null,meeting_delivery='{"state":"blocked"}',meeting_delivery_job_id=null where organization_id=new.organization_id and contact_id=new.id; return new; end;$$; revoke all on function public.fn_meet_redact_contact() from public,anon,authenticated; create or replace function public.fn_appointment_recover(p_org uuid,p_event uuid) returns jsonb language plpgsql security definer set search_path=public as $$ declare e public.event_log; a public.calendar_appointments; r public.appointment_recovery_receipts; contact uuid; rev bigint; result text; candidates uuid[]; pointer uuid; agent uuid; version uuid; node text; boundary jsonb; enrollment uuid; begin select * into e from public.event_log where organization_id=p_org and id=p_event and event_type='appointment.outcome_confirmed' and entity_kind='appointment'; if not found then raise exception 'appointment_source_event_missing' using errcode='P0002'; end if; rev:=(e.payload->>'appointment_revision')::bigint; select contact_id into contact from public.calendar_appointments where organization_id=p_org and id=e.entity_id; if not found then raise exception 'appointment_not_found' using errcode='P0002'; end if; if contact is not null then perform public.fn_service_lock(p_org,contact); end if; select * into a from public.calendar_appointments where organization_id=p_org and id=e.entity_id for update; if a.contact_id is distinct from contact then raise exception 'appointment_stale' using errcode='40001'; end if; select * into r from public.appointment_recovery_receipts where organization_id=p_org and appointment_id=a.id and appointment_revision=rev; if found then return to_jsonb(r); end if; result:=case when contact is null then 'no_contact' when a.revision<>rev or a.status<>'no_show' or a.outcome_recorded_at is null or not exists(select 1 from public.contacts where organization_id=p_org and id=contact and not is_anonymized and is_merged_into is null and not is_blocked) then 'stale' else null end; if result is null then select array_agg(p.id) into candidates from public.followup_flow_pointers p where p.organization_id=p_org and p.status='active' and p.active_version_id is not null and p.trigger_config->>'kind'='appointment_no_show' and (coalesce(jsonb_array_length(p.trigger_config->'params'->'event_type_ids'),0)=0 or p.trigger_config->'params'->'event_type_ids' ? a.event_type_id::text) and exists(select 1 from public.ai_agent_versions v where v.organization_id=p_org and v.status='published' and v.followup->'enabled'='true'::jsonb and v.followup->'flow_pointer_ids' ? p.id::text); result:=case when coalesce(cardinality(candidates),0)=0 then 'not_configured' when cardinality(candidates)>1 then 'ambiguous' else null end; end if; if result is null and exists(select 1 from public.followup_enrollments where organization_id=p_org and contact_id=contact and status in ('active','waiting_reply','paused_handoff','paused_manual')) then result:='other_flow'; end if; if result is null then pointer:=candidates[1]; select active_version_id into version from public.followup_flow_pointers where organization_id=p_org and id=pointer and status='active' for share; -- Precedência de AGENTES já canônica em resolveAgentForAutomaticTrigger. select agent_id into agent from public.ai_agent_versions where organization_id=p_org and status='published' and followup->'enabled'='true'::jsonb and followup->'flow_pointer_ids' ? pointer::text order by agent_id limit 1; select n->>'id' into node from public.followup_flow_versions v cross join lateral jsonb_array_elements(v.graph->'nodes') n where v.organization_id=p_org and v.id=version and n->>'type'='trigger'; if version is null or agent is null or node is null then raise exception 'appointment_flow_changed' using errcode='40001'; end if; begin boundary:=public.fn_service_event_origin(p_org,p_event,contact, (select channel_session_id from public.conversations where organization_id=p_org and id=a.conversation_id and contact_id=contact)); exception when serialization_failure then result:='stale'; end; if result is null then begin insert into public.followup_enrollments(organization_id,pointer_id,version_id,contact_id,conversation_id,agent_id,current_node_id,service_boundary, appointment_id,appointment_revision) values(p_org,pointer,version,contact,(boundary->>'conversation_id')::uuid,agent,node,boundary,a.id,a.revision) returning id into enrollment; insert into public.followup_enrollment_events(organization_id,enrollment_id,node_id,event_type,payload,idempotency_key) values(p_org,enrollment,node,'enrolled',jsonb_build_object('trigger_kind','appointment_no_show','appointment_id',a.id,'appointment_revision',a.revision),'appointment:'||a.id||':'||a.revision); result:='started'; exception when unique_violation then result:='other_flow'; end; end if; end if; insert into public.appointment_recovery_receipts(organization_id,appointment_id,appointment_revision,source_event_id,result,pointer_id,enrollment_id) values(p_org,a.id,rev,p_event,result,pointer,enrollment) returning * into r; if result<>'started' and not exists(select 1 from public.contacts where organization_id=p_org and id=contact and is_anonymized) then insert into public.agent_inbox_items(organization_id,kind,severity,title,body,ref_kind,ref_id,appointment_revision) values(p_org,'appointment_recovery_review','warn','A recuperação não foi iniciada', case result when 'other_flow' then 'Este contato já tem outro acompanhamento. Revise o próximo passo; nenhuma recuperação ficou aguardando vaga.' when 'ambiguous' then 'Mais de um fluxo atende a esta falta. Deixe apenas um configurado ou escolha manualmente o próximo passo.' when 'not_configured' then 'Configure um fluxo de recuperação e habilite-o em um assistente publicado. Esta falta não será iniciada retroativamente.' else 'O contexto mudou ou não há contato vinculado. Abra o compromisso e escolha o próximo passo.' end,'appointment',a.id,rev) on conflict(organization_id,ref_id,appointment_revision,kind) where ref_kind='appointment' and appointment_revision is not null do nothing; end if; return to_jsonb(r); end; $$; revoke all on function public.fn_appointment_recover(uuid,uuid) from public,anon,authenticated; grant execute on function public.fn_appointment_recover(uuid,uuid) to service_role; create or replace function public.fn_meet_notice(p_org uuid,p_id uuid,p_reason text) returns void language plpgsql security definer set search_path=public as $$ declare contact uuid; begin select contact_id into contact from public.calendar_appointments where organization_id=p_org and id=p_id; -- Pode vir de trigger com row lock: nunca esperar por advisory tardio. if contact is not null and not pg_try_advisory_xact_lock(hashtextextended(p_org::text||':'||contact::text,222)) then raise exception 'appointment_notice_busy' using errcode='40001'; end if; insert into public.agent_inbox_items(organization_id,kind,severity,title,body,ref_kind,ref_id,appointment_revision) select p_org,'other','warn','Link da reunião precisa de atenção', 'Abra o compromisso na Agenda para verificar o link ou autorizar uma nova entrega.','appointment',id,revision from public.calendar_appointments where organization_id=p_org and id=p_id and contact_id is not distinct from contact and not exists(select 1 from public.contacts c where c.organization_id=p_org and c.id=contact and c.is_anonymized) on conflict(organization_id,ref_id,appointment_revision,kind) where ref_kind='appointment' and appointment_revision is not null do update set status='open',resolved_at=null,body=excluded.body; end;$$; revoke all on function public.fn_meet_notice(uuid,uuid,text) from public,anon,authenticated; grant execute on function public.fn_meet_notice(uuid,uuid,text) to service_role; create or replace function public.fn_appointment_confirmation_sweep(p_limit int default 100,p_now timestamptz default now()) returns int language plpgsql security definer set search_path=public as $$ declare a record; candidate record; n int:=0; expired boolean; begin -- Escolhe o mesmo lote vencido e obtém mutexes em ordem, sem row lock prévio. for candidate in select * from ( select c.id,c.organization_id,c.contact_id,c.ends_at from public.calendar_appointments c join public.organizations o on o.id=c.organization_id where c.status in ('pending','confirmed') and c.ends_at+make_interval(mins=>public.fn_agenda_minutes(o.settings,'confirmation_delay_minutes',10))<=p_now and (c.confirmation_next_at is null or c.confirmation_next_at<=p_now) and not exists(select 1 from public.contacts ct where ct.organization_id=c.organization_id and ct.id=c.contact_id and ct.is_anonymized) order by c.ends_at,c.id limit greatest(1,least(p_limit,500)) ) due order by organization_id,contact_id,id loop if candidate.contact_id is not null and not pg_try_advisory_xact_lock(hashtextextended(candidate.organization_id::text||':'||candidate.contact_id::text,222)) then continue;end if; select c.*,o.settings into a from public.calendar_appointments c join public.organizations o on o.id=c.organization_id where c.id=candidate.id and c.organization_id=candidate.organization_id and c.contact_id is not distinct from candidate.contact_id and c.status in ('pending','confirmed') and c.ends_at+make_interval(mins=>public.fn_agenda_minutes(o.settings,'confirmation_delay_minutes',10))<=p_now and (c.confirmation_next_at is null or c.confirmation_next_at<=p_now) and not exists(select 1 from public.contacts ct where ct.organization_id=c.organization_id and ct.id=c.contact_id and ct.is_anonymized) for update of c skip locked; if not found then continue;end if; expired:=a.ends_at+make_interval(mins=>public.fn_agenda_minutes(a.settings,'unknown_protection_minutes',1440))<=p_now; insert into public.agent_inbox_items(organization_id,kind,severity,title,body,ref_kind,ref_id,appointment_revision) values(a.organization_id,'appointment_outcome_required',case when expired then 'critical' else 'warn' end, case when expired then 'Presença sem confirmação há mais tempo' else 'Confirme a presença no compromisso' end, 'Compromisso: '||a.title||'. Abra e registre se a pessoa compareceu, faltou ou cancelou. O horário sozinho não confirma falta.', 'appointment',a.id,a.revision) on conflict(organization_id,ref_id,appointment_revision,kind) where ref_kind='appointment' and appointment_revision is not null do update set status='open',resolved_at=null,severity=excluded.severity,title=excluded.title; update public.calendar_appointments set confirmation_next_at=case when expired then p_now+interval '24 hours' else least(p_now+interval '24 hours',a.ends_at+make_interval(mins=>public.fn_agenda_minutes(a.settings,'unknown_protection_minutes',1440))) end where id=a.id and organization_id=a.organization_id; n:=n+1; end loop; return n; end; $$; revoke all on function public.fn_appointment_confirmation_sweep(int,timestamptz) from public,anon,authenticated; grant execute on function public.fn_appointment_confirmation_sweep(int,timestamptz) to service_role; CREATE OR REPLACE FUNCTION "public"."fn_lgpd_cascade_redact_contact"("p_organization_id" "uuid", "p_contact_id" "uuid", "p_request_id" "uuid") RETURNS "jsonb" LANGUAGE "plpgsql" SECURITY DEFINER SET "search_path" TO 'public' AS $$ declare v_already bool; v_counts jsonb := '{}'::jsonb; v_media_paths text[] := '{}'; v_anon_label text; v_count int; begin perform public.fn_service_lock(p_organization_id,p_contact_id); select is_anonymized into v_already from contacts where id = p_contact_id and organization_id = p_organization_id; if not found then raise exception 'contact not found' using errcode = 'P0002'; end if; if v_already then return jsonb_build_object('already_anonymized', true, 'counts', v_counts, 'media_paths', v_media_paths); end if; v_anon_label := 'Cliente Anonimizado #' || substring(p_contact_id::text from 1 for 8); -- Collect media storage paths (we only delete what we own — media_storage_path) select coalesce(array_agg(distinct media_storage_path) filter (where media_storage_path is not null), '{}') into v_media_paths from messages where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ); -- 1. contacts (irreversible) update contacts set name = v_anon_label, display_name = v_anon_label, email = null, -- email_normalized NÃO entra: é GENERATED ALWAYS AS (lower(trim(email))) -- e o Postgres recusa escrita nela — a linha acima já a zera por derivação. -- Com a atribuição, o cascade INTEIRO abortava e nada era anonimizado. phone_number = null, cpf_encrypted = null, cpf_hash = null, birthdate = null, is_anonymized = true, anonymized_at = now(), consent = '{}'::jsonb, source_metadata = '{}'::jsonb, tags = '{}'::text[], updated_at = now() where id = p_contact_id and organization_id = p_organization_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('contacts', v_count); -- 2. conversations metadata + preview strip update conversations set metadata = '{}'::jsonb, last_message_preview = null, updated_at = now() where contact_id = p_contact_id and organization_id = p_organization_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('conversations', v_count); -- 3. messages: redact body + null media + strip metadata (preserve status/timestamps/conversation_id) update messages set body = '[mensagem anonimizada]', media_url = null, media_mime = null, media_size_bytes = null, media_storage_path = null, metadata = '{}'::jsonb, updated_at = now() where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('messages', v_count); -- 4. crm_lead_activities — strip payload, metadata E reason (migration 0071). -- `reason` é texto livre escrito por LLM sobre a conversa do lead: supor que -- nunca conterá um nome é a suposição que falha. `evidence` NÃO é limpa — -- guarda só ids, e as linhas apontadas são redigidas por conta própria. update crm_lead_activities set payload = '{}'::jsonb, metadata = '{}'::jsonb, reason = null where organization_id = p_organization_id and ( contact_id = p_contact_id or lead_id in ( select lead_id from crm_lead_links where target_kind = 'contact' and target_id = p_contact_id and organization_id = p_organization_id ) or lead_id in ( select id from crm_leads where contact_id = p_contact_id and organization_id = p_organization_id ) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('activities', v_count); -- 5. crm_leads — strip title/description/custom_fields/source_metadata/tags but PRESERVE pipeline/stage/value update crm_leads set title = v_anon_label, description = null, custom_fields = '{}'::jsonb, source_metadata = '{}'::jsonb, tags = '{}'::text[], updated_at = now() where organization_id = p_organization_id and ( contact_id = p_contact_id or id in ( select lead_id from crm_lead_links where target_kind = 'contact' and target_id = p_contact_id and organization_id = p_organization_id ) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('leads', v_count); -- 6. orders — PRESERVE values + status + timestamps. Strip personal fields from payload jsonb -- and replace customer_external_id with null (FK-safe; soft de-link). Keep contact_id null. update orders set payload = (coalesce(payload, '{}'::jsonb)) - 'customer' - 'customer_name' - 'customer_email' - 'customer_phone' - 'shipping_address' - 'billing_address' - 'contact_identification', customer_external_id = null, contact_id = null, is_anonymized = true, updated_at = now() where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('orders', v_count); -- 7. enqueue media for async deletion (idempotent via unique (bucket, object_path)) if array_length(v_media_paths, 1) > 0 then insert into storage_redaction_queue (organization_id, request_id, bucket, object_path) select p_organization_id, p_request_id, 'whatsapp-media', path from unnest(v_media_paths) as path where path is not null and length(path) > 0 on conflict (bucket, object_path) do nothing; end if; -- 8. dense audit row insert into api_audit_log (organization_id, action, actor_user_id, resource_type, resource_id, metadata, bypassed_rls) values ( p_organization_id, 'lgpd.redact_executed', null, 'contact', p_contact_id, jsonb_build_object( 'cascaded_to', v_counts, 'media_queued', coalesce(array_length(v_media_paths, 1), 0), 'request_id', p_request_id ), true ); return jsonb_build_object( 'already_anonymized', false, 'counts', v_counts, 'media_paths', v_media_paths ); end; $$; revoke all on function public.fn_lgpd_cascade_redact_contact(uuid,uuid,uuid) from public,anon,authenticated; grant execute on function public.fn_lgpd_cascade_redact_contact(uuid,uuid,uuid) to service_role; -- UPDATE cru já detém a linha ao entrar em BEFORE ROW. Compatibilidade sem -- espera invertida: ocupado implica retry da transação inteira, inclusive true→true. create or replace function public.fn_contact_redaction_lock() returns trigger language plpgsql security definer set search_path=public as $$ begin if not pg_try_advisory_xact_lock(hashtextextended(new.organization_id::text||':'||new.id::text,222)) then raise exception 'contact_redaction_busy' using errcode='40001'; end if; return new; end;$$; revoke all on function public.fn_contact_redaction_lock() from public,anon,authenticated; drop trigger if exists trg_contact_redaction_lock on public.contacts; create trigger trg_contact_redaction_lock before update on public.contacts for each row when(new.is_anonymized is true) execute function public.fn_contact_redaction_lock(); -- Passo 1 legado: mesma autoridade humana, apenas a escrita do contato. -- A retomada de leads/atividades segue na rota e usa o timestamp retornado aqui. create or replace function public.fn_lgpd_anonymize_contact(p_organization_id uuid,p_contact_id uuid) returns jsonb language plpgsql security definer set search_path=public as $$ declare c public.contacts; support jsonb; begin support:=public.fn_support_context(); if auth.uid() is null or not public.fn_support_write_allowed(p_organization_id) or not (public.fn_role_at_least(p_organization_id,'admin') or (public.fn_is_platform_admin() and support is null)) then raise exception 'contact_anonymize_forbidden' using errcode='42501'; end if; if not public.fn_session_mfa_proven() then raise exception 'contact_anonymize_mfa_required' using errcode='42501';end if; perform public.fn_service_lock(p_organization_id,p_contact_id); select * into c from public.contacts where organization_id=p_organization_id and id=p_contact_id for update; if not found then raise exception 'contact_not_found' using errcode='P0002';end if; if c.is_anonymized then return jsonb_build_object('already_anonymized',true,'anonymized_at',c.anonymized_at);end if; update public.contacts set name=null,display_name='Contato Anonimizado #'||substring(p_contact_id::text from 1 for 8), email=null,phone_number=null,cpf_encrypted=null,cpf_hash=null,birthdate=null, is_anonymized=true,anonymized_at=now(),updated_at=now() where organization_id=p_organization_id and id=p_contact_id returning * into c; return jsonb_build_object('already_anonymized',false,'anonymized_at',c.anonymized_at); end;$$; revoke all on function public.fn_lgpd_anonymize_contact(uuid,uuid) from public,anon,authenticated,service_role; grant execute on function public.fn_lgpd_anonymize_contact(uuid,uuid) to authenticated; -- Cura apenas resíduos deste footprint em clones que já anonimizaram o contato. -- Mesma ordem de mutexes; não reescreve o contato nem a data original do direito. do $$ declare c record; begin for c in select distinct ct.organization_id,ct.id from public.contacts ct join public.calendar_appointments a on a.organization_id=ct.organization_id and a.contact_id=ct.id join public.agent_inbox_items n on n.organization_id=ct.organization_id and n.ref_kind='appointment' and n.ref_id=a.id where ct.is_anonymized and n.kind in ('other','appointment_outcome_required','appointment_recovery_review') order by ct.organization_id,ct.id loop perform public.fn_service_lock(c.organization_id,c.id); update public.agent_inbox_items set status='resolved',resolved_at=now(),body='Contato anonimizado.',ref_id=null where organization_id=c.organization_id and ref_kind='appointment' and kind in ('other','appointment_outcome_required','appointment_recovery_review') and ref_id in(select id from public.calendar_appointments where organization_id=c.organization_id and contact_id=c.id); end loop; end;$$; notify pgrst,'reload schema'; -- ---- Reserva WAHA preserva pré-go-live (migration 0230) ---- -- 0230 — reserva WAHA cria canais em pré-go-live como os demais providers. -- Forward-only: 0228 já aplicada. A única mudança funcional é metadata no INSERT -- de canal novo; retry/replay/reconexão preservam integralmente a política atual. -- Contrato compartilhado com metadataInicialDoCanal, coberto pelo censo unit e PG. create or replace function public.fn_reserve_channel_connection(p_org uuid,p_key uuid,p_hash text,p_display_name text default null,p_onboarding boolean default false) returns jsonb language plpgsql security definer set search_path=public as $$ declare receipt public.channel_connection_requests; channel public.channel_sessions; token uuid:=gen_random_uuid(); begin if auth.uid() is null or not public.fn_role_at_least(p_org,'admin') or not public.fn_support_write_allowed(p_org) then raise exception 'connection_forbidden' using errcode='42501';end if; if not public.fn_session_mfa_proven() then raise exception 'connection_mfa_required' using errcode='42501';end if; if p_key is null or p_hash is null or length(p_hash)<>64 or length(coalesce(p_display_name,''))>100 then raise exception 'connection_invalid_request' using errcode='22023';end if; perform pg_advisory_xact_lock(hashtextextended(p_org::text,2281)); delete from public.channel_connection_requests where organization_id=p_org and idempotency_key=p_key and state='succeeded' and updated_atp_hash then raise exception 'idempotency_conflict' using errcode='22023';end if; if receipt.state='succeeded' then select * into channel from public.channel_sessions where organization_id=p_org and id=receipt.channel_session_id; return jsonb_build_object('replay',true,'channel',to_jsonb(channel),'receipt_id',receipt.id); end if; if receipt.state='processing' and receipt.lease_until>now() then raise exception 'connection_in_progress' using errcode='55P03';end if; select * into channel from public.channel_sessions where organization_id=p_org and id=receipt.channel_session_id for update; if not found then raise exception 'connection_reservation_missing' using errcode='P0002';end if; else if p_onboarding then select * into channel from public.channel_sessions where organization_id=p_org and provider='waha' and (metadata->>'onboarding'='true' or waha_session_name='org_'||left(p_org::text,8)) order by created_at limit 1 for update; end if; if channel.id is null then insert into public.channel_sessions(organization_id,waha_session_name,display_name,engine,webhook_path_token, webhook_secret_encrypted,status,last_status_change_at,consecutive_health_fails,daily_message_limit,metadata) values(p_org,'org_'||replace(p_org::text,'-','')||'_'||replace(gen_random_uuid()::text,'-',''),p_display_name,'NOWEB', replace(gen_random_uuid()::text,'-',''),'\x00'::bytea,'STARTING',now(),0,250, '{"ai_gate":"allowlist","ai_gate_mode":"pre_go_live","ai_test_phone_numbers":[]}'::jsonb || case when p_onboarding then '{"onboarding":true}'::jsonb else '{}'::jsonb end) returning * into channel; end if; if exists(select 1 from public.channel_connection_requests where organization_id=p_org and channel_session_id=channel.id and (state='processing' and lease_until>now())) then raise exception 'connection_in_progress' using errcode='55P03';end if; insert into public.channel_connection_requests(organization_id,idempotency_key,request_hash,channel_session_id) values(p_org,p_key,p_hash,channel.id) returning * into receipt; end if; if exists(select 1 from public.channel_connection_requests where organization_id=p_org and channel_session_id=channel.id and id<>receipt.id and (state='processing' and lease_until>now())) then raise exception 'connection_in_progress' using errcode='55P03';end if; update public.channel_connection_requests set state='processing',lease_token=token,lease_until=now()+interval '5 minutes', remote_created=false,updated_at=now() where organization_id=p_org and id=receipt.id; -- Não ressuscita antes da pós-condição remota. Arquivado permanece invisível -- até finish; falha conserva identidade e estado FAILED para reparo. update public.channel_sessions set status='STARTING',status_reason='connection_pending',last_status_change_at=now() where organization_id=p_org and id=channel.id returning * into channel; return jsonb_build_object('replay',false,'channel',to_jsonb(channel),'receipt_id',receipt.id,'lease_token',token); end; $$; revoke all on function public.fn_reserve_channel_connection(uuid,uuid,text,text,boolean) from public,anon; grant execute on function public.fn_reserve_channel_connection(uuid,uuid,text,text,boolean) to authenticated; notify pgrst,'reload schema'; -- ---- nome de sessão WAHA cabe no teto do WAHA (migration 0233) ---- -- O `devlikeapro/waha:latest-2026.7.2` valida `name` de sessão com @MaxLength(54); -- `org_<32>_<32>` = 69 e todo `POST /api/sessions` de canal novo tomava 400. O -- prefixo da org encurta para 8 (`org_<8>_<32>` = 45), alinhado com a busca de -- canal de onboarding logo acima no corpo. Idempotente: `create or replace`. create or replace function public.fn_reserve_channel_connection(p_org uuid,p_key uuid,p_hash text,p_display_name text default null,p_onboarding boolean default false) returns jsonb language plpgsql security definer set search_path=public as $$ declare receipt public.channel_connection_requests; channel public.channel_sessions; token uuid:=gen_random_uuid(); begin if auth.uid() is null or not public.fn_role_at_least(p_org,'admin') or not public.fn_support_write_allowed(p_org) then raise exception 'connection_forbidden' using errcode='42501';end if; if not public.fn_session_mfa_proven() then raise exception 'connection_mfa_required' using errcode='42501';end if; if p_key is null or p_hash is null or length(p_hash)<>64 or length(coalesce(p_display_name,''))>100 then raise exception 'connection_invalid_request' using errcode='22023';end if; perform pg_advisory_xact_lock(hashtextextended(p_org::text,2281)); delete from public.channel_connection_requests where organization_id=p_org and idempotency_key=p_key and state='succeeded' and updated_atp_hash then raise exception 'idempotency_conflict' using errcode='22023';end if; if receipt.state='succeeded' then select * into channel from public.channel_sessions where organization_id=p_org and id=receipt.channel_session_id; return jsonb_build_object('replay',true,'channel',to_jsonb(channel),'receipt_id',receipt.id); end if; if receipt.state='processing' and receipt.lease_until>now() then raise exception 'connection_in_progress' using errcode='55P03';end if; select * into channel from public.channel_sessions where organization_id=p_org and id=receipt.channel_session_id for update; if not found then raise exception 'connection_reservation_missing' using errcode='P0002';end if; else if p_onboarding then select * into channel from public.channel_sessions where organization_id=p_org and provider='waha' and (metadata->>'onboarding'='true' or waha_session_name='org_'||left(p_org::text,8)) order by created_at limit 1 for update; end if; if channel.id is null then insert into public.channel_sessions(organization_id,waha_session_name,display_name,engine,webhook_path_token, webhook_secret_encrypted,status,last_status_change_at,consecutive_health_fails,daily_message_limit,metadata) values(p_org,'org_'||left(replace(p_org::text,'-',''),8)||'_'||replace(gen_random_uuid()::text,'-',''),p_display_name,'NOWEB', replace(gen_random_uuid()::text,'-',''),'\x00'::bytea,'STARTING',now(),0,250, '{"ai_gate":"allowlist","ai_gate_mode":"pre_go_live","ai_test_phone_numbers":[]}'::jsonb || case when p_onboarding then '{"onboarding":true}'::jsonb else '{}'::jsonb end) returning * into channel; end if; if exists(select 1 from public.channel_connection_requests where organization_id=p_org and channel_session_id=channel.id and (state='processing' and lease_until>now())) then raise exception 'connection_in_progress' using errcode='55P03';end if; insert into public.channel_connection_requests(organization_id,idempotency_key,request_hash,channel_session_id) values(p_org,p_key,p_hash,channel.id) returning * into receipt; end if; if exists(select 1 from public.channel_connection_requests where organization_id=p_org and channel_session_id=channel.id and id<>receipt.id and (state='processing' and lease_until>now())) then raise exception 'connection_in_progress' using errcode='55P03';end if; update public.channel_connection_requests set state='processing',lease_token=token,lease_until=now()+interval '5 minutes', remote_created=false,updated_at=now() where organization_id=p_org and id=receipt.id; update public.channel_sessions set status='STARTING',status_reason='connection_pending',last_status_change_at=now() where organization_id=p_org and id=channel.id returning * into channel; return jsonb_build_object('replay',false,'channel',to_jsonb(channel),'receipt_id',receipt.id,'lease_token',token); end; $$; revoke all on function public.fn_reserve_channel_connection(uuid,uuid,text,text,boolean) from public,anon; grant execute on function public.fn_reserve_channel_connection(uuid,uuid,text,text,boolean) to authenticated; -- Auto-curativo: canal WAHA com nome fora do teto que nunca pareou nem está de -- pé recebe um nome curto. Sessão que o WAHA nunca aceitou; renomear é seguro. update public.channel_sessions set waha_session_name = 'org_'||left(replace(organization_id::text,'-',''),8)||'_'||replace(gen_random_uuid()::text,'-',''), updated_at = now() where provider = 'waha' and waha_session_name is not null and length(waha_session_name) > 54 and phone_number is null and status <> 'WORKING'; -- ---- Convites de time persistidos (migration 0238) ---- -- -- Racional completo no cabeçalho da migration 0238. Em uma linha: o convite -- pendente não existia no banco (token stateless + linha só no aceite), então a -- tela de Equipe não o mostrava e REVOGAR era impossível. `team_invites` é o -- registro; o `id` da linha é o `invite_id` do token. -- -- Idempotente e auto-curativo: `if not exists` em tabela/índices, `drop ... if -- exists` antes de cada policy e do trigger; dedup antes do índice único. create table if not exists public.team_invites ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, email text not null, role text not null, interface_settings jsonb not null default '{"preset":"completa"}'::jsonb, invited_by uuid references auth.users(id) on delete set null, inviter_name text, email_dispatched boolean not null default false, created_at timestamptz not null default now(), last_sent_at timestamptz not null default now(), resend_count integer not null default 0, expires_at timestamptz not null, accepted_at timestamptz, accepted_by uuid references auth.users(id) on delete set null, revoked_at timestamptz, revoked_by uuid references auth.users(id) on delete set null, updated_at timestamptz not null default now(), constraint team_invites_role_check check (role in ('viewer','agent','manager','admin')), constraint team_invites_email_nao_vazio check (length(btrim(email)) > 0) ); -- Clone com versão antiga desta tabela: garante as colunas que vieram depois. alter table public.team_invites add column if not exists interface_settings jsonb not null default '{"preset":"completa"}'::jsonb; alter table public.team_invites add column if not exists inviter_name text; alter table public.team_invites add column if not exists email_dispatched boolean not null default false; alter table public.team_invites add column if not exists last_sent_at timestamptz not null default now(); alter table public.team_invites add column if not exists resend_count integer not null default 0; alter table public.team_invites add column if not exists accepted_by uuid references auth.users(id) on delete set null; alter table public.team_invites add column if not exists revoked_at timestamptz; alter table public.team_invites add column if not exists revoked_by uuid references auth.users(id) on delete set null; alter table public.team_invites add column if not exists updated_at timestamptz not null default now(); create index if not exists team_invites_org_created_idx on public.team_invites (organization_id, created_at desc); -- Antes do índice único: resolve dados que o violem (clone bugado) mantendo o -- pendente mais recente e revogando os demais. with ranked as ( select id, row_number() over ( partition by organization_id, lower(email) order by created_at desc, id desc ) as rn from public.team_invites where accepted_at is null and revoked_at is null ) update public.team_invites t set revoked_at = now(), updated_at = now() from ranked where t.id = ranked.id and ranked.rn > 1; create unique index if not exists team_invites_um_pendente_por_email_idx on public.team_invites (organization_id, lower(email)) where accepted_at is null and revoked_at is null; alter table public.team_invites enable row level security; drop policy if exists team_invites_select on public.team_invites; create policy team_invites_select on public.team_invites for select using ( public.fn_is_platform_admin() or ((organization_id in (select public.fn_user_org_ids())) and public.fn_role_at_least(organization_id, 'manager')) ); drop policy if exists team_invites_write on public.team_invites; create policy team_invites_write on public.team_invites using ( public.fn_is_platform_admin() or ((organization_id in (select public.fn_user_org_ids())) and public.fn_role_at_least(organization_id, 'admin')) ) with check ( public.fn_is_platform_admin() or ((organization_id in (select public.fn_user_org_ids())) and public.fn_role_at_least(organization_id, 'admin')) ); revoke all on public.team_invites from anon; grant select, insert, update, delete on public.team_invites to authenticated; grant all on public.team_invites to service_role; drop trigger if exists trg_team_invites_updated_at on public.team_invites; create trigger trg_team_invites_updated_at before update on public.team_invites for each row execute function public.fn_set_updated_at(); comment on table public.team_invites is 'Convite de time PENDENTE e seu histórico. O id da linha = invite_id do token HMAC; o aceite casa os dois e recusa convite revogado. Status é derivado, não coluna.'; notify pgrst,'reload schema'; -- ---- chamada de voz WaCalls — voice_calls (migration 0233) ---- -- -- Spec docs/specs/18-spec-voice-calls-wacalls.md. As colunas wacalls_* e as -- constraints channel_sessions_provider_check/_ref_check já foram estendidas -- no bloco ÚNICO delas, lá em cima (doutrina "uma constraint, um bloco" — -- tests/unit/baseline-constraint-reconstruida.test.ts). Aqui só a tabela nova. create table if not exists public.voice_calls ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, channel_session_id uuid not null references public.channel_sessions(id) on delete cascade, contact_id uuid references public.contacts(id) on delete set null, wacalls_call_id text not null, direction text not null check (direction in ('inbound', 'outbound')), peer_phone text not null, -- Vocabulário do UPSTREAM (cmd/server/broker.go CallStatus), passthrough -- literal. "Chamada perdida" não é status próprio lá: é end_reason numa -- chamada sem answered_at. status text not null check (status in ('starting', 'ringing', 'connected', 'ended')), -- Vocabulário do UPSTREAM (EndCallReason), sem CHECK de propósito — pode -- ganhar valor novo numa versão futura do WaCalls (doutrina DIRC, mesma -- exceção de crm_lead_activities.type). Conhecidos hoje: user_ended, -- declined, timeout, busy, cancelled, failed, do_not_disturb, unknown. end_reason text, started_at timestamptz not null default now(), answered_at timestamptz, ended_at timestamptz, duration_ms integer, created_by uuid references auth.users(id), created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique (organization_id, wacalls_call_id) ); alter table public.voice_calls add column if not exists end_reason text; alter table public.voice_calls drop constraint if exists voice_calls_status_check; alter table public.voice_calls add constraint voice_calls_status_check check (status in ('starting', 'ringing', 'connected', 'ended')); create index if not exists idx_voice_calls_org on public.voice_calls(organization_id); create index if not exists idx_voice_calls_contact on public.voice_calls(contact_id); create index if not exists idx_voice_calls_channel_session on public.voice_calls(channel_session_id); alter table public.voice_calls enable row level security; -- (a 0235, mais abaixo neste arquivo, substitui esta policy pelo par -- select/write com papel; aqui ela já nasce com o papel para o caso de o -- apêndice ser aplicado parcialmente) drop policy if exists tenant_isolation_voice_calls_all on public.voice_calls; drop policy if exists voice_calls_select on public.voice_calls; drop policy if exists voice_calls_write on public.voice_calls; create policy voice_calls_select on public.voice_calls for select using (organization_id in (select public.fn_user_org_ids())); create policy voice_calls_write on public.voice_calls for all using ( organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'agent') ) with check ( organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'agent') ); drop trigger if exists trg_voice_calls_set_updated_at on public.voice_calls; create trigger trg_voice_calls_set_updated_at before update on public.voice_calls for each row execute function public.fn_set_updated_at(); -- Realtime (forward-fix da migration 0234): sem isto o frontend nunca recebe -- o INSERT/UPDATE que o worker grava em call-status/call-ended — achado -- testando ao vivo, ligação real tocou e a tela ficou muda. do $$ begin if not exists ( select 1 from pg_publication_tables where pubname = 'supabase_realtime' and schemaname = 'public' and tablename = 'voice_calls' ) then execute 'alter publication supabase_realtime add table public.voice_calls'; end if; end $$; -- agent_inbox_items_kind_check já foi estendida com 'voice_call_missed' no -- bloco ÚNICO dela (mais acima, perto do resto do catálogo de kinds) — mesma -- doutrina, não duplicar aqui. -- ---- chamada de voz: isolamento, LGPD e dono da ligação (migration 0235) ---- -- -- Forward-fix da 0232. Tudo idempotente e auto-curativo: o `update.sh` de um -- clone que já aplicou a 0232 aplica isto por cima sem erro, e um banco novo -- recebe a versão final direto. Ver o cabeçalho da migration para o PORQUÊ de -- cada um dos seis blocos. -- ─── 1. dono da ligação ───────────────────────────────────────────────────── alter table public.voice_calls add column if not exists owner_user_id uuid references auth.users(id) on delete set null; comment on column public.voice_calls.owner_user_id is 'Quem esteve NA LINHA. Gravado pela rota de atender/iniciar e confirmado pelo campo `owner` do upstream (que é o X-Client-Id que nós mandamos, ou seja, o auth.users.id). Distinto de created_by, que só existe na chamada iniciada pelo CRM e é nulo em toda ligação recebida.'; -- Índice do recorte que `fn_attendant_metrics` faz: dono + janela de atendimento. create index if not exists idx_voice_calls_owner_answered on public.voice_calls(organization_id, owner_user_id, answered_at) where answered_at is not null; -- ─── 2. isolamento declarado, não presumido ───────────────────────────────── alter table public.voice_calls enable row level security; -- Policy ALL que confere só a organização deixa QUALQUER membro escrever — -- inclusive `viewer`. Todo mundo da organização LÊ o histórico de ligações; -- quem ESCREVE é quem pode atender (`agent` para cima), o mesmo papel que as -- rotas de voz exigem. Vigiado por `tests/invariants/rbac-config-ia-canais.test.ts`. drop policy if exists tenant_isolation_voice_calls_all on public.voice_calls; drop policy if exists voice_calls_select on public.voice_calls; drop policy if exists voice_calls_write on public.voice_calls; create policy voice_calls_select on public.voice_calls for select using (organization_id in (select public.fn_user_org_ids())); create policy voice_calls_write on public.voice_calls for all using ( organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'agent') ) with check ( organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'agent') ); revoke all on public.voice_calls from anon; -- ─── 3. apagar o canal não apaga o histórico ──────────────────────────────── do $$ declare v_nome text; begin select conname into v_nome from pg_constraint where conrelid = 'public.voice_calls'::regclass and contype = 'f' and conkey = array[(select attnum from pg_attribute where attrelid = 'public.voice_calls'::regclass and attname = 'channel_session_id')]; if v_nome is not null and ( select confdeltype from pg_constraint where conname = v_nome and conrelid = 'public.voice_calls'::regclass) <> 'r' then execute format('alter table public.voice_calls drop constraint %I', v_nome); end if; end $$; alter table public.voice_calls drop constraint if exists voice_calls_channel_session_id_fkey; alter table public.voice_calls add constraint voice_calls_channel_session_id_fkey foreign key (channel_session_id) references public.channel_sessions(id) on delete restrict; -- ─── 4. LGPD: o telefone da pessoa entra na cascata ───────────────────────── CREATE OR REPLACE FUNCTION "public"."fn_lgpd_cascade_redact_contact"("p_organization_id" "uuid", "p_contact_id" "uuid", "p_request_id" "uuid") RETURNS "jsonb" LANGUAGE "plpgsql" SECURITY DEFINER SET "search_path" TO 'public' AS $$ declare v_already bool; v_counts jsonb := '{}'::jsonb; v_media_paths text[] := '{}'; v_anon_label text; v_count int; begin perform public.fn_service_lock(p_organization_id,p_contact_id); select is_anonymized into v_already from contacts where id = p_contact_id and organization_id = p_organization_id; if not found then raise exception 'contact not found' using errcode = 'P0002'; end if; if v_already then return jsonb_build_object('already_anonymized', true, 'counts', v_counts, 'media_paths', v_media_paths); end if; v_anon_label := 'Cliente Anonimizado #' || substring(p_contact_id::text from 1 for 8); -- Collect media storage paths (we only delete what we own — media_storage_path) select coalesce(array_agg(distinct media_storage_path) filter (where media_storage_path is not null), '{}') into v_media_paths from messages where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ); -- 1. contacts (irreversible) update contacts set name = v_anon_label, display_name = v_anon_label, email = null, -- email_normalized NÃO entra: é GENERATED ALWAYS AS (lower(trim(email))) -- e o Postgres recusa escrita nela — a linha acima já a zera por derivação. -- Com a atribuição, o cascade INTEIRO abortava e nada era anonimizado. phone_number = null, cpf_encrypted = null, cpf_hash = null, birthdate = null, is_anonymized = true, anonymized_at = now(), consent = '{}'::jsonb, source_metadata = '{}'::jsonb, tags = '{}'::text[], updated_at = now() where id = p_contact_id and organization_id = p_organization_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('contacts', v_count); -- 2. conversations metadata + preview strip update conversations set metadata = '{}'::jsonb, last_message_preview = null, updated_at = now() where contact_id = p_contact_id and organization_id = p_organization_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('conversations', v_count); -- 3. messages: redact body + null media + strip metadata (preserve status/timestamps/conversation_id) update messages set body = '[mensagem anonimizada]', media_url = null, media_mime = null, media_size_bytes = null, media_storage_path = null, metadata = '{}'::jsonb, updated_at = now() where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('messages', v_count); -- 4. crm_lead_activities — strip payload, metadata E reason (migration 0071). -- `reason` é texto livre escrito por LLM sobre a conversa do lead: supor que -- nunca conterá um nome é a suposição que falha. `evidence` NÃO é limpa — -- guarda só ids, e as linhas apontadas são redigidas por conta própria. update crm_lead_activities set payload = '{}'::jsonb, metadata = '{}'::jsonb, reason = null where organization_id = p_organization_id and ( contact_id = p_contact_id or lead_id in ( select lead_id from crm_lead_links where target_kind = 'contact' and target_id = p_contact_id and organization_id = p_organization_id ) or lead_id in ( select id from crm_leads where contact_id = p_contact_id and organization_id = p_organization_id ) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('activities', v_count); -- 5. crm_leads — strip title/description/custom_fields/source_metadata/tags but PRESERVE pipeline/stage/value update crm_leads set title = v_anon_label, description = null, custom_fields = '{}'::jsonb, source_metadata = '{}'::jsonb, tags = '{}'::text[], updated_at = now() where organization_id = p_organization_id and ( contact_id = p_contact_id or id in ( select lead_id from crm_lead_links where target_kind = 'contact' and target_id = p_contact_id and organization_id = p_organization_id ) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('leads', v_count); -- 6. orders — PRESERVE values + status + timestamps. Strip personal fields from payload jsonb -- and replace customer_external_id with null (FK-safe; soft de-link). Keep contact_id null. update orders set payload = (coalesce(payload, '{}'::jsonb)) - 'customer' - 'customer_name' - 'customer_email' - 'customer_phone' - 'shipping_address' - 'billing_address' - 'contact_identification', customer_external_id = null, contact_id = null, is_anonymized = true, updated_at = now() where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('orders', v_count); -- 7. enqueue media for async deletion (idempotent via unique (bucket, object_path)) if array_length(v_media_paths, 1) > 0 then insert into storage_redaction_queue (organization_id, request_id, bucket, object_path) select p_organization_id, p_request_id, 'whatsapp-media', path from unnest(v_media_paths) as path where path is not null and length(path) > 0 on conflict (bucket, object_path) do nothing; end if; -- 7b. voice_calls — o TELEFONE de quem falou ao telefone (migration 0235). -- -- `peer_phone` é `not null` e guarda o número da outra ponta: depois de -- anonimizar o contato, ele sobrevivia ligado ao `contact_id` e reidentificava -- a pessoa que pediu para ser esquecida. É o mesmo argumento que a foto de -- perfil já tinha (ver o bloco do avatar em `lib/lgpd/redact-cascade.ts`): -- anonimizar em toda parte menos numa é não ter anonimizado. -- -- O que fica: direção, status, motivo do fim, marcas de tempo e duração. Um -- registro de "houve uma chamada de 12 minutos" sem número e sem dono não -- identifica ninguém e é o que sustenta a métrica do atendente e a fatura. -- `peer_phone` é NOT NULL, então recebe o rótulo, não `null`. update voice_calls set peer_phone = v_anon_label, owner_user_id = null, created_by = null, updated_at = now() where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('voice_calls', v_count); -- 8. dense audit row insert into api_audit_log (organization_id, action, actor_user_id, resource_type, resource_id, metadata, bypassed_rls) values ( p_organization_id, 'lgpd.redact_executed', null, 'contact', p_contact_id, jsonb_build_object( 'cascaded_to', v_counts, 'media_queued', coalesce(array_length(v_media_paths, 1), 0), 'request_id', p_request_id ), true ); return jsonb_build_object( 'already_anonymized', false, 'counts', v_counts, 'media_paths', v_media_paths ); end; $$; revoke all on function public.fn_lgpd_cascade_redact_contact(uuid,uuid,uuid) from public,anon,authenticated; grant execute on function public.fn_lgpd_cascade_redact_contact(uuid,uuid,uuid) to service_role; -- ─── 5. ligação atendida quebra o silêncio do negócio ─────────────────────── create or replace function public.fn_update_last_activity_at() returns trigger language plpgsql set search_path to 'public', 'pg_temp' as $function$ begin -- LISTA POSITIVA: só isto conta como "alguém tocou este negócio". Tipo que -- não está aqui NÃO quebra o silêncio — inclusive tipo que ainda não existe. -- Ver o cabeçalho da 0079 antes de acrescentar linha nesta lista. if new.type not in ( 'ai_turn', -- a IA falou com o cliente 'note', -- alguém registrou trabalho no negócio 'lead_edited', -- humano mexeu nos dados 'stage_changed', -- humano moveu o negócio 'next_action_approved', -- humano decidiu agir -- (0235) Uma ligação ATENDIDA é interação, e das mais fortes: alguém falou -- com o cliente. Sem esta linha o Radar de Risco seguia marcando como frio -- quem tinha acabado de passar vinte minutos ao telefone, e a IA propunha -- "retomar contato" com quem nunca ficou sem contato. -- -- `voice_call_missed` NÃO entra, e a ausência é a regra e não esquecimento: -- telefone que tocou sem resposta é constatação de silêncio, não quebra -- dele. É exatamente a assimetria que a 0079 existe para preservar. 'voice_call' ) then return new; end if; update public.crm_leads set last_activity_at = greatest(coalesce(last_activity_at, '-infinity'::timestamptz), new.performed_at) where id = new.lead_id; if new.contact_id is not null then update public.contacts set last_activity_at = greatest(coalesce(last_activity_at, '-infinity'::timestamptz), new.performed_at) where id = new.contact_id; end if; return new; end$function$; -- ─── 6. trabalho ao telefone conta como trabalho ──────────────────────────── create or replace function public.fn_attendant_metrics( p_org uuid, p_from timestamptz, p_to timestamptz, p_owner uuid default null ) returns jsonb language sql stable set search_path = public as $$ with lead_agg as ( select owner_user_id as user_id, count(*) filter (where status = 'won') as won, count(*) filter (where status = 'lost') as lost from public.crm_leads where organization_id = p_org and status in ('won', 'lost') and closed_at >= p_from and closed_at < p_to and owner_user_id is not null and (p_owner is null or owner_user_id = p_owner) group by owner_user_id ), conv_agg as ( select assigned_to_user_id as user_id, count(*) as conversations_handled from public.conversations where organization_id = p_org and assigned_to_user_id is not null and assigned_at >= p_from and assigned_at < p_to and (p_owner is null or assigned_to_user_id = p_owner) group by assigned_to_user_id ), -- (0235) Chamada de voz ATENDIDA conta como trabalho. -- -- Quem passa o dia ao telefone tinha produtividade zero nesta função: ela -- lia negócios fechados, conversas atribuídas e primeira resposta por -- MENSAGEM, e nenhuma das três enxerga uma ligação. -- -- `owner_user_id` é quem esteve NA LINHA (a rota de atender grava; a ponte de -- eventos confirma pelo `owner` do upstream) — e não `created_by`, que só -- existe na chamada iniciada pelo CRM e diria zero para toda ligação -- recebida. `answered_at is not null` é o que separa trabalho de telefone -- tocando. voice_agg as ( select owner_user_id as user_id, count(*) as calls_answered, coalesce(sum(duration_ms), 0)::bigint as call_ms from public.voice_calls where organization_id = p_org and owner_user_id is not null and answered_at is not null and answered_at >= p_from and answered_at < p_to and (p_owner is null or owner_user_id = p_owner) group by owner_user_id ), ttfr as ( select c.assigned_to_user_id as user_id, avg(extract(epoch from (fr.first_human_out - fr.first_in))) as avg_first_response_seconds from public.conversations c cross join lateral ( select min(m.sent_at) filter (where m.direction = 'inbound') as first_in, min(m.sent_at) filter ( where m.direction = 'outbound' and m.sent_by_user_id is not null ) as first_human_out from public.messages m where m.conversation_id = c.id ) fr where c.organization_id = p_org and c.assigned_to_user_id is not null and (p_owner is null or c.assigned_to_user_id = p_owner) and fr.first_in is not null and fr.first_human_out is not null and fr.first_human_out > fr.first_in and fr.first_human_out >= p_from and fr.first_human_out < p_to group by c.assigned_to_user_id ), attendant_ids as ( select user_id from lead_agg union select user_id from conv_agg union select user_id from ttfr union select user_id from voice_agg ) select jsonb_build_object( 'funnel', coalesce(( select jsonb_agg( jsonb_build_object( 'stage_id', s.id, 'stage_name', s.name, 'position', s.position, 'count', coalesce(l.cnt, 0) ) order by s.position, s.name ) from public.crm_stages s left join ( select stage_id, count(*) as cnt from public.crm_leads where organization_id = p_org and status = 'open' and (p_owner is null or owner_user_id = p_owner) group by stage_id ) l on l.stage_id = s.id where s.organization_id = p_org and s.is_archived = false ), '[]'::jsonb), 'attendants', coalesce(( select jsonb_agg( jsonb_build_object( 'user_id', a.user_id, 'won', coalesce(la.won, 0), 'lost', coalesce(la.lost, 0), 'conversations_handled', coalesce(ca.conversations_handled, 0), 'avg_first_response_seconds', tf.avg_first_response_seconds, 'calls_answered', coalesce(va.calls_answered, 0), 'call_seconds', (coalesce(va.call_ms, 0) / 1000)::bigint ) order by coalesce(la.won, 0) desc, a.user_id ) from attendant_ids a left join lead_agg la on la.user_id = a.user_id left join conv_agg ca on ca.user_id = a.user_id left join ttfr tf on tf.user_id = a.user_id left join voice_agg va on va.user_id = a.user_id ), '[]'::jsonb) ); $$; revoke all on function public.fn_attendant_metrics(uuid,timestamptz,timestamptz,uuid) from public, anon; grant execute on function public.fn_attendant_metrics(uuid,timestamptz,timestamptz,uuid) to authenticated, service_role; -- ---- chamada de voz nasce desligada (migration 0236) ---- -- -- A chamada de voz vincula um SEGUNDO APARELHO ao mesmo número de WhatsApp que -- já atende, por um caminho que não é o oficial. O risco é a CONTA ser -- bloqueada, não o aparelho — e risco desse tamanho não se herda por -- atualização. Aplicar este bloco NÃO liga nada para ninguém: ele só cria a -- porta e o registro de quem assinou o risco. -- -- AUSÊNCIA DE LINHA É "DESLIGADO" — e aqui isso é o CONTRÁRIO da 0142. Lá, -- `null` valia o ambiente porque havia instalações que já tinham decidido -- aquilo no `.env`. Aqui a capacidade é nova, ninguém a tem, e não há decisão -- anterior a preservar: `false` por ausência é a leitura verdadeira do estado -- do mundo. -- -- LEITURA org-flat, ESCRITA de admin, no BANCO — a lição que a 0143 pagou como -- forward-fix da 0142: rota não é fronteira, e o `ALTER DEFAULT PRIVILEGES ... -- GRANT ALL ON TABLES TO anon, authenticated` deste mesmo baseline vale para -- toda tabela criada depois dele. Sem a policy de papel um `viewer` ligaria a -- feature pelo PostgREST com a anon key, sem auditoria. -- -- Idempotente e auto-curativo: `create table if not exists` + `drop policy if -- exists` antes de cada `create policy`. create table if not exists public.org_voice_calls ( organization_id uuid primary key references public.organizations(id) on delete cascade, enabled boolean not null default false, risco_aceito_em timestamptz, risco_aceito_por uuid references auth.users(id) on delete set null, updated_at timestamptz not null default now() ); alter table public.org_voice_calls enable row level security; drop policy if exists org_voice_calls_select on public.org_voice_calls; drop policy if exists org_voice_calls_admin_write on public.org_voice_calls; create policy org_voice_calls_select on public.org_voice_calls for select using ( (organization_id in (select public.fn_user_org_ids())) or public.fn_is_platform_admin() ); create policy org_voice_calls_admin_write on public.org_voice_calls using ( public.fn_is_platform_admin() or ((organization_id in (select public.fn_user_org_ids())) and public.fn_role_at_least(organization_id, 'admin')) ) with check ( public.fn_is_platform_admin() or ((organization_id in (select public.fn_user_org_ids())) and public.fn_role_at_least(organization_id, 'admin')) ); revoke all on public.org_voice_calls from anon; drop trigger if exists trg_org_voice_calls_set_updated_at on public.org_voice_calls; create trigger trg_org_voice_calls_set_updated_at before update on public.org_voice_calls for each row execute function public.fn_set_updated_at(); -- ---- lembrete em degraus (migration 0254) ---- -- Um tipo de evento passa a ter mais de um lembrete: `reminder_minutes_before` -- segue sendo o degrau principal e `reminder_extra_offsets_minutes` guarda os -- ADICIONAIS. Vazio = um lembrete só, que é o comportamento anterior — por isso -- o update de um clone não precisa decidir nada. -- -- O carimbo por degrau é o que torna o segundo aviso possível: com -- `reminder_sent_at` como filtro, quem recebeu o de um dia nunca voltaria para -- receber o de três horas. O backfill abaixo marca o degrau principal onde já -- havia carimbo, senão a primeira varredura depois da atualização reenviaria -- lembrete para todo compromisso já avisado. alter table public.calendar_event_types add column if not exists reminder_extra_offsets_minutes integer[] not null default '{}'; alter table public.calendar_appointments add column if not exists reminder_sent_offsets_minutes integer[] not null default '{}'; create or replace function public.fn_degraus_de_lembrete_validos(p_degraus integer[]) returns boolean language sql immutable as $$ select coalesce(array_length(p_degraus, 1), 0) <= 3 and coalesce(bool_and(x between 15 and 10080), true) from unnest(coalesce(p_degraus, '{}'::integer[])) as x; $$; revoke execute on function public.fn_degraus_de_lembrete_validos(integer[]) from public, anon; grant execute on function public.fn_degraus_de_lembrete_validos(integer[]) to authenticated, service_role; do $$ begin if not exists ( select 1 from pg_constraint where conname = 'calendar_event_types_extras_na_faixa' and conrelid = 'public.calendar_event_types'::regclass ) then update public.calendar_event_types set reminder_extra_offsets_minutes = '{}' where not public.fn_degraus_de_lembrete_validos(reminder_extra_offsets_minutes); alter table public.calendar_event_types add constraint calendar_event_types_extras_na_faixa check (public.fn_degraus_de_lembrete_validos(reminder_extra_offsets_minutes)); end if; end $$; update public.calendar_appointments a set reminder_sent_offsets_minutes = array[t.reminder_minutes_before] from public.calendar_event_types t where a.event_type_id = t.id and a.reminder_sent_at is not null and coalesce(array_length(a.reminder_sent_offsets_minutes, 1), 0) = 0; comment on column public.calendar_event_types.reminder_extra_offsets_minutes is 'Degraus ADICIONAIS de lembrete, em minutos antes do compromisso. Os degraus efetivos são reminder_minutes_before mais estes. Vazio = um lembrete só, o comportamento anterior.'; comment on column public.calendar_appointments.reminder_sent_offsets_minutes is 'Quais degraus de lembrete já saíram para este compromisso. É a autoridade sobre o que falta enviar — reminder_sent_at guarda apenas o instante do último envio e NÃO deve ser usado como filtro.'; comment on column public.calendar_appointments.reminder_sent_at is 'Instante do último lembrete enviado. Informativo: quem decide o que ainda falta enviar é reminder_sent_offsets_minutes.'; -- ---- aniversário do contato (migration 0252) ---- -- `contacts.birthdate` existia e não acionava nada. `birthday_md` é o mês e o -- dia num inteiro (914 = 14 de setembro), gerado e armazenado, para a varredura -- diária buscar por igualdade em vez de varrer a tabela. -- -- `extract` sobre `date` é immutable, que é o que a coluna gerada exige; -- `to_char` não é (depende de configuração regional) e o Postgres a recusaria. alter table public.contacts add column if not exists birthday_md integer generated always as ( case when birthdate is null then null else (extract(month from birthdate)::integer * 100 + extract(day from birthdate)::integer) end ) stored; create index if not exists contacts_org_aniversario_idx on public.contacts (organization_id, birthday_md) where birthday_md is not null; comment on column public.contacts.birthday_md is 'Mês e dia do aniversário num inteiro (914 = 14 de setembro), derivado de birthdate. Existe para a varredura diária do cron contact-birthdays poder buscar por igualdade em vez de varrer a tabela.'; notify pgrst, 'reload schema'; -- ---- Registro não nasce `pending` (migration 0239) ---- -- -- Racional completo no cabeçalho da migration 0244. Em uma linha: tipo de evento -- que ninguém consome não é fila — é registro, e a linha nasce `done`. -- -- O defeito medido (issue #753): `event_log.status` nasce `pending` e nenhum -- drain seleciona tipo sem handler (`drain.ts` filtra por -- `event_type in (handlers)`; o drain do agent-engine filtra -- `ai_agent.dispatch_requested`), então o registro acaba a vida `pending` — 626 -- linhas em 8 tipos na instalação da issue, indistinguíveis de fila entupida. -- -- A lista mora no BANCO porque é o banco que escreve o status: quem emitir um -- tipo novo sem consumidor cai em -- `tests/unit/evento-de-fato-nao-fica-pendente.test.ts`, que lê a lista daqui e -- a cobra exaustiva em relação ao que o código emite. create or replace function public.fn_event_log_e_registro(p_event_type text) returns boolean language sql immutable set search_path to 'public', 'pg_temp' as $$ select p_event_type = any (array[ -- IA e agente 'ai.responded', 'ai_agent.created', 'ai_agent.published', 'ai_agent.run_completed', 'ai_agent.run_failed', 'ai_agent.run_started', -- agente (harness) — o motor registra quando não há negócio para pendurar 'agent.activity_unrouted', -- canal e conversa 'channel_session.status_changed', 'conversation.claimed', 'conversation.transferred', 'whatsapp.chat_id_not_recognized', 'whatsapp.conversation_mark_failed', -- contato, lead, organização e plataforma 'contact.anonymized', 'contact.created', 'contact.deleted', 'contact.updated', 'crm.activity_write_failed', 'incident.resolved', 'lead.bulk_assigned', 'lead.bulk_deleted', 'lead.bulk_tagged', 'lead.reopened', 'lead.risk_backlog_seeded', 'lead.updated', 'org.updated', 'tenant.onboarded', 'tenant.reactivated', 'tenant.suspended', 'user.profile_updated', -- mensagem ('message.failed' saiu aqui na 0417: ele ganhou consumidor) 'message.outbound', 'message.sending', 'message.sent', -- LGPD 'lgpd.export_delivered', 'lgpd.export_generated', 'lgpd.redact_applied', 'lgpd.redact_failed' ]::text[]); $$; -- Mesma ACL de `fn_log_event` (migration 0034): função pura de leitura, útil no -- SQL editor de uma instalação, e nunca alcançável pela anon key. revoke all on function public.fn_event_log_e_registro(text) from public, anon; grant execute on function public.fn_event_log_e_registro(text) to authenticated, service_role; create or replace function public.fn_event_log_marca_registro() returns trigger language plpgsql set search_path to 'public', 'pg_temp' as $$ begin -- Só o que nasce `pending`: quem escolhe status na origem não é reescrito -- (o agent-engine insere `ai_agent.dispatch_requested` e -- `agent.operator_turn` com o status que quer). if new.status = 'pending' and public.fn_event_log_e_registro(new.event_type) then new.status := 'done'; end if; return new; end; $$; revoke all on function public.fn_event_log_marca_registro() from public, anon; grant execute on function public.fn_event_log_marca_registro() to service_role; drop trigger if exists trg_event_log_marca_registro on public.event_log; create trigger trg_event_log_marca_registro before insert on public.event_log for each row execute function public.fn_event_log_marca_registro(); -- Backfill do estoque: só os tipos da lista, e só `pending` — `processing` -- (claim perdido, dono é o reaper do drain) e `dead` (erro de consumidor) são -- outra história, com outro dono. update public.event_log set status = 'done' where status = 'pending' and public.fn_event_log_e_registro(event_type); -- ---- Credencial de enfeite não derruba a leitura (migration 0240) ---- -- -- Racional completo no cabeçalho da migration 0252. Em uma linha: não tente -- decifrar o que não pode ser cifra — devolva null, que é o contrato que os -- leitores já tratam (`lib/webhooks/secrets.ts`). -- -- O defeito medido (issue #754): `fn_decrypt_oauth` chamava `pgp_sym_decrypt` em -- QUALQUER bytea, e o schema grava byte de enfeite onde ainda não há credencial -- (as colunas cifradas são NOT NULL — `Buffer.from([0])` nas rotas que criam -- sessão, conforme `lib/waha/webhook-auth.ts` já documenta). Resultado: 500 -- permanente em 10-30% das chamadas do RPC, nos mesmos registros. -- -- Medido nesta VPS antes do fix: `\x00` => 39000, bytea vazio => 39000, pacote -- de verdade => decifra, NULL => NULL. O menor pacote que `fn_encrypt_oauth` -- produz tem 66 bytes, e pacote PGP começa com o bit 7 ligado (o real: 0xC3) — -- são as duas condições da guarda. A ordem importa: `get_byte()` em bytea vazio -- estoura (`index 0 out of valid range`). create or replace function public.fn_decrypt_oauth(ciphertext bytea) returns text language plpgsql security definer set search_path to 'public', 'private', 'extensions', 'pg_temp' as $$ declare k text := private.fn_oauth_key(); begin -- 1. Sem valor não há credencial (comportamento que a função já tinha). if ciphertext is null then return null; end if; -- 2. Curto demais para ser pacote deste par: o menor que fn_encrypt_oauth -- produz (texto vazio, aes256) tem 66 bytes — medido. Abaixo disso é -- sentinela (`\x00`, o byte de enfeite das rotas de sessão), bytea vazio, -- lixo ou truncamento. if octet_length(ciphertext) < 66 then return null; end if; -- 3. Pacote PGP começa com o bit 7 ligado (o real medido: 0xC3). Sem cara de -- pacote é JSON em claro, hex ou texto — e o tamanho sozinho não pega isso. -- Esta linha vem DEPOIS da de tamanho de propósito: `get_byte()` em bytea -- vazio estoura com `index 0 out of valid range, 0..-1` — medido. if get_byte(ciphertext, 0) < 128 then return null; end if; -- Daqui para baixo só chega pacote de verdade: se não abrir, é chave mestra -- trocada ou dado corrompido, e isso tem de aparecer. return pgp_sym_decrypt(ciphertext, k); end$$; revoke all on function public.fn_decrypt_oauth(bytea) from public, anon, authenticated; grant execute on function public.fn_decrypt_oauth(bytea) to service_role; notify pgrst, 'reload schema'; -- ---- rascunho de agente sem número de WhatsApp (migration 0241) ---- -- -- `channel_session_id` era NOT NULL, e o editor exigia o número para SALVAR. -- Instalação nova não tem nenhuma linha em `channel_sessions` (o aparelho é -- pareado outro dia), então o dono escrevia o prompt do atendente e não -- conseguia guardar nada. Escolher o número é requisito para ATENDER. -- -- Publicar sem número continua recusado por `fn_publish_ai_agent_version` -- (`channel_session_not_found`: o select por id nulo não acha linha), e o runtime -- só executa `ai_agents.published_version_id` — rascunho sem número é invisível -- para o atendimento por construção. -- -- Idempotente e sem backfill: `drop not null` em coluna já anulável é no-op, e -- afrouxar a restrição não invalida nenhuma linha existente. alter table public.ai_agent_versions alter column channel_session_id drop not null; comment on column public.ai_agent_versions.channel_session_id is 'Por qual número este agente atende. NULL = ainda não escolhido (rascunho legítimo de quem não pareou o WhatsApp). Publicar com NULL é recusado por fn_publish_ai_agent_version (channel_session_not_found).'; -- ---- aviso de caso parado: índice do watcher (migration 0242) ---- -- -- O VOCABULÁRIO do kind (case_stale) mora no bloco único da constraint, lá em -- cima — aqui só o índice. Reconstruir a constraint num segundo bloco faria as -- duas listas divergirem, e é o que -- reprova. -- -- Parcial em status=open porque é a única pergunta do watcher ("existe aviso -- aberto para este caso?") e porque avisos resolvidos viram a maioria das -- linhas com o tempo. create index if not exists agent_inbox_items_case_stale_aberto_idx on public.agent_inbox_items (organization_id, ref_id) where kind = 'case_stale' and status = 'open'; -- ---- chamada de API tem PRAZO para esperar uma trava (migration 0243) ---- -- `lock_timeout` é 0 por padrão no Postgres: esperar para sempre. O cliente HTTP -- desiste em 10s, mas a consulta continua viva segurando a fila, e o clique -- seguinte empilha atrás. Medido em produção: 10 chamadas simultâneas de -- "Enviar link ao cliente", Postgres a 357% de CPU. -- -- No PAPEL e não na função: `authenticator` é usado em TODA requisição da API, e -- `set role` não reinicia parâmetros de sessão. Cobre as sete funções que a -- varredura achou com a mesma forma, e as que ainda não existem. -- `service_role` fica de fora: trabalho de fundo pode esperar. -- -- Idempotente: `alter role ... set` sobrescreve. do $$ begin if exists (select 1 from pg_roles where rolname = 'authenticator') then execute 'alter role authenticator set lock_timeout = ''4s'''; end if; if exists (select 1 from pg_roles where rolname = 'authenticated') then execute 'alter role authenticated set lock_timeout = ''4s'''; end if; end $$; -- ---- tags de conversa em uso (migration 0244) ---- -- O seletor de etiqueta do Inbox oferece as etiquetas EM USO, e nao so a lista -- curada a mao. `security INVOKER` de proposito: a funcao recebe a organizacao -- por ARGUMENTO e e concedida a `authenticated`, entao `definer` aqui seria -- leitura cross-tenant (o mesmo aviso esta no comentario de -- `fn_gasto_de_ia_do_mes`). Sob invoker quem isola e a RLS de `conversations`. -- Idempotente por construcao: `create or replace` + `revoke`/`grant`. create or replace function public.fn_tags_de_conversa_em_uso(p_org uuid) returns table (tag text) language sql stable security invoker set search_path = public as $$ select distinct t from public.conversations c, unnest(c.tags) as t where c.organization_id = p_org and c.tags is not null order by t limit 200; $$; -- As DUAS origens de EXECUTE: o ALTER DEFAULT PRIVILEGES do baseline (que da a -- anon) e o grant a PUBLIC que o Postgres da ao criar. Revogar uma so deixa a -- funcao alcancavel pela anon key, que vai para o browser. revoke execute on function public.fn_tags_de_conversa_em_uso(uuid) from public, anon; grant execute on function public.fn_tags_de_conversa_em_uso(uuid) to authenticated, service_role; -- ---- Índices em FKs de mensagens e runs (migration 0247) ---- create index if not exists idx_messages_contact_id on public.messages (contact_id) where contact_id is not null; create index if not exists idx_messages_channel_session_id on public.messages (channel_session_id) where channel_session_id is not null; create index if not exists idx_ai_agent_runs_contact_id on public.ai_agent_runs (contact_id) where contact_id is not null; create index if not exists idx_ai_agent_runs_channel_session_id on public.ai_agent_runs (channel_session_id) where channel_session_id is not null; create index if not exists idx_ai_agent_runs_conversation_id on public.ai_agent_runs (conversation_id) where conversation_id is not null; create index if not exists idx_ai_agent_runs_inbound_message_id on public.ai_agent_runs (inbound_message_id) where inbound_message_id is not null; create index if not exists idx_ai_agent_runs_outbound_message_id on public.ai_agent_runs (outbound_message_id) where outbound_message_id is not null; -- ---- o caso tem assunto: agent_cases.kind (migration 0248) ---- -- `agent_cases.kind` — do que o caso trata, para quem tria a fila. -- -- POR QUE: hoje o assunto de um caso vive só em texto livre (`title`, `summary`, -- `blocker`). Com a fila curta isso basta — dá para ler tudo. Com volume, não: -- quem abre a fila quer separar "alguém quer marcar horário" de "alguém está -- reclamando" antes de ler qualquer coisa, porque as duas pedem pessoas e -- urgências diferentes. -- -- Medido no CRM de origem: 102 pedidos em poucos meses, distribuídos em -- agendamento 53, atendimento humano 33, remarcação 4, pagamento 4, curso 3, -- cancelamento 2, dúvida 2, outro 1. A triagem por assunto era o que a tela de -- lá oferecia, e é o que falta aqui. -- -- ⚠️ SEM CHECK, DE PROPÓSITO — e isto é a doutrina de vocabulário ABERTO do -- CLAUDE.md, não descuido. O vocabulário útil muda com o negócio: clínica tem -- "remarcação", loja tem "troca". Um CHECK fixo aqui obrigaria uma migration -- por nicho, e faria o `update.sh` de um clone com valor próprio quebrar. Quem -- prende o vocabulário é a constante `TIPOS_DE_CASO` no TypeScript, e o emissor -- usa ela — nunca string literal. A coluna fica FORA do invariante -- `vocabulario-banco-x-typescript`, que só cobre coluna que JÁ tem CHECK. -- -- `default 'outro'` e `not null`: caso antigo não fica com buraco, e caso novo -- sem classificação cai no genérico em vez de num nulo que toda tela precisa -- tratar. Nenhum backfill: o default resolve as linhas existentes na hora. alter table public.agent_cases add column if not exists kind text not null default 'outro'; comment on column public.agent_cases.kind is 'Do que o caso trata, para triagem. Vocabulário ABERTO (sem CHECK): a lista vigente é TIPOS_DE_CASO em lib/ai/case-copy.ts, e quem escreve usa a constante. Valor desconhecido cai no rótulo genérico da tela, nunca quebra.'; -- A fila é sempre lida por organização e por status; o assunto é o terceiro -- corte. Parcial nos abertos porque é neles que se tria — resolvido vira -- histórico, e histórico se consulta inteiro. create index if not exists agent_cases_org_status_kind_idx on public.agent_cases (organization_id, kind) where status in ('awaiting_human', 'awaiting_lead'); -- ---- agenda: prazo de expiração do pedido não confirmado (migration 0249) ---- -- -- `fn_agenda_settings` ENUMERA as chaves aceitas e rejeita extras, então o campo -- novo precisa dela recriada — senão a tela salva e recebe 22023. Opcional de -- propósito: toda organização já instalada tem duas chaves, e exigir a terceira -- quebraria o PATCH de uma aba aberta antes da atualização. Ausente = default do -- lado TypeScript (1440 minutos). Nenhum backfill: a ausência já é estado válido. -- -- ⚠️ ESTA VERSÃO É DERIVADA DA QUE ESTÁ EM VIGOR, NÃO REESCRITA DO ZERO — e o -- portão de MFA da linha abaixo é o motivo. Ele entrou pela migration 0229 -- (`0229_mfa_e_lgpd_agenda`), e uma reescrita a partir do corpo ANTIGO o -- apagaria sem deixar rastro: `create or replace` não avisa o que sumiu, o -- espelho migration↔baseline continua fiel (fiel carregando o defeito), e o -- `update.sh` de quem já rodava REMOVERIA a proteção que ele tinha. Recriar -- função aqui é sempre derivar da que está em vigor. create or replace function public.fn_agenda_settings(p_org uuid, p_config jsonb) returns jsonb language plpgsql security definer set search_path = public as $$ begin if auth.uid() is null or not public.fn_role_at_least(p_org, 'manager') or not public.fn_support_write_allowed(p_org) then raise exception 'agenda_settings_forbidden' using errcode = '42501'; end if; -- Portão de MFA (migration 0229). Prazos de agenda são configuração que muda -- o comportamento do produto para a organização inteira. if not public.fn_session_mfa_proven() then raise exception 'agenda_mfa_required' using errcode = '42501'; end if; if jsonb_typeof(p_config->'confirmation_delay_minutes') is distinct from 'number' or jsonb_typeof(p_config->'unknown_protection_minutes') is distinct from 'number' or (p_config - 'confirmation_delay_minutes' - 'unknown_protection_minutes' - 'pending_expires_after_minutes') <> '{}'::jsonb or (p_config->>'confirmation_delay_minutes' ~ '^[0-9]{1,5}$') is not true or (p_config->>'unknown_protection_minutes' ~ '^[0-9]{1,5}$') is not true or (p_config->>'confirmation_delay_minutes')::int not between 1 and 10080 or (p_config->>'unknown_protection_minutes')::int not between 1 and 10080 or (p_config->>'unknown_protection_minutes')::int < (p_config->>'confirmation_delay_minutes')::int then raise exception 'agenda_settings_invalid' using errcode = '22023'; end if; if p_config ? 'pending_expires_after_minutes' then if jsonb_typeof(p_config->'pending_expires_after_minutes') is distinct from 'number' or (p_config->>'pending_expires_after_minutes' ~ '^[0-9]{1,5}$') is not true or (p_config->>'pending_expires_after_minutes')::int not between 15 and 10080 then raise exception 'agenda_settings_invalid' using errcode = '22023'; end if; end if; update public.organizations set settings = jsonb_set(coalesce(settings, '{}'::jsonb), '{agenda}', p_config, true) where id = p_org; if not found then raise exception 'organization_not_found' using errcode = 'P0002'; end if; return p_config; end; $$; revoke all on function public.fn_agenda_settings(uuid, jsonb) from public, anon, authenticated; grant execute on function public.fn_agenda_settings(uuid, jsonb) to authenticated; -- ---- guarda contra replay do gateway do Supabase (migration 0250) ---- -- O gateway entre o Cloudflare e o PostgREST reexecuta resposta 5xx sem limite. -- Um `raise ... errcode='40001'` (conflito benigno) vira HTTP 500 no PostgREST; -- 8 requisições de dois dias antes, reexecutadas ~280×/s cada, ocuparam o pool -- inteiro, o schema cache não carregou e TODA requisição virou 503 PGRST002 — -- o produto inteiro em "Algo deu errado" (2026-09-11). Este hook responde 409 a -- requisição cujo `sb-request-id` (UUIDv7) tem mais de 5 minutos: 4xx não é -- reexecutado. Idempotente: `create or replace`, grants e `alter role` repetíveis. create or replace function public.fn_pgrst_recusar_replay_do_gateway() returns void language plpgsql stable set search_path = '' as $$ declare rid text; aceito_ha interval; begin rid := coalesce(nullif(current_setting('request.headers', true), '')::jsonb ->> 'sb-request-id', ''); -- Só UUIDv7 (versão 7 no 3º grupo) carrega instante; qualquer outro formato passa. if rid !~ '^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-' then return; end if; aceito_ha := now() - to_timestamp((('x' || replace(left(rid, 13), '-', ''))::bit(48)::bigint) / 1000.0); if aceito_ha > interval '5 minutes' then raise exception 'gateway_replay' using errcode = 'PT409', detail = format('sb-request-id %s foi aceito pelo gateway há %s', rid, aceito_ha), hint = 'A requisição original já expirou; esta é uma reexecução do gateway de uma resposta 5xx antiga.'; end if; exception when sqlstate 'PT409' then raise; when others then -- A guarda nunca derruba uma requisição por defeito próprio (cabeçalho fora do esperado etc.). return; end; $$; comment on function public.fn_pgrst_recusar_replay_do_gateway() is 'pgrst.db_pre_request: responde 409 a requisição que o gateway do Supabase reexecuta há >5 min (sb-request-id UUIDv7 velho), para não alimentar o loop de retry de 5xx que esgota o pool do PostgREST.'; -- Roda sob o papel da REQUISIÇÃO (anon/authenticated/service_role), então os três -- precisam de EXECUTE; sem isso a própria guarda vira "permission denied" → 5xx. -- Não é definer e não lê nada além dos GUCs da requisição: expô-la não amplia nada. revoke all on function public.fn_pgrst_recusar_replay_do_gateway() from public, anon; grant execute on function public.fn_pgrst_recusar_replay_do_gateway() to anon, authenticated, service_role; -- O papel `authenticator` só existe onde há PostgREST (Supabase). No Postgres -- descartável do `test:db` não existe, e um ALTER ROLE sem guarda derrubaria o -- install fresco (ON_ERROR_STOP=1). do $$ begin if to_regrole('authenticator') is not null then execute $c$alter role authenticator set pgrst.db_pre_request = 'public.fn_pgrst_recusar_replay_do_gateway'$c$; end if; end $$; notify pgrst, 'reload config'; notify pgrst, 'reload schema'; -- ---- O modo de acesso da IA volta atrás junto com o gate (migration 0251) ---- -- 0241 · Forward-fix da 0218 (issue #602). A RPC gravava o LITERAL -- 'pre_go_live' em `ai_gate_mode` em toda chamada — abrir o canal ao público -- limpava o `ai_gate` e deixava o marcador de teste para trás. Ficava inerte -- enquanto o canal estava aberto e virava armadilha na volta: o script CLI -- (`scripts/ativar-gate-elegibilidade-ia.ts`, allowlist POR ORIGEM) escrevia só -- `{ai_gate}`, e o canal reaparecia em pré-go-live com a lista de testadores -- velha — enquanto o preflight do mesmo script prometia `autorizado` e o motor -- executava `fora_da_lista_de_teste`. Aqui `ai_gate_mode` recebe o modo REAL -- (`p_modo`), no vocabulário do contrato da tela ('open' | 'pre_go_live'). -- `create or replace`, sem DDL novo e sem backfill: o estado antigo é inerte e -- sai na próxima gravação da tela ou do script. create or replace function public.fn_configurar_pre_go_live_canal( p_org uuid, p_canal uuid, p_modo text, p_numeros text[] ) returns integer language plpgsql security invoker set search_path = '' as $$ declare v_linhas integer; v_gate text; begin if p_modo is null or p_modo not in ('open', 'pre_go_live') then raise exception 'modo de acesso da IA inválido' using errcode = '22023'; end if; if p_numeros is null or exists ( select 1 from unnest(p_numeros) as n(numero) where numero is null or numero !~ '^\+[1-9][0-9]{7,14}$' ) then raise exception 'lista de telefones de teste inválida' using errcode = '22023'; end if; v_gate := case when p_modo = 'pre_go_live' then 'allowlist' else 'open' end; update public.channel_sessions set metadata = jsonb_set( jsonb_set( jsonb_set(coalesce(metadata, '{}'::jsonb), '{ai_gate}', to_jsonb(v_gate), true), -- O modo REAL, não o literal (o defeito da issue #602). '{ai_gate_mode}', to_jsonb(p_modo), true ), '{ai_test_phone_numbers}', to_jsonb(p_numeros), true ) where organization_id = p_org and id = p_canal and archived_at is null; get diagnostics v_linhas = row_count; return v_linhas; end; $$; revoke execute on function public.fn_configurar_pre_go_live_canal(uuid, uuid, text, text[]) from public, anon, authenticated; grant execute on function public.fn_configurar_pre_go_live_canal(uuid, uuid, text, text[]) to service_role; notify pgrst, 'reload schema'; -- ---- lead do ingest nao duplica (migration 0256) ---- -- Check-then-act em TypeScript deixava três mensagens seguidas virarem três -- negócios (medido: mesmo contato, três cards às 17:07). O advisory lock -- serializa só o MESMO contato; um índice único resolveria a corrida e -- quebraria o caso legítimo de dois negócios abertos criados à mão. create or replace function public.fn_nascer_lead_da_conversa( p_org uuid, p_contact uuid, p_pipeline uuid, p_stage uuid, p_title text, p_source text, p_source_metadata jsonb default '{}'::jsonb, p_tags text[] default '{}'::text[] ) returns uuid language plpgsql security invoker set search_path = public as $$ declare v_id uuid; begin -- Serializa por (organização, contato). Transaction-scoped: liberado no -- commit, sem risco de lock vazado. perform pg_advisory_xact_lock(hashtextextended(p_org::text || ':' || p_contact::text, 0)); select id into v_id from public.crm_leads where organization_id = p_org and contact_id = p_contact and status = 'open' limit 1; -- NULL significa "já existe", e quem chama traduz isso para `ja_existe`. Não é -- erro: é o desfecho correto da segunda mensagem. if v_id is not null then return null; end if; insert into public.crm_leads (organization_id, pipeline_id, stage_id, contact_id, title, source, source_metadata, tags) values (p_org, p_pipeline, p_stage, p_contact, p_title, p_source, coalesce(p_source_metadata, '{}'::jsonb), coalesce(p_tags, '{}'::text[])) returning id into v_id; return v_id; end; $$; revoke execute on function public.fn_nascer_lead_da_conversa(uuid, uuid, uuid, uuid, text, text, jsonb, text[]) from public, anon; grant execute on function public.fn_nascer_lead_da_conversa(uuid, uuid, uuid, uuid, text, text, jsonb, text[]) to authenticated, service_role; comment on function public.fn_nascer_lead_da_conversa(uuid, uuid, uuid, uuid, text, text, jsonb, text[]) is 'Cria o lead de entrada do ingest serializando por (organização, contato) com advisory lock. Devolve NULL quando já existe um aberto. Existe porque o check-then-act em TypeScript deixava três mensagens seguidas virarem três negócios; um índice único resolveria a corrida e quebraria o caso legítimo de dois negócios abertos criados à mão.'; -- ---- o audit log perde UPDATE, DELETE e TRUNCATE nos papéis do PostgREST (migration 0258) ---- -- -- Todo projeto Supabase nasce com um default ACL de TABELAS em `public` -- (`anon=arwdDxt`, `authenticated=arwdDxt`, `service_role=arwdDxt`), gravado -- pelo bootstrap do Supabase antes de qualquer SQL nosso. `api_audit_log` nasce -- com tudo, e o `GRANT SELECT,INSERT,REFERENCES,TRIGGER,TRUNCATE` que o dump -- emite acima só ACRESCENTA. Resultado no Supabase real: `service_role` — que -- ignora RLS — apagava e reescrevia linha escolhida da auditoria pela REST, e os -- três papéis podiam esvaziá-la com TRUNCATE. `anon`/`authenticated` só não -- apagavam porque a RLS não tem policy de UPDATE/DELETE. -- -- Até a issue #887 o prelude do `test:db` reproduzia o default ACL do Supabase -- só para funções, e por isso o gate de grants ficou verde para UPDATE e DELETE -- enquanto eles estavam abertos. O TRUNCATE vinha do próprio `GRANT` do dump e -- ficou verde por outro motivo: a sonda não perguntava por ele. O invariante -- `audit-log-sob-o-default-acl-do-supabase` -- reproduz o de tabela e reaplica ESTE bloco, extraído daqui pelo rótulo. -- -- O expurgo legítimo não depende destes grants: `fn_expurgar_auditoria_vencida` -- (0167) é `security definer` de dono `postgres`. As FKs `on delete set null` -- desta tabela também não: a ação referencial roda como o dono da tabela. -- `public` entra por completude — um grant a PUBLIC seria herdado pelos três. -- -- `revoke` do que já não existe não é erro: idempotente por natureza, e o -- `update.sh` de um clone pode reaplicar à vontade — inclusive depois do GRANT -- do corpo do dump, que reconcede TRUNCATE a cada passada e é revogado aqui. revoke update, delete, truncate on table public.api_audit_log from public, anon, authenticated, service_role; comment on table public.api_audit_log is 'L-10: Append-only para os papéis do PostgREST — anon, authenticated e service_role não têm UPDATE, DELETE nem TRUNCATE (migration 0258; o default ACL do Supabase concedia os três). O único apagamento é fn_expurgar_auditoria_vencida (0167), security definer com piso de 90 dias no corpo. Retencao default 5 anos, configuravel em AUDIT_LOG_RETENTION_DAYS.'; notify pgrst, 'reload schema'; -- ---- três índices que não pagam o próprio aluguel (migration 0259) ---- -- -- Índice redundante custa em TODO insert/update e ocupa disco. Os três abaixo -- têm o trabalho JÁ feito por outro índice da mesma tabela: -- -- 1. `ai_models_provider_model_unique (provider, model_id)`, da migration 0127, -- contra a constraint `ai_models_unique (provider, model_id)` do schema -- original — mesmas colunas, mesma ordem, os dois UNIQUE. É o "índice -- duplicado em ai_models" que o advisor apontou numa VPS de cliente. Fica a -- constraint, que é a forma mais forte. -- 2. `idx_crm_lead_links_lead (lead_id)` contra -- `uniq_crm_lead_links_lead_target_link (lead_id, target_kind, target_id, -- link_kind)` — um btree responde por qualquer PREFIXO das suas colunas. -- 3. `calendar_connections_org_pessoa_idx (organization_id, user_id)` contra -- `calendar_connections_conta_key (organization_id, user_id, provider, -- account_email)` — mesmo argumento de prefixo. -- -- O planner NÃO ignorava os dois de prefixo: quando existiam, ele os preferia, -- porque são menores. Medido em pg17, 20 000 vínculos em 2 000 leads, busca por -- `lead_id`: com os dois índices, `Bitmap Index Scan on` o de uma coluna -- (216 kB, custo 4,36); só com o largo, o mesmo plano no de quatro (1464 kB, -- custo 4,49; total 39,00 → 39,13). A busca segue servida por índice; o que se -- troca é um índice menor na leitura por um índice a menos em toda escrita. -- -- ⚠️ CADA DROP CONFERE QUE O SUBSTITUTO ESTÁ DE PÉ. O `update.sh` roda sem -- `ON_ERROR_STOP`: uma criação que falhou acima (duplicata num clone, por -- exemplo) segue em silêncio, e derrubar o índice menor sem o maior deixaria -- a tabela sem índice nenhum para a busca — ou, no caso 1, sem a ÚNICA coisa -- impedindo dois cadastros do mesmo modelo. -- -- E este arquivo não os cria mais para derrubar aqui: a linha do dump saiu, o -- bloco da 0127 só cria o índice onde a constraint falta, e o do calendário não -- o declara. Antes, toda instalação e todo `update.sh` construía os três e os -- jogava fora neste bloco — `CREATE INDEX` não concorrente, que trava escrita -- na tabela enquanto constrói. Quem já os tem (instalou antes da 0259) os perde -- aqui, uma vez. do $$ begin if exists ( select 1 from pg_constraint where conname = 'ai_models_unique' and conrelid = 'public.ai_models'::regclass ) then drop index if exists public.ai_models_provider_model_unique; end if; if exists ( select 1 from pg_indexes where schemaname = 'public' and tablename = 'crm_lead_links' and indexname = 'uniq_crm_lead_links_lead_target_link' ) then drop index if exists public.idx_crm_lead_links_lead; end if; if exists ( select 1 from pg_indexes where schemaname = 'public' and tablename = 'calendar_connections' and indexname = 'calendar_connections_conta_key' ) then drop index if exists public.calendar_connections_org_pessoa_idx; end if; end $$; -- ---- a ocupação do Google do dono não depende de quem consulta (migration 0260) ---- -- Racional completo no cabeçalho da migration 0260 (issue #879, PR #883). Em uma -- linha: a RLS de `calendar_connections` esconde a conexão de um `agent`, e a -- junção que levava ao Google do dono voltava vazia — a grade e o encaixe -- ofereciam horário em cima de compromisso que existe. As duas funções -- atravessam SÓ essa RLS, conferem o pertencimento no corpo (`fn_user_org_ids`), -- filtram o dono e devolvem ocupação (início/fim/transparência/situação), nunca -- conteúdo do evento. Idempotente: `create or replace` + revoke/grant. create or replace function public.fn_agenda_ocupacao_google_do_dono( p_org uuid, p_owner uuid, p_de timestamptz, p_ate timestamptz ) returns table ( starts_at timestamptz, ends_at timestamptz, transparency text, status text, connection_status text ) language sql stable security definer set search_path = public as $$ select e.starts_at, e.ends_at, e.transparency, e.status, c.status from public.calendar_selected_external_events e join public.calendar_connections c on c.organization_id = e.organization_id and c.id = e.connection_id where (auth.uid() is null or p_org in (select public.fn_user_org_ids()) or public.fn_is_platform_admin()) and e.organization_id = p_org and c.user_id = p_owner -- Cruzamento ESTRITO, a régua de `colide`: encostar não é ocupar. and e.starts_at < p_ate and e.ends_at > p_de; $$; create or replace function public.fn_agenda_conexoes_google_do_dono( p_org uuid, p_owner uuid ) returns table ( status text, last_sync_at timestamptz ) language sql stable security definer set search_path = public as $$ select c.status, c.last_sync_at from public.calendar_connections c where (auth.uid() is null or p_org in (select public.fn_user_org_ids()) or public.fn_is_platform_admin()) and c.organization_id = p_org and c.user_id = p_owner; $$; -- Função nova em `public` nasce EXPOSTA — as DUAS origens de EXECUTE (CLAUDE.md): -- (A) o `ALTER DEFAULT PRIVILEGES ... GRANT ALL ON FUNCTIONS TO anon` do -- baseline, que `revoke from public` NÃO remove; -- (B) o grant a PUBLIC que o Postgres dá a toda função criada, que -- `revoke from anon` NÃO remove. revoke execute on function public.fn_agenda_ocupacao_google_do_dono(uuid, uuid, timestamptz, timestamptz) from public, anon; grant execute on function public.fn_agenda_ocupacao_google_do_dono(uuid, uuid, timestamptz, timestamptz) to authenticated, service_role; revoke execute on function public.fn_agenda_conexoes_google_do_dono(uuid, uuid) from public, anon; grant execute on function public.fn_agenda_conexoes_google_do_dono(uuid, uuid) to authenticated, service_role; notify pgrst, 'reload schema'; -- ---- PRIVACIDADE: o título do evento pessoal do Google sai do alcance do membro (migration 0261) ---- -- -- ## O que estava aberto, e foi medido -- -- `public.calendar_external_events` é o espelho da agenda PESSOAL de quem atende. -- O papel `authenticated` tinha SELECT de TABELA nesta tabela — vindo do default -- ACL de TABELAS (`ALTER DEFAULT PRIVILEGES … GRANT ALL ON TABLES`, que o Supabase -- grava e este dump reemite; não há `GRANT` desta tabela no dump) — e a view -- `calendar_selected_external_events` era `select e.*` — com `title` dentro. Num -- banco instalado do zero (`baseline.sql` da v1.26.0), qualquer membro da -- organização, inclusive Somente leitura, lia o `title` de uma linha do colega -- (com o título inserido à mão — ver o alcance logo abaixo): -- -- select title from calendar_external_events … → "Terapia sigilosa" -- -- tanto direto na tabela quanto pela view, e -- `has_column_privilege('authenticated','calendar_external_events','title','SELECT')` -- respondia `true`. -- -- ## O alcance real: o privilégio estava aberto; o nome, quase nunca -- -- Numa instalação v1.17.0 ou mais nova o sincronizador grava o título nulo (ver -- "O que este bloco NÃO faz"). O nome só existe em linhas gravadas pelo cron -- anterior à v1.17.0 e ainda não regravadas: o rebuild completo, a cada 24h, -- regrava de 1 dia atrás a 90 dias à frente; o passado espera -- `fn_expurgar_espelho_da_agenda` (por padrão 90 dias depois de `ends_at`); e a -- agenda que o sincronizador não lê não é regravada, futuro inclusive — conexão -- que não está saudável, membro revogado e agenda fora do catálogo do Google (a -- reserva é recusada ou não sai, medido no invariante), e agenda desmarcada, que -- o cron adia sem ler. Dentro da janela, o evento CANCELADO escapa do rebuild: o -- `page` final apaga o não visto com `and status<>'cancelled'`, e a leitura -- completa do Google não devolve cancelados — um cancelado FUTURO guarda o nome -- até o expurgo (medido no invariante). O conserto fecha esse resíduo e vale como -- defesa em profundidade contra um escritor futuro. -- -- ## Por que o conserto é no PRIVILÉGIO — e o que a policy fecharia -- -- O que o CRM usa de um evento do Google é ocupado/livre (`starts_at`, `ends_at`, -- `transparency`, `status`); o título não tem consumidor nenhum na tela, vigiado -- por `tests/unit/ocupacao-do-google-nao-expoe-titulo.test.ts` (leituras pela -- tabela ou pela view) e por `tests/e2e/agenda-ocupacao-do-google-na-grade.spec.ts`. -- -- Então o SELECT de `authenticated` sai da TABELA e volta COLUNA A COLUNA, sem -- `title`. Revogar coluna sem revogar a tabela não faz nada: o privilégio de TABELA -- cobre todas as colunas, e é ele que o default ACL de tabelas concede. -- -- A policy de leitura segue sendo da ORGANIZAÇÃO, e este bloco não a toca — mas -- não porque "a grade da equipe mostra a ocupação do colega", como uma versão -- anterior dizia. As duas leituras de tela (`app/app/agenda/page.tsx` e -- `app/api/v1/agenda/agendamentos/route.ts`) pedem a view pela sessão com o embed -- `calendar_connections!inner(user_id)`, e a RLS da conexão (dono OU manager ou -- acima) tira a linha do colega de quem não é gestor: para Somente leitura e -- Atendente a grade de hoje JÁ não mostra essa ocupação (issue #879). Quem a -- entrega a todo membro é `fn_agenda_ocupacao_google_do_dono` (0260), `security -- definer`, que policy nenhuma alcança. Medido numa transação desfeita com a policy -- trocada por "dono da conexão OU manager ou acima": não-gestor com 0 linha na -- tabela, na view e na tela, a função da 0260 com a ocupação, dono e gestor com a -- tela inteira. É o fechamento mais barato do que fica aberto abaixo, sem mudar -- leitura nenhuma; muda QUEM lê o espelho, então é decisão do dono. -- -- ## O que continua ao alcance do membro, e por quê -- -- O título NÃO é o único dado pessoal do espelho. `external_calendar_id` é o `id` -- do CalendarList do Google (`fn_google_catalog` grava `it->>'id'`), e na agenda -- PRINCIPAL — a que conta por padrão — esse id é o e-mail da conta conectada. A -- RLS de `calendar_connections` esconde essa conta de um colega que não é gestor; -- esta tabela e a view a entregam a todo membro. `external_event_id` também segue -- concedido, e `ical_uid` — que não é id do Google: é o UID RFC 5545 gerado pelo -- sistema de quem criou o evento (`lib/agenda/google/evento.ts`) — é resíduo do -- mesmo período do `title` (só o cron anterior à v1.17.0 o gravava) e, ao -- contrário dele, a ressincronização NÃO o limpa: o `on conflict` de -- `fn_google_calendar` não o põe no `set` (medido no invariante). Fica aberto, por -- escrito. Revogar a COLUNA não serve: a view é `security_invoker` e passa a -- coluna a `fn_google_counts_for_conflicts`, então revogá-la derruba TODA leitura -- da view por membro, a do dono inclusive (medido). O que fecha é a policy "dono -- da conexão OU manager ou acima" da seção anterior, sem tocar em tela nem em rota -- — e o gestor já lê `account_email` em `calendar_connections`. Decisão do dono. O -- invariante mede que o colega segue lendo o id. -- -- ## A view só é recriada quando ainda está na forma antiga -- -- `calendar_selected_external_events` era `select e.*`. Com `security_invoker`, o -- Postgres confere privilégio de coluna EM NOME DO INVOCADOR para toda coluna -- referenciada na definição — inclusive as de um `e.*` que já foi expandido quando -- a view nasceu. Deixá-la assim faria TODA leitura de ocupação por membro falhar -- com `permission denied` no `title`. E `create or replace view` não tira coluna -- do meio (o Postgres recusa: "cannot drop columns from view"). -- -- Por isso o `drop` daqui é CONDICIONAL (issue #1086): quem ainda tem o `title` — -- a forma da v1.26.0 — cai no `drop` e é recriado; quem já está na forma alvo -- passa direto pelo `create or replace`, que PRESERVA o OID. Derrubar e recriar -- a view a cada passada deste arquivo era o defeito da issue: o que quebrava a -- segunda passada era o `create view` sobre o objeto existente, não a falta do -- `drop`. A lista explícita segue sendo o conserto de fundo: `e.*` era a forma de -- a próxima coluna nascer exposta, e ela anda junto com a lista da guarda. -- -- ## O que este bloco NÃO faz, de propósito -- -- * Não apaga os títulos que sobraram de sincronizações anteriores à v1.17.0, nem -- os `ical_uid` do mesmo período. Desde a 0225 o sincronizador grava `title` -- nulo (`fn_google_calendar`, ação `item`: `null` no insert e `set title=null` -- no `on conflict`, que zera o que encontra), mas a 0225 não anulou as linhas -- antigas. O que se fecha é a LEITURA por login de usuário — do colega e também -- do próprio dono, já que nenhuma tela o mostra. Anular o resíduo é decisão do -- dono, e sai em migration própria — não de carona num conserto de permissão. -- * Não toca em `service_role` nem no dono do banco. `service_role` mantém -- SELECT/UPDATE na coluna, mas nenhum caminho do produto os usa: o sincronizador -- grava pela `fn_google_calendar`, `security definer`, com o privilégio do dono -- dela (do `service_role` só usa o EXECUTE), e a desconexão usa DELETE e SELECT -- nas colunas do filtro. -- * Não impede, sozinho, que uma função leia o título: o grant de coluna fecha o -- LOGIN, e uma `security definer` (ou view sem `security_invoker`) lê com o -- privilégio do dono. Hoje nenhuma função nem view que `authenticated` ou `anon` -- alcance cita o título ou a linha inteira do espelho — a única que cita o -- título é `fn_google_calendar`, só `service_role`, para gravá-lo nulo —, e o -- invariante varre `pg_proc` e as views de `public` para que continue assim. -- * Não concede nada a `anon`, que continua sem privilégio nesta tabela desde a -- 0177 (`revoke all … from anon`). -- -- ## Para quem mexer depois -- -- * O grant é por LISTA de colunas: coluna nova no espelho nasce SEM SELECT para -- `authenticated`. É o lado seguro, e é uma decisão — o invariante reprova até -- alguém escrever se ela vai ao alcance do membro (entra no grant e na lista da -- view, que andam juntos, senão `select *` na view vira 42501) ou não. Estar no -- grant não quer dizer "não é pessoal": ver `external_calendar_id`, acima. -- * Quem LER esta view de dentro de função não pode usar `begin atomic`: a -- dependência registrada no catálogo impede o `drop view` condicional da guarda -- abaixo, que é o único caminho de quem ainda está na forma antiga (`e.*`). Hoje -- o único leitor é `fn_agenda_ocupacao_google_do_dono` (0260), `language sql` sem -- `begin atomic`. `fn_google_counts_for_conflicts` não é leitora — é a view que a -- chama, e essa direção não trava o `drop`. revoke select on public.calendar_external_events from authenticated; grant select ( id, organization_id, connection_id, external_calendar_id, external_event_id, starts_at, ends_at, is_all_day, status, transparency, external_updated_at, created_at, updated_at, ical_uid, seen_generation, recurring_event_id, original_start_time ) on public.calendar_external_events to authenticated; -- A MESMA guarda do bloco da reconciliação do Google (migration 0225), e -- repetida de propósito: este bloco é medido -- SOZINHO por `tests/invariants/titulo-do-evento-pessoal-fora-do-alcance.test.ts`, -- sobre o estado da v1.26.0 (view com `e.*`), então a forma antiga tem de ser -- curada aqui também, sem depender do que veio antes no arquivo. do $$ begin if exists ( select 1 from pg_attribute a join pg_class c on c.oid = a.attrelid join pg_namespace n on n.oid = c.relnamespace where n.nspname = 'public' and c.relname = 'calendar_selected_external_events' and c.relkind = 'v' and a.attnum > 0 and not a.attisdropped and a.attname not in ( 'id', 'organization_id', 'connection_id', 'external_calendar_id', 'external_event_id', 'starts_at', 'ends_at', 'is_all_day', 'status', 'transparency', 'external_updated_at', 'created_at', 'updated_at', 'ical_uid', 'seen_generation', 'recurring_event_id', 'original_start_time' ) ) then drop view if exists public.calendar_selected_external_events; end if; end $$; create or replace view public.calendar_selected_external_events with (security_invoker = true) as select e.id, e.organization_id, e.connection_id, e.external_calendar_id, e.external_event_id, e.starts_at, e.ends_at, e.is_all_day, e.status, e.transparency, e.external_updated_at, e.created_at, e.updated_at, e.ical_uid, e.seen_generation, e.recurring_event_id, e.original_start_time from public.calendar_external_events e where e.status <> 'cancelled' and public.fn_google_counts_for_conflicts(e.organization_id, e.connection_id, e.external_calendar_id); revoke all on public.calendar_selected_external_events from public, anon; grant select on public.calendar_selected_external_events to authenticated, service_role; notify pgrst, 'reload schema'; -- ---- cliente pela agenda (migration 0262) ---- -- -- Derivado de supabase/migrations/20260915180000_0262_cliente_pela_agenda.sql (a -- partir da seção 1; o porquê inteiro está no cabeçalho de lá). Contribuição de -- @423313 (PR #867), com os ajustes da decisão do dono: regra desligada por -- organização, cancelado/falta não contam, a etiqueta tem dono (o sistema só -- tira a que pôs e só repõe a que tirou), e contact.tag_added sai uma vez por -- contato. A seção 7 redefine `fn_mesclar_contatos` para pegar a trava da -- organização antes dos contatos. -- -- Idempotente e auto-curativo: add column/create index if not exists, create or -- replace function, drop trigger if exists. NENHUM backfill de classificação: o -- update.sh de quem já roda não etiqueta nenhum contato — o histórico só é -- classificado quando um administrador liga a regra -- (fn_definir_cliente_pela_agenda). O único UPDATE de dados (seção 1) carimba -- `client_recognized_at` em quem tem `first_service_at` sem carimbo; numa -- instalação que nunca teve a coluna, casa zero linhas. -- -- Nenhum dado a deduplicar antes do índice único novo: nenhuma linha nasce com -- is_client_pipeline = true. Nem antes do CHECK de client_tag_by_system: a -- coluna nasce junto com ele, toda null. -- -- ⚠️ ANTES do bloco da VARREDURA anon, depois do qual nenhuma função é criada. -- -- ──────────────────────────────────────────────────────────────────────────── -- 1 · o fato, no contato -- ──────────────────────────────────────────────────────────────────────────── alter table public.contacts add column if not exists first_service_at timestamptz; comment on column public.contacts.first_service_at is 'Quando a relação começou: o mais cedo entre marcar e o início do horário, entre os agendamentos que ' 'CONTAM (fn_situacao_conta_como_atendimento) — min(least(created_at, starts_at)). Histórico importado ' 'fica com a data passada; um horário marcado hoje para o mês que vem fica com hoje, nunca com data futura. ' 'Mantida pelos triggers de calendar_appointments (inserir, alterar, apagar) só enquanto ' 'organizations.settings.crm.cliente_pela_agenda = true; desligada, fica congelada e nenhuma TELA a ' 'mostra — o export de LGPD (lib/lgpd/export-collector.ts) e a API de contatos continuam levando o valor ' 'congelado, porque é dado guardado. Só o SISTEMA a grava: um BEFORE UPDATE recusa a escrita de sessão. ' 'Cancelar, marcar falta ou apagar o único horário que conta a devolve a null. Preservada na anonimização.'; alter table public.contacts add column if not exists client_recognized_at timestamptz; comment on column public.contacts.client_recognized_at is 'A PRIMEIRA vez que a regra cliente pela agenda reconheceu o contato como cliente: marcando, ao ligar a ' 'regra ou por junção de contatos. Nunca volta a null. É o que faz contact.tag_added sair uma vez por ' 'contato: quem já foi reconhecido não dispara as automações de novo ao voltar a marcar.'; alter table public.contacts add column if not exists client_tag_by_system text constraint contacts_client_tag_by_system_check check (client_tag_by_system in ('added', 'removed')); comment on column public.contacts.client_tag_by_system is 'De quem é a etiqueta cliente. added = o sistema pôs; removed = o sistema tirou a que ele mesmo pôs; ' 'null = o sistema nunca mexeu, ou a equipe assumiu (tirou a do sistema, ou pôs uma à mão). O sistema só ' 'tira a etiqueta que é dele e só repõe a que ele mesmo tirou. O que a equipe fez é lido NA HORA, pelo ' 'BEFORE UPDATE fn_colunas_de_cliente_sao_do_sistema — quem mexe na etiqueta sem gravar o dono na mesma ' 'escrita passa a ser o dono dela. Vocabulário só do banco: nenhum TypeScript lê ou grava.'; -- Auto-cura de banco que aplicou uma versão anterior desta migration: contato -- com data e sem carimbo seria tratado como "nunca reconhecido" e dispararia a -- automação ao voltar a marcar. Em instalação que nunca teve a coluna, zero -- linhas — `first_service_at` nasce null em todo contato. update public.contacts set client_recognized_at = now() where first_service_at is not null and client_recognized_at is null; create index if not exists contacts_clientes_idx on public.contacts (organization_id, first_service_at desc) where first_service_at is not null; -- ──────────────────────────────────────────────────────────────────────────── -- 2 · onde o cliente que volta a escrever entra -- ──────────────────────────────────────────────────────────────────────────── -- COLUNA, E NÃO CHAVE EM `crm_pipelines.settings` (do autor): papel do funil -- dentro da organização já mora em coluna (`is_default`, `is_archived`), e só -- com índice único quem cobra a exclusividade é o banco. alter table public.crm_pipelines add column if not exists is_client_pipeline boolean not null default false; comment on column public.crm_pipelines.is_client_pipeline is 'Onde nasce o negocio de quem JA e cliente (contacts.first_service_at nao nulo). ' 'So tem efeito com organizations.settings.crm.cliente_pela_agenda ligado. ' 'Espelha is_default: booleano, exclusivo por organizacao, com tela em /app/kanban. ' 'Ausente e estado VALIDO, e e o de toda instalacao nova: sem funil marcado, o ' 'cliente nasce no funil padrao. Um mesmo funil pode ser padrao E de clientes.'; -- Cópia literal da forma de `uniq_crm_pipelines_org_default`, que é -- `where (is_default = true)` — sem recorte de arquivado. create unique index if not exists uniq_crm_pipelines_org_client on public.crm_pipelines (organization_id) where (is_client_pipeline = true); -- ──────────────────────────────────────────────────────────────────────────── -- 3 · a régua: que situação de agendamento conta como atendimento -- ──────────────────────────────────────────────────────────────────────────── create or replace function public.fn_situacao_conta_como_atendimento(p_status text) returns boolean language sql immutable set search_path = public, pg_temp as $$ select p_status not in ('cancelled', 'no_show') $$; comment on function public.fn_situacao_conta_como_atendimento(text) is 'A agenda conta este status como atendimento? Espelho SQL de LIBERAM_O_HORARIO ' '(lib/agenda/ocupados.ts): o que libera o horário não faz cliente. Vigiado por ' 'tests/invariants/cliente-nasce-do-agendamento.test.ts, que compara com ' 'SITUACOES_QUE_OCUPAM para todo status do vocabulário.'; revoke execute on function public.fn_situacao_conta_como_atendimento(text) from public, anon, authenticated; -- ──────────────────────────────────────────────────────────────────────────── -- 4 · o recálculo de UM contato — a única régua de transição -- ──────────────────────────────────────────────────────────────────────────── -- Usado pelos triggers e pela ligação da regra. Devolve o que aconteceu, para -- quem liga poder contar. `p_emitir` diz se ESTA escrita pode ser a virada que -- as automações veem: o INSERT e a alteração de um horário podem; a ligação da -- regra, o repontamento de uma junção e o horário apagado não. create or replace function public.fn_recalcular_cliente_do_contato(p_org uuid, p_contact uuid, p_emitir boolean) returns text language plpgsql security definer set search_path = public, pg_temp as $$ declare c_etiqueta constant text := 'cliente'; v_antes timestamptz; v_tags text[]; v_reconhecido timestamptz; v_dono text; v_depois timestamptz; v_tem boolean; v_novas text[]; v_resultado text; begin -- TRAVA O CONTATO ANTES DE LER A AGENDA. Na ordem inversa, duas marcações -- simultâneas do mesmo contato gravam um min() velho por cima do certo: em -- READ COMMITTED o min() lido DEPOIS da trava enxerga a marcação concorrente -- que já commitou. -- -- `for no key update`, e não `for update`: é a trava que o UPDATE abaixo toma -- de qualquer jeito, e ela não conflita com o `for key share` que a FK de toda -- tabela que aponta para `contacts` toma num INSERT. Medido com `for update`: -- a ligação da regra (trava da organização, depois o contato) e um INSERT de -- agendamento (a FK trava o contato, depois o trigger espera a trava da -- organização) fechavam `deadlock detected`. -- -- Anonimizado e mesclado não recebem escrita derivada nova: sem esta guarda -- um agendamento posterior faria "Cliente Anonimizado #N" reaparecer -- etiquetado. select c.first_service_at, coalesce(c.tags, '{}'::text[]), c.client_recognized_at, c.client_tag_by_system into v_antes, v_tags, v_reconhecido, v_dono from public.contacts c where c.organization_id = p_org and c.id = p_contact and c.is_anonymized = false and c.is_merged_into is null for no key update; if not found then return 'ignorado'; end if; select min(least(a.created_at, a.starts_at)) into v_depois from public.calendar_appointments a where a.organization_id = p_org and a.contact_id = p_contact and public.fn_situacao_conta_como_atendimento(a.status); -- O caso comum — cliente antigo marcando a enésima hora — não escreve nada: -- `updated_at` não se move e o contato não vira ruído de realtime. if v_antes is not distinct from v_depois then return 'igual'; end if; v_tem := c_etiqueta = any(v_tags); -- REDE, e não mais a regra: quem lê o que a equipe fez é a guarda da seção -- 4b, na hora da escrita. Isto aqui alcança os dois casos que ela não vê — -- um banco que aplicou uma versão anterior desta migration (a etiqueta mudou -- de mão antes de a guarda existir) e uma restauração com -- `session_replication_role = replica`, que desliga trigger. if (v_dono = 'added' and not v_tem) or (v_dono = 'removed' and v_tem) then v_dono := null; end if; -- `array_append`/`array_remove` e não `||`: sem cast, o `||` lê o literal -- como ARRAY e morre em `malformed array literal` (medido pelo autor no CI). v_novas := v_tags; if v_antes is null then -- Virou cliente. A etiqueta entra se nunca foi reconhecido (a primeira vez) -- ou se foi o sistema que a tirou. Se a equipe a tirou, fica fora. if not v_tem and (v_reconhecido is null or v_dono = 'removed') then v_novas := array_append(v_tags, c_etiqueta); v_dono := 'added'; v_resultado := 'etiquetado'; else v_resultado := 'virou_cliente'; end if; elsif v_depois is null then -- Deixou de ser cliente. Só sai a etiqueta que é do sistema. if v_tem and v_dono = 'added' then v_novas := array_remove(v_tags, c_etiqueta); v_dono := 'removed'; v_resultado := 'desetiquetado'; else v_resultado := 'deixou_de_ser_cliente'; end if; else v_resultado := 'mudou_a_data'; end if; -- A ESCRITA SE ANUNCIA. `auth.uid()` continua preenchido aqui dentro — uma -- `security definer` troca o dono da função, nunca o JWT da sessão —, então -- sem um sinal explícito a guarda da seção 4b barraria o próprio sistema. A -- chave é de TRANSAÇÃO (`set_config(..., true)`) e volta a 'off' na linha -- seguinte: a janela é o UPDATE, não o resto da transação. perform set_config('deskcomm.cliente_pela_agenda', 'on', true); update public.contacts set first_service_at = v_depois, client_recognized_at = coalesce(v_reconhecido, case when v_depois is not null then now() end), client_tag_by_system = v_dono, tags = v_novas, updated_at = now() where organization_id = p_org and id = p_contact; perform set_config('deskcomm.cliente_pela_agenda', 'off', true); -- UMA VEZ POR CONTATO: só quando a etiqueta entra na primeira vez que a regra -- o reconhece. if v_resultado = 'etiquetado' and v_reconhecido is null and p_emitir then -- O MESMO formato que o app emite (app/api/v1/contacts/_handler.ts e -- lib/automation/actions/add-tag.ts): `added_tags` + `tags`. -- -- SEM `service_origin`: `emit_event` o carimba sozinho para -- contact.tag_added, e o recusaria (42501) vindo de sessão autenticada. -- SEM `caused_by_rule`: a automação TEM de ver este evento. -- Trigger nunca faz HTTP: a linha vai para event_log e o worker consome. perform public.emit_event( 'contact.tag_added', 'contact', p_contact, jsonb_build_object('added_tags', jsonb_build_array(c_etiqueta), 'tags', to_jsonb(v_novas)), jsonb_build_object('actor_type', 'system', 'actor_id', 'trg_agendamento_marca_cliente'), p_org ); end if; return v_resultado; end $$; revoke execute on function public.fn_recalcular_cliente_do_contato(uuid, uuid, boolean) from public, anon, authenticated; -- ──────────────────────────────────────────────────────────────────────────── -- 4b · as três colunas são do SISTEMA — e o dono da etiqueta é lido na escrita -- ──────────────────────────────────────────────────────────────────────────── -- DUAS COISAS NUMA FUNÇÃO SÓ, e a ordem entre elas é a razão de não serem dois -- triggers: BEFORE dispara por ordem ALFABÉTICA do nome, e a reconciliação -- GRAVA `client_tag_by_system` — vindo depois da guarda, ela mesma seria -- recusada. Aqui a guarda julga o que a ESCRITA trouxe, e só então o dono é -- reconciliado. -- -- (1) A GUARDA. As três colunas nascem com UPDATE para `authenticated` (o -- `ALTER DEFAULT PRIVILEGES … GRANT ALL ON TABLES` que todo projeto -- Supabase traz), e a única policy de escrita de `contacts` é cega a papel: -- `tenant_isolation_contacts_all` é `organization_id in fn_user_org_ids()`, -- sem `fn_role_at_least`. Medido num Postgres descartável, antes desta -- seção: `set local role authenticated` com o JWT de um `viewer` — o papel -- que a tela chama de "Somente leitura" — da PRÓPRIA organização gravava -- `first_service_at = '2019-01-01'` e devolvia `UPDATE 1`. Isso é "Cliente -- desde 2019" forjado; é o lead daquele contato passando a nascer no funil -- de clientes (`lib/leads/nascimento-do-lead.ts` lê exatamente essa -- coluna); e é `contact.tag_added` silenciado para sempre naquele contato, -- porque `client_recognized_at` nunca volta a null. O limite multi-tenant -- não caía — nada disso alcança outra organização —, mas dentro do tenant o -- papel mais fraco decidia roteamento. -- -- POR QUE TRIGGER E NÃO GRANT DE COLUNA, que é a forma da migration irmã -- (0261 faz `revoke select on table` + `grant select ()`): lá a -- tabela tem lista de colunas estável e o alvo é o SELECT. Aqui seria -- `revoke update on table contacts` + `grant update ()`, e -- toda coluna acrescentada a `contacts` depois disto nasceria NÃO-gravável -- por sessão nenhuma, em silêncio, até alguém lembrar de estender a lista. -- A recusa nomeada custa um trigger e não deixa esse rastro. -- -- `auth.uid() is null` PASSA de propósito: é o admin client (service role), -- que resolve a organização de fonte confiável, e é o caminho da -- anonimização de LGPD, dos importadores e das migrations. Quem é barrado é -- a SESSÃO — inclusive a de um admin, porque a coluna não é campo de ficha. -- -- (2) O DONO DA ETIQUETA. O sinal de "foi o sistema" é o próprio -- `client_tag_by_system` mudar na MESMA escrita, e é o que -- `fn_recalcular_cliente_do_contato` faz sempre: toda vez que ele mexe na -- etiqueta, grava o dono junto. Mudou a presença sem o dono mudar → foi a -- equipe (pela tela de Contatos, pela automação "adicionar tag", pela API), -- e a etiqueta passa a ser dela. Com o dono já nulo não há o que -- reconciliar, que é o caso da esmagadora maioria das edições de tag. -- -- A CHAVE `deskcomm.cliente_pela_agenda` é de transação e não é alcançável de -- fora: o PostgREST não envia SQL solto, e `set_config` mora em `pg_catalog`, -- fora do schema exposto. Ela existe porque `auth.uid()` continua preenchido -- dentro da `security definer` chamada pela sessão. create or replace function public.fn_colunas_de_cliente_sao_do_sistema() returns trigger language plpgsql set search_path = public, pg_temp as $$ declare c_etiqueta constant text := 'cliente'; begin if auth.uid() is not null and coalesce(current_setting('deskcomm.cliente_pela_agenda', true), '') <> 'on' and (old.first_service_at is distinct from new.first_service_at or old.client_recognized_at is distinct from new.client_recognized_at or old.client_tag_by_system is distinct from new.client_tag_by_system) then raise exception 'colunas_de_cliente_sao_do_sistema' using errcode = '42501'; end if; if new.client_tag_by_system is not null and old.client_tag_by_system is not distinct from new.client_tag_by_system and (c_etiqueta = any(coalesce(old.tags, '{}'::text[]))) is distinct from (c_etiqueta = any(coalesce(new.tags, '{}'::text[]))) then new.client_tag_by_system := null; end if; return new; end $$; comment on function public.fn_colunas_de_cliente_sao_do_sistema() is 'Guarda de contacts (migration 0262): sessão nenhuma grava first_service_at, client_recognized_at ou ' 'client_tag_by_system (42501 colunas_de_cliente_sao_do_sistema); o service role e as migrations passam. ' 'E quem mexe na etiqueta cliente sem gravar o dono na mesma escrita vira o dono dela, o que é como a ' 'remoção à mão passa a ser respeitada NA HORA. Provado em tests/invariants/cliente-nasce-do-agendamento.test.ts.'; -- Função de trigger não exige EXECUTE de quem dispara o UPDATE; revogar das -- duas origens (o grant a PUBLIC e o grant direto a `anon` do baseline) não -- quebra nada. revoke execute on function public.fn_colunas_de_cliente_sao_do_sistema() from public, anon, authenticated; -- `before update` sem lista de colunas, com a WHEN filtrando: a lista do -- `update of` dispara quando a coluna é MENCIONADA na escrita, mesmo sem mudar -- de valor — um `select *` que volta inteiro no UPDATE acordaria a guarda à toa. -- A WHEN compara VALORES, e o caso comum (nenhuma das quatro mudou) nem chama a -- função. drop trigger if exists trg_contato_colunas_de_cliente on public.contacts; create trigger trg_contato_colunas_de_cliente before update on public.contacts for each row when (old.first_service_at is distinct from new.first_service_at or old.client_recognized_at is distinct from new.client_recognized_at or old.client_tag_by_system is distinct from new.client_tag_by_system or old.tags is distinct from new.tags) execute function public.fn_colunas_de_cliente_sao_do_sistema(); -- ──────────────────────────────────────────────────────────────────────────── -- 5 · os triggers — condicionais ao interruptor, em INSERT, UPDATE e DELETE -- ──────────────────────────────────────────────────────────────────────────── -- Os nomes são os do PR (`fn_marcar_contato_como_cliente`, -- `trg_agendamento_marca_cliente`); o corpo é outro. -- -- A SERIALIZAÇÃO COM QUEM LIGA A REGRA É UM ADVISORY LOCK DA ORGANIZAÇÃO, e não -- uma trava de linha em `organizations`. O trigger toma a versão COMPARTILHADA -- (não espera ninguém a não ser a ligação); `fn_definir_cliente_pela_agenda` -- toma a EXCLUSIVA. Uma trava de linha (`for key share` aqui, `for update` lá) -- serializaria o mesmo par, mas o `for update` na linha da organização barra -- TODO insert com FK para ela enquanto o histórico é classificado — mensagem, -- event_log, auditoria — e a trava compartilhada de linha escreve na tupla da -- organização a cada alteração de agendamento, em toda organização, ligada ou -- não. O advisory serializa só as duas partes que precisam. create or replace function public.fn_marcar_contato_como_cliente() returns trigger language plpgsql security definer set search_path = public, pg_temp as $$ declare v_org uuid; v_ligado boolean; begin if tg_op = 'DELETE' then v_org := old.organization_id; else v_org := new.organization_id; end if; -- Espera a ligação em voo commitar. O SELECT abaixo é outro comando, então -- em READ COMMITTED tira snapshot novo e enxerga a chave já gravada. perform pg_advisory_xact_lock_shared(hashtextextended(v_org::text, 262)); -- Comparar com 'true'::jsonb nunca lança erro. Um `::boolean` abortaria a -- marcação do horário se alguém gravasse lixo na chave. select (o.settings -> 'crm' -> 'cliente_pela_agenda') = 'true'::jsonb into v_ligado from public.organizations o where o.id = v_org; if v_ligado is not true then return null; end if; if tg_op = 'INSERT' then perform public.fn_recalcular_cliente_do_contato(v_org, new.contact_id, true); elsif tg_op = 'UPDATE' then if new.contact_id is not null then -- O CONTATO DO HORÁRIO MUDOU — e a condição `is distinct from` tem DUAS -- causas, não uma. A primeira é o repontamento de `fn_mesclar_contatos` -- (X → Y): o horário só trocou de cadastro, e a escrita no vencedor não é -- a virada que as automações devem ver. A segunda é o PRIMEIRO vínculo de -- um horário que nasceu sem contato (null → Y), e esse é reconhecimento -- de verdade: é a primeira vez que este contato tem horário, e emite como -- um INSERT emitiria. Medido antes desta linha: no caminho null → Y o -- contato virava cliente, ganhava a etiqueta, ficava com -- `client_recognized_at` carimbado — e NENHUM `contact.tag_added` saía, -- nem ali nem nunca mais, porque o carimbo não volta a null. perform public.fn_recalcular_cliente_do_contato( v_org, new.contact_id, old.contact_id is not distinct from new.contact_id or old.contact_id is null); end if; if old.contact_id is not null and old.contact_id is distinct from new.contact_id then perform public.fn_recalcular_cliente_do_contato(v_org, old.contact_id, false); end if; else perform public.fn_recalcular_cliente_do_contato(v_org, old.contact_id, false); end if; return null; end $$; -- Função de trigger não exige EXECUTE de quem dispara o INSERT: revogar das -- DUAS origens (o grant a PUBLIC e o grant direto a `anon` do ALTER DEFAULT -- PRIVILEGES do baseline) não quebra nada. revoke execute on function public.fn_marcar_contato_como_cliente() from public, anon, authenticated; drop trigger if exists trg_agendamento_marca_cliente on public.calendar_appointments; create trigger trg_agendamento_marca_cliente after insert on public.calendar_appointments for each row when (new.contact_id is not null) execute function public.fn_marcar_contato_como_cliente(); drop trigger if exists trg_agendamento_recalcula_cliente on public.calendar_appointments; create trigger trg_agendamento_recalcula_cliente after update of status, starts_at, contact_id on public.calendar_appointments for each row when (old.status is distinct from new.status or old.starts_at is distinct from new.starts_at or old.contact_id is distinct from new.contact_id) execute function public.fn_marcar_contato_como_cliente(); -- Apagar o único horário que conta é o mesmo que cancelá-lo, para o contato. -- `contact_id` é `on delete restrict`, então este trigger nunca vê a cascata de -- um contato apagado; a de uma organização apagada chega aqui com a linha da -- organização já invisível, e o interruptor lê desligado. drop trigger if exists trg_agendamento_apagado_recalcula_cliente on public.calendar_appointments; create trigger trg_agendamento_apagado_recalcula_cliente after delete on public.calendar_appointments for each row when (old.contact_id is not null) execute function public.fn_marcar_contato_como_cliente(); -- ──────────────────────────────────────────────────────────────────────────── -- 6 · ligar e desligar — e classificar o histórico ao ligar -- ──────────────────────────────────────────────────────────────────────────── -- Chamada por app/actions/settings/definirClientePelaAgenda.ts com o client da -- SESSÃO: `auth.uid()` é o que permite conferir papel aqui dentro. Nunca pelo -- admin client, e nunca com `.from('organizations').update` — a única policy de -- escrita da tabela é de platform admin, e o UPDATE de um admin de tenant casa -- ZERO linhas e devolve sucesso. -- -- PAPEL `admin`, e não `manager` como a vizinha `fn_agenda_settings`: aquela é -- configuração reversível que não reescreve dado; ligar esta reescreve as -- etiquetas de todo contato com histórico, e desligar não desfaz. -- -- O HISTÓRICO É CLASSIFICADO SEM EVENTO. No estúdio medido pelo autor seriam -- 630 `contact.tag_added` de uma vez, e uma regra "Quando um contato ganhar uma -- tag" → enviar WhatsApp dispararia centenas de mensagens que ninguém pediu, -- contra a doutrina de anti-banimento. O rastro é UMA linha de auditoria (na -- action) com as contagens que esta função devolve. -- -- DESLIGAR só grava `false`: nenhum contato muda, `first_service_at` fica -- congelada e nenhuma TELA a mostra (o export de LGPD e a API de contatos -- continuam levando o valor congelado, porque é dado guardado). RELIGAR recalcula todos — quem virou cliente -- enquanto estava desligada ganha a etiqueta (sem evento: ao religar ele já era -- cliente), quem ficou sem horário que conte perde a etiqueta que o sistema -- tinha posto, e quem já tinha data não passa por virada. A etiqueta da equipe, -- posta ou tirada à mão, não se mexe em nenhum dos três casos. -- -- ⚠️ RELIGAR TIRA ETIQUETA, e a tela diz isso ANTES de confirmar -- (components/agenda/ClientePelaAgenda.tsx) — `perderam_etiqueta` existe por isso. create or replace function public.fn_definir_cliente_pela_agenda(p_org uuid, p_ligado boolean) returns jsonb language plpgsql security definer set search_path = public, pg_temp as $$ declare v_settings jsonb; v_antes boolean; v_contato uuid; v_r text; v_ganharam integer := 0; v_perderam integer := 0; begin if auth.uid() is null or p_org is null or p_ligado is null or not public.fn_role_at_least(p_org, 'admin') or not public.fn_support_write_allowed(p_org) then raise exception 'cliente_pela_agenda_forbidden' using errcode = '42501'; end if; if not public.fn_session_mfa_proven() then raise exception 'cliente_pela_agenda_mfa_required' using errcode = '42501'; end if; -- EXCLUSIVA, ANTES de ler qualquer coisa: espera todo INSERT/UPDATE de -- agendamento desta organização que já passou pelo trigger (e segura a -- compartilhada até commitar), e faz os seguintes esperarem esta transação. -- Os comandos abaixo tiram snapshot novo e enxergam o que já commitou. perform pg_advisory_xact_lock(hashtextextended(p_org::text, 262)); -- Sem `for update` na linha da organização: a exclusiva acima já serializa -- esta função consigo mesma e com o trigger, e a trava de linha barraria todo -- insert com FK para a organização durante o laço. O UPDATE abaixo toma só a -- trava que não conflita com essas FKs. select o.settings into v_settings from public.organizations o where o.id = p_org; if not found then raise exception 'organization_not_found' using errcode = 'P0002'; end if; v_antes := (v_settings -> 'crm' -> 'cliente_pela_agenda') = 'true'::jsonb; -- Mescla dentro de `crm`: o que mais morar ali (hoje nada) não é apagado, e -- um `crm` que não seja objeto é substituído em vez de abortar. update public.organizations set settings = jsonb_set( coalesce(settings, '{}'::jsonb), '{crm}', (case when jsonb_typeof(settings -> 'crm') = 'object' then settings -> 'crm' else '{}'::jsonb end) || jsonb_build_object('cliente_pela_agenda', p_ligado), true) where id = p_org; -- O histórico, SÓ na virada desligado → ligado, SÓ desta organização. if p_ligado and v_antes is not true then for v_contato in select c.id from public.contacts c where c.organization_id = p_org and c.is_anonymized = false and c.is_merged_into is null and (c.first_service_at is not null or exists (select 1 from public.calendar_appointments a where a.organization_id = p_org and a.contact_id = c.id)) order by c.id loop v_r := public.fn_recalcular_cliente_do_contato(p_org, v_contato, false); if v_r = 'etiquetado' then v_ganharam := v_ganharam + 1; elsif v_r = 'desetiquetado' then v_perderam := v_perderam + 1; end if; end loop; end if; -- O QUARTO NÚMERO EXISTE PARA A TELA NÃO MENTIR. Medido: numa organização -- cujo único contato TEM horário marcado, todos cancelados, o corpo era -- `{ganharam: 0, perderam: 0, clientes: 0}` — e a última frase de -- `components/agenda/ClientePelaAgenda.tsx` dizia "Nenhum contato tinha -- horário marcado ainda". Numa clínica com cancelamentos, que é o nicho que -- esta migration cita, essa é a primeira frase depois de ligar. Zero -- etiquetas novas tem QUATRO causas, e esta é a única que os outros três -- números não distinguem. return jsonb_build_object( 'ligado', p_ligado, 'mudou', coalesce(v_antes, false) <> p_ligado, 'ganharam_etiqueta', v_ganharam, 'perderam_etiqueta', v_perderam, 'clientes', (select count(*) from public.contacts where organization_id = p_org and first_service_at is not null and is_anonymized = false and is_merged_into is null), 'com_agendamento_que_nao_conta', ( select count(*) from public.contacts c where c.organization_id = p_org and c.first_service_at is null and c.is_anonymized = false and c.is_merged_into is null and exists (select 1 from public.calendar_appointments a where a.organization_id = p_org and a.contact_id = c.id)) ); end $$; revoke execute on function public.fn_definir_cliente_pela_agenda(uuid, boolean) from public, anon; grant execute on function public.fn_definir_cliente_pela_agenda(uuid, boolean) to authenticated; -- ──────────────────────────────────────────────────────────────────────────── -- 7 · a junção de contatos pega a trava da organização primeiro -- ──────────────────────────────────────────────────────────────────────────── -- Cópia de `fn_mesclar_contatos` como está em vigor (migration 0222, a última a -- redefini-la), com UMA mudança: a linha do `pg_advisory_xact_lock_shared` antes -- do mutex dos atendimentos. O porquê está no comentário ao lado dela. CREATE OR REPLACE FUNCTION public.fn_mesclar_contatos(p_organization_id uuid, p_contato_principal uuid, p_contatos_secundarios uuid[]) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $function$ declare v_principal public.contacts%rowtype; v_esperado integer; v_achado integer; v_alvo record; v_linha record; v_movidas integer; v_pulados integer; v_repontado jsonb := '{}'::jsonb; v_nao_repontado jsonb := '{}'::jsonb; v_nome text; v_apelido text; v_nascimento date; v_email text; v_telefone text; v_lid text; v_tags text[]; v_leads integer := 0; v_service_contact uuid; begin if not public.fn_support_write_allowed(p_organization_id) then raise exception 'support_readonly' using errcode='42501'; end if; -- 1 · Autorização. Fundir é destrutivo na prática: `manager`, o mesmo piso das -- policies de `merge_queue`. Sessão de service role (auth.uid() nulo) não -- passa por aqui — quem resolve a org nesse caminho é a rota, de fonte -- confiável, nunca do body. if auth.uid() is not null and not public.fn_role_at_least(p_organization_id, 'manager') then raise exception using errcode = '42501', message = 'insufficient_role'; end if; if p_contato_principal is null or p_contatos_secundarios is null or cardinality(p_contatos_secundarios) = 0 or p_contato_principal = any(p_contatos_secundarios) then raise exception using errcode = '22023', message = 'selecao_de_mesclagem_invalida'; end if; select count(distinct id)::integer into v_esperado from unnest(p_contatos_secundarios) as ids(id); if v_esperado <> cardinality(p_contatos_secundarios) then raise exception using errcode = '22023', message = 'secundario_repetido'; end if; -- A TRAVA DA REGRA "CLIENTES PELA AGENDA" (migration 0262), ANTES DE TODA -- OUTRA. O passo 5 reponta `calendar_appointments.contact_id`, e o trigger -- desse repontamento pede `pg_advisory_xact_lock_shared(org, 262)` — só que -- a esta altura a fusão já segura os contatos (passos 2 e 3). -- `fn_definir_cliente_pela_agenda` pega a mesma trava EXCLUSIVA e depois -- trava contato por contato. Medido com duas sessões, sem esta linha: a fusão -- morria em `deadlock detected` e a rota devolvia 500. Aqui a ordem fica a -- mesma das duas funções — a organização primeiro, os contatos depois. Duas -- fusões, ou uma fusão e uma marcação, pegam a versão compartilhada e não se -- esperam. perform pg_catalog.pg_advisory_xact_lock_shared(pg_catalog.hashtextextended(p_organization_id::text, 262)); -- Mesmo mutex dos atendimentos, ANTES de qualquer row lock. for v_service_contact in select distinct id from unnest(array[p_contato_principal]||p_contatos_secundarios) ids(id) order by id loop perform public.fn_service_lock(p_organization_id,v_service_contact); end loop; perform 1 from public.conversations where organization_id=p_organization_id and contact_id=any(array[p_contato_principal]||p_contatos_secundarios) order by id for no key update; -- Conversa colidente NÃO aborta a fusão. Duas conversas no mesmo -- `channel_session_id` é exatamente COMO a duplicata de WhatsApp nasce (dois -- cadastros, dois números, o mesmo número de atendimento), então recusar aqui -- fecharia o caminho dominante do recurso — medido: o caso ordinário do -- `tests/e2e/juntar-contatos-duplicados.spec.ts` virava 409. -- Quem trata a colisão é o passo 5: `uniq_conversations_1to1_per_contact_session` -- levanta unique_violation, o repontamento cai para linha a linha, a conversa -- que não coube FICA na lápide e sai contada em `nao_repontado` — que a rota -- devolve e a tela anuncia ("N registro(s) continuaram no cadastro antigo"). -- Mensagem não se perde: `messages.contact_id` não tem índice único por -- contato e passa inteira para o vencedor. -- 2 · O principal existe, é desta org, está vivo — e trava até o fim. select * into v_principal from public.contacts where id = p_contato_principal and organization_id = p_organization_id and is_merged_into is null and is_anonymized = false for update; if not found then raise exception using errcode = 'P0002', message = 'contato_principal_indisponivel'; end if; -- 3 · Os secundários também. `is_anonymized = false` não é zelo: L-04 é -- irreversível, e reencaixar a linha anonimizada num contato ativo a -- traria de volta ao atendimento pela porta dos fundos. perform 1 from public.contacts where id = any(p_contatos_secundarios) and organization_id = p_organization_id and is_merged_into is null and is_anonymized = false for update; get diagnostics v_achado = row_count; if v_achado <> v_esperado then raise exception using errcode = 'P0002', message = 'contato_secundario_indisponivel'; end if; -- 4 · A LÁPIDE VEM ANTES de tudo. É ela que solta telefone/e-mail/CPF dos -- índices únicos parciais para o vencedor poder herdá-los no passo 6. update public.contacts set is_merged_into = p_contato_principal, merged_at = now(), updated_at = now() where organization_id = p_organization_id and id = any(p_contatos_secundarios); -- Cadeia: quem já tinha sido mesclado NUM dos secundários passa a apontar para -- o vencedor. Sem isto, `is_merged_into` vira uma corrente que a leitura teria -- de percorrer, e ninguém percorre. update public.contacts set is_merged_into = p_contato_principal where organization_id = p_organization_id and is_merged_into = any(p_contatos_secundarios); -- 5 · Reponta TODO ponteiro para os perdedores. A lista sai do catálogo; o -- polimórfico entra à mão porque catálogo nenhum o conhece. for v_alvo in select n.nspname as esquema, c.relname as tabela, a.attname as coluna, ''::text as filtro from pg_catalog.pg_constraint co join pg_catalog.pg_class c on c.oid = co.conrelid join pg_catalog.pg_namespace n on n.oid = c.relnamespace join pg_catalog.pg_attribute a on a.attrelid = co.conrelid and a.attnum = co.conkey[1] where co.contype = 'f' and co.confrelid = 'public.contacts'::regclass and co.conrelid <> 'public.contacts'::regclass and array_length(co.conkey, 1) = 1 and c.relkind = 'r' and n.nspname = 'public' union all select 'public', 'crm_lead_links', 'target_id', ' and target_kind = ''contact''' where to_regclass('public.crm_lead_links') is not null order by 2, 3 loop v_pulados := 0; begin execute format( 'update %I.%I set %I = $1 where %I = any($2)%s', v_alvo.esquema, v_alvo.tabela, v_alvo.coluna, v_alvo.coluna, v_alvo.filtro ) using p_contato_principal, p_contatos_secundarios; get diagnostics v_movidas = row_count; exception when unique_violation or exclusion_violation then -- Colisão REAL e esperada: `uniq_job_queue_one_running_per_contact` deixa -- um job 'running' por contato, e os dois lados podem ter um. Em vez de -- abortar a fusão inteira por causa de estado efêmero de runtime, reponta -- linha a linha e conta quem ficou. Quem fica NÃO vira FK órfã — continua -- apontando para a lápide, que existe. v_movidas := 0; for v_linha in execute format( 'select ctid as tid from %I.%I where %I = any($1)%s', v_alvo.esquema, v_alvo.tabela, v_alvo.coluna, v_alvo.filtro ) using p_contatos_secundarios loop begin execute format( 'update %I.%I set %I = $1 where ctid = $2', v_alvo.esquema, v_alvo.tabela, v_alvo.coluna ) using p_contato_principal, v_linha.tid; v_movidas := v_movidas + 1; exception when unique_violation or exclusion_violation then v_pulados := v_pulados + 1; end; end loop; end; if v_movidas > 0 then v_repontado := v_repontado || jsonb_build_object(v_alvo.tabela || '.' || v_alvo.coluna, v_movidas); end if; if v_pulados > 0 then v_nao_repontado := v_nao_repontado || jsonb_build_object(v_alvo.tabela || '.' || v_alvo.coluna, v_pulados); end if; end loop; -- 6 · O principal MANDA; o que ele não tem, vem dos perdedores. Nunca o -- contrário: sobrescrever o que o atendente digitou seria fusão com -- surpresa, e fusão não tem desfazer. select c.name into v_nome from public.contacts c where c.id = any(p_contatos_secundarios) and c.name is not null order by c.created_at, c.id limit 1; select c.display_name into v_apelido from public.contacts c where c.id = any(p_contatos_secundarios) and c.display_name is not null order by c.created_at, c.id limit 1; select c.birthdate into v_nascimento from public.contacts c where c.id = any(p_contatos_secundarios) and c.birthdate is not null order by c.created_at, c.id limit 1; select c.email into v_email from public.contacts c where c.id = any(p_contatos_secundarios) and c.email is not null order by c.created_at, c.id limit 1; select c.phone_number into v_telefone from public.contacts c where c.id = any(p_contatos_secundarios) and c.phone_number is not null order by c.created_at, c.id limit 1; -- `wa_identity`/`wa_lid` são GERADAS: o que se herda é a origem delas. Sem -- isto o WhatsApp do perdedor fica órfão — `fn_upsert_wa_contact` filtra -- `is_merged_into is null`, não acharia mais ninguém e criaria um contato -- novo na mensagem seguinte, refazendo a duplicata que acabou de ser desfeita. select c.source_metadata->>'waha_lid' into v_lid from public.contacts c where c.id = any(p_contatos_secundarios) and c.source_metadata->>'waha_lid' is not null order by c.created_at, c.id limit 1; -- Guardas de unicidade. A lápide já tirou os perdedores dos índices parciais, -- então o que sobrar aqui é conflito com um TERCEIRO contato vivo — e nesse -- caso o vencedor simplesmente não herda o campo. Falhar a fusão inteira por -- causa de um e-mail seria perder o repontamento que já valeu a pena. if v_email is not null and exists ( select 1 from public.contacts o where o.organization_id = p_organization_id and o.is_merged_into is null and o.id <> p_contato_principal and o.email_normalized = lower(btrim(v_email)) ) then v_email := null; end if; if v_telefone is not null and exists ( select 1 from public.contacts o where o.organization_id = p_organization_id and o.is_merged_into is null and o.id <> p_contato_principal and o.phone_number = v_telefone ) then v_telefone := null; end if; if v_lid is not null and exists ( select 1 from public.contacts o where o.organization_id = p_organization_id and o.is_merged_into is null and o.id <> p_contato_principal and o.wa_lid = v_lid ) then v_lid := null; end if; select coalesce(array_agg(distinct t), '{}'::text[]) into v_tags from ( select unnest(c.tags) as t from public.contacts c where c.organization_id = p_organization_id and (c.id = p_contato_principal or c.id = any(p_contatos_secundarios)) ) as todas; -- CPF e `consent` NÃO são herdados, de propósito. CPF é um PAR -- (`cpf_encrypted` + `cpf_hash`) preso por check constraint e criptografado -- com a chave da instalação — mover metade quebra a linha. `consent` é -- registro legal do que AQUELA pessoa autorizou; herdar um "granted_at" de -- outro cadastro fabricaria consentimento. Falha fechada nos dois. update public.contacts set name = coalesce(name, v_nome), display_name = coalesce(display_name, v_apelido), birthdate = coalesce(birthdate, v_nascimento), email = coalesce(email, v_email), phone_number = coalesce(phone_number, v_telefone), tags = v_tags, last_activity_at = greatest( last_activity_at, (select max(c.last_activity_at) from public.contacts c where c.id = any(p_contatos_secundarios)) ), source_metadata = ( case when source_metadata->>'waha_lid' is null and v_lid is not null then source_metadata || jsonb_build_object('waha_lid', v_lid) else source_metadata end ) - case when coalesce(phone_number, v_telefone) is not null then 'telefone_em_conflito' else '' end || jsonb_build_object( 'mesclado_de', coalesce(source_metadata->'mesclado_de', '[]'::jsonb) || to_jsonb(p_contatos_secundarios), 'mesclado_em', to_jsonb(now()) ), updated_at = now() where id = p_contato_principal and organization_id = p_organization_id; -- 7 · A fusão aparece na timeline de cada negócio que o vencedor passou a ter. -- `crm_lead_activities.lead_id` é NOT NULL — contato sem negócio nenhum -- não tem onde escrever, e para esse caso quem guarda o rastro é o -- `api_audit_log` que a rota emite, sempre. insert into public.crm_lead_activities (organization_id, lead_id, contact_id, source_module, source_id, type, payload, metadata, performed_at, performed_by_user_id) select p_organization_id, l.id, p_contato_principal, 'crm', p_contato_principal, 'contacts_merged', jsonb_build_object( 'contatos_mesclados', to_jsonb(p_contatos_secundarios), 'repontado', v_repontado, 'nao_repontado', v_nao_repontado ), '{}'::jsonb, now(), auth.uid() from public.crm_leads l where l.organization_id = p_organization_id and l.contact_id = p_contato_principal; get diagnostics v_leads = row_count; return jsonb_build_object( 'contato_id', p_contato_principal, 'contatos_mesclados', to_jsonb(p_contatos_secundarios), 'repontado', v_repontado, 'nao_repontado', v_nao_repontado, 'atividades_emitidas', v_leads ); end; $function$; revoke execute on function public.fn_mesclar_contatos(uuid, uuid, uuid[]) from public, anon; grant execute on function public.fn_mesclar_contatos(uuid, uuid, uuid[]) to authenticated, service_role; notify pgrst, 'reload schema'; -- ---- vocabulario de tags da organizacao (migration 0264) ---- -- O vocabulario de etiquetas deixa de ser so de LEITURA: alem de listar (esta -- funcao ja existia na 0244 para conversas), a organizacao passa a poder -- RENOMEAR, JUNTAR e EXCLUIR a etiqueta, com o uso por tabela na frente e as -- regras `add_tag` dos agentes corrigidas NA MESMA transacao. Sem isso, renomear -- deixa o agente escrevendo a grafia velha e a etiqueta volta como fantasma. -- Idempotente por construcao: `create or replace` + `revoke`/`grant` nas duas -- origens de EXECUTE. create or replace function public.fn_vocabulario_de_tags(p_org uuid) returns table ( tag text, uso_em_contatos bigint, uso_em_leads bigint, uso_em_conversas bigint, em_regras bigint, cor text, descricao text, no_vocabulario boolean ) language sql stable security invoker set search_path = public as $$ with vocabulario as ( -- A organização pode guardar o vocabulário de dois jeitos, e os dois contam: -- `tags` (a lista com cor e descrição, vinda da tela) e -- `canonical_conversation_tags` (as sementes, que a 0244 já usava). select nullif(btrim(coalesce(entrada.valor ->> 'tag', entrada.valor #>> '{}')), '') as tag, nullif(btrim(coalesce(entrada.valor ->> 'cor', '')), '') as cor, nullif(btrim(coalesce(entrada.valor ->> 'descricao', '')), '') as descricao from public.organizations o cross join lateral jsonb_array_elements( case when jsonb_typeof(o.settings -> 'tags') = 'array' then o.settings -> 'tags' else '[]'::jsonb end ) as entrada(valor) where o.id = p_org union all select nullif(btrim(coalesce(semente #>> '{}', '')), ''), null, null from public.organizations o cross join lateral jsonb_array_elements( case when jsonb_typeof(o.settings -> 'canonical_conversation_tags') = 'array' then o.settings -> 'canonical_conversation_tags' else '[]'::jsonb end ) as semente(valor) where o.id = p_org ), vocabulario_limpo as ( -- Uma linha por nome canônico. Se a lista curada tem cor/descrição, ela vence -- a semente crua. select distinct on (lower(v.tag)) v.tag, v.cor, v.descricao from vocabulario v where v.tag is not null order by lower(v.tag), (v.cor is not null or v.descricao is not null) desc ), uso as ( select nullif(btrim(t.valor), '') as tag, 'contatos' as origem from public.contacts c, unnest(coalesce(c.tags, '{}'::text[])) as t(valor) where c.organization_id = p_org union all select nullif(btrim(t.valor), ''), 'leads' from public.crm_leads l, unnest(coalesce(l.tags, '{}'::text[])) as t(valor) where l.organization_id = p_org union all select nullif(btrim(t.valor), ''), 'conversas' from public.conversations v, unnest(coalesce(v.tags, '{}'::text[])) as t(valor) where v.organization_id = p_org ), uso_limpo as ( select u.tag, u.origem from uso u where u.tag is not null and u.tag <> '' ), regras as ( -- As ações `add_tag` dos agentes. É o que o operador NÃO via: a etiqueta -- podia ter zero conversas e ainda estar sendo escrita amanhã pela regra. -- `r.id` junto: a coluna "Regras de agente" da tela conta REGRAS, e a -- exclusão (mais abaixo) conta `distinct r.id`. Sem o id aqui, uma regra com -- duas ações `add_tag` da mesma etiqueta aparecia como "2" na lista e como -- "1" no resultado da operação — o mesmo rótulo contando coisas diferentes. select r.id as regra_id, nullif(btrim(etiqueta.valor #>> '{}'), '') as tag from public.automation_rules r cross join lateral jsonb_array_elements(coalesce(r.actions, '[]'::jsonb)) as acao(valor) cross join lateral jsonb_array_elements( case when jsonb_typeof(acao.valor -> 'config' -> 'tags') = 'array' then acao.valor -> 'config' -> 'tags' else '[]'::jsonb end ) as etiqueta(valor) where r.organization_id = p_org and acao.valor ->> 'type' = 'add_tag' ), regras_limpo as ( select r.regra_id, r.tag from regras r where r.tag is not null and r.tag <> '' ), bruto as ( select tag from vocabulario_limpo union all select tag from uso_limpo union all select tag from regras_limpo ), todas as ( select min(b.tag) as tag, lower(b.tag) as chave from bruto b where b.tag is not null group by lower(b.tag) ) select coalesce(v.tag, t.tag) as tag, (select count(*) from uso_limpo u where lower(u.tag) = t.chave and u.origem = 'contatos') as uso_em_contatos, (select count(*) from uso_limpo u where lower(u.tag) = t.chave and u.origem = 'leads') as uso_em_leads, (select count(*) from uso_limpo u where lower(u.tag) = t.chave and u.origem = 'conversas') as uso_em_conversas, (select count(distinct r.regra_id) from regras_limpo r where lower(r.tag) = t.chave) as em_regras, v.cor, v.descricao, (v.tag is not null) as no_vocabulario from todas t left join vocabulario_limpo v on lower(v.tag) = t.chave order by t.chave -- Teto, como na 0244: numa organização bagunçada a união cresce sem limite e -- isto vai para uma tela. limit 500; $$; -- ─── 2. o rename/junção/exclusão numa lista de etiquetas ───────────────────── create or replace function public.fn_tags_normalizar( p_tags text[], p_de text, p_para text, p_remover boolean ) returns text[] language sql immutable security invoker set search_path = public as $$ -- `distinct on` pela chave canônica DO RESULTADO, e não da entrada. -- -- ⚠️ Deduplicar pela entrada parece a mesma coisa e não é: no `juntar`, os dois -- nomes têm chaves DIFERENTES por definição (é o que os torna duas etiquetas), -- e depois da substituição viram o MESMO nome. Medido num Postgres real: -- `{VIP, obra}` juntando `obra` em `VIP` devolvia `{VIP, VIP}` — a etiqueta -- duplicada no array, e `fn_vocabulario_de_tags` conta OCORRÊNCIAS, então o -- registro passava a pesar 2 na própria tela que deveria arrumá-lo. O caso -- `{vip, VIP}` do teste passava por acidente: ali as duas chaves já eram -- iguais ANTES da substituição. select coalesce(array_agg(n.tag order by n.ord), '{}'::text[]) from ( select distinct on (lower(s.tag)) s.tag, s.ord from ( select case when p_remover then null when lower(btrim(e.valor)) = lower(btrim(coalesce(p_de, ''))) then btrim(p_para) else btrim(e.valor) end as tag, e.ord from unnest(coalesce(p_tags, '{}'::text[])) with ordinality as e(valor, ord) where btrim(coalesce(e.valor, '')) <> '' ) s where s.tag is not null and s.tag <> '' order by lower(s.tag), s.ord ) as n; $$; -- ─── 3. a operação: tudo numa transação, regra do agente incluída ──────────── create or replace function public.fn_vocabulario_de_tags_operar( p_org uuid, p_acao text, p_tag text, p_destino text, p_cor text default null ) returns jsonb language plpgsql volatile security definer set search_path = public as $$ declare v_tag text := btrim(coalesce(p_tag, '')); v_destino text := btrim(coalesce(p_destino, '')); -- A cor entra normalizada (minúscula, sem espaço). A rota valida com Zod antes; -- esta linha defende o caminho que NÃO passa por ela — RPC direta, psql, um -- cliente futuro. Sem isso, `#FFF` gravaria e a comparação por igualdade da -- tela (que compara o que o servidor devolveu) passaria a mentir. v_cor text := lower(btrim(coalesce(p_cor, ''))); v_remover boolean; v_so_cor boolean; v_contatos integer := 0; v_leads integer := 0; v_conversas integer := 0; v_regras integer := 0; v_id uuid; v_ids uuid[]; v_settings jsonb; v_antes jsonb; v_depois jsonb; v_definido boolean := false; begin -- Portão de papel ANTES de qualquer escrita. Definer com p_org vindo da rota: -- é esta linha que separa o tenant de quem chama. if p_org is null or not public.fn_role_at_least(p_org, 'manager') then raise exception using errcode = '42501', message = 'insufficient_role'; end if; if p_acao is null or p_acao not in ('renomear', 'juntar', 'excluir', 'definir_cor') then raise exception using errcode = '22023', message = 'acao_invalida'; end if; if v_tag = '' then raise exception using errcode = '22023', message = 'tag_obrigatoria'; end if; v_remover := (p_acao = 'excluir'); v_so_cor := (p_acao = 'definir_cor'); if not v_remover and not v_so_cor and v_destino = '' then raise exception using errcode = '22023', message = 'destino_obrigatorio'; end if; -- `v_cor` vazio é pedido legítimo ("sem cor"): limpa. O que não passa é cor -- malformada — gravar `#12` e devolver `#12` para a tela pintar deixaria o -- chip sem cor sem ninguém saber por quê. if v_so_cor and v_cor <> '' and v_cor !~ '^#[0-9a-f]{6}$' then raise exception using errcode = '22023', message = 'cor_invalida'; end if; -- ── POR QUE NÃO SAI EVENTO DAQUI ────────────────────────────────────────── -- -- Os laços abaixo CONTAM as linhas alteradas e não emitem nada em `event_log`. -- A primeira versão emitia `contact.tags_changed` / `lead.tags_changed` / -- `conversation.tags_changed` POR LINHA, e nenhum desses tipos tem consumidor: -- `lib/event-log/register-handlers.ts` registra 13 handlers e nenhum os -- declara; o motor de automação ouve `lead.tag_added`/`contact.tag_added`, que -- é outro tipo (e disparar automação num renomear em lote seria pior que não -- disparar). Evento sem consumidor é o anti-pattern 3 do CLAUDE.md, e aqui -- custava milhares de linhas dentro de UMA transação, num log que nada drena e -- nada expurga. -- -- Quem registra a operação é o AUDIT LOG, na borda: `tag_vocabulary.changed` -- em `app/api/v1/tags/vocabulario/route.ts`, com os contadores que este corpo -- devolve. E a tela aberta se atualiza pelo Realtime das próprias tabelas. -- ── (z) A COR SAI ANTES DOS LAÇOS, E NÃO É OTIMIZAÇÃO ───────────────────── -- -- Cor é atributo do VOCABULÁRIO, não das linhas: `contacts.tags`, -- `crm_leads.tags` e `conversations.tags` continuam `text[]` de nomes, porque -- automação, webhook (`lead.tag_added`) e MCP (`*.tags_changed`) falam em -- string há versões (contrato da fatia S4). Então a ação `definir_cor` não tem -- o que reescrever em contatos, leads nem conversas — e os laços abaixo, se -- rodassem, custariam uma varredura das três tabelas para devolver zero. -- -- O bloco de `canonical_conversation_tags` (mais abaixo) é pior que inútil -- aqui: ele troca o nome da semente por `v_destino` e descarta o que sobra -- vazio — com `destino` nulo nesta ação, a semente seria APAGADA. Daí o -- `return` cedo: nesta ação, só o vocabulário curado muda. if v_so_cor then select coalesce(o.settings, '{}'::jsonb) into v_settings from public.organizations o where o.id = p_org; if v_settings is null then v_settings := '{}'::jsonb; end if; -- (a) tolera `settings.tags` torto (escalar/objeto): a leitura já tolera com -- `jsonb_typeof`, e sem esta guarda o `jsonb_array_elements` levantava -- `cannot extract elements from a scalar` e derrubava a tela inteira numa -- organização com o dado malformado. Lista que não é lista é lista vazia. v_antes := case when jsonb_typeof(v_settings -> 'tags') = 'array' then v_settings -> 'tags' else '[]'::jsonb end; v_depois := coalesce( ( select jsonb_agg(entrada.valor order by entrada.ord) from ( -- Uma entrada por chave canônica, agora acrescentando a cor na que -- casar. A entrada que era string vira objeto — a mesma forma que o -- rename já grava (mais abaixo, `jsonb_build_object('tag', …)`) — e -- `descricao` que já existia é PRESERVADA: esta ação fala de cor. -- -- ⚠️ O desempate é o MESMO da função de leitura -- (`fn_vocabulario_de_tags`, `order by … (cor is not null or descricao -- is not null) desc`), e de propósito: onde a lista curada já tiver a -- mesma etiqueta duas vezes (uma como string, outra como objeto com -- cor), quem sobrevive é a entrada que carrega o metadado. Ordenar só -- por `ord` apagaria a cor na primeira vez que a ação rodasse sobre um -- vocabulário nesse estado, e a tela mostraria "sem cor" logo depois de -- alguém ter escolhido uma. select distinct on (lower(x.chave)) x.valor, x.ord from ( select btrim(coalesce(e.valor ->> 'tag', e.valor #>> '{}')) as chave, case when lower(btrim(coalesce(e.valor ->> 'tag', e.valor #>> '{}'))) = lower(v_tag) then case when nullif(v_cor, '') is null then (case when jsonb_typeof(e.valor) = 'string' then jsonb_build_object('tag', e.valor #>> '{}') else e.valor end) - 'cor' else jsonb_set( case when jsonb_typeof(e.valor) = 'string' then jsonb_build_object('tag', e.valor #>> '{}') else e.valor end, '{cor}', to_jsonb(v_cor)) end else case when jsonb_typeof(e.valor) = 'string' then jsonb_build_object('tag', e.valor #>> '{}') else e.valor end end as valor, e.ord from jsonb_array_elements(v_antes) with ordinality as e(valor, ord) where btrim(coalesce(e.valor ->> 'tag', e.valor #>> '{}')) <> '' ) x where x.valor is not null order by lower(x.chave), ((x.valor ->> 'cor') is not null or (x.valor ->> 'descricao') is not null) desc, x.ord ) as entrada ), '[]'::jsonb ); -- A etiqueta que ainda não tinha entrada no vocabulário curado — semente, ou -- nome que só existe em uso (`no_vocabulario = false` na leitura) — GANHA -- uma. É deliberado: dar cor é curar. Sem isto, a tela ofereceria cor para -- uma etiqueta que continuaria marcada como "em uso, fora do vocabulário", e -- a leitura devolveria a cor de uma linha que não está na lista curada. if nullif(v_cor, '') is not null and not exists ( select 1 from jsonb_array_elements(v_depois) as e(valor) where lower(btrim(coalesce(e.valor ->> 'tag', e.valor #>> '{}'))) = lower(v_tag) ) then v_depois := v_depois || jsonb_build_array(jsonb_build_object('tag', v_tag, 'cor', v_cor)); end if; if v_depois <> v_antes then v_settings := jsonb_set(v_settings, '{tags}', v_depois); update public.organizations o set settings = v_settings, updated_at = now() where o.id = p_org; v_definido := true; end if; return jsonb_build_object( 'acao', p_acao, 'tag', v_tag, 'destino', null, 'cor', nullif(v_cor, ''), 'contatos', 0, 'leads', 0, 'conversas', 0, 'regras', 0, 'alterou', v_definido ); end if; -- (a) contatos for v_id in with alvo as ( select c.id, public.fn_tags_normalizar(c.tags, v_tag, v_destino, v_remover) as novas from public.contacts c where c.organization_id = p_org and exists ( select 1 from unnest(coalesce(c.tags, '{}'::text[])) as x(valor) where lower(btrim(x.valor)) = lower(v_tag) ) ), mudou as ( update public.contacts c set tags = a.novas from alvo a where c.id = a.id and c.tags is distinct from a.novas returning c.id ) select id from mudou loop v_contatos := v_contatos + 1; end loop; -- (b) leads for v_id in with alvo as ( select l.id, public.fn_tags_normalizar(l.tags, v_tag, v_destino, v_remover) as novas from public.crm_leads l where l.organization_id = p_org and exists ( select 1 from unnest(coalesce(l.tags, '{}'::text[])) as x(valor) where lower(btrim(x.valor)) = lower(v_tag) ) ), mudou as ( update public.crm_leads l set tags = a.novas from alvo a where l.id = a.id and l.tags is distinct from a.novas returning l.id ) select id from mudou loop v_leads := v_leads + 1; end loop; -- (c) conversas for v_id in with alvo as ( select v.id, public.fn_tags_normalizar(v.tags, v_tag, v_destino, v_remover) as novas from public.conversations v where v.organization_id = p_org and exists ( select 1 from unnest(coalesce(v.tags, '{}'::text[])) as x(valor) where lower(btrim(x.valor)) = lower(v_tag) ) ), mudou as ( update public.conversations v set tags = a.novas from alvo a where v.id = a.id and v.tags is distinct from a.novas returning v.id ) select id from mudou loop v_conversas := v_conversas + 1; end loop; -- (d) as regras dos agentes — o ponto da issue. -- -- `excluir` NÃO apaga a regra: quem exclui a etiqueta é avisado de quantas -- regras a escrevem (o número volta no jsonb e a tela pede confirmação), mas -- apagar `add_tag` de um agente em produção é decisão de outra tela. Aqui a -- lista da regra só é reescrita quando o nome muda ou quando ele sai. select coalesce(o.settings, '{}'::jsonb) into v_settings from public.organizations o where o.id = p_org; if v_settings is null then v_settings := '{}'::jsonb; end if; if not v_remover then with alvo as ( select r.id, jsonb_agg( case when a.valor ->> 'type' = 'add_tag' and jsonb_typeof(a.valor -> 'config' -> 'tags') = 'array' then jsonb_set( a.valor, '{config,tags}', to_jsonb(public.fn_tags_normalizar( array(select jsonb_array_elements_text(a.valor -> 'config' -> 'tags')), v_tag, v_destino, false )) ) else a.valor end order by a.ord ) as novas from public.automation_rules r cross join lateral jsonb_array_elements(coalesce(r.actions, '[]'::jsonb)) with ordinality as a(valor, ord) where r.organization_id = p_org -- ⚠️ `group by r.id` E SÓ. Agrupar também pelo TIPO da ação devolvia uma -- linha por (regra, tipo), cada uma com `novas` = só o subconjunto daquele -- tipo; o `update ... from alvo` casava as duas linhas, o Postgres usava -- UMA arbitrária, e `is distinct from` é sempre verdadeiro num subconjunto -- — então a regra com ações de dois tipos era TRUNCADA a um tipo só, em -- toda organização, mesmo que ela nunca tenha citado a etiqueta renomeada. -- Medido num Postgres real: regra com `add_tag` + `assign_owner` ficava com -- uma ação, e a tela dizia "atualizada em 1 regra(s) de agente". group by r.id ), mudou as ( update public.automation_rules r set actions = alvo.novas, updated_at = now() from alvo where r.id = alvo.id and r.actions is distinct from alvo.novas returning r.id ) select count(*) into v_regras from mudou; else -- Exclusão: conta as regras que ainda escrevem a etiqueta, sem tocar nelas. select count(distinct r.id) into v_regras from public.automation_rules r cross join lateral jsonb_array_elements(coalesce(r.actions, '[]'::jsonb)) as a(valor) cross join lateral jsonb_array_elements( case when jsonb_typeof(a.valor -> 'config' -> 'tags') = 'array' then a.valor -> 'config' -> 'tags' else '[]'::jsonb end ) as e(valor) where r.organization_id = p_org and a.valor ->> 'type' = 'add_tag' and lower(btrim(e.valor #>> '{}')) = lower(v_tag); end if; -- (e) o vocabulário da organização, nos dois lugares onde ele mora. -- Mesma guarda do ramo de renomear: `settings.tags` malformado não pode -- derrubar a cor (a leitura tolera; a escrita agora também). v_antes := case when jsonb_typeof(v_settings -> 'tags') = 'array' then v_settings -> 'tags' else '[]'::jsonb end; v_depois := coalesce( ( select jsonb_agg(entrada.valor order by entrada.ord) from ( -- Dedupe pela chave DEPOIS da substituição (mesma razão de -- `fn_tags_normalizar`): juntar duas entradas de chaves diferentes num -- nome só deixava as duas no vocabulário, agora com o mesmo `tag`. -- -- ⚠️ `cor` e `descricao` da entrada sobrevivem ao rename: o `jsonb_set` -- mexe só em `{tag}`. Renomear não é perder a cor que alguém escolheu. select distinct on (lower(x.chave)) x.valor, x.ord from ( select case when v_remover then null when lower(btrim(coalesce(e.valor ->> 'tag', e.valor #>> '{}'))) = lower(v_tag) then v_destino else btrim(coalesce(e.valor ->> 'tag', e.valor #>> '{}')) end as chave, case when v_remover then null when lower(btrim(coalesce(e.valor ->> 'tag', e.valor #>> '{}'))) = lower(v_tag) then jsonb_set( case when jsonb_typeof(e.valor) = 'string' then jsonb_build_object('tag', e.valor #>> '{}') else e.valor end, '{tag}', to_jsonb(v_destino)) else case when jsonb_typeof(e.valor) = 'string' then jsonb_build_object('tag', e.valor #>> '{}') else e.valor end end as valor, e.ord from jsonb_array_elements(v_antes) with ordinality as e(valor, ord) where btrim(coalesce(e.valor ->> 'tag', e.valor #>> '{}')) <> '' ) x where x.valor is not null and coalesce(x.chave, '') <> '' order by lower(x.chave), x.ord ) as entrada where entrada.valor is not null ), '[]'::jsonb ); if v_depois <> v_antes then v_settings := jsonb_set(v_settings, '{tags}', v_depois); v_definido := true; end if; v_antes := coalesce(v_settings -> 'canonical_conversation_tags', '[]'::jsonb); v_depois := coalesce( ( select jsonb_agg(semente.valor order by semente.ord) from ( -- Dedupe pela chave DEPOIS da substituição, como acima. select distinct on (lower(y.valor)) y.valor, y.ord from ( select case when v_remover then null when lower(btrim(s.valor #>> '{}')) = lower(v_tag) then v_destino else btrim(s.valor #>> '{}') end as valor, s.ord from jsonb_array_elements(v_antes) with ordinality as s(valor, ord) where btrim(s.valor #>> '{}') <> '' ) y where coalesce(y.valor, '') <> '' order by lower(y.valor), y.ord ) as semente where semente.valor is not null ), '[]'::jsonb ); if v_depois <> v_antes then v_settings := jsonb_set(v_settings, '{canonical_conversation_tags}', v_depois); v_definido := true; end if; if v_definido then update public.organizations o set settings = v_settings, updated_at = now() where o.id = p_org; end if; return jsonb_build_object( 'acao', p_acao, 'tag', v_tag, 'destino', nullif(v_destino, ''), 'cor', null, 'contatos', v_contatos, 'leads', v_leads, 'conversas', v_conversas, 'regras', v_regras, 'alterou', (v_contatos + v_leads + v_conversas + v_regras > 0 or v_definido) ); end; $$; -- Função nova em `public` nasce EXPOSTA — as DUAS origens de EXECUTE (CLAUDE.md): -- (A) o `ALTER DEFAULT PRIVILEGES ... GRANT ALL ON FUNCTIONS TO anon` do baseline, -- que vale para toda função criada depois dele e que `revoke from public` NÃO -- remove; -- (B) o grant a PUBLIC que o Postgres dá a qualquer função ao criá-la, que -- `revoke from anon` NÃO remove. -- Tratar só uma deixa a função alcançável pela anon key, que vai para o browser. revoke execute on function public.fn_vocabulario_de_tags(uuid) from public, anon; grant execute on function public.fn_vocabulario_de_tags(uuid) to authenticated, service_role; revoke execute on function public.fn_tags_normalizar(text[], text, text, boolean) from public, anon; grant execute on function public.fn_tags_normalizar(text[], text, text, boolean) to authenticated, service_role; -- ⚠️ A partir da 0336 a assinatura é (uuid, text, text, text, text) — -- `p_cor text default null`. Revoke/grant com a assinatura ANTIGA não -- alcançam a função que existe. -- A de escrita é definer e volátil: `authenticated` chama pela sessão do usuário -- (POST app/api/v1/tags/vocabulario/route.ts, com createClient de cookie), e por -- isso está declarada em AUTHENTICATED_PERMITIDO no gate -- tests/invariants/hardening-definer-varredura.test.ts — a exceção nomeia o call -- site, não abre a porta. revoke execute on function public.fn_vocabulario_de_tags_operar(uuid, text, text, text, text) from public, anon; grant execute on function public.fn_vocabulario_de_tags_operar(uuid, text, text, text, text) to authenticated, service_role; -- ---- mensagem do lembrete no tipo (migration 0328) ---- -- O texto que o cron manda no WhatsApp passa a ser do MOLDE. NULL = a frase -- padrão ("Passando pra lembrar…"), o comportamento anterior. Distinto de -- reminder_template_name, que é o nome do template do provedor oficial. alter table public.calendar_event_types add column if not exists reminder_body text; comment on column public.calendar_event_types.reminder_body is 'Texto do lembrete no WhatsApp. NULL = a frase padrão do cron. Variáveis {{nome}}, {{titulo}}, {{dia}}, {{hora}}, {{endereco}}. Distinto de reminder_template_name, que é o nome do template aprovado no provedor oficial.'; -- ---- mensagem por lembrete (migration 0329) ---- -- Cada extra ganha texto próprio (`reminder_bodies`) e o teto de 3 extras -- sobe para 20. O CHECK antigo chama a função pelo nome: `create or replace` -- basta. Backfill copia reminder_body para cada extra que já existia, para -- a atualização não trocar o texto que o cliente já recebia. alter table public.calendar_event_types add column if not exists reminder_bodies jsonb not null default '{}'::jsonb; create or replace function public.fn_degraus_de_lembrete_validos(p_degraus integer[]) returns boolean language sql immutable as $$ select coalesce(array_length(p_degraus, 1), 0) <= 20 and coalesce(bool_and(x between 15 and 10080), true) from unnest(coalesce(p_degraus, '{}'::integer[])) as x; $$; revoke execute on function public.fn_degraus_de_lembrete_validos(integer[]) from public, anon; grant execute on function public.fn_degraus_de_lembrete_validos(integer[]) to authenticated, service_role; create or replace function public.fn_corpos_de_lembrete_validos(p_corpos jsonb) returns boolean language sql immutable as $$ select p_corpos is not null and jsonb_typeof(p_corpos) = 'object' and coalesce((select count(*) from jsonb_object_keys(p_corpos)), 0) <= 20 and coalesce(( select bool_and( e.key ~ '^[0-9]+$' and jsonb_typeof(e.value) = 'string' and length(e.value #>> '{}') <= 1000 ) from jsonb_each(p_corpos) as e ), true); $$; revoke execute on function public.fn_corpos_de_lembrete_validos(jsonb) from public, anon; grant execute on function public.fn_corpos_de_lembrete_validos(jsonb) to authenticated, service_role; update public.calendar_event_types set reminder_bodies = coalesce(( select jsonb_object_agg(x::text, reminder_body) from unnest(reminder_extra_offsets_minutes) as x ), '{}'::jsonb) where reminder_body is not null and length(trim(reminder_body)) > 0 and coalesce(array_length(reminder_extra_offsets_minutes, 1), 0) > 0 and reminder_bodies = '{}'::jsonb; do $$ begin if not exists ( select 1 from pg_constraint where conname = 'calendar_event_types_corpos_validos' and conrelid = 'public.calendar_event_types'::regclass ) then update public.calendar_event_types set reminder_bodies = '{}'::jsonb where not public.fn_corpos_de_lembrete_validos(reminder_bodies); alter table public.calendar_event_types add constraint calendar_event_types_corpos_validos check (public.fn_corpos_de_lembrete_validos(reminder_bodies)); end if; end $$; comment on column public.calendar_event_types.reminder_bodies is 'Texto de cada lembrete ADICIONAL, chave = minutos antes (string). Extra ausente do mapa usa a frase de fábrica do cron, não reminder_body. Vazio = nenhum extra tem texto próprio.'; -- ---- endereços salvos da agenda (migration 0330) ---- -- Lista da ORGANIZAÇÃO: salas e unidades que a equipe reusa ao marcar. -- Unique por (org, endereço normalizado). Escrita agent+; leitura de membro. create table if not exists public.calendar_locations ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, address text not null, created_by uuid references auth.users(id) on delete set null, created_at timestamptz not null default now(), constraint calendar_locations_endereco_tamanho check (char_length(btrim(address)) between 1 and 300) ); create unique index if not exists calendar_locations_org_endereco_key on public.calendar_locations (organization_id, lower(btrim(address))); comment on table public.calendar_locations is 'Endereços da ORGANIZAÇÃO reutilizáveis ao marcar. Não é o endereço de um contato: é o lugar onde se atende (sala, unidade). Unique por org + endereço normalizado.'; comment on column public.calendar_locations.address is 'Texto livre, 1–300 caracteres depois do trim. O mesmo teto de calendar_appointments.location_details.'; alter table public.calendar_locations enable row level security; drop policy if exists calendar_locations_select on public.calendar_locations; create policy calendar_locations_select on public.calendar_locations for select using ( (organization_id in (select public.fn_user_org_ids())) or public.fn_is_platform_admin() ); drop policy if exists calendar_locations_insert on public.calendar_locations; create policy calendar_locations_insert on public.calendar_locations for insert with check ( public.fn_is_platform_admin() or ((organization_id in (select public.fn_user_org_ids())) and public.fn_role_at_least(organization_id, 'agent')) ); revoke all on public.calendar_locations from anon; -- ---- a transferência entre funis não é perda comercial (migration 0266) ---- -- -- Entra ANTES do bloco da varredura anon, que é de propósito o último do arquivo -- (`tests/unit/varredura-anon-e-o-ultimo-bloco.test.ts`). Apêndice da 0266: o -- canônico `moved_to_another_pipeline` no trigger do motivo da perda e a -- transferência fora da contagem de perdas das duas métricas. create or replace function public.fn_validate_lost_reason_required() returns trigger language plpgsql set search_path to 'public', 'pg_temp' as $$ declare v_canonical text[] := array['requested_by_customer','price','no_response','product_unavailable', 'cancelled_by_store','cancelled_by_customer','payment_failed','other', 'moved_to_another_pipeline']; v_pipeline_extra text[]; begin if new.status = 'lost' then if new.lost_reason is null or length(new.lost_reason) = 0 then raise exception 'lost_reason_required' using errcode = '22023'; end if; -- #1537: `settings.lost_reasons` aceita texto puro E `{ label, categoria }`. -- O que o trigger compara é o RÓTULO nos dois formatos: `jsonb_array_elements_text` -- de um objeto devolveria o JSON inteiro e recusaria com 22023 um motivo que -- a própria tela acabou de oferecer. `#>> '{}'` desembrulha o string. select coalesce( array( select case when jsonb_typeof(e) = 'object' then nullif(e ->> 'label', '') else nullif(e #>> '{}', '') end from jsonb_array_elements(settings->'lost_reasons') as t(e) ), '{}'::text[] ) into v_pipeline_extra from public.crm_pipelines where id = new.pipeline_id; if not (new.lost_reason = any (v_canonical) or new.lost_reason = any (v_pipeline_extra)) then raise exception 'lost_reason_invalid: %', new.lost_reason using errcode = '22023'; end if; end if; return new; end$$; create or replace function public.fn_atrito_metrics( p_org uuid, p_from timestamptz, p_to timestamptz, p_abandono_horas int default 72, p_repeticao_min float8 default 0.7, p_espera_horas int default 4 ) returns jsonb language sql stable set search_path = public as $$ with -- DENOMINADOR DEFINITIVO: demandas encerradas na janela. Não mais os casos. demandas_j as ( select d.id, d.agent_case_id, d.aberta_em, d.fechada_em, d.desfecho from public.demandas d where d.organization_id = p_org and d.fechada_em is not null and d.fechada_em >= p_from and d.fechada_em < p_to ), -- Turnos: mensagens de TODAS as conversas da demanda (N:N), dentro da vida -- dela. Uma demanda que atravessou dois canais soma os dois. turnos as ( select d.id, (select count(*) from public.demanda_conversas dc join public.messages m on m.conversation_id = dc.conversation_id and m.organization_id = p_org and m.sent_at >= d.aberta_em and m.sent_at < d.fechada_em where dc.demanda_id = d.id) as n from demandas_j d ), -- Insistência: só existe onde houve caso. O payload declara o denominador -- próprio (`demandas_com_caso`) para o número não ser lido como se fosse -- sobre o total. insistencia as ( select avg(c.followup_attempts)::float8 as media, max(c.followup_attempts) as maximo, count(*) as base from demandas_j d join public.agent_cases c on c.id = d.agent_case_id ), humano as ( select e.case_id, count(*) as intervencoes, min(e.created_at) as primeiro_toque from public.agent_case_events e join demandas_j d on d.agent_case_id = e.case_id where e.organization_id = p_org and e.actor_kind = 'human' group by e.case_id ), espera_fila as ( select extract(epoch from (h.primeiro_toque - d.aberta_em)) as segundos from demandas_j d join humano h on h.case_id = d.agent_case_id where h.primeiro_toque > d.aberta_em ), retrabalho as ( select count(distinct e.case_id) as n from public.agent_case_events e join demandas_j d on d.agent_case_id = e.case_id where e.organization_id = p_org and (e.kind = 'escalated' or e.human_action = 'escalate') ), abandono as ( select count(*) filter ( where cv.last_outbound_at >= p_from and cv.last_outbound_at < p_to and (cv.last_inbound_at is null or cv.last_outbound_at > cv.last_inbound_at) and cv.last_outbound_at < now() - make_interval(hours => p_abandono_horas) and cv.status not in ('resolved', 'closed') ) as abandonadas, count(*) filter ( where cv.last_outbound_at >= p_from and cv.last_outbound_at < p_to ) as com_fala_nossa from public.conversations cv where cv.organization_id = p_org and cv.last_outbound_at is not null ), -- INVARIANTE 4, agora VERIFICÁVEL: demanda aberta sem próximo passo é o -- vazamento que a doutrina proíbe. Antes da 0119 isto não era enumerável. sem_proximo_passo as ( select count(*) as n from public.demandas d where d.organization_id = p_org and d.fechada_em is null and d.proximo_passo is null ), demandas_abertas as ( select count(*) as n from public.demandas d where d.organization_id = p_org and d.fechada_em is null ), inbounds as ( select m.conversation_id, m.sent_at, m.body, lag(m.body) over (partition by m.conversation_id order by m.sent_at) as body_anterior, lag(m.sent_at) over (partition by m.conversation_id order by m.sent_at) as sent_at_anterior from public.messages m where m.organization_id = p_org and m.direction = 'inbound' and m.body is not null and m.sent_at >= p_from and m.sent_at < p_to ), repeticao as ( select count(*) filter ( where i.body_anterior is not null and exists (select 1 from public.messages o where o.organization_id = p_org and o.conversation_id = i.conversation_id and o.direction = 'outbound' and o.sent_at > i.sent_at_anterior and o.sent_at < i.sent_at) and public.fn_atrito_jaccard(i.body, i.body_anterior) >= p_repeticao_min ) as repetidas, count(*) filter ( where i.body_anterior is not null and exists (select 1 from public.messages o where o.organization_id = p_org and o.conversation_id = i.conversation_id and o.direction = 'outbound' and o.sent_at > i.sent_at_anterior and o.sent_at < i.sent_at) ) as com_resposta_no_meio from inbounds i ), espera_calada as ( select count(*) filter (where prox.espera_s > p_espera_horas * 3600) as caladas, count(*) as com_resposta, percentile_cont(0.9) within group (order by prox.espera_s) as p90_s from ( select extract(epoch from ( (select min(o.sent_at) from public.messages o where o.organization_id = p_org and o.conversation_id = m.conversation_id and o.direction = 'outbound' and o.sent_at > m.sent_at) - m.sent_at)) as espera_s from public.messages m where m.organization_id = p_org and m.direction = 'inbound' and m.sent_at >= p_from and m.sent_at < p_to ) prox where prox.espera_s is not null ), envios as ( select count(*) filter (where m.sent_via = 'ai') as por_ia, count(*) filter (where m.sent_via = 'automation') as por_automacao, count(*) filter (where m.sent_via = 'system') as por_integracao, count(*) filter (where m.sent_via = 'user') as por_humano_no_sistema, count(*) filter (where m.sent_via = 'external_device') as por_humano_fora from public.messages m where m.organization_id = p_org and m.direction = 'outbound' and m.sent_at >= p_from and m.sent_at < p_to ), vetos as ( select count(*) filter (where t.vetoed_gate is not null) as vetados, count(distinct t.job_id) as execucoes from public.before_send_traces t where t.organization_id = p_org and t.created_at >= p_from and t.created_at < p_to ), descadastros as ( select count(*) as n from public.contacts c where c.organization_id = p_org and c.blocked_at is not null and c.blocked_at >= p_from and c.blocked_at < p_to ), pedidos_humano as ( select count(*) as n from public.crm_lead_activities a where a.organization_id = p_org and a.type = 'handoff_triggered' and a.performed_at >= p_from and a.performed_at < p_to ), eficiencia as ( select count(*) filter (where status = 'won') as ganhos, count(*) filter ( where status = 'lost' -- A transferência entre funis não é perda comercial (migration 0266). and coalesce(lost_reason, '') <> 'moved_to_another_pipeline' ) as perdidos from public.crm_leads where organization_id = p_org and status in ('won', 'lost') and closed_at >= p_from and closed_at < p_to ) select jsonb_build_object( 'escopo', jsonb_build_object( 'demandas', (select count(*) from demandas_j), 'demandas_com_caso', (select base from insistencia), 'demandas_abertas', (select n from demandas_abertas), 'de', p_from, 'ate', p_to, 'abandono_horas', p_abandono_horas, 'repeticao_min', p_repeticao_min, 'espera_horas', p_espera_horas, -- Marca a régua do denominador: quem comparar dois períodos precisa saber -- se foram medidos sobre casos ou sobre demandas. 'denominador', 'demandas' ), 'cliente', jsonb_build_object( 'turnos_p50', (select percentile_cont(0.5) within group (order by n) from turnos), 'turnos_p90', (select percentile_cont(0.9) within group (order by n) from turnos), 'insistencia_media', (select media from insistencia), 'insistencia_max', (select maximo from insistencia), 'pedidos_de_humano', (select n from pedidos_humano), 'descadastros', (select n from descadastros), 'abandonos', (select abandonadas from abandono), 'conversas_com_fala_nossa', (select com_fala_nossa from abandono), 'reperguntas', (select repetidas from repeticao), 'perguntas_com_resposta', (select com_resposta_no_meio from repeticao), 'esperas_caladas', (select caladas from espera_calada), 'esperas_medidas', (select com_resposta from espera_calada), 'espera_resposta_p90_s', (select p90_s from espera_calada) ), 'empresa', jsonb_build_object( 'intervencoes_por_demanda', (select avg(coalesce(h.intervencoes, 0))::float8 from demandas_j d left join humano h on h.case_id = d.agent_case_id), 'espera_humana_p50_s', (select percentile_cont(0.5) within group (order by segundos) from espera_fila), 'espera_humana_p90_s', (select percentile_cont(0.9) within group (order by segundos) from espera_fila), 'retrabalho', (select n from retrabalho), 'vetos', (select vetados from vetos), 'execucoes_medidas', (select execucoes from vetos), 'envios_por_ia', (select por_ia from envios), 'envios_por_automacao', (select por_automacao from envios), 'envios_por_integracao', (select por_integracao from envios), 'envios_humano_no_sistema', (select por_humano_no_sistema from envios), 'envios_humano_fora', (select por_humano_fora from envios), -- O invariante 4 vira NÚMERO na tela: demanda aberta sem próximo passo é -- vazamento, e vazamento invisível é o que a doutrina inteira combate. 'demandas_sem_proximo_passo', (select n from sem_proximo_passo) ), 'eficiencia', jsonb_build_object( 'ganhos', (select ganhos from eficiencia), 'perdidos', (select perdidos from eficiencia) ) ); $$; revoke all on function public.fn_atrito_metrics(uuid, timestamptz, timestamptz, int, float8, int) from public; revoke execute on function public.fn_atrito_metrics(uuid, timestamptz, timestamptz, int, float8, int) from anon; grant execute on function public.fn_atrito_metrics(uuid, timestamptz, timestamptz, int, float8, int) to authenticated, service_role; create or replace function public.fn_attendant_metrics( p_org uuid, p_from timestamptz, p_to timestamptz, p_owner uuid default null ) returns jsonb language sql stable set search_path = public as $$ with lead_agg as ( select owner_user_id as user_id, count(*) filter (where status = 'won') as won, count(*) filter ( where status = 'lost' -- A transferência entre funis não é perda comercial (migration 0266). and coalesce(lost_reason, '') <> 'moved_to_another_pipeline' ) as lost from public.crm_leads where organization_id = p_org and status in ('won', 'lost') and closed_at >= p_from and closed_at < p_to and owner_user_id is not null and (p_owner is null or owner_user_id = p_owner) group by owner_user_id ), conv_agg as ( select assigned_to_user_id as user_id, count(*) as conversations_handled from public.conversations where organization_id = p_org and assigned_to_user_id is not null and assigned_at >= p_from and assigned_at < p_to and (p_owner is null or assigned_to_user_id = p_owner) group by assigned_to_user_id ), -- (0235) Chamada de voz ATENDIDA conta como trabalho. -- -- Quem passa o dia ao telefone tinha produtividade zero nesta função: ela -- lia negócios fechados, conversas atribuídas e primeira resposta por -- MENSAGEM, e nenhuma das três enxerga uma ligação. -- -- `owner_user_id` é quem esteve NA LINHA (a rota de atender grava; a ponte de -- eventos confirma pelo `owner` do upstream) — e não `created_by`, que só -- existe na chamada iniciada pelo CRM e diria zero para toda ligação -- recebida. `answered_at is not null` é o que separa trabalho de telefone -- tocando. voice_agg as ( select owner_user_id as user_id, count(*) as calls_answered, coalesce(sum(duration_ms), 0)::bigint as call_ms from public.voice_calls where organization_id = p_org and owner_user_id is not null and answered_at is not null and answered_at >= p_from and answered_at < p_to and (p_owner is null or owner_user_id = p_owner) group by owner_user_id ), ttfr as ( select c.assigned_to_user_id as user_id, avg(extract(epoch from (fr.first_human_out - fr.first_in))) as avg_first_response_seconds from public.conversations c cross join lateral ( select min(m.sent_at) filter (where m.direction = 'inbound') as first_in, min(m.sent_at) filter ( where m.direction = 'outbound' and m.sent_by_user_id is not null ) as first_human_out from public.messages m where m.conversation_id = c.id ) fr where c.organization_id = p_org and c.assigned_to_user_id is not null and (p_owner is null or c.assigned_to_user_id = p_owner) and fr.first_in is not null and fr.first_human_out is not null and fr.first_human_out > fr.first_in and fr.first_human_out >= p_from and fr.first_human_out < p_to group by c.assigned_to_user_id ), attendant_ids as ( select user_id from lead_agg union select user_id from conv_agg union select user_id from ttfr union select user_id from voice_agg ) select jsonb_build_object( 'funnel', coalesce(( select jsonb_agg( jsonb_build_object( 'stage_id', s.id, 'stage_name', s.name, 'position', s.position, 'count', coalesce(l.cnt, 0) ) order by s.position, s.name ) from public.crm_stages s left join ( select stage_id, count(*) as cnt from public.crm_leads where organization_id = p_org and status = 'open' and (p_owner is null or owner_user_id = p_owner) group by stage_id ) l on l.stage_id = s.id where s.organization_id = p_org and s.is_archived = false ), '[]'::jsonb), 'attendants', coalesce(( select jsonb_agg( jsonb_build_object( 'user_id', a.user_id, 'won', coalesce(la.won, 0), 'lost', coalesce(la.lost, 0), 'conversations_handled', coalesce(ca.conversations_handled, 0), 'avg_first_response_seconds', tf.avg_first_response_seconds, 'calls_answered', coalesce(va.calls_answered, 0), 'call_seconds', (coalesce(va.call_ms, 0) / 1000)::bigint ) order by coalesce(la.won, 0) desc, a.user_id ) from attendant_ids a left join lead_agg la on la.user_id = a.user_id left join conv_agg ca on ca.user_id = a.user_id left join ttfr tf on tf.user_id = a.user_id left join voice_agg va on va.user_id = a.user_id ), '[]'::jsonb) ); $$; revoke all on function public.fn_attendant_metrics(uuid,timestamptz,timestamptz,uuid) from public, anon; grant execute on function public.fn_attendant_metrics(uuid,timestamptz,timestamptz,uuid) to authenticated, service_role; -- ════════════════════════════════════════════════════════════════════════════ -- PAÍS DA ORGANIZAÇÃO (migration 20260918014500_0277) — issue #1033 -- -- Derivado de supabase/migrations/20260918014500_0277_pais_da_organizacao.sql; -- o porquê inteiro (o defeito medido, o que entra e o que NÃO entra) está no -- cabeçalho de lá. -- -- Idempotente: `add column if not exists` + `drop constraint if exists` antes -- do `add constraint` — quem ATUALIZA (a coluna já existe) precisa da trava de -- forma tanto quanto quem instala do zero. -- -- NENHUM backfill e NENHUM default: `null` é Brasil, que é o comportamento de -- antes desta migration. Nenhuma linha existente é reescrita. Nada de RLS, -- grant ou policy: a coluna nasce na tabela que já tem as regras dela. -- -- ⚠️ ENTRA ANTES do bloco da VARREDURA anon, que é de propósito o último do -- arquivo (esta migration não cria função, mas o apêndice segue a ordem). -- ──────────────────────────────────────────────────────────────────────────── -- 1 · o país, na organização -- ──────────────────────────────────────────────────────────────────────────── alter table public.organizations add column if not exists country text; alter table public.organizations drop constraint if exists organizations_country_check; alter table public.organizations add constraint organizations_country_check check (country is null or country ~ '^[A-Z]{2}$'); comment on column public.organizations.country is 'O país DA ORGANIZAÇÃO (ISO-3166 alpha-2, maiúsculas; null = Brasil), de onde saem o rótulo e a ' 'validação do documento do contato, a lei citada no PDF de acesso ao titular, o calendário de dias ' 'úteis do prazo desse direito e os padrões de dado pessoal que o anonimizador redige antes de a ' 'conversa ir para o modelo. Resolvido por lib/legal/perfil-do-pais.ts — nenhuma rota lê esta coluna ' 'inline, pela mesma razão escrita em lib/catalogo/moeda-da-org.ts: duas leituras divergem no dia em ' 'que uma ganhar fallback e a outra não, e aqui a divergência prometeria a lei de um país com o prazo ' 'de outro. Sem default e sem backfill: null é o perfil brasileiro, o comportamento de antes da 0277. ' 'País só é oferecido no seletor quando a citação da lei dele já foi revisada por quem pode revisar.'; -- ---- extensões declarativas: catálogo, artefato, instalação, vínculo e recibo (migration 0271) ---- -- BEGIN 0271_extensoes_declarativas — 20260917120000 -- 0271 — Documentos declarativos locais; nenhuma execução de pacote ou DDL dinâmico. -- A autoridade de publicação é um recibo preparing, sem TTL. Cancelamento e -- admissão nova removem essa autoridade sob a mesma trava da conclusão. -- A trava de atualização cobre system_update_runs (app), não o kit manual externo. -- Atualizar, desfazer a última troca e remover: ponteiro de artefato com histórico de UM passo, -- precondição pela revisão da instalação e remoção lógica (nenhuma linha é apagada). create table if not exists public.extension_catalogs ( id uuid primary key default gen_random_uuid(), origin text not null unique, revision integer not null check (revision > 0), digest text not null check (digest ~ '^[a-f0-9]{64}$'), snapshot jsonb not null check (jsonb_typeof(snapshot) = 'object'), admitted_by uuid references auth.users(id) on delete set null, admitted_at timestamptz not null default now() ); create table if not exists public.extension_artifacts ( id uuid primary key default gen_random_uuid(), sha256 text not null unique check (sha256 ~ '^[a-f0-9]{64}$'), byte_length integer not null check (byte_length between 1 and 65536), manifest jsonb not null check (jsonb_typeof(manifest) = 'object'), document text not null check (octet_length(document) between 1 and 65536), created_at timestamptz not null default now() ); create table if not exists public.extension_installations ( id uuid primary key default gen_random_uuid(), catalog_id uuid not null references public.extension_catalogs(id), artifact_id uuid not null references public.extension_artifacts(id), publisher text not null check (publisher ~ '^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$'), name text not null check (name ~ '^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$'), version text not null check (version ~ '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'), installed_by uuid references auth.users(id) on delete set null, installed_at timestamptz not null default now(), unique (catalog_id, publisher, name) ); create table if not exists public.organization_extensions ( organization_id uuid not null references public.organizations(id) on delete cascade, installation_id uuid not null references public.extension_installations(id), enabled boolean not null, configuration jsonb not null check ( jsonb_typeof(configuration) = 'object' and configuration ?& array['density','show_description'] and configuration - array['density','show_description'] = '{}'::jsonb and configuration->>'density' is not null and configuration->>'density' in ('comfortable','compact') and jsonb_typeof(configuration->'show_description') = 'boolean' ), revision integer not null check (revision > 0), updated_by uuid references auth.users(id) on delete set null, updated_at timestamptz not null default now(), primary key (organization_id, installation_id) ); create table if not exists public.extension_operations ( id uuid primary key, kind text not null, status text not null, actor_id uuid references auth.users(id) on delete set null, organization_id uuid references public.organizations(id) on delete cascade, catalog_id uuid references public.extension_catalogs(id), installation_id uuid references public.extension_installations(id), publisher text, name text, version text, request_fingerprint text not null check (request_fingerprint ~ '^[a-f0-9]{64}$'), request jsonb not null, admission_revision integer, admission_digest text, entry jsonb, result jsonb, error_code text, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), constraint extension_operations_scope check ((kind = 'configure') = (organization_id is not null)) ); create index if not exists extension_operations_preparing on public.extension_operations(catalog_id) where status = 'preparing'; create index if not exists extension_operations_org on public.extension_operations(organization_id, created_at desc); create index if not exists organization_extensions_installation on public.organization_extensions(installation_id); -- Atualizar, desfazer e remover. Nada disto mora dentro de `create table if not exists`, que num -- banco com a tabela não executa: colunas por `add column if not exists`, e as três CHECKs de -- vocabulário com o NOME que o Postgres gerou para as inline antigas — o drop acha a velha e a -- nova entra no lugar. Com outro nome as duas conviveriam e todo `update` daria 23514. alter table public.extension_installations add column if not exists previous_artifact_id uuid references public.extension_artifacts(id), add column if not exists revision integer not null default 1 check (revision > 0), add column if not exists removed_at timestamptz, add column if not exists removed_by uuid references auth.users(id) on delete set null; alter table public.extension_installations drop constraint if exists extension_installations_removed_by_requires_removed_at; alter table public.extension_installations add constraint extension_installations_removed_by_requires_removed_at check (removed_by is null or removed_at is not null); alter table public.organization_extensions add column if not exists deactivated_by_removal_at timestamptz; alter table public.extension_operations drop constraint if exists extension_operations_kind_check; alter table public.extension_operations add constraint extension_operations_kind_check check (kind in ('catalog_admission','install','update','revert','removal','configure','module_install')); alter table public.extension_operations drop constraint if exists extension_operations_status_check; alter table public.extension_operations add constraint extension_operations_status_check check (status in ('preparing','completed','failed','cancelled')); alter table public.extension_operations drop constraint if exists extension_operations_preparing; alter table public.extension_operations add constraint extension_operations_preparing check (status <> 'preparing' or kind in ('install','update')); alter table public.extension_catalogs enable row level security; alter table public.extension_artifacts enable row level security; alter table public.extension_installations enable row level security; alter table public.organization_extensions enable row level security; alter table public.extension_operations enable row level security; revoke all on public.extension_catalogs, public.extension_artifacts, public.extension_installations, public.organization_extensions, public.extension_operations from public, anon, authenticated, service_role; grant select on public.extension_catalogs, public.extension_artifacts, public.extension_installations, public.organization_extensions, public.extension_operations to service_role; grant select on public.organization_extensions to authenticated; drop policy if exists tenant_isolation_organization_extensions_select on public.organization_extensions; -- fn_user_org_ids() inclui convite ainda não aceito e sessão de suporte ativa. O -- vínculo exige convite aceito de quem é membro e, sem exigir linha de membership, -- aceita a sessão de suporte ativa na organização atendida: sem isso, quem dá suporte -- via todas as extensões como "desativadas" enquanto o cliente as via ativas. create policy tenant_isolation_organization_extensions_select on public.organization_extensions for select to authenticated using ( organization_id in (select public.fn_user_org_ids()) and ( exists (select 1 from public.user_organizations u where u.organization_id = organization_extensions.organization_id and u.user_id = auth.uid() and u.accepted_at is not null and u.revoked_at is null) or exists (select 1 from (select public.fn_support_context() s) c where c.s->>'status' = 'active' and (c.s->>'organization_id')::uuid = organization_extensions.organization_id) ) ); -- Helpers privados: EXECUTE fechado também porque o baseline concede defaults a anon. create or replace function public.fn_extensions_assert_actor(p_actor uuid, p_organization uuid default null) returns void language plpgsql security definer set search_path = public, pg_temp as $$ begin if p_actor is null or not exists (select 1 from auth.users where id = p_actor) then raise exception using errcode = 'P0001', message = 'extension_forbidden'; end if; if p_organization is null then if not exists (select 1 from public.platform_admins where user_id = p_actor and revoked_at is null and scope = 'full') then raise exception using errcode = 'P0001', message = 'extension_forbidden'; end if; elsif not exists (select 1 from public.user_organizations where user_id = p_actor and organization_id = p_organization and revoked_at is null and accepted_at is not null and role = 'admin') then raise exception using errcode = 'P0001', message = 'extension_forbidden'; end if; end $$; create or replace function public.fn_extensions_fingerprint(p_request jsonb) returns text language sql immutable set search_path = public, extensions, pg_temp as $$ select encode(digest(convert_to(p_request::text, 'UTF8'), 'sha256'), 'hex'); $$; create or replace function public.fn_extensions_admit_catalog(p_actor uuid, p_operation uuid, p_snapshot jsonb, p_digest text) returns jsonb language plpgsql security definer set search_path = public, pg_temp as $$ declare v_request jsonb := jsonb_build_object('kind','catalog_admission','actor',p_actor,'snapshot',p_snapshot,'digest',p_digest); v_op public.extension_operations; v_catalog public.extension_catalogs; v_entry jsonb; v_revision integer; begin perform public.fn_extensions_assert_actor(p_actor); if p_operation is null then raise exception using errcode='P0001', message='extension_invalid_input'; end if; perform pg_advisory_xact_lock(255,1); perform public.fn_extensions_assert_actor(p_actor); perform pg_advisory_xact_lock(hashtextextended(p_operation::text,255)); perform public.fn_extensions_assert_actor(p_actor); select * into v_op from public.extension_operations where id=p_operation; if found then if v_op.request_fingerprint <> public.fn_extensions_fingerprint(v_request) then raise exception using errcode='P0001', message='extension_idempotency_conflict'; end if; return to_jsonb(v_op) || jsonb_build_object('applied_now', false); end if; if p_snapshot is null or jsonb_typeof(p_snapshot) <> 'object' or not (p_snapshot ?& array['format_version','origin','revision','entries']) or p_snapshot - array['format_version','origin','revision','entries'] <> '{}'::jsonb or p_snapshot->'format_version' is distinct from '1'::jsonb or jsonb_typeof(p_snapshot->'origin') is distinct from 'string' or p_snapshot->>'origin' !~ '^https?://[^/@?#[:space:]]+$' or jsonb_typeof(p_snapshot->'revision') is distinct from 'number' or p_snapshot->>'revision' !~ '^[1-9][0-9]{0,8}$' or jsonb_typeof(p_snapshot->'entries') is distinct from 'array' or p_digest is null or p_digest !~ '^[a-f0-9]{64}$' then raise exception using errcode='P0001', message='extension_invalid_input'; end if; if jsonb_array_length(p_snapshot->'entries') > 128 then raise exception using errcode='P0001', message='extension_invalid_input'; end if; for v_entry in select value from jsonb_array_elements(p_snapshot->'entries') loop if jsonb_typeof(v_entry) <> 'object' or not (v_entry ?& array['publisher','name','version','license','host_api','display','permissions','sha256','byte_length']) or v_entry - array['publisher','name','version','license','host_api','display','permissions','sha256','byte_length'] <> '{}'::jsonb or exists (select 1 from jsonb_each(v_entry) e where e.value='null'::jsonb) or jsonb_typeof(v_entry->'byte_length') is distinct from 'number' or jsonb_typeof(v_entry->'host_api') is distinct from 'object' or jsonb_typeof(v_entry->'display') is distinct from 'object' or v_entry->>'publisher' !~ '^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$' or v_entry->>'name' !~ '^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$' or v_entry->>'version' !~ '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' or v_entry->>'sha256' !~ '^[a-f0-9]{64}$' or v_entry->>'byte_length' !~ '^[1-9][0-9]{0,4}$' or v_entry->>'license' <> 'MIT' or v_entry->'permissions' <> '["navigation.tasks"]'::jsonb then raise exception using errcode='P0001', message='extension_invalid_input'; end if; if (v_entry->>'byte_length')::integer > 65536 then raise exception using errcode='P0001', message='extension_invalid_input'; end if; end loop; if exists (select 1 from jsonb_array_elements(p_snapshot->'entries') e group by e->>'publisher', e->>'name', e->>'version' having count(*) > 1) then raise exception using errcode='P0001', message='extension_invalid_input'; end if; v_revision := (p_snapshot->>'revision')::integer; select * into v_catalog from public.extension_catalogs where origin=p_snapshot->>'origin'; if found and (v_revision < v_catalog.revision or (v_revision = v_catalog.revision and (p_digest <> v_catalog.digest or p_snapshot <> v_catalog.snapshot))) then raise exception using errcode='P0001', message='extension_catalog_revision_conflict'; end if; if v_catalog.id is null then if (select count(*) from public.extension_catalogs) >= 8 then raise exception using errcode='P0001',message='extension_catalog_limit'; end if; insert into public.extension_catalogs(origin,revision,digest,snapshot,admitted_by) values(p_snapshot->>'origin',v_revision,p_digest,p_snapshot,p_actor) returning * into v_catalog; elsif v_revision > v_catalog.revision then update public.extension_catalogs set revision=v_revision,digest=p_digest,snapshot=p_snapshot, admitted_by=p_actor,admitted_at=now() where id=v_catalog.id returning * into v_catalog; update public.extension_operations set status='cancelled',error_code='extension_catalog_stale',updated_at=now() where catalog_id=v_catalog.id and kind in ('install','update') and status='preparing'; end if; insert into public.extension_operations(id,kind,status,actor_id,catalog_id,request,request_fingerprint,result) values(p_operation,'catalog_admission','completed',p_actor,v_catalog.id,v_request, public.fn_extensions_fingerprint(v_request),jsonb_build_object('catalog',to_jsonb(v_catalog))) returning * into v_op; return to_jsonb(v_op) || jsonb_build_object('applied_now', true); end $$; -- Publicar espera a atualização do core; um `dispatched` com mais de 15 minutos é, para o -- próprio app, desfecho desconhecido (RUN_STALE_AFTER_MS em lib/system/update-run.ts) e não -- bloqueia. Sem prazo, um agente morto travava toda publicação para sempre. create or replace function public.fn_extensions_core_update_in_progress() returns boolean language sql stable set search_path = public, pg_temp as $$ select exists (select 1 from public.system_update_runs where status='dispatched' and dispatched_at > now() - interval '15 minutes'); $$; -- Instalar, atualizar, trocar para versão menor e reinstalar passam por aqui. A precondição é a -- revisão da instalação que a tela exibiu (null = a tela não viu linha): sem ela, uma aba antiga -- rebaixaria a versão ou desfaria uma remoção em silêncio. drop function if exists public.fn_extensions_prepare_install(uuid,uuid,uuid,text,text,text); create or replace function public.fn_extensions_prepare_install(p_actor uuid, p_operation uuid, p_catalog uuid, p_publisher text, p_name text, p_version text, p_expected_installation_revision integer) returns jsonb language plpgsql security definer set search_path = public, pg_temp as $$ declare v_request jsonb := jsonb_build_object('kind','install','actor',p_actor,'catalog',p_catalog,'publisher',p_publisher, 'name',p_name,'version',p_version,'expected_installation_revision',p_expected_installation_revision); v_op public.extension_operations; v_catalog public.extension_catalogs; v_entry jsonb; v_install public.extension_installations; v_current public.extension_artifacts; v_previous public.extension_artifacts; v_kind text; v_from jsonb := jsonb_build_object('from_revision', null); begin perform public.fn_extensions_assert_actor(p_actor); if p_operation is null or p_catalog is null or p_publisher is null or p_name is null or p_version is null or p_expected_installation_revision < 1 then raise exception using errcode='P0001',message='extension_invalid_input'; end if; perform pg_advisory_xact_lock(255,1); perform public.fn_extensions_assert_actor(p_actor); perform pg_advisory_xact_lock(hashtextextended(p_operation::text,255)); perform public.fn_extensions_assert_actor(p_actor); select * into v_op from public.extension_operations where id=p_operation; if found then if v_op.request_fingerprint <> public.fn_extensions_fingerprint(v_request) then raise exception using errcode='P0001',message='extension_idempotency_conflict'; end if; return to_jsonb(v_op) || jsonb_build_object('applied_now', false); end if; if public.fn_extensions_core_update_in_progress() then raise exception using errcode='P0001',message='extension_core_update_in_progress'; end if; select * into v_catalog from public.extension_catalogs where id=p_catalog; if not found then raise exception using errcode='P0001',message='extension_catalog_not_found'; end if; select value into v_entry from jsonb_array_elements(v_catalog.snapshot->'entries') where value->>'publisher'=p_publisher and value->>'name'=p_name and value->>'version'=p_version; if not found then raise exception using errcode='P0001',message='extension_entry_not_found'; end if; select * into v_install from public.extension_installations where catalog_id=p_catalog and publisher=p_publisher and name=p_name; if v_install.revision is distinct from p_expected_installation_revision then raise exception using errcode='P0001',message='extension_version_changed'; end if; if exists (select 1 from public.extension_operations where kind in ('install','update') and status='preparing' and catalog_id=p_catalog and publisher=p_publisher and name=p_name) then raise exception using errcode='P0001',message='extension_preparation_in_progress'; end if; if v_install.id is not null then select * into v_current from public.extension_artifacts where id=v_install.artifact_id; select * into v_previous from public.extension_artifacts where id=v_install.previous_artifact_id; -- A mesma versão com outro digest é conflito contra o vigente, o anterior e a linha removida. if (v_install.version = p_version and v_current.sha256 <> v_entry->>'sha256') or (v_previous.id is not null and v_previous.manifest->>'version' = p_version and v_previous.sha256 <> v_entry->>'sha256') then raise exception using errcode='P0001',message='extension_version_conflict'; end if; v_from := jsonb_build_object('from_revision',v_install.revision,'from_artifact_id',v_install.artifact_id, 'from_version',v_install.version); if v_install.removed_at is null and v_install.version = p_version then insert into public.extension_operations(id,kind,status,actor_id,catalog_id,installation_id,publisher,name,version, request,request_fingerprint,admission_revision,admission_digest,entry,result) values(p_operation,'install','completed',p_actor,p_catalog,v_install.id,p_publisher,p_name,p_version,v_request, public.fn_extensions_fingerprint(v_request),v_catalog.revision,v_catalog.digest,v_entry, jsonb_build_object('installation',to_jsonb(v_install),'to_artifact_id',v_install.artifact_id)) returning * into v_op; return to_jsonb(v_op) || jsonb_build_object('applied_now', false); end if; end if; if v_install.id is not null and v_install.removed_at is null then v_kind := 'update'; else v_kind := 'install'; -- A preparação de update não conta: ela não cria identidade. if (select count(*) from public.extension_installations where removed_at is null) + (select count(*) from public.extension_operations where kind='install' and status='preparing') >= 128 then raise exception using errcode='P0001',message='extension_installation_limit'; end if; end if; insert into public.extension_operations(id,kind,status,actor_id,catalog_id,installation_id,publisher,name,version, request,request_fingerprint,admission_revision,admission_digest,entry,result) values(p_operation,v_kind,'preparing',p_actor,p_catalog,v_install.id,p_publisher,p_name,p_version,v_request, public.fn_extensions_fingerprint(v_request),v_catalog.revision,v_catalog.digest,v_entry,v_from) returning * into v_op; return to_jsonb(v_op) || jsonb_build_object('applied_now', true); end $$; create or replace function public.fn_extensions_finish_install(p_actor uuid, p_operation uuid, p_manifest jsonb, p_sha256 text, p_byte_length integer, p_document text) returns jsonb language plpgsql security definer set search_path = public, pg_temp as $$ declare v_op public.extension_operations; v_catalog public.extension_catalogs; v_artifact public.extension_artifacts; v_install public.extension_installations; v_current public.extension_artifacts; v_previous public.extension_artifacts; v_document_json jsonb; v_active integer := 0; begin perform public.fn_extensions_assert_actor(p_actor); perform pg_advisory_xact_lock(255,1); perform public.fn_extensions_assert_actor(p_actor); select * into v_op from public.extension_operations where id=p_operation for update; if not found then raise exception using errcode='P0001',message='extension_operation_not_found'; end if; if v_op.kind not in ('install','update') or v_op.actor_id is distinct from p_actor then raise exception using errcode='P0001',message='extension_operation_conflict'; end if; -- Sem autoridade após cancel/fail. Resposta perdida de completed segue verificando payload. if v_op.status in ('cancelled','failed') then return to_jsonb(v_op) || jsonb_build_object('applied_now', false); end if; if p_document is null or octet_length(p_document) not between 1 and 65536 or octet_length(p_document) is distinct from p_byte_length or encode(sha256(convert_to(p_document,'UTF8')),'hex') is distinct from p_sha256 then raise exception using errcode='P0001',message='extension_artifact_mismatch'; end if; begin v_document_json := p_document::jsonb; exception when invalid_text_representation or untranslatable_character or program_limit_exceeded then raise exception using errcode='P0001',message='extension_artifact_mismatch'; end; if v_document_json is distinct from p_manifest then raise exception using errcode='P0001',message='extension_artifact_mismatch'; end if; if p_sha256 is distinct from v_op.entry->>'sha256' or p_byte_length is distinct from (v_op.entry->>'byte_length')::integer or p_manifest is null or jsonb_typeof(p_manifest) <> 'object' or not (p_manifest ?& array['format_version','profile','publisher','name','version','license','host_api','permissions','dependencies','data','display','configuration','contributions']) or p_manifest - array['format_version','profile','publisher','name','version','license','host_api','permissions','dependencies','data','display','configuration','contributions'] <> '{}'::jsonb or exists (select 1 from jsonb_each(p_manifest) e where e.value='null'::jsonb) or p_manifest->'format_version' is distinct from '1'::jsonb or p_manifest->>'profile' is distinct from 'declarative' or jsonb_typeof(p_manifest->'configuration') is distinct from 'object' or jsonb_typeof(p_manifest->'contributions') is distinct from 'object' or p_manifest->>'publisher' is distinct from v_op.publisher or p_manifest->>'name' is distinct from v_op.name or p_manifest->>'version' is distinct from v_op.version or p_manifest->'dependencies' <> '[]'::jsonb or p_manifest->'data' <> '{"mode":"none"}'::jsonb or (p_manifest - array['format_version','profile','dependencies','data','configuration','contributions']) is distinct from (v_op.entry - array['sha256','byte_length']) then raise exception using errcode='P0001',message='extension_artifact_mismatch'; end if; if v_op.status='completed' then -- Compara com o artefato que ESTA conclusão publicou, não com o ponteiro de agora: um -- "desfazer" posterior não pode fazer a repetição acusar pacote adulterado. select * into v_artifact from public.extension_artifacts where id=coalesce(v_op.result->>'to_artifact_id', v_op.result->'installation'->>'artifact_id')::uuid; if not found or v_artifact.manifest is distinct from p_manifest or v_artifact.document is distinct from p_document then raise exception using errcode='P0001',message='extension_artifact_mismatch'; end if; return to_jsonb(v_op) || jsonb_build_object('applied_now', false); end if; if public.fn_extensions_core_update_in_progress() then raise exception using errcode='P0001',message='extension_core_update_in_progress'; end if; select * into v_catalog from public.extension_catalogs where id=v_op.catalog_id; if v_catalog.revision is distinct from v_op.admission_revision or v_catalog.digest is distinct from v_op.admission_digest then raise exception using errcode='P0001',message='extension_catalog_stale'; end if; select * into v_install from public.extension_installations where catalog_id=v_op.catalog_id and publisher=v_op.publisher and name=v_op.name for update; -- Defesa estrutural: a linha tem de estar na revisão que a preparação viu. if v_install.revision is distinct from (v_op.result->>'from_revision')::integer or (v_op.kind='update' and v_install.removed_at is not null) or (v_op.kind='install' and v_install.id is not null and v_install.removed_at is null) then raise exception using errcode='P0001',message='extension_version_changed'; end if; if v_install.id is not null then select * into v_current from public.extension_artifacts where id=v_install.artifact_id; select * into v_previous from public.extension_artifacts where id=v_install.previous_artifact_id; if (v_install.version = v_op.version and v_current.sha256 <> p_sha256) or (v_previous.id is not null and v_previous.manifest->>'version' = v_op.version and v_previous.sha256 <> p_sha256) then raise exception using errcode='P0001',message='extension_version_conflict'; end if; end if; select * into v_artifact from public.extension_artifacts where sha256=p_sha256; if found then if v_artifact.manifest is distinct from p_manifest or v_artifact.document is distinct from p_document or v_artifact.byte_length <> p_byte_length then raise exception using errcode='P0001',message='extension_artifact_mismatch'; end if; else insert into public.extension_artifacts(sha256,byte_length,manifest,document) values(p_sha256,p_byte_length,p_manifest,p_document) returning * into v_artifact; end if; if v_install.id is null then insert into public.extension_installations(catalog_id,artifact_id,publisher,name,version,installed_by) values(v_op.catalog_id,v_artifact.id,v_op.publisher,v_op.name,v_op.version,p_actor) returning * into v_install; elsif v_op.kind='install' then -- Reinstalação de uma linha removida: os vínculos NÃO voltam ativos; cada organização decide. update public.extension_installations set artifact_id=v_artifact.id, version=v_op.version, previous_artifact_id=null, removed_at=null, removed_by=null, installed_by=p_actor, installed_at=now(), revision=revision+1 where id=v_install.id returning * into v_install; else update public.extension_installations set previous_artifact_id=artifact_id, artifact_id=v_artifact.id, version=v_op.version, revision=revision+1 where id=v_install.id returning * into v_install; select count(*)::integer into v_active from public.organization_extensions where installation_id=v_install.id and enabled; end if; update public.extension_operations set status='completed',installation_id=v_install.id, result=coalesce(v_op.result,'{}'::jsonb) || jsonb_build_object('installation',to_jsonb(v_install), 'to_artifact_id',v_artifact.id,'to_version',v_op.version,'organizations_active',v_active), updated_at=now() where id=p_operation returning * into v_op; return to_jsonb(v_op) || jsonb_build_object('applied_now', true); end $$; create or replace function public.fn_extensions_fail_install(p_actor uuid, p_operation uuid, p_error_code text) returns jsonb language plpgsql security definer set search_path = public, pg_temp as $$ declare v_op public.extension_operations; v_applied boolean := false; begin perform public.fn_extensions_assert_actor(p_actor); if p_error_code is null or p_error_code not in ('extension_invalid_package','extension_incompatible','extension_download_failed', 'extension_unsafe_origin','extension_digest_mismatch','extension_payload_too_large','extension_storage_failed') then raise exception using errcode='P0001',message='extension_invalid_input'; end if; perform pg_advisory_xact_lock(255,1); perform public.fn_extensions_assert_actor(p_actor); select * into v_op from public.extension_operations where id=p_operation for update; if not found then raise exception using errcode='P0001',message='extension_operation_not_found'; end if; if v_op.kind not in ('install','update') or v_op.actor_id is distinct from p_actor then raise exception using errcode='P0001',message='extension_operation_conflict'; end if; if v_op.status='preparing' then update public.extension_operations set status='failed',error_code=p_error_code,updated_at=now() where id=p_operation returning * into v_op; v_applied := true; elsif v_op.status='failed' and v_op.error_code is distinct from p_error_code then raise exception using errcode='P0001',message='extension_idempotency_conflict'; end if; return to_jsonb(v_op) || jsonb_build_object('applied_now', v_applied); end $$; create or replace function public.fn_extensions_cancel_install(p_actor uuid, p_operation uuid) returns jsonb language plpgsql security definer set search_path = public, pg_temp as $$ declare v_op public.extension_operations; v_applied boolean := false; begin perform public.fn_extensions_assert_actor(p_actor); perform pg_advisory_xact_lock(255,1); perform public.fn_extensions_assert_actor(p_actor); select * into v_op from public.extension_operations where id=p_operation for update; if not found then raise exception using errcode='P0001',message='extension_operation_not_found'; end if; if v_op.kind not in ('install','update') then raise exception using errcode='P0001',message='extension_operation_conflict'; end if; -- Outro administrador atual pode recuperar uma preparação cujo ator foi removido. if v_op.status='preparing' then update public.extension_operations set status='cancelled',updated_at=now() where id=p_operation returning * into v_op; v_applied := true; end if; return to_jsonb(v_op) || jsonb_build_object('applied_now', v_applied); end $$; create or replace function public.fn_extensions_configure(p_actor uuid, p_organization uuid, p_installation uuid, p_operation uuid, p_expected_revision integer, p_enabled boolean, p_configuration jsonb) returns jsonb language plpgsql security definer set search_path = public, pg_temp as $$ declare v_request jsonb := jsonb_build_object('kind','configure','actor',p_actor,'organization',p_organization, 'installation',p_installation,'expected_revision',p_expected_revision,'enabled',p_enabled,'configuration',p_configuration); v_op public.extension_operations; v_link public.organization_extensions; v_config jsonb; v_manifest jsonb; v_removed_at timestamptz; begin if p_organization is null or p_operation is null or p_installation is null or p_expected_revision is null or p_expected_revision < 0 or p_enabled is null then raise exception using errcode='P0001',message='extension_invalid_input'; end if; perform public.fn_extensions_assert_actor(p_actor,p_organization); perform pg_advisory_xact_lock(hashtextextended(p_operation::text,255)); perform public.fn_extensions_assert_actor(p_actor,p_organization); select * into v_op from public.extension_operations where id=p_operation; if found then if v_op.request_fingerprint <> public.fn_extensions_fingerprint(v_request) then raise exception using errcode='P0001',message='extension_idempotency_conflict'; end if; return to_jsonb(v_op) || jsonb_build_object('applied_now', false); end if; perform 1 from public.organizations where id=p_organization for update; perform public.fn_extensions_assert_actor(p_actor,p_organization); -- FOR SHARE na instalação serializa a ativação com toda troca de ponteiro e com a remoção -- (que faz UPDATE na instalação antes dos vínculos). Sem isso, configurar e remover ao mesmo -- tempo deixava um vínculo ativo numa extensão removida, que nenhuma tela desativava. select i.removed_at, a.manifest into v_removed_at, v_manifest from public.extension_installations i join public.extension_artifacts a on a.id=i.artifact_id where i.id=p_installation for share of i; if not found then raise exception using errcode='P0001',message='extension_installation_not_found'; end if; if v_removed_at is not null then raise exception using errcode='P0001',message='extension_removed'; end if; select * into v_link from public.organization_extensions where organization_id=p_organization and installation_id=p_installation; if coalesce(v_link.revision,0) <> p_expected_revision then raise exception using errcode='P0001',message='extension_revision_conflict'; end if; v_config := coalesce(p_configuration,v_link.configuration,v_manifest->'configuration'); if v_config is null or jsonb_typeof(v_config) <> 'object' or not (v_config ?& array['density','show_description']) or v_config - array['density','show_description'] <> '{}'::jsonb or v_config->>'density' is null or v_config->>'density' not in ('comfortable','compact') or jsonb_typeof(v_config->'show_description') is distinct from 'boolean' then raise exception using errcode='P0001',message='extension_invalid_input'; end if; if p_enabled and not coalesce(v_link.enabled,false) and (select count(*) from public.organization_extensions where organization_id=p_organization and enabled) >= 8 then raise exception using errcode='P0001',message='extension_active_limit'; end if; insert into public.organization_extensions(organization_id,installation_id,enabled,configuration,revision,updated_by) values(p_organization,p_installation,p_enabled,v_config,p_expected_revision+1,p_actor) on conflict (organization_id,installation_id) do update set enabled=excluded.enabled,configuration=excluded.configuration, revision=excluded.revision,updated_by=excluded.updated_by,updated_at=now(), -- Ativar apaga a marca da remoção; desativar ou mudar a densidade a preserva. deactivated_by_removal_at=case when excluded.enabled then null else organization_extensions.deactivated_by_removal_at end returning * into v_link; insert into public.extension_operations(id,kind,status,actor_id,organization_id,installation_id,request,request_fingerprint,result) values(p_operation,'configure','completed',p_actor,p_organization,p_installation,v_request, public.fn_extensions_fingerprint(v_request),jsonb_build_object('organization_extension',to_jsonb(v_link))) returning * into v_op; return to_jsonb(v_op) || jsonb_build_object('applied_now', true); end $$; -- Desfazer a última troca: o anterior vira vigente e o vigente vira anterior (é a própria -- inversa). Não baixa nada, então funciona com o catálogo desligado. Histórico de UM passo. create or replace function public.fn_extensions_revert_install(p_actor uuid, p_operation uuid, p_installation uuid, p_expected_installation_revision integer) returns jsonb language plpgsql security definer set search_path = public, pg_temp as $$ declare v_request jsonb := jsonb_build_object('kind','revert','actor',p_actor,'installation',p_installation, 'expected_installation_revision',p_expected_installation_revision); v_op public.extension_operations; v_install public.extension_installations; v_from public.extension_installations; v_target public.extension_artifacts; v_active integer; begin perform public.fn_extensions_assert_actor(p_actor); if p_operation is null or p_installation is null or p_expected_installation_revision is null or p_expected_installation_revision < 1 then raise exception using errcode='P0001',message='extension_invalid_input'; end if; perform pg_advisory_xact_lock(255,1); perform public.fn_extensions_assert_actor(p_actor); perform pg_advisory_xact_lock(hashtextextended(p_operation::text,255)); perform public.fn_extensions_assert_actor(p_actor); select * into v_op from public.extension_operations where id=p_operation; if found then if v_op.request_fingerprint <> public.fn_extensions_fingerprint(v_request) then raise exception using errcode='P0001',message='extension_idempotency_conflict'; end if; return to_jsonb(v_op) || jsonb_build_object('applied_now', false); end if; if public.fn_extensions_core_update_in_progress() then raise exception using errcode='P0001',message='extension_core_update_in_progress'; end if; select * into v_install from public.extension_installations where id=p_installation for update; if not found then raise exception using errcode='P0001',message='extension_installation_not_found'; end if; if v_install.removed_at is not null then raise exception using errcode='P0001',message='extension_removed'; end if; if exists (select 1 from public.extension_operations where kind in ('install','update') and status='preparing' and catalog_id=v_install.catalog_id and publisher=v_install.publisher and name=v_install.name) then raise exception using errcode='P0001',message='extension_preparation_in_progress'; end if; if v_install.revision <> p_expected_installation_revision then raise exception using errcode='P0001',message='extension_version_changed'; end if; if v_install.previous_artifact_id is null then raise exception using errcode='P0001',message='extension_no_previous_version'; end if; select * into v_target from public.extension_artifacts where id=v_install.previous_artifact_id; v_from := v_install; update public.extension_installations set artifact_id=previous_artifact_id, previous_artifact_id=artifact_id, version=v_target.manifest->>'version', revision=revision+1 where id=p_installation returning * into v_install; select count(*)::integer into v_active from public.organization_extensions where installation_id=p_installation and enabled; insert into public.extension_operations(id,kind,status,actor_id,catalog_id,installation_id,publisher,name,version, request,request_fingerprint,result) values(p_operation,'revert','completed',p_actor,v_install.catalog_id,v_install.id,v_install.publisher,v_install.name, v_install.version,v_request,public.fn_extensions_fingerprint(v_request), jsonb_build_object('installation',to_jsonb(v_install),'from_revision',v_from.revision,'from_artifact_id',v_from.artifact_id, 'from_version',v_from.version,'to_artifact_id',v_install.artifact_id,'to_version',v_install.version, 'organizations_active',v_active)) returning * into v_op; return to_jsonb(v_op) || jsonb_build_object('applied_now', true); end $$; -- Remover da instalação: nenhuma linha é apagada. A instalação sai do hub e do guia; todo -- vínculo ATIVO, em todas as organizações, é desligado com a marca da remoção e a configuração -- preservada. Tirar não espera a atualização do core: só reduz o que está ativo. create or replace function public.fn_extensions_remove_installation(p_actor uuid, p_operation uuid, p_installation uuid, p_expected_installation_revision integer) returns jsonb language plpgsql security definer set search_path = public, pg_temp as $$ declare v_request jsonb := jsonb_build_object('kind','removal','actor',p_actor,'installation',p_installation, 'expected_installation_revision',p_expected_installation_revision); v_op public.extension_operations; v_install public.extension_installations; v_from public.extension_installations; v_orgs uuid[]; begin perform public.fn_extensions_assert_actor(p_actor); if p_operation is null or p_installation is null or p_expected_installation_revision is null or p_expected_installation_revision < 1 then raise exception using errcode='P0001',message='extension_invalid_input'; end if; perform pg_advisory_xact_lock(255,1); perform public.fn_extensions_assert_actor(p_actor); perform pg_advisory_xact_lock(hashtextextended(p_operation::text,255)); perform public.fn_extensions_assert_actor(p_actor); select * into v_op from public.extension_operations where id=p_operation; if found then if v_op.request_fingerprint <> public.fn_extensions_fingerprint(v_request) then raise exception using errcode='P0001',message='extension_idempotency_conflict'; end if; return to_jsonb(v_op) || jsonb_build_object('applied_now', false); end if; select * into v_install from public.extension_installations where id=p_installation for update; if not found then raise exception using errcode='P0001',message='extension_installation_not_found'; end if; if v_install.removed_at is not null then raise exception using errcode='P0001',message='extension_removed'; end if; if exists (select 1 from public.extension_operations where kind in ('install','update') and status='preparing' and catalog_id=v_install.catalog_id and publisher=v_install.publisher and name=v_install.name) then raise exception using errcode='P0001',message='extension_preparation_in_progress'; end if; if v_install.revision <> p_expected_installation_revision then raise exception using errcode='P0001',message='extension_version_changed'; end if; v_from := v_install; -- A instalação ANTES dos vínculos: na ordem inversa, a corrida com a configuração dá impasse. update public.extension_installations set removed_at=now(), removed_by=p_actor, revision=revision+1 where id=p_installation returning * into v_install; with desligados as ( update public.organization_extensions set enabled=false, revision=revision+1, updated_by=p_actor, updated_at=now(), deactivated_by_removal_at=now() where installation_id=p_installation and enabled returning organization_id) select coalesce(array_agg(organization_id order by organization_id), array[]::uuid[]) into v_orgs from desligados; insert into public.extension_operations(id,kind,status,actor_id,catalog_id,installation_id,publisher,name,version, request,request_fingerprint,result) values(p_operation,'removal','completed',p_actor,v_install.catalog_id,v_install.id,v_install.publisher,v_install.name, v_from.version,v_request,public.fn_extensions_fingerprint(v_request), jsonb_build_object('installation',to_jsonb(v_install),'from_revision',v_from.revision,'from_artifact_id',v_from.artifact_id, 'from_version',v_from.version,'organizations_disabled',to_jsonb(v_orgs), 'organizations_disabled_count',coalesce(array_length(v_orgs,1),0))) returning * into v_op; return to_jsonb(v_op) || jsonb_build_object('applied_now', true); end $$; -- Contagem entre organizações para quem administra a instalação: só números, nunca ids. É a -- única leitura de organization_extensions que atravessa organizações, e a spec a declara. Confere -- o ator no banco, como as funções que escrevem: a barreira não depende só de quem a chama. drop function if exists public.fn_extensions_installation_counts(); create or replace function public.fn_extensions_installation_counts(p_actor uuid) returns table(installation_id uuid, active_organizations integer, awaiting_reactivation integer) language plpgsql security definer set search_path = public, pg_temp as $$ begin perform public.fn_extensions_assert_actor(p_actor); return query select e.installation_id, (count(*) filter (where e.enabled))::integer, (count(*) filter (where not e.enabled and e.deactivated_by_removal_at is not null))::integer from public.organization_extensions e group by e.installation_id; end $$; create or replace function public.fn_extensions_guard_core_update() returns trigger language plpgsql security definer set search_path = public, pg_temp as $$ begin if new.status='dispatched' then perform pg_advisory_xact_lock(255,1); if exists (select 1 from public.extension_operations where kind in ('install','update') and status='preparing') then raise exception using errcode='P0001',message='extension_preparation_in_progress'; end if; end if; return new; end $$; drop trigger if exists extensions_guard_core_update on public.system_update_runs; create trigger extensions_guard_core_update before insert or update of status on public.system_update_runs for each row execute function public.fn_extensions_guard_core_update(); revoke execute on function public.fn_extensions_assert_actor(uuid,uuid) from public,anon,authenticated,service_role; revoke execute on function public.fn_extensions_fingerprint(jsonb) from public,anon,authenticated,service_role; revoke execute on function public.fn_extensions_guard_core_update() from public,anon,authenticated,service_role; revoke execute on function public.fn_extensions_core_update_in_progress() from public,anon,authenticated,service_role; revoke execute on function public.fn_extensions_admit_catalog(uuid,uuid,jsonb,text) from public,anon,authenticated; revoke execute on function public.fn_extensions_prepare_install(uuid,uuid,uuid,text,text,text,integer) from public,anon,authenticated; revoke execute on function public.fn_extensions_finish_install(uuid,uuid,jsonb,text,integer,text) from public,anon,authenticated; revoke execute on function public.fn_extensions_fail_install(uuid,uuid,text) from public,anon,authenticated; revoke execute on function public.fn_extensions_cancel_install(uuid,uuid) from public,anon,authenticated; revoke execute on function public.fn_extensions_configure(uuid,uuid,uuid,uuid,integer,boolean,jsonb) from public,anon,authenticated; revoke execute on function public.fn_extensions_revert_install(uuid,uuid,uuid,integer) from public,anon,authenticated; revoke execute on function public.fn_extensions_remove_installation(uuid,uuid,uuid,integer) from public,anon,authenticated; revoke execute on function public.fn_extensions_installation_counts(uuid) from public,anon,authenticated; grant execute on function public.fn_extensions_admit_catalog(uuid,uuid,jsonb,text) to service_role; grant execute on function public.fn_extensions_prepare_install(uuid,uuid,uuid,text,text,text,integer) to service_role; grant execute on function public.fn_extensions_finish_install(uuid,uuid,jsonb,text,integer,text) to service_role; grant execute on function public.fn_extensions_fail_install(uuid,uuid,text) to service_role; grant execute on function public.fn_extensions_cancel_install(uuid,uuid) to service_role; grant execute on function public.fn_extensions_configure(uuid,uuid,uuid,uuid,integer,boolean,jsonb) to service_role; grant execute on function public.fn_extensions_revert_install(uuid,uuid,uuid,integer) to service_role; grant execute on function public.fn_extensions_remove_installation(uuid,uuid,uuid,integer) to service_role; grant execute on function public.fn_extensions_installation_counts(uuid) to service_role; -- END 0271_extensoes_declarativas -- ---- travas do modo somente leitura do suporte: a enumeração vira função (migration 0274) ---- -- -- Só a DEFINIÇÃO mora aqui, antes da varredura de anon (função nova não entra -- depois dela — tests/unit/varredura-anon-e-o-ultimo-bloco.test.ts). A CHAMADA é -- o último bloco do arquivo, depois de toda tabela: é isso que faz a instalação -- nova chegar ao mesmo conjunto de travas que a atualização. -- -- Regra de seleção (a mesma da 0220): tabela comum de `public`, RLS ligada, com -- `organization_id` (ou `organizations`, pela `id`). Gravável por `authenticated` -- → as três restritivas; só do servidor → nenhuma `support_write_*`. create or replace function public.fn_aplicar_travas_de_suporte() returns void language plpgsql set search_path = public as $f$ declare r record; v_col text; begin for r in select c.oid,c.relname from pg_class c join pg_namespace n on n.oid=c.relnamespace where n.nspname='public' and c.relkind='r' and c.relrowsecurity and (exists(select 1 from pg_attribute a where a.attrelid=c.oid and a.attname='organization_id' and not a.attisdropped) or c.relname='organizations') loop v_col:=case when r.relname='organizations' then 'id' else 'organization_id' end; if not (has_table_privilege('authenticated',r.oid,'insert') or has_table_privilege('authenticated',r.oid,'update') or has_table_privilege('authenticated',r.oid,'delete')) then execute format('drop policy if exists support_write_insert on public.%I',r.relname); execute format('drop policy if exists support_write_update on public.%I',r.relname); execute format('drop policy if exists support_write_delete on public.%I',r.relname); continue; -- tabela server-only mantém ZERO policies, contrato mais restritivo end if; execute format('drop policy if exists support_write_insert on public.%I',r.relname); execute format('create policy support_write_insert on public.%I as restrictive for insert to authenticated with check (public.fn_support_write_allowed(%I))',r.relname,v_col); execute format('drop policy if exists support_write_update on public.%I',r.relname); execute format('create policy support_write_update on public.%I as restrictive for update to authenticated using (public.fn_support_write_allowed(%I)) with check (public.fn_support_write_allowed(%I))',r.relname,v_col,v_col); execute format('drop policy if exists support_write_delete on public.%I',r.relname); execute format('create policy support_write_delete on public.%I as restrictive for delete to authenticated using (public.fn_support_write_allowed(%I))',r.relname,v_col); end loop; end $f$; -- Só quem aplica o schema (o dono das tabelas) a chama; não é `security definer`. -- EXECUTE sai das duas origens e dos papéis que o default ACL do Supabase alcança. revoke execute on function public.fn_aplicar_travas_de_suporte() from public, anon, authenticated, service_role; -- ---- a espera da Fila não recomeça a cada mensagem do cliente (migration 0267) ---- -- -- Issue #990. A aba Fila ordena por tempo de espera crescente e a régua era -- `last_inbound_at` — a ÚLTIMA mensagem do cliente. Essa coluna é reescrita a -- cada mensagem nova (`greatest(last_inbound_at, p_at)`, logo acima neste -- arquivo), então o cliente que insiste volta para o fim da fila: a espera dele -- "recomeça" a cada pergunta, e quem escreveu uma vez e ficou quieto passa na -- frente de quem está tentando ser atendido desde antes. -- -- A régua passa a ser a mensagem do cliente MAIS ANTIGA sem resposta: -- -- min(messages.sent_at) where direction='inbound' and sent_at > last_outbound_at -- -- e ela virou COLUNA (`conversations.awaiting_since`) porque a ordem da Fila é um -- `order by` pedido ao PostgREST pela rota da lista, e o PostgREST ordena por -- coluna: a expressão acima mora em `messages` e depende de `last_outbound_at`. -- -- `awaiting_since` carrega `last_inbound_at` quando não há mensagem sem resposta -- (a bola está com o cliente). Essas linhas nunca tiveram o defeito, e o valor -- mantém ordem, pílula "Aguardando há…" e a posição entregue às ferramentas de IA -- apontando para o MESMO instante — as três leem esta coluna. Sem ele a linha -- ficaria NULL, e NULL ordena por último: na atualização, conversa que hoje -- aparece no meio da fila cairia para o fim. -- -- Idempotente: `add column if not exists`, preenchimento em DUAS passadas (a -- primeira só copia `last_inbound_at` para quem já está respondido; a segunda -- consulta `messages` apenas para quem TEM mensagem sem resposta — um `min()` -- por conversa, servido por `idx_messages_conversation_sent`) e o corpo da -- função DERIVADO da versão em vigor — recriá-lo a partir de uma versão anterior -- apagaria o lock de serviço, a guarda de troca de contato e a fronteira de -- `service_closed_at`. alter table public.conversations add column if not exists awaiting_since timestamptz; comment on column public.conversations.awaiting_since is 'Desde quando o cliente espera resposta: o instante da mensagem DELE mais antiga que ninguém respondeu ainda (min(sent_at) dos inbound posteriores a last_outbound_at, no atendimento em curso). É a régua da Fila — a ordem da lista, a pílula "Aguardando há…" da linha e a posição entregue às ferramentas de IA leem esta coluna, e é isso que faz a ordem da tela e o número dito ao cliente não divergirem. last_inbound_at (a ÚLTIMA mensagem) reinicia a cada mensagem e fazia quem insiste descer para o fim da fila (issue #990); esta coluna mantém o começo da espera. Quando não há mensagem sem resposta — a bola está com o cliente —, carrega last_inbound_at, que é o que a Fila usava antes desta migration.'; update public.conversations c set awaiting_since = c.last_inbound_at where c.awaiting_since is null and c.last_inbound_at is not null and c.last_outbound_at is not null and c.last_inbound_at <= c.last_outbound_at; update public.conversations c set awaiting_since = coalesce( ( select min(m.sent_at) from public.messages m where m.conversation_id = c.id and m.direction = 'inbound' and m.sent_at > coalesce(c.last_outbound_at, '-infinity'::timestamptz) and m.sent_at > coalesce(c.service_closed_at, '-infinity'::timestamptz) ), c.last_inbound_at ) where c.awaiting_since is null and c.last_inbound_at is not null and (c.last_outbound_at is null or c.last_inbound_at > c.last_outbound_at); create or replace function public.fn_mark_conversation_message(p_conv uuid,p_direction text,p_preview text,p_at timestamptz) returns void language plpgsql security definer set search_path=public as $$ declare c public.conversations; pre_contact uuid; begin select * into c from public.conversations where id=p_conv; if not found then return; end if; pre_contact:=c.contact_id; perform public.fn_service_lock(c.organization_id,c.contact_id); select * into c from public.conversations where id=p_conv for no key update; if c.contact_id is distinct from pre_contact then raise exception 'service_contact_changed' using errcode='40001'; end if; if p_direction='inbound' and p_at<=c.service_closed_at then return; end if; update public.conversations set last_message_at=greatest(last_message_at,p_at), last_message_preview=case when last_message_at is null or p_at>=last_message_at then p_preview else last_message_preview end, last_inbound_at=case when p_direction='inbound' then greatest(last_inbound_at,p_at) else last_inbound_at end, last_outbound_at=case when p_direction='outbound' then greatest(last_outbound_at,p_at) else last_outbound_at end, unread_count_for_assignee=case when p_direction='inbound' then unread_count_for_assignee+1 when p_direction='outbound' then 0 else unread_count_for_assignee end, -- A régua da Fila (issue #990). Inbound, na ordem: (1) mensagem ATRASADA -- (escrita antes da última resposta) já está respondida e não é espera — -- mantém o que havia; (2) a espera guardada é de uma mensagem SEM RESPOSTA -- deste atendimento — o cliente insistiu, e fica o começo da espera, o mais -- ANTIGO dos dois; (3) não havia espera (tudo respondido) ou ela é de um -- atendimento já encerrado — a espera de agora começa nesta mensagem. No -- outbound: resposta anterior à espera guardada não a responde (fora de -- ordem, mantém); qualquer outra responde tudo até aqui e a coluna volta ao -- last_inbound_at — "não há mensagem sem resposta". awaiting_since=case when p_direction='inbound' then case when p_at<=coalesce(c.last_outbound_at,'-infinity'::timestamptz) then coalesce(c.awaiting_since,greatest(coalesce(c.last_inbound_at,'-infinity'::timestamptz),p_at)) when c.awaiting_since>coalesce(c.last_outbound_at,'-infinity'::timestamptz) and c.awaiting_since>coalesce(c.service_closed_at,'-infinity'::timestamptz) then least(c.awaiting_since,p_at) else p_at end else case when c.awaiting_since is not null and p_at>'organization_id')::uuid); if v_org_id is null then select organization_id into v_org_id from public.user_organizations where user_id = auth.uid() and revoked_at is null limit 1; end if; if v_org_id is null then raise exception 'emit_event: organization_id obrigatorio'; end if; if auth.uid() is not null and not public.fn_role_at_least(v_org_id, 'viewer') then raise exception 'caller_not_authorized_for_org' using hint = 'emit_event: caller must be an active member of the organization'; end if; if not public.fn_support_write_allowed(v_org_id) then raise exception 'support_readonly' using errcode='42501'; end if; -- A ORIGEM E RESERVADA AO SERVIDOR — ENTAO O SERVIDOR TEM DE ESCREVE-LA. -- -- O bloco acima recusa `service_origin` vindo de chamador autenticado (42501, -- e com razao: e o campo que AUTORIZA efeito operacional, nao payload -- publico). So que ninguem o escrevia no lugar dele. Efeito medido: quem move -- o negocio pela IA carimba a origem no servidor (`agent-stage-sync`, -- `appointment-stage-move`, `handoff-stage-move`) e o follow-up nasce; quem -- move PELO QUADRO — o operador, pela rota HTTP autenticada — emitia um -- evento SEM origem, `fn_service_event_origin` caia no `service_stale` final -- (40001), `serviceForEvent` engolia como `stale_origin` e o follow-up nunca -- nascia. Sem erro em lugar nenhum: o gatilho de etapa era inalcancavel pelo -- caminho que o produto oferece na tela. -- -- O retrato e tirado AQUI, no instante da emissao, que e exatamente a -- semantica de procedencia que a 0223 quer: "quando este evento nasceu, o -- atendimento estava assim". A resolucao do contato repete a mesma regra de -- `fn_service_event_origin` — se ela nao souber resolver o tipo, nao ha o que -- carimbar e o evento segue sem origem, como antes. if not (coalesce(p_payload,'{}'::jsonb) ? 'service_origin') and not (coalesce(p_metadata,'{}'::jsonb) ? 'service_origin') then if p_event_type in ('lead.created','lead.stage_changed','lead.tag_added') and p_entity_kind='crm_lead' then select contact_id into v_contact from public.crm_leads where organization_id=v_org_id and id=p_entity_id; elsif p_event_type='contact.tag_added' and p_entity_kind='contact' then select id into v_contact from public.contacts where organization_id=v_org_id and id=p_entity_id; end if; if v_contact is not null and exists(select 1 from public.contacts where organization_id=v_org_id and id=v_contact and not is_anonymized and is_merged_into is null) then v_origin := jsonb_build_object('kind','command', 'observed', public.fn_service_observe_command(v_org_id, v_contact)); end if; end if; insert into public.event_log (organization_id, event_type, entity_kind, entity_id, payload, metadata) values (v_org_id, p_event_type, p_entity_kind, p_entity_id, coalesce(p_payload, '{}'::jsonb) || case when v_origin is null then '{}'::jsonb else jsonb_build_object('service_origin', v_origin) end, coalesce(p_metadata, '{}'::jsonb) || jsonb_build_object('emitted_at', extract(epoch from now()))) returning id into v_event_id; return v_event_id; end $function$; -- A mensagem fica com o nome herdado (`reserved_message_received`): renomeá-la é -- mudança de contrato observável, e NÃO MEDIMOS se alguém a trata por nome. -- ── travas do suporte, depois de toda mudança de privilégio (migration 0274) ─ -- As três tabelas passam a ser server-only, e o ramo server-only da função -- derruba as `support_write_*` que elas tinham. Escrever `drop policy` à mão -- aqui seria a segunda representação da mesma regra. do $f$ begin perform public.fn_aplicar_travas_de_suporte(); end $f$; notify pgrst, 'reload schema'; -- ---- a espera longa dorme: status `dormente` (migration 0308) ---- -- -- A espera longa de um fluxo passa a sobreviver ao contato mandar mensagem, que -- é o que faltava para uma cadência de retorno ("volte a falar daqui a 28 dias") -- caber num fluxo em vez de morar no prompt do agente. Duas coisas a matavam, as -- duas caladas: `lib/followup/reactivity.ts` ou CANCELA a inscrição parada num -- `wait` (`cancel_on_reply`) ou grava `inbound_woke` e CORTA o timer; e o índice -- único anti-spam trancaria o contato fora de qualquer outra cadência por um mês. -- -- O status `dormente` é a projeção em runtime de `wait.immune_to_reply` (campo do -- nó, no grafo pinado) — não uma coluna `imune`, que seria segunda verdade sobre o -- mesmo fato. Ele faz a feature custar ZERO na reatividade: `LIVE_STATUSES` não o -- inclui, então a inscrição dormente nem é carregada (a exceção deliberada é o -- opt-out, que alcança todo mundo). -- -- ⚠️ OS DOIS CHECKs NÃO ESTÃO AQUI, DE PROPÓSITO. Eles vivem no bloco da 0145 -- ("o dossiê do follow-up"), que já os derruba e recria — e `dormente` foi -- acrescentado LÁ, no vocabulário final. Um segundo bloco reconstruindo a mesma -- constraint deixa a tabela SEM constraint entre o drop de um e o add do outro -- quando o `update.sh` reaplica o arquivo, e é reprovado por -- `tests/unit/baseline-constraint-reconstruida.test.ts`. O que sobra aqui é só o -- que não existia antes: o índice do claim e a função que passa a enxergá-lo. -- -- O índice único anti-spam (`idx_followup_enrollments_one_live`) também NÃO é -- tocado: ele enumera os status que ocupam vaga, e `dormente` fica de fora por -- construção — a vaga é liberada sem uma linha de DDL sobre ele. -- ---- 3. o claim tem de enxergar o dormente --------------------------------- -- -- ⚠️ É AQUI QUE ESTA MIGRATION FALHA CALADA se alguém a encurtar. Sem `dormente` -- nas duas listas da função, a inscrição dorme e NUNCA acorda: nada reclama a -- linha, nada reprova, e o retorno simplesmente não acontece no dia 28. create index if not exists idx_followup_enrollments_due_por_org on public.followup_enrollments (organization_id, next_eval_at) where status in ('active','waiting_reply','dormente'); create or replace function fn_claim_due_followup_enrollments(p_limit int, p_lease_seconds int) returns setof followup_enrollments language sql security definer set search_path = public as $$ with orgs as ( -- Sem a condição de claim aqui de propósito: o lateral abaixo a aplica, e uma -- organização cujos vencidos estão todos com lease apenas devolve zero linhas. select distinct organization_id from followup_enrollments where status in ('active','waiting_reply','dormente') and next_eval_at <= now() ), fila as ( select f.id, f.next_eval_at, f.posicao_na_org from orgs cross join lateral ( select d.id, d.next_eval_at, row_number() over (order by d.next_eval_at) as posicao_na_org from followup_enrollments d where d.organization_id = orgs.organization_id and d.status in ('active','waiting_reply','dormente') and d.next_eval_at <= now() and (d.claimed_until is null or d.claimed_until < now()) order by d.next_eval_at limit p_limit ) f ), escolhidos as ( -- O rodízio: posição 1 de todas as organizações, depois a 2 de todas, etc. -- Empate na mesma posição vai para quem esperou mais. select id from fila order by posicao_na_org, next_eval_at limit p_limit ), travados as ( select e.id from followup_enrollments e where e.id in (select id from escolhidos) for update skip locked ) update followup_enrollments e set claimed_until = now() + make_interval(secs => p_lease_seconds), updated_at = now() where e.id in (select id from travados) -- A condição de lease É REPETIDA AQUI, e não é redundante com a CTE `fila`. -- Sem ela, duas conexões simultâneas reclamam as MESMAS linhas: a segunda -- espera o lock da primeira, e quando ele sai o Postgres (READ COMMITTED) -- reavalia só o WHERE do UPDATE — que não olhava `claimed_until` — e grava -- por cima. O `skip locked` da CTE não salva: as duas materializam a mesma -- lista antes de qualquer lock existir. Medido: interseção de 5 em 5 no -- invariante de concorrência (followup-schema.test.ts). and (e.claimed_until is null or e.claimed_until < now()) returning e.*; $$; revoke execute on function fn_claim_due_followup_enrollments(int, int) from public, anon, authenticated; grant execute on function fn_claim_due_followup_enrollments(int, int) to service_role; notify pgrst, 'reload schema'; -- ---- Google Ads: landing page de captura de gclid (migration 0306) ---- -- `lib/plataformas-de-anuncio/registry.ts` (0213) já declarava por que `google_ads` -- não tem transporte de conversão: sem extrator de gclid não há o que reportar. -- Faltava a LANDING PAGE que captura o clique e o carrega para dentro da -- conversa do WhatsApp (o Google Ads, ao contrário da Meta, não tem um -- "Clique para o WhatsApp" nativo). Duas tabelas: para onde a landing -- redireciona, e o par token-curto↔gclid criado no clique e consultado quando -- a mensagem chega. Mesmo desenho server-side-only de `ad_platform_connections` -- (0213): RLS ligada sem policies, grants de anon/authenticated revogados. create table if not exists public.google_ads_landing_pages ( organization_id uuid primary key references public.organizations(id) on delete cascade, whatsapp_e164 text not null, message_template text not null default 'Olá! Vim pelo anúncio e quero saber mais. [ref:{token}]', enabled boolean not null default true, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), updated_by uuid, constraint google_ads_landing_pages_template_tem_placeholder check (message_template like '%{token}%') ); comment on table public.google_ads_landing_pages is 'Configuração da landing page de captura de gclid, por organização: para qual WhatsApp e com qual texto pré-preenchido ela redireciona. Server-side only.'; comment on column public.google_ads_landing_pages.message_template is 'Precisa conter o literal {token}: é onde o código do clique é injetado antes do redirect para o wa.me.'; alter table public.google_ads_landing_pages enable row level security; revoke all on public.google_ads_landing_pages from anon, authenticated; grant select, insert, update, delete on public.google_ads_landing_pages to service_role; drop trigger if exists trg_google_ads_landing_pages_updated_at on public.google_ads_landing_pages; create trigger trg_google_ads_landing_pages_updated_at before update on public.google_ads_landing_pages for each row execute function public.fn_set_updated_at(); create table if not exists public.google_ads_click_refs ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, token text not null, gclid text not null, query_raw jsonb not null default '{}'::jsonb, created_at timestamptz not null default now(), matched_at timestamptz, contact_id uuid references public.contacts(id) on delete set null ); create unique index if not exists google_ads_click_refs_org_token_uk on public.google_ads_click_refs (organization_id, token); comment on table public.google_ads_click_refs is 'Par token curto ↔ gclid, criado quando a landing page recebe um clique de anúncio e consultado quando a mensagem do WhatsApp chega com o token no texto. Server-side only.'; comment on column public.google_ads_click_refs.token is 'Código opaco no texto pré-preenchido do wa.me — não o gclid cru, que fica só nesta linha.'; comment on column public.google_ads_click_refs.matched_at is 'Carimbado no match com a mensagem recebida. Um clique só casa uma vez: a UPDATE que o faz é condicional a matched_at is null.'; alter table public.google_ads_click_refs enable row level security; revoke all on public.google_ads_click_refs from anon, authenticated; grant select, insert, update, delete on public.google_ads_click_refs to service_role; -- ---- Meta: ref curto que carrega as UTMs da landing page (migration 0381) ---- -- Espelho da captura do Google Ads acima, para o caso que falta: a página com -- botão de WhatsApp. O link `wa.me` não fala com o CRM (abre o app no aparelho -- da pessoa), então a origem tem de viajar dentro do TEXTO da mensagem. O -- contrato `[dk1:]` já fazia isso sem servidor e continua valendo; -- estas tabelas trocam os ~200 caracteres de base64 visíveis para o lead — e o -- script que quem monta a página precisaria colar — por `[ref:XXXXXX]` e um -- endereço do próprio CRM. Mesmo desenho server-side-only da 0306: RLS ligada -- sem policies, grants de anon/authenticated revogados. create table if not exists public.meta_ads_landing_pages ( organization_id uuid primary key references public.organizations(id) on delete cascade, whatsapp_e164 text not null, message_template text not null default 'Olá! Vim pelo site. [ref:{token}]', enabled boolean not null default true, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), updated_by uuid, constraint meta_ads_landing_pages_template_tem_placeholder check (message_template like '%{token}%') ); comment on table public.meta_ads_landing_pages is 'Configuração do redirecionamento de captura de UTM, por organização: para qual WhatsApp e com qual texto pré-preenchido a rota pública manda quem clicou no botão da landing page. Server-side only.'; comment on column public.meta_ads_landing_pages.message_template is 'Precisa conter o literal {token}: é onde o ref do clique é injetado antes do redirect para o wa.me.'; alter table public.meta_ads_landing_pages enable row level security; revoke all on public.meta_ads_landing_pages from anon, authenticated; grant select, insert, update, delete on public.meta_ads_landing_pages to service_role; drop trigger if exists trg_meta_ads_landing_pages_updated_at on public.meta_ads_landing_pages; create trigger trg_meta_ads_landing_pages_updated_at before update on public.meta_ads_landing_pages for each row execute function public.fn_set_updated_at(); create table if not exists public.meta_ads_click_refs ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, token text not null, utm jsonb not null, query_raw jsonb not null default '{}'::jsonb, created_at timestamptz not null default now(), matched_at timestamptz, contact_id uuid references public.contacts(id) on delete set null, constraint meta_ads_click_refs_utm_nao_vazio check (utm <> '{}'::jsonb) ); create unique index if not exists meta_ads_click_refs_org_token_uk on public.meta_ads_click_refs (organization_id, token); comment on table public.meta_ads_click_refs is 'Par ref curto ↔ UTM, criado quando a rota pública recebe o clique do botão da landing page e consultado quando a mensagem do WhatsApp chega com o ref no texto. Server-side only, mesmo desenho de google_ads_click_refs (0306).'; comment on column public.meta_ads_click_refs.utm is 'Só as chaves de CHAVES_DE_UTM, já normalizadas — o que não é chave de campanha não atravessa.'; comment on column public.meta_ads_click_refs.matched_at is 'Carimbado no match com a mensagem recebida. Um clique só casa uma vez: a UPDATE que o faz é condicional a matched_at is null.'; alter table public.meta_ads_click_refs enable row level security; revoke all on public.meta_ads_click_refs from anon, authenticated; grant select, insert, update, delete on public.meta_ads_click_refs to service_role; -- ---- Google Ads: credencial de conversão (migration 0307) ---- -- Refresh token OAuth (não access token longo-vivo) + os três identificadores -- que dizem para onde reportar dentro da conta. Mesmo desenho server-side-only -- de ad_platform_connections (0213); ver o cabeçalho da migration 0307 para o -- racional completo. alter table public.ad_platform_connections add column if not exists google_refresh_token_encrypted bytea, add column if not exists google_customer_id text, add column if not exists google_login_customer_id text, add column if not exists google_conversion_action_id text; comment on column public.ad_platform_connections.google_refresh_token_encrypted is 'Refresh token OAuth do Google Ads, cifrado por fn_encrypt_oauth. Só platform=google_ads usa esta coluna — o access token derivado dele expira em ~1h e nunca é persistido.'; comment on column public.ad_platform_connections.google_customer_id is 'A conta de anúncios do Google Ads (10 dígitos, sem hífen) para onde a organização reporta conversões.'; comment on column public.ad_platform_connections.google_login_customer_id is 'A conta de GERENTE (MCC) através da qual google_customer_id é acessada, quando aplicável. NULL = acesso direto, sem MCC.'; comment on column public.ad_platform_connections.google_conversion_action_id is 'Qual ação de conversão, dentro de google_customer_id, recebe os envios de venda. Formato: só o id numérico, o resource name completo é montado no transporte.'; -- ---- o caso anonimizado leva o que a IA escreveu sobre ele (migration 0280) ---- -- 0280 — Anonimizar um contato alcança o CASO que a IA abriu sobre ele. -- -- ─── O defeito ──────────────────────────────────────────────────────────── -- -- Quando o atendimento automático trava, o motor abre um caso e escreve nele o -- que entendeu: título, resumo da conversa e o que falta para resolver, mais o -- recorte da conversa que foi ao modelo (`context_snapshot`). Depois abre um -- aviso na Central com esse texto dentro, e a demanda do pedido guarda o -- assunto. Nada disso é registro de operação: é o relato do problema de uma -- pessoa identificável, escrito por máquina, em quatro tabelas. -- -- `fn_lgpd_cascade_redact_contact` percorre uma lista escrita à mão, e nenhuma -- das quatro estava nela. O modo de falha é o pior que existe para obrigação -- legal: a rota devolve SUCESSO, a contagem por tabela fecha, o SLA de D+15 é -- marcado como cumprido, e o relato continua legível com o nome de quem pediu -- para ser esquecido. Nada erra, nada loga. -- -- ─── O que esta migration faz ───────────────────────────────────────────── -- -- Redefine a cascata com QUATRO passos novos, derivados do corpo vigente (a -- definição de maior número de linha em `supabase/baseline.sql`, copiada, não -- redigitada — o corpo anterior fica byte a byte igual): -- -- agent_cases title/summary/blocker/context_snapshot (vínculo: conversa) -- agent_case_events body/metadata (vínculo: caso) -- demandas assunto (vínculo: contato) -- agent_inbox_items resolve + corpo fixo + solta a referência (polimórfico) -- -- Cada passo preserva o que é OPERAÇÃO — estado, dono, marcas de tempo, -- contagem. Um passo que apagasse a linha inteira ficaria verde num teste de -- "o texto sumiu" e tiraria da organização a resposta a "quantos atendimentos -- houve em março". -- -- ─── O que esta migration NÃO faz ───────────────────────────────────────── -- -- Não cria tabela, coluna, índice nem constraint; não toca dado fora da -- anonimização; não muda assinatura (então `lib/database.types.ts` não muda). -- Não alarga o predicado para os outros `kind` de aviso que apontam para a -- conversa do titular (`routing_unassigned`, `snooze_expired`, -- `promise_unfulfilled` e irmãos): eles têm produtor e dono próprios, e essa -- medição é de outra entrega. -- -- ─── Reaplicação ────────────────────────────────────────────────────────── -- -- `create or replace function` é idempotente por construção. A função já era -- idempotente no efeito: a segunda chamada devolve `already_anonymized` e não -- escreve nada — por isso `resolved_at = now()` no passo dos avisos não oscila -- entre execuções. CREATE OR REPLACE FUNCTION "public"."fn_lgpd_cascade_redact_contact"("p_organization_id" "uuid", "p_contact_id" "uuid", "p_request_id" "uuid") RETURNS "jsonb" LANGUAGE "plpgsql" SECURITY DEFINER SET "search_path" TO 'public' AS $$ declare v_already bool; v_counts jsonb := '{}'::jsonb; v_media_paths text[] := '{}'; v_anon_label text; v_count int; begin perform public.fn_service_lock(p_organization_id,p_contact_id); select is_anonymized into v_already from contacts where id = p_contact_id and organization_id = p_organization_id; if not found then raise exception 'contact not found' using errcode = 'P0002'; end if; if v_already then return jsonb_build_object('already_anonymized', true, 'counts', v_counts, 'media_paths', v_media_paths); end if; v_anon_label := 'Cliente Anonimizado #' || substring(p_contact_id::text from 1 for 8); -- Collect media storage paths (we only delete what we own — media_storage_path) select coalesce(array_agg(distinct media_storage_path) filter (where media_storage_path is not null), '{}') into v_media_paths from messages where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ); -- 1. contacts (irreversible) update contacts set name = v_anon_label, display_name = v_anon_label, email = null, -- email_normalized NÃO entra: é GENERATED ALWAYS AS (lower(trim(email))) -- e o Postgres recusa escrita nela — a linha acima já a zera por derivação. -- Com a atribuição, o cascade INTEIRO abortava e nada era anonimizado. phone_number = null, cpf_encrypted = null, cpf_hash = null, birthdate = null, is_anonymized = true, anonymized_at = now(), consent = '{}'::jsonb, source_metadata = '{}'::jsonb, tags = '{}'::text[], updated_at = now() where id = p_contact_id and organization_id = p_organization_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('contacts', v_count); -- 2. conversations metadata + preview strip update conversations set metadata = '{}'::jsonb, last_message_preview = null, updated_at = now() where contact_id = p_contact_id and organization_id = p_organization_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('conversations', v_count); -- 3. messages: redact body + null media + strip metadata (preserve status/timestamps/conversation_id) update messages set body = '[mensagem anonimizada]', media_url = null, media_mime = null, media_size_bytes = null, media_storage_path = null, metadata = '{}'::jsonb, updated_at = now() where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('messages', v_count); -- 4. crm_lead_activities — strip payload, metadata E reason (migration 0071). -- `reason` é texto livre escrito por LLM sobre a conversa do lead: supor que -- nunca conterá um nome é a suposição que falha. `evidence` NÃO é limpa — -- guarda só ids, e as linhas apontadas são redigidas por conta própria. update crm_lead_activities set payload = '{}'::jsonb, metadata = '{}'::jsonb, reason = null where organization_id = p_organization_id and ( contact_id = p_contact_id or lead_id in ( select lead_id from crm_lead_links where target_kind = 'contact' and target_id = p_contact_id and organization_id = p_organization_id ) or lead_id in ( select id from crm_leads where contact_id = p_contact_id and organization_id = p_organization_id ) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('activities', v_count); -- 5. crm_leads — strip title/description/custom_fields/source_metadata/tags but PRESERVE pipeline/stage/value update crm_leads set title = v_anon_label, description = null, custom_fields = '{}'::jsonb, source_metadata = '{}'::jsonb, tags = '{}'::text[], updated_at = now() where organization_id = p_organization_id and ( contact_id = p_contact_id or id in ( select lead_id from crm_lead_links where target_kind = 'contact' and target_id = p_contact_id and organization_id = p_organization_id ) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('leads', v_count); -- 6. orders — PRESERVE values + status + timestamps. Strip personal fields from payload jsonb -- and replace customer_external_id with null (FK-safe; soft de-link). Keep contact_id null. update orders set payload = (coalesce(payload, '{}'::jsonb)) - 'customer' - 'customer_name' - 'customer_email' - 'customer_phone' - 'shipping_address' - 'billing_address' - 'contact_identification', customer_external_id = null, contact_id = null, is_anonymized = true, updated_at = now() where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('orders', v_count); -- 7. enqueue media for async deletion (idempotent via unique (bucket, object_path)) if array_length(v_media_paths, 1) > 0 then insert into storage_redaction_queue (organization_id, request_id, bucket, object_path) select p_organization_id, p_request_id, 'whatsapp-media', path from unnest(v_media_paths) as path where path is not null and length(path) > 0 on conflict (bucket, object_path) do nothing; end if; -- 7b. voice_calls — o TELEFONE de quem falou ao telefone (migration 0235). -- -- `peer_phone` é `not null` e guarda o número da outra ponta: depois de -- anonimizar o contato, ele sobrevivia ligado ao `contact_id` e reidentificava -- a pessoa que pediu para ser esquecida. É o mesmo argumento que a foto de -- perfil já tinha (ver o bloco do avatar em `lib/lgpd/redact-cascade.ts`): -- anonimizar em toda parte menos numa é não ter anonimizado. -- -- O que fica: direção, status, motivo do fim, marcas de tempo e duração. Um -- registro de "houve uma chamada de 12 minutos" sem número e sem dono não -- identifica ninguém e é o que sustenta a métrica do atendente e a fatura. -- `peer_phone` é NOT NULL, então recebe o rótulo, não `null`. update voice_calls set peer_phone = v_anon_label, owner_user_id = null, created_by = null, updated_at = now() where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('voice_calls', v_count); -- agent_cases — o que a IA escreveu SOBRE a pessoa quando travou (migration 0280). -- -- O caso é o texto que a equipe lê antes de decidir: `title`, `summary` e -- `blocker` saem do modelo a partir da conversa, e `context_snapshot` é o -- recorte dessa conversa que o motor mandou para ele. Nada disso é registro de -- operação — é o relato do problema de uma pessoa identificável, escrito por -- máquina. Sem este passo, anonimizar devolvia SUCESSO com o relato intacto. -- -- As três colunas de texto são `not null`: recebem rótulo e texto fixo, nunca -- `null` (a mesma razão de `voice_calls.peer_phone` logo acima). -- -- ⚠️ `updated_at` FICA FORA DO `set`, de propósito. O cobrador de caso parado -- (`app/api/v1/cron/case-stale-watcher/route.ts`) lê `updated_at` como "alguém -- da equipe encostou neste caso". A cascata não é alguém encostando: escrever -- ali faria a anonimização ADIAR a cobrança de um caso que continua parado, e -- o efeito só apareceria como um cliente esperando mais tempo. -- -- O vínculo é pela CONVERSA porque `agent_cases` não tem FK para `contacts`. update agent_cases set title = v_anon_label, summary = '[resumo anonimizado]', blocker = '[bloqueio anonimizado]', context_snapshot = '{}'::jsonb where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('agent_cases', v_count); -- agent_case_events — a linha do tempo do caso (migration 0280). -- -- `body` é o que a pessoa da equipe escreveu ao responder o caso e o que o -- agente registrou sobre o que o LEAD respondeu; `metadata` carrega o recorte -- que o motor anexou. `kind`, `actor_kind`, `human_action` e `created_at` -- FICAM: são o registro de que houve um toque humano e quando — operação, não -- dado da pessoa, e é deles que sai a métrica de atendimento. update agent_case_events set body = null, metadata = '{}'::jsonb where organization_id = p_organization_id and case_id in ( select id from agent_cases where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('agent_case_events', v_count); -- demandas — o assunto do pedido (migration 0280). -- -- `assunto` é texto livre sobre o que a pessoa pediu. O resto da linha é a -- operação da demanda (origem, estado, dono, prazo, desfecho) e fica de pé: -- apagar a linha inteira tiraria da organização a resposta a "quantos pedidos -- houve em março", que é o mesmo argumento do compromisso da agenda. -- -- FK direta (`demandas.contact_id` é `not null`), então o vínculo é o contato. update demandas set assunto = null where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('demandas', v_count); -- agent_inbox_items — o aviso que leva o texto do caso para a Central (migration 0280). -- -- O `body` do aviso de caso parado EMBUTE o título do caso -- (`app/api/v1/cron/case-stale-watcher/route.ts:128`), e o do handoff embute o -- motivo da parada (`lib/ai/handoff/orchestrator.ts:335`). Redigir o caso e -- deixar o aviso de pé seria anonimizar em toda parte menos numa — que é não -- ter anonimizado. O molde (resolver + trocar o corpo + soltar a referência) é -- o de `fn_meet_redact_contact`, que já faz isto para o aviso de compromisso. -- -- ⚠️ O VÍNCULO É POLIMÓRFICO E TEM TRÊS BRAÇOS, não dois. Medido nos -- produtores, não suposto: `handoff` nasce com `ref_kind='contact'` -- (`lib/ai/handoff/orchestrator.ts:339`) E com `ref_kind='conversation'` -- (`lib/agent-engine/agent/inbound-turn.ts:4100`); `case_stale` nasce SEMPRE -- com `ref_kind='agent_case'` (a rota do cron acima, e a política em -- `lib/ai/inbox-destino.ts:38`). Um predicado com só os dois primeiros braços -- casa ZERO avisos de caso parado — e casar zero linha não é erro: é sucesso -- com o texto intacto. -- -- Os `kind` são os MEDIDOS no CHECK vigente (`supabase/baseline.sql`, bloco -- único de `agent_inbox_items_kind_check`). `case_opened` NÃO existe, e kind -- inexistente num `in (...)` também casa zero e devolve sucesso. Para -- reconferir sem acreditar nesta prosa: -- grep -n "agent_inbox_items_kind_check check" -A40 supabase/baseline.sql update agent_inbox_items set status = 'resolved', resolved_at = now(), body = 'Contato anonimizado.', ref_id = null where organization_id = p_organization_id and kind in ('handoff', 'case_stale') and ( (ref_kind = 'contact' and ref_id = p_contact_id) or (ref_kind = 'conversation' and ref_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id )) or (ref_kind = 'agent_case' and ref_id in ( select id from agent_cases where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ) )) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('agent_inbox_items', v_count); -- 8. dense audit row insert into api_audit_log (organization_id, action, actor_user_id, resource_type, resource_id, metadata, bypassed_rls) values ( p_organization_id, 'lgpd.redact_executed', null, 'contact', p_contact_id, jsonb_build_object( 'cascaded_to', v_counts, 'media_queued', coalesce(array_length(v_media_paths, 1), 0), 'request_id', p_request_id ), true ); return jsonb_build_object( 'already_anonymized', false, 'counts', v_counts, 'media_paths', v_media_paths ); end; $$; revoke all on function public.fn_lgpd_cascade_redact_contact(uuid,uuid,uuid) from public,anon,authenticated; grant execute on function public.fn_lgpd_cascade_redact_contact(uuid,uuid,uuid) to service_role; notify pgrst, 'reload schema'; -- ---- a conversa do caso: a consulta interna da equipe (migration 0281) ---- -- 0281 — Conversar com o caso: onde a consulta interna da equipe à IA fica. -- -- ─── POR QUE TABELA PRÓPRIA, e não um lugar que já existe ───────────────── -- -- As três alternativas óbvias quebram consumidores VIVOS, e as três foram -- medidas antes de a tabela ser escrita: -- -- · `conversation_notes` → `lerContinuidadeHumana` (lib/escalacao/ -- continuidade.ts) lê essa tabela e `montarResumo` põe o texto, literal, -- no prompt do agente que fala com o CLIENTE. A pergunta do atendente -- viraria ordem para a IA — e o cliente leria a consequência. -- · `agent_case_events` com `actor_kind='human'` → `fn_atrito_metrics` conta -- `count(*)` como intervenções e `min(created_at)` como primeiro toque. -- Perguntar zeraria a espera da fila sem ninguém ter decidido nada: o -- Índice de Atrito passaria a medir LEITURA em vez de DECISÃO. -- · `agent_cases`, qualquer coluna → `updated_at` é o "alguém encostou" do -- cobrador de caso parado (`app/api/v1/cron/case-stale-watcher`). Escrever -- ali calaria o vigia. Esta tabela NÃO toca `agent_cases`, e a ausência é -- garantia e não esperança: não há trigger de `updated_at` naquela tabela. -- -- ─── POR QUE A COLUNA DE TEXTO SE CHAMA `body` ──────────────────────────── -- -- De propósito, e não por gosto. `tests/invariants/lgpd-cascata-alcanca-quem- -- guarda-pessoa.test.ts` só enxerga a tabela que satisfaz as DUAS condições: -- FK para `contacts` E coluna cujo NOME case o padrão de PII -- (`…|notes|note|body|content|title|subject…`). Uma tabela com colunas -- `pergunta`/`resposta` nasceria INVISÍVEL ao gate — que é exatamente o ponto -- cego que a investigação mediu. Escolher o nome que o gate lê é mais barato -- que ensinar o gate a ler outro nome. `contact_id` existe pela mesma razão. -- -- ─── O QUE ESTA MIGRATION FAZ ───────────────────────────────────────────── -- -- 1. cria `public.agent_case_chat_messages` — uma linha por MENSAGEM, com -- `turn_id` agrupando pergunta e resposta; -- 2. liga a RLS de LEITURA em três condições (organização + papel `agent` + -- visibilidade da conversa) e deixa a tabela SERVER-ONLY na escrita; -- 3. cria `fn_expurgar_conversa_do_caso_vencida` (365 dias, piso de 90 no -- CORPO), que o cron diário de retenção passa a chamar; -- 4. redefine `fn_lgpd_cascade_redact_contact` com UM passo novo, derivado -- do corpo VIGENTE (a definição de maior número de linha no -- `supabase/baseline.sql`, copiada por script — o corpo anterior fica -- byte a byte igual). -- -- ─── REAPLICAÇÃO ────────────────────────────────────────────────────────── -- -- `create table if not exists`, `create index if not exists`, `drop policy if -- exists` + `create policy`, `create or replace function`. O `update.sh` de um -- clone reaplica sem erro e sem duplicar efeito. create table if not exists public.agent_case_chat_messages ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, case_id uuid not null references public.agent_cases(id) on delete cascade, -- DUAS COLUNAS DENORMALIZADAS DE PROPÓSITO (doutrina DIRC: Duplicar, não -- Referenciar): -- · `conversation_id`: a policy de SELECT precisa da conversa SEM passar -- por `agent_cases`, cuja policy é org-wide e reintroduziria exatamente o -- vazamento que esta feature fecha; -- · `contact_id`: é a FK que põe a tabela no escopo do invariante de LGPD e -- o filtro que a redação e o export usam. Sem ela, anonimizar devolveria -- sucesso com a conversa legível, e nenhum gate acusaria. conversation_id uuid not null references public.conversations(id) on delete cascade, contact_id uuid not null references public.contacts(id) on delete cascade, -- Agrupa pergunta + resposta. GERADO NO CLIENTE: é ele que dá a idempotência -- sem copiar a resposta para `idempotency_keys` (ver a unique lá embaixo). turn_id uuid not null, author_kind text not null check (author_kind in ('human','ai')), -- `on delete set null`: a saída de uma pessoa do sistema não apaga o que ela -- perguntou. Por isso NÃO existe check acoplando `author_kind` a -- `author_user_id` — ele quebraria o próprio `set null`. author_user_id uuid references auth.users(id) on delete set null, -- null quando a resposta falhou, ou quando a redação de LGPD passou por aqui. body text, -- null = deu certo. NÃO existe coluna `status`: ela seria a segunda -- representação do mesmo fato (anti-pattern 2 do CLAUDE.md). error_code text, -- Quem RESPONDEU de fato. null = respondeu a persona padrão da organização -- (agente do caso ausente, arquivado, pausado ou despublicado). A persona "de -- agora" é recalculada a cada requisição; esta coluna é o registro histórico. agent_id uuid references public.ai_agents(id) on delete set null, llm_call_id uuid references public.llm_calls(id) on delete set null, -- O atendimento que originou o caso já tinha mudado quando esta resposta foi -- dada. Mesmo vocabulário do audit da rota de resposta ao caso. service_stale boolean not null default false, redacted_at timestamptz, created_at timestamptz not null default now(), -- A IDEMPOTÊNCIA DO POST, no banco e NÃO em `idempotency_keys`: o helper -- `comIdempotencia` grava `response_body` (lib/api/idempotency.ts), que seria -- uma cópia da resposta sobre a pessoa numa tabela FORA da cascata de LGPD e -- SEM expurgo nenhum (`grep -rn "idempotency_keys" app/api/v1/cron lib/retencao -- lib/lgpd` → vazio). Esta unique resolve o clique duplo E a corrida que o -- próprio helper declara não cobrir. constraint agent_case_chat_messages_turno_unico unique (organization_id, case_id, turn_id, author_kind) ); -- Auto-cura para o clone que já tenha uma versão ANTERIOR da tabela: o -- `create table if not exists` acima é no-op ali. Só as colunas que podem ser -- acrescentadas a uma tabela COM LINHAS entram — as `not null` sem default não -- podem, e não precisam: a tabela nasce aqui, então nenhum clone tem uma forma -- anterior dela sem elas. alter table public.agent_case_chat_messages add column if not exists author_user_id uuid; alter table public.agent_case_chat_messages add column if not exists body text; alter table public.agent_case_chat_messages add column if not exists error_code text; alter table public.agent_case_chat_messages add column if not exists agent_id uuid; alter table public.agent_case_chat_messages add column if not exists llm_call_id uuid; alter table public.agent_case_chat_messages add column if not exists service_stale boolean not null default false; alter table public.agent_case_chat_messages add column if not exists redacted_at timestamptz; -- O CHECK e a unique em bloco próprio, para o clone que tenha a tabela sem -- eles. `drop` + `add` é auto-curativo; a tabela nasce vazia, então não há dado -- a corrigir antes (a regra 8 da doutrina de migrations). alter table public.agent_case_chat_messages drop constraint if exists agent_case_chat_messages_author_kind_check; alter table public.agent_case_chat_messages add constraint agent_case_chat_messages_author_kind_check check (author_kind in ('human','ai')); alter table public.agent_case_chat_messages drop constraint if exists agent_case_chat_messages_turno_unico; alter table public.agent_case_chat_messages add constraint agent_case_chat_messages_turno_unico unique (organization_id, case_id, turn_id, author_kind); -- Três índices, um propósito cada, e nenhum é prefixo de outro (a memória da -- 0259: índice redundante sai). create index if not exists agent_case_chat_messages_case_idx on public.agent_case_chat_messages (organization_id, case_id, created_at); -- Redação e export só procuram o que ainda é legível. create index if not exists agent_case_chat_messages_contact_idx on public.agent_case_chat_messages (organization_id, contact_id) where redacted_at is null; -- O expurgo ordena por `created_at`. create index if not exists agent_case_chat_messages_purga_idx on public.agent_case_chat_messages (created_at); alter table public.agent_case_chat_messages enable row level security; -- ── Privilégio: leitura pelo login, escrita SÓ pelo servidor ─────────────── -- `revoke all` PRIMEIRO porque o `ALTER DEFAULT PRIVILEGES … GRANT ALL ON -- TABLES TO "authenticated"` do baseline vem ANTES de toda tabela de apêndice: -- sem o revoke, a tabela nasce com INSERT/UPDATE/DELETE para `authenticated` e -- a policy seria a única coisa entre um `viewer` e a escrita. Mesmo desenho de -- `ai_reply_drafts` (0227) e das três tabelas da 0279. revoke all on public.agent_case_chat_messages from anon, authenticated; grant select on public.agent_case_chat_messages to authenticated; grant all on public.agent_case_chat_messages to service_role; -- ── RLS: organização + papel + VISIBILIDADE DA CONVERSA ─────────────────── -- A terceira condição é a razão de a tabela carregar `conversation_id` dentro. -- `fn_can_view_conversation` restringe SÓ o papel `agent`: viewer/manager/admin -- leem tudo por desenho, que é a decisão do dono do produto. Policy POR COMANDO -- (`for select`), nunca `for all` — e aqui nem se trata disso: não existe -- caminho de escrita pelo PostgREST, porque o `revoke` acima o fechou. drop policy if exists tenant_isolation_agent_case_chat_messages_select on public.agent_case_chat_messages; create policy tenant_isolation_agent_case_chat_messages_select on public.agent_case_chat_messages for select to authenticated using ( organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'agent') and exists ( select 1 from public.conversations c where c.organization_id = agent_case_chat_messages.organization_id and c.id = agent_case_chat_messages.conversation_id and public.fn_can_view_conversation(c.organization_id, c.assigned_to_user_id) ) ); -- ── Retenção: a tabela nasce com dono de piso ───────────────────────────── create or replace function public.fn_expurgar_conversa_do_caso_vencida( p_retencao_dias int default null, p_limite int default null ) returns int language plpgsql security definer set search_path = public, pg_temp as $$ declare -- 365 = um ano fiscal: depois disso, "por que decidimos assim" é respondido -- pelos EVENTOS do caso, não pela deliberação que os precedeu. O piso de 90 -- impede que o knob vire apagador de rastro recente — o mesmo piso da -- auditoria, e pela mesma razão. O piso mora AQUI, no corpo, porque só assim -- ele vale para QUALQUER chamador, inclusive um `psql` na mão. v_dias int := greatest(coalesce(p_retencao_dias, 365), 90); v_limite int := least(greatest(coalesce(p_limite, 1000), 1), 10000); v_apagadas int; begin with vencidas as ( select m.id from public.agent_case_chat_messages m where m.created_at < now() - make_interval(days => v_dias) order by m.created_at limit v_limite ) delete from public.agent_case_chat_messages m using vencidas v where m.id = v.id; get diagnostics v_apagadas = row_count; return v_apagadas; end; $$; -- As DUAS origens de EXECUTE: o `ALTER DEFAULT PRIVILEGES … GRANT ALL ON -- FUNCTIONS TO anon` do baseline (que `revoke from public` não remove) e o -- grant implícito a PUBLIC que o Postgres dá a toda função ao criá-la (que -- `revoke from anon` não remove). Fechar uma só deixa a função exposta com o -- gate verde. revoke all on function public.fn_expurgar_conversa_do_caso_vencida(int,int) from public, anon, authenticated; grant execute on function public.fn_expurgar_conversa_do_caso_vencida(int,int) to service_role; -- ── A cascata de LGPD alcança a conversa do caso ────────────────────────── -- Derivada do corpo VIGENTE do baseline (a definição de maior número de linha), -- por script, nunca redigitada: o corpo anterior fica byte a byte igual e o -- único acréscimo é o passo de `agent_case_chat_messages`. O Postgres troca o -- corpo INTEIRO num `create or replace` — quem derivar da versão errada apaga -- o passo de outra entrega sem um único erro. A catraca que vigia isso é -- `tests/invariants/cascata-lgpd-nao-encolhe.test.ts`. CREATE OR REPLACE FUNCTION "public"."fn_lgpd_cascade_redact_contact"("p_organization_id" "uuid", "p_contact_id" "uuid", "p_request_id" "uuid") RETURNS "jsonb" LANGUAGE "plpgsql" SECURITY DEFINER SET "search_path" TO 'public' AS $$ declare v_already bool; v_counts jsonb := '{}'::jsonb; v_media_paths text[] := '{}'; v_anon_label text; v_count int; begin perform public.fn_service_lock(p_organization_id,p_contact_id); select is_anonymized into v_already from contacts where id = p_contact_id and organization_id = p_organization_id; if not found then raise exception 'contact not found' using errcode = 'P0002'; end if; if v_already then return jsonb_build_object('already_anonymized', true, 'counts', v_counts, 'media_paths', v_media_paths); end if; v_anon_label := 'Cliente Anonimizado #' || substring(p_contact_id::text from 1 for 8); -- Collect media storage paths (we only delete what we own — media_storage_path) select coalesce(array_agg(distinct media_storage_path) filter (where media_storage_path is not null), '{}') into v_media_paths from messages where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ); -- 1. contacts (irreversible) update contacts set name = v_anon_label, display_name = v_anon_label, email = null, -- email_normalized NÃO entra: é GENERATED ALWAYS AS (lower(trim(email))) -- e o Postgres recusa escrita nela — a linha acima já a zera por derivação. -- Com a atribuição, o cascade INTEIRO abortava e nada era anonimizado. phone_number = null, cpf_encrypted = null, cpf_hash = null, birthdate = null, is_anonymized = true, anonymized_at = now(), consent = '{}'::jsonb, source_metadata = '{}'::jsonb, tags = '{}'::text[], updated_at = now() where id = p_contact_id and organization_id = p_organization_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('contacts', v_count); -- 2. conversations metadata + preview strip update conversations set metadata = '{}'::jsonb, last_message_preview = null, updated_at = now() where contact_id = p_contact_id and organization_id = p_organization_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('conversations', v_count); -- 3. messages: redact body + null media + strip metadata (preserve status/timestamps/conversation_id) update messages set body = '[mensagem anonimizada]', media_url = null, media_mime = null, media_size_bytes = null, media_storage_path = null, metadata = '{}'::jsonb, updated_at = now() where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('messages', v_count); -- 4. crm_lead_activities — strip payload, metadata E reason (migration 0071). -- `reason` é texto livre escrito por LLM sobre a conversa do lead: supor que -- nunca conterá um nome é a suposição que falha. `evidence` NÃO é limpa — -- guarda só ids, e as linhas apontadas são redigidas por conta própria. update crm_lead_activities set payload = '{}'::jsonb, metadata = '{}'::jsonb, reason = null where organization_id = p_organization_id and ( contact_id = p_contact_id or lead_id in ( select lead_id from crm_lead_links where target_kind = 'contact' and target_id = p_contact_id and organization_id = p_organization_id ) or lead_id in ( select id from crm_leads where contact_id = p_contact_id and organization_id = p_organization_id ) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('activities', v_count); -- 5. crm_leads — strip title/description/custom_fields/source_metadata/tags but PRESERVE pipeline/stage/value update crm_leads set title = v_anon_label, description = null, custom_fields = '{}'::jsonb, source_metadata = '{}'::jsonb, tags = '{}'::text[], updated_at = now() where organization_id = p_organization_id and ( contact_id = p_contact_id or id in ( select lead_id from crm_lead_links where target_kind = 'contact' and target_id = p_contact_id and organization_id = p_organization_id ) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('leads', v_count); -- 6. orders — PRESERVE values + status + timestamps. Strip personal fields from payload jsonb -- and replace customer_external_id with null (FK-safe; soft de-link). Keep contact_id null. update orders set payload = (coalesce(payload, '{}'::jsonb)) - 'customer' - 'customer_name' - 'customer_email' - 'customer_phone' - 'shipping_address' - 'billing_address' - 'contact_identification', customer_external_id = null, contact_id = null, is_anonymized = true, updated_at = now() where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('orders', v_count); -- 7. enqueue media for async deletion (idempotent via unique (bucket, object_path)) if array_length(v_media_paths, 1) > 0 then insert into storage_redaction_queue (organization_id, request_id, bucket, object_path) select p_organization_id, p_request_id, 'whatsapp-media', path from unnest(v_media_paths) as path where path is not null and length(path) > 0 on conflict (bucket, object_path) do nothing; end if; -- 7b. voice_calls — o TELEFONE de quem falou ao telefone (migration 0235). -- -- `peer_phone` é `not null` e guarda o número da outra ponta: depois de -- anonimizar o contato, ele sobrevivia ligado ao `contact_id` e reidentificava -- a pessoa que pediu para ser esquecida. É o mesmo argumento que a foto de -- perfil já tinha (ver o bloco do avatar em `lib/lgpd/redact-cascade.ts`): -- anonimizar em toda parte menos numa é não ter anonimizado. -- -- O que fica: direção, status, motivo do fim, marcas de tempo e duração. Um -- registro de "houve uma chamada de 12 minutos" sem número e sem dono não -- identifica ninguém e é o que sustenta a métrica do atendente e a fatura. -- `peer_phone` é NOT NULL, então recebe o rótulo, não `null`. update voice_calls set peer_phone = v_anon_label, owner_user_id = null, created_by = null, updated_at = now() where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('voice_calls', v_count); -- agent_cases — o que a IA escreveu SOBRE a pessoa quando travou (migration 0280). -- -- O caso é o texto que a equipe lê antes de decidir: `title`, `summary` e -- `blocker` saem do modelo a partir da conversa, e `context_snapshot` é o -- recorte dessa conversa que o motor mandou para ele. Nada disso é registro de -- operação — é o relato do problema de uma pessoa identificável, escrito por -- máquina. Sem este passo, anonimizar devolvia SUCESSO com o relato intacto. -- -- As três colunas de texto são `not null`: recebem rótulo e texto fixo, nunca -- `null` (a mesma razão de `voice_calls.peer_phone` logo acima). -- -- ⚠️ `updated_at` FICA FORA DO `set`, de propósito. O cobrador de caso parado -- (`app/api/v1/cron/case-stale-watcher/route.ts`) lê `updated_at` como "alguém -- da equipe encostou neste caso". A cascata não é alguém encostando: escrever -- ali faria a anonimização ADIAR a cobrança de um caso que continua parado, e -- o efeito só apareceria como um cliente esperando mais tempo. -- -- O vínculo é pela CONVERSA porque `agent_cases` não tem FK para `contacts`. update agent_cases set title = v_anon_label, summary = '[resumo anonimizado]', blocker = '[bloqueio anonimizado]', context_snapshot = '{}'::jsonb where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('agent_cases', v_count); -- agent_case_events — a linha do tempo do caso (migration 0280). -- -- `body` é o que a pessoa da equipe escreveu ao responder o caso e o que o -- agente registrou sobre o que o LEAD respondeu; `metadata` carrega o recorte -- que o motor anexou. `kind`, `actor_kind`, `human_action` e `created_at` -- FICAM: são o registro de que houve um toque humano e quando — operação, não -- dado da pessoa, e é deles que sai a métrica de atendimento. update agent_case_events set body = null, metadata = '{}'::jsonb where organization_id = p_organization_id and case_id in ( select id from agent_cases where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('agent_case_events', v_count); -- demandas — o assunto do pedido (migration 0280). -- -- `assunto` é texto livre sobre o que a pessoa pediu. O resto da linha é a -- operação da demanda (origem, estado, dono, prazo, desfecho) e fica de pé: -- apagar a linha inteira tiraria da organização a resposta a "quantos pedidos -- houve em março", que é o mesmo argumento do compromisso da agenda. -- -- FK direta (`demandas.contact_id` é `not null`), então o vínculo é o contato. update demandas set assunto = null where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('demandas', v_count); -- agent_inbox_items — o aviso que leva o texto do caso para a Central (migration 0280). -- -- O `body` do aviso de caso parado EMBUTE o título do caso -- (`app/api/v1/cron/case-stale-watcher/route.ts:128`), e o do handoff embute o -- motivo da parada (`lib/ai/handoff/orchestrator.ts:335`). Redigir o caso e -- deixar o aviso de pé seria anonimizar em toda parte menos numa — que é não -- ter anonimizado. O molde (resolver + trocar o corpo + soltar a referência) é -- o de `fn_meet_redact_contact`, que já faz isto para o aviso de compromisso. -- -- ⚠️ O VÍNCULO É POLIMÓRFICO E TEM TRÊS BRAÇOS, não dois. Medido nos -- produtores, não suposto: `handoff` nasce com `ref_kind='contact'` -- (`lib/ai/handoff/orchestrator.ts:339`) E com `ref_kind='conversation'` -- (`lib/agent-engine/agent/inbound-turn.ts:4100`); `case_stale` nasce SEMPRE -- com `ref_kind='agent_case'` (a rota do cron acima, e a política em -- `lib/ai/inbox-destino.ts:38`). Um predicado com só os dois primeiros braços -- casa ZERO avisos de caso parado — e casar zero linha não é erro: é sucesso -- com o texto intacto. -- -- Os `kind` são os MEDIDOS no CHECK vigente (`supabase/baseline.sql`, bloco -- único de `agent_inbox_items_kind_check`). `case_opened` NÃO existe, e kind -- inexistente num `in (...)` também casa zero e devolve sucesso. Para -- reconferir sem acreditar nesta prosa: -- grep -n "agent_inbox_items_kind_check check" -A40 supabase/baseline.sql update agent_inbox_items set status = 'resolved', resolved_at = now(), body = 'Contato anonimizado.', ref_id = null where organization_id = p_organization_id and kind in ('handoff', 'case_stale') and ( (ref_kind = 'contact' and ref_id = p_contact_id) or (ref_kind = 'conversation' and ref_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id )) or (ref_kind = 'agent_case' and ref_id in ( select id from agent_cases where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ) )) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('agent_inbox_items', v_count); -- agent_case_chat_messages — a consulta interna da equipe à IA SOBRE o caso -- (migration 0281). FK DIRETA para `contacts`, então o vínculo é o titular e -- não precisa passar pela conversa. -- -- `redacted_at is null` no `where` é o que torna o passo IDEMPOTENTE: a -- varredura diária de redações incompletas roda a função de novo, e sem essa -- condição o carimbo de QUANDO se apagou seria reescrito a cada rodada. -- -- A linha NÃO é apagada, só o texto: quem abrir o caso depois continua vendo -- que a equipe perguntou N vezes, quando, e se a IA respondeu. Apagar a linha -- inteira ficaria verde num teste de "o texto sumiu" e tiraria da organização -- a resposta a "quanto a equipe deliberou sobre este caso". update agent_case_chat_messages set body = null, redacted_at = now() where organization_id = p_organization_id and contact_id = p_contact_id and redacted_at is null; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('agent_case_chat_messages', v_count); -- 8. dense audit row insert into api_audit_log (organization_id, action, actor_user_id, resource_type, resource_id, metadata, bypassed_rls) values ( p_organization_id, 'lgpd.redact_executed', null, 'contact', p_contact_id, jsonb_build_object( 'cascaded_to', v_counts, 'media_queued', coalesce(array_length(v_media_paths, 1), 0), 'request_id', p_request_id ), true ); return jsonb_build_object( 'already_anonymized', false, 'counts', v_counts, 'media_paths', v_media_paths ); end; $$; revoke all on function public.fn_lgpd_cascade_redact_contact(uuid,uuid,uuid) from public,anon,authenticated; grant execute on function public.fn_lgpd_cascade_redact_contact(uuid,uuid,uuid) to service_role; -- ── travas do suporte, depois de toda tabela nova (migration 0274) ───────── -- `agent_case_chat_messages` nasce SERVER-ONLY (revoke de anon/authenticated + -- grant select), então o ramo server-only da função lhe dá ZERO policies -- `support_write_*` — que é o contrato mais restritivo. Escrever `drop policy` -- à mão aqui seria a segunda representação da mesma regra. do $f$ begin perform public.fn_aplicar_travas_de_suporte(); end $f$; notify pgrst, 'reload schema'; -- ---- a passagem para uma pessoa vira um fato com registro (migration 0291) ---- -- 0291 — A passagem do atendimento para uma pessoa vira um FATO com registro. -- -- ─── O que se perdia, e onde ────────────────────────────────────────────── -- -- Existem dois motores que tiram a conversa do automático: `performHumanHandoff` -- (`lib/agent-engine`, `pg.Pool`) e `triggerHandoff` (`lib/ai/handoff`, -- supabase-js). O primeiro monta um resumo do checkpoint e o enfia no corpo do -- aviso da Central; o segundo abre o aviso SEM resumo nenhum. Em nenhum dos dois -- sobrevive o que a pessoa que assume precisa: POR QUE a IA passou, o que ela já -- tentou, o que o cliente pediu com as palavras dele, e se ele chegou a ser -- avisado de que alguém vai responder. -- -- O resultado, medido em conversa real: quem assume relê a conversa inteira e -- repete as perguntas que a IA já fez. O cliente responde duas vezes. É esse o -- laço de retorno desta entrega — se o briefing chegou, a repetição cai; se não -- chegou, ela não muda e a feature é decoração. -- -- ─── POR QUE TABELA PRÓPRIA, e não um veículo que já existe ─────────────── -- -- `crm_lead_activities.reason` é o candidato óbvio (a transferência manual já o -- usa), e as três razões de ele não servir foram medidas antes: -- -- 1. a coluna é declarada "O PORQUÊ, legível por humano. Sem PII" em -- `lib/leads/activity-emitter.ts`, e é por isso que `performHumanHandoff` -- grava ali o texto FIXO "Atendimento passado para uma pessoa". O briefing -- é resumo de conversa: é PII por construção; -- 2. a atividade é roteada para um NEGÓCIO ABERTO (`emitAgentActivityForContact`) -- e sem negócio ela não nasce — passagem sem lead existiria e seria invisível; -- 3. o registro precisa de ESTADO (`reconhecido_por`/`reconhecido_em`) e de -- estrutura (as tentativas). `crm_lead_activities` não tem onde pôr isso sem -- virar `jsonb` lido por path, que é o anti-pattern nº 6 do CLAUDE.md. -- -- ─── POR QUE AS COLUNAS SE CHAMAM `title`/`body`/`notes`/`content` ──────── -- -- De propósito, e não por gosto — é a mesma escolha da 0281. -- `tests/invariants/lgpd-cascata-alcanca-quem-guarda-pessoa.test.ts` só enxerga -- a tabela que satisfaz as DUAS condições: FK para `contacts` E coluna cujo NOME -- case o padrão de PII (`…|notes|note|body|content|title|subject…`). Uma tabela -- com `resumo`/`motivo_texto`/`cliente_quer` — que foi o desenho anterior — -- nasceria INVISÍVEL ao gate, e a cobertura dependeria de alguém lembrar de um -- invariante comportamental que uma sessão futura pode apagar com o gate de -- classe verde. Escolher o nome que o instrumento lê é mais barato que ensinar o -- instrumento a ler outro nome. -- -- ─── DOUTRINA DIRC, respondida ──────────────────────────────────────────── -- -- Duplicar — não: motivo, narrativa e tentativas não existem hoje em lugar -- nenhum (`rg -n -i "o que (já )?tentou|ja_tentou"` → vazio); -- Integrar — `contact_id`/`conversation_id`/`caso_id` são FK, não cópias; -- Referenciar— `motor`/`origem`/`motivo_codigo` são vocabulário fechado; -- Calcular — `body` NÃO é calculável depois: ele depende do estado do turno, -- que não sobrevive ao turno. -- -- ─── O QUE ESTA MIGRATION FAZ ───────────────────────────────────────────── -- -- 1. cria `public.passagens_de_atendimento` — uma linha por episódio; -- 2. liga a RLS de LEITURA em três condições (organização + papel `agent` + -- visibilidade da conversa) e deixa a tabela SERVER-ONLY na escrita; -- 3. cria `fn_expurgar_passagens_vencidas` (1825 dias, piso de 90 no CORPO), -- que o cron diário de retenção passa a chamar — e que NUNCA apaga -- passagem ainda não reconhecida, porque passagem aberta é demanda viva; -- 4. redefine `fn_lgpd_cascade_redact_contact` com UM passo novo e UMA coluna -- a mais no passo 2, derivada do corpo VIGENTE (a definição de maior número -- de linha no `supabase/baseline.sql`, copiada por script — o corpo anterior -- fica byte a byte igual, exceto pelas duas edições declaradas). -- -- O que esta migration NÃO faz, declarado: ninguém ESCREVE nesta tabela ainda. -- Os 13 call sites dos dois motores, o reconhecimento automático por -- `fn_conversation_assign` e o cartão na conversa são das ondas seguintes. Uma -- tabela sem escritor é "evento sem consumidor" ao contrário, e a única razão de -- ela nascer antes é que schema e call site no mesmo PR fazem o reviewer ler -- 1.500 linhas para julgar uma decisão de modelagem. -- -- ─── REAPLICAÇÃO ────────────────────────────────────────────────────────── -- -- `create table if not exists`, `add column if not exists`, `drop constraint if -- exists` + `add constraint`, `create index if not exists`, `drop policy if -- exists` + `create policy`, `create or replace function`. O `update.sh` de um -- clone reaplica sem erro e sem duplicar efeito. Nenhuma constraint nova sobre -- dados existentes ⇒ não há deduplicação prévia a fazer (regra 8 da doutrina). create table if not exists public.passagens_de_atendimento ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, -- FK para `contacts`: é ela que põe a tabela no escopo do invariante de LGPD, -- e é o filtro que a redação e o export usam. contact_id uuid not null references public.contacts(id) on delete cascade, -- FK para `conversations`: a passagem é da CONVERSA (é onde a pessoa -- responde), e é este ponteiro que a RLS usa para herdar -- `fn_can_view_conversation` sem passar por nenhuma tabela org-wide. conversation_id uuid not null references public.conversations(id) on delete cascade, -- Só quando a passagem nasceu do "Não consigo → escalar" de um caso. -- `set null` e NÃO `cascade`: apagar o caso não pode apagar o fato de a -- conversa ter ido para uma pessoa. caso_id uuid references public.agent_cases(id) on delete set null, -- Qual dos dois motores passou. Sem esta coluna, "o motor B parou de gravar" -- é indistinguível de "ninguém passou conversa nenhuma". motor text not null check (motor in ('engine','crm')), -- POR ONDE entrou (o caminho de código), distinto de POR QUE (a razão). O -- mesmo `requested_human` chega por três origens, e é a origem que responde -- "que parte do sistema decidiu isto?". origem text not null check (origem in ( 'pedido_explicito','opt_out_provavel','ferramenta_do_modelo', 'teto_de_gasto','caso_escalado','sentimento','legado_pedido', 'legado_juridico','legado_etapa','legado_confianca','legado_teto', 'mcp_externo','runtime_nativo')), -- POR QUE saiu do automático. É o que vira FRASE na tela — o código nunca -- aparece para uma pessoa (`lib/escalacao/passagem.ts` → `FRASE_DO_MOTIVO`). motivo_codigo text not null check (motivo_codigo in ( 'requested_human','suspected_optout','orcamento_de_ia','low_sentiment', 'low_confidence','critical_stage','legal_mention','refund_mention', 'caso_escalado')), -- ─── OS QUATRO NOMES QUE O GATE DE LGPD LÊ (ver o cabeçalho) ───────────── -- title ← "o que o cliente quer", em uma linha (é o título do cartão) -- body ← a narrativa montada, que é o que a pessoa lê antes de responder -- notes ← as últimas palavras LITERAIS do cliente (citação, não conclusão) -- content ← o texto livre de quem passou (o `por_que` da ferramenta, a razão -- humana do caso, o `reason` do MCP). Vem do modelo ou de fora: é -- dado NÃO confiável, e por isso é sanitizado antes do insert e -- exibido como citação, nunca como instrução. title text, -- `not null` porque é o que a tela mostra: um cartão sem corpo afirma que não -- há contexto, quando o que houve foi a montagem não ter recebido nada. O piso -- mora em `lib/escalacao/briefing-da-passagem.ts` (`PISO_DO_BRIEFING`). body text not null, notes text, content text, -- Estruturado, para a lista numerada do cartão. NÃO é "texto livre solto": é -- validado por `tentativasDaPassagemSchema` (Zod, `lib/escalacao/passagem.ts`) -- ANTES do insert e lido por parser tipado, nunca por path cru — o CHECK aqui -- garante só a forma externa, porque é o que um CHECK consegue garantir. tentativas jsonb not null default '[]'::jsonb check (jsonb_typeof(tentativas) = 'array'), -- A VERDADE sobre o aviso ao cliente. `null` = ninguém tentou avisar (o -- caminho acionado por uma pessoa que já está na conversa e fala por si). -- A distinção existe porque a promessa "o cliente JÁ foi avisado" era dita sem -- ninguém olhar o desfecho do envio: `sendMessageHandler` devolve `failed` sem -- lançar, e o caminho seguinte afirmava `avisado: true`. cliente_avisado boolean, -- Vocabulário FECHADO, não texto livre: é a TELA que traduz. Uma frase gravada -- em português aqui seria a segunda representação do mesmo fato, e a primeira -- a ficar sem espanhol. aviso_motivo_codigo text check (aviso_motivo_codigo is null or aviso_motivo_codigo in ( 'na_fila_canal_fora','falhou_no_envio','sem_telefone', 'pre_go_live','canal_arquivado','fora_da_janela')), criado_em timestamptz not null default now(), -- Quem assumiu. `reconhecido_por is null` COM `reconhecido_em` preenchido = a -- conversa foi devolvida ao automático (ninguém assumiu, mas o episódio -- fechou). Nunca o contrário — é o que a constraint abaixo garante. reconhecido_por uuid references auth.users(id) on delete set null, reconhecido_em timestamptz, constraint passagens_reconhecimento_coerente check (reconhecido_por is null or reconhecido_em is not null) ); -- Auto-cura para o clone que já tenha uma versão ANTERIOR da tabela: o -- `create table if not exists` acima é no-op ali. Só as colunas que podem ser -- acrescentadas a uma tabela COM LINHAS entram — as `not null` sem default não -- podem, e não precisam: a tabela nasce aqui. alter table public.passagens_de_atendimento add column if not exists caso_id uuid; alter table public.passagens_de_atendimento add column if not exists title text; alter table public.passagens_de_atendimento add column if not exists notes text; alter table public.passagens_de_atendimento add column if not exists content text; alter table public.passagens_de_atendimento add column if not exists tentativas jsonb not null default '[]'::jsonb; alter table public.passagens_de_atendimento add column if not exists cliente_avisado boolean; alter table public.passagens_de_atendimento add column if not exists aviso_motivo_codigo text; alter table public.passagens_de_atendimento add column if not exists reconhecido_por uuid; alter table public.passagens_de_atendimento add column if not exists reconhecido_em timestamptz; -- Os CHECK em bloco próprio, para o clone que tenha a tabela sem eles. -- `drop` + `add` é auto-curativo E é o que garante UMA constraint por coluna: o -- nome usado aqui é o mesmo que o Postgres dá ao CHECK inline do `create table` -- acima, então a segunda aplicação substitui em vez de duplicar. Duas -- constraints definindo o mesmo vocabulário fazem -- `tests/invariants/vocabulario-banco-x-typescript.test.ts` se RECUSAR a medir — -- e ele está certo em se recusar: escolher uma daria veredito falso sobre todos -- os pares. alter table public.passagens_de_atendimento drop constraint if exists passagens_de_atendimento_motor_check; alter table public.passagens_de_atendimento add constraint passagens_de_atendimento_motor_check check (motor in ('engine','crm')); alter table public.passagens_de_atendimento drop constraint if exists passagens_de_atendimento_origem_check; alter table public.passagens_de_atendimento add constraint passagens_de_atendimento_origem_check check (origem in ( 'pedido_explicito','opt_out_provavel','ferramenta_do_modelo', 'teto_de_gasto','caso_escalado','sentimento','legado_pedido', 'legado_juridico','legado_etapa','legado_confianca','legado_teto', 'mcp_externo','runtime_nativo')); alter table public.passagens_de_atendimento drop constraint if exists passagens_de_atendimento_motivo_codigo_check; alter table public.passagens_de_atendimento add constraint passagens_de_atendimento_motivo_codigo_check check (motivo_codigo in ( 'requested_human','suspected_optout','orcamento_de_ia','low_sentiment', 'low_confidence','critical_stage','legal_mention','refund_mention', 'caso_escalado')); alter table public.passagens_de_atendimento drop constraint if exists passagens_de_atendimento_aviso_motivo_codigo_check; alter table public.passagens_de_atendimento add constraint passagens_de_atendimento_aviso_motivo_codigo_check check (aviso_motivo_codigo is null or aviso_motivo_codigo in ( 'na_fila_canal_fora','falhou_no_envio','sem_telefone', 'pre_go_live','canal_arquivado','fora_da_janela')); alter table public.passagens_de_atendimento drop constraint if exists passagens_de_atendimento_tentativas_check; alter table public.passagens_de_atendimento add constraint passagens_de_atendimento_tentativas_check check (jsonb_typeof(tentativas) = 'array'); alter table public.passagens_de_atendimento drop constraint if exists passagens_reconhecimento_coerente; alter table public.passagens_de_atendimento add constraint passagens_reconhecimento_coerente check (reconhecido_por is null or reconhecido_em is not null); -- Três índices, um LEITOR DECLARADO cada. Índice sem leitor é evento sem -- consumidor com outro nome. -- · por conversa → o cartão, que lista as passagens daquela conversa; create index if not exists passagens_por_conversa on public.passagens_de_atendimento (organization_id, conversation_id, criado_em desc); -- · por contato → a redação e o export do titular (FK direta); create index if not exists passagens_por_contato on public.passagens_de_atendimento (organization_id, contact_id, criado_em desc); -- · não reconhecidas → o segundo braço do cobrador em -- `app/api/v1/cron/case-stale-watcher` (onda seguinte) e o expurgo, que só -- apaga linha JÁ reconhecida. create index if not exists passagens_nao_reconhecidas on public.passagens_de_atendimento (organization_id, criado_em desc) where reconhecido_em is null; alter table public.passagens_de_atendimento enable row level security; -- ── Privilégio: leitura pelo login, escrita SÓ pelo servidor ─────────────── -- `revoke all` PRIMEIRO porque o `ALTER DEFAULT PRIVILEGES … GRANT ALL ON -- TABLES TO "authenticated"` do baseline vem ANTES de toda tabela de apêndice: -- sem o revoke, a tabela nasce com INSERT/UPDATE/DELETE para `authenticated` e a -- policy seria a única coisa entre um `viewer` e a escrita. É exatamente o -- defeito que a 0279 teve de consertar em três tabelas já nascidas. -- -- E não há caminho de escrita pelo PostgREST NENHUM: quem grava é o service role -- (os dois motores) e, na onda seguinte, o trigger definer do reconhecimento. -- Uma passagem forjada por um membro é uma mentira com cara de registro — ela -- diria que a IA desistiu de um atendimento que ela nunca tocou. revoke all on public.passagens_de_atendimento from anon, authenticated; grant select on public.passagens_de_atendimento to authenticated; grant all on public.passagens_de_atendimento to service_role; -- ── RLS: organização + papel + VISIBILIDADE DA CONVERSA ─────────────────── -- As três condições, e cada uma fecha uma porta diferente: -- · `fn_user_org_ids` — o vizinho não lê; -- · `fn_role_at_least` — `viewer` não lê briefing de atendimento (ele vê a -- conversa por desenho, e o briefing diz MAIS que a conversa: diz o que a IA -- concluiu sobre a pessoa); -- · `fn_can_view_conversation` — numa organização em `visibility_mode='own'`, -- um atendente não lê o briefing de um atendimento que não é dele. É o mesmo -- predicado de `ai_reply_drafts`, e é a razão de `conversation_id` viver -- dentro desta tabela. drop policy if exists tenant_isolation_passagens_de_atendimento_all on public.passagens_de_atendimento; drop policy if exists tenant_isolation_passagens_de_atendimento_select on public.passagens_de_atendimento; create policy tenant_isolation_passagens_de_atendimento_select on public.passagens_de_atendimento for select to authenticated using ( organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'agent') and exists ( select 1 from public.conversations c where c.organization_id = passagens_de_atendimento.organization_id and c.id = passagens_de_atendimento.conversation_id and public.fn_can_view_conversation(c.organization_id, c.assigned_to_user_id) ) ); -- ── Retenção: a tabela nasce com dono de piso ───────────────────────────── create or replace function public.fn_expurgar_passagens_vencidas( p_retencao_dias int default null, p_limite int default null ) returns int language plpgsql security definer set search_path = public, pg_temp as $$ declare -- 1825 = os 5 anos da auditoria, e pelo mesmo motivo: a passagem é rastro de -- ATENDIMENTO — quem assumiu, quando, e por quê. O piso de 90 impede que o -- knob vire apagador de rastro recente, e mora AQUI, no corpo, porque só assim -- vale para QUALQUER chamador, inclusive um `psql` na mão. v_dias int := greatest(coalesce(p_retencao_dias, 1825), 90); v_limite int := least(greatest(coalesce(p_limite, 1000), 1), 10000); v_apagadas int; begin with vencidas as ( select p.id from public.passagens_de_atendimento p -- ⚠️ SÓ passagem JÁ RECONHECIDA. Uma passagem aberta é demanda viva: alguém -- do outro lado está esperando resposta e ninguém assumiu. Apagá-la por -- idade seria o expurgo virando esquecedor de pendência — e o único sinal -- de que a pessoa ficou sem resposta some junto. where p.reconhecido_em is not null and p.criado_em < now() - make_interval(days => v_dias) order by p.criado_em limit v_limite ) delete from public.passagens_de_atendimento p using vencidas v where p.id = v.id; get diagnostics v_apagadas = row_count; return v_apagadas; end; $$; -- As DUAS origens de EXECUTE: o `ALTER DEFAULT PRIVILEGES … GRANT ALL ON -- FUNCTIONS TO anon` do baseline (que `revoke from public` não remove) e o grant -- implícito a PUBLIC que o Postgres dá a toda função ao criá-la (que `revoke -- from anon` não remove). Fechar uma só deixa a função exposta com o gate verde. revoke all on function public.fn_expurgar_passagens_vencidas(int,int) from public, anon, authenticated; grant execute on function public.fn_expurgar_passagens_vencidas(int,int) to service_role; -- ── A cascata de LGPD alcança a passagem ────────────────────────────────── -- Derivada do corpo VIGENTE do baseline (a definição de maior número de linha), -- por script, nunca redigitada: o corpo anterior fica byte a byte igual e as -- ÚNICAS mudanças são as duas declaradas — o passo de `passagens_de_atendimento` -- e o `last_handoff_reason = null` dentro do passo 2, que já visita as mesmas -- linhas com o mesmo predicado. O Postgres troca o corpo INTEIRO num `create or -- replace`; quem derivar da versão errada apaga o passo de outra entrega sem um -- único erro. A catraca que vigia isso é -- `tests/invariants/cascata-lgpd-nao-encolhe.test.ts`. CREATE OR REPLACE FUNCTION "public"."fn_lgpd_cascade_redact_contact"("p_organization_id" "uuid", "p_contact_id" "uuid", "p_request_id" "uuid") RETURNS "jsonb" LANGUAGE "plpgsql" SECURITY DEFINER SET "search_path" TO 'public' AS $$ declare v_already bool; v_counts jsonb := '{}'::jsonb; v_media_paths text[] := '{}'; v_anon_label text; v_count int; begin perform public.fn_service_lock(p_organization_id,p_contact_id); select is_anonymized into v_already from contacts where id = p_contact_id and organization_id = p_organization_id; if not found then raise exception 'contact not found' using errcode = 'P0002'; end if; if v_already then return jsonb_build_object('already_anonymized', true, 'counts', v_counts, 'media_paths', v_media_paths); end if; v_anon_label := 'Cliente Anonimizado #' || substring(p_contact_id::text from 1 for 8); -- Collect media storage paths (we only delete what we own — media_storage_path) select coalesce(array_agg(distinct media_storage_path) filter (where media_storage_path is not null), '{}') into v_media_paths from messages where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ); -- 1. contacts (irreversible) update contacts set name = v_anon_label, display_name = v_anon_label, email = null, -- email_normalized NÃO entra: é GENERATED ALWAYS AS (lower(trim(email))) -- e o Postgres recusa escrita nela — a linha acima já a zera por derivação. -- Com a atribuição, o cascade INTEIRO abortava e nada era anonimizado. phone_number = null, cpf_encrypted = null, cpf_hash = null, birthdate = null, is_anonymized = true, anonymized_at = now(), consent = '{}'::jsonb, source_metadata = '{}'::jsonb, tags = '{}'::text[], updated_at = now() where id = p_contact_id and organization_id = p_organization_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('contacts', v_count); -- 2. conversations metadata + preview strip update conversations set metadata = '{}'::jsonb, last_message_preview = null, -- O motivo CRU da última passagem (migration 0291). É código de -- vocabulário, não texto livre — mas ele diz que ESTA pessoa foi escalada -- por irritação, por assunto jurídico ou por suspeita de opt-out, e isso é -- um fato sobre ela. Entra NESTE update, e não num segundo: mesmo -- predicado, mesmas linhas, metade das varreduras. -- -- ⚠️ `last_handoff_reason` é CHAVE DE NEGÓCIO em outro módulo: a ponte de -- voz limpa o silêncio filtrando pelo VALOR da coluna -- (`lib/wacalls/events-bridge.ts`). Zerá-la num contato anonimizado é -- seguro — não há chamada viva de contato anonimizado — e é a razão de -- esta entrega NÃO usar essa coluna para texto rico: ela continua -- recebendo só o código, e o texto vive em `passagens_de_atendimento`. last_handoff_reason = null, updated_at = now() where contact_id = p_contact_id and organization_id = p_organization_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('conversations', v_count); -- 3. messages: redact body + null media + strip metadata (preserve status/timestamps/conversation_id) update messages set body = '[mensagem anonimizada]', media_url = null, media_mime = null, media_size_bytes = null, media_storage_path = null, metadata = '{}'::jsonb, updated_at = now() where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('messages', v_count); -- 4. crm_lead_activities — strip payload, metadata E reason (migration 0071). -- `reason` é texto livre escrito por LLM sobre a conversa do lead: supor que -- nunca conterá um nome é a suposição que falha. `evidence` NÃO é limpa — -- guarda só ids, e as linhas apontadas são redigidas por conta própria. update crm_lead_activities set payload = '{}'::jsonb, metadata = '{}'::jsonb, reason = null where organization_id = p_organization_id and ( contact_id = p_contact_id or lead_id in ( select lead_id from crm_lead_links where target_kind = 'contact' and target_id = p_contact_id and organization_id = p_organization_id ) or lead_id in ( select id from crm_leads where contact_id = p_contact_id and organization_id = p_organization_id ) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('activities', v_count); -- 5. crm_leads — strip title/description/custom_fields/source_metadata/tags but PRESERVE pipeline/stage/value update crm_leads set title = v_anon_label, description = null, custom_fields = '{}'::jsonb, source_metadata = '{}'::jsonb, tags = '{}'::text[], updated_at = now() where organization_id = p_organization_id and ( contact_id = p_contact_id or id in ( select lead_id from crm_lead_links where target_kind = 'contact' and target_id = p_contact_id and organization_id = p_organization_id ) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('leads', v_count); -- 6. orders — PRESERVE values + status + timestamps. Strip personal fields from payload jsonb -- and replace customer_external_id with null (FK-safe; soft de-link). Keep contact_id null. update orders set payload = (coalesce(payload, '{}'::jsonb)) - 'customer' - 'customer_name' - 'customer_email' - 'customer_phone' - 'shipping_address' - 'billing_address' - 'contact_identification', customer_external_id = null, contact_id = null, is_anonymized = true, updated_at = now() where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('orders', v_count); -- 7. enqueue media for async deletion (idempotent via unique (bucket, object_path)) if array_length(v_media_paths, 1) > 0 then insert into storage_redaction_queue (organization_id, request_id, bucket, object_path) select p_organization_id, p_request_id, 'whatsapp-media', path from unnest(v_media_paths) as path where path is not null and length(path) > 0 on conflict (bucket, object_path) do nothing; end if; -- 7b. voice_calls — o TELEFONE de quem falou ao telefone (migration 0235). -- -- `peer_phone` é `not null` e guarda o número da outra ponta: depois de -- anonimizar o contato, ele sobrevivia ligado ao `contact_id` e reidentificava -- a pessoa que pediu para ser esquecida. É o mesmo argumento que a foto de -- perfil já tinha (ver o bloco do avatar em `lib/lgpd/redact-cascade.ts`): -- anonimizar em toda parte menos numa é não ter anonimizado. -- -- O que fica: direção, status, motivo do fim, marcas de tempo e duração. Um -- registro de "houve uma chamada de 12 minutos" sem número e sem dono não -- identifica ninguém e é o que sustenta a métrica do atendente e a fatura. -- `peer_phone` é NOT NULL, então recebe o rótulo, não `null`. update voice_calls set peer_phone = v_anon_label, owner_user_id = null, created_by = null, updated_at = now() where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('voice_calls', v_count); -- agent_cases — o que a IA escreveu SOBRE a pessoa quando travou (migration 0280). -- -- O caso é o texto que a equipe lê antes de decidir: `title`, `summary` e -- `blocker` saem do modelo a partir da conversa, e `context_snapshot` é o -- recorte dessa conversa que o motor mandou para ele. Nada disso é registro de -- operação — é o relato do problema de uma pessoa identificável, escrito por -- máquina. Sem este passo, anonimizar devolvia SUCESSO com o relato intacto. -- -- As três colunas de texto são `not null`: recebem rótulo e texto fixo, nunca -- `null` (a mesma razão de `voice_calls.peer_phone` logo acima). -- -- ⚠️ `updated_at` FICA FORA DO `set`, de propósito. O cobrador de caso parado -- (`app/api/v1/cron/case-stale-watcher/route.ts`) lê `updated_at` como "alguém -- da equipe encostou neste caso". A cascata não é alguém encostando: escrever -- ali faria a anonimização ADIAR a cobrança de um caso que continua parado, e -- o efeito só apareceria como um cliente esperando mais tempo. -- -- O vínculo é pela CONVERSA porque `agent_cases` não tem FK para `contacts`. update agent_cases set title = v_anon_label, summary = '[resumo anonimizado]', blocker = '[bloqueio anonimizado]', context_snapshot = '{}'::jsonb where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('agent_cases', v_count); -- agent_case_events — a linha do tempo do caso (migration 0280). -- -- `body` é o que a pessoa da equipe escreveu ao responder o caso e o que o -- agente registrou sobre o que o LEAD respondeu; `metadata` carrega o recorte -- que o motor anexou. `kind`, `actor_kind`, `human_action` e `created_at` -- FICAM: são o registro de que houve um toque humano e quando — operação, não -- dado da pessoa, e é deles que sai a métrica de atendimento. update agent_case_events set body = null, metadata = '{}'::jsonb where organization_id = p_organization_id and case_id in ( select id from agent_cases where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('agent_case_events', v_count); -- demandas — o assunto do pedido (migration 0280). -- -- `assunto` é texto livre sobre o que a pessoa pediu. O resto da linha é a -- operação da demanda (origem, estado, dono, prazo, desfecho) e fica de pé: -- apagar a linha inteira tiraria da organização a resposta a "quantos pedidos -- houve em março", que é o mesmo argumento do compromisso da agenda. -- -- FK direta (`demandas.contact_id` é `not null`), então o vínculo é o contato. update demandas set assunto = null where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('demandas', v_count); -- agent_inbox_items — o aviso que leva o texto do caso para a Central (migration 0280). -- -- O `body` do aviso de caso parado EMBUTE o título do caso -- (`app/api/v1/cron/case-stale-watcher/route.ts:128`), e o do handoff embute o -- motivo da parada (`lib/ai/handoff/orchestrator.ts:335`). Redigir o caso e -- deixar o aviso de pé seria anonimizar em toda parte menos numa — que é não -- ter anonimizado. O molde (resolver + trocar o corpo + soltar a referência) é -- o de `fn_meet_redact_contact`, que já faz isto para o aviso de compromisso. -- -- ⚠️ O VÍNCULO É POLIMÓRFICO E TEM TRÊS BRAÇOS, não dois. Medido nos -- produtores, não suposto: `handoff` nasce com `ref_kind='contact'` -- (`lib/ai/handoff/orchestrator.ts:339`) E com `ref_kind='conversation'` -- (`lib/agent-engine/agent/inbound-turn.ts:4100`); `case_stale` nasce SEMPRE -- com `ref_kind='agent_case'` (a rota do cron acima, e a política em -- `lib/ai/inbox-destino.ts:38`). Um predicado com só os dois primeiros braços -- casa ZERO avisos de caso parado — e casar zero linha não é erro: é sucesso -- com o texto intacto. -- -- Os `kind` são os MEDIDOS no CHECK vigente (`supabase/baseline.sql`, bloco -- único de `agent_inbox_items_kind_check`). `case_opened` NÃO existe, e kind -- inexistente num `in (...)` também casa zero e devolve sucesso. Para -- reconferir sem acreditar nesta prosa: -- grep -n "agent_inbox_items_kind_check check" -A40 supabase/baseline.sql update agent_inbox_items set status = 'resolved', resolved_at = now(), body = 'Contato anonimizado.', ref_id = null where organization_id = p_organization_id and kind in ('handoff', 'case_stale') and ( (ref_kind = 'contact' and ref_id = p_contact_id) or (ref_kind = 'conversation' and ref_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id )) or (ref_kind = 'agent_case' and ref_id in ( select id from agent_cases where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ) )) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('agent_inbox_items', v_count); -- agent_case_chat_messages — a consulta interna da equipe à IA SOBRE o caso -- (migration 0281). FK DIRETA para `contacts`, então o vínculo é o titular e -- não precisa passar pela conversa. -- -- `redacted_at is null` no `where` é o que torna o passo IDEMPOTENTE: a -- varredura diária de redações incompletas roda a função de novo, e sem essa -- condição o carimbo de QUANDO se apagou seria reescrito a cada rodada. -- -- A linha NÃO é apagada, só o texto: quem abrir o caso depois continua vendo -- que a equipe perguntou N vezes, quando, e se a IA respondeu. Apagar a linha -- inteira ficaria verde num teste de "o texto sumiu" e tiraria da organização -- a resposta a "quanto a equipe deliberou sobre este caso". update agent_case_chat_messages set body = null, redacted_at = now() where organization_id = p_organization_id and contact_id = p_contact_id and redacted_at is null; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('agent_case_chat_messages', v_count); -- passagens_de_atendimento — o BRIEFING é sobre a pessoa (migration 0291). -- -- A linha guarda o que a IA concluiu sobre um atendimento de alguém -- identificável: o que ela entendeu que a pessoa quer (`title`), a narrativa -- que quem assumiu leu (`body`), as PALAVRAS LITERAIS do cliente (`notes`), o -- texto livre de quem passou (`content`) e o que a IA já tinha tentado -- (`tentativas`). Nada disso é registro de operação — é o relato do problema -- de uma pessoa, escrito por máquina, na tela de quem vai responder. -- -- `body` é `not null` e recebe o RÓTULO, não `null` — a mesma razão de -- `voice_calls.peer_phone` e de `agent_cases.title` acima: coluna obrigatória -- anulada aborta o cascade INTEIRO, e um cascade abortado não anonimiza nada. -- -- O que FICA, de propósito: `motor`, `origem`, `motivo_codigo`, -- `cliente_avisado`, `aviso_motivo_codigo`, `criado_em` e o par de -- reconhecimento. São operação — quantas passagens houve, por quê, quanto -- tempo até alguém assumir. Um passo que apagasse a linha inteira ficaria -- verde num teste de "o texto sumiu" e tiraria da organização a resposta a -- "quantos atendimentos a IA devolveu em março, e quanto tempo esperaram". -- -- O vínculo é a FK DIRETA `contact_id`: a tabela a carrega exatamente para -- este passo não precisar passar pela conversa. update passagens_de_atendimento set body = v_anon_label, title = null, notes = null, content = null, tentativas = '[]'::jsonb where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('passagens_de_atendimento', v_count); -- 8. dense audit row insert into api_audit_log (organization_id, action, actor_user_id, resource_type, resource_id, metadata, bypassed_rls) values ( p_organization_id, 'lgpd.redact_executed', null, 'contact', p_contact_id, jsonb_build_object( 'cascaded_to', v_counts, 'media_queued', coalesce(array_length(v_media_paths, 1), 0), 'request_id', p_request_id ), true ); return jsonb_build_object( 'already_anonymized', false, 'counts', v_counts, 'media_paths', v_media_paths ); end; $$; revoke all on function public.fn_lgpd_cascade_redact_contact(uuid,uuid,uuid) from public,anon,authenticated; grant execute on function public.fn_lgpd_cascade_redact_contact(uuid,uuid,uuid) to service_role; -- ── travas do suporte, depois de toda tabela nova (migration 0274) ───────── -- `passagens_de_atendimento` nasce SERVER-ONLY (revoke de anon/authenticated + -- grant select), então o ramo server-only da função lhe dá ZERO policies -- `support_write_*` — que é o contrato mais restritivo. Escrever `drop policy` à -- mão aqui seria a segunda representação da mesma regra. do $f$ begin perform public.fn_aplicar_travas_de_suporte(); end $f$; notify pgrst, 'reload schema'; -- ---- o WhatsApp da equipe é avisado quando a IA abre um caso (migration 0292) ---- -- 0292 — O WhatsApp da equipe é avisado quando a IA abre um caso. -- -- ─── O que não existia ──────────────────────────────────────────────────── -- -- A IA abre um caso quando trava (`agent_cases`), o caso entra numa fila e -- espera alguém da equipe. O único lugar onde ele APARECE é uma tela do CRM — -- e quem opera uma PME não fica com o CRM aberto: fica com o WhatsApp aberto. -- O cobrador de caso parado (`app/api/v1/cron/case-stale-watcher`) só reclama -- DEPOIS de horas, e reclama na mesma tela que ninguém abriu. O resultado -- medido é um cliente esperando do outro lado sem que ninguém tenha sido -- avisado de nada. -- -- Esta migration é o SCHEMA e as RPCs desse aviso. A tela que o liga é a onda -- seguinte; o motor que o envia (`lib/escalacao/aviso-ao-suporte.ts`) entra no -- MESMO commit que este arquivo. -- -- ─── DOUTRINA DIRC, respondida ──────────────────────────────────────────── -- -- Duplicar — não: não existe hoje nenhuma tabela de "para onde mandar -- aviso interno" (`organizations.settings` guarda preferência de -- produto, não vínculo com `channel_sessions`); -- Integrar — `channel_session_id` e `case_id` são FK, não cópias; -- Referenciar— `status` e `erro_codigo` são vocabulário fechado; -- Calcular — "este aviso já saiu?" NÃO é calculável depois: sem a linha de -- entrega, o único jeito de saber seria reler o WhatsApp da -- equipe, e a segunda rodada do dreno mandaria de novo. -- -- ─── AS DUAS TABELAS, e por que são duas ────────────────────────────────── -- -- `config_aviso_de_caso` — UMA linha por organização: para onde mandar, -- por qual conexão, ligado ou não. -- `entregas_de_aviso_de_caso` — UMA linha por (organização, caso, destino). -- É a `unique` dela que dá a IDEMPOTÊNCIA: o -- dreno do `event_log` reentrega o mesmo evento -- em retry, e sem essa chave a equipe receberia o -- mesmo aviso três vezes. -- -- **O TEXTO DO AVISO NUNCA É GUARDADO.** Só `corpo_hash` — precedente -- `send_ledger.body_hash`. Um registro de entrega que guardasse o corpo seria -- uma segunda cópia do relato do cliente, numa tabela que a cascata de LGPD -- teria de aprender a redigir. A única coluna capaz de ecoar um dado pessoal é -- `erro_detalhe` (o texto cru do transporte), e é ela que a cascata zera. -- -- ─── POR QUE NÃO HÁ `check (ligado = false or channel_session_id is not null)` -- -- Ele parece a expressão natural de "ligado sem canal nunca dispara", e é uma -- armadilha: `on delete set null` é um UPDATE, o CHECK é reavaliado na linha -- resultante e VIOLA quando `ligado` é `true` — abortando o DELETE INTEIRO da -- conexão. A rota de exclusão de canal devolveria 500 com mensagem de -- constraint, sem nenhuma pista de que a causa está em outra tela. A coerência -- é do trigger `trg_aviso_de_caso_coerente`, que se autocura: canal nulo ⇒ -- `ligado` cai para `false`, e a tela explica o que houve. -- -- ─── POR QUE A ESCRITA É POR RPC E A LEITURA É POR RLS ──────────────────── -- -- Padrão vigente da 0228 e da 0262. A escrita precisa de quatro guardas na -- MESMA transação (papel `admin`, escrita de suporte liberada, MFA comprovada -- quando há fator, e o canal sendo da própria organização) — e uma policy não -- sabe recusar "este número já é de um cliente seu". A leitura da CONFIGURAÇÃO -- é `admin` (quem configura conexão é admin); a do HISTÓRICO é `manager`, -- porque "o aviso está saindo?" é pergunta de quem opera o atendimento. -- Nenhuma policy `for all` ⇒ as duas tabelas nascem fora da consulta `cmd='ALL'` -- do gate de RBAC, sem entrar em dívida nova. -- -- ─── AS DUAS COLUNAS DE CONTAGEM NÃO SÃO ENFEITE ────────────────────────── -- -- `mensagens_ignoradas` / `ultima_mensagem_ignorada_em` existem porque a onda -- do corte (o número de aviso é INTERNO: nada que venha dele vira contato, -- conversa, lead ou despacho do agente) produz um silêncio que, na tela, é -- indistinguível de defeito. Com elas a tela diz "3 mensagens deste número -- foram ignoradas nos últimos 7 dias — é o esperado". Quem incrementa é -- `fn_contar_mensagem_ignorada`, e ela NÃO toca `updated_at`: aquele carimbo -- responde "quando alguém mexeu na configuração", e uma resposta do suporte não -- é alguém mexendo na configuração. -- -- ─── REAPLICAÇÃO ────────────────────────────────────────────────────────── -- -- `create table if not exists`, `add column if not exists`, `drop constraint if -- exists` + `add constraint`, `create index if not exists`, `drop policy if -- exists` + `create policy`, `create or replace function`, `drop trigger if -- exists` + `create trigger`. O `update.sh` de um clone reaplica sem erro e sem -- duplicar efeito. Nenhuma constraint nova sobre dados existentes (as duas -- tabelas nascem aqui, e os dois CHECK de vocabulário só CRESCEM) ⇒ não há -- deduplicação prévia a fazer (regra 8 da doutrina de migrations). create table if not exists public.config_aviso_de_caso ( organization_id uuid primary key references public.organizations(id) on delete cascade, -- NULLABLE de propósito, e `set null` e não `cascade`/`restrict`: com -- `cascade` a exclusão do canal apagaria a configuração CALADA; com -- `restrict`, a exclusão do canal falharia por causa de um aviso. `set null` -- desliga (pelo trigger) e deixa a tela explicar. channel_session_id uuid references public.channel_sessions(id) on delete set null, -- E.164, com `+`. É o que a pessoa digita e o que o transporte recebe. telefone_destino text not null, -- O JID que o transporte resolveu da última vez. É o que faz o corte da -- ingestão funcionar para destinatário em MODO PRIVACIDADE, onde o telefone -- nunca chega no webhook e o chat chega como um identificador opaco. destino_jid text, -- Como a equipe chama esse número ("Plantão", "Suporte 1"). Só rótulo. rotulo text, ligado boolean not null default false, -- A SUPERFÍCIE DO DESCARTE (ver o cabeçalho): sem elas, "as mensagens deste -- número somem" é indistinguível de defeito para quem olha a tela. mensagens_ignoradas integer not null default 0, ultima_mensagem_ignorada_em timestamptz, criado_por uuid references auth.users(id) on delete set null, atualizado_por uuid references auth.users(id) on delete set null, created_at timestamptz not null default now(), -- Responde "quando alguém MEXEU na configuração" — e só isso. O contador de -- mensagens ignoradas não o toca de propósito. updated_at timestamptz not null default now() ); -- Colunas declaradas de novo para o clone que já tenha a tabela de uma versão -- anterior deste arquivo: `add column if not exists` é o que torna o apêndice -- auto-curativo. alter table public.config_aviso_de_caso add column if not exists destino_jid text, add column if not exists rotulo text, add column if not exists mensagens_ignoradas integer not null default 0, add column if not exists ultima_mensagem_ignorada_em timestamptz, add column if not exists criado_por uuid, add column if not exists atualizado_por uuid; -- As constraints nomeadas fora do `create table`: é o que as torna -- auto-curativas num clone cuja tabela nasceu de uma versão anterior. O nome é -- o MESMO que o Postgres daria ao inline, então não há duas constraints -- definindo o mesmo domínio — duas fariam o invariante de vocabulário se -- RECUSAR a medir. alter table public.config_aviso_de_caso drop constraint if exists config_aviso_de_caso_e164; alter table public.config_aviso_de_caso add constraint config_aviso_de_caso_e164 check (telefone_destino ~ '^\+[1-9][0-9]{7,14}$'); alter table public.config_aviso_de_caso drop constraint if exists config_aviso_de_caso_rotulo_curto; alter table public.config_aviso_de_caso add constraint config_aviso_de_caso_rotulo_curto check (rotulo is null or char_length(rotulo) <= 60); -- Coerência que se AUTOCURA, em vez de um CHECK que aborta o DELETE da conexão -- (ver o cabeçalho). `before insert or update` para valer também quando o -- `on delete set null` da FK dispara o UPDATE. create or replace function public.fn_aviso_de_caso_coerente() returns trigger language plpgsql set search_path = public, pg_temp as $$ begin -- Sem canal não há por onde mandar. Deixar `ligado = true` aqui produziria a -- pior tela possível: a que diz que o aviso está ativo enquanto ele nunca -- dispara. `security invoker` de propósito — ela não lê nem escreve nada além -- da linha que o próprio comando já está tocando. if new.channel_session_id is null then new.ligado := false; end if; return new; end; $$; revoke all on function public.fn_aviso_de_caso_coerente() from public, anon, authenticated; drop trigger if exists trg_aviso_de_caso_coerente on public.config_aviso_de_caso; create trigger trg_aviso_de_caso_coerente before insert or update on public.config_aviso_de_caso for each row execute function public.fn_aviso_de_caso_coerente(); create table if not exists public.entregas_de_aviso_de_caso ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, case_id uuid not null references public.agent_cases(id) on delete cascade, -- O destino NO INSTANTE do envio. Não é FK para a configuração de propósito: -- trocar o número do plantão não pode reescrever para onde os avisos de ontem -- foram — isso é registro, não estado. destino text not null, channel_session_id uuid references public.channel_sessions(id) on delete set null, status text not null default 'pendente', tentativas smallint not null default 0, -- Vocabulário FECHADO (lib/escalacao/vocabulario-do-aviso.ts), nunca a -- mensagem do provedor: é o que a tela lê e o que a Central traduz. erro_codigo text, -- O texto cru do transporte, truncado. ÚNICA coluna desta tabela capaz de -- ecoar um dado pessoal — e é por isso que a cascata de LGPD a zera. erro_detalhe text, external_id text, -- O TEXTO NUNCA É GUARDADO (precedente: `send_ledger.body_hash`). O hash -- responde "o aviso que saiu era este?" sem guardar o relato do cliente uma -- segunda vez. corpo_hash text, enviado_em timestamptz, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); alter table public.entregas_de_aviso_de_caso add column if not exists channel_session_id uuid, add column if not exists erro_codigo text, add column if not exists erro_detalhe text, add column if not exists external_id text, add column if not exists corpo_hash text, add column if not exists enviado_em timestamptz; alter table public.entregas_de_aviso_de_caso drop constraint if exists entregas_de_aviso_de_caso_status_check; alter table public.entregas_de_aviso_de_caso add constraint entregas_de_aviso_de_caso_status_check check (status in ('pendente', 'enviado', 'falhou', 'cancelado')); alter table public.entregas_de_aviso_de_caso drop constraint if exists entregas_de_aviso_de_caso_erro_codigo_check; alter table public.entregas_de_aviso_de_caso add constraint entregas_de_aviso_de_caso_erro_codigo_check check (erro_codigo is null or erro_codigo in ( 'canal_desconectado', 'canal_arquivado', 'canal_nao_aceita_aviso_livre', 'transporte_ausente', 'destino_invalido', 'teto_diario_do_numero', 'sem_endereco_publico', 'titular_anonimizado', 'expirou', 'falha_no_envio', 'indeterminado', -- (migration 0439) O número de destino voltou a ser de uma conexão ATIVA -- desta organização — o laço robô-com-robô que a 0292 recusa ao DEFINIR o -- aviso. Este bloco é o único da constraint, e já carrega o vocabulário -- vigente: quem amplia o conjunto edita AQUI. 'destino_da_propria_organizacao')); -- A CHAVE DA IDEMPOTÊNCIA. O dreno do `event_log` reentrega o mesmo evento em -- retry e três processos diferentes drenam a mesma fila: sem esta unique, a -- equipe receberia o mesmo aviso uma vez por tentativa. O `23505` dela é o -- sinal que o handler lê para REIVINDICAR a entrega antes de tocar a rede. create unique index if not exists entregas_de_aviso_de_caso_unica on public.entregas_de_aviso_de_caso (organization_id, case_id, destino); -- O leitor declarado: a lista "os últimos avisos" da tela de configuração, que -- é a fonte da verdade sobre "o aviso está saindo?" (a Central pode ter tido o -- item apagado por qualquer membro; esta tabela, não). create index if not exists entregas_de_aviso_de_caso_org_idx on public.entregas_de_aviso_de_caso (organization_id, created_at desc); -- `updated_at` da ENTREGA é operacional: ele responde "há quanto tempo esta -- reivindicação está de pé?", e é ele que separa "outro processo está enviando -- agora" de "alguém morreu no meio". Por isso a entrega ganha o trigger e a -- CONFIGURAÇÃO não: lá o carimbo significa "alguém mexeu", e um contador de -- mensagem ignorada não é alguém mexendo. drop trigger if exists trg_entregas_de_aviso_de_caso_updated_at on public.entregas_de_aviso_de_caso; create trigger trg_entregas_de_aviso_de_caso_updated_at before update on public.entregas_de_aviso_de_caso for each row execute function public.fn_set_updated_at(); alter table public.config_aviso_de_caso enable row level security; alter table public.entregas_de_aviso_de_caso enable row level security; -- `revoke all` PRIMEIRO: o `ALTER DEFAULT PRIVILEGES … GRANT ALL ON TABLES TO -- "authenticated"` do baseline vem ANTES de toda tabela de apêndice, então sem -- ele as tabelas nascem com INSERT/UPDATE/DELETE para `authenticated` e a -- policy seria a única coisa entre um `viewer` e a escrita. É o defeito que a -- 0279 teve de consertar em três tabelas já nascidas. revoke all on public.config_aviso_de_caso from anon, authenticated; revoke all on public.entregas_de_aviso_de_caso from anon, authenticated; grant select on public.config_aviso_de_caso to authenticated; grant select on public.entregas_de_aviso_de_caso to authenticated; grant all on public.config_aviso_de_caso to service_role; grant all on public.entregas_de_aviso_de_caso to service_role; -- Leitura da CONFIGURAÇÃO: `admin`. Ela carrega o telefone de um funcionário e -- o vínculo com a conexão — quem configura conexão neste produto é admin. drop policy if exists leitura_config_aviso_de_caso on public.config_aviso_de_caso; create policy leitura_config_aviso_de_caso on public.config_aviso_de_caso for select to authenticated using ( organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'admin') ); -- Leitura do HISTÓRICO: `manager`. "O aviso está saindo?" é pergunta de quem -- opera o atendimento, e a linha não expõe o número inteiro para a tela (que o -- mascara) nem guarda texto nenhum. drop policy if exists leitura_entregas_de_aviso_de_caso on public.entregas_de_aviso_de_caso; create policy leitura_entregas_de_aviso_de_caso on public.entregas_de_aviso_de_caso for select to authenticated using ( organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'manager') ); -- ── A escrita da configuração: uma RPC, quatro guardas, uma transação ────── create or replace function public.fn_definir_aviso_de_caso( p_org uuid, p_channel uuid, p_telefone text, p_rotulo text, p_ligado boolean, p_confirma_contato boolean default false ) returns jsonb language plpgsql security definer set search_path = public, pg_temp as $$ declare v_antes public.config_aviso_de_caso; v_arch timestamptz; v_digitos text; v_variantes text[]; begin -- Papel + suporte, nesta ordem e na MESMA transação da escrita. `auth.uid()` -- nulo é o caminho do service role: quem escreve configuração é gente. if auth.uid() is null or p_org is null or not public.fn_role_at_least(p_org, 'admin') or not public.fn_support_write_allowed(p_org) then raise exception 'aviso_de_caso_forbidden' using errcode = '42501'; end if; -- Quem NÃO tem fator cadastrado passa: a função já trata isso, e é coerente -- com a política de MFA opcional deste produto. if not public.fn_session_mfa_proven() then raise exception 'aviso_de_caso_mfa_required' using errcode = '42501'; end if; if p_telefone is null or p_telefone !~ '^\+[1-9][0-9]{7,14}$' then raise exception 'aviso_de_caso_telefone_invalido' using errcode = '22023'; end if; -- O canal é DA organização e não está arquivado. Sem isto a FK simples -- deixaria apontar para o canal de outro tenant — a FK composta do padrão -- 0228 não serve aqui porque o `on delete set null` anularia também -- `organization_id`, que é a chave primária desta tabela. if p_channel is not null then select archived_at into v_arch from public.channel_sessions where id = p_channel and organization_id = p_org; if not found or v_arch is not null then raise exception 'aviso_de_caso_canal_invalido' using errcode = '22023'; end if; end if; -- As duas grafias do nono dígito — a MESMA regra de -- `lib/channels/phone-variants.ts`. Comparar a string crua deixaria passar o -- número do suporte cadastrado com 9 e registrado sem. v_digitos := regexp_replace(p_telefone, '\D', '', 'g'); v_variantes := array[v_digitos]; if v_digitos like '55%' then if length(v_digitos) = 13 and substring(v_digitos from 5 for 1) = '9' and substring(v_digitos from 6 for 1) between '6' and '9' then v_variantes := v_variantes || (substring(v_digitos from 1 for 4) || substring(v_digitos from 6)); elsif length(v_digitos) = 12 and substring(v_digitos from 5 for 1) between '6' and '9' then v_variantes := v_variantes || (substring(v_digitos from 1 for 4) || '9' || substring(v_digitos from 5)); end if; end if; -- O NÚMERO DE AVISO NÃO PODE SER UM NÚMERO DA PRÓPRIA ORGANIZAÇÃO. É o laço -- robô-com-robô: a conexão de avisos manda para o número oficial, o agente -- dele responde, e as duas pontas se alimentam sem fim. -- A conexão ARQUIVADA fica FORA da conta. Ela não envia nem recebe, então o -- laço não acontece por ela — e contá-la bloqueia o número PARA SEMPRE, porque -- a conexão que já teve agente publicado não pode ser apagada (as versões a -- seguram) e o número nunca mais poderia receber aviso. if exists ( select 1 from public.channel_sessions s where s.organization_id = p_org and s.archived_at is null and s.phone_number is not null and regexp_replace(s.phone_number, '\D', '', 'g') = any (v_variantes)) then raise exception 'aviso_de_caso_numero_da_propria_org' using errcode = '22023'; end if; -- O número de aviso vira INTERNO: tudo o que chegar dele deixa de virar -- contato, conversa, lead e despacho do agente. Se ele já é um CLIENTE desta -- organização, as mensagens dessa pessoa param de chegar ao CRM — e isso não -- pode acontecer por engano. A tela pergunta e reenvia com `p_confirma_contato`. if not coalesce(p_confirma_contato, false) and exists ( select 1 from public.contacts c where c.organization_id = p_org and c.phone_number is not null and regexp_replace(c.phone_number, '\D', '', 'g') = any (v_variantes)) then raise exception 'aviso_de_caso_numero_de_cliente' using errcode = '22023'; end if; select * into v_antes from public.config_aviso_de_caso where organization_id = p_org; insert into public.config_aviso_de_caso (organization_id, channel_session_id, telefone_destino, rotulo, ligado, criado_por, atualizado_por) values (p_org, p_channel, p_telefone, nullif(btrim(p_rotulo), ''), coalesce(p_ligado, false), auth.uid(), auth.uid()) on conflict (organization_id) do update set channel_session_id = excluded.channel_session_id, telefone_destino = excluded.telefone_destino, rotulo = excluded.rotulo, ligado = excluded.ligado, atualizado_por = auth.uid(), -- Trocou o número, o JID resolvido do anterior não vale mais — e é o -- JID que o corte da ingestão usa para reconhecer quem está em modo -- privacidade. Mantê-lo faria o corte continuar valendo para o número -- ANTIGO, que pode voltar a ser um cliente. destino_jid = case when excluded.telefone_destino is distinct from config_aviso_de_caso.telefone_destino then null else config_aviso_de_caso.destino_jid end, updated_at = now(); return jsonb_build_object( 'trocou_numero', (v_antes.telefone_destino is distinct from p_telefone), 'antes_ligado', coalesce(v_antes.ligado, false) ); end; $$; -- AS DUAS ORIGENS DE EXECUTE (item 9 da doutrina de migrations): o grant direto -- a `anon` do `ALTER DEFAULT PRIVILEGES … GRANT ALL ON FUNCTIONS TO anon` do -- baseline (que `revoke from public` não remove) e o grant implícito a PUBLIC -- que o Postgres dá a toda função ao criá-la (que `revoke from anon` não -- remove). Fechar uma só deixa a função exposta com o gate verde. revoke all on function public.fn_definir_aviso_de_caso(uuid,uuid,text,text,boolean,boolean) from public, anon; grant execute on function public.fn_definir_aviso_de_caso(uuid,uuid,text,text,boolean,boolean) to authenticated; -- ── O JID que o transporte resolveu ─────────────────────────────────────── -- Função SEPARADA, e não `update` direto pelo handler: dar `update` da -- configuração ao service role abriria o caminho de "o motor mudou o número de -- destino sozinho". Aqui ele só pode gravar UM campo, o que ele mesmo resolveu. create or replace function public.fn_registrar_jid_do_aviso(p_org uuid, p_jid text) returns void language plpgsql security definer set search_path = public, pg_temp as $$ begin if p_org is null or p_jid is null or btrim(p_jid) = '' then return; end if; -- `is distinct from` para não gastar um UPDATE (e o trigger de coerência) a -- cada aviso enviado: o JID muda uma vez e depois é sempre o mesmo. -- `updated_at` FICA FORA: ele responde "alguém mexeu na configuração". update public.config_aviso_de_caso set destino_jid = p_jid where organization_id = p_org and destino_jid is distinct from p_jid; end; $$; revoke all on function public.fn_registrar_jid_do_aviso(uuid,text) from public, anon, authenticated; grant execute on function public.fn_registrar_jid_do_aviso(uuid,text) to service_role; -- ── O contador do descarte ──────────────────────────────────────────────── -- Chamada pelos ingestores quando uma mensagem do número interno é descartada. -- Sem ela o silêncio é indistinguível de defeito para quem olha a tela. create or replace function public.fn_contar_mensagem_ignorada(p_org uuid) returns void language plpgsql security definer set search_path = public, pg_temp as $$ begin if p_org is null then return; end if; -- `updated_at` FICA FORA, de propósito: uma resposta do suporte não é alguém -- mexendo na configuração, e a tela usa aquele carimbo para dizer "alterado -- por Fulano em tal dia". update public.config_aviso_de_caso set mensagens_ignoradas = mensagens_ignoradas + 1, ultima_mensagem_ignorada_em = now() where organization_id = p_org; end; $$; revoke all on function public.fn_contar_mensagem_ignorada(uuid) from public, anon, authenticated; grant execute on function public.fn_contar_mensagem_ignorada(uuid) to service_role; -- ── Retenção: a tabela nasce com dono de piso ───────────────────────────── create or replace function public.fn_expurgar_avisos_de_caso_vencidos( p_retencao_dias int default null, p_limite int default null ) returns int language plpgsql security definer set search_path = public, pg_temp as $$ declare -- 180 dias: mais curto que a auditoria (5 anos) porque a pergunta útil — "o -- aviso daquele caso saiu?" — é de semanas, não de anos. O piso de 30 impede -- que o knob vire apagador do rastro de um incidente que ainda está sendo -- apurado, e mora AQUI, no corpo, porque só assim vale para QUALQUER -- chamador, inclusive um `psql` na mão. v_dias int := greatest(coalesce(p_retencao_dias, 180), 30); v_limite int := least(greatest(coalesce(p_limite, 1000), 1), 10000); v_apagadas int; begin with vencidas as ( select e.id from public.entregas_de_aviso_de_caso e where e.created_at < now() - make_interval(days => v_dias) order by e.created_at limit v_limite ) delete from public.entregas_de_aviso_de_caso e using vencidas v where e.id = v.id; get diagnostics v_apagadas = row_count; return v_apagadas; end; $$; revoke all on function public.fn_expurgar_avisos_de_caso_vencidos(int,int) from public, anon, authenticated; grant execute on function public.fn_expurgar_avisos_de_caso_vencidos(int,int) to service_role; -- Os dois CHECK de vocabulário (`agent_case_events.kind` += 'alert_sent' e -- `agent_inbox_items.kind` += 'aviso_de_caso_nao_entregue') NÃO são -- reconstruídos aqui: neste arquivo cada constraint tem UM bloco só, e os -- valores novos foram acrescentados ao bloco original (o de -- `agent_case_events.kind ganha 'agent_noted' (migration 0100)` e o de -- `agent_inbox_items.kind ganha 'capabilities_missing' (migration 0105)`). -- Um segundo bloco é o defeito da issue #159: num banco com uma linha do -- vocabulário mais novo, ele falha ao reaplicar e a tabela fica sem -- constraint entre o `drop` e o `add` que funciona. Para conferir na fonte: -- grep -c "agent_inbox_items_kind_check check" supabase/baseline.sql -- ── A cascata de LGPD alcança o registro de entrega ─────────────────────── -- Derivada do corpo VIGENTE do baseline (a definição de maior número de linha), -- por script, nunca redigitada: o corpo anterior fica byte a byte igual e as -- ÚNICAS mudanças são as duas declaradas — o passo de `entregas_de_aviso_de_caso` -- e o `aviso_de_caso_nao_entregue` acrescentado ao `kind in (...)` do passo de -- `agent_inbox_items`. O Postgres troca o corpo INTEIRO num `create or replace`; -- quem derivar da versão errada apaga o passo de outra entrega sem um único -- erro. A catraca que vigia isso é -- `tests/invariants/cascata-lgpd-nao-encolhe.test.ts`. CREATE OR REPLACE FUNCTION "public"."fn_lgpd_cascade_redact_contact"("p_organization_id" "uuid", "p_contact_id" "uuid", "p_request_id" "uuid") RETURNS "jsonb" LANGUAGE "plpgsql" SECURITY DEFINER SET "search_path" TO 'public' AS $$ declare v_already bool; v_counts jsonb := '{}'::jsonb; v_media_paths text[] := '{}'; v_anon_label text; v_count int; begin perform public.fn_service_lock(p_organization_id,p_contact_id); select is_anonymized into v_already from contacts where id = p_contact_id and organization_id = p_organization_id; if not found then raise exception 'contact not found' using errcode = 'P0002'; end if; if v_already then return jsonb_build_object('already_anonymized', true, 'counts', v_counts, 'media_paths', v_media_paths); end if; v_anon_label := 'Cliente Anonimizado #' || substring(p_contact_id::text from 1 for 8); -- Collect media storage paths (we only delete what we own — media_storage_path) select coalesce(array_agg(distinct media_storage_path) filter (where media_storage_path is not null), '{}') into v_media_paths from messages where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ); -- 1. contacts (irreversible) update contacts set name = v_anon_label, display_name = v_anon_label, email = null, -- email_normalized NÃO entra: é GENERATED ALWAYS AS (lower(trim(email))) -- e o Postgres recusa escrita nela — a linha acima já a zera por derivação. -- Com a atribuição, o cascade INTEIRO abortava e nada era anonimizado. phone_number = null, cpf_encrypted = null, cpf_hash = null, birthdate = null, is_anonymized = true, anonymized_at = now(), consent = '{}'::jsonb, source_metadata = '{}'::jsonb, tags = '{}'::text[], updated_at = now() where id = p_contact_id and organization_id = p_organization_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('contacts', v_count); -- 2. conversations metadata + preview strip update conversations set metadata = '{}'::jsonb, last_message_preview = null, -- O motivo CRU da última passagem (migration 0291). É código de -- vocabulário, não texto livre — mas ele diz que ESTA pessoa foi escalada -- por irritação, por assunto jurídico ou por suspeita de opt-out, e isso é -- um fato sobre ela. Entra NESTE update, e não num segundo: mesmo -- predicado, mesmas linhas, metade das varreduras. -- -- ⚠️ `last_handoff_reason` é CHAVE DE NEGÓCIO em outro módulo: a ponte de -- voz limpa o silêncio filtrando pelo VALOR da coluna -- (`lib/wacalls/events-bridge.ts`). Zerá-la num contato anonimizado é -- seguro — não há chamada viva de contato anonimizado — e é a razão de -- esta entrega NÃO usar essa coluna para texto rico: ela continua -- recebendo só o código, e o texto vive em `passagens_de_atendimento`. last_handoff_reason = null, updated_at = now() where contact_id = p_contact_id and organization_id = p_organization_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('conversations', v_count); -- 3. messages: redact body + null media + strip metadata (preserve status/timestamps/conversation_id) update messages set body = '[mensagem anonimizada]', media_url = null, media_mime = null, media_size_bytes = null, media_storage_path = null, metadata = '{}'::jsonb, updated_at = now() where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('messages', v_count); -- 4. crm_lead_activities — strip payload, metadata E reason (migration 0071). -- `reason` é texto livre escrito por LLM sobre a conversa do lead: supor que -- nunca conterá um nome é a suposição que falha. `evidence` NÃO é limpa — -- guarda só ids, e as linhas apontadas são redigidas por conta própria. update crm_lead_activities set payload = '{}'::jsonb, metadata = '{}'::jsonb, reason = null where organization_id = p_organization_id and ( contact_id = p_contact_id or lead_id in ( select lead_id from crm_lead_links where target_kind = 'contact' and target_id = p_contact_id and organization_id = p_organization_id ) or lead_id in ( select id from crm_leads where contact_id = p_contact_id and organization_id = p_organization_id ) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('activities', v_count); -- 5. crm_leads — strip title/description/custom_fields/source_metadata/tags but PRESERVE pipeline/stage/value update crm_leads set title = v_anon_label, description = null, custom_fields = '{}'::jsonb, source_metadata = '{}'::jsonb, tags = '{}'::text[], updated_at = now() where organization_id = p_organization_id and ( contact_id = p_contact_id or id in ( select lead_id from crm_lead_links where target_kind = 'contact' and target_id = p_contact_id and organization_id = p_organization_id ) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('leads', v_count); -- 6. orders — PRESERVE values + status + timestamps. Strip personal fields from payload jsonb -- and replace customer_external_id with null (FK-safe; soft de-link). Keep contact_id null. update orders set payload = (coalesce(payload, '{}'::jsonb)) - 'customer' - 'customer_name' - 'customer_email' - 'customer_phone' - 'shipping_address' - 'billing_address' - 'contact_identification', customer_external_id = null, contact_id = null, is_anonymized = true, updated_at = now() where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('orders', v_count); -- 7. enqueue media for async deletion (idempotent via unique (bucket, object_path)) if array_length(v_media_paths, 1) > 0 then insert into storage_redaction_queue (organization_id, request_id, bucket, object_path) select p_organization_id, p_request_id, 'whatsapp-media', path from unnest(v_media_paths) as path where path is not null and length(path) > 0 on conflict (bucket, object_path) do nothing; end if; -- 7b. voice_calls — o TELEFONE de quem falou ao telefone (migration 0235). -- -- `peer_phone` é `not null` e guarda o número da outra ponta: depois de -- anonimizar o contato, ele sobrevivia ligado ao `contact_id` e reidentificava -- a pessoa que pediu para ser esquecida. É o mesmo argumento que a foto de -- perfil já tinha (ver o bloco do avatar em `lib/lgpd/redact-cascade.ts`): -- anonimizar em toda parte menos numa é não ter anonimizado. -- -- O que fica: direção, status, motivo do fim, marcas de tempo e duração. Um -- registro de "houve uma chamada de 12 minutos" sem número e sem dono não -- identifica ninguém e é o que sustenta a métrica do atendente e a fatura. -- `peer_phone` é NOT NULL, então recebe o rótulo, não `null`. update voice_calls set peer_phone = v_anon_label, owner_user_id = null, created_by = null, updated_at = now() where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('voice_calls', v_count); -- agent_cases — o que a IA escreveu SOBRE a pessoa quando travou (migration 0280). -- -- O caso é o texto que a equipe lê antes de decidir: `title`, `summary` e -- `blocker` saem do modelo a partir da conversa, e `context_snapshot` é o -- recorte dessa conversa que o motor mandou para ele. Nada disso é registro de -- operação — é o relato do problema de uma pessoa identificável, escrito por -- máquina. Sem este passo, anonimizar devolvia SUCESSO com o relato intacto. -- -- As três colunas de texto são `not null`: recebem rótulo e texto fixo, nunca -- `null` (a mesma razão de `voice_calls.peer_phone` logo acima). -- -- ⚠️ `updated_at` FICA FORA DO `set`, de propósito. O cobrador de caso parado -- (`app/api/v1/cron/case-stale-watcher/route.ts`) lê `updated_at` como "alguém -- da equipe encostou neste caso". A cascata não é alguém encostando: escrever -- ali faria a anonimização ADIAR a cobrança de um caso que continua parado, e -- o efeito só apareceria como um cliente esperando mais tempo. -- -- O vínculo é pela CONVERSA porque `agent_cases` não tem FK para `contacts`. update agent_cases set title = v_anon_label, summary = '[resumo anonimizado]', blocker = '[bloqueio anonimizado]', context_snapshot = '{}'::jsonb where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('agent_cases', v_count); -- agent_case_events — a linha do tempo do caso (migration 0280). -- -- `body` é o que a pessoa da equipe escreveu ao responder o caso e o que o -- agente registrou sobre o que o LEAD respondeu; `metadata` carrega o recorte -- que o motor anexou. `kind`, `actor_kind`, `human_action` e `created_at` -- FICAM: são o registro de que houve um toque humano e quando — operação, não -- dado da pessoa, e é deles que sai a métrica de atendimento. update agent_case_events set body = null, metadata = '{}'::jsonb where organization_id = p_organization_id and case_id in ( select id from agent_cases where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('agent_case_events', v_count); -- demandas — o assunto do pedido (migration 0280). -- -- `assunto` é texto livre sobre o que a pessoa pediu. O resto da linha é a -- operação da demanda (origem, estado, dono, prazo, desfecho) e fica de pé: -- apagar a linha inteira tiraria da organização a resposta a "quantos pedidos -- houve em março", que é o mesmo argumento do compromisso da agenda. -- -- FK direta (`demandas.contact_id` é `not null`), então o vínculo é o contato. update demandas set assunto = null where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('demandas', v_count); -- agent_inbox_items — o aviso que leva o texto do caso para a Central (migration 0280). -- -- O `body` do aviso de caso parado EMBUTE o título do caso -- (`app/api/v1/cron/case-stale-watcher/route.ts:128`), e o do handoff embute o -- motivo da parada (`lib/ai/handoff/orchestrator.ts:335`). Redigir o caso e -- deixar o aviso de pé seria anonimizar em toda parte menos numa — que é não -- ter anonimizado. O molde (resolver + trocar o corpo + soltar a referência) é -- o de `fn_meet_redact_contact`, que já faz isto para o aviso de compromisso. -- -- ⚠️ O VÍNCULO É POLIMÓRFICO E TEM TRÊS BRAÇOS, não dois. Medido nos -- produtores, não suposto: `handoff` nasce com `ref_kind='contact'` -- (`lib/ai/handoff/orchestrator.ts:339`) E com `ref_kind='conversation'` -- (`lib/agent-engine/agent/inbound-turn.ts:4100`); `case_stale` nasce SEMPRE -- com `ref_kind='agent_case'` (a rota do cron acima, e a política em -- `lib/ai/inbox-destino.ts:38`). Um predicado com só os dois primeiros braços -- casa ZERO avisos de caso parado — e casar zero linha não é erro: é sucesso -- com o texto intacto. -- -- Os `kind` são os MEDIDOS no CHECK vigente (`supabase/baseline.sql`, bloco -- único de `agent_inbox_items_kind_check`). `case_opened` NÃO existe, e kind -- inexistente num `in (...)` também casa zero e devolve sucesso. Para -- reconferir sem acreditar nesta prosa: -- grep -n "agent_inbox_items_kind_check check" -A40 supabase/baseline.sql update agent_inbox_items set status = 'resolved', resolved_at = now(), body = 'Contato anonimizado.', ref_id = null where organization_id = p_organization_id -- `aviso_de_caso_nao_entregue` (migration 0292) entra AQUI e não num -- passo próprio: é o mesmo predicado polimórfico, e o braço -- `ref_kind='agent_case'` já alcança o caso do titular. O corpo do aviso -- embute o título do caso, que é texto sobre a pessoa. and kind in ('handoff', 'case_stale', 'aviso_de_caso_nao_entregue') and ( (ref_kind = 'contact' and ref_id = p_contact_id) or (ref_kind = 'conversation' and ref_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id )) or (ref_kind = 'agent_case' and ref_id in ( select id from agent_cases where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ) )) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('agent_inbox_items', v_count); -- agent_case_chat_messages — a consulta interna da equipe à IA SOBRE o caso -- (migration 0281). FK DIRETA para `contacts`, então o vínculo é o titular e -- não precisa passar pela conversa. -- -- `redacted_at is null` no `where` é o que torna o passo IDEMPOTENTE: a -- varredura diária de redações incompletas roda a função de novo, e sem essa -- condição o carimbo de QUANDO se apagou seria reescrito a cada rodada. -- -- A linha NÃO é apagada, só o texto: quem abrir o caso depois continua vendo -- que a equipe perguntou N vezes, quando, e se a IA respondeu. Apagar a linha -- inteira ficaria verde num teste de "o texto sumiu" e tiraria da organização -- a resposta a "quanto a equipe deliberou sobre este caso". update agent_case_chat_messages set body = null, redacted_at = now() where organization_id = p_organization_id and contact_id = p_contact_id and redacted_at is null; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('agent_case_chat_messages', v_count); -- passagens_de_atendimento — o BRIEFING é sobre a pessoa (migration 0291). -- -- A linha guarda o que a IA concluiu sobre um atendimento de alguém -- identificável: o que ela entendeu que a pessoa quer (`title`), a narrativa -- que quem assumiu leu (`body`), as PALAVRAS LITERAIS do cliente (`notes`), o -- texto livre de quem passou (`content`) e o que a IA já tinha tentado -- (`tentativas`). Nada disso é registro de operação — é o relato do problema -- de uma pessoa, escrito por máquina, na tela de quem vai responder. -- -- `body` é `not null` e recebe o RÓTULO, não `null` — a mesma razão de -- `voice_calls.peer_phone` e de `agent_cases.title` acima: coluna obrigatória -- anulada aborta o cascade INTEIRO, e um cascade abortado não anonimiza nada. -- -- O que FICA, de propósito: `motor`, `origem`, `motivo_codigo`, -- `cliente_avisado`, `aviso_motivo_codigo`, `criado_em` e o par de -- reconhecimento. São operação — quantas passagens houve, por quê, quanto -- tempo até alguém assumir. Um passo que apagasse a linha inteira ficaria -- verde num teste de "o texto sumiu" e tiraria da organização a resposta a -- "quantos atendimentos a IA devolveu em março, e quanto tempo esperaram". -- -- O vínculo é a FK DIRETA `contact_id`: a tabela a carrega exatamente para -- este passo não precisar passar pela conversa. update passagens_de_atendimento set body = v_anon_label, title = null, notes = null, content = null, tentativas = '[]'::jsonb where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('passagens_de_atendimento', v_count); -- entregas_de_aviso_de_caso — o registro do aviso ao suporte (migration 0292). -- -- A tabela NÃO guarda o texto do aviso (só `corpo_hash`), e a única coluna -- capaz de ecoar um dado da pessoa é `erro_detalhe`: ali vai o texto CRU que -- o transporte devolveu, truncado, e um provedor que recusa um envio costuma -- devolver o destinatário dentro da mensagem de erro. -- -- O que FICA, de propósito: `status`, `erro_codigo`, `tentativas`, -- `enviado_em`, `destino`, `corpo_hash`. São operação — quantos avisos saíram, -- quantos falharam e por quê. Um passo que apagasse a linha inteira ficaria -- verde num teste de "o texto sumiu" e tiraria da organização a resposta a -- "quantos avisos não chegaram em março". `destino` é o telefone da EQUIPE, -- não do titular: anonimizar um cliente não apaga o número do plantão. -- -- ⚠️ PONTO CEGO DECLARADO: `tests/invariants/lgpd-cascata-alcanca-quem- -- guarda-pessoa.test.ts` só cobra tabela com FK para `contacts` E coluna cujo -- NOME case o padrão de PII. Esta tabela não satisfaz nenhuma das duas — o -- gate ficaria VERDE sem este passo. Ele entra porque é certo, não porque o -- gate cobra, e isto está escrito aqui para a próxima sessão não o remover -- achando que é ornamento. Quem o vigia é a catraca -- `tests/invariants/cascata-lgpd-nao-encolhe.test.ts`. -- -- O vínculo é pela CONVERSA, como o de `agent_cases`: esta tabela aponta para -- o caso, e o caso não tem FK para `contacts`. update entregas_de_aviso_de_caso set erro_detalhe = null where organization_id = p_organization_id and case_id in ( select id from agent_cases where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('entregas_de_aviso_de_caso', v_count); -- 8. dense audit row insert into api_audit_log (organization_id, action, actor_user_id, resource_type, resource_id, metadata, bypassed_rls) values ( p_organization_id, 'lgpd.redact_executed', null, 'contact', p_contact_id, jsonb_build_object( 'cascaded_to', v_counts, 'media_queued', coalesce(array_length(v_media_paths, 1), 0), 'request_id', p_request_id ), true ); return jsonb_build_object( 'already_anonymized', false, 'counts', v_counts, 'media_paths', v_media_paths ); end; $$; revoke all on function public.fn_lgpd_cascade_redact_contact(uuid,uuid,uuid) from public,anon,authenticated; grant execute on function public.fn_lgpd_cascade_redact_contact(uuid,uuid,uuid) to service_role; -- ── travas do suporte, depois de toda tabela nova (migration 0274) ───────── -- `config_aviso_de_caso` e `entregas_de_aviso_de_caso` nascem com a escrita -- fechada para `anon`/`authenticated` (revoke + grant select), então o ramo -- server-only da função lhes dá ZERO policies `support_write_*` — que é o -- contrato mais restritivo. Escrever `drop policy` à mão aqui seria a segunda -- representação da mesma regra. do $f$ begin perform public.fn_aplicar_travas_de_suporte(); end $f$; notify pgrst, 'reload schema'; -- ---- a passagem se reconhece sozinha (migration 0293) ---- -- -- DERIVADO, byte a byte, de -- supabase/migrations/20260918130000_0293_a_passagem_se_reconhece_sozinha.sql -- (copiado por script, nunca redigitado — ver -- tests/unit/apendice-do-baseline-nao-diverge-da-cadeia.test.ts). -- -- ⚠️ ENTRA ANTES DO BLOCO DA VARREDURA anon, que é de propósito o último a -- criar função: este bloco CRIA duas, e a varredura proíbe `create function` -- depois dela. -- ════════════════════════════════════════════════════════════════════════════ -- 0293 — A PASSAGEM SE RECONHECE SOZINHA -- ════════════════════════════════════════════════════════════════════════════ -- -- ─── O defeito que esta migration fecha ──────────────────────────────────── -- -- A migration 0291 criou `passagens_de_atendimento` com `reconhecido_por` / -- `reconhecido_em`, e NINGUÉM os escrevia. Sem um escritor, três coisas ficam -- quebradas ao mesmo tempo: -- -- 1. o cartão da conversa nunca sai do estado "esperando alguém assumir", -- mesmo depois de alguém ter assumido; -- 2. o aviso da Central fica ABERTO para sempre — e como o aviso deduplica -- por episódio aberto, a PRÓXIMA passagem daquela conversa não abre aviso -- nenhum. O cliente pede um atendente de novo e ninguém é avisado; -- 3. `fn_expurgar_passagens_vencidas` só apaga linha reconhecida (é o certo: -- passagem aberta é demanda viva), então a tabela nunca é podada. -- -- ─── Por que um TRIGGER, e não uma chamada em cinco rotas ────────────────── -- -- Os cinco caminhos que trocam o dono de uma conversa — assumir, transferir, -- liberar, devolver ao automático e o rodízio por canal — passam TODOS por -- `public.fn_conversation_assign`, que insere a linha de auditoria em -- `conversation_assignment_events` na MESMA transação. Um gatilho ali cobre os -- cinco sem tocar em rota nenhuma, e cobre também o sexto caminho que alguém -- escrever amanhã. -- -- É SQL puro: **nenhum HTTP dentro de trigger** (anti-pattern nº 9). Ele faz dois -- `update` locais e volta. -- -- ─── A guarda de estado, e por que ela é a SEGUNDA camada ────────────────── -- -- A migration 0279 já revogou `insert` direto em `conversation_assignment_events` -- de `authenticated`: pela REST ninguém forja um evento de atribuição. Esta -- guarda fecha a INSTÂNCIA também para quem escreve com a service key: o gatilho -- só reconhece quando a conversa REALMENTE está com aquele dono. Sem ela, uma -- linha de auditoria incoerente (inserida à mão, ou por um script de migração de -- dados) marcaria como "assumida" uma passagem que ninguém assumiu — e o aviso -- da Central sumiria da lista de quem precisa agir. -- -- ─── Portabilidade ───────────────────────────────────────────────────────── -- -- Idempotente e portável em `psql` puro: `create or replace function`, -- `drop trigger if exists` + `create trigger`. Sem `BEGIN`/`COMMIT` (o runner já -- envolve). Nenhuma tabela é criada, então não há travas de suporte a reaplicar. -- ── O gatilho: alguém assumiu a conversa ──────────────────────────────────── create or replace function public.fn_passagem_reconhecida() returns trigger language plpgsql security definer set search_path = public, pg_temp as $$ begin -- `to_user_id is null` é release/devolução ao automático: ninguém assumiu. -- Quem fecha esse episódio é `fn_passagem_devolvida`, chamada pela rota de -- "devolver ao automático" — e ela grava `reconhecido_em` SEM -- `reconhecido_por`, que é como a tabela distingue os dois desfechos. if new.to_user_id is null then return new; end if; -- SEGUNDA CAMADA (ver o cabeçalho): só reconhece se a conversa está mesmo com -- aquele dono agora. `is not distinct from` e não `=` porque os dois lados -- podem ser nulos em outras rotas desta mesma tabela. if not exists ( select 1 from public.conversations c where c.id = new.conversation_id and c.organization_id = new.organization_id and c.assigned_to_user_id is not distinct from new.to_user_id ) then return new; end if; update public.passagens_de_atendimento set reconhecido_por = new.to_user_id, reconhecido_em = now() where organization_id = new.organization_id and conversation_id = new.conversation_id and reconhecido_em is null; -- O aviso da Central se resolve junto. `ref_kind='conversation'` é a chave que -- os dois motores passaram a usar (a mesma da dedup) — com `contact` o aviso -- de um contato com duas conversas abertas era um só. update public.agent_inbox_items set status = 'resolved', resolved_at = now() where organization_id = new.organization_id and kind = 'handoff' and ref_kind = 'conversation' and ref_id = new.conversation_id and status = 'open'; return new; end; $$; -- Função nova em `public` nasce EXPOSTA por DUAS origens (o grant implícito a -- PUBLIC do Postgres e o `ALTER DEFAULT PRIVILEGES … TO anon` do baseline), e -- revogar só uma deixa a função alcançável com o gate verde. revoke all on function public.fn_passagem_reconhecida() from public, anon, authenticated; drop trigger if exists trg_passagem_reconhecida on public.conversation_assignment_events; create trigger trg_passagem_reconhecida after insert on public.conversation_assignment_events for each row execute function public.fn_passagem_reconhecida(); -- ── A devolução ao automático: o episódio fechou sem ninguém assumir ──────── -- -- `reconhecido_em` preenchido COM `reconhecido_por` nulo é o par que a 0291 -- documentou: "devolvida ao automático (ninguém assumiu, mas o episódio -- fechou)". O CHECK `passagens_reconhecimento_coerente` permite exatamente esse -- lado e proíbe o inverso. -- -- **`security definer` e não o client de sessão**: a policy da tabela é `for -- select` apenas, e `authenticated` não tem `update` — de propósito, para que -- ninguém reescreva um fato. E não o client de serviço porque abrir admin numa -- rota quando há molde de definer no repositório é privilégio a mais sem -- necessidade. A autorização mora NO CORPO. create or replace function public.fn_passagem_devolvida( p_organization_id uuid, p_conversation_id uuid ) returns integer language plpgsql security definer set search_path = public, pg_temp as $$ declare v_fechadas integer; begin -- Mesmo padrão de `fn_conversation_assign`: quando há sessão, ela precisa ser -- de um membro `agent`+ da organização. Sem sessão (worker com service key) a -- checagem não se aplica — quem tem a chave já tem tudo. if auth.uid() is not null and not public.fn_role_at_least(p_organization_id, 'agent') then raise exception 'caller_not_authorized_for_org' using hint = 'caller must be an active agent+ member of the organization'; end if; update public.passagens_de_atendimento set reconhecido_em = now() where organization_id = p_organization_id and conversation_id = p_conversation_id and reconhecido_em is null; get diagnostics v_fechadas = row_count; update public.agent_inbox_items set status = 'resolved', resolved_at = now() where organization_id = p_organization_id and kind = 'handoff' and ref_kind = 'conversation' and ref_id = p_conversation_id and status = 'open'; return v_fechadas; end; $$; revoke all on function public.fn_passagem_devolvida(uuid, uuid) from public, anon; grant execute on function public.fn_passagem_devolvida(uuid, uuid) to authenticated, service_role; -- ---- o cliente repetiu depois da passagem + o contador do cobrador (migration 0294) ---- -- ═══════════════════════════════════════════════════════════════════════════ -- 0294 — O LAÇO DE RETORNO DA PASSAGEM, e o contador que cala o cobrador. -- -- ─── O que esta migration responde ──────────────────────────────────────── -- -- A entrega da passagem (0291, 0293) fez a IA gravar POR QUE parou, o que já -- tentou e o que o cliente quer, e pôs esse texto na frente de quem assume. A -- pergunta que ela ainda não respondia é a única que diz se aquilo serviu para -- alguma coisa: **depois de a IA passar a conversa, o cliente precisou repetir -- o que já tinha dito?** -- -- Se o briefing chegou, a repetição cai. Se não chegou, ela não muda — e a -- feature é decoração cara. Sem este número, a única evidência de sucesso seria -- alguém achar o cartão bonito. -- -- ─── POR QUE NÃO UMA TABELA DE MÉTRICA, NEM UM PAINEL NOVO ──────────────── -- -- O instrumento já existe e está calibrado: `fn_atrito_jaccard(a,b)` (0135), -- `immutable`, com limiar `p_repeticao_min` default 0.7 escolhido para zero -- falso positivo. `fn_atrito_metrics` já o usa para a repergunta dentro do -- Índice de Atrito, já roda SECURITY INVOKER (logo a RLS da tabela nova vale) e -- já devolve `jsonb` — onde acrescentar chave não quebra leitor antigo. Uma -- tabela de agregado seria um número sincronizado por cron onde cabe uma -- consulta, que é o anti-pattern nº 5 do CLAUDE.md. -- -- ─── AS DUAS CHAVES, E POR QUE SÃO DUAS ────────────────────────────────── -- -- `repeticao_pos_passagem` — numerador: passagens em que o cliente repetiu; -- `passagens_medidas` — denominador: passagens em que ele voltou a falar. -- -- A razão NÃO é calculada aqui. Ela é montada em `lib/metrics/atrito.ts`, que é -- onde mora a regra "denominador zero devolve null, nunca 0". Uma razão -- calculada no SQL devolveria `0/0` como `null` por acaso e `0/1` como `0` por -- acidente — e a tela não teria como distinguir "ninguém repetiu" de "ninguém -- voltou a falar". Publicar os dois números é o que torna a régua auditável por -- quem lê a tela, não só por quem lê este arquivo. -- -- ⚠️ A RESSALVA QUE VIAJA COM O NÚMERO (e que a tela publica na `nota`): -- `fn_atrito_metrics` é **SECURITY INVOKER**. Um `agent` numa organização em -- `visibility_mode='own'` enxerga o número só das conversas dele; `manager` e -- `admin` enxergam o da organização. Dois papéis veem números diferentes de -- boa-fé, e quem comparar sem saber disso vai achar que um deles está errado. -- -- ─── A SEGUNDA COISA: `cobrancas` ──────────────────────────────────────── -- -- O reconhecimento da passagem (0293) só acontece por gesto de quem CHEGOU. -- Ninguém cobra a passagem em que ninguém chegou — e dos treze caminhos que -- passam conversa para uma pessoa, UM nasce de caso, então o -- `case-stale-watcher` não alcança os outros doze nem por acidente. A população -- já foi medida num CRM em produção com o mesmo desenho de fila (2026-09-14): -- 22 pedidos parados, o mais antigo há 17,6 dias, e ONZE deles eram gente -- pedindo para falar com uma pessoa. -- -- O conserto é um segundo braço no MESMO cron, e ele precisa de um lugar para -- guardar quantas vezes já cobrou — senão o alarme nunca cala, e alarme que -- nunca cala treina a equipe a ignorar o alarme certo. `agent_cases` tem -- `followup_attempts` para isso; `passagens_de_atendimento` não tinha nada. -- -- DIRC, respondida: Duplicar — não, o contador é desta linha e de mais nada; -- Integrar — não há de onde vir (o aviso da Central não conta tentativa); -- Referenciar — não é ponteiro; Calcular — não dá: a cobrança não deixa rastro -- próprio em lugar nenhum, e inferi-la por idade cobraria de novo o que já foi -- cobrado três vezes. -- -- ─── REAPLICAÇÃO ───────────────────────────────────────────────────────── -- -- `add column if not exists` com default (aplicável a tabela COM linhas) e -- `create or replace function` com a MESMA assinatura. O `update.sh` de um -- clone reaplica sem erro e sem duplicar efeito. Nenhuma constraint nova sobre -- dados existentes ⇒ não há deduplicação prévia a fazer (regra 8 da doutrina). -- -- O corpo de `fn_atrito_metrics` abaixo é DERIVADO do corpo vigente por script -- (`scratchpad/onda11/montar-0294.py`), nunca redigitado: duas edições, as duas -- provadas reversíveis ao byte antes de o arquivo ser escrito. -- ═══════════════════════════════════════════════════════════════════════════ -- Quantas vezes o vigia já cobrou ESTA passagem. Teto de 3 no chamador -- (`app/api/v1/cron/case-stale-watcher/route.ts`), pelo mesmo argumento de -- `agent_cases.followup_attempts`: quem ignorou três vezes não atende no quarto. alter table public.passagens_de_atendimento add column if not exists cobrancas int not null default 0; drop function if exists public.fn_atrito_metrics(uuid, timestamptz, timestamptz, int, float8, int); create or replace function public.fn_atrito_metrics( p_org uuid, p_from timestamptz, p_to timestamptz, p_abandono_horas int default 72, p_repeticao_min float8 default 0.7, p_espera_horas int default 4 ) returns jsonb language sql stable set search_path = public as $$ with -- DENOMINADOR DEFINITIVO: demandas encerradas na janela. Não mais os casos. demandas_j as ( select d.id, d.agent_case_id, d.aberta_em, d.fechada_em, d.desfecho from public.demandas d where d.organization_id = p_org and d.fechada_em is not null and d.fechada_em >= p_from and d.fechada_em < p_to ), -- Turnos: mensagens de TODAS as conversas da demanda (N:N), dentro da vida -- dela. Uma demanda que atravessou dois canais soma os dois. turnos as ( select d.id, (select count(*) from public.demanda_conversas dc join public.messages m on m.conversation_id = dc.conversation_id and m.organization_id = p_org and m.sent_at >= d.aberta_em and m.sent_at < d.fechada_em where dc.demanda_id = d.id) as n from demandas_j d ), -- Insistência: só existe onde houve caso. O payload declara o denominador -- próprio (`demandas_com_caso`) para o número não ser lido como se fosse -- sobre o total. insistencia as ( select avg(c.followup_attempts)::float8 as media, max(c.followup_attempts) as maximo, count(*) as base from demandas_j d join public.agent_cases c on c.id = d.agent_case_id ), humano as ( select e.case_id, count(*) as intervencoes, min(e.created_at) as primeiro_toque from public.agent_case_events e join demandas_j d on d.agent_case_id = e.case_id where e.organization_id = p_org and e.actor_kind = 'human' group by e.case_id ), espera_fila as ( select extract(epoch from (h.primeiro_toque - d.aberta_em)) as segundos from demandas_j d join humano h on h.case_id = d.agent_case_id where h.primeiro_toque > d.aberta_em ), retrabalho as ( select count(distinct e.case_id) as n from public.agent_case_events e join demandas_j d on d.agent_case_id = e.case_id where e.organization_id = p_org and (e.kind = 'escalated' or e.human_action = 'escalate') ), abandono as ( select count(*) filter ( where cv.last_outbound_at >= p_from and cv.last_outbound_at < p_to and (cv.last_inbound_at is null or cv.last_outbound_at > cv.last_inbound_at) and cv.last_outbound_at < now() - make_interval(hours => p_abandono_horas) and cv.status not in ('resolved', 'closed') ) as abandonadas, count(*) filter ( where cv.last_outbound_at >= p_from and cv.last_outbound_at < p_to ) as com_fala_nossa from public.conversations cv where cv.organization_id = p_org and cv.last_outbound_at is not null ), -- INVARIANTE 4, agora VERIFICÁVEL: demanda aberta sem próximo passo é o -- vazamento que a doutrina proíbe. Antes da 0119 isto não era enumerável. sem_proximo_passo as ( select count(*) as n from public.demandas d where d.organization_id = p_org and d.fechada_em is null and d.proximo_passo is null ), demandas_abertas as ( select count(*) as n from public.demandas d where d.organization_id = p_org and d.fechada_em is null ), inbounds as ( select m.conversation_id, m.sent_at, m.body, lag(m.body) over (partition by m.conversation_id order by m.sent_at) as body_anterior, lag(m.sent_at) over (partition by m.conversation_id order by m.sent_at) as sent_at_anterior from public.messages m where m.organization_id = p_org and m.direction = 'inbound' and m.body is not null and m.sent_at >= p_from and m.sent_at < p_to ), repeticao as ( select count(*) filter ( where i.body_anterior is not null and exists (select 1 from public.messages o where o.organization_id = p_org and o.conversation_id = i.conversation_id and o.direction = 'outbound' and o.sent_at > i.sent_at_anterior and o.sent_at < i.sent_at) and public.fn_atrito_jaccard(i.body, i.body_anterior) >= p_repeticao_min ) as repetidas, count(*) filter ( where i.body_anterior is not null and exists (select 1 from public.messages o where o.organization_id = p_org and o.conversation_id = i.conversation_id and o.direction = 'outbound' and o.sent_at > i.sent_at_anterior and o.sent_at < i.sent_at) ) as com_resposta_no_meio from inbounds i ), espera_calada as ( select count(*) filter (where prox.espera_s > p_espera_horas * 3600) as caladas, count(*) as com_resposta, percentile_cont(0.9) within group (order by prox.espera_s) as p90_s from ( select extract(epoch from ( (select min(o.sent_at) from public.messages o where o.organization_id = p_org and o.conversation_id = m.conversation_id and o.direction = 'outbound' and o.sent_at > m.sent_at) - m.sent_at)) as espera_s from public.messages m where m.organization_id = p_org and m.direction = 'inbound' and m.sent_at >= p_from and m.sent_at < p_to ) prox where prox.espera_s is not null ), envios as ( select count(*) filter (where m.sent_via = 'ai') as por_ia, count(*) filter (where m.sent_via = 'automation') as por_automacao, count(*) filter (where m.sent_via = 'system') as por_integracao, count(*) filter (where m.sent_via = 'user') as por_humano_no_sistema, count(*) filter (where m.sent_via = 'external_device') as por_humano_fora from public.messages m where m.organization_id = p_org and m.direction = 'outbound' and m.sent_at >= p_from and m.sent_at < p_to ), vetos as ( select count(*) filter (where t.vetoed_gate is not null) as vetados, count(distinct t.job_id) as execucoes from public.before_send_traces t where t.organization_id = p_org and t.created_at >= p_from and t.created_at < p_to ), descadastros as ( select count(*) as n from public.contacts c where c.organization_id = p_org and c.blocked_at is not null and c.blocked_at >= p_from and c.blocked_at < p_to ), pedidos_humano as ( select count(*) as n from public.crm_lead_activities a where a.organization_id = p_org and a.type = 'handoff_triggered' and a.performed_at >= p_from and a.performed_at < p_to ), -- ─── O LAÇO DE RETORNO DA PASSAGEM (migration 0294) ────────────────────── -- A pergunta que mede se o briefing serviu para alguma coisa: DEPOIS de a IA -- passar a conversa, o cliente precisou repetir o que já tinha dito? Se o -- contexto chegou a quem assumiu, a repetição cai; se não chegou, ela não -- muda — e a feature é decoração. -- -- A RÉGUA, escrita para o número não envelhecer: -- · limiar = `p_repeticao_min` (0.7), o MESMO do índice de -- repergunta — dois limiares para o mesmo fenômeno -- fariam dois números incomparáveis na mesma tela; -- · janela = 24 h depois da passagem. Mais que isso já é outra -- conversa; menos deixaria de fora o atendente que -- assumiu no dia seguinte; -- · denominador = passagens em que o cliente VOLTOU A FALAR. Sem fala -- nova não há repetição a medir, e contá-las como "não -- repetiu" inflaria o número para o lado bonito. É a -- mesma regra de `lib/metrics/atrito.ts`: ausência de -- dado é `null`, nunca `0` — e é a razão de as DUAS -- chaves saírem daqui (numerador e denominador), em vez -- de uma razão já calculada. repeticao_pos_passagem as ( select count(*) filter (where r.repetiu) as repetidas, count(*) as medidas from ( select p.id, exists ( select 1 from public.messages depois join public.messages antes on antes.organization_id = depois.organization_id and antes.conversation_id = depois.conversation_id and antes.direction = 'inbound' and antes.body is not null and antes.sent_at < p.criado_em where depois.organization_id = p.organization_id and depois.conversation_id = p.conversation_id and depois.direction = 'inbound' and depois.body is not null and depois.sent_at > p.criado_em and depois.sent_at < p.criado_em + interval '24 hours' and public.fn_atrito_jaccard(depois.body, antes.body) >= p_repeticao_min ) as repetiu from public.passagens_de_atendimento p where p.organization_id = p_org and p.criado_em >= p_from and p.criado_em < p_to and exists ( select 1 from public.messages m where m.organization_id = p.organization_id and m.conversation_id = p.conversation_id and m.direction = 'inbound' and m.body is not null and m.sent_at > p.criado_em and m.sent_at < p.criado_em + interval '24 hours' ) ) r ), eficiencia as ( select count(*) filter (where status = 'won') as ganhos, count(*) filter ( where status = 'lost' -- A transferência entre funis não é perda comercial (migration 0266). and coalesce(lost_reason, '') <> 'moved_to_another_pipeline' ) as perdidos from public.crm_leads where organization_id = p_org and status in ('won', 'lost') and closed_at >= p_from and closed_at < p_to ) select jsonb_build_object( 'escopo', jsonb_build_object( 'demandas', (select count(*) from demandas_j), 'demandas_com_caso', (select base from insistencia), 'demandas_abertas', (select n from demandas_abertas), 'de', p_from, 'ate', p_to, 'abandono_horas', p_abandono_horas, 'repeticao_min', p_repeticao_min, 'espera_horas', p_espera_horas, -- Marca a régua do denominador: quem comparar dois períodos precisa saber -- se foram medidos sobre casos ou sobre demandas. 'denominador', 'demandas' ), 'cliente', jsonb_build_object( 'turnos_p50', (select percentile_cont(0.5) within group (order by n) from turnos), 'turnos_p90', (select percentile_cont(0.9) within group (order by n) from turnos), 'insistencia_media', (select media from insistencia), 'insistencia_max', (select maximo from insistencia), 'pedidos_de_humano', (select n from pedidos_humano), 'descadastros', (select n from descadastros), 'abandonos', (select abandonadas from abandono), 'conversas_com_fala_nossa', (select com_fala_nossa from abandono), 'reperguntas', (select repetidas from repeticao), 'perguntas_com_resposta', (select com_resposta_no_meio from repeticao), 'esperas_caladas', (select caladas from espera_calada), 'esperas_medidas', (select com_resposta from espera_calada), 'espera_resposta_p90_s', (select p90_s from espera_calada), -- As duas chaves do laço da passagem (0294). Numerador e denominador -- SEPARADOS de propósito: a razão é calculada na borda, que é onde -- mora a regra de devolver `null` quando o denominador é zero. 'repeticao_pos_passagem', (select repetidas from repeticao_pos_passagem), 'passagens_medidas', (select medidas from repeticao_pos_passagem) ), 'empresa', jsonb_build_object( 'intervencoes_por_demanda', (select avg(coalesce(h.intervencoes, 0))::float8 from demandas_j d left join humano h on h.case_id = d.agent_case_id), 'espera_humana_p50_s', (select percentile_cont(0.5) within group (order by segundos) from espera_fila), 'espera_humana_p90_s', (select percentile_cont(0.9) within group (order by segundos) from espera_fila), 'retrabalho', (select n from retrabalho), 'vetos', (select vetados from vetos), 'execucoes_medidas', (select execucoes from vetos), 'envios_por_ia', (select por_ia from envios), 'envios_por_automacao', (select por_automacao from envios), 'envios_por_integracao', (select por_integracao from envios), 'envios_humano_no_sistema', (select por_humano_no_sistema from envios), 'envios_humano_fora', (select por_humano_fora from envios), -- O invariante 4 vira NÚMERO na tela: demanda aberta sem próximo passo é -- vazamento, e vazamento invisível é o que a doutrina inteira combate. 'demandas_sem_proximo_passo', (select n from sem_proximo_passo) ), 'eficiencia', jsonb_build_object( 'ganhos', (select ganhos from eficiencia), 'perdidos', (select perdidos from eficiencia) ) ); $$; revoke all on function public.fn_atrito_metrics(uuid, timestamptz, timestamptz, int, float8, int) from public; revoke execute on function public.fn_atrito_metrics(uuid, timestamptz, timestamptz, int, float8, int) from anon; grant execute on function public.fn_atrito_metrics(uuid, timestamptz, timestamptz, int, float8, int) to authenticated, service_role; -- ---- o banco conhece o perfil declarativo v2 das extensões (migration 0282) ---- -- 0282 — O banco passa a conhecer o perfil declarativo v2 (ADR-0003) -- -- POR QUE ESTA MIGRATION EXISTE, e por que a ADR-0003 dizia que ela não existiria. -- -- A ADR afirmou "esta ADR não altera o schema", apoiada numa medição PARCIAL: li a validação -- do MANIFESTO (0271:356-357), vi que ela fecha o conjunto de CHAVES e não o conteúdo delas, -- e concluí sobre o sistema inteiro. Faltou ler até o efeito. A validação do CATÁLOGO, dentro -- de `fn_extensions_admit_catalog`, faz duas coisas que a do manifesto não faz: -- -- 0271:194 v_entry->'permissions' <> '["navigation.tasks"]'::jsonb -- valor FIXO -- 0271:184 v_entry - array[...9 chaves...] <> '{}'::jsonb -- chave nova é erro -- -- Efeito medido: sem esta migration, um catálogo do perfil v2 é RECUSADO pelo banco — tanto -- por declarar outra permissão quanto por trazer o metadado de loja. O contrato novo viveria -- só no TypeScript, e a admissão falharia com `extension_invalid_input`. -- -- O QUE MUDA -- -- 1. Permissões viram conjunto fechado, o mesmo de `lib/extensions/capacidades.ts`: lista não -- vazia, sem repetição, sem valor desconhecido. A mesma regra dos dois lados. -- 2. As cinco chaves de loja passam a ser aceitas NO CATÁLOGO. No manifesto continuam -- recusadas: o pacote descreve o que faz, o catálogo revisado descreve de quem é. -- 3. `extension_permissions_changed` passa a existir de verdade, em atualizar e em desfazer. -- -- As três funções são reescritas INTEIRAS, com o corpo da 0271 preservado e as alterações -- aplicadas aqui, no arquivo — e não por substituição de texto em tempo de execução lendo o -- `prosrc` do banco, que dependeria do estado de cada clone e falharia em silêncio. -- -- Idempotente: `create or replace` em todas. Reaplicar não duplica efeito. create or replace function public.fn_extensions_permissoes_validas(p_permissions jsonb) returns boolean language sql immutable set search_path = public, pg_temp as $$ select p_permissions is not null and jsonb_typeof(p_permissions) = 'array' and jsonb_array_length(p_permissions) between 1 and 6 and not exists ( select 1 from jsonb_array_elements(p_permissions) e where jsonb_typeof(e.value) <> 'string' or e.value #>> '{}' not in ( 'navigation.tasks', 'navigation.inbox', 'navigation.kanban', 'navigation.contacts', 'navigation.agenda', 'navigation.radar') ) and (select count(distinct e.value) from jsonb_array_elements(p_permissions) e) = jsonb_array_length(p_permissions); $$; revoke execute on function public.fn_extensions_permissoes_validas(jsonb) from public, anon; revoke execute on function public.fn_extensions_permissoes_validas(jsonb) from authenticated; grant execute on function public.fn_extensions_permissoes_validas(jsonb) to service_role; create or replace function public.fn_extensions_admit_catalog(p_actor uuid, p_operation uuid, p_snapshot jsonb, p_digest text) returns jsonb language plpgsql security definer set search_path = public, pg_temp as $$ declare v_request jsonb := jsonb_build_object('kind','catalog_admission','actor',p_actor,'snapshot',p_snapshot,'digest',p_digest); v_op public.extension_operations; v_catalog public.extension_catalogs; v_entry jsonb; v_revision integer; begin perform public.fn_extensions_assert_actor(p_actor); if p_operation is null then raise exception using errcode='P0001', message='extension_invalid_input'; end if; perform pg_advisory_xact_lock(255,1); perform public.fn_extensions_assert_actor(p_actor); perform pg_advisory_xact_lock(hashtextextended(p_operation::text,255)); perform public.fn_extensions_assert_actor(p_actor); select * into v_op from public.extension_operations where id=p_operation; if found then if v_op.request_fingerprint <> public.fn_extensions_fingerprint(v_request) then raise exception using errcode='P0001', message='extension_idempotency_conflict'; end if; return to_jsonb(v_op) || jsonb_build_object('applied_now', false); end if; if p_snapshot is null or jsonb_typeof(p_snapshot) <> 'object' or not (p_snapshot ?& array['format_version','origin','revision','entries']) or p_snapshot - array['format_version','origin','revision','entries'] <> '{}'::jsonb or p_snapshot->'format_version' is distinct from '1'::jsonb or jsonb_typeof(p_snapshot->'origin') is distinct from 'string' or p_snapshot->>'origin' !~ '^https?://[^/@?#[:space:]]+$' or jsonb_typeof(p_snapshot->'revision') is distinct from 'number' or p_snapshot->>'revision' !~ '^[1-9][0-9]{0,8}$' or jsonb_typeof(p_snapshot->'entries') is distinct from 'array' or p_digest is null or p_digest !~ '^[a-f0-9]{64}$' then raise exception using errcode='P0001', message='extension_invalid_input'; end if; if jsonb_array_length(p_snapshot->'entries') > 128 then raise exception using errcode='P0001', message='extension_invalid_input'; end if; for v_entry in select value from jsonb_array_elements(p_snapshot->'entries') loop if jsonb_typeof(v_entry) <> 'object' or not (v_entry ?& array['publisher','name','version','license','host_api','display','permissions','sha256','byte_length']) or v_entry - array['publisher','name','version','license','host_api','display','permissions','sha256','byte_length','publisher_label','homepage','repository','tags','published_at'] <> '{}'::jsonb or exists (select 1 from jsonb_each(v_entry) e where e.value='null'::jsonb) or jsonb_typeof(v_entry->'byte_length') is distinct from 'number' or jsonb_typeof(v_entry->'host_api') is distinct from 'object' or jsonb_typeof(v_entry->'display') is distinct from 'object' or v_entry->>'publisher' !~ '^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$' or v_entry->>'name' !~ '^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$' or v_entry->>'version' !~ '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' or v_entry->>'sha256' !~ '^[a-f0-9]{64}$' or v_entry->>'byte_length' !~ '^[1-9][0-9]{0,4}$' or v_entry->>'license' <> 'MIT' or not public.fn_extensions_permissoes_validas(v_entry->'permissions') then raise exception using errcode='P0001', message='extension_invalid_input'; end if; if (v_entry->>'byte_length')::integer > 65536 then raise exception using errcode='P0001', message='extension_invalid_input'; end if; end loop; if exists (select 1 from jsonb_array_elements(p_snapshot->'entries') e group by e->>'publisher', e->>'name', e->>'version' having count(*) > 1) then raise exception using errcode='P0001', message='extension_invalid_input'; end if; v_revision := (p_snapshot->>'revision')::integer; select * into v_catalog from public.extension_catalogs where origin=p_snapshot->>'origin'; if found and (v_revision < v_catalog.revision or (v_revision = v_catalog.revision and (p_digest <> v_catalog.digest or p_snapshot <> v_catalog.snapshot))) then raise exception using errcode='P0001', message='extension_catalog_revision_conflict'; end if; if v_catalog.id is null then if (select count(*) from public.extension_catalogs) >= 8 then raise exception using errcode='P0001',message='extension_catalog_limit'; end if; insert into public.extension_catalogs(origin,revision,digest,snapshot,admitted_by) values(p_snapshot->>'origin',v_revision,p_digest,p_snapshot,p_actor) returning * into v_catalog; elsif v_revision > v_catalog.revision then update public.extension_catalogs set revision=v_revision,digest=p_digest,snapshot=p_snapshot, admitted_by=p_actor,admitted_at=now() where id=v_catalog.id returning * into v_catalog; update public.extension_operations set status='cancelled',error_code='extension_catalog_stale',updated_at=now() where catalog_id=v_catalog.id and kind in ('install','update') and status='preparing'; end if; insert into public.extension_operations(id,kind,status,actor_id,catalog_id,request,request_fingerprint,result) values(p_operation,'catalog_admission','completed',p_actor,v_catalog.id,v_request, public.fn_extensions_fingerprint(v_request),jsonb_build_object('catalog',to_jsonb(v_catalog))) returning * into v_op; return to_jsonb(v_op) || jsonb_build_object('applied_now', true); end $$; create or replace function public.fn_extensions_finish_install(p_actor uuid, p_operation uuid, p_manifest jsonb, p_sha256 text, p_byte_length integer, p_document text) returns jsonb language plpgsql security definer set search_path = public, pg_temp as $$ declare v_op public.extension_operations; v_catalog public.extension_catalogs; v_artifact public.extension_artifacts; v_install public.extension_installations; v_current public.extension_artifacts; v_previous public.extension_artifacts; v_document_json jsonb; v_active integer := 0; begin perform public.fn_extensions_assert_actor(p_actor); perform pg_advisory_xact_lock(255,1); perform public.fn_extensions_assert_actor(p_actor); select * into v_op from public.extension_operations where id=p_operation for update; if not found then raise exception using errcode='P0001',message='extension_operation_not_found'; end if; if v_op.kind not in ('install','update') or v_op.actor_id is distinct from p_actor then raise exception using errcode='P0001',message='extension_operation_conflict'; end if; -- Sem autoridade após cancel/fail. Resposta perdida de completed segue verificando payload. if v_op.status in ('cancelled','failed') then return to_jsonb(v_op) || jsonb_build_object('applied_now', false); end if; if p_document is null or octet_length(p_document) not between 1 and 65536 or octet_length(p_document) is distinct from p_byte_length or encode(sha256(convert_to(p_document,'UTF8')),'hex') is distinct from p_sha256 then raise exception using errcode='P0001',message='extension_artifact_mismatch'; end if; begin v_document_json := p_document::jsonb; exception when invalid_text_representation or untranslatable_character or program_limit_exceeded then raise exception using errcode='P0001',message='extension_artifact_mismatch'; end; if v_document_json is distinct from p_manifest then raise exception using errcode='P0001',message='extension_artifact_mismatch'; end if; if p_sha256 is distinct from v_op.entry->>'sha256' or p_byte_length is distinct from (v_op.entry->>'byte_length')::integer or p_manifest is null or jsonb_typeof(p_manifest) <> 'object' or not (p_manifest ?& array['format_version','profile','publisher','name','version','license','host_api','permissions','dependencies','data','display','configuration','contributions']) or p_manifest - array['format_version','profile','publisher','name','version','license','host_api','permissions','dependencies','data','display','configuration','contributions'] <> '{}'::jsonb or exists (select 1 from jsonb_each(p_manifest) e where e.value='null'::jsonb) or p_manifest->'format_version' is distinct from '1'::jsonb or p_manifest->>'profile' is distinct from 'declarative' or jsonb_typeof(p_manifest->'configuration') is distinct from 'object' or jsonb_typeof(p_manifest->'contributions') is distinct from 'object' or p_manifest->>'publisher' is distinct from v_op.publisher or p_manifest->>'name' is distinct from v_op.name or p_manifest->>'version' is distinct from v_op.version or p_manifest->'dependencies' <> '[]'::jsonb or p_manifest->'data' <> '{"mode":"none"}'::jsonb or (p_manifest - array['format_version','profile','dependencies','data','configuration','contributions']) is distinct from (v_op.entry - array['sha256','byte_length']) then raise exception using errcode='P0001',message='extension_artifact_mismatch'; end if; if v_op.status='completed' then -- Compara com o artefato que ESTA conclusão publicou, não com o ponteiro de agora: um -- "desfazer" posterior não pode fazer a repetição acusar pacote adulterado. select * into v_artifact from public.extension_artifacts where id=coalesce(v_op.result->>'to_artifact_id', v_op.result->'installation'->>'artifact_id')::uuid; if not found or v_artifact.manifest is distinct from p_manifest or v_artifact.document is distinct from p_document then raise exception using errcode='P0001',message='extension_artifact_mismatch'; end if; return to_jsonb(v_op) || jsonb_build_object('applied_now', false); end if; if public.fn_extensions_core_update_in_progress() then raise exception using errcode='P0001',message='extension_core_update_in_progress'; end if; select * into v_catalog from public.extension_catalogs where id=v_op.catalog_id; if v_catalog.revision is distinct from v_op.admission_revision or v_catalog.digest is distinct from v_op.admission_digest then raise exception using errcode='P0001',message='extension_catalog_stale'; end if; select * into v_install from public.extension_installations where catalog_id=v_op.catalog_id and publisher=v_op.publisher and name=v_op.name for update; -- Defesa estrutural: a linha tem de estar na revisão que a preparação viu. if v_install.revision is distinct from (v_op.result->>'from_revision')::integer or (v_op.kind='update' and v_install.removed_at is not null) or (v_op.kind='install' and v_install.id is not null and v_install.removed_at is null) then raise exception using errcode='P0001',message='extension_version_changed'; end if; if v_install.id is not null then select * into v_current from public.extension_artifacts where id=v_install.artifact_id; -- A recusa que a spec v1 prometeu para "quando o contrato admitir outra permissão". -- Sem ela, 1.0 -> 1.1 acrescentaria uma porta sem ninguém na organização rever a lista -- que a tela existe para mostrar: o furo entra pela porta lateral da própria propriedade -- que a lista de permissões garante. Mudar o conjunto de portas é outra extensão. if v_op.kind='update' and v_current.id is not null and v_current.manifest->'permissions' is distinct from p_manifest->'permissions' then raise exception using errcode='P0001',message='extension_permissions_changed'; end if; select * into v_previous from public.extension_artifacts where id=v_install.previous_artifact_id; if (v_install.version = v_op.version and v_current.sha256 <> p_sha256) or (v_previous.id is not null and v_previous.manifest->>'version' = v_op.version and v_previous.sha256 <> p_sha256) then raise exception using errcode='P0001',message='extension_version_conflict'; end if; end if; select * into v_artifact from public.extension_artifacts where sha256=p_sha256; if found then if v_artifact.manifest is distinct from p_manifest or v_artifact.document is distinct from p_document or v_artifact.byte_length <> p_byte_length then raise exception using errcode='P0001',message='extension_artifact_mismatch'; end if; else insert into public.extension_artifacts(sha256,byte_length,manifest,document) values(p_sha256,p_byte_length,p_manifest,p_document) returning * into v_artifact; end if; if v_install.id is null then insert into public.extension_installations(catalog_id,artifact_id,publisher,name,version,installed_by) values(v_op.catalog_id,v_artifact.id,v_op.publisher,v_op.name,v_op.version,p_actor) returning * into v_install; elsif v_op.kind='install' then -- Reinstalação de uma linha removida: os vínculos NÃO voltam ativos; cada organização decide. update public.extension_installations set artifact_id=v_artifact.id, version=v_op.version, previous_artifact_id=null, removed_at=null, removed_by=null, installed_by=p_actor, installed_at=now(), revision=revision+1 where id=v_install.id returning * into v_install; else update public.extension_installations set previous_artifact_id=artifact_id, artifact_id=v_artifact.id, version=v_op.version, revision=revision+1 where id=v_install.id returning * into v_install; select count(*)::integer into v_active from public.organization_extensions where installation_id=v_install.id and enabled; end if; update public.extension_operations set status='completed',installation_id=v_install.id, result=coalesce(v_op.result,'{}'::jsonb) || jsonb_build_object('installation',to_jsonb(v_install), 'to_artifact_id',v_artifact.id,'to_version',v_op.version,'organizations_active',v_active), updated_at=now() where id=p_operation returning * into v_op; return to_jsonb(v_op) || jsonb_build_object('applied_now', true); end $$; create or replace function public.fn_extensions_revert_install(p_actor uuid, p_operation uuid, p_installation uuid, p_expected_installation_revision integer) returns jsonb language plpgsql security definer set search_path = public, pg_temp as $$ declare v_request jsonb := jsonb_build_object('kind','revert','actor',p_actor,'installation',p_installation, 'expected_installation_revision',p_expected_installation_revision); v_op public.extension_operations; v_install public.extension_installations; v_from public.extension_installations; v_target public.extension_artifacts; v_active integer; begin perform public.fn_extensions_assert_actor(p_actor); if p_operation is null or p_installation is null or p_expected_installation_revision is null or p_expected_installation_revision < 1 then raise exception using errcode='P0001',message='extension_invalid_input'; end if; perform pg_advisory_xact_lock(255,1); perform public.fn_extensions_assert_actor(p_actor); perform pg_advisory_xact_lock(hashtextextended(p_operation::text,255)); perform public.fn_extensions_assert_actor(p_actor); select * into v_op from public.extension_operations where id=p_operation; if found then if v_op.request_fingerprint <> public.fn_extensions_fingerprint(v_request) then raise exception using errcode='P0001',message='extension_idempotency_conflict'; end if; return to_jsonb(v_op) || jsonb_build_object('applied_now', false); end if; if public.fn_extensions_core_update_in_progress() then raise exception using errcode='P0001',message='extension_core_update_in_progress'; end if; select * into v_install from public.extension_installations where id=p_installation for update; if not found then raise exception using errcode='P0001',message='extension_installation_not_found'; end if; if v_install.removed_at is not null then raise exception using errcode='P0001',message='extension_removed'; end if; if exists (select 1 from public.extension_operations where kind in ('install','update') and status='preparing' and catalog_id=v_install.catalog_id and publisher=v_install.publisher and name=v_install.name) then raise exception using errcode='P0001',message='extension_preparation_in_progress'; end if; if v_install.revision <> p_expected_installation_revision then raise exception using errcode='P0001',message='extension_version_changed'; end if; if v_install.previous_artifact_id is null then raise exception using errcode='P0001',message='extension_no_previous_version'; end if; select * into v_target from public.extension_artifacts where id=v_install.previous_artifact_id; -- Desfazer tem a mesma regra: voltar a uma versão com outro conjunto de portas mudaria em -- silêncio o que a organização aceitou. Quem precisa disso reinstala e reativa. if v_target.manifest->'permissions' is distinct from (select manifest->'permissions' from public.extension_artifacts where id=v_install.artifact_id) then raise exception using errcode='P0001',message='extension_permissions_changed'; end if; v_from := v_install; update public.extension_installations set artifact_id=previous_artifact_id, previous_artifact_id=artifact_id, version=v_target.manifest->>'version', revision=revision+1 where id=p_installation returning * into v_install; select count(*)::integer into v_active from public.organization_extensions where installation_id=p_installation and enabled; insert into public.extension_operations(id,kind,status,actor_id,catalog_id,installation_id,publisher,name,version, request,request_fingerprint,result) values(p_operation,'revert','completed',p_actor,v_install.catalog_id,v_install.id,v_install.publisher,v_install.name, v_install.version,v_request,public.fn_extensions_fingerprint(v_request), jsonb_build_object('installation',to_jsonb(v_install),'from_revision',v_from.revision,'from_artifact_id',v_from.artifact_id, 'from_version',v_from.version,'to_artifact_id',v_install.artifact_id,'to_version',v_install.version, 'organizations_active',v_active)) returning * into v_op; return to_jsonb(v_op) || jsonb_build_object('applied_now', true); end $$; revoke execute on function public.fn_extensions_admit_catalog(uuid, uuid, jsonb, text) from public, anon; revoke execute on function public.fn_extensions_admit_catalog(uuid, uuid, jsonb, text) from authenticated; grant execute on function public.fn_extensions_admit_catalog(uuid, uuid, jsonb, text) to service_role; revoke execute on function public.fn_extensions_finish_install(uuid, uuid, jsonb, text, integer, text) from public, anon; revoke execute on function public.fn_extensions_finish_install(uuid, uuid, jsonb, text, integer, text) from authenticated; grant execute on function public.fn_extensions_finish_install(uuid, uuid, jsonb, text, integer, text) to service_role; revoke execute on function public.fn_extensions_revert_install(uuid, uuid, uuid, integer) from public, anon; revoke execute on function public.fn_extensions_revert_install(uuid, uuid, uuid, integer) from authenticated; grant execute on function public.fn_extensions_revert_install(uuid, uuid, uuid, integer) to service_role; -- ---- marcadores do contato no filtro de conversas (migration 0323) ---- -- Campo calculado do PostgREST: o filtro ?tag= do Inbox casa conversations.tags -- OU contacts.tags num único or=, sem lista de ids na URL. SECURITY INVOKER (a -- RLS de contacts vale para quem chama); as duas origens de EXECUTE revogadas. -- Antes da varredura de anon, como toda função nova do apêndice. create or replace function public.tags_do_contato(c public.conversations) returns text[] language sql stable set search_path = public as $$ select ct.tags from public.contacts ct where ct.id = c.contact_id $$; comment on function public.tags_do_contato(public.conversations) is 'Campo calculado do PostgREST: os marcadores do contato da conversa. Permite ao filtro ?tag= do Inbox casar conversations.tags OU contacts.tags num único or= (migration 0323).'; revoke execute on function public.tags_do_contato(public.conversations) from public, anon; grant execute on function public.tags_do_contato(public.conversations) to authenticated, service_role; notify pgrst, 'reload schema'; -- ---- transporte SMTP da instalação: a segunda opção de e-mail (migration 0333) ---- -- -- Singleton de escopo de INSTALAÇÃO, no mesmo desenho de `platform_meta_app` -- (0257) e `platform_google_oauth` (0201): um servidor SMTP atende os e-mails de -- todas as empresas desta VPS. A Resend NÃO sai — `lib/email/roteador.ts` usa -- SMTP quando há SMTP e Resend quando não há, e as sete `SMTP_*` do `.env` -- seguem valendo como piso de rollback. -- -- O `revoke` é obrigatório: o `alter default privileges` do topo deste arquivo -- concede tabela nova a `anon` e `authenticated`. -- -- Idempotente e auto-curativo (é o caminho do `update.sh` de um clone): tabela, -- comentários e trigger com `if not exists`/`drop … if exists`. Nenhum dado é -- tocado e nenhuma constraint nova incide sobre linha existente. create table if not exists public.platform_smtp_settings ( id smallint primary key default 1, smtp_host text, smtp_port integer not null default 587 check (smtp_port between 1 and 65535), smtp_security text not null default 'starttls' check (smtp_security in ('starttls', 'tls', 'none')), smtp_username text, smtp_password_encrypted bytea, from_email text, from_name text, updated_at timestamptz not null default now(), updated_by uuid, constraint platform_smtp_settings_singleton check (id = 1), constraint platform_smtp_settings_host check (smtp_host is null or smtp_host ~ '^[A-Za-z0-9][A-Za-z0-9.-]{0,252}$'), constraint platform_smtp_settings_from_email check ( from_email is null or from_email ~* '^[^[:space:]@]+@[^[:space:]@]+\.[^[:space:]@]+$' ) ); comment on table public.platform_smtp_settings is 'O servidor SMTP DESTA INSTALAÇÃO (singleton). Server-side only: RLS ligada sem policies e grants revogados de anon/authenticated — o PostgREST não a serve. A senha é cifrada e nunca volta ao browser; a tela devolve apenas se existe.'; comment on column public.platform_smtp_settings.smtp_password_encrypted is 'Cifrada por fn_encrypt_oauth (pgp_sym_encrypt/aes256). Nunca gravar em claro: sem a chave mestra o save recusa. Quem tem este valor manda e-mail como a instalação.'; comment on column public.platform_smtp_settings.smtp_security is 'starttls (normalmente porta 587), tls (TLS implícito, normalmente 465) ou none. O CHECK existe porque o valor vira flag do transporte em lib/email/smtp.ts.'; alter table public.platform_smtp_settings enable row level security; revoke all on public.platform_smtp_settings from anon, authenticated; grant select, insert, update on public.platform_smtp_settings to service_role; drop trigger if exists trg_platform_smtp_settings_updated_at on public.platform_smtp_settings; create trigger trg_platform_smtp_settings_updated_at before update on public.platform_smtp_settings for each row execute function public.fn_set_updated_at(); -- ---- ai_agents.channel + phone_numbers (migration 0347) ---- -- ============================================================ -- 0347_modulo_voip — ai_agents.channel, phone_numbers, fn_resolve_inbound_number -- -- RECOMPOSTA. Esta migration existiu no PR #677 como 0232_modulo_voip e foi -- apagada por acidente no commit 97eed0955 (que unificou crm_calls em -- voice_calls): o apêndice do baseline manteve o bloco, mas o arquivo sumiu, -- e quem aplica as migrations em ordem nunca receberia phone_numbers. -- O corpo abaixo é o bloco do apêndice, que já descartava crm_calls (ver -- 0348_voice_calls_sip). Renumerada para acima do máximo da main. -- ============================================================ -- -- SIP/Asterisk + IA de voz via OpenAI Realtime. Segue os mesmos padrões de -- conversations/messages: RLS por tenant via fn_user_org_ids(), audit -- append-only em mutações, text+CHECK (nunca enum nativo). -- -- Sem event_log para call.*: nenhum handler em lib/event-log/register-handlers.ts -- consumiria esses tipos ainda — evento sem handler nasce `pending` pra sempre -- no drain (anti-pattern nº 3, ver migration 0155). Se um consumidor real -- aparecer, adicionar handler + trigger juntos, não antes. alter table public.ai_agents add column if not exists channel text not null default 'whatsapp'; alter table public.ai_agents drop constraint if exists ai_agents_channel_check; alter table public.ai_agents add constraint ai_agents_channel_check check (channel = any (array['whatsapp', 'voice'])); create table if not exists public.phone_numbers ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, number text not null unique, label text, trunk_endpoint text not null, routing_mode text not null default 'ai' check (routing_mode = any (array['ai', 'human', 'ai_then_human'])), default_ai_agent_id uuid references public.ai_agents(id) on delete set null, fallback_user_id uuid references auth.users(id) on delete set null, is_active boolean not null default true, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create index if not exists idx_phone_numbers_org on public.phone_numbers(organization_id); create index if not exists idx_phone_numbers_active on public.phone_numbers(number) where is_active; alter table public.phone_numbers enable row level security; drop policy if exists phone_numbers_isolation on public.phone_numbers; create policy phone_numbers_isolation on public.phone_numbers using (organization_id in (select fn_user_org_ids())) with check (organization_id in (select fn_user_org_ids())); drop trigger if exists trg_phone_numbers_updated_at on public.phone_numbers; create trigger trg_phone_numbers_updated_at before update on public.phone_numbers for each row execute function public.fn_set_updated_at(); drop trigger if exists trg_phone_numbers_audit on public.phone_numbers; create trigger trg_phone_numbers_audit after insert or update or delete on public.phone_numbers for each row execute function public.fn_audit_log_row(); -- Resolve org + config de roteamento a partir do número discado (DNIS). -- Usado pelo worker (service-role, ignora RLS). create or replace function public.fn_resolve_inbound_number(p_number text) returns table ( organization_id uuid, routing_mode text, default_ai_agent_id uuid, fallback_user_id uuid ) as $$ select organization_id, routing_mode, default_ai_agent_id, fallback_user_id from public.phone_numbers where number = p_number and is_active limit 1; $$ language sql security definer stable; -- Regra do item 9 do CLAUDE.md: função nova em public nasce exposta via as -- DUAS origens (default privileges + grant implícito a PUBLIC). Revoga as -- duas, concede só a service_role (é o worker quem chama, via admin client). revoke execute on function public.fn_resolve_inbound_number(text) from public, anon; grant execute on function public.fn_resolve_inbound_number(text) to service_role; notify pgrst,'reload schema'; -- ---- voice_calls ganha o módulo SIP (migration 0348) ---- -- 0348_voice_calls_sip (nasceu 0252 no PR #677; renumerada) -- -- Unifica `crm_calls` (nosso módulo SIP/AudioSocket, ainda não mergeado — -- PR #677) dentro de `voice_calls` (WhatsApp/WaCalls, mergeada via #628/#697, -- migrations 0233-0236). A própria triagem do #677 apontou o problema: duas -- tabelas de chamada que não conversam — o histórico de uma ligação SIP não -- aparecia junto do de uma ligação de WhatsApp na ficha do mesmo cliente. -- -- `voice_calls` foi desenhada só pra WhatsApp: `channel_session_id` -- (sessão pareada) e `wacalls_call_id` são `not null`, e o vocabulário de -- `status` (`starting|ringing|connected|ended`) é literal do BINÁRIO WaCalls -- upstream, não nosso — não se toca nisso (mesmo espírito do `end_reason`, -- que a 0233 já deixa livre de propósito por ser vocabulário de terceiro). -- -- O que este arquivo faz: -- 1. Relaxa as duas colunas WhatsApp-only pra nullable (SIP não tem sessão -- pareada nem id do binário WaCalls). -- 2. Acrescenta `provider` (discriminador) + colunas SIP/IA, todas aditivas -- e nullable — toda linha de WhatsApp existente fica com elas em null. -- 3. Migra as linhas de `crm_calls` (dados de teste do módulo SIP, ainda -- não em produção real) pro novo formato e derruba a tabela antiga — -- dentro do mesmo arquivo pra não deixar as duas tabelas concorrentes -- vivas em nenhum commit. -- 4. Estende `fn_lgpd_cascade_redact_contact`: sem isto, anonimizar um -- contato deixaria a TRANSCRIÇÃO da ligação (que pode conter o nome -- dele, falado em voz) intacta, ligada ao `contact_id` — mesmo buraco -- de reidentificação que a 0235 já fechou pra `peer_phone`. -- -- Doutrina deste repo: migration não se edita depois de aplicada em algum -- ambiente — o que se corrige, corrige-se pra frente. Tudo aqui é -- idempotente (`if not exists`/`if exists`) pra rodar seguro num clone que -- ainda não tem `crm_calls` (a tabela nunca chegou a ser mergeada em `main`) -- e também num ambiente (esta VPS) onde ela já existe com dados de teste. -- ─── 1. relaxa colunas WhatsApp-only ──────────────────────────────────────── alter table public.voice_calls alter column channel_session_id drop not null; alter table public.voice_calls alter column wacalls_call_id drop not null; -- ─── 2. colunas novas, aditivas ───────────────────────────────────────────── alter table public.voice_calls add column if not exists provider text not null default 'wacalls', add column if not exists asterisk_channel_id text, add column if not exists lead_id uuid references public.crm_leads(id) on delete set null, add column if not exists ai_agent_id uuid references public.ai_agents(id) on delete set null, add column if not exists handled_by text, add column if not exists transcript jsonb, add column if not exists metadata jsonb not null default '{}'::jsonb; alter table public.voice_calls drop constraint if exists voice_calls_provider_check; alter table public.voice_calls add constraint voice_calls_provider_check check (provider = any (array['wacalls', 'sip'])); alter table public.voice_calls drop constraint if exists voice_calls_handled_by_check; alter table public.voice_calls add constraint voice_calls_handled_by_check check (handled_by is null or handled_by = any (array['human', 'ai', 'ai_then_human'])); create index if not exists idx_voice_calls_asterisk_channel on public.voice_calls(asterisk_channel_id) where asterisk_channel_id is not null; create index if not exists idx_voice_calls_lead on public.voice_calls(lead_id) where lead_id is not null; comment on column public.voice_calls.provider is 'Discrimina a origem da ligação: ''wacalls'' (WhatsApp, #628/#697) ou ''sip'' (Asterisk/AudioSocket, #677). Todo o resto do schema é compartilhado.'; comment on column public.voice_calls.status is 'Vocabulário do provider ''wacalls'' (binário WaCalls upstream) reaproveitado por ''sip'': ringing=tocando, connected=atendida, ended=terminal (granularidade extra em end_reason). Ver mapeamento no worker (lib/voip).'; -- ─── 3. migra dados de crm_calls (se existir) e derruba a tabela antiga ──── do $$ begin if exists (select 1 from information_schema.tables where table_schema = 'public' and table_name = 'crm_calls') then insert into public.voice_calls ( id, organization_id, contact_id, lead_id, provider, direction, status, end_reason, peer_phone, asterisk_channel_id, ai_agent_id, handled_by, transcript, metadata, started_at, answered_at, ended_at, duration_ms, created_by, created_at, updated_at ) select c.id, c.organization_id, c.contact_id, c.lead_id, 'sip', c.direction, case c.status when 'ringing' then 'ringing' when 'in_progress' then 'connected' else 'ended' end, case c.status when 'no_answer' then 'timeout' when 'busy' then 'busy' when 'failed' then 'failed' when 'canceled' then 'cancelled' when 'completed' then 'user_ended' else null end, case c.direction when 'outbound' then c.to_number else c.from_number end, c.asterisk_channel_id, c.ai_agent_id, c.handled_by, c.transcript, coalesce(c.metadata, '{}'::jsonb), c.started_at, c.answered_at, c.ended_at, c.duration_seconds * 1000, c.assigned_to_user_id, c.created_at, c.updated_at from public.crm_calls c on conflict (id) do nothing; drop table public.crm_calls; end if; end $$; -- ─── 4. LGPD: a transcrição entra na cascata de redação ──────────────────── -- (redefine a function inteira — mesmo padrão da 0235, que já fez isto pra -- acrescentar o bloco de voice_calls original; aqui só o UPDATE de -- voice_calls ganha `transcript = null` a mais. O corpo é a definição -- VIGENTE da main no momento da renumeração, e não a de quando este PR -- nasceu: redefinir a partir de uma cópia velha desfaria em silêncio o que -- a main consertou na cascata desde então.) CREATE OR REPLACE FUNCTION "public"."fn_lgpd_cascade_redact_contact"("p_organization_id" "uuid", "p_contact_id" "uuid", "p_request_id" "uuid") RETURNS "jsonb" LANGUAGE "plpgsql" SECURITY DEFINER SET "search_path" TO 'public' AS $$ declare v_already bool; v_counts jsonb := '{}'::jsonb; v_media_paths text[] := '{}'; v_anon_label text; v_count int; begin perform public.fn_service_lock(p_organization_id,p_contact_id); select is_anonymized into v_already from contacts where id = p_contact_id and organization_id = p_organization_id; if not found then raise exception 'contact not found' using errcode = 'P0002'; end if; if v_already then return jsonb_build_object('already_anonymized', true, 'counts', v_counts, 'media_paths', v_media_paths); end if; v_anon_label := 'Cliente Anonimizado #' || substring(p_contact_id::text from 1 for 8); -- Collect media storage paths (we only delete what we own — media_storage_path) select coalesce(array_agg(distinct media_storage_path) filter (where media_storage_path is not null), '{}') into v_media_paths from messages where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ); -- 1. contacts (irreversible) update contacts set name = v_anon_label, display_name = v_anon_label, email = null, -- email_normalized NÃO entra: é GENERATED ALWAYS AS (lower(trim(email))) -- e o Postgres recusa escrita nela — a linha acima já a zera por derivação. -- Com a atribuição, o cascade INTEIRO abortava e nada era anonimizado. phone_number = null, cpf_encrypted = null, cpf_hash = null, birthdate = null, is_anonymized = true, anonymized_at = now(), consent = '{}'::jsonb, source_metadata = '{}'::jsonb, tags = '{}'::text[], updated_at = now() where id = p_contact_id and organization_id = p_organization_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('contacts', v_count); -- 2. conversations metadata + preview strip update conversations set metadata = '{}'::jsonb, last_message_preview = null, -- O motivo CRU da última passagem (migration 0291). É código de -- vocabulário, não texto livre — mas ele diz que ESTA pessoa foi escalada -- por irritação, por assunto jurídico ou por suspeita de opt-out, e isso é -- um fato sobre ela. Entra NESTE update, e não num segundo: mesmo -- predicado, mesmas linhas, metade das varreduras. -- -- ⚠️ `last_handoff_reason` é CHAVE DE NEGÓCIO em outro módulo: a ponte de -- voz limpa o silêncio filtrando pelo VALOR da coluna -- (`lib/wacalls/events-bridge.ts`). Zerá-la num contato anonimizado é -- seguro — não há chamada viva de contato anonimizado — e é a razão de -- esta entrega NÃO usar essa coluna para texto rico: ela continua -- recebendo só o código, e o texto vive em `passagens_de_atendimento`. last_handoff_reason = null, updated_at = now() where contact_id = p_contact_id and organization_id = p_organization_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('conversations', v_count); -- 3. messages: redact body + null media + strip metadata (preserve status/timestamps/conversation_id) update messages set body = '[mensagem anonimizada]', media_url = null, media_mime = null, media_size_bytes = null, media_storage_path = null, metadata = '{}'::jsonb, updated_at = now() where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('messages', v_count); -- 4. crm_lead_activities — strip payload, metadata E reason (migration 0071). -- `reason` é texto livre escrito por LLM sobre a conversa do lead: supor que -- nunca conterá um nome é a suposição que falha. `evidence` NÃO é limpa — -- guarda só ids, e as linhas apontadas são redigidas por conta própria. update crm_lead_activities set payload = '{}'::jsonb, metadata = '{}'::jsonb, reason = null where organization_id = p_organization_id and ( contact_id = p_contact_id or lead_id in ( select lead_id from crm_lead_links where target_kind = 'contact' and target_id = p_contact_id and organization_id = p_organization_id ) or lead_id in ( select id from crm_leads where contact_id = p_contact_id and organization_id = p_organization_id ) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('activities', v_count); -- 5. crm_leads — strip title/description/custom_fields/source_metadata/tags but PRESERVE pipeline/stage/value update crm_leads set title = v_anon_label, description = null, custom_fields = '{}'::jsonb, source_metadata = '{}'::jsonb, tags = '{}'::text[], updated_at = now() where organization_id = p_organization_id and ( contact_id = p_contact_id or id in ( select lead_id from crm_lead_links where target_kind = 'contact' and target_id = p_contact_id and organization_id = p_organization_id ) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('leads', v_count); -- 6. orders — PRESERVE values + status + timestamps. Strip personal fields from payload jsonb -- and replace customer_external_id with null (FK-safe; soft de-link). Keep contact_id null. update orders set payload = (coalesce(payload, '{}'::jsonb)) - 'customer' - 'customer_name' - 'customer_email' - 'customer_phone' - 'shipping_address' - 'billing_address' - 'contact_identification', customer_external_id = null, contact_id = null, is_anonymized = true, updated_at = now() where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('orders', v_count); -- 7. enqueue media for async deletion (idempotent via unique (bucket, object_path)) if array_length(v_media_paths, 1) > 0 then insert into storage_redaction_queue (organization_id, request_id, bucket, object_path) select p_organization_id, p_request_id, 'whatsapp-media', path from unnest(v_media_paths) as path where path is not null and length(path) > 0 on conflict (bucket, object_path) do nothing; end if; -- 7b. voice_calls — o TELEFONE de quem falou ao telefone (migration 0235). -- -- `peer_phone` é `not null` e guarda o número da outra ponta: depois de -- anonimizar o contato, ele sobrevivia ligado ao `contact_id` e reidentificava -- a pessoa que pediu para ser esquecida. É o mesmo argumento que a foto de -- perfil já tinha (ver o bloco do avatar em `lib/lgpd/redact-cascade.ts`): -- anonimizar em toda parte menos numa é não ter anonimizado. -- -- O que fica: direção, status, motivo do fim, marcas de tempo e duração. Um -- registro de "houve uma chamada de 12 minutos" sem número e sem dono não -- identifica ninguém e é o que sustenta a métrica do atendente e a fatura. -- `peer_phone` é NOT NULL, então recebe o rótulo, não `null`. update voice_calls set transcript = null, peer_phone = v_anon_label, owner_user_id = null, created_by = null, updated_at = now() where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('voice_calls', v_count); -- agent_cases — o que a IA escreveu SOBRE a pessoa quando travou (migration 0280). -- -- O caso é o texto que a equipe lê antes de decidir: `title`, `summary` e -- `blocker` saem do modelo a partir da conversa, e `context_snapshot` é o -- recorte dessa conversa que o motor mandou para ele. Nada disso é registro de -- operação — é o relato do problema de uma pessoa identificável, escrito por -- máquina. Sem este passo, anonimizar devolvia SUCESSO com o relato intacto. -- -- As três colunas de texto são `not null`: recebem rótulo e texto fixo, nunca -- `null` (a mesma razão de `voice_calls.peer_phone` logo acima). -- -- ⚠️ `updated_at` FICA FORA DO `set`, de propósito. O cobrador de caso parado -- (`app/api/v1/cron/case-stale-watcher/route.ts`) lê `updated_at` como "alguém -- da equipe encostou neste caso". A cascata não é alguém encostando: escrever -- ali faria a anonimização ADIAR a cobrança de um caso que continua parado, e -- o efeito só apareceria como um cliente esperando mais tempo. -- -- O vínculo é pela CONVERSA porque `agent_cases` não tem FK para `contacts`. update agent_cases set title = v_anon_label, summary = '[resumo anonimizado]', blocker = '[bloqueio anonimizado]', context_snapshot = '{}'::jsonb where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('agent_cases', v_count); -- agent_case_events — a linha do tempo do caso (migration 0280). -- -- `body` é o que a pessoa da equipe escreveu ao responder o caso e o que o -- agente registrou sobre o que o LEAD respondeu; `metadata` carrega o recorte -- que o motor anexou. `kind`, `actor_kind`, `human_action` e `created_at` -- FICAM: são o registro de que houve um toque humano e quando — operação, não -- dado da pessoa, e é deles que sai a métrica de atendimento. update agent_case_events set body = null, metadata = '{}'::jsonb where organization_id = p_organization_id and case_id in ( select id from agent_cases where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('agent_case_events', v_count); -- demandas — o assunto do pedido (migration 0280). -- -- `assunto` é texto livre sobre o que a pessoa pediu. O resto da linha é a -- operação da demanda (origem, estado, dono, prazo, desfecho) e fica de pé: -- apagar a linha inteira tiraria da organização a resposta a "quantos pedidos -- houve em março", que é o mesmo argumento do compromisso da agenda. -- -- FK direta (`demandas.contact_id` é `not null`), então o vínculo é o contato. update demandas set assunto = null where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('demandas', v_count); -- agent_inbox_items — o aviso que leva o texto do caso para a Central (migration 0280). -- -- O `body` do aviso de caso parado EMBUTE o título do caso -- (`app/api/v1/cron/case-stale-watcher/route.ts:128`), e o do handoff embute o -- motivo da parada (`lib/ai/handoff/orchestrator.ts:335`). Redigir o caso e -- deixar o aviso de pé seria anonimizar em toda parte menos numa — que é não -- ter anonimizado. O molde (resolver + trocar o corpo + soltar a referência) é -- o de `fn_meet_redact_contact`, que já faz isto para o aviso de compromisso. -- -- ⚠️ O VÍNCULO É POLIMÓRFICO E TEM TRÊS BRAÇOS, não dois. Medido nos -- produtores, não suposto: `handoff` nasce com `ref_kind='contact'` -- (`lib/ai/handoff/orchestrator.ts:339`) E com `ref_kind='conversation'` -- (`lib/agent-engine/agent/inbound-turn.ts:4100`); `case_stale` nasce SEMPRE -- com `ref_kind='agent_case'` (a rota do cron acima, e a política em -- `lib/ai/inbox-destino.ts:38`). Um predicado com só os dois primeiros braços -- casa ZERO avisos de caso parado — e casar zero linha não é erro: é sucesso -- com o texto intacto. -- -- Os `kind` são os MEDIDOS no CHECK vigente (`supabase/baseline.sql`, bloco -- único de `agent_inbox_items_kind_check`). `case_opened` NÃO existe, e kind -- inexistente num `in (...)` também casa zero e devolve sucesso. Para -- reconferir sem acreditar nesta prosa: -- grep -n "agent_inbox_items_kind_check check" -A40 supabase/baseline.sql update agent_inbox_items set status = 'resolved', resolved_at = now(), body = 'Contato anonimizado.', ref_id = null where organization_id = p_organization_id -- `aviso_de_caso_nao_entregue` (migration 0292) entra AQUI e não num -- passo próprio: é o mesmo predicado polimórfico, e o braço -- `ref_kind='agent_case'` já alcança o caso do titular. O corpo do aviso -- embute o título do caso, que é texto sobre a pessoa. and kind in ('handoff', 'case_stale', 'aviso_de_caso_nao_entregue') and ( (ref_kind = 'contact' and ref_id = p_contact_id) or (ref_kind = 'conversation' and ref_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id )) or (ref_kind = 'agent_case' and ref_id in ( select id from agent_cases where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ) )) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('agent_inbox_items', v_count); -- agent_case_chat_messages — a consulta interna da equipe à IA SOBRE o caso -- (migration 0281). FK DIRETA para `contacts`, então o vínculo é o titular e -- não precisa passar pela conversa. -- -- `redacted_at is null` no `where` é o que torna o passo IDEMPOTENTE: a -- varredura diária de redações incompletas roda a função de novo, e sem essa -- condição o carimbo de QUANDO se apagou seria reescrito a cada rodada. -- -- A linha NÃO é apagada, só o texto: quem abrir o caso depois continua vendo -- que a equipe perguntou N vezes, quando, e se a IA respondeu. Apagar a linha -- inteira ficaria verde num teste de "o texto sumiu" e tiraria da organização -- a resposta a "quanto a equipe deliberou sobre este caso". update agent_case_chat_messages set body = null, redacted_at = now() where organization_id = p_organization_id and contact_id = p_contact_id and redacted_at is null; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('agent_case_chat_messages', v_count); -- passagens_de_atendimento — o BRIEFING é sobre a pessoa (migration 0291). -- -- A linha guarda o que a IA concluiu sobre um atendimento de alguém -- identificável: o que ela entendeu que a pessoa quer (`title`), a narrativa -- que quem assumiu leu (`body`), as PALAVRAS LITERAIS do cliente (`notes`), o -- texto livre de quem passou (`content`) e o que a IA já tinha tentado -- (`tentativas`). Nada disso é registro de operação — é o relato do problema -- de uma pessoa, escrito por máquina, na tela de quem vai responder. -- -- `body` é `not null` e recebe o RÓTULO, não `null` — a mesma razão de -- `voice_calls.peer_phone` e de `agent_cases.title` acima: coluna obrigatória -- anulada aborta o cascade INTEIRO, e um cascade abortado não anonimiza nada. -- -- O que FICA, de propósito: `motor`, `origem`, `motivo_codigo`, -- `cliente_avisado`, `aviso_motivo_codigo`, `criado_em` e o par de -- reconhecimento. São operação — quantas passagens houve, por quê, quanto -- tempo até alguém assumir. Um passo que apagasse a linha inteira ficaria -- verde num teste de "o texto sumiu" e tiraria da organização a resposta a -- "quantos atendimentos a IA devolveu em março, e quanto tempo esperaram". -- -- O vínculo é a FK DIRETA `contact_id`: a tabela a carrega exatamente para -- este passo não precisar passar pela conversa. update passagens_de_atendimento set body = v_anon_label, title = null, notes = null, content = null, tentativas = '[]'::jsonb where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('passagens_de_atendimento', v_count); -- entregas_de_aviso_de_caso — o registro do aviso ao suporte (migration 0292). -- -- A tabela NÃO guarda o texto do aviso (só `corpo_hash`), e a única coluna -- capaz de ecoar um dado da pessoa é `erro_detalhe`: ali vai o texto CRU que -- o transporte devolveu, truncado, e um provedor que recusa um envio costuma -- devolver o destinatário dentro da mensagem de erro. -- -- O que FICA, de propósito: `status`, `erro_codigo`, `tentativas`, -- `enviado_em`, `destino`, `corpo_hash`. São operação — quantos avisos saíram, -- quantos falharam e por quê. Um passo que apagasse a linha inteira ficaria -- verde num teste de "o texto sumiu" e tiraria da organização a resposta a -- "quantos avisos não chegaram em março". `destino` é o telefone da EQUIPE, -- não do titular: anonimizar um cliente não apaga o número do plantão. -- -- ⚠️ PONTO CEGO DECLARADO: `tests/invariants/lgpd-cascata-alcanca-quem- -- guarda-pessoa.test.ts` só cobra tabela com FK para `contacts` E coluna cujo -- NOME case o padrão de PII. Esta tabela não satisfaz nenhuma das duas — o -- gate ficaria VERDE sem este passo. Ele entra porque é certo, não porque o -- gate cobra, e isto está escrito aqui para a próxima sessão não o remover -- achando que é ornamento. Quem o vigia é a catraca -- `tests/invariants/cascata-lgpd-nao-encolhe.test.ts`. -- -- O vínculo é pela CONVERSA, como o de `agent_cases`: esta tabela aponta para -- o caso, e o caso não tem FK para `contacts`. update entregas_de_aviso_de_caso set erro_detalhe = null where organization_id = p_organization_id and case_id in ( select id from agent_cases where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('entregas_de_aviso_de_caso', v_count); -- 8. dense audit row insert into api_audit_log (organization_id, action, actor_user_id, resource_type, resource_id, metadata, bypassed_rls) values ( p_organization_id, 'lgpd.redact_executed', null, 'contact', p_contact_id, jsonb_build_object( 'cascaded_to', v_counts, 'media_queued', coalesce(array_length(v_media_paths, 1), 0), 'request_id', p_request_id ), true ); return jsonb_build_object( 'already_anonymized', false, 'counts', v_counts, 'media_paths', v_media_paths ); end; $$; revoke all on function public.fn_lgpd_cascade_redact_contact(uuid,uuid,uuid) from public,anon,authenticated; grant execute on function public.fn_lgpd_cascade_redact_contact(uuid,uuid,uuid) to service_role; notify pgrst, 'reload schema'; -- ---- voip_trunk_settings (migration 0349) ---- -- 0349_voip_trunk_settings (nasceu 0257 no PR #677; renumerada) -- -- Tela de configuração de trunk SIP por organização — hoje o único trunk do -- módulo de voz (Asterisk/AudioSocket, #677) vive hardcoded em -- `asterisk/pjsip.conf`, um arquivo na VPS, fora do banco. Decisão: um trunk -- por organização, configurável numa tela (Configurações > Trunk SIP). -- -- Aplicar no Asterisk continua MANUAL por enquanto (decisão explícita) — -- esta tabela só guarda e exibe; não há reload automático de `pjsip.conf` -- nesta fase. `organization_id` é a CHAVE PRIMÁRIA (mesmo padrão de -- `org_voice_calls`/`org_guardrail_layers`): é config de UM trunk por -- organização, não uma lista. -- -- Senha cifrada com o MESMO esquema AES-256-GCM de `ai_provider_credentials` -- (`lib/crypto/aes_gcm.ts`, chave `AI_CRED_AES_KEY`) — nunca plaintext em -- disco. Só `password_last4` é exposto pela view segura -- (`voip_trunk_settings_safe`), mesmo padrão de `api_key_last4`. create table if not exists public.voip_trunk_settings ( organization_id uuid primary key references public.organizations(id) on delete cascade, host text not null, port integer not null default 5060, username text not null, password_encrypted bytea not null, password_iv bytea not null, password_tag bytea not null, password_last4 text not null, from_domain text, endpoint_name text not null, is_active boolean not null default true, updated_by uuid references auth.users(id) on delete set null, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); alter table public.voip_trunk_settings enable row level security; -- Leitura: qualquer membro da org (a tela de originar chamada precisa saber -- SE existe trunk configurado). Escrita: só admin — são credenciais de um -- provedor SIP real, o mesmo nível de sensibilidade de ai_provider_credentials. drop policy if exists voip_trunk_settings_select on public.voip_trunk_settings; create policy voip_trunk_settings_select on public.voip_trunk_settings for select using (organization_id in (select public.fn_user_org_ids())); drop policy if exists voip_trunk_settings_admin_write on public.voip_trunk_settings; create policy voip_trunk_settings_admin_write on public.voip_trunk_settings for all using ( organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'admin') ) with check ( organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'admin') ); -- A anon key vai para o browser — sem isto, o GRANT ALL ON TABLES TO anon do -- baseline (que vale pra toda tabela nova) deixaria a tabela alcançável sem -- sessão nenhuma, RLS ou não. revoke all on public.voip_trunk_settings from anon; drop trigger if exists trg_voip_trunk_settings_set_updated_at on public.voip_trunk_settings; create trigger trg_voip_trunk_settings_set_updated_at before update on public.voip_trunk_settings for each row execute function public.fn_set_updated_at(); -- View segura: NUNCA expõe password_encrypted/iv/tag — mesmo padrão de -- ai_provider_credentials_safe. security_invoker=true: a view roda com o -- privilégio de quem CONSULTA, então a RLS da tabela base (acima) se aplica -- através dela também — sem isto, uma view SECURITY DEFINER furaria o RLS. create or replace view public.voip_trunk_settings_safe with (security_invoker = true) as select organization_id, host, port, username, password_last4, from_domain, endpoint_name, is_active, updated_by, created_at, updated_at from public.voip_trunk_settings; revoke all on public.voip_trunk_settings_safe from anon; grant select on public.voip_trunk_settings_safe to authenticated; notify pgrst, 'reload schema'; -- ---- módulo instalado: instalar e reaplicar, D3 e D6 da ADR-0002 (migration 0340) ---- -- -- Cópia literal da migration, com duas diferenças: as duas chamadas do fim moram -- no rodapé do arquivo, depois de toda tabela; e a CHECK de `kind` ampliada vive no -- bloco único dela (0271). Entra ANTES da VARREDURA anon porque cria função. -- 0340 — Módulo instalado: D3 e D6 da ADR-0002 (onda 2, issue #1114) -- -- Empilhada sobre a 0325 (#1178, onda 1): chama as provisionadoras, que terminam em -- `fn_proteger_modulo_provisionado()`. Por isso vem DEPOIS dela no timestamp — posição de -- migration aqui é semântica, não cosmética. -- -- D3 — instalar um módulo na INSTÂNCIA cria as tabelas dele na hora. O corte é por instalação, -- não por organização (decisão do dono, aceite da ADR-0002). -- D6 — reaplicar nas atualizações é explícito e falha alto. -- -- O QUE NÃO ESTÁ AQUI: a provisionadora de um módulo concreto. Nenhum módulo com tabelas está na -- main; o primeiro (financeiro/comanda) escreve o próprio corpo. Até lá, a lista de módulos -- instaláveis é VAZIA em todo banco de cliente, e o mecanismo é provado por um módulo de teste -- que só existe na bateria de invariantes. -- -- Desenho completo: docs/specs/modulo-instalado-onda-2.md. -- ── 1. O registro da instância ─────────────────────────────────────────────── create table if not exists public.modulos_instalados ( modulo text primary key check (modulo ~ '^[a-z][a-z0-9_]{1,40}$'), estado text not null default 'ativo' check (estado in ('ativo', 'suspenso')), instalado_em timestamptz not null default now(), instalado_por uuid references auth.users(id) on delete set null, reaplicado_em timestamptz, motivo_suspensao text ); comment on table public.modulos_instalados is 'Módulos opcionais instalados NA INSTÂNCIA (ADR-0002, D3). Sem organization_id: o corte é por instalação. Escrito só por fn_modulo_instalar e fn_reaplicar_modulos_instalados.'; alter table public.modulos_instalados enable row level security; revoke all on public.modulos_instalados from anon, authenticated; -- ── 2. O recibo mora no mesmo livro das extensões ──────────────────────────── -- Um tipo novo, `module_install`, em vez de um segundo livro: a instalação passa "pelo mesmo -- caminho já provado das extensões" (ADR-0002, D3) — chave idempotente, `applied_now`, e a tela -- de recibos que já existe. É recibo de PLATAFORMA (organization_id nulo), o que a restrição de -- escopo já aceita sem mudança. -- A lista ampliada com `module_install` NÃO é recriada aqui: ela vive no bloco único da -- constraint, no apêndice da migration 0271 — uma constraint, um bloco -- (tests/unit/baseline-constraint-reconstruida.test.ts). A migration 0340 faz o drop + add. -- ── 3. A porta de instalação ───────────────────────────────────────────────── create or replace function public.fn_modulo_instalar(p_actor uuid, p_operation uuid, p_modulo text) returns jsonb language plpgsql security definer set search_path = public, pg_temp as $$ declare v_request jsonb := jsonb_build_object('kind', 'module_install', 'actor', p_actor, 'modulo', p_modulo); v_op public.extension_operations; begin perform public.fn_extensions_assert_actor(p_actor); if p_operation is null or p_modulo is null or p_modulo !~ '^[a-z][a-z0-9_]{1,40}$' then raise exception using errcode = 'P0001', message = 'extension_invalid_input'; end if; -- A mesma trava das extensões: serializa com instalar/atualizar pacote E com a atualização do -- núcleo. O ator é conferido de novo depois dela, porque a autoridade pode ter mudado na espera. perform pg_advisory_xact_lock(255, 1); perform public.fn_extensions_assert_actor(p_actor); select * into v_op from public.extension_operations where id = p_operation; if found then if v_op.request_fingerprint <> public.fn_extensions_fingerprint(v_request) then raise exception using errcode = 'P0001', message = 'extension_idempotency_conflict'; end if; return to_jsonb(v_op) || jsonb_build_object('applied_now', false); end if; -- A lista de módulos instaláveis é o conjunto de provisionadoras que EXISTEM. Não há segunda -- lista para divergir: um módulo oficial entra pela tripla migration + baseline + MANIFEST -- trazendo `fn__provisionar()`, e o invariante da onda 1 prova a forma dela. if to_regprocedure(format('public.fn_%s_provisionar()', p_modulo)) is null then raise exception using errcode = 'P0001', message = 'extension_module_unknown'; end if; if public.fn_extensions_core_update_in_progress() then raise exception using errcode = 'P0001', message = 'extension_core_update_in_progress'; end if; -- O nome só chega aqui depois de passar pelo slug e pela existência da função; `%I` o cita. execute format('select public.%I()', 'fn_' || p_modulo || '_provisionar'); insert into public.modulos_instalados (modulo, estado, instalado_por, reaplicado_em) values (p_modulo, 'ativo', p_actor, now()) on conflict (modulo) do update set estado = 'ativo', motivo_suspensao = null, reaplicado_em = now(); insert into public.extension_operations (id, kind, status, actor_id, name, request_fingerprint, request, result) values (p_operation, 'module_install', 'completed', p_actor, p_modulo, public.fn_extensions_fingerprint(v_request), v_request, jsonb_build_object('modulo', p_modulo)) returning * into v_op; -- Tabela criada em tempo de execução é INVISÍVEL para a API até o PostgREST recarregar o -- schema. Sem isto, "instalar e usar, sem espera" (condição 3 do dono) seria falso: o módulo -- estaria instalado e o app receberia 404 ao consultá-lo. A notificação sai no commit. perform pg_notify('pgrst', 'reload schema'); return to_jsonb(v_op) || jsonb_build_object('applied_now', true); end $$; revoke execute on function public.fn_modulo_instalar(uuid, uuid, text) from public, anon; revoke execute on function public.fn_modulo_instalar(uuid, uuid, text) from authenticated; grant execute on function public.fn_modulo_instalar(uuid, uuid, text) to service_role; -- ── 4. A reaplicação nas atualizações (D6) — dois comandos, de propósito ───── -- O kit aplica o baseline SEM transação única (`psql -f`) e trata como falha toda linha ERROR -- que não case com a lista de benignos (`already exists` e afins, em _common.sh). Então: -- A) captura a falha de cada módulo e o marca `suspenso` SEM relançar — o comando se confirma -- sozinho e a marca PERSISTE; -- B) se há módulo suspenso, levanta um ERROR com texto próprio, que o update.sh já reporta. -- Num comando só, relançar desfaria a marca; não relançar deixaria o kit dizer "atualizado". create or replace function public.fn_reaplicar_modulos_instalados() returns void language plpgsql set search_path = public, pg_temp as $$ declare r record; begin for r in select modulo from public.modulos_instalados order by modulo loop begin if to_regprocedure(format('public.fn_%s_provisionar()', r.modulo)) is null then raise exception 'a provisionadora de % não existe nesta versão', r.modulo; end if; execute format('select public.%I()', 'fn_' || r.modulo || '_provisionar'); update public.modulos_instalados set estado = 'ativo', motivo_suspensao = null, reaplicado_em = now() where modulo = r.modulo; exception -- Disputa de trava com o app no ar NÃO é defeito do módulo: relançar desfaz esta -- passada inteira (nenhum módulo é marcado), e o texto do Postgres — "deadlock -- detected", "could not obtain lock" — é o que o kit reconhece como disputa e o faz -- aplicar de novo. Suspender aqui tiraria do ar um módulo que só precisava esperar. when deadlock_detected or serialization_failure or lock_not_available then raise; when others then update public.modulos_instalados set estado = 'suspenso', motivo_suspensao = sqlerrm where modulo = r.modulo; end; end loop; perform pg_notify('pgrst', 'reload schema'); end $$; create or replace function public.fn_conferir_modulos_instalados() returns void language plpgsql set search_path = public, pg_temp as $$ declare v_suspensos text; begin select string_agg(modulo, ', ' order by modulo) into v_suspensos from public.modulos_instalados where estado = 'suspenso'; -- A mensagem NÃO repete o erro original, e isso é o ponto: se a provisionadora falhou com -- "already exists" e o texto viesse junto, a linha casaria com a lista de erros benignos do -- kit e seria ENGOLIDA — exatamente o silêncio que esta função existe para impedir. O motivo -- fica em modulos_instalados.motivo_suspensao; o aviso só nomeia o módulo. if v_suspensos is not null then raise exception 'modulo suspenso na atualizacao: % — o motivo esta em modulos_instalados.motivo_suspensao', v_suspensos; end if; end $$; revoke execute on function public.fn_reaplicar_modulos_instalados() from public, anon, authenticated, service_role; revoke execute on function public.fn_conferir_modulos_instalados() from public, anon, authenticated, service_role; -- ---- a agenda dos colegas é uma opção da organização (migration 0343) ---- -- -- A opção "Atendentes podem mexer na agenda dos colegas" (issue #978), LIGADA -- por padrão: `settings.colegas_podem_mexer_na_agenda` ausente = ligada, e só o -- booleano `false` explícito desliga. Com ela desligada, o Atendente só mexe no -- compromisso de que é dono; Gerente e Administrador seguem mexendo em tudo. -- -- Chave PRÓPRIA de topo, e não `settings.agenda`: `fn_agenda_settings` substitui -- o objeto inteiro e recusa chave que não conheça (o mesmo motivo que levou -- `cliente_pela_agenda` para `settings.crm` na 0262). Sem backfill: nenhuma -- linha de `organizations` é reescrita e quem já instalou não vê mudança. -- -- O núcleo abaixo é a definição em vigor com UM bloco novo (a checagem de dono). -- A explicação completa, e para quem a regra vale (pessoa / IA e integração / -- canal remoto), está no cabeçalho de -- `supabase/migrations/20260919160431_0343_agenda_dos_colegas.sql`. create or replace function public.fn_colegas_podem_mexer_na_agenda(p_org uuid) returns boolean language sql stable security definer set search_path=public as $$ select coalesce( (select (o.settings->'colegas_podem_mexer_na_agenda') is distinct from 'false'::jsonb from public.organizations o where o.id = p_org), true); $$; revoke all on function public.fn_colegas_podem_mexer_na_agenda(uuid) from public,anon; grant execute on function public.fn_colegas_podem_mexer_na_agenda(uuid) to authenticated,service_role; comment on function public.fn_colegas_podem_mexer_na_agenda(uuid) is 'A opção "Atendentes podem mexer na agenda dos colegas" desta organização (issue #978). Ausente = ligada: só o booleano false explícito em settings.colegas_podem_mexer_na_agenda desliga.'; create or replace function public.fn_appointment_change_core(p_org uuid,p_id uuid,p_revision bigint,p_patch jsonb,p_remote boolean,p_base jsonb) returns jsonb language plpgsql security definer set search_path=public as $$ declare a public.calendar_appointments; contact uuid; origin jsonb; event_id uuid; begin if p_remote and (auth.uid() is not null or (p_patch-'starts_at'-'ends_at'-'time_zone'-'status'-'cancellation_reason')<>'{}'::jsonb or coalesce(p_patch->>'status','cancelled')<>'cancelled') then raise exception 'google_patch_forbidden' using errcode='42501';end if; if auth.uid() is not null and (not public.fn_role_at_least(p_org,'agent') or not public.fn_support_write_allowed(p_org)) then raise exception 'appointment_forbidden' using errcode='42501'; end if; if auth.uid() is not null and not public.fn_session_mfa_proven() then raise exception 'appointment_mfa_required' using errcode='42501';end if; select contact_id into contact from public.calendar_appointments where organization_id=p_org and id=p_id; if not found then raise exception 'appointment_not_found' using errcode='P0002'; end if; if contact is not null then perform public.fn_service_lock(p_org,contact); end if; select * into a from public.calendar_appointments where organization_id=p_org and id=p_id for update; if a.contact_id is distinct from contact or a.revision is distinct from p_revision then raise exception 'appointment_stale' using errcode='40001'; end if; -- A AGENDA DO COLEGA É UMA OPÇÃO DA ORGANIZAÇÃO (migration 0343, issue #978). if auth.uid() is not null and not public.fn_role_at_least(p_org,'manager') and not public.fn_colegas_podem_mexer_na_agenda(p_org) and a.owner_user_id is distinct from auth.uid() then raise exception 'appointment_do_colega' using errcode='42501'; end if; if p_remote and a.status not in ('pending','confirmed') then raise exception 'google_outcome_protected' using errcode='40001';end if; if a.status='cancelled' then raise exception 'appointment_cancelled' using errcode='22023'; end if; if contact is not null then origin:=jsonb_build_object('kind','command','observed',public.fn_service_observe_command(p_org,contact)); end if; update public.calendar_appointments set google_base_projection=case when p_remote then p_base else google_base_projection end, starts_at=case when p_patch?'starts_at' then (p_patch->>'starts_at')::timestamptz else starts_at end, ends_at=case when p_patch?'ends_at' then (p_patch->>'ends_at')::timestamptz else ends_at end, time_zone=coalesce(p_patch->>'time_zone',time_zone), status=coalesce(p_patch->>'status',status), cancelled_at=case when p_patch->>'status'='cancelled' then now() else cancelled_at end, cancellation_reason=case when p_patch?'cancellation_reason' then p_patch->>'cancellation_reason' else cancellation_reason end, notes=case when p_patch?'notes' then p_patch->>'notes' else notes end, guest_email=case when p_patch?'guest_email' then p_patch->>'guest_email' else guest_email end, outcome_message_id=case when p_patch?'outcome_message_id' then (p_patch->>'outcome_message_id')::uuid else null end, confirmation_next_at=case when p_patch?'confirmation_next_at' then (p_patch->>'confirmation_next_at')::timestamptz else confirmation_next_at end where organization_id=p_org and id=p_id returning * into a; if p_patch?'confirmation_next_at' and (a.confirmation_next_at<=now() or a.confirmation_next_at>now()+interval '24 hours') then raise exception 'appointment_invalid_snooze' using errcode='22023'; end if; update public.followup_enrollments set status='cancelled',cancel_reason='O compromisso mudou. Revise o próximo passo.',completed_at=now(),next_eval_at=null,claimed_until=null where organization_id=p_org and appointment_id=p_id and appointment_revision<>a.revision and status in ('active','waiting_reply','paused_handoff','paused_manual'); update public.agent_inbox_items set status='resolved',resolved_at=now() where organization_id=p_org and ref_kind='appointment' and ref_id=p_id and status='open' and (appointment_revision<>a.revision or a.status in ('completed','no_show','cancelled') or p_patch?'confirmation_next_at'); if contact is not null and a.status='no_show' and a.outcome_recorded_at is not null and a.revision<>p_revision then insert into public.event_log(organization_id,event_type,entity_kind,entity_id,payload) values(p_org,'appointment.outcome_confirmed','appointment',p_id, jsonb_build_object('appointment_revision',a.revision,'service_origin',origin)) returning id into event_id; end if; return to_jsonb(a); end; $$; revoke all on function public.fn_appointment_change_core(uuid,uuid,bigint,jsonb,boolean,jsonb) from public,anon,authenticated; create or replace function public.fn_definir_colegas_podem_mexer_na_agenda(p_org uuid,p_ligado boolean) returns jsonb language plpgsql security definer set search_path=public as $$ declare v_atual boolean; v_linhas int; begin if p_ligado is null then raise exception 'agenda_dos_colegas_invalido' using errcode='22023'; end if; if auth.uid() is null or not public.fn_role_at_least(p_org,'manager') or not public.fn_support_write_allowed(p_org) then raise exception 'agenda_dos_colegas_forbidden' using errcode='42501'; end if; if not public.fn_session_mfa_proven() then raise exception 'mfa_required' using errcode='42501'; end if; v_atual := public.fn_colegas_podem_mexer_na_agenda(p_org); if v_atual is not distinct from p_ligado then return jsonb_build_object('ligado',v_atual,'mudou',false); end if; update public.organizations set settings = coalesce(settings,'{}'::jsonb) || jsonb_build_object('colegas_podem_mexer_na_agenda',to_jsonb(p_ligado)) where id = p_org; get diagnostics v_linhas = row_count; if v_linhas = 0 then raise exception 'agenda_dos_colegas_sem_organizacao' using errcode='P0002'; end if; return jsonb_build_object('ligado',p_ligado,'mudou',true); end; $$; revoke all on function public.fn_definir_colegas_podem_mexer_na_agenda(uuid,boolean) from public,anon; grant execute on function public.fn_definir_colegas_podem_mexer_na_agenda(uuid,boolean) to authenticated,service_role; comment on function public.fn_definir_colegas_podem_mexer_na_agenda(uuid,boolean) is 'Liga/desliga "Atendentes podem mexer na agenda dos colegas" (issue #978). Gerente ou acima, suporte de escrita e MFA comprovado; ela mesma confere pelo auth.uid(). Grava settings.colegas_podem_mexer_na_agenda e devolve {ligado,mudou}.'; notify pgrst, 'reload schema'; -- ---- catálogo financeiro: contas, formas de pagamento, plano de contas (migration 0350) ---- -- O CATÁLOGO FINANCEIRO — a primeira camada do módulo de comanda/financeiro. -- -- Três tabelas que não guardam dinheiro, só definem PARA ONDE ele vai: -- -- financial_accounts onde o dinheiro fica (Caixa, Banco) -- payment_methods como o cliente paga — e cada forma APONTA para a conta -- em que aquele dinheiro cai -- account_plans a classificação contábil do lançamento -- -- A ordem importa: a forma de pagamento é quem decide em qual conta a entrada -- é lançada quando uma comanda é finalizada. Sem esta camada, a comanda não tem -- onde depositar, e é por isso que ela vem primeiro. -- -- ⚠️ NADA AQUI TEM SALDO GRAVADO. `opening_balance_cents` é o saldo INICIAL — -- o ponto de partida declarado por quem cadastrou a conta, que não muda com -- lançamento nenhum. O saldo corrente é sempre DERIVADO por soma, e essa é uma -- das invariantes do modelo: saldo gravado e lançamentos divergem no primeiro -- estorno, e a divergência não dá sinal. -- -- ⚠️ DINHEIRO EM `_cents` + `currency`, como manda o CLAUDE.md. Nunca `numeric` -- solto: arredondamento de ponto flutuante em dinheiro é defeito que aparece -- meses depois, num relatório que não fecha por centavos. -- ─── onde o dinheiro fica ──────────────────────────────────────────────────── create table if not exists public.financial_accounts ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, name text not null, -- `text` + CHECK e não enum: enum é difícil de estender, e a lista de tipos de -- conta cresce com o negócio (carteira digital, aplicação, adquirente). kind text not null default 'cash' check (kind in ('cash', 'bank', 'other')), opening_balance_cents bigint not null default 0, currency text not null default 'BRL' check (char_length(currency) = 3), -- Inativa-se, não se apaga: conta com lançamento é história, e apagá-la -- deixaria o lançamento órfão ou o levaria junto. is_active boolean not null default true, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create unique index if not exists financial_accounts_org_nome_key on public.financial_accounts (organization_id, lower(name)) where is_active; create index if not exists financial_accounts_org_idx on public.financial_accounts (organization_id, is_active); -- ─── como o cliente paga ───────────────────────────────────────────────────── create table if not exists public.payment_methods ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, name text not null, -- ⚠️ `on delete restrict`, e é a decisão desta migration: a forma de pagamento -- é quem diz em que conta o dinheiro cai. Apagar a conta em cascata deixaria -- formas apontando para o nada e lançamentos futuros sem destino — em -- silêncio. `restrict` obriga a inativar a conta, que é o caminho certo. account_id uuid references public.financial_accounts(id) on delete restrict, is_active boolean not null default true, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create unique index if not exists payment_methods_org_nome_key on public.payment_methods (organization_id, lower(name)) where is_active; create index if not exists payment_methods_org_idx on public.payment_methods (organization_id, is_active); -- ─── a classificação do lançamento ─────────────────────────────────────────── create table if not exists public.account_plans ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, name text not null, -- Entrada ou saída. O sistema de origem tinha TODAS as 17 linhas como -- 'debito', inclusive "Serviços" e "Comissão", que são coisas opostas — um -- campo que existe e não distingue nada. Aqui ele distingue, e o CHECK -- garante que continue distinguindo. direction text not null check (direction in ('in', 'out')), is_active boolean not null default true, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create unique index if not exists account_plans_org_nome_key on public.account_plans (organization_id, lower(name)) where is_active; create index if not exists account_plans_org_idx on public.account_plans (organization_id, is_active, direction); -- ─── RLS: as três são tenant-aware e seguem o helper da casa ───────────────── -- -- Leitura para quem é da organização; escrita para manager+. Dinheiro não é -- coisa que `agent` configure — quem atende não define plano de contas. do $$ declare t text; begin foreach t in array array['financial_accounts', 'payment_methods', 'account_plans'] loop execute format('alter table public.%I enable row level security', t); execute format('drop policy if exists tenant_isolation_%I_all on public.%I', t, t); execute format($f$ create policy tenant_isolation_%I_all on public.%I for all using (organization_id in (select public.fn_user_org_ids()) or public.fn_is_platform_admin()) with check ( public.fn_is_platform_admin() or (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'manager')) ) $f$, t, t); execute format('revoke all on public.%I from anon', t); end loop; end $$; -- `updated_at` pelo mesmo trigger que o resto da base usa, se ele existir nesta -- instalação. `if exists` porque o baseline de um clone antigo pode não tê-lo, e -- uma migration que falha por causa de carimbo de data é migration que trava -- atualização por nada. do $$ declare t text; begin if exists (select 1 from pg_proc where proname = 'fn_touch_updated_at') then foreach t in array array['financial_accounts', 'payment_methods', 'account_plans'] loop execute format('drop trigger if exists trg_%I_touch on public.%I', t, t); execute format( 'create trigger trg_%I_touch before update on public.%I for each row execute function public.fn_touch_updated_at()', t, t); end loop; end if; end $$; comment on table public.financial_accounts is 'Onde o dinheiro fica. `opening_balance_cents` é o saldo INICIAL declarado; o saldo corrente é sempre derivado por soma dos lançamentos, nunca gravado.'; comment on table public.payment_methods is 'Como o cliente paga. `account_id` decide em qual conta a entrada cai quando a comanda é finalizada.'; comment on table public.account_plans is 'Classificação do lançamento, com direção (in/out) que o sistema de origem tinha e não usava.'; -- ---- comanda, financeiro, comissão e fidelidade (migration 0351) ---- -- A COMANDA E O QUE ELA MOVE — segunda e última camada do módulo financeiro. -- -- Cinco tabelas e uma função. A função é o ponto: finalizar uma comanda faz -- SEIS coisas numa única transação — marca a venda, gera comissão por item, -- lança a entrada na conta que a forma de pagamento determina, dá o ponto de -- fidelidade e conclui o agendamento. Não são módulos vizinhos; é o corpo da -- mesma transação, e é por isso que nascem juntos. -- -- ═══ OS INVARIANTES, E POR QUE CADA UM ═══ -- -- 1. NADA É APAGADO. Comanda cancela, conta inativa, item sai por cancelamento -- da comanda. `delete` em linha de dinheiro é reescrever o passado. -- 2. SALDO É SEMPRE DERIVADO. Não existe coluna de saldo em lugar nenhum — -- nem na conta, nem no cliente. Saldo gravado e lançamentos divergem no -- primeiro estorno, e a divergência não dá sinal. -- 3. ESTORNO É CONTRA-LANÇAMENTO, nunca exclusão. Duas linhas que se somam a -- zero contam a história; uma linha apagada não conta nada. -- 4. A COMISSÃO É RESOLVIDA NA INCLUSÃO DO ITEM e gravada na linha. A -- finalização NÃO recalcula: mudar a regra de comissão amanhã não pode -- mexer no que já foi combinado ontem. -- 5. A NUMERAÇÃO NÃO REINICIA. Sequência por organização, monotônica. -- 6. LANÇAMENTO PAGO É IMUTÁVEL. Trigger recusa UPDATE que mexa em valor, -- conta ou data depois de `paid_at`. -- ─── a comanda ─────────────────────────────────────────────────────────────── create table if not exists public.sales ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, -- Número visível, por organização. `bigint` e não `serial`: a sequência é -- própria de cada tenant (ver `fn_proximo_numero_de_comanda`), e um serial -- global vazaria o volume de um cliente para outro. number bigint not null, contact_id uuid references public.contacts(id) on delete set null, -- Quem atendeu. `set null` porque a pessoa pode sair da equipe e a venda -- continua tendo acontecido. attendant_user_id uuid references auth.users(id) on delete set null, appointment_id uuid references public.calendar_appointments(id) on delete set null, status text not null default 'open' check (status in ('open', 'finalized', 'cancelled')), -- Desconto da COMANDA, separado do desconto de item. Fidelidade e comissão -- incidem sobre o item, nunca sobre este — senão um desconto de caixa -- reduziria o prêmio de quem atendeu. discount_cents bigint not null default 0 check (discount_cents >= 0), total_cents bigint not null default 0, currency text not null default 'BRL' check (char_length(currency) = 3), payment_method_id uuid references public.payment_methods(id) on delete restrict, notes text, finalized_at timestamptz, cancelled_at timestamptz, cancel_reason text, -- Estornada: a comanda continua finalizada e ganha o contra-lançamento. reversed_at timestamptz, reverse_reason text, created_by_user_id uuid references auth.users(id) on delete set null, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), -- Finalizar exige forma de pagamento: é ela que diz em que conta o dinheiro -- cai. Sem isso, a entrada não teria destino — e o CHECK diz isso no schema, -- não numa validação que alguém pode esquecer de chamar. constraint sales_finalizada_tem_forma check (status <> 'finalized' or payment_method_id is not null) ); create unique index if not exists sales_org_numero_key on public.sales (organization_id, number); create index if not exists sales_org_status_idx on public.sales (organization_id, status, created_at desc); create index if not exists sales_org_contato_idx on public.sales (organization_id, contact_id); create index if not exists sales_appointment_idx on public.sales (appointment_id) where appointment_id is not null; -- ─── o item ────────────────────────────────────────────────────────────────── create table if not exists public.sale_items ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, -- `cascade` aqui e só aqui: item não existe fora da comanda, e comanda não é -- apagada (cancela). O cascade só dispara se a ORGANIZAÇÃO inteira sair. sale_id uuid not null references public.sales(id) on delete cascade, -- O que foi feito. `event_type_id` porque, neste produto, o catálogo de -- serviços JÁ é `calendar_event_types` — criar uma tabela de serviços ao lado -- seria a segunda fonte da mesma verdade. event_type_id uuid references public.calendar_event_types(id) on delete restrict, -- Congelado na inclusão: o nome muda, a linha da venda não. description text not null, attendant_user_id uuid references auth.users(id) on delete set null, quantity integer not null default 1 check (quantity > 0), unit_price_cents bigint not null check (unit_price_cents >= 0), discount_cents bigint not null default 0 check (discount_cents >= 0), total_cents bigint not null, -- ⚠️ RESOLVIDA NA INCLUSÃO e gravada aqui. A finalização não recalcula: -- mudar a regra amanhã não mexe no que já foi combinado ontem. commission_percent numeric(5, 2) not null default 0 check (commission_percent >= 0 and commission_percent <= 100), created_at timestamptz not null default now() ); create index if not exists sale_items_sale_idx on public.sale_items (sale_id); create index if not exists sale_items_org_idx on public.sale_items (organization_id, created_at desc); -- ─── a regra de comissão ───────────────────────────────────────────────────── -- -- Precedência: (pessoa + serviço) → (pessoa) → (serviço). A mais específica -- vence, e é por isso que as três colunas são nullable com um índice único por -- combinação — não há linha "curinga" mágica, há ausência. create table if not exists public.commission_rules ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, attendant_user_id uuid references auth.users(id) on delete cascade, event_type_id uuid references public.calendar_event_types(id) on delete cascade, percent numeric(5, 2) not null check (percent >= 0 and percent <= 100), created_at timestamptz not null default now(), -- Pelo menos um dos dois: uma regra sem pessoa E sem serviço seria a regra -- "de tudo", que é o default da organização e mora em outro lugar. constraint commission_rules_tem_alvo check (attendant_user_id is not null or event_type_id is not null) ); -- `coalesce` no índice: NULL não colide com NULL numa UNIQUE, e sem isto duas -- regras "só para a Ana" passariam as duas, em silêncio. create unique index if not exists commission_rules_alvo_key on public.commission_rules ( organization_id, coalesce(attendant_user_id, '00000000-0000-0000-0000-000000000000'::uuid), coalesce(event_type_id, '00000000-0000-0000-0000-000000000000'::uuid) ); -- ─── a comissão gerada ─────────────────────────────────────────────────────── create table if not exists public.commissions ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, sale_item_id uuid not null references public.sale_items(id) on delete cascade, attendant_user_id uuid not null references auth.users(id) on delete restrict, percent numeric(5, 2) not null, amount_cents bigint not null, status text not null default 'pending' check (status in ('pending', 'paid', 'reversed')), paid_at timestamptz, reversed_at timestamptz, created_at timestamptz not null default now() ); create unique index if not exists commissions_item_key on public.commissions (sale_item_id); create index if not exists commissions_org_pessoa_idx on public.commissions (organization_id, attendant_user_id, status); -- ─── o lançamento financeiro ───────────────────────────────────────────────── create table if not exists public.financial_entries ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, account_id uuid not null references public.financial_accounts(id) on delete restrict, account_plan_id uuid references public.account_plans(id) on delete restrict, sale_id uuid references public.sales(id) on delete set null, direction text not null check (direction in ('in', 'out')), -- SEMPRE positivo; quem dá o sinal é `direction`. Valor negativo com direção -- é duas formas de dizer a mesma coisa, e elas divergem. amount_cents bigint not null check (amount_cents > 0), currency text not null default 'BRL' check (char_length(currency) = 3), description text, entry_date date not null default current_date, status text not null default 'pending' check (status in ('pending', 'paid')), paid_at timestamptz, -- O contra-lançamento aponta para o que ele estorna. Duas linhas que se somam -- a zero, e a ligação entre elas explícita. reverses_entry_id uuid references public.financial_entries(id) on delete restrict, origin text not null default 'manual' check (origin in ('manual', 'sale', 'reversal', 'recurring')), created_by_user_id uuid references auth.users(id) on delete set null, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create index if not exists financial_entries_org_data_idx on public.financial_entries (organization_id, entry_date desc); create index if not exists financial_entries_conta_idx on public.financial_entries (organization_id, account_id, status); create index if not exists financial_entries_sale_idx on public.financial_entries (sale_id) where sale_id is not null; -- ─── o livro-razão da fidelidade ───────────────────────────────────────────── -- -- LEDGER, não saldo. O saldo do cliente é `sum(points)` e nunca uma coluna: -- guardar o saldo faria o primeiro estorno divergir em silêncio. create table if not exists public.loyalty_ledger ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, contact_id uuid not null references public.contacts(id) on delete cascade, -- Assinado: ganhar é positivo, resgatar é negativo. Uma coluna de "tipo" ao -- lado seria a segunda forma de dizer o mesmo sinal. points integer not null, reason text not null, sale_id uuid references public.sales(id) on delete set null, sale_item_id uuid references public.sale_items(id) on delete set null, -- Idempotência do ganho: finalizar a mesma comanda duas vezes não dá ponto -- em dobro. A UNIQUE parcial é a garantia, não a boa intenção de quem chama. idempotency_key text, created_by_user_id uuid references auth.users(id) on delete set null, created_at timestamptz not null default now() ); create unique index if not exists loyalty_ledger_idem_key on public.loyalty_ledger (organization_id, idempotency_key) where idempotency_key is not null; create index if not exists loyalty_ledger_contato_idx on public.loyalty_ledger (organization_id, contact_id, created_at desc); -- ─── lançamento pago é imutável ────────────────────────────────────────────── create or replace function public.fn_lancamento_pago_e_imutavel() returns trigger language plpgsql as $$ begin if old.paid_at is not null and ( new.amount_cents is distinct from old.amount_cents or new.account_id is distinct from old.account_id or new.direction is distinct from old.direction or new.entry_date is distinct from old.entry_date ) then -- Não é capricho: um lançamento pago já foi conciliado com extrato. Mudá-lo -- faz o relatório de ontem contar outra história hoje, sem deixar rastro. -- O caminho certo é o contra-lançamento. raise exception 'lancamento_pago_imutavel' using errcode = '42501', hint = 'Um lançamento já pago não muda de valor, conta, direção ou data. Estorne com um contra-lançamento.'; end if; return new; end $$; drop trigger if exists trg_financial_entries_imutavel on public.financial_entries; create trigger trg_financial_entries_imutavel before update on public.financial_entries for each row execute function public.fn_lancamento_pago_e_imutavel(); -- ─── a numeração que não reinicia ──────────────────────────────────────────── -- ⚠️ `security invoker` (o default), e NÃO definer, de propósito. Ela só LÊ -- `public.sales`, e a RLS daquela tabela já é a cerca: com a sessão de quem -- chama, o `max(number)` só enxerga a própria organização. Definer aqui -- responderia a qualquer usuário logado qual é o número da próxima comanda de -- QUALQUER organização — que é exatamente o volume de vendas do vizinho, o -- vazamento que o comentário abaixo diz querer evitar. A varredura -- `tests/invariants/definer-membership-varredura.test.ts` mede isso. create or replace function public.fn_proximo_numero_de_comanda(p_org uuid) returns bigint language sql stable set search_path = public as $$ -- `coalesce(max)+1` sob o lock da transação de quem chama. Uma sequence do -- Postgres seria global e vazaria volume entre tenants; e o buraco de uma -- sequence (números pulados no rollback) faria a numeração de uma comanda -- parecer que houve venda cancelada onde não houve. select coalesce(max(number), 0) + 1 from public.sales where organization_id = p_org; $$; revoke execute on function public.fn_proximo_numero_de_comanda(uuid) from public, anon; grant execute on function public.fn_proximo_numero_de_comanda(uuid) to authenticated, service_role; -- ─── A FINALIZAÇÃO: as seis coisas numa transação ──────────────────────────── create or replace function public.fn_finalizar_comanda( p_org uuid, p_sale uuid, p_payment_method uuid, p_loyalty_points integer default 0 ) returns jsonb language plpgsql security definer set search_path = public as $$ declare v_sale public.sales%rowtype; v_conta uuid; v_plano uuid; v_total bigint; v_item record; v_entry uuid; begin if auth.uid() is null or not public.fn_role_at_least(p_org, 'agent') then raise exception 'comanda_forbidden' using errcode = '42501'; end if; -- FOR UPDATE: duas finalizações simultâneas da mesma comanda geravam -- lançamento em dobro. O lock é o que torna esta função idempotente de fato, -- e não só na intenção. select * into v_sale from public.sales where id = p_sale and organization_id = p_org for update; if not found then raise exception 'comanda_nao_encontrada' using errcode = 'P0002'; end if; if v_sale.status = 'finalized' then -- Não é erro: quem chamou duas vezes recebe o mesmo desfecho. return jsonb_build_object('sale_id', v_sale.id, 'ja_finalizada', true); end if; if v_sale.status = 'cancelled' then raise exception 'comanda_cancelada' using errcode = '22023'; end if; select account_id into v_conta from public.payment_methods where id = p_payment_method and organization_id = p_org and is_active; if not found then raise exception 'forma_de_pagamento_invalida' using errcode = '22023'; end if; if v_conta is null then -- A forma existe e não diz para onde o dinheiro vai. Recusar aqui é melhor -- que escolher uma conta por conta própria. raise exception 'forma_sem_conta' using errcode = '22023', hint = 'Esta forma de pagamento ainda não tem conta de destino. Defina em Configurações → Financeiro.'; end if; select coalesce(sum(total_cents), 0) into v_total from public.sale_items where sale_id = p_sale; v_total := greatest(v_total - coalesce(v_sale.discount_cents, 0), 0); -- (1) a venda update public.sales set status = 'finalized', finalized_at = now(), payment_method_id = p_payment_method, total_cents = v_total where id = p_sale; -- (2) a comissão por item, com o percentual CONGELADO na inclusão for v_item in select * from public.sale_items where sale_id = p_sale and attendant_user_id is not null loop insert into public.commissions (organization_id, sale_item_id, attendant_user_id, percent, amount_cents) values ( p_org, v_item.id, v_item.attendant_user_id, v_item.commission_percent, -- Sobre o item, NUNCA sobre o desconto da comanda: um desconto de caixa -- não pode reduzir o que quem atendeu combinou. floor(v_item.total_cents * v_item.commission_percent / 100.0) ) on conflict (sale_item_id) do nothing; end loop; -- (3) a entrada na conta que a FORMA DE PAGAMENTO determina select id into v_plano from public.account_plans where organization_id = p_org and direction = 'in' and is_active order by created_at limit 1; insert into public.financial_entries (organization_id, account_id, account_plan_id, sale_id, direction, amount_cents, currency, description, status, paid_at, origin, created_by_user_id) values ( p_org, v_conta, v_plano, p_sale, 'in', greatest(v_total, 1), v_sale.currency, format('Comanda #%s', v_sale.number), 'paid', now(), 'sale', auth.uid() ) returning id into v_entry; -- (4) o ponto de fidelidade, idempotente pela chave da comanda if p_loyalty_points > 0 and v_sale.contact_id is not null then insert into public.loyalty_ledger (organization_id, contact_id, points, reason, sale_id, idempotency_key, created_by_user_id) values ( p_org, v_sale.contact_id, p_loyalty_points, 'Comanda finalizada', p_sale, format('sale:%s', p_sale), auth.uid() ) on conflict do nothing; end if; -- (5) o agendamento conclui — e SÓ se ainda estiver de pé. if v_sale.appointment_id is not null then update public.calendar_appointments set status = 'completed', outcome_recorded_at = now() where id = v_sale.appointment_id and organization_id = p_org -- A guarda que o sistema de origem não tinha em todos os caminhos: -- cancelado e faltou são desfechos DECIDIDOS, e faturar não os desfaz. and status not in ('cancelled', 'no_show'); end if; return jsonb_build_object( 'sale_id', v_sale.id, 'number', v_sale.number, 'total_cents', v_total, 'entry_id', v_entry ); end $$; revoke execute on function public.fn_finalizar_comanda(uuid, uuid, uuid, integer) from public, anon; grant execute on function public.fn_finalizar_comanda(uuid, uuid, uuid, integer) to authenticated; -- ─── O ESTORNO: contra-lançamento, nunca exclusão ──────────────────────────── create or replace function public.fn_estornar_comanda(p_org uuid, p_sale uuid, p_motivo text) returns jsonb language plpgsql security definer set search_path = public as $$ declare v_sale public.sales%rowtype; v_orig public.financial_entries%rowtype; v_novo uuid; begin if auth.uid() is null or not public.fn_role_at_least(p_org, 'manager') then raise exception 'estorno_forbidden' using errcode = '42501'; end if; select * into v_sale from public.sales where id = p_sale and organization_id = p_org for update; if not found then raise exception 'comanda_nao_encontrada' using errcode = 'P0002'; end if; if v_sale.status <> 'finalized' then raise exception 'comanda_nao_finalizada' using errcode = '22023'; end if; if v_sale.reversed_at is not null then return jsonb_build_object('sale_id', v_sale.id, 'ja_estornada', true); end if; update public.sales set reversed_at = now(), reverse_reason = p_motivo where id = p_sale; -- O contra-lançamento de cada entrada da comanda. A original NÃO é tocada: -- ela está paga e é imutável (o trigger acima recusaria). for v_orig in select * from public.financial_entries where sale_id = p_sale and organization_id = p_org and origin = 'sale' loop insert into public.financial_entries (organization_id, account_id, account_plan_id, sale_id, direction, amount_cents, currency, description, status, paid_at, origin, reverses_entry_id, created_by_user_id) values ( p_org, v_orig.account_id, v_orig.account_plan_id, p_sale, case when v_orig.direction = 'in' then 'out' else 'in' end, v_orig.amount_cents, v_orig.currency, format('Estorno da comanda #%s', v_sale.number), 'paid', now(), 'reversal', v_orig.id, auth.uid() ) returning id into v_novo; end loop; -- A comissão vira 'reversed' — não some, porque ela existiu e alguém pode já -- ter sido pago por ela. update public.commissions c set status = 'reversed', reversed_at = now() from public.sale_items i where c.sale_item_id = i.id and i.sale_id = p_sale and c.status <> 'reversed'; -- E o ponto de fidelidade volta como movimento NEGATIVO, nunca apagando o -- ganho: o livro-razão conta as duas coisas. insert into public.loyalty_ledger (organization_id, contact_id, points, reason, sale_id, idempotency_key, created_by_user_id) select p_org, v_sale.contact_id, -l.points, 'Estorno da comanda', p_sale, format('reversal:%s', p_sale), auth.uid() from public.loyalty_ledger l where l.sale_id = p_sale and l.organization_id = p_org and l.points > 0 and v_sale.contact_id is not null on conflict do nothing; return jsonb_build_object('sale_id', v_sale.id, 'estornada', true); end $$; revoke execute on function public.fn_estornar_comanda(uuid, uuid, text) from public, anon; grant execute on function public.fn_estornar_comanda(uuid, uuid, text) to authenticated; -- ─── RLS nas cinco ─────────────────────────────────────────────────────────── do $$ declare t text; begin foreach t in array array['sales', 'sale_items', 'commission_rules', 'commissions', 'financial_entries', 'loyalty_ledger'] loop execute format('alter table public.%I enable row level security', t); execute format('drop policy if exists tenant_isolation_%I_all on public.%I', t, t); execute format($f$ create policy tenant_isolation_%I_all on public.%I for all using (organization_id in (select public.fn_user_org_ids()) or public.fn_is_platform_admin()) with check ( public.fn_is_platform_admin() or (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'agent')) ) $f$, t, t); execute format('revoke all on public.%I from anon', t); end loop; end $$; comment on table public.sales is 'A comanda. Cancela, nunca apaga. `number` é sequencial por organização e não reinicia.'; comment on table public.loyalty_ledger is 'Livro-razão de fidelidade. O saldo do cliente é sum(points) — NUNCA uma coluna.'; comment on function public.fn_finalizar_comanda(uuid, uuid, uuid, integer) is 'As seis coisas numa transação: venda, comissão por item, entrada na conta da forma de pagamento, ponto de fidelidade e conclusão do agendamento. Idempotente sob FOR UPDATE.'; -- ---- uma comanda por agendamento (migration 0352) ---- -- A rota consulta antes de abrir, e isso resolve o toque repetido, não a -- corrida: duas requisições simultâneas passam pelas duas consultas antes de -- qualquer insert. Duas comandas abertas para o mesmo atendimento não dão erro -- nenhum — são faturadas separadamente, e o cliente paga duas vezes. -- -- Parcial nas duas pontas: comanda avulsa é a maioria e não se exclui entre si; -- comanda cancelada deixa de valer, senão cancelar por engano trancaria o -- agendamento para sempre. update public.sales s set appointment_id = null where s.appointment_id is not null and s.status <> 'cancelled' and exists ( select 1 from public.sales anterior where anterior.appointment_id = s.appointment_id and anterior.organization_id = s.organization_id and anterior.status <> 'cancelled' and (anterior.created_at, anterior.id) < (s.created_at, s.id) ); create unique index if not exists sales_agendamento_unico_idx on public.sales (organization_id, appointment_id) where appointment_id is not null and status <> 'cancelled'; -- ---- relatório financeiro (migrations 0353 + 0356 + 0444) ---- -- Agrega NO BANCO: o PostgREST corta em 1000 linhas sem avisar, e somar na -- aplicação devolve um número menor com cara de certo (medido nesta base: -- R$ 141.436,00 em vez de R$ 641.103,60). Invoker, para a RLS de cada tabela -- continuar valendo. -- -- O corpo abaixo é o da 0444, que ACRESCENTOU `por_moeda` (os mesmos totais e -- listas, separados por moeda — #1531) sobre o da 0356, que ACRESCENTOU -- `por_servico` e `por_cliente`; nenhuma das duas mudou a assinatura nem os -- campos que já existiam. O apêndice guarda o estado final, nunca as versões -- empilhadas — senão quem lê o baseline vê a definição antiga primeiro e -- conclui que ela é a que vale. create or replace function public.fn_relatorio_financeiro( p_org uuid, p_de date, p_ate date ) returns jsonb language sql stable set search_path = public as $$ with lancamentos as ( select direction, amount_cents, currency from public.financial_entries where organization_id = p_org and status = 'paid' and entry_date between p_de and p_ate ), comandas as ( select id, status, total_cents, currency, reversed_at, payment_method_id, contact_id from public.sales where organization_id = p_org and finalized_at is not null and finalized_at::date between p_de and p_ate ), por_forma as ( select coalesce(pm.name, 'Sem forma') as nome, count(*) as quantidade, sum(c.total_cents) as total_cents from comandas c left join public.payment_methods pm on pm.id = c.payment_method_id and pm.organization_id = p_org group by 1 ), por_profissional as ( select co.attendant_user_id, count(*) as itens, sum(co.amount_cents) as comissao_cents from public.commissions co join public.sale_items si on si.id = co.sale_item_id and si.organization_id = p_org join comandas s on s.id = si.sale_id where co.organization_id = p_org and co.status <> 'reversed' group by 1 ), por_servico as ( -- Agrupa pela DESCRIÇÃO congelada no item, e não pelo nome atual do tipo de -- evento. É o que o cliente comprou, com o nome que tinha na hora — e é o -- único agrupamento que continua verdadeiro depois de alguém renomear um -- serviço. O item avulso (sem `event_type_id`) entra por aqui também, em vez -- de sumir do relatório. select si.description as nome, sum(si.quantity) as quantidade, sum(si.total_cents) as total_cents from public.sale_items si join comandas s on s.id = si.sale_id where si.organization_id = p_org group by 1 ), por_cliente as ( select c.contact_id, count(*) as comandas, sum(c.total_cents) as total_cents from comandas c where c.contact_id is not null group by 1 ), -- Daqui para baixo, os mesmos agrupamentos com a moeda na chave. Ficam -- paralelos aos de cima, em vez de o topo passar a somar os blocos, para que -- se prove por leitura que nenhum campo antigo mudou de conta. moedas as ( select currency as moeda from lancamentos union select currency from comandas ), forma_por_moeda as ( select c.currency as moeda, coalesce(pm.name, 'Sem forma') as nome, count(*) as quantidade, sum(c.total_cents) as total_cents from comandas c left join public.payment_methods pm on pm.id = c.payment_method_id and pm.organization_id = p_org group by 1, 2 ), profissional_por_moeda as ( select s.currency as moeda, co.attendant_user_id, count(*) as itens, sum(co.amount_cents) as comissao_cents from public.commissions co join public.sale_items si on si.id = co.sale_item_id and si.organization_id = p_org join comandas s on s.id = si.sale_id where co.organization_id = p_org and co.status <> 'reversed' group by 1, 2 ), servico_por_moeda as ( select s.currency as moeda, si.description as nome, sum(si.quantity) as quantidade, sum(si.total_cents) as total_cents from public.sale_items si join comandas s on s.id = si.sale_id where si.organization_id = p_org group by 1, 2 ), cliente_por_moeda as ( select c.currency as moeda, c.contact_id, count(*) as comandas, sum(c.total_cents) as total_cents from comandas c where c.contact_id is not null group by 1, 2 ) select jsonb_build_object( 'de', p_de, 'ate', p_ate, 'entradas_cents', coalesce((select sum(amount_cents) from lancamentos where direction = 'in'), 0), 'saidas_cents', coalesce((select sum(amount_cents) from lancamentos where direction = 'out'), 0), 'saldo_cents', coalesce((select sum(case when direction = 'in' then amount_cents else -amount_cents end) from lancamentos), 0), 'comandas_finalizadas', (select count(*) from comandas), 'comandas_estornadas', (select count(*) from comandas where reversed_at is not null), 'faturado_cents', coalesce((select sum(total_cents) from comandas), 0), 'ticket_medio_cents', coalesce((select sum(total_cents) / nullif(count(*), 0) from comandas), 0), 'por_forma', coalesce(( select jsonb_agg(jsonb_build_object('nome', nome, 'quantidade', quantidade, 'total_cents', total_cents) order by total_cents desc) from por_forma ), '[]'::jsonb), 'por_profissional', coalesce(( select jsonb_agg(jsonb_build_object('attendant_user_id', attendant_user_id, 'itens', itens, 'comissao_cents', comissao_cents) order by comissao_cents desc) from por_profissional ), '[]'::jsonb), 'por_servico', coalesce(( select jsonb_agg(jsonb_build_object('nome', nome, 'quantidade', quantidade, 'total_cents', total_cents) order by total_cents desc) from (select * from por_servico order by total_cents desc limit 10) t ), '[]'::jsonb), 'por_cliente', coalesce(( select jsonb_agg(jsonb_build_object('contact_id', contact_id, 'comandas', comandas, 'total_cents', total_cents) order by total_cents desc) from (select * from por_cliente order by total_cents desc limit 10) t ), '[]'::jsonb), -- O corte de 10 vale POR MOEDA: a lista do real e a do euro são listas -- diferentes, e cortar a soma misturada deixaria a moeda menor sem linha. 'por_moeda', coalesce(( select jsonb_object_agg(m.moeda, jsonb_build_object( 'entradas_cents', coalesce((select sum(l.amount_cents) from lancamentos l where l.currency = m.moeda and l.direction = 'in'), 0), 'saidas_cents', coalesce((select sum(l.amount_cents) from lancamentos l where l.currency = m.moeda and l.direction = 'out'), 0), 'saldo_cents', coalesce((select sum(case when l.direction = 'in' then l.amount_cents else -l.amount_cents end) from lancamentos l where l.currency = m.moeda), 0), 'comandas_finalizadas', (select count(*) from comandas c where c.currency = m.moeda), 'comandas_estornadas', (select count(*) from comandas c where c.currency = m.moeda and c.reversed_at is not null), 'faturado_cents', coalesce((select sum(c.total_cents) from comandas c where c.currency = m.moeda), 0), 'ticket_medio_cents', coalesce((select sum(c.total_cents) / nullif(count(*), 0) from comandas c where c.currency = m.moeda), 0), 'por_forma', coalesce(( select jsonb_agg(jsonb_build_object('nome', f.nome, 'quantidade', f.quantidade, 'total_cents', f.total_cents) order by f.total_cents desc) from forma_por_moeda f where f.moeda = m.moeda ), '[]'::jsonb), 'por_profissional', coalesce(( select jsonb_agg(jsonb_build_object('attendant_user_id', p.attendant_user_id, 'itens', p.itens, 'comissao_cents', p.comissao_cents) order by p.comissao_cents desc) from profissional_por_moeda p where p.moeda = m.moeda ), '[]'::jsonb), 'por_servico', coalesce(( select jsonb_agg(jsonb_build_object('nome', t.nome, 'quantidade', t.quantidade, 'total_cents', t.total_cents) order by t.total_cents desc) from (select * from servico_por_moeda sv where sv.moeda = m.moeda order by sv.total_cents desc limit 10) t ), '[]'::jsonb), 'por_cliente', coalesce(( select jsonb_agg(jsonb_build_object('contact_id', t.contact_id, 'comandas', t.comandas, 'total_cents', t.total_cents) order by t.total_cents desc) from (select * from cliente_por_moeda cl where cl.moeda = m.moeda order by cl.total_cents desc limit 10) t ), '[]'::jsonb) )) from moedas m ), '{}'::jsonb) ); $$; revoke execute on function public.fn_relatorio_financeiro(uuid, date, date) from public, anon; grant execute on function public.fn_relatorio_financeiro(uuid, date, date) to authenticated, service_role; -- ---- regra de comissao inativa (migration 0354) ---- -- A regra entra no catálogo financeiro genérico, que espera `is_active`. -- Antes disto não havia porta nenhuma para cadastrar uma regra, e toda -- comissão nascia 0% em toda instalação. Inativar e não apagar preserva a -- resposta a "por que aquela comanda saiu com este percentual". -- `name` é o rótulo que a pessoa lê na lista ("Ana em manicure"). Ele é -- redundante com os dois alvos, e a redundância é deliberada: o catálogo -- genérico exige um nome em toda entidade, e derivá-lo no servidor produziria um -- texto que ninguém pode corrigir quando ficar ambíguo. alter table public.commission_rules add column if not exists name text not null default 'Regra de comissão'; alter table public.commission_rules add column if not exists is_active boolean not null default true; create index if not exists commission_rules_org_ativas_idx on public.commission_rules (organization_id, event_type_id, attendant_user_id) where is_active; comment on column public.commission_rules.is_active is 'Regra em vigor. Inativa em vez de apagar: o percentual já aplicado está congelado no item, e o que se perderia é a resposta a "por que aquela comanda saiu com este percentual".'; -- ---- saldo de fidelidade (migration 0355) ---- -- O saldo é sum(points) do livro-razão, somado NO BANCO: o PostgREST corta em -- 1000 linhas sem avisar, e saldo truncado vira prêmio negado a quem tinha -- direito. Por CLIENTE, nunca agregado — o total geral esconde erros que se -- compensam. create or replace function public.fn_saldo_de_fidelidade(p_org uuid, p_contact uuid) returns integer language sql stable set search_path = public as $$ select coalesce(sum(points), 0)::integer from public.loyalty_ledger where organization_id = p_org and contact_id = p_contact; $$; revoke execute on function public.fn_saldo_de_fidelidade(uuid, uuid) from public, anon; grant execute on function public.fn_saldo_de_fidelidade(uuid, uuid) to authenticated, service_role; comment on function public.fn_saldo_de_fidelidade(uuid, uuid) is 'Saldo de pontos de um contato: sum(points) do livro-razão. Soma no banco porque o PostgREST corta em 1000 linhas sem avisar, e saldo truncado vira prêmio negado a quem tinha direito.'; -- ---- lancamento recorrente (migration 0357) ---- -- O molde de um lançamento que se repete todo mês. Não movimenta dinheiro: -- quem nasce é uma linha PENDENTE em `financial_entries`. Nasce pendente e -- nunca paga — o sistema sabe que a conta vence, não sabe se alguém pagou. -- -- A idempotência é do BANCO (índice único por molde e competência), e não de -- uma flag de "último gerado": esta resolveria o caso comum e falharia -- exatamente no que importa, duas execuções simultâneas. create table if not exists public.recurring_entries ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, name text not null, account_id uuid not null references public.financial_accounts(id) on delete restrict, account_plan_id uuid references public.account_plans(id) on delete restrict, direction text not null check (direction in ('in', 'out')), amount_cents bigint not null check (amount_cents > 0), currency text not null default 'BRL' check (char_length(currency) = 3), -- 1 a 31. O que não existe no mês cai no último dia dele. day_of_month integer not null check (day_of_month between 1 and 31), -- Inativa-se, não se apaga: o molde explica os lançamentos que ele gerou. is_active boolean not null default true, created_by_user_id uuid references auth.users(id) on delete set null, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create index if not exists recurring_entries_org_ativas_idx on public.recurring_entries (organization_id) where is_active; alter table public.financial_entries add column if not exists recurring_entry_id uuid references public.recurring_entries(id) on delete set null; -- A GARANTIA de que a mesma competência não nasce duas vezes. Parcial porque a -- imensa maioria dos lançamentos não vem de molde nenhum. create unique index if not exists financial_entries_recorrencia_competencia_idx on public.financial_entries (recurring_entry_id, entry_date) where recurring_entry_id is not null; alter table public.recurring_entries enable row level security; drop policy if exists tenant_isolation_recurring_entries_all on public.recurring_entries; create policy tenant_isolation_recurring_entries_all on public.recurring_entries for all using (organization_id in (select public.fn_user_org_ids()) or public.fn_is_platform_admin()) with check ( public.fn_is_platform_admin() or (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'manager')) ); revoke all on public.recurring_entries from anon; comment on table public.recurring_entries is 'O molde de um lançamento que se repete todo mês. Não movimenta dinheiro: quem nasce é uma linha pendente em financial_entries. Mudar o molde não reescreve o que já foi gerado.'; comment on column public.recurring_entries.day_of_month is 'Dia do mês, 1 a 31. O que não existe no mês cai no último dia dele — pular deixaria de cobrar o aluguel em fevereiro.'; -- ---- preco do tipo de evento (migration 0358) ---- -- O catálogo de serviços JÁ é o de tipos de agendamento (decisão da 0240), e -- faltava o preço. Sem ele o balcão digita valor a cada item e o faturamento -- em lote é impossível. NULLABLE: nem todo negócio tem preço fixo, e vazio -- significa "digite na hora", que é o comportamento de antes desta migration. -- É SEMENTE, nunca preço final — o item congela o seu próprio valor. alter table public.calendar_event_types add column if not exists default_price_cents bigint check (default_price_cents is null or default_price_cents >= 0); comment on column public.calendar_event_types.default_price_cents is 'Preço padrão do serviço, em centavos. Vazio = digite na hora. É SEMENTE do item da comanda, nunca o preço dele: o item guarda o seu próprio unit_price_cents, congelado na inclusão.'; -- ---- a cascata de anonimizacao alcanca a comanda (migration 0359) ---- -- A CASCATA DE ANONIMIZAÇÃO ALCANÇA A COMANDA (forward-fix da 0351). -- -- As migrations 0350-0357 trouxeram o módulo financeiro, e `sales` guarda -- `notes`, `cancel_reason` e `reverse_reason` — texto livre que um atendente -- escreve SOBRE a pessoa — com FK para `contacts`. Fora da cascata, anonimizar -- devolvia SUCESSO e o texto continuava legível: a falha é muda, e o SLA de -- D+15 é marcado como cumprido sobre um dado que não saiu. -- -- Forward-fix e não edição da 0351 porque a cascata é uma função do NÚCLEO, -- anterior a este módulo — o passo pertence a ela, não à migration que criou a -- tabela. `create or replace` da função inteira: ela percorre uma lista escrita -- à mão, e não há como acrescentar um passo sem reemiti-la. -- -- Vigiado por `tests/invariants/lgpd-cascata-alcanca-quem-guarda-pessoa.test.ts`, -- que deriva o escopo do CATÁLOGO (FK para contacts + coluna de PII) e lê o -- corpo REAL da função instalada — não uma lista de tabelas escrita ao lado. CREATE OR REPLACE FUNCTION "public"."fn_lgpd_cascade_redact_contact"("p_organization_id" "uuid", "p_contact_id" "uuid", "p_request_id" "uuid") RETURNS "jsonb" LANGUAGE "plpgsql" SECURITY DEFINER SET "search_path" TO 'public' AS $$ declare v_already bool; v_counts jsonb := '{}'::jsonb; v_media_paths text[] := '{}'; v_anon_label text; v_count int; begin perform public.fn_service_lock(p_organization_id,p_contact_id); select is_anonymized into v_already from contacts where id = p_contact_id and organization_id = p_organization_id; if not found then raise exception 'contact not found' using errcode = 'P0002'; end if; if v_already then return jsonb_build_object('already_anonymized', true, 'counts', v_counts, 'media_paths', v_media_paths); end if; v_anon_label := 'Cliente Anonimizado #' || substring(p_contact_id::text from 1 for 8); -- Collect media storage paths (we only delete what we own — media_storage_path) select coalesce(array_agg(distinct media_storage_path) filter (where media_storage_path is not null), '{}') into v_media_paths from messages where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ); -- 1. contacts (irreversible) update contacts set name = v_anon_label, display_name = v_anon_label, email = null, -- email_normalized NÃO entra: é GENERATED ALWAYS AS (lower(trim(email))) -- e o Postgres recusa escrita nela — a linha acima já a zera por derivação. -- Com a atribuição, o cascade INTEIRO abortava e nada era anonimizado. phone_number = null, cpf_encrypted = null, cpf_hash = null, birthdate = null, is_anonymized = true, anonymized_at = now(), consent = '{}'::jsonb, source_metadata = '{}'::jsonb, tags = '{}'::text[], updated_at = now() where id = p_contact_id and organization_id = p_organization_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('contacts', v_count); -- 2. conversations metadata + preview strip update conversations set metadata = '{}'::jsonb, last_message_preview = null, -- O motivo CRU da última passagem (migration 0291). É código de -- vocabulário, não texto livre — mas ele diz que ESTA pessoa foi escalada -- por irritação, por assunto jurídico ou por suspeita de opt-out, e isso é -- um fato sobre ela. Entra NESTE update, e não num segundo: mesmo -- predicado, mesmas linhas, metade das varreduras. -- -- ⚠️ `last_handoff_reason` é CHAVE DE NEGÓCIO em outro módulo: a ponte de -- voz limpa o silêncio filtrando pelo VALOR da coluna -- (`lib/wacalls/events-bridge.ts`). Zerá-la num contato anonimizado é -- seguro — não há chamada viva de contato anonimizado — e é a razão de -- esta entrega NÃO usar essa coluna para texto rico: ela continua -- recebendo só o código, e o texto vive em `passagens_de_atendimento`. last_handoff_reason = null, updated_at = now() where contact_id = p_contact_id and organization_id = p_organization_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('conversations', v_count); -- 3. messages: redact body + null media + strip metadata (preserve status/timestamps/conversation_id) update messages set body = '[mensagem anonimizada]', media_url = null, media_mime = null, media_size_bytes = null, media_storage_path = null, metadata = '{}'::jsonb, updated_at = now() where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('messages', v_count); -- 4. crm_lead_activities — strip payload, metadata E reason (migration 0071). -- `reason` é texto livre escrito por LLM sobre a conversa do lead: supor que -- nunca conterá um nome é a suposição que falha. `evidence` NÃO é limpa — -- guarda só ids, e as linhas apontadas são redigidas por conta própria. update crm_lead_activities set payload = '{}'::jsonb, metadata = '{}'::jsonb, reason = null where organization_id = p_organization_id and ( contact_id = p_contact_id or lead_id in ( select lead_id from crm_lead_links where target_kind = 'contact' and target_id = p_contact_id and organization_id = p_organization_id ) or lead_id in ( select id from crm_leads where contact_id = p_contact_id and organization_id = p_organization_id ) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('activities', v_count); -- 5. crm_leads — strip title/description/custom_fields/source_metadata/tags but PRESERVE pipeline/stage/value update crm_leads set title = v_anon_label, description = null, custom_fields = '{}'::jsonb, source_metadata = '{}'::jsonb, tags = '{}'::text[], updated_at = now() where organization_id = p_organization_id and ( contact_id = p_contact_id or id in ( select lead_id from crm_lead_links where target_kind = 'contact' and target_id = p_contact_id and organization_id = p_organization_id ) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('leads', v_count); -- 6. orders — PRESERVE values + status + timestamps. Strip personal fields from payload jsonb -- and replace customer_external_id with null (FK-safe; soft de-link). Keep contact_id null. update orders set payload = (coalesce(payload, '{}'::jsonb)) - 'customer' - 'customer_name' - 'customer_email' - 'customer_phone' - 'shipping_address' - 'billing_address' - 'contact_identification', customer_external_id = null, contact_id = null, is_anonymized = true, updated_at = now() where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('orders', v_count); -- 6b. sales — a comanda. PRESERVA valor, status e datas, e NÃO desliga o -- contato: a venda é registro financeiro (e fiscal) da organização, e -- desligá-la do contato faria o relatório por cliente deixar de fechar -- com o faturamento do período — divergência muda, meses depois, num -- número que ninguém consegue reconciliar. O contato apontado já é -- `Cliente Anonimizado #N`; o que sai daqui é o TEXTO LIVRE, que é onde -- a pessoa é nomeada de novo ("cliente da Ana, filha da Dona Maria"). -- Os itens (`sale_items`) não entram: `description` ali é o nome do -- SERVIÇO, congelado na inclusão, e apagá-lo destruiria o relatório por -- serviço sem tirar dado de pessoa nenhum. update sales set notes = null, cancel_reason = case when cancel_reason is null then null else '[redigido]' end, reverse_reason = case when reverse_reason is null then null else '[redigido]' end, updated_at = now() where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('sales', v_count); -- 7. enqueue media for async deletion (idempotent via unique (bucket, object_path)) if array_length(v_media_paths, 1) > 0 then insert into storage_redaction_queue (organization_id, request_id, bucket, object_path) select p_organization_id, p_request_id, 'whatsapp-media', path from unnest(v_media_paths) as path where path is not null and length(path) > 0 on conflict (bucket, object_path) do nothing; end if; -- 7b. voice_calls — o TELEFONE de quem falou ao telefone (migration 0235). -- -- `peer_phone` é `not null` e guarda o número da outra ponta: depois de -- anonimizar o contato, ele sobrevivia ligado ao `contact_id` e reidentificava -- a pessoa que pediu para ser esquecida. É o mesmo argumento que a foto de -- perfil já tinha (ver o bloco do avatar em `lib/lgpd/redact-cascade.ts`): -- anonimizar em toda parte menos numa é não ter anonimizado. -- -- O que fica: direção, status, motivo do fim, marcas de tempo e duração. Um -- registro de "houve uma chamada de 12 minutos" sem número e sem dono não -- identifica ninguém e é o que sustenta a métrica do atendente e a fatura. -- `peer_phone` é NOT NULL, então recebe o rótulo, não `null`. update voice_calls set peer_phone = v_anon_label, owner_user_id = null, created_by = null, updated_at = now() where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('voice_calls', v_count); -- agent_cases — o que a IA escreveu SOBRE a pessoa quando travou (migration 0280). -- -- O caso é o texto que a equipe lê antes de decidir: `title`, `summary` e -- `blocker` saem do modelo a partir da conversa, e `context_snapshot` é o -- recorte dessa conversa que o motor mandou para ele. Nada disso é registro de -- operação — é o relato do problema de uma pessoa identificável, escrito por -- máquina. Sem este passo, anonimizar devolvia SUCESSO com o relato intacto. -- -- As três colunas de texto são `not null`: recebem rótulo e texto fixo, nunca -- `null` (a mesma razão de `voice_calls.peer_phone` logo acima). -- -- ⚠️ `updated_at` FICA FORA DO `set`, de propósito. O cobrador de caso parado -- (`app/api/v1/cron/case-stale-watcher/route.ts`) lê `updated_at` como "alguém -- da equipe encostou neste caso". A cascata não é alguém encostando: escrever -- ali faria a anonimização ADIAR a cobrança de um caso que continua parado, e -- o efeito só apareceria como um cliente esperando mais tempo. -- -- O vínculo é pela CONVERSA porque `agent_cases` não tem FK para `contacts`. update agent_cases set title = v_anon_label, summary = '[resumo anonimizado]', blocker = '[bloqueio anonimizado]', context_snapshot = '{}'::jsonb where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('agent_cases', v_count); -- agent_case_events — a linha do tempo do caso (migration 0280). -- -- `body` é o que a pessoa da equipe escreveu ao responder o caso e o que o -- agente registrou sobre o que o LEAD respondeu; `metadata` carrega o recorte -- que o motor anexou. `kind`, `actor_kind`, `human_action` e `created_at` -- FICAM: são o registro de que houve um toque humano e quando — operação, não -- dado da pessoa, e é deles que sai a métrica de atendimento. update agent_case_events set body = null, metadata = '{}'::jsonb where organization_id = p_organization_id and case_id in ( select id from agent_cases where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('agent_case_events', v_count); -- demandas — o assunto do pedido (migration 0280). -- -- `assunto` é texto livre sobre o que a pessoa pediu. O resto da linha é a -- operação da demanda (origem, estado, dono, prazo, desfecho) e fica de pé: -- apagar a linha inteira tiraria da organização a resposta a "quantos pedidos -- houve em março", que é o mesmo argumento do compromisso da agenda. -- -- FK direta (`demandas.contact_id` é `not null`), então o vínculo é o contato. update demandas set assunto = null where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('demandas', v_count); -- agent_inbox_items — o aviso que leva o texto do caso para a Central (migration 0280). -- -- O `body` do aviso de caso parado EMBUTE o título do caso -- (`app/api/v1/cron/case-stale-watcher/route.ts:128`), e o do handoff embute o -- motivo da parada (`lib/ai/handoff/orchestrator.ts:335`). Redigir o caso e -- deixar o aviso de pé seria anonimizar em toda parte menos numa — que é não -- ter anonimizado. O molde (resolver + trocar o corpo + soltar a referência) é -- o de `fn_meet_redact_contact`, que já faz isto para o aviso de compromisso. -- -- ⚠️ O VÍNCULO É POLIMÓRFICO E TEM TRÊS BRAÇOS, não dois. Medido nos -- produtores, não suposto: `handoff` nasce com `ref_kind='contact'` -- (`lib/ai/handoff/orchestrator.ts:339`) E com `ref_kind='conversation'` -- (`lib/agent-engine/agent/inbound-turn.ts:4100`); `case_stale` nasce SEMPRE -- com `ref_kind='agent_case'` (a rota do cron acima, e a política em -- `lib/ai/inbox-destino.ts:38`). Um predicado com só os dois primeiros braços -- casa ZERO avisos de caso parado — e casar zero linha não é erro: é sucesso -- com o texto intacto. -- -- Os `kind` são os MEDIDOS no CHECK vigente (`supabase/baseline.sql`, bloco -- único de `agent_inbox_items_kind_check`). `case_opened` NÃO existe, e kind -- inexistente num `in (...)` também casa zero e devolve sucesso. Para -- reconferir sem acreditar nesta prosa: -- grep -n "agent_inbox_items_kind_check check" -A40 supabase/baseline.sql update agent_inbox_items set status = 'resolved', resolved_at = now(), body = 'Contato anonimizado.', ref_id = null where organization_id = p_organization_id -- `aviso_de_caso_nao_entregue` (migration 0292) entra AQUI e não num -- passo próprio: é o mesmo predicado polimórfico, e o braço -- `ref_kind='agent_case'` já alcança o caso do titular. O corpo do aviso -- embute o título do caso, que é texto sobre a pessoa. and kind in ('handoff', 'case_stale', 'aviso_de_caso_nao_entregue') and ( (ref_kind = 'contact' and ref_id = p_contact_id) or (ref_kind = 'conversation' and ref_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id )) or (ref_kind = 'agent_case' and ref_id in ( select id from agent_cases where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ) )) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('agent_inbox_items', v_count); -- agent_case_chat_messages — a consulta interna da equipe à IA SOBRE o caso -- (migration 0281). FK DIRETA para `contacts`, então o vínculo é o titular e -- não precisa passar pela conversa. -- -- `redacted_at is null` no `where` é o que torna o passo IDEMPOTENTE: a -- varredura diária de redações incompletas roda a função de novo, e sem essa -- condição o carimbo de QUANDO se apagou seria reescrito a cada rodada. -- -- A linha NÃO é apagada, só o texto: quem abrir o caso depois continua vendo -- que a equipe perguntou N vezes, quando, e se a IA respondeu. Apagar a linha -- inteira ficaria verde num teste de "o texto sumiu" e tiraria da organização -- a resposta a "quanto a equipe deliberou sobre este caso". update agent_case_chat_messages set body = null, redacted_at = now() where organization_id = p_organization_id and contact_id = p_contact_id and redacted_at is null; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('agent_case_chat_messages', v_count); -- passagens_de_atendimento — o BRIEFING é sobre a pessoa (migration 0291). -- -- A linha guarda o que a IA concluiu sobre um atendimento de alguém -- identificável: o que ela entendeu que a pessoa quer (`title`), a narrativa -- que quem assumiu leu (`body`), as PALAVRAS LITERAIS do cliente (`notes`), o -- texto livre de quem passou (`content`) e o que a IA já tinha tentado -- (`tentativas`). Nada disso é registro de operação — é o relato do problema -- de uma pessoa, escrito por máquina, na tela de quem vai responder. -- -- `body` é `not null` e recebe o RÓTULO, não `null` — a mesma razão de -- `voice_calls.peer_phone` e de `agent_cases.title` acima: coluna obrigatória -- anulada aborta o cascade INTEIRO, e um cascade abortado não anonimiza nada. -- -- O que FICA, de propósito: `motor`, `origem`, `motivo_codigo`, -- `cliente_avisado`, `aviso_motivo_codigo`, `criado_em` e o par de -- reconhecimento. São operação — quantas passagens houve, por quê, quanto -- tempo até alguém assumir. Um passo que apagasse a linha inteira ficaria -- verde num teste de "o texto sumiu" e tiraria da organização a resposta a -- "quantos atendimentos a IA devolveu em março, e quanto tempo esperaram". -- -- O vínculo é a FK DIRETA `contact_id`: a tabela a carrega exatamente para -- este passo não precisar passar pela conversa. update passagens_de_atendimento set body = v_anon_label, title = null, notes = null, content = null, tentativas = '[]'::jsonb where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('passagens_de_atendimento', v_count); -- entregas_de_aviso_de_caso — o registro do aviso ao suporte (migration 0292). -- -- A tabela NÃO guarda o texto do aviso (só `corpo_hash`), e a única coluna -- capaz de ecoar um dado da pessoa é `erro_detalhe`: ali vai o texto CRU que -- o transporte devolveu, truncado, e um provedor que recusa um envio costuma -- devolver o destinatário dentro da mensagem de erro. -- -- O que FICA, de propósito: `status`, `erro_codigo`, `tentativas`, -- `enviado_em`, `destino`, `corpo_hash`. São operação — quantos avisos saíram, -- quantos falharam e por quê. Um passo que apagasse a linha inteira ficaria -- verde num teste de "o texto sumiu" e tiraria da organização a resposta a -- "quantos avisos não chegaram em março". `destino` é o telefone da EQUIPE, -- não do titular: anonimizar um cliente não apaga o número do plantão. -- -- ⚠️ PONTO CEGO DECLARADO: `tests/invariants/lgpd-cascata-alcanca-quem- -- guarda-pessoa.test.ts` só cobra tabela com FK para `contacts` E coluna cujo -- NOME case o padrão de PII. Esta tabela não satisfaz nenhuma das duas — o -- gate ficaria VERDE sem este passo. Ele entra porque é certo, não porque o -- gate cobra, e isto está escrito aqui para a próxima sessão não o remover -- achando que é ornamento. Quem o vigia é a catraca -- `tests/invariants/cascata-lgpd-nao-encolhe.test.ts`. -- -- O vínculo é pela CONVERSA, como o de `agent_cases`: esta tabela aponta para -- o caso, e o caso não tem FK para `contacts`. update entregas_de_aviso_de_caso set erro_detalhe = null where organization_id = p_organization_id and case_id in ( select id from agent_cases where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('entregas_de_aviso_de_caso', v_count); -- 8. dense audit row insert into api_audit_log (organization_id, action, actor_user_id, resource_type, resource_id, metadata, bypassed_rls) values ( p_organization_id, 'lgpd.redact_executed', null, 'contact', p_contact_id, jsonb_build_object( 'cascaded_to', v_counts, 'media_queued', coalesce(array_length(v_media_paths, 1), 0), 'request_id', p_request_id ), true ); return jsonb_build_object( 'already_anonymized', false, 'counts', v_counts, 'media_paths', v_media_paths ); end; $$; -- ---- a recusa permanente da agenda não pede repetição (migration 0363) ---- -- `PT409` no lugar de `40001` nas três recusas PERMANENTES de `fn_meet_action`. -- `40001` vira HTTP 500 no PostgREST e o gateway do Supabase reexecuta 5xx sem -- limite (docs/runbooks/postgrest-replay-do-gateway.md); `PTxxx` chega como o -- status dos três últimos dígitos, e 4xx não é reexecutado. Corpo idêntico ao -- da definição acima, com três `errcode` trocados. Idempotente. -- Recorte do PR #803, de @paulolimajr77. create or replace function public.fn_meet_action(p_org uuid,p_id uuid,p_revision text,p_request uuid,p_action text,p_conversation uuid default null) returns boolean language plpgsql security definer set search_path=public as $$ declare a public.calendar_appointments; contact uuid; b jsonb; destination_channel uuid; begin if auth.uid() is null or not public.fn_role_at_least(p_org,'agent') or not public.fn_support_write_allowed(p_org) then raise exception 'meet_forbidden' using errcode='42501';end if; if not public.fn_session_mfa_proven() then raise exception 'meet_mfa_required' using errcode='42501';end if; select contact_id into contact from public.calendar_appointments where organization_id=p_org and id=p_id; if contact is not null then perform public.fn_service_lock(p_org,contact);end if; select * into a from public.calendar_appointments where organization_id=p_org and id=p_id for update; if not found or a.owner_user_id is distinct from auth.uid() or not exists(select 1 from public.user_organizations where organization_id=p_org and user_id=auth.uid() and revoked_at is null) then raise exception 'meet_forbidden' using errcode='42501';end if; if a.revision::text is distinct from p_revision or a.meeting_request_id is distinct from p_request or a.status='cancelled' or a.location_kind<>'google_meet' or exists(select 1 from public.contacts where id=a.contact_id and organization_id=p_org and is_anonymized) then raise exception 'meet_stale' using errcode='PT409';end if; if p_action='retry' then if a.google_conflict is not null then raise exception 'google_conflict_requires_choice' using errcode='PT409';end if; if a.meeting_state='ready' then return false;end if; if a.meeting_state<>'failed' then update public.calendar_appointments set meeting_next_attempt_at=now(),google_next_attempt_at=now() where organization_id=p_org and id=p_id;return true; end if; -- Tempo/timeout não provam rejeição. Somente failure recebido gira solicitação. update public.calendar_appointments set meeting_request_id=case when meeting_last_error='google_failure' and meeting_received_at is not null then gen_random_uuid() else meeting_request_id end, meeting_requested_at=case when meeting_last_error='google_failure' and meeting_received_at is not null then null else meeting_requested_at end, meeting_received_at=case when meeting_last_error='google_failure' then null else meeting_received_at end, meeting_state='pending',meeting_attempts=0,meeting_last_error=null,meeting_next_attempt_at=now(),google_next_attempt_at=now() where organization_id=p_org and id=p_id; elsif p_action='deliver' then if a.contact_id is null then raise exception 'meet_conversation_unavailable' using errcode='42501';end if; select channel_session_id into destination_channel from public.conversations where organization_id=p_org and id=p_conversation and contact_id=a.contact_id and not is_group and public.fn_can_view_conversation(organization_id,assigned_to_user_id) for update; if not found then raise exception 'meet_conversation_unavailable' using errcode='42501';end if; b:=public.fn_service_boundary(p_org,p_conversation)-'status'-'demanda_fechada_em'-'service_started_at'; if not public.fn_meet_boundary_current(b) then raise exception 'meet_conversation_stale' using errcode='PT409';end if; if a.meeting_delivery->'service_boundary'=b and a.meeting_delivery->>'channel_session_id'=destination_channel::text then if a.meeting_delivery->>'state' in ('waiting_for_link','sent') then return false;end if; if a.meeting_delivery->>'state'='queued' and a.meeting_delivery_job_id is not null then -- Recuperação humana de job morto conserva ledger/identidade. Não duplicar -- uma mensagem aceita antes do crash nem reconstruir fronteira antiga. update public.job_queue set status='pending',locked_by=null,locked_at=null,attempts=0,run_after=now(),last_error=null where organization_id=p_org and id=a.meeting_delivery_job_id and kind='transactional_delivery' and status in ('dead','failed','done'); return found; end if; end if; update public.job_queue set status='failed',locked_by=null,locked_at=null,last_error='meet_delivery_superseded' where organization_id=p_org and id=a.meeting_delivery_job_id and kind='transactional_delivery' and status in ('pending','running'); update public.calendar_appointments set meeting_delivery=jsonb_build_object('state','waiting_for_link','generation',gen_random_uuid(),'service_boundary',b,'authorized_by',jsonb_build_object('kind','user','id',auth.uid()),'source_operation_id',gen_random_uuid()),meeting_delivery_job_id=null where organization_id=p_org and id=p_id; else raise exception 'meet_action_invalid' using errcode='22023';end if; return true; end;$$; -- ---- reenviar o link do Meet é ação própria (migration 0365) ---- -- `p_action in ('deliver','resend')`, e o `return false` em estado enviado -- passa a valer só para o `deliver` — ele é a proteção contra clique duplo, e -- afrouxá-lo daria o reenvio tirando a proteção. `waiting_for_link`/`queued` -- continuam trancando os dois. Corpo idêntico ao do bloco da 0363, com o ramo -- do envio ampliado. Idempotente. -- ⚠️ ENTRA ANTES DO BLOCO DA VARREDURA anon: ela cura só o que veio antes, e -- função criada depois nasce exposta a `anon` e fica. -- Recorte do PR #803, de @paulolimajr77. create or replace function public.fn_meet_action(p_org uuid,p_id uuid,p_revision text,p_request uuid,p_action text,p_conversation uuid default null) returns boolean language plpgsql security definer set search_path=public as $$ declare a public.calendar_appointments; contact uuid; b jsonb; destination_channel uuid; begin if auth.uid() is null or not public.fn_role_at_least(p_org,'agent') or not public.fn_support_write_allowed(p_org) then raise exception 'meet_forbidden' using errcode='42501';end if; if not public.fn_session_mfa_proven() then raise exception 'meet_mfa_required' using errcode='42501';end if; select contact_id into contact from public.calendar_appointments where organization_id=p_org and id=p_id; if contact is not null then perform public.fn_service_lock(p_org,contact);end if; select * into a from public.calendar_appointments where organization_id=p_org and id=p_id for update; if not found or a.owner_user_id is distinct from auth.uid() or not exists(select 1 from public.user_organizations where organization_id=p_org and user_id=auth.uid() and revoked_at is null) then raise exception 'meet_forbidden' using errcode='42501';end if; if a.revision::text is distinct from p_revision or a.meeting_request_id is distinct from p_request or a.status='cancelled' or a.location_kind<>'google_meet' or exists(select 1 from public.contacts where id=a.contact_id and organization_id=p_org and is_anonymized) then raise exception 'meet_stale' using errcode='PT409';end if; if p_action='retry' then if a.google_conflict is not null then raise exception 'google_conflict_requires_choice' using errcode='PT409';end if; if a.meeting_state='ready' then return false;end if; if a.meeting_state<>'failed' then update public.calendar_appointments set meeting_next_attempt_at=now(),google_next_attempt_at=now() where organization_id=p_org and id=p_id;return true; end if; -- Tempo/timeout não provam rejeição. Somente failure recebido gira solicitação. update public.calendar_appointments set meeting_request_id=case when meeting_last_error='google_failure' and meeting_received_at is not null then gen_random_uuid() else meeting_request_id end, meeting_requested_at=case when meeting_last_error='google_failure' and meeting_received_at is not null then null else meeting_requested_at end, meeting_received_at=case when meeting_last_error='google_failure' then null else meeting_received_at end, meeting_state='pending',meeting_attempts=0,meeting_last_error=null,meeting_next_attempt_at=now(),google_next_attempt_at=now() where organization_id=p_org and id=p_id; elsif p_action in ('deliver','resend') then if a.contact_id is null then raise exception 'meet_conversation_unavailable' using errcode='42501';end if; select channel_session_id into destination_channel from public.conversations where organization_id=p_org and id=p_conversation and contact_id=a.contact_id and not is_group and public.fn_can_view_conversation(organization_id,assigned_to_user_id) for update; if not found then raise exception 'meet_conversation_unavailable' using errcode='42501';end if; b:=public.fn_service_boundary(p_org,p_conversation)-'status'-'demanda_fechada_em'-'service_started_at'; if not public.fn_meet_boundary_current(b) then raise exception 'meet_conversation_stale' using errcode='PT409';end if; if a.meeting_delivery->'service_boundary'=b and a.meeting_delivery->>'channel_session_id'=destination_channel::text then -- ⛔ ESTE `return false` É A PROTEÇÃO CONTRA CLIQUE DUPLO, e é por isso que o -- reenvio é uma AÇÃO NOVA em vez de um ramo reescrito. Ele impede a mesma -- mensagem de sair duas vezes por um clique nervoso; se o botão "Enviar de -- novo" apenas reescrevesse este ramo, ganharíamos o reenvio e perderíamos a -- proteção — e envio em dobro para cliente é pior que não-envio. -- `deliver` continua exatamente como era; `resend` passa reto, e quem o -- dispara já confirmou na tela. if p_action='deliver' and a.meeting_delivery->>'state' in ('waiting_for_link','sent') then return false;end if; if a.meeting_delivery->>'state'='queued' and a.meeting_delivery_job_id is not null then -- Recuperação humana de job morto conserva ledger/identidade. Não duplicar -- uma mensagem aceita antes do crash nem reconstruir fronteira antiga. update public.job_queue set status='pending',locked_by=null,locked_at=null,attempts=0,run_after=now(),last_error=null where organization_id=p_org and id=a.meeting_delivery_job_id and kind='transactional_delivery' and status in ('dead','failed','done'); return found; end if; end if; update public.job_queue set status='failed',locked_by=null,locked_at=null,last_error='meet_delivery_superseded' where organization_id=p_org and id=a.meeting_delivery_job_id and kind='transactional_delivery' and status in ('pending','running'); update public.calendar_appointments set meeting_delivery=jsonb_build_object('state','waiting_for_link','generation',gen_random_uuid(),'service_boundary',b,'authorized_by',jsonb_build_object('kind','user','id',auth.uid()),'source_operation_id',gen_random_uuid()),meeting_delivery_job_id=null where organization_id=p_org and id=p_id; else raise exception 'meet_action_invalid' using errcode='22023';end if; return true; end;$$; -- ---- o compromisso chega ao cliente mesmo sem Google Meet (migration 0366) ---- -- A exigência de link pronto passa a valer SÓ onde `location_kind='google_meet'` -- nas TRÊS pontas: o gatilho que enfileira, o porteiro do envio e a ação que -- autoriza. Nada mais muda. ⚠️ ANTES DA VARREDURA anon. Idempotente. -- Recorte do PR #803, de @paulolimajr77. create or replace function public.fn_meet_delivery_current(p_org uuid,p_job uuid,p_worker text,p_acquired_at timestamptz) returns boolean language sql stable security definer set search_path=public as $$ select exists(select 1 from public.job_queue j join public.calendar_appointments a on a.organization_id=j.organization_id and a.id::text=j.payload->>'appointment_id' join public.contacts c on c.organization_id=a.organization_id and c.id=a.contact_id join public.conversations v on v.organization_id=a.organization_id and v.contact_id=a.contact_id and v.id::text=j.payload->'service_boundary'->>'conversation_id' join public.channel_sessions cs on cs.organization_id=v.organization_id and cs.id=v.channel_session_id join public.organizations o on o.id=a.organization_id and o.status='active' where cs.archived_at is null and a.meeting_delivery->>'channel_session_id'=cs.id::text and j.organization_id=p_org and j.id=p_job and j.kind='transactional_delivery' and j.status='running' and j.locked_by=p_worker and j.locked_at=p_acquired_at and a.contact_id=j.contact_id and not c.is_anonymized and not c.is_blocked and a.status<>'cancelled' and (a.location_kind<>'google_meet' or (a.meeting_state='ready' and a.meeting_url is not null)) and a.meeting_request_id::text=j.payload->>'meeting_request_id' and a.meeting_delivery->>'generation'=j.payload->>'delivery_generation' and a.meeting_delivery_job_id=j.id and a.meeting_delivery->>'state'='queued' and exists(select 1 from public.user_organizations where organization_id=p_org and user_id=a.owner_user_id and revoked_at is null) and (a.meeting_delivery->'authorized_by'->>'kind'='ai_agent' or (a.meeting_delivery->'authorized_by'->>'kind'='user' and a.meeting_delivery->'authorized_by'->>'id'=a.owner_user_id::text and exists( select 1 from public.user_organizations u where u.organization_id=p_org and u.user_id=a.owner_user_id and u.revoked_at is null and u.role in ('agent','manager','admin') and (u.role in ('manager','admin') or v.assigned_to_user_id=u.user_id or o.settings->>'visibility_mode'='all' or (coalesce(o.settings->>'visibility_mode','own_and_unassigned')='own_and_unassigned' and v.assigned_to_user_id is null))))) and a.meeting_delivery->'service_boundary'=j.payload->'service_boundary' and public.fn_meet_boundary_current(j.payload->'service_boundary')); $$; revoke all on function public.fn_meet_delivery_current(uuid,uuid,text,timestamptz) from public,anon,authenticated; grant execute on function public.fn_meet_delivery_current(uuid,uuid,text,timestamptz) to service_role; create or replace function public.fn_meet_action(p_org uuid,p_id uuid,p_revision text,p_request uuid,p_action text,p_conversation uuid default null) returns boolean language plpgsql security definer set search_path=public as $$ declare a public.calendar_appointments; contact uuid; b jsonb; destination_channel uuid; begin if auth.uid() is null or not public.fn_role_at_least(p_org,'agent') or not public.fn_support_write_allowed(p_org) then raise exception 'meet_forbidden' using errcode='42501';end if; if not public.fn_session_mfa_proven() then raise exception 'meet_mfa_required' using errcode='42501';end if; select contact_id into contact from public.calendar_appointments where organization_id=p_org and id=p_id; if contact is not null then perform public.fn_service_lock(p_org,contact);end if; select * into a from public.calendar_appointments where organization_id=p_org and id=p_id for update; if not found or a.owner_user_id is distinct from auth.uid() or not exists(select 1 from public.user_organizations where organization_id=p_org and user_id=auth.uid() and revoked_at is null) then raise exception 'meet_forbidden' using errcode='42501';end if; if a.revision::text is distinct from p_revision or a.meeting_request_id is distinct from p_request or a.status='cancelled' or exists(select 1 from public.contacts where id=a.contact_id and organization_id=p_org and is_anonymized) then raise exception 'meet_stale' using errcode='PT409';end if; if p_action='retry' then if a.google_conflict is not null then raise exception 'google_conflict_requires_choice' using errcode='PT409';end if; if a.meeting_state='ready' then return false;end if; if a.meeting_state<>'failed' then update public.calendar_appointments set meeting_next_attempt_at=now(),google_next_attempt_at=now() where organization_id=p_org and id=p_id;return true; end if; -- Tempo/timeout não provam rejeição. Somente failure recebido gira solicitação. update public.calendar_appointments set meeting_request_id=case when meeting_last_error='google_failure' and meeting_received_at is not null then gen_random_uuid() else meeting_request_id end, meeting_requested_at=case when meeting_last_error='google_failure' and meeting_received_at is not null then null else meeting_requested_at end, meeting_received_at=case when meeting_last_error='google_failure' then null else meeting_received_at end, meeting_state='pending',meeting_attempts=0,meeting_last_error=null,meeting_next_attempt_at=now(),google_next_attempt_at=now() where organization_id=p_org and id=p_id; elsif p_action in ('deliver','resend') then if a.contact_id is null then raise exception 'meet_conversation_unavailable' using errcode='42501';end if; select channel_session_id into destination_channel from public.conversations where organization_id=p_org and id=p_conversation and contact_id=a.contact_id and not is_group and public.fn_can_view_conversation(organization_id,assigned_to_user_id) for update; if not found then raise exception 'meet_conversation_unavailable' using errcode='42501';end if; b:=public.fn_service_boundary(p_org,p_conversation)-'status'-'demanda_fechada_em'-'service_started_at'; if not public.fn_meet_boundary_current(b) then raise exception 'meet_conversation_stale' using errcode='PT409';end if; if a.meeting_delivery->'service_boundary'=b and a.meeting_delivery->>'channel_session_id'=destination_channel::text then -- ⛔ ESTE `return false` É A PROTEÇÃO CONTRA CLIQUE DUPLO, e é por isso que o -- reenvio é uma AÇÃO NOVA em vez de um ramo reescrito. Ele impede a mesma -- mensagem de sair duas vezes por um clique nervoso; se o botão "Enviar de -- novo" apenas reescrevesse este ramo, ganharíamos o reenvio e perderíamos a -- proteção — e envio em dobro para cliente é pior que não-envio. -- `deliver` continua exatamente como era; `resend` passa reto, e quem o -- dispara já confirmou na tela. if p_action='deliver' and a.meeting_delivery->>'state' in ('waiting_for_link','sent') then return false;end if; if a.meeting_delivery->>'state'='queued' and a.meeting_delivery_job_id is not null then -- Recuperação humana de job morto conserva ledger/identidade. Não duplicar -- uma mensagem aceita antes do crash nem reconstruir fronteira antiga. update public.job_queue set status='pending',locked_by=null,locked_at=null,attempts=0,run_after=now(),last_error=null where organization_id=p_org and id=a.meeting_delivery_job_id and kind='transactional_delivery' and status in ('dead','failed','done'); return found; end if; end if; update public.job_queue set status='failed',locked_by=null,locked_at=null,last_error='meet_delivery_superseded' where organization_id=p_org and id=a.meeting_delivery_job_id and kind='transactional_delivery' and status in ('pending','running'); update public.calendar_appointments set meeting_delivery=jsonb_build_object('state','waiting_for_link','generation',gen_random_uuid(),'service_boundary',b,'authorized_by',jsonb_build_object('kind','user','id',auth.uid()),'source_operation_id',gen_random_uuid()),meeting_delivery_job_id=null where organization_id=p_org and id=p_id; else raise exception 'meet_action_invalid' using errcode='22023';end if; return true; end;$$; create or replace function public.fn_meet_delivery_enqueue() returns trigger language plpgsql security definer set search_path=public as $$ declare jid uuid; b jsonb; begin -- A MESMA ORDEM DE TRAVA das ~20 irmãs: contato PRIMEIRO, job_queue depois. -- Sem esta linha, este gatilho já segurava a linha do compromisso (é BEFORE/ -- AFTER na própria calendar_appointments) e ia travar job_queue sem o mutex do -- contato, enquanto fn_meet_redact_contact (0229) pega o mutex do contato e só -- então mexe em job_queue. Duas ordens opostas sobre os mesmos dois recursos = -- deadlock (40P01) sob concorrência, e quem paga é o cliente com anonimização -- LGPD acontecendo enquanto um link de reunião é entregue. perform public.fn_service_lock(new.organization_id,new.contact_id); -- ⚠️ `status` ENTRA AQUI, e a falta dele era um buraco REAL que só apareceu -- ao abrir a entrega para compromisso sem Meet. -- -- A guarda olhava só `meeting_state='cancelled'` — o estado do LINK, não do -- compromisso. Enquanto a entrega exigia link pronto isso bastava por -- acidente: cancelar o compromisso cancelava o link junto. Sem Meet não há -- link para cancelar, e um compromisso CANCELADO passava a enfileirar -- entrega. O porteiro do envio recusaria depois (`a.status<>'cancelled'`), -- então o cliente não receberia nada — mas o job nasceria para morrer -- bloqueado, e a tela mostraria uma entrega a caminho que nunca sai. -- -- Achado do @paulolimajr77, e foi o teste DELE que o pegou aqui. if new.status='cancelled' or new.meeting_state='cancelled' or new.meeting_delivery->>'state' in ('blocked','stale') then update public.job_queue set status='failed',locked_at=null,locked_by=null,payload='{}',last_error='meet_delivery_stale' where organization_id=new.organization_id and id=new.meeting_delivery_job_id and kind='transactional_delivery' and status in ('pending','running'); return new; end if; if new.meeting_state='failed' then perform public.fn_meet_notice(new.organization_id,new.id,'meeting_failed');end if; -- ⛔ ESPERAR O LINK VALE SÓ ONDE O LOCAL É O MEET. -- -- Esta é a exigência mais fácil de esquecer e a pior de esquecer: num -- compromisso PRESENCIAL o `meeting_state` é `not_requested` para sempre, -- então a entrega era autorizada, o gatilho passava por aqui, devolvia sem -- enfileirar nada, e a entrega ficava em `waiting_for_link` PARA SEMPRE — em -- silêncio, sem job, sem aviso e sem erro. Foi o teste do autor que a achou. -- -- Onde o local É o Meet, nada muda: sem link pronto não sai job, porque -- mandar uma reunião sem como entrar nela é pior que não mandar. if (new.location_kind='google_meet' and new.meeting_state<>'ready') or new.meeting_delivery->>'state'<>'waiting_for_link' then return new;end if; b:=new.meeting_delivery->'service_boundary'; if not public.fn_meet_boundary_current(b) then update public.calendar_appointments set meeting_delivery=meeting_delivery||'{"state":"stale","error":"service_boundary_stale"}' where organization_id=new.organization_id and id=new.id; perform public.fn_meet_notice(new.organization_id,new.id,'service_boundary_stale');return new; end if; jid:=gen_random_uuid(); insert into public.job_queue(id,organization_id,contact_id,kind,payload,run_after) values(jid,new.organization_id,new.contact_id,'transactional_delivery',jsonb_build_object('appointment_id',new.id,'meeting_request_id',new.meeting_request_id, 'delivery_generation',new.meeting_delivery->>'generation','service_boundary',b),now()); update public.calendar_appointments set meeting_delivery_job_id=jid,meeting_delivery=meeting_delivery||'{"state":"queued"}' where organization_id=new.organization_id and id=new.id; return new; end;$$; revoke all on function public.fn_meet_delivery_enqueue() from public,anon,authenticated; -- APÊNDICE 20260921030000_0368_redes_sociais_nativas.sql -- Social connections reuse channel sessions, the inbox and the outbound ledger. -- Credentials are server-only; tenant admins use authenticated API routes. create table if not exists public.channel_integrations ( organization_id uuid primary key references public.organizations(id) on delete cascade, profile_id text not null, credential_encrypted bytea not null, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); alter table public.channel_integrations enable row level security; revoke all on public.channel_integrations from public, anon, authenticated; grant all on public.channel_integrations to service_role; alter table public.contacts add column if not exists social_identity text; -- A ficha MESCLADA fica de fora do índice: depois de juntar dois contatos, o -- perdedor continua na tabela com `is_merged_into` apontando para o vencedor, e -- os dois carregam a mesma identidade social. Sem esta guarda, a junção passa a -- falhar com violação de unicidade — e quem junta é o operador, na tela. create unique index if not exists contacts_org_social_identity_unique on public.contacts (organization_id, social_identity) where social_identity is not null and is_merged_into is null; comment on column public.contacts.social_identity is 'Opaque network/account/participant key. Never interpreted as a telephone or WhatsApp identity.'; -- As duas CHECKs de `channel_sessions` que o provider novo exige NÃO estão -- aqui: elas ficam no bloco "provider zernio_social entra nos CHECKs" -- (ABAIXO, logo antes da varredura de anon), porque precisam vir DEPOIS da -- definição que o dump traz — a última definição é a que vale. Esta linha -- afirmava o contrário ("incluídas no bloco único acima") e era falsa: o -- apêndice não tocava constraint nenhuma, e toda VPS de cliente batia 23514 na -- primeira conexão de rede social. alter table public.conversations drop constraint if exists conversations_channel_check; alter table public.conversations add constraint conversations_channel_check check (channel in ('whatsapp', 'instagram', 'facebook')); -- APÊNDICE 20260921030100_0369_prospeccao_nativa.sql -- Native prospecting is an adapter to discovery, CRM creation and existing AI delivery. -- Server-only tables: authenticated routes resolve the tenant and authorize every command. create table if not exists public.prospecting_settings ( organization_id uuid primary key references public.organizations(id) on delete cascade, credential_encrypted bytea not null, updated_at timestamptz not null default now() ); create table if not exists public.prospecting_campaigns ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, request_id uuid not null, name text not null, search jsonb not null, config jsonb, status text not null default 'draft' check (status in ('draft','running','paused','completed')), search_status text not null default 'starting' check (search_status in ('starting','running','succeeded','failed','unknown')), run_id text, dataset_id text, cost_usd numeric, result_count integer not null default 0, skipped_count integer not null default 0, error text, next_send_at timestamptz not null default now(), created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique (organization_id, id), unique (organization_id, request_id) ); create unique index if not exists prospecting_one_running_org on public.prospecting_campaigns(organization_id) where status='running'; create table if not exists public.prospecting_candidates ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, campaign_id uuid not null, place_id text not null, phone text, data jsonb not null, status text not null default 'new' check (status in ('new','queued','sending','sent','skipped','failed')), contact_id uuid references public.contacts(id) on delete set null, lead_id uuid references public.crm_leads(id) on delete set null, conversation_id uuid references public.conversations(id) on delete set null, service_boundary jsonb, message_id uuid not null default gen_random_uuid(), attempted_at timestamptz, error text, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), foreign key (organization_id, campaign_id) references public.prospecting_campaigns(organization_id,id) on delete cascade, unique (organization_id, place_id) ); create unique index if not exists prospecting_phone_once_org on public.prospecting_candidates(organization_id,phone) where phone is not null; create index if not exists prospecting_pending_campaign on public.prospecting_candidates(organization_id,campaign_id,status); create index if not exists prospecting_conversation on public.prospecting_candidates(organization_id,conversation_id) where conversation_id is not null; alter table public.prospecting_settings enable row level security; alter table public.prospecting_campaigns enable row level security; alter table public.prospecting_candidates enable row level security; revoke all on public.prospecting_settings, public.prospecting_campaigns, public.prospecting_candidates from public, anon, authenticated; grant all on public.prospecting_settings, public.prospecting_campaigns, public.prospecting_candidates to service_role; notify pgrst, 'reload schema'; -- Migration 0370: native prospecting redaction and suppression -- 0370: Redact discovery data through the canonical contact cascade. -- Suppression tokens are pseudonymous, server-only and used exclusively to -- refuse re-import. The API explicitly selects public fields and never exposes them. alter table public.prospecting_candidates add column if not exists suppression_salt bytea; alter table public.prospecting_candidates add column if not exists suppression_place bytea; alter table public.prospecting_candidates add column if not exists suppression_phone bytea; create index if not exists prospecting_suppressed_org on public.prospecting_candidates(organization_id) where suppression_salt is not null; create or replace function public.fn_prospecting_refuse_erased_candidate() returns trigger language plpgsql security definer set search_path = public, extensions, pg_temp as $$ begin if exists ( select 1 from public.prospecting_candidates p where p.organization_id = new.organization_id and p.suppression_salt is not null and (p.suppression_place = hmac(convert_to(new.place_id, 'UTF8'), p.suppression_salt, 'sha256') or (new.phone is not null and p.suppression_phone = hmac(convert_to(new.phone, 'UTF8'), p.suppression_salt, 'sha256'))) ) then return null; end if; return new; end; $$; revoke all on function public.fn_prospecting_refuse_erased_candidate() from public, anon, authenticated; grant execute on function public.fn_prospecting_refuse_erased_candidate() to service_role; drop trigger if exists prospecting_refuse_erased on public.prospecting_candidates; create trigger prospecting_refuse_erased before insert on public.prospecting_candidates for each row execute function public.fn_prospecting_refuse_erased_candidate(); -- ---- as duas grafias do nono dígito, em UM lugar ---- -- -- Mesma regra de `lib/channels/phone-variants.ts`, e o SQL dela já existia -- COPIADO dentro de `fn_aviso_de_caso_*`. Uma terceira cópia é como regra de -- telefone diverge: alguém corrige uma e não sabe das outras. Aqui ela vira -- função, e o expurgo de LGPD abaixo é o primeiro a consumi-la. -- -- Por que comparar por VARIANTE e não pela string: o mesmo celular é gravado -- com e sem o nono dígito por caminhos diferentes (cadastro à mão, importação, -- o que o WhatsApp devolve). Comparar a string crua deixa a pessoa no banco -- porque uma ponta tem um `9` a mais — e, em expurgo, não alcançar é violação. -- -- A direção que REMOVE o nono confere o que sobra (`6-9` na primeira posição), -- como o TypeScript faz: sem isso, um `9` grudado num fixo geraria o número -- REAL de outra pessoa, e alcançar terceiro em expurgo é o erro oposto. create or replace function public.fn_telefone_variantes(p_telefone text) returns text[] language sql immutable set search_path to 'public', 'pg_temp' as $$ with d as (select regexp_replace(coalesce(p_telefone, ''), '\D', '', 'g') as v) select case when d.v = '' then array[]::text[] when d.v not like '55%' then array[d.v] when length(d.v) = 13 and substring(d.v from 5 for 1) = '9' and substring(d.v from 6 for 1) between '6' and '9' then array[d.v, substring(d.v from 1 for 4) || substring(d.v from 6)] when length(d.v) = 12 and substring(d.v from 5 for 1) between '6' and '9' then array[d.v, substring(d.v from 1 for 4) || '9' || substring(d.v from 5)] else array[d.v] end from d; $$; -- Função nova em `public` nasce alcançável pelas DUAS origens (o grant a PUBLIC -- que o Postgres dá, e o default privilege do baseline para `anon`): as duas -- saem, e só quem precisa entra. revoke execute on function public.fn_telefone_variantes(text) from public, anon; grant execute on function public.fn_telefone_variantes(text) to service_role; CREATE OR REPLACE FUNCTION "public"."fn_lgpd_cascade_redact_contact"("p_organization_id" "uuid", "p_contact_id" "uuid", "p_request_id" "uuid") RETURNS "jsonb" LANGUAGE "plpgsql" SECURITY DEFINER SET "search_path" TO 'public', 'extensions', 'pg_temp' AS $$ declare v_already bool; v_counts jsonb := '{}'::jsonb; v_media_paths text[] := '{}'; v_anon_label text; v_count int; -- As grafias do telefone desta pessoa, capturadas ANTES de o passo 1 zerar -- `contacts.phone_number`. A ordem aqui não é detalhe: o expurgo da -- prospecção roda ~150 linhas depois do `update contacts`, e ler o telefone -- lá embaixo leria NULL — o braço por telefone existiria no código e não -- alcançaria linha nenhuma, que é pior que não existir, porque parece feito. v_variantes text[] := '{}'; begin perform public.fn_service_lock(p_organization_id,p_contact_id); select is_anonymized into v_already from contacts where id = p_contact_id and organization_id = p_organization_id; if not found then raise exception 'contact not found' using errcode = 'P0002'; end if; if v_already then return jsonb_build_object('already_anonymized', true, 'counts', v_counts, 'media_paths', v_media_paths); end if; v_anon_label := 'Cliente Anonimizado #' || substring(p_contact_id::text from 1 for 8); -- Capturado AGORA, enquanto o telefone ainda existe (o passo 1 o apaga). select coalesce(public.fn_telefone_variantes(phone_number), '{}') into v_variantes from contacts where id = p_contact_id and organization_id = p_organization_id; -- Collect media storage paths (we only delete what we own — media_storage_path) select coalesce(array_agg(distinct media_storage_path) filter (where media_storage_path is not null), '{}') into v_media_paths from messages where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ); -- 1. contacts (irreversible) update contacts set name = v_anon_label, display_name = v_anon_label, email = null, -- email_normalized NÃO entra: é GENERATED ALWAYS AS (lower(trim(email))) -- e o Postgres recusa escrita nela — a linha acima já a zera por derivação. -- Com a atribuição, o cascade INTEIRO abortava e nada era anonimizado. phone_number = null, cpf_encrypted = null, cpf_hash = null, birthdate = null, is_anonymized = true, anonymized_at = now(), consent = '{}'::jsonb, source_metadata = '{}'::jsonb, tags = '{}'::text[], updated_at = now() where id = p_contact_id and organization_id = p_organization_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('contacts', v_count); -- 2. conversations metadata + preview strip update conversations set metadata = '{}'::jsonb, last_message_preview = null, -- O motivo CRU da última passagem (migration 0291). É código de -- vocabulário, não texto livre — mas ele diz que ESTA pessoa foi escalada -- por irritação, por assunto jurídico ou por suspeita de opt-out, e isso é -- um fato sobre ela. Entra NESTE update, e não num segundo: mesmo -- predicado, mesmas linhas, metade das varreduras. -- -- ⚠️ `last_handoff_reason` é CHAVE DE NEGÓCIO em outro módulo: a ponte de -- voz limpa o silêncio filtrando pelo VALOR da coluna -- (`lib/wacalls/events-bridge.ts`). Zerá-la num contato anonimizado é -- seguro — não há chamada viva de contato anonimizado — e é a razão de -- esta entrega NÃO usar essa coluna para texto rico: ela continua -- recebendo só o código, e o texto vive em `passagens_de_atendimento`. last_handoff_reason = null, updated_at = now() where contact_id = p_contact_id and organization_id = p_organization_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('conversations', v_count); -- 3. messages: redact body + null media + strip metadata (preserve status/timestamps/conversation_id) update messages set body = '[mensagem anonimizada]', media_url = null, media_mime = null, media_size_bytes = null, media_storage_path = null, metadata = '{}'::jsonb, updated_at = now() where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('messages', v_count); -- 4. crm_lead_activities — strip payload, metadata E reason (migration 0071). -- `reason` é texto livre escrito por LLM sobre a conversa do lead: supor que -- nunca conterá um nome é a suposição que falha. `evidence` NÃO é limpa — -- guarda só ids, e as linhas apontadas são redigidas por conta própria. update crm_lead_activities set payload = '{}'::jsonb, metadata = '{}'::jsonb, reason = null where organization_id = p_organization_id and ( contact_id = p_contact_id or lead_id in ( select lead_id from crm_lead_links where target_kind = 'contact' and target_id = p_contact_id and organization_id = p_organization_id ) or lead_id in ( select id from crm_leads where contact_id = p_contact_id and organization_id = p_organization_id ) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('activities', v_count); -- 5. crm_leads — strip title/description/custom_fields/source_metadata/tags but PRESERVE pipeline/stage/value update crm_leads set title = v_anon_label, description = null, custom_fields = '{}'::jsonb, source_metadata = '{}'::jsonb, tags = '{}'::text[], updated_at = now() where organization_id = p_organization_id and ( contact_id = p_contact_id or id in ( select lead_id from crm_lead_links where target_kind = 'contact' and target_id = p_contact_id and organization_id = p_organization_id ) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('leads', v_count); -- 6. orders — PRESERVE values + status + timestamps. Strip personal fields from payload jsonb -- and replace customer_external_id with null (FK-safe; soft de-link). Keep contact_id null. update orders set payload = (coalesce(payload, '{}'::jsonb)) - 'customer' - 'customer_name' - 'customer_email' - 'customer_phone' - 'shipping_address' - 'billing_address' - 'contact_identification', customer_external_id = null, contact_id = null, is_anonymized = true, updated_at = now() where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('orders', v_count); -- CAMPANHAS: o que foi DITO à pessoa e o endereço para onde foi. -- -- `rendered_body` é a mensagem que ela recebeu e `recipient_address` o -- telefone. Sem esta limpeza, anonimizar devolveria SUCESSO deixando a -- prospecção legível — falha muda, com o SLA marcado como cumprido. -- A LINHA FICA: ela é a prova de que a pessoa esteve naquela campanha, e -- apagá-la desfaria a contagem de quem recebeu. update campaign_recipients set rendered_body = null, recipient_address = null, variables = '{}'::jsonb, last_error_detail = null, updated_at = now() where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('campaign_recipients', v_count); -- LISTA DE EXCLUSÃO: solta o vínculo e apaga a cauda do telefone. -- -- O HASH do endereço PERMANECE de propósito: é ele que faz o "não me mande -- mais" continuar valendo depois da anonimização. Apagá-lo faria a pessoa -- voltar a receber campanha. update campaign_suppressions set address_tail = null, reason = null, contact_id = null where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('campaign_suppressions', v_count); -- REAPLICADO AO DERIVAR ESTE APÊNDICE (merge da main, 0359 comanda). -- O Postgres troca o corpo INTEIRO num `create or replace`: um apêndice -- escrito sobre uma versão anterior da função APAGA, em silêncio, o passo -- que outra entrega acrescentou. Anonimizar devolveria SUCESSO com o texto -- da comanda ainda legível — e o SLA marcado como cumprido. -- 6b. sales — a comanda. PRESERVA valor, status e datas, e NÃO desliga o -- contato: a venda é registro financeiro (e fiscal) da organização, e -- desligá-la do contato faria o relatório por cliente deixar de fechar -- com o faturamento do período — divergência muda, meses depois, num -- número que ninguém consegue reconciliar. O contato apontado já é -- `Cliente Anonimizado #N`; o que sai daqui é o TEXTO LIVRE, que é onde -- a pessoa é nomeada de novo ("cliente da Ana, filha da Dona Maria"). -- Os itens (`sale_items`) não entram: `description` ali é o nome do -- SERVIÇO, congelado na inclusão, e apagá-lo destruiria o relatório por -- serviço sem tirar dado de pessoa nenhum. update sales set notes = null, cancel_reason = case when cancel_reason is null then null else '[redigido]' end, reverse_reason = case when reverse_reason is null then null else '[redigido]' end, updated_at = now() where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('sales', v_count); -- 7. enqueue media for async deletion (idempotent via unique (bucket, object_path)) if array_length(v_media_paths, 1) > 0 then insert into storage_redaction_queue (organization_id, request_id, bucket, object_path) select p_organization_id, p_request_id, 'whatsapp-media', path from unnest(v_media_paths) as path where path is not null and length(path) > 0 on conflict (bucket, object_path) do nothing; end if; -- 7b. voice_calls — o TELEFONE de quem falou ao telefone (migration 0235). -- -- `peer_phone` é `not null` e guarda o número da outra ponta: depois de -- anonimizar o contato, ele sobrevivia ligado ao `contact_id` e reidentificava -- a pessoa que pediu para ser esquecida. É o mesmo argumento que a foto de -- perfil já tinha (ver o bloco do avatar em `lib/lgpd/redact-cascade.ts`): -- anonimizar em toda parte menos numa é não ter anonimizado. -- -- O que fica: direção, status, motivo do fim, marcas de tempo e duração. Um -- registro de "houve uma chamada de 12 minutos" sem número e sem dono não -- identifica ninguém e é o que sustenta a métrica do atendente e a fatura. -- `peer_phone` é NOT NULL, então recebe o rótulo, não `null`. update voice_calls set peer_phone = v_anon_label, owner_user_id = null, created_by = null, updated_at = now() where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('voice_calls', v_count); -- Native discovery stores commercial/person data before the Inbox exists. -- Keep only keyed suppression tokens, restricted to the server, to prevent -- another extraction from reintroducing this erased candidate. -- -- O PREDICADO ALCANÇA POR VÍNCULO **OU** POR TELEFONE, e o segundo braço é o -- que conserta um buraco real: quando o telefone raspado já pertencia a um -- contato conhecido da organização, `lib/prospecting/store.ts` grava o -- candidato como `skipped` e DEIXA `contact_id` nulo de propósito (lá o -- vínculo é o freio de mão do envio, em `worker.ts`). Só pelo `contact_id`, -- essa pessoa — justamente a que a empresa já conhece — pedia exclusão, -- recebia sucesso, a auditoria gravava `lgpd.redact_executed`, e o nome, o -- telefone e o endereço dela seguiam legíveis aqui. -- -- Em expurgo os dois erros não têm o mesmo preço: alcançar demais custa um -- registro de prospecção descartado; alcançar de menos é violação legal. Por -- isso o `or`, e por isso a comparação por VARIANTE do nono dígito. update prospecting_candidates set suppression_salt = gen_random_bytes(32) where organization_id = p_organization_id and (contact_id = p_contact_id or (phone is not null and regexp_replace(phone, '\D', '', 'g') = any (v_variantes))) and suppression_salt is null; update prospecting_candidates set suppression_place = hmac(convert_to(place_id, 'UTF8'), suppression_salt, 'sha256'), suppression_phone = case when phone is null then null else hmac(convert_to(phone, 'UTF8'), suppression_salt, 'sha256') end, place_id = 'redacted:' || id::text, phone = null, data = jsonb_build_object('key', 'redacted:' || id::text, 'name', v_anon_label, 'phone', null, 'website', null, 'category', null, 'address', null, 'maps_url', null, 'rating', null, 'reviews', null, 'emails', '[]'::jsonb, 'socials', '[]'::jsonb), status = 'skipped', service_boundary = null, error = null, updated_at = now() -- MESMO predicado do bloco anterior. Se os dois divergirem, a linha alcançada -- por um e não pelo outro fica com `suppression_salt` semeado e os dados -- pessoais intactos — um estado que parece tratado e não está. where organization_id = p_organization_id and (contact_id = p_contact_id or (phone is not null and regexp_replace(phone, '\D', '', 'g') = any (v_variantes))); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('prospecting_candidates', v_count); -- agent_cases — o que a IA escreveu SOBRE a pessoa quando travou (migration 0280). -- -- O caso é o texto que a equipe lê antes de decidir: `title`, `summary` e -- `blocker` saem do modelo a partir da conversa, e `context_snapshot` é o -- recorte dessa conversa que o motor mandou para ele. Nada disso é registro de -- operação — é o relato do problema de uma pessoa identificável, escrito por -- máquina. Sem este passo, anonimizar devolvia SUCESSO com o relato intacto. -- -- As três colunas de texto são `not null`: recebem rótulo e texto fixo, nunca -- `null` (a mesma razão de `voice_calls.peer_phone` logo acima). -- -- ⚠️ `updated_at` FICA FORA DO `set`, de propósito. O cobrador de caso parado -- (`app/api/v1/cron/case-stale-watcher/route.ts`) lê `updated_at` como "alguém -- da equipe encostou neste caso". A cascata não é alguém encostando: escrever -- ali faria a anonimização ADIAR a cobrança de um caso que continua parado, e -- o efeito só apareceria como um cliente esperando mais tempo. -- -- O vínculo é pela CONVERSA porque `agent_cases` não tem FK para `contacts`. update agent_cases set title = v_anon_label, summary = '[resumo anonimizado]', blocker = '[bloqueio anonimizado]', context_snapshot = '{}'::jsonb where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('agent_cases', v_count); -- agent_case_events — a linha do tempo do caso (migration 0280). -- -- `body` é o que a pessoa da equipe escreveu ao responder o caso e o que o -- agente registrou sobre o que o LEAD respondeu; `metadata` carrega o recorte -- que o motor anexou. `kind`, `actor_kind`, `human_action` e `created_at` -- FICAM: são o registro de que houve um toque humano e quando — operação, não -- dado da pessoa, e é deles que sai a métrica de atendimento. update agent_case_events set body = null, metadata = '{}'::jsonb where organization_id = p_organization_id and case_id in ( select id from agent_cases where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('agent_case_events', v_count); -- demandas — o assunto do pedido (migration 0280). -- -- `assunto` é texto livre sobre o que a pessoa pediu. O resto da linha é a -- operação da demanda (origem, estado, dono, prazo, desfecho) e fica de pé: -- apagar a linha inteira tiraria da organização a resposta a "quantos pedidos -- houve em março", que é o mesmo argumento do compromisso da agenda. -- -- FK direta (`demandas.contact_id` é `not null`), então o vínculo é o contato. update demandas set assunto = null where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('demandas', v_count); -- agent_inbox_items — o aviso que leva o texto do caso para a Central (migration 0280). -- -- O `body` do aviso de caso parado EMBUTE o título do caso -- (`app/api/v1/cron/case-stale-watcher/route.ts:128`), e o do handoff embute o -- motivo da parada (`lib/ai/handoff/orchestrator.ts:335`). Redigir o caso e -- deixar o aviso de pé seria anonimizar em toda parte menos numa — que é não -- ter anonimizado. O molde (resolver + trocar o corpo + soltar a referência) é -- o de `fn_meet_redact_contact`, que já faz isto para o aviso de compromisso. -- -- ⚠️ O VÍNCULO É POLIMÓRFICO E TEM TRÊS BRAÇOS, não dois. Medido nos -- produtores, não suposto: `handoff` nasce com `ref_kind='contact'` -- (`lib/ai/handoff/orchestrator.ts:339`) E com `ref_kind='conversation'` -- (`lib/agent-engine/agent/inbound-turn.ts:4100`); `case_stale` nasce SEMPRE -- com `ref_kind='agent_case'` (a rota do cron acima, e a política em -- `lib/ai/inbox-destino.ts:38`). Um predicado com só os dois primeiros braços -- casa ZERO avisos de caso parado — e casar zero linha não é erro: é sucesso -- com o texto intacto. -- -- Os `kind` são os MEDIDOS no CHECK vigente (`supabase/baseline.sql`, bloco -- único de `agent_inbox_items_kind_check`). `case_opened` NÃO existe, e kind -- inexistente num `in (...)` também casa zero e devolve sucesso. Para -- reconferir sem acreditar nesta prosa: -- grep -n "agent_inbox_items_kind_check check" -A40 supabase/baseline.sql update agent_inbox_items set status = 'resolved', resolved_at = now(), body = 'Contato anonimizado.', ref_id = null where organization_id = p_organization_id -- `aviso_de_caso_nao_entregue` (migration 0292) entra AQUI e não num -- passo próprio: é o mesmo predicado polimórfico, e o braço -- `ref_kind='agent_case'` já alcança o caso do titular. O corpo do aviso -- embute o título do caso, que é texto sobre a pessoa. and kind in ('handoff', 'case_stale', 'aviso_de_caso_nao_entregue') and ( (ref_kind = 'contact' and ref_id = p_contact_id) or (ref_kind = 'conversation' and ref_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id )) or (ref_kind = 'agent_case' and ref_id in ( select id from agent_cases where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ) )) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('agent_inbox_items', v_count); -- agent_case_chat_messages — a consulta interna da equipe à IA SOBRE o caso -- (migration 0281). FK DIRETA para `contacts`, então o vínculo é o titular e -- não precisa passar pela conversa. -- -- `redacted_at is null` no `where` é o que torna o passo IDEMPOTENTE: a -- varredura diária de redações incompletas roda a função de novo, e sem essa -- condição o carimbo de QUANDO se apagou seria reescrito a cada rodada. -- -- A linha NÃO é apagada, só o texto: quem abrir o caso depois continua vendo -- que a equipe perguntou N vezes, quando, e se a IA respondeu. Apagar a linha -- inteira ficaria verde num teste de "o texto sumiu" e tiraria da organização -- a resposta a "quanto a equipe deliberou sobre este caso". update agent_case_chat_messages set body = null, redacted_at = now() where organization_id = p_organization_id and contact_id = p_contact_id and redacted_at is null; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('agent_case_chat_messages', v_count); -- passagens_de_atendimento — o BRIEFING é sobre a pessoa (migration 0291). -- -- A linha guarda o que a IA concluiu sobre um atendimento de alguém -- identificável: o que ela entendeu que a pessoa quer (`title`), a narrativa -- que quem assumiu leu (`body`), as PALAVRAS LITERAIS do cliente (`notes`), o -- texto livre de quem passou (`content`) e o que a IA já tinha tentado -- (`tentativas`). Nada disso é registro de operação — é o relato do problema -- de uma pessoa, escrito por máquina, na tela de quem vai responder. -- -- `body` é `not null` e recebe o RÓTULO, não `null` — a mesma razão de -- `voice_calls.peer_phone` e de `agent_cases.title` acima: coluna obrigatória -- anulada aborta o cascade INTEIRO, e um cascade abortado não anonimiza nada. -- -- O que FICA, de propósito: `motor`, `origem`, `motivo_codigo`, -- `cliente_avisado`, `aviso_motivo_codigo`, `criado_em` e o par de -- reconhecimento. São operação — quantas passagens houve, por quê, quanto -- tempo até alguém assumir. Um passo que apagasse a linha inteira ficaria -- verde num teste de "o texto sumiu" e tiraria da organização a resposta a -- "quantos atendimentos a IA devolveu em março, e quanto tempo esperaram". -- -- O vínculo é a FK DIRETA `contact_id`: a tabela a carrega exatamente para -- este passo não precisar passar pela conversa. update passagens_de_atendimento set body = v_anon_label, title = null, notes = null, content = null, tentativas = '[]'::jsonb where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('passagens_de_atendimento', v_count); -- entregas_de_aviso_de_caso — o registro do aviso ao suporte (migration 0292). -- -- A tabela NÃO guarda o texto do aviso (só `corpo_hash`), e a única coluna -- capaz de ecoar um dado da pessoa é `erro_detalhe`: ali vai o texto CRU que -- o transporte devolveu, truncado, e um provedor que recusa um envio costuma -- devolver o destinatário dentro da mensagem de erro. -- -- O que FICA, de propósito: `status`, `erro_codigo`, `tentativas`, -- `enviado_em`, `destino`, `corpo_hash`. São operação — quantos avisos saíram, -- quantos falharam e por quê. Um passo que apagasse a linha inteira ficaria -- verde num teste de "o texto sumiu" e tiraria da organização a resposta a -- "quantos avisos não chegaram em março". `destino` é o telefone da EQUIPE, -- não do titular: anonimizar um cliente não apaga o número do plantão. -- -- ⚠️ PONTO CEGO DECLARADO: `tests/invariants/lgpd-cascata-alcanca-quem- -- guarda-pessoa.test.ts` só cobra tabela com FK para `contacts` E coluna cujo -- NOME case o padrão de PII. Esta tabela não satisfaz nenhuma das duas — o -- gate ficaria VERDE sem este passo. Ele entra porque é certo, não porque o -- gate cobra, e isto está escrito aqui para a próxima sessão não o remover -- achando que é ornamento. Quem o vigia é a catraca -- `tests/invariants/cascata-lgpd-nao-encolhe.test.ts`. -- -- O vínculo é pela CONVERSA, como o de `agent_cases`: esta tabela aponta para -- o caso, e o caso não tem FK para `contacts`. update entregas_de_aviso_de_caso set erro_detalhe = null where organization_id = p_organization_id and case_id in ( select id from agent_cases where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('entregas_de_aviso_de_caso', v_count); -- 8. dense audit row insert into api_audit_log (organization_id, action, actor_user_id, resource_type, resource_id, metadata, bypassed_rls) values ( p_organization_id, 'lgpd.redact_executed', null, 'contact', p_contact_id, jsonb_build_object( 'cascaded_to', v_counts, 'media_queued', coalesce(array_length(v_media_paths, 1), 0), 'request_id', p_request_id ), true ); return jsonb_build_object( 'already_anonymized', false, 'counts', v_counts, 'media_paths', v_media_paths ); end; $$; revoke all on function public.fn_lgpd_cascade_redact_contact(uuid,uuid,uuid) from public,anon,authenticated; grant execute on function public.fn_lgpd_cascade_redact_contact(uuid,uuid,uuid) to service_role; notify pgrst, 'reload schema'; -- ---- Conversa de configuração da prospecção (migration 0371) ---- -- The administrator's unfinished setup belongs to the campaign, not to Inbox. -- Existing rows keep the empty default. Server-only RLS/grants remain unchanged. alter table public.prospecting_campaigns add column if not exists agent_setup jsonb not null default '{}'::jsonb, add column if not exists agent_setup_revision bigint not null default 0; notify pgrst, 'reload schema'; -- ---- provider "zernio_social" nos CHECKs de channel_sessions (migration 0368) ---- -- -- NÃO HÁ BLOCO AQUI, e a ausência é decisão: `zernio_social` foi somado ao -- bloco ÚNICO das duas constraints, lá em cima (procure por -- `channel_sessions_provider_check`). A doutrina é "uma constraint, um bloco" -- (`tests/unit/baseline-constraint-reconstruida.test.ts`), e ela existe por um -- motivo de produção: cada drop+add repetido é mais uma janela em que a tabela -- fica SEM constraint durante o `update.sh` de um cliente, e o último bloco a -- rodar é quem decide o vocabulário — dois blocos discordando viram um banco -- que recusa o canal novo com o script fechando verde. -- -- Este comentário fica no lugar do bloco porque foi exatamente aqui que a -- versão anterior deste PR o pôs, e a cerca reprovou (`2x` cada constraint). -- Quem vier somar o quinto provider: some no bloco de cima, não aqui. -- ---- remarcar um compromisso já enviado corrige o cliente (migration 0374) ---- -- Gatilho novo `trg_remarcar_corrige_o_envio` (BEFORE UPDATE): quem JÁ recebeu -- e teve `starts_at`/`time_zone` mudados ganha uma correção autorizada pela -- mesma pessoa, com espera de 2 min (`nao_antes_de`) para arrastar na grade não -- virar uma mensagem por arrasto. O enfileirador carrega o `motivo` ao payload. -- ⚠️ ANTES DA VARREDURA anon. Idempotente. -- Recorte do PR #803, de @paulolimajr77. create or replace function public.fn_remarcar_corrige_o_envio() returns trigger language plpgsql security definer set search_path=public as $$ begin if tg_op <> 'UPDATE' then return new; end if; -- Cancelado não recebe correção: avisar cancelamento é outra funcionalidade, e -- mandar "o horário mudou" de um compromisso que não existe mais é pior que calar. if new.status = 'cancelled' then return new; end if; -- Só quem JÁ recebeu. Quem está em `waiting_for_link`/`queued` já vai sair com -- o horário novo sozinho, porque o texto é montado no envio. if coalesce(new.meeting_delivery->>'state','') <> 'sent' then return new; end if; -- APENAS os campos que entram no texto da mensagem. Reagir a qualquer `update` -- na linha faria uma edição de TÍTULO mandar mensagem ao cliente. if row(new.starts_at, new.time_zone) is not distinct from row(old.starts_at, old.time_zone) then return new; end if; new.meeting_delivery := jsonb_build_object( 'state','waiting_for_link', -- Geração nova: é ela que faz um job anterior reprovar na vigência e se -- cancelar sozinho, em vez de duas mensagens saírem. 'generation', gen_random_uuid(), 'service_boundary', old.meeting_delivery->'service_boundary', 'channel_session_id', old.meeting_delivery->>'channel_session_id', -- Quem autorizou o envio original autoriza a correção: é a mesma intenção, -- corrigida. E a vigência RECONFERE no envio se essa pessoa ainda é a -- responsável e ainda tem papel — se não for, a correção não sai e abre aviso -- na Central, que é o comportamento certo. 'authorized_by', old.meeting_delivery->'authorized_by', 'source_operation_id', gen_random_uuid(), 'motivo','remarcado', 'nao_antes_de', (now() + interval '2 minutes')::text); new.meeting_delivery_job_id := null; return new; end;$$; revoke execute on function public.fn_remarcar_corrige_o_envio() from public, anon, authenticated; drop trigger if exists trg_remarcar_corrige_o_envio on public.calendar_appointments; create trigger trg_remarcar_corrige_o_envio before update on public.calendar_appointments for each row execute function public.fn_remarcar_corrige_o_envio(); create or replace function public.fn_meet_delivery_enqueue() returns trigger language plpgsql security definer set search_path=public as $$ declare jid uuid; b jsonb; begin -- A MESMA ORDEM DE TRAVA das ~20 irmãs: contato PRIMEIRO, job_queue depois. -- Sem esta linha, este gatilho já segurava a linha do compromisso (é BEFORE/ -- AFTER na própria calendar_appointments) e ia travar job_queue sem o mutex do -- contato, enquanto fn_meet_redact_contact (0229) pega o mutex do contato e só -- então mexe em job_queue. Duas ordens opostas sobre os mesmos dois recursos = -- deadlock (40P01) sob concorrência, e quem paga é o cliente com anonimização -- LGPD acontecendo enquanto um link de reunião é entregue. perform public.fn_service_lock(new.organization_id,new.contact_id); -- ⚠️ `status` ENTRA AQUI, e a falta dele era um buraco REAL que só apareceu -- ao abrir a entrega para compromisso sem Meet. -- -- A guarda olhava só `meeting_state='cancelled'` — o estado do LINK, não do -- compromisso. Enquanto a entrega exigia link pronto isso bastava por -- acidente: cancelar o compromisso cancelava o link junto. Sem Meet não há -- link para cancelar, e um compromisso CANCELADO passava a enfileirar -- entrega. O porteiro do envio recusaria depois (`a.status<>'cancelled'`), -- então o cliente não receberia nada — mas o job nasceria para morrer -- bloqueado, e a tela mostraria uma entrega a caminho que nunca sai. -- -- Achado do @paulolimajr77, e foi o teste DELE que o pegou aqui. if new.status='cancelled' or new.meeting_state='cancelled' or new.meeting_delivery->>'state' in ('blocked','stale') then update public.job_queue set status='failed',locked_at=null,locked_by=null,payload='{}',last_error='meet_delivery_stale' where organization_id=new.organization_id and id=new.meeting_delivery_job_id and kind='transactional_delivery' and status in ('pending','running'); return new; end if; if new.meeting_state='failed' then perform public.fn_meet_notice(new.organization_id,new.id,'meeting_failed');end if; -- ⛔ ESPERAR O LINK VALE SÓ ONDE O LOCAL É O MEET. -- -- Esta é a exigência mais fácil de esquecer e a pior de esquecer: num -- compromisso PRESENCIAL o `meeting_state` é `not_requested` para sempre, -- então a entrega era autorizada, o gatilho passava por aqui, devolvia sem -- enfileirar nada, e a entrega ficava em `waiting_for_link` PARA SEMPRE — em -- silêncio, sem job, sem aviso e sem erro. Foi o teste do autor que a achou. -- -- Onde o local É o Meet, nada muda: sem link pronto não sai job, porque -- mandar uma reunião sem como entrar nela é pior que não mandar. if (new.location_kind='google_meet' and new.meeting_state<>'ready') or new.meeting_delivery->>'state'<>'waiting_for_link' then return new;end if; b:=new.meeting_delivery->'service_boundary'; if not public.fn_meet_boundary_current(b) then update public.calendar_appointments set meeting_delivery=meeting_delivery||'{"state":"stale","error":"service_boundary_stale"}' where organization_id=new.organization_id and id=new.id; perform public.fn_meet_notice(new.organization_id,new.id,'service_boundary_stale');return new; end if; jid:=gen_random_uuid(); insert into public.job_queue(id,organization_id,contact_id,kind,payload,run_after) values(jid,new.organization_id,new.contact_id,'transactional_delivery',jsonb_build_object('appointment_id',new.id,'meeting_request_id',new.meeting_request_id, 'delivery_generation',new.meeting_delivery->>'generation','service_boundary',b, -- O MOTIVO decide a FRASE que o cliente lê. Ausente = `primeiro_envio`, -- que é o comportamento de antes desta migration e o certo para toda -- entrega que já estava na fila quando ela foi aplicada. 'motivo',coalesce(new.meeting_delivery->>'motivo','primeiro_envio')), -- ANTIRREPETIÇÃO: a correção ESPERA antes de sair, e uma remarcação nova -- dentro da janela substitui esta. Sem a espera, arrastar o compromisso na -- grade viraria uma mensagem por arrasto. coalesce((new.meeting_delivery->>'nao_antes_de')::timestamptz, now())); update public.calendar_appointments set meeting_delivery_job_id=jid,meeting_delivery=meeting_delivery||'{"state":"queued"}' where organization_id=new.organization_id and id=new.id; return new; end;$$; revoke all on function public.fn_meet_delivery_enqueue() from public,anon,authenticated; -- ---- Cache da hierarquia do anúncio (migration 0380) ---- -- Nome do anúncio, do conjunto e da campanha por id de anúncio. Existe porque a -- conta de anúncios opera em cota baixa e um único anúncio gera centenas de -- contatos: sem cache, cada ficha aberta repetiria a mesma pergunta. Mesmo -- desenho server-side-only de ad_insights_connections (0214); ver o cabeçalho da -- migration 0380 para o racional completo. create table if not exists public.ad_hierarchy_cache ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, platform text not null, ad_id text not null, ad_name text, adset_id text, adset_name text, campaign_id text, campaign_name text, fetched_at timestamptz not null default now(), created_at timestamptz not null default now(), updated_at timestamptz not null default now(), constraint ad_hierarchy_cache_platform_conhecida check (platform in ('meta_ads', 'google_ads')) ); create unique index if not exists ad_hierarchy_cache_org_platform_ad_uk on public.ad_hierarchy_cache (organization_id, platform, ad_id); comment on table public.ad_hierarchy_cache is 'Nome do anúncio, do conjunto e da campanha, guardados por id de anúncio. Existe porque a conta de anúncios opera em cota baixa e um único anúncio gera centenas de contatos: sem cache, cada ficha aberta gastaria uma chamada para repetir a mesma pergunta. Server-side only: RLS ligada sem policies e grants revogados de anon/authenticated.'; comment on column public.ad_hierarchy_cache.ad_id is 'O identificador do anúncio na plataforma — o mesmo que a ingestão grava em contacts.source_metadata.ad_id. Sem FK: o anúncio é da plataforma e pode ser apagado lá sem aviso.'; comment on column public.ad_hierarchy_cache.fetched_at is 'Quando a hierarquia foi lida da plataforma. A idade aceitável é decisão do código que lê, não do schema: ela muda com o degrau de cota da conta, e não com a forma do dado.'; alter table public.ad_hierarchy_cache enable row level security; revoke all on public.ad_hierarchy_cache from anon, authenticated; grant select, insert, update, delete on public.ad_hierarchy_cache to service_role; drop trigger if exists trg_ad_hierarchy_cache_updated_at on public.ad_hierarchy_cache; create trigger trg_ad_hierarchy_cache_updated_at before update on public.ad_hierarchy_cache for each row execute function public.fn_set_updated_at(); -- ---- campanhas (migration 0375) ---- -- 0375 — CAMPANHAS (Sub-PRD 12 / Spec 12 / Spec 13) -- -- ═══ O que nasce aqui, e o que deliberadamente NÃO nasce ═══ -- -- Duas tabelas: a campanha e o destinatário. Nenhuma tabela de proteção de envio, -- nenhuma tabela de template, nenhuma suppression list. -- -- * Proteção de envio (Spec 13 §4.1 pede `channel_send_protection`): já existe -- neste repo e tem tela — `channel_knobs` (throttle, jitter, janela, domingo, -- fuso, warm-up) mais `channel_sessions.daily_message_limit`, editados pela -- `AntiBanSheet` (cujo título é, literalmente, "Proteção de envio"), e -- respeitados por `decidePacing` com contador real em `pacing_ledger`. Criar a -- tabela da spec daria à mesma instalação DUAS janelas e DOIS tetos por -- conexão, e alguém teria de decidir qual ganha em cada caminho de envio — o -- anti-pattern nº 2 do CLAUDE.md (duplicação sem source of truth). O que a -- campanha ganha aqui é só o OVERRIDE dela, sempre mais restritivo que o canal. -- * Templates internos e suppression list ficam fora do MVP por decisão do dono -- do produto (2026-09-18). A coluna de conteúdo é uma só e é texto. -- * `message_mode`/`provider_template_*` (Spec 12 §2.1) não entram: este fork -- envia por WAHA, e coluna que ninguém escreve é promessa de recurso que não -- existe. Quando o canal oficial entrar, entra com a migration dele. -- -- ═══ Por que destinatário é LINHA e não lista em jsonb ═══ -- -- É ele que tem estado individual (pendente/enviado/pulado + motivo), unicidade -- (ninguém recebe duas vezes) e contagem para o relatório. Em jsonb, cada envio -- reescreveria o documento inteiro e duas rodadas concorrentes do cron perderiam -- uma da outra. -- -- ═══ Base legal não tem default ═══ -- -- Campanha sem base legal declarada não deve existir, e um default plausível aqui -- seria exatamente o buraco que a Regra nº 1 proíbe: pareceria configurado e não -- estaria. Interesse legítimo SEM a referência da LIA é o mesmo que nenhuma base -- legal — é a referência que permite responder "com base em quê você me mandou -- isto?" (vault: LIA-2026-01). O gate de LGPD da cadeia de envio -- (`lib/agent-engine/guardrails/lgpd/legal-basis.ts`) usa a mesma régua. create table if not exists public.campaigns ( id uuid primary key default uuid_generate_v4(), organization_id uuid not null references public.organizations(id) on delete cascade, name text not null, description text, -- Os nove estados da Spec 12 §7.1. As transições válidas vivem em -- `lib/campanhas/maquina-de-estados.ts` — CHECK aqui guarda o VOCABULÁRIO, não a -- ordem: uma matriz de transição em SQL exigiria trigger, e trigger que decide -- fluxo é lógica de produto fora do lugar onde ela é testável. status text not null default 'draft', -- `on delete restrict`: apagar o número que uma campanha usou apagaria o -- histórico de para quem ela falou. Quem quiser sumir com o número arquiva a -- campanha antes. channel_session_id uuid not null, message_body text, base_legal text not null, lia_ref text, audience_filter jsonb not null default '{}'::jsonb, -- Sobe a cada preparação nova. O destinatário guarda a versão do CONTEÚDO com -- que foi congelado; a da audiência distingue snapshots entre si. audience_version integer not null default 1, content_version integer not null default 1, -- ═══ Ritmo PRÓPRIO da campanha (Spec 13 §4.2) ═══ -- Todas nullable: null = herda do canal. O efetivo é sempre o MAIS RESTRITIVO -- entre campanha e canal — a campanha só sabe ir mais devagar, nunca mais -- rápido. Para lista FRIA o ritmo do canal não basta: 30 mensagens em 30 min do -- mesmo número, para quem nunca falou com a empresa, é o padrão que o WhatsApp -- bane, e número banido volta em semanas de warm-up, não em dias. intervalo_segundos integer, janela_inicio_hora smallint, janela_fim_hora smallint, teto_diario integer, teto_horario integer, scheduled_at timestamptz, prepared_at timestamptz, started_at timestamptz, paused_at timestamptz, completed_at timestamptz, cancelled_at timestamptz, failed_at timestamptz, failure_code text, failure_detail text, snapshot_total integer not null default 0, snapshot_eligible integer not null default 0, snapshot_excluded integer not null default 0, created_by uuid references auth.users(id) on delete set null, updated_by uuid references auth.users(id) on delete set null, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), constraint campaigns_status_check check (status in ( 'draft','preparing','ready','scheduled','running', 'paused','completed','cancelled','failed' )), constraint campaigns_name_check check (btrim(name) <> ''), constraint campaigns_base_legal_check check (base_legal in ('consent','legitimate_interest')), constraint campaigns_lia_exige_ref check ( base_legal <> 'legitimate_interest' or coalesce(btrim(lia_ref), '') <> '' ), -- Faixas de sanidade do ritmo. Não são o default de comportamento (esse mora em -- `lib/agent-engine/pacing/defaults.ts`, fonte única dos números de pacing): -- são o que a coluna aceita de um operador. constraint campaigns_intervalo_check check ( intervalo_segundos is null or intervalo_segundos between 1 and 86400 ), constraint campaigns_janela_check check ( (janela_inicio_hora is null and janela_fim_hora is null) or (janela_inicio_hora between 0 and 23 and janela_fim_hora between 1 and 24 and janela_fim_hora > janela_inicio_hora) ), constraint campaigns_teto_diario_check check (teto_diario is null or teto_diario between 1 and 10000), constraint campaigns_teto_horario_check check (teto_horario is null or teto_horario between 1 and 10000) ); -- FK composta pela doutrina multi-tenant: uma campanha não pode apontar para o -- número de OUTRA organização. Alvo é `uq_channel_sessions_org_id` (0262/0228). do $$ begin if not exists ( select 1 from pg_constraint where conname = 'campaigns_channel_org_fk' ) then alter table public.campaigns add constraint campaigns_channel_org_fk foreign key (organization_id, channel_session_id) references public.channel_sessions (organization_id, id) on delete restrict; end if; end $$; comment on table public.campaigns is 'Envio proativo a uma lista explícita de contatos, por um número. O ritmo próprio (intervalo/janela/tetos) é sempre mais restritivo que o do canal (channel_knobs + channel_sessions.daily_message_limit), nunca mais frouxo.'; comment on column public.campaigns.base_legal is 'Base legal do tratamento (LGPD art. 7º). Sem default de propósito: campanha sem base legal declarada não deve existir. `legitimate_interest` exige `lia_ref` — a referência da avaliação de interesse legítimo que responde "com base em quê você me mandou isto?".'; comment on column public.campaigns.content_version is 'Sobe quando o texto muda. O destinatário guarda a versão com que foi congelado, para edição futura não reescrever mensagem já preparada.'; create index if not exists idx_campaigns_org_status on public.campaigns (organization_id, status, created_at desc); -- A pergunta do scheduler: quais campanhas agendadas já venceram. create index if not exists idx_campaigns_agendadas on public.campaigns (scheduled_at) where status = 'scheduled'; create table if not exists public.campaign_recipients ( id uuid primary key default uuid_generate_v4(), organization_id uuid not null references public.organizations(id) on delete cascade, campaign_id uuid not null references public.campaigns(id) on delete cascade, contact_id uuid not null references public.contacts(id) on delete cascade, -- Preenchida no envio, não no snapshot: a conversa pode nem existir quando a -- lista é montada. conversation_id uuid references public.conversations(id) on delete set null, -- O telefone CONGELADO no snapshot. O contato continua sendo a fonte da verdade -- do CRM, mas a campanha não recalcula retroativamente para quem ela ia falar. recipient_address text, status text not null default 'pending', eligibility_status text not null default 'eligible', -- Código, não frase: a frase legível mora no TypeScript e é traduzida. exclusion_reason text, variables jsonb not null default '{}'::jsonb, rendered_body text, content_version integer not null default 1, message_id uuid references public.messages(id) on delete set null, attempt_count integer not null default 0, next_attempt_at timestamptz, last_attempt_at timestamptz, last_error_code text, last_error_detail text, queued_at timestamptz, sending_at timestamptz, sent_at timestamptz, delivered_at timestamptz, read_at timestamptz, replied_at timestamptz, opted_out_at timestamptz, cancelled_at timestamptz, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), constraint campaign_recipients_status_check check (status in ( 'pending','queued','sending','sent','delivered','read','replied', 'failed','skipped','cancelled','opted_out' )), constraint campaign_recipients_eligibility_check check ( eligibility_status in ('eligible','excluded') ), -- Ninguém recebe duas vezes: nem pelo mesmo cadastro, nem por dois cadastros -- gêmeos com o mesmo número. A segunda unicidade tolera NULL (excluído sem -- telefone não disputa endereço com ninguém). constraint campaign_recipients_contato_unico unique (campaign_id, contact_id), constraint campaign_recipients_endereco_unico unique (campaign_id, recipient_address) ); comment on table public.campaign_recipients is 'O snapshot: para quem a campanha IA falar, congelado na preparação, com o estado individual de cada envio. Fonte da verdade das métricas — os contadores em campaigns são cache.'; comment on column public.campaign_recipients.recipient_address is 'Telefone congelado no snapshot. Nunca sai em log (a doutrina proíbe PII em log); quem precisa correlacionar usa o id.'; -- A fila do worker: pendentes de UMA campanha, na ordem de entrada, respeitando -- reagendamento por ritmo. create index if not exists idx_campaign_recipients_fila on public.campaign_recipients (campaign_id, next_attempt_at, created_at) where status in ('pending', 'queued'); -- O caminho do ack: da mensagem de volta ao destinatário. create index if not exists idx_campaign_recipients_message on public.campaign_recipients (message_id) where message_id is not null; -- A pergunta da atribuição de resposta: o envio mais recente a este contato. create index if not exists idx_campaign_recipients_contato_envio on public.campaign_recipients (organization_id, contact_id, sent_at desc); -- A reconciliação de `sending` travado. create index if not exists idx_campaign_recipients_enviando on public.campaign_recipients (sending_at) where status = 'sending'; drop trigger if exists trg_campaigns_updated_at on public.campaigns; create trigger trg_campaigns_updated_at before update on public.campaigns for each row execute function public.fn_set_updated_at(); drop trigger if exists trg_campaign_recipients_updated_at on public.campaign_recipients; create trigger trg_campaign_recipients_updated_at before update on public.campaign_recipients for each row execute function public.fn_set_updated_at(); -- ═══ Entregue/lido: o ack da mensagem chega ao destinatário ═══ -- -- Não há hook de aplicação para mudança de status de mensagem: o trigger -- `trg_messages_emit_event` é AFTER **INSERT**, então UPDATE de status não emite -- evento nenhum (o `recover-stuck-messages` documenta isso e emite à mão). As -- alternativas eram polling no cron — que só descobre a entrega no tique seguinte -- e varre a tabela de mensagens — ou este trigger, que é local ao banco, roda na -- mesma transação do ack e não faz I/O externo (o anti-pattern nº 9 é trigger que -- fala HTTP; este não fala com ninguém). -- -- Regra dura: status analítico NUNCA retrocede. `read` não volta para `delivered`, -- e `replied`/`opted_out`/`cancelled` não voltam para nada — resposta é o desfecho -- mais forte, e um ack atrasado não pode desfazê-lo. create or replace function public.fn_campanha_sincroniza_ack() returns trigger language plpgsql security definer set search_path to 'public' as $$ begin if new.status is not distinct from old.status then return new; end if; update public.campaign_recipients r set delivered_at = case when new.status in ('delivered', 'read') then coalesce(r.delivered_at, new.delivered_at, now()) else r.delivered_at end, read_at = case when new.status = 'read' then coalesce(r.read_at, new.read_at, now()) else r.read_at end, sent_at = case when new.status in ('sent', 'delivered', 'read') then coalesce(r.sent_at, new.sent_at, now()) else r.sent_at end, status = case when r.status in ('replied', 'opted_out', 'cancelled') then r.status when new.status = 'read' then 'read' when new.status = 'delivered' and r.status in ('queued', 'sending', 'sent') then 'delivered' when new.status = 'sent' and r.status in ('queued', 'sending') then 'sent' when new.status = 'failed' and r.status in ('queued', 'sending', 'sent') then 'failed' else r.status end, last_error_code = case when new.status = 'failed' then coalesce(new.error_code, r.last_error_code) else r.last_error_code end, last_error_detail = case when new.status = 'failed' then coalesce(new.error_message, r.last_error_detail) else r.last_error_detail end, updated_at = now() where r.message_id = new.id; return new; end $$; comment on function public.fn_campanha_sincroniza_ack() is 'Trigger de messages: leva o ack do canal (sent/delivered/read/failed) ao campaign_recipients daquela mensagem. Status analítico nunca retrocede.'; -- As DUAS origens de EXECUTE (CLAUDE.md, doutrina de migrations item 9): o grant -- que o Postgres dá a PUBLIC ao criar, e o `alter default privileges ... to anon` -- do baseline, que vale para toda função criada depois dele. `authenticated` -- entra na lista pelo mesmo motivo. O trigger não depende de nenhum deles: a -- permissão de função de trigger é conferida na CRIAÇÃO do trigger, não a cada -- disparo. revoke execute on function public.fn_campanha_sincroniza_ack() from public, anon, authenticated; grant execute on function public.fn_campanha_sincroniza_ack() to service_role; drop trigger if exists trg_messages_sincroniza_campanha on public.messages; create trigger trg_messages_sincroniza_campanha after update of status on public.messages for each row when (new.direction = 'outbound') execute function public.fn_campanha_sincroniza_ack(); -- ═══ LGPD: o contato anonimizado não deixa telefone nem texto para trás ═══ -- -- `campaign_recipients` guarda telefone congelado e o corpo renderizado (que -- carrega o nome). Anonimizar o contato sem alcançar estas colunas devolveria -- SUCESSO com o dado legível — a pior falha possível numa obrigação legal, -- porque nada erra e nada loga. -- -- TRIGGER e não um 9º passo dentro de `fn_lgpd_cascade_redact_contact`, pelo -- mesmo motivo escrito no apêndice da 0174: aquela função tem 180 linhas e -- reescrevê-la aqui criaria duas cópias que divergem no primeiro conserto. O -- gancho é a transição `is_anonymized false → true`, que é o último fato da -- anonimização, roda na MESMA transação e alcança QUALQUER caminho que anonimize -- — inclusive os que não passam pela função. -- -- O endereço vira NULL e não texto redigido: com NULL, o destinatário pendente -- cai no veto `sem_telefone` do próximo despacho, e nenhuma mensagem sai para -- quem exerceu o direito de apagamento. Linha enviada continua contando nas -- métricas (contagem não é dado pessoal). create or replace function public.fn_redigir_campanhas_do_contato_anonimizado() returns trigger language plpgsql security definer set search_path = public, pg_temp as $$ begin if new.is_anonymized is true and coalesce(old.is_anonymized, false) is false then update public.campaign_recipients set recipient_address = null, rendered_body = null, variables = '{}'::jsonb, last_error_detail = null, updated_at = now() where organization_id = new.organization_id and contact_id = new.id; end if; return new; end; $$; revoke execute on function public.fn_redigir_campanhas_do_contato_anonimizado() from public, anon, authenticated; grant execute on function public.fn_redigir_campanhas_do_contato_anonimizado() to service_role; drop trigger if exists trg_redigir_campanhas_anonimizado on public.contacts; create trigger trg_redigir_campanhas_anonimizado after update of is_anonymized on public.contacts for each row execute function public.fn_redigir_campanhas_do_contato_anonimizado(); -- ═══ RLS ═══ -- -- Padrão da 0261: SELECT aberto ao tenant, ESCRITA a partir de `manager`. Policy -- `ALL` só-tenancy em tabela nova é reprovada por `rbac-config-ia-canais.test.ts` -- — e com razão: quem fala direto com o PostgREST usando o próprio JWT não passa -- pelo `requireRole()` das rotas, e disparar para uma lista de gente não é gesto -- de `viewer`. `manager` e não `admin` por decisão do dono (2026-09-18): na matriz -- do PRD §5.2 os dois operam campanha igual. alter table public.campaigns enable row level security; drop policy if exists tenant_isolation_campaigns_all on public.campaigns; drop policy if exists campaigns_select on public.campaigns; create policy campaigns_select on public.campaigns for select using ( (organization_id in (select public.fn_user_org_ids())) or public.fn_is_platform_admin() ); drop policy if exists campaigns_write on public.campaigns; create policy campaigns_write on public.campaigns using ( public.fn_is_platform_admin() or ((organization_id in (select public.fn_user_org_ids())) and public.fn_role_at_least(organization_id, 'manager')) ) with check ( public.fn_is_platform_admin() or ((organization_id in (select public.fn_user_org_ids())) and public.fn_role_at_least(organization_id, 'manager')) ); revoke all on public.campaigns from anon, authenticated; grant select on public.campaigns to authenticated; grant all on public.campaigns to service_role; alter table public.campaign_recipients enable row level security; drop policy if exists tenant_isolation_campaign_recipients_all on public.campaign_recipients; drop policy if exists campaign_recipients_select on public.campaign_recipients; create policy campaign_recipients_select on public.campaign_recipients for select using ( (organization_id in (select public.fn_user_org_ids())) or public.fn_is_platform_admin() ); drop policy if exists campaign_recipients_write on public.campaign_recipients; create policy campaign_recipients_write on public.campaign_recipients using ( public.fn_is_platform_admin() or ((organization_id in (select public.fn_user_org_ids())) and public.fn_role_at_least(organization_id, 'manager')) ) with check ( public.fn_is_platform_admin() or ((organization_id in (select public.fn_user_org_ids())) and public.fn_role_at_least(organization_id, 'manager')) ); revoke all on public.campaign_recipients from anon, authenticated; grant select on public.campaign_recipients to authenticated; grant all on public.campaign_recipients to service_role; -- ---- templates e lista de exclusão de campanha (migration 0376) ---- -- 0376 — TEMPLATES E LISTA DE EXCLUSÃO DE CAMPANHA (Spec 12 §2.3 e §2.4) -- -- As duas tabelas ficaram de fora da 0375 por decisão de escopo do dono -- (2026-09-18: "MVP é texto livre"). Entram agora, pedidas na tela, com a -- diferença de que não são mais projeto: cada uma resolve um problema medido. -- -- ═══ `campaign_templates` — a copy que sobrevive à campanha ═══ -- -- Hoje o texto vive dentro de UMA campanha. Quem escreveu uma abordagem que -- funciona e quer usá-la de novo copia e cola — e a cada cópia a versão boa e a -- versão velha ficam indistinguíveis. O template guarda a copy fora da execução. -- -- Conteúdo de campanha JÁ PREPARADA não muda quando o template muda: o texto é -- congelado por destinatário em `campaign_recipients.rendered_body` com o -- `content_version` junto. Editar um template amanhã não reescreve o que alguém -- recebeu ontem. -- -- ═══ `campaign_suppressions` — parar de falar com alguém sem apagá-lo ═══ -- -- Diferente de opt-out: o opt-out é do TITULAR (ele pediu, e `contacts.is_blocked` -- responde por isso em todo o produto). A suppression é da OPERAÇÃO — "não -- mande campanha para este número" — e não deve mexer no cadastro do contato -- nem no que o agente pode responder quando ELE escreve. -- -- Guarda HASH e não o telefone: dedup e consulta funcionam igual, e uma lista de -- "não mandar" não precisa virar um segundo lugar onde telefone de gente mora. -- -- Mesmo assim ela ENTRA na cascata de anonimização, e a primeira versão deste -- cabeçalho dizia o contrário: "guarda hash, logo não há PII". O invariante -- `lgpd-cascata-alcanca-quem-guarda-pessoa` discordou, e estava certo — a linha -- guarda `contact_id` e os últimos dígitos, e os dois juntos dizem de QUEM ela é. -- O trigger abaixo apaga esses dois e PRESERVA o hash, que é o veto. create table if not exists public.campaign_templates ( id uuid primary key default uuid_generate_v4(), organization_id uuid not null references public.organizations(id) on delete cascade, name text not null, body text not null, created_by uuid references auth.users(id) on delete set null, updated_by uuid references auth.users(id) on delete set null, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), constraint campaign_templates_name_check check (btrim(name) <> ''), constraint campaign_templates_body_check check (btrim(body) <> ''), -- Dois templates com o mesmo nome na mesma organização é a receita para usar -- o errado: quem escolhe na tela escolhe pelo nome. constraint campaign_templates_nome_unico unique (organization_id, name) ); comment on table public.campaign_templates is 'Copy reutilizável de campanha. Não é template de provedor (Meta): é texto livre com as mesmas variáveis do renderizador. Campanha preparada não muda quando o template muda — o conteúdo é congelado por destinatário.'; create index if not exists idx_campaign_templates_org on public.campaign_templates (organization_id, name); create table if not exists public.campaign_suppressions ( id uuid primary key default uuid_generate_v4(), organization_id uuid not null references public.organizations(id) on delete cascade, -- Nullable: dá para excluir um número que ainda não é contato de ninguém. contact_id uuid references public.contacts(id) on delete set null, recipient_address_hash text not null, -- Só os últimos dígitos, para a tela dizer DE QUEM é a linha sem guardar o -- número inteiro. "termina em 4321" basta para a pessoa reconhecer o que ela -- mesma cadastrou. address_tail text, reason text, source text not null default 'manual', created_by uuid references auth.users(id) on delete set null, created_at timestamptz not null default now(), constraint campaign_suppressions_source_check check (source in ('manual', 'import', 'sistema')), constraint campaign_suppressions_unico unique (organization_id, recipient_address_hash) ); comment on table public.campaign_suppressions is 'Lista de exclusão da OPERAÇÃO: não mandar campanha para este endereço. Diferente do opt-out, que é do titular e vive em contacts.is_blocked — aqui não se mexe no cadastro nem no que o agente responde a quem escreve. Guarda hash, nunca o telefone.'; comment on column public.campaign_suppressions.recipient_address_hash is 'SHA-256 do telefone normalizado (E.164). O mesmo cálculo mora em lib/campanhas/exclusoes.ts — mudar um lado sem o outro faz a lista parar de casar, em silêncio.'; create index if not exists idx_campaign_suppressions_org on public.campaign_suppressions (organization_id, created_at desc); drop trigger if exists trg_campaign_templates_updated_at on public.campaign_templates; create trigger trg_campaign_templates_updated_at before update on public.campaign_templates for each row execute function public.fn_set_updated_at(); -- ═══ LGPD: anonimizar apaga o que APONTA para a pessoa, e preserva o veto ═══ -- -- A lista guarda hash, e hash não reidentifica ninguém. Mas ela guarda também -- `contact_id` e os últimos dígitos — e esses dois, juntos, dizem de QUEM é a -- linha. Ao anonimizar, os dois saem. -- -- O HASH FICA, e isso é deliberado: ele é o veto. Apagá-lo devolveria o número -- para dentro das campanhas no dia em que o contato fosse anonimizado — o -- oposto do que o titular pediu. O que sobra é uma linha que impede envio para -- um número que ninguém consegue ler a partir dela. create or replace function public.fn_redigir_exclusoes_do_contato_anonimizado() returns trigger language plpgsql security definer set search_path = public, pg_temp as $$ begin if new.is_anonymized is true and coalesce(old.is_anonymized, false) is false then update public.campaign_suppressions set contact_id = null, address_tail = null, reason = null where organization_id = new.organization_id and contact_id = new.id; end if; return new; end; $$; revoke execute on function public.fn_redigir_exclusoes_do_contato_anonimizado() from public, anon, authenticated; grant execute on function public.fn_redigir_exclusoes_do_contato_anonimizado() to service_role; drop trigger if exists trg_redigir_exclusoes_anonimizado on public.contacts; create trigger trg_redigir_exclusoes_anonimizado after update of is_anonymized on public.contacts for each row execute function public.fn_redigir_exclusoes_do_contato_anonimizado(); -- ═══ RLS — mesmo padrão da 0375 ═══ -- SELECT para o tenant; escrita a partir de `manager`. Policy `ALL` só-tenancy -- em tabela nova é reprovada por `rbac-config-ia-canais.test.ts`. alter table public.campaign_templates enable row level security; drop policy if exists campaign_templates_select on public.campaign_templates; create policy campaign_templates_select on public.campaign_templates for select using ( (organization_id in (select public.fn_user_org_ids())) or public.fn_is_platform_admin() ); drop policy if exists campaign_templates_write on public.campaign_templates; create policy campaign_templates_write on public.campaign_templates using ( public.fn_is_platform_admin() or ((organization_id in (select public.fn_user_org_ids())) and public.fn_role_at_least(organization_id, 'manager')) ) with check ( public.fn_is_platform_admin() or ((organization_id in (select public.fn_user_org_ids())) and public.fn_role_at_least(organization_id, 'manager')) ); revoke all on public.campaign_templates from anon, authenticated; grant select on public.campaign_templates to authenticated; grant all on public.campaign_templates to service_role; alter table public.campaign_suppressions enable row level security; drop policy if exists campaign_suppressions_select on public.campaign_suppressions; create policy campaign_suppressions_select on public.campaign_suppressions for select using ( (organization_id in (select public.fn_user_org_ids())) or public.fn_is_platform_admin() ); drop policy if exists campaign_suppressions_write on public.campaign_suppressions; create policy campaign_suppressions_write on public.campaign_suppressions using ( public.fn_is_platform_admin() or ((organization_id in (select public.fn_user_org_ids())) and public.fn_role_at_least(organization_id, 'manager')) ) with check ( public.fn_is_platform_admin() or ((organization_id in (select public.fn_user_org_ids())) and public.fn_role_at_least(organization_id, 'manager')) ); revoke all on public.campaign_suppressions from anon, authenticated; grant select on public.campaign_suppressions to authenticated; grant all on public.campaign_suppressions to service_role; -- ---- rodízio de números na campanha (migration 0377) ---- -- 0377 — RODÍZIO DE NÚMEROS NA CAMPANHA -- -- A campanha falava por UM número (`campaigns.channel_session_id`, not null). -- Passa a poder falar por VÁRIOS, escolhidos explicitamente. -- -- ═══ Por que uma tabela de vínculo, e não um array de uuid ═══ -- -- O vínculo é tenant-aware e aponta para `channel_sessions`: com array, nenhuma -- FK protege contra o número de OUTRA organização entrar na lista, e a checagem -- viraria código que alguém esquece. Com linha, a FK composta -- `(organization_id, channel_session_id)` recusa no banco — o mesmo padrão da -- 0260, 0262 e 0375. -- -- ═══ O que NÃO muda ═══ -- -- `campaigns.channel_session_id` CONTINUA obrigatório e é o número principal: -- toda campanha que já existe segue funcionando sem uma linha sequer nesta -- tabela, e quem não quiser rodízio nunca abre essa parte da tela. O pool -- efetivo é "o principal mais os vinculados". -- -- ═══ Por que o destinatário guarda o número ═══ -- -- A escolha é feita no ENVIO (quem tem mais folga naquele instante), então só -- depois de enviar se sabe por onde foi. Sem gravar, a tela precisaria buscar a -- mensagem para responder "quem falou com esta pessoa?", e o relatório por -- número viraria um join a mais em cada linha. -- -- ⚠️ A coluna é NULLABLE e assim fica: destinatário excluído na preparação -- nunca recebe número, e um default aqui inventaria um envio que não houve. create table if not exists public.campaign_channel_sessions ( id uuid primary key default uuid_generate_v4(), organization_id uuid not null references public.organizations(id) on delete cascade, campaign_id uuid not null references public.campaigns(id) on delete cascade, channel_session_id uuid not null, created_at timestamptz not null default now(), -- O mesmo número duas vezes na mesma campanha dobraria o peso dele no -- rodízio sem ninguém pedir. constraint campaign_channel_sessions_unico unique (campaign_id, channel_session_id) ); do $$ begin if not exists ( select 1 from pg_constraint where conname = 'campaign_channel_sessions_org_fk' ) then alter table public.campaign_channel_sessions add constraint campaign_channel_sessions_org_fk foreign key (organization_id, channel_session_id) references public.channel_sessions (organization_id, id) on delete cascade; end if; end $$; comment on table public.campaign_channel_sessions is 'Os números que UMA campanha pode usar, além do principal em campaigns.channel_session_id. Rodízio: a cada envio o worker escolhe entre eles o que tem mais folga, preferindo aquele em que o contato já conversa.'; create index if not exists idx_campaign_channel_sessions_campanha on public.campaign_channel_sessions (campaign_id); alter table public.campaign_recipients add column if not exists channel_session_id uuid; do $$ begin if not exists ( select 1 from pg_constraint where conname = 'campaign_recipients_channel_org_fk' ) then alter table public.campaign_recipients add constraint campaign_recipients_channel_org_fk foreign key (organization_id, channel_session_id) references public.channel_sessions (organization_id, id) on delete set null; end if; end $$; comment on column public.campaign_recipients.channel_session_id is 'Por qual número esta pessoa foi falada. Preenchido no ENVIO, porque é lá que o rodízio decide. NULL = ainda não saiu, ou foi excluída na preparação.'; -- A pergunta do relatório por número: quantas saíram por cada um, nesta campanha. create index if not exists idx_campaign_recipients_por_numero on public.campaign_recipients (campaign_id, channel_session_id) where channel_session_id is not null; -- ═══ RLS — o padrão da 0375/0376 ═══ alter table public.campaign_channel_sessions enable row level security; drop policy if exists campaign_channel_sessions_select on public.campaign_channel_sessions; create policy campaign_channel_sessions_select on public.campaign_channel_sessions for select using ( (organization_id in (select public.fn_user_org_ids())) or public.fn_is_platform_admin() ); drop policy if exists campaign_channel_sessions_write on public.campaign_channel_sessions; create policy campaign_channel_sessions_write on public.campaign_channel_sessions using ( public.fn_is_platform_admin() or ((organization_id in (select public.fn_user_org_ids())) and public.fn_role_at_least(organization_id, 'manager')) ) with check ( public.fn_is_platform_admin() or ((organization_id in (select public.fn_user_org_ids())) and public.fn_role_at_least(organization_id, 'manager')) ); revoke all on public.campaign_channel_sessions from anon, authenticated; grant select on public.campaign_channel_sessions to authenticated; grant all on public.campaign_channel_sessions to service_role; -- ---- campanha declara funil, etapa e agente (migration 0378) ---- -- 0378 — A CAMPANHA DECLARA FUNIL, ETAPA E AGENTE -- -- Três colunas nullable em `campaigns`, e o índice que o degrau novo do -- roteamento precisa. Aditiva: campanha que já existe continua com tudo NULL e -- se comporta exatamente como antes. -- -- ═══ `pipeline_id` / `stage_id` — onde o card nasce ═══ -- -- Hoje quem decide o funil de um lead nascido de conversa é o NÚMERO -- (`crm_pipelines.channel_session_id`, migration 0262), com o funil padrão sem -- número como reserva. A campanha passa a poder dizer o funil e a etapa, e -- VENCE o número quando declara — decisão do dono (2026-09-19): quem montou a -- campanha sabe o que quer medir, e é a escolha mais específica. Quem não -- declarar continua caindo na regra da 0262, sem mudança nenhuma. -- -- ═══ `agent_id` — quem atende quem responde ═══ -- -- A dívida estava DECLARADA no repo desde antes desta entrega, em -- `lib/ai/elegibilidade/campanha.ts`: "encaminhar por campanha exige levar o -- agent_id (…) e o resolve-turn-agent respeitá-lo — não feito nesta entrega". -- O schema de lá já aceitava `agent_id` e o ignorava. -- -- Hoje quem atende a resposta de uma campanha é o agente publicado no NÚMERO, -- ou o roteador dele. Para prospecção isso é errado por construção: o roteiro -- de quem aborda é outro, e a LIA-2026-01 promete que quem perguntar "de onde -- veio meu contato?" recebe a resposta na hora — promessa que só se cumpre se -- QUEM ATENDE souber respondê-la. -- -- O agente da campanha só assume conversa que NASCE dela (decisão do dono): o -- cliente antigo que responde a uma reativação continua com quem já o atendia, -- em vez de ser sequestrado para o roteiro de prospecção. alter table public.campaigns add column if not exists pipeline_id uuid; alter table public.campaigns add column if not exists stage_id uuid; alter table public.campaigns add column if not exists agent_id uuid; -- Alvos das FKs compostas: índice único (organization_id, id) em cada tabela. -- Mesmo cuidado da 0262 — criar só se NÃO houver índice único sobre exatamente -- essas duas colunas, senão toda instalação ganha um segundo índice idêntico, -- pago em cada escrita. do $$ declare alvo text; begin foreach alvo in array array['crm_pipelines', 'crm_stages', 'ai_agents'] loop if not exists ( select 1 from pg_index i join pg_class t on t.oid = i.indrelid where t.relname = alvo and t.relnamespace = 'public'::regnamespace and i.indisunique and i.indnatts = 2 and ( select array_agg(a.attname::text order by k.ord) from unnest(i.indkey) with ordinality as k(attnum, ord) join pg_attribute a on a.attrelid = t.oid and a.attnum = k.attnum ) = array['organization_id', 'id'] ) then execute format('create unique index uq_%s_org_id on public.%I (organization_id, id)', alvo, alvo); end if; end loop; end $$; do $$ begin if not exists (select 1 from pg_constraint where conname = 'campaigns_pipeline_org_fk') then alter table public.campaigns add constraint campaigns_pipeline_org_fk foreign key (organization_id, pipeline_id) references public.crm_pipelines (organization_id, id) on delete set null; end if; if not exists (select 1 from pg_constraint where conname = 'campaigns_stage_org_fk') then alter table public.campaigns add constraint campaigns_stage_org_fk foreign key (organization_id, stage_id) references public.crm_stages (organization_id, id) on delete set null; end if; if not exists (select 1 from pg_constraint where conname = 'campaigns_agent_org_fk') then alter table public.campaigns add constraint campaigns_agent_org_fk foreign key (organization_id, agent_id) references public.ai_agents (organization_id, id) on delete set null; end if; end $$; -- Etapa sem funil seria um card sem coluna: o par anda junto ou não anda. do $$ begin alter table public.campaigns add constraint campaigns_etapa_exige_funil check (stage_id is null or pipeline_id is not null); exception when duplicate_object then null; end $$; comment on column public.campaigns.pipeline_id is 'Funil em que nasce o card de quem responde. VENCE o funil do número (0262) quando declarado; NULL mantém a regra do número.'; comment on column public.campaigns.agent_id is 'Quem atende quem responde a esta campanha. Só assume conversa que NASCE da campanha — cliente antigo segue com quem já o atendia. Lido por resolve-turn-agent num degrau acima do roteador.'; -- O degrau novo do roteamento pergunta, a cada turno: esta conversa nasceu de -- uma campanha com agente? Sem índice, isso seria uma varredura em -- `campaign_recipients` a cada mensagem recebida da organização inteira. create index if not exists idx_campaign_recipients_conversa on public.campaign_recipients (conversation_id) where conversation_id is not null; -- ---- Link da mídia salvo no modelo (migration 0382) ---- -- Valores que o operador salvou para reaproveitar em todo disparo do modelo, -- chaveados como template_values. Só link de mídia. Sobrevive à sincronização, -- que não lista esta coluna no upsert. Ver o cabeçalho da migration 0382. alter table public.meta_templates add column if not exists saved_values jsonb not null default '{}'::jsonb; alter table public.meta_templates drop constraint if exists meta_templates_saved_values_objeto; alter table public.meta_templates add constraint meta_templates_saved_values_objeto check (jsonb_typeof(saved_values) = 'object'); comment on column public.meta_templates.saved_values is 'Valores que o operador salvou para reaproveitar em todo disparo deste modelo, chaveados como template_values (slotKey: header:1, button0:1). Só link de mídia: a rota de escrita recusa valor de texto, que costuma ser dado de pessoa. Sobrevive à sincronização, que não lista esta coluna no upsert.'; -- ---- honorários: primeiro módulo oficial via ADR-0002 (migration 0480) ---- -- ⚠️ ANTES DA VARREDURA anon: cria função. Corpo completo e o porquê de cada -- decisão (D2/D4/D5/D8) em supabase/migrations/20260928150200_0480_honorarios_modulo_oficial.sql — -- criar a função aqui NÃO cria tabela nenhuma; as tabelas só nascem quando um -- administrador da instalação chama fn_modulo_instalar('honorarios', ...). create or replace function public.fn_honorarios_provisionar() returns void language plpgsql security definer set search_path = public, pg_temp as $f$ begin create table if not exists public.honorarios_contratos ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, -- Preservado mesmo se o lead for excluído (mesma decisão de -- `financial_entries.sale_id`): o contrato é registro financeiro e sobrevive -- à linha operacional que o originou. lead_id uuid references public.crm_leads(id) on delete set null, -- `text` + CHECK, não enum (doutrina: enum é difícil de estender). modelo text not null check (modelo in ('fixo', 'exito', 'misto')), valor_fixo_cents bigint check (valor_fixo_cents is null or valor_fixo_cents > 0), percentual_exito numeric(5,2) check (percentual_exito is null or (percentual_exito > 0 and percentual_exito <= 100)), repasse_advogado_pct numeric(5,2) check (repasse_advogado_pct is null or (repasse_advogado_pct >= 0 and repasse_advogado_pct <= 100)), -- Modelo declara o campo que faz sentido: fixo pede valor, êxito pede -- percentual, misto pede os dois. Não impede o resto de ficar em branco. constraint honorarios_contratos_modelo_tem_o_campo check ( (modelo = 'fixo' and valor_fixo_cents is not null) or (modelo = 'exito' and percentual_exito is not null) or (modelo = 'misto' and valor_fixo_cents is not null and percentual_exito is not null) ), created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create index if not exists honorarios_contratos_org_idx on public.honorarios_contratos (organization_id); create index if not exists honorarios_contratos_lead_idx on public.honorarios_contratos (organization_id, lead_id) where lead_id is not null; create table if not exists public.honorarios_parcelas ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, contrato_id uuid not null references public.honorarios_contratos(id) on delete cascade, numero integer not null check (numero > 0), vencimento date not null, valor_cents bigint not null check (valor_cents > 0), -- Preservada mesmo se o lançamento do caixa for desfeito — a MESMA decisão -- de `financial_entries.sale_id`: o link é conveniência de navegação, nunca -- a fonte da verdade do valor ou da data. financial_entry_id uuid references public.financial_entries(id) on delete set null, status text not null default 'pendente' check (status in ('pendente', 'pago', 'atrasado')), created_at timestamptz not null default now(), updated_at timestamptz not null default now(), constraint honorarios_parcelas_numero_unico unique (contrato_id, numero) ); create index if not exists honorarios_parcelas_org_idx on public.honorarios_parcelas (organization_id); create index if not exists honorarios_parcelas_contrato_idx on public.honorarios_parcelas (organization_id, contrato_id); create index if not exists honorarios_parcelas_vencimento_idx on public.honorarios_parcelas (organization_id, vencimento) where status = 'pendente'; -- ── RLS POR OPERAÇÃO (D5, ligada aqui e não pela rotina automática) ──────── -- Molde da 0464 (propostas): uma policy por operação, espelhando as ROTAS, -- porque o PostgREST é porta tão aberta quanto elas (o JWT da sessão fala com -- ele direto; ver 0150) e o baseline dá GRANT ALL a `authenticated`. -- SELECT qualquer papel da organização (GET /honorarios/... é `viewer`); -- INSERT `manager` (POST de contrato e de parcela é `manager`); -- UPDATE `manager` — nenhuma rota edita, e dinheiro não é coisa que -- `agent` configure (mesmo piso do caixa núcleo, migration 0350); -- DELETE `manager`, e PARCELA PAGA NÃO SE APAGA: nem ela, nem o contrato -- que a tem (o `on delete cascade` levaria a parcela junto, e a -- cascata de FK não passa por RLS). -- A policy anterior era UMA só, `for all`, com USING = membro e WITH CHECK = -- manager+. DELETE só avalia o USING: `viewer` e `agent` apagavam contrato -- (com as parcelas) ou parcela paga (revisão do #1578). -- -- Parcela paga é imutável pela sessão, e a sessão não marca parcela como -- paga: `pago` com `financial_entry_id` só nasce em fn_honorarios_parcela_pagar -- (definer, dona da tabela, não passa por aqui), que lança o caixa junto. -- Deixar a sessão escrever `status`/`financial_entry_id` à mão desfaria esse -- par: "pago" sem lançamento, ou "pendente" de novo para pagar duas vezes. -- A parcela só aponta para contrato da própria organização (a FK só confere -- que o contrato existe). alter table public.honorarios_contratos enable row level security; drop policy if exists tenant_isolation_honorarios_contratos_all on public.honorarios_contratos; drop policy if exists honorarios_contratos_select on public.honorarios_contratos; -- Cada `create policy` deste corpo ocupa DUAS linhas de propósito (#1906). -- O `update.sh` de v1.39.0 a v1.63.0 lê as regras do TEXTO deste arquivo -- (nome da regra e tabela na MESMA linha do create), até dentro de corpo de -- função, e cobrava estas 8 em instalação sem o módulo. Esse script antigo -- é o que roda na atualização (fica no disco), então o conserto dele não -- alcança quem atualiza: a forma do texto sim. Vigiado por -- tests/unit/adr-0002-funcao-provisionadora.test.ts. create policy honorarios_contratos_select on public.honorarios_contratos for select using ( organization_id in (select public.fn_user_org_ids()) or public.fn_is_platform_admin() ); drop policy if exists honorarios_contratos_insert on public.honorarios_contratos; create policy honorarios_contratos_insert on public.honorarios_contratos for insert with check (public.fn_is_platform_admin() or (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'manager'))); drop policy if exists honorarios_contratos_update on public.honorarios_contratos; create policy honorarios_contratos_update on public.honorarios_contratos for update using (public.fn_is_platform_admin() or (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'manager'))) with check (public.fn_is_platform_admin() or (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'manager'))); drop policy if exists honorarios_contratos_delete on public.honorarios_contratos; create policy honorarios_contratos_delete on public.honorarios_contratos for delete using ((public.fn_is_platform_admin() or (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'manager'))) and not exists (select 1 from public.honorarios_parcelas p where p.contrato_id = honorarios_contratos.id and p.status = 'pago')); revoke all on public.honorarios_contratos from anon; alter table public.honorarios_parcelas enable row level security; drop policy if exists tenant_isolation_honorarios_parcelas_all on public.honorarios_parcelas; drop policy if exists honorarios_parcelas_select on public.honorarios_parcelas; create policy honorarios_parcelas_select on public.honorarios_parcelas for select using ( organization_id in (select public.fn_user_org_ids()) or public.fn_is_platform_admin() ); drop policy if exists honorarios_parcelas_insert on public.honorarios_parcelas; create policy honorarios_parcelas_insert on public.honorarios_parcelas for insert with check ((public.fn_is_platform_admin() or (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'manager'))) and status <> 'pago' and financial_entry_id is null and exists (select 1 from public.honorarios_contratos c where c.id = contrato_id and c.organization_id = honorarios_parcelas.organization_id)); drop policy if exists honorarios_parcelas_update on public.honorarios_parcelas; create policy honorarios_parcelas_update on public.honorarios_parcelas for update using ((public.fn_is_platform_admin() or (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'manager'))) and status <> 'pago') with check ((public.fn_is_platform_admin() or (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'manager'))) and status <> 'pago' and financial_entry_id is null and exists (select 1 from public.honorarios_contratos c where c.id = contrato_id and c.organization_id = honorarios_parcelas.organization_id)); drop policy if exists honorarios_parcelas_delete on public.honorarios_parcelas; create policy honorarios_parcelas_delete on public.honorarios_parcelas for delete using ((public.fn_is_platform_admin() or (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'manager'))) and status <> 'pago'); revoke all on public.honorarios_parcelas from anon; comment on table public.honorarios_contratos is 'Modelo de cobrança do caso (fixo/êxito/misto). Financeiro real (contas, lançamentos) é o caixa núcleo — este módulo só descreve o contrato.'; comment on table public.honorarios_parcelas is 'Calendário de parcelas do contrato. Pagar uma parcela cria um financial_entries e liga por financial_entry_id; não há tabela de "pagamento" própria.'; -- RLS já ligada por nós, então esta rotina não mexe mais nelas (D5) — só -- aplica as travas de suporte, que dependem de RLS já estar de pé. perform public.fn_proteger_modulo_provisionado(); end; $f$; revoke execute on function public.fn_honorarios_provisionar() from public, anon, authenticated; grant execute on function public.fn_honorarios_provisionar() to service_role; -- ---- fn_honorarios_parcela_pagar: pagamento atômico (migration 0480, achado da revisão do PR #1578) ---- -- D7 (ADR-0002): `record`, não `honorarios_parcelas%rowtype` — compila mesmo antes do módulo -- instalado. Mesmo desenho de fn_finalizar_comanda (migration 0351): security definer + for -- update + fn_role_at_least, para a transição pendente→pago ser atômica (dois cliques na -- mesma parcela não lançam duas vezes no caixa). create or replace function public.fn_honorarios_parcela_pagar( p_org uuid, p_parcela uuid, p_account_id uuid, p_account_plan_id uuid default null ) returns jsonb language plpgsql security definer set search_path = public, pg_temp as $$ declare v_parcela record; v_entry uuid; begin if auth.uid() is null or not public.fn_role_at_least(p_org, 'manager') then raise exception 'honorarios_forbidden' using errcode = '42501'; end if; select * into v_parcela from public.honorarios_parcelas where id = p_parcela and organization_id = p_org for update; if not found then raise exception 'parcela_nao_encontrada' using errcode = 'P0002'; end if; if v_parcela.status = 'pago' then raise exception 'parcela_ja_paga' using errcode = '22023'; end if; -- A conta e o plano vêm do corpo da requisição, e a função é definer: sem esta -- conferência a FK aceitaria a conta de OUTRA organização e o dinheiro desta -- entraria no extrato de lá. fn_finalizar_comanda resolve a conta pela forma de -- pagamento filtrada por p_org; aqui a conta chega direto, então o filtro é este. if not exists ( select 1 from public.financial_accounts where id = p_account_id and organization_id = p_org and is_active ) then raise exception 'conta_invalida' using errcode = '22023'; end if; if p_account_plan_id is not null and not exists ( select 1 from public.account_plans where id = p_account_plan_id and organization_id = p_org and is_active ) then raise exception 'conta_invalida' using errcode = '22023'; end if; insert into public.financial_entries (organization_id, account_id, account_plan_id, direction, amount_cents, description, status, paid_at, origin, created_by_user_id) values ( p_org, p_account_id, p_account_plan_id, 'in', v_parcela.valor_cents, format('Parcela %s de honorários', v_parcela.numero), 'paid', now(), 'manual', auth.uid() ) returning id into v_entry; update public.honorarios_parcelas set status = 'pago', financial_entry_id = v_entry where id = p_parcela; return jsonb_build_object( 'id', v_parcela.id, 'contrato_id', v_parcela.contrato_id, 'numero', v_parcela.numero, 'valor_cents', v_parcela.valor_cents, 'status', 'pago', 'financial_entry_id', v_entry ); end; $$; revoke execute on function public.fn_honorarios_parcela_pagar(uuid, uuid, uuid, uuid) from public, anon; grant execute on function public.fn_honorarios_parcela_pagar(uuid, uuid, uuid, uuid) to authenticated; -- ---- canal de WhatsApp Datafy (migration 0387) ---- -- Recorte do PR #1130, de @vgamkt. As COLUNAS e o VOCABULÁRIO dos CHECKs de -- `channel_sessions` (provider e ref) e de `webhook_events_log` vivem nos blocos -- ÚNICOS deles, lá em cima — doutrina "uma constraint, um bloco" -- (`tests/unit/baseline-constraint-reconstruida.test.ts`). Aqui só o que é desta -- migration e de mais ninguém: a dedup e o índice único entre ativos (desenho da -- 0165). A dedup roda ANTES do índice, para o `update.sh` de um banco que já -- tenha dois ativos com o mesmo número consertar em vez de quebrar. with ativos as ( select id, row_number() over ( partition by datafy_phone_number_id order by created_at desc nulls last, id desc ) as posicao from public.channel_sessions where archived_at is null and datafy_phone_number_id is not null ) update public.channel_sessions s set datafy_phone_number_id = s.datafy_phone_number_id || '-conflito-' || s.id::text from ativos a where a.id = s.id and a.posicao > 1; create unique index if not exists channel_sessions_datafy_phone_number_id_ativo_unique on public.channel_sessions (datafy_phone_number_id) where archived_at is null and datafy_phone_number_id is not null; comment on column public.channel_sessions.datafy_phone_number_id is 'phone_number_id da WABA no canal Datafy (parceiro que espelha a Cloud API). É o sessionRef deste canal. Espelhado em lib/channels/session-ref.ts.'; comment on column public.channel_sessions.datafy_token_encrypted is 'Token do Datafy (sk_live_…), cifrado por fn_encrypt_oauth. Nunca volta à tela depois de gravado.'; -- ---- fim canal de WhatsApp Datafy (migration 0387) ---- -- ---- anonimizar pela tela também redige conversas, mensagens e resumos (migration 0391) ---- -- -- Há dois caminhos que anonimizam um contato, e só um redigia a conversa: -- -- fn_lgpd_cascade_redact_contact o pedido formal (redact) redigia mensagens e conversas -- fn_lgpd_anonymize_contact o botão da ficha do contato só o contato -- + lib/lgpd/cascata.ts (leads, atividades, régua) -- -- Pelo botão, o nome e o CPF que a pessoa escreveu continuavam no corpo das -- mensagens, no `last_message_preview` da conversa (o cabeçalho da ficha -- anonimizada) e no resumo que o agente guarda por contato (`lead_checkpoints`) -- — o "anonimizado" da tela era mentira sobre o que mais importa. -- -- O conserto é no ESTADO, não num dos caminhos: um gatilho na virada de -- `is_anonymized`, o mesmo desenho de `trg_contacts_anonimizado_limpa_custom_fields` -- e dos outros seis gatilhos de redação deste schema. Assim os dois caminhos — e -- qualquer um que venha — passam pelo mesmo lugar, na mesma transação da virada. -- Os comandos de mensagens e conversas são os MESMOS da cascata formal; ela os -- repete depois, sem efeito novo. -- -- `lead_checkpoints` não estava em NENHUM dos dois caminhos: o resumo corrido, -- os compromissos, as objeções, a próxima ação e a declaração do turno são texto -- escrito por modelo sobre a conversa, e nomeiam a pessoa. -- -- A mídia das mensagens vai para `storage_redaction_queue` ANTES de a coluna -- ser zerada: zerar primeiro perderia o único ponteiro para o arquivo, que -- ficaria no bucket para sempre. `request_id` fica nulo — no caminho do botão não -- há pedido, e no formal a fila já é idempotente por (bucket, object_path). create or replace function public.fn_redigir_conversas_ao_anonimizar() returns trigger language plpgsql security definer set search_path = public, pg_temp as $$ begin insert into public.storage_redaction_queue (organization_id, bucket, object_path) select distinct new.organization_id, 'whatsapp-media', m.media_storage_path from public.messages m where m.organization_id = new.organization_id and m.conversation_id in ( select c.id from public.conversations c where c.contact_id = new.id and c.organization_id = new.organization_id) and m.media_storage_path is not null and length(m.media_storage_path) > 0 on conflict (bucket, object_path) do nothing; update public.messages set body = '[mensagem anonimizada]', media_url = null, media_mime = null, media_size_bytes = null, media_storage_path = null, metadata = '{}'::jsonb, updated_at = now() where organization_id = new.organization_id and conversation_id in ( select c.id from public.conversations c where c.contact_id = new.id and c.organization_id = new.organization_id); update public.conversations set metadata = '{}'::jsonb, last_message_preview = null, last_handoff_reason = null, updated_at = now() where contact_id = new.id and organization_id = new.organization_id; update public.lead_checkpoints set rolling_summary = '[resumo anonimizado]', commitments = '[]'::jsonb, objections = '[]'::jsonb, next_action = null, declaracao = null where contact_id = new.id and organization_id = new.organization_id; return new; end $$; -- As DUAS origens de EXECUTE (item 9 do CLAUDE.md): o grant a PUBLIC da criação -- e o grant nominal a anon do ALTER DEFAULT PRIVILEGES do baseline. revoke all on function public.fn_redigir_conversas_ao_anonimizar() from public; revoke execute on function public.fn_redigir_conversas_ao_anonimizar() from anon; revoke execute on function public.fn_redigir_conversas_ao_anonimizar() from authenticated; drop trigger if exists trg_redigir_conversas_ao_anonimizar on public.contacts; create trigger trg_redigir_conversas_ao_anonimizar after update of is_anonymized on public.contacts for each row when (new.is_anonymized = true and coalesce(old.is_anonymized, false) = false) execute function public.fn_redigir_conversas_ao_anonimizar(); -- Cura: contatos que JÁ foram anonimizados pelo botão antes deste gatilho. O -- gatilho só dispara na virada, e para eles a virada já passou. Cada comando só -- alcança o que existia ATÉ `anonymized_at`: um contato anonimizado que volta a -- escrever (religado pelo LID) tem conversa NOVA, e reaplicar o baseline no -- update.sh não pode redigi-la nem mandar a mídia dela para o apagamento. insert into public.storage_redaction_queue (organization_id, bucket, object_path) select distinct m.organization_id, 'whatsapp-media', m.media_storage_path from public.messages m join public.conversations c on c.id = m.conversation_id and c.organization_id = m.organization_id join public.contacts k on k.id = c.contact_id and k.organization_id = c.organization_id where k.is_anonymized and m.created_at <= k.anonymized_at and m.media_storage_path is not null and length(m.media_storage_path) > 0 on conflict (bucket, object_path) do nothing; update public.messages m set body = '[mensagem anonimizada]', media_url = null, media_mime = null, media_size_bytes = null, media_storage_path = null, metadata = '{}'::jsonb, updated_at = now() from public.conversations c join public.contacts k on k.id = c.contact_id and k.organization_id = c.organization_id where c.id = m.conversation_id and c.organization_id = m.organization_id and k.is_anonymized and m.created_at <= k.anonymized_at and (m.body is distinct from '[mensagem anonimizada]' or m.media_url is not null or m.media_storage_path is not null or m.metadata <> '{}'::jsonb); update public.conversations c set metadata = '{}'::jsonb, last_message_preview = null, last_handoff_reason = null, updated_at = now() from public.contacts k where k.id = c.contact_id and k.organization_id = c.organization_id and k.is_anonymized and coalesce(c.last_message_at, c.created_at) <= k.anonymized_at and (c.metadata <> '{}'::jsonb or c.last_message_preview is not null or c.last_handoff_reason is not null); update public.lead_checkpoints l set rolling_summary = '[resumo anonimizado]', commitments = '[]'::jsonb, objections = '[]'::jsonb, next_action = null, declaracao = null from public.contacts k where k.id = l.contact_id and k.organization_id = l.organization_id and k.is_anonymized and l.created_at <= k.anonymized_at and (l.rolling_summary is distinct from '[resumo anonimizado]' or l.commitments <> '[]'::jsonb or l.objections <> '[]'::jsonb or l.next_action is not null or l.declaracao is not null); -- ---- fluxos de atendimento: a base, desligada por padrão (migration 0394, de @vgamkt, #1130) ---- -- Os CHECKs de `surface` e de `status` ('atendimento', 'coletando') estão nos -- blocos únicos da 0196 e da 0145, acima. Aqui: o índice do roteiro vivo, o -- ponteiro do roteador com FK composta, e o gatilho que encerra o roteiro vivo -- na anonimização (os dois caminhos). Racional inteiro na migration 0394. -- ⚠️ ANTES da VARREDURA anon, porque cria função. Idempotente. create unique index if not exists idx_followup_enrollments_um_roteiro_coletando on public.followup_enrollments (organization_id, contact_id) where status = 'coletando'; create unique index if not exists idx_followup_flow_pointers_org_id on public.followup_flow_pointers (organization_id, id); alter table public.ai_router_members add column if not exists flow_pointer_id uuid; do $$ begin alter table public.ai_router_members add constraint ai_router_members_flow_pointer_mesma_org foreign key (organization_id, flow_pointer_id) references public.followup_flow_pointers (organization_id, id) on delete set null (flow_pointer_id); exception when duplicate_object then null; end $$; comment on column public.ai_router_members.flow_pointer_id is 'Roteiro de atendimento (surface=atendimento) que começa quando esta intenção casa. NULL = só roteia o agente. FK composta: só roteiro da mesma organização.'; create or replace function public.fn_contato_anonimizado_encerra_roteiro() returns trigger language plpgsql security definer set search_path = public, pg_temp as $$ begin update public.followup_enrollments set status = 'cancelled', cancel_reason = 'Contato anonimizado (LGPD)', completed_at = now(), updated_at = now() where organization_id = new.organization_id and contact_id = new.id and status = 'coletando'; return new; end $$; revoke all on function public.fn_contato_anonimizado_encerra_roteiro() from public; revoke execute on function public.fn_contato_anonimizado_encerra_roteiro() from anon; revoke execute on function public.fn_contato_anonimizado_encerra_roteiro() from authenticated; drop trigger if exists trg_contato_anonimizado_encerra_roteiro on public.contacts; create trigger trg_contato_anonimizado_encerra_roteiro after update of is_anonymized on public.contacts for each row when (new.is_anonymized = true and coalesce(old.is_anonymized, false) = false) execute function public.fn_contato_anonimizado_encerra_roteiro(); -- A superfície e o status andam juntos, no BANCO. Quem cria enrollment pelo -- relógio (gatilhos de etapa, lead, caso, retorno, silêncio, o enroll manual) lê -- o pointer pelo `trigger_config`, não pela superfície: um roteiro de -- atendimento com gatilho de silêncio viraria enrollment 'active' e o motor de -- follow-up executaria as perguntas como passos de relógio. E o inverso — um -- 'coletando' num fluxo de follow-up — ocuparia a vaga do roteiro. Uma regra, um -- lugar, para todos os produtores de hoje e os que vierem. create or replace function public.fn_enrollment_superficie_coerente() returns trigger language plpgsql security definer set search_path = public, pg_temp as $$ declare v_surface text; begin select p.surface into v_surface from public.followup_flow_pointers p where p.id = new.pointer_id; if v_surface = 'atendimento' and new.status not in ('coletando','completed','cancelled','dead') then raise exception 'roteiro de atendimento só roda como coletando (status %)', new.status using errcode = '23514'; end if; if v_surface is distinct from 'atendimento' and new.status = 'coletando' then raise exception 'coletando é exclusivo de roteiro de atendimento' using errcode = '23514'; end if; return new; end $$; revoke all on function public.fn_enrollment_superficie_coerente() from public; revoke execute on function public.fn_enrollment_superficie_coerente() from anon; revoke execute on function public.fn_enrollment_superficie_coerente() from authenticated; drop trigger if exists trg_enrollment_superficie_coerente on public.followup_enrollments; create trigger trg_enrollment_superficie_coerente before insert or update of status, pointer_id on public.followup_enrollments for each row execute function public.fn_enrollment_superficie_coerente(); -- A superfície de um fluxo é IMUTÁVEL depois de criado, e roteiro de atendimento -- só tem gatilho manual (revisão adversarial do #1559). A policy de -- `followup_flow_pointers` é só de tenant: qualquer membro da empresa, até -- viewer, faria pelo PostgREST `update ... set surface = 'atendimento'` num -- fluxo de silêncio ativo — e o `trg_enrollment_superficie_coerente` passaria a -- recusar (23514) cada inscrição da varredura. E um PATCH de gatilho levaria um -- roteiro publicado de Manual para Silêncio. As duas portas fecham no BANCO. -- Nenhuma linha antes da 0394 pode ter 'atendimento' (o CHECK de conjunto o -- recusava), então o CHECK abaixo não tem dado a corrigir. alter table public.followup_flow_pointers drop constraint if exists followup_flow_pointers_roteiro_so_manual; alter table public.followup_flow_pointers add constraint followup_flow_pointers_roteiro_so_manual check (surface <> 'atendimento' or coalesce(trigger_config->>'kind', 'manual') = 'manual'); create or replace function public.fn_superficie_do_fluxo_imutavel() returns trigger language plpgsql security definer set search_path = public, pg_temp as $$ begin if new.surface is distinct from old.surface then raise exception 'a superfície de um fluxo não muda depois de criado (% → %)', old.surface, new.surface using errcode = '23514'; end if; return new; end $$; revoke all on function public.fn_superficie_do_fluxo_imutavel() from public; revoke execute on function public.fn_superficie_do_fluxo_imutavel() from anon; revoke execute on function public.fn_superficie_do_fluxo_imutavel() from authenticated; drop trigger if exists trg_superficie_do_fluxo_imutavel on public.followup_flow_pointers; create trigger trg_superficie_do_fluxo_imutavel before update of surface on public.followup_flow_pointers for each row execute function public.fn_superficie_do_fluxo_imutavel(); -- ---- o roteiro de atendimento encerra quando um humano assume, no opt-out e no prazo (migration 0397, #1130) ---- -- Gatilho na virada false→true de `force_human`/`is_blocked` (um lugar para todos -- os escritores) e `fn_encerrar_roteiros_vencidos` (prazo em settings.expira_em_horas, -- padrão 72 h), chamada pelo relógio do follow-up. Racional na migration 0397. -- ⚠️ ANTES da VARREDURA anon, porque cria função. Idempotente. create or replace function public.fn_contato_encerra_roteiro_com_humano_ou_opt_out() returns trigger language plpgsql security definer set search_path = public, pg_temp as $$ declare v_motivo text; begin if new.is_blocked = true and coalesce(old.is_blocked, false) = false then v_motivo := 'opt_out'; elsif new.force_human = true and coalesce(old.force_human, false) = false then v_motivo := 'humano_assumiu'; else return new; end if; with encerrados as ( update public.followup_enrollments set status = 'cancelled', cancel_reason = case v_motivo when 'opt_out' then 'Contato pediu para parar (opt-out)' else 'Humano assumiu o atendimento' end, completed_at = now(), updated_at = now() where organization_id = new.organization_id and contact_id = new.id and status = 'coletando' returning id, organization_id, current_node_id ) insert into public.followup_enrollment_events (organization_id, enrollment_id, node_id, event_type, payload, idempotency_key) select organization_id, id, current_node_id, 'roteiro_cancelado', jsonb_build_object('motivo', v_motivo), 'roteiro_cancelado:' || v_motivo from encerrados on conflict do nothing; return new; end $$; revoke all on function public.fn_contato_encerra_roteiro_com_humano_ou_opt_out() from public; revoke execute on function public.fn_contato_encerra_roteiro_com_humano_ou_opt_out() from anon; revoke execute on function public.fn_contato_encerra_roteiro_com_humano_ou_opt_out() from authenticated; drop trigger if exists trg_contato_encerra_roteiro_com_humano_ou_opt_out on public.contacts; create trigger trg_contato_encerra_roteiro_com_humano_ou_opt_out after update of force_human, is_blocked on public.contacts for each row when ((new.force_human = true and coalesce(old.force_human, false) = false) or (new.is_blocked = true and coalesce(old.is_blocked, false) = false)) execute function public.fn_contato_encerra_roteiro_com_humano_ou_opt_out(); create or replace function public.fn_encerrar_roteiros_vencidos(p_limite int default 200) returns int language plpgsql security definer set search_path = public, pg_temp as $$ declare v_encerrados int; begin with vencidos as ( select e.id from public.followup_enrollments e join public.followup_flow_versions v on v.id = e.version_id and v.organization_id = e.organization_id where e.status = 'coletando' and greatest( e.started_at, coalesce((select max(ev.created_at) from public.followup_enrollment_events ev where ev.enrollment_id = e.id and ev.organization_id = e.organization_id and ev.event_type = 'roteiro_mensagem'), e.started_at) ) < now() - make_interval(hours => case when (v.graph->'settings'->>'expira_em_horas') ~ '^[0-9]{1,4}$' then greatest(1, (v.graph->'settings'->>'expira_em_horas')::int) else 72 end) order by e.started_at limit greatest(1, least(coalesce(p_limite, 200), 1000)) for update of e skip locked ), encerrados as ( update public.followup_enrollments e set status = 'cancelled', cancel_reason = 'Roteiro expirou sem resposta', completed_at = now(), updated_at = now() from vencidos where e.id = vencidos.id and e.status = 'coletando' returning e.id, e.organization_id, e.current_node_id ), eventos as ( insert into public.followup_enrollment_events (organization_id, enrollment_id, node_id, event_type, payload, idempotency_key) select organization_id, id, current_node_id, 'roteiro_expirado', '{}'::jsonb, 'roteiro_expirado' from encerrados on conflict do nothing returning 1 ) select count(*)::int into v_encerrados from encerrados; return v_encerrados; end $$; revoke all on function public.fn_encerrar_roteiros_vencidos(int) from public; revoke execute on function public.fn_encerrar_roteiros_vencidos(int) from anon; revoke execute on function public.fn_encerrar_roteiros_vencidos(int) from authenticated; grant execute on function public.fn_encerrar_roteiros_vencidos(int) to service_role; -- ---- a conversa fica com quem atendeu, ajuste por empresa (migration 0396) ---- -- 0396 — "A conversa fica com quem atendeu": ajuste por empresa, DESLIGADO por -- padrão (ideia de @gustavorodcruz96, #1527). -- -- Ligado (organizations.settings.routing.conversation_stays_with_attendant = -- true), uma nova mensagem numa conversa encerrada a reabre com o último -- atendente, sem passar pelo roteamento, e a IA fica calada. Só conserva um -- dono humano que ainda é membro ativo agent+ da organização. A revisão de -- serviço e a nova demanda continuam novas: trabalho do episódio encerrado não -- ganha autoridade sobre o episódio reaberto. -- -- Desligado (o padrão, e o de toda empresa que já existe), a função faz o mesmo -- que antes: a conversa reaberta volta para a fila, sem dono. create or replace function public.fn_service_inbound(p_message uuid) returns void language plpgsql security definer set search_path = public as $$ declare m public.messages; c public.conversations; d public.demandas; reopened boolean; keep_owner boolean; pre_contact uuid; begin select * into m from public.messages where id = p_message; if not found or m.direction <> 'inbound' or m.service_revision is not null then return; end if; select * into c from public.conversations where id = m.conversation_id; if not found or c.organization_id is distinct from m.organization_id or c.channel_session_id is distinct from m.channel_session_id or not exists(select 1 from public.channel_sessions where id=m.channel_session_id and organization_id=m.organization_id) then raise exception 'service_scope_mismatch' using errcode='23503'; end if; if c.is_group or coalesce(c.group_chat_id,'') like '%@g.us' then return; end if; if c.contact_id is distinct from m.contact_id or not exists(select 1 from public.contacts where id=m.contact_id and organization_id=m.organization_id) then raise exception 'service_scope_mismatch' using errcode='23503'; end if; pre_contact:=c.contact_id; perform public.fn_service_lock(c.organization_id,c.contact_id); select * into c from public.conversations where id=m.conversation_id and organization_id=m.organization_id for no key update; if c.contact_id is distinct from pre_contact then raise exception 'service_contact_changed' using errcode='40001'; end if; if m.sent_at <= c.service_closed_at then return; end if; reopened := c.status in ('closed','resolved','archived'); -- Só quando a empresa ligou "a conversa fica com quem atendeu". O caminho é o -- de lib/schemas/routing.ts, e só o booleano true liga: chave ausente ou com -- outro valor = o comportamento de sempre (volta para a fila). keep_owner := reopened and c.assigned_to_user_id is not null and coalesce((select o.settings->'routing'->'conversation_stays_with_attendant' = 'true'::jsonb from public.organizations o where o.id = c.organization_id), false) and coalesce(public.fn_member_role_in_org(c.assigned_to_user_id,c.organization_id),'none') in ('agent','manager','admin'); if not reopened then select x.* into d from public.demandas x join public.demanda_conversas dc on dc.demanda_id=x.id where x.id=c.current_demanda_id and x.organization_id=c.organization_id and x.contact_id=c.contact_id and dc.organization_id=c.organization_id and dc.conversation_id=c.id and dc.service_revision=c.service_revision and x.fechada_em is null; end if; if d.id is null then insert into public.demandas (organization_id,contact_id,aberta_em,origem,estado,dono_kind,dono_user_id,proximo_passo) values(c.organization_id,c.contact_id,m.sent_at,'inbound','aberta', case when keep_owner then 'humano' else 'ia' end, case when keep_owner then c.assigned_to_user_id else null end, 'Responder à nova mensagem do cliente') returning * into d; end if; if reopened then update public.conversations set status=case when keep_owner then 'claimed' else 'open' end, status_changed_at=clock_timestamp(), service_revision=service_revision+1,service_started_at=m.sent_at, assigned_to_user_id=case when keep_owner then c.assigned_to_user_id else null end, -- O relógio do episódio NOVO, não o do encerrado: o prazo de devolução -- automática à IA (handoff_return_after_minutes) conta a partir do -- último sinal humano, e assigned_at é um deles. Guardar o do episódio -- antigo devolveria a conversa à IA no primeiro tick do cron. assigned_at=case when keep_owner then clock_timestamp() else null end, assignee_kind=case when keep_owner then 'user' else null end, bot_silenced_until=case when keep_owner then 'infinity'::timestamptz else c.bot_silenced_until end, active_ai_agent_id=null, current_demanda_id=d.id where id=c.id and organization_id=c.organization_id returning * into c; else update public.conversations set service_revision=service_revision+case when current_demanda_id is not null and current_demanda_id<>d.id then 1 else 0 end, service_started_at=case when current_demanda_id is not null and current_demanda_id<>d.id then m.sent_at else coalesce(service_started_at,m.sent_at) end, current_demanda_id=d.id where id=c.id and organization_id=c.organization_id returning * into c; end if; insert into public.demanda_conversas(organization_id,demanda_id,conversation_id,service_revision) values(c.organization_id,d.id,c.id,c.service_revision) on conflict(demanda_id,conversation_id) do update set service_revision=excluded.service_revision; update public.messages set service_revision=c.service_revision,demanda_id=d.id,demanda_revision=d.revision where id=m.id and organization_id=c.organization_id; end; $$; revoke execute on function public.fn_service_inbound(uuid) from public,anon,authenticated; grant execute on function public.fn_service_inbound(uuid) to service_role; -- ---- o negócio que nasce da conversa nasce na moeda da organização (migration 0400) ---- -- -- `fn_nascer_lead_da_conversa` (0256) não passava `currency`, e o insert pegava -- o default da coluna, 'BRL', em toda organização. Medido numa organização em -- guarani: 229 de 229 negócios em BRL. O valor do pedido, gravado depois pelo -- assistente, sairia para a Meta como ₲125.000 lidos em real. A rota REST e a -- ferramenta do agente já usavam a moeda da organização; faltava este caminho, -- que é o de TODO negócio nascido de uma mensagem. create or replace function public.fn_nascer_lead_da_conversa( p_org uuid, p_contact uuid, p_pipeline uuid, p_stage uuid, p_title text, p_source text, p_source_metadata jsonb default '{}'::jsonb, p_tags text[] default '{}'::text[] ) returns uuid language plpgsql security invoker set search_path = public as $$ declare v_id uuid; begin -- Serializa por (organização, contato). Transaction-scoped: liberado no -- commit, sem risco de lock vazado. perform pg_advisory_xact_lock(hashtextextended(p_org::text || ':' || p_contact::text, 0)); select id into v_id from public.crm_leads where organization_id = p_org and contact_id = p_contact and status = 'open' limit 1; -- NULL significa "já existe", e quem chama traduz isso para `ja_existe`. Não é -- erro: é o desfecho correto da segunda mensagem. if v_id is not null then return null; end if; -- A moeda é a da organização. Sem ela o negócio pegava o default da coluna -- ('BRL') em QUALQUER organização, e o valor que o assistente grava depois -- saía para a plataforma de anúncio como real: ₲125.000 viravam R$ 125.000. insert into public.crm_leads (organization_id, pipeline_id, stage_id, contact_id, title, source, source_metadata, tags, currency) values (p_org, p_pipeline, p_stage, p_contact, p_title, p_source, coalesce(p_source_metadata, '{}'::jsonb), coalesce(p_tags, '{}'::text[]), coalesce((select o.currency from public.organizations o where o.id = p_org), 'BRL')) returning id into v_id; return v_id; end; $$; revoke execute on function public.fn_nascer_lead_da_conversa(uuid, uuid, uuid, uuid, text, text, jsonb, text[]) from public, anon; grant execute on function public.fn_nascer_lead_da_conversa(uuid, uuid, uuid, uuid, text, text, jsonb, text[]) to authenticated, service_role; -- O que já nasceu errado. Só negócio SEM valor: sem valor, a moeda não diz nada -- e alinhar não muda número nenhum. Negócio COM valor fica como está — ali a -- moeda pode ter sido escolhida à mão, e trocar o rótulo mudaria o que o -- número significa. Só em organização que declarou moeda diferente do default. update public.crm_leads l set currency = o.currency from public.organizations o where o.id = l.organization_id and o.currency is not null and o.currency <> 'BRL' and l.currency = 'BRL' and l.value_cents is null; -- ---- Conversões: processamento e reenvio (migration 0401) ---- -- 0401: preservar conexões existentes; novos protocolos têm consulta durável. alter table public.ad_platform_connections add column if not exists google_api text not null default 'google_ads'; alter table public.ad_platform_connections drop constraint if exists ad_platform_connections_google_api_check; alter table public.ad_platform_connections add constraint ad_platform_connections_google_api_check check (google_api in ('google_ads', 'data_manager')); alter table public.ad_conversion_dispatches add column if not exists remote_request_id text, add column if not exists remote_requested_at timestamptz; -- Uma execução atrasada não pode apagar a prova de envio de outra execução. create or replace function public.fn_preservar_conversao_enviada() returns trigger language plpgsql set search_path = public as $$ begin if old.status = 'sent' and new.status <> 'sent' then return old; end if; return new; end; $$; revoke execute on function public.fn_preservar_conversao_enviada() from public, anon, authenticated; grant execute on function public.fn_preservar_conversao_enviada() to service_role; drop trigger if exists trg_preservar_conversao_enviada on public.ad_conversion_dispatches; create trigger trg_preservar_conversao_enviada before update on public.ad_conversion_dispatches for each row execute function public.fn_preservar_conversao_enviada(); -- Só o backend autorizado alcança esta porta. O evento é exclusivo do consumidor -- de conversões: reprocessar venda não dispara notificações/follow-ups de lead.won. create or replace function public.fn_solicitar_reenvio_conversao(p_org uuid, p_lead uuid) returns boolean language plpgsql set search_path = public as $$ declare v_linha public.ad_conversion_dispatches%rowtype; begin select * into v_linha from public.ad_conversion_dispatches where organization_id = p_org and lead_id = p_lead and event_name = 'Purchase' for update; if not found or v_linha.status = 'sent' then return false; end if; if v_linha.remote_request_id is null and not exists (select 1 from public.crm_leads where id = p_lead and organization_id = p_org and status = 'won') then return false; end if; if exists (select 1 from public.event_log where organization_id = p_org and entity_id = p_lead and event_type = 'ad_conversion.retry_requested' and status in ('pending', 'processing')) then return false; end if; perform public.emit_event('ad_conversion.retry_requested', 'crm_lead', p_lead, '{}'::jsonb, '{}'::jsonb, p_org); update public.ad_conversion_dispatches set reason = 'reprocessamento_solicitado', attempted_at = now() where id = v_linha.id and organization_id = p_org; return true; end; $$; revoke execute on function public.fn_solicitar_reenvio_conversao(uuid, uuid) from public, anon, authenticated; grant execute on function public.fn_solicitar_reenvio_conversao(uuid, uuid) to service_role; -- ---- Google: captura e qualificação (migration 0402) ---- -- Evolução de conversões já distribuídas: nenhuma integração é ligada automaticamente. alter table public.google_ads_click_refs alter column gclid drop not null; alter table public.google_ads_click_refs add column if not exists gbraid text, add column if not exists wbraid text; -- NOT VALID preserva eventuais linhas legadas inválidas sem inventar origem; -- continua exigindo identificador em toda escrita nova. alter table public.google_ads_click_refs drop constraint if exists google_click_tem_identificador; alter table public.google_ads_click_refs add constraint google_click_tem_identificador check (nullif(btrim(gclid), '') is not null or nullif(btrim(gbraid), '') is not null or nullif(btrim(wbraid), '') is not null) not valid; alter table public.ad_platform_connections add column if not exists google_qualification_stage_id uuid, add column if not exists google_qualification_action_id text, add column if not exists google_qualification_configured_at timestamptz; alter table public.ad_platform_connections drop constraint if exists ad_qualification_stage_org_fk; alter table public.ad_platform_connections add constraint ad_qualification_stage_org_fk foreign key (organization_id, google_qualification_stage_id) references public.crm_stages (organization_id, id) on delete set null (google_qualification_stage_id); alter table public.ad_platform_connections drop constraint if exists ad_qualification_action_distinta; alter table public.ad_platform_connections add constraint ad_qualification_action_distinta check (google_qualification_action_id is null or (google_qualification_action_id ~ '^[0-9]{1,32}$' and google_qualification_action_id is distinct from google_conversion_action_id)); -- Snapshot do primeiro envio: reprocessar não inventa data nem troca a ação. alter table public.ad_conversion_dispatches add column if not exists event_occurred_at timestamptz, add column if not exists google_action_id text; create or replace function public.fn_solicitar_reenvio_conversao(p_org uuid, p_lead uuid, p_event text) returns boolean language plpgsql set search_path = public as $$ declare v_linha public.ad_conversion_dispatches%rowtype; begin if p_event not in ('Purchase', 'QualifiedLead') then return false; end if; select * into v_linha from public.ad_conversion_dispatches where organization_id = p_org and lead_id = p_lead and event_name = p_event for update; if not found or v_linha.status = 'sent' then return false; end if; if p_event = 'QualifiedLead' and (v_linha.event_occurred_at is null or v_linha.google_action_id is null) then return false; end if; if p_event = 'Purchase' and v_linha.remote_request_id is null and not exists ( select 1 from public.crm_leads where id = p_lead and organization_id = p_org and status = 'won' ) then return false; end if; if exists (select 1 from public.event_log where organization_id = p_org and entity_id = p_lead and event_type = 'ad_conversion.retry_requested' and status in ('pending', 'processing') and coalesce(payload->>'event_name', 'Purchase') = p_event) then return false; end if; perform public.emit_event('ad_conversion.retry_requested', 'crm_lead', p_lead, jsonb_build_object('event_name', p_event), '{}'::jsonb, p_org); update public.ad_conversion_dispatches set reason = 'reprocessamento_solicitado', attempted_at = now() where id = v_linha.id and organization_id = p_org; return true; end; $$; revoke execute on function public.fn_solicitar_reenvio_conversao(uuid, uuid, text) from public, anon, authenticated; grant execute on function public.fn_solicitar_reenvio_conversao(uuid, uuid, text) to service_role; -- Assinatura anterior segue funcionando para clientes e eventos já existentes. create or replace function public.fn_solicitar_reenvio_conversao(p_org uuid, p_lead uuid) returns boolean language sql set search_path = public as $$ select public.fn_solicitar_reenvio_conversao(p_org, p_lead, 'Purchase'); $$; revoke execute on function public.fn_solicitar_reenvio_conversao(uuid, uuid) from public, anon, authenticated; grant execute on function public.fn_solicitar_reenvio_conversao(uuid, uuid) to service_role; -- Uma troca de regra só vale para movimentos posteriores à configuração. create or replace function public.fn_marcar_configuracao_qualificacao() returns trigger language plpgsql set search_path = public as $$ begin if tg_op = 'INSERT' then new.google_qualification_configured_at := now(); elsif new.google_qualification_stage_id is distinct from old.google_qualification_stage_id or new.google_qualification_action_id is distinct from old.google_qualification_action_id then new.google_qualification_configured_at := now(); else new.google_qualification_configured_at := old.google_qualification_configured_at; end if; return new; end; $$; revoke execute on function public.fn_marcar_configuracao_qualificacao() from public, anon, authenticated; grant execute on function public.fn_marcar_configuracao_qualificacao() to service_role; drop trigger if exists trg_marcar_configuracao_qualificacao on public.ad_platform_connections; create trigger trg_marcar_configuracao_qualificacao before insert or update on public.ad_platform_connections for each row execute function public.fn_marcar_configuracao_qualificacao(); create or replace function public.fn_preservar_conversao_enviada() returns trigger language plpgsql set search_path = public as $$ begin if old.status = 'sent' and new.status <> 'sent' then return old; end if; new.event_occurred_at := coalesce(old.event_occurred_at, new.event_occurred_at); new.google_action_id := coalesce(old.google_action_id, new.google_action_id); return new; end; $$; revoke execute on function public.fn_preservar_conversao_enviada() from public, anon, authenticated; grant execute on function public.fn_preservar_conversao_enviada() to service_role; -- ---- o lead só se liga a contato e responsável da própria empresa (migration 0403) ---- -- -- `crm_leads_contact_id_fkey` referencia só `contacts(id)` e a FK de -- `owner_user_id` só garante que a pessoa existe: nenhuma pergunta de QUAL -- organização. Os handlers de lead já conferem (app/api/v1/leads/_handler.ts), -- mas não são o único caminho: a REST do banco (`/rest/v1/crm_leads`, com GRANT -- para `authenticated` e políticas que não olham `contact_id`) e a RPC -- `fn_nascer_lead_da_conversa` (security invoker) gravavam o vínculo cruzado. -- A regra passa a morar na tabela, onde todo escritor passa. -- -- 1 · CURA, antes do gatilho, genérica (sem id fixo) e idempotente: -- - lead cujo contato é de OUTRA organização perde o contato; -- - lead cujo responsável NUNCA foi membro da organização do lead (nenhum -- vínculo, revogado ou não) perde o responsável. -- Cada lead curado ganha uma atividade `lead_edited` de sistema dizendo o -- porquê, sem o id da outra organização. Reaplicar não acha mais nada. -- Responsável DESLIGADO (vínculo revogado) ou viewer NÃO é curado: é um -- estado legítimo do passado — o lead era dele — e o gatilho não o exige -- de quem não mexe no campo. -- -- 2 · GATILHO BEFORE INSERT OR UPDATE OF contact_id, owner_user_id, -- organization_id: no INSERT confere o que vier preenchido; no UPDATE só o -- campo que MUDOU (IS DISTINCT FROM OLD) — reenviar o que o lead já tem não -- é ligar de novo. A régua do responsável é a do handler (G3-04): vínculo -- não revogado e papel acima de viewer. -- -- 3 · O ERRO não diz se o id existe noutra organização. SQLSTATE `PT404` / -- `PT422`: o PostgREST devolve 404 / 422 com a mensagem genérica, e o -- handler traduz os mesmos códigos. -- -- Security definer com search_path fixo: a função precisa ler `contacts` e -- `user_organizations` de quem chama sob RLS (um `agent` não vê o vínculo dos -- colegas). Não é RPC: revoga as duas origens de EXECUTE e não concede a -- ninguém — o gatilho roda com o dono da função, não com o privilégio de quem -- escreve. -- 1 · cura ------------------------------------------------------------------ with curados as ( update public.crm_leads l set contact_id = null where l.contact_id is not null and not exists ( select 1 from public.contacts c where c.id = l.contact_id and c.organization_id = l.organization_id ) returning l.id, l.organization_id ) insert into public.crm_lead_activities (organization_id, lead_id, contact_id, source_module, source_id, type, actor_kind, reason, payload) select organization_id, id, null, 'crm', id, 'lead_edited', 'system', 'Contato desvinculado: ele não pertence a esta empresa', jsonb_build_object('fields', jsonb_build_array('contact_id'), 'motivo', 'contato_de_outra_organizacao') from curados; with curados as ( update public.crm_leads l set owner_user_id = null, owner_kind = case when l.owner_kind = 'user' then null else l.owner_kind end where l.owner_user_id is not null and not exists ( select 1 from public.user_organizations uo where uo.user_id = l.owner_user_id and uo.organization_id = l.organization_id ) returning l.id, l.organization_id ) insert into public.crm_lead_activities (organization_id, lead_id, contact_id, source_module, source_id, type, actor_kind, reason, payload) select organization_id, id, null, 'crm', id, 'lead_edited', 'system', 'Responsável removido: a pessoa não é membro desta empresa', jsonb_build_object('fields', jsonb_build_array('owner_user_id'), 'motivo', 'responsavel_de_outra_organizacao') from curados; -- 2 · gatilho --------------------------------------------------------------- create or replace function public.fn_lead_so_liga_a_propria_empresa() returns trigger language plpgsql security definer set search_path = public, pg_temp as $$ declare v_mudou_org boolean := tg_op = 'UPDATE' and new.organization_id is distinct from old.organization_id; begin if new.contact_id is not null and (tg_op = 'INSERT' or v_mudou_org or new.contact_id is distinct from old.contact_id) and not exists ( select 1 from public.contacts c where c.id = new.contact_id and c.organization_id = new.organization_id ) then raise exception 'Contato não encontrado.' using errcode = 'PT404'; end if; if new.owner_user_id is not null and (tg_op = 'INSERT' or v_mudou_org or new.owner_user_id is distinct from old.owner_user_id) and not exists ( select 1 from public.user_organizations uo where uo.user_id = new.owner_user_id and uo.organization_id = new.organization_id and uo.revoked_at is null and uo.role <> 'viewer' ) then raise exception 'Responsável não é um atendente ativo desta organização.' using errcode = 'PT422'; end if; return new; end; $$; revoke execute on function public.fn_lead_so_liga_a_propria_empresa() from public, anon, authenticated; comment on function public.fn_lead_so_liga_a_propria_empresa() is 'Gatilho de crm_leads (migration 0403): contact_id e owner_user_id só apontam para a própria organização. No UPDATE só confere o campo que mudou. Erro genérico PT404/PT422, sem dizer se o id existe noutra organização.'; drop trigger if exists trg_lead_so_liga_a_propria_empresa on public.crm_leads; create trigger trg_lead_so_liga_a_propria_empresa before insert or update of contact_id, owner_user_id, organization_id on public.crm_leads for each row execute function public.fn_lead_so_liga_a_propria_empresa(); -- ---- 0404 — `comando_da_conversa` deixa de reavaliar a RLS por conversa (issue #1571) ---- -- -- Apêndice idempotente: o `update.sh` do clone re-executa este bloco inteiro a -- cada atualização — e PRECISA, porque a definição do MEIO deste arquivo ainda -- nasce namedada + invoker (a 0203, como ela foi escrita). Aqui mora a decisão -- da 0404: SECURITY DEFINER para a contagem das abas da Inbox parar de pagar a -- policy de `contacts` duas vezes por conversa (medido na issue: 823–846 ms → -- 75–94 ms em 528 conversas), e parâmetro SEM NOME para a PostgREST não publicar -- a função em `/rpc` — com nome, uma linha fabricada de `conversations` leria -- `force_human`/`is_blocked` de outro tenant sob o definer. A coluna calculada -- (`?select=`, `?comando_da_conversa=in.(...)`) não muda de forma. As duas -- subconsultas exigem `ct.organization_id = $1.organization_id`: a policy de -- UPDATE de `conversations` não confere o `contact_id` e a FK não passa pela RLS, -- então sem o predicado uma conversa apontada para contato de outra empresa -- leria os dois bits dele. Entra ANTES da varredura anon porque cria função. -- -- DROP sem `cascade`: medi que nada em `supabase/` depende desta função além -- dela mesma. O DROP leva a ACL, então as DUAS origens de EXECUTE voltam -- explícitas; o `notify` é obrigatório porque a forma do schema mudou (o /rpc -- some) e sem ele o PostgREST serve o schema velho até reinício manual. drop function if exists public.comando_da_conversa(public.conversations); create function public.comando_da_conversa(public.conversations) returns text language sql stable security definer set search_path = public as $comando$ select public.fn_comando_da_conversa( $1.status, $1.assigned_to_user_id, $1.bot_silenced_until, coalesce((select ct.force_human from public.contacts ct where ct.id = $1.contact_id and ct.organization_id = $1.organization_id), false), coalesce((select ct.is_blocked from public.contacts ct where ct.id = $1.contact_id and ct.organization_id = $1.organization_id), false), now() ); $comando$; comment on function public.comando_da_conversa(public.conversations) is 'Campo calculado exposto pelo PostgREST: ?select=comando_da_conversa e ?comando_da_conversa=in.(...). Resolve o contato e carimba now(); a regra em si é fn_comando_da_conversa. SECURITY DEFINER desde a 0404 (issue #1571: a contagem das abas reavaliava a RLS de contacts 2x por conversa); parâmetro SEM NOME de propósito — com nome a PostgREST a exporia em /rpc, e ali uma linha fabricada leria force_human/is_blocked de outro tenant.'; revoke execute on function public.comando_da_conversa(public.conversations) from public, anon; grant execute on function public.comando_da_conversa(public.conversations) to authenticated, service_role; notify pgrst, 'reload schema'; -- ---- o motivo 'member_revoked' na auditoria de atribuição (migration 0405, #1562, @webtecnica) ---- -- `fn_routing_member_revoked` (editada no lugar, no bloco da 0228) grava -- reason='member_revoked' ao devolver à fila as conversas de quem foi revogado. -- O CHECK inline da tabela (batizado `conversation_assignment_events_reason_check`) -- não aceitava o valor: toda revogação com conversa aberta falhava com 23514. -- Bloco ÚNICO desta constraint, com o conjunto final; as linhas existentes -- cabem nele, então reaplicar no `update.sh` não viola nada. alter table public.conversation_assignment_events drop constraint if exists conversation_assignment_events_reason_check; alter table public.conversation_assignment_events add constraint conversation_assignment_events_reason_check check (reason in ('claim','transfer','release','routing','handoff','member_revoked')); -- ---- logo por tema: coluna da instalação (migration 0406) ---- -- 0406 — Logo opcional para o tema escuro, preservando o logo padrão. -- Aditiva: código anterior continua usando logo_path; rollback de imagem não -- exige apagar coluna, arquivos ou dados. Somente a rota de logo escreve os caminhos. alter table public.platform_branding add column if not exists logo_dark_path text; update public.platform_branding set logo_dark_path = null where logo_dark_path is not null and logo_dark_path !~ '^platform/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(png|jpg)$'; alter table public.platform_branding drop constraint if exists platform_branding_logo_dark_path; alter table public.platform_branding add constraint platform_branding_logo_dark_path check ( logo_dark_path is null or logo_dark_path ~ '^platform/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(png|jpg)$' ); comment on column public.platform_branding.logo_dark_path is 'Logo opcional para fundo escuro, sem moldura branca. Caminho em brand-logos; null conserva o comportamento do logo padrão.'; -- ---- logo por tema: funções (migration 0406) ---- create or replace function public.fn_definir_logo_por_tema_da_organizacao( p_org uuid, p_actor uuid, p_path text, p_tema text ) returns integer language plpgsql volatile security definer set search_path to 'public', 'pg_temp' as $$ declare v_linhas integer; v_path text; v_campo text; begin if p_org is null or p_actor is null then raise exception 'logo_da_organizacao_argumento_nulo' using errcode = '22023'; end if; if p_tema is null or p_tema not in ('claro', 'escuro') then raise exception 'logo_tema_invalido' using errcode = '22023'; end if; v_campo := case when p_tema = 'escuro' then 'logo_dark_path' else 'logo_path' end; v_path := nullif(btrim(coalesce(p_path, '')), ''); -- O PREFIXO ASSEVERADO DENTRO DO BANCO — o gate que sobrevive ao segundo -- chamador. A rota monta o caminho a partir da organização resolvida do -- cookie, mas "a rota monta certo" é promessa de UM chamador. Sem esta linha, -- um caminho de outro escopo (o `platform/...` que qualquer pessoa lê no HTML -- da tela de login) entraria como logo da organização — e o delete-on-replace -- da rota, rodando como `service_role`, apagaria o logo da instalação inteira -- na troca seguinte. if v_path is not null and v_path !~ ('^' || p_org::text || '/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(png|jpg)$') then raise exception 'logo_da_organizacao_caminho_fora_do_escopo' using errcode = '22023'; end if; if not exists ( select 1 from public.user_organizations uo where uo.user_id = p_actor and uo.organization_id = p_org and uo.role = 'admin' and uo.revoked_at is null ) and not exists ( select 1 from public.platform_admins pa where pa.user_id = p_actor and pa.revoked_at is null ) then raise exception 'logo_da_organizacao_sem_permissao' using errcode = '42501'; end if; -- Merge no CAMPO. `jsonb_set` direto em '{branding,logo_path}' NÃO serviria: -- com `branding` ausente, `create_missing` só cria a ÚLTIMA chave e o caminho -- intermediário faltando devolve o jsonb original intocado — silenciosamente. update public.organizations o set settings = case when v_path is null then jsonb_set( coalesce(o.settings, '{}'::jsonb), '{branding}', coalesce(o.settings -> 'branding', '{}'::jsonb) - v_campo, true) else jsonb_set( coalesce(o.settings, '{}'::jsonb), '{branding}', coalesce(o.settings -> 'branding', '{}'::jsonb) || jsonb_build_object(v_campo, v_path), true) end where o.id = p_org; get diagnostics v_linhas = row_count; return v_linhas; end; $$; create or replace function public.fn_definir_logo_da_organizacao( p_org uuid, p_actor uuid, p_path text ) returns integer language sql volatile security invoker set search_path to 'public', 'pg_temp' as $$ select public.fn_definir_logo_por_tema_da_organizacao(p_org, p_actor, p_path, 'claro'); $$; create or replace function public.fn_definir_marca_da_organizacao( p_org uuid, p_actor uuid, p_marca jsonb ) returns integer language plpgsql volatile security definer set search_path to 'public', 'pg_temp' as $$ declare v_linhas integer; v_hex text; v_limpar boolean; begin if p_org is null or p_actor is null then raise exception 'marca_da_organizacao_argumento_nulo' using errcode = '22023'; end if; v_limpar := p_marca is null or jsonb_typeof(p_marca) = 'null'; if not v_limpar and jsonb_typeof(p_marca) <> 'object' then raise exception 'marca_da_organizacao_forma_invalida: %', jsonb_typeof(p_marca) using errcode = '22023'; end if; v_hex := nullif(p_marca ->> 'accent_hex', ''); if v_hex is not null and v_hex !~ '^#[0-9a-f]{6}$' then raise exception 'marca_da_organizacao_accent_hex_invalido' using errcode = '22023'; end if; if not exists ( select 1 from public.user_organizations uo where uo.user_id = p_actor and uo.organization_id = p_org and uo.role = 'admin' and uo.revoked_at is null ) and not exists ( select 1 from public.platform_admins pa where pa.user_id = p_actor and pa.revoked_at is null ) then raise exception 'marca_da_organizacao_sem_permissao' using errcode = '42501'; end if; -- Nome/cor não podem injetar nem apagar os arquivos, que têm rota própria. update public.organizations o set settings = case when v_limpar and coalesce(o.settings #>> '{branding,logo_path}', '') = '' and coalesce(o.settings #>> '{branding,logo_dark_path}', '') = '' then coalesce(o.settings, '{}'::jsonb) - 'branding' else jsonb_set( coalesce(o.settings, '{}'::jsonb), '{branding}', (case when v_limpar then '{}'::jsonb else p_marca - 'logo_path' - 'logo_dark_path' end) || jsonb_strip_nulls(jsonb_build_object( 'logo_path', o.settings #> '{branding,logo_path}', 'logo_dark_path', o.settings #> '{branding,logo_dark_path}' )), true) end where o.id = p_org; get diagnostics v_linhas = row_count; return v_linhas; end; $$; revoke execute on function public.fn_definir_logo_por_tema_da_organizacao(uuid, uuid, text, text) from public, anon, authenticated; grant execute on function public.fn_definir_logo_por_tema_da_organizacao(uuid, uuid, text, text) to service_role; revoke execute on function public.fn_definir_logo_da_organizacao(uuid, uuid, text) from public, anon, authenticated; grant execute on function public.fn_definir_logo_da_organizacao(uuid, uuid, text) to service_role; revoke execute on function public.fn_definir_marca_da_organizacao(uuid, uuid, jsonb) from public, anon, authenticated; grant execute on function public.fn_definir_marca_da_organizacao(uuid, uuid, jsonb) to service_role; notify pgrst, 'reload schema'; -- ---- a fusão de fichas herda a identidade social (migration 0407, issue #1455) ---- -- -- `fn_mesclar_contatos` herdava `waha_lid` (a origem da identidade de -- WhatsApp) mas não `social_identity`. Com o #1444 o canal social filtra -- `is_merged_into is null`, então sem esta herança a próxima DM de quem veio -- por Instagram não acha ficha viva e abre uma nova — refazendo a duplicata que -- a fusão acabou de desfazer. Mesmo desenho do `waha_lid`: o perdedor entrega o -- campo quando o vencedor não tem um, e a guarda de unicidade espelha a dos -- demais (o índice é parcial em `is_merged_into is null`, então o conflito -- possível é só com um terceiro contato vivo, e nesse caso o vencedor não -- herda). O corpo é o mesmo da migration 0407 — os dois artefatos têm de -- divergir juntos (tests/unit/apendice-do-baseline-nao-diverge-da-cadeia.test.ts). CREATE OR REPLACE FUNCTION public.fn_mesclar_contatos(p_organization_id uuid, p_contato_principal uuid, p_contatos_secundarios uuid[]) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $function$ declare v_principal public.contacts%rowtype; v_esperado integer; v_achado integer; v_alvo record; v_linha record; v_movidas integer; v_pulados integer; v_repontado jsonb := '{}'::jsonb; v_nao_repontado jsonb := '{}'::jsonb; v_nome text; v_apelido text; v_nascimento date; v_email text; v_telefone text; v_lid text; v_social text; v_tags text[]; v_leads integer := 0; v_service_contact uuid; begin if not public.fn_support_write_allowed(p_organization_id) then raise exception 'support_readonly' using errcode='42501'; end if; -- 1 · Autorização. Fundir é destrutivo na prática: `manager`, o mesmo piso das -- policies de `merge_queue`. Sessão de service role (auth.uid() nulo) não -- passa por aqui — quem resolve a org nesse caminho é a rota, de fonte -- confiável, nunca do body. if auth.uid() is not null and not public.fn_role_at_least(p_organization_id, 'manager') then raise exception using errcode = '42501', message = 'insufficient_role'; end if; if p_contato_principal is null or p_contatos_secundarios is null or cardinality(p_contatos_secundarios) = 0 or p_contato_principal = any(p_contatos_secundarios) then raise exception using errcode = '22023', message = 'selecao_de_mesclagem_invalida'; end if; select count(distinct id)::integer into v_esperado from unnest(p_contatos_secundarios) as ids(id); if v_esperado <> cardinality(p_contatos_secundarios) then raise exception using errcode = '22023', message = 'secundario_repetido'; end if; -- A TRAVA DA REGRA "CLIENTES PELA AGENDA" (migration 0262), ANTES DE TODA -- OUTRA. O passo 5 reponta `calendar_appointments.contact_id`, e o trigger -- desse repontamento pede `pg_advisory_xact_lock_shared(org, 262)` — só que -- a esta altura a fusão já segura os contatos (passos 2 e 3). -- `fn_definir_cliente_pela_agenda` pega a mesma trava EXCLUSIVA e depois -- trava contato por contato. Medido com duas sessões, sem esta linha: a fusão -- morria em `deadlock detected` e a rota devolvia 500. Aqui a ordem fica a -- mesma das duas funções — a organização primeiro, os contatos depois. Duas -- fusões, ou uma fusão e uma marcação, pegam a versão compartilhada e não se -- esperam. perform pg_catalog.pg_advisory_xact_lock_shared(pg_catalog.hashtextextended(p_organization_id::text, 262)); -- Mesmo mutex dos atendimentos, ANTES de qualquer row lock. for v_service_contact in select distinct id from unnest(array[p_contato_principal]||p_contatos_secundarios) ids(id) order by id loop perform public.fn_service_lock(p_organization_id,v_service_contact); end loop; perform 1 from public.conversations where organization_id=p_organization_id and contact_id=any(array[p_contato_principal]||p_contatos_secundarios) order by id for no key update; -- Conversa colidente NÃO aborta a fusão. Duas conversas no mesmo -- `channel_session_id` é exatamente COMO a duplicata de WhatsApp nasce (dois -- cadastros, dois números, o mesmo número de atendimento), então recusar aqui -- fecharia o caminho dominante do recurso — medido: o caso ordinário do -- `tests/e2e/juntar-contatos-duplicados.spec.ts` virava 409. -- Quem trata a colisão é o passo 5: `uniq_conversations_1to1_per_contact_session` -- levanta unique_violation, o repontamento cai para linha a linha, a conversa -- que não coube FICA na lápide e sai contada em `nao_repontado` — que a rota -- devolve e a tela anuncia ("N registro(s) continuaram no cadastro antigo"). -- Mensagem não se perde: `messages.contact_id` não tem índice único por -- contato e passa inteira para o vencedor. -- 2 · O principal existe, é desta org, está vivo — e trava até o fim. select * into v_principal from public.contacts where id = p_contato_principal and organization_id = p_organization_id and is_merged_into is null and is_anonymized = false for update; if not found then raise exception using errcode = 'P0002', message = 'contato_principal_indisponivel'; end if; -- 3 · Os secundários também. `is_anonymized = false` não é zelo: L-04 é -- irreversível, e reencaixar a linha anonimizada num contato ativo a -- traria de volta ao atendimento pela porta dos fundos. perform 1 from public.contacts where id = any(p_contatos_secundarios) and organization_id = p_organization_id and is_merged_into is null and is_anonymized = false for update; get diagnostics v_achado = row_count; if v_achado <> v_esperado then raise exception using errcode = 'P0002', message = 'contato_secundario_indisponivel'; end if; -- 4 · A LÁPIDE VEM ANTES de tudo. É ela que solta telefone/e-mail/CPF dos -- índices únicos parciais para o vencedor poder herdá-los no passo 6. update public.contacts set is_merged_into = p_contato_principal, merged_at = now(), updated_at = now() where organization_id = p_organization_id and id = any(p_contatos_secundarios); -- Cadeia: quem já tinha sido mesclado NUM dos secundários passa a apontar para -- o vencedor. Sem isto, `is_merged_into` vira uma corrente que a leitura teria -- de percorrer, e ninguém percorre. update public.contacts set is_merged_into = p_contato_principal where organization_id = p_organization_id and is_merged_into = any(p_contatos_secundarios); -- 5 · Reponta TODO ponteiro para os perdedores. A lista sai do catálogo; o -- polimórfico entra à mão porque catálogo nenhum o conhece. for v_alvo in select n.nspname as esquema, c.relname as tabela, a.attname as coluna, ''::text as filtro from pg_catalog.pg_constraint co join pg_catalog.pg_class c on c.oid = co.conrelid join pg_catalog.pg_namespace n on n.oid = c.relnamespace join pg_catalog.pg_attribute a on a.attrelid = co.conrelid and a.attnum = co.conkey[1] where co.contype = 'f' and co.confrelid = 'public.contacts'::regclass and co.conrelid <> 'public.contacts'::regclass and array_length(co.conkey, 1) = 1 and c.relkind = 'r' and n.nspname = 'public' union all select 'public', 'crm_lead_links', 'target_id', ' and target_kind = ''contact''' where to_regclass('public.crm_lead_links') is not null order by 2, 3 loop v_pulados := 0; begin execute format( 'update %I.%I set %I = $1 where %I = any($2)%s', v_alvo.esquema, v_alvo.tabela, v_alvo.coluna, v_alvo.coluna, v_alvo.filtro ) using p_contato_principal, p_contatos_secundarios; get diagnostics v_movidas = row_count; exception when unique_violation or exclusion_violation then -- Colisão REAL e esperada: `uniq_job_queue_one_running_per_contact` deixa -- um job 'running' por contato, e os dois lados podem ter um. Em vez de -- abortar a fusão inteira por causa de estado efêmero de runtime, reponta -- linha a linha e conta quem ficou. Quem fica NÃO vira FK órfã — continua -- apontando para a lápide, que existe. v_movidas := 0; for v_linha in execute format( 'select ctid as tid from %I.%I where %I = any($1)%s', v_alvo.esquema, v_alvo.tabela, v_alvo.coluna, v_alvo.filtro ) using p_contatos_secundarios loop begin execute format( 'update %I.%I set %I = $1 where ctid = $2', v_alvo.esquema, v_alvo.tabela, v_alvo.coluna ) using p_contato_principal, v_linha.tid; v_movidas := v_movidas + 1; exception when unique_violation or exclusion_violation then v_pulados := v_pulados + 1; end; end loop; end; if v_movidas > 0 then v_repontado := v_repontado || jsonb_build_object(v_alvo.tabela || '.' || v_alvo.coluna, v_movidas); end if; if v_pulados > 0 then v_nao_repontado := v_nao_repontado || jsonb_build_object(v_alvo.tabela || '.' || v_alvo.coluna, v_pulados); end if; end loop; -- 6 · O principal MANDA; o que ele não tem, vem dos perdedores. Nunca o -- contrário: sobrescrever o que o atendente digitou seria fusão com -- surpresa, e fusão não tem desfazer. select c.name into v_nome from public.contacts c where c.id = any(p_contatos_secundarios) and c.name is not null order by c.created_at, c.id limit 1; select c.display_name into v_apelido from public.contacts c where c.id = any(p_contatos_secundarios) and c.display_name is not null order by c.created_at, c.id limit 1; select c.birthdate into v_nascimento from public.contacts c where c.id = any(p_contatos_secundarios) and c.birthdate is not null order by c.created_at, c.id limit 1; select c.email into v_email from public.contacts c where c.id = any(p_contatos_secundarios) and c.email is not null order by c.created_at, c.id limit 1; select c.phone_number into v_telefone from public.contacts c where c.id = any(p_contatos_secundarios) and c.phone_number is not null order by c.created_at, c.id limit 1; -- `wa_identity`/`wa_lid` são GERADAS: o que se herda é a origem delas. Sem -- isto o WhatsApp do perdedor fica órfão — `fn_upsert_wa_contact` filtra -- `is_merged_into is null`, não acharia mais ninguém e criaria um contato -- novo na mensagem seguinte, refazendo a duplicata que acabou de ser desfeita. select c.source_metadata->>'waha_lid' into v_lid from public.contacts c where c.id = any(p_contatos_secundarios) and c.source_metadata->>'waha_lid' is not null order by c.created_at, c.id limit 1; -- A identidade social é a MESMA razão do `waha_lid`, pelo lado de quem fala -- por rede social: com o #1444 `upsertSocialContact` filtra -- `is_merged_into is null`, então sem herdar a identidade a próxima DM daquela -- pessoa não acha ficha viva com esta identidade e abre uma nova — refazendo a -- duplicata que a fusão acabou de desfazer (issue #1455). O índice -- `contacts_org_social_identity_unique` é parcial em `is_merged_into is null`, -- então o único conflito possível é com um TERCEIRO contato vivo. select c.social_identity into v_social from public.contacts c where c.id = any(p_contatos_secundarios) and c.social_identity is not null order by c.created_at, c.id limit 1; -- Guardas de unicidade. A lápide já tirou os perdedores dos índices parciais, -- então o que sobrar aqui é conflito com um TERCEIRO contato vivo — e nesse -- caso o vencedor simplesmente não herda o campo. Falhar a fusão inteira por -- causa de um e-mail seria perder o repontamento que já valeu a pena. if v_email is not null and exists ( select 1 from public.contacts o where o.organization_id = p_organization_id and o.is_merged_into is null and o.id <> p_contato_principal and o.email_normalized = lower(btrim(v_email)) ) then v_email := null; end if; if v_telefone is not null and exists ( select 1 from public.contacts o where o.organization_id = p_organization_id and o.is_merged_into is null and o.id <> p_contato_principal and o.phone_number = v_telefone ) then v_telefone := null; end if; if v_lid is not null and exists ( select 1 from public.contacts o where o.organization_id = p_organization_id and o.is_merged_into is null and o.id <> p_contato_principal and o.wa_lid = v_lid ) then v_lid := null; end if; if v_social is not null and exists ( select 1 from public.contacts o where o.organization_id = p_organization_id and o.is_merged_into is null and o.id <> p_contato_principal and o.social_identity = v_social ) then v_social := null; end if; select coalesce(array_agg(distinct t), '{}'::text[]) into v_tags from ( select unnest(c.tags) as t from public.contacts c where c.organization_id = p_organization_id and (c.id = p_contato_principal or c.id = any(p_contatos_secundarios)) ) as todas; -- CPF e `consent` NÃO são herdados, de propósito. CPF é um PAR -- (`cpf_encrypted` + `cpf_hash`) preso por check constraint e criptografado -- com a chave da instalação — mover metade quebra a linha. `consent` é -- registro legal do que AQUELA pessoa autorizou; herdar um "granted_at" de -- outro cadastro fabricaria consentimento. Falha fechada nos dois. update public.contacts set name = coalesce(name, v_nome), display_name = coalesce(display_name, v_apelido), birthdate = coalesce(birthdate, v_nascimento), email = coalesce(email, v_email), phone_number = coalesce(phone_number, v_telefone), social_identity = coalesce(social_identity, v_social), tags = v_tags, last_activity_at = greatest( last_activity_at, (select max(c.last_activity_at) from public.contacts c where c.id = any(p_contatos_secundarios)) ), source_metadata = ( case when source_metadata->>'waha_lid' is null and v_lid is not null then source_metadata || jsonb_build_object('waha_lid', v_lid) else source_metadata end ) - case when coalesce(phone_number, v_telefone) is not null then 'telefone_em_conflito' else '' end || jsonb_build_object( 'mesclado_de', coalesce(source_metadata->'mesclado_de', '[]'::jsonb) || to_jsonb(p_contatos_secundarios), 'mesclado_em', to_jsonb(now()) ), updated_at = now() where id = p_contato_principal and organization_id = p_organization_id; -- 7 · A fusão aparece na timeline de cada negócio que o vencedor passou a ter. -- `crm_lead_activities.lead_id` é NOT NULL — contato sem negócio nenhum -- não tem onde escrever, e para esse caso quem guarda o rastro é o -- `api_audit_log` que a rota emite, sempre. insert into public.crm_lead_activities (organization_id, lead_id, contact_id, source_module, source_id, type, payload, metadata, performed_at, performed_by_user_id) select p_organization_id, l.id, p_contato_principal, 'crm', p_contato_principal, 'contacts_merged', jsonb_build_object( 'contatos_mesclados', to_jsonb(p_contatos_secundarios), 'repontado', v_repontado, 'nao_repontado', v_nao_repontado ), '{}'::jsonb, now(), auth.uid() from public.crm_leads l where l.organization_id = p_organization_id and l.contact_id = p_contato_principal; get diagnostics v_leads = row_count; return jsonb_build_object( 'contato_id', p_contato_principal, 'contatos_mesclados', to_jsonb(p_contatos_secundarios), 'repontado', v_repontado, 'nao_repontado', v_nao_repontado, 'atividades_emitidas', v_leads ); end; $function$; revoke execute on function public.fn_mesclar_contatos(uuid, uuid, uuid[]) from public, anon; grant execute on function public.fn_mesclar_contatos(uuid, uuid, uuid[]) to authenticated, service_role; notify pgrst, 'reload schema'; -- ---- 0408 — os candidatos da prospecção nativa ganham prazo (issue #1313) ---- -- -- Apêndice idempotente: o `update.sh` do clone re-executa este bloco inteiro a -- cada atualização. Quem aplica o prazo é ESTA função, chamada em lotes pelo -- cron `app/api/v1/cron/data-retention` — a declaração em -- `lib/retencao/politica.ts` sem ela é decorativa, e o teste de guarda diz -- isso. Padrão 365 / piso 90, decisão do dono (24/09/2026, PR #1577). -- O relógio é `coalesce(attempted_at, created_at)`: nunca contatado conta da -- criação, contatado conta da última tentativa. `queued`/`sending` nunca -- entram (trabalho vivo) e o tombstone de LGPD (0370) nunca entra — é ele que -- faz o trigger `prospecting_refuse_erased` barrar a reimportação. create or replace function public.fn_expurgar_prospeccao_vencida( p_retencao_dias int default null, p_limite int default null ) returns int language plpgsql security definer set search_path = public, pg_temp as $$ declare -- 365 = um ano, o horizonte decidido pelo dono (0408, issue #1313). O piso -- de 90 impede que o knob vire apagador de rastro recente — e mora AQUI, -- no corpo, para valer contra qualquer chamador. v_dias int := greatest(coalesce(p_retencao_dias, 365), 90); v_limite int := least(greatest(coalesce(p_limite, 1000), 1), 10000); v_apagadas int; begin with vencidos as ( select c.id from public.prospecting_candidates c where c.status not in ('queued','sending') and c.suppression_salt is null and coalesce(c.attempted_at, c.created_at) < now() - make_interval(days => v_dias) order by coalesce(c.attempted_at, c.created_at) limit v_limite ) delete from public.prospecting_candidates c using vencidos v where c.id = v.id; get diagnostics v_apagadas = row_count; return v_apagadas; end; $$; revoke all on function public.fn_expurgar_prospeccao_vencida(int,int) from public; revoke execute on function public.fn_expurgar_prospeccao_vencida(int,int) from anon; revoke execute on function public.fn_expurgar_prospeccao_vencida(int,int) from authenticated; grant execute on function public.fn_expurgar_prospeccao_vencida(int,int) to service_role; create index if not exists prospecting_candidates_expira_idx on public.prospecting_candidates ((coalesce(attempted_at, created_at))) where status not in ('queued','sending') and suppression_salt is null; -- ---- reindexação incremental: hash do conteúdo indexado (migration 0409, de @vgamkt, #1130) ---- -- O indexador pula a fonte cujo conteúdo não mudou desde a última indexação -- bem-sucedida com o mesmo modelo. Racional inteiro na migration 0409. alter table public.ai_knowledge_sources add column if not exists content_hash text; comment on column public.ai_knowledge_sources.content_hash is 'Hash do conteúdo que foi indexado por último. O indexador pula a reindexação quando o hash atual é igual E o modelo de embedding da versão ativa é o mesmo.'; -- ---- birthdate na fila de proposta (migration 0412, issue #1546) ---- -- NADA DE DDL AQUI, e a razão é a cerca `baseline-constraint-reconstruida`: -- `contact_field_proposals_campo_check` já tem o seu bloco ÚNICO, e foi ele que -- a 0412 editou, acrescentando `birthdate` ao conjunto. Um segundo `add` -- constraint neste apêndice faria o bloco antigo falhar no `update.sh` de um -- clone cuja fila já tenha uma proposta de nascimento — e deixaria a tabela sem -- constraint entre o `drop` e o `add` que funciona, se o run morrer no meio. -- O vocabulário novo é lido lá onde a constraint mora; esta linha é só o -- marcador de que a mudança existe e onde ela foi parar. -- ---- provedor personalizado: endereço da credencial (migration 0413, #1642) ---- -- -- `base_url` na linha da credencial: o endereço do endpoint compatível com a -- OpenAI que vai receber a chave, escolha do operador na tela de Credenciais. -- Aditiva e idempotente; nenhum provedor nativo muda (`null` em toda linha -- existente, e o runtime só lê a coluna quando o provider é `custom`). -- Racional inteiro na migration 0413. alter table public.ai_provider_credentials add column if not exists base_url text; -- Forma do dado no banco, igual à da aplicação (zod da rota): http(s) sem -- espaço. O CHECK é o que sobra para quem escrever direto no SQL ou pelo -- PostgREST — e `null` continua sendo a resposta de todo provedor nativo. alter table public.ai_provider_credentials drop constraint if exists ai_provider_credentials_base_url_check; alter table public.ai_provider_credentials add constraint ai_provider_credentials_base_url_check check (base_url is null or base_url ~* '^https?://[^[:space:]]+$'); -- A view é a ÚNICA superfície de leitura da tela: expor `base_url` aqui é o -- que faz a tela mostrar o endereço cadastrado sem abrir a tabela. Coluna nova -- no FIM da lista — `create or replace view` não renomea nem reordena coluna -- existente. create or replace view public.ai_provider_credentials_safe with (security_invoker = true) as select id, organization_id, provider, label, api_key_last4, validated_at, validation_error, models_available, is_active, created_by, created_at, updated_at, base_url from public.ai_provider_credentials; -- O SELECT é POR COLUNA desde a 0150: as três colunas do segredo ficam fora -- de propósito, e `revoke` de tabela inteira é quem as mantém fora. A lista tem -- de acompanhar a tabela — sem `base_url` aqui, a view nova responderia -- "permission denied for table ai_provider_credentials" para todo manager, e a -- tela de Credenciais viraria `[]`. `base_url` não é segredo: é um endpoint. revoke select on public.ai_provider_credentials from authenticated, anon; grant select ( id, organization_id, provider, label, api_key_last4, validated_at, validation_error, models_available, is_active, created_by, created_at, updated_at, base_url ) on public.ai_provider_credentials to authenticated; grant select on public.ai_provider_credentials_safe to authenticated; -- O PostgREST guarda o schema em cache; sem isto a coluna nova só aparece no -- próximo reload. notify pgrst, 'reload schema'; -- ---- redact unificado: o portão do botão chama a cascata canônica (migration 0414, issue #1504) ---- -- -- Os DOIS caminhos de anonimizar passam a redigir na MESMA função, -- `fn_lgpd_cascade_redact_contact`: o botão da ficha (`fn_lgpd_anonymize_contact`, -- este portão) e o pedido formal (`lib/lgpd/redact-cascade.ts`). Antes desta -- troca o botão reescrevia o CONTATO e mais nada — as 11 tabelas ligadas ao -- contato (orders, sales, voice_calls, prospecting_candidates, agent_cases, -- agent_case_events, demandas, agent_inbox_items, agent_case_chat_messages, -- passagens_de_atendimento, entregas_de_aviso_de_caso) e os campos `consent`, -- `source_metadata` e `tags` do contato só eram alcançados pelo pedido formal. -- -- O portão continua portão: autoridade (suporte, papel `admin`, MFA), mutex -- (`fn_service_lock` antes do `for update`) e o contrato de retorno -- `{already_anonymized, anonymized_at}` com a data original na retomada. O que -- sai do corpo dele é o `update contacts` — a escrita do contato é da cascata. -- -- Racional completo, decisão por decisão, na própria migration. Corpo IGUAL ao -- da cadeia (`apendice-do-baseline-nao-diverge-da-cadeia` cobra), antes da -- VARREDURA anon logo abaixo. create or replace function public.fn_lgpd_anonymize_contact(p_organization_id uuid,p_contact_id uuid) returns jsonb language plpgsql security definer set search_path=public as $$ declare c public.contacts; support jsonb; v_quando timestamptz; begin support:=public.fn_support_context(); if auth.uid() is null or not public.fn_support_write_allowed(p_organization_id) or not (public.fn_role_at_least(p_organization_id,'admin') or (public.fn_is_platform_admin() and support is null)) then raise exception 'contact_anonymize_forbidden' using errcode='42501'; end if; if not public.fn_session_mfa_proven() then raise exception 'contact_anonymize_mfa_required' using errcode='42501';end if; perform public.fn_service_lock(p_organization_id,p_contact_id); select * into c from public.contacts where organization_id=p_organization_id and id=p_contact_id for update; if not found then raise exception 'contact_not_found' using errcode='P0002';end if; if c.is_anonymized then return jsonb_build_object('already_anonymized',true,'anonymized_at',c.anonymized_at);end if; -- issue #1504 — a redação em si é da função ÚNICA. Este caminho (o botão) e o -- pedido formal passam por aqui; o portão acima é quem decide QUEM pode -- anonimizar, e nada é escrito por conta próprio neste corpo. perform public.fn_lgpd_cascade_redact_contact(p_organization_id,p_contact_id,null); select anonymized_at into v_quando from public.contacts where organization_id=p_organization_id and id=p_contact_id; return jsonb_build_object('already_anonymized',false,'anonymized_at',v_quando); end;$$; revoke all on function public.fn_lgpd_anonymize_contact(uuid,uuid) from public,anon,authenticated,service_role; grant execute on function public.fn_lgpd_anonymize_contact(uuid,uuid) to authenticated; -- ---- teto de tokens ativos por organização (migration 0415, issue #1448) ---- -- Um trigger BEFORE INSERT conta os tokens VIVOS da organização (sem -- revoked_at, não expirados) e recusa a emissão quando bate no teto, com -- mensagem própria em PT-BR que diz o limite e manda revogar um token para -- liberar espaço. Revogados e expirados não contam: é o que deixa a rotação -- legítima passar. SQLSTATE `PT409`, que a rota de emissão devolve como 409 — -- mesmo desenho do `PT404`/`PT422` da 0403. Racional inteiro no cabeçalho da -- migration; a definição abaixo é a MESMA, byte a byte. create or replace function public.fn_teto_de_tokens_ativos() returns trigger language plpgsql security definer set search_path = '' as $$ declare v_teto constant integer := 50; v_ativos integer; begin select count(*) into v_ativos from public.api_tokens where organization_id = new.organization_id and revoked_at is null and (expires_at is null or expires_at > now()); if v_ativos >= v_teto then raise exception 'Teto de tokens ativos por organização atingido: % de %. Revogue um token que não esteja mais em uso (Configurações → Tokens de API → Revogar) para liberar espaço — tokens revogados ou expirados não contam — e tente criar outro.', v_ativos, v_teto using errcode = 'PT409'; end if; return new; end; $$; revoke execute on function public.fn_teto_de_tokens_ativos() from public, anon, authenticated; comment on function public.fn_teto_de_tokens_ativos() is 'Gatilho de api_tokens (migration 0415, issue #1448): recusa a INSERÇÃO quando a organização já tem o teto de tokens ATIVOS (sem revoked_at e não expirados). Mensagem própria em PT-BR com o limite e como revogar; SQLSTATE PT409, que a rota de emissão devolve como 409.'; drop trigger if exists trg_teto_de_tokens_ativos on public.api_tokens; create trigger trg_teto_de_tokens_ativos before insert on public.api_tokens for each row execute function public.fn_teto_de_tokens_ativos(); -- ---- autoria "em nome de" na mensagem (migration 0416, issue #1613) ---- -- -- Coluna nova, nullable, sem backfill e sem policy nova: a RLS por organização -- já cobre a linha de `messages`, e o campo é gravado pelo handler só depois do -- gate `messages:on_behalf` na rota. Idempotente porque o `update.sh` do clone -- re-executa este bloco inteiro a cada atualização. Fica antes da varredura de -- `anon`, como todo apêndice novo, embora não crie função. alter table public.messages add column if not exists sent_on_behalf_of_user_id uuid; comment on column public.messages.sent_on_behalf_of_user_id is 'Autoria "em nome de" (#1613, migration 0416): a PESSOA — membro ativo agent+ da organização — em nome de quem um token enviou esta mensagem. null em todo envio direto. Só a rota POST /api/v1/messages grava, e só com o escopo messages:on_behalf; o balão mostra "Fulano · via {token}" a partir de metadata.sent_on_behalf.'; -- ---- motivo de ganho nativo (migration 0420, issue #1536) ---- -- -- Coluna nova, nullable, sem backfill e sem policy nova — a RLS por organização -- já cobre a linha de `crm_leads`. SEM CHECK e SEM trigger de propósito: a -- obrigatoriedade é opt-in por funil (`settings.won_reason_required`) e o -- vocabulário é `settings.won_reasons`, ambos decididos no servidor -- (`lib/leads/campos-exigidos.ts`) — uma CHECK aqui tornaria o motivo exigido -- para todo install, inclusive os que nunca cadastraram lista nenhuma. A CHECK -- da PERDA (`crm_leads_lost_reason_required`) é de outra issue (#917) e não -- muda. Idempotente porque o `update.sh` do clone re-executa este bloco a cada -- atualização. Fica antes da varredura de `anon`, como todo apêndice novo. alter table public.crm_leads add column if not exists won_reason text; comment on column public.crm_leads.won_reason is 'Motivo do ganho (issue #1536, migration 0420): por que este negócio foi fechado como ganho. null quando ninguém informou. Texto livre por padrão; settings.won_reasons do funil transforma em lista e settings.won_reason_required liga a obrigatoriedade — as duas decididas no servidor (lib/leads/campos-exigidos.ts), nunca por CHECK: o ganho não tinha exigência nenhuma antes e não pode ganhar uma para o install inteiro.'; -- ---- publicar agente com o provedor personalizado (migration 0418, #1642) ---- -- Para `custom`, o modelo é conferido na lista que o PRÓPRIO endpoint devolveu -- (`models_available` da credencial da versão), não no catálogo global -- `ai_models`, onde nada escreve linha `custom`. Racional inteiro no cabeçalho -- da migration; a definição abaixo é a MESMA, byte a byte. create or replace function public.fn_publish_ai_agent_version( p_org_id uuid, p_agent_id uuid, p_version_id uuid, p_platform_credential_verified boolean, p_expected_provenance text ) returns table ( agent_id uuid, version_id uuid, previous_version_id uuid, published_at timestamptz ) language plpgsql security definer set search_path to 'public' as $$ declare v_agent record; v_version record; v_credential record; v_session record; v_model_count integer; v_previous_version_id uuid; v_published_at timestamptz := now(); begin select a.id, a.organization_id, a.published_version_id, a.archived_at into v_agent from public.ai_agents a where a.id = p_agent_id for update; if not found then raise exception 'agent_not_found' using errcode = 'P0001'; end if; if v_agent.organization_id <> p_org_id then raise exception 'agent_not_found' using errcode = 'P0001'; end if; if v_agent.archived_at is not null then raise exception 'agent_archived' using errcode = 'P0001'; end if; select v.id, v.organization_id, v.agent_id, v.status, v.provider, v.model, v.credential_id, v.channel_session_id, v.provisioning_origin into v_version from public.ai_agent_versions v where v.id = p_version_id for update; if not found then raise exception 'version_not_found' using errcode = 'P0001'; end if; if v_version.agent_id <> p_agent_id or v_version.organization_id <> p_org_id then raise exception 'version_not_found' using errcode = 'P0001'; end if; if p_expected_provenance is not null and ( p_expected_provenance not in('onboarding','legacy_reconciliation') or v_version.provisioning_origin is distinct from p_expected_provenance or (select count(*) from public.ai_agent_versions own_version where own_version.organization_id=p_org_id and own_version.agent_id=p_agent_id)<>1 ) then raise exception 'existing_version_requires_review' using errcode='P0001';end if; if v_version.status not in ('draft', 'superseded') then raise exception 'version_invalid_state' using errcode = 'P0001'; end if; if v_version.credential_id is null and p_platform_credential_verified is not true then raise exception 'credential_missing' using errcode = 'P0001'; end if; if v_version.credential_id is not null then select c.id, c.organization_id, c.provider, c.is_active, c.validated_at, c.models_available into v_credential from public.ai_provider_credentials c where c.id = v_version.credential_id; if not found or v_credential.organization_id <> p_org_id then raise exception 'credential_not_found' using errcode = 'P0001'; end if; if not v_credential.is_active then raise exception 'credential_inactive' using errcode = 'P0001'; end if; if v_credential.validated_at is null then raise exception 'credential_not_validated' using errcode = 'P0001'; end if; if v_credential.provider <> v_version.provider then raise exception 'credential_provider_mismatch' using errcode = 'P0001'; end if; end if; select s.id, s.organization_id, s.status into v_session from public.channel_sessions s where s.id = v_version.channel_session_id; if not found or v_session.organization_id <> p_org_id then raise exception 'channel_session_not_found' using errcode = 'P0001'; end if; if v_session.status <> 'WORKING' then raise exception 'channel_session_offline' using errcode = 'P0001'; end if; -- Provedor personalizado (0418): o endpoint é da empresa, e quem diz que o -- modelo existe é a lista que ELE devolveu, gravada na credencial já -- conferida acima. Sem credencial própria não há lista — recusado. if v_version.provider = 'custom' then if v_version.credential_id is null then raise exception 'model_not_found' using errcode = 'P0001'; end if; if not (v_version.model = any(coalesce(v_credential.models_available, '{}'::text[]))) then raise exception 'model_not_found' using errcode = 'P0001'; end if; else select count(*) into v_model_count from public.ai_models m where m.provider = v_version.provider and m.model_id = v_version.model and m.deprecated_at is null; if v_model_count = 0 then raise exception 'model_not_found' using errcode = 'P0001'; end if; end if; v_previous_version_id := v_agent.published_version_id; if v_previous_version_id is not null and v_previous_version_id <> p_version_id then update public.ai_agent_versions set status = 'superseded', superseded_at = v_published_at where id = v_previous_version_id; end if; update public.ai_agent_versions set status = 'published', published_at = v_published_at, superseded_at = null where id = p_version_id; update public.ai_agents set published_version_id = p_version_id, updated_at = v_published_at where id = p_agent_id; return query select p_agent_id, p_version_id, v_previous_version_id, v_published_at; end; $$; revoke all on function public.fn_publish_ai_agent_version(uuid,uuid,uuid,boolean,text) from public,anon,authenticated; grant execute on function public.fn_publish_ai_agent_version(uuid,uuid,uuid,boolean,text) to service_role; -- ---- rascunho sugerido por integração (migration 0419, issue #1611) ---- -- -- Espelho idempotente da 0419. O kit self-host aplica SÓ o baseline, então sem -- este bloco a tabela não existiria em quem instalou numa VPS. -- -- Por que o rascunho vive no servidor e não no link: mensagem a cliente tem -- dado pessoal, URL acaba em registro de proxy/histórico, o comprimento é -- limitado e um link com texto pronto mandado por qualquer pessoa vira -- engenharia social contra o atendente. Com a linha guardada, só quem tem token -- da organização cria, e o envio continua sendo um clique de gente. create table if not exists public.conversation_drafts ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations (id) on delete cascade, conversation_id uuid not null references public.conversations (id) on delete cascade, body text not null, source text not null default 'integracao', created_by_api_token_id uuid references public.api_tokens (id) on delete set null, expires_at timestamptz not null, consumed_at timestamptz, consumed_by_user_id uuid references auth.users (id) on delete set null, created_at timestamptz not null default now(), constraint conversation_drafts_body_check check (char_length(body) >= 1 and char_length(body) <= 4096) ); create index if not exists conversation_drafts_conversation on public.conversation_drafts (organization_id, conversation_id, created_at desc); -- RLS: organização + papel + VISIBILIDADE DA CONVERSA, por operação (o molde -- de `passagens_de_atendimento` e `ai_reply_drafts`). Cada condição fecha uma -- porta: `fn_user_org_ids` — o vizinho não lê; `fn_role_at_least('agent')` — -- `viewer` não envia, então não lê nem consome o texto que outro sistema -- escreveu PARA o cliente; `fn_can_view_conversation` — em `visibility_mode = -- 'own'` o atendente não lê o rascunho de uma conversa que não é dele. -- Quem escreve pela SESSÃO: a rota de criação (INSERT, sem token — a origem de -- token é só do service role) e o consumo (UPDATE de uma linha ainda não usada, -- que só pode virar "usada por MIM"). DELETE não tem caminho de sessão: quem -- apaga é o trigger definer da LGPD. `for all` só-tenancy deixava um `viewer` -- escrever e apagar pelo PostgREST (gate `0150` de rbac-config-ia-canais). alter table public.conversation_drafts enable row level security; revoke all on public.conversation_drafts from anon, authenticated; grant select, insert, update on public.conversation_drafts to authenticated; grant all on public.conversation_drafts to service_role; drop policy if exists tenant_isolation_conversation_drafts_all on public.conversation_drafts; drop policy if exists conversation_drafts_select on public.conversation_drafts; create policy conversation_drafts_select on public.conversation_drafts for select to authenticated using ( organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'agent') and exists ( select 1 from public.conversations c where c.organization_id = conversation_drafts.organization_id and c.id = conversation_drafts.conversation_id and public.fn_can_view_conversation(c.organization_id, c.assigned_to_user_id) ) ); drop policy if exists conversation_drafts_insert on public.conversation_drafts; create policy conversation_drafts_insert on public.conversation_drafts for insert to authenticated with check ( organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'agent') and created_by_api_token_id is null and exists ( select 1 from public.conversations c where c.organization_id = conversation_drafts.organization_id and c.id = conversation_drafts.conversation_id and public.fn_can_view_conversation(c.organization_id, c.assigned_to_user_id) ) ); drop policy if exists conversation_drafts_update on public.conversation_drafts; create policy conversation_drafts_update on public.conversation_drafts for update to authenticated using ( organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'agent') and consumed_at is null and exists ( select 1 from public.conversations c where c.organization_id = conversation_drafts.organization_id and c.id = conversation_drafts.conversation_id and public.fn_can_view_conversation(c.organization_id, c.assigned_to_user_id) ) ) with check ( organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'agent') and consumed_at is not null and consumed_by_user_id = (select auth.uid()) ); -- LGPD: a anonimização do contato apaga os rascunhos das conversas dele. O -- `body` é o texto escrito PARA a pessoa ("Oi Maria, seu boleto de R$ 320 -- venceu") e a tabela não tem FK para `contacts`, então nem a cascata nem o -- invariante de cascata a enxergam. Apagar, e não redigir: o rascunho é uma -- proposta que ninguém enviou (o que foi enviado está em `messages`, que a -- cascata já redige), e o que houve de operação fica no audit -- (`conversation.draft_created` / `draft_used`). Trigger na transição -- `is_anonymized false → true`, no molde de trg_redigir_tarefas_ao_anonimizar. create or replace function public.fn_apagar_rascunhos_do_contato_anonimizado() returns trigger language plpgsql security definer set search_path = public, pg_temp as $$ begin delete from public.conversation_drafts where organization_id = new.organization_id and conversation_id in ( select id from public.conversations where organization_id = new.organization_id and contact_id = new.id ); return new; end; $$; revoke execute on function public.fn_apagar_rascunhos_do_contato_anonimizado() from public, anon, authenticated; grant execute on function public.fn_apagar_rascunhos_do_contato_anonimizado() to service_role; drop trigger if exists trg_apagar_rascunhos_ao_anonimizar on public.contacts; create trigger trg_apagar_rascunhos_ao_anonimizar after update of is_anonymized on public.contacts for each row when (new.is_anonymized is true and old.is_anonymized is distinct from true) execute function public.fn_apagar_rascunhos_do_contato_anonimizado(); -- ---- as observações do Jev, tarefa a tarefa (migration 0421) ---- -- -- O Jev ao lado do mecanismo de hoje: o rótulo de cada um e se concordaram, sem -- texto de cliente. ANTES da varredura de anon (cria função) e, por isso também, -- antes da reaplicação de módulos e das proteções e travas do fim do arquivo, -- que precisam ver a tabela nova. Racional inteiro na migration 0421. create table if not exists public.jev_observacoes ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, -- `TAREFAS_DO_JEV` (lib/ai/decisao/tarefas.ts). Vocabulário ABERTO, sem CHECK: -- cada tarefa nova seria uma migration só para caber aqui. tarefa text not null, -- O estado da tarefa quando o Jev respondeu. Desligada não pergunta nada. estado text not null constraint jev_observacoes_estado_check check (estado in ('observando', 'decidindo')), -- Ponteiros, SEM FK de propósito: uma FK para messages/contacts travaria a -- anonimização ou apagaria a observação junto do histórico, e a linha não -- guarda nada da pessoa para redigir. conversation_id uuid, message_id uuid, job_id uuid, rotulo_jev text, probabilidade_jev numeric, confianca_jev numeric, -- O que o mecanismo de hoje decidiu. NULL = ele não decidiu (falhou): sem par. rotulo_atual text, -- NULL quando falta um dos lados — "sem par" não é discordância. concordou boolean generated always as ( case when rotulo_jev is null or rotulo_atual is null then null else rotulo_jev = rotulo_atual end ) stored, modelo text, latencia_ms integer, created_at timestamptz not null default now() ); comment on table public.jev_observacoes is 'O Jev ao lado do mecanismo de hoje, tarefa a tarefa: o rótulo de cada um e se concordaram. Sem texto de cliente. Escrita só pelo servidor (lib/ai/decisao); lida pelo cartão do Jev (GET /api/v1/ai/jev). Expurgada por fn_expurgar_observacoes_do_jev (cron data-retention).'; -- A leitura do cartão: uma organização, uma tarefa, os últimos 30 dias. create index if not exists jev_observacoes_org_tarefa_criada_idx on public.jev_observacoes (organization_id, tarefa, created_at desc); -- A poda: a ponta mais velha, de todas as organizações. create index if not exists jev_observacoes_criada_idx on public.jev_observacoes (created_at); -- Uma resposta por tarefa e mensagem: o retry do job pergunta de novo sobre a -- mesma, e a segunda contaria em dobro na concordância. Sem deduplicar antes: -- a tabela nasce nesta migration, sem linha nenhuma. create unique index if not exists jev_observacoes_uma_por_mensagem_idx on public.jev_observacoes (organization_id, tarefa, message_id) where message_id is not null; -- Leitura por qualquer membro da organização (é concordância, não dado de -- pessoa); escrita só do servidor, que passa por cima da RLS. Sem policy ALL: -- `authenticated` não tem por que escrever aqui. alter table public.jev_observacoes enable row level security; drop policy if exists tenant_isolation_jev_observacoes_select on public.jev_observacoes; create policy tenant_isolation_jev_observacoes_select on public.jev_observacoes for select using (organization_id in (select public.fn_user_org_ids())); -- O ALTER DEFAULT PRIVILEGES do baseline dá GRANT ALL em TABLES a `anon`: toda -- tabela nova nasce exposta e revoga por conta própria. revoke all on public.jev_observacoes from anon, authenticated; grant select on public.jev_observacoes to authenticated; grant all on public.jev_observacoes to service_role; -- O prazo: padrão 90 dias (JEV_OBSERVACOES_RETENTION_DAYS), piso 30 — a janela -- da concordância no cartão. Abaixo dela o cartão diria "30 dias" contando -- menos. O piso mora NO CORPO, para valer contra qualquer chamador. create or replace function public.fn_expurgar_observacoes_do_jev( p_retencao_dias int default null, p_limite int default null ) returns int language plpgsql security definer set search_path = public, pg_temp as $$ declare v_dias int := greatest(coalesce(p_retencao_dias, 90), 30); v_limite int := least(greatest(coalesce(p_limite, 1000), 1), 10000); v_apagadas int; begin with vencidas as ( select o.id from public.jev_observacoes o where o.created_at < now() - make_interval(days => v_dias) order by o.created_at limit v_limite ) delete from public.jev_observacoes o using vencidas v where o.id = v.id; get diagnostics v_apagadas = row_count; return v_apagadas; end; $$; revoke all on function public.fn_expurgar_observacoes_do_jev(int,int) from public; revoke execute on function public.fn_expurgar_observacoes_do_jev(int,int) from anon; revoke execute on function public.fn_expurgar_observacoes_do_jev(int,int) from authenticated; grant execute on function public.fn_expurgar_observacoes_do_jev(int,int) to service_role; -- ---- os candidatos ao golden set viram linha de rótulo (migration 0428) ---- -- -- O candidato do matcher de skills (F3-09) e do classificador de etapa (F3-11) -- sai do disco (JSON em `GOLDEN_CANDIDATES_DIR`) e vira linha SEM texto de -- cliente, com ponteiro `lead_id` para quem quiser ler a conversa de verdade. -- ANTES da varredura de anon, como a 0421: a tabela nasce aqui para quem só -- aplica o baseline. Racional inteiro na migration 0428. create table if not exists public.golden_candidates ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, lead_id uuid, job_id uuid not null, fonte text not null constraint golden_candidates_fonte_check check (fonte in ('skill_match_miss', 'stage_classifier_divergence')), skill text, motivo text, estagio_sugerido text, estagio_confirmado text, constraint golden_candidates_rotulos_check check ( (fonte = 'skill_match_miss' and skill is not null and motivo is not null and estagio_sugerido is null and estagio_confirmado is null) or (fonte = 'stage_classifier_divergence' and estagio_sugerido is not null and estagio_confirmado is not null and skill is null and motivo is null) ), created_at timestamptz not null default now() ); comment on table public.golden_candidates is 'Candidatos ao golden set (near-miss de skill e divergência classificador×modelo), em RÓTULO: sem texto de cliente, com ponteiro lead_id para quem quiser ler a conversa de verdade. Escrita só do servidor (lib/agent-engine/agent); leitura por membro da organização. Expurgada por fn_expurgar_candidatos_do_golden (cron data-retention).'; create index if not exists golden_candidates_criada_idx on public.golden_candidates (created_at); create index if not exists golden_candidates_org_criada_idx on public.golden_candidates (organization_id, created_at desc); create unique index if not exists golden_candidates_uma_por_job_skill_idx on public.golden_candidates (organization_id, job_id, skill) where fonte = 'skill_match_miss'; create unique index if not exists golden_candidates_uma_por_job_divergencia_idx on public.golden_candidates (organization_id, job_id) where fonte = 'stage_classifier_divergence'; alter table public.golden_candidates enable row level security; drop policy if exists tenant_isolation_golden_candidates_select on public.golden_candidates; create policy tenant_isolation_golden_candidates_select on public.golden_candidates for select using (organization_id in (select public.fn_user_org_ids())); revoke all on public.golden_candidates from anon, authenticated; grant select on public.golden_candidates to authenticated; grant all on public.golden_candidates to service_role; create or replace function public.fn_expurgar_candidatos_do_golden( p_retencao_dias int default null, p_limite int default null ) returns int language plpgsql security definer set search_path = public, pg_temp as $$ declare v_dias int := greatest(coalesce(p_retencao_dias, 90), 30); v_limite int := least(greatest(coalesce(p_limite, 1000), 1), 10000); v_apagadas int; begin with vencidas as ( select g.id from public.golden_candidates g where g.created_at < now() - make_interval(days => v_dias) order by g.created_at limit v_limite ) delete from public.golden_candidates g using vencidas v where g.id = v.id; get diagnostics v_apagadas = row_count; return v_apagadas; end; $$; revoke all on function public.fn_expurgar_candidatos_do_golden(int,int) from public; revoke execute on function public.fn_expurgar_candidatos_do_golden(int,int) from anon; revoke execute on function public.fn_expurgar_candidatos_do_golden(int,int) from authenticated; grant execute on function public.fn_expurgar_candidatos_do_golden(int,int) to service_role; -- ---- a retenção de mídia passa a existir (migration 0432) ---- -- ---- a fila de remoção de mídia deixa de ser eterna (migration 0434) ---- -- ---- a contagem do expurgo volta para o retorno (migration 0435) ---- -- Ver o cabeçalho das DUAS migrations: a 0432 enfileira arquivo vencido e -- órfão na mesma fila da LGPD (o cron storage-redaction remove pelo Storage -- API); a 0434 (#1739) reabre `deleted`/`skipped` quando o mesmo caminho -- volta a existir e expurga linha `deleted` com mais de 90 dias; a 0435 -- (#1765) devolve a contagem desse expurgo, que antes não aparecia nem no -- retorno nem na trilha. O corpo abaixo é a 0435 EDITADA NO LUGAR — ele tem -- de casar com o da última migration, senão quem instala pelo kit self-host -- fica com outra função de quem aplica a cadeia -- (apendice-do-baseline-nao-diverge-da-cadeia). create or replace function public.fn_enfileirar_midia_vencida(p_limite integer default 500) returns jsonb language plpgsql security definer set search_path = public, storage as $$ declare v_lim integer := greatest(1, least(coalesce(p_limite, 500), 5000)); v_vencidas integer := 0; v_orfas integer := 0; -- Órfãos do bucket PRÓPRIO da nota interna (0483). Contam em `v_orfas`: -- é a mesma categoria — arquivo sem ponteiro — e a chave de retorno não -- muda (o `toEqual` congelado de `poda-de-midia.test.ts` mede as três). v_orfas_nota integer := 0; -- O que o expurgo apagou NESTA chamada (#1765). Começa em 0 para que a -- rodada sem nada a expurgar devolva 0 — e não null, que o cron somaria -- como se fosse apagado. v_expurgadas integer := 0; -- Janela do expurgo, em UM lugar só: é a constante que se muda amanhã. v_janela_deleted interval := interval '90 days'; begin -- 0. EXPURGO: a linha `deleted` da RETENÇÃO já cumpriu o papel (o arquivo -- saiu do bucket) e nada mais precisa dela — sem isto a fila cresce sem -- teto (#1739, item 2). Só `deleted`: `skipped` é «o objeto já não -- existe», `failed` é a prova de uma remoção que nunca passou das 3 -- tentativas, e a issue manda não mexer em nenhuma das duas. -- E só a de retenção (`request_id is null`): a linha de pedido LGPD é o -- ÚNICO registro por objeto de que a mídia do titular saiu do bucket — o -- worker só troca o `status` e nada audita a remoção física. Ela sai -- sozinha se o pedido for apagado (FK `on delete set null`). -- O `GET DIAGNOSTICS` conta o que o DELETE apagou NESTA chamada (#1765): -- sem ele a rodada que só expurgou é indistinguível, na trilha, da rodada -- que não tinha o que fazer. delete from public.storage_redaction_queue where status = 'deleted' and request_id is null and coalesce(processed_at, enqueued_at) < now() - v_janela_deleted; get diagnostics v_expurgadas = row_count; -- 1. VENCIDAS: arquivo de mensagem mais velho que a retenção da organização. -- A mensagem fica (texto, status, horário); só o arquivo sai, e a tela -- mostra «Mídia indisponível». O piso de 30 dias é o mesmo do formulário. with alvo as ( select m.id, m.organization_id, m.media_storage_path as caminho from public.messages m join public.organizations o on o.id = m.organization_id where m.media_storage_path is not null and m.created_at < now() - make_interval(days => greatest(coalesce(o.media_retention_days, 365), 30)) order by m.created_at limit v_lim for update of m skip locked ), fila as ( -- O arquivo só vai para a fila quando nenhuma OUTRA mensagem o usa: a foto -- de catálogo tem caminho fixo por conversa e é reaproveitada a cada -- reenvio (`fotos-do-produto.ts`), então a mensagem de ontem pode apontar -- para o mesmo arquivo da vencida. A vencida perde o caminho do mesmo -- jeito; o arquivo sai quando a última referência vencer (aqui) ou no -- passo 2, como órfão. -- -- O `do update` é o conserto do #1739: se aquele caminho já saiu da fila -- (`deleted`) ou o objeto já nem existia (`skipped`), um arquivo NOVO pode -- estar gravado ali agora — e o `do nothing` da 0432 engolia este pedido -- silenciosamente, deixando o arquivo novo fora da retenção PARA SEMPRE. -- O `where` é a outra metade do conserto: `pending`/`failed` em curso não -- são interrompidos (uma remoção em andamento não perde a tentativa). insert into public.storage_redaction_queue (organization_id, bucket, object_path) select distinct a.organization_id, 'whatsapp-media', a.caminho from alvo a where not exists ( select 1 from public.messages m2 where m2.media_storage_path = a.caminho and m2.id not in (select id from alvo) ) on conflict (bucket, object_path) do update set status = 'pending', attempts = 0, enqueued_at = now(), processed_at = null, error_message = null where storage_redaction_queue.status in ('deleted', 'skipped') returning 1 ), limpas as ( update public.messages m set media_storage_path = null, updated_at = now() from alvo where m.id = alvo.id returning 1 ) select count(*) into v_vencidas from limpas; -- 2. ÓRFÃOS: arquivo que nada no banco aponta — o rastro de conversa apagada. -- Só as duas pastas que o CRM grava por mensagem e por contato: -- `org//…` e `org/avatars/…`. `org/templates/…` (cabeçalho de -- modelo) NUNCA entra: quem o usa guarda o link, não o caminho. Um dia de -- carência cobre o envio que sobe o arquivo antes de gravar a mensagem. with orfaos as ( select o.name as caminho, split_part(o.name, '/', 1)::uuid as org from storage.objects o where o.bucket_id = 'whatsapp-media' and o.created_at < now() - interval '1 day' and split_part(o.name, '/', 1) ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' and exists (select 1 from public.organizations g where g.id::text = split_part(o.name, '/', 1)) and ( split_part(o.name, '/', 2) ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' or split_part(o.name, '/', 2) = 'avatars' ) and not exists (select 1 from public.messages m where m.media_storage_path = o.name) and not exists (select 1 from public.contacts c where c.avatar_storage_path = o.name) -- Só linha EM CURSO segura o caminho (`pending`, ou `failed` que ainda -- é o registro de uma remoção não feita). Linha `deleted`/`skipped` -- NÃO bloqueia mais: é justamente o caso do avatar reaproveitado -- (#1739) — o objeto novo no caminho antigo tinha de chegar no conflito -- lá embaixo para ser reaberto, e este `not exists` o engolia antes. and not exists ( select 1 from public.storage_redaction_queue q where q.bucket = 'whatsapp-media' and q.object_path = o.name and q.status not in ('deleted', 'skipped') ) limit v_lim ), fila as ( insert into public.storage_redaction_queue (organization_id, bucket, object_path) select org, 'whatsapp-media', caminho from orfaos on conflict (bucket, object_path) do update set status = 'pending', attempts = 0, enqueued_at = now(), processed_at = null, error_message = null where storage_redaction_queue.status in ('deleted', 'skipped') returning 1 ) select count(*) into v_orfas from fila; -- 2b. ÓRFÃOS DA NOTA INTERNA (migration 0483): o passo 2 varre SÓ o bucket -- `whatsapp-media` (filtro `bucket_id`), então um anexo de nota nunca -- entraria na conta — e a nota que o atendente apagou deixaria o arquivo -- para sempre no `internal-media`, custo que só cresce. Mesmo desenho do -- passo 2, com as duas pontas certas: bucket `internal-media` e -- `conversation_notes.media_storage_path` como a referência que segura o -- caminho. Um dia de carência cobre o upload que sobe ANTES de a nota ser -- gravada (é a ordem do composer), como o passo 2 cobre o envio. -- Uma linha `pending`/`failed` em curso segura o caminho; `deleted`/ -- `skipped` não, pelo mesmo motivo escrito no passo 2 (caminho reuso). with orfaos_da_nota as ( select o.name as caminho, split_part(o.name, '/', 1)::uuid as org from storage.objects o where o.bucket_id = 'internal-media' and o.created_at < now() - interval '1 day' and split_part(o.name, '/', 1) ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' and exists (select 1 from public.organizations g where g.id::text = split_part(o.name, '/', 1)) and split_part(o.name, '/', 2) ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' and not exists ( select 1 from public.conversation_notes n where n.media_storage_path = o.name ) and not exists ( select 1 from public.storage_redaction_queue q where q.bucket = 'internal-media' and q.object_path = o.name and q.status not in ('deleted', 'skipped') ) limit v_lim ), fila_da_nota as ( insert into public.storage_redaction_queue (organization_id, bucket, object_path) select org, 'internal-media', caminho from orfaos_da_nota on conflict (bucket, object_path) do update set status = 'pending', attempts = 0, enqueued_at = now(), processed_at = null, error_message = null where storage_redaction_queue.status in ('deleted', 'skipped') returning 1 ) select count(*) into v_orfas_nota from fila_da_nota; v_orfas := v_orfas + v_orfas_nota; return jsonb_build_object('vencidas', v_vencidas, 'orfas', v_orfas, 'expurgadas', v_expurgadas); end; $$; revoke execute on function public.fn_enfileirar_midia_vencida(integer) from public, anon, authenticated; grant execute on function public.fn_enfileirar_midia_vencida(integer) to service_role; notify pgrst, 'reload schema'; -- ---- conversão do Google Ads por etapa + venda sem valor (migration 0436) ---- -- -- Racional inteiro na migration 0436. Cria função, então fica ANTES da varredura de anon. create table if not exists public.google_ads_conversion_rules ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, stage_id uuid not null, event_name text not null, label text not null, google_action_id text not null, category text not null default 'DEFAULT', included_in_conversions boolean not null default true, channel text not null default 'todos', enabled boolean not null default true, configured_at timestamptz not null default now(), created_at timestamptz not null default now(), updated_at timestamptz not null default now(), updated_by uuid, constraint google_ads_conversion_rules_action_numerica check (google_action_id ~ '^[0-9]{1,32}$'), constraint google_ads_conversion_rules_evento_conhecido check (event_name = 'QualifiedLead' or event_name ~ '^Etapa:[0-9a-f-]{36}$'), constraint google_ads_conversion_rules_canal_conhecido check (channel in ('todos', 'whatsapp', 'outros')), constraint google_ads_conversion_rules_categoria_conhecida check (category in ( 'DEFAULT', 'PAGE_VIEW', 'PURCHASE', 'SIGNUP', 'DOWNLOAD', 'ADD_TO_CART', 'BEGIN_CHECKOUT', 'SUBSCRIBE_PAID', 'PHONE_CALL_LEAD', 'IMPORTED_LEAD', 'SUBMIT_LEAD_FORM', 'BOOK_APPOINTMENT', 'REQUEST_QUOTE', 'GET_DIRECTIONS', 'OUTBOUND_CLICK', 'CONTACT', 'ENGAGEMENT', 'STORE_VISIT', 'STORE_SALE', 'QUALIFIED_LEAD', 'CONVERTED_LEAD' )), constraint google_ads_conversion_rules_label_curto check (char_length(btrim(label)) between 1 and 100) ); alter table public.google_ads_conversion_rules drop constraint if exists google_ads_conversion_rules_stage_org_fk; alter table public.google_ads_conversion_rules add constraint google_ads_conversion_rules_stage_org_fk foreign key (organization_id, stage_id) references public.crm_stages (organization_id, id) on delete cascade; create unique index if not exists google_ads_conversion_rules_org_stage_uk on public.google_ads_conversion_rules (organization_id, stage_id); create unique index if not exists google_ads_conversion_rules_org_event_uk on public.google_ads_conversion_rules (organization_id, event_name); comment on table public.google_ads_conversion_rules is 'Qual ação de conversão do Google Ads cada etapa do funil envia quando um negócio entra nela. event_name é a chave do livro-razão ad_conversion_dispatches. Server-side only: RLS sem policy e grants revogados de anon/authenticated.'; comment on column public.google_ads_conversion_rules.configured_at is 'Trava de retroatividade: só movimentos de etapa posteriores enviam. Regravada pelo gatilho quando a etapa ou a ação mudam.'; comment on column public.google_ads_conversion_rules.channel is 'Por onde o negócio precisa ter entrado para enviar: todos, whatsapp (tem conversa vinculada) ou outros (sem conversa).'; alter table public.google_ads_conversion_rules enable row level security; revoke all on public.google_ads_conversion_rules from anon, authenticated; grant select, insert, update, delete on public.google_ads_conversion_rules to service_role; drop trigger if exists trg_google_ads_conversion_rules_updated_at on public.google_ads_conversion_rules; create trigger trg_google_ads_conversion_rules_updated_at before update on public.google_ads_conversion_rules for each row execute function public.fn_set_updated_at(); create or replace function public.fn_marcar_configuracao_regra_google() returns trigger language plpgsql set search_path = public as $$ begin if tg_op = 'INSERT' then -- A migração importa o carimbo legado; inserções normais usam DEFAULT now(). new.configured_at := coalesce(new.configured_at, now()); elsif new.stage_id is distinct from old.stage_id or new.google_action_id is distinct from old.google_action_id or (new.enabled and not old.enabled) then new.configured_at := now(); else new.configured_at := old.configured_at; end if; return new; end; $$; revoke execute on function public.fn_marcar_configuracao_regra_google() from public, anon, authenticated; grant execute on function public.fn_marcar_configuracao_regra_google() to service_role; drop trigger if exists trg_marcar_configuracao_regra_google on public.google_ads_conversion_rules; create trigger trg_marcar_configuracao_regra_google before insert or update on public.google_ads_conversion_rules for each row execute function public.fn_marcar_configuracao_regra_google(); -- A qualificação que já existia vira a primeira regra, com o nome de evento de -- sempre. `on conflict do nothing`: reaplicar não duplica nem sobrescreve a -- regra que o admin já editou. O `configured_at` herdado preserva a trava. insert into public.google_ads_conversion_rules (organization_id, stage_id, event_name, label, google_action_id, category, configured_at) select c.organization_id, c.google_qualification_stage_id, 'QualifiedLead', 'Lead qualificado', c.google_qualification_action_id, 'QUALIFIED_LEAD', coalesce(c.google_qualification_configured_at, now()) from public.ad_platform_connections c join public.crm_stages s on s.organization_id = c.organization_id and s.id = c.google_qualification_stage_id where c.platform = 'google_ads' and c.google_qualification_stage_id is not null and c.google_qualification_action_id ~ '^[0-9]{1,32}$' on conflict do nothing; alter table public.ad_platform_connections add column if not exists google_purchase_value_mode text not null default 'obrigatorio', add column if not exists google_purchase_category text not null default 'PURCHASE', add column if not exists google_send_hashed_phone boolean not null default false; alter table public.ad_platform_connections drop constraint if exists ad_platform_connections_google_purchase_value_mode_check; alter table public.ad_platform_connections add constraint ad_platform_connections_google_purchase_value_mode_check check (google_purchase_value_mode in ('obrigatorio', 'quando_houver', 'nunca')); comment on column public.ad_platform_connections.google_purchase_value_mode is 'Negócio ganho sem valor: obrigatorio (não envia, padrão histórico), quando_houver (envia; valor só se existir), nunca (envia sempre sem valor).'; comment on column public.ad_platform_connections.google_send_hashed_phone is 'Envia o telefone do contato em SHA-256 (E.164) com a conversão. Desligado por padrão: dado pessoal.'; -- O reenvio passa a aceitar os eventos de etapa, com a mesma exigência da -- qualificação: só reenvia o que tem o retrato (quando + qual ação) gravado. create or replace function public.fn_solicitar_reenvio_conversao(p_org uuid, p_lead uuid, p_event text) returns boolean language plpgsql set search_path = public as $$ declare v_linha public.ad_conversion_dispatches%rowtype; begin if p_event is null or not (p_event in ('Purchase', 'QualifiedLead') or p_event ~ '^Etapa:[0-9a-f-]{36}$') then return false; end if; select * into v_linha from public.ad_conversion_dispatches where organization_id = p_org and lead_id = p_lead and event_name = p_event for update; if not found or v_linha.status = 'sent' then return false; end if; if p_event <> 'Purchase' and (v_linha.event_occurred_at is null or v_linha.google_action_id is null) then return false; end if; if p_event = 'Purchase' and v_linha.remote_request_id is null and not exists ( select 1 from public.crm_leads where id = p_lead and organization_id = p_org and status = 'won' ) then return false; end if; if exists (select 1 from public.event_log where organization_id = p_org and entity_id = p_lead and event_type = 'ad_conversion.retry_requested' and status in ('pending', 'processing') and coalesce(payload->>'event_name', 'Purchase') = p_event) then return false; end if; perform public.emit_event('ad_conversion.retry_requested', 'crm_lead', p_lead, jsonb_build_object('event_name', p_event), '{}'::jsonb, p_org); update public.ad_conversion_dispatches set reason = 'reprocessamento_solicitado', attempted_at = now() where id = v_linha.id and organization_id = p_org; return true; end; $$; revoke execute on function public.fn_solicitar_reenvio_conversao(uuid, uuid, text) from public, anon, authenticated; grant execute on function public.fn_solicitar_reenvio_conversao(uuid, uuid, text) to service_role; notify pgrst, 'reload schema'; -- ---- links rastreáveis nomeados (migration 0437) ---- -- Links nomeados compartilham os refs existentes; o clique continua sendo consumido uma vez. create table if not exists public.ad_tracking_links ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, name text not null check (char_length(btrim(name)) between 1 and 100), whatsapp_e164 text not null check (whatsapp_e164 ~ '^\+[1-9][0-9]{7,14}$'), message_template text not null check (char_length(message_template) between 1 and 1000), use_case text not null check (use_case in ('site','anuncio','organico')), utm jsonb not null default '{}'::jsonb check (jsonb_typeof(utm) = 'object'), enabled boolean not null default true, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique (organization_id, id) ); alter table public.ad_tracking_links enable row level security; revoke all on public.ad_tracking_links from public, anon, authenticated; grant select, insert, update, delete on public.ad_tracking_links to service_role; drop trigger if exists trg_ad_tracking_links_updated_at on public.ad_tracking_links; create trigger trg_ad_tracking_links_updated_at before update on public.ad_tracking_links for each row execute function public.fn_set_updated_at(); alter table public.google_ads_click_refs add column if not exists tracking_link_id uuid; alter table public.meta_ads_click_refs add column if not exists tracking_link_id uuid; alter table public.google_ads_click_refs drop constraint if exists google_click_tracking_link_org_fk; alter table public.google_ads_click_refs add constraint google_click_tracking_link_org_fk foreign key (organization_id, tracking_link_id) references public.ad_tracking_links(organization_id, id); alter table public.meta_ads_click_refs drop constraint if exists meta_click_tracking_link_org_fk; alter table public.meta_ads_click_refs add constraint meta_click_tracking_link_org_fk foreign key (organization_id, tracking_link_id) references public.ad_tracking_links(organization_id, id); create index if not exists google_click_tracking_link_idx on public.google_ads_click_refs(organization_id, tracking_link_id, created_at); create index if not exists meta_click_tracking_link_idx on public.meta_ads_click_refs(organization_id, tracking_link_id, created_at); -- Apenas service_role. O p_org é resolvido da sessão no servidor, nunca do browser. create or replace function public.fn_metricas_links_rastreaveis(p_org uuid) returns table(link_id uuid, clicks bigint, contacts bigint, leads bigint) language sql stable security definer set search_path = public as $$ with clicks as ( select tracking_link_id, contact_id from public.google_ads_click_refs where organization_id = p_org and tracking_link_id is not null union all select tracking_link_id, contact_id from public.meta_ads_click_refs where organization_id = p_org and tracking_link_id is not null ), counts as ( select tracking_link_id, count(*) as n, count(distinct contact_id) as c from clicks group by tracking_link_id ), lead_counts as ( select c.tracking_link_id, count(distinct l.id) as n from clicks c join public.crm_leads l on l.contact_id = c.contact_id and l.organization_id = p_org group by c.tracking_link_id ) select c.tracking_link_id, c.n, c.c, coalesce(l.n,0) from counts c left join lead_counts l using(tracking_link_id); $$; revoke all on function public.fn_metricas_links_rastreaveis(uuid) from public, anon, authenticated; grant execute on function public.fn_metricas_links_rastreaveis(uuid) to service_role; notify pgrst, 'reload schema'; -- ---- o aviso da Central anuncia no barramento que nasceu (migration 0442) ---- -- -- Ver o cabeçalho da migration: trigger AFTER INSERT em `agent_inbox_items` -- emite `central.aviso_criado` (item, kind, ref) para o push decidir o que vai -- ao celular. Sem I/O; falha do anúncio não impede o aviso de nascer; aviso de -- plataforma (organização nula) não anuncia. Função com as DUAS origens de -- EXECUTE revogadas. Entra ANTES da VARREDURA anon porque cria função. -- Idempotente (`create or replace` + `drop trigger if exists`). create or replace function public.fn_emit_aviso_da_central() returns trigger language plpgsql security definer set search_path = public as $$ begin -- Aviso de PLATAFORMA (organização nula) não vai para o celular de ninguém. if new.organization_id is null then return null; end if; begin perform public.emit_event( 'central.aviso_criado', 'agent_inbox_item', new.id, jsonb_build_object( 'item_id', new.id, 'kind', new.kind, 'ref_kind', new.ref_kind, 'ref_id', new.ref_id ), '{}'::jsonb, new.organization_id -- SEMPRE de `new`: é o filtro de tenant ); exception when others then raise warning 'fn_emit_aviso_da_central: anúncio do aviso % falhou: %', new.id, sqlerrm; end; return null; -- AFTER trigger: o retorno é ignorado end; $$; alter function public.fn_emit_aviso_da_central() owner to postgres; -- As DUAS origens de EXECUTE (doutrina de migrations, item 9). Função de -- trigger: ninguém a chama pela REST, então não há `grant` a ninguém. revoke all on function public.fn_emit_aviso_da_central() from public; revoke execute on function public.fn_emit_aviso_da_central() from anon, authenticated; drop trigger if exists trg_aviso_da_central_criado on public.agent_inbox_items; create trigger trg_aviso_da_central_criado after insert on public.agent_inbox_items for each row execute function public.fn_emit_aviso_da_central(); -- ---- configuracoes de propostas (migration 0462) ---- update public.organizations set settings = jsonb_set( coalesce(settings, '{}'::jsonb), '{proposals}', '{"enabled": false, "default_valid_days": 15, "default_conditions": null}'::jsonb, true ) where settings->'proposals' is null; -- ---- o agente pode rascunhar proposta sozinho (migration 0463) ---- alter table public.ai_agent_versions add column if not exists proposal_ai_draft_enabled boolean not null default true; comment on column public.ai_agent_versions.proposal_ai_draft_enabled is 'O agente pode rascunhar uma proposta sozinho quando ligado. Default TRUE dentro de quem ligou a capacidade "Propostas" — a pessoa sempre revisa e envia (spec §3, §16 decisão 3).'; -- ---- a proposta comercial: rascunho, envio, versão, aceite (migration 0464) ---- -- -- A organização emite para um contato, com itens, valor e prazo, cujo desfecho volta para o funil. Ver -- docs/superpowers/specs/2026-09-16-proposta-comercial-design.md. -- -- Numeração e versão são decisão do dono (spec §5.3/§5.4): numero+ano -- nascem NULL no rascunho — só existem quando a proposta é ENVIADA — e uma -- revisão de proposta enviada cria uma v2 que HERDA o número da v1. create table if not exists public.crm_proposals ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, lead_id uuid not null references public.crm_leads(id) on delete cascade, contact_id uuid not null references public.contacts(id) on delete cascade, conversation_id uuid references public.conversations(id) on delete set null, status text not null default 'rascunho' check (status in ('rascunho','enviada','aceita','recusada','vencida','cancelada','substituida')), titulo text not null, condicoes text, total_cents bigint not null default 0, moeda text not null default 'BRL', valid_until date, pdf_path text, numero integer, ano integer, versao integer not null default 1, substitui_id uuid references public.crm_proposals(id) on delete set null, drafted_by_agent_id uuid references public.ai_agents(id) on delete set null, revision bigint not null default 1, sent_at timestamptz, sent_by_user_id uuid references auth.users(id), decided_at timestamptz, decided_by_user_id uuid references auth.users(id), decision_reason text, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), constraint crm_proposals_moeda_iso check (moeda ~ '^[A-Z]{3}$'), constraint crm_proposals_total_nao_negativo check (total_cents >= 0), constraint crm_proposals_numero_ano_juntos check ((numero is null) = (ano is null)) ); create index if not exists crm_proposals_org_lead_idx on public.crm_proposals(organization_id, lead_id); create index if not exists crm_proposals_org_status_idx on public.crm_proposals(organization_id, status); -- C4/0469: o índice `crm_proposals_numero_ano_org_uidx` (0464) foi substituído -- por `crm_proposals_numero_ano_versao_org_uidx` — a v1 `enviada` e a v2 -- `rascunho` da mesma cadeia convivem com o mesmo numero/ano (unicidade agora -- inclui `versao`), então o índice antigo barraria a revisão. A criação dele -- saiu daqui (não construir o que o apêndice C4 derruba, logo abaixo); quem -- atualiza recebe o `drop` pela migration 0469, que também o derruba. create table if not exists public.crm_proposal_items ( id uuid primary key default gen_random_uuid(), proposal_id uuid not null references public.crm_proposals(id) on delete cascade, -- Desnormalizado de crm_proposals.organization_id: toda tabela tenant-aware -- precisa da própria coluna (CLAUDE.md) para a trava de suporte -- (fn_aplicar_travas_de_suporte, migration 0274) alcançar esta tabela — a -- função seleciona por `pg_attribute.attname = 'organization_id'`, e uma -- tabela sem a coluna cai fora da trava (nem protegida, nem exempta). organization_id uuid not null references public.organizations(id) on delete cascade, product_id uuid references public.catalog_products(id) on delete set null, descricao text not null, quantidade numeric not null default 1, preco_unitario_cents bigint not null, desconto_cents bigint not null default 0, -- fractional indexing, igual position_in_stage — NUNCA int (CLAUDE.md). position numeric not null, created_at timestamptz not null default now(), constraint crm_proposal_items_quantidade_positiva check (quantidade > 0), constraint crm_proposal_items_preco_nao_negativo check (preco_unitario_cents >= 0), constraint crm_proposal_items_desconto_nao_negativo check (desconto_cents >= 0) ); create index if not exists crm_proposal_items_proposal_idx on public.crm_proposal_items(proposal_id, position); create index if not exists crm_proposal_items_org_idx on public.crm_proposal_items(organization_id); -- A policy de write de crm_proposal_items (abaixo) filtra direto por -- `organization_id` da PRÓPRIA linha — desde a correção do Important 4 da -- revisão, ela não confere mais, sozinha, que esse organization_id bate com o -- dono real da proposta referenciada por `proposal_id`. Sem esta trava, um -- INSERT com organization_id = A e proposal_id de uma proposta que pertence a -- B passaria pela RLS (que só olha o organization_id da linha) e quebraria o -- isolamento entre tenants — não há FK composta nem CHECK que amarre as duas -- colunas. Mesmo padrão já usado em `fn_validate_activity_lead_org` -- (crm_lead_activities.lead_id → crm_leads.organization_id). A cláusula -- "not found" não é necessária aqui: `proposal_id` já tem FK not null para -- crm_proposals(id), então a linha referenciada sempre existe no momento do -- INSERT/UPDATE. create or replace function public.fn_verificar_org_do_item_da_proposta() returns trigger language plpgsql set search_path = public as $$ declare v_org uuid; begin select organization_id into v_org from public.crm_proposals where id = new.proposal_id; if v_org is distinct from new.organization_id then raise exception 'crm_proposal_item_org_mismatch' using errcode = '23514'; end if; return new; end; $$; drop trigger if exists trg_crm_proposal_items_org_consistente on public.crm_proposal_items; create trigger trg_crm_proposal_items_org_consistente before insert or update on public.crm_proposal_items for each row execute function public.fn_verificar_org_do_item_da_proposta(); -- Função de gatilho: não é RPC, mas nasce com EXECUTE para public e anon -- como qualquer função em public (CLAUDE.md, Migrations item 9). revoke execute on function public.fn_verificar_org_do_item_da_proposta() from public, anon; alter table public.crm_proposals enable row level security; alter table public.crm_proposal_items enable row level security; -- Leitura: qualquer papel da organização. A escrita espelha as ROTAS, por -- operação — o PostgREST é porta tão aberta quanto elas (o JWT da sessão fala -- com ele direto; ver 0150): -- INSERT `agent`, e só rascunho (POST /proposals); -- UPDATE `agent`, em rascunho (editar) ou enviada (decidir) — a TRANSIÇÃO -- é conferida pelo gatilho `trg_crm_proposals_transicao_da_sessao`, -- porque policy permissiva não vê o `old` e casaria o USING de uma -- com o CHECK de outra; -- DELETE `manager`, e só rascunho — enviada é documento, ninguém apaga. -- Enviar, numerar e revisar são do servidor (service_role), nunca da sessão. -- SELECT tem o bypass de suporte da plataforma (molde de catalog_products); -- a escrita não tem, de propósito. drop policy if exists crm_proposals_select on public.crm_proposals; create policy crm_proposals_select on public.crm_proposals for select using ( (organization_id in (select public.fn_user_org_ids())) or public.fn_is_platform_admin() ); drop policy if exists crm_proposals_write on public.crm_proposals; drop policy if exists crm_proposals_insert on public.crm_proposals; create policy crm_proposals_insert on public.crm_proposals for insert with check (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'agent') and status = 'rascunho'); drop policy if exists crm_proposals_update on public.crm_proposals; create policy crm_proposals_update on public.crm_proposals for update using (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'agent') and status in ('rascunho', 'enviada')) with check (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'agent')); drop policy if exists crm_proposals_delete on public.crm_proposals; create policy crm_proposals_delete on public.crm_proposals for delete using (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'manager') and status = 'rascunho'); -- A sessão (PostgREST, papel `authenticated`) só faz o que uma rota faz. O -- servidor (`service_role`) e as funções `security definer` não passam por -- aqui: `current_user` delas não é o da sessão. INVOKER de propósito, como -- `fn_meet_stamp`. create or replace function public.fn_crm_proposals_transicao_da_sessao() returns trigger language plpgsql set search_path = public, pg_temp as $$ declare -- o que só o ENVIO escreve (numeração, arquivo, mensagem, retorno) v_envio constant text[] := array['numero', 'ano', 'versao', 'substitui_id', 'sent_at', 'sent_by_user_id', 'pdf_path', 'message_id', 'retorno_id', 'template_snapshot', 'rendered_snapshot']; -- o que decidir e descartar mudam v_decisao constant text[] := array['status', 'decided_at', 'decided_by_user_id', 'decision_reason', 'updated_at']; v_new jsonb := to_jsonb(new); v_old jsonb; begin if current_user not in ('authenticated', 'anon') then return new; end if; if tg_op = 'INSERT' then if exists (select 1 from unnest(v_envio) k where k <> 'versao' and v_new -> k <> 'null'::jsonb) or coalesce(v_new ->> 'versao', '1') <> '1' then raise exception 'proposta_envio_e_do_servidor' using errcode = '42501'; end if; return new; end if; v_old := to_jsonb(old); if old.status = 'rascunho' and new.status = 'rascunho' then -- editar o rascunho: o conteúdo muda, o que é do envio não if exists (select 1 from unnest(v_envio) k where v_new -> k is distinct from v_old -> k) then raise exception 'proposta_envio_e_do_servidor' using errcode = '42501'; end if; elsif (old.status = 'enviada' and new.status in ('aceita', 'recusada')) or (old.status = 'rascunho' and new.status = 'cancelada' and public.fn_role_at_least(new.organization_id, 'manager')) then -- decidir (agent) ou descartar (manager): só a decisão muda if (v_new - v_decisao) is distinct from (v_old - v_decisao) then raise exception 'proposta_transicao_negada' using errcode = '42501'; end if; else raise exception 'proposta_transicao_negada' using errcode = '42501'; end if; return new; end; $$; revoke execute on function public.fn_crm_proposals_transicao_da_sessao() from public, anon; drop trigger if exists trg_crm_proposals_transicao_da_sessao on public.crm_proposals; create trigger trg_crm_proposals_transicao_da_sessao before insert or update on public.crm_proposals for each row execute function public.fn_crm_proposals_transicao_da_sessao(); -- organization_id direto na linha (não mais join com crm_proposals): mais -- simples, mais rápido, e é o que a trava de suporte (0274) precisa medir. drop policy if exists crm_proposal_items_select on public.crm_proposal_items; create policy crm_proposal_items_select on public.crm_proposal_items for select using ( (organization_id in (select public.fn_user_org_ids())) or public.fn_is_platform_admin() ); -- Item só se escreve em RASCUNHO (POST/PATCH/assistente, todos `agent`): o -- item de uma proposta enviada é o que o cliente recebeu, e a v2 é clonada -- pelo servidor. O `delete` do rascunho leva os itens pela FK, sem RLS. drop policy if exists crm_proposal_items_write on public.crm_proposal_items; create policy crm_proposal_items_write on public.crm_proposal_items for all using (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'agent') and exists (select 1 from public.crm_proposals p where p.id = crm_proposal_items.proposal_id and p.organization_id = crm_proposal_items.organization_id and p.status = 'rascunho')) with check (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'agent') and exists (select 1 from public.crm_proposals p where p.id = crm_proposal_items.proposal_id and p.organization_id = crm_proposal_items.organization_id and p.status = 'rascunho')); revoke all on public.crm_proposals from anon; revoke all on public.crm_proposal_items from anon; grant select, insert, update, delete on public.crm_proposals to authenticated; grant select, insert, update, delete on public.crm_proposal_items to authenticated; grant all on public.crm_proposals to service_role; grant all on public.crm_proposal_items to service_role; drop trigger if exists trg_crm_proposals_updated_at on public.crm_proposals; create trigger trg_crm_proposals_updated_at before update on public.crm_proposals for each row execute function public.fn_set_updated_at(); comment on table public.crm_proposals is 'Documento comercial emitido para um contato: itens, valor, prazo. Desfecho volta ao funil.'; comment on column public.crm_proposals.numero is 'Nasce NULL. Alocado só no ENVIO — rascunho descartado não queima número (spec §5.3).'; comment on column public.crm_proposals.versao is 'v2 herda numero/ano da v1 quando uma proposta ENVIADA é revisada (spec §5.4).'; comment on column public.crm_proposals.revision is 'Concorrência otimista do EDITOR: incrementa a cada PATCH de rascunho ou aplicação do assistente. Diferente de `versao`, que é a versão pós-envio, visível ao cliente no PDF.'; -- Numeração: aloca dentro da MESMA transação do envio. A rota que chama isto -- (Tarefa 14) captura 23505 (unique_violation do índice parcial acima) e -- tenta de novo — é o padrão de idempotência que o repositório já usa. create or replace function public.fn_proposta_aloca_numero(p_org uuid, p_ano int) returns int language sql stable security definer set search_path = public as $$ select coalesce(max(numero), 0) + 1 from public.crm_proposals where organization_id = p_org and ano = p_ano; $$; -- Só service_role chama (a rota de envio, Tarefa 14, usa createAdminClient()). -- NUNCA authenticated: a função não confere se p_org pertence a quem chama — -- exposta a authenticated seria RPC cross-tenant (qualquer usuário logado -- aprenderia a numeração de outra organização passando o organization_id dela). revoke all on function public.fn_proposta_aloca_numero(uuid, int) from public, anon, authenticated; grant execute on function public.fn_proposta_aloca_numero(uuid, int) to service_role; -- Bucket privado, URL sempre assinada — mesmo padrão de `lgpd-exports` -- (file_size_limit/allowed_mime_types inclusive; só PDF faz sentido aqui). insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types) values ('propostas', 'propostas', false, 52428800, array['application/pdf']) on conflict (id) do nothing; drop policy if exists "propostas: leitura por organizacao" on storage.objects; create policy "propostas: leitura por organizacao" on storage.objects for select using ( bucket_id = 'propostas' and (split_part(name, '/', 1))::uuid in (select public.fn_user_org_ids()) ); -- Sem policy de escrita: `service_role` ignora RLS (é o papel que faz bypass), -- então uma policy aqui seria decorativa — mesmo padrão dos outros buckets do -- produto (`lgpd-exports`, `skill-assets`), nenhum deles tem uma. `auth.role()` -- também não existe fora de um projeto Supabase real, e quebrava o Postgres -- efêmero do CI (test:db) ao aplicar o baseline. -- Os três `kind` novos em `agent_inbox_items_kind_check` (vencimento, laço de -- retorno, promessa não cumprida) NÃO entram aqui: a constraint tem o seu ÚNICO -- bloco, lá em cima, e foram acrescentados nele -- (tests/unit/baseline-constraint-reconstruida.test.ts). -- A tarefa gravada a partir de um aviso de promessa (Tarefa 1) precisa dizer -- DE ONDE veio, sem exigir que toda `crm_tasks` tenha origem — vocabulário -- ABERTO (sem CHECK), mesmo padrão de `crm_lead_activities.type` (CLAUDE.md -- doutrina de Migrations, exceção DIRC): o emissor usa a constante -- compartilhada de `lib/tarefas/vocabulario-de-origem.ts`, nunca string solta. alter table public.crm_tasks add column if not exists source_kind text; comment on column public.crm_tasks.source_kind is 'De onde a tarefa nasceu (ex.: promised_proposal). NULL = criada à mão. Vocabulário aberto — TypeScript, sem CHECK.'; -- ---- a proposta não aponta para outra organização (migration 0465) ---- create or replace function public.fn_verificar_org_da_proposta() returns trigger language plpgsql set search_path = public as $$ begin if new.lead_id is not null and not exists ( select 1 from public.crm_leads where id = new.lead_id and organization_id = new.organization_id ) then raise exception 'crm_proposal_lead_org_mismatch' using errcode = '23514'; end if; if new.contact_id is not null and not exists ( select 1 from public.contacts where id = new.contact_id and organization_id = new.organization_id ) then raise exception 'crm_proposal_contact_org_mismatch' using errcode = '23514'; end if; if new.conversation_id is not null and not exists ( select 1 from public.conversations where id = new.conversation_id and organization_id = new.organization_id ) then raise exception 'crm_proposal_conversation_org_mismatch' using errcode = '23514'; end if; return new; end; $$; revoke execute on function public.fn_verificar_org_da_proposta() from public, anon; drop trigger if exists trg_crm_proposals_org_consistente on public.crm_proposals; create trigger trg_crm_proposals_org_consistente before insert or update of organization_id, lead_id, contact_id, conversation_id on public.crm_proposals for each row execute function public.fn_verificar_org_da_proposta(); -- ---- C2: contador de propostas, estado enviando e sobrevivencia ao negocio (migration 0466) ---- -- D9 — auditoria de produção (org 59914589, 19/09/2026): `fn_proposta_aloca_numero` -- calculava `max(numero)+1` sobre linhas que EXISTEM; apagar a linha liberava -- o número. O contador abaixo nunca deriva de linha nenhuma — só cresce. create table if not exists public.crm_proposal_counters ( organization_id uuid not null references public.organizations(id) on delete cascade, ano int not null, ultimo_numero int not null default 0, primary key (organization_id, ano) ); comment on table public.crm_proposal_counters is 'D9: numeração de propostas. Só cresce; apagar proposta, negócio ou dados operacionais NUNCA mexe aqui.'; alter table public.crm_proposal_counters enable row level security; revoke all on public.crm_proposal_counters from anon, authenticated; insert into public.crm_proposal_counters (organization_id, ano, ultimo_numero) select organization_id, ano, max(numero) from public.crm_proposals where numero is not null group by organization_id, ano on conflict (organization_id, ano) do update set ultimo_numero = greatest(public.crm_proposal_counters.ultimo_numero, excluded.ultimo_numero); -- `organization_id` de api_audit_log aceita nulo (ON DELETE SET NULL) e é -- exatamente essa a linha que sobrevive à organização apagada — mas -- `crm_proposal_counters.organization_id` é NOT NULL, então sem os dois -- filtros abaixo esta reaplicação falha (e o update.sh trava) na primeira -- instalação que já teve uma organização removida. insert into public.crm_proposal_counters (organization_id, ano, ultimo_numero) select a.organization_id, (a.metadata->>'ano')::int as ano, max((a.metadata->>'numero')::int) as ultimo_numero from public.api_audit_log a where a.action = 'proposal.sent' and a.organization_id is not null and exists (select 1 from public.organizations o where o.id = a.organization_id) and a.metadata->>'numero' is not null and a.metadata->>'ano' is not null group by a.organization_id, (a.metadata->>'ano')::int on conflict (organization_id, ano) do update set ultimo_numero = greatest(public.crm_proposal_counters.ultimo_numero, excluded.ultimo_numero); create or replace function public.fn_proposta_aloca_numero(p_org uuid, p_ano int) returns int language sql security definer set search_path = public, pg_temp as $$ insert into public.crm_proposal_counters (organization_id, ano, ultimo_numero) values (p_org, p_ano, 1) on conflict (organization_id, ano) do update set ultimo_numero = public.crm_proposal_counters.ultimo_numero + 1 returning ultimo_numero; $$; revoke execute on function public.fn_proposta_aloca_numero(uuid, int) from public, anon; revoke execute on function public.fn_proposta_aloca_numero(uuid, int) from authenticated; grant execute on function public.fn_proposta_aloca_numero(uuid, int) to service_role; -- D3 — estado intermediário `enviando`: separa "número reservado" de "entregue". alter table public.crm_proposals drop constraint if exists crm_proposals_status_check; alter table public.crm_proposals add constraint crm_proposals_status_check check (status in ('rascunho','enviando','enviada','aceita','recusada','vencida','cancelada','substituida')); alter table public.crm_proposals add column if not exists message_id uuid references public.messages(id) on delete set null; alter table public.crm_proposals add column if not exists ultima_falha_envio text; -- D10 — a proposta sobrevive ao negócio: SET NULL em vez de CASCADE, e o nome -- impresso no PDF fica gravado para o documento continuar legível sozinho. alter table public.crm_proposals add column if not exists destinatario_nome text; alter table public.crm_proposals alter column lead_id drop not null; alter table public.crm_proposals alter column contact_id drop not null; alter table public.crm_proposals drop constraint if exists crm_proposals_lead_id_fkey; alter table public.crm_proposals add constraint crm_proposals_lead_id_fkey foreign key (lead_id) references public.crm_leads(id) on delete set null; alter table public.crm_proposals drop constraint if exists crm_proposals_contact_id_fkey; alter table public.crm_proposals add constraint crm_proposals_contact_id_fkey foreign key (contact_id) references public.contacts(id) on delete set null; -- Rascunho não tem valor fora do negócio — vira `cancelada` em vez de ficar -- órfão. Enviada e além sobrevivem via o SET NULL acima. Trigger roda ANTES -- do delete: lead_id ainda aponta para a linha que vai sumir. create or replace function public.fn_cancelar_propostas_rascunho_do_lead() returns trigger language plpgsql security definer set search_path = public, pg_temp as $$ begin update public.crm_proposals set status = 'cancelada' where lead_id = old.id and organization_id = old.organization_id and status = 'rascunho'; return old; end; $$; revoke execute on function public.fn_cancelar_propostas_rascunho_do_lead() from public, anon; revoke execute on function public.fn_cancelar_propostas_rascunho_do_lead() from authenticated; drop trigger if exists trg_crm_leads_cancelar_propostas_rascunho on public.crm_leads; create trigger trg_crm_leads_cancelar_propostas_rascunho before delete on public.crm_leads for each row execute function public.fn_cancelar_propostas_rascunho_do_lead(); notify pgrst, 'reload schema'; -- ---- C3: precificação e disciplina do rascunho (migration 0467) ---- -- 0467 — Onda C3 da spec de Propostas (2026-09-23): D5 (raiz: preço vindo do -- catálogo no servidor) + §5.1/5.2 (pricing_status, item sem preço) + §5.3 -- (um rascunho aberto por negócio, com dedupe do que já existe). -- ── §5.2 — item sem preço ("a definir") ───────────────────────────────────── -- O CHECK crm_proposal_items_preco_nao_negativo (preco_unitario_cents >= 0) -- continua valendo quando houver valor: em Postgres, CHECK só falha quando a -- expressão avalia para FALSE, e `NULL >= 0` avalia NULL (passa). Não precisa -- reescrever a constraint. alter table public.crm_proposal_items alter column preco_unitario_cents drop not null; -- ── §5.1 — pricing_status ──────────────────────────────────────────────────── alter table public.crm_proposals add column if not exists pricing_status text not null default 'missing' check (pricing_status in ('missing', 'catalog', 'manual', 'approved')); comment on column public.crm_proposals.pricing_status is 'C3/§5.1: missing (algum item sem preço, PDF mostra "a definir", envio recusado), ' 'catalog (todo item veio do catálogo), manual (algum item com preço digitado). ' '"approved" é reservado para fluxo de aprovação fora desta onda — nunca escrito aqui.'; -- Backfill idempotente: toda linha que já existe é recalculada a partir dos -- próprios itens (genérico — nenhum id de tenant hardcoded, doutrina de -- migrations item 4). Antes desta migration TODO item tinha preço obrigatório, -- então o resultado aqui só pode ser 'catalog', 'manual' ou 'missing' (proposta -- sem item nenhum). with computo as ( select p.id, case when count(i.id) = 0 then 'missing' when bool_or(i.preco_unitario_cents is null) then 'missing' when bool_and(i.product_id is not null) then 'catalog' else 'manual' end as status_calculado from public.crm_proposals p left join public.crm_proposal_items i on i.proposal_id = p.id group by p.id ) -- `pricing_status <> 'approved'` protege um fluxo que ainda não existe: o -- baseline é reaplicado em TODO update.sh, e este backfill roda de novo a -- cada vez. No dia em que uma onda futura gravar 'approved' (aprovação -- manual de uma proposta), uma atualização de VPS sem essa guarda -- desfaria a aprovação em silêncio, recalculando a partir dos itens. update public.crm_proposals p set pricing_status = c.status_calculado from computo c where p.id = c.id and p.pricing_status is distinct from c.status_calculado and p.pricing_status <> 'approved'; -- ── §5.3 — um rascunho aberto por negócio ─────────────────────────────────── -- Dedupe ANTES do índice (doutrina de migrations item 8): mantém só o -- rascunho mais recente por (organization_id, lead_id); os demais viram -- 'cancelada' — NUNCA apagados, o histórico continua na timeline/auditoria. -- `lead_id` nulo (proposta órfã, D10) nunca colide aqui: a trigger -- `fn_cancelar_propostas_rascunho_do_lead` (migration 0466) já vira -- 'cancelada' TODO rascunho antes do lead ser apagado, então nenhuma linha -- com status='rascunho' e lead_id nulo pode existir. with ranking as ( select id, row_number() over ( partition by organization_id, lead_id order by created_at desc, id desc ) as posicao from public.crm_proposals where status = 'rascunho' ) update public.crm_proposals p set status = 'cancelada' from ranking r where p.id = r.id and r.posicao > 1; create unique index if not exists crm_proposals_rascunho_unico_por_negocio_uidx on public.crm_proposals (organization_id, lead_id) where status = 'rascunho'; notify pgrst, 'reload schema'; -- ---- M0: proposta referencia modelo (migration 0468) ---- -- `crm_proposals` ganha `template_slug`, `template_version`, -- `template_snapshot` e `rendered_snapshot` — nullable e aditiva: proposta sem -- modelo (todo o histórico de hoje) convive sem migração de dado nenhuma. Os -- snapshots ficam vazios até a Onda M5 (envio); a M0 só abre o lugar. CHECK -- `crm_proposals_template_slug_versao_juntos_check`: os dois campos de "qual -- modelo" nascem e morrem juntos. alter table public.crm_proposals add column if not exists template_slug text; alter table public.crm_proposals add column if not exists template_version int; alter table public.crm_proposals add column if not exists template_snapshot jsonb; alter table public.crm_proposals add column if not exists rendered_snapshot jsonb; -- `template_slug_sugerido` vive no bloco da migration 0474, abaixo. alter table public.crm_proposals drop constraint if exists crm_proposals_template_slug_versao_juntos_check; alter table public.crm_proposals add constraint crm_proposals_template_slug_versao_juntos_check check ((template_slug is null) = (template_version is null)); notify pgrst, 'reload schema'; -- ---- C4: revisão por versão e auditoria (migration 0469) ---- -- Onda C4 da spec de Propostas (2026-09-23): D4 (revisar cria v2 em -- rascunho pela tela — a v1 e a v2 convivem, a v1 ainda `enviada`, até a v2 -- ser enviada). A unicidade de numeração vigente é (organization_id, ano, -- numero) — cedo demais para D4: as duas linhas da mesma cadeia teriam o -- MESMO numero/ano com status <> 'substituida' ao mesmo tempo. -- Medir ANTES de trocar o índice (doutrina de migrations item 8): não deve -- haver hoje nenhum grupo violando a chave nova, porque a v2 só nascia -- (até esta migration) dentro do envio, no mesmo instante em que a v1 virava -- substituida. Se houver, a migration PARA — investigar manualmente é mais -- seguro que criar um índice que a própria migration furaria. do $$ declare v_conflitos int; begin select count(*) into v_conflitos from ( select organization_id, ano, numero, versao from public.crm_proposals where numero is not null and status <> 'substituida' group by organization_id, ano, numero, versao having count(*) > 1 ) c; if v_conflitos > 0 then raise exception 'migration_0413: % grupo(s) já violam (organization_id, ano, numero, versao) — investigar antes de trocar o índice', v_conflitos; end if; end $$; drop index if exists public.crm_proposals_numero_ano_org_uidx; create unique index if not exists crm_proposals_numero_ano_versao_org_uidx on public.crm_proposals (organization_id, ano, numero, versao) where numero is not null and status <> 'substituida'; notify pgrst, 'reload schema'; -- ---- E1: followup automatico ao enviar (migration 0470) ---- -- Onda E1 da spec de Propostas (2026-09-24): N2 (a proposta ENVIADA agenda um -- retorno automático via lib/followup/retorno-crm.ts). `retorno_id` guarda QUAL -- retorno foi agendado, para cancelá-lo se o cliente decidir (aceita/recusada) -- antes da data marcada. `on delete set null`: se a linha do cron sumir, a -- proposta continua íntegra (o retorno já disparou ou foi cancelado por fora). alter table public.crm_proposals add column if not exists retorno_id uuid references public.cron_jobs(id) on delete set null; comment on column public.crm_proposals.retorno_id is 'N2: id do retorno automático agendado ao enviar (cron_jobs). NULL = nenhum agendado (falha ao agendar não bloqueia o envio) ou já cancelado/disparado.'; notify pgrst, 'reload schema'; -- ---- M0: tabela de modelos de proposta (migration 0471) ---- -- proposal_templates guarda só CÓPIAS por organização (spec-mãe §6.1: a base -- da plataforma mora no código, MODELOS_BASE, nunca no banco com -- organization_id nulo). SEM CHECK fechado de slug: os 8 modelos-piloto da -- spec de 21/09 não estão no repositório (medido em 24/09/2026); validação de -- slug fica no Zod da aplicação até o piloto ser definido. Índice único -- parcial (organization_id, slug) where is_active: só uma versão ATIVA por -- slug por organização. `base_slug`/`base_version` guardam de qual modelo da -- base a cópia veio (spec-mãe §6.1, achado Important da revisão final — sem -- isto a "atualização sugerida" da decisão 15 não tem como comparar). RLS em -- duas policies, molde de crm_proposals (migration 0466): SELECT aberto a -- todo membro da organização (+ bypass de suporte da plataforma), WRITE com -- piso `fn_role_at_least(organization_id, 'agent')` — sem esse piso (achado -- Important da revisão final), qualquer viewer conseguia escrever/apagar -- modelo pela REST. `revoke ... from anon` explícito, mesma régua da tabela -- irmã. create table if not exists public.proposal_templates ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, slug text not null, version int not null default 1, base_slug text, base_version int, sections jsonb not null default '[]'::jsonb, section_order text[] not null default '{}', is_active boolean not null default true, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); alter table public.proposal_templates drop constraint if exists proposal_templates_base_slug_versao_juntos_check; alter table public.proposal_templates add constraint proposal_templates_base_slug_versao_juntos_check check ((base_slug is null) = (base_version is null)); comment on table public.proposal_templates is 'Cópia por organização de um modelo de proposta. A base da plataforma (os modelos-piloto) mora no código (MODELOS_BASE), nunca aqui com organization_id nulo — ver spec-mãe §6.1.'; create unique index if not exists proposal_templates_ativo_por_slug_org_uidx on public.proposal_templates (organization_id, slug) where is_active; create unique index if not exists proposal_templates_slug_versao_org_uidx on public.proposal_templates (organization_id, slug, version); create index if not exists proposal_templates_org_idx on public.proposal_templates (organization_id); alter table public.proposal_templates enable row level security; drop policy if exists tenant_isolation_proposal_templates_all on public.proposal_templates; drop policy if exists proposal_templates_select on public.proposal_templates; create policy proposal_templates_select on public.proposal_templates for select using ( (organization_id in (select public.fn_user_org_ids())) or public.fn_is_platform_admin() ); -- proposal_templates_write nasce no bloco "modelos de proposta da empresa -- (migration 0476)", mais abaixo, já com o piso de 'manager' — o drop dela -- ali cobre o clone que só tem esta versão (0471). revoke all on public.proposal_templates from anon; grant select, insert, update, delete on public.proposal_templates to authenticated; grant all on public.proposal_templates to service_role; notify pgrst, 'reload schema'; -- ---- M1: briefing e motivo de revisão (migration 0472) ---- alter table public.crm_proposals add column if not exists briefing_json jsonb; alter table public.crm_proposals add column if not exists prazo_dias_uteis int; alter table public.crm_proposals add column if not exists pagamento text; alter table public.crm_proposals add column if not exists resumo_comercial text; alter table public.crm_proposals add column if not exists version_reason text; notify pgrst, 'reload schema'; -- ---- M3: edição manual por seção do documento (migration 0473) ---- alter table public.crm_proposals add column if not exists secoes_editadas jsonb; -- ---- IA sugere o modelo da proposta (migration 0474) ---- -- A IA sugere um modelo ao rascunhar; uma pessoa confirma (decisão do -- dono, 25/09/2026). `template_slug_sugerido` é ESTADO PROVISÓRIO: -- nunca entra na constraint -- `crm_proposals_template_slug_versao_juntos_check` (bloco da migration -- 0468, acima), porque essa constraint é sobre o modelo CONFIRMADO -- (`template_slug` + `template_version`). alter table public.crm_proposals add column if not exists template_slug_sugerido text; comment on column public.crm_proposals.template_slug_sugerido is 'Modelo que a IA sugeriu ao rascunhar (crm_draft_proposal). Some quando alguém confirma um modelo (vira template_slug) ou troca por outro — nunca é o modelo "de fato".'; notify pgrst, 'reload schema'; -- ---- proposta pronta para revisão (migration 0475) ---- -- A Central avisa quando uma proposta rascunhada pela IA precisa de -- revisão humana: falta confirmar o modelo sugerido (plano N1) ou falta -- preço de catálogo. Nasce ao rascunhar e se resolve sozinho quando as -- duas pendências somem, ou quando a proposta é enviada ou descartada. -- -- Sem DDL aqui DE PROPÓSITO: a única mudança desta migration é o valor -- 'proposta_pronta_para_revisao' em `agent_inbox_items_kind_check`, que -- tem bloco ÚNICO neste arquivo (doutrina de -- `baseline-constraint-reconstruida`; precedente: a migration 0139 é SEM -- apêndice pelo mesmo motivo). O valor já está na lista, acima. -- ---- modelos de proposta da empresa (migration 0476) ---- -- Espelho idempotente de supabase/migrations/20260928141400_0476_modelos_de_proposta_da_empresa.sql alter table public.proposal_templates add column if not exists nome text; alter table public.proposal_templates add column if not exists descricao text; comment on column public.proposal_templates.nome is 'Nome do modelo para uma pessoa ler. Nulo numa cópia de modelo da plataforma = usa o rótulo do código (ROTULO_DO_MODELO).'; comment on column public.proposal_templates.descricao is 'Para que serve este modelo, em uma frase. Opcional.'; drop policy if exists proposal_templates_write on public.proposal_templates; create policy proposal_templates_write on public.proposal_templates for all using (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'manager')) with check (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'manager')); -- ---- LGPD alcança a proposta comercial (migration 0477) ---- -- Redefine `fn_lgpd_cascade_redact_contact` a partir da ÚLTIMA definição -- deste arquivo (doutrina item 10: o arquivo é aplicado em ordem e a -- última definição é a que vale) — todo o corpo abaixo é a main -- vigente, e só o passo 6b-crm_proposals é novo: -- destinatario_nome recebe o rótulo (não null — mesma razão de -- `crm_leads.title`); briefing_json e resumo_comercial são redigidos -- por descreverem a PESSOA; `template_slug_sugerido` NÃO entra (slug -- de modelo). O PDF enviado vai para `storage_redaction_queue` no bucket -- `propostas`, e o texto do documento (pdf_path, rendered_snapshot, -- secoes_editadas) sai da linha. -- Espelho de supabase/migrations/20260928141500_0477_redact_alcanca_crm_proposals.sql. CREATE OR REPLACE FUNCTION "public"."fn_lgpd_cascade_redact_contact"("p_organization_id" "uuid", "p_contact_id" "uuid", "p_request_id" "uuid") RETURNS "jsonb" LANGUAGE "plpgsql" SECURITY DEFINER SET "search_path" TO 'public', 'extensions', 'pg_temp' AS $$ declare v_already bool; v_counts jsonb := '{}'::jsonb; v_media_paths text[] := '{}'; v_anon_label text; v_count int; -- As grafias do telefone desta pessoa, capturadas ANTES de o passo 1 zerar -- `contacts.phone_number`. A ordem aqui não é detalhe: o expurgo da -- prospecção roda ~150 linhas depois do `update contacts`, e ler o telefone -- lá embaixo leria NULL — o braço por telefone existiria no código e não -- alcançaria linha nenhuma, que é pior que não existir, porque parece feito. v_variantes text[] := '{}'; begin perform public.fn_service_lock(p_organization_id,p_contact_id); select is_anonymized into v_already from contacts where id = p_contact_id and organization_id = p_organization_id; if not found then raise exception 'contact not found' using errcode = 'P0002'; end if; if v_already then return jsonb_build_object('already_anonymized', true, 'counts', v_counts, 'media_paths', v_media_paths); end if; v_anon_label := 'Cliente Anonimizado #' || substring(p_contact_id::text from 1 for 8); -- Capturado AGORA, enquanto o telefone ainda existe (o passo 1 o apaga). select coalesce(public.fn_telefone_variantes(phone_number), '{}') into v_variantes from contacts where id = p_contact_id and organization_id = p_organization_id; -- Collect media storage paths (we only delete what we own — media_storage_path) select coalesce(array_agg(distinct media_storage_path) filter (where media_storage_path is not null), '{}') into v_media_paths from messages where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ); -- 1. contacts (irreversible) update contacts set name = v_anon_label, display_name = v_anon_label, email = null, -- email_normalized NÃO entra: é GENERATED ALWAYS AS (lower(trim(email))) -- e o Postgres recusa escrita nela — a linha acima já a zera por derivação. -- Com a atribuição, o cascade INTEIRO abortava e nada era anonimizado. phone_number = null, cpf_encrypted = null, cpf_hash = null, birthdate = null, is_anonymized = true, anonymized_at = now(), consent = '{}'::jsonb, source_metadata = '{}'::jsonb, tags = '{}'::text[], updated_at = now() where id = p_contact_id and organization_id = p_organization_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('contacts', v_count); -- 2. conversations metadata + preview strip update conversations set metadata = '{}'::jsonb, last_message_preview = null, -- O motivo CRU da última passagem (migration 0291). É código de -- vocabulário, não texto livre — mas ele diz que ESTA pessoa foi escalada -- por irritação, por assunto jurídico ou por suspeita de opt-out, e isso é -- um fato sobre ela. Entra NESTE update, e não num segundo: mesmo -- predicado, mesmas linhas, metade das varreduras. -- -- ⚠️ `last_handoff_reason` é CHAVE DE NEGÓCIO em outro módulo: a ponte de -- voz limpa o silêncio filtrando pelo VALOR da coluna -- (`lib/wacalls/events-bridge.ts`). Zerá-la num contato anonimizado é -- seguro — não há chamada viva de contato anonimizado — e é a razão de -- esta entrega NÃO usar essa coluna para texto rico: ela continua -- recebendo só o código, e o texto vive em `passagens_de_atendimento`. last_handoff_reason = null, updated_at = now() where contact_id = p_contact_id and organization_id = p_organization_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('conversations', v_count); -- 3. messages: redact body + null media + strip metadata (preserve status/timestamps/conversation_id) update messages set body = '[mensagem anonimizada]', media_url = null, media_mime = null, media_size_bytes = null, media_storage_path = null, metadata = '{}'::jsonb, updated_at = now() where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('messages', v_count); -- 4. crm_lead_activities — strip payload, metadata E reason (migration 0071). -- `reason` é texto livre escrito por LLM sobre a conversa do lead: supor que -- nunca conterá um nome é a suposição que falha. `evidence` NÃO é limpa — -- guarda só ids, e as linhas apontadas são redigidas por conta própria. update crm_lead_activities set payload = '{}'::jsonb, metadata = '{}'::jsonb, reason = null where organization_id = p_organization_id and ( contact_id = p_contact_id or lead_id in ( select lead_id from crm_lead_links where target_kind = 'contact' and target_id = p_contact_id and organization_id = p_organization_id ) or lead_id in ( select id from crm_leads where contact_id = p_contact_id and organization_id = p_organization_id ) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('activities', v_count); -- 5. crm_leads — strip title/description/custom_fields/source_metadata/tags but PRESERVE pipeline/stage/value update crm_leads set title = v_anon_label, description = null, custom_fields = '{}'::jsonb, source_metadata = '{}'::jsonb, tags = '{}'::text[], updated_at = now() where organization_id = p_organization_id and ( contact_id = p_contact_id or id in ( select lead_id from crm_lead_links where target_kind = 'contact' and target_id = p_contact_id and organization_id = p_organization_id ) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('leads', v_count); -- 6. orders — PRESERVE values + status + timestamps. Strip personal fields from payload jsonb -- and replace customer_external_id with null (FK-safe; soft de-link). Keep contact_id null. update orders set payload = (coalesce(payload, '{}'::jsonb)) - 'customer' - 'customer_name' - 'customer_email' - 'customer_phone' - 'shipping_address' - 'billing_address' - 'contact_identification', customer_external_id = null, contact_id = null, is_anonymized = true, updated_at = now() where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('orders', v_count); -- 6b. crm_proposals (migration 0477, #1504) — PRESERVA número, valores, -- itens, datas e status; redige só o que identifica a PESSOA: -- destinatario_nome — nome impresso no PDF (D10/0466: gravado para o -- documento continuar legível sozinho); recebe o rótulo, não null — -- mesma razão de `crm_leads.title`. -- briefing_json — insumo estruturado do briefing (0472): descreve -- o que o CLIENTE disse sobre o próprio negócio. -- resumo_comercial — texto gerado na emissão a partir do briefing e do -- nome do destinatário. -- `template_slug_sugerido` NÃO entra: é slug de MODELO, nunca dado do contato. -- O PDF que o cliente recebeu (bucket `propostas`, `/.pdf`) -- tem o nome dele impresso: redigir as colunas e deixar o arquivo seria -- anonimizar a linha e manter o documento. Vai para a mesma fila de expurgo -- da mídia (passo 7), com o bucket CERTO — a mensagem que levou o PDF -- aponta para o mesmo caminho, mas o passo 7 só enfileira `whatsapp-media`. -- Lido ANTES de o passo seguinte zerar `pdf_path`. insert into storage_redaction_queue (organization_id, request_id, bucket, object_path) select p_organization_id, p_request_id, 'propostas', pdf_path from crm_proposals where organization_id = p_organization_id and contact_id = p_contact_id and pdf_path is not null and length(pdf_path) > 0 -- só arquivo DESTA organização: o expurgo nunca alcança o PDF de outra and pdf_path like p_organization_id::text || '/%' on conflict (bucket, object_path) do nothing; update crm_proposals set destinatario_nome = v_anon_label, briefing_json = '{}'::jsonb, resumo_comercial = null, -- o texto do documento como foi montado e como foi editado à mão: é o -- conteúdo do PDF, com o mesmo nome dentro. rendered_snapshot = null, secoes_editadas = null, pdf_path = null, updated_at = now() where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('crm_proposals', v_count); -- CAMPANHAS: o que foi DITO à pessoa e o endereço para onde foi. -- -- `rendered_body` é a mensagem que ela recebeu e `recipient_address` o -- telefone. Sem esta limpeza, anonimizar devolveria SUCESSO deixando a -- prospecção legível — falha muda, com o SLA marcado como cumprido. -- A LINHA FICA: ela é a prova de que a pessoa esteve naquela campanha, e -- apagá-la desfaria a contagem de quem recebeu. update campaign_recipients set rendered_body = null, recipient_address = null, variables = '{}'::jsonb, last_error_detail = null, updated_at = now() where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('campaign_recipients', v_count); -- LISTA DE EXCLUSÃO: solta o vínculo e apaga a cauda do telefone. -- -- O HASH do endereço PERMANECE de propósito: é ele que faz o "não me mande -- mais" continuar valendo depois da anonimização. Apagá-lo faria a pessoa -- voltar a receber campanha. update campaign_suppressions set address_tail = null, reason = null, contact_id = null where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('campaign_suppressions', v_count); -- REAPLICADO AO DERIVAR ESTE APÊNDICE (merge da main, 0359 comanda). -- O Postgres troca o corpo INTEIRO num `create or replace`: um apêndice -- escrito sobre uma versão anterior da função APAGA, em silêncio, o passo -- que outra entrega acrescentou. Anonimizar devolveria SUCESSO com o texto -- da comanda ainda legível — e o SLA marcado como cumprido. -- 6b. sales — a comanda. PRESERVA valor, status e datas, e NÃO desliga o -- contato: a venda é registro financeiro (e fiscal) da organização, e -- desligá-la do contato faria o relatório por cliente deixar de fechar -- com o faturamento do período — divergência muda, meses depois, num -- número que ninguém consegue reconciliar. O contato apontado já é -- `Cliente Anonimizado #N`; o que sai daqui é o TEXTO LIVRE, que é onde -- a pessoa é nomeada de novo ("cliente da Ana, filha da Dona Maria"). -- Os itens (`sale_items`) não entram: `description` ali é o nome do -- SERVIÇO, congelado na inclusão, e apagá-lo destruiria o relatório por -- serviço sem tirar dado de pessoa nenhum. update sales set notes = null, cancel_reason = case when cancel_reason is null then null else '[redigido]' end, reverse_reason = case when reverse_reason is null then null else '[redigido]' end, updated_at = now() where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('sales', v_count); -- 7. enqueue media for async deletion (idempotent via unique (bucket, object_path)) if array_length(v_media_paths, 1) > 0 then insert into storage_redaction_queue (organization_id, request_id, bucket, object_path) select p_organization_id, p_request_id, 'whatsapp-media', path from unnest(v_media_paths) as path where path is not null and length(path) > 0 on conflict (bucket, object_path) do nothing; end if; -- 7b. voice_calls — o TELEFONE de quem falou ao telefone (migration 0235). -- -- `peer_phone` é `not null` e guarda o número da outra ponta: depois de -- anonimizar o contato, ele sobrevivia ligado ao `contact_id` e reidentificava -- a pessoa que pediu para ser esquecida. É o mesmo argumento que a foto de -- perfil já tinha (ver o bloco do avatar em `lib/lgpd/redact-cascade.ts`): -- anonimizar em toda parte menos numa é não ter anonimizado. -- -- O que fica: direção, status, motivo do fim, marcas de tempo e duração. Um -- registro de "houve uma chamada de 12 minutos" sem número e sem dono não -- identifica ninguém e é o que sustenta a métrica do atendente e a fatura. -- `peer_phone` é NOT NULL, então recebe o rótulo, não `null`. update voice_calls set peer_phone = v_anon_label, owner_user_id = null, created_by = null, updated_at = now() where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('voice_calls', v_count); -- Native discovery stores commercial/person data before the Inbox exists. -- Keep only keyed suppression tokens, restricted to the server, to prevent -- another extraction from reintroducing this erased candidate. -- -- O PREDICADO ALCANÇA POR VÍNCULO **OU** POR TELEFONE, e o segundo braço é o -- que conserta um buraco real: quando o telefone raspado já pertencia a um -- contato conhecido da organização, `lib/prospecting/store.ts` grava o -- candidato como `skipped` e DEIXA `contact_id` nulo de propósito (lá o -- vínculo é o freio de mão do envio, em `worker.ts`). Só pelo `contact_id`, -- essa pessoa — justamente a que a empresa já conhece — pedia exclusão, -- recebia sucesso, a auditoria gravava `lgpd.redact_executed`, e o nome, o -- telefone e o endereço dela seguiam legíveis aqui. -- -- Em expurgo os dois erros não têm o mesmo preço: alcançar demais custa um -- registro de prospecção descartado; alcançar de menos é violação legal. Por -- isso o `or`, e por isso a comparação por VARIANTE do nono dígito. update prospecting_candidates set suppression_salt = gen_random_bytes(32) where organization_id = p_organization_id and (contact_id = p_contact_id or (phone is not null and regexp_replace(phone, '\D', '', 'g') = any (v_variantes))) and suppression_salt is null; update prospecting_candidates set suppression_place = hmac(convert_to(place_id, 'UTF8'), suppression_salt, 'sha256'), suppression_phone = case when phone is null then null else hmac(convert_to(phone, 'UTF8'), suppression_salt, 'sha256') end, place_id = 'redacted:' || id::text, phone = null, data = jsonb_build_object('key', 'redacted:' || id::text, 'name', v_anon_label, 'phone', null, 'website', null, 'category', null, 'address', null, 'maps_url', null, 'rating', null, 'reviews', null, 'emails', '[]'::jsonb, 'socials', '[]'::jsonb), status = 'skipped', service_boundary = null, error = null, updated_at = now() -- MESMO predicado do bloco anterior. Se os dois divergirem, a linha alcançada -- por um e não pelo outro fica com `suppression_salt` semeado e os dados -- pessoais intactos — um estado que parece tratado e não está. where organization_id = p_organization_id and (contact_id = p_contact_id or (phone is not null and regexp_replace(phone, '\D', '', 'g') = any (v_variantes))); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('prospecting_candidates', v_count); -- agent_cases — o que a IA escreveu SOBRE a pessoa quando travou (migration 0280). -- -- O caso é o texto que a equipe lê antes de decidir: `title`, `summary` e -- `blocker` saem do modelo a partir da conversa, e `context_snapshot` é o -- recorte dessa conversa que o motor mandou para ele. Nada disso é registro de -- operação — é o relato do problema de uma pessoa identificável, escrito por -- máquina. Sem este passo, anonimizar devolvia SUCESSO com o relato intacto. -- -- As três colunas de texto são `not null`: recebem rótulo e texto fixo, nunca -- `null` (a mesma razão de `voice_calls.peer_phone` logo acima). -- -- ⚠️ `updated_at` FICA FORA DO `set`, de propósito. O cobrador de caso parado -- (`app/api/v1/cron/case-stale-watcher/route.ts`) lê `updated_at` como "alguém -- da equipe encostou neste caso". A cascata não é alguém encostando: escrever -- ali faria a anonimização ADIAR a cobrança de um caso que continua parado, e -- o efeito só apareceria como um cliente esperando mais tempo. -- -- O vínculo é pela CONVERSA porque `agent_cases` não tem FK para `contacts`. update agent_cases set title = v_anon_label, summary = '[resumo anonimizado]', blocker = '[bloqueio anonimizado]', context_snapshot = '{}'::jsonb where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('agent_cases', v_count); -- agent_case_events — a linha do tempo do caso (migration 0280). -- -- `body` é o que a pessoa da equipe escreveu ao responder o caso e o que o -- agente registrou sobre o que o LEAD respondeu; `metadata` carrega o recorte -- que o motor anexou. `kind`, `actor_kind`, `human_action` e `created_at` -- FICAM: são o registro de que houve um toque humano e quando — operação, não -- dado da pessoa, e é deles que sai a métrica de atendimento. update agent_case_events set body = null, metadata = '{}'::jsonb where organization_id = p_organization_id and case_id in ( select id from agent_cases where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('agent_case_events', v_count); -- demandas — o assunto do pedido (migration 0280). -- -- `assunto` é texto livre sobre o que a pessoa pediu. O resto da linha é a -- operação da demanda (origem, estado, dono, prazo, desfecho) e fica de pé: -- apagar a linha inteira tiraria da organização a resposta a "quantos pedidos -- houve em março", que é o mesmo argumento do compromisso da agenda. -- -- FK direta (`demandas.contact_id` é `not null`), então o vínculo é o contato. update demandas set assunto = null where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('demandas', v_count); -- agent_inbox_items — o aviso que leva o texto do caso para a Central (migration 0280). -- -- O `body` do aviso de caso parado EMBUTE o título do caso -- (`app/api/v1/cron/case-stale-watcher/route.ts:128`), e o do handoff embute o -- motivo da parada (`lib/ai/handoff/orchestrator.ts:335`). Redigir o caso e -- deixar o aviso de pé seria anonimizar em toda parte menos numa — que é não -- ter anonimizado. O molde (resolver + trocar o corpo + soltar a referência) é -- o de `fn_meet_redact_contact`, que já faz isto para o aviso de compromisso. -- -- ⚠️ O VÍNCULO É POLIMÓRFICO E TEM TRÊS BRAÇOS, não dois. Medido nos -- produtores, não suposto: `handoff` nasce com `ref_kind='contact'` -- (`lib/ai/handoff/orchestrator.ts:339`) E com `ref_kind='conversation'` -- (`lib/agent-engine/agent/inbound-turn.ts:4100`); `case_stale` nasce SEMPRE -- com `ref_kind='agent_case'` (a rota do cron acima, e a política em -- `lib/ai/inbox-destino.ts:38`). Um predicado com só os dois primeiros braços -- casa ZERO avisos de caso parado — e casar zero linha não é erro: é sucesso -- com o texto intacto. -- -- Os `kind` são os MEDIDOS no CHECK vigente (`supabase/baseline.sql`, bloco -- único de `agent_inbox_items_kind_check`). `case_opened` NÃO existe, e kind -- inexistente num `in (...)` também casa zero e devolve sucesso. Para -- reconferir sem acreditar nesta prosa: -- grep -n "agent_inbox_items_kind_check check" -A40 supabase/baseline.sql update agent_inbox_items set status = 'resolved', resolved_at = now(), body = 'Contato anonimizado.', ref_id = null where organization_id = p_organization_id -- `aviso_de_caso_nao_entregue` (migration 0292) entra AQUI e não num -- passo próprio: é o mesmo predicado polimórfico, e o braço -- `ref_kind='agent_case'` já alcança o caso do titular. O corpo do aviso -- embute o título do caso, que é texto sobre a pessoa. and kind in ('handoff', 'case_stale', 'aviso_de_caso_nao_entregue') and ( (ref_kind = 'contact' and ref_id = p_contact_id) or (ref_kind = 'conversation' and ref_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id )) or (ref_kind = 'agent_case' and ref_id in ( select id from agent_cases where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ) )) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('agent_inbox_items', v_count); -- agent_case_chat_messages — a consulta interna da equipe à IA SOBRE o caso -- (migration 0281). FK DIRETA para `contacts`, então o vínculo é o titular e -- não precisa passar pela conversa. -- -- `redacted_at is null` no `where` é o que torna o passo IDEMPOTENTE: a -- varredura diária de redações incompletas roda a função de novo, e sem essa -- condição o carimbo de QUANDO se apagou seria reescrito a cada rodada. -- -- A linha NÃO é apagada, só o texto: quem abrir o caso depois continua vendo -- que a equipe perguntou N vezes, quando, e se a IA respondeu. Apagar a linha -- inteira ficaria verde num teste de "o texto sumiu" e tiraria da organização -- a resposta a "quanto a equipe deliberou sobre este caso". update agent_case_chat_messages set body = null, redacted_at = now() where organization_id = p_organization_id and contact_id = p_contact_id and redacted_at is null; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('agent_case_chat_messages', v_count); -- passagens_de_atendimento — o BRIEFING é sobre a pessoa (migration 0291). -- -- A linha guarda o que a IA concluiu sobre um atendimento de alguém -- identificável: o que ela entendeu que a pessoa quer (`title`), a narrativa -- que quem assumiu leu (`body`), as PALAVRAS LITERAIS do cliente (`notes`), o -- texto livre de quem passou (`content`) e o que a IA já tinha tentado -- (`tentativas`). Nada disso é registro de operação — é o relato do problema -- de uma pessoa, escrito por máquina, na tela de quem vai responder. -- -- `body` é `not null` e recebe o RÓTULO, não `null` — a mesma razão de -- `voice_calls.peer_phone` e de `agent_cases.title` acima: coluna obrigatória -- anulada aborta o cascade INTEIRO, e um cascade abortado não anonimiza nada. -- -- O que FICA, de propósito: `motor`, `origem`, `motivo_codigo`, -- `cliente_avisado`, `aviso_motivo_codigo`, `criado_em` e o par de -- reconhecimento. São operação — quantas passagens houve, por quê, quanto -- tempo até alguém assumir. Um passo que apagasse a linha inteira ficaria -- verde num teste de "o texto sumiu" e tiraria da organização a resposta a -- "quantos atendimentos a IA devolveu em março, e quanto tempo esperaram". -- -- O vínculo é a FK DIRETA `contact_id`: a tabela a carrega exatamente para -- este passo não precisar passar pela conversa. update passagens_de_atendimento set body = v_anon_label, title = null, notes = null, content = null, tentativas = '[]'::jsonb where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('passagens_de_atendimento', v_count); -- entregas_de_aviso_de_caso — o registro do aviso ao suporte (migration 0292). -- -- A tabela NÃO guarda o texto do aviso (só `corpo_hash`), e a única coluna -- capaz de ecoar um dado da pessoa é `erro_detalhe`: ali vai o texto CRU que -- o transporte devolveu, truncado, e um provedor que recusa um envio costuma -- devolver o destinatário dentro da mensagem de erro. -- -- O que FICA, de propósito: `status`, `erro_codigo`, `tentativas`, -- `enviado_em`, `destino`, `corpo_hash`. São operação — quantos avisos saíram, -- quantos falharam e por quê. Um passo que apagasse a linha inteira ficaria -- verde num teste de "o texto sumiu" e tiraria da organização a resposta a -- "quantos avisos não chegaram em março". `destino` é o telefone da EQUIPE, -- não do titular: anonimizar um cliente não apaga o número do plantão. -- -- ⚠️ PONTO CEGO DECLARADO: `tests/invariants/lgpd-cascata-alcanca-quem- -- guarda-pessoa.test.ts` só cobra tabela com FK para `contacts` E coluna cujo -- NOME case o padrão de PII. Esta tabela não satisfaz nenhuma das duas — o -- gate ficaria VERDE sem este passo. Ele entra porque é certo, não porque o -- gate cobra, e isto está escrito aqui para a próxima sessão não o remover -- achando que é ornamento. Quem o vigia é a catraca -- `tests/invariants/cascata-lgpd-nao-encolhe.test.ts`. -- -- O vínculo é pela CONVERSA, como o de `agent_cases`: esta tabela aponta para -- o caso, e o caso não tem FK para `contacts`. update entregas_de_aviso_de_caso set erro_detalhe = null where organization_id = p_organization_id and case_id in ( select id from agent_cases where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('entregas_de_aviso_de_caso', v_count); -- 8. dense audit row insert into api_audit_log (organization_id, action, actor_user_id, resource_type, resource_id, metadata, bypassed_rls) values ( p_organization_id, 'lgpd.redact_executed', null, 'contact', p_contact_id, jsonb_build_object( 'cascaded_to', v_counts, 'media_queued', coalesce(array_length(v_media_paths, 1), 0), 'request_id', p_request_id ), true ); return jsonb_build_object( 'already_anonymized', false, 'counts', v_counts, 'media_paths', v_media_paths ); end; $$; revoke all on function public.fn_lgpd_cascade_redact_contact(uuid,uuid,uuid) from public,anon,authenticated; grant execute on function public.fn_lgpd_cascade_redact_contact(uuid,uuid,uuid) to service_role; notify pgrst, 'reload schema'; -- ---- empresas, pessoas que decidem e importação de planilha (migration 0448, de @renatofortal, #1621) ---- -- -- Módulo opcional desligado por padrão (`MODULO_CRM_B2B`, doc 68). RLS por -- operação espelhando as rotas; racional no cabeçalho da migration 0448. create table if not exists public.companies ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, legal_name text, trade_name text, cnpj text, normalized_cnpj text, registration_status text, legal_nature text, company_size text, share_capital numeric, opened_at date, main_cnae_code text, main_cnae_description text, secondary_cnaes jsonb not null default '[]'::jsonb, street text, number text, complement text, district text, city text, state text, zip_code text, email text, phone text, enrichment_status text not null default 'pending' check (enrichment_status in ('pending', 'processing', 'completed', 'failed')), enriched_at timestamptz, enrichment_error text, brasilapi_raw jsonb, created_by uuid references auth.users(id) on delete set null, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), constraint companies_normalized_cnpj_digits check (normalized_cnpj is null or normalized_cnpj ~ '^\d{14}$') ); create index if not exists idx_companies_org_updated on public.companies (organization_id, updated_at desc); create index if not exists idx_companies_org_trade on public.companies (organization_id, trade_name); create unique index if not exists companies_org_normalized_cnpj_uidx on public.companies (organization_id, normalized_cnpj) where normalized_cnpj is not null; alter table public.companies enable row level security; drop policy if exists tenant_isolation_companies_all on public.companies; drop policy if exists companies_select on public.companies; create policy companies_select on public.companies for select using (organization_id in (select public.fn_user_org_ids())); drop policy if exists companies_insert on public.companies; create policy companies_insert on public.companies for insert with check (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'manager')); drop policy if exists companies_update on public.companies; create policy companies_update on public.companies for update using (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'agent')) with check (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'agent')); drop policy if exists companies_delete on public.companies; create policy companies_delete on public.companies for delete using (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'manager')); revoke all on table public.companies from anon; grant select, insert, update, delete on table public.companies to authenticated; grant all on table public.companies to service_role; drop trigger if exists trg_companies_set_updated_at on public.companies; create trigger trg_companies_set_updated_at before update on public.companies for each row execute function public.fn_set_updated_at(); -- --------------------------------------------------------------------------- -- 2. people -- --------------------------------------------------------------------------- create table if not exists public.people ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, full_name text not null, normalized_name text, email text, notes text, created_by uuid references auth.users(id) on delete set null, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), constraint people_full_name_nao_vazio check (length(btrim(full_name)) > 0) ); create index if not exists idx_people_org_name on public.people (organization_id, normalized_name); create index if not exists idx_people_org_updated on public.people (organization_id, updated_at desc); alter table public.people enable row level security; drop policy if exists tenant_isolation_people_all on public.people; drop policy if exists people_select on public.people; create policy people_select on public.people for select using (organization_id in (select public.fn_user_org_ids())); drop policy if exists people_insert on public.people; create policy people_insert on public.people for insert with check (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'manager')); drop policy if exists people_update on public.people; create policy people_update on public.people for update using (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'agent')) with check (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'agent')); drop policy if exists people_delete on public.people; create policy people_delete on public.people for delete using (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'manager')); revoke all on table public.people from anon; grant select, insert, update, delete on table public.people to authenticated; grant all on table public.people to service_role; drop trigger if exists trg_people_set_updated_at on public.people; create trigger trg_people_set_updated_at before update on public.people for each row execute function public.fn_set_updated_at(); -- --------------------------------------------------------------------------- -- 3. company_people -- --------------------------------------------------------------------------- create table if not exists public.company_people ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, company_id uuid not null references public.companies(id) on delete cascade, person_id uuid not null references public.people(id) on delete cascade, job_title text, department text, is_decision_maker boolean not null default false, is_primary boolean not null default false, notes text, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), constraint company_people_company_person_uidx unique (company_id, person_id) ); create index if not exists idx_company_people_org on public.company_people (organization_id); create index if not exists idx_company_people_person on public.company_people (organization_id, person_id); create index if not exists idx_company_people_company on public.company_people (organization_id, company_id); alter table public.company_people enable row level security; drop policy if exists tenant_isolation_company_people_all on public.company_people; drop policy if exists company_people_select on public.company_people; create policy company_people_select on public.company_people for select using (organization_id in (select public.fn_user_org_ids())); drop policy if exists company_people_insert on public.company_people; create policy company_people_insert on public.company_people for insert with check (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'manager')); drop policy if exists company_people_update on public.company_people; create policy company_people_update on public.company_people for update using (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'agent')) with check (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'agent')); drop policy if exists company_people_delete on public.company_people; create policy company_people_delete on public.company_people for delete using (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'manager')); revoke all on table public.company_people from anon; grant select, insert, update, delete on table public.company_people to authenticated; grant all on table public.company_people to service_role; drop trigger if exists trg_company_people_set_updated_at on public.company_people; create trigger trg_company_people_set_updated_at before update on public.company_people for each row execute function public.fn_set_updated_at(); -- Mesma organization entre vínculo, company e person (anti cross-tenant por FK). create or replace function public.fn_company_people_same_org() returns trigger language plpgsql security invoker set search_path = public as $$ declare v_company_org uuid; v_person_org uuid; begin select organization_id into v_company_org from public.companies where id = new.company_id; select organization_id into v_person_org from public.people where id = new.person_id; if v_company_org is null then raise exception 'company_people: company_id inexistente'; end if; if v_person_org is null then raise exception 'company_people: person_id inexistente'; end if; if new.organization_id is distinct from v_company_org or new.organization_id is distinct from v_person_org then raise exception 'company_people: organization_id deve coincidir com company e person'; end if; return new; end; $$; revoke execute on function public.fn_company_people_same_org() from public, anon; -- trigger functions are owned; no grant needed for callers drop trigger if exists trg_company_people_same_org on public.company_people; create trigger trg_company_people_same_org before insert or update on public.company_people for each row execute function public.fn_company_people_same_org(); -- --------------------------------------------------------------------------- -- 4. contacts.person_id (aditivo, nullable) -- --------------------------------------------------------------------------- alter table public.contacts add column if not exists person_id uuid references public.people(id) on delete set null; create index if not exists idx_contacts_org_person on public.contacts (organization_id, person_id) where person_id is not null; create or replace function public.fn_contacts_person_same_org() returns trigger language plpgsql security invoker set search_path = public as $$ declare v_person_org uuid; begin if new.person_id is null then return new; end if; select organization_id into v_person_org from public.people where id = new.person_id; if v_person_org is null then raise exception 'contacts.person_id: pessoa inexistente'; end if; if new.organization_id is distinct from v_person_org then raise exception 'contacts.person_id: organization_id deve coincidir com a pessoa'; end if; return new; end; $$; revoke execute on function public.fn_contacts_person_same_org() from public, anon; drop trigger if exists trg_contacts_person_same_org on public.contacts; create trigger trg_contacts_person_same_org before insert or update of person_id, organization_id on public.contacts for each row execute function public.fn_contacts_person_same_org(); -- --------------------------------------------------------------------------- -- 5. import_batches / import_rows -- --------------------------------------------------------------------------- create table if not exists public.import_batches ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, kind text not null default 'companies_people' check (kind in ('companies_people', 'contacts')), filename text not null, status text not null default 'pending' check (status in ('pending', 'processing', 'completed', 'failed')), total_rows integer not null default 0, processed_rows integer not null default 0, successful_rows integer not null default 0, failed_rows integer not null default 0, conflict_rows integer not null default 0, column_mapping jsonb not null default '{}'::jsonb, created_by uuid references auth.users(id) on delete set null, created_at timestamptz not null default now(), completed_at timestamptz, updated_at timestamptz not null default now() ); create index if not exists idx_import_batches_org_created on public.import_batches (organization_id, created_at desc); alter table public.import_batches enable row level security; drop policy if exists tenant_isolation_import_batches_all on public.import_batches; drop policy if exists import_batches_select on public.import_batches; create policy import_batches_select on public.import_batches for select using (organization_id in (select public.fn_user_org_ids())); drop policy if exists import_batches_insert on public.import_batches; create policy import_batches_insert on public.import_batches for insert with check (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'manager')); drop policy if exists import_batches_update on public.import_batches; create policy import_batches_update on public.import_batches for update using (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'manager')) with check (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'manager')); drop policy if exists import_batches_delete on public.import_batches; create policy import_batches_delete on public.import_batches for delete using (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'manager')); revoke all on table public.import_batches from anon; grant select, insert, update, delete on table public.import_batches to authenticated; grant all on table public.import_batches to service_role; drop trigger if exists trg_import_batches_set_updated_at on public.import_batches; create trigger trg_import_batches_set_updated_at before update on public.import_batches for each row execute function public.fn_set_updated_at(); create table if not exists public.import_rows ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, batch_id uuid not null references public.import_batches(id) on delete cascade, row_number integer not null, raw_data jsonb not null default '{}'::jsonb, normalized_data jsonb not null default '{}'::jsonb, status text not null default 'pending' check (status in ('pending', 'processing', 'success', 'conflict', 'failed')), error text, company_id uuid references public.companies(id) on delete set null, person_id uuid references public.people(id) on delete set null, contact_id uuid references public.contacts(id) on delete set null, created_at timestamptz not null default now(), constraint import_rows_batch_row_uidx unique (batch_id, row_number) ); create index if not exists idx_import_rows_batch_status on public.import_rows (batch_id, status); create index if not exists idx_import_rows_org on public.import_rows (organization_id); alter table public.import_rows enable row level security; drop policy if exists tenant_isolation_import_rows_all on public.import_rows; drop policy if exists import_rows_select on public.import_rows; create policy import_rows_select on public.import_rows for select using (organization_id in (select public.fn_user_org_ids())); drop policy if exists import_rows_insert on public.import_rows; create policy import_rows_insert on public.import_rows for insert with check (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'manager')); drop policy if exists import_rows_update on public.import_rows; create policy import_rows_update on public.import_rows for update using (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'manager')) with check (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'manager')); drop policy if exists import_rows_delete on public.import_rows; create policy import_rows_delete on public.import_rows for delete using (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'manager')); revoke all on table public.import_rows from anon; grant select, insert, update, delete on table public.import_rows to authenticated; grant all on table public.import_rows to service_role; create or replace function public.fn_import_rows_same_org() returns trigger language plpgsql security invoker set search_path = public as $$ declare v_batch_org uuid; begin select organization_id into v_batch_org from public.import_batches where id = new.batch_id; if v_batch_org is null then raise exception 'import_rows: batch_id inexistente'; end if; if new.organization_id is distinct from v_batch_org then raise exception 'import_rows: organization_id deve coincidir com o batch'; end if; return new; end; $$; revoke execute on function public.fn_import_rows_same_org() from public, anon; drop trigger if exists trg_import_rows_same_org on public.import_rows; create trigger trg_import_rows_same_org before insert or update on public.import_rows for each row execute function public.fn_import_rows_same_org(); -- ---- a anonimização (LGPD) alcança a pessoa e a linha de planilha (migration 0449) ---- -- -- Gatilho na virada de `is_anonymized`, molde de trg_redigir_tarefas_ao_anonimizar. -- Racional (e por que não um passo na cascata) no cabeçalho da migration 0449. create or replace function public.fn_redigir_b2b_do_contato_anonimizado() returns trigger language plpgsql security definer set search_path = public, pg_temp as $$ begin update public.import_rows set raw_data = '{}'::jsonb, normalized_data = '{}'::jsonb, error = null where organization_id = new.organization_id and (contact_id = new.id or (new.person_id is not null and person_id = new.person_id)); if new.person_id is not null then update public.people set full_name = 'Pessoa anonimizada #' || substring(new.person_id::text from 1 for 8), normalized_name = null, email = null, notes = null where organization_id = new.organization_id and id = new.person_id; update public.company_people set job_title = null, department = null, notes = null where organization_id = new.organization_id and person_id = new.person_id; end if; return new; end; $$; -- Função de gatilho não exige EXECUTE de quem dispara o UPDATE; revogar das -- três origens a mantém fora da lista de exceções do invariante de hardening. revoke execute on function public.fn_redigir_b2b_do_contato_anonimizado() from public, anon, authenticated; grant execute on function public.fn_redigir_b2b_do_contato_anonimizado() to service_role; drop trigger if exists trg_redigir_b2b_ao_anonimizar on public.contacts; create trigger trg_redigir_b2b_ao_anonimizar after update of is_anonymized on public.contacts for each row when (new.is_anonymized is true and old.is_anonymized is distinct from true) execute function public.fn_redigir_b2b_do_contato_anonimizado(); -- ---- grupos de WhatsApp na inbox (migration 0482) ---- -- Grupos de WhatsApp na inbox: histórico e resposta manual, IA nunca responde. -- Spec: docs/superpowers/specs/2026-09-23-grupos-na-inbox-design.md -- 1. O contato que representa um grupo. Todo contato existente vira 'person' pelo default. alter table public.contacts add column if not exists kind text not null default 'person'; do $$ begin if not exists (select 1 from pg_constraint where conname = 'contacts_kind_check') then alter table public.contacts add constraint contacts_kind_check check (kind in ('person','whatsapp_group')); end if; end $$; create index if not exists idx_contacts_org_kind on public.contacts (organization_id, kind) where kind <> 'person'; -- 1b. Um contato de grupo por organização + grupo (dedup: duas ingestões concorrentes -- do mesmo grupo não podem criar dois placeholders para a mesma conversa). A ficha -- mesclada (`is_merged_into is not null`) sai da disputa, como os demais índices de -- identidade de `contacts` — senão o grupo perdedor de um merge segura o -- `group_chat_id` para sempre e a ingestão nunca cria (nem reencontra) o vencedor. -- Só derruba o índice quando ele está na definição ANTIGA (sem a guarda de -- merge) — um banco de dev pode tê-la. Na definição certa não há rebuild: sem -- este `if`, todo `update.sh` reconstruía o índice com trava de escrita e -- varredura inteira de `contacts`. do $$ begin if exists (select 1 from pg_indexes where schemaname = 'public' and indexname = 'uq_contacts_grupo' and indexdef not like '%is_merged_into IS NULL%') then drop index public.uq_contacts_grupo; end if; end $$; create unique index if not exists uq_contacts_grupo on public.contacts (organization_id, (source_metadata->>'group_chat_id')) where kind = 'whatsapp_group' and is_merged_into is null; -- 2. Os grupos de cada número, com a chave liga/desliga. create table if not exists public.channel_session_groups ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, channel_session_id uuid not null references public.channel_sessions(id) on delete cascade, group_chat_id text not null check (group_chat_id like '%@g.us'), subject text, enabled boolean not null default false, enabled_at timestamptz, enabled_by_user_id uuid references auth.users(id) on delete set null, contact_id uuid references public.contacts(id) on delete set null, conversation_id uuid references public.conversations(id) on delete set null, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique (organization_id, channel_session_id, group_chat_id) ); alter table public.channel_session_groups enable row level security; -- Só o service role ESCREVE (decidido na revisão final): o único escritor -- legítimo é a API (`lib/grupos/servico.ts`), que confirma o filtro do WhatsApp -- antes de gravar e audita. Uma policy de escrita para gerente deixava o -- PostgREST ligar grupo sem filtro e sem auditoria, ou apontar -- `conversation_id` para uma conversa 1:1 (e a mensagem do grupo emitiria -- `message.received`, acordando IA e automações). Membro da org só LÊ. -- O `revoke` explícito é o que protege no Supabase real: o default ACL de -- tabelas em `public` concede tudo a anon/authenticated (ver CLAUDE.md, 0258). drop policy if exists tenant_isolation_channel_session_groups_all on public.channel_session_groups; drop policy if exists channel_session_groups_select on public.channel_session_groups; drop policy if exists channel_session_groups_write on public.channel_session_groups; create policy channel_session_groups_select on public.channel_session_groups for select using (organization_id in (select public.fn_user_org_ids())); revoke insert, update, delete, truncate on public.channel_session_groups from anon, authenticated; drop trigger if exists trg_channel_session_groups_updated_at on public.channel_session_groups; create trigger trg_channel_session_groups_updated_at before update on public.channel_session_groups for each row execute function public.fn_set_updated_at(); -- 3. Roteamento automático não atribui grupo (quebraria a visibilidade por "sem dono"). create or replace function public.fn_request_channel_routing(p_org uuid,p_conversation uuid) returns void language plpgsql security definer set search_path=public as $$ declare c public.conversations; begin select * into c from public.conversations where organization_id=p_org and id=p_conversation; if not found or c.assigned_to_user_id is not null or c.status not in('open','pending','claimed','ai_handling') then return;end if; if c.is_group then return; end if; -- grupos: nunca roteados (migration 0482) insert into public.event_log(organization_id,event_type,entity_kind,entity_id,payload) values(p_org,'conversation.routing_requested','conversation',c.id, jsonb_build_object('organization_id',p_org,'conversation_id',c.id,'channel_session_id',c.channel_session_id)) on conflict(organization_id,entity_id) where event_type='conversation.routing_requested' and status in('pending','processing') do update set next_attempt_at=case when event_log.status='pending' then now() else event_log.next_attempt_at end; end; $$; revoke all on function public.fn_request_channel_routing(uuid,uuid) from public,anon,authenticated; grant execute on function public.fn_request_channel_routing(uuid,uuid) to service_role; -- 4. Mensagem recebida em grupo emite message.group_received: nenhum consumidor de -- message.received (IA, follow-up, campanhas, automações, webhooks, sentimento) a vê. create or replace function public.fn_emit_message_event() returns trigger language plpgsql set search_path to 'public', 'pg_temp' as $$ declare v_event text; begin if new.direction = 'inbound' then if exists (select 1 from public.conversations c where c.id = new.conversation_id and c.organization_id = new.organization_id and c.is_group) then v_event := 'message.group_received'; else v_event := 'message.received'; end if; else v_event := case new.status when 'sending' then 'message.sending' when 'sent' then 'message.sent' when 'failed' then 'message.failed' else 'message.outbound' end; end if; perform public.fn_log_event( new.organization_id, v_event, jsonb_build_object( 'message_id', new.id, 'conversation_id', new.conversation_id, 'contact_id', new.contact_id, 'direction', new.direction, 'type', new.type, 'status', new.status, 'external_id', new.external_id, 'channel_session_id', new.channel_session_id, 'body_preview', "left"(new.body, 280) ) ); return new; end$$; grant all on function public.fn_emit_message_event() to anon; grant all on function public.fn_emit_message_event() to authenticated; grant all on function public.fn_emit_message_event() to service_role; -- 5. Cascata de anonimização LGPD alcança channel_session_groups.subject. -- O corpo é o da 0477 (redact alcança crm_proposals) INTEIRO, mais um passo: -- o de channel_session_groups, antes da linha de auditoria. Esta migration vem -- DEPOIS da 0477 na cadeia de propósito: `create or replace` reescreve o corpo -- inteiro, e partir de um corpo anterior tiraria as propostas (o PDF no bucket -- `propostas` e as colunas redigidas) da anonimização em quem aplica a cadeia. create or replace function public.fn_lgpd_cascade_redact_contact(p_organization_id uuid, p_contact_id uuid, p_request_id uuid) returns jsonb language plpgsql security definer set search_path to 'public', 'extensions', 'pg_temp' as $$ declare v_already bool; v_counts jsonb := '{}'::jsonb; v_media_paths text[] := '{}'; v_anon_label text; v_count int; v_variantes text[] := '{}'; begin perform public.fn_service_lock(p_organization_id,p_contact_id); select is_anonymized into v_already from contacts where id = p_contact_id and organization_id = p_organization_id; if not found then raise exception 'contact not found' using errcode = 'P0002'; end if; if v_already then return jsonb_build_object('already_anonymized', true, 'counts', v_counts, 'media_paths', v_media_paths); end if; v_anon_label := 'Cliente Anonimizado #' || substring(p_contact_id::text from 1 for 8); select coalesce(public.fn_telefone_variantes(phone_number), '{}') into v_variantes from contacts where id = p_contact_id and organization_id = p_organization_id; select coalesce(array_agg(distinct media_storage_path) filter (where media_storage_path is not null), '{}') into v_media_paths from messages where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ); -- 1. contacts (irreversible) update contacts set name = v_anon_label, display_name = v_anon_label, email = null, phone_number = null, cpf_encrypted = null, cpf_hash = null, birthdate = null, is_anonymized = true, anonymized_at = now(), consent = '{}'::jsonb, source_metadata = '{}'::jsonb, tags = '{}'::text[], updated_at = now() where id = p_contact_id and organization_id = p_organization_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('contacts', v_count); -- 2. conversations metadata + preview strip update conversations set metadata = '{}'::jsonb, last_message_preview = null, last_handoff_reason = null, updated_at = now() where contact_id = p_contact_id and organization_id = p_organization_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('conversations', v_count); -- 3. messages: redact body + null media + strip metadata (preserve status/timestamps/conversation_id) update messages set body = '[mensagem anonimizada]', media_url = null, media_mime = null, media_size_bytes = null, media_storage_path = null, metadata = '{}'::jsonb, updated_at = now() where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('messages', v_count); -- 4. crm_lead_activities — strip payload, metadata E reason (migration 0071). update crm_lead_activities set payload = '{}'::jsonb, metadata = '{}'::jsonb, reason = null where organization_id = p_organization_id and ( contact_id = p_contact_id or lead_id in ( select lead_id from crm_lead_links where target_kind = 'contact' and target_id = p_contact_id and organization_id = p_organization_id ) or lead_id in ( select id from crm_leads where contact_id = p_contact_id and organization_id = p_organization_id ) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('activities', v_count); -- 5. crm_leads — strip title/description/custom_fields/source_metadata/tags but PRESERVE pipeline/stage/value update crm_leads set title = v_anon_label, description = null, custom_fields = '{}'::jsonb, source_metadata = '{}'::jsonb, tags = '{}'::text[], updated_at = now() where organization_id = p_organization_id and ( contact_id = p_contact_id or id in ( select lead_id from crm_lead_links where target_kind = 'contact' and target_id = p_contact_id and organization_id = p_organization_id ) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('leads', v_count); -- 6. orders — PRESERVE values + status + timestamps. Strip personal fields from payload jsonb -- and replace customer_external_id with null (FK-safe; soft de-link). Keep contact_id null. update orders set payload = (coalesce(payload, '{}'::jsonb)) - 'customer' - 'customer_name' - 'customer_email' - 'customer_phone' - 'shipping_address' - 'billing_address' - 'contact_identification', customer_external_id = null, contact_id = null, is_anonymized = true, updated_at = now() where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('orders', v_count); -- 6b. crm_proposals (migration 0477, #1504) — PRESERVA número, valores, -- itens, datas e status; redige só o que identifica a PESSOA. Ver o -- cabeçalho desta migration para o porquê de cada coluna. -- O PDF que o cliente recebeu (bucket `propostas`, `/.pdf`) -- tem o nome dele impresso: redigir as colunas e deixar o arquivo seria -- anonimizar a linha e manter o documento. Vai para a mesma fila de expurgo -- da mídia (passo 7), com o bucket CERTO — a mensagem que levou o PDF -- aponta para o mesmo caminho, mas o passo 7 só enfileira `whatsapp-media`. -- Lido ANTES de o passo seguinte zerar `pdf_path`. insert into storage_redaction_queue (organization_id, request_id, bucket, object_path) select p_organization_id, p_request_id, 'propostas', pdf_path from crm_proposals where organization_id = p_organization_id and contact_id = p_contact_id and pdf_path is not null and length(pdf_path) > 0 -- só arquivo DESTA organização: o expurgo nunca alcança o PDF de outra and pdf_path like p_organization_id::text || '/%' on conflict (bucket, object_path) do nothing; update crm_proposals set destinatario_nome = v_anon_label, briefing_json = '{}'::jsonb, resumo_comercial = null, -- o texto do documento como foi montado e como foi editado à mão: é o -- conteúdo do PDF, com o mesmo nome dentro. rendered_snapshot = null, secoes_editadas = null, pdf_path = null, updated_at = now() where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('crm_proposals', v_count); -- CAMPANHAS: o que foi DITO à pessoa e o endereço para onde foi. update campaign_recipients set rendered_body = null, recipient_address = null, variables = '{}'::jsonb, last_error_detail = null, updated_at = now() where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('campaign_recipients', v_count); -- LISTA DE EXCLUSÃO: solta o vínculo e apaga a cauda do telefone. update campaign_suppressions set address_tail = null, reason = null, contact_id = null where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('campaign_suppressions', v_count); -- 6c. sales — a comanda. PRESERVA valor, status e datas, e NÃO desliga o -- contato (ver racional completo no baseline, bloco desta função). update sales set notes = null, cancel_reason = case when cancel_reason is null then null else '[redigido]' end, reverse_reason = case when reverse_reason is null then null else '[redigido]' end, updated_at = now() where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('sales', v_count); -- 7. enqueue media for async deletion (idempotent via unique (bucket, object_path)) if array_length(v_media_paths, 1) > 0 then insert into storage_redaction_queue (organization_id, request_id, bucket, object_path) select p_organization_id, p_request_id, 'whatsapp-media', path from unnest(v_media_paths) as path where path is not null and length(path) > 0 on conflict (bucket, object_path) do nothing; end if; -- 7b. voice_calls — o TELEFONE de quem falou ao telefone (migration 0235). update voice_calls set peer_phone = v_anon_label, owner_user_id = null, created_by = null, updated_at = now() where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('voice_calls', v_count); update prospecting_candidates set suppression_salt = gen_random_bytes(32) where organization_id = p_organization_id and (contact_id = p_contact_id or (phone is not null and regexp_replace(phone, '\D', '', 'g') = any (v_variantes))) and suppression_salt is null; update prospecting_candidates set suppression_place = hmac(convert_to(place_id, 'UTF8'), suppression_salt, 'sha256'), suppression_phone = case when phone is null then null else hmac(convert_to(phone, 'UTF8'), suppression_salt, 'sha256') end, place_id = 'redacted:' || id::text, phone = null, data = jsonb_build_object('key', 'redacted:' || id::text, 'name', v_anon_label, 'phone', null, 'website', null, 'category', null, 'address', null, 'maps_url', null, 'rating', null, 'reviews', null, 'emails', '[]'::jsonb, 'socials', '[]'::jsonb), status = 'skipped', service_boundary = null, error = null, updated_at = now() where organization_id = p_organization_id and (contact_id = p_contact_id or (phone is not null and regexp_replace(phone, '\D', '', 'g') = any (v_variantes))); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('prospecting_candidates', v_count); -- agent_cases — o que a IA escreveu SOBRE a pessoa quando travou (migration 0280). update agent_cases set title = v_anon_label, summary = '[resumo anonimizado]', blocker = '[bloqueio anonimizado]', context_snapshot = '{}'::jsonb where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('agent_cases', v_count); -- agent_case_events — a linha do tempo do caso (migration 0280). update agent_case_events set body = null, metadata = '{}'::jsonb where organization_id = p_organization_id and case_id in ( select id from agent_cases where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('agent_case_events', v_count); -- demandas — o assunto do pedido (migration 0280). update demandas set assunto = null where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('demandas', v_count); -- agent_inbox_items — o aviso que leva o texto do caso para a Central (migration 0280/0292). update agent_inbox_items set status = 'resolved', resolved_at = now(), body = 'Contato anonimizado.', ref_id = null where organization_id = p_organization_id and kind in ('handoff', 'case_stale', 'aviso_de_caso_nao_entregue') and ( (ref_kind = 'contact' and ref_id = p_contact_id) or (ref_kind = 'conversation' and ref_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id )) or (ref_kind = 'agent_case' and ref_id in ( select id from agent_cases where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ) )) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('agent_inbox_items', v_count); -- agent_case_chat_messages — a consulta interna da equipe à IA SOBRE o caso (migration 0281). update agent_case_chat_messages set body = null, redacted_at = now() where organization_id = p_organization_id and contact_id = p_contact_id and redacted_at is null; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('agent_case_chat_messages', v_count); -- passagens_de_atendimento — o BRIEFING é sobre a pessoa (migration 0291). update passagens_de_atendimento set body = v_anon_label, title = null, notes = null, content = null, tentativas = '[]'::jsonb where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('passagens_de_atendimento', v_count); -- entregas_de_aviso_de_caso — o registro do aviso ao suporte (migration 0292). update entregas_de_aviso_de_caso set erro_detalhe = null where organization_id = p_organization_id and case_id in ( select id from agent_cases where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('entregas_de_aviso_de_caso', v_count); -- channel_session_groups.subject — o NOME do grupo, e a FK contact_id aponta -- para o placeholder do grupo (contacts.kind = 'whatsapp_group'), nunca para -- o titular real sendo anonimizado neste caminho — mas a FK para contacts e o -- nome da coluna casam o padrão automático do escopo (migration 0482), e -- nulificar não perde nada operacional: número, conversa e liga/desliga ficam. update public.channel_session_groups set subject = null where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('channel_session_groups', v_count); -- 8. dense audit row insert into api_audit_log (organization_id, action, actor_user_id, resource_type, resource_id, metadata, bypassed_rls) values ( p_organization_id, 'lgpd.redact_executed', null, 'contact', p_contact_id, jsonb_build_object( 'cascaded_to', v_counts, 'media_queued', coalesce(array_length(v_media_paths, 1), 0), 'request_id', p_request_id ), true ); return jsonb_build_object( 'already_anonymized', false, 'counts', v_counts, 'media_paths', v_media_paths ); end; $$; revoke all on function public.fn_lgpd_cascade_redact_contact(uuid,uuid,uuid) from public, anon, authenticated; grant execute on function public.fn_lgpd_cascade_redact_contact(uuid,uuid,uuid) to service_role; -- 6. Grupo sem dono é conversa HUMANA esperando alguém ('aguardando'), nunca -- 'automatico': o automático nunca atende grupo (o banco nem emite -- message.received para ele). Sem isto o grupo aparecia como "Automático -- atendendo" e morava na aba Automático, fora da fila humana. -- `p_is_group` entra como SÉTIMO parâmetro, com default: a assinatura de seis é -- removida antes (duas sobrecargas com default tornariam a chamada de seis -- ambígua). Funções `language sql` não registram dependência, então o drop não -- arrasta `comando_da_conversa(c)`, que é recriada logo abaixo passando -- `c.is_group`. Espelho TS: `comandoDaConversa()` em -- lib/inbox/comando-da-conversa.ts, casados por -- tests/invariants/comando-da-conversa-espelha-o-ts.test.ts. drop function if exists public.fn_comando_da_conversa(text, uuid, timestamptz, boolean, boolean, timestamptz); create or replace function public.fn_comando_da_conversa( p_status text, p_assigned_to_user_id uuid, p_bot_silenced_until timestamptz, p_force_human boolean, p_is_blocked boolean, p_agora timestamptz, p_is_group boolean default false ) returns text language sql immutable set search_path = public as $fn_comando$ select case -- A ordem é a mesma de `comandoDaConversa`, e ela é o contrato: dono primeiro -- (a aba "Fechadas" precisa continuar dizendo QUEM atendeu), encerrada depois, -- e só então as travas — grupo entre elas. when p_assigned_to_user_id is not null then 'humano' when p_status in ('closed', 'archived', 'resolved') then 'encerrada' when p_is_group is true or p_force_human is true or p_is_blocked is true or (p_bot_silenced_until is not null and p_bot_silenced_until > p_agora) then 'aguardando' else 'automatico' end; $fn_comando$; comment on function public.fn_comando_da_conversa(text, uuid, timestamptz, boolean, boolean, timestamptz, boolean) is 'Quem manda na conversa. Espelho SQL de comandoDaConversa() (lib/inbox/comando-da-conversa.ts); as duas são casadas por tests/invariants/comando-da-conversa-espelha-o-ts.test.ts. Grupo sem dono é aguardando (migration 0482).'; -- `comando_da_conversa` segue a forma da 0404 (issue #1571, upstream): -- SECURITY DEFINER para a contagem das abas não reavaliar a RLS de `contacts` -- por conversa, parâmetro SEM NOME para a PostgREST não publicá-la em `/rpc`, e -- as subconsultas presas a `ct.organization_id = $1.organization_id`. O que esta -- migration acrescenta é só o sétimo argumento, `$1.is_group`. Este bloco vem -- DEPOIS do da 0404 de propósito: é a última definição que vale, e ela tem de -- carregar as duas decisões. DROP sem `cascade`, como na 0404 (nada depende -- dela); o DROP leva a ACL, então as duas origens de EXECUTE voltam explícitas. drop function if exists public.comando_da_conversa(public.conversations); create function public.comando_da_conversa(public.conversations) returns text language sql stable security definer set search_path = public as $comando$ select public.fn_comando_da_conversa( $1.status, $1.assigned_to_user_id, $1.bot_silenced_until, coalesce((select ct.force_human from public.contacts ct where ct.id = $1.contact_id and ct.organization_id = $1.organization_id), false), coalesce((select ct.is_blocked from public.contacts ct where ct.id = $1.contact_id and ct.organization_id = $1.organization_id), false), now(), coalesce($1.is_group, false) ); $comando$; comment on function public.comando_da_conversa(public.conversations) is 'Campo calculado exposto pelo PostgREST: ?select=comando_da_conversa e ?comando_da_conversa=in.(...). Resolve o contato e carimba now(); a regra em si é fn_comando_da_conversa. SECURITY DEFINER desde a 0404 (issue #1571: a contagem das abas reavaliava a RLS de contacts 2x por conversa); parâmetro SEM NOME de propósito — com nome a PostgREST a exporia em /rpc, e ali uma linha fabricada leria force_human/is_blocked de outro tenant. Passa is_group desde a 0482 (grupos de WhatsApp na inbox).'; revoke execute on function public.fn_comando_da_conversa(text, uuid, timestamptz, boolean, boolean, timestamptz, boolean) from public, anon; revoke execute on function public.comando_da_conversa(public.conversations) from public, anon; grant execute on function public.fn_comando_da_conversa(text, uuid, timestamptz, boolean, boolean, timestamptz, boolean) to authenticated, service_role; grant execute on function public.comando_da_conversa(public.conversations) to authenticated, service_role; -- 7. LGPD alcança as mensagens de GRUPO escritas por quem JÁ É contato do CRM. -- -- A mensagem de grupo mora na conversa do contato PLACEHOLDER do grupo, não na -- do titular: o autor só existe em `messages.metadata.group_sender` -- ({name, phone, lid}). Sem este passo, anonimizar alguém deixava tudo o que -- ele escreveu nos grupos ligados — corpo, mídia e o próprio rótulo com nome e -- telefone — intacto. -- -- O passo mora no GATILHO da virada de `is_anonymized` (migration 0391), e não -- em `fn_lgpd_cascade_redact_contact`, pela mesma razão que trouxe o gatilho: -- os DOIS caminhos (o pedido formal e o botão da ficha) passam por ele. O -- casamento é pelo telefone (`fn_telefone_variantes`, com e sem o nono dígito) -- OU pelo lid (`contacts.wa_lid`), lidos de OLD: os dois caminhos zeram -- `phone_number` no MESMO update que vira `is_anonymized`, e a cascata formal -- zera também `source_metadata`, de onde o lid é GERADO. Ler de NEW não -- alcançaria linha nenhuma — e pareceria feito. -- -- ⚠️ Só alcança quem JÁ É contato do CRM. O participante de grupo que nunca -- virou contato não tem ficha, não tem pedido LGPD e não tem caminho por aqui: -- achá-lo exigiria buscar por telefone/lid solto, fora de um titular — mudança -- de desenho, não esquecimento. Ver a spec, "LGPD — mensagens de grupo". -- -- Sem cura retroativa, de propósito: mensagem de grupo só existe a partir desta -- migration, e o gatilho nasce junto com ela. -- -- ponytail: varredura sem índice sobre as mensagens de grupo da org; um índice -- de expressão em (metadata->'group_sender'->>'phone') resolve se anonimizar -- ficar lento em org com muito grupo. create or replace function public.fn_redigir_conversas_ao_anonimizar() returns trigger language plpgsql security definer set search_path = public, pg_temp as $$ declare v_variantes text[]; v_lid text; v_msgs_de_grupo uuid[]; begin insert into public.storage_redaction_queue (organization_id, bucket, object_path) select distinct new.organization_id, 'whatsapp-media', m.media_storage_path from public.messages m where m.organization_id = new.organization_id and m.conversation_id in ( select c.id from public.conversations c where c.contact_id = new.id and c.organization_id = new.organization_id) and m.media_storage_path is not null and length(m.media_storage_path) > 0 on conflict (bucket, object_path) do nothing; update public.messages set body = '[mensagem anonimizada]', media_url = null, media_mime = null, media_size_bytes = null, media_storage_path = null, metadata = '{}'::jsonb, updated_at = now() where organization_id = new.organization_id and conversation_id in ( select c.id from public.conversations c where c.contact_id = new.id and c.organization_id = new.organization_id); update public.conversations set metadata = '{}'::jsonb, last_message_preview = null, last_handoff_reason = null, updated_at = now() where contact_id = new.id and organization_id = new.organization_id; update public.lead_checkpoints set rolling_summary = '[resumo anonimizado]', commitments = '[]'::jsonb, objections = '[]'::jsonb, next_action = null, declaracao = null where contact_id = new.id and organization_id = new.organization_id; -- Mensagens de grupo escritas pelo titular (ver o cabeçalho deste bloco). v_variantes := coalesce(public.fn_telefone_variantes(coalesce(old.phone_number, new.phone_number)), '{}'); v_lid := coalesce(old.wa_lid, new.wa_lid); select coalesce(array_agg(m.id), '{}') into v_msgs_de_grupo from public.messages m where m.organization_id = new.organization_id and m.metadata ? 'group_sender' and ( regexp_replace(coalesce(m.metadata->'group_sender'->>'phone', ''), '\D', '', 'g') = any(v_variantes) or (v_lid is not null and m.metadata->'group_sender'->>'lid' = v_lid) ); if cardinality(v_msgs_de_grupo) > 0 then -- Mídia ANTES de zerar a coluna, pelo mesmo motivo do começo da função. insert into public.storage_redaction_queue (organization_id, bucket, object_path) select distinct new.organization_id, 'whatsapp-media', m.media_storage_path from public.messages m where m.organization_id = new.organization_id and m.id = any(v_msgs_de_grupo) and m.media_storage_path is not null and length(m.media_storage_path) > 0 on conflict (bucket, object_path) do nothing; -- A prévia da conversa do GRUPO pode ser o texto do titular: sai junto. As -- mensagens dos outros participantes ficam; a próxima que chegar a repõe. update public.conversations set last_message_preview = null, updated_at = now() where organization_id = new.organization_id and id in (select m.conversation_id from public.messages m where m.organization_id = new.organization_id and m.id = any(v_msgs_de_grupo)); update public.messages set body = '[mensagem anonimizada]', media_url = null, media_mime = null, media_size_bytes = null, media_storage_path = null, metadata = '{}'::jsonb, updated_at = now() where organization_id = new.organization_id and id = any(v_msgs_de_grupo); end if; return new; end $$; -- As DUAS origens de EXECUTE (item 9 do CLAUDE.md), repetidas: `create or -- replace` preserva a ACL, mas quem lê este bloco não precisa confiar nisso. revoke all on function public.fn_redigir_conversas_ao_anonimizar() from public; revoke execute on function public.fn_redigir_conversas_ao_anonimizar() from anon; revoke execute on function public.fn_redigir_conversas_ao_anonimizar() from authenticated; notify pgrst, 'reload schema'; -- ---- fim grupos de WhatsApp na inbox (migration 0482) ---- -- ---- anexo na nota interna: mídia própria, bucket próprio e alcance da LGPD (migration 0483) ---- -- F3 da #1863. O corpo da cascata e o do expurgo são gerados do MESMO texto da -- migration 0483 (script), porque `apendice-do-baseline-nao-diverge-da-cadeia` -- compara os dois artefatos e quem instala pelo kit self-host aplica SÓ o -- baseline. O bloco do expurgo da 0435, acima, foi EDITADO NO LUGAR (mesmo -- desenho das 0417/0426/0434/0435): dois corpos da mesma função num arquivo só -- faria o segundo vencer sem que o primeiro fosse lido por ninguém. alter table public.conversation_notes add column if not exists media_storage_path text, add column if not exists media_mime text, add column if not exists media_size_bytes bigint; comment on column public.conversation_notes.media_storage_path is 'Caminho do anexo no bucket internal-media ({org}/{conversa}/note-...). Null = nota só com texto.'; comment on column public.conversation_notes.media_mime is 'MIME real do arquivo GRAVADO (o do upload validado), não o que o browser declarou.'; comment on column public.conversation_notes.media_size_bytes is 'Tamanho do arquivo gravado, em bytes — é o que o card mostra (formatBytes).'; insert into storage.buckets (id, name, public, file_size_limit) values ('internal-media', 'internal-media', false, 52428800) on conflict (id) do update set file_size_limit = excluded.file_size_limit; -- ---- bucket internal-media: sem policy, como o whatsapp-media da 0055 ---- -- Leitura e escrita são do service_role (rota de upload + signed URL da rota -- de nota); sem policy, `authenticated` não alcança o Storage API direto. create or replace function public.fn_lgpd_cascade_redact_contact(p_organization_id uuid, p_contact_id uuid, p_request_id uuid) returns jsonb language plpgsql security definer set search_path to 'public', 'extensions', 'pg_temp' as $$ declare v_already bool; v_counts jsonb := '{}'::jsonb; v_media_paths text[] := '{}'; v_anon_label text; v_count int; v_variantes text[] := '{}'; begin perform public.fn_service_lock(p_organization_id,p_contact_id); select is_anonymized into v_already from contacts where id = p_contact_id and organization_id = p_organization_id; if not found then raise exception 'contact not found' using errcode = 'P0002'; end if; if v_already then return jsonb_build_object('already_anonymized', true, 'counts', v_counts, 'media_paths', v_media_paths); end if; v_anon_label := 'Cliente Anonimizado #' || substring(p_contact_id::text from 1 for 8); select coalesce(public.fn_telefone_variantes(phone_number), '{}') into v_variantes from contacts where id = p_contact_id and organization_id = p_organization_id; select coalesce(array_agg(distinct media_storage_path) filter (where media_storage_path is not null), '{}') into v_media_paths from messages where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ); -- 1. contacts (irreversible) update contacts set name = v_anon_label, display_name = v_anon_label, email = null, phone_number = null, cpf_encrypted = null, cpf_hash = null, birthdate = null, is_anonymized = true, anonymized_at = now(), consent = '{}'::jsonb, source_metadata = '{}'::jsonb, tags = '{}'::text[], updated_at = now() where id = p_contact_id and organization_id = p_organization_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('contacts', v_count); -- 2. conversations metadata + preview strip update conversations set metadata = '{}'::jsonb, last_message_preview = null, last_handoff_reason = null, updated_at = now() where contact_id = p_contact_id and organization_id = p_organization_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('conversations', v_count); -- 3. messages: redact body + null media + strip metadata (preserve status/timestamps/conversation_id) update messages set body = '[mensagem anonimizada]', media_url = null, media_mime = null, media_size_bytes = null, media_storage_path = null, metadata = '{}'::jsonb, updated_at = now() where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('messages', v_count); -- 4. crm_lead_activities — strip payload, metadata E reason (migration 0071). update crm_lead_activities set payload = '{}'::jsonb, metadata = '{}'::jsonb, reason = null where organization_id = p_organization_id and ( contact_id = p_contact_id or lead_id in ( select lead_id from crm_lead_links where target_kind = 'contact' and target_id = p_contact_id and organization_id = p_organization_id ) or lead_id in ( select id from crm_leads where contact_id = p_contact_id and organization_id = p_organization_id ) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('activities', v_count); -- 5. crm_leads — strip title/description/custom_fields/source_metadata/tags but PRESERVE pipeline/stage/value update crm_leads set title = v_anon_label, description = null, custom_fields = '{}'::jsonb, source_metadata = '{}'::jsonb, tags = '{}'::text[], updated_at = now() where organization_id = p_organization_id and ( contact_id = p_contact_id or id in ( select lead_id from crm_lead_links where target_kind = 'contact' and target_id = p_contact_id and organization_id = p_organization_id ) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('leads', v_count); -- 6. orders — PRESERVE values + status + timestamps. Strip personal fields from payload jsonb -- and replace customer_external_id with null (FK-safe; soft de-link). Keep contact_id null. update orders set payload = (coalesce(payload, '{}'::jsonb)) - 'customer' - 'customer_name' - 'customer_email' - 'customer_phone' - 'shipping_address' - 'billing_address' - 'contact_identification', customer_external_id = null, contact_id = null, is_anonymized = true, updated_at = now() where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('orders', v_count); -- 6b. crm_proposals (migration 0477, #1504) — PRESERVA número, valores, -- itens, datas e status; redige só o que identifica a PESSOA. Ver o -- cabeçalho desta migration para o porquê de cada coluna. -- O PDF que o cliente recebeu (bucket `propostas`, `/.pdf`) -- tem o nome dele impresso: redigir as colunas e deixar o arquivo seria -- anonimizar a linha e manter o documento. Vai para a mesma fila de expurgo -- da mídia (passo 7), com o bucket CERTO — a mensagem que levou o PDF -- aponta para o mesmo caminho, mas o passo 7 só enfileira `whatsapp-media`. -- Lido ANTES de o passo seguinte zerar `pdf_path`. insert into storage_redaction_queue (organization_id, request_id, bucket, object_path) select p_organization_id, p_request_id, 'propostas', pdf_path from crm_proposals where organization_id = p_organization_id and contact_id = p_contact_id and pdf_path is not null and length(pdf_path) > 0 -- só arquivo DESTA organização: o expurgo nunca alcança o PDF de outra and pdf_path like p_organization_id::text || '/%' on conflict (bucket, object_path) do nothing; update crm_proposals set destinatario_nome = v_anon_label, briefing_json = '{}'::jsonb, resumo_comercial = null, -- o texto do documento como foi montado e como foi editado à mão: é o -- conteúdo do PDF, com o mesmo nome dentro. rendered_snapshot = null, secoes_editadas = null, pdf_path = null, updated_at = now() where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('crm_proposals', v_count); -- CAMPANHAS: o que foi DITO à pessoa e o endereço para onde foi. update campaign_recipients set rendered_body = null, recipient_address = null, variables = '{}'::jsonb, last_error_detail = null, updated_at = now() where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('campaign_recipients', v_count); -- LISTA DE EXCLUSÃO: solta o vínculo e apaga a cauda do telefone. update campaign_suppressions set address_tail = null, reason = null, contact_id = null where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('campaign_suppressions', v_count); -- 6c. sales — a comanda. PRESERVA valor, status e datas, e NÃO desliga o -- contato (ver racional completo no baseline, bloco desta função). update sales set notes = null, cancel_reason = case when cancel_reason is null then null else '[redigido]' end, reverse_reason = case when reverse_reason is null then null else '[redigido]' end, updated_at = now() where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('sales', v_count); -- 6d. conversation_notes (migration 0483, F3 da #1863) — a nota interna é -- texto escrito SOBRE a pessoa durante o atendimento, e o anexo dela é mídia -- ancorada na conversa: os dois entram no alcance do titular. A 0477 já -- mostrou o desenho (arquivo vai para a fila ANTES de a coluna ser zerada). -- O bucket é `internal-media`, e não o do passo 7: a nota nunca sobe no -- `whatsapp-media` (é o bucket do canal do CLIENTE), e enfileirar o caminho -- num bucket onde ele não está deixaria a remoção apontando para o nada — -- a mesma falha de não ter anonimizado, um endereço mais para a direita. -- Por isso os caminhos de nota também NÃO entram em `v_media_paths`: essa -- lista só existe para o passo 7, que enfileira `whatsapp-media`. insert into storage_redaction_queue (organization_id, request_id, bucket, object_path) select p_organization_id, p_request_id, 'internal-media', n.media_storage_path from conversation_notes n where n.organization_id = p_organization_id and n.conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ) and n.media_storage_path is not null and length(n.media_storage_path) > 0 and n.media_storage_path like p_organization_id::text || '/%' on conflict (bucket, object_path) do nothing; update conversation_notes set body = '[nota interna anonimizada]', media_storage_path = null, media_mime = null, media_size_bytes = null where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('conversation_notes', v_count); -- 7. enqueue media for async deletion (idempotent via unique (bucket, object_path)) if array_length(v_media_paths, 1) > 0 then insert into storage_redaction_queue (organization_id, request_id, bucket, object_path) select p_organization_id, p_request_id, 'whatsapp-media', path from unnest(v_media_paths) as path where path is not null and length(path) > 0 on conflict (bucket, object_path) do nothing; end if; -- 7b. voice_calls — o TELEFONE de quem falou ao telefone (migration 0235). update voice_calls set peer_phone = v_anon_label, owner_user_id = null, created_by = null, updated_at = now() where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('voice_calls', v_count); update prospecting_candidates set suppression_salt = gen_random_bytes(32) where organization_id = p_organization_id and (contact_id = p_contact_id or (phone is not null and regexp_replace(phone, '\D', '', 'g') = any (v_variantes))) and suppression_salt is null; update prospecting_candidates set suppression_place = hmac(convert_to(place_id, 'UTF8'), suppression_salt, 'sha256'), suppression_phone = case when phone is null then null else hmac(convert_to(phone, 'UTF8'), suppression_salt, 'sha256') end, place_id = 'redacted:' || id::text, phone = null, data = jsonb_build_object('key', 'redacted:' || id::text, 'name', v_anon_label, 'phone', null, 'website', null, 'category', null, 'address', null, 'maps_url', null, 'rating', null, 'reviews', null, 'emails', '[]'::jsonb, 'socials', '[]'::jsonb), status = 'skipped', service_boundary = null, error = null, updated_at = now() where organization_id = p_organization_id and (contact_id = p_contact_id or (phone is not null and regexp_replace(phone, '\D', '', 'g') = any (v_variantes))); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('prospecting_candidates', v_count); -- agent_cases — o que a IA escreveu SOBRE a pessoa quando travou (migration 0280). update agent_cases set title = v_anon_label, summary = '[resumo anonimizado]', blocker = '[bloqueio anonimizado]', context_snapshot = '{}'::jsonb where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('agent_cases', v_count); -- agent_case_events — a linha do tempo do caso (migration 0280). update agent_case_events set body = null, metadata = '{}'::jsonb where organization_id = p_organization_id and case_id in ( select id from agent_cases where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('agent_case_events', v_count); -- demandas — o assunto do pedido (migration 0280). update demandas set assunto = null where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('demandas', v_count); -- agent_inbox_items — o aviso que leva o texto do caso para a Central (migration 0280/0292). update agent_inbox_items set status = 'resolved', resolved_at = now(), body = 'Contato anonimizado.', ref_id = null where organization_id = p_organization_id and kind in ('handoff', 'case_stale', 'aviso_de_caso_nao_entregue') and ( (ref_kind = 'contact' and ref_id = p_contact_id) or (ref_kind = 'conversation' and ref_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id )) or (ref_kind = 'agent_case' and ref_id in ( select id from agent_cases where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ) )) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('agent_inbox_items', v_count); -- agent_case_chat_messages — a consulta interna da equipe à IA SOBRE o caso (migration 0281). update agent_case_chat_messages set body = null, redacted_at = now() where organization_id = p_organization_id and contact_id = p_contact_id and redacted_at is null; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('agent_case_chat_messages', v_count); -- passagens_de_atendimento — o BRIEFING é sobre a pessoa (migration 0291). update passagens_de_atendimento set body = v_anon_label, title = null, notes = null, content = null, tentativas = '[]'::jsonb where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('passagens_de_atendimento', v_count); -- entregas_de_aviso_de_caso — o registro do aviso ao suporte (migration 0292). update entregas_de_aviso_de_caso set erro_detalhe = null where organization_id = p_organization_id and case_id in ( select id from agent_cases where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('entregas_de_aviso_de_caso', v_count); -- channel_session_groups.subject — o NOME do grupo, e a FK contact_id aponta -- para o placeholder do grupo (contacts.kind = 'whatsapp_group'), nunca para -- o titular real sendo anonimizado neste caminho — mas a FK para contacts e o -- nome da coluna casam o padrão automático do escopo (migration 0482), e -- nulificar não perde nada operacional: número, conversa e liga/desliga ficam. update public.channel_session_groups set subject = null where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('channel_session_groups', v_count); -- 8. dense audit row insert into api_audit_log (organization_id, action, actor_user_id, resource_type, resource_id, metadata, bypassed_rls) values ( p_organization_id, 'lgpd.redact_executed', null, 'contact', p_contact_id, jsonb_build_object( 'cascaded_to', v_counts, 'media_queued', coalesce(array_length(v_media_paths, 1), 0), 'request_id', p_request_id ), true ); return jsonb_build_object( 'already_anonymized', false, 'counts', v_counts, 'media_paths', v_media_paths ); end; $$; revoke all on function public.fn_lgpd_cascade_redact_contact(uuid,uuid,uuid) from public, anon, authenticated; grant execute on function public.fn_lgpd_cascade_redact_contact(uuid,uuid,uuid) to service_role; -- ---- as seções de módulo que a anonimização alcança (migration 0485) ---- -- ⚠️ ENTRA ANTES do bloco da VARREDURA anon: cria função. Corpo IDÊNTICO ao da migration -- 0485 (gate `apendice-do-baseline-nao-diverge-da-cadeia` compara o que o Postgres executa). -- Idempotente: `if not exists`, `create or replace`, `drop trigger if exists`. -- 0485 — A anonimização de LGPD alcança as SEÇÕES DE MÓDULO declaradas (D8 da ADR-0002, #1114) -- -- Lei: `docs/adr/0002-tabelas-de-modulo-num-banco-so.md`, D8: -- "Anonimização e retenção alcançam as tabelas do módulo por SQL dinâmico protegido por -- `to_regclass`: onde o módulo não está instalado, pulam sem erro. Uma cascata que citasse -- a tabela pelo nome abortaria a anonimização inteira em toda instalação sem o módulo — -- medido." -- -- ── O que JÁ existe e o que este arquivo acrescenta ────────────────────────────────────────── -- A cascata `fn_lgpd_cascade_redact_contact` continua sendo a função única do NÚCLEO (0119 → -- 0482), e a exportação já trata o módulo ausente: `lib/lgpd/export-collector.ts` engole só o -- 42P01 ("relation does not exist") e LANÇA para todo outro erro, porque seção de módulo -- ilegível nunca sai como export completo (D8, parte de export, 0480/#1578). -- -- O que faltava era o mecanismo GENÉRICO da D8: um módulo declara as SUAS seções UMA vez, e a -- anonimização as alcança sem que a cascata ganhe um passo novo a cada módulo. Sem isto, o -- próximo módulo com texto livre sobre a pessoa só ficaria alcançado se alguém lembrasse de -- reescrever uma função de ~400 linhas — e o esquecimento, em LGPD, é o modo de falha silencioso -- (rota devolve SUCESSO, SLA cumprido, linha legível). -- -- ── As três peças ───────────────────────────────────────────────────────────────────────────── -- 1. `modulo_secoes_lgpd` — o módulo declara `(modulo, tabela, ligacao, colunas, colunas_rotulo)`. -- Escrito só pela migration do módulo: RLS ligada, zero policy, `anon`/`authenticated` sem -- privilégio (mesmo desenho de `modulos_instalados`, 0340). Nenhum módulo oficial declara -- linha hoje — honorários não tem texto livre sobre a pessoa (decisão escrita na 0480) —, e a -- tabela nasce VAZIA de propósito: não se inventa dado de LGPD para um módulo que não pediu. -- 2. `fn_lgpd_redigir_secoes_de_modulo()` — gatilho `after update of is_anonymized` em -- `contacts`, a MESMA porta das 0174/0184/0210/0391: a virada `false → true` é por onde os -- DOIS caminhos de anonimização passam (a cascata e `fn_lgpd_anonymize_contact`), então não -- há caminho que escape por construção. -- 3. O `to_regclass` antes de CADA seção — módulo não instalado = tabela ausente = `continue`, -- sem erro, em qualquer instalação. É literalmente o que a ADR pede e o que a cascata -- nomeada por tabela não pode dar. -- -- ── Por que SECURITY DEFINER sem parâmetro (D4) ─────────────────────────────────────────────── -- O gatilho roda com a sessão de quem atualizou `contacts`; sem `definer`, um caminho que -- atualiza como `authenticated` não teria permissão de escrever na tabela de outro módulo por -- cima da RLS. Sem PARÂMETRO nenhum (nada de tabela, SQL ou organização vindo de fora), o efeito -- é fixo e conhecido: a mesma argumentação da D4 para a provisionadora. `execute` revogado de -- `public`, `anon` e `authenticated` — gatilho não precisa de grant para disparar, e sem argumento -- de organização esta função fica fora da régua de `definer-membership-varredura` por construção. -- -- ── Coluna declarada que não existe: ERRO ALTO, não redação pela metade ────────────────────── -- Declaração errada da migration do módulo levanta `modulo_secao_invalida` nomeando módulo e -- tabela. Silenciar aqui seria entregar ANONIMIZAÇÃO COM SUCESSO com a pessoa legível — o mesmo -- modo de falha que a LGPD não tolera em lugar nenhum. O invariante da D8 mede os dois lados. -- -- Reaplicável (tripla da casa): `if not exists`, `create or replace`, `drop trigger if exists`. -- O apêndice do `baseline.sql` entra ANTES do bloco da `VARREDURA anon` (0116), que proíbe -- `create function` depois dela — ver `tests/unit/varredura-anon-e-o-ultimo-bloco.test.ts`. create table if not exists public.modulo_secoes_lgpd ( modulo text not null check (modulo ~ '^[a-z][a-z0-9_]{1,40}$'), tabela text not null check (tabela ~ '^[a-z][a-z0-9_]{1,40}$'), -- Predicado que liga a linha da tabela ao contato, com $1 = organization_id e -- $2 = contact_id. Vai para o `execute` via `using`: nenhum valor de contato entra no texto. ligacao text not null, colunas text[] not null default '{}'::text[], colunas_rotulo text[] not null default '{}'::text[], primary key (modulo, tabela) ); comment on table public.modulo_secoes_lgpd is 'Seções de LGPD que um MÓDULO opcional declara (ADR-0002, D8). Escrito só pela migration do módulo; fn_lgpd_redigir_secoes_de_modulo lê com to_regclass e PULA a seção cuja tabela não existe (módulo não instalado).'; alter table public.modulo_secoes_lgpd enable row level security; -- Fechada também para `service_role`: o gatilho abaixo é `definer` de dono `postgres` e -- executa o `tabela`/`ligacao` gravados aqui, e o default ACL daria GRANT ALL a ele. revoke all on public.modulo_secoes_lgpd from public, anon, authenticated, service_role; create or replace function public.fn_lgpd_redigir_secoes_de_modulo() returns trigger language plpgsql security definer set search_path = public, pg_temp as $f$ declare s record; v_rel oid; v_sets text; v_nulos text; v_rotulos text; v_rotulo text := 'Cliente Anonimizado #' || substring(new.id::text from 1 for 8); begin -- O gatilho já tem `when (new.is_anonymized and not old.is_anonymized)`, e a guarda aqui é a -- mesma: uma função que também serve de alvo de `execute` não deve depender do chamador. if not (new.is_anonymized and not old.is_anonymized) then return null; end if; for s in select modulo, tabela, ligacao, colunas, colunas_rotulo from public.modulo_secoes_lgpd order by modulo, tabela loop v_rel := to_regclass(format('public.%I', s.tabela)); -- D8, o ponto central: módulo não instalado não existe aqui, e a anonimização de um -- contato NUNCA pode falhar por causa de módulo que ninguém ligou. if v_rel is null then continue; end if; if btrim(s.ligacao) = '' or (cardinality(s.colunas) = 0 and cardinality(s.colunas_rotulo) = 0) then raise exception 'modulo_secao_invalida: %/% declara ligação vazia ou sem coluna', s.modulo, s.tabela; end if; if exists ( select 1 from unnest(s.colunas || s.colunas_rotulo) as c(coluna) where not exists ( select 1 from pg_attribute a where a.attrelid = v_rel and a.attname = c.coluna and a.attnum > 0 and not a.attisdropped ) ) then raise exception 'modulo_secao_invalida: %/% tem coluna declarada que não existe', s.modulo, s.tabela; end if; select string_agg(format('%I = null', c), ', ' order by c) into v_nulos from unnest(s.colunas) as c; select string_agg(format('%I = %L', c, v_rotulo), ', ' order by c) into v_rotulos from unnest(s.colunas_rotulo) as c; v_sets := concat_ws(', ', v_nulos, v_rotulos); -- SQL dinâmico: o NOME da tabela vem da declaração (e já passou pelo to_regclass acima), -- o predicado vai literal e os dois valores entram por `using`. execute format('update public.%I set %s where (%s)', s.tabela, v_sets, s.ligacao) using new.organization_id, new.id; end loop; return null; end $f$; revoke execute on function public.fn_lgpd_redigir_secoes_de_modulo() from public, anon, authenticated; drop trigger if exists trg_lgpd_secoes_de_modulo on public.contacts; create trigger trg_lgpd_secoes_de_modulo after update of is_anonymized on public.contacts for each row when (new.is_anonymized and not old.is_anonymized) execute function public.fn_lgpd_redigir_secoes_de_modulo(); -- ---- Exclusão de contato com turno de follow-up: ficha inteira (migration 0488) ---- -- Issue #1862: a rota apagava `messages`, `conversations` e `contacts` em três -- chamadas separadas, e o `contacts` era recusado com 42501 pelo gatilho de -- follow-up quando a ficha tinha turno — histórico apagado, ficha ficando. -- A função nova abaixo é a chamada ÚNICA que a rota passa a fazer: as três -- saem numa transação só. SECURITY INVOKER de propósito, como os três DELETE -- separados que ela substitui: a RLS de quem chama continua valendo, e -- `p_organization_id` fecha a linha por dentro — sem service role. -- O conserto da outra metade (a guarda do gatilho, `pg_trigger_depth() > 1`) -- está no bloco da 0224, EDITADO NO LUGAR, porque é a MESMA função. create or replace function public.fn_apagar_contato_com_historico( p_contact_id uuid, p_organization_id uuid ) returns boolean language plpgsql volatile security invoker set search_path to 'public', 'pg_temp' as $$ begin -- RESTRICT da #752: o histórico sai antes da ficha, na mesma transação. delete from public.messages where contact_id = p_contact_id and organization_id = p_organization_id; delete from public.conversations where contact_id = p_contact_id and organization_id = p_organization_id; delete from public.contacts where id = p_contact_id and organization_id = p_organization_id; -- `found` é do DELETE da ficha: false = a ficha não estava acessível para quem -- chamou (outra organização, RLS, corrida) — a rota devolve 404 nesse caso. return found; end; $$; -- Função nova em `public` nasce exposta (ALTER DEFAULT PRIVILEGES do dump): -- o revoke tira anon e o grant deixa só quem a rota usa. revoke execute on function public.fn_apagar_contato_com_historico(uuid, uuid) from public, anon; grant execute on function public.fn_apagar_contato_com_historico(uuid, uuid) to authenticated, service_role; notify pgrst, 'reload schema'; -- ---- followup_enrollments e followup_flow_pointers: RLS por operação (migration 0489) ---- -- A policy `for all` sem papel mínimo deixava `viewer` apagar inscrição e fluxo pelo PostgREST, -- e a cascata levava turnos e trilha (issue #1913; o PR #1912 abriria o buraco inteiro). -- Escrita = `manager`, como as rotas. Não cria função. Corpo e porquê: a migration 0489. drop policy if exists tenant_isolation_followup_enrollments_all on public.followup_enrollments; drop policy if exists followup_enrollments_select on public.followup_enrollments; create policy followup_enrollments_select on public.followup_enrollments for select using (organization_id in (select public.fn_user_org_ids())); drop policy if exists followup_enrollments_insert on public.followup_enrollments; create policy followup_enrollments_insert on public.followup_enrollments for insert with check (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'manager')); drop policy if exists followup_enrollments_update on public.followup_enrollments; create policy followup_enrollments_update on public.followup_enrollments for update using (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'manager')) with check (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'manager')); drop policy if exists followup_enrollments_delete on public.followup_enrollments; create policy followup_enrollments_delete on public.followup_enrollments for delete using (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'manager')); drop policy if exists tenant_isolation_followup_flow_pointers_all on public.followup_flow_pointers; drop policy if exists followup_flow_pointers_select on public.followup_flow_pointers; create policy followup_flow_pointers_select on public.followup_flow_pointers for select using (organization_id in (select public.fn_user_org_ids())); drop policy if exists followup_flow_pointers_insert on public.followup_flow_pointers; create policy followup_flow_pointers_insert on public.followup_flow_pointers for insert with check (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'manager')); drop policy if exists followup_flow_pointers_update on public.followup_flow_pointers; create policy followup_flow_pointers_update on public.followup_flow_pointers for update using (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'manager')) with check (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'manager')); drop policy if exists followup_flow_pointers_delete on public.followup_flow_pointers; create policy followup_flow_pointers_delete on public.followup_flow_pointers for delete using (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'manager')); -- ---- followup_enrollment_events e followup_flow_versions: RLS por operação (migration 0490) ---- -- A policy `for all` sem papel mínimo deixava `viewer` apagar ou reescrever a trilha de uma -- inscrição e as versões de um fluxo pelo PostgREST (issue #1915). Escrita só onde uma rota -- escreve pela sessão (`manager`); o resto fica com o motor. Corpo e porquê: a migration 0490. drop policy if exists tenant_isolation_followup_enrollment_events_all on public.followup_enrollment_events; drop policy if exists followup_enrollment_events_select on public.followup_enrollment_events; create policy followup_enrollment_events_select on public.followup_enrollment_events for select using (organization_id in (select public.fn_user_org_ids())); drop policy if exists followup_enrollment_events_insert on public.followup_enrollment_events; create policy followup_enrollment_events_insert on public.followup_enrollment_events for insert with check (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'manager')); drop policy if exists tenant_isolation_followup_flow_versions_all on public.followup_flow_versions; drop policy if exists followup_flow_versions_select on public.followup_flow_versions; create policy followup_flow_versions_select on public.followup_flow_versions for select using (organization_id in (select public.fn_user_org_ids())); drop policy if exists followup_flow_versions_delete on public.followup_flow_versions; create policy followup_flow_versions_delete on public.followup_flow_versions for delete using (organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'manager')); -- ---- org operante e suspensão tipada (migration 0501) ---- -- A suspensão que suspende (spec cobrança do revendedor §2.1, §3.1). Corpo e -- porquê: a migration 0501. Cópia byte a byte das seções A, B, C0a, C0, C, E, F e G dela; a -- seção D (kind 'org_reativada') entra NO LUGAR, no bloco único de -- agent_inbox_items_kind_check. Entra ANTES da VARREDURA anon porque cria função. -- ── A. suspended_kind + fn_org_operante ────────────────────────────────────── alter table public.organizations add column if not exists suspended_kind text; update public.organizations set suspended_kind = 'administrativa' where status = 'suspended' and suspended_kind is null; alter table public.organizations drop constraint if exists organizations_suspended_kind_check; alter table public.organizations add constraint organizations_suspended_kind_check check (suspended_kind in ('administrativa', 'cobranca')); comment on column public.organizations.suspended_kind is 'Por que a organização está suspensa: administrativa (platform admin) ou cobranca (régua de cobrança). Só significa algo com status = suspended: o lgpd-redact-worker troca para redacted sem limpar. Escrito só por fn_suspender_organizacao e fn_reativar_organizacao (migration 0501).'; create or replace function public.fn_org_operante(p_org uuid) returns boolean language sql stable security invoker set search_path = '' as $$ select coalesce((select o.status = 'active' from public.organizations o where o.id = p_org), false); $$; revoke execute on function public.fn_org_operante(uuid) from public, anon, authenticated; grant execute on function public.fn_org_operante(uuid) to service_role; -- ── B. o estado da organização só muda pelo servidor ───────────────────────── -- `orgs_write_platform_admin` aceita qualquer `fn_is_platform_admin()`, que -- ignora o scope, e `authenticated` tem GRANT ALL: sem isto um support_readonly -- reativaria uma suspensa, trocaria o tipo da suspensão, gravaria uma data de -- anonimização (`redacted_at`, escrita só pelo lgpd-redact-worker) ou criaria -- org isenta pelo PostgREST. Todo escritor legítimo é service_role ou função definer, onde -- `current_user` é o dono da função. Molde: `fn_meet_stamp`. create or replace function public.fn_organizacao_estado_so_pelo_servidor() returns trigger language plpgsql security invoker set search_path = '' as $$ begin if current_user not in ('authenticated', 'anon') then return new; end if; if tg_op = 'INSERT' then raise exception 'organizacao_nasce_so_pelo_servidor' using errcode = '42501', detail = 'Organização nasce por rota de servidor (service_role ou função definer), nunca pela sessão.'; end if; if new.status is distinct from old.status or new.suspended_kind is distinct from old.suspended_kind or new.suspended_at is distinct from old.suspended_at or new.suspended_reason is distinct from old.suspended_reason or new.suspended_by is distinct from old.suspended_by or new.redacted_at is distinct from old.redacted_at or new.created_by is distinct from old.created_by then raise exception 'estado_da_organizacao_so_pelo_servidor' using errcode = '42501', detail = 'Status, suspensão, anonimização e autoria mudam só por fn_suspender_organizacao, fn_reativar_organizacao ou rota de servidor.'; end if; return new; end; $$; revoke execute on function public.fn_organizacao_estado_so_pelo_servidor() from public, anon, authenticated; drop trigger if exists trg_organizacao_estado_so_pelo_servidor on public.organizations; create trigger trg_organizacao_estado_so_pelo_servidor before insert or update on public.organizations for each row execute function public.fn_organizacao_estado_so_pelo_servidor(); -- ── C0a. o turno de envio que sai sem rodar ───────────────────────────────── -- O turno de envio de uma inscrição parada num nó `action` saiu sem ter rodado. -- O evento diz isso ao motor, que enfileira um turno novo quando a organização -- volta a operar (EVENTO_TURNO_DESCARTADO em lib/followup/node-handlers.ts). -- Sem ele, os rechecks da reativação esgotavam o dead-man e matavam a inscrição -- com `action_turn_never_completed` e um `followup_dead` de motivo falso. Duas -- origens, uma regra: a C0 (turno `pending` falhado pela suspensão) e o worker -- (turno que JÁ RODAVA na suspensão, com o envio barrado por -- `OrgNaoOperanteError` — a C0 não toca `running`). A chave não termina em -- `:`: não conta como passo para fn_followup_job_current. Idempotente -- pela chave; devolve se gravou. Sem guarda de status da org: a reativação -- chama a C0 com a org já `active`. create or replace function public.fn_followup_turno_descartado(p_org uuid, p_job uuid) returns boolean language sql security invoker set search_path = '' as $$ with gravado as ( insert into public.followup_enrollment_events (organization_id, enrollment_id, node_id, event_type, payload, idempotency_key) select p_org, e.id, e.current_node_id, 'turn_discarded', jsonb_build_object('job_id', j.id, 'motivo', 'org_nao_operante'), coalesce(j.payload->>'source_step_key', j.id::text) || ':descartado' from public.job_queue j join public.followup_enrollments e on e.organization_id = p_org and e.id::text = j.payload->>'followup_enrollment_id' and e.current_node_id = j.payload->>'node_id' and e.status in ('active', 'waiting_reply', 'dormente') where j.id = p_job and j.organization_id = p_org and j.kind = 'followup_turn' and j.payload->>'purpose' = 'send_message' on conflict (enrollment_id, idempotency_key) where idempotency_key is not null do nothing returning 1 ) select exists (select 1 from gravado); $$; revoke execute on function public.fn_followup_turno_descartado(uuid, uuid) from public, anon, authenticated; grant execute on function public.fn_followup_turno_descartado(uuid, uuid) to service_role; -- ── C0. a fila que a organização parada descarta ──────────────────────────── -- Falhar o job `pending` por fora não basta: o estado que dependia dele só é -- assentado pelo acerto normal (fn_reply_settle, fn_meet_delivery_settle), que -- nunca roda para um job que não saiu da fila. Sem isto, o rascunho aprovado -- ficava 'aguardando envio' e o link do Meet nunca saía, sem aviso. Precedente: -- fn_reply_redact, que falha job e rascunho juntos. Chamada pelas duas funções -- de estado; nenhum papel a executa direto. create or replace function public.fn_org_parada_descarta_fila(p_org uuid) returns void language plpgsql security invoker set search_path = '' as $$ declare v_entregas uuid[]; v_turnos uuid[]; v_compromisso uuid; begin with falhados as ( update public.job_queue set status = 'failed', last_error = 'org_nao_operante' where organization_id = p_org and status = 'pending' returning id, kind, payload ), rascunhos as ( update public.ai_reply_drafts d set status = 'failed', error_code = 'org_suspensa', updated_at = now() from falhados f where d.organization_id = p_org and d.send_job_id = f.id and f.kind = 'approved_reply' and d.status = 'approved' returning d.id ) select coalesce(array_agg(f.id) filter (where f.kind = 'transactional_delivery'), '{}'), coalesce(array_agg(f.id) filter (where f.kind = 'followup_turn'), '{}') into v_entregas, v_turnos from falhados f; -- O turno de envio que saiu da fila sem rodar avisa o motor (C0a). perform public.fn_followup_turno_descartado(p_org, t.id) from unnest(v_turnos) as t(id); for v_compromisso in update public.calendar_appointments a set meeting_delivery = a.meeting_delivery || jsonb_build_object('state', 'failed', 'error', 'org_suspensa', 'settled_at', now()) where a.organization_id = p_org and a.meeting_delivery_job_id = any(v_entregas) returning a.id loop perform public.fn_meet_notice(p_org, v_compromisso, 'failed'); end loop; end; $$; revoke execute on function public.fn_org_parada_descarta_fila(uuid) from public, anon, authenticated, service_role; -- ── C. fn_suspender_organizacao: uma transação, fila parada ────────────────── -- Conserta a rota que lia, gravava e emitia o evento sem await em três passos. -- `failed` e não `dead` nos jobs: é o terminal de veto (queue.ts); `dead` abre -- aviso `job_dead`. A mensagem `queued` vira `failed` para o redrive não a -- mandar quando alguém olhar de novo. Suspensão com tipo NULO (imagem anterior -- à 0501, depois de rollback) vale como administrativa. create or replace function public.fn_suspender_organizacao( p_org uuid, p_kind text, p_motivo text, p_ator uuid ) returns jsonb language plpgsql security definer set search_path = '' as $$ declare v_status text; v_kind text; begin if p_kind is null or p_kind not in ('administrativa', 'cobranca') then raise exception 'tipo_de_suspensao_invalido' using errcode = '22023'; end if; select o.status, coalesce(o.suspended_kind, 'administrativa') into v_status, v_kind from public.organizations o where o.id = p_org for update; if not found then raise exception 'organization_not_found' using errcode = 'P0002'; end if; if v_status = 'suspended' then if v_kind = p_kind then return jsonb_build_object('changed', false, 'motivo', 'ja_suspensa'); end if; if p_kind = 'cobranca' then return jsonb_build_object('changed', false, 'motivo', 'administrativa_prevalece'); end if; -- cobranca → administrativa: troca o tipo e mantém o início da suspensão. update public.organizations set suspended_kind = 'administrativa', suspended_reason = p_motivo, suspended_by = p_ator where id = p_org; elsif v_status = 'active' then update public.organizations set status = 'suspended', suspended_kind = p_kind, suspended_reason = p_motivo, suspended_at = now(), suspended_by = p_ator where id = p_org; else -- redacted / archived: inalterados, já não operam. return jsonb_build_object('changed', false, 'motivo', 'org_encerrada'); end if; perform public.fn_org_parada_descarta_fila(p_org); update public.messages set status = 'failed', error_code = 'org_suspensa' where organization_id = p_org and status = 'queued'; insert into public.event_log (organization_id, event_type, entity_kind, entity_id, payload) values (p_org, 'tenant.suspended', 'organization', p_org, jsonb_build_object('tenant_id', p_org, 'kind', p_kind, 'suspended_by', p_ator, 'reason', p_motivo)); return jsonb_build_object('changed', true); end; $$; revoke execute on function public.fn_suspender_organizacao(uuid, text, text, uuid) from public, anon, authenticated; grant execute on function public.fn_suspender_organizacao(uuid, text, text, uuid) to service_role; -- ── E. fn_reativar_organizacao: volta sem rajada ───────────────────────────── -- Exige o tipo: `/reactivate` desfaz só a administrativa; a de cobrança sai por -- pagamento, prazo ou isenção (PR 2 em diante). Nada é reprocessado: jobs -- `pending` que sobraram viram `failed`, e as conversas que receberam mensagem -- durante a suspensão viram UM item na Central (sem referência) para uma -- pessoa revisar. create or replace function public.fn_reativar_organizacao( p_org uuid, p_kind_exigido text, p_ator uuid ) returns jsonb language plpgsql security definer set search_path = '' as $$ declare v_status text; v_kind text; v_desde timestamptz; v_conversas integer := 0; begin if p_kind_exigido is null or p_kind_exigido not in ('administrativa', 'cobranca') then raise exception 'tipo_de_suspensao_invalido' using errcode = '22023'; end if; select o.status, coalesce(o.suspended_kind, 'administrativa'), o.suspended_at into v_status, v_kind, v_desde from public.organizations o where o.id = p_org for update; if not found then raise exception 'organization_not_found' using errcode = 'P0002'; end if; if v_status <> 'suspended' then return jsonb_build_object('changed', false, 'motivo', 'nao_suspensa'); end if; if v_kind <> p_kind_exigido then return jsonb_build_object('changed', false, 'motivo', case v_kind when 'cobranca' then 'suspensao_de_cobranca' else 'suspensao_administrativa' end); end if; update public.organizations set status = 'active', suspended_kind = null, suspended_at = null, suspended_reason = null, suspended_by = null where id = p_org; perform public.fn_org_parada_descarta_fila(p_org); if v_desde is not null then select count(*) into v_conversas from public.conversations c where c.organization_id = p_org and not c.is_group and c.last_inbound_at >= v_desde; end if; if v_conversas > 0 then insert into public.agent_inbox_items (organization_id, kind, severity, title, body) values (p_org, 'org_reativada', 'warn', 'A conta foi reativada — há conversas para revisar', -- Só o fato: o que fazer é a orientação do aviso na tela -- (lib/ai/inbox-destino.ts, org_reativada), que sabe das abas. case when v_conversas = 1 then '1 conversa recebeu mensagem enquanto a conta estava suspensa.' else format('%s conversas receberam mensagem enquanto a conta estava suspensa.', v_conversas) end); end if; insert into public.event_log (organization_id, event_type, entity_kind, entity_id, payload) values (p_org, 'tenant.reactivated', 'organization', p_org, jsonb_build_object('tenant_id', p_org, 'kind', v_kind, 'reactivated_by', p_ator, 'conversas_com_mensagem', v_conversas)); return jsonb_build_object('changed', true); end; $$; revoke execute on function public.fn_reativar_organizacao(uuid, text, uuid) from public, anon, authenticated; grant execute on function public.fn_reativar_organizacao(uuid, text, uuid) to service_role; -- ── F. o claim do follow-up não vê a organização parada ────────────────────── -- Sem isto o motor seguia avançando fluxos da org suspensa, enfileirava turnos e -- pagava o LLM de classificação. Definição VIGENTE da 0308 (espera longa dorme), -- copiada do baseline, com UMA mudança: a CTE `orgs` só aceita organização -- `active` (a régua de fn_org_operante, escrita como `exists` para o planner). -- A inscrição da org parada não é tocada — nem o lease —, e volta ao rodízio na -- reativação. Revoke e grant iguais aos da 0308. create or replace function fn_claim_due_followup_enrollments(p_limit int, p_lease_seconds int) returns setof followup_enrollments language sql security definer set search_path = public as $$ with orgs as ( -- Sem a condição de claim aqui de propósito: o lateral abaixo a aplica, e uma -- organização cujos vencidos estão todos com lease apenas devolve zero linhas. select distinct organization_id from followup_enrollments where status in ('active','waiting_reply','dormente') and next_eval_at <= now() -- Organização parada (suspensa, redigida, arquivada) não roda follow-up -- (migration 0501). and exists (select 1 from public.organizations o where o.id = followup_enrollments.organization_id and o.status = 'active') ), fila as ( select f.id, f.next_eval_at, f.posicao_na_org from orgs cross join lateral ( select d.id, d.next_eval_at, row_number() over (order by d.next_eval_at) as posicao_na_org from followup_enrollments d where d.organization_id = orgs.organization_id and d.status in ('active','waiting_reply','dormente') and d.next_eval_at <= now() and (d.claimed_until is null or d.claimed_until < now()) order by d.next_eval_at limit p_limit ) f ), escolhidos as ( -- O rodízio: posição 1 de todas as organizações, depois a 2 de todas, etc. -- Empate na mesma posição vai para quem esperou mais. select id from fila order by posicao_na_org, next_eval_at limit p_limit ), travados as ( select e.id from followup_enrollments e where e.id in (select id from escolhidos) for update skip locked ) update followup_enrollments e set claimed_until = now() + make_interval(secs => p_lease_seconds), updated_at = now() where e.id in (select id from travados) -- A condição de lease É REPETIDA AQUI, e não é redundante com a CTE `fila`. -- Sem ela, duas conexões simultâneas reclamam as MESMAS linhas: a segunda -- espera o lock da primeira, e quando ele sai o Postgres (READ COMMITTED) -- reavalia só o WHERE do UPDATE — que não olhava `claimed_until` — e grava -- por cima. O `skip locked` da CTE não salva: as duas materializam a mesma -- lista antes de qualquer lock existir. Medido: interseção de 5 em 5 no -- invariante de concorrência (followup-schema.test.ts). and (e.claimed_until is null or e.claimed_until < now()) returning e.*; $$; revoke execute on function fn_claim_due_followup_enrollments(int, int) from public, anon, authenticated; grant execute on function fn_claim_due_followup_enrollments(int, int) to service_role; -- ── G. o evento turn_discarded é só do servidor ────────────────────────────── -- A C0 grava `turn_discarded` para o motor enfileirar um turno novo na -- reativação. A policy `followup_enrollment_events_insert` (0490) deixa -- `manager` inserir pela sessão, e a chave `…:descartado` não termina em -- `:`: sem isto, um manager forjava o evento pelo PostgREST e o motor -- enfileirava um 2º turno de envio. Definição VIGENTE da 0488 com UMA mudança: -- o ramo da trilha também recusa `event_type = 'turn_discarded'` quando há -- `auth.uid()`. O servidor (service_role, funções de estado) não tem -- `auth.uid()` e segue gravando. create or replace function public.fn_followup_generation_write() returns trigger language plpgsql security definer set search_path=public as $$ begin -- #1862 — DELETE que chega em CASCATA não é escrita de follow-up. Este gatilho -- é BEFORE ROW: o DELETE vindo de `on delete cascade` roda sob o gatilho da -- chave estrangeira, com `pg_trigger_depth() > 1`. Passa QUALQUER cascata, não -- só a da ficha: apagar o contato, a inscrição (followup_enrollments), o fluxo -- (followup_flow_pointers) ou a organização leva junto os registros internos. -- O turno que sobra sem inscrição/evento falha fechado em -- fn_followup_job_current. A profundidade não distingue cascata de DELETE -- feito por outro gatilho: hoje nenhum gatilho apaga nestas duas tabelas, e -- quem criar um herda esta passagem. O DELETE DIRETO (profundidade 1, com -- `auth.uid()`) continua caindo na recusa abaixo — a 42501 não afrouxa. if tg_op='DELETE' and pg_trigger_depth()>1 then return old; end if; if tg_table_name='job_queue' then if auth.uid() is not null and ((tg_op<>'DELETE' and new.kind='followup_turn') or (tg_op<>'INSERT' and old.kind='followup_turn')) then raise exception 'followup_job_internal' using errcode='42501'; end if; if tg_op='UPDATE' and old.kind='followup_turn' then if new.organization_id<>old.organization_id or new.contact_id is distinct from old.contact_id or new.kind<>old.kind or new.payload->'followup_enrollment_id' is distinct from old.payload->'followup_enrollment_id' or new.payload->'node_id' is distinct from old.payload->'node_id' or new.payload->'source_step_key' is distinct from old.payload->'source_step_key' then raise exception 'followup_job_origin_immutable' using errcode='42501'; end if; end if; elsif auth.uid() is not null and ( (tg_op<>'DELETE' and (new.idempotency_key ~ ':[0-9]+$' or new.event_type='turn_discarded')) or (tg_op<>'INSERT' and (old.idempotency_key ~ ':[0-9]+$' or old.event_type='turn_discarded'))) then raise exception 'followup_step_internal' using errcode='42501'; end if; if tg_op='DELETE' then return old; end if; return new; end; $$; revoke all on function public.fn_followup_generation_write() from public,anon,authenticated; -- ---- a cascata do BANCO alcança lead_notes, tool_calls, lead_state e social_identity (migration 0494) ---- -- Follow-up do #1958 (issue #1964). A dorsa `fn_redigir_conversas_ao_anonimizar` -- (gatilho da virada de is_anonymized, desenho da 0391) passou a redigir também: -- lead_notes.headline/body (+embedding), ai_agent_runs.tool_calls (preserva o -- nome da ferramenta), lead_state.next_action/qualification e -- contacts.social_identity — filtrando organização E contato e guardando o -- marcador de "já redigido" que a cascata de app usa, para a varredura diária -- não reescrever o que já está anonimizado. O apêndice entra ANTES do bloco da -- VARREDURA anon (0116), que proíbe `create function` depois dela. create or replace function public.fn_redigir_conversas_ao_anonimizar() returns trigger language plpgsql security definer set search_path = public, pg_temp as $f$ declare v_variantes text[]; v_lid text; v_msgs_de_grupo uuid[]; begin insert into public.storage_redaction_queue (organization_id, bucket, object_path) select distinct new.organization_id, 'whatsapp-media', m.media_storage_path from public.messages m where m.organization_id = new.organization_id and m.conversation_id in ( select c.id from public.conversations c where c.contact_id = new.id and c.organization_id = new.organization_id) and m.media_storage_path is not null and length(m.media_storage_path) > 0 on conflict (bucket, object_path) do nothing; update public.messages set body = '[mensagem anonimizada]', media_url = null, media_mime = null, media_size_bytes = null, media_storage_path = null, metadata = '{}'::jsonb, updated_at = now() where organization_id = new.organization_id and conversation_id in ( select c.id from public.conversations c where c.contact_id = new.id and c.organization_id = new.organization_id); update public.conversations set metadata = '{}'::jsonb, last_message_preview = null, last_handoff_reason = null, updated_at = now() where contact_id = new.id and organization_id = new.organization_id; update public.lead_checkpoints set rolling_summary = '[resumo anonimizado]', commitments = '[]'::jsonb, objections = '[]'::jsonb, next_action = null, declaracao = null where contact_id = new.id and organization_id = new.organization_id; update public.lead_notes set headline = '(anonimizado)', body = '(anonimizado)', embedding = null, updated_at = now() where organization_id = new.organization_id and contact_id = new.id and (headline is distinct from '(anonimizado)' or body is distinct from '(anonimizado)' or embedding is not null); update public.ai_agent_runs set tool_calls = public.fn_lgpd_redigir_tool_calls(ai_agent_runs.tool_calls) where ai_agent_runs.organization_id = new.organization_id and ai_agent_runs.contact_id = new.id and exists ( select 1 from jsonb_array_elements(ai_agent_runs.tool_calls) s where coalesce(s->>'redacted', 'false')::boolean is not true ); update public.lead_state set next_action = null, qualification = '{}'::jsonb, updated_at = now() where organization_id = new.organization_id and contact_id = new.id and (next_action is not null or coalesce(qualification, '{}'::jsonb) <> '{}'::jsonb); update public.contacts set social_identity = null, updated_at = now() where id = new.id and organization_id = new.organization_id and social_identity is not null; v_variantes := coalesce(public.fn_telefone_variantes(coalesce(old.phone_number, new.phone_number)), '{}'); v_lid := coalesce(old.wa_lid, new.wa_lid); select coalesce(array_agg(m.id), '{}') into v_msgs_de_grupo from public.messages m where m.organization_id = new.organization_id and m.metadata ? 'group_sender' and ( regexp_replace(coalesce(m.metadata->'group_sender'->>'phone', ''), '\D', '', 'g') = any(v_variantes) or (v_lid is not null and m.metadata->'group_sender'->>'lid' = v_lid) ); if cardinality(v_msgs_de_grupo) > 0 then insert into public.storage_redaction_queue (organization_id, bucket, object_path) select distinct new.organization_id, 'whatsapp-media', m.media_storage_path from public.messages m where m.organization_id = new.organization_id and m.id = any(v_msgs_de_grupo) and m.media_storage_path is not null and length(m.media_storage_path) > 0 on conflict (bucket, object_path) do nothing; update public.conversations set last_message_preview = null, updated_at = now() where organization_id = new.organization_id and id in (select m.conversation_id from public.messages m where m.organization_id = new.organization_id and m.id = any(v_msgs_de_grupo)); update public.messages set body = '[mensagem anonimizada]', media_url = null, media_mime = null, media_size_bytes = null, media_storage_path = null, metadata = '{}'::jsonb, updated_at = now() where organization_id = new.organization_id and id = any(v_msgs_de_grupo); end if; return new; end $f$; revoke all on function public.fn_redigir_conversas_ao_anonimizar() from public; revoke execute on function public.fn_redigir_conversas_ao_anonimizar() from anon; revoke execute on function public.fn_redigir_conversas_ao_anonimizar() from authenticated; create or replace function public.fn_lgpd_redigir_tool_calls(p_tool_calls jsonb) returns jsonb language sql immutable set search_path = public, pg_temp as $t$ select coalesce(jsonb_agg(t.step_json order by t.ord), '[]'::jsonb) from ( select jsonb_strip_nulls(jsonb_build_object( 'step', case when jsonb_typeof(s.step -> 'step') = 'number' then (s.step ->> 'step')::jsonb end, 'tool_name', case when jsonb_typeof(s.step -> 'tool_name') = 'string' then to_jsonb(s.step ->> 'tool_name') end, 'redacted', true, 'tool_calls', coalesce(( select jsonb_agg(jsonb_build_object('tool_name', coalesce(c ->> 'tool_name', 'unknown'))) from jsonb_array_elements(s.step -> 'tool_calls') c ), '[]'::jsonb) )) as step_json, s.ord from jsonb_array_elements(coalesce(p_tool_calls, '[]'::jsonb)) with ordinality s(step, ord) ) t; $t$; revoke execute on function public.fn_lgpd_redigir_tool_calls(jsonb) from public, anon, authenticated; notify pgrst, 'reload schema'; -- ---- anonimizar apaga também a transcrição da mídia (migration 0497) ---- -- Achado na triagem do #1988 (@AlecLimaDev). `messages.media_derived_text` -- (transcrição/OCR do media-derive-worker, 0058) sobrevivia a toda anonimização. -- Uma linha (`media_derived_text = null`) em cada UPDATE de messages dos dois -- corpos, que partem do corpo ATUAL: o gatilho da 0494 (bloco logo acima) e a -- cascata da 0483. Corpo IDÊNTICO ao da migration 0497 (gate -- `apendice-do-baseline-nao-diverge-da-cadeia`). Entra ANTES do bloco da -- VARREDURA anon (0116), que proíbe `create function` depois dela. A cura no fim -- é idempotente pelo `is not null` — o update.sh reaplica sem reescrever. create or replace function public.fn_redigir_conversas_ao_anonimizar() returns trigger language plpgsql security definer set search_path = public, pg_temp as $f$ declare v_variantes text[]; v_lid text; v_msgs_de_grupo uuid[]; begin insert into public.storage_redaction_queue (organization_id, bucket, object_path) select distinct new.organization_id, 'whatsapp-media', m.media_storage_path from public.messages m where m.organization_id = new.organization_id and m.conversation_id in ( select c.id from public.conversations c where c.contact_id = new.id and c.organization_id = new.organization_id) and m.media_storage_path is not null and length(m.media_storage_path) > 0 on conflict (bucket, object_path) do nothing; update public.messages set body = '[mensagem anonimizada]', media_url = null, media_mime = null, media_size_bytes = null, media_storage_path = null, media_derived_text = null, metadata = '{}'::jsonb, updated_at = now() where organization_id = new.organization_id and conversation_id in ( select c.id from public.conversations c where c.contact_id = new.id and c.organization_id = new.organization_id); update public.conversations set metadata = '{}'::jsonb, last_message_preview = null, last_handoff_reason = null, updated_at = now() where contact_id = new.id and organization_id = new.organization_id; update public.lead_checkpoints set rolling_summary = '[resumo anonimizado]', commitments = '[]'::jsonb, objections = '[]'::jsonb, next_action = null, declaracao = null where contact_id = new.id and organization_id = new.organization_id; -- ── issue #1964 — lead_notes: a memória do agente sobre o contato ───────── update public.lead_notes set headline = '(anonimizado)', body = '(anonimizado)', embedding = null, updated_at = now() where organization_id = new.organization_id and contact_id = new.id and (headline is distinct from '(anonimizado)' or body is distinct from '(anonimizado)' or embedding is not null); -- ── issue #1964 — ai_agent_runs.tool_calls: preserva o nome e redige o resto -- A transformação espelha `redigirToolCalls` (lib/lgpd/cascata.ts): cada -- passo vira `{ step?, tool_name?, redacted: true, tool_calls: [{ tool_name }] }`. -- Fica QUAIS ferramentas rodaram e em que passo; sai o texto do modelo, os -- argumentos e os resultados. Guard no WHERE: só corre quando há passo sem -- `redacted`, então o `[]` de nascença e a run já redigida não são tocados. update public.ai_agent_runs set tool_calls = public.fn_lgpd_redigir_tool_calls(ai_agent_runs.tool_calls) where ai_agent_runs.organization_id = new.organization_id and ai_agent_runs.contact_id = new.id and exists ( select 1 from jsonb_array_elements(ai_agent_runs.tool_calls) s where coalesce(s->>'redacted', 'false')::boolean is not true ); -- ── issue #1964 — lead_state: a próxima ação e a qualificação da lead ───── update public.lead_state set next_action = null, qualification = '{}'::jsonb, updated_at = now() where organization_id = new.organization_id and contact_id = new.id and (next_action is not null or coalesce(qualification, '{}'::jsonb) <> '{}'::jsonb); -- ── issue #1964 — contacts.social_identity ─────────────────────────────── -- O índice único parcial `where social_identity is not null` torna anular -- seguro (a linha sai do índice sem violar unicidade). Guard no WHERE para a -- varredura não reescrever o que já está anonimizado. update public.contacts set social_identity = null, updated_at = now() where id = new.id and organization_id = new.organization_id and social_identity is not null; -- Mensagens de grupo escritas pelo titular (0482, ver o cabeçalho daquele bloco). v_variantes := coalesce(public.fn_telefone_variantes(coalesce(old.phone_number, new.phone_number)), '{}'); v_lid := coalesce(old.wa_lid, new.wa_lid); select coalesce(array_agg(m.id), '{}') into v_msgs_de_grupo from public.messages m where m.organization_id = new.organization_id and m.metadata ? 'group_sender' and ( regexp_replace(coalesce(m.metadata->'group_sender'->>'phone', ''), '\D', '', 'g') = any(v_variantes) or (v_lid is not null and m.metadata->'group_sender'->>'lid' = v_lid) ); if cardinality(v_msgs_de_grupo) > 0 then insert into public.storage_redaction_queue (organization_id, bucket, object_path) select distinct new.organization_id, 'whatsapp-media', m.media_storage_path from public.messages m where m.organization_id = new.organization_id and m.id = any(v_msgs_de_grupo) and m.media_storage_path is not null and length(m.media_storage_path) > 0 on conflict (bucket, object_path) do nothing; update public.conversations set last_message_preview = null, updated_at = now() where organization_id = new.organization_id and id in (select m.conversation_id from public.messages m where m.organization_id = new.organization_id and m.id = any(v_msgs_de_grupo)); update public.messages set body = '[mensagem anonimizada]', media_url = null, media_mime = null, media_size_bytes = null, media_storage_path = null, media_derived_text = null, metadata = '{}'::jsonb, updated_at = now() where organization_id = new.organization_id and id = any(v_msgs_de_grupo); end if; return new; end $f$; -- As DUAS origens de EXECUTE (item 9 do CLAUDE.md): o grant a PUBLIC da criação -- e o grant nominal a anon do ALTER DEFAULT PRIVILEGES do baseline. revoke all on function public.fn_redigir_conversas_ao_anonimizar() from public; revoke execute on function public.fn_redigir_conversas_ao_anonimizar() from anon; revoke execute on function public.fn_redigir_conversas_ao_anonimizar() from authenticated; create or replace function public.fn_lgpd_cascade_redact_contact(p_organization_id uuid, p_contact_id uuid, p_request_id uuid) returns jsonb language plpgsql security definer set search_path to 'public', 'extensions', 'pg_temp' as $$ declare v_already bool; v_counts jsonb := '{}'::jsonb; v_media_paths text[] := '{}'; v_anon_label text; v_count int; v_variantes text[] := '{}'; begin perform public.fn_service_lock(p_organization_id,p_contact_id); select is_anonymized into v_already from contacts where id = p_contact_id and organization_id = p_organization_id; if not found then raise exception 'contact not found' using errcode = 'P0002'; end if; if v_already then return jsonb_build_object('already_anonymized', true, 'counts', v_counts, 'media_paths', v_media_paths); end if; v_anon_label := 'Cliente Anonimizado #' || substring(p_contact_id::text from 1 for 8); select coalesce(public.fn_telefone_variantes(phone_number), '{}') into v_variantes from contacts where id = p_contact_id and organization_id = p_organization_id; select coalesce(array_agg(distinct media_storage_path) filter (where media_storage_path is not null), '{}') into v_media_paths from messages where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ); -- 1. contacts (irreversible) update contacts set name = v_anon_label, display_name = v_anon_label, email = null, phone_number = null, cpf_encrypted = null, cpf_hash = null, birthdate = null, is_anonymized = true, anonymized_at = now(), consent = '{}'::jsonb, source_metadata = '{}'::jsonb, tags = '{}'::text[], updated_at = now() where id = p_contact_id and organization_id = p_organization_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('contacts', v_count); -- 2. conversations metadata + preview strip update conversations set metadata = '{}'::jsonb, last_message_preview = null, last_handoff_reason = null, updated_at = now() where contact_id = p_contact_id and organization_id = p_organization_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('conversations', v_count); -- 3. messages: redact body + null media + strip metadata (preserve status/timestamps/conversation_id) update messages set body = '[mensagem anonimizada]', media_url = null, media_mime = null, media_size_bytes = null, media_storage_path = null, media_derived_text = null, metadata = '{}'::jsonb, updated_at = now() where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('messages', v_count); -- 4. crm_lead_activities — strip payload, metadata E reason (migration 0071). update crm_lead_activities set payload = '{}'::jsonb, metadata = '{}'::jsonb, reason = null where organization_id = p_organization_id and ( contact_id = p_contact_id or lead_id in ( select lead_id from crm_lead_links where target_kind = 'contact' and target_id = p_contact_id and organization_id = p_organization_id ) or lead_id in ( select id from crm_leads where contact_id = p_contact_id and organization_id = p_organization_id ) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('activities', v_count); -- 5. crm_leads — strip title/description/custom_fields/source_metadata/tags but PRESERVE pipeline/stage/value update crm_leads set title = v_anon_label, description = null, custom_fields = '{}'::jsonb, source_metadata = '{}'::jsonb, tags = '{}'::text[], updated_at = now() where organization_id = p_organization_id and ( contact_id = p_contact_id or id in ( select lead_id from crm_lead_links where target_kind = 'contact' and target_id = p_contact_id and organization_id = p_organization_id ) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('leads', v_count); -- 6. orders — PRESERVE values + status + timestamps. Strip personal fields from payload jsonb -- and replace customer_external_id with null (FK-safe; soft de-link). Keep contact_id null. update orders set payload = (coalesce(payload, '{}'::jsonb)) - 'customer' - 'customer_name' - 'customer_email' - 'customer_phone' - 'shipping_address' - 'billing_address' - 'contact_identification', customer_external_id = null, contact_id = null, is_anonymized = true, updated_at = now() where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('orders', v_count); -- 6b. crm_proposals (migration 0477, #1504) — PRESERVA número, valores, -- itens, datas e status; redige só o que identifica a PESSOA. Ver o -- cabeçalho desta migration para o porquê de cada coluna. -- O PDF que o cliente recebeu (bucket `propostas`, `/.pdf`) -- tem o nome dele impresso: redigir as colunas e deixar o arquivo seria -- anonimizar a linha e manter o documento. Vai para a mesma fila de expurgo -- da mídia (passo 7), com o bucket CERTO — a mensagem que levou o PDF -- aponta para o mesmo caminho, mas o passo 7 só enfileira `whatsapp-media`. -- Lido ANTES de o passo seguinte zerar `pdf_path`. insert into storage_redaction_queue (organization_id, request_id, bucket, object_path) select p_organization_id, p_request_id, 'propostas', pdf_path from crm_proposals where organization_id = p_organization_id and contact_id = p_contact_id and pdf_path is not null and length(pdf_path) > 0 -- só arquivo DESTA organização: o expurgo nunca alcança o PDF de outra and pdf_path like p_organization_id::text || '/%' on conflict (bucket, object_path) do nothing; update crm_proposals set destinatario_nome = v_anon_label, briefing_json = '{}'::jsonb, resumo_comercial = null, -- o texto do documento como foi montado e como foi editado à mão: é o -- conteúdo do PDF, com o mesmo nome dentro. rendered_snapshot = null, secoes_editadas = null, pdf_path = null, updated_at = now() where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('crm_proposals', v_count); -- CAMPANHAS: o que foi DITO à pessoa e o endereço para onde foi. update campaign_recipients set rendered_body = null, recipient_address = null, variables = '{}'::jsonb, last_error_detail = null, updated_at = now() where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('campaign_recipients', v_count); -- LISTA DE EXCLUSÃO: solta o vínculo e apaga a cauda do telefone. update campaign_suppressions set address_tail = null, reason = null, contact_id = null where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('campaign_suppressions', v_count); -- 6c. sales — a comanda. PRESERVA valor, status e datas, e NÃO desliga o -- contato (ver racional completo no baseline, bloco desta função). update sales set notes = null, cancel_reason = case when cancel_reason is null then null else '[redigido]' end, reverse_reason = case when reverse_reason is null then null else '[redigido]' end, updated_at = now() where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('sales', v_count); -- 6d. conversation_notes (migration 0483, F3 da #1863) — a nota interna é -- texto escrito SOBRE a pessoa durante o atendimento, e o anexo dela é mídia -- ancorada na conversa: os dois entram no alcance do titular. A 0477 já -- mostrou o desenho (arquivo vai para a fila ANTES de a coluna ser zerada). -- O bucket é `internal-media`, e não o do passo 7: a nota nunca sobe no -- `whatsapp-media` (é o bucket do canal do CLIENTE), e enfileirar o caminho -- num bucket onde ele não está deixaria a remoção apontando para o nada — -- a mesma falha de não ter anonimizado, um endereço mais para a direita. -- Por isso os caminhos de nota também NÃO entram em `v_media_paths`: essa -- lista só existe para o passo 7, que enfileira `whatsapp-media`. insert into storage_redaction_queue (organization_id, request_id, bucket, object_path) select p_organization_id, p_request_id, 'internal-media', n.media_storage_path from conversation_notes n where n.organization_id = p_organization_id and n.conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ) and n.media_storage_path is not null and length(n.media_storage_path) > 0 and n.media_storage_path like p_organization_id::text || '/%' on conflict (bucket, object_path) do nothing; update conversation_notes set body = '[nota interna anonimizada]', media_storage_path = null, media_mime = null, media_size_bytes = null where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('conversation_notes', v_count); -- 7. enqueue media for async deletion (idempotent via unique (bucket, object_path)) if array_length(v_media_paths, 1) > 0 then insert into storage_redaction_queue (organization_id, request_id, bucket, object_path) select p_organization_id, p_request_id, 'whatsapp-media', path from unnest(v_media_paths) as path where path is not null and length(path) > 0 on conflict (bucket, object_path) do nothing; end if; -- 7b. voice_calls — o TELEFONE de quem falou ao telefone (migration 0235). update voice_calls set peer_phone = v_anon_label, owner_user_id = null, created_by = null, updated_at = now() where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('voice_calls', v_count); update prospecting_candidates set suppression_salt = gen_random_bytes(32) where organization_id = p_organization_id and (contact_id = p_contact_id or (phone is not null and regexp_replace(phone, '\D', '', 'g') = any (v_variantes))) and suppression_salt is null; update prospecting_candidates set suppression_place = hmac(convert_to(place_id, 'UTF8'), suppression_salt, 'sha256'), suppression_phone = case when phone is null then null else hmac(convert_to(phone, 'UTF8'), suppression_salt, 'sha256') end, place_id = 'redacted:' || id::text, phone = null, data = jsonb_build_object('key', 'redacted:' || id::text, 'name', v_anon_label, 'phone', null, 'website', null, 'category', null, 'address', null, 'maps_url', null, 'rating', null, 'reviews', null, 'emails', '[]'::jsonb, 'socials', '[]'::jsonb), status = 'skipped', service_boundary = null, error = null, updated_at = now() where organization_id = p_organization_id and (contact_id = p_contact_id or (phone is not null and regexp_replace(phone, '\D', '', 'g') = any (v_variantes))); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('prospecting_candidates', v_count); -- agent_cases — o que a IA escreveu SOBRE a pessoa quando travou (migration 0280). update agent_cases set title = v_anon_label, summary = '[resumo anonimizado]', blocker = '[bloqueio anonimizado]', context_snapshot = '{}'::jsonb where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('agent_cases', v_count); -- agent_case_events — a linha do tempo do caso (migration 0280). update agent_case_events set body = null, metadata = '{}'::jsonb where organization_id = p_organization_id and case_id in ( select id from agent_cases where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('agent_case_events', v_count); -- demandas — o assunto do pedido (migration 0280). update demandas set assunto = null where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('demandas', v_count); -- agent_inbox_items — o aviso que leva o texto do caso para a Central (migration 0280/0292). update agent_inbox_items set status = 'resolved', resolved_at = now(), body = 'Contato anonimizado.', ref_id = null where organization_id = p_organization_id and kind in ('handoff', 'case_stale', 'aviso_de_caso_nao_entregue') and ( (ref_kind = 'contact' and ref_id = p_contact_id) or (ref_kind = 'conversation' and ref_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id )) or (ref_kind = 'agent_case' and ref_id in ( select id from agent_cases where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ) )) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('agent_inbox_items', v_count); -- agent_case_chat_messages — a consulta interna da equipe à IA SOBRE o caso (migration 0281). update agent_case_chat_messages set body = null, redacted_at = now() where organization_id = p_organization_id and contact_id = p_contact_id and redacted_at is null; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('agent_case_chat_messages', v_count); -- passagens_de_atendimento — o BRIEFING é sobre a pessoa (migration 0291). update passagens_de_atendimento set body = v_anon_label, title = null, notes = null, content = null, tentativas = '[]'::jsonb where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('passagens_de_atendimento', v_count); -- entregas_de_aviso_de_caso — o registro do aviso ao suporte (migration 0292). update entregas_de_aviso_de_caso set erro_detalhe = null where organization_id = p_organization_id and case_id in ( select id from agent_cases where organization_id = p_organization_id and conversation_id in ( select id from conversations where contact_id = p_contact_id and organization_id = p_organization_id ) ); get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('entregas_de_aviso_de_caso', v_count); -- channel_session_groups.subject — o NOME do grupo, e a FK contact_id aponta -- para o placeholder do grupo (contacts.kind = 'whatsapp_group'), nunca para -- o titular real sendo anonimizado neste caminho — mas a FK para contacts e o -- nome da coluna casam o padrão automático do escopo (migration 0482), e -- nulificar não perde nada operacional: número, conversa e liga/desliga ficam. update public.channel_session_groups set subject = null where organization_id = p_organization_id and contact_id = p_contact_id; get diagnostics v_count = row_count; v_counts := v_counts || jsonb_build_object('channel_session_groups', v_count); -- 8. dense audit row insert into api_audit_log (organization_id, action, actor_user_id, resource_type, resource_id, metadata, bypassed_rls) values ( p_organization_id, 'lgpd.redact_executed', null, 'contact', p_contact_id, jsonb_build_object( 'cascaded_to', v_counts, 'media_queued', coalesce(array_length(v_media_paths, 1), 0), 'request_id', p_request_id ), true ); return jsonb_build_object( 'already_anonymized', false, 'counts', v_counts, 'media_paths', v_media_paths ); end; $$; revoke all on function public.fn_lgpd_cascade_redact_contact(uuid,uuid,uuid) from public, anon, authenticated; grant execute on function public.fn_lgpd_cascade_redact_contact(uuid,uuid,uuid) to service_role; -- Cura: mensagens já anonimizadas que guardaram a transcrição. update public.messages set media_derived_text = null, updated_at = now() where body = '[mensagem anonimizada]' and media_derived_text is not null; -- ---- os avisos do Jev na Central: um por conversa e pedido, e fecham sozinhos (migration 0500) ---- -- -- Os dois kinds entraram no bloco ÚNICO de `agent_inbox_items_kind_check` (o -- do `capabilities_missing`, migration 0105), não aqui: um segundo bloco da -- mesma constraint é o defeito do #159. Daqui para baixo, o texto é o MESMO da -- migration 0500 (partes 2 e 3), com o racional inteiro lá. -- 2. UM AVISO POR CONVERSA E PEDIDO, NO BANCO. Índice único parcial em -- (organização, kind, conversa) para os dois kinds do Jev, SEM status — o -- precedente é o `agent_inbox_routing_unique` do `routing_unassigned`. Com o -- status fora do índice, o "Reabrir" nunca encontra um segundo aberto, e o -- pedido novo sobre o mesmo aviso o REABRE em vez de abrir outro (o gravador, -- lib/ai/decisao/pedidos.ts, faz o insert e trata o 23505). A busca antes da -- escrita, que havia antes, deixava dois drenos simultâneos abrirem dois. -- Antes do índice, os repetidos saem (fica o aberto, e o mais novo): só o -- banco de quem rodou este PR antes do conserto os tem, mas o `update.sh` -- de qualquer clone não pode quebrar aqui. delete from public.agent_inbox_items a using ( select id, row_number() over ( partition by organization_id, kind, ref_id order by (status = 'open') desc, created_at desc, id desc ) as n from public.agent_inbox_items where kind in ('jev_pedido_de_humano','jev_parar_de_receber') ) d where a.id = d.id and d.n > 1; create unique index if not exists agent_inbox_jev_pedido_unico on public.agent_inbox_items (organization_id, kind, ref_id) where kind in ('jev_pedido_de_humano','jev_parar_de_receber'); -- 3. O AVISO FECHA QUANDO O PEDIDO FOI ATENDIDO, por qualquer caminho. -- A conversa saiu dos estados abertos (encerrada): os dois avisos. -- A conversa ficou com uma pessoa — alguém assumiu, ou ela foi PASSADA: -- `performHumanHandoff` (a regra de hoje, o descadastro ambíguo, a -- ferramenta `request_human_handoff` do modelo), o orquestrador do clima e -- a atribuição manual gravam `last_handoff_at` e calam o robô -- (`bot_silenced_until` no futuro) — fecha SÓ o de falar com uma pessoa. -- O de parar de receber segue aberto aí: o texto dele pede que a equipe -- assuma E peça ao cliente o PARAR, e fechá-lo no primeiro passo sumiria -- com o lembrete de um pedido de descadastro antes do passo que o atende. -- No contato: bloqueado (`is_blocked` passa a true — o único escritor é o -- STOP do próprio cliente, na entrada da mensagem, lib/channels/pos-entrada.ts; -- ninguém da equipe bloqueia à mão), fecha o de parar de receber de todas -- as conversas dele. -- Gatilhos próprios, e não o de atribuição da 0228: aquele só dispara em -- `assigned_to_user_id`/`status`, e a passagem nem sempre muda o status. -- Nenhum faz HTTP; os dois filtram a organização da própria linha. create or replace function public.fn_fechar_avisos_do_jev_da_conversa() returns trigger language plpgsql security definer set search_path=public as $$ begin if new.status not in('open','pending','claimed','ai_handling') then update public.agent_inbox_items set status='resolved',resolved_at=now() where organization_id=new.organization_id and ref_kind='conversation' and ref_id=new.id and kind in('jev_pedido_de_humano','jev_parar_de_receber') and status<>'resolved'; elsif new.assigned_to_user_id is not null or (new.last_handoff_at is not null and new.last_handoff_at is distinct from old.last_handoff_at) or (new.bot_silenced_until > now() and new.bot_silenced_until is distinct from old.bot_silenced_until) then update public.agent_inbox_items set status='resolved',resolved_at=now() where organization_id=new.organization_id and ref_kind='conversation' and ref_id=new.id and kind='jev_pedido_de_humano' and status<>'resolved'; end if; return new; end; $$; revoke all on function public.fn_fechar_avisos_do_jev_da_conversa() from public,anon,authenticated; drop trigger if exists trg_fechar_avisos_do_jev_da_conversa on public.conversations; create trigger trg_fechar_avisos_do_jev_da_conversa after update of assigned_to_user_id,status,bot_silenced_until,last_handoff_at on public.conversations for each row execute function public.fn_fechar_avisos_do_jev_da_conversa(); create or replace function public.fn_fechar_aviso_do_jev_ao_bloquear() returns trigger language plpgsql security definer set search_path=public as $$ begin update public.agent_inbox_items set status='resolved',resolved_at=now() where organization_id=new.organization_id and kind='jev_parar_de_receber' and ref_kind='conversation' and status<>'resolved' and ref_id in(select v.id from public.conversations v where v.organization_id=new.organization_id and v.contact_id=new.id); return new; end; $$; revoke all on function public.fn_fechar_aviso_do_jev_ao_bloquear() from public,anon,authenticated; drop trigger if exists trg_fechar_aviso_do_jev_ao_bloquear on public.contacts; create trigger trg_fechar_aviso_do_jev_ao_bloquear after update of is_blocked on public.contacts for each row when (new.is_blocked and old.is_blocked is distinct from true) execute function public.fn_fechar_aviso_do_jev_ao_bloquear(); notify pgrst, 'reload schema'; -- ---- VARREDURA anon: função nova nasce exposta em quem ATUALIZA (migration 0116) ---- -- -- ⚠️ DE PROPÓSITO, NENHUMA FUNÇÃO É CRIADA DEPOIS DESTE BLOCO. Apêndice que cria -- função entra ANTES dele — quem o empurrar para o meio desarma a cura para tudo -- que vier depois. (O último bloco do arquivo é a chamada das travas do suporte, -- migration 0274, que não cria função.) -- Vigiado por `tests/unit/varredura-anon-e-o-ultimo-bloco.test.ts`. -- -- A 0108 revogou anon numa LISTA de 8 funções, medida num banco instalado do -- ZERO. Quem ATUALIZA tem outro estado: o `ALTER DEFAULT PRIVILEGES ... GRANT -- ALL ON FUNCTIONS TO anon` do corpo deste arquivo grava uma entrada em -- `pg_default_acl` que fica no catálogo PARA SEMPRE, e a partir daí toda função -- criada em `public` nasce com EXECUTE para anon — inclusive as deste apêndice. -- -- Medido numa VPS real (2026-08-07), comparando com o que um install fresco -- produz: 6 definer expostas a anon e 5 a authenticated, entre elas -- `fn_decrypt_oauth` — alcançável pela anon key, que vai para o browser. -- -- Lista conserta o estoque e reabre no próximo `create function`. Esta varredura -- é auto-curativa e roda DEPOIS de tudo que cria função, então cura no mesmo run -- em que o defeito nasceria. Desfazer o ALTER DEFAULT PRIVILEGES não serve: ele -- vem do `pg_dump` do Supabase e é reescrito a cada re-aplicação. -- -- As duas origens de EXECUTE (a mesma lição da 0108): grant DIRETO a anon, que -- `revoke from public` não remove; e grant a PUBLIC, do qual anon HERDA, que -- `revoke from anon` não remove. O privilégio EFETIVO de authenticated e -- service_role é medido ANTES e devolvido depois — tira anon sem tirar leitura. do $$ declare f record; tinha_auth boolean; tinha_service boolean; begin if to_regrole('anon') is null then return; end if; for f in select p.oid, p.oid::regprocedure as assinatura from pg_proc p join pg_namespace n on n.oid = p.pronamespace where n.nspname = 'public' and p.prosecdef loop tinha_auth := to_regrole('authenticated') is not null and has_function_privilege('authenticated', f.oid, 'EXECUTE'); tinha_service := to_regrole('service_role') is not null and has_function_privilege('service_role', f.oid, 'EXECUTE'); execute format('revoke execute on function %s from public, anon', f.assinatura); if tinha_auth then execute format('grant execute on function %s to authenticated', f.assinatura); end if; if tinha_service then execute format('grant execute on function %s to service_role', f.assinatura); end if; end loop; end $$; -- regra 2 (authenticated): as 5 que o update abriu e o install não abre. Aqui não -- cabe varredura — `authenticated` PRECISA de EXECUTE nos helpers de RLS e em -- `retrieve_top_k_chunks` (num install fresco ele tem). É julgamento por função, -- e o alvo de cada linha é o valor que um install fresco produz, medido. revoke execute on function public.fn_audit_log_row() from authenticated; revoke execute on function public.fn_decrypt_oauth(bytea) from authenticated; revoke execute on function public.fn_encrypt_oauth(text) from authenticated; revoke execute on function public.fn_lgpd_cascade_redact_contact(uuid, uuid, uuid) from authenticated; revoke execute on function public.fn_update_budget_consumption() from authenticated; grant execute on function public.fn_audit_log_row() to service_role; grant execute on function public.fn_decrypt_oauth(bytea) to service_role; grant execute on function public.fn_encrypt_oauth(text) to service_role; grant execute on function public.fn_lgpd_cascade_redact_contact(uuid, uuid, uuid) to service_role; grant execute on function public.fn_update_budget_consumption() to service_role; -- ---- Criador provisório sai na entrega (migration 0237) ---- -- As duas funções acima já saíram com a regra; aqui fica só a COLUNA, que é -- o dado que faltava. Idempotente. NÃO há expurgo retroativo, de propósito: -- vínculo antigo não tem a marca, e deduzi-la foi o erro que a primeira -- versão desta regra cometeu (ver o cabeçalho da migration). alter table public.user_organizations add column if not exists provisional_until_handover boolean not null default false; comment on column public.user_organizations.provisional_until_handover is 'Este vínculo existe só para a organização não nascer vazia, e sai quando o ' 'dono assumir. Gravado APENAS por fn_create_tenant_with_owner, e apenas ' 'quando o tenant foi criado para OUTRA pessoa (owner_email <> e-mail de quem ' 'cria). Nunca deduzir este valor depois: a ausência dele foi o que fez a ' 'primeira versão desta regra expulsar alguém da própria empresa.'; -- ---- política de cadastro da instalação (migration 0253) ---- create table if not exists public.platform_settings ( id smallint primary key default 1, signup_mode text not null default 'aberto', -- Comportamento da instalação (0331). NULAS de propósito: null = "a -- instalação não opinou" e quem responde é o arquivo de ambiente, o que faz -- a migration não mudar comportamento de quem nunca abrir a tela. orcamento_de_ia text, exigir_assinatura_no_webhook boolean, divulgacao_de_pagamento text, promessa_semantica boolean, updated_at timestamptz not null default now(), updated_by uuid, constraint platform_settings_singleton check (id = 1), constraint platform_settings_signup_mode check (signup_mode in ('aberto', 'so_convite')), constraint platform_settings_orcamento_de_ia check (orcamento_de_ia is null or orcamento_de_ia in ('on', 'avisar', 'off')), constraint platform_settings_divulgacao_de_pagamento check (divulgacao_de_pagamento is null or divulgacao_de_pagamento in ('inject', 'veto')) ); comment on table public.platform_settings is 'Configuração da INSTALAÇÃO (não do tenant) — linha única id=1. Hoje a política de cadastro e o COMPORTAMENTO (orçamento de IA, assinatura de webhook, divulgação de pagamento, conferência de promessa). Coluna nula = a instalação não opinou, e quem responde é o arquivo de ambiente. Lida/escrita apenas server-side (service_role); a ausência da linha significa o default. Ver lib/auth/politica-de-cadastro.ts e lib/instalacao/comportamento.ts.'; comment on column public.platform_settings.signup_mode is 'aberto = qualquer pessoa cria conta em /signup (comportamento histórico). so_convite = só quem chega com convite válido; sem convite, /signup recusa com tela e /auth/confirm NÃO provisiona organização.'; alter table public.platform_settings enable row level security; -- ZERO POLICIES, DE PROPÓSITO. Mesma decisão de `platform_branding`: esta linha -- não pertence a organização nenhuma, então não há predicado de tenant que a -- isole. RLS ligada sem policy = ninguém alcança pela REST; quem lê é o -- service_role, que a bypassa, e só a partir do servidor. revoke all on public.platform_settings from anon, authenticated; grant select, insert, update on public.platform_settings to service_role; drop trigger if exists trg_platform_settings_touch on public.platform_settings; create trigger trg_platform_settings_touch before update on public.platform_settings for each row execute function public.fn_touch_updated_at(); notify pgrst, 'reload schema'; -- ---- comportamento da instalação (migration 0331) ---- -- O `create table if not exists` acima só age em instalação NOVA: quem já tem -- `platform_settings` (desde a 0253, v1.25.0) passa por ele sem efeito no -- `update.sh`, e as quatro colunas nunca nasceriam. Este bloco é o espelho da -- migration 0331 e é o que as leva a quem ATUALIZA. NULAS e sem default, de -- propósito: null = "a instalação não opinou", e quem responde é o `.env`. -- Sem dado a corrigir antes das CHECKs: as colunas nascem nulas, e as CHECKs -- aceitam null. alter table public.platform_settings add column if not exists orcamento_de_ia text, add column if not exists exigir_assinatura_no_webhook boolean, add column if not exists divulgacao_de_pagamento text, add column if not exists promessa_semantica boolean; alter table public.platform_settings drop constraint if exists platform_settings_orcamento_de_ia; alter table public.platform_settings add constraint platform_settings_orcamento_de_ia check (orcamento_de_ia is null or orcamento_de_ia in ('on', 'avisar', 'off')); alter table public.platform_settings drop constraint if exists platform_settings_divulgacao_de_pagamento; alter table public.platform_settings add constraint platform_settings_divulgacao_de_pagamento check (divulgacao_de_pagamento is null or divulgacao_de_pagamento in ('inject', 'veto')); comment on column public.platform_settings.orcamento_de_ia is 'on = a IA respeita o teto de gasto que cada organização escolheu (default do produto, e o que o .env declara). avisar = a IA responde e apenas avisa quem opera. off = sem proteção de gasto. null = a instalação não opinou; vale AI_BUDGET_ENFORCEMENT do arquivo de ambiente. Só AFROUXA o que a organização escolheu: nunca liga proteção que a empresa não pediu.'; comment on column public.platform_settings.exigir_assinatura_no_webhook is 'true = toda entrega de webhook do canal precisa vir assinada com o segredo da sessão; sem assinatura (ou com assinatura errada) a entrega é recusada. null = a instalação não opinou; vale WAHA_WEBHOOK_REQUIRE_SIGNATURE do arquivo de ambiente (default do produto: false).'; comment on column public.platform_settings.divulgacao_de_pagamento is 'inject = o texto de divulgação de pagamento entra na primeira mensagem. veto = o envio sem esse texto é bloqueado e devolvido ao modelo com a razão, para ele reescrever. null = a instalação não opinou; vale DISCLOSURE_MODE do arquivo de ambiente (default do produto: inject).'; comment on column public.platform_settings.promessa_semantica is 'true = cada envio passa por uma conferência de modelo antes de sair, para não prometer o que a empresa não cumpre (custa uma chamada de modelo por envio). null = a instalação não opinou; vale PROMISE_SEMANTIC_ENABLED do arquivo de ambiente (default do produto: true).'; notify pgrst, 'reload schema'; -- ---- cadastro com aprovação: pedidos de empresa nova (migration 0383) ---- -- Recorte do PR #714, de @betoarts. Depois dos blocos 0253 e 0331 de propósito: -- a CHECK nova altera `platform_settings`, que precisa existir numa instalação -- NOVA. Em quem ATUALIZA, o `create table if not exists` da 0253 passa sem -- efeito e é o `drop constraint` + `add constraint` abaixo que leva o terceiro -- modo. Não cria função (o bloco da varredura anon fica acima sem prejuízo) e -- a tabela nova vem antes das travas do suporte, que são o último bloco. alter table public.platform_settings drop constraint if exists platform_settings_signup_mode; alter table public.platform_settings add constraint platform_settings_signup_mode check (signup_mode in ('aberto', 'com_aprovacao', 'so_convite')); comment on column public.platform_settings.signup_mode is 'aberto = qualquer pessoa cria conta em /signup e abre a própria empresa (comportamento histórico). com_aprovacao = a conta é criada, mas a empresa só nasce quando o administrador da instalação aprova o pedido em /admin/cadastro (tabela registration_requests). so_convite = só quem chega com convite válido; sem convite, /signup recusa com tela e /auth/confirm NÃO provisiona organização.'; create table if not exists public.registration_requests ( id uuid primary key default gen_random_uuid(), user_id uuid not null references auth.users(id) on delete cascade, requested_organization_name text not null, status text not null default 'pending', decided_by uuid references auth.users(id) on delete set null, decided_at timestamptz, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), constraint registration_requests_status check (status in ('pending', 'approved', 'rejected')), constraint registration_requests_decision check ( (status = 'pending' and decided_at is null) or (status in ('approved', 'rejected') and decided_at is not null) ) ); comment on table public.registration_requests is 'Pedido de empresa nova numa instalação em signup_mode = com_aprovacao. Da INSTALAÇÃO, não do tenant (a organização ainda não existe): RLS ligada sem policy, lida e escrita só pelo servidor. Ver lib/auth/registration-requests.ts.'; -- Um pedido pendente por conta: clique repetido no formulário não enfileira -- duplicata para o administrador. create unique index if not exists registration_requests_one_pending_per_user on public.registration_requests (user_id) where status = 'pending'; create index if not exists registration_requests_pending_idx on public.registration_requests (created_at) where status = 'pending'; alter table public.registration_requests enable row level security; revoke all on public.registration_requests from anon, authenticated; grant select, insert, update on public.registration_requests to service_role; drop trigger if exists trg_registration_requests_touch on public.registration_requests; create trigger trg_registration_requests_touch before update on public.registration_requests for each row execute function public.fn_touch_updated_at(); notify pgrst, 'reload schema'; -- ---- O App da Meta sai do `.env` e vira linha da INSTALAÇÃO (migration 0257) ---- -- -- O App Secret e o verify token do webhook são do APP, e um App da Meta atende N -- WABAs de N organizações: não há o que separar por tenant. Antes disto os dois -- viviam no `.env` (SSH em quem instalou), e a partir do 2º número não havia como -- configurar o app sem mexer no que já funcionava (issue #850, fatia F3). -- -- Mesmo desenho de `platform_google_oauth` (0201): uma linha só, RLS ligada SEM -- policies, `anon`/`authenticated` revogados e leitura/escrita pelo `service_role` -- atrás do gate administrativo. O `revoke` é obrigatório porque o -- `alter default privileges` do topo deste arquivo concede tabela nova a `anon` e -- `authenticated`. -- -- O `.env` NÃO é apagado: ele é o piso de rollback (código novo sobre banco que -- ainda não aplicou esta migration) e a rota de verificação do webhook lê o banco -- primeiro. As duas fontes não se misturam. create table if not exists public.platform_meta_app ( id smallint primary key default 1, app_secret_encrypted bytea, verify_token_encrypted bytea, verify_token_created_at timestamptz, updated_at timestamptz not null default now(), updated_by uuid, constraint platform_meta_app_singleton check (id = 1) ); comment on table public.platform_meta_app is 'O App da Meta DESTA INSTALAÇÃO (singleton): App Secret que assina a entrega do webhook e verify token que responde ao handshake. Server-side only: RLS ligada sem policies e grants revogados de anon/authenticated — o PostgREST não a serve. Nenhum dos dois segredos volta ao browser; a tela devolve apenas se existem.'; comment on column public.platform_meta_app.app_secret_encrypted is 'Cifrado por fn_encrypt_oauth (pgp_sym_encrypt/aes256). Nunca gravar em claro: sem a chave mestra o save recusa. Quem tem este valor assina uma entrega de webhook válida com dados inventados.'; comment on column public.platform_meta_app.verify_token_encrypted is 'Cifrado por fn_encrypt_oauth. Gerado pelo SERVIDOR (32 bytes de CSPRNG) e exibido UMA vez: não há leitura que o devolva em claro — quem perde o valor usa a rotação da tela. Um token escolhido à mão ("deskcomm", o nome da empresa) é adivinhável, e quem o acerta passa a receber o tráfego do webhook.'; comment on column public.platform_meta_app.verify_token_created_at is 'Quando o verify token em vigor nasceu. A tela mostra a data para quem acabou de rotacionar saber se o valor colado no painel da Meta é o novo.'; alter table public.platform_meta_app enable row level security; revoke all on public.platform_meta_app from anon, authenticated; grant select, insert, update on public.platform_meta_app to service_role; drop trigger if exists trg_platform_meta_app_updated_at on public.platform_meta_app; create trigger trg_platform_meta_app_updated_at before update on public.platform_meta_app for each row execute function public.fn_set_updated_at(); -- ---- a regra de automação guarda a CONFIGURAÇÃO do gatilho (migration 0268) ---- -- O gatilho de data do funil (#989) não nasce de evento: quem o emite é a -- varredura `cron/lead-date-field-due`, e ela só sabe onde olhar se a regra -- disser o funil, o campo de data e quantos dias antes (ou depois) avisar. -- -- Vazio nos outros gatilhos, e `not null default '{}'` dispensa backfill: regra -- que já existe nasce com o objeto vazio, e quem lê trata ausência e objeto -- vazio do mesmo jeito. alter table public.automation_rules add column if not exists trigger_config jsonb not null default '{}'::jsonb; comment on column public.automation_rules.trigger_config is 'Configuração do gatilho (issue #989). Vazio nos gatilhos que nascem de evento. No gatilho lead.date_field_due guarda {pipeline_id, campo, dias} — o campo de data pertence a UM funil, e sem essa dupla a varredura não sabe onde olhar.'; notify pgrst, 'reload schema'; -- 0311 · O webhook do NÚMERO, registrado pela própria instalação (issue #850, fatia F1). -- -- ─── O que o usuário via ──────────────────────────────────────────────────── -- Conectar o canal oficial era metade do caminho: o canal ENVIAVA e não RECEBIA até -- alguém entrar no painel da Meta, abrir a configuração do webhook, colar a URL de -- callback e escolher os campos — por número. Quem não sabia disso (o produto é -- self-host para quem NÃO programa) ficava com um canal que parece pronto e cujas -- mensagens recebidas simplesmente não existem em lugar nenhum: nem erro, nem log. -- -- ─── O que estas colunas guardam ──────────────────────────────────────────── -- O DESFECHO do registro automático, não a configuração: a URL que ficou registrada -- (`meta_webhook_override_uri`), o motivo da última falha (`..._erro`) e quando foi -- (`..._em`). São o que a tela lê para dizer "conectado, webhook pendente: " -- com botão de tentar de novo — em vez de dizer "conectado" e deixar a descoberta -- para a primeira mensagem que nunca chega. -- -- ─── Por que colunas, e não o `metadata` jsonb que já existe na tabela ────── -- Porque a TELA consulta este estado a cada render e o desfecho tem três leitores -- (GET do canal, POST de conexão, rota de re-registro): chave dentro de jsonb é -- contrato que ninguém vê quebrar — o `metadata` da sessão é do ingest/roteamento, e -- misturar os dois faz um `update` de lá apagar o desfecho daqui. -- -- ─── Por que registrar DEPOIS de gravar a sessão ──────────────────────────── -- O GET de verificação da Meta chega no instante em que o override é registrado e -- procura a sessão pelo `webhook_path_token`. Registrar antes de a linha existir -- devolveria 404, e a Meta marcaria o webhook como inválido — pior que não registrar. -- Ordem invertida = defeito, não preferência. -- -- ─── Exposição: nenhuma nova ──────────────────────────────────────────────── -- A URL registrada contém o `webhook_path_token`, que JÁ vive nesta tabela -- (`channel_sessions`, com `GRANT ALL` a anon/authenticated e RLS de isolamento por -- organização desde as migrations 0106/0099). Não há coluna nova de segredo, não há -- grant novo, não há policy nova: a coluna herda exatamente o acesso das vizinhas. -- O que ela NÃO guarda é o token da Meta — esse continua só em -- `meta_token_encrypted`, cifrado (fn_encrypt_oauth). -- -- ─── O que NÃO entra aqui, de propósito ───────────────────────────────────── -- * `message_template_status_update`: a Meta NÃO aceita override por número para este -- tópico — ele continua indo para a URL do app (limite da plataforma, não escolha). -- * Limpeza no arquivamento do canal e reaplicação na reconexão: é a fatia F1b, e -- roda em cima destas mesmas colunas (`meta_webhook_override_uri` null = desfeito). -- * Índice: as três colunas são lidas sempre pela chave primária da sessão. alter table public.channel_sessions add column if not exists meta_webhook_override_uri text, add column if not exists meta_webhook_override_erro text, add column if not exists meta_webhook_override_em timestamptz; comment on column public.channel_sessions.meta_webhook_override_uri is 'URL de callback registrada na Meta para ESTE número (override por phone_number_id). Nulo = não registrado (ou desfeito). Contém o webhook_path_token, que já é desta tabela.'; comment on column public.channel_sessions.meta_webhook_override_erro is 'Motivo da última falha ao registrar o webhook, como a Graph API devolveu. Não é falha da conexão: o canal envia normalmente; o que depende disto é a ENTREGA. Nulo = última tentativa deu certo.'; comment on column public.channel_sessions.meta_webhook_override_em is 'Quando foi a última TENTATIVA de registrar (sucesso ou falha). A tela usa a data para o operador saber se o estado que ele vê é o de agora.'; -- ---- a resposta revisada para de segurar a Zona de perigo (migration 0273) ---- -- A FK inline da 0227 nasceu sem ação de exclusão (NO ACTION) e era a ÚNICA das -- quatro que apontam para `public.messages(id)` fora do padrão `on delete set -- null` das irmãs (v. 11337, 14759 e 19939). Resultado: numa organização que já -- enviou uma resposta revisada, o PRIMEIRO delete da Zona de perigo -- (`messages`, em `lib/settings/apagar-dados-operacionais.ts`) era recusado com -- 23503 — `violates foreign key constraint "ai_reply_drafts_message_id_fkey"` — -- e o reset morria sem apagar nada. `set null` e não `cascade`: existe caminho -- legítimo que apaga mensagem por motivo alheio à resposta (dedup de eco, -- exclusão de uma mensagem avulsa) e ali cascade apagaria o rascunho revisado — -- histórico sumindo por causa de um ponteiro, o que a doutrina da irmã de 14759 -- proíbe. A Zona de perigo não precisa que o rascunho morra junto com a -- mensagem: o cascade de `conversations` já leva os rascunhos da organização. -- `message_id` é nullable, então não há default nem backfill. alter table public.ai_reply_drafts drop constraint if exists ai_reply_drafts_message_id_fkey; alter table public.ai_reply_drafts add constraint ai_reply_drafts_message_id_fkey foreign key (message_id) references public.messages(id) on delete set null; notify pgrst, 'reload schema'; -- ---- a configuração da instalação cabe na tela (migration 0341) ---- -- 0341 — A configuração da INSTALAÇÃO sai do `.env` e passa a caber na tela. -- -- ── O problema ─────────────────────────────────────────────────────────────── -- -- Trocar a chave de IA, o token do WAHA ou o remetente de e-mail exige SSH na -- VPS, editar o `.env` e recriar os contêineres. Para o público do kit — quem -- compra hospedagem e instala sozinho — isso é o mesmo que não ser configurável. -- A marca (0155) e a credencial do Google (0201) já fizeram essa travessia; esta -- migration generaliza o caminho para o resto da configuração. -- -- ── Por que LINHAS e não COLUNAS ───────────────────────────────────────────── -- -- `platform_branding` e `platform_settings` são singletons com uma coluna por -- campo, e para 3 ou 4 campos isso é o certo. Aqui não serve, por duas razões -- medidas: -- -- 1. ESCALA. São 42 chaves candidatas (27 migráveis + 15 knobs). Cifrada, cada -- credencial ocupa quatro campos (ciphertext, iv, tag, last4) — a tabela -- passaria de 150 colunas, e cada chave nova seria um ALTER. -- -- 2. O TUDO-OU-NADA. O cabeçalho de `lib/branding/instalacao.ts` documenta que -- coluna nova em singleton é tudo-ou-nada por construção: código novo sobre -- schema velho faz o PostgREST devolver `42703` para a LINHA INTEIRA, e a -- marca toda cai no `.env`. Numa tabela de linhas esse modo de falha não -- existe: chave que o banco ainda não tem é simplesmente linha ausente, e -- linha ausente JÁ significa "usa o `.env`" — que é o mesmo desfecho, sem -- derrubar as outras 41 no caminho. -- -- ── Por que a cifra é da APLICAÇÃO e não do banco ──────────────────────────── -- -- O repositório tem DOIS padrões de cifra convivendo, e a escolha entre eles não -- é estética: -- -- • `fn_encrypt_oauth` (0201/0257) cifra no banco com `pgp_sym_encrypt`, e a -- chave mora em `private.app_secrets`, semeada pelo kit. -- • `lib/crypto/aes_gcm.ts` cifra na aplicação (AES-256-GCM), e a chave mora -- só no `.env` (`AI_CRED_AES_KEY`). É o que já protege -- `ai_provider_credentials`, com nove consumidores. -- -- O `backup.sh` do kit roda `pg_dump` SEM filtrar schema, pela mesma conexão -- privilegiada que semeia a chave — se a semeadura alcança `private.app_secrets`, -- o dump também alcança. E o backup NÃO leva o `.env` (só banco + sessões do -- WhatsApp). Com a cifra do banco, portanto, um arquivo de backup vazado entrega -- a chave e o cofre juntos. Isso é tolerável para um segredo do Google; deixa de -- ser quando o cofre guarda TODAS as credenciais da instalação. -- -- Por isso esta tabela guarda o envelope AES-GCM cru (`ciphertext`/`iv`/`tag`) e -- nenhuma função do banco sabe abri-lo. Backup vazado sem o `.env` é ruído. -- -- ── `semeado_do_env` não é enfeite de proveniência ─────────────────────────── -- -- É o que impede o `.env` de desfazer uma escolha humana, e a regra vem inteira -- de `precisaSemear` em `lib/branding/instalacao.ts`: a escrita pela tela zera o -- campo, e linha com `semeado_do_env = false` NUNCA é semeada de novo. Sem isso, -- o valor antigo do `.env` reescreveria no próximo boot o que a pessoa acabou de -- digitar, e o campo pareceria não funcionar. -- -- Apagar a linha é o "voltar ao padrão": sem linha, o resolvedor lê o `.env` de -- novo e pode semear outra vez. Por isso `delete` entra no grant. -- -- Sem dado tocado, sem backfill: tabela nova, vazia, e o resolvedor degrada para -- o `.env` enquanto ela estiver assim. create table if not exists public.platform_config ( chave text primary key, valor text, ciphertext bytea, iv bytea, tag bytea, last4 text, eh_segredo boolean not null default false, semeado_do_env boolean not null default false, updated_at timestamptz not null default now(), updated_by uuid, -- A chave É o nome da variável de ambiente, para que a correspondência -- banco ↔ `.env` seja literal e conferível por quem opera a VPS. constraint platform_config_chave_formato check (chave ~ '^[A-Z][A-Z0-9_]{2,63}$'), -- Segredo e knob são formas mutuamente exclusivas da mesma linha. Sem este -- XOR, uma linha poderia ter `valor` em claro E envelope cifrado — e o -- resolvedor teria de escolher, o que é como um segredo vaza em claro. constraint platform_config_forma_do_valor check ( (eh_segredo and ciphertext is not null and iv is not null and tag is not null and valor is null) or (not eh_segredo and valor is not null and ciphertext is null and iv is null and tag is null) ) ); comment on table public.platform_config is 'Configuração da INSTALAÇÃO editável pela tela (não do tenant): uma linha por variável, nomeada como a própria variável de ambiente. Linha ausente = usa o .env. Segredo guarda envelope AES-256-GCM cru (lib/crypto/aes_gcm.ts, chave em AI_CRED_AES_KEY, fora do banco de propósito — ver o cabeçalho da migration 0341); knob guarda texto. Lida/escrita só server-side por service_role. Ver lib/instalacao/config.ts.'; comment on column public.platform_config.semeado_do_env is 'true = o valor veio do .env por semeadura automática e pode ser re-semeado. false = uma pessoa escreveu pela tela, e o .env NUNCA sobrescreve. Mesma regra de platform_branding.seeded_from_env (0155).'; comment on column public.platform_config.last4 is 'Últimos 4 caracteres do segredo, para a tela identificar QUAL chave está lá sem nunca devolver o valor. Null para knob.'; -- ZERO POLICIES, DE PROPÓSITO — mesma decisão de `platform_branding` (0155) e -- `platform_settings` (0253): esta linha não pertence a organização nenhuma, -- então não há predicado de tenant que a isole. RLS ligada sem policy = ninguém -- alcança pela REST; quem lê é o service_role, que a bypassa, e só do servidor. alter table public.platform_config enable row level security; -- As DUAS origens de grant, e tratar só uma deixa a tabela exposta com o gate -- verde: (A) o `alter default privileges ... on tables to anon` do baseline -- alcança TODA tabela criada depois dele — isto é, todo apêndice novo; (B) o -- grant que o Postgres dá ao dono. O repositório já registra alguém que -- conhecia a doutrina e errou exatamente aqui. revoke all on public.platform_config from anon, authenticated; grant select, insert, update, delete on public.platform_config to service_role; drop trigger if exists trg_platform_config_touch on public.platform_config; create trigger trg_platform_config_touch before update on public.platform_config for each row execute function public.fn_touch_updated_at(); notify pgrst, 'reload schema'; -- ---- CSV como material de conhecimento (migration 0310) ---- -- O bucket `ai-policy` (acima, migration 0014) tinha `allowed_mime_types` -- fechado em PDF/Markdown/texto. O acervo de IA passou a aceitar CSV -- (lib/ai/rag/extractors/csv.ts) — sem esta linha o Storage recusa o upload -- ANTES de qualquer código da aplicação rodar, com erro sem relação nenhuma -- com "extensão não suportada". `update`, não `insert ... on conflict`: o -- bucket já existe em todo clone; é a MIME list que precisa alcançar quem -- instalou antes desta mudança. update storage.buckets set allowed_mime_types = array['application/pdf', 'text/markdown', 'text/x-markdown', 'text/plain', 'text/csv'] where id = 'ai-policy'; -- ---- o recibo de idempotência ganha o estado "em curso" (migration 0321) ---- -- Issue #778, PR #1189 (@webtecnica). Reserva = `status_code` e `response_body` -- nulos, gravada ANTES do efeito; recibo = os dois preenchidos. O `create table` -- do corpo já nasce anulável (install); as duas primeiras linhas levam a -- nulidade a quem JÁ tinha a tabela (update), onde o `create table if not -- exists` é no-op. `drop not null` em coluna já anulável é no-op. O CHECK fecha -- o meio-termo (um gravado e o outro não), que nenhum leitor sabe interpretar; -- toda linha anterior tem as duas colunas preenchidas e passa sem backfill. alter table public.idempotency_keys alter column status_code drop not null; alter table public.idempotency_keys alter column response_body drop not null; alter table public.idempotency_keys drop constraint if exists idempotency_keys_recibo_ou_reserva; alter table public.idempotency_keys add constraint idempotency_keys_recibo_ou_reserva check ((status_code is null) = (response_body is null)); -- ---- destinos internos que o dono da instalação autoriza (migration 0326) ---- -- Decisão 22-d, #1004. null = nunca configurado pela tela (vale o .env); -- '{}' = o dono esvaziou a lista. Idempotente; sem dado tocado. alter table public.platform_settings add column if not exists internal_destinations text[]; comment on column public.platform_settings.internal_destinations is 'IPv4 e faixas CIDR IPv4 que a INSTALAÇÃO pode alcançar mesmo sendo rede interna — só para destinos configurados pela instalação, nunca por uma organização (decisão 22-d, #1004). null = nunca configurado pela tela: vale IA_DESTINOS_INTERNOS_PERMITIDOS do .env. Array vazio = nada autorizado. Ver lib/automation/destinos-internos-autorizados.ts.'; notify pgrst, 'reload schema'; -- ---- marcador do contato normalizado, no dado que já estava gravado (migration 0335) ---- -- Issue #1224 (triagem do #1206), @webtecnica. A escrita passou a normalizar o -- marcador do contato nos quatro caminhos (ficha, importação por CSV, API e -- `crm_manage_tags`) pela MESMA função que o filtro usa para ler -- (lib/contacts/tag-normalizada.ts) — sem isso, `?tag=vip` não encontra o contato -- marcado como "VIP" e o chip do marcador não sai da ficha por remoção nenhuma. -- Este apêndice é o backfill do dado ANTERIOR, e é idempotente por -- `is distinct from`: aplicado numa VPS que já recebeu a migration 0335, nenhuma -- linha é tocada (o arquivo é aplicado inteiro em quem instala, e de novo em -- quem atualiza). A ordem é a mesma da aplicação — corta as pontas, minúsculas, -- teto de 40 caracteres, descarta o vazio e tira o repetido — e a ordem de -- primeira aparição é preservada (`with ordinality`) para a ficha do contato não -- reembaralhar os marcadores de quem já os tinha. update public.contacts c set tags = sub.normalizados from ( select ct.id, array_agg(ct.tag order by ct.ord) as normalizados from ( -- `c2.id` NA CHAVE: sem ele o `distinct on` é global e guarda UMA -- linha por marcador na TABELA INTEIRA — o segundo contato com "VIP" -- perde o marcador, e a deduplicação atravessa organizações. A -- consulta é válida, roda sem erro e sem aviso; o que denuncia é o -- dado. Reproduzido em Postgres 17.6: {VIP,Suporte} virava {suporte}. select distinct on (c2.id, left(lower(btrim(u.x)), 40)) c2.id, left(lower(btrim(u.x)), 40) as tag, u.ord from public.contacts c2 cross join lateral unnest(c2.tags) with ordinality as u(x, ord) where c2.tags is not null and left(lower(btrim(u.x)), 40) <> '' order by c2.id, left(lower(btrim(u.x)), 40), u.ord ) ct group by ct.id ) sub where c.id = sub.id and c.tags is distinct from sub.normalizados; -- Marcador que era só espaço vira lista vazia: a sentença acima não alcança -- essas linhas (a subconsulta descarta o vazio) e o contato ficaria com um -- marcador invisível que nenhum filtro casa e nenhuma tela mostra. update public.contacts c set tags = '{}'::text[] where c.tags is not null and cardinality(c.tags) > 0 and c.tags is distinct from '{}'::text[] and not exists ( select 1 from unnest(c.tags) as x where left(lower(btrim(x)), 40) <> '' ); notify pgrst, 'reload schema'; -- ---- banco de dados externo do agente (migrations 0372 e 0373) ---- -- -- Recorte do PR #1130, de @vgamkt. O cadastro da conexão da organização com um -- PostgreSQL de OUTRO sistema (segundo CRM, ERP), que o agente consulta em -- tempo real. A senha é cifrada pelo app (AES-256-GCM, `AI_CRED_AES_KEY`) e -- nunca tem coluna em claro; a tela lê a view `_safe`, que omite as três -- colunas cifradas. -- -- Vem ANTES da reaplicação de módulos e das varreduras do fim do arquivo: é -- tabela de organização nova, e é o que faz a PRIMEIRA aplicação deste arquivo -- chegar ao mesmo conjunto de travas de suporte que a segunda. -- -- Nenhuma função nova em `public` ⇒ nenhuma superfície `security definer` nova. -- O bloco traz o estado FINAL das duas migrations (cadastro + limites por -- conexão), porque o apêndice descreve onde o banco tem de chegar, não o -- caminho. create table if not exists public.external_db_connections ( id uuid primary key default gen_random_uuid(), organization_id uuid not null references public.organizations(id) on delete cascade, label text not null, host text not null, port integer not null default 5432, database_name text not null, username text not null, password_encrypted bytea not null, password_iv bytea not null, password_tag bytea not null, ssl_mode text not null default 'require', enabled boolean not null default true, last_tested_at timestamptz, last_test_ok boolean, last_test_error text, created_by uuid references auth.users(id), created_at timestamptz not null default now(), updated_at timestamptz not null default now(), constraint external_db_connections_label_uk unique (organization_id, label), constraint external_db_connections_port_valido check (port between 1 and 65535), constraint external_db_connections_ssl_conhecido check (ssl_mode in ('disable', 'prefer', 'require', 'verify-ca', 'verify-full')) ); -- Os limites por conexão (0373). `if not exists` para o clone que já aplicou a -- versão anterior deste bloco. alter table public.external_db_connections add column if not exists max_rows integer not null default 200, add column if not exists max_filters integer not null default 20, add column if not exists max_response_bytes integer not null default 30000; comment on table public.external_db_connections is 'Conexão da organização com um PostgreSQL externo (outro CRM/sistema). A senha é cifrada com AES-GCM (AI_CRED_AES_KEY) e nunca é exposta: a tela lê external_db_connections_safe. O schema do banco externo não é espelhado — a introspecção é ao vivo.'; comment on column public.external_db_connections.password_encrypted is 'Ciphertext AES-256-GCM. Par de password_iv (12 bytes) e password_tag (16 bytes). Sem AI_CRED_AES_KEY a leitura falha fechada.'; comment on column public.external_db_connections.ssl_mode is 'Modo TLS da conexão pg. Default require: remoto sem TLS vaza credencial e dado.'; comment on column public.external_db_connections.last_test_error is 'Erro do último teste de conexão, truncado e sem segredo. Superfície de falha lida pela tela.'; comment on column public.external_db_connections.max_rows is 'Teto de linhas por consulta (grade e agente). Faixa 1..5000; default 200.'; comment on column public.external_db_connections.max_filters is 'Teto de filtros por consulta do agente. Faixa 0..100; default 20.'; comment on column public.external_db_connections.max_response_bytes is 'Teto de bytes da resposta devolvida ao modelo. Faixa 4096..1048576; default 30000.'; -- Doutrina de migrations, item 8: corrigir o dado ANTES da constraint. Num banco -- novo não há linha; num clone onde alguém tenha escrito um teto fora da faixa -- direto no SQL, o `update.sh` conserta em vez de quebrar. update public.external_db_connections set max_rows = least(greatest(max_rows, 1), 5000) where max_rows not between 1 and 5000; update public.external_db_connections set max_filters = least(greatest(max_filters, 0), 100) where max_filters not between 0 and 100; update public.external_db_connections set max_response_bytes = least(greatest(max_response_bytes, 4096), 1048576) where max_response_bytes not between 4096 and 1048576; alter table public.external_db_connections drop constraint if exists external_db_connections_max_rows_valido, drop constraint if exists external_db_connections_max_filters_valido, drop constraint if exists external_db_connections_max_response_bytes_valido; alter table public.external_db_connections add constraint external_db_connections_max_rows_valido check (max_rows between 1 and 5000), add constraint external_db_connections_max_filters_valido check (max_filters between 0 and 100), add constraint external_db_connections_max_response_bytes_valido check (max_response_bytes between 4096 and 1048576); create index if not exists external_db_connections_org_idx on public.external_db_connections (organization_id) where enabled; alter table public.external_db_connections enable row level security; drop policy if exists tenant_isolation_external_db_connections_select on public.external_db_connections; create policy tenant_isolation_external_db_connections_select on public.external_db_connections for select using (organization_id in (select * from public.fn_user_org_ids())); -- Leitura: qualquer membro (D2). Escrita: `admin` também na RLS — a mesma regra -- na camada que sobrevive a uma rota nova. drop policy if exists tenant_isolation_external_db_connections_modify on public.external_db_connections; drop policy if exists tenant_isolation_external_db_connections_write on public.external_db_connections; create policy tenant_isolation_external_db_connections_write on public.external_db_connections for all using ( organization_id in (select * from public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'admin') ) with check ( organization_id in (select * from public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'admin') ); -- O ALTER DEFAULT PRIVILEGES deste baseline dá GRANT ALL em TABLES a `anon`: -- toda tabela nova nasce exposta e precisa revogar por conta própria. revoke all on public.external_db_connections from anon; drop view if exists public.external_db_connections_safe; create view public.external_db_connections_safe with (security_invoker = true) as select id, organization_id, label, host, port, database_name, username, ssl_mode, enabled, max_rows, max_filters, max_response_bytes, last_tested_at, last_test_ok, last_test_error, created_by, created_at, updated_at from public.external_db_connections; revoke all on public.external_db_connections_safe from anon; grant select on public.external_db_connections_safe to authenticated; drop trigger if exists trg_external_db_connections_updated_at on public.external_db_connections; create trigger trg_external_db_connections_updated_at before update on public.external_db_connections for each row execute function public.fn_set_updated_at(); drop trigger if exists trg_external_db_connections_audit on public.external_db_connections; create trigger trg_external_db_connections_audit after insert or update or delete on public.external_db_connections for each row execute function public.fn_audit_log_row(); -- ---- o banco externo vira módulo opcional da instalação (migration 0384) ---- -- Doc 37: desligado por padrão, ligado em /admin/sistema. A chave é a linha -- `MODULO_BANCO_EXTERNO` de `platform_config` (0341, bloco acima); sem linha = -- desligado. Quem já tinha conexão cadastrada nasce LIGADO, para a atualização -- não tirar a função calada; as outras nascem `desligado`. A linha é gravada -- SEMPRE na primeira aplicação: sem ela, uma conexão escrita depois pelo admin -- de uma empresa ligaria o módulo para todos no `update.sh` seguinte. `do -- nothing`: da primeira vez em diante, só a tela muda a escolha. Vem DEPOIS das -- duas tabelas que lê. insert into public.platform_config (chave, valor, eh_segredo, semeado_do_env) select 'MODULO_BANCO_EXTERNO', case when exists (select 1 from public.external_db_connections) then 'ligado' else 'desligado' end, false, false on conflict (chave) do nothing; -- ---- a memória da organização aceita a origem 'agent' (migration 0385) ---- -- `crm_save_org_memory` grava `source = 'agent'`, e o CHECK inline da 0067 (que o -- Postgres batiza `org_memory_entries_source_check`) só aceitava `manual` e -- `flywheel`: toda chamada da ferramenta falhava com 23514 (diagnóstico de -- @vgamkt, #1130). Bloco ÚNICO desta constraint, com o conjunto final; as linhas -- existentes cabem nele, então reaplicar no `update.sh` não viola nada. alter table public.org_memory_entries drop constraint if exists org_memory_entries_source_check; alter table public.org_memory_entries add constraint org_memory_entries_source_check check (source in ('manual', 'flywheel', 'agent')); -- ---- o produto ganha foto (migration 0390, ideia de @vgamkt, #1130) ---- -- Caminhos em storage/catalog-photos (//.); a ordem -- é a da tela e a primeira é a capa. Bucket privado, 5 MB, só JPEG/PNG (o que o -- WhatsApp oficial aceita como imagem). Racional inteiro na migration 0390. alter table public.catalog_products add column if not exists fotos text[] not null default '{}'; alter table public.catalog_products drop constraint if exists catalog_products_fotos_no_maximo_5; alter table public.catalog_products add constraint catalog_products_fotos_no_maximo_5 check (cardinality(fotos) <= 5); comment on column public.catalog_products.fotos is 'Caminhos em storage/catalog-photos, sempre //.. A ordem é a da tela e a primeira é a capa. Escrito só por app/api/v1/products/[id]/fotos.'; insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types) values ('catalog-photos', 'catalog-photos', false, 5242880, array['image/jpeg', 'image/png']) on conflict (id) do update set public = excluded.public, file_size_limit = excluded.file_size_limit, allowed_mime_types = excluded.allowed_mime_types; -- ---- módulos instalados são reaplicados, depois de toda tabela do núcleo (migration 0340) ---- -- -- A provisionadora de cada módulo instalado roda de novo, sobre o núcleo já -- atualizado. Falha de um módulo NÃO derruba este comando: ele marca o módulo -- `suspenso` e a marca se confirma sozinha (o kit aplica sem transação única). -- Vem ANTES das proteções e das travas, para que tabela recriada aqui passe por elas. do $f$ begin perform public.fn_reaplicar_modulos_instalados(); end $f$; -- ---- proteção de tabela de organização, depois de toda tabela (migration 0325) ---- -- -- Auto-curativa e no-op hoje (as 119 tabelas de organização deste baseline já -- têm RLS ligada — medido, e cobrado por -- tests/invariants/rls-completude-varredura.test.ts). Ela existe para o dia em -- que um apêndice novo, ou a provisionadora de um módulo, criar tabela de -- organização sem as proteções: a cura acontece no MESMO run em que o defeito -- nasceria. Vem ANTES da chamada da 0274 de propósito — as travas do suporte -- leem o privilégio de `authenticated` de cada tabela, então precisam ver a -- tabela já com RLS e isolamento. do $f$ begin perform public.fn_proteger_tabelas_de_organizacao(); end $f$; -- ---- travas do modo somente leitura do suporte, depois de toda tabela (migration 0274) ---- -- -- ⚠️ ESTA CHAMADA É O ÚLTIMO BLOCO DO ARQUIVO. Tabela nova, coluna -- `organization_id` nova, RLS ligada ou grant a `authenticated` entram ANTES -- dela: é o que faz a primeira aplicação do arquivo chegar ao mesmo conjunto de -- travas que a segunda. Vigiado, com o baseline aplicado UMA vez, por -- tests/invariants/travas-de-suporte-cobrem-toda-tabela-na-instalacao.test.ts. -- A definição da função está antes da varredura de anon. do $f$ begin perform public.fn_aplicar_travas_de_suporte(); end $f$; -- ---- Catálogo da DeepSeek (migration 0342) ---- -- -- O próximo provedor que a abertura de vocabulário da 0127 existia para -- destravar: OpenAI-compatível e com desconto automático de prefixo de cache. -- Ids e preços verificados no provedor (`GET /models`; docs oficiais em dólares -- por 1M). Preço em CENTAVOS por milhão — entrada (cache miss) 14, saída 28; o -- cache hit (0,28¢/1M) não cabe no integer do catálogo e é desconto de -- cobrança, não preço de tabela. `ai_pricing` acompanha para o orçamento somar -- com o mesmo número. Sem `is_default_for_provider`: a escolha cai no mais -- barato com ferramentas, como na OpenRouter. insert into public.ai_models (provider, model_id, display_name, description, input_price_per_million_cents, output_price_per_million_cents, supports_tools) values ('deepseek', 'deepseek-flash', 'DeepSeek Flash', 'O mais barato da DeepSeek, para atendimento de volume. Tem desconto automático do trecho repetido da conversa.', 14, 28, true), ('deepseek', 'deepseek-v4-pro', 'DeepSeek V4 Pro', 'O mais capaz da linha v4, para conversas que exigem raciocínio. Também desconta o trecho repetido da conversa.', 44, 87, true) on conflict (provider, model_id) do update set display_name = excluded.display_name, description = excluded.description, input_price_per_million_cents = excluded.input_price_per_million_cents, output_price_per_million_cents = excluded.output_price_per_million_cents, supports_tools = excluded.supports_tools; insert into public.ai_pricing (model, prompt_cents_per_million_tokens, completion_cents_per_million_tokens, notes) values ('deepseek-flash', 14, 28, 'catálogo 0342 — cache hit 0,28¢/1M não cabe no catálogo'), ('deepseek-v4-pro', 44, 87, 'catálogo 0342 — cache hit 0,28¢/1M não cabe no catálogo') on conflict (model) do update set prompt_cents_per_million_tokens = excluded.prompt_cents_per_million_tokens, completion_cents_per_million_tokens = excluded.completion_cents_per_million_tokens, notes = excluded.notes, superseded_at = null; -- ---- Catálogo da Requesty (migration 0410) ---- -- -- Roteador OpenAI-compatível, como a OpenRouter: ids `fabricante/modelo` -- verificados em `GET https://router.requesty.ai/v1/models`, preço do mesmo -- endpoint convertido para CENTAVOS por milhão. `supports_vision` entra junto -- porque num roteador é o catálogo que diz se o modelo enxerga imagem. Não -- insere em `ai_pricing`; o backfill 0113 acima cria a linha por `model_id` na -- próxima reaplicação (update.sh), e o preço é resolvido só por `model_id`, -- sem provider. Racional inteiro na migration 0410. insert into public.ai_models (provider, model_id, display_name, description, context_window, input_price_per_million_cents, output_price_per_million_cents, supports_tools, supports_vision) values ('requesty', 'openai/gpt-4o-mini', 'GPT-4o mini (Requesty)', 'Barato e rápido, bom para atendimento de volume. Enxerga imagem.', 128000, 15, 60, true, true), ('requesty', 'openai/gpt-4.1-mini', 'GPT-4.1 mini (Requesty)', 'Segue instruções melhor que o 4o mini, com contexto longo. Enxerga imagem.', 1047576, 40, 160, true, true), ('requesty', 'google/gemini-2.5-flash', 'Gemini 2.5 Flash (Requesty)', 'Contexto muito longo e custo baixo. Enxerga imagem.', 1048576, 30, 250, true, true), ('requesty', 'anthropic/claude-haiku-4-5', 'Claude Haiku 4.5 (Requesty)', 'Rápido, para atendimentos curtos e classificação. Enxerga imagem.', 200000, 100, 500, true, true), ('requesty', 'anthropic/claude-sonnet-4-5', 'Claude Sonnet 4.5 (Requesty)', 'O que melhor segue instruções longas e usa as ferramentas do CRM. Enxerga imagem.', 1000000, 300, 1500, true, true) on conflict (provider, model_id) do update set display_name = excluded.display_name, description = excluded.description, context_window = excluded.context_window, input_price_per_million_cents = excluded.input_price_per_million_cents, output_price_per_million_cents = excluded.output_price_per_million_cents, supports_tools = excluded.supports_tools, supports_vision = excluded.supports_vision; -- ---- menu lateral por EMPRESA (migration 0367, issue #1341) ---- -- -- `organizations.interface_settings` é a escolha da EMPRESA: o universo de portas -- da instalação, com a mesma forma da escolha por vínculo da 0221. Entra aqui -- para a instalação nova (e para a reaplicação do baseline) já nascer com a -- coluna; a leitura resolve EMPRESA ∩ VÍNCULO ∩ papel, e o `default` deixa toda -- organização que não mexer em nada exatamente como estava. alter table public.organizations add column if not exists interface_settings jsonb not null default '{"preset":"completa"}'::jsonb; do $$ begin if not exists ( select 1 from pg_constraint where conrelid = 'public.organizations'::regclass and conname = 'organizations_interface_settings_shape' ) then alter table public.organizations add constraint organizations_interface_settings_shape check ( jsonb_typeof(interface_settings) = 'object' and interface_settings ? 'preset' and interface_settings->>'preset' in ('completa', 'simplificada') and ( not interface_settings ? 'destinos' or ( jsonb_typeof(interface_settings->'destinos') = 'array' and interface_settings->'destinos' <> '[]'::jsonb ) ) ); end if; end $$; -- ---- índice de cooldown do gatilho de silêncio (migration 0481) ---- -- -- Racional inteiro na migration 0481: a consulta de cooldown de -- `loadContactIdsEmCooldown` (lib/followup/silence-sweep.ts) filtra -- `followup_enrollments` por (organization_id, pointer_id, contact_id, -- updated_at) a cada tick do cron, e não havia índice cobrindo `pointer_id`. create index if not exists idx_followup_enrollments_pointer_contact_cooldown on public.followup_enrollments (organization_id, pointer_id, contact_id, updated_at); -- ---- ponteiros legados de skills (migration 0422) ---- alter table public.skill_pointers add column if not exists name text; alter table public.skill_pointers add column if not exists version_id uuid; do $reconciliar_skill_pointers$ begin if exists ( select 1 from information_schema.columns where table_schema = 'public' and table_name = 'skill_pointers' and column_name = 'slug' ) then execute $sql$ update public.skill_pointers set name = slug where name is null and slug is not null $sql$; end if; if exists ( select 1 from information_schema.columns where table_schema = 'public' and table_name = 'skill_pointers' and column_name = 'active_version_id' ) then execute $sql$ update public.skill_pointers set version_id = active_version_id where version_id is null and active_version_id is not null $sql$; end if; end $reconciliar_skill_pointers$; do $skill_pointer_fk$ begin if not exists ( select 1 from pg_constraint where conrelid = 'public.skill_pointers'::regclass and conname = 'skill_pointers_version_id_fkey' ) then alter table public.skill_pointers add constraint skill_pointers_version_id_fkey foreign key (version_id) references public.skill_versions(id) not valid; end if; end $skill_pointer_fk$; create unique index if not exists uniq_skill_pointers_org on public.skill_pointers (organization_id, name) where organization_id is not null; create unique index if not exists uniq_skill_pointers_platform on public.skill_pointers (name) where organization_id is null; -- ---- versões imutáveis de skills sobrevivem ao ponteiro legado (migration 0424) ---- do $skill_versions_legadas$ begin if to_regclass('public.skill_versions') is null or not exists ( select 1 from information_schema.columns where table_schema = 'public' and table_name = 'skill_versions' and column_name = 'pointer_id' ) then return; end if; alter table public.skill_versions alter column pointer_id drop not null; alter table public.skill_versions drop constraint if exists skill_versions_pointer_id_fkey; alter table public.skill_versions add constraint skill_versions_pointer_id_fkey foreign key (pointer_id) references public.skill_pointers(id) on delete set null; end $skill_versions_legadas$; -- ---- hardening de função de gatilho legada (migration 0423) ---- -- Alguns bancos antigos ainda têm esta função, embora ela não faça parte de -- um install fresco. O search_path fixo elimina a resolução influenciável pela -- sessão; a guarda evita falha onde ela já não existe. do $$ begin if to_regprocedure('public.fn_espelha_nome_e_name()') is not null then alter function public.fn_espelha_nome_e_name() set search_path = public, pg_temp; end if; end $$; -- ---- módulo suspenso vira ERRO que o kit reporta (migration 0340) ---- -- -- Um comando SEPARADO da reaplicação, de propósito: se ela relançasse, a marca -- de suspenso seria desfeita junto. Aqui o ERROR sai com texto que não casa com -- a lista de erros benignos do update.sh, então a atualização não diz -- "atualizado" com módulo fora do ar. Instalação nova não tem módulo: no-op. do $f$ begin perform public.fn_conferir_modulos_instalados(); end $f$; -- ---- retomada de negócio encerrado guarda a cadeia de tentativas (migration 0425) ---- -- -- Aditiva e idempotente: a coluna nasce null em toda linha existente, a FK é -- `on delete set null` (apagar um negócio solta o ponteiro da tentativa nova, em -- vez de recusar a exclusão ou propagá-la) e o índice é parcial — só a linha que -- aponta para alguém é consultada pela cadeia "tentativas até ganhar". alter table public.crm_leads add column if not exists retomado_de_lead_id uuid; do $$ begin if not exists ( select 1 from pg_constraint where conname = 'fk_crm_leads_retomado_de_lead' and conrelid = 'public.crm_leads'::regclass ) then alter table public.crm_leads add constraint fk_crm_leads_retomado_de_lead foreign key (retomado_de_lead_id) references public.crm_leads(id) on delete set null; end if; end $$; create index if not exists idx_crm_leads_retomado_de_lead on public.crm_leads (retomado_de_lead_id) where retomado_de_lead_id is not null; -- ---- em que etapa o negócio morreu (migration 0426, #1537) ---- -- -- Aditiva e idempotente: a coluna nasce `null` em toda linha existente (nenhuma -- perda anterior tem origem registrada — derivar retroativamente seria inventar -- dado) e a FK é `on delete set null` (apagar a etapa solta o ponteiro em vez de -- recusar a exclusão do funil). Quem PREENCHE é o gatilho -- `fn_crm_lead_close_on_stage`, redefinido acima no corpo que o banco usa. alter table public.crm_leads add column if not exists lost_from_stage_id uuid; do $$ begin if not exists ( select 1 from pg_constraint where conname = 'fk_crm_leads_lost_from_stage' and conrelid = 'public.crm_leads'::regclass ) then alter table public.crm_leads add constraint fk_crm_leads_lost_from_stage foreign key (lost_from_stage_id) references public.crm_stages(id) on delete set null; end if; end $$; -- ---- probabilidade de ganho por etapa (migration 0427) ---- -- -- Aditiva e idempotente: a coluna nasce null em toda linha existente, e null -- significa "esta etapa não tem probabilidade calibrada" — que a regra de -- previsão reporta à parte (balde "sem probabilidade"), nunca some como zero -- em silêncio. `is_won`/`is_lost` valem 100 e 0 na regra -- (`lib/leads/previsao.ts`), não gravado: gravar aqui seria um segundo lugar -- para a mesma verdade divergir. alter table public.crm_stages add column if not exists win_probability smallint; alter table public.crm_stages drop constraint if exists crm_stages_win_probability_range; alter table public.crm_stages add constraint crm_stages_win_probability_range check (win_probability is null or win_probability between 0 and 100); -- ---- a etapa que avisa a equipe na Central (migration 0440) ---- -- -- Marca por etapa, desligada por padrão: negócio que ENTRA numa etapa marcada -- abre um aviso na Central (kind `other`, ref `lead`, botão «Abrir negócio»). -- Quem lê é `lib/leads/aviso-de-etapa.handler.ts`, no evento -- `lead.stage_changed`. Aditiva e idempotente: coluna com default, nenhuma -- linha existente a corrigir antes. alter table public.crm_stages add column if not exists avisar_na_central boolean not null default false; comment on column public.crm_stages.avisar_na_central is 'Negócio que entra nesta etapa abre um aviso na Central de avisos (0440).'; notify pgrst, 'reload schema'; -- ---- o bucket dos sons dos avisos da Central (migration 0441) ---- -- Privado; só o service_role lê e grava, pela rota `app/api/v1/settings/sons`. -- Teto e tipos são os de `lib/notifications/sons-da-org.ts` (1 MB, MP3/OGG/WAV), -- conferidos por `tests/invariants/sons-dos-avisos.test.ts`. Sem policy em -- `storage.objects`. Idempotente. insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types) values ('org-sounds', 'org-sounds', false, 1048576, array['audio/mpeg', 'audio/ogg', 'audio/wav']) on conflict (id) do update set public = excluded.public, file_size_limit = excluded.file_size_limit, allowed_mime_types = excluded.allowed_mime_types; -- ---- ícone da aba: coluna da instalação (migration 0443) ---- -- 0443 — Ícone da aba (favicon) opcional da instalação, subido em /admin/marca. -- Aditiva: sem arquivo, a aba segue com o ícone desenhado por `app/icon.tsx` -- (cor + inicial). Rollback de imagem não exige apagar coluna nem arquivo: o -- código anterior não lê a coluna e volta ao ícone desenhado. -- Só a instalação tem ícone: a aba é a mesma para todas as organizações, e o -- login (anterior a qualquer organização) também a mostra. -- Somente a rota `/api/v1/marca/logo` escreve o caminho, no mesmo bucket -- `brand-logos` e sob o mesmo prefixo `platform/` do logo. alter table public.platform_branding add column if not exists favicon_path text; update public.platform_branding set favicon_path = null where favicon_path is not null and favicon_path !~ '^platform/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(png|jpg)$'; alter table public.platform_branding drop constraint if exists platform_branding_favicon_path; alter table public.platform_branding add constraint platform_branding_favicon_path check ( favicon_path is null or favicon_path ~ '^platform/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(png|jpg)$' ); comment on column public.platform_branding.favicon_path is 'Ícone da aba do navegador, subido pela tela. Caminho em brand-logos; null mantém o ícone desenhado (cor + inicial).'; -- ---- notas internas: realtime + visibilidade herdada da conversa (migration 0478, #1863) ---- -- F1: `conversation_notes` estava FORA da publicação supabase_realtime. O hook -- (`hooks/inbox/useConversationNotes.ts`) abria o canal, o Supabase respondia -- `SUBSCRIBED` e nenhum evento chegava — falha muda, sem erro. A anotação só -- aparecia para os demais quando alguém recarregava. Mesmo desenho idempotente -- do `foreach` lá acima: checa `pg_publication_tables` antes de adicionar. do $$ begin if not exists ( select 1 from pg_publication_tables where pubname = 'supabase_realtime' and schemaname = 'public' and tablename = 'conversation_notes' ) then execute 'alter publication supabase_realtime add table public.conversation_notes'; end if; end $$; comment on table public.conversation_notes is 'Nota interna da conversa (Onda 5.2) — anotação de procedimento interno que NUNCA vai ao cliente. Realtime desde a 0478 (o canal assinava e não recebia nada); visibilidade herdada da conversa desde a 0478, igual a ai_reply_drafts.'; -- F2: a policy testava só `fn_user_org_ids()`, enquanto `fn_can_view_conversation` -- (21 usos) é quem implementa `visibility_mode`. Em `own_and_unassigned`, o -- atendente que não abria a conversa lia as notas dela, e quem perdeu a -- conversa continuava vendo. Molde: `tenant_isolation_ai_reply_drafts_all`. drop policy if exists "conversation_notes_select" on public.conversation_notes; create policy "conversation_notes_select" on public.conversation_notes for select using ( organization_id in (select public.fn_user_org_ids()) and exists ( select 1 from public.conversations c where c.organization_id = conversation_notes.organization_id and c.id = conversation_notes.conversation_id and public.fn_can_view_conversation(c.organization_id, c.assigned_to_user_id) ) ); -- ⚠️ A política de ESCRITA precisa da MESMA condição: policies são OR e -- `conversation_notes_write` é `for all`, que concede SELECT junto — sem isto -- a policy nova de SELECT é anulada. O teste `F2: ... não lê a nota` pegou -- exatamente isso (devolveu 1 em vez de 0) antes do conserto. drop policy if exists "conversation_notes_write" on public.conversation_notes; create policy "conversation_notes_write" on public.conversation_notes for all using ( organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'agent') and exists ( select 1 from public.conversations c where c.organization_id = conversation_notes.organization_id and c.id = conversation_notes.conversation_id and public.fn_can_view_conversation(c.organization_id, c.assigned_to_user_id) ) ) with check ( organization_id in (select public.fn_user_org_ids()) and public.fn_role_at_least(organization_id, 'agent') and exists ( select 1 from public.conversations c where c.organization_id = conversation_notes.organization_id and c.id = conversation_notes.conversation_id and public.fn_can_view_conversation(c.organization_id, c.assigned_to_user_id) ) ); -- O ramo `or fn_is_platform_admin()` da política antiga vira policy própria: -- o admin de plataforma não é membro de organização nenhuma por definição. drop policy if exists "conversation_notes_select_platform_admin" on public.conversation_notes; create policy "conversation_notes_select_platform_admin" on public.conversation_notes for select using (public.fn_is_platform_admin()); -- ---- busca humana na telemetria: author_kind (migration 0484) ---- -- 0484 — a busca HUMANA do acervo entra na telemetria (F2 da #1869). -- `knowledge_searches` só recebia o caminho do agente; o grafico de -- `/app/ai/evolution` conta linhas sem filtrar, entao a linha humana -- aparece sozinha — mas so se alguem gravar. -- `author_kind` segue o vocabulario da 0281 ('human','ai'); NAO usa -- `agent_id is null`, que e `on delete set null` desde a 0181 e nao -- distingue 'foi o operador' de 'o agente foi apagado depois'. -- DEFAULT 'ai' e o proprio backfill: toda linha existente veio do -- agente. Sem check acoplando author_user_id: quebraria o set null. alter table public.knowledge_searches add column if not exists author_kind text not null default 'ai' check (author_kind in ('human', 'ai')); alter table public.knowledge_searches add column if not exists author_user_id uuid references auth.users(id) on delete set null; comment on column public.knowledge_searches.author_kind is 'Quem perguntou: ''ai'' = turno do agente ou a ferramenta MCP crm_search_knowledge; ''human'' = o operador na tela "Perguntar ao acervo" (F1 da #1869). Vocabulário da 0281.'; comment on column public.knowledge_searches.author_user_id is 'Operador que perguntou no caminho humano. null no caminho do agente, e também quando a pessoa sai do sistema — on delete set null preserva a pergunta, por isso não há check acoplando esta coluna à author_kind.'; -- ---- playbook `agendamento` v3: a cadeia de dois passos (migration 0486) ---- do $pub$ declare -- md5 do corpo abaixo. Conferido logo após o insert — ver item 2 do cabeçalho. v_md5 constant text := '73c66800b7d64797252795b708b26cb3'; v_id uuid; begin select id into v_id from skill_versions where organization_id is null and name = 'agendamento' and md5(body) = v_md5 limit 1; if v_id is null then insert into skill_versions (organization_id, name, description, body, matcher) values ( null, 'agendamento', 'Playbook pra marcar/remarcar horário (consulta, visita, sessão) — consulta a agenda real em dois passos (tipos e depois horários) pelas ferramentas quando elas existem, nunca inventa disponibilidade, e confirma por escrito antes de fechar.', $body$# Playbook: marcar horário/agendamento ## Quando usar O lead pede pra marcar um horário, consulta, visita, demonstração ou sessão — qualquer compromisso com data/hora. Comum em clínicas, imobiliárias (visitas), serviços e consultorias. ## Regra de ouro: consulte a agenda, não adivinhe Você tem acesso à agenda **se, e somente se**, a ferramenta `crm_find_free_slots` estiver disponível para você. Não julgue isso por intuição — chame e leia a resposta. Se `crm_list_event_types` também estiver na sua mão, a consulta é de DOIS passos, e os dois no MESMO TURNO: ela devolve os tipos de atendimento da empresa com o `slug` de cada um, e só então `crm_find_free_slots` consulta horários DESSE tipo, com o `event_type_slug` que veio da lista. Parar depois da lista e responder "vou verificar" é o defeito — a lista é o começo da conversa com a agenda, não a resposta. Nunca invente nem traduza um `slug`: se o tipo que o lead pediu não está na lista, diga o que existe em vez de verificar o que não existe. - Voltou com horários → ofereça 2 ou 3 deles, concretos. - Voltou `publicou_horarios: false` → o atendente ainda não publicou os horários de trabalho dele. Isso NÃO é "está lotado" e NÃO é "não tem vaga": não invente horário, não diga que a agenda está cheia, e avise que alguém da equipe confirma. - Voltou com `motivo` → leia a `mensagem` e faça o que ela manda. Ela foi escrita para o cliente ouvir. - Voltou `fuso_suposto: true` → o fuso da agenda veio do padrão e ninguém confirmou. Ofereça pedindo confirmação — "consigo terça às 14h; confere se esse horário bate aí pra você?" — em vez de afirmar. - Você não tem essa ferramenta → aí sim: não ofereça horário nenhum, diga que vai confirmar a disponibilidade e sinalize handoff para quem tem acesso. Prometer um horário que depois não existe quebra confiança e gera reagendamento forçado. Inventar é pior do que demorar um instante a mais para responder. ## Fluxo padrão (if-then) **1. Identifique o serviço/motivo antes de oferecer horário** - SE o lead só disse "quero agendar" sem contexto → pergunte o motivo/serviço primeiro. Agendar sem saber o quê gera erro de encaixe (ex.: consulta de 20min marcada num slot de 1h de procedimento). - SE você ainda não tem o `slug` desse serviço e `crm_list_event_types` está na sua mão → chame-a e escolha o tipo pelo que o lead descreveu; é dela que sai o `event_type_slug` do passo seguinte. **2. Ofereça opções fechadas, não uma pergunta aberta** - SE o tipo já está na lista mas horário nenhum foi consultado ainda → chame `crm_find_free_slots` com o `event_type_slug` dele ANTES de responder. - SE `crm_find_free_slots` respondeu com horários → ofereça 2-3 concretos ("tenho terça 14h ou quarta 10h, qual funciona?"). Pergunta aberta tipo "qual horário você prefere?" gera ida e volta desnecessária e trava a conversa. - SE você não tem a ferramenta → não invente. Diga algo como "vou confirmar a disponibilidade e te retorno em instantes" e sinalize handoff/task pra quem tem acesso. **3. Colete os dados obrigatórios antes de confirmar** - Nome completo do lead (ou confirme o que já está no CRM). - Serviço/motivo específico. - Unidade/local, se o tenant tiver mais de uma (clínica com filiais, imobiliária com múltiplos imóveis). - Se for reagendamento, o horário anterior a ser substituído. **4. Confirme por escrito antes de encerrar** - SE o lead escolheu um horário e `crm_book_appointment` está na sua mão → grave de verdade com ela, usando o `starts_at` que `crm_find_free_slots` devolveu, sem reescrever, e SÓ ENTÃO repita por escrito. Horário oferecido e não marcado não é reserva — é ele que gera reagendamento forçado. - SE o lead aceitar um horário → repita de volta por escrito: "Confirmado: [serviço] dia [data] às [hora], em [local]. Confirma pra mim?" - Só considere o agendamento fechado depois do "sim"/confirmação explícita do lead — silêncio ou "ok" vago não é confirmação suficiente pra compromissos com custo de no-show alto (ex. consulta médica, visita a imóvel). **5. Reagendamento e cancelamento** - SE o lead pedir pra remarcar E você tem `crm_reschedule_appointment` → use ela. NÃO cancele e marque de novo: é o MESMO compromisso mudando de hora. O histórico continua um só e o lembrete é refeito sozinho para o horário novo. - SE o lead pedir pra remarcar e você NÃO tem essa ferramenta → então cancelar e marcar de novo é o único caminho, e ele tem um custo que você precisa administrar: o cliente pode receber dois avisos seguidos e contraditórios ("desmarcado" e depois "marcado"). Antes de fazer, diga a ele em uma frase o que vai acontecer — "vou desmarcar o horário antigo e já marcar o novo, você pode receber dois avisos" — e nunca deixe os dois compromissos de pé ao mesmo tempo. - SE o lead pedir pra cancelar → use `crm_cancel_appointment` se você a tiver, informe o motivo, e pergunte se quer remarcar pra outra data, sem pressionar. Cancelar libera aquele horário para outra pessoa e não dá para desfazer: confirme antes. **6. Risco de no-show** - Se o negócio tiver política de confirmação D-1 documentada na base de conhecimento, siga-a (ex.: mensagem de lembrete automática). Se não houver, não invente política — apenas confirme o agendamento normalmente. ## Regras duras - Nunca confirme horário sem ter checado disponibilidade real (ou sem sinalizar que ainda vai confirmar). - Nunca marque dois compromissos conflitantes pro mesmo lead sem avisar. - Se o lead pedir um horário fora do funcionamento do negócio (ex. domingo, madrugada) e isso não estiver nas regras do tenant, não confirme — explique a janela real de atendimento. - Dado sensível (endereço completo, documento) só é coletado se o fluxo do tenant realmente exigir — não peça informação a mais que o agendamento precisa. - Marcar consulta e agendar retorno são coisas DIFERENTES. `crm_book_appointment` é para hora combinada COM o cliente, que ele reservou e vai comparecer — alguém espera por ele. `crm_schedule_followup` é decisão interna nossa de voltar a falar: o cliente não fica sabendo e nada é reservado na agenda de ninguém. Se ele ESCOLHEU um horário para ser atendido, é a primeira. ## Exemplos de resposta (tom, não copiar literal) - "Pra eu te encaixar certo: é pra qual serviço/motivo?" - "Tenho quinta às 15h ou sexta às 9h — qual fica melhor pra você?" - "Confirmado: consulta dia 28/07 às 15h, na unidade Centro. Pode confirmar pra mim?" ## O que NÃO fazer - Não pergunte "qual horário você prefere?" sem oferecer opções concretas quando você tem a agenda. - Não confirme agendamento sem resposta explícita do lead. - Não invente disponibilidade que você não checou.$body$, '{"any_keywords": ["agendar", "marcar horário", "marcar consulta", "marcar uma visita", "agenda", "que horas vocês", "horário disponível", "remarcar", "reagendar", "cancelar o horário", "desmarcar"], "probe_keywords": ["que horas", "qual dia", "tem vaga", "disponibilidade"]}'::jsonb ) returning id into v_id; if (select md5(body) from skill_versions where id = v_id) is distinct from v_md5 then raise exception 'playbook agendamento: o md5 declarado (%) nao corresponde ao corpo inserido. Recalcule antes de publicar.', v_md5; end if; end if; -- Repointe SEMPRE. O ponteiro global e unico por nome (uniq_skill_pointers_platform, -- parcial em organization_id is null), entao update-senao-insert e seguro e nao depende -- de inferencia de conflito sobre indice parcial. update skill_pointers set version_id = v_id, updated_at = now() where organization_id is null and name = 'agendamento'; if not found then insert into skill_pointers (organization_id, name, version_id) values (null, 'agendamento', v_id); end if; end $pub$; -- ---- janela de RESPOSTA separada da janela de DISPARO (migration 0495) ---- -- O agente passa a poder responder a quem escreveu fora do horário comercial sem -- abrir junto o disparo em massa, a prospecção e a retomada de conversa parada. -- As duas coisas eram regidas por UM par (`window_start_hour`/`window_end_hour`). -- -- Colunas soltas, não jsonb: `window_*_hour` é coluna desde a 0010 e a tela de -- Conexões já os edita; um `resposta_knobs` jsonb nasceria sem CHECK forte e -- divergiria do vizinho na mesma tabela. -- -- ⚠️ Sem DEFAULT: NULL = a resposta herda a janela de disparo, coluna a coluna, -- que é o comportamento de antes. Quem só atualiza não muda de operação. -- A primeira versão desta migration (PR #1983, fechado sem merge) chamava as -- colunas `reengajar_*`. Quem já a aplicou tem os dados lá: renomeia em vez de -- criar coluna nova ao lado, e a constraint de nome velho sai junto. do $renomear_reengajar$ begin if exists (select 1 from information_schema.columns where table_schema = 'public' and table_name = 'channel_knobs' and column_name = 'reengajar_start_hour') and not exists (select 1 from information_schema.columns where table_schema = 'public' and table_name = 'channel_knobs' and column_name = 'resposta_start_hour') then alter table public.channel_knobs rename column reengajar_start_hour to resposta_start_hour; end if; if exists (select 1 from information_schema.columns where table_schema = 'public' and table_name = 'channel_knobs' and column_name = 'reengajar_end_hour') and not exists (select 1 from information_schema.columns where table_schema = 'public' and table_name = 'channel_knobs' and column_name = 'resposta_end_hour') then alter table public.channel_knobs rename column reengajar_end_hour to resposta_end_hour; end if; end $renomear_reengajar$; alter table public.channel_knobs drop constraint if exists channel_knobs_reengajar_horas_validas; alter table public.channel_knobs add column if not exists resposta_start_hour smallint, add column if not exists resposta_end_hour smallint; comment on column public.channel_knobs.resposta_start_hour is 'Início da janela de RESPOSTA do agente (h, hora local da org). NULL = usa window_start_hour (comportamento anterior).'; comment on column public.channel_knobs.resposta_end_hour is 'Fim da janela de RESPOSTA do agente (h, exclusivo; 24 = meia-noite). NULL = usa window_end_hour.'; -- 0..24. `end` pode ser 24 (meia-noite seguinte) porque `insideWindow` compara -- `wall.h < windowEndHour` e a hora local nunca passa de 23. -- ⚠️ O `drop … if exists` ANTES do `add` é o que torna isto reaplicável: o -- `update.sh` roda o apêndice inteiro em toda atualização, e `add constraint` -- sem guarda quebra com 'already exists' no segundo clone que atualizar. É o -- gate `tests/unit/baseline-reaplicavel.test.ts` que cobra esta forma. alter table public.channel_knobs drop constraint if exists channel_knobs_resposta_horas_validas; alter table public.channel_knobs add constraint channel_knobs_resposta_horas_validas check ( (resposta_start_hour is null or resposta_start_hour between 0 and 23) and (resposta_end_hour is null or resposta_end_hour between 1 and 24) ); -- ---- atraso humano configurável por conexão (migration 0499) ---- -- 0499 — os quatro números do atraso humano ANTES da primeira bolha -- (`atraso-humano.ts`) viram knob por conexão (issue #653): NOTAR, POR_CARACTERE, -- MINIMO e MAXIMO, todos NULL = default em defaults.ts (900/22/1200/7500 — os -- valores de antes, regressão zero). Nascem em `channel_knobs`, a mesma tabela -- dos knobs de pacing por conexão (0010 e 0495); nada de tabela nova. O jitter -- entre bolhas não ganha coluna: ele já é `throttle_ms` + `jitter_max_ms`. -- Reaplicável: `add column if not exists` e `drop constraint if exists` antes -- do `add constraint` — o `update.sh` roda o apêndice inteiro a cada atualização. alter table public.channel_knobs add column if not exists atraso_notar_ms integer, add column if not exists ms_por_caractere integer, add column if not exists atraso_minimo_ms integer, add column if not exists atraso_maximo_ms integer; comment on column public.channel_knobs.atraso_notar_ms is 'Parcela fixa do atraso humano (ms): ver a notificação e abrir a conversa. NULL = default (900).'; comment on column public.channel_knobs.ms_por_caractere is 'Taxa de digitação do atraso humano (ms por caractere). NULL = default (22).'; comment on column public.channel_knobs.atraso_minimo_ms is 'Piso do atraso humano (ms). NULL = default (1200).'; comment on column public.channel_knobs.atraso_maximo_ms is 'Teto do atraso humano (ms). NULL = default (7500).'; alter table public.channel_knobs drop constraint if exists channel_knobs_atraso_humano_saneamento; alter table public.channel_knobs add constraint channel_knobs_atraso_humano_saneamento check ( (atraso_notar_ms is null or (atraso_notar_ms between 0 and 600000)) and (ms_por_caractere is null or (ms_por_caractere between 0 and 1000)) and (atraso_minimo_ms is null or (atraso_minimo_ms between 0 and 600000)) and (atraso_maximo_ms is null or (atraso_maximo_ms between 0 and 600000)) and (atraso_maximo_ms is null or atraso_minimo_ms is null or atraso_maximo_ms >= atraso_minimo_ms) ); -- ---- dedupe de event_dead atômico: índice único parcial (migration 0491) ---- -- 0491 — o aviso `event_dead` não abre em dobro com dois drenos concorrentes -- (issue #880). O dedupe era uma pergunta seguida de uma escrita: `lib/event-log/ -- drain.ts` consulta "já existe um aviso aberto?" e depois insere, e o `insert … -- where not exists` de `insertInboxItem` (`lib/agent-engine/db/repository.ts`) -- juntava as duas numa instrução sem índice nenhum que sustentasse a condição. -- O cron `event-log-drain` e o drain-loop do worker rodam `drainEventLog` ao -- mesmo tempo: os dois leem "não existe" antes de qualquer escrita e os dois -- inserem — dois avisos idênticos para o mesmo problema. -- -- A chave é (organização, kind, TÍTULO): `event_dead` tem duas famílias que -- precisam conviver abertas na mesma organização (o da IA que deixou de -- responder e o de mídia/automação — `lib/event-log/aviso-de-evento-morto.ts`), -- então (organização, kind) sozinho recusaria a segunda. O predicado é PARCIAL -- (`where status = 'open'`): na chave, `status` guardaria UMA linha resolvida -- para sempre e a reabertura morreria no segundo ciclo. Escopo só `event_dead` -- — os outros dedupes da tabela querem várias linhas abertas com o mesmo -- título, uma por conversa ou por lead. -- -- Prévia: as cópias abertas repetidas são RESOLVIDAS (só a mais antiga fica -- aberta), nunca apagadas, como a 0064 mandava. Idempotente nas duas pontas. with repetidas as ( select id, row_number() over ( partition by organization_id, kind, title order by created_at asc, id asc ) as ordem from public.agent_inbox_items where status = 'open' and kind = 'event_dead' ) update public.agent_inbox_items i set status = 'resolved', resolved_at = now() from repetidas r where i.id = r.id and r.ordem > 1; create unique index if not exists agent_inbox_event_dead_aberto_unico on public.agent_inbox_items (organization_id, kind, title) where status = 'open' and kind = 'event_dead'; -- ---- janela de rajada configurável por agente (migration 0498) ---- -- 0498 (#1856, de @webtecnica): a janela que junta mensagens do MESMO contato -- numa resposta sai da env global `INBOUND_DEBOUNCE_MS` e vira campo da versão -- do agente. NULL = usa a env da instalação (quem só atualiza não muda nada); -- 0 desliga; teto 60s. Par drop/add da CHECK para o `update.sh` reaplicar sem -- 'already exists'. Sem função nova (nada a revogar de anon). alter table public.ai_agent_versions drop constraint if exists ai_agent_versions_inbound_debounce_ms_check; alter table public.ai_agent_versions add column if not exists inbound_debounce_ms integer; comment on column public.ai_agent_versions.inbound_debounce_ms is 'Janela de coalescência de rajada inbound em ms para ESTE agente. NULL = usa o INBOUND_DEBOUNCE_MS da instalação; 0 = desliga a coalescência; teto 60s.'; alter table public.ai_agent_versions add constraint ai_agent_versions_inbound_debounce_ms_check check (inbound_debounce_ms is null or (inbound_debounce_ms >= 0 and inbound_debounce_ms <= 60000));