@fayz-ai/plugin-conversations 0.23.14 → 0.24.0
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-BTRYZPM7.js +1274 -0
- package/dist/ConversationsPage-BTRYZPM7.js.map +1 -0
- package/dist/ConversationsSettings-Q3PGQMB3.js +158 -0
- package/dist/ConversationsSettings-Q3PGQMB3.js.map +1 -0
- package/dist/chunk-2JV6B366.js +319 -0
- package/dist/chunk-2JV6B366.js.map +1 -0
- package/dist/chunk-HMWUU5O7.js +75 -0
- package/dist/chunk-HMWUU5O7.js.map +1 -0
- package/dist/chunk-X7BP2JH6.js +74 -0
- package/dist/chunk-X7BP2JH6.js.map +1 -0
- package/dist/components/InboxWatcher.d.ts +6 -15
- package/dist/components/InboxWatcher.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +161 -4206
- package/dist/index.js.map +1 -1
- package/dist/integrations/evolution/connectorDef.d.ts +4 -0
- package/dist/integrations/evolution/connectorDef.d.ts.map +1 -0
- 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/ConversationList.d.ts.map +1 -1
- package/dist/views/ConversationsPage.d.ts.map +1 -1
- package/dist/views/WhatsAppConnectionFooter.d.ts +8 -0
- package/dist/views/WhatsAppConnectionFooter.d.ts.map +1 -0
- package/package.json +10 -4
- package/dist/components/ConversationsRailPanel.d.ts +0 -15
- package/dist/components/ConversationsRailPanel.d.ts.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,1432 +1,103 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
import
|
|
8
|
-
import {
|
|
1
|
+
import { isWaitingOnUs, assinarChegada, ConversationsContextProvider, useConversationsStore } from './chunk-X7BP2JH6.js';
|
|
2
|
+
export { isWaitingOnUs } from './chunk-X7BP2JH6.js';
|
|
3
|
+
import { T, listMessagingChannels, findDedicatedChannel, findFallbackChannel, isPendingChannelStatus, canClaimDedicated, CHANNEL_CLAIM_TIMELINE, WhatsAppProviders } from './chunk-2JV6B366.js';
|
|
4
|
+
export { WhatsAppProviders, listMessagingChannels } from './chunk-2JV6B366.js';
|
|
5
|
+
import { providersApi } from './chunk-HMWUU5O7.js';
|
|
6
|
+
export { providersApi } from './chunk-HMWUU5O7.js';
|
|
7
|
+
import * as React from 'react';
|
|
8
|
+
import React__default, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
|
9
|
+
import { jsxs, jsx } from 'react/jsx-runtime';
|
|
10
|
+
import { createConnectionStore, connectionRuns, connectionStatus, tl, lazyComponent, useActiveTenantId, registerTranslations, getSupabaseClientOptional, describeError, getActiveTenantId, countByTenant, CONNECTOR_RUNTIME_TOKEN_HEADER, connectorRuntimeToken, useTranslation, navigateApp } from '@fayz-ai/core';
|
|
11
|
+
import { defineSettingsTab, useNavContribution, playChime, notifySystem, requestNotificationPermission } from '@fayz-ai/admin';
|
|
9
12
|
import { createStore } from 'zustand/vanilla';
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
24
|
-
function useConversationsStore(selector) {
|
|
25
|
-
const store2 = React14__default.useContext(StoreContext);
|
|
26
|
-
if (!store2) throw new Error("useConversationsStore must be used within ConversationsPage");
|
|
27
|
-
return useStore(store2, selector);
|
|
28
|
-
}
|
|
29
|
-
function useConversationsConfig() {
|
|
30
|
-
return React14__default.useContext(ConfigContext);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// src/types.ts
|
|
34
|
-
var CHANNEL_LABELS = {
|
|
35
|
-
sms: "SMS",
|
|
36
|
-
whatsapp: "WhatsApp",
|
|
37
|
-
instagram: "Instagram",
|
|
38
|
-
email: "Email",
|
|
39
|
-
webchat: "Web Chat"
|
|
40
|
-
};
|
|
41
|
-
function isWaitingOnUs(conversation) {
|
|
42
|
-
if (conversation.status !== "open") return false;
|
|
43
|
-
if (conversation.acknowledgedAt && conversation.lastMessageAt && conversation.acknowledgedAt >= conversation.lastMessageAt) return false;
|
|
44
|
-
if (conversation.lastMessageDirection) return conversation.lastMessageDirection === "inbound";
|
|
45
|
-
return conversation.unreadCount > 0;
|
|
46
|
-
}
|
|
47
|
-
var CHANNEL_ICON = {
|
|
48
|
-
sms: Phone,
|
|
49
|
-
whatsapp: MessageSquare,
|
|
50
|
-
instagram: Instagram,
|
|
51
|
-
email: Mail,
|
|
52
|
-
webchat: Globe
|
|
53
|
-
};
|
|
54
|
-
var CHANNEL_ACCENT = {
|
|
55
|
-
whatsapp: { color: "#22c55e", badge: "bg-[#22c55e]/12 text-[#15803d] dark:text-[#4ade80]" },
|
|
56
|
-
sms: { color: "#6366f1", badge: "bg-[#6366f1]/12 text-[#4338ca] dark:text-[#a5b4fc]" },
|
|
57
|
-
instagram: { color: "#ec4899", badge: "bg-[#ec4899]/12 text-[#be185d] dark:text-[#f9a8d4]" },
|
|
58
|
-
email: { color: "#0ea5e9", badge: "bg-[#0ea5e9]/12 text-[#0369a1] dark:text-[#7dd3fc]" },
|
|
59
|
-
webchat: { color: "#f59e0b", badge: "bg-[#f59e0b]/12 text-[#b45309] dark:text-[#fcd34d]" }
|
|
60
|
-
};
|
|
61
|
-
function useMediaQuery(query) {
|
|
62
|
-
const [matches, setMatches] = React14__default.useState(
|
|
63
|
-
() => typeof window !== "undefined" && window.matchMedia(query).matches
|
|
64
|
-
);
|
|
65
|
-
React14__default.useEffect(() => {
|
|
66
|
-
if (typeof window === "undefined") return;
|
|
67
|
-
const mql = window.matchMedia(query);
|
|
68
|
-
const onChange = () => setMatches(mql.matches);
|
|
69
|
-
onChange();
|
|
70
|
-
mql.addEventListener("change", onChange);
|
|
71
|
-
return () => mql.removeEventListener("change", onChange);
|
|
72
|
-
}, [query]);
|
|
73
|
-
return matches;
|
|
74
|
-
}
|
|
75
|
-
function initialsOf(name) {
|
|
76
|
-
return name.replace("@", "").split(/\s+/).filter(Boolean).map((w) => w[0]).slice(0, 2).join("").toUpperCase();
|
|
77
|
-
}
|
|
78
|
-
function Avatar({ name, accent, size = "md", channel }) {
|
|
79
|
-
const dims = size === "lg" ? "h-14 w-14 text-lg" : size === "sm" ? "h-9 w-9 text-xs" : "h-10 w-10 text-sm";
|
|
80
|
-
const Icon = channel ? CHANNEL_ICON[channel] : null;
|
|
81
|
-
return /* @__PURE__ */ jsxs("div", { className: "relative shrink-0", children: [
|
|
82
|
-
/* @__PURE__ */ jsx(
|
|
83
|
-
"div",
|
|
84
|
-
{
|
|
85
|
-
className: cn("flex items-center justify-center rounded-full font-semibold text-white", dims),
|
|
86
|
-
style: { backgroundColor: accent },
|
|
87
|
-
children: initialsOf(name)
|
|
88
|
-
}
|
|
89
|
-
),
|
|
90
|
-
Icon && /* @__PURE__ */ jsx(
|
|
91
|
-
"span",
|
|
92
|
-
{
|
|
93
|
-
className: "absolute -bottom-0.5 -right-0.5 flex h-4 w-4 items-center justify-center rounded-full ring-2 ring-card",
|
|
94
|
-
style: { backgroundColor: CHANNEL_ACCENT[channel].color },
|
|
95
|
-
children: /* @__PURE__ */ jsx(Icon, { className: "h-2.5 w-2.5 text-white" })
|
|
96
|
-
}
|
|
97
|
-
)
|
|
98
|
-
] });
|
|
99
|
-
}
|
|
100
|
-
function ChannelBadge({ channel, className }) {
|
|
101
|
-
const Icon = CHANNEL_ICON[channel];
|
|
102
|
-
return /* @__PURE__ */ jsxs(
|
|
103
|
-
"span",
|
|
104
|
-
{
|
|
105
|
-
className: cn(
|
|
106
|
-
"inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium",
|
|
107
|
-
CHANNEL_ACCENT[channel].badge,
|
|
108
|
-
className
|
|
109
|
-
),
|
|
110
|
-
children: [
|
|
111
|
-
/* @__PURE__ */ jsx(Icon, { className: "h-2.5 w-2.5" }),
|
|
112
|
-
CHANNEL_LABELS[channel]
|
|
113
|
-
]
|
|
114
|
-
}
|
|
115
|
-
);
|
|
116
|
-
}
|
|
117
|
-
function relativeTime(iso) {
|
|
118
|
-
const diff = Date.now() - new Date(iso).getTime();
|
|
119
|
-
const mins = Math.round(diff / 6e4);
|
|
120
|
-
if (mins < 1) return "now";
|
|
121
|
-
if (mins < 60) return `${mins}m`;
|
|
122
|
-
const hours = Math.round(mins / 60);
|
|
123
|
-
if (hours < 24) return `${hours}h`;
|
|
124
|
-
const days = Math.round(hours / 24);
|
|
125
|
-
if (days < 7) return `${days}d`;
|
|
126
|
-
return `${Math.round(days / 7)}w`;
|
|
127
|
-
}
|
|
128
|
-
function dayLabel(iso) {
|
|
129
|
-
const d = new Date(iso);
|
|
130
|
-
const now = /* @__PURE__ */ new Date();
|
|
131
|
-
const startOf = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
|
|
132
|
-
const dayDiff = Math.round((startOf(now) - startOf(d)) / 864e5);
|
|
133
|
-
if (dayDiff <= 0) return "Today";
|
|
134
|
-
if (dayDiff === 1) return "Yesterday";
|
|
135
|
-
if (dayDiff < 7) return d.toLocaleDateString([], { weekday: "long" });
|
|
136
|
-
return d.toLocaleDateString([], { month: "short", day: "numeric", year: now.getFullYear() === d.getFullYear() ? void 0 : "numeric" });
|
|
137
|
-
}
|
|
138
|
-
var FN = "conversations-provider";
|
|
139
|
-
function client() {
|
|
140
|
-
const c = getSupabaseClientOptional();
|
|
141
|
-
if (!c) throw new Error("Supabase n\xE3o inicializado");
|
|
142
|
-
return c;
|
|
143
|
-
}
|
|
144
|
-
async function callFn(action, body = {}) {
|
|
145
|
-
const tenantId = getActiveTenantId();
|
|
146
|
-
if (!tenantId) throw new Error("Nenhuma casa ativa");
|
|
147
|
-
const { data, error } = await client().functions.invoke(FN, {
|
|
148
|
-
body: { tenantId, action, ...body }
|
|
149
|
-
});
|
|
150
|
-
if (error) {
|
|
151
|
-
const detail = await error.context?.json?.().catch(() => null);
|
|
152
|
-
throw new Error(detail?.message ?? error.message);
|
|
153
|
-
}
|
|
154
|
-
if (data?.error) throw new Error(data.message ?? data.error);
|
|
155
|
-
return data;
|
|
156
|
-
}
|
|
157
|
-
var providersApi = {
|
|
158
|
-
/** O que está configurado nesta casa. Sem segredo. */
|
|
159
|
-
async list() {
|
|
160
|
-
const tenantId = getActiveTenantId();
|
|
161
|
-
if (!tenantId) return [];
|
|
162
|
-
const { data, error } = await client().from("v_conversations_providers").select("provider, config, capabilities, is_active, status, status_detail, checked_at, has_credential").eq("tenant_id", tenantId);
|
|
163
|
-
if (error) throw new Error(error.message);
|
|
164
|
-
return (data ?? []).map((r) => ({
|
|
165
|
-
provider: r.provider,
|
|
166
|
-
config: r.config ?? {},
|
|
167
|
-
capabilities: r.capabilities ?? {},
|
|
168
|
-
isActive: Boolean(r.is_active),
|
|
169
|
-
status: r.status,
|
|
170
|
-
statusDetail: r.status_detail ?? null,
|
|
171
|
-
checkedAt: r.checked_at ?? null,
|
|
172
|
-
hasCredential: Boolean(r.has_credential)
|
|
173
|
-
}));
|
|
174
|
-
},
|
|
175
|
-
/** Guarda config e, quando vier, a chave. Chave ausente é "não mexe": salvar
|
|
176
|
-
* a URL do Evolution sem redigitar a chave é o gesto comum. */
|
|
177
|
-
async save(provider, config, apiKey) {
|
|
178
|
-
const tenantId = getActiveTenantId();
|
|
179
|
-
if (!tenantId) throw new Error("Nenhuma casa ativa");
|
|
180
|
-
const { error } = await client().rpc("conversations_set_provider", {
|
|
181
|
-
p_tenant: tenantId,
|
|
182
|
-
p_provider: provider,
|
|
183
|
-
p_config: config,
|
|
184
|
-
p_api_key: apiKey && apiKey.trim() ? apiKey.trim() : null
|
|
185
|
-
});
|
|
186
|
-
if (error) throw new Error(error.message);
|
|
187
|
-
},
|
|
188
|
-
/** A troca. Um ativo por casa, garantido por índice único no banco. */
|
|
189
|
-
async activate(provider) {
|
|
190
|
-
const tenantId = getActiveTenantId();
|
|
191
|
-
if (!tenantId) throw new Error("Nenhuma casa ativa");
|
|
192
|
-
const { error } = await client().rpc("conversations_activate_provider", {
|
|
193
|
-
p_tenant: tenantId,
|
|
194
|
-
p_provider: provider
|
|
195
|
-
});
|
|
196
|
-
if (error) throw new Error(error.message);
|
|
197
|
-
},
|
|
198
|
-
health: (provider) => callFn("health", { provider }),
|
|
199
|
-
numbers: (provider) => callFn("numbers", { provider }),
|
|
200
|
-
/** O QR do Evolution. O Tyxter recusa com `unsupported`. */
|
|
201
|
-
pair: (provider) => callFn("pair", { provider }),
|
|
202
|
-
templates: (provider) => callFn("templates", { provider }),
|
|
203
|
-
createTemplate: (template, provider) => callFn("create_template", { template, provider }),
|
|
204
|
-
send: (input) => callFn("send", input)
|
|
205
|
-
};
|
|
206
|
-
|
|
207
|
-
// src/data/channel-connections.ts
|
|
208
|
-
var whatsappCache = null;
|
|
209
|
-
var whatsappConnected = () => {
|
|
210
|
-
whatsappCache ?? (whatsappCache = providersApi.list().then((rows) => rows.some((r) => r.isActive || r.hasCredential)).catch(() => {
|
|
211
|
-
whatsappCache = null;
|
|
212
|
-
return false;
|
|
213
|
-
}));
|
|
214
|
-
return whatsappCache;
|
|
215
|
-
};
|
|
216
|
-
function useChannelConnections() {
|
|
217
|
-
const [whatsapp, setWhatsapp] = React14.useState(void 0);
|
|
218
|
-
const { connectors } = useConnectors({ hostPluginId: "conversations", includeGrants: false });
|
|
219
|
-
React14.useEffect(() => {
|
|
220
|
-
let cancelled = false;
|
|
221
|
-
void whatsappConnected().then((v) => {
|
|
222
|
-
if (!cancelled) setWhatsapp(v);
|
|
223
|
-
});
|
|
224
|
-
return () => {
|
|
225
|
-
cancelled = true;
|
|
226
|
-
};
|
|
227
|
-
}, []);
|
|
228
|
-
return React14.useMemo(() => {
|
|
229
|
-
if (whatsapp === void 0) return void 0;
|
|
230
|
-
const live = (needle) => (connectors ?? []).some((c) => c.state === "connected" && c.slug.toLowerCase().includes(needle));
|
|
231
|
-
return { whatsapp: whatsapp || live("whatsapp"), email: live("mail"), instagram: live("instagram") };
|
|
232
|
-
}, [whatsapp, connectors]);
|
|
233
|
-
}
|
|
234
|
-
var FILTERS = ["all", "whatsapp", "instagram", "email"];
|
|
235
|
-
function ConversationList({ className }) {
|
|
236
|
-
const t = useTranslation();
|
|
237
|
-
const connected = useChannelConnections();
|
|
238
|
-
const {
|
|
239
|
-
conversations,
|
|
240
|
-
selectedId,
|
|
241
|
-
channelFilter,
|
|
242
|
-
leadOwnerFilter,
|
|
243
|
-
search,
|
|
244
|
-
loading,
|
|
245
|
-
select,
|
|
246
|
-
markRead,
|
|
247
|
-
setChannelFilter,
|
|
248
|
-
setLeadOwnerFilter,
|
|
249
|
-
setSearch
|
|
250
|
-
} = useConversationsStore((s) => s);
|
|
251
|
-
const donos = React14__default.useMemo(() => {
|
|
252
|
-
const vistos = /* @__PURE__ */ new Map();
|
|
253
|
-
for (const c of conversations) {
|
|
254
|
-
if (c.leadOwnerId) vistos.set(c.leadOwnerId, c.leadOwnerName ?? c.leadOwnerId);
|
|
255
|
-
}
|
|
256
|
-
return [...vistos].map(([value, label]) => ({ value, label }));
|
|
257
|
-
}, [conversations]);
|
|
258
|
-
return /* @__PURE__ */ jsxs("aside", { className: cn("w-full shrink-0 flex-col border-r border-border bg-card lg:w-[320px]", className), children: [
|
|
259
|
-
/* @__PURE__ */ jsxs("div", { className: "border-b border-border px-3 py-3", children: [
|
|
260
|
-
/* @__PURE__ */ jsxs("div", { className: "relative", children: [
|
|
261
|
-
/* @__PURE__ */ jsx(Search, { className: "pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" }),
|
|
262
|
-
/* @__PURE__ */ jsx(
|
|
263
|
-
Input,
|
|
264
|
-
{
|
|
265
|
-
value: search,
|
|
266
|
-
onChange: (e) => setSearch(e.target.value),
|
|
267
|
-
placeholder: t("conversations.list.search"),
|
|
268
|
-
className: "pl-8"
|
|
269
|
-
}
|
|
270
|
-
)
|
|
271
|
-
] }),
|
|
272
|
-
/* @__PURE__ */ jsx("div", { className: "mt-2 flex flex-wrap gap-1", children: FILTERS.map((id) => {
|
|
273
|
-
const off = id !== "all" && connected !== void 0 && !connected[id];
|
|
274
|
-
return /* @__PURE__ */ jsx(
|
|
275
|
-
Button,
|
|
276
|
-
{
|
|
277
|
-
variant: "bare",
|
|
278
|
-
size: "bare",
|
|
279
|
-
onClick: () => setChannelFilter(id),
|
|
280
|
-
title: off ? t("conversations.filter.notConnected") : void 0,
|
|
281
|
-
className: cn(
|
|
282
|
-
"rounded-full border px-2.5 py-1 text-xs font-medium transition-colors",
|
|
283
|
-
channelFilter === id ? "border-transparent bg-primary text-primary-foreground" : off ? "border-dashed border-muted-foreground/40 bg-transparent text-muted-foreground hover:bg-muted/50" : "border-transparent bg-muted text-muted-foreground hover:bg-muted/70"
|
|
284
|
-
),
|
|
285
|
-
children: t(`conversations.filter.${id}`)
|
|
286
|
-
},
|
|
287
|
-
id
|
|
288
|
-
);
|
|
289
|
-
}) }),
|
|
290
|
-
donos.length > 0 && /* @__PURE__ */ jsx("div", { className: "mt-2", children: /* @__PURE__ */ jsx(
|
|
291
|
-
FilterChip,
|
|
292
|
-
{
|
|
293
|
-
label: t("conversations.contact.leadOwner"),
|
|
294
|
-
icon: User,
|
|
295
|
-
allLabel: t("conversations.filter.anyOwner"),
|
|
296
|
-
options: [{ value: "none", label: t("conversations.filter.noOwner") }, ...donos],
|
|
297
|
-
value: leadOwnerFilter,
|
|
298
|
-
onChange: (v) => {
|
|
299
|
-
void setLeadOwnerFilter(v);
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
) })
|
|
303
|
-
] }),
|
|
304
|
-
/* @__PURE__ */ jsxs("div", { className: "min-h-0 flex-1 overflow-y-auto", children: [
|
|
305
|
-
loading && conversations.length === 0 && Array.from({ length: 6 }, (_, i) => /* @__PURE__ */ jsxs("div", { className: "flex w-full items-start gap-3 border-b border-border/50 px-3 py-3", children: [
|
|
306
|
-
/* @__PURE__ */ jsx(Skeleton, { className: "h-10 w-10 shrink-0 rounded-full" }),
|
|
307
|
-
/* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
|
|
308
|
-
/* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2", children: [
|
|
309
|
-
/* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-28" }),
|
|
310
|
-
/* @__PURE__ */ jsx(Skeleton, { className: "h-3 w-8" })
|
|
311
|
-
] }),
|
|
312
|
-
/* @__PURE__ */ jsx(Skeleton, { className: "mt-1.5 h-4 w-16 rounded-full" }),
|
|
313
|
-
/* @__PURE__ */ jsx(Skeleton, { className: "mt-1.5 h-3 w-3/4" })
|
|
314
|
-
] })
|
|
315
|
-
] }, i)),
|
|
316
|
-
!loading && conversations.length === 0 && /* @__PURE__ */ jsxs("div", { className: "flex flex-col items-center gap-2 p-8 text-center text-muted-foreground", children: [
|
|
317
|
-
/* @__PURE__ */ jsx(Inbox, { className: "h-6 w-6" }),
|
|
318
|
-
/* @__PURE__ */ jsx("p", { className: "text-sm", children: t("conversations.list.empty") })
|
|
319
|
-
] }),
|
|
320
|
-
conversations.map((c) => {
|
|
321
|
-
const active = c.id === selectedId;
|
|
322
|
-
const unread = isWaitingOnUs(c);
|
|
323
|
-
return (
|
|
324
|
-
// `div` em volta e não um botão dentro de outro: HTML não permite
|
|
325
|
-
// aninhar, e o navegador resolve isso silenciosamente movendo o
|
|
326
|
-
// interno para fora — a linha inteira deixa de ser clicável e
|
|
327
|
-
// ninguém entende por quê.
|
|
328
|
-
/* @__PURE__ */ jsxs("div", { className: "group relative", children: [
|
|
329
|
-
/* @__PURE__ */ jsxs(
|
|
330
|
-
Button,
|
|
331
|
-
{
|
|
332
|
-
variant: "bare",
|
|
333
|
-
size: "bare",
|
|
334
|
-
onClick: () => select(c.id),
|
|
335
|
-
className: cn(
|
|
336
|
-
"flex w-full items-start justify-start gap-3 rounded-none border-b border-border/50 px-3 py-3 text-left font-normal transition-colors",
|
|
337
|
-
// Um TOM da cor de marca, e não `bg-accent`.
|
|
338
|
-
//
|
|
339
|
-
// `bg-accent` resolve para quase preto neste tema, e a linha da
|
|
340
|
-
// conversa aberta ficava escura sobre escura. Trocar junto o
|
|
341
|
-
// `text-accent-foreground` deixava legível, mas pintava de
|
|
342
|
-
// preto a única linha que se está a ler — chamava mais atenção
|
|
343
|
-
// que a conversa.
|
|
344
|
-
//
|
|
345
|
-
// `bg-primary/10` é um véu: o texto continua com as cores dele,
|
|
346
|
-
// a seleção se lê como seleção e não como bloco, e funciona nos
|
|
347
|
-
// dois temas porque não presume nada sobre o fundo.
|
|
348
|
-
active ? "bg-primary/10" : "hover:bg-muted/50"
|
|
349
|
-
),
|
|
350
|
-
children: [
|
|
351
|
-
/* @__PURE__ */ jsx(Avatar, { name: c.contactName, accent: c.accent, channel: c.channel }),
|
|
352
|
-
/* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
|
|
353
|
-
/* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2", children: [
|
|
354
|
-
/* @__PURE__ */ jsx("span", { className: cn("truncate text-sm text-foreground", unread ? "font-semibold" : "font-medium"), children: c.contactName }),
|
|
355
|
-
/* @__PURE__ */ jsx("span", { className: cn("shrink-0 text-[11px]", unread ? "font-semibold text-primary" : "text-muted-foreground"), children: relativeTime(c.lastMessageAt) })
|
|
356
|
-
] }),
|
|
357
|
-
/* @__PURE__ */ jsxs("div", { className: "mt-1 flex items-center gap-2", children: [
|
|
358
|
-
/* @__PURE__ */ jsx(ChannelBadge, { channel: c.channel }),
|
|
359
|
-
c.status !== "open" && /* @__PURE__ */ jsx("span", { className: "text-[10px] uppercase tracking-wide text-muted-foreground/70", children: t(`conversations.status.${c.status}`) }),
|
|
360
|
-
c.leadOwnerName && /* @__PURE__ */ jsxs("span", { className: "inline-flex shrink-0 items-center gap-0.5 rounded-full bg-muted px-1.5 text-[10px] text-muted-foreground", children: [
|
|
361
|
-
/* @__PURE__ */ jsx(User, { className: "h-2.5 w-2.5" }),
|
|
362
|
-
c.leadOwnerName.split(" ")[0]
|
|
363
|
-
] })
|
|
364
|
-
] }),
|
|
365
|
-
/* @__PURE__ */ jsxs("div", { className: "mt-1 flex items-center justify-between gap-2", children: [
|
|
366
|
-
/* @__PURE__ */ jsx("span", { className: cn("truncate text-xs", unread ? "text-foreground" : "text-muted-foreground"), children: c.lastMessagePreview }),
|
|
367
|
-
unread && /* @__PURE__ */ jsx("span", { className: "flex h-[18px] min-w-[18px] shrink-0 items-center justify-center rounded-full bg-destructive px-1.5 text-[10px] font-semibold text-destructive-foreground", children: c.unreadCount > 0 ? c.unreadCount : "\xB7" })
|
|
368
|
-
] })
|
|
369
|
-
] })
|
|
370
|
-
]
|
|
371
|
-
}
|
|
372
|
-
),
|
|
373
|
-
unread && /* @__PURE__ */ jsx(
|
|
374
|
-
Button,
|
|
375
|
-
{
|
|
376
|
-
variant: "bare",
|
|
377
|
-
size: "bare",
|
|
378
|
-
type: "button",
|
|
379
|
-
title: t("conversations.markRead"),
|
|
380
|
-
"aria-label": t("conversations.markRead"),
|
|
381
|
-
onClick: (e) => {
|
|
382
|
-
e.stopPropagation();
|
|
383
|
-
void markRead(c.id);
|
|
384
|
-
},
|
|
385
|
-
className: "absolute right-2 top-2 hidden rounded-full p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground group-hover:block",
|
|
386
|
-
children: /* @__PURE__ */ jsx(CheckCheck, { className: "h-3.5 w-3.5" })
|
|
387
|
-
}
|
|
388
|
-
)
|
|
389
|
-
] }, c.id)
|
|
390
|
-
);
|
|
391
|
-
})
|
|
392
|
-
] })
|
|
393
|
-
] });
|
|
394
|
-
}
|
|
395
|
-
function useSendWindow(conversationId) {
|
|
396
|
-
const [row, setRow] = React14__default.useState(null);
|
|
397
|
-
const [providers, setProviders] = React14__default.useState(null);
|
|
398
|
-
React14__default.useEffect(() => {
|
|
399
|
-
let alive = true;
|
|
400
|
-
providersApi.list().then((p) => {
|
|
401
|
-
if (alive) setProviders(p);
|
|
402
|
-
}).catch(() => {
|
|
403
|
-
if (alive) setProviders([]);
|
|
404
|
-
});
|
|
405
|
-
return () => {
|
|
406
|
-
alive = false;
|
|
13
|
+
import { Input, Button, toast, defineKpiWidget, defineTableWidget, KpiCard, TableWidget } from '@fayz-ai/ui';
|
|
14
|
+
import { Loader2, ExternalLink } from 'lucide-react';
|
|
15
|
+
import { MIGRATIONS } from '#migrations/main';
|
|
16
|
+
|
|
17
|
+
var EVOLUTION_CONNECTOR_ID = "evolution";
|
|
18
|
+
async function evolutionStatus() {
|
|
19
|
+
const row = (await providersApi.list()).find((r) => r.provider === EVOLUTION_CONNECTOR_ID);
|
|
20
|
+
if (!row || row.status === "unconfigured") return { connected: false };
|
|
21
|
+
if (row.status === "connected") {
|
|
22
|
+
return {
|
|
23
|
+
connected: true,
|
|
24
|
+
plane: "request",
|
|
25
|
+
detail: row.isActive ? "Valendo agora" : "Conectado, mas outro provedor est\xE1 valendo"
|
|
407
26
|
};
|
|
408
|
-
}, []);
|
|
409
|
-
React14__default.useEffect(() => {
|
|
410
|
-
let alive = true;
|
|
411
|
-
if (!conversationId) {
|
|
412
|
-
setRow(null);
|
|
413
|
-
return;
|
|
414
|
-
}
|
|
415
|
-
const sb = getSupabaseClientOptional();
|
|
416
|
-
if (!sb) {
|
|
417
|
-
setRow(null);
|
|
418
|
-
return;
|
|
419
|
-
}
|
|
420
|
-
sb.from("v_conversations_window").select("window_open, window_expires_at, minutes_left, last_inbound_at").eq("conversation_id", conversationId).eq("tenant_id", getActiveTenantId()).maybeSingle().then(({ data }) => {
|
|
421
|
-
if (alive) setRow(data ?? null);
|
|
422
|
-
}).catch(() => {
|
|
423
|
-
if (alive) setRow(null);
|
|
424
|
-
});
|
|
425
|
-
return () => {
|
|
426
|
-
alive = false;
|
|
427
|
-
};
|
|
428
|
-
}, [conversationId]);
|
|
429
|
-
const provider = (providers ?? []).find((p) => p.isActive);
|
|
430
|
-
const mode = React14__default.useMemo(() => {
|
|
431
|
-
if (!provider) return { kind: "blocked", reason: "no_provider" };
|
|
432
|
-
if (!provider.capabilities?.requires_template) {
|
|
433
|
-
return { kind: "free", until: null, minutesLeft: null };
|
|
434
|
-
}
|
|
435
|
-
if (row?.window_open) {
|
|
436
|
-
return { kind: "free", until: row.window_expires_at, minutesLeft: row.minutes_left };
|
|
437
|
-
}
|
|
438
|
-
return { kind: "template", reason: row?.last_inbound_at ? "window_closed" : "never_replied" };
|
|
439
|
-
}, [row, provider]);
|
|
440
|
-
return { loading: providers === null, mode, provider };
|
|
441
|
-
}
|
|
442
|
-
function humanLeft(minutes) {
|
|
443
|
-
if (minutes == null) return "";
|
|
444
|
-
if (minutes < 60) return `${minutes} min`;
|
|
445
|
-
const h = Math.floor(minutes / 60);
|
|
446
|
-
return h < 24 ? `${h}h` : `${Math.floor(h / 24)}d`;
|
|
447
|
-
}
|
|
448
|
-
function SendWindowBanner({ state }) {
|
|
449
|
-
const { mode, provider } = state;
|
|
450
|
-
if (mode.kind === "free" && mode.minutesLeft == null) return null;
|
|
451
|
-
if (mode.kind === "free") {
|
|
452
|
-
const urgent = (mode.minutesLeft ?? 0) < 120;
|
|
453
|
-
return /* @__PURE__ */ jsxs("div", { className: `flex items-center gap-2 px-4 py-1.5 text-[11px] ${urgent ? "bg-warning-soft text-warning-soft-foreground" : "text-muted-foreground"}`, children: [
|
|
454
|
-
/* @__PURE__ */ jsx("span", { className: "h-1.5 w-1.5 rounded-full bg-success" }),
|
|
455
|
-
"Janela aberta \u2014 texto livre por mais ",
|
|
456
|
-
humanLeft(mode.minutesLeft)
|
|
457
|
-
] });
|
|
458
|
-
}
|
|
459
|
-
if (mode.kind === "blocked") {
|
|
460
|
-
return /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 bg-destructive-soft px-4 py-2 text-[11px] text-destructive-soft-foreground", children: [
|
|
461
|
-
/* @__PURE__ */ jsx("span", { className: "h-1.5 w-1.5 rounded-full bg-destructive" }),
|
|
462
|
-
"Nenhum provedor de WhatsApp ativo \u2014 configure em Ajustes \u203A Conversas."
|
|
463
|
-
] });
|
|
464
27
|
}
|
|
465
|
-
return
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
"No ",
|
|
470
|
-
provider?.provider ?? "provedor",
|
|
471
|
-
", texto livre s\xF3 sai dentro de 24h da resposta do cliente. Para falar agora \xE9 preciso um template aprovado."
|
|
472
|
-
] })
|
|
473
|
-
] });
|
|
474
|
-
}
|
|
475
|
-
var STATUS = {
|
|
476
|
-
queued: { icon: Clock, label: "na fila", cls: "text-white/60" },
|
|
477
|
-
sent: { icon: Check, label: "enviada", cls: "text-white/70" },
|
|
478
|
-
delivered: { icon: CheckCheck, label: "entregue", cls: "text-white/80" },
|
|
479
|
-
read: { icon: CheckCheck, label: "lida", cls: "text-sky-200" },
|
|
480
|
-
failed: { icon: AlertTriangle, label: "N\xC3O ENVIADA", cls: "text-white" },
|
|
481
|
-
expired: { icon: AlertTriangle, label: "expirou", cls: "text-white" },
|
|
482
|
-
delivery_timeout: { icon: AlertTriangle, label: "sem confirma\xE7\xE3o", cls: "text-white" },
|
|
483
|
-
cancelled: { icon: X, label: "cancelada", cls: "text-white" },
|
|
484
|
-
opted_out: { icon: X, label: "recusada", cls: "text-white" }
|
|
485
|
-
};
|
|
486
|
-
var BAD = /* @__PURE__ */ new Set(["failed", "expired", "cancelled", "opted_out", "delivery_timeout"]);
|
|
487
|
-
function isFailed(m) {
|
|
488
|
-
if (m.direction !== "outbound") return false;
|
|
489
|
-
if (m.deliveryStatus) return BAD.has(m.deliveryStatus);
|
|
490
|
-
if (m.providerMessageId) return false;
|
|
491
|
-
const age = Date.now() - new Date(m.at).getTime();
|
|
492
|
-
return Number.isFinite(age) && age > 12e4;
|
|
493
|
-
}
|
|
494
|
-
function DeliveryMark({ m }) {
|
|
495
|
-
if (m.direction !== "outbound") return null;
|
|
496
|
-
const key = isFailed(m) ? "failed" : m.deliveryStatus ?? "sent";
|
|
497
|
-
const cfg = STATUS[key] ?? STATUS.sent;
|
|
498
|
-
const Icon = cfg.icon;
|
|
499
|
-
return /* @__PURE__ */ jsxs("span", { className: `inline-flex items-center gap-0.5 ${cfg.cls}`, title: cfg.label, children: [
|
|
500
|
-
/* @__PURE__ */ jsx(Icon, { className: "h-3 w-3" }),
|
|
501
|
-
BAD.has(key) && /* @__PURE__ */ jsx("span", { className: "text-[9px] font-semibold uppercase", children: cfg.label })
|
|
502
|
-
] });
|
|
503
|
-
}
|
|
504
|
-
function MessageMenu({ m }) {
|
|
505
|
-
const [open, setOpen] = React14__default.useState(false);
|
|
506
|
-
const [detail, setDetail] = React14__default.useState(null);
|
|
507
|
-
const [loading, setLoading] = React14__default.useState(false);
|
|
508
|
-
const [err, setErr] = React14__default.useState(null);
|
|
509
|
-
async function load() {
|
|
510
|
-
setOpen(true);
|
|
511
|
-
if (!m.providerMessageId) return;
|
|
512
|
-
setLoading(true);
|
|
513
|
-
setErr(null);
|
|
514
|
-
try {
|
|
515
|
-
const sb = getSupabaseClientOptional();
|
|
516
|
-
const { data, error } = await sb.functions.invoke("conversations-provider", {
|
|
517
|
-
body: { tenantId: getActiveTenantId(), action: "message_detail", providerMessageId: m.providerMessageId }
|
|
518
|
-
});
|
|
519
|
-
if (error) {
|
|
520
|
-
const d = await error.context?.json?.().catch(() => null);
|
|
521
|
-
throw new Error(d?.message ?? error.message);
|
|
522
|
-
}
|
|
523
|
-
if (data?.error) throw new Error(data.message ?? data.error);
|
|
524
|
-
setDetail(data.detail);
|
|
525
|
-
} catch (e) {
|
|
526
|
-
setErr(e?.message ?? "N\xE3o consegui buscar");
|
|
527
|
-
} finally {
|
|
528
|
-
setLoading(false);
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
532
|
-
/* @__PURE__ */ jsx(
|
|
533
|
-
Button,
|
|
534
|
-
{
|
|
535
|
-
variant: "bare",
|
|
536
|
-
size: "bare",
|
|
537
|
-
onClick: () => void load(),
|
|
538
|
-
"aria-label": "Detalhes da mensagem",
|
|
539
|
-
className: "opacity-0 transition-opacity group-hover:opacity-60 hover:!opacity-100",
|
|
540
|
-
children: /* @__PURE__ */ jsx(MoreVertical, { className: "h-3.5 w-3.5" })
|
|
541
|
-
}
|
|
542
|
-
),
|
|
543
|
-
open && /* @__PURE__ */ jsx("div", { className: "fixed inset-0 z-[120] flex items-center justify-center bg-black/50 p-4", onClick: () => setOpen(false), children: /* @__PURE__ */ jsxs("div", { className: "max-h-[80vh] w-full max-w-2xl overflow-hidden rounded-card bg-card shadow-2xl", onClick: (e) => e.stopPropagation(), children: [
|
|
544
|
-
/* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between border-b px-4 py-3", children: [
|
|
545
|
-
/* @__PURE__ */ jsxs("div", { children: [
|
|
546
|
-
/* @__PURE__ */ jsx("p", { className: "text-sm font-semibold", children: "Detalhes da mensagem" }),
|
|
547
|
-
/* @__PURE__ */ jsxs("p", { className: "text-[11px] text-muted-foreground", children: [
|
|
548
|
-
new Date(m.at).toLocaleString(),
|
|
549
|
-
" \xB7 ",
|
|
550
|
-
m.direction === "outbound" ? "sa\xEDda" : "entrada",
|
|
551
|
-
m.deliveryStatus ? ` \xB7 ${m.deliveryStatus}` : ""
|
|
552
|
-
] })
|
|
553
|
-
] }),
|
|
554
|
-
/* @__PURE__ */ jsx(Button, { variant: "bare", size: "bare", onClick: () => setOpen(false), "aria-label": "Fechar", children: /* @__PURE__ */ jsx(X, { className: "h-4 w-4" }) })
|
|
555
|
-
] }),
|
|
556
|
-
/* @__PURE__ */ jsxs("div", { className: "max-h-[60vh] overflow-auto p-4 text-xs", children: [
|
|
557
|
-
/* @__PURE__ */ jsx("p", { className: "mb-1 font-medium text-muted-foreground", children: "Nesta base" }),
|
|
558
|
-
/* @__PURE__ */ jsx("pre", { className: "mb-4 overflow-auto rounded bg-muted/40 p-3 text-[11px] leading-relaxed", children: JSON.stringify({
|
|
559
|
-
id: m.id,
|
|
560
|
-
direction: m.direction,
|
|
561
|
-
body: m.body,
|
|
562
|
-
at: m.at,
|
|
563
|
-
author: m.author,
|
|
564
|
-
deliveryStatus: m.deliveryStatus,
|
|
565
|
-
providerMessageId: m.providerMessageId
|
|
566
|
-
}, null, 2) }),
|
|
567
|
-
/* @__PURE__ */ jsx("p", { className: "mb-1 font-medium text-muted-foreground", children: "No provedor" }),
|
|
568
|
-
!m.providerMessageId && // A ausência é a informação: sem id, ninguém do outro lado
|
|
569
|
-
// chegou a aceitar esta mensagem. Ela não atrasou — ela não saiu.
|
|
570
|
-
/* @__PURE__ */ jsx("p", { className: "rounded bg-destructive-soft p-3 text-[11px] text-destructive-soft-foreground", children: "Esta mensagem n\xE3o tem id no provedor \u2014 ela nunca chegou a ser aceita para envio. N\xE3o h\xE1 o que buscar do outro lado." }),
|
|
571
|
-
loading && /* @__PURE__ */ jsxs("p", { className: "flex items-center gap-2 p-3 text-muted-foreground", children: [
|
|
572
|
-
/* @__PURE__ */ jsx(Loader2, { className: "h-3 w-3 animate-spin" }),
|
|
573
|
-
" buscando\u2026"
|
|
574
|
-
] }),
|
|
575
|
-
err && /* @__PURE__ */ jsx("p", { className: "rounded bg-destructive-soft p-3 text-[11px] text-destructive-soft-foreground", children: err }),
|
|
576
|
-
detail != null && /* @__PURE__ */ jsx("pre", { className: "overflow-auto rounded bg-muted/40 p-3 text-[11px] leading-relaxed", children: JSON.stringify(detail, null, 2) })
|
|
577
|
-
] })
|
|
578
|
-
] }) })
|
|
579
|
-
] });
|
|
580
|
-
}
|
|
581
|
-
var COM_ARQUIVO = /* @__PURE__ */ new Set(["image", "video", "audio", "document", "sticker"]);
|
|
582
|
-
function temArquivo(m) {
|
|
583
|
-
return !!m.mediaPath && COM_ARQUIVO.has(String(m.messageType));
|
|
584
|
-
}
|
|
585
|
-
function useUrlAssinada(m, visivel) {
|
|
586
|
-
const [url, setUrl] = React14.useState(null);
|
|
587
|
-
const [erro, setErro] = React14.useState(false);
|
|
588
|
-
React14.useEffect(() => {
|
|
589
|
-
if (!visivel || url || erro || !temArquivo(m)) return;
|
|
590
|
-
let vivo = true;
|
|
591
|
-
(async () => {
|
|
592
|
-
try {
|
|
593
|
-
const sb = getSupabaseClientOptional();
|
|
594
|
-
const { data, error } = await sb.functions.invoke("conversations-provider", {
|
|
595
|
-
body: { tenantId: getActiveTenantId(), action: "media_url", messageId: m.id }
|
|
596
|
-
});
|
|
597
|
-
if (error || data?.error) throw new Error("falhou");
|
|
598
|
-
if (vivo && data?.url) setUrl(data.url);
|
|
599
|
-
else if (vivo) setErro(true);
|
|
600
|
-
} catch {
|
|
601
|
-
if (vivo) setErro(true);
|
|
602
|
-
}
|
|
603
|
-
})();
|
|
604
|
-
return () => {
|
|
605
|
-
vivo = false;
|
|
606
|
-
};
|
|
607
|
-
}, [visivel, m.id, url, erro]);
|
|
608
|
-
return { url, erro };
|
|
609
|
-
}
|
|
610
|
-
function tamanho(bytes) {
|
|
611
|
-
if (!bytes || bytes <= 0) return "";
|
|
612
|
-
if (bytes < 1024) return `${bytes} B`;
|
|
613
|
-
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
|
614
|
-
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
28
|
+
return {
|
|
29
|
+
connected: false,
|
|
30
|
+
detail: row.status === "error" ? row.statusDetail ?? "A inst\xE2ncia respondeu com erro" : "Configurado \u2014 falta parear o n\xFAmero pelo QR"
|
|
31
|
+
};
|
|
615
32
|
}
|
|
616
|
-
function
|
|
617
|
-
|
|
618
|
-
const m = Math.floor(seg / 60);
|
|
619
|
-
const s = Math.floor(seg % 60);
|
|
620
|
-
return `${m}:${String(s).padStart(2, "0")}`;
|
|
33
|
+
function EvolutionPanel() {
|
|
34
|
+
return /* @__PURE__ */ jsx(WhatsAppProviders, { only: "evolution" });
|
|
621
35
|
}
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
}, []);
|
|
641
|
-
const tipo = String(m.messageType);
|
|
642
|
-
const p = m.payload ?? {};
|
|
643
|
-
const legenda = p.caption ?? null;
|
|
644
|
-
const rotulo = /* @__PURE__ */ jsx("p", { className: "whitespace-pre-wrap break-words", children: m.body });
|
|
645
|
-
if (erro) {
|
|
646
|
-
return /* @__PURE__ */ jsxs("div", { ref, className: "space-y-1", children: [
|
|
647
|
-
rotulo,
|
|
648
|
-
/* @__PURE__ */ jsx("p", { className: "text-xs opacity-60", children: "O arquivo n\xE3o carregou." })
|
|
649
|
-
] });
|
|
650
|
-
}
|
|
651
|
-
if (!url) {
|
|
652
|
-
return /* @__PURE__ */ jsxs("div", { ref, className: "space-y-1", children: [
|
|
653
|
-
rotulo,
|
|
654
|
-
/* @__PURE__ */ jsx("div", { className: "h-1 w-24 animate-pulse rounded bg-current opacity-20" })
|
|
655
|
-
] });
|
|
656
|
-
}
|
|
657
|
-
if (tipo === "image" || tipo === "sticker") {
|
|
658
|
-
return /* @__PURE__ */ jsxs("div", { ref, className: "space-y-1", children: [
|
|
659
|
-
/* @__PURE__ */ jsx("a", { href: url, target: "_blank", rel: "noreferrer", children: /* @__PURE__ */ jsx(
|
|
660
|
-
"img",
|
|
661
|
-
{
|
|
662
|
-
src: url,
|
|
663
|
-
alt: legenda ?? (tipo === "sticker" ? "Figurinha" : "Imagem recebida"),
|
|
664
|
-
className: tipo === "sticker" ? "max-h-32 w-auto" : "max-h-80 w-auto rounded-lg",
|
|
665
|
-
loading: "lazy"
|
|
666
|
-
}
|
|
667
|
-
) }),
|
|
668
|
-
legenda ? /* @__PURE__ */ jsx("p", { className: "whitespace-pre-wrap break-words", children: legenda }) : null
|
|
669
|
-
] });
|
|
670
|
-
}
|
|
671
|
-
if (tipo === "video") {
|
|
672
|
-
return /* @__PURE__ */ jsxs("div", { ref, className: "space-y-1", children: [
|
|
673
|
-
/* @__PURE__ */ jsx("video", { src: url, controls: true, preload: "metadata", className: "max-h-80 w-auto rounded-lg" }),
|
|
674
|
-
legenda ? /* @__PURE__ */ jsx("p", { className: "whitespace-pre-wrap break-words", children: legenda }) : null
|
|
675
|
-
] });
|
|
676
|
-
}
|
|
677
|
-
if (tipo === "audio") {
|
|
678
|
-
return /* @__PURE__ */ jsxs("div", { ref, className: "space-y-1", children: [
|
|
679
|
-
/* @__PURE__ */ jsx("audio", { src: url, controls: true, preload: "metadata", className: "h-9 w-64 max-w-full" }),
|
|
680
|
-
/* @__PURE__ */ jsxs("p", { className: "text-xs opacity-60", children: [
|
|
681
|
-
p.ptt ? "Mensagem de voz" : "\xC1udio",
|
|
682
|
-
duracao(p.seconds) ? ` \xB7 ${duracao(p.seconds)}` : ""
|
|
683
|
-
] })
|
|
684
|
-
] });
|
|
685
|
-
}
|
|
686
|
-
const nome = p.fileName ?? "Documento";
|
|
687
|
-
return /* @__PURE__ */ jsxs("div", { ref, className: "space-y-1", children: [
|
|
688
|
-
/* @__PURE__ */ jsxs(
|
|
689
|
-
"a",
|
|
690
|
-
{
|
|
691
|
-
href: url,
|
|
692
|
-
target: "_blank",
|
|
693
|
-
rel: "noreferrer",
|
|
694
|
-
download: nome,
|
|
695
|
-
className: `flex items-center gap-2 rounded-lg border p-2 ${outbound ? "border-white/30" : "border-black/10"}`,
|
|
696
|
-
children: [
|
|
697
|
-
/* @__PURE__ */ jsx(FileText, { className: "h-5 w-5 shrink-0 opacity-70" }),
|
|
698
|
-
/* @__PURE__ */ jsxs("span", { className: "min-w-0 flex-1", children: [
|
|
699
|
-
/* @__PURE__ */ jsx("span", { className: "block truncate text-sm", children: nome }),
|
|
700
|
-
tamanho(p.fileLength) ? /* @__PURE__ */ jsx("span", { className: "block text-xs opacity-60", children: tamanho(p.fileLength) }) : null
|
|
701
|
-
] }),
|
|
702
|
-
/* @__PURE__ */ jsx(Download, { className: "h-4 w-4 shrink-0 opacity-70" })
|
|
703
|
-
]
|
|
704
|
-
}
|
|
705
|
-
),
|
|
706
|
-
legenda ? /* @__PURE__ */ jsx("p", { className: "whitespace-pre-wrap break-words", children: legenda }) : null
|
|
707
|
-
] });
|
|
708
|
-
}
|
|
709
|
-
function ContactCard({ contacts, outbound }) {
|
|
710
|
-
const [copied, setCopied] = React14__default.useState(null);
|
|
711
|
-
const copy = (value) => {
|
|
712
|
-
void navigator.clipboard?.writeText(value);
|
|
713
|
-
setCopied(value);
|
|
714
|
-
window.setTimeout(() => setCopied((c) => c === value ? null : c), 1500);
|
|
715
|
-
};
|
|
716
|
-
return /* @__PURE__ */ jsx("div", { className: "flex flex-col gap-2", children: contacts.map((c, i) => /* @__PURE__ */ jsxs(
|
|
717
|
-
"div",
|
|
36
|
+
var evolutionConnectorDef = {
|
|
37
|
+
id: EVOLUTION_CONNECTOR_ID,
|
|
38
|
+
hostPluginId: "conversations",
|
|
39
|
+
name: "Evolution API",
|
|
40
|
+
description: "WhatsApp pelo n\xFAmero da pr\xF3pria casa: parea com um QR no celular e manda texto livre, sem template e sem janela de 24h.",
|
|
41
|
+
icon: "MessageCircle",
|
|
42
|
+
// The channel's mark: the store sees "WhatsApp", which is what it connects.
|
|
43
|
+
logoUrl: "https://assets.fayz.ai/connectors/whatsapp.png",
|
|
44
|
+
category: "messaging",
|
|
45
|
+
authKind: "api-key",
|
|
46
|
+
planes: ["request", "sync"],
|
|
47
|
+
direction: "outbound",
|
|
48
|
+
docsUrl: "https://doc.evolution-api.com",
|
|
49
|
+
// A message that does not go out must never take the order with it: past
|
|
50
|
+
// this budget the send gives up and the order carries on.
|
|
51
|
+
latencyBudgetMs: 3e3,
|
|
52
|
+
onFailure: "degrade",
|
|
53
|
+
permissions: [
|
|
718
54
|
{
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
children: [
|
|
724
|
-
/* @__PURE__ */ jsx(UserRound, { className: cn("mt-0.5 h-4 w-4 shrink-0", outbound ? "text-white/80" : "text-muted-foreground") }),
|
|
725
|
-
/* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
|
|
726
|
-
/* @__PURE__ */ jsx("p", { className: "truncate font-medium", children: c.name }),
|
|
727
|
-
c.phones.map((p) => /* @__PURE__ */ jsxs(
|
|
728
|
-
Button,
|
|
729
|
-
{
|
|
730
|
-
variant: "bare",
|
|
731
|
-
size: "bare",
|
|
732
|
-
type: "button",
|
|
733
|
-
onClick: () => copy(p.display),
|
|
734
|
-
className: cn(
|
|
735
|
-
"group/phone mt-0.5 flex items-center justify-start gap-1.5 text-left text-[13px] font-normal tabular-nums",
|
|
736
|
-
outbound ? "text-white/90 hover:text-white" : "text-muted-foreground hover:text-foreground"
|
|
737
|
-
),
|
|
738
|
-
children: [
|
|
739
|
-
p.display,
|
|
740
|
-
copied === p.display ? /* @__PURE__ */ jsx(Check, { className: "h-3 w-3" }) : /* @__PURE__ */ jsx(Copy, { className: "h-3 w-3 opacity-0 transition-opacity group-hover/phone:opacity-100" })
|
|
741
|
-
]
|
|
742
|
-
},
|
|
743
|
-
p.digits
|
|
744
|
-
))
|
|
745
|
-
] })
|
|
746
|
-
]
|
|
55
|
+
kind: "data",
|
|
56
|
+
resource: "conversation",
|
|
57
|
+
actions: ["read", "write"],
|
|
58
|
+
reason: "Abrir a conversa do cliente no WhatsApp e mant\xEA-la em dia."
|
|
747
59
|
},
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
if (contacts.length > 0) return /* @__PURE__ */ jsx(ContactCard, { contacts, outbound });
|
|
754
|
-
if (temArquivo(m)) return /* @__PURE__ */ jsx(MediaBubble, { m, outbound });
|
|
755
|
-
return /* @__PURE__ */ jsx("p", { className: "whitespace-pre-wrap break-words", children: m.body });
|
|
756
|
-
}
|
|
757
|
-
function buildRows(messages) {
|
|
758
|
-
const rows = [];
|
|
759
|
-
let lastDay = "";
|
|
760
|
-
messages.forEach((m, i) => {
|
|
761
|
-
const day = new Date(m.at).toDateString();
|
|
762
|
-
if (day !== lastDay) {
|
|
763
|
-
rows.push({ kind: "day", id: `day-${day}`, label: dayLabel(m.at) });
|
|
764
|
-
lastDay = day;
|
|
765
|
-
}
|
|
766
|
-
const prev = messages[i - 1];
|
|
767
|
-
const next = messages[i + 1];
|
|
768
|
-
const samePrev = prev && prev.direction === m.direction && new Date(prev.at).toDateString() === day;
|
|
769
|
-
const sameNext = next && next.direction === m.direction && new Date(next.at).toDateString() === day;
|
|
770
|
-
rows.push({ kind: "msg", id: m.id, message: m, startsRun: !samePrev, endsRun: !sameNext });
|
|
771
|
-
});
|
|
772
|
-
return rows;
|
|
773
|
-
}
|
|
774
|
-
function MessageThread({ selected, onTogglePanel, panelOpen, onBack, backAlways, className }) {
|
|
775
|
-
const t = useTranslation();
|
|
776
|
-
const { messages, sending, send, setStatus, refreshMessages } = useConversationsStore((s) => s);
|
|
777
|
-
const [draft, setDraft] = React14__default.useState("");
|
|
778
|
-
const win = useSendWindow(selected.id);
|
|
779
|
-
const canType = win.mode.kind === "free";
|
|
780
|
-
const threadRef = React14__default.useRef(null);
|
|
781
|
-
React14__default.useEffect(() => {
|
|
782
|
-
threadRef.current?.scrollTo({ top: threadRef.current.scrollHeight, behavior: "smooth" });
|
|
783
|
-
}, [messages.length, selected.id]);
|
|
784
|
-
React14__default.useEffect(() => {
|
|
785
|
-
if (!selected.id) return;
|
|
786
|
-
let timer = null;
|
|
787
|
-
const start = () => {
|
|
788
|
-
if (!timer) timer = setInterval(() => {
|
|
789
|
-
void refreshMessages();
|
|
790
|
-
}, 1e4);
|
|
791
|
-
};
|
|
792
|
-
const stop = () => {
|
|
793
|
-
if (timer) {
|
|
794
|
-
clearInterval(timer);
|
|
795
|
-
timer = null;
|
|
796
|
-
}
|
|
797
|
-
};
|
|
798
|
-
const onVis = () => document.visibilityState === "visible" ? start() : stop();
|
|
799
|
-
onVis();
|
|
800
|
-
document.addEventListener("visibilitychange", onVis);
|
|
801
|
-
return () => {
|
|
802
|
-
stop();
|
|
803
|
-
document.removeEventListener("visibilitychange", onVis);
|
|
804
|
-
};
|
|
805
|
-
}, [selected.id, refreshMessages]);
|
|
806
|
-
async function handleSend() {
|
|
807
|
-
if (!draft.trim()) return;
|
|
808
|
-
const body = draft;
|
|
809
|
-
setDraft("");
|
|
810
|
-
await send(body);
|
|
811
|
-
}
|
|
812
|
-
const accent = CHANNEL_ACCENT[selected.channel];
|
|
813
|
-
const rows = React14__default.useMemo(() => buildRows(messages), [messages]);
|
|
814
|
-
return /* @__PURE__ */ jsxs("section", { className: cn("flex min-w-0 flex-1 flex-col bg-muted/20", className), children: [
|
|
815
|
-
/* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2 border-b border-border bg-card px-3 py-2.5 md:px-5", children: [
|
|
816
|
-
/* @__PURE__ */ jsxs("div", { className: "flex min-w-0 items-center gap-2 md:gap-3", children: [
|
|
817
|
-
onBack && /* @__PURE__ */ jsx(Button, { variant: "ghost", size: "icon", className: cn("-ml-1 shrink-0", !backAlways && "lg:hidden"), onClick: onBack, "aria-label": t("conversations.thread.back"), children: /* @__PURE__ */ jsx(ChevronLeft, { className: "h-5 w-5" }) }),
|
|
818
|
-
/* @__PURE__ */ jsx(Avatar, { name: selected.contactName, accent: selected.accent, size: "sm", channel: selected.channel }),
|
|
819
|
-
/* @__PURE__ */ jsxs("div", { className: "min-w-0", children: [
|
|
820
|
-
/* @__PURE__ */ jsx("p", { className: "truncate text-sm font-semibold text-foreground", children: selected.contactName }),
|
|
821
|
-
!panelOpen && /* @__PURE__ */ jsxs("p", { className: "flex items-center gap-1.5 text-xs text-muted-foreground", children: [
|
|
822
|
-
/* @__PURE__ */ jsx(ChannelBadge, { channel: selected.channel }),
|
|
823
|
-
/* @__PURE__ */ jsx("span", { className: "truncate", children: selected.contactHandle })
|
|
824
|
-
] })
|
|
825
|
-
] })
|
|
826
|
-
] }),
|
|
827
|
-
/* @__PURE__ */ jsxs("div", { className: "flex shrink-0 items-center gap-1.5", children: [
|
|
828
|
-
/* @__PURE__ */ jsxs(Button, { variant: "outline", size: "sm", onClick: () => setStatus("closed"), "aria-label": "Arquivar", children: [
|
|
829
|
-
/* @__PURE__ */ jsx(Archive, { className: "h-3.5 w-3.5 sm:mr-1" }),
|
|
830
|
-
" ",
|
|
831
|
-
/* @__PURE__ */ jsx("span", { className: "hidden sm:inline", children: "Arquivar" })
|
|
832
|
-
] }),
|
|
833
|
-
/* @__PURE__ */ jsx(
|
|
834
|
-
Button,
|
|
835
|
-
{
|
|
836
|
-
variant: panelOpen ? "secondary" : "ghost",
|
|
837
|
-
size: "icon",
|
|
838
|
-
onClick: onTogglePanel,
|
|
839
|
-
"aria-label": t("conversations.thread.details"),
|
|
840
|
-
children: /* @__PURE__ */ jsx(PanelRight, { className: "h-4 w-4" })
|
|
841
|
-
}
|
|
842
|
-
)
|
|
843
|
-
] })
|
|
844
|
-
] }),
|
|
845
|
-
/* @__PURE__ */ jsxs("div", { ref: threadRef, className: "min-h-0 flex-1 overflow-y-auto px-5 py-4", children: [
|
|
846
|
-
rows.map((row) => {
|
|
847
|
-
if (row.kind === "day") {
|
|
848
|
-
return /* @__PURE__ */ jsx("div", { className: "my-3 flex items-center justify-center", children: /* @__PURE__ */ jsx("span", { className: "rounded-full bg-muted px-3 py-0.5 text-[11px] font-medium text-muted-foreground", children: row.label }) }, row.id);
|
|
849
|
-
}
|
|
850
|
-
const m = row.message;
|
|
851
|
-
const outbound = m.direction === "outbound";
|
|
852
|
-
const failed = isFailed(m);
|
|
853
|
-
return /* @__PURE__ */ jsxs(
|
|
854
|
-
"div",
|
|
855
|
-
{
|
|
856
|
-
className: cn("group flex items-center gap-1", outbound ? "justify-end" : "justify-start", row.startsRun ? "mt-2.5" : "mt-0.5"),
|
|
857
|
-
children: [
|
|
858
|
-
outbound && /* @__PURE__ */ jsx(MessageMenu, { m }),
|
|
859
|
-
/* @__PURE__ */ jsxs(
|
|
860
|
-
"div",
|
|
861
|
-
{
|
|
862
|
-
className: cn(
|
|
863
|
-
"max-w-[68%] px-3.5 py-2 text-sm shadow-sm",
|
|
864
|
-
outbound ? "rounded-2xl text-white" : "rounded-2xl bg-card text-foreground",
|
|
865
|
-
// Tail only on the last bubble of a run, on the sender's side.
|
|
866
|
-
outbound && row.endsRun && "rounded-br-sm",
|
|
867
|
-
!outbound && row.endsRun && "rounded-bl-sm",
|
|
868
|
-
// UMA MENSAGEM QUE FALHOU PRECISA PARECER QUE FALHOU. Duas
|
|
869
|
-
// ficaram um dia na caixa com a cara de qualquer outra e nunca
|
|
870
|
-
// chegaram a ninguém — não havia como saber olhando.
|
|
871
|
-
outbound && failed && "ring-2 ring-destructive"
|
|
872
|
-
),
|
|
873
|
-
style: outbound ? { backgroundColor: failed ? "hsl(var(--destructive))" : accent.color } : void 0,
|
|
874
|
-
children: [
|
|
875
|
-
/* @__PURE__ */ jsx(MessageBody, { m, outbound }),
|
|
876
|
-
/* @__PURE__ */ jsxs("div", { className: cn("mt-0.5 flex items-center justify-end gap-1 text-[10px]", outbound ? "text-white/70" : "text-muted-foreground"), children: [
|
|
877
|
-
new Date(m.at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
|
|
878
|
-
/* @__PURE__ */ jsx(DeliveryMark, { m })
|
|
879
|
-
] })
|
|
880
|
-
]
|
|
881
|
-
}
|
|
882
|
-
),
|
|
883
|
-
!outbound && /* @__PURE__ */ jsx(MessageMenu, { m })
|
|
884
|
-
]
|
|
885
|
-
},
|
|
886
|
-
row.id
|
|
887
|
-
);
|
|
888
|
-
}),
|
|
889
|
-
rows.length === 0 && /* @__PURE__ */ jsxs("div", { className: "flex h-full flex-col items-center justify-center text-muted-foreground", children: [
|
|
890
|
-
/* @__PURE__ */ jsx(MessageSquare, { className: "h-7 w-7" }),
|
|
891
|
-
/* @__PURE__ */ jsx("p", { className: "mt-2 text-sm", children: t("conversations.thread.empty") })
|
|
892
|
-
] })
|
|
893
|
-
] }),
|
|
894
|
-
/* @__PURE__ */ jsxs("div", { className: "border-t border-border bg-card", children: [
|
|
895
|
-
/* @__PURE__ */ jsx(SendWindowBanner, { state: win }),
|
|
896
|
-
/* @__PURE__ */ jsxs("div", { className: "flex items-end gap-2 px-4 py-3", children: [
|
|
897
|
-
/* @__PURE__ */ jsx("div", { className: "flex-1", children: /* @__PURE__ */ jsx(
|
|
898
|
-
Textarea,
|
|
899
|
-
{
|
|
900
|
-
value: draft,
|
|
901
|
-
onChange: (e) => setDraft(e.target.value),
|
|
902
|
-
onKeyDown: (e) => {
|
|
903
|
-
if (e.key === "Enter" && !e.shiftKey) {
|
|
904
|
-
e.preventDefault();
|
|
905
|
-
void handleSend();
|
|
906
|
-
}
|
|
907
|
-
},
|
|
908
|
-
rows: 1,
|
|
909
|
-
disabled: !canType,
|
|
910
|
-
placeholder: canType ? t("conversations.thread.reply", { channel: CHANNEL_LABELS[selected.channel] }) : win.mode.kind === "blocked" ? "Envio bloqueado" : "Fora da janela \u2014 envie um template",
|
|
911
|
-
className: "max-h-32 min-h-[40px] resize-none rounded-md border-border bg-background shadow-none focus:border-primary focus-visible:ring-0 focus-visible:ring-offset-0 disabled:bg-muted/40 disabled:text-muted-foreground disabled:opacity-100"
|
|
912
|
-
}
|
|
913
|
-
) }),
|
|
914
|
-
/* @__PURE__ */ jsx(Button, { onClick: () => void handleSend(), disabled: !canType || sending || !draft.trim(), "aria-label": t("conversations.thread.send"), children: /* @__PURE__ */ jsx(Send, { className: "h-4 w-4" }) })
|
|
915
|
-
] })
|
|
916
|
-
] })
|
|
917
|
-
] });
|
|
918
|
-
}
|
|
919
|
-
var TEMP = {
|
|
920
|
-
hot: { icon: Flame, cls: "bg-destructive-soft text-destructive-soft-foreground", label: "Quente" },
|
|
921
|
-
warm: { icon: Sun, cls: "bg-warning-soft text-warning-soft-foreground", label: "Morno" },
|
|
922
|
-
cold: { icon: Snowflake, cls: "bg-info-soft text-info-soft-foreground", label: "Frio" }
|
|
923
|
-
};
|
|
924
|
-
function LeadCard({ personId, handle }) {
|
|
925
|
-
const [lead, setLead] = React14__default.useState(void 0);
|
|
926
|
-
React14__default.useEffect(() => {
|
|
927
|
-
let alive = true;
|
|
928
|
-
const sb = getSupabaseClientOptional();
|
|
929
|
-
const tenantId = getActiveTenantId();
|
|
930
|
-
if (!sb || !tenantId || !personId && !handle) {
|
|
931
|
-
setLead(null);
|
|
932
|
-
return;
|
|
933
|
-
}
|
|
934
|
-
(async () => {
|
|
935
|
-
try {
|
|
936
|
-
let q = sb.from("people").select("id, name, metadata, phone").eq("tenant_id", tenantId).eq("kind", "lead").limit(1);
|
|
937
|
-
q = personId ? q.eq("id", personId) : q.like("phone", `%${String(handle).replace(/\D/g, "").slice(-8)}`);
|
|
938
|
-
const { data } = await q.maybeSingle();
|
|
939
|
-
if (!alive) return;
|
|
940
|
-
if (!data) {
|
|
941
|
-
setLead(null);
|
|
942
|
-
return;
|
|
943
|
-
}
|
|
944
|
-
const meta = data.metadata ?? {};
|
|
945
|
-
let score = null;
|
|
946
|
-
try {
|
|
947
|
-
const { data: s } = await sb.from("v_crm_lead_score").select("temperature, fit_score, interest_score, reasons").eq("lead_id", data.id).maybeSingle();
|
|
948
|
-
score = s;
|
|
949
|
-
} catch {
|
|
950
|
-
}
|
|
951
|
-
setLead({
|
|
952
|
-
id: data.id,
|
|
953
|
-
name: data.name,
|
|
954
|
-
company: meta.company ?? null,
|
|
955
|
-
status: meta.status ?? null,
|
|
956
|
-
sourceName: meta.sourceName ?? null,
|
|
957
|
-
temperature: score?.temperature ?? null,
|
|
958
|
-
fitScore: score?.fit_score ?? null,
|
|
959
|
-
interestScore: score?.interest_score ?? null,
|
|
960
|
-
reasons: Array.isArray(score?.reasons) ? score.reasons : []
|
|
961
|
-
});
|
|
962
|
-
} catch {
|
|
963
|
-
if (alive) setLead(null);
|
|
964
|
-
}
|
|
965
|
-
})();
|
|
966
|
-
return () => {
|
|
967
|
-
alive = false;
|
|
968
|
-
};
|
|
969
|
-
}, [personId, handle]);
|
|
970
|
-
if (lead === void 0) return /* @__PURE__ */ jsx("div", { className: "px-4 py-3 text-xs text-muted-foreground", children: "carregando\u2026" });
|
|
971
|
-
if (!lead) {
|
|
972
|
-
return /* @__PURE__ */ jsx("div", { className: "border-t border-border px-4 py-3", children: /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: "Este contato ainda n\xE3o \xE9 um lead cadastrado." }) });
|
|
973
|
-
}
|
|
974
|
-
const t = lead.temperature ? TEMP[lead.temperature] : null;
|
|
975
|
-
const Icon = t?.icon;
|
|
976
|
-
return /* @__PURE__ */ jsxs("div", { className: "border-t border-border px-4 py-3", children: [
|
|
977
|
-
/* @__PURE__ */ jsxs("div", { className: "flex items-start justify-between gap-2", children: [
|
|
978
|
-
/* @__PURE__ */ jsxs("div", { className: "min-w-0", children: [
|
|
979
|
-
/* @__PURE__ */ jsx("p", { className: "truncate text-sm font-semibold", children: lead.name }),
|
|
980
|
-
lead.company && /* @__PURE__ */ jsxs("p", { className: "flex items-center gap-1 truncate text-xs text-muted-foreground", children: [
|
|
981
|
-
/* @__PURE__ */ jsx(Building2, { className: "h-3 w-3 shrink-0" }),
|
|
982
|
-
" ",
|
|
983
|
-
lead.company
|
|
984
|
-
] })
|
|
985
|
-
] }),
|
|
986
|
-
/* @__PURE__ */ jsxs(
|
|
987
|
-
"a",
|
|
988
|
-
{
|
|
989
|
-
href: `#/sales/leads/${lead.id}`,
|
|
990
|
-
className: "inline-flex shrink-0 items-center gap-1 rounded-button border bg-card px-2 py-1 text-[11px] font-medium hover:bg-muted",
|
|
991
|
-
children: [
|
|
992
|
-
"Abrir lead ",
|
|
993
|
-
/* @__PURE__ */ jsx(ExternalLink, { className: "h-3 w-3" })
|
|
994
|
-
]
|
|
995
|
-
}
|
|
996
|
-
)
|
|
997
|
-
] }),
|
|
998
|
-
/* @__PURE__ */ jsxs("div", { className: "mt-2 flex flex-wrap items-center gap-1.5", children: [
|
|
999
|
-
t && Icon && /* @__PURE__ */ jsxs("span", { className: `inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium ${t.cls}`, children: [
|
|
1000
|
-
/* @__PURE__ */ jsx(Icon, { className: "h-3 w-3" }),
|
|
1001
|
-
" ",
|
|
1002
|
-
t.label,
|
|
1003
|
-
lead.interestScore != null && /* @__PURE__ */ jsx("span", { className: "tabular-nums opacity-70", children: lead.interestScore })
|
|
1004
|
-
] }),
|
|
1005
|
-
lead.status && /* @__PURE__ */ jsx("span", { className: "rounded-full bg-muted px-2 py-0.5 text-[10px] capitalize text-muted-foreground", children: lead.status }),
|
|
1006
|
-
lead.sourceName && /* @__PURE__ */ jsx("span", { className: "rounded-full bg-muted px-2 py-0.5 text-[10px] text-muted-foreground", children: lead.sourceName })
|
|
1007
|
-
] }),
|
|
1008
|
-
lead.reasons.length > 0 && /* @__PURE__ */ jsx("ul", { className: "mt-2 space-y-0.5", children: lead.reasons.slice(0, 4).map((r, i) => /* @__PURE__ */ jsxs("li", { className: "flex gap-1.5 text-[11px] text-muted-foreground", children: [
|
|
1009
|
-
/* @__PURE__ */ jsx("span", { className: "text-muted-foreground/40", children: "\u2022" }),
|
|
1010
|
-
/* @__PURE__ */ jsx("span", { children: r })
|
|
1011
|
-
] }, i)) })
|
|
1012
|
-
] });
|
|
1013
|
-
}
|
|
1014
|
-
function Section({ icon: Icon, title, children }) {
|
|
1015
|
-
return /* @__PURE__ */ jsxs("div", { className: "border-t border-border px-4 py-3", children: [
|
|
1016
|
-
/* @__PURE__ */ jsxs("p", { className: "mb-1.5 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground", children: [
|
|
1017
|
-
/* @__PURE__ */ jsx(Icon, { className: "h-3 w-3" }),
|
|
1018
|
-
" ",
|
|
1019
|
-
title
|
|
1020
|
-
] }),
|
|
1021
|
-
children
|
|
1022
|
-
] });
|
|
1023
|
-
}
|
|
1024
|
-
function ContactPanel({ contact, onClose, className }) {
|
|
1025
|
-
const t = useTranslation();
|
|
1026
|
-
return /* @__PURE__ */ jsxs("aside", { className: cn("flex shrink-0 flex-col overflow-y-auto border-l border-border bg-card", className), children: [
|
|
1027
|
-
onClose && /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between border-b border-border px-3 py-2 xl:hidden", children: [
|
|
1028
|
-
/* @__PURE__ */ jsx("span", { className: "text-sm font-semibold text-foreground", children: t("conversations.contact.details") }),
|
|
1029
|
-
/* @__PURE__ */ jsx(Button, { variant: "ghost", size: "icon", onClick: onClose, "aria-label": t("conversations.contact.closeDetails"), children: /* @__PURE__ */ jsx(X, { className: "h-4 w-4" }) })
|
|
1030
|
-
] }),
|
|
1031
|
-
/* @__PURE__ */ jsxs("div", { className: "flex flex-col items-center gap-2 px-4 py-5 text-center", children: [
|
|
1032
|
-
/* @__PURE__ */ jsx(Avatar, { name: contact.contactName, accent: contact.accent, size: "lg", channel: contact.channel }),
|
|
1033
|
-
/* @__PURE__ */ jsxs("div", { children: [
|
|
1034
|
-
/* @__PURE__ */ jsx("p", { className: "text-sm font-semibold text-foreground", children: contact.contactName }),
|
|
1035
|
-
/* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: contact.contactHandle })
|
|
1036
|
-
] }),
|
|
1037
|
-
/* @__PURE__ */ jsx(ChannelBadge, { channel: contact.channel })
|
|
1038
|
-
] }),
|
|
1039
|
-
/* @__PURE__ */ jsx(LeadCard, { personId: contact.contactPersonId, handle: contact.contactHandle }),
|
|
1040
|
-
/* @__PURE__ */ jsx(Section, { icon: User, title: t("conversations.contact.details"), children: /* @__PURE__ */ jsxs("dl", { className: "space-y-1.5 text-sm", children: [
|
|
1041
|
-
/* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2", children: [
|
|
1042
|
-
/* @__PURE__ */ jsx("dt", { className: "text-muted-foreground", children: t("conversations.contact.channel") }),
|
|
1043
|
-
/* @__PURE__ */ jsx("dd", { className: "text-foreground", children: CHANNEL_LABELS[contact.channel] })
|
|
1044
|
-
] }),
|
|
1045
|
-
/* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2", children: [
|
|
1046
|
-
/* @__PURE__ */ jsx("dt", { className: "text-muted-foreground", children: t("conversations.contact.status") }),
|
|
1047
|
-
/* @__PURE__ */ jsx("dd", { className: "text-foreground", children: t(`conversations.status.${contact.status}`) })
|
|
1048
|
-
] }),
|
|
1049
|
-
contact.assignedTo && /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2", children: [
|
|
1050
|
-
/* @__PURE__ */ jsx("dt", { className: "text-muted-foreground", children: t("conversations.contact.assignedTo") }),
|
|
1051
|
-
/* @__PURE__ */ jsx("dd", { className: "text-foreground", children: contact.assignedTo })
|
|
1052
|
-
] }),
|
|
1053
|
-
contact.leadOwnerName && /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2", children: [
|
|
1054
|
-
/* @__PURE__ */ jsx("dt", { className: "text-muted-foreground", children: t("conversations.contact.leadOwner") }),
|
|
1055
|
-
/* @__PURE__ */ jsx("dd", { className: "font-medium text-foreground", children: contact.leadOwnerName })
|
|
1056
|
-
] }),
|
|
1057
|
-
contact.location && /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2", children: [
|
|
1058
|
-
/* @__PURE__ */ jsxs("dt", { className: "flex items-center gap-1 text-muted-foreground", children: [
|
|
1059
|
-
/* @__PURE__ */ jsx(MapPin, { className: "h-3 w-3" }),
|
|
1060
|
-
" ",
|
|
1061
|
-
t("conversations.contact.location")
|
|
1062
|
-
] }),
|
|
1063
|
-
/* @__PURE__ */ jsx("dd", { className: "text-foreground", children: contact.location })
|
|
1064
|
-
] })
|
|
1065
|
-
] }) }),
|
|
1066
|
-
contact.tags.length > 0 && /* @__PURE__ */ jsx(Section, { icon: Tag, title: t("conversations.contact.tags"), children: /* @__PURE__ */ jsx("div", { className: "flex flex-wrap gap-1.5", children: contact.tags.map((tag) => /* @__PURE__ */ jsx("span", { className: "rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-foreground", children: tag }, tag)) }) }),
|
|
1067
|
-
contact.note && /* @__PURE__ */ jsx(Section, { icon: StickyNote, title: t("conversations.contact.note"), children: /* @__PURE__ */ jsx("p", { className: "text-sm text-foreground", children: contact.note }) }),
|
|
1068
|
-
/* @__PURE__ */ jsx(Section, { icon: Link2, title: t("conversations.contact.linkedRecords"), children: /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: t("conversations.contact.noLinkedRecords") }) })
|
|
1069
|
-
] });
|
|
1070
|
-
}
|
|
1071
|
-
var CHANNELS = ["whatsapp", "sms", "instagram", "email", "webchat"];
|
|
1072
|
-
function personHandleFor(channel, contact) {
|
|
1073
|
-
if (!contact) return "";
|
|
1074
|
-
if (channel === "email") return contact.email ?? "";
|
|
1075
|
-
if (channel === "sms" || channel === "whatsapp") return contact.phone ?? "";
|
|
1076
|
-
return "";
|
|
1077
|
-
}
|
|
1078
|
-
var HANDLE_LABEL_KEY = {
|
|
1079
|
-
whatsapp: "conversations.new.handleLabel.phone",
|
|
1080
|
-
sms: "conversations.new.handleLabel.phone",
|
|
1081
|
-
email: "conversations.new.handleLabel.email",
|
|
1082
|
-
instagram: "conversations.new.handleLabel.instagram",
|
|
1083
|
-
webchat: "conversations.new.handleLabel.webchat"
|
|
1084
|
-
};
|
|
1085
|
-
function NewConversationPanel({
|
|
1086
|
-
open,
|
|
1087
|
-
onOpenChange
|
|
1088
|
-
}) {
|
|
1089
|
-
const t = useTranslation();
|
|
1090
|
-
const create = useConversationsStore((s) => s.create);
|
|
1091
|
-
const config = useConversationsConfig();
|
|
1092
|
-
const guardConversations = useLimitGuard("conversations_month");
|
|
1093
|
-
const [channel, setChannel] = React14__default.useState("whatsapp");
|
|
1094
|
-
const [contact, setContact] = React14__default.useState(null);
|
|
1095
|
-
const [typedHandle, setTypedHandle] = React14__default.useState("");
|
|
1096
|
-
const [creatingContact, setCreatingContact] = React14__default.useState(false);
|
|
1097
|
-
const [firstMessage, setFirstMessage] = React14__default.useState("");
|
|
1098
|
-
const [submitting, setSubmitting] = React14__default.useState(false);
|
|
1099
|
-
const [pickerKey, setPickerKey] = React14__default.useState(0);
|
|
1100
|
-
React14__default.useEffect(() => {
|
|
1101
|
-
if (open) {
|
|
1102
|
-
setChannel("whatsapp");
|
|
1103
|
-
setContact(null);
|
|
1104
|
-
setTypedHandle("");
|
|
1105
|
-
setCreatingContact(false);
|
|
1106
|
-
setFirstMessage("");
|
|
1107
|
-
setSubmitting(false);
|
|
1108
|
-
setPickerKey((k) => k + 1);
|
|
1109
|
-
}
|
|
1110
|
-
}, [open]);
|
|
1111
|
-
const derivedHandle = personHandleFor(channel, contact);
|
|
1112
|
-
const effectiveHandle = derivedHandle || typedHandle;
|
|
1113
|
-
const handleLabel = t(HANDLE_LABEL_KEY[channel]);
|
|
1114
|
-
const canSubmit = (contact?.name.trim().length ?? 0) > 0 && !submitting;
|
|
1115
|
-
async function handleSubmit(e) {
|
|
1116
|
-
e.preventDefault();
|
|
1117
|
-
if (!canSubmit) return;
|
|
1118
|
-
setSubmitting(true);
|
|
1119
|
-
try {
|
|
1120
|
-
if (await guardConversations() === "blocked") return;
|
|
1121
|
-
await create({
|
|
1122
|
-
channel,
|
|
1123
|
-
contactName: contact.name.trim(),
|
|
1124
|
-
contactPersonId: contact?.id,
|
|
1125
|
-
contactHandle: effectiveHandle.trim() || void 0,
|
|
1126
|
-
firstMessage: firstMessage.trim() || void 0
|
|
1127
|
-
});
|
|
1128
|
-
invalidateLimit("conversations_month");
|
|
1129
|
-
onOpenChange(false);
|
|
1130
|
-
} catch (err) {
|
|
1131
|
-
const message = errorMessage(err);
|
|
1132
|
-
toast.error(t("conversations.new.createFailed"), { description: message });
|
|
1133
|
-
} finally {
|
|
1134
|
-
setSubmitting(false);
|
|
60
|
+
{
|
|
61
|
+
kind: "data",
|
|
62
|
+
resource: "message",
|
|
63
|
+
actions: ["read", "write"],
|
|
64
|
+
reason: "Guardar cada mensagem enviada e recebida, com fotos, \xE1udios e documentos."
|
|
1135
65
|
}
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
onClick: () => setChannel(ch),
|
|
1153
|
-
"aria-pressed": active,
|
|
1154
|
-
className: cn(
|
|
1155
|
-
"inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition-colors",
|
|
1156
|
-
active ? "text-white" : "bg-muted text-muted-foreground hover:bg-muted/70"
|
|
1157
|
-
),
|
|
1158
|
-
style: active ? { backgroundColor: CHANNEL_ACCENT[ch].color } : void 0,
|
|
1159
|
-
children: [
|
|
1160
|
-
/* @__PURE__ */ jsx(Icon, { className: "h-3 w-3" }),
|
|
1161
|
-
CHANNEL_LABELS[ch]
|
|
1162
|
-
]
|
|
1163
|
-
},
|
|
1164
|
-
ch
|
|
1165
|
-
);
|
|
1166
|
-
}) })
|
|
1167
|
-
] }),
|
|
1168
|
-
/* @__PURE__ */ jsx(
|
|
1169
|
-
ContactPicker,
|
|
1170
|
-
{
|
|
1171
|
-
value: contact,
|
|
1172
|
-
onChange: setContact,
|
|
1173
|
-
kind: config.contactKind,
|
|
1174
|
-
lookup: config.contactLookup,
|
|
1175
|
-
createEntityDef: config.contactEntityDef,
|
|
1176
|
-
allowFreeText: true,
|
|
1177
|
-
onCreatingChange: setCreatingContact,
|
|
1178
|
-
autoFocus: true,
|
|
1179
|
-
label: t("conversations.new.contactName"),
|
|
1180
|
-
placeholder: t("conversations.new.contactNamePlaceholder"),
|
|
1181
|
-
secondaryText: derivedHandle || void 0,
|
|
1182
|
-
handleField: {
|
|
1183
|
-
label: handleLabel,
|
|
1184
|
-
derived: derivedHandle || void 0,
|
|
1185
|
-
value: typedHandle,
|
|
1186
|
-
onChange: setTypedHandle,
|
|
1187
|
-
fieldLabel: t("conversations.new.handle"),
|
|
1188
|
-
placeholder: t("conversations.new.handlePlaceholder")
|
|
1189
|
-
}
|
|
1190
|
-
},
|
|
1191
|
-
pickerKey
|
|
1192
|
-
),
|
|
1193
|
-
/* @__PURE__ */ jsxs("div", { children: [
|
|
1194
|
-
/* @__PURE__ */ jsx("label", { htmlFor: "conv-first-message", className: "mb-1.5 block text-xs font-medium text-muted-foreground", children: t("conversations.new.firstMessage") }),
|
|
1195
|
-
/* @__PURE__ */ jsx(
|
|
1196
|
-
Textarea,
|
|
1197
|
-
{
|
|
1198
|
-
id: "conv-first-message",
|
|
1199
|
-
value: firstMessage,
|
|
1200
|
-
onChange: (e) => setFirstMessage(e.target.value),
|
|
1201
|
-
rows: 3,
|
|
1202
|
-
placeholder: t("conversations.new.firstMessagePlaceholder"),
|
|
1203
|
-
className: "max-h-40 min-h-[64px] resize-none rounded-md border-border bg-background shadow-none focus:border-primary focus-visible:ring-0 focus-visible:ring-offset-0"
|
|
1204
|
-
}
|
|
1205
|
-
)
|
|
1206
|
-
] })
|
|
1207
|
-
] }),
|
|
1208
|
-
/* @__PURE__ */ jsx(ModalFooter, { children: /* @__PURE__ */ jsx(Button, { type: "submit", disabled: !canSubmit, className: "w-full", children: submitting ? t("conversations.new.creating") : t("conversations.new.create") }) })
|
|
1209
|
-
] })
|
|
1210
|
-
] }) });
|
|
1211
|
-
}
|
|
1212
|
-
var porConta = /* @__PURE__ */ new Map();
|
|
1213
|
-
function assinarChegada(tenantId, ouvinte) {
|
|
1214
|
-
const supabase = getSupabaseClientOptional();
|
|
1215
|
-
if (!supabase?.channel || !tenantId) return () => {
|
|
1216
|
-
};
|
|
1217
|
-
let assinatura = porConta.get(tenantId);
|
|
1218
|
-
if (!assinatura) {
|
|
1219
|
-
const ouvintes = /* @__PURE__ */ new Set();
|
|
1220
|
-
const canal = supabase.channel(`conversas:${tenantId}`).on("broadcast", { event: "inbound" }, (msg2) => {
|
|
1221
|
-
const carga = { conversationId: msg2?.payload?.conversationId };
|
|
1222
|
-
for (const f of [...ouvintes]) {
|
|
1223
|
-
try {
|
|
1224
|
-
f(carga);
|
|
1225
|
-
} catch {
|
|
1226
|
-
}
|
|
1227
|
-
}
|
|
1228
|
-
}).subscribe();
|
|
1229
|
-
assinatura = { canal, ouvintes };
|
|
1230
|
-
porConta.set(tenantId, assinatura);
|
|
1231
|
-
}
|
|
1232
|
-
assinatura.ouvintes.add(ouvinte);
|
|
1233
|
-
return () => {
|
|
1234
|
-
const a = porConta.get(tenantId);
|
|
1235
|
-
if (!a) return;
|
|
1236
|
-
a.ouvintes.delete(ouvinte);
|
|
1237
|
-
if (a.ouvintes.size === 0) {
|
|
1238
|
-
porConta.delete(tenantId);
|
|
1239
|
-
void getSupabaseClientOptional()?.removeChannel?.(a.canal);
|
|
66
|
+
],
|
|
67
|
+
// The URL, the key and the instance are typed in the provider card below,
|
|
68
|
+
// which already saves them where the sender reads them.
|
|
69
|
+
fields: [],
|
|
70
|
+
invokedAt: [
|
|
71
|
+
{
|
|
72
|
+
key: "messaging.send",
|
|
73
|
+
label: "Uma automa\xE7\xE3o ou um pedido manda mensagem e ela sai pelo WhatsApp da casa",
|
|
74
|
+
touches: ["conversation", "message"]
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
key: "conversations.inbound",
|
|
78
|
+
label: "O cliente responde, e a resposta entra na caixa de entrada",
|
|
79
|
+
plane: "sync",
|
|
80
|
+
trigger: "webhook",
|
|
81
|
+
touches: ["conversation", "message"]
|
|
1240
82
|
}
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
const abertoRef = React14.useRef(selectedId);
|
|
1251
|
-
abertoRef.current = selectedId;
|
|
1252
|
-
React14.useEffect(() => {
|
|
1253
|
-
if (!tenantId) return;
|
|
1254
|
-
return assinarChegada(tenantId, ({ conversationId }) => {
|
|
1255
|
-
void load();
|
|
1256
|
-
if (conversationId && conversationId === abertoRef.current) {
|
|
1257
|
-
void refreshMessages();
|
|
1258
|
-
}
|
|
1259
|
-
});
|
|
1260
|
-
}, [tenantId, load, refreshMessages]);
|
|
1261
|
-
}
|
|
1262
|
-
function InboxView({ layout = "page", newSignal = 0 } = {}) {
|
|
1263
|
-
const t = useTranslation();
|
|
1264
|
-
const dock = layout !== "page";
|
|
1265
|
-
const { conversations, selectedId, deselect } = useConversationsStore((s) => s);
|
|
1266
|
-
useCaixaAoVivo();
|
|
1267
|
-
const isWideViewport = useMediaQuery("(min-width: 1280px)");
|
|
1268
|
-
const isWidePanel = isWideViewport && !dock;
|
|
1269
|
-
const railOpen = useRightRailStore((s) => s.open);
|
|
1270
|
-
const [panelOpen, setPanelOpen] = React14__default.useState(false);
|
|
1271
|
-
const [newOpen, setNewOpen] = React14__default.useState(false);
|
|
1272
|
-
const selected = conversations.find((c) => c.id === selectedId) ?? null;
|
|
1273
|
-
React14__default.useEffect(() => {
|
|
1274
|
-
if (newSignal > 0) setNewOpen(true);
|
|
1275
|
-
}, [newSignal]);
|
|
1276
|
-
React14__default.useEffect(() => {
|
|
1277
|
-
setPanelOpen(isWidePanel && !railOpen);
|
|
1278
|
-
}, [isWidePanel, railOpen]);
|
|
1279
|
-
const newActions = React14__default.useMemo(() => [{
|
|
1280
|
-
id: "new-conversation",
|
|
1281
|
-
label: t("conversations.list.new"),
|
|
1282
|
-
icon: SquarePen,
|
|
1283
|
-
intent: "primary",
|
|
1284
|
-
// Starting a conversation creates a thread — the FAB is a "+".
|
|
1285
|
-
creates: true,
|
|
1286
|
-
testId: "conversations-new",
|
|
1287
|
-
permission: { feature: "conversations", action: "create" },
|
|
1288
|
-
onSelect: () => setNewOpen(true)
|
|
1289
|
-
}], [t]);
|
|
1290
|
-
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
1291
|
-
!dock && /* @__PURE__ */ jsx(PageHeaderActions, { children: /* @__PURE__ */ jsx(PageActions, { actions: newActions }) }),
|
|
1292
|
-
/* @__PURE__ */ jsx(NewConversationPanel, { open: newOpen, onOpenChange: setNewOpen }),
|
|
1293
|
-
/* @__PURE__ */ jsxs("div", { className: "relative flex h-full min-h-0 overflow-hidden bg-background", children: [
|
|
1294
|
-
/* @__PURE__ */ jsx(ConversationList, { className: cn(
|
|
1295
|
-
layout === "page" && (selected ? "hidden lg:flex" : "flex"),
|
|
1296
|
-
layout === "dock" && (selected ? "hidden" : "flex lg:w-full border-r-0"),
|
|
1297
|
-
layout === "dock-wide" && "flex lg:w-[300px]"
|
|
1298
|
-
) }),
|
|
1299
|
-
selected ? /* @__PURE__ */ jsx(
|
|
1300
|
-
MessageThread,
|
|
1301
|
-
{
|
|
1302
|
-
selected,
|
|
1303
|
-
panelOpen,
|
|
1304
|
-
onTogglePanel: () => setPanelOpen((v) => !v),
|
|
1305
|
-
onBack: deselect,
|
|
1306
|
-
backAlways: layout === "dock",
|
|
1307
|
-
className: "flex"
|
|
1308
|
-
}
|
|
1309
|
-
) : layout === "dock" ? null : /* @__PURE__ */ jsxs("section", { className: cn("min-w-0 flex-1 flex-col items-center justify-center bg-muted/20 text-muted-foreground", dock ? "flex" : "hidden lg:flex"), children: [
|
|
1310
|
-
/* @__PURE__ */ jsx(MessageSquare, { className: "h-9 w-9" }),
|
|
1311
|
-
/* @__PURE__ */ jsx("p", { className: "mt-2 text-sm", children: t("conversations.empty.select") })
|
|
1312
|
-
] }),
|
|
1313
|
-
selected && panelOpen && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
1314
|
-
/* @__PURE__ */ jsx(
|
|
1315
|
-
Button,
|
|
1316
|
-
{
|
|
1317
|
-
variant: "bare",
|
|
1318
|
-
size: "bare",
|
|
1319
|
-
className: cn("absolute inset-0 z-10 rounded-none bg-black/40", !dock && "xl:hidden"),
|
|
1320
|
-
"aria-label": "Close details",
|
|
1321
|
-
onClick: () => setPanelOpen(false)
|
|
1322
|
-
}
|
|
1323
|
-
),
|
|
1324
|
-
/* @__PURE__ */ jsx(
|
|
1325
|
-
ContactPanel,
|
|
1326
|
-
{
|
|
1327
|
-
contact: selected,
|
|
1328
|
-
onClose: () => setPanelOpen(false),
|
|
1329
|
-
className: cn("absolute inset-y-0 right-0 z-20 w-[340px] max-w-[88%] shadow-2xl", !dock && "xl:static xl:z-auto xl:w-[300px] xl:max-w-none xl:shadow-none")
|
|
1330
|
-
}
|
|
1331
|
-
)
|
|
1332
|
-
] })
|
|
1333
|
-
] })
|
|
1334
|
-
] });
|
|
1335
|
-
}
|
|
1336
|
-
var CONNECTORS_ROUTE = "/settings/conversations/connectors";
|
|
1337
|
-
var SPLIT_AT = 720;
|
|
1338
|
-
function ConversationsRailPanel({ store: store2, config }) {
|
|
1339
|
-
const t = useTranslation();
|
|
1340
|
-
const ref = React14.useRef(null);
|
|
1341
|
-
const [width, setWidth] = React14.useState(0);
|
|
1342
|
-
const [newSignal, setNewSignal] = React14.useState(0);
|
|
1343
|
-
const connected = useChannelConnections();
|
|
1344
|
-
const closeRail = useRightRailStore((s) => s.close);
|
|
1345
|
-
const wide = width >= SPLIT_AT;
|
|
1346
|
-
React14.useLayoutEffect(() => {
|
|
1347
|
-
const el = ref.current;
|
|
1348
|
-
if (!el) return;
|
|
1349
|
-
const ro = new ResizeObserver(([e]) => setWidth(e.contentRect.width));
|
|
1350
|
-
ro.observe(el);
|
|
1351
|
-
return () => ro.disconnect();
|
|
1352
|
-
}, []);
|
|
1353
|
-
React14.useEffect(() => {
|
|
1354
|
-
const narrow = (ref.current?.getBoundingClientRect().width ?? 0) < SPLIT_AT;
|
|
1355
|
-
if (narrow) store2.getState().deselect();
|
|
1356
|
-
void store2.getState().load({ autoSelect: !narrow });
|
|
1357
|
-
}, [store2]);
|
|
1358
|
-
const go = (route) => {
|
|
1359
|
-
closeRail();
|
|
1360
|
-
adminNavigateTo(route);
|
|
1361
|
-
};
|
|
1362
|
-
useRightRailMenu([
|
|
1363
|
-
{ id: "new", label: t("conversations.list.new"), icon: SquarePen, onSelect: () => setNewSignal((n) => n + 1) },
|
|
1364
|
-
{ id: "full", label: tl("Open full screen", "Abrir em tela cheia"), icon: ExternalLink, onSelect: () => go("/conversations") }
|
|
1365
|
-
]);
|
|
1366
|
-
return /* @__PURE__ */ jsxs("div", { ref, className: "flex h-full min-h-0 flex-col", children: [
|
|
1367
|
-
/* @__PURE__ */ jsx("div", { className: "min-h-0 flex-1", children: /* @__PURE__ */ jsx(ConversationsContextProvider, { store: store2, config, children: /* @__PURE__ */ jsx(InboxView, { layout: wide ? "dock-wide" : "dock", newSignal }) }) }),
|
|
1368
|
-
/* @__PURE__ */ jsx("div", { className: "shrink-0 border-t border-border p-3", children: connected === void 0 ? /* @__PURE__ */ jsx("div", { className: "h-10", "aria-hidden": true }) : connected.whatsapp ? /* @__PURE__ */ jsxs(
|
|
1369
|
-
Button,
|
|
1370
|
-
{
|
|
1371
|
-
variant: "bare",
|
|
1372
|
-
size: "bare",
|
|
1373
|
-
type: "button",
|
|
1374
|
-
onClick: () => go(CONNECTORS_ROUTE),
|
|
1375
|
-
className: "font-normal whitespace-normal justify-start fz-tap flex h-10 w-full items-center gap-2 rounded-button px-2 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground",
|
|
1376
|
-
children: [
|
|
1377
|
-
/* @__PURE__ */ jsx(CheckCircle2, { className: "h-4 w-4 text-[#25D366]", "aria-hidden": true }),
|
|
1378
|
-
/* @__PURE__ */ jsx("span", { className: "min-w-0 flex-1 truncate text-left", children: tl("WhatsApp connected", "WhatsApp conectado") }),
|
|
1379
|
-
/* @__PURE__ */ jsx("span", { className: "text-xs text-primary", children: tl("Manage", "Gerenciar") })
|
|
1380
|
-
]
|
|
1381
|
-
}
|
|
1382
|
-
) : /* @__PURE__ */ jsxs(
|
|
1383
|
-
Button,
|
|
1384
|
-
{
|
|
1385
|
-
variant: "bare",
|
|
1386
|
-
size: "bare",
|
|
1387
|
-
type: "button",
|
|
1388
|
-
onClick: () => go(CONNECTORS_ROUTE),
|
|
1389
|
-
"data-testid": "inbox-connect-whatsapp",
|
|
1390
|
-
className: "font-normal whitespace-normal fz-tap flex h-11 w-full items-center justify-center gap-2 rounded-button bg-[#25D366] text-sm font-semibold text-white shadow-sm transition-colors hover:bg-[#1EBE5A] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#25D366] focus-visible:ring-offset-2",
|
|
1391
|
-
children: [
|
|
1392
|
-
/* @__PURE__ */ jsx(WhatsAppMark, { className: "h-[18px] w-[18px]" }),
|
|
1393
|
-
tl("Connect WhatsApp", "Conectar WhatsApp")
|
|
1394
|
-
]
|
|
1395
|
-
}
|
|
1396
|
-
) })
|
|
1397
|
-
] });
|
|
1398
|
-
}
|
|
1399
|
-
function WhatsAppMark({ className }) {
|
|
1400
|
-
return /* @__PURE__ */ jsx("svg", { viewBox: "0 0 24 24", fill: "currentColor", className, "aria-hidden": true, children: /* @__PURE__ */ jsx("path", { d: "M17.47 14.38c-.3-.15-1.76-.87-2.03-.97-.27-.1-.47-.15-.67.15-.2.3-.77.96-.94 1.16-.17.2-.35.22-.65.07-.3-.15-1.26-.46-2.4-1.48-.89-.79-1.49-1.77-1.66-2.07-.17-.3-.02-.46.13-.61.13-.13.3-.35.45-.52.15-.17.2-.3.3-.5.1-.2.05-.37-.02-.52-.08-.15-.67-1.61-.92-2.2-.24-.58-.49-.5-.67-.51h-.57c-.2 0-.52.07-.79.37-.27.3-1.04 1.02-1.04 2.48s1.07 2.88 1.21 3.08c.15.2 2.1 3.2 5.08 4.49.71.31 1.26.49 1.69.63.71.22 1.36.19 1.87.12.57-.09 1.76-.72 2.01-1.41.25-.7.25-1.29.17-1.41-.07-.13-.27-.2-.57-.35zM12.05 21.5h-.01a9.4 9.4 0 0 1-4.8-1.32l-.34-.2-3.56.93.95-3.47-.22-.36a9.4 9.4 0 0 1-1.44-5.02c0-5.2 4.23-9.43 9.43-9.43 2.52 0 4.89.98 6.67 2.77a9.37 9.37 0 0 1 2.76 6.67c0 5.2-4.23 9.43-9.44 9.43zm8.03-17.46A11.3 11.3 0 0 0 12.05.72C5.79.72.7 5.81.7 12.07c0 2 .52 3.95 1.52 5.67L.6 23.6l6-1.57a11.32 11.32 0 0 0 5.44 1.39h.01c6.26 0 11.35-5.1 11.35-11.35 0-3.03-1.18-5.88-3.32-8.03z" }) });
|
|
1401
|
-
}
|
|
1402
|
-
|
|
1403
|
-
// src/data/tables.ts
|
|
1404
|
-
var T = {
|
|
1405
|
-
conversations: "plg_conversations",
|
|
1406
|
-
/** A LEITURA da conversa: a mesma linha, com o dono do lead junto. A escrita
|
|
1407
|
-
* continua na tabela — view com `security_invoker` não aceita INSERT aqui. */
|
|
1408
|
-
conversationsView: "v_conversations",
|
|
1409
|
-
messages: "plg_conversation_messages",
|
|
1410
|
-
channels: "plg_conversations_channels"
|
|
83
|
+
],
|
|
84
|
+
howItSyncs: [
|
|
85
|
+
"As mensagens saem pelo n\xFAmero da pr\xF3pria casa, pareado lendo um QR com o WhatsApp do celular (Aparelhos conectados).",
|
|
86
|
+
"Manda texto livre para qualquer cliente, sem template aprovado e sem a janela de 24h do WhatsApp oficial.",
|
|
87
|
+
"\xC9 o WhatsApp Web por baixo: o n\xFAmero pode ser banido sem aviso e a sess\xE3o pode cair. Use um n\xFAmero j\xE1 ativo, e pareie de novo se cair."
|
|
88
|
+
],
|
|
89
|
+
getStatus: evolutionStatus,
|
|
90
|
+
disconnectNote: "Para desconectar, desative o provedor ou desconecte o aparelho no WhatsApp do celular. As conversas que j\xE1 existem continuam aqui.",
|
|
91
|
+
ExtraPanel: EvolutionPanel
|
|
1411
92
|
};
|
|
1412
93
|
var tocar = playChime;
|
|
1413
94
|
var pedirPermissao = requestNotificationPermission;
|
|
1414
95
|
var avisarNoSistema = (titulo, corpo, tag) => notifySystem(titulo, corpo, tag);
|
|
1415
|
-
|
|
1416
|
-
function InboxWatcher({ label, route = "/conversations", store: store2, config, inHeader }) {
|
|
1417
|
-
const Panel = React14.useMemo(() => {
|
|
1418
|
-
if (!store2) return null;
|
|
1419
|
-
const C = () => React14.createElement(ConversationsRailPanel, { store: store2, config });
|
|
1420
|
-
C.displayName = "ConversationsRailPanel";
|
|
1421
|
-
return C;
|
|
1422
|
-
}, [store2, config]);
|
|
1423
|
-
const railOpen = useRightRailStore((s) => s.open && s.active === RAIL_ID);
|
|
1424
|
-
const togglePanel = useRightRailStore((s) => s.togglePanel);
|
|
96
|
+
function InboxWatcher({ label, route = "/conversations", section = "main", position = 1 }) {
|
|
1425
97
|
const tenantId = useActiveTenantId();
|
|
1426
|
-
const
|
|
1427
|
-
const
|
|
1428
|
-
|
|
1429
|
-
React14.useEffect(() => {
|
|
98
|
+
const [naoLidas, setNaoLidas] = React.useState(0);
|
|
99
|
+
const podeTocar = React.useRef(false);
|
|
100
|
+
React.useEffect(() => {
|
|
1430
101
|
const liberar = () => {
|
|
1431
102
|
podeTocar.current = true;
|
|
1432
103
|
void pedirPermissao();
|
|
@@ -1438,7 +109,7 @@ function InboxWatcher({ label, route = "/conversations", store: store2, config,
|
|
|
1438
109
|
for (const ev of ["pointerdown", "keydown", "touchstart"]) window.removeEventListener(ev, liberar);
|
|
1439
110
|
};
|
|
1440
111
|
}, []);
|
|
1441
|
-
const contar =
|
|
112
|
+
const contar = React.useCallback(async () => {
|
|
1442
113
|
const supabase = getSupabaseClientOptional();
|
|
1443
114
|
if (!supabase) return;
|
|
1444
115
|
const { data } = await supabase.from(T.conversations).select("id, status, last_message_direction, unread_count").eq("status", "open");
|
|
@@ -1450,10 +121,10 @@ function InboxWatcher({ label, route = "/conversations", store: store2, config,
|
|
|
1450
121
|
})).length;
|
|
1451
122
|
setNaoLidas(n);
|
|
1452
123
|
}, []);
|
|
1453
|
-
|
|
124
|
+
React.useEffect(() => {
|
|
1454
125
|
void contar();
|
|
1455
126
|
}, [contar]);
|
|
1456
|
-
|
|
127
|
+
React.useEffect(() => {
|
|
1457
128
|
if (!tenantId) return;
|
|
1458
129
|
return assinarChegada(tenantId, ({ conversationId }) => {
|
|
1459
130
|
void contar();
|
|
@@ -1471,155 +142,8 @@ function InboxWatcher({ label, route = "/conversations", store: store2, config,
|
|
|
1471
142
|
})();
|
|
1472
143
|
});
|
|
1473
144
|
}, [contar, tenantId, label]);
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
label,
|
|
1477
|
-
icon: MessageCircle,
|
|
1478
|
-
order: 12,
|
|
1479
|
-
badge: { count: naoLidas, tone: "destructive" },
|
|
1480
|
-
Component: Panel
|
|
1481
|
-
} : null, [Panel, label, naoLidas]));
|
|
1482
|
-
const naPagina = path === route || path.startsWith(`${route}/`);
|
|
1483
|
-
const aberta = naPagina || railOpen;
|
|
1484
|
-
const surface = useWidgetSurface();
|
|
1485
|
-
const named = !inHeader && surface === "rail";
|
|
1486
|
-
return /* @__PURE__ */ jsxs(
|
|
1487
|
-
Button,
|
|
1488
|
-
{
|
|
1489
|
-
variant: "bare",
|
|
1490
|
-
size: "bare",
|
|
1491
|
-
type: "button",
|
|
1492
|
-
onClick: () => Panel && !naPagina ? togglePanel(RAIL_ID) : adminNavigateTo(route),
|
|
1493
|
-
"aria-expanded": Panel ? railOpen : void 0,
|
|
1494
|
-
"aria-label": naoLidas > 0 ? `${label} (${naoLidas})` : label,
|
|
1495
|
-
title: label,
|
|
1496
|
-
"aria-current": aberta ? "page" : void 0,
|
|
1497
|
-
className: `fz-tap relative inline-flex shrink-0 items-center rounded-button transition-colors hover:bg-accent ${named ? "w-full gap-3 px-2.5 py-2.5" : "justify-center p-2"} ${aberta ? "text-foreground" : "text-muted-foreground hover:text-foreground"}`,
|
|
1498
|
-
children: [
|
|
1499
|
-
/* @__PURE__ */ jsx(MessageCircle, { className: "h-4 w-4 shrink-0" }),
|
|
1500
|
-
named && /* @__PURE__ */ jsx("span", { className: "min-w-0 flex-1 truncate text-left", children: label }),
|
|
1501
|
-
naoLidas > 0 && /* @__PURE__ */ jsx("span", { className: "absolute -right-0.5 -top-0.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-medium leading-none text-destructive-foreground tabular-nums", children: naoLidas > 9 ? "9+" : naoLidas })
|
|
1502
|
-
]
|
|
1503
|
-
}
|
|
1504
|
-
);
|
|
1505
|
-
}
|
|
1506
|
-
var CHANNELS2 = ["sms", "whatsapp", "instagram", "email", "webchat"];
|
|
1507
|
-
function requestedContact() {
|
|
1508
|
-
try {
|
|
1509
|
-
const hash = window.location.hash;
|
|
1510
|
-
const qs = hash.includes("?") ? hash.slice(hash.indexOf("?") + 1) : "";
|
|
1511
|
-
if (!qs) return null;
|
|
1512
|
-
const p = new URLSearchParams(qs);
|
|
1513
|
-
const handle = (p.get("handle") ?? "").trim();
|
|
1514
|
-
const name = (p.get("name") ?? "").trim();
|
|
1515
|
-
const personId = (p.get("personId") ?? "").trim() || void 0;
|
|
1516
|
-
if (!handle) return null;
|
|
1517
|
-
const raw = (p.get("channel") ?? "whatsapp").toLowerCase();
|
|
1518
|
-
const channel = CHANNELS2.includes(raw) ? raw : "whatsapp";
|
|
1519
|
-
return { personId, name: name || handle, handle, channel };
|
|
1520
|
-
} catch {
|
|
1521
|
-
return null;
|
|
1522
|
-
}
|
|
1523
|
-
}
|
|
1524
|
-
function ConversationsPage({ store: store2, config }) {
|
|
1525
|
-
React14__default.useEffect(() => {
|
|
1526
|
-
const wanted = requestedContact();
|
|
1527
|
-
if (!wanted) {
|
|
1528
|
-
void store2.getState().load();
|
|
1529
|
-
return;
|
|
1530
|
-
}
|
|
1531
|
-
void store2.getState().openForPerson(wanted).catch(() => {
|
|
1532
|
-
void store2.getState().load();
|
|
1533
|
-
});
|
|
1534
|
-
try {
|
|
1535
|
-
const hash = window.location.hash;
|
|
1536
|
-
const clean = hash.includes("?") ? hash.slice(0, hash.indexOf("?")) : hash;
|
|
1537
|
-
window.history.replaceState(null, "", clean || "#/conversations");
|
|
1538
|
-
} catch {
|
|
1539
|
-
}
|
|
1540
|
-
}, []);
|
|
1541
|
-
return /* @__PURE__ */ jsx(ConversationsContextProvider, { store: store2, config, children: /* @__PURE__ */ jsx(InboxView, {}) });
|
|
1542
|
-
}
|
|
1543
|
-
var money = (n) => formatCurrency(n, "BRL");
|
|
1544
|
-
var PROMISES = [
|
|
1545
|
-
["Answers your clients on WhatsApp, day and night", "Responde seus clientes no WhatsApp, de dia e de noite"],
|
|
1546
|
-
["Books, confirms and takes orders by itself", "Agenda, confirma e anota pedidos sozinho"],
|
|
1547
|
-
["Hands over to a person when it should", "Passa para uma pessoa quando precisa"],
|
|
1548
|
-
["Learns your prices, hours and services", "Aprende seus pre\xE7os, hor\xE1rios e servi\xE7os"]
|
|
1549
|
-
];
|
|
1550
|
-
function AtendeAIPage() {
|
|
1551
|
-
const runtime = usePluginRuntimeOptional();
|
|
1552
|
-
const plugin = runtime?.plugins.find((p) => p.id === "conversations");
|
|
1553
|
-
const on = plugin?.config?.facets?.atendeai === true;
|
|
1554
|
-
const price = PRICE_BOOK["conversations.atendeai"];
|
|
1555
|
-
const go = (hash) => {
|
|
1556
|
-
window.location.hash = hash;
|
|
1557
|
-
};
|
|
1558
|
-
if (!on) {
|
|
1559
|
-
return /* @__PURE__ */ jsxs("div", { className: "mx-auto max-w-3xl space-y-4", children: [
|
|
1560
|
-
/* @__PURE__ */ jsx(Card, { className: "overflow-hidden border-violet-200 dark:border-violet-900", children: /* @__PURE__ */ jsxs("div", { className: "space-y-5 bg-gradient-to-br from-violet-50 to-transparent p-6 dark:from-violet-950/40", children: [
|
|
1561
|
-
/* @__PURE__ */ jsxs("div", { className: "flex items-start gap-3", children: [
|
|
1562
|
-
/* @__PURE__ */ jsx("span", { className: "flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-violet-100 text-violet-700 dark:bg-violet-900/60 dark:text-violet-200", children: /* @__PURE__ */ jsx(Bot, { className: "h-6 w-6", "aria-hidden": true }) }),
|
|
1563
|
-
/* @__PURE__ */ jsxs("div", { className: "min-w-0 space-y-1", children: [
|
|
1564
|
-
/* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [
|
|
1565
|
-
/* @__PURE__ */ jsx("h2", { className: "text-lg font-semibold", children: "AtendeAI" }),
|
|
1566
|
-
/* @__PURE__ */ jsxs(Badge, { className: "border-transparent bg-violet-100 text-violet-700 dark:bg-violet-900/60 dark:text-violet-200", children: [
|
|
1567
|
-
/* @__PURE__ */ jsx(Sparkles, { className: "mr-1 h-3 w-3", "aria-hidden": true }),
|
|
1568
|
-
tl("AI", "IA")
|
|
1569
|
-
] })
|
|
1570
|
-
] }),
|
|
1571
|
-
/* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: tl(
|
|
1572
|
-
"An attendant that never sleeps: it talks to your clients on WhatsApp and only calls your team when it matters.",
|
|
1573
|
-
"Um atendente que n\xE3o dorme: conversa com seus clientes no WhatsApp e s\xF3 chama a sua equipe quando importa."
|
|
1574
|
-
) })
|
|
1575
|
-
] })
|
|
1576
|
-
] }),
|
|
1577
|
-
/* @__PURE__ */ jsx("ul", { className: "grid gap-2 sm:grid-cols-2", children: PROMISES.map(([en2, pt]) => /* @__PURE__ */ jsxs("li", { className: "flex items-start gap-2 text-sm", children: [
|
|
1578
|
-
/* @__PURE__ */ jsx(Check, { className: "mt-0.5 h-4 w-4 shrink-0 text-violet-600 dark:text-violet-300", "aria-hidden": true }),
|
|
1579
|
-
/* @__PURE__ */ jsx("span", { children: tl(en2, pt) })
|
|
1580
|
-
] }, pt)) }),
|
|
1581
|
-
/* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between", children: [
|
|
1582
|
-
price && /* @__PURE__ */ jsxs("p", { className: "text-sm", children: [
|
|
1583
|
-
/* @__PURE__ */ jsx("span", { className: "text-2xl font-bold tabular-nums", children: money(price.price) }),
|
|
1584
|
-
/* @__PURE__ */ jsxs("span", { className: "text-muted-foreground", children: [
|
|
1585
|
-
" ",
|
|
1586
|
-
price.per === "unit" ? tl("per unit / month", "por unidade / m\xEAs") : tl("/ month", "/ m\xEAs")
|
|
1587
|
-
] })
|
|
1588
|
-
] }),
|
|
1589
|
-
/* @__PURE__ */ jsxs(Button, { className: "h-11 bg-violet-600 text-white hover:bg-violet-700", onClick: () => go("/settings/modules"), children: [
|
|
1590
|
-
/* @__PURE__ */ jsx(Sparkles, { className: "h-4 w-4", "aria-hidden": true }),
|
|
1591
|
-
tl("Turn on AtendeAI", "Ligar o AtendeAI")
|
|
1592
|
-
] })
|
|
1593
|
-
] })
|
|
1594
|
-
] }) }),
|
|
1595
|
-
/* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: tl("Turned on in Settings \u203A Modules; its settings appear right after.", "Liga em Configura\xE7\xF5es \u203A M\xF3dulos; as configura\xE7\xF5es dele aparecem logo em seguida.") })
|
|
1596
|
-
] });
|
|
1597
|
-
}
|
|
1598
|
-
return /* @__PURE__ */ jsxs("div", { className: "mx-auto max-w-3xl space-y-4", children: [
|
|
1599
|
-
/* @__PURE__ */ jsxs("div", { className: "flex flex-wrap justify-end gap-2", children: [
|
|
1600
|
-
/* @__PURE__ */ jsx(Button, { variant: "ghost", className: "h-11", onClick: () => go("/settings/modules"), children: tl("Manage or turn off", "Gerenciar ou desligar") }),
|
|
1601
|
-
/* @__PURE__ */ jsxs(Button, { variant: "outline", className: "h-11", onClick: () => go("/settings/conversations-atendeai"), children: [
|
|
1602
|
-
/* @__PURE__ */ jsx(Settings2, { className: "h-4 w-4", "aria-hidden": true }),
|
|
1603
|
-
tl("AtendeAI settings", "Configura\xE7\xF5es do AtendeAI")
|
|
1604
|
-
] })
|
|
1605
|
-
] }),
|
|
1606
|
-
/* @__PURE__ */ jsx(
|
|
1607
|
-
ComingSoon,
|
|
1608
|
-
{
|
|
1609
|
-
title: tl("AtendeAI operation", "Opera\xE7\xE3o do AtendeAI"),
|
|
1610
|
-
summary: tl(
|
|
1611
|
-
"Follow what the AI is answering, accept or correct its drafts and teach it new answers.",
|
|
1612
|
-
"Acompanhe o que a IA est\xE1 respondendo, aceite ou corrija os rascunhos e ensine respostas novas."
|
|
1613
|
-
),
|
|
1614
|
-
v1Path: "AtendeAI \u2192 Opera\xE7\xE3o",
|
|
1615
|
-
missing: [
|
|
1616
|
-
tl("The WhatsApp number of each unit connected", "O n\xFAmero de WhatsApp de cada unidade conectado"),
|
|
1617
|
-
tl("The AI answering engine (personality, Q&A, capabilities)", "O motor de respostas da IA (personalidade, perguntas e respostas, capacidades)"),
|
|
1618
|
-
tl("The queue of drafts waiting for acceptance", "A fila de rascunhos esperando aceite")
|
|
1619
|
-
]
|
|
1620
|
-
}
|
|
1621
|
-
)
|
|
1622
|
-
] });
|
|
145
|
+
useNavContribution("conversations:unread", naoLidas > 0 ? [{ id: "conversations", label, route, badge: naoLidas, badgeTone: "destructive", section, position }] : []);
|
|
146
|
+
return null;
|
|
1623
147
|
}
|
|
1624
148
|
|
|
1625
149
|
// src/data/accents.ts
|
|
@@ -1948,7 +472,7 @@ function createSupabaseConversationsProvider(config) {
|
|
|
1948
472
|
if (!config?.tenantId) return void 0;
|
|
1949
473
|
return typeof config.tenantId === "function" ? config.tenantId() : config.tenantId;
|
|
1950
474
|
}
|
|
1951
|
-
function
|
|
475
|
+
function client3() {
|
|
1952
476
|
const supabase = config?.supabaseClient ?? getSupabaseClientOptional();
|
|
1953
477
|
if (!supabase) {
|
|
1954
478
|
throw new Error(
|
|
@@ -1959,7 +483,7 @@ function createSupabaseConversationsProvider(config) {
|
|
|
1959
483
|
}
|
|
1960
484
|
return {
|
|
1961
485
|
async listConversations(query) {
|
|
1962
|
-
let q =
|
|
486
|
+
let q = client3().from(T.conversationsView).select("*");
|
|
1963
487
|
const tenantId = resolveTenantId();
|
|
1964
488
|
if (tenantId) q = q.eq("tenant_id", tenantId);
|
|
1965
489
|
if (query?.channel && query.channel !== "all") {
|
|
@@ -1987,7 +511,7 @@ function createSupabaseConversationsProvider(config) {
|
|
|
1987
511
|
return (data ?? []).map(mapConversation);
|
|
1988
512
|
},
|
|
1989
513
|
async getMessages(conversationId) {
|
|
1990
|
-
const selected =
|
|
514
|
+
const selected = client3().from(T.messages).select("*");
|
|
1991
515
|
const filtered = selected.eq(
|
|
1992
516
|
"conversation_id",
|
|
1993
517
|
conversationId
|
|
@@ -2024,7 +548,7 @@ function createSupabaseConversationsProvider(config) {
|
|
|
2024
548
|
note: input.note?.trim() || null
|
|
2025
549
|
};
|
|
2026
550
|
if (tenantId) convRow.tenant_id = tenantId;
|
|
2027
|
-
const { data: created, error } = await
|
|
551
|
+
const { data: created, error } = await client3().from(T.conversations).insert(convRow).select().single();
|
|
2028
552
|
if (error) throw error;
|
|
2029
553
|
if (!created) throw new Error("Conversation not created");
|
|
2030
554
|
if (firstMessage) {
|
|
@@ -2037,13 +561,13 @@ function createSupabaseConversationsProvider(config) {
|
|
|
2037
561
|
at: now
|
|
2038
562
|
};
|
|
2039
563
|
if (tenantId) msgRow.tenant_id = tenantId;
|
|
2040
|
-
await
|
|
564
|
+
await client3().from(T.messages).insert(msgRow);
|
|
2041
565
|
}
|
|
2042
566
|
return mapConversation(created);
|
|
2043
567
|
},
|
|
2044
568
|
async sendMessage(input) {
|
|
2045
569
|
const tenantId = resolveTenantId();
|
|
2046
|
-
const convSelected =
|
|
570
|
+
const convSelected = client3().from(T.conversations).select(
|
|
2047
571
|
"channel, contact_handle"
|
|
2048
572
|
);
|
|
2049
573
|
const convFiltered = convSelected.eq(
|
|
@@ -2062,14 +586,14 @@ function createSupabaseConversationsProvider(config) {
|
|
|
2062
586
|
at
|
|
2063
587
|
};
|
|
2064
588
|
if (tenantId) row.tenant_id = tenantId;
|
|
2065
|
-
const { data: created, error } = await
|
|
589
|
+
const { data: created, error } = await client3().from(T.messages).insert(row).select().single();
|
|
2066
590
|
if (error) throw error;
|
|
2067
591
|
let sentId = null;
|
|
2068
592
|
let sentStatus = null;
|
|
2069
593
|
if (channel === "whatsapp") {
|
|
2070
594
|
const handle = String(conv?.contact_handle ?? "");
|
|
2071
595
|
try {
|
|
2072
|
-
const { data: out, error: sendErr } = await
|
|
596
|
+
const { data: out, error: sendErr } = await client3().functions.invoke(
|
|
2073
597
|
"conversations-provider",
|
|
2074
598
|
{ body: { tenantId, action: "send", to: handle, text: input.body } }
|
|
2075
599
|
);
|
|
@@ -2081,7 +605,7 @@ function createSupabaseConversationsProvider(config) {
|
|
|
2081
605
|
sentId = out?.providerMessageId ?? null;
|
|
2082
606
|
sentStatus = out?.queued ? "queued" : mapDeliveryStatus(out?.status);
|
|
2083
607
|
if (created?.id) {
|
|
2084
|
-
await
|
|
608
|
+
await client3().from(T.messages).update({
|
|
2085
609
|
provider_message_id: sentId,
|
|
2086
610
|
// O provedor fala o dialeto dele. A Tyxter devolve `accepted`,
|
|
2087
611
|
// que NÃO existe no CHECK desta coluna — o update explodia, caía
|
|
@@ -2092,14 +616,14 @@ function createSupabaseConversationsProvider(config) {
|
|
|
2092
616
|
}
|
|
2093
617
|
} catch (err) {
|
|
2094
618
|
if (created?.id) {
|
|
2095
|
-
await
|
|
619
|
+
await client3().from(T.messages).update({
|
|
2096
620
|
delivery_status: "failed"
|
|
2097
621
|
}).eq("id", String(created.id));
|
|
2098
622
|
}
|
|
2099
623
|
throw err;
|
|
2100
624
|
}
|
|
2101
625
|
}
|
|
2102
|
-
await
|
|
626
|
+
await client3().from(T.conversations).update({
|
|
2103
627
|
last_message_preview: input.body,
|
|
2104
628
|
last_message_at: at,
|
|
2105
629
|
unread_count: 0,
|
|
@@ -2112,7 +636,7 @@ function createSupabaseConversationsProvider(config) {
|
|
|
2112
636
|
};
|
|
2113
637
|
},
|
|
2114
638
|
async markRead(conversationId) {
|
|
2115
|
-
const { error } = await
|
|
639
|
+
const { error } = await client3().from(T.conversations).update({
|
|
2116
640
|
unread_count: 0,
|
|
2117
641
|
// O instante é o que tira a bolinha. `unread_count` sozinho não
|
|
2118
642
|
// tirava — a fila olha a DIREÇÃO da última mensagem, e ela continua
|
|
@@ -2122,7 +646,7 @@ function createSupabaseConversationsProvider(config) {
|
|
|
2122
646
|
if (error) throw error;
|
|
2123
647
|
},
|
|
2124
648
|
async setStatus(conversationId, status) {
|
|
2125
|
-
const updated =
|
|
649
|
+
const updated = client3().from(T.conversations).update({
|
|
2126
650
|
status
|
|
2127
651
|
});
|
|
2128
652
|
const filtered = updated.eq("id", conversationId);
|
|
@@ -2585,7 +1109,7 @@ function WaitingTable() {
|
|
|
2585
1109
|
loading: loading && waiting.length === 0,
|
|
2586
1110
|
emptyMessage: t("conversations.dashboard.needsYouEmpty"),
|
|
2587
1111
|
onRowClick: (row) => {
|
|
2588
|
-
|
|
1112
|
+
navigateApp(`/conversations?thread=${row.id}`);
|
|
2589
1113
|
}
|
|
2590
1114
|
}
|
|
2591
1115
|
);
|
|
@@ -2610,464 +1134,23 @@ function createConversationsDashboardWidgets(ctx) {
|
|
|
2610
1134
|
component: withCtx(WaitingKpi)
|
|
2611
1135
|
}),
|
|
2612
1136
|
defineKpiWidget({
|
|
2613
|
-
id: "conversations.kpi.open",
|
|
2614
|
-
title: "conversations.dashboard.openThreads",
|
|
2615
|
-
domain: "conversations",
|
|
2616
|
-
defaultOrder: 4,
|
|
2617
|
-
defaultVisible: false,
|
|
2618
|
-
component: withCtx(OpenThreadsKpi)
|
|
2619
|
-
}),
|
|
2620
|
-
defineTableWidget({
|
|
2621
|
-
id: "conversations.table.waiting",
|
|
2622
|
-
title: "conversations.dashboard.needsYou",
|
|
2623
|
-
domain: "conversations",
|
|
2624
|
-
span: 2,
|
|
2625
|
-
defaultOrder: 10,
|
|
2626
|
-
component: withCtx(WaitingTable)
|
|
2627
|
-
})
|
|
2628
|
-
];
|
|
2629
|
-
}
|
|
2630
|
-
var COPY = {
|
|
2631
|
-
tyxter: {
|
|
2632
|
-
name: "Tyxter",
|
|
2633
|
-
what: "API oficial do WhatsApp Business, via revenda.",
|
|
2634
|
-
strength: "Entrega confi\xE1vel, sem risco de banimento. A Meta mede a qualidade do n\xFAmero.",
|
|
2635
|
-
cost: "Para iniciar conversa \xE9 obrigat\xF3rio um template aprovado pela Meta (~1 dia). Texto livre s\xF3 dentro de 24h da resposta do cliente.",
|
|
2636
|
-
fields: [
|
|
2637
|
-
{ key: "api_key", label: "Chave da API", placeholder: "tx_live_\u2026", secret: true },
|
|
2638
|
-
{ key: "default_sender_id", label: "N\xFAmero remetente (id)", placeholder: "cm\u2026" }
|
|
2639
|
-
]
|
|
2640
|
-
},
|
|
2641
|
-
evolution: {
|
|
2642
|
-
name: "Evolution API",
|
|
2643
|
-
what: "Gateway auto-hospedado, sobre o WhatsApp Web.",
|
|
2644
|
-
strength: "Manda texto livre para quem quiser, sem template e sem janela de 24h.",
|
|
2645
|
-
cost: "O n\xFAmero pode ser banido sem aviso, e a sess\xE3o cai sozinha. Aquecer o n\xFAmero n\xE3o \xE9 opcional aqui.",
|
|
2646
|
-
fields: [
|
|
2647
|
-
{ key: "base_url", label: "URL da inst\xE2ncia", placeholder: "https://evo.seudominio.com" },
|
|
2648
|
-
{ key: "api_key", label: "Chave da API", placeholder: "sua apikey", secret: true },
|
|
2649
|
-
{ key: "instance", label: "Nome da inst\xE2ncia", placeholder: "chefcontrol" }
|
|
2650
|
-
]
|
|
2651
|
-
}
|
|
2652
|
-
};
|
|
2653
|
-
var ALL = ["tyxter", "evolution"];
|
|
2654
|
-
function StatusChip({ row }) {
|
|
2655
|
-
if (!row || row.status === "unconfigured") {
|
|
2656
|
-
return /* @__PURE__ */ jsx("span", { className: "rounded-full bg-muted px-2 py-0.5 text-[10px] text-muted-foreground", children: "n\xE3o configurado" });
|
|
2657
|
-
}
|
|
2658
|
-
const map = {
|
|
2659
|
-
connected: "bg-success-soft text-success-soft-foreground",
|
|
2660
|
-
connecting: "bg-warning-soft text-warning-soft-foreground",
|
|
2661
|
-
disconnected: "bg-muted text-muted-foreground",
|
|
2662
|
-
error: "bg-destructive-soft text-destructive-soft-foreground"
|
|
2663
|
-
};
|
|
2664
|
-
const label = {
|
|
2665
|
-
connected: "conectado",
|
|
2666
|
-
connecting: "aguardando teste",
|
|
2667
|
-
disconnected: "desconectado",
|
|
2668
|
-
error: "com erro"
|
|
2669
|
-
};
|
|
2670
|
-
return /* @__PURE__ */ jsx("span", { className: `rounded-full px-2 py-0.5 text-[10px] font-medium ${map[row.status] ?? "bg-muted"}`, children: label[row.status] ?? row.status });
|
|
2671
|
-
}
|
|
2672
|
-
function NumberRow({ n }) {
|
|
2673
|
-
return /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center gap-x-3 gap-y-1 py-1.5 text-xs", children: [
|
|
2674
|
-
/* @__PURE__ */ jsx("span", { className: `h-1.5 w-1.5 shrink-0 rounded-full ${n.ready ? "bg-success" : "bg-muted-foreground/40"}` }),
|
|
2675
|
-
/* @__PURE__ */ jsx("span", { className: "font-medium tabular-nums", children: n.phone ?? n.id }),
|
|
2676
|
-
n.displayName && /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: n.displayName }),
|
|
2677
|
-
/* @__PURE__ */ jsxs("span", { className: "text-muted-foreground", children: [
|
|
2678
|
-
"qualidade: ",
|
|
2679
|
-
/* @__PURE__ */ jsx("span", { className: "text-foreground", children: n.quality ?? "\u2014" })
|
|
2680
|
-
] }),
|
|
2681
|
-
/* @__PURE__ */ jsxs("span", { className: "text-muted-foreground", children: [
|
|
2682
|
-
"limite: ",
|
|
2683
|
-
/* @__PURE__ */ jsx("span", { className: "text-foreground", children: n.tier ?? "\u2014" })
|
|
2684
|
-
] }),
|
|
2685
|
-
n.used24h != null && n.allowance != null && /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground", children: [
|
|
2686
|
-
"24h: ",
|
|
2687
|
-
/* @__PURE__ */ jsxs("span", { className: "text-foreground tabular-nums", children: [
|
|
2688
|
-
n.used24h,
|
|
2689
|
-
"/",
|
|
2690
|
-
n.used24h + n.allowance
|
|
2691
|
-
] })
|
|
2692
|
-
] }),
|
|
2693
|
-
!n.ready && /* @__PURE__ */ jsx("span", { className: "text-warning", children: "n\xE3o pronto para enviar" })
|
|
2694
|
-
] });
|
|
2695
|
-
}
|
|
2696
|
-
function WhatsAppProviders({ only, onChanged } = {}) {
|
|
2697
|
-
const [rows, setRows] = React14__default.useState(null);
|
|
2698
|
-
const [draft, setDraft] = React14__default.useState({});
|
|
2699
|
-
const [busy, setBusy] = React14__default.useState(null);
|
|
2700
|
-
const [numbers, setNumbers] = React14__default.useState({});
|
|
2701
|
-
const [qr, setQr] = React14__default.useState(null);
|
|
2702
|
-
const load = React14__default.useCallback(async () => {
|
|
2703
|
-
try {
|
|
2704
|
-
setRows(await providersApi.list());
|
|
2705
|
-
} catch {
|
|
2706
|
-
setRows([]);
|
|
2707
|
-
}
|
|
2708
|
-
onChanged?.();
|
|
2709
|
-
}, [onChanged]);
|
|
2710
|
-
React14__default.useEffect(() => {
|
|
2711
|
-
void load();
|
|
2712
|
-
}, [load]);
|
|
2713
|
-
const byId = React14__default.useMemo(
|
|
2714
|
-
() => Object.fromEntries((rows ?? []).map((r) => [r.provider, r])),
|
|
2715
|
-
[rows]
|
|
2716
|
-
);
|
|
2717
|
-
function field(p, key) {
|
|
2718
|
-
const row = byId[p];
|
|
2719
|
-
if (key === "api_key") return draft[p]?.[key] ?? "";
|
|
2720
|
-
return draft[p]?.[key] ?? String(row?.config?.[key] ?? "");
|
|
2721
|
-
}
|
|
2722
|
-
function setField(p, key, v) {
|
|
2723
|
-
setDraft((d) => ({ ...d, [p]: { ...d[p] ?? {}, [key]: v } }));
|
|
2724
|
-
}
|
|
2725
|
-
async function run(id, fn) {
|
|
2726
|
-
setBusy(id);
|
|
2727
|
-
try {
|
|
2728
|
-
await fn();
|
|
2729
|
-
} catch (e) {
|
|
2730
|
-
toast.error(e?.message ?? "Falhou");
|
|
2731
|
-
} finally {
|
|
2732
|
-
setBusy(null);
|
|
2733
|
-
}
|
|
2734
|
-
}
|
|
2735
|
-
const save = (p) => run(`save:${p}`, async () => {
|
|
2736
|
-
const d = draft[p] ?? {};
|
|
2737
|
-
const config = {};
|
|
2738
|
-
for (const f of COPY[p].fields) {
|
|
2739
|
-
if (f.secret) continue;
|
|
2740
|
-
const v = field(p, f.key);
|
|
2741
|
-
if (v) config[f.key] = v;
|
|
2742
|
-
}
|
|
2743
|
-
if (p === "tyxter" && !config.api_base_url) config.api_base_url = "https://api.tyxter.com";
|
|
2744
|
-
await providersApi.save(p, config, d.api_key);
|
|
2745
|
-
setDraft((x) => ({ ...x, [p]: { ...x[p] ?? {}, api_key: "" } }));
|
|
2746
|
-
await load();
|
|
2747
|
-
toast.success("Guardado");
|
|
2748
|
-
});
|
|
2749
|
-
const test = (p) => run(`test:${p}`, async () => {
|
|
2750
|
-
const h = await providersApi.health(p);
|
|
2751
|
-
await load();
|
|
2752
|
-
toast[h.ok ? "success" : "error"](h.ok ? `Conectado \u2014 ${h.detail ?? "ok"}` : `N\xE3o conectou: ${h.detail ?? "?"}`);
|
|
2753
|
-
});
|
|
2754
|
-
const refreshNumbers = (p) => run(`numbers:${p}`, async () => {
|
|
2755
|
-
const d = await providersApi.numbers(p);
|
|
2756
|
-
setNumbers((n) => ({ ...n, [p]: d.numbers }));
|
|
2757
|
-
if (d.numbers.length === 0) toast.error("Nenhum n\xFAmero nesta conta");
|
|
2758
|
-
});
|
|
2759
|
-
const activate = (p) => run(`on:${p}`, async () => {
|
|
2760
|
-
await providersApi.activate(p);
|
|
2761
|
-
await load();
|
|
2762
|
-
toast.success(`${COPY[p].name} est\xE1 valendo agora`);
|
|
2763
|
-
});
|
|
2764
|
-
const pair = (p) => run(`qr:${p}`, async () => {
|
|
2765
|
-
const d = await providersApi.pair(p);
|
|
2766
|
-
setQr({ provider: p, image: d.qr, code: d.code });
|
|
2767
|
-
if (!d.qr && !d.code) toast.error("A inst\xE2ncia n\xE3o devolveu QR \u2014 j\xE1 est\xE1 pareada?");
|
|
2768
|
-
});
|
|
2769
|
-
const body = /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
2770
|
-
rows === null && /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 py-6 text-sm text-muted-foreground", children: [
|
|
2771
|
-
/* @__PURE__ */ jsx(Loader2, { className: "h-4 w-4 animate-spin" }),
|
|
2772
|
-
" carregando\u2026"
|
|
2773
|
-
] }),
|
|
2774
|
-
rows !== null && (only ? [only] : ALL).map((p) => {
|
|
2775
|
-
const row = byId[p];
|
|
2776
|
-
const copy = COPY[p];
|
|
2777
|
-
const active = Boolean(row?.isActive);
|
|
2778
|
-
const caps = row?.capabilities ?? {};
|
|
2779
|
-
return /* @__PURE__ */ jsxs(
|
|
2780
|
-
"div",
|
|
2781
|
-
{
|
|
2782
|
-
className: `rounded-card border p-4 my-2 ${active ? "border-primary bg-primary/[0.03]" : "bg-card"}`,
|
|
2783
|
-
"data-testid": `provider-${p}`,
|
|
2784
|
-
children: [
|
|
2785
|
-
/* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [
|
|
2786
|
-
/* @__PURE__ */ jsx("span", { className: "text-sm font-semibold", children: copy.name }),
|
|
2787
|
-
active && /* @__PURE__ */ jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-primary px-2 py-0.5 text-[10px] font-medium text-primary-foreground", children: [
|
|
2788
|
-
/* @__PURE__ */ jsx(Check, { className: "h-3 w-3" }),
|
|
2789
|
-
" valendo agora"
|
|
2790
|
-
] }),
|
|
2791
|
-
/* @__PURE__ */ jsx(StatusChip, { row }),
|
|
2792
|
-
row?.hasCredential && /* @__PURE__ */ jsxs("span", { className: "inline-flex items-center gap-1 text-[10px] text-muted-foreground", children: [
|
|
2793
|
-
/* @__PURE__ */ jsx(ShieldCheck, { className: "h-3 w-3" }),
|
|
2794
|
-
" chave guardada"
|
|
2795
|
-
] }),
|
|
2796
|
-
caps.ban_risk === "high" && /* @__PURE__ */ jsxs("span", { className: "inline-flex items-center gap-1 text-[10px] text-warning", children: [
|
|
2797
|
-
/* @__PURE__ */ jsx(ShieldAlert, { className: "h-3 w-3" }),
|
|
2798
|
-
" risco de banimento"
|
|
2799
|
-
] })
|
|
2800
|
-
] }),
|
|
2801
|
-
/* @__PURE__ */ jsx("p", { className: "mt-1 text-xs text-muted-foreground", children: copy.what }),
|
|
2802
|
-
/* @__PURE__ */ jsxs("div", { className: "mt-2 grid gap-1 sm:grid-cols-2", children: [
|
|
2803
|
-
/* @__PURE__ */ jsxs("p", { className: "text-[11px] text-success-soft-foreground", children: [
|
|
2804
|
-
"\u2713 ",
|
|
2805
|
-
copy.strength
|
|
2806
|
-
] }),
|
|
2807
|
-
/* @__PURE__ */ jsxs("p", { className: "text-[11px] text-warning", children: [
|
|
2808
|
-
"! ",
|
|
2809
|
-
copy.cost
|
|
2810
|
-
] })
|
|
2811
|
-
] }),
|
|
2812
|
-
/* @__PURE__ */ jsx("div", { className: "mt-3 grid gap-2 sm:grid-cols-2", children: copy.fields.map((f) => /* @__PURE__ */ jsxs("label", { className: "block", children: [
|
|
2813
|
-
/* @__PURE__ */ jsx("span", { className: "text-[11px] text-muted-foreground", children: f.label }),
|
|
2814
|
-
/* @__PURE__ */ jsx(
|
|
2815
|
-
Input,
|
|
2816
|
-
{
|
|
2817
|
-
className: "mt-0.5 h-8 text-sm",
|
|
2818
|
-
type: f.secret ? "password" : "text",
|
|
2819
|
-
autoComplete: "off",
|
|
2820
|
-
placeholder: f.secret && row?.hasCredential ? "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022 (guardada)" : f.placeholder,
|
|
2821
|
-
value: field(p, f.key),
|
|
2822
|
-
onChange: (e) => setField(p, f.key, e.target.value)
|
|
2823
|
-
}
|
|
2824
|
-
)
|
|
2825
|
-
] }, f.key)) }),
|
|
2826
|
-
/* @__PURE__ */ jsxs("div", { className: "mt-3 flex flex-wrap gap-2", children: [
|
|
2827
|
-
/* @__PURE__ */ jsx(Button, { size: "sm", variant: "outline", disabled: busy === `save:${p}`, onClick: () => save(p), children: busy === `save:${p}` ? /* @__PURE__ */ jsx(Loader2, { className: "h-3 w-3 animate-spin" }) : "Guardar" }),
|
|
2828
|
-
/* @__PURE__ */ jsx(Button, { size: "sm", variant: "outline", disabled: !row?.hasCredential || busy === `test:${p}`, onClick: () => test(p), children: busy === `test:${p}` ? /* @__PURE__ */ jsx(Loader2, { className: "h-3 w-3 animate-spin" }) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
2829
|
-
/* @__PURE__ */ jsx(RefreshCw, { className: "mr-1 h-3 w-3" }),
|
|
2830
|
-
" Testar"
|
|
2831
|
-
] }) }),
|
|
2832
|
-
/* @__PURE__ */ jsx(Button, { size: "sm", variant: "outline", disabled: !row?.hasCredential || busy === `numbers:${p}`, onClick: () => refreshNumbers(p), children: "N\xFAmeros" }),
|
|
2833
|
-
caps.qr_pairing && /* @__PURE__ */ jsxs(Button, { size: "sm", variant: "outline", disabled: !row?.hasCredential || busy === `qr:${p}`, onClick: () => pair(p), children: [
|
|
2834
|
-
/* @__PURE__ */ jsx(QrCode, { className: "mr-1 h-3 w-3" }),
|
|
2835
|
-
" Parear"
|
|
2836
|
-
] }),
|
|
2837
|
-
!active && /* @__PURE__ */ jsxs(Button, { size: "sm", disabled: !row?.hasCredential || busy === `on:${p}`, onClick: () => activate(p), children: [
|
|
2838
|
-
/* @__PURE__ */ jsx(Zap, { className: "mr-1 h-3 w-3" }),
|
|
2839
|
-
" Usar este"
|
|
2840
|
-
] })
|
|
2841
|
-
] }),
|
|
2842
|
-
numbers[p]?.length ? /* @__PURE__ */ jsx("div", { className: "mt-3 border-t pt-2", children: numbers[p].map((n) => /* @__PURE__ */ jsx(NumberRow, { n }, n.id)) }) : null,
|
|
2843
|
-
qr?.provider === p && /* @__PURE__ */ jsxs("div", { className: "mt-3 border-t pt-3", children: [
|
|
2844
|
-
/* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground mb-2", children: "Abra o WhatsApp no celular \u2192 Aparelhos conectados \u2192 Conectar aparelho." }),
|
|
2845
|
-
qr.image && /* @__PURE__ */ jsx("img", { src: qr.image, alt: "QR", className: "h-48 w-48 rounded border bg-white p-2" }),
|
|
2846
|
-
qr.code && /* @__PURE__ */ jsx("p", { className: "mt-2 font-mono text-lg tracking-widest", children: qr.code })
|
|
2847
|
-
] })
|
|
2848
|
-
]
|
|
2849
|
-
},
|
|
2850
|
-
p
|
|
2851
|
-
);
|
|
2852
|
-
})
|
|
2853
|
-
] });
|
|
2854
|
-
return only ? body : /* @__PURE__ */ jsx(
|
|
2855
|
-
SettingsGroup,
|
|
2856
|
-
{
|
|
2857
|
-
title: "WhatsApp \u2014 por onde as mensagens saem",
|
|
2858
|
-
description: "Dois caminhos, um valendo de cada vez. Troque quando quiser; a configura\xE7\xE3o do outro fica guardada.",
|
|
2859
|
-
children: body
|
|
2860
|
-
}
|
|
2861
|
-
);
|
|
2862
|
-
}
|
|
2863
|
-
var UPCOMING = ["Meta Cloud API", "Z-API", "Twilio"];
|
|
2864
|
-
var PROVIDERS = ["tyxter", "evolution"];
|
|
2865
|
-
function providerState(row) {
|
|
2866
|
-
if (row?.isActive) return { label: tl("In use", "Valendo agora"), tone: "default" };
|
|
2867
|
-
if (row?.hasCredential) return { label: tl("Connected", "Conectado"), tone: "secondary" };
|
|
2868
|
-
return { label: tl("Not connected", "N\xE3o conectado"), tone: "outline" };
|
|
2869
|
-
}
|
|
2870
|
-
function WhatsAppConnectorList() {
|
|
2871
|
-
const [rows, setRows] = React14.useState(null);
|
|
2872
|
-
const [open, setOpen] = React14.useState(null);
|
|
2873
|
-
const [hubSelected, setHubSelected] = React14.useState(null);
|
|
2874
|
-
const { connectors } = useConnectors({ hostPluginId: "conversations", includeGrants: false });
|
|
2875
|
-
const panelAvailable = useConnectorsPanelAvailable();
|
|
2876
|
-
const load = React14.useCallback(() => {
|
|
2877
|
-
providersApi.list().then(setRows).catch(() => setRows([]));
|
|
2878
|
-
}, []);
|
|
2879
|
-
React14.useEffect(load, [load]);
|
|
2880
|
-
const others = (connectors ?? []).filter((c) => !PROVIDERS.includes(c.slug));
|
|
2881
|
-
const byId = Object.fromEntries((rows ?? []).map((r) => [r.provider, r]));
|
|
2882
|
-
const tyxterHub = (connectors ?? []).find((c) => c.slug === "tyxter");
|
|
2883
|
-
return /* @__PURE__ */ jsxs("div", { className: "space-y-2", children: [
|
|
2884
|
-
/* @__PURE__ */ jsx("h3", { className: "px-1 text-sm font-semibold", children: tl("WhatsApp providers", "Provedores de WhatsApp") }),
|
|
2885
|
-
/* @__PURE__ */ jsxs("ul", { className: "divide-y rounded-card border bg-card shadow-sm", children: [
|
|
2886
|
-
PROVIDERS.map((p) => {
|
|
2887
|
-
const row = byId[p];
|
|
2888
|
-
const state = providerState(row);
|
|
2889
|
-
const copy = COPY[p];
|
|
2890
|
-
return /* @__PURE__ */ jsxs("li", { className: "flex items-center gap-3 p-4", children: [
|
|
2891
|
-
p === "tyxter" && tyxterHub ? /* @__PURE__ */ jsx(ConnectorMark, { connector: tyxterHub }) : /* @__PURE__ */ jsx("span", { className: "flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-muted text-xs font-semibold text-muted-foreground", children: copy.name.slice(0, 2) }),
|
|
2892
|
-
/* @__PURE__ */ jsxs("span", { className: "min-w-0 flex-1", children: [
|
|
2893
|
-
/* @__PURE__ */ jsxs("span", { className: "flex flex-wrap items-center gap-2", children: [
|
|
2894
|
-
/* @__PURE__ */ jsx("span", { className: "font-medium", children: copy.name }),
|
|
2895
|
-
/* @__PURE__ */ jsx(Badge, { variant: state.tone, className: "font-normal", children: state.label })
|
|
2896
|
-
] }),
|
|
2897
|
-
/* @__PURE__ */ jsx("span", { className: "mt-0.5 block text-xs text-muted-foreground", children: copy.what })
|
|
2898
|
-
] }),
|
|
2899
|
-
/* @__PURE__ */ jsx(Button, { variant: "outline", className: "h-9 shrink-0", onClick: () => setOpen(p), children: row?.hasCredential ? tl("Configure", "Configurar") : tl("Connect", "Conectar") })
|
|
2900
|
-
] }, p);
|
|
2901
|
-
}),
|
|
2902
|
-
others.map((c) => /* @__PURE__ */ jsxs("li", { className: "flex items-center gap-3 p-4", children: [
|
|
2903
|
-
/* @__PURE__ */ jsx(ConnectorMark, { connector: c }),
|
|
2904
|
-
/* @__PURE__ */ jsxs("span", { className: "min-w-0 flex-1", children: [
|
|
2905
|
-
/* @__PURE__ */ jsxs("span", { className: "flex flex-wrap items-center gap-2", children: [
|
|
2906
|
-
/* @__PURE__ */ jsx("span", { className: "font-medium", children: c.name }),
|
|
2907
|
-
/* @__PURE__ */ jsx(StateChip, { state: c.state })
|
|
2908
|
-
] }),
|
|
2909
|
-
c.description && /* @__PURE__ */ jsx("span", { className: "mt-0.5 block text-xs text-muted-foreground", children: c.description })
|
|
2910
|
-
] }),
|
|
2911
|
-
/* @__PURE__ */ jsx(
|
|
2912
|
-
Button,
|
|
2913
|
-
{
|
|
2914
|
-
variant: "outline",
|
|
2915
|
-
className: "h-9 shrink-0",
|
|
2916
|
-
onClick: () => panelAvailable ? openConnectorsPanel(c) : setHubSelected(c),
|
|
2917
|
-
children: c.state === "connected" ? tl("Configure", "Configurar") : tl("Connect", "Conectar")
|
|
2918
|
-
}
|
|
2919
|
-
)
|
|
2920
|
-
] }, c.id)),
|
|
2921
|
-
UPCOMING.map((name) => /* @__PURE__ */ jsxs("li", { className: "flex items-center gap-3 p-4 opacity-70", children: [
|
|
2922
|
-
/* @__PURE__ */ jsx("span", { className: "flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-muted text-xs font-semibold text-muted-foreground", children: name.slice(0, 2) }),
|
|
2923
|
-
/* @__PURE__ */ jsx("span", { className: "min-w-0 flex-1 font-medium", children: name }),
|
|
2924
|
-
/* @__PURE__ */ jsx(Badge, { variant: "secondary", children: tl("Coming soon", "Em breve") })
|
|
2925
|
-
] }, name))
|
|
2926
|
-
] }),
|
|
2927
|
-
/* @__PURE__ */ jsx(Sheet, { open: !!open, onOpenChange: (o) => {
|
|
2928
|
-
if (!o) setOpen(null);
|
|
2929
|
-
}, children: /* @__PURE__ */ jsxs(SheetContent, { width: "max-w-xl", children: [
|
|
2930
|
-
/* @__PURE__ */ jsxs(SheetHeader, { children: [
|
|
2931
|
-
/* @__PURE__ */ jsx(SheetTitle, { children: open ? COPY[open].name : "" }),
|
|
2932
|
-
/* @__PURE__ */ jsx(SheetDescription, { children: tl("One provider sends at a time; the other keeps its settings.", "Um provedor envia por vez; o outro guarda a configura\xE7\xE3o.") })
|
|
2933
|
-
] }),
|
|
2934
|
-
/* @__PURE__ */ jsx(SheetBody, { children: open && /* @__PURE__ */ jsx(WhatsAppProviders, { only: open, onChanged: load }) })
|
|
2935
|
-
] }) }),
|
|
2936
|
-
/* @__PURE__ */ jsx(ConnectorDetail, { connector: hubSelected, open: !!hubSelected, onOpenChange: (o) => {
|
|
2937
|
-
if (!o) setHubSelected(null);
|
|
2938
|
-
} })
|
|
2939
|
-
] });
|
|
2940
|
-
}
|
|
2941
|
-
function mapChannel(r) {
|
|
2942
|
-
return {
|
|
2943
|
-
id: String(r.id),
|
|
2944
|
-
tenantId: r.tenant_id ?? null,
|
|
2945
|
-
channel: r.channel ?? "whatsapp",
|
|
2946
|
-
provider: String(r.provider ?? ""),
|
|
2947
|
-
providerNumberId: String(r.provider_number_id ?? ""),
|
|
2948
|
-
phoneE164: r.phone_e164 ?? null,
|
|
2949
|
-
kind: r.kind ?? "dedicated",
|
|
2950
|
-
status: r.status ?? "requested",
|
|
2951
|
-
displayName: r.display_name ?? null,
|
|
2952
|
-
metadata: r.metadata ?? {},
|
|
2953
|
-
createdAt: String(r.created_at ?? ""),
|
|
2954
|
-
updatedAt: String(r.updated_at ?? "")
|
|
2955
|
-
};
|
|
2956
|
-
}
|
|
2957
|
-
async function listMessagingChannels(options = {}) {
|
|
2958
|
-
const supabase = options.supabaseClient ?? getSupabaseClientOptional();
|
|
2959
|
-
if (!supabase) {
|
|
2960
|
-
throw new Error(
|
|
2961
|
-
"[plugin-conversations] Supabase client not available. Pass supabaseClient or register the global client via createFayzApp."
|
|
2962
|
-
);
|
|
2963
|
-
}
|
|
2964
|
-
let q = supabase.from(T.channels).select("*");
|
|
2965
|
-
if (options.provider) q = q.eq("provider", options.provider);
|
|
2966
|
-
if (options.channel) q = q.eq("channel", options.channel);
|
|
2967
|
-
q = q.order("kind", { ascending: true });
|
|
2968
|
-
const { data, error } = await q;
|
|
2969
|
-
if (error) throw error;
|
|
2970
|
-
return (data ?? []).map(mapChannel);
|
|
2971
|
-
}
|
|
2972
|
-
var CHANNEL_CLAIM_TIMELINE = [
|
|
2973
|
-
"requested",
|
|
2974
|
-
"provisioning",
|
|
2975
|
-
"provisioned",
|
|
2976
|
-
"verifying",
|
|
2977
|
-
"active"
|
|
2978
|
-
];
|
|
2979
|
-
var LIVE_CHANNEL_STATUSES = [
|
|
2980
|
-
"requested",
|
|
2981
|
-
"provisioning",
|
|
2982
|
-
"provisioned",
|
|
2983
|
-
"verifying",
|
|
2984
|
-
"active"
|
|
2985
|
-
];
|
|
2986
|
-
function isDeadChannelStatus(status) {
|
|
2987
|
-
return !LIVE_CHANNEL_STATUSES.includes(status);
|
|
2988
|
-
}
|
|
2989
|
-
function isPendingChannelStatus(status) {
|
|
2990
|
-
return LIVE_CHANNEL_STATUSES.includes(status) && status !== "active";
|
|
2991
|
-
}
|
|
2992
|
-
function findDedicatedChannel(channels, tenantId) {
|
|
2993
|
-
return channels.find(
|
|
2994
|
-
(channel) => channel.kind === "dedicated" && (!tenantId || channel.tenantId === tenantId) && !isDeadChannelStatus(channel.status)
|
|
2995
|
-
) ?? null;
|
|
2996
|
-
}
|
|
2997
|
-
function findFallbackChannel(channels) {
|
|
2998
|
-
return channels.find((channel) => channel.kind === "fallback") ?? null;
|
|
2999
|
-
}
|
|
3000
|
-
function canClaimDedicated(channels, tenantId) {
|
|
3001
|
-
return findDedicatedChannel(channels, tenantId) === null;
|
|
3002
|
-
}
|
|
3003
|
-
function channelLabel(account, t) {
|
|
3004
|
-
const key = `conversations.filter.${account.channel}`;
|
|
3005
|
-
const translated = t(key);
|
|
3006
|
-
return translated === key ? account.channel : translated;
|
|
3007
|
-
}
|
|
3008
|
-
function ChannelRow({ account }) {
|
|
3009
|
-
const t = useTranslation();
|
|
3010
|
-
const pending = isPendingChannelStatus(account.status);
|
|
3011
|
-
return /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-3 border-b py-2.5 last:border-b-0", children: [
|
|
3012
|
-
/* @__PURE__ */ jsxs("div", { className: "min-w-0", children: [
|
|
3013
|
-
/* @__PURE__ */ jsx("p", { className: "truncate text-sm font-medium", children: account.phoneE164 ?? account.displayName ?? channelLabel(account, t) }),
|
|
3014
|
-
/* @__PURE__ */ jsxs("p", { className: "text-xs text-muted-foreground", children: [
|
|
3015
|
-
channelLabel(account, t),
|
|
3016
|
-
" \xB7 ",
|
|
3017
|
-
t(
|
|
3018
|
-
account.kind === "dedicated" ? "conversations.settings.channelDedicated" : "conversations.settings.channelShared"
|
|
3019
|
-
)
|
|
3020
|
-
] })
|
|
3021
|
-
] }),
|
|
3022
|
-
/* @__PURE__ */ jsx(Badge, { variant: account.status === "active" ? "default" : pending ? "secondary" : "outline", children: t(`conversations.settings.status.${account.status}`) === `conversations.settings.status.${account.status}` ? account.status : t(`conversations.settings.status.${account.status}`) })
|
|
3023
|
-
] });
|
|
3024
|
-
}
|
|
3025
|
-
function ConversationsGeneralSettings() {
|
|
3026
|
-
const t = useTranslation();
|
|
3027
|
-
const [channels, setChannels] = React14.useState(null);
|
|
3028
|
-
const [failed, setFailed] = React14.useState(false);
|
|
3029
|
-
React14.useEffect(() => {
|
|
3030
|
-
let cancelled = false;
|
|
3031
|
-
listMessagingChannels().then((rows) => {
|
|
3032
|
-
if (!cancelled) setChannels(rows);
|
|
3033
|
-
}).catch(() => {
|
|
3034
|
-
if (!cancelled) {
|
|
3035
|
-
setFailed(true);
|
|
3036
|
-
setChannels([]);
|
|
3037
|
-
}
|
|
3038
|
-
});
|
|
3039
|
-
return () => {
|
|
3040
|
-
cancelled = true;
|
|
3041
|
-
};
|
|
3042
|
-
}, []);
|
|
3043
|
-
return /* @__PURE__ */ jsx("div", { className: "space-y-6", children: /* @__PURE__ */ jsxs(
|
|
3044
|
-
SettingsGroup,
|
|
3045
|
-
{
|
|
3046
|
-
title: t("conversations.settings.channels"),
|
|
3047
|
-
description: t("conversations.settings.channelsHelp"),
|
|
3048
|
-
children: [
|
|
3049
|
-
channels === null && /* @__PURE__ */ jsx("p", { className: "py-2 text-sm text-muted-foreground", children: t("conversations.list.loading") }),
|
|
3050
|
-
channels !== null && failed && /* @__PURE__ */ jsx("p", { className: "py-2 text-sm text-muted-foreground", children: t("conversations.settings.channelsUnavailable") }),
|
|
3051
|
-
channels !== null && !failed && channels.length === 0 && /* @__PURE__ */ jsx("p", { className: "py-2 text-sm text-muted-foreground", children: t("conversations.settings.channelsEmpty") }),
|
|
3052
|
-
channels?.map((account) => /* @__PURE__ */ jsx(ChannelRow, { account }, account.id))
|
|
3053
|
-
]
|
|
3054
|
-
}
|
|
3055
|
-
) });
|
|
3056
|
-
}
|
|
3057
|
-
ConversationsGeneralSettings.displayName = "ConversationsGeneralSettings";
|
|
3058
|
-
function ConversationsSettingsTab() {
|
|
3059
|
-
const t = useTranslation();
|
|
3060
|
-
return /* @__PURE__ */ jsx(
|
|
3061
|
-
PluginSettingsPanel,
|
|
3062
|
-
{
|
|
3063
|
-
title: t("conversations.settings.title"),
|
|
3064
|
-
generalSettings: /* @__PURE__ */ jsx(ConversationsGeneralSettings, {}),
|
|
3065
|
-
customTabs: [{ id: "connectors", label: tl("Connectors", "Conectores"), icon: "Plug", content: /* @__PURE__ */ jsx(WhatsAppConnectorList, {}) }],
|
|
3066
|
-
routeBase: "/settings/conversations"
|
|
3067
|
-
}
|
|
3068
|
-
);
|
|
1137
|
+
id: "conversations.kpi.open",
|
|
1138
|
+
title: "conversations.dashboard.openThreads",
|
|
1139
|
+
domain: "conversations",
|
|
1140
|
+
defaultOrder: 4,
|
|
1141
|
+
defaultVisible: false,
|
|
1142
|
+
component: withCtx(OpenThreadsKpi)
|
|
1143
|
+
}),
|
|
1144
|
+
defineTableWidget({
|
|
1145
|
+
id: "conversations.table.waiting",
|
|
1146
|
+
title: "conversations.dashboard.needsYou",
|
|
1147
|
+
domain: "conversations",
|
|
1148
|
+
span: 2,
|
|
1149
|
+
defaultOrder: 10,
|
|
1150
|
+
component: withCtx(WaitingTable)
|
|
1151
|
+
})
|
|
1152
|
+
];
|
|
3069
1153
|
}
|
|
3070
|
-
ConversationsSettingsTab.displayName = "ConversationsSettingsTab";
|
|
3071
1154
|
function buildConversationsOnboarding() {
|
|
3072
1155
|
return {
|
|
3073
1156
|
id: "conversations",
|
|
@@ -3122,7 +1205,7 @@ function buildConversationsOnboarding() {
|
|
|
3122
1205
|
};
|
|
3123
1206
|
}
|
|
3124
1207
|
var TYXTER_NUMBER_CLAIM_FUNCTION = "tyxter-number-claim";
|
|
3125
|
-
function
|
|
1208
|
+
function client() {
|
|
3126
1209
|
const supabase = getSupabaseClientOptional();
|
|
3127
1210
|
if (!supabase) throw new Error("Sem conex\xE3o com o banco para falar com a Tyxter.");
|
|
3128
1211
|
return supabase;
|
|
@@ -3134,7 +1217,7 @@ async function invoke(body) {
|
|
|
3134
1217
|
} catch {
|
|
3135
1218
|
headers = void 0;
|
|
3136
1219
|
}
|
|
3137
|
-
const { data, error } = await
|
|
1220
|
+
const { data, error } = await client().functions.invoke(TYXTER_NUMBER_CLAIM_FUNCTION, {
|
|
3138
1221
|
body,
|
|
3139
1222
|
...headers ? { headers } : {}
|
|
3140
1223
|
});
|
|
@@ -3515,2144 +1598,6 @@ var tyxterConnectorDef = {
|
|
|
3515
1598
|
disconnectNote: "Desconectar para de enviar e de receber mensagens no WhatsApp. As conversas que j\xE1 existem continuam aqui.",
|
|
3516
1599
|
ExtraPanel: TyxterExtraPanel
|
|
3517
1600
|
};
|
|
3518
|
-
|
|
3519
|
-
// src/migrations/index.ts
|
|
3520
|
-
var MIGRATION_000_BASELINE = `-- ============================================================================
|
|
3521
|
-
-- plugins/plugin-conversations/src/migrations/000_baseline.sql \u2014 what installing this provisions, as one file.
|
|
3522
|
-
--
|
|
3523
|
-
-- GENERATED by scripts/emit-unit-baselines.mjs (#338). Do not edit by hand:
|
|
3524
|
-
-- change the schema, regenerate, and let scripts/check-chain-equivalence.mjs
|
|
3525
|
-
-- prove the result still matches.
|
|
3526
|
-
--
|
|
3527
|
-
-- This replaced 9 migration files. They are kept, unapplied, in
|
|
3528
|
-
-- migrations.archive/ \u2014 a year of decisions is worth reading even when it is no
|
|
3529
|
-
-- longer worth replaying.
|
|
3530
|
-
--
|
|
3531
|
-
-- Object order is fixed rather than dependency-sorted: schemas, types,
|
|
3532
|
-
-- sequences, tables, defaults, functions, constraints, views, indexes, foreign
|
|
3533
|
-
-- keys, triggers, row security, policies, grants. Dependencies BETWEEN units
|
|
3534
|
-
-- are carried by the order packages/db/chain.json declares.
|
|
3535
|
-
-- ============================================================================
|
|
3536
|
-
|
|
3537
|
-
-- A LANGUAGE sql function binds its body at CREATE time, and 570 functions
|
|
3538
|
-
-- sorted by name are not sorted by who calls whom. This is what lets them load
|
|
3539
|
-
-- in any order; every body is still compiled on first call, so a genuinely
|
|
3540
|
-
-- broken reference surfaces then rather than never. Session-scoped.
|
|
3541
|
-
SET check_function_bodies = false;
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
-- \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
|
|
3546
|
-
-- Cleared BEFORE anything is created, so every object below gets the ACL the
|
|
3547
|
-
-- dump describes rather than the image's defaults on top of it. Restored at
|
|
3548
|
-
-- the end of this file.
|
|
3549
|
-
|
|
3550
|
-
DO $dp$ BEGIN
|
|
3551
|
-
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role, fayz_connector$stmt$;
|
|
3552
|
-
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
3553
|
-
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$;
|
|
3554
|
-
END $dp$;
|
|
3555
|
-
|
|
3556
|
-
DO $dp$ BEGIN
|
|
3557
|
-
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role, fayz_connector$stmt$;
|
|
3558
|
-
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
3559
|
-
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$;
|
|
3560
|
-
END $dp$;
|
|
3561
|
-
|
|
3562
|
-
DO $dp$ BEGIN
|
|
3563
|
-
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role, fayz_connector$stmt$;
|
|
3564
|
-
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
3565
|
-
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$;
|
|
3566
|
-
END $dp$;
|
|
3567
|
-
|
|
3568
|
-
DO $dp$ BEGIN
|
|
3569
|
-
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role, fayz_connector$stmt$;
|
|
3570
|
-
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
3571
|
-
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$;
|
|
3572
|
-
END $dp$;
|
|
3573
|
-
|
|
3574
|
-
DO $dp$ BEGIN
|
|
3575
|
-
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role, fayz_connector$stmt$;
|
|
3576
|
-
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
3577
|
-
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$;
|
|
3578
|
-
END $dp$;
|
|
3579
|
-
|
|
3580
|
-
DO $dp$ BEGIN
|
|
3581
|
-
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role, fayz_connector$stmt$;
|
|
3582
|
-
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
3583
|
-
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$;
|
|
3584
|
-
END $dp$;
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
-- \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
|
|
3588
|
-
|
|
3589
|
-
CREATE TABLE public.plg_conversation_messages (
|
|
3590
|
-
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
|
3591
|
-
tenant_id uuid NOT NULL,
|
|
3592
|
-
conversation_id uuid NOT NULL,
|
|
3593
|
-
channel text,
|
|
3594
|
-
direction text,
|
|
3595
|
-
body text NOT NULL,
|
|
3596
|
-
author text,
|
|
3597
|
-
at timestamp with time zone DEFAULT now(),
|
|
3598
|
-
provider_message_id text,
|
|
3599
|
-
delivery_status text,
|
|
3600
|
-
sender_kind text,
|
|
3601
|
-
sender_label text,
|
|
3602
|
-
subject_type text,
|
|
3603
|
-
subject_id text,
|
|
3604
|
-
CONSTRAINT plg_conversation_messages_channel_check CHECK (((channel IS NULL) OR (channel = ANY (ARRAY['sms'::text, 'whatsapp'::text, 'instagram'::text, 'email'::text, 'webchat'::text])))),
|
|
3605
|
-
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])))),
|
|
3606
|
-
CONSTRAINT plg_conversation_messages_direction_check CHECK ((direction = ANY (ARRAY['inbound'::text, 'outbound'::text]))),
|
|
3607
|
-
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]))))
|
|
3608
|
-
);
|
|
3609
|
-
|
|
3610
|
-
CREATE TABLE public.plg_conversations (
|
|
3611
|
-
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
|
3612
|
-
tenant_id uuid NOT NULL,
|
|
3613
|
-
contact_name text NOT NULL,
|
|
3614
|
-
contact_handle text,
|
|
3615
|
-
channel text NOT NULL,
|
|
3616
|
-
last_message_preview text,
|
|
3617
|
-
last_message_at timestamp with time zone DEFAULT now(),
|
|
3618
|
-
unread_count integer DEFAULT 0,
|
|
3619
|
-
status text DEFAULT 'open'::text,
|
|
3620
|
-
assigned_to text,
|
|
3621
|
-
accent text,
|
|
3622
|
-
tags text[],
|
|
3623
|
-
location text,
|
|
3624
|
-
note text,
|
|
3625
|
-
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
|
3626
|
-
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
|
3627
|
-
contact_person_id uuid,
|
|
3628
|
-
CONSTRAINT plg_conversations_channel_check CHECK ((channel = ANY (ARRAY['sms'::text, 'whatsapp'::text, 'instagram'::text, 'email'::text, 'webchat'::text]))),
|
|
3629
|
-
CONSTRAINT plg_conversations_status_check CHECK ((status = ANY (ARRAY['open'::text, 'snoozed'::text, 'closed'::text])))
|
|
3630
|
-
);
|
|
3631
|
-
|
|
3632
|
-
CREATE TABLE public.plg_conversations_channels (
|
|
3633
|
-
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
|
3634
|
-
tenant_id uuid,
|
|
3635
|
-
channel text DEFAULT 'whatsapp'::text NOT NULL,
|
|
3636
|
-
provider text NOT NULL,
|
|
3637
|
-
provider_number_id text NOT NULL,
|
|
3638
|
-
phone_e164 text,
|
|
3639
|
-
kind text DEFAULT 'dedicated'::text NOT NULL,
|
|
3640
|
-
status text DEFAULT 'requested'::text NOT NULL,
|
|
3641
|
-
display_name text,
|
|
3642
|
-
metadata jsonb DEFAULT '{}'::jsonb NOT NULL,
|
|
3643
|
-
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
|
3644
|
-
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
|
3645
|
-
CONSTRAINT plg_conversations_channels_channel_check CHECK ((channel = ANY (ARRAY['sms'::text, 'whatsapp'::text, 'instagram'::text, 'email'::text, 'webchat'::text]))),
|
|
3646
|
-
CONSTRAINT plg_conversations_channels_kind_check CHECK ((kind = ANY (ARRAY['fallback'::text, 'dedicated'::text]))),
|
|
3647
|
-
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])))
|
|
3648
|
-
);
|
|
3649
|
-
|
|
3650
|
-
CREATE TABLE public.plg_conversations_optouts (
|
|
3651
|
-
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
|
3652
|
-
channel text DEFAULT 'whatsapp'::text NOT NULL,
|
|
3653
|
-
phone_e164 text NOT NULL,
|
|
3654
|
-
tenant_id uuid,
|
|
3655
|
-
provider text NOT NULL,
|
|
3656
|
-
reason text,
|
|
3657
|
-
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
|
3658
|
-
CONSTRAINT plg_conversations_optouts_channel_check CHECK ((channel = ANY (ARRAY['sms'::text, 'whatsapp'::text, 'instagram'::text, 'email'::text, 'webchat'::text])))
|
|
3659
|
-
);
|
|
3660
|
-
|
|
3661
|
-
CREATE TABLE public.plg_conversations_payment_requests (
|
|
3662
|
-
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
|
3663
|
-
tenant_id uuid NOT NULL,
|
|
3664
|
-
conversation_id uuid,
|
|
3665
|
-
message_id uuid,
|
|
3666
|
-
subject_type text,
|
|
3667
|
-
subject_id text,
|
|
3668
|
-
amount_cents integer NOT NULL,
|
|
3669
|
-
currency text DEFAULT 'BRL'::text NOT NULL,
|
|
3670
|
-
description text,
|
|
3671
|
-
provider text NOT NULL,
|
|
3672
|
-
status text DEFAULT 'created'::text NOT NULL,
|
|
3673
|
-
provider_payment_id text,
|
|
3674
|
-
payment_link_url text,
|
|
3675
|
-
pix_copy_paste text,
|
|
3676
|
-
link_delivered_at timestamp with time zone,
|
|
3677
|
-
settled_notified_at timestamp with time zone,
|
|
3678
|
-
settled_at timestamp with time zone,
|
|
3679
|
-
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
|
3680
|
-
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
|
3681
|
-
CONSTRAINT plg_conversations_payment_requests_amount_cents_check CHECK ((amount_cents > 0)),
|
|
3682
|
-
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])))
|
|
3683
|
-
);
|
|
3684
|
-
|
|
3685
|
-
CREATE TABLE public.plg_conversations_webhook_events (
|
|
3686
|
-
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
|
3687
|
-
provider text NOT NULL,
|
|
3688
|
-
event_id text NOT NULL,
|
|
3689
|
-
event_type text NOT NULL,
|
|
3690
|
-
received_at timestamp with time zone DEFAULT now() NOT NULL
|
|
3691
|
-
);
|
|
3692
|
-
|
|
3693
|
-
|
|
3694
|
-
-- \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
|
|
3695
|
-
|
|
3696
|
-
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)
|
|
3697
|
-
LANGUAGE sql STABLE SECURITY DEFINER
|
|
3698
|
-
SET search_path TO 'public'
|
|
3699
|
-
AS $$
|
|
3700
|
-
WITH candidate AS (
|
|
3701
|
-
SELECT c.id, c.tenant_id, c.contact_name, c.contact_handle, c.contact_person_id,
|
|
3702
|
-
c.last_message_at,
|
|
3703
|
-
regexp_replace(COALESCE(c.contact_handle, ''), '[^0-9]', '', 'g') AS digits
|
|
3704
|
-
FROM public.plg_conversations c
|
|
3705
|
-
WHERE c.channel = p_channel
|
|
3706
|
-
AND (p_tenant_id IS NULL OR c.tenant_id = p_tenant_id)
|
|
3707
|
-
)
|
|
3708
|
-
SELECT id, tenant_id, contact_name, contact_handle, contact_person_id
|
|
3709
|
-
FROM candidate
|
|
3710
|
-
WHERE length(digits) >= 8
|
|
3711
|
-
AND ((p_last10 IS NOT NULL AND right(digits, 10) = p_last10)
|
|
3712
|
-
OR (p_last8 IS NOT NULL AND right(digits, 8) = p_last8))
|
|
3713
|
-
-- The 10-digit match wins over an 8-digit one even when the 8-digit thread
|
|
3714
|
-
-- is newer: a wrong area code is a different person, and a newer wrong
|
|
3715
|
-
-- answer is still wrong.
|
|
3716
|
-
ORDER BY (p_last10 IS NOT NULL AND right(digits, 10) = p_last10) DESC,
|
|
3717
|
-
last_message_at DESC NULLS LAST
|
|
3718
|
-
LIMIT 1;
|
|
3719
|
-
$$;
|
|
3720
|
-
|
|
3721
|
-
|
|
3722
|
-
-- \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
|
|
3723
|
-
|
|
3724
|
-
ALTER TABLE ONLY public.plg_conversation_messages
|
|
3725
|
-
ADD CONSTRAINT plg_conversation_messages_pkey PRIMARY KEY (id);
|
|
3726
|
-
|
|
3727
|
-
ALTER TABLE ONLY public.plg_conversations_channels
|
|
3728
|
-
ADD CONSTRAINT plg_conversations_channels_pkey PRIMARY KEY (id);
|
|
3729
|
-
|
|
3730
|
-
ALTER TABLE ONLY public.plg_conversations_optouts
|
|
3731
|
-
ADD CONSTRAINT plg_conversations_optouts_pkey PRIMARY KEY (id);
|
|
3732
|
-
|
|
3733
|
-
ALTER TABLE ONLY public.plg_conversations_payment_requests
|
|
3734
|
-
ADD CONSTRAINT plg_conversations_payment_requests_pkey PRIMARY KEY (id);
|
|
3735
|
-
|
|
3736
|
-
ALTER TABLE ONLY public.plg_conversations
|
|
3737
|
-
ADD CONSTRAINT plg_conversations_pkey PRIMARY KEY (id);
|
|
3738
|
-
|
|
3739
|
-
ALTER TABLE ONLY public.plg_conversations_webhook_events
|
|
3740
|
-
ADD CONSTRAINT plg_conversations_webhook_events_pkey PRIMARY KEY (id);
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
-- \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
|
|
3744
|
-
|
|
3745
|
-
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);
|
|
3746
|
-
|
|
3747
|
-
CREATE INDEX idx_plg_conversation_messages_subject ON public.plg_conversation_messages USING btree (conversation_id, at DESC) WHERE (subject_id IS NOT NULL);
|
|
3748
|
-
|
|
3749
|
-
CREATE INDEX idx_plg_conversation_messages_thread ON public.plg_conversation_messages USING btree (conversation_id, at);
|
|
3750
|
-
|
|
3751
|
-
CREATE INDEX idx_plg_conversations_channels_tenant ON public.plg_conversations_channels USING btree (tenant_id, channel, status) WHERE (tenant_id IS NOT NULL);
|
|
3752
|
-
|
|
3753
|
-
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);
|
|
3754
|
-
|
|
3755
|
-
CREATE INDEX idx_plg_conversations_optouts_lookup ON public.plg_conversations_optouts USING btree (channel, phone_e164);
|
|
3756
|
-
|
|
3757
|
-
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);
|
|
3758
|
-
|
|
3759
|
-
CREATE INDEX idx_plg_conversations_payment_requests_tenant ON public.plg_conversations_payment_requests USING btree (tenant_id, created_at DESC);
|
|
3760
|
-
|
|
3761
|
-
CREATE INDEX idx_plg_conversations_person ON public.plg_conversations USING btree (tenant_id, contact_person_id) WHERE (contact_person_id IS NOT NULL);
|
|
3762
|
-
|
|
3763
|
-
CREATE INDEX idx_plg_conversations_tenant ON public.plg_conversations USING btree (tenant_id);
|
|
3764
|
-
|
|
3765
|
-
CREATE INDEX idx_plg_conversations_tenant_recent ON public.plg_conversations USING btree (tenant_id, last_message_at DESC);
|
|
3766
|
-
|
|
3767
|
-
CREATE INDEX idx_plg_conversations_webhook_events_received ON public.plg_conversations_webhook_events USING btree (received_at);
|
|
3768
|
-
|
|
3769
|
-
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);
|
|
3770
|
-
|
|
3771
|
-
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));
|
|
3772
|
-
|
|
3773
|
-
CREATE UNIQUE INDEX uq_plg_conversations_channels_number ON public.plg_conversations_channels USING btree (provider, provider_number_id);
|
|
3774
|
-
|
|
3775
|
-
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));
|
|
3776
|
-
|
|
3777
|
-
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])));
|
|
3778
|
-
|
|
3779
|
-
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);
|
|
3780
|
-
|
|
3781
|
-
CREATE UNIQUE INDEX uq_plg_conversations_webhook_events_event ON public.plg_conversations_webhook_events USING btree (provider, event_id);
|
|
3782
|
-
|
|
3783
|
-
|
|
3784
|
-
-- \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
|
|
3785
|
-
|
|
3786
|
-
ALTER TABLE ONLY public.plg_conversation_messages
|
|
3787
|
-
ADD CONSTRAINT plg_conversation_messages_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES public.plg_conversations(id) ON DELETE CASCADE;
|
|
3788
|
-
|
|
3789
|
-
ALTER TABLE ONLY public.plg_conversation_messages
|
|
3790
|
-
ADD CONSTRAINT plg_conversation_messages_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
|
|
3791
|
-
|
|
3792
|
-
ALTER TABLE ONLY public.plg_conversations_channels
|
|
3793
|
-
ADD CONSTRAINT plg_conversations_channels_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
|
|
3794
|
-
|
|
3795
|
-
ALTER TABLE ONLY public.plg_conversations
|
|
3796
|
-
ADD CONSTRAINT plg_conversations_contact_person_id_fkey FOREIGN KEY (contact_person_id) REFERENCES public.people(id) ON DELETE SET NULL;
|
|
3797
|
-
|
|
3798
|
-
ALTER TABLE ONLY public.plg_conversations_optouts
|
|
3799
|
-
ADD CONSTRAINT plg_conversations_optouts_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
|
|
3800
|
-
|
|
3801
|
-
ALTER TABLE ONLY public.plg_conversations_payment_requests
|
|
3802
|
-
ADD CONSTRAINT plg_conversations_payment_requests_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES public.plg_conversations(id) ON DELETE SET NULL;
|
|
3803
|
-
|
|
3804
|
-
ALTER TABLE ONLY public.plg_conversations_payment_requests
|
|
3805
|
-
ADD CONSTRAINT plg_conversations_payment_requests_message_id_fkey FOREIGN KEY (message_id) REFERENCES public.plg_conversation_messages(id) ON DELETE SET NULL;
|
|
3806
|
-
|
|
3807
|
-
ALTER TABLE ONLY public.plg_conversations_payment_requests
|
|
3808
|
-
ADD CONSTRAINT plg_conversations_payment_requests_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
|
|
3809
|
-
|
|
3810
|
-
ALTER TABLE ONLY public.plg_conversations
|
|
3811
|
-
ADD CONSTRAINT plg_conversations_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
-- \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
|
|
3815
|
-
|
|
3816
|
-
ALTER TABLE public.plg_conversation_messages ENABLE ROW LEVEL SECURITY;
|
|
3817
|
-
|
|
3818
|
-
ALTER TABLE public.plg_conversations ENABLE ROW LEVEL SECURITY;
|
|
3819
|
-
|
|
3820
|
-
ALTER TABLE public.plg_conversations_channels ENABLE ROW LEVEL SECURITY;
|
|
3821
|
-
|
|
3822
|
-
ALTER TABLE public.plg_conversations_optouts ENABLE ROW LEVEL SECURITY;
|
|
3823
|
-
|
|
3824
|
-
ALTER TABLE public.plg_conversations_payment_requests ENABLE ROW LEVEL SECURITY;
|
|
3825
|
-
|
|
3826
|
-
ALTER TABLE public.plg_conversations_webhook_events ENABLE ROW LEVEL SECURITY;
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
-- \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
|
|
3830
|
-
|
|
3831
|
-
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)));
|
|
3832
|
-
|
|
3833
|
-
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)));
|
|
3834
|
-
|
|
3835
|
-
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)));
|
|
3836
|
-
|
|
3837
|
-
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)));
|
|
3838
|
-
|
|
3839
|
-
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))));
|
|
3840
|
-
|
|
3841
|
-
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)));
|
|
3842
|
-
|
|
3843
|
-
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)));
|
|
3844
|
-
|
|
3845
|
-
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))));
|
|
3846
|
-
|
|
3847
|
-
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)));
|
|
3848
|
-
|
|
3849
|
-
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)));
|
|
3850
|
-
|
|
3851
|
-
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)));
|
|
3852
|
-
|
|
3853
|
-
|
|
3854
|
-
-- \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
|
|
3855
|
-
|
|
3856
|
-
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;
|
|
3857
|
-
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;
|
|
3858
|
-
|
|
3859
|
-
GRANT MAINTAIN ON TABLE public.plg_conversation_messages TO anon;
|
|
3860
|
-
GRANT ALL ON TABLE public.plg_conversation_messages TO authenticated;
|
|
3861
|
-
GRANT ALL ON TABLE public.plg_conversation_messages TO service_role;
|
|
3862
|
-
|
|
3863
|
-
GRANT MAINTAIN ON TABLE public.plg_conversations TO anon;
|
|
3864
|
-
GRANT ALL ON TABLE public.plg_conversations TO authenticated;
|
|
3865
|
-
GRANT ALL ON TABLE public.plg_conversations TO service_role;
|
|
3866
|
-
|
|
3867
|
-
GRANT MAINTAIN ON TABLE public.plg_conversations_channels TO anon;
|
|
3868
|
-
GRANT ALL ON TABLE public.plg_conversations_channels TO authenticated;
|
|
3869
|
-
GRANT ALL ON TABLE public.plg_conversations_channels TO service_role;
|
|
3870
|
-
|
|
3871
|
-
GRANT MAINTAIN ON TABLE public.plg_conversations_optouts TO anon;
|
|
3872
|
-
GRANT ALL ON TABLE public.plg_conversations_optouts TO authenticated;
|
|
3873
|
-
GRANT ALL ON TABLE public.plg_conversations_optouts TO service_role;
|
|
3874
|
-
|
|
3875
|
-
GRANT MAINTAIN ON TABLE public.plg_conversations_payment_requests TO anon;
|
|
3876
|
-
GRANT ALL ON TABLE public.plg_conversations_payment_requests TO authenticated;
|
|
3877
|
-
GRANT ALL ON TABLE public.plg_conversations_payment_requests TO service_role;
|
|
3878
|
-
|
|
3879
|
-
GRANT MAINTAIN ON TABLE public.plg_conversations_webhook_events TO anon;
|
|
3880
|
-
GRANT ALL ON TABLE public.plg_conversations_webhook_events TO authenticated;
|
|
3881
|
-
GRANT ALL ON TABLE public.plg_conversations_webhook_events TO service_role;
|
|
3882
|
-
|
|
3883
|
-
|
|
3884
|
-
-- \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
|
|
3885
|
-
-- Put back, so the NEXT thing installed inherits what the chain had.
|
|
3886
|
-
|
|
3887
|
-
DO $dp$ BEGIN
|
|
3888
|
-
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;
|
|
3889
|
-
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
3890
|
-
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$;
|
|
3891
|
-
END $dp$;
|
|
3892
|
-
|
|
3893
|
-
DO $dp$ BEGIN
|
|
3894
|
-
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;
|
|
3895
|
-
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
3896
|
-
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$;
|
|
3897
|
-
END $dp$;
|
|
3898
|
-
|
|
3899
|
-
DO $dp$ BEGIN
|
|
3900
|
-
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;
|
|
3901
|
-
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
3902
|
-
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$;
|
|
3903
|
-
END $dp$;
|
|
3904
|
-
|
|
3905
|
-
DO $dp$ BEGIN
|
|
3906
|
-
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;
|
|
3907
|
-
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
3908
|
-
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$;
|
|
3909
|
-
END $dp$;
|
|
3910
|
-
|
|
3911
|
-
DO $dp$ BEGIN
|
|
3912
|
-
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;
|
|
3913
|
-
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
3914
|
-
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$;
|
|
3915
|
-
END $dp$;
|
|
3916
|
-
|
|
3917
|
-
DO $dp$ BEGIN
|
|
3918
|
-
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;
|
|
3919
|
-
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
3920
|
-
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$;
|
|
3921
|
-
END $dp$;
|
|
3922
|
-
|
|
3923
|
-
DO $dp$ BEGIN
|
|
3924
|
-
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;
|
|
3925
|
-
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
3926
|
-
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$;
|
|
3927
|
-
END $dp$;
|
|
3928
|
-
|
|
3929
|
-
DO $dp$ BEGIN
|
|
3930
|
-
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;
|
|
3931
|
-
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
3932
|
-
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$;
|
|
3933
|
-
END $dp$;
|
|
3934
|
-
|
|
3935
|
-
DO $dp$ BEGIN
|
|
3936
|
-
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;
|
|
3937
|
-
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
3938
|
-
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$;
|
|
3939
|
-
END $dp$;
|
|
3940
|
-
|
|
3941
|
-
DO $dp$ BEGIN
|
|
3942
|
-
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;
|
|
3943
|
-
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
3944
|
-
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$;
|
|
3945
|
-
END $dp$;
|
|
3946
|
-
|
|
3947
|
-
DO $dp$ BEGIN
|
|
3948
|
-
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;
|
|
3949
|
-
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
3950
|
-
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$;
|
|
3951
|
-
END $dp$;
|
|
3952
|
-
|
|
3953
|
-
DO $dp$ BEGIN
|
|
3954
|
-
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;
|
|
3955
|
-
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
3956
|
-
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$;
|
|
3957
|
-
END $dp$;
|
|
3958
|
-
|
|
3959
|
-
DO $dp$ BEGIN
|
|
3960
|
-
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO anon$stmt$;
|
|
3961
|
-
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
3962
|
-
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$;
|
|
3963
|
-
END $dp$;
|
|
3964
|
-
|
|
3965
|
-
DO $dp$ BEGIN
|
|
3966
|
-
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;
|
|
3967
|
-
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
3968
|
-
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$;
|
|
3969
|
-
END $dp$;
|
|
3970
|
-
|
|
3971
|
-
DO $dp$ BEGIN
|
|
3972
|
-
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;
|
|
3973
|
-
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
3974
|
-
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$;
|
|
3975
|
-
END $dp$;
|
|
3976
|
-
|
|
3977
|
-
DO $dp$ BEGIN
|
|
3978
|
-
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;
|
|
3979
|
-
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
3980
|
-
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$;
|
|
3981
|
-
END $dp$;
|
|
3982
|
-
|
|
3983
|
-
DO $dp$ BEGIN
|
|
3984
|
-
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT MAINTAIN ON TABLES TO anon$stmt$;
|
|
3985
|
-
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
3986
|
-
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$;
|
|
3987
|
-
END $dp$;
|
|
3988
|
-
|
|
3989
|
-
DO $dp$ BEGIN
|
|
3990
|
-
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;
|
|
3991
|
-
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
3992
|
-
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$;
|
|
3993
|
-
END $dp$;
|
|
3994
|
-
|
|
3995
|
-
DO $dp$ BEGIN
|
|
3996
|
-
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;
|
|
3997
|
-
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
3998
|
-
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$;
|
|
3999
|
-
END $dp$;
|
|
4000
|
-
|
|
4001
|
-
DO $dp$ BEGIN
|
|
4002
|
-
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;
|
|
4003
|
-
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
4004
|
-
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$;
|
|
4005
|
-
END $dp$;
|
|
4006
|
-
|
|
4007
|
-
DO $dp$ BEGIN
|
|
4008
|
-
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO anon$stmt$;
|
|
4009
|
-
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
4010
|
-
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$;
|
|
4011
|
-
END $dp$;
|
|
4012
|
-
|
|
4013
|
-
DO $dp$ BEGIN
|
|
4014
|
-
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;
|
|
4015
|
-
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
4016
|
-
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$;
|
|
4017
|
-
END $dp$;
|
|
4018
|
-
|
|
4019
|
-
DO $dp$ BEGIN
|
|
4020
|
-
EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;
|
|
4021
|
-
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
4022
|
-
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$;
|
|
4023
|
-
END $dp$;
|
|
4024
|
-
|
|
4025
|
-
DO $dp$ BEGIN
|
|
4026
|
-
EXECUTE $stmt$--
|
|
4027
|
-
-- PostgreSQL database dump complete
|
|
4028
|
-
--$stmt$;
|
|
4029
|
-
EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
|
|
4030
|
-
RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$--
|
|
4031
|
-
-- PostgreSQL database dump complete
|
|
4032
|
-
--$stmt$;
|
|
4033
|
-
END $dp$;
|
|
4034
|
-
`;
|
|
4035
|
-
var MIGRATION_001_A_CAIXA_SABE_QUEM_FALOU_POR_ULTIMO = `-- ---------------------------------------------------------------------------
|
|
4036
|
-
-- 001_a_caixa_sabe_quem_falou_por_ultimo.sql
|
|
4037
|
-
--
|
|
4038
|
-
-- A conversa guarda o TEXTO e a HORA da \xFAltima mensagem, e n\xE3o guarda de quem
|
|
4039
|
-
-- ela foi. Sem isso, "quem est\xE1 esperando resposta" \u2014 que \xE9 a pergunta com que
|
|
4040
|
-
-- este produto abre o dia \u2014 n\xE3o \xE9 respond\xEDvel a partir da lista: seria preciso
|
|
4041
|
-
-- carregar as mensagens de cada thread para descobrir quem falou por \xFAltimo.
|
|
4042
|
-
--
|
|
4043
|
-
-- O painel respondia com \`unread_count > 0\`, que \xE9 a aproxima\xE7\xE3o honesta que o
|
|
4044
|
-
-- shape permitia e n\xE3o \xE9 a resposta certa. Ela erra exatamente no caso que mais
|
|
4045
|
-
-- d\xF3i: a thread que algu\xE9m ABRIU, leu, decidiu responder depois e esqueceu. O
|
|
4046
|
-
-- n\xE3o-lida zera na leitura; o cliente continua esperando.
|
|
4047
|
-
--
|
|
4048
|
-
-- \u2500\u2500 Por que TRIGGER e n\xE3o mais uma coluna que cada escritor preenche \u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
4049
|
-
--
|
|
4050
|
-
-- \`last_message_preview\` e \`last_message_at\` s\xE3o escritos hoje em CINCO lugares:
|
|
4051
|
-
-- o provider do navegador (criar conversa, enviar), a fun\xE7\xE3o \`messaging-send\` e
|
|
4052
|
-
-- o \`tyxter-webhook\` (duas vezes, contando o handler de pagamento). Uma sexta
|
|
4053
|
-
-- coluna com a mesma disciplina \xE9 uma sexta chance de algu\xE9m esquecer \u2014 e o
|
|
4054
|
-
-- modo de falhar \xE9 silencioso: a fila simplesmente para de listar algu\xE9m.
|
|
4055
|
-
--
|
|
4056
|
-
-- O gatilho tem UM escritor e n\xE3o pode ser esquecido pelo pr\xF3ximo chamador. Ele
|
|
4057
|
-
-- \xE9 deliberadamente estreito: cuida s\xF3 de \`last_message_direction\`. Trazer o
|
|
4058
|
-
-- preview e a hora para c\xE1 seria a corre\xE7\xE3o certa e \xE9 uma mudan\xE7a maior, com
|
|
4059
|
-
-- cinco chamadores para reconciliar \u2014 fica anotado, n\xE3o feito aqui.
|
|
4060
|
-
-- ---------------------------------------------------------------------------
|
|
4061
|
-
|
|
4062
|
-
ALTER TABLE public.plg_conversations
|
|
4063
|
-
ADD COLUMN IF NOT EXISTS last_message_direction text;
|
|
4064
|
-
|
|
4065
|
-
DO $$
|
|
4066
|
-
BEGIN
|
|
4067
|
-
ALTER TABLE public.plg_conversations
|
|
4068
|
-
ADD CONSTRAINT plg_conversations_last_message_direction_check
|
|
4069
|
-
CHECK (last_message_direction IS NULL
|
|
4070
|
-
OR last_message_direction = ANY (ARRAY['inbound'::text, 'outbound'::text]));
|
|
4071
|
-
EXCEPTION WHEN duplicate_object THEN
|
|
4072
|
-
NULL;
|
|
4073
|
-
END $$;
|
|
4074
|
-
|
|
4075
|
-
COMMENT ON COLUMN public.plg_conversations.last_message_direction IS
|
|
4076
|
-
'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.';
|
|
4077
|
-
|
|
4078
|
-
-- \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
|
|
4079
|
-
--
|
|
4080
|
-
-- AFTER INSERT: a mensagem j\xE1 existe quando a conversa \xE9 atualizada, ent\xE3o uma
|
|
4081
|
-
-- falha aqui n\xE3o pode desfazer o recebimento \u2014 e recebimento perdido \xE9 pior que
|
|
4082
|
-
-- fila desatualizada.
|
|
4083
|
-
--
|
|
4084
|
-
-- Sem cl\xE1usula de ordem: a mensagem que acabou de entrar \xC9 a \xFAltima. Comparar
|
|
4085
|
-
-- com \`last_message_at\` para decidir seria correto e traria uma corrida com os
|
|
4086
|
-
-- cinco escritores daquela coluna, que \xE9 justamente o problema que o gatilho
|
|
4087
|
-
-- existe para n\xE3o ter.
|
|
4088
|
-
CREATE OR REPLACE FUNCTION public.plg_conversations_stamp_direction()
|
|
4089
|
-
RETURNS trigger
|
|
4090
|
-
LANGUAGE plpgsql
|
|
4091
|
-
SECURITY DEFINER
|
|
4092
|
-
SET search_path TO ''
|
|
4093
|
-
AS $function$
|
|
4094
|
-
BEGIN
|
|
4095
|
-
IF NEW.direction IS NULL THEN
|
|
4096
|
-
RETURN NULL;
|
|
4097
|
-
END IF;
|
|
4098
|
-
|
|
4099
|
-
UPDATE public.plg_conversations
|
|
4100
|
-
SET last_message_direction = NEW.direction
|
|
4101
|
-
WHERE id = NEW.conversation_id;
|
|
4102
|
-
|
|
4103
|
-
RETURN NULL;
|
|
4104
|
-
END $function$;
|
|
4105
|
-
|
|
4106
|
-
COMMENT ON FUNCTION public.plg_conversations_stamp_direction() IS
|
|
4107
|
-
'Carimba plg_conversations.last_message_direction a cada mensagem inserida (001). O \xFAnico escritor da coluna.';
|
|
4108
|
-
|
|
4109
|
-
DROP TRIGGER IF EXISTS plg_conversation_messages_stamp_direction ON public.plg_conversation_messages;
|
|
4110
|
-
CREATE TRIGGER plg_conversation_messages_stamp_direction
|
|
4111
|
-
AFTER INSERT ON public.plg_conversation_messages
|
|
4112
|
-
FOR EACH ROW
|
|
4113
|
-
EXECUTE FUNCTION public.plg_conversations_stamp_direction();
|
|
4114
|
-
|
|
4115
|
-
-- \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
|
|
4116
|
-
--
|
|
4117
|
-
-- DISTINCT ON pega uma linha por conversa, a mais recente. \`at DESC NULLS LAST\`
|
|
4118
|
-
-- e depois \`id DESC\` porque \`at\` tem default e n\xE3o NOT NULL: duas mensagens no
|
|
4119
|
-
-- mesmo instante (ou ambas sem hora) precisam de um desempate est\xE1vel, ou o
|
|
4120
|
-
-- backfill devolve resultado diferente a cada execu\xE7\xE3o.
|
|
4121
|
-
UPDATE public.plg_conversations c
|
|
4122
|
-
SET last_message_direction = m.direction
|
|
4123
|
-
FROM (
|
|
4124
|
-
SELECT DISTINCT ON (conversation_id) conversation_id, direction
|
|
4125
|
-
FROM public.plg_conversation_messages
|
|
4126
|
-
WHERE direction IS NOT NULL
|
|
4127
|
-
ORDER BY conversation_id, at DESC NULLS LAST, id DESC
|
|
4128
|
-
) m
|
|
4129
|
-
WHERE m.conversation_id = c.id
|
|
4130
|
-
AND c.last_message_direction IS DISTINCT FROM m.direction;
|
|
4131
|
-
|
|
4132
|
-
-- S\xF3 as threads que interessam \xE0 fila, e s\xF3 quando o cliente falou por \xFAltimo:
|
|
4133
|
-
-- \xE9 o \xEDndice que a pergunta "quem est\xE1 esperando" faz, e ele fica pequeno
|
|
4134
|
-
-- porque a maioria das conversas de um tenant est\xE1 encerrada.
|
|
4135
|
-
CREATE INDEX IF NOT EXISTS idx_plg_conversations_waiting
|
|
4136
|
-
ON public.plg_conversations (tenant_id, last_message_at DESC)
|
|
4137
|
-
WHERE status = 'open' AND last_message_direction = 'inbound';
|
|
4138
|
-
`;
|
|
4139
|
-
var MIGRATION_002_A_CASA_ESCOLHE_POR_ONDE_O_WHATSAPP_SAI = `-- ---------------------------------------------------------------------------
|
|
4140
|
-
-- 002_a_casa_escolhe_por_onde_o_whatsapp_sai.sql \u2014 dois provedores de WhatsApp,
|
|
4141
|
-
-- a credencial de cada um, e a escolha de qual est\xE1 valendo.
|
|
4142
|
-
--
|
|
4143
|
-
-- \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
|
|
4144
|
-
--
|
|
4145
|
-
-- O \`messaging-send\` (WP3) pega a chave assim:
|
|
4146
|
-
--
|
|
4147
|
-
-- const credential = await redeemConnectorCredential({ connectorSlug: 'tyxter', \u2026 })
|
|
4148
|
-
--
|
|
4149
|
-
-- Quinze linhas que chamam a PLATAFORMA Fayz. Funciona para o Tyxter porque a
|
|
4150
|
-
-- chave \xE9 uma s\xF3, da FayaLabs, e a plataforma a serve para qualquer inquilino
|
|
4151
|
-
-- do projeto. N\xE3o funciona para o Evolution, e n\xE3o vai funcionar nunca: o
|
|
4152
|
-
-- Evolution \xE9 auto-hospedado, e cada casa tem a pr\xF3pria URL e a pr\xF3pria chave.
|
|
4153
|
-
-- Credencial POR INQUILINO n\xE3o \xE9 atalho, \xE9 requisito do segundo provedor.
|
|
4154
|
-
--
|
|
4155
|
-
-- Um app que n\xE3o est\xE1 ligado a projeto Fayz nenhum \u2014 como o FullControl hoje \u2014
|
|
4156
|
-
-- tamb\xE9m n\xE3o resgata nada. Aqui a chave tem onde ficar nos dois casos.
|
|
4157
|
-
--
|
|
4158
|
-
-- \u2500\u2500 os dois provedores n\xE3o s\xE3o a mesma coisa, e a tabela diz isso \u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
4159
|
-
--
|
|
4160
|
-
-- \`tyxter\` API oficial (Cloud API, via revenda). Iniciar conversa EXIGE
|
|
4161
|
-
-- template aprovado pela Meta; texto livre s\xF3 dentro de 24h da
|
|
4162
|
-
-- \xFAltima mensagem do cliente. Em troca: entrega confi\xE1vel, sem
|
|
4163
|
-
-- risco de banimento, e a Meta responde por n\xFAmero e qualidade.
|
|
4164
|
-
-- \`evolution\` WhatsApp Web, auto-hospedado. N\xE3o tem template nem janela de
|
|
4165
|
-
-- 24h \u2014 manda o que quiser. Em troca: a sess\xE3o morre, o n\xFAmero
|
|
4166
|
-
-- pode ser banido, e ningu\xE9m responde por isso al\xE9m de voc\xEA.
|
|
4167
|
-
--
|
|
4168
|
-
-- \`capabilities\` guarda essa diferen\xE7a como DADO e n\xE3o como \`if\` espalhado pela
|
|
4169
|
-
-- tela. Um bot\xE3o de "submeter template" num provedor que n\xE3o tem template n\xE3o
|
|
4170
|
-
-- pode existir; a tela l\xEA daqui para desabilitar em vez de fingir.
|
|
4171
|
-
--
|
|
4172
|
-
-- \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
|
|
4173
|
-
--
|
|
4174
|
-
-- A tabela nega SELECT a \`authenticated\`. Quem l\xEA \xE9 o \`service_role\`, do lado
|
|
4175
|
-
-- do servidor. A tela sabe se EXISTE credencial e quais s\xE3o os campos n\xE3o
|
|
4176
|
-
-- secretos (a URL do Evolution, o nome da inst\xE2ncia) por uma view; o segredo
|
|
4177
|
-
-- ela nunca v\xEA, nem para reexibir mascarado.
|
|
4178
|
-
-- ---------------------------------------------------------------------------
|
|
4179
|
-
|
|
4180
|
-
-- \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
|
|
4181
|
-
CREATE TABLE IF NOT EXISTS public.plg_conversations_providers (
|
|
4182
|
-
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
4183
|
-
provider text NOT NULL,
|
|
4184
|
-
-- N\xE3o secreto: a URL base do Evolution, o nome da inst\xE2ncia, o id do n\xFAmero.
|
|
4185
|
-
-- \xC9 isto que a tela mostra e deixa editar.
|
|
4186
|
-
config jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
4187
|
-
-- Secreto. Nenhuma pol\xEDtica de leitura alcan\xE7a esta coluna.
|
|
4188
|
-
secrets jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
4189
|
-
-- O que este provedor SABE fazer. Lido pela tela para desabilitar o que n\xE3o
|
|
4190
|
-
-- existe, em vez de oferecer um bot\xE3o que erra.
|
|
4191
|
-
capabilities jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
4192
|
-
-- Um s\xF3 por inquilino fica ativo. O outro continua configurado, pronto para
|
|
4193
|
-
-- a troca \u2014 que \xE9 o pedido: poder alternar sem reconfigurar.
|
|
4194
|
-
is_active boolean NOT NULL DEFAULT false,
|
|
4195
|
-
status text NOT NULL DEFAULT 'unconfigured',
|
|
4196
|
-
status_detail text,
|
|
4197
|
-
checked_at timestamptz,
|
|
4198
|
-
created_at timestamptz NOT NULL DEFAULT now(),
|
|
4199
|
-
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
4200
|
-
PRIMARY KEY (tenant_id, provider),
|
|
4201
|
-
CONSTRAINT plg_conversations_providers_provider
|
|
4202
|
-
CHECK (provider IN ('tyxter', 'evolution')),
|
|
4203
|
-
CONSTRAINT plg_conversations_providers_status
|
|
4204
|
-
CHECK (status IN ('unconfigured', 'connecting', 'connected', 'error', 'disconnected'))
|
|
4205
|
-
);
|
|
4206
|
-
|
|
4207
|
-
-- UM ativo por inquilino, garantido pelo banco e n\xE3o pela tela. Duas telas
|
|
4208
|
-
-- abertas em abas diferentes s\xE3o o caso comum, e "o \xFAltimo clique vence" s\xF3 \xE9
|
|
4209
|
-
-- verdade se algu\xE9m impedir o empate.
|
|
4210
|
-
CREATE UNIQUE INDEX IF NOT EXISTS plg_conversations_providers_one_active
|
|
4211
|
-
ON public.plg_conversations_providers (tenant_id)
|
|
4212
|
-
WHERE is_active;
|
|
4213
|
-
|
|
4214
|
-
ALTER TABLE public.plg_conversations_providers ENABLE ROW LEVEL SECURITY;
|
|
4215
|
-
ALTER TABLE public.plg_conversations_providers FORCE ROW LEVEL SECURITY;
|
|
4216
|
-
|
|
4217
|
-
-- Nenhuma pol\xEDtica para \`authenticated\`: o segredo mora numa coluna desta
|
|
4218
|
-
-- tabela, e uma pol\xEDtica de SELECT aqui entregaria a chave ao navegador.
|
|
4219
|
-
--
|
|
4220
|
-
-- E o REVOKE expl\xEDcito, porque a aus\xEAncia de pol\xEDtica N\xC3O \xE9 a \xFAnica linha de
|
|
4221
|
-
-- defesa. O schema \`public\` deste cluster carrega grants amplos de f\xE1brica \u2014
|
|
4222
|
-
-- medido nesta pr\xF3pria tabela logo ap\xF3s cri\xE1-la: sete privil\xE9gios j\xE1
|
|
4223
|
-
-- concedidos a \`authenticated\` sem ningu\xE9m ter pedido. A RLS for\xE7ada barra
|
|
4224
|
-
-- assim mesmo, mas uma pol\xEDtica escrita errada num dia ruim \xE9 tudo o que
|
|
4225
|
-
-- separa a chave do navegador. Duas fechaduras.
|
|
4226
|
-
REVOKE ALL ON public.plg_conversations_providers FROM PUBLIC, anon, authenticated;
|
|
4227
|
-
GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_conversations_providers TO service_role;
|
|
4228
|
-
|
|
4229
|
-
-- \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
|
|
4230
|
-
--
|
|
4231
|
-
-- Tudo menos o segredo. \`has_credential\` responde a \xFAnica pergunta que a tela
|
|
4232
|
-
-- precisa fazer sobre ele: j\xE1 foi posto?
|
|
4233
|
-
-- \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
|
|
4234
|
-
--
|
|
4235
|
-
-- A regra da casa \xE9 \`security_invoker = true\`: a view enxerga o que o leitor
|
|
4236
|
-
-- enxerga, e a RLS da tabela continua valendo. Aqui isso se autoderrota.
|
|
4237
|
-
--
|
|
4238
|
-
-- \`authenticated\` N\xC3O pode ler \`plg_conversations_providers\` \u2014 \xE9 onde o segredo
|
|
4239
|
-
-- mora, e o REVOKE logo acima \xE9 deliberado. Uma view invoker sobre uma tabela
|
|
4240
|
-
-- que o chamador n\xE3o alcan\xE7a devolve \`42501 permission denied\`, que foi
|
|
4241
|
-
-- exatamente o que a tela recebeu no primeiro teste com um JWT de verdade.
|
|
4242
|
-
--
|
|
4243
|
-
-- Ent\xE3o esta view \xE9 definer POR PROJETO, e paga o pre\xE7o sendo ela mesma a
|
|
4244
|
-
-- fronteira: o \`WHERE tenant_id IN (user_tenant_ids())\` abaixo n\xE3o \xE9
|
|
4245
|
-
-- decora\xE7\xE3o, \xE9 a RLS desta view. E a coluna do segredo n\xE3o est\xE1 na lista do
|
|
4246
|
-
-- SELECT \u2014 o que ela n\xE3o seleciona n\xE3o existe para quem l\xEA.
|
|
4247
|
-
DROP VIEW IF EXISTS public.v_conversations_providers;
|
|
4248
|
-
CREATE VIEW public.v_conversations_providers AS
|
|
4249
|
-
SELECT p.tenant_id,
|
|
4250
|
-
p.provider,
|
|
4251
|
-
p.config,
|
|
4252
|
-
p.capabilities,
|
|
4253
|
-
p.is_active,
|
|
4254
|
-
p.status,
|
|
4255
|
-
p.status_detail,
|
|
4256
|
-
p.checked_at,
|
|
4257
|
-
-- Nunca o valor. S\xF3 se existe.
|
|
4258
|
-
(p.secrets ? 'api_key') AS has_credential,
|
|
4259
|
-
p.created_at,
|
|
4260
|
-
p.updated_at
|
|
4261
|
-
FROM public.plg_conversations_providers p
|
|
4262
|
-
WHERE p.tenant_id IN (SELECT public.user_tenant_ids());
|
|
4263
|
-
|
|
4264
|
-
GRANT SELECT ON public.v_conversations_providers TO authenticated;
|
|
4265
|
-
|
|
4266
|
-
COMMENT ON VIEW public.v_conversations_providers IS
|
|
4267
|
-
'Os provedores de WhatsApp da casa, SEM o segredo. \`has_credential\` diz se a chave foi posta; o valor n\xE3o sai daqui (002).';
|
|
4268
|
-
|
|
4269
|
-
-- \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
|
|
4270
|
-
--
|
|
4271
|
-
-- SECURITY DEFINER porque a tabela nega escrita a \`authenticated\`: \xE9 a \xFAnica
|
|
4272
|
-
-- porta, e ela confere o inquilino antes de abrir. \`search_path\` vazio porque
|
|
4273
|
-
-- uma fun\xE7\xE3o definer sem isso \xE9 uma fun\xE7\xE3o que o chamador pode redirecionar.
|
|
4274
|
-
CREATE OR REPLACE FUNCTION public.conversations_set_provider(
|
|
4275
|
-
p_tenant uuid,
|
|
4276
|
-
p_provider text,
|
|
4277
|
-
p_config jsonb DEFAULT '{}'::jsonb,
|
|
4278
|
-
p_api_key text DEFAULT NULL,
|
|
4279
|
-
p_capabilities jsonb DEFAULT NULL
|
|
4280
|
-
) RETURNS void
|
|
4281
|
-
LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
|
|
4282
|
-
AS $$
|
|
4283
|
-
DECLARE v_caps jsonb;
|
|
4284
|
-
BEGIN
|
|
4285
|
-
IF p_tenant IS NULL OR NOT (p_tenant IN (SELECT public.user_tenant_ids())) THEN
|
|
4286
|
-
RAISE EXCEPTION 'conversations_set_provider: n\xE3o \xE9 uma casa sua';
|
|
4287
|
-
END IF;
|
|
4288
|
-
|
|
4289
|
-
-- O que cada provedor sabe fazer \xE9 conhecimento do produto, n\xE3o do usu\xE1rio.
|
|
4290
|
-
-- Quem chama pode sobrescrever (um Evolution atr\xE1s de proxy pode ganhar
|
|
4291
|
-
-- capacidades), mas o padr\xE3o descreve a verdade de cada um.
|
|
4292
|
-
v_caps := coalesce(p_capabilities, CASE p_provider
|
|
4293
|
-
WHEN 'tyxter' THEN jsonb_build_object(
|
|
4294
|
-
'templates', true, -- e s\xE3o OBRIGAT\xD3RIOS para iniciar conversa
|
|
4295
|
-
'requires_template', true,
|
|
4296
|
-
'session_window_hours', 24, -- texto livre s\xF3 dentro dela
|
|
4297
|
-
'delivery_receipts', true,
|
|
4298
|
-
'qr_pairing', false,
|
|
4299
|
-
'ban_risk', 'low')
|
|
4300
|
-
WHEN 'evolution' THEN jsonb_build_object(
|
|
4301
|
-
'templates', false, -- n\xE3o existem: manda texto direto
|
|
4302
|
-
'requires_template', false,
|
|
4303
|
-
'session_window_hours', null,
|
|
4304
|
-
'delivery_receipts', true,
|
|
4305
|
-
'qr_pairing', true, -- parear \xE9 ler um QR, n\xE3o cadastrar na Meta
|
|
4306
|
-
'ban_risk', 'high') -- e \xE9 por isso que o aquecimento importa
|
|
4307
|
-
ELSE '{}'::jsonb END);
|
|
4308
|
-
|
|
4309
|
-
INSERT INTO public.plg_conversations_providers AS t
|
|
4310
|
-
(tenant_id, provider, config, capabilities, status,
|
|
4311
|
-
secrets)
|
|
4312
|
-
VALUES
|
|
4313
|
-
(p_tenant, p_provider, coalesce(p_config, '{}'::jsonb), v_caps,
|
|
4314
|
-
CASE WHEN p_api_key IS NULL THEN 'unconfigured' ELSE 'connecting' END,
|
|
4315
|
-
CASE WHEN p_api_key IS NULL THEN '{}'::jsonb
|
|
4316
|
-
ELSE jsonb_build_object('api_key', p_api_key) END)
|
|
4317
|
-
ON CONFLICT (tenant_id, provider) DO UPDATE
|
|
4318
|
-
SET config = coalesce(p_config, t.config),
|
|
4319
|
-
capabilities = v_caps,
|
|
4320
|
-
-- Chave nula \xE9 "n\xE3o mexe", n\xE3o "apaga". Salvar a URL do Evolution sem
|
|
4321
|
-
-- redigitar a chave \xE9 o gesto comum, e apag\xE1-la aqui seria a surpresa.
|
|
4322
|
-
secrets = CASE WHEN p_api_key IS NULL THEN t.secrets
|
|
4323
|
-
ELSE jsonb_set(t.secrets, '{api_key}', to_jsonb(p_api_key)) END,
|
|
4324
|
-
status = CASE WHEN p_api_key IS NULL THEN t.status ELSE 'connecting' END,
|
|
4325
|
-
updated_at = now();
|
|
4326
|
-
END $$;
|
|
4327
|
-
|
|
4328
|
-
REVOKE ALL ON FUNCTION public.conversations_set_provider(uuid, text, jsonb, text, jsonb) FROM PUBLIC, anon;
|
|
4329
|
-
GRANT EXECUTE ON FUNCTION public.conversations_set_provider(uuid, text, jsonb, text, jsonb) TO authenticated, service_role;
|
|
4330
|
-
|
|
4331
|
-
-- \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
|
|
4332
|
-
CREATE OR REPLACE FUNCTION public.conversations_activate_provider(
|
|
4333
|
-
p_tenant uuid, p_provider text
|
|
4334
|
-
) RETURNS void
|
|
4335
|
-
LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
|
|
4336
|
-
AS $$
|
|
4337
|
-
BEGIN
|
|
4338
|
-
IF p_tenant IS NULL OR NOT (p_tenant IN (SELECT public.user_tenant_ids())) THEN
|
|
4339
|
-
RAISE EXCEPTION 'conversations_activate_provider: n\xE3o \xE9 uma casa sua';
|
|
4340
|
-
END IF;
|
|
4341
|
-
|
|
4342
|
-
-- Desligar ANTES de ligar: o \xEDndice \xFAnico parcial recusaria os dois ativos,
|
|
4343
|
-
-- e numa transa\xE7\xE3o s\xF3 a ordem \xE9 o que decide entre trocar e falhar.
|
|
4344
|
-
UPDATE public.plg_conversations_providers
|
|
4345
|
-
SET is_active = false, updated_at = now()
|
|
4346
|
-
WHERE tenant_id = p_tenant AND is_active;
|
|
4347
|
-
|
|
4348
|
-
UPDATE public.plg_conversations_providers
|
|
4349
|
-
SET is_active = true, updated_at = now()
|
|
4350
|
-
WHERE tenant_id = p_tenant AND provider = p_provider;
|
|
4351
|
-
|
|
4352
|
-
IF NOT FOUND THEN
|
|
4353
|
-
RAISE EXCEPTION 'conversations_activate_provider: % n\xE3o est\xE1 configurado nesta casa', p_provider;
|
|
4354
|
-
END IF;
|
|
4355
|
-
END $$;
|
|
4356
|
-
|
|
4357
|
-
REVOKE ALL ON FUNCTION public.conversations_activate_provider(uuid, text) FROM PUBLIC, anon;
|
|
4358
|
-
GRANT EXECUTE ON FUNCTION public.conversations_activate_provider(uuid, text) TO authenticated, service_role;
|
|
4359
|
-
|
|
4360
|
-
-- \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
|
|
4361
|
-
--
|
|
4362
|
-
-- \`plg_conversations_channels.provider\` j\xE1 existe e j\xE1 guarda 'tyxter'. O que
|
|
4363
|
-
-- falta \xE9 a liga\xE7\xE3o com a credencial: um n\xFAmero do Evolution precisa saber de
|
|
4364
|
-
-- QUAL inst\xE2ncia ele saiu, porque a mesma casa pode ter mais de uma.
|
|
4365
|
-
ALTER TABLE public.plg_conversations_channels
|
|
4366
|
-
ADD COLUMN IF NOT EXISTS instance_key text;
|
|
4367
|
-
|
|
4368
|
-
COMMENT ON COLUMN public.plg_conversations_channels.instance_key IS
|
|
4369
|
-
'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).';
|
|
4370
|
-
`;
|
|
4371
|
-
var MIGRATION_003_A_CAMPANHA_SAI_NO_RITMO_QUE_O_NUMERO_AGUENTA = `-- ---------------------------------------------------------------------------
|
|
4372
|
-
-- 003_a_campanha_sai_no_ritmo_que_o_numero_aguenta.sql \u2014 template, campanha,
|
|
4373
|
-
-- log de envio e o ritmo que impede o n\xFAmero de ser banido.
|
|
4374
|
-
--
|
|
4375
|
-
-- O desenho n\xE3o \xE9 inven\xE7\xE3o: saiu de ler Mautic, EspoCRM, Odoo e Twenty, e de
|
|
4376
|
-
-- ler a documenta\xE7\xE3o da Meta em vez do folclore que circula sobre ela. Onde os
|
|
4377
|
-
-- quatro discordam, este arquivo diz qual escolheu e por qu\xEA.
|
|
4378
|
-
--
|
|
4379
|
-
-- \u2500\u2500 tr\xEAs coisas que a Meta documenta e que viram REGRA, n\xE3o ajuste \u2500\u2500\u2500\u2500\u2500\u2500
|
|
4380
|
-
--
|
|
4381
|
-
-- \xB7 131050 (o usu\xE1rio saiu do marketing) NUNCA se repete. Reenviar \xE9 outra
|
|
4382
|
-
-- infra\xE7\xE3o, n\xE3o outra tentativa.
|
|
4383
|
-
-- \xB7 131048 (bloqueado/marcado como spam) \xE9 parada dura. \xC9 o alarme de
|
|
4384
|
-
-- qualidade, n\xE3o um erro de rede.
|
|
4385
|
-
-- \xB7 A janela de 24h s\xF3 abre por mensagem OU chamada DO CLIENTE. Template
|
|
4386
|
-
-- nosso n\xE3o abre janela nenhuma \u2014 e \xE9 o erro que mais se v\xEA em CRM.
|
|
4387
|
-
--
|
|
4388
|
-
-- O resto \u2014 a rampa de aquecimento, a faixa de atraso entre mensagens \u2014 \xE9
|
|
4389
|
-
-- pr\xE1tica de comunidade, n\xE3o documenta\xE7\xE3o. Vira PADR\xC3O EDIT\xC1VEL: quem opera o
|
|
4390
|
-
-- n\xFAmero sabe mais do que uma tabela escrita hoje.
|
|
4391
|
-
--
|
|
4392
|
-
-- \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
|
|
4393
|
-
--
|
|
4394
|
-
-- \`rotation\` \xE9 do Mautic: a chave \xFAnica \xE9 (campanha, contato, rota\xE7\xE3o), e a
|
|
4395
|
-
-- rota\xE7\xE3o sobe quando algu\xE9m REINICIA a campanha de prop\xF3sito. Sem ela, a \xFAnica
|
|
4396
|
-
-- forma de reenviar \xE9 apagar o hist\xF3rico \u2014 e a\xED ningu\xE9m consegue provar o que
|
|
4397
|
-
-- foi mandado.
|
|
4398
|
-
--
|
|
4399
|
-
-- \`claim_token\` + \`claim_expires_at\` \xE9 do Twenty: quem vai enviar ARRENDA a
|
|
4400
|
-
-- linha por cinco minutos. Duas inst\xE2ncias do disparador n\xE3o mandam a mesma
|
|
4401
|
-
-- mensagem duas vezes, e um processo que morre no meio devolve a linha sozinho
|
|
4402
|
-
-- quando o arrendamento vence. Os quatro CRMs resolvem isso com \xEDndice \xFAnico
|
|
4403
|
-
-- mais arrendamento; nenhum usa advisory lock, e nenhum usa outbox.
|
|
4404
|
-
-- ---------------------------------------------------------------------------
|
|
4405
|
-
|
|
4406
|
-
-- \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
|
|
4407
|
-
--
|
|
4408
|
-
-- A tabela j\xE1 existe e j\xE1 \xE9 chaveada pelo N\xDAMERO e n\xE3o pelo contato \u2014 que \xE9 o
|
|
4409
|
-
-- acerto do Odoo e do Twenty, e o erro do Mautic: n\xFAmero \xE9 reatribu\xEDdo, contato
|
|
4410
|
-
-- \xE9 duplicado, e a supress\xE3o precisa sobreviver aos dois.
|
|
4411
|
-
--
|
|
4412
|
-
-- O que falta \xE9 n\xE3o apagar nunca. O EspoCRM apaga a linha ao reinscrever e
|
|
4413
|
-
-- perde a prova de que algu\xE9m um dia pediu para sair; o Odoo desliga um
|
|
4414
|
-
-- booleano. Seguimos o Odoo.
|
|
4415
|
-
ALTER TABLE public.plg_conversations_optouts
|
|
4416
|
-
ADD COLUMN IF NOT EXISTS active boolean NOT NULL DEFAULT true,
|
|
4417
|
-
ADD COLUMN IF NOT EXISTS revoked_at timestamptz,
|
|
4418
|
-
ADD COLUMN IF NOT EXISTS source text;
|
|
4419
|
-
|
|
4420
|
-
COMMENT ON COLUMN public.plg_conversations_optouts.active IS
|
|
4421
|
-
'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).';
|
|
4422
|
-
|
|
4423
|
-
-- \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
|
|
4424
|
-
--
|
|
4425
|
-
-- Um template aqui N\xC3O \xE9 um template da Meta. \xC9 o texto da casa, com o espelho
|
|
4426
|
-
-- do que o provedor respondeu sobre ele. Assim o mesmo registro serve aos dois:
|
|
4427
|
-
-- no Tyxter ele carrega o nome e o status da aprova\xE7\xE3o; no Evolution, que n\xE3o
|
|
4428
|
-
-- tem o conceito, ele \xE9 s\xF3 o texto \u2014 e a campanha n\xE3o precisa saber a diferen\xE7a.
|
|
4429
|
-
CREATE TABLE IF NOT EXISTS public.plg_conversations_templates (
|
|
4430
|
-
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
4431
|
-
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
4432
|
-
name text NOT NULL,
|
|
4433
|
-
language text NOT NULL DEFAULT 'pt_BR',
|
|
4434
|
-
category text NOT NULL DEFAULT 'MARKETING',
|
|
4435
|
-
body text NOT NULL,
|
|
4436
|
-
-- Os nomes das vari\xE1veis, na ordem em que {{1}}, {{2}} aparecem. A Meta s\xF3
|
|
4437
|
-
-- entende posi\xE7\xE3o; gente entende nome. Guardar os dois \xE9 o que deixa a tela
|
|
4438
|
-
-- pedir "cargo" em vez de "vari\xE1vel 3".
|
|
4439
|
-
variables text[] NOT NULL DEFAULT '{}',
|
|
4440
|
-
provider text,
|
|
4441
|
-
provider_template_id text,
|
|
4442
|
-
-- \`draft\` enquanto \xE9 nosso; depois \xE9 o que o provedor respondeu, verbatim.
|
|
4443
|
-
status text NOT NULL DEFAULT 'draft',
|
|
4444
|
-
status_detail text,
|
|
4445
|
-
submitted_at timestamptz,
|
|
4446
|
-
approved_at timestamptz,
|
|
4447
|
-
created_at timestamptz NOT NULL DEFAULT now(),
|
|
4448
|
-
updated_at timestamptz NOT NULL DEFAULT now()
|
|
4449
|
-
);
|
|
4450
|
-
|
|
4451
|
-
CREATE UNIQUE INDEX IF NOT EXISTS plg_conversations_templates_name_uq
|
|
4452
|
-
ON public.plg_conversations_templates (tenant_id, name, language);
|
|
4453
|
-
|
|
4454
|
-
ALTER TABLE public.plg_conversations_templates ENABLE ROW LEVEL SECURITY;
|
|
4455
|
-
ALTER TABLE public.plg_conversations_templates FORCE ROW LEVEL SECURITY;
|
|
4456
|
-
DROP POLICY IF EXISTS plg_conversations_templates_rw ON public.plg_conversations_templates;
|
|
4457
|
-
CREATE POLICY plg_conversations_templates_rw ON public.plg_conversations_templates
|
|
4458
|
-
FOR ALL TO authenticated
|
|
4459
|
-
USING (tenant_id IN (SELECT public.user_tenant_ids()))
|
|
4460
|
-
WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
4461
|
-
GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_conversations_templates TO authenticated, service_role;
|
|
4462
|
-
|
|
4463
|
-
-- \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
|
|
4464
|
-
CREATE TABLE IF NOT EXISTS public.plg_conversations_campaigns (
|
|
4465
|
-
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
4466
|
-
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
4467
|
-
name text NOT NULL,
|
|
4468
|
-
template_id uuid REFERENCES public.plg_conversations_templates(id) ON DELETE SET NULL,
|
|
4469
|
-
channel_id uuid REFERENCES public.plg_conversations_channels(id) ON DELETE SET NULL,
|
|
4470
|
-
provider text,
|
|
4471
|
-
status text NOT NULL DEFAULT 'draft',
|
|
4472
|
-
-- O ritmo. Nasce do padr\xE3o do provedor e \xE9 edit\xE1vel: a rampa \xE9 pr\xE1tica de
|
|
4473
|
-
-- comunidade, e quem opera o n\xFAmero sabe mais que esta tabela.
|
|
4474
|
-
pacing jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
4475
|
-
-- Por que parou, quando parou sozinha. Ver \`crm\` nenhum: \xE9 o alarme de
|
|
4476
|
-
-- qualidade da Meta chegando na tela de quem disparou.
|
|
4477
|
-
paused_reason text,
|
|
4478
|
-
started_at timestamptz,
|
|
4479
|
-
finished_at timestamptz,
|
|
4480
|
-
created_by uuid,
|
|
4481
|
-
created_at timestamptz NOT NULL DEFAULT now(),
|
|
4482
|
-
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
4483
|
-
CONSTRAINT plg_conversations_campaigns_status
|
|
4484
|
-
CHECK (status IN ('draft', 'scheduled', 'running', 'paused', 'done', 'cancelled'))
|
|
4485
|
-
);
|
|
4486
|
-
|
|
4487
|
-
ALTER TABLE public.plg_conversations_campaigns ENABLE ROW LEVEL SECURITY;
|
|
4488
|
-
ALTER TABLE public.plg_conversations_campaigns FORCE ROW LEVEL SECURITY;
|
|
4489
|
-
DROP POLICY IF EXISTS plg_conversations_campaigns_rw ON public.plg_conversations_campaigns;
|
|
4490
|
-
CREATE POLICY plg_conversations_campaigns_rw ON public.plg_conversations_campaigns
|
|
4491
|
-
FOR ALL TO authenticated
|
|
4492
|
-
USING (tenant_id IN (SELECT public.user_tenant_ids()))
|
|
4493
|
-
WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
4494
|
-
GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_conversations_campaigns TO authenticated, service_role;
|
|
4495
|
-
|
|
4496
|
-
-- \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
|
|
4497
|
-
--
|
|
4498
|
-
-- \xC9 o log de envio e a fila ao mesmo tempo. Vale para os dois provedores, e \xE9
|
|
4499
|
-
-- o \xFAnico lugar onde se pode PROVAR o que foi mandado, para quem, quando, e
|
|
4500
|
-
-- por que algu\xE9m N\xC3O foi contatado.
|
|
4501
|
-
CREATE TABLE IF NOT EXISTS public.plg_conversations_campaign_targets (
|
|
4502
|
-
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
4503
|
-
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
4504
|
-
campaign_id uuid NOT NULL REFERENCES public.plg_conversations_campaigns(id) ON DELETE CASCADE,
|
|
4505
|
-
-- A pessoa do CRM. \`SET NULL\` e n\xE3o \`CASCADE\`: apagar a pessoa n\xE3o pode
|
|
4506
|
-
-- apagar a prova de que uma mensagem foi enviada a ela.
|
|
4507
|
-
person_id uuid,
|
|
4508
|
-
phone_e164 text NOT NULL,
|
|
4509
|
-
-- Ver o cabe\xE7alho: sobe quando algu\xE9m REINICIA a campanha de prop\xF3sito.
|
|
4510
|
-
rotation integer NOT NULL DEFAULT 1,
|
|
4511
|
-
state text NOT NULL DEFAULT 'queued',
|
|
4512
|
-
skip_reason text,
|
|
4513
|
-
failure_code text,
|
|
4514
|
-
failure_detail text,
|
|
4515
|
-
-- O arrendamento. S\xF3 quem segura o token manda.
|
|
4516
|
-
claim_token uuid,
|
|
4517
|
-
claim_expires_at timestamptz,
|
|
4518
|
-
provider_message_id text,
|
|
4519
|
-
variables jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
4520
|
-
queued_at timestamptz NOT NULL DEFAULT now(),
|
|
4521
|
-
sent_at timestamptz,
|
|
4522
|
-
delivered_at timestamptz,
|
|
4523
|
-
read_at timestamptz,
|
|
4524
|
-
replied_at timestamptz,
|
|
4525
|
-
failed_at timestamptz,
|
|
4526
|
-
CONSTRAINT plg_conversations_campaign_targets_state
|
|
4527
|
-
CHECK (state IN ('queued', 'sending', 'sent', 'delivered', 'read', 'replied', 'failed', 'skipped')),
|
|
4528
|
-
-- O arrendamento existe se e s\xF3 se est\xE1 enviando. Duas verifica\xE7\xF5es porque
|
|
4529
|
-
-- um token sem prazo \xE9 um token que nunca volta.
|
|
4530
|
-
CONSTRAINT plg_conversations_campaign_targets_lease
|
|
4531
|
-
CHECK (state <> 'sending' OR claim_expires_at IS NOT NULL),
|
|
4532
|
-
CONSTRAINT plg_conversations_campaign_targets_lease_pair
|
|
4533
|
-
CHECK ((claim_token IS NULL) = (claim_expires_at IS NULL))
|
|
4534
|
-
);
|
|
4535
|
-
|
|
4536
|
-
-- Uma vez por campanha, por pessoa, por rodada. \xC9 esta linha que impede o
|
|
4537
|
-
-- disparo duplo \u2014 n\xE3o um \`if\` no c\xF3digo do disparador.
|
|
4538
|
-
CREATE UNIQUE INDEX IF NOT EXISTS plg_conversations_campaign_targets_once
|
|
4539
|
-
ON public.plg_conversations_campaign_targets (campaign_id, phone_e164, rotation);
|
|
4540
|
-
|
|
4541
|
-
-- O webhook do provedor chega mais de uma vez, de prop\xF3sito (o Evolution tenta
|
|
4542
|
-
-- 10 vezes em ~50 min). A idempot\xEAncia do recebimento \xE9 este \xEDndice.
|
|
4543
|
-
CREATE UNIQUE INDEX IF NOT EXISTS plg_conversations_campaign_targets_provider_msg
|
|
4544
|
-
ON public.plg_conversations_campaign_targets (tenant_id, provider_message_id)
|
|
4545
|
-
WHERE provider_message_id IS NOT NULL;
|
|
4546
|
-
|
|
4547
|
-
CREATE INDEX IF NOT EXISTS plg_conversations_campaign_targets_pending
|
|
4548
|
-
ON public.plg_conversations_campaign_targets (campaign_id)
|
|
4549
|
-
WHERE state IN ('queued', 'sending');
|
|
4550
|
-
|
|
4551
|
-
-- O ceifeiro: linhas cujo arrendamento venceu voltam para a fila.
|
|
4552
|
-
CREATE INDEX IF NOT EXISTS plg_conversations_campaign_targets_reaper
|
|
4553
|
-
ON public.plg_conversations_campaign_targets (claim_expires_at)
|
|
4554
|
-
WHERE state = 'sending';
|
|
4555
|
-
|
|
4556
|
-
ALTER TABLE public.plg_conversations_campaign_targets ENABLE ROW LEVEL SECURITY;
|
|
4557
|
-
ALTER TABLE public.plg_conversations_campaign_targets FORCE ROW LEVEL SECURITY;
|
|
4558
|
-
DROP POLICY IF EXISTS plg_conversations_campaign_targets_rw ON public.plg_conversations_campaign_targets;
|
|
4559
|
-
CREATE POLICY plg_conversations_campaign_targets_rw ON public.plg_conversations_campaign_targets
|
|
4560
|
-
FOR ALL TO authenticated
|
|
4561
|
-
USING (tenant_id IN (SELECT public.user_tenant_ids()))
|
|
4562
|
-
WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
4563
|
-
GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_conversations_campaign_targets TO authenticated, service_role;
|
|
4564
|
-
|
|
4565
|
-
-- \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
|
|
4566
|
-
--
|
|
4567
|
-
-- Mora no canal e n\xE3o na campanha: o limite \xE9 do N\xDAMERO, e duas campanhas no
|
|
4568
|
-
-- mesmo n\xFAmero somam. Guardar na campanha deixaria cada uma achar que tem a
|
|
4569
|
-
-- cota inteira.
|
|
4570
|
-
ALTER TABLE public.plg_conversations_channels
|
|
4571
|
-
ADD COLUMN IF NOT EXISTS pacing jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
4572
|
-
ADD COLUMN IF NOT EXISTS warmup_day integer,
|
|
4573
|
-
ADD COLUMN IF NOT EXISTS warmup_started_at timestamptz,
|
|
4574
|
-
ADD COLUMN IF NOT EXISTS quality text,
|
|
4575
|
-
ADD COLUMN IF NOT EXISTS quality_at timestamptz;
|
|
4576
|
-
|
|
4577
|
-
COMMENT ON COLUMN public.plg_conversations_channels.pacing IS
|
|
4578
|
-
'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).';
|
|
4579
|
-
|
|
4580
|
-
-- Os padr\xF5es, por provedor. N\xFAmeros da pesquisa; o coment\xE1rio diz a
|
|
4581
|
-
-- proced\xEAncia de cada um, porque "por que 20?" \xE9 a pergunta que sempre volta.
|
|
4582
|
-
CREATE OR REPLACE FUNCTION public.conversations_default_pacing(p_provider text)
|
|
4583
|
-
RETURNS jsonb LANGUAGE sql IMMUTABLE AS $$
|
|
4584
|
-
SELECT CASE p_provider
|
|
4585
|
-
-- Oficial. O teto real \xE9 o tier da Meta (250/2.000/10.000/100.000), que a
|
|
4586
|
-
-- pr\xF3pria API informa por n\xFAmero \u2014 ent\xE3o aqui s\xF3 entra o que a Meta N\xC3O
|
|
4587
|
-
-- controla: o ritmo instant\xE2neo. 1 msg / 6 s para o MESMO destinat\xE1rio \xE9
|
|
4588
|
-
-- documentado; 60/min \xE9 75% do teto de 80/s por seguran\xE7a.
|
|
4589
|
-
WHEN 'tyxter' THEN jsonb_build_object(
|
|
4590
|
-
'per_minute', 60, 'per_hour', 1000, 'per_day', null,
|
|
4591
|
-
'delay_seconds', jsonb_build_array(1, 3),
|
|
4592
|
-
'same_recipient_seconds', 6,
|
|
4593
|
-
'hours', jsonb_build_object('start', '09:00', 'end', '18:00', 'weekdays_only', true))
|
|
4594
|
-
-- N\xE3o oficial. Aqui n\xE3o h\xE1 teto do provedor \u2014 h\xE1 banimento. Os n\xFAmeros s\xE3o
|
|
4595
|
-
-- a moda de treze rampas brasileiras publicadas: 10-30/dia na primeira
|
|
4596
|
-
-- semana, 200-300/dia no regime. Atraso ALEAT\xD3RIO entre 15 e 45 s porque
|
|
4597
|
-
-- intervalo fixo \xE9 assinatura de rob\xF4.
|
|
4598
|
-
WHEN 'evolution' THEN jsonb_build_object(
|
|
4599
|
-
'per_minute', 12, 'per_hour', 300, 'per_day', 250,
|
|
4600
|
-
'delay_seconds', jsonb_build_array(15, 45),
|
|
4601
|
-
'same_recipient_seconds', 6,
|
|
4602
|
-
'batch_size', 50, 'batch_cooldown_minutes', 12,
|
|
4603
|
-
'hours', jsonb_build_object('start', '09:00', 'end', '18:00', 'weekdays_only', true))
|
|
4604
|
-
ELSE '{}'::jsonb END
|
|
4605
|
-
$$;
|
|
4606
|
-
|
|
4607
|
-
COMMENT ON FUNCTION public.conversations_default_pacing(text) IS
|
|
4608
|
-
'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).';
|
|
4609
|
-
|
|
4610
|
-
-- A rampa de aquecimento, em dias. Pr\xE1tica de comunidade, edit\xE1vel \u2014 e a
|
|
4611
|
-
-- fun\xE7\xE3o existe para que o padr\xE3o seja UM lugar e n\xE3o uma constante copiada.
|
|
4612
|
-
CREATE OR REPLACE FUNCTION public.conversations_warmup_allowance(p_day integer)
|
|
4613
|
-
RETURNS integer LANGUAGE sql IMMUTABLE AS $$
|
|
4614
|
-
SELECT CASE
|
|
4615
|
-
WHEN p_day IS NULL THEN NULL -- n\xFAmero sem aquecimento declarado: sem teto extra
|
|
4616
|
-
WHEN p_day <= 3 THEN 20
|
|
4617
|
-
WHEN p_day <= 7 THEN 50
|
|
4618
|
-
WHEN p_day <= 14 THEN 100
|
|
4619
|
-
WHEN p_day <= 21 THEN 200
|
|
4620
|
-
ELSE 300
|
|
4621
|
-
END
|
|
4622
|
-
$$;
|
|
4623
|
-
|
|
4624
|
-
COMMENT ON FUNCTION public.conversations_warmup_allowance(integer) IS
|
|
4625
|
-
'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).';
|
|
4626
|
-
|
|
4627
|
-
-- \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
|
|
4628
|
-
DROP VIEW IF EXISTS public.v_conversations_campaign_progress;
|
|
4629
|
-
CREATE VIEW public.v_conversations_campaign_progress
|
|
4630
|
-
WITH (security_invoker = true) AS
|
|
4631
|
-
SELECT c.id AS campaign_id,
|
|
4632
|
-
c.tenant_id,
|
|
4633
|
-
c.name,
|
|
4634
|
-
c.status,
|
|
4635
|
-
c.paused_reason,
|
|
4636
|
-
c.provider,
|
|
4637
|
-
count(t.id) AS total,
|
|
4638
|
-
count(*) FILTER (WHERE t.state = 'queued') AS queued,
|
|
4639
|
-
count(*) FILTER (WHERE t.state = 'sending') AS sending,
|
|
4640
|
-
count(*) FILTER (WHERE t.state IN ('sent','delivered','read','replied')) AS sent,
|
|
4641
|
-
count(*) FILTER (WHERE t.state = 'delivered') AS delivered,
|
|
4642
|
-
count(*) FILTER (WHERE t.state = 'read') AS read,
|
|
4643
|
-
count(*) FILTER (WHERE t.state = 'replied') AS replied,
|
|
4644
|
-
count(*) FILTER (WHERE t.state = 'failed') AS failed,
|
|
4645
|
-
count(*) FILTER (WHERE t.state = 'skipped') AS skipped,
|
|
4646
|
-
-- A taxa de resposta \xE9 o sinal que decide se o n\xFAmero sobrevive: a
|
|
4647
|
-
-- pr\xE1tica diz que abaixo de 30% o risco de banimento sobe. Fica na
|
|
4648
|
-
-- view porque \xE9 o n\xFAmero que precisa estar na tela, n\xE3o num relat\xF3rio.
|
|
4649
|
-
CASE WHEN count(*) FILTER (WHERE t.state IN ('sent','delivered','read','replied')) > 0
|
|
4650
|
-
THEN round(100.0 * count(*) FILTER (WHERE t.state = 'replied')
|
|
4651
|
-
/ count(*) FILTER (WHERE t.state IN ('sent','delivered','read','replied')), 1)
|
|
4652
|
-
END AS reply_rate,
|
|
4653
|
-
min(t.sent_at) AS first_sent_at,
|
|
4654
|
-
max(t.sent_at) AS last_sent_at
|
|
4655
|
-
FROM public.plg_conversations_campaigns c
|
|
4656
|
-
LEFT JOIN public.plg_conversations_campaign_targets t ON t.campaign_id = c.id
|
|
4657
|
-
WHERE c.tenant_id IN (SELECT public.user_tenant_ids())
|
|
4658
|
-
GROUP BY c.id, c.tenant_id, c.name, c.status, c.paused_reason, c.provider;
|
|
4659
|
-
|
|
4660
|
-
GRANT SELECT ON public.v_conversations_campaign_progress TO authenticated;
|
|
4661
|
-
|
|
4662
|
-
-- \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
|
|
4663
|
-
--
|
|
4664
|
-
-- \`FOR UPDATE SKIP LOCKED\` \xE9 o que deixa dois disparadores trabalharem a mesma
|
|
4665
|
-
-- campanha sem pisar um no outro, e sem advisory lock. O arrendamento de cinco
|
|
4666
|
-
-- minutos \xE9 o que devolve a linha quando um deles morre no meio.
|
|
4667
|
-
--
|
|
4668
|
-
-- A supress\xE3o \xE9 conferida AQUI, na mesma transa\xE7\xE3o que arrenda. Conferir antes
|
|
4669
|
-
-- e mandar depois deixa uma janela em que algu\xE9m pede para sair e recebe assim
|
|
4670
|
-
-- mesmo \u2014 e \xE9 justamente essa mensagem que vira den\xFAncia.
|
|
4671
|
-
CREATE OR REPLACE FUNCTION public.conversations_claim_targets(
|
|
4672
|
-
p_campaign uuid, p_limit integer DEFAULT 10
|
|
4673
|
-
) RETURNS TABLE (
|
|
4674
|
-
id uuid, phone_e164 text, person_id uuid, variables jsonb, claim_token uuid
|
|
4675
|
-
)
|
|
4676
|
-
LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
|
|
4677
|
-
AS $$
|
|
4678
|
-
DECLARE v_token uuid := gen_random_uuid(); v_tenant uuid;
|
|
4679
|
-
BEGIN
|
|
4680
|
-
SELECT c.tenant_id INTO v_tenant
|
|
4681
|
-
FROM public.plg_conversations_campaigns c WHERE c.id = p_campaign;
|
|
4682
|
-
IF v_tenant IS NULL THEN RAISE EXCEPTION 'campanha n\xE3o encontrada'; END IF;
|
|
4683
|
-
|
|
4684
|
-
-- TR\xCAS COMANDOS, E N\xC3O UM. A primeira vers\xE3o fazia o ceifeiro, a supress\xE3o e
|
|
4685
|
-
-- o arrendamento em CTEs do mesmo UPDATE, e isso \xE9 uma armadilha: dentro de
|
|
4686
|
-
-- um comando s\xF3, todas as CTEs enxergam o MESMO instant\xE2neo da tabela, e uma
|
|
4687
|
-
-- linha atualizada por duas delas recebe s\xF3 a primeira \u2014 em sil\xEAncio. O
|
|
4688
|
-
-- suprimido vinha marcado E arrendado, e qual dos dois vencia dependia do
|
|
4689
|
-
-- plano que o Postgres escolhesse naquele dia.
|
|
4690
|
-
--
|
|
4691
|
-
-- Separado, cada passo enxerga o resultado do anterior. \xC9 mais lento por um
|
|
4692
|
-
-- par de milissegundos e \xE9 a diferen\xE7a entre uma regra e uma coincid\xEAncia.
|
|
4693
|
-
|
|
4694
|
-
-- 1. O ceifeiro. Quem venceu o arrendamento volta para a fila, sen\xE3o uma
|
|
4695
|
-
-- campanha inteira fica presa num processo que morreu.
|
|
4696
|
-
UPDATE public.plg_conversations_campaign_targets t
|
|
4697
|
-
SET state = 'queued', claim_token = NULL, claim_expires_at = NULL
|
|
4698
|
-
WHERE t.campaign_id = p_campaign AND t.state = 'sending'
|
|
4699
|
-
AND t.claim_expires_at < now();
|
|
4700
|
-
|
|
4701
|
-
-- 2. Quem pediu para sair NUNCA entra num lote. A linha fica marcada em vez
|
|
4702
|
-
-- de sumir: \xE9 assim que se prova que a decis\xE3o foi deliberada.
|
|
4703
|
-
UPDATE public.plg_conversations_campaign_targets t
|
|
4704
|
-
SET state = 'skipped', skip_reason = 'suppressed'
|
|
4705
|
-
WHERE t.campaign_id = p_campaign AND t.state = 'queued'
|
|
4706
|
-
AND EXISTS (SELECT 1 FROM public.plg_conversations_optouts o
|
|
4707
|
-
WHERE o.tenant_id = t.tenant_id
|
|
4708
|
-
AND o.phone_e164 = t.phone_e164
|
|
4709
|
-
AND o.active);
|
|
4710
|
-
|
|
4711
|
-
-- 3. S\xF3 ent\xE3o arrenda. \`SKIP LOCKED\` deixa dois disparadores trabalharem a
|
|
4712
|
-
-- mesma campanha sem pisar um no outro, e sem advisory lock.
|
|
4713
|
-
RETURN QUERY
|
|
4714
|
-
WITH escolhidos AS (
|
|
4715
|
-
SELECT t.id FROM public.plg_conversations_campaign_targets t
|
|
4716
|
-
WHERE t.campaign_id = p_campaign AND t.state = 'queued'
|
|
4717
|
-
ORDER BY t.queued_at
|
|
4718
|
-
LIMIT greatest(1, least(coalesce(p_limit, 10), 100))
|
|
4719
|
-
FOR UPDATE SKIP LOCKED
|
|
4720
|
-
)
|
|
4721
|
-
UPDATE public.plg_conversations_campaign_targets t
|
|
4722
|
-
SET state = 'sending', claim_token = v_token,
|
|
4723
|
-
claim_expires_at = now() + interval '5 minutes'
|
|
4724
|
-
FROM escolhidos e
|
|
4725
|
-
WHERE t.id = e.id
|
|
4726
|
-
RETURNING t.id, t.phone_e164, t.person_id, t.variables, t.claim_token;
|
|
4727
|
-
END $$;
|
|
4728
|
-
|
|
4729
|
-
REVOKE ALL ON FUNCTION public.conversations_claim_targets(uuid, integer) FROM PUBLIC, anon;
|
|
4730
|
-
GRANT EXECUTE ON FUNCTION public.conversations_claim_targets(uuid, integer) TO service_role;
|
|
4731
|
-
`;
|
|
4732
|
-
var MIGRATION_004_A_TELA_SABE_SE_PODE_FALAR_ANTES_DE_DEIXAR_ESCREVER = `-- ---------------------------------------------------------------------------
|
|
4733
|
-
-- 004_a_tela_sabe_se_pode_falar_antes_de_deixar_escrever.sql \u2014 a janela de 24h,
|
|
4734
|
-
-- como DADO, para a tela responder antes de algu\xE9m digitar.
|
|
4735
|
-
--
|
|
4736
|
-
-- \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
|
|
4737
|
-
--
|
|
4738
|
-
-- Na API oficial do WhatsApp, texto livre s\xF3 sai dentro de 24h contadas da
|
|
4739
|
-
-- \xFAltima mensagem DO CLIENTE. Fora dela a Meta recusa, e a recusa chega como um
|
|
4740
|
-
-- c\xF3digo no log \u2014 n\xE3o na tela de quem escreveu. O atendente digita, aperta
|
|
4741
|
-
-- enviar, v\xEA a mensagem aparecer na conversa, e ela nunca chega.
|
|
4742
|
-
--
|
|
4743
|
-
-- Isso \xE9 pior que um erro: \xE9 uma tela que mente. E some inteiro num provedor
|
|
4744
|
-
-- n\xE3o oficial, onde n\xE3o existe janela nenhuma \u2014 ent\xE3o o MESMO campo de texto
|
|
4745
|
-
-- funciona ou falha calado dependendo de qual provedor est\xE1 ativo.
|
|
4746
|
-
--
|
|
4747
|
-
-- A corre\xE7\xE3o n\xE3o \xE9 um aviso depois. \xC9 a tela saber ANTES: janela aberta, texto
|
|
4748
|
-
-- livre; janela fechada, s\xF3 template, e o campo diz isso em vez de aceitar.
|
|
4749
|
-
--
|
|
4750
|
-
-- \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
|
|
4751
|
-
--
|
|
4752
|
-
-- O CLIENTE, e s\xF3 ele. Mensagem nossa n\xE3o abre nada \u2014 nem template aprovado.
|
|
4753
|
-
-- \xC9 o erro mais comum de quem implementa isso, e \xE9 por isso que o c\xE1lculo
|
|
4754
|
-
-- abaixo olha exclusivamente \`direction = 'inbound'\`.
|
|
4755
|
-
--
|
|
4756
|
-
-- (Origem: developers.facebook.com/documentation/business-messaging/whatsapp \u2014
|
|
4757
|
-
-- "The user messages you about the product. This opens a 24 hour customer
|
|
4758
|
-
-- service window." Cliques em an\xFAncio Click-to-WhatsApp abrem 72h; n\xE3o
|
|
4759
|
-
-- distinguimos ainda, e 24h \xE9 o limite conservador dos dois.)
|
|
4760
|
-
-- ---------------------------------------------------------------------------
|
|
4761
|
-
|
|
4762
|
-
DROP VIEW IF EXISTS public.v_conversations_window;
|
|
4763
|
-
CREATE VIEW public.v_conversations_window
|
|
4764
|
-
WITH (security_invoker = true) AS
|
|
4765
|
-
SELECT c.id AS conversation_id,
|
|
4766
|
-
c.tenant_id,
|
|
4767
|
-
c.channel,
|
|
4768
|
-
c.contact_handle,
|
|
4769
|
-
c.contact_person_id,
|
|
4770
|
-
u.last_inbound_at,
|
|
4771
|
-
-- Quando fecha. Nulo = nunca abriu, que \xE9 o caso de todo lead que
|
|
4772
|
-
-- ainda n\xE3o respondeu \u2014 a maioria de uma lista de feira.
|
|
4773
|
-
(u.last_inbound_at + interval '24 hours') AS window_expires_at,
|
|
4774
|
-
(u.last_inbound_at IS NOT NULL
|
|
4775
|
-
AND u.last_inbound_at > now() - interval '24 hours') AS window_open,
|
|
4776
|
-
-- Quanto falta, em minutos. A tela mostra "faltam 3h" e n\xE3o um
|
|
4777
|
-
-- carimbo de data: quem est\xE1 respondendo quer saber se d\xE1 tempo.
|
|
4778
|
-
CASE WHEN u.last_inbound_at IS NOT NULL
|
|
4779
|
-
THEN greatest(0, floor(extract(epoch FROM
|
|
4780
|
-
(u.last_inbound_at + interval '24 hours') - now()) / 60))::int
|
|
4781
|
-
END AS minutes_left,
|
|
4782
|
-
-- Quem pediu para n\xE3o ser mais contatado. Some da tela antes de
|
|
4783
|
-
-- qualquer janela: n\xE3o importa se est\xE1 aberta.
|
|
4784
|
-
EXISTS (SELECT 1 FROM public.plg_conversations_optouts o
|
|
4785
|
-
WHERE o.tenant_id = c.tenant_id
|
|
4786
|
-
AND o.phone_e164 = regexp_replace(coalesce(c.contact_handle,''), '\\D', '', 'g')
|
|
4787
|
-
AND o.active) AS opted_out
|
|
4788
|
-
FROM public.plg_conversations c
|
|
4789
|
-
LEFT JOIN LATERAL (
|
|
4790
|
-
SELECT max(m.at) AS last_inbound_at
|
|
4791
|
-
FROM public.plg_conversation_messages m
|
|
4792
|
-
WHERE m.conversation_id = c.id
|
|
4793
|
-
AND m.direction = 'inbound'
|
|
4794
|
-
) u ON true
|
|
4795
|
-
WHERE c.tenant_id IN (SELECT public.user_tenant_ids());
|
|
4796
|
-
|
|
4797
|
-
GRANT SELECT ON public.v_conversations_window TO authenticated;
|
|
4798
|
-
|
|
4799
|
-
COMMENT ON VIEW public.v_conversations_window IS
|
|
4800
|
-
'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).';
|
|
4801
|
-
|
|
4802
|
-
-- O \xEDndice que a LATERAL usa. Sem ele, cada linha da caixa varre as mensagens
|
|
4803
|
-
-- da conversa inteira, e a caixa \xE9 justamente a tela que abre o dia todo.
|
|
4804
|
-
CREATE INDEX IF NOT EXISTS plg_conversation_messages_inbound_at
|
|
4805
|
-
ON public.plg_conversation_messages (conversation_id, at DESC)
|
|
4806
|
-
WHERE direction = 'inbound';
|
|
4807
|
-
`;
|
|
4808
|
-
var MIGRATION_005_A_CONVERSA_CONTA_AO_FUNIL_QUE_HOUVE_CONTATO = `-- ---------------------------------------------------------------------------
|
|
4809
|
-
-- 005_a_conversa_conta_ao_funil_que_houve_contato.sql \u2014 a ponte que faltava.
|
|
4810
|
-
--
|
|
4811
|
-
-- \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
|
|
4812
|
-
--
|
|
4813
|
-
-- No cluster: 8 conversas, 42 mensagens trocadas \u2014 incluindo respostas de
|
|
4814
|
-
-- gente de verdade \u2014 e 315 neg\xF3cios TODOS parados em "Novo (feira)", com
|
|
4815
|
-
-- \`contact_status = 'not_contacted'\`. Zero atividades vindas da caixa.
|
|
4816
|
-
--
|
|
4817
|
-
-- N\xE3o era defeito: n\xE3o existia liga\xE7\xE3o nenhuma. Nenhum \`INSERT\` em
|
|
4818
|
-
-- \`plg_crm_activities\` fora do plugin-crm, nenhuma fonte de linha do tempo
|
|
4819
|
-
-- registrada. A caixa de entrada e o funil olhavam para as mesmas pessoas e
|
|
4820
|
-
-- n\xE3o se falavam.
|
|
4821
|
-
--
|
|
4822
|
-
-- O maquin\xE1rio do CRM j\xE1 estava pronto e correto: \`crm_recompute_deal_status\`
|
|
4823
|
-
-- (crm/020) l\xEA as atividades, pega o \xDALTIMO \`outcome\` e deriva o status; o
|
|
4824
|
-
-- gatilho \`crm_activity_restatus\` dispara a cada atividade inserida. Faltava
|
|
4825
|
-
-- algu\xE9m inserir.
|
|
4826
|
-
--
|
|
4827
|
-
-- \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
|
|
4828
|
-
--
|
|
4829
|
-
-- mensagem RECEBIDA \u2192 \`replied\` o lead respondeu. \xC9 o fato mais forte
|
|
4830
|
-
-- que a caixa produz, e o que mais move
|
|
4831
|
-
-- o status ("Respondeu").
|
|
4832
|
-
-- mensagem ENVIADA \u2192 \`left_message\` deixamos recado. Mandar N\xC3O \xE9 ser
|
|
4833
|
-
-- lido: tratar envio como contato feito
|
|
4834
|
-
-- encheria o funil de "conectado" sem
|
|
4835
|
-
-- ningu\xE9m do outro lado. A 020 mapeia
|
|
4836
|
-
-- este valor para "N\xE3o atendeu", que \xE9
|
|
4837
|
-
-- literalmente o estado \u2014 ainda sem
|
|
4838
|
-
-- resposta.
|
|
4839
|
-
--
|
|
4840
|
-
-- O vocabul\xE1rio \xE9 FECHADO por CHECK (crm/020:49): \`connected, no_answer,
|
|
4841
|
-
-- left_message, replied, scheduled\`. Conferido antes de escrever \u2014 a primeira
|
|
4842
|
-
-- vers\xE3o desta migration usava \`attempted\`, que a regra da 020 nomeia mas a
|
|
4843
|
-
-- restri\xE7\xE3o da coluna N\xC3O aceita, e teria falhado em toda mensagem enviada.
|
|
4844
|
-
--
|
|
4845
|
-
-- \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
|
|
4846
|
-
--
|
|
4847
|
-
-- \`crm_recompute_deal_status\` agrega \`WHERE a.deal_id = p_deal_id\`. Uma
|
|
4848
|
-
-- atividade s\xF3 com \`lead_id\` n\xE3o entra na conta e n\xE3o move status nenhum \u2014
|
|
4849
|
-
-- por isso o gatilho procura o neg\xF3cio do lead e o carimba.
|
|
4850
|
-
--
|
|
4851
|
-
-- \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
|
|
4852
|
-
--
|
|
4853
|
-
-- Uma conversa de trinta mensagens n\xE3o s\xE3o trinta contatos: \xE9 um. Registrar
|
|
4854
|
-
-- cada mensagem encheria a linha do tempo de ru\xEDdo e afogaria o que importa \u2014
|
|
4855
|
-
-- a liga\xE7\xE3o, a visita, a proposta. Ent\xE3o a primeira mensagem do dia abre a
|
|
4856
|
-
-- atividade e as seguintes ATUALIZAM a mesma linha (o corpo mais recente, e o
|
|
4857
|
-
-- \`outcome\` mais forte do dia).
|
|
4858
|
-
--
|
|
4859
|
-
-- Atualizar tamb\xE9m dispara o \`crm_activity_restatus\` (o gatilho \xE9 INSERT OR
|
|
4860
|
-
-- UPDATE), ent\xE3o uma resposta que chega depois de um envio move o status no
|
|
4861
|
-
-- mesmo dia, que \xE9 o comportamento que se quer.
|
|
4862
|
-
--
|
|
4863
|
-
-- \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
|
|
4864
|
-
--
|
|
4865
|
-
-- \`contact_person_id\` \xE9 nulo enquanto ningu\xE9m casou o telefone com uma pessoa.
|
|
4866
|
-
-- A\xED n\xE3o h\xE1 a quem pendurar o fato e a fun\xE7\xE3o sai calada. N\xE3o inventa lead: a
|
|
4867
|
-
-- caixa recebe mensagem de entregador, de banco e de engano, e cada um deles
|
|
4868
|
-
-- viraria um card no funil de algu\xE9m.
|
|
4869
|
-
-- ---------------------------------------------------------------------------
|
|
4870
|
-
|
|
4871
|
-
CREATE OR REPLACE FUNCTION public.conversations_log_crm_activity() RETURNS trigger
|
|
4872
|
-
LANGUAGE plpgsql SECURITY DEFINER
|
|
4873
|
-
SET search_path TO ''
|
|
4874
|
-
AS $$
|
|
4875
|
-
DECLARE
|
|
4876
|
-
v_tenant uuid;
|
|
4877
|
-
v_person uuid;
|
|
4878
|
-
v_canal text;
|
|
4879
|
-
v_deal uuid;
|
|
4880
|
-
v_outcome text;
|
|
4881
|
-
v_titulo text;
|
|
4882
|
-
v_corpo text;
|
|
4883
|
-
v_dia date := (NEW.at AT TIME ZONE 'UTC')::date;
|
|
4884
|
-
v_ativ uuid;
|
|
4885
|
-
BEGIN
|
|
4886
|
-
-- Sem CRM montado n\xE3o h\xE1 o que escrever. \`to_regclass\` em vez de refer\xEAncia
|
|
4887
|
-
-- direta porque conversas \xE9 plugin OPCIONAL e pode viver num cluster que n\xE3o
|
|
4888
|
-
-- tem funil nenhum.
|
|
4889
|
-
IF to_regclass('public.plg_crm_activities') IS NULL THEN RETURN NULL; END IF;
|
|
4890
|
-
|
|
4891
|
-
SELECT c.tenant_id, c.contact_person_id, c.channel
|
|
4892
|
-
INTO v_tenant, v_person, v_canal
|
|
4893
|
-
FROM public.plg_conversations c WHERE c.id = NEW.conversation_id;
|
|
4894
|
-
|
|
4895
|
-
IF v_person IS NULL THEN RETURN NULL; END IF;
|
|
4896
|
-
|
|
4897
|
-
SELECT d.id INTO v_deal
|
|
4898
|
-
FROM public.plg_crm_deals d
|
|
4899
|
-
WHERE d.tenant_id = v_tenant AND d.lead_id = v_person
|
|
4900
|
-
ORDER BY d.created_at DESC LIMIT 1;
|
|
4901
|
-
|
|
4902
|
-
IF NEW.direction = 'inbound' THEN
|
|
4903
|
-
v_outcome := 'replied';
|
|
4904
|
-
v_titulo := 'Respondeu no ' || coalesce(v_canal, 'canal');
|
|
4905
|
-
ELSE
|
|
4906
|
-
v_outcome := 'left_message';
|
|
4907
|
-
v_titulo := 'Mensagem enviada no ' || coalesce(v_canal, 'canal');
|
|
4908
|
-
END IF;
|
|
4909
|
-
|
|
4910
|
-
v_corpo := left(coalesce(NEW.body, ''), 2000);
|
|
4911
|
-
|
|
4912
|
-
-- A atividade do dia, se j\xE1 houver.
|
|
4913
|
-
SELECT a.id INTO v_ativ
|
|
4914
|
-
FROM public.plg_crm_activities a
|
|
4915
|
-
WHERE a.tenant_id = v_tenant
|
|
4916
|
-
AND a.lead_id = v_person
|
|
4917
|
-
AND a.activity_type = 'whatsapp'
|
|
4918
|
-
AND (a.completed_at AT TIME ZONE 'UTC')::date = v_dia
|
|
4919
|
-
ORDER BY a.completed_at DESC LIMIT 1;
|
|
4920
|
-
|
|
4921
|
-
IF v_ativ IS NULL THEN
|
|
4922
|
-
INSERT INTO public.plg_crm_activities (
|
|
4923
|
-
tenant_id, deal_id, lead_id, contact_id, activity_type,
|
|
4924
|
-
title, description, outcome, completed_at, created_by_name
|
|
4925
|
-
) VALUES (
|
|
4926
|
-
v_tenant, v_deal, v_person, v_person, 'whatsapp',
|
|
4927
|
-
v_titulo, v_corpo, v_outcome, NEW.at,
|
|
4928
|
-
-- Quem fez foi a caixa, n\xE3o uma pessoa: o autor em uuid seria mentira,
|
|
4929
|
-
-- e "(sistema)" \xE9 a resposta honesta para quem l\xEA a linha do tempo.
|
|
4930
|
-
'Caixa de entrada'
|
|
4931
|
-
);
|
|
4932
|
-
ELSE
|
|
4933
|
-
UPDATE public.plg_crm_activities a SET
|
|
4934
|
-
-- \`replied\` NUNCA \xE9 rebaixado por um envio posterior: o lead ter
|
|
4935
|
-
-- respondido \xE9 o fato do dia, e mandar outra mensagem depois n\xE3o o
|
|
4936
|
-
-- desfaz.
|
|
4937
|
-
outcome = CASE WHEN a.outcome = 'replied' THEN 'replied' ELSE v_outcome END,
|
|
4938
|
-
title = CASE WHEN a.outcome = 'replied' THEN a.title ELSE v_titulo END,
|
|
4939
|
-
description = v_corpo,
|
|
4940
|
-
completed_at = NEW.at,
|
|
4941
|
-
deal_id = coalesce(a.deal_id, v_deal)
|
|
4942
|
-
WHERE a.id = v_ativ;
|
|
4943
|
-
END IF;
|
|
4944
|
-
|
|
4945
|
-
RETURN NULL;
|
|
4946
|
-
END $$;
|
|
4947
|
-
|
|
4948
|
-
REVOKE ALL ON FUNCTION public.conversations_log_crm_activity() FROM PUBLIC;
|
|
4949
|
-
GRANT EXECUTE ON FUNCTION public.conversations_log_crm_activity() TO authenticated, service_role;
|
|
4950
|
-
|
|
4951
|
-
COMMENT ON FUNCTION public.conversations_log_crm_activity() IS
|
|
4952
|
-
'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).';
|
|
4953
|
-
|
|
4954
|
-
-- AFTER: falhar ao registrar no CRM n\xE3o pode recusar uma mensagem que j\xE1
|
|
4955
|
-
-- chegou do provedor. A caixa \xE9 a fonte; o funil \xE9 quem escuta.
|
|
4956
|
-
DROP TRIGGER IF EXISTS conversations_log_crm_activity ON public.plg_conversation_messages;
|
|
4957
|
-
CREATE TRIGGER conversations_log_crm_activity
|
|
4958
|
-
AFTER INSERT ON public.plg_conversation_messages
|
|
4959
|
-
FOR EACH ROW EXECUTE FUNCTION public.conversations_log_crm_activity();
|
|
4960
|
-
|
|
4961
|
-
-- O \xEDndice que a busca da atividade do dia usa. Sem ele, cada mensagem varre
|
|
4962
|
-
-- as atividades do inquilino inteiro \u2014 e mensagem \xE9 o que mais chega aqui.
|
|
4963
|
-
DO $$
|
|
4964
|
-
BEGIN
|
|
4965
|
-
-- Guarda pela mesma raz\xE3o da fun\xE7\xE3o: conversas \xE9 opcional e pode viver sem
|
|
4966
|
-
-- CRM. Um \xEDndice sobre tabela que n\xE3o existe recusa o arquivo inteiro.
|
|
4967
|
-
IF to_regclass('public.plg_crm_activities') IS NULL THEN RETURN; END IF;
|
|
4968
|
-
CREATE INDEX IF NOT EXISTS plg_crm_activities_lead_day
|
|
4969
|
-
ON public.plg_crm_activities (tenant_id, lead_id, activity_type, completed_at DESC);
|
|
4970
|
-
END $$;
|
|
4971
|
-
`;
|
|
4972
|
-
var MIGRATION_006_A_CAIXA_AVISA_QUE_CHEGOU = `-- ---------------------------------------------------------------------------
|
|
4973
|
-
-- 006_a_caixa_avisa_que_chegou.sql
|
|
4974
|
-
--
|
|
4975
|
-
-- \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
|
|
4976
|
-
--
|
|
4977
|
-
-- A caixa recebia mensagens e ningu\xE9m era avisado. Sem som, sem contagem no
|
|
4978
|
-
-- menu, sem destaque na lista \u2014 e a suspeita natural \xE9 que falta a tela que
|
|
4979
|
-
-- avisa. N\xE3o faltava: o evento nunca sa\xEDa do banco.
|
|
4980
|
-
--
|
|
4981
|
-
-- \`plg_conversation_messages\` e \`plg_conversations\` n\xE3o estavam em
|
|
4982
|
-
-- \`supabase_realtime\`. A publica\xE7\xE3o existe (\`puballtables = false\`, ou seja,
|
|
4983
|
-
-- cada tabela entra nomeada) e nenhuma das duas fora nomeada. Qualquer cliente
|
|
4984
|
-
-- que assinasse \`postgres_changes\` sobre elas ficaria subscrito para sempre a
|
|
4985
|
-
-- um canal por onde nada passa \u2014 e subscrever com sucesso a um canal mudo \xE9 o
|
|
4986
|
-
-- modo de falhar mais convincente que h\xE1: o c\xF3digo parece certo, o console
|
|
4987
|
-
-- fica limpo, e nada acontece.
|
|
4988
|
-
--
|
|
4989
|
-
-- \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
|
|
4990
|
-
--
|
|
4991
|
-
-- A mensagem \xE9 o que dispara o alerta; a CONVERSA \xE9 o que carrega o estado que
|
|
4992
|
-
-- a lista pinta (\`status\`, \`unread_count\`, \`last_message_direction\`). Publicar
|
|
4993
|
-
-- s\xF3 a primeira daria o som sem o badge parar de mentir depois de algu\xE9m ler.
|
|
4994
|
-
--
|
|
4995
|
-
-- \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
|
|
4996
|
-
--
|
|
4997
|
-
-- Publicar n\xE3o abre nada: o Realtime do Supabase aplica as pol\xEDticas da tabela
|
|
4998
|
-
-- por assinante, e quem n\xE3o pode ler a linha n\xE3o a recebe. O que a publica\xE7\xE3o
|
|
4999
|
-
-- decide \xE9 se o evento EXISTE, n\xE3o quem o v\xEA.
|
|
5000
|
-
--
|
|
5001
|
-
-- \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
|
|
5002
|
-
--
|
|
5003
|
-
-- \`ALTER PUBLICATION ... ADD TABLE\` rebenta se a tabela j\xE1 l\xE1 est\xE1, e a
|
|
5004
|
-
-- publica\xE7\xE3o \`supabase_realtime\` s\xF3 existe num Postgres da Supabase. As duas
|
|
5005
|
-
-- condi\xE7\xF5es s\xE3o verificadas antes, porque esta migration tamb\xE9m roda no banco
|
|
5006
|
-
-- de teste, que n\xE3o tem nenhuma delas.
|
|
5007
|
-
-- ---------------------------------------------------------------------------
|
|
5008
|
-
|
|
5009
|
-
DO $$
|
|
5010
|
-
DECLARE
|
|
5011
|
-
t text;
|
|
5012
|
-
v_n integer := 0;
|
|
5013
|
-
BEGIN
|
|
5014
|
-
IF NOT EXISTS (SELECT 1 FROM pg_publication WHERE pubname = 'supabase_realtime') THEN
|
|
5015
|
-
RAISE NOTICE 'conversations/006: sem publica\xE7\xE3o supabase_realtime \u2014 nada a fazer';
|
|
5016
|
-
RETURN;
|
|
5017
|
-
END IF;
|
|
5018
|
-
|
|
5019
|
-
FOREACH t IN ARRAY ARRAY['plg_conversation_messages', 'plg_conversations'] LOOP
|
|
5020
|
-
CONTINUE WHEN to_regclass('public.' || t) IS NULL;
|
|
5021
|
-
CONTINUE WHEN EXISTS (
|
|
5022
|
-
SELECT 1 FROM pg_publication_tables
|
|
5023
|
-
WHERE pubname = 'supabase_realtime' AND schemaname = 'public' AND tablename = t);
|
|
5024
|
-
EXECUTE format('ALTER PUBLICATION supabase_realtime ADD TABLE public.%I', t);
|
|
5025
|
-
v_n := v_n + 1;
|
|
5026
|
-
END LOOP;
|
|
5027
|
-
|
|
5028
|
-
RAISE NOTICE 'conversations/006: % tabela(s) publicada(s) no realtime', v_n;
|
|
5029
|
-
END $$;
|
|
5030
|
-
|
|
5031
|
-
-- \`REPLICA IDENTITY FULL\` na conversa: sem ela o UPDATE chega com a chave
|
|
5032
|
-
-- prim\xE1ria e mais nada, e a tela n\xE3o consegue saber que \`unread_count\` mudou \u2014
|
|
5033
|
-
-- que \xE9 exatamente o UPDATE que interessa aqui. Na tabela de mensagens n\xE3o \xE9
|
|
5034
|
-
-- preciso: o que importa \xE9 o INSERT, e esse vem inteiro de qualquer forma.
|
|
5035
|
-
DO $$
|
|
5036
|
-
BEGIN
|
|
5037
|
-
IF to_regclass('public.plg_conversations') IS NOT NULL THEN
|
|
5038
|
-
ALTER TABLE public.plg_conversations REPLICA IDENTITY FULL;
|
|
5039
|
-
END IF;
|
|
5040
|
-
END $$;
|
|
5041
|
-
|
|
5042
|
-
-- \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
|
|
5043
|
-
--
|
|
5044
|
-
-- \`unread_count\` estava em ZERO nas oito conversas do cluster, incluindo as
|
|
5045
|
-
-- quatro cuja \xFAltima mensagem \xE9 de entrada. N\xE3o era um zero verdadeiro: n\xE3o h\xE1
|
|
5046
|
-
-- (n\xE3o havia) nada que a incremente. A coluna existe desde a baseline, a tela
|
|
5047
|
-
-- da lista decide o destaque por ela (\`unreadCount > 0\`) e a RPC de marcar como
|
|
5048
|
-
-- lida zera-a \u2014 s\xF3 faltava o lado que soma.
|
|
5049
|
-
--
|
|
5050
|
-
-- Por isso \`isWaitingOnUs\` recorre a \`lastMessageDirection\` quando a conta \xE9
|
|
5051
|
-
-- zero: foi escrita j\xE1 sabendo que a conta n\xE3o era de fiar. Esta migration
|
|
5052
|
-
-- torna-a de fiar, e a recorr\xEAncia volta a ser o que devia ser \u2014 uma rede, e
|
|
5053
|
-
-- n\xE3o o caminho normal.
|
|
5054
|
-
--
|
|
5055
|
-
-- S\xF3 o que CHEGA conta. Somar o que n\xF3s enviamos faria a caixa dizer que h\xE1
|
|
5056
|
-
-- doze por ler logo depois de algu\xE9m responder doze vezes.
|
|
5057
|
-
CREATE OR REPLACE FUNCTION public.plg_conversations_bump_unread() RETURNS trigger
|
|
5058
|
-
LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
|
|
5059
|
-
AS $$
|
|
5060
|
-
BEGIN
|
|
5061
|
-
IF NEW.direction IS DISTINCT FROM 'inbound' THEN RETURN NULL; END IF;
|
|
5062
|
-
UPDATE public.plg_conversations
|
|
5063
|
-
SET unread_count = coalesce(unread_count, 0) + 1
|
|
5064
|
-
WHERE id = NEW.conversation_id;
|
|
5065
|
-
RETURN NULL;
|
|
5066
|
-
END $$;
|
|
5067
|
-
|
|
5068
|
-
REVOKE ALL ON FUNCTION public.plg_conversations_bump_unread() FROM PUBLIC;
|
|
5069
|
-
GRANT EXECUTE ON FUNCTION public.plg_conversations_bump_unread() TO authenticated, service_role;
|
|
5070
|
-
|
|
5071
|
-
COMMENT ON FUNCTION public.plg_conversations_bump_unread() IS
|
|
5072
|
-
'Soma uma n\xE3o lida por mensagem que CHEGA. A coluna existia e era zerada ao ler, mas nada a incrementava (006).';
|
|
5073
|
-
|
|
5074
|
-
-- AFTER, como o irm\xE3o que carimba a dire\xE7\xE3o: falhar a contar n\xE3o pode recusar
|
|
5075
|
-
-- uma mensagem que o provedor j\xE1 entregou.
|
|
5076
|
-
DROP TRIGGER IF EXISTS plg_conversation_messages_bump_unread ON public.plg_conversation_messages;
|
|
5077
|
-
CREATE TRIGGER plg_conversation_messages_bump_unread
|
|
5078
|
-
AFTER INSERT ON public.plg_conversation_messages
|
|
5079
|
-
FOR EACH ROW EXECUTE FUNCTION public.plg_conversations_bump_unread();
|
|
5080
|
-
|
|
5081
|
-
-- O retroativo, e o que ele pode e n\xE3o pode saber.
|
|
5082
|
-
--
|
|
5083
|
-
-- N\xE3o h\xE1 coluna de "lido at\xE9 quando" nesta tabela \u2014 \`markRead\` zera a conta e
|
|
5084
|
-
-- mais nada. Ent\xE3o n\xE3o d\xE1 para reconstituir quantas mensagens ficaram por ler:
|
|
5085
|
-
-- o que d\xE1 para saber \xE9 que a conversa EST\xC1 \xE0 espera, porque a \xFAltima mensagem
|
|
5086
|
-
-- dela \xE9 de entrada.
|
|
5087
|
-
--
|
|
5088
|
-
-- Nesse caso a conta vira 1. Um n\xFAmero inventado seria pior que um n\xFAmero
|
|
5089
|
-
-- modesto: 1 diz "h\xE1 algo aqui", que \xE9 verdade e \xE9 o que faz a linha destacar;
|
|
5090
|
-
-- dizer "7" alegaria uma precis\xE3o que esta tabela nunca guardou. A partir de
|
|
5091
|
-
-- agora o gatilho conta de verdade.
|
|
5092
|
-
UPDATE public.plg_conversations
|
|
5093
|
-
SET unread_count = 1
|
|
5094
|
-
WHERE last_message_direction = 'inbound'
|
|
5095
|
-
AND status = 'open'
|
|
5096
|
-
AND coalesce(unread_count, 0) = 0;
|
|
5097
|
-
`;
|
|
5098
|
-
var MIGRATION_007_A_CONVERSA_DIZ_DE_QUEM_E_O_LEAD = `-- ---------------------------------------------------------------------------
|
|
5099
|
-
-- 007_a_conversa_diz_de_quem_e_o_lead.sql
|
|
5100
|
-
--
|
|
5101
|
-
-- \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
|
|
5102
|
-
--
|
|
5103
|
-
-- A caixa j\xE1 mostra um "Respons\xE1vel", e ele \xE9 \`plg_conversations.assigned_to\`:
|
|
5104
|
-
-- quem est\xE1 ATENDENDO aquela conversa. N\xE3o \xE9 o dono do lead.
|
|
5105
|
-
--
|
|
5106
|
-
-- Os dois convivem e precisam conviver: a Carolina \xE9 dona do lead, e quem
|
|
5107
|
-
-- responde a mensagem que chegou \xE0s 2h da manh\xE3 pode ser outra pessoa. Colapsar
|
|
5108
|
-
-- num s\xF3 campo faria uma das duas perguntas ficar sem resposta \u2014 e a que
|
|
5109
|
-
-- desapareceria \xE9 a do funil, que \xE9 a que decide quem faz o follow-up.
|
|
5110
|
-
--
|
|
5111
|
-
-- Esta view acrescenta a SEGUNDA, com nome pr\xF3prio (\`lead_owner_*\`), sem tocar
|
|
5112
|
-
-- na primeira.
|
|
5113
|
-
--
|
|
5114
|
-
-- \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
|
|
5115
|
-
--
|
|
5116
|
-
-- O dono do lead muda \u2014 algu\xE9m clica "Assumir" no funil e a carteira troca de
|
|
5117
|
-
-- m\xE3os. Uma coluna copiada aqui seria uma segunda verdade que envelhece em
|
|
5118
|
-
-- sil\xEAncio: a conversa diria Carolina depois de o lead j\xE1 ser do Julio, e
|
|
5119
|
-
-- ningu\xE9m saberia dizer qual das duas telas est\xE1 certa.
|
|
5120
|
-
--
|
|
5121
|
-
-- \xC9 exatamente o problema que a 047 acabou de resolver do outro lado, onde o
|
|
5122
|
-
-- dono estava escrito em dois lugares que n\xE3o se falavam. Derivar \xE9 o que
|
|
5123
|
-
-- impede a repeti\xE7\xE3o.
|
|
5124
|
-
--
|
|
5125
|
-
-- \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
|
|
5126
|
-
--
|
|
5127
|
-
-- \`v_leads.assigned_to_id\` j\xE1 resolve a preced\xEAncia entre o que a PESSOA
|
|
5128
|
-
-- declara (\`metadata.assignedToId\`) e o que o NEG\xD3CIO diz (047). Ir direto ao
|
|
5129
|
-
-- neg\xF3cio aqui reimplementaria essa regra, e as duas divergiriam no dia em que
|
|
5130
|
-
-- uma mudasse.
|
|
5131
|
-
--
|
|
5132
|
-
-- \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
|
|
5133
|
-
--
|
|
5134
|
-
-- \`plugin-conversations\` roda em casa sem funil nenhum. \`v_leads\` \xE9 da espinha
|
|
5135
|
-
-- (n\xE3o do CRM), ent\xE3o a depend\xEAncia \xE9 leg\xEDtima \u2014 mas a jun\xE7\xE3o \xE9 LEFT: uma
|
|
5136
|
-
-- conversa de quem n\xE3o \xE9 lead continua aparecendo, com o dono nulo.
|
|
5137
|
-
-- ---------------------------------------------------------------------------
|
|
5138
|
-
|
|
5139
|
-
CREATE OR REPLACE VIEW public.v_conversations WITH (security_invoker = true) AS
|
|
5140
|
-
SELECT c.*,
|
|
5141
|
-
l.assigned_to_id AS lead_owner_id,
|
|
5142
|
-
p.name AS lead_owner_name
|
|
5143
|
-
FROM public.plg_conversations c
|
|
5144
|
-
-- Pelo contato: \xE9 \`contact_person_id\` que liga a conversa a uma pessoa da
|
|
5145
|
-
-- base. Nulo enquanto ningu\xE9m casou o telefone com algu\xE9m, e a\xED n\xE3o h\xE1
|
|
5146
|
-
-- lead a quem perguntar o dono.
|
|
5147
|
-
LEFT JOIN public.v_leads l
|
|
5148
|
-
ON l.id = c.contact_person_id
|
|
5149
|
-
-- O NOME do dono, que \xE9 o que a tela mostra. O id sozinho obrigaria cada
|
|
5150
|
-
-- tela a resolver o mesmo de-para \u2014 foi o que fez o filtro de leads exibir
|
|
5151
|
-
-- UUID em produ\xE7\xE3o por um dia.
|
|
5152
|
-
LEFT JOIN public.v_team_members m
|
|
5153
|
-
ON m.user_id::text = l.assigned_to_id AND m.tenant_id = c.tenant_id
|
|
5154
|
-
LEFT JOIN public.people p
|
|
5155
|
-
ON p.id = m.person_id;
|
|
5156
|
-
|
|
5157
|
-
COMMENT ON VIEW public.v_conversations IS
|
|
5158
|
-
'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).';
|
|
5159
|
-
`;
|
|
5160
|
-
var MIGRATION_008_DAR_POR_VISTA_E_UMA_DECISAO = `-- ---------------------------------------------------------------------------
|
|
5161
|
-
-- 008_dar_por_vista_e_uma_decisao.sql
|
|
5162
|
-
--
|
|
5163
|
-
-- \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
|
|
5164
|
-
--
|
|
5165
|
-
-- A bolinha vermelha da caixa n\xE3o \xE9 "n\xE3o lido": \xE9 \`isWaitingOnUs\`, que olha a
|
|
5166
|
-
-- DIRE\xC7\xC3O da \xFAltima mensagem. Se a \xFAltima \xE9 de entrada, a conversa espera
|
|
5167
|
-
-- resposta \u2014 e continua esperando por mais que algu\xE9m abra e leia.
|
|
5168
|
-
--
|
|
5169
|
-
-- Isso est\xE1 certo enquanto a pergunta \xE9 "o que falta responder". Mas deixa sem
|
|
5170
|
-
-- sa\xEDda o caso comum: a pessoa leu, decidiu que n\xE3o cabe resposta ("ok",
|
|
5171
|
-
-- "obrigado", um \xE1udio que j\xE1 foi tratado por telefone) e n\xE3o tem como tirar a
|
|
5172
|
-
-- conversa da fila. \`unread_count = 0\` n\xE3o resolve, porque n\xE3o \xE9 ele que
|
|
5173
|
-
-- acende a bolinha.
|
|
5174
|
-
--
|
|
5175
|
-
-- \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
|
|
5176
|
-
--
|
|
5177
|
-
-- \`lida boolean\` exigiria algu\xE9m lembrar de apag\xE1-la quando chegasse mensagem
|
|
5178
|
-
-- nova \u2014 e o dia em que esquecessem, a conversa sumiria da fila com o cliente
|
|
5179
|
-
-- esperando. \xC9 o pior defeito poss\xEDvel numa caixa de entrada: silencioso, e do
|
|
5180
|
-
-- lado de perder trabalho.
|
|
5181
|
-
--
|
|
5182
|
-
-- Um INSTANTE se compara com \`last_message_at\` e se invalida sozinho: deu por
|
|
5183
|
-
-- vista \xE0s 14h, chegou mensagem \xE0s 15h, a conversa volta \xE0 fila sem ningu\xE9m
|
|
5184
|
-
-- fazer nada. A regra \xE9 uma compara\xE7\xE3o, n\xE3o um estado a manter.
|
|
5185
|
-
--
|
|
5186
|
-
-- \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
|
|
5187
|
-
--
|
|
5188
|
-
-- \`unread_count\` continua onde est\xE1. Ele conta mensagens e serve ao contador
|
|
5189
|
-
-- do menu; esta coluna responde outra pergunta ("algu\xE9m j\xE1 decidiu sobre
|
|
5190
|
-
-- isto?"). Colapsar as duas faria a contagem do menu mentir no dia em que
|
|
5191
|
-
-- algu\xE9m desse por vista sem ler.
|
|
5192
|
-
--
|
|
5193
|
-
-- Idempotente e replay-safe.
|
|
5194
|
-
-- ---------------------------------------------------------------------------
|
|
5195
|
-
|
|
5196
|
-
ALTER TABLE public.plg_conversations
|
|
5197
|
-
ADD COLUMN IF NOT EXISTS acknowledged_at timestamptz;
|
|
5198
|
-
|
|
5199
|
-
COMMENT ON COLUMN public.plg_conversations.acknowledged_at IS
|
|
5200
|
-
'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).';
|
|
5201
|
-
|
|
5202
|
-
-- A fila \xE9 lida por \`last_message_at\` e por esta coluna juntas.
|
|
5203
|
-
CREATE INDEX IF NOT EXISTS plg_conversations_fila_idx
|
|
5204
|
-
ON public.plg_conversations (tenant_id, status, last_message_at DESC)
|
|
5205
|
-
WHERE status = 'open';
|
|
5206
|
-
`;
|
|
5207
|
-
var MIGRATION_009_A_MENSAGEM_INTEIRA_ATRAVESSA_O_REALTIME = `-- ---------------------------------------------------------------------------
|
|
5208
|
-
-- 009_a_mensagem_inteira_atravessa_o_realtime.sql
|
|
5209
|
-
--
|
|
5210
|
-
-- \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
|
|
5211
|
-
--
|
|
5212
|
-
-- O vigia da caixa assinava \`plg_conversation_messages\`, recebia \`SUBSCRIBED\`
|
|
5213
|
-
-- do servidor, e nunca recebia um \xFAnico evento. Nenhum erro, nenhum aviso: a
|
|
5214
|
-
-- assinatura dizia que estava tudo bem e n\xE3o chegava nada.
|
|
5215
|
-
--
|
|
5216
|
-
-- A causa \xE9 a combina\xE7\xE3o de duas coisas que, sozinhas, est\xE3o certas:
|
|
5217
|
-
--
|
|
5218
|
-
-- 1. a tabela tem RLS ligada, como toda tabela desta base;
|
|
5219
|
-
-- 2. a tabela estava com \`REPLICA IDENTITY\` no padr\xE3o \u2014 s\xF3 a chave prim\xE1ria.
|
|
5220
|
-
--
|
|
5221
|
-
-- O Realtime do Supabase aplica a POL\xCDTICA DE LEITURA do usu\xE1rio a cada evento
|
|
5222
|
-
-- antes de entreg\xE1-lo. Para isso ele precisa da linha inteira: a pol\xEDtica fala
|
|
5223
|
-
-- de \`tenant_id\`, e \`tenant_id\` n\xE3o \xE9 a chave prim\xE1ria. Com s\xF3 a PK no WAL, n\xE3o
|
|
5224
|
-
-- h\xE1 o que avaliar \u2014 e o evento \xE9 descartado sem ru\xEDdo.
|
|
5225
|
-
--
|
|
5226
|
-
-- \`plg_conversations\` j\xE1 estava \`FULL\` (006). A tabela de MENSAGENS ficou de
|
|
5227
|
-
-- fora daquela migration, e \xE9 justamente ela que o vigia escuta.
|
|
5228
|
-
--
|
|
5229
|
-
-- \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
|
|
5230
|
-
--
|
|
5231
|
-
-- \`FULL\` faz o WAL carregar a linha inteira em cada UPDATE e DELETE, n\xE3o s\xF3 as
|
|
5232
|
-
-- colunas mudadas. Numa tabela de mensagens isso \xE9 peso real.
|
|
5233
|
-
--
|
|
5234
|
-
-- A alternativa \u2014 desligar a RLS para o Realtime enxergar \u2014 trocaria um custo
|
|
5235
|
-
-- de disco por um vazamento: as mensagens de uma casa chegariam ao navegador
|
|
5236
|
-
-- de outra. Entre pagar WAL e vazar conversa, paga-se o WAL.
|
|
5237
|
-
--
|
|
5238
|
-
-- Idempotente e replay-safe.
|
|
5239
|
-
-- ---------------------------------------------------------------------------
|
|
5240
|
-
|
|
5241
|
-
ALTER TABLE public.plg_conversation_messages REPLICA IDENTITY FULL;
|
|
5242
|
-
|
|
5243
|
-
-- Garante a publica\xE7\xE3o tamb\xE9m \u2014 uma tabela com identidade cheia e fora da
|
|
5244
|
-
-- publica\xE7\xE3o continua sem emitir nada, e o sintoma seria id\xEAntico.
|
|
5245
|
-
DO $$
|
|
5246
|
-
BEGIN
|
|
5247
|
-
IF NOT EXISTS (
|
|
5248
|
-
SELECT 1 FROM pg_publication_tables
|
|
5249
|
-
WHERE pubname = 'supabase_realtime'
|
|
5250
|
-
AND schemaname = 'public'
|
|
5251
|
-
AND tablename = 'plg_conversation_messages'
|
|
5252
|
-
) THEN
|
|
5253
|
-
EXECUTE 'ALTER PUBLICATION supabase_realtime ADD TABLE public.plg_conversation_messages';
|
|
5254
|
-
END IF;
|
|
5255
|
-
END $$;
|
|
5256
|
-
`;
|
|
5257
|
-
var MIGRATION_010_A_CHEGADA_E_ANUNCIADA_POR_CONTA = `-- ---------------------------------------------------------------------------
|
|
5258
|
-
-- 010_a_chegada_e_anunciada_por_conta.sql
|
|
5259
|
-
--
|
|
5260
|
-
-- \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
|
|
5261
|
-
--
|
|
5262
|
-
-- O vigia assinava a TABELA de mensagens e recebia \`SUBSCRIBED\` do servidor.
|
|
5263
|
-
-- Nenhum evento chegava. Nenhum erro, nenhum aviso \u2014 a assinatura dizia que
|
|
5264
|
-
-- estava tudo bem.
|
|
5265
|
-
--
|
|
5266
|
-
-- O Realtime aplica a pol\xEDtica de leitura a cada evento antes de entreg\xE1-lo. A
|
|
5267
|
-
-- pol\xEDtica desta base resolve o tenant por, nesta ordem:
|
|
5268
|
-
--
|
|
5269
|
-
-- \u2460 a claim \`tenant_id\` do JWT
|
|
5270
|
-
-- \u2461 o CABE\xC7ALHO da requisi\xE7\xE3o (\`app.requested_tenant()\`)
|
|
5271
|
-
-- \u2462 a filia\xE7\xE3o \xFAnica, quando a pessoa s\xF3 tem uma
|
|
5272
|
-
--
|
|
5273
|
-
-- O Realtime manda o JWT e N\xC3O manda cabe\xE7alho. E o JWT desta casa n\xE3o carrega
|
|
5274
|
-
-- \`tenant_id\` \u2014 a conta ativa \xE9 escolhida por cabe\xE7alho, porque a mesma pessoa
|
|
5275
|
-
-- atende sete contas. Sobra \u2462, que devolve nulo para quem tem mais de uma.
|
|
5276
|
-
--
|
|
5277
|
-
-- Resultado: a pol\xEDtica recusa, o evento \xE9 descartado em sil\xEAncio, e o defeito
|
|
5278
|
-
-- parece "o realtime n\xE3o funciona" quando na verdade \xE9 "o realtime n\xE3o sabe em
|
|
5279
|
-
-- que conta voc\xEA est\xE1".
|
|
5280
|
-
--
|
|
5281
|
-
-- \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
|
|
5282
|
-
--
|
|
5283
|
-
-- Broadcast n\xE3o passa pela RLS da tabela: o servidor entrega a quem est\xE1 no
|
|
5284
|
-
-- T\xD3PICO. Ent\xE3o o t\xF3pico carrega a conta no nome \u2014 \`conversas:<tenant>\` \u2014 e
|
|
5285
|
-
-- quem escuta j\xE1 teve de saber em que conta est\xE1 para montar o nome.
|
|
5286
|
-
--
|
|
5287
|
-
-- Isso inverte a depend\xEAncia de forma saud\xE1vel: em vez de o banco adivinhar o
|
|
5288
|
-
-- contexto do navegador, o navegador declara o contexto que j\xE1 tem.
|
|
5289
|
-
--
|
|
5290
|
-
-- \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
|
|
5291
|
-
--
|
|
5292
|
-
-- O M\xCDNIMO para decidir: id da conversa, canal, e um trecho. N\xE3o a mensagem
|
|
5293
|
-
-- inteira \u2014 um broadcast \xE9 entregue a todos do t\xF3pico, e mandar o corpo
|
|
5294
|
-
-- completo por ali seria espalhar conversa por um caminho que n\xE3o \xE9 o que
|
|
5295
|
-
-- guarda conversa. Quem quiser o texto l\xEA a tabela, com RLS, como sempre.
|
|
5296
|
-
--
|
|
5297
|
-
-- Idempotente e replay-safe.
|
|
5298
|
-
-- ---------------------------------------------------------------------------
|
|
5299
|
-
|
|
5300
|
-
CREATE OR REPLACE FUNCTION public.plg_conversations_anunciar_chegada()
|
|
5301
|
-
RETURNS trigger
|
|
5302
|
-
LANGUAGE plpgsql
|
|
5303
|
-
SECURITY DEFINER
|
|
5304
|
-
SET search_path = ''
|
|
5305
|
-
AS $$
|
|
5306
|
-
DECLARE
|
|
5307
|
-
v_nome text;
|
|
5308
|
-
BEGIN
|
|
5309
|
-
-- S\xF3 o que CHEGA. A resposta que o operador manda tamb\xE9m \xE9 um INSERT, e
|
|
5310
|
-
-- anunci\xE1-la faria o sistema apitar para quem acabou de digitar.
|
|
5311
|
-
IF NEW.direction IS DISTINCT FROM 'inbound' THEN
|
|
5312
|
-
RETURN NEW;
|
|
5313
|
-
END IF;
|
|
5314
|
-
|
|
5315
|
-
SELECT c.contact_name INTO v_nome
|
|
5316
|
-
FROM public.plg_conversations c
|
|
5317
|
-
WHERE c.id = NEW.conversation_id;
|
|
5318
|
-
|
|
5319
|
-
PERFORM realtime.send(
|
|
5320
|
-
jsonb_build_object(
|
|
5321
|
-
'conversationId', NEW.conversation_id,
|
|
5322
|
-
'channel', NEW.channel,
|
|
5323
|
-
'contactName', coalesce(v_nome, ''),
|
|
5324
|
-
-- Um trecho, n\xE3o o corpo: o suficiente para a notifica\xE7\xE3o do sistema
|
|
5325
|
-
-- dizer do que se trata sem espalhar a conversa pelo broadcast.
|
|
5326
|
-
'preview', left(coalesce(NEW.body, ''), 120),
|
|
5327
|
-
'at', NEW.at
|
|
5328
|
-
),
|
|
5329
|
-
'inbound',
|
|
5330
|
-
'conversas:' || NEW.tenant_id::text,
|
|
5331
|
-
-- Privado: exige que quem assina esteja autenticado. P\xFAblico deixaria
|
|
5332
|
-
-- qualquer um com o nome do t\xF3pico ouvir a chegada.
|
|
5333
|
-
true
|
|
5334
|
-
);
|
|
5335
|
-
RETURN NEW;
|
|
5336
|
-
END $$;
|
|
5337
|
-
|
|
5338
|
-
REVOKE ALL ON FUNCTION public.plg_conversations_anunciar_chegada() FROM public, anon, authenticated;
|
|
5339
|
-
|
|
5340
|
-
DROP TRIGGER IF EXISTS plg_conversations_anunciar_chegada ON public.plg_conversation_messages;
|
|
5341
|
-
CREATE TRIGGER plg_conversations_anunciar_chegada
|
|
5342
|
-
AFTER INSERT ON public.plg_conversation_messages
|
|
5343
|
-
FOR EACH ROW EXECUTE FUNCTION public.plg_conversations_anunciar_chegada();
|
|
5344
|
-
|
|
5345
|
-
COMMENT ON FUNCTION public.plg_conversations_anunciar_chegada() IS
|
|
5346
|
-
'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).';
|
|
5347
|
-
`;
|
|
5348
|
-
var MIGRATION_011_O_ANUNCIO_E_UMA_CAMPAINHA_NAO_UM_RECADO = `-- ---------------------------------------------------------------------------
|
|
5349
|
-
-- 011_o_anuncio_e_uma_campainha_nao_um_recado.sql
|
|
5350
|
-
--
|
|
5351
|
-
-- \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
|
|
5352
|
-
--
|
|
5353
|
-
-- A 010 anunciava num t\xF3pico PRIVADO e mandava, no an\xFAncio, o nome do contato e
|
|
5354
|
-
-- um trecho da mensagem. T\xF3pico privado exige pol\xEDtica em \`realtime.messages\` \u2014
|
|
5355
|
-
-- e essa tabela \xE9 do Supabase, dona \`supabase_realtime_admin\`, um papel que
|
|
5356
|
-
-- esta conex\xE3o n\xE3o pode assumir:
|
|
5357
|
-
--
|
|
5358
|
-
-- ERROR: 42501: permission denied to set role "supabase_realtime_admin"
|
|
5359
|
-
--
|
|
5360
|
-
-- Sem a pol\xEDtica, o servidor recusa a assinatura e nada chega. Com o t\xF3pico
|
|
5361
|
-
-- p\xFAblico, qualquer um que soubesse o nome do t\xF3pico leria o trecho da
|
|
5362
|
-
-- conversa \u2014 o que n\xE3o se faz.
|
|
5363
|
-
--
|
|
5364
|
-
-- \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
|
|
5365
|
-
--
|
|
5366
|
-
-- O an\xFAncio passa a ser uma campainha: diz que chegou algo e em que conversa,
|
|
5367
|
-
-- e mais nada. Nome e texto o navegador busca pela via de sempre \u2014 a tabela,
|
|
5368
|
-
-- com RLS, onde o cabe\xE7alho da conta existe e a pol\xEDtica funciona.
|
|
5369
|
-
--
|
|
5370
|
-
-- O t\xF3pico continua carregando o uuid da conta no nome, que \xE9 o que separa uma
|
|
5371
|
-
-- casa da outra. Ele n\xE3o \xE9 adivinh\xE1vel, e mesmo que fosse, o que se ouviria \xE9
|
|
5372
|
-
-- "chegou mensagem", sem nome nem texto. O sigilo fica onde sempre esteve: na
|
|
5373
|
-
-- leitura da tabela.
|
|
5374
|
-
--
|
|
5375
|
-
-- \xC9 uma troca deliberada de superf\xEDcie: em vez de tentar proteger um caminho
|
|
5376
|
-
-- novo, n\xE3o se p\xF5e nada sens\xEDvel nele.
|
|
5377
|
-
--
|
|
5378
|
-
-- Idempotente e replay-safe.
|
|
5379
|
-
-- ---------------------------------------------------------------------------
|
|
5380
|
-
|
|
5381
|
-
CREATE OR REPLACE FUNCTION public.plg_conversations_anunciar_chegada()
|
|
5382
|
-
RETURNS trigger
|
|
5383
|
-
LANGUAGE plpgsql
|
|
5384
|
-
SECURITY DEFINER
|
|
5385
|
-
SET search_path = ''
|
|
5386
|
-
AS $$
|
|
5387
|
-
BEGIN
|
|
5388
|
-
-- S\xF3 o que CHEGA. A resposta que o operador manda tamb\xE9m \xE9 um INSERT, e
|
|
5389
|
-
-- anunci\xE1-la faria o sistema apitar para quem acabou de digitar.
|
|
5390
|
-
IF NEW.direction IS DISTINCT FROM 'inbound' THEN
|
|
5391
|
-
RETURN NEW;
|
|
5392
|
-
END IF;
|
|
5393
|
-
|
|
5394
|
-
PERFORM realtime.send(
|
|
5395
|
-
-- S\xD3 o id da conversa. Nem nome, nem trecho, nem corpo: quem ouve vai
|
|
5396
|
-
-- buscar isso na tabela, com a pol\xEDtica de leitura no meio do caminho.
|
|
5397
|
-
-- Um uuid sozinho n\xE3o conta nada a quem n\xE3o pode ler a conversa.
|
|
5398
|
-
jsonb_build_object('conversationId', NEW.conversation_id),
|
|
5399
|
-
'inbound',
|
|
5400
|
-
'conversas:' || NEW.tenant_id::text,
|
|
5401
|
-
-- P\xDABLICO, e por isso o payload \xE9 vazio de conte\xFAdo. Privado exigiria uma
|
|
5402
|
-
-- pol\xEDtica numa tabela que n\xE3o \xE9 nossa.
|
|
5403
|
-
false
|
|
5404
|
-
);
|
|
5405
|
-
RETURN NEW;
|
|
5406
|
-
END $$;
|
|
5407
|
-
|
|
5408
|
-
COMMENT ON FUNCTION public.plg_conversations_anunciar_chegada() IS
|
|
5409
|
-
'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).';
|
|
5410
|
-
`;
|
|
5411
|
-
var MIGRATION_012_A_RICH_MESSAGE_KEEPS_ITS_SHAPE = `-- ---------------------------------------------------------------------------
|
|
5412
|
-
-- 012_a_rich_message_keeps_its_shape.sql
|
|
5413
|
-
--
|
|
5414
|
-
-- \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
|
|
5415
|
-
--
|
|
5416
|
-
-- \`plg_conversation_messages\` has exactly one content column: \`body text\`. A
|
|
5417
|
-
-- WhatsApp message that is not text therefore arrives flattened to whatever
|
|
5418
|
-
-- string the ingest could make of it \u2014 and when the ingest could make none, to
|
|
5419
|
-
-- the empty string, which renders as an empty bubble.
|
|
5420
|
-
--
|
|
5421
|
-
-- On 2026-09-22 a lead sent his finance director's contact card. It stored as
|
|
5422
|
-
-- \`body = ''\`. He then sent a screenshot of the same card; that stored as
|
|
5423
|
-
-- \`body = ''\` too. The fact the conversation existed to obtain \u2014 a name and a
|
|
5424
|
-
-- phone number \u2014 was in the payload both times and in the row neither time.
|
|
5425
|
-
--
|
|
5426
|
-
-- Recovering it meant going back to the Evolution instance and reading the
|
|
5427
|
-
-- provider's own copy. That worked, but it is not a recovery path: Evolution
|
|
5428
|
-
-- prunes, the instance is local-only, and a row that needs a second system to
|
|
5429
|
-
-- be legible is not a record.
|
|
5430
|
-
--
|
|
5431
|
-
-- \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
|
|
5432
|
-
--
|
|
5433
|
-
-- \`message_type\` names what the provider sent \u2014 text, contact, image, audio,
|
|
5434
|
-
-- document, sticker, location, reaction, unknown. The inbox needs it to choose
|
|
5435
|
-
-- a bubble, and \`unknown\` is the one that makes the NEXT gap visible in a query
|
|
5436
|
-
-- instead of in a complaint.
|
|
5437
|
-
--
|
|
5438
|
-
-- \`payload\` keeps the part that does not survive flattening: the vCard's parsed
|
|
5439
|
-
-- name and numbers, a media mimetype and size, a location's coordinates. Not
|
|
5440
|
-
-- the whole provider envelope \u2014 that carries decryption keys and a base64
|
|
5441
|
-
-- thumbnail, neither of which belongs in a row that every member of the tenant
|
|
5442
|
-
-- can read.
|
|
5443
|
-
--
|
|
5444
|
-
-- Deliberately NOT stored here: delivery status. It changes after the fact \u2014
|
|
5445
|
-
-- sent, delivered, read \u2014 and \`conversations-provider\`'s \`message_detail\` reads
|
|
5446
|
-
-- it live for that reason. \`payload\` is the shape of the message, which does
|
|
5447
|
-
-- not change once it arrives.
|
|
5448
|
-
--
|
|
5449
|
-
-- \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
|
|
5450
|
-
--
|
|
5451
|
-
-- Every row written before this migration has neither, and no default can
|
|
5452
|
-
-- invent them: \`message_type\` would have to guess, and guessing 'text' for the
|
|
5453
|
-
-- three empty bubbles already in the table would assert something false about
|
|
5454
|
-
-- exactly the rows this exists to fix. A reader that treats NULL as "written
|
|
5455
|
-
-- before 012" is correct; a reader that treats '' as text is the bug.
|
|
5456
|
-
--
|
|
5457
|
-
-- Table-level \`GRANT ALL ... TO authenticated\` already covers new columns, and
|
|
5458
|
-
-- the four tenant policies are row-scoped, so neither needs restating.
|
|
5459
|
-
--
|
|
5460
|
-
-- Idempotent and replay-safe.
|
|
5461
|
-
-- ---------------------------------------------------------------------------
|
|
5462
|
-
|
|
5463
|
-
ALTER TABLE public.plg_conversation_messages
|
|
5464
|
-
ADD COLUMN IF NOT EXISTS message_type text,
|
|
5465
|
-
ADD COLUMN IF NOT EXISTS payload jsonb;
|
|
5466
|
-
|
|
5467
|
-
COMMENT ON COLUMN public.plg_conversation_messages.message_type IS
|
|
5468
|
-
'What the provider sent: text, contact, image, video, audio, document, sticker, location, reaction, unknown. NULL means the row predates 012 (#504).';
|
|
5469
|
-
|
|
5470
|
-
COMMENT ON COLUMN public.plg_conversation_messages.payload IS
|
|
5471
|
-
'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).';
|
|
5472
|
-
`;
|
|
5473
|
-
var MIGRATION_013_A_MIDIA_FICA_CONOSCO = `-- ---------------------------------------------------------------------------
|
|
5474
|
-
-- 013_a_midia_fica_conosco.sql
|
|
5475
|
-
--
|
|
5476
|
-
-- \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
|
|
5477
|
-
--
|
|
5478
|
-
-- A 012 fez a mensagem que n\xE3o \xE9 texto parar de chegar como bolha vazia:
|
|
5479
|
-
-- \`message_type\` diz que \xE9 imagem, \`payload\` guarda mimetype, tamanho e
|
|
5480
|
-
-- dura\xE7\xE3o. Funcionou \u2014 um \xE1udio de 23 segundos entrou classificado como
|
|
5481
|
-
-- \`audio\`, \`ptt: true\`, \`audio/ogg; codecs=opus\`, 55.825 bytes.
|
|
5482
|
-
--
|
|
5483
|
-
-- E a tela mostra \`[\xE1udio]\`. Porque saber QUE existe um arquivo n\xE3o \xE9 ter o
|
|
5484
|
-
-- arquivo. Nada no banco aponta para bytes nenhuns: o WhatsApp entrega m\xEDdia
|
|
5485
|
-
-- cifrada num CDN pr\xF3prio, e quem decifra \xE9 o provedor.
|
|
5486
|
-
--
|
|
5487
|
-
-- \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
|
|
5488
|
-
--
|
|
5489
|
-
-- A 012 escreveu que \`payload\` \xE9 "a forma da mensagem, que n\xE3o muda depois que
|
|
5490
|
-
-- ela chega". Onde o arquivo foi parar \xE9 decidido DEPOIS \u2014 no upload, que pode
|
|
5491
|
-
-- falhar, ser refeito, ou ainda n\xE3o ter acontecido. Enfiar isso no \`payload\`
|
|
5492
|
-
-- contradiz a \xFAnica frase que diz quando \`payload\` pode ser lido sem medo.
|
|
5493
|
-
--
|
|
5494
|
-
-- Em coluna, a pergunta que importa vira consulta:
|
|
5495
|
-
--
|
|
5496
|
-
-- SELECT count(*) FROM plg_conversation_messages
|
|
5497
|
-
-- WHERE message_type IN ('image','video','audio','document','sticker')
|
|
5498
|
-
-- AND media_path IS NULL;
|
|
5499
|
-
--
|
|
5500
|
-
-- "quanta m\xEDdia eu recebi e n\xE3o guardei" \u2014 que num campo de JSON seria uma
|
|
5501
|
-
-- varredura e aqui \xE9 um \xEDndice.
|
|
5502
|
-
--
|
|
5503
|
-
-- \u2500\u2500 por que guardar, em vez de buscar no provedor quando algu\xE9m abrir \u2500\u2500\u2500
|
|
5504
|
-
--
|
|
5505
|
-
-- A Evolution decifra sob demanda, e seria mais barato n\xE3o copiar nada. Mas ela
|
|
5506
|
-
-- \xE9 local-only, poda o que \xE9 antigo, e a inst\xE2ncia roda numa m\xE1quina que nem
|
|
5507
|
-
-- sempre est\xE1 de p\xE9. Essa era exatamente a obje\xE7\xE3o da 012 contra depender dela
|
|
5508
|
-
-- para ler um vCard: uma linha que precisa de um segundo sistema para ser
|
|
5509
|
-
-- leg\xEDvel n\xE3o \xE9 registro. Vale igual para os bytes.
|
|
5510
|
-
--
|
|
5511
|
-
-- O destino \xE9 o balde \`attachments\` \u2014 privado, 25 MB, j\xE1 existente e j\xE1 em uso.
|
|
5512
|
-
-- Uma foto que um lead mandou no WhatsApp n\xE3o pode ser p\xFAblica, e um balde novo
|
|
5513
|
-
-- seria mais uma lista de mimetypes para algu\xE9m manter desatualizada.
|
|
5514
|
-
--
|
|
5515
|
-
-- \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
|
|
5516
|
-
--
|
|
5517
|
-
-- \`media_path\` \xE9 NULL em tr\xEAs situa\xE7\xF5es diferentes e todas normais: a mensagem
|
|
5518
|
-
-- \xE9 texto; a linha \xE9 anterior a esta migra\xE7\xE3o; ou o upload ainda n\xE3o aconteceu.
|
|
5519
|
-
-- Quem l\xEA distingue pela combina\xE7\xE3o com \`message_type\`, que \xE9 o que torna a
|
|
5520
|
-
-- consulta acima poss\xEDvel. Um default inventaria um caminho para arquivo que
|
|
5521
|
-
-- n\xE3o existe.
|
|
5522
|
-
--
|
|
5523
|
-
-- Idempotente e replay-safe.
|
|
5524
|
-
-- ---------------------------------------------------------------------------
|
|
5525
|
-
|
|
5526
|
-
ALTER TABLE public.plg_conversation_messages
|
|
5527
|
-
ADD COLUMN IF NOT EXISTS media_path text;
|
|
5528
|
-
|
|
5529
|
-
COMMENT ON COLUMN public.plg_conversation_messages.media_path IS
|
|
5530
|
-
'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).';
|
|
5531
|
-
|
|
5532
|
-
-- Parcial: s\xF3 a m\xEDdia sem arquivo interessa, e \xE9 a minoria. O \xEDndice fica do
|
|
5533
|
-
-- tamanho do problema em vez do tamanho da tabela.
|
|
5534
|
-
CREATE INDEX IF NOT EXISTS plg_conversation_messages_midia_sem_arquivo
|
|
5535
|
-
ON public.plg_conversation_messages (tenant_id, at DESC)
|
|
5536
|
-
WHERE media_path IS NULL
|
|
5537
|
-
AND message_type IN ('image', 'video', 'audio', 'document', 'sticker');
|
|
5538
|
-
`;
|
|
5539
|
-
var MIGRATION_014_CLAIMING_TARGETS_IS_SERVER_ONLY = `-- 014_claiming_targets_is_server_only.sql
|
|
5540
|
-
--
|
|
5541
|
-
-- 003 revoked \`conversations_claim_targets\` from PUBLIC and anon but not from
|
|
5542
|
-
-- \`authenticated\`, which the pool's default privileges grant on every new
|
|
5543
|
-
-- function in public. Live, any signed-in user of any tenant could claim (and
|
|
5544
|
-
-- so burn) another tenant's campaign targets by uuid \u2014 SECURITY DEFINER, no
|
|
5545
|
-
-- caller check. The only caller is the conversations-provider edge function,
|
|
5546
|
-
-- which uses the service-role key.
|
|
5547
|
-
|
|
5548
|
-
REVOKE ALL ON FUNCTION public.conversations_claim_targets(uuid, integer) FROM PUBLIC, anon, authenticated;
|
|
5549
|
-
GRANT EXECUTE ON FUNCTION public.conversations_claim_targets(uuid, integer) TO service_role;
|
|
5550
|
-
`;
|
|
5551
|
-
var MIGRATION_015_THE_SERVICE_DOOR_FOR_MESSAGING = `-- ============================================================================
|
|
5552
|
-
-- 015_the_service_door_for_messaging \u2014 the WhatsApp edge functions move to the
|
|
5553
|
-
-- integration service (ADR 0003 phase 2, wave 2).
|
|
5554
|
-
--
|
|
5555
|
-
-- Two kinds of table, two kinds of access:
|
|
5556
|
-
--
|
|
5557
|
-
-- * tenant tables a member already writes (conversations, messages,
|
|
5558
|
-
-- campaigns, templates): the service writes them AS the store's owner or
|
|
5559
|
-
-- as the verified caller (core migrations 210/212), under RLS. Nothing
|
|
5560
|
-
-- here.
|
|
5561
|
-
-- * service tables only a machine writes, which service_role wrote until
|
|
5562
|
-
-- now: provider credentials, channels, opt-outs, webhook events, provider
|
|
5563
|
-
-- templates, payment requests. fayz_integration gets exactly that, table
|
|
5564
|
-
-- by table, with an explicit policy. Panels keep their SELECT policies.
|
|
5565
|
-
--
|
|
5566
|
-
-- And the three server-only functions the webhooks call get EXECUTE for the
|
|
5567
|
-
-- service role (they were service_role only, 014).
|
|
5568
|
-
--
|
|
5569
|
-
-- Guarded: the role is an operator step. Replay-safe.
|
|
5570
|
-
-- ============================================================================
|
|
5571
|
-
|
|
5572
|
-
DO $$
|
|
5573
|
-
DECLARE
|
|
5574
|
-
t text;
|
|
5575
|
-
f text;
|
|
5576
|
-
BEGIN
|
|
5577
|
-
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fayz_integration') THEN
|
|
5578
|
-
RETURN;
|
|
5579
|
-
END IF;
|
|
5580
|
-
|
|
5581
|
-
FOREACH t IN ARRAY ARRAY[
|
|
5582
|
-
'plg_conversations_providers',
|
|
5583
|
-
'plg_conversations_channels',
|
|
5584
|
-
'plg_conversations_optouts',
|
|
5585
|
-
'plg_conversations_webhook_events',
|
|
5586
|
-
'plg_automations_provider_templates',
|
|
5587
|
-
'plg_conversations_payment_requests'
|
|
5588
|
-
] LOOP
|
|
5589
|
-
IF to_regclass('public.' || t) IS NULL THEN CONTINUE; END IF;
|
|
5590
|
-
EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON public.%I TO fayz_integration', t);
|
|
5591
|
-
EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I', t || '_service', t);
|
|
5592
|
-
EXECUTE format('CREATE POLICY %I ON public.%I FOR ALL TO fayz_integration USING (true) WITH CHECK (true)', t || '_service', t);
|
|
5593
|
-
END LOOP;
|
|
5594
|
-
|
|
5595
|
-
FOREACH f IN ARRAY ARRAY[
|
|
5596
|
-
'public.conversations_claim_targets(uuid, integer)',
|
|
5597
|
-
'public.plg_booking_apply_channel_reply(uuid, text, uuid)',
|
|
5598
|
-
'public.plg_conversations_match_by_phone(text, text, text, uuid)'
|
|
5599
|
-
] LOOP
|
|
5600
|
-
IF to_regprocedure(f) IS NOT NULL THEN
|
|
5601
|
-
EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO fayz_integration', f);
|
|
5602
|
-
END IF;
|
|
5603
|
-
END LOOP;
|
|
5604
|
-
END $$;
|
|
5605
|
-
`;
|
|
5606
|
-
var MIGRATION_016_A_DELIVERY_RECEIPT_FINDS_ITS_TENANT = `-- ============================================================================
|
|
5607
|
-
-- 016_a_delivery_receipt_finds_its_tenant \u2014 the Tyxter webhook moves to the
|
|
5608
|
-
-- integration service (ADR 0003 phase 2).
|
|
5609
|
-
--
|
|
5610
|
-
-- A delivery receipt names only the provider's message id. The service writes
|
|
5611
|
-
-- the inbox AS the store's owner (core 212), so it must learn the store first:
|
|
5612
|
-
-- this answers the tenant of the message that id was sent as, and nothing
|
|
5613
|
-
-- else. SECURITY DEFINER, service role only.
|
|
5614
|
-
--
|
|
5615
|
-
-- Replay-safe: CREATE OR REPLACE; guarded grants.
|
|
5616
|
-
-- ============================================================================
|
|
5617
|
-
|
|
5618
|
-
CREATE OR REPLACE FUNCTION public.conversations_message_tenant(p_provider_message_id text) RETURNS uuid
|
|
5619
|
-
LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO ''
|
|
5620
|
-
AS $function$
|
|
5621
|
-
SELECT m.tenant_id
|
|
5622
|
-
FROM public.plg_conversation_messages m
|
|
5623
|
-
WHERE m.provider_message_id = p_provider_message_id
|
|
5624
|
-
ORDER BY m.at DESC NULLS LAST
|
|
5625
|
-
LIMIT 1;
|
|
5626
|
-
$function$;
|
|
5627
|
-
|
|
5628
|
-
DO $$
|
|
5629
|
-
BEGIN
|
|
5630
|
-
REVOKE ALL ON FUNCTION public.conversations_message_tenant(text) FROM PUBLIC;
|
|
5631
|
-
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN REVOKE ALL ON FUNCTION public.conversations_message_tenant(text) FROM anon; END IF;
|
|
5632
|
-
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN REVOKE ALL ON FUNCTION public.conversations_message_tenant(text) FROM authenticated; END IF;
|
|
5633
|
-
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;
|
|
5634
|
-
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;
|
|
5635
|
-
END $$;
|
|
5636
|
-
`;
|
|
5637
|
-
var MIGRATIONS = [
|
|
5638
|
-
{ id: "000_baseline", sql: MIGRATION_000_BASELINE },
|
|
5639
|
-
{ id: "001_a_caixa_sabe_quem_falou_por_ultimo", sql: MIGRATION_001_A_CAIXA_SABE_QUEM_FALOU_POR_ULTIMO },
|
|
5640
|
-
{ id: "002_a_casa_escolhe_por_onde_o_whatsapp_sai", sql: MIGRATION_002_A_CASA_ESCOLHE_POR_ONDE_O_WHATSAPP_SAI },
|
|
5641
|
-
{ id: "003_a_campanha_sai_no_ritmo_que_o_numero_aguenta", sql: MIGRATION_003_A_CAMPANHA_SAI_NO_RITMO_QUE_O_NUMERO_AGUENTA },
|
|
5642
|
-
{ 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 },
|
|
5643
|
-
{ id: "005_a_conversa_conta_ao_funil_que_houve_contato", sql: MIGRATION_005_A_CONVERSA_CONTA_AO_FUNIL_QUE_HOUVE_CONTATO },
|
|
5644
|
-
{ id: "006_a_caixa_avisa_que_chegou", sql: MIGRATION_006_A_CAIXA_AVISA_QUE_CHEGOU },
|
|
5645
|
-
{ id: "007_a_conversa_diz_de_quem_e_o_lead", sql: MIGRATION_007_A_CONVERSA_DIZ_DE_QUEM_E_O_LEAD },
|
|
5646
|
-
{ id: "008_dar_por_vista_e_uma_decisao", sql: MIGRATION_008_DAR_POR_VISTA_E_UMA_DECISAO },
|
|
5647
|
-
{ id: "009_a_mensagem_inteira_atravessa_o_realtime", sql: MIGRATION_009_A_MENSAGEM_INTEIRA_ATRAVESSA_O_REALTIME },
|
|
5648
|
-
{ id: "010_a_chegada_e_anunciada_por_conta", sql: MIGRATION_010_A_CHEGADA_E_ANUNCIADA_POR_CONTA },
|
|
5649
|
-
{ id: "011_o_anuncio_e_uma_campainha_nao_um_recado", sql: MIGRATION_011_O_ANUNCIO_E_UMA_CAMPAINHA_NAO_UM_RECADO },
|
|
5650
|
-
{ id: "012_a_rich_message_keeps_its_shape", sql: MIGRATION_012_A_RICH_MESSAGE_KEEPS_ITS_SHAPE },
|
|
5651
|
-
{ id: "013_a_midia_fica_conosco", sql: MIGRATION_013_A_MIDIA_FICA_CONOSCO },
|
|
5652
|
-
{ id: "014_claiming_targets_is_server_only", sql: MIGRATION_014_CLAIMING_TARGETS_IS_SERVER_ONLY },
|
|
5653
|
-
{ id: "015_the_service_door_for_messaging", sql: MIGRATION_015_THE_SERVICE_DOOR_FOR_MESSAGING },
|
|
5654
|
-
{ id: "016_a_delivery_receipt_finds_its_tenant", sql: MIGRATION_016_A_DELIVERY_RECEIPT_FINDS_ITS_TENANT }
|
|
5655
|
-
];
|
|
5656
1601
|
var conversationsV1SettingsTabs = [
|
|
5657
1602
|
defineSettingsTab({
|
|
5658
1603
|
id: "conversations-atendeai",
|
|
@@ -5691,7 +1636,7 @@ var conversationsV1SettingsTabs = [
|
|
|
5691
1636
|
]
|
|
5692
1637
|
})
|
|
5693
1638
|
];
|
|
5694
|
-
function
|
|
1639
|
+
function client2() {
|
|
5695
1640
|
const c = getSupabaseClientOptional();
|
|
5696
1641
|
if (!c) throw new Error("Supabase n\xE3o inicializado");
|
|
5697
1642
|
return c;
|
|
@@ -5703,7 +1648,7 @@ function tenant() {
|
|
|
5703
1648
|
}
|
|
5704
1649
|
var campaignsApi = {
|
|
5705
1650
|
async templates() {
|
|
5706
|
-
const { data, error } = await
|
|
1651
|
+
const { data, error } = await client2().from("plg_conversations_templates").select("id, name, language, category, body, variables, provider, status, status_detail").eq("tenant_id", tenant()).order("name");
|
|
5707
1652
|
if (error) throw new Error(error.message);
|
|
5708
1653
|
return (data ?? []).map((r) => ({
|
|
5709
1654
|
id: r.id,
|
|
@@ -5727,12 +1672,12 @@ var campaignsApi = {
|
|
|
5727
1672
|
category: t.category ?? "MARKETING",
|
|
5728
1673
|
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
5729
1674
|
};
|
|
5730
|
-
const q = t.id ?
|
|
1675
|
+
const q = t.id ? client2().from("plg_conversations_templates").update(row).eq("id", t.id) : client2().from("plg_conversations_templates").insert(row);
|
|
5731
1676
|
const { error } = await q;
|
|
5732
1677
|
if (error) throw new Error(error.message);
|
|
5733
1678
|
},
|
|
5734
1679
|
async list() {
|
|
5735
|
-
const { data, error } = await
|
|
1680
|
+
const { data, error } = await client2().from("v_conversations_campaign_progress").select("*").eq("tenant_id", tenant());
|
|
5736
1681
|
if (error) throw new Error(error.message);
|
|
5737
1682
|
return (data ?? []).map((r) => ({
|
|
5738
1683
|
campaignId: r.campaign_id,
|
|
@@ -5761,7 +1706,7 @@ var campaignsApi = {
|
|
|
5761
1706
|
* cliques no botão enfileiram uma vez. */
|
|
5762
1707
|
async create(input) {
|
|
5763
1708
|
const tenantId = tenant();
|
|
5764
|
-
const { data: camp, error } = await
|
|
1709
|
+
const { data: camp, error } = await client2().from("plg_conversations_campaigns").insert({
|
|
5765
1710
|
tenant_id: tenantId,
|
|
5766
1711
|
name: input.name,
|
|
5767
1712
|
template_id: input.templateId ?? null,
|
|
@@ -5778,7 +1723,7 @@ var campaignsApi = {
|
|
|
5778
1723
|
variables: t.variables ?? {}
|
|
5779
1724
|
})).filter((r) => r.phone_e164.length >= 10);
|
|
5780
1725
|
for (let i = 0; i < rows.length; i += 200) {
|
|
5781
|
-
const { error: e } = await
|
|
1726
|
+
const { error: e } = await client2().from("plg_conversations_campaign_targets").upsert(rows.slice(i, i + 200), { onConflict: "campaign_id,phone_e164,rotation", ignoreDuplicates: true });
|
|
5782
1727
|
if (e) throw new Error(e.message);
|
|
5783
1728
|
}
|
|
5784
1729
|
return camp.id;
|
|
@@ -5789,13 +1734,13 @@ var campaignsApi = {
|
|
|
5789
1734
|
patch.started_at = (/* @__PURE__ */ new Date()).toISOString();
|
|
5790
1735
|
patch.paused_reason = null;
|
|
5791
1736
|
}
|
|
5792
|
-
const { error } = await
|
|
1737
|
+
const { error } = await client2().from("plg_conversations_campaigns").update(patch).eq("id", campaignId);
|
|
5793
1738
|
if (error) throw new Error(error.message);
|
|
5794
1739
|
},
|
|
5795
1740
|
/** Um lote. Quem chama repete até `remaining` chegar a zero — o arrendamento
|
|
5796
1741
|
* de cinco minutos é o que impede duas chamadas de mandarem o mesmo. */
|
|
5797
1742
|
async dispatch(campaignId, limit) {
|
|
5798
|
-
const { data, error } = await
|
|
1743
|
+
const { data, error } = await client2().functions.invoke("conversations-provider", {
|
|
5799
1744
|
body: { tenantId: tenant(), action: "dispatch", campaignId, limit }
|
|
5800
1745
|
});
|
|
5801
1746
|
if (error) {
|
|
@@ -5838,7 +1783,7 @@ function createConversationsPlugin(options) {
|
|
|
5838
1783
|
contactEntityDef: options?.contactEntityDef
|
|
5839
1784
|
};
|
|
5840
1785
|
const dashboardWidgets = createConversationsDashboardWidgets({ store: store2, config });
|
|
5841
|
-
const PageComponent = () =>
|
|
1786
|
+
const PageComponent = () => React__default.createElement(ConversationsPage, { store: store2, config });
|
|
5842
1787
|
PageComponent.displayName = "ConversationsPage";
|
|
5843
1788
|
return {
|
|
5844
1789
|
id: "conversations",
|
|
@@ -5855,11 +1800,11 @@ function createConversationsPlugin(options) {
|
|
|
5855
1800
|
declaredLimits: [
|
|
5856
1801
|
{ key: "conversations_month", label: "Conversations this month", table: "plg_conversations", period: "month" }
|
|
5857
1802
|
],
|
|
5858
|
-
//
|
|
5859
|
-
//
|
|
5860
|
-
//
|
|
5861
|
-
//
|
|
5862
|
-
//
|
|
1803
|
+
// `/conversations` has its rail line and its own screen. From 09-28 the
|
|
1804
|
+
// inbox lived as a header icon and from 10-15 in the right column beside
|
|
1805
|
+
// the assistant; the column squeezed the thread into a sliver and lost
|
|
1806
|
+
// what the full screen does. The unread count is written on this line by
|
|
1807
|
+
// the watcher below.
|
|
5863
1808
|
//
|
|
5864
1809
|
// E SEM CAMPANHAS NEM TEMPLATES. As duas telas foram removidas — não
|
|
5865
1810
|
// escondidas: elas tomavam duas linhas de rail num plugin que declara, na
|
|
@@ -5874,6 +1819,15 @@ function createConversationsPlugin(options) {
|
|
|
5874
1819
|
// AtendeAI does get a rail line, as in V1: whoever runs the AI follows it
|
|
5875
1820
|
// all day. Offered to every account — off, the page is the upsell.
|
|
5876
1821
|
navigation: [
|
|
1822
|
+
{
|
|
1823
|
+
id: "conversations",
|
|
1824
|
+
section: options?.navSection ?? "main",
|
|
1825
|
+
position: options?.navPosition ?? 1,
|
|
1826
|
+
label: options?.navLabel ?? "Conversations",
|
|
1827
|
+
route: "/conversations",
|
|
1828
|
+
icon: "MessageCircle",
|
|
1829
|
+
permission: { feature: "conversations", action: "read" }
|
|
1830
|
+
},
|
|
5877
1831
|
// AtendeAI é recurso de restaurante (ChefControl): só aparece no vertical
|
|
5878
1832
|
// 'food'. Uma loja de outro ramo não vê a linha.
|
|
5879
1833
|
{ section: "main", position: 45, label: "AtendeAI", route: "/atendeai", icon: "Bot", before: ["/analytics", "/reports"], verticalId: "food", permission: { feature: "conversations", action: "read" } }
|
|
@@ -5891,23 +1845,20 @@ function createConversationsPlugin(options) {
|
|
|
5891
1845
|
permission: { feature: "conversations", action: "read" }
|
|
5892
1846
|
}
|
|
5893
1847
|
],
|
|
5894
|
-
//
|
|
5895
|
-
//
|
|
5896
|
-
//
|
|
5897
|
-
// count survive navigation.
|
|
1848
|
+
// The watcher renders nothing; it is a header control because header
|
|
1849
|
+
// controls live outside the route switch, so the chime, the system
|
|
1850
|
+
// notification and the rail badge survive navigation.
|
|
5898
1851
|
headerControls: [{
|
|
5899
1852
|
id: "conversations-inbox",
|
|
5900
1853
|
component: (() => {
|
|
5901
|
-
const W = () =>
|
|
1854
|
+
const W = () => React__default.createElement(InboxWatcher, {
|
|
5902
1855
|
label: options?.navLabel ?? "Conversations",
|
|
5903
|
-
|
|
5904
|
-
|
|
5905
|
-
inHeader: true
|
|
1856
|
+
section: options?.navSection ?? "main",
|
|
1857
|
+
position: options?.navPosition ?? 1
|
|
5906
1858
|
});
|
|
5907
|
-
W.displayName = "
|
|
1859
|
+
W.displayName = "InboxWatcher";
|
|
5908
1860
|
return W;
|
|
5909
1861
|
})(),
|
|
5910
|
-
// After the cash drawer (90), right before the bell.
|
|
5911
1862
|
order: 95
|
|
5912
1863
|
}],
|
|
5913
1864
|
dashboardWidgets,
|
|
@@ -6065,13 +2016,17 @@ function createConversationsPlugin(options) {
|
|
|
6065
2016
|
}
|
|
6066
2017
|
],
|
|
6067
2018
|
onboarding: { program: buildConversationsOnboarding() },
|
|
6068
|
-
// WhatsApp through Tyxter
|
|
6069
|
-
//
|
|
6070
|
-
|
|
2019
|
+
// WhatsApp through Tyxter or through the store's own number (Evolution).
|
|
2020
|
+
// Declared here because this plugin IS the inbox the messages land in;
|
|
2021
|
+
// both show on Configurações → Conectores like every other connector.
|
|
2022
|
+
connectors: [tyxterConnectorDef, evolutionConnectorDef],
|
|
6071
2023
|
locales: conversationsLocales
|
|
6072
2024
|
};
|
|
6073
2025
|
}
|
|
2026
|
+
var ConversationsPage = React__default.lazy(() => import('./ConversationsPage-BTRYZPM7.js').then((m) => ({ default: m.ConversationsPage })));
|
|
2027
|
+
var AtendeAIPage = React__default.lazy(() => import('./AtendeAIPage-PBBMWIDW.js').then((m) => ({ default: m.AtendeAIPage })));
|
|
2028
|
+
var ConversationsSettingsTab = lazyComponent(() => import('./ConversationsSettings-Q3PGQMB3.js').then((m) => m.ConversationsSettingsTab));
|
|
6074
2029
|
|
|
6075
|
-
export { PAYMENT_PREFLIGHT_FUNCTION, TYXTER_CONNECTOR_ID, TYXTER_LATENCY_BUDGET_MS,
|
|
2030
|
+
export { PAYMENT_PREFLIGHT_FUNCTION, TYXTER_CONNECTOR_ID, TYXTER_LATENCY_BUDGET_MS, campaignsApi, canOfferPayment, createConversationsPlugin, createMockConversationsProvider, createSupabaseConversationsProvider, openPaymentSetupSession, readPaymentReadiness, tyxterConnectorDef };
|
|
6076
2031
|
//# sourceMappingURL=index.js.map
|
|
6077
2032
|
//# sourceMappingURL=index.js.map
|