@fayz-ai/plugin-conversations 0.16.1 → 0.17.1

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":"AAkGA,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,QAiFA"}
@@ -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';
@@ -386,6 +386,37 @@ var T = {
386
386
  messages: "plg_conversation_messages",
387
387
  channels: "plg_conversations_channels"
388
388
  };
389
+ var porConta = /* @__PURE__ */ new Map();
390
+ function assinarChegada(tenantId, ouvinte) {
391
+ const supabase = getSupabaseClientOptional();
392
+ if (!supabase?.channel || !tenantId) return () => {
393
+ };
394
+ let assinatura = porConta.get(tenantId);
395
+ if (!assinatura) {
396
+ const ouvintes = /* @__PURE__ */ new Set();
397
+ const canal = supabase.channel(`conversas:${tenantId}`).on("broadcast", { event: "inbound" }, (msg2) => {
398
+ const carga = { conversationId: msg2?.payload?.conversationId };
399
+ for (const f of [...ouvintes]) {
400
+ try {
401
+ f(carga);
402
+ } catch {
403
+ }
404
+ }
405
+ }).subscribe();
406
+ assinatura = { canal, ouvintes };
407
+ porConta.set(tenantId, assinatura);
408
+ }
409
+ assinatura.ouvintes.add(ouvinte);
410
+ return () => {
411
+ const a = porConta.get(tenantId);
412
+ if (!a) return;
413
+ a.ouvintes.delete(ouvinte);
414
+ if (a.ouvintes.size === 0) {
415
+ porConta.delete(tenantId);
416
+ void getSupabaseClientOptional()?.removeChannel?.(a.canal);
417
+ }
418
+ };
419
+ }
389
420
 
390
421
  // src/types.ts
391
422
  var CHANNEL_LABELS = {
@@ -397,6 +428,7 @@ var CHANNEL_LABELS = {
397
428
  };
398
429
  function isWaitingOnUs(conversation) {
399
430
  if (conversation.status !== "open") return false;
431
+ if (conversation.acknowledgedAt && conversation.lastMessageAt && conversation.acknowledgedAt >= conversation.lastMessageAt) return false;
400
432
  if (conversation.lastMessageDirection) return conversation.lastMessageDirection === "inbound";
401
433
  return conversation.unreadCount > 0;
402
434
  }
@@ -426,12 +458,39 @@ function tocar() {
426
458
  } catch {
427
459
  }
428
460
  }
