@fayz-ai/plugin-marketing 0.9.0 → 0.10.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.
Files changed (100) hide show
  1. package/README.md +3 -3
  2. package/dist/{MarketingContext.d.ts → context.d.ts} +5 -3
  3. package/dist/context.d.ts.map +1 -0
  4. package/dist/data/mock.d.ts +1 -1
  5. package/dist/data/mock.d.ts.map +1 -1
  6. package/dist/{registries.d.ts → data/registries.d.ts} +1 -1
  7. package/dist/data/registries.d.ts.map +1 -0
  8. package/dist/index.d.ts +6 -4
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +241 -188
  11. package/dist/index.js.map +1 -1
  12. package/dist/integrations/ranklayer/connectorDef.d.ts +1 -1
  13. package/dist/integrations/ranklayer/connectorDef.d.ts.map +1 -1
  14. package/dist/integrations/ranklayer/data/supabase.d.ts +24 -6
  15. package/dist/integrations/ranklayer/data/supabase.d.ts.map +1 -1
  16. package/dist/integrations/ranklayer/outbound.d.ts +16 -0
  17. package/dist/integrations/ranklayer/outbound.d.ts.map +1 -0
  18. package/dist/integrations/ranklayer/settings.d.ts +12 -0
  19. package/dist/integrations/ranklayer/settings.d.ts.map +1 -0
  20. package/dist/integrations/ranklayer/types.d.ts +0 -1
  21. package/dist/integrations/ranklayer/types.d.ts.map +1 -1
  22. package/dist/lib/format.d.ts.map +1 -0
  23. package/dist/{presets.d.ts → lib/presets.d.ts} +1 -1
  24. package/dist/lib/presets.d.ts.map +1 -0
  25. package/dist/migrations/index.d.ts +1 -1
  26. package/dist/migrations/index.d.ts.map +1 -1
  27. package/dist/{MarketingPage.d.ts → views/MarketingPage.d.ts} +5 -5
  28. package/dist/views/MarketingPage.d.ts.map +1 -0
  29. package/dist/views/blog/BlogAdminView.d.ts.map +1 -1
  30. package/dist/views/content/ContentPlannerContext.d.ts +1 -1
  31. package/dist/views/content/ContentPlannerContext.d.ts.map +1 -1
  32. package/dist/views/dashboardWidgets.d.ts +1 -1
  33. package/dist/views/dashboardWidgets.d.ts.map +1 -1
  34. package/package.json +6 -9
  35. package/dist/MarketingContext.d.ts.map +0 -1
  36. package/dist/MarketingPage.d.ts.map +0 -1
  37. package/dist/format.d.ts.map +0 -1
  38. package/dist/presets.d.ts.map +0 -1
  39. package/dist/registries.d.ts.map +0 -1
  40. package/src/MarketingContext.tsx +0 -42
  41. package/src/MarketingPage.tsx +0 -139
  42. package/src/components/MarketingBits.tsx +0 -131
  43. package/src/components/icons.tsx +0 -36
  44. package/src/data/blogEntities.tsx +0 -91
  45. package/src/data/capability.test.ts +0 -92
  46. package/src/data/contentMedia.ts +0 -25
  47. package/src/data/contentMock.ts +0 -253
  48. package/src/data/contentSupabase.ts +0 -263
  49. package/src/data/contentTypes.ts +0 -138
  50. package/src/data/mock.ts +0 -308
  51. package/src/data/supabase.ts +0 -613
  52. package/src/data/tables.ts +0 -33
  53. package/src/data/types.ts +0 -97
  54. package/src/format.ts +0 -40
  55. package/src/index.ts +0 -457
  56. package/src/integrations/ranklayer/RANKLAYER.md +0 -40
  57. package/src/integrations/ranklayer/connectorDef.tsx +0 -102
  58. package/src/integrations/ranklayer/data/supabase.ts +0 -106
  59. package/src/integrations/ranklayer/types.ts +0 -16
  60. package/src/locales/en.ts +0 -192
  61. package/src/locales/index.ts +0 -7
  62. package/src/locales/pt-BR.ts +0 -192
  63. package/src/migrations/000_plg_rename.sql +0 -15
  64. package/src/migrations/001_content_planner.sql +0 -94
  65. package/src/migrations/002_multi_platform.sql +0 -25
  66. package/src/migrations/003_recording_ops.sql +0 -25
  67. package/src/migrations/004_ranklayer.sql +0 -50
  68. package/src/migrations/005_analytics_base.sql +0 -60
  69. package/src/migrations/006_analytics_views.sql +0 -68
  70. package/src/migrations/007_agent_rpcs.sql +0 -119
  71. package/src/migrations/008_landing_pages.sql +0 -112
  72. package/src/migrations/index.ts +0 -601
  73. package/src/presets.ts +0 -119
  74. package/src/registries.ts +0 -62
  75. package/src/schema/index.ts +0 -106
  76. package/src/store.ts +0 -102
  77. package/src/types.ts +0 -153
  78. package/src/views/CampaignComposer.tsx +0 -89
  79. package/src/views/CampaignsView.tsx +0 -59
  80. package/src/views/ChannelDetailView.tsx +0 -59
  81. package/src/views/ChannelsView.tsx +0 -46
  82. package/src/views/FunnelView.tsx +0 -56
  83. package/src/views/LandingPageComposer.tsx +0 -201
  84. package/src/views/LandingPagesView.tsx +0 -172
  85. package/src/views/OverviewView.tsx +0 -24
  86. package/src/views/SettingsView.tsx +0 -72
  87. package/src/views/blog/BlogAdminView.tsx +0 -99
  88. package/src/views/blog/BlogEntityForm.tsx +0 -101
  89. package/src/views/blog/useEntityCrud.ts +0 -50
  90. package/src/views/content/ContentPlannerContext.tsx +0 -13
  91. package/src/views/content/ContentView.tsx +0 -908
  92. package/src/views/content/PlanBriefView.tsx +0 -268
  93. package/src/views/content/PostPage.tsx +0 -370
  94. package/src/views/content/PostSocialPreview.tsx +0 -212
  95. package/src/views/content/RecordingChecklist.tsx +0 -145
  96. package/src/views/content/contentBits.tsx +0 -114
  97. package/src/views/content/contentStore.ts +0 -160
  98. package/src/views/content/templates.ts +0 -172
  99. package/src/views/dashboardWidgets.tsx +0 -173
  100. /package/dist/{format.d.ts → lib/format.d.ts} +0 -0
