@fayz-ai/plugin-conversations 0.12.2 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,7 @@ export declare const MIGRATION_001_A_CAIXA_SABE_QUEM_FALOU_POR_ULTIMO = "-- ----
3
3
  export declare const MIGRATION_002_A_CASA_ESCOLHE_POR_ONDE_O_WHATSAPP_SAI = "-- ---------------------------------------------------------------------------\n-- 002_a_casa_escolhe_por_onde_o_whatsapp_sai.sql \u2014 dois provedores de WhatsApp,\n-- a credencial de cada um, e a escolha de qual est\u00E1 valendo.\n--\n-- \u2500\u2500 por que a credencial precisa morar aqui \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- O `messaging-send` (WP3) pega a chave assim:\n--\n-- const credential = await redeemConnectorCredential({ connectorSlug: 'tyxter', \u2026 })\n--\n-- Quinze linhas que chamam a PLATAFORMA Fayz. Funciona para o Tyxter porque a\n-- chave \u00E9 uma s\u00F3, da FayaLabs, e a plataforma a serve para qualquer inquilino\n-- do projeto. N\u00E3o funciona para o Evolution, e n\u00E3o vai funcionar nunca: o\n-- Evolution \u00E9 auto-hospedado, e cada casa tem a pr\u00F3pria URL e a pr\u00F3pria chave.\n-- Credencial POR INQUILINO n\u00E3o \u00E9 atalho, \u00E9 requisito do segundo provedor.\n--\n-- Um app que n\u00E3o est\u00E1 ligado a projeto Fayz nenhum \u2014 como o FullControl hoje \u2014\n-- tamb\u00E9m n\u00E3o resgata nada. Aqui a chave tem onde ficar nos dois casos.\n--\n-- \u2500\u2500 os dois provedores n\u00E3o s\u00E3o a mesma coisa, e a tabela diz isso \u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- `tyxter` API oficial (Cloud API, via revenda). Iniciar conversa EXIGE\n-- template aprovado pela Meta; texto livre s\u00F3 dentro de 24h da\n-- \u00FAltima mensagem do cliente. Em troca: entrega confi\u00E1vel, sem\n-- risco de banimento, e a Meta responde por n\u00FAmero e qualidade.\n-- `evolution` WhatsApp Web, auto-hospedado. N\u00E3o tem template nem janela de\n-- 24h \u2014 manda o que quiser. Em troca: a sess\u00E3o morre, o n\u00FAmero\n-- pode ser banido, e ningu\u00E9m responde por isso al\u00E9m de voc\u00EA.\n--\n-- `capabilities` guarda essa diferen\u00E7a como DADO e n\u00E3o como `if` espalhado pela\n-- tela. Um bot\u00E3o de \"submeter template\" num provedor que n\u00E3o tem template n\u00E3o\n-- pode existir; a tela l\u00EA daqui para desabilitar em vez de fingir.\n--\n-- \u2500\u2500 o segredo n\u00E3o passa pelo navegador \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- A tabela nega SELECT a `authenticated`. Quem l\u00EA \u00E9 o `service_role`, do lado\n-- do servidor. A tela sabe se EXISTE credencial e quais s\u00E3o os campos n\u00E3o\n-- secretos (a URL do Evolution, o nome da inst\u00E2ncia) por uma view; o segredo\n-- ela nunca v\u00EA, nem para reexibir mascarado.\n-- ---------------------------------------------------------------------------\n\n-- \u2500\u2500 quem est\u00E1 valendo, por inquilino \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nCREATE TABLE IF NOT EXISTS public.plg_conversations_providers (\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n provider text NOT NULL,\n -- N\u00E3o secreto: a URL base do Evolution, o nome da inst\u00E2ncia, o id do n\u00FAmero.\n -- \u00C9 isto que a tela mostra e deixa editar.\n config jsonb NOT NULL DEFAULT '{}'::jsonb,\n -- Secreto. Nenhuma pol\u00EDtica de leitura alcan\u00E7a esta coluna.\n secrets jsonb NOT NULL DEFAULT '{}'::jsonb,\n -- O que este provedor SABE fazer. Lido pela tela para desabilitar o que n\u00E3o\n -- existe, em vez de oferecer um bot\u00E3o que erra.\n capabilities jsonb NOT NULL DEFAULT '{}'::jsonb,\n -- Um s\u00F3 por inquilino fica ativo. O outro continua configurado, pronto para\n -- a troca \u2014 que \u00E9 o pedido: poder alternar sem reconfigurar.\n is_active boolean NOT NULL DEFAULT false,\n status text NOT NULL DEFAULT 'unconfigured',\n status_detail text,\n checked_at timestamptz,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n PRIMARY KEY (tenant_id, provider),\n CONSTRAINT plg_conversations_providers_provider\n CHECK (provider IN ('tyxter', 'evolution')),\n CONSTRAINT plg_conversations_providers_status\n CHECK (status IN ('unconfigured', 'connecting', 'connected', 'error', 'disconnected'))\n);\n\n-- UM ativo por inquilino, garantido pelo banco e n\u00E3o pela tela. Duas telas\n-- abertas em abas diferentes s\u00E3o o caso comum, e \"o \u00FAltimo clique vence\" s\u00F3 \u00E9\n-- verdade se algu\u00E9m impedir o empate.\nCREATE UNIQUE INDEX IF NOT EXISTS plg_conversations_providers_one_active\n ON public.plg_conversations_providers (tenant_id)\n WHERE is_active;\n\nALTER TABLE public.plg_conversations_providers ENABLE ROW LEVEL SECURITY;\nALTER TABLE public.plg_conversations_providers FORCE ROW LEVEL SECURITY;\n\n-- Nenhuma pol\u00EDtica para `authenticated`: o segredo mora numa coluna desta\n-- tabela, e uma pol\u00EDtica de SELECT aqui entregaria a chave ao navegador.\n--\n-- E o REVOKE expl\u00EDcito, porque a aus\u00EAncia de pol\u00EDtica N\u00C3O \u00E9 a \u00FAnica linha de\n-- defesa. O schema `public` deste cluster carrega grants amplos de f\u00E1brica \u2014\n-- medido nesta pr\u00F3pria tabela logo ap\u00F3s cri\u00E1-la: sete privil\u00E9gios j\u00E1\n-- concedidos a `authenticated` sem ningu\u00E9m ter pedido. A RLS for\u00E7ada barra\n-- assim mesmo, mas uma pol\u00EDtica escrita errada num dia ruim \u00E9 tudo o que\n-- separa a chave do navegador. Duas fechaduras.\nREVOKE ALL ON public.plg_conversations_providers FROM PUBLIC, anon, authenticated;\nGRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_conversations_providers TO service_role;\n\n-- \u2500\u2500 o que a tela pode ver \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- Tudo menos o segredo. `has_credential` responde a \u00FAnica pergunta que a tela\n-- precisa fazer sobre ele: j\u00E1 foi posto?\n-- \u2500\u2500 A \u00DANICA VIEW DESTE REPO QUE N\u00C3O \u00C9 `security_invoker` \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- A regra da casa \u00E9 `security_invoker = true`: a view enxerga o que o leitor\n-- enxerga, e a RLS da tabela continua valendo. Aqui isso se autoderrota.\n--\n-- `authenticated` N\u00C3O pode ler `plg_conversations_providers` \u2014 \u00E9 onde o segredo\n-- mora, e o REVOKE logo acima \u00E9 deliberado. Uma view invoker sobre uma tabela\n-- que o chamador n\u00E3o alcan\u00E7a devolve `42501 permission denied`, que foi\n-- exatamente o que a tela recebeu no primeiro teste com um JWT de verdade.\n--\n-- Ent\u00E3o esta view \u00E9 definer POR PROJETO, e paga o pre\u00E7o sendo ela mesma a\n-- fronteira: o `WHERE tenant_id IN (user_tenant_ids())` abaixo n\u00E3o \u00E9\n-- decora\u00E7\u00E3o, \u00E9 a RLS desta view. E a coluna do segredo n\u00E3o est\u00E1 na lista do\n-- SELECT \u2014 o que ela n\u00E3o seleciona n\u00E3o existe para quem l\u00EA.\nDROP VIEW IF EXISTS public.v_conversations_providers;\nCREATE VIEW public.v_conversations_providers AS\n SELECT p.tenant_id,\n p.provider,\n p.config,\n p.capabilities,\n p.is_active,\n p.status,\n p.status_detail,\n p.checked_at,\n -- Nunca o valor. S\u00F3 se existe.\n (p.secrets ? 'api_key') AS has_credential,\n p.created_at,\n p.updated_at\n FROM public.plg_conversations_providers p\n WHERE p.tenant_id IN (SELECT public.user_tenant_ids());\n\nGRANT SELECT ON public.v_conversations_providers TO authenticated;\n\nCOMMENT ON VIEW public.v_conversations_providers IS\n 'Os provedores de WhatsApp da casa, SEM o segredo. `has_credential` diz se a chave foi posta; o valor n\u00E3o sai daqui (002).';\n\n-- \u2500\u2500 p\u00F4r a credencial \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- SECURITY DEFINER porque a tabela nega escrita a `authenticated`: \u00E9 a \u00FAnica\n-- porta, e ela confere o inquilino antes de abrir. `search_path` vazio porque\n-- uma fun\u00E7\u00E3o definer sem isso \u00E9 uma fun\u00E7\u00E3o que o chamador pode redirecionar.\nCREATE OR REPLACE FUNCTION public.conversations_set_provider(\n p_tenant uuid,\n p_provider text,\n p_config jsonb DEFAULT '{}'::jsonb,\n p_api_key text DEFAULT NULL,\n p_capabilities jsonb DEFAULT NULL\n) RETURNS void\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $$\nDECLARE v_caps jsonb;\nBEGIN\n IF p_tenant IS NULL OR NOT (p_tenant IN (SELECT public.user_tenant_ids())) THEN\n RAISE EXCEPTION 'conversations_set_provider: n\u00E3o \u00E9 uma casa sua';\n END IF;\n\n -- O que cada provedor sabe fazer \u00E9 conhecimento do produto, n\u00E3o do usu\u00E1rio.\n -- Quem chama pode sobrescrever (um Evolution atr\u00E1s de proxy pode ganhar\n -- capacidades), mas o padr\u00E3o descreve a verdade de cada um.\n v_caps := coalesce(p_capabilities, CASE p_provider\n WHEN 'tyxter' THEN jsonb_build_object(\n 'templates', true, -- e s\u00E3o OBRIGAT\u00D3RIOS para iniciar conversa\n 'requires_template', true,\n 'session_window_hours', 24, -- texto livre s\u00F3 dentro dela\n 'delivery_receipts', true,\n 'qr_pairing', false,\n 'ban_risk', 'low')\n WHEN 'evolution' THEN jsonb_build_object(\n 'templates', false, -- n\u00E3o existem: manda texto direto\n 'requires_template', false,\n 'session_window_hours', null,\n 'delivery_receipts', true,\n 'qr_pairing', true, -- parear \u00E9 ler um QR, n\u00E3o cadastrar na Meta\n 'ban_risk', 'high') -- e \u00E9 por isso que o aquecimento importa\n ELSE '{}'::jsonb END);\n\n INSERT INTO public.plg_conversations_providers AS t\n (tenant_id, provider, config, capabilities, status,\n secrets)\n VALUES\n (p_tenant, p_provider, coalesce(p_config, '{}'::jsonb), v_caps,\n CASE WHEN p_api_key IS NULL THEN 'unconfigured' ELSE 'connecting' END,\n CASE WHEN p_api_key IS NULL THEN '{}'::jsonb\n ELSE jsonb_build_object('api_key', p_api_key) END)\n ON CONFLICT (tenant_id, provider) DO UPDATE\n SET config = coalesce(p_config, t.config),\n capabilities = v_caps,\n -- Chave nula \u00E9 \"n\u00E3o mexe\", n\u00E3o \"apaga\". Salvar a URL do Evolution sem\n -- redigitar a chave \u00E9 o gesto comum, e apag\u00E1-la aqui seria a surpresa.\n secrets = CASE WHEN p_api_key IS NULL THEN t.secrets\n ELSE jsonb_set(t.secrets, '{api_key}', to_jsonb(p_api_key)) END,\n status = CASE WHEN p_api_key IS NULL THEN t.status ELSE 'connecting' END,\n updated_at = now();\nEND $$;\n\nREVOKE ALL ON FUNCTION public.conversations_set_provider(uuid, text, jsonb, text, jsonb) FROM PUBLIC, anon;\nGRANT EXECUTE ON FUNCTION public.conversations_set_provider(uuid, text, jsonb, text, jsonb) TO authenticated, service_role;\n\n-- \u2500\u2500 trocar qual est\u00E1 valendo \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nCREATE OR REPLACE FUNCTION public.conversations_activate_provider(\n p_tenant uuid, p_provider text\n) RETURNS void\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $$\nBEGIN\n IF p_tenant IS NULL OR NOT (p_tenant IN (SELECT public.user_tenant_ids())) THEN\n RAISE EXCEPTION 'conversations_activate_provider: n\u00E3o \u00E9 uma casa sua';\n END IF;\n\n -- Desligar ANTES de ligar: o \u00EDndice \u00FAnico parcial recusaria os dois ativos,\n -- e numa transa\u00E7\u00E3o s\u00F3 a ordem \u00E9 o que decide entre trocar e falhar.\n UPDATE public.plg_conversations_providers\n SET is_active = false, updated_at = now()\n WHERE tenant_id = p_tenant AND is_active;\n\n UPDATE public.plg_conversations_providers\n SET is_active = true, updated_at = now()\n WHERE tenant_id = p_tenant AND provider = p_provider;\n\n IF NOT FOUND THEN\n RAISE EXCEPTION 'conversations_activate_provider: % n\u00E3o est\u00E1 configurado nesta casa', p_provider;\n END IF;\nEND $$;\n\nREVOKE ALL ON FUNCTION public.conversations_activate_provider(uuid, text) FROM PUBLIC, anon;\nGRANT EXECUTE ON FUNCTION public.conversations_activate_provider(uuid, text) TO authenticated, service_role;\n\n-- \u2500\u2500 o canal aprende de que provedor ele \u00E9 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- `plg_conversations_channels.provider` j\u00E1 existe e j\u00E1 guarda 'tyxter'. O que\n-- falta \u00E9 a liga\u00E7\u00E3o com a credencial: um n\u00FAmero do Evolution precisa saber de\n-- QUAL inst\u00E2ncia ele saiu, porque a mesma casa pode ter mais de uma.\nALTER TABLE public.plg_conversations_channels\n ADD COLUMN IF NOT EXISTS instance_key text;\n\nCOMMENT ON COLUMN public.plg_conversations_channels.instance_key IS\n 'A inst\u00E2ncia do provedor de onde este n\u00FAmero sai. Nulo no Tyxter (uma conta por projeto); no Evolution \u00E9 o nome da inst\u00E2ncia (002).';\n";
