@fayz-ai/plugin-conversations 0.13.0 → 0.14.0

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.
@@ -4,6 +4,7 @@ export declare const MIGRATION_002_A_CASA_ESCOLHE_POR_ONDE_O_WHATSAPP_SAI = "--
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
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";
7
+ export declare const MIGRATION_006_A_CAIXA_AVISA_QUE_CHEGOU = "-- ---------------------------------------------------------------------------\n-- 006_a_caixa_avisa_que_chegou.sql\n--\n-- \u2500\u2500 o sil\u00EAncio, e de onde ele vinha \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 caixa recebia mensagens e ningu\u00E9m era avisado. Sem som, sem contagem no\n-- menu, sem destaque na lista \u2014 e a suspeita natural \u00E9 que falta a tela que\n-- avisa. N\u00E3o faltava: o evento nunca sa\u00EDa do banco.\n--\n-- `plg_conversation_messages` e `plg_conversations` n\u00E3o estavam em\n-- `supabase_realtime`. A publica\u00E7\u00E3o existe (`puballtables = false`, ou seja,\n-- cada tabela entra nomeada) e nenhuma das duas fora nomeada. Qualquer cliente\n-- que assinasse `postgres_changes` sobre elas ficaria subscrito para sempre a\n-- um canal por onde nada passa \u2014 e subscrever com sucesso a um canal mudo \u00E9 o\n-- modo de falhar mais convincente que h\u00E1: o c\u00F3digo parece certo, o console\n-- fica limpo, e nada acontece.\n--\n-- \u2500\u2500 as duas, e n\u00E3o s\u00F3 as mensagens \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 mensagem \u00E9 o que dispara o alerta; a CONVERSA \u00E9 o que carrega o estado que\n-- a lista pinta (`status`, `unread_count`, `last_message_direction`). Publicar\n-- s\u00F3 a primeira daria o som sem o badge parar de mentir depois de algu\u00E9m ler.\n--\n-- \u2500\u2500 o RLS continua a valer \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Publicar n\u00E3o abre nada: o Realtime do Supabase aplica as pol\u00EDticas da tabela\n-- por assinante, e quem n\u00E3o pode ler a linha n\u00E3o a recebe. O que a publica\u00E7\u00E3o\n-- decide \u00E9 se o evento EXISTE, n\u00E3o quem o v\u00EA.\n--\n-- \u2500\u2500 idempotente, e silenciosa onde n\u00E3o se aplica \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-- `ALTER PUBLICATION ... ADD TABLE` rebenta se a tabela j\u00E1 l\u00E1 est\u00E1, e a\n-- publica\u00E7\u00E3o `supabase_realtime` s\u00F3 existe num Postgres da Supabase. As duas\n-- condi\u00E7\u00F5es s\u00E3o verificadas antes, porque esta migration tamb\u00E9m roda no banco\n-- de teste, que n\u00E3o tem nenhuma delas.\n-- ---------------------------------------------------------------------------\n\nDO $$\nDECLARE\n t text;\n v_n integer := 0;\nBEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_publication WHERE pubname = 'supabase_realtime') THEN\n RAISE NOTICE 'conversations/006: sem publica\u00E7\u00E3o supabase_realtime \u2014 nada a fazer';\n RETURN;\n END IF;\n\n FOREACH t IN ARRAY ARRAY['plg_conversation_messages', 'plg_conversations'] LOOP\n CONTINUE WHEN to_regclass('public.' || t) IS NULL;\n CONTINUE WHEN EXISTS (\n SELECT 1 FROM pg_publication_tables\n WHERE pubname = 'supabase_realtime' AND schemaname = 'public' AND tablename = t);\n EXECUTE format('ALTER PUBLICATION supabase_realtime ADD TABLE public.%I', t);\n v_n := v_n + 1;\n END LOOP;\n\n RAISE NOTICE 'conversations/006: % tabela(s) publicada(s) no realtime', v_n;\nEND $$;\n\n-- `REPLICA IDENTITY FULL` na conversa: sem ela o UPDATE chega com a chave\n-- prim\u00E1ria e mais nada, e a tela n\u00E3o consegue saber que `unread_count` mudou \u2014\n-- que \u00E9 exatamente o UPDATE que interessa aqui. Na tabela de mensagens n\u00E3o \u00E9\n-- preciso: o que importa \u00E9 o INSERT, e esse vem inteiro de qualquer forma.\nDO $$\nBEGIN\n IF to_regclass('public.plg_conversations') IS NOT NULL THEN\n ALTER TABLE public.plg_conversations REPLICA IDENTITY FULL;\n END IF;\nEND $$;\n\n-- \u2500\u2500 e a conta de n\u00E3o lidas, que ningu\u00E9m mantinha \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-- `unread_count` estava em ZERO nas oito conversas do cluster, incluindo as\n-- quatro cuja \u00FAltima mensagem \u00E9 de entrada. N\u00E3o era um zero verdadeiro: n\u00E3o h\u00E1\n-- (n\u00E3o havia) nada que a incremente. A coluna existe desde a baseline, a tela\n-- da lista decide o destaque por ela (`unreadCount > 0`) e a RPC de marcar como\n-- lida zera-a \u2014 s\u00F3 faltava o lado que soma.\n--\n-- Por isso `isWaitingOnUs` recorre a `lastMessageDirection` quando a conta \u00E9\n-- zero: foi escrita j\u00E1 sabendo que a conta n\u00E3o era de fiar. Esta migration\n-- torna-a de fiar, e a recorr\u00EAncia volta a ser o que devia ser \u2014 uma rede, e\n-- n\u00E3o o caminho normal.\n--\n-- S\u00F3 o que CHEGA conta. Somar o que n\u00F3s enviamos faria a caixa dizer que h\u00E1\n-- doze por ler logo depois de algu\u00E9m responder doze vezes.\nCREATE OR REPLACE FUNCTION public.plg_conversations_bump_unread() RETURNS trigger\n LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\n AS $$\nBEGIN\n IF NEW.direction IS DISTINCT FROM 'inbound' THEN RETURN NULL; END IF;\n UPDATE public.plg_conversations\n SET unread_count = coalesce(unread_count, 0) + 1\n WHERE id = NEW.conversation_id;\n RETURN NULL;\nEND $$;\n\nREVOKE ALL ON FUNCTION public.plg_conversations_bump_unread() FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.plg_conversations_bump_unread() TO authenticated, service_role;\n\nCOMMENT ON FUNCTION public.plg_conversations_bump_unread() IS\n 'Soma uma n\u00E3o lida por mensagem que CHEGA. A coluna existia e era zerada ao ler, mas nada a incrementava (006).';\n\n-- AFTER, como o irm\u00E3o que carimba a dire\u00E7\u00E3o: falhar a contar n\u00E3o pode recusar\n-- uma mensagem que o provedor j\u00E1 entregou.\nDROP TRIGGER IF EXISTS plg_conversation_messages_bump_unread ON public.plg_conversation_messages;\nCREATE TRIGGER plg_conversation_messages_bump_unread\n AFTER INSERT ON public.plg_conversation_messages\n FOR EACH ROW EXECUTE FUNCTION public.plg_conversations_bump_unread();\n\n-- O retroativo, e o que ele pode e n\u00E3o pode saber.\n--\n-- N\u00E3o h\u00E1 coluna de \"lido at\u00E9 quando\" nesta tabela \u2014 `markRead` zera a conta e\n-- mais nada. Ent\u00E3o n\u00E3o d\u00E1 para reconstituir quantas mensagens ficaram por ler:\n-- o que d\u00E1 para saber \u00E9 que a conversa EST\u00C1 \u00E0 espera, porque a \u00FAltima mensagem\n-- dela \u00E9 de entrada.\n--\n-- Nesse caso a conta vira 1. Um n\u00FAmero inventado seria pior que um n\u00FAmero\n-- modesto: 1 diz \"h\u00E1 algo aqui\", que \u00E9 verdade e \u00E9 o que faz a linha destacar;\n-- dizer \"7\" alegaria uma precis\u00E3o que esta tabela nunca guardou. A partir de\n-- agora o gatilho conta de verdade.\nUPDATE public.plg_conversations\n SET unread_count = 1\n WHERE last_message_direction = 'inbound'\n AND status = 'open'\n AND coalesce(unread_count, 0) = 0;\n";
7
8
  export declare const MIGRATIONS: Array<{
8
9
  id: string;
9
10
  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,yDAAyD,w1RAmKrE,CAAA;AAED,eAAO,MAAM,UAAU,EAAE,KAAK,CAAC;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAOzD,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,sCAAsC,mtOA6HlD,CAAA;AAED,eAAO,MAAM,UAAU,EAAE,KAAK,CAAC;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAQzD,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,qBAsHrE"}
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,qBA6HrE"}
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "status": "preview",
5
5
  "dependencies": []
6
6
  },
7
- "version": "0.13.0",
7
+ "version": "0.14.0",
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.24.0",
32
- "@fayz-ai/admin": "^0.24.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",