package/dist/index.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import React9, { useState, useEffect, useMemo, useCallback } from 'react';
2
- import { getActiveTenantId, registerTranslations, createSafeDataProvider, getSupabaseClientOptional, useTranslation, useDataChanged, resolveDataProvider } from '@fayz-ai/core';
3
- import { createPluginContext, PluginSettingsPanel, useModuleNavigation, createViewRouter, ModuleActionBar, SettingsGroup, SelectRow, ToggleRow, PermissionGate, useLimitGuard, useOrganizationStore, CrudFormPage, invalidateLimit, CrudListView } from '@fayz-ai/saas';
4
- import { defineKpiWidget, defineCustomWidget, defineTableWidget, ModulePage, DataTable, SubpageHeader, Button, Skeleton, ConfirmDialog, ContentSplit, ContentSplitTrigger, Input, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, MarkdownEditor, SegmentedControl, DashboardCanvas, cn, Modal, ModalContent, ModalHeader, ModalTitle, ModalBody, ModalFooter, Badge, SelectSeparator, Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetBody, SheetFooter, Checkbox, toast, KpiCard as KpiCard$1, Card, CardHeader, CardTitle, CardContent, TableWidget } from '@fayz-ai/ui';
2
+ import { connectionSettings, connectionStatus, settingsStatus, createConnectionStore, getActiveTenantId, registerTranslations, createSafeDataProvider, getSupabaseClientOptional, useTranslation, useDataChanged, resolveDataProvider } from '@fayz-ai/core';
3
+ import { createPluginContext, fayzConnectorOAuth, PluginSettingsPanel, useModuleNavigation, createViewRouter, ModuleActionBar, SettingsGroup, SelectRow, ToggleRow, PermissionGate, useLimitGuard, useOrganizationStore, CrudFormPage, invalidateLimit, CrudListView } from '@fayz-ai/admin';
4
+ import { defineKpiWidget, defineCustomWidget, defineTableWidget, ModulePage, DataTable, SubpageHeader, Button, Skeleton, ConfirmDialog, ContentSplit, ContentSplitTrigger, Input, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, MarkdownEditor, DashboardCanvas, SegmentedControl, cn, Modal, ModalContent, ModalHeader, ModalTitle, ModalBody, ModalFooter, Badge, SelectSeparator, Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetBody, SheetFooter, Checkbox, toast, KpiCard as KpiCard$1, Card, CardHeader, CardTitle, CardContent, TableWidget } from '@fayz-ai/ui';
5
5
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
6
- import { Sparkles, History, CheckCircle2, AlertCircle, ChevronRight, Users, Target, Percent, DollarSign, Trash2, Plus, ExternalLink, Pencil, Layers, MousePointerClick, NotebookPen, ArrowUpRight, ArrowDownRight, Settings2, Columns3, CalendarDays, List, ChevronLeft, Loader2, ImagePlus, X, Bike, Utensils, DoorOpen, MessageCircle, Instagram, Mail, Globe, Megaphone, Search, Link2, AtSign, Pin, Twitter, Linkedin, Facebook, Youtube, Music2, Repeat2, Heart, BarChart2, Share2, ThumbsUp, Send, MoreHorizontal, Bookmark, GripVertical, Image, Trophy } from 'lucide-react';
6
+ import { History, CheckCircle2, AlertCircle, ChevronRight, Users, Target, Percent, DollarSign, Trash2, Plus, ExternalLink, Sparkles, Pencil, Layers, MousePointerClick, NotebookPen, ArrowUpRight, ArrowDownRight, Settings2, Columns3, CalendarDays, List, ChevronLeft, Loader2, ImagePlus, X, Bike, Utensils, DoorOpen, MessageCircle, Instagram, Mail, Globe, Megaphone, Search, Link2, AtSign, Pin, Twitter, Linkedin, Facebook, Youtube, Music2, Repeat2, Heart, BarChart2, Share2, ThumbsUp, Send, MoreHorizontal, Bookmark, GripVertical, Image, Trophy } from 'lucide-react';
7
7
  import { createStore } from 'zustand/vanilla';
8
8
 
9
9
  // src/index.ts
@@ -31,7 +31,7 @@ function OverviewView() {
31
31
  );
32
32
  }
33
33
 
34
- // src/format.ts
34
+ // src/lib/format.ts
35
35
  var DEFAULT_CURRENCY = { code: "USD", locale: "en-US", symbol: "$" };
