@fayz-ai/plugin-marketing 0.8.1 → 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 (47) hide show
  1. package/dist/MarketingPage.d.ts.map +1 -1
  2. package/dist/data/capability.test.d.ts +2 -0
  3. package/dist/data/capability.test.d.ts.map +1 -0
  4. package/dist/data/supabase.d.ts +10 -0
  5. package/dist/data/supabase.d.ts.map +1 -0
  6. package/dist/data/tables.d.ts +7 -0
  7. package/dist/data/tables.d.ts.map +1 -1
  8. package/dist/data/types.d.ts +2 -0
  9. package/dist/data/types.d.ts.map +1 -1
  10. package/dist/index.cjs +744 -58
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +746 -61
  15. package/dist/index.js.map +1 -1
  16. package/dist/migrations/index.d.ts +3 -0
  17. package/dist/migrations/index.d.ts.map +1 -1
  18. package/dist/registries.d.ts +3 -3
  19. package/dist/registries.d.ts.map +1 -1
  20. package/dist/schema/index.d.ts +354 -0
  21. package/dist/schema/index.d.ts.map +1 -1
  22. package/dist/schema.cjs +30 -0
  23. package/dist/schema.cjs.map +1 -1
  24. package/dist/schema.js +30 -2
  25. package/dist/schema.js.map +1 -1
  26. package/dist/store.d.ts +2 -0
  27. package/dist/store.d.ts.map +1 -1
  28. package/dist/views/CampaignsView.d.ts.map +1 -1
  29. package/dist/views/blog/BlogEntityForm.d.ts.map +1 -1
  30. package/dist/views/content/ContentView.d.ts.map +1 -1
  31. package/package.json +8 -6
  32. package/src/MarketingPage.tsx +5 -1
  33. package/src/data/capability.test.ts +92 -0
  34. package/src/data/supabase.ts +401 -0
  35. package/src/data/tables.ts +9 -0
  36. package/src/data/types.ts +2 -0
  37. package/src/index.ts +138 -10
  38. package/src/migrations/005_analytics_base.sql +60 -0
  39. package/src/migrations/006_analytics_views.sql +68 -0
  40. package/src/migrations/007_agent_rpcs.sql +119 -0
  41. package/src/migrations/index.ts +257 -1
  42. package/src/registries.ts +18 -6
  43. package/src/schema/index.ts +38 -1
  44. package/src/store.ts +21 -8
  45. package/src/views/CampaignsView.tsx +4 -1
  46. package/src/views/blog/BlogEntityForm.tsx +11 -2
  47. package/src/views/content/ContentView.tsx +8 -1
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import React8, { useState, useEffect, useMemo, useCallback } from 'react';
2
- import { getActiveTenantId, registerTranslations, createSafeDataProvider, getSupabaseClientOptional, useTranslation, resolveDataProvider } from '@fayz-ai/core';
3
- import { createPluginContext, PluginSettingsPanel, useModuleNavigation, createViewRouter, ModuleActionBar, SettingsGroup, SelectRow, ToggleRow, useOrganizationStore, CrudFormPage, CrudListView } from '@fayz-ai/saas';
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
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, 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
6
  import { Sparkles, History, CheckCircle2, AlertCircle, ChevronRight, Users, Target, Percent, DollarSign, Trash2, Plus, MousePointerClick, Layers, 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';
@@ -359,11 +359,11 @@ function CampaignsView() {
359
359
  /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: t("marketing.campaigns.subtitle") }),
360
360
  /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
361
361
  /* @__PURE__ */ jsx(RangeTabs, {}),
362
- /* @__PURE__ */ jsxs(Button, { onClick: () => setComposerOpen(true), children: [
362
+ /* @__PURE__ */ jsx(PermissionGate, { feature: "marketing", action: "create", children: /* @__PURE__ */ jsxs(Button, { onClick: () => setComposerOpen(true), children: [
363
363
  /* @__PURE__ */ jsx(Plus, { className: "mr-1.5 h-4 w-4" }),
364
364
  " ",
365
365
  t("marketing.campaigns.new")
366
- ] })
366
+ ] }) })
367
367
  ] })
368
368
  ] }),
369
369
  /* @__PURE__ */ jsx(DataTable, { columns, data: campaigns, emptyMessage: t("marketing.campaigns.empty") }),
@@ -1308,6 +1308,7 @@ function ContentView({ onOpenPost }) {
1308
1308
  const viewMode = useContentPlannerStore((s) => s.viewMode);
1309
1309
  const setViewMode = useContentPlannerStore((s) => s.setViewMode);
1310
1310
  const loadedTenantId = useContentPlannerStore((s) => s.loadedTenantId);
1311
+ const guardContentPosts = useLimitGuard("content_posts_month");
1311
1312
  const [briefOpen, setBriefOpen] = React8.useState(false);
1312
1313
  const [pendingDeleteId, setPendingDeleteId] = React8.useState(null);
1313
1314
  const tenantId = useOrganizationStore((state) => state.currentOrg?.id);
@@ -1335,6 +1336,7 @@ function ContentView({ onOpenPost }) {
1335
1336
  });
1336
1337
  };
1337
1338
  const createPost = async (week) => {
1339
+ if (await guardContentPosts() === "blocked") return;
1338
1340
  const targetPlan = await ensurePlan();
1339
1341
  if (!targetPlan) return;
1340
1342
  const start = targetPlan.startDate ? /* @__PURE__ */ new Date(`${targetPlan.startDate}T00:00:00`) : mondayOfCurrentWeek();
@@ -1347,6 +1349,7 @@ function ContentView({ onOpenPost }) {
1347
1349
  status: "idea",
1348
1350
  scheduledDate: toISODate(start)
1349
1351
  });
1352
+ invalidateLimit("content_posts_month");
1350
1353
  onOpenPost(post.id);
1351
1354
  };
1352
1355
  const movePost = async (postId, week) => {
@@ -1355,6 +1358,7 @@ function ContentView({ onOpenPost }) {
1355
1358
  await savePost({ id: postId, weekNumber: week });
1356
1359
  };
1357
1360
  const createPostAt = async (isoDate) => {
1361
+ if (await guardContentPosts() === "blocked") return;
1358
1362
  const targetPlan = await ensurePlan();
1359
1363
  if (!targetPlan) return;
1360
1364
  let week = 1;
@@ -1372,6 +1376,7 @@ function ContentView({ onOpenPost }) {
1372
1376
  status: "idea",
1373
1377
  scheduledDate: isoDate
1374
1378
  });
1379
+ invalidateLimit("content_posts_month");
1375
1380
  onOpenPost(post.id);
1376
1381
  };
1377
1382
  const busy = !loaded || loading;
