@fayz-ai/plugin-conversations 0.16.1 → 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.
@@ -1 +1 @@
1
- {"version":3,"file":"InboxWatcher.d.ts","sourceRoot":"","sources":["../../src/components/InboxWatcher.tsx"],"names":[],"mappings":"AAiEA,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,QA8DA"}
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;AA6CD,wBAAgB,mCAAmC,CACjD,MAAM,CAAC,EAAE,2BAA2B,GACnC,qBAAqB,CAmRvB"}
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, CheckCheck, Snowflake, Sun, Flame, Building2 } from 'lucide-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, 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("conversas:entrada").on(
462
- "postgres_changes",
463
- { event: "INSERT", schema: "public", table: T.messages },
464
- (payload) => {
465
- if (payload?.new?.direction !== "inbound") return;
466
- void contar();
467
- if (podeTocar.current) tocar();
468
- }
469
- ).subscribe();
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 /* @__PURE__ */ jsxs(
889
- "button",
890
- {
891
- onClick: () => select(c.id),
892
- className: cn(
893
- "flex w-full items-start gap-3 border-b border-border/50 px-3 py-3 text-left transition-colors",
894
- // Um TOM da cor de marca, e não `bg-accent`.
895
- //
896
- // `bg-accent` resolve para quase preto neste tema, e a linha da
897
- // conversa aberta ficava escura sobre escura. Trocar junto o
898
- // `text-accent-foreground` deixava legível, mas pintava de
899
- // preto a única linha que se está a ler — chamava mais atenção
900
- // que a conversa.
901
- //
902
- // `bg-primary/10` é um véu: o texto continua com as cores dele,
903
- // a seleção se lê como seleção e não como bloco, e funciona nos
904
- // dois temas porque não presume nada sobre o fundo.
905
- active ? "bg-primary/10" : "hover:bg-muted/50"
906
- ),
907
- children: [
908
- /* @__PURE__ */ jsx(Avatar, { name: c.contactName, accent: c.accent, channel: c.channel }),
909
- /* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
910
- /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2", children: [
911
- /* @__PURE__ */ jsx("span", { className: cn("truncate text-sm text-foreground", unread ? "font-semibold" : "font-medium"), children: c.contactName }),
912
- /* @__PURE__ */ jsx("span", { className: cn("shrink-0 text-[11px]", unread ? "font-semibold text-primary" : "text-muted-foreground"), children: relativeTime(c.lastMessageAt) })
913
- ] }),
914
- /* @__PURE__ */ jsxs("div", { className: "mt-1 flex items-center gap-2", children: [
915
- /* @__PURE__ */ jsx(ChannelBadge, { channel: c.channel }),
916
- c.status !== "open" && /* @__PURE__ */ jsx("span", { className: "text-[10px] uppercase tracking-wide text-muted-foreground/70", children: t(`conversations.status.${c.status}`) }),
917
- 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: [
918
- /* @__PURE__ */ jsx(User, { className: "h-2.5 w-2.5" }),
919
- c.leadOwnerName.split(" ")[0]
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
- /* @__PURE__ */ jsxs("div", { className: "mt-1 flex items-center justify-between gap-2", children: [
923
- /* @__PURE__ */ jsx("span", { className: cn("truncate text-xs", unread ? "text-foreground" : "text-muted-foreground"), children: c.lastMessagePreview }),
924
- 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" })
925
- ] })
926
- ] })
927
- ]
928
- },
929
- c.id
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