@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.
Files changed (74) hide show
  1. package/dist/MarketingContext.d.ts +2 -0
  2. package/dist/MarketingContext.d.ts.map +1 -1
  3. package/dist/MarketingPage.d.ts.map +1 -1
  4. package/dist/data/blogEntities.d.ts +28 -0
  5. package/dist/data/blogEntities.d.ts.map +1 -0
  6. package/dist/data/capability.test.d.ts +2 -0
  7. package/dist/data/capability.test.d.ts.map +1 -0
  8. package/dist/data/supabase.d.ts +10 -0
  9. package/dist/data/supabase.d.ts.map +1 -0
  10. package/dist/data/tables.d.ts +7 -0
  11. package/dist/data/tables.d.ts.map +1 -1
  12. package/dist/data/types.d.ts +2 -0
  13. package/dist/data/types.d.ts.map +1 -1
  14. package/dist/index.cjs +1239 -87
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.ts +1 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +1245 -94
  19. package/dist/index.js.map +1 -1
  20. package/dist/integrations/ranklayer/connectorDef.d.ts +3 -0
  21. package/dist/integrations/ranklayer/connectorDef.d.ts.map +1 -0
  22. package/dist/integrations/ranklayer/data/supabase.d.ts +16 -0
  23. package/dist/integrations/ranklayer/data/supabase.d.ts.map +1 -0
  24. package/dist/integrations/ranklayer/types.d.ts +16 -0
  25. package/dist/integrations/ranklayer/types.d.ts.map +1 -0
  26. package/dist/migrations/index.d.ts +4 -0
  27. package/dist/migrations/index.d.ts.map +1 -1
  28. package/dist/presets.d.ts +2 -0
  29. package/dist/presets.d.ts.map +1 -1
  30. package/dist/registries.d.ts +3 -3
  31. package/dist/registries.d.ts.map +1 -1
  32. package/dist/schema/index.d.ts +354 -0
  33. package/dist/schema/index.d.ts.map +1 -1
  34. package/dist/schema.cjs +30 -0
  35. package/dist/schema.cjs.map +1 -1
  36. package/dist/schema.js +30 -2
  37. package/dist/schema.js.map +1 -1
  38. package/dist/store.d.ts +2 -0
  39. package/dist/store.d.ts.map +1 -1
  40. package/dist/views/CampaignsView.d.ts.map +1 -1
  41. package/dist/views/blog/BlogAdminView.d.ts +5 -0
  42. package/dist/views/blog/BlogAdminView.d.ts.map +1 -0
  43. package/dist/views/blog/BlogEntityForm.d.ts +11 -0
  44. package/dist/views/blog/BlogEntityForm.d.ts.map +1 -0
  45. package/dist/views/blog/useEntityCrud.d.ts +29 -0
  46. package/dist/views/blog/useEntityCrud.d.ts.map +1 -0
  47. package/dist/views/content/ContentView.d.ts.map +1 -1
  48. package/package.json +8 -6
  49. package/src/MarketingContext.tsx +2 -0
  50. package/src/MarketingPage.tsx +27 -2
  51. package/src/data/blogEntities.tsx +91 -0
  52. package/src/data/capability.test.ts +92 -0
  53. package/src/data/supabase.ts +401 -0
  54. package/src/data/tables.ts +9 -0
  55. package/src/data/types.ts +2 -0
  56. package/src/index.ts +153 -10
  57. package/src/integrations/ranklayer/RANKLAYER.md +40 -0
  58. package/src/integrations/ranklayer/connectorDef.tsx +98 -0
  59. package/src/integrations/ranklayer/data/supabase.ts +106 -0
  60. package/src/integrations/ranklayer/types.ts +16 -0
  61. package/src/migrations/004_ranklayer.sql +50 -0
  62. package/src/migrations/005_analytics_base.sql +60 -0
  63. package/src/migrations/006_analytics_views.sql +68 -0
  64. package/src/migrations/007_agent_rpcs.sql +119 -0
  65. package/src/migrations/index.ts +310 -1
  66. package/src/presets.ts +5 -3
  67. package/src/registries.ts +18 -6
  68. package/src/schema/index.ts +38 -1
  69. package/src/store.ts +21 -8
  70. package/src/views/CampaignsView.tsx +4 -1
  71. package/src/views/blog/BlogAdminView.tsx +99 -0
  72. package/src/views/blog/BlogEntityForm.tsx +101 -0
  73. package/src/views/blog/useEntityCrud.ts +50 -0
  74. package/src/views/content/ContentView.tsx +8 -1
@@ -1,4 +1,4 @@
1
- // AUTO-GENERATED from 000_plg_rename.sql, 001_content_planner.sql, 002_multi_platform.sql, 003_recording_ops.sql — regenerate with scripts/embed-migrations.mjs
1
+ // AUTO-GENERATED from 000_plg_rename.sql, 001_content_planner.sql, 002_multi_platform.sql, 003_recording_ops.sql, 004_ranklayer.sql, 005_analytics_base.sql, 006_analytics_views.sql, 007_agent_rpcs.sql — regenerate with scripts/embed-migrations.mjs
2
2
  // SQL files are the source of truth; this inline copy lets the manifest declare
3
3
  // migrations as data. Do not edit by hand — run the embed script instead.
