@fayz-ai/plugin-marketing 0.2.1 → 0.8.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 (58) hide show
  1. package/README.md +2 -0
  2. package/dist/MarketingContext.d.ts +2 -0
  3. package/dist/MarketingContext.d.ts.map +1 -1
  4. package/dist/MarketingPage.d.ts.map +1 -1
  5. package/dist/data/blogEntities.d.ts +28 -0
  6. package/dist/data/blogEntities.d.ts.map +1 -0
  7. package/dist/data/contentSupabase.d.ts.map +1 -1
  8. package/dist/data/tables.d.ts +6 -0
  9. package/dist/data/tables.d.ts.map +1 -0
  10. package/dist/index.cjs +630 -127
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +636 -133
  14. package/dist/index.js.map +1 -1
  15. package/dist/integrations/ranklayer/connectorDef.d.ts +3 -0
  16. package/dist/integrations/ranklayer/connectorDef.d.ts.map +1 -0
  17. package/dist/integrations/ranklayer/data/supabase.d.ts +16 -0
  18. package/dist/integrations/ranklayer/data/supabase.d.ts.map +1 -0
  19. package/dist/integrations/ranklayer/types.d.ts +16 -0
  20. package/dist/integrations/ranklayer/types.d.ts.map +1 -0
  21. package/dist/migrations/index.d.ts +9 -3
  22. package/dist/migrations/index.d.ts.map +1 -1
  23. package/dist/presets.d.ts +2 -0
  24. package/dist/presets.d.ts.map +1 -1
  25. package/dist/schema/index.d.ts +44 -44
  26. package/dist/schema/index.d.ts.map +1 -1
  27. package/dist/schema.cjs +9 -9
  28. package/dist/schema.cjs.map +1 -1
  29. package/dist/schema.js +9 -9
  30. package/dist/schema.js.map +1 -1
  31. package/dist/views/blog/BlogAdminView.d.ts +5 -0
  32. package/dist/views/blog/BlogAdminView.d.ts.map +1 -0
  33. package/dist/views/blog/BlogEntityForm.d.ts +11 -0
  34. package/dist/views/blog/BlogEntityForm.d.ts.map +1 -0
  35. package/dist/views/blog/useEntityCrud.d.ts +29 -0
  36. package/dist/views/blog/useEntityCrud.d.ts.map +1 -0
  37. package/package.json +8 -5
  38. package/src/MarketingContext.tsx +2 -0
  39. package/src/MarketingPage.tsx +22 -1
  40. package/src/data/blogEntities.tsx +91 -0
  41. package/src/data/contentSupabase.ts +18 -17
  42. package/src/data/tables.ts +7 -0
  43. package/src/index.ts +27 -3
  44. package/src/integrations/ranklayer/RANKLAYER.md +40 -0
  45. package/src/integrations/ranklayer/connectorDef.tsx +98 -0
  46. package/src/integrations/ranklayer/data/supabase.ts +106 -0
  47. package/src/integrations/ranklayer/types.ts +16 -0
  48. package/src/migrations/000_plg_rename.sql +15 -0
  49. package/src/migrations/001_content_planner.sql +48 -48
  50. package/src/migrations/002_multi_platform.sql +5 -5
  51. package/src/migrations/003_recording_ops.sql +10 -10
  52. package/src/migrations/004_ranklayer.sql +50 -0
  53. package/src/migrations/index.ts +152 -74
  54. package/src/presets.ts +5 -3
  55. package/src/schema/index.ts +9 -9
  56. package/src/views/blog/BlogAdminView.tsx +99 -0
  57. package/src/views/blog/BlogEntityForm.tsx +92 -0
  58. package/src/views/blog/useEntityCrud.ts +50 -0
