@fayz-ai/plugin-marketing 0.8.1 → 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.
- package/dist/MarketingPage.d.ts.map +1 -1
- package/dist/data/capability.test.d.ts +2 -0
- package/dist/data/capability.test.d.ts.map +1 -0
- package/dist/data/supabase.d.ts +10 -0
- package/dist/data/supabase.d.ts.map +1 -0
- package/dist/data/tables.d.ts +7 -0
- package/dist/data/tables.d.ts.map +1 -1
- package/dist/data/types.d.ts +2 -0
- package/dist/data/types.d.ts.map +1 -1
- package/dist/index.cjs +744 -58
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +746 -61
- package/dist/index.js.map +1 -1
- package/dist/migrations/index.d.ts +3 -0
- package/dist/migrations/index.d.ts.map +1 -1
- package/dist/registries.d.ts +3 -3
- package/dist/registries.d.ts.map +1 -1
- package/dist/schema/index.d.ts +354 -0
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema.cjs +30 -0
- package/dist/schema.cjs.map +1 -1
- package/dist/schema.js +30 -2
- package/dist/schema.js.map +1 -1
- package/dist/store.d.ts +2 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/views/CampaignsView.d.ts.map +1 -1
- package/dist/views/blog/BlogEntityForm.d.ts.map +1 -1
- package/dist/views/content/ContentView.d.ts.map +1 -1
- package/package.json +8 -6
- package/src/MarketingPage.tsx +5 -1
- package/src/data/capability.test.ts +92 -0
- package/src/data/supabase.ts +401 -0
- package/src/data/tables.ts +9 -0
- package/src/data/types.ts +2 -0
- package/src/index.ts +138 -10
- package/src/migrations/005_analytics_base.sql +60 -0
- package/src/migrations/006_analytics_views.sql +68 -0
- package/src/migrations/007_agent_rpcs.sql +119 -0
- package/src/migrations/index.ts +257 -1
- package/src/registries.ts +18 -6
- package/src/schema/index.ts +38 -1
- package/src/store.ts +21 -8
- package/src/views/CampaignsView.tsx +4 -1
- package/src/views/blog/BlogEntityForm.tsx +11 -2
- package/src/views/content/ContentView.tsx +8 -1
package/src/migrations/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
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
|
|
1
|
+
// AUTO-GENERATED from 000_plg_rename.sql, 001_content_planner.sql, 002_multi_platform.sql, 003_recording_ops.sql, 004_ranklayer.sql, 005_analytics_base.sql, 006_analytics_views.sql, 007_agent_rpcs.sql — regenerate with scripts/embed-migrations.mjs
|
|
2
2
|
// SQL files are the source of truth; this inline copy lets the manifest declare
|
|
3
3
|
// migrations as data. Do not edit by hand — run the embed script instead.
|
|
4
4
|
|
|
@@ -221,10 +221,266 @@ CREATE POLICY plg_marketing_ranklayer_sync_log_insert ON public.plg_marketing_ra
|
|
|
221
221
|
GRANT SELECT, INSERT ON public.plg_marketing_ranklayer_sync_log TO authenticated;
|
|
222
222
|
`
|
|
223
223
|
|
|
224
|
+
export const MIGRATION_005_ANALYTICS_BASE = `-- Marketing Plugin: analytics base tables (channels + campaigns).
|
|
225
|
+
-- Prefix: plg_marketing_
|
|
226
|
+
-- Channels are tenant-owned rows lazily seeded from the app's domain preset
|
|
227
|
+
-- (channel_key = preset/config id). Campaign performance is DERIVED at read
|
|
228
|
+
-- time from v_marketing_attribution (006); only spend is stored.
|
|
229
|
+
-- Canonical RLS form: tenant_id IN (SELECT public.user_tenant_ids()).
|
|
230
|
+
|
|
231
|
+
CREATE TABLE IF NOT EXISTS public.plg_marketing_channels (
|
|
232
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
233
|
+
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
234
|
+
channel_key text NOT NULL,
|
|
235
|
+
label text NOT NULL,
|
|
236
|
+
icon text,
|
|
237
|
+
kind text NOT NULL DEFAULT 'organic'
|
|
238
|
+
CHECK (kind IN ('paid', 'organic', 'social', 'referral', 'direct', 'outbound')),
|
|
239
|
+
is_active boolean NOT NULL DEFAULT true,
|
|
240
|
+
monthly_spend numeric NOT NULL DEFAULT 0,
|
|
241
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
242
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
243
|
+
);
|
|
244
|
+
CREATE INDEX IF NOT EXISTS idx_plg_marketing_channels_tenant ON public.plg_marketing_channels(tenant_id);
|
|
245
|
+
CREATE UNIQUE INDEX IF NOT EXISTS u_plg_marketing_channels_tenant_key ON public.plg_marketing_channels(tenant_id, channel_key);
|
|
246
|
+
|
|
247
|
+
CREATE TABLE IF NOT EXISTS public.plg_marketing_campaigns (
|
|
248
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
249
|
+
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
250
|
+
name text NOT NULL,
|
|
251
|
+
channel_key text NOT NULL,
|
|
252
|
+
status text NOT NULL DEFAULT 'draft'
|
|
253
|
+
CHECK (status IN ('active', 'paused', 'ended', 'draft')),
|
|
254
|
+
starts_at timestamptz,
|
|
255
|
+
ends_at timestamptz,
|
|
256
|
+
spend numeric NOT NULL DEFAULT 0,
|
|
257
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
258
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
259
|
+
);
|
|
260
|
+
CREATE INDEX IF NOT EXISTS idx_plg_marketing_campaigns_tenant ON public.plg_marketing_campaigns(tenant_id);
|
|
261
|
+
CREATE INDEX IF NOT EXISTS idx_plg_marketing_campaigns_channel ON public.plg_marketing_campaigns(tenant_id, channel_key);
|
|
262
|
+
|
|
263
|
+
DO $$
|
|
264
|
+
DECLARE t text;
|
|
265
|
+
BEGIN
|
|
266
|
+
FOR t IN SELECT unnest(ARRAY['plg_marketing_channels','plg_marketing_campaigns'])
|
|
267
|
+
LOOP
|
|
268
|
+
EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', t);
|
|
269
|
+
EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON public.%I TO authenticated', t);
|
|
270
|
+
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_select') THEN
|
|
271
|
+
EXECUTE format('CREATE POLICY %I ON public.%I FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_select', t);
|
|
272
|
+
END IF;
|
|
273
|
+
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_insert') THEN
|
|
274
|
+
EXECUTE format('CREATE POLICY %I ON public.%I FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_insert', t);
|
|
275
|
+
END IF;
|
|
276
|
+
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_update') THEN
|
|
277
|
+
EXECUTE format('CREATE POLICY %I ON public.%I FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_update', t);
|
|
278
|
+
END IF;
|
|
279
|
+
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_delete') THEN
|
|
280
|
+
EXECUTE format('CREATE POLICY %I ON public.%I FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_delete', t);
|
|
281
|
+
END IF;
|
|
282
|
+
END LOOP;
|
|
283
|
+
END $$;
|
|
284
|
+
`
|
|
285
|
+
|
|
286
|
+
export const MIGRATION_006_ANALYTICS_VIEWS = `-- Marketing Plugin: analytics read views.
|
|
287
|
+
-- v_marketing_channels / v_marketing_campaigns — bridge views for the surface
|
|
288
|
+
-- and the agent's data primitives.
|
|
289
|
+
-- v_marketing_attribution — GENERIC, spine-only attribution event stream
|
|
290
|
+
-- (people/appointments/orders metadata). \`channel_raw\` is the unnormalized
|
|
291
|
+
-- origin string; the provider matches it against the app's channel set
|
|
292
|
+
-- (id or label, case/punctuation-insensitive).
|
|
293
|
+
-- Apps whose origin lives in an app-local extension table (e.g. beauty's
|
|
294
|
+
-- clients.origin) may CREATE OR REPLACE this view in their own incubator
|
|
295
|
+
-- migration, KEEPING the column contract:
|
|
296
|
+
-- (tenant_id uuid, kind text, source text, channel_raw text,
|
|
297
|
+
-- occurred_at timestamptz, value numeric)
|
|
298
|
+
|
|
299
|
+
CREATE OR REPLACE VIEW public.v_marketing_channels WITH (security_invoker=true) AS
|
|
300
|
+
SELECT c.id, c.tenant_id, c.channel_key, c.label, c.icon, c.kind,
|
|
301
|
+
c.is_active, c.monthly_spend, c.created_at, c.updated_at
|
|
302
|
+
FROM public.plg_marketing_channels c;
|
|
303
|
+
GRANT SELECT ON public.v_marketing_channels TO authenticated;
|
|
304
|
+
|
|
305
|
+
CREATE OR REPLACE VIEW public.v_marketing_campaigns WITH (security_invoker=true) AS
|
|
306
|
+
SELECT g.id, g.tenant_id, g.name, g.channel_key, ch.label AS channel_label,
|
|
307
|
+
g.status, g.starts_at, g.ends_at, g.spend, g.created_at, g.updated_at
|
|
308
|
+
FROM public.plg_marketing_campaigns g
|
|
309
|
+
LEFT JOIN public.plg_marketing_channels ch
|
|
310
|
+
ON ch.tenant_id = g.tenant_id AND ch.channel_key = g.channel_key;
|
|
311
|
+
GRANT SELECT ON public.v_marketing_campaigns TO authenticated;
|
|
312
|
+
|
|
313
|
+
CREATE OR REPLACE VIEW public.v_marketing_attribution WITH (security_invoker=true) AS
|
|
314
|
+
-- lead events (CRM leads; sourceId aligns with lead-sources/channel ids)
|
|
315
|
+
SELECT p.tenant_id,
|
|
316
|
+
'lead'::text AS kind,
|
|
317
|
+
'crm'::text AS source,
|
|
318
|
+
COALESCE(p.metadata->>'sourceId', p.metadata->>'sourceName', p.metadata->>'origin') AS channel_raw,
|
|
319
|
+
p.created_at AS occurred_at,
|
|
320
|
+
NULLIF(p.metadata->>'value', '')::numeric AS value
|
|
321
|
+
FROM public.people p
|
|
322
|
+
WHERE p.kind = 'lead'
|
|
323
|
+
UNION ALL
|
|
324
|
+
-- booking conversions (agenda verticals)
|
|
325
|
+
SELECT b.tenant_id, 'conversion', 'agenda',
|
|
326
|
+
COALESCE(b.metadata->>'origin', pc.metadata->>'origin', pc.metadata->>'sourceId', pc.metadata->>'sourceName'),
|
|
327
|
+
b.starts_at,
|
|
328
|
+
o.total
|
|
329
|
+
FROM public.appointments b
|
|
330
|
+
LEFT JOIN public.people pc ON pc.id = b.party_id
|
|
331
|
+
LEFT JOIN public.orders o ON o.id = b.order_id
|
|
332
|
+
WHERE b.status NOT IN ('cancelled', 'no_show')
|
|
333
|
+
UNION ALL
|
|
334
|
+
-- order conversions (commerce verticals)
|
|
335
|
+
SELECT o.tenant_id, 'conversion', 'orders',
|
|
336
|
+
COALESCE(o.metadata->>'origin', pp.metadata->>'origin', pp.metadata->>'sourceId'),
|
|
337
|
+
o.created_at,
|
|
338
|
+
o.total
|
|
339
|
+
FROM public.orders o
|
|
340
|
+
LEFT JOIN public.people pp ON pp.id = o.party_id
|
|
341
|
+
WHERE o.kind NOT IN ('appointment', 'deal')
|
|
342
|
+
AND o.status NOT IN ('cancelled', 'draft')
|
|
343
|
+
UNION ALL
|
|
344
|
+
-- won-deal conversions (CRM verticals)
|
|
345
|
+
SELECT o.tenant_id, 'conversion', 'crm',
|
|
346
|
+
COALESCE(pp.metadata->>'sourceId', pp.metadata->>'sourceName', o.metadata->>'origin'),
|
|
347
|
+
o.updated_at,
|
|
348
|
+
o.total
|
|
349
|
+
FROM public.orders o
|
|
350
|
+
LEFT JOIN public.people pp ON pp.id = o.party_id
|
|
351
|
+
WHERE o.kind = 'deal' AND o.status = 'won';
|
|
352
|
+
|
|
353
|
+
GRANT SELECT ON public.v_marketing_attribution TO authenticated;
|
|
354
|
+
`
|
|
355
|
+
|
|
356
|
+
export const MIGRATION_007_AGENT_RPCS = `-- ============================================================================
|
|
357
|
+
-- plugin-marketing 007: server-plane agent write RPC.
|
|
358
|
+
--
|
|
359
|
+
-- public.agent_marketing_create_campaign — guarded campaign create for the
|
|
360
|
+
-- assistant. Mirrors agent_agenda_create_appointment (agenda 005):
|
|
361
|
+
-- * actor-authorized: public.agent_guard (spine 015) runs role→plan→limit
|
|
362
|
+
-- BEFORE anything — denial comes back as structured jsonb;
|
|
363
|
+
-- * channel is validated against the tenant's plg_marketing_channels rows
|
|
364
|
+
-- (key or label, case/punctuation-insensitive); when the tenant has no
|
|
365
|
+
-- channel rows yet (surface not opened → lazy seed not run) the raw key is
|
|
366
|
+
-- accepted as-is;
|
|
367
|
+
-- * audited: audit_logs row with the acting user.
|
|
368
|
+
--
|
|
369
|
+
-- Contract (all agent_* RPCs): (p_tenant_id, p_actor_user_id, p_payload jsonb)
|
|
370
|
+
-- → jsonb {ok:true, id, record:{...}} | {ok:false, denial:{...}} | {ok:false,
|
|
371
|
+
-- error text}. GRANT authenticated+service_role — NEVER anon.
|
|
372
|
+
-- ============================================================================
|
|
373
|
+
|
|
374
|
+
CREATE OR REPLACE FUNCTION public.agent_marketing_create_campaign(
|
|
375
|
+
p_tenant_id uuid,
|
|
376
|
+
p_actor_user_id uuid,
|
|
377
|
+
p_payload jsonb
|
|
378
|
+
) RETURNS jsonb
|
|
379
|
+
LANGUAGE plpgsql SECURITY DEFINER
|
|
380
|
+
SET search_path = public
|
|
381
|
+
AS $$
|
|
382
|
+
DECLARE
|
|
383
|
+
v_denial jsonb;
|
|
384
|
+
v_used int;
|
|
385
|
+
v_name text;
|
|
386
|
+
v_channel_raw text;
|
|
387
|
+
v_channel_key text;
|
|
388
|
+
v_status text;
|
|
389
|
+
v_starts timestamptz;
|
|
390
|
+
v_ends timestamptz;
|
|
391
|
+
v_spend numeric;
|
|
392
|
+
v_has_channels boolean;
|
|
393
|
+
v_id uuid;
|
|
394
|
+
BEGIN
|
|
395
|
+
-- ── payload ──────────────────────────────────────────────────────────────
|
|
396
|
+
v_name := left(trim(p_payload->>'name'), 200);
|
|
397
|
+
v_channel_raw := trim(p_payload->>'channel_key');
|
|
398
|
+
v_status := COALESCE(NULLIF(trim(p_payload->>'status'), ''), 'draft');
|
|
399
|
+
v_spend := COALESCE(NULLIF(p_payload->>'spend', '')::numeric, 0);
|
|
400
|
+
BEGIN
|
|
401
|
+
v_starts := NULLIF(p_payload->>'starts_at', '')::timestamptz;
|
|
402
|
+
v_ends := NULLIF(p_payload->>'ends_at', '')::timestamptz;
|
|
403
|
+
EXCEPTION WHEN OTHERS THEN
|
|
404
|
+
RETURN jsonb_build_object('ok', false, 'error', 'invalid payload: ' || SQLERRM);
|
|
405
|
+
END;
|
|
406
|
+
IF v_name IS NULL OR v_name = '' OR v_channel_raw IS NULL OR v_channel_raw = '' THEN
|
|
407
|
+
RETURN jsonb_build_object('ok', false, 'error', 'name and channel_key are required');
|
|
408
|
+
END IF;
|
|
409
|
+
IF v_status NOT IN ('active', 'paused', 'ended', 'draft') THEN
|
|
410
|
+
RETURN jsonb_build_object('ok', false, 'error', 'status must be one of active|paused|ended|draft');
|
|
411
|
+
END IF;
|
|
412
|
+
|
|
413
|
+
-- ── authorization: role → plan → campaigns_active cap ────────────────────
|
|
414
|
+
SELECT count(*) INTO v_used FROM plg_marketing_campaigns c
|
|
415
|
+
WHERE c.tenant_id = p_tenant_id AND c.status = 'active';
|
|
416
|
+
v_denial := agent_guard(p_tenant_id, p_actor_user_id,
|
|
417
|
+
'marketing', 'create', 'campaigns_active', v_used,
|
|
418
|
+
CASE WHEN v_status = 'active' THEN 1 ELSE 0 END);
|
|
419
|
+
IF v_denial IS NOT NULL THEN
|
|
420
|
+
RETURN jsonb_build_object('ok', false, 'denial', v_denial);
|
|
421
|
+
END IF;
|
|
422
|
+
|
|
423
|
+
-- ── channel resolution (key or label, normalized) ────────────────────────
|
|
424
|
+
SELECT EXISTS (SELECT 1 FROM plg_marketing_channels ch WHERE ch.tenant_id = p_tenant_id)
|
|
425
|
+
INTO v_has_channels;
|
|
426
|
+
IF v_has_channels THEN
|
|
427
|
+
SELECT ch.channel_key INTO v_channel_key
|
|
428
|
+
FROM plg_marketing_channels ch
|
|
429
|
+
WHERE ch.tenant_id = p_tenant_id
|
|
430
|
+
AND (
|
|
431
|
+
regexp_replace(lower(ch.channel_key), '[^a-z0-9]', '', 'g')
|
|
432
|
+
= regexp_replace(lower(v_channel_raw), '[^a-z0-9]', '', 'g')
|
|
433
|
+
OR regexp_replace(lower(ch.label), '[^a-z0-9]', '', 'g')
|
|
434
|
+
= regexp_replace(lower(v_channel_raw), '[^a-z0-9]', '', 'g')
|
|
435
|
+
)
|
|
436
|
+
LIMIT 1;
|
|
437
|
+
IF v_channel_key IS NULL THEN
|
|
438
|
+
RETURN jsonb_build_object('ok', false, 'error',
|
|
439
|
+
'unknown channel "' || v_channel_raw || '" — valid channels: ' ||
|
|
440
|
+
COALESCE((SELECT string_agg(ch.channel_key, ', ' ORDER BY ch.channel_key)
|
|
441
|
+
FROM plg_marketing_channels ch WHERE ch.tenant_id = p_tenant_id), ''));
|
|
442
|
+
END IF;
|
|
443
|
+
ELSE
|
|
444
|
+
v_channel_key := v_channel_raw;
|
|
445
|
+
END IF;
|
|
446
|
+
|
|
447
|
+
-- ── write + audit ────────────────────────────────────────────────────────
|
|
448
|
+
INSERT INTO plg_marketing_campaigns (tenant_id, name, channel_key, status, starts_at, ends_at, spend)
|
|
449
|
+
VALUES (p_tenant_id, v_name, v_channel_key, v_status, COALESCE(v_starts, now()), v_ends, v_spend)
|
|
450
|
+
RETURNING id INTO v_id;
|
|
451
|
+
|
|
452
|
+
INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)
|
|
453
|
+
VALUES (p_tenant_id, p_actor_user_id, 'agent.createCampaign', 'marketing_campaign', v_id::text,
|
|
454
|
+
jsonb_build_object('payload', p_payload));
|
|
455
|
+
|
|
456
|
+
RETURN jsonb_build_object(
|
|
457
|
+
'ok', true,
|
|
458
|
+
'id', v_id,
|
|
459
|
+
'record', jsonb_build_object(
|
|
460
|
+
'id', v_id,
|
|
461
|
+
'name', v_name,
|
|
462
|
+
'channel_key', v_channel_key,
|
|
463
|
+
'status', v_status,
|
|
464
|
+
'starts_at', COALESCE(v_starts, now()),
|
|
465
|
+
'spend', v_spend
|
|
466
|
+
)
|
|
467
|
+
);
|
|
468
|
+
END;
|
|
469
|
+
$$;
|
|
470
|
+
|
|
471
|
+
REVOKE ALL ON FUNCTION public.agent_marketing_create_campaign(uuid, uuid, jsonb) FROM public;
|
|
472
|
+
REVOKE EXECUTE ON FUNCTION public.agent_marketing_create_campaign(uuid, uuid, jsonb) FROM anon;
|
|
473
|
+
GRANT EXECUTE ON FUNCTION public.agent_marketing_create_campaign(uuid, uuid, jsonb)
|
|
474
|
+
TO authenticated, service_role;
|
|
475
|
+
`
|
|
476
|
+
|
|
224
477
|
export const MIGRATIONS: Array<{ id: string; sql: string }> = [
|
|
225
478
|
{ id: "000_plg_rename", sql: MIGRATION_000_PLG_RENAME },
|
|
226
479
|
{ id: "001_content_planner", sql: MIGRATION_001_CONTENT_PLANNER },
|
|
227
480
|
{ id: "002_multi_platform", sql: MIGRATION_002_MULTI_PLATFORM },
|
|
228
481
|
{ id: "003_recording_ops", sql: MIGRATION_003_RECORDING_OPS },
|
|
229
482
|
{ id: "004_ranklayer", sql: MIGRATION_004_RANKLAYER },
|
|
483
|
+
{ id: "005_analytics_base", sql: MIGRATION_005_ANALYTICS_BASE },
|
|
484
|
+
{ id: "006_analytics_views", sql: MIGRATION_006_ANALYTICS_VIEWS },
|
|
485
|
+
{ id: "007_agent_rpcs", sql: MIGRATION_007_AGENT_RPCS },
|
|
230
486
|
]
|
package/src/registries.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { PluginRegistryDef, EntityDef } from '@fayz-ai/core'
|
|
2
2
|
import type { AcquisitionChannel } from './types'
|
|
3
|
+
import { T } from './data/tables'
|
|
3
4
|
|
|
4
5
|
// ---------------------------------------------------------------------------
|
|
5
6
|
// Settings registries — surfaced as CRUD tabs in the central Settings area
|
|
@@ -24,15 +25,23 @@ const channelEntity: EntityDef = {
|
|
|
24
25
|
defaultSort: 'label',
|
|
25
26
|
fields: [
|
|
26
27
|
{ key: 'label', label: 'Channel', type: 'text', required: true, showInTable: true, searchable: true },
|
|
28
|
+
{ key: 'channelKey', label: 'Key', type: 'text', required: true, showInTable: true },
|
|
27
29
|
{ key: 'kind', label: 'Type', type: 'select', options: KIND_OPTIONS, showInTable: true },
|
|
28
|
-
{ key: 'icon', label: 'Icon', type: 'text'
|
|
30
|
+
{ key: 'icon', label: 'Icon', type: 'text' },
|
|
31
|
+
{ key: 'monthlySpend', label: 'Monthly spend', type: 'number', showInTable: true, defaultValue: 0 },
|
|
32
|
+
{ key: 'isActive', label: 'Active', type: 'boolean', showInTable: true, defaultValue: true, inlineToggle: true },
|
|
29
33
|
],
|
|
30
34
|
facets: [{ field: 'kind' }],
|
|
35
|
+
data: {
|
|
36
|
+
table: T.channels,
|
|
37
|
+
tenantScoped: true,
|
|
38
|
+
searchColumns: ['label', 'channel_key'],
|
|
39
|
+
},
|
|
31
40
|
}
|
|
32
41
|
|
|
33
|
-
/** Build the marketing settings registries
|
|
34
|
-
*
|
|
35
|
-
*
|
|
42
|
+
/** Build the marketing settings registries. Channels are tenant-editable rows
|
|
43
|
+
* in plg_marketing_channels; the Supabase provider lazily seeds them from the
|
|
44
|
+
* resolved domain preset (seedData doubles as walkable mock data). */
|
|
36
45
|
export function buildMarketingRegistries(channels: AcquisitionChannel[]): PluginRegistryDef[] {
|
|
37
46
|
return [
|
|
38
47
|
{
|
|
@@ -41,8 +50,11 @@ export function buildMarketingRegistries(channels: AcquisitionChannel[]): Plugin
|
|
|
41
50
|
icon: 'Radio',
|
|
42
51
|
description: 'Acquisition channels tracked for this workspace',
|
|
43
52
|
display: 'table',
|
|
44
|
-
|
|
45
|
-
|
|
53
|
+
seedData: [
|
|
54
|
+
...channels.map((c) => ({
|
|
55
|
+
id: c.id, channelKey: c.id, label: c.label, kind: c.kind, icon: c.icon, isActive: true, monthlySpend: 0,
|
|
56
|
+
})),
|
|
57
|
+
],
|
|
46
58
|
},
|
|
47
59
|
]
|
|
48
60
|
}
|
package/src/schema/index.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Builders come from @fayz-ai/db (single drizzle-orm instance) — never import
|
|
2
2
|
// pgTable/uuid directly from drizzle-orm/pg-core. See MIGRATION-ARCHITECTURE §6.
|
|
3
3
|
import {
|
|
4
|
-
pgTable, uuid, text, boolean, integer, date, jsonb, index,
|
|
4
|
+
pgTable, uuid, text, boolean, integer, date, jsonb, index, uniqueIndex,
|
|
5
|
+
numeric, timestamp,
|
|
5
6
|
tenantId, timestamps, createdAt,
|
|
6
7
|
} from '@fayz-ai/db'
|
|
7
8
|
|
|
@@ -11,6 +12,42 @@ import {
|
|
|
11
12
|
// see src/migrations/001_content_planner.sql.
|
|
12
13
|
// ---------------------------------------------------------------------------
|
|
13
14
|
|
|
15
|
+
// Acquisition channels — tenant-owned rows seeded from the domain preset.
|
|
16
|
+
// `channel_key` is the stable preset/config id ('instagram', 'paid-search'…),
|
|
17
|
+
// aligned with plugin-crm's lead-sources where they overlap.
|
|
18
|
+
export const mktChannels = pgTable('plg_marketing_channels', {
|
|
19
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
20
|
+
tenantId: tenantId(),
|
|
21
|
+
channelKey: text('channel_key').notNull(),
|
|
22
|
+
label: text('label').notNull(),
|
|
23
|
+
icon: text('icon'),
|
|
24
|
+
kind: text('kind').notNull().default('organic'),
|
|
25
|
+
isActive: boolean('is_active').notNull().default(true),
|
|
26
|
+
monthlySpend: numeric('monthly_spend').notNull().default('0'),
|
|
27
|
+
...timestamps,
|
|
28
|
+
}, (t) => [
|
|
29
|
+
index('idx_plg_marketing_channels_tenant').on(t.tenantId),
|
|
30
|
+
uniqueIndex('u_plg_marketing_channels_tenant_key').on(t.tenantId, t.channelKey),
|
|
31
|
+
])
|
|
32
|
+
|
|
33
|
+
// Acquisition campaigns — real CRUD; performance is DERIVED at read time from
|
|
34
|
+
// v_marketing_attribution (channel match within the campaign window), only
|
|
35
|
+
// spend is stored.
|
|
36
|
+
export const mktCampaigns = pgTable('plg_marketing_campaigns', {
|
|
37
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
38
|
+
tenantId: tenantId(),
|
|
39
|
+
name: text('name').notNull(),
|
|
40
|
+
channelKey: text('channel_key').notNull(),
|
|
41
|
+
status: text('status').notNull().default('draft'),
|
|
42
|
+
startsAt: timestamp('starts_at', { withTimezone: true }),
|
|
43
|
+
endsAt: timestamp('ends_at', { withTimezone: true }),
|
|
44
|
+
spend: numeric('spend').notNull().default('0'),
|
|
45
|
+
...timestamps,
|
|
46
|
+
}, (t) => [
|
|
47
|
+
index('idx_plg_marketing_campaigns_tenant').on(t.tenantId),
|
|
48
|
+
index('idx_plg_marketing_campaigns_channel').on(t.tenantId, t.channelKey),
|
|
49
|
+
])
|
|
50
|
+
|
|
14
51
|
export const mktSocialAccounts = pgTable('plg_marketing_social_accounts', {
|
|
15
52
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
16
53
|
tenantId: tenantId(),
|
package/src/store.ts
CHANGED
|
@@ -19,6 +19,8 @@ export interface MarketingUIState {
|
|
|
19
19
|
loading: boolean
|
|
20
20
|
|
|
21
21
|
load(): Promise<void>
|
|
22
|
+
/** Invalidate the provider cache and reload (external writes, e.g. agent RPCs). */
|
|
23
|
+
refresh(): Promise<void>
|
|
22
24
|
setRange(range: DateRangeKey): Promise<void>
|
|
23
25
|
saveCampaign(input: SaveCampaignInput): Promise<Campaign>
|
|
24
26
|
deleteCampaign(id: string): Promise<void>
|
|
@@ -37,14 +39,25 @@ export function createMarketingStore(provider: MarketingDataProvider): StoreApi<
|
|
|
37
39
|
async load() {
|
|
38
40
|
const range = get().range
|
|
39
41
|
set({ loading: true })
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
42
|
+
try {
|
|
43
|
+
const [overview, channels, campaigns, funnel, landingPages] = await Promise.all([
|
|
44
|
+
provider.overview({ range }),
|
|
45
|
+
provider.channelPerformance({ range }),
|
|
46
|
+
provider.listCampaigns({ range }),
|
|
47
|
+
provider.funnel({ range }),
|
|
48
|
+
provider.landingPages({ range }),
|
|
49
|
+
])
|
|
50
|
+
set({ overview, channels, campaigns, funnel, landingPages, loading: false })
|
|
51
|
+
} catch (err) {
|
|
52
|
+
// A failed load must never wedge the surface in `loading` forever.
|
|
53
|
+
set({ loading: false })
|
|
54
|
+
throw err
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
async refresh() {
|
|
59
|
+
provider.invalidateCache?.()
|
|
60
|
+
await get().load()
|
|
48
61
|
},
|
|
49
62
|
|
|
50
63
|
async setRange(range) {
|
|
@@ -2,6 +2,7 @@ import React from 'react'
|
|
|
2
2
|
import { Plus, Trash2 } from 'lucide-react'
|
|
3
3
|
import type { ColumnDef } from '@tanstack/react-table'
|
|
4
4
|
import { Button, DataTable } from '@fayz-ai/ui'
|
|
5
|
+
import { PermissionGate } from '@fayz-ai/saas'
|
|
5
6
|
import { useTranslation } from '@fayz-ai/core'
|
|
6
7
|
import { useMarketingConfig, useMarketingStore } from '../MarketingContext'
|
|
7
8
|
import { formatCurrency, formatNumber, formatPercent } from '../format'
|
|
@@ -44,7 +45,9 @@ export function CampaignsView() {
|
|
|
44
45
|
<p className="text-sm text-muted-foreground">{t('marketing.campaigns.subtitle')}</p>
|
|
45
46
|
<div className="flex items-center gap-2">
|
|
46
47
|
<RangeTabs />
|
|
47
|
-
<
|
|
48
|
+
<PermissionGate feature="marketing" action="create">
|
|
49
|
+
<Button onClick={() => setComposerOpen(true)}><Plus className="mr-1.5 h-4 w-4" /> {t('marketing.campaigns.new')}</Button>
|
|
50
|
+
</PermissionGate>
|
|
48
51
|
</div>
|
|
49
52
|
</div>
|
|
50
53
|
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
2
|
import { SubpageHeader, Button, toast } from '@fayz-ai/ui'
|
|
3
|
-
import { CrudFormPage } from '@fayz-ai/saas'
|
|
3
|
+
import { CrudFormPage, useLimitGuard, invalidateLimit } from '@fayz-ai/saas'
|
|
4
4
|
import type { EntityDef } from '@fayz-ai/core'
|
|
5
5
|
import { useEntityCrud } from './useEntityCrud'
|
|
6
|
+
import { BLOG_POSTS_TABLE } from '../../data/blogEntities'
|
|
6
7
|
|
|
7
8
|
// ---------------------------------------------------------------------------
|
|
8
9
|
// Generic create/edit for a blog entity (post or category), rendered through
|
|
@@ -24,6 +25,9 @@ export function BlogEntityForm<T extends { id: string }>({
|
|
|
24
25
|
}) {
|
|
25
26
|
const { getById, create, update, remove } = useEntityCrud(entity)
|
|
26
27
|
const isEdit = !!editId
|
|
28
|
+
// Plan quantity guard applies to blog POSTS only (categories are unbounded).
|
|
29
|
+
const isPost = (entity as { data?: { table?: string } }).data?.table === BLOG_POSTS_TABLE
|
|
30
|
+
const guardBlogPosts = useLimitGuard('blog_posts')
|
|
27
31
|
const namePlural = entity.namePlural ?? entity.name + 's'
|
|
28
32
|
const [initialData, setInitialData] = React.useState<Record<string, any> | null>(isEdit ? null : {})
|
|
29
33
|
|
|
@@ -38,9 +42,14 @@ export function BlogEntityForm<T extends { id: string }>({
|
|
|
38
42
|
}, [editId, getById])
|
|
39
43
|
|
|
40
44
|
async function save(values: Record<string, any>) {
|
|
45
|
+
// Client-side plan guard before the provider create — posts only, on create.
|
|
46
|
+
if (!isEdit && isPost && (await guardBlogPosts()) === 'blocked') return
|
|
41
47
|
try {
|
|
42
48
|
if (isEdit) await update(editId!, values)
|
|
43
|
-
else
|
|
49
|
+
else {
|
|
50
|
+
await create(values)
|
|
51
|
+
if (isPost) invalidateLimit('blog_posts')
|
|
52
|
+
}
|
|
44
53
|
toast.success(isEdit ? `${entity.name} updated` : `${entity.name} created`)
|
|
45
54
|
onDone()
|
|
46
55
|
} catch (err) {
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
Modal, ModalContent, ModalHeader, ModalTitle, ModalBody, ModalFooter,
|
|
11
11
|
} from '@fayz-ai/ui'
|
|
12
12
|
import { useTranslation } from '@fayz-ai/core'
|
|
13
|
-
import { useOrganizationStore } from '@fayz-ai/saas'
|
|
13
|
+
import { useOrganizationStore, useLimitGuard, invalidateLimit } from '@fayz-ai/saas'
|
|
14
14
|
import { PlanBriefSheet } from './PlanBriefView'
|
|
15
15
|
import type { ContentPost } from '../../data/contentTypes'
|
|
16
16
|
import { useContentPlannerStore } from './ContentPlannerContext'
|
|
@@ -727,6 +727,7 @@ export function ContentView({ onOpenPost }: {
|
|
|
727
727
|
const viewMode = useContentPlannerStore((s) => s.viewMode)
|
|
728
728
|
const setViewMode = useContentPlannerStore((s) => s.setViewMode)
|
|
729
729
|
const loadedTenantId = useContentPlannerStore((s) => s.loadedTenantId)
|
|
730
|
+
const guardContentPosts = useLimitGuard('content_posts_month')
|
|
730
731
|
|
|
731
732
|
const [briefOpen, setBriefOpen] = React.useState(false)
|
|
732
733
|
const [pendingDeleteId, setPendingDeleteId] = React.useState<string | null>(null)
|
|
@@ -765,6 +766,8 @@ export function ContentView({ onOpenPost }: {
|
|
|
765
766
|
// default plan and drops the post straight into it. The scheduled date
|
|
766
767
|
// prefills with the first day of the selected week so the post lands dated.
|
|
767
768
|
const createPost = async (week: number) => {
|
|
769
|
+
// Client-side monthly-quota guard before the store create.
|
|
770
|
+
if ((await guardContentPosts()) === 'blocked') return
|
|
768
771
|
const targetPlan = await ensurePlan()
|
|
769
772
|
if (!targetPlan) return
|
|
770
773
|
const start = targetPlan.startDate
|
|
@@ -779,6 +782,7 @@ export function ContentView({ onOpenPost }: {
|
|
|
779
782
|
status: 'idea',
|
|
780
783
|
scheduledDate: toISODate(start),
|
|
781
784
|
})
|
|
785
|
+
invalidateLimit('content_posts_month')
|
|
782
786
|
onOpenPost(post.id)
|
|
783
787
|
}
|
|
784
788
|
|
|
@@ -790,6 +794,8 @@ export function ContentView({ onOpenPost }: {
|
|
|
790
794
|
|
|
791
795
|
// Calendar day click → derive the plan week from startDate when available.
|
|
792
796
|
const createPostAt = async (isoDate: string) => {
|
|
797
|
+
// Client-side monthly-quota guard before the store create.
|
|
798
|
+
if ((await guardContentPosts()) === 'blocked') return
|
|
793
799
|
const targetPlan = await ensurePlan()
|
|
794
800
|
if (!targetPlan) return
|
|
795
801
|
let week = 1
|
|
@@ -807,6 +813,7 @@ export function ContentView({ onOpenPost }: {
|
|
|
807
813
|
status: 'idea',
|
|
808
814
|
scheduledDate: isoDate,
|
|
809
815
|
})
|
|
816
|
+
invalidateLimit('content_posts_month')
|
|
810
817
|
onOpenPost(post.id)
|
|
811
818
|
}
|
|
812
819
|
|