@fayz-ai/plugin-conversations 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,9 +1,9 @@
1
- import React4 from 'react';
2
- import { registerTranslations, getSupabaseClientOptional, getActiveTenantId, useTranslation } from '@fayz-ai/core';
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';
3
3
  import { useStore } from 'zustand';
4
- import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
5
- import { SquarePen, MessageSquare, Globe, Mail, Instagram, Phone, Search, Inbox, ChevronLeft, Clock, Archive, PanelRight, Send, X, User, MapPin, Tag, StickyNote, Link2 } from 'lucide-react';
6
- import { PageHeaderActions, Button, cn, Input, Skeleton, toast } from '@fayz-ai/ui';
4
+ import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
5
+ 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
7
  import { PermissionGate, useLimitGuard, RightRailPage, ContactPicker, invalidateLimit } from '@fayz-ai/admin';
8
8
  import { createStore } from 'zustand/vanilla';
9
9
 
@@ -14,16 +14,16 @@ var DEFAULT_CONVERSATIONS_CONFIG = {
14
14
  var StoreContext = React4.createContext(null);
15
15
  var ConfigContext = React4.createContext(DEFAULT_CONVERSATIONS_CONFIG);
16
16
  function ConversationsContextProvider({
17
- store,
17
+ store: store2,
18
18
  config = DEFAULT_CONVERSATIONS_CONFIG,
19
19
  children
20
20
  }) {
21
- return /* @__PURE__ */ jsx(StoreContext.Provider, { value: store, children: /* @__PURE__ */ jsx(ConfigContext.Provider, { value: config, children }) });
21
+ return /* @__PURE__ */ jsx(StoreContext.Provider, { value: store2, children: /* @__PURE__ */ jsx(ConfigContext.Provider, { value: config, children }) });
22
22
  }
23
23
  function useConversationsStore(selector) {
24
- const store = React4.useContext(StoreContext);
25
- if (!store) throw new Error("useConversationsStore must be used within ConversationsPage");
26
- return useStore(store, selector);
24
+ const store2 = React4.useContext(StoreContext);
25
+ if (!store2) throw new Error("useConversationsStore must be used within ConversationsPage");
26
+ return useStore(store2, selector);
27
27
  }
28
28
  function useConversationsConfig() {
29
29
  return React4.useContext(ConfigContext);
@@ -604,11 +604,11 @@ function InboxView() {
604
604
  ] })
605
605
  ] });
606
606
  }
