@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
@@ -0,0 +1,40 @@
1
+ # RankLayer connector
2
+
3
+ Surfaces in **Marketing → settings → Integrações** (rendered by the shared
4
+ `ConnectorsHub`). Follows the connector contract in
5
+ `packages/core/src/integrations/index.ts` and mirrors the PlugBank precedent
6
+ (`beauty-saas/src/plugins/openbanking`).
7
+
8
+ ## Status: SCAFFOLD
9
+
10
+ This ships the control plane only:
11
+
12
+ - `connectorDef.tsx` — the `ConnectorDefinition` (api-key form: API Key + site
13
+ domain, status badge, `ExtraPanel` with a "sync coming soon" note).
14
+ - `data/supabase.ts` — persists the connection row and reads the sync log.
15
+ - `../../migrations/004_ranklayer.sql` — `plg_marketing_ranklayer_integrations`
16
+ + `plg_marketing_ranklayer_sync_log` (tenant RLS).
17
+
18
+ `testConnection` currently accepts any non-empty API key and **no real sync
19
+ runs** — RankLayer has no public API wired yet.
20
+
21
+ ## What the RankLayer PR needs to fill in
22
+
23
+ 1. **Edge function** `functions/ranklayer-sync/index.ts` holding the RankLayer
24
+ API key server-side (move it out of the `api_key` column into a secret if the
25
+ provider requires it), with actions:
26
+ - `test_connection` — validate the key against RankLayer.
27
+ - `sync` — the agreed direction. RankLayer generates/publishes SEO pages and
28
+ articles, so the likely first flow is **inbound**: pull RankLayer-published
29
+ articles and upsert them as `plg_blog_posts` (status `published`) so they
30
+ appear on the site's `/blog`. Confirm direction with the RankLayer team.
31
+ 2. **Real `testConnection` / `sync`** in `data/supabase.ts` — replace the stubs
32
+ to invoke the edge function; write a `plg_marketing_ranklayer_sync_log` row
33
+ per run (the `SyncRun` audit shape).
34
+ 3. **ExtraPanel** — swap the "coming soon" note for a "Sync now" button + the
35
+ real history table (the history rendering is already wired).
36
+
37
+ The connector is already registered on the marketing manifest
38
+ (`connectors: [ranklayerConnectorDef]`) and the Integrações tab is enabled
39
+ (`hostPluginId: 'marketing'` on the settings panel), so no wiring changes are
40
+ needed — only the data-plane implementation above.
@@ -0,0 +1,98 @@
1
+ // RankLayer as a unified ConnectorDefinition, surfaced in Marketing → settings
2
+ // → Integrações (rendered by the shared ConnectorsHub). SCAFFOLD: the hub gives
3
+ // us the API-key form + Test/Save; the ExtraPanel below explains that automated
4
+ // SEO content sync is being prepared. The real sync lands via an external PR —
5
+ // see ./RANKLAYER.md.
6
+ import React, { useEffect, useState } from 'react'
7
+ import { Sparkles, History, CheckCircle2, AlertCircle } from 'lucide-react'
8
+ import type { ConnectorDefinition } from '@fayz-ai/core'
9
+ import { createRankLayerProvider } from './data/supabase'
10
+ import type { RankLayerIntegration, RankLayerSyncLogEntry } from './types'
11
+
12
+ const provider = createRankLayerProvider()
13
+
14
+ function RankLayerExtraPanel() {
15
+ const [integration, setIntegration] = useState<RankLayerIntegration | null>(null)
16
+ const [log, setLog] = useState<RankLayerSyncLogEntry[]>([])
17
+ const [loaded, setLoaded] = useState(false)
18
+
19
+ useEffect(() => {
20
+ let cancelled = false
21
+ ;(async () => {
22
+ try {
23
+ const found = await provider.getIntegration()
24
+ if (cancelled) return
25
+ setIntegration(found)
26
+ if (found) setLog(await provider.getSyncLog(found.id))
27
+ } catch {
28
+ /* not connected / no client — nothing to show */
29
+ } finally {
30
+ if (!cancelled) setLoaded(true)
31
+ }
32
+ })()
33
+ return () => { cancelled = true }
34
+ }, [])
35
+
36
+ if (!loaded || !integration) return null // nothing until the connection is saved
37
+
38
+ return (
39
+ <div className="space-y-4 border-t pt-4">
40
+ <div className="flex items-start gap-3 rounded-md border border-dashed p-3">
41
+ <Sparkles className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
42
+ <div className="space-y-1">
43
+ <h4 className="text-sm font-semibold">Sincronização de conteúdo — em breve</h4>
44
+ <p className="text-xs text-muted-foreground">
45
+ A publicação automática de páginas e artigos de SEO com a RankLayer está em preparação.
46
+ {integration.siteDomain ? ` Domínio configurado: ${integration.siteDomain}.` : ''} Assim que a
47
+ integração estiver ativa, o histórico de sincronizações aparece aqui.
48
+ </p>
49
+ </div>
50
+ </div>
51
+
52
+ {log.length > 0 && (
53
+ <div className="space-y-2">
54
+ <div className="flex items-center gap-2">
55
+ <History className="h-4 w-4 text-muted-foreground" />
56
+ <h4 className="text-sm font-semibold">Histórico</h4>
57
+ </div>
58
+ <div className="divide-y rounded-md border text-sm">
59
+ {log.map((r) => (
60
+ <div key={r.id} className="flex items-center gap-3 px-3 py-2">
61
+ <span className="text-xs text-muted-foreground">{new Date(r.createdAt).toLocaleString('pt-BR')}</span>
62
+ <span className="ml-auto text-xs">{r.written}/{r.fetched} publicadas</span>
63
+ {r.status === 'success'
64
+ ? <CheckCircle2 className="h-3.5 w-3.5 text-success" />
65
+ : <AlertCircle className="h-3.5 w-3.5 text-warning" />}
66
+ </div>
67
+ ))}
68
+ </div>
69
+ </div>
70
+ )}
71
+ </div>
72
+ )
73
+ }
74
+
75
+ export const ranklayerConnectorDef: ConnectorDefinition = {
76
+ id: 'ranklayer',
77
+ hostPluginId: 'marketing',
78
+ name: 'RankLayer',
79
+ description: 'Publicação de conteúdo e páginas de SEO por serviço/bairro para ser encontrado no Google e em assistentes de IA.',
80
+ icon: 'Search',
81
+ authKind: 'api-key',
82
+ fields: [
83
+ { key: 'apiKey', label: 'API Key', type: 'password', placeholder: '••••••••' },
84
+ { key: 'siteDomain', label: 'Domínio do site', type: 'text', placeholder: 'exemplo.com.br' },
85
+ ],
86
+ async getStatus() {
87
+ try {
88
+ const found = await provider.getIntegration()
89
+ return { connected: !!found?.apiKey, detail: found?.siteDomain }
90
+ } catch {
91
+ return { connected: false }
92
+ }
93
+ },
94
+ testConnection: (values) => provider.testConnection({ apiKey: values.apiKey }),
95
+ saveConnection: async (values) => { await provider.saveIntegration({ apiKey: values.apiKey, siteDomain: values.siteDomain }) },
96
+ disconnect: () => provider.disconnect(),
97
+ ExtraPanel: RankLayerExtraPanel,
98
+ }
@@ -0,0 +1,106 @@
1
+ import { getSupabaseClientOptional, getActiveTenantId } from '@fayz-ai/core'
2
+ import type { TestConnectionResult } from '@fayz-ai/core'
3
+ import type { RankLayerIntegration, RankLayerSyncLogEntry } from '../types'
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // RankLayer connector — control-plane client (SCAFFOLD).
7
+ //
8
+ // Reads/writes the connection row (plg_marketing_ranklayer_integrations) and
9
+ // reads the sync log. `testConnection` and `sync` are stubbed: RankLayer has no
10
+ // public API wired yet, so the real work (an edge function calling RankLayer)
11
+ // lands via an external PR — see ../RANKLAYER.md. Everything here is safe to
12
+ // ship: it only persists the tenant's API key + domain and surfaces status.
13
+ // ---------------------------------------------------------------------------
14
+
15
+ const INTEGRATIONS = 'plg_marketing_ranklayer_integrations'
16
+ const SYNC_LOG = 'plg_marketing_ranklayer_sync_log'
17
+
18
+ function getClient() {
19
+ const supabase = getSupabaseClientOptional() as { from: (t: string) => any } | null
20
+ if (!supabase) throw new Error('[ranklayer] Supabase not initialized')
21
+ return supabase
22
+ }
23
+
24
+ function requireTenantId(): string {
25
+ const tenantId = getActiveTenantId()
26
+ if (!tenantId) throw new Error('[ranklayer] No active tenant')
27
+ return tenantId
28
+ }
29
+
30
+ function toIntegration(row: Record<string, any>): RankLayerIntegration {
31
+ return {
32
+ id: row.id,
33
+ apiKey: row.api_key ?? undefined,
34
+ siteDomain: row.site_domain ?? undefined,
35
+ active: row.active ?? true,
36
+ lastSyncAt: row.last_sync_at ?? undefined,
37
+ }
38
+ }
39
+
40
+ export function createRankLayerProvider() {
41
+ return {
42
+ async getIntegration(): Promise<RankLayerIntegration | null> {
43
+ const tenantId = getActiveTenantId()
44
+ if (!tenantId) return null
45
+ const { data } = await getClient()
46
+ .from(INTEGRATIONS)
47
+ .select('*')
48
+ .eq('tenant_id', tenantId)
49
+ .maybeSingle()
50
+ return data ? toIntegration(data) : null
51
+ },
52
+
53
+ async saveIntegration(values: { apiKey: string; siteDomain?: string }): Promise<RankLayerIntegration> {
54
+ const tenantId = requireTenantId()
55
+ const payload = {
56
+ tenant_id: tenantId,
57
+ api_key: values.apiKey,
58
+ site_domain: values.siteDomain ?? null,
59
+ active: true,
60
+ updated_at: new Date().toISOString(),
61
+ }
62
+ // Upsert on the unique tenant_id.
63
+ const { data, error } = await getClient()
64
+ .from(INTEGRATIONS)
65
+ .upsert(payload, { onConflict: 'tenant_id' })
66
+ .select('*')
67
+ .single()
68
+ if (error) throw error
69
+ return toIntegration(data)
70
+ },
71
+
72
+ async disconnect(): Promise<void> {
73
+ const tenantId = getActiveTenantId()
74
+ if (!tenantId) return
75
+ await getClient().from(INTEGRATIONS).delete().eq('tenant_id', tenantId)
76
+ },
77
+
78
+ // SCAFFOLD: no RankLayer API yet — accept any non-empty key so the connect
79
+ // flow can be exercised. Replace with a real credential check in the PR.
80
+ async testConnection(values: { apiKey: string }): Promise<TestConnectionResult> {
81
+ if (!values.apiKey || values.apiKey.trim().length < 8) {
82
+ return { ok: false, message: 'Informe uma API Key válida da RankLayer.' }
83
+ }
84
+ return { ok: true, message: 'Chave aceita (validação real pendente — integração em preparação).' }
85
+ },
86
+
87
+ async getSyncLog(integrationId: string): Promise<RankLayerSyncLogEntry[]> {
88
+ const { data } = await getClient()
89
+ .from(SYNC_LOG)
90
+ .select('*')
91
+ .eq('integration_id', integrationId)
92
+ .order('created_at', { ascending: false })
93
+ .limit(20)
94
+ return (data ?? []).map((r: Record<string, any>) => ({
95
+ id: r.id,
96
+ createdAt: r.created_at,
97
+ status: r.status,
98
+ message: r.message ?? undefined,
99
+ fetched: r.fetched ?? 0,
100
+ written: r.written ?? 0,
101
+ }))
102
+ },
103
+ }
104
+ }
105
+
106
+ export type RankLayerProvider = ReturnType<typeof createRankLayerProvider>
@@ -0,0 +1,16 @@
1
+ export interface RankLayerIntegration {
2
+ id: string
3
+ apiKey?: string
4
+ siteDomain?: string
5
+ active: boolean
6
+ lastSyncAt?: string
7
+ }
8
+
9
+ export interface RankLayerSyncLogEntry {
10
+ id: string
11
+ createdAt: string
12
+ status: 'success' | 'partial' | 'error'
13
+ message?: string
14
+ fetched: number
15
+ written: number
16
+ }
@@ -0,0 +1,50 @@
1
+ -- Marketing Plugin: RankLayer integration (SEO content publishing).
2
+ -- Control-plane state for the RankLayer connector rendered in Marketing →
3
+ -- Integrações. Prefix: plg_marketing_ranklayer_. Scaffold: the real sync
4
+ -- (edge function + RankLayer API calls) lands via an external PR — see
5
+ -- src/integrations/ranklayer/RANKLAYER.md.
6
+
7
+ CREATE TABLE IF NOT EXISTS public.plg_marketing_ranklayer_integrations (
8
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
9
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
10
+ api_key text,
11
+ site_domain text,
12
+ active boolean NOT NULL DEFAULT true,
13
+ last_sync_at timestamptz,
14
+ created_at timestamptz NOT NULL DEFAULT now(),
15
+ updated_at timestamptz NOT NULL DEFAULT now()
16
+ );
17
+ ALTER TABLE public.plg_marketing_ranklayer_integrations ENABLE ROW LEVEL SECURITY;
18
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_plg_marketing_ranklayer_integrations_tenant
19
+ ON public.plg_marketing_ranklayer_integrations(tenant_id);
20
+
21
+ CREATE TABLE IF NOT EXISTS public.plg_marketing_ranklayer_sync_log (
22
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
23
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
24
+ integration_id uuid REFERENCES public.plg_marketing_ranklayer_integrations(id) ON DELETE CASCADE,
25
+ status text NOT NULL DEFAULT 'success' CHECK (status IN ('success', 'partial', 'error')),
26
+ message text,
27
+ fetched integer NOT NULL DEFAULT 0,
28
+ written integer NOT NULL DEFAULT 0,
29
+ created_at timestamptz NOT NULL DEFAULT now()
30
+ );
31
+ ALTER TABLE public.plg_marketing_ranklayer_sync_log ENABLE ROW LEVEL SECURITY;
32
+ CREATE INDEX IF NOT EXISTS idx_plg_marketing_ranklayer_sync_log_tenant
33
+ ON public.plg_marketing_ranklayer_sync_log(tenant_id, created_at DESC);
34
+
35
+ -- RLS: authenticated tenant CRUD
36
+ DROP POLICY IF EXISTS plg_marketing_ranklayer_integrations_select ON public.plg_marketing_ranklayer_integrations;
37
+ DROP POLICY IF EXISTS plg_marketing_ranklayer_integrations_insert ON public.plg_marketing_ranklayer_integrations;
38
+ DROP POLICY IF EXISTS plg_marketing_ranklayer_integrations_update ON public.plg_marketing_ranklayer_integrations;
39
+ DROP POLICY IF EXISTS plg_marketing_ranklayer_integrations_delete ON public.plg_marketing_ranklayer_integrations;
40
+ 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()));
41
+ 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()));
42
+ 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()));
43
+ 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()));
44
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_marketing_ranklayer_integrations TO authenticated;
45
+
46
+ DROP POLICY IF EXISTS plg_marketing_ranklayer_sync_log_select ON public.plg_marketing_ranklayer_sync_log;
47
+ DROP POLICY IF EXISTS plg_marketing_ranklayer_sync_log_insert ON public.plg_marketing_ranklayer_sync_log;
48
+ 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()));
49
+ 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()));
50
+ GRANT SELECT, INSERT ON public.plg_marketing_ranklayer_sync_log TO authenticated;
@@ -0,0 +1,60 @@
1
+ -- Marketing Plugin: analytics base tables (channels + campaigns).
2
+ -- Prefix: plg_marketing_
3
+ -- Channels are tenant-owned rows lazily seeded from the app's domain preset
4
+ -- (channel_key = preset/config id). Campaign performance is DERIVED at read
5
+ -- time from v_marketing_attribution (006); only spend is stored.
6
+ -- Canonical RLS form: tenant_id IN (SELECT public.user_tenant_ids()).
7
+
8
+ CREATE TABLE IF NOT EXISTS public.plg_marketing_channels (
9
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
10
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
11
+ channel_key text NOT NULL,
12
+ label text NOT NULL,
13
+ icon text,
14
+ kind text NOT NULL DEFAULT 'organic'
15
+ CHECK (kind IN ('paid', 'organic', 'social', 'referral', 'direct', 'outbound')),
16
+ is_active boolean NOT NULL DEFAULT true,
17
+ monthly_spend numeric NOT NULL DEFAULT 0,
18
+ created_at timestamptz NOT NULL DEFAULT now(),
19
+ updated_at timestamptz NOT NULL DEFAULT now()
20
+ );
21
+ CREATE INDEX IF NOT EXISTS idx_plg_marketing_channels_tenant ON public.plg_marketing_channels(tenant_id);
22
+ CREATE UNIQUE INDEX IF NOT EXISTS u_plg_marketing_channels_tenant_key ON public.plg_marketing_channels(tenant_id, channel_key);
23
+
24
+ CREATE TABLE IF NOT EXISTS public.plg_marketing_campaigns (
25
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
26
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
27
+ name text NOT NULL,
28
+ channel_key text NOT NULL,
29
+ status text NOT NULL DEFAULT 'draft'
30
+ CHECK (status IN ('active', 'paused', 'ended', 'draft')),
31
+ starts_at timestamptz,
32
+ ends_at timestamptz,
33
+ spend numeric NOT NULL DEFAULT 0,
34
+ created_at timestamptz NOT NULL DEFAULT now(),
35
+ updated_at timestamptz NOT NULL DEFAULT now()
36
+ );
37
+ CREATE INDEX IF NOT EXISTS idx_plg_marketing_campaigns_tenant ON public.plg_marketing_campaigns(tenant_id);
38
+ CREATE INDEX IF NOT EXISTS idx_plg_marketing_campaigns_channel ON public.plg_marketing_campaigns(tenant_id, channel_key);
39
+
40
+ DO $$
41
+ DECLARE t text;
42
+ BEGIN
43
+ FOR t IN SELECT unnest(ARRAY['plg_marketing_channels','plg_marketing_campaigns'])
44
+ LOOP
45
+ EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', t);
46
+ EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON public.%I TO authenticated', t);
47
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_select') THEN
48
+ EXECUTE format('CREATE POLICY %I ON public.%I FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_select', t);
49
+ END IF;
50
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_insert') THEN
51
+ 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);
52
+ END IF;
53
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_update') THEN
54
+ EXECUTE format('CREATE POLICY %I ON public.%I FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_update', t);
55
+ END IF;
56
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_delete') THEN
57
+ EXECUTE format('CREATE POLICY %I ON public.%I FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_delete', t);
58
+ END IF;
59
+ END LOOP;
60
+ END $$;
@@ -0,0 +1,68 @@
1
+ -- Marketing Plugin: analytics read views.
2
+ -- v_marketing_channels / v_marketing_campaigns — bridge views for the surface
3
+ -- and the agent's data primitives.
4
+ -- v_marketing_attribution — GENERIC, spine-only attribution event stream
5
+ -- (people/appointments/orders metadata). `channel_raw` is the unnormalized
6
+ -- origin string; the provider matches it against the app's channel set
7
+ -- (id or label, case/punctuation-insensitive).
8
+ -- Apps whose origin lives in an app-local extension table (e.g. beauty's
9
+ -- clients.origin) may CREATE OR REPLACE this view in their own incubator
10
+ -- migration, KEEPING the column contract:
11
+ -- (tenant_id uuid, kind text, source text, channel_raw text,
12
+ -- occurred_at timestamptz, value numeric)
13
+
14
+ CREATE OR REPLACE VIEW public.v_marketing_channels WITH (security_invoker=true) AS
15
+ SELECT c.id, c.tenant_id, c.channel_key, c.label, c.icon, c.kind,
16
+ c.is_active, c.monthly_spend, c.created_at, c.updated_at
17
+ FROM public.plg_marketing_channels c;
18
+ GRANT SELECT ON public.v_marketing_channels TO authenticated;
19
+
20
+ CREATE OR REPLACE VIEW public.v_marketing_campaigns WITH (security_invoker=true) AS
21
+ SELECT g.id, g.tenant_id, g.name, g.channel_key, ch.label AS channel_label,
22
+ g.status, g.starts_at, g.ends_at, g.spend, g.created_at, g.updated_at
23
+ FROM public.plg_marketing_campaigns g
24
+ LEFT JOIN public.plg_marketing_channels ch
25
+ ON ch.tenant_id = g.tenant_id AND ch.channel_key = g.channel_key;
26
+ GRANT SELECT ON public.v_marketing_campaigns TO authenticated;
27
+
28
+ CREATE OR REPLACE VIEW public.v_marketing_attribution WITH (security_invoker=true) AS
29
+ -- lead events (CRM leads; sourceId aligns with lead-sources/channel ids)
30
+ SELECT p.tenant_id,
31
+ 'lead'::text AS kind,
32
+ 'crm'::text AS source,
33
+ COALESCE(p.metadata->>'sourceId', p.metadata->>'sourceName', p.metadata->>'origin') AS channel_raw,
34
+ p.created_at AS occurred_at,
35
+ NULLIF(p.metadata->>'value', '')::numeric AS value
36
+ FROM public.people p
37
+ WHERE p.kind = 'lead'
38
+ UNION ALL
39
+ -- booking conversions (agenda verticals)
40
+ SELECT b.tenant_id, 'conversion', 'agenda',
41
+ COALESCE(b.metadata->>'origin', pc.metadata->>'origin', pc.metadata->>'sourceId', pc.metadata->>'sourceName'),
42
+ b.starts_at,
43
+ o.total
44
+ FROM public.appointments b
45
+ LEFT JOIN public.people pc ON pc.id = b.party_id
46
+ LEFT JOIN public.orders o ON o.id = b.order_id
47
+ WHERE b.status NOT IN ('cancelled', 'no_show')
48
+ UNION ALL
49
+ -- order conversions (commerce verticals)
50
+ SELECT o.tenant_id, 'conversion', 'orders',
51
+ COALESCE(o.metadata->>'origin', pp.metadata->>'origin', pp.metadata->>'sourceId'),
52
+ o.created_at,
53
+ o.total
54
+ FROM public.orders o
55
+ LEFT JOIN public.people pp ON pp.id = o.party_id
56
+ WHERE o.kind NOT IN ('appointment', 'deal')
57
+ AND o.status NOT IN ('cancelled', 'draft')
58
+ UNION ALL
59
+ -- won-deal conversions (CRM verticals)
60
+ SELECT o.tenant_id, 'conversion', 'crm',
61
+ COALESCE(pp.metadata->>'sourceId', pp.metadata->>'sourceName', o.metadata->>'origin'),
62
+ o.updated_at,
63
+ o.total
64
+ FROM public.orders o
65
+ LEFT JOIN public.people pp ON pp.id = o.party_id
66
+ WHERE o.kind = 'deal' AND o.status = 'won';
67
+
68
+ GRANT SELECT ON public.v_marketing_attribution TO authenticated;
@@ -0,0 +1,119 @@
1
+ -- ============================================================================
2
+ -- plugin-marketing 007: server-plane agent write RPC.
3
+ --
4
+ -- public.agent_marketing_create_campaign — guarded campaign create for the
5
+ -- assistant. Mirrors agent_agenda_create_appointment (agenda 005):
6
+ -- * actor-authorized: public.agent_guard (spine 015) runs role→plan→limit
7
+ -- BEFORE anything — denial comes back as structured jsonb;
8
+ -- * channel is validated against the tenant's plg_marketing_channels rows
9
+ -- (key or label, case/punctuation-insensitive); when the tenant has no
10
+ -- channel rows yet (surface not opened → lazy seed not run) the raw key is
11
+ -- accepted as-is;
12
+ -- * audited: audit_logs row with the acting user.
13
+ --
14
+ -- Contract (all agent_* RPCs): (p_tenant_id, p_actor_user_id, p_payload jsonb)
15
+ -- → jsonb {ok:true, id, record:{...}} | {ok:false, denial:{...}} | {ok:false,
16
+ -- error text}. GRANT authenticated+service_role — NEVER anon.
17
+ -- ============================================================================
18
+
19
+ CREATE OR REPLACE FUNCTION public.agent_marketing_create_campaign(
20
+ p_tenant_id uuid,
21
+ p_actor_user_id uuid,
22
+ p_payload jsonb
23
+ ) RETURNS jsonb
24
+ LANGUAGE plpgsql SECURITY DEFINER
25
+ SET search_path = public
26
+ AS $$
27
+ DECLARE
28
+ v_denial jsonb;
29
+ v_used int;
30
+ v_name text;
31
+ v_channel_raw text;
32
+ v_channel_key text;
33
+ v_status text;
34
+ v_starts timestamptz;
35
+ v_ends timestamptz;
36
+ v_spend numeric;
37
+ v_has_channels boolean;
38
+ v_id uuid;
39
+ BEGIN
40
+ -- ── payload ──────────────────────────────────────────────────────────────
41
+ v_name := left(trim(p_payload->>'name'), 200);
42
+ v_channel_raw := trim(p_payload->>'channel_key');
43
+ v_status := COALESCE(NULLIF(trim(p_payload->>'status'), ''), 'draft');
44
+ v_spend := COALESCE(NULLIF(p_payload->>'spend', '')::numeric, 0);
45
+ BEGIN
46
+ v_starts := NULLIF(p_payload->>'starts_at', '')::timestamptz;
47
+ v_ends := NULLIF(p_payload->>'ends_at', '')::timestamptz;
48
+ EXCEPTION WHEN OTHERS THEN
49
+ RETURN jsonb_build_object('ok', false, 'error', 'invalid payload: ' || SQLERRM);
50
+ END;
51
+ IF v_name IS NULL OR v_name = '' OR v_channel_raw IS NULL OR v_channel_raw = '' THEN
52
+ RETURN jsonb_build_object('ok', false, 'error', 'name and channel_key are required');
53
+ END IF;
54
+ IF v_status NOT IN ('active', 'paused', 'ended', 'draft') THEN
55
+ RETURN jsonb_build_object('ok', false, 'error', 'status must be one of active|paused|ended|draft');
56
+ END IF;
57
+
58
+ -- ── authorization: role → plan → campaigns_active cap ────────────────────
59
+ SELECT count(*) INTO v_used FROM plg_marketing_campaigns c
60
+ WHERE c.tenant_id = p_tenant_id AND c.status = 'active';
61
+ v_denial := agent_guard(p_tenant_id, p_actor_user_id,
62
+ 'marketing', 'create', 'campaigns_active', v_used,
63
+ CASE WHEN v_status = 'active' THEN 1 ELSE 0 END);
64
+ IF v_denial IS NOT NULL THEN
65
+ RETURN jsonb_build_object('ok', false, 'denial', v_denial);
66
+ END IF;
67
+
68
+ -- ── channel resolution (key or label, normalized) ────────────────────────
69
+ SELECT EXISTS (SELECT 1 FROM plg_marketing_channels ch WHERE ch.tenant_id = p_tenant_id)
70
+ INTO v_has_channels;
71
+ IF v_has_channels THEN
72
+ SELECT ch.channel_key INTO v_channel_key
73
+ FROM plg_marketing_channels ch
74
+ WHERE ch.tenant_id = p_tenant_id
75
+ AND (
76
+ regexp_replace(lower(ch.channel_key), '[^a-z0-9]', '', 'g')
77
+ = regexp_replace(lower(v_channel_raw), '[^a-z0-9]', '', 'g')
78
+ OR regexp_replace(lower(ch.label), '[^a-z0-9]', '', 'g')
79
+ = regexp_replace(lower(v_channel_raw), '[^a-z0-9]', '', 'g')
80
+ )
81
+ LIMIT 1;
82
+ IF v_channel_key IS NULL THEN
83
+ RETURN jsonb_build_object('ok', false, 'error',
84
+ 'unknown channel "' || v_channel_raw || '" — valid channels: ' ||
85
+ COALESCE((SELECT string_agg(ch.channel_key, ', ' ORDER BY ch.channel_key)
86
+ FROM plg_marketing_channels ch WHERE ch.tenant_id = p_tenant_id), ''));
87
+ END IF;
88
+ ELSE
89
+ v_channel_key := v_channel_raw;
90
+ END IF;
91
+
92
+ -- ── write + audit ────────────────────────────────────────────────────────
93
+ INSERT INTO plg_marketing_campaigns (tenant_id, name, channel_key, status, starts_at, ends_at, spend)
94
+ VALUES (p_tenant_id, v_name, v_channel_key, v_status, COALESCE(v_starts, now()), v_ends, v_spend)
95
+ RETURNING id INTO v_id;
96
+
97
+ INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)
98
+ VALUES (p_tenant_id, p_actor_user_id, 'agent.createCampaign', 'marketing_campaign', v_id::text,
99
+ jsonb_build_object('payload', p_payload));
100
+
101
+ RETURN jsonb_build_object(
102
+ 'ok', true,
103
+ 'id', v_id,
104
+ 'record', jsonb_build_object(
105
+ 'id', v_id,
106
+ 'name', v_name,
107
+ 'channel_key', v_channel_key,
108
+ 'status', v_status,
109
+ 'starts_at', COALESCE(v_starts, now()),
110
+ 'spend', v_spend
111
+ )
112
+ );
113
+ END;
114
+ $$;
115
+
116
+ REVOKE ALL ON FUNCTION public.agent_marketing_create_campaign(uuid, uuid, jsonb) FROM public;
117
+ REVOKE EXECUTE ON FUNCTION public.agent_marketing_create_campaign(uuid, uuid, jsonb) FROM anon;
118
+ GRANT EXECUTE ON FUNCTION public.agent_marketing_create_campaign(uuid, uuid, jsonb)
119
+ TO authenticated, service_role;