4
4
 
@@ -169,9 +169,318 @@ CREATE POLICY plg_marketing_media_update ON storage.objects FOR UPDATE TO authen
169
169
  CREATE POLICY plg_marketing_media_delete ON storage.objects FOR DELETE TO authenticated USING (bucket_id = 'mkt-media');
170
170
  `
171
171
 
172
+ export const MIGRATION_004_RANKLAYER = `-- Marketing Plugin: RankLayer integration (SEO content publishing).
173
+ -- Control-plane state for the RankLayer connector rendered in Marketing →
174
+ -- Integrações. Prefix: plg_marketing_ranklayer_. Scaffold: the real sync
175
+ -- (edge function + RankLayer API calls) lands via an external PR — see
176
+ -- src/integrations/ranklayer/RANKLAYER.md.
177
+
178
+ CREATE TABLE IF NOT EXISTS public.plg_marketing_ranklayer_integrations (
179
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
180
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
181
+ api_key text,
182
+ site_domain text,
183
+ active boolean NOT NULL DEFAULT true,
184
+ last_sync_at timestamptz,
185
+ created_at timestamptz NOT NULL DEFAULT now(),
186
+ updated_at timestamptz NOT NULL DEFAULT now()
187
+ );
188
+ ALTER TABLE public.plg_marketing_ranklayer_integrations ENABLE ROW LEVEL SECURITY;
189
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_plg_marketing_ranklayer_integrations_tenant
190
+ ON public.plg_marketing_ranklayer_integrations(tenant_id);
191
+
192
+ CREATE TABLE IF NOT EXISTS public.plg_marketing_ranklayer_sync_log (
193
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
194
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
195
+ integration_id uuid REFERENCES public.plg_marketing_ranklayer_integrations(id) ON DELETE CASCADE,
196
+ status text NOT NULL DEFAULT 'success' CHECK (status IN ('success', 'partial', 'error')),
197
+ message text,
198
+ fetched integer NOT NULL DEFAULT 0,
199
+ written integer NOT NULL DEFAULT 0,
200
+ created_at timestamptz NOT NULL DEFAULT now()
201
+ );
202
+ ALTER TABLE public.plg_marketing_ranklayer_sync_log ENABLE ROW LEVEL SECURITY;
203
+ CREATE INDEX IF NOT EXISTS idx_plg_marketing_ranklayer_sync_log_tenant
204
+ ON public.plg_marketing_ranklayer_sync_log(tenant_id, created_at DESC);
205
+
206
+ -- RLS: authenticated tenant CRUD
207
+ DROP POLICY IF EXISTS plg_marketing_ranklayer_integrations_select ON public.plg_marketing_ranklayer_integrations;
208
+ DROP POLICY IF EXISTS plg_marketing_ranklayer_integrations_insert ON public.plg_marketing_ranklayer_integrations;
209
+ DROP POLICY IF EXISTS plg_marketing_ranklayer_integrations_update ON public.plg_marketing_ranklayer_integrations;
210
+ DROP POLICY IF EXISTS plg_marketing_ranklayer_integrations_delete ON public.plg_marketing_ranklayer_integrations;
211
+ CREATE POLICY plg_marketing_ranklayer_integrations_select ON public.plg_marketing_ranklayer_integrations FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
212
+ CREATE POLICY plg_marketing_ranklayer_integrations_insert ON public.plg_marketing_ranklayer_integrations FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
213
+ CREATE POLICY plg_marketing_ranklayer_integrations_update ON public.plg_marketing_ranklayer_integrations FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
214
+ CREATE POLICY plg_marketing_ranklayer_integrations_delete ON public.plg_marketing_ranklayer_integrations FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
215
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_marketing_ranklayer_integrations TO authenticated;
216
+
217
+ DROP POLICY IF EXISTS plg_marketing_ranklayer_sync_log_select ON public.plg_marketing_ranklayer_sync_log;
218
+ DROP POLICY IF EXISTS plg_marketing_ranklayer_sync_log_insert ON public.plg_marketing_ranklayer_sync_log;
219
+ CREATE POLICY plg_marketing_ranklayer_sync_log_select ON public.plg_marketing_ranklayer_sync_log FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
220
+ CREATE POLICY plg_marketing_ranklayer_sync_log_insert ON public.plg_marketing_ranklayer_sync_log FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
221
+ GRANT SELECT, INSERT ON public.plg_marketing_ranklayer_sync_log TO authenticated;
222
+ `
223
+
224
+ export const MIGRATION_005_ANALYTICS_BASE = `-- Marketing Plugin: analytics base tables (channels + campaigns).
225
+ -- Prefix: plg_marketing_
226
+ -- Channels are tenant-owned rows lazily seeded from the app's domain preset
227
+ -- (channel_key = preset/config id). Campaign performance is DERIVED at read
228
+ -- time from v_marketing_attribution (006); only spend is stored.
229
+ -- Canonical RLS form: tenant_id IN (SELECT public.user_tenant_ids()).
230
+
231
+ CREATE TABLE IF NOT EXISTS public.plg_marketing_channels (
232
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
233
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
234
+ channel_key text NOT NULL,
235
+ label text NOT NULL,
236
+ icon text,
237
+ kind text NOT NULL DEFAULT 'organic'
238
+ CHECK (kind IN ('paid', 'organic', 'social', 'referral', 'direct', 'outbound')),
239
+ is_active boolean NOT NULL DEFAULT true,
240
+ monthly_spend numeric NOT NULL DEFAULT 0,
241
+ created_at timestamptz NOT NULL DEFAULT now(),
242
+ updated_at timestamptz NOT NULL DEFAULT now()
243
+ );
244
+ CREATE INDEX IF NOT EXISTS idx_plg_marketing_channels_tenant ON public.plg_marketing_channels(tenant_id);
245
+ CREATE UNIQUE INDEX IF NOT EXISTS u_plg_marketing_channels_tenant_key ON public.plg_marketing_channels(tenant_id, channel_key);
246
+
247
+ CREATE TABLE IF NOT EXISTS public.plg_marketing_campaigns (
248
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
249
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
250
+ name text NOT NULL,
251
+ channel_key text NOT NULL,
252
+ status text NOT NULL DEFAULT 'draft'
253
+ CHECK (status IN ('active', 'paused', 'ended', 'draft')),
254
+ starts_at timestamptz,
255
+ ends_at timestamptz,
256
+ spend numeric NOT NULL DEFAULT 0,
257
+ created_at timestamptz NOT NULL DEFAULT now(),
258
+ updated_at timestamptz NOT NULL DEFAULT now()
259
+ );
260
+ CREATE INDEX IF NOT EXISTS idx_plg_marketing_campaigns_tenant ON public.plg_marketing_campaigns(tenant_id);
261
+ CREATE INDEX IF NOT EXISTS idx_plg_marketing_campaigns_channel ON public.plg_marketing_campaigns(tenant_id, channel_key);
262
+
263
+ DO $$
264
+ DECLARE t text;
265
+ BEGIN
266
+ FOR t IN SELECT unnest(ARRAY['plg_marketing_channels','plg_marketing_campaigns'])
267
+ LOOP
268
+ EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', t);
269
+ EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON public.%I TO authenticated', t);
270
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_select') THEN
271
+ EXECUTE format('CREATE POLICY %I ON public.%I FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_select', t);
272
+ END IF;
273
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_insert') THEN
274
+ EXECUTE format('CREATE POLICY %I ON public.%I FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_insert', t);
275
+ END IF;
276
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_update') THEN
277
+ EXECUTE format('CREATE POLICY %I ON public.%I FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_update', t);
278
+ END IF;
279
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_delete') THEN
280
+ EXECUTE format('CREATE POLICY %I ON public.%I FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_delete', t);
281
+ END IF;
282
+ END LOOP;
283
+ END $$;
284
+ `
285
+
286
+ export const MIGRATION_006_ANALYTICS_VIEWS = `-- Marketing Plugin: analytics read views.
287
+ -- v_marketing_channels / v_marketing_campaigns — bridge views for the surface
288
+ -- and the agent's data primitives.
289
+ -- v_marketing_attribution — GENERIC, spine-only attribution event stream
290
+ -- (people/appointments/orders metadata). \`channel_raw\` is the unnormalized
291
+ -- origin string; the provider matches it against the app's channel set
292
+ -- (id or label, case/punctuation-insensitive).
293
+ -- Apps whose origin lives in an app-local extension table (e.g. beauty's
294
+ -- clients.origin) may CREATE OR REPLACE this view in their own incubator
295
+ -- migration, KEEPING the column contract:
296
+ -- (tenant_id uuid, kind text, source text, channel_raw text,
297
+ -- occurred_at timestamptz, value numeric)
298
+
299
+ CREATE OR REPLACE VIEW public.v_marketing_channels WITH (security_invoker=true) AS
300
+ SELECT c.id, c.tenant_id, c.channel_key, c.label, c.icon, c.kind,
301
+ c.is_active, c.monthly_spend, c.created_at, c.updated_at
302
+ FROM public.plg_marketing_channels c;
303
+ GRANT SELECT ON public.v_marketing_channels TO authenticated;
304
+
305
+ CREATE OR REPLACE VIEW public.v_marketing_campaigns WITH (security_invoker=true) AS
306
+ SELECT g.id, g.tenant_id, g.name, g.channel_key, ch.label AS channel_label,
307
+ g.status, g.starts_at, g.ends_at, g.spend, g.created_at, g.updated_at
308
+ FROM public.plg_marketing_campaigns g
309
+ LEFT JOIN public.plg_marketing_channels ch
310
+ ON ch.tenant_id = g.tenant_id AND ch.channel_key = g.channel_key;
311
+ GRANT SELECT ON public.v_marketing_campaigns TO authenticated;
312
+
313
+ CREATE OR REPLACE VIEW public.v_marketing_attribution WITH (security_invoker=true) AS
314
+ -- lead events (CRM leads; sourceId aligns with lead-sources/channel ids)
315
+ SELECT p.tenant_id,
316
+ 'lead'::text AS kind,
317
+ 'crm'::text AS source,
318
+ COALESCE(p.metadata->>'sourceId', p.metadata->>'sourceName', p.metadata->>'origin') AS channel_raw,
319
+ p.created_at AS occurred_at,
320
+ NULLIF(p.metadata->>'value', '')::numeric AS value
321
+ FROM public.people p
322
+ WHERE p.kind = 'lead'
323
+ UNION ALL
324
+ -- booking conversions (agenda verticals)
325
+ SELECT b.tenant_id, 'conversion', 'agenda',
326
+ COALESCE(b.metadata->>'origin', pc.metadata->>'origin', pc.metadata->>'sourceId', pc.metadata->>'sourceName'),
327
+ b.starts_at,
328
+ o.total
329
+ FROM public.appointments b
330
+ LEFT JOIN public.people pc ON pc.id = b.party_id
331
+ LEFT JOIN public.orders o ON o.id = b.order_id
332
+ WHERE b.status NOT IN ('cancelled', 'no_show')
333
+ UNION ALL
334
+ -- order conversions (commerce verticals)
335
+ SELECT o.tenant_id, 'conversion', 'orders',
336
+ COALESCE(o.metadata->>'origin', pp.metadata->>'origin', pp.metadata->>'sourceId'),
337
+ o.created_at,
338
+ o.total
339
+ FROM public.orders o
340
+ LEFT JOIN public.people pp ON pp.id = o.party_id
341
+ WHERE o.kind NOT IN ('appointment', 'deal')
342
+ AND o.status NOT IN ('cancelled', 'draft')
343
+ UNION ALL
344
+ -- won-deal conversions (CRM verticals)
345
+ SELECT o.tenant_id, 'conversion', 'crm',
346
+ COALESCE(pp.metadata->>'sourceId', pp.metadata->>'sourceName', o.metadata->>'origin'),
347
+ o.updated_at,
348
+ o.total
349
+ FROM public.orders o
350
+ LEFT JOIN public.people pp ON pp.id = o.party_id
351
+ WHERE o.kind = 'deal' AND o.status = 'won';
352
+
353
+ GRANT SELECT ON public.v_marketing_attribution TO authenticated;
354
+ `
355
+
356
+ export const MIGRATION_007_AGENT_RPCS = `-- ============================================================================
357
+ -- plugin-marketing 007: server-plane agent write RPC.
358
+ --
359
+ -- public.agent_marketing_create_campaign — guarded campaign create for the
360
+ -- assistant. Mirrors agent_agenda_create_appointment (agenda 005):
361
+ -- * actor-authorized: public.agent_guard (spine 015) runs role→plan→limit
362
+ -- BEFORE anything — denial comes back as structured jsonb;
363
+ -- * channel is validated against the tenant's plg_marketing_channels rows
364
+ -- (key or label, case/punctuation-insensitive); when the tenant has no
365
+ -- channel rows yet (surface not opened → lazy seed not run) the raw key is
366
+ -- accepted as-is;
367
+ -- * audited: audit_logs row with the acting user.
368
+ --
369
+ -- Contract (all agent_* RPCs): (p_tenant_id, p_actor_user_id, p_payload jsonb)
370
+ -- → jsonb {ok:true, id, record:{...}} | {ok:false, denial:{...}} | {ok:false,
371
+ -- error text}. GRANT authenticated+service_role — NEVER anon.
372
+ -- ============================================================================
373
+
374
+ CREATE OR REPLACE FUNCTION public.agent_marketing_create_campaign(
375
+ p_tenant_id uuid,
376
+ p_actor_user_id uuid,
377
+ p_payload jsonb
378
+ ) RETURNS jsonb
379
+ LANGUAGE plpgsql SECURITY DEFINER
380
+ SET search_path = public
381
+ AS $$
382
+ DECLARE
383
+ v_denial jsonb;
384
+ v_used int;
385
+ v_name text;
386
+ v_channel_raw text;
387
+ v_channel_key text;
388
+ v_status text;
389
+ v_starts timestamptz;
390
+ v_ends timestamptz;
391
+ v_spend numeric;
392
+ v_has_channels boolean;
393
+ v_id uuid;
394
+ BEGIN
395
+ -- ── payload ──────────────────────────────────────────────────────────────
396
+ v_name := left(trim(p_payload->>'name'), 200);
397
+ v_channel_raw := trim(p_payload->>'channel_key');
398
+ v_status := COALESCE(NULLIF(trim(p_payload->>'status'), ''), 'draft');
399
+ v_spend := COALESCE(NULLIF(p_payload->>'spend', '')::numeric, 0);
400
+ BEGIN
401
+ v_starts := NULLIF(p_payload->>'starts_at', '')::timestamptz;
402
+ v_ends := NULLIF(p_payload->>'ends_at', '')::timestamptz;
403
+ EXCEPTION WHEN OTHERS THEN
404
+ RETURN jsonb_build_object('ok', false, 'error', 'invalid payload: ' || SQLERRM);
405
+ END;
406
+ IF v_name IS NULL OR v_name = '' OR v_channel_raw IS NULL OR v_channel_raw = '' THEN
407
+ RETURN jsonb_build_object('ok', false, 'error', 'name and channel_key are required');
408
+ END IF;
409
+ IF v_status NOT IN ('active', 'paused', 'ended', 'draft') THEN
410
+ RETURN jsonb_build_object('ok', false, 'error', 'status must be one of active|paused|ended|draft');
411
+ END IF;
412
+
413
+ -- ── authorization: role → plan → campaigns_active cap ────────────────────
414
+ SELECT count(*) INTO v_used FROM plg_marketing_campaigns c
415
+ WHERE c.tenant_id = p_tenant_id AND c.status = 'active';
416
+ v_denial := agent_guard(p_tenant_id, p_actor_user_id,
417
+ 'marketing', 'create', 'campaigns_active', v_used,
418
+ CASE WHEN v_status = 'active' THEN 1 ELSE 0 END);
419
+ IF v_denial IS NOT NULL THEN
420
+ RETURN jsonb_build_object('ok', false, 'denial', v_denial);
421
+ END IF;
422
+
423
+ -- ── channel resolution (key or label, normalized) ────────────────────────
424
+ SELECT EXISTS (SELECT 1 FROM plg_marketing_channels ch WHERE ch.tenant_id = p_tenant_id)
425
+ INTO v_has_channels;
426
+ IF v_has_channels THEN
427
+ SELECT ch.channel_key INTO v_channel_key
428
+ FROM plg_marketing_channels ch
429
+ WHERE ch.tenant_id = p_tenant_id
430
+ AND (
431
+ regexp_replace(lower(ch.channel_key), '[^a-z0-9]', '', 'g')
432
+ = regexp_replace(lower(v_channel_raw), '[^a-z0-9]', '', 'g')
433
+ OR regexp_replace(lower(ch.label), '[^a-z0-9]', '', 'g')
434
+ = regexp_replace(lower(v_channel_raw), '[^a-z0-9]', '', 'g')
435
+ )
436
+ LIMIT 1;
437
+ IF v_channel_key IS NULL THEN
438
+ RETURN jsonb_build_object('ok', false, 'error',
439
+ 'unknown channel "' || v_channel_raw || '" — valid channels: ' ||
440
+ COALESCE((SELECT string_agg(ch.channel_key, ', ' ORDER BY ch.channel_key)
441
+ FROM plg_marketing_channels ch WHERE ch.tenant_id = p_tenant_id), ''));
442
+ END IF;
443
+ ELSE
444
+ v_channel_key := v_channel_raw;
445
+ END IF;
446
+
447
+ -- ── write + audit ────────────────────────────────────────────────────────
448
+ INSERT INTO plg_marketing_campaigns (tenant_id, name, channel_key, status, starts_at, ends_at, spend)
449
+ VALUES (p_tenant_id, v_name, v_channel_key, v_status, COALESCE(v_starts, now()), v_ends, v_spend)
450
+ RETURNING id INTO v_id;
451
+
452
+ INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)
453
+ VALUES (p_tenant_id, p_actor_user_id, 'agent.createCampaign', 'marketing_campaign', v_id::text,
454
+ jsonb_build_object('payload', p_payload));
455
+
456
+ RETURN jsonb_build_object(
457
+ 'ok', true,
458
+ 'id', v_id,
459
+ 'record', jsonb_build_object(
460
+ 'id', v_id,
461
+ 'name', v_name,
462
+ 'channel_key', v_channel_key,
463
+ 'status', v_status,
464
+ 'starts_at', COALESCE(v_starts, now()),
465
+ 'spend', v_spend
466
+ )
467
+ );
468
+ END;
469
+ $$;
470
+
471
+ REVOKE ALL ON FUNCTION public.agent_marketing_create_campaign(uuid, uuid, jsonb) FROM public;
472
+ REVOKE EXECUTE ON FUNCTION public.agent_marketing_create_campaign(uuid, uuid, jsonb) FROM anon;
473
+ GRANT EXECUTE ON FUNCTION public.agent_marketing_create_campaign(uuid, uuid, jsonb)
474
+ TO authenticated, service_role;
475
+ `
476
+
172
477
  export const MIGRATIONS: Array<{ id: string; sql: string }> = [
173
478
  { id: "000_plg_rename", sql: MIGRATION_000_PLG_RENAME },
174
479
  { id: "001_content_planner", sql: MIGRATION_001_CONTENT_PLANNER },
175
480
  { id: "002_multi_platform", sql: MIGRATION_002_MULTI_PLATFORM },
176
481
  { id: "003_recording_ops", sql: MIGRATION_003_RECORDING_OPS },
482
+ { id: "004_ranklayer", sql: MIGRATION_004_RANKLAYER },
483
+ { id: "005_analytics_base", sql: MIGRATION_005_ANALYTICS_BASE },
484
+ { id: "006_analytics_views", sql: MIGRATION_006_ANALYTICS_VIEWS },
485
+ { id: "007_agent_rpcs", sql: MIGRATION_007_AGENT_RPCS },
177
486
  ]