607
- function ConversationsPage({ store, config }) {
607
+ function ConversationsPage({ store: store2, config }) {
608
608
  React4.useEffect(() => {
609
- void store.getState().load();
609
+ void store2.getState().load();
610
610
  }, []);
611
- return /* @__PURE__ */ jsx(ConversationsContextProvider, { store, config, children: /* @__PURE__ */ jsx(InboxView, {}) });
611
+ return /* @__PURE__ */ jsx(ConversationsContextProvider, { store: store2, config, children: /* @__PURE__ */ jsx(InboxView, {}) });
612
612
  }
613
613
 
614
614
  // src/data/accents.ts
@@ -870,7 +870,8 @@ function createMockConversationsProvider(config) {
870
870
  // src/data/tables.ts
871
871
  var T = {
872
872
  conversations: "plg_conversations",
873
- messages: "plg_conversation_messages"
873
+ messages: "plg_conversation_messages",
874
+ channels: "plg_conversations_channels"
874
875
  };
875
876
 
876
877
  // src/data/supabase.ts
@@ -909,7 +910,7 @@ function createSupabaseConversationsProvider(config) {
909
910
  if (!config?.tenantId) return void 0;
910
911
  return typeof config.tenantId === "function" ? config.tenantId() : config.tenantId;
911
912
  }
912
- function client() {
913
+ function client2() {
913
914
  const supabase = config?.supabaseClient ?? getSupabaseClientOptional();
914
915
  if (!supabase) {
915
916
  throw new Error(
@@ -920,7 +921,7 @@ function createSupabaseConversationsProvider(config) {
920
921
  }
921
922
  return {
922
923
  async listConversations(query) {
923
- let q = client().from(T.conversations).select("*");
924
+ let q = client2().from(T.conversations).select("*");
924
925
  const tenantId = resolveTenantId();
925
926
  if (tenantId) q = q.eq("tenant_id", tenantId);
926
927
  if (query?.channel && query.channel !== "all") {
@@ -943,7 +944,7 @@ function createSupabaseConversationsProvider(config) {
943
944
  return (data ?? []).map(mapConversation);
944
945
  },
945
946
  async getMessages(conversationId) {
946
- const selected = client().from(T.messages).select("*");
947
+ const selected = client2().from(T.messages).select("*");
947
948
  const filtered = selected.eq(
948
949
  "conversation_id",
949
950
  conversationId
@@ -980,7 +981,7 @@ function createSupabaseConversationsProvider(config) {
980
981
  note: input.note?.trim() || null
981
982
  };
982
983
  if (tenantId) convRow.tenant_id = tenantId;
983
- const { data: created, error } = await client().from(T.conversations).insert(convRow).select().single();
984
+ const { data: created, error } = await client2().from(T.conversations).insert(convRow).select().single();
984
985
  if (error) throw error;
985
986
  if (!created) throw new Error("Conversation not created");
986
987
  if (firstMessage) {
@@ -993,13 +994,13 @@ function createSupabaseConversationsProvider(config) {
993
994
  at: now
994
995
  };
995
996
  if (tenantId) msgRow.tenant_id = tenantId;
996
- await client().from(T.messages).insert(msgRow);
997
+ await client2().from(T.messages).insert(msgRow);
997
998
  }
998
999
  return mapConversation(created);
999
1000
  },
1000
1001
  async sendMessage(input) {
1001
1002
  const tenantId = resolveTenantId();
1002
- const convSelected = client().from(T.conversations).select(
1003
+ const convSelected = client2().from(T.conversations).select(
1003
1004
  "channel"
1004
1005
  );
1005
1006
  const convFiltered = convSelected.eq(
@@ -1018,9 +1019,9 @@ function createSupabaseConversationsProvider(config) {
1018
1019
  at
1019
1020
  };
1020
1021
  if (tenantId) row.tenant_id = tenantId;
1021
- const { data: created, error } = await client().from(T.messages).insert(row).select().single();
1022
+ const { data: created, error } = await client2().from(T.messages).insert(row).select().single();
1022
1023
  if (error) throw error;
1023
- await client().from(T.conversations).update({
1024
+ await client2().from(T.conversations).update({
1024
1025
  last_message_preview: input.body,
1025
1026
  last_message_at: at,
1026
1027
  unread_count: 0,
@@ -1029,13 +1030,13 @@ function createSupabaseConversationsProvider(config) {
1029
1030
  return mapMessage(created ?? row);
1030
1031
  },
1031
1032
  async markRead(conversationId) {
1032
- const { error } = await client().from(T.conversations).update({
1033
+ const { error } = await client2().from(T.conversations).update({
1033
1034
  unread_count: 0
1034
1035
  }).eq("id", conversationId);
1035
1036
  if (error) throw error;
1036
1037
  },
1037
1038
  async setStatus(conversationId, status) {
1038
- const updated = client().from(T.conversations).update({
1039
+ const updated = client2().from(T.conversations).update({
1039
1040
  status
1040
1041
  });
1041
1042
  const filtered = updated.eq("id", conversationId);
@@ -1260,6 +1261,471 @@ var conversationsLocales = {
1260
1261
  en,
1261
1262
  "pt-BR": ptBR
1262
1263
  };
1264
+ function mapChannel(r) {
1265
+ return {
1266
+ id: String(r.id),
1267
+ tenantId: r.tenant_id ?? null,
1268
+ channel: r.channel ?? "whatsapp",
1269
+ provider: String(r.provider ?? ""),
1270
+ providerNumberId: String(r.provider_number_id ?? ""),
1271
+ phoneE164: r.phone_e164 ?? null,
1272
+ kind: r.kind ?? "dedicated",
1273
+ status: r.status ?? "requested",
1274
+ displayName: r.display_name ?? null,
1275
+ metadata: r.metadata ?? {},
1276
+ createdAt: String(r.created_at ?? ""),
1277
+ updatedAt: String(r.updated_at ?? "")
1278
+ };
1279
+ }
1280
+ async function listMessagingChannels(options = {}) {
1281
+ const supabase = options.supabaseClient ?? getSupabaseClientOptional();
1282
+ if (!supabase) {
1283
+ throw new Error(
1284
+ "[plugin-conversations] Supabase client not available. Pass supabaseClient or register the global client via createFayzApp."
1285
+ );
1286
+ }
1287
+ let q = supabase.from(T.channels).select("*");
1288
+ if (options.provider) q = q.eq("provider", options.provider);
1289
+ if (options.channel) q = q.eq("channel", options.channel);
1290
+ q = q.order("kind", { ascending: true });
1291
+ const { data, error } = await q;
1292
+ if (error) throw error;
1293
+ return (data ?? []).map(mapChannel);
1294
+ }
1295
+ var CHANNEL_CLAIM_TIMELINE = [
1296
+ "requested",
1297
+ "provisioning",
1298
+ "provisioned",
1299
+ "verifying",
1300
+ "active"
1301
+ ];
1302
+ var LIVE_CHANNEL_STATUSES = [
1303
+ "requested",
1304
+ "provisioning",
1305
+ "provisioned",
1306
+ "verifying",
1307
+ "active"
1308
+ ];
1309
+ function isDeadChannelStatus(status) {
1310
+ return !LIVE_CHANNEL_STATUSES.includes(status);
1311
+ }
1312
+ function isPendingChannelStatus(status) {
1313
+ return LIVE_CHANNEL_STATUSES.includes(status) && status !== "active";
1314
+ }
1315
+ function findDedicatedChannel(channels, tenantId) {
1316
+ return channels.find(
1317
+ (channel) => channel.kind === "dedicated" && (!tenantId || channel.tenantId === tenantId) && !isDeadChannelStatus(channel.status)
1318
+ ) ?? null;
1319
+ }
1320
+ function findFallbackChannel(channels) {
1321
+ return channels.find((channel) => channel.kind === "fallback") ?? null;
1322
+ }
1323
+ function canClaimDedicated(channels, tenantId) {
1324
+ return findDedicatedChannel(channels, tenantId) === null;
1325
+ }
1326
+ var TYXTER_NUMBER_CLAIM_FUNCTION = "tyxter-number-claim";
1327
+ function client() {
1328
+ const supabase = getSupabaseClientOptional();
1329
+ if (!supabase) throw new Error("Sem conex\xE3o com o banco para falar com a Tyxter.");
1330
+ return supabase;
1331
+ }
1332
+ async function invoke(body) {
1333
+ let headers;
1334
+ try {
1335
+ headers = { [CONNECTOR_RUNTIME_TOKEN_HEADER]: await connectorRuntimeToken({ tenantId: String(body.tenantId ?? "") }) };
1336
+ } catch {
1337
+ headers = void 0;
1338
+ }
1339
+ const { data, error } = await client().functions.invoke(TYXTER_NUMBER_CLAIM_FUNCTION, {
1340
+ body,
1341
+ ...headers ? { headers } : {}
1342
+ });
1343
+ if (error) throw new Error(error.message);
1344
+ return data;
1345
+ }
1346
+ function returnUrlFrom(href) {
1347
+ if (!href) return null;
1348
+ try {
1349
+ const url = new URL(href);
1350
+ url.hash = "";
1351
+ url.username = "";
1352
+ url.password = "";
1353
+ return url.toString();
1354
+ } catch {
1355
+ return null;
1356
+ }
1357
+ }
1358
+ function currentReturnUrl() {
1359
+ return typeof window === "undefined" ? null : returnUrlFrom(window.location.href);
1360
+ }
1361
+ function claimDedicatedNumber(input) {
1362
+ return invoke({
1363
+ action: "claim",
1364
+ tenantId: input.tenantId,
1365
+ ddd: input.ddd,
1366
+ returnUrl: currentReturnUrl()
1367
+ });
1368
+ }
1369
+ function refreshDedicatedNumbers(tenantId) {
1370
+ return invoke({ action: "status", tenantId });
1371
+ }
1372
+ function metaRegistrationHandoff(tenantId) {
1373
+ return invoke({ action: "handoff", tenantId, returnUrl: currentReturnUrl() });
1374
+ }
1375
+ var PAYMENT_PREFLIGHT_FUNCTION = "tyxter-payment-preflight";
1376
+ async function invoke2(body, tenantId) {
1377
+ const supabase = getSupabaseClientOptional();
1378
+ if (!supabase) throw new Error("Sem conex\xE3o com o banco para verificar os pagamentos.");
1379
+ const tenant = tenantId ?? getActiveTenantId();
1380
+ if (!tenant) throw new Error("Sem neg\xF3cio selecionado.");
1381
+ const { data, error } = await supabase.functions.invoke(PAYMENT_PREFLIGHT_FUNCTION, {
1382
+ body: { ...body, tenantId: tenant }
1383
+ });
1384
+ if (!error) return data;
1385
+ let message = "";
1386
+ try {
1387
+ const payload = await error?.context?.json?.();
1388
+ message = String(payload?.message ?? payload?.error ?? "").trim();
1389
+ } catch {
1390
+ }
1391
+ throw new Error(message || error.message);
1392
+ }
1393
+ async function readPaymentReadiness(tenantId) {
1394
+ const answer = await invoke2({ action: "status" }, tenantId);
1395
+ return answer.payments;
1396
+ }
1397
+ async function openPaymentSetupSession(tenantId) {
1398
+ return await invoke2({ action: "connect" }, tenantId);
1399
+ }
1400
+ function canOfferPayment(readiness) {
1401
+ if (!readiness?.ready) return false;
1402
+ return readiness.mode !== null;
1403
+ }
1404
+ var TYXTER_CONNECTOR_ID = "tyxter";
1405
+ var TYXTER_LATENCY_BUDGET_MS = 4e3;
1406
+ var store = createConnectionStore(TYXTER_CONNECTOR_ID);
1407
+ var KIND_LABEL = {
1408
+ fallback: "N\xFAmero da plataforma",
1409
+ dedicated: "N\xFAmero dedicado"
1410
+ };
1411
+ var STATUS_LABEL = {
1412
+ requested: "Solicitado",
1413
+ provisioning: "Em prepara\xE7\xE3o",
1414
+ provisioned: "Preparado",
1415
+ verifying: "Em verifica\xE7\xE3o",
1416
+ active: "Ativo",
1417
+ failed: "Falhou",
1418
+ released: "Liberado",
1419
+ disconnected: "Desconectado"
1420
+ };
1421
+ var POLL_INTERVAL_MS = 5e3;
1422
+ function ClaimTimeline({ status }) {
1423
+ if (status === "failed" || status === "released" || status === "disconnected") {
1424
+ return /* @__PURE__ */ jsx("p", { className: "text-xs text-destructive", children: status === "failed" ? "O pedido n\xE3o foi conclu\xEDdo. Voc\xEA pode tentar de novo." : `Este n\xFAmero saiu do ar (${STATUS_LABEL[status]}).` });
1425
+ }
1426
+ const current = CHANNEL_CLAIM_TIMELINE.indexOf(status);
1427
+ return /* @__PURE__ */ jsx("ol", { className: "space-y-1", children: CHANNEL_CLAIM_TIMELINE.map((step, index) => {
1428
+ const done = current > index;
1429
+ const now = current === index;
1430
+ return /* @__PURE__ */ jsxs(
1431
+ "li",
1432
+ {
1433
+ className: `flex items-center gap-2 text-xs ${now ? "font-medium text-foreground" : done ? "text-muted-foreground" : "text-muted-foreground/60"}`,
1434
+ children: [
1435
+ /* @__PURE__ */ jsx("span", { "aria-hidden": true, className: `h-1.5 w-1.5 rounded-full ${done || now ? "bg-primary" : "bg-border"}` }),
1436
+ STATUS_LABEL[step],
1437
+ now && status !== "active" && /* @__PURE__ */ jsx(Loader2, { className: "h-3 w-3 animate-spin" })
1438
+ ]
1439
+ },
1440
+ step
1441
+ );
1442
+ }) });
1443
+ }
1444
+ function MetaRegistrationCard({ handoff }) {
1445
+ return /* @__PURE__ */ jsxs("div", { className: "space-y-2 rounded-md border border-primary/40 bg-primary/5 p-3", children: [
1446
+ /* @__PURE__ */ jsx("p", { className: "text-xs font-medium text-foreground", children: "Falta liberar o n\xFAmero na Meta" }),
1447
+ /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: "O n\xFAmero j\xE1 \xE9 seu, mas ainda n\xE3o pode enviar: a Meta exige que algu\xE9m conclua o cadastro do WhatsApp numa janela do navegador. \xC9 r\xE1pido, e s\xF3 precisa ser feito uma vez." }),
1448
+ /* @__PURE__ */ jsx(Button, { asChild: true, size: "sm", children: /* @__PURE__ */ jsxs("a", { href: handoff.url, target: "_blank", rel: "noopener noreferrer", children: [
1449
+ "Concluir cadastro na Meta",
1450
+ /* @__PURE__ */ jsx(ExternalLink, { className: "ml-1.5 h-3.5 w-3.5" })
1451
+ ] }) }),
1452
+ handoff.source === "default" && /* @__PURE__ */ jsx("p", { className: "text-[11px] text-muted-foreground", children: "Confira se a conta aberta no navegador \xE9 a certa antes de concluir." })
1453
+ ] });
1454
+ }
1455
+ function TyxterPaymentsPanel() {
1456
+ const [readiness, setReadiness] = useState(null);
1457
+ const [error, setError] = useState(null);
1458
+ const [loading, setLoading] = useState(true);
1459
+ const [handoff, setHandoff] = useState(null);
1460
+ const [opening, setOpening] = useState(false);
1461
+ useEffect(() => {
1462
+ let cancelled = false;
1463
+ void readPaymentReadiness().then((value) => {
1464
+ if (!cancelled) setReadiness(value);
1465
+ }).catch((caught) => {
1466
+ if (!cancelled) setError(caught instanceof Error ? caught.message : "N\xE3o deu para verificar agora.");
1467
+ }).finally(() => {
1468
+ if (!cancelled) setLoading(false);
1469
+ });
1470
+ return () => {
1471
+ cancelled = true;
1472
+ };
1473
+ }, []);
1474
+ const connect = async () => {
1475
+ setOpening(true);
1476
+ setError(null);
1477
+ try {
1478
+ const session = await openPaymentSetupSession();
1479
+ setHandoff(session.setupUrl);
1480
+ } catch (caught) {
1481
+ setError(caught instanceof Error ? caught.message : "N\xE3o deu para abrir a configura\xE7\xE3o.");
1482
+ } finally {
1483
+ setOpening(false);
1484
+ }
1485
+ };
1486
+ const ready = canOfferPayment(readiness);
1487
+ return /* @__PURE__ */ jsxs("div", { className: "space-y-3 border-t pt-4", children: [
1488
+ /* @__PURE__ */ jsxs("div", { children: [
1489
+ /* @__PURE__ */ jsx("p", { className: "text-sm font-medium text-foreground", children: "Pagamento no WhatsApp" }),
1490
+ /* @__PURE__ */ jsx("p", { className: "mt-0.5 text-xs text-muted-foreground", children: "Com isso ligado, o cliente recebe o Pix na pr\xF3pria conversa e o agendamento fica marcado como pago sozinho." })
1491
+ ] }),
1492
+ loading && /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: "Verificando\u2026" }),
1493
+ !loading && ready && /* @__PURE__ */ jsxs("p", { className: "text-xs text-foreground", children: [
1494
+ /* @__PURE__ */ jsx("span", { className: "font-medium text-emerald-600", children: "Pronto para cobrar" }),
1495
+ readiness?.sandbox && /* @__PURE__ */ jsx("span", { className: "ml-2 rounded bg-amber-100 px-1.5 py-0.5 text-[11px] font-medium text-amber-800", children: "sandbox \u2014 o dinheiro n\xE3o \xE9 real" })
1496
+ ] }),
1497
+ !loading && !ready && /* @__PURE__ */ jsxs("div", { className: "space-y-2", children: [
1498
+ /* @__PURE__ */ jsxs("p", { className: "text-xs text-muted-foreground", children: [
1499
+ "Ainda n\xE3o d\xE1 para cobrar por aqui. A conta de pagamento \xE9 configurada na Tyxter, em uma p\xE1gina deles \u2014 a sua chave n\xE3o passa por este app.",
1500
+ readiness?.reason ? ` (${readiness.reason})` : ""
1501
+ ] }),
1502
+ handoff ? /* @__PURE__ */ jsx(
1503
+ "a",
1504
+ {
1505
+ href: handoff,
1506
+ target: "_blank",
1507
+ rel: "noreferrer",
1508
+ className: "inline-block rounded-md border px-3 py-1.5 text-xs font-medium hover:bg-muted",
1509
+ children: "Abrir a configura\xE7\xE3o de pagamento \u2192"
1510
+ }
1511
+ ) : /* @__PURE__ */ jsx(
1512
+ "button",
1513
+ {
1514
+ type: "button",
1515
+ onClick: () => {
1516
+ void connect();
1517
+ },
1518
+ disabled: opening,
1519
+ className: "rounded-md border px-3 py-1.5 text-xs font-medium hover:bg-muted disabled:opacity-50",
1520
+ children: opening ? "Abrindo\u2026" : "Configurar pagamento"
1521
+ }
1522
+ )
1523
+ ] }),
1524
+ error && /* @__PURE__ */ jsx("p", { className: "text-xs text-destructive", children: error })
1525
+ ] });
1526
+ }
1527
+ function TyxterExtraPanel() {
1528
+ const tenantId = useActiveTenantId();
1529
+ const [channels, setChannels] = useState(null);
1530
+ const [error, setError] = useState(null);
1531
+ const [ddd, setDdd] = useState("");
1532
+ const [claiming, setClaiming] = useState(false);
1533
+ const [handoff, setHandoff] = useState(null);
1534
+ const mounted = useRef(true);
1535
+ useEffect(() => {
1536
+ mounted.current = true;
1537
+ return () => {
1538
+ mounted.current = false;
1539
+ };
1540
+ }, []);
1541
+ const load = useCallback(async () => {
1542
+ try {
1543
+ const rows = await listMessagingChannels({ provider: TYXTER_CONNECTOR_ID, channel: "whatsapp" });
1544
+ if (mounted.current) {
1545
+ setChannels(rows);
1546
+ setError(null);
1547
+ }
1548
+ } catch {
1549
+ if (mounted.current) setError("N\xE3o conseguimos ler os n\xFAmeros agora.");
1550
+ }
1551
+ }, []);
1552
+ useEffect(() => {
1553
+ void load();
1554
+ }, [load]);
1555
+ const dedicated = channels ? findDedicatedChannel(channels, tenantId) : null;
1556
+ const fallback = channels ? findFallbackChannel(channels) : null;
1557
+ const pending = dedicated ? isPendingChannelStatus(dedicated.status) : false;
1558
+ useEffect(() => {
1559
+ if (!pending || !tenantId) return void 0;
1560
+ const timer = setInterval(() => {
1561
+ void refreshDedicatedNumbers(tenantId).then(() => load()).catch(() => {
1562
+ });
1563
+ }, POLL_INTERVAL_MS);
1564
+ return () => clearInterval(timer);
1565
+ }, [pending, tenantId, load]);
1566
+ const registered = !!dedicated?.metadata?.meta_phone_number_id;
1567
+ useEffect(() => {
1568
+ if (!tenantId || !dedicated || dedicated.status !== "active" || registered || handoff) return;
1569
+ void metaRegistrationHandoff(tenantId).then((answer) => {
1570
+ if (mounted.current) setHandoff(answer.metaRegistration);
1571
+ }).catch(() => {
1572
+ });
1573
+ }, [tenantId, dedicated, registered, handoff]);
1574
+ const canClaim = !!channels && !!tenantId && canClaimDedicated(channels, tenantId);
1575
+ async function onClaim() {
1576
+ if (!tenantId || !/^\d{2}$/.test(ddd)) return;
1577
+ setClaiming(true);
1578
+ try {
1579
+ const result = await claimDedicatedNumber({ tenantId, ddd });
1580
+ setHandoff(result.metaRegistration);
1581
+ setDdd("");
1582
+ await load();
1583
+ toast.success("N\xFAmero solicitado. Acompanhe aqui o andamento.");
1584
+ } catch (caught) {
1585
+ toast.error(caught instanceof Error ? caught.message : "N\xE3o foi poss\xEDvel pedir o n\xFAmero.");
1586
+ } finally {
1587
+ if (mounted.current) setClaiming(false);
1588
+ }
1589
+ }
1590
+ return /* @__PURE__ */ jsxs("div", { className: "space-y-3", children: [
1591
+ /* @__PURE__ */ jsxs("div", { children: [
1592
+ /* @__PURE__ */ jsx("p", { className: "text-sm font-medium text-foreground", children: "N\xFAmeros" }),
1593
+ /* @__PURE__ */ jsx("p", { className: "mt-0.5 text-xs text-muted-foreground", children: "Por onde as suas mensagens de WhatsApp saem hoje." })
1594
+ ] }),
1595
+ error && /* @__PURE__ */ jsx("p", { className: "text-xs text-destructive", children: error }),
1596
+ channels && channels.length === 0 && /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: "Nenhum n\xFAmero configurado ainda. Assim que a plataforma liberar o n\xFAmero compartilhado, ele aparece aqui." }),
1597
+ channels && channels.length > 0 && /* @__PURE__ */ jsx("ul", { className: "space-y-2", children: channels.map((channel) => /* @__PURE__ */ jsxs(
1598
+ "li",
1599
+ {
1600
+ className: "flex flex-wrap items-baseline justify-between gap-2 rounded-md border bg-muted/30 p-3 text-xs",
1601
+ children: [
1602
+ /* @__PURE__ */ jsx("span", { className: "font-medium text-foreground", children: channel.phoneE164 ?? channel.providerNumberId }),
1603
+ /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground", children: [
1604
+ KIND_LABEL[channel.kind],
1605
+ " \xB7 ",
1606
+ STATUS_LABEL[channel.status]
1607
+ ] })
1608
+ ]
1609
+ },
1610
+ channel.id
1611
+ )) }),
1612
+ dedicated && /* @__PURE__ */ jsxs("div", { className: "space-y-2 rounded-md border p-3", children: [
1613
+ /* @__PURE__ */ jsx("p", { className: "text-xs font-medium text-foreground", children: "Seu n\xFAmero dedicado" }),
1614
+ /* @__PURE__ */ jsx(ClaimTimeline, { status: dedicated.status }),
1615
+ dedicated.status === "active" && !handoff && /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: "Ativo. As suas mensagens saem por ele, com o seu nome e sem dividir com ningu\xE9m." })
1616
+ ] }),
1617
+ handoff && /* @__PURE__ */ jsx(MetaRegistrationCard, { handoff }),
1618
+ canClaim && /* @__PURE__ */ jsxs("div", { className: "space-y-2 border-t pt-3", children: [
1619
+ /* @__PURE__ */ jsx("p", { className: "text-xs font-medium text-foreground", children: "Quer um n\xFAmero s\xF3 seu?" }),
1620
+ /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: fallback ? "Hoje as suas mensagens saem pelo n\xFAmero da plataforma, dividido com outros neg\xF3cios. 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." : "Um n\xFAmero dedicado sai s\xF3 com o seu nome e tem limite de envio pr\xF3prio." }),
1621
+ /* @__PURE__ */ jsxs("div", { className: "flex items-end gap-2", children: [
1622
+ /* @__PURE__ */ jsxs("label", { className: "text-xs text-muted-foreground", children: [
1623
+ "DDD",
1624
+ /* @__PURE__ */ jsx(
1625
+ Input,
1626
+ {
1627
+ value: ddd,
1628
+ onChange: (e) => setDdd(e.target.value.replace(/\D/g, "").slice(0, 2)),
1629
+ inputMode: "numeric",
1630
+ placeholder: "21",
1631
+ className: "mt-1 w-20",
1632
+ "aria-label": "DDD do n\xFAmero dedicado"
1633
+ }
1634
+ )
1635
+ ] }),
1636
+ /* @__PURE__ */ jsxs(Button, { size: "sm", disabled: claiming || !/^\d{2}$/.test(ddd), onClick: () => void onClaim(), children: [
1637
+ claiming && /* @__PURE__ */ jsx(Loader2, { className: "mr-1.5 h-3.5 w-3.5 animate-spin" }),
1638
+ "Solicitar n\xFAmero dedicado"
1639
+ ] })
1640
+ ] }),
1641
+ /* @__PURE__ */ jsx("p", { className: "text-[11px] text-muted-foreground", children: "O n\xFAmero \xE9 alugado no m\xEAs e, depois que chegar, ainda precisa de um cadastro r\xE1pido na Meta para come\xE7ar a enviar." })
1642
+ ] }),
1643
+ /* @__PURE__ */ jsx(TyxterPaymentsPanel, {})
1644
+ ] });
1645
+ }
1646
+ var tyxterConnectorDef = {
1647
+ id: TYXTER_CONNECTOR_ID,
1648
+ hostPluginId: "conversations",
1649
+ name: "Tyxter",
1650
+ description: "WhatsApp j\xE1 pronto: confirma\xE7\xF5es e lembretes saem por um n\xFAmero da plataforma, e a resposta do cliente cai na sua caixa de entrada.",
1651
+ icon: "MessageCircle",
1652
+ logoUrl: "https://assets.fayz.ai/connectors/tyxter.png",
1653
+ category: "messaging",
1654
+ authKind: "api-key",
1655
+ // Sending is in line with the automation that asked for it; the reply, the
1656
+ // delivery status and the template approvals arrive later and file runs.
1657
+ planes: ["request", "sync"],
1658
+ direction: "outbound",
1659
+ docsUrl: "https://tyxter.com/docs",
1660
+ // A message that does not go out must never take the booking with it. The
1661
+ // opposite verdict from a payment gateway, and for the opposite reason.
1662
+ latencyBudgetMs: TYXTER_LATENCY_BUDGET_MS,
1663
+ onFailure: "degrade",
1664
+ permissions: [
1665
+ {
1666
+ kind: "data",
1667
+ resource: "conversation",
1668
+ actions: ["read", "write"],
1669
+ reason: "Abrir a conversa do cliente no WhatsApp e mant\xEA-la em dia \u2014 \xE9 onde a mensagem enviada e a resposta dele ficam juntas."
1670
+ },
1671
+ {
1672
+ kind: "data",
1673
+ resource: "message",
1674
+ actions: ["read", "write"],
1675
+ reason: "Guardar cada mensagem enviada e recebida, com o status que o WhatsApp devolve (enviada, entregue, lida)."
1676
+ },
1677
+ {
1678
+ kind: "data",
1679
+ resource: "message_template",
1680
+ actions: ["read", "write"],
1681
+ reason: "Ler os modelos de mensagem deste app e guardar o que a Meta respondeu sobre cada um: aprovado, recusado ou pausado."
1682
+ }
1683
+ ],
1684
+ // No form: the key is the platform's. The connect step says so on its own
1685
+ // (`connectors.authorize.nothingToFill`) rather than drawing an empty grid.
1686
+ fields: [],
1687
+ invokedAt: [
1688
+ {
1689
+ key: "messaging.send",
1690
+ label: "Uma automa\xE7\xE3o dispara e a mensagem sai no WhatsApp do cliente",
1691
+ touches: ["conversation", "message"]
1692
+ },
1693
+ {
1694
+ key: "conversations.inbound",
1695
+ label: "O cliente responde, e a resposta entra na caixa de entrada",
1696
+ plane: "sync",
1697
+ trigger: "webhook",
1698
+ touches: ["conversation", "message"]
1699
+ },
1700
+ {
1701
+ key: "messaging.template_sync",
1702
+ label: "Os modelos de mensagem v\xE3o para aprova\xE7\xE3o da Meta e o resultado volta para c\xE1",
1703
+ plane: "sync",
1704
+ trigger: "scheduled",
1705
+ touches: ["message_template"]
1706
+ }
1707
+ ],
1708
+ howItSyncs: [
1709
+ "As mensagens saem por um n\xFAmero de WhatsApp da plataforma, que j\xE1 vem pronto \u2014 voc\xEA n\xE3o cadastra chave nenhuma nem precisa de um chip s\xF3 seu.",
1710
+ "Como esse n\xFAmero \xE9 dividido com outros neg\xF3cios, o nome do seu vai escrito no come\xE7o da mensagem, e s\xF3 modelos j\xE1 aprovados pela Meta podem sair por ele.",
1711
+ "Se voc\xEA pedir um n\xFAmero dedicado, ele passa a ser o remetente assim que ficar ativo \u2014 a\xED a mensagem sai s\xF3 com o seu nome e nada mais \xE9 dividido.",
1712
+ "A resposta do cliente volta para Conversas em segundos, e bot\xF5es como Confirmar e Cancelar j\xE1 atualizam o agendamento sozinhos."
1713
+ ],
1714
+ ...connectionStatus(
1715
+ TYXTER_CONNECTOR_ID,
1716
+ (connection) => ({ connected: !!connection?.active, plane: "request" }),
1717
+ { store: () => store }
1718
+ ),
1719
+ ...connectionRuns(store),
1720
+ // Nothing to receive: the hub split out a credential half that does not exist
1721
+ // for this connector, so `values` is empty and consent is the whole act.
1722
+ saveConnection: async () => {
1723
+ await store.save({ active: true });
1724
+ },
1725
+ disconnect: () => store.disconnect(),
1726
+ disconnectNote: "Desconectar para de enviar e de receber mensagens no WhatsApp. As conversas que j\xE1 existem continuam aqui.",
1727
+ ExtraPanel: TyxterExtraPanel
1728
+ };
1263
1729
 
1264
1730
  // src/migrations/index.ts
1265
1731
  var MIGRATION_001_CONVERSATIONS = `-- ============================================================================
@@ -1360,6 +1826,537 @@ CREATE INDEX IF NOT EXISTS idx_plg_conversations_person
1360
1826
  ON public.plg_conversations(tenant_id, contact_person_id)
1361
1827
  WHERE contact_person_id IS NOT NULL;
1362
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
+
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
+
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
+ -- ============================================================================
1926
+
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;
1932
+
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
+ $$;
1954
+
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
+ -- ============================================================================
1984
+
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()
1992
+ );
1993
+ ALTER TABLE public.plg_conversations_webhook_events ENABLE ROW LEVEL SECURITY;
1994
+
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);
2001
+
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);
2007
+
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
+ -- ============================================================================
2035
+
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()
2049
+ );
2050
+ ALTER TABLE public.plg_conversations_optouts ENABLE ROW LEVEL SECURITY;
2051
+
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
+ -- ============================================================================
2086
+
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
+
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$
2158
+ WITH candidate AS (
2159
+ SELECT c.id, c.tenant_id, c.contact_name, c.contact_handle, c.contact_person_id,
2160
+ c.last_message_at,
2161
+ regexp_replace(COALESCE(c.contact_handle, ''), '[^0-9]', '', 'g') AS digits
2162
+ FROM public.plg_conversations c
2163
+ WHERE c.channel = p_channel
2164
+ AND (p_tenant_id IS NULL OR c.tenant_id = p_tenant_id)
2165
+ )
2166
+ SELECT id, tenant_id, contact_name, contact_handle, contact_person_id
2167
+ FROM candidate
2168
+ WHERE length(digits) >= 8
2169
+ AND ((p_last10 IS NOT NULL AND right(digits, 10) = p_last10)
2170
+ OR (p_last8 IS NOT NULL AND right(digits, 8) = p_last8))
2171
+ -- The 10-digit match wins over an 8-digit one even when the 8-digit thread
2172
+ -- is newer: a wrong area code is a different person, and a newer wrong
2173
+ -- answer is still wrong.
2174
+ ORDER BY (p_last10 IS NOT NULL AND right(digits, 10) = p_last10) DESC,
2175
+ last_message_at DESC NULLS LAST
2176
+ 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
+ -- ============================================================================
2223
+
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;
2241
+ `;
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.
2245
+ --
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.
2250
+ --
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.
2258
+ --
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
2264
+ --
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
2267
+ --
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.
2271
+ --
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.
2278
+ --
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
+ -- ============================================================================
2285
+
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;
2324
+
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;
2359
+ `;
1363
2360
 
1364
2361
  // src/index.ts
1365
2362
  function createSafeProvider() {
@@ -1385,13 +2382,13 @@ function createSafeProvider() {
1385
2382
  function createConversationsPlugin(options) {
1386
2383
  registerTranslations(conversationsLocales);
1387
2384
  const provider = options?.dataProvider ?? createSafeProvider();
1388
- const store = createConversationsStore(provider);
2385
+ const store2 = createConversationsStore(provider);
1389
2386
  const config = {
1390
2387
  contactKind: options?.contactKind ?? "contact",
1391
2388
  contactLookup: options?.contactLookup,
1392
2389
  contactEntityDef: options?.contactEntityDef
1393
2390
  };
1394
- const PageComponent = () => React4.createElement(ConversationsPage, { store, config });
2391
+ const PageComponent = () => React4.createElement(ConversationsPage, { store: store2, config });
1395
2392
  PageComponent.displayName = "ConversationsPage";
1396
2393
  return {
1397
2394
  id: "conversations",
@@ -1429,7 +2426,31 @@ function createConversationsPlugin(options) {
1429
2426
  widgets: [],
1430
2427
  events: [
1431
2428
  { name: "conversations.message.received", description: "An inbound message arrived on any channel" },
1432
- { name: "conversations.message.sent", description: "An outbound message was sent" }
2429
+ { name: "conversations.message.sent", description: "An outbound message was sent" },
2430
+ {
2431
+ // Emitted server-side by plg_emit_event, from the tyxter-webhook's
2432
+ // payment handler — the browser never sees this write, which is the
2433
+ // whole reason packages/db/migrations/027 exists.
2434
+ name: "conversations.payment_received",
2435
+ title: "Pagamento recebido na conversa",
2436
+ description: "O cliente pagou uma cobran\xE7a enviada dentro da conversa",
2437
+ trigger: "Quando o provedor confirma o pagamento de uma cobran\xE7a aberta no chat",
2438
+ icon: "BadgeDollarSign",
2439
+ fields: [
2440
+ { name: "amount", label: "Valor", type: "money", example: "R$ 120,00" },
2441
+ { name: "amount_cents", label: "Valor em centavos", type: "number", example: "12000" },
2442
+ { name: "currency", label: "Moeda", type: "string", example: "BRL" },
2443
+ { name: "description", label: "Descri\xE7\xE3o da cobran\xE7a", type: "string", example: "Corte + Escova" },
2444
+ { name: "channel", label: "Canal", type: "string", example: "whatsapp" },
2445
+ { name: "provider", label: "Provedor", type: "string", example: "tyxter" },
2446
+ // The subject is the BOOKING, not the charge: an automation reacting
2447
+ // to this needs the thing that was paid for, and the charge id is the
2448
+ // audit trail beside it.
2449
+ { name: "subject_type", label: "Tipo do registro", type: "string", example: "booking" },
2450
+ { name: "subject_id", label: "Registro", type: "id", example: "9f1c\u2026" },
2451
+ { name: "payment_request_id", label: "Cobran\xE7a", type: "id", example: "3ab2\u2026" }
2452
+ ]
2453
+ }
1433
2454
  ],
1434
2455
  aiTools: [
1435
2456
  {
@@ -1484,12 +2505,115 @@ function createConversationsPlugin(options) {
1484
2505
  version: "1.1.0",
1485
2506
  sql: MIGRATION_002_CONTACT_PERSON,
1486
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
+ ],
2552
+ functions: [
2553
+ {
2554
+ slug: "tyxter-webhook",
2555
+ // PUBLIC, and the only function in this plugin that is. The caller is
2556
+ // Tyxter's infrastructure, which has no Supabase session and never
2557
+ // will — so the gateway check is off and the HMAC signature over
2558
+ // `${timestamp}.${rawBody}` IS the authentication.
2559
+ ingress: "none",
2560
+ verifyJwt: false,
2561
+ // NAMES only. TYXTER_WEBHOOK_SECRET is the endpoint signing secret,
2562
+ // returned exactly ONCE by POST /v1/webhook-endpoints and stored as a
2563
+ // pool secret by the provisioning script — it is per-endpoint, which is
2564
+ // why it can live in the pool while the API key cannot.
2565
+ secrets: [
2566
+ "TYXTER_WEBHOOK_SECRET",
2567
+ "TYXTER_API_BASE_URL",
2568
+ "FAYZ_API_URL",
2569
+ "FAYZ_PROJECT_ID",
2570
+ "CONNECTOR_REDEMPTION_SECRET"
2571
+ ]
2572
+ },
2573
+ {
2574
+ slug: "tyxter-template-sync",
2575
+ // Two callers, neither of them the outside world: an operator with a
2576
+ // Supabase JWT, and an ops script with TYXTER_SYNC_SECRET. The second
2577
+ // cannot present a JWT, so the gateway check has to be off and the
2578
+ // function authenticates itself.
2579
+ ingress: "none",
2580
+ verifyJwt: false,
2581
+ // NAMES only. The Tyxter key is NOT here and could not be: a Supabase
2582
+ // secret is per-POOL and the key belongs to the platform's project — it
2583
+ // is redeemed per run from the vault, which is what the last three are for.
2584
+ secrets: [
2585
+ "TYXTER_API_BASE_URL",
2586
+ "TYXTER_SYNC_SECRET",
2587
+ "FAYZ_API_URL",
2588
+ "FAYZ_PROJECT_ID",
2589
+ "CONNECTOR_REDEMPTION_SECRET"
2590
+ ]
2591
+ },
2592
+ {
2593
+ slug: "tyxter-payment-preflight",
2594
+ // verify_jwt TRUE — the only function in this wave with a human caller
2595
+ // and nothing else. An operator looking at the connector panel has a
2596
+ // Supabase session; there is no clock and no ops script here, so the
2597
+ // gateway's own check is left on and membership is checked on top of it.
2598
+ ingress: "none",
2599
+ verifyJwt: true,
2600
+ // NAMES only. The browser cannot ask Tyxter anything — the key is the
2601
+ // platform's — which is the whole reason this function exists.
2602
+ secrets: [
2603
+ "TYXTER_API_BASE_URL",
2604
+ "FAYZ_API_URL",
2605
+ "FAYZ_PROJECT_ID",
2606
+ "CONNECTOR_REDEMPTION_SECRET"
2607
+ ]
1487
2608
  }
1488
2609
  ],
2610
+ // WhatsApp through Tyxter. Declared here because this plugin IS the inbox
2611
+ // the messages land in — the connector configures inside Conversas.
2612
+ connectors: [tyxterConnectorDef],
1489
2613
  locales: conversationsLocales
1490
2614
  };
1491
2615
  }
1492
2616
 
1493
- export { createConversationsPlugin, createMockConversationsProvider, createSupabaseConversationsProvider };
2617
+ export { PAYMENT_PREFLIGHT_FUNCTION, TYXTER_CONNECTOR_ID, TYXTER_LATENCY_BUDGET_MS, canOfferPayment, createConversationsPlugin, createMockConversationsProvider, createSupabaseConversationsProvider, listMessagingChannels, openPaymentSetupSession, readPaymentReadiness, tyxterConnectorDef };
1494
2618
  //# sourceMappingURL=index.js.map
1495
2619
  //# sourceMappingURL=index.js.map