@@ -2346,6 +2351,8 @@ function BlogEntityForm({
2346
2351
  }) {
2347
2352
  const { getById, create, update, remove } = useEntityCrud(entity);
2348
2353
  const isEdit = !!editId;
2354
+ const isPost = entity.data?.table === BLOG_POSTS_TABLE;
2355
+ const guardBlogPosts = useLimitGuard("blog_posts");
2349
2356
  const namePlural = entity.namePlural ?? entity.name + "s";
2350
2357
  const [initialData, setInitialData] = React8.useState(isEdit ? null : {});
2351
2358
  React8.useEffect(() => {
@@ -2360,9 +2367,13 @@ function BlogEntityForm({
2360
2367
  };
2361
2368
  }, [editId, getById]);
2362
2369
  async function save(values) {
2370
+ if (!isEdit && isPost && await guardBlogPosts() === "blocked") return;
2363
2371
  try {
2364
2372
  if (isEdit) await update(editId, values);
2365
- else await create(values);
2373
+ else {
2374
+ await create(values);
2375
+ if (isPost) invalidateLimit("blog_posts");
2376
+ }
2366
2377
  toast.success(isEdit ? `${entity.name} updated` : `${entity.name} created`);
2367
2378
  onDone();
2368
2379
  } catch (err) {
@@ -2468,6 +2479,9 @@ function MarketingPage({ config, provider: provider2, store, contentProvider, co
2468
2479
  React8.useEffect(() => {
2469
2480
  void store.getState().load();
2470
2481
  }, [store]);
2482
+ useDataChanged({ table: "plg_marketing_campaigns" }, () => {
2483
+ void store.getState().refresh();
2484
+ }, [store]);
2471
2485
  const isOverview = view === "overview";
2472
2486
  const nav = buildNav(config, view, navigate);
2473
2487
  const blogPostEntity = React8.useMemo(() => buildBlogPostEntity(), []);
@@ -2583,6 +2597,20 @@ function SettingsView() {
2583
2597
  ] });
2584
2598
  }
2585
2599
 
2600
+ // src/data/tables.ts
2601
+ var T = {
2602
+ socialAccounts: "plg_marketing_social_accounts",
2603
+ contentPlans: "plg_marketing_content_plans",
2604
+ contentPosts: "plg_marketing_content_posts",
2605
+ channels: "plg_marketing_channels",
2606
+ campaigns: "plg_marketing_campaigns"
2607
+ };
2608
+ var V = {
2609
+ channels: "v_marketing_channels",
2610
+ campaigns: "v_marketing_campaigns",
2611
+ attribution: "v_marketing_attribution"
2612
+ };
2613
+
2586
2614
  // src/registries.ts
2587
2615
  var KIND_OPTIONS = [
2588
2616
  { value: "paid", label: "Paid" },
@@ -2600,10 +2628,18 @@ var channelEntity = {
2600
2628
  defaultSort: "label",
2601
2629
  fields: [
2602
2630
  { key: "label", label: "Channel", type: "text", required: true, showInTable: true, searchable: true },
2631
+ { key: "channelKey", label: "Key", type: "text", required: true, showInTable: true },
2603
2632
  { key: "kind", label: "Type", type: "select", options: KIND_OPTIONS, showInTable: true },
2604
- { key: "icon", label: "Icon", type: "text", showInTable: true }
2633
+ { key: "icon", label: "Icon", type: "text" },
2634
+ { key: "monthlySpend", label: "Monthly spend", type: "number", showInTable: true, defaultValue: 0 },
2635
+ { key: "isActive", label: "Active", type: "boolean", showInTable: true, defaultValue: true, inlineToggle: true }
2605
2636
  ],
2606
- facets: [{ field: "kind" }]
2637
+ facets: [{ field: "kind" }],
2638
+ data: {
2639
+ table: T.channels,
2640
+ tenantScoped: true,
2641
+ searchColumns: ["label", "channel_key"]
2642
+ }
2607
2643
  };
2608
2644
  function buildMarketingRegistries(channels) {
2609
2645
  return [
@@ -2613,8 +2649,17 @@ function buildMarketingRegistries(channels) {
2613
2649
  icon: "Radio",
2614
2650
  description: "Acquisition channels tracked for this workspace",
2615
2651
  display: "table",
2616
- readOnly: true,
2617
- seedData: channels.map((c) => ({ id: c.id, label: c.label, kind: c.kind, icon: c.icon }))
2652
+ seedData: [
2653
+ ...channels.map((c) => ({
2654
+ id: c.id,
2655
+ channelKey: c.id,
2656
+ label: c.label,
2657
+ kind: c.kind,
2658
+ icon: c.icon,
2659
+ isActive: true,
2660
+ monthlySpend: 0
2661
+ }))
2662
+ ]
2618
2663
  }
2619
2664
  ];
2620
2665
  }
@@ -3035,6 +3080,276 @@ function createMockMarketingProvider(cfg) {
3035
3080
  }
3036
3081
  };
3037
3082
  }
3083
+ var RANGE_DAYS = { "7d": 7, "30d": 30, "90d": 90 };
3084
+ function getClient() {
3085
+ const supabase = getSupabaseClientOptional();
3086
+ if (!supabase) throw new Error("Supabase not initialized");
3087
+ return supabase;
3088
+ }
3089
+ function requireTenantId() {
3090
+ const tenantId = getActiveTenantId();
3091
+ if (!tenantId) throw new Error("No active tenant");
3092
+ return tenantId;
3093
+ }
3094
+ function normKey(value) {
3095
+ return (value ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
3096
+ }
3097
+ function createSupabaseMarketingProvider(options) {
3098
+ const { channels, conversion, attributionBridge, sitesBridge } = options;
3099
+ const channelByNorm = /* @__PURE__ */ new Map();
3100
+ for (const ch of channels) {
3101
+ channelByNorm.set(normKey(ch.id), ch.id);
3102
+ channelByNorm.set(normKey(ch.label), ch.id);
3103
+ }
3104
+ const resolveChannel = (raw) => channelByNorm.get(normKey(raw)) ?? null;
3105
+ const seeded = /* @__PURE__ */ new Set();
3106
+ async function ensureChannels(tenantId) {
3107
+ if (seeded.has(tenantId)) return;
3108
+ seeded.add(tenantId);
3109
+ await getClient().from(T.channels).upsert(
3110
+ channels.map((c) => ({
3111
+ tenant_id: tenantId,
3112
+ channel_key: c.id,
3113
+ label: c.label,
3114
+ icon: c.icon,
3115
+ kind: c.kind
3116
+ })),
3117
+ { onConflict: "tenant_id,channel_key", ignoreDuplicates: true }
3118
+ );
3119
+ }
3120
+ const CACHE_TTL_MS = 3e4;
3121
+ const cache = /* @__PURE__ */ new Map();
3122
+ function memo(key, fn) {
3123
+ const hit = cache.get(key);
3124
+ if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.promise;
3125
+ const promise = fn().catch((err) => {
3126
+ cache.delete(key);
3127
+ throw err;
3128
+ });
3129
+ cache.set(key, { at: Date.now(), promise });
3130
+ return promise;
3131
+ }
3132
+ function windowFor(range) {
3133
+ const days = RANGE_DAYS[range];
3134
+ const now = Math.floor(Date.now() / 6e4) * 6e4;
3135
+ return { since: new Date(now - days * 864e5), days };
3136
+ }
3137
+ function fetchAttribution(tenantId, since, until) {
3138
+ const key = `attr:${tenantId}:${since.getTime()}:${until?.getTime() ?? ""}`;
3139
+ return memo(key, async () => {
3140
+ 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})`);
3141
+ if (until) q = q.lt("occurred_at", until.toISOString());
3142
+ const { data, error } = await q;
3143
+ if (error) throw error;
3144
+ return data ?? [];
3145
+ });
3146
+ }
3147
+ function rollup(rows) {
3148
+ const map = /* @__PURE__ */ new Map();
3149
+ for (const row of rows) {
3150
+ const channelId = resolveChannel(row.channel_raw);
3151
+ if (!channelId) continue;
3152
+ const agg = map.get(channelId) ?? { leads: 0, conversions: 0, value: 0 };
3153
+ if (row.kind === "lead") agg.leads += 1;
3154
+ else {
3155
+ agg.conversions += 1;
3156
+ agg.value += Number(row.value ?? conversion.avgValue);
3157
+ }
3158
+ map.set(channelId, agg);
3159
+ }
3160
+ return map;
3161
+ }
3162
+ function fetchSpend(tenantId, days, since) {
3163
+ return memo(`spend:${tenantId}:${days}:${since.getTime()}`, () => fetchSpendRaw(tenantId, days, since));
3164
+ }
3165
+ async function fetchSpendRaw(tenantId, days, since) {
3166
+ const client = getClient();
3167
+ const [channelRows, campaignRows] = await Promise.all([
3168
+ client.from(V.channels).select("channel_key, monthly_spend").eq("tenant_id", tenantId),
3169
+ client.from(V.campaigns).select("channel_key, spend, status, starts_at, ends_at").eq("tenant_id", tenantId).neq("status", "draft")
3170
+ ]);
3171
+ const spend = /* @__PURE__ */ new Map();
3172
+ for (const row of channelRows.data ?? []) {
3173
+ const id = resolveChannel(row.channel_key);
3174
+ if (!id) continue;
3175
+ spend.set(id, (spend.get(id) ?? 0) + Number(row.monthly_spend) * days / 30);
3176
+ }
3177
+ for (const row of campaignRows.data ?? []) {
3178
+ const id = resolveChannel(row.channel_key);
3179
+ if (!id) continue;
3180
+ const ended = row.ends_at ? new Date(row.ends_at) : null;
3181
+ if (ended && ended < since) continue;
3182
+ spend.set(id, (spend.get(id) ?? 0) + Number(row.spend));
3183
+ }
3184
+ return spend;
3185
+ }
3186
+ async function channelPerformance(query) {
3187
+ const tenantId = requireTenantId();
3188
+ await ensureChannels(tenantId);
3189
+ const { since, days } = windowFor(query.range);
3190
+ const [rows, spend] = await Promise.all([
3191
+ fetchAttribution(tenantId, since),
3192
+ fetchSpend(tenantId, days, since)
3193
+ ]);
3194
+ const byChannel = rollup(rows);
3195
+ if (attributionBridge) {
3196
+ const bridged = await attributionBridge.conversionsByChannel(query);
3197
+ for (const b of bridged) {
3198
+ const agg = byChannel.get(b.channelId) ?? { leads: 0, conversions: 0, value: 0 };
3199
+ agg.conversions = b.conversions;
3200
+ agg.value = b.value;
3201
+ byChannel.set(b.channelId, agg);
3202
+ }
3203
+ }
3204
+ const list = channels.map((ch) => {
3205
+ const agg = byChannel.get(ch.id) ?? { leads: 0, conversions: 0, value: 0 };
3206
+ const chSpend = spend.get(ch.id) ?? 0;
3207
+ return {
3208
+ channelId: ch.id,
3209
+ reach: 0,
3210
+ leads: agg.leads,
3211
+ conversions: agg.conversions,
3212
+ spend: Math.round(chSpend),
3213
+ value: Math.round(agg.value),
3214
+ cvr: 0,
3215
+ cpa: agg.conversions > 0 && chSpend > 0 ? chSpend / agg.conversions : 0
3216
+ };
3217
+ });
3218
+ return query.channelId ? list.filter((c) => c.channelId === query.channelId) : list;
3219
+ }
3220
+ function campaignFromRow(row) {
3221
+ return {
3222
+ id: row.id,
3223
+ name: row.name,
3224
+ channelId: row.channel_key,
3225
+ status: row.status,
3226
+ start: row.starts_at ?? row.created_at,
3227
+ end: row.ends_at ?? void 0,
3228
+ spend: Number(row.spend ?? 0),
3229
+ reach: 0,
3230
+ leads: 0,
3231
+ conversions: 0,
3232
+ value: 0
3233
+ };
3234
+ }
3235
+ function withDerived(campaign, rows) {
3236
+ const start = new Date(campaign.start).getTime();
3237
+ const end = campaign.end ? new Date(campaign.end).getTime() : Number.POSITIVE_INFINITY;
3238
+ let leads = 0;
3239
+ let conversions = 0;
3240
+ let value = 0;
3241
+ for (const row of rows) {
3242
+ if (resolveChannel(row.channel_raw) !== campaign.channelId) continue;
3243
+ const at = new Date(row.occurred_at).getTime();
3244
+ if (at < start || at > end) continue;
3245
+ if (row.kind === "lead") leads += 1;
3246
+ else {
3247
+ conversions += 1;
3248
+ value += Number(row.value ?? conversion.avgValue);
3249
+ }
3250
+ }
3251
+ return { ...campaign, leads, conversions, value: Math.round(value) };
3252
+ }
3253
+ return {
3254
+ channelPerformance,
3255
+ async overview(query) {
3256
+ const tenantId = requireTenantId();
3257
+ const perf = await channelPerformance(query);
3258
+ const { since, days } = windowFor(query.range);
3259
+ const prevRows = await fetchAttribution(
3260
+ tenantId,
3261
+ new Date(since.getTime() - days * 864e5),
3262
+ since
3263
+ );
3264
+ const conversions = perf.reduce((s, c) => s + c.conversions, 0);
3265
+ const spend = perf.reduce((s, c) => s + c.spend, 0);
3266
+ const value = perf.reduce((s, c) => s + c.value, 0);
3267
+ const conversionsPrev = prevRows.filter(
3268
+ (r) => r.kind === "conversion" && resolveChannel(r.channel_raw)
3269
+ ).length;
3270
+ const top = [...perf].sort((a, b) => b.conversions - a.conversions)[0];
3271
+ return {
3272
+ conversions,
3273
+ conversionsPrev,
3274
+ cvr: 0,
3275
+ cvrPrev: 0,
3276
+ spend,
3277
+ cpa: conversions > 0 && spend > 0 ? spend / conversions : 0,
3278
+ value,
3279
+ topChannelId: top && top.conversions > 0 ? top.channelId : null,
3280
+ channelMix: perf.map((c) => ({ channelId: c.channelId, conversions: c.conversions }))
3281
+ };
3282
+ },
3283
+ async funnel(query) {
3284
+ if (attributionBridge) return attributionBridge.funnel(query);
3285
+ const tenantId = requireTenantId();
3286
+ const { since } = windowFor(query.range);
3287
+ const rows = await fetchAttribution(tenantId, since);
3288
+ const leads = rows.filter((r) => r.kind === "lead").length;
3289
+ const conversions = rows.filter((r) => r.kind === "conversion").length;
3290
+ return [
3291
+ { id: "leads", label: "Leads", count: leads, color: "#0ea5e9" },
3292
+ { id: "converted", label: conversion.labelPlural, count: conversions, color: "#22c55e" }
3293
+ ];
3294
+ },
3295
+ async listCampaigns(query) {
3296
+ const tenantId = requireTenantId();
3297
+ const fetchRows = () => memo(`campaigns:${tenantId}`, async () => {
3298
+ const { data: data2, error } = await getClient().from(V.campaigns).select("*").eq("tenant_id", tenantId).order("created_at", { ascending: false });
3299
+ if (error) throw error;
3300
+ return data2 ?? [];
3301
+ });
3302
+ const [data, rows] = await Promise.all([
3303
+ fetchRows(),
3304
+ fetchAttribution(tenantId, windowFor(query.range).since)
3305
+ ]);
3306
+ const list = query.channelId ? data.filter((r) => r.channel_key === query.channelId) : data;
3307
+ return list.map((row) => withDerived(campaignFromRow(row), rows));
3308
+ },
3309
+ async getCampaign(id) {
3310
+ const tenantId = requireTenantId();
3311
+ const { data, error } = await getClient().from(V.campaigns).select("*").eq("tenant_id", tenantId).eq("id", id).maybeSingle();
3312
+ if (error) throw error;
3313
+ if (!data) return null;
3314
+ const campaign = campaignFromRow(data);
3315
+ const rows = await fetchAttribution(tenantId, new Date(campaign.start));
3316
+ return withDerived(campaign, rows);
3317
+ },
3318
+ async saveCampaign(input) {
3319
+ const tenantId = requireTenantId();
3320
+ const payload = {
3321
+ name: input.name,
3322
+ channel_key: input.channelId,
3323
+ status: input.status,
3324
+ starts_at: input.start || null,
3325
+ ends_at: input.end || null,
3326
+ spend: input.spend ?? 0
3327
+ };
3328
+ if (input.id) {
3329
+ const { data: data2, error: error2 } = await getClient().from(T.campaigns).update(payload).eq("id", input.id).select("*").single();
3330
+ if (error2) throw error2;
3331
+ cache.clear();
3332
+ return campaignFromRow(data2);
3333
+ }
3334
+ const { data, error } = await getClient().from(T.campaigns).insert({ tenant_id: tenantId, ...payload }).select("*").single();
3335
+ if (error) throw error;
3336
+ cache.clear();
3337
+ return campaignFromRow(data);
3338
+ },
3339
+ async deleteCampaign(id) {
3340
+ const { error } = await getClient().from(T.campaigns).delete().eq("id", id);
3341
+ if (error) throw error;
3342
+ cache.clear();
3343
+ },
3344
+ invalidateCache() {
3345
+ cache.clear();
3346
+ },
3347
+ async landingPages(query) {
3348
+ if (sitesBridge) return sitesBridge.landingPages(query);
3349
+ return [];
3350
+ }
3351
+ };
3352
+ }
3038
3353
 
3039
3354
  // src/data/contentMock.ts
3040
3355
  var counter = 0;
@@ -3282,21 +3597,12 @@ function createMockContentPlannerProvider() {
3282
3597
  }
3283
3598
  };
3284
3599
  }
3285
-
3286
- // src/data/tables.ts
3287
- var T = {
3288
- socialAccounts: "plg_marketing_social_accounts",
3289
- contentPlans: "plg_marketing_content_plans",
3290
- contentPosts: "plg_marketing_content_posts"
3291
- };
3292
-
3293
- // src/data/contentSupabase.ts
3294
- function getClient() {
3600
+ function getClient2() {
3295
3601
  const supabase = getSupabaseClientOptional();
3296
3602
  if (!supabase) throw new Error("Supabase not initialized");
3297
3603
  return supabase;
3298
3604
  }
3299
- function requireTenantId() {
3605
+ function requireTenantId2() {
3300
3606
  const tenantId = getActiveTenantId();
3301
3607
  if (!tenantId) throw new Error("No active tenant");
3302
3608
  return tenantId;
@@ -3332,7 +3638,7 @@ function createSupabaseContentPlannerProvider() {
3332
3638
  async listAccounts() {
3333
3639
  const tenantId = getActiveTenantId();
3334
3640
  if (!tenantId) return [];
3335
- const { data } = await getClient().from(T.socialAccounts).select("*").eq("tenant_id", tenantId).eq("is_active", true).order("created_at", { ascending: true });
3641
+ const { data } = await getClient2().from(T.socialAccounts).select("*").eq("tenant_id", tenantId).eq("is_active", true).order("created_at", { ascending: true });
3336
3642
  return (data ?? []).map(toAccount);
3337
3643
  },
3338
3644
  async saveAccount(input) {
@@ -3342,12 +3648,12 @@ function createSupabaseContentPlannerProvider() {
3342
3648
  if (input.handle !== void 0) payload.handle = input.handle;
3343
3649
  if (input.platforms !== void 0) payload.platforms = input.platforms;
3344
3650
  if (input.isActive !== void 0) payload.is_active = input.isActive;
3345
- const { data: data2, error: error2 } = await getClient().from(T.socialAccounts).update(payload).eq("id", input.id).select("*").single();
3651
+ const { data: data2, error: error2 } = await getClient2().from(T.socialAccounts).update(payload).eq("id", input.id).select("*").single();
3346
3652
  if (error2) throw error2;
3347
3653
  return toAccount(data2);
3348
3654
  }
3349
- const { data, error } = await getClient().from(T.socialAccounts).insert({
3350
- tenant_id: requireTenantId(),
3655
+ const { data, error } = await getClient2().from(T.socialAccounts).insert({
3656
+ tenant_id: requireTenantId2(),
3351
3657
  name: input.name,
3352
3658
  handle: input.handle ?? null,
3353
3659
  platforms: input.platforms ?? ["instagram"],
@@ -3357,17 +3663,17 @@ function createSupabaseContentPlannerProvider() {
3357
3663
  return toAccount(data);
3358
3664
  },
3359
3665
  async deleteAccount(id) {
3360
- const { error } = await getClient().from(T.socialAccounts).delete().eq("id", id);
3666
+ const { error } = await getClient2().from(T.socialAccounts).delete().eq("id", id);
3361
3667
  if (error) throw error;
3362
3668
  },
3363
3669
  async listPlans(accountId) {
3364
3670
  const tenantId = getActiveTenantId();
3365
3671
  if (!tenantId) return [];
3366
- const { data } = await getClient().from(T.contentPlans).select("*").eq("tenant_id", tenantId).eq("account_id", accountId).order("created_at", { ascending: false });
3672
+ const { data } = await getClient2().from(T.contentPlans).select("*").eq("tenant_id", tenantId).eq("account_id", accountId).order("created_at", { ascending: false });
3367
3673
  return (data ?? []).map(toPlan);
3368
3674
  },
3369
3675
  async getPlan(id) {
3370
- const { data } = await getClient().from(T.contentPlans).select("*").eq("id", id).maybeSingle();
3676
+ const { data } = await getClient2().from(T.contentPlans).select("*").eq("id", id).maybeSingle();
3371
3677
  return data ? toPlan(data) : null;
3372
3678
  },
3373
3679
  async savePlan(input) {
@@ -3383,13 +3689,13 @@ function createSupabaseContentPlannerProvider() {
3383
3689
  if (input.formats !== void 0) payload.formats = input.formats;
3384
3690
  if (input.weeklyFrequency !== void 0) payload.weekly_frequency = input.weeklyFrequency;
3385
3691
  if (input.briefMd !== void 0) payload.brief_md = input.briefMd;
3386
- const { data: data2, error: error2 } = await getClient().from(T.contentPlans).update(payload).eq("id", input.id).select("*").single();
3692
+ const { data: data2, error: error2 } = await getClient2().from(T.contentPlans).update(payload).eq("id", input.id).select("*").single();
3387
3693
  if (error2) throw error2;
3388
3694
  return toPlan(data2);
3389
3695
  }
3390
3696
  if (!input.accountId) throw new Error("accountId is required");
3391
- const { data, error } = await getClient().from(T.contentPlans).insert({
3392
- tenant_id: requireTenantId(),
3697
+ const { data, error } = await getClient2().from(T.contentPlans).insert({
3698
+ tenant_id: requireTenantId2(),
3393
3699
  account_id: input.accountId,
3394
3700
  name: input.name ?? "Novo plano",
3395
3701
  status: input.status ?? "draft",
@@ -3406,14 +3712,14 @@ function createSupabaseContentPlannerProvider() {
3406
3712
  return toPlan(data);
3407
3713
  },
3408
3714
  async deletePlan(id) {
3409
- await getClient().from(T.contentPlans).delete().eq("id", id);
3715
+ await getClient2().from(T.contentPlans).delete().eq("id", id);
3410
3716
  },
3411
3717
  async listPosts(planId) {
3412
- const { data } = await getClient().from(T.contentPosts).select("*").eq("plan_id", planId).order("week_number", { ascending: true }).order("position", { ascending: true });
3718
+ const { data } = await getClient2().from(T.contentPosts).select("*").eq("plan_id", planId).order("week_number", { ascending: true }).order("position", { ascending: true });
3413
3719
  return (data ?? []).map(toPost);
3414
3720
  },
3415
3721
  async getPost(id) {
3416
- const { data } = await getClient().from(T.contentPosts).select("*").eq("id", id).maybeSingle();
3722
+ const { data } = await getClient2().from(T.contentPosts).select("*").eq("id", id).maybeSingle();
3417
3723
  return data ? toPost(data) : null;
3418
3724
  },
3419
3725
  async savePost(input) {
@@ -3431,16 +3737,16 @@ function createSupabaseContentPlannerProvider() {
3431
3737
  if (input.hook !== void 0) payload.hook = input.hook;
3432
3738
  if (input.cta !== void 0) payload.cta = input.cta;
3433
3739
  if (input.contentMd !== void 0) payload.content_md = input.contentMd;
3434
- const { data: data2, error: error2 } = await getClient().from(T.contentPosts).update(payload).eq("id", input.id).select("*").single();
3740
+ const { data: data2, error: error2 } = await getClient2().from(T.contentPosts).update(payload).eq("id", input.id).select("*").single();
3435
3741
  if (error2) throw error2;
3436
3742
  return toPost(data2);
3437
3743
  }
3438
3744
  if (!input.planId) throw new Error("planId is required");
3439
3745
  const week = input.weekNumber ?? 1;
3440
- 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);
3746
+ 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);
3441
3747
  const maxPos = maxData?.[0]?.position ?? -1;
3442
- const { data, error } = await getClient().from(T.contentPosts).insert({
3443
- tenant_id: requireTenantId(),
3748
+ const { data, error } = await getClient2().from(T.contentPosts).insert({
3749
+ tenant_id: requireTenantId2(),
3444
3750
  plan_id: input.planId,
3445
3751
  week_number: week,
3446
3752
  position: input.position ?? maxPos + 1,
@@ -3459,7 +3765,7 @@ function createSupabaseContentPlannerProvider() {
3459
3765
  return toPost(data);
3460
3766
  },
3461
3767
  async deletePost(id) {
3462
- await getClient().from(T.contentPosts).delete().eq("id", id);
3768
+ await getClient2().from(T.contentPosts).delete().eq("id", id);
3463
3769
  }
3464
3770
  };
3465
3771
  }
@@ -3475,14 +3781,23 @@ function createMarketingStore(provider2) {
3475
3781
  async load() {
3476
3782
  const range = get().range;
3477
3783
  set({ loading: true });
3478
- const [overview, channels, campaigns, funnel, landingPages] = await Promise.all([
3479
- provider2.overview({ range }),
3480
- provider2.channelPerformance({ range }),
3481
- provider2.listCampaigns({ range }),
3482
- provider2.funnel({ range }),
3483
- provider2.landingPages({ range })
3484
- ]);
3485
- set({ overview, channels, campaigns, funnel, landingPages, loading: false });
3784
+ try {
3785
+ const [overview, channels, campaigns, funnel, landingPages] = await Promise.all([
3786
+ provider2.overview({ range }),
3787
+ provider2.channelPerformance({ range }),
3788
+ provider2.listCampaigns({ range }),
3789
+ provider2.funnel({ range }),
3790
+ provider2.landingPages({ range })
3791
+ ]);
3792
+ set({ overview, channels, campaigns, funnel, landingPages, loading: false });
3793
+ } catch (err) {
3794
+ set({ loading: false });
3795
+ throw err;
3796
+ }
3797
+ },
3798
+ async refresh() {
3799
+ provider2.invalidateCache?.();
3800
+ await get().load();
3486
3801
  },
3487
3802
  async setRange(range) {
3488
3803
  set({ range });
@@ -3824,14 +4139,264 @@ CREATE POLICY plg_marketing_ranklayer_sync_log_select ON public.plg_marketing_ra
3824
4139
  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()));
3825
4140
  GRANT SELECT, INSERT ON public.plg_marketing_ranklayer_sync_log TO authenticated;
3826
4141
  `;
4142
+ var MIGRATION_005_ANALYTICS_BASE = `-- Marketing Plugin: analytics base tables (channels + campaigns).
4143
+ -- Prefix: plg_marketing_
4144
+ -- Channels are tenant-owned rows lazily seeded from the app's domain preset
4145
+ -- (channel_key = preset/config id). Campaign performance is DERIVED at read
4146
+ -- time from v_marketing_attribution (006); only spend is stored.
4147
+ -- Canonical RLS form: tenant_id IN (SELECT public.user_tenant_ids()).
4148
+
4149
+ CREATE TABLE IF NOT EXISTS public.plg_marketing_channels (
4150
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
4151
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
4152
+ channel_key text NOT NULL,
4153
+ label text NOT NULL,
4154
+ icon text,
4155
+ kind text NOT NULL DEFAULT 'organic'
4156
+ CHECK (kind IN ('paid', 'organic', 'social', 'referral', 'direct', 'outbound')),
4157
+ is_active boolean NOT NULL DEFAULT true,
4158
+ monthly_spend numeric NOT NULL DEFAULT 0,
4159
+ created_at timestamptz NOT NULL DEFAULT now(),
4160
+ updated_at timestamptz NOT NULL DEFAULT now()
4161
+ );
4162
+ CREATE INDEX IF NOT EXISTS idx_plg_marketing_channels_tenant ON public.plg_marketing_channels(tenant_id);
4163
+ CREATE UNIQUE INDEX IF NOT EXISTS u_plg_marketing_channels_tenant_key ON public.plg_marketing_channels(tenant_id, channel_key);
4164
+
4165
+ CREATE TABLE IF NOT EXISTS public.plg_marketing_campaigns (
4166
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
4167
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
4168
+ name text NOT NULL,
4169
+ channel_key text NOT NULL,
4170
+ status text NOT NULL DEFAULT 'draft'
4171
+ CHECK (status IN ('active', 'paused', 'ended', 'draft')),
4172
+ starts_at timestamptz,
4173
+ ends_at timestamptz,
4174
+ spend numeric NOT NULL DEFAULT 0,
4175
+ created_at timestamptz NOT NULL DEFAULT now(),
4176
+ updated_at timestamptz NOT NULL DEFAULT now()
4177
+ );
4178
+ CREATE INDEX IF NOT EXISTS idx_plg_marketing_campaigns_tenant ON public.plg_marketing_campaigns(tenant_id);
4179
+ CREATE INDEX IF NOT EXISTS idx_plg_marketing_campaigns_channel ON public.plg_marketing_campaigns(tenant_id, channel_key);
4180
+
4181
+ DO $$
4182
+ DECLARE t text;
4183
+ BEGIN
4184
+ FOR t IN SELECT unnest(ARRAY['plg_marketing_channels','plg_marketing_campaigns'])
4185
+ LOOP
4186
+ EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', t);
4187
+ EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON public.%I TO authenticated', t);
4188
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_select') THEN
4189
+ EXECUTE format('CREATE POLICY %I ON public.%I FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_select', t);
4190
+ END IF;
4191
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_insert') THEN
4192
+ 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);
4193
+ END IF;
4194
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_update') THEN
4195
+ EXECUTE format('CREATE POLICY %I ON public.%I FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_update', t);
4196
+ END IF;
4197
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_delete') THEN
4198
+ EXECUTE format('CREATE POLICY %I ON public.%I FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_delete', t);
4199
+ END IF;
4200
+ END LOOP;
4201
+ END $$;
4202
+ `;
4203
+ var MIGRATION_006_ANALYTICS_VIEWS = `-- Marketing Plugin: analytics read views.
4204
+ -- v_marketing_channels / v_marketing_campaigns \u2014 bridge views for the surface
4205
+ -- and the agent's data primitives.
4206
+ -- v_marketing_attribution \u2014 GENERIC, spine-only attribution event stream
4207
+ -- (people/appointments/orders metadata). \`channel_raw\` is the unnormalized
4208
+ -- origin string; the provider matches it against the app's channel set
4209
+ -- (id or label, case/punctuation-insensitive).
4210
+ -- Apps whose origin lives in an app-local extension table (e.g. beauty's
4211
+ -- clients.origin) may CREATE OR REPLACE this view in their own incubator
4212
+ -- migration, KEEPING the column contract:
4213
+ -- (tenant_id uuid, kind text, source text, channel_raw text,
4214
+ -- occurred_at timestamptz, value numeric)
4215
+
4216
+ CREATE OR REPLACE VIEW public.v_marketing_channels WITH (security_invoker=true) AS
4217
+ SELECT c.id, c.tenant_id, c.channel_key, c.label, c.icon, c.kind,
4218
+ c.is_active, c.monthly_spend, c.created_at, c.updated_at
4219
+ FROM public.plg_marketing_channels c;
4220
+ GRANT SELECT ON public.v_marketing_channels TO authenticated;
4221
+
4222
+ CREATE OR REPLACE VIEW public.v_marketing_campaigns WITH (security_invoker=true) AS
4223
+ SELECT g.id, g.tenant_id, g.name, g.channel_key, ch.label AS channel_label,
4224
+ g.status, g.starts_at, g.ends_at, g.spend, g.created_at, g.updated_at
4225
+ FROM public.plg_marketing_campaigns g
4226
+ LEFT JOIN public.plg_marketing_channels ch
4227
+ ON ch.tenant_id = g.tenant_id AND ch.channel_key = g.channel_key;
4228
+ GRANT SELECT ON public.v_marketing_campaigns TO authenticated;
4229
+
4230
+ CREATE OR REPLACE VIEW public.v_marketing_attribution WITH (security_invoker=true) AS
4231
+ -- lead events (CRM leads; sourceId aligns with lead-sources/channel ids)
4232
+ SELECT p.tenant_id,
4233
+ 'lead'::text AS kind,
4234
+ 'crm'::text AS source,
4235
+ COALESCE(p.metadata->>'sourceId', p.metadata->>'sourceName', p.metadata->>'origin') AS channel_raw,
4236
+ p.created_at AS occurred_at,
4237
+ NULLIF(p.metadata->>'value', '')::numeric AS value
4238
+ FROM public.people p
4239
+ WHERE p.kind = 'lead'
4240
+ UNION ALL
4241
+ -- booking conversions (agenda verticals)
4242
+ SELECT b.tenant_id, 'conversion', 'agenda',
4243
+ COALESCE(b.metadata->>'origin', pc.metadata->>'origin', pc.metadata->>'sourceId', pc.metadata->>'sourceName'),
4244
+ b.starts_at,
4245
+ o.total
4246
+ FROM public.appointments b
4247
+ LEFT JOIN public.people pc ON pc.id = b.party_id
4248
+ LEFT JOIN public.orders o ON o.id = b.order_id
4249
+ WHERE b.status NOT IN ('cancelled', 'no_show')
4250
+ UNION ALL
4251
+ -- order conversions (commerce verticals)
4252
+ SELECT o.tenant_id, 'conversion', 'orders',
4253
+ COALESCE(o.metadata->>'origin', pp.metadata->>'origin', pp.metadata->>'sourceId'),
4254
+ o.created_at,
4255
+ o.total
4256
+ FROM public.orders o
4257
+ LEFT JOIN public.people pp ON pp.id = o.party_id
4258
+ WHERE o.kind NOT IN ('appointment', 'deal')
4259
+ AND o.status NOT IN ('cancelled', 'draft')
4260
+ UNION ALL
4261
+ -- won-deal conversions (CRM verticals)
4262
+ SELECT o.tenant_id, 'conversion', 'crm',
4263
+ COALESCE(pp.metadata->>'sourceId', pp.metadata->>'sourceName', o.metadata->>'origin'),
4264
+ o.updated_at,
4265
+ o.total
4266
+ FROM public.orders o
4267
+ LEFT JOIN public.people pp ON pp.id = o.party_id
4268
+ WHERE o.kind = 'deal' AND o.status = 'won';
4269
+
4270
+ GRANT SELECT ON public.v_marketing_attribution TO authenticated;
4271
+ `;
4272
+ var MIGRATION_007_AGENT_RPCS = `-- ============================================================================
4273
+ -- plugin-marketing 007: server-plane agent write RPC.
4274
+ --
4275
+ -- public.agent_marketing_create_campaign \u2014 guarded campaign create for the
4276
+ -- assistant. Mirrors agent_agenda_create_appointment (agenda 005):
4277
+ -- * actor-authorized: public.agent_guard (spine 015) runs role\u2192plan\u2192limit
4278
+ -- BEFORE anything \u2014 denial comes back as structured jsonb;
4279
+ -- * channel is validated against the tenant's plg_marketing_channels rows
4280
+ -- (key or label, case/punctuation-insensitive); when the tenant has no
4281
+ -- channel rows yet (surface not opened \u2192 lazy seed not run) the raw key is
4282
+ -- accepted as-is;
4283
+ -- * audited: audit_logs row with the acting user.
4284
+ --
4285
+ -- Contract (all agent_* RPCs): (p_tenant_id, p_actor_user_id, p_payload jsonb)
4286
+ -- \u2192 jsonb {ok:true, id, record:{...}} | {ok:false, denial:{...}} | {ok:false,
4287
+ -- error text}. GRANT authenticated+service_role \u2014 NEVER anon.
4288
+ -- ============================================================================
4289
+
4290
+ CREATE OR REPLACE FUNCTION public.agent_marketing_create_campaign(
4291
+ p_tenant_id uuid,
4292
+ p_actor_user_id uuid,
4293
+ p_payload jsonb
4294
+ ) RETURNS jsonb
4295
+ LANGUAGE plpgsql SECURITY DEFINER
4296
+ SET search_path = public
4297
+ AS $$
4298
+ DECLARE
4299
+ v_denial jsonb;
4300
+ v_used int;
4301
+ v_name text;
4302
+ v_channel_raw text;
4303
+ v_channel_key text;
4304
+ v_status text;
4305
+ v_starts timestamptz;
4306
+ v_ends timestamptz;
4307
+ v_spend numeric;
4308
+ v_has_channels boolean;
4309
+ v_id uuid;
4310
+ BEGIN
4311
+ -- \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
4312
+ v_name := left(trim(p_payload->>'name'), 200);
4313
+ v_channel_raw := trim(p_payload->>'channel_key');
4314
+ v_status := COALESCE(NULLIF(trim(p_payload->>'status'), ''), 'draft');
4315
+ v_spend := COALESCE(NULLIF(p_payload->>'spend', '')::numeric, 0);
4316
+ BEGIN
4317
+ v_starts := NULLIF(p_payload->>'starts_at', '')::timestamptz;
4318
+ v_ends := NULLIF(p_payload->>'ends_at', '')::timestamptz;
4319
+ EXCEPTION WHEN OTHERS THEN
4320
+ RETURN jsonb_build_object('ok', false, 'error', 'invalid payload: ' || SQLERRM);
4321
+ END;
4322
+ IF v_name IS NULL OR v_name = '' OR v_channel_raw IS NULL OR v_channel_raw = '' THEN
4323
+ RETURN jsonb_build_object('ok', false, 'error', 'name and channel_key are required');
4324
+ END IF;
4325
+ IF v_status NOT IN ('active', 'paused', 'ended', 'draft') THEN
4326
+ RETURN jsonb_build_object('ok', false, 'error', 'status must be one of active|paused|ended|draft');
4327
+ END IF;
4328
+
4329
+ -- \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
4330
+ SELECT count(*) INTO v_used FROM plg_marketing_campaigns c
4331
+ WHERE c.tenant_id = p_tenant_id AND c.status = 'active';
4332
+ v_denial := agent_guard(p_tenant_id, p_actor_user_id,
4333
+ 'marketing', 'create', 'campaigns_active', v_used,
4334
+ CASE WHEN v_status = 'active' THEN 1 ELSE 0 END);
4335
+ IF v_denial IS NOT NULL THEN
4336
+ RETURN jsonb_build_object('ok', false, 'denial', v_denial);
4337
+ END IF;
4338
+
4339
+ -- \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
4340
+ SELECT EXISTS (SELECT 1 FROM plg_marketing_channels ch WHERE ch.tenant_id = p_tenant_id)
4341
+ INTO v_has_channels;
4342
+ IF v_has_channels THEN
4343
+ SELECT ch.channel_key INTO v_channel_key
4344
+ FROM plg_marketing_channels ch
4345
+ WHERE ch.tenant_id = p_tenant_id
4346
+ AND (
4347
+ regexp_replace(lower(ch.channel_key), '[^a-z0-9]', '', 'g')
4348
+ = regexp_replace(lower(v_channel_raw), '[^a-z0-9]', '', 'g')
4349
+ OR regexp_replace(lower(ch.label), '[^a-z0-9]', '', 'g')
4350
+ = regexp_replace(lower(v_channel_raw), '[^a-z0-9]', '', 'g')
4351
+ )
4352
+ LIMIT 1;
4353
+ IF v_channel_key IS NULL THEN
4354
+ RETURN jsonb_build_object('ok', false, 'error',
4355
+ 'unknown channel "' || v_channel_raw || '" \u2014 valid channels: ' ||
4356
+ COALESCE((SELECT string_agg(ch.channel_key, ', ' ORDER BY ch.channel_key)
4357
+ FROM plg_marketing_channels ch WHERE ch.tenant_id = p_tenant_id), ''));
4358
+ END IF;
4359
+ ELSE
4360
+ v_channel_key := v_channel_raw;
4361
+ END IF;
4362
+
4363
+ -- \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
4364
+ INSERT INTO plg_marketing_campaigns (tenant_id, name, channel_key, status, starts_at, ends_at, spend)
4365
+ VALUES (p_tenant_id, v_name, v_channel_key, v_status, COALESCE(v_starts, now()), v_ends, v_spend)
4366
+ RETURNING id INTO v_id;
4367
+
4368
+ INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)
4369
+ VALUES (p_tenant_id, p_actor_user_id, 'agent.createCampaign', 'marketing_campaign', v_id::text,
4370
+ jsonb_build_object('payload', p_payload));
4371
+
4372
+ RETURN jsonb_build_object(
4373
+ 'ok', true,
4374
+ 'id', v_id,
4375
+ 'record', jsonb_build_object(
4376
+ 'id', v_id,
4377
+ 'name', v_name,
4378
+ 'channel_key', v_channel_key,
4379
+ 'status', v_status,
4380
+ 'starts_at', COALESCE(v_starts, now()),
4381
+ 'spend', v_spend
4382
+ )
4383
+ );
4384
+ END;
4385
+ $$;
4386
+
4387
+ REVOKE ALL ON FUNCTION public.agent_marketing_create_campaign(uuid, uuid, jsonb) FROM public;
4388
+ REVOKE EXECUTE ON FUNCTION public.agent_marketing_create_campaign(uuid, uuid, jsonb) FROM anon;
4389
+ GRANT EXECUTE ON FUNCTION public.agent_marketing_create_campaign(uuid, uuid, jsonb)
4390
+ TO authenticated, service_role;
4391
+ `;
3827
4392
  var INTEGRATIONS = "plg_marketing_ranklayer_integrations";
3828
4393
  var SYNC_LOG = "plg_marketing_ranklayer_sync_log";
3829
- function getClient2() {
4394
+ function getClient3() {
3830
4395
  const supabase = getSupabaseClientOptional();
3831
4396
  if (!supabase) throw new Error("[ranklayer] Supabase not initialized");
3832
4397
  return supabase;
3833
4398
  }
3834
- function requireTenantId2() {
4399
+ function requireTenantId3() {
3835
4400
  const tenantId = getActiveTenantId();
3836
4401
  if (!tenantId) throw new Error("[ranklayer] No active tenant");
3837
4402
  return tenantId;
@@ -3850,11 +4415,11 @@ function createRankLayerProvider() {
3850
4415
  async getIntegration() {
3851
4416
  const tenantId = getActiveTenantId();
3852
4417
  if (!tenantId) return null;
3853
- const { data } = await getClient2().from(INTEGRATIONS).select("*").eq("tenant_id", tenantId).maybeSingle();
4418
+ const { data } = await getClient3().from(INTEGRATIONS).select("*").eq("tenant_id", tenantId).maybeSingle();
3854
4419
  return data ? toIntegration(data) : null;
3855
4420
  },
3856
4421
  async saveIntegration(values) {
3857
- const tenantId = requireTenantId2();
4422
+ const tenantId = requireTenantId3();
3858
4423
  const payload = {
3859
4424
  tenant_id: tenantId,
3860
4425
  api_key: values.apiKey,
@@ -3862,14 +4427,14 @@ function createRankLayerProvider() {
3862
4427
  active: true,
3863
4428
  updated_at: (/* @__PURE__ */ new Date()).toISOString()
3864
4429
  };
3865
- const { data, error } = await getClient2().from(INTEGRATIONS).upsert(payload, { onConflict: "tenant_id" }).select("*").single();
4430
+ const { data, error } = await getClient3().from(INTEGRATIONS).upsert(payload, { onConflict: "tenant_id" }).select("*").single();
3866
4431
  if (error) throw error;
3867
4432
  return toIntegration(data);
3868
4433
  },
3869
4434
  async disconnect() {
3870
4435
  const tenantId = getActiveTenantId();
3871
4436
  if (!tenantId) return;
3872
- await getClient2().from(INTEGRATIONS).delete().eq("tenant_id", tenantId);
4437
+ await getClient3().from(INTEGRATIONS).delete().eq("tenant_id", tenantId);
3873
4438
  },
3874
4439
  // SCAFFOLD: no RankLayer API yet — accept any non-empty key so the connect
3875
4440
  // flow can be exercised. Replace with a real credential check in the PR.
@@ -3880,7 +4445,7 @@ function createRankLayerProvider() {
3880
4445
  return { ok: true, message: "Chave aceita (valida\xE7\xE3o real pendente \u2014 integra\xE7\xE3o em prepara\xE7\xE3o)." };
3881
4446
  },
3882
4447
  async getSyncLog(integrationId) {
3883
- const { data } = await getClient2().from(SYNC_LOG).select("*").eq("integration_id", integrationId).order("created_at", { ascending: false }).limit(20);
4448
+ const { data } = await getClient3().from(SYNC_LOG).select("*").eq("integration_id", integrationId).order("created_at", { ascending: false }).limit(20);
3884
4449
  return (data ?? []).map((r) => ({
3885
4450
  id: r.id,
3886
4451
  createdAt: r.created_at,
@@ -4346,14 +4911,21 @@ function resolveConfig(options) {
4346
4911
  };
4347
4912
  return { config, domain };
4348
4913
  }
4349
- function createSafeProvider(config, domain) {
4350
- void getSupabaseClientOptional();
4351
- return createMockMarketingProvider({ channels: config.channels, conversion: config.conversion, domain });
4914
+ function createSafeProvider(config, domain, options) {
4915
+ return createSafeDataProvider(
4916
+ () => createSupabaseMarketingProvider({
4917
+ channels: config.channels,
4918
+ conversion: config.conversion,
4919
+ attributionBridge: options?.attributionBridge,
4920
+ sitesBridge: options?.sitesBridge
4921
+ }),
4922
+ () => createMockMarketingProvider({ channels: config.channels, conversion: config.conversion, domain })
4923
+ );
4352
4924
  }
4353
4925
  function createMarketingPlugin(options) {
4354
4926
  registerTranslations(marketingLocales);
4355
4927
  const { config, domain } = resolveConfig(options);
4356
- const provider2 = options?.dataProvider ?? createSafeProvider(config, domain);
4928
+ const provider2 = options?.dataProvider ?? createSafeProvider(config, domain, options);
4357
4929
  const store = createMarketingStore(provider2);
4358
4930
  const dashboardWidgets = createMarketingDashboardWidgets({ config, provider: provider2, store });
4359
4931
  const contentProvider = options?.contentPlanner?.dataProvider ?? createSafeDataProvider(
@@ -4403,6 +4975,94 @@ function createMarketingPlugin(options) {
4403
4975
  ...config.modules.contentPlanner ? [{ id: "marketing.content", label: config.labels.content, group: "Engage" }] : [],
4404
4976
  ...config.modules.blog ? [{ id: "marketing.blog", label: config.labels.blog, group: "Engage" }] : []
4405
4977
  ],
4978
+ declaredLimits: [
4979
+ ...config.modules.campaigns ? [{ key: "campaigns_active", label: "Active campaigns", table: T.campaigns }] : [],
4980
+ ...config.modules.contentPlanner ? [{ key: "content_posts_month", label: "Content posts / month", table: "plg_marketing_content_posts", period: "month" }] : [],
4981
+ ...config.modules.blog ? [{ key: "blog_posts", label: "Blog posts", table: "plg_blog_posts" }] : []
4982
+ ],
4983
+ // Read-models for the agent's data primitives (searchRecords/queryData) —
4984
+ // mirrors agenda's `agenda:appointments`. Keys are stable ASCII; fields are
4985
+ // the provider's camelCase output shape.
4986
+ queryEntities: [
4987
+ ...config.modules.campaigns ? [{
4988
+ key: "marketing:campaigns",
4989
+ writable: false,
4990
+ entity: {
4991
+ name: "Campaign",
4992
+ namePlural: "Campaigns",
4993
+ icon: "Megaphone",
4994
+ permission: { feature: "marketing", action: "read" },
4995
+ fields: [
4996
+ { key: "name", label: "Name", type: "text", searchable: true },
4997
+ { key: "channelKey", label: "Channel", type: "text", searchable: true },
4998
+ { key: "channelLabel", label: "Channel label", type: "text" },
4999
+ { key: "status", label: "Status", type: "text" },
5000
+ { key: "startsAt", label: "Starts at", type: "text" },
5001
+ { key: "endsAt", label: "Ends at", type: "text" },
5002
+ { key: "spend", label: "Spend", type: "number" },
5003
+ { key: "createdAt", label: "Created at", type: "text" }
5004
+ ],
5005
+ data: {
5006
+ table: "v_marketing_campaigns",
5007
+ tenantScoped: true,
5008
+ searchColumns: ["name", "channel_key"]
5009
+ }
5010
+ }
5011
+ }] : [],
5012
+ ...config.modules.channels ? [{
5013
+ key: "marketing:channels",
5014
+ writable: false,
5015
+ entity: {
5016
+ name: "Marketing channel",
5017
+ namePlural: "Marketing channels",
5018
+ icon: "Radio",
5019
+ permission: { feature: "marketing", action: "read" },
5020
+ fields: [
5021
+ { key: "channelKey", label: "Key", type: "text", searchable: true },
5022
+ { key: "label", label: "Channel", type: "text", searchable: true },
5023
+ { key: "kind", label: "Type", type: "text" },
5024
+ { key: "isActive", label: "Active", type: "boolean" },
5025
+ { key: "monthlySpend", label: "Monthly spend", type: "number" }
5026
+ ],
5027
+ data: {
5028
+ table: "v_marketing_channels",
5029
+ tenantScoped: true,
5030
+ searchColumns: ["label", "channel_key"]
5031
+ }
5032
+ }
5033
+ }] : [],
5034
+ ...config.modules.contentPlanner ? [{
5035
+ key: "marketing:contentPosts",
5036
+ writable: false,
5037
+ entity: {
5038
+ name: "Content post",
5039
+ namePlural: "Content posts",
5040
+ icon: "FileText",
5041
+ permission: { feature: "marketing.content", action: "read" },
5042
+ fields: [
5043
+ { key: "title", label: "Title", type: "text", searchable: true },
5044
+ { key: "format", label: "Format", type: "text" },
5045
+ { key: "status", label: "Status", type: "text" },
5046
+ { key: "weekNumber", label: "Week", type: "number" },
5047
+ { key: "scheduledDate", label: "Scheduled date", type: "text" },
5048
+ { key: "hook", label: "Hook", type: "text", searchable: true }
5049
+ ],
5050
+ data: {
5051
+ table: T.contentPosts,
5052
+ tenantScoped: true,
5053
+ searchColumns: ["title", "hook"]
5054
+ }
5055
+ }
5056
+ }] : []
5057
+ ],
5058
+ declaredRpcs: [
5059
+ ...config.modules.campaigns ? [{
5060
+ name: "agent_marketing_create_campaign",
5061
+ kind: "write",
5062
+ description: "Guarded campaign create: agent_guard (role\u2192plan\u2192campaigns_active cap), channel validated against the tenant channel set, audited.",
5063
+ audits: true
5064
+ }] : []
5065
+ ],
4406
5066
  navigation: [
4407
5067
  {
4408
5068
  section: options?.navSection ?? "main",
@@ -4461,6 +5121,24 @@ function createMarketingPlugin(options) {
4461
5121
  version: "1.3.0",
4462
5122
  sql: MIGRATION_004_RANKLAYER,
4463
5123
  description: "RankLayer connector state: plg_marketing_ranklayer_integrations + _sync_log (tenant RLS)"
5124
+ },
5125
+ {
5126
+ id: "marketing-005-analytics-base",
5127
+ version: "1.4.0",
5128
+ sql: MIGRATION_005_ANALYTICS_BASE,
5129
+ description: "Analytics base: plg_marketing_channels + plg_marketing_campaigns with canonical tenant RLS"
5130
+ },
5131
+ {
5132
+ id: "marketing-006-analytics-views",
5133
+ version: "1.4.0",
5134
+ sql: MIGRATION_006_ANALYTICS_VIEWS,
5135
+ description: "Read views v_marketing_channels/_campaigns + generic spine-only v_marketing_attribution"
5136
+ },
5137
+ {
5138
+ id: "marketing-007-agent-rpcs",
5139
+ version: "1.4.0",
5140
+ sql: MIGRATION_007_AGENT_RPCS,
5141
+ description: "agent_marketing_create_campaign \u2014 guarded, audited server-plane campaign create"
4464
5142
  }
4465
5143
  ],
4466
5144
  // RankLayer SEO connector — renders in the Integrações settings tab. Scaffold
@@ -4506,18 +5184,25 @@ function createMarketingPlugin(options) {
4506
5184
  {
4507
5185
  id: "marketing.create-campaign",
4508
5186
  name: "createCampaign",
4509
- description: "Creates an acquisition campaign on a channel.",
5187
+ 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.",
4510
5188
  icon: "Plus",
4511
5189
  mode: "persist",
5190
+ limitKey: "campaigns_active",
5191
+ execution: { plane: "server", kind: "rpc", rpc: "agent_marketing_create_campaign" },
4512
5192
  category: "Marketing",
4513
5193
  parameters: {
4514
5194
  type: "object",
4515
5195
  properties: {
4516
5196
  name: { type: "string", description: "Campaign name" },
4517
- channelId: { type: "string", description: "Acquisition channel id" }
5197
+ channel_key: { type: "string", description: "Acquisition channel key (e.g. 'instagram') or its label" },
5198
+ status: { type: "string", enum: ["draft", "active", "paused"], description: "Defaults to draft" },
5199
+ starts_at: { type: "string", description: "ISO start date-time (defaults to now)" },
5200
+ ends_at: { type: "string", description: "ISO end date-time (optional)" },
5201
+ spend: { type: "number", description: "Planned/committed spend (optional)" }
4518
5202
  },
4519
- required: ["name", "channelId"]
5203
+ required: ["name", "channel_key"]
4520
5204
  },
5205
+ suggestions: [{ label: "Create an Instagram campaign for this month" }],
4521
5206
  permission: { feature: "marketing", action: "create" }
4522
5207
  }
4523
5208
  ],
@@ -4536,6 +5221,6 @@ function createMarketingPlugin(options) {
4536
5221
  };
4537
5222
  }
4538
5223
 
4539
- export { MARKETING_PRESETS, createMarketingPlugin, createMockContentPlannerProvider, createMockMarketingProvider, createSupabaseContentPlannerProvider };
5224
+ export { MARKETING_PRESETS, createMarketingPlugin, createMockContentPlannerProvider, createMockMarketingProvider, createSupabaseContentPlannerProvider, createSupabaseMarketingProvider };
4540
5225
  //# sourceMappingURL=index.js.map
4541
5226
  //# sourceMappingURL=index.js.map