@fayz-ai/plugin-conversations 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/InboxWatcher.d.ts.map +1 -1
- package/dist/data/supabase.d.ts.map +1 -1
- package/dist/index.js +405 -56
- package/dist/index.js.map +1 -1
- package/dist/locales/en.d.ts.map +1 -1
- package/dist/locales/pt-BR.d.ts.map +1 -1
- package/dist/migrations/index.d.ts +4 -0
- package/dist/migrations/index.d.ts.map +1 -1
- package/dist/store.d.ts +2 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/types.d.ts +5 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/views/ConversationList.d.ts.map +1 -1
- package/dist/views/InboxView.d.ts.map +1 -1
- package/dist/views/useCaixaAoVivo.d.ts +27 -0
- package/dist/views/useCaixaAoVivo.d.ts.map +1 -0
- package/package.json +3 -3
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"InboxWatcher.d.ts","sourceRoot":"","sources":["../../src/components/InboxWatcher.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"InboxWatcher.d.ts","sourceRoot":"","sources":["../../src/components/InboxWatcher.tsx"],"names":[],"mappings":"AAiGA,wBAAgB,YAAY,CAAC,EAAE,KAAK,EAAE,KAAwB,EAAE,OAAgB,EAAE,QAAY,EAAE,EAAE;IAChG,sDAAsD;IACtD,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,CAAC,EAAE,MAAM,CAAA;IACd;;qCAEiC;IACjC,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB,QAgGA"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"supabase.d.ts","sourceRoot":"","sources":["../../src/data/supabase.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,SAAS,CAAA;AAwBpD,MAAM,WAAW,2BAA2B;IAC1C,+EAA+E;IAC/E,cAAc,CAAC,EAAE,OAAO,CAAA;IACxB,+EAA+E;IAC/E,QAAQ,CAAC,EAAE,MAAM,GAAG,CAAC,MAAM,MAAM,GAAG,SAAS,CAAC,CAAA;IAC9C,+DAA+D;IAC/D,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;
|
|
1
|
+
{"version":3,"file":"supabase.d.ts","sourceRoot":"","sources":["../../src/data/supabase.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,SAAS,CAAA;AAwBpD,MAAM,WAAW,2BAA2B;IAC1C,+EAA+E;IAC/E,cAAc,CAAC,EAAE,OAAO,CAAA;IACxB,+EAA+E;IAC/E,QAAQ,CAAC,EAAE,MAAM,GAAG,CAAC,MAAM,MAAM,GAAG,SAAS,CAAC,CAAA;IAC9C,+DAA+D;IAC/D,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AA8CD,wBAAgB,mCAAmC,CACjD,MAAM,CAAC,EAAE,2BAA2B,GACnC,qBAAqB,CAuRvB"}
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as React11 from 'react';
|
|
2
2
|
import React11__default, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
|
3
|
-
import { Loader2, MessageSquare, AlertTriangle, Pause, Play, Send, RefreshCw, Plus, Clock, CheckCircle2, Check, ShieldCheck, ShieldAlert, QrCode, Zap, ExternalLink, SquarePen, Globe, Mail, Instagram, Phone, Search, User, Inbox, ChevronLeft, Archive, PanelRight, X, MapPin, Tag, StickyNote, Link2, MoreVertical,
|
|
3
|
+
import { Loader2, MessageSquare, AlertTriangle, Pause, Play, Send, RefreshCw, Plus, Clock, CheckCircle2, Check, ShieldCheck, ShieldAlert, QrCode, Zap, ExternalLink, SquarePen, Globe, Mail, Instagram, Phone, Search, User, Inbox, CheckCheck, ChevronLeft, Archive, PanelRight, X, MapPin, Tag, StickyNote, Link2, MoreVertical, Snowflake, Sun, Flame, Building2 } from 'lucide-react';
|
|
4
4
|
import { toast, Button, Input, defineKpiWidget, defineTableWidget, PageHeaderActions, cn, Badge, FilterChip, Skeleton, KpiCard, TableWidget } from '@fayz-ai/ui';
|
|
5
5
|
import { createConnectionStore, connectionRuns, connectionStatus, getActiveTenantId, getSupabaseClientOptional, useActiveTenantId, registerTranslations, useTranslation, countByTenant, CONNECTOR_RUNTIME_TOKEN_HEADER, connectorRuntimeToken, errorMessage } from '@fayz-ai/core';
|
|
6
6
|
import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
|
|
@@ -397,6 +397,7 @@ var CHANNEL_LABELS = {
|
|
|
397
397
|
};
|
|
398
398
|
function isWaitingOnUs(conversation) {
|
|
399
399
|
if (conversation.status !== "open") return false;
|
|
400
|
+
if (conversation.acknowledgedAt && conversation.lastMessageAt && conversation.acknowledgedAt >= conversation.lastMessageAt) return false;
|
|
400
401
|
if (conversation.lastMessageDirection) return conversation.lastMessageDirection === "inbound";
|
|
401
402
|
return conversation.unreadCount > 0;
|
|
402
403
|
}
|
|
@@ -426,12 +427,39 @@ function tocar() {
|
|
|
426
427
|
} catch {
|
|
427
428
|
}
|
|
428
429
|
}
|
|
430
|
+
async function pedirPermissao() {
|
|
431
|
+
if (typeof Notification === "undefined") return false;
|
|
432
|
+
if (Notification.permission === "granted") return true;
|
|
433
|
+
if (Notification.permission === "denied") return false;
|
|
434
|
+
try {
|
|
435
|
+
return await Notification.requestPermission() === "granted";
|
|
436
|
+
} catch {
|
|
437
|
+
return false;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
function avisarNoSistema(titulo, corpo, tag) {
|
|
441
|
+
try {
|
|
442
|
+
if (typeof Notification === "undefined" || Notification.permission !== "granted") return;
|
|
443
|
+
if (typeof document !== "undefined" && document.visibilityState === "visible") return;
|
|
444
|
+
const n = new Notification(titulo, { body: corpo, tag, renotify: true });
|
|
445
|
+
n.onclick = () => {
|
|
446
|
+
try {
|
|
447
|
+
window.focus();
|
|
448
|
+
n.close();
|
|
449
|
+
} catch {
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
} catch {
|
|
453
|
+
}
|
|
454
|
+
}
|
|
429
455
|
function InboxWatcher({ label, route = "/conversations", section = "main", position = 1 }) {
|
|
456
|
+
const tenantId = useActiveTenantId();
|
|
430
457
|
const [naoLidas, setNaoLidas] = React11.useState(0);
|
|
431
458
|
const podeTocar = React11.useRef(false);
|
|
432
459
|
React11.useEffect(() => {
|
|
433
460
|
const liberar = () => {
|
|
434
461
|
podeTocar.current = true;
|
|
462
|
+
void pedirPermissao();
|
|
435
463
|
};
|
|
436
464
|
for (const ev of ["pointerdown", "keydown", "touchstart"]) {
|
|
437
465
|
window.addEventListener(ev, liberar, { once: true, passive: true });
|
|
@@ -457,20 +485,26 @@ function InboxWatcher({ label, route = "/conversations", section = "main", posit
|
|
|
457
485
|
}, [contar]);
|
|
458
486
|
React11.useEffect(() => {
|
|
459
487
|
const supabase = getSupabaseClientOptional();
|
|
460
|
-
if (!supabase?.channel) return;
|
|
461
|
-
const canal = supabase.channel(
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
(
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
488
|
+
if (!supabase?.channel || !tenantId) return;
|
|
489
|
+
const canal = supabase.channel(`conversas:${tenantId}`).on("broadcast", { event: "inbound" }, (msg2) => {
|
|
490
|
+
const id = msg2?.payload?.conversationId;
|
|
491
|
+
void contar();
|
|
492
|
+
if (podeTocar.current) tocar();
|
|
493
|
+
void (async () => {
|
|
494
|
+
let titulo = label;
|
|
495
|
+
let corpo = "";
|
|
496
|
+
if (id) {
|
|
497
|
+
const { data } = await supabase.from(T.conversations).select("contact_name, last_message_preview").eq("id", id).maybeSingle();
|
|
498
|
+
titulo = data?.contact_name || label;
|
|
499
|
+
corpo = String(data?.last_message_preview || "").replace(/\s+/g, " ").slice(0, 140);
|
|
500
|
+
}
|
|
501
|
+
avisarNoSistema(titulo, corpo, String(id ?? "inbox"));
|
|
502
|
+
})();
|
|
503
|
+
}).subscribe();
|
|
470
504
|
return () => {
|
|
471
505
|
void supabase.removeChannel?.(canal);
|
|
472
506
|
};
|
|
473
|
-
}, [contar]);
|
|
507
|
+
}, [contar, tenantId, label]);
|
|
474
508
|
useNavContribution("conversations:unread", naoLidas > 0 ? [{ id: "conversations", label, route, badge: naoLidas, badgeTone: "destructive", section, position }] : []);
|
|
475
509
|
return null;
|
|
476
510
|
}
|
|
@@ -815,6 +849,7 @@ function ConversationList({ className }) {
|
|
|
815
849
|
search,
|
|
816
850
|
loading,
|
|
817
851
|
select,
|
|
852
|
+
markRead,
|
|
818
853
|
setChannelFilter,
|
|
819
854
|
setLeadOwnerFilter,
|
|
820
855
|
setSearch
|
|
@@ -885,48 +920,69 @@ function ConversationList({ className }) {
|
|
|
885
920
|
conversations.map((c) => {
|
|
886
921
|
const active = c.id === selectedId;
|
|
887
922
|
const unread = isWaitingOnUs(c);
|
|
888
|
-
return
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
/* @__PURE__ */
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
923
|
+
return (
|
|
924
|
+
// `div` em volta e não um botão dentro de outro: HTML não permite
|
|
925
|
+
// aninhar, e o navegador resolve isso silenciosamente movendo o
|
|
926
|
+
// interno para fora — a linha inteira deixa de ser clicável e
|
|
927
|
+
// ninguém entende por quê.
|
|
928
|
+
/* @__PURE__ */ jsxs("div", { className: "group relative", children: [
|
|
929
|
+
/* @__PURE__ */ jsxs(
|
|
930
|
+
"button",
|
|
931
|
+
{
|
|
932
|
+
onClick: () => select(c.id),
|
|
933
|
+
className: cn(
|
|
934
|
+
"flex w-full items-start gap-3 border-b border-border/50 px-3 py-3 text-left transition-colors",
|
|
935
|
+
// Um TOM da cor de marca, e não `bg-accent`.
|
|
936
|
+
//
|
|
937
|
+
// `bg-accent` resolve para quase preto neste tema, e a linha da
|
|
938
|
+
// conversa aberta ficava escura sobre escura. Trocar junto o
|
|
939
|
+
// `text-accent-foreground` deixava legível, mas pintava de
|
|
940
|
+
// preto a única linha que se está a ler — chamava mais atenção
|
|
941
|
+
// que a conversa.
|
|
942
|
+
//
|
|
943
|
+
// `bg-primary/10` é um véu: o texto continua com as cores dele,
|
|
944
|
+
// a seleção se lê como seleção e não como bloco, e funciona nos
|
|
945
|
+
// dois temas porque não presume nada sobre o fundo.
|
|
946
|
+
active ? "bg-primary/10" : "hover:bg-muted/50"
|
|
947
|
+
),
|
|
948
|
+
children: [
|
|
949
|
+
/* @__PURE__ */ jsx(Avatar, { name: c.contactName, accent: c.accent, channel: c.channel }),
|
|
950
|
+
/* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
|
|
951
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2", children: [
|
|
952
|
+
/* @__PURE__ */ jsx("span", { className: cn("truncate text-sm text-foreground", unread ? "font-semibold" : "font-medium"), children: c.contactName }),
|
|
953
|
+
/* @__PURE__ */ jsx("span", { className: cn("shrink-0 text-[11px]", unread ? "font-semibold text-primary" : "text-muted-foreground"), children: relativeTime(c.lastMessageAt) })
|
|
954
|
+
] }),
|
|
955
|
+
/* @__PURE__ */ jsxs("div", { className: "mt-1 flex items-center gap-2", children: [
|
|
956
|
+
/* @__PURE__ */ jsx(ChannelBadge, { channel: c.channel }),
|
|
957
|
+
c.status !== "open" && /* @__PURE__ */ jsx("span", { className: "text-[10px] uppercase tracking-wide text-muted-foreground/70", children: t(`conversations.status.${c.status}`) }),
|
|
958
|
+
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: [
|
|
959
|
+
/* @__PURE__ */ jsx(User, { className: "h-2.5 w-2.5" }),
|
|
960
|
+
c.leadOwnerName.split(" ")[0]
|
|
961
|
+
] })
|
|
962
|
+
] }),
|
|
963
|
+
/* @__PURE__ */ jsxs("div", { className: "mt-1 flex items-center justify-between gap-2", children: [
|
|
964
|
+
/* @__PURE__ */ jsx("span", { className: cn("truncate text-xs", unread ? "text-foreground" : "text-muted-foreground"), children: c.lastMessagePreview }),
|
|
965
|
+
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" })
|
|
966
|
+
] })
|
|
920
967
|
] })
|
|
921
|
-
]
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
968
|
+
]
|
|
969
|
+
}
|
|
970
|
+
),
|
|
971
|
+
unread && /* @__PURE__ */ jsx(
|
|
972
|
+
"button",
|
|
973
|
+
{
|
|
974
|
+
type: "button",
|
|
975
|
+
title: t("conversations.markRead"),
|
|
976
|
+
"aria-label": t("conversations.markRead"),
|
|
977
|
+
onClick: (e) => {
|
|
978
|
+
e.stopPropagation();
|
|
979
|
+
void markRead(c.id);
|
|
980
|
+
},
|
|
981
|
+
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",
|
|
982
|
+
children: /* @__PURE__ */ jsx(CheckCheck, { className: "h-3.5 w-3.5" })
|
|
983
|
+
}
|
|
984
|
+
)
|
|
985
|
+
] }, c.id)
|
|
930
986
|
);
|
|
931
987
|
})
|
|
932
988
|
] })
|
|
@@ -1573,9 +1629,31 @@ function NewConversationPanel({
|
|
|
1573
1629
|
}
|
|
1574
1630
|
);
|
|
1575
1631
|
}
|
|
1632
|
+
function useCaixaAoVivo() {
|
|
1633
|
+
const tenantId = useActiveTenantId();
|
|
1634
|
+
const load = useConversationsStore((s) => s.load);
|
|
1635
|
+
const refreshMessages = useConversationsStore((s) => s.refreshMessages);
|
|
1636
|
+
const selectedId = useConversationsStore((s) => s.selectedId);
|
|
1637
|
+
const abertoRef = React11.useRef(selectedId);
|
|
1638
|
+
abertoRef.current = selectedId;
|
|
1639
|
+
React11.useEffect(() => {
|
|
1640
|
+
const supabase = getSupabaseClientOptional();
|
|
1641
|
+
if (!supabase?.channel || !tenantId) return;
|
|
1642
|
+
const canal = supabase.channel(`conversas:${tenantId}`).on("broadcast", { event: "inbound" }, (msg2) => {
|
|
1643
|
+
void load();
|
|
1644
|
+
if (msg2?.payload?.conversationId && msg2.payload.conversationId === abertoRef.current) {
|
|
1645
|
+
void refreshMessages();
|
|
1646
|
+
}
|
|
1647
|
+
}).subscribe();
|
|
1648
|
+
return () => {
|
|
1649
|
+
void supabase.removeChannel?.(canal);
|
|
1650
|
+
};
|
|
1651
|
+
}, [tenantId, load, refreshMessages]);
|
|
1652
|
+
}
|
|
1576
1653
|
function InboxView() {
|
|
1577
1654
|
const t = useTranslation();
|
|
1578
1655
|
const { conversations, selectedId, deselect } = useConversationsStore((s) => s);
|
|
1656
|
+
useCaixaAoVivo();
|
|
1579
1657
|
const isWidePanel = useMediaQuery("(min-width: 1280px)");
|
|
1580
1658
|
const [panelOpen, setPanelOpen] = React11__default.useState(false);
|
|
1581
1659
|
const [newOpen, setNewOpen] = React11__default.useState(false);
|
|
@@ -1954,6 +2032,7 @@ function mapConversation(r) {
|
|
|
1954
2032
|
// which is the mistake this one was not going to repeat).
|
|
1955
2033
|
lastMessageDirection: r.last_message_direction ?? void 0,
|
|
1956
2034
|
unreadCount: Number(r.unread_count ?? 0),
|
|
2035
|
+
acknowledgedAt: r.acknowledged_at ?? null,
|
|
1957
2036
|
status: r.status ?? "open",
|
|
1958
2037
|
assignedTo: r.assigned_to ?? void 0,
|
|
1959
2038
|
// Presentes só quando a leitura veio da view. Na escrita (que volta da
|
|
@@ -2156,7 +2235,11 @@ function createSupabaseConversationsProvider(config) {
|
|
|
2156
2235
|
},
|
|
2157
2236
|
async markRead(conversationId) {
|
|
2158
2237
|
const { error } = await client4().from(T.conversations).update({
|
|
2159
|
-
unread_count: 0
|
|
2238
|
+
unread_count: 0,
|
|
2239
|
+
// O instante é o que tira a bolinha. `unread_count` sozinho não
|
|
2240
|
+
// tirava — a fila olha a DIREÇÃO da última mensagem, e ela continua
|
|
2241
|
+
// sendo de entrada por mais que alguém leia.
|
|
2242
|
+
acknowledged_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
2160
2243
|
}).eq("id", conversationId);
|
|
2161
2244
|
if (error) throw error;
|
|
2162
2245
|
},
|
|
@@ -2199,13 +2282,22 @@ function createConversationsStore(provider) {
|
|
|
2199
2282
|
set({ conversations, loading: false, selectedId });
|
|
2200
2283
|
if (selectedId) await get().select(selectedId);
|
|
2201
2284
|
},
|
|
2285
|
+
/** Dar por vista sem abrir — o caso do "ok, obrigado" que não pede resposta. */
|
|
2286
|
+
async markRead(id) {
|
|
2287
|
+
await provider.markRead(id);
|
|
2288
|
+
const agora = (/* @__PURE__ */ new Date()).toISOString();
|
|
2289
|
+
set((s) => ({
|
|
2290
|
+
conversations: s.conversations.map((c) => c.id === id ? { ...c, unreadCount: 0, acknowledgedAt: agora } : c)
|
|
2291
|
+
}));
|
|
2292
|
+
},
|
|
2202
2293
|
async select(id) {
|
|
2203
2294
|
set({ selectedId: id });
|
|
2204
2295
|
const messages = await provider.getMessages(id);
|
|
2205
2296
|
set({ messages });
|
|
2206
2297
|
await provider.markRead(id);
|
|
2298
|
+
const agora = (/* @__PURE__ */ new Date()).toISOString();
|
|
2207
2299
|
set((s) => ({
|
|
2208
|
-
conversations: s.conversations.map((c) => c.id === id ? { ...c, unreadCount: 0 } : c)
|
|
2300
|
+
conversations: s.conversations.map((c) => c.id === id ? { ...c, unreadCount: 0, acknowledgedAt: agora } : c)
|
|
2209
2301
|
}));
|
|
2210
2302
|
},
|
|
2211
2303
|
/** Relê as mensagens da conversa aberta, sem tocar em mais nada.
|
|
@@ -2324,6 +2416,7 @@ function createConversationsStore(provider) {
|
|
|
2324
2416
|
|
|
2325
2417
|
// src/locales/en.ts
|
|
2326
2418
|
var en = {
|
|
2419
|
+
"conversations.markRead": "Mark as read",
|
|
2327
2420
|
"conversations.title": "Conversations",
|
|
2328
2421
|
"conversations.subtitle": "Unified inbox across every channel",
|
|
2329
2422
|
// Conversation list
|
|
@@ -2419,6 +2512,7 @@ var en = {
|
|
|
2419
2512
|
|
|
2420
2513
|
// src/locales/pt-BR.ts
|
|
2421
2514
|
var ptBR = {
|
|
2515
|
+
"conversations.markRead": "Marcar como lida",
|
|
2422
2516
|
"conversations.title": "Conversas",
|
|
2423
2517
|
"conversations.subtitle": "Caixa de entrada unificada de todos os canais",
|
|
2424
2518
|
// Lista de conversas
|
|
@@ -5115,6 +5209,257 @@ CREATE OR REPLACE VIEW public.v_conversations WITH (security_invoker = true) AS
|
|
|
5115
5209
|
COMMENT ON VIEW public.v_conversations IS
|
|
5116
5210
|
'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).';
|
|
5117
5211
|
`;
|
|
5212
|
+
var MIGRATION_008_DAR_POR_VISTA_E_UMA_DECISAO = `-- ---------------------------------------------------------------------------
|
|
5213
|
+
-- 008_dar_por_vista_e_uma_decisao.sql
|
|
5214
|
+
--
|
|
5215
|
+
-- \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
|
|
5216
|
+
--
|
|
5217
|
+
-- A bolinha vermelha da caixa n\xE3o \xE9 "n\xE3o lido": \xE9 \`isWaitingOnUs\`, que olha a
|
|
5218
|
+
-- DIRE\xC7\xC3O da \xFAltima mensagem. Se a \xFAltima \xE9 de entrada, a conversa espera
|
|
5219
|
+
-- resposta \u2014 e continua esperando por mais que algu\xE9m abra e leia.
|
|
5220
|
+
--
|
|
5221
|
+
-- Isso est\xE1 certo enquanto a pergunta \xE9 "o que falta responder". Mas deixa sem
|
|
5222
|
+
-- sa\xEDda o caso comum: a pessoa leu, decidiu que n\xE3o cabe resposta ("ok",
|
|
5223
|
+
-- "obrigado", um \xE1udio que j\xE1 foi tratado por telefone) e n\xE3o tem como tirar a
|
|
5224
|
+
-- conversa da fila. \`unread_count = 0\` n\xE3o resolve, porque n\xE3o \xE9 ele que
|
|
5225
|
+
-- acende a bolinha.
|
|
5226
|
+
--
|
|
5227
|
+
-- \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
|
|
5228
|
+
--
|
|
5229
|
+
-- \`lida boolean\` exigiria algu\xE9m lembrar de apag\xE1-la quando chegasse mensagem
|
|
5230
|
+
-- nova \u2014 e o dia em que esquecessem, a conversa sumiria da fila com o cliente
|
|
5231
|
+
-- esperando. \xC9 o pior defeito poss\xEDvel numa caixa de entrada: silencioso, e do
|
|
5232
|
+
-- lado de perder trabalho.
|
|
5233
|
+
--
|
|
5234
|
+
-- Um INSTANTE se compara com \`last_message_at\` e se invalida sozinho: deu por
|
|
5235
|
+
-- vista \xE0s 14h, chegou mensagem \xE0s 15h, a conversa volta \xE0 fila sem ningu\xE9m
|
|
5236
|
+
-- fazer nada. A regra \xE9 uma compara\xE7\xE3o, n\xE3o um estado a manter.
|
|
5237
|
+
--
|
|
5238
|
+
-- \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
|
|
5239
|
+
--
|
|
5240
|
+
-- \`unread_count\` continua onde est\xE1. Ele conta mensagens e serve ao contador
|
|
5241
|
+
-- do menu; esta coluna responde outra pergunta ("algu\xE9m j\xE1 decidiu sobre
|
|
5242
|
+
-- isto?"). Colapsar as duas faria a contagem do menu mentir no dia em que
|
|
5243
|
+
-- algu\xE9m desse por vista sem ler.
|
|
5244
|
+
--
|
|
5245
|
+
-- Idempotente e replay-safe.
|
|
5246
|
+
-- ---------------------------------------------------------------------------
|
|
5247
|
+
|
|
5248
|
+
ALTER TABLE public.plg_conversations
|
|
5249
|
+
ADD COLUMN IF NOT EXISTS acknowledged_at timestamptz;
|
|
5250
|
+
|
|
5251
|
+
COMMENT ON COLUMN public.plg_conversations.acknowledged_at IS
|
|
5252
|
+
'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).';
|
|
5253
|
+
|
|
5254
|
+
-- A fila \xE9 lida por \`last_message_at\` e por esta coluna juntas.
|
|
5255
|
+
CREATE INDEX IF NOT EXISTS plg_conversations_fila_idx
|
|
5256
|
+
ON public.plg_conversations (tenant_id, status, last_message_at DESC)
|
|
5257
|
+
WHERE status = 'open';
|
|
5258
|
+
`;
|
|
5259
|
+
var MIGRATION_009_A_MENSAGEM_INTEIRA_ATRAVESSA_O_REALTIME = `-- ---------------------------------------------------------------------------
|
|
5260
|
+
-- 009_a_mensagem_inteira_atravessa_o_realtime.sql
|
|
5261
|
+
--
|
|
5262
|
+
-- \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
|
|
5263
|
+
--
|
|
5264
|
+
-- O vigia da caixa assinava \`plg_conversation_messages\`, recebia \`SUBSCRIBED\`
|
|
5265
|
+
-- do servidor, e nunca recebia um \xFAnico evento. Nenhum erro, nenhum aviso: a
|
|
5266
|
+
-- assinatura dizia que estava tudo bem e n\xE3o chegava nada.
|
|
5267
|
+
--
|
|
5268
|
+
-- A causa \xE9 a combina\xE7\xE3o de duas coisas que, sozinhas, est\xE3o certas:
|
|
5269
|
+
--
|
|
5270
|
+
-- 1. a tabela tem RLS ligada, como toda tabela desta base;
|
|
5271
|
+
-- 2. a tabela estava com \`REPLICA IDENTITY\` no padr\xE3o \u2014 s\xF3 a chave prim\xE1ria.
|
|
5272
|
+
--
|
|
5273
|
+
-- O Realtime do Supabase aplica a POL\xCDTICA DE LEITURA do usu\xE1rio a cada evento
|
|
5274
|
+
-- antes de entreg\xE1-lo. Para isso ele precisa da linha inteira: a pol\xEDtica fala
|
|
5275
|
+
-- de \`tenant_id\`, e \`tenant_id\` n\xE3o \xE9 a chave prim\xE1ria. Com s\xF3 a PK no WAL, n\xE3o
|
|
5276
|
+
-- h\xE1 o que avaliar \u2014 e o evento \xE9 descartado sem ru\xEDdo.
|
|
5277
|
+
--
|
|
5278
|
+
-- \`plg_conversations\` j\xE1 estava \`FULL\` (006). A tabela de MENSAGENS ficou de
|
|
5279
|
+
-- fora daquela migration, e \xE9 justamente ela que o vigia escuta.
|
|
5280
|
+
--
|
|
5281
|
+
-- \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
|
|
5282
|
+
--
|
|
5283
|
+
-- \`FULL\` faz o WAL carregar a linha inteira em cada UPDATE e DELETE, n\xE3o s\xF3 as
|
|
5284
|
+
-- colunas mudadas. Numa tabela de mensagens isso \xE9 peso real.
|
|
5285
|
+
--
|
|
5286
|
+
-- A alternativa \u2014 desligar a RLS para o Realtime enxergar \u2014 trocaria um custo
|
|
5287
|
+
-- de disco por um vazamento: as mensagens de uma casa chegariam ao navegador
|
|
5288
|
+
-- de outra. Entre pagar WAL e vazar conversa, paga-se o WAL.
|
|
5289
|
+
--
|
|
5290
|
+
-- Idempotente e replay-safe.
|
|
5291
|
+
-- ---------------------------------------------------------------------------
|
|
5292
|
+
|
|
5293
|
+
ALTER TABLE public.plg_conversation_messages REPLICA IDENTITY FULL;
|
|
5294
|
+
|
|
5295
|
+
-- Garante a publica\xE7\xE3o tamb\xE9m \u2014 uma tabela com identidade cheia e fora da
|
|
5296
|
+
-- publica\xE7\xE3o continua sem emitir nada, e o sintoma seria id\xEAntico.
|
|
5297
|
+
DO $$
|
|
5298
|
+
BEGIN
|
|
5299
|
+
IF NOT EXISTS (
|
|
5300
|
+
SELECT 1 FROM pg_publication_tables
|
|
5301
|
+
WHERE pubname = 'supabase_realtime'
|
|
5302
|
+
AND schemaname = 'public'
|
|
5303
|
+
AND tablename = 'plg_conversation_messages'
|
|
5304
|
+
) THEN
|
|
5305
|
+
EXECUTE 'ALTER PUBLICATION supabase_realtime ADD TABLE public.plg_conversation_messages';
|
|
5306
|
+
END IF;
|
|
5307
|
+
END $$;
|
|
5308
|
+
`;
|
|
5309
|
+
var MIGRATION_010_A_CHEGADA_E_ANUNCIADA_POR_CONTA = `-- ---------------------------------------------------------------------------
|
|
5310
|
+
-- 010_a_chegada_e_anunciada_por_conta.sql
|
|
5311
|
+
--
|
|
5312
|
+
-- \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
|
|
5313
|
+
--
|
|
5314
|
+
-- O vigia assinava a TABELA de mensagens e recebia \`SUBSCRIBED\` do servidor.
|
|
5315
|
+
-- Nenhum evento chegava. Nenhum erro, nenhum aviso \u2014 a assinatura dizia que
|
|
5316
|
+
-- estava tudo bem.
|
|
5317
|
+
--
|
|
5318
|
+
-- O Realtime aplica a pol\xEDtica de leitura a cada evento antes de entreg\xE1-lo. A
|
|
5319
|
+
-- pol\xEDtica desta base resolve o tenant por, nesta ordem:
|
|
5320
|
+
--
|
|
5321
|
+
-- \u2460 a claim \`tenant_id\` do JWT
|
|
5322
|
+
-- \u2461 o CABE\xC7ALHO da requisi\xE7\xE3o (\`app.requested_tenant()\`)
|
|
5323
|
+
-- \u2462 a filia\xE7\xE3o \xFAnica, quando a pessoa s\xF3 tem uma
|
|
5324
|
+
--
|
|
5325
|
+
-- O Realtime manda o JWT e N\xC3O manda cabe\xE7alho. E o JWT desta casa n\xE3o carrega
|
|
5326
|
+
-- \`tenant_id\` \u2014 a conta ativa \xE9 escolhida por cabe\xE7alho, porque a mesma pessoa
|
|
5327
|
+
-- atende sete contas. Sobra \u2462, que devolve nulo para quem tem mais de uma.
|
|
5328
|
+
--
|
|
5329
|
+
-- Resultado: a pol\xEDtica recusa, o evento \xE9 descartado em sil\xEAncio, e o defeito
|
|
5330
|
+
-- parece "o realtime n\xE3o funciona" quando na verdade \xE9 "o realtime n\xE3o sabe em
|
|
5331
|
+
-- que conta voc\xEA est\xE1".
|
|
5332
|
+
--
|
|
5333
|
+
-- \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
|
|
5334
|
+
--
|
|
5335
|
+
-- Broadcast n\xE3o passa pela RLS da tabela: o servidor entrega a quem est\xE1 no
|
|
5336
|
+
-- T\xD3PICO. Ent\xE3o o t\xF3pico carrega a conta no nome \u2014 \`conversas:<tenant>\` \u2014 e
|
|
5337
|
+
-- quem escuta j\xE1 teve de saber em que conta est\xE1 para montar o nome.
|
|
5338
|
+
--
|
|
5339
|
+
-- Isso inverte a depend\xEAncia de forma saud\xE1vel: em vez de o banco adivinhar o
|
|
5340
|
+
-- contexto do navegador, o navegador declara o contexto que j\xE1 tem.
|
|
5341
|
+
--
|
|
5342
|
+
-- \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
|
|
5343
|
+
--
|
|
5344
|
+
-- O M\xCDNIMO para decidir: id da conversa, canal, e um trecho. N\xE3o a mensagem
|
|
5345
|
+
-- inteira \u2014 um broadcast \xE9 entregue a todos do t\xF3pico, e mandar o corpo
|
|
5346
|
+
-- completo por ali seria espalhar conversa por um caminho que n\xE3o \xE9 o que
|
|
5347
|
+
-- guarda conversa. Quem quiser o texto l\xEA a tabela, com RLS, como sempre.
|
|
5348
|
+
--
|
|
5349
|
+
-- Idempotente e replay-safe.
|
|
5350
|
+
-- ---------------------------------------------------------------------------
|
|
5351
|
+
|
|
5352
|
+
CREATE OR REPLACE FUNCTION public.plg_conversations_anunciar_chegada()
|
|
5353
|
+
RETURNS trigger
|
|
5354
|
+
LANGUAGE plpgsql
|
|
5355
|
+
SECURITY DEFINER
|
|
5356
|
+
SET search_path = ''
|
|
5357
|
+
AS $$
|
|
5358
|
+
DECLARE
|
|
5359
|
+
v_nome text;
|
|
5360
|
+
BEGIN
|
|
5361
|
+
-- S\xF3 o que CHEGA. A resposta que o operador manda tamb\xE9m \xE9 um INSERT, e
|
|
5362
|
+
-- anunci\xE1-la faria o sistema apitar para quem acabou de digitar.
|
|
5363
|
+
IF NEW.direction IS DISTINCT FROM 'inbound' THEN
|
|
5364
|
+
RETURN NEW;
|
|
5365
|
+
END IF;
|
|
5366
|
+
|
|
5367
|
+
SELECT c.contact_name INTO v_nome
|
|
5368
|
+
FROM public.plg_conversations c
|
|
5369
|
+
WHERE c.id = NEW.conversation_id;
|
|
5370
|
+
|
|
5371
|
+
PERFORM realtime.send(
|
|
5372
|
+
jsonb_build_object(
|
|
5373
|
+
'conversationId', NEW.conversation_id,
|
|
5374
|
+
'channel', NEW.channel,
|
|
5375
|
+
'contactName', coalesce(v_nome, ''),
|
|
5376
|
+
-- Um trecho, n\xE3o o corpo: o suficiente para a notifica\xE7\xE3o do sistema
|
|
5377
|
+
-- dizer do que se trata sem espalhar a conversa pelo broadcast.
|
|
5378
|
+
'preview', left(coalesce(NEW.body, ''), 120),
|
|
5379
|
+
'at', NEW.at
|
|
5380
|
+
),
|
|
5381
|
+
'inbound',
|
|
5382
|
+
'conversas:' || NEW.tenant_id::text,
|
|
5383
|
+
-- Privado: exige que quem assina esteja autenticado. P\xFAblico deixaria
|
|
5384
|
+
-- qualquer um com o nome do t\xF3pico ouvir a chegada.
|
|
5385
|
+
true
|
|
5386
|
+
);
|
|
5387
|
+
RETURN NEW;
|
|
5388
|
+
END $$;
|
|
5389
|
+
|
|
5390
|
+
REVOKE ALL ON FUNCTION public.plg_conversations_anunciar_chegada() FROM public, anon, authenticated;
|
|
5391
|
+
|
|
5392
|
+
DROP TRIGGER IF EXISTS plg_conversations_anunciar_chegada ON public.plg_conversation_messages;
|
|
5393
|
+
CREATE TRIGGER plg_conversations_anunciar_chegada
|
|
5394
|
+
AFTER INSERT ON public.plg_conversation_messages
|
|
5395
|
+
FOR EACH ROW EXECUTE FUNCTION public.plg_conversations_anunciar_chegada();
|
|
5396
|
+
|
|
5397
|
+
COMMENT ON FUNCTION public.plg_conversations_anunciar_chegada() IS
|
|
5398
|
+
'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).';
|
|
5399
|
+
`;
|
|
5400
|
+
var MIGRATION_011_O_ANUNCIO_E_UMA_CAMPAINHA_NAO_UM_RECADO = `-- ---------------------------------------------------------------------------
|
|
5401
|
+
-- 011_o_anuncio_e_uma_campainha_nao_um_recado.sql
|
|
5402
|
+
--
|
|
5403
|
+
-- \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
|
|
5404
|
+
--
|
|
5405
|
+
-- A 010 anunciava num t\xF3pico PRIVADO e mandava, no an\xFAncio, o nome do contato e
|
|
5406
|
+
-- um trecho da mensagem. T\xF3pico privado exige pol\xEDtica em \`realtime.messages\` \u2014
|
|
5407
|
+
-- e essa tabela \xE9 do Supabase, dona \`supabase_realtime_admin\`, um papel que
|
|
5408
|
+
-- esta conex\xE3o n\xE3o pode assumir:
|
|
5409
|
+
--
|
|
5410
|
+
-- ERROR: 42501: permission denied to set role "supabase_realtime_admin"
|
|
5411
|
+
--
|
|
5412
|
+
-- Sem a pol\xEDtica, o servidor recusa a assinatura e nada chega. Com o t\xF3pico
|
|
5413
|
+
-- p\xFAblico, qualquer um que soubesse o nome do t\xF3pico leria o trecho da
|
|
5414
|
+
-- conversa \u2014 o que n\xE3o se faz.
|
|
5415
|
+
--
|
|
5416
|
+
-- \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
|
|
5417
|
+
--
|
|
5418
|
+
-- O an\xFAncio passa a ser uma campainha: diz que chegou algo e em que conversa,
|
|
5419
|
+
-- e mais nada. Nome e texto o navegador busca pela via de sempre \u2014 a tabela,
|
|
5420
|
+
-- com RLS, onde o cabe\xE7alho da conta existe e a pol\xEDtica funciona.
|
|
5421
|
+
--
|
|
5422
|
+
-- O t\xF3pico continua carregando o uuid da conta no nome, que \xE9 o que separa uma
|
|
5423
|
+
-- casa da outra. Ele n\xE3o \xE9 adivinh\xE1vel, e mesmo que fosse, o que se ouviria \xE9
|
|
5424
|
+
-- "chegou mensagem", sem nome nem texto. O sigilo fica onde sempre esteve: na
|
|
5425
|
+
-- leitura da tabela.
|
|
5426
|
+
--
|
|
5427
|
+
-- \xC9 uma troca deliberada de superf\xEDcie: em vez de tentar proteger um caminho
|
|
5428
|
+
-- novo, n\xE3o se p\xF5e nada sens\xEDvel nele.
|
|
5429
|
+
--
|
|
5430
|
+
-- Idempotente e replay-safe.
|
|
5431
|
+
-- ---------------------------------------------------------------------------
|
|
5432
|
+
|
|
5433
|
+
CREATE OR REPLACE FUNCTION public.plg_conversations_anunciar_chegada()
|
|
5434
|
+
RETURNS trigger
|
|
5435
|
+
LANGUAGE plpgsql
|
|
5436
|
+
SECURITY DEFINER
|
|
5437
|
+
SET search_path = ''
|
|
5438
|
+
AS $$
|
|
5439
|
+
BEGIN
|
|
5440
|
+
-- S\xF3 o que CHEGA. A resposta que o operador manda tamb\xE9m \xE9 um INSERT, e
|
|
5441
|
+
-- anunci\xE1-la faria o sistema apitar para quem acabou de digitar.
|
|
5442
|
+
IF NEW.direction IS DISTINCT FROM 'inbound' THEN
|
|
5443
|
+
RETURN NEW;
|
|
5444
|
+
END IF;
|
|
5445
|
+
|
|
5446
|
+
PERFORM realtime.send(
|
|
5447
|
+
-- S\xD3 o id da conversa. Nem nome, nem trecho, nem corpo: quem ouve vai
|
|
5448
|
+
-- buscar isso na tabela, com a pol\xEDtica de leitura no meio do caminho.
|
|
5449
|
+
-- Um uuid sozinho n\xE3o conta nada a quem n\xE3o pode ler a conversa.
|
|
5450
|
+
jsonb_build_object('conversationId', NEW.conversation_id),
|
|
5451
|
+
'inbound',
|
|
5452
|
+
'conversas:' || NEW.tenant_id::text,
|
|
5453
|
+
-- P\xDABLICO, e por isso o payload \xE9 vazio de conte\xFAdo. Privado exigiria uma
|
|
5454
|
+
-- pol\xEDtica numa tabela que n\xE3o \xE9 nossa.
|
|
5455
|
+
false
|
|
5456
|
+
);
|
|
5457
|
+
RETURN NEW;
|
|
5458
|
+
END $$;
|
|
5459
|
+
|
|
5460
|
+
COMMENT ON FUNCTION public.plg_conversations_anunciar_chegada() IS
|
|
5461
|
+
'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).';
|
|
5462
|
+
`;
|
|
5118
5463
|
var MIGRATIONS = [
|
|
5119
5464
|
{ id: "000_baseline", sql: MIGRATION_000_BASELINE },
|
|
5120
5465
|
{ id: "001_a_caixa_sabe_quem_falou_por_ultimo", sql: MIGRATION_001_A_CAIXA_SABE_QUEM_FALOU_POR_ULTIMO },
|
|
@@ -5123,7 +5468,11 @@ var MIGRATIONS = [
|
|
|
5123
5468
|
{ 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 },
|
|
5124
5469
|
{ id: "005_a_conversa_conta_ao_funil_que_houve_contato", sql: MIGRATION_005_A_CONVERSA_CONTA_AO_FUNIL_QUE_HOUVE_CONTATO },
|
|
5125
5470
|
{ id: "006_a_caixa_avisa_que_chegou", sql: MIGRATION_006_A_CAIXA_AVISA_QUE_CHEGOU },
|
|
5126
|
-
{ id: "007_a_conversa_diz_de_quem_e_o_lead", sql: MIGRATION_007_A_CONVERSA_DIZ_DE_QUEM_E_O_LEAD }
|
|
5471
|
+
{ id: "007_a_conversa_diz_de_quem_e_o_lead", sql: MIGRATION_007_A_CONVERSA_DIZ_DE_QUEM_E_O_LEAD },
|
|
5472
|
+
{ id: "008_dar_por_vista_e_uma_decisao", sql: MIGRATION_008_DAR_POR_VISTA_E_UMA_DECISAO },
|
|
5473
|
+
{ id: "009_a_mensagem_inteira_atravessa_o_realtime", sql: MIGRATION_009_A_MENSAGEM_INTEIRA_ATRAVESSA_O_REALTIME },
|
|
5474
|
+
{ id: "010_a_chegada_e_anunciada_por_conta", sql: MIGRATION_010_A_CHEGADA_E_ANUNCIADA_POR_CONTA },
|
|
5475
|
+
{ id: "011_o_anuncio_e_uma_campainha_nao_um_recado", sql: MIGRATION_011_O_ANUNCIO_E_UMA_CAMPAINHA_NAO_UM_RECADO }
|
|
5127
5476
|
];
|
|
5128
5477
|
|
|
5129
5478
|
// src/index.ts
|