4
4
  export declare const MIGRATION_003_A_CAMPANHA_SAI_NO_RITMO_QUE_O_NUMERO_AGUENTA = "-- ---------------------------------------------------------------------------\n-- 003_a_campanha_sai_no_ritmo_que_o_numero_aguenta.sql \u2014 template, campanha,\n-- log de envio e o ritmo que impede o n\u00FAmero de ser banido.\n--\n-- O desenho n\u00E3o \u00E9 inven\u00E7\u00E3o: saiu de ler Mautic, EspoCRM, Odoo e Twenty, e de\n-- ler a documenta\u00E7\u00E3o da Meta em vez do folclore que circula sobre ela. Onde os\n-- quatro discordam, este arquivo diz qual escolheu e por qu\u00EA.\n--\n-- \u2500\u2500 tr\u00EAs coisas que a Meta documenta e que viram REGRA, n\u00E3o ajuste \u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- \u00B7 131050 (o usu\u00E1rio saiu do marketing) NUNCA se repete. Reenviar \u00E9 outra\n-- infra\u00E7\u00E3o, n\u00E3o outra tentativa.\n-- \u00B7 131048 (bloqueado/marcado como spam) \u00E9 parada dura. \u00C9 o alarme de\n-- qualidade, n\u00E3o um erro de rede.\n-- \u00B7 A janela de 24h s\u00F3 abre por mensagem OU chamada DO CLIENTE. Template\n-- nosso n\u00E3o abre janela nenhuma \u2014 e \u00E9 o erro que mais se v\u00EA em CRM.\n--\n-- O resto \u2014 a rampa de aquecimento, a faixa de atraso entre mensagens \u2014 \u00E9\n-- pr\u00E1tica de comunidade, n\u00E3o documenta\u00E7\u00E3o. Vira PADR\u00C3O EDIT\u00C1VEL: quem opera o\n-- n\u00FAmero sabe mais do que uma tabela escrita hoje.\n--\n-- \u2500\u2500 por que o log de envio tem `rotation` e `claim_token` \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- `rotation` \u00E9 do Mautic: a chave \u00FAnica \u00E9 (campanha, contato, rota\u00E7\u00E3o), e a\n-- rota\u00E7\u00E3o sobe quando algu\u00E9m REINICIA a campanha de prop\u00F3sito. Sem ela, a \u00FAnica\n-- forma de reenviar \u00E9 apagar o hist\u00F3rico \u2014 e a\u00ED ningu\u00E9m consegue provar o que\n-- foi mandado.\n--\n-- `claim_token` + `claim_expires_at` \u00E9 do Twenty: quem vai enviar ARRENDA a\n-- linha por cinco minutos. Duas inst\u00E2ncias do disparador n\u00E3o mandam a mesma\n-- mensagem duas vezes, e um processo que morre no meio devolve a linha sozinho\n-- quando o arrendamento vence. Os quatro CRMs resolvem isso com \u00EDndice \u00FAnico\n-- mais arrendamento; nenhum usa advisory lock, e nenhum usa outbox.\n-- ---------------------------------------------------------------------------\n\n-- \u2500\u2500 quem nunca mais deve ser contatado \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- A tabela j\u00E1 existe e j\u00E1 \u00E9 chaveada pelo N\u00DAMERO e n\u00E3o pelo contato \u2014 que \u00E9 o\n-- acerto do Odoo e do Twenty, e o erro do Mautic: n\u00FAmero \u00E9 reatribu\u00EDdo, contato\n-- \u00E9 duplicado, e a supress\u00E3o precisa sobreviver aos dois.\n--\n-- O que falta \u00E9 n\u00E3o apagar nunca. O EspoCRM apaga a linha ao reinscrever e\n-- perde a prova de que algu\u00E9m um dia pediu para sair; o Odoo desliga um\n-- booleano. Seguimos o Odoo.\nALTER TABLE public.plg_conversations_optouts\n ADD COLUMN IF NOT EXISTS active boolean NOT NULL DEFAULT true,\n ADD COLUMN IF NOT EXISTS revoked_at timestamptz,\n ADD COLUMN IF NOT EXISTS source text;\n\nCOMMENT ON COLUMN public.plg_conversations_optouts.active IS\n 'Desligar em vez de apagar: a linha \u00E9 a prova de que a pessoa pediu para sair, e apag\u00E1-la \u00E9 perder a prova (003).';\n\n-- \u2500\u2500 o texto que se manda \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- Um template aqui N\u00C3O \u00E9 um template da Meta. \u00C9 o texto da casa, com o espelho\n-- do que o provedor respondeu sobre ele. Assim o mesmo registro serve aos dois:\n-- no Tyxter ele carrega o nome e o status da aprova\u00E7\u00E3o; no Evolution, que n\u00E3o\n-- tem o conceito, ele \u00E9 s\u00F3 o texto \u2014 e a campanha n\u00E3o precisa saber a diferen\u00E7a.\nCREATE TABLE IF NOT EXISTS public.plg_conversations_templates (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n name text NOT NULL,\n language text NOT NULL DEFAULT 'pt_BR',\n category text NOT NULL DEFAULT 'MARKETING',\n body text NOT NULL,\n -- Os nomes das vari\u00E1veis, na ordem em que {{1}}, {{2}} aparecem. A Meta s\u00F3\n -- entende posi\u00E7\u00E3o; gente entende nome. Guardar os dois \u00E9 o que deixa a tela\n -- pedir \"cargo\" em vez de \"vari\u00E1vel 3\".\n variables text[] NOT NULL DEFAULT '{}',\n provider text,\n provider_template_id text,\n -- `draft` enquanto \u00E9 nosso; depois \u00E9 o que o provedor respondeu, verbatim.\n status text NOT NULL DEFAULT 'draft',\n status_detail text,\n submitted_at timestamptz,\n approved_at timestamptz,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\n\nCREATE UNIQUE INDEX IF NOT EXISTS plg_conversations_templates_name_uq\n ON public.plg_conversations_templates (tenant_id, name, language);\n\nALTER TABLE public.plg_conversations_templates ENABLE ROW LEVEL SECURITY;\nALTER TABLE public.plg_conversations_templates FORCE ROW LEVEL SECURITY;\nDROP POLICY IF EXISTS plg_conversations_templates_rw ON public.plg_conversations_templates;\nCREATE POLICY plg_conversations_templates_rw ON public.plg_conversations_templates\n FOR ALL TO authenticated\n USING (tenant_id IN (SELECT public.user_tenant_ids()))\n WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));\nGRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_conversations_templates TO authenticated, service_role;\n\n-- \u2500\u2500 a campanha \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nCREATE TABLE IF NOT EXISTS public.plg_conversations_campaigns (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n name text NOT NULL,\n template_id uuid REFERENCES public.plg_conversations_templates(id) ON DELETE SET NULL,\n channel_id uuid REFERENCES public.plg_conversations_channels(id) ON DELETE SET NULL,\n provider text,\n status text NOT NULL DEFAULT 'draft',\n -- O ritmo. Nasce do padr\u00E3o do provedor e \u00E9 edit\u00E1vel: a rampa \u00E9 pr\u00E1tica de\n -- comunidade, e quem opera o n\u00FAmero sabe mais que esta tabela.\n pacing jsonb NOT NULL DEFAULT '{}'::jsonb,\n -- Por que parou, quando parou sozinha. Ver `crm` nenhum: \u00E9 o alarme de\n -- qualidade da Meta chegando na tela de quem disparou.\n paused_reason text,\n started_at timestamptz,\n finished_at timestamptz,\n created_by uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n CONSTRAINT plg_conversations_campaigns_status\n CHECK (status IN ('draft', 'scheduled', 'running', 'paused', 'done', 'cancelled'))\n);\n\nALTER TABLE public.plg_conversations_campaigns ENABLE ROW LEVEL SECURITY;\nALTER TABLE public.plg_conversations_campaigns FORCE ROW LEVEL SECURITY;\nDROP POLICY IF EXISTS plg_conversations_campaigns_rw ON public.plg_conversations_campaigns;\nCREATE POLICY plg_conversations_campaigns_rw ON public.plg_conversations_campaigns\n FOR ALL TO authenticated\n USING (tenant_id IN (SELECT public.user_tenant_ids()))\n WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));\nGRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_conversations_campaigns TO authenticated, service_role;\n\n-- \u2500\u2500 uma linha por destinat\u00E1rio, para sempre \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- \u00C9 o log de envio e a fila ao mesmo tempo. Vale para os dois provedores, e \u00E9\n-- o \u00FAnico lugar onde se pode PROVAR o que foi mandado, para quem, quando, e\n-- por que algu\u00E9m N\u00C3O foi contatado.\nCREATE TABLE IF NOT EXISTS public.plg_conversations_campaign_targets (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n campaign_id uuid NOT NULL REFERENCES public.plg_conversations_campaigns(id) ON DELETE CASCADE,\n -- A pessoa do CRM. `SET NULL` e n\u00E3o `CASCADE`: apagar a pessoa n\u00E3o pode\n -- apagar a prova de que uma mensagem foi enviada a ela.\n person_id uuid,\n phone_e164 text NOT NULL,\n -- Ver o cabe\u00E7alho: sobe quando algu\u00E9m REINICIA a campanha de prop\u00F3sito.\n rotation integer NOT NULL DEFAULT 1,\n state text NOT NULL DEFAULT 'queued',\n skip_reason text,\n failure_code text,\n failure_detail text,\n -- O arrendamento. S\u00F3 quem segura o token manda.\n claim_token uuid,\n claim_expires_at timestamptz,\n provider_message_id text,\n variables jsonb NOT NULL DEFAULT '{}'::jsonb,\n queued_at timestamptz NOT NULL DEFAULT now(),\n sent_at timestamptz,\n delivered_at timestamptz,\n read_at timestamptz,\n replied_at timestamptz,\n failed_at timestamptz,\n CONSTRAINT plg_conversations_campaign_targets_state\n CHECK (state IN ('queued', 'sending', 'sent', 'delivered', 'read', 'replied', 'failed', 'skipped')),\n -- O arrendamento existe se e s\u00F3 se est\u00E1 enviando. Duas verifica\u00E7\u00F5es porque\n -- um token sem prazo \u00E9 um token que nunca volta.\n CONSTRAINT plg_conversations_campaign_targets_lease\n CHECK (state <> 'sending' OR claim_expires_at IS NOT NULL),\n CONSTRAINT plg_conversations_campaign_targets_lease_pair\n CHECK ((claim_token IS NULL) = (claim_expires_at IS NULL))\n);\n\n-- Uma vez por campanha, por pessoa, por rodada. \u00C9 esta linha que impede o\n-- disparo duplo \u2014 n\u00E3o um `if` no c\u00F3digo do disparador.\nCREATE UNIQUE INDEX IF NOT EXISTS plg_conversations_campaign_targets_once\n ON public.plg_conversations_campaign_targets (campaign_id, phone_e164, rotation);\n\n-- O webhook do provedor chega mais de uma vez, de prop\u00F3sito (o Evolution tenta\n-- 10 vezes em ~50 min). A idempot\u00EAncia do recebimento \u00E9 este \u00EDndice.\nCREATE UNIQUE INDEX IF NOT EXISTS plg_conversations_campaign_targets_provider_msg\n ON public.plg_conversations_campaign_targets (tenant_id, provider_message_id)\n WHERE provider_message_id IS NOT NULL;\n\nCREATE INDEX IF NOT EXISTS plg_conversations_campaign_targets_pending\n ON public.plg_conversations_campaign_targets (campaign_id)\n WHERE state IN ('queued', 'sending');\n\n-- O ceifeiro: linhas cujo arrendamento venceu voltam para a fila.\nCREATE INDEX IF NOT EXISTS plg_conversations_campaign_targets_reaper\n ON public.plg_conversations_campaign_targets (claim_expires_at)\n WHERE state = 'sending';\n\nALTER TABLE public.plg_conversations_campaign_targets ENABLE ROW LEVEL SECURITY;\nALTER TABLE public.plg_conversations_campaign_targets FORCE ROW LEVEL SECURITY;\nDROP POLICY IF EXISTS plg_conversations_campaign_targets_rw ON public.plg_conversations_campaign_targets;\nCREATE POLICY plg_conversations_campaign_targets_rw ON public.plg_conversations_campaign_targets\n FOR ALL TO authenticated\n USING (tenant_id IN (SELECT public.user_tenant_ids()))\n WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));\nGRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_conversations_campaign_targets TO authenticated, service_role;\n\n-- \u2500\u2500 o ritmo que o n\u00FAmero aguenta \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- Mora no canal e n\u00E3o na campanha: o limite \u00E9 do N\u00DAMERO, e duas campanhas no\n-- mesmo n\u00FAmero somam. Guardar na campanha deixaria cada uma achar que tem a\n-- cota inteira.\nALTER TABLE public.plg_conversations_channels\n ADD COLUMN IF NOT EXISTS pacing jsonb NOT NULL DEFAULT '{}'::jsonb,\n ADD COLUMN IF NOT EXISTS warmup_day integer,\n ADD COLUMN IF NOT EXISTS warmup_started_at timestamptz,\n ADD COLUMN IF NOT EXISTS quality text,\n ADD COLUMN IF NOT EXISTS quality_at timestamptz;\n\nCOMMENT ON COLUMN public.plg_conversations_channels.pacing IS\n 'Ritmo deste n\u00FAmero: por minuto, por hora, por dia, faixa de atraso e janela de hor\u00E1rio. Padr\u00E3o por provedor, edit\u00E1vel \u2014 a rampa \u00E9 pr\u00E1tica de comunidade, n\u00E3o documenta\u00E7\u00E3o da Meta (003).';\n\n-- Os padr\u00F5es, por provedor. N\u00FAmeros da pesquisa; o coment\u00E1rio diz a\n-- proced\u00EAncia de cada um, porque \"por que 20?\" \u00E9 a pergunta que sempre volta.\nCREATE OR REPLACE FUNCTION public.conversations_default_pacing(p_provider text)\nRETURNS jsonb LANGUAGE sql IMMUTABLE AS $$\n SELECT CASE p_provider\n -- Oficial. O teto real \u00E9 o tier da Meta (250/2.000/10.000/100.000), que a\n -- pr\u00F3pria API informa por n\u00FAmero \u2014 ent\u00E3o aqui s\u00F3 entra o que a Meta N\u00C3O\n -- controla: o ritmo instant\u00E2neo. 1 msg / 6 s para o MESMO destinat\u00E1rio \u00E9\n -- documentado; 60/min \u00E9 75% do teto de 80/s por seguran\u00E7a.\n WHEN 'tyxter' THEN jsonb_build_object(\n 'per_minute', 60, 'per_hour', 1000, 'per_day', null,\n 'delay_seconds', jsonb_build_array(1, 3),\n 'same_recipient_seconds', 6,\n 'hours', jsonb_build_object('start', '09:00', 'end', '18:00', 'weekdays_only', true))\n -- N\u00E3o oficial. Aqui n\u00E3o h\u00E1 teto do provedor \u2014 h\u00E1 banimento. Os n\u00FAmeros s\u00E3o\n -- a moda de treze rampas brasileiras publicadas: 10-30/dia na primeira\n -- semana, 200-300/dia no regime. Atraso ALEAT\u00D3RIO entre 15 e 45 s porque\n -- intervalo fixo \u00E9 assinatura de rob\u00F4.\n WHEN 'evolution' THEN jsonb_build_object(\n 'per_minute', 12, 'per_hour', 300, 'per_day', 250,\n 'delay_seconds', jsonb_build_array(15, 45),\n 'same_recipient_seconds', 6,\n 'batch_size', 50, 'batch_cooldown_minutes', 12,\n 'hours', jsonb_build_object('start', '09:00', 'end', '18:00', 'weekdays_only', true))\n ELSE '{}'::jsonb END\n$$;\n\nCOMMENT ON FUNCTION public.conversations_default_pacing(text) IS\n 'O ritmo inicial de cada provedor. Tyxter: o teto \u00E9 da Meta, aqui s\u00F3 o ritmo instant\u00E2neo. Evolution: n\u00E3o h\u00E1 teto, h\u00E1 banimento (003).';\n\n-- A rampa de aquecimento, em dias. Pr\u00E1tica de comunidade, edit\u00E1vel \u2014 e a\n-- fun\u00E7\u00E3o existe para que o padr\u00E3o seja UM lugar e n\u00E3o uma constante copiada.\nCREATE OR REPLACE FUNCTION public.conversations_warmup_allowance(p_day integer)\nRETURNS integer LANGUAGE sql IMMUTABLE AS $$\n SELECT CASE\n WHEN p_day IS NULL THEN NULL -- n\u00FAmero sem aquecimento declarado: sem teto extra\n WHEN p_day <= 3 THEN 20\n WHEN p_day <= 7 THEN 50\n WHEN p_day <= 14 THEN 100\n WHEN p_day <= 21 THEN 200\n ELSE 300\n END\n$$;\n\nCOMMENT ON FUNCTION public.conversations_warmup_allowance(integer) IS\n 'Quantos destinat\u00E1rios NOVOS por dia, por dia de aquecimento. Moda de treze rampas brasileiras publicadas \u2014 comunidade, n\u00E3o Meta. Padr\u00E3o edit\u00E1vel (003).';\n\n-- \u2500\u2500 o que a tela do disparo precisa ver \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nDROP VIEW IF EXISTS public.v_conversations_campaign_progress;\nCREATE VIEW public.v_conversations_campaign_progress\nWITH (security_invoker = true) AS\n SELECT c.id AS campaign_id,\n c.tenant_id,\n c.name,\n c.status,\n c.paused_reason,\n c.provider,\n count(t.id) AS total,\n count(*) FILTER (WHERE t.state = 'queued') AS queued,\n count(*) FILTER (WHERE t.state = 'sending') AS sending,\n count(*) FILTER (WHERE t.state IN ('sent','delivered','read','replied')) AS sent,\n count(*) FILTER (WHERE t.state = 'delivered') AS delivered,\n count(*) FILTER (WHERE t.state = 'read') AS read,\n count(*) FILTER (WHERE t.state = 'replied') AS replied,\n count(*) FILTER (WHERE t.state = 'failed') AS failed,\n count(*) FILTER (WHERE t.state = 'skipped') AS skipped,\n -- A taxa de resposta \u00E9 o sinal que decide se o n\u00FAmero sobrevive: a\n -- pr\u00E1tica diz que abaixo de 30% o risco de banimento sobe. Fica na\n -- view porque \u00E9 o n\u00FAmero que precisa estar na tela, n\u00E3o num relat\u00F3rio.\n CASE WHEN count(*) FILTER (WHERE t.state IN ('sent','delivered','read','replied')) > 0\n THEN round(100.0 * count(*) FILTER (WHERE t.state = 'replied')\n / count(*) FILTER (WHERE t.state IN ('sent','delivered','read','replied')), 1)\n END AS reply_rate,\n min(t.sent_at) AS first_sent_at,\n max(t.sent_at) AS last_sent_at\n FROM public.plg_conversations_campaigns c\n LEFT JOIN public.plg_conversations_campaign_targets t ON t.campaign_id = c.id\n WHERE c.tenant_id IN (SELECT public.user_tenant_ids())\n GROUP BY c.id, c.tenant_id, c.name, c.status, c.paused_reason, c.provider;\n\nGRANT SELECT ON public.v_conversations_campaign_progress TO authenticated;\n\n-- \u2500\u2500 arrendar o pr\u00F3ximo lote \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- `FOR UPDATE SKIP LOCKED` \u00E9 o que deixa dois disparadores trabalharem a mesma\n-- campanha sem pisar um no outro, e sem advisory lock. O arrendamento de cinco\n-- minutos \u00E9 o que devolve a linha quando um deles morre no meio.\n--\n-- A supress\u00E3o \u00E9 conferida AQUI, na mesma transa\u00E7\u00E3o que arrenda. Conferir antes\n-- e mandar depois deixa uma janela em que algu\u00E9m pede para sair e recebe assim\n-- mesmo \u2014 e \u00E9 justamente essa mensagem que vira den\u00FAncia.\nCREATE OR REPLACE FUNCTION public.conversations_claim_targets(\n p_campaign uuid, p_limit integer DEFAULT 10\n) RETURNS TABLE (\n id uuid, phone_e164 text, person_id uuid, variables jsonb, claim_token uuid\n)\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $$\nDECLARE v_token uuid := gen_random_uuid(); v_tenant uuid;\nBEGIN\n SELECT c.tenant_id INTO v_tenant\n FROM public.plg_conversations_campaigns c WHERE c.id = p_campaign;\n IF v_tenant IS NULL THEN RAISE EXCEPTION 'campanha n\u00E3o encontrada'; END IF;\n\n -- TR\u00CAS COMANDOS, E N\u00C3O UM. A primeira vers\u00E3o fazia o ceifeiro, a supress\u00E3o e\n -- o arrendamento em CTEs do mesmo UPDATE, e isso \u00E9 uma armadilha: dentro de\n -- um comando s\u00F3, todas as CTEs enxergam o MESMO instant\u00E2neo da tabela, e uma\n -- linha atualizada por duas delas recebe s\u00F3 a primeira \u2014 em sil\u00EAncio. O\n -- suprimido vinha marcado E arrendado, e qual dos dois vencia dependia do\n -- plano que o Postgres escolhesse naquele dia.\n --\n -- Separado, cada passo enxerga o resultado do anterior. \u00C9 mais lento por um\n -- par de milissegundos e \u00E9 a diferen\u00E7a entre uma regra e uma coincid\u00EAncia.\n\n -- 1. O ceifeiro. Quem venceu o arrendamento volta para a fila, sen\u00E3o uma\n -- campanha inteira fica presa num processo que morreu.\n UPDATE public.plg_conversations_campaign_targets t\n SET state = 'queued', claim_token = NULL, claim_expires_at = NULL\n WHERE t.campaign_id = p_campaign AND t.state = 'sending'\n AND t.claim_expires_at < now();\n\n -- 2. Quem pediu para sair NUNCA entra num lote. A linha fica marcada em vez\n -- de sumir: \u00E9 assim que se prova que a decis\u00E3o foi deliberada.\n UPDATE public.plg_conversations_campaign_targets t\n SET state = 'skipped', skip_reason = 'suppressed'\n WHERE t.campaign_id = p_campaign AND t.state = 'queued'\n AND EXISTS (SELECT 1 FROM public.plg_conversations_optouts o\n WHERE o.tenant_id = t.tenant_id\n AND o.phone_e164 = t.phone_e164\n AND o.active);\n\n -- 3. S\u00F3 ent\u00E3o arrenda. `SKIP LOCKED` deixa dois disparadores trabalharem a\n -- mesma campanha sem pisar um no outro, e sem advisory lock.\n RETURN QUERY\n WITH escolhidos AS (\n SELECT t.id FROM public.plg_conversations_campaign_targets t\n WHERE t.campaign_id = p_campaign AND t.state = 'queued'\n ORDER BY t.queued_at\n LIMIT greatest(1, least(coalesce(p_limit, 10), 100))\n FOR UPDATE SKIP LOCKED\n )\n UPDATE public.plg_conversations_campaign_targets t\n SET state = 'sending', claim_token = v_token,\n claim_expires_at = now() + interval '5 minutes'\n FROM escolhidos e\n WHERE t.id = e.id\n RETURNING t.id, t.phone_e164, t.person_id, t.variables, t.claim_token;\nEND $$;\n\nREVOKE ALL ON FUNCTION public.conversations_claim_targets(uuid, integer) FROM PUBLIC, anon;\nGRANT EXECUTE ON FUNCTION public.conversations_claim_targets(uuid, integer) TO service_role;\n";
