@fayz-ai/plugin-marketing 0.13.7 → 0.13.8

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_THE_LANDING_PAGE_NAMES_THE_SITE = "-- =======
3
3
  export declare const MIGRATION_003_A_PORTA_PUBLICA_E_A_PAGINA = "-- 003_a_porta_publica_e_a_pagina.sql\n--\n-- Havia DUAS portas p\u00FAblicas para a mesma coisa.\n--\n-- A landing page \u00E9 uma p\u00E1gina com endere\u00E7o p\u00FAblico que existe para capturar\n-- gente. A \"p\u00E1gina de captura\" (crm/007) \u00E9 um endere\u00E7o p\u00FAblico que existe para\n-- capturar gente. A diferen\u00E7a que eu tinha em mente \u2014 uma \u00E9 conte\u00FAdo, a outra \u00E9\n-- regra \u2014 n\u00E3o \u00E9 uma diferen\u00E7a que o lojista faz: ele quer UMA porta, com o\n-- texto e as regras dela no mesmo lugar.\n--\n-- Ent\u00E3o a regra entra na p\u00E1gina. A landing page ganha o que s\u00F3 a captura tinha:\n-- a chave opaca, o destino, os campos obrigat\u00F3rios e a janela de anteced\u00EAncia.\n-- E as duas fun\u00E7\u00F5es que `anon` executa passam a ler daqui.\n--\n-- POR QUE AS FUN\u00C7\u00D5ES MUDAM DE PLUGIN: a corrente aplica `crm` ANTES de\n-- `marketing`, ent\u00E3o uma fun\u00E7\u00E3o do CRM n\u00E3o pode ler uma tabela de marketing \u2014\n-- num replay do zero ela apontaria para algo que ainda n\u00E3o existe. Captura\n-- passou a ser assunto de marketing, e a fun\u00E7\u00E3o vai junto.\n\nALTER TABLE public.plg_marketing_landing_pages\n -- O identificador P\u00DABLICO e opaco. Continua sendo o que vai na URL; um slug\n -- adivinh\u00E1vel deixaria achar a p\u00E1gina de outra casa escrevendo o nome dela.\n ADD COLUMN IF NOT EXISTS capture_key text,\n -- Em que sala a porta d\u00E1. \"Agendamento\" \u00E9 palavra de sal\u00E3o; num restaurante a\n -- mesma p\u00E1gina marca MESA, e numa loja s\u00F3 captura contato.\n ADD COLUMN IF NOT EXISTS capture_destination text DEFAULT 'lead' NOT NULL,\n ADD COLUMN IF NOT EXISTS capture_required_fields text[] DEFAULT '{}'::text[] NOT NULL,\n -- A janela \u00E9 dupla: piso em horas contra \"daqui a dez minutos\", teto em dias\n -- contra \"o ano que vem\". Nulas quando o destino n\u00E3o marca nada.\n ADD COLUMN IF NOT EXISTS min_advance_hours integer,\n ADD COLUMN IF NOT EXISTS max_advance_days integer;\n\nDO $$ BEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_marketing_landing_pages_capture_key_key') THEN\n ALTER TABLE ONLY public.plg_marketing_landing_pages\n ADD CONSTRAINT plg_marketing_landing_pages_capture_key_key UNIQUE (capture_key);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_marketing_landing_pages_destination') THEN\n ALTER TABLE public.plg_marketing_landing_pages\n ADD CONSTRAINT plg_marketing_landing_pages_destination\n CHECK (capture_destination = ANY (ARRAY['lead'::text, 'booking'::text, 'reservation'::text]));\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_marketing_landing_pages_window') THEN\n ALTER TABLE public.plg_marketing_landing_pages\n ADD CONSTRAINT plg_marketing_landing_pages_window CHECK (\n min_advance_hours IS NULL OR max_advance_days IS NULL\n OR (min_advance_hours >= 0 AND max_advance_days >= 1\n AND min_advance_hours <= max_advance_days * 24));\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_marketing_landing_pages_fields') THEN\n ALTER TABLE public.plg_marketing_landing_pages\n ADD CONSTRAINT plg_marketing_landing_pages_fields CHECK (\n capture_required_fields <@ ARRAY['name','phone','email','document','notes']::text[]);\n END IF;\nEND $$;\n\nCOMMENT ON COLUMN public.plg_marketing_landing_pages.capture_key IS\n 'A chave P\u00DABLICA e opaca da p\u00E1gina. Uma porta s\u00F3: conte\u00FAdo e regra no mesmo lugar (marketing/003).';\n\n-- A chave nasce sozinha, com o mesmo auxiliar do token de convite (crm/003),\n-- que resolve o pgcrypto em tempo de execu\u00E7\u00E3o.\nCREATE OR REPLACE FUNCTION public.trg_marketing_capture_key()\nRETURNS trigger LANGUAGE plpgsql SET search_path TO '' AS $$\nBEGIN\n IF NEW.capture_key IS NULL OR btrim(NEW.capture_key) = '' THEN\n NEW.capture_key := public.crm_url_token(18);\n END IF;\n RETURN NEW;\nEND $$;\n\nDROP TRIGGER IF EXISTS plg_marketing_capture_key ON public.plg_marketing_landing_pages;\nCREATE TRIGGER plg_marketing_capture_key\n BEFORE INSERT ON public.plg_marketing_landing_pages\n FOR EACH ROW EXECUTE FUNCTION public.trg_marketing_capture_key();\n\nUPDATE public.plg_marketing_landing_pages\n SET capture_key = public.crm_url_token(18)\n WHERE capture_key IS NULL;\n\n-- \u2500\u2500 abrir a porta (quem est\u00E1 de fora) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- `anon` executa: devolve o m\u00EDnimo para desenhar a p\u00E1gina e NADA que sirva para\n-- descobrir outra coisa. N\u00E3o lista p\u00E1ginas, n\u00E3o aceita id (s\u00F3 a chave), n\u00E3o\n-- devolve tenant_id. P\u00E1gina inativa responde `closed` e continua existindo \u2014 um\n-- 404 num endere\u00E7o que o cliente tem salvo parece site fora do ar.\nCREATE OR REPLACE FUNCTION public.marketing_capture_load(p_key text)\nRETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER STABLE SET search_path TO ''\nAS $$\nDECLARE v record;\nBEGIN\n IF p_key IS NULL OR length(p_key) < 8 THEN\n RETURN jsonb_build_object('status', 'not_found');\n END IF;\n SELECT * INTO v FROM public.plg_marketing_landing_pages WHERE capture_key = p_key;\n IF v.id IS NULL THEN RETURN jsonb_build_object('status', 'not_found'); END IF;\n\n RETURN jsonb_build_object(\n 'status', CASE WHEN v.is_active AND v.status = 'live' THEN 'ok' ELSE 'closed' END,\n 'name', v.name,\n 'route', v.route,\n 'destination', v.capture_destination,\n 'requiredFields', to_jsonb(v.capture_required_fields),\n 'leadTags', to_jsonb(v.lead_tags),\n -- A janela vai junto para a p\u00E1gina APAGAR os hor\u00E1rios imposs\u00EDveis, em vez\n -- de deixar o cliente escolher um e levar um \"n\u00E3o\" depois.\n 'minAdvanceHours', v.min_advance_hours,\n 'maxAdvanceDays', v.max_advance_days\n );\nEND $$;\n\nREVOKE ALL ON FUNCTION public.marketing_capture_load(text) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.marketing_capture_load(text) TO anon, authenticated, service_role;\n\n-- \u2500\u2500 a janela, aplicada no servidor \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Uma janela que s\u00F3 existe na tela \u00E9 decora\u00E7\u00E3o: quem manda um POST direto passa\n-- por cima. Devolve o MOTIVO porque \"cedo demais\" e \"longe demais\" pedem\n-- corre\u00E7\u00F5es opostas, e uma recusa gen\u00E9rica faz o cliente tentar de novo errado.\nCREATE OR REPLACE FUNCTION public.marketing_capture_window_check(p_key text, p_starts_at timestamp with time zone)\nRETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER STABLE SET search_path TO ''\nAS $$\nDECLARE v record;\nBEGIN\n SELECT * INTO v FROM public.plg_marketing_landing_pages WHERE capture_key = p_key;\n IF v.id IS NULL THEN RETURN jsonb_build_object('ok', false, 'reason', 'not_found'); END IF;\n IF NOT (v.is_active AND v.status = 'live') THEN RETURN jsonb_build_object('ok', false, 'reason', 'closed'); END IF;\n IF p_starts_at IS NULL THEN RETURN jsonb_build_object('ok', true); END IF;\n\n IF v.min_advance_hours IS NOT NULL\n AND p_starts_at < now() + make_interval(hours => v.min_advance_hours) THEN\n RETURN jsonb_build_object('ok', false, 'reason', 'too_soon', 'minAdvanceHours', v.min_advance_hours);\n END IF;\n IF v.max_advance_days IS NOT NULL\n AND p_starts_at > now() + make_interval(days => v.max_advance_days) THEN\n RETURN jsonb_build_object('ok', false, 'reason', 'too_far', 'maxAdvanceDays', v.max_advance_days);\n END IF;\n RETURN jsonb_build_object('ok', true);\nEND $$;\n\nREVOKE ALL ON FUNCTION public.marketing_capture_window_check(text, timestamp with time zone) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.marketing_capture_window_check(text, timestamp with time zone) TO anon, authenticated, service_role;\n\n-- \u2500\u2500 a porta antiga vem para a p\u00E1gina \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Cada `plg_crm_capture_pages` vira uma landing page `planned` com a mesma\n-- chave \u2014 a chave \u00E9 o endere\u00E7o que o cliente pode ter salvo, e troc\u00E1-la\n-- quebraria todo link j\u00E1 colado num site. Nasce como `planned` e n\u00E3o `live`\n-- porque a p\u00E1gina ainda n\u00E3o tem conte\u00FAdo: publicar automaticamente poria no ar\n-- um endere\u00E7o em branco.\nDO $$\nDECLARE r record; v_site uuid; v_n integer := 0;\nBEGIN\n IF to_regclass('public.plg_crm_capture_pages') IS NULL THEN RETURN; END IF;\n\n FOR r IN SELECT * FROM public.plg_crm_capture_pages LOOP\n SELECT s.id INTO v_site FROM public.published_sites s\n WHERE s.tenant_id = r.tenant_id ORDER BY s.created_at LIMIT 1;\n -- Sem site n\u00E3o h\u00E1 onde a p\u00E1gina morar. A porta \u00E9 preservada no aviso, n\u00E3o\n -- apagada em sil\u00EAncio.\n IF v_site IS NULL THEN\n RAISE WARNING '003: tenant % tem porta de captura \"%\" e nenhum site \u2014 a porta n\u00E3o foi migrada', r.tenant_id, r.name;\n CONTINUE;\n END IF;\n\n INSERT INTO public.plg_marketing_landing_pages\n (tenant_id, site_id, name, route, status, is_active, lead_tags, unit_id,\n capture_key, capture_destination, capture_required_fields, min_advance_hours, max_advance_days)\n VALUES\n (r.tenant_id, v_site, r.name,\n '/' || coalesce(public.crm_slug(r.name), 'captura'),\n 'planned', r.is_active, r.lead_tags, r.unit_id,\n r.key, r.destination, r.required_fields, r.min_advance_hours, r.max_advance_days)\n ON CONFLICT DO NOTHING;\n v_n := v_n + 1;\n END LOOP;\n\n RAISE NOTICE '003: % porta(s) de captura viraram landing page', v_n;\nEND $$;\n\nDROP TABLE IF EXISTS public.plg_crm_capture_pages;\nDELETE FROM app.scaffold_registry WHERE table_name = 'plg_crm_capture_pages';\nDROP FUNCTION IF EXISTS public.crm_capture_page_load(text);\nDROP FUNCTION IF EXISTS public.crm_capture_window_check(text, timestamp with time zone);\nDROP FUNCTION IF EXISTS public.trg_crm_capture_page_key();\n";