@@ -1,13 +1,31 @@
1
- // Inline copy of 001_content_planner.sql for manifest.migrations[] (the
2
- // forward-compat install-lifecycle delivery). The .sql file next to this is
3
- // what beauty-saas' db-apply actually executes today — keep both in sync.
1
+ // AUTO-GENERATED from 000_plg_rename.sql, 001_content_planner.sql, 002_multi_platform.sql, 003_recording_ops.sql, 004_ranklayer.sql — regenerate with scripts/embed-migrations.mjs
2
+ // SQL files are the source of truth; this inline copy lets the manifest declare
3
+ // migrations as data. Do not edit by hand — run the embed script instead.
4
+
5
+ export const MIGRATION_000_PLG_RENAME = `-- 000_plg_rename.sql — rename legacy marketing tables to plg_marketing_* for pools
6
+ -- provisioned before the industry-pool rename. Guarded: fires only when legacy
7
+ -- name exists and target does not, so fresh pools skip every branch.
8
+ DO $$
9
+ BEGIN
10
+ IF to_regclass('public.mkt_social_accounts') IS NOT NULL AND to_regclass('public.plg_marketing_social_accounts') IS NULL THEN
11
+ ALTER TABLE public.mkt_social_accounts RENAME TO plg_marketing_social_accounts;
12
+ END IF;
13
+ IF to_regclass('public.mkt_content_plans') IS NOT NULL AND to_regclass('public.plg_marketing_content_plans') IS NULL THEN
14
+ ALTER TABLE public.mkt_content_plans RENAME TO plg_marketing_content_plans;
15
+ END IF;
16
+ IF to_regclass('public.mkt_content_posts') IS NOT NULL AND to_regclass('public.plg_marketing_content_posts') IS NULL THEN
17
+ ALTER TABLE public.mkt_content_posts RENAME TO plg_marketing_content_posts;
18
+ END IF;
19
+ END $$;
20
+ `
21
+
4
22
  export const MIGRATION_001_CONTENT_PLANNER = `-- Marketing Plugin: Content Planner
5
- -- Prefix: mkt_
23
+ -- Prefix: plg_marketing_
6
24
  -- Social accounts + content plans + posts (Notion-style markdown pages).
7
25
 
8
- CREATE TABLE IF NOT EXISTS public.mkt_social_accounts (
26
+ CREATE TABLE IF NOT EXISTS public.plg_marketing_social_accounts (
9
27
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
10
- tenant_id uuid NOT NULL REFERENCES saas_core.tenants(id) ON DELETE CASCADE,
28
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
11
29
  name text NOT NULL,
12
30
  handle text,
13
31
  platform text NOT NULL DEFAULT 'instagram'
@@ -15,13 +33,13 @@ CREATE TABLE IF NOT EXISTS public.mkt_social_accounts (
15
33
  is_active boolean NOT NULL DEFAULT true,
16
34
  created_at timestamptz NOT NULL DEFAULT now()
17
35
  );
18
- ALTER TABLE public.mkt_social_accounts ENABLE ROW LEVEL SECURITY;
19
- CREATE INDEX IF NOT EXISTS idx_mkt_social_accounts_tenant ON public.mkt_social_accounts(tenant_id);
36
+ ALTER TABLE public.plg_marketing_social_accounts ENABLE ROW LEVEL SECURITY;
37
+ CREATE INDEX IF NOT EXISTS idx_plg_marketing_social_accounts_tenant ON public.plg_marketing_social_accounts(tenant_id);
20
38
 
21
- CREATE TABLE IF NOT EXISTS public.mkt_content_plans (
39
+ CREATE TABLE IF NOT EXISTS public.plg_marketing_content_plans (
22
40
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
23
- tenant_id uuid NOT NULL REFERENCES saas_core.tenants(id) ON DELETE CASCADE,
24
- account_id uuid NOT NULL REFERENCES public.mkt_social_accounts(id) ON DELETE CASCADE,
41
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
42
+ account_id uuid NOT NULL REFERENCES public.plg_marketing_social_accounts(id) ON DELETE CASCADE,
25
43
  name text NOT NULL,
26
44
  status text NOT NULL DEFAULT 'active'
27
45
  CHECK (status IN ('draft', 'active', 'archived')),
@@ -36,14 +54,14 @@ CREATE TABLE IF NOT EXISTS public.mkt_content_plans (
36
54
  created_at timestamptz NOT NULL DEFAULT now(),
37
55
  updated_at timestamptz NOT NULL DEFAULT now()
38
56
  );
39
- ALTER TABLE public.mkt_content_plans ENABLE ROW LEVEL SECURITY;
40
- CREATE INDEX IF NOT EXISTS idx_mkt_content_plans_tenant ON public.mkt_content_plans(tenant_id);
41
- CREATE INDEX IF NOT EXISTS idx_mkt_content_plans_account ON public.mkt_content_plans(account_id);
57
+ ALTER TABLE public.plg_marketing_content_plans ENABLE ROW LEVEL SECURITY;
58
+ CREATE INDEX IF NOT EXISTS idx_plg_marketing_content_plans_tenant ON public.plg_marketing_content_plans(tenant_id);
59
+ CREATE INDEX IF NOT EXISTS idx_plg_marketing_content_plans_account ON public.plg_marketing_content_plans(account_id);
42
60
 
43
- CREATE TABLE IF NOT EXISTS public.mkt_content_posts (
61
+ CREATE TABLE IF NOT EXISTS public.plg_marketing_content_posts (
44
62
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
45
- tenant_id uuid NOT NULL REFERENCES saas_core.tenants(id) ON DELETE CASCADE,
46
- plan_id uuid NOT NULL REFERENCES public.mkt_content_plans(id) ON DELETE CASCADE,
63
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
64
+ plan_id uuid NOT NULL REFERENCES public.plg_marketing_content_plans(id) ON DELETE CASCADE,
47
65
  week_number integer NOT NULL DEFAULT 1,
48
66
  position integer NOT NULL DEFAULT 0,
49
67
  title text NOT NULL,
@@ -58,51 +76,51 @@ CREATE TABLE IF NOT EXISTS public.mkt_content_posts (
58
76
  created_at timestamptz NOT NULL DEFAULT now(),
59
77
  updated_at timestamptz NOT NULL DEFAULT now()
60
78
  );
61
- ALTER TABLE public.mkt_content_posts ENABLE ROW LEVEL SECURITY;
62
- CREATE INDEX IF NOT EXISTS idx_mkt_content_posts_tenant ON public.mkt_content_posts(tenant_id);
63
- CREATE INDEX IF NOT EXISTS idx_mkt_content_posts_plan ON public.mkt_content_posts(plan_id, week_number, position);
64
- CREATE INDEX IF NOT EXISTS idx_mkt_content_posts_status ON public.mkt_content_posts(tenant_id, status);
65
-
66
- -- RLS policies for mkt_social_accounts
67
- DROP POLICY IF EXISTS mkt_social_accounts_select ON public.mkt_social_accounts;
68
- DROP POLICY IF EXISTS mkt_social_accounts_insert ON public.mkt_social_accounts;
69
- DROP POLICY IF EXISTS mkt_social_accounts_update ON public.mkt_social_accounts;
70
- DROP POLICY IF EXISTS mkt_social_accounts_delete ON public.mkt_social_accounts;
71
- CREATE POLICY mkt_social_accounts_select ON public.mkt_social_accounts FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
72
- CREATE POLICY mkt_social_accounts_insert ON public.mkt_social_accounts FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
73
- CREATE POLICY mkt_social_accounts_update ON public.mkt_social_accounts FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
74
- CREATE POLICY mkt_social_accounts_delete ON public.mkt_social_accounts FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
75
- GRANT SELECT, INSERT, UPDATE, DELETE ON public.mkt_social_accounts TO authenticated;
76
-
77
- -- RLS policies for mkt_content_plans
78
- DROP POLICY IF EXISTS mkt_content_plans_select ON public.mkt_content_plans;
79
- DROP POLICY IF EXISTS mkt_content_plans_insert ON public.mkt_content_plans;
80
- DROP POLICY IF EXISTS mkt_content_plans_update ON public.mkt_content_plans;
81
- DROP POLICY IF EXISTS mkt_content_plans_delete ON public.mkt_content_plans;
82
- CREATE POLICY mkt_content_plans_select ON public.mkt_content_plans FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
83
- CREATE POLICY mkt_content_plans_insert ON public.mkt_content_plans FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
84
- CREATE POLICY mkt_content_plans_update ON public.mkt_content_plans FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
85
- CREATE POLICY mkt_content_plans_delete ON public.mkt_content_plans FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
86
- GRANT SELECT, INSERT, UPDATE, DELETE ON public.mkt_content_plans TO authenticated;
87
-
88
- -- RLS policies for mkt_content_posts
89
- DROP POLICY IF EXISTS mkt_content_posts_select ON public.mkt_content_posts;
90
- DROP POLICY IF EXISTS mkt_content_posts_insert ON public.mkt_content_posts;
91
- DROP POLICY IF EXISTS mkt_content_posts_update ON public.mkt_content_posts;
92
- DROP POLICY IF EXISTS mkt_content_posts_delete ON public.mkt_content_posts;
93
- CREATE POLICY mkt_content_posts_select ON public.mkt_content_posts FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
94
- CREATE POLICY mkt_content_posts_insert ON public.mkt_content_posts FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
95
- CREATE POLICY mkt_content_posts_update ON public.mkt_content_posts FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
96
- CREATE POLICY mkt_content_posts_delete ON public.mkt_content_posts FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
97
- GRANT SELECT, INSERT, UPDATE, DELETE ON public.mkt_content_posts TO authenticated;`
98
-
99
- // Inline copy of 002_multi_platform.sql — same keep-in-sync contract as 001.
79
+ ALTER TABLE public.plg_marketing_content_posts ENABLE ROW LEVEL SECURITY;
80
+ CREATE INDEX IF NOT EXISTS idx_plg_marketing_content_posts_tenant ON public.plg_marketing_content_posts(tenant_id);
81
+ CREATE INDEX IF NOT EXISTS idx_plg_marketing_content_posts_plan ON public.plg_marketing_content_posts(plan_id, week_number, position);
82
+ CREATE INDEX IF NOT EXISTS idx_plg_marketing_content_posts_status ON public.plg_marketing_content_posts(tenant_id, status);
83
+
84
+ -- RLS policies for plg_marketing_social_accounts
85
+ DROP POLICY IF EXISTS plg_marketing_social_accounts_select ON public.plg_marketing_social_accounts;
86
+ DROP POLICY IF EXISTS plg_marketing_social_accounts_insert ON public.plg_marketing_social_accounts;
87
+ DROP POLICY IF EXISTS plg_marketing_social_accounts_update ON public.plg_marketing_social_accounts;
88
+ DROP POLICY IF EXISTS plg_marketing_social_accounts_delete ON public.plg_marketing_social_accounts;
89
+ CREATE POLICY plg_marketing_social_accounts_select ON public.plg_marketing_social_accounts FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
90
+ CREATE POLICY plg_marketing_social_accounts_insert ON public.plg_marketing_social_accounts FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
91
+ CREATE POLICY plg_marketing_social_accounts_update ON public.plg_marketing_social_accounts FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
92
+ CREATE POLICY plg_marketing_social_accounts_delete ON public.plg_marketing_social_accounts FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
93
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_marketing_social_accounts TO authenticated;
94
+
95
+ -- RLS policies for plg_marketing_content_plans
96
+ DROP POLICY IF EXISTS plg_marketing_content_plans_select ON public.plg_marketing_content_plans;
97
+ DROP POLICY IF EXISTS plg_marketing_content_plans_insert ON public.plg_marketing_content_plans;
98
+ DROP POLICY IF EXISTS plg_marketing_content_plans_update ON public.plg_marketing_content_plans;
99
+ DROP POLICY IF EXISTS plg_marketing_content_plans_delete ON public.plg_marketing_content_plans;
100
+ CREATE POLICY plg_marketing_content_plans_select ON public.plg_marketing_content_plans FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
101
+ CREATE POLICY plg_marketing_content_plans_insert ON public.plg_marketing_content_plans FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
102
+ CREATE POLICY plg_marketing_content_plans_update ON public.plg_marketing_content_plans FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
103
+ CREATE POLICY plg_marketing_content_plans_delete ON public.plg_marketing_content_plans FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
104
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_marketing_content_plans TO authenticated;
105
+
106
+ -- RLS policies for plg_marketing_content_posts
107
+ DROP POLICY IF EXISTS plg_marketing_content_posts_select ON public.plg_marketing_content_posts;
108
+ DROP POLICY IF EXISTS plg_marketing_content_posts_insert ON public.plg_marketing_content_posts;
109
+ DROP POLICY IF EXISTS plg_marketing_content_posts_update ON public.plg_marketing_content_posts;
110
+ DROP POLICY IF EXISTS plg_marketing_content_posts_delete ON public.plg_marketing_content_posts;
111
+ CREATE POLICY plg_marketing_content_posts_select ON public.plg_marketing_content_posts FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
112
+ CREATE POLICY plg_marketing_content_posts_insert ON public.plg_marketing_content_posts FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
113
+ CREATE POLICY plg_marketing_content_posts_update ON public.plg_marketing_content_posts FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
114
+ CREATE POLICY plg_marketing_content_posts_delete ON public.plg_marketing_content_posts FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
115
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_marketing_content_posts TO authenticated;
116
+ `
117
+
100
118
  export const MIGRATION_002_MULTI_PLATFORM = `-- Marketing Plugin: multi-platform accounts + per-post targets.
