@fayz-ai/plugin-conversations 0.23.14 → 0.23.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,1356 +1,36 @@
1
- import * as React14 from 'react';
2
- import React14__default, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
3
- import { Loader2, Check, ShieldCheck, ShieldAlert, RefreshCw, QrCode, Zap, ExternalLink, Bot, Sparkles, Settings2, MessageCircle, SquarePen, MessageSquare, Globe, Mail, Instagram, Phone, Search, User, Inbox, CheckCheck, ChevronLeft, Archive, PanelRight, Send, X, MapPin, Tag, StickyNote, Link2, CheckCircle2, MoreVertical, AlertTriangle, Clock, Snowflake, Sun, Flame, Building2, UserRound, Copy, FileText, Download } from 'lucide-react';
4
- import { createConnectionStore, connectionRuns, connectionStatus, tl, getActiveTenantId, getSupabaseClientOptional, useActiveTenantId, registerTranslations, describeError, useTranslation, usePluginRuntimeOptional, countByTenant, CONNECTOR_RUNTIME_TOKEN_HEADER, connectorRuntimeToken, formatCurrency, errorMessage } from '@fayz-ai/core';
5
- import { defineSettingsTab, SettingsGroup, PluginSettingsPanel, PRICE_BOOK, useRightRailStore, useAdminPath, useRightRailPanel, useWidgetSurface, adminNavigateTo, useConnectors, useConnectorsPanelAvailable, ConnectorMark, StateChip, openConnectorsPanel, ConnectorDetail, PageActions, useLimitGuard, ContactPicker, useRightRailMenu, requestNotificationPermission, invalidateLimit, playChime, notifySystem } from '@fayz-ai/admin';
6
- import { Input, Button, toast, defineKpiWidget, defineTableWidget, Card, Badge, ComingSoon, Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetBody, PageHeaderActions, cn, Modal, ModalContent, ModalHeader, ModalTitle, ModalBody, Textarea, ModalFooter, FilterChip, Skeleton, KpiCard, TableWidget } from '@fayz-ai/ui';
7
- import { useStore } from 'zustand';
8
- import { jsxs, Fragment, jsx } from 'react/jsx-runtime';
1
+ import { isWaitingOnUs, assinarChegada, ConversationsContextProvider, useChannelConnections, InboxView, useConversationsStore } from './chunk-LJKLHGFX.js';
2
+ export { isWaitingOnUs } from './chunk-LJKLHGFX.js';
3
+ import { T, listMessagingChannels, findDedicatedChannel, findFallbackChannel, isPendingChannelStatus, canClaimDedicated, CHANNEL_CLAIM_TIMELINE } from './chunk-3FZX3ZQ3.js';
4
+ export { WhatsAppProviders, listMessagingChannels } from './chunk-3FZX3ZQ3.js';
5
+ export { providersApi } from './chunk-HMWUU5O7.js';
6
+ import * as React2 from 'react';
7
+ import React2__default, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
8
+ import { Loader2, ExternalLink, MessageCircle, SquarePen, CheckCircle2 } from 'lucide-react';
9
+ import { createConnectionStore, connectionRuns, connectionStatus, tl, lazyComponent, useActiveTenantId, registerTranslations, getSupabaseClientOptional, describeError, getActiveTenantId, countByTenant, CONNECTOR_RUNTIME_TOKEN_HEADER, connectorRuntimeToken, useTranslation, navigateApp } from '@fayz-ai/core';
10
+ import { defineSettingsTab, useRightRailStore, useAdminPath, useRightRailPanel, useWidgetSurface, adminNavigateTo, playChime, notifySystem, useRightRailMenu, requestNotificationPermission } from '@fayz-ai/admin';
11
+ import { Input, Button, toast, defineKpiWidget, defineTableWidget, KpiCard, TableWidget } from '@fayz-ai/ui';
12
+ import { jsxs, jsx } from 'react/jsx-runtime';
9
13
  import { createStore } from 'zustand/vanilla';
14
+ import { MIGRATIONS } from '#migrations/main';
10
15
 