4
4
  export declare const MIGRATION_004_A_CAMPANHA_APONTA_PARA_UM_RECORTE = "-- 004_a_campanha_aponta_para_um_recorte.sql\n--\n-- A CAMPANHA N\u00C3O CONSEGUIA APONTAR PARA UM SEGMENTO, QUE \u00C9 PARA O QUE O\n-- SEGMENTO FOI FEITO.\n--\n-- O coment\u00E1rio da 004 do CRM diz textualmente por que `plg_crm_segments`\n-- existe: \"a campanha aponta para ele em vez de carregar a pr\u00F3pria c\u00F3pia do\n-- filtro\". S\u00F3 que `marketing_resolve_audience` nunca aprendeu a ler um\n-- segmento \u2014 ela resolve `audience_query = {source_view, where[]}`, a c\u00F3pia do\n-- filtro. Ent\u00E3o havia duas verdades sobre quem \u00E9 \"cliente ativo\": a da tela de\n-- segmentos e a da campanha, e elas divergiam no dia em que algu\u00E9m corrigisse\n-- uma.\n--\n-- E O VOCABUL\u00C1RIO DELA \u00C9 MENOR, que \u00E9 o problema mais grave dos dois. A\n-- campanha aceita seis operadores de compara\u00E7\u00E3o e mais nada: sem `within_days`,\n-- sem `has_tag`, sem `is_null`. Copiar a regra do segmento para dentro da\n-- campanha, al\u00E9m de duplicar, QUEBRA \u2014 \"comprou nos \u00FAltimos 30 dias\" n\u00E3o tem\n-- como ser expresso, e \"anivers\u00E1rio este m\u00EAs\" tamb\u00E9m n\u00E3o.\n--\n-- \u2500\u2500 as duas decis\u00F5es \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- 1. A CAMPANHA GUARDA UM PONTEIRO, N\u00C3O UMA C\u00D3PIA. `{\"segment_id\": \"...\"}` no\n-- `audience_query`. Editar o recorte muda para quem a campanha vai \u2014 que \u00E9\n-- a expectativa de quem acabou de editar o recorte. Quem quiser a foto\n-- congelada usa um segmento do tipo `list`, que \u00E9 exatamente o que ele \u00E9.\n--\n-- 2. O CONJUNTO DE OPERADORES PASSA A SER O MESMO DO CRM, ponteiro ou n\u00E3o.\n-- Uma campanha que aponta direto (sem segmento) ganha `within_days`,\n-- `has_tag` e companhia pela mesma tabela de CASE. Duas linguagens de\n-- filtro no mesmo produto \u00E9 como se chega a duas respostas diferentes para\n-- a mesma pergunta.\n--\n-- O QUE N\u00C3O MUDA, E \u00C9 O MAIS IMPORTANTE: o filtro de consentimento continua na\n-- sa\u00EDda. `marketing_has_consent(tenant, pessoa, purpose)` \u00E9 a \u00FAltima cl\u00E1usula,\n-- sempre \u2014 um p\u00FAblico resolvido por segmento n\u00E3o atravessa o opt-out s\u00F3 porque\n-- veio do CRM. A `purpose` da campanha \u00E9 o que d\u00E1 sentido a ele, e \u00E9 por isso\n-- que \"criar campanha com uma finalidade espec\u00EDfica\" \u00E9 uma funcionalidade e\n-- n\u00E3o um campo de texto: a finalidade DECIDE quem pode receber.\n--\n-- Sem depend\u00EAncia dura de plugin: se `plg_crm_segments` n\u00E3o existe neste app,\n-- um `segment_id` \u00E9 recusado com mensagem clara em vez de estourar 42P01.\n\nSET check_function_bodies = false;\n\nCREATE OR REPLACE FUNCTION public.marketing_resolve_audience(p_campaign_id uuid)\nRETURNS TABLE(person_id uuid)\n LANGUAGE plpgsql STABLE SECURITY DEFINER\n SET search_path TO ''\nAS $_$\nDECLARE\n c public.plg_marketing_campaigns%ROWTYPE;\n v_source text;\n v_rules jsonb;\n v_where text := '';\n v_clause jsonb;\n v_col text;\n v_op text;\n v_kind char;\n v_seg_id uuid;\n v_seg record;\nBEGIN\n SELECT * INTO c FROM public.plg_marketing_campaigns WHERE id = p_campaign_id;\n IF c.id IS NULL THEN\n RAISE EXCEPTION 'marketing_resolve_audience: campaign % not found', p_campaign_id USING ERRCODE = 'P0002';\n END IF;\n\n -- \u2500\u2500 o ponteiro \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 IF c.audience_query ? 'segment_id' THEN\n IF to_regclass('public.plg_crm_segments') IS NULL THEN\n RAISE EXCEPTION 'marketing_resolve_audience: campanha aponta para um segmento, mas o CRM n\u00E3o est\u00E1 instalado neste app'\n USING ERRCODE = '42P01';\n END IF;\n BEGIN\n v_seg_id := (c.audience_query ->> 'segment_id')::uuid;\n EXCEPTION WHEN OTHERS THEN\n RAISE EXCEPTION 'marketing_resolve_audience: segment_id inv\u00E1lido' USING ERRCODE = '22023';\n END;\n\n -- Mesmo tenant, sempre. Um p\u00FAblico que atravessasse inquilino seria a pior\n -- falha poss\u00EDvel deste arquivo.\n SELECT s.source_view, s.rules, s.kind INTO v_seg\n FROM public.plg_crm_segments s\n WHERE s.id = v_seg_id AND s.tenant_id = c.tenant_id;\n IF NOT FOUND THEN\n RAISE EXCEPTION 'marketing_resolve_audience: segmento % n\u00E3o \u00E9 deste tenant', v_seg_id USING ERRCODE = 'P0002';\n END IF;\n\n -- A lista escolhida a dedo \u00C9 a resposta \u2014 e mesmo ela passa pelo consentimento.\n IF v_seg.kind = 'list' THEN\n RETURN QUERY\n SELECT m.person_id FROM public.plg_crm_segment_members m\n WHERE m.segment_id = v_seg_id AND m.tenant_id = c.tenant_id\n AND public.marketing_has_consent(c.tenant_id, m.person_id, c.purpose);\n RETURN;\n END IF;\n\n v_source := coalesce(v_seg.source_view, 'people');\n v_rules := coalesce(v_seg.rules -> 'where', '[]'::jsonb);\n ELSE\n v_source := coalesce(c.audience_query ->> 'source_view', 'people');\n v_rules := coalesce(c.audience_query -> 'where', '[]'::jsonb);\n END IF;\n\n IF v_source !~ '^[a-z_][a-z0-9_]*$' THEN\n RAISE EXCEPTION 'marketing_resolve_audience: % is not a bare relation name', v_source USING ERRCODE = '22023';\n END IF;\n SELECT cl.relkind INTO v_kind FROM pg_catalog.pg_class cl\n JOIN pg_catalog.pg_namespace n ON n.oid = cl.relnamespace\n WHERE n.nspname = 'public' AND cl.relname = v_source;\n IF v_kind IS NULL THEN\n RAISE EXCEPTION 'marketing_resolve_audience: public.% does not exist', v_source USING ERRCODE = '42P01';\n END IF;\n\n v_where := format('tenant_id = %L', c.tenant_id);\n\n -- Unidade da campanha estreita o p\u00FAblico, quando a leitura tem a coluna.\n IF c.unit_id IS NOT NULL AND EXISTS (\n SELECT 1 FROM information_schema.columns\n WHERE table_schema = 'public' AND table_name = v_source AND column_name = 'unit_id'\n ) THEN\n v_where := v_where || format(' AND unit_id = %L', c.unit_id);\n END IF;\n\n -- \u2500\u2500 o mesmo CASE do CRM, operador por operador \u2500\u2500\u2500\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 FOR v_clause IN SELECT * FROM jsonb_array_elements(v_rules) LOOP\n v_col := v_clause ->> 'column';\n v_op := coalesce(v_clause ->> 'op', '=');\n\n IF NOT EXISTS (SELECT 1 FROM information_schema.columns\n WHERE table_schema = 'public' AND table_name = v_source AND column_name = v_col) THEN\n RAISE EXCEPTION 'marketing_resolve_audience: public.% has no column %', v_source, v_col USING ERRCODE = '42703';\n END IF;\n\n v_where := v_where || CASE\n WHEN v_op IN ('=', '<>', '>', '<', '>=', '<=') THEN\n format(' AND %I %s %L', v_col,\n CASE v_op WHEN '<>' THEN '<>' WHEN '>' THEN '>' WHEN '<' THEN '<'\n WHEN '>=' THEN '>=' WHEN '<=' THEN '<=' ELSE '=' END,\n v_clause ->> 'value')\n\n WHEN v_op = 'ilike' THEN\n format(' AND %I ILIKE %L', v_col, '%' || coalesce(v_clause ->> 'value', '') || '%')\n\n WHEN v_op IN ('in', 'not_in') THEN (\n SELECT format(' AND %I %s (%s)', v_col,\n CASE WHEN v_op = 'not_in' THEN 'NOT IN' ELSE 'IN' END,\n coalesce(string_agg(quote_literal(e), ','), 'NULL'))\n FROM jsonb_array_elements_text(coalesce(v_clause -> 'value', '[]'::jsonb)) AS e)\n\n WHEN v_op = 'is_null' THEN format(' AND %I IS NULL', v_col)\n WHEN v_op = 'not_null' THEN format(' AND %I IS NOT NULL', v_col)\n\n WHEN v_op = 'has_tag' THEN\n format(' AND %L = ANY(%I)', v_clause ->> 'value', v_col)\n\n WHEN v_op IN ('within_days', 'older_than_days') THEN (\n SELECT format(' AND %I %s now() - make_interval(days => %s)', v_col,\n CASE WHEN v_op = 'within_days' THEN '>=' ELSE '<' END,\n (coalesce(v_clause ->> 'value', '0'))::integer))\n\n WHEN v_op = 'birthday_within_days' THEN (\n SELECT format(\n ' AND %I IS NOT NULL AND ((EXTRACT(doy FROM %I::date)::int - EXTRACT(doy FROM now())::int + 366) %% 366) <= %s',\n v_col, v_col, (coalesce(v_clause ->> 'value', '0'))::integer))\n\n ELSE\n -- Operador desconhecido ALARGA o p\u00FAblico se for ignorado \u2014 e um p\u00FAblico\n -- alargado em sil\u00EAncio \u00E9 a mensagem indo para quem n\u00E3o devia.\n NULL\n END;\n\n IF v_where IS NULL THEN\n RAISE EXCEPTION 'marketing_resolve_audience: operador % n\u00E3o \u00E9 reconhecido', v_op USING ERRCODE = '22023';\n END IF;\n END LOOP;\n\n -- O consentimento \u00E9 a \u00DALTIMA cl\u00E1usula, e continua sendo, venha o p\u00FAblico de\n -- onde vier.\n RETURN QUERY EXECUTE format(\n 'SELECT s.id FROM public.%I s WHERE %s AND public.marketing_has_consent(%L, s.id, %L)',\n v_source, v_where, c.tenant_id, c.purpose);\nEND $_$;\n\nCOMMENT ON FUNCTION public.marketing_resolve_audience(uuid) IS\n 'Quem a campanha alcan\u00E7a. Aponta para um segmento do CRM (segment_id) ou carrega a pr\u00F3pria regra; mesmo conjunto de operadores do CRM; consentimento sempre na sa\u00EDda (004).';\n";