101
119
  -- An account is a brand/profile that publishes to MANY platforms (Instagram,
102
120
  -- YouTube, TikTok, ...). Posts optionally narrow the target platforms; an
103
121
  -- empty array means "inherit the account's platforms".
104
122
 
105
- ALTER TABLE public.mkt_social_accounts
123
+ ALTER TABLE public.plg_marketing_social_accounts
106
124
  ADD COLUMN IF NOT EXISTS platforms text[] NOT NULL DEFAULT '{instagram}';
107
125
 
108
126
  -- Backfill from the legacy single-platform column, then drop it (also drops
@@ -111,29 +129,28 @@ DO $$
111
129
  BEGIN
112
130
  IF EXISTS (
113
131
  SELECT 1 FROM information_schema.columns
114
- WHERE table_schema = 'public' AND table_name = 'mkt_social_accounts' AND column_name = 'platform'
132
+ WHERE table_schema = 'public' AND table_name = 'plg_marketing_social_accounts' AND column_name = 'platform'
115
133
  ) THEN
116
- UPDATE public.mkt_social_accounts
134
+ UPDATE public.plg_marketing_social_accounts
117
135
  SET platforms = ARRAY[platform]
118
136
  WHERE platform IS NOT NULL AND platforms = '{instagram}';
119
- ALTER TABLE public.mkt_social_accounts DROP COLUMN platform;
137
+ ALTER TABLE public.plg_marketing_social_accounts DROP COLUMN platform;
120
138
  END IF;
121
139
  END $$;
122
140
 
123
- ALTER TABLE public.mkt_content_posts
124
- ADD COLUMN IF NOT EXISTS platforms text[] NOT NULL DEFAULT '{}';`
141
+ ALTER TABLE public.plg_marketing_content_posts
142
+ ADD COLUMN IF NOT EXISTS platforms text[] NOT NULL DEFAULT '{}';
143
+ `
125
144
 
126
-
127
- // Inline copy of 003_recording_ops.sql — same keep-in-sync contract as 001.
128
145
  export const MIGRATION_003_RECORDING_OPS = `-- Marketing Plugin: recording-day ops on posts.
129
146
  -- checklist: shooting checklist items [{ id, text, done }] — the on-set
130
147
  -- companion for recording day. media_url: uploaded asset (static posts get a
131
148
  -- caption + final art instead of a script).
132
149
 
133
- ALTER TABLE public.mkt_content_posts
150
+ ALTER TABLE public.plg_marketing_content_posts
134
151
  ADD COLUMN IF NOT EXISTS checklist jsonb NOT NULL DEFAULT '[]';
135
152
 
136
- ALTER TABLE public.mkt_content_posts
153
+ ALTER TABLE public.plg_marketing_content_posts
137
154
  ADD COLUMN IF NOT EXISTS media_url text;
138
155
 
139
156
  -- Public media bucket for content assets (mirrors the avatars-bucket pattern:
@@ -142,11 +159,72 @@ INSERT INTO storage.buckets (id, name, public)
142
159
  VALUES ('mkt-media', 'mkt-media', true)
143
160
  ON CONFLICT (id) DO NOTHING;
144
161
 
145
- DROP POLICY IF EXISTS mkt_media_read ON storage.objects;
146
- DROP POLICY IF EXISTS mkt_media_insert ON storage.objects;
147
- DROP POLICY IF EXISTS mkt_media_update ON storage.objects;
148
- DROP POLICY IF EXISTS mkt_media_delete ON storage.objects;
149
- CREATE POLICY mkt_media_read ON storage.objects FOR SELECT TO public USING (bucket_id = 'mkt-media');
150
- CREATE POLICY mkt_media_insert ON storage.objects FOR INSERT TO authenticated WITH CHECK (bucket_id = 'mkt-media');
151
- CREATE POLICY mkt_media_update ON storage.objects FOR UPDATE TO authenticated USING (bucket_id = 'mkt-media');
152
- CREATE POLICY mkt_media_delete ON storage.objects FOR DELETE TO authenticated USING (bucket_id = 'mkt-media');`
162
+ DROP POLICY IF EXISTS plg_marketing_media_read ON storage.objects;
163
+ DROP POLICY IF EXISTS plg_marketing_media_insert ON storage.objects;
164
+ DROP POLICY IF EXISTS plg_marketing_media_update ON storage.objects;
165
+ DROP POLICY IF EXISTS plg_marketing_media_delete ON storage.objects;
166
+ CREATE POLICY plg_marketing_media_read ON storage.objects FOR SELECT TO public USING (bucket_id = 'mkt-media');
167
+ CREATE POLICY plg_marketing_media_insert ON storage.objects FOR INSERT TO authenticated WITH CHECK (bucket_id = 'mkt-media');
168
+ CREATE POLICY plg_marketing_media_update ON storage.objects FOR UPDATE TO authenticated USING (bucket_id = 'mkt-media');
169
+ CREATE POLICY plg_marketing_media_delete ON storage.objects FOR DELETE TO authenticated USING (bucket_id = 'mkt-media');
170
+ `
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 MIGRATIONS: Array<{ id: string; sql: string }> = [
225
+ { id: "000_plg_rename", sql: MIGRATION_000_PLG_RENAME },
226
+ { id: "001_content_planner", sql: MIGRATION_001_CONTENT_PLANNER },
227
+ { id: "002_multi_platform", sql: MIGRATION_002_MULTI_PLATFORM },
228
+ { id: "003_recording_ops", sql: MIGRATION_003_RECORDING_OPS },
229
+ { id: "004_ranklayer", sql: MIGRATION_004_RANKLAYER },
230
+ ]
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> = {
@@ -11,7 +11,7 @@ import {
11
11
  // see src/migrations/001_content_planner.sql.
12
12
  // ---------------------------------------------------------------------------
13
13
 
14
- export const mktSocialAccounts = pgTable('mkt_social_accounts', {
14
+ export const mktSocialAccounts = pgTable('plg_marketing_social_accounts', {
15
15
  id: uuid('id').primaryKey().defaultRandom(),
16
16
  tenantId: tenantId(),
17
17
  name: text('name').notNull(),
@@ -20,9 +20,9 @@ export const mktSocialAccounts = pgTable('mkt_social_accounts', {
20
20
  platforms: text('platforms').array().notNull().default(['instagram']),
21
21
  isActive: boolean('is_active').notNull().default(true),
22
22
  ...createdAt,
23
- }, (t) => [index('idx_mkt_social_accounts_tenant').on(t.tenantId)])
23
+ }, (t) => [index('idx_plg_marketing_social_accounts_tenant').on(t.tenantId)])
24
24
 
25
- export const mktContentPlans = pgTable('mkt_content_plans', {
25
+ export const mktContentPlans = pgTable('plg_marketing_content_plans', {
26
26
  id: uuid('id').primaryKey().defaultRandom(),
27
27
  tenantId: tenantId(),
28
28
  accountId: uuid('account_id').notNull().references(() => mktSocialAccounts.id, { onDelete: 'cascade' }),
@@ -38,11 +38,11 @@ export const mktContentPlans = pgTable('mkt_content_plans', {
38
38
  briefMd: text('brief_md').notNull().default(''),
39
39
  ...timestamps,
40
40
  }, (t) => [
41
- index('idx_mkt_content_plans_tenant').on(t.tenantId),
42
- index('idx_mkt_content_plans_account').on(t.accountId),
41
+ index('idx_plg_marketing_content_plans_tenant').on(t.tenantId),
42
+ index('idx_plg_marketing_content_plans_account').on(t.accountId),
43
43
  ])
44
44
 
45
- export const mktContentPosts = pgTable('mkt_content_posts', {
45
+ export const mktContentPosts = pgTable('plg_marketing_content_posts', {
46
46
  id: uuid('id').primaryKey().defaultRandom(),
47
47
  tenantId: tenantId(),
48
48
  planId: uuid('plan_id').notNull().references(() => mktContentPlans.id, { onDelete: 'cascade' }),
@@ -63,7 +63,7 @@ export const mktContentPosts = pgTable('mkt_content_posts', {
63
63
  contentMd: text('content_md').notNull().default(''),
64
64
  ...timestamps,
65
65
  }, (t) => [
66
- index('idx_mkt_content_posts_tenant').on(t.tenantId),
67
- index('idx_mkt_content_posts_plan').on(t.planId, t.weekNumber, t.position),
68
- index('idx_mkt_content_posts_status').on(t.tenantId, t.status),
66
+ index('idx_plg_marketing_content_posts_tenant').on(t.tenantId),
67
+ index('idx_plg_marketing_content_posts_plan').on(t.planId, t.weekNumber, t.position),
68
+ index('idx_plg_marketing_content_posts_status').on(t.tenantId, t.status),
69
69
  ])
@@ -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
+ }
@@ -0,0 +1,92 @@
1
+ import React from 'react'
2
+ import { SubpageHeader, Button, toast } from '@fayz-ai/ui'
3
+ import { CrudFormPage } from '@fayz-ai/saas'
4
+ import type { EntityDef } from '@fayz-ai/core'
5
+ import { useEntityCrud } from './useEntityCrud'
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Generic create/edit for a blog entity (post or category), rendered through
9
+ // the shared CrudFormPage (declarative EntityDef → fields, incl. the markdown
10
+ // body + category relation). Persistence flows through the generic Supabase
11
+ // provider; this only wires load / save / delete + the back link.
12
+ // ---------------------------------------------------------------------------
13
+
14
+ export function BlogEntityForm<T extends { id: string }>({
15
+ entity,
16
+ editId,
17
+ parentLabel,
18
+ onDone,
19
+ }: {
20
+ entity: EntityDef<T>
21
+ editId?: string
22
+ parentLabel: string
23
+ onDone: () => void
24
+ }) {
25
+ const { getById, create, update, remove } = useEntityCrud(entity)
26
+ const isEdit = !!editId
27
+ const namePlural = entity.namePlural ?? entity.name + 's'
28
+ const [initialData, setInitialData] = React.useState<Record<string, any> | null>(isEdit ? null : {})
29
+
30
+ React.useEffect(() => {
31
+ if (!editId) return
32
+ let cancelled = false
33
+ ;(async () => {
34
+ const item = await getById(editId)
35
+ if (!cancelled) setInitialData((item as Record<string, any>) ?? {})
36
+ })()
37
+ return () => { cancelled = true }
38
+ }, [editId, getById])
39
+
40
+ async function save(values: Record<string, any>) {
41
+ try {
42
+ if (isEdit) await update(editId!, values)
43
+ else await create(values)
44
+ toast.success(isEdit ? `${entity.name} updated` : `${entity.name} created`)
45
+ onDone()
46
+ } catch (err) {
47
+ toast.error(`Failed to save ${entity.name.toLowerCase()}`)
48
+ throw err
49
+ }
50
+ }
51
+
52
+ async function del() {
53
+ if (!editId) return
54
+ try {
55
+ await remove(editId)
56
+ toast.success(`${entity.name} deleted`)
57
+ onDone()
58
+ } catch {
59
+ toast.error(`Failed to delete ${entity.name.toLowerCase()}`)
60
+ }
61
+ }
62
+
63
+ const title = isEdit
64
+ ? (initialData?.[entity.displayField ?? 'name'] ?? entity.name)
65
+ : `New ${entity.name.toLowerCase()}`
66
+
67
+ return (
68
+ <div className="space-y-4">
69
+ <SubpageHeader
70
+ title={String(title)}
71
+ onBack={onDone}
72
+ parentLabel={parentLabel}
73
+ actions={isEdit ? (
74
+ <Button variant="ghost" className="text-destructive hover:text-destructive" onClick={del}>
75
+ Delete
76
+ </Button>
77
+ ) : undefined}
78
+ />
79
+ {initialData !== null && (
80
+ <CrudFormPage
81
+ entityDef={entity}
82
+ mode={isEdit ? 'edit' : 'create'}
83
+ initialData={initialData}
84
+ namePlural={namePlural}
85
+ onCancel={onDone}
86
+ onSubmit={save}
87
+ hideBreadcrumb
88
+ />
89
+ )}
90
+ </div>
91
+ )
92
+ }
@@ -0,0 +1,50 @@
1
+ import { useMemo, useState, useCallback } from 'react'
2
+ import { resolveDataProvider } from '@fayz-ai/core'
3
+ import type { EntityDef } from '@fayz-ai/core'
4
+
5
+ interface ListQuery {
6
+ search?: string
7
+ filters?: Record<string, unknown>
8
+ page?: number
9
+ pageSize?: number
10
+ sortBy?: string
11
+ sortDir?: 'asc' | 'desc'
12
+ }
13
+
14
+ /**
15
+ * Minimal store-less CRUD wiring for a plugin that owns its data + routing.
16
+ * Resolves the generic Supabase provider from the EntityDef's `data.table`
17
+ * (mock fallback when Supabase is not configured) and exposes list/get/save/
18
+ * remove. Used by the blog backoffice list + form views — the heavy list/form
19
+ * rendering stays in the shared CrudListView / CrudFormPage.
20
+ */
21
+ export function useEntityCrud<T extends { id: string }>(entity: EntityDef<T>) {
22
+ const provider = useMemo(() => resolveDataProvider<T>(entity), [entity])
23
+ const [items, setItems] = useState<T[] | null>(null)
24
+ const [total, setTotal] = useState(0)
25
+
26
+ const fetch = useCallback(async (query: ListQuery = {}) => {
27
+ try {
28
+ const res = await provider.list(query as any)
29
+ setItems(res.data)
30
+ setTotal(res.total)
31
+ } catch (err) {
32
+ // Table not provisioned yet (migration not applied to this pool) or a
33
+ // transient error — surface an empty list instead of a perpetual skeleton.
34
+ console.warn('[blog] list failed; showing empty. Apply the plugin-blog migration to this pool.', err)
35
+ setItems([])
36
+ setTotal(0)
37
+ }
38
+ }, [provider])
39
+
40
+ const getById = useCallback(async (id: string): Promise<T | null> => {
41
+ const res = await provider.list({ filters: { id }, page: 1, pageSize: 1 } as any)
42
+ return res.data[0] ?? null
43
+ }, [provider])
44
+
45
+ const create = useCallback((data: Record<string, unknown>) => provider.create(data as any), [provider])
46
+ const update = useCallback((id: string, data: Record<string, unknown>) => provider.update(id, data as any), [provider])
47
+ const remove = useCallback((id: string) => provider.remove(id), [provider])
48
+
49
+ return { items, total, fetch, getById, create, update, remove }
50
+ }