@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.
- package/dist/AtendeAIPage-PBBMWIDW.js +92 -0
- package/dist/AtendeAIPage-PBBMWIDW.js.map +1 -0
- package/dist/ConversationsPage-4V4VLM4D.js +47 -0
- package/dist/ConversationsPage-4V4VLM4D.js.map +1 -0
- package/dist/ConversationsSettings-7UQYM26R.js +158 -0
- package/dist/ConversationsSettings-7UQYM26R.js.map +1 -0
- package/dist/chunk-3FZX3ZQ3.js +318 -0
- package/dist/chunk-3FZX3ZQ3.js.map +1 -0
- package/dist/chunk-HMWUU5O7.js +75 -0
- package/dist/chunk-HMWUU5O7.js.map +1 -0
- package/dist/chunk-LJKLHGFX.js +1268 -0
- package/dist/chunk-LJKLHGFX.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +79 -4103
- package/dist/index.js.map +1 -1
- package/dist/migrations/main.browser.js +23 -0
- package/dist/migrations/main.browser.js.map +1 -0
- package/dist/migrations/main.js +2141 -0
- package/dist/migrations/main.js.map +1 -0
- package/dist/views/ConversationsPage.d.ts.map +1 -1
- package/package.json +10 -4
|
@@ -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
|