@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.
- package/dist/MarketingPage.d.ts.map +1 -1
- package/dist/data/capability.test.d.ts +2 -0
- package/dist/data/capability.test.d.ts.map +1 -0
- package/dist/data/supabase.d.ts +10 -0
- package/dist/data/supabase.d.ts.map +1 -0
- package/dist/data/tables.d.ts +7 -0
- package/dist/data/tables.d.ts.map +1 -1
- package/dist/data/types.d.ts +2 -0
- package/dist/data/types.d.ts.map +1 -1
- package/dist/index.cjs +744 -58
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +746 -61
- package/dist/index.js.map +1 -1
- package/dist/migrations/index.d.ts +3 -0
- package/dist/migrations/index.d.ts.map +1 -1
- package/dist/registries.d.ts +3 -3
- package/dist/registries.d.ts.map +1 -1
- package/dist/schema/index.d.ts +354 -0
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema.cjs +30 -0
- package/dist/schema.cjs.map +1 -1
- package/dist/schema.js +30 -2
- package/dist/schema.js.map +1 -1
- package/dist/store.d.ts +2 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/views/CampaignsView.d.ts.map +1 -1
- package/dist/views/blog/BlogEntityForm.d.ts.map +1 -1
- package/dist/views/content/ContentView.d.ts.map +1 -1
- package/package.json +8 -6
- package/src/MarketingPage.tsx +5 -1
- package/src/data/capability.test.ts +92 -0
- package/src/data/supabase.ts +401 -0
- package/src/data/tables.ts +9 -0
- package/src/data/types.ts +2 -0
- package/src/index.ts +138 -10
- package/src/migrations/005_analytics_base.sql +60 -0
- package/src/migrations/006_analytics_views.sql +68 -0
- package/src/migrations/007_agent_rpcs.sql +119 -0
- package/src/migrations/index.ts +257 -1
- package/src/registries.ts +18 -6
- package/src/schema/index.ts +38 -1
- package/src/store.ts +21 -8
- package/src/views/CampaignsView.tsx +4 -1
- package/src/views/blog/BlogEntityForm.tsx +11 -2
- package/src/views/content/ContentView.tsx +8 -1
package/dist/index.cjs
CHANGED
|
@@ -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;
|
|
@@ -2352,6 +2357,8 @@ function BlogEntityForm({
|
|
|
2352
2357
|
}) {
|
|
2353
2358
|
const { getById, create, update, remove } = useEntityCrud(entity);
|
|
2354
2359
|
const isEdit = !!editId;
|
|
2360
|
+
const isPost = entity.data?.table === BLOG_POSTS_TABLE;
|
|
2361
|
+
const guardBlogPosts = saas.useLimitGuard("blog_posts");
|
|
2355
2362
|
const namePlural = entity.namePlural ?? entity.name + "s";
|
|
2356
2363
|
const [initialData, setInitialData] = React8__default.default.useState(isEdit ? null : {});
|
|
2357
2364
|
React8__default.default.useEffect(() => {
|
|
@@ -2366,9 +2373,13 @@ function BlogEntityForm({
|
|
|
2366
2373
|
};
|
|
2367
2374
|
}, [editId, getById]);
|
|
2368
2375
|
async function save(values) {
|
|
2376
|
+
if (!isEdit && isPost && await guardBlogPosts() === "blocked") return;
|
|
2369
2377
|
try {
|
|
2370
2378
|
if (isEdit) await update(editId, values);
|
|
2371
|
-
else
|
|
2379
|
+
else {
|
|
2380
|
+
await create(values);
|
|
2381
|
+
if (isPost) saas.invalidateLimit("blog_posts");
|
|
2382
|
+
}
|
|
2372
2383
|
ui.toast.success(isEdit ? `${entity.name} updated` : `${entity.name} created`);
|
|
2373
2384
|
onDone();
|
|
2374
2385
|
} catch (err) {
|
|
@@ -2474,6 +2485,9 @@ function MarketingPage({ config, provider: provider2, store, contentProvider, co
|
|
|
2474
2485
|
React8__default.default.useEffect(() => {
|
|
2475
2486
|
void store.getState().load();
|
|
2476
2487
|
}, [store]);
|
|
2488
|
+
core.useDataChanged({ table: "plg_marketing_campaigns" }, () => {
|
|
2489
|
+
void store.getState().refresh();
|
|
2490
|
+
}, [store]);
|
|
2477
2491
|
const isOverview = view === "overview";
|
|
2478
2492
|
const nav = buildNav(config, view, navigate);
|
|
2479
2493
|
const blogPostEntity = React8__default.default.useMemo(() => buildBlogPostEntity(), []);
|
|
@@ -2589,6 +2603,20 @@ function SettingsView() {
|
|
|
2589
2603
|
] });
|
|
2590
2604
|
}
|
|
2591
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
|
+
|
|
2592
2620
|
// src/registries.ts
|
|
2593
2621
|
var KIND_OPTIONS = [
|
|
2594
2622
|
{ value: "paid", label: "Paid" },
|
|
@@ -2606,10 +2634,18 @@ var channelEntity = {
|
|
|
2606
2634
|
defaultSort: "label",
|
|
2607
2635
|
fields: [
|
|
2608
2636
|
{ key: "label", label: "Channel", type: "text", required: true, showInTable: true, searchable: true },
|
|
2637
|
+
{ key: "channelKey", label: "Key", type: "text", required: true, showInTable: true },
|
|
2609
2638
|
{ key: "kind", label: "Type", type: "select", options: KIND_OPTIONS, showInTable: true },
|
|
2610
|
-
{ key: "icon", label: "Icon", type: "text"
|
|
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 }
|
|
2611
2642
|
],
|
|
2612
|
-
facets: [{ field: "kind" }]
|
|
2643
|
+
facets: [{ field: "kind" }],
|
|
2644
|
+
data: {
|
|
2645
|
+
table: T.channels,
|
|
2646
|
+
tenantScoped: true,
|
|
2647
|
+
searchColumns: ["label", "channel_key"]
|
|
2648
|
+
}
|
|
2613
2649
|
};
|
|
2614
2650
|
function buildMarketingRegistries(channels) {
|
|
2615
2651
|
return [
|
|
@@ -2619,8 +2655,17 @@ function buildMarketingRegistries(channels) {
|
|
|
2619
2655
|
icon: "Radio",
|
|
2620
2656
|
description: "Acquisition channels tracked for this workspace",
|
|
2621
2657
|
display: "table",
|
|
2622
|
-
|
|
2623
|
-
|
|
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
|
+
]
|
|
2624
2669
|
}
|
|
2625
2670
|
];
|
|
2626
2671
|
}
|
|
@@ -3041,6 +3086,276 @@ function createMockMarketingProvider(cfg) {
|
|
|
3041
3086
|
}
|
|
3042
3087
|
};
|
|
3043
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
|
+
}
|
|
3044
3359
|
|
|
3045
3360
|
// src/data/contentMock.ts
|
|
3046
3361
|
var counter = 0;
|
|
@@ -3288,21 +3603,12 @@ function createMockContentPlannerProvider() {
|
|
|
3288
3603
|
}
|
|
3289
3604
|
};
|
|
3290
3605
|
}
|
|
3291
|
-
|
|
3292
|
-
// src/data/tables.ts
|
|
3293
|
-
var T = {
|
|
3294
|
-
socialAccounts: "plg_marketing_social_accounts",
|
|
3295
|
-
contentPlans: "plg_marketing_content_plans",
|
|
3296
|
-
contentPosts: "plg_marketing_content_posts"
|
|
3297
|
-
};
|
|
3298
|
-
|
|
3299
|
-
// src/data/contentSupabase.ts
|
|
3300
|
-
function getClient() {
|
|
3606
|
+
function getClient2() {
|
|
3301
3607
|
const supabase = core.getSupabaseClientOptional();
|
|
3302
3608
|
if (!supabase) throw new Error("Supabase not initialized");
|
|
3303
3609
|
return supabase;
|
|
3304
3610
|
}
|
|
3305
|
-
function
|
|
3611
|
+
function requireTenantId2() {
|
|
3306
3612
|
const tenantId = core.getActiveTenantId();
|
|
3307
3613
|
if (!tenantId) throw new Error("No active tenant");
|
|
3308
3614
|
return tenantId;
|
|
@@ -3338,7 +3644,7 @@ function createSupabaseContentPlannerProvider() {
|
|
|
3338
3644
|
async listAccounts() {
|
|
3339
3645
|
const tenantId = core.getActiveTenantId();
|
|
3340
3646
|
if (!tenantId) return [];
|
|
3341
|
-
const { data } = await
|
|
3647
|
+
const { data } = await getClient2().from(T.socialAccounts).select("*").eq("tenant_id", tenantId).eq("is_active", true).order("created_at", { ascending: true });
|
|
3342
3648
|
return (data ?? []).map(toAccount);
|
|
3343
3649
|
},
|
|
3344
3650
|
async saveAccount(input) {
|
|
@@ -3348,12 +3654,12 @@ function createSupabaseContentPlannerProvider() {
|
|
|
3348
3654
|
if (input.handle !== void 0) payload.handle = input.handle;
|
|
3349
3655
|
if (input.platforms !== void 0) payload.platforms = input.platforms;
|
|
3350
3656
|
if (input.isActive !== void 0) payload.is_active = input.isActive;
|
|
3351
|
-
const { data: data2, error: error2 } = await
|
|
3657
|
+
const { data: data2, error: error2 } = await getClient2().from(T.socialAccounts).update(payload).eq("id", input.id).select("*").single();
|
|
3352
3658
|
if (error2) throw error2;
|
|
3353
3659
|
return toAccount(data2);
|
|
3354
3660
|
}
|
|
3355
|
-
const { data, error } = await
|
|
3356
|
-
tenant_id:
|
|
3661
|
+
const { data, error } = await getClient2().from(T.socialAccounts).insert({
|
|
3662
|
+
tenant_id: requireTenantId2(),
|
|
3357
3663
|
name: input.name,
|
|
3358
3664
|
handle: input.handle ?? null,
|
|
3359
3665
|
platforms: input.platforms ?? ["instagram"],
|
|
@@ -3363,17 +3669,17 @@ function createSupabaseContentPlannerProvider() {
|
|
|
3363
3669
|
return toAccount(data);
|
|
3364
3670
|
},
|
|
3365
3671
|
async deleteAccount(id) {
|
|
3366
|
-
const { error } = await
|
|
3672
|
+
const { error } = await getClient2().from(T.socialAccounts).delete().eq("id", id);
|
|
3367
3673
|
if (error) throw error;
|
|
3368
3674
|
},
|
|
3369
3675
|
async listPlans(accountId) {
|
|
3370
3676
|
const tenantId = core.getActiveTenantId();
|
|
3371
3677
|
if (!tenantId) return [];
|
|
3372
|
-
const { data } = await
|
|
3678
|
+
const { data } = await getClient2().from(T.contentPlans).select("*").eq("tenant_id", tenantId).eq("account_id", accountId).order("created_at", { ascending: false });
|
|
3373
3679
|
return (data ?? []).map(toPlan);
|
|
3374
3680
|
},
|
|
3375
3681
|
async getPlan(id) {
|
|
3376
|
-
const { data } = await
|
|
3682
|
+
const { data } = await getClient2().from(T.contentPlans).select("*").eq("id", id).maybeSingle();
|
|
3377
3683
|
return data ? toPlan(data) : null;
|
|
3378
3684
|
},
|
|
3379
3685
|
async savePlan(input) {
|
|
@@ -3389,13 +3695,13 @@ function createSupabaseContentPlannerProvider() {
|
|
|
3389
3695
|
if (input.formats !== void 0) payload.formats = input.formats;
|
|
3390
3696
|
if (input.weeklyFrequency !== void 0) payload.weekly_frequency = input.weeklyFrequency;
|
|
3391
3697
|
if (input.briefMd !== void 0) payload.brief_md = input.briefMd;
|
|
3392
|
-
const { data: data2, error: error2 } = await
|
|
3698
|
+
const { data: data2, error: error2 } = await getClient2().from(T.contentPlans).update(payload).eq("id", input.id).select("*").single();
|
|
3393
3699
|
if (error2) throw error2;
|
|
3394
3700
|
return toPlan(data2);
|
|
3395
3701
|
}
|
|
3396
3702
|
if (!input.accountId) throw new Error("accountId is required");
|
|
3397
|
-
const { data, error } = await
|
|
3398
|
-
tenant_id:
|
|
3703
|
+
const { data, error } = await getClient2().from(T.contentPlans).insert({
|
|
3704
|
+
tenant_id: requireTenantId2(),
|
|
3399
3705
|
account_id: input.accountId,
|
|
3400
3706
|
name: input.name ?? "Novo plano",
|
|
3401
3707
|
status: input.status ?? "draft",
|
|
@@ -3412,14 +3718,14 @@ function createSupabaseContentPlannerProvider() {
|
|
|
3412
3718
|
return toPlan(data);
|
|
3413
3719
|
},
|
|
3414
3720
|
async deletePlan(id) {
|
|
3415
|
-
await
|
|
3721
|
+
await getClient2().from(T.contentPlans).delete().eq("id", id);
|
|
3416
3722
|
},
|
|
3417
3723
|
async listPosts(planId) {
|
|
3418
|
-
const { data } = await
|
|
3724
|
+
const { data } = await getClient2().from(T.contentPosts).select("*").eq("plan_id", planId).order("week_number", { ascending: true }).order("position", { ascending: true });
|
|
3419
3725
|
return (data ?? []).map(toPost);
|
|
3420
3726
|
},
|
|
3421
3727
|
async getPost(id) {
|
|
3422
|
-
const { data } = await
|
|
3728
|
+
const { data } = await getClient2().from(T.contentPosts).select("*").eq("id", id).maybeSingle();
|
|
3423
3729
|
return data ? toPost(data) : null;
|
|
3424
3730
|
},
|
|
3425
3731
|
async savePost(input) {
|
|
@@ -3437,16 +3743,16 @@ function createSupabaseContentPlannerProvider() {
|
|
|
3437
3743
|
if (input.hook !== void 0) payload.hook = input.hook;
|
|
3438
3744
|
if (input.cta !== void 0) payload.cta = input.cta;
|
|
3439
3745
|
if (input.contentMd !== void 0) payload.content_md = input.contentMd;
|
|
3440
|
-
const { data: data2, error: error2 } = await
|
|
3746
|
+
const { data: data2, error: error2 } = await getClient2().from(T.contentPosts).update(payload).eq("id", input.id).select("*").single();
|
|
3441
3747
|
if (error2) throw error2;
|
|
3442
3748
|
return toPost(data2);
|
|
3443
3749
|
}
|
|
3444
3750
|
if (!input.planId) throw new Error("planId is required");
|
|
3445
3751
|
const week = input.weekNumber ?? 1;
|
|
3446
|
-
const { data: maxData } = await
|
|
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);
|
|
3447
3753
|
const maxPos = maxData?.[0]?.position ?? -1;
|
|
3448
|
-
const { data, error } = await
|
|
3449
|
-
tenant_id:
|
|
3754
|
+
const { data, error } = await getClient2().from(T.contentPosts).insert({
|
|
3755
|
+
tenant_id: requireTenantId2(),
|
|
3450
3756
|
plan_id: input.planId,
|
|
3451
3757
|
week_number: week,
|
|
3452
3758
|
position: input.position ?? maxPos + 1,
|
|
@@ -3465,7 +3771,7 @@ function createSupabaseContentPlannerProvider() {
|
|
|
3465
3771
|
return toPost(data);
|
|
3466
3772
|
},
|
|
3467
3773
|
async deletePost(id) {
|
|
3468
|
-
await
|
|
3774
|
+
await getClient2().from(T.contentPosts).delete().eq("id", id);
|
|
3469
3775
|
}
|
|
3470
3776
|
};
|
|
3471
3777
|
}
|
|
@@ -3481,14 +3787,23 @@ function createMarketingStore(provider2) {
|
|
|
3481
3787
|
async load() {
|
|
3482
3788
|
const range = get().range;
|
|
3483
3789
|
set({ loading: true });
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
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();
|
|
3492
3807
|
},
|
|
3493
3808
|
async setRange(range) {
|
|
3494
3809
|
set({ range });
|
|
@@ -3830,14 +4145,264 @@ CREATE POLICY plg_marketing_ranklayer_sync_log_select ON public.plg_marketing_ra
|
|
|
3830
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()));
|
|
3831
4146
|
GRANT SELECT, INSERT ON public.plg_marketing_ranklayer_sync_log TO authenticated;
|
|
3832
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
|
+
`;
|
|
3833
4398
|
var INTEGRATIONS = "plg_marketing_ranklayer_integrations";
|
|
3834
4399
|
var SYNC_LOG = "plg_marketing_ranklayer_sync_log";
|
|
3835
|
-
function
|
|
4400
|
+
function getClient3() {
|
|
3836
4401
|
const supabase = core.getSupabaseClientOptional();
|
|
3837
4402
|
if (!supabase) throw new Error("[ranklayer] Supabase not initialized");
|
|
3838
4403
|
return supabase;
|
|
3839
4404
|
}
|
|
3840
|
-
function
|
|
4405
|
+
function requireTenantId3() {
|
|
3841
4406
|
const tenantId = core.getActiveTenantId();
|
|
3842
4407
|
if (!tenantId) throw new Error("[ranklayer] No active tenant");
|
|
3843
4408
|
return tenantId;
|
|
@@ -3856,11 +4421,11 @@ function createRankLayerProvider() {
|
|
|
3856
4421
|
async getIntegration() {
|
|
3857
4422
|
const tenantId = core.getActiveTenantId();
|
|
3858
4423
|
if (!tenantId) return null;
|
|
3859
|
-
const { data } = await
|
|
4424
|
+
const { data } = await getClient3().from(INTEGRATIONS).select("*").eq("tenant_id", tenantId).maybeSingle();
|
|
3860
4425
|
return data ? toIntegration(data) : null;
|
|
3861
4426
|
},
|
|
3862
4427
|
async saveIntegration(values) {
|
|
3863
|
-
const tenantId =
|
|
4428
|
+
const tenantId = requireTenantId3();
|
|
3864
4429
|
const payload = {
|
|
3865
4430
|
tenant_id: tenantId,
|
|
3866
4431
|
api_key: values.apiKey,
|
|
@@ -3868,14 +4433,14 @@ function createRankLayerProvider() {
|
|
|
3868
4433
|
active: true,
|
|
3869
4434
|
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
3870
4435
|
};
|
|
3871
|
-
const { data, error } = await
|
|
4436
|
+
const { data, error } = await getClient3().from(INTEGRATIONS).upsert(payload, { onConflict: "tenant_id" }).select("*").single();
|
|
3872
4437
|
if (error) throw error;
|
|
3873
4438
|
return toIntegration(data);
|
|
3874
4439
|
},
|
|
3875
4440
|
async disconnect() {
|
|
3876
4441
|
const tenantId = core.getActiveTenantId();
|
|
3877
4442
|
if (!tenantId) return;
|
|
3878
|
-
await
|
|
4443
|
+
await getClient3().from(INTEGRATIONS).delete().eq("tenant_id", tenantId);
|
|
3879
4444
|
},
|
|
3880
4445
|
// SCAFFOLD: no RankLayer API yet — accept any non-empty key so the connect
|
|
3881
4446
|
// flow can be exercised. Replace with a real credential check in the PR.
|
|
@@ -3886,7 +4451,7 @@ function createRankLayerProvider() {
|
|
|
3886
4451
|
return { ok: true, message: "Chave aceita (valida\xE7\xE3o real pendente \u2014 integra\xE7\xE3o em prepara\xE7\xE3o)." };
|
|
3887
4452
|
},
|
|
3888
4453
|
async getSyncLog(integrationId) {
|
|
3889
|
-
const { data } = await
|
|
4454
|
+
const { data } = await getClient3().from(SYNC_LOG).select("*").eq("integration_id", integrationId).order("created_at", { ascending: false }).limit(20);
|
|
3890
4455
|
return (data ?? []).map((r) => ({
|
|
3891
4456
|
id: r.id,
|
|
3892
4457
|
createdAt: r.created_at,
|
|
@@ -4352,14 +4917,21 @@ function resolveConfig(options) {
|
|
|
4352
4917
|
};
|
|
4353
4918
|
return { config, domain };
|
|
4354
4919
|
}
|
|
4355
|
-
function createSafeProvider(config, domain) {
|
|
4356
|
-
|
|
4357
|
-
|
|
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
|
+
);
|
|
4358
4930
|
}
|
|
4359
4931
|
function createMarketingPlugin(options) {
|
|
4360
4932
|
core.registerTranslations(marketingLocales);
|
|
4361
4933
|
const { config, domain } = resolveConfig(options);
|
|
4362
|
-
const provider2 = options?.dataProvider ?? createSafeProvider(config, domain);
|
|
4934
|
+
const provider2 = options?.dataProvider ?? createSafeProvider(config, domain, options);
|
|
4363
4935
|
const store = createMarketingStore(provider2);
|
|
4364
4936
|
const dashboardWidgets = createMarketingDashboardWidgets({ config, provider: provider2, store });
|
|
4365
4937
|
const contentProvider = options?.contentPlanner?.dataProvider ?? core.createSafeDataProvider(
|
|
@@ -4409,6 +4981,94 @@ function createMarketingPlugin(options) {
|
|
|
4409
4981
|
...config.modules.contentPlanner ? [{ id: "marketing.content", label: config.labels.content, group: "Engage" }] : [],
|
|
4410
4982
|
...config.modules.blog ? [{ id: "marketing.blog", label: config.labels.blog, group: "Engage" }] : []
|
|
4411
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
|
+
}] : []
|
|
5071
|
+
],
|
|
4412
5072
|
navigation: [
|
|
4413
5073
|
{
|
|
4414
5074
|
section: options?.navSection ?? "main",
|
|
@@ -4467,6 +5127,24 @@ function createMarketingPlugin(options) {
|
|
|
4467
5127
|
version: "1.3.0",
|
|
4468
5128
|
sql: MIGRATION_004_RANKLAYER,
|
|
4469
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"
|
|
4470
5148
|
}
|
|
4471
5149
|
],
|
|
4472
5150
|
// RankLayer SEO connector — renders in the Integrações settings tab. Scaffold
|
|
@@ -4512,18 +5190,25 @@ function createMarketingPlugin(options) {
|
|
|
4512
5190
|
{
|
|
4513
5191
|
id: "marketing.create-campaign",
|
|
4514
5192
|
name: "createCampaign",
|
|
4515
|
-
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.",
|
|
4516
5194
|
icon: "Plus",
|
|
4517
5195
|
mode: "persist",
|
|
5196
|
+
limitKey: "campaigns_active",
|
|
5197
|
+
execution: { plane: "server", kind: "rpc", rpc: "agent_marketing_create_campaign" },
|
|
4518
5198
|
category: "Marketing",
|
|
4519
5199
|
parameters: {
|
|
4520
5200
|
type: "object",
|
|
4521
5201
|
properties: {
|
|
4522
5202
|
name: { type: "string", description: "Campaign name" },
|
|
4523
|
-
|
|
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)" }
|
|
4524
5208
|
},
|
|
4525
|
-
required: ["name", "
|
|
5209
|
+
required: ["name", "channel_key"]
|
|
4526
5210
|
},
|
|
5211
|
+
suggestions: [{ label: "Create an Instagram campaign for this month" }],
|
|
4527
5212
|
permission: { feature: "marketing", action: "create" }
|
|
4528
5213
|
}
|
|
4529
5214
|
],
|
|
@@ -4547,5 +5232,6 @@ exports.createMarketingPlugin = createMarketingPlugin;
|
|
|
4547
5232
|
exports.createMockContentPlannerProvider = createMockContentPlannerProvider;
|
|
4548
5233
|
exports.createMockMarketingProvider = createMockMarketingProvider;
|
|
4549
5234
|
exports.createSupabaseContentPlannerProvider = createSupabaseContentPlannerProvider;
|
|
5235
|
+
exports.createSupabaseMarketingProvider = createSupabaseMarketingProvider;
|
|
4550
5236
|
//# sourceMappingURL=index.cjs.map
|
|
4551
5237
|
//# sourceMappingURL=index.cjs.map
|