5
5
  export declare const MIGRATION_005_A_ATRIBUICAO_DO_LEAD_ATRAVESSA_A_ORIGEM_ATE_O_CANAL = "-- ---------------------------------------------------------------------------\n-- 005_a_atribuicao_do_lead_atravessa_a_origem_ate_o_canal.sql \u2014 os tr\u00EAs n\u00EDveis\n-- de atribui\u00E7\u00E3o num lugar s\u00F3, e o conserto de uma tela que contava zero.\n--\n-- \u2500\u2500 por que este arquivo est\u00E1 no marketing e n\u00E3o no CRM \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- A view junta tr\u00EAs tabelas de duas casas: `plg_crm_lead_sources` (CRM) com\n-- `plg_marketing_channels` e `plg_marketing_campaigns` (aqui). Na ordem da\n-- chain o CRM roda ANTES do marketing, ent\u00E3o do lado de l\u00E1 as tabelas deste\n-- plugin ainda n\u00E3o existem. Do lado de c\u00E1 as do CRM j\u00E1 existem.\n--\n-- A regra que isso ensina: quem cria uma view que atravessa plugins \u00E9 o \u00DALTIMO\n-- dos seus dependentes. `crm/038` tentou o contr\u00E1rio e o `db-from-zero` pegou\n-- na primeira passada.\n--\n-- \u2500\u2500 as tr\u00EAs perguntas \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- CANAL (medium) COMO a gente se encontrou. \"Evento presencial\"\n-- ORIGEM (source) ONDE, especificamente. \"Sal\u00E3o Abrasel\"\n-- CAMPANHA QUAL esfor\u00E7o, com custo. \"ABRASEL 2026\"\n--\n-- Salesforce separa `LeadSource` de `Campaign`; HubSpot separa \"Original\n-- source\" do drill-down; Odoo e Mautic usam o trio UTM. Nenhum junta os tr\u00EAs\n-- num campo s\u00F3: \"quanto custou este lead\" s\u00F3 existe na campanha, e \"que tipo\n-- de esfor\u00E7o traz gente melhor\" s\u00F3 existe no canal.\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-- `v_marketing_attribution` lia `metadata.sourceId` e chamava aquilo de\n-- `channel_raw` \u2014 a ORIGEM ocupando o lugar do CANAL. Com isso a tela de\n-- Canais contava zero para todos, porque nenhum `channel_key` jamais bate com\n-- um uuid de origem. Esta view atravessa a origem at\u00E9 o canal, que \u00E9 o salto\n-- que faltava. A antiga fica: outras telas dependem da forma dela.\n-- ---------------------------------------------------------------------------\n\nDROP VIEW IF EXISTS public.v_marketing_lead_attribution;\nCREATE VIEW public.v_marketing_lead_attribution\nWITH (security_invoker = true) AS\n SELECT p.id AS lead_id,\n p.tenant_id,\n p.created_at,\n s.id AS source_id,\n s.name AS source_name,\n -- O canal vem da ORIGEM; quando ela n\u00E3o declara, o lead n\u00E3o \u00E9\n -- atribu\u00EDdo a canal nenhum. Nulo \u00E9 honesto: for\u00E7ar 'direct' faria a\n -- tela afirmar uma proced\u00EAncia que ningu\u00E9m mediu.\n s.channel_key,\n ch.label AS channel_label,\n ch.kind AS channel_kind,\n (p.metadata ->> 'campaignId') AS campaign_id,\n mc.name AS campaign_name,\n mc.spend AS campaign_spend\n FROM public.people p\n LEFT JOIN public.plg_crm_lead_sources s\n ON s.tenant_id = p.tenant_id\n AND s.id::text = (p.metadata ->> 'sourceId')\n LEFT JOIN public.plg_marketing_channels ch\n ON ch.tenant_id = p.tenant_id\n AND ch.channel_key = s.channel_key\n LEFT JOIN public.plg_marketing_campaigns mc\n ON mc.tenant_id = p.tenant_id\n AND mc.id::text = (p.metadata ->> 'campaignId')\n WHERE p.kind = 'lead'\n AND p.merged_into_id IS NULL\n AND p.tenant_id IN (SELECT public.user_tenant_ids());\n\nGRANT SELECT ON public.v_marketing_lead_attribution TO authenticated;\n\nCOMMENT ON VIEW public.v_marketing_lead_attribution IS\n 'Cada lead com seus tr\u00EAs n\u00EDveis: canal (COMO), origem (ONDE) e campanha (QUAL esfor\u00E7o, com custo). A origem resolve o canal \u2014 o lead n\u00E3o o carrega (marketing/005).';\n";
