@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.
- package/README.md +2 -0
- package/dist/MarketingContext.d.ts +2 -0
- package/dist/MarketingContext.d.ts.map +1 -1
- package/dist/MarketingPage.d.ts.map +1 -1
- package/dist/data/blogEntities.d.ts +28 -0
- package/dist/data/blogEntities.d.ts.map +1 -0
- package/dist/data/contentSupabase.d.ts.map +1 -1
- package/dist/data/tables.d.ts +6 -0
- package/dist/data/tables.d.ts.map +1 -0
- package/dist/index.cjs +630 -127
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +636 -133
- package/dist/index.js.map +1 -1
- package/dist/integrations/ranklayer/connectorDef.d.ts +3 -0
- package/dist/integrations/ranklayer/connectorDef.d.ts.map +1 -0
- package/dist/integrations/ranklayer/data/supabase.d.ts +16 -0
- package/dist/integrations/ranklayer/data/supabase.d.ts.map +1 -0
- package/dist/integrations/ranklayer/types.d.ts +16 -0
- package/dist/integrations/ranklayer/types.d.ts.map +1 -0
- package/dist/migrations/index.d.ts +9 -3
- package/dist/migrations/index.d.ts.map +1 -1
- package/dist/presets.d.ts +2 -0
- package/dist/presets.d.ts.map +1 -1
- package/dist/schema/index.d.ts +44 -44
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema.cjs +9 -9
- package/dist/schema.cjs.map +1 -1
- package/dist/schema.js +9 -9
- package/dist/schema.js.map +1 -1
- package/dist/views/blog/BlogAdminView.d.ts +5 -0
- package/dist/views/blog/BlogAdminView.d.ts.map +1 -0
- package/dist/views/blog/BlogEntityForm.d.ts +11 -0
- package/dist/views/blog/BlogEntityForm.d.ts.map +1 -0
- package/dist/views/blog/useEntityCrud.d.ts +29 -0
- package/dist/views/blog/useEntityCrud.d.ts.map +1 -0
- package/package.json +8 -5
- package/src/MarketingContext.tsx +2 -0
- package/src/MarketingPage.tsx +22 -1
- package/src/data/blogEntities.tsx +91 -0
- package/src/data/contentSupabase.ts +18 -17
- package/src/data/tables.ts +7 -0
- package/src/index.ts +27 -3
- package/src/integrations/ranklayer/RANKLAYER.md +40 -0
- package/src/integrations/ranklayer/connectorDef.tsx +98 -0
- package/src/integrations/ranklayer/data/supabase.ts +106 -0
- package/src/integrations/ranklayer/types.ts +16 -0
- package/src/migrations/000_plg_rename.sql +15 -0
- package/src/migrations/001_content_planner.sql +48 -48
- package/src/migrations/002_multi_platform.sql +5 -5
- package/src/migrations/003_recording_ops.sql +10 -10
- package/src/migrations/004_ranklayer.sql +50 -0
- package/src/migrations/index.ts +152 -74
- package/src/presets.ts +5 -3
- package/src/schema/index.ts +9 -9
- package/src/views/blog/BlogAdminView.tsx +99 -0
- package/src/views/blog/BlogEntityForm.tsx +92 -0
- package/src/views/blog/useEntityCrud.ts +50 -0
package/src/migrations/index.ts
CHANGED
|
@@ -1,13 +1,31 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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:
|
|
23
|
+
-- Prefix: plg_marketing_
|
|
6
24
|
-- Social accounts + content plans + posts (Notion-style markdown pages).
|
|
7
25
|
|
|
8
|
-
CREATE TABLE IF NOT EXISTS public.
|
|
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
|
|
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.
|
|
19
|
-
CREATE INDEX IF NOT EXISTS
|
|
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.
|
|
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
|
|
24
|
-
account_id uuid NOT NULL REFERENCES public.
|
|
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.
|
|
40
|
-
CREATE INDEX IF NOT EXISTS
|
|
41
|
-
CREATE INDEX IF NOT EXISTS
|
|
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.
|
|
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
|
|
46
|
-
plan_id uuid NOT NULL REFERENCES public.
|
|
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.
|
|
62
|
-
CREATE INDEX IF NOT EXISTS
|
|
63
|
-
CREATE INDEX IF NOT EXISTS
|
|
64
|
-
CREATE INDEX IF NOT EXISTS
|
|
65
|
-
|
|
66
|
-
-- RLS policies for
|
|
67
|
-
DROP POLICY IF EXISTS
|
|
68
|
-
DROP POLICY IF EXISTS
|
|
69
|
-
DROP POLICY IF EXISTS
|
|
70
|
-
DROP POLICY IF EXISTS
|
|
71
|
-
CREATE POLICY
|
|
72
|
-
CREATE POLICY
|
|
73
|
-
CREATE POLICY
|
|
74
|
-
CREATE POLICY
|
|
75
|
-
GRANT SELECT, INSERT, UPDATE, DELETE ON public.
|
|
76
|
-
|
|
77
|
-
-- RLS policies for
|
|
78
|
-
DROP POLICY IF EXISTS
|
|
79
|
-
DROP POLICY IF EXISTS
|
|
80
|
-
DROP POLICY IF EXISTS
|
|
81
|
-
DROP POLICY IF EXISTS
|
|
82
|
-
CREATE POLICY
|
|
83
|
-
CREATE POLICY
|
|
84
|
-
CREATE POLICY
|
|
85
|
-
CREATE POLICY
|
|
86
|
-
GRANT SELECT, INSERT, UPDATE, DELETE ON public.
|
|
87
|
-
|
|
88
|
-
-- RLS policies for
|
|
89
|
-
DROP POLICY IF EXISTS
|
|
90
|
-
DROP POLICY IF EXISTS
|
|
91
|
-
DROP POLICY IF EXISTS
|
|
92
|
-
DROP POLICY IF EXISTS
|
|
93
|
-
CREATE POLICY
|
|
94
|
-
CREATE POLICY
|
|
95
|
-
CREATE POLICY
|
|
96
|
-
CREATE POLICY
|
|
97
|
-
GRANT SELECT, INSERT, UPDATE, DELETE ON public.
|
|
98
|
-
|
|
99
|
-
|
|
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.
|
|
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 = '
|
|
132
|
+
WHERE table_schema = 'public' AND table_name = 'plg_marketing_social_accounts' AND column_name = 'platform'
|
|
115
133
|
) THEN
|
|
116
|
-
UPDATE public.
|
|
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.
|
|
137
|
+
ALTER TABLE public.plg_marketing_social_accounts DROP COLUMN platform;
|
|
120
138
|
END IF;
|
|
121
139
|
END $$;
|
|
122
140
|
|
|
123
|
-
ALTER TABLE public.
|
|
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.
|
|
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.
|
|
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
|
|
146
|
-
DROP POLICY IF EXISTS
|
|
147
|
-
DROP POLICY IF EXISTS
|
|
148
|
-
DROP POLICY IF EXISTS
|
|
149
|
-
CREATE POLICY
|
|
150
|
-
CREATE POLICY
|
|
151
|
-
CREATE POLICY
|
|
152
|
-
CREATE POLICY
|
|
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> = {
|
package/src/schema/index.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
// see src/migrations/001_content_planner.sql.
|
|
12
12
|
// ---------------------------------------------------------------------------
|
|
13
13
|
|
|
14
|
-
export const mktSocialAccounts = pgTable('
|
|
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('
|
|
23
|
+
}, (t) => [index('idx_plg_marketing_social_accounts_tenant').on(t.tenantId)])
|
|
24
24
|
|
|
25
|
-
export const mktContentPlans = pgTable('
|
|
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('
|
|
42
|
-
index('
|
|
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('
|
|
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('
|
|
67
|
-
index('
|
|
68
|
-
index('
|
|
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
|
+
}
|