11
- // src/index.ts
12
- var DEFAULT_CONVERSATIONS_CONFIG = {
13
- contactKind: "contact"
14
- };
15
- var StoreContext = React14__default.createContext(null);
16
- var ConfigContext = React14__default.createContext(DEFAULT_CONVERSATIONS_CONFIG);
17
- function ConversationsContextProvider({
18
- store: store2,
19
- config = DEFAULT_CONVERSATIONS_CONFIG,
20
- children
21
- }) {
22
- return /* @__PURE__ */ jsx(StoreContext.Provider, { value: store2, children: /* @__PURE__ */ jsx(ConfigContext.Provider, { value: config, children }) });
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;
407
- };
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
- }
465
- return /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center gap-x-2 gap-y-1 bg-warning-soft px-4 py-2 text-[11px] text-warning-soft-foreground", children: [
466
- /* @__PURE__ */ jsx("span", { className: "h-1.5 w-1.5 shrink-0 rounded-full bg-warning" }),
467
- /* @__PURE__ */ jsx("span", { className: "font-medium", children: mode.reason === "never_replied" ? "Esta pessoa nunca respondeu." : "A janela de 24 horas fechou." }),
468
- /* @__PURE__ */ jsxs("span", { children: [
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`;
615
- }
616
- function duracao(seg) {
617
- if (!seg || seg <= 0) return "";
618
- const m = Math.floor(seg / 60);
619
- const s = Math.floor(seg % 60);
620
- return `${m}:${String(s).padStart(2, "0")}`;
621
- }
622
- function MediaBubble({ m, outbound }) {
623
- const ref = React14.useRef(null);
624
- const [visivel, setVisivel] = React14.useState(false);
625
- const { url, erro } = useUrlAssinada(m, visivel);
626
- React14.useEffect(() => {
627
- const el = ref.current;
628
- if (!el || typeof IntersectionObserver === "undefined") {
629
- setVisivel(true);
630
- return;
631
- }
632
- const obs = new IntersectionObserver((entradas) => {
633
- if (entradas.some((e) => e.isIntersecting)) {
634
- setVisivel(true);
635
- obs.disconnect();
636
- }
637
- }, { rootMargin: "200px" });
638
- obs.observe(el);
639
- return () => obs.disconnect();
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",
718
- {
719
- className: cn(
720
- "flex items-start gap-2.5 rounded-xl px-2.5 py-2",
721
- outbound ? "bg-white/15" : "bg-muted"
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
- ]
747
- },
748
- `${c.name}-${i}`
749
- )) });
750
- }
751
- function MessageBody({ m, outbound }) {
752
- const contacts = m.messageType === "contact" ? m.payload?.contacts ?? [] : [];
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);
1135
- }
1136
- }
1137
- return /* @__PURE__ */ jsx(Modal, { open, onOpenChange, children: /* @__PURE__ */ jsxs(ModalContent, { className: "max-w-md", children: [
1138
- /* @__PURE__ */ jsx(ModalHeader, { children: /* @__PURE__ */ jsx(ModalTitle, { children: t("conversations.new.title") }) }),
1139
- /* @__PURE__ */ jsxs("form", { onSubmit: handleSubmit, className: "flex min-h-0 flex-1 flex-col overflow-hidden", children: [
1140
- /* @__PURE__ */ jsxs(ModalBody, { className: "space-y-4", children: [
1141
- /* @__PURE__ */ jsxs("div", { children: [
1142
- /* @__PURE__ */ jsx("label", { className: "mb-1.5 block text-xs font-medium text-muted-foreground", children: t("conversations.new.channel") }),
1143
- /* @__PURE__ */ jsx("div", { className: "flex flex-wrap gap-1.5", children: CHANNELS.map((ch) => {
1144
- const Icon = CHANNEL_ICON[ch];
1145
- const active = channel === ch;
1146
- return /* @__PURE__ */ jsxs(
1147
- Button,
1148
- {
1149
- variant: "bare",
1150
- size: "bare",
1151
- type: "button",
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);
1240
- }
1241
- };
1242
- }
1243
-
1244
- // src/views/useCaixaAoVivo.ts
1245
- function useCaixaAoVivo() {
1246
- const tenantId = useActiveTenantId();
1247
- const load = useConversationsStore((s) => s.load);
1248
- const refreshMessages = useConversationsStore((s) => s.refreshMessages);
1249
- const selectedId = useConversationsStore((s) => s.selectedId);
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
16
  var CONNECTORS_ROUTE = "/settings/conversations/connectors";
1337
17
  var SPLIT_AT = 720;
1338
18
  function ConversationsRailPanel({ store: store2, config }) {
1339
19
  const t = useTranslation();
1340
- const ref = React14.useRef(null);
1341
- const [width, setWidth] = React14.useState(0);
1342
- const [newSignal, setNewSignal] = React14.useState(0);
20
+ const ref = React2.useRef(null);
21
+ const [width, setWidth] = React2.useState(0);
22
+ const [newSignal, setNewSignal] = React2.useState(0);
1343
23
  const connected = useChannelConnections();
1344
24
  const closeRail = useRightRailStore((s) => s.close);
1345
25
  const wide = width >= SPLIT_AT;
1346
- React14.useLayoutEffect(() => {
26
+ React2.useLayoutEffect(() => {
1347
27
  const el = ref.current;
1348
28
  if (!el) return;
1349
29
  const ro = new ResizeObserver(([e]) => setWidth(e.contentRect.width));
1350
30
  ro.observe(el);
1351
31
  return () => ro.disconnect();
1352
32
  }, []);
1353
- React14.useEffect(() => {
33
+ React2.useEffect(() => {
1354
34
  const narrow = (ref.current?.getBoundingClientRect().width ?? 0) < SPLIT_AT;
1355
35
  if (narrow) store2.getState().deselect();
1356
36
  void store2.getState().load({ autoSelect: !narrow });
@@ -1399,24 +79,14 @@ function ConversationsRailPanel({ store: store2, config }) {
1399
79
  function WhatsAppMark({ className }) {
1400
80
  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
81
  }
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"
1411
- };
1412
82
  var tocar = playChime;
1413
83
  var pedirPermissao = requestNotificationPermission;
1414
84
  var avisarNoSistema = (titulo, corpo, tag) => notifySystem(titulo, corpo, tag);
1415
85
  var RAIL_ID = "conversations";
1416
86
  function InboxWatcher({ label, route = "/conversations", store: store2, config, inHeader }) {
1417
- const Panel = React14.useMemo(() => {
87
+ const Panel = React2.useMemo(() => {
1418
88
  if (!store2) return null;
1419
- const C = () => React14.createElement(ConversationsRailPanel, { store: store2, config });
89
+ const C = () => React2.createElement(ConversationsRailPanel, { store: store2, config });
1420
90
  C.displayName = "ConversationsRailPanel";
1421
91
  return C;
1422
92
  }, [store2, config]);
@@ -1424,9 +94,9 @@ function InboxWatcher({ label, route = "/conversations", store: store2, config,
1424
94
  const togglePanel = useRightRailStore((s) => s.togglePanel);
1425
95
  const tenantId = useActiveTenantId();
1426
96
  const path = useAdminPath();
1427
- const [naoLidas, setNaoLidas] = React14.useState(0);
1428
- const podeTocar = React14.useRef(false);
1429
- React14.useEffect(() => {
97
+ const [naoLidas, setNaoLidas] = React2.useState(0);
98
+ const podeTocar = React2.useRef(false);
99
+ React2.useEffect(() => {
1430
100
  const liberar = () => {
1431
101
  podeTocar.current = true;
1432
102
  void pedirPermissao();
@@ -1438,7 +108,7 @@ function InboxWatcher({ label, route = "/conversations", store: store2, config,
1438
108
  for (const ev of ["pointerdown", "keydown", "touchstart"]) window.removeEventListener(ev, liberar);
1439
109
  };
1440
110
  }, []);
1441
- const contar = React14.useCallback(async () => {
111
+ const contar = React2.useCallback(async () => {
1442
112
  const supabase = getSupabaseClientOptional();
1443
113
  if (!supabase) return;
1444
114
  const { data } = await supabase.from(T.conversations).select("id, status, last_message_direction, unread_count").eq("status", "open");
@@ -1450,10 +120,10 @@ function InboxWatcher({ label, route = "/conversations", store: store2, config,
1450
120
  })).length;
1451
121
  setNaoLidas(n);
1452
122
  }, []);
1453
- React14.useEffect(() => {
123
+ React2.useEffect(() => {
1454
124
  void contar();
1455
125
  }, [contar]);
1456
- React14.useEffect(() => {
126
+ React2.useEffect(() => {
1457
127
  if (!tenantId) return;
1458
128
  return assinarChegada(tenantId, ({ conversationId }) => {
1459
129
  void contar();
@@ -1471,7 +141,7 @@ function InboxWatcher({ label, route = "/conversations", store: store2, config,
1471
141
  })();
1472
142
  });
1473
143
  }, [contar, tenantId, label]);
1474
- useRightRailPanel(React14.useMemo(() => Panel ? {
144
+ useRightRailPanel(React2.useMemo(() => Panel ? {
1475
145
  id: RAIL_ID,
1476
146
  label,
1477
147
  icon: MessageCircle,
@@ -1503,124 +173,6 @@ function InboxWatcher({ label, route = "/conversations", store: store2, config,
1503
173
  }
1504
174
  );
1505
175
  }
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
- ] });
1623
- }
1624
176
 
1625
177
  // src/data/accents.ts
1626
178
  var CHANNEL_ACCENT_HEX = {
@@ -1948,7 +500,7 @@ function createSupabaseConversationsProvider(config) {
1948
500
  if (!config?.tenantId) return void 0;
1949
501
  return typeof config.tenantId === "function" ? config.tenantId() : config.tenantId;
1950
502
  }
1951
- function client4() {
503
+ function client3() {
1952
504
  const supabase = config?.supabaseClient ?? getSupabaseClientOptional();
1953
505
  if (!supabase) {
1954
506
  throw new Error(
@@ -1959,7 +511,7 @@ function createSupabaseConversationsProvider(config) {
1959
511
  }
1960
512
  return {
1961
513
  async listConversations(query) {
1962
- let q = client4().from(T.conversationsView).select("*");
514
+ let q = client3().from(T.conversationsView).select("*");
1963
515
  const tenantId = resolveTenantId();
1964
516
  if (tenantId) q = q.eq("tenant_id", tenantId);
1965
517
  if (query?.channel && query.channel !== "all") {
@@ -1987,7 +539,7 @@ function createSupabaseConversationsProvider(config) {
1987
539
  return (data ?? []).map(mapConversation);
1988
540
  },
1989
541
  async getMessages(conversationId) {
1990
- const selected = client4().from(T.messages).select("*");
542
+ const selected = client3().from(T.messages).select("*");
1991
543
  const filtered = selected.eq(
1992
544
  "conversation_id",
1993
545
  conversationId
@@ -2024,7 +576,7 @@ function createSupabaseConversationsProvider(config) {
2024
576
  note: input.note?.trim() || null
2025
577
  };
2026
578
  if (tenantId) convRow.tenant_id = tenantId;
2027
- const { data: created, error } = await client4().from(T.conversations).insert(convRow).select().single();
579
+ const { data: created, error } = await client3().from(T.conversations).insert(convRow).select().single();
2028
580
  if (error) throw error;
2029
581
  if (!created) throw new Error("Conversation not created");
2030
582
  if (firstMessage) {
@@ -2037,13 +589,13 @@ function createSupabaseConversationsProvider(config) {
2037
589
  at: now
2038
590
  };
2039
591
  if (tenantId) msgRow.tenant_id = tenantId;
2040
- await client4().from(T.messages).insert(msgRow);
592
+ await client3().from(T.messages).insert(msgRow);
2041
593
  }
2042
594
  return mapConversation(created);
2043
595
  },
2044
596
  async sendMessage(input) {
2045
597
  const tenantId = resolveTenantId();
2046
- const convSelected = client4().from(T.conversations).select(
598
+ const convSelected = client3().from(T.conversations).select(
2047
599
  "channel, contact_handle"
2048
600
  );
2049
601
  const convFiltered = convSelected.eq(
@@ -2062,14 +614,14 @@ function createSupabaseConversationsProvider(config) {
2062
614
  at
2063
615
  };
2064
616
  if (tenantId) row.tenant_id = tenantId;
2065
- const { data: created, error } = await client4().from(T.messages).insert(row).select().single();
617
+ const { data: created, error } = await client3().from(T.messages).insert(row).select().single();
2066
618
  if (error) throw error;
2067
619
  let sentId = null;
2068
620
  let sentStatus = null;
2069
621
  if (channel === "whatsapp") {
2070
622
  const handle = String(conv?.contact_handle ?? "");
2071
623
  try {
2072
- const { data: out, error: sendErr } = await client4().functions.invoke(
624
+ const { data: out, error: sendErr } = await client3().functions.invoke(
2073
625
  "conversations-provider",
2074
626
  { body: { tenantId, action: "send", to: handle, text: input.body } }
2075
627
  );
@@ -2081,7 +633,7 @@ function createSupabaseConversationsProvider(config) {
2081
633
  sentId = out?.providerMessageId ?? null;
2082
634
  sentStatus = out?.queued ? "queued" : mapDeliveryStatus(out?.status);
2083
635
  if (created?.id) {
2084
- await client4().from(T.messages).update({
636
+ await client3().from(T.messages).update({
2085
637
  provider_message_id: sentId,
2086
638
  // O provedor fala o dialeto dele. A Tyxter devolve `accepted`,
2087
639
  // que NÃO existe no CHECK desta coluna — o update explodia, caía
@@ -2092,14 +644,14 @@ function createSupabaseConversationsProvider(config) {
2092
644
  }
2093
645
  } catch (err) {
2094
646
  if (created?.id) {
2095
- await client4().from(T.messages).update({
647
+ await client3().from(T.messages).update({
2096
648
  delivery_status: "failed"
2097
649
  }).eq("id", String(created.id));
2098
650
  }
2099
651
  throw err;
2100
652
  }
2101
653
  }
2102
- await client4().from(T.conversations).update({
654
+ await client3().from(T.conversations).update({
2103
655
  last_message_preview: input.body,
2104
656
  last_message_at: at,
2105
657
  unread_count: 0,
@@ -2112,7 +664,7 @@ function createSupabaseConversationsProvider(config) {
2112
664
  };
2113
665
  },
2114
666
  async markRead(conversationId) {
2115
- const { error } = await client4().from(T.conversations).update({
667
+ const { error } = await client3().from(T.conversations).update({
2116
668
  unread_count: 0,
2117
669
  // O instante é o que tira a bolinha. `unread_count` sozinho não
2118
670
  // tirava — a fila olha a DIREÇÃO da última mensagem, e ela continua
@@ -2122,7 +674,7 @@ function createSupabaseConversationsProvider(config) {
2122
674
  if (error) throw error;
2123
675
  },
2124
676
  async setStatus(conversationId, status) {
2125
- const updated = client4().from(T.conversations).update({
677
+ const updated = client3().from(T.conversations).update({
2126
678
  status
2127
679
  });
2128
680
  const filtered = updated.eq("id", conversationId);
@@ -2585,7 +1137,7 @@ function WaitingTable() {
2585
1137
  loading: loading && waiting.length === 0,
2586
1138
  emptyMessage: t("conversations.dashboard.needsYouEmpty"),
2587
1139
  onRowClick: (row) => {
2588
- window.location.hash = `/conversations?thread=${row.id}`;
1140
+ navigateApp(`/conversations?thread=${row.id}`);
2589
1141
  }
2590
1142
  }
2591
1143
  );
@@ -2604,470 +1156,29 @@ function createConversationsDashboardWidgets(ctx) {
2604
1156
  // only reaches the people who already knew to look.
2605
1157
  defineKpiWidget({
2606
1158
  id: "conversations.kpi.waiting",
2607
- title: "conversations.dashboard.waiting",
2608
- domain: "conversations",
2609
- defaultOrder: -1,
2610
- component: withCtx(WaitingKpi)
2611
- }),
2612
- 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
- );
1159
+ title: "conversations.dashboard.waiting",
1160
+ domain: "conversations",
1161
+ defaultOrder: -1,
1162
+ component: withCtx(WaitingKpi)
1163
+ }),
1164
+ defineKpiWidget({
1165
+ id: "conversations.kpi.open",
1166
+ title: "conversations.dashboard.openThreads",
1167
+ domain: "conversations",
1168
+ defaultOrder: 4,
1169
+ defaultVisible: false,
1170
+ component: withCtx(OpenThreadsKpi)
1171
+ }),
1172
+ defineTableWidget({
1173
+ id: "conversations.table.waiting",
1174
+ title: "conversations.dashboard.needsYou",
1175
+ domain: "conversations",
1176
+ span: 2,
1177
+ defaultOrder: 10,
1178
+ component: withCtx(WaitingTable)
1179
+ })
1180
+ ];
3069
1181
  }
3070
- ConversationsSettingsTab.displayName = "ConversationsSettingsTab";
3071
1182
  function buildConversationsOnboarding() {
3072
1183
  return {
3073
1184
  id: "conversations",
@@ -3122,7 +1233,7 @@ function buildConversationsOnboarding() {
3122
1233
  };
3123
1234
  }
3124
1235
  var TYXTER_NUMBER_CLAIM_FUNCTION = "tyxter-number-claim";
3125
- function client2() {
1236
+ function client() {
3126
1237
  const supabase = getSupabaseClientOptional();
3127
1238
  if (!supabase) throw new Error("Sem conex\xE3o com o banco para falar com a Tyxter.");
3128
1239
  return supabase;
@@ -3134,7 +1245,7 @@ async function invoke(body) {
3134
1245
  } catch {
3135
1246
  headers = void 0;
3136
1247
  }
3137
- const { data, error } = await client2().functions.invoke(TYXTER_NUMBER_CLAIM_FUNCTION, {
1248
+ const { data, error } = await client().functions.invoke(TYXTER_NUMBER_CLAIM_FUNCTION, {
3138
1249
  body,
3139
1250
  ...headers ? { headers } : {}
3140
1251
  });
@@ -3515,2144 +1626,6 @@ var tyxterConnectorDef = {
3515
1626
  disconnectNote: "Desconectar para de enviar e de receber mensagens no WhatsApp. As conversas que j\xE1 existem continuam aqui.",
3516
1627
  ExtraPanel: TyxterExtraPanel
3517
1628
  };
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
1629
  var conversationsV1SettingsTabs = [
5657
1630
  defineSettingsTab({
5658
1631
  id: "conversations-atendeai",
@@ -5691,7 +1664,7 @@ var conversationsV1SettingsTabs = [
5691
1664
  ]
5692
1665
  })
5693
1666
  ];
5694
- function client3() {
1667
+ function client2() {
5695
1668
  const c = getSupabaseClientOptional();
5696
1669
  if (!c) throw new Error("Supabase n\xE3o inicializado");
5697
1670
  return c;
@@ -5703,7 +1676,7 @@ function tenant() {
5703
1676
  }
5704
1677
  var campaignsApi = {
5705
1678
  async templates() {
5706
- const { data, error } = await client3().from("plg_conversations_templates").select("id, name, language, category, body, variables, provider, status, status_detail").eq("tenant_id", tenant()).order("name");
1679
+ 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
1680
  if (error) throw new Error(error.message);
5708
1681
  return (data ?? []).map((r) => ({
5709
1682
  id: r.id,
@@ -5727,12 +1700,12 @@ var campaignsApi = {
5727
1700
  category: t.category ?? "MARKETING",
5728
1701
  updated_at: (/* @__PURE__ */ new Date()).toISOString()
5729
1702
  };
5730
- const q = t.id ? client3().from("plg_conversations_templates").update(row).eq("id", t.id) : client3().from("plg_conversations_templates").insert(row);
1703
+ const q = t.id ? client2().from("plg_conversations_templates").update(row).eq("id", t.id) : client2().from("plg_conversations_templates").insert(row);
5731
1704
  const { error } = await q;
5732
1705
  if (error) throw new Error(error.message);
5733
1706
  },
5734
1707
  async list() {
5735
- const { data, error } = await client3().from("v_conversations_campaign_progress").select("*").eq("tenant_id", tenant());
1708
+ const { data, error } = await client2().from("v_conversations_campaign_progress").select("*").eq("tenant_id", tenant());
5736
1709
  if (error) throw new Error(error.message);
5737
1710
  return (data ?? []).map((r) => ({
5738
1711
  campaignId: r.campaign_id,
@@ -5761,7 +1734,7 @@ var campaignsApi = {
5761
1734
  * cliques no botão enfileiram uma vez. */
5762
1735
  async create(input) {
5763
1736
  const tenantId = tenant();
5764
- const { data: camp, error } = await client3().from("plg_conversations_campaigns").insert({
1737
+ const { data: camp, error } = await client2().from("plg_conversations_campaigns").insert({
5765
1738
  tenant_id: tenantId,
5766
1739
  name: input.name,
5767
1740
  template_id: input.templateId ?? null,
@@ -5778,7 +1751,7 @@ var campaignsApi = {
5778
1751
  variables: t.variables ?? {}
5779
1752
  })).filter((r) => r.phone_e164.length >= 10);
5780
1753
  for (let i = 0; i < rows.length; i += 200) {
5781
- const { error: e } = await client3().from("plg_conversations_campaign_targets").upsert(rows.slice(i, i + 200), { onConflict: "campaign_id,phone_e164,rotation", ignoreDuplicates: true });
1754
+ 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
1755
  if (e) throw new Error(e.message);
5783
1756
  }
5784
1757
  return camp.id;
@@ -5789,13 +1762,13 @@ var campaignsApi = {
5789
1762
  patch.started_at = (/* @__PURE__ */ new Date()).toISOString();
5790
1763
  patch.paused_reason = null;
5791
1764
  }
5792
- const { error } = await client3().from("plg_conversations_campaigns").update(patch).eq("id", campaignId);
1765
+ const { error } = await client2().from("plg_conversations_campaigns").update(patch).eq("id", campaignId);
5793
1766
  if (error) throw new Error(error.message);
5794
1767
  },
5795
1768
  /** Um lote. Quem chama repete até `remaining` chegar a zero — o arrendamento
5796
1769
  * de cinco minutos é o que impede duas chamadas de mandarem o mesmo. */
5797
1770
  async dispatch(campaignId, limit) {
5798
- const { data, error } = await client3().functions.invoke("conversations-provider", {
1771
+ const { data, error } = await client2().functions.invoke("conversations-provider", {
5799
1772
  body: { tenantId: tenant(), action: "dispatch", campaignId, limit }
5800
1773
  });
5801
1774
  if (error) {
@@ -5838,7 +1811,7 @@ function createConversationsPlugin(options) {
5838
1811
  contactEntityDef: options?.contactEntityDef
5839
1812
  };
5840
1813
  const dashboardWidgets = createConversationsDashboardWidgets({ store: store2, config });
5841
- const PageComponent = () => React14__default.createElement(ConversationsPage, { store: store2, config });
1814
+ const PageComponent = () => React2__default.createElement(ConversationsPage, { store: store2, config });
5842
1815
  PageComponent.displayName = "ConversationsPage";
5843
1816
  return {
5844
1817
  id: "conversations",
@@ -5898,7 +1871,7 @@ function createConversationsPlugin(options) {
5898
1871
  headerControls: [{
5899
1872
  id: "conversations-inbox",
5900
1873
  component: (() => {
5901
- const W = () => React14__default.createElement(InboxWatcher, {
1874
+ const W = () => React2__default.createElement(InboxWatcher, {
5902
1875
  label: options?.navLabel ?? "Conversations",
5903
1876
  store: store2,
5904
1877
  config,
@@ -6071,7 +2044,10 @@ function createConversationsPlugin(options) {
6071
2044
  locales: conversationsLocales
6072
2045
  };
6073
2046
  }
2047
+ var ConversationsPage = React2__default.lazy(() => import('./ConversationsPage-4V4VLM4D.js').then((m) => ({ default: m.ConversationsPage })));
2048
+ var AtendeAIPage = React2__default.lazy(() => import('./AtendeAIPage-PBBMWIDW.js').then((m) => ({ default: m.AtendeAIPage })));
2049
+ var ConversationsSettingsTab = lazyComponent(() => import('./ConversationsSettings-7UQYM26R.js').then((m) => m.ConversationsSettingsTab));
6074
2050
 
6075
- export { PAYMENT_PREFLIGHT_FUNCTION, TYXTER_CONNECTOR_ID, TYXTER_LATENCY_BUDGET_MS, WhatsAppProviders, campaignsApi, canOfferPayment, createConversationsPlugin, createMockConversationsProvider, createSupabaseConversationsProvider, isWaitingOnUs, listMessagingChannels, openPaymentSetupSession, providersApi, readPaymentReadiness, tyxterConnectorDef };
2051
+ export { PAYMENT_PREFLIGHT_FUNCTION, TYXTER_CONNECTOR_ID, TYXTER_LATENCY_BUDGET_MS, campaignsApi, canOfferPayment, createConversationsPlugin, createMockConversationsProvider, createSupabaseConversationsProvider, openPaymentSetupSession, readPaymentReadiness, tyxterConnectorDef };
6076
2052
  //# sourceMappingURL=index.js.map
6077
2053
  //# sourceMappingURL=index.js.map