@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.
Files changed (88) hide show
  1. package/README.md +3 -3
  2. package/dist/{MarketingContext.d.ts → context.d.ts} +5 -3
  3. package/dist/context.d.ts.map +1 -0
  4. package/dist/data/mock.d.ts +1 -1
  5. package/dist/data/mock.d.ts.map +1 -1
  6. package/dist/{registries.d.ts → data/registries.d.ts} +1 -1
  7. package/dist/data/registries.d.ts.map +1 -0
  8. package/dist/index.d.ts +4 -4
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +17 -22
  11. package/dist/index.js.map +1 -1
  12. package/dist/lib/format.d.ts.map +1 -0
  13. package/dist/{presets.d.ts → lib/presets.d.ts} +1 -1
  14. package/dist/lib/presets.d.ts.map +1 -0
  15. package/dist/{MarketingPage.d.ts → views/MarketingPage.d.ts} +5 -5
  16. package/dist/views/MarketingPage.d.ts.map +1 -0
  17. package/dist/views/blog/BlogAdminView.d.ts.map +1 -1
  18. package/dist/views/content/ContentPlannerContext.d.ts +1 -1
  19. package/dist/views/content/ContentPlannerContext.d.ts.map +1 -1
  20. package/dist/views/dashboardWidgets.d.ts +1 -1
  21. package/dist/views/dashboardWidgets.d.ts.map +1 -1
  22. package/package.json +6 -9
  23. package/dist/MarketingContext.d.ts.map +0 -1
  24. package/dist/MarketingPage.d.ts.map +0 -1
  25. package/dist/format.d.ts.map +0 -1
  26. package/dist/presets.d.ts.map +0 -1
  27. package/dist/registries.d.ts.map +0 -1
  28. package/src/MarketingContext.tsx +0 -42
  29. package/src/MarketingPage.tsx +0 -139
  30. package/src/components/MarketingBits.tsx +0 -131
  31. package/src/components/icons.tsx +0 -36
  32. package/src/data/blogEntities.tsx +0 -91
  33. package/src/data/capability.test.ts +0 -92
  34. package/src/data/contentMedia.ts +0 -25
  35. package/src/data/contentMock.ts +0 -253
  36. package/src/data/contentSupabase.ts +0 -263
  37. package/src/data/contentTypes.ts +0 -138
  38. package/src/data/mock.ts +0 -308
  39. package/src/data/supabase.ts +0 -613
  40. package/src/data/tables.ts +0 -33
  41. package/src/data/types.ts +0 -97
  42. package/src/format.ts +0 -40
  43. package/src/index.ts +0 -457
  44. package/src/integrations/ranklayer/RANKLAYER.md +0 -40
  45. package/src/integrations/ranklayer/connectorDef.tsx +0 -102
  46. package/src/integrations/ranklayer/data/supabase.ts +0 -106
  47. package/src/integrations/ranklayer/types.ts +0 -16
  48. package/src/locales/en.ts +0 -192
  49. package/src/locales/index.ts +0 -7
  50. package/src/locales/pt-BR.ts +0 -192
  51. package/src/migrations/000_plg_rename.sql +0 -15
  52. package/src/migrations/001_content_planner.sql +0 -94
  53. package/src/migrations/002_multi_platform.sql +0 -25
  54. package/src/migrations/003_recording_ops.sql +0 -25
  55. package/src/migrations/004_ranklayer.sql +0 -50
  56. package/src/migrations/005_analytics_base.sql +0 -60
  57. package/src/migrations/006_analytics_views.sql +0 -68
  58. package/src/migrations/007_agent_rpcs.sql +0 -119
  59. package/src/migrations/008_landing_pages.sql +0 -112
  60. package/src/migrations/index.ts +0 -601
  61. package/src/presets.ts +0 -119
  62. package/src/registries.ts +0 -62
  63. package/src/schema/index.ts +0 -106
  64. package/src/store.ts +0 -102
  65. package/src/types.ts +0 -153
  66. package/src/views/CampaignComposer.tsx +0 -89
  67. package/src/views/CampaignsView.tsx +0 -59
  68. package/src/views/ChannelDetailView.tsx +0 -59
  69. package/src/views/ChannelsView.tsx +0 -46
  70. package/src/views/FunnelView.tsx +0 -56
  71. package/src/views/LandingPageComposer.tsx +0 -201
  72. package/src/views/LandingPagesView.tsx +0 -172
  73. package/src/views/OverviewView.tsx +0 -24
  74. package/src/views/SettingsView.tsx +0 -72
  75. package/src/views/blog/BlogAdminView.tsx +0 -99
  76. package/src/views/blog/BlogEntityForm.tsx +0 -101
  77. package/src/views/blog/useEntityCrud.ts +0 -50
  78. package/src/views/content/ContentPlannerContext.tsx +0 -13
  79. package/src/views/content/ContentView.tsx +0 -908
  80. package/src/views/content/PlanBriefView.tsx +0 -268
  81. package/src/views/content/PostPage.tsx +0 -370
  82. package/src/views/content/PostSocialPreview.tsx +0 -212
  83. package/src/views/content/RecordingChecklist.tsx +0 -145
  84. package/src/views/content/contentBits.tsx +0 -114
  85. package/src/views/content/contentStore.ts +0 -160
  86. package/src/views/content/templates.ts +0 -172
  87. package/src/views/dashboardWidgets.tsx +0 -173
  88. /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
- })
@@ -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
- }
@@ -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
- }