@fayz-ai/plugin-conversations 0.23.14 → 0.23.15

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,2141 @@
1
+ // src/migrations/index.ts
2
+ var MIGRATION_000_BASELINE = `-- ============================================================================
3
+ -- plugins/plugin-conversations/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 9 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_conversation_messages (
72
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
73
+ tenant_id uuid NOT NULL,
74
+ conversation_id uuid NOT NULL,
75
+ channel text,
76
+ direction text,
77
+ body text NOT NULL,
78
+ author text,
79
+ at timestamp with time zone DEFAULT now(),
80
+ provider_message_id text,
81
+ delivery_status text,
82
+ sender_kind text,
83
+ sender_label text,
84
+ subject_type text,
85
+ subject_id text,
86
+ CONSTRAINT plg_conversation_messages_channel_check CHECK (((channel IS NULL) OR (channel = ANY (ARRAY['sms'::text, 'whatsapp'::text, 'instagram'::text, 'email'::text, 'webchat'::text])))),
87
+ CONSTRAINT plg_conversation_messages_delivery_status_check CHECK (((delivery_status IS NULL) OR (delivery_status = ANY (ARRAY['queued'::text, 'sent'::text, 'delivered'::text, 'read'::text, 'failed'::text, 'expired'::text, 'delivery_timeout'::text, 'cancelled'::text, 'opted_out'::text])))),
88
+ CONSTRAINT plg_conversation_messages_direction_check CHECK ((direction = ANY (ARRAY['inbound'::text, 'outbound'::text]))),
89
+ CONSTRAINT plg_conversation_messages_sender_kind_check CHECK (((sender_kind IS NULL) OR (sender_kind = ANY (ARRAY['user'::text, 'system'::text, 'ai'::text, 'automation'::text]))))
90
+ );
91
+
92
+ CREATE TABLE public.plg_conversations (
93
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
94
+ tenant_id uuid NOT NULL,
95
+ contact_name text NOT NULL,
96
+ contact_handle text,
97
+ channel text NOT NULL,
98
+ last_message_preview text,
99
+ last_message_at timestamp with time zone DEFAULT now(),
100
+ unread_count integer DEFAULT 0,
101
+ status text DEFAULT 'open'::text,
102
+ assigned_to text,
103
+ accent text,
104
+ tags text[],
105
+ location text,
106
+ note text,
107
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
108
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
109
+ contact_person_id uuid,
110
+ CONSTRAINT plg_conversations_channel_check CHECK ((channel = ANY (ARRAY['sms'::text, 'whatsapp'::text, 'instagram'::text, 'email'::text, 'webchat'::text]))),
111
+ CONSTRAINT plg_conversations_status_check CHECK ((status = ANY (ARRAY['open'::text, 'snoozed'::text, 'closed'::text])))
112
+ );
113
+
114
+ CREATE TABLE public.plg_conversations_channels (
115
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
116
+ tenant_id uuid,
117
+ channel text DEFAULT 'whatsapp'::text NOT NULL,
118
+ provider text NOT NULL,
119
+ provider_number_id text NOT NULL,
120
+ phone_e164 text,
121
+ kind text DEFAULT 'dedicated'::text NOT NULL,
122
+ status text DEFAULT 'requested'::text NOT NULL,
123
+ display_name text,
124
+ metadata jsonb DEFAULT '{}'::jsonb NOT NULL,
125
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
126
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
127
+ CONSTRAINT plg_conversations_channels_channel_check CHECK ((channel = ANY (ARRAY['sms'::text, 'whatsapp'::text, 'instagram'::text, 'email'::text, 'webchat'::text]))),
128
+ CONSTRAINT plg_conversations_channels_kind_check CHECK ((kind = ANY (ARRAY['fallback'::text, 'dedicated'::text]))),
129
+ CONSTRAINT plg_conversations_channels_status_check CHECK ((status = ANY (ARRAY['requested'::text, 'provisioning'::text, 'provisioned'::text, 'verifying'::text, 'active'::text, 'failed'::text, 'released'::text, 'disconnected'::text])))
130
+ );
131
+
132
+ CREATE TABLE public.plg_conversations_optouts (
133
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
134
+ channel text DEFAULT 'whatsapp'::text NOT NULL,
135
+ phone_e164 text NOT NULL,
136
+ tenant_id uuid,
137
+ provider text NOT NULL,
138
+ reason text,
139
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
140
+ CONSTRAINT plg_conversations_optouts_channel_check CHECK ((channel = ANY (ARRAY['sms'::text, 'whatsapp'::text, 'instagram'::text, 'email'::text, 'webchat'::text])))
141
+ );
142
+
143
+ CREATE TABLE public.plg_conversations_payment_requests (
144
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
145
+ tenant_id uuid NOT NULL,
146
+ conversation_id uuid,
147
+ message_id uuid,
148
+ subject_type text,
149
+ subject_id text,
150
+ amount_cents integer NOT NULL,
151
+ currency text DEFAULT 'BRL'::text NOT NULL,
152
+ description text,
153
+ provider text NOT NULL,
154
+ status text DEFAULT 'created'::text NOT NULL,
155
+ provider_payment_id text,
156
+ payment_link_url text,
157
+ pix_copy_paste text,
158
+ link_delivered_at timestamp with time zone,
159
+ settled_notified_at timestamp with time zone,
160
+ settled_at timestamp with time zone,
161
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
162
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
163
+ CONSTRAINT plg_conversations_payment_requests_amount_cents_check CHECK ((amount_cents > 0)),
164
+ CONSTRAINT plg_conversations_payment_requests_status_check CHECK ((status = ANY (ARRAY['created'::text, 'link_generated'::text, 'approval_requested'::text, 'approved'::text, 'paid'::text, 'failed'::text, 'expired'::text, 'cancelled'::text])))
165
+ );
166
+
167
+ CREATE TABLE public.plg_conversations_webhook_events (
168
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
169
+ provider text NOT NULL,
170
+ event_id text NOT NULL,
171
+ event_type text NOT NULL,
172
+ received_at timestamp with time zone DEFAULT now() NOT NULL
173
+ );
174
+
175
+
176
+ -- \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
177
+
178
+ CREATE FUNCTION public.plg_conversations_match_by_phone(p_channel text, p_last10 text, p_last8 text, p_tenant_id uuid DEFAULT NULL::uuid) RETURNS TABLE(id uuid, tenant_id uuid, contact_name text, contact_handle text, contact_person_id uuid)
179
+ LANGUAGE sql STABLE SECURITY DEFINER
180
+ SET search_path TO 'public'
181
+ AS $$
182
+ WITH candidate AS (
183
+ SELECT c.id, c.tenant_id, c.contact_name, c.contact_handle, c.contact_person_id,
184
+ c.last_message_at,
185
+ regexp_replace(COALESCE(c.contact_handle, ''), '[^0-9]', '', 'g') AS digits
186
+ FROM public.plg_conversations c
187
+ WHERE c.channel = p_channel
188
+ AND (p_tenant_id IS NULL OR c.tenant_id = p_tenant_id)
189
+ )
190
+ SELECT id, tenant_id, contact_name, contact_handle, contact_person_id
191
+ FROM candidate
192
+ WHERE length(digits) >= 8
193
+ AND ((p_last10 IS NOT NULL AND right(digits, 10) = p_last10)
194
+ OR (p_last8 IS NOT NULL AND right(digits, 8) = p_last8))
195
+ -- The 10-digit match wins over an 8-digit one even when the 8-digit thread
196
+ -- is newer: a wrong area code is a different person, and a newer wrong
197
+ -- answer is still wrong.
198
+ ORDER BY (p_last10 IS NOT NULL AND right(digits, 10) = p_last10) DESC,
199
+ last_message_at DESC NULLS LAST
200
+ LIMIT 1;
201
+ $$;
202
+
203
+
204
+ -- \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
205
+
206
+ ALTER TABLE ONLY public.plg_conversation_messages
207
+ ADD CONSTRAINT plg_conversation_messages_pkey PRIMARY KEY (id);
208
+
209
+ ALTER TABLE ONLY public.plg_conversations_channels
210
+ ADD CONSTRAINT plg_conversations_channels_pkey PRIMARY KEY (id);
211
+
212
+ ALTER TABLE ONLY public.plg_conversations_optouts
213
+ ADD CONSTRAINT plg_conversations_optouts_pkey PRIMARY KEY (id);
214
+
215
+ ALTER TABLE ONLY public.plg_conversations_payment_requests
216
+ ADD CONSTRAINT plg_conversations_payment_requests_pkey PRIMARY KEY (id);
217
+
218
+ ALTER TABLE ONLY public.plg_conversations
219
+ ADD CONSTRAINT plg_conversations_pkey PRIMARY KEY (id);
220
+
221
+ ALTER TABLE ONLY public.plg_conversations_webhook_events
222
+ ADD CONSTRAINT plg_conversations_webhook_events_pkey PRIMARY KEY (id);
223
+
224
+
225
+ -- \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
226
+
227
+ CREATE INDEX idx_plg_conversation_messages_by_subject ON public.plg_conversation_messages USING btree (tenant_id, subject_type, subject_id) WHERE (subject_id IS NOT NULL);
228
+
229
+ CREATE INDEX idx_plg_conversation_messages_subject ON public.plg_conversation_messages USING btree (conversation_id, at DESC) WHERE (subject_id IS NOT NULL);
230
+
231
+ CREATE INDEX idx_plg_conversation_messages_thread ON public.plg_conversation_messages USING btree (conversation_id, at);
232
+
233
+ CREATE INDEX idx_plg_conversations_channels_tenant ON public.plg_conversations_channels USING btree (tenant_id, channel, status) WHERE (tenant_id IS NOT NULL);
234
+
235
+ CREATE INDEX idx_plg_conversations_handle_suffix ON public.plg_conversations USING btree (channel, "right"(regexp_replace(COALESCE(contact_handle, ''::text), '[^0-9]'::text, ''::text, 'g'::text), 8), last_message_at DESC);
236
+
237
+ CREATE INDEX idx_plg_conversations_optouts_lookup ON public.plg_conversations_optouts USING btree (channel, phone_e164);
238
+
239
+ CREATE INDEX idx_plg_conversations_payment_requests_subject ON public.plg_conversations_payment_requests USING btree (tenant_id, subject_type, subject_id) WHERE (subject_id IS NOT NULL);
240
+
241
+ CREATE INDEX idx_plg_conversations_payment_requests_tenant ON public.plg_conversations_payment_requests USING btree (tenant_id, created_at DESC);
242
+
243
+ CREATE INDEX idx_plg_conversations_person ON public.plg_conversations USING btree (tenant_id, contact_person_id) WHERE (contact_person_id IS NOT NULL);
244
+
245
+ CREATE INDEX idx_plg_conversations_tenant ON public.plg_conversations USING btree (tenant_id);
246
+
247
+ CREATE INDEX idx_plg_conversations_tenant_recent ON public.plg_conversations USING btree (tenant_id, last_message_at DESC);
248
+
249
+ CREATE INDEX idx_plg_conversations_webhook_events_received ON public.plg_conversations_webhook_events USING btree (received_at);
250
+
251
+ CREATE UNIQUE INDEX uq_plg_conversation_messages_provider_message ON public.plg_conversation_messages USING btree (provider_message_id) WHERE (provider_message_id IS NOT NULL);
252
+
253
+ CREATE UNIQUE INDEX uq_plg_conversations_channels_fallback ON public.plg_conversations_channels USING btree (channel, provider) WHERE ((tenant_id IS NULL) AND (kind = 'fallback'::text));
254
+
255
+ CREATE UNIQUE INDEX uq_plg_conversations_channels_number ON public.plg_conversations_channels USING btree (provider, provider_number_id);
256
+
257
+ CREATE UNIQUE INDEX uq_plg_conversations_optouts_contact ON public.plg_conversations_optouts USING btree (channel, phone_e164, COALESCE(tenant_id, '00000000-0000-0000-0000-000000000000'::uuid));
258
+
259
+ CREATE UNIQUE INDEX uq_plg_conversations_payment_requests_open_subject ON public.plg_conversations_payment_requests USING btree (tenant_id, subject_type, subject_id) WHERE ((subject_id IS NOT NULL) AND (status = ANY (ARRAY['created'::text, 'link_generated'::text, 'approval_requested'::text, 'approved'::text])));
260
+
261
+ CREATE UNIQUE INDEX uq_plg_conversations_payment_requests_provider ON public.plg_conversations_payment_requests USING btree (provider, provider_payment_id) WHERE (provider_payment_id IS NOT NULL);
262
+
263
+ CREATE UNIQUE INDEX uq_plg_conversations_webhook_events_event ON public.plg_conversations_webhook_events USING btree (provider, event_id);
264
+
265
+
266
+ -- \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
267
+
268
+ ALTER TABLE ONLY public.plg_conversation_messages
269
+ ADD CONSTRAINT plg_conversation_messages_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES public.plg_conversations(id) ON DELETE CASCADE;
270
+
271
+ ALTER TABLE ONLY public.plg_conversation_messages
272
+ ADD CONSTRAINT plg_conversation_messages_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
273
+
274
+ ALTER TABLE ONLY public.plg_conversations_channels
275
+ ADD CONSTRAINT plg_conversations_channels_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
276
+
277
+ ALTER TABLE ONLY public.plg_conversations
278
+ ADD CONSTRAINT plg_conversations_contact_person_id_fkey FOREIGN KEY (contact_person_id) REFERENCES public.people(id) ON DELETE SET NULL;
279
+
280
+ ALTER TABLE ONLY public.plg_conversations_optouts
281
+ ADD CONSTRAINT plg_conversations_optouts_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
282
+
283
+ ALTER TABLE ONLY public.plg_conversations_payment_requests
284
+ ADD CONSTRAINT plg_conversations_payment_requests_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES public.plg_conversations(id) ON DELETE SET NULL;
285
+
286
+ ALTER TABLE ONLY public.plg_conversations_payment_requests
287
+ ADD CONSTRAINT plg_conversations_payment_requests_message_id_fkey FOREIGN KEY (message_id) REFERENCES public.plg_conversation_messages(id) ON DELETE SET NULL;
288
+
289
+ ALTER TABLE ONLY public.plg_conversations_payment_requests
290
+ ADD CONSTRAINT plg_conversations_payment_requests_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
291
+
292
+ ALTER TABLE ONLY public.plg_conversations
293
+ ADD CONSTRAINT plg_conversations_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
294
+
295
+
296
+ -- \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
297
+
298
+ ALTER TABLE public.plg_conversation_messages ENABLE ROW LEVEL SECURITY;
299
+
300
+ ALTER TABLE public.plg_conversations ENABLE ROW LEVEL SECURITY;
301
+
302
+ ALTER TABLE public.plg_conversations_channels ENABLE ROW LEVEL SECURITY;
303
+
304
+ ALTER TABLE public.plg_conversations_optouts ENABLE ROW LEVEL SECURITY;
305
+
306
+ ALTER TABLE public.plg_conversations_payment_requests ENABLE ROW LEVEL SECURITY;
307
+
308
+ ALTER TABLE public.plg_conversations_webhook_events ENABLE ROW LEVEL SECURITY;
309
+
310
+
311
+ -- \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
312
+
313
+ CREATE POLICY plg_conversation_messages_delete ON public.plg_conversation_messages FOR DELETE TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));
314
+
315
+ CREATE POLICY plg_conversation_messages_insert ON public.plg_conversation_messages FOR INSERT TO authenticated WITH CHECK ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));
316
+
317
+ CREATE POLICY plg_conversation_messages_select ON public.plg_conversation_messages FOR SELECT TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));
318
+
319
+ CREATE POLICY plg_conversation_messages_update ON public.plg_conversation_messages FOR UPDATE TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));
320
+
321
+ CREATE POLICY plg_conversations_channels_select ON public.plg_conversations_channels FOR SELECT TO authenticated USING (((tenant_id IS NULL) OR (tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids))));
322
+
323
+ CREATE POLICY plg_conversations_delete ON public.plg_conversations FOR DELETE TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));
324
+
325
+ CREATE POLICY plg_conversations_insert ON public.plg_conversations FOR INSERT TO authenticated WITH CHECK ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));
326
+
327
+ CREATE POLICY plg_conversations_optouts_select ON public.plg_conversations_optouts FOR SELECT TO authenticated USING (((tenant_id IS NULL) OR (tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids))));
328
+
329
+ CREATE POLICY plg_conversations_payment_requests_select ON public.plg_conversations_payment_requests FOR SELECT TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));
330
+
331
+ CREATE POLICY plg_conversations_select ON public.plg_conversations FOR SELECT TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));
332
+
333
+ CREATE POLICY plg_conversations_update ON public.plg_conversations FOR UPDATE TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));
334
+
335
+
336
+ -- \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
337
+
338
+ REVOKE ALL ON FUNCTION public.plg_conversations_match_by_phone(p_channel text, p_last10 text, p_last8 text, p_tenant_id uuid) FROM PUBLIC;
339
+ GRANT ALL ON FUNCTION public.plg_conversations_match_by_phone(p_channel text, p_last10 text, p_last8 text, p_tenant_id uuid) TO service_role;
340
+
341
+ GRANT MAINTAIN ON TABLE public.plg_conversation_messages TO anon;
342
+ GRANT ALL ON TABLE public.plg_conversation_messages TO authenticated;
343
+ GRANT ALL ON TABLE public.plg_conversation_messages TO service_role;
344
+
345
+ GRANT MAINTAIN ON TABLE public.plg_conversations TO anon;
346
+ GRANT ALL ON TABLE public.plg_conversations TO authenticated;
347
+ GRANT ALL ON TABLE public.plg_conversations TO service_role;
348
+
349
+ GRANT MAINTAIN ON TABLE public.plg_conversations_channels TO anon;
350
+ GRANT ALL ON TABLE public.plg_conversations_channels TO authenticated;
351
+ GRANT ALL ON TABLE public.plg_conversations_channels TO service_role;
352
+
353
+ GRANT MAINTAIN ON TABLE public.plg_conversations_optouts TO anon;
354
+ GRANT ALL ON TABLE public.plg_conversations_optouts TO authenticated;
355
+ GRANT ALL ON TABLE public.plg_conversations_optouts TO service_role;
356
+
357
+ GRANT MAINTAIN ON TABLE public.plg_conversations_payment_requests TO anon;
358
+ GRANT ALL ON TABLE public.plg_conversations_payment_requests TO authenticated;
359
+ GRANT ALL ON TABLE public.plg_conversations_payment_requests TO service_role;
360
+
361
+ GRANT MAINTAIN ON TABLE public.plg_conversations_webhook_events TO anon;
362
+ GRANT ALL ON TABLE public.plg_conversations_webhook_events TO authenticated;
363
+ GRANT ALL ON TABLE public.plg_conversations_webhook_events TO service_role;
364
+
365
+
366
+ -- \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
367
+ -- Put back, so the NEXT thing installed inherits what the chain had.
368
+
369
+ DO $dp$ BEGIN
370
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;
371
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
372
+ 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$;
373
+ END $dp$;
374
+
375
+ DO $dp$ BEGIN
376
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;
377
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
378
+ 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$;
379
+ END $dp$;
380
+
381
+ DO $dp$ BEGIN
382
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;
383
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
384
+ 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$;
385
+ END $dp$;
386
+
387
+ DO $dp$ BEGIN
388
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;
389
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
390
+ 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$;
391
+ END $dp$;
392
+
393
+ DO $dp$ BEGIN
394
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;
395
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
396
+ 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$;
397
+ END $dp$;
398
+
399
+ DO $dp$ BEGIN
400
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;
401
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
402
+ 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$;
403
+ END $dp$;
404
+
405
+ DO $dp$ BEGIN
406
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;
407
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
408
+ 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$;
409
+ END $dp$;
410
+
411
+ DO $dp$ BEGIN
412
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;
413
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
414
+ 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$;
415
+ END $dp$;
416
+
417
+ DO $dp$ BEGIN
418
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;
419
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
420
+ 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$;
421
+ END $dp$;
422
+
423
+ DO $dp$ BEGIN
424
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;
425
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
426
+ 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$;
427
+ END $dp$;
428
+
429
+ DO $dp$ BEGIN
430
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;
431
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
432
+ 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$;
433
+ END $dp$;
434
+
435
+ DO $dp$ BEGIN
436
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;
437
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
438
+ 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$;
439
+ END $dp$;
440
+
441
+ DO $dp$ BEGIN
442
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO anon$stmt$;
443
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
444
+ 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$;
445
+ END $dp$;
446
+
447
+ DO $dp$ BEGIN
448
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;
449
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
450
+ 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$;
451
+ END $dp$;
452
+
453
+ DO $dp$ BEGIN
454
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;
455
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
456
+ 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$;
457
+ END $dp$;
458
+
459
+ DO $dp$ BEGIN
460
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;
461
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
462
+ 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$;
463
+ END $dp$;
464
+
465
+ DO $dp$ BEGIN
466
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT MAINTAIN ON TABLES TO anon$stmt$;
467
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
468
+ 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$;
469
+ END $dp$;
470
+
471
+ DO $dp$ BEGIN
472
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;
473
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
474
+ 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$;
475
+ END $dp$;
476
+
477
+ DO $dp$ BEGIN
478
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;
479
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
480
+ 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$;
481
+ END $dp$;
482
+
483
+ DO $dp$ BEGIN
484
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;
485
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
486
+ 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$;
487
+ END $dp$;
488
+
489
+ DO $dp$ BEGIN
490
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO anon$stmt$;
491
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
492
+ 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$;
493
+ END $dp$;
494
+
495
+ DO $dp$ BEGIN
496
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;
497
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
498
+ 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$;
499
+ END $dp$;
500
+
501
+ DO $dp$ BEGIN
502
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;
503
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
504
+ 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$;
505
+ END $dp$;
506
+
507
+ DO $dp$ BEGIN
508
+ EXECUTE $stmt$--
509
+ -- PostgreSQL database dump complete
510
+ --$stmt$;
511
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
512
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$--
513
+ -- PostgreSQL database dump complete
514
+ --$stmt$;
515
+ END $dp$;
516
+ `;
517
+ var MIGRATION_001_A_CAIXA_SABE_QUEM_FALOU_POR_ULTIMO = `-- ---------------------------------------------------------------------------
518
+ -- 001_a_caixa_sabe_quem_falou_por_ultimo.sql
519
+ --
520
+ -- A conversa guarda o TEXTO e a HORA da \xFAltima mensagem, e n\xE3o guarda de quem
521
+ -- ela foi. Sem isso, "quem est\xE1 esperando resposta" \u2014 que \xE9 a pergunta com que
522
+ -- este produto abre o dia \u2014 n\xE3o \xE9 respond\xEDvel a partir da lista: seria preciso
523
+ -- carregar as mensagens de cada thread para descobrir quem falou por \xFAltimo.
524
+ --
525
+ -- O painel respondia com \`unread_count > 0\`, que \xE9 a aproxima\xE7\xE3o honesta que o
526
+ -- shape permitia e n\xE3o \xE9 a resposta certa. Ela erra exatamente no caso que mais
527
+ -- d\xF3i: a thread que algu\xE9m ABRIU, leu, decidiu responder depois e esqueceu. O
528
+ -- n\xE3o-lida zera na leitura; o cliente continua esperando.
529
+ --
530
+ -- \u2500\u2500 Por que TRIGGER e n\xE3o mais uma coluna que cada escritor preenche \u2500\u2500\u2500\u2500\u2500\u2500\u2500
531
+ --
532
+ -- \`last_message_preview\` e \`last_message_at\` s\xE3o escritos hoje em CINCO lugares:
533
+ -- o provider do navegador (criar conversa, enviar), a fun\xE7\xE3o \`messaging-send\` e
534
+ -- o \`tyxter-webhook\` (duas vezes, contando o handler de pagamento). Uma sexta
535
+ -- coluna com a mesma disciplina \xE9 uma sexta chance de algu\xE9m esquecer \u2014 e o
536
+ -- modo de falhar \xE9 silencioso: a fila simplesmente para de listar algu\xE9m.
537
+ --
538
+ -- O gatilho tem UM escritor e n\xE3o pode ser esquecido pelo pr\xF3ximo chamador. Ele
539
+ -- \xE9 deliberadamente estreito: cuida s\xF3 de \`last_message_direction\`. Trazer o
540
+ -- preview e a hora para c\xE1 seria a corre\xE7\xE3o certa e \xE9 uma mudan\xE7a maior, com
541
+ -- cinco chamadores para reconciliar \u2014 fica anotado, n\xE3o feito aqui.
542
+ -- ---------------------------------------------------------------------------
543
+
544
+ ALTER TABLE public.plg_conversations
545
+ ADD COLUMN IF NOT EXISTS last_message_direction text;
546
+
547
+ DO $$
548
+ BEGIN
549
+ ALTER TABLE public.plg_conversations
550
+ ADD CONSTRAINT plg_conversations_last_message_direction_check
551
+ CHECK (last_message_direction IS NULL
552
+ OR last_message_direction = ANY (ARRAY['inbound'::text, 'outbound'::text]));
553
+ EXCEPTION WHEN duplicate_object THEN
554
+ NULL;
555
+ END $$;
556
+
557
+ COMMENT ON COLUMN public.plg_conversations.last_message_direction IS
558
+ 'Quem falou por \xFAltimo: inbound = o cliente, outbound = a casa (001). Mantida por gatilho em plg_conversation_messages, nunca escrita \xE0 m\xE3o. NULL = thread anterior a esta migration cujo backfill n\xE3o achou mensagem nenhuma.';
559
+
560
+ -- \u2500\u2500 o \xFAnico escritor \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
561
+ --
562
+ -- AFTER INSERT: a mensagem j\xE1 existe quando a conversa \xE9 atualizada, ent\xE3o uma
563
+ -- falha aqui n\xE3o pode desfazer o recebimento \u2014 e recebimento perdido \xE9 pior que
564
+ -- fila desatualizada.
565
+ --
566
+ -- Sem cl\xE1usula de ordem: a mensagem que acabou de entrar \xC9 a \xFAltima. Comparar
567
+ -- com \`last_message_at\` para decidir seria correto e traria uma corrida com os
568
+ -- cinco escritores daquela coluna, que \xE9 justamente o problema que o gatilho
569
+ -- existe para n\xE3o ter.
570
+ CREATE OR REPLACE FUNCTION public.plg_conversations_stamp_direction()
571
+ RETURNS trigger
572
+ LANGUAGE plpgsql
573
+ SECURITY DEFINER
574
+ SET search_path TO ''
575
+ AS $function$
576
+ BEGIN
577
+ IF NEW.direction IS NULL THEN
578
+ RETURN NULL;
579
+ END IF;
580
+
581
+ UPDATE public.plg_conversations
582
+ SET last_message_direction = NEW.direction
583
+ WHERE id = NEW.conversation_id;
584
+
585
+ RETURN NULL;
586
+ END $function$;
587
+
588
+ COMMENT ON FUNCTION public.plg_conversations_stamp_direction() IS
589
+ 'Carimba plg_conversations.last_message_direction a cada mensagem inserida (001). O \xFAnico escritor da coluna.';
590
+
591
+ DROP TRIGGER IF EXISTS plg_conversation_messages_stamp_direction ON public.plg_conversation_messages;
592
+ CREATE TRIGGER plg_conversation_messages_stamp_direction
593
+ AFTER INSERT ON public.plg_conversation_messages
594
+ FOR EACH ROW
595
+ EXECUTE FUNCTION public.plg_conversations_stamp_direction();
596
+
597
+ -- \u2500\u2500 backfill \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
598
+ --
599
+ -- DISTINCT ON pega uma linha por conversa, a mais recente. \`at DESC NULLS LAST\`
600
+ -- e depois \`id DESC\` porque \`at\` tem default e n\xE3o NOT NULL: duas mensagens no
601
+ -- mesmo instante (ou ambas sem hora) precisam de um desempate est\xE1vel, ou o
602
+ -- backfill devolve resultado diferente a cada execu\xE7\xE3o.
603
+ UPDATE public.plg_conversations c
604
+ SET last_message_direction = m.direction
605
+ FROM (
606
+ SELECT DISTINCT ON (conversation_id) conversation_id, direction
607
+ FROM public.plg_conversation_messages
608
+ WHERE direction IS NOT NULL
609
+ ORDER BY conversation_id, at DESC NULLS LAST, id DESC
610
+ ) m
611
+ WHERE m.conversation_id = c.id
612
+ AND c.last_message_direction IS DISTINCT FROM m.direction;
613
+
614
+ -- S\xF3 as threads que interessam \xE0 fila, e s\xF3 quando o cliente falou por \xFAltimo:
615
+ -- \xE9 o \xEDndice que a pergunta "quem est\xE1 esperando" faz, e ele fica pequeno
616
+ -- porque a maioria das conversas de um tenant est\xE1 encerrada.
617
+ CREATE INDEX IF NOT EXISTS idx_plg_conversations_waiting
618
+ ON public.plg_conversations (tenant_id, last_message_at DESC)
619
+ WHERE status = 'open' AND last_message_direction = 'inbound';
620
+ `;
621
+ var MIGRATION_002_A_CASA_ESCOLHE_POR_ONDE_O_WHATSAPP_SAI = `-- ---------------------------------------------------------------------------
622
+ -- 002_a_casa_escolhe_por_onde_o_whatsapp_sai.sql \u2014 dois provedores de WhatsApp,
623
+ -- a credencial de cada um, e a escolha de qual est\xE1 valendo.
624
+ --
625
+ -- \u2500\u2500 por que a credencial precisa morar aqui \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
626
+ --
627
+ -- O \`messaging-send\` (WP3) pega a chave assim:
628
+ --
629
+ -- const credential = await redeemConnectorCredential({ connectorSlug: 'tyxter', \u2026 })
630
+ --
631
+ -- Quinze linhas que chamam a PLATAFORMA Fayz. Funciona para o Tyxter porque a
632
+ -- chave \xE9 uma s\xF3, da FayaLabs, e a plataforma a serve para qualquer inquilino
633
+ -- do projeto. N\xE3o funciona para o Evolution, e n\xE3o vai funcionar nunca: o
634
+ -- Evolution \xE9 auto-hospedado, e cada casa tem a pr\xF3pria URL e a pr\xF3pria chave.
635
+ -- Credencial POR INQUILINO n\xE3o \xE9 atalho, \xE9 requisito do segundo provedor.
636
+ --
637
+ -- Um app que n\xE3o est\xE1 ligado a projeto Fayz nenhum \u2014 como o FullControl hoje \u2014
638
+ -- tamb\xE9m n\xE3o resgata nada. Aqui a chave tem onde ficar nos dois casos.
639
+ --
640
+ -- \u2500\u2500 os dois provedores n\xE3o s\xE3o a mesma coisa, e a tabela diz isso \u2500\u2500\u2500\u2500\u2500\u2500\u2500
641
+ --
642
+ -- \`tyxter\` API oficial (Cloud API, via revenda). Iniciar conversa EXIGE
643
+ -- template aprovado pela Meta; texto livre s\xF3 dentro de 24h da
644
+ -- \xFAltima mensagem do cliente. Em troca: entrega confi\xE1vel, sem
645
+ -- risco de banimento, e a Meta responde por n\xFAmero e qualidade.
646
+ -- \`evolution\` WhatsApp Web, auto-hospedado. N\xE3o tem template nem janela de
647
+ -- 24h \u2014 manda o que quiser. Em troca: a sess\xE3o morre, o n\xFAmero
648
+ -- pode ser banido, e ningu\xE9m responde por isso al\xE9m de voc\xEA.
649
+ --
650
+ -- \`capabilities\` guarda essa diferen\xE7a como DADO e n\xE3o como \`if\` espalhado pela
651
+ -- tela. Um bot\xE3o de "submeter template" num provedor que n\xE3o tem template n\xE3o
652
+ -- pode existir; a tela l\xEA daqui para desabilitar em vez de fingir.
653
+ --
654
+ -- \u2500\u2500 o segredo n\xE3o passa pelo navegador \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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
+ -- A tabela nega SELECT a \`authenticated\`. Quem l\xEA \xE9 o \`service_role\`, do lado
657
+ -- do servidor. A tela sabe se EXISTE credencial e quais s\xE3o os campos n\xE3o
658
+ -- secretos (a URL do Evolution, o nome da inst\xE2ncia) por uma view; o segredo
659
+ -- ela nunca v\xEA, nem para reexibir mascarado.
660
+ -- ---------------------------------------------------------------------------
661
+
662
+ -- \u2500\u2500 quem est\xE1 valendo, por inquilino \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
663
+ CREATE TABLE IF NOT EXISTS public.plg_conversations_providers (
664
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
665
+ provider text NOT NULL,
666
+ -- N\xE3o secreto: a URL base do Evolution, o nome da inst\xE2ncia, o id do n\xFAmero.
667
+ -- \xC9 isto que a tela mostra e deixa editar.
668
+ config jsonb NOT NULL DEFAULT '{}'::jsonb,
669
+ -- Secreto. Nenhuma pol\xEDtica de leitura alcan\xE7a esta coluna.
670
+ secrets jsonb NOT NULL DEFAULT '{}'::jsonb,
671
+ -- O que este provedor SABE fazer. Lido pela tela para desabilitar o que n\xE3o
672
+ -- existe, em vez de oferecer um bot\xE3o que erra.
673
+ capabilities jsonb NOT NULL DEFAULT '{}'::jsonb,
674
+ -- Um s\xF3 por inquilino fica ativo. O outro continua configurado, pronto para
675
+ -- a troca \u2014 que \xE9 o pedido: poder alternar sem reconfigurar.
676
+ is_active boolean NOT NULL DEFAULT false,
677
+ status text NOT NULL DEFAULT 'unconfigured',
678
+ status_detail text,
679
+ checked_at timestamptz,
680
+ created_at timestamptz NOT NULL DEFAULT now(),
681
+ updated_at timestamptz NOT NULL DEFAULT now(),
682
+ PRIMARY KEY (tenant_id, provider),
683
+ CONSTRAINT plg_conversations_providers_provider
684
+ CHECK (provider IN ('tyxter', 'evolution')),
685
+ CONSTRAINT plg_conversations_providers_status
686
+ CHECK (status IN ('unconfigured', 'connecting', 'connected', 'error', 'disconnected'))
687
+ );
688
+
689
+ -- UM ativo por inquilino, garantido pelo banco e n\xE3o pela tela. Duas telas
690
+ -- abertas em abas diferentes s\xE3o o caso comum, e "o \xFAltimo clique vence" s\xF3 \xE9
691
+ -- verdade se algu\xE9m impedir o empate.
692
+ CREATE UNIQUE INDEX IF NOT EXISTS plg_conversations_providers_one_active
693
+ ON public.plg_conversations_providers (tenant_id)
694
+ WHERE is_active;
695
+
696
+ ALTER TABLE public.plg_conversations_providers ENABLE ROW LEVEL SECURITY;
697
+ ALTER TABLE public.plg_conversations_providers FORCE ROW LEVEL SECURITY;
698
+
699
+ -- Nenhuma pol\xEDtica para \`authenticated\`: o segredo mora numa coluna desta
700
+ -- tabela, e uma pol\xEDtica de SELECT aqui entregaria a chave ao navegador.
701
+ --
702
+ -- E o REVOKE expl\xEDcito, porque a aus\xEAncia de pol\xEDtica N\xC3O \xE9 a \xFAnica linha de
703
+ -- defesa. O schema \`public\` deste cluster carrega grants amplos de f\xE1brica \u2014
704
+ -- medido nesta pr\xF3pria tabela logo ap\xF3s cri\xE1-la: sete privil\xE9gios j\xE1
705
+ -- concedidos a \`authenticated\` sem ningu\xE9m ter pedido. A RLS for\xE7ada barra
706
+ -- assim mesmo, mas uma pol\xEDtica escrita errada num dia ruim \xE9 tudo o que
707
+ -- separa a chave do navegador. Duas fechaduras.
708
+ REVOKE ALL ON public.plg_conversations_providers FROM PUBLIC, anon, authenticated;
709
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_conversations_providers TO service_role;
710
+
711
+ -- \u2500\u2500 o que a tela pode ver \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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
+ -- Tudo menos o segredo. \`has_credential\` responde a \xFAnica pergunta que a tela
714
+ -- precisa fazer sobre ele: j\xE1 foi posto?
715
+ -- \u2500\u2500 A \xDANICA VIEW DESTE REPO QUE N\xC3O \xC9 \`security_invoker\` \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
716
+ --
717
+ -- A regra da casa \xE9 \`security_invoker = true\`: a view enxerga o que o leitor
718
+ -- enxerga, e a RLS da tabela continua valendo. Aqui isso se autoderrota.
719
+ --
720
+ -- \`authenticated\` N\xC3O pode ler \`plg_conversations_providers\` \u2014 \xE9 onde o segredo
721
+ -- mora, e o REVOKE logo acima \xE9 deliberado. Uma view invoker sobre uma tabela
722
+ -- que o chamador n\xE3o alcan\xE7a devolve \`42501 permission denied\`, que foi
723
+ -- exatamente o que a tela recebeu no primeiro teste com um JWT de verdade.
724
+ --
725
+ -- Ent\xE3o esta view \xE9 definer POR PROJETO, e paga o pre\xE7o sendo ela mesma a
726
+ -- fronteira: o \`WHERE tenant_id IN (user_tenant_ids())\` abaixo n\xE3o \xE9
727
+ -- decora\xE7\xE3o, \xE9 a RLS desta view. E a coluna do segredo n\xE3o est\xE1 na lista do
728
+ -- SELECT \u2014 o que ela n\xE3o seleciona n\xE3o existe para quem l\xEA.
729
+ DROP VIEW IF EXISTS public.v_conversations_providers;
730
+ CREATE VIEW public.v_conversations_providers AS
731
+ SELECT p.tenant_id,
732
+ p.provider,
733
+ p.config,
734
+ p.capabilities,
735
+ p.is_active,
736
+ p.status,
737
+ p.status_detail,
738
+ p.checked_at,
739
+ -- Nunca o valor. S\xF3 se existe.
740
+ (p.secrets ? 'api_key') AS has_credential,
741
+ p.created_at,
742
+ p.updated_at
743
+ FROM public.plg_conversations_providers p
744
+ WHERE p.tenant_id IN (SELECT public.user_tenant_ids());
745
+
746
+ GRANT SELECT ON public.v_conversations_providers TO authenticated;
747
+
748
+ COMMENT ON VIEW public.v_conversations_providers IS
749
+ 'Os provedores de WhatsApp da casa, SEM o segredo. \`has_credential\` diz se a chave foi posta; o valor n\xE3o sai daqui (002).';
750
+
751
+ -- \u2500\u2500 p\xF4r a credencial \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
752
+ --
753
+ -- SECURITY DEFINER porque a tabela nega escrita a \`authenticated\`: \xE9 a \xFAnica
754
+ -- porta, e ela confere o inquilino antes de abrir. \`search_path\` vazio porque
755
+ -- uma fun\xE7\xE3o definer sem isso \xE9 uma fun\xE7\xE3o que o chamador pode redirecionar.
756
+ CREATE OR REPLACE FUNCTION public.conversations_set_provider(
757
+ p_tenant uuid,
758
+ p_provider text,
759
+ p_config jsonb DEFAULT '{}'::jsonb,
760
+ p_api_key text DEFAULT NULL,
761
+ p_capabilities jsonb DEFAULT NULL
762
+ ) RETURNS void
763
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
764
+ AS $$
765
+ DECLARE v_caps jsonb;
766
+ BEGIN
767
+ IF p_tenant IS NULL OR NOT (p_tenant IN (SELECT public.user_tenant_ids())) THEN
768
+ RAISE EXCEPTION 'conversations_set_provider: n\xE3o \xE9 uma casa sua';
769
+ END IF;
770
+
771
+ -- O que cada provedor sabe fazer \xE9 conhecimento do produto, n\xE3o do usu\xE1rio.
772
+ -- Quem chama pode sobrescrever (um Evolution atr\xE1s de proxy pode ganhar
773
+ -- capacidades), mas o padr\xE3o descreve a verdade de cada um.
774
+ v_caps := coalesce(p_capabilities, CASE p_provider
775
+ WHEN 'tyxter' THEN jsonb_build_object(
776
+ 'templates', true, -- e s\xE3o OBRIGAT\xD3RIOS para iniciar conversa
777
+ 'requires_template', true,
778
+ 'session_window_hours', 24, -- texto livre s\xF3 dentro dela
779
+ 'delivery_receipts', true,
780
+ 'qr_pairing', false,
781
+ 'ban_risk', 'low')
782
+ WHEN 'evolution' THEN jsonb_build_object(
783
+ 'templates', false, -- n\xE3o existem: manda texto direto
784
+ 'requires_template', false,
785
+ 'session_window_hours', null,
786
+ 'delivery_receipts', true,
787
+ 'qr_pairing', true, -- parear \xE9 ler um QR, n\xE3o cadastrar na Meta
788
+ 'ban_risk', 'high') -- e \xE9 por isso que o aquecimento importa
789
+ ELSE '{}'::jsonb END);
790
+
791
+ INSERT INTO public.plg_conversations_providers AS t
792
+ (tenant_id, provider, config, capabilities, status,
793
+ secrets)
794
+ VALUES
795
+ (p_tenant, p_provider, coalesce(p_config, '{}'::jsonb), v_caps,
796
+ CASE WHEN p_api_key IS NULL THEN 'unconfigured' ELSE 'connecting' END,
797
+ CASE WHEN p_api_key IS NULL THEN '{}'::jsonb
798
+ ELSE jsonb_build_object('api_key', p_api_key) END)
799
+ ON CONFLICT (tenant_id, provider) DO UPDATE
800
+ SET config = coalesce(p_config, t.config),
801
+ capabilities = v_caps,
802
+ -- Chave nula \xE9 "n\xE3o mexe", n\xE3o "apaga". Salvar a URL do Evolution sem
803
+ -- redigitar a chave \xE9 o gesto comum, e apag\xE1-la aqui seria a surpresa.
804
+ secrets = CASE WHEN p_api_key IS NULL THEN t.secrets
805
+ ELSE jsonb_set(t.secrets, '{api_key}', to_jsonb(p_api_key)) END,
806
+ status = CASE WHEN p_api_key IS NULL THEN t.status ELSE 'connecting' END,
807
+ updated_at = now();
808
+ END $$;
809
+
810
+ REVOKE ALL ON FUNCTION public.conversations_set_provider(uuid, text, jsonb, text, jsonb) FROM PUBLIC, anon;
811
+ GRANT EXECUTE ON FUNCTION public.conversations_set_provider(uuid, text, jsonb, text, jsonb) TO authenticated, service_role;
812
+
813
+ -- \u2500\u2500 trocar qual est\xE1 valendo \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
814
+ CREATE OR REPLACE FUNCTION public.conversations_activate_provider(
815
+ p_tenant uuid, p_provider text
816
+ ) RETURNS void
817
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
818
+ AS $$
819
+ BEGIN
820
+ IF p_tenant IS NULL OR NOT (p_tenant IN (SELECT public.user_tenant_ids())) THEN
821
+ RAISE EXCEPTION 'conversations_activate_provider: n\xE3o \xE9 uma casa sua';
822
+ END IF;
823
+
824
+ -- Desligar ANTES de ligar: o \xEDndice \xFAnico parcial recusaria os dois ativos,
825
+ -- e numa transa\xE7\xE3o s\xF3 a ordem \xE9 o que decide entre trocar e falhar.
826
+ UPDATE public.plg_conversations_providers
827
+ SET is_active = false, updated_at = now()
828
+ WHERE tenant_id = p_tenant AND is_active;
829
+
830
+ UPDATE public.plg_conversations_providers
831
+ SET is_active = true, updated_at = now()
832
+ WHERE tenant_id = p_tenant AND provider = p_provider;
833
+
834
+ IF NOT FOUND THEN
835
+ RAISE EXCEPTION 'conversations_activate_provider: % n\xE3o est\xE1 configurado nesta casa', p_provider;
836
+ END IF;
837
+ END $$;
838
+
839
+ REVOKE ALL ON FUNCTION public.conversations_activate_provider(uuid, text) FROM PUBLIC, anon;
840
+ GRANT EXECUTE ON FUNCTION public.conversations_activate_provider(uuid, text) TO authenticated, service_role;
841
+
842
+ -- \u2500\u2500 o canal aprende de que provedor ele \xE9 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
843
+ --
844
+ -- \`plg_conversations_channels.provider\` j\xE1 existe e j\xE1 guarda 'tyxter'. O que
845
+ -- falta \xE9 a liga\xE7\xE3o com a credencial: um n\xFAmero do Evolution precisa saber de
846
+ -- QUAL inst\xE2ncia ele saiu, porque a mesma casa pode ter mais de uma.
847
+ ALTER TABLE public.plg_conversations_channels
848
+ ADD COLUMN IF NOT EXISTS instance_key text;
849
+
850
+ COMMENT ON COLUMN public.plg_conversations_channels.instance_key IS
851
+ 'A inst\xE2ncia do provedor de onde este n\xFAmero sai. Nulo no Tyxter (uma conta por projeto); no Evolution \xE9 o nome da inst\xE2ncia (002).';
852
+ `;
853
+ var MIGRATION_003_A_CAMPANHA_SAI_NO_RITMO_QUE_O_NUMERO_AGUENTA = `-- ---------------------------------------------------------------------------
854
+ -- 003_a_campanha_sai_no_ritmo_que_o_numero_aguenta.sql \u2014 template, campanha,
855
+ -- log de envio e o ritmo que impede o n\xFAmero de ser banido.
856
+ --
857
+ -- O desenho n\xE3o \xE9 inven\xE7\xE3o: saiu de ler Mautic, EspoCRM, Odoo e Twenty, e de
858
+ -- ler a documenta\xE7\xE3o da Meta em vez do folclore que circula sobre ela. Onde os
859
+ -- quatro discordam, este arquivo diz qual escolheu e por qu\xEA.
860
+ --
861
+ -- \u2500\u2500 tr\xEAs coisas que a Meta documenta e que viram REGRA, n\xE3o ajuste \u2500\u2500\u2500\u2500\u2500\u2500
862
+ --
863
+ -- \xB7 131050 (o usu\xE1rio saiu do marketing) NUNCA se repete. Reenviar \xE9 outra
864
+ -- infra\xE7\xE3o, n\xE3o outra tentativa.
865
+ -- \xB7 131048 (bloqueado/marcado como spam) \xE9 parada dura. \xC9 o alarme de
866
+ -- qualidade, n\xE3o um erro de rede.
867
+ -- \xB7 A janela de 24h s\xF3 abre por mensagem OU chamada DO CLIENTE. Template
868
+ -- nosso n\xE3o abre janela nenhuma \u2014 e \xE9 o erro que mais se v\xEA em CRM.
869
+ --
870
+ -- O resto \u2014 a rampa de aquecimento, a faixa de atraso entre mensagens \u2014 \xE9
871
+ -- pr\xE1tica de comunidade, n\xE3o documenta\xE7\xE3o. Vira PADR\xC3O EDIT\xC1VEL: quem opera o
872
+ -- n\xFAmero sabe mais do que uma tabela escrita hoje.
873
+ --
874
+ -- \u2500\u2500 por que o log de envio tem \`rotation\` e \`claim_token\` \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
875
+ --
876
+ -- \`rotation\` \xE9 do Mautic: a chave \xFAnica \xE9 (campanha, contato, rota\xE7\xE3o), e a
877
+ -- rota\xE7\xE3o sobe quando algu\xE9m REINICIA a campanha de prop\xF3sito. Sem ela, a \xFAnica
878
+ -- forma de reenviar \xE9 apagar o hist\xF3rico \u2014 e a\xED ningu\xE9m consegue provar o que
879
+ -- foi mandado.
880
+ --
881
+ -- \`claim_token\` + \`claim_expires_at\` \xE9 do Twenty: quem vai enviar ARRENDA a
882
+ -- linha por cinco minutos. Duas inst\xE2ncias do disparador n\xE3o mandam a mesma
883
+ -- mensagem duas vezes, e um processo que morre no meio devolve a linha sozinho
884
+ -- quando o arrendamento vence. Os quatro CRMs resolvem isso com \xEDndice \xFAnico
885
+ -- mais arrendamento; nenhum usa advisory lock, e nenhum usa outbox.
886
+ -- ---------------------------------------------------------------------------
887
+
888
+ -- \u2500\u2500 quem nunca mais deve ser contatado \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
889
+ --
890
+ -- A tabela j\xE1 existe e j\xE1 \xE9 chaveada pelo N\xDAMERO e n\xE3o pelo contato \u2014 que \xE9 o
891
+ -- acerto do Odoo e do Twenty, e o erro do Mautic: n\xFAmero \xE9 reatribu\xEDdo, contato
892
+ -- \xE9 duplicado, e a supress\xE3o precisa sobreviver aos dois.
893
+ --
894
+ -- O que falta \xE9 n\xE3o apagar nunca. O EspoCRM apaga a linha ao reinscrever e
895
+ -- perde a prova de que algu\xE9m um dia pediu para sair; o Odoo desliga um
896
+ -- booleano. Seguimos o Odoo.
897
+ ALTER TABLE public.plg_conversations_optouts
898
+ ADD COLUMN IF NOT EXISTS active boolean NOT NULL DEFAULT true,
899
+ ADD COLUMN IF NOT EXISTS revoked_at timestamptz,
900
+ ADD COLUMN IF NOT EXISTS source text;
901
+
902
+ COMMENT ON COLUMN public.plg_conversations_optouts.active IS
903
+ 'Desligar em vez de apagar: a linha \xE9 a prova de que a pessoa pediu para sair, e apag\xE1-la \xE9 perder a prova (003).';
904
+
905
+ -- \u2500\u2500 o texto que se manda \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
906
+ --
907
+ -- Um template aqui N\xC3O \xE9 um template da Meta. \xC9 o texto da casa, com o espelho
908
+ -- do que o provedor respondeu sobre ele. Assim o mesmo registro serve aos dois:
909
+ -- no Tyxter ele carrega o nome e o status da aprova\xE7\xE3o; no Evolution, que n\xE3o
910
+ -- tem o conceito, ele \xE9 s\xF3 o texto \u2014 e a campanha n\xE3o precisa saber a diferen\xE7a.
911
+ CREATE TABLE IF NOT EXISTS public.plg_conversations_templates (
912
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
913
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
914
+ name text NOT NULL,
915
+ language text NOT NULL DEFAULT 'pt_BR',
916
+ category text NOT NULL DEFAULT 'MARKETING',
917
+ body text NOT NULL,
918
+ -- Os nomes das vari\xE1veis, na ordem em que {{1}}, {{2}} aparecem. A Meta s\xF3
919
+ -- entende posi\xE7\xE3o; gente entende nome. Guardar os dois \xE9 o que deixa a tela
920
+ -- pedir "cargo" em vez de "vari\xE1vel 3".
921
+ variables text[] NOT NULL DEFAULT '{}',
922
+ provider text,
923
+ provider_template_id text,
924
+ -- \`draft\` enquanto \xE9 nosso; depois \xE9 o que o provedor respondeu, verbatim.
925
+ status text NOT NULL DEFAULT 'draft',
926
+ status_detail text,
927
+ submitted_at timestamptz,
928
+ approved_at timestamptz,
929
+ created_at timestamptz NOT NULL DEFAULT now(),
930
+ updated_at timestamptz NOT NULL DEFAULT now()
931
+ );
932
+
933
+ CREATE UNIQUE INDEX IF NOT EXISTS plg_conversations_templates_name_uq
934
+ ON public.plg_conversations_templates (tenant_id, name, language);
935
+
936
+ ALTER TABLE public.plg_conversations_templates ENABLE ROW LEVEL SECURITY;
937
+ ALTER TABLE public.plg_conversations_templates FORCE ROW LEVEL SECURITY;
938
+ DROP POLICY IF EXISTS plg_conversations_templates_rw ON public.plg_conversations_templates;
939
+ CREATE POLICY plg_conversations_templates_rw ON public.plg_conversations_templates
940
+ FOR ALL TO authenticated
941
+ USING (tenant_id IN (SELECT public.user_tenant_ids()))
942
+ WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
943
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_conversations_templates TO authenticated, service_role;
944
+
945
+ -- \u2500\u2500 a campanha \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
946
+ CREATE TABLE IF NOT EXISTS public.plg_conversations_campaigns (
947
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
948
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
949
+ name text NOT NULL,
950
+ template_id uuid REFERENCES public.plg_conversations_templates(id) ON DELETE SET NULL,
951
+ channel_id uuid REFERENCES public.plg_conversations_channels(id) ON DELETE SET NULL,
952
+ provider text,
953
+ status text NOT NULL DEFAULT 'draft',
954
+ -- O ritmo. Nasce do padr\xE3o do provedor e \xE9 edit\xE1vel: a rampa \xE9 pr\xE1tica de
955
+ -- comunidade, e quem opera o n\xFAmero sabe mais que esta tabela.
956
+ pacing jsonb NOT NULL DEFAULT '{}'::jsonb,
957
+ -- Por que parou, quando parou sozinha. Ver \`crm\` nenhum: \xE9 o alarme de
958
+ -- qualidade da Meta chegando na tela de quem disparou.
959
+ paused_reason text,
960
+ started_at timestamptz,
961
+ finished_at timestamptz,
962
+ created_by uuid,
963
+ created_at timestamptz NOT NULL DEFAULT now(),
964
+ updated_at timestamptz NOT NULL DEFAULT now(),
965
+ CONSTRAINT plg_conversations_campaigns_status
966
+ CHECK (status IN ('draft', 'scheduled', 'running', 'paused', 'done', 'cancelled'))
967
+ );
968
+
969
+ ALTER TABLE public.plg_conversations_campaigns ENABLE ROW LEVEL SECURITY;
970
+ ALTER TABLE public.plg_conversations_campaigns FORCE ROW LEVEL SECURITY;
971
+ DROP POLICY IF EXISTS plg_conversations_campaigns_rw ON public.plg_conversations_campaigns;
972
+ CREATE POLICY plg_conversations_campaigns_rw ON public.plg_conversations_campaigns
973
+ FOR ALL TO authenticated
974
+ USING (tenant_id IN (SELECT public.user_tenant_ids()))
975
+ WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
976
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_conversations_campaigns TO authenticated, service_role;
977
+
978
+ -- \u2500\u2500 uma linha por destinat\xE1rio, para sempre \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
979
+ --
980
+ -- \xC9 o log de envio e a fila ao mesmo tempo. Vale para os dois provedores, e \xE9
981
+ -- o \xFAnico lugar onde se pode PROVAR o que foi mandado, para quem, quando, e
982
+ -- por que algu\xE9m N\xC3O foi contatado.
983
+ CREATE TABLE IF NOT EXISTS public.plg_conversations_campaign_targets (
984
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
985
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
986
+ campaign_id uuid NOT NULL REFERENCES public.plg_conversations_campaigns(id) ON DELETE CASCADE,
987
+ -- A pessoa do CRM. \`SET NULL\` e n\xE3o \`CASCADE\`: apagar a pessoa n\xE3o pode
988
+ -- apagar a prova de que uma mensagem foi enviada a ela.
989
+ person_id uuid,
990
+ phone_e164 text NOT NULL,
991
+ -- Ver o cabe\xE7alho: sobe quando algu\xE9m REINICIA a campanha de prop\xF3sito.
992
+ rotation integer NOT NULL DEFAULT 1,
993
+ state text NOT NULL DEFAULT 'queued',
994
+ skip_reason text,
995
+ failure_code text,
996
+ failure_detail text,
997
+ -- O arrendamento. S\xF3 quem segura o token manda.
998
+ claim_token uuid,
999
+ claim_expires_at timestamptz,
1000
+ provider_message_id text,
1001
+ variables jsonb NOT NULL DEFAULT '{}'::jsonb,
1002
+ queued_at timestamptz NOT NULL DEFAULT now(),
1003
+ sent_at timestamptz,
1004
+ delivered_at timestamptz,
1005
+ read_at timestamptz,
1006
+ replied_at timestamptz,
1007
+ failed_at timestamptz,
1008
+ CONSTRAINT plg_conversations_campaign_targets_state
1009
+ CHECK (state IN ('queued', 'sending', 'sent', 'delivered', 'read', 'replied', 'failed', 'skipped')),
1010
+ -- O arrendamento existe se e s\xF3 se est\xE1 enviando. Duas verifica\xE7\xF5es porque
1011
+ -- um token sem prazo \xE9 um token que nunca volta.
1012
+ CONSTRAINT plg_conversations_campaign_targets_lease
1013
+ CHECK (state <> 'sending' OR claim_expires_at IS NOT NULL),
1014
+ CONSTRAINT plg_conversations_campaign_targets_lease_pair
1015
+ CHECK ((claim_token IS NULL) = (claim_expires_at IS NULL))
1016
+ );
1017
+
1018
+ -- Uma vez por campanha, por pessoa, por rodada. \xC9 esta linha que impede o
1019
+ -- disparo duplo \u2014 n\xE3o um \`if\` no c\xF3digo do disparador.
1020
+ CREATE UNIQUE INDEX IF NOT EXISTS plg_conversations_campaign_targets_once
1021
+ ON public.plg_conversations_campaign_targets (campaign_id, phone_e164, rotation);
1022
+
1023
+ -- O webhook do provedor chega mais de uma vez, de prop\xF3sito (o Evolution tenta
1024
+ -- 10 vezes em ~50 min). A idempot\xEAncia do recebimento \xE9 este \xEDndice.
1025
+ CREATE UNIQUE INDEX IF NOT EXISTS plg_conversations_campaign_targets_provider_msg
1026
+ ON public.plg_conversations_campaign_targets (tenant_id, provider_message_id)
1027
+ WHERE provider_message_id IS NOT NULL;
1028
+
1029
+ CREATE INDEX IF NOT EXISTS plg_conversations_campaign_targets_pending
1030
+ ON public.plg_conversations_campaign_targets (campaign_id)
1031
+ WHERE state IN ('queued', 'sending');
1032
+
1033
+ -- O ceifeiro: linhas cujo arrendamento venceu voltam para a fila.
1034
+ CREATE INDEX IF NOT EXISTS plg_conversations_campaign_targets_reaper
1035
+ ON public.plg_conversations_campaign_targets (claim_expires_at)
1036
+ WHERE state = 'sending';
1037
+
1038
+ ALTER TABLE public.plg_conversations_campaign_targets ENABLE ROW LEVEL SECURITY;
1039
+ ALTER TABLE public.plg_conversations_campaign_targets FORCE ROW LEVEL SECURITY;
1040
+ DROP POLICY IF EXISTS plg_conversations_campaign_targets_rw ON public.plg_conversations_campaign_targets;
1041
+ CREATE POLICY plg_conversations_campaign_targets_rw ON public.plg_conversations_campaign_targets
1042
+ FOR ALL TO authenticated
1043
+ USING (tenant_id IN (SELECT public.user_tenant_ids()))
1044
+ WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
1045
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_conversations_campaign_targets TO authenticated, service_role;
1046
+
1047
+ -- \u2500\u2500 o ritmo que o n\xFAmero aguenta \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1048
+ --
1049
+ -- Mora no canal e n\xE3o na campanha: o limite \xE9 do N\xDAMERO, e duas campanhas no
1050
+ -- mesmo n\xFAmero somam. Guardar na campanha deixaria cada uma achar que tem a
1051
+ -- cota inteira.
1052
+ ALTER TABLE public.plg_conversations_channels
1053
+ ADD COLUMN IF NOT EXISTS pacing jsonb NOT NULL DEFAULT '{}'::jsonb,
1054
+ ADD COLUMN IF NOT EXISTS warmup_day integer,
1055
+ ADD COLUMN IF NOT EXISTS warmup_started_at timestamptz,
1056
+ ADD COLUMN IF NOT EXISTS quality text,
1057
+ ADD COLUMN IF NOT EXISTS quality_at timestamptz;
1058
+
1059
+ COMMENT ON COLUMN public.plg_conversations_channels.pacing IS
1060
+ 'Ritmo deste n\xFAmero: por minuto, por hora, por dia, faixa de atraso e janela de hor\xE1rio. Padr\xE3o por provedor, edit\xE1vel \u2014 a rampa \xE9 pr\xE1tica de comunidade, n\xE3o documenta\xE7\xE3o da Meta (003).';
1061
+
1062
+ -- Os padr\xF5es, por provedor. N\xFAmeros da pesquisa; o coment\xE1rio diz a
1063
+ -- proced\xEAncia de cada um, porque "por que 20?" \xE9 a pergunta que sempre volta.
1064
+ CREATE OR REPLACE FUNCTION public.conversations_default_pacing(p_provider text)
1065
+ RETURNS jsonb LANGUAGE sql IMMUTABLE AS $$
1066
+ SELECT CASE p_provider
1067
+ -- Oficial. O teto real \xE9 o tier da Meta (250/2.000/10.000/100.000), que a
1068
+ -- pr\xF3pria API informa por n\xFAmero \u2014 ent\xE3o aqui s\xF3 entra o que a Meta N\xC3O
1069
+ -- controla: o ritmo instant\xE2neo. 1 msg / 6 s para o MESMO destinat\xE1rio \xE9
1070
+ -- documentado; 60/min \xE9 75% do teto de 80/s por seguran\xE7a.
1071
+ WHEN 'tyxter' THEN jsonb_build_object(
1072
+ 'per_minute', 60, 'per_hour', 1000, 'per_day', null,
1073
+ 'delay_seconds', jsonb_build_array(1, 3),
1074
+ 'same_recipient_seconds', 6,
1075
+ 'hours', jsonb_build_object('start', '09:00', 'end', '18:00', 'weekdays_only', true))
1076
+ -- N\xE3o oficial. Aqui n\xE3o h\xE1 teto do provedor \u2014 h\xE1 banimento. Os n\xFAmeros s\xE3o
1077
+ -- a moda de treze rampas brasileiras publicadas: 10-30/dia na primeira
1078
+ -- semana, 200-300/dia no regime. Atraso ALEAT\xD3RIO entre 15 e 45 s porque
1079
+ -- intervalo fixo \xE9 assinatura de rob\xF4.
1080
+ WHEN 'evolution' THEN jsonb_build_object(
1081
+ 'per_minute', 12, 'per_hour', 300, 'per_day', 250,
1082
+ 'delay_seconds', jsonb_build_array(15, 45),
1083
+ 'same_recipient_seconds', 6,
1084
+ 'batch_size', 50, 'batch_cooldown_minutes', 12,
1085
+ 'hours', jsonb_build_object('start', '09:00', 'end', '18:00', 'weekdays_only', true))
1086
+ ELSE '{}'::jsonb END
1087
+ $$;
1088
+
1089
+ COMMENT ON FUNCTION public.conversations_default_pacing(text) IS
1090
+ 'O ritmo inicial de cada provedor. Tyxter: o teto \xE9 da Meta, aqui s\xF3 o ritmo instant\xE2neo. Evolution: n\xE3o h\xE1 teto, h\xE1 banimento (003).';
1091
+
1092
+ -- A rampa de aquecimento, em dias. Pr\xE1tica de comunidade, edit\xE1vel \u2014 e a
1093
+ -- fun\xE7\xE3o existe para que o padr\xE3o seja UM lugar e n\xE3o uma constante copiada.
1094
+ CREATE OR REPLACE FUNCTION public.conversations_warmup_allowance(p_day integer)
1095
+ RETURNS integer LANGUAGE sql IMMUTABLE AS $$
1096
+ SELECT CASE
1097
+ WHEN p_day IS NULL THEN NULL -- n\xFAmero sem aquecimento declarado: sem teto extra
1098
+ WHEN p_day <= 3 THEN 20
1099
+ WHEN p_day <= 7 THEN 50
1100
+ WHEN p_day <= 14 THEN 100
1101
+ WHEN p_day <= 21 THEN 200
1102
+ ELSE 300
1103
+ END
1104
+ $$;
1105
+
1106
+ COMMENT ON FUNCTION public.conversations_warmup_allowance(integer) IS
1107
+ 'Quantos destinat\xE1rios NOVOS por dia, por dia de aquecimento. Moda de treze rampas brasileiras publicadas \u2014 comunidade, n\xE3o Meta. Padr\xE3o edit\xE1vel (003).';
1108
+
1109
+ -- \u2500\u2500 o que a tela do disparo precisa ver \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1110
+ DROP VIEW IF EXISTS public.v_conversations_campaign_progress;
1111
+ CREATE VIEW public.v_conversations_campaign_progress
1112
+ WITH (security_invoker = true) AS
1113
+ SELECT c.id AS campaign_id,
1114
+ c.tenant_id,
1115
+ c.name,
1116
+ c.status,
1117
+ c.paused_reason,
1118
+ c.provider,
1119
+ count(t.id) AS total,
1120
+ count(*) FILTER (WHERE t.state = 'queued') AS queued,
1121
+ count(*) FILTER (WHERE t.state = 'sending') AS sending,
1122
+ count(*) FILTER (WHERE t.state IN ('sent','delivered','read','replied')) AS sent,
1123
+ count(*) FILTER (WHERE t.state = 'delivered') AS delivered,
1124
+ count(*) FILTER (WHERE t.state = 'read') AS read,
1125
+ count(*) FILTER (WHERE t.state = 'replied') AS replied,
1126
+ count(*) FILTER (WHERE t.state = 'failed') AS failed,
1127
+ count(*) FILTER (WHERE t.state = 'skipped') AS skipped,
1128
+ -- A taxa de resposta \xE9 o sinal que decide se o n\xFAmero sobrevive: a
1129
+ -- pr\xE1tica diz que abaixo de 30% o risco de banimento sobe. Fica na
1130
+ -- view porque \xE9 o n\xFAmero que precisa estar na tela, n\xE3o num relat\xF3rio.
1131
+ CASE WHEN count(*) FILTER (WHERE t.state IN ('sent','delivered','read','replied')) > 0
1132
+ THEN round(100.0 * count(*) FILTER (WHERE t.state = 'replied')
1133
+ / count(*) FILTER (WHERE t.state IN ('sent','delivered','read','replied')), 1)
1134
+ END AS reply_rate,
1135
+ min(t.sent_at) AS first_sent_at,
1136
+ max(t.sent_at) AS last_sent_at
1137
+ FROM public.plg_conversations_campaigns c
1138
+ LEFT JOIN public.plg_conversations_campaign_targets t ON t.campaign_id = c.id
1139
+ WHERE c.tenant_id IN (SELECT public.user_tenant_ids())
1140
+ GROUP BY c.id, c.tenant_id, c.name, c.status, c.paused_reason, c.provider;
1141
+
1142
+ GRANT SELECT ON public.v_conversations_campaign_progress TO authenticated;
1143
+
1144
+ -- \u2500\u2500 arrendar o pr\xF3ximo lote \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1145
+ --
1146
+ -- \`FOR UPDATE SKIP LOCKED\` \xE9 o que deixa dois disparadores trabalharem a mesma
1147
+ -- campanha sem pisar um no outro, e sem advisory lock. O arrendamento de cinco
1148
+ -- minutos \xE9 o que devolve a linha quando um deles morre no meio.
1149
+ --
1150
+ -- A supress\xE3o \xE9 conferida AQUI, na mesma transa\xE7\xE3o que arrenda. Conferir antes
1151
+ -- e mandar depois deixa uma janela em que algu\xE9m pede para sair e recebe assim
1152
+ -- mesmo \u2014 e \xE9 justamente essa mensagem que vira den\xFAncia.
1153
+ CREATE OR REPLACE FUNCTION public.conversations_claim_targets(
1154
+ p_campaign uuid, p_limit integer DEFAULT 10
1155
+ ) RETURNS TABLE (
1156
+ id uuid, phone_e164 text, person_id uuid, variables jsonb, claim_token uuid
1157
+ )
1158
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
1159
+ AS $$
1160
+ DECLARE v_token uuid := gen_random_uuid(); v_tenant uuid;
1161
+ BEGIN
1162
+ SELECT c.tenant_id INTO v_tenant
1163
+ FROM public.plg_conversations_campaigns c WHERE c.id = p_campaign;
1164
+ IF v_tenant IS NULL THEN RAISE EXCEPTION 'campanha n\xE3o encontrada'; END IF;
1165
+
1166
+ -- TR\xCAS COMANDOS, E N\xC3O UM. A primeira vers\xE3o fazia o ceifeiro, a supress\xE3o e
1167
+ -- o arrendamento em CTEs do mesmo UPDATE, e isso \xE9 uma armadilha: dentro de
1168
+ -- um comando s\xF3, todas as CTEs enxergam o MESMO instant\xE2neo da tabela, e uma
1169
+ -- linha atualizada por duas delas recebe s\xF3 a primeira \u2014 em sil\xEAncio. O
1170
+ -- suprimido vinha marcado E arrendado, e qual dos dois vencia dependia do
1171
+ -- plano que o Postgres escolhesse naquele dia.
1172
+ --
1173
+ -- Separado, cada passo enxerga o resultado do anterior. \xC9 mais lento por um
1174
+ -- par de milissegundos e \xE9 a diferen\xE7a entre uma regra e uma coincid\xEAncia.
1175
+
1176
+ -- 1. O ceifeiro. Quem venceu o arrendamento volta para a fila, sen\xE3o uma
1177
+ -- campanha inteira fica presa num processo que morreu.
1178
+ UPDATE public.plg_conversations_campaign_targets t
1179
+ SET state = 'queued', claim_token = NULL, claim_expires_at = NULL
1180
+ WHERE t.campaign_id = p_campaign AND t.state = 'sending'
1181
+ AND t.claim_expires_at < now();
1182
+
1183
+ -- 2. Quem pediu para sair NUNCA entra num lote. A linha fica marcada em vez
1184
+ -- de sumir: \xE9 assim que se prova que a decis\xE3o foi deliberada.
1185
+ UPDATE public.plg_conversations_campaign_targets t
1186
+ SET state = 'skipped', skip_reason = 'suppressed'
1187
+ WHERE t.campaign_id = p_campaign AND t.state = 'queued'
1188
+ AND EXISTS (SELECT 1 FROM public.plg_conversations_optouts o
1189
+ WHERE o.tenant_id = t.tenant_id
1190
+ AND o.phone_e164 = t.phone_e164
1191
+ AND o.active);
1192
+
1193
+ -- 3. S\xF3 ent\xE3o arrenda. \`SKIP LOCKED\` deixa dois disparadores trabalharem a
1194
+ -- mesma campanha sem pisar um no outro, e sem advisory lock.
1195
+ RETURN QUERY
1196
+ WITH escolhidos AS (
1197
+ SELECT t.id FROM public.plg_conversations_campaign_targets t
1198
+ WHERE t.campaign_id = p_campaign AND t.state = 'queued'
1199
+ ORDER BY t.queued_at
1200
+ LIMIT greatest(1, least(coalesce(p_limit, 10), 100))
1201
+ FOR UPDATE SKIP LOCKED
1202
+ )
1203
+ UPDATE public.plg_conversations_campaign_targets t
1204
+ SET state = 'sending', claim_token = v_token,
1205
+ claim_expires_at = now() + interval '5 minutes'
1206
+ FROM escolhidos e
1207
+ WHERE t.id = e.id
1208
+ RETURNING t.id, t.phone_e164, t.person_id, t.variables, t.claim_token;
1209
+ END $$;
1210
+
1211
+ REVOKE ALL ON FUNCTION public.conversations_claim_targets(uuid, integer) FROM PUBLIC, anon;
1212
+ GRANT EXECUTE ON FUNCTION public.conversations_claim_targets(uuid, integer) TO service_role;
1213
+ `;
1214
+ var MIGRATION_004_A_TELA_SABE_SE_PODE_FALAR_ANTES_DE_DEIXAR_ESCREVER = `-- ---------------------------------------------------------------------------
1215
+ -- 004_a_tela_sabe_se_pode_falar_antes_de_deixar_escrever.sql \u2014 a janela de 24h,
1216
+ -- como DADO, para a tela responder antes de algu\xE9m digitar.
1217
+ --
1218
+ -- \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
1219
+ --
1220
+ -- Na API oficial do WhatsApp, texto livre s\xF3 sai dentro de 24h contadas da
1221
+ -- \xFAltima mensagem DO CLIENTE. Fora dela a Meta recusa, e a recusa chega como um
1222
+ -- c\xF3digo no log \u2014 n\xE3o na tela de quem escreveu. O atendente digita, aperta
1223
+ -- enviar, v\xEA a mensagem aparecer na conversa, e ela nunca chega.
1224
+ --
1225
+ -- Isso \xE9 pior que um erro: \xE9 uma tela que mente. E some inteiro num provedor
1226
+ -- n\xE3o oficial, onde n\xE3o existe janela nenhuma \u2014 ent\xE3o o MESMO campo de texto
1227
+ -- funciona ou falha calado dependendo de qual provedor est\xE1 ativo.
1228
+ --
1229
+ -- A corre\xE7\xE3o n\xE3o \xE9 um aviso depois. \xC9 a tela saber ANTES: janela aberta, texto
1230
+ -- livre; janela fechada, s\xF3 template, e o campo diz isso em vez de aceitar.
1231
+ --
1232
+ -- \u2500\u2500 quem abre a janela \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1233
+ --
1234
+ -- O CLIENTE, e s\xF3 ele. Mensagem nossa n\xE3o abre nada \u2014 nem template aprovado.
1235
+ -- \xC9 o erro mais comum de quem implementa isso, e \xE9 por isso que o c\xE1lculo
1236
+ -- abaixo olha exclusivamente \`direction = 'inbound'\`.
1237
+ --
1238
+ -- (Origem: developers.facebook.com/documentation/business-messaging/whatsapp \u2014
1239
+ -- "The user messages you about the product. This opens a 24 hour customer
1240
+ -- service window." Cliques em an\xFAncio Click-to-WhatsApp abrem 72h; n\xE3o
1241
+ -- distinguimos ainda, e 24h \xE9 o limite conservador dos dois.)
1242
+ -- ---------------------------------------------------------------------------
1243
+
1244
+ DROP VIEW IF EXISTS public.v_conversations_window;
1245
+ CREATE VIEW public.v_conversations_window
1246
+ WITH (security_invoker = true) AS
1247
+ SELECT c.id AS conversation_id,
1248
+ c.tenant_id,
1249
+ c.channel,
1250
+ c.contact_handle,
1251
+ c.contact_person_id,
1252
+ u.last_inbound_at,
1253
+ -- Quando fecha. Nulo = nunca abriu, que \xE9 o caso de todo lead que
1254
+ -- ainda n\xE3o respondeu \u2014 a maioria de uma lista de feira.
1255
+ (u.last_inbound_at + interval '24 hours') AS window_expires_at,
1256
+ (u.last_inbound_at IS NOT NULL
1257
+ AND u.last_inbound_at > now() - interval '24 hours') AS window_open,
1258
+ -- Quanto falta, em minutos. A tela mostra "faltam 3h" e n\xE3o um
1259
+ -- carimbo de data: quem est\xE1 respondendo quer saber se d\xE1 tempo.
1260
+ CASE WHEN u.last_inbound_at IS NOT NULL
1261
+ THEN greatest(0, floor(extract(epoch FROM
1262
+ (u.last_inbound_at + interval '24 hours') - now()) / 60))::int
1263
+ END AS minutes_left,
1264
+ -- Quem pediu para n\xE3o ser mais contatado. Some da tela antes de
1265
+ -- qualquer janela: n\xE3o importa se est\xE1 aberta.
1266
+ EXISTS (SELECT 1 FROM public.plg_conversations_optouts o
1267
+ WHERE o.tenant_id = c.tenant_id
1268
+ AND o.phone_e164 = regexp_replace(coalesce(c.contact_handle,''), '\\D', '', 'g')
1269
+ AND o.active) AS opted_out
1270
+ FROM public.plg_conversations c
1271
+ LEFT JOIN LATERAL (
1272
+ SELECT max(m.at) AS last_inbound_at
1273
+ FROM public.plg_conversation_messages m
1274
+ WHERE m.conversation_id = c.id
1275
+ AND m.direction = 'inbound'
1276
+ ) u ON true
1277
+ WHERE c.tenant_id IN (SELECT public.user_tenant_ids());
1278
+
1279
+ GRANT SELECT ON public.v_conversations_window TO authenticated;
1280
+
1281
+ COMMENT ON VIEW public.v_conversations_window IS
1282
+ 'Se d\xE1 para mandar texto livre para esta conversa agora. A janela de 24h s\xF3 abre por mensagem DO CLIENTE \u2014 template nosso n\xE3o abre nada (004).';
1283
+
1284
+ -- O \xEDndice que a LATERAL usa. Sem ele, cada linha da caixa varre as mensagens
1285
+ -- da conversa inteira, e a caixa \xE9 justamente a tela que abre o dia todo.
1286
+ CREATE INDEX IF NOT EXISTS plg_conversation_messages_inbound_at
1287
+ ON public.plg_conversation_messages (conversation_id, at DESC)
1288
+ WHERE direction = 'inbound';
1289
+ `;
1290
+ var MIGRATION_005_A_CONVERSA_CONTA_AO_FUNIL_QUE_HOUVE_CONTATO = `-- ---------------------------------------------------------------------------
1291
+ -- 005_a_conversa_conta_ao_funil_que_houve_contato.sql \u2014 a ponte que faltava.
1292
+ --
1293
+ -- \u2500\u2500 o buraco, medido \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1294
+ --
1295
+ -- No cluster: 8 conversas, 42 mensagens trocadas \u2014 incluindo respostas de
1296
+ -- gente de verdade \u2014 e 315 neg\xF3cios TODOS parados em "Novo (feira)", com
1297
+ -- \`contact_status = 'not_contacted'\`. Zero atividades vindas da caixa.
1298
+ --
1299
+ -- N\xE3o era defeito: n\xE3o existia liga\xE7\xE3o nenhuma. Nenhum \`INSERT\` em
1300
+ -- \`plg_crm_activities\` fora do plugin-crm, nenhuma fonte de linha do tempo
1301
+ -- registrada. A caixa de entrada e o funil olhavam para as mesmas pessoas e
1302
+ -- n\xE3o se falavam.
1303
+ --
1304
+ -- O maquin\xE1rio do CRM j\xE1 estava pronto e correto: \`crm_recompute_deal_status\`
1305
+ -- (crm/020) l\xEA as atividades, pega o \xDALTIMO \`outcome\` e deriva o status; o
1306
+ -- gatilho \`crm_activity_restatus\` dispara a cada atividade inserida. Faltava
1307
+ -- algu\xE9m inserir.
1308
+ --
1309
+ -- \u2500\u2500 por que o \`outcome\` \xE9 o que \xE9 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1310
+ --
1311
+ -- mensagem RECEBIDA \u2192 \`replied\` o lead respondeu. \xC9 o fato mais forte
1312
+ -- que a caixa produz, e o que mais move
1313
+ -- o status ("Respondeu").
1314
+ -- mensagem ENVIADA \u2192 \`left_message\` deixamos recado. Mandar N\xC3O \xE9 ser
1315
+ -- lido: tratar envio como contato feito
1316
+ -- encheria o funil de "conectado" sem
1317
+ -- ningu\xE9m do outro lado. A 020 mapeia
1318
+ -- este valor para "N\xE3o atendeu", que \xE9
1319
+ -- literalmente o estado \u2014 ainda sem
1320
+ -- resposta.
1321
+ --
1322
+ -- O vocabul\xE1rio \xE9 FECHADO por CHECK (crm/020:49): \`connected, no_answer,
1323
+ -- left_message, replied, scheduled\`. Conferido antes de escrever \u2014 a primeira
1324
+ -- vers\xE3o desta migration usava \`attempted\`, que a regra da 020 nomeia mas a
1325
+ -- restri\xE7\xE3o da coluna N\xC3O aceita, e teria falhado em toda mensagem enviada.
1326
+ --
1327
+ -- \u2500\u2500 e o \`deal_id\` n\xE3o \xE9 opcional \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1328
+ --
1329
+ -- \`crm_recompute_deal_status\` agrega \`WHERE a.deal_id = p_deal_id\`. Uma
1330
+ -- atividade s\xF3 com \`lead_id\` n\xE3o entra na conta e n\xE3o move status nenhum \u2014
1331
+ -- por isso o gatilho procura o neg\xF3cio do lead e o carimba.
1332
+ --
1333
+ -- \u2500\u2500 uma atividade por CONVERSA E POR DIA, n\xE3o por mensagem \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1334
+ --
1335
+ -- Uma conversa de trinta mensagens n\xE3o s\xE3o trinta contatos: \xE9 um. Registrar
1336
+ -- cada mensagem encheria a linha do tempo de ru\xEDdo e afogaria o que importa \u2014
1337
+ -- a liga\xE7\xE3o, a visita, a proposta. Ent\xE3o a primeira mensagem do dia abre a
1338
+ -- atividade e as seguintes ATUALIZAM a mesma linha (o corpo mais recente, e o
1339
+ -- \`outcome\` mais forte do dia).
1340
+ --
1341
+ -- Atualizar tamb\xE9m dispara o \`crm_activity_restatus\` (o gatilho \xE9 INSERT OR
1342
+ -- UPDATE), ent\xE3o uma resposta que chega depois de um envio move o status no
1343
+ -- mesmo dia, que \xE9 o comportamento que se quer.
1344
+ --
1345
+ -- \u2500\u2500 e se o contato n\xE3o for um lead \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1346
+ --
1347
+ -- \`contact_person_id\` \xE9 nulo enquanto ningu\xE9m casou o telefone com uma pessoa.
1348
+ -- A\xED n\xE3o h\xE1 a quem pendurar o fato e a fun\xE7\xE3o sai calada. N\xE3o inventa lead: a
1349
+ -- caixa recebe mensagem de entregador, de banco e de engano, e cada um deles
1350
+ -- viraria um card no funil de algu\xE9m.
1351
+ -- ---------------------------------------------------------------------------
1352
+
1353
+ CREATE OR REPLACE FUNCTION public.conversations_log_crm_activity() RETURNS trigger
1354
+ LANGUAGE plpgsql SECURITY DEFINER
1355
+ SET search_path TO ''
1356
+ AS $$
1357
+ DECLARE
1358
+ v_tenant uuid;
1359
+ v_person uuid;
1360
+ v_canal text;
1361
+ v_deal uuid;
1362
+ v_outcome text;
1363
+ v_titulo text;
1364
+ v_corpo text;
1365
+ v_dia date := (NEW.at AT TIME ZONE 'UTC')::date;
1366
+ v_ativ uuid;
1367
+ BEGIN
1368
+ -- Sem CRM montado n\xE3o h\xE1 o que escrever. \`to_regclass\` em vez de refer\xEAncia
1369
+ -- direta porque conversas \xE9 plugin OPCIONAL e pode viver num cluster que n\xE3o
1370
+ -- tem funil nenhum.
1371
+ IF to_regclass('public.plg_crm_activities') IS NULL THEN RETURN NULL; END IF;
1372
+
1373
+ SELECT c.tenant_id, c.contact_person_id, c.channel
1374
+ INTO v_tenant, v_person, v_canal
1375
+ FROM public.plg_conversations c WHERE c.id = NEW.conversation_id;
1376
+
1377
+ IF v_person IS NULL THEN RETURN NULL; END IF;
1378
+
1379
+ SELECT d.id INTO v_deal
1380
+ FROM public.plg_crm_deals d
1381
+ WHERE d.tenant_id = v_tenant AND d.lead_id = v_person
1382
+ ORDER BY d.created_at DESC LIMIT 1;
1383
+
1384
+ IF NEW.direction = 'inbound' THEN
1385
+ v_outcome := 'replied';
1386
+ v_titulo := 'Respondeu no ' || coalesce(v_canal, 'canal');
1387
+ ELSE
1388
+ v_outcome := 'left_message';
1389
+ v_titulo := 'Mensagem enviada no ' || coalesce(v_canal, 'canal');
1390
+ END IF;
1391
+
1392
+ v_corpo := left(coalesce(NEW.body, ''), 2000);
1393
+
1394
+ -- A atividade do dia, se j\xE1 houver.
1395
+ SELECT a.id INTO v_ativ
1396
+ FROM public.plg_crm_activities a
1397
+ WHERE a.tenant_id = v_tenant
1398
+ AND a.lead_id = v_person
1399
+ AND a.activity_type = 'whatsapp'
1400
+ AND (a.completed_at AT TIME ZONE 'UTC')::date = v_dia
1401
+ ORDER BY a.completed_at DESC LIMIT 1;
1402
+
1403
+ IF v_ativ IS NULL THEN
1404
+ INSERT INTO public.plg_crm_activities (
1405
+ tenant_id, deal_id, lead_id, contact_id, activity_type,
1406
+ title, description, outcome, completed_at, created_by_name
1407
+ ) VALUES (
1408
+ v_tenant, v_deal, v_person, v_person, 'whatsapp',
1409
+ v_titulo, v_corpo, v_outcome, NEW.at,
1410
+ -- Quem fez foi a caixa, n\xE3o uma pessoa: o autor em uuid seria mentira,
1411
+ -- e "(sistema)" \xE9 a resposta honesta para quem l\xEA a linha do tempo.
1412
+ 'Caixa de entrada'
1413
+ );
1414
+ ELSE
1415
+ UPDATE public.plg_crm_activities a SET
1416
+ -- \`replied\` NUNCA \xE9 rebaixado por um envio posterior: o lead ter
1417
+ -- respondido \xE9 o fato do dia, e mandar outra mensagem depois n\xE3o o
1418
+ -- desfaz.
1419
+ outcome = CASE WHEN a.outcome = 'replied' THEN 'replied' ELSE v_outcome END,
1420
+ title = CASE WHEN a.outcome = 'replied' THEN a.title ELSE v_titulo END,
1421
+ description = v_corpo,
1422
+ completed_at = NEW.at,
1423
+ deal_id = coalesce(a.deal_id, v_deal)
1424
+ WHERE a.id = v_ativ;
1425
+ END IF;
1426
+
1427
+ RETURN NULL;
1428
+ END $$;
1429
+
1430
+ REVOKE ALL ON FUNCTION public.conversations_log_crm_activity() FROM PUBLIC;
1431
+ GRANT EXECUTE ON FUNCTION public.conversations_log_crm_activity() TO authenticated, service_role;
1432
+
1433
+ COMMENT ON FUNCTION public.conversations_log_crm_activity() IS
1434
+ 'Uma mensagem vira contato na linha do tempo do lead e do neg\xF3cio, e o status do card deriva disso pelo gatilho da crm/020. Uma atividade por conversa por dia (005).';
1435
+
1436
+ -- AFTER: falhar ao registrar no CRM n\xE3o pode recusar uma mensagem que j\xE1
1437
+ -- chegou do provedor. A caixa \xE9 a fonte; o funil \xE9 quem escuta.
1438
+ DROP TRIGGER IF EXISTS conversations_log_crm_activity ON public.plg_conversation_messages;
1439
+ CREATE TRIGGER conversations_log_crm_activity
1440
+ AFTER INSERT ON public.plg_conversation_messages
1441
+ FOR EACH ROW EXECUTE FUNCTION public.conversations_log_crm_activity();
1442
+
1443
+ -- O \xEDndice que a busca da atividade do dia usa. Sem ele, cada mensagem varre
1444
+ -- as atividades do inquilino inteiro \u2014 e mensagem \xE9 o que mais chega aqui.
1445
+ DO $$
1446
+ BEGIN
1447
+ -- Guarda pela mesma raz\xE3o da fun\xE7\xE3o: conversas \xE9 opcional e pode viver sem
1448
+ -- CRM. Um \xEDndice sobre tabela que n\xE3o existe recusa o arquivo inteiro.
1449
+ IF to_regclass('public.plg_crm_activities') IS NULL THEN RETURN; END IF;
1450
+ CREATE INDEX IF NOT EXISTS plg_crm_activities_lead_day
1451
+ ON public.plg_crm_activities (tenant_id, lead_id, activity_type, completed_at DESC);
1452
+ END $$;
1453
+ `;
1454
+ var MIGRATION_006_A_CAIXA_AVISA_QUE_CHEGOU = `-- ---------------------------------------------------------------------------
1455
+ -- 006_a_caixa_avisa_que_chegou.sql
1456
+ --
1457
+ -- \u2500\u2500 o sil\xEAncio, e de onde ele vinha \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1458
+ --
1459
+ -- A caixa recebia mensagens e ningu\xE9m era avisado. Sem som, sem contagem no
1460
+ -- menu, sem destaque na lista \u2014 e a suspeita natural \xE9 que falta a tela que
1461
+ -- avisa. N\xE3o faltava: o evento nunca sa\xEDa do banco.
1462
+ --
1463
+ -- \`plg_conversation_messages\` e \`plg_conversations\` n\xE3o estavam em
1464
+ -- \`supabase_realtime\`. A publica\xE7\xE3o existe (\`puballtables = false\`, ou seja,
1465
+ -- cada tabela entra nomeada) e nenhuma das duas fora nomeada. Qualquer cliente
1466
+ -- que assinasse \`postgres_changes\` sobre elas ficaria subscrito para sempre a
1467
+ -- um canal por onde nada passa \u2014 e subscrever com sucesso a um canal mudo \xE9 o
1468
+ -- modo de falhar mais convincente que h\xE1: o c\xF3digo parece certo, o console
1469
+ -- fica limpo, e nada acontece.
1470
+ --
1471
+ -- \u2500\u2500 as duas, e n\xE3o s\xF3 as mensagens \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1472
+ --
1473
+ -- A mensagem \xE9 o que dispara o alerta; a CONVERSA \xE9 o que carrega o estado que
1474
+ -- a lista pinta (\`status\`, \`unread_count\`, \`last_message_direction\`). Publicar
1475
+ -- s\xF3 a primeira daria o som sem o badge parar de mentir depois de algu\xE9m ler.
1476
+ --
1477
+ -- \u2500\u2500 o RLS continua a valer \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1478
+ --
1479
+ -- Publicar n\xE3o abre nada: o Realtime do Supabase aplica as pol\xEDticas da tabela
1480
+ -- por assinante, e quem n\xE3o pode ler a linha n\xE3o a recebe. O que a publica\xE7\xE3o
1481
+ -- decide \xE9 se o evento EXISTE, n\xE3o quem o v\xEA.
1482
+ --
1483
+ -- \u2500\u2500 idempotente, e silenciosa onde n\xE3o se aplica \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1484
+ --
1485
+ -- \`ALTER PUBLICATION ... ADD TABLE\` rebenta se a tabela j\xE1 l\xE1 est\xE1, e a
1486
+ -- publica\xE7\xE3o \`supabase_realtime\` s\xF3 existe num Postgres da Supabase. As duas
1487
+ -- condi\xE7\xF5es s\xE3o verificadas antes, porque esta migration tamb\xE9m roda no banco
1488
+ -- de teste, que n\xE3o tem nenhuma delas.
1489
+ -- ---------------------------------------------------------------------------
1490
+
1491
+ DO $$
1492
+ DECLARE
1493
+ t text;
1494
+ v_n integer := 0;
1495
+ BEGIN
1496
+ IF NOT EXISTS (SELECT 1 FROM pg_publication WHERE pubname = 'supabase_realtime') THEN
1497
+ RAISE NOTICE 'conversations/006: sem publica\xE7\xE3o supabase_realtime \u2014 nada a fazer';
1498
+ RETURN;
1499
+ END IF;
1500
+
1501
+ FOREACH t IN ARRAY ARRAY['plg_conversation_messages', 'plg_conversations'] LOOP
1502
+ CONTINUE WHEN to_regclass('public.' || t) IS NULL;
1503
+ CONTINUE WHEN EXISTS (
1504
+ SELECT 1 FROM pg_publication_tables
1505
+ WHERE pubname = 'supabase_realtime' AND schemaname = 'public' AND tablename = t);
1506
+ EXECUTE format('ALTER PUBLICATION supabase_realtime ADD TABLE public.%I', t);
1507
+ v_n := v_n + 1;
1508
+ END LOOP;
1509
+
1510
+ RAISE NOTICE 'conversations/006: % tabela(s) publicada(s) no realtime', v_n;
1511
+ END $$;
1512
+
1513
+ -- \`REPLICA IDENTITY FULL\` na conversa: sem ela o UPDATE chega com a chave
1514
+ -- prim\xE1ria e mais nada, e a tela n\xE3o consegue saber que \`unread_count\` mudou \u2014
1515
+ -- que \xE9 exatamente o UPDATE que interessa aqui. Na tabela de mensagens n\xE3o \xE9
1516
+ -- preciso: o que importa \xE9 o INSERT, e esse vem inteiro de qualquer forma.
1517
+ DO $$
1518
+ BEGIN
1519
+ IF to_regclass('public.plg_conversations') IS NOT NULL THEN
1520
+ ALTER TABLE public.plg_conversations REPLICA IDENTITY FULL;
1521
+ END IF;
1522
+ END $$;
1523
+
1524
+ -- \u2500\u2500 e a conta de n\xE3o lidas, que ningu\xE9m mantinha \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1525
+ --
1526
+ -- \`unread_count\` estava em ZERO nas oito conversas do cluster, incluindo as
1527
+ -- quatro cuja \xFAltima mensagem \xE9 de entrada. N\xE3o era um zero verdadeiro: n\xE3o h\xE1
1528
+ -- (n\xE3o havia) nada que a incremente. A coluna existe desde a baseline, a tela
1529
+ -- da lista decide o destaque por ela (\`unreadCount > 0\`) e a RPC de marcar como
1530
+ -- lida zera-a \u2014 s\xF3 faltava o lado que soma.
1531
+ --
1532
+ -- Por isso \`isWaitingOnUs\` recorre a \`lastMessageDirection\` quando a conta \xE9
1533
+ -- zero: foi escrita j\xE1 sabendo que a conta n\xE3o era de fiar. Esta migration
1534
+ -- torna-a de fiar, e a recorr\xEAncia volta a ser o que devia ser \u2014 uma rede, e
1535
+ -- n\xE3o o caminho normal.
1536
+ --
1537
+ -- S\xF3 o que CHEGA conta. Somar o que n\xF3s enviamos faria a caixa dizer que h\xE1
1538
+ -- doze por ler logo depois de algu\xE9m responder doze vezes.
1539
+ CREATE OR REPLACE FUNCTION public.plg_conversations_bump_unread() RETURNS trigger
1540
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
1541
+ AS $$
1542
+ BEGIN
1543
+ IF NEW.direction IS DISTINCT FROM 'inbound' THEN RETURN NULL; END IF;
1544
+ UPDATE public.plg_conversations
1545
+ SET unread_count = coalesce(unread_count, 0) + 1
1546
+ WHERE id = NEW.conversation_id;
1547
+ RETURN NULL;
1548
+ END $$;
1549
+
1550
+ REVOKE ALL ON FUNCTION public.plg_conversations_bump_unread() FROM PUBLIC;
1551
+ GRANT EXECUTE ON FUNCTION public.plg_conversations_bump_unread() TO authenticated, service_role;
1552
+
1553
+ COMMENT ON FUNCTION public.plg_conversations_bump_unread() IS
1554
+ 'Soma uma n\xE3o lida por mensagem que CHEGA. A coluna existia e era zerada ao ler, mas nada a incrementava (006).';
1555
+
1556
+ -- AFTER, como o irm\xE3o que carimba a dire\xE7\xE3o: falhar a contar n\xE3o pode recusar
1557
+ -- uma mensagem que o provedor j\xE1 entregou.
1558
+ DROP TRIGGER IF EXISTS plg_conversation_messages_bump_unread ON public.plg_conversation_messages;
1559
+ CREATE TRIGGER plg_conversation_messages_bump_unread
1560
+ AFTER INSERT ON public.plg_conversation_messages
1561
+ FOR EACH ROW EXECUTE FUNCTION public.plg_conversations_bump_unread();
1562
+
1563
+ -- O retroativo, e o que ele pode e n\xE3o pode saber.
1564
+ --
1565
+ -- N\xE3o h\xE1 coluna de "lido at\xE9 quando" nesta tabela \u2014 \`markRead\` zera a conta e
1566
+ -- mais nada. Ent\xE3o n\xE3o d\xE1 para reconstituir quantas mensagens ficaram por ler:
1567
+ -- o que d\xE1 para saber \xE9 que a conversa EST\xC1 \xE0 espera, porque a \xFAltima mensagem
1568
+ -- dela \xE9 de entrada.
1569
+ --
1570
+ -- Nesse caso a conta vira 1. Um n\xFAmero inventado seria pior que um n\xFAmero
1571
+ -- modesto: 1 diz "h\xE1 algo aqui", que \xE9 verdade e \xE9 o que faz a linha destacar;
1572
+ -- dizer "7" alegaria uma precis\xE3o que esta tabela nunca guardou. A partir de
1573
+ -- agora o gatilho conta de verdade.
1574
+ UPDATE public.plg_conversations
1575
+ SET unread_count = 1
1576
+ WHERE last_message_direction = 'inbound'
1577
+ AND status = 'open'
1578
+ AND coalesce(unread_count, 0) = 0;
1579
+ `;
1580
+ var MIGRATION_007_A_CONVERSA_DIZ_DE_QUEM_E_O_LEAD = `-- ---------------------------------------------------------------------------
1581
+ -- 007_a_conversa_diz_de_quem_e_o_lead.sql
1582
+ --
1583
+ -- \u2500\u2500 duas coisas chamadas "respons\xE1vel" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1584
+ --
1585
+ -- A caixa j\xE1 mostra um "Respons\xE1vel", e ele \xE9 \`plg_conversations.assigned_to\`:
1586
+ -- quem est\xE1 ATENDENDO aquela conversa. N\xE3o \xE9 o dono do lead.
1587
+ --
1588
+ -- Os dois convivem e precisam conviver: a Carolina \xE9 dona do lead, e quem
1589
+ -- responde a mensagem que chegou \xE0s 2h da manh\xE3 pode ser outra pessoa. Colapsar
1590
+ -- num s\xF3 campo faria uma das duas perguntas ficar sem resposta \u2014 e a que
1591
+ -- desapareceria \xE9 a do funil, que \xE9 a que decide quem faz o follow-up.
1592
+ --
1593
+ -- Esta view acrescenta a SEGUNDA, com nome pr\xF3prio (\`lead_owner_*\`), sem tocar
1594
+ -- na primeira.
1595
+ --
1596
+ -- \u2500\u2500 por que view, e n\xE3o uma coluna na conversa \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1597
+ --
1598
+ -- O dono do lead muda \u2014 algu\xE9m clica "Assumir" no funil e a carteira troca de
1599
+ -- m\xE3os. Uma coluna copiada aqui seria uma segunda verdade que envelhece em
1600
+ -- sil\xEAncio: a conversa diria Carolina depois de o lead j\xE1 ser do Julio, e
1601
+ -- ningu\xE9m saberia dizer qual das duas telas est\xE1 certa.
1602
+ --
1603
+ -- \xC9 exatamente o problema que a 047 acabou de resolver do outro lado, onde o
1604
+ -- dono estava escrito em dois lugares que n\xE3o se falavam. Derivar \xE9 o que
1605
+ -- impede a repeti\xE7\xE3o.
1606
+ --
1607
+ -- \u2500\u2500 e por que \`v_leads\` e n\xE3o \`plg_crm_deals\` \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1608
+ --
1609
+ -- \`v_leads.assigned_to_id\` j\xE1 resolve a preced\xEAncia entre o que a PESSOA
1610
+ -- declara (\`metadata.assignedToId\`) e o que o NEG\xD3CIO diz (047). Ir direto ao
1611
+ -- neg\xF3cio aqui reimplementaria essa regra, e as duas divergiriam no dia em que
1612
+ -- uma mudasse.
1613
+ --
1614
+ -- \u2500\u2500 o CRM \xE9 opcional \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1615
+ --
1616
+ -- \`plugin-conversations\` roda em casa sem funil nenhum. \`v_leads\` \xE9 da espinha
1617
+ -- (n\xE3o do CRM), ent\xE3o a depend\xEAncia \xE9 leg\xEDtima \u2014 mas a jun\xE7\xE3o \xE9 LEFT: uma
1618
+ -- conversa de quem n\xE3o \xE9 lead continua aparecendo, com o dono nulo.
1619
+ -- ---------------------------------------------------------------------------
1620
+
1621
+ CREATE OR REPLACE VIEW public.v_conversations WITH (security_invoker = true) AS
1622
+ SELECT c.*,
1623
+ l.assigned_to_id AS lead_owner_id,
1624
+ p.name AS lead_owner_name
1625
+ FROM public.plg_conversations c
1626
+ -- Pelo contato: \xE9 \`contact_person_id\` que liga a conversa a uma pessoa da
1627
+ -- base. Nulo enquanto ningu\xE9m casou o telefone com algu\xE9m, e a\xED n\xE3o h\xE1
1628
+ -- lead a quem perguntar o dono.
1629
+ LEFT JOIN public.v_leads l
1630
+ ON l.id = c.contact_person_id
1631
+ -- O NOME do dono, que \xE9 o que a tela mostra. O id sozinho obrigaria cada
1632
+ -- tela a resolver o mesmo de-para \u2014 foi o que fez o filtro de leads exibir
1633
+ -- UUID em produ\xE7\xE3o por um dia.
1634
+ LEFT JOIN public.v_team_members m
1635
+ ON m.user_id::text = l.assigned_to_id AND m.tenant_id = c.tenant_id
1636
+ LEFT JOIN public.people p
1637
+ ON p.id = m.person_id;
1638
+
1639
+ COMMENT ON VIEW public.v_conversations IS
1640
+ 'A conversa com o DONO DO LEAD junto \u2014 diferente de \`assigned_to\`, que \xE9 quem atende a conversa. Derivado de \`v_leads\` para n\xE3o virar uma segunda verdade que envelhece (007).';
1641
+ `;
1642
+ var MIGRATION_008_DAR_POR_VISTA_E_UMA_DECISAO = `-- ---------------------------------------------------------------------------
1643
+ -- 008_dar_por_vista_e_uma_decisao.sql
1644
+ --
1645
+ -- \u2500\u2500 o buraco \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1646
+ --
1647
+ -- A bolinha vermelha da caixa n\xE3o \xE9 "n\xE3o lido": \xE9 \`isWaitingOnUs\`, que olha a
1648
+ -- DIRE\xC7\xC3O da \xFAltima mensagem. Se a \xFAltima \xE9 de entrada, a conversa espera
1649
+ -- resposta \u2014 e continua esperando por mais que algu\xE9m abra e leia.
1650
+ --
1651
+ -- Isso est\xE1 certo enquanto a pergunta \xE9 "o que falta responder". Mas deixa sem
1652
+ -- sa\xEDda o caso comum: a pessoa leu, decidiu que n\xE3o cabe resposta ("ok",
1653
+ -- "obrigado", um \xE1udio que j\xE1 foi tratado por telefone) e n\xE3o tem como tirar a
1654
+ -- conversa da fila. \`unread_count = 0\` n\xE3o resolve, porque n\xE3o \xE9 ele que
1655
+ -- acende a bolinha.
1656
+ --
1657
+ -- \u2500\u2500 por que um instante, e n\xE3o um booleano \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1658
+ --
1659
+ -- \`lida boolean\` exigiria algu\xE9m lembrar de apag\xE1-la quando chegasse mensagem
1660
+ -- nova \u2014 e o dia em que esquecessem, a conversa sumiria da fila com o cliente
1661
+ -- esperando. \xC9 o pior defeito poss\xEDvel numa caixa de entrada: silencioso, e do
1662
+ -- lado de perder trabalho.
1663
+ --
1664
+ -- Um INSTANTE se compara com \`last_message_at\` e se invalida sozinho: deu por
1665
+ -- vista \xE0s 14h, chegou mensagem \xE0s 15h, a conversa volta \xE0 fila sem ningu\xE9m
1666
+ -- fazer nada. A regra \xE9 uma compara\xE7\xE3o, n\xE3o um estado a manter.
1667
+ --
1668
+ -- \u2500\u2500 o que N\xC3O muda \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1669
+ --
1670
+ -- \`unread_count\` continua onde est\xE1. Ele conta mensagens e serve ao contador
1671
+ -- do menu; esta coluna responde outra pergunta ("algu\xE9m j\xE1 decidiu sobre
1672
+ -- isto?"). Colapsar as duas faria a contagem do menu mentir no dia em que
1673
+ -- algu\xE9m desse por vista sem ler.
1674
+ --
1675
+ -- Idempotente e replay-safe.
1676
+ -- ---------------------------------------------------------------------------
1677
+
1678
+ ALTER TABLE public.plg_conversations
1679
+ ADD COLUMN IF NOT EXISTS acknowledged_at timestamptz;
1680
+
1681
+ COMMENT ON COLUMN public.plg_conversations.acknowledged_at IS
1682
+ 'Quando algu\xE9m deu a conversa por vista. Vale enquanto for POSTERIOR a \`last_message_at\` \u2014 mensagem nova devolve a conversa \xE0 fila sozinha (008).';
1683
+
1684
+ -- A fila \xE9 lida por \`last_message_at\` e por esta coluna juntas.
1685
+ CREATE INDEX IF NOT EXISTS plg_conversations_fila_idx
1686
+ ON public.plg_conversations (tenant_id, status, last_message_at DESC)
1687
+ WHERE status = 'open';
1688
+ `;
1689
+ var MIGRATION_009_A_MENSAGEM_INTEIRA_ATRAVESSA_O_REALTIME = `-- ---------------------------------------------------------------------------
1690
+ -- 009_a_mensagem_inteira_atravessa_o_realtime.sql
1691
+ --
1692
+ -- \u2500\u2500 o buraco \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1693
+ --
1694
+ -- O vigia da caixa assinava \`plg_conversation_messages\`, recebia \`SUBSCRIBED\`
1695
+ -- do servidor, e nunca recebia um \xFAnico evento. Nenhum erro, nenhum aviso: a
1696
+ -- assinatura dizia que estava tudo bem e n\xE3o chegava nada.
1697
+ --
1698
+ -- A causa \xE9 a combina\xE7\xE3o de duas coisas que, sozinhas, est\xE3o certas:
1699
+ --
1700
+ -- 1. a tabela tem RLS ligada, como toda tabela desta base;
1701
+ -- 2. a tabela estava com \`REPLICA IDENTITY\` no padr\xE3o \u2014 s\xF3 a chave prim\xE1ria.
1702
+ --
1703
+ -- O Realtime do Supabase aplica a POL\xCDTICA DE LEITURA do usu\xE1rio a cada evento
1704
+ -- antes de entreg\xE1-lo. Para isso ele precisa da linha inteira: a pol\xEDtica fala
1705
+ -- de \`tenant_id\`, e \`tenant_id\` n\xE3o \xE9 a chave prim\xE1ria. Com s\xF3 a PK no WAL, n\xE3o
1706
+ -- h\xE1 o que avaliar \u2014 e o evento \xE9 descartado sem ru\xEDdo.
1707
+ --
1708
+ -- \`plg_conversations\` j\xE1 estava \`FULL\` (006). A tabela de MENSAGENS ficou de
1709
+ -- fora daquela migration, e \xE9 justamente ela que o vigia escuta.
1710
+ --
1711
+ -- \u2500\u2500 por que isso \xE9 caro e ainda assim certo \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1712
+ --
1713
+ -- \`FULL\` faz o WAL carregar a linha inteira em cada UPDATE e DELETE, n\xE3o s\xF3 as
1714
+ -- colunas mudadas. Numa tabela de mensagens isso \xE9 peso real.
1715
+ --
1716
+ -- A alternativa \u2014 desligar a RLS para o Realtime enxergar \u2014 trocaria um custo
1717
+ -- de disco por um vazamento: as mensagens de uma casa chegariam ao navegador
1718
+ -- de outra. Entre pagar WAL e vazar conversa, paga-se o WAL.
1719
+ --
1720
+ -- Idempotente e replay-safe.
1721
+ -- ---------------------------------------------------------------------------
1722
+
1723
+ ALTER TABLE public.plg_conversation_messages REPLICA IDENTITY FULL;
1724
+
1725
+ -- Garante a publica\xE7\xE3o tamb\xE9m \u2014 uma tabela com identidade cheia e fora da
1726
+ -- publica\xE7\xE3o continua sem emitir nada, e o sintoma seria id\xEAntico.
1727
+ DO $$
1728
+ BEGIN
1729
+ IF NOT EXISTS (
1730
+ SELECT 1 FROM pg_publication_tables
1731
+ WHERE pubname = 'supabase_realtime'
1732
+ AND schemaname = 'public'
1733
+ AND tablename = 'plg_conversation_messages'
1734
+ ) THEN
1735
+ EXECUTE 'ALTER PUBLICATION supabase_realtime ADD TABLE public.plg_conversation_messages';
1736
+ END IF;
1737
+ END $$;
1738
+ `;
1739
+ var MIGRATION_010_A_CHEGADA_E_ANUNCIADA_POR_CONTA = `-- ---------------------------------------------------------------------------
1740
+ -- 010_a_chegada_e_anunciada_por_conta.sql
1741
+ --
1742
+ -- \u2500\u2500 por que \`postgres_changes\` nunca funcionou aqui \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1743
+ --
1744
+ -- O vigia assinava a TABELA de mensagens e recebia \`SUBSCRIBED\` do servidor.
1745
+ -- Nenhum evento chegava. Nenhum erro, nenhum aviso \u2014 a assinatura dizia que
1746
+ -- estava tudo bem.
1747
+ --
1748
+ -- O Realtime aplica a pol\xEDtica de leitura a cada evento antes de entreg\xE1-lo. A
1749
+ -- pol\xEDtica desta base resolve o tenant por, nesta ordem:
1750
+ --
1751
+ -- \u2460 a claim \`tenant_id\` do JWT
1752
+ -- \u2461 o CABE\xC7ALHO da requisi\xE7\xE3o (\`app.requested_tenant()\`)
1753
+ -- \u2462 a filia\xE7\xE3o \xFAnica, quando a pessoa s\xF3 tem uma
1754
+ --
1755
+ -- O Realtime manda o JWT e N\xC3O manda cabe\xE7alho. E o JWT desta casa n\xE3o carrega
1756
+ -- \`tenant_id\` \u2014 a conta ativa \xE9 escolhida por cabe\xE7alho, porque a mesma pessoa
1757
+ -- atende sete contas. Sobra \u2462, que devolve nulo para quem tem mais de uma.
1758
+ --
1759
+ -- Resultado: a pol\xEDtica recusa, o evento \xE9 descartado em sil\xEAncio, e o defeito
1760
+ -- parece "o realtime n\xE3o funciona" quando na verdade \xE9 "o realtime n\xE3o sabe em
1761
+ -- que conta voc\xEA est\xE1".
1762
+ --
1763
+ -- \u2500\u2500 a sa\xEDda: anunciar, em vez de espiar a tabela \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1764
+ --
1765
+ -- Broadcast n\xE3o passa pela RLS da tabela: o servidor entrega a quem est\xE1 no
1766
+ -- T\xD3PICO. Ent\xE3o o t\xF3pico carrega a conta no nome \u2014 \`conversas:<tenant>\` \u2014 e
1767
+ -- quem escuta j\xE1 teve de saber em que conta est\xE1 para montar o nome.
1768
+ --
1769
+ -- Isso inverte a depend\xEAncia de forma saud\xE1vel: em vez de o banco adivinhar o
1770
+ -- contexto do navegador, o navegador declara o contexto que j\xE1 tem.
1771
+ --
1772
+ -- \u2500\u2500 o que vai no an\xFAncio \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1773
+ --
1774
+ -- O M\xCDNIMO para decidir: id da conversa, canal, e um trecho. N\xE3o a mensagem
1775
+ -- inteira \u2014 um broadcast \xE9 entregue a todos do t\xF3pico, e mandar o corpo
1776
+ -- completo por ali seria espalhar conversa por um caminho que n\xE3o \xE9 o que
1777
+ -- guarda conversa. Quem quiser o texto l\xEA a tabela, com RLS, como sempre.
1778
+ --
1779
+ -- Idempotente e replay-safe.
1780
+ -- ---------------------------------------------------------------------------
1781
+
1782
+ CREATE OR REPLACE FUNCTION public.plg_conversations_anunciar_chegada()
1783
+ RETURNS trigger
1784
+ LANGUAGE plpgsql
1785
+ SECURITY DEFINER
1786
+ SET search_path = ''
1787
+ AS $$
1788
+ DECLARE
1789
+ v_nome text;
1790
+ BEGIN
1791
+ -- S\xF3 o que CHEGA. A resposta que o operador manda tamb\xE9m \xE9 um INSERT, e
1792
+ -- anunci\xE1-la faria o sistema apitar para quem acabou de digitar.
1793
+ IF NEW.direction IS DISTINCT FROM 'inbound' THEN
1794
+ RETURN NEW;
1795
+ END IF;
1796
+
1797
+ SELECT c.contact_name INTO v_nome
1798
+ FROM public.plg_conversations c
1799
+ WHERE c.id = NEW.conversation_id;
1800
+
1801
+ PERFORM realtime.send(
1802
+ jsonb_build_object(
1803
+ 'conversationId', NEW.conversation_id,
1804
+ 'channel', NEW.channel,
1805
+ 'contactName', coalesce(v_nome, ''),
1806
+ -- Um trecho, n\xE3o o corpo: o suficiente para a notifica\xE7\xE3o do sistema
1807
+ -- dizer do que se trata sem espalhar a conversa pelo broadcast.
1808
+ 'preview', left(coalesce(NEW.body, ''), 120),
1809
+ 'at', NEW.at
1810
+ ),
1811
+ 'inbound',
1812
+ 'conversas:' || NEW.tenant_id::text,
1813
+ -- Privado: exige que quem assina esteja autenticado. P\xFAblico deixaria
1814
+ -- qualquer um com o nome do t\xF3pico ouvir a chegada.
1815
+ true
1816
+ );
1817
+ RETURN NEW;
1818
+ END $$;
1819
+
1820
+ REVOKE ALL ON FUNCTION public.plg_conversations_anunciar_chegada() FROM public, anon, authenticated;
1821
+
1822
+ DROP TRIGGER IF EXISTS plg_conversations_anunciar_chegada ON public.plg_conversation_messages;
1823
+ CREATE TRIGGER plg_conversations_anunciar_chegada
1824
+ AFTER INSERT ON public.plg_conversation_messages
1825
+ FOR EACH ROW EXECUTE FUNCTION public.plg_conversations_anunciar_chegada();
1826
+
1827
+ COMMENT ON FUNCTION public.plg_conversations_anunciar_chegada() IS
1828
+ 'Anuncia a chegada no t\xF3pico \`conversas:<tenant>\`. Broadcast e n\xE3o \`postgres_changes\` porque a RLS da tabela resolve o tenant por cabe\xE7alho, e o Realtime n\xE3o manda cabe\xE7alho (010).';
1829
+ `;
1830
+ var MIGRATION_011_O_ANUNCIO_E_UMA_CAMPAINHA_NAO_UM_RECADO = `-- ---------------------------------------------------------------------------
1831
+ -- 011_o_anuncio_e_uma_campainha_nao_um_recado.sql
1832
+ --
1833
+ -- \u2500\u2500 por que a 010 n\xE3o podia ficar como estava \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1834
+ --
1835
+ -- A 010 anunciava num t\xF3pico PRIVADO e mandava, no an\xFAncio, o nome do contato e
1836
+ -- um trecho da mensagem. T\xF3pico privado exige pol\xEDtica em \`realtime.messages\` \u2014
1837
+ -- e essa tabela \xE9 do Supabase, dona \`supabase_realtime_admin\`, um papel que
1838
+ -- esta conex\xE3o n\xE3o pode assumir:
1839
+ --
1840
+ -- ERROR: 42501: permission denied to set role "supabase_realtime_admin"
1841
+ --
1842
+ -- Sem a pol\xEDtica, o servidor recusa a assinatura e nada chega. Com o t\xF3pico
1843
+ -- p\xFAblico, qualquer um que soubesse o nome do t\xF3pico leria o trecho da
1844
+ -- conversa \u2014 o que n\xE3o se faz.
1845
+ --
1846
+ -- \u2500\u2500 a sa\xEDda: separar o AVISO do CONTE\xDADO \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1847
+ --
1848
+ -- O an\xFAncio passa a ser uma campainha: diz que chegou algo e em que conversa,
1849
+ -- e mais nada. Nome e texto o navegador busca pela via de sempre \u2014 a tabela,
1850
+ -- com RLS, onde o cabe\xE7alho da conta existe e a pol\xEDtica funciona.
1851
+ --
1852
+ -- O t\xF3pico continua carregando o uuid da conta no nome, que \xE9 o que separa uma
1853
+ -- casa da outra. Ele n\xE3o \xE9 adivinh\xE1vel, e mesmo que fosse, o que se ouviria \xE9
1854
+ -- "chegou mensagem", sem nome nem texto. O sigilo fica onde sempre esteve: na
1855
+ -- leitura da tabela.
1856
+ --
1857
+ -- \xC9 uma troca deliberada de superf\xEDcie: em vez de tentar proteger um caminho
1858
+ -- novo, n\xE3o se p\xF5e nada sens\xEDvel nele.
1859
+ --
1860
+ -- Idempotente e replay-safe.
1861
+ -- ---------------------------------------------------------------------------
1862
+
1863
+ CREATE OR REPLACE FUNCTION public.plg_conversations_anunciar_chegada()
1864
+ RETURNS trigger
1865
+ LANGUAGE plpgsql
1866
+ SECURITY DEFINER
1867
+ SET search_path = ''
1868
+ AS $$
1869
+ BEGIN
1870
+ -- S\xF3 o que CHEGA. A resposta que o operador manda tamb\xE9m \xE9 um INSERT, e
1871
+ -- anunci\xE1-la faria o sistema apitar para quem acabou de digitar.
1872
+ IF NEW.direction IS DISTINCT FROM 'inbound' THEN
1873
+ RETURN NEW;
1874
+ END IF;
1875
+
1876
+ PERFORM realtime.send(
1877
+ -- S\xD3 o id da conversa. Nem nome, nem trecho, nem corpo: quem ouve vai
1878
+ -- buscar isso na tabela, com a pol\xEDtica de leitura no meio do caminho.
1879
+ -- Um uuid sozinho n\xE3o conta nada a quem n\xE3o pode ler a conversa.
1880
+ jsonb_build_object('conversationId', NEW.conversation_id),
1881
+ 'inbound',
1882
+ 'conversas:' || NEW.tenant_id::text,
1883
+ -- P\xDABLICO, e por isso o payload \xE9 vazio de conte\xFAdo. Privado exigiria uma
1884
+ -- pol\xEDtica numa tabela que n\xE3o \xE9 nossa.
1885
+ false
1886
+ );
1887
+ RETURN NEW;
1888
+ END $$;
1889
+
1890
+ COMMENT ON FUNCTION public.plg_conversations_anunciar_chegada() IS
1891
+ 'Toca a campainha no t\xF3pico \`conversas:<tenant>\` \u2014 s\xF3 o id da conversa. O conte\xFAdo \xE9 lido da tabela, com RLS (011).';
1892
+ `;
1893
+ var MIGRATION_012_A_RICH_MESSAGE_KEEPS_ITS_SHAPE = `-- ---------------------------------------------------------------------------
1894
+ -- 012_a_rich_message_keeps_its_shape.sql
1895
+ --
1896
+ -- \u2500\u2500 what was lost, and how \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1897
+ --
1898
+ -- \`plg_conversation_messages\` has exactly one content column: \`body text\`. A
1899
+ -- WhatsApp message that is not text therefore arrives flattened to whatever
1900
+ -- string the ingest could make of it \u2014 and when the ingest could make none, to
1901
+ -- the empty string, which renders as an empty bubble.
1902
+ --
1903
+ -- On 2026-09-22 a lead sent his finance director's contact card. It stored as
1904
+ -- \`body = ''\`. He then sent a screenshot of the same card; that stored as
1905
+ -- \`body = ''\` too. The fact the conversation existed to obtain \u2014 a name and a
1906
+ -- phone number \u2014 was in the payload both times and in the row neither time.
1907
+ --
1908
+ -- Recovering it meant going back to the Evolution instance and reading the
1909
+ -- provider's own copy. That worked, but it is not a recovery path: Evolution
1910
+ -- prunes, the instance is local-only, and a row that needs a second system to
1911
+ -- be legible is not a record.
1912
+ --
1913
+ -- \u2500\u2500 the two columns \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1914
+ --
1915
+ -- \`message_type\` names what the provider sent \u2014 text, contact, image, audio,
1916
+ -- document, sticker, location, reaction, unknown. The inbox needs it to choose
1917
+ -- a bubble, and \`unknown\` is the one that makes the NEXT gap visible in a query
1918
+ -- instead of in a complaint.
1919
+ --
1920
+ -- \`payload\` keeps the part that does not survive flattening: the vCard's parsed
1921
+ -- name and numbers, a media mimetype and size, a location's coordinates. Not
1922
+ -- the whole provider envelope \u2014 that carries decryption keys and a base64
1923
+ -- thumbnail, neither of which belongs in a row that every member of the tenant
1924
+ -- can read.
1925
+ --
1926
+ -- Deliberately NOT stored here: delivery status. It changes after the fact \u2014
1927
+ -- sent, delivered, read \u2014 and \`conversations-provider\`'s \`message_detail\` reads
1928
+ -- it live for that reason. \`payload\` is the shape of the message, which does
1929
+ -- not change once it arrives.
1930
+ --
1931
+ -- \u2500\u2500 why both are nullable, and nothing is backfilled \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1932
+ --
1933
+ -- Every row written before this migration has neither, and no default can
1934
+ -- invent them: \`message_type\` would have to guess, and guessing 'text' for the
1935
+ -- three empty bubbles already in the table would assert something false about
1936
+ -- exactly the rows this exists to fix. A reader that treats NULL as "written
1937
+ -- before 012" is correct; a reader that treats '' as text is the bug.
1938
+ --
1939
+ -- Table-level \`GRANT ALL ... TO authenticated\` already covers new columns, and
1940
+ -- the four tenant policies are row-scoped, so neither needs restating.
1941
+ --
1942
+ -- Idempotent and replay-safe.
1943
+ -- ---------------------------------------------------------------------------
1944
+
1945
+ ALTER TABLE public.plg_conversation_messages
1946
+ ADD COLUMN IF NOT EXISTS message_type text,
1947
+ ADD COLUMN IF NOT EXISTS payload jsonb;
1948
+
1949
+ COMMENT ON COLUMN public.plg_conversation_messages.message_type IS
1950
+ 'What the provider sent: text, contact, image, video, audio, document, sticker, location, reaction, unknown. NULL means the row predates 012 (#504).';
1951
+
1952
+ COMMENT ON COLUMN public.plg_conversation_messages.payload IS
1953
+ 'The structured remainder that \`body\` cannot hold \u2014 parsed vCards, media metadata, coordinates. Never credentials, thumbnails or decryption keys; never delivery status, which is read live (#504).';
1954
+ `;
1955
+ var MIGRATION_013_A_MIDIA_FICA_CONOSCO = `-- ---------------------------------------------------------------------------
1956
+ -- 013_a_midia_fica_conosco.sql
1957
+ --
1958
+ -- \u2500\u2500 o que a 012 resolveu, e o que ela n\xE3o resolveu \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1959
+ --
1960
+ -- A 012 fez a mensagem que n\xE3o \xE9 texto parar de chegar como bolha vazia:
1961
+ -- \`message_type\` diz que \xE9 imagem, \`payload\` guarda mimetype, tamanho e
1962
+ -- dura\xE7\xE3o. Funcionou \u2014 um \xE1udio de 23 segundos entrou classificado como
1963
+ -- \`audio\`, \`ptt: true\`, \`audio/ogg; codecs=opus\`, 55.825 bytes.
1964
+ --
1965
+ -- E a tela mostra \`[\xE1udio]\`. Porque saber QUE existe um arquivo n\xE3o \xE9 ter o
1966
+ -- arquivo. Nada no banco aponta para bytes nenhuns: o WhatsApp entrega m\xEDdia
1967
+ -- cifrada num CDN pr\xF3prio, e quem decifra \xE9 o provedor.
1968
+ --
1969
+ -- \u2500\u2500 por que uma coluna, e n\xE3o um campo no payload \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1970
+ --
1971
+ -- A 012 escreveu que \`payload\` \xE9 "a forma da mensagem, que n\xE3o muda depois que
1972
+ -- ela chega". Onde o arquivo foi parar \xE9 decidido DEPOIS \u2014 no upload, que pode
1973
+ -- falhar, ser refeito, ou ainda n\xE3o ter acontecido. Enfiar isso no \`payload\`
1974
+ -- contradiz a \xFAnica frase que diz quando \`payload\` pode ser lido sem medo.
1975
+ --
1976
+ -- Em coluna, a pergunta que importa vira consulta:
1977
+ --
1978
+ -- SELECT count(*) FROM plg_conversation_messages
1979
+ -- WHERE message_type IN ('image','video','audio','document','sticker')
1980
+ -- AND media_path IS NULL;
1981
+ --
1982
+ -- "quanta m\xEDdia eu recebi e n\xE3o guardei" \u2014 que num campo de JSON seria uma
1983
+ -- varredura e aqui \xE9 um \xEDndice.
1984
+ --
1985
+ -- \u2500\u2500 por que guardar, em vez de buscar no provedor quando algu\xE9m abrir \u2500\u2500\u2500
1986
+ --
1987
+ -- A Evolution decifra sob demanda, e seria mais barato n\xE3o copiar nada. Mas ela
1988
+ -- \xE9 local-only, poda o que \xE9 antigo, e a inst\xE2ncia roda numa m\xE1quina que nem
1989
+ -- sempre est\xE1 de p\xE9. Essa era exatamente a obje\xE7\xE3o da 012 contra depender dela
1990
+ -- para ler um vCard: uma linha que precisa de um segundo sistema para ser
1991
+ -- leg\xEDvel n\xE3o \xE9 registro. Vale igual para os bytes.
1992
+ --
1993
+ -- O destino \xE9 o balde \`attachments\` \u2014 privado, 25 MB, j\xE1 existente e j\xE1 em uso.
1994
+ -- Uma foto que um lead mandou no WhatsApp n\xE3o pode ser p\xFAblica, e um balde novo
1995
+ -- seria mais uma lista de mimetypes para algu\xE9m manter desatualizada.
1996
+ --
1997
+ -- \u2500\u2500 nulo \xE9 um estado leg\xEDtimo, n\xE3o uma falha \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1998
+ --
1999
+ -- \`media_path\` \xE9 NULL em tr\xEAs situa\xE7\xF5es diferentes e todas normais: a mensagem
2000
+ -- \xE9 texto; a linha \xE9 anterior a esta migra\xE7\xE3o; ou o upload ainda n\xE3o aconteceu.
2001
+ -- Quem l\xEA distingue pela combina\xE7\xE3o com \`message_type\`, que \xE9 o que torna a
2002
+ -- consulta acima poss\xEDvel. Um default inventaria um caminho para arquivo que
2003
+ -- n\xE3o existe.
2004
+ --
2005
+ -- Idempotente e replay-safe.
2006
+ -- ---------------------------------------------------------------------------
2007
+
2008
+ ALTER TABLE public.plg_conversation_messages
2009
+ ADD COLUMN IF NOT EXISTS media_path text;
2010
+
2011
+ COMMENT ON COLUMN public.plg_conversation_messages.media_path IS
2012
+ 'Onde os bytes ficaram no balde \`attachments\`, ou NULL: texto, linha anterior \xE0 013, ou upload que ainda n\xE3o aconteceu. Distingue-se pela combina\xE7\xE3o com \`message_type\` (013).';
2013
+
2014
+ -- Parcial: s\xF3 a m\xEDdia sem arquivo interessa, e \xE9 a minoria. O \xEDndice fica do
2015
+ -- tamanho do problema em vez do tamanho da tabela.
2016
+ CREATE INDEX IF NOT EXISTS plg_conversation_messages_midia_sem_arquivo
2017
+ ON public.plg_conversation_messages (tenant_id, at DESC)
2018
+ WHERE media_path IS NULL
2019
+ AND message_type IN ('image', 'video', 'audio', 'document', 'sticker');
2020
+ `;
2021
+ var MIGRATION_014_CLAIMING_TARGETS_IS_SERVER_ONLY = `-- 014_claiming_targets_is_server_only.sql
2022
+ --
2023
+ -- 003 revoked \`conversations_claim_targets\` from PUBLIC and anon but not from
2024
+ -- \`authenticated\`, which the pool's default privileges grant on every new
2025
+ -- function in public. Live, any signed-in user of any tenant could claim (and
2026
+ -- so burn) another tenant's campaign targets by uuid \u2014 SECURITY DEFINER, no
2027
+ -- caller check. The only caller is the conversations-provider edge function,
2028
+ -- which uses the service-role key.
2029
+
2030
+ REVOKE ALL ON FUNCTION public.conversations_claim_targets(uuid, integer) FROM PUBLIC, anon, authenticated;
2031
+ GRANT EXECUTE ON FUNCTION public.conversations_claim_targets(uuid, integer) TO service_role;
2032
+ `;
2033
+ var MIGRATION_015_THE_SERVICE_DOOR_FOR_MESSAGING = `-- ============================================================================
2034
+ -- 015_the_service_door_for_messaging \u2014 the WhatsApp edge functions move to the
2035
+ -- integration service (ADR 0003 phase 2, wave 2).
2036
+ --
2037
+ -- Two kinds of table, two kinds of access:
2038
+ --
2039
+ -- * tenant tables a member already writes (conversations, messages,
2040
+ -- campaigns, templates): the service writes them AS the store's owner or
2041
+ -- as the verified caller (core migrations 210/212), under RLS. Nothing
2042
+ -- here.
2043
+ -- * service tables only a machine writes, which service_role wrote until
2044
+ -- now: provider credentials, channels, opt-outs, webhook events, provider
2045
+ -- templates, payment requests. fayz_integration gets exactly that, table
2046
+ -- by table, with an explicit policy. Panels keep their SELECT policies.
2047
+ --
2048
+ -- And the three server-only functions the webhooks call get EXECUTE for the
2049
+ -- service role (they were service_role only, 014).
2050
+ --
2051
+ -- Guarded: the role is an operator step. Replay-safe.
2052
+ -- ============================================================================
2053
+
2054
+ DO $$
2055
+ DECLARE
2056
+ t text;
2057
+ f text;
2058
+ BEGIN
2059
+ IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fayz_integration') THEN
2060
+ RETURN;
2061
+ END IF;
2062
+
2063
+ FOREACH t IN ARRAY ARRAY[
2064
+ 'plg_conversations_providers',
2065
+ 'plg_conversations_channels',
2066
+ 'plg_conversations_optouts',
2067
+ 'plg_conversations_webhook_events',
2068
+ 'plg_automations_provider_templates',
2069
+ 'plg_conversations_payment_requests'
2070
+ ] LOOP
2071
+ IF to_regclass('public.' || t) IS NULL THEN CONTINUE; END IF;
2072
+ EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON public.%I TO fayz_integration', t);
2073
+ EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I', t || '_service', t);
2074
+ EXECUTE format('CREATE POLICY %I ON public.%I FOR ALL TO fayz_integration USING (true) WITH CHECK (true)', t || '_service', t);
2075
+ END LOOP;
2076
+
2077
+ FOREACH f IN ARRAY ARRAY[
2078
+ 'public.conversations_claim_targets(uuid, integer)',
2079
+ 'public.plg_booking_apply_channel_reply(uuid, text, uuid)',
2080
+ 'public.plg_conversations_match_by_phone(text, text, text, uuid)'
2081
+ ] LOOP
2082
+ IF to_regprocedure(f) IS NOT NULL THEN
2083
+ EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO fayz_integration', f);
2084
+ END IF;
2085
+ END LOOP;
2086
+ END $$;
2087
+ `;
2088
+ var MIGRATION_016_A_DELIVERY_RECEIPT_FINDS_ITS_TENANT = `-- ============================================================================
2089
+ -- 016_a_delivery_receipt_finds_its_tenant \u2014 the Tyxter webhook moves to the
2090
+ -- integration service (ADR 0003 phase 2).
2091
+ --
2092
+ -- A delivery receipt names only the provider's message id. The service writes
2093
+ -- the inbox AS the store's owner (core 212), so it must learn the store first:
2094
+ -- this answers the tenant of the message that id was sent as, and nothing
2095
+ -- else. SECURITY DEFINER, service role only.
2096
+ --
2097
+ -- Replay-safe: CREATE OR REPLACE; guarded grants.
2098
+ -- ============================================================================
2099
+
2100
+ CREATE OR REPLACE FUNCTION public.conversations_message_tenant(p_provider_message_id text) RETURNS uuid
2101
+ LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO ''
2102
+ AS $function$
2103
+ SELECT m.tenant_id
2104
+ FROM public.plg_conversation_messages m
2105
+ WHERE m.provider_message_id = p_provider_message_id
2106
+ ORDER BY m.at DESC NULLS LAST
2107
+ LIMIT 1;
2108
+ $function$;
2109
+
2110
+ DO $$
2111
+ BEGIN
2112
+ REVOKE ALL ON FUNCTION public.conversations_message_tenant(text) FROM PUBLIC;
2113
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN REVOKE ALL ON FUNCTION public.conversations_message_tenant(text) FROM anon; END IF;
2114
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN REVOKE ALL ON FUNCTION public.conversations_message_tenant(text) FROM authenticated; END IF;
2115
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'service_role') THEN GRANT EXECUTE ON FUNCTION public.conversations_message_tenant(text) TO service_role; END IF;
2116
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fayz_integration') THEN GRANT EXECUTE ON FUNCTION public.conversations_message_tenant(text) TO fayz_integration; END IF;
2117
+ END $$;
2118
+ `;
2119
+ var MIGRATIONS = [
2120
+ { id: "000_baseline", sql: MIGRATION_000_BASELINE },
2121
+ { id: "001_a_caixa_sabe_quem_falou_por_ultimo", sql: MIGRATION_001_A_CAIXA_SABE_QUEM_FALOU_POR_ULTIMO },
2122
+ { id: "002_a_casa_escolhe_por_onde_o_whatsapp_sai", sql: MIGRATION_002_A_CASA_ESCOLHE_POR_ONDE_O_WHATSAPP_SAI },
2123
+ { id: "003_a_campanha_sai_no_ritmo_que_o_numero_aguenta", sql: MIGRATION_003_A_CAMPANHA_SAI_NO_RITMO_QUE_O_NUMERO_AGUENTA },
2124
+ { id: "004_a_tela_sabe_se_pode_falar_antes_de_deixar_escrever", sql: MIGRATION_004_A_TELA_SABE_SE_PODE_FALAR_ANTES_DE_DEIXAR_ESCREVER },
2125
+ { id: "005_a_conversa_conta_ao_funil_que_houve_contato", sql: MIGRATION_005_A_CONVERSA_CONTA_AO_FUNIL_QUE_HOUVE_CONTATO },
2126
+ { id: "006_a_caixa_avisa_que_chegou", sql: MIGRATION_006_A_CAIXA_AVISA_QUE_CHEGOU },
2127
+ { id: "007_a_conversa_diz_de_quem_e_o_lead", sql: MIGRATION_007_A_CONVERSA_DIZ_DE_QUEM_E_O_LEAD },
2128
+ { id: "008_dar_por_vista_e_uma_decisao", sql: MIGRATION_008_DAR_POR_VISTA_E_UMA_DECISAO },
2129
+ { id: "009_a_mensagem_inteira_atravessa_o_realtime", sql: MIGRATION_009_A_MENSAGEM_INTEIRA_ATRAVESSA_O_REALTIME },
2130
+ { id: "010_a_chegada_e_anunciada_por_conta", sql: MIGRATION_010_A_CHEGADA_E_ANUNCIADA_POR_CONTA },
2131
+ { id: "011_o_anuncio_e_uma_campainha_nao_um_recado", sql: MIGRATION_011_O_ANUNCIO_E_UMA_CAMPAINHA_NAO_UM_RECADO },
2132
+ { id: "012_a_rich_message_keeps_its_shape", sql: MIGRATION_012_A_RICH_MESSAGE_KEEPS_ITS_SHAPE },
2133
+ { id: "013_a_midia_fica_conosco", sql: MIGRATION_013_A_MIDIA_FICA_CONOSCO },
2134
+ { id: "014_claiming_targets_is_server_only", sql: MIGRATION_014_CLAIMING_TARGETS_IS_SERVER_ONLY },
2135
+ { id: "015_the_service_door_for_messaging", sql: MIGRATION_015_THE_SERVICE_DOOR_FOR_MESSAGING },
2136
+ { id: "016_a_delivery_receipt_finds_its_tenant", sql: MIGRATION_016_A_DELIVERY_RECEIPT_FINDS_ITS_TENANT }
2137
+ ];
2138
+
2139
+ export { MIGRATIONS, MIGRATION_000_BASELINE, MIGRATION_001_A_CAIXA_SABE_QUEM_FALOU_POR_ULTIMO, MIGRATION_002_A_CASA_ESCOLHE_POR_ONDE_O_WHATSAPP_SAI, MIGRATION_003_A_CAMPANHA_SAI_NO_RITMO_QUE_O_NUMERO_AGUENTA, MIGRATION_004_A_TELA_SABE_SE_PODE_FALAR_ANTES_DE_DEIXAR_ESCREVER, MIGRATION_005_A_CONVERSA_CONTA_AO_FUNIL_QUE_HOUVE_CONTATO, MIGRATION_006_A_CAIXA_AVISA_QUE_CHEGOU, MIGRATION_007_A_CONVERSA_DIZ_DE_QUEM_E_O_LEAD, MIGRATION_008_DAR_POR_VISTA_E_UMA_DECISAO, MIGRATION_009_A_MENSAGEM_INTEIRA_ATRAVESSA_O_REALTIME, MIGRATION_010_A_CHEGADA_E_ANUNCIADA_POR_CONTA, MIGRATION_011_O_ANUNCIO_E_UMA_CAMPAINHA_NAO_UM_RECADO, MIGRATION_012_A_RICH_MESSAGE_KEEPS_ITS_SHAPE, MIGRATION_013_A_MIDIA_FICA_CONOSCO, MIGRATION_014_CLAIMING_TARGETS_IS_SERVER_ONLY, MIGRATION_015_THE_SERVICE_DOOR_FOR_MESSAGING, MIGRATION_016_A_DELIVERY_RECEIPT_FINDS_ITS_TENANT };
2140
+ //# sourceMappingURL=main.js.map
2141
+ //# sourceMappingURL=main.js.map