@fayz-ai/plugin-marketing 0.8.0 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/MarketingContext.d.ts +2 -0
- package/dist/MarketingContext.d.ts.map +1 -1
- package/dist/MarketingPage.d.ts.map +1 -1
- package/dist/data/blogEntities.d.ts +28 -0
- package/dist/data/blogEntities.d.ts.map +1 -0
- 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 +1239 -87
- 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 +1245 -94
- package/dist/index.js.map +1 -1
- package/dist/integrations/ranklayer/connectorDef.d.ts +3 -0
- package/dist/integrations/ranklayer/connectorDef.d.ts.map +1 -0
- package/dist/integrations/ranklayer/data/supabase.d.ts +16 -0
- package/dist/integrations/ranklayer/data/supabase.d.ts.map +1 -0
- package/dist/integrations/ranklayer/types.d.ts +16 -0
- package/dist/integrations/ranklayer/types.d.ts.map +1 -0
- package/dist/migrations/index.d.ts +4 -0
- package/dist/migrations/index.d.ts.map +1 -1
- package/dist/presets.d.ts +2 -0
- package/dist/presets.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/BlogAdminView.d.ts +5 -0
- package/dist/views/blog/BlogAdminView.d.ts.map +1 -0
- package/dist/views/blog/BlogEntityForm.d.ts +11 -0
- package/dist/views/blog/BlogEntityForm.d.ts.map +1 -0
- package/dist/views/blog/useEntityCrud.d.ts +29 -0
- package/dist/views/blog/useEntityCrud.d.ts.map +1 -0
- package/dist/views/content/ContentView.d.ts.map +1 -1
- package/package.json +8 -6
- package/src/MarketingContext.tsx +2 -0
- package/src/MarketingPage.tsx +27 -2
- package/src/data/blogEntities.tsx +91 -0
- 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 +153 -10
- package/src/integrations/ranklayer/RANKLAYER.md +40 -0
- package/src/integrations/ranklayer/connectorDef.tsx +98 -0
- package/src/integrations/ranklayer/data/supabase.ts +106 -0
- package/src/integrations/ranklayer/types.ts +16 -0
- package/src/migrations/004_ranklayer.sql +50 -0
- 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 +310 -1
- package/src/presets.ts +5 -3
- 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/BlogAdminView.tsx +99 -0
- package/src/views/blog/BlogEntityForm.tsx +101 -0
- package/src/views/blog/useEntityCrud.ts +50 -0
- package/src/views/content/ContentView.tsx +8 -1
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import { getSupabaseClientOptional, getActiveTenantId } from '@fayz-ai/core'
|
|
2
|
+
import type {
|
|
3
|
+
AttributionBridge,
|
|
4
|
+
MarketingDataProvider,
|
|
5
|
+
SaveCampaignInput,
|
|
6
|
+
SitesPerformanceBridge,
|
|
7
|
+
} from './types'
|
|
8
|
+
import type {
|
|
9
|
+
AcquisitionChannel,
|
|
10
|
+
Campaign,
|
|
11
|
+
ChannelPerformance,
|
|
12
|
+
ConversionModel,
|
|
13
|
+
DateRangeKey,
|
|
14
|
+
FunnelStage,
|
|
15
|
+
LandingPagePerf,
|
|
16
|
+
MarketingOverview,
|
|
17
|
+
MarketingQuery,
|
|
18
|
+
} from '../types'
|
|
19
|
+
import { T, V } from './tables'
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Supabase marketing provider. Channels/campaigns are real CRUD rows
|
|
23
|
+
// (plg_marketing_channels/_campaigns, lazily seeded from the app's channel
|
|
24
|
+
// config); performance is derived from v_marketing_attribution — REAL
|
|
25
|
+
// conversions (bookings/orders/won deals) and leads, attributed by matching
|
|
26
|
+
// the event's origin string against the channel set. Reach has no generic
|
|
27
|
+
// source yet, so it reads 0 until a sites/ads integration feeds it.
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
export interface SupabaseMarketingProviderOptions {
|
|
31
|
+
channels: AcquisitionChannel[]
|
|
32
|
+
conversion: ConversionModel
|
|
33
|
+
attributionBridge?: AttributionBridge
|
|
34
|
+
sitesBridge?: SitesPerformanceBridge
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const RANGE_DAYS: Record<DateRangeKey, number> = { '7d': 7, '30d': 30, '90d': 90 }
|
|
38
|
+
|
|
39
|
+
function getClient() {
|
|
40
|
+
const supabase = getSupabaseClientOptional() as any
|
|
41
|
+
if (!supabase) throw new Error('Supabase not initialized')
|
|
42
|
+
return supabase
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function requireTenantId(): string {
|
|
46
|
+
const tenantId = getActiveTenantId()
|
|
47
|
+
if (!tenantId) throw new Error('No active tenant')
|
|
48
|
+
return tenantId
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 'Walk-in' → 'walkin', 'paid-search' → 'paidsearch' — origin strings match
|
|
52
|
+
* channel ids OR labels regardless of case/punctuation. */
|
|
53
|
+
function normKey(value: string | null | undefined): string {
|
|
54
|
+
return (value ?? '').toLowerCase().replace(/[^a-z0-9]/g, '')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface AttributionRow {
|
|
58
|
+
kind: 'lead' | 'conversion'
|
|
59
|
+
source: string
|
|
60
|
+
channel_raw: string | null
|
|
61
|
+
occurred_at: string
|
|
62
|
+
value: number | null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function createSupabaseMarketingProvider(
|
|
66
|
+
options: SupabaseMarketingProviderOptions,
|
|
67
|
+
): MarketingDataProvider {
|
|
68
|
+
const { channels, conversion, attributionBridge, sitesBridge } = options
|
|
69
|
+
|
|
70
|
+
const channelByNorm = new Map<string, string>()
|
|
71
|
+
for (const ch of channels) {
|
|
72
|
+
channelByNorm.set(normKey(ch.id), ch.id)
|
|
73
|
+
channelByNorm.set(normKey(ch.label), ch.id)
|
|
74
|
+
}
|
|
75
|
+
const resolveChannel = (raw: string | null): string | null =>
|
|
76
|
+
channelByNorm.get(normKey(raw)) ?? null
|
|
77
|
+
|
|
78
|
+
// Lazy per-tenant seed: the channel set comes from app config (SQL can't know
|
|
79
|
+
// the domain), so the first read upserts the preset rows. Idempotent via the
|
|
80
|
+
// (tenant_id, channel_key) unique index.
|
|
81
|
+
const seeded = new Set<string>()
|
|
82
|
+
async function ensureChannels(tenantId: string): Promise<void> {
|
|
83
|
+
if (seeded.has(tenantId)) return
|
|
84
|
+
seeded.add(tenantId)
|
|
85
|
+
await getClient()
|
|
86
|
+
.from(T.channels)
|
|
87
|
+
.upsert(
|
|
88
|
+
channels.map((c) => ({
|
|
89
|
+
tenant_id: tenantId,
|
|
90
|
+
channel_key: c.id,
|
|
91
|
+
label: c.label,
|
|
92
|
+
icon: c.icon,
|
|
93
|
+
kind: c.kind,
|
|
94
|
+
})),
|
|
95
|
+
{ onConflict: 'tenant_id,channel_key', ignoreDuplicates: true },
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// One store.load() fans out to overview + channels + funnel + campaigns, and
|
|
100
|
+
// dashboard widgets pile on top — without dedup that's ~10 concurrent
|
|
101
|
+
// requests per mount (a stampede the instant mock never surfaced). Share
|
|
102
|
+
// in-flight promises and cache briefly; writes clear the cache.
|
|
103
|
+
const CACHE_TTL_MS = 30_000
|
|
104
|
+
const cache = new Map<string, { at: number; promise: Promise<unknown> }>()
|
|
105
|
+
function memo<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
|
106
|
+
const hit = cache.get(key)
|
|
107
|
+
if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.promise as Promise<T>
|
|
108
|
+
const promise = fn().catch((err) => {
|
|
109
|
+
cache.delete(key)
|
|
110
|
+
throw err
|
|
111
|
+
})
|
|
112
|
+
cache.set(key, { at: Date.now(), promise })
|
|
113
|
+
return promise
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function windowFor(range: DateRangeKey): { since: Date; days: number } {
|
|
117
|
+
const days = RANGE_DAYS[range]
|
|
118
|
+
// Quantized to the minute so equal ranges hit the same cache key.
|
|
119
|
+
const now = Math.floor(Date.now() / 60_000) * 60_000
|
|
120
|
+
return { since: new Date(now - days * 86_400_000), days }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function fetchAttribution(
|
|
124
|
+
tenantId: string,
|
|
125
|
+
since: Date,
|
|
126
|
+
until?: Date,
|
|
127
|
+
): Promise<AttributionRow[]> {
|
|
128
|
+
const key = `attr:${tenantId}:${since.getTime()}:${until?.getTime() ?? ''}`
|
|
129
|
+
return memo(key, async () => {
|
|
130
|
+
let q = getClient()
|
|
131
|
+
.from(V.attribution)
|
|
132
|
+
.select('kind, source, channel_raw, occurred_at, value')
|
|
133
|
+
.eq('tenant_id', tenantId)
|
|
134
|
+
.gte('occurred_at', since.toISOString())
|
|
135
|
+
// Leads always count; conversions only from the vertical's source.
|
|
136
|
+
.or(`kind.eq.lead,and(kind.eq.conversion,source.eq.${conversion.source})`)
|
|
137
|
+
if (until) q = q.lt('occurred_at', until.toISOString())
|
|
138
|
+
const { data, error } = await q
|
|
139
|
+
if (error) throw error
|
|
140
|
+
return (data ?? []) as AttributionRow[]
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
interface ChannelRollup {
|
|
145
|
+
leads: number
|
|
146
|
+
conversions: number
|
|
147
|
+
value: number
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function rollup(rows: AttributionRow[]): Map<string, ChannelRollup> {
|
|
151
|
+
const map = new Map<string, ChannelRollup>()
|
|
152
|
+
for (const row of rows) {
|
|
153
|
+
const channelId = resolveChannel(row.channel_raw)
|
|
154
|
+
if (!channelId) continue
|
|
155
|
+
const agg = map.get(channelId) ?? { leads: 0, conversions: 0, value: 0 }
|
|
156
|
+
if (row.kind === 'lead') agg.leads += 1
|
|
157
|
+
else {
|
|
158
|
+
agg.conversions += 1
|
|
159
|
+
agg.value += Number(row.value ?? conversion.avgValue)
|
|
160
|
+
}
|
|
161
|
+
map.set(channelId, agg)
|
|
162
|
+
}
|
|
163
|
+
return map
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function fetchSpend(
|
|
167
|
+
tenantId: string,
|
|
168
|
+
days: number,
|
|
169
|
+
since: Date,
|
|
170
|
+
): Promise<Map<string, number>> {
|
|
171
|
+
return memo(`spend:${tenantId}:${days}:${since.getTime()}`, () => fetchSpendRaw(tenantId, days, since))
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function fetchSpendRaw(
|
|
175
|
+
tenantId: string,
|
|
176
|
+
days: number,
|
|
177
|
+
since: Date,
|
|
178
|
+
): Promise<Map<string, number>> {
|
|
179
|
+
const client = getClient()
|
|
180
|
+
const [channelRows, campaignRows] = await Promise.all([
|
|
181
|
+
client.from(V.channels).select('channel_key, monthly_spend').eq('tenant_id', tenantId),
|
|
182
|
+
client
|
|
183
|
+
.from(V.campaigns)
|
|
184
|
+
.select('channel_key, spend, status, starts_at, ends_at')
|
|
185
|
+
.eq('tenant_id', tenantId)
|
|
186
|
+
.neq('status', 'draft'),
|
|
187
|
+
])
|
|
188
|
+
const spend = new Map<string, number>()
|
|
189
|
+
for (const row of channelRows.data ?? []) {
|
|
190
|
+
const id = resolveChannel(row.channel_key)
|
|
191
|
+
if (!id) continue
|
|
192
|
+
spend.set(id, (spend.get(id) ?? 0) + (Number(row.monthly_spend) * days) / 30)
|
|
193
|
+
}
|
|
194
|
+
for (const row of campaignRows.data ?? []) {
|
|
195
|
+
const id = resolveChannel(row.channel_key)
|
|
196
|
+
if (!id) continue
|
|
197
|
+
// Count a campaign's spend when its window overlaps the query window.
|
|
198
|
+
const ended = row.ends_at ? new Date(row.ends_at) : null
|
|
199
|
+
if (ended && ended < since) continue
|
|
200
|
+
spend.set(id, (spend.get(id) ?? 0) + Number(row.spend))
|
|
201
|
+
}
|
|
202
|
+
return spend
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function channelPerformance(query: MarketingQuery): Promise<ChannelPerformance[]> {
|
|
206
|
+
const tenantId = requireTenantId()
|
|
207
|
+
await ensureChannels(tenantId)
|
|
208
|
+
const { since, days } = windowFor(query.range)
|
|
209
|
+
const [rows, spend] = await Promise.all([
|
|
210
|
+
fetchAttribution(tenantId, since),
|
|
211
|
+
fetchSpend(tenantId, days, since),
|
|
212
|
+
])
|
|
213
|
+
const byChannel = rollup(rows)
|
|
214
|
+
if (attributionBridge) {
|
|
215
|
+
const bridged = await attributionBridge.conversionsByChannel(query)
|
|
216
|
+
for (const b of bridged) {
|
|
217
|
+
const agg = byChannel.get(b.channelId) ?? { leads: 0, conversions: 0, value: 0 }
|
|
218
|
+
agg.conversions = b.conversions
|
|
219
|
+
agg.value = b.value
|
|
220
|
+
byChannel.set(b.channelId, agg)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const list = channels.map((ch) => {
|
|
224
|
+
const agg = byChannel.get(ch.id) ?? { leads: 0, conversions: 0, value: 0 }
|
|
225
|
+
const chSpend = spend.get(ch.id) ?? 0
|
|
226
|
+
return {
|
|
227
|
+
channelId: ch.id,
|
|
228
|
+
reach: 0,
|
|
229
|
+
leads: agg.leads,
|
|
230
|
+
conversions: agg.conversions,
|
|
231
|
+
spend: Math.round(chSpend),
|
|
232
|
+
value: Math.round(agg.value),
|
|
233
|
+
cvr: 0,
|
|
234
|
+
cpa: agg.conversions > 0 && chSpend > 0 ? chSpend / agg.conversions : 0,
|
|
235
|
+
}
|
|
236
|
+
})
|
|
237
|
+
return query.channelId ? list.filter((c) => c.channelId === query.channelId) : list
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function campaignFromRow(row: Record<string, any>): Campaign {
|
|
241
|
+
return {
|
|
242
|
+
id: row.id,
|
|
243
|
+
name: row.name,
|
|
244
|
+
channelId: row.channel_key,
|
|
245
|
+
status: row.status,
|
|
246
|
+
start: row.starts_at ?? row.created_at,
|
|
247
|
+
end: row.ends_at ?? undefined,
|
|
248
|
+
spend: Number(row.spend ?? 0),
|
|
249
|
+
reach: 0,
|
|
250
|
+
leads: 0,
|
|
251
|
+
conversions: 0,
|
|
252
|
+
value: 0,
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function withDerived(campaign: Campaign, rows: AttributionRow[]): Campaign {
|
|
257
|
+
const start = new Date(campaign.start).getTime()
|
|
258
|
+
const end = campaign.end ? new Date(campaign.end).getTime() : Number.POSITIVE_INFINITY
|
|
259
|
+
let leads = 0
|
|
260
|
+
let conversions = 0
|
|
261
|
+
let value = 0
|
|
262
|
+
for (const row of rows) {
|
|
263
|
+
if (resolveChannel(row.channel_raw) !== campaign.channelId) continue
|
|
264
|
+
const at = new Date(row.occurred_at).getTime()
|
|
265
|
+
if (at < start || at > end) continue
|
|
266
|
+
if (row.kind === 'lead') leads += 1
|
|
267
|
+
else {
|
|
268
|
+
conversions += 1
|
|
269
|
+
value += Number(row.value ?? conversion.avgValue)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return { ...campaign, leads, conversions, value: Math.round(value) }
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
channelPerformance,
|
|
277
|
+
|
|
278
|
+
async overview(query: MarketingQuery): Promise<MarketingOverview> {
|
|
279
|
+
const tenantId = requireTenantId()
|
|
280
|
+
const perf = await channelPerformance(query)
|
|
281
|
+
const { since, days } = windowFor(query.range)
|
|
282
|
+
const prevRows = await fetchAttribution(
|
|
283
|
+
tenantId,
|
|
284
|
+
new Date(since.getTime() - days * 86_400_000),
|
|
285
|
+
since,
|
|
286
|
+
)
|
|
287
|
+
const conversions = perf.reduce((s, c) => s + c.conversions, 0)
|
|
288
|
+
const spend = perf.reduce((s, c) => s + c.spend, 0)
|
|
289
|
+
const value = perf.reduce((s, c) => s + c.value, 0)
|
|
290
|
+
const conversionsPrev = prevRows.filter(
|
|
291
|
+
(r) => r.kind === 'conversion' && resolveChannel(r.channel_raw),
|
|
292
|
+
).length
|
|
293
|
+
const top = [...perf].sort((a, b) => b.conversions - a.conversions)[0]
|
|
294
|
+
return {
|
|
295
|
+
conversions,
|
|
296
|
+
conversionsPrev,
|
|
297
|
+
cvr: 0,
|
|
298
|
+
cvrPrev: 0,
|
|
299
|
+
spend,
|
|
300
|
+
cpa: conversions > 0 && spend > 0 ? spend / conversions : 0,
|
|
301
|
+
value,
|
|
302
|
+
topChannelId: top && top.conversions > 0 ? top.channelId : null,
|
|
303
|
+
channelMix: perf.map((c) => ({ channelId: c.channelId, conversions: c.conversions })),
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
|
|
307
|
+
async funnel(query: MarketingQuery): Promise<FunnelStage[]> {
|
|
308
|
+
if (attributionBridge) return attributionBridge.funnel(query)
|
|
309
|
+
const tenantId = requireTenantId()
|
|
310
|
+
const { since } = windowFor(query.range)
|
|
311
|
+
const rows = await fetchAttribution(tenantId, since)
|
|
312
|
+
const leads = rows.filter((r) => r.kind === 'lead').length
|
|
313
|
+
const conversions = rows.filter((r) => r.kind === 'conversion').length
|
|
314
|
+
return [
|
|
315
|
+
{ id: 'leads', label: 'Leads', count: leads, color: '#0ea5e9' },
|
|
316
|
+
{ id: 'converted', label: conversion.labelPlural, count: conversions, color: '#22c55e' },
|
|
317
|
+
]
|
|
318
|
+
},
|
|
319
|
+
|
|
320
|
+
async listCampaigns(query: MarketingQuery): Promise<Campaign[]> {
|
|
321
|
+
const tenantId = requireTenantId()
|
|
322
|
+
const fetchRows = () =>
|
|
323
|
+
memo(`campaigns:${tenantId}`, async () => {
|
|
324
|
+
const { data, error } = await getClient()
|
|
325
|
+
.from(V.campaigns)
|
|
326
|
+
.select('*')
|
|
327
|
+
.eq('tenant_id', tenantId)
|
|
328
|
+
.order('created_at', { ascending: false })
|
|
329
|
+
if (error) throw error
|
|
330
|
+
return (data ?? []) as Record<string, any>[]
|
|
331
|
+
})
|
|
332
|
+
const [data, rows] = await Promise.all([
|
|
333
|
+
fetchRows(),
|
|
334
|
+
fetchAttribution(tenantId, windowFor(query.range).since),
|
|
335
|
+
])
|
|
336
|
+
const list = query.channelId ? data.filter((r) => r.channel_key === query.channelId) : data
|
|
337
|
+
return list.map((row) => withDerived(campaignFromRow(row), rows))
|
|
338
|
+
},
|
|
339
|
+
|
|
340
|
+
async getCampaign(id: string): Promise<Campaign | null> {
|
|
341
|
+
const tenantId = requireTenantId()
|
|
342
|
+
const { data, error } = await getClient()
|
|
343
|
+
.from(V.campaigns)
|
|
344
|
+
.select('*')
|
|
345
|
+
.eq('tenant_id', tenantId)
|
|
346
|
+
.eq('id', id)
|
|
347
|
+
.maybeSingle()
|
|
348
|
+
if (error) throw error
|
|
349
|
+
if (!data) return null
|
|
350
|
+
const campaign = campaignFromRow(data)
|
|
351
|
+
const rows = await fetchAttribution(tenantId, new Date(campaign.start))
|
|
352
|
+
return withDerived(campaign, rows)
|
|
353
|
+
},
|
|
354
|
+
|
|
355
|
+
async saveCampaign(input: SaveCampaignInput): Promise<Campaign> {
|
|
356
|
+
const tenantId = requireTenantId()
|
|
357
|
+
const payload = {
|
|
358
|
+
name: input.name,
|
|
359
|
+
channel_key: input.channelId,
|
|
360
|
+
status: input.status,
|
|
361
|
+
starts_at: input.start || null,
|
|
362
|
+
ends_at: input.end || null,
|
|
363
|
+
spend: input.spend ?? 0,
|
|
364
|
+
}
|
|
365
|
+
if (input.id) {
|
|
366
|
+
const { data, error } = await getClient()
|
|
367
|
+
.from(T.campaigns)
|
|
368
|
+
.update(payload)
|
|
369
|
+
.eq('id', input.id)
|
|
370
|
+
.select('*')
|
|
371
|
+
.single()
|
|
372
|
+
if (error) throw error
|
|
373
|
+
cache.clear()
|
|
374
|
+
return campaignFromRow(data)
|
|
375
|
+
}
|
|
376
|
+
const { data, error } = await getClient()
|
|
377
|
+
.from(T.campaigns)
|
|
378
|
+
.insert({ tenant_id: tenantId, ...payload })
|
|
379
|
+
.select('*')
|
|
380
|
+
.single()
|
|
381
|
+
if (error) throw error
|
|
382
|
+
cache.clear()
|
|
383
|
+
return campaignFromRow(data)
|
|
384
|
+
},
|
|
385
|
+
|
|
386
|
+
async deleteCampaign(id: string): Promise<void> {
|
|
387
|
+
const { error } = await getClient().from(T.campaigns).delete().eq('id', id)
|
|
388
|
+
if (error) throw error
|
|
389
|
+
cache.clear()
|
|
390
|
+
},
|
|
391
|
+
|
|
392
|
+
invalidateCache() {
|
|
393
|
+
cache.clear()
|
|
394
|
+
},
|
|
395
|
+
|
|
396
|
+
async landingPages(query: MarketingQuery): Promise<LandingPagePerf[]> {
|
|
397
|
+
if (sitesBridge) return sitesBridge.landingPages(query)
|
|
398
|
+
return []
|
|
399
|
+
},
|
|
400
|
+
}
|
|
401
|
+
}
|
package/src/data/tables.ts
CHANGED
|
@@ -4,4 +4,13 @@ export const T = {
|
|
|
4
4
|
socialAccounts: 'plg_marketing_social_accounts',
|
|
5
5
|
contentPlans: 'plg_marketing_content_plans',
|
|
6
6
|
contentPosts: 'plg_marketing_content_posts',
|
|
7
|
+
channels: 'plg_marketing_channels',
|
|
8
|
+
campaigns: 'plg_marketing_campaigns',
|
|
9
|
+
} as const
|
|
10
|
+
|
|
11
|
+
// Read views (bridge views over spine + plugin tables; see 006_analytics_views.sql)
|
|
12
|
+
export const V = {
|
|
13
|
+
channels: 'v_marketing_channels',
|
|
14
|
+
campaigns: 'v_marketing_campaigns',
|
|
15
|
+
attribution: 'v_marketing_attribution',
|
|
7
16
|
} as const
|
package/src/data/types.ts
CHANGED
|
@@ -34,6 +34,8 @@ export interface MarketingDataProvider {
|
|
|
34
34
|
deleteCampaign(id: string): Promise<void>
|
|
35
35
|
funnel(query: MarketingQuery): Promise<FunnelStage[]>
|
|
36
36
|
landingPages(query: MarketingQuery): Promise<LandingPagePerf[]>
|
|
37
|
+
/** Drop any provider-side read cache (external writes, e.g. agent RPCs). */
|
|
38
|
+
invalidateCache?(): void
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
// ---------------------------------------------------------------------------
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
2
|
import type { PluginManifest, PluginRegistryDef, PluginScope, VerticalId } from '@fayz-ai/core'
|
|
3
|
-
import { createSafeDataProvider,
|
|
3
|
+
import { createSafeDataProvider, registerTranslations } from '@fayz-ai/core'
|
|
4
4
|
import { PluginSettingsPanel } from '@fayz-ai/saas'
|
|
5
5
|
import { MarketingPage } from './MarketingPage'
|
|
6
6
|
import { SettingsView } from './views/SettingsView'
|
|
@@ -17,12 +17,15 @@ import type {
|
|
|
17
17
|
SitesPerformanceBridge,
|
|
18
18
|
} from './data/types'
|
|
19
19
|
import { createMockMarketingProvider } from './data/mock'
|
|
20
|
+
import { createSupabaseMarketingProvider } from './data/supabase'
|
|
21
|
+
import { T } from './data/tables'
|
|
20
22
|
import type { ContentPlannerProvider } from './data/contentTypes'
|
|
21
23
|
import { createMockContentPlannerProvider } from './data/contentMock'
|
|
22
24
|
import { createSupabaseContentPlannerProvider } from './data/contentSupabase'
|
|
23
25
|
import { createMarketingStore } from './store'
|
|
24
26
|
import { createContentPlannerStore } from './views/content/contentStore'
|
|
25
|
-
import { MIGRATION_000_PLG_RENAME, MIGRATION_001_CONTENT_PLANNER, MIGRATION_002_MULTI_PLATFORM, MIGRATION_003_RECORDING_OPS } from './migrations'
|
|
27
|
+
import { MIGRATION_000_PLG_RENAME, MIGRATION_001_CONTENT_PLANNER, MIGRATION_002_MULTI_PLATFORM, MIGRATION_003_RECORDING_OPS, MIGRATION_004_RANKLAYER, MIGRATION_005_ANALYTICS_BASE, MIGRATION_006_ANALYTICS_VIEWS, MIGRATION_007_AGENT_RPCS } from './migrations'
|
|
28
|
+
import { ranklayerConnectorDef } from './integrations/ranklayer/connectorDef'
|
|
26
29
|
import { DEFAULT_CURRENCY, type MarketingCurrency } from './format'
|
|
27
30
|
import { MARKETING_PRESETS, type MarketingDomain, type MarketingDomainModules } from './presets'
|
|
28
31
|
import type { AcquisitionChannel, ConversionModel } from './types'
|
|
@@ -74,6 +77,8 @@ const DEFAULT_LABELS: MarketingLabels = {
|
|
|
74
77
|
funnel: 'Funnel',
|
|
75
78
|
landingPages: 'Landing pages',
|
|
76
79
|
content: 'Content',
|
|
80
|
+
blog: 'Blog',
|
|
81
|
+
blogCategories: 'Categorias',
|
|
77
82
|
settings: 'Settings',
|
|
78
83
|
}
|
|
79
84
|
|
|
@@ -90,16 +95,26 @@ function resolveConfig(options?: MarketingPluginOptions): { config: ResolvedMark
|
|
|
90
95
|
return { config, domain }
|
|
91
96
|
}
|
|
92
97
|
|
|
93
|
-
function createSafeProvider(
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
98
|
+
function createSafeProvider(
|
|
99
|
+
config: ResolvedMarketingConfig,
|
|
100
|
+
domain: MarketingDomain,
|
|
101
|
+
options?: MarketingPluginOptions,
|
|
102
|
+
): MarketingDataProvider {
|
|
103
|
+
return createSafeDataProvider(
|
|
104
|
+
() => createSupabaseMarketingProvider({
|
|
105
|
+
channels: config.channels,
|
|
106
|
+
conversion: config.conversion,
|
|
107
|
+
attributionBridge: options?.attributionBridge,
|
|
108
|
+
sitesBridge: options?.sitesBridge,
|
|
109
|
+
}),
|
|
110
|
+
() => createMockMarketingProvider({ channels: config.channels, conversion: config.conversion, domain }),
|
|
111
|
+
)
|
|
97
112
|
}
|
|
98
113
|
|
|
99
114
|
export function createMarketingPlugin(options?: MarketingPluginOptions): PluginManifest {
|
|
100
115
|
registerTranslations(marketingLocales)
|
|
101
116
|
const { config, domain } = resolveConfig(options)
|
|
102
|
-
const provider = options?.dataProvider ?? createSafeProvider(config, domain)
|
|
117
|
+
const provider = options?.dataProvider ?? createSafeProvider(config, domain, options)
|
|
103
118
|
const store = createMarketingStore(provider)
|
|
104
119
|
const dashboardWidgets = createMarketingDashboardWidgets({ config, provider, store })
|
|
105
120
|
|
|
@@ -130,6 +145,8 @@ export function createMarketingPlugin(options?: MarketingPluginOptions): PluginM
|
|
|
130
145
|
subtitle: config.labels.pageSubtitle,
|
|
131
146
|
generalSettings: React.createElement(SettingsView),
|
|
132
147
|
registries: marketingRegistries,
|
|
148
|
+
// Enables the "Integrações" tab (renders manifest connectors for this host).
|
|
149
|
+
hostPluginId: 'marketing',
|
|
133
150
|
routeBase: '/settings/marketing',
|
|
134
151
|
}))
|
|
135
152
|
SettingsComponent.displayName = 'MarketingSettings'
|
|
@@ -150,6 +167,96 @@ export function createMarketingPlugin(options?: MarketingPluginOptions): PluginM
|
|
|
150
167
|
{ id: 'marketing', label: config.labels.pageTitle, group: 'Engage' },
|
|
151
168
|
...(config.modules.landingPages ? [{ id: 'marketing.landing-pages', label: config.labels.landingPages, group: 'Engage' }] : []),
|
|
152
169
|
...(config.modules.contentPlanner ? [{ id: 'marketing.content', label: config.labels.content, group: 'Engage' }] : []),
|
|
170
|
+
...(config.modules.blog ? [{ id: 'marketing.blog', label: config.labels.blog, group: 'Engage' }] : []),
|
|
171
|
+
],
|
|
172
|
+
declaredLimits: [
|
|
173
|
+
...(config.modules.campaigns ? [{ key: 'campaigns_active', label: 'Active campaigns', table: T.campaigns }] : []),
|
|
174
|
+
...(config.modules.contentPlanner ? [{ key: 'content_posts_month', label: 'Content posts / month', table: 'plg_marketing_content_posts', period: 'month' as const }] : []),
|
|
175
|
+
...(config.modules.blog ? [{ key: 'blog_posts', label: 'Blog posts', table: 'plg_blog_posts' }] : []),
|
|
176
|
+
],
|
|
177
|
+
// Read-models for the agent's data primitives (searchRecords/queryData) —
|
|
178
|
+
// mirrors agenda's `agenda:appointments`. Keys are stable ASCII; fields are
|
|
179
|
+
// the provider's camelCase output shape.
|
|
180
|
+
queryEntities: [
|
|
181
|
+
...(config.modules.campaigns ? [{
|
|
182
|
+
key: 'marketing:campaigns',
|
|
183
|
+
writable: false,
|
|
184
|
+
entity: {
|
|
185
|
+
name: 'Campaign',
|
|
186
|
+
namePlural: 'Campaigns',
|
|
187
|
+
icon: 'Megaphone',
|
|
188
|
+
permission: { feature: 'marketing', action: 'read' as const },
|
|
189
|
+
fields: [
|
|
190
|
+
{ key: 'name', label: 'Name', type: 'text' as const, searchable: true },
|
|
191
|
+
{ key: 'channelKey', label: 'Channel', type: 'text' as const, searchable: true },
|
|
192
|
+
{ key: 'channelLabel', label: 'Channel label', type: 'text' as const },
|
|
193
|
+
{ key: 'status', label: 'Status', type: 'text' as const },
|
|
194
|
+
{ key: 'startsAt', label: 'Starts at', type: 'text' as const },
|
|
195
|
+
{ key: 'endsAt', label: 'Ends at', type: 'text' as const },
|
|
196
|
+
{ key: 'spend', label: 'Spend', type: 'number' as const },
|
|
197
|
+
{ key: 'createdAt', label: 'Created at', type: 'text' as const },
|
|
198
|
+
],
|
|
199
|
+
data: {
|
|
200
|
+
table: 'v_marketing_campaigns',
|
|
201
|
+
tenantScoped: true,
|
|
202
|
+
searchColumns: ['name', 'channel_key'],
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
}] : []),
|
|
206
|
+
...(config.modules.channels ? [{
|
|
207
|
+
key: 'marketing:channels',
|
|
208
|
+
writable: false,
|
|
209
|
+
entity: {
|
|
210
|
+
name: 'Marketing channel',
|
|
211
|
+
namePlural: 'Marketing channels',
|
|
212
|
+
icon: 'Radio',
|
|
213
|
+
permission: { feature: 'marketing', action: 'read' as const },
|
|
214
|
+
fields: [
|
|
215
|
+
{ key: 'channelKey', label: 'Key', type: 'text' as const, searchable: true },
|
|
216
|
+
{ key: 'label', label: 'Channel', type: 'text' as const, searchable: true },
|
|
217
|
+
{ key: 'kind', label: 'Type', type: 'text' as const },
|
|
218
|
+
{ key: 'isActive', label: 'Active', type: 'boolean' as const },
|
|
219
|
+
{ key: 'monthlySpend', label: 'Monthly spend', type: 'number' as const },
|
|
220
|
+
],
|
|
221
|
+
data: {
|
|
222
|
+
table: 'v_marketing_channels',
|
|
223
|
+
tenantScoped: true,
|
|
224
|
+
searchColumns: ['label', 'channel_key'],
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
}] : []),
|
|
228
|
+
...(config.modules.contentPlanner ? [{
|
|
229
|
+
key: 'marketing:contentPosts',
|
|
230
|
+
writable: false,
|
|
231
|
+
entity: {
|
|
232
|
+
name: 'Content post',
|
|
233
|
+
namePlural: 'Content posts',
|
|
234
|
+
icon: 'FileText',
|
|
235
|
+
permission: { feature: 'marketing.content', action: 'read' as const },
|
|
236
|
+
fields: [
|
|
237
|
+
{ key: 'title', label: 'Title', type: 'text' as const, searchable: true },
|
|
238
|
+
{ key: 'format', label: 'Format', type: 'text' as const },
|
|
239
|
+
{ key: 'status', label: 'Status', type: 'text' as const },
|
|
240
|
+
{ key: 'weekNumber', label: 'Week', type: 'number' as const },
|
|
241
|
+
{ key: 'scheduledDate', label: 'Scheduled date', type: 'text' as const },
|
|
242
|
+
{ key: 'hook', label: 'Hook', type: 'text' as const, searchable: true },
|
|
243
|
+
],
|
|
244
|
+
data: {
|
|
245
|
+
table: T.contentPosts,
|
|
246
|
+
tenantScoped: true,
|
|
247
|
+
searchColumns: ['title', 'hook'],
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
}] : []),
|
|
251
|
+
],
|
|
252
|
+
declaredRpcs: [
|
|
253
|
+
...(config.modules.campaigns ? [{
|
|
254
|
+
name: 'agent_marketing_create_campaign',
|
|
255
|
+
kind: 'write' as const,
|
|
256
|
+
description:
|
|
257
|
+
'Guarded campaign create: agent_guard (role→plan→campaigns_active cap), channel validated against the tenant channel set, audited.',
|
|
258
|
+
audits: true,
|
|
259
|
+
}] : []),
|
|
153
260
|
],
|
|
154
261
|
navigation: [
|
|
155
262
|
{
|
|
@@ -204,7 +311,34 @@ export function createMarketingPlugin(options?: MarketingPluginOptions): PluginM
|
|
|
204
311
|
sql: MIGRATION_003_RECORDING_OPS,
|
|
205
312
|
description: 'Recording-day checklist + media asset URL on posts; content-media storage bucket',
|
|
206
313
|
},
|
|
314
|
+
{
|
|
315
|
+
id: 'marketing-004-ranklayer',
|
|
316
|
+
version: '1.3.0',
|
|
317
|
+
sql: MIGRATION_004_RANKLAYER,
|
|
318
|
+
description: 'RankLayer connector state: plg_marketing_ranklayer_integrations + _sync_log (tenant RLS)',
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
id: 'marketing-005-analytics-base',
|
|
322
|
+
version: '1.4.0',
|
|
323
|
+
sql: MIGRATION_005_ANALYTICS_BASE,
|
|
324
|
+
description: 'Analytics base: plg_marketing_channels + plg_marketing_campaigns with canonical tenant RLS',
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
id: 'marketing-006-analytics-views',
|
|
328
|
+
version: '1.4.0',
|
|
329
|
+
sql: MIGRATION_006_ANALYTICS_VIEWS,
|
|
330
|
+
description: 'Read views v_marketing_channels/_campaigns + generic spine-only v_marketing_attribution',
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
id: 'marketing-007-agent-rpcs',
|
|
334
|
+
version: '1.4.0',
|
|
335
|
+
sql: MIGRATION_007_AGENT_RPCS,
|
|
336
|
+
description: 'agent_marketing_create_campaign — guarded, audited server-plane campaign create',
|
|
337
|
+
},
|
|
207
338
|
],
|
|
339
|
+
// RankLayer SEO connector — renders in the Integrações settings tab. Scaffold
|
|
340
|
+
// (control plane only); the real sync lands via an external PR (RANKLAYER.md).
|
|
341
|
+
connectors: [ranklayerConnectorDef],
|
|
208
342
|
aiTools: [
|
|
209
343
|
{
|
|
210
344
|
id: 'marketing.channel-performance',
|
|
@@ -245,18 +379,26 @@ export function createMarketingPlugin(options?: MarketingPluginOptions): PluginM
|
|
|
245
379
|
{
|
|
246
380
|
id: 'marketing.create-campaign',
|
|
247
381
|
name: 'createCampaign',
|
|
248
|
-
description:
|
|
382
|
+
description:
|
|
383
|
+
'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.',
|
|
249
384
|
icon: 'Plus',
|
|
250
385
|
mode: 'persist' as const,
|
|
386
|
+
limitKey: 'campaigns_active',
|
|
387
|
+
execution: { plane: 'server' as const, kind: 'rpc' as const, rpc: 'agent_marketing_create_campaign' },
|
|
251
388
|
category: 'Marketing',
|
|
252
389
|
parameters: {
|
|
253
390
|
type: 'object' as const,
|
|
254
391
|
properties: {
|
|
255
392
|
name: { type: 'string' as const, description: 'Campaign name' },
|
|
256
|
-
|
|
393
|
+
channel_key: { type: 'string' as const, description: "Acquisition channel key (e.g. 'instagram') or its label" },
|
|
394
|
+
status: { type: 'string' as const, enum: ['draft', 'active', 'paused'], description: 'Defaults to draft' },
|
|
395
|
+
starts_at: { type: 'string' as const, description: 'ISO start date-time (defaults to now)' },
|
|
396
|
+
ends_at: { type: 'string' as const, description: 'ISO end date-time (optional)' },
|
|
397
|
+
spend: { type: 'number' as const, description: 'Planned/committed spend (optional)' },
|
|
257
398
|
},
|
|
258
|
-
required: ['name', '
|
|
399
|
+
required: ['name', 'channel_key'],
|
|
259
400
|
},
|
|
401
|
+
suggestions: [{ label: 'Create an Instagram campaign for this month' }],
|
|
260
402
|
permission: { feature: 'marketing', action: 'create' as const },
|
|
261
403
|
},
|
|
262
404
|
],
|
|
@@ -289,6 +431,7 @@ export type {
|
|
|
289
431
|
} from './types'
|
|
290
432
|
export { MARKETING_PRESETS, type MarketingDomain } from './presets'
|
|
291
433
|
export { createMockMarketingProvider } from './data/mock'
|
|
434
|
+
export { createSupabaseMarketingProvider } from './data/supabase'
|
|
292
435
|
export type {
|
|
293
436
|
ContentPlannerProvider,
|
|
294
437
|
ContentPlan,
|