5
5
  export declare const MIGRATION_004_A_TELA_SABE_SE_PODE_FALAR_ANTES_DE_DEIXAR_ESCREVER = "-- ---------------------------------------------------------------------------\n-- 004_a_tela_sabe_se_pode_falar_antes_de_deixar_escrever.sql \u2014 a janela de 24h,\n-- como DADO, para a tela responder antes de algu\u00E9m digitar.\n--\n-- \u2500\u2500 o defeito que isto conserta \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- Na API oficial do WhatsApp, texto livre s\u00F3 sai dentro de 24h contadas da\n-- \u00FAltima mensagem DO CLIENTE. Fora dela a Meta recusa, e a recusa chega como um\n-- c\u00F3digo no log \u2014 n\u00E3o na tela de quem escreveu. O atendente digita, aperta\n-- enviar, v\u00EA a mensagem aparecer na conversa, e ela nunca chega.\n--\n-- Isso \u00E9 pior que um erro: \u00E9 uma tela que mente. E some inteiro num provedor\n-- n\u00E3o oficial, onde n\u00E3o existe janela nenhuma \u2014 ent\u00E3o o MESMO campo de texto\n-- funciona ou falha calado dependendo de qual provedor est\u00E1 ativo.\n--\n-- A corre\u00E7\u00E3o n\u00E3o \u00E9 um aviso depois. \u00C9 a tela saber ANTES: janela aberta, texto\n-- livre; janela fechada, s\u00F3 template, e o campo diz isso em vez de aceitar.\n--\n-- \u2500\u2500 quem abre a janela \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- O CLIENTE, e s\u00F3 ele. Mensagem nossa n\u00E3o abre nada \u2014 nem template aprovado.\n-- \u00C9 o erro mais comum de quem implementa isso, e \u00E9 por isso que o c\u00E1lculo\n-- abaixo olha exclusivamente `direction = 'inbound'`.\n--\n-- (Origem: developers.facebook.com/documentation/business-messaging/whatsapp \u2014\n-- \"The user messages you about the product. This opens a 24 hour customer\n-- service window.\" Cliques em an\u00FAncio Click-to-WhatsApp abrem 72h; n\u00E3o\n-- distinguimos ainda, e 24h \u00E9 o limite conservador dos dois.)\n-- ---------------------------------------------------------------------------\n\nDROP VIEW IF EXISTS public.v_conversations_window;\nCREATE VIEW public.v_conversations_window\nWITH (security_invoker = true) AS\n SELECT c.id AS conversation_id,\n c.tenant_id,\n c.channel,\n c.contact_handle,\n c.contact_person_id,\n u.last_inbound_at,\n -- Quando fecha. Nulo = nunca abriu, que \u00E9 o caso de todo lead que\n -- ainda n\u00E3o respondeu \u2014 a maioria de uma lista de feira.\n (u.last_inbound_at + interval '24 hours') AS window_expires_at,\n (u.last_inbound_at IS NOT NULL\n AND u.last_inbound_at > now() - interval '24 hours') AS window_open,\n -- Quanto falta, em minutos. A tela mostra \"faltam 3h\" e n\u00E3o um\n -- carimbo de data: quem est\u00E1 respondendo quer saber se d\u00E1 tempo.\n CASE WHEN u.last_inbound_at IS NOT NULL\n THEN greatest(0, floor(extract(epoch FROM\n (u.last_inbound_at + interval '24 hours') - now()) / 60))::int\n END AS minutes_left,\n -- Quem pediu para n\u00E3o ser mais contatado. Some da tela antes de\n -- qualquer janela: n\u00E3o importa se est\u00E1 aberta.\n EXISTS (SELECT 1 FROM public.plg_conversations_optouts o\n WHERE o.tenant_id = c.tenant_id\n AND o.phone_e164 = regexp_replace(coalesce(c.contact_handle,''), '\\D', '', 'g')\n AND o.active) AS opted_out\n FROM public.plg_conversations c\n LEFT JOIN LATERAL (\n SELECT max(m.at) AS last_inbound_at\n FROM public.plg_conversation_messages m\n WHERE m.conversation_id = c.id\n AND m.direction = 'inbound'\n ) u ON true\n WHERE c.tenant_id IN (SELECT public.user_tenant_ids());\n\nGRANT SELECT ON public.v_conversations_window TO authenticated;\n\nCOMMENT ON VIEW public.v_conversations_window IS\n 'Se d\u00E1 para mandar texto livre para esta conversa agora. A janela de 24h s\u00F3 abre por mensagem DO CLIENTE \u2014 template nosso n\u00E3o abre nada (004).';\n\n-- O \u00EDndice que a LATERAL usa. Sem ele, cada linha da caixa varre as mensagens\n-- da conversa inteira, e a caixa \u00E9 justamente a tela que abre o dia todo.\nCREATE INDEX IF NOT EXISTS plg_conversation_messages_inbound_at\n ON public.plg_conversation_messages (conversation_id, at DESC)\n WHERE direction = 'inbound';\n";