package/src/presets.ts CHANGED
@@ -16,6 +16,8 @@ export interface MarketingDomainModules {
16
16
  landingPages: boolean
17
17
  /** Social content planner — accounts, week board, markdown post pages. */
18
18
  contentPlanner: boolean
19
+ /** Blog backoffice — posts + categories CMS feeding @fayz-ai/plugin-blog. Off by default. */
20
+ blog: boolean
19
21
  /** Outbound — reserved, off by default, implemented in a later milestone. */
20
22
  broadcasts: boolean
21
23
  journeys: boolean
@@ -44,7 +46,7 @@ const AGENCY: MarketingDomainPreset = {
44
46
  { id: 'referral', label: 'Referral', icon: 'Users', kind: 'referral' },
45
47
  { id: 'direct', label: 'Direct', icon: 'MousePointerClick', kind: 'direct' },
46
48
  ],
47
- modules: { channels: true, campaigns: true, funnel: true, landingPages: true, contentPlanner: false, broadcasts: false, journeys: false },
49
+ modules: { channels: true, campaigns: true, funnel: true, landingPages: true, contentPlanner: false, blog: false, broadcasts: false, journeys: false },
48
50
  }
49
51
 
50
52
  const BEAUTY: MarketingDomainPreset = {
@@ -63,7 +65,7 @@ const BEAUTY: MarketingDomainPreset = {
63
65
  { id: 'whatsapp', label: 'WhatsApp', icon: 'MessageCircle', kind: 'social' },
64
66
  { id: 'walkin', label: 'Walk-in', icon: 'DoorOpen', kind: 'direct' },
65
67
  ],
66
- modules: { channels: true, campaigns: true, funnel: true, landingPages: false, contentPlanner: true, broadcasts: false, journeys: false },
68
+ modules: { channels: true, campaigns: true, funnel: true, landingPages: false, contentPlanner: true, blog: false, broadcasts: false, journeys: false },
67
69
  }