6
+ export declare const MIGRATION_006_THE_CAMPAIGN_ENGINE_IS_SERVER_ONLY = "-- 006_the_campaign_engine_is_server_only.sql\n--\n-- `marketing_campaign_run`, `marketing_resolve_audience` and\n-- `marketing_has_consent` are SECURITY DEFINER and look a campaign or a tenant\n-- up by id without asking who is calling. Granted to `authenticated`, any\n-- logged-in user of any tenant could fire another tenant's campaign or list\n-- its audience by guessing a uuid.\n--\n-- No screen, edge function or agent tool calls them: the only caller is\n-- `marketing_run_due` on the minute tick (via `event_runner_tick`, as postgres)\n-- and the functions themselves. So the fix is to take the door away, not to\n-- guard it. A future \"send now\" button gets a tenant-checked wrapper.\n--\n-- `REVOKE ... FROM PUBLIC` alone is not enough here: the pool's default\n-- privileges grant EXECUTE to `authenticated` on every new function in public.\n\nREVOKE ALL ON FUNCTION public.marketing_campaign_run(uuid, integer) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION public.marketing_campaign_run(uuid, integer) TO service_role;\n\nREVOKE ALL ON FUNCTION public.marketing_resolve_audience(uuid) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION public.marketing_resolve_audience(uuid) TO service_role;\n\nREVOKE ALL ON FUNCTION public.marketing_has_consent(uuid, uuid, text) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION public.marketing_has_consent(uuid, uuid, text) TO service_role;\n\nREVOKE ALL ON FUNCTION public.marketing_run_due(integer) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION public.marketing_run_due(integer) TO service_role;\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,wo3EAwmClC,CAAA;AAED,eAAO,MAAM,6CAA6C,w6DA6CzD,CAAA;AAED,eAAO,MAAM,wCAAwC,i1UA0LpD,CAAA;AAED,eAAO,MAAM,+CAA+C,ilTAyL3D,CAAA;AAED,eAAO,MAAM,iEAAiE,k0IAsE7E,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,wo3EAwmClC,CAAA;AAED,eAAO,MAAM,6CAA6C,w6DA6CzD,CAAA;AAED,eAAO,MAAM,wCAAwC,i1UA0LpD,CAAA;AAED,eAAO,MAAM,+CAA+C,ilTAyL3D,CAAA;AAED,eAAO,MAAM,iEAAiE,k0IAsE7E,CAAA;AAED,eAAO,MAAM,gDAAgD,0kDA2B5D,CAAA;AAED,eAAO,MAAM,UAAU,EAAE,KAAK,CAAC;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAOzD,CAAA"}
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "status": "beta",
5
5
  "dependencies": []
6
6
  },
7
- "version": "0.13.7",
7
+ "version": "0.13.8",
8
8
  "description": "Fayz SDK — marketing campaigns & broadcasts plugin",
9
9
  "type": "module",
10
10
  "sideEffects": false,
@@ -33,10 +33,10 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "@tanstack/react-table": "^8.20.0",
36
- "@fayz-ai/core": "^0.36.0",
37
- "@fayz-ai/admin": "^0.36.0",
38
- "@fayz-ai/ui": "^0.36.0",
39
- "@fayz-ai/db": "^0.22.0"
36
+ "@fayz-ai/core": "^0.36.1",
37
+ "@fayz-ai/admin": "^0.37.0",
38
+ "@fayz-ai/ui": "^0.37.0",
39
+ "@fayz-ai/db": "^0.22.3"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@types/react": "^18.3.0",