6
+ export declare const MIGRATION_005_A_CONVERSA_CONTA_AO_FUNIL_QUE_HOUVE_CONTATO = "-- ---------------------------------------------------------------------------\n-- 005_a_conversa_conta_ao_funil_que_houve_contato.sql \u2014 a ponte que faltava.\n--\n-- \u2500\u2500 o buraco, medido \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- No cluster: 8 conversas, 42 mensagens trocadas \u2014 incluindo respostas de\n-- gente de verdade \u2014 e 315 neg\u00F3cios TODOS parados em \"Novo (feira)\", com\n-- `contact_status = 'not_contacted'`. Zero atividades vindas da caixa.\n--\n-- N\u00E3o era defeito: n\u00E3o existia liga\u00E7\u00E3o nenhuma. Nenhum `INSERT` em\n-- `plg_crm_activities` fora do plugin-crm, nenhuma fonte de linha do tempo\n-- registrada. A caixa de entrada e o funil olhavam para as mesmas pessoas e\n-- n\u00E3o se falavam.\n--\n-- O maquin\u00E1rio do CRM j\u00E1 estava pronto e correto: `crm_recompute_deal_status`\n-- (crm/020) l\u00EA as atividades, pega o \u00DALTIMO `outcome` e deriva o status; o\n-- gatilho `crm_activity_restatus` dispara a cada atividade inserida. Faltava\n-- algu\u00E9m inserir.\n--\n-- \u2500\u2500 por que o `outcome` \u00E9 o que \u00E9 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- mensagem RECEBIDA \u2192 `replied` o lead respondeu. \u00C9 o fato mais forte\n-- que a caixa produz, e o que mais move\n-- o status (\"Respondeu\").\n-- mensagem ENVIADA \u2192 `left_message` deixamos recado. Mandar N\u00C3O \u00E9 ser\n-- lido: tratar envio como contato feito\n-- encheria o funil de \"conectado\" sem\n-- ningu\u00E9m do outro lado. A 020 mapeia\n-- este valor para \"N\u00E3o atendeu\", que \u00E9\n-- literalmente o estado \u2014 ainda sem\n-- resposta.\n--\n-- O vocabul\u00E1rio \u00E9 FECHADO por CHECK (crm/020:49): `connected, no_answer,\n-- left_message, replied, scheduled`. Conferido antes de escrever \u2014 a primeira\n-- vers\u00E3o desta migration usava `attempted`, que a regra da 020 nomeia mas a\n-- restri\u00E7\u00E3o da coluna N\u00C3O aceita, e teria falhado em toda mensagem enviada.\n--\n-- \u2500\u2500 e o `deal_id` n\u00E3o \u00E9 opcional \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- `crm_recompute_deal_status` agrega `WHERE a.deal_id = p_deal_id`. Uma\n-- atividade s\u00F3 com `lead_id` n\u00E3o entra na conta e n\u00E3o move status nenhum \u2014\n-- por isso o gatilho procura o neg\u00F3cio do lead e o carimba.\n--\n-- \u2500\u2500 uma atividade por CONVERSA E POR DIA, n\u00E3o por mensagem \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- Uma conversa de trinta mensagens n\u00E3o s\u00E3o trinta contatos: \u00E9 um. Registrar\n-- cada mensagem encheria a linha do tempo de ru\u00EDdo e afogaria o que importa \u2014\n-- a liga\u00E7\u00E3o, a visita, a proposta. Ent\u00E3o a primeira mensagem do dia abre a\n-- atividade e as seguintes ATUALIZAM a mesma linha (o corpo mais recente, e o\n-- `outcome` mais forte do dia).\n--\n-- Atualizar tamb\u00E9m dispara o `crm_activity_restatus` (o gatilho \u00E9 INSERT OR\n-- UPDATE), ent\u00E3o uma resposta que chega depois de um envio move o status no\n-- mesmo dia, que \u00E9 o comportamento que se quer.\n--\n-- \u2500\u2500 e se o contato n\u00E3o for um lead \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- `contact_person_id` \u00E9 nulo enquanto ningu\u00E9m casou o telefone com uma pessoa.\n-- A\u00ED n\u00E3o h\u00E1 a quem pendurar o fato e a fun\u00E7\u00E3o sai calada. N\u00E3o inventa lead: a\n-- caixa recebe mensagem de entregador, de banco e de engano, e cada um deles\n-- viraria um card no funil de algu\u00E9m.\n-- ---------------------------------------------------------------------------\n\nCREATE OR REPLACE FUNCTION public.conversations_log_crm_activity() RETURNS trigger\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_tenant uuid;\n v_person uuid;\n v_canal text;\n v_deal uuid;\n v_outcome text;\n v_titulo text;\n v_corpo text;\n v_dia date := (NEW.at AT TIME ZONE 'UTC')::date;\n v_ativ uuid;\nBEGIN\n -- Sem CRM montado n\u00E3o h\u00E1 o que escrever. `to_regclass` em vez de refer\u00EAncia\n -- direta porque conversas \u00E9 plugin OPCIONAL e pode viver num cluster que n\u00E3o\n -- tem funil nenhum.\n IF to_regclass('public.plg_crm_activities') IS NULL THEN RETURN NULL; END IF;\n\n SELECT c.tenant_id, c.contact_person_id, c.channel\n INTO v_tenant, v_person, v_canal\n FROM public.plg_conversations c WHERE c.id = NEW.conversation_id;\n\n IF v_person IS NULL THEN RETURN NULL; END IF;\n\n SELECT d.id INTO v_deal\n FROM public.plg_crm_deals d\n WHERE d.tenant_id = v_tenant AND d.lead_id = v_person\n ORDER BY d.created_at DESC LIMIT 1;\n\n IF NEW.direction = 'inbound' THEN\n v_outcome := 'replied';\n v_titulo := 'Respondeu no ' || coalesce(v_canal, 'canal');\n ELSE\n v_outcome := 'left_message';\n v_titulo := 'Mensagem enviada no ' || coalesce(v_canal, 'canal');\n END IF;\n\n v_corpo := left(coalesce(NEW.body, ''), 2000);\n\n -- A atividade do dia, se j\u00E1 houver.\n SELECT a.id INTO v_ativ\n FROM public.plg_crm_activities a\n WHERE a.tenant_id = v_tenant\n AND a.lead_id = v_person\n AND a.activity_type = 'whatsapp'\n AND (a.completed_at AT TIME ZONE 'UTC')::date = v_dia\n ORDER BY a.completed_at DESC LIMIT 1;\n\n IF v_ativ IS NULL THEN\n INSERT INTO public.plg_crm_activities (\n tenant_id, deal_id, lead_id, contact_id, activity_type,\n title, description, outcome, completed_at, created_by_name\n ) VALUES (\n v_tenant, v_deal, v_person, v_person, 'whatsapp',\n v_titulo, v_corpo, v_outcome, NEW.at,\n -- Quem fez foi a caixa, n\u00E3o uma pessoa: o autor em uuid seria mentira,\n -- e \"(sistema)\" \u00E9 a resposta honesta para quem l\u00EA a linha do tempo.\n 'Caixa de entrada'\n );\n ELSE\n UPDATE public.plg_crm_activities a SET\n -- `replied` NUNCA \u00E9 rebaixado por um envio posterior: o lead ter\n -- respondido \u00E9 o fato do dia, e mandar outra mensagem depois n\u00E3o o\n -- desfaz.\n outcome = CASE WHEN a.outcome = 'replied' THEN 'replied' ELSE v_outcome END,\n title = CASE WHEN a.outcome = 'replied' THEN a.title ELSE v_titulo END,\n description = v_corpo,\n completed_at = NEW.at,\n deal_id = coalesce(a.deal_id, v_deal)\n WHERE a.id = v_ativ;\n END IF;\n\n RETURN NULL;\nEND $$;\n\nREVOKE ALL ON FUNCTION public.conversations_log_crm_activity() FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.conversations_log_crm_activity() TO authenticated, service_role;\n\nCOMMENT ON FUNCTION public.conversations_log_crm_activity() IS\n 'Uma mensagem vira contato na linha do tempo do lead e do neg\u00F3cio, e o status do card deriva disso pelo gatilho da crm/020. Uma atividade por conversa por dia (005).';\n\n-- AFTER: falhar ao registrar no CRM n\u00E3o pode recusar uma mensagem que j\u00E1\n-- chegou do provedor. A caixa \u00E9 a fonte; o funil \u00E9 quem escuta.\nDROP TRIGGER IF EXISTS conversations_log_crm_activity ON public.plg_conversation_messages;\nCREATE TRIGGER conversations_log_crm_activity\n AFTER INSERT ON public.plg_conversation_messages\n FOR EACH ROW EXECUTE FUNCTION public.conversations_log_crm_activity();\n\n-- O \u00EDndice que a busca da atividade do dia usa. Sem ele, cada mensagem varre\n-- as atividades do inquilino inteiro \u2014 e mensagem \u00E9 o que mais chega aqui.\nDO $$\nBEGIN\n -- Guarda pela mesma raz\u00E3o da fun\u00E7\u00E3o: conversas \u00E9 opcional e pode viver sem\n -- CRM. Um \u00EDndice sobre tabela que n\u00E3o existe recusa o arquivo inteiro.\n IF to_regclass('public.plg_crm_activities') IS NULL THEN RETURN; END IF;\n CREATE INDEX IF NOT EXISTS plg_crm_activities_lead_day\n ON public.plg_crm_activities (tenant_id, lead_id, activity_type, completed_at DESC);\nEND $$;\n";
6
7
  export declare const MIGRATIONS: Array<{
7
8
  id: string;
8
9
  sql: string;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/migrations/index.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,sBAAsB,2rhCAkgBlC,CAAA;AAED,eAAO,MAAM,gDAAgD,i0LAuG5D,CAAA;AAED,eAAO,MAAM,oDAAoD,m5bAuOhE,CAAA;AAED,eAAO,MAAM,0DAA0D,81rBAwWtE,CAAA;AAED,eAAO,MAAM,gEAAgE,+lJA2E5E,CAAA;AAED,eAAO,MAAM,UAAU,EAAE,KAAK,CAAC;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAMzD,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/migrations/index.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,sBAAsB,2rhCAkgBlC,CAAA;AAED,eAAO,MAAM,gDAAgD,i0LAuG5D,CAAA;AAED,eAAO,MAAM,oDAAoD,m5bAuOhE,CAAA;AAED,eAAO,MAAM,0DAA0D,81rBAwWtE,CAAA;AAED,eAAO,MAAM,gEAAgE,+lJA2E5E,CAAA;AAED,eAAO,MAAM,yDAAyD,w1RAmKrE,CAAA;AAED,eAAO,MAAM,UAAU,EAAE,KAAK,CAAC;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAOzD,CAAA"}
@@ -1 +1 @@
1
- {"version":3,"file":"ConversationList.d.ts","sourceRoot":"","sources":["../../src/views/ConversationList.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AAUzB,wBAAgB,gBAAgB,CAAC,EAAE,SAAS,EAAE,EAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,qBAgHrE"}
1
+ {"version":3,"file":"ConversationList.d.ts","sourceRoot":"","sources":["../../src/views/ConversationList.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AAUzB,wBAAgB,gBAAgB,CAAC,EAAE,SAAS,EAAE,EAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,qBAsHrE"}
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "status": "preview",
5
5
  "dependencies": []
6
6
  },
7
- "version": "0.12.2",
7
+ "version": "0.13.1",
8
8
  "description": "[experimental] Fayz SDK — unified conversations / omni-channel inbox plugin",
9
9
  "type": "module",
10
10
  "sideEffects": false,
@@ -28,8 +28,8 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@fayz-ai/core": "^0.23.0",
31
- "@fayz-ai/ui": "^0.23.0",
32
- "@fayz-ai/admin": "^0.23.0"
31
+ "@fayz-ai/ui": "^0.25.0",
32
+ "@fayz-ai/admin": "^0.25.0"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@types/react": "^18.3.0",