@fayz-ai/plugin-conversations 0.11.2 → 0.11.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,18 +1,19 @@
1
- import React4, { useState, useRef, useEffect, useCallback } from 'react';
2
- import { createConnectionStore, connectionRuns, connectionStatus, getSupabaseClientOptional, useActiveTenantId, registerTranslations, getActiveTenantId, CONNECTOR_RUNTIME_TOKEN_HEADER, connectorRuntimeToken, useTranslation } from '@fayz-ai/core';
1
+ import * as React4 from 'react';
2
+ import React4__default, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
3
+ import { createConnectionStore, connectionRuns, connectionStatus, getSupabaseClientOptional, useActiveTenantId, registerTranslations, useTranslation, getActiveTenantId, countByTenant, CONNECTOR_RUNTIME_TOKEN_HEADER, connectorRuntimeToken, errorMessage } from '@fayz-ai/core';
3
4
  import { useStore } from 'zustand';
4
5
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
5
6
  import { Loader2, ExternalLink, SquarePen, MessageSquare, Globe, Mail, Instagram, Phone, Search, Inbox, ChevronLeft, Clock, Archive, PanelRight, Send, X, User, MapPin, Tag, StickyNote, Link2 } from 'lucide-react';
6
- import { Input, Button, toast, PageHeaderActions, cn, Skeleton } from '@fayz-ai/ui';
7
- import { PermissionGate, useLimitGuard, RightRailPage, ContactPicker, invalidateLimit } from '@fayz-ai/admin';
7
+ import { Input, Button, toast, defineKpiWidget, defineTableWidget, PageHeaderActions, cn, Badge, Skeleton, KpiCard, TableWidget } from '@fayz-ai/ui';
8
+ import { PluginSettingsPanel, SettingsGroup, PermissionGate, useLimitGuard, RightRailPage, ContactPicker, invalidateLimit } from '@fayz-ai/admin';
8
9
  import { createStore } from 'zustand/vanilla';
9
10
 
10
11
  // src/index.ts
11
12
  var DEFAULT_CONVERSATIONS_CONFIG = {
12
13
  contactKind: "contact"
13
14
  };