461
+ async function pedirPermissao() {
462
+ if (typeof Notification === "undefined") return false;
463
+ if (Notification.permission === "granted") return true;
464
+ if (Notification.permission === "denied") return false;
465
+ try {
466
+ return await Notification.requestPermission() === "granted";
467
+ } catch {
468
+ return false;
469
+ }
470
+ }
471
+ function avisarNoSistema(titulo, corpo, tag) {
472
+ try {
473
+ if (typeof Notification === "undefined" || Notification.permission !== "granted") return;
474
+ if (typeof document !== "undefined" && document.visibilityState === "visible") return;
475
+ const n = new Notification(titulo, { body: corpo, tag, renotify: true });
476
+ n.onclick = () => {
477
+ try {
478
+ window.focus();
479
+ n.close();
480
+ } catch {
481
+ }
482
+ };
483
+ } catch {
484
+ }
485
+ }
429
486
  function InboxWatcher({ label, route = "/conversations", section = "main", position = 1 }) {
487
+ const tenantId = useActiveTenantId();
430
488
  const [naoLidas, setNaoLidas] = React11.useState(0);
431
489
  const podeTocar = React11.useRef(false);
432
490
  React11.useEffect(() => {
433
491
  const liberar = () => {
434
492
  podeTocar.current = true;
493
+ void pedirPermissao();
435
494
  };
436
495
  for (const ev of ["pointerdown", "keydown", "touchstart"]) {
437
496
  window.addEventListener(ev, liberar, { once: true, passive: true });
@@ -456,21 +515,23 @@ function InboxWatcher({ label, route = "/conversations", section = "main", posit
456
515
  void contar();
457
516
  }, [contar]);
458
517
  React11.useEffect(() => {
459
- 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();
470
- return () => {
471
- void supabase.removeChannel?.(canal);
472
- };
473
- }, [contar]);
518
+ if (!tenantId) return;
519
+ return assinarChegada(tenantId, ({ conversationId }) => {
520
+ void contar();
521
+ if (podeTocar.current) tocar();
522
+ void (async () => {
523
+ const supabase = getSupabaseClientOptional();
524
+ let titulo = label;
525
+ let corpo = "";
526
+ if (conversationId && supabase) {
527
+ const { data } = await supabase.from(T.conversations).select("contact_name, last_message_preview").eq("id", conversationId).maybeSingle();
528
+ titulo = data?.contact_name || label;
529
+ corpo = String(data?.last_message_preview || "").replace(/\s+/g, " ").slice(0, 140);
530
+ }
531
+ avisarNoSistema(titulo, corpo, String(conversationId ?? "inbox"));
532
+ })();
533
+ });
534
+ }, [contar, tenantId, label]);
474
535
  useNavContribution("conversations:unread", naoLidas > 0 ? [{ id: "conversations", label, route, badge: naoLidas, badgeTone: "destructive", section, position }] : []);
475
536
  return null;
476
537
  }
@@ -815,6 +876,7 @@ function ConversationList({ className }) {
815
876
  search,
816
877
  loading,
817
878
  select,
879
+ markRead,
818
880
  setChannelFilter,
819
881
  setLeadOwnerFilter,
820
882
  setSearch
@@ -885,48 +947,69 @@ function ConversationList({ className }) {
885
947
  conversations.map((c) => {
886
948
  const active = c.id === selectedId;
887
949
  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]
950
+ return (
951
+ // `div` em volta e não um botão dentro de outro: HTML não permite
952
+ // aninhar, e o navegador resolve isso silenciosamente movendo o
953
+ // interno para fora — a linha inteira deixa de ser clicável e
954
+ // ninguém entende por quê.
955
+ /* @__PURE__ */ jsxs("div", { className: "group relative", children: [
956
+ /* @__PURE__ */ jsxs(
957
+ "button",
958
+ {
959
+ onClick: () => select(c.id),
960
+ className: cn(
961
+ "flex w-full items-start gap-3 border-b border-border/50 px-3 py-3 text-left transition-colors",
962
+ // Um TOM da cor de marca, e não `bg-accent`.
963
+ //
964
+ // `bg-accent` resolve para quase preto neste tema, e a linha da
965
+ // conversa aberta ficava escura sobre escura. Trocar junto o
966
+ // `text-accent-foreground` deixava legível, mas pintava de
967
+ // preto a única linha que se está a ler — chamava mais atenção
968
+ // que a conversa.
969
+ //
970
+ // `bg-primary/10` é um véu: o texto continua com as cores dele,
971
+ // a seleção se lê como seleção e não como bloco, e funciona nos
972
+ // dois temas porque não presume nada sobre o fundo.
973
+ active ? "bg-primary/10" : "hover:bg-muted/50"
974
+ ),
975
+ children: [
976
+ /* @__PURE__ */ jsx(Avatar, { name: c.contactName, accent: c.accent, channel: c.channel }),
977
+ /* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
978
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2", children: [
979
+ /* @__PURE__ */ jsx("span", { className: cn("truncate text-sm text-foreground", unread ? "font-semibold" : "font-medium"), children: c.contactName }),
980
+ /* @__PURE__ */ jsx("span", { className: cn("shrink-0 text-[11px]", unread ? "font-semibold text-primary" : "text-muted-foreground"), children: relativeTime(c.lastMessageAt) })
981
+ ] }),
982
+ /* @__PURE__ */ jsxs("div", { className: "mt-1 flex items-center gap-2", children: [
983
+ /* @__PURE__ */ jsx(ChannelBadge, { channel: c.channel }),
984
+ c.status !== "open" && /* @__PURE__ */ jsx("span", { className: "text-[10px] uppercase tracking-wide text-muted-foreground/70", children: t(`conversations.status.${c.status}`) }),
985
+ 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: [
986
+ /* @__PURE__ */ jsx(User, { className: "h-2.5 w-2.5" }),
987
+ c.leadOwnerName.split(" ")[0]
988
+ ] })
989
+ ] }),
990
+ /* @__PURE__ */ jsxs("div", { className: "mt-1 flex items-center justify-between gap-2", children: [
991
+ /* @__PURE__ */ jsx("span", { className: cn("truncate text-xs", unread ? "text-foreground" : "text-muted-foreground"), children: c.lastMessagePreview }),
992
+ 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" })
993
+ ] })
920
994
  ] })
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
995
+ ]
996
+ }
997
+ ),
998
+ unread && /* @__PURE__ */ jsx(
999
+ "button",
1000
+ {
1001
+ type: "button",
1002
+ title: t("conversations.markRead"),
1003
+ "aria-label": t("conversations.markRead"),
1004
+ onClick: (e) => {
1005
+ e.stopPropagation();
1006
+ void markRead(c.id);
1007
+ },
1008
+ 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",
1009
+ children: /* @__PURE__ */ jsx(CheckCheck, { className: "h-3.5 w-3.5" })
1010
+ }
1011
+ )
1012
+ ] }, c.id)
930
1013
  );