68
70
 
69
71
  const RESTO: MarketingDomainPreset = {
@@ -82,7 +84,7 @@ const RESTO: MarketingDomainPreset = {
82
84
  { id: 'instagram', label: 'Instagram', icon: 'Instagram', kind: 'social' },
83
85
  { id: 'walkin', label: 'Walk-in', icon: 'DoorOpen', kind: 'direct' },
84
86
  ],
85
- modules: { channels: true, campaigns: true, funnel: true, landingPages: false, contentPlanner: false, broadcasts: false, journeys: false },
87
+ modules: { channels: true, campaigns: true, funnel: true, landingPages: false, contentPlanner: false, blog: false, broadcasts: false, journeys: false },
86
88
  }
87
89
 
88
90
  export const MARKETING_PRESETS: Record<MarketingDomain, MarketingDomainPreset> = {
package/src/registries.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { PluginRegistryDef, EntityDef } from '@fayz-ai/core'
2
2
  import type { AcquisitionChannel } from './types'
3
+ import { T } from './data/tables'
3
4
 
4
5
  // ---------------------------------------------------------------------------
5
6
  // Settings registries — surfaced as CRUD tabs in the central Settings area
@@ -24,15 +25,23 @@ const channelEntity: EntityDef = {
24
25
  defaultSort: 'label',
25
26
  fields: [
26
27
  { key: 'label', label: 'Channel', type: 'text', required: true, showInTable: true, searchable: true },
28
+ { key: 'channelKey', label: 'Key', type: 'text', required: true, showInTable: true },
27
29
  { key: 'kind', label: 'Type', type: 'select', options: KIND_OPTIONS, showInTable: true },
28
- { key: 'icon', label: 'Icon', type: 'text', showInTable: true },
30
+ { key: 'icon', label: 'Icon', type: 'text' },
31
+ { key: 'monthlySpend', label: 'Monthly spend', type: 'number', showInTable: true, defaultValue: 0 },
32
+ { key: 'isActive', label: 'Active', type: 'boolean', showInTable: true, defaultValue: true, inlineToggle: true },
29
33
  ],
30
34
  facets: [{ field: 'kind' }],
35
+ data: {
36
+ table: T.channels,
37
+ tenantScoped: true,
38
+ searchColumns: ['label', 'channel_key'],
39
+ },
31
40
  }
32
41
 
33
- /** Build the marketing settings registries, seeding Channels from the resolved
34
- * domain preset. Read-only today (channels come from the preset); flip
35
- * readOnly + add a `data.table` to make them tenant-editable. */
42
+ /** Build the marketing settings registries. Channels are tenant-editable rows
43
+ * in plg_marketing_channels; the Supabase provider lazily seeds them from the
44
+ * resolved domain preset (seedData doubles as walkable mock data). */
36
45
  export function buildMarketingRegistries(channels: AcquisitionChannel[]): PluginRegistryDef[] {
37
46
  return [
38
47
  {
@@ -41,8 +50,11 @@ export function buildMarketingRegistries(channels: AcquisitionChannel[]): Plugin
41
50
  icon: 'Radio',
42
51
  description: 'Acquisition channels tracked for this workspace',
43
52
  display: 'table',
44
- readOnly: true,
45
- seedData: channels.map((c) => ({ id: c.id, label: c.label, kind: c.kind, icon: c.icon })),
53
+ seedData: [
54
+ ...channels.map((c) => ({
55
+ id: c.id, channelKey: c.id, label: c.label, kind: c.kind, icon: c.icon, isActive: true, monthlySpend: 0,
56
+ })),
57
+ ],
46
58
  },
47
59
  ]
48
60
  }
@@ -1,7 +1,8 @@
1
1
  // Builders come from @fayz-ai/db (single drizzle-orm instance) — never import
2
2
  // pgTable/uuid directly from drizzle-orm/pg-core. See MIGRATION-ARCHITECTURE §6.
3
3
  import {
4
- pgTable, uuid, text, boolean, integer, date, jsonb, index,
4
+ pgTable, uuid, text, boolean, integer, date, jsonb, index, uniqueIndex,
5
+ numeric, timestamp,
5
6
  tenantId, timestamps, createdAt,
6
7
  } from '@fayz-ai/db'
7
8
 
@@ -11,6 +12,42 @@ import {
11
12
  // see src/migrations/001_content_planner.sql.
12
13
  // ---------------------------------------------------------------------------
13
14
 
15
+ // Acquisition channels — tenant-owned rows seeded from the domain preset.
16
+ // `channel_key` is the stable preset/config id ('instagram', 'paid-search'…),
17
+ // aligned with plugin-crm's lead-sources where they overlap.
18
+ export const mktChannels = pgTable('plg_marketing_channels', {
19
+ id: uuid('id').primaryKey().defaultRandom(),
20
+ tenantId: tenantId(),
21
+ channelKey: text('channel_key').notNull(),
22
+ label: text('label').notNull(),
23
+ icon: text('icon'),
24
+ kind: text('kind').notNull().default('organic'),
25
+ isActive: boolean('is_active').notNull().default(true),
26
+ monthlySpend: numeric('monthly_spend').notNull().default('0'),
27
+ ...timestamps,
28
+ }, (t) => [
29
+ index('idx_plg_marketing_channels_tenant').on(t.tenantId),
30
+ uniqueIndex('u_plg_marketing_channels_tenant_key').on(t.tenantId, t.channelKey),
31
+ ])
32
+
33
+ // Acquisition campaigns — real CRUD; performance is DERIVED at read time from
34
+ // v_marketing_attribution (channel match within the campaign window), only
35
+ // spend is stored.
36
+ export const mktCampaigns = pgTable('plg_marketing_campaigns', {
37
+ id: uuid('id').primaryKey().defaultRandom(),
38
+ tenantId: tenantId(),
39
+ name: text('name').notNull(),
40
+ channelKey: text('channel_key').notNull(),
41
+ status: text('status').notNull().default('draft'),
42
+ startsAt: timestamp('starts_at', { withTimezone: true }),
43
+ endsAt: timestamp('ends_at', { withTimezone: true }),
44
+ spend: numeric('spend').notNull().default('0'),
45
+ ...timestamps,
46
+ }, (t) => [
47
+ index('idx_plg_marketing_campaigns_tenant').on(t.tenantId),
48
+ index('idx_plg_marketing_campaigns_channel').on(t.tenantId, t.channelKey),
49
+ ])
50
+
14
51
  export const mktSocialAccounts = pgTable('plg_marketing_social_accounts', {
15
52
  id: uuid('id').primaryKey().defaultRandom(),
16
53
  tenantId: tenantId(),
package/src/store.ts CHANGED
@@ -19,6 +19,8 @@ export interface MarketingUIState {
19
19
  loading: boolean
20
20
 
21
21
  load(): Promise<void>
22
+ /** Invalidate the provider cache and reload (external writes, e.g. agent RPCs). */
23
+ refresh(): Promise<void>
22
24
  setRange(range: DateRangeKey): Promise<void>
23
25
  saveCampaign(input: SaveCampaignInput): Promise<Campaign>
24
26
  deleteCampaign(id: string): Promise<void>
@@ -37,14 +39,25 @@ export function createMarketingStore(provider: MarketingDataProvider): StoreApi<
37
39
  async load() {
38
40
  const range = get().range
39
41
  set({ loading: true })
40
- const [overview, channels, campaigns, funnel, landingPages] = await Promise.all([
41
- provider.overview({ range }),
42
- provider.channelPerformance({ range }),
43
- provider.listCampaigns({ range }),
44
- provider.funnel({ range }),
45
- provider.landingPages({ range }),
46
- ])
47
- set({ overview, channels, campaigns, funnel, landingPages, loading: false })
42
+ try {
43
+ const [overview, channels, campaigns, funnel, landingPages] = await Promise.all([
44
+ provider.overview({ range }),
45
+ provider.channelPerformance({ range }),
46
+ provider.listCampaigns({ range }),
47
+ provider.funnel({ range }),
48
+ provider.landingPages({ range }),
49
+ ])
50
+ set({ overview, channels, campaigns, funnel, landingPages, loading: false })
51
+ } catch (err) {
52
+ // A failed load must never wedge the surface in `loading` forever.
53
+ set({ loading: false })
54
+ throw err
55
+ }
56
+ },
57
+
58
+ async refresh() {
59
+ provider.invalidateCache?.()
60
+ await get().load()
48
61
  },
49
62
 
50
63
  async setRange(range) {
@@ -2,6 +2,7 @@ import React from 'react'
2
2
  import { Plus, Trash2 } from 'lucide-react'
3
3
  import type { ColumnDef } from '@tanstack/react-table'
4
4
  import { Button, DataTable } from '@fayz-ai/ui'
5
+ import { PermissionGate } from '@fayz-ai/saas'
5
6
  import { useTranslation } from '@fayz-ai/core'
6
7
  import { useMarketingConfig, useMarketingStore } from '../MarketingContext'
7
8
  import { formatCurrency, formatNumber, formatPercent } from '../format'
@@ -44,7 +45,9 @@ export function CampaignsView() {
44
45
  <p className="text-sm text-muted-foreground">{t('marketing.campaigns.subtitle')}</p>
45
46
  <div className="flex items-center gap-2">
46
47
  <RangeTabs />
47
- <Button onClick={() => setComposerOpen(true)}><Plus className="mr-1.5 h-4 w-4" /> {t('marketing.campaigns.new')}</Button>
48
+ <PermissionGate feature="marketing" action="create">
49
+ <Button onClick={() => setComposerOpen(true)}><Plus className="mr-1.5 h-4 w-4" /> {t('marketing.campaigns.new')}</Button>
50
+ </PermissionGate>
48
51
  </div>
49
52
  </div>
50
53
 
@@ -0,0 +1,99 @@
1
+ import React, { useEffect, useMemo, useState } from 'react'
2
+ import { CrudListView } from '@fayz-ai/saas'
3
+ import { SegmentedControl } from '@fayz-ai/ui'
4
+ import { useMarketingConfig } from '../../MarketingContext'
5
+ import { buildBlogPostEntity, buildBlogCategoryEntity } from '../../data/blogEntities'
6
+ import { useEntityCrud } from './useEntityCrud'
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Blog backoffice — an in-page marketing tab with two lenses (Posts /
10
+ // Categories), each a generic CrudListView backed by the blog tables. Follows
11
+ // the content-planner pattern: this plugin owns the data + routing; create/edit
12
+ // deep-link through the marketing module nav (navigate('blog-post:id') etc.).
13
+ // ---------------------------------------------------------------------------
14
+
15
+ function PostsList({ navigate }: { navigate: (view: string) => void }) {
16
+ const entity = useMemo(() => buildBlogPostEntity(), [])
17
+ const { items, total, fetch } = useEntityCrud(entity)
18
+ const [search, setSearch] = useState('')
19
+ const [statusFilter, setStatusFilter] = useState<string | undefined>()
20
+ const [loadedOnce, setLoadedOnce] = useState(false)
21
+
22
+ useEffect(() => {
23
+ let active = true
24
+ Promise.resolve(
25
+ fetch({ search: search || undefined, filters: statusFilter ? { status: statusFilter } : undefined }),
26
+ ).finally(() => { if (active) setLoadedOnce(true) })
27
+ return () => { active = false }
28
+ }, [search, statusFilter, fetch])
29
+
30
+ const facets = useMemo(() => [{
31
+ field: 'status',
32
+ allLabel: 'All',
33
+ options: [{ value: 'draft', label: 'Draft' }, { value: 'published', label: 'Published' }],
34
+ }], [])
35
+
36
+ return (
37
+ <CrudListView
38
+ entityDef={entity}
39
+ items={loadedOnce ? items : null}
40
+ total={total}
41
+ search={search}
42
+ onSearchChange={setSearch}
43
+ searchPlaceholder="Search posts…"
44
+ facets={facets}
45
+ activeFilters={{ status: statusFilter }}
46
+ onFacetChange={(_field, value) => setStatusFilter(value)}
47
+ onNew={() => navigate('blog-new')}
48
+ addLabel="New post"
49
+ onRowClick={(row) => navigate(`blog-post:${(row as { id: string }).id}`)}
50
+ />
51
+ )
52
+ }
53
+
54
+ function CategoriesList({ navigate }: { navigate: (view: string) => void }) {
55
+ const entity = useMemo(() => buildBlogCategoryEntity(), [])
56
+ const { items, total, fetch } = useEntityCrud(entity)
57
+ const [search, setSearch] = useState('')
58
+ const [loadedOnce, setLoadedOnce] = useState(false)
59
+
60
+ useEffect(() => {
61
+ let active = true
62
+ Promise.resolve(fetch({ search: search || undefined })).finally(() => { if (active) setLoadedOnce(true) })
63
+ return () => { active = false }
64
+ }, [search, fetch])
65
+
66
+ return (
67
+ <CrudListView
68
+ entityDef={entity}
69
+ items={loadedOnce ? items : null}
70
+ total={total}
71
+ search={search}
72
+ onSearchChange={setSearch}
73
+ searchPlaceholder="Search categories…"
74
+ onNew={() => navigate('blog-cat-new')}
75
+ addLabel="New category"
76
+ onRowClick={(row) => navigate(`blog-cat:${(row as { id: string }).id}`)}
77
+ />
78
+ )
79
+ }
80
+
81
+ export function BlogAdminView({ navigate }: { navigate: (view: string) => void }) {
82
+ const { labels } = useMarketingConfig()
83
+ const [lens, setLens] = useState<'posts' | 'categories'>('posts')
84
+
85
+ return (
86
+ <div className="space-y-4">
87
+ <SegmentedControl
88
+ aria-label="Blog section"
89
+ options={[
90
+ { value: 'posts', label: labels.blog },
91
+ { value: 'categories', label: labels.blogCategories },
92
+ ]}
93
+ value={lens}
94
+ onChange={(v) => setLens(v as 'posts' | 'categories')}
95
+ />
96
+ {lens === 'posts' ? <PostsList navigate={navigate} /> : <CategoriesList navigate={navigate} />}
97
+ </div>
98
+ )
99
+ }