@fayz-ai/plugin-marketing 0.8.0 → 0.8.2

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 (74) hide show
  1. package/dist/MarketingContext.d.ts +2 -0
  2. package/dist/MarketingContext.d.ts.map +1 -1
  3. package/dist/MarketingPage.d.ts.map +1 -1
  4. package/dist/data/blogEntities.d.ts +28 -0
  5. package/dist/data/blogEntities.d.ts.map +1 -0
  6. package/dist/data/capability.test.d.ts +2 -0
  7. package/dist/data/capability.test.d.ts.map +1 -0
  8. package/dist/data/supabase.d.ts +10 -0
  9. package/dist/data/supabase.d.ts.map +1 -0
  10. package/dist/data/tables.d.ts +7 -0
  11. package/dist/data/tables.d.ts.map +1 -1
  12. package/dist/data/types.d.ts +2 -0
  13. package/dist/data/types.d.ts.map +1 -1
  14. package/dist/index.cjs +1239 -87
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.ts +1 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +1245 -94
  19. package/dist/index.js.map +1 -1
  20. package/dist/integrations/ranklayer/connectorDef.d.ts +3 -0
  21. package/dist/integrations/ranklayer/connectorDef.d.ts.map +1 -0
  22. package/dist/integrations/ranklayer/data/supabase.d.ts +16 -0
  23. package/dist/integrations/ranklayer/data/supabase.d.ts.map +1 -0
  24. package/dist/integrations/ranklayer/types.d.ts +16 -0
  25. package/dist/integrations/ranklayer/types.d.ts.map +1 -0
  26. package/dist/migrations/index.d.ts +4 -0
  27. package/dist/migrations/index.d.ts.map +1 -1
  28. package/dist/presets.d.ts +2 -0
  29. package/dist/presets.d.ts.map +1 -1
  30. package/dist/registries.d.ts +3 -3
  31. package/dist/registries.d.ts.map +1 -1
  32. package/dist/schema/index.d.ts +354 -0
  33. package/dist/schema/index.d.ts.map +1 -1
  34. package/dist/schema.cjs +30 -0
  35. package/dist/schema.cjs.map +1 -1
  36. package/dist/schema.js +30 -2
  37. package/dist/schema.js.map +1 -1
  38. package/dist/store.d.ts +2 -0
  39. package/dist/store.d.ts.map +1 -1
  40. package/dist/views/CampaignsView.d.ts.map +1 -1
  41. package/dist/views/blog/BlogAdminView.d.ts +5 -0
  42. package/dist/views/blog/BlogAdminView.d.ts.map +1 -0
  43. package/dist/views/blog/BlogEntityForm.d.ts +11 -0
  44. package/dist/views/blog/BlogEntityForm.d.ts.map +1 -0
  45. package/dist/views/blog/useEntityCrud.d.ts +29 -0
  46. package/dist/views/blog/useEntityCrud.d.ts.map +1 -0
  47. package/dist/views/content/ContentView.d.ts.map +1 -1
  48. package/package.json +8 -6
  49. package/src/MarketingContext.tsx +2 -0
  50. package/src/MarketingPage.tsx +27 -2
  51. package/src/data/blogEntities.tsx +91 -0
  52. package/src/data/capability.test.ts +92 -0
  53. package/src/data/supabase.ts +401 -0
  54. package/src/data/tables.ts +9 -0
  55. package/src/data/types.ts +2 -0
  56. package/src/index.ts +153 -10
  57. package/src/integrations/ranklayer/RANKLAYER.md +40 -0
  58. package/src/integrations/ranklayer/connectorDef.tsx +98 -0
  59. package/src/integrations/ranklayer/data/supabase.ts +106 -0
  60. package/src/integrations/ranklayer/types.ts +16 -0
  61. package/src/migrations/004_ranklayer.sql +50 -0
  62. package/src/migrations/005_analytics_base.sql +60 -0
  63. package/src/migrations/006_analytics_views.sql +68 -0
  64. package/src/migrations/007_agent_rpcs.sql +119 -0
  65. package/src/migrations/index.ts +310 -1
  66. package/src/presets.ts +5 -3
  67. package/src/registries.ts +18 -6
  68. package/src/schema/index.ts +38 -1
  69. package/src/store.ts +21 -8
  70. package/src/views/CampaignsView.tsx +4 -1
  71. package/src/views/blog/BlogAdminView.tsx +99 -0
  72. package/src/views/blog/BlogEntityForm.tsx +101 -0
  73. package/src/views/blog/useEntityCrud.ts +50 -0
  74. package/src/views/content/ContentView.tsx +8 -1
package/dist/index.cjs CHANGED
@@ -219,11 +219,11 @@ function ChannelDetailView({ channelId, onBack }) {
219
219
  const range = useMarketingStore((s) => s.range);
220
220
  const channels = useMarketingStore((s) => s.channels);
221
221
  const lookup = useChannelLookup();
222
- const provider = useMarketingProvider();
222
+ const provider2 = useMarketingProvider();
223
223
  const [campaigns, setCampaigns] = React8__default.default.useState([]);
224
224
  React8__default.default.useEffect(() => {
225
- void provider.listCampaigns({ range, channelId }).then(setCampaigns);
226
- }, [provider, range, channelId]);
225
+ void provider2.listCampaigns({ range, channelId }).then(setCampaigns);
226
+ }, [provider2, range, channelId]);
227
227
  const perf = channels.find((c) => c.channelId === channelId);
228
228
  const channel = lookup.get(channelId);
229
229
  const columns = React8__default.default.useMemo(() => [
@@ -365,11 +365,11 @@ function CampaignsView() {
365
365
  /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-sm text-muted-foreground", children: t("marketing.campaigns.subtitle") }),
366
366
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-2", children: [
367
367
  /* @__PURE__ */ jsxRuntime.jsx(RangeTabs, {}),
368
- /* @__PURE__ */ jsxRuntime.jsxs(ui.Button, { onClick: () => setComposerOpen(true), children: [
368
+ /* @__PURE__ */ jsxRuntime.jsx(saas.PermissionGate, { feature: "marketing", action: "create", children: /* @__PURE__ */ jsxRuntime.jsxs(ui.Button, { onClick: () => setComposerOpen(true), children: [
369
369
  /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Plus, { className: "mr-1.5 h-4 w-4" }),
370
370
  " ",
371
371
  t("marketing.campaigns.new")
372
- ] })
372
+ ] }) })
373
373
  ] })
374
374
  ] }),
375
375
  /* @__PURE__ */ jsxRuntime.jsx(ui.DataTable, { columns, data: campaigns, emptyMessage: t("marketing.campaigns.empty") }),
@@ -1314,6 +1314,7 @@ function ContentView({ onOpenPost }) {
1314
1314
  const viewMode = useContentPlannerStore((s) => s.viewMode);
1315
1315
  const setViewMode = useContentPlannerStore((s) => s.setViewMode);
1316
1316
  const loadedTenantId = useContentPlannerStore((s) => s.loadedTenantId);
1317
+ const guardContentPosts = saas.useLimitGuard("content_posts_month");
1317
1318
  const [briefOpen, setBriefOpen] = React8__default.default.useState(false);
1318
1319
  const [pendingDeleteId, setPendingDeleteId] = React8__default.default.useState(null);
1319
1320
  const tenantId = saas.useOrganizationStore((state) => state.currentOrg?.id);
@@ -1341,6 +1342,7 @@ function ContentView({ onOpenPost }) {
1341
1342
  });
1342
1343
  };
1343
1344
  const createPost = async (week) => {
1345
+ if (await guardContentPosts() === "blocked") return;
1344
1346
  const targetPlan = await ensurePlan();
1345
1347
  if (!targetPlan) return;
1346
1348
  const start = targetPlan.startDate ? /* @__PURE__ */ new Date(`${targetPlan.startDate}T00:00:00`) : mondayOfCurrentWeek();
@@ -1353,6 +1355,7 @@ function ContentView({ onOpenPost }) {
1353
1355
  status: "idea",
1354
1356
  scheduledDate: toISODate(start)
1355
1357
  });
1358
+ saas.invalidateLimit("content_posts_month");
1356
1359
  onOpenPost(post.id);
1357
1360
  };
1358
1361
  const movePost = async (postId, week) => {
@@ -1361,6 +1364,7 @@ function ContentView({ onOpenPost }) {
1361
1364
  await savePost({ id: postId, weekNumber: week });
1362
1365
  };
1363
1366
  const createPostAt = async (isoDate) => {
1367
+ if (await guardContentPosts() === "blocked") return;
1364
1368
  const targetPlan = await ensurePlan();
1365
1369
  if (!targetPlan) return;
1366
1370
  let week = 1;
@@ -1378,6 +1382,7 @@ function ContentView({ onOpenPost }) {
1378
1382
  status: "idea",
1379
1383
  scheduledDate: isoDate
1380
1384
  });
1385
+ saas.invalidateLimit("content_posts_month");
1381
1386
  onOpenPost(post.id);
1382
1387
  };
1383
1388
  const busy = !loaded || loading;
@@ -1968,7 +1973,7 @@ function toDraft2(post) {
1968
1973
  }
