@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.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
|
|
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"
|
|
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
|
-
|
|
2617
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
3350
|
-
tenant_id:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
3392
|
-
tenant_id:
|
|
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
|
|
3715
|
+
await getClient2().from(T.contentPlans).delete().eq("id", id);
|
|
3410
3716
|
},
|
|
3411
3717
|
async listPosts(planId) {
|
|
3412
|
-
const { data } = await
|
|
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
|
|
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
|
|
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
|
|
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
|
|
3443
|
-
tenant_id:
|
|
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
|
|
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
|
-
|
|
3479
|
-
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
4351
|
-
|
|
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
|
-
|
|
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", "
|
|
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
|