14
- var StoreContext = React4.createContext(null);
15
- var ConfigContext = React4.createContext(DEFAULT_CONVERSATIONS_CONFIG);
15
+ var StoreContext = React4__default.createContext(null);
16
+ var ConfigContext = React4__default.createContext(DEFAULT_CONVERSATIONS_CONFIG);
16
17
  function ConversationsContextProvider({
17
18
  store: store2,
18
19
  config = DEFAULT_CONVERSATIONS_CONFIG,
@@ -21,12 +22,12 @@ function ConversationsContextProvider({
21
22
  return /* @__PURE__ */ jsx(StoreContext.Provider, { value: store2, children: /* @__PURE__ */ jsx(ConfigContext.Provider, { value: config, children }) });
22
23
  }
23
24
  function useConversationsStore(selector) {
24
- const store2 = React4.useContext(StoreContext);
25
+ const store2 = React4__default.useContext(StoreContext);
25
26
  if (!store2) throw new Error("useConversationsStore must be used within ConversationsPage");
26
27
  return useStore(store2, selector);
27
28
  }
28
29
  function useConversationsConfig() {
29
- return React4.useContext(ConfigContext);
30
+ return React4__default.useContext(ConfigContext);
30
31
  }
31
32
 
32
33
  // src/types.ts
@@ -37,6 +38,11 @@ var CHANNEL_LABELS = {
37
38
  email: "Email",
38
39
  webchat: "Web Chat"
39
40
  };
41
+ function isWaitingOnUs(conversation) {
42
+ if (conversation.status !== "open") return false;
43
+ if (conversation.lastMessageDirection) return conversation.lastMessageDirection === "inbound";
44
+ return conversation.unreadCount > 0;
45
+ }
40
46
 
41
47
  // src/lib/channel.ts
42
48
  var CHANNEL_ICON = {
@@ -54,10 +60,10 @@ var CHANNEL_ACCENT = {
54
60
  webchat: { color: "#f59e0b", badge: "bg-[#f59e0b]/12 text-[#b45309] dark:text-[#fcd34d]" }
55
61
  };
56
62
  function useMediaQuery(query) {
57
- const [matches, setMatches] = React4.useState(
63
+ const [matches, setMatches] = React4__default.useState(
58
64
  () => typeof window !== "undefined" && window.matchMedia(query).matches
59
65
  );
60
- React4.useEffect(() => {
66
+ React4__default.useEffect(() => {
61
67
  if (typeof window === "undefined") return;
62
68
  const mql = window.matchMedia(query);
63
69
  const onChange = () => setMatches(mql.matches);
@@ -241,9 +247,9 @@ function buildRows(messages) {
241
247
  function MessageThread({ selected, onTogglePanel, panelOpen, onBack, className }) {
242
248
  const t = useTranslation();
243
249
  const { messages, sending, send, setStatus } = useConversationsStore((s) => s);
244
- const [draft, setDraft] = React4.useState("");
245
- const threadRef = React4.useRef(null);
246
- React4.useEffect(() => {
250
+ const [draft, setDraft] = React4__default.useState("");
251
+ const threadRef = React4__default.useRef(null);
252
+ React4__default.useEffect(() => {
247
253
  threadRef.current?.scrollTo({ top: threadRef.current.scrollHeight, behavior: "smooth" });
248
254
  }, [messages.length, selected.id]);
249
255
  async function handleSend() {
@@ -253,7 +259,7 @@ function MessageThread({ selected, onTogglePanel, panelOpen, onBack, className }
253
259
  await send(body);
254
260
  }
255
261
  const accent = CHANNEL_ACCENT[selected.channel];
256
- const rows = React4.useMemo(() => buildRows(messages), [messages]);
262
+ const rows = React4__default.useMemo(() => buildRows(messages), [messages]);
257
263
  return /* @__PURE__ */ jsxs("section", { className: cn("flex min-w-0 flex-1 flex-col bg-muted/20", className), children: [
258
264
  /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2 border-b border-border bg-card px-3 py-2.5 md:px-5", children: [
259
265
  /* @__PURE__ */ jsxs("div", { className: "flex min-w-0 items-center gap-2 md:gap-3", children: [
@@ -422,14 +428,14 @@ function NewConversationPanel({
422
428
  const create = useConversationsStore((s) => s.create);
423
429
  const config = useConversationsConfig();
424
430
  const guardConversations = useLimitGuard("conversations_month");
425
- const [channel, setChannel] = React4.useState("whatsapp");
426
- const [contact, setContact] = React4.useState(null);
427
- const [typedHandle, setTypedHandle] = React4.useState("");
428
- const [creatingContact, setCreatingContact] = React4.useState(false);
429
- const [firstMessage, setFirstMessage] = React4.useState("");
430
- const [submitting, setSubmitting] = React4.useState(false);
431
- const [pickerKey, setPickerKey] = React4.useState(0);
432
- React4.useEffect(() => {
431
+ const [channel, setChannel] = React4__default.useState("whatsapp");
432
+ const [contact, setContact] = React4__default.useState(null);
433
+ const [typedHandle, setTypedHandle] = React4__default.useState("");
434
+ const [creatingContact, setCreatingContact] = React4__default.useState(false);
435
+ const [firstMessage, setFirstMessage] = React4__default.useState("");
436
+ const [submitting, setSubmitting] = React4__default.useState(false);
437
+ const [pickerKey, setPickerKey] = React4__default.useState(0);
438
+ React4__default.useEffect(() => {
433
439
  if (open) {
434
440
  setChannel("whatsapp");
435
441
  setContact(null);
@@ -460,7 +466,7 @@ function NewConversationPanel({
460
466
  invalidateLimit("conversations_month");
461
467
  onOpenChange(false);
462
468
  } catch (err) {
463
- const message = err instanceof Error ? err.message : String(err);
469
+ const message = errorMessage(err);
464
470
  toast.error(t("conversations.new.createFailed"), { description: message });
465
471
  } finally {
466
472
  setSubmitting(false);
@@ -547,10 +553,10 @@ function InboxView() {
547
553
  const t = useTranslation();
548
554
  const { conversations, selectedId, deselect } = useConversationsStore((s) => s);
549
555
  const isWidePanel = useMediaQuery("(min-width: 1280px)");
550
- const [panelOpen, setPanelOpen] = React4.useState(false);
551
- const [newOpen, setNewOpen] = React4.useState(false);
556
+ const [panelOpen, setPanelOpen] = React4__default.useState(false);
557
+ const [newOpen, setNewOpen] = React4__default.useState(false);
552
558
  const selected = conversations.find((c) => c.id === selectedId) ?? null;
553
- React4.useEffect(() => {
559
+ React4__default.useEffect(() => {
554
560
  setPanelOpen(isWidePanel);
555
561
  }, [isWidePanel]);
556
562
  return /* @__PURE__ */ jsxs(Fragment, { children: [
@@ -605,7 +611,7 @@ function InboxView() {
605
611
  ] });
606
612
  }
607
613
  function ConversationsPage({ store: store2, config }) {
608
- React4.useEffect(() => {
614
+ React4__default.useEffect(() => {
609
615
  void store2.getState().load();
610
616
  }, []);
611
617
  return /* @__PURE__ */ jsx(ConversationsContextProvider, { store: store2, config, children: /* @__PURE__ */ jsx(InboxView, {}) });
@@ -634,6 +640,7 @@ function seed() {
634
640
  channel: "whatsapp",
635
641
  lastMessagePreview: "Perfect, can we book for Friday at 3pm?",
636
642
  lastMessageAt: minutesAgo(base, 4),
643
+ lastMessageDirection: "inbound",
637
644
  unreadCount: 2,
638
645
  status: "open",
639
646
  assignedTo: "You",
@@ -649,6 +656,7 @@ function seed() {
649
656
  channel: "sms",
650
657
  lastMessagePreview: "Got it \u2014 sending the deposit now.",
651
658
  lastMessageAt: minutesAgo(base, 22),
659
+ lastMessageDirection: "inbound",
652
660
  unreadCount: 0,
653
661
  status: "open",
654
662
  assignedTo: "You",
@@ -663,6 +671,7 @@ function seed() {
663
671
  channel: "instagram",
664
672
  lastMessagePreview: "Do you offer balayage on weekends?",
665
673
  lastMessageAt: minutesAgo(base, 51),
674
+ lastMessageDirection: "inbound",
666
675
  unreadCount: 1,
667
676
  status: "open",
668
677
  accent: "#ec4899",
@@ -675,6 +684,9 @@ function seed() {
675
684
  channel: "email",
676
685
  lastMessagePreview: "Re: Proposal \u2014 looks great, one question on pricing\u2026",
677
686
  lastMessageAt: minutesAgo(base, 95),
687
+ // Read, never answered — zero unread and a customer still waiting. The
688
+ // exact case `unreadCount` alone gets wrong.
689
+ lastMessageDirection: "inbound",
678
690
  unreadCount: 0,
679
691
  status: "open",
680
692
  assignedTo: "Sofia",
@@ -690,6 +702,7 @@ function seed() {
690
702
  channel: "webchat",
691
703
  lastMessagePreview: "Is anyone available to chat?",
692
704
  lastMessageAt: minutesAgo(base, 140),
705
+ lastMessageDirection: "inbound",
693
706
  unreadCount: 0,
694
707
  status: "snoozed",
695
708
  accent: "#f59e0b",
@@ -702,6 +715,7 @@ function seed() {
702
715
  channel: "whatsapp",
703
716
  lastMessagePreview: "Thank you! See you next week \u{1F64C}",
704
717
  lastMessageAt: minutesAgo(base, 1440),
718
+ lastMessageDirection: "outbound",
705
719
  unreadCount: 0,
706
720
  status: "closed",
707
721
  assignedTo: "You",
@@ -807,6 +821,10 @@ function createMockConversationsProvider(config) {
807
821
  channel: input.channel,
808
822
  lastMessagePreview: firstMessage ?? "",
809
823
  lastMessageAt: now,
824
+ // A thread the house opened is not waiting on the house. Left undefined
825
+ // when there is no first message: nobody has spoken, so there is no
826
+ // last speaker to name, and `isWaitingOnUs` falls back to unread (0).
827
+ lastMessageDirection: firstMessage ? "outbound" : void 0,
810
828
  unreadCount: 0,
811
829
  status: "open",
812
830
  assignedTo: selfAuthor,
@@ -844,6 +862,7 @@ function createMockConversationsProvider(config) {
844
862
  if (conv) {
845
863
  conv.lastMessagePreview = input.body;
846
864
  conv.lastMessageAt = created.at;
865
+ conv.lastMessageDirection = "outbound";
847
866
  conv.unreadCount = 0;
848
867
  if (conv.status === "closed") conv.status = "open";
849
868
  }
@@ -884,6 +903,10 @@ function mapConversation(r) {
884
903
  channel: r.channel ?? "sms",
885
904
  lastMessagePreview: r.last_message_preview ?? "",
886
905
  lastMessageAt: r.last_message_at ?? "",
906
+ // Kept current by the trigger in migration 001, never written from here:
907
+ // the column has one writer on purpose (last_message_preview/at have five,
908
+ // which is the mistake this one was not going to repeat).
909
+ lastMessageDirection: r.last_message_direction ?? void 0,
887
910
  unreadCount: Number(r.unread_count ?? 0),
888
911
  status: r.status ?? "open",
889
912
  assignedTo: r.assigned_to ?? void 0,
@@ -1190,7 +1213,36 @@ var en = {
1190
1213
  "conversations.new.cancel": "Cancel",
1191
1214
  "conversations.new.create": "Start conversation",
1192
1215
  "conversations.new.creating": "Starting\u2026",
1193
- "conversations.new.createFailed": "Could not create the conversation"
1216
+ "conversations.new.createFailed": "Could not create the conversation",
1217
+ // Dashboard — the queue of people waiting on a reply
1218
+ "conversations.dashboard.waiting": "Waiting on you",
1219
+ "conversations.dashboard.waitingOldest": "Oldest waiting {{time}}",
1220
+ "conversations.dashboard.waitingNone": "Nobody waiting",
1221
+ "conversations.dashboard.openThreads": "Open conversations",
1222
+ "conversations.dashboard.openThreadsSub": "{{count}} waiting on a reply",
1223
+ "conversations.dashboard.needsYou": "Needs you",
1224
+ "conversations.dashboard.needsYouEmpty": "Nobody waiting. Inbox is clear.",
1225
+ "conversations.dashboard.who": "Who",
1226
+ "conversations.dashboard.waited": "Waiting",
1227
+ "conversations.waited.minutes": "{{count}} min",
1228
+ "conversations.waited.hours": "{{count}} h",
1229
+ "conversations.waited.days": "{{count}} d",
1230
+ // Inbox settings
1231
+ "conversations.settings.title": "Conversations",
1232
+ "conversations.settings.channels": "Your numbers",
1233
+ "conversations.settings.channelsHelp": "Where messages come in and go out. Connect and manage channels in the Integrations tab.",
1234
+ "conversations.settings.channelsEmpty": "No channel connected yet \u2014 until then the inbox stays empty. Connect WhatsApp in the Integrations tab.",
1235
+ "conversations.settings.channelsUnavailable": "Could not load your channels right now.",
1236
+ "conversations.settings.channelDedicated": "Your own number",
1237
+ "conversations.settings.channelShared": "Platform number",
1238
+ "conversations.settings.status.active": "Active",
1239
+ "conversations.settings.status.requested": "Requested",
1240
+ "conversations.settings.status.provisioning": "Provisioning",
1241
+ "conversations.settings.status.provisioned": "Provisioned",
1242
+ "conversations.settings.status.verifying": "Verifying",
1243
+ "conversations.settings.status.failed": "Failed",
1244
+ "conversations.settings.status.released": "Released",
1245
+ "conversations.settings.status.disconnected": "Disconnected"
1194
1246
  };
1195
1247
 
1196
1248
  // src/locales/pt-BR.ts
@@ -1253,7 +1305,36 @@ var ptBR = {
1253
1305
  "conversations.new.cancel": "Cancelar",
1254
1306
  "conversations.new.create": "Iniciar conversa",
1255
1307
  "conversations.new.creating": "Iniciando\u2026",
1256
- "conversations.new.createFailed": "N\xE3o foi poss\xEDvel criar a conversa"
1308
+ "conversations.new.createFailed": "N\xE3o foi poss\xEDvel criar a conversa",
1309
+ // Painel — a fila de quem está esperando resposta
1310
+ "conversations.dashboard.waiting": "Esperando resposta",
1311
+ "conversations.dashboard.waitingOldest": "A mais antiga h\xE1 {{time}}",
1312
+ "conversations.dashboard.waitingNone": "Ningu\xE9m na fila",
1313
+ "conversations.dashboard.openThreads": "Conversas abertas",
1314
+ "conversations.dashboard.openThreadsSub": "{{count}} esperando resposta",
1315
+ "conversations.dashboard.needsYou": "Precisa de voc\xEA",
1316
+ "conversations.dashboard.needsYouEmpty": "Ningu\xE9m esperando. Caixa de entrada em dia.",
1317
+ "conversations.dashboard.who": "Quem",
1318
+ "conversations.dashboard.waited": "Esperando h\xE1",
1319
+ "conversations.waited.minutes": "{{count}} min",
1320
+ "conversations.waited.hours": "{{count}} h",
1321
+ "conversations.waited.days": "{{count}} d",
1322
+ // Configurações da caixa de entrada
1323
+ "conversations.settings.title": "Conversas",
1324
+ "conversations.settings.channels": "Seus n\xFAmeros",
1325
+ "conversations.settings.channelsHelp": "Por onde a mensagem entra e sai. Ligue e gerencie os canais na aba Integra\xE7\xF5es.",
1326
+ "conversations.settings.channelsEmpty": "Nenhum canal ligado ainda \u2014 enquanto isso, a caixa de entrada fica vazia. Ligue o WhatsApp na aba Integra\xE7\xF5es.",
1327
+ "conversations.settings.channelsUnavailable": "N\xE3o foi poss\xEDvel consultar seus canais agora.",
1328
+ "conversations.settings.channelDedicated": "N\xFAmero s\xF3 seu",
1329
+ "conversations.settings.channelShared": "N\xFAmero da plataforma",
1330
+ "conversations.settings.status.active": "Ativo",
1331
+ "conversations.settings.status.requested": "Solicitado",
1332
+ "conversations.settings.status.provisioning": "Provisionando",
1333
+ "conversations.settings.status.provisioned": "Provisionado",
1334
+ "conversations.settings.status.verifying": "Verificando",
1335
+ "conversations.settings.status.failed": "Falhou",
1336
+ "conversations.settings.status.released": "Devolvido",
1337
+ "conversations.settings.status.disconnected": "Desconectado"
1257
1338
  };
1258
1339
 
1259
1340
  // src/locales/index.ts
@@ -1261,6 +1342,142 @@ var conversationsLocales = {
1261
1342
  en,
1262
1343
  "pt-BR": ptBR
1263
1344
  };
1345
+ function useEnsureConversations() {
1346
+ const load = useConversationsStore((s) => s.load);
1347
+ const loaded = useConversationsStore((s) => s.conversations.length > 0);
1348
+ useEffect(() => {
1349
+ if (!loaded) void load();
1350
+ }, []);
1351
+ }
1352
+ function useWaiting() {
1353
+ useEnsureConversations();
1354
+ const conversations = useConversationsStore((s) => s.conversations);
1355
+ const loading = useConversationsStore((s) => s.loading);
1356
+ return useMemo(() => {
1357
+ const open = conversations.filter((c) => c.status === "open");
1358
+ const waiting = open.filter(isWaitingOnUs).sort((a, b) => Date.parse(a.lastMessageAt) - Date.parse(b.lastMessageAt));
1359
+ return { waiting, open: open.length, loading };
1360
+ }, [conversations, loading]);
1361
+ }
1362
+ function waitedFor(iso, t) {
1363
+ const ms = Date.now() - Date.parse(iso);
1364
+ if (!Number.isFinite(ms) || ms < 0) return "\u2014";
1365
+ const minutes = Math.floor(ms / 6e4);
1366
+ if (minutes < 60) return t("conversations.waited.minutes", { count: String(Math.max(minutes, 1)) });
1367
+ const hours = Math.floor(minutes / 60);
1368
+ if (hours < 24) return t("conversations.waited.hours", { count: String(hours) });
1369
+ return t("conversations.waited.days", { count: String(Math.floor(hours / 24)) });
1370
+ }
1371
+ function WaitingKpi() {
1372
+ const t = useTranslation();
1373
+ const { waiting, loading } = useWaiting();
1374
+ const oldest = waiting[0];
1375
+ return /* @__PURE__ */ jsx(
1376
+ KpiCard,
1377
+ {
1378
+ label: t("conversations.dashboard.waiting"),
1379
+ icon: "MessageCircleWarning",
1380
+ loading: loading && waiting.length === 0,
1381
+ value: String(waiting.length),
1382
+ sub: oldest ? t("conversations.dashboard.waitingOldest", { time: waitedFor(oldest.lastMessageAt, t) }) : t("conversations.dashboard.waitingNone"),
1383
+ invertTrend: true
1384
+ }
1385
+ );
1386
+ }
1387
+ function OpenThreadsKpi() {
1388
+ const t = useTranslation();
1389
+ const { open, waiting, loading } = useWaiting();
1390
+ return /* @__PURE__ */ jsx(
1391
+ KpiCard,
1392
+ {
1393
+ label: t("conversations.dashboard.openThreads"),
1394
+ icon: "MessagesSquare",
1395
+ loading: loading && open === 0,
1396
+ value: String(open),
1397
+ sub: t("conversations.dashboard.openThreadsSub", { count: String(waiting.length) })
1398
+ }
1399
+ );
1400
+ }
1401
+ function WaitingTable() {
1402
+ const t = useTranslation();
1403
+ const { waiting, loading } = useWaiting();
1404
+ const columns = useMemo(() => [
1405
+ {
1406
+ accessorKey: "contactName",
1407
+ header: t("conversations.dashboard.who"),
1408
+ cell: ({ row }) => /* @__PURE__ */ jsx("span", { className: "font-medium", children: row.original.contactName })
1409
+ },
1410
+ {
1411
+ id: "preview",
1412
+ header: "",
1413
+ // The message, not just the name: half of triage is deciding which of
1414
+ // five waiting people is the one worth answering first, and that is
1415
+ // decided by what they said.
1416
+ cell: ({ row }) => /* @__PURE__ */ jsx("span", { className: "line-clamp-1 text-xs text-muted-foreground", children: row.original.lastMessagePreview })
1417
+ },
1418
+ {
1419
+ id: "channel",
1420
+ header: t("conversations.contact.channel"),
1421
+ cell: ({ row }) => /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground", children: t(`conversations.filter.${row.original.channel}`) })
1422
+ },
1423
+ {
1424
+ id: "waited",
1425
+ header: t("conversations.dashboard.waited"),
1426
+ cell: ({ row }) => /* @__PURE__ */ jsx("span", { className: "whitespace-nowrap text-xs font-medium tabular-nums", children: waitedFor(row.original.lastMessageAt, t) })
1427
+ }
1428
+ ], [t]);
1429
+ return /* @__PURE__ */ jsx(
1430
+ TableWidget,
1431
+ {
1432
+ title: t("conversations.dashboard.needsYou"),
1433
+ icon: "MessageCircleWarning",
1434
+ columns,
1435
+ data: waiting,
1436
+ loading: loading && waiting.length === 0,
1437
+ emptyMessage: t("conversations.dashboard.needsYouEmpty"),
1438
+ onRowClick: (row) => {
1439
+ window.location.hash = `/conversations?thread=${row.id}`;
1440
+ }
1441
+ }
1442
+ );
1443
+ }
1444
+ function createConversationsDashboardWidgets(ctx) {
1445
+ const withCtx = (Inner) => {
1446
+ const Wrapped = () => /* @__PURE__ */ jsx(ConversationsContextProvider, { store: ctx.store, config: ctx.config, children: /* @__PURE__ */ jsx(Inner, {}) });
1447
+ Wrapped.displayName = `ConversationsWidget(${Inner.displayName ?? Inner.name})`;
1448
+ return Wrapped;
1449
+ };
1450
+ return [
1451
+ // Visible on the home by DEFAULT, unlike most plugin widgets. The rest of
1452
+ // the registry is opt-in because a dashboard of everything is a dashboard
1453
+ // of nothing — but an unanswered customer is the one item on any home
1454
+ // screen that is time-critical, and hiding it behind Customize means it
1455
+ // only reaches the people who already knew to look.
1456
+ defineKpiWidget({
1457
+ id: "conversations.kpi.waiting",
1458
+ title: "conversations.dashboard.waiting",
1459
+ domain: "conversations",
1460
+ defaultOrder: -1,
1461
+ component: withCtx(WaitingKpi)
1462
+ }),
1463
+ defineKpiWidget({
1464
+ id: "conversations.kpi.open",
1465
+ title: "conversations.dashboard.openThreads",
1466
+ domain: "conversations",
1467
+ defaultOrder: 4,
1468
+ defaultVisible: false,
1469
+ component: withCtx(OpenThreadsKpi)
1470
+ }),
1471
+ defineTableWidget({
1472
+ id: "conversations.table.waiting",
1473
+ title: "conversations.dashboard.needsYou",
1474
+ domain: "conversations",
1475
+ span: 2,
1476
+ defaultOrder: 10,
1477
+ component: withCtx(WaitingTable)
1478
+ })
1479
+ ];
1480
+ }
1264
1481
  function mapChannel(r) {
1265
1482
  return {
1266
1483
  id: String(r.id),
@@ -1323,6 +1540,127 @@ function findFallbackChannel(channels) {
1323
1540
  function canClaimDedicated(channels, tenantId) {
1324
1541
  return findDedicatedChannel(channels, tenantId) === null;
1325
1542
  }
1543
+ function channelLabel(account, t) {
1544
+ const key = `conversations.filter.${account.channel}`;
1545
+ const translated = t(key);
1546
+ return translated === key ? account.channel : translated;
1547
+ }
1548
+ function ChannelRow({ account }) {
1549
+ const t = useTranslation();
1550
+ const pending = isPendingChannelStatus(account.status);
1551
+ return /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-3 border-b py-2.5 last:border-b-0", children: [
1552
+ /* @__PURE__ */ jsxs("div", { className: "min-w-0", children: [
1553
+ /* @__PURE__ */ jsx("p", { className: "truncate text-sm font-medium", children: account.phoneE164 ?? account.displayName ?? channelLabel(account, t) }),
1554
+ /* @__PURE__ */ jsxs("p", { className: "text-xs text-muted-foreground", children: [
1555
+ channelLabel(account, t),
1556
+ " \xB7 ",
1557
+ t(
1558
+ account.kind === "dedicated" ? "conversations.settings.channelDedicated" : "conversations.settings.channelShared"
1559
+ )
1560
+ ] })
1561
+ ] }),
1562
+ /* @__PURE__ */ jsx(Badge, { variant: account.status === "active" ? "default" : pending ? "secondary" : "outline", children: t(`conversations.settings.status.${account.status}`) === `conversations.settings.status.${account.status}` ? account.status : t(`conversations.settings.status.${account.status}`) })
1563
+ ] });
1564
+ }
1565
+ function ConversationsGeneralSettings() {
1566
+ const t = useTranslation();
1567
+ const [channels, setChannels] = React4.useState(null);
1568
+ const [failed, setFailed] = React4.useState(false);
1569
+ React4.useEffect(() => {
1570
+ let cancelled = false;
1571
+ listMessagingChannels().then((rows) => {
1572
+ if (!cancelled) setChannels(rows);
1573
+ }).catch(() => {
1574
+ if (!cancelled) {
1575
+ setFailed(true);
1576
+ setChannels([]);
1577
+ }
1578
+ });
1579
+ return () => {
1580
+ cancelled = true;
1581
+ };
1582
+ }, []);
1583
+ return /* @__PURE__ */ jsx("div", { className: "space-y-6", children: /* @__PURE__ */ jsxs(
1584
+ SettingsGroup,
1585
+ {
1586
+ title: t("conversations.settings.channels"),
1587
+ description: t("conversations.settings.channelsHelp"),
1588
+ children: [
1589
+ channels === null && /* @__PURE__ */ jsx("p", { className: "py-2 text-sm text-muted-foreground", children: t("conversations.list.loading") }),
1590
+ channels !== null && failed && /* @__PURE__ */ jsx("p", { className: "py-2 text-sm text-muted-foreground", children: t("conversations.settings.channelsUnavailable") }),
1591
+ channels !== null && !failed && channels.length === 0 && /* @__PURE__ */ jsx("p", { className: "py-2 text-sm text-muted-foreground", children: t("conversations.settings.channelsEmpty") }),
1592
+ channels?.map((account) => /* @__PURE__ */ jsx(ChannelRow, { account }, account.id))
1593
+ ]
1594
+ }
1595
+ ) });
1596
+ }
1597
+ ConversationsGeneralSettings.displayName = "ConversationsGeneralSettings";
1598
+ function ConversationsSettingsTab() {
1599
+ const t = useTranslation();
1600
+ return /* @__PURE__ */ jsx(
1601
+ PluginSettingsPanel,
1602
+ {
1603
+ title: t("conversations.settings.title"),
1604
+ generalSettings: /* @__PURE__ */ jsx(ConversationsGeneralSettings, {}),
1605
+ hostPluginId: "conversations",
1606
+ routeBase: "/settings/conversations"
1607
+ }
1608
+ );
1609
+ }
1610
+ ConversationsSettingsTab.displayName = "ConversationsSettingsTab";
1611
+ function buildConversationsOnboarding() {
1612
+ return {
1613
+ id: "conversations",
1614
+ title: "Quem fala com voc\xEA",
1615
+ order: 30,
1616
+ role: "frontdesk",
1617
+ instructions: `Voc\xEA est\xE1 ligando a caixa de entrada. O risco aqui n\xE3o \xE9 a pessoa n\xE3o achar a tela \u2014 \xE9 ela achar a tela, ver que est\xE1 vazia e concluir que o produto n\xE3o funciona.
1618
+
1619
+ - Comece pelo CANAL. Sem n\xFAmero ligado n\xE3o entra mensagem nenhuma, e todo o resto do m\xF3dulo \xE9 decora\xE7\xE3o. O caminho \xE9 Configura\xE7\xF5es \u203A Conversas \u203A Integra\xE7\xF5es.
1620
+ - Explique a diferen\xE7a entre o n\xFAmero da plataforma e um n\xFAmero dedicado ANTES de ela escolher: no compartilhado ela j\xE1 come\xE7a a receber hoje; no dedicado sai o nome dela, mas ainda passa por um cadastro na Meta.
1621
+ - O segundo passo \xE9 a prova: pe\xE7a para ela mandar uma mensagem do pr\xF3prio celular para o n\xFAmero e responder daqui. \xC9 o que mostra que ida e volta funcionam.
1622
+ - Se ela j\xE1 tem WhatsApp Business no celular, diga com todas as letras o que muda: as conversas passam a ficar aqui, onde a equipe inteira v\xEA, em vez de num aparelho s\xF3.`,
1623
+ steps: [
1624
+ {
1625
+ key: "channel",
1626
+ title: "Ligue o canal onde seu cliente j\xE1 fala",
1627
+ description: "Sem n\xFAmero conectado, a caixa de entrada abre vazia para sempre.",
1628
+ icon: "MessageCircle",
1629
+ route: "/settings/conversations/_integrations",
1630
+ priority: "urgent",
1631
+ // O passo É a tela: um app que monte o inbox sem a rota de configurações
1632
+ // não tem onde ligar canal nenhum, e a linha ficaria aberta para sempre.
1633
+ requiresRoute: true,
1634
+ agentHint: "Abra Integra\xE7\xF5es e apresente as duas op\xE7\xF5es pelo que elas custam \xE0 pessoa HOJE: o n\xFAmero da plataforma come\xE7a a receber agora, o dedicado sai com o nome dela mas espera o cadastro na Meta.",
1635
+ howTo: "Configura\xE7\xF5es \u203A Conversas \u203A Integra\xE7\xF5es \u203A Tyxter. O n\xFAmero da plataforma j\xE1 est\xE1 ligado e \xE9 compartilhado com outros neg\xF3cios \u2014 d\xE1 para come\xE7ar a receber por ele hoje. Um n\xFAmero dedicado sai s\xF3 com o seu nome, libera os seus pr\xF3prios modelos de mensagem e tem limite de envio pr\xF3prio; ele \xE9 alugado no m\xEAs e ainda passa por um cadastro r\xE1pido na Meta antes do primeiro envio.",
1636
+ celebrate: "Canal ligado \u2014 agora d\xE1 para te acharem \u{1F4F2}",
1637
+ script: [
1638
+ { kind: "goto", route: "/settings/conversations/_integrations" },
1639
+ {
1640
+ kind: "ask",
1641
+ text: "Estamos nas Integra\xE7\xF5es da caixa de entrada. Mostre o n\xFAmero da plataforma e o dedicado, explique o que muda em cada um e ajude a pessoa a escolher."
1642
+ }
1643
+ ],
1644
+ check: async () => await countByTenant(T.channels) > 0
1645
+ },
1646
+ {
1647
+ key: "first-reply",
1648
+ title: "Receba e responda a primeira mensagem",
1649
+ description: "A prova de que a ida e a volta funcionam.",
1650
+ icon: "Send",
1651
+ route: "/conversations",
1652
+ priority: "high",
1653
+ agentHint: "N\xE3o \xE9 passo de cadastro. Pe\xE7a para a pessoa mandar uma mensagem do pr\xF3prio celular para o n\xFAmero ligado e responder aqui de dentro \u2014 e fique com ela at\xE9 a resposta chegar do outro lado.",
1654
+ howTo: "Pegue o seu celular, mande uma mensagem qualquer para o n\xFAmero que voc\xEA acabou de ligar e volte aqui: a conversa aparece em Conversas. Responda por aqui e confira que chegou no celular. \xC9 o teste que prova que a caixa de entrada est\xE1 no ar \u2014 e \xE9 tamb\xE9m como a equipe vai trabalhar todo dia, com a conversa num lugar que todo mundo v\xEA em vez de num aparelho s\xF3.",
1655
+ celebrate: "Ida e volta funcionando \u{1F4AC}",
1656
+ // A conversa existe assim que a primeira mensagem entra, então a
1657
+ // contagem de threads é a checagem — e não a de mensagens, que seria
1658
+ // fechada por um envio que ninguém respondeu.
1659
+ check: async () => await countByTenant(T.conversations) > 0
1660
+ }
1661
+ ]
1662
+ };
1663
+ }
1326
1664
  var TYXTER_NUMBER_CLAIM_FUNCTION = "tyxter-number-claim";
1327
1665
  function client() {
1328
1666
  const supabase = getSupabaseClientOptional();
@@ -1728,433 +2066,186 @@ var tyxterConnectorDef = {
1728
2066
  };
1729
2067
 
1730
2068
  // src/migrations/index.ts
1731
- var MIGRATION_001_CONVERSATIONS = `-- ============================================================================
1732
- -- plugin-conversations 001: omni-channel inbox model (SMS / WhatsApp /
1733
- -- Instagram / Email / Web chat). Prefix: plg_conversations / plg_conversation_messages.
1734
- -- \xA71 plg_conversations \u2014 one thread per contact+channel
1735
- -- \xA72 plg_conversation_messages \u2014 inbound/outbound messages within a thread
1736
- -- \xA73 RLS: authenticated tenant-scoped CRUD on both tables + GRANTs
2069
+ var MIGRATION_000_BASELINE = `-- ============================================================================
2070
+ -- plugins/plugin-conversations/src/migrations/000_baseline.sql \u2014 what installing this provisions, as one file.
1737
2071
  --
1738
- -- Column names mirror exactly what supabase.ts's mapConversation / mapMessage
1739
- -- read. Real channel connectors (Twilio, WhatsApp Cloud, Meta, IMAP) deliver
1740
- -- inbound rows here out-of-band; the provider is the read/compose surface.
1741
- -- Idempotent + safe to re-run.
1742
- -- ============================================================================
1743
-
1744
- -- \xA71 \u2014 conversations (threads)
1745
- CREATE TABLE IF NOT EXISTS public.plg_conversations (
1746
- id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
1747
- tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
1748
- contact_name text NOT NULL,
1749
- contact_handle text,
1750
- channel text NOT NULL
1751
- CHECK (channel IN ('sms', 'whatsapp', 'instagram', 'email', 'webchat')),
1752
- last_message_preview text,
1753
- last_message_at timestamptz DEFAULT now(),
1754
- unread_count int DEFAULT 0,
1755
- status text DEFAULT 'open'
1756
- CHECK (status IN ('open', 'snoozed', 'closed')),
1757
- assigned_to text,
1758
- accent text,
1759
- tags text[],
1760
- location text,
1761
- note text,
1762
- created_at timestamptz NOT NULL DEFAULT now(),
1763
- updated_at timestamptz NOT NULL DEFAULT now()
1764
- );
1765
- ALTER TABLE public.plg_conversations ENABLE ROW LEVEL SECURITY;
1766
- CREATE INDEX IF NOT EXISTS idx_plg_conversations_tenant ON public.plg_conversations(tenant_id);
1767
- CREATE INDEX IF NOT EXISTS idx_plg_conversations_tenant_recent ON public.plg_conversations(tenant_id, last_message_at DESC);
1768
-
1769
- -- \xA72 \u2014 messages
1770
- CREATE TABLE IF NOT EXISTS public.plg_conversation_messages (
1771
- id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
1772
- tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
1773
- conversation_id uuid NOT NULL REFERENCES public.plg_conversations(id) ON DELETE CASCADE,
1774
- channel text
1775
- CHECK (channel IS NULL OR channel IN ('sms', 'whatsapp', 'instagram', 'email', 'webchat')),
1776
- direction text
1777
- CHECK (direction IN ('inbound', 'outbound')),
1778
- body text NOT NULL,
1779
- author text,
1780
- at timestamptz DEFAULT now()
1781
- );
1782
- ALTER TABLE public.plg_conversation_messages ENABLE ROW LEVEL SECURITY;
1783
- CREATE INDEX IF NOT EXISTS idx_plg_conversation_messages_thread ON public.plg_conversation_messages(conversation_id, at);
1784
-
1785
- -- \xA73 \u2014 RLS: authenticated tenant CRUD (the inbox reads/writes here)
1786
- DROP POLICY IF EXISTS plg_conversations_select ON public.plg_conversations;
1787
- DROP POLICY IF EXISTS plg_conversations_insert ON public.plg_conversations;
1788
- DROP POLICY IF EXISTS plg_conversations_update ON public.plg_conversations;
1789
- DROP POLICY IF EXISTS plg_conversations_delete ON public.plg_conversations;
1790
- CREATE POLICY plg_conversations_select ON public.plg_conversations FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
1791
- CREATE POLICY plg_conversations_insert ON public.plg_conversations FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
1792
- CREATE POLICY plg_conversations_update ON public.plg_conversations FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
1793
- CREATE POLICY plg_conversations_delete ON public.plg_conversations FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
1794
- GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_conversations TO authenticated;
1795
-
1796
- DROP POLICY IF EXISTS plg_conversation_messages_select ON public.plg_conversation_messages;
1797
- DROP POLICY IF EXISTS plg_conversation_messages_insert ON public.plg_conversation_messages;
1798
- DROP POLICY IF EXISTS plg_conversation_messages_update ON public.plg_conversation_messages;
1799
- DROP POLICY IF EXISTS plg_conversation_messages_delete ON public.plg_conversation_messages;
1800
- CREATE POLICY plg_conversation_messages_select ON public.plg_conversation_messages FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
1801
- CREATE POLICY plg_conversation_messages_insert ON public.plg_conversation_messages FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
1802
- CREATE POLICY plg_conversation_messages_update ON public.plg_conversation_messages FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
1803
- CREATE POLICY plg_conversation_messages_delete ON public.plg_conversation_messages FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
1804
- GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_conversation_messages TO authenticated;
1805
- `;
1806
- var MIGRATION_002_CONTACT_PERSON = `-- ============================================================================
1807
- -- plugin-conversations 002: link a thread to a REAL person record.
2072
+ -- GENERATED by scripts/emit-unit-baselines.mjs (#338). Do not edit by hand:
2073
+ -- change the schema, regenerate, and let scripts/check-chain-equivalence.mjs
2074
+ -- prove the result still matches.
1808
2075
  --
1809
- -- The compose modal used to take a free-text name + handle, so a conversation
1810
- -- with "Maria" had nothing to do with the Maria in the agenda, the CRM or the
1811
- -- financial module. The shared ContactPicker (find-or-create over
1812
- -- public.people) now resolves a person, and this column stores that link.
2076
+ -- This replaced 9 migration files. They are kept, unapplied, in
2077
+ -- migrations.archive/ \u2014 a year of decisions is worth reading even when it is no
2078
+ -- longer worth replaying.
1813
2079
  --
1814
- -- Nullable on purpose, in both directions of time:
1815
- -- \u2022 rows created before this migration keep working (name/handle only);
1816
- -- \u2022 an inbound message from an unknown number still opens a thread with no
1817
- -- person attached \u2014 the contact panel can offer "create contact" later.
1818
- -- ON DELETE SET NULL: deleting a person must never take their history with it.
1819
- -- Idempotent + safe to re-run.
2080
+ -- Object order is fixed rather than dependency-sorted: schemas, types,
2081
+ -- sequences, tables, defaults, functions, constraints, views, indexes, foreign
2082
+ -- keys, triggers, row security, policies, grants. Dependencies BETWEEN units
2083
+ -- are carried by the order packages/db/chain.json declares.
1820
2084
  -- ============================================================================
1821
2085
 
1822
- ALTER TABLE public.plg_conversations
1823
- ADD COLUMN IF NOT EXISTS contact_person_id uuid REFERENCES public.people(id) ON DELETE SET NULL;
2086
+ -- A LANGUAGE sql function binds its body at CREATE time, and 570 functions
2087
+ -- sorted by name are not sorted by who calls whom. This is what lets them load
2088
+ -- in any order; every body is still compiled on first call, so a genuinely
2089
+ -- broken reference surfaces then rather than never. Session-scoped.
2090
+ SET check_function_bodies = false;
1824
2091
 
1825
- CREATE INDEX IF NOT EXISTS idx_plg_conversations_person
1826
- ON public.plg_conversations(tenant_id, contact_person_id)
1827
- WHERE contact_person_id IS NOT NULL;
1828
- `;
1829
- var MIGRATION_003_CHANNELS = `-- ============================================================================
1830
- -- plugin-conversations 003: channel accounts \u2014 WHICH number a message leaves by.
1831
- --
1832
- -- The inbox has always been the read/compose surface for rows a connector
1833
- -- delivered out-of-band. This is the first table that says where those rows
1834
- -- come FROM, and it exists because WhatsApp has two number models at once:
1835
- --
1836
- -- \u2022 kind 'fallback' \u2014 the product's own number, shared by every tenant.
1837
- -- tenant_id IS NULL: it belongs to the software, not to a salon, and a
1838
- -- tenant column with a made-up value there would make every read lie.
1839
- -- \u2022 kind 'dedicated' \u2014 a number rented for one tenant. Once it is \`active\`
1840
- -- it wins over the fallback; until then the fallback keeps carrying.
1841
- --
1842
- -- Provider-neutral on purpose (\`provider\`, \`provider_number_id\`): the sender
1843
- -- model is a WhatsApp fact, not a Tyxter fact, and the second messaging
1844
- -- provider must not need a second table.
1845
- --
1846
- -- Writes are SERVICE-ROLE ONLY. Every row is created by a webhook or by an
1847
- -- edge function acting on the provider's answer \u2014 a member who could edit
1848
- -- \`provider_number_id\` could point another tenant's replies at their own inbox.
1849
- -- Members read, and the fallback row is readable by all of them because it is
1850
- -- the number their own messages go out on.
1851
- -- Idempotent + safe to re-run.
1852
- -- ============================================================================
1853
2092
 
1854
- CREATE TABLE IF NOT EXISTS public.plg_conversations_channels (
1855
- id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
1856
- -- NULL = the product fallback row. Not a missing value: an owner.
1857
- tenant_id uuid REFERENCES public.tenants(id) ON DELETE CASCADE,
1858
- channel text NOT NULL DEFAULT 'whatsapp'
1859
- CHECK (channel IN ('sms', 'whatsapp', 'instagram', 'email', 'webchat')),
1860
- provider text NOT NULL,
1861
- -- The provider's handle for the number ('pn_\u2026'); what a webhook routes on.
1862
- provider_number_id text NOT NULL,
1863
- phone_e164 text,
1864
- kind text NOT NULL DEFAULT 'dedicated'
1865
- CHECK (kind IN ('fallback', 'dedicated')),
1866
- -- The provider's own lifecycle, kept verbatim so a status webhook never has
1867
- -- to be translated into a vocabulary of ours that means slightly less.
1868
- status text NOT NULL DEFAULT 'requested'
1869
- CHECK (status IN ('requested', 'provisioning', 'provisioned', 'verifying',
1870
- 'active', 'failed', 'released', 'disconnected')),
1871
- display_name text,
1872
- metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
1873
- created_at timestamptz NOT NULL DEFAULT now(),
1874
- updated_at timestamptz NOT NULL DEFAULT now()
1875
- );
1876
- ALTER TABLE public.plg_conversations_channels ENABLE ROW LEVEL SECURITY;
1877
2093
 
1878
- -- One row per number at a provider: the webhook resolves the tenant by this
1879
- -- pair, and a duplicate would make that resolution a coin toss.
1880
- CREATE UNIQUE INDEX IF NOT EXISTS uq_plg_conversations_channels_number
1881
- ON public.plg_conversations_channels(provider, provider_number_id);
1882
-
1883
- -- At most one fallback per (channel, provider). Sender resolution falls back to
1884
- -- "the product's number", singular \u2014 two of them is an ambiguity no caller can
1885
- -- resolve, and it would surface as messages leaving by the wrong one.
1886
- CREATE UNIQUE INDEX IF NOT EXISTS uq_plg_conversations_channels_fallback
1887
- ON public.plg_conversations_channels(channel, provider)
1888
- WHERE tenant_id IS NULL AND kind = 'fallback';
1889
-
1890
- CREATE INDEX IF NOT EXISTS idx_plg_conversations_channels_tenant
1891
- ON public.plg_conversations_channels(tenant_id, channel, status)
1892
- WHERE tenant_id IS NOT NULL;
1893
-
1894
- -- RLS: members read their own rows AND the product fallback (it is the number
1895
- -- they send from, so hiding it would leave the panel with nothing to say).
1896
- -- No INSERT/UPDATE/DELETE policy and no write GRANT: service role only.
1897
- DROP POLICY IF EXISTS plg_conversations_channels_select ON public.plg_conversations_channels;
1898
- CREATE POLICY plg_conversations_channels_select ON public.plg_conversations_channels
1899
- FOR SELECT TO authenticated
1900
- USING (tenant_id IS NULL OR tenant_id IN (SELECT public.user_tenant_ids()));
1901
- GRANT SELECT ON public.plg_conversations_channels TO authenticated;
1902
- `;
1903
- var MIGRATION_004_MESSAGE_DELIVERY = `-- ============================================================================
1904
- -- plugin-conversations 004: what the provider did with a message, and who wrote
1905
- -- it. Four columns, ported from the legacy beautyplace inbox.
1906
- --
1907
- -- \`direction\` + \`author\` was enough while every row was typed by a person in
1908
- -- this app. It stops being enough the moment a message is sent by a connector:
1909
- --
1910
- -- provider_message_id the provider's handle for the row. A delivery webhook
1911
- -- names only this, so without it a status can never be applied to anything.
1912
- -- UNIQUE (where present) so an at-least-once webhook that redelivers the
1913
- -- same inbound message cannot open a second copy of it in the thread.
1914
- -- delivery_status the last thing the provider said, applied MONOTONICALLY
1915
- -- by the webhook handler \u2014 'read' must not be overwritten by a 'delivered'
1916
- -- that arrived late (they are unordered).
1917
- -- sender_kind person / app / assistant / automation. The backbone of
1918
- -- a shared inbox: legacy proved that a human and an AI writing into the same
1919
- -- thread with no attribution is a thread nobody trusts.
1920
- -- sender_label who exactly, in words \u2014 "Ana", "Lembrete de hor\xE1rio".
1921
- --
1922
- -- Nullable throughout: rows written before this migration are not wrong, they
1923
- -- are simply from a time when only people wrote here.
1924
- -- Idempotent + safe to re-run.
1925
- -- ============================================================================
2094
+ -- \u2500\u2500 default privileges: reset \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2095
+ -- Cleared BEFORE anything is created, so every object below gets the ACL the
2096
+ -- dump describes rather than the image's defaults on top of it. Restored at
2097
+ -- the end of this file.
1926
2098
 
1927
- ALTER TABLE public.plg_conversation_messages
1928
- ADD COLUMN IF NOT EXISTS provider_message_id text,
1929
- ADD COLUMN IF NOT EXISTS delivery_status text,
1930
- ADD COLUMN IF NOT EXISTS sender_kind text,
1931
- ADD COLUMN IF NOT EXISTS sender_label text;
2099
+ DO $dp$ BEGIN
2100
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role, fayz_connector$stmt$;
2101
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
2102
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role, fayz_connector$stmt$;
2103
+ END $dp$;
1932
2104
 
1933
- -- Added apart from the column so a re-run on a pool that already has the column
1934
- -- still installs the constraint (ADD COLUMN IF NOT EXISTS skips its inline ones).
1935
- DO $$
1936
- BEGIN
1937
- IF NOT EXISTS (
1938
- SELECT 1 FROM pg_constraint WHERE conname = 'plg_conversation_messages_delivery_status_check'
1939
- ) THEN
1940
- ALTER TABLE public.plg_conversation_messages
1941
- ADD CONSTRAINT plg_conversation_messages_delivery_status_check
1942
- CHECK (delivery_status IS NULL OR delivery_status IN
1943
- ('queued', 'sent', 'delivered', 'read', 'failed', 'expired'));
1944
- END IF;
1945
- IF NOT EXISTS (
1946
- SELECT 1 FROM pg_constraint WHERE conname = 'plg_conversation_messages_sender_kind_check'
1947
- ) THEN
1948
- ALTER TABLE public.plg_conversation_messages
1949
- ADD CONSTRAINT plg_conversation_messages_sender_kind_check
1950
- CHECK (sender_kind IS NULL OR sender_kind IN ('user', 'system', 'ai', 'automation'));
1951
- END IF;
1952
- END
1953
- $$;
2105
+ DO $dp$ BEGIN
2106
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role, fayz_connector$stmt$;
2107
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
2108
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role, fayz_connector$stmt$;
2109
+ END $dp$;
1954
2110
 
1955
- CREATE UNIQUE INDEX IF NOT EXISTS uq_plg_conversation_messages_provider_message
1956
- ON public.plg_conversation_messages(provider_message_id)
1957
- WHERE provider_message_id IS NOT NULL;
1958
- `;
1959
- var MIGRATION_005_WEBHOOK_EVENTS = `-- ============================================================================
1960
- -- plugin-conversations 005: the dedupe ledger \u2014 one row per event the provider
1961
- -- ever delivered, and the reason a retry is free.
1962
- --
1963
- -- Tyxter delivers AT LEAST ONCE: 8 attempts over ~32.7 hours, and an attempt
1964
- -- counts as failed on anything that is not a 2xx inside 10 seconds. So the same
1965
- -- \`message.received\` arrives again whenever our answer was slow, and without
1966
- -- this table the second delivery opens a second message in the thread and
1967
- -- re-applies the button the customer pressed once.
1968
- --
1969
- -- Deliberately NOT keyed on the message: the same message produces several
1970
- -- events (sent, delivered, read) and the same event may name no message at all
1971
- -- (a template approval). The identity that is unique per DELIVERED FACT is the
1972
- -- provider's event id, so that is the key.
1973
- --
1974
- -- The insert is the lock. \`ON CONFLICT DO NOTHING \u2026 RETURNING id\` returns a row
1975
- -- for the first delivery and nothing for every later one, which is a claim
1976
- -- taken in a single statement \u2014 a SELECT-then-INSERT would let two concurrent
1977
- -- retries both find nothing and both apply.
1978
- --
1979
- -- Service-role only, and not because the rows are secret: a member who could
1980
- -- delete one could make a webhook re-apply, and one who could insert could make
1981
- -- a real delivery be skipped as a duplicate.
1982
- -- Idempotent + safe to re-run.
1983
- -- ============================================================================
2111
+ DO $dp$ BEGIN
2112
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role, fayz_connector$stmt$;
2113
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
2114
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role, fayz_connector$stmt$;
2115
+ END $dp$;
2116
+
2117
+ DO $dp$ BEGIN
2118
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role, fayz_connector$stmt$;
2119
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
2120
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role, fayz_connector$stmt$;
2121
+ END $dp$;
2122
+
2123
+ DO $dp$ BEGIN
2124
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role, fayz_connector$stmt$;
2125
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
2126
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role, fayz_connector$stmt$;
2127
+ END $dp$;
2128
+
2129
+ DO $dp$ BEGIN
2130
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role, fayz_connector$stmt$;
2131
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
2132
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role, fayz_connector$stmt$;
2133
+ END $dp$;
2134
+
2135
+
2136
+ -- \u2500\u2500 table \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1984
2137
 
1985
- CREATE TABLE IF NOT EXISTS public.plg_conversations_webhook_events (
1986
- id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
1987
- provider text NOT NULL,
1988
- -- The provider's id for the fact ('evt_\u2026'), NOT for the message.
1989
- event_id text NOT NULL,
1990
- event_type text NOT NULL,
1991
- received_at timestamptz NOT NULL DEFAULT now()
2138
+ CREATE TABLE public.plg_conversation_messages (
2139
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
2140
+ tenant_id uuid NOT NULL,
2141
+ conversation_id uuid NOT NULL,
2142
+ channel text,
2143
+ direction text,
2144
+ body text NOT NULL,
2145
+ author text,
2146
+ at timestamp with time zone DEFAULT now(),
2147
+ provider_message_id text,
2148
+ delivery_status text,
2149
+ sender_kind text,
2150
+ sender_label text,
2151
+ subject_type text,
2152
+ subject_id text,
2153
+ CONSTRAINT plg_conversation_messages_channel_check CHECK (((channel IS NULL) OR (channel = ANY (ARRAY['sms'::text, 'whatsapp'::text, 'instagram'::text, 'email'::text, 'webchat'::text])))),
2154
+ CONSTRAINT plg_conversation_messages_delivery_status_check CHECK (((delivery_status IS NULL) OR (delivery_status = ANY (ARRAY['queued'::text, 'sent'::text, 'delivered'::text, 'read'::text, 'failed'::text, 'expired'::text, 'delivery_timeout'::text, 'cancelled'::text, 'opted_out'::text])))),
2155
+ CONSTRAINT plg_conversation_messages_direction_check CHECK ((direction = ANY (ARRAY['inbound'::text, 'outbound'::text]))),
2156
+ CONSTRAINT plg_conversation_messages_sender_kind_check CHECK (((sender_kind IS NULL) OR (sender_kind = ANY (ARRAY['user'::text, 'system'::text, 'ai'::text, 'automation'::text]))))
1992
2157
  );
1993
- ALTER TABLE public.plg_conversations_webhook_events ENABLE ROW LEVEL SECURITY;
1994
2158
 
1995
- -- No tenant column on purpose: at claim time the tenant is not known yet \u2014 the
1996
- -- whole point of the row is to stop the SECOND delivery before any of the work
1997
- -- that would resolve one. Scoped by provider so two providers cannot collide on
1998
- -- an id shape neither of them controls.
1999
- CREATE UNIQUE INDEX IF NOT EXISTS uq_plg_conversations_webhook_events_event
2000
- ON public.plg_conversations_webhook_events(provider, event_id);
2159
+ CREATE TABLE public.plg_conversations (
2160
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
2161
+ tenant_id uuid NOT NULL,
2162
+ contact_name text NOT NULL,
2163
+ contact_handle text,
2164
+ channel text NOT NULL,
2165
+ last_message_preview text,
2166
+ last_message_at timestamp with time zone DEFAULT now(),
2167
+ unread_count integer DEFAULT 0,
2168
+ status text DEFAULT 'open'::text,
2169
+ assigned_to text,
2170
+ accent text,
2171
+ tags text[],
2172
+ location text,
2173
+ note text,
2174
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
2175
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
2176
+ contact_person_id uuid,
2177
+ CONSTRAINT plg_conversations_channel_check CHECK ((channel = ANY (ARRAY['sms'::text, 'whatsapp'::text, 'instagram'::text, 'email'::text, 'webchat'::text]))),
2178
+ CONSTRAINT plg_conversations_status_check CHECK ((status = ANY (ARRAY['open'::text, 'snoozed'::text, 'closed'::text])))
2179
+ );
2001
2180
 
2002
- -- For the sweep that keeps this table from growing forever. Nothing prunes it
2003
- -- yet; retention past the provider's 32.7h retry horizon buys nothing, and an
2004
- -- index the pruner will need is cheaper to add now than to add under load.
2005
- CREATE INDEX IF NOT EXISTS idx_plg_conversations_webhook_events_received
2006
- ON public.plg_conversations_webhook_events(received_at);
2181
+ CREATE TABLE public.plg_conversations_channels (
2182
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
2183
+ tenant_id uuid,
2184
+ channel text DEFAULT 'whatsapp'::text NOT NULL,
2185
+ provider text NOT NULL,
2186
+ provider_number_id text NOT NULL,
2187
+ phone_e164 text,
2188
+ kind text DEFAULT 'dedicated'::text NOT NULL,
2189
+ status text DEFAULT 'requested'::text NOT NULL,
2190
+ display_name text,
2191
+ metadata jsonb DEFAULT '{}'::jsonb NOT NULL,
2192
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
2193
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
2194
+ CONSTRAINT plg_conversations_channels_channel_check CHECK ((channel = ANY (ARRAY['sms'::text, 'whatsapp'::text, 'instagram'::text, 'email'::text, 'webchat'::text]))),
2195
+ CONSTRAINT plg_conversations_channels_kind_check CHECK ((kind = ANY (ARRAY['fallback'::text, 'dedicated'::text]))),
2196
+ CONSTRAINT plg_conversations_channels_status_check CHECK ((status = ANY (ARRAY['requested'::text, 'provisioning'::text, 'provisioned'::text, 'verifying'::text, 'active'::text, 'failed'::text, 'released'::text, 'disconnected'::text])))
2197
+ );
2007
2198
 
2008
- -- RLS on with NO policy and no GRANT: service role only, in both directions.
2009
- `;
2010
- var MIGRATION_006_OPTOUTS = `-- ============================================================================
2011
- -- plugin-conversations 006: who told us to stop.
2012
- --
2013
- -- Tyxter keeps its own consent ledger and refuses a send to an opted-out
2014
- -- contact at its edge. This table is not a copy of that for redundancy's sake \u2014
2015
- -- it exists so the REFUSAL IS OURS. A send that fails at the provider is a
2016
- -- failed send: a queued row, a failed delivery status, a retry, and a line in
2017
- -- an operator's screen saying WhatsApp is broken. A send that never leaves
2018
- -- because this row exists is a suppression, which is a different fact and the
2019
- -- only one a person can act on.
2020
- --
2021
- -- \`tenant_id\` is NULLABLE and the null carries meaning, the same way it does on
2022
- -- plg_conversations_channels:
2023
- -- \u2022 NULL \u2014 the customer opted out of the PRODUCT's shared fallback number.
2024
- -- They cannot have consented to one salon and not another on a number all
2025
- -- the salons share, so the suppression is product-wide.
2026
- -- \u2022 set \u2014 the customer opted out of that tenant's dedicated number. Their
2027
- -- other conversations are untouched.
2028
- --
2029
- -- Written by the webhook (contact.opted_out / contact.erased) and read by
2030
- -- messaging-send before every send. Nothing else writes it: a member who could
2031
- -- delete a row could resume messaging somebody who asked us to stop, which is
2032
- -- the one mistake in this file with a legal name.
2033
- -- Idempotent + safe to re-run.
2034
- -- ============================================================================
2199
+ CREATE TABLE public.plg_conversations_optouts (
2200
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
2201
+ channel text DEFAULT 'whatsapp'::text NOT NULL,
2202
+ phone_e164 text NOT NULL,
2203
+ tenant_id uuid,
2204
+ provider text NOT NULL,
2205
+ reason text,
2206
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
2207
+ CONSTRAINT plg_conversations_optouts_channel_check CHECK ((channel = ANY (ARRAY['sms'::text, 'whatsapp'::text, 'instagram'::text, 'email'::text, 'webchat'::text])))
2208
+ );
2035
2209
 
2036
- CREATE TABLE IF NOT EXISTS public.plg_conversations_optouts (
2037
- id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
2038
- channel text NOT NULL DEFAULT 'whatsapp'
2039
- CHECK (channel IN ('sms', 'whatsapp', 'instagram', 'email', 'webchat')),
2040
- phone_e164 text NOT NULL,
2041
- -- NULL = the product's shared number. Not a missing value: a scope.
2042
- tenant_id uuid REFERENCES public.tenants(id) ON DELETE CASCADE,
2043
- -- No DEFAULT: the table is provider-neutral, and a column that quietly
2044
- -- defaults to one provider is how the second messaging provider's rows end up
2045
- -- filed under the first one's name. Every writer names it.
2046
- provider text NOT NULL,
2047
- reason text,
2048
- created_at timestamptz NOT NULL DEFAULT now()
2210
+ CREATE TABLE public.plg_conversations_payment_requests (
2211
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
2212
+ tenant_id uuid NOT NULL,
2213
+ conversation_id uuid,
2214
+ message_id uuid,
2215
+ subject_type text,
2216
+ subject_id text,
2217
+ amount_cents integer NOT NULL,
2218
+ currency text DEFAULT 'BRL'::text NOT NULL,
2219
+ description text,
2220
+ provider text NOT NULL,
2221
+ status text DEFAULT 'created'::text NOT NULL,
2222
+ provider_payment_id text,
2223
+ payment_link_url text,
2224
+ pix_copy_paste text,
2225
+ link_delivered_at timestamp with time zone,
2226
+ settled_notified_at timestamp with time zone,
2227
+ settled_at timestamp with time zone,
2228
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
2229
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
2230
+ CONSTRAINT plg_conversations_payment_requests_amount_cents_check CHECK ((amount_cents > 0)),
2231
+ CONSTRAINT plg_conversations_payment_requests_status_check CHECK ((status = ANY (ARRAY['created'::text, 'link_generated'::text, 'approval_requested'::text, 'approved'::text, 'paid'::text, 'failed'::text, 'expired'::text, 'cancelled'::text])))
2049
2232
  );
2050
- ALTER TABLE public.plg_conversations_optouts ENABLE ROW LEVEL SECURITY;
2051
2233
 
2052
- -- NULL-safe uniqueness. A plain UNIQUE(channel, phone_e164, tenant_id) does not
2053
- -- constrain the product-wide rows at all \u2014 in SQL two NULLs are not equal, so
2054
- -- every redelivered contact.opted_out would insert another row and the
2055
- -- suppression read would have to be a DISTINCT. The sentinel uuid is never a
2056
- -- real tenant id, so COALESCE gives the three columns one identity.
2057
- CREATE UNIQUE INDEX IF NOT EXISTS uq_plg_conversations_optouts_contact
2058
- ON public.plg_conversations_optouts(
2059
- channel, phone_e164,
2060
- COALESCE(tenant_id, '00000000-0000-0000-0000-000000000000'::uuid));
2061
-
2062
- -- The read messaging-send makes on every send: "is this number suppressed for
2063
- -- this tenant, or product-wide?"
2064
- CREATE INDEX IF NOT EXISTS idx_plg_conversations_optouts_lookup
2065
- ON public.plg_conversations_optouts(channel, phone_e164);
2066
-
2067
- -- RLS: members READ the suppressions that apply to them (their own tenant's and
2068
- -- the product-wide ones), because "why did this customer stop getting messages"
2069
- -- is a question the inbox has to be able to answer. Writes are service-role
2070
- -- only \u2014 no INSERT/UPDATE/DELETE policy, no write GRANT.
2071
- DROP POLICY IF EXISTS plg_conversations_optouts_select ON public.plg_conversations_optouts;
2072
- CREATE POLICY plg_conversations_optouts_select ON public.plg_conversations_optouts
2073
- FOR SELECT TO authenticated
2074
- USING (tenant_id IS NULL OR tenant_id IN (SELECT public.user_tenant_ids()));
2075
- GRANT SELECT ON public.plg_conversations_optouts TO authenticated;
2076
- `;
2077
- var MIGRATION_007_INBOUND_ROUTING = `-- ============================================================================
2078
- -- plugin-conversations 007: the two things the webhook needs that 004 could not
2079
- -- know it would.
2080
- --
2081
- -- \xA71 delivery_status gains the statuses the provider actually emits.
2082
- -- \xA72 plg_conversations_match_by_phone \u2014 find the thread a WhatsApp number
2083
- -- belongs to, when the number was typed by a person and delivered by Meta.
2084
- -- Idempotent + safe to re-run.
2085
- -- ============================================================================
2234
+ CREATE TABLE public.plg_conversations_webhook_events (
2235
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
2236
+ provider text NOT NULL,
2237
+ event_id text NOT NULL,
2238
+ event_type text NOT NULL,
2239
+ received_at timestamp with time zone DEFAULT now() NOT NULL
2240
+ );
2086
2241
 
2087
- -- \xA71 \u2014 the closed set was one delivery short --------------------------------
2088
- --
2089
- -- 004 wrote the vocabulary from the design. Registering a webhook endpoint at
2090
- -- Tyxter answers with the ENUMERATED list of deliverable events, and it holds
2091
- -- three the CHECK would have refused: \`message.delivery_timeout\` (the provider
2092
- -- gave up before the recipient's device ever acknowledged), \`message.cancelled\`
2093
- -- (an accepted message pulled back before it left), and \`message.opted_out\`
2094
- -- (refused at the provider because the contact withdrew consent \u2014 see 006).
2095
- --
2096
- -- Refusing them is worse than it sounds: the handler's UPDATE fails, the
2097
- -- webhook answers non-2xx, and Tyxter retries the same event eight times over a
2098
- -- day and a half before giving up. The row keeps whatever it said before, which
2099
- -- is 'sent'. An operator reads "sent" for a message that was never delivered.
2100
- DO $$
2101
- BEGIN
2102
- IF EXISTS (
2103
- SELECT 1 FROM pg_constraint WHERE conname = 'plg_conversation_messages_delivery_status_check'
2104
- ) THEN
2105
- ALTER TABLE public.plg_conversation_messages
2106
- DROP CONSTRAINT plg_conversation_messages_delivery_status_check;
2107
- END IF;
2108
- ALTER TABLE public.plg_conversation_messages
2109
- ADD CONSTRAINT plg_conversation_messages_delivery_status_check
2110
- CHECK (delivery_status IS NULL OR delivery_status IN
2111
- ('queued', 'sent', 'delivered', 'read',
2112
- 'failed', 'expired', 'delivery_timeout', 'cancelled', 'opted_out'));
2113
- END
2114
- $$;
2115
2242
 
2116
- -- \xA72 \u2014 matching a phone number to a thread ----------------------------------
2117
- --
2118
- -- The problem, ported from beautyplace where it cost a duplicate thread per
2119
- -- returning customer: the receptionist typed \`(21) 99889-9889\`, WhatsApp
2120
- -- delivers \`+5521998899889\`, and the mobile ninth digit is optional on records
2121
- -- older than 2016. Three spellings of one person, and \`=\` matches none of them.
2122
- --
2123
- -- The rule is digit-suffix, longest first:
2124
- -- last 10 = DDD + the 8 significant digits. Specific: a collision needs two
2125
- -- customers in the same area code sharing 8 digits.
2126
- -- last 8 = the subscriber digits alone. The FALLBACK, tried only when the
2127
- -- first finds nothing, because it crosses area codes.
2128
- --
2129
- -- SECURITY DEFINER and CROSS-TENANT by construction, which is the part to read
2130
- -- carefully. The product's fallback number is shared by every tenant in the
2131
- -- pool, so an inbound message on it arrives with no tenant attached and finding
2132
- -- one means looking at all of them. That is exactly the read that must never be
2133
- -- reachable by a member, so EXECUTE is granted to service_role ONLY and
2134
- -- explicitly revoked from authenticated and anon. The webhook holds the
2135
- -- service-role key; nothing in the browser can call this.
2136
- -- \`p_tenant_id\` narrows the search to one tenant, which is what a DEDICATED
2137
- -- number wants: the tenant is already known from the channel row, and the
2138
- -- cross-tenant search could hand back another salon's thread for the same
2139
- -- customer. NULL searches the whole pool, which is the fallback number's case
2140
- -- and the only one that has no other answer.
2141
- CREATE OR REPLACE FUNCTION public.plg_conversations_match_by_phone(
2142
- p_channel text,
2143
- p_last10 text,
2144
- p_last8 text,
2145
- p_tenant_id uuid DEFAULT NULL
2146
- ) RETURNS TABLE (
2147
- id uuid,
2148
- tenant_id uuid,
2149
- contact_name text,
2150
- contact_handle text,
2151
- contact_person_id uuid
2152
- )
2153
- LANGUAGE sql
2154
- STABLE
2155
- SECURITY DEFINER
2156
- SET search_path = public
2157
- AS $fn$
2243
+ -- \u2500\u2500 function \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2244
+
2245
+ CREATE FUNCTION public.plg_conversations_match_by_phone(p_channel text, p_last10 text, p_last8 text, p_tenant_id uuid DEFAULT NULL::uuid) RETURNS TABLE(id uuid, tenant_id uuid, contact_name text, contact_handle text, contact_person_id uuid)
2246
+ LANGUAGE sql STABLE SECURITY DEFINER
2247
+ SET search_path TO 'public'
2248
+ AS $$
2158
2249
  WITH candidate AS (
2159
2250
  SELECT c.id, c.tenant_id, c.contact_name, c.contact_handle, c.contact_person_id,
2160
2251
  c.last_message_at,
@@ -2174,189 +2265,430 @@ AS $fn$
2174
2265
  ORDER BY (p_last10 IS NOT NULL AND right(digits, 10) = p_last10) DESC,
2175
2266
  last_message_at DESC NULLS LAST
2176
2267
  LIMIT 1;
2177
- $fn$;
2178
-
2179
- REVOKE ALL ON FUNCTION public.plg_conversations_match_by_phone(text, text, text, uuid) FROM public;
2180
- REVOKE ALL ON FUNCTION public.plg_conversations_match_by_phone(text, text, text, uuid) FROM anon;
2181
- REVOKE ALL ON FUNCTION public.plg_conversations_match_by_phone(text, text, text, uuid) FROM authenticated;
2182
- GRANT EXECUTE ON FUNCTION public.plg_conversations_match_by_phone(text, text, text, uuid) TO service_role;
2183
-
2184
- -- The suffix is computed per row, so without this the match is a full scan of
2185
- -- every WhatsApp thread in the pool on every inbound message. All three
2186
- -- functions are IMMUTABLE, which is what makes the expression indexable.
2187
- CREATE INDEX IF NOT EXISTS idx_plg_conversations_handle_suffix
2188
- ON public.plg_conversations(
2189
- channel,
2190
- right(regexp_replace(COALESCE(contact_handle, ''), '[^0-9]', '', 'g'), 8),
2191
- last_message_at DESC);
2192
- `;
2193
- var MIGRATION_008_MESSAGE_SUBJECT = `-- ============================================================================
2194
- -- plugin-conversations 008: what a message was ABOUT.
2195
- --
2196
- -- Two columns, and they exist because of one unproved assumption in the
2197
- -- WhatsApp design. The template send attaches a per-button payload carrying the
2198
- -- booking id (\`act:confirm:booking:<uuid>\`), and the whole confirm/cancel flow
2199
- -- rests on Meta echoing that payload back when the customer taps. Tyxter's
2200
- -- sandbox accepts the components with a 202 and stores them verbatim \u2014 but it
2201
- -- validates that array not at all (an index of 7 on a two-button template was
2202
- -- accepted the same way), so the 202 proves nothing about Meta. Only a live
2203
- -- WABA can.
2204
- --
2205
- -- If Meta turns out to echo the button's static TEXT instead, the reply arrives
2206
- -- saying "Confirmar" and naming nothing. These columns are what it is then
2207
- -- correlated against: the last outbound message on that conversation, and the
2208
- -- subject recorded on it. Which is exactly the seam legacy beautyplace had to
2209
- -- build after the fact \u2014 their keyword matcher confirmed EVERY future
2210
- -- appointment the customer had, and the 48-hour re-enrichment subsystem existed
2211
- -- only to guess which one was meant. Recording the subject at send time costs
2212
- -- two nullable columns and removes the guess.
2213
- --
2214
- -- messaging-send (WP3) already writes them, tolerating 42703 and retrying
2215
- -- without \u2014 so a pool that has not run this migration keeps sending, and one
2216
- -- that has gains the anchor. This is the migration that closes that gap.
2217
- --
2218
- -- Nullable, and deliberately not a foreign key: the subject is polymorphic
2219
- -- ('booking', later 'order', 'invoice'), lives in tables this plugin does not
2220
- -- own, and a message about a deleted booking is still a message somebody sent.
2221
- -- Idempotent + safe to re-run.
2222
- -- ============================================================================
2268
+ $$;
2269
+
2270
+
2271
+ -- \u2500\u2500 constraint \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2272
+
2273
+ ALTER TABLE ONLY public.plg_conversation_messages
2274
+ ADD CONSTRAINT plg_conversation_messages_pkey PRIMARY KEY (id);
2275
+
2276
+ ALTER TABLE ONLY public.plg_conversations_channels
2277
+ ADD CONSTRAINT plg_conversations_channels_pkey PRIMARY KEY (id);
2278
+
2279
+ ALTER TABLE ONLY public.plg_conversations_optouts
2280
+ ADD CONSTRAINT plg_conversations_optouts_pkey PRIMARY KEY (id);
2281
+
2282
+ ALTER TABLE ONLY public.plg_conversations_payment_requests
2283
+ ADD CONSTRAINT plg_conversations_payment_requests_pkey PRIMARY KEY (id);
2284
+
2285
+ ALTER TABLE ONLY public.plg_conversations
2286
+ ADD CONSTRAINT plg_conversations_pkey PRIMARY KEY (id);
2287
+
2288
+ ALTER TABLE ONLY public.plg_conversations_webhook_events
2289
+ ADD CONSTRAINT plg_conversations_webhook_events_pkey PRIMARY KEY (id);
2290
+
2291
+
2292
+ -- \u2500\u2500 index \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2293
+
2294
+ CREATE INDEX idx_plg_conversation_messages_by_subject ON public.plg_conversation_messages USING btree (tenant_id, subject_type, subject_id) WHERE (subject_id IS NOT NULL);
2295
+
2296
+ CREATE INDEX idx_plg_conversation_messages_subject ON public.plg_conversation_messages USING btree (conversation_id, at DESC) WHERE (subject_id IS NOT NULL);
2297
+
2298
+ CREATE INDEX idx_plg_conversation_messages_thread ON public.plg_conversation_messages USING btree (conversation_id, at);
2299
+
2300
+ CREATE INDEX idx_plg_conversations_channels_tenant ON public.plg_conversations_channels USING btree (tenant_id, channel, status) WHERE (tenant_id IS NOT NULL);
2301
+
2302
+ CREATE INDEX idx_plg_conversations_handle_suffix ON public.plg_conversations USING btree (channel, "right"(regexp_replace(COALESCE(contact_handle, ''::text), '[^0-9]'::text, ''::text, 'g'::text), 8), last_message_at DESC);
2303
+
2304
+ CREATE INDEX idx_plg_conversations_optouts_lookup ON public.plg_conversations_optouts USING btree (channel, phone_e164);
2305
+
2306
+ CREATE INDEX idx_plg_conversations_payment_requests_subject ON public.plg_conversations_payment_requests USING btree (tenant_id, subject_type, subject_id) WHERE (subject_id IS NOT NULL);
2307
+
2308
+ CREATE INDEX idx_plg_conversations_payment_requests_tenant ON public.plg_conversations_payment_requests USING btree (tenant_id, created_at DESC);
2309
+
2310
+ CREATE INDEX idx_plg_conversations_person ON public.plg_conversations USING btree (tenant_id, contact_person_id) WHERE (contact_person_id IS NOT NULL);
2311
+
2312
+ CREATE INDEX idx_plg_conversations_tenant ON public.plg_conversations USING btree (tenant_id);
2313
+
2314
+ CREATE INDEX idx_plg_conversations_tenant_recent ON public.plg_conversations USING btree (tenant_id, last_message_at DESC);
2315
+
2316
+ CREATE INDEX idx_plg_conversations_webhook_events_received ON public.plg_conversations_webhook_events USING btree (received_at);
2317
+
2318
+ CREATE UNIQUE INDEX uq_plg_conversation_messages_provider_message ON public.plg_conversation_messages USING btree (provider_message_id) WHERE (provider_message_id IS NOT NULL);
2319
+
2320
+ CREATE UNIQUE INDEX uq_plg_conversations_channels_fallback ON public.plg_conversations_channels USING btree (channel, provider) WHERE ((tenant_id IS NULL) AND (kind = 'fallback'::text));
2321
+
2322
+ CREATE UNIQUE INDEX uq_plg_conversations_channels_number ON public.plg_conversations_channels USING btree (provider, provider_number_id);
2323
+
2324
+ CREATE UNIQUE INDEX uq_plg_conversations_optouts_contact ON public.plg_conversations_optouts USING btree (channel, phone_e164, COALESCE(tenant_id, '00000000-0000-0000-0000-000000000000'::uuid));
2325
+
2326
+ CREATE UNIQUE INDEX uq_plg_conversations_payment_requests_open_subject ON public.plg_conversations_payment_requests USING btree (tenant_id, subject_type, subject_id) WHERE ((subject_id IS NOT NULL) AND (status = ANY (ARRAY['created'::text, 'link_generated'::text, 'approval_requested'::text, 'approved'::text])));
2327
+
2328
+ CREATE UNIQUE INDEX uq_plg_conversations_payment_requests_provider ON public.plg_conversations_payment_requests USING btree (provider, provider_payment_id) WHERE (provider_payment_id IS NOT NULL);
2329
+
2330
+ CREATE UNIQUE INDEX uq_plg_conversations_webhook_events_event ON public.plg_conversations_webhook_events USING btree (provider, event_id);
2331
+
2332
+
2333
+ -- \u2500\u2500 fk constraint \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2334
+
2335
+ ALTER TABLE ONLY public.plg_conversation_messages
2336
+ ADD CONSTRAINT plg_conversation_messages_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES public.plg_conversations(id) ON DELETE CASCADE;
2337
+
2338
+ ALTER TABLE ONLY public.plg_conversation_messages
2339
+ ADD CONSTRAINT plg_conversation_messages_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
2340
+
2341
+ ALTER TABLE ONLY public.plg_conversations_channels
2342
+ ADD CONSTRAINT plg_conversations_channels_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
2343
+
2344
+ ALTER TABLE ONLY public.plg_conversations
2345
+ ADD CONSTRAINT plg_conversations_contact_person_id_fkey FOREIGN KEY (contact_person_id) REFERENCES public.people(id) ON DELETE SET NULL;
2346
+
2347
+ ALTER TABLE ONLY public.plg_conversations_optouts
2348
+ ADD CONSTRAINT plg_conversations_optouts_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
2349
+
2350
+ ALTER TABLE ONLY public.plg_conversations_payment_requests
2351
+ ADD CONSTRAINT plg_conversations_payment_requests_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES public.plg_conversations(id) ON DELETE SET NULL;
2352
+
2353
+ ALTER TABLE ONLY public.plg_conversations_payment_requests
2354
+ ADD CONSTRAINT plg_conversations_payment_requests_message_id_fkey FOREIGN KEY (message_id) REFERENCES public.plg_conversation_messages(id) ON DELETE SET NULL;
2355
+
2356
+ ALTER TABLE ONLY public.plg_conversations_payment_requests
2357
+ ADD CONSTRAINT plg_conversations_payment_requests_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
2358
+
2359
+ ALTER TABLE ONLY public.plg_conversations
2360
+ ADD CONSTRAINT plg_conversations_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
2361
+
2362
+
2363
+ -- \u2500\u2500 row security \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2364
+
2365
+ ALTER TABLE public.plg_conversation_messages ENABLE ROW LEVEL SECURITY;
2366
+
2367
+ ALTER TABLE public.plg_conversations ENABLE ROW LEVEL SECURITY;
2368
+
2369
+ ALTER TABLE public.plg_conversations_channels ENABLE ROW LEVEL SECURITY;
2370
+
2371
+ ALTER TABLE public.plg_conversations_optouts ENABLE ROW LEVEL SECURITY;
2372
+
2373
+ ALTER TABLE public.plg_conversations_payment_requests ENABLE ROW LEVEL SECURITY;
2374
+
2375
+ ALTER TABLE public.plg_conversations_webhook_events ENABLE ROW LEVEL SECURITY;
2376
+
2377
+
2378
+ -- \u2500\u2500 policy \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2379
+
2380
+ CREATE POLICY plg_conversation_messages_delete ON public.plg_conversation_messages FOR DELETE TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));
2381
+
2382
+ CREATE POLICY plg_conversation_messages_insert ON public.plg_conversation_messages FOR INSERT TO authenticated WITH CHECK ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));
2383
+
2384
+ CREATE POLICY plg_conversation_messages_select ON public.plg_conversation_messages FOR SELECT TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));
2385
+
2386
+ CREATE POLICY plg_conversation_messages_update ON public.plg_conversation_messages FOR UPDATE TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));
2387
+
2388
+ CREATE POLICY plg_conversations_channels_select ON public.plg_conversations_channels FOR SELECT TO authenticated USING (((tenant_id IS NULL) OR (tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids))));
2389
+
2390
+ CREATE POLICY plg_conversations_delete ON public.plg_conversations FOR DELETE TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));
2391
+
2392
+ CREATE POLICY plg_conversations_insert ON public.plg_conversations FOR INSERT TO authenticated WITH CHECK ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));
2393
+
2394
+ CREATE POLICY plg_conversations_optouts_select ON public.plg_conversations_optouts FOR SELECT TO authenticated USING (((tenant_id IS NULL) OR (tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids))));
2395
+
2396
+ CREATE POLICY plg_conversations_payment_requests_select ON public.plg_conversations_payment_requests FOR SELECT TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));
2397
+
2398
+ CREATE POLICY plg_conversations_select ON public.plg_conversations FOR SELECT TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));
2399
+
2400
+ CREATE POLICY plg_conversations_update ON public.plg_conversations FOR UPDATE TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));
2401
+
2402
+
2403
+ -- \u2500\u2500 acl \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2404
+
2405
+ REVOKE ALL ON FUNCTION public.plg_conversations_match_by_phone(p_channel text, p_last10 text, p_last8 text, p_tenant_id uuid) FROM PUBLIC;
2406
+ GRANT ALL ON FUNCTION public.plg_conversations_match_by_phone(p_channel text, p_last10 text, p_last8 text, p_tenant_id uuid) TO service_role;
2223
2407
 
2224
- ALTER TABLE public.plg_conversation_messages
2225
- ADD COLUMN IF NOT EXISTS subject_type text,
2226
- ADD COLUMN IF NOT EXISTS subject_id text;
2227
-
2228
- -- The correlation read: the most recent outbound message on this thread that
2229
- -- was about something. Partial, because the rows that carry a subject are the
2230
- -- minority and an index over the rest would be paid for on every insert.
2231
- CREATE INDEX IF NOT EXISTS idx_plg_conversation_messages_subject
2232
- ON public.plg_conversation_messages(conversation_id, at DESC)
2233
- WHERE subject_id IS NOT NULL;
2234
-
2235
- -- The other direction, for "show me everything ever said about this booking" \u2014
2236
- -- the thread view a booking detail page wants, and the audit answer to "was the
2237
- -- customer actually told?".
2238
- CREATE INDEX IF NOT EXISTS idx_plg_conversation_messages_by_subject
2239
- ON public.plg_conversation_messages(tenant_id, subject_type, subject_id)
2240
- WHERE subject_id IS NOT NULL;
2408
+ GRANT MAINTAIN ON TABLE public.plg_conversation_messages TO anon;
2409
+ GRANT ALL ON TABLE public.plg_conversation_messages TO authenticated;
2410
+ GRANT ALL ON TABLE public.plg_conversation_messages TO service_role;
2411
+
2412
+ GRANT MAINTAIN ON TABLE public.plg_conversations TO anon;
2413
+ GRANT ALL ON TABLE public.plg_conversations TO authenticated;
2414
+ GRANT ALL ON TABLE public.plg_conversations TO service_role;
2415
+
2416
+ GRANT MAINTAIN ON TABLE public.plg_conversations_channels TO anon;
2417
+ GRANT ALL ON TABLE public.plg_conversations_channels TO authenticated;
2418
+ GRANT ALL ON TABLE public.plg_conversations_channels TO service_role;
2419
+
2420
+ GRANT MAINTAIN ON TABLE public.plg_conversations_optouts TO anon;
2421
+ GRANT ALL ON TABLE public.plg_conversations_optouts TO authenticated;
2422
+ GRANT ALL ON TABLE public.plg_conversations_optouts TO service_role;
2423
+
2424
+ GRANT MAINTAIN ON TABLE public.plg_conversations_payment_requests TO anon;
2425
+ GRANT ALL ON TABLE public.plg_conversations_payment_requests TO authenticated;
2426
+ GRANT ALL ON TABLE public.plg_conversations_payment_requests TO service_role;
2427
+
2428
+ GRANT MAINTAIN ON TABLE public.plg_conversations_webhook_events TO anon;
2429
+ GRANT ALL ON TABLE public.plg_conversations_webhook_events TO authenticated;
2430
+ GRANT ALL ON TABLE public.plg_conversations_webhook_events TO service_role;
2431
+
2432
+
2433
+ -- \u2500\u2500 default privileges: restore \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2434
+ -- Put back, so the NEXT thing installed inherits what the chain had.
2435
+
2436
+ DO $dp$ BEGIN
2437
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;
2438
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
2439
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;
2440
+ END $dp$;
2441
+
2442
+ DO $dp$ BEGIN
2443
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;
2444
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
2445
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;
2446
+ END $dp$;
2447
+
2448
+ DO $dp$ BEGIN
2449
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;
2450
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
2451
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;
2452
+ END $dp$;
2453
+
2454
+ DO $dp$ BEGIN
2455
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;
2456
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
2457
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;
2458
+ END $dp$;
2459
+
2460
+ DO $dp$ BEGIN
2461
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;
2462
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
2463
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;
2464
+ END $dp$;
2465
+
2466
+ DO $dp$ BEGIN
2467
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;
2468
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
2469
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;
2470
+ END $dp$;
2471
+
2472
+ DO $dp$ BEGIN
2473
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;
2474
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
2475
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;
2476
+ END $dp$;
2477
+
2478
+ DO $dp$ BEGIN
2479
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;
2480
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
2481
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;
2482
+ END $dp$;
2483
+
2484
+ DO $dp$ BEGIN
2485
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;
2486
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
2487
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;
2488
+ END $dp$;
2489
+
2490
+ DO $dp$ BEGIN
2491
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;
2492
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
2493
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;
2494
+ END $dp$;
2495
+
2496
+ DO $dp$ BEGIN
2497
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;
2498
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
2499
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;
2500
+ END $dp$;
2501
+
2502
+ DO $dp$ BEGIN
2503
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;
2504
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
2505
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;
2506
+ END $dp$;
2507
+
2508
+ DO $dp$ BEGIN
2509
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO anon$stmt$;
2510
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
2511
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO anon$stmt$;
2512
+ END $dp$;
2513
+
2514
+ DO $dp$ BEGIN
2515
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;
2516
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
2517
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;
2518
+ END $dp$;
2519
+
2520
+ DO $dp$ BEGIN
2521
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;
2522
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
2523
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;
2524
+ END $dp$;
2525
+
2526
+ DO $dp$ BEGIN
2527
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;
2528
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
2529
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;
2530
+ END $dp$;
2531
+
2532
+ DO $dp$ BEGIN
2533
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT MAINTAIN ON TABLES TO anon$stmt$;
2534
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
2535
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT MAINTAIN ON TABLES TO anon$stmt$;
2536
+ END $dp$;
2537
+
2538
+ DO $dp$ BEGIN
2539
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;
2540
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
2541
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;
2542
+ END $dp$;
2543
+
2544
+ DO $dp$ BEGIN
2545
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;
2546
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
2547
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;
2548
+ END $dp$;
2549
+
2550
+ DO $dp$ BEGIN
2551
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;
2552
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
2553
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;
2554
+ END $dp$;
2555
+
2556
+ DO $dp$ BEGIN
2557
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO anon$stmt$;
2558
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
2559
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO anon$stmt$;
2560
+ END $dp$;
2561
+
2562
+ DO $dp$ BEGIN
2563
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;
2564
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
2565
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;
2566
+ END $dp$;
2567
+
2568
+ DO $dp$ BEGIN
2569
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;
2570
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
2571
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;
2572
+ END $dp$;
2573
+
2574
+ DO $dp$ BEGIN
2575
+ EXECUTE $stmt$--
2576
+ -- PostgreSQL database dump complete
2577
+ --$stmt$;
2578
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
2579
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$--
2580
+ -- PostgreSQL database dump complete
2581
+ --$stmt$;
2582
+ END $dp$;
2241
2583
  `;
2242
- var MIGRATION_009_PAYMENT_REQUESTS = `-- ============================================================================
2243
- -- plugin-conversations 009: the charge a customer was asked for inside a
2244
- -- conversation, and the row that stops it being asked for twice.
2584
+ var MIGRATION_001_A_CAIXA_SABE_QUEM_FALOU_POR_ULTIMO = `-- ---------------------------------------------------------------------------
2585
+ -- 001_a_caixa_sabe_quem_falou_por_ultimo.sql
2245
2586
  --
2246
- -- The flow this table exists for: the customer taps "Pagar" on a WhatsApp
2247
- -- confirmation, a Pix is opened at the provider, the copy-and-paste code is
2248
- -- sent back into the same thread, and the money \u2014 or the expiry \u2014 comes back as
2249
- -- a webhook. Four hops, at least one of them delivered more than once.
2587
+ -- A conversa guarda o TEXTO e a HORA da \xFAltima mensagem, e n\xE3o guarda de quem
2588
+ -- ela foi. Sem isso, "quem est\xE1 esperando resposta" \u2014 que \xE9 a pergunta com que
2589
+ -- este produto abre o dia \u2014 n\xE3o \xE9 respond\xEDvel a partir da lista: seria preciso
2590
+ -- carregar as mensagens de cada thread para descobrir quem falou por \xFAltimo.
2250
2591
  --
2251
- -- \`id\` IS the \`external_reference\` sent to the provider, and that is the whole
2252
- -- reconciliation story: the provider echoes it on every payment event, so a
2253
- -- delivery names the row without a lookup table, and a payment created by this
2254
- -- pool can never be confused with one created by the merchant's own AbacatePay
2255
- -- account (the redundancy the design accepted on purpose). It is also the
2256
- -- Idempotency-Key of the create call, so a retried create replays the provider's
2257
- -- own first answer instead of opening a second charge.
2592
+ -- O painel respondia com \`unread_count > 0\`, que \xE9 a aproxima\xE7\xE3o honesta que o
2593
+ -- shape permitia e n\xE3o \xE9 a resposta certa. Ela erra exatamente no caso que mais
2594
+ -- d\xF3i: a thread que algu\xE9m ABRIU, leu, decidiu responder depois e esqueceu. O
2595
+ -- n\xE3o-lida zera na leitura; o cliente continua esperando.
2258
2596
  --
2259
- -- \u2500\u2500 The two columns that are not data, but claims \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2260
- -- \`link_delivered_at\` and \`settled_notified_at\` are LOCKS, taken with a
2261
- -- conditional UPDATE \u2026 WHERE \u2026 IS NULL RETURNING. They exist because of a fact
2262
- -- verified against the sandbox on 2026-08-24: ONE Pix becoming available emits
2263
- -- TWO webhook events with two different event ids \u2014
2597
+ -- \u2500\u2500 Por que TRIGGER e n\xE3o mais uma coluna que cada escritor preenche \u2500\u2500\u2500\u2500\u2500\u2500\u2500
2264
2598
  --
2265
- -- payment.link_generated evt_2448\u2026 occurred_at 16:52:49.640Z
2266
- -- payment.approval_available evt_750b\u2026 occurred_at 16:52:49.639Z
2599
+ -- \`last_message_preview\` e \`last_message_at\` s\xE3o escritos hoje em CINCO lugares:
2600
+ -- o provider do navegador (criar conversa, enviar), a fun\xE7\xE3o \`messaging-send\` e
2601
+ -- o \`tyxter-webhook\` (duas vezes, contando o handler de pagamento). Uma sexta
2602
+ -- coluna com a mesma disciplina \xE9 uma sexta chance de algu\xE9m esquecer \u2014 e o
2603
+ -- modo de falhar \xE9 silencioso: a fila simplesmente para de listar algu\xE9m.
2267
2604
  --
2268
- -- \u2014 carrying identical \`data\`. The dedupe ledger (005) cannot help: they are
2269
- -- genuinely two events. Without a claim on the ROW the customer receives the
2270
- -- same Pix code twice, one second apart, which reads as a double charge.
2605
+ -- O gatilho tem UM escritor e n\xE3o pode ser esquecido pelo pr\xF3ximo chamador. Ele
2606
+ -- \xE9 deliberadamente estreito: cuida s\xF3 de \`last_message_direction\`. Trazer o
2607
+ -- preview e a hora para c\xE1 seria a corre\xE7\xE3o certa e \xE9 uma mudan\xE7a maior, com
2608
+ -- cinco chamadores para reconciliar \u2014 fica anotado, n\xE3o feito aqui.
2609
+ -- ---------------------------------------------------------------------------
2610
+
2611
+ ALTER TABLE public.plg_conversations
2612
+ ADD COLUMN IF NOT EXISTS last_message_direction text;
2613
+
2614
+ DO $$
2615
+ BEGIN
2616
+ ALTER TABLE public.plg_conversations
2617
+ ADD CONSTRAINT plg_conversations_last_message_direction_check
2618
+ CHECK (last_message_direction IS NULL
2619
+ OR last_message_direction = ANY (ARRAY['inbound'::text, 'outbound'::text]));
2620
+ EXCEPTION WHEN duplicate_object THEN
2621
+ NULL;
2622
+ END $$;
2623
+
2624
+ COMMENT ON COLUMN public.plg_conversations.last_message_direction IS
2625
+ 'Quem falou por \xFAltimo: inbound = o cliente, outbound = a casa (001). Mantida por gatilho em plg_conversation_messages, nunca escrita \xE0 m\xE3o. NULL = thread anterior a esta migration cujo backfill n\xE3o achou mensagem nenhuma.';
2626
+
2627
+ -- \u2500\u2500 o \xFAnico escritor \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2271
2628
  --
2272
- -- \u2500\u2500 Money moves forward only \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2273
- -- \`status\` is applied monotonically by the same rule the delivery statuses use,
2274
- -- because webhooks are unordered: a \`link_generated\` arriving after a \`paid\`
2275
- -- must not walk a settled charge back to "waiting". The CHECK carries the
2276
- -- provider's own vocabulary verbatim, including \`approval_requested\` and
2277
- -- \`approved\`, which the design's list did not have and the OpenAPI does.
2629
+ -- AFTER INSERT: a mensagem j\xE1 existe quando a conversa \xE9 atualizada, ent\xE3o uma
2630
+ -- falha aqui n\xE3o pode desfazer o recebimento \u2014 e recebimento perdido \xE9 pior que
2631
+ -- fila desatualizada.
2278
2632
  --
2279
- -- Writes are SERVICE-ROLE ONLY. A member who could update \`status\` could mark a
2280
- -- charge paid; one who could update \`amount_cents\` could change what a customer
2281
- -- is asked for after the fact. Members read \u2014 "was this booking paid, and how
2282
- -- much for" is a question the inbox and the booking page both have to answer.
2283
- -- Idempotent + safe to re-run.
2284
- -- ============================================================================
2633
+ -- Sem cl\xE1usula de ordem: a mensagem que acabou de entrar \xC9 a \xFAltima. Comparar
2634
+ -- com \`last_message_at\` para decidir seria correto e traria uma corrida com os
2635
+ -- cinco escritores daquela coluna, que \xE9 justamente o problema que o gatilho
2636
+ -- existe para n\xE3o ter.
2637
+ CREATE OR REPLACE FUNCTION public.plg_conversations_stamp_direction()
2638
+ RETURNS trigger
2639
+ LANGUAGE plpgsql
2640
+ SECURITY DEFINER
2641
+ SET search_path TO ''
2642
+ AS $function$
2643
+ BEGIN
2644
+ IF NEW.direction IS NULL THEN
2645
+ RETURN NULL;
2646
+ END IF;
2285
2647
 
2286
- CREATE TABLE IF NOT EXISTS public.plg_conversations_payment_requests (
2287
- -- Ours, and the provider's \`external_reference\`, and the create's
2288
- -- Idempotency-Key. One identity, so there is nothing to keep in step.
2289
- id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
2290
- tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
2291
- conversation_id uuid REFERENCES public.plg_conversations(id) ON DELETE SET NULL,
2292
- -- The outbound message carrying the Pix code, once it has been sent. NULL
2293
- -- until then, and that is exactly how "the code has not gone out yet" is read.
2294
- message_id uuid REFERENCES public.plg_conversation_messages(id) ON DELETE SET NULL,
2295
- -- Polymorphic and deliberately not a foreign key, for the same reason 008's
2296
- -- subject columns are not: 'booking' today, 'order' and 'invoice' later, in
2297
- -- tables this plugin does not own.
2298
- subject_type text,
2299
- subject_id text,
2300
- -- Integers, because the provider takes \`amount_brl_centavos\` and a float that
2301
- -- rounds to 4499 is a customer charged one centavo less than the salon reads.
2302
- amount_cents integer NOT NULL CHECK (amount_cents > 0),
2303
- currency text NOT NULL DEFAULT 'BRL',
2304
- description text,
2305
- -- Provider-neutral, like every other column in this plugin: the second
2306
- -- payment rail must not need a second table.
2307
- provider text NOT NULL,
2308
- status text NOT NULL DEFAULT 'created'
2309
- CHECK (status IN ('created', 'link_generated', 'approval_requested', 'approved',
2310
- 'paid', 'failed', 'expired', 'cancelled')),
2311
- provider_payment_id text,
2312
- payment_link_url text,
2313
- -- Kept because the customer may lose the message and ask for it again, and
2314
- -- re-reading a code we already have beats opening a second charge.
2315
- pix_copy_paste text,
2316
- -- The claims. See the header.
2317
- link_delivered_at timestamptz,
2318
- settled_notified_at timestamptz,
2319
- settled_at timestamptz,
2320
- created_at timestamptz NOT NULL DEFAULT now(),
2321
- updated_at timestamptz NOT NULL DEFAULT now()
2322
- );
2323
- ALTER TABLE public.plg_conversations_payment_requests ENABLE ROW LEVEL SECURITY;
2648
+ UPDATE public.plg_conversations
2649
+ SET last_message_direction = NEW.direction
2650
+ WHERE id = NEW.conversation_id;
2651
+
2652
+ RETURN NULL;
2653
+ END $function$;
2654
+
2655
+ COMMENT ON FUNCTION public.plg_conversations_stamp_direction() IS
2656
+ 'Carimba plg_conversations.last_message_direction a cada mensagem inserida (001). O \xFAnico escritor da coluna.';
2657
+
2658
+ DROP TRIGGER IF EXISTS plg_conversation_messages_stamp_direction ON public.plg_conversation_messages;
2659
+ CREATE TRIGGER plg_conversation_messages_stamp_direction
2660
+ AFTER INSERT ON public.plg_conversation_messages
2661
+ FOR EACH ROW
2662
+ EXECUTE FUNCTION public.plg_conversations_stamp_direction();
2324
2663
 
2325
- -- The webhook's lookup: an event names the payment, and this finds the row.
2326
- -- UNIQUE where present \u2014 two rows claiming one provider payment would make the
2327
- -- settlement a coin toss.
2328
- CREATE UNIQUE INDEX IF NOT EXISTS uq_plg_conversations_payment_requests_provider
2329
- ON public.plg_conversations_payment_requests(provider, provider_payment_id)
2330
- WHERE provider_payment_id IS NOT NULL;
2331
-
2332
- -- At most ONE live charge per subject. A customer tapping "Pagar" twice sends
2333
- -- two \`message.received\` events with two different event ids, so the dedupe
2334
- -- ledger lets both through \u2014 this is what stops the second one opening a second
2335
- -- Pix for the same appointment. Settled rows are excluded: a booking legitimately
2336
- -- gets a new charge after one expired.
2337
- CREATE UNIQUE INDEX IF NOT EXISTS uq_plg_conversations_payment_requests_open_subject
2338
- ON public.plg_conversations_payment_requests(tenant_id, subject_type, subject_id)
2339
- WHERE subject_id IS NOT NULL
2340
- AND status IN ('created', 'link_generated', 'approval_requested', 'approved');
2341
-
2342
- CREATE INDEX IF NOT EXISTS idx_plg_conversations_payment_requests_tenant
2343
- ON public.plg_conversations_payment_requests(tenant_id, created_at DESC);
2344
-
2345
- CREATE INDEX IF NOT EXISTS idx_plg_conversations_payment_requests_subject
2346
- ON public.plg_conversations_payment_requests(tenant_id, subject_type, subject_id)
2347
- WHERE subject_id IS NOT NULL;
2348
-
2349
- -- RLS: members read their own tenant's charges. No INSERT/UPDATE/DELETE policy
2350
- -- and no write GRANT \u2014 every write here is made by the webhook with the
2351
- -- service-role key, acting on a signed delivery.
2352
- DROP POLICY IF EXISTS plg_conversations_payment_requests_select
2353
- ON public.plg_conversations_payment_requests;
2354
- CREATE POLICY plg_conversations_payment_requests_select
2355
- ON public.plg_conversations_payment_requests
2356
- FOR SELECT TO authenticated
2357
- USING (tenant_id IN (SELECT public.user_tenant_ids()));
2358
- GRANT SELECT ON public.plg_conversations_payment_requests TO authenticated;
2664
+ -- \u2500\u2500 backfill \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2665
+ --
2666
+ -- DISTINCT ON pega uma linha por conversa, a mais recente. \`at DESC NULLS LAST\`
2667
+ -- e depois \`id DESC\` porque \`at\` tem default e n\xE3o NOT NULL: duas mensagens no
2668
+ -- mesmo instante (ou ambas sem hora) precisam de um desempate est\xE1vel, ou o
2669
+ -- backfill devolve resultado diferente a cada execu\xE7\xE3o.
2670
+ UPDATE public.plg_conversations c
2671
+ SET last_message_direction = m.direction
2672
+ FROM (
2673
+ SELECT DISTINCT ON (conversation_id) conversation_id, direction
2674
+ FROM public.plg_conversation_messages
2675
+ WHERE direction IS NOT NULL
2676
+ ORDER BY conversation_id, at DESC NULLS LAST, id DESC
2677
+ ) m
2678
+ WHERE m.conversation_id = c.id
2679
+ AND c.last_message_direction IS DISTINCT FROM m.direction;
2680
+
2681
+ -- S\xF3 as threads que interessam \xE0 fila, e s\xF3 quando o cliente falou por \xFAltimo:
2682
+ -- \xE9 o \xEDndice que a pergunta "quem est\xE1 esperando" faz, e ele fica pequeno
2683
+ -- porque a maioria das conversas de um tenant est\xE1 encerrada.
2684
+ CREATE INDEX IF NOT EXISTS idx_plg_conversations_waiting
2685
+ ON public.plg_conversations (tenant_id, last_message_at DESC)
2686
+ WHERE status = 'open' AND last_message_direction = 'inbound';
2359
2687
  `;
2688
+ var MIGRATIONS = [
2689
+ { id: "000_baseline", sql: MIGRATION_000_BASELINE },
2690
+ { id: "001_a_caixa_sabe_quem_falou_por_ultimo", sql: MIGRATION_001_A_CAIXA_SABE_QUEM_FALOU_POR_ULTIMO }
2691
+ ];
2360
2692
 
2361
2693
  // src/index.ts
2362
2694
  function createSafeProvider() {
@@ -2388,7 +2720,8 @@ function createConversationsPlugin(options) {
2388
2720
  contactLookup: options?.contactLookup,
2389
2721
  contactEntityDef: options?.contactEntityDef
2390
2722
  };
2391
- const PageComponent = () => React4.createElement(ConversationsPage, { store: store2, config });
2723
+ const dashboardWidgets = createConversationsDashboardWidgets({ store: store2, config });
2724
+ const PageComponent = () => React4__default.createElement(ConversationsPage, { store: store2, config });
2392
2725
  PageComponent.displayName = "ConversationsPage";
2393
2726
  return {
2394
2727
  id: "conversations",
@@ -2424,6 +2757,7 @@ function createConversationsPlugin(options) {
2424
2757
  }
2425
2758
  ],
2426
2759
  widgets: [],
2760
+ dashboardWidgets,
2427
2761
  events: [
2428
2762
  { name: "conversations.message.received", description: "An inbound message arrived on any channel" },
2429
2763
  { name: "conversations.message.sent", description: "An outbound message was sent" },
@@ -2493,62 +2827,15 @@ function createConversationsPlugin(options) {
2493
2827
  permission: { feature: "conversations", action: "create" }
2494
2828
  }
2495
2829
  ],
2496
- migrations: [
2497
- {
2498
- id: "conversations-001-base-tables",
2499
- version: "1.0.0",
2500
- sql: MIGRATION_001_CONVERSATIONS,
2501
- description: "Create plg_conversations and plg_conversation_messages (tenant-scoped RLS)"
2502
- },
2503
- {
2504
- id: "conversations-002-contact-person",
2505
- version: "1.1.0",
2506
- sql: MIGRATION_002_CONTACT_PERSON,
2507
- description: "Link threads to public.people via contact_person_id (nullable, ON DELETE SET NULL)"
2508
- },
2509
- {
2510
- id: "conversations-003-channels",
2511
- version: "1.2.0",
2512
- sql: MIGRATION_003_CHANNELS,
2513
- description: "Create plg_conversations_channels \u2014 which number a message leaves by (product fallback vs tenant dedicated); member read, service-role writes"
2514
- },
2515
- {
2516
- id: "conversations-004-message-delivery",
2517
- version: "1.2.0",
2518
- sql: MIGRATION_004_MESSAGE_DELIVERY,
2519
- description: "Message provider_message_id + delivery_status + sender attribution (sender_kind / sender_label)"
2520
- },
2521
- {
2522
- id: "conversations-005-webhook-events",
2523
- version: "1.3.0",
2524
- sql: MIGRATION_005_WEBHOOK_EVENTS,
2525
- description: "Create plg_conversations_webhook_events \u2014 the dedupe ledger that makes an at-least-once webhook safe to apply"
2526
- },
2527
- {
2528
- id: "conversations-006-optouts",
2529
- version: "1.3.0",
2530
- sql: MIGRATION_006_OPTOUTS,
2531
- description: "Create plg_conversations_optouts \u2014 consent suppression read by messaging-send before every send"
2532
- },
2533
- {
2534
- id: "conversations-007-inbound-routing",
2535
- version: "1.3.0",
2536
- sql: MIGRATION_007_INBOUND_ROUTING,
2537
- description: "Widen delivery_status to the statuses Tyxter emits + plg_conversations_match_by_phone (BR digit-suffix thread matching, service-role only)"
2538
- },
2539
- {
2540
- id: "conversations-008-message-subject",
2541
- version: "1.3.0",
2542
- sql: MIGRATION_008_MESSAGE_SUBJECT,
2543
- description: "Message subject_type / subject_id \u2014 the correlation anchor a button reply falls back to when the provider echoes the button text instead of our payload"
2544
- },
2545
- {
2546
- id: "conversations-009-payment-requests",
2547
- version: "1.4.0",
2548
- sql: MIGRATION_009_PAYMENT_REQUESTS,
2549
- description: "Create plg_conversations_payment_requests \u2014 the charge opened inside a conversation, with the row-level claims that make one Pix arrive once and a settlement notify once"
2550
- }
2551
- ],
2830
+ // One baseline per unit since #338 — read from the generated barrel
2831
+ // rather than listed here, so regenerating the SQL never needs a
2832
+ // manifest edit to match.
2833
+ migrations: MIGRATIONS.map((entry) => ({
2834
+ id: `conversations-${entry.id}`,
2835
+ version: "1.0.0",
2836
+ sql: entry.sql,
2837
+ description: "The whole schema this plugin provisions, as one file: every table, view, function, policy and grant, plus the reference rows it seeds. Replaced the per-change chain in the 1.0.0 squash (#338); the files it collapsed are kept, unapplied, in src/migrations.archive/."
2838
+ })),
2552
2839
  functions: [
2553
2840
  {
2554
2841
  slug: "tyxter-webhook",
@@ -2607,6 +2894,20 @@ function createConversationsPlugin(options) {
2607
2894
  ]
2608
2895
  }
2609
2896
  ],
2897
+ // The settings screen exists so the connector below can be REACHED: the
2898
+ // Integrations tab is drawn by PluginSettingsPanel, so a plugin with a
2899
+ // connector and no settings route ships a connector nobody can open.
2900
+ settings: [
2901
+ {
2902
+ id: "conversations",
2903
+ label: "Conversas",
2904
+ icon: "MessageCircle",
2905
+ component: ConversationsSettingsTab,
2906
+ order: 30,
2907
+ permission: { feature: "conversations", action: "read" }
2908
+ }
2909
+ ],
2910
+ onboarding: { program: buildConversationsOnboarding() },
2610
2911
  // WhatsApp through Tyxter. Declared here because this plugin IS the inbox
2611
2912
  // the messages land in — the connector configures inside Conversas.
2612
2913
  connectors: [tyxterConnectorDef],
@@ -2614,6 +2915,6 @@ function createConversationsPlugin(options) {
2614
2915
  };
2615
2916
  }
2616
2917
 
2617
- export { PAYMENT_PREFLIGHT_FUNCTION, TYXTER_CONNECTOR_ID, TYXTER_LATENCY_BUDGET_MS, canOfferPayment, createConversationsPlugin, createMockConversationsProvider, createSupabaseConversationsProvider, listMessagingChannels, openPaymentSetupSession, readPaymentReadiness, tyxterConnectorDef };
2918
+ export { PAYMENT_PREFLIGHT_FUNCTION, TYXTER_CONNECTOR_ID, TYXTER_LATENCY_BUDGET_MS, canOfferPayment, createConversationsPlugin, createMockConversationsProvider, createSupabaseConversationsProvider, isWaitingOnUs, listMessagingChannels, openPaymentSetupSession, readPaymentReadiness, tyxterConnectorDef };
2618
2919
  //# sourceMappingURL=index.js.map
2619
2920
  //# sourceMappingURL=index.js.map