931
1014
  })
932
1015
  ] })
@@ -1573,9 +1656,27 @@ function NewConversationPanel({
1573
1656
  }
1574
1657
  );
1575
1658
  }
1659
+ function useCaixaAoVivo() {
1660
+ const tenantId = useActiveTenantId();
1661
+ const load = useConversationsStore((s) => s.load);
1662
+ const refreshMessages = useConversationsStore((s) => s.refreshMessages);
1663
+ const selectedId = useConversationsStore((s) => s.selectedId);
1664
+ const abertoRef = React11.useRef(selectedId);
1665
+ abertoRef.current = selectedId;
1666
+ React11.useEffect(() => {
1667
+ if (!tenantId) return;
1668
+ return assinarChegada(tenantId, ({ conversationId }) => {
1669
+ void load();
1670
+ if (conversationId && conversationId === abertoRef.current) {
1671
+ void refreshMessages();
1672
+ }
1673
+ });
1674
+ }, [tenantId, load, refreshMessages]);
1675
+ }
1576
1676
  function InboxView() {
1577
1677
  const t = useTranslation();
1578
1678
  const { conversations, selectedId, deselect } = useConversationsStore((s) => s);
1679
+ useCaixaAoVivo();
1579
1680
  const isWidePanel = useMediaQuery("(min-width: 1280px)");
1580
1681
  const [panelOpen, setPanelOpen] = React11__default.useState(false);
1581
1682
  const [newOpen, setNewOpen] = React11__default.useState(false);
@@ -1954,6 +2055,7 @@ function mapConversation(r) {
1954
2055
  // which is the mistake this one was not going to repeat).
1955
2056
  lastMessageDirection: r.last_message_direction ?? void 0,
1956
2057
  unreadCount: Number(r.unread_count ?? 0),
2058
+ acknowledgedAt: r.acknowledged_at ?? null,
1957
2059
  status: r.status ?? "open",
1958
2060
  assignedTo: r.assigned_to ?? void 0,
1959
2061
  // Presentes só quando a leitura veio da view. Na escrita (que volta da
@@ -2156,7 +2258,11 @@ function createSupabaseConversationsProvider(config) {
2156
2258
  },
2157
2259
  async markRead(conversationId) {
2158
2260
  const { error } = await client4().from(T.conversations).update({
2159
- unread_count: 0
2261
+ unread_count: 0,
2262
+ // O instante é o que tira a bolinha. `unread_count` sozinho não
2263
+ // tirava — a fila olha a DIREÇÃO da última mensagem, e ela continua
2264
+ // sendo de entrada por mais que alguém leia.
2265
+ acknowledged_at: (/* @__PURE__ */ new Date()).toISOString()
2160
2266
  }).eq("id", conversationId);
2161
2267
  if (error) throw error;
2162
2268
  },
@@ -2199,13 +2305,22 @@ function createConversationsStore(provider) {
2199
2305
  set({ conversations, loading: false, selectedId });
2200
2306
  if (selectedId) await get().select(selectedId);
2201
2307
  },
2308
+ /** Dar por vista sem abrir — o caso do "ok, obrigado" que não pede resposta. */
2309
+ async markRead(id) {
2310
+ await provider.markRead(id);
2311
+ const agora = (/* @__PURE__ */ new Date()).toISOString();
2312
+ set((s) => ({
2313
+ conversations: s.conversations.map((c) => c.id === id ? { ...c, unreadCount: 0, acknowledgedAt: agora } : c)
2314
+ }));
2315
+ },
2202
2316
  async select(id) {
2203
2317
  set({ selectedId: id });
2204
2318
  const messages = await provider.getMessages(id);
2205
2319
  set({ messages });
2206
2320
  await provider.markRead(id);
2321
+ const agora = (/* @__PURE__ */ new Date()).toISOString();
2207
2322
  set((s) => ({
2208
- conversations: s.conversations.map((c) => c.id === id ? { ...c, unreadCount: 0 } : c)
2323
+ conversations: s.conversations.map((c) => c.id === id ? { ...c, unreadCount: 0, acknowledgedAt: agora } : c)
2209
2324
  }));
2210
2325
  },
2211
2326
  /** Relê as mensagens da conversa aberta, sem tocar em mais nada.
@@ -2324,6 +2439,7 @@ function createConversationsStore(provider) {
2324
2439
 
2325
2440
  // src/locales/en.ts
2326
2441
  var en = {
2442
+ "conversations.markRead": "Mark as read",
2327
2443
  "conversations.title": "Conversations",
2328
2444
  "conversations.subtitle": "Unified inbox across every channel",
2329
2445
  // Conversation list
@@ -2419,6 +2535,7 @@ var en = {
2419
2535
 
2420
2536
  // src/locales/pt-BR.ts
2421
2537
  var ptBR = {
2538
+ "conversations.markRead": "Marcar como lida",
2422
2539
  "conversations.title": "Conversas",
2423
2540
  "conversations.subtitle": "Caixa de entrada unificada de todos os canais",
2424
2541
  // Lista de conversas
@@ -5115,6 +5232,257 @@ CREATE OR REPLACE VIEW public.v_conversations WITH (security_invoker = true) AS
5115
5232
  COMMENT ON VIEW public.v_conversations IS
5116
5233
  '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
5234
  `;
5235
+ var MIGRATION_008_DAR_POR_VISTA_E_UMA_DECISAO = `-- ---------------------------------------------------------------------------
5236
+ -- 008_dar_por_vista_e_uma_decisao.sql
5237
+ --
5238
+ -- \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
5239
+ --
5240
+ -- A bolinha vermelha da caixa n\xE3o \xE9 "n\xE3o lido": \xE9 \`isWaitingOnUs\`, que olha a
5241
+ -- DIRE\xC7\xC3O da \xFAltima mensagem. Se a \xFAltima \xE9 de entrada, a conversa espera
5242
+ -- resposta \u2014 e continua esperando por mais que algu\xE9m abra e leia.
5243
+ --
5244
+ -- Isso est\xE1 certo enquanto a pergunta \xE9 "o que falta responder". Mas deixa sem
5245
+ -- sa\xEDda o caso comum: a pessoa leu, decidiu que n\xE3o cabe resposta ("ok",
5246
+ -- "obrigado", um \xE1udio que j\xE1 foi tratado por telefone) e n\xE3o tem como tirar a
5247
+ -- conversa da fila. \`unread_count = 0\` n\xE3o resolve, porque n\xE3o \xE9 ele que
5248
+ -- acende a bolinha.
5249
+ --
5250
+ -- \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
5251
+ --
5252
+ -- \`lida boolean\` exigiria algu\xE9m lembrar de apag\xE1-la quando chegasse mensagem
5253
+ -- nova \u2014 e o dia em que esquecessem, a conversa sumiria da fila com o cliente
5254
+ -- esperando. \xC9 o pior defeito poss\xEDvel numa caixa de entrada: silencioso, e do
5255
+ -- lado de perder trabalho.
5256
+ --
5257
+ -- Um INSTANTE se compara com \`last_message_at\` e se invalida sozinho: deu por
5258
+ -- vista \xE0s 14h, chegou mensagem \xE0s 15h, a conversa volta \xE0 fila sem ningu\xE9m
5259
+ -- fazer nada. A regra \xE9 uma compara\xE7\xE3o, n\xE3o um estado a manter.
5260
+ --
5261
+ -- \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
5262
+ --
5263
+ -- \`unread_count\` continua onde est\xE1. Ele conta mensagens e serve ao contador
5264
+ -- do menu; esta coluna responde outra pergunta ("algu\xE9m j\xE1 decidiu sobre
5265
+ -- isto?"). Colapsar as duas faria a contagem do menu mentir no dia em que
5266
+ -- algu\xE9m desse por vista sem ler.
5267
+ --
5268
+ -- Idempotente e replay-safe.
5269
+ -- ---------------------------------------------------------------------------
5270
+
5271
+ ALTER TABLE public.plg_conversations
5272
+ ADD COLUMN IF NOT EXISTS acknowledged_at timestamptz;
5273
+
5274
+ COMMENT ON COLUMN public.plg_conversations.acknowledged_at IS
5275
+ '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).';
5276
+
5277
+ -- A fila \xE9 lida por \`last_message_at\` e por esta coluna juntas.
5278
+ CREATE INDEX IF NOT EXISTS plg_conversations_fila_idx
5279
+ ON public.plg_conversations (tenant_id, status, last_message_at DESC)
5280
+ WHERE status = 'open';
5281
+ `;
5282
+ var MIGRATION_009_A_MENSAGEM_INTEIRA_ATRAVESSA_O_REALTIME = `-- ---------------------------------------------------------------------------
5283
+ -- 009_a_mensagem_inteira_atravessa_o_realtime.sql
5284
+ --
5285
+ -- \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
5286
+ --
5287
+ -- O vigia da caixa assinava \`plg_conversation_messages\`, recebia \`SUBSCRIBED\`
5288
+ -- do servidor, e nunca recebia um \xFAnico evento. Nenhum erro, nenhum aviso: a
5289
+ -- assinatura dizia que estava tudo bem e n\xE3o chegava nada.
5290
+ --
5291
+ -- A causa \xE9 a combina\xE7\xE3o de duas coisas que, sozinhas, est\xE3o certas:
5292
+ --
5293
+ -- 1. a tabela tem RLS ligada, como toda tabela desta base;
5294
+ -- 2. a tabela estava com \`REPLICA IDENTITY\` no padr\xE3o \u2014 s\xF3 a chave prim\xE1ria.
5295
+ --
5296
+ -- O Realtime do Supabase aplica a POL\xCDTICA DE LEITURA do usu\xE1rio a cada evento
5297
+ -- antes de entreg\xE1-lo. Para isso ele precisa da linha inteira: a pol\xEDtica fala
5298
+ -- de \`tenant_id\`, e \`tenant_id\` n\xE3o \xE9 a chave prim\xE1ria. Com s\xF3 a PK no WAL, n\xE3o
5299
+ -- h\xE1 o que avaliar \u2014 e o evento \xE9 descartado sem ru\xEDdo.
5300
+ --
5301
+ -- \`plg_conversations\` j\xE1 estava \`FULL\` (006). A tabela de MENSAGENS ficou de
5302
+ -- fora daquela migration, e \xE9 justamente ela que o vigia escuta.
5303
+ --
5304
+ -- \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
5305
+ --
5306
+ -- \`FULL\` faz o WAL carregar a linha inteira em cada UPDATE e DELETE, n\xE3o s\xF3 as
5307
+ -- colunas mudadas. Numa tabela de mensagens isso \xE9 peso real.
5308
+ --
5309
+ -- A alternativa \u2014 desligar a RLS para o Realtime enxergar \u2014 trocaria um custo
5310
+ -- de disco por um vazamento: as mensagens de uma casa chegariam ao navegador
5311
+ -- de outra. Entre pagar WAL e vazar conversa, paga-se o WAL.
5312
+ --
5313
+ -- Idempotente e replay-safe.
5314
+ -- ---------------------------------------------------------------------------
5315
+
5316
+ ALTER TABLE public.plg_conversation_messages REPLICA IDENTITY FULL;
5317
+
5318
+ -- Garante a publica\xE7\xE3o tamb\xE9m \u2014 uma tabela com identidade cheia e fora da
5319
+ -- publica\xE7\xE3o continua sem emitir nada, e o sintoma seria id\xEAntico.
5320
+ DO $$
5321
+ BEGIN
5322
+ IF NOT EXISTS (
5323
+ SELECT 1 FROM pg_publication_tables
5324
+ WHERE pubname = 'supabase_realtime'
5325
+ AND schemaname = 'public'
5326
+ AND tablename = 'plg_conversation_messages'
5327
+ ) THEN
5328
+ EXECUTE 'ALTER PUBLICATION supabase_realtime ADD TABLE public.plg_conversation_messages';
5329
+ END IF;
5330
+ END $$;
5331
+ `;
5332
+ var MIGRATION_010_A_CHEGADA_E_ANUNCIADA_POR_CONTA = `-- ---------------------------------------------------------------------------
5333
+ -- 010_a_chegada_e_anunciada_por_conta.sql
5334
+ --
5335
+ -- \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
5336
+ --
5337
+ -- O vigia assinava a TABELA de mensagens e recebia \`SUBSCRIBED\` do servidor.
5338
+ -- Nenhum evento chegava. Nenhum erro, nenhum aviso \u2014 a assinatura dizia que
5339
+ -- estava tudo bem.
5340
+ --
5341
+ -- O Realtime aplica a pol\xEDtica de leitura a cada evento antes de entreg\xE1-lo. A
5342
+ -- pol\xEDtica desta base resolve o tenant por, nesta ordem:
5343
+ --
5344
+ -- \u2460 a claim \`tenant_id\` do JWT
5345
+ -- \u2461 o CABE\xC7ALHO da requisi\xE7\xE3o (\`app.requested_tenant()\`)
5346
+ -- \u2462 a filia\xE7\xE3o \xFAnica, quando a pessoa s\xF3 tem uma
5347
+ --
5348
+ -- O Realtime manda o JWT e N\xC3O manda cabe\xE7alho. E o JWT desta casa n\xE3o carrega
5349
+ -- \`tenant_id\` \u2014 a conta ativa \xE9 escolhida por cabe\xE7alho, porque a mesma pessoa
5350
+ -- atende sete contas. Sobra \u2462, que devolve nulo para quem tem mais de uma.
5351
+ --
5352
+ -- Resultado: a pol\xEDtica recusa, o evento \xE9 descartado em sil\xEAncio, e o defeito
5353
+ -- parece "o realtime n\xE3o funciona" quando na verdade \xE9 "o realtime n\xE3o sabe em
5354
+ -- que conta voc\xEA est\xE1".
5355
+ --
5356
+ -- \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
5357
+ --
5358
+ -- Broadcast n\xE3o passa pela RLS da tabela: o servidor entrega a quem est\xE1 no
5359
+ -- T\xD3PICO. Ent\xE3o o t\xF3pico carrega a conta no nome \u2014 \`conversas:<tenant>\` \u2014 e
5360
+ -- quem escuta j\xE1 teve de saber em que conta est\xE1 para montar o nome.
5361
+ --
5362
+ -- Isso inverte a depend\xEAncia de forma saud\xE1vel: em vez de o banco adivinhar o
5363
+ -- contexto do navegador, o navegador declara o contexto que j\xE1 tem.
5364
+ --
5365
+ -- \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
5366
+ --
5367
+ -- O M\xCDNIMO para decidir: id da conversa, canal, e um trecho. N\xE3o a mensagem
5368
+ -- inteira \u2014 um broadcast \xE9 entregue a todos do t\xF3pico, e mandar o corpo
5369
+ -- completo por ali seria espalhar conversa por um caminho que n\xE3o \xE9 o que
5370
+ -- guarda conversa. Quem quiser o texto l\xEA a tabela, com RLS, como sempre.
5371
+ --
5372
+ -- Idempotente e replay-safe.
5373
+ -- ---------------------------------------------------------------------------
5374
+
5375
+ CREATE OR REPLACE FUNCTION public.plg_conversations_anunciar_chegada()
5376
+ RETURNS trigger
5377
+ LANGUAGE plpgsql
5378
+ SECURITY DEFINER
5379
+ SET search_path = ''
5380
+ AS $$
5381
+ DECLARE
5382
+ v_nome text;
5383
+ BEGIN
5384
+ -- S\xF3 o que CHEGA. A resposta que o operador manda tamb\xE9m \xE9 um INSERT, e
5385
+ -- anunci\xE1-la faria o sistema apitar para quem acabou de digitar.
5386
+ IF NEW.direction IS DISTINCT FROM 'inbound' THEN
5387
+ RETURN NEW;
5388
+ END IF;
5389
+
5390
+ SELECT c.contact_name INTO v_nome
5391
+ FROM public.plg_conversations c
5392
+ WHERE c.id = NEW.conversation_id;
5393
+
5394
+ PERFORM realtime.send(
5395
+ jsonb_build_object(
5396
+ 'conversationId', NEW.conversation_id,
5397
+ 'channel', NEW.channel,
5398
+ 'contactName', coalesce(v_nome, ''),
5399
+ -- Um trecho, n\xE3o o corpo: o suficiente para a notifica\xE7\xE3o do sistema
5400
+ -- dizer do que se trata sem espalhar a conversa pelo broadcast.
5401
+ 'preview', left(coalesce(NEW.body, ''), 120),
5402
+ 'at', NEW.at
5403
+ ),
5404
+ 'inbound',
5405
+ 'conversas:' || NEW.tenant_id::text,
5406
+ -- Privado: exige que quem assina esteja autenticado. P\xFAblico deixaria
5407
+ -- qualquer um com o nome do t\xF3pico ouvir a chegada.
5408
+ true
5409
+ );
5410
+ RETURN NEW;
5411
+ END $$;
5412
+
5413
+ REVOKE ALL ON FUNCTION public.plg_conversations_anunciar_chegada() FROM public, anon, authenticated;
5414
+
5415
+ DROP TRIGGER IF EXISTS plg_conversations_anunciar_chegada ON public.plg_conversation_messages;
5416
+ CREATE TRIGGER plg_conversations_anunciar_chegada
5417
+ AFTER INSERT ON public.plg_conversation_messages
5418
+ FOR EACH ROW EXECUTE FUNCTION public.plg_conversations_anunciar_chegada();
5419
+
5420
+ COMMENT ON FUNCTION public.plg_conversations_anunciar_chegada() IS
5421
+ '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).';
5422
+ `;
5423
+ var MIGRATION_011_O_ANUNCIO_E_UMA_CAMPAINHA_NAO_UM_RECADO = `-- ---------------------------------------------------------------------------
5424
+ -- 011_o_anuncio_e_uma_campainha_nao_um_recado.sql
5425
+ --
5426
+ -- \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
5427
+ --
5428
+ -- A 010 anunciava num t\xF3pico PRIVADO e mandava, no an\xFAncio, o nome do contato e
5429
+ -- um trecho da mensagem. T\xF3pico privado exige pol\xEDtica em \`realtime.messages\` \u2014
5430
+ -- e essa tabela \xE9 do Supabase, dona \`supabase_realtime_admin\`, um papel que
5431
+ -- esta conex\xE3o n\xE3o pode assumir:
5432
+ --
5433
+ -- ERROR: 42501: permission denied to set role "supabase_realtime_admin"
5434
+ --
5435
+ -- Sem a pol\xEDtica, o servidor recusa a assinatura e nada chega. Com o t\xF3pico
5436
+ -- p\xFAblico, qualquer um que soubesse o nome do t\xF3pico leria o trecho da
5437
+ -- conversa \u2014 o que n\xE3o se faz.
5438
+ --
5439
+ -- \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
5440
+ --
5441
+ -- O an\xFAncio passa a ser uma campainha: diz que chegou algo e em que conversa,
5442
+ -- e mais nada. Nome e texto o navegador busca pela via de sempre \u2014 a tabela,
5443
+ -- com RLS, onde o cabe\xE7alho da conta existe e a pol\xEDtica funciona.
5444
+ --
5445
+ -- O t\xF3pico continua carregando o uuid da conta no nome, que \xE9 o que separa uma
5446
+ -- casa da outra. Ele n\xE3o \xE9 adivinh\xE1vel, e mesmo que fosse, o que se ouviria \xE9
5447
+ -- "chegou mensagem", sem nome nem texto. O sigilo fica onde sempre esteve: na
5448
+ -- leitura da tabela.
5449
+ --
5450
+ -- \xC9 uma troca deliberada de superf\xEDcie: em vez de tentar proteger um caminho
5451
+ -- novo, n\xE3o se p\xF5e nada sens\xEDvel nele.
5452
+ --
5453
+ -- Idempotente e replay-safe.
5454
+ -- ---------------------------------------------------------------------------
5455
+
5456
+ CREATE OR REPLACE FUNCTION public.plg_conversations_anunciar_chegada()
5457
+ RETURNS trigger
5458
+ LANGUAGE plpgsql
5459
+ SECURITY DEFINER
5460
+ SET search_path = ''
5461
+ AS $$
5462
+ BEGIN
5463
+ -- S\xF3 o que CHEGA. A resposta que o operador manda tamb\xE9m \xE9 um INSERT, e
5464
+ -- anunci\xE1-la faria o sistema apitar para quem acabou de digitar.
5465
+ IF NEW.direction IS DISTINCT FROM 'inbound' THEN
5466
+ RETURN NEW;
5467
+ END IF;
5468
+
5469
+ PERFORM realtime.send(
5470
+ -- S\xD3 o id da conversa. Nem nome, nem trecho, nem corpo: quem ouve vai
5471
+ -- buscar isso na tabela, com a pol\xEDtica de leitura no meio do caminho.
5472
+ -- Um uuid sozinho n\xE3o conta nada a quem n\xE3o pode ler a conversa.
5473
+ jsonb_build_object('conversationId', NEW.conversation_id),
5474
+ 'inbound',
5475
+ 'conversas:' || NEW.tenant_id::text,
5476
+ -- P\xDABLICO, e por isso o payload \xE9 vazio de conte\xFAdo. Privado exigiria uma
5477
+ -- pol\xEDtica numa tabela que n\xE3o \xE9 nossa.
5478
+ false
5479
+ );
5480
+ RETURN NEW;
5481
+ END $$;
5482
+
5483
+ COMMENT ON FUNCTION public.plg_conversations_anunciar_chegada() IS
5484
+ '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).';
5485
+ `;
5118
5486
  var MIGRATIONS = [
5119
5487
  { id: "000_baseline", sql: MIGRATION_000_BASELINE },
5120
5488
  { id: "001_a_caixa_sabe_quem_falou_por_ultimo", sql: MIGRATION_001_A_CAIXA_SABE_QUEM_FALOU_POR_ULTIMO },
@@ -5123,7 +5491,11 @@ var MIGRATIONS = [
5123
5491
  { 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
5492
  { id: "005_a_conversa_conta_ao_funil_que_houve_contato", sql: MIGRATION_005_A_CONVERSA_CONTA_AO_FUNIL_QUE_HOUVE_CONTATO },
5125
5493
  { 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 }
5494
+ { id: "007_a_conversa_diz_de_quem_e_o_lead", sql: MIGRATION_007_A_CONVERSA_DIZ_DE_QUEM_E_O_LEAD },
5495
+ { id: "008_dar_por_vista_e_uma_decisao", sql: MIGRATION_008_DAR_POR_VISTA_E_UMA_DECISAO },
5496
+ { id: "009_a_mensagem_inteira_atravessa_o_realtime", sql: MIGRATION_009_A_MENSAGEM_INTEIRA_ATRAVESSA_O_REALTIME },
5497
+ { id: "010_a_chegada_e_anunciada_por_conta", sql: MIGRATION_010_A_CHEGADA_E_ANUNCIADA_POR_CONTA },
5498
+ { id: "011_o_anuncio_e_uma_campainha_nao_um_recado", sql: MIGRATION_011_O_ANUNCIO_E_UMA_CAMPAINHA_NAO_UM_RECADO }
5127
5499
  ];
5128
5500
 
5129
5501
  // src/index.ts