36
36
  function formatCurrency(value, currency) {
37
37
  try {
@@ -2543,7 +2543,7 @@ function useEntityCrud(entity) {
2543
2543
  const remove = useCallback((id) => provider2.remove(id), [provider2]);
2544
2544
  return { items, total, fetch, getById, create, update, remove };
2545
2545
  }
2546
- function PostsList({ navigate }) {
2546
+ function PostsList({ navigate, navTabs }) {
2547
2547
  const entity = useMemo(() => buildBlogPostEntity(), []);
2548
2548
  const { items, total, fetch } = useEntityCrud(entity);
2549
2549
  const [search, setSearch] = useState("");
@@ -2577,13 +2577,15 @@ function PostsList({ navigate }) {
2577
2577
  facets,
2578
2578
  activeFilters: { status: statusFilter },
2579
2579
  onFacetChange: (_field, value) => setStatusFilter(value),
2580
+ navTabs,
2581
+ activeNavTab: "posts",
2580
2582
  onNew: () => navigate("blog-new"),
2581
2583
  addLabel: "New post",
2582
2584
  onRowClick: (row) => navigate(`blog-post:${row.id}`)
2583
2585
  }
2584
2586
  );
2585
2587
  }
2586
- function CategoriesList({ navigate }) {
2588
+ function CategoriesList({ navigate, navTabs }) {
2587
2589
  const entity = useMemo(() => buildBlogCategoryEntity(), []);
2588
2590
  const { items, total, fetch } = useEntityCrud(entity);
2589
2591
  const [search, setSearch] = useState("");
@@ -2606,6 +2608,8 @@ function CategoriesList({ navigate }) {
2606
2608
  search,
2607
2609
  onSearchChange: setSearch,
2608
2610
  searchPlaceholder: "Search categories\u2026",
2611
+ navTabs,
2612
+ activeNavTab: "categories",
2609
2613
  onNew: () => navigate("blog-cat-new"),
2610
2614
  addLabel: "New category",
2611
2615
  onRowClick: (row) => navigate(`blog-cat:${row.id}`)
@@ -2615,21 +2619,11 @@ function CategoriesList({ navigate }) {
2615
2619
  function BlogAdminView({ navigate }) {
2616
2620
  const { labels } = useMarketingConfig();
2617
2621
  const [lens, setLens] = useState("posts");
2618
- return /* @__PURE__ */ jsxs("div", { className: "space-y-4", children: [
2619
- /* @__PURE__ */ jsx(
2620
- SegmentedControl,
2621
- {
2622
- "aria-label": "Blog section",
2623
- options: [
2624
- { value: "posts", label: labels.blog },
2625
- { value: "categories", label: labels.blogCategories }
2626
- ],
2627
- value: lens,
2628
- onChange: (v) => setLens(v)
2629
- }
2630
- ),
2631
- lens === "posts" ? /* @__PURE__ */ jsx(PostsList, { navigate }) : /* @__PURE__ */ jsx(CategoriesList, { navigate })
2632
- ] });
2622
+ const navTabs = useMemo(() => [
2623
+ { id: "posts", label: labels.blog, onSelect: () => setLens("posts") },
2624
+ { id: "categories", label: labels.blogCategories, onSelect: () => setLens("categories") }
2625
+ ], [labels.blog, labels.blogCategories]);
2626
+ return lens === "posts" ? /* @__PURE__ */ jsx(PostsList, { navigate, navTabs }) : /* @__PURE__ */ jsx(CategoriesList, { navigate, navTabs });
2633
2627
  }
2634
2628
  function BlogEntityForm({
2635
2629
  entity,
@@ -2746,7 +2740,7 @@ function buildNav(config, view, navigate) {
2746
2740
  }
2747
2741
  return items;
2748
2742
  }
2749
- function MarketingPage({ config, provider: provider2, store, contentProvider, contentStore }) {
2743
+ function MarketingPage({ config, provider: provider2, store: store3, contentProvider, contentStore }) {
2750
2744
  const t = useTranslation();
2751
2745
  const { view, direction, navigate } = useModuleNavigation("/marketing", {
2752
2746
  overview: 0,
@@ -2765,11 +2759,11 @@ function MarketingPage({ config, provider: provider2, store, contentProvider, co
2765
2759
  "blog-cat": 1
2766
2760
  }, "overview");
2767
2761
  React9.useEffect(() => {
2768
- void store.getState().load();
2769
- }, [store]);
2762
+ void store3.getState().load();
2763
+ }, [store3]);
2770
2764
  useDataChanged({ table: "plg_marketing_campaigns" }, () => {
2771
- void store.getState().refresh();
2772
- }, [store]);
2765
+ void store3.getState().refresh();
2766
+ }, [store3]);
2773
2767
  const isOverview = view === "overview";
2774
2768
  const nav = buildNav(config, view, navigate);
2775
2769
  const blogPostEntity = React9.useMemo(() => buildBlogPostEntity(), []);
@@ -2793,12 +2787,13 @@ function MarketingPage({ config, provider: provider2, store, contentProvider, co
2793
2787
  { id: "blog-cat", render: ({ id }) => /* @__PURE__ */ jsx(BlogEntityForm, { entity: blogCategoryEntity, editId: id, parentLabel: config.labels.blogCategories, onDone: () => navigate("blog") }) },
2794
2788
  { id: "overview", render: () => /* @__PURE__ */ jsx(OverviewView, {}) }
2795
2789
  ], "overview");
2796
- return /* @__PURE__ */ jsx(MarketingContextProvider, { config, provider: provider2, store, children: /* @__PURE__ */ jsx(ContentPlannerContextProvider, { config, provider: contentProvider, store: contentStore, children: /* @__PURE__ */ jsx(
2790
+ return /* @__PURE__ */ jsx(MarketingContextProvider, { config, provider: provider2, store: store3, children: /* @__PURE__ */ jsx(ContentPlannerContextProvider, { config, provider: contentProvider, store: contentStore, children: /* @__PURE__ */ jsx(
2797
2791
  ModulePage,
2798
2792
  {
2799
2793
  title: config.labels.pageTitle,
2800
2794
  subtitle: config.labels.pageSubtitle,
2801
2795
  nav,
2796
+ navVariant: config.moduleNav,
2802
2797
  showHeader: isOverview,
2803
2798
  viewKey: view,
2804
2799
  direction,
@@ -2908,7 +2903,7 @@ var V = {
2908
2903
  leadTagEvents: "v_marketing_lead_tag_events"
2909
2904
  };
2910
2905
 
2911
- // src/registries.ts
2906
+ // src/data/registries.ts
2912
2907
  var KIND_OPTIONS = [
2913
2908
  { value: "paid", label: "Paid" },
2914
2909
  { value: "organic", label: "Organic" },
@@ -3090,7 +3085,7 @@ function createMarketingDashboardWidgets(ctx3) {
3090
3085
  ];
3091
3086
  }
3092
3087
 
3093
- // src/presets.ts
3088
+ // src/lib/presets.ts
3094
3089
  var AGENCY = {
3095
3090
  conversion: {
3096
3091
  id: "won-deal",
@@ -4598,57 +4593,6 @@ CREATE POLICY plg_marketing_media_insert ON storage.objects FOR INSERT TO authen
4598
4593
  CREATE POLICY plg_marketing_media_update ON storage.objects FOR UPDATE TO authenticated USING (bucket_id = 'mkt-media');
4599
4594
  CREATE POLICY plg_marketing_media_delete ON storage.objects FOR DELETE TO authenticated USING (bucket_id = 'mkt-media');
4600
4595
  `;
4601
- var MIGRATION_004_RANKLAYER = `-- Marketing Plugin: RankLayer integration (SEO content publishing).
4602
- -- Control-plane state for the RankLayer connector rendered in Marketing \u2192
4603
- -- Integra\xE7\xF5es. Prefix: plg_marketing_ranklayer_. Scaffold: the real sync
4604
- -- (edge function + RankLayer API calls) lands via an external PR \u2014 see
4605
- -- src/integrations/ranklayer/RANKLAYER.md.
4606
-
4607
- CREATE TABLE IF NOT EXISTS public.plg_marketing_ranklayer_integrations (
4608
- id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
4609
- tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
4610
- api_key text,
4611
- site_domain text,
4612
- active boolean NOT NULL DEFAULT true,
4613
- last_sync_at timestamptz,
4614
- created_at timestamptz NOT NULL DEFAULT now(),
4615
- updated_at timestamptz NOT NULL DEFAULT now()
4616
- );
4617
- ALTER TABLE public.plg_marketing_ranklayer_integrations ENABLE ROW LEVEL SECURITY;
4618
- CREATE UNIQUE INDEX IF NOT EXISTS uq_plg_marketing_ranklayer_integrations_tenant
4619
- ON public.plg_marketing_ranklayer_integrations(tenant_id);
4620
-
4621
- CREATE TABLE IF NOT EXISTS public.plg_marketing_ranklayer_sync_log (
4622
- id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
4623
- tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
4624
- integration_id uuid REFERENCES public.plg_marketing_ranklayer_integrations(id) ON DELETE CASCADE,
4625
- status text NOT NULL DEFAULT 'success' CHECK (status IN ('success', 'partial', 'error')),
4626
- message text,
4627
- fetched integer NOT NULL DEFAULT 0,
4628
- written integer NOT NULL DEFAULT 0,
4629
- created_at timestamptz NOT NULL DEFAULT now()
4630
- );
4631
- ALTER TABLE public.plg_marketing_ranklayer_sync_log ENABLE ROW LEVEL SECURITY;
4632
- CREATE INDEX IF NOT EXISTS idx_plg_marketing_ranklayer_sync_log_tenant
4633
- ON public.plg_marketing_ranklayer_sync_log(tenant_id, created_at DESC);
4634
-
4635
- -- RLS: authenticated tenant CRUD
4636
- DROP POLICY IF EXISTS plg_marketing_ranklayer_integrations_select ON public.plg_marketing_ranklayer_integrations;
4637
- DROP POLICY IF EXISTS plg_marketing_ranklayer_integrations_insert ON public.plg_marketing_ranklayer_integrations;
4638
- DROP POLICY IF EXISTS plg_marketing_ranklayer_integrations_update ON public.plg_marketing_ranklayer_integrations;
4639
- DROP POLICY IF EXISTS plg_marketing_ranklayer_integrations_delete ON public.plg_marketing_ranklayer_integrations;
4640
- CREATE POLICY plg_marketing_ranklayer_integrations_select ON public.plg_marketing_ranklayer_integrations FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
4641
- CREATE POLICY plg_marketing_ranklayer_integrations_insert ON public.plg_marketing_ranklayer_integrations FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
4642
- CREATE POLICY plg_marketing_ranklayer_integrations_update ON public.plg_marketing_ranklayer_integrations FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
4643
- CREATE POLICY plg_marketing_ranklayer_integrations_delete ON public.plg_marketing_ranklayer_integrations FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
4644
- GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_marketing_ranklayer_integrations TO authenticated;
4645
-
4646
- DROP POLICY IF EXISTS plg_marketing_ranklayer_sync_log_select ON public.plg_marketing_ranklayer_sync_log;
4647
- DROP POLICY IF EXISTS plg_marketing_ranklayer_sync_log_insert ON public.plg_marketing_ranklayer_sync_log;
4648
- CREATE POLICY plg_marketing_ranklayer_sync_log_select ON public.plg_marketing_ranklayer_sync_log FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
4649
- CREATE POLICY plg_marketing_ranklayer_sync_log_insert ON public.plg_marketing_ranklayer_sync_log FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
4650
- GRANT SELECT, INSERT ON public.plg_marketing_ranklayer_sync_log TO authenticated;
4651
- `;
4652
4596
  var MIGRATION_005_ANALYTICS_BASE = `-- Marketing Plugin: analytics base tables (channels + campaigns).
4653
4597
  -- Prefix: plg_marketing_
4654
4598
  -- Channels are tenant-owned rows lazily seeded from the app's domain preset
@@ -5012,75 +4956,122 @@ WHERE p.kind = 'lead'
5012
4956
  AND COALESCE(p.is_active, true);
5013
4957
  GRANT SELECT ON public.v_marketing_lead_tag_events TO authenticated;
5014
4958
  `;
5015
- var INTEGRATIONS = "plg_marketing_ranklayer_integrations";
5016
- var SYNC_LOG = "plg_marketing_ranklayer_sync_log";
5017
- function getClient3() {
5018
- const supabase = getSupabaseClientOptional();
5019
- if (!supabase) throw new Error("[ranklayer] Supabase not initialized");
5020
- return supabase;
4959
+ var MIGRATION_010_RANKLAYER_ONTO_CONNECTIONS = `-- ============================================================================
4960
+ -- Marketing 010 \u2014 RankLayer stops owning tables.
4961
+ --
4962
+ -- Retires plg_marketing_ranklayer_integrations and plg_marketing_ranklayer_sync_log.
4963
+ -- Both were the generic shape under a private name: tenant, active, one config
4964
+ -- field, last sync \u2014 plus a run history beside it. They are now rows of
4965
+ -- public.plg_connections / public.plg_sync_runs (packages/db 029), reached
4966
+ -- through createConnectionStore('ranklayer'). See FAY-1376.
4967
+ --
4968
+ -- Where each column went:
4969
+ -- integrations.site_domain \u2192 plg_connections.settings->>'siteDomain'
4970
+ -- integrations.active \u2192 plg_connections.active
4971
+ -- integrations.last_sync_at \u2192 plg_connections.last_sync_at
4972
+ -- UNIQUE (tenant_id) \u2192 UNIQUE (tenant_id, connector_id, instance_key),
4973
+ -- with instance_key = '' (RankLayer publishes to
4974
+ -- one site per tenant; a second site is a second
4975
+ -- instance_key the day it is asked for)
4976
+ -- sync_log.created_at \u2192 plg_sync_runs.started_at
4977
+ -- sync_log.status \u2192 plg_sync_runs.status
4978
+ -- sync_log.fetched/written \u2192 plg_sync_runs.fetched/written
4979
+ -- sync_log.message \u2192 plg_sync_runs.error on a failed run, and
4980
+ -- plg_sync_runs.stats->>'message' otherwise
4981
+ -- sync_log.integration_id \u2192 plg_sync_runs.connection_id
4982
+ --
4983
+ -- 004 (which created the pair) and 009 (which dropped its api_key column) are
4984
+ -- deleted rather than edited: their ids stay in the ledger of any pool that ran
4985
+ -- them, and this migration is what removes what they left behind. Nothing is in
4986
+ -- production, so this is a drop, not a data migration.
4987
+ --
4988
+ -- One thing is not merely moved: 004 granted INSERT on the sync log to
4989
+ -- \`authenticated\`, so any member of the tenant could forge publication history.
4990
+ -- plg_sync_runs carries no such grant \u2014 runs are written server-side. The
4991
+ -- exposure closes by moving.
4992
+ -- ============================================================================
4993
+
4994
+ DROP TABLE IF EXISTS public.plg_marketing_ranklayer_sync_log CASCADE;
4995
+ DROP TABLE IF EXISTS public.plg_marketing_ranklayer_integrations CASCADE;
4996
+ `;
4997
+
4998
+ // src/integrations/ranklayer/settings.ts
4999
+ var RANKLAYER_SETTINGS = [
5000
+ {
5001
+ key: "siteDomain",
5002
+ label: "Dom\xEDnio do site",
5003
+ description: "O site em que a RankLayer publica os artigos deste blog.",
5004
+ type: "text",
5005
+ placeholder: "exemplo.com.br",
5006
+ requiredToOperate: true
5007
+ }
5008
+ ];
5009
+
5010
+ // src/integrations/ranklayer/data/supabase.ts
5011
+ var RANKLAYER_CONNECTOR_ID = "ranklayer";
5012
+ var INSTANCE_KEY = "";
5013
+ function ranklayerConnectionStore() {
5014
+ return createConnectionStore(RANKLAYER_CONNECTOR_ID, {
5015
+ settings: RANKLAYER_SETTINGS,
5016
+ instanceKey: INSTANCE_KEY
5017
+ });
5021
5018
  }
5022
- function requireTenantId3() {
5023
- const tenantId = getActiveTenantId();
5024
- if (!tenantId) throw new Error("[ranklayer] No active tenant");
5025
- return tenantId;
5019
+ var store = ranklayerConnectionStore;
5020
+ function toIntegration(connection) {
5021
+ return {
5022
+ id: connection.id ?? "",
5023
+ siteDomain: connection.settings?.siteDomain || void 0,
5024
+ active: connection.active,
5025
+ lastSyncAt: connection.lastSyncAt ?? void 0
5026
+ };
5026
5027
  }
5027
- function toIntegration(row) {
5028
+ function toLogEntry(run) {
5028
5029
  return {
5029
- id: row.id,
5030
- apiKey: row.api_key ?? void 0,
5031
- siteDomain: row.site_domain ?? void 0,
5032
- active: row.active ?? true,
5033
- lastSyncAt: row.last_sync_at ?? void 0
5030
+ id: run.id ?? "",
5031
+ createdAt: run.startedAt ?? "",
5032
+ status: run.status === "success" ? "success" : "error",
5033
+ message: run.error ?? run.message ?? void 0,
5034
+ fetched: run.fetched ?? 0,
5035
+ written: run.written ?? 0
5034
5036
  };
5035
5037
  }
5036
5038
  function createRankLayerProvider() {
5037
5039
  return {
5038
5040
  async getIntegration() {
5039
- const tenantId = getActiveTenantId();
5040
- if (!tenantId) return null;
5041
- const { data } = await getClient3().from(INTEGRATIONS).select("*").eq("tenant_id", tenantId).maybeSingle();
5042
- return data ? toIntegration(data) : null;
5041
+ try {
5042
+ const connection = await store().get();
5043
+ return connection ? toIntegration(connection) : null;
5044
+ } catch {
5045
+ return null;
5046
+ }
5043
5047
  },
5044
5048
  async saveIntegration(values) {
5045
- const tenantId = requireTenantId3();
5046
- const payload = {
5047
- tenant_id: tenantId,
5048
- api_key: values.apiKey,
5049
- site_domain: values.siteDomain ?? null,
5050
- active: true,
5051
- updated_at: (/* @__PURE__ */ new Date()).toISOString()
5052
- };
5053
- const { data, error } = await getClient3().from(INTEGRATIONS).upsert(payload, { onConflict: "tenant_id" }).select("*").single();
5054
- if (error) throw error;
5055
- return toIntegration(data);
5049
+ const saved = await store().save({
5050
+ settings: { siteDomain: values.siteDomain ?? null },
5051
+ active: true
5052
+ });
5053
+ return toIntegration(saved);
5056
5054
  },
5055
+ /**
5056
+ * Soft, where the old table hard-deleted the row. The publication history
5057
+ * survives a disconnect now — it is attributed to the connection, and a
5058
+ * tenant that reconnects next week should still see what was published.
5059
+ */
5057
5060
  async disconnect() {
5058
- const tenantId = getActiveTenantId();
5059
- if (!tenantId) return;
5060
- await getClient3().from(INTEGRATIONS).delete().eq("tenant_id", tenantId);
5061
+ await store().disconnect();
5061
5062
  },
5062
- // SCAFFOLD: no RankLayer API yet — accept any non-empty key so the connect
5063
- // flow can be exercised. Replace with a real credential check in the PR.
5064
- async testConnection(values) {
5065
- if (!values.apiKey || values.apiKey.trim().length < 8) {
5066
- return { ok: false, message: "Informe uma API Key v\xE1lida da RankLayer." };
5063
+ async getSyncLog() {
5064
+ try {
5065
+ const runs = await store().listRuns({ limit: 20 });
5066
+ return runs.map(toLogEntry);
5067
+ } catch {
5068
+ return [];
5067
5069
  }
5068
- return { ok: true, message: "Chave aceita (valida\xE7\xE3o real pendente \u2014 integra\xE7\xE3o em prepara\xE7\xE3o)." };
5069
- },
5070
- async getSyncLog(integrationId) {
5071
- const { data } = await getClient3().from(SYNC_LOG).select("*").eq("integration_id", integrationId).order("created_at", { ascending: false }).limit(20);
5072
- return (data ?? []).map((r) => ({
5073
- id: r.id,
5074
- createdAt: r.created_at,
5075
- status: r.status,
5076
- message: r.message ?? void 0,
5077
- fetched: r.fetched ?? 0,
5078
- written: r.written ?? 0
5079
- }));
5080
5070
  }
5081
5071
  };
5082
5072
  }
5083
5073
  var provider = createRankLayerProvider();
5074
+ var store2 = ranklayerConnectionStore();
5084
5075
  function RankLayerExtraPanel() {
5085
5076
  const [integration, setIntegration] = useState(null);
5086
5077
  const [log, setLog] = useState([]);
@@ -5092,7 +5083,10 @@ function RankLayerExtraPanel() {
5092
5083
  const found = await provider.getIntegration();
5093
5084
  if (cancelled) return;
5094
5085
  setIntegration(found);
5095
- if (found) setLog(await provider.getSyncLog(found.id));
5086
+ if (found) {
5087
+ const rows = await provider.getSyncLog();
5088
+ if (!cancelled) setLog(rows);
5089
+ }
5096
5090
  } catch {
5097
5091
  } finally {
5098
5092
  if (!cancelled) setLoaded(true);
@@ -5102,35 +5096,27 @@ function RankLayerExtraPanel() {
5102
5096
  cancelled = true;
5103
5097
  };
5104
5098
  }, []);
5105
- if (!loaded || !integration) return null;
5099
+ if (!loaded || !integration?.active) return null;
5106
5100
  return /* @__PURE__ */ jsxs("div", { className: "space-y-4 border-t pt-4", children: [
5107
- /* @__PURE__ */ jsxs("div", { className: "flex items-start gap-3 rounded-md border border-dashed p-3", children: [
5108
- /* @__PURE__ */ jsx(Sparkles, { className: "mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" }),
5109
- /* @__PURE__ */ jsxs("div", { className: "space-y-1", children: [
5110
- /* @__PURE__ */ jsx("h4", { className: "text-sm font-semibold", children: "Sincroniza\xE7\xE3o de conte\xFAdo \u2014 em breve" }),
5111
- /* @__PURE__ */ jsxs("p", { className: "text-xs text-muted-foreground", children: [
5112
- "A publica\xE7\xE3o autom\xE1tica de p\xE1ginas e artigos de SEO com a RankLayer est\xE1 em prepara\xE7\xE3o.",
5113
- integration.siteDomain ? ` Dom\xEDnio configurado: ${integration.siteDomain}.` : "",
5114
- " Assim que a integra\xE7\xE3o estiver ativa, o hist\xF3rico de sincroniza\xE7\xF5es aparece aqui."
5115
- ] })
5116
- ] })
5101
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
5102
+ /* @__PURE__ */ jsx(History, { className: "h-4 w-4 text-muted-foreground" }),
5103
+ /* @__PURE__ */ jsx("h4", { className: "text-sm font-semibold", children: "Hist\xF3rico de publica\xE7\xF5es" })
5117
5104
  ] }),
5118
- log.length > 0 && /* @__PURE__ */ jsxs("div", { className: "space-y-2", children: [
5119
- /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
5120
- /* @__PURE__ */ jsx(History, { className: "h-4 w-4 text-muted-foreground" }),
5121
- /* @__PURE__ */ jsx("h4", { className: "text-sm font-semibold", children: "Hist\xF3rico" })
5105
+ log.length === 0 ? /* @__PURE__ */ jsxs("p", { className: "text-xs text-muted-foreground", children: [
5106
+ "Nenhuma publica\xE7\xE3o recebida at\xE9 agora. Quem envia \xE9 a RankLayer",
5107
+ integration.siteDomain ? ` (site ${integration.siteDomain})` : "",
5108
+ ": a cada envio, uma linha aparece aqui com a data e quantos artigos foram gravados."
5109
+ ] }) : /* @__PURE__ */ jsx("div", { className: "divide-y rounded-md border text-sm", children: log.map((r) => /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3 px-3 py-2", title: r.message, children: [
5110
+ /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground", children: new Date(r.createdAt).toLocaleString("pt-BR") }),
5111
+ /* @__PURE__ */ jsxs("span", { className: "ml-auto text-xs", children: [
5112
+ r.written,
5113
+ "/",
5114
+ r.fetched,
5115
+ " publicadas"
5122
5116
  ] }),
5123
- /* @__PURE__ */ jsx("div", { className: "divide-y rounded-md border text-sm", children: log.map((r) => /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3 px-3 py-2", children: [
5124
- /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground", children: new Date(r.createdAt).toLocaleString("pt-BR") }),
5125
- /* @__PURE__ */ jsxs("span", { className: "ml-auto text-xs", children: [
5126
- r.written,
5127
- "/",
5128
- r.fetched,
5129
- " publicadas"
5130
- ] }),
5131
- r.status === "success" ? /* @__PURE__ */ jsx(CheckCircle2, { className: "h-3.5 w-3.5 text-success" }) : /* @__PURE__ */ jsx(AlertCircle, { className: "h-3.5 w-3.5 text-warning" })
5132
- ] }, r.id)) })
5133
- ] })
5117
+ r.status === "success" ? /* @__PURE__ */ jsx(CheckCircle2, { className: "h-3.5 w-3.5 text-success" }) : /* @__PURE__ */ jsx(AlertCircle, { className: "h-3.5 w-3.5 text-warning" })
5118
+ ] }, r.id)) }),
5119
+ /* @__PURE__ */ jsx("p", { className: "text-[11px] text-muted-foreground", children: "A chave que a RankLayer usa para publicar aqui \xE9 emitida na plataforma Fayz e n\xE3o fica guardada neste aplicativo." })
5134
5120
  ] });
5135
5121
  }
5136
5122
  var RANKLAYER_MARK = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgdmlld0JveD0iMCAwIDMyIDMyIj48cmVjdCB3aWR0aD0iMzIiIGhlaWdodD0iMzIiIHJ4PSI3IiBmaWxsPSIjMUYyNDMxIi8+PHBvbHlnb24gcG9pbnRzPSIxNiw1LjYgMjYuNCwxMS42IDE2LDE3LjYgNS42LDExLjYiIGZpbGw9IiNGRkZGRkYiIHN0cm9rZT0iI0ZGRkZGRiIgc3Ryb2tlLXdpZHRoPSIxLjI1IiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PGcgb3BhY2l0eT0iMC41NSI+PHBvbHlnb24gcG9pbnRzPSI1LjYsMTYuNCAxNiwyMi40IDI2LjQsMTYuNCAyNi40LDE4LjIgMTYsMjQuMiA1LjYsMTguMiIgZmlsbD0iI0ZGRkZGRiIgc3Ryb2tlPSIjRkZGRkZGIiBzdHJva2Utd2lkdGg9IjEuMjUiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L2c+PGcgb3BhY2l0eT0iMC4yOCI+PHBvbHlnb24gcG9pbnRzPSI1LjYsMjEuMiAxNiwyNy4yIDI2LjQsMjEuMiAyNi40LDIzIDE2LDI5IDUuNiwyMyIgZmlsbD0iI0ZGRkZGRiIgc3Ryb2tlPSIjRkZGRkZGIiBzdHJva2Utd2lkdGg9IjEuMjUiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L2c+PC9zdmc+";
@@ -5141,22 +5127,84 @@ var ranklayerConnectorDef = {
5141
5127
  description: "Publica\xE7\xE3o de conte\xFAdo e p\xE1ginas de SEO por servi\xE7o/bairro para ser encontrado no Google e em assistentes de IA.",
5142
5128
  icon: "Search",
5143
5129
  logoUrl: RANKLAYER_MARK,
5144
- authKind: "api-key",
5145
- fields: [
5146
- { key: "apiKey", label: "API Key", type: "password", placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" },
5147
- { key: "siteDomain", label: "Dom\xEDnio do site", type: "text", placeholder: "exemplo.com.br" }
5148
- ],
5149
- async getStatus() {
5150
- try {
5151
- const found = await provider.getIntegration();
5152
- return { connected: !!found?.apiKey, detail: found?.siteDomain };
5153
- } catch {
5154
- return { connected: false };
5130
+ // OAuth, not a pasted key: what the tenant is doing is linking their RankLayer
5131
+ // account, and a key typed into this app would be a second copy of a secret
5132
+ // the platform already holds — the exact shape migration 009 deleted.
5133
+ authKind: "oauth",
5134
+ // The catalogue files RankLayer under "Analytics & Marketing"
5135
+ // (docs/site/data/integrations.json).
5136
+ category: "analytics",
5137
+ // Inbound because RankLayer reaches us: it writes the articles in. The edit
5138
+ // that goes back is delivered by the Fayz ingress, not by this app.
5139
+ direction: "inbound",
5140
+ docsUrl: "https://ranklayer.app",
5141
+ // The write is grounded: RankLayer's server calls `blog_upsert_external_post`
5142
+ // (plugin-blog migrations §external), which INSERTs/UPDATEs plg_blog_posts and
5143
+ // refuses a credential not scoped to exactly one tenant.
5144
+ //
5145
+ // The read is grounded only as far as the managed automation below — sending
5146
+ // an edit back means reading the article. That push is declared and its
5147
+ // anti-loop guard exists (./outbound.ts `shouldPushToRankLayer`), but nothing
5148
+ // calls it: the ingress that would is the same one that does not exist. Kept
5149
+ // rather than dropped because the automation IS declared, and a permission
5150
+ // list that contradicts the automation next to it is the defect this whole
5151
+ // declaration exists to prevent.
5152
+ permissions: [
5153
+ {
5154
+ kind: "data",
5155
+ resource: "blog_post",
5156
+ actions: ["read", "write"],
5157
+ reason: "Publicar aqui os artigos que a RankLayer escreve, e ler os artigos daqui para mandar de volta as edi\xE7\xF5es que voc\xEA fizer."
5158
+ },
5159
+ {
5160
+ kind: "event",
5161
+ resource: "blog.post.*",
5162
+ actions: ["listen"],
5163
+ reason: "Saber quando um artigo \xE9 criado, editado, publicado ou apagado aqui."
5155
5164
  }
5156
- },
5157
- testConnection: (values) => provider.testConnection({ apiKey: values.apiKey }),
5158
- saveConnection: async (values) => {
5159
- await provider.saveIntegration({ apiKey: values.apiKey, siteDomain: values.siteDomain });
5165
+ ],
5166
+ /**
5167
+ * No form. The connect step authorizes and nothing else; the site domain is
5168
+ * configuration and lives on the Configurações tab (./settings.ts).
5169
+ *
5170
+ * TODO(ranklayer): the consent screen only opens once the platform has a
5171
+ * `ranklayer` OAuth provider registered — client id/secret and the authorize
5172
+ * and token URLs, plus our redirect URI allow-listed on RankLayer's side. Until
5173
+ * then Conectar answers `config_missing` and the tenant is told, in as many
5174
+ * words, that this is not something they can fix. Nothing in this repository
5175
+ * changes when it lands.
5176
+ */
5177
+ startOAuth: fayzConnectorOAuth(RANKLAYER_CONNECTOR_ID),
5178
+ settings: RANKLAYER_SETTINGS,
5179
+ subscribesTo: ["blog.post.*"],
5180
+ managedAutomations: [
5181
+ { eventKey: "blog.post.updated", label: "Enviar para a RankLayer as edi\xE7\xF5es feitas no artigo aqui" }
5182
+ ],
5183
+ // Read from the row the list already batched — one request for N connectors.
5184
+ ...connectionStatus(
5185
+ RANKLAYER_CONNECTOR_ID,
5186
+ (connection) => {
5187
+ const found = connection ? toIntegration(connection) : null;
5188
+ return {
5189
+ connected: !!found?.active,
5190
+ detail: found?.siteDomain,
5191
+ plane: "sync",
5192
+ // "Conectado, falta configurar" while the domain is empty — which is
5193
+ // true, and is the difference between not ready and broken.
5194
+ ...settingsStatus(RANKLAYER_SETTINGS, connection)
5195
+ };
5196
+ },
5197
+ { store: () => store2 }
5198
+ ),
5199
+ ...connectionSettings(store2),
5200
+ /**
5201
+ * Called with no values: the credential is not ours to see, and the platform's
5202
+ * redirect route does not write this pool's `plg_connections` (FAY-1388) — the
5203
+ * app writes its own row, or the tenant authorizes, comes back, and reads "not
5204
+ * connected" forever. Bling shipped with the other belief and looped.
5205
+ */
5206
+ saveConnection: async () => {
5207
+ await store2.save({ active: true });
5160
5208
  },
5161
5209
  disconnect: () => provider.disconnect(),
5162
5210
  ExtraPanel: RankLayerExtraPanel
@@ -5578,7 +5626,8 @@ function resolveConfig(options) {
5578
5626
  channels: options?.channels ?? preset.channels,
5579
5627
  modules: { ...preset.modules, ...options?.modules },
5580
5628
  currency: { ...DEFAULT_CURRENCY, ...options?.currency },
5581
- labels: { ...DEFAULT_LABELS, ...options?.labels }
5629
+ labels: { ...DEFAULT_LABELS, ...options?.labels },
5630
+ moduleNav: options?.moduleNav
5582
5631
  };
5583
5632
  return { config, domain };
5584
5633
  }
@@ -5598,8 +5647,8 @@ function createMarketingPlugin(options) {
5598
5647
  registerTranslations(marketingLocales);
5599
5648
  const { config, domain } = resolveConfig(options);
5600
5649
  const provider2 = options?.dataProvider ?? createSafeProvider(config, domain, options);
5601
- const store = createMarketingStore(provider2);
5602
- const dashboardWidgets = createMarketingDashboardWidgets({ config, provider: provider2, store });
5650
+ const store3 = createMarketingStore(provider2);
5651
+ const dashboardWidgets = createMarketingDashboardWidgets({ config, provider: provider2, store: store3 });
5603
5652
  const contentProvider = options?.contentPlanner?.dataProvider ?? createSafeDataProvider(
5604
5653
  () => createSupabaseContentPlannerProvider(),
5605
5654
  () => createMockContentPlannerProvider()
@@ -5607,8 +5656,8 @@ function createMarketingPlugin(options) {
5607
5656
  const contentStore = createContentPlannerStore(contentProvider);
5608
5657
  const PageComponent = () => React9.createElement(
5609
5658
  MarketingContextProvider,
5610
- { config, provider: provider2, store },
5611
- React9.createElement(MarketingPage, { config, provider: provider2, store, contentProvider, contentStore })
5659
+ { config, provider: provider2, store: store3 },
5660
+ React9.createElement(MarketingPage, { config, provider: provider2, store: store3, contentProvider, contentStore })
5612
5661
  );
5613
5662
  PageComponent.displayName = "MarketingPage";
5614
5663
  const marketingRegistries = [
@@ -5617,7 +5666,7 @@ function createMarketingPlugin(options) {
5617
5666
  ];
5618
5667
  const SettingsComponent = () => React9.createElement(
5619
5668
  MarketingContextProvider,
5620
- { config, provider: provider2, store },
5669
+ { config, provider: provider2, store: store3 },
5621
5670
  React9.createElement(PluginSettingsPanel, {
5622
5671
  title: config.labels.settings ?? config.labels.pageTitle,
5623
5672
  subtitle: config.labels.pageSubtitle,
@@ -5788,12 +5837,6 @@ function createMarketingPlugin(options) {
5788
5837
  sql: MIGRATION_003_RECORDING_OPS,
5789
5838
  description: "Recording-day checklist + media asset URL on posts; content-media storage bucket"
5790
5839
  },
5791
- {
5792
- id: "marketing-004-ranklayer",
5793
- version: "1.3.0",
5794
- sql: MIGRATION_004_RANKLAYER,
5795
- description: "RankLayer connector state: plg_marketing_ranklayer_integrations + _sync_log (tenant RLS)"
5796
- },
5797
5840
  {
5798
5841
  id: "marketing-005-analytics-base",
5799
5842
  version: "1.4.0",
@@ -5817,10 +5860,17 @@ function createMarketingPlugin(options) {
5817
5860
  version: "1.5.0",
5818
5861
  sql: MIGRATION_008_LANDING_PAGES,
5819
5862
  description: "Sites + landing pages (site + route + lead tag) with tenant RLS, v_marketing_landing_pages and v_marketing_lead_tag_events"
5863
+ },
5864
+ {
5865
+ id: "marketing-010-ranklayer-onto-connections",
5866
+ version: "1.6.0",
5867
+ sql: MIGRATION_010_RANKLAYER_ONTO_CONNECTIONS,
5868
+ description: "Retire plg_marketing_ranklayer_integrations + _sync_log \u2014 the connection and its publication history are rows of plg_connections/plg_sync_runs (packages/db 029)"
5820
5869
  }
5821
5870
  ],
5822
- // RankLayer SEO connector — renders in the Integrações settings tab. Scaffold
5823
- // (control plane only); the real sync lands via an external PR (RANKLAYER.md).
5871
+ // RankLayer SEO connector — renders in the Integrações settings tab. A push
5872
+ // connector: RankLayer writes posts in and subscribes to blog.post.* to hear
5873
+ // the edits back. See src/integrations/ranklayer/RANKLAYER.md.
5824
5874
  connectors: [ranklayerConnectorDef],
5825
5875
  aiTools: [
5826
5876
  {
@@ -5895,6 +5945,9 @@ function createMarketingPlugin(options) {
5895
5945
  permission: { feature: "marketing", action: "read" }
5896
5946
  }
5897
5947
  ],
5948
+ // Google Meu Negócio sits on the same shelf as RankLayer above: being
5949
+ // found locally, not being measured.
5950
+ integrations: ["google-my-business"],
5898
5951
  locales: marketingLocales
5899
5952
  };
5900
5953
  }