1969
1974
  function PostPage({ postId, onBack }) {
1970
1975
  const t = core.useTranslation();
1971
- const provider = useContentPlannerProvider();
1976
+ const provider2 = useContentPlannerProvider();
1972
1977
  const storePost = useContentPlannerStore((s) => s.posts.find((p) => p.id === postId));
1973
1978
  const savePost = useContentPlannerStore((s) => s.savePost);
1974
1979
  const deletePost = useContentPlannerStore((s) => s.deletePost);
@@ -1985,13 +1990,13 @@ function PostPage({ postId, onBack }) {
1985
1990
  setDraft(toDraft2(storePost));
1986
1991
  return;
1987
1992
  }
1988
- void provider.getPost(postId).then((fetched) => {
1993
+ void provider2.getPost(postId).then((fetched) => {
1989
1994
  if (fetched) {
1990
1995
  setPost(fetched);
1991
1996
  setDraft(toDraft2(fetched));
1992
1997
  }
1993
1998
  });
1994
- }, [postId, storePost, post?.id, provider]);
1999
+ }, [postId, storePost, post?.id, provider2]);
1995
2000
  if (!post || !draft) {
1996
2001
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "mx-auto max-w-3xl space-y-5", children: [
1997
2002
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center justify-between", children: [
@@ -2178,6 +2183,245 @@ function PostPage({ postId, onBack }) {
2178
2183
  post.id
2179
2184
  );
2180
2185
  }
2186
+
2187
+ // src/data/blogEntities.tsx
2188
+ var BLOG_POSTS_TABLE = "plg_blog_posts";
2189
+ var BLOG_CATEGORIES_TABLE = "plg_blog_categories";
2190
+ function buildBlogPostEntity() {
2191
+ const fields = [
2192
+ { key: "title", label: "Title", type: "text", required: true, searchable: true, showInTable: true, sortable: true },
2193
+ { key: "slug", label: "Slug", type: "text", required: true, searchable: true, showInTable: false, hint: 'URL identifier, e.g. "cannabis-and-oral-health"' },
2194
+ { key: "status", label: "Status", type: "select", options: ["draft", "published"], showInTable: true, defaultValue: "draft" },
2195
+ { key: "categoryId", label: "Category", type: "relation", relation: { table: BLOG_CATEGORIES_TABLE, valueField: "id", labelField: "name", tenantScoped: true }, showInTable: false },
2196
+ { key: "excerpt", label: "Excerpt", type: "textarea", span: 2, showInTable: false, placeholder: "Short summary shown on cards" },
2197
+ { key: "body", label: "Body", type: "markdown", span: 2, showInTable: false },
2198
+ { key: "coverImage", label: "Cover image", type: "image", showInTable: false, placeholder: "https://\u2026" },
2199
+ { key: "readTime", label: "Read time", type: "text", showInTable: false, placeholder: "5 min" },
2200
+ { key: "publishedAt", label: "Published at", type: "date", showInTable: true, sortable: true },
2201
+ { key: "authorName", label: "Author name", type: "text", showInTable: false },
2202
+ { key: "authorRole", label: "Author role", type: "text", showInTable: false, placeholder: "e.g. Cirurgi\xE3o-Dentista" },
2203
+ { key: "authorAvatarUrl", label: "Author avatar", type: "image", showInTable: false, placeholder: "https://\u2026" },
2204
+ { key: "authorBio", label: "Author bio", type: "textarea", span: 2, showInTable: false }
2205
+ ];
2206
+ return {
2207
+ name: "Post",
2208
+ namePlural: "Posts",
2209
+ icon: "Newspaper",
2210
+ fields,
2211
+ displayField: "title",
2212
+ subtitleField: "slug",
2213
+ facets: [{ field: "status", allLabel: "All" }],
2214
+ data: {
2215
+ table: BLOG_POSTS_TABLE,
2216
+ tenantScoped: true,
2217
+ searchColumns: ["title", "slug", "excerpt"]
2218
+ }
2219
+ };
2220
+ }
2221
+ function buildBlogCategoryEntity() {
2222
+ const fields = [
2223
+ { key: "name", label: "Name", type: "text", required: true, searchable: true, showInTable: true, sortable: true },
2224
+ { key: "slug", label: "Slug", type: "text", required: true, searchable: true, showInTable: true, hint: "URL identifier" },
2225
+ { key: "description", label: "Description", type: "textarea", span: 2, showInTable: false }
2226
+ ];
2227
+ return {
2228
+ name: "Category",
2229
+ namePlural: "Categories",
2230
+ icon: "Tags",
2231
+ fields,
2232
+ displayField: "name",
2233
+ data: {
2234
+ table: BLOG_CATEGORIES_TABLE,
2235
+ tenantScoped: true,
2236
+ searchColumns: ["name", "slug"]
2237
+ }
2238
+ };
2239
+ }
2240
+ function useEntityCrud(entity) {
2241
+ const provider2 = React8.useMemo(() => core.resolveDataProvider(entity), [entity]);
2242
+ const [items, setItems] = React8.useState(null);
2243
+ const [total, setTotal] = React8.useState(0);
2244
+ const fetch = React8.useCallback(async (query = {}) => {
2245
+ try {
2246
+ const res = await provider2.list(query);
2247
+ setItems(res.data);
2248
+ setTotal(res.total);
2249
+ } catch (err) {
2250
+ console.warn("[blog] list failed; showing empty. Apply the plugin-blog migration to this pool.", err);
2251
+ setItems([]);
2252
+ setTotal(0);
2253
+ }
2254
+ }, [provider2]);
2255
+ const getById = React8.useCallback(async (id) => {
2256
+ const res = await provider2.list({ filters: { id }, page: 1, pageSize: 1 });
2257
+ return res.data[0] ?? null;
2258
+ }, [provider2]);
2259
+ const create = React8.useCallback((data) => provider2.create(data), [provider2]);
2260
+ const update = React8.useCallback((id, data) => provider2.update(id, data), [provider2]);
2261
+ const remove = React8.useCallback((id) => provider2.remove(id), [provider2]);
2262
+ return { items, total, fetch, getById, create, update, remove };
2263
+ }
2264
+ function PostsList({ navigate }) {
2265
+ const entity = React8.useMemo(() => buildBlogPostEntity(), []);
2266
+ const { items, total, fetch } = useEntityCrud(entity);
2267
+ const [search, setSearch] = React8.useState("");
2268
+ const [statusFilter, setStatusFilter] = React8.useState();
2269
+ const [loadedOnce, setLoadedOnce] = React8.useState(false);
2270
+ React8.useEffect(() => {
2271
+ let active = true;
2272
+ Promise.resolve(
2273
+ fetch({ search: search || void 0, filters: statusFilter ? { status: statusFilter } : void 0 })
2274
+ ).finally(() => {
2275
+ if (active) setLoadedOnce(true);
2276
+ });
2277
+ return () => {
2278
+ active = false;
2279
+ };
2280
+ }, [search, statusFilter, fetch]);
2281
+ const facets = React8.useMemo(() => [{
2282
+ field: "status",
2283
+ allLabel: "All",
2284
+ options: [{ value: "draft", label: "Draft" }, { value: "published", label: "Published" }]
2285
+ }], []);
2286
+ return /* @__PURE__ */ jsxRuntime.jsx(
2287
+ saas.CrudListView,
2288
+ {
2289
+ entityDef: entity,
2290
+ items: loadedOnce ? items : null,
2291
+ total,
2292
+ search,
2293
+ onSearchChange: setSearch,
2294
+ searchPlaceholder: "Search posts\u2026",
2295
+ facets,
2296
+ activeFilters: { status: statusFilter },
2297
+ onFacetChange: (_field, value) => setStatusFilter(value),
2298
+ onNew: () => navigate("blog-new"),
2299
+ addLabel: "New post",
2300
+ onRowClick: (row) => navigate(`blog-post:${row.id}`)
2301
+ }
2302
+ );
2303
+ }
2304
+ function CategoriesList({ navigate }) {
2305
+ const entity = React8.useMemo(() => buildBlogCategoryEntity(), []);
2306
+ const { items, total, fetch } = useEntityCrud(entity);
2307
+ const [search, setSearch] = React8.useState("");
2308
+ const [loadedOnce, setLoadedOnce] = React8.useState(false);
2309
+ React8.useEffect(() => {
2310
+ let active = true;
2311
+ Promise.resolve(fetch({ search: search || void 0 })).finally(() => {
2312
+ if (active) setLoadedOnce(true);
2313
+ });
2314
+ return () => {
2315
+ active = false;
2316
+ };
2317
+ }, [search, fetch]);
2318
+ return /* @__PURE__ */ jsxRuntime.jsx(
2319
+ saas.CrudListView,
2320
+ {
2321
+ entityDef: entity,
2322
+ items: loadedOnce ? items : null,
2323
+ total,
2324
+ search,
2325
+ onSearchChange: setSearch,
2326
+ searchPlaceholder: "Search categories\u2026",
2327
+ onNew: () => navigate("blog-cat-new"),
2328
+ addLabel: "New category",
2329
+ onRowClick: (row) => navigate(`blog-cat:${row.id}`)
2330
+ }
2331
+ );
2332
+ }
2333
+ function BlogAdminView({ navigate }) {
2334
+ const { labels } = useMarketingConfig();
2335
+ const [lens, setLens] = React8.useState("posts");
2336
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "space-y-4", children: [
2337
+ /* @__PURE__ */ jsxRuntime.jsx(
2338
+ ui.SegmentedControl,
2339
+ {
2340
+ "aria-label": "Blog section",
2341
+ options: [
2342
+ { value: "posts", label: labels.blog },
2343
+ { value: "categories", label: labels.blogCategories }
2344
+ ],
2345
+ value: lens,
2346
+ onChange: (v) => setLens(v)
2347
+ }
2348
+ ),
2349
+ lens === "posts" ? /* @__PURE__ */ jsxRuntime.jsx(PostsList, { navigate }) : /* @__PURE__ */ jsxRuntime.jsx(CategoriesList, { navigate })
2350
+ ] });
2351
+ }
2352
+ function BlogEntityForm({
2353
+ entity,
2354
+ editId,
2355
+ parentLabel,
2356
+ onDone
2357
+ }) {
2358
+ const { getById, create, update, remove } = useEntityCrud(entity);
2359
+ const isEdit = !!editId;
2360
+ const isPost = entity.data?.table === BLOG_POSTS_TABLE;
2361
+ const guardBlogPosts = saas.useLimitGuard("blog_posts");
2362
+ const namePlural = entity.namePlural ?? entity.name + "s";
2363
+ const [initialData, setInitialData] = React8__default.default.useState(isEdit ? null : {});
2364
+ React8__default.default.useEffect(() => {
2365
+ if (!editId) return;
2366
+ let cancelled = false;
2367
+ (async () => {
2368
+ const item = await getById(editId);
2369
+ if (!cancelled) setInitialData(item ?? {});
2370
+ })();
2371
+ return () => {
2372
+ cancelled = true;
2373
+ };
2374
+ }, [editId, getById]);
2375
+ async function save(values) {
2376
+ if (!isEdit && isPost && await guardBlogPosts() === "blocked") return;
2377
+ try {
2378
+ if (isEdit) await update(editId, values);
2379
+ else {
2380
+ await create(values);
2381
+ if (isPost) saas.invalidateLimit("blog_posts");
2382
+ }
2383
+ ui.toast.success(isEdit ? `${entity.name} updated` : `${entity.name} created`);
2384
+ onDone();
2385
+ } catch (err) {
2386
+ ui.toast.error(`Failed to save ${entity.name.toLowerCase()}`);
2387
+ throw err;
2388
+ }
2389
+ }
2390
+ async function del() {
2391
+ if (!editId) return;
2392
+ try {
2393
+ await remove(editId);
2394
+ ui.toast.success(`${entity.name} deleted`);
2395
+ onDone();
2396
+ } catch {
2397
+ ui.toast.error(`Failed to delete ${entity.name.toLowerCase()}`);
2398
+ }
2399
+ }
2400
+ const title = isEdit ? initialData?.[entity.displayField ?? "name"] ?? entity.name : `New ${entity.name.toLowerCase()}`;
2401
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "space-y-4", children: [
2402
+ /* @__PURE__ */ jsxRuntime.jsx(
2403
+ ui.SubpageHeader,
2404
+ {
2405
+ title: String(title),
2406
+ onBack: onDone,
2407
+ parentLabel,
2408
+ actions: isEdit ? /* @__PURE__ */ jsxRuntime.jsx(ui.Button, { variant: "ghost", className: "text-destructive hover:text-destructive", onClick: del, children: "Delete" }) : void 0
2409
+ }
2410
+ ),
2411
+ initialData !== null && /* @__PURE__ */ jsxRuntime.jsx(
2412
+ saas.CrudFormPage,
2413
+ {
2414
+ entityDef: entity,
2415
+ mode: isEdit ? "edit" : "create",
2416
+ initialData,
2417
+ namePlural,
2418
+ onCancel: onDone,
2419
+ onSubmit: save,
2420
+ hideBreadcrumb: true
2421
+ }
2422
+ )
2423
+ ] });
2424
+ }
2181
2425
  function buildNav(config, view, navigate) {
2182
2426
  const items = [
2183
2427
  { id: "overview", label: config.labels.overview, icon: "BarChart3", active: view === "overview", onClick: () => navigate("overview") }
@@ -2209,9 +2453,18 @@ function buildNav(config, view, navigate) {
2209
2453
  onClick: () => navigate("content")
2210
2454
  });
2211
2455
  }
2456
+ if (config.modules.blog) {
2457
+ items.push({
2458
+ id: "blog",
2459
+ label: config.labels.blog,
2460
+ icon: "Newspaper",
2461
+ active: view === "blog" || view === "blog-new" || view.startsWith("blog-post:") || view === "blog-cat-new" || view.startsWith("blog-cat:"),
2462
+ onClick: () => navigate("blog")
2463
+ });
2464
+ }
2212
2465
  return items;
2213
2466
  }
2214
- function MarketingPage({ config, provider, store, contentProvider, contentStore }) {
2467
+ function MarketingPage({ config, provider: provider2, store, contentProvider, contentStore }) {
2215
2468
  const t = core.useTranslation();
2216
2469
  const { view, direction, navigate } = saas.useModuleNavigation("/marketing", {
2217
2470
  overview: 0,
@@ -2220,15 +2473,25 @@ function MarketingPage({ config, provider, store, contentProvider, contentStore
2220
2473
  funnel: 0,
2221
2474
  "landing-pages": 0,
2222
2475
  content: 0,
2476
+ blog: 0,
2223
2477
  "channel-detail": 1,
2224
2478
  "content-post": 1,
2225
- "content-brief": 1
2479
+ "content-brief": 1,
2480
+ "blog-new": 1,
2481
+ "blog-post": 1,
2482
+ "blog-cat-new": 1,
2483
+ "blog-cat": 1
2226
2484
  }, "overview");
2227
2485
  React8__default.default.useEffect(() => {
2228
2486
  void store.getState().load();
2229
2487
  }, [store]);
2488
+ core.useDataChanged({ table: "plg_marketing_campaigns" }, () => {
2489
+ void store.getState().refresh();
2490
+ }, [store]);
2230
2491
  const isOverview = view === "overview";
2231
2492
  const nav = buildNav(config, view, navigate);
2493
+ const blogPostEntity = React8__default.default.useMemo(() => buildBlogPostEntity(), []);
2494
+ const blogCategoryEntity = React8__default.default.useMemo(() => buildBlogCategoryEntity(), []);
2232
2495
  const quickActions = React8__default.default.useMemo(() => [
2233
2496
  { id: "new-campaign", label: t("marketing.campaigns.new"), icon: "Megaphone", description: config.labels.campaigns, action: () => navigate("campaigns") }
2234
2497
  ], [t, config.labels.campaigns]);
@@ -2241,9 +2504,14 @@ function MarketingPage({ config, provider, store, contentProvider, contentStore
2241
2504
  { id: "content", render: () => /* @__PURE__ */ jsxRuntime.jsx(ContentView, { onOpenPost: (id) => navigate(`content-post:${id}`) }) },
2242
2505
  { id: "content-post", render: ({ id }) => /* @__PURE__ */ jsxRuntime.jsx(PostPage, { postId: id, onBack: () => navigate("content") }) },
2243
2506
  { id: "content-brief", render: () => /* @__PURE__ */ jsxRuntime.jsx(PlanBriefView, { onBack: () => navigate("content") }) },
2507
+ { id: "blog", render: () => /* @__PURE__ */ jsxRuntime.jsx(BlogAdminView, { navigate }) },
2508
+ { id: "blog-new", render: () => /* @__PURE__ */ jsxRuntime.jsx(BlogEntityForm, { entity: blogPostEntity, parentLabel: config.labels.blog, onDone: () => navigate("blog") }) },
2509
+ { id: "blog-post", render: ({ id }) => /* @__PURE__ */ jsxRuntime.jsx(BlogEntityForm, { entity: blogPostEntity, editId: id, parentLabel: config.labels.blog, onDone: () => navigate("blog") }) },
2510
+ { id: "blog-cat-new", render: () => /* @__PURE__ */ jsxRuntime.jsx(BlogEntityForm, { entity: blogCategoryEntity, parentLabel: config.labels.blogCategories, onDone: () => navigate("blog") }) },
2511
+ { id: "blog-cat", render: ({ id }) => /* @__PURE__ */ jsxRuntime.jsx(BlogEntityForm, { entity: blogCategoryEntity, editId: id, parentLabel: config.labels.blogCategories, onDone: () => navigate("blog") }) },
2244
2512
  { id: "overview", render: () => /* @__PURE__ */ jsxRuntime.jsx(OverviewView, {}) }
2245
2513
  ], "overview");
2246
- return /* @__PURE__ */ jsxRuntime.jsx(MarketingContextProvider, { config, provider, store, children: /* @__PURE__ */ jsxRuntime.jsx(ContentPlannerContextProvider, { config, provider: contentProvider, store: contentStore, children: /* @__PURE__ */ jsxRuntime.jsx(
2514
+ return /* @__PURE__ */ jsxRuntime.jsx(MarketingContextProvider, { config, provider: provider2, store, children: /* @__PURE__ */ jsxRuntime.jsx(ContentPlannerContextProvider, { config, provider: contentProvider, store: contentStore, children: /* @__PURE__ */ jsxRuntime.jsx(
2247
2515
  ui.ModulePage,
2248
2516
  {
2249
2517
  title: config.labels.pageTitle,
@@ -2335,6 +2603,20 @@ function SettingsView() {
2335
2603
  ] });
2336
2604
  }
2337
2605
 
2606
+ // src/data/tables.ts
2607
+ var T = {
2608
+ socialAccounts: "plg_marketing_social_accounts",
2609
+ contentPlans: "plg_marketing_content_plans",
2610
+ contentPosts: "plg_marketing_content_posts",
2611
+ channels: "plg_marketing_channels",
2612
+ campaigns: "plg_marketing_campaigns"
2613
+ };
2614
+ var V = {
2615
+ channels: "v_marketing_channels",
2616
+ campaigns: "v_marketing_campaigns",
2617
+ attribution: "v_marketing_attribution"
2618
+ };
2619
+
2338
2620
  // src/registries.ts
2339
2621
  var KIND_OPTIONS = [
2340
2622
  { value: "paid", label: "Paid" },
@@ -2352,10 +2634,18 @@ var channelEntity = {
2352
2634
  defaultSort: "label",
2353
2635
  fields: [
2354
2636
  { key: "label", label: "Channel", type: "text", required: true, showInTable: true, searchable: true },
2637
+ { key: "channelKey", label: "Key", type: "text", required: true, showInTable: true },
2355
2638
  { key: "kind", label: "Type", type: "select", options: KIND_OPTIONS, showInTable: true },
2356
- { key: "icon", label: "Icon", type: "text", showInTable: true }
2639
+ { key: "icon", label: "Icon", type: "text" },
2640
+ { key: "monthlySpend", label: "Monthly spend", type: "number", showInTable: true, defaultValue: 0 },
2641
+ { key: "isActive", label: "Active", type: "boolean", showInTable: true, defaultValue: true, inlineToggle: true }
2357
2642
  ],
2358
- facets: [{ field: "kind" }]
2643
+ facets: [{ field: "kind" }],
2644
+ data: {
2645
+ table: T.channels,
2646
+ tenantScoped: true,
2647
+ searchColumns: ["label", "channel_key"]
2648
+ }
2359
2649
  };
2360
2650
  function buildMarketingRegistries(channels) {
2361
2651
  return [
@@ -2365,8 +2655,17 @@ function buildMarketingRegistries(channels) {
2365
2655
  icon: "Radio",
2366
2656
  description: "Acquisition channels tracked for this workspace",
2367
2657
  display: "table",
2368
- readOnly: true,
2369
- seedData: channels.map((c) => ({ id: c.id, label: c.label, kind: c.kind, icon: c.icon }))
2658
+ seedData: [
2659
+ ...channels.map((c) => ({
2660
+ id: c.id,
2661
+ channelKey: c.id,
2662
+ label: c.label,
2663
+ kind: c.kind,
2664
+ icon: c.icon,
2665
+ isActive: true,
2666
+ monthlySpend: 0
2667
+ }))
2668
+ ]
2370
2669
  }
2371
2670
  ];
2372
2671
  }
@@ -2518,7 +2817,7 @@ var AGENCY = {
2518
2817
  { id: "referral", label: "Referral", icon: "Users", kind: "referral" },
2519
2818
  { id: "direct", label: "Direct", icon: "MousePointerClick", kind: "direct" }
2520
2819
  ],
2521
- modules: { channels: true, campaigns: true, funnel: true, landingPages: true, contentPlanner: false, broadcasts: false, journeys: false }
2820
+ modules: { channels: true, campaigns: true, funnel: true, landingPages: true, contentPlanner: false, blog: false, broadcasts: false, journeys: false }
2522
2821
  };
2523
2822
  var BEAUTY = {
2524
2823
  conversion: {
@@ -2536,7 +2835,7 @@ var BEAUTY = {
2536
2835
  { id: "whatsapp", label: "WhatsApp", icon: "MessageCircle", kind: "social" },
2537
2836
  { id: "walkin", label: "Walk-in", icon: "DoorOpen", kind: "direct" }
2538
2837
  ],
2539
- modules: { channels: true, campaigns: true, funnel: true, landingPages: false, contentPlanner: true, broadcasts: false, journeys: false }
2838
+ modules: { channels: true, campaigns: true, funnel: true, landingPages: false, contentPlanner: true, blog: false, broadcasts: false, journeys: false }
2540
2839
  };
2541
2840
  var RESTO = {
2542
2841
  conversion: {
@@ -2554,7 +2853,7 @@ var RESTO = {
2554
2853
  { id: "instagram", label: "Instagram", icon: "Instagram", kind: "social" },
2555
2854
  { id: "walkin", label: "Walk-in", icon: "DoorOpen", kind: "direct" }
2556
2855
  ],
2557
- modules: { channels: true, campaigns: true, funnel: true, landingPages: false, contentPlanner: false, broadcasts: false, journeys: false }
2856
+ modules: { channels: true, campaigns: true, funnel: true, landingPages: false, contentPlanner: false, blog: false, broadcasts: false, journeys: false }
2558
2857
  };
2559
2858
  var MARKETING_PRESETS = {
2560
2859
  agency: AGENCY,
@@ -2787,6 +3086,276 @@ function createMockMarketingProvider(cfg) {
2787
3086
  }
2788
3087
  };
2789
3088
  }
3089
+ var RANGE_DAYS = { "7d": 7, "30d": 30, "90d": 90 };
3090
+ function getClient() {
3091
+ const supabase = core.getSupabaseClientOptional();
3092
+ if (!supabase) throw new Error("Supabase not initialized");
3093
+ return supabase;
3094
+ }
3095
+ function requireTenantId() {
3096
+ const tenantId = core.getActiveTenantId();
3097
+ if (!tenantId) throw new Error("No active tenant");
3098
+ return tenantId;
3099
+ }
3100
+ function normKey(value) {
3101
+ return (value ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
3102
+ }
3103
+ function createSupabaseMarketingProvider(options) {
3104
+ const { channels, conversion, attributionBridge, sitesBridge } = options;
3105
+ const channelByNorm = /* @__PURE__ */ new Map();
3106
+ for (const ch of channels) {
3107
+ channelByNorm.set(normKey(ch.id), ch.id);
3108
+ channelByNorm.set(normKey(ch.label), ch.id);
3109
+ }
3110
+ const resolveChannel = (raw) => channelByNorm.get(normKey(raw)) ?? null;
3111
+ const seeded = /* @__PURE__ */ new Set();
3112
+ async function ensureChannels(tenantId) {
3113
+ if (seeded.has(tenantId)) return;
3114
+ seeded.add(tenantId);
3115
+ await getClient().from(T.channels).upsert(
3116
+ channels.map((c) => ({
3117
+ tenant_id: tenantId,
3118
+ channel_key: c.id,
3119
+ label: c.label,
3120
+ icon: c.icon,
3121
+ kind: c.kind
3122
+ })),
3123
+ { onConflict: "tenant_id,channel_key", ignoreDuplicates: true }
3124
+ );
3125
+ }
3126
+ const CACHE_TTL_MS = 3e4;
3127
+ const cache = /* @__PURE__ */ new Map();
3128
+ function memo(key, fn) {
3129
+ const hit = cache.get(key);
3130
+ if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.promise;
3131
+ const promise = fn().catch((err) => {
3132
+ cache.delete(key);
3133
+ throw err;
3134
+ });
3135
+ cache.set(key, { at: Date.now(), promise });
3136
+ return promise;
3137
+ }
3138
+ function windowFor(range) {
3139
+ const days = RANGE_DAYS[range];
3140
+ const now = Math.floor(Date.now() / 6e4) * 6e4;
3141
+ return { since: new Date(now - days * 864e5), days };
3142
+ }
3143
+ function fetchAttribution(tenantId, since, until) {
3144
+ const key = `attr:${tenantId}:${since.getTime()}:${until?.getTime() ?? ""}`;
3145
+ return memo(key, async () => {
3146
+ let q = getClient().from(V.attribution).select("kind, source, channel_raw, occurred_at, value").eq("tenant_id", tenantId).gte("occurred_at", since.toISOString()).or(`kind.eq.lead,and(kind.eq.conversion,source.eq.${conversion.source})`);
3147
+ if (until) q = q.lt("occurred_at", until.toISOString());
3148
+ const { data, error } = await q;
3149
+ if (error) throw error;
3150
+ return data ?? [];
3151
+ });
3152
+ }
3153
+ function rollup(rows) {
3154
+ const map = /* @__PURE__ */ new Map();
3155
+ for (const row of rows) {
3156
+ const channelId = resolveChannel(row.channel_raw);
3157
+ if (!channelId) continue;
3158
+ const agg = map.get(channelId) ?? { leads: 0, conversions: 0, value: 0 };
3159
+ if (row.kind === "lead") agg.leads += 1;
3160
+ else {
3161
+ agg.conversions += 1;
3162
+ agg.value += Number(row.value ?? conversion.avgValue);
3163
+ }
3164
+ map.set(channelId, agg);
3165
+ }
3166
+ return map;
3167
+ }
3168
+ function fetchSpend(tenantId, days, since) {
3169
+ return memo(`spend:${tenantId}:${days}:${since.getTime()}`, () => fetchSpendRaw(tenantId, days, since));
3170
+ }
3171
+ async function fetchSpendRaw(tenantId, days, since) {
3172
+ const client = getClient();
3173
+ const [channelRows, campaignRows] = await Promise.all([
3174
+ client.from(V.channels).select("channel_key, monthly_spend").eq("tenant_id", tenantId),
3175
+ client.from(V.campaigns).select("channel_key, spend, status, starts_at, ends_at").eq("tenant_id", tenantId).neq("status", "draft")
3176
+ ]);
3177
+ const spend = /* @__PURE__ */ new Map();
3178
+ for (const row of channelRows.data ?? []) {
3179
+ const id = resolveChannel(row.channel_key);
3180
+ if (!id) continue;
3181
+ spend.set(id, (spend.get(id) ?? 0) + Number(row.monthly_spend) * days / 30);
3182
+ }
3183
+ for (const row of campaignRows.data ?? []) {
3184
+ const id = resolveChannel(row.channel_key);
3185
+ if (!id) continue;
3186
+ const ended = row.ends_at ? new Date(row.ends_at) : null;
3187
+ if (ended && ended < since) continue;
3188
+ spend.set(id, (spend.get(id) ?? 0) + Number(row.spend));
3189
+ }
3190
+ return spend;
3191
+ }
3192
+ async function channelPerformance(query) {
3193
+ const tenantId = requireTenantId();
3194
+ await ensureChannels(tenantId);
3195
+ const { since, days } = windowFor(query.range);
3196
+ const [rows, spend] = await Promise.all([
3197
+ fetchAttribution(tenantId, since),
3198
+ fetchSpend(tenantId, days, since)
3199
+ ]);
3200
+ const byChannel = rollup(rows);
3201
+ if (attributionBridge) {
3202
+ const bridged = await attributionBridge.conversionsByChannel(query);
3203
+ for (const b of bridged) {
3204
+ const agg = byChannel.get(b.channelId) ?? { leads: 0, conversions: 0, value: 0 };
3205
+ agg.conversions = b.conversions;
3206
+ agg.value = b.value;
3207
+ byChannel.set(b.channelId, agg);
3208
+ }
3209
+ }
3210
+ const list = channels.map((ch) => {
3211
+ const agg = byChannel.get(ch.id) ?? { leads: 0, conversions: 0, value: 0 };
3212
+ const chSpend = spend.get(ch.id) ?? 0;
3213
+ return {
3214
+ channelId: ch.id,
3215
+ reach: 0,
3216
+ leads: agg.leads,
3217
+ conversions: agg.conversions,
3218
+ spend: Math.round(chSpend),
3219
+ value: Math.round(agg.value),
3220
+ cvr: 0,
3221
+ cpa: agg.conversions > 0 && chSpend > 0 ? chSpend / agg.conversions : 0
3222
+ };
3223
+ });
3224
+ return query.channelId ? list.filter((c) => c.channelId === query.channelId) : list;
3225
+ }
3226
+ function campaignFromRow(row) {
3227
+ return {
3228
+ id: row.id,
3229
+ name: row.name,
3230
+ channelId: row.channel_key,
3231
+ status: row.status,
3232
+ start: row.starts_at ?? row.created_at,
3233
+ end: row.ends_at ?? void 0,
3234
+ spend: Number(row.spend ?? 0),
3235
+ reach: 0,
3236
+ leads: 0,
3237
+ conversions: 0,
3238
+ value: 0
3239
+ };
3240
+ }
3241
+ function withDerived(campaign, rows) {
3242
+ const start = new Date(campaign.start).getTime();
3243
+ const end = campaign.end ? new Date(campaign.end).getTime() : Number.POSITIVE_INFINITY;
3244
+ let leads = 0;
3245
+ let conversions = 0;
3246
+ let value = 0;
3247
+ for (const row of rows) {
3248
+ if (resolveChannel(row.channel_raw) !== campaign.channelId) continue;
3249
+ const at = new Date(row.occurred_at).getTime();
3250
+ if (at < start || at > end) continue;
3251
+ if (row.kind === "lead") leads += 1;
3252
+ else {
3253
+ conversions += 1;
3254
+ value += Number(row.value ?? conversion.avgValue);
3255
+ }
3256
+ }
3257
+ return { ...campaign, leads, conversions, value: Math.round(value) };
3258
+ }
3259
+ return {
3260
+ channelPerformance,
3261
+ async overview(query) {
3262
+ const tenantId = requireTenantId();
3263
+ const perf = await channelPerformance(query);
3264
+ const { since, days } = windowFor(query.range);
3265
+ const prevRows = await fetchAttribution(
3266
+ tenantId,
3267
+ new Date(since.getTime() - days * 864e5),
3268
+ since
3269
+ );
3270
+ const conversions = perf.reduce((s, c) => s + c.conversions, 0);
3271
+ const spend = perf.reduce((s, c) => s + c.spend, 0);
3272
+ const value = perf.reduce((s, c) => s + c.value, 0);
3273
+ const conversionsPrev = prevRows.filter(
3274
+ (r) => r.kind === "conversion" && resolveChannel(r.channel_raw)
3275
+ ).length;
3276
+ const top = [...perf].sort((a, b) => b.conversions - a.conversions)[0];
3277
+ return {
3278
+ conversions,
3279
+ conversionsPrev,
3280
+ cvr: 0,
3281
+ cvrPrev: 0,
3282
+ spend,
3283
+ cpa: conversions > 0 && spend > 0 ? spend / conversions : 0,
3284
+ value,
3285
+ topChannelId: top && top.conversions > 0 ? top.channelId : null,
3286
+ channelMix: perf.map((c) => ({ channelId: c.channelId, conversions: c.conversions }))
3287
+ };
3288
+ },
3289
+ async funnel(query) {
3290
+ if (attributionBridge) return attributionBridge.funnel(query);
3291
+ const tenantId = requireTenantId();
3292
+ const { since } = windowFor(query.range);
3293
+ const rows = await fetchAttribution(tenantId, since);
3294
+ const leads = rows.filter((r) => r.kind === "lead").length;
3295
+ const conversions = rows.filter((r) => r.kind === "conversion").length;
3296
+ return [
3297
+ { id: "leads", label: "Leads", count: leads, color: "#0ea5e9" },
3298
+ { id: "converted", label: conversion.labelPlural, count: conversions, color: "#22c55e" }
3299
+ ];
3300
+ },
3301
+ async listCampaigns(query) {
3302
+ const tenantId = requireTenantId();
3303
+ const fetchRows = () => memo(`campaigns:${tenantId}`, async () => {
3304
+ const { data: data2, error } = await getClient().from(V.campaigns).select("*").eq("tenant_id", tenantId).order("created_at", { ascending: false });
3305
+ if (error) throw error;
3306
+ return data2 ?? [];
3307
+ });
3308
+ const [data, rows] = await Promise.all([
3309
+ fetchRows(),
3310
+ fetchAttribution(tenantId, windowFor(query.range).since)
3311
+ ]);
3312
+ const list = query.channelId ? data.filter((r) => r.channel_key === query.channelId) : data;
3313
+ return list.map((row) => withDerived(campaignFromRow(row), rows));
3314
+ },
3315
+ async getCampaign(id) {
3316
+ const tenantId = requireTenantId();
3317
+ const { data, error } = await getClient().from(V.campaigns).select("*").eq("tenant_id", tenantId).eq("id", id).maybeSingle();
3318
+ if (error) throw error;
3319
+ if (!data) return null;
3320
+ const campaign = campaignFromRow(data);
3321
+ const rows = await fetchAttribution(tenantId, new Date(campaign.start));
3322
+ return withDerived(campaign, rows);
3323
+ },
3324
+ async saveCampaign(input) {
3325
+ const tenantId = requireTenantId();
3326
+ const payload = {
3327
+ name: input.name,
3328
+ channel_key: input.channelId,
3329
+ status: input.status,
3330
+ starts_at: input.start || null,
3331
+ ends_at: input.end || null,
3332
+ spend: input.spend ?? 0
3333
+ };
3334
+ if (input.id) {
3335
+ const { data: data2, error: error2 } = await getClient().from(T.campaigns).update(payload).eq("id", input.id).select("*").single();
3336
+ if (error2) throw error2;
3337
+ cache.clear();
3338
+ return campaignFromRow(data2);
3339
+ }
3340
+ const { data, error } = await getClient().from(T.campaigns).insert({ tenant_id: tenantId, ...payload }).select("*").single();
3341
+ if (error) throw error;
3342
+ cache.clear();
3343
+ return campaignFromRow(data);
3344
+ },
3345
+ async deleteCampaign(id) {
3346
+ const { error } = await getClient().from(T.campaigns).delete().eq("id", id);
3347
+ if (error) throw error;
3348
+ cache.clear();
3349
+ },
3350
+ invalidateCache() {
3351
+ cache.clear();
3352
+ },
3353
+ async landingPages(query) {
3354
+ if (sitesBridge) return sitesBridge.landingPages(query);
3355
+ return [];
3356
+ }
3357
+ };
3358
+ }
2790
3359
 
2791
3360
  // src/data/contentMock.ts
2792
3361
  var counter = 0;
@@ -3034,21 +3603,12 @@ function createMockContentPlannerProvider() {
3034
3603
  }
3035
3604
  };
3036
3605
  }
3037
-
3038
- // src/data/tables.ts
3039
- var T = {
3040
- socialAccounts: "plg_marketing_social_accounts",
3041
- contentPlans: "plg_marketing_content_plans",
3042
- contentPosts: "plg_marketing_content_posts"
3043
- };
3044
-
3045
- // src/data/contentSupabase.ts
3046
- function getClient() {
3606
+ function getClient2() {
3047
3607
  const supabase = core.getSupabaseClientOptional();
3048
3608
  if (!supabase) throw new Error("Supabase not initialized");
3049
3609
  return supabase;
3050
3610
  }
3051
- function requireTenantId() {
3611
+ function requireTenantId2() {
3052
3612
  const tenantId = core.getActiveTenantId();
3053
3613
  if (!tenantId) throw new Error("No active tenant");
3054
3614
  return tenantId;
@@ -3084,7 +3644,7 @@ function createSupabaseContentPlannerProvider() {
3084
3644
  async listAccounts() {
3085
3645
  const tenantId = core.getActiveTenantId();
3086
3646
  if (!tenantId) return [];
3087
- const { data } = await getClient().from(T.socialAccounts).select("*").eq("tenant_id", tenantId).eq("is_active", true).order("created_at", { ascending: true });
3647
+ const { data } = await getClient2().from(T.socialAccounts).select("*").eq("tenant_id", tenantId).eq("is_active", true).order("created_at", { ascending: true });
3088
3648
  return (data ?? []).map(toAccount);
3089
3649
  },
3090
3650
  async saveAccount(input) {
@@ -3094,12 +3654,12 @@ function createSupabaseContentPlannerProvider() {
3094
3654
  if (input.handle !== void 0) payload.handle = input.handle;
3095
3655
  if (input.platforms !== void 0) payload.platforms = input.platforms;
3096
3656
  if (input.isActive !== void 0) payload.is_active = input.isActive;
3097
- const { data: data2, error: error2 } = await getClient().from(T.socialAccounts).update(payload).eq("id", input.id).select("*").single();
3657
+ const { data: data2, error: error2 } = await getClient2().from(T.socialAccounts).update(payload).eq("id", input.id).select("*").single();
3098
3658
  if (error2) throw error2;
3099
3659
  return toAccount(data2);
3100
3660
  }
3101
- const { data, error } = await getClient().from(T.socialAccounts).insert({
3102
- tenant_id: requireTenantId(),
3661
+ const { data, error } = await getClient2().from(T.socialAccounts).insert({
3662
+ tenant_id: requireTenantId2(),
3103
3663
  name: input.name,
3104
3664
  handle: input.handle ?? null,
3105
3665
  platforms: input.platforms ?? ["instagram"],
@@ -3109,17 +3669,17 @@ function createSupabaseContentPlannerProvider() {
3109
3669
  return toAccount(data);
3110
3670
  },
3111
3671
  async deleteAccount(id) {
3112
- const { error } = await getClient().from(T.socialAccounts).delete().eq("id", id);
3672
+ const { error } = await getClient2().from(T.socialAccounts).delete().eq("id", id);
3113
3673
  if (error) throw error;
3114
3674
  },
3115
3675
  async listPlans(accountId) {
3116
3676
  const tenantId = core.getActiveTenantId();
3117
3677
  if (!tenantId) return [];
3118
- const { data } = await getClient().from(T.contentPlans).select("*").eq("tenant_id", tenantId).eq("account_id", accountId).order("created_at", { ascending: false });
3678
+ const { data } = await getClient2().from(T.contentPlans).select("*").eq("tenant_id", tenantId).eq("account_id", accountId).order("created_at", { ascending: false });
3119
3679
  return (data ?? []).map(toPlan);
3120
3680
  },
3121
3681
  async getPlan(id) {
3122
- const { data } = await getClient().from(T.contentPlans).select("*").eq("id", id).maybeSingle();
3682
+ const { data } = await getClient2().from(T.contentPlans).select("*").eq("id", id).maybeSingle();
3123
3683
  return data ? toPlan(data) : null;
3124
3684
  },
3125
3685
  async savePlan(input) {
@@ -3135,13 +3695,13 @@ function createSupabaseContentPlannerProvider() {
3135
3695
  if (input.formats !== void 0) payload.formats = input.formats;
3136
3696
  if (input.weeklyFrequency !== void 0) payload.weekly_frequency = input.weeklyFrequency;
3137
3697
  if (input.briefMd !== void 0) payload.brief_md = input.briefMd;
3138
- const { data: data2, error: error2 } = await getClient().from(T.contentPlans).update(payload).eq("id", input.id).select("*").single();
3698
+ const { data: data2, error: error2 } = await getClient2().from(T.contentPlans).update(payload).eq("id", input.id).select("*").single();
3139
3699
  if (error2) throw error2;
3140
3700
  return toPlan(data2);
3141
3701
  }
3142
3702
  if (!input.accountId) throw new Error("accountId is required");
3143
- const { data, error } = await getClient().from(T.contentPlans).insert({
3144
- tenant_id: requireTenantId(),
3703
+ const { data, error } = await getClient2().from(T.contentPlans).insert({
3704
+ tenant_id: requireTenantId2(),
3145
3705
  account_id: input.accountId,
3146
3706
  name: input.name ?? "Novo plano",
3147
3707
  status: input.status ?? "draft",
@@ -3158,14 +3718,14 @@ function createSupabaseContentPlannerProvider() {
3158
3718
  return toPlan(data);
3159
3719
  },
3160
3720
  async deletePlan(id) {
3161
- await getClient().from(T.contentPlans).delete().eq("id", id);
3721
+ await getClient2().from(T.contentPlans).delete().eq("id", id);
3162
3722
  },
3163
3723
  async listPosts(planId) {
3164
- const { data } = await getClient().from(T.contentPosts).select("*").eq("plan_id", planId).order("week_number", { ascending: true }).order("position", { ascending: true });
3724
+ const { data } = await getClient2().from(T.contentPosts).select("*").eq("plan_id", planId).order("week_number", { ascending: true }).order("position", { ascending: true });
3165
3725
  return (data ?? []).map(toPost);
3166
3726
  },
3167
3727
  async getPost(id) {
3168
- const { data } = await getClient().from(T.contentPosts).select("*").eq("id", id).maybeSingle();
3728
+ const { data } = await getClient2().from(T.contentPosts).select("*").eq("id", id).maybeSingle();
3169
3729
  return data ? toPost(data) : null;
3170
3730
  },
3171
3731
  async savePost(input) {
@@ -3183,16 +3743,16 @@ function createSupabaseContentPlannerProvider() {
3183
3743
  if (input.hook !== void 0) payload.hook = input.hook;
3184
3744
  if (input.cta !== void 0) payload.cta = input.cta;
3185
3745
  if (input.contentMd !== void 0) payload.content_md = input.contentMd;
3186
- const { data: data2, error: error2 } = await getClient().from(T.contentPosts).update(payload).eq("id", input.id).select("*").single();
3746
+ const { data: data2, error: error2 } = await getClient2().from(T.contentPosts).update(payload).eq("id", input.id).select("*").single();
3187
3747
  if (error2) throw error2;
3188
3748
  return toPost(data2);
3189
3749
  }
3190
3750
  if (!input.planId) throw new Error("planId is required");
3191
3751
  const week = input.weekNumber ?? 1;
3192
- const { data: maxData } = await getClient().from(T.contentPosts).select("position").eq("plan_id", input.planId).eq("week_number", week).order("position", { ascending: false }).limit(1);
3752
+ const { data: maxData } = await getClient2().from(T.contentPosts).select("position").eq("plan_id", input.planId).eq("week_number", week).order("position", { ascending: false }).limit(1);
3193
3753
  const maxPos = maxData?.[0]?.position ?? -1;
3194
- const { data, error } = await getClient().from(T.contentPosts).insert({
3195
- tenant_id: requireTenantId(),
3754
+ const { data, error } = await getClient2().from(T.contentPosts).insert({
3755
+ tenant_id: requireTenantId2(),
3196
3756
  plan_id: input.planId,
3197
3757
  week_number: week,
3198
3758
  position: input.position ?? maxPos + 1,
@@ -3211,11 +3771,11 @@ function createSupabaseContentPlannerProvider() {
3211
3771
  return toPost(data);
3212
3772
  },
3213
3773
  async deletePost(id) {
3214
- await getClient().from(T.contentPosts).delete().eq("id", id);
3774
+ await getClient2().from(T.contentPosts).delete().eq("id", id);
3215
3775
  }
3216
3776
  };
3217
3777
  }
3218
- function createMarketingStore(provider) {
3778
+ function createMarketingStore(provider2) {
3219
3779
  return vanilla.createStore((set, get) => ({
3220
3780
  range: "30d",
3221
3781
  overview: null,
@@ -3227,35 +3787,44 @@ function createMarketingStore(provider) {
3227
3787
  async load() {
3228
3788
  const range = get().range;
3229
3789
  set({ loading: true });
3230
- const [overview, channels, campaigns, funnel, landingPages] = await Promise.all([
3231
- provider.overview({ range }),
3232
- provider.channelPerformance({ range }),
3233
- provider.listCampaigns({ range }),
3234
- provider.funnel({ range }),
3235
- provider.landingPages({ range })
3236
- ]);
3237
- set({ overview, channels, campaigns, funnel, landingPages, loading: false });
3790
+ try {
3791
+ const [overview, channels, campaigns, funnel, landingPages] = await Promise.all([
3792
+ provider2.overview({ range }),
3793
+ provider2.channelPerformance({ range }),
3794
+ provider2.listCampaigns({ range }),
3795
+ provider2.funnel({ range }),
3796
+ provider2.landingPages({ range })
3797
+ ]);
3798
+ set({ overview, channels, campaigns, funnel, landingPages, loading: false });
3799
+ } catch (err) {
3800
+ set({ loading: false });
3801
+ throw err;
3802
+ }
3803
+ },
3804
+ async refresh() {
3805
+ provider2.invalidateCache?.();
3806
+ await get().load();
3238
3807
  },
3239
3808
  async setRange(range) {
3240
3809
  set({ range });
3241
3810
  await get().load();
3242
3811
  },
3243
3812
  async saveCampaign(input) {
3244
- const created = await provider.saveCampaign(input);
3813
+ const created = await provider2.saveCampaign(input);
3245
3814
  await get().load();
3246
3815
  return created;
3247
3816
  },
3248
3817
  async deleteCampaign(id) {
3249
- await provider.deleteCampaign(id);
3818
+ await provider2.deleteCampaign(id);
3250
3819
  await get().load();
3251
3820
  }
3252
3821
  }));
3253
3822
  }
3254
- function createContentPlannerStore(provider) {
3823
+ function createContentPlannerStore(provider2) {
3255
3824
  async function loadPlansFor(accountId) {
3256
- const plans = await provider.listPlans(accountId);
3825
+ const plans = await provider2.listPlans(accountId);
3257
3826
  const activePlan = plans.find((p) => p.status === "active") ?? plans[0] ?? null;
3258
- const posts = activePlan ? await provider.listPosts(activePlan.id) : [];
3827
+ const posts = activePlan ? await provider2.listPosts(activePlan.id) : [];
3259
3828
  return { plans, activePlan, posts };
3260
3829
  }
3261
3830
  return vanilla.createStore((set, get) => ({
@@ -3275,7 +3844,7 @@ function createContentPlannerStore(provider) {
3275
3844
  if (get().loading) return;
3276
3845
  set({ loading: true });
3277
3846
  try {
3278
- const accounts = await provider.listAccounts();
3847
+ const accounts = await provider2.listAccounts();
3279
3848
  const current = get().activeAccountId;
3280
3849
  const activeAccountId = (current && accounts.some((a) => a.id === current) ? current : accounts[0]?.id) ?? null;
3281
3850
  const { plans, activePlan, posts } = activeAccountId ? await loadPlansFor(activeAccountId) : { plans: [], activePlan: null, posts: [] };
@@ -3306,19 +3875,19 @@ function createContentPlannerStore(provider) {
3306
3875
  },
3307
3876
  async selectPlan(planId) {
3308
3877
  const plan = get().plans.find((p) => p.id === planId) ?? null;
3309
- const posts = plan ? await provider.listPosts(plan.id) : [];
3878
+ const posts = plan ? await provider2.listPosts(plan.id) : [];
3310
3879
  set({ activePlan: plan, posts });
3311
3880
  },
3312
3881
  async saveAccount(input) {
3313
- const account = await provider.saveAccount(input);
3314
- const accounts = await provider.listAccounts();
3882
+ const account = await provider2.saveAccount(input);
3883
+ const accounts = await provider2.listAccounts();
3315
3884
  set({ accounts });
3316
3885
  if (!get().activeAccountId) await get().setAccount(account.id);
3317
3886
  return account;
3318
3887
  },
3319
3888
  async deleteAccount(id) {
3320
- await provider.deleteAccount(id);
3321
- const accounts = await provider.listAccounts();
3889
+ await provider2.deleteAccount(id);
3890
+ const accounts = await provider2.listAccounts();
3322
3891
  const wasActive = get().activeAccountId === id;
3323
3892
  set({ accounts });
3324
3893
  if (wasActive) {
@@ -3331,30 +3900,30 @@ function createContentPlannerStore(provider) {
3331
3900
  }
3332
3901
  },
3333
3902
  async savePlan(input) {
3334
- const plan = await provider.savePlan(input);
3903
+ const plan = await provider2.savePlan(input);
3335
3904
  const accountId = get().activeAccountId;
3336
3905
  if (accountId) {
3337
- const plans = await provider.listPlans(accountId);
3906
+ const plans = await provider2.listPlans(accountId);
3338
3907
  const activePlan = plans.find((p) => p.id === plan.id) ?? get().activePlan;
3339
- const posts = activePlan ? await provider.listPosts(activePlan.id) : [];
3908
+ const posts = activePlan ? await provider2.listPosts(activePlan.id) : [];
3340
3909
  set({ plans, activePlan, posts });
3341
3910
  }
3342
3911
  return plan;
3343
3912
  },
3344
3913
  async savePost(input) {
3345
- const post = await provider.savePost(input);
3914
+ const post = await provider2.savePost(input);
3346
3915
  const planId = get().activePlan?.id;
3347
3916
  if (planId && post.planId === planId) {
3348
- const posts = await provider.listPosts(planId);
3917
+ const posts = await provider2.listPosts(planId);
3349
3918
  set({ posts });
3350
3919
  }
3351
3920
  return post;
3352
3921
  },
3353
3922
  async deletePost(id) {
3354
- await provider.deletePost(id);
3923
+ await provider2.deletePost(id);
3355
3924
  const planId = get().activePlan?.id;
3356
3925
  if (planId) {
3357
- const posts = await provider.listPosts(planId);
3926
+ const posts = await provider2.listPosts(planId);
3358
3927
  set({ posts });
3359
3928
  }
3360
3929
  }
@@ -3525,6 +4094,454 @@ CREATE POLICY plg_marketing_media_insert ON storage.objects FOR INSERT TO authen
3525
4094
  CREATE POLICY plg_marketing_media_update ON storage.objects FOR UPDATE TO authenticated USING (bucket_id = 'mkt-media');
3526
4095
  CREATE POLICY plg_marketing_media_delete ON storage.objects FOR DELETE TO authenticated USING (bucket_id = 'mkt-media');
3527
4096
  `;
4097
+ var MIGRATION_004_RANKLAYER = `-- Marketing Plugin: RankLayer integration (SEO content publishing).
4098
+ -- Control-plane state for the RankLayer connector rendered in Marketing \u2192
4099
+ -- Integra\xE7\xF5es. Prefix: plg_marketing_ranklayer_. Scaffold: the real sync
4100
+ -- (edge function + RankLayer API calls) lands via an external PR \u2014 see
4101
+ -- src/integrations/ranklayer/RANKLAYER.md.
4102
+
4103
+ CREATE TABLE IF NOT EXISTS public.plg_marketing_ranklayer_integrations (
4104
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
4105
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
4106
+ api_key text,
4107
+ site_domain text,
4108
+ active boolean NOT NULL DEFAULT true,
4109
+ last_sync_at timestamptz,
4110
+ created_at timestamptz NOT NULL DEFAULT now(),
4111
+ updated_at timestamptz NOT NULL DEFAULT now()
4112
+ );
4113
+ ALTER TABLE public.plg_marketing_ranklayer_integrations ENABLE ROW LEVEL SECURITY;
4114
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_plg_marketing_ranklayer_integrations_tenant
4115
+ ON public.plg_marketing_ranklayer_integrations(tenant_id);
4116
+
4117
+ CREATE TABLE IF NOT EXISTS public.plg_marketing_ranklayer_sync_log (
4118
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
4119
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
4120
+ integration_id uuid REFERENCES public.plg_marketing_ranklayer_integrations(id) ON DELETE CASCADE,
4121
+ status text NOT NULL DEFAULT 'success' CHECK (status IN ('success', 'partial', 'error')),
4122
+ message text,
4123
+ fetched integer NOT NULL DEFAULT 0,
4124
+ written integer NOT NULL DEFAULT 0,
4125
+ created_at timestamptz NOT NULL DEFAULT now()
4126
+ );
4127
+ ALTER TABLE public.plg_marketing_ranklayer_sync_log ENABLE ROW LEVEL SECURITY;
4128
+ CREATE INDEX IF NOT EXISTS idx_plg_marketing_ranklayer_sync_log_tenant
4129
+ ON public.plg_marketing_ranklayer_sync_log(tenant_id, created_at DESC);
4130
+
4131
+ -- RLS: authenticated tenant CRUD
4132
+ DROP POLICY IF EXISTS plg_marketing_ranklayer_integrations_select ON public.plg_marketing_ranklayer_integrations;
4133
+ DROP POLICY IF EXISTS plg_marketing_ranklayer_integrations_insert ON public.plg_marketing_ranklayer_integrations;
4134
+ DROP POLICY IF EXISTS plg_marketing_ranklayer_integrations_update ON public.plg_marketing_ranklayer_integrations;
4135
+ DROP POLICY IF EXISTS plg_marketing_ranklayer_integrations_delete ON public.plg_marketing_ranklayer_integrations;
4136
+ 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()));
4137
+ 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()));
4138
+ 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()));
4139
+ 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()));
4140
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_marketing_ranklayer_integrations TO authenticated;
4141
+
4142
+ DROP POLICY IF EXISTS plg_marketing_ranklayer_sync_log_select ON public.plg_marketing_ranklayer_sync_log;
4143
+ DROP POLICY IF EXISTS plg_marketing_ranklayer_sync_log_insert ON public.plg_marketing_ranklayer_sync_log;
4144
+ 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()));
4145
+ 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()));
4146
+ GRANT SELECT, INSERT ON public.plg_marketing_ranklayer_sync_log TO authenticated;
4147
+ `;
4148
+ var MIGRATION_005_ANALYTICS_BASE = `-- Marketing Plugin: analytics base tables (channels + campaigns).
4149
+ -- Prefix: plg_marketing_
4150
+ -- Channels are tenant-owned rows lazily seeded from the app's domain preset
4151
+ -- (channel_key = preset/config id). Campaign performance is DERIVED at read
4152
+ -- time from v_marketing_attribution (006); only spend is stored.
4153
+ -- Canonical RLS form: tenant_id IN (SELECT public.user_tenant_ids()).
4154
+
4155
+ CREATE TABLE IF NOT EXISTS public.plg_marketing_channels (
4156
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
4157
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
4158
+ channel_key text NOT NULL,
4159
+ label text NOT NULL,
4160
+ icon text,
4161
+ kind text NOT NULL DEFAULT 'organic'
4162
+ CHECK (kind IN ('paid', 'organic', 'social', 'referral', 'direct', 'outbound')),
4163
+ is_active boolean NOT NULL DEFAULT true,
4164
+ monthly_spend numeric NOT NULL DEFAULT 0,
4165
+ created_at timestamptz NOT NULL DEFAULT now(),
4166
+ updated_at timestamptz NOT NULL DEFAULT now()
4167
+ );
4168
+ CREATE INDEX IF NOT EXISTS idx_plg_marketing_channels_tenant ON public.plg_marketing_channels(tenant_id);
4169
+ CREATE UNIQUE INDEX IF NOT EXISTS u_plg_marketing_channels_tenant_key ON public.plg_marketing_channels(tenant_id, channel_key);
4170
+
4171
+ CREATE TABLE IF NOT EXISTS public.plg_marketing_campaigns (
4172
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
4173
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
4174
+ name text NOT NULL,
4175
+ channel_key text NOT NULL,
4176
+ status text NOT NULL DEFAULT 'draft'
4177
+ CHECK (status IN ('active', 'paused', 'ended', 'draft')),
4178
+ starts_at timestamptz,
4179
+ ends_at timestamptz,
4180
+ spend numeric NOT NULL DEFAULT 0,
4181
+ created_at timestamptz NOT NULL DEFAULT now(),
4182
+ updated_at timestamptz NOT NULL DEFAULT now()
4183
+ );
4184
+ CREATE INDEX IF NOT EXISTS idx_plg_marketing_campaigns_tenant ON public.plg_marketing_campaigns(tenant_id);
4185
+ CREATE INDEX IF NOT EXISTS idx_plg_marketing_campaigns_channel ON public.plg_marketing_campaigns(tenant_id, channel_key);
4186
+
4187
+ DO $$
4188
+ DECLARE t text;
4189
+ BEGIN
4190
+ FOR t IN SELECT unnest(ARRAY['plg_marketing_channels','plg_marketing_campaigns'])
4191
+ LOOP
4192
+ EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', t);
4193
+ EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON public.%I TO authenticated', t);
4194
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_select') THEN
4195
+ EXECUTE format('CREATE POLICY %I ON public.%I FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_select', t);
4196
+ END IF;
4197
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_insert') THEN
4198
+ EXECUTE format('CREATE POLICY %I ON public.%I FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_insert', t);
4199
+ END IF;
4200
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_update') THEN
4201
+ EXECUTE format('CREATE POLICY %I ON public.%I FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_update', t);
4202
+ END IF;
4203
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_delete') THEN
4204
+ EXECUTE format('CREATE POLICY %I ON public.%I FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_delete', t);
4205
+ END IF;
4206
+ END LOOP;
4207
+ END $$;
4208
+ `;
4209
+ var MIGRATION_006_ANALYTICS_VIEWS = `-- Marketing Plugin: analytics read views.
4210
+ -- v_marketing_channels / v_marketing_campaigns \u2014 bridge views for the surface
4211
+ -- and the agent's data primitives.
4212
+ -- v_marketing_attribution \u2014 GENERIC, spine-only attribution event stream
4213
+ -- (people/appointments/orders metadata). \`channel_raw\` is the unnormalized
4214
+ -- origin string; the provider matches it against the app's channel set
4215
+ -- (id or label, case/punctuation-insensitive).
4216
+ -- Apps whose origin lives in an app-local extension table (e.g. beauty's
4217
+ -- clients.origin) may CREATE OR REPLACE this view in their own incubator
4218
+ -- migration, KEEPING the column contract:
4219
+ -- (tenant_id uuid, kind text, source text, channel_raw text,
4220
+ -- occurred_at timestamptz, value numeric)
4221
+
4222
+ CREATE OR REPLACE VIEW public.v_marketing_channels WITH (security_invoker=true) AS
4223
+ SELECT c.id, c.tenant_id, c.channel_key, c.label, c.icon, c.kind,
4224
+ c.is_active, c.monthly_spend, c.created_at, c.updated_at
4225
+ FROM public.plg_marketing_channels c;
4226
+ GRANT SELECT ON public.v_marketing_channels TO authenticated;
4227
+
4228
+ CREATE OR REPLACE VIEW public.v_marketing_campaigns WITH (security_invoker=true) AS
4229
+ SELECT g.id, g.tenant_id, g.name, g.channel_key, ch.label AS channel_label,
4230
+ g.status, g.starts_at, g.ends_at, g.spend, g.created_at, g.updated_at
4231
+ FROM public.plg_marketing_campaigns g
4232
+ LEFT JOIN public.plg_marketing_channels ch
4233
+ ON ch.tenant_id = g.tenant_id AND ch.channel_key = g.channel_key;
4234
+ GRANT SELECT ON public.v_marketing_campaigns TO authenticated;
4235
+
4236
+ CREATE OR REPLACE VIEW public.v_marketing_attribution WITH (security_invoker=true) AS
4237
+ -- lead events (CRM leads; sourceId aligns with lead-sources/channel ids)
4238
+ SELECT p.tenant_id,
4239
+ 'lead'::text AS kind,
4240
+ 'crm'::text AS source,
4241
+ COALESCE(p.metadata->>'sourceId', p.metadata->>'sourceName', p.metadata->>'origin') AS channel_raw,
4242
+ p.created_at AS occurred_at,
4243
+ NULLIF(p.metadata->>'value', '')::numeric AS value
4244
+ FROM public.people p
4245
+ WHERE p.kind = 'lead'
4246
+ UNION ALL
4247
+ -- booking conversions (agenda verticals)
4248
+ SELECT b.tenant_id, 'conversion', 'agenda',
4249
+ COALESCE(b.metadata->>'origin', pc.metadata->>'origin', pc.metadata->>'sourceId', pc.metadata->>'sourceName'),
4250
+ b.starts_at,
4251
+ o.total
4252
+ FROM public.appointments b
4253
+ LEFT JOIN public.people pc ON pc.id = b.party_id
4254
+ LEFT JOIN public.orders o ON o.id = b.order_id
4255
+ WHERE b.status NOT IN ('cancelled', 'no_show')
4256
+ UNION ALL
4257
+ -- order conversions (commerce verticals)
4258
+ SELECT o.tenant_id, 'conversion', 'orders',
4259
+ COALESCE(o.metadata->>'origin', pp.metadata->>'origin', pp.metadata->>'sourceId'),
4260
+ o.created_at,
4261
+ o.total
4262
+ FROM public.orders o
4263
+ LEFT JOIN public.people pp ON pp.id = o.party_id
4264
+ WHERE o.kind NOT IN ('appointment', 'deal')
4265
+ AND o.status NOT IN ('cancelled', 'draft')
4266
+ UNION ALL
4267
+ -- won-deal conversions (CRM verticals)
4268
+ SELECT o.tenant_id, 'conversion', 'crm',
4269
+ COALESCE(pp.metadata->>'sourceId', pp.metadata->>'sourceName', o.metadata->>'origin'),
4270
+ o.updated_at,
4271
+ o.total
4272
+ FROM public.orders o
4273
+ LEFT JOIN public.people pp ON pp.id = o.party_id
4274
+ WHERE o.kind = 'deal' AND o.status = 'won';
4275
+
4276
+ GRANT SELECT ON public.v_marketing_attribution TO authenticated;
4277
+ `;
4278
+ var MIGRATION_007_AGENT_RPCS = `-- ============================================================================
4279
+ -- plugin-marketing 007: server-plane agent write RPC.
4280
+ --
4281
+ -- public.agent_marketing_create_campaign \u2014 guarded campaign create for the
4282
+ -- assistant. Mirrors agent_agenda_create_appointment (agenda 005):
4283
+ -- * actor-authorized: public.agent_guard (spine 015) runs role\u2192plan\u2192limit
4284
+ -- BEFORE anything \u2014 denial comes back as structured jsonb;
4285
+ -- * channel is validated against the tenant's plg_marketing_channels rows
4286
+ -- (key or label, case/punctuation-insensitive); when the tenant has no
4287
+ -- channel rows yet (surface not opened \u2192 lazy seed not run) the raw key is
4288
+ -- accepted as-is;
4289
+ -- * audited: audit_logs row with the acting user.
4290
+ --
4291
+ -- Contract (all agent_* RPCs): (p_tenant_id, p_actor_user_id, p_payload jsonb)
4292
+ -- \u2192 jsonb {ok:true, id, record:{...}} | {ok:false, denial:{...}} | {ok:false,
4293
+ -- error text}. GRANT authenticated+service_role \u2014 NEVER anon.
4294
+ -- ============================================================================
4295
+
4296
+ CREATE OR REPLACE FUNCTION public.agent_marketing_create_campaign(
4297
+ p_tenant_id uuid,
4298
+ p_actor_user_id uuid,
4299
+ p_payload jsonb
4300
+ ) RETURNS jsonb
4301
+ LANGUAGE plpgsql SECURITY DEFINER
4302
+ SET search_path = public
4303
+ AS $$
4304
+ DECLARE
4305
+ v_denial jsonb;
4306
+ v_used int;
4307
+ v_name text;
4308
+ v_channel_raw text;
4309
+ v_channel_key text;
4310
+ v_status text;
4311
+ v_starts timestamptz;
4312
+ v_ends timestamptz;
4313
+ v_spend numeric;
4314
+ v_has_channels boolean;
4315
+ v_id uuid;
4316
+ BEGIN
4317
+ -- \u2500\u2500 payload \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
4318
+ v_name := left(trim(p_payload->>'name'), 200);
4319
+ v_channel_raw := trim(p_payload->>'channel_key');
4320
+ v_status := COALESCE(NULLIF(trim(p_payload->>'status'), ''), 'draft');
4321
+ v_spend := COALESCE(NULLIF(p_payload->>'spend', '')::numeric, 0);
4322
+ BEGIN
4323
+ v_starts := NULLIF(p_payload->>'starts_at', '')::timestamptz;
4324
+ v_ends := NULLIF(p_payload->>'ends_at', '')::timestamptz;
4325
+ EXCEPTION WHEN OTHERS THEN
4326
+ RETURN jsonb_build_object('ok', false, 'error', 'invalid payload: ' || SQLERRM);
4327
+ END;
4328
+ IF v_name IS NULL OR v_name = '' OR v_channel_raw IS NULL OR v_channel_raw = '' THEN
4329
+ RETURN jsonb_build_object('ok', false, 'error', 'name and channel_key are required');
4330
+ END IF;
4331
+ IF v_status NOT IN ('active', 'paused', 'ended', 'draft') THEN
4332
+ RETURN jsonb_build_object('ok', false, 'error', 'status must be one of active|paused|ended|draft');
4333
+ END IF;
4334
+
4335
+ -- \u2500\u2500 authorization: role \u2192 plan \u2192 campaigns_active cap \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
4336
+ SELECT count(*) INTO v_used FROM plg_marketing_campaigns c
4337
+ WHERE c.tenant_id = p_tenant_id AND c.status = 'active';
4338
+ v_denial := agent_guard(p_tenant_id, p_actor_user_id,
4339
+ 'marketing', 'create', 'campaigns_active', v_used,
4340
+ CASE WHEN v_status = 'active' THEN 1 ELSE 0 END);
4341
+ IF v_denial IS NOT NULL THEN
4342
+ RETURN jsonb_build_object('ok', false, 'denial', v_denial);
4343
+ END IF;
4344
+
4345
+ -- \u2500\u2500 channel resolution (key or label, normalized) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
4346
+ SELECT EXISTS (SELECT 1 FROM plg_marketing_channels ch WHERE ch.tenant_id = p_tenant_id)
4347
+ INTO v_has_channels;
4348
+ IF v_has_channels THEN
4349
+ SELECT ch.channel_key INTO v_channel_key
4350
+ FROM plg_marketing_channels ch
4351
+ WHERE ch.tenant_id = p_tenant_id
4352
+ AND (
4353
+ regexp_replace(lower(ch.channel_key), '[^a-z0-9]', '', 'g')
4354
+ = regexp_replace(lower(v_channel_raw), '[^a-z0-9]', '', 'g')
4355
+ OR regexp_replace(lower(ch.label), '[^a-z0-9]', '', 'g')
4356
+ = regexp_replace(lower(v_channel_raw), '[^a-z0-9]', '', 'g')
4357
+ )
4358
+ LIMIT 1;
4359
+ IF v_channel_key IS NULL THEN
4360
+ RETURN jsonb_build_object('ok', false, 'error',
4361
+ 'unknown channel "' || v_channel_raw || '" \u2014 valid channels: ' ||
4362
+ COALESCE((SELECT string_agg(ch.channel_key, ', ' ORDER BY ch.channel_key)
4363
+ FROM plg_marketing_channels ch WHERE ch.tenant_id = p_tenant_id), ''));
4364
+ END IF;
4365
+ ELSE
4366
+ v_channel_key := v_channel_raw;
4367
+ END IF;
4368
+
4369
+ -- \u2500\u2500 write + audit \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
4370
+ INSERT INTO plg_marketing_campaigns (tenant_id, name, channel_key, status, starts_at, ends_at, spend)
4371
+ VALUES (p_tenant_id, v_name, v_channel_key, v_status, COALESCE(v_starts, now()), v_ends, v_spend)
4372
+ RETURNING id INTO v_id;
4373
+
4374
+ INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)
4375
+ VALUES (p_tenant_id, p_actor_user_id, 'agent.createCampaign', 'marketing_campaign', v_id::text,
4376
+ jsonb_build_object('payload', p_payload));
4377
+
4378
+ RETURN jsonb_build_object(
4379
+ 'ok', true,
4380
+ 'id', v_id,
4381
+ 'record', jsonb_build_object(
4382
+ 'id', v_id,
4383
+ 'name', v_name,
4384
+ 'channel_key', v_channel_key,
4385
+ 'status', v_status,
4386
+ 'starts_at', COALESCE(v_starts, now()),
4387
+ 'spend', v_spend
4388
+ )
4389
+ );
4390
+ END;
4391
+ $$;
4392
+
4393
+ REVOKE ALL ON FUNCTION public.agent_marketing_create_campaign(uuid, uuid, jsonb) FROM public;
4394
+ REVOKE EXECUTE ON FUNCTION public.agent_marketing_create_campaign(uuid, uuid, jsonb) FROM anon;
4395
+ GRANT EXECUTE ON FUNCTION public.agent_marketing_create_campaign(uuid, uuid, jsonb)
4396
+ TO authenticated, service_role;
4397
+ `;
4398
+ var INTEGRATIONS = "plg_marketing_ranklayer_integrations";
4399
+ var SYNC_LOG = "plg_marketing_ranklayer_sync_log";
4400
+ function getClient3() {
4401
+ const supabase = core.getSupabaseClientOptional();
4402
+ if (!supabase) throw new Error("[ranklayer] Supabase not initialized");
4403
+ return supabase;
4404
+ }
4405
+ function requireTenantId3() {
4406
+ const tenantId = core.getActiveTenantId();
4407
+ if (!tenantId) throw new Error("[ranklayer] No active tenant");
4408
+ return tenantId;
4409
+ }
4410
+ function toIntegration(row) {
4411
+ return {
4412
+ id: row.id,
4413
+ apiKey: row.api_key ?? void 0,
4414
+ siteDomain: row.site_domain ?? void 0,
4415
+ active: row.active ?? true,
4416
+ lastSyncAt: row.last_sync_at ?? void 0
4417
+ };
4418
+ }
4419
+ function createRankLayerProvider() {
4420
+ return {
4421
+ async getIntegration() {
4422
+ const tenantId = core.getActiveTenantId();
4423
+ if (!tenantId) return null;
4424
+ const { data } = await getClient3().from(INTEGRATIONS).select("*").eq("tenant_id", tenantId).maybeSingle();
4425
+ return data ? toIntegration(data) : null;
4426
+ },
4427
+ async saveIntegration(values) {
4428
+ const tenantId = requireTenantId3();
4429
+ const payload = {
4430
+ tenant_id: tenantId,
4431
+ api_key: values.apiKey,
4432
+ site_domain: values.siteDomain ?? null,
4433
+ active: true,
4434
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
4435
+ };
4436
+ const { data, error } = await getClient3().from(INTEGRATIONS).upsert(payload, { onConflict: "tenant_id" }).select("*").single();
4437
+ if (error) throw error;
4438
+ return toIntegration(data);
4439
+ },
4440
+ async disconnect() {
4441
+ const tenantId = core.getActiveTenantId();
4442
+ if (!tenantId) return;
4443
+ await getClient3().from(INTEGRATIONS).delete().eq("tenant_id", tenantId);
4444
+ },
4445
+ // SCAFFOLD: no RankLayer API yet — accept any non-empty key so the connect
4446
+ // flow can be exercised. Replace with a real credential check in the PR.
4447
+ async testConnection(values) {
4448
+ if (!values.apiKey || values.apiKey.trim().length < 8) {
4449
+ return { ok: false, message: "Informe uma API Key v\xE1lida da RankLayer." };
4450
+ }
4451
+ return { ok: true, message: "Chave aceita (valida\xE7\xE3o real pendente \u2014 integra\xE7\xE3o em prepara\xE7\xE3o)." };
4452
+ },
4453
+ async getSyncLog(integrationId) {
4454
+ const { data } = await getClient3().from(SYNC_LOG).select("*").eq("integration_id", integrationId).order("created_at", { ascending: false }).limit(20);
4455
+ return (data ?? []).map((r) => ({
4456
+ id: r.id,
4457
+ createdAt: r.created_at,
4458
+ status: r.status,
4459
+ message: r.message ?? void 0,
4460
+ fetched: r.fetched ?? 0,
4461
+ written: r.written ?? 0
4462
+ }));
4463
+ }
4464
+ };
4465
+ }
4466
+ var provider = createRankLayerProvider();
4467
+ function RankLayerExtraPanel() {
4468
+ const [integration, setIntegration] = React8.useState(null);
4469
+ const [log, setLog] = React8.useState([]);
4470
+ const [loaded, setLoaded] = React8.useState(false);
4471
+ React8.useEffect(() => {
4472
+ let cancelled = false;
4473
+ (async () => {
4474
+ try {
4475
+ const found = await provider.getIntegration();
4476
+ if (cancelled) return;
4477
+ setIntegration(found);
4478
+ if (found) setLog(await provider.getSyncLog(found.id));
4479
+ } catch {
4480
+ } finally {
4481
+ if (!cancelled) setLoaded(true);
4482
+ }
4483
+ })();
4484
+ return () => {
4485
+ cancelled = true;
4486
+ };
4487
+ }, []);
4488
+ if (!loaded || !integration) return null;
4489
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "space-y-4 border-t pt-4", children: [
4490
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-start gap-3 rounded-md border border-dashed p-3", children: [
4491
+ /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Sparkles, { className: "mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" }),
4492
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "space-y-1", children: [
4493
+ /* @__PURE__ */ jsxRuntime.jsx("h4", { className: "text-sm font-semibold", children: "Sincroniza\xE7\xE3o de conte\xFAdo \u2014 em breve" }),
4494
+ /* @__PURE__ */ jsxRuntime.jsxs("p", { className: "text-xs text-muted-foreground", children: [
4495
+ "A publica\xE7\xE3o autom\xE1tica de p\xE1ginas e artigos de SEO com a RankLayer est\xE1 em prepara\xE7\xE3o.",
4496
+ integration.siteDomain ? ` Dom\xEDnio configurado: ${integration.siteDomain}.` : "",
4497
+ " Assim que a integra\xE7\xE3o estiver ativa, o hist\xF3rico de sincroniza\xE7\xF5es aparece aqui."
4498
+ ] })
4499
+ ] })
4500
+ ] }),
4501
+ log.length > 0 && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "space-y-2", children: [
4502
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-2", children: [
4503
+ /* @__PURE__ */ jsxRuntime.jsx(lucideReact.History, { className: "h-4 w-4 text-muted-foreground" }),
4504
+ /* @__PURE__ */ jsxRuntime.jsx("h4", { className: "text-sm font-semibold", children: "Hist\xF3rico" })
4505
+ ] }),
4506
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "divide-y rounded-md border text-sm", children: log.map((r) => /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-3 px-3 py-2", children: [
4507
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-xs text-muted-foreground", children: new Date(r.createdAt).toLocaleString("pt-BR") }),
4508
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "ml-auto text-xs", children: [
4509
+ r.written,
4510
+ "/",
4511
+ r.fetched,
4512
+ " publicadas"
4513
+ ] }),
4514
+ r.status === "success" ? /* @__PURE__ */ jsxRuntime.jsx(lucideReact.CheckCircle2, { className: "h-3.5 w-3.5 text-success" }) : /* @__PURE__ */ jsxRuntime.jsx(lucideReact.AlertCircle, { className: "h-3.5 w-3.5 text-warning" })
4515
+ ] }, r.id)) })
4516
+ ] })
4517
+ ] });
4518
+ }
4519
+ var ranklayerConnectorDef = {
4520
+ id: "ranklayer",
4521
+ hostPluginId: "marketing",
4522
+ name: "RankLayer",
4523
+ 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.",
4524
+ icon: "Search",
4525
+ authKind: "api-key",
4526
+ fields: [
4527
+ { key: "apiKey", label: "API Key", type: "password", placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" },
4528
+ { key: "siteDomain", label: "Dom\xEDnio do site", type: "text", placeholder: "exemplo.com.br" }
4529
+ ],
4530
+ async getStatus() {
4531
+ try {
4532
+ const found = await provider.getIntegration();
4533
+ return { connected: !!found?.apiKey, detail: found?.siteDomain };
4534
+ } catch {
4535
+ return { connected: false };
4536
+ }
4537
+ },
4538
+ testConnection: (values) => provider.testConnection({ apiKey: values.apiKey }),
4539
+ saveConnection: async (values) => {
4540
+ await provider.saveIntegration({ apiKey: values.apiKey, siteDomain: values.siteDomain });
4541
+ },
4542
+ disconnect: () => provider.disconnect(),
4543
+ ExtraPanel: RankLayerExtraPanel
4544
+ };
3528
4545
 
3529
4546
  // src/locales/en.ts
3530
4547
  var en = {
@@ -3884,6 +4901,8 @@ var DEFAULT_LABELS = {
3884
4901
  funnel: "Funnel",
3885
4902
  landingPages: "Landing pages",
3886
4903
  content: "Content",
4904
+ blog: "Blog",
4905
+ blogCategories: "Categorias",
3887
4906
  settings: "Settings"
3888
4907
  };
3889
4908
  function resolveConfig(options) {
@@ -3898,16 +4917,23 @@ function resolveConfig(options) {
3898
4917
  };
3899
4918
  return { config, domain };
3900
4919
  }
3901
- function createSafeProvider(config, domain) {
3902
- void core.getSupabaseClientOptional();
3903
- return createMockMarketingProvider({ channels: config.channels, conversion: config.conversion, domain });
4920
+ function createSafeProvider(config, domain, options) {
4921
+ return core.createSafeDataProvider(
4922
+ () => createSupabaseMarketingProvider({
4923
+ channels: config.channels,
4924
+ conversion: config.conversion,
4925
+ attributionBridge: options?.attributionBridge,
4926
+ sitesBridge: options?.sitesBridge
4927
+ }),
4928
+ () => createMockMarketingProvider({ channels: config.channels, conversion: config.conversion, domain })
4929
+ );
3904
4930
  }
3905
4931
  function createMarketingPlugin(options) {
3906
4932
  core.registerTranslations(marketingLocales);
3907
4933
  const { config, domain } = resolveConfig(options);
3908
- const provider = options?.dataProvider ?? createSafeProvider(config, domain);
3909
- const store = createMarketingStore(provider);
3910
- const dashboardWidgets = createMarketingDashboardWidgets({ config, provider, store });
4934
+ const provider2 = options?.dataProvider ?? createSafeProvider(config, domain, options);
4935
+ const store = createMarketingStore(provider2);
4936
+ const dashboardWidgets = createMarketingDashboardWidgets({ config, provider: provider2, store });
3911
4937
  const contentProvider = options?.contentPlanner?.dataProvider ?? core.createSafeDataProvider(
3912
4938
  () => createSupabaseContentPlannerProvider(),
3913
4939
  () => createMockContentPlannerProvider()
@@ -3915,8 +4941,8 @@ function createMarketingPlugin(options) {
3915
4941
  const contentStore = createContentPlannerStore(contentProvider);
3916
4942
  const PageComponent = () => React8__default.default.createElement(
3917
4943
  MarketingContextProvider,
3918
- { config, provider, store },
3919
- React8__default.default.createElement(MarketingPage, { config, provider, store, contentProvider, contentStore })
4944
+ { config, provider: provider2, store },
4945
+ React8__default.default.createElement(MarketingPage, { config, provider: provider2, store, contentProvider, contentStore })
3920
4946
  );
3921
4947
  PageComponent.displayName = "MarketingPage";
3922
4948
  const marketingRegistries = [
@@ -3925,12 +4951,14 @@ function createMarketingPlugin(options) {
3925
4951
  ];
3926
4952
  const SettingsComponent = () => React8__default.default.createElement(
3927
4953
  MarketingContextProvider,
3928
- { config, provider, store },
4954
+ { config, provider: provider2, store },
3929
4955
  React8__default.default.createElement(saas.PluginSettingsPanel, {
3930
4956
  title: config.labels.settings ?? config.labels.pageTitle,
3931
4957
  subtitle: config.labels.pageSubtitle,
3932
4958
  generalSettings: React8__default.default.createElement(SettingsView),
3933
4959
  registries: marketingRegistries,
4960
+ // Enables the "Integrações" tab (renders manifest connectors for this host).
4961
+ hostPluginId: "marketing",
3934
4962
  routeBase: "/settings/marketing"
3935
4963
  })
3936
4964
  );
@@ -3950,7 +4978,96 @@ function createMarketingPlugin(options) {
3950
4978
  declaredFeatures: [
3951
4979
  { id: "marketing", label: config.labels.pageTitle, group: "Engage" },
3952
4980
  ...config.modules.landingPages ? [{ id: "marketing.landing-pages", label: config.labels.landingPages, group: "Engage" }] : [],
3953
- ...config.modules.contentPlanner ? [{ id: "marketing.content", label: config.labels.content, group: "Engage" }] : []
4981
+ ...config.modules.contentPlanner ? [{ id: "marketing.content", label: config.labels.content, group: "Engage" }] : [],
4982
+ ...config.modules.blog ? [{ id: "marketing.blog", label: config.labels.blog, group: "Engage" }] : []
4983
+ ],
4984
+ declaredLimits: [
4985
+ ...config.modules.campaigns ? [{ key: "campaigns_active", label: "Active campaigns", table: T.campaigns }] : [],
4986
+ ...config.modules.contentPlanner ? [{ key: "content_posts_month", label: "Content posts / month", table: "plg_marketing_content_posts", period: "month" }] : [],
4987
+ ...config.modules.blog ? [{ key: "blog_posts", label: "Blog posts", table: "plg_blog_posts" }] : []
4988
+ ],
4989
+ // Read-models for the agent's data primitives (searchRecords/queryData) —
4990
+ // mirrors agenda's `agenda:appointments`. Keys are stable ASCII; fields are
4991
+ // the provider's camelCase output shape.
4992
+ queryEntities: [
4993
+ ...config.modules.campaigns ? [{
4994
+ key: "marketing:campaigns",
4995
+ writable: false,
4996
+ entity: {
4997
+ name: "Campaign",
4998
+ namePlural: "Campaigns",
4999
+ icon: "Megaphone",
5000
+ permission: { feature: "marketing", action: "read" },
5001
+ fields: [
5002
+ { key: "name", label: "Name", type: "text", searchable: true },
5003
+ { key: "channelKey", label: "Channel", type: "text", searchable: true },
5004
+ { key: "channelLabel", label: "Channel label", type: "text" },
5005
+ { key: "status", label: "Status", type: "text" },
5006
+ { key: "startsAt", label: "Starts at", type: "text" },
5007
+ { key: "endsAt", label: "Ends at", type: "text" },
5008
+ { key: "spend", label: "Spend", type: "number" },
5009
+ { key: "createdAt", label: "Created at", type: "text" }
5010
+ ],
5011
+ data: {
5012
+ table: "v_marketing_campaigns",
5013
+ tenantScoped: true,
5014
+ searchColumns: ["name", "channel_key"]
5015
+ }
5016
+ }
5017
+ }] : [],
5018
+ ...config.modules.channels ? [{
5019
+ key: "marketing:channels",
5020
+ writable: false,
5021
+ entity: {
5022
+ name: "Marketing channel",
5023
+ namePlural: "Marketing channels",
5024
+ icon: "Radio",
5025
+ permission: { feature: "marketing", action: "read" },
5026
+ fields: [
5027
+ { key: "channelKey", label: "Key", type: "text", searchable: true },
5028
+ { key: "label", label: "Channel", type: "text", searchable: true },
5029
+ { key: "kind", label: "Type", type: "text" },
5030
+ { key: "isActive", label: "Active", type: "boolean" },
5031
+ { key: "monthlySpend", label: "Monthly spend", type: "number" }
5032
+ ],
5033
+ data: {
5034
+ table: "v_marketing_channels",
5035
+ tenantScoped: true,
5036
+ searchColumns: ["label", "channel_key"]
5037
+ }
5038
+ }
5039
+ }] : [],
5040
+ ...config.modules.contentPlanner ? [{
5041
+ key: "marketing:contentPosts",
5042
+ writable: false,
5043
+ entity: {
5044
+ name: "Content post",
5045
+ namePlural: "Content posts",
5046
+ icon: "FileText",
5047
+ permission: { feature: "marketing.content", action: "read" },
5048
+ fields: [
5049
+ { key: "title", label: "Title", type: "text", searchable: true },
5050
+ { key: "format", label: "Format", type: "text" },
5051
+ { key: "status", label: "Status", type: "text" },
5052
+ { key: "weekNumber", label: "Week", type: "number" },
5053
+ { key: "scheduledDate", label: "Scheduled date", type: "text" },
5054
+ { key: "hook", label: "Hook", type: "text", searchable: true }
5055
+ ],
5056
+ data: {
5057
+ table: T.contentPosts,
5058
+ tenantScoped: true,
5059
+ searchColumns: ["title", "hook"]
5060
+ }
5061
+ }
5062
+ }] : []
5063
+ ],
5064
+ declaredRpcs: [
5065
+ ...config.modules.campaigns ? [{
5066
+ name: "agent_marketing_create_campaign",
5067
+ kind: "write",
5068
+ description: "Guarded campaign create: agent_guard (role\u2192plan\u2192campaigns_active cap), channel validated against the tenant channel set, audited.",
5069
+ audits: true
5070
+ }] : []
3954
5071
  ],
3955
5072
  navigation: [
3956
5073
  {
@@ -4004,8 +5121,35 @@ function createMarketingPlugin(options) {
4004
5121
  version: "1.2.0",
4005
5122
  sql: MIGRATION_003_RECORDING_OPS,
4006
5123
  description: "Recording-day checklist + media asset URL on posts; content-media storage bucket"
5124
+ },
5125
+ {
5126
+ id: "marketing-004-ranklayer",
5127
+ version: "1.3.0",
5128
+ sql: MIGRATION_004_RANKLAYER,
5129
+ description: "RankLayer connector state: plg_marketing_ranklayer_integrations + _sync_log (tenant RLS)"
5130
+ },
5131
+ {
5132
+ id: "marketing-005-analytics-base",
5133
+ version: "1.4.0",
5134
+ sql: MIGRATION_005_ANALYTICS_BASE,
5135
+ description: "Analytics base: plg_marketing_channels + plg_marketing_campaigns with canonical tenant RLS"
5136
+ },
5137
+ {
5138
+ id: "marketing-006-analytics-views",
5139
+ version: "1.4.0",
5140
+ sql: MIGRATION_006_ANALYTICS_VIEWS,
5141
+ description: "Read views v_marketing_channels/_campaigns + generic spine-only v_marketing_attribution"
5142
+ },
5143
+ {
5144
+ id: "marketing-007-agent-rpcs",
5145
+ version: "1.4.0",
5146
+ sql: MIGRATION_007_AGENT_RPCS,
5147
+ description: "agent_marketing_create_campaign \u2014 guarded, audited server-plane campaign create"
4007
5148
  }
4008
5149
  ],
5150
+ // RankLayer SEO connector — renders in the Integrações settings tab. Scaffold
5151
+ // (control plane only); the real sync lands via an external PR (RANKLAYER.md).
5152
+ connectors: [ranklayerConnectorDef],
4009
5153
  aiTools: [
4010
5154
  {
4011
5155
  id: "marketing.channel-performance",
@@ -4046,18 +5190,25 @@ function createMarketingPlugin(options) {
4046
5190
  {
4047
5191
  id: "marketing.create-campaign",
4048
5192
  name: "createCampaign",
4049
- description: "Creates an acquisition campaign on a channel.",
5193
+ description: "Creates an acquisition campaign on a channel. Resolve the channel first via queryData/searchRecords on marketing channels (use its channel key). The user confirms before creating.",
4050
5194
  icon: "Plus",
4051
5195
  mode: "persist",
5196
+ limitKey: "campaigns_active",
5197
+ execution: { plane: "server", kind: "rpc", rpc: "agent_marketing_create_campaign" },
4052
5198
  category: "Marketing",
4053
5199
  parameters: {
4054
5200
  type: "object",
4055
5201
  properties: {
4056
5202
  name: { type: "string", description: "Campaign name" },
4057
- channelId: { type: "string", description: "Acquisition channel id" }
5203
+ channel_key: { type: "string", description: "Acquisition channel key (e.g. 'instagram') or its label" },
5204
+ status: { type: "string", enum: ["draft", "active", "paused"], description: "Defaults to draft" },
5205
+ starts_at: { type: "string", description: "ISO start date-time (defaults to now)" },
5206
+ ends_at: { type: "string", description: "ISO end date-time (optional)" },
5207
+ spend: { type: "number", description: "Planned/committed spend (optional)" }
4058
5208
  },
4059
- required: ["name", "channelId"]
5209
+ required: ["name", "channel_key"]
4060
5210
  },
5211
+ suggestions: [{ label: "Create an Instagram campaign for this month" }],
4061
5212
  permission: { feature: "marketing", action: "create" }
4062
5213
  }
4063
5214
  ],
@@ -4081,5 +5232,6 @@ exports.createMarketingPlugin = createMarketingPlugin;
4081
5232
  exports.createMockContentPlannerProvider = createMockContentPlannerProvider;
4082
5233
  exports.createMockMarketingProvider = createMockMarketingProvider;
4083
5234
  exports.createSupabaseContentPlannerProvider = createSupabaseContentPlannerProvider;
5235
+ exports.createSupabaseMarketingProvider = createSupabaseMarketingProvider;
4084
5236
  //# sourceMappingURL=index.cjs.map
4085
5237
  //# sourceMappingURL=index.cjs.map