@fayz-ai/plugin-conversations 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/dist/data/channels.d.ts +70 -0
- package/dist/data/channels.d.ts.map +1 -0
- package/dist/data/payment-preflight.d.ts +42 -0
- package/dist/data/payment-preflight.d.ts.map +1 -0
- package/dist/data/tables.d.ts +1 -0
- package/dist/data/tables.d.ts.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1152 -28
- package/dist/index.js.map +1 -1
- package/dist/integrations/tyxter/connectorDef.d.ts +14 -0
- package/dist/integrations/tyxter/connectorDef.d.ts.map +1 -0
- package/dist/integrations/tyxter/data.d.ts +70 -0
- package/dist/integrations/tyxter/data.d.ts.map +1 -0
- package/dist/migrations/index.d.ts +7 -0
- package/dist/migrations/index.d.ts.map +1 -1
- package/package.json +9 -7
package/dist/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import React4 from 'react';
|
|
2
|
-
import {
|
|
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 {
|
|
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 {
|
|
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:
|
|
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
|
|
25
|
-
if (!
|
|
26
|
-
return useStore(
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
1022
|
+
const { data: created, error } = await client2().from(T.messages).insert(row).select().single();
|
|
1022
1023
|
if (error) throw error;
|
|
1023
|
-
await
|
|
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
|
|
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 =
|
|
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
|
|
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
|