@fayz-ai/plugin-marketing 0.13.32 → 0.13.34

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.
@@ -0,0 +1,1660 @@
1
+ // src/migrations/index.ts
2
+ var MIGRATION_000_BASELINE = `-- ============================================================================
3
+ -- plugins/plugin-marketing/src/migrations/000_baseline.sql \u2014 what installing this provisions, as one file.
4
+ --
5
+ -- GENERATED by scripts/emit-unit-baselines.mjs (#338). Do not edit by hand:
6
+ -- change the schema, regenerate, and let scripts/check-chain-equivalence.mjs
7
+ -- prove the result still matches.
8
+ --
9
+ -- This replaced 12 migration files. They are kept, unapplied, in
10
+ -- migrations.archive/ \u2014 a year of decisions is worth reading even when it is no
11
+ -- longer worth replaying.
12
+ --
13
+ -- Object order is fixed rather than dependency-sorted: schemas, types,
14
+ -- sequences, tables, defaults, functions, constraints, views, indexes, foreign
15
+ -- keys, triggers, row security, policies, grants. Dependencies BETWEEN units
16
+ -- are carried by the order packages/db/chain.json declares.
17
+ -- ============================================================================
18
+
19
+ -- A LANGUAGE sql function binds its body at CREATE time, and 570 functions
20
+ -- sorted by name are not sorted by who calls whom. This is what lets them load
21
+ -- in any order; every body is still compiled on first call, so a genuinely
22
+ -- broken reference surfaces then rather than never. Session-scoped.
23
+ SET check_function_bodies = false;
24
+
25
+
26
+
27
+ -- \u2500\u2500 default privileges: reset \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
28
+ -- Cleared BEFORE anything is created, so every object below gets the ACL the
29
+ -- dump describes rather than the image's defaults on top of it. Restored at
30
+ -- the end of this file.
31
+
32
+ DO $dp$ BEGIN
33
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role, fayz_connector$stmt$;
34
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
35
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role, fayz_connector$stmt$;
36
+ END $dp$;
37
+
38
+ DO $dp$ BEGIN
39
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role, fayz_connector$stmt$;
40
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
41
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role, fayz_connector$stmt$;
42
+ END $dp$;
43
+
44
+ DO $dp$ BEGIN
45
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role, fayz_connector$stmt$;
46
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
47
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role, fayz_connector$stmt$;
48
+ END $dp$;
49
+
50
+ DO $dp$ BEGIN
51
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role, fayz_connector$stmt$;
52
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
53
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role, fayz_connector$stmt$;
54
+ END $dp$;
55
+
56
+ DO $dp$ BEGIN
57
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role, fayz_connector$stmt$;
58
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
59
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role, fayz_connector$stmt$;
60
+ END $dp$;
61
+
62
+ DO $dp$ BEGIN
63
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role, fayz_connector$stmt$;
64
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
65
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role, fayz_connector$stmt$;
66
+ END $dp$;
67
+
68
+
69
+ -- \u2500\u2500 table \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
70
+
71
+ CREATE TABLE public.plg_marketing_audiences (
72
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
73
+ tenant_id uuid NOT NULL,
74
+ unit_id uuid,
75
+ campaign_id uuid NOT NULL,
76
+ person_id uuid NOT NULL,
77
+ state text DEFAULT 'entered'::text NOT NULL,
78
+ entered_at timestamp with time zone DEFAULT now() NOT NULL,
79
+ queued_at timestamp with time zone,
80
+ converted_at timestamp with time zone,
81
+ left_at timestamp with time zone,
82
+ reason text,
83
+ queue_id uuid,
84
+ owner_id uuid,
85
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
86
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
87
+ CONSTRAINT plg_marketing_audiences_state_check CHECK ((state = ANY (ARRAY['entered'::text, 'queued'::text, 'sent'::text, 'converted'::text, 'left'::text, 'blocked'::text])))
88
+ );
89
+
90
+ ALTER TABLE ONLY public.plg_marketing_audiences FORCE ROW LEVEL SECURITY;
91
+
92
+ CREATE TABLE public.plg_marketing_campaigns (
93
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
94
+ tenant_id uuid NOT NULL,
95
+ name text NOT NULL,
96
+ channel_key text NOT NULL,
97
+ status text DEFAULT 'draft'::text NOT NULL,
98
+ starts_at timestamp with time zone,
99
+ ends_at timestamp with time zone,
100
+ spend numeric DEFAULT 0 NOT NULL,
101
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
102
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
103
+ unit_id uuid,
104
+ owner_id uuid,
105
+ kind text DEFAULT 'ad_spend'::text NOT NULL,
106
+ key text,
107
+ template_key text,
108
+ audience_query jsonb DEFAULT '{}'::jsonb NOT NULL,
109
+ purpose text DEFAULT 'promotion'::text NOT NULL,
110
+ schedule text,
111
+ last_run_at timestamp with time zone,
112
+ next_run_at timestamp with time zone,
113
+ metadata jsonb DEFAULT '{}'::jsonb NOT NULL,
114
+ CONSTRAINT plg_marketing_campaigns_kind_check CHECK ((kind = ANY (ARRAY['ad_spend'::text, 'outbound'::text]))),
115
+ CONSTRAINT plg_marketing_campaigns_outbound_shape CHECK (((kind <> 'outbound'::text) OR ((template_key IS NOT NULL) AND (key IS NOT NULL)))),
116
+ CONSTRAINT plg_marketing_campaigns_purpose_check CHECK ((purpose = ANY (ARRAY['promotion'::text, 'survey'::text, 'newsletter'::text, 'transactional'::text]))),
117
+ CONSTRAINT plg_marketing_campaigns_status_check CHECK ((status = ANY (ARRAY['active'::text, 'paused'::text, 'ended'::text, 'draft'::text])))
118
+ );
119
+
120
+ ALTER TABLE ONLY public.plg_marketing_campaigns FORCE ROW LEVEL SECURITY;
121
+
122
+ CREATE TABLE public.plg_marketing_channels (
123
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
124
+ tenant_id uuid NOT NULL,
125
+ channel_key text NOT NULL,
126
+ label text NOT NULL,
127
+ icon text,
128
+ kind text DEFAULT 'organic'::text NOT NULL,
129
+ is_active boolean DEFAULT true NOT NULL,
130
+ monthly_spend numeric DEFAULT 0 NOT NULL,
131
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
132
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
133
+ unit_id uuid,
134
+ owner_id uuid,
135
+ CONSTRAINT plg_marketing_channels_kind_check CHECK ((kind = ANY (ARRAY['paid'::text, 'organic'::text, 'social'::text, 'referral'::text, 'direct'::text, 'outbound'::text])))
136
+ );
137
+
138
+ ALTER TABLE ONLY public.plg_marketing_channels FORCE ROW LEVEL SECURITY;
139
+
140
+ CREATE TABLE public.plg_marketing_consents (
141
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
142
+ tenant_id uuid NOT NULL,
143
+ unit_id uuid,
144
+ person_id uuid NOT NULL,
145
+ purpose text NOT NULL,
146
+ granted_at timestamp with time zone,
147
+ revoked_at timestamp with time zone,
148
+ source text,
149
+ evidence jsonb DEFAULT '{}'::jsonb NOT NULL,
150
+ owner_id uuid,
151
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
152
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
153
+ CONSTRAINT plg_marketing_consents_purpose_check CHECK ((purpose = ANY (ARRAY['promotion'::text, 'survey'::text, 'newsletter'::text, 'transactional'::text])))
154
+ );
155
+
156
+ ALTER TABLE ONLY public.plg_marketing_consents FORCE ROW LEVEL SECURITY;
157
+
158
+ CREATE TABLE public.plg_marketing_content_plans (
159
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
160
+ tenant_id uuid NOT NULL,
161
+ account_id uuid NOT NULL,
162
+ name text NOT NULL,
163
+ status text DEFAULT 'active'::text NOT NULL,
164
+ weeks_count integer DEFAULT 8 NOT NULL,
165
+ start_date date,
166
+ objective text,
167
+ tone text,
168
+ pillars jsonb DEFAULT '[]'::jsonb NOT NULL,
169
+ formats jsonb DEFAULT '[]'::jsonb NOT NULL,
170
+ weekly_frequency integer DEFAULT 3 NOT NULL,
171
+ brief_md text DEFAULT ''::text NOT NULL,
172
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
173
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
174
+ CONSTRAINT plg_marketing_content_plans_status_check CHECK ((status = ANY (ARRAY['draft'::text, 'active'::text, 'archived'::text])))
175
+ );
176
+
177
+ CREATE TABLE public.plg_marketing_content_posts (
178
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
179
+ tenant_id uuid NOT NULL,
180
+ plan_id uuid NOT NULL,
181
+ week_number integer DEFAULT 1 NOT NULL,
182
+ "position" integer DEFAULT 0 NOT NULL,
183
+ title text NOT NULL,
184
+ format text DEFAULT 'reel'::text NOT NULL,
185
+ status text DEFAULT 'idea'::text NOT NULL,
186
+ scheduled_date date,
187
+ hook text,
188
+ cta text,
189
+ content_md text DEFAULT ''::text NOT NULL,
190
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
191
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
192
+ platforms text[] DEFAULT '{}'::text[] NOT NULL,
193
+ checklist jsonb DEFAULT '[]'::jsonb NOT NULL,
194
+ media_url text,
195
+ CONSTRAINT plg_marketing_content_posts_format_check CHECK ((format = ANY (ARRAY['reel'::text, 'static'::text, 'carousel'::text, 'story'::text]))),
196
+ CONSTRAINT plg_marketing_content_posts_status_check CHECK ((status = ANY (ARRAY['idea'::text, 'script'::text, 'recording'::text, 'editing'::text, 'scheduled'::text, 'published'::text])))
197
+ );
198
+
199
+ CREATE TABLE public.plg_marketing_landing_pages (
200
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
201
+ tenant_id uuid NOT NULL,
202
+ site_id uuid NOT NULL,
203
+ name text NOT NULL,
204
+ route text NOT NULL,
205
+ lead_tags text[] DEFAULT '{}'::text[] NOT NULL,
206
+ status text DEFAULT 'live'::text NOT NULL,
207
+ prompt text,
208
+ is_active boolean DEFAULT true NOT NULL,
209
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
210
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
211
+ unit_id uuid,
212
+ owner_id uuid,
213
+ CONSTRAINT plg_marketing_landing_pages_status_check CHECK ((status = ANY (ARRAY['live'::text, 'planned'::text])))
214
+ );
215
+
216
+ ALTER TABLE ONLY public.plg_marketing_landing_pages FORCE ROW LEVEL SECURITY;
217
+
218
+ CREATE TABLE public.plg_marketing_social_accounts (
219
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
220
+ tenant_id uuid NOT NULL,
221
+ name text NOT NULL,
222
+ handle text,
223
+ is_active boolean DEFAULT true NOT NULL,
224
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
225
+ platforms text[] DEFAULT '{instagram}'::text[] NOT NULL
226
+ );
227
+
228
+
229
+ -- \u2500\u2500 function \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
230
+
231
+ CREATE FUNCTION public.agent_marketing_create_campaign(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) RETURNS jsonb
232
+ LANGUAGE plpgsql SECURITY DEFINER
233
+ SET search_path TO 'public'
234
+ AS $$
235
+ DECLARE
236
+ v_denial jsonb;
237
+ v_used int;
238
+ v_name text;
239
+ v_channel_raw text;
240
+ v_channel_key text;
241
+ v_status text;
242
+ v_starts timestamptz;
243
+ v_ends timestamptz;
244
+ v_spend numeric;
245
+ v_has_channels boolean;
246
+ v_id uuid;
247
+ BEGIN
248
+ -- \u2500\u2500 payload \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
249
+ v_name := left(trim(p_payload->>'name'), 200);
250
+ v_channel_raw := trim(p_payload->>'channel_key');
251
+ v_status := COALESCE(NULLIF(trim(p_payload->>'status'), ''), 'draft');
252
+ v_spend := COALESCE(NULLIF(p_payload->>'spend', '')::numeric, 0);
253
+ BEGIN
254
+ v_starts := NULLIF(p_payload->>'starts_at', '')::timestamptz;
255
+ v_ends := NULLIF(p_payload->>'ends_at', '')::timestamptz;
256
+ EXCEPTION WHEN OTHERS THEN
257
+ RETURN jsonb_build_object('ok', false, 'error', 'invalid payload: ' || SQLERRM);
258
+ END;
259
+ IF v_name IS NULL OR v_name = '' OR v_channel_raw IS NULL OR v_channel_raw = '' THEN
260
+ RETURN jsonb_build_object('ok', false, 'error', 'name and channel_key are required');
261
+ END IF;
262
+ IF v_status NOT IN ('active', 'paused', 'ended', 'draft') THEN
263
+ RETURN jsonb_build_object('ok', false, 'error', 'status must be one of active|paused|ended|draft');
264
+ END IF;
265
+
266
+ -- \u2500\u2500 authorization: role \u2192 plan \u2192 campaigns_active cap \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
267
+ SELECT count(*) INTO v_used FROM plg_marketing_campaigns c
268
+ WHERE c.tenant_id = p_tenant_id AND c.status = 'active';
269
+ v_denial := agent_guard(p_tenant_id, p_actor_user_id,
270
+ 'marketing', 'create', 'campaigns_active', v_used,
271
+ CASE WHEN v_status = 'active' THEN 1 ELSE 0 END);
272
+ IF v_denial IS NOT NULL THEN
273
+ RETURN jsonb_build_object('ok', false, 'denial', v_denial);
274
+ END IF;
275
+
276
+ -- \u2500\u2500 channel resolution (key or label, normalized) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
277
+ SELECT EXISTS (SELECT 1 FROM plg_marketing_channels ch WHERE ch.tenant_id = p_tenant_id)
278
+ INTO v_has_channels;
279
+ IF v_has_channels THEN
280
+ SELECT ch.channel_key INTO v_channel_key
281
+ FROM plg_marketing_channels ch
282
+ WHERE ch.tenant_id = p_tenant_id
283
+ AND (
284
+ regexp_replace(lower(ch.channel_key), '[^a-z0-9]', '', 'g')
285
+ = regexp_replace(lower(v_channel_raw), '[^a-z0-9]', '', 'g')
286
+ OR regexp_replace(lower(ch.label), '[^a-z0-9]', '', 'g')
287
+ = regexp_replace(lower(v_channel_raw), '[^a-z0-9]', '', 'g')
288
+ )
289
+ LIMIT 1;
290
+ IF v_channel_key IS NULL THEN
291
+ RETURN jsonb_build_object('ok', false, 'error',
292
+ 'unknown channel "' || v_channel_raw || '" \u2014 valid channels: ' ||
293
+ COALESCE((SELECT string_agg(ch.channel_key, ', ' ORDER BY ch.channel_key)
294
+ FROM plg_marketing_channels ch WHERE ch.tenant_id = p_tenant_id), ''));
295
+ END IF;
296
+ ELSE
297
+ v_channel_key := v_channel_raw;
298
+ END IF;
299
+
300
+ -- \u2500\u2500 write + audit \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
301
+ INSERT INTO plg_marketing_campaigns (tenant_id, name, channel_key, status, starts_at, ends_at, spend)
302
+ VALUES (p_tenant_id, v_name, v_channel_key, v_status, COALESCE(v_starts, now()), v_ends, v_spend)
303
+ RETURNING id INTO v_id;
304
+
305
+ INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)
306
+ VALUES (p_tenant_id, p_actor_user_id, 'agent.createCampaign', 'marketing_campaign', v_id::text,
307
+ jsonb_build_object('payload', p_payload));
308
+
309
+ RETURN jsonb_build_object(
310
+ 'ok', true,
311
+ 'id', v_id,
312
+ 'record', jsonb_build_object(
313
+ 'id', v_id,
314
+ 'name', v_name,
315
+ 'channel_key', v_channel_key,
316
+ 'status', v_status,
317
+ 'starts_at', COALESCE(v_starts, now()),
318
+ 'spend', v_spend
319
+ )
320
+ );
321
+ END;
322
+ $$;
323
+
324
+ CREATE FUNCTION public.marketing_campaign_run(p_campaign_id uuid, p_limit integer DEFAULT 1000) RETURNS jsonb
325
+ LANGUAGE plpgsql SECURITY DEFINER
326
+ SET search_path TO ''
327
+ AS $$
328
+ DECLARE
329
+ c public.plg_marketing_campaigns%ROWTYPE;
330
+ v_person uuid;
331
+ v_queue uuid;
332
+ v_entered integer := 0;
333
+ v_queued integer := 0;
334
+ v_left integer := 0;
335
+ BEGIN
336
+ SELECT * INTO c FROM public.plg_marketing_campaigns WHERE id = p_campaign_id;
337
+ IF c.id IS NULL THEN
338
+ RAISE EXCEPTION 'marketing_campaign_run: campaign % not found', p_campaign_id USING ERRCODE = 'P0002';
339
+ END IF;
340
+ IF c.kind <> 'outbound' THEN
341
+ RETURN jsonb_build_object('skipped', 'not an outbound campaign');
342
+ END IF;
343
+ IF to_regprocedure('public.notifications_enqueue(uuid, text, uuid, jsonb, text, timestamptz, text, text, uuid, uuid)') IS NULL THEN
344
+ RAISE EXCEPTION 'marketing_campaign_run: plugin-notifications is not installed' USING ERRCODE = '0A000';
345
+ END IF;
346
+
347
+ -- Whoever no longer qualifies LEAVES, and the moment is recorded. Revoking
348
+ -- consent has to show up as an act, not as an absence.
349
+ UPDATE public.plg_marketing_audiences a
350
+ SET state = 'left', left_at = now(), reason = 'no longer in the audience'
351
+ WHERE a.campaign_id = c.id
352
+ AND a.state IN ('entered', 'queued')
353
+ AND NOT EXISTS (SELECT 1 FROM public.marketing_resolve_audience(c.id) r WHERE r.person_id = a.person_id);
354
+ GET DIAGNOSTICS v_left = ROW_COUNT;
355
+
356
+ FOR v_person IN
357
+ SELECT r.person_id FROM public.marketing_resolve_audience(c.id) r
358
+ LIMIT greatest(p_limit, 0)
359
+ LOOP
360
+ INSERT INTO public.plg_marketing_audiences (tenant_id, unit_id, campaign_id, person_id, state)
361
+ VALUES (c.tenant_id, c.unit_id, c.id, v_person, 'entered')
362
+ ON CONFLICT (campaign_id, person_id) DO NOTHING;
363
+ IF FOUND THEN v_entered := v_entered + 1; END IF;
364
+
365
+ -- Enqueue once per (campaign, person). The dedupe key is what makes a second
366
+ -- run of the same campaign harmless, exactly as a redelivered event is.
367
+ IF EXISTS (SELECT 1 FROM public.plg_marketing_audiences a
368
+ WHERE a.campaign_id = c.id AND a.person_id = v_person AND a.state = 'entered') THEN
369
+ v_queue := public.notifications_enqueue(
370
+ p_tenant_id => c.tenant_id,
371
+ p_template_key => c.template_key,
372
+ p_person_id => v_person,
373
+ p_payload => jsonb_build_object('campaign', c.name, 'campaign_key', c.key),
374
+ p_dedupe_key => 'campaign:' || c.id::text || ':' || v_person::text,
375
+ p_unit_id => c.unit_id);
376
+
377
+ UPDATE public.plg_marketing_audiences
378
+ SET state = 'queued', queued_at = now(), queue_id = v_queue
379
+ WHERE campaign_id = c.id AND person_id = v_person;
380
+ v_queued := v_queued + 1;
381
+ END IF;
382
+ END LOOP;
383
+
384
+ UPDATE public.plg_marketing_campaigns
385
+ SET last_run_at = now(), next_run_at = now() + interval '1 day'
386
+ WHERE id = c.id;
387
+
388
+ RETURN jsonb_build_object('campaign_id', c.id, 'entered', v_entered, 'queued', v_queued, 'left', v_left);
389
+ END $$;
390
+
391
+ CREATE FUNCTION public.marketing_has_consent(p_tenant uuid, p_person uuid, p_purpose text) RETURNS boolean
392
+ LANGUAGE sql STABLE SECURITY DEFINER
393
+ SET search_path TO ''
394
+ AS $$
395
+ SELECT CASE
396
+ -- The carve-out, named rather than assumed: a message about something the
397
+ -- person already bought is not marketing. It is still subject to the channel
398
+ -- opt-out, which the notification engine applies on its own.
399
+ WHEN p_purpose = 'transactional' THEN true
400
+ ELSE EXISTS (
401
+ SELECT 1 FROM public.plg_marketing_consents c
402
+ WHERE c.tenant_id = p_tenant AND c.person_id = p_person AND c.purpose = p_purpose
403
+ AND c.granted_at IS NOT NULL AND c.revoked_at IS NULL)
404
+ END
405
+ $$;
406
+
407
+ CREATE FUNCTION public.marketing_resolve_audience(p_campaign_id uuid) RETURNS TABLE(person_id uuid)
408
+ LANGUAGE plpgsql STABLE SECURITY DEFINER
409
+ SET search_path TO ''
410
+ AS $_$
411
+ DECLARE
412
+ c public.plg_marketing_campaigns%ROWTYPE;
413
+ v_source text;
414
+ v_where text := '';
415
+ v_clause jsonb;
416
+ v_kind char;
417
+ BEGIN
418
+ SELECT * INTO c FROM public.plg_marketing_campaigns WHERE id = p_campaign_id;
419
+ IF c.id IS NULL THEN
420
+ RAISE EXCEPTION 'marketing_resolve_audience: campaign % not found', p_campaign_id USING ERRCODE = 'P0002';
421
+ END IF;
422
+
423
+ v_source := coalesce(c.audience_query ->> 'source_view', 'people');
424
+ IF v_source !~ '^[a-z_][a-z0-9_]*$' THEN
425
+ RAISE EXCEPTION 'marketing_resolve_audience: % is not a bare relation name', v_source USING ERRCODE = '22023';
426
+ END IF;
427
+ SELECT cl.relkind INTO v_kind FROM pg_class cl JOIN pg_namespace n ON n.oid = cl.relnamespace
428
+ WHERE n.nspname = 'public' AND cl.relname = v_source;
429
+ IF v_kind IS NULL THEN
430
+ RAISE EXCEPTION 'marketing_resolve_audience: public.% does not exist', v_source USING ERRCODE = '42P01';
431
+ END IF;
432
+
433
+ v_where := format('tenant_id = %L', c.tenant_id);
434
+
435
+ FOR v_clause IN SELECT * FROM jsonb_array_elements(coalesce(c.audience_query -> 'where', '[]'::jsonb)) LOOP
436
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns
437
+ WHERE table_schema = 'public' AND table_name = v_source
438
+ AND column_name = v_clause ->> 'column') THEN
439
+ RAISE EXCEPTION 'marketing_resolve_audience: public.% has no column %', v_source, v_clause ->> 'column'
440
+ USING ERRCODE = '42703';
441
+ END IF;
442
+ -- The operator comes from a fixed set and the value is quoted as a literal:
443
+ -- the audience is declarative on purpose, so a campaign can never become a
444
+ -- way to run arbitrary SQL.
445
+ v_where := v_where || format(' AND %I %s %L',
446
+ v_clause ->> 'column',
447
+ CASE v_clause ->> 'op' WHEN '>=' THEN '>=' WHEN '<=' THEN '<=' WHEN '>' THEN '>' WHEN '<' THEN '<'
448
+ WHEN '<>' THEN '<>' WHEN 'ilike' THEN 'ILIKE' ELSE '=' END,
449
+ v_clause ->> 'value');
450
+ END LOOP;
451
+
452
+ RETURN QUERY EXECUTE format(
453
+ 'SELECT s.id FROM public.%I s WHERE %s AND public.marketing_has_consent(%L, s.id, %L)',
454
+ v_source, v_where, c.tenant_id, c.purpose);
455
+ END $_$;
456
+
457
+ CREATE FUNCTION public.marketing_run_due(p_limit integer DEFAULT 20) RETURNS jsonb
458
+ LANGUAGE plpgsql SECURITY DEFINER
459
+ SET search_path TO ''
460
+ AS $$
461
+ DECLARE c record; v_n integer := 0; v_out jsonb := '[]'::jsonb;
462
+ BEGIN
463
+ FOR c IN
464
+ SELECT id FROM public.plg_marketing_campaigns
465
+ WHERE kind = 'outbound' AND status = 'active'
466
+ AND (next_run_at IS NULL OR next_run_at <= now())
467
+ AND (starts_at IS NULL OR starts_at <= now())
468
+ AND (ends_at IS NULL OR ends_at >= now())
469
+ ORDER BY next_run_at NULLS FIRST
470
+ LIMIT greatest(p_limit, 0)
471
+ FOR UPDATE SKIP LOCKED
472
+ LOOP
473
+ v_out := v_out || public.marketing_campaign_run(c.id);
474
+ v_n := v_n + 1;
475
+ END LOOP;
476
+ RETURN jsonb_build_object('campaigns', v_n, 'results', v_out);
477
+ END $$;
478
+
479
+
480
+ -- \u2500\u2500 constraint \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
481
+
482
+ ALTER TABLE ONLY public.plg_marketing_audiences
483
+ ADD CONSTRAINT plg_marketing_audiences_campaign_id_person_id_key UNIQUE (campaign_id, person_id);
484
+
485
+ ALTER TABLE ONLY public.plg_marketing_audiences
486
+ ADD CONSTRAINT plg_marketing_audiences_pkey PRIMARY KEY (id);
487
+
488
+ ALTER TABLE ONLY public.plg_marketing_audiences
489
+ ADD CONSTRAINT plg_marketing_audiences_tenant_id_id_key UNIQUE (tenant_id, id);
490
+
491
+ ALTER TABLE ONLY public.plg_marketing_campaigns
492
+ ADD CONSTRAINT plg_marketing_campaigns_pkey PRIMARY KEY (id);
493
+
494
+ ALTER TABLE ONLY public.plg_marketing_campaigns
495
+ ADD CONSTRAINT plg_marketing_campaigns_tenant_id_id_key UNIQUE (tenant_id, id);
496
+
497
+ ALTER TABLE ONLY public.plg_marketing_channels
498
+ ADD CONSTRAINT plg_marketing_channels_pkey PRIMARY KEY (id);
499
+
500
+ ALTER TABLE ONLY public.plg_marketing_channels
501
+ ADD CONSTRAINT plg_marketing_channels_tenant_id_id_key UNIQUE (tenant_id, id);
502
+
503
+ ALTER TABLE ONLY public.plg_marketing_consents
504
+ ADD CONSTRAINT plg_marketing_consents_pkey PRIMARY KEY (id);
505
+
506
+ ALTER TABLE ONLY public.plg_marketing_consents
507
+ ADD CONSTRAINT plg_marketing_consents_tenant_id_id_key UNIQUE (tenant_id, id);
508
+
509
+ ALTER TABLE ONLY public.plg_marketing_consents
510
+ ADD CONSTRAINT plg_marketing_consents_tenant_id_person_id_purpose_key UNIQUE (tenant_id, person_id, purpose);
511
+
512
+ ALTER TABLE ONLY public.plg_marketing_content_plans
513
+ ADD CONSTRAINT plg_marketing_content_plans_pkey PRIMARY KEY (id);
514
+
515
+ ALTER TABLE ONLY public.plg_marketing_content_posts
516
+ ADD CONSTRAINT plg_marketing_content_posts_pkey PRIMARY KEY (id);
517
+
518
+ ALTER TABLE ONLY public.plg_marketing_landing_pages
519
+ ADD CONSTRAINT plg_marketing_landing_pages_pkey PRIMARY KEY (id);
520
+
521
+ ALTER TABLE ONLY public.plg_marketing_landing_pages
522
+ ADD CONSTRAINT plg_marketing_landing_pages_tenant_id_id_key UNIQUE (tenant_id, id);
523
+
524
+ ALTER TABLE ONLY public.plg_marketing_social_accounts
525
+ ADD CONSTRAINT plg_marketing_social_accounts_pkey PRIMARY KEY (id);
526
+
527
+
528
+ -- \u2500\u2500 view \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
529
+
530
+ CREATE VIEW public.v_marketing_attribution WITH (security_invoker='true') AS
531
+ SELECT p.tenant_id,
532
+ 'lead'::text AS kind,
533
+ 'crm'::text AS source,
534
+ COALESCE((p.metadata ->> 'sourceId'::text), (p.metadata ->> 'sourceName'::text), (p.metadata ->> 'origin'::text)) AS channel_raw,
535
+ p.created_at AS occurred_at,
536
+ (NULLIF((p.metadata ->> 'value'::text), ''::text))::numeric AS value
537
+ FROM public.people p
538
+ WHERE (p.kind = 'lead'::text)
539
+ UNION ALL
540
+ SELECT b.tenant_id,
541
+ 'conversion'::text AS kind,
542
+ 'agenda'::text AS source,
543
+ COALESCE((b.metadata ->> 'origin'::text), (pc.metadata ->> 'origin'::text), (pc.metadata ->> 'sourceId'::text), (pc.metadata ->> 'sourceName'::text)) AS channel_raw,
544
+ b.starts_at AS occurred_at,
545
+ o.total AS value
546
+ FROM ((public.appointments b
547
+ LEFT JOIN public.people pc ON ((pc.id = b.party_id)))
548
+ LEFT JOIN public.orders o ON ((o.id = b.order_id)))
549
+ WHERE (b.status <> ALL (ARRAY['cancelled'::text, 'no_show'::text]))
550
+ UNION ALL
551
+ SELECT o.tenant_id,
552
+ 'conversion'::text AS kind,
553
+ 'orders'::text AS source,
554
+ COALESCE((o.metadata ->> 'origin'::text), (pp.metadata ->> 'origin'::text), (pp.metadata ->> 'sourceId'::text)) AS channel_raw,
555
+ o.created_at AS occurred_at,
556
+ o.total AS value
557
+ FROM (public.orders o
558
+ LEFT JOIN public.people pp ON ((pp.id = o.party_id)))
559
+ WHERE ((o.kind <> ALL (ARRAY['appointment'::text, 'deal'::text])) AND (o.status <> ALL (ARRAY['cancelled'::text, 'draft'::text])))
560
+ UNION ALL
561
+ SELECT o.tenant_id,
562
+ 'conversion'::text AS kind,
563
+ 'crm'::text AS source,
564
+ COALESCE((pp.metadata ->> 'sourceId'::text), (pp.metadata ->> 'sourceName'::text), (o.metadata ->> 'origin'::text)) AS channel_raw,
565
+ o.updated_at AS occurred_at,
566
+ o.total AS value
567
+ FROM (public.orders o
568
+ LEFT JOIN public.people pp ON ((pp.id = o.party_id)))
569
+ WHERE ((o.kind = 'deal'::text) AND (o.status = 'won'::text));
570
+
571
+ CREATE VIEW public.v_marketing_campaign_metrics WITH (security_invoker='true') AS
572
+ SELECT c.id AS campaign_id,
573
+ c.tenant_id,
574
+ c.unit_id,
575
+ c.key,
576
+ c.name,
577
+ c.status,
578
+ c.purpose,
579
+ c.template_key,
580
+ c.last_run_at,
581
+ count(a.id) AS audience,
582
+ count(a.id) FILTER (WHERE (a.state = 'queued'::text)) AS queued,
583
+ count(a.id) FILTER (WHERE (a.state = 'sent'::text)) AS sent,
584
+ count(a.id) FILTER (WHERE (a.state = 'converted'::text)) AS converted,
585
+ count(a.id) FILTER (WHERE (a.state = 'left'::text)) AS left_audience,
586
+ count(a.id) FILTER (WHERE (a.state = 'blocked'::text)) AS blocked,
587
+ count(q.id) FILTER (WHERE (q.status = 'sent'::text)) AS delivered,
588
+ count(q.id) FILTER (WHERE (q.status = 'blocked'::text)) AS refused
589
+ FROM ((public.plg_marketing_campaigns c
590
+ LEFT JOIN public.plg_marketing_audiences a ON ((a.campaign_id = c.id)))
591
+ LEFT JOIN public.plg_notifications_queue q ON ((q.id = a.queue_id)))
592
+ WHERE (c.kind = 'outbound'::text)
593
+ GROUP BY c.id, c.tenant_id, c.unit_id, c.key, c.name, c.status, c.purpose, c.template_key, c.last_run_at;
594
+
595
+ CREATE VIEW public.v_marketing_campaigns WITH (security_invoker='true') AS
596
+ SELECT g.id,
597
+ g.tenant_id,
598
+ g.name,
599
+ g.channel_key,
600
+ ch.label AS channel_label,
601
+ g.status,
602
+ g.starts_at,
603
+ g.ends_at,
604
+ g.spend,
605
+ g.created_at,
606
+ g.updated_at
607
+ FROM (public.plg_marketing_campaigns g
608
+ LEFT JOIN public.plg_marketing_channels ch ON (((ch.tenant_id = g.tenant_id) AND (ch.channel_key = g.channel_key))));
609
+
610
+ CREATE VIEW public.v_marketing_channels WITH (security_invoker='true') AS
611
+ SELECT id,
612
+ tenant_id,
613
+ channel_key,
614
+ label,
615
+ icon,
616
+ kind,
617
+ is_active,
618
+ monthly_spend,
619
+ created_at,
620
+ updated_at
621
+ FROM public.plg_marketing_channels c;
622
+
623
+ CREATE VIEW public.v_marketing_landing_pages WITH (security_invoker='true') AS
624
+ SELECT lp.id,
625
+ lp.tenant_id,
626
+ lp.site_id,
627
+ lp.name,
628
+ lp.route,
629
+ lp.lead_tags,
630
+ lp.status,
631
+ lp.prompt,
632
+ lp.is_active,
633
+ s.name AS site_name,
634
+ s.preview_url AS site_url,
635
+ s.app_id,
636
+ s.fayz_project_id,
637
+ s.editor_url,
638
+ lp.created_at,
639
+ lp.updated_at
640
+ FROM (public.plg_marketing_landing_pages lp
641
+ JOIN public.fayz_projects s ON ((s.id = lp.site_id)));
642
+
643
+ CREATE VIEW public.v_marketing_lead_tag_events WITH (security_invoker='true') AS
644
+ SELECT p.tenant_id,
645
+ tag.tag,
646
+ p.id AS person_id,
647
+ p.created_at AS occurred_at,
648
+ (NULLIF((p.metadata ->> 'value'::text), ''::text))::numeric AS value
649
+ FROM (public.people p
650
+ CROSS JOIN LATERAL unnest(COALESCE(p.tags, '{}'::text[])) tag(tag))
651
+ WHERE ((p.kind = 'lead'::text) AND COALESCE(p.is_active, true));
652
+
653
+
654
+ -- \u2500\u2500 index \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
655
+
656
+ CREATE INDEX idx_plg_marketing_campaigns_channel ON public.plg_marketing_campaigns USING btree (tenant_id, channel_key);
657
+
658
+ CREATE INDEX idx_plg_marketing_campaigns_tenant ON public.plg_marketing_campaigns USING btree (tenant_id);
659
+
660
+ CREATE INDEX idx_plg_marketing_channels_tenant ON public.plg_marketing_channels USING btree (tenant_id);
661
+
662
+ CREATE INDEX idx_plg_marketing_content_plans_account ON public.plg_marketing_content_plans USING btree (account_id);
663
+
664
+ CREATE INDEX idx_plg_marketing_content_plans_tenant ON public.plg_marketing_content_plans USING btree (tenant_id);
665
+
666
+ CREATE INDEX idx_plg_marketing_content_posts_plan ON public.plg_marketing_content_posts USING btree (plan_id, week_number, "position");
667
+
668
+ CREATE INDEX idx_plg_marketing_content_posts_status ON public.plg_marketing_content_posts USING btree (tenant_id, status);
669
+
670
+ CREATE INDEX idx_plg_marketing_content_posts_tenant ON public.plg_marketing_content_posts USING btree (tenant_id);
671
+
672
+ CREATE INDEX idx_plg_marketing_landing_pages_site ON public.plg_marketing_landing_pages USING btree (site_id);
673
+
674
+ CREATE INDEX idx_plg_marketing_landing_pages_tenant ON public.plg_marketing_landing_pages USING btree (tenant_id);
675
+
676
+ CREATE INDEX idx_plg_marketing_social_accounts_tenant ON public.plg_marketing_social_accounts USING btree (tenant_id);
677
+
678
+ CREATE INDEX plg_marketing_audiences_state_idx ON public.plg_marketing_audiences USING btree (campaign_id, state);
679
+
680
+ CREATE INDEX plg_marketing_audiences_tenant_owner_idx ON public.plg_marketing_audiences USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);
681
+
682
+ CREATE INDEX plg_marketing_audiences_tenant_unit_idx ON public.plg_marketing_audiences USING btree (tenant_id, unit_id);
683
+
684
+ CREATE INDEX plg_marketing_campaigns_due_idx ON public.plg_marketing_campaigns USING btree (next_run_at) WHERE ((kind = 'outbound'::text) AND (status = 'active'::text));
685
+
686
+ CREATE UNIQUE INDEX plg_marketing_campaigns_key_uq ON public.plg_marketing_campaigns USING btree (tenant_id, key) WHERE (key IS NOT NULL);
687
+
688
+ CREATE INDEX plg_marketing_campaigns_tenant_owner_idx ON public.plg_marketing_campaigns USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);
689
+
690
+ CREATE INDEX plg_marketing_campaigns_tenant_unit_idx ON public.plg_marketing_campaigns USING btree (tenant_id, unit_id);
691
+
692
+ CREATE INDEX plg_marketing_channels_tenant_owner_idx ON public.plg_marketing_channels USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);
693
+
694
+ CREATE INDEX plg_marketing_channels_tenant_unit_idx ON public.plg_marketing_channels USING btree (tenant_id, unit_id);
695
+
696
+ CREATE INDEX plg_marketing_consents_person_idx ON public.plg_marketing_consents USING btree (tenant_id, person_id);
697
+
698
+ CREATE INDEX plg_marketing_consents_tenant_owner_idx ON public.plg_marketing_consents USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);
699
+
700
+ CREATE INDEX plg_marketing_consents_tenant_unit_idx ON public.plg_marketing_consents USING btree (tenant_id, unit_id);
701
+
702
+ CREATE INDEX plg_marketing_landing_pages_tenant_owner_idx ON public.plg_marketing_landing_pages USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);
703
+
704
+ CREATE INDEX plg_marketing_landing_pages_tenant_unit_idx ON public.plg_marketing_landing_pages USING btree (tenant_id, unit_id);
705
+
706
+ CREATE UNIQUE INDEX u_plg_marketing_channels_tenant_key ON public.plg_marketing_channels USING btree (tenant_id, channel_key);
707
+
708
+ CREATE UNIQUE INDEX u_plg_marketing_landing_pages_site_route ON public.plg_marketing_landing_pages USING btree (site_id, route);
709
+
710
+
711
+ -- \u2500\u2500 fk constraint \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
712
+
713
+ ALTER TABLE ONLY public.plg_marketing_audiences
714
+ ADD CONSTRAINT plg_marketing_audiences_campaign_id_fkey FOREIGN KEY (campaign_id) REFERENCES public.plg_marketing_campaigns(id) ON DELETE CASCADE;
715
+
716
+ ALTER TABLE ONLY public.plg_marketing_audiences
717
+ ADD CONSTRAINT plg_marketing_audiences_owner_fk FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;
718
+
719
+ ALTER TABLE ONLY public.plg_marketing_audiences
720
+ ADD CONSTRAINT plg_marketing_audiences_person_id_fkey FOREIGN KEY (person_id) REFERENCES public.people(id) ON DELETE CASCADE;
721
+
722
+ ALTER TABLE ONLY public.plg_marketing_audiences
723
+ ADD CONSTRAINT plg_marketing_audiences_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
724
+
725
+ ALTER TABLE ONLY public.plg_marketing_audiences
726
+ ADD CONSTRAINT plg_marketing_audiences_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;
727
+
728
+ ALTER TABLE ONLY public.plg_marketing_campaigns
729
+ ADD CONSTRAINT plg_marketing_campaigns_owner_fk FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;
730
+
731
+ ALTER TABLE ONLY public.plg_marketing_campaigns
732
+ ADD CONSTRAINT plg_marketing_campaigns_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
733
+
734
+ ALTER TABLE ONLY public.plg_marketing_campaigns
735
+ ADD CONSTRAINT plg_marketing_campaigns_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;
736
+
737
+ ALTER TABLE ONLY public.plg_marketing_channels
738
+ ADD CONSTRAINT plg_marketing_channels_owner_fk FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;
739
+
740
+ ALTER TABLE ONLY public.plg_marketing_channels
741
+ ADD CONSTRAINT plg_marketing_channels_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
742
+
743
+ ALTER TABLE ONLY public.plg_marketing_channels
744
+ ADD CONSTRAINT plg_marketing_channels_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;
745
+
746
+ ALTER TABLE ONLY public.plg_marketing_consents
747
+ ADD CONSTRAINT plg_marketing_consents_owner_fk FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;
748
+
749
+ ALTER TABLE ONLY public.plg_marketing_consents
750
+ ADD CONSTRAINT plg_marketing_consents_person_id_fkey FOREIGN KEY (person_id) REFERENCES public.people(id) ON DELETE CASCADE;
751
+
752
+ ALTER TABLE ONLY public.plg_marketing_consents
753
+ ADD CONSTRAINT plg_marketing_consents_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
754
+
755
+ ALTER TABLE ONLY public.plg_marketing_consents
756
+ ADD CONSTRAINT plg_marketing_consents_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;
757
+
758
+ ALTER TABLE ONLY public.plg_marketing_content_plans
759
+ ADD CONSTRAINT plg_marketing_content_plans_account_id_fkey FOREIGN KEY (account_id) REFERENCES public.plg_marketing_social_accounts(id) ON DELETE CASCADE;
760
+
761
+ ALTER TABLE ONLY public.plg_marketing_content_plans
762
+ ADD CONSTRAINT plg_marketing_content_plans_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
763
+
764
+ ALTER TABLE ONLY public.plg_marketing_content_posts
765
+ ADD CONSTRAINT plg_marketing_content_posts_plan_id_fkey FOREIGN KEY (plan_id) REFERENCES public.plg_marketing_content_plans(id) ON DELETE CASCADE;
766
+
767
+ ALTER TABLE ONLY public.plg_marketing_content_posts
768
+ ADD CONSTRAINT plg_marketing_content_posts_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
769
+
770
+ ALTER TABLE ONLY public.plg_marketing_landing_pages
771
+ ADD CONSTRAINT plg_marketing_landing_pages_owner_fk FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;
772
+
773
+ ALTER TABLE ONLY public.plg_marketing_landing_pages
774
+ ADD CONSTRAINT plg_marketing_landing_pages_site_fk FOREIGN KEY (site_id) REFERENCES public.published_sites(id) ON DELETE CASCADE;
775
+
776
+ ALTER TABLE ONLY public.plg_marketing_landing_pages
777
+ ADD CONSTRAINT plg_marketing_landing_pages_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
778
+
779
+ ALTER TABLE ONLY public.plg_marketing_landing_pages
780
+ ADD CONSTRAINT plg_marketing_landing_pages_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;
781
+
782
+ ALTER TABLE ONLY public.plg_marketing_social_accounts
783
+ ADD CONSTRAINT plg_marketing_social_accounts_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
784
+
785
+
786
+ -- \u2500\u2500 trigger \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
787
+
788
+ CREATE TRIGGER plg_marketing_audiences_updated_at BEFORE UPDATE ON public.plg_marketing_audiences FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
789
+
790
+ CREATE TRIGGER plg_marketing_campaigns_updated_at BEFORE UPDATE ON public.plg_marketing_campaigns FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
791
+
792
+ CREATE TRIGGER plg_marketing_channels_updated_at BEFORE UPDATE ON public.plg_marketing_channels FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
793
+
794
+ CREATE TRIGGER plg_marketing_consents_updated_at BEFORE UPDATE ON public.plg_marketing_consents FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
795
+
796
+ CREATE TRIGGER plg_marketing_landing_pages_updated_at BEFORE UPDATE ON public.plg_marketing_landing_pages FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
797
+
798
+
799
+ -- \u2500\u2500 row security \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
800
+
801
+ ALTER TABLE public.plg_marketing_audiences ENABLE ROW LEVEL SECURITY;
802
+
803
+ ALTER TABLE public.plg_marketing_campaigns ENABLE ROW LEVEL SECURITY;
804
+
805
+ ALTER TABLE public.plg_marketing_channels ENABLE ROW LEVEL SECURITY;
806
+
807
+ ALTER TABLE public.plg_marketing_consents ENABLE ROW LEVEL SECURITY;
808
+
809
+ ALTER TABLE public.plg_marketing_content_plans ENABLE ROW LEVEL SECURITY;
810
+
811
+ ALTER TABLE public.plg_marketing_content_posts ENABLE ROW LEVEL SECURITY;
812
+
813
+ ALTER TABLE public.plg_marketing_landing_pages ENABLE ROW LEVEL SECURITY;
814
+
815
+ ALTER TABLE public.plg_marketing_social_accounts ENABLE ROW LEVEL SECURITY;
816
+
817
+
818
+ -- \u2500\u2500 policy \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
819
+
820
+ CREATE POLICY plg_marketing_audiences_authz_delete ON public.plg_marketing_audiences FOR DELETE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_marketing_audiences.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('marketing.audience'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('marketing.audience'::text, plg_marketing_audiences.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('marketing.read_all'::text, plg_marketing_audiences.unit_id) AS has_permission)) AND ( SELECT app.has_permission('marketing.delete'::text, plg_marketing_audiences.unit_id) AS has_permission)));
821
+
822
+ CREATE POLICY plg_marketing_audiences_authz_insert ON public.plg_marketing_audiences FOR INSERT TO authenticated WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_marketing_audiences.unit_id) AS has_unit)) AND ( SELECT app.has_permission('marketing.create'::text, plg_marketing_audiences.unit_id) AS has_permission)));
823
+
824
+ CREATE POLICY plg_marketing_audiences_authz_select ON public.plg_marketing_audiences FOR SELECT TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_marketing_audiences.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('marketing.audience'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('marketing.audience'::text, plg_marketing_audiences.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('marketing.read_all'::text, plg_marketing_audiences.unit_id) AS has_permission)) AND ( SELECT app.has_permission('marketing.read'::text, plg_marketing_audiences.unit_id) AS has_permission)));
825
+
826
+ CREATE POLICY plg_marketing_audiences_authz_update ON public.plg_marketing_audiences FOR UPDATE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_marketing_audiences.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('marketing.audience'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('marketing.audience'::text, plg_marketing_audiences.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('marketing.read_all'::text, plg_marketing_audiences.unit_id) AS has_permission)) AND ( SELECT app.has_permission('marketing.edit'::text, plg_marketing_audiences.unit_id) AS has_permission))) WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_marketing_audiences.unit_id) AS has_unit)) AND ( SELECT app.has_permission('marketing.edit'::text, plg_marketing_audiences.unit_id) AS has_permission)));
827
+
828
+ CREATE POLICY plg_marketing_campaigns_authz_delete ON public.plg_marketing_campaigns FOR DELETE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_marketing_campaigns.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('marketing.campaign'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('marketing.campaign'::text, plg_marketing_campaigns.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('marketing.read_all'::text, plg_marketing_campaigns.unit_id) AS has_permission)) AND ( SELECT app.has_permission('marketing.delete'::text, plg_marketing_campaigns.unit_id) AS has_permission)));
829
+
830
+ CREATE POLICY plg_marketing_campaigns_authz_insert ON public.plg_marketing_campaigns FOR INSERT TO authenticated WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_marketing_campaigns.unit_id) AS has_unit)) AND ( SELECT app.has_permission('marketing.create'::text, plg_marketing_campaigns.unit_id) AS has_permission)));
831
+
832
+ CREATE POLICY plg_marketing_campaigns_authz_select ON public.plg_marketing_campaigns FOR SELECT TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_marketing_campaigns.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('marketing.campaign'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('marketing.campaign'::text, plg_marketing_campaigns.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('marketing.read_all'::text, plg_marketing_campaigns.unit_id) AS has_permission)) AND ( SELECT app.has_permission('marketing.read'::text, plg_marketing_campaigns.unit_id) AS has_permission)));
833
+
834
+ CREATE POLICY plg_marketing_campaigns_authz_update ON public.plg_marketing_campaigns FOR UPDATE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_marketing_campaigns.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('marketing.campaign'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('marketing.campaign'::text, plg_marketing_campaigns.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('marketing.read_all'::text, plg_marketing_campaigns.unit_id) AS has_permission)) AND ( SELECT app.has_permission('marketing.edit'::text, plg_marketing_campaigns.unit_id) AS has_permission))) WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_marketing_campaigns.unit_id) AS has_unit)) AND ( SELECT app.has_permission('marketing.edit'::text, plg_marketing_campaigns.unit_id) AS has_permission)));
835
+
836
+ CREATE POLICY plg_marketing_channels_authz_delete ON public.plg_marketing_channels FOR DELETE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_marketing_channels.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('marketing.channel'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('marketing.channel'::text, plg_marketing_channels.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('marketing.read_all'::text, plg_marketing_channels.unit_id) AS has_permission)) AND ( SELECT app.has_permission('marketing.delete'::text, plg_marketing_channels.unit_id) AS has_permission)));
837
+
838
+ CREATE POLICY plg_marketing_channels_authz_insert ON public.plg_marketing_channels FOR INSERT TO authenticated WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_marketing_channels.unit_id) AS has_unit)) AND ( SELECT app.has_permission('marketing.create'::text, plg_marketing_channels.unit_id) AS has_permission)));
839
+
840
+ CREATE POLICY plg_marketing_channels_authz_select ON public.plg_marketing_channels FOR SELECT TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_marketing_channels.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('marketing.channel'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('marketing.channel'::text, plg_marketing_channels.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('marketing.read_all'::text, plg_marketing_channels.unit_id) AS has_permission)) AND ( SELECT app.has_permission('marketing.read'::text, plg_marketing_channels.unit_id) AS has_permission)));
841
+
842
+ CREATE POLICY plg_marketing_channels_authz_update ON public.plg_marketing_channels FOR UPDATE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_marketing_channels.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('marketing.channel'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('marketing.channel'::text, plg_marketing_channels.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('marketing.read_all'::text, plg_marketing_channels.unit_id) AS has_permission)) AND ( SELECT app.has_permission('marketing.edit'::text, plg_marketing_channels.unit_id) AS has_permission))) WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_marketing_channels.unit_id) AS has_unit)) AND ( SELECT app.has_permission('marketing.edit'::text, plg_marketing_channels.unit_id) AS has_permission)));
843
+
844
+ CREATE POLICY plg_marketing_consents_authz_delete ON public.plg_marketing_consents FOR DELETE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_marketing_consents.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('marketing.consent'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('marketing.consent'::text, plg_marketing_consents.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('marketing.read_all'::text, plg_marketing_consents.unit_id) AS has_permission)) AND ( SELECT app.has_permission('marketing.delete'::text, plg_marketing_consents.unit_id) AS has_permission)));
845
+
846
+ CREATE POLICY plg_marketing_consents_authz_insert ON public.plg_marketing_consents FOR INSERT TO authenticated WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_marketing_consents.unit_id) AS has_unit)) AND ( SELECT app.has_permission('marketing.create'::text, plg_marketing_consents.unit_id) AS has_permission)));
847
+
848
+ CREATE POLICY plg_marketing_consents_authz_select ON public.plg_marketing_consents FOR SELECT TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_marketing_consents.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('marketing.consent'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('marketing.consent'::text, plg_marketing_consents.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('marketing.read_all'::text, plg_marketing_consents.unit_id) AS has_permission)) AND ( SELECT app.has_permission('marketing.read'::text, plg_marketing_consents.unit_id) AS has_permission)));
849
+
850
+ CREATE POLICY plg_marketing_consents_authz_update ON public.plg_marketing_consents FOR UPDATE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_marketing_consents.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('marketing.consent'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('marketing.consent'::text, plg_marketing_consents.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('marketing.read_all'::text, plg_marketing_consents.unit_id) AS has_permission)) AND ( SELECT app.has_permission('marketing.edit'::text, plg_marketing_consents.unit_id) AS has_permission))) WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_marketing_consents.unit_id) AS has_unit)) AND ( SELECT app.has_permission('marketing.edit'::text, plg_marketing_consents.unit_id) AS has_permission)));
851
+
852
+ 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() AS user_tenant_ids)));
853
+
854
+ 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() AS user_tenant_ids)));
855
+
856
+ 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() AS user_tenant_ids)));
857
+
858
+ 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() AS user_tenant_ids)));
859
+
860
+ 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() AS user_tenant_ids)));
861
+
862
+ 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() AS user_tenant_ids)));
863
+
864
+ 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() AS user_tenant_ids)));
865
+
866
+ 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() AS user_tenant_ids)));
867
+
868
+ CREATE POLICY plg_marketing_landing_pages_authz_delete ON public.plg_marketing_landing_pages FOR DELETE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_marketing_landing_pages.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('marketing.landing_page'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('marketing.landing_page'::text, plg_marketing_landing_pages.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('marketing.read_all'::text, plg_marketing_landing_pages.unit_id) AS has_permission)) AND ( SELECT app.has_permission('marketing.delete'::text, plg_marketing_landing_pages.unit_id) AS has_permission)));
869
+
870
+ CREATE POLICY plg_marketing_landing_pages_authz_insert ON public.plg_marketing_landing_pages FOR INSERT TO authenticated WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_marketing_landing_pages.unit_id) AS has_unit)) AND ( SELECT app.has_permission('marketing.create'::text, plg_marketing_landing_pages.unit_id) AS has_permission)));
871
+
872
+ CREATE POLICY plg_marketing_landing_pages_authz_select ON public.plg_marketing_landing_pages FOR SELECT TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_marketing_landing_pages.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('marketing.landing_page'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('marketing.landing_page'::text, plg_marketing_landing_pages.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('marketing.read_all'::text, plg_marketing_landing_pages.unit_id) AS has_permission)) AND ( SELECT app.has_permission('marketing.read'::text, plg_marketing_landing_pages.unit_id) AS has_permission)));
873
+
874
+ CREATE POLICY plg_marketing_landing_pages_authz_update ON public.plg_marketing_landing_pages FOR UPDATE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_marketing_landing_pages.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('marketing.landing_page'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('marketing.landing_page'::text, plg_marketing_landing_pages.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('marketing.read_all'::text, plg_marketing_landing_pages.unit_id) AS has_permission)) AND ( SELECT app.has_permission('marketing.edit'::text, plg_marketing_landing_pages.unit_id) AS has_permission))) WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_marketing_landing_pages.unit_id) AS has_unit)) AND ( SELECT app.has_permission('marketing.edit'::text, plg_marketing_landing_pages.unit_id) AS has_permission)));
875
+
876
+ 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() AS user_tenant_ids)));
877
+
878
+ 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() AS user_tenant_ids)));
879
+
880
+ 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() AS user_tenant_ids)));
881
+
882
+ 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() AS user_tenant_ids)));
883
+
884
+
885
+ -- \u2500\u2500 acl \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
886
+
887
+ REVOKE ALL ON FUNCTION public.agent_marketing_create_campaign(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) FROM PUBLIC;
888
+ GRANT ALL ON FUNCTION public.agent_marketing_create_campaign(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) TO authenticated;
889
+ GRANT ALL ON FUNCTION public.agent_marketing_create_campaign(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) TO service_role;
890
+
891
+ REVOKE ALL ON FUNCTION public.marketing_campaign_run(p_campaign_id uuid, p_limit integer) FROM PUBLIC;
892
+ GRANT ALL ON FUNCTION public.marketing_campaign_run(p_campaign_id uuid, p_limit integer) TO authenticated;
893
+ GRANT ALL ON FUNCTION public.marketing_campaign_run(p_campaign_id uuid, p_limit integer) TO service_role;
894
+
895
+ REVOKE ALL ON FUNCTION public.marketing_has_consent(p_tenant uuid, p_person uuid, p_purpose text) FROM PUBLIC;
896
+ GRANT ALL ON FUNCTION public.marketing_has_consent(p_tenant uuid, p_person uuid, p_purpose text) TO authenticated;
897
+ GRANT ALL ON FUNCTION public.marketing_has_consent(p_tenant uuid, p_person uuid, p_purpose text) TO service_role;
898
+
899
+ REVOKE ALL ON FUNCTION public.marketing_resolve_audience(p_campaign_id uuid) FROM PUBLIC;
900
+ GRANT ALL ON FUNCTION public.marketing_resolve_audience(p_campaign_id uuid) TO authenticated;
901
+ GRANT ALL ON FUNCTION public.marketing_resolve_audience(p_campaign_id uuid) TO service_role;
902
+
903
+ REVOKE ALL ON FUNCTION public.marketing_run_due(p_limit integer) FROM PUBLIC;
904
+ GRANT ALL ON FUNCTION public.marketing_run_due(p_limit integer) TO service_role;
905
+
906
+ GRANT MAINTAIN ON TABLE public.plg_marketing_audiences TO anon;
907
+ GRANT ALL ON TABLE public.plg_marketing_audiences TO authenticated;
908
+ GRANT ALL ON TABLE public.plg_marketing_audiences TO service_role;
909
+
910
+ GRANT MAINTAIN ON TABLE public.plg_marketing_campaigns TO anon;
911
+ GRANT ALL ON TABLE public.plg_marketing_campaigns TO authenticated;
912
+ GRANT ALL ON TABLE public.plg_marketing_campaigns TO service_role;
913
+
914
+ GRANT MAINTAIN ON TABLE public.plg_marketing_channels TO anon;
915
+ GRANT ALL ON TABLE public.plg_marketing_channels TO authenticated;
916
+ GRANT ALL ON TABLE public.plg_marketing_channels TO service_role;
917
+
918
+ GRANT MAINTAIN ON TABLE public.plg_marketing_consents TO anon;
919
+ GRANT ALL ON TABLE public.plg_marketing_consents TO authenticated;
920
+ GRANT ALL ON TABLE public.plg_marketing_consents TO service_role;
921
+
922
+ GRANT MAINTAIN ON TABLE public.plg_marketing_content_plans TO anon;
923
+ GRANT ALL ON TABLE public.plg_marketing_content_plans TO authenticated;
924
+ GRANT ALL ON TABLE public.plg_marketing_content_plans TO service_role;
925
+
926
+ GRANT MAINTAIN ON TABLE public.plg_marketing_content_posts TO anon;
927
+ GRANT ALL ON TABLE public.plg_marketing_content_posts TO authenticated;
928
+ GRANT ALL ON TABLE public.plg_marketing_content_posts TO service_role;
929
+
930
+ GRANT MAINTAIN ON TABLE public.plg_marketing_landing_pages TO anon;
931
+ GRANT ALL ON TABLE public.plg_marketing_landing_pages TO authenticated;
932
+ GRANT ALL ON TABLE public.plg_marketing_landing_pages TO service_role;
933
+
934
+ GRANT MAINTAIN ON TABLE public.plg_marketing_social_accounts TO anon;
935
+ GRANT ALL ON TABLE public.plg_marketing_social_accounts TO authenticated;
936
+ GRANT ALL ON TABLE public.plg_marketing_social_accounts TO service_role;
937
+
938
+ GRANT MAINTAIN ON TABLE public.v_marketing_attribution TO anon;
939
+ GRANT ALL ON TABLE public.v_marketing_attribution TO authenticated;
940
+ GRANT ALL ON TABLE public.v_marketing_attribution TO service_role;
941
+
942
+ GRANT MAINTAIN ON TABLE public.v_marketing_campaign_metrics TO anon;
943
+ GRANT ALL ON TABLE public.v_marketing_campaign_metrics TO authenticated;
944
+ GRANT ALL ON TABLE public.v_marketing_campaign_metrics TO service_role;
945
+
946
+ GRANT MAINTAIN ON TABLE public.v_marketing_campaigns TO anon;
947
+ GRANT ALL ON TABLE public.v_marketing_campaigns TO authenticated;
948
+ GRANT ALL ON TABLE public.v_marketing_campaigns TO service_role;
949
+
950
+ GRANT MAINTAIN ON TABLE public.v_marketing_channels TO anon;
951
+ GRANT ALL ON TABLE public.v_marketing_channels TO authenticated;
952
+ GRANT ALL ON TABLE public.v_marketing_channels TO service_role;
953
+
954
+ GRANT MAINTAIN ON TABLE public.v_marketing_landing_pages TO anon;
955
+ GRANT ALL ON TABLE public.v_marketing_landing_pages TO authenticated;
956
+ GRANT ALL ON TABLE public.v_marketing_landing_pages TO service_role;
957
+
958
+ GRANT MAINTAIN ON TABLE public.v_marketing_lead_tag_events TO anon;
959
+ GRANT ALL ON TABLE public.v_marketing_lead_tag_events TO authenticated;
960
+ GRANT ALL ON TABLE public.v_marketing_lead_tag_events TO service_role;
961
+
962
+
963
+ -- \u2500\u2500 comment \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
964
+
965
+ COMMENT ON FUNCTION public.marketing_campaign_run(p_campaign_id uuid, p_limit integer) IS 'Run a campaign (PRD 09): re-resolve the audience, drop whoever no longer qualifies, and ENQUEUE the rest through the notification engine. It never sends \u2014 that is what makes a promotion and a dunning reminder the same machine.';
966
+
967
+ COMMENT ON FUNCTION public.marketing_resolve_audience(p_campaign_id uuid) IS 'Who is in a campaign''s audience right now (PRD 09). Declarative filters over a read model, with consent for the campaign''s purpose applied AT THE SOURCE \u2014 someone who revoked never appears here, rather than appearing and being dropped later.';
968
+
969
+ COMMENT ON TABLE public.plg_marketing_audiences IS 'Who a campaign reached and what happened (PRD 09). The audience is a QUERY re-evaluated on every run, not a frozen list: whoever qualifies later enters later, and whoever revokes consent leaves on the next run. This table is the record of that, not its definition.';
970
+
971
+ COMMENT ON COLUMN public.plg_marketing_campaigns.kind IS 'ad_spend (the imported numbers of an external campaign) or outbound (a campaign this system fires). Same noun, two animals \u2014 a species rather than a second table called almost the same thing.';
972
+
973
+ COMMENT ON COLUMN public.plg_marketing_campaigns.audience_query IS 'A DECLARATIVE audience: {"source_view": "...", "where": [{"column","op","value"}]}. Not SQL \u2014 a stored query string would make every campaign an injection surface, and a frozen list would answer yesterday''s question.';
974
+
975
+ COMMENT ON TABLE public.plg_marketing_consents IS 'Consent per PURPOSE (PRD 09), separate from the channel opt-out: a customer may accept a payment reminder and refuse a promotion. Revoking is a timestamp, never a delete \u2014 the record that consent once existed is itself the evidence LGPD asks for.';
976
+
977
+ COMMENT ON VIEW public.v_marketing_campaign_metrics IS 'Reach, delivery and conversion per campaign (PRD 09), computed from the audience and the notification queue. The campaign keeps no counter of its own \u2014 two tallies of the same fact is how a dashboard starts disagreeing with the queue.';
978
+
979
+
980
+ -- \u2500\u2500 default privileges: restore \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
981
+ -- Put back, so the NEXT thing installed inherits what the chain had.
982
+
983
+ DO $dp$ BEGIN
984
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;
985
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
986
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;
987
+ END $dp$;
988
+
989
+ DO $dp$ BEGIN
990
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;
991
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
992
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;
993
+ END $dp$;
994
+
995
+ DO $dp$ BEGIN
996
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;
997
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
998
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;
999
+ END $dp$;
1000
+
1001
+ DO $dp$ BEGIN
1002
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;
1003
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
1004
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;
1005
+ END $dp$;
1006
+
1007
+ DO $dp$ BEGIN
1008
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;
1009
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
1010
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;
1011
+ END $dp$;
1012
+
1013
+ DO $dp$ BEGIN
1014
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;
1015
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
1016
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;
1017
+ END $dp$;
1018
+
1019
+ DO $dp$ BEGIN
1020
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;
1021
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
1022
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;
1023
+ END $dp$;
1024
+
1025
+ DO $dp$ BEGIN
1026
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;
1027
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
1028
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;
1029
+ END $dp$;
1030
+
1031
+ DO $dp$ BEGIN
1032
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;
1033
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
1034
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;
1035
+ END $dp$;
1036
+
1037
+ DO $dp$ BEGIN
1038
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;
1039
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
1040
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;
1041
+ END $dp$;
1042
+
1043
+ DO $dp$ BEGIN
1044
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;
1045
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
1046
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;
1047
+ END $dp$;
1048
+
1049
+ DO $dp$ BEGIN
1050
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;
1051
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
1052
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;
1053
+ END $dp$;
1054
+
1055
+ DO $dp$ BEGIN
1056
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO anon$stmt$;
1057
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
1058
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO anon$stmt$;
1059
+ END $dp$;
1060
+
1061
+ DO $dp$ BEGIN
1062
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;
1063
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
1064
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;
1065
+ END $dp$;
1066
+
1067
+ DO $dp$ BEGIN
1068
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;
1069
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
1070
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;
1071
+ END $dp$;
1072
+
1073
+ DO $dp$ BEGIN
1074
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;
1075
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
1076
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;
1077
+ END $dp$;
1078
+
1079
+ DO $dp$ BEGIN
1080
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT MAINTAIN ON TABLES TO anon$stmt$;
1081
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
1082
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT MAINTAIN ON TABLES TO anon$stmt$;
1083
+ END $dp$;
1084
+
1085
+ DO $dp$ BEGIN
1086
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;
1087
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
1088
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;
1089
+ END $dp$;
1090
+
1091
+ DO $dp$ BEGIN
1092
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;
1093
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
1094
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;
1095
+ END $dp$;
1096
+
1097
+ DO $dp$ BEGIN
1098
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;
1099
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
1100
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;
1101
+ END $dp$;
1102
+
1103
+ DO $dp$ BEGIN
1104
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO anon$stmt$;
1105
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
1106
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO anon$stmt$;
1107
+ END $dp$;
1108
+
1109
+ DO $dp$ BEGIN
1110
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;
1111
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
1112
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;
1113
+ END $dp$;
1114
+
1115
+ DO $dp$ BEGIN
1116
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;
1117
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
1118
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;
1119
+ END $dp$;
1120
+
1121
+ DO $dp$ BEGIN
1122
+ EXECUTE $stmt$--
1123
+ -- PostgreSQL database dump complete
1124
+ --$stmt$;
1125
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
1126
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$--
1127
+ -- PostgreSQL database dump complete
1128
+ --$stmt$;
1129
+ END $dp$;
1130
+ `;
1131
+ var MIGRATION_001_THE_LANDING_PAGE_NAMES_THE_SITE = `-- ============================================================================
1132
+ -- 001_the_landing_page_names_the_site \u2014 the last caller of fayz_projects, and
1133
+ -- then the name itself (#352).
1134
+ --
1135
+ -- 137 renamed the vendor out of the spine: \`fayz_projects\` became
1136
+ -- \`published_sites\`, because a table should say what it holds and not who
1137
+ -- built it. 141 left the old name answering as a view until callers moved.
1138
+ -- This view is the last one that did not, and it is the reason the name could
1139
+ -- not be dropped by the spine: a CREATE VIEW is a HARD dependency, so the
1140
+ -- spine \u2014 which runs first \u2014 would be dropping a name this file then joins.
1141
+ --
1142
+ -- So the drop lives here, with the last reader. The view body is
1143
+ -- 000_baseline's with one name changed.
1144
+ -- ============================================================================
1145
+
1146
+ -- DROP, not CREATE OR REPLACE: this renames a COLUMN (fayz_project_id ->
1147
+ -- provider_site_id) and Postgres refuses that in a replace, which is the same
1148
+ -- wall 0084 and 0086 hit. Dropping takes the grants with it, so they are
1149
+ -- restored below exactly as 000_baseline set them.
1150
+ DROP VIEW IF EXISTS public.v_marketing_landing_pages;
1151
+
1152
+ CREATE VIEW public.v_marketing_landing_pages WITH (security_invoker='true') AS
1153
+ SELECT lp.id,
1154
+ lp.tenant_id,
1155
+ lp.site_id,
1156
+ lp.name,
1157
+ lp.route,
1158
+ lp.lead_tags,
1159
+ lp.status,
1160
+ lp.prompt,
1161
+ lp.is_active,
1162
+ s.name AS site_name,
1163
+ s.preview_url AS site_url,
1164
+ s.app_id,
1165
+ s.provider_site_id,
1166
+ s.editor_url,
1167
+ lp.created_at,
1168
+ lp.updated_at
1169
+ FROM (public.plg_marketing_landing_pages lp
1170
+ JOIN public.published_sites s ON ((s.id = lp.site_id)));
1171
+ GRANT MAINTAIN ON TABLE public.v_marketing_landing_pages TO anon;
1172
+ GRANT ALL ON TABLE public.v_marketing_landing_pages TO authenticated;
1173
+ GRANT ALL ON TABLE public.v_marketing_landing_pages TO service_role;
1174
+
1175
+ DROP VIEW IF EXISTS public.fayz_projects;
1176
+ `;
1177
+ var MIGRATION_003_A_PORTA_PUBLICA_E_A_PAGINA = `-- 003_a_porta_publica_e_a_pagina.sql
1178
+ --
1179
+ -- Havia DUAS portas p\xFAblicas para a mesma coisa.
1180
+ --
1181
+ -- A landing page \xE9 uma p\xE1gina com endere\xE7o p\xFAblico que existe para capturar
1182
+ -- gente. A "p\xE1gina de captura" (crm/007) \xE9 um endere\xE7o p\xFAblico que existe para
1183
+ -- capturar gente. A diferen\xE7a que eu tinha em mente \u2014 uma \xE9 conte\xFAdo, a outra \xE9
1184
+ -- regra \u2014 n\xE3o \xE9 uma diferen\xE7a que o lojista faz: ele quer UMA porta, com o
1185
+ -- texto e as regras dela no mesmo lugar.
1186
+ --
1187
+ -- Ent\xE3o a regra entra na p\xE1gina. A landing page ganha o que s\xF3 a captura tinha:
1188
+ -- a chave opaca, o destino, os campos obrigat\xF3rios e a janela de anteced\xEAncia.
1189
+ -- E as duas fun\xE7\xF5es que \`anon\` executa passam a ler daqui.
1190
+ --
1191
+ -- POR QUE AS FUN\xC7\xD5ES MUDAM DE PLUGIN: a corrente aplica \`crm\` ANTES de
1192
+ -- \`marketing\`, ent\xE3o uma fun\xE7\xE3o do CRM n\xE3o pode ler uma tabela de marketing \u2014
1193
+ -- num replay do zero ela apontaria para algo que ainda n\xE3o existe. Captura
1194
+ -- passou a ser assunto de marketing, e a fun\xE7\xE3o vai junto.
1195
+
1196
+ ALTER TABLE public.plg_marketing_landing_pages
1197
+ -- O identificador P\xDABLICO e opaco. Continua sendo o que vai na URL; um slug
1198
+ -- adivinh\xE1vel deixaria achar a p\xE1gina de outra casa escrevendo o nome dela.
1199
+ ADD COLUMN IF NOT EXISTS capture_key text,
1200
+ -- Em que sala a porta d\xE1. "Agendamento" \xE9 palavra de sal\xE3o; num restaurante a
1201
+ -- mesma p\xE1gina marca MESA, e numa loja s\xF3 captura contato.
1202
+ ADD COLUMN IF NOT EXISTS capture_destination text DEFAULT 'lead' NOT NULL,
1203
+ ADD COLUMN IF NOT EXISTS capture_required_fields text[] DEFAULT '{}'::text[] NOT NULL,
1204
+ -- A janela \xE9 dupla: piso em horas contra "daqui a dez minutos", teto em dias
1205
+ -- contra "o ano que vem". Nulas quando o destino n\xE3o marca nada.
1206
+ ADD COLUMN IF NOT EXISTS min_advance_hours integer,
1207
+ ADD COLUMN IF NOT EXISTS max_advance_days integer;
1208
+
1209
+ DO $$ BEGIN
1210
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_marketing_landing_pages_capture_key_key') THEN
1211
+ ALTER TABLE ONLY public.plg_marketing_landing_pages
1212
+ ADD CONSTRAINT plg_marketing_landing_pages_capture_key_key UNIQUE (capture_key);
1213
+ END IF;
1214
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_marketing_landing_pages_destination') THEN
1215
+ ALTER TABLE public.plg_marketing_landing_pages
1216
+ ADD CONSTRAINT plg_marketing_landing_pages_destination
1217
+ CHECK (capture_destination = ANY (ARRAY['lead'::text, 'booking'::text, 'reservation'::text]));
1218
+ END IF;
1219
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_marketing_landing_pages_window') THEN
1220
+ ALTER TABLE public.plg_marketing_landing_pages
1221
+ ADD CONSTRAINT plg_marketing_landing_pages_window CHECK (
1222
+ min_advance_hours IS NULL OR max_advance_days IS NULL
1223
+ OR (min_advance_hours >= 0 AND max_advance_days >= 1
1224
+ AND min_advance_hours <= max_advance_days * 24));
1225
+ END IF;
1226
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_marketing_landing_pages_fields') THEN
1227
+ ALTER TABLE public.plg_marketing_landing_pages
1228
+ ADD CONSTRAINT plg_marketing_landing_pages_fields CHECK (
1229
+ capture_required_fields <@ ARRAY['name','phone','email','document','notes']::text[]);
1230
+ END IF;
1231
+ END $$;
1232
+
1233
+ COMMENT ON COLUMN public.plg_marketing_landing_pages.capture_key IS
1234
+ 'A chave P\xDABLICA e opaca da p\xE1gina. Uma porta s\xF3: conte\xFAdo e regra no mesmo lugar (marketing/003).';
1235
+
1236
+ -- A chave nasce sozinha, com o mesmo auxiliar do token de convite (crm/003),
1237
+ -- que resolve o pgcrypto em tempo de execu\xE7\xE3o.
1238
+ CREATE OR REPLACE FUNCTION public.trg_marketing_capture_key()
1239
+ RETURNS trigger LANGUAGE plpgsql SET search_path TO '' AS $$
1240
+ BEGIN
1241
+ IF NEW.capture_key IS NULL OR btrim(NEW.capture_key) = '' THEN
1242
+ NEW.capture_key := public.crm_url_token(18);
1243
+ END IF;
1244
+ RETURN NEW;
1245
+ END $$;
1246
+
1247
+ DROP TRIGGER IF EXISTS plg_marketing_capture_key ON public.plg_marketing_landing_pages;
1248
+ CREATE TRIGGER plg_marketing_capture_key
1249
+ BEFORE INSERT ON public.plg_marketing_landing_pages
1250
+ FOR EACH ROW EXECUTE FUNCTION public.trg_marketing_capture_key();
1251
+
1252
+ UPDATE public.plg_marketing_landing_pages
1253
+ SET capture_key = public.crm_url_token(18)
1254
+ WHERE capture_key IS NULL;
1255
+
1256
+ -- \u2500\u2500 abrir a porta (quem est\xE1 de fora) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1257
+ --
1258
+ -- \`anon\` executa: devolve o m\xEDnimo para desenhar a p\xE1gina e NADA que sirva para
1259
+ -- descobrir outra coisa. N\xE3o lista p\xE1ginas, n\xE3o aceita id (s\xF3 a chave), n\xE3o
1260
+ -- devolve tenant_id. P\xE1gina inativa responde \`closed\` e continua existindo \u2014 um
1261
+ -- 404 num endere\xE7o que o cliente tem salvo parece site fora do ar.
1262
+ CREATE OR REPLACE FUNCTION public.marketing_capture_load(p_key text)
1263
+ RETURNS jsonb
1264
+ LANGUAGE plpgsql SECURITY DEFINER STABLE SET search_path TO ''
1265
+ AS $$
1266
+ DECLARE v record;
1267
+ BEGIN
1268
+ IF p_key IS NULL OR length(p_key) < 8 THEN
1269
+ RETURN jsonb_build_object('status', 'not_found');
1270
+ END IF;
1271
+ SELECT * INTO v FROM public.plg_marketing_landing_pages WHERE capture_key = p_key;
1272
+ IF v.id IS NULL THEN RETURN jsonb_build_object('status', 'not_found'); END IF;
1273
+
1274
+ RETURN jsonb_build_object(
1275
+ 'status', CASE WHEN v.is_active AND v.status = 'live' THEN 'ok' ELSE 'closed' END,
1276
+ 'name', v.name,
1277
+ 'route', v.route,
1278
+ 'destination', v.capture_destination,
1279
+ 'requiredFields', to_jsonb(v.capture_required_fields),
1280
+ 'leadTags', to_jsonb(v.lead_tags),
1281
+ -- A janela vai junto para a p\xE1gina APAGAR os hor\xE1rios imposs\xEDveis, em vez
1282
+ -- de deixar o cliente escolher um e levar um "n\xE3o" depois.
1283
+ 'minAdvanceHours', v.min_advance_hours,
1284
+ 'maxAdvanceDays', v.max_advance_days
1285
+ );
1286
+ END $$;
1287
+
1288
+ REVOKE ALL ON FUNCTION public.marketing_capture_load(text) FROM PUBLIC;
1289
+ GRANT EXECUTE ON FUNCTION public.marketing_capture_load(text) TO anon, authenticated, service_role;
1290
+
1291
+ -- \u2500\u2500 a janela, aplicada no servidor \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1292
+ --
1293
+ -- Uma janela que s\xF3 existe na tela \xE9 decora\xE7\xE3o: quem manda um POST direto passa
1294
+ -- por cima. Devolve o MOTIVO porque "cedo demais" e "longe demais" pedem
1295
+ -- corre\xE7\xF5es opostas, e uma recusa gen\xE9rica faz o cliente tentar de novo errado.
1296
+ CREATE OR REPLACE FUNCTION public.marketing_capture_window_check(p_key text, p_starts_at timestamp with time zone)
1297
+ RETURNS jsonb
1298
+ LANGUAGE plpgsql SECURITY DEFINER STABLE SET search_path TO ''
1299
+ AS $$
1300
+ DECLARE v record;
1301
+ BEGIN
1302
+ SELECT * INTO v FROM public.plg_marketing_landing_pages WHERE capture_key = p_key;
1303
+ IF v.id IS NULL THEN RETURN jsonb_build_object('ok', false, 'reason', 'not_found'); END IF;
1304
+ IF NOT (v.is_active AND v.status = 'live') THEN RETURN jsonb_build_object('ok', false, 'reason', 'closed'); END IF;
1305
+ IF p_starts_at IS NULL THEN RETURN jsonb_build_object('ok', true); END IF;
1306
+
1307
+ IF v.min_advance_hours IS NOT NULL
1308
+ AND p_starts_at < now() + make_interval(hours => v.min_advance_hours) THEN
1309
+ RETURN jsonb_build_object('ok', false, 'reason', 'too_soon', 'minAdvanceHours', v.min_advance_hours);
1310
+ END IF;
1311
+ IF v.max_advance_days IS NOT NULL
1312
+ AND p_starts_at > now() + make_interval(days => v.max_advance_days) THEN
1313
+ RETURN jsonb_build_object('ok', false, 'reason', 'too_far', 'maxAdvanceDays', v.max_advance_days);
1314
+ END IF;
1315
+ RETURN jsonb_build_object('ok', true);
1316
+ END $$;
1317
+
1318
+ REVOKE ALL ON FUNCTION public.marketing_capture_window_check(text, timestamp with time zone) FROM PUBLIC;
1319
+ GRANT EXECUTE ON FUNCTION public.marketing_capture_window_check(text, timestamp with time zone) TO anon, authenticated, service_role;
1320
+
1321
+ -- \u2500\u2500 a porta antiga vem para a p\xE1gina \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1322
+ --
1323
+ -- Cada \`plg_crm_capture_pages\` vira uma landing page \`planned\` com a mesma
1324
+ -- chave \u2014 a chave \xE9 o endere\xE7o que o cliente pode ter salvo, e troc\xE1-la
1325
+ -- quebraria todo link j\xE1 colado num site. Nasce como \`planned\` e n\xE3o \`live\`
1326
+ -- porque a p\xE1gina ainda n\xE3o tem conte\xFAdo: publicar automaticamente poria no ar
1327
+ -- um endere\xE7o em branco.
1328
+ DO $$
1329
+ DECLARE r record; v_site uuid; v_n integer := 0;
1330
+ BEGIN
1331
+ IF to_regclass('public.plg_crm_capture_pages') IS NULL THEN RETURN; END IF;
1332
+
1333
+ FOR r IN SELECT * FROM public.plg_crm_capture_pages LOOP
1334
+ SELECT s.id INTO v_site FROM public.published_sites s
1335
+ WHERE s.tenant_id = r.tenant_id ORDER BY s.created_at LIMIT 1;
1336
+ -- Sem site n\xE3o h\xE1 onde a p\xE1gina morar. A porta \xE9 preservada no aviso, n\xE3o
1337
+ -- apagada em sil\xEAncio.
1338
+ IF v_site IS NULL THEN
1339
+ RAISE WARNING '003: tenant % tem porta de captura "%" e nenhum site \u2014 a porta n\xE3o foi migrada', r.tenant_id, r.name;
1340
+ CONTINUE;
1341
+ END IF;
1342
+
1343
+ INSERT INTO public.plg_marketing_landing_pages
1344
+ (tenant_id, site_id, name, route, status, is_active, lead_tags, unit_id,
1345
+ capture_key, capture_destination, capture_required_fields, min_advance_hours, max_advance_days)
1346
+ VALUES
1347
+ (r.tenant_id, v_site, r.name,
1348
+ '/' || coalesce(public.crm_slug(r.name), 'captura'),
1349
+ 'planned', r.is_active, r.lead_tags, r.unit_id,
1350
+ r.key, r.destination, r.required_fields, r.min_advance_hours, r.max_advance_days)
1351
+ ON CONFLICT DO NOTHING;
1352
+ v_n := v_n + 1;
1353
+ END LOOP;
1354
+
1355
+ RAISE NOTICE '003: % porta(s) de captura viraram landing page', v_n;
1356
+ END $$;
1357
+
1358
+ DROP TABLE IF EXISTS public.plg_crm_capture_pages;
1359
+ DELETE FROM app.scaffold_registry WHERE table_name = 'plg_crm_capture_pages';
1360
+ DROP FUNCTION IF EXISTS public.crm_capture_page_load(text);
1361
+ DROP FUNCTION IF EXISTS public.crm_capture_window_check(text, timestamp with time zone);
1362
+ DROP FUNCTION IF EXISTS public.trg_crm_capture_page_key();
1363
+ `;
1364
+ var MIGRATION_004_A_CAMPANHA_APONTA_PARA_UM_RECORTE = `-- 004_a_campanha_aponta_para_um_recorte.sql
1365
+ --
1366
+ -- A CAMPANHA N\xC3O CONSEGUIA APONTAR PARA UM SEGMENTO, QUE \xC9 PARA O QUE O
1367
+ -- SEGMENTO FOI FEITO.
1368
+ --
1369
+ -- O coment\xE1rio da 004 do CRM diz textualmente por que \`plg_crm_segments\`
1370
+ -- existe: "a campanha aponta para ele em vez de carregar a pr\xF3pria c\xF3pia do
1371
+ -- filtro". S\xF3 que \`marketing_resolve_audience\` nunca aprendeu a ler um
1372
+ -- segmento \u2014 ela resolve \`audience_query = {source_view, where[]}\`, a c\xF3pia do
1373
+ -- filtro. Ent\xE3o havia duas verdades sobre quem \xE9 "cliente ativo": a da tela de
1374
+ -- segmentos e a da campanha, e elas divergiam no dia em que algu\xE9m corrigisse
1375
+ -- uma.
1376
+ --
1377
+ -- E O VOCABUL\xC1RIO DELA \xC9 MENOR, que \xE9 o problema mais grave dos dois. A
1378
+ -- campanha aceita seis operadores de compara\xE7\xE3o e mais nada: sem \`within_days\`,
1379
+ -- sem \`has_tag\`, sem \`is_null\`. Copiar a regra do segmento para dentro da
1380
+ -- campanha, al\xE9m de duplicar, QUEBRA \u2014 "comprou nos \xFAltimos 30 dias" n\xE3o tem
1381
+ -- como ser expresso, e "anivers\xE1rio este m\xEAs" tamb\xE9m n\xE3o.
1382
+ --
1383
+ -- \u2500\u2500 as duas decis\xF5es \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1384
+ --
1385
+ -- 1. A CAMPANHA GUARDA UM PONTEIRO, N\xC3O UMA C\xD3PIA. \`{"segment_id": "..."}\` no
1386
+ -- \`audience_query\`. Editar o recorte muda para quem a campanha vai \u2014 que \xE9
1387
+ -- a expectativa de quem acabou de editar o recorte. Quem quiser a foto
1388
+ -- congelada usa um segmento do tipo \`list\`, que \xE9 exatamente o que ele \xE9.
1389
+ --
1390
+ -- 2. O CONJUNTO DE OPERADORES PASSA A SER O MESMO DO CRM, ponteiro ou n\xE3o.
1391
+ -- Uma campanha que aponta direto (sem segmento) ganha \`within_days\`,
1392
+ -- \`has_tag\` e companhia pela mesma tabela de CASE. Duas linguagens de
1393
+ -- filtro no mesmo produto \xE9 como se chega a duas respostas diferentes para
1394
+ -- a mesma pergunta.
1395
+ --
1396
+ -- O QUE N\xC3O MUDA, E \xC9 O MAIS IMPORTANTE: o filtro de consentimento continua na
1397
+ -- sa\xEDda. \`marketing_has_consent(tenant, pessoa, purpose)\` \xE9 a \xFAltima cl\xE1usula,
1398
+ -- sempre \u2014 um p\xFAblico resolvido por segmento n\xE3o atravessa o opt-out s\xF3 porque
1399
+ -- veio do CRM. A \`purpose\` da campanha \xE9 o que d\xE1 sentido a ele, e \xE9 por isso
1400
+ -- que "criar campanha com uma finalidade espec\xEDfica" \xE9 uma funcionalidade e
1401
+ -- n\xE3o um campo de texto: a finalidade DECIDE quem pode receber.
1402
+ --
1403
+ -- Sem depend\xEAncia dura de plugin: se \`plg_crm_segments\` n\xE3o existe neste app,
1404
+ -- um \`segment_id\` \xE9 recusado com mensagem clara em vez de estourar 42P01.
1405
+
1406
+ SET check_function_bodies = false;
1407
+
1408
+ CREATE OR REPLACE FUNCTION public.marketing_resolve_audience(p_campaign_id uuid)
1409
+ RETURNS TABLE(person_id uuid)
1410
+ LANGUAGE plpgsql STABLE SECURITY DEFINER
1411
+ SET search_path TO ''
1412
+ AS $_$
1413
+ DECLARE
1414
+ c public.plg_marketing_campaigns%ROWTYPE;
1415
+ v_source text;
1416
+ v_rules jsonb;
1417
+ v_where text := '';
1418
+ v_clause jsonb;
1419
+ v_col text;
1420
+ v_op text;
1421
+ v_kind char;
1422
+ v_seg_id uuid;
1423
+ v_seg record;
1424
+ BEGIN
1425
+ SELECT * INTO c FROM public.plg_marketing_campaigns WHERE id = p_campaign_id;
1426
+ IF c.id IS NULL THEN
1427
+ RAISE EXCEPTION 'marketing_resolve_audience: campaign % not found', p_campaign_id USING ERRCODE = 'P0002';
1428
+ END IF;
1429
+
1430
+ -- \u2500\u2500 o ponteiro \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1431
+ IF c.audience_query ? 'segment_id' THEN
1432
+ IF to_regclass('public.plg_crm_segments') IS NULL THEN
1433
+ RAISE EXCEPTION 'marketing_resolve_audience: campanha aponta para um segmento, mas o CRM n\xE3o est\xE1 instalado neste app'
1434
+ USING ERRCODE = '42P01';
1435
+ END IF;
1436
+ BEGIN
1437
+ v_seg_id := (c.audience_query ->> 'segment_id')::uuid;
1438
+ EXCEPTION WHEN OTHERS THEN
1439
+ RAISE EXCEPTION 'marketing_resolve_audience: segment_id inv\xE1lido' USING ERRCODE = '22023';
1440
+ END;
1441
+
1442
+ -- Mesmo tenant, sempre. Um p\xFAblico que atravessasse inquilino seria a pior
1443
+ -- falha poss\xEDvel deste arquivo.
1444
+ SELECT s.source_view, s.rules, s.kind INTO v_seg
1445
+ FROM public.plg_crm_segments s
1446
+ WHERE s.id = v_seg_id AND s.tenant_id = c.tenant_id;
1447
+ IF NOT FOUND THEN
1448
+ RAISE EXCEPTION 'marketing_resolve_audience: segmento % n\xE3o \xE9 deste tenant', v_seg_id USING ERRCODE = 'P0002';
1449
+ END IF;
1450
+
1451
+ -- A lista escolhida a dedo \xC9 a resposta \u2014 e mesmo ela passa pelo consentimento.
1452
+ IF v_seg.kind = 'list' THEN
1453
+ RETURN QUERY
1454
+ SELECT m.person_id FROM public.plg_crm_segment_members m
1455
+ WHERE m.segment_id = v_seg_id AND m.tenant_id = c.tenant_id
1456
+ AND public.marketing_has_consent(c.tenant_id, m.person_id, c.purpose);
1457
+ RETURN;
1458
+ END IF;
1459
+
1460
+ v_source := coalesce(v_seg.source_view, 'people');
1461
+ v_rules := coalesce(v_seg.rules -> 'where', '[]'::jsonb);
1462
+ ELSE
1463
+ v_source := coalesce(c.audience_query ->> 'source_view', 'people');
1464
+ v_rules := coalesce(c.audience_query -> 'where', '[]'::jsonb);
1465
+ END IF;
1466
+
1467
+ IF v_source !~ '^[a-z_][a-z0-9_]*$' THEN
1468
+ RAISE EXCEPTION 'marketing_resolve_audience: % is not a bare relation name', v_source USING ERRCODE = '22023';
1469
+ END IF;
1470
+ SELECT cl.relkind INTO v_kind FROM pg_catalog.pg_class cl
1471
+ JOIN pg_catalog.pg_namespace n ON n.oid = cl.relnamespace
1472
+ WHERE n.nspname = 'public' AND cl.relname = v_source;
1473
+ IF v_kind IS NULL THEN
1474
+ RAISE EXCEPTION 'marketing_resolve_audience: public.% does not exist', v_source USING ERRCODE = '42P01';
1475
+ END IF;
1476
+
1477
+ v_where := format('tenant_id = %L', c.tenant_id);
1478
+
1479
+ -- Unidade da campanha estreita o p\xFAblico, quando a leitura tem a coluna.
1480
+ IF c.unit_id IS NOT NULL AND EXISTS (
1481
+ SELECT 1 FROM information_schema.columns
1482
+ WHERE table_schema = 'public' AND table_name = v_source AND column_name = 'unit_id'
1483
+ ) THEN
1484
+ v_where := v_where || format(' AND unit_id = %L', c.unit_id);
1485
+ END IF;
1486
+
1487
+ -- \u2500\u2500 o mesmo CASE do CRM, operador por operador \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1488
+ FOR v_clause IN SELECT * FROM jsonb_array_elements(v_rules) LOOP
1489
+ v_col := v_clause ->> 'column';
1490
+ v_op := coalesce(v_clause ->> 'op', '=');
1491
+
1492
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns
1493
+ WHERE table_schema = 'public' AND table_name = v_source AND column_name = v_col) THEN
1494
+ RAISE EXCEPTION 'marketing_resolve_audience: public.% has no column %', v_source, v_col USING ERRCODE = '42703';
1495
+ END IF;
1496
+
1497
+ v_where := v_where || CASE
1498
+ WHEN v_op IN ('=', '<>', '>', '<', '>=', '<=') THEN
1499
+ format(' AND %I %s %L', v_col,
1500
+ CASE v_op WHEN '<>' THEN '<>' WHEN '>' THEN '>' WHEN '<' THEN '<'
1501
+ WHEN '>=' THEN '>=' WHEN '<=' THEN '<=' ELSE '=' END,
1502
+ v_clause ->> 'value')
1503
+
1504
+ WHEN v_op = 'ilike' THEN
1505
+ format(' AND %I ILIKE %L', v_col, '%' || coalesce(v_clause ->> 'value', '') || '%')
1506
+
1507
+ WHEN v_op IN ('in', 'not_in') THEN (
1508
+ SELECT format(' AND %I %s (%s)', v_col,
1509
+ CASE WHEN v_op = 'not_in' THEN 'NOT IN' ELSE 'IN' END,
1510
+ coalesce(string_agg(quote_literal(e), ','), 'NULL'))
1511
+ FROM jsonb_array_elements_text(coalesce(v_clause -> 'value', '[]'::jsonb)) AS e)
1512
+
1513
+ WHEN v_op = 'is_null' THEN format(' AND %I IS NULL', v_col)
1514
+ WHEN v_op = 'not_null' THEN format(' AND %I IS NOT NULL', v_col)
1515
+
1516
+ WHEN v_op = 'has_tag' THEN
1517
+ format(' AND %L = ANY(%I)', v_clause ->> 'value', v_col)
1518
+
1519
+ WHEN v_op IN ('within_days', 'older_than_days') THEN (
1520
+ SELECT format(' AND %I %s now() - make_interval(days => %s)', v_col,
1521
+ CASE WHEN v_op = 'within_days' THEN '>=' ELSE '<' END,
1522
+ (coalesce(v_clause ->> 'value', '0'))::integer))
1523
+
1524
+ WHEN v_op = 'birthday_within_days' THEN (
1525
+ SELECT format(
1526
+ ' AND %I IS NOT NULL AND ((EXTRACT(doy FROM %I::date)::int - EXTRACT(doy FROM now())::int + 366) %% 366) <= %s',
1527
+ v_col, v_col, (coalesce(v_clause ->> 'value', '0'))::integer))
1528
+
1529
+ ELSE
1530
+ -- Operador desconhecido ALARGA o p\xFAblico se for ignorado \u2014 e um p\xFAblico
1531
+ -- alargado em sil\xEAncio \xE9 a mensagem indo para quem n\xE3o devia.
1532
+ NULL
1533
+ END;
1534
+
1535
+ IF v_where IS NULL THEN
1536
+ RAISE EXCEPTION 'marketing_resolve_audience: operador % n\xE3o \xE9 reconhecido', v_op USING ERRCODE = '22023';
1537
+ END IF;
1538
+ END LOOP;
1539
+
1540
+ -- O consentimento \xE9 a \xDALTIMA cl\xE1usula, e continua sendo, venha o p\xFAblico de
1541
+ -- onde vier.
1542
+ RETURN QUERY EXECUTE format(
1543
+ 'SELECT s.id FROM public.%I s WHERE %s AND public.marketing_has_consent(%L, s.id, %L)',
1544
+ v_source, v_where, c.tenant_id, c.purpose);
1545
+ END $_$;
1546
+
1547
+ COMMENT ON FUNCTION public.marketing_resolve_audience(uuid) IS
1548
+ 'Quem a campanha alcan\xE7a. Aponta para um segmento do CRM (segment_id) ou carrega a pr\xF3pria regra; mesmo conjunto de operadores do CRM; consentimento sempre na sa\xEDda (004).';
1549
+ `;
1550
+ var MIGRATION_005_A_ATRIBUICAO_DO_LEAD_ATRAVESSA_A_ORIGEM_ATE_O_CANAL = `-- ---------------------------------------------------------------------------
1551
+ -- 005_a_atribuicao_do_lead_atravessa_a_origem_ate_o_canal.sql \u2014 os tr\xEAs n\xEDveis
1552
+ -- de atribui\xE7\xE3o num lugar s\xF3, e o conserto de uma tela que contava zero.
1553
+ --
1554
+ -- \u2500\u2500 por que este arquivo est\xE1 no marketing e n\xE3o no CRM \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1555
+ --
1556
+ -- A view junta tr\xEAs tabelas de duas casas: \`plg_crm_lead_sources\` (CRM) com
1557
+ -- \`plg_marketing_channels\` e \`plg_marketing_campaigns\` (aqui). Na ordem da
1558
+ -- chain o CRM roda ANTES do marketing, ent\xE3o do lado de l\xE1 as tabelas deste
1559
+ -- plugin ainda n\xE3o existem. Do lado de c\xE1 as do CRM j\xE1 existem.
1560
+ --
1561
+ -- A regra que isso ensina: quem cria uma view que atravessa plugins \xE9 o \xDALTIMO
1562
+ -- dos seus dependentes. \`crm/038\` tentou o contr\xE1rio e o \`db-from-zero\` pegou
1563
+ -- na primeira passada.
1564
+ --
1565
+ -- \u2500\u2500 as tr\xEAs perguntas \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1566
+ --
1567
+ -- CANAL (medium) COMO a gente se encontrou. "Evento presencial"
1568
+ -- ORIGEM (source) ONDE, especificamente. "Sal\xE3o Abrasel"
1569
+ -- CAMPANHA QUAL esfor\xE7o, com custo. "ABRASEL 2026"
1570
+ --
1571
+ -- Salesforce separa \`LeadSource\` de \`Campaign\`; HubSpot separa "Original
1572
+ -- source" do drill-down; Odoo e Mautic usam o trio UTM. Nenhum junta os tr\xEAs
1573
+ -- num campo s\xF3: "quanto custou este lead" s\xF3 existe na campanha, e "que tipo
1574
+ -- de esfor\xE7o traz gente melhor" s\xF3 existe no canal.
1575
+ --
1576
+ -- \u2500\u2500 o defeito que isto conserta \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1577
+ --
1578
+ -- \`v_marketing_attribution\` lia \`metadata.sourceId\` e chamava aquilo de
1579
+ -- \`channel_raw\` \u2014 a ORIGEM ocupando o lugar do CANAL. Com isso a tela de
1580
+ -- Canais contava zero para todos, porque nenhum \`channel_key\` jamais bate com
1581
+ -- um uuid de origem. Esta view atravessa a origem at\xE9 o canal, que \xE9 o salto
1582
+ -- que faltava. A antiga fica: outras telas dependem da forma dela.
1583
+ -- ---------------------------------------------------------------------------
1584
+
1585
+ DROP VIEW IF EXISTS public.v_marketing_lead_attribution;
1586
+ CREATE VIEW public.v_marketing_lead_attribution
1587
+ WITH (security_invoker = true) AS
1588
+ SELECT p.id AS lead_id,
1589
+ p.tenant_id,
1590
+ p.created_at,
1591
+ s.id AS source_id,
1592
+ s.name AS source_name,
1593
+ -- O canal vem da ORIGEM; quando ela n\xE3o declara, o lead n\xE3o \xE9
1594
+ -- atribu\xEDdo a canal nenhum. Nulo \xE9 honesto: for\xE7ar 'direct' faria a
1595
+ -- tela afirmar uma proced\xEAncia que ningu\xE9m mediu.
1596
+ s.channel_key,
1597
+ ch.label AS channel_label,
1598
+ ch.kind AS channel_kind,
1599
+ (p.metadata ->> 'campaignId') AS campaign_id,
1600
+ mc.name AS campaign_name,
1601
+ mc.spend AS campaign_spend
1602
+ FROM public.people p
1603
+ LEFT JOIN public.plg_crm_lead_sources s
1604
+ ON s.tenant_id = p.tenant_id
1605
+ AND s.id::text = (p.metadata ->> 'sourceId')
1606
+ LEFT JOIN public.plg_marketing_channels ch
1607
+ ON ch.tenant_id = p.tenant_id
1608
+ AND ch.channel_key = s.channel_key
1609
+ LEFT JOIN public.plg_marketing_campaigns mc
1610
+ ON mc.tenant_id = p.tenant_id
1611
+ AND mc.id::text = (p.metadata ->> 'campaignId')
1612
+ WHERE p.kind = 'lead'
1613
+ AND p.merged_into_id IS NULL
1614
+ AND p.tenant_id IN (SELECT public.user_tenant_ids());
1615
+
1616
+ GRANT SELECT ON public.v_marketing_lead_attribution TO authenticated;
1617
+
1618
+ COMMENT ON VIEW public.v_marketing_lead_attribution IS
1619
+ 'Cada lead com seus tr\xEAs n\xEDveis: canal (COMO), origem (ONDE) e campanha (QUAL esfor\xE7o, com custo). A origem resolve o canal \u2014 o lead n\xE3o o carrega (marketing/005).';
1620
+ `;
1621
+ var MIGRATION_006_THE_CAMPAIGN_ENGINE_IS_SERVER_ONLY = `-- 006_the_campaign_engine_is_server_only.sql
1622
+ --
1623
+ -- \`marketing_campaign_run\`, \`marketing_resolve_audience\` and
1624
+ -- \`marketing_has_consent\` are SECURITY DEFINER and look a campaign or a tenant
1625
+ -- up by id without asking who is calling. Granted to \`authenticated\`, any
1626
+ -- logged-in user of any tenant could fire another tenant's campaign or list
1627
+ -- its audience by guessing a uuid.
1628
+ --
1629
+ -- No screen, edge function or agent tool calls them: the only caller is
1630
+ -- \`marketing_run_due\` on the minute tick (via \`event_runner_tick\`, as postgres)
1631
+ -- and the functions themselves. So the fix is to take the door away, not to
1632
+ -- guard it. A future "send now" button gets a tenant-checked wrapper.
1633
+ --
1634
+ -- \`REVOKE ... FROM PUBLIC\` alone is not enough here: the pool's default
1635
+ -- privileges grant EXECUTE to \`authenticated\` on every new function in public.
1636
+
1637
+ REVOKE ALL ON FUNCTION public.marketing_campaign_run(uuid, integer) FROM PUBLIC, anon, authenticated;
1638
+ GRANT EXECUTE ON FUNCTION public.marketing_campaign_run(uuid, integer) TO service_role;
1639
+
1640
+ REVOKE ALL ON FUNCTION public.marketing_resolve_audience(uuid) FROM PUBLIC, anon, authenticated;
1641
+ GRANT EXECUTE ON FUNCTION public.marketing_resolve_audience(uuid) TO service_role;
1642
+
1643
+ REVOKE ALL ON FUNCTION public.marketing_has_consent(uuid, uuid, text) FROM PUBLIC, anon, authenticated;
1644
+ GRANT EXECUTE ON FUNCTION public.marketing_has_consent(uuid, uuid, text) TO service_role;
1645
+
1646
+ REVOKE ALL ON FUNCTION public.marketing_run_due(integer) FROM PUBLIC, anon, authenticated;
1647
+ GRANT EXECUTE ON FUNCTION public.marketing_run_due(integer) TO service_role;
1648
+ `;
1649
+ var MIGRATIONS = [
1650
+ { id: "000_baseline", sql: MIGRATION_000_BASELINE },
1651
+ { id: "001_the_landing_page_names_the_site", sql: MIGRATION_001_THE_LANDING_PAGE_NAMES_THE_SITE },
1652
+ { id: "003_a_porta_publica_e_a_pagina", sql: MIGRATION_003_A_PORTA_PUBLICA_E_A_PAGINA },
1653
+ { id: "004_a_campanha_aponta_para_um_recorte", sql: MIGRATION_004_A_CAMPANHA_APONTA_PARA_UM_RECORTE },
1654
+ { id: "005_a_atribuicao_do_lead_atravessa_a_origem_ate_o_canal", sql: MIGRATION_005_A_ATRIBUICAO_DO_LEAD_ATRAVESSA_A_ORIGEM_ATE_O_CANAL },
1655
+ { id: "006_the_campaign_engine_is_server_only", sql: MIGRATION_006_THE_CAMPAIGN_ENGINE_IS_SERVER_ONLY }
1656
+ ];
1657
+
1658
+ export { MIGRATIONS, MIGRATION_000_BASELINE, MIGRATION_001_THE_LANDING_PAGE_NAMES_THE_SITE, MIGRATION_003_A_PORTA_PUBLICA_E_A_PAGINA, MIGRATION_004_A_CAMPANHA_APONTA_PARA_UM_RECORTE, MIGRATION_005_A_ATRIBUICAO_DO_LEAD_ATRAVESSA_A_ORIGEM_ATE_O_CANAL, MIGRATION_006_THE_CAMPAIGN_ENGINE_IS_SERVER_ONLY };
1659
+ //# sourceMappingURL=main.js.map
1660
+ //# sourceMappingURL=main.js.map