@fayz-ai/plugin-marketing 0.9.0-next.1 → 0.9.1
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/README.md +3 -3
- package/dist/{MarketingContext.d.ts → context.d.ts} +5 -3
- package/dist/context.d.ts.map +1 -0
- package/dist/data/mock.d.ts +1 -1
- package/dist/data/mock.d.ts.map +1 -1
- package/dist/{registries.d.ts → data/registries.d.ts} +1 -1
- package/dist/data/registries.d.ts.map +1 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -22
- package/dist/index.js.map +1 -1
- package/dist/lib/format.d.ts.map +1 -0
- package/dist/{presets.d.ts → lib/presets.d.ts} +1 -1
- package/dist/lib/presets.d.ts.map +1 -0
- package/dist/{MarketingPage.d.ts → views/MarketingPage.d.ts} +5 -5
- package/dist/views/MarketingPage.d.ts.map +1 -0
- package/dist/views/blog/BlogAdminView.d.ts.map +1 -1
- package/dist/views/content/ContentPlannerContext.d.ts +1 -1
- package/dist/views/content/ContentPlannerContext.d.ts.map +1 -1
- package/dist/views/dashboardWidgets.d.ts +1 -1
- package/dist/views/dashboardWidgets.d.ts.map +1 -1
- package/package.json +6 -9
- package/dist/MarketingContext.d.ts.map +0 -1
- package/dist/MarketingPage.d.ts.map +0 -1
- package/dist/format.d.ts.map +0 -1
- package/dist/presets.d.ts.map +0 -1
- package/dist/registries.d.ts.map +0 -1
- package/src/MarketingContext.tsx +0 -42
- package/src/MarketingPage.tsx +0 -139
- package/src/components/MarketingBits.tsx +0 -131
- package/src/components/icons.tsx +0 -36
- package/src/data/blogEntities.tsx +0 -91
- package/src/data/capability.test.ts +0 -92
- package/src/data/contentMedia.ts +0 -25
- package/src/data/contentMock.ts +0 -253
- package/src/data/contentSupabase.ts +0 -263
- package/src/data/contentTypes.ts +0 -138
- package/src/data/mock.ts +0 -308
- package/src/data/supabase.ts +0 -613
- package/src/data/tables.ts +0 -33
- package/src/data/types.ts +0 -97
- package/src/format.ts +0 -40
- package/src/index.ts +0 -457
- package/src/integrations/ranklayer/RANKLAYER.md +0 -40
- package/src/integrations/ranklayer/connectorDef.tsx +0 -102
- package/src/integrations/ranklayer/data/supabase.ts +0 -106
- package/src/integrations/ranklayer/types.ts +0 -16
- package/src/locales/en.ts +0 -192
- package/src/locales/index.ts +0 -7
- package/src/locales/pt-BR.ts +0 -192
- package/src/migrations/000_plg_rename.sql +0 -15
- package/src/migrations/001_content_planner.sql +0 -94
- package/src/migrations/002_multi_platform.sql +0 -25
- package/src/migrations/003_recording_ops.sql +0 -25
- package/src/migrations/004_ranklayer.sql +0 -50
- package/src/migrations/005_analytics_base.sql +0 -60
- package/src/migrations/006_analytics_views.sql +0 -68
- package/src/migrations/007_agent_rpcs.sql +0 -119
- package/src/migrations/008_landing_pages.sql +0 -112
- package/src/migrations/index.ts +0 -601
- package/src/presets.ts +0 -119
- package/src/registries.ts +0 -62
- package/src/schema/index.ts +0 -106
- package/src/store.ts +0 -102
- package/src/types.ts +0 -153
- package/src/views/CampaignComposer.tsx +0 -89
- package/src/views/CampaignsView.tsx +0 -59
- package/src/views/ChannelDetailView.tsx +0 -59
- package/src/views/ChannelsView.tsx +0 -46
- package/src/views/FunnelView.tsx +0 -56
- package/src/views/LandingPageComposer.tsx +0 -201
- package/src/views/LandingPagesView.tsx +0 -172
- package/src/views/OverviewView.tsx +0 -24
- package/src/views/SettingsView.tsx +0 -72
- package/src/views/blog/BlogAdminView.tsx +0 -99
- package/src/views/blog/BlogEntityForm.tsx +0 -101
- package/src/views/blog/useEntityCrud.ts +0 -50
- package/src/views/content/ContentPlannerContext.tsx +0 -13
- package/src/views/content/ContentView.tsx +0 -908
- package/src/views/content/PlanBriefView.tsx +0 -268
- package/src/views/content/PostPage.tsx +0 -370
- package/src/views/content/PostSocialPreview.tsx +0 -212
- package/src/views/content/RecordingChecklist.tsx +0 -145
- package/src/views/content/contentBits.tsx +0 -114
- package/src/views/content/contentStore.ts +0 -160
- package/src/views/content/templates.ts +0 -172
- package/src/views/dashboardWidgets.tsx +0 -173
- /package/dist/{format.d.ts → lib/format.d.ts} +0 -0
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
// Capability test for the marketing analytics data slice (mock provider) —
|
|
2
|
-
// template: plugin-tasks/src/data/capability.test.ts. The Supabase provider
|
|
3
|
-
// implements the same MarketingDataProvider interface against
|
|
4
|
-
// plg_marketing_channels/_campaigns + v_marketing_attribution.
|
|
5
|
-
import { describe, it, expect } from 'vitest'
|
|
6
|
-
import { createMockMarketingProvider } from './mock'
|
|
7
|
-
import { MARKETING_PRESETS } from '../presets'
|
|
8
|
-
|
|
9
|
-
function makeProvider() {
|
|
10
|
-
const preset = MARKETING_PRESETS.beauty
|
|
11
|
-
return createMockMarketingProvider({
|
|
12
|
-
channels: preset.channels,
|
|
13
|
-
conversion: preset.conversion,
|
|
14
|
-
domain: 'beauty',
|
|
15
|
-
})
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
describe('plugin-marketing capability · analytics slice (mock provider)', () => {
|
|
19
|
-
it('reports per-channel performance for every configured channel', async () => {
|
|
20
|
-
const provider = makeProvider()
|
|
21
|
-
const perf = await provider.channelPerformance({ range: '30d' })
|
|
22
|
-
expect(perf.map((c) => c.channelId).sort()).toEqual(
|
|
23
|
-
MARKETING_PRESETS.beauty.channels.map((c) => c.id).sort(),
|
|
24
|
-
)
|
|
25
|
-
for (const c of perf) {
|
|
26
|
-
expect(c.conversions).toBeGreaterThanOrEqual(0)
|
|
27
|
-
expect(c.cvr).toBeGreaterThanOrEqual(0)
|
|
28
|
-
}
|
|
29
|
-
})
|
|
30
|
-
|
|
31
|
-
it('overview aggregates the same channels the mix reports', async () => {
|
|
32
|
-
const provider = makeProvider()
|
|
33
|
-
const overview = await provider.overview({ range: '30d' })
|
|
34
|
-
expect(overview.channelMix.length).toBe(MARKETING_PRESETS.beauty.channels.length)
|
|
35
|
-
expect(overview.conversions).toBe(
|
|
36
|
-
overview.channelMix.reduce((s, c) => s + c.conversions, 0),
|
|
37
|
-
)
|
|
38
|
-
expect(overview.topChannelId).toBeTruthy()
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
it('persists a created campaign: it surfaces in list and get', async () => {
|
|
42
|
-
const provider = makeProvider()
|
|
43
|
-
const created = await provider.saveCampaign({
|
|
44
|
-
name: 'Campanha Teste',
|
|
45
|
-
channelId: 'instagram',
|
|
46
|
-
status: 'active',
|
|
47
|
-
start: new Date('2026-07-01').toISOString(),
|
|
48
|
-
spend: 150,
|
|
49
|
-
})
|
|
50
|
-
expect(created.id).toBeTruthy()
|
|
51
|
-
|
|
52
|
-
const listed = await provider.listCampaigns({ range: '30d' })
|
|
53
|
-
expect(listed.some((c) => c.id === created.id)).toBe(true)
|
|
54
|
-
|
|
55
|
-
const fetched = await provider.getCampaign(created.id)
|
|
56
|
-
expect(fetched?.name).toBe('Campanha Teste')
|
|
57
|
-
expect(fetched?.channelId).toBe('instagram')
|
|
58
|
-
})
|
|
59
|
-
|
|
60
|
-
it('round-trips update and delete', async () => {
|
|
61
|
-
const provider = makeProvider()
|
|
62
|
-
const created = await provider.saveCampaign({
|
|
63
|
-
name: 'temp',
|
|
64
|
-
channelId: 'google',
|
|
65
|
-
status: 'draft',
|
|
66
|
-
start: new Date('2026-07-01').toISOString(),
|
|
67
|
-
spend: 0,
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
const updated = await provider.saveCampaign({
|
|
71
|
-
id: created.id,
|
|
72
|
-
name: 'temp',
|
|
73
|
-
channelId: 'google',
|
|
74
|
-
status: 'paused',
|
|
75
|
-
start: created.start,
|
|
76
|
-
spend: 80,
|
|
77
|
-
})
|
|
78
|
-
expect(updated.status).toBe('paused')
|
|
79
|
-
|
|
80
|
-
await provider.deleteCampaign(created.id)
|
|
81
|
-
expect(await provider.getCampaign(created.id)).toBeNull()
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
it('funnel stages end on the vertical conversion label', async () => {
|
|
85
|
-
const provider = makeProvider()
|
|
86
|
-
const funnel = await provider.funnel({ range: '30d' })
|
|
87
|
-
expect(funnel.length).toBeGreaterThanOrEqual(2)
|
|
88
|
-
expect(funnel[funnel.length - 1].label).toBe(
|
|
89
|
-
MARKETING_PRESETS.beauty.conversion.labelPlural,
|
|
90
|
-
)
|
|
91
|
-
})
|
|
92
|
-
})
|
package/src/data/contentMedia.ts
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import { getSupabaseClientOptional, getActiveTenantId } from '@fayz-ai/core'
|
|
2
|
-
|
|
3
|
-
// ---------------------------------------------------------------------------
|
|
4
|
-
// Media asset upload for content posts (static art, covers). Public bucket
|
|
5
|
-
// `mkt-media` (created by migration 003); path content/{tenantId}/{postId}-{ts}.{ext}
|
|
6
|
-
// so re-uploads never collide and old URLs keep resolving until replaced.
|
|
7
|
-
// ---------------------------------------------------------------------------
|
|
8
|
-
|
|
9
|
-
export function canUploadMedia(): boolean {
|
|
10
|
-
return !!getSupabaseClientOptional()
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export async function uploadPostMedia(postId: string, file: File): Promise<string> {
|
|
14
|
-
const supabase = getSupabaseClientOptional() as any
|
|
15
|
-
if (!supabase) throw new Error('Supabase not initialized')
|
|
16
|
-
const tenantId = getActiveTenantId()
|
|
17
|
-
if (!tenantId) throw new Error('No active tenant')
|
|
18
|
-
|
|
19
|
-
const ext = file.name.split('.').pop()?.toLowerCase() ?? 'bin'
|
|
20
|
-
const path = `content/${tenantId}/${postId}-${Date.now()}.${ext}`
|
|
21
|
-
const { error } = await supabase.storage.from('mkt-media').upload(path, file, { upsert: true })
|
|
22
|
-
if (error) throw error
|
|
23
|
-
const { data } = supabase.storage.from('mkt-media').getPublicUrl(path)
|
|
24
|
-
return data.publicUrl as string
|
|
25
|
-
}
|
package/src/data/contentMock.ts
DELETED
|
@@ -1,253 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
ContentPlan,
|
|
3
|
-
ContentPlannerProvider,
|
|
4
|
-
ContentPost,
|
|
5
|
-
SocialAccount,
|
|
6
|
-
} from './contentTypes'
|
|
7
|
-
|
|
8
|
-
// ---------------------------------------------------------------------------
|
|
9
|
-
// Mock content-planner provider — in-memory CRUD seeded with a realistic
|
|
10
|
-
// 8-week clinic plan (2 reels + 1 static per week) so the surface demos well
|
|
11
|
-
// without Supabase. Same shape the beauty seed uses in production.
|
|
12
|
-
// ---------------------------------------------------------------------------
|
|
13
|
-
|
|
14
|
-
let counter = 0
|
|
15
|
-
const nextId = (prefix: string) => `${prefix}-${++counter}`
|
|
16
|
-
|
|
17
|
-
const SAMPLE_SCRIPT = `# 5 sinais de que sua queda capilar não é normal
|
|
18
|
-
|
|
19
|
-
**TEMA:** 5 SINAIS DE QUE SUA QUEDA CAPILAR NÃO É NORMAL
|
|
20
|
-
**Formato:** Reel · **Duração:** 45 a 60 segundos
|
|
21
|
-
|
|
22
|
-
---
|
|
23
|
-
|
|
24
|
-
## TAKE 1 – GANCHO (0s a 5s)
|
|
25
|
-
|
|
26
|
-
*Dra. andando até a câmera.*
|
|
27
|
-
|
|
28
|
-
**Texto na tela:** ⚠️ 5 sinais de alerta
|
|
29
|
-
|
|
30
|
-
**Fala:** "Seu cabelo está caindo e você não sabe se isso é normal? Existem sinais que merecem atenção."
|
|
31
|
-
|
|
32
|
-
---
|
|
33
|
-
|
|
34
|
-
## TAKE 2 – SINAL 1 (5s a 12s)
|
|
35
|
-
|
|
36
|
-
*Mostrar a Dra. segurando alguns fios de cabelo ou imagem ilustrativa.*
|
|
37
|
-
|
|
38
|
-
**Texto na tela:** Excesso de fios caindo.
|
|
39
|
-
|
|
40
|
-
**Fala:** "Primeiro sinal: você percebe muitos fios no travesseiro, no banho ou ao pentear os cabelos."
|
|
41
|
-
|
|
42
|
-
---
|
|
43
|
-
|
|
44
|
-
## TAKE 3 – CTA (40s a 60s)
|
|
45
|
-
|
|
46
|
-
**Fala:** "Se você se identificou com algum desses sinais, agende uma avaliação capilar."
|
|
47
|
-
|
|
48
|
-
**CTA:** Agende sua avaliação
|
|
49
|
-
`
|
|
50
|
-
|
|
51
|
-
const WEEK_TITLES: Array<[string, string]> = [
|
|
52
|
-
['5 sinais de que sua queda capilar não é normal', 'Antes e depois de tratamento capilar'],
|
|
53
|
-
['O que é DHT?', 'Tour pela clínica'],
|
|
54
|
-
['3 erros que aceleram a calvície', 'Depoimento de paciente'],
|
|
55
|
-
['Como funciona uma avaliação capilar', 'Mitos sobre queda de cabelo'],
|
|
56
|
-
['Por que seu tratamento anterior não funcionou?', 'Ozonioterapia para gordura localizada'],
|
|
57
|
-
['Diferença entre dermatologista e tricologista', 'Caso real de evolução'],
|
|
58
|
-
['Alopecia feminina explicada', 'Bastidores da clínica'],
|
|
59
|
-
['Quando procurar ajuda para queda capilar', 'Convite para avaliação gratuita'],
|
|
60
|
-
]
|
|
61
|
-
|
|
62
|
-
function buildSeed() {
|
|
63
|
-
const account: SocialAccount = {
|
|
64
|
-
id: nextId('acc'),
|
|
65
|
-
name: 'Conta demo',
|
|
66
|
-
handle: '@clinica.demo',
|
|
67
|
-
platforms: ['instagram'],
|
|
68
|
-
isActive: true,
|
|
69
|
-
}
|
|
70
|
-
const plan: ContentPlan = {
|
|
71
|
-
id: nextId('plan'),
|
|
72
|
-
accountId: account.id,
|
|
73
|
-
name: 'Plano de conteúdo — 8 semanas',
|
|
74
|
-
status: 'active',
|
|
75
|
-
weeksCount: 8,
|
|
76
|
-
objective: 'Atrair pacientes qualificados para avaliação capilar',
|
|
77
|
-
tone: 'Autoridade acessível — técnica, mas em linguagem simples',
|
|
78
|
-
pillars: ['Educação', 'Autoridade', 'Prova social', 'Bastidores', 'Conversão'],
|
|
79
|
-
formats: ['reel', 'static'],
|
|
80
|
-
weeklyFrequency: 3,
|
|
81
|
-
briefMd: '## Objetivo\n\nPosicionar a especialista como referência em saúde capilar e gerar agendamentos de avaliação.\n\n## Estratégia\n\n- 2 reels educativos/prova social por semana\n- 1 post estático por semana\n- CTA recorrente: agendar avaliação',
|
|
82
|
-
}
|
|
83
|
-
const posts: ContentPost[] = []
|
|
84
|
-
WEEK_TITLES.forEach(([reel1, reel2], i) => {
|
|
85
|
-
const week = i + 1
|
|
86
|
-
posts.push(
|
|
87
|
-
{
|
|
88
|
-
id: nextId('post'), planId: plan.id, weekNumber: week, position: 0,
|
|
89
|
-
title: reel1, format: 'reel', status: week === 1 ? 'script' : 'idea',
|
|
90
|
-
platforms: [], checklist: [], contentMd: week === 1 ? SAMPLE_SCRIPT : '',
|
|
91
|
-
},
|
|
92
|
-
{
|
|
93
|
-
id: nextId('post'), planId: plan.id, weekNumber: week, position: 1,
|
|
94
|
-
title: reel2, format: 'reel', status: 'idea', platforms: [], checklist: [], contentMd: '',
|
|
95
|
-
},
|
|
96
|
-
{
|
|
97
|
-
id: nextId('post'), planId: plan.id, weekNumber: week, position: 2,
|
|
98
|
-
title: 'Estático', format: 'static', status: 'idea', platforms: [], checklist: [], contentMd: '',
|
|
99
|
-
},
|
|
100
|
-
)
|
|
101
|
-
})
|
|
102
|
-
return { accounts: [account], plans: [plan], posts }
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
export function createMockContentPlannerProvider(): ContentPlannerProvider {
|
|
106
|
-
const db = buildSeed()
|
|
107
|
-
|
|
108
|
-
return {
|
|
109
|
-
async listAccounts() {
|
|
110
|
-
return [...db.accounts]
|
|
111
|
-
},
|
|
112
|
-
|
|
113
|
-
async saveAccount(input) {
|
|
114
|
-
if (input.id) {
|
|
115
|
-
const acc = db.accounts.find((a) => a.id === input.id)
|
|
116
|
-
if (!acc) throw new Error('Account not found')
|
|
117
|
-
Object.assign(acc, {
|
|
118
|
-
name: input.name ?? acc.name,
|
|
119
|
-
handle: input.handle ?? acc.handle,
|
|
120
|
-
platforms: input.platforms ?? acc.platforms,
|
|
121
|
-
isActive: input.isActive ?? acc.isActive,
|
|
122
|
-
})
|
|
123
|
-
return { ...acc }
|
|
124
|
-
}
|
|
125
|
-
const acc: SocialAccount = {
|
|
126
|
-
id: nextId('acc'),
|
|
127
|
-
name: input.name,
|
|
128
|
-
handle: input.handle,
|
|
129
|
-
platforms: input.platforms ?? ['instagram'],
|
|
130
|
-
isActive: input.isActive ?? true,
|
|
131
|
-
}
|
|
132
|
-
db.accounts.push(acc)
|
|
133
|
-
return { ...acc }
|
|
134
|
-
},
|
|
135
|
-
|
|
136
|
-
async deleteAccount(id) {
|
|
137
|
-
db.accounts = db.accounts.filter((a) => a.id !== id)
|
|
138
|
-
const planIds = new Set(db.plans.filter((p) => p.accountId === id).map((p) => p.id))
|
|
139
|
-
db.plans = db.plans.filter((p) => p.accountId !== id)
|
|
140
|
-
db.posts = db.posts.filter((p) => !planIds.has(p.planId))
|
|
141
|
-
},
|
|
142
|
-
|
|
143
|
-
async listPlans(accountId) {
|
|
144
|
-
return db.plans.filter((p) => p.accountId === accountId).map((p) => ({ ...p }))
|
|
145
|
-
},
|
|
146
|
-
|
|
147
|
-
async getPlan(id) {
|
|
148
|
-
const plan = db.plans.find((p) => p.id === id)
|
|
149
|
-
return plan ? { ...plan } : null
|
|
150
|
-
},
|
|
151
|
-
|
|
152
|
-
async savePlan(input) {
|
|
153
|
-
if (input.id) {
|
|
154
|
-
const plan = db.plans.find((p) => p.id === input.id)
|
|
155
|
-
if (!plan) throw new Error('Plan not found')
|
|
156
|
-
Object.assign(plan, {
|
|
157
|
-
...(input.name !== undefined && { name: input.name }),
|
|
158
|
-
...(input.status !== undefined && { status: input.status }),
|
|
159
|
-
...(input.weeksCount !== undefined && { weeksCount: input.weeksCount }),
|
|
160
|
-
...(input.startDate !== undefined && { startDate: input.startDate ?? undefined }),
|
|
161
|
-
...(input.objective !== undefined && { objective: input.objective }),
|
|
162
|
-
...(input.tone !== undefined && { tone: input.tone }),
|
|
163
|
-
...(input.pillars !== undefined && { pillars: input.pillars }),
|
|
164
|
-
...(input.formats !== undefined && { formats: input.formats }),
|
|
165
|
-
...(input.weeklyFrequency !== undefined && { weeklyFrequency: input.weeklyFrequency }),
|
|
166
|
-
...(input.briefMd !== undefined && { briefMd: input.briefMd }),
|
|
167
|
-
})
|
|
168
|
-
return { ...plan }
|
|
169
|
-
}
|
|
170
|
-
if (!input.accountId) throw new Error('accountId is required')
|
|
171
|
-
const plan: ContentPlan = {
|
|
172
|
-
id: nextId('plan'),
|
|
173
|
-
accountId: input.accountId,
|
|
174
|
-
name: input.name ?? 'Novo plano',
|
|
175
|
-
status: input.status ?? 'draft',
|
|
176
|
-
weeksCount: input.weeksCount ?? 4,
|
|
177
|
-
startDate: input.startDate ?? undefined,
|
|
178
|
-
objective: input.objective,
|
|
179
|
-
tone: input.tone,
|
|
180
|
-
pillars: input.pillars ?? [],
|
|
181
|
-
formats: input.formats ?? ['reel', 'static'],
|
|
182
|
-
weeklyFrequency: input.weeklyFrequency ?? 3,
|
|
183
|
-
briefMd: input.briefMd ?? '',
|
|
184
|
-
}
|
|
185
|
-
db.plans.push(plan)
|
|
186
|
-
return { ...plan }
|
|
187
|
-
},
|
|
188
|
-
|
|
189
|
-
async deletePlan(id) {
|
|
190
|
-
db.plans = db.plans.filter((p) => p.id !== id)
|
|
191
|
-
db.posts = db.posts.filter((p) => p.planId !== id)
|
|
192
|
-
},
|
|
193
|
-
|
|
194
|
-
async listPosts(planId) {
|
|
195
|
-
return db.posts
|
|
196
|
-
.filter((p) => p.planId === planId)
|
|
197
|
-
.sort((a, b) => a.weekNumber - b.weekNumber || a.position - b.position)
|
|
198
|
-
.map((p) => ({ ...p }))
|
|
199
|
-
},
|
|
200
|
-
|
|
201
|
-
async getPost(id) {
|
|
202
|
-
const post = db.posts.find((p) => p.id === id)
|
|
203
|
-
return post ? { ...post } : null
|
|
204
|
-
},
|
|
205
|
-
|
|
206
|
-
async savePost(input) {
|
|
207
|
-
if (input.id) {
|
|
208
|
-
const post = db.posts.find((p) => p.id === input.id)
|
|
209
|
-
if (!post) throw new Error('Post not found')
|
|
210
|
-
Object.assign(post, {
|
|
211
|
-
...(input.weekNumber !== undefined && { weekNumber: input.weekNumber }),
|
|
212
|
-
...(input.position !== undefined && { position: input.position }),
|
|
213
|
-
...(input.title !== undefined && { title: input.title }),
|
|
214
|
-
...(input.format !== undefined && { format: input.format }),
|
|
215
|
-
...(input.status !== undefined && { status: input.status }),
|
|
216
|
-
...(input.scheduledDate !== undefined && { scheduledDate: input.scheduledDate ?? undefined }),
|
|
217
|
-
...(input.platforms !== undefined && { platforms: input.platforms }),
|
|
218
|
-
...(input.checklist !== undefined && { checklist: input.checklist }),
|
|
219
|
-
...(input.mediaUrl !== undefined && { mediaUrl: input.mediaUrl ?? undefined }),
|
|
220
|
-
...(input.hook !== undefined && { hook: input.hook }),
|
|
221
|
-
...(input.cta !== undefined && { cta: input.cta }),
|
|
222
|
-
...(input.contentMd !== undefined && { contentMd: input.contentMd }),
|
|
223
|
-
})
|
|
224
|
-
return { ...post }
|
|
225
|
-
}
|
|
226
|
-
if (!input.planId) throw new Error('planId is required')
|
|
227
|
-
const week = input.weekNumber ?? 1
|
|
228
|
-
const siblings = db.posts.filter((p) => p.planId === input.planId && p.weekNumber === week)
|
|
229
|
-
const post: ContentPost = {
|
|
230
|
-
id: nextId('post'),
|
|
231
|
-
planId: input.planId,
|
|
232
|
-
weekNumber: week,
|
|
233
|
-
position: input.position ?? siblings.length,
|
|
234
|
-
title: input.title ?? '',
|
|
235
|
-
format: input.format ?? 'reel',
|
|
236
|
-
status: input.status ?? 'idea',
|
|
237
|
-
scheduledDate: input.scheduledDate ?? undefined,
|
|
238
|
-
platforms: input.platforms ?? [],
|
|
239
|
-
checklist: input.checklist ?? [],
|
|
240
|
-
mediaUrl: input.mediaUrl ?? undefined,
|
|
241
|
-
hook: input.hook,
|
|
242
|
-
cta: input.cta,
|
|
243
|
-
contentMd: input.contentMd ?? '',
|
|
244
|
-
}
|
|
245
|
-
db.posts.push(post)
|
|
246
|
-
return { ...post }
|
|
247
|
-
},
|
|
248
|
-
|
|
249
|
-
async deletePost(id) {
|
|
250
|
-
db.posts = db.posts.filter((p) => p.id !== id)
|
|
251
|
-
},
|
|
252
|
-
}
|
|
253
|
-
}
|
|
@@ -1,263 +0,0 @@
|
|
|
1
|
-
import { getSupabaseClientOptional, getActiveTenantId } from '@fayz-ai/core'
|
|
2
|
-
import type {
|
|
3
|
-
ContentPlan,
|
|
4
|
-
ContentPlannerProvider,
|
|
5
|
-
ContentPost,
|
|
6
|
-
SocialAccount,
|
|
7
|
-
} from './contentTypes'
|
|
8
|
-
import { T } from './tables'
|
|
9
|
-
|
|
10
|
-
// ---------------------------------------------------------------------------
|
|
11
|
-
// Supabase content-planner provider. Tables: plg_marketing_social_accounts,
|
|
12
|
-
// plg_marketing_content_plans, plg_marketing_content_posts (see src/migrations/001_content_planner.sql).
|
|
13
|
-
// RLS scopes rows to the user's tenants; we still filter tenant_id explicitly
|
|
14
|
-
// on reads so multi-tenant sessions see only the active tenant.
|
|
15
|
-
// ---------------------------------------------------------------------------
|
|
16
|
-
|
|
17
|
-
function getClient() {
|
|
18
|
-
const supabase = getSupabaseClientOptional() as any
|
|
19
|
-
if (!supabase) throw new Error('Supabase not initialized')
|
|
20
|
-
return supabase
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function requireTenantId(): string {
|
|
24
|
-
const tenantId = getActiveTenantId()
|
|
25
|
-
if (!tenantId) throw new Error('No active tenant')
|
|
26
|
-
return tenantId
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function snakeToCamel(obj: Record<string, unknown>): Record<string, unknown> {
|
|
30
|
-
const result: Record<string, unknown> = {}
|
|
31
|
-
for (const [key, value] of Object.entries(obj)) {
|
|
32
|
-
result[key.replace(/_([a-z])/g, (_, c) => c.toUpperCase())] = value ?? undefined
|
|
33
|
-
}
|
|
34
|
-
return result
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function toAccount(row: Record<string, unknown>): SocialAccount {
|
|
38
|
-
const account = snakeToCamel(row) as unknown as SocialAccount
|
|
39
|
-
if (!Array.isArray(account.platforms)) account.platforms = []
|
|
40
|
-
return account
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function toPlan(row: Record<string, unknown>): ContentPlan {
|
|
44
|
-
const plan = snakeToCamel(row) as unknown as ContentPlan
|
|
45
|
-
if (!Array.isArray(plan.pillars)) plan.pillars = []
|
|
46
|
-
if (!Array.isArray(plan.formats)) plan.formats = []
|
|
47
|
-
plan.briefMd = plan.briefMd ?? ''
|
|
48
|
-
return plan
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function toPost(row: Record<string, unknown>): ContentPost {
|
|
52
|
-
const post = snakeToCamel(row) as unknown as ContentPost
|
|
53
|
-
post.contentMd = post.contentMd ?? ''
|
|
54
|
-
if (!Array.isArray(post.platforms)) post.platforms = []
|
|
55
|
-
if (!Array.isArray(post.checklist)) post.checklist = []
|
|
56
|
-
return post
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export function createSupabaseContentPlannerProvider(): ContentPlannerProvider {
|
|
60
|
-
return {
|
|
61
|
-
async listAccounts() {
|
|
62
|
-
const tenantId = getActiveTenantId()
|
|
63
|
-
if (!tenantId) return []
|
|
64
|
-
const { data } = await getClient()
|
|
65
|
-
.from(T.socialAccounts)
|
|
66
|
-
.select('*')
|
|
67
|
-
.eq('tenant_id', tenantId)
|
|
68
|
-
.eq('is_active', true)
|
|
69
|
-
.order('created_at', { ascending: true })
|
|
70
|
-
return (data ?? []).map(toAccount)
|
|
71
|
-
},
|
|
72
|
-
|
|
73
|
-
async saveAccount(input) {
|
|
74
|
-
if (input.id) {
|
|
75
|
-
const payload: Record<string, unknown> = {}
|
|
76
|
-
if (input.name !== undefined) payload.name = input.name
|
|
77
|
-
if (input.handle !== undefined) payload.handle = input.handle
|
|
78
|
-
if (input.platforms !== undefined) payload.platforms = input.platforms
|
|
79
|
-
if (input.isActive !== undefined) payload.is_active = input.isActive
|
|
80
|
-
const { data, error } = await getClient()
|
|
81
|
-
.from(T.socialAccounts)
|
|
82
|
-
.update(payload)
|
|
83
|
-
.eq('id', input.id)
|
|
84
|
-
.select('*')
|
|
85
|
-
.single()
|
|
86
|
-
if (error) throw error
|
|
87
|
-
return toAccount(data)
|
|
88
|
-
}
|
|
89
|
-
const { data, error } = await getClient()
|
|
90
|
-
.from(T.socialAccounts)
|
|
91
|
-
.insert({
|
|
92
|
-
tenant_id: requireTenantId(),
|
|
93
|
-
name: input.name,
|
|
94
|
-
handle: input.handle ?? null,
|
|
95
|
-
platforms: input.platforms ?? ['instagram'],
|
|
96
|
-
is_active: input.isActive ?? true,
|
|
97
|
-
})
|
|
98
|
-
.select('*')
|
|
99
|
-
.single()
|
|
100
|
-
if (error) throw error
|
|
101
|
-
return toAccount(data)
|
|
102
|
-
},
|
|
103
|
-
|
|
104
|
-
async deleteAccount(id) {
|
|
105
|
-
const { error } = await getClient().from(T.socialAccounts).delete().eq('id', id)
|
|
106
|
-
if (error) throw error
|
|
107
|
-
},
|
|
108
|
-
|
|
109
|
-
async listPlans(accountId) {
|
|
110
|
-
const tenantId = getActiveTenantId()
|
|
111
|
-
if (!tenantId) return []
|
|
112
|
-
const { data } = await getClient()
|
|
113
|
-
.from(T.contentPlans)
|
|
114
|
-
.select('*')
|
|
115
|
-
.eq('tenant_id', tenantId)
|
|
116
|
-
.eq('account_id', accountId)
|
|
117
|
-
.order('created_at', { ascending: false })
|
|
118
|
-
return (data ?? []).map(toPlan)
|
|
119
|
-
},
|
|
120
|
-
|
|
121
|
-
async getPlan(id) {
|
|
122
|
-
const { data } = await getClient()
|
|
123
|
-
.from(T.contentPlans)
|
|
124
|
-
.select('*')
|
|
125
|
-
.eq('id', id)
|
|
126
|
-
.maybeSingle()
|
|
127
|
-
return data ? toPlan(data) : null
|
|
128
|
-
},
|
|
129
|
-
|
|
130
|
-
async savePlan(input) {
|
|
131
|
-
if (input.id) {
|
|
132
|
-
const payload: Record<string, unknown> = { updated_at: new Date().toISOString() }
|
|
133
|
-
if (input.name !== undefined) payload.name = input.name
|
|
134
|
-
if (input.status !== undefined) payload.status = input.status
|
|
135
|
-
if (input.weeksCount !== undefined) payload.weeks_count = input.weeksCount
|
|
136
|
-
if (input.startDate !== undefined) payload.start_date = input.startDate
|
|
137
|
-
if (input.objective !== undefined) payload.objective = input.objective
|
|
138
|
-
if (input.tone !== undefined) payload.tone = input.tone
|
|
139
|
-
if (input.pillars !== undefined) payload.pillars = input.pillars
|
|
140
|
-
if (input.formats !== undefined) payload.formats = input.formats
|
|
141
|
-
if (input.weeklyFrequency !== undefined) payload.weekly_frequency = input.weeklyFrequency
|
|
142
|
-
if (input.briefMd !== undefined) payload.brief_md = input.briefMd
|
|
143
|
-
const { data, error } = await getClient()
|
|
144
|
-
.from(T.contentPlans)
|
|
145
|
-
.update(payload)
|
|
146
|
-
.eq('id', input.id)
|
|
147
|
-
.select('*')
|
|
148
|
-
.single()
|
|
149
|
-
if (error) throw error
|
|
150
|
-
return toPlan(data)
|
|
151
|
-
}
|
|
152
|
-
if (!input.accountId) throw new Error('accountId is required')
|
|
153
|
-
const { data, error } = await getClient()
|
|
154
|
-
.from(T.contentPlans)
|
|
155
|
-
.insert({
|
|
156
|
-
tenant_id: requireTenantId(),
|
|
157
|
-
account_id: input.accountId,
|
|
158
|
-
name: input.name ?? 'Novo plano',
|
|
159
|
-
status: input.status ?? 'draft',
|
|
160
|
-
weeks_count: input.weeksCount ?? 4,
|
|
161
|
-
start_date: input.startDate ?? null,
|
|
162
|
-
objective: input.objective ?? null,
|
|
163
|
-
tone: input.tone ?? null,
|
|
164
|
-
pillars: input.pillars ?? [],
|
|
165
|
-
formats: input.formats ?? ['reel', 'static'],
|
|
166
|
-
weekly_frequency: input.weeklyFrequency ?? 3,
|
|
167
|
-
brief_md: input.briefMd ?? '',
|
|
168
|
-
})
|
|
169
|
-
.select('*')
|
|
170
|
-
.single()
|
|
171
|
-
if (error) throw error
|
|
172
|
-
return toPlan(data)
|
|
173
|
-
},
|
|
174
|
-
|
|
175
|
-
async deletePlan(id) {
|
|
176
|
-
await getClient().from(T.contentPlans).delete().eq('id', id)
|
|
177
|
-
},
|
|
178
|
-
|
|
179
|
-
async listPosts(planId) {
|
|
180
|
-
const { data } = await getClient()
|
|
181
|
-
.from(T.contentPosts)
|
|
182
|
-
.select('*')
|
|
183
|
-
.eq('plan_id', planId)
|
|
184
|
-
.order('week_number', { ascending: true })
|
|
185
|
-
.order('position', { ascending: true })
|
|
186
|
-
return (data ?? []).map(toPost)
|
|
187
|
-
},
|
|
188
|
-
|
|
189
|
-
async getPost(id) {
|
|
190
|
-
const { data } = await getClient()
|
|
191
|
-
.from(T.contentPosts)
|
|
192
|
-
.select('*')
|
|
193
|
-
.eq('id', id)
|
|
194
|
-
.maybeSingle()
|
|
195
|
-
return data ? toPost(data) : null
|
|
196
|
-
},
|
|
197
|
-
|
|
198
|
-
async savePost(input) {
|
|
199
|
-
if (input.id) {
|
|
200
|
-
const payload: Record<string, unknown> = { updated_at: new Date().toISOString() }
|
|
201
|
-
if (input.weekNumber !== undefined) payload.week_number = input.weekNumber
|
|
202
|
-
if (input.position !== undefined) payload.position = input.position
|
|
203
|
-
if (input.title !== undefined) payload.title = input.title
|
|
204
|
-
if (input.format !== undefined) payload.format = input.format
|
|
205
|
-
if (input.status !== undefined) payload.status = input.status
|
|
206
|
-
if (input.scheduledDate !== undefined) payload.scheduled_date = input.scheduledDate
|
|
207
|
-
if (input.platforms !== undefined) payload.platforms = input.platforms
|
|
208
|
-
if (input.checklist !== undefined) payload.checklist = input.checklist
|
|
209
|
-
if (input.mediaUrl !== undefined) payload.media_url = input.mediaUrl
|
|
210
|
-
if (input.hook !== undefined) payload.hook = input.hook
|
|
211
|
-
if (input.cta !== undefined) payload.cta = input.cta
|
|
212
|
-
if (input.contentMd !== undefined) payload.content_md = input.contentMd
|
|
213
|
-
const { data, error } = await getClient()
|
|
214
|
-
.from(T.contentPosts)
|
|
215
|
-
.update(payload)
|
|
216
|
-
.eq('id', input.id)
|
|
217
|
-
.select('*')
|
|
218
|
-
.single()
|
|
219
|
-
if (error) throw error
|
|
220
|
-
return toPost(data)
|
|
221
|
-
}
|
|
222
|
-
if (!input.planId) throw new Error('planId is required')
|
|
223
|
-
const week = input.weekNumber ?? 1
|
|
224
|
-
|
|
225
|
-
// Next position within the (plan, week) group.
|
|
226
|
-
const { data: maxData } = await getClient()
|
|
227
|
-
.from(T.contentPosts)
|
|
228
|
-
.select('position')
|
|
229
|
-
.eq('plan_id', input.planId)
|
|
230
|
-
.eq('week_number', week)
|
|
231
|
-
.order('position', { ascending: false })
|
|
232
|
-
.limit(1)
|
|
233
|
-
const maxPos = maxData?.[0]?.position ?? -1
|
|
234
|
-
|
|
235
|
-
const { data, error } = await getClient()
|
|
236
|
-
.from(T.contentPosts)
|
|
237
|
-
.insert({
|
|
238
|
-
tenant_id: requireTenantId(),
|
|
239
|
-
plan_id: input.planId,
|
|
240
|
-
week_number: week,
|
|
241
|
-
position: input.position ?? maxPos + 1,
|
|
242
|
-
title: input.title ?? '',
|
|
243
|
-
format: input.format ?? 'reel',
|
|
244
|
-
status: input.status ?? 'idea',
|
|
245
|
-
scheduled_date: input.scheduledDate ?? null,
|
|
246
|
-
platforms: input.platforms ?? [],
|
|
247
|
-
checklist: input.checklist ?? [],
|
|
248
|
-
media_url: input.mediaUrl ?? null,
|
|
249
|
-
hook: input.hook ?? null,
|
|
250
|
-
cta: input.cta ?? null,
|
|
251
|
-
content_md: input.contentMd ?? '',
|
|
252
|
-
})
|
|
253
|
-
.select('*')
|
|
254
|
-
.single()
|
|
255
|
-
if (error) throw error
|
|
256
|
-
return toPost(data)
|
|
257
|
-
},
|
|
258
|
-
|
|
259
|
-
async deletePost(id) {
|
|
260
|
-
await getClient().from(T.contentPosts).delete().eq('id', id)
|
|
261
|
-
},
|
|
262
|
-
}
|
|
263
|
-
}
|