@fayz-ai/plugin-conversations 0.11.5 → 0.12.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/dist/data/campaigns.d.ts +81 -0
- package/dist/data/campaigns.d.ts.map +1 -0
- package/dist/data/providers.d.ts +111 -0
- package/dist/data/providers.d.ts.map +1 -0
- package/dist/data/supabase.d.ts.map +1 -1
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2094 -112
- package/dist/index.js.map +1 -1
- package/dist/migrations/index.d.ts +3 -0
- package/dist/migrations/index.d.ts.map +1 -1
- package/dist/settings/ConversationsSettings.d.ts.map +1 -1
- package/dist/settings/WhatsAppProviders.d.ts +3 -0
- package/dist/settings/WhatsAppProviders.d.ts.map +1 -0
- package/dist/store.d.ts +13 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/types.d.ts +6 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/views/CampaignsView.d.ts +3 -0
- package/dist/views/CampaignsView.d.ts.map +1 -0
- package/dist/views/ContactPanel.d.ts.map +1 -1
- package/dist/views/ConversationsPage.d.ts.map +1 -1
- package/dist/views/LeadCard.d.ts +6 -0
- package/dist/views/LeadCard.d.ts.map +1 -0
- package/dist/views/MessageInspector.d.ts +11 -0
- package/dist/views/MessageInspector.d.ts.map +1 -0
- package/dist/views/MessageThread.d.ts.map +1 -1
- package/dist/views/SendWindow.d.ts +45 -0
- package/dist/views/SendWindow.d.ts.map +1 -0
- package/dist/views/TemplatesView.d.ts +3 -0
- package/dist/views/TemplatesView.d.ts.map +1 -0
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -1,19 +1,605 @@
|
|
|
1
|
-
import * as
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
1
|
+
import * as React9 from 'react';
|
|
2
|
+
import React9__default, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
|
3
|
+
import { Loader2, MessageSquare, AlertTriangle, Pause, Play, Send, RefreshCw, Plus, Clock, CheckCircle2, Check, ShieldCheck, ShieldAlert, QrCode, Zap, ExternalLink, SquarePen, Globe, Mail, Instagram, Phone, Search, Inbox, ChevronLeft, Archive, PanelRight, X, User, MapPin, Tag, StickyNote, Link2, MoreVertical, CheckCheck, Snowflake, Sun, Flame, Building2 } from 'lucide-react';
|
|
4
|
+
import { toast, Button, Input, defineKpiWidget, defineTableWidget, PageHeaderActions, cn, Badge, Skeleton, KpiCard, TableWidget } from '@fayz-ai/ui';
|
|
5
|
+
import { createConnectionStore, connectionRuns, connectionStatus, getActiveTenantId, getSupabaseClientOptional, useActiveTenantId, registerTranslations, useTranslation, countByTenant, CONNECTOR_RUNTIME_TOKEN_HEADER, connectorRuntimeToken, errorMessage } from '@fayz-ai/core';
|
|
5
6
|
import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { PluginSettingsPanel, SettingsGroup, PermissionGate, useLimitGuard, RightRailPage, ContactPicker, invalidateLimit } from '@fayz-ai/admin';
|
|
7
|
+
import { useStore } from 'zustand';
|
|
8
|
+
import { SettingsGroup, PluginSettingsPanel, PermissionGate, useLimitGuard, RightRailPage, ContactPicker, invalidateLimit } from '@fayz-ai/admin';
|
|
9
9
|
import { createStore } from 'zustand/vanilla';
|
|
10
10
|
|
|
11
11
|
// src/index.ts
|
|
12
|
+
function client() {
|
|
13
|
+
const c = getSupabaseClientOptional();
|
|
14
|
+
if (!c) throw new Error("Supabase n\xE3o inicializado");
|
|
15
|
+
return c;
|
|
16
|
+
}
|
|
17
|
+
function tenant() {
|
|
18
|
+
const t = getActiveTenantId();
|
|
19
|
+
if (!t) throw new Error("Nenhuma casa ativa");
|
|
20
|
+
return t;
|
|
21
|
+
}
|
|
22
|
+
var campaignsApi = {
|
|
23
|
+
async templates() {
|
|
24
|
+
const { data, error } = await client().from("plg_conversations_templates").select("id, name, language, category, body, variables, provider, status, status_detail").eq("tenant_id", tenant()).order("name");
|
|
25
|
+
if (error) throw new Error(error.message);
|
|
26
|
+
return (data ?? []).map((r) => ({
|
|
27
|
+
id: r.id,
|
|
28
|
+
name: r.name,
|
|
29
|
+
language: r.language,
|
|
30
|
+
category: r.category,
|
|
31
|
+
body: r.body,
|
|
32
|
+
variables: r.variables ?? [],
|
|
33
|
+
provider: r.provider,
|
|
34
|
+
status: r.status,
|
|
35
|
+
statusDetail: r.status_detail ?? null
|
|
36
|
+
}));
|
|
37
|
+
},
|
|
38
|
+
async saveTemplate(t) {
|
|
39
|
+
const row = {
|
|
40
|
+
tenant_id: tenant(),
|
|
41
|
+
name: t.name,
|
|
42
|
+
body: t.body,
|
|
43
|
+
variables: t.variables ?? [],
|
|
44
|
+
language: t.language ?? "pt_BR",
|
|
45
|
+
category: t.category ?? "MARKETING",
|
|
46
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
47
|
+
};
|
|
48
|
+
const q = t.id ? client().from("plg_conversations_templates").update(row).eq("id", t.id) : client().from("plg_conversations_templates").insert(row);
|
|
49
|
+
const { error } = await q;
|
|
50
|
+
if (error) throw new Error(error.message);
|
|
51
|
+
},
|
|
52
|
+
async list() {
|
|
53
|
+
const { data, error } = await client().from("v_conversations_campaign_progress").select("*").eq("tenant_id", tenant());
|
|
54
|
+
if (error) throw new Error(error.message);
|
|
55
|
+
return (data ?? []).map((r) => ({
|
|
56
|
+
campaignId: r.campaign_id,
|
|
57
|
+
name: r.name,
|
|
58
|
+
status: r.status,
|
|
59
|
+
pausedReason: r.paused_reason ?? null,
|
|
60
|
+
provider: r.provider,
|
|
61
|
+
total: Number(r.total ?? 0),
|
|
62
|
+
queued: Number(r.queued ?? 0),
|
|
63
|
+
sending: Number(r.sending ?? 0),
|
|
64
|
+
sent: Number(r.sent ?? 0),
|
|
65
|
+
delivered: Number(r.delivered ?? 0),
|
|
66
|
+
read: Number(r.read ?? 0),
|
|
67
|
+
replied: Number(r.replied ?? 0),
|
|
68
|
+
failed: Number(r.failed ?? 0),
|
|
69
|
+
skipped: Number(r.skipped ?? 0),
|
|
70
|
+
replyRate: r.reply_rate == null ? null : Number(r.reply_rate),
|
|
71
|
+
firstSentAt: r.first_sent_at ?? null,
|
|
72
|
+
lastSentAt: r.last_sent_at ?? null
|
|
73
|
+
}));
|
|
74
|
+
},
|
|
75
|
+
/** Cria a campanha e enfileira os destinatários.
|
|
76
|
+
*
|
|
77
|
+
* `ON CONFLICT DO NOTHING` via upsert: o índice único (campanha, telefone,
|
|
78
|
+
* rodada) é quem impede o disparo duplo, e não uma checagem aqui. Dois
|
|
79
|
+
* cliques no botão enfileiram uma vez. */
|
|
80
|
+
async create(input) {
|
|
81
|
+
const tenantId = tenant();
|
|
82
|
+
const { data: camp, error } = await client().from("plg_conversations_campaigns").insert({
|
|
83
|
+
tenant_id: tenantId,
|
|
84
|
+
name: input.name,
|
|
85
|
+
template_id: input.templateId ?? null,
|
|
86
|
+
provider: input.provider ?? null,
|
|
87
|
+
channel_id: input.channelId ?? null,
|
|
88
|
+
status: "draft"
|
|
89
|
+
}).select("id").single();
|
|
90
|
+
if (error) throw new Error(error.message);
|
|
91
|
+
const rows = input.targets.map((t) => ({
|
|
92
|
+
tenant_id: tenantId,
|
|
93
|
+
campaign_id: camp.id,
|
|
94
|
+
phone_e164: String(t.phone).replace(/\D/g, ""),
|
|
95
|
+
person_id: t.personId ?? null,
|
|
96
|
+
variables: t.variables ?? {}
|
|
97
|
+
})).filter((r) => r.phone_e164.length >= 10);
|
|
98
|
+
for (let i = 0; i < rows.length; i += 200) {
|
|
99
|
+
const { error: e } = await client().from("plg_conversations_campaign_targets").upsert(rows.slice(i, i + 200), { onConflict: "campaign_id,phone_e164,rotation", ignoreDuplicates: true });
|
|
100
|
+
if (e) throw new Error(e.message);
|
|
101
|
+
}
|
|
102
|
+
return camp.id;
|
|
103
|
+
},
|
|
104
|
+
async setStatus(campaignId, status) {
|
|
105
|
+
const patch = { status, updated_at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
106
|
+
if (status === "running") {
|
|
107
|
+
patch.started_at = (/* @__PURE__ */ new Date()).toISOString();
|
|
108
|
+
patch.paused_reason = null;
|
|
109
|
+
}
|
|
110
|
+
const { error } = await client().from("plg_conversations_campaigns").update(patch).eq("id", campaignId);
|
|
111
|
+
if (error) throw new Error(error.message);
|
|
112
|
+
},
|
|
113
|
+
/** Um lote. Quem chama repete até `remaining` chegar a zero — o arrendamento
|
|
114
|
+
* de cinco minutos é o que impede duas chamadas de mandarem o mesmo. */
|
|
115
|
+
async dispatch(campaignId, limit) {
|
|
116
|
+
const { data, error } = await client().functions.invoke("conversations-provider", {
|
|
117
|
+
body: { tenantId: tenant(), action: "dispatch", campaignId, limit }
|
|
118
|
+
});
|
|
119
|
+
if (error) {
|
|
120
|
+
const detail = await error.context?.json?.().catch(() => null);
|
|
121
|
+
throw new Error(detail?.message ?? error.message);
|
|
122
|
+
}
|
|
123
|
+
if (data?.error) throw new Error(data.message ?? data.error);
|
|
124
|
+
return data;
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
var FN = "conversations-provider";
|
|
128
|
+
function client2() {
|
|
129
|
+
const c = getSupabaseClientOptional();
|
|
130
|
+
if (!c) throw new Error("Supabase n\xE3o inicializado");
|
|
131
|
+
return c;
|
|
132
|
+
}
|
|
133
|
+
async function callFn(action, body = {}) {
|
|
134
|
+
const tenantId = getActiveTenantId();
|
|
135
|
+
if (!tenantId) throw new Error("Nenhuma casa ativa");
|
|
136
|
+
const { data, error } = await client2().functions.invoke(FN, {
|
|
137
|
+
body: { tenantId, action, ...body }
|
|
138
|
+
});
|
|
139
|
+
if (error) {
|
|
140
|
+
const detail = await error.context?.json?.().catch(() => null);
|
|
141
|
+
throw new Error(detail?.message ?? error.message);
|
|
142
|
+
}
|
|
143
|
+
if (data?.error) throw new Error(data.message ?? data.error);
|
|
144
|
+
return data;
|
|
145
|
+
}
|
|
146
|
+
var providersApi = {
|
|
147
|
+
/** O que está configurado nesta casa. Sem segredo. */
|
|
148
|
+
async list() {
|
|
149
|
+
const tenantId = getActiveTenantId();
|
|
150
|
+
if (!tenantId) return [];
|
|
151
|
+
const { data, error } = await client2().from("v_conversations_providers").select("provider, config, capabilities, is_active, status, status_detail, checked_at, has_credential").eq("tenant_id", tenantId);
|
|
152
|
+
if (error) throw new Error(error.message);
|
|
153
|
+
return (data ?? []).map((r) => ({
|
|
154
|
+
provider: r.provider,
|
|
155
|
+
config: r.config ?? {},
|
|
156
|
+
capabilities: r.capabilities ?? {},
|
|
157
|
+
isActive: Boolean(r.is_active),
|
|
158
|
+
status: r.status,
|
|
159
|
+
statusDetail: r.status_detail ?? null,
|
|
160
|
+
checkedAt: r.checked_at ?? null,
|
|
161
|
+
hasCredential: Boolean(r.has_credential)
|
|
162
|
+
}));
|
|
163
|
+
},
|
|
164
|
+
/** Guarda config e, quando vier, a chave. Chave ausente é "não mexe": salvar
|
|
165
|
+
* a URL do Evolution sem redigitar a chave é o gesto comum. */
|
|
166
|
+
async save(provider, config, apiKey) {
|
|
167
|
+
const tenantId = getActiveTenantId();
|
|
168
|
+
if (!tenantId) throw new Error("Nenhuma casa ativa");
|
|
169
|
+
const { error } = await client2().rpc("conversations_set_provider", {
|
|
170
|
+
p_tenant: tenantId,
|
|
171
|
+
p_provider: provider,
|
|
172
|
+
p_config: config,
|
|
173
|
+
p_api_key: apiKey && apiKey.trim() ? apiKey.trim() : null
|
|
174
|
+
});
|
|
175
|
+
if (error) throw new Error(error.message);
|
|
176
|
+
},
|
|
177
|
+
/** A troca. Um ativo por casa, garantido por índice único no banco. */
|
|
178
|
+
async activate(provider) {
|
|
179
|
+
const tenantId = getActiveTenantId();
|
|
180
|
+
if (!tenantId) throw new Error("Nenhuma casa ativa");
|
|
181
|
+
const { error } = await client2().rpc("conversations_activate_provider", {
|
|
182
|
+
p_tenant: tenantId,
|
|
183
|
+
p_provider: provider
|
|
184
|
+
});
|
|
185
|
+
if (error) throw new Error(error.message);
|
|
186
|
+
},
|
|
187
|
+
health: (provider) => callFn("health", { provider }),
|
|
188
|
+
numbers: (provider) => callFn("numbers", { provider }),
|
|
189
|
+
/** O QR do Evolution. O Tyxter recusa com `unsupported`. */
|
|
190
|
+
pair: (provider) => callFn("pair", { provider }),
|
|
191
|
+
templates: (provider) => callFn("templates", { provider }),
|
|
192
|
+
createTemplate: (template, provider) => callFn("create_template", { template, provider }),
|
|
193
|
+
send: (input) => callFn("send", input)
|
|
194
|
+
};
|
|
195
|
+
var STATE_COPY = {
|
|
196
|
+
draft: { label: "rascunho", cls: "bg-muted text-muted-foreground" },
|
|
197
|
+
scheduled: { label: "agendada", cls: "bg-info-soft text-info-soft-foreground" },
|
|
198
|
+
running: { label: "disparando", cls: "bg-success-soft text-success-soft-foreground" },
|
|
199
|
+
paused: { label: "pausada", cls: "bg-warning-soft text-warning-soft-foreground" },
|
|
200
|
+
done: { label: "conclu\xEDda", cls: "bg-muted text-muted-foreground" },
|
|
201
|
+
cancelled: { label: "cancelada", cls: "bg-muted text-muted-foreground" }
|
|
202
|
+
};
|
|
203
|
+
function Bar({ c }) {
|
|
204
|
+
const total = Math.max(1, c.total);
|
|
205
|
+
const seg = (n, cls, title) => n > 0 ? /* @__PURE__ */ jsx("div", { className: cls, style: { width: `${n / total * 100}%` }, title: `${title}: ${n}` }) : null;
|
|
206
|
+
return /* @__PURE__ */ jsxs("div", { className: "flex h-1.5 w-full overflow-hidden rounded-full bg-muted", children: [
|
|
207
|
+
seg(c.replied, "bg-success", "responderam"),
|
|
208
|
+
seg(c.sent - c.replied, "bg-primary", "enviadas"),
|
|
209
|
+
seg(c.failed, "bg-destructive", "falharam"),
|
|
210
|
+
seg(c.skipped, "bg-muted-foreground/40", "puladas")
|
|
211
|
+
] });
|
|
212
|
+
}
|
|
213
|
+
function CampaignsView() {
|
|
214
|
+
const [rows, setRows] = React9__default.useState(null);
|
|
215
|
+
const [templates, setTemplates] = React9__default.useState([]);
|
|
216
|
+
const [providers, setProviders] = React9__default.useState([]);
|
|
217
|
+
const [running, setRunning] = React9__default.useState(null);
|
|
218
|
+
const stop = React9__default.useRef(false);
|
|
219
|
+
const load = React9__default.useCallback(async () => {
|
|
220
|
+
try {
|
|
221
|
+
const [c, t, p] = await Promise.all([
|
|
222
|
+
campaignsApi.list(),
|
|
223
|
+
campaignsApi.templates(),
|
|
224
|
+
providersApi.list()
|
|
225
|
+
]);
|
|
226
|
+
setRows(c);
|
|
227
|
+
setTemplates(t);
|
|
228
|
+
setProviders(p);
|
|
229
|
+
} catch (e) {
|
|
230
|
+
toast.error(e?.message ?? "N\xE3o consegui carregar");
|
|
231
|
+
setRows([]);
|
|
232
|
+
}
|
|
233
|
+
}, []);
|
|
234
|
+
React9__default.useEffect(() => {
|
|
235
|
+
void load();
|
|
236
|
+
}, [load]);
|
|
237
|
+
const active = providers.find((p) => p.isActive);
|
|
238
|
+
async function run(c) {
|
|
239
|
+
stop.current = false;
|
|
240
|
+
setRunning(c.campaignId);
|
|
241
|
+
try {
|
|
242
|
+
if (c.status !== "running") await campaignsApi.setStatus(c.campaignId, "running");
|
|
243
|
+
for (; ; ) {
|
|
244
|
+
if (stop.current) {
|
|
245
|
+
await campaignsApi.setStatus(c.campaignId, "paused");
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
const r = await campaignsApi.dispatch(c.campaignId);
|
|
249
|
+
await load();
|
|
250
|
+
if (r.paused) {
|
|
251
|
+
toast.error(`Pausado: ${r.paused}`);
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
if (r.skipped) {
|
|
255
|
+
toast.error(r.reason ?? "Campanha n\xE3o est\xE1 disparando");
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
if (r.failures?.length) {
|
|
259
|
+
for (const f of r.failures.slice(0, 3)) toast.error(`${f.to}: ${f.message}`);
|
|
260
|
+
}
|
|
261
|
+
if (!r.remaining) {
|
|
262
|
+
toast.success("Campanha conclu\xEDda");
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
} catch (e) {
|
|
267
|
+
toast.error(e?.message ?? "Falhou");
|
|
268
|
+
} finally {
|
|
269
|
+
setRunning(null);
|
|
270
|
+
void load();
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return /* @__PURE__ */ jsxs("div", { className: "space-y-4", children: [
|
|
274
|
+
/* @__PURE__ */ jsx("div", { className: "flex flex-wrap items-center justify-between gap-2", children: /* @__PURE__ */ jsxs("div", { children: [
|
|
275
|
+
/* @__PURE__ */ jsx("h2", { className: "text-lg font-semibold", children: "Campanhas" }),
|
|
276
|
+
/* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: active ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
277
|
+
"saindo por ",
|
|
278
|
+
/* @__PURE__ */ jsx("span", { className: "font-medium text-foreground", children: active.provider }),
|
|
279
|
+
active.capabilities?.requires_template && " \xB7 exige template aprovado"
|
|
280
|
+
] }) : "nenhum provedor de WhatsApp ativo \u2014 configure em Ajustes" })
|
|
281
|
+
] }) }),
|
|
282
|
+
rows === null && /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 py-8 text-sm text-muted-foreground", children: [
|
|
283
|
+
/* @__PURE__ */ jsx(Loader2, { className: "h-4 w-4 animate-spin" }),
|
|
284
|
+
" carregando\u2026"
|
|
285
|
+
] }),
|
|
286
|
+
rows?.length === 0 && /* @__PURE__ */ jsxs("div", { className: "rounded-card border-2 border-dashed py-10 text-center", children: [
|
|
287
|
+
/* @__PURE__ */ jsx(MessageSquare, { className: "mx-auto h-8 w-8 text-muted-foreground/30" }),
|
|
288
|
+
/* @__PURE__ */ jsx("p", { className: "mt-2 text-sm text-muted-foreground", children: "Nenhuma campanha ainda." }),
|
|
289
|
+
/* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: 'Crie uma a partir da lista de leads: filtre, selecione e escolha "Enviar WhatsApp".' })
|
|
290
|
+
] }),
|
|
291
|
+
rows?.map((c) => {
|
|
292
|
+
const st = STATE_COPY[c.status] ?? STATE_COPY.draft;
|
|
293
|
+
const busy = running === c.campaignId;
|
|
294
|
+
return /* @__PURE__ */ jsxs("div", { className: "rounded-card border bg-card p-4", "data-testid": `campaign-${c.campaignId}`, children: [
|
|
295
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [
|
|
296
|
+
/* @__PURE__ */ jsx("span", { className: "text-sm font-semibold", children: c.name }),
|
|
297
|
+
/* @__PURE__ */ jsx("span", { className: `rounded-full px-2 py-0.5 text-[10px] font-medium ${st.cls}`, children: st.label }),
|
|
298
|
+
c.provider && /* @__PURE__ */ jsx("span", { className: "text-[10px] text-muted-foreground", children: c.provider }),
|
|
299
|
+
c.pausedReason && /* @__PURE__ */ jsxs("span", { className: "inline-flex items-center gap-1 text-[10px] text-warning", children: [
|
|
300
|
+
/* @__PURE__ */ jsx(AlertTriangle, { className: "h-3 w-3" }),
|
|
301
|
+
" ",
|
|
302
|
+
c.pausedReason
|
|
303
|
+
] })
|
|
304
|
+
] }),
|
|
305
|
+
/* @__PURE__ */ jsx("div", { className: "mt-2", children: /* @__PURE__ */ jsx(Bar, { c }) }),
|
|
306
|
+
/* @__PURE__ */ jsxs("div", { className: "mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground", children: [
|
|
307
|
+
/* @__PURE__ */ jsxs("span", { children: [
|
|
308
|
+
c.total,
|
|
309
|
+
" destinat\xE1rios"
|
|
310
|
+
] }),
|
|
311
|
+
/* @__PURE__ */ jsxs("span", { children: [
|
|
312
|
+
"enviadas: ",
|
|
313
|
+
/* @__PURE__ */ jsx("span", { className: "text-foreground tabular-nums", children: c.sent })
|
|
314
|
+
] }),
|
|
315
|
+
/* @__PURE__ */ jsxs("span", { children: [
|
|
316
|
+
"responderam: ",
|
|
317
|
+
/* @__PURE__ */ jsx("span", { className: "text-foreground tabular-nums", children: c.replied })
|
|
318
|
+
] }),
|
|
319
|
+
c.failed > 0 && /* @__PURE__ */ jsxs("span", { className: "text-destructive", children: [
|
|
320
|
+
"falharam: ",
|
|
321
|
+
c.failed
|
|
322
|
+
] }),
|
|
323
|
+
c.skipped > 0 && /* @__PURE__ */ jsxs("span", { children: [
|
|
324
|
+
"puladas: ",
|
|
325
|
+
c.skipped
|
|
326
|
+
] }),
|
|
327
|
+
c.queued > 0 && /* @__PURE__ */ jsxs("span", { children: [
|
|
328
|
+
"na fila: ",
|
|
329
|
+
/* @__PURE__ */ jsx("span", { className: "text-foreground tabular-nums", children: c.queued })
|
|
330
|
+
] }),
|
|
331
|
+
c.replyRate != null && /* @__PURE__ */ jsxs("span", { className: c.replyRate < 30 ? "text-warning" : "text-success", children: [
|
|
332
|
+
"resposta: ",
|
|
333
|
+
c.replyRate,
|
|
334
|
+
"%"
|
|
335
|
+
] })
|
|
336
|
+
] }),
|
|
337
|
+
/* @__PURE__ */ jsxs("div", { className: "mt-3 flex gap-2", children: [
|
|
338
|
+
busy ? /* @__PURE__ */ jsxs(Button, { size: "sm", variant: "outline", onClick: () => {
|
|
339
|
+
stop.current = true;
|
|
340
|
+
}, children: [
|
|
341
|
+
/* @__PURE__ */ jsx(Pause, { className: "mr-1 h-3 w-3" }),
|
|
342
|
+
" Pausar"
|
|
343
|
+
] }) : /* @__PURE__ */ jsx(
|
|
344
|
+
Button,
|
|
345
|
+
{
|
|
346
|
+
size: "sm",
|
|
347
|
+
disabled: !active || c.queued === 0 || c.status === "done" || c.status === "cancelled",
|
|
348
|
+
onClick: () => void run(c),
|
|
349
|
+
children: c.status === "paused" ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
350
|
+
/* @__PURE__ */ jsx(Play, { className: "mr-1 h-3 w-3" }),
|
|
351
|
+
" Continuar"
|
|
352
|
+
] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
353
|
+
/* @__PURE__ */ jsx(Send, { className: "mr-1 h-3 w-3" }),
|
|
354
|
+
" Disparar"
|
|
355
|
+
] })
|
|
356
|
+
}
|
|
357
|
+
),
|
|
358
|
+
busy && /* @__PURE__ */ jsxs("span", { className: "flex items-center gap-1 text-xs text-muted-foreground", children: [
|
|
359
|
+
/* @__PURE__ */ jsx(Loader2, { className: "h-3 w-3 animate-spin" }),
|
|
360
|
+
" disparando \u2014 mantenha esta aba aberta"
|
|
361
|
+
] })
|
|
362
|
+
] })
|
|
363
|
+
] }, c.campaignId);
|
|
364
|
+
}),
|
|
365
|
+
templates.length > 0 && /* @__PURE__ */ jsxs("div", { className: "rounded-card border bg-card p-4", children: [
|
|
366
|
+
/* @__PURE__ */ jsxs("p", { className: "text-xs font-medium text-muted-foreground mb-2", children: [
|
|
367
|
+
"Templates (",
|
|
368
|
+
templates.length,
|
|
369
|
+
")"
|
|
370
|
+
] }),
|
|
371
|
+
templates.map((t) => /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-baseline gap-2 py-1 text-xs", children: [
|
|
372
|
+
/* @__PURE__ */ jsx("span", { className: "font-medium", children: t.name }),
|
|
373
|
+
/* @__PURE__ */ jsx("span", { className: t.status === "approved" ? "text-success" : "text-muted-foreground", children: t.status }),
|
|
374
|
+
/* @__PURE__ */ jsx("span", { className: "truncate text-muted-foreground", children: t.body.slice(0, 70) })
|
|
375
|
+
] }, t.id))
|
|
376
|
+
] })
|
|
377
|
+
] });
|
|
378
|
+
}
|
|
379
|
+
var STATUS_UI = {
|
|
380
|
+
approved: { icon: CheckCircle2, cls: "text-success", label: "aprovado" },
|
|
381
|
+
submitted: { icon: Clock, cls: "text-warning", label: "em an\xE1lise" },
|
|
382
|
+
pending: { icon: Clock, cls: "text-warning", label: "em an\xE1lise" },
|
|
383
|
+
rejected: { icon: AlertTriangle, cls: "text-destructive", label: "recusado" },
|
|
384
|
+
paused: { icon: AlertTriangle, cls: "text-warning", label: "pausado" },
|
|
385
|
+
disabled: { icon: AlertTriangle, cls: "text-destructive", label: "desativado" },
|
|
386
|
+
draft: { icon: Clock, cls: "text-muted-foreground", label: "rascunho" }
|
|
387
|
+
};
|
|
388
|
+
function slots(body) {
|
|
389
|
+
const found = new Set(body.match(/\{\{(\d+)\}\}/g) ?? []);
|
|
390
|
+
return found.size;
|
|
391
|
+
}
|
|
392
|
+
function TemplatesView() {
|
|
393
|
+
const [mine, setMine] = React9__default.useState(null);
|
|
394
|
+
const [theirs, setTheirs] = React9__default.useState([]);
|
|
395
|
+
const [providers, setProviders] = React9__default.useState([]);
|
|
396
|
+
const [busy, setBusy] = React9__default.useState(null);
|
|
397
|
+
const [draft, setDraft] = React9__default.useState({ name: "", body: "", variables: "" });
|
|
398
|
+
const active = providers.find((p) => p.isActive);
|
|
399
|
+
const supportsTemplates = active?.capabilities?.templates !== false;
|
|
400
|
+
const load = React9__default.useCallback(async () => {
|
|
401
|
+
try {
|
|
402
|
+
const [m, p] = await Promise.all([campaignsApi.templates(), providersApi.list()]);
|
|
403
|
+
setMine(m);
|
|
404
|
+
setProviders(p);
|
|
405
|
+
} catch (e) {
|
|
406
|
+
toast.error(e?.message ?? "N\xE3o consegui carregar");
|
|
407
|
+
setMine([]);
|
|
408
|
+
}
|
|
409
|
+
}, []);
|
|
410
|
+
React9__default.useEffect(() => {
|
|
411
|
+
void load();
|
|
412
|
+
}, [load]);
|
|
413
|
+
const sync = async () => {
|
|
414
|
+
setBusy("sync");
|
|
415
|
+
try {
|
|
416
|
+
const d = await providersApi.templates();
|
|
417
|
+
setTheirs(d.templates);
|
|
418
|
+
toast.success(`${d.templates.length} template(s) no provedor`);
|
|
419
|
+
} catch (e) {
|
|
420
|
+
toast.error(e?.message ?? "Falhou");
|
|
421
|
+
} finally {
|
|
422
|
+
setBusy(null);
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
const create = async () => {
|
|
426
|
+
const name = draft.name.trim().toLowerCase().replace(/[^a-z0-9_]/g, "_");
|
|
427
|
+
if (!name || !draft.body.trim()) {
|
|
428
|
+
toast.error("Nome e corpo s\xE3o obrigat\xF3rios");
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (draft.body.length > 1024) {
|
|
432
|
+
toast.error(`Corpo com ${draft.body.length} caracteres \u2014 a Meta aceita at\xE9 1024`);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
setBusy("create");
|
|
436
|
+
try {
|
|
437
|
+
const vars = draft.variables.split(",").map((v) => v.trim()).filter(Boolean);
|
|
438
|
+
await campaignsApi.saveTemplate({ name, body: draft.body, variables: vars });
|
|
439
|
+
if (supportsTemplates) {
|
|
440
|
+
await providersApi.createTemplate({
|
|
441
|
+
name,
|
|
442
|
+
body: draft.body,
|
|
443
|
+
example: vars.length ? vars.map((v) => `exemplo ${v}`) : void 0
|
|
444
|
+
});
|
|
445
|
+
toast.success("Submetido \u2014 a Meta costuma responder em ~24h");
|
|
446
|
+
} else {
|
|
447
|
+
toast.success("Guardado. Este provedor n\xE3o usa templates: o texto sai direto.");
|
|
448
|
+
}
|
|
449
|
+
setDraft({ name: "", body: "", variables: "" });
|
|
450
|
+
await load();
|
|
451
|
+
await sync();
|
|
452
|
+
} catch (e) {
|
|
453
|
+
toast.error(e?.message ?? "Falhou");
|
|
454
|
+
} finally {
|
|
455
|
+
setBusy(null);
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
const byName = new Map(theirs.map((t) => [t.name, t]));
|
|
459
|
+
return /* @__PURE__ */ jsxs("div", { className: "space-y-4", children: [
|
|
460
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center justify-between gap-2", children: [
|
|
461
|
+
/* @__PURE__ */ jsxs("div", { children: [
|
|
462
|
+
/* @__PURE__ */ jsx("h2", { className: "text-lg font-semibold", children: "Templates" }),
|
|
463
|
+
/* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: supportsTemplates ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
464
|
+
"No ",
|
|
465
|
+
/* @__PURE__ */ jsx("span", { className: "font-medium text-foreground", children: active?.provider ?? "provedor" }),
|
|
466
|
+
", um template aprovado \xE9 a \xFAnica porta para falar com quem nunca respondeu."
|
|
467
|
+
] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
468
|
+
"O ",
|
|
469
|
+
/* @__PURE__ */ jsx("span", { className: "font-medium text-foreground", children: active?.provider }),
|
|
470
|
+
" n\xE3o usa templates \u2014 o texto sai direto. O que voc\xEA escrever aqui vira o corpo da campanha."
|
|
471
|
+
] }) })
|
|
472
|
+
] }),
|
|
473
|
+
/* @__PURE__ */ jsx(Button, { size: "sm", variant: "outline", disabled: busy === "sync" || !active, onClick: () => void sync(), children: busy === "sync" ? /* @__PURE__ */ jsx(Loader2, { className: "h-3 w-3 animate-spin" }) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
474
|
+
/* @__PURE__ */ jsx(RefreshCw, { className: "mr-1 h-3 w-3" }),
|
|
475
|
+
" Buscar no provedor"
|
|
476
|
+
] }) })
|
|
477
|
+
] }),
|
|
478
|
+
/* @__PURE__ */ jsxs("div", { className: "rounded-card border bg-card p-4", children: [
|
|
479
|
+
/* @__PURE__ */ jsxs("p", { className: "mb-2 flex items-center gap-1.5 text-sm font-medium", children: [
|
|
480
|
+
/* @__PURE__ */ jsx(Plus, { className: "h-3.5 w-3.5" }),
|
|
481
|
+
" Novo template"
|
|
482
|
+
] }),
|
|
483
|
+
/* @__PURE__ */ jsxs("div", { className: "grid gap-2 sm:grid-cols-[1fr_1fr]", children: [
|
|
484
|
+
/* @__PURE__ */ jsxs("label", { className: "block", children: [
|
|
485
|
+
/* @__PURE__ */ jsx("span", { className: "text-[11px] text-muted-foreground", children: "Nome (sem espa\xE7os)" }),
|
|
486
|
+
/* @__PURE__ */ jsx(
|
|
487
|
+
Input,
|
|
488
|
+
{
|
|
489
|
+
className: "mt-0.5 h-8 text-sm",
|
|
490
|
+
placeholder: "primeiro_contato_feira",
|
|
491
|
+
value: draft.name,
|
|
492
|
+
onChange: (e) => setDraft({ ...draft, name: e.target.value })
|
|
493
|
+
}
|
|
494
|
+
)
|
|
495
|
+
] }),
|
|
496
|
+
/* @__PURE__ */ jsxs("label", { className: "block", children: [
|
|
497
|
+
/* @__PURE__ */ jsx("span", { className: "text-[11px] text-muted-foreground", children: "Vari\xE1veis, em ordem, separadas por v\xEDrgula" }),
|
|
498
|
+
/* @__PURE__ */ jsx(
|
|
499
|
+
Input,
|
|
500
|
+
{
|
|
501
|
+
className: "mt-0.5 h-8 text-sm",
|
|
502
|
+
placeholder: "nome, vendedor, dor",
|
|
503
|
+
value: draft.variables,
|
|
504
|
+
onChange: (e) => setDraft({ ...draft, variables: e.target.value })
|
|
505
|
+
}
|
|
506
|
+
)
|
|
507
|
+
] })
|
|
508
|
+
] }),
|
|
509
|
+
/* @__PURE__ */ jsxs("label", { className: "mt-2 block", children: [
|
|
510
|
+
/* @__PURE__ */ jsxs("span", { className: "text-[11px] text-muted-foreground", children: [
|
|
511
|
+
"Corpo \u2014 use ",
|
|
512
|
+
"{{1}}",
|
|
513
|
+
", ",
|
|
514
|
+
"{{2}}",
|
|
515
|
+
" na ordem das vari\xE1veis"
|
|
516
|
+
] }),
|
|
517
|
+
/* @__PURE__ */ jsx(
|
|
518
|
+
"textarea",
|
|
519
|
+
{
|
|
520
|
+
rows: 5,
|
|
521
|
+
className: "mt-0.5 w-full resize-y rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:border-primary",
|
|
522
|
+
placeholder: "Oi {{1}}! Aqui \xE9 {{2}}, do ChefControl \u{1F44B}\n\nA gente se falou no Sal\xE3o Abrasel...",
|
|
523
|
+
value: draft.body,
|
|
524
|
+
onChange: (e) => setDraft({ ...draft, body: e.target.value })
|
|
525
|
+
}
|
|
526
|
+
)
|
|
527
|
+
] }),
|
|
528
|
+
/* @__PURE__ */ jsxs("div", { className: "mt-1 flex flex-wrap items-center gap-3 text-[11px]", children: [
|
|
529
|
+
/* @__PURE__ */ jsxs("span", { className: draft.body.length > 1024 ? "font-medium text-destructive" : "text-muted-foreground", children: [
|
|
530
|
+
draft.body.length,
|
|
531
|
+
"/1024 caracteres"
|
|
532
|
+
] }),
|
|
533
|
+
/* @__PURE__ */ jsxs("span", { className: "text-muted-foreground", children: [
|
|
534
|
+
slots(draft.body),
|
|
535
|
+
" vari\xE1vel(is) no corpo"
|
|
536
|
+
] }),
|
|
537
|
+
slots(draft.body) !== draft.variables.split(",").filter((v) => v.trim()).length && draft.body && /* @__PURE__ */ jsxs("span", { className: "text-warning", children: [
|
|
538
|
+
"\u26A0 o corpo tem ",
|
|
539
|
+
slots(draft.body),
|
|
540
|
+
" e voc\xEA nomeou ",
|
|
541
|
+
draft.variables.split(",").filter((v) => v.trim()).length
|
|
542
|
+
] })
|
|
543
|
+
] }),
|
|
544
|
+
/* @__PURE__ */ jsx(Button, { size: "sm", className: "mt-2", disabled: busy === "create", onClick: () => void create(), children: busy === "create" ? /* @__PURE__ */ jsx(Loader2, { className: "h-3 w-3 animate-spin" }) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
545
|
+
/* @__PURE__ */ jsx(Send, { className: "mr-1 h-3 w-3" }),
|
|
546
|
+
" ",
|
|
547
|
+
supportsTemplates ? "Guardar e submeter" : "Guardar"
|
|
548
|
+
] }) })
|
|
549
|
+
] }),
|
|
550
|
+
/* @__PURE__ */ jsxs("div", { className: "rounded-card border bg-card", children: [
|
|
551
|
+
/* @__PURE__ */ jsxs("p", { className: "border-b px-4 py-2 text-xs font-medium text-muted-foreground", children: [
|
|
552
|
+
"Desta casa ",
|
|
553
|
+
mine ? `(${mine.length})` : ""
|
|
554
|
+
] }),
|
|
555
|
+
mine === null && /* @__PURE__ */ jsx("p", { className: "px-4 py-3 text-xs text-muted-foreground", children: "carregando\u2026" }),
|
|
556
|
+
mine?.length === 0 && /* @__PURE__ */ jsx("p", { className: "px-4 py-3 text-xs text-muted-foreground", children: "Nenhum template ainda." }),
|
|
557
|
+
mine?.map((t) => {
|
|
558
|
+
const remote = byName.get(t.name);
|
|
559
|
+
const st = STATUS_UI[remote?.status ?? t.status] ?? STATUS_UI.draft;
|
|
560
|
+
const Icon = st.icon;
|
|
561
|
+
return /* @__PURE__ */ jsxs("div", { className: "border-b px-4 py-2.5 last:border-b-0", children: [
|
|
562
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [
|
|
563
|
+
/* @__PURE__ */ jsx("span", { className: "text-sm font-medium", children: t.name }),
|
|
564
|
+
/* @__PURE__ */ jsxs("span", { className: `inline-flex items-center gap-1 text-[10px] ${st.cls}`, children: [
|
|
565
|
+
/* @__PURE__ */ jsx(Icon, { className: "h-3 w-3" }),
|
|
566
|
+
" ",
|
|
567
|
+
st.label
|
|
568
|
+
] }),
|
|
569
|
+
t.variables.length > 0 && /* @__PURE__ */ jsx("span", { className: "text-[10px] text-muted-foreground", children: t.variables.join(" \xB7 ") }),
|
|
570
|
+
supportsTemplates && !remote && /* @__PURE__ */ jsx("span", { className: "text-[10px] text-warning", children: "n\xE3o est\xE1 no provedor" })
|
|
571
|
+
] }),
|
|
572
|
+
/* @__PURE__ */ jsx("p", { className: "mt-0.5 whitespace-pre-wrap text-xs text-muted-foreground", children: t.body }),
|
|
573
|
+
remote?.rejectionReason && /* @__PURE__ */ jsxs("p", { className: "mt-1 text-[11px] text-destructive", children: [
|
|
574
|
+
"Recusado: ",
|
|
575
|
+
remote.rejectionReason
|
|
576
|
+
] })
|
|
577
|
+
] }, t.id);
|
|
578
|
+
})
|
|
579
|
+
] }),
|
|
580
|
+
theirs.filter((t) => !(mine ?? []).some((m) => m.name === t.name)).length > 0 && /* @__PURE__ */ jsxs("div", { className: "rounded-card border bg-card", children: [
|
|
581
|
+
/* @__PURE__ */ jsx("p", { className: "border-b px-4 py-2 text-xs font-medium text-muted-foreground", children: "No provedor, sem par nesta casa \u2014 criados por fora" }),
|
|
582
|
+
theirs.filter((t) => !(mine ?? []).some((m) => m.name === t.name)).map((t) => {
|
|
583
|
+
const st = STATUS_UI[t.status] ?? STATUS_UI.draft;
|
|
584
|
+
const Icon = st.icon;
|
|
585
|
+
return /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-baseline gap-2 border-b px-4 py-2 text-xs last:border-b-0", children: [
|
|
586
|
+
/* @__PURE__ */ jsx("span", { className: "font-medium", children: t.name }),
|
|
587
|
+
/* @__PURE__ */ jsxs("span", { className: `inline-flex items-center gap-1 text-[10px] ${st.cls}`, children: [
|
|
588
|
+
/* @__PURE__ */ jsx(Icon, { className: "h-3 w-3" }),
|
|
589
|
+
" ",
|
|
590
|
+
st.label
|
|
591
|
+
] }),
|
|
592
|
+
/* @__PURE__ */ jsx("span", { className: "truncate text-muted-foreground", children: t.body.slice(0, 80) })
|
|
593
|
+
] }, t.id);
|
|
594
|
+
})
|
|
595
|
+
] })
|
|
596
|
+
] });
|
|
597
|
+
}
|
|
12
598
|
var DEFAULT_CONVERSATIONS_CONFIG = {
|
|
13
599
|
contactKind: "contact"
|
|
14
600
|
};
|
|
15
|
-
var StoreContext =
|
|
16
|
-
var ConfigContext =
|
|
601
|
+
var StoreContext = React9__default.createContext(null);
|
|
602
|
+
var ConfigContext = React9__default.createContext(DEFAULT_CONVERSATIONS_CONFIG);
|
|
17
603
|
function ConversationsContextProvider({
|
|
18
604
|
store: store2,
|
|
19
605
|
config = DEFAULT_CONVERSATIONS_CONFIG,
|
|
@@ -22,12 +608,12 @@ function ConversationsContextProvider({
|
|
|
22
608
|
return /* @__PURE__ */ jsx(StoreContext.Provider, { value: store2, children: /* @__PURE__ */ jsx(ConfigContext.Provider, { value: config, children }) });
|
|
23
609
|
}
|
|
24
610
|
function useConversationsStore(selector) {
|
|
25
|
-
const store2 =
|
|
611
|
+
const store2 = React9__default.useContext(StoreContext);
|
|
26
612
|
if (!store2) throw new Error("useConversationsStore must be used within ConversationsPage");
|
|
27
613
|
return useStore(store2, selector);
|
|
28
614
|
}
|
|
29
615
|
function useConversationsConfig() {
|
|
30
|
-
return
|
|
616
|
+
return React9__default.useContext(ConfigContext);
|
|
31
617
|
}
|
|
32
618
|
|
|
33
619
|
// src/types.ts
|
|
@@ -60,10 +646,10 @@ var CHANNEL_ACCENT = {
|
|
|
60
646
|
webchat: { color: "#f59e0b", badge: "bg-[#f59e0b]/12 text-[#b45309] dark:text-[#fcd34d]" }
|
|
61
647
|
};
|
|
62
648
|
function useMediaQuery(query) {
|
|
63
|
-
const [matches, setMatches] =
|
|
649
|
+
const [matches, setMatches] = React9__default.useState(
|
|
64
650
|
() => typeof window !== "undefined" && window.matchMedia(query).matches
|
|
65
651
|
);
|
|
66
|
-
|
|
652
|
+
React9__default.useEffect(() => {
|
|
67
653
|
if (typeof window === "undefined") return;
|
|
68
654
|
const mql = window.matchMedia(query);
|
|
69
655
|
const onChange = () => setMatches(mql.matches);
|
|
@@ -227,6 +813,190 @@ function ConversationList({ className }) {
|
|
|
227
813
|
] })
|
|
228
814
|
] });
|
|
229
815
|
}
|
|
816
|
+
function useSendWindow(conversationId) {
|
|
817
|
+
const [row, setRow] = React9__default.useState(null);
|
|
818
|
+
const [providers, setProviders] = React9__default.useState(null);
|
|
819
|
+
React9__default.useEffect(() => {
|
|
820
|
+
let alive = true;
|
|
821
|
+
providersApi.list().then((p) => {
|
|
822
|
+
if (alive) setProviders(p);
|
|
823
|
+
}).catch(() => {
|
|
824
|
+
if (alive) setProviders([]);
|
|
825
|
+
});
|
|
826
|
+
return () => {
|
|
827
|
+
alive = false;
|
|
828
|
+
};
|
|
829
|
+
}, []);
|
|
830
|
+
React9__default.useEffect(() => {
|
|
831
|
+
let alive = true;
|
|
832
|
+
if (!conversationId) {
|
|
833
|
+
setRow(null);
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
const sb = getSupabaseClientOptional();
|
|
837
|
+
if (!sb) {
|
|
838
|
+
setRow(null);
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
sb.from("v_conversations_window").select("window_open, window_expires_at, minutes_left, last_inbound_at").eq("conversation_id", conversationId).eq("tenant_id", getActiveTenantId()).maybeSingle().then(({ data }) => {
|
|
842
|
+
if (alive) setRow(data ?? null);
|
|
843
|
+
}).catch(() => {
|
|
844
|
+
if (alive) setRow(null);
|
|
845
|
+
});
|
|
846
|
+
return () => {
|
|
847
|
+
alive = false;
|
|
848
|
+
};
|
|
849
|
+
}, [conversationId]);
|
|
850
|
+
const provider = (providers ?? []).find((p) => p.isActive);
|
|
851
|
+
const mode = React9__default.useMemo(() => {
|
|
852
|
+
if (!provider) return { kind: "blocked", reason: "no_provider" };
|
|
853
|
+
if (!provider.capabilities?.requires_template) {
|
|
854
|
+
return { kind: "free", until: null, minutesLeft: null };
|
|
855
|
+
}
|
|
856
|
+
if (row?.window_open) {
|
|
857
|
+
return { kind: "free", until: row.window_expires_at, minutesLeft: row.minutes_left };
|
|
858
|
+
}
|
|
859
|
+
return { kind: "template", reason: row?.last_inbound_at ? "window_closed" : "never_replied" };
|
|
860
|
+
}, [row, provider]);
|
|
861
|
+
return { loading: providers === null, mode, provider };
|
|
862
|
+
}
|
|
863
|
+
function humanLeft(minutes) {
|
|
864
|
+
if (minutes == null) return "";
|
|
865
|
+
if (minutes < 60) return `${minutes} min`;
|
|
866
|
+
const h = Math.floor(minutes / 60);
|
|
867
|
+
return h < 24 ? `${h}h` : `${Math.floor(h / 24)}d`;
|
|
868
|
+
}
|
|
869
|
+
function SendWindowBanner({ state }) {
|
|
870
|
+
const { mode, provider } = state;
|
|
871
|
+
if (mode.kind === "free" && mode.minutesLeft == null) return null;
|
|
872
|
+
if (mode.kind === "free") {
|
|
873
|
+
const urgent = (mode.minutesLeft ?? 0) < 120;
|
|
874
|
+
return /* @__PURE__ */ jsxs("div", { className: `flex items-center gap-2 px-4 py-1.5 text-[11px] ${urgent ? "bg-warning-soft text-warning-soft-foreground" : "text-muted-foreground"}`, children: [
|
|
875
|
+
/* @__PURE__ */ jsx("span", { className: "h-1.5 w-1.5 rounded-full bg-success" }),
|
|
876
|
+
"Janela aberta \u2014 texto livre por mais ",
|
|
877
|
+
humanLeft(mode.minutesLeft)
|
|
878
|
+
] });
|
|
879
|
+
}
|
|
880
|
+
if (mode.kind === "blocked") {
|
|
881
|
+
return /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 bg-destructive-soft px-4 py-2 text-[11px] text-destructive-soft-foreground", children: [
|
|
882
|
+
/* @__PURE__ */ jsx("span", { className: "h-1.5 w-1.5 rounded-full bg-destructive" }),
|
|
883
|
+
"Nenhum provedor de WhatsApp ativo \u2014 configure em Ajustes \u203A Conversas."
|
|
884
|
+
] });
|
|
885
|
+
}
|
|
886
|
+
return /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center gap-x-2 gap-y-1 bg-warning-soft px-4 py-2 text-[11px] text-warning-soft-foreground", children: [
|
|
887
|
+
/* @__PURE__ */ jsx("span", { className: "h-1.5 w-1.5 shrink-0 rounded-full bg-warning" }),
|
|
888
|
+
/* @__PURE__ */ jsx("span", { className: "font-medium", children: mode.reason === "never_replied" ? "Esta pessoa nunca respondeu." : "A janela de 24 horas fechou." }),
|
|
889
|
+
/* @__PURE__ */ jsxs("span", { children: [
|
|
890
|
+
"No ",
|
|
891
|
+
provider?.provider ?? "provedor",
|
|
892
|
+
", texto livre s\xF3 sai dentro de 24h da resposta do cliente. Para falar agora \xE9 preciso um template aprovado."
|
|
893
|
+
] })
|
|
894
|
+
] });
|
|
895
|
+
}
|
|
896
|
+
var STATUS = {
|
|
897
|
+
queued: { icon: Clock, label: "na fila", cls: "text-white/60" },
|
|
898
|
+
sent: { icon: Check, label: "enviada", cls: "text-white/70" },
|
|
899
|
+
delivered: { icon: CheckCheck, label: "entregue", cls: "text-white/80" },
|
|
900
|
+
read: { icon: CheckCheck, label: "lida", cls: "text-sky-200" },
|
|
901
|
+
failed: { icon: AlertTriangle, label: "N\xC3O ENVIADA", cls: "text-white" },
|
|
902
|
+
expired: { icon: AlertTriangle, label: "expirou", cls: "text-white" },
|
|
903
|
+
delivery_timeout: { icon: AlertTriangle, label: "sem confirma\xE7\xE3o", cls: "text-white" },
|
|
904
|
+
cancelled: { icon: X, label: "cancelada", cls: "text-white" },
|
|
905
|
+
opted_out: { icon: X, label: "recusada", cls: "text-white" }
|
|
906
|
+
};
|
|
907
|
+
var BAD = /* @__PURE__ */ new Set(["failed", "expired", "cancelled", "opted_out", "delivery_timeout"]);
|
|
908
|
+
function isFailed(m) {
|
|
909
|
+
if (m.direction !== "outbound") return false;
|
|
910
|
+
if (m.deliveryStatus) return BAD.has(m.deliveryStatus);
|
|
911
|
+
if (m.providerMessageId) return false;
|
|
912
|
+
const age = Date.now() - new Date(m.at).getTime();
|
|
913
|
+
return Number.isFinite(age) && age > 12e4;
|
|
914
|
+
}
|
|
915
|
+
function DeliveryMark({ m }) {
|
|
916
|
+
if (m.direction !== "outbound") return null;
|
|
917
|
+
const key = isFailed(m) ? "failed" : m.deliveryStatus ?? "sent";
|
|
918
|
+
const cfg = STATUS[key] ?? STATUS.sent;
|
|
919
|
+
const Icon = cfg.icon;
|
|
920
|
+
return /* @__PURE__ */ jsxs("span", { className: `inline-flex items-center gap-0.5 ${cfg.cls}`, title: cfg.label, children: [
|
|
921
|
+
/* @__PURE__ */ jsx(Icon, { className: "h-3 w-3" }),
|
|
922
|
+
BAD.has(key) && /* @__PURE__ */ jsx("span", { className: "text-[9px] font-semibold uppercase", children: cfg.label })
|
|
923
|
+
] });
|
|
924
|
+
}
|
|
925
|
+
function MessageMenu({ m }) {
|
|
926
|
+
const [open, setOpen] = React9__default.useState(false);
|
|
927
|
+
const [detail, setDetail] = React9__default.useState(null);
|
|
928
|
+
const [loading, setLoading] = React9__default.useState(false);
|
|
929
|
+
const [err, setErr] = React9__default.useState(null);
|
|
930
|
+
async function load() {
|
|
931
|
+
setOpen(true);
|
|
932
|
+
if (!m.providerMessageId) return;
|
|
933
|
+
setLoading(true);
|
|
934
|
+
setErr(null);
|
|
935
|
+
try {
|
|
936
|
+
const sb = getSupabaseClientOptional();
|
|
937
|
+
const { data, error } = await sb.functions.invoke("conversations-provider", {
|
|
938
|
+
body: { tenantId: getActiveTenantId(), action: "message_detail", providerMessageId: m.providerMessageId }
|
|
939
|
+
});
|
|
940
|
+
if (error) {
|
|
941
|
+
const d = await error.context?.json?.().catch(() => null);
|
|
942
|
+
throw new Error(d?.message ?? error.message);
|
|
943
|
+
}
|
|
944
|
+
if (data?.error) throw new Error(data.message ?? data.error);
|
|
945
|
+
setDetail(data.detail);
|
|
946
|
+
} catch (e) {
|
|
947
|
+
setErr(e?.message ?? "N\xE3o consegui buscar");
|
|
948
|
+
} finally {
|
|
949
|
+
setLoading(false);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
953
|
+
/* @__PURE__ */ jsx(
|
|
954
|
+
"button",
|
|
955
|
+
{
|
|
956
|
+
onClick: () => void load(),
|
|
957
|
+
"aria-label": "Detalhes da mensagem",
|
|
958
|
+
className: "opacity-0 transition-opacity group-hover:opacity-60 hover:!opacity-100",
|
|
959
|
+
children: /* @__PURE__ */ jsx(MoreVertical, { className: "h-3.5 w-3.5" })
|
|
960
|
+
}
|
|
961
|
+
),
|
|
962
|
+
open && /* @__PURE__ */ jsx("div", { className: "fixed inset-0 z-[120] flex items-center justify-center bg-black/50 p-4", onClick: () => setOpen(false), children: /* @__PURE__ */ jsxs("div", { className: "max-h-[80vh] w-full max-w-2xl overflow-hidden rounded-card bg-card shadow-2xl", onClick: (e) => e.stopPropagation(), children: [
|
|
963
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between border-b px-4 py-3", children: [
|
|
964
|
+
/* @__PURE__ */ jsxs("div", { children: [
|
|
965
|
+
/* @__PURE__ */ jsx("p", { className: "text-sm font-semibold", children: "Detalhes da mensagem" }),
|
|
966
|
+
/* @__PURE__ */ jsxs("p", { className: "text-[11px] text-muted-foreground", children: [
|
|
967
|
+
new Date(m.at).toLocaleString(),
|
|
968
|
+
" \xB7 ",
|
|
969
|
+
m.direction === "outbound" ? "sa\xEDda" : "entrada",
|
|
970
|
+
m.deliveryStatus ? ` \xB7 ${m.deliveryStatus}` : ""
|
|
971
|
+
] })
|
|
972
|
+
] }),
|
|
973
|
+
/* @__PURE__ */ jsx("button", { onClick: () => setOpen(false), "aria-label": "Fechar", children: /* @__PURE__ */ jsx(X, { className: "h-4 w-4" }) })
|
|
974
|
+
] }),
|
|
975
|
+
/* @__PURE__ */ jsxs("div", { className: "max-h-[60vh] overflow-auto p-4 text-xs", children: [
|
|
976
|
+
/* @__PURE__ */ jsx("p", { className: "mb-1 font-medium text-muted-foreground", children: "Nesta base" }),
|
|
977
|
+
/* @__PURE__ */ jsx("pre", { className: "mb-4 overflow-auto rounded bg-muted/40 p-3 text-[11px] leading-relaxed", children: JSON.stringify({
|
|
978
|
+
id: m.id,
|
|
979
|
+
direction: m.direction,
|
|
980
|
+
body: m.body,
|
|
981
|
+
at: m.at,
|
|
982
|
+
author: m.author,
|
|
983
|
+
deliveryStatus: m.deliveryStatus,
|
|
984
|
+
providerMessageId: m.providerMessageId
|
|
985
|
+
}, null, 2) }),
|
|
986
|
+
/* @__PURE__ */ jsx("p", { className: "mb-1 font-medium text-muted-foreground", children: "No provedor" }),
|
|
987
|
+
!m.providerMessageId && // A ausência é a informação: sem id, ninguém do outro lado
|
|
988
|
+
// chegou a aceitar esta mensagem. Ela não atrasou — ela não saiu.
|
|
989
|
+
/* @__PURE__ */ jsx("p", { className: "rounded bg-destructive-soft p-3 text-[11px] text-destructive-soft-foreground", children: "Esta mensagem n\xE3o tem id no provedor \u2014 ela nunca chegou a ser aceita para envio. N\xE3o h\xE1 o que buscar do outro lado." }),
|
|
990
|
+
loading && /* @__PURE__ */ jsxs("p", { className: "flex items-center gap-2 p-3 text-muted-foreground", children: [
|
|
991
|
+
/* @__PURE__ */ jsx(Loader2, { className: "h-3 w-3 animate-spin" }),
|
|
992
|
+
" buscando\u2026"
|
|
993
|
+
] }),
|
|
994
|
+
err && /* @__PURE__ */ jsx("p", { className: "rounded bg-destructive-soft p-3 text-[11px] text-destructive-soft-foreground", children: err }),
|
|
995
|
+
detail != null && /* @__PURE__ */ jsx("pre", { className: "overflow-auto rounded bg-muted/40 p-3 text-[11px] leading-relaxed", children: JSON.stringify(detail, null, 2) })
|
|
996
|
+
] })
|
|
997
|
+
] }) })
|
|
998
|
+
] });
|
|
999
|
+
}
|
|
230
1000
|
function buildRows(messages) {
|
|
231
1001
|
const rows = [];
|
|
232
1002
|
let lastDay = "";
|
|
@@ -246,12 +1016,36 @@ function buildRows(messages) {
|
|
|
246
1016
|
}
|
|
247
1017
|
function MessageThread({ selected, onTogglePanel, panelOpen, onBack, className }) {
|
|
248
1018
|
const t = useTranslation();
|
|
249
|
-
const { messages, sending, send, setStatus } = useConversationsStore((s) => s);
|
|
250
|
-
const [draft, setDraft] =
|
|
251
|
-
const
|
|
252
|
-
|
|
1019
|
+
const { messages, sending, send, setStatus, refreshMessages } = useConversationsStore((s) => s);
|
|
1020
|
+
const [draft, setDraft] = React9__default.useState("");
|
|
1021
|
+
const win = useSendWindow(selected.id);
|
|
1022
|
+
const canType = win.mode.kind === "free";
|
|
1023
|
+
const threadRef = React9__default.useRef(null);
|
|
1024
|
+
React9__default.useEffect(() => {
|
|
253
1025
|
threadRef.current?.scrollTo({ top: threadRef.current.scrollHeight, behavior: "smooth" });
|
|
254
1026
|
}, [messages.length, selected.id]);
|
|
1027
|
+
React9__default.useEffect(() => {
|
|
1028
|
+
if (!selected.id) return;
|
|
1029
|
+
let timer = null;
|
|
1030
|
+
const start = () => {
|
|
1031
|
+
if (!timer) timer = setInterval(() => {
|
|
1032
|
+
void refreshMessages();
|
|
1033
|
+
}, 1e4);
|
|
1034
|
+
};
|
|
1035
|
+
const stop = () => {
|
|
1036
|
+
if (timer) {
|
|
1037
|
+
clearInterval(timer);
|
|
1038
|
+
timer = null;
|
|
1039
|
+
}
|
|
1040
|
+
};
|
|
1041
|
+
const onVis = () => document.visibilityState === "visible" ? start() : stop();
|
|
1042
|
+
onVis();
|
|
1043
|
+
document.addEventListener("visibilitychange", onVis);
|
|
1044
|
+
return () => {
|
|
1045
|
+
stop();
|
|
1046
|
+
document.removeEventListener("visibilitychange", onVis);
|
|
1047
|
+
};
|
|
1048
|
+
}, [selected.id, refreshMessages]);
|
|
255
1049
|
async function handleSend() {
|
|
256
1050
|
if (!draft.trim()) return;
|
|
257
1051
|
const body = draft;
|
|
@@ -259,7 +1053,7 @@ function MessageThread({ selected, onTogglePanel, panelOpen, onBack, className }
|
|
|
259
1053
|
await send(body);
|
|
260
1054
|
}
|
|
261
1055
|
const accent = CHANNEL_ACCENT[selected.channel];
|
|
262
|
-
const rows =
|
|
1056
|
+
const rows = React9__default.useMemo(() => buildRows(messages), [messages]);
|
|
263
1057
|
return /* @__PURE__ */ jsxs("section", { className: cn("flex min-w-0 flex-1 flex-col bg-muted/20", className), children: [
|
|
264
1058
|
/* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2 border-b border-border bg-card px-3 py-2.5 md:px-5", children: [
|
|
265
1059
|
/* @__PURE__ */ jsxs("div", { className: "flex min-w-0 items-center gap-2 md:gap-3", children: [
|
|
@@ -274,15 +1068,10 @@ function MessageThread({ selected, onTogglePanel, panelOpen, onBack, className }
|
|
|
274
1068
|
] })
|
|
275
1069
|
] }),
|
|
276
1070
|
/* @__PURE__ */ jsxs("div", { className: "flex shrink-0 items-center gap-1.5", children: [
|
|
277
|
-
/* @__PURE__ */ jsxs(Button, { variant: "outline", size: "sm", onClick: () => setStatus("
|
|
278
|
-
/* @__PURE__ */ jsx(Clock, { className: "h-3.5 w-3.5 sm:mr-1" }),
|
|
279
|
-
" ",
|
|
280
|
-
/* @__PURE__ */ jsx("span", { className: "hidden sm:inline", children: t("conversations.thread.snooze") })
|
|
281
|
-
] }),
|
|
282
|
-
/* @__PURE__ */ jsxs(Button, { variant: "outline", size: "sm", onClick: () => setStatus("closed"), "aria-label": t("conversations.thread.close"), children: [
|
|
1071
|
+
/* @__PURE__ */ jsxs(Button, { variant: "outline", size: "sm", onClick: () => setStatus("closed"), "aria-label": "Arquivar", children: [
|
|
283
1072
|
/* @__PURE__ */ jsx(Archive, { className: "h-3.5 w-3.5 sm:mr-1" }),
|
|
284
1073
|
" ",
|
|
285
|
-
/* @__PURE__ */ jsx("span", { className: "hidden sm:inline", children:
|
|
1074
|
+
/* @__PURE__ */ jsx("span", { className: "hidden sm:inline", children: "Arquivar" })
|
|
286
1075
|
] }),
|
|
287
1076
|
/* @__PURE__ */ jsx(
|
|
288
1077
|
Button,
|
|
@@ -303,27 +1092,39 @@ function MessageThread({ selected, onTogglePanel, panelOpen, onBack, className }
|
|
|
303
1092
|
}
|
|
304
1093
|
const m = row.message;
|
|
305
1094
|
const outbound = m.direction === "outbound";
|
|
306
|
-
|
|
1095
|
+
const failed = isFailed(m);
|
|
1096
|
+
return /* @__PURE__ */ jsxs(
|
|
307
1097
|
"div",
|
|
308
1098
|
{
|
|
309
|
-
className: cn("flex", outbound ? "justify-end" : "justify-start", row.startsRun ? "mt-2.5" : "mt-0.5"),
|
|
310
|
-
children:
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
1099
|
+
className: cn("group flex items-center gap-1", outbound ? "justify-end" : "justify-start", row.startsRun ? "mt-2.5" : "mt-0.5"),
|
|
1100
|
+
children: [
|
|
1101
|
+
outbound && /* @__PURE__ */ jsx(MessageMenu, { m }),
|
|
1102
|
+
/* @__PURE__ */ jsxs(
|
|
1103
|
+
"div",
|
|
1104
|
+
{
|
|
1105
|
+
className: cn(
|
|
1106
|
+
"max-w-[68%] px-3.5 py-2 text-sm shadow-sm",
|
|
1107
|
+
outbound ? "rounded-2xl text-white" : "rounded-2xl bg-card text-foreground",
|
|
1108
|
+
// Tail only on the last bubble of a run, on the sender's side.
|
|
1109
|
+
outbound && row.endsRun && "rounded-br-sm",
|
|
1110
|
+
!outbound && row.endsRun && "rounded-bl-sm",
|
|
1111
|
+
// UMA MENSAGEM QUE FALHOU PRECISA PARECER QUE FALHOU. Duas
|
|
1112
|
+
// ficaram um dia na caixa com a cara de qualquer outra e nunca
|
|
1113
|
+
// chegaram a ninguém — não havia como saber olhando.
|
|
1114
|
+
outbound && failed && "ring-2 ring-destructive"
|
|
1115
|
+
),
|
|
1116
|
+
style: outbound ? { backgroundColor: failed ? "hsl(var(--destructive))" : accent.color } : void 0,
|
|
1117
|
+
children: [
|
|
1118
|
+
/* @__PURE__ */ jsx("p", { className: "whitespace-pre-wrap break-words", children: m.body }),
|
|
1119
|
+
/* @__PURE__ */ jsxs("div", { className: cn("mt-0.5 flex items-center justify-end gap-1 text-[10px]", outbound ? "text-white/70" : "text-muted-foreground"), children: [
|
|
1120
|
+
new Date(m.at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
|
|
1121
|
+
/* @__PURE__ */ jsx(DeliveryMark, { m })
|
|
1122
|
+
] })
|
|
1123
|
+
]
|
|
1124
|
+
}
|
|
1125
|
+
),
|
|
1126
|
+
!outbound && /* @__PURE__ */ jsx(MessageMenu, { m })
|
|
1127
|
+
]
|
|
327
1128
|
},
|
|
328
1129
|
row.id
|
|
329
1130
|
);
|
|
@@ -333,25 +1134,124 @@ function MessageThread({ selected, onTogglePanel, panelOpen, onBack, className }
|
|
|
333
1134
|
/* @__PURE__ */ jsx("p", { className: "mt-2 text-sm", children: t("conversations.thread.empty") })
|
|
334
1135
|
] })
|
|
335
1136
|
] }),
|
|
336
|
-
/* @__PURE__ */
|
|
337
|
-
/* @__PURE__ */ jsx(
|
|
338
|
-
|
|
1137
|
+
/* @__PURE__ */ jsxs("div", { className: "border-t border-border bg-card", children: [
|
|
1138
|
+
/* @__PURE__ */ jsx(SendWindowBanner, { state: win }),
|
|
1139
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-end gap-2 px-4 py-3", children: [
|
|
1140
|
+
/* @__PURE__ */ jsx(
|
|
1141
|
+
"textarea",
|
|
1142
|
+
{
|
|
1143
|
+
value: draft,
|
|
1144
|
+
onChange: (e) => setDraft(e.target.value),
|
|
1145
|
+
onKeyDown: (e) => {
|
|
1146
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
1147
|
+
e.preventDefault();
|
|
1148
|
+
void handleSend();
|
|
1149
|
+
}
|
|
1150
|
+
},
|
|
1151
|
+
rows: 1,
|
|
1152
|
+
disabled: !canType,
|
|
1153
|
+
placeholder: canType ? t("conversations.thread.reply", { channel: CHANNEL_LABELS[selected.channel] }) : win.mode.kind === "blocked" ? "Envio bloqueado" : "Fora da janela \u2014 envie um template",
|
|
1154
|
+
className: "max-h-32 min-h-[40px] flex-1 resize-none rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary disabled:cursor-not-allowed disabled:bg-muted/40 disabled:text-muted-foreground"
|
|
1155
|
+
}
|
|
1156
|
+
),
|
|
1157
|
+
/* @__PURE__ */ jsx(Button, { onClick: () => void handleSend(), disabled: !canType || sending || !draft.trim(), "aria-label": t("conversations.thread.send"), children: /* @__PURE__ */ jsx(Send, { className: "h-4 w-4" }) })
|
|
1158
|
+
] })
|
|
1159
|
+
] })
|
|
1160
|
+
] });
|
|
1161
|
+
}
|
|
1162
|
+
var TEMP = {
|
|
1163
|
+
hot: { icon: Flame, cls: "bg-destructive-soft text-destructive-soft-foreground", label: "Quente" },
|
|
1164
|
+
warm: { icon: Sun, cls: "bg-warning-soft text-warning-soft-foreground", label: "Morno" },
|
|
1165
|
+
cold: { icon: Snowflake, cls: "bg-info-soft text-info-soft-foreground", label: "Frio" }
|
|
1166
|
+
};
|
|
1167
|
+
function LeadCard({ personId, handle }) {
|
|
1168
|
+
const [lead, setLead] = React9__default.useState(void 0);
|
|
1169
|
+
React9__default.useEffect(() => {
|
|
1170
|
+
let alive = true;
|
|
1171
|
+
const sb = getSupabaseClientOptional();
|
|
1172
|
+
const tenantId = getActiveTenantId();
|
|
1173
|
+
if (!sb || !tenantId || !personId && !handle) {
|
|
1174
|
+
setLead(null);
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
(async () => {
|
|
1178
|
+
try {
|
|
1179
|
+
let q = sb.from("people").select("id, name, metadata, phone").eq("tenant_id", tenantId).eq("kind", "lead").limit(1);
|
|
1180
|
+
q = personId ? q.eq("id", personId) : q.like("phone", `%${String(handle).replace(/\D/g, "").slice(-8)}`);
|
|
1181
|
+
const { data } = await q.maybeSingle();
|
|
1182
|
+
if (!alive) return;
|
|
1183
|
+
if (!data) {
|
|
1184
|
+
setLead(null);
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
const meta = data.metadata ?? {};
|
|
1188
|
+
let score = null;
|
|
1189
|
+
try {
|
|
1190
|
+
const { data: s } = await sb.from("v_crm_lead_score").select("temperature, fit_score, interest_score, reasons").eq("lead_id", data.id).maybeSingle();
|
|
1191
|
+
score = s;
|
|
1192
|
+
} catch {
|
|
1193
|
+
}
|
|
1194
|
+
setLead({
|
|
1195
|
+
id: data.id,
|
|
1196
|
+
name: data.name,
|
|
1197
|
+
company: meta.company ?? null,
|
|
1198
|
+
status: meta.status ?? null,
|
|
1199
|
+
sourceName: meta.sourceName ?? null,
|
|
1200
|
+
temperature: score?.temperature ?? null,
|
|
1201
|
+
fitScore: score?.fit_score ?? null,
|
|
1202
|
+
interestScore: score?.interest_score ?? null,
|
|
1203
|
+
reasons: Array.isArray(score?.reasons) ? score.reasons : []
|
|
1204
|
+
});
|
|
1205
|
+
} catch {
|
|
1206
|
+
if (alive) setLead(null);
|
|
1207
|
+
}
|
|
1208
|
+
})();
|
|
1209
|
+
return () => {
|
|
1210
|
+
alive = false;
|
|
1211
|
+
};
|
|
1212
|
+
}, [personId, handle]);
|
|
1213
|
+
if (lead === void 0) return /* @__PURE__ */ jsx("div", { className: "px-4 py-3 text-xs text-muted-foreground", children: "carregando\u2026" });
|
|
1214
|
+
if (!lead) {
|
|
1215
|
+
return /* @__PURE__ */ jsx("div", { className: "border-t border-border px-4 py-3", children: /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: "Este contato ainda n\xE3o \xE9 um lead cadastrado." }) });
|
|
1216
|
+
}
|
|
1217
|
+
const t = lead.temperature ? TEMP[lead.temperature] : null;
|
|
1218
|
+
const Icon = t?.icon;
|
|
1219
|
+
return /* @__PURE__ */ jsxs("div", { className: "border-t border-border px-4 py-3", children: [
|
|
1220
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-start justify-between gap-2", children: [
|
|
1221
|
+
/* @__PURE__ */ jsxs("div", { className: "min-w-0", children: [
|
|
1222
|
+
/* @__PURE__ */ jsx("p", { className: "truncate text-sm font-semibold", children: lead.name }),
|
|
1223
|
+
lead.company && /* @__PURE__ */ jsxs("p", { className: "flex items-center gap-1 truncate text-xs text-muted-foreground", children: [
|
|
1224
|
+
/* @__PURE__ */ jsx(Building2, { className: "h-3 w-3 shrink-0" }),
|
|
1225
|
+
" ",
|
|
1226
|
+
lead.company
|
|
1227
|
+
] })
|
|
1228
|
+
] }),
|
|
1229
|
+
/* @__PURE__ */ jsxs(
|
|
1230
|
+
"a",
|
|
339
1231
|
{
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
}
|
|
347
|
-
},
|
|
348
|
-
rows: 1,
|
|
349
|
-
placeholder: t("conversations.thread.reply", { channel: CHANNEL_LABELS[selected.channel] }),
|
|
350
|
-
className: "max-h-32 min-h-[40px] flex-1 resize-none rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary"
|
|
1232
|
+
href: `#/sales/leads/${lead.id}`,
|
|
1233
|
+
className: "inline-flex shrink-0 items-center gap-1 rounded-button border bg-card px-2 py-1 text-[11px] font-medium hover:bg-muted",
|
|
1234
|
+
children: [
|
|
1235
|
+
"Abrir lead ",
|
|
1236
|
+
/* @__PURE__ */ jsx(ExternalLink, { className: "h-3 w-3" })
|
|
1237
|
+
]
|
|
351
1238
|
}
|
|
352
|
-
)
|
|
353
|
-
|
|
354
|
-
|
|
1239
|
+
)
|
|
1240
|
+
] }),
|
|
1241
|
+
/* @__PURE__ */ jsxs("div", { className: "mt-2 flex flex-wrap items-center gap-1.5", children: [
|
|
1242
|
+
t && Icon && /* @__PURE__ */ jsxs("span", { className: `inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium ${t.cls}`, children: [
|
|
1243
|
+
/* @__PURE__ */ jsx(Icon, { className: "h-3 w-3" }),
|
|
1244
|
+
" ",
|
|
1245
|
+
t.label,
|
|
1246
|
+
lead.interestScore != null && /* @__PURE__ */ jsx("span", { className: "tabular-nums opacity-70", children: lead.interestScore })
|
|
1247
|
+
] }),
|
|
1248
|
+
lead.status && /* @__PURE__ */ jsx("span", { className: "rounded-full bg-muted px-2 py-0.5 text-[10px] capitalize text-muted-foreground", children: lead.status }),
|
|
1249
|
+
lead.sourceName && /* @__PURE__ */ jsx("span", { className: "rounded-full bg-muted px-2 py-0.5 text-[10px] text-muted-foreground", children: lead.sourceName })
|
|
1250
|
+
] }),
|
|
1251
|
+
lead.reasons.length > 0 && /* @__PURE__ */ jsx("ul", { className: "mt-2 space-y-0.5", children: lead.reasons.slice(0, 4).map((r, i) => /* @__PURE__ */ jsxs("li", { className: "flex gap-1.5 text-[11px] text-muted-foreground", children: [
|
|
1252
|
+
/* @__PURE__ */ jsx("span", { className: "text-muted-foreground/40", children: "\u2022" }),
|
|
1253
|
+
/* @__PURE__ */ jsx("span", { children: r })
|
|
1254
|
+
] }, i)) })
|
|
355
1255
|
] });
|
|
356
1256
|
}
|
|
357
1257
|
function Section({ icon: Icon, title, children }) {
|
|
@@ -379,6 +1279,7 @@ function ContactPanel({ contact, onClose, className }) {
|
|
|
379
1279
|
] }),
|
|
380
1280
|
/* @__PURE__ */ jsx(ChannelBadge, { channel: contact.channel })
|
|
381
1281
|
] }),
|
|
1282
|
+
/* @__PURE__ */ jsx(LeadCard, { personId: contact.contactPersonId, handle: contact.contactHandle }),
|
|
382
1283
|
/* @__PURE__ */ jsx(Section, { icon: User, title: t("conversations.contact.details"), children: /* @__PURE__ */ jsxs("dl", { className: "space-y-1.5 text-sm", children: [
|
|
383
1284
|
/* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2", children: [
|
|
384
1285
|
/* @__PURE__ */ jsx("dt", { className: "text-muted-foreground", children: t("conversations.contact.channel") }),
|
|
@@ -428,14 +1329,14 @@ function NewConversationPanel({
|
|
|
428
1329
|
const create = useConversationsStore((s) => s.create);
|
|
429
1330
|
const config = useConversationsConfig();
|
|
430
1331
|
const guardConversations = useLimitGuard("conversations_month");
|
|
431
|
-
const [channel, setChannel] =
|
|
432
|
-
const [contact, setContact] =
|
|
433
|
-
const [typedHandle, setTypedHandle] =
|
|
434
|
-
const [creatingContact, setCreatingContact] =
|
|
435
|
-
const [firstMessage, setFirstMessage] =
|
|
436
|
-
const [submitting, setSubmitting] =
|
|
437
|
-
const [pickerKey, setPickerKey] =
|
|
438
|
-
|
|
1332
|
+
const [channel, setChannel] = React9__default.useState("whatsapp");
|
|
1333
|
+
const [contact, setContact] = React9__default.useState(null);
|
|
1334
|
+
const [typedHandle, setTypedHandle] = React9__default.useState("");
|
|
1335
|
+
const [creatingContact, setCreatingContact] = React9__default.useState(false);
|
|
1336
|
+
const [firstMessage, setFirstMessage] = React9__default.useState("");
|
|
1337
|
+
const [submitting, setSubmitting] = React9__default.useState(false);
|
|
1338
|
+
const [pickerKey, setPickerKey] = React9__default.useState(0);
|
|
1339
|
+
React9__default.useEffect(() => {
|
|
439
1340
|
if (open) {
|
|
440
1341
|
setChannel("whatsapp");
|
|
441
1342
|
setContact(null);
|
|
@@ -553,10 +1454,10 @@ function InboxView() {
|
|
|
553
1454
|
const t = useTranslation();
|
|
554
1455
|
const { conversations, selectedId, deselect } = useConversationsStore((s) => s);
|
|
555
1456
|
const isWidePanel = useMediaQuery("(min-width: 1280px)");
|
|
556
|
-
const [panelOpen, setPanelOpen] =
|
|
557
|
-
const [newOpen, setNewOpen] =
|
|
1457
|
+
const [panelOpen, setPanelOpen] = React9__default.useState(false);
|
|
1458
|
+
const [newOpen, setNewOpen] = React9__default.useState(false);
|
|
558
1459
|
const selected = conversations.find((c) => c.id === selectedId) ?? null;
|
|
559
|
-
|
|
1460
|
+
React9__default.useEffect(() => {
|
|
560
1461
|
setPanelOpen(isWidePanel);
|
|
561
1462
|
}, [isWidePanel]);
|
|
562
1463
|
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
@@ -610,9 +1511,40 @@ function InboxView() {
|
|
|
610
1511
|
] })
|
|
611
1512
|
] });
|
|
612
1513
|
}
|
|
1514
|
+
var CHANNELS2 = ["sms", "whatsapp", "instagram", "email", "webchat"];
|
|
1515
|
+
function requestedContact() {
|
|
1516
|
+
try {
|
|
1517
|
+
const hash = window.location.hash;
|
|
1518
|
+
const qs = hash.includes("?") ? hash.slice(hash.indexOf("?") + 1) : "";
|
|
1519
|
+
if (!qs) return null;
|
|
1520
|
+
const p = new URLSearchParams(qs);
|
|
1521
|
+
const handle = (p.get("handle") ?? "").trim();
|
|
1522
|
+
const name = (p.get("name") ?? "").trim();
|
|
1523
|
+
const personId = (p.get("personId") ?? "").trim() || void 0;
|
|
1524
|
+
if (!handle) return null;
|
|
1525
|
+
const raw = (p.get("channel") ?? "whatsapp").toLowerCase();
|
|
1526
|
+
const channel = CHANNELS2.includes(raw) ? raw : "whatsapp";
|
|
1527
|
+
return { personId, name: name || handle, handle, channel };
|
|
1528
|
+
} catch {
|
|
1529
|
+
return null;
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
613
1532
|
function ConversationsPage({ store: store2, config }) {
|
|
614
|
-
|
|
615
|
-
|
|
1533
|
+
React9__default.useEffect(() => {
|
|
1534
|
+
const wanted = requestedContact();
|
|
1535
|
+
if (!wanted) {
|
|
1536
|
+
void store2.getState().load();
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1539
|
+
void store2.getState().openForPerson(wanted).catch(() => {
|
|
1540
|
+
void store2.getState().load();
|
|
1541
|
+
});
|
|
1542
|
+
try {
|
|
1543
|
+
const hash = window.location.hash;
|
|
1544
|
+
const clean = hash.includes("?") ? hash.slice(0, hash.indexOf("?")) : hash;
|
|
1545
|
+
window.history.replaceState(null, "", clean || "#/conversations");
|
|
1546
|
+
} catch {
|
|
1547
|
+
}
|
|
616
1548
|
}, []);
|
|
617
1549
|
return /* @__PURE__ */ jsx(ConversationsContextProvider, { store: store2, config, children: /* @__PURE__ */ jsx(InboxView, {}) });
|
|
618
1550
|
}
|
|
@@ -924,16 +1856,24 @@ function mapMessage(r) {
|
|
|
924
1856
|
direction: r.direction ?? "inbound",
|
|
925
1857
|
body: r.body ?? "",
|
|
926
1858
|
at: r.at ?? "",
|
|
927
|
-
author: r.author ?? ""
|
|
1859
|
+
author: r.author ?? "",
|
|
1860
|
+
deliveryStatus: r.delivery_status ?? null,
|
|
1861
|
+
providerMessageId: r.provider_message_id ?? null
|
|
928
1862
|
};
|
|
929
1863
|
}
|
|
930
1864
|
function createSupabaseConversationsProvider(config) {
|
|
1865
|
+
const DELIVERY = /* @__PURE__ */ new Set(["queued", "sent", "delivered", "read", "failed", "expired", "delivery_timeout", "cancelled", "opted_out"]);
|
|
1866
|
+
function mapDeliveryStatus(raw) {
|
|
1867
|
+
const v = String(raw ?? "").toLowerCase();
|
|
1868
|
+
if (v === "accepted" || v === "received") return "sent";
|
|
1869
|
+
return DELIVERY.has(v) ? v : "sent";
|
|
1870
|
+
}
|
|
931
1871
|
const selfAuthor = config?.selfAuthor ?? "You";
|
|
932
1872
|
function resolveTenantId() {
|
|
933
1873
|
if (!config?.tenantId) return void 0;
|
|
934
1874
|
return typeof config.tenantId === "function" ? config.tenantId() : config.tenantId;
|
|
935
1875
|
}
|
|
936
|
-
function
|
|
1876
|
+
function client4() {
|
|
937
1877
|
const supabase = config?.supabaseClient ?? getSupabaseClientOptional();
|
|
938
1878
|
if (!supabase) {
|
|
939
1879
|
throw new Error(
|
|
@@ -944,7 +1884,7 @@ function createSupabaseConversationsProvider(config) {
|
|
|
944
1884
|
}
|
|
945
1885
|
return {
|
|
946
1886
|
async listConversations(query) {
|
|
947
|
-
let q =
|
|
1887
|
+
let q = client4().from(T.conversations).select("*");
|
|
948
1888
|
const tenantId = resolveTenantId();
|
|
949
1889
|
if (tenantId) q = q.eq("tenant_id", tenantId);
|
|
950
1890
|
if (query?.channel && query.channel !== "all") {
|
|
@@ -967,7 +1907,7 @@ function createSupabaseConversationsProvider(config) {
|
|
|
967
1907
|
return (data ?? []).map(mapConversation);
|
|
968
1908
|
},
|
|
969
1909
|
async getMessages(conversationId) {
|
|
970
|
-
const selected =
|
|
1910
|
+
const selected = client4().from(T.messages).select("*");
|
|
971
1911
|
const filtered = selected.eq(
|
|
972
1912
|
"conversation_id",
|
|
973
1913
|
conversationId
|
|
@@ -1004,7 +1944,7 @@ function createSupabaseConversationsProvider(config) {
|
|
|
1004
1944
|
note: input.note?.trim() || null
|
|
1005
1945
|
};
|
|
1006
1946
|
if (tenantId) convRow.tenant_id = tenantId;
|
|
1007
|
-
const { data: created, error } = await
|
|
1947
|
+
const { data: created, error } = await client4().from(T.conversations).insert(convRow).select().single();
|
|
1008
1948
|
if (error) throw error;
|
|
1009
1949
|
if (!created) throw new Error("Conversation not created");
|
|
1010
1950
|
if (firstMessage) {
|
|
@@ -1017,14 +1957,14 @@ function createSupabaseConversationsProvider(config) {
|
|
|
1017
1957
|
at: now
|
|
1018
1958
|
};
|
|
1019
1959
|
if (tenantId) msgRow.tenant_id = tenantId;
|
|
1020
|
-
await
|
|
1960
|
+
await client4().from(T.messages).insert(msgRow);
|
|
1021
1961
|
}
|
|
1022
1962
|
return mapConversation(created);
|
|
1023
1963
|
},
|
|
1024
1964
|
async sendMessage(input) {
|
|
1025
1965
|
const tenantId = resolveTenantId();
|
|
1026
|
-
const convSelected =
|
|
1027
|
-
"channel"
|
|
1966
|
+
const convSelected = client4().from(T.conversations).select(
|
|
1967
|
+
"channel, contact_handle"
|
|
1028
1968
|
);
|
|
1029
1969
|
const convFiltered = convSelected.eq(
|
|
1030
1970
|
"id",
|
|
@@ -1042,24 +1982,63 @@ function createSupabaseConversationsProvider(config) {
|
|
|
1042
1982
|
at
|
|
1043
1983
|
};
|
|
1044
1984
|
if (tenantId) row.tenant_id = tenantId;
|
|
1045
|
-
const { data: created, error } = await
|
|
1985
|
+
const { data: created, error } = await client4().from(T.messages).insert(row).select().single();
|
|
1046
1986
|
if (error) throw error;
|
|
1047
|
-
|
|
1987
|
+
let sentId = null;
|
|
1988
|
+
let sentStatus = null;
|
|
1989
|
+
if (channel === "whatsapp") {
|
|
1990
|
+
const handle = String(conv?.contact_handle ?? "");
|
|
1991
|
+
try {
|
|
1992
|
+
const { data: out, error: sendErr } = await client4().functions.invoke(
|
|
1993
|
+
"conversations-provider",
|
|
1994
|
+
{ body: { tenantId, action: "send", to: handle, text: input.body } }
|
|
1995
|
+
);
|
|
1996
|
+
if (sendErr) {
|
|
1997
|
+
const detail = await sendErr.context?.json?.().catch(() => null);
|
|
1998
|
+
throw new Error(detail?.message ?? sendErr.message);
|
|
1999
|
+
}
|
|
2000
|
+
if (out?.error) throw new Error(out.message ?? out.error);
|
|
2001
|
+
sentId = out?.providerMessageId ?? null;
|
|
2002
|
+
sentStatus = out?.queued ? "queued" : mapDeliveryStatus(out?.status);
|
|
2003
|
+
if (created?.id) {
|
|
2004
|
+
await client4().from(T.messages).update({
|
|
2005
|
+
provider_message_id: sentId,
|
|
2006
|
+
// O provedor fala o dialeto dele. A Tyxter devolve `accepted`,
|
|
2007
|
+
// que NÃO existe no CHECK desta coluna — o update explodia, caía
|
|
2008
|
+
// no catch, e a mensagem era marcada como falha mesmo tendo
|
|
2009
|
+
// saído. Foi assim que "opa" apareceu verde e contou como erro.
|
|
2010
|
+
delivery_status: sentStatus
|
|
2011
|
+
}).eq("id", String(created.id));
|
|
2012
|
+
}
|
|
2013
|
+
} catch (err) {
|
|
2014
|
+
if (created?.id) {
|
|
2015
|
+
await client4().from(T.messages).update({
|
|
2016
|
+
delivery_status: "failed"
|
|
2017
|
+
}).eq("id", String(created.id));
|
|
2018
|
+
}
|
|
2019
|
+
throw err;
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
await client4().from(T.conversations).update({
|
|
1048
2023
|
last_message_preview: input.body,
|
|
1049
2024
|
last_message_at: at,
|
|
1050
2025
|
unread_count: 0,
|
|
1051
2026
|
status: "open"
|
|
1052
2027
|
}).eq("id", input.conversationId);
|
|
1053
|
-
return
|
|
2028
|
+
return {
|
|
2029
|
+
...mapMessage(created ?? row),
|
|
2030
|
+
providerMessageId: sentId,
|
|
2031
|
+
deliveryStatus: sentStatus
|
|
2032
|
+
};
|
|
1054
2033
|
},
|
|
1055
2034
|
async markRead(conversationId) {
|
|
1056
|
-
const { error } = await
|
|
2035
|
+
const { error } = await client4().from(T.conversations).update({
|
|
1057
2036
|
unread_count: 0
|
|
1058
2037
|
}).eq("id", conversationId);
|
|
1059
2038
|
if (error) throw error;
|
|
1060
2039
|
},
|
|
1061
2040
|
async setStatus(conversationId, status) {
|
|
1062
|
-
const updated =
|
|
2041
|
+
const updated = client4().from(T.conversations).update({
|
|
1063
2042
|
status
|
|
1064
2043
|
});
|
|
1065
2044
|
const filtered = updated.eq("id", conversationId);
|
|
@@ -1071,6 +2050,11 @@ function createSupabaseConversationsProvider(config) {
|
|
|
1071
2050
|
}
|
|
1072
2051
|
};
|
|
1073
2052
|
}
|
|
2053
|
+
function handleKey(h) {
|
|
2054
|
+
const v = (h ?? "").trim();
|
|
2055
|
+
if (!v) return "";
|
|
2056
|
+
return /[@a-z]/i.test(v) ? v.toLowerCase() : v.replace(/\D/g, "");
|
|
2057
|
+
}
|
|
1074
2058
|
function createConversationsStore(provider) {
|
|
1075
2059
|
return createStore((set, get) => ({
|
|
1076
2060
|
conversations: [],
|
|
@@ -1099,6 +2083,26 @@ function createConversationsStore(provider) {
|
|
|
1099
2083
|
conversations: s.conversations.map((c) => c.id === id ? { ...c, unreadCount: 0 } : c)
|
|
1100
2084
|
}));
|
|
1101
2085
|
},
|
|
2086
|
+
/** Relê as mensagens da conversa aberta, sem tocar em mais nada.
|
|
2087
|
+
*
|
|
2088
|
+
* O status de uma mensagem de WhatsApp muda DEPOIS do envio — entregue,
|
|
2089
|
+
* lida, às vezes minutos depois — e quem avisa é o webhook, que escreve no
|
|
2090
|
+
* banco sem a tela saber. Sem isto a caixa mostra para sempre o status do
|
|
2091
|
+
* instante do envio: um tique só, numa mensagem que já foi lida.
|
|
2092
|
+
*
|
|
2093
|
+
* Não usa `select()` de propósito: aquele marca a conversa como lida e
|
|
2094
|
+
* zera o contador, e uma releitura de fundo não é um gesto de ninguém. */
|
|
2095
|
+
async refreshMessages() {
|
|
2096
|
+
const id = get().selectedId;
|
|
2097
|
+
if (!id) return;
|
|
2098
|
+
try {
|
|
2099
|
+
const messages = await provider.getMessages(id);
|
|
2100
|
+
const before = get().messages;
|
|
2101
|
+
const same = before.length === messages.length && before.every((m, i) => m.id === messages[i].id && m.deliveryStatus === messages[i].deliveryStatus);
|
|
2102
|
+
if (!same) set({ messages });
|
|
2103
|
+
} catch {
|
|
2104
|
+
}
|
|
2105
|
+
},
|
|
1102
2106
|
deselect() {
|
|
1103
2107
|
set({ selectedId: null, messages: [] });
|
|
1104
2108
|
},
|
|
@@ -1129,6 +2133,31 @@ function createConversationsStore(provider) {
|
|
|
1129
2133
|
})();
|
|
1130
2134
|
return created;
|
|
1131
2135
|
},
|
|
2136
|
+
// Chegar aqui vindo de fora — da ficha de um lead, por exemplo — não pode
|
|
2137
|
+
// criar uma segunda thread com alguém com quem a casa já conversa. Procura
|
|
2138
|
+
// primeiro pela PESSOA, e só então pelo telefone: o vínculo é a verdade, e
|
|
2139
|
+
// o handle é o que resgata threads criadas antes de o vínculo existir.
|
|
2140
|
+
async openForPerson({ personId, name, handle, channel }) {
|
|
2141
|
+
if (get().conversations.length === 0 && !get().loading) {
|
|
2142
|
+
try {
|
|
2143
|
+
await get().load();
|
|
2144
|
+
} catch {
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
const key = handleKey(handle);
|
|
2148
|
+
const existing = get().conversations.find((c) => c.channel === channel && (personId && c.contactPersonId === personId || key !== "" && handleKey(c.contactHandle) === key));
|
|
2149
|
+
if (existing) {
|
|
2150
|
+
set({ channelFilter: "all", search: "" });
|
|
2151
|
+
await get().select(existing.id);
|
|
2152
|
+
return existing;
|
|
2153
|
+
}
|
|
2154
|
+
return get().create({
|
|
2155
|
+
contactName: name,
|
|
2156
|
+
contactPersonId: personId,
|
|
2157
|
+
contactHandle: handle,
|
|
2158
|
+
channel
|
|
2159
|
+
});
|
|
2160
|
+
},
|
|
1132
2161
|
async send(body) {
|
|
1133
2162
|
const id = get().selectedId;
|
|
1134
2163
|
if (!id || !body.trim()) return;
|
|
@@ -1138,7 +2167,18 @@ function createConversationsStore(provider) {
|
|
|
1138
2167
|
sending: false,
|
|
1139
2168
|
messages: [...s.messages, created],
|
|
1140
2169
|
conversations: s.conversations.map(
|
|
1141
|
-
(c) =>
|
|
2170
|
+
(c) => (
|
|
2171
|
+
// `lastMessageDirection` acompanha a prévia, senão a fila mente. O
|
|
2172
|
+
// gatilho da migration 001 carimba a coluna no banco, mas a lista só
|
|
2173
|
+
// volta a ler no próximo `load()` — e até lá a thread que ACABOU de
|
|
2174
|
+
// ser respondida continua em "esperando nós", que é exatamente a
|
|
2175
|
+
// pergunta que essa coluna existe para responder.
|
|
2176
|
+
//
|
|
2177
|
+
// Antes da 001 isto não aparecia: a coluna vinha sempre indefinida e
|
|
2178
|
+
// `isWaitingOnUs` caía no `unreadCount`. A migration não criou o
|
|
2179
|
+
// defeito, tornou-o visível.
|
|
2180
|
+
c.id === id ? { ...c, lastMessagePreview: created.body, lastMessageAt: created.at, lastMessageDirection: "outbound" } : c
|
|
2181
|
+
)
|
|
1142
2182
|
)
|
|
1143
2183
|
}));
|
|
1144
2184
|
},
|
|
@@ -1478,6 +2518,237 @@ function createConversationsDashboardWidgets(ctx) {
|
|
|
1478
2518
|
})
|
|
1479
2519
|
];
|
|
1480
2520
|
}
|
|
2521
|
+
var COPY = {
|
|
2522
|
+
tyxter: {
|
|
2523
|
+
name: "Tyxter",
|
|
2524
|
+
what: "API oficial do WhatsApp Business, via revenda.",
|
|
2525
|
+
strength: "Entrega confi\xE1vel, sem risco de banimento. A Meta mede a qualidade do n\xFAmero.",
|
|
2526
|
+
cost: "Para iniciar conversa \xE9 obrigat\xF3rio um template aprovado pela Meta (~1 dia). Texto livre s\xF3 dentro de 24h da resposta do cliente.",
|
|
2527
|
+
fields: [
|
|
2528
|
+
{ key: "api_key", label: "Chave da API", placeholder: "tx_live_\u2026", secret: true },
|
|
2529
|
+
{ key: "default_sender_id", label: "N\xFAmero remetente (id)", placeholder: "cm\u2026" }
|
|
2530
|
+
]
|
|
2531
|
+
},
|
|
2532
|
+
evolution: {
|
|
2533
|
+
name: "Evolution API",
|
|
2534
|
+
what: "Gateway auto-hospedado, sobre o WhatsApp Web.",
|
|
2535
|
+
strength: "Manda texto livre para quem quiser, sem template e sem janela de 24h.",
|
|
2536
|
+
cost: "O n\xFAmero pode ser banido sem aviso, e a sess\xE3o cai sozinha. Aquecer o n\xFAmero n\xE3o \xE9 opcional aqui.",
|
|
2537
|
+
fields: [
|
|
2538
|
+
{ key: "base_url", label: "URL da inst\xE2ncia", placeholder: "https://evo.seudominio.com" },
|
|
2539
|
+
{ key: "api_key", label: "Chave da API", placeholder: "sua apikey", secret: true },
|
|
2540
|
+
{ key: "instance", label: "Nome da inst\xE2ncia", placeholder: "chefcontrol" }
|
|
2541
|
+
]
|
|
2542
|
+
}
|
|
2543
|
+
};
|
|
2544
|
+
var ALL = ["tyxter", "evolution"];
|
|
2545
|
+
function StatusChip({ row }) {
|
|
2546
|
+
if (!row || row.status === "unconfigured") {
|
|
2547
|
+
return /* @__PURE__ */ jsx("span", { className: "rounded-full bg-muted px-2 py-0.5 text-[10px] text-muted-foreground", children: "n\xE3o configurado" });
|
|
2548
|
+
}
|
|
2549
|
+
const map = {
|
|
2550
|
+
connected: "bg-success-soft text-success-soft-foreground",
|
|
2551
|
+
connecting: "bg-warning-soft text-warning-soft-foreground",
|
|
2552
|
+
disconnected: "bg-muted text-muted-foreground",
|
|
2553
|
+
error: "bg-destructive-soft text-destructive-soft-foreground"
|
|
2554
|
+
};
|
|
2555
|
+
const label = {
|
|
2556
|
+
connected: "conectado",
|
|
2557
|
+
connecting: "aguardando teste",
|
|
2558
|
+
disconnected: "desconectado",
|
|
2559
|
+
error: "com erro"
|
|
2560
|
+
};
|
|
2561
|
+
return /* @__PURE__ */ jsx("span", { className: `rounded-full px-2 py-0.5 text-[10px] font-medium ${map[row.status] ?? "bg-muted"}`, children: label[row.status] ?? row.status });
|
|
2562
|
+
}
|
|
2563
|
+
function NumberRow({ n }) {
|
|
2564
|
+
return /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center gap-x-3 gap-y-1 py-1.5 text-xs", children: [
|
|
2565
|
+
/* @__PURE__ */ jsx("span", { className: `h-1.5 w-1.5 shrink-0 rounded-full ${n.ready ? "bg-success" : "bg-muted-foreground/40"}` }),
|
|
2566
|
+
/* @__PURE__ */ jsx("span", { className: "font-medium tabular-nums", children: n.phone ?? n.id }),
|
|
2567
|
+
n.displayName && /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: n.displayName }),
|
|
2568
|
+
/* @__PURE__ */ jsxs("span", { className: "text-muted-foreground", children: [
|
|
2569
|
+
"qualidade: ",
|
|
2570
|
+
/* @__PURE__ */ jsx("span", { className: "text-foreground", children: n.quality ?? "\u2014" })
|
|
2571
|
+
] }),
|
|
2572
|
+
/* @__PURE__ */ jsxs("span", { className: "text-muted-foreground", children: [
|
|
2573
|
+
"limite: ",
|
|
2574
|
+
/* @__PURE__ */ jsx("span", { className: "text-foreground", children: n.tier ?? "\u2014" })
|
|
2575
|
+
] }),
|
|
2576
|
+
n.used24h != null && n.allowance != null && /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground", children: [
|
|
2577
|
+
"24h: ",
|
|
2578
|
+
/* @__PURE__ */ jsxs("span", { className: "text-foreground tabular-nums", children: [
|
|
2579
|
+
n.used24h,
|
|
2580
|
+
"/",
|
|
2581
|
+
n.used24h + n.allowance
|
|
2582
|
+
] })
|
|
2583
|
+
] }),
|
|
2584
|
+
!n.ready && /* @__PURE__ */ jsx("span", { className: "text-warning", children: "n\xE3o pronto para enviar" })
|
|
2585
|
+
] });
|
|
2586
|
+
}
|
|
2587
|
+
function WhatsAppProviders() {
|
|
2588
|
+
const [rows, setRows] = React9__default.useState(null);
|
|
2589
|
+
const [draft, setDraft] = React9__default.useState({});
|
|
2590
|
+
const [busy, setBusy] = React9__default.useState(null);
|
|
2591
|
+
const [numbers, setNumbers] = React9__default.useState({});
|
|
2592
|
+
const [qr, setQr] = React9__default.useState(null);
|
|
2593
|
+
const load = React9__default.useCallback(async () => {
|
|
2594
|
+
try {
|
|
2595
|
+
setRows(await providersApi.list());
|
|
2596
|
+
} catch {
|
|
2597
|
+
setRows([]);
|
|
2598
|
+
}
|
|
2599
|
+
}, []);
|
|
2600
|
+
React9__default.useEffect(() => {
|
|
2601
|
+
void load();
|
|
2602
|
+
}, [load]);
|
|
2603
|
+
const byId = React9__default.useMemo(
|
|
2604
|
+
() => Object.fromEntries((rows ?? []).map((r) => [r.provider, r])),
|
|
2605
|
+
[rows]
|
|
2606
|
+
);
|
|
2607
|
+
function field(p, key) {
|
|
2608
|
+
const row = byId[p];
|
|
2609
|
+
if (key === "api_key") return draft[p]?.[key] ?? "";
|
|
2610
|
+
return draft[p]?.[key] ?? String(row?.config?.[key] ?? "");
|
|
2611
|
+
}
|
|
2612
|
+
function setField(p, key, v) {
|
|
2613
|
+
setDraft((d) => ({ ...d, [p]: { ...d[p] ?? {}, [key]: v } }));
|
|
2614
|
+
}
|
|
2615
|
+
async function run(id, fn) {
|
|
2616
|
+
setBusy(id);
|
|
2617
|
+
try {
|
|
2618
|
+
await fn();
|
|
2619
|
+
} catch (e) {
|
|
2620
|
+
toast.error(e?.message ?? "Falhou");
|
|
2621
|
+
} finally {
|
|
2622
|
+
setBusy(null);
|
|
2623
|
+
}
|
|
2624
|
+
}
|
|
2625
|
+
const save = (p) => run(`save:${p}`, async () => {
|
|
2626
|
+
const d = draft[p] ?? {};
|
|
2627
|
+
const config = {};
|
|
2628
|
+
for (const f of COPY[p].fields) {
|
|
2629
|
+
if (f.secret) continue;
|
|
2630
|
+
const v = field(p, f.key);
|
|
2631
|
+
if (v) config[f.key] = v;
|
|
2632
|
+
}
|
|
2633
|
+
if (p === "tyxter" && !config.api_base_url) config.api_base_url = "https://api.tyxter.com";
|
|
2634
|
+
await providersApi.save(p, config, d.api_key);
|
|
2635
|
+
setDraft((x) => ({ ...x, [p]: { ...x[p] ?? {}, api_key: "" } }));
|
|
2636
|
+
await load();
|
|
2637
|
+
toast.success("Guardado");
|
|
2638
|
+
});
|
|
2639
|
+
const test = (p) => run(`test:${p}`, async () => {
|
|
2640
|
+
const h = await providersApi.health(p);
|
|
2641
|
+
await load();
|
|
2642
|
+
toast[h.ok ? "success" : "error"](h.ok ? `Conectado \u2014 ${h.detail ?? "ok"}` : `N\xE3o conectou: ${h.detail ?? "?"}`);
|
|
2643
|
+
});
|
|
2644
|
+
const refreshNumbers = (p) => run(`numbers:${p}`, async () => {
|
|
2645
|
+
const d = await providersApi.numbers(p);
|
|
2646
|
+
setNumbers((n) => ({ ...n, [p]: d.numbers }));
|
|
2647
|
+
if (d.numbers.length === 0) toast.error("Nenhum n\xFAmero nesta conta");
|
|
2648
|
+
});
|
|
2649
|
+
const activate = (p) => run(`on:${p}`, async () => {
|
|
2650
|
+
await providersApi.activate(p);
|
|
2651
|
+
await load();
|
|
2652
|
+
toast.success(`${COPY[p].name} est\xE1 valendo agora`);
|
|
2653
|
+
});
|
|
2654
|
+
const pair = (p) => run(`qr:${p}`, async () => {
|
|
2655
|
+
const d = await providersApi.pair(p);
|
|
2656
|
+
setQr({ provider: p, image: d.qr, code: d.code });
|
|
2657
|
+
if (!d.qr && !d.code) toast.error("A inst\xE2ncia n\xE3o devolveu QR \u2014 j\xE1 est\xE1 pareada?");
|
|
2658
|
+
});
|
|
2659
|
+
return /* @__PURE__ */ jsxs(
|
|
2660
|
+
SettingsGroup,
|
|
2661
|
+
{
|
|
2662
|
+
title: "WhatsApp \u2014 por onde as mensagens saem",
|
|
2663
|
+
description: "Dois caminhos, um valendo de cada vez. Troque quando quiser; a configura\xE7\xE3o do outro fica guardada.",
|
|
2664
|
+
children: [
|
|
2665
|
+
rows === null && /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 py-6 text-sm text-muted-foreground", children: [
|
|
2666
|
+
/* @__PURE__ */ jsx(Loader2, { className: "h-4 w-4 animate-spin" }),
|
|
2667
|
+
" carregando\u2026"
|
|
2668
|
+
] }),
|
|
2669
|
+
rows !== null && ALL.map((p) => {
|
|
2670
|
+
const row = byId[p];
|
|
2671
|
+
const copy = COPY[p];
|
|
2672
|
+
const active = Boolean(row?.isActive);
|
|
2673
|
+
const caps = row?.capabilities ?? {};
|
|
2674
|
+
return /* @__PURE__ */ jsxs(
|
|
2675
|
+
"div",
|
|
2676
|
+
{
|
|
2677
|
+
className: `rounded-card border p-4 my-2 ${active ? "border-primary bg-primary/[0.03]" : "bg-card"}`,
|
|
2678
|
+
"data-testid": `provider-${p}`,
|
|
2679
|
+
children: [
|
|
2680
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [
|
|
2681
|
+
/* @__PURE__ */ jsx("span", { className: "text-sm font-semibold", children: copy.name }),
|
|
2682
|
+
active && /* @__PURE__ */ jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-primary px-2 py-0.5 text-[10px] font-medium text-primary-foreground", children: [
|
|
2683
|
+
/* @__PURE__ */ jsx(Check, { className: "h-3 w-3" }),
|
|
2684
|
+
" valendo agora"
|
|
2685
|
+
] }),
|
|
2686
|
+
/* @__PURE__ */ jsx(StatusChip, { row }),
|
|
2687
|
+
row?.hasCredential && /* @__PURE__ */ jsxs("span", { className: "inline-flex items-center gap-1 text-[10px] text-muted-foreground", children: [
|
|
2688
|
+
/* @__PURE__ */ jsx(ShieldCheck, { className: "h-3 w-3" }),
|
|
2689
|
+
" chave guardada"
|
|
2690
|
+
] }),
|
|
2691
|
+
caps.ban_risk === "high" && /* @__PURE__ */ jsxs("span", { className: "inline-flex items-center gap-1 text-[10px] text-warning", children: [
|
|
2692
|
+
/* @__PURE__ */ jsx(ShieldAlert, { className: "h-3 w-3" }),
|
|
2693
|
+
" risco de banimento"
|
|
2694
|
+
] })
|
|
2695
|
+
] }),
|
|
2696
|
+
/* @__PURE__ */ jsx("p", { className: "mt-1 text-xs text-muted-foreground", children: copy.what }),
|
|
2697
|
+
/* @__PURE__ */ jsxs("div", { className: "mt-2 grid gap-1 sm:grid-cols-2", children: [
|
|
2698
|
+
/* @__PURE__ */ jsxs("p", { className: "text-[11px] text-success-soft-foreground", children: [
|
|
2699
|
+
"\u2713 ",
|
|
2700
|
+
copy.strength
|
|
2701
|
+
] }),
|
|
2702
|
+
/* @__PURE__ */ jsxs("p", { className: "text-[11px] text-warning", children: [
|
|
2703
|
+
"! ",
|
|
2704
|
+
copy.cost
|
|
2705
|
+
] })
|
|
2706
|
+
] }),
|
|
2707
|
+
/* @__PURE__ */ jsx("div", { className: "mt-3 grid gap-2 sm:grid-cols-2", children: copy.fields.map((f) => /* @__PURE__ */ jsxs("label", { className: "block", children: [
|
|
2708
|
+
/* @__PURE__ */ jsx("span", { className: "text-[11px] text-muted-foreground", children: f.label }),
|
|
2709
|
+
/* @__PURE__ */ jsx(
|
|
2710
|
+
Input,
|
|
2711
|
+
{
|
|
2712
|
+
className: "mt-0.5 h-8 text-sm",
|
|
2713
|
+
type: f.secret ? "password" : "text",
|
|
2714
|
+
autoComplete: "off",
|
|
2715
|
+
placeholder: f.secret && row?.hasCredential ? "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022 (guardada)" : f.placeholder,
|
|
2716
|
+
value: field(p, f.key),
|
|
2717
|
+
onChange: (e) => setField(p, f.key, e.target.value)
|
|
2718
|
+
}
|
|
2719
|
+
)
|
|
2720
|
+
] }, f.key)) }),
|
|
2721
|
+
/* @__PURE__ */ jsxs("div", { className: "mt-3 flex flex-wrap gap-2", children: [
|
|
2722
|
+
/* @__PURE__ */ jsx(Button, { size: "sm", variant: "outline", disabled: busy === `save:${p}`, onClick: () => save(p), children: busy === `save:${p}` ? /* @__PURE__ */ jsx(Loader2, { className: "h-3 w-3 animate-spin" }) : "Guardar" }),
|
|
2723
|
+
/* @__PURE__ */ jsx(Button, { size: "sm", variant: "outline", disabled: !row?.hasCredential || busy === `test:${p}`, onClick: () => test(p), children: busy === `test:${p}` ? /* @__PURE__ */ jsx(Loader2, { className: "h-3 w-3 animate-spin" }) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
2724
|
+
/* @__PURE__ */ jsx(RefreshCw, { className: "mr-1 h-3 w-3" }),
|
|
2725
|
+
" Testar"
|
|
2726
|
+
] }) }),
|
|
2727
|
+
/* @__PURE__ */ jsx(Button, { size: "sm", variant: "outline", disabled: !row?.hasCredential || busy === `numbers:${p}`, onClick: () => refreshNumbers(p), children: "N\xFAmeros" }),
|
|
2728
|
+
caps.qr_pairing && /* @__PURE__ */ jsxs(Button, { size: "sm", variant: "outline", disabled: !row?.hasCredential || busy === `qr:${p}`, onClick: () => pair(p), children: [
|
|
2729
|
+
/* @__PURE__ */ jsx(QrCode, { className: "mr-1 h-3 w-3" }),
|
|
2730
|
+
" Parear"
|
|
2731
|
+
] }),
|
|
2732
|
+
!active && /* @__PURE__ */ jsxs(Button, { size: "sm", disabled: !row?.hasCredential || busy === `on:${p}`, onClick: () => activate(p), children: [
|
|
2733
|
+
/* @__PURE__ */ jsx(Zap, { className: "mr-1 h-3 w-3" }),
|
|
2734
|
+
" Usar este"
|
|
2735
|
+
] })
|
|
2736
|
+
] }),
|
|
2737
|
+
numbers[p]?.length ? /* @__PURE__ */ jsx("div", { className: "mt-3 border-t pt-2", children: numbers[p].map((n) => /* @__PURE__ */ jsx(NumberRow, { n }, n.id)) }) : null,
|
|
2738
|
+
qr?.provider === p && /* @__PURE__ */ jsxs("div", { className: "mt-3 border-t pt-3", children: [
|
|
2739
|
+
/* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground mb-2", children: "Abra o WhatsApp no celular \u2192 Aparelhos conectados \u2192 Conectar aparelho." }),
|
|
2740
|
+
qr.image && /* @__PURE__ */ jsx("img", { src: qr.image, alt: "QR", className: "h-48 w-48 rounded border bg-white p-2" }),
|
|
2741
|
+
qr.code && /* @__PURE__ */ jsx("p", { className: "mt-2 font-mono text-lg tracking-widest", children: qr.code })
|
|
2742
|
+
] })
|
|
2743
|
+
]
|
|
2744
|
+
},
|
|
2745
|
+
p
|
|
2746
|
+
);
|
|
2747
|
+
})
|
|
2748
|
+
]
|
|
2749
|
+
}
|
|
2750
|
+
);
|
|
2751
|
+
}
|
|
1481
2752
|
function mapChannel(r) {
|
|
1482
2753
|
return {
|
|
1483
2754
|
id: String(r.id),
|
|
@@ -1564,9 +2835,9 @@ function ChannelRow({ account }) {
|
|
|
1564
2835
|
}
|
|
1565
2836
|
function ConversationsGeneralSettings() {
|
|
1566
2837
|
const t = useTranslation();
|
|
1567
|
-
const [channels, setChannels] =
|
|
1568
|
-
const [failed, setFailed] =
|
|
1569
|
-
|
|
2838
|
+
const [channels, setChannels] = React9.useState(null);
|
|
2839
|
+
const [failed, setFailed] = React9.useState(false);
|
|
2840
|
+
React9.useEffect(() => {
|
|
1570
2841
|
let cancelled = false;
|
|
1571
2842
|
listMessagingChannels().then((rows) => {
|
|
1572
2843
|
if (!cancelled) setChannels(rows);
|
|
@@ -1580,19 +2851,22 @@ function ConversationsGeneralSettings() {
|
|
|
1580
2851
|
cancelled = true;
|
|
1581
2852
|
};
|
|
1582
2853
|
}, []);
|
|
1583
|
-
return /* @__PURE__ */
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
2854
|
+
return /* @__PURE__ */ jsxs("div", { className: "space-y-6", children: [
|
|
2855
|
+
/* @__PURE__ */ jsx(WhatsAppProviders, {}),
|
|
2856
|
+
/* @__PURE__ */ jsxs(
|
|
2857
|
+
SettingsGroup,
|
|
2858
|
+
{
|
|
2859
|
+
title: t("conversations.settings.channels"),
|
|
2860
|
+
description: t("conversations.settings.channelsHelp"),
|
|
2861
|
+
children: [
|
|
2862
|
+
channels === null && /* @__PURE__ */ jsx("p", { className: "py-2 text-sm text-muted-foreground", children: t("conversations.list.loading") }),
|
|
2863
|
+
channels !== null && failed && /* @__PURE__ */ jsx("p", { className: "py-2 text-sm text-muted-foreground", children: t("conversations.settings.channelsUnavailable") }),
|
|
2864
|
+
channels !== null && !failed && channels.length === 0 && /* @__PURE__ */ jsx("p", { className: "py-2 text-sm text-muted-foreground", children: t("conversations.settings.channelsEmpty") }),
|
|
2865
|
+
channels?.map((account) => /* @__PURE__ */ jsx(ChannelRow, { account }, account.id))
|
|
2866
|
+
]
|
|
2867
|
+
}
|
|
2868
|
+
)
|
|
2869
|
+
] });
|
|
1596
2870
|
}
|
|
1597
2871
|
ConversationsGeneralSettings.displayName = "ConversationsGeneralSettings";
|
|
1598
2872
|
function ConversationsSettingsTab() {
|
|
@@ -1662,7 +2936,7 @@ function buildConversationsOnboarding() {
|
|
|
1662
2936
|
};
|
|
1663
2937
|
}
|
|
1664
2938
|
var TYXTER_NUMBER_CLAIM_FUNCTION = "tyxter-number-claim";
|
|
1665
|
-
function
|
|
2939
|
+
function client3() {
|
|
1666
2940
|
const supabase = getSupabaseClientOptional();
|
|
1667
2941
|
if (!supabase) throw new Error("Sem conex\xE3o com o banco para falar com a Tyxter.");
|
|
1668
2942
|
return supabase;
|
|
@@ -1674,7 +2948,7 @@ async function invoke(body) {
|
|
|
1674
2948
|
} catch {
|
|
1675
2949
|
headers = void 0;
|
|
1676
2950
|
}
|
|
1677
|
-
const { data, error } = await
|
|
2951
|
+
const { data, error } = await client3().functions.invoke(TYXTER_NUMBER_CLAIM_FUNCTION, {
|
|
1678
2952
|
body,
|
|
1679
2953
|
...headers ? { headers } : {}
|
|
1680
2954
|
});
|
|
@@ -1714,10 +2988,10 @@ var PAYMENT_PREFLIGHT_FUNCTION = "tyxter-payment-preflight";
|
|
|
1714
2988
|
async function invoke2(body, tenantId) {
|
|
1715
2989
|
const supabase = getSupabaseClientOptional();
|
|
1716
2990
|
if (!supabase) throw new Error("Sem conex\xE3o com o banco para verificar os pagamentos.");
|
|
1717
|
-
const
|
|
1718
|
-
if (!
|
|
2991
|
+
const tenant2 = tenantId ?? getActiveTenantId();
|
|
2992
|
+
if (!tenant2) throw new Error("Sem neg\xF3cio selecionado.");
|
|
1719
2993
|
const { data, error } = await supabase.functions.invoke(PAYMENT_PREFLIGHT_FUNCTION, {
|
|
1720
|
-
body: { ...body, tenantId:
|
|
2994
|
+
body: { ...body, tenantId: tenant2 }
|
|
1721
2995
|
});
|
|
1722
2996
|
if (!error) return data;
|
|
1723
2997
|
let message = "";
|
|
@@ -2685,9 +3959,681 @@ CREATE INDEX IF NOT EXISTS idx_plg_conversations_waiting
|
|
|
2685
3959
|
ON public.plg_conversations (tenant_id, last_message_at DESC)
|
|
2686
3960
|
WHERE status = 'open' AND last_message_direction = 'inbound';
|
|
2687
3961
|
`;
|
|
3962
|
+
var MIGRATION_002_A_CASA_ESCOLHE_POR_ONDE_O_WHATSAPP_SAI = `-- ---------------------------------------------------------------------------
|
|
3963
|
+
-- 002_a_casa_escolhe_por_onde_o_whatsapp_sai.sql \u2014 dois provedores de WhatsApp,
|
|
3964
|
+
-- a credencial de cada um, e a escolha de qual est\xE1 valendo.
|
|
3965
|
+
--
|
|
3966
|
+
-- \u2500\u2500 por que a credencial precisa morar aqui \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
3967
|
+
--
|
|
3968
|
+
-- O \`messaging-send\` (WP3) pega a chave assim:
|
|
3969
|
+
--
|
|
3970
|
+
-- const credential = await redeemConnectorCredential({ connectorSlug: 'tyxter', \u2026 })
|
|
3971
|
+
--
|
|
3972
|
+
-- Quinze linhas que chamam a PLATAFORMA Fayz. Funciona para o Tyxter porque a
|
|
3973
|
+
-- chave \xE9 uma s\xF3, da FayaLabs, e a plataforma a serve para qualquer inquilino
|
|
3974
|
+
-- do projeto. N\xE3o funciona para o Evolution, e n\xE3o vai funcionar nunca: o
|
|
3975
|
+
-- Evolution \xE9 auto-hospedado, e cada casa tem a pr\xF3pria URL e a pr\xF3pria chave.
|
|
3976
|
+
-- Credencial POR INQUILINO n\xE3o \xE9 atalho, \xE9 requisito do segundo provedor.
|
|
3977
|
+
--
|
|
3978
|
+
-- Um app que n\xE3o est\xE1 ligado a projeto Fayz nenhum \u2014 como o FullControl hoje \u2014
|
|
3979
|
+
-- tamb\xE9m n\xE3o resgata nada. Aqui a chave tem onde ficar nos dois casos.
|
|
3980
|
+
--
|
|
3981
|
+
-- \u2500\u2500 os dois provedores n\xE3o s\xE3o a mesma coisa, e a tabela diz isso \u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
3982
|
+
--
|
|
3983
|
+
-- \`tyxter\` API oficial (Cloud API, via revenda). Iniciar conversa EXIGE
|
|
3984
|
+
-- template aprovado pela Meta; texto livre s\xF3 dentro de 24h da
|
|
3985
|
+
-- \xFAltima mensagem do cliente. Em troca: entrega confi\xE1vel, sem
|
|
3986
|
+
-- risco de banimento, e a Meta responde por n\xFAmero e qualidade.
|
|
3987
|
+
-- \`evolution\` WhatsApp Web, auto-hospedado. N\xE3o tem template nem janela de
|
|
3988
|
+
-- 24h \u2014 manda o que quiser. Em troca: a sess\xE3o morre, o n\xFAmero
|
|
3989
|
+
-- pode ser banido, e ningu\xE9m responde por isso al\xE9m de voc\xEA.
|
|
3990
|
+
--
|
|
3991
|
+
-- \`capabilities\` guarda essa diferen\xE7a como DADO e n\xE3o como \`if\` espalhado pela
|
|
3992
|
+
-- tela. Um bot\xE3o de "submeter template" num provedor que n\xE3o tem template n\xE3o
|
|
3993
|
+
-- pode existir; a tela l\xEA daqui para desabilitar em vez de fingir.
|
|
3994
|
+
--
|
|
3995
|
+
-- \u2500\u2500 o segredo n\xE3o passa pelo navegador \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
3996
|
+
--
|
|
3997
|
+
-- A tabela nega SELECT a \`authenticated\`. Quem l\xEA \xE9 o \`service_role\`, do lado
|
|
3998
|
+
-- do servidor. A tela sabe se EXISTE credencial e quais s\xE3o os campos n\xE3o
|
|
3999
|
+
-- secretos (a URL do Evolution, o nome da inst\xE2ncia) por uma view; o segredo
|
|
4000
|
+
-- ela nunca v\xEA, nem para reexibir mascarado.
|
|
4001
|
+
-- ---------------------------------------------------------------------------
|
|
4002
|
+
|
|
4003
|
+
-- \u2500\u2500 quem est\xE1 valendo, por inquilino \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
4004
|
+
CREATE TABLE IF NOT EXISTS public.plg_conversations_providers (
|
|
4005
|
+
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
4006
|
+
provider text NOT NULL,
|
|
4007
|
+
-- N\xE3o secreto: a URL base do Evolution, o nome da inst\xE2ncia, o id do n\xFAmero.
|
|
4008
|
+
-- \xC9 isto que a tela mostra e deixa editar.
|
|
4009
|
+
config jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
4010
|
+
-- Secreto. Nenhuma pol\xEDtica de leitura alcan\xE7a esta coluna.
|
|
4011
|
+
secrets jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
4012
|
+
-- O que este provedor SABE fazer. Lido pela tela para desabilitar o que n\xE3o
|
|
4013
|
+
-- existe, em vez de oferecer um bot\xE3o que erra.
|
|
4014
|
+
capabilities jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
4015
|
+
-- Um s\xF3 por inquilino fica ativo. O outro continua configurado, pronto para
|
|
4016
|
+
-- a troca \u2014 que \xE9 o pedido: poder alternar sem reconfigurar.
|
|
4017
|
+
is_active boolean NOT NULL DEFAULT false,
|
|
4018
|
+
status text NOT NULL DEFAULT 'unconfigured',
|
|
4019
|
+
status_detail text,
|
|
4020
|
+
checked_at timestamptz,
|
|
4021
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
4022
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
4023
|
+
PRIMARY KEY (tenant_id, provider),
|
|
4024
|
+
CONSTRAINT plg_conversations_providers_provider
|
|
4025
|
+
CHECK (provider IN ('tyxter', 'evolution')),
|
|
4026
|
+
CONSTRAINT plg_conversations_providers_status
|
|
4027
|
+
CHECK (status IN ('unconfigured', 'connecting', 'connected', 'error', 'disconnected'))
|
|
4028
|
+
);
|
|
4029
|
+
|
|
4030
|
+
-- UM ativo por inquilino, garantido pelo banco e n\xE3o pela tela. Duas telas
|
|
4031
|
+
-- abertas em abas diferentes s\xE3o o caso comum, e "o \xFAltimo clique vence" s\xF3 \xE9
|
|
4032
|
+
-- verdade se algu\xE9m impedir o empate.
|
|
4033
|
+
CREATE UNIQUE INDEX IF NOT EXISTS plg_conversations_providers_one_active
|
|
4034
|
+
ON public.plg_conversations_providers (tenant_id)
|
|
4035
|
+
WHERE is_active;
|
|
4036
|
+
|
|
4037
|
+
ALTER TABLE public.plg_conversations_providers ENABLE ROW LEVEL SECURITY;
|
|
4038
|
+
ALTER TABLE public.plg_conversations_providers FORCE ROW LEVEL SECURITY;
|
|
4039
|
+
|
|
4040
|
+
-- Nenhuma pol\xEDtica para \`authenticated\`: o segredo mora numa coluna desta
|
|
4041
|
+
-- tabela, e uma pol\xEDtica de SELECT aqui entregaria a chave ao navegador.
|
|
4042
|
+
--
|
|
4043
|
+
-- E o REVOKE expl\xEDcito, porque a aus\xEAncia de pol\xEDtica N\xC3O \xE9 a \xFAnica linha de
|
|
4044
|
+
-- defesa. O schema \`public\` deste cluster carrega grants amplos de f\xE1brica \u2014
|
|
4045
|
+
-- medido nesta pr\xF3pria tabela logo ap\xF3s cri\xE1-la: sete privil\xE9gios j\xE1
|
|
4046
|
+
-- concedidos a \`authenticated\` sem ningu\xE9m ter pedido. A RLS for\xE7ada barra
|
|
4047
|
+
-- assim mesmo, mas uma pol\xEDtica escrita errada num dia ruim \xE9 tudo o que
|
|
4048
|
+
-- separa a chave do navegador. Duas fechaduras.
|
|
4049
|
+
REVOKE ALL ON public.plg_conversations_providers FROM PUBLIC, anon, authenticated;
|
|
4050
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_conversations_providers TO service_role;
|
|
4051
|
+
|
|
4052
|
+
-- \u2500\u2500 o que a tela pode ver \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
4053
|
+
--
|
|
4054
|
+
-- Tudo menos o segredo. \`has_credential\` responde a \xFAnica pergunta que a tela
|
|
4055
|
+
-- precisa fazer sobre ele: j\xE1 foi posto?
|
|
4056
|
+
-- \u2500\u2500 A \xDANICA VIEW DESTE REPO QUE N\xC3O \xC9 \`security_invoker\` \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
4057
|
+
--
|
|
4058
|
+
-- A regra da casa \xE9 \`security_invoker = true\`: a view enxerga o que o leitor
|
|
4059
|
+
-- enxerga, e a RLS da tabela continua valendo. Aqui isso se autoderrota.
|
|
4060
|
+
--
|
|
4061
|
+
-- \`authenticated\` N\xC3O pode ler \`plg_conversations_providers\` \u2014 \xE9 onde o segredo
|
|
4062
|
+
-- mora, e o REVOKE logo acima \xE9 deliberado. Uma view invoker sobre uma tabela
|
|
4063
|
+
-- que o chamador n\xE3o alcan\xE7a devolve \`42501 permission denied\`, que foi
|
|
4064
|
+
-- exatamente o que a tela recebeu no primeiro teste com um JWT de verdade.
|
|
4065
|
+
--
|
|
4066
|
+
-- Ent\xE3o esta view \xE9 definer POR PROJETO, e paga o pre\xE7o sendo ela mesma a
|
|
4067
|
+
-- fronteira: o \`WHERE tenant_id IN (user_tenant_ids())\` abaixo n\xE3o \xE9
|
|
4068
|
+
-- decora\xE7\xE3o, \xE9 a RLS desta view. E a coluna do segredo n\xE3o est\xE1 na lista do
|
|
4069
|
+
-- SELECT \u2014 o que ela n\xE3o seleciona n\xE3o existe para quem l\xEA.
|
|
4070
|
+
DROP VIEW IF EXISTS public.v_conversations_providers;
|
|
4071
|
+
CREATE VIEW public.v_conversations_providers AS
|
|
4072
|
+
SELECT p.tenant_id,
|
|
4073
|
+
p.provider,
|
|
4074
|
+
p.config,
|
|
4075
|
+
p.capabilities,
|
|
4076
|
+
p.is_active,
|
|
4077
|
+
p.status,
|
|
4078
|
+
p.status_detail,
|
|
4079
|
+
p.checked_at,
|
|
4080
|
+
-- Nunca o valor. S\xF3 se existe.
|
|
4081
|
+
(p.secrets ? 'api_key') AS has_credential,
|
|
4082
|
+
p.created_at,
|
|
4083
|
+
p.updated_at
|
|
4084
|
+
FROM public.plg_conversations_providers p
|
|
4085
|
+
WHERE p.tenant_id IN (SELECT public.user_tenant_ids());
|
|
4086
|
+
|
|
4087
|
+
GRANT SELECT ON public.v_conversations_providers TO authenticated;
|
|
4088
|
+
|
|
4089
|
+
COMMENT ON VIEW public.v_conversations_providers IS
|
|
4090
|
+
'Os provedores de WhatsApp da casa, SEM o segredo. \`has_credential\` diz se a chave foi posta; o valor n\xE3o sai daqui (002).';
|
|
4091
|
+
|
|
4092
|
+
-- \u2500\u2500 p\xF4r a credencial \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
4093
|
+
--
|
|
4094
|
+
-- SECURITY DEFINER porque a tabela nega escrita a \`authenticated\`: \xE9 a \xFAnica
|
|
4095
|
+
-- porta, e ela confere o inquilino antes de abrir. \`search_path\` vazio porque
|
|
4096
|
+
-- uma fun\xE7\xE3o definer sem isso \xE9 uma fun\xE7\xE3o que o chamador pode redirecionar.
|
|
4097
|
+
CREATE OR REPLACE FUNCTION public.conversations_set_provider(
|
|
4098
|
+
p_tenant uuid,
|
|
4099
|
+
p_provider text,
|
|
4100
|
+
p_config jsonb DEFAULT '{}'::jsonb,
|
|
4101
|
+
p_api_key text DEFAULT NULL,
|
|
4102
|
+
p_capabilities jsonb DEFAULT NULL
|
|
4103
|
+
) RETURNS void
|
|
4104
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
|
|
4105
|
+
AS $$
|
|
4106
|
+
DECLARE v_caps jsonb;
|
|
4107
|
+
BEGIN
|
|
4108
|
+
IF p_tenant IS NULL OR NOT (p_tenant IN (SELECT public.user_tenant_ids())) THEN
|
|
4109
|
+
RAISE EXCEPTION 'conversations_set_provider: n\xE3o \xE9 uma casa sua';
|
|
4110
|
+
END IF;
|
|
4111
|
+
|
|
4112
|
+
-- O que cada provedor sabe fazer \xE9 conhecimento do produto, n\xE3o do usu\xE1rio.
|
|
4113
|
+
-- Quem chama pode sobrescrever (um Evolution atr\xE1s de proxy pode ganhar
|
|
4114
|
+
-- capacidades), mas o padr\xE3o descreve a verdade de cada um.
|
|
4115
|
+
v_caps := coalesce(p_capabilities, CASE p_provider
|
|
4116
|
+
WHEN 'tyxter' THEN jsonb_build_object(
|
|
4117
|
+
'templates', true, -- e s\xE3o OBRIGAT\xD3RIOS para iniciar conversa
|
|
4118
|
+
'requires_template', true,
|
|
4119
|
+
'session_window_hours', 24, -- texto livre s\xF3 dentro dela
|
|
4120
|
+
'delivery_receipts', true,
|
|
4121
|
+
'qr_pairing', false,
|
|
4122
|
+
'ban_risk', 'low')
|
|
4123
|
+
WHEN 'evolution' THEN jsonb_build_object(
|
|
4124
|
+
'templates', false, -- n\xE3o existem: manda texto direto
|
|
4125
|
+
'requires_template', false,
|
|
4126
|
+
'session_window_hours', null,
|
|
4127
|
+
'delivery_receipts', true,
|
|
4128
|
+
'qr_pairing', true, -- parear \xE9 ler um QR, n\xE3o cadastrar na Meta
|
|
4129
|
+
'ban_risk', 'high') -- e \xE9 por isso que o aquecimento importa
|
|
4130
|
+
ELSE '{}'::jsonb END);
|
|
4131
|
+
|
|
4132
|
+
INSERT INTO public.plg_conversations_providers AS t
|
|
4133
|
+
(tenant_id, provider, config, capabilities, status,
|
|
4134
|
+
secrets)
|
|
4135
|
+
VALUES
|
|
4136
|
+
(p_tenant, p_provider, coalesce(p_config, '{}'::jsonb), v_caps,
|
|
4137
|
+
CASE WHEN p_api_key IS NULL THEN 'unconfigured' ELSE 'connecting' END,
|
|
4138
|
+
CASE WHEN p_api_key IS NULL THEN '{}'::jsonb
|
|
4139
|
+
ELSE jsonb_build_object('api_key', p_api_key) END)
|
|
4140
|
+
ON CONFLICT (tenant_id, provider) DO UPDATE
|
|
4141
|
+
SET config = coalesce(p_config, t.config),
|
|
4142
|
+
capabilities = v_caps,
|
|
4143
|
+
-- Chave nula \xE9 "n\xE3o mexe", n\xE3o "apaga". Salvar a URL do Evolution sem
|
|
4144
|
+
-- redigitar a chave \xE9 o gesto comum, e apag\xE1-la aqui seria a surpresa.
|
|
4145
|
+
secrets = CASE WHEN p_api_key IS NULL THEN t.secrets
|
|
4146
|
+
ELSE jsonb_set(t.secrets, '{api_key}', to_jsonb(p_api_key)) END,
|
|
4147
|
+
status = CASE WHEN p_api_key IS NULL THEN t.status ELSE 'connecting' END,
|
|
4148
|
+
updated_at = now();
|
|
4149
|
+
END $$;
|
|
4150
|
+
|
|
4151
|
+
REVOKE ALL ON FUNCTION public.conversations_set_provider(uuid, text, jsonb, text, jsonb) FROM PUBLIC, anon;
|
|
4152
|
+
GRANT EXECUTE ON FUNCTION public.conversations_set_provider(uuid, text, jsonb, text, jsonb) TO authenticated, service_role;
|
|
4153
|
+
|
|
4154
|
+
-- \u2500\u2500 trocar qual est\xE1 valendo \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
4155
|
+
CREATE OR REPLACE FUNCTION public.conversations_activate_provider(
|
|
4156
|
+
p_tenant uuid, p_provider text
|
|
4157
|
+
) RETURNS void
|
|
4158
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
|
|
4159
|
+
AS $$
|
|
4160
|
+
BEGIN
|
|
4161
|
+
IF p_tenant IS NULL OR NOT (p_tenant IN (SELECT public.user_tenant_ids())) THEN
|
|
4162
|
+
RAISE EXCEPTION 'conversations_activate_provider: n\xE3o \xE9 uma casa sua';
|
|
4163
|
+
END IF;
|
|
4164
|
+
|
|
4165
|
+
-- Desligar ANTES de ligar: o \xEDndice \xFAnico parcial recusaria os dois ativos,
|
|
4166
|
+
-- e numa transa\xE7\xE3o s\xF3 a ordem \xE9 o que decide entre trocar e falhar.
|
|
4167
|
+
UPDATE public.plg_conversations_providers
|
|
4168
|
+
SET is_active = false, updated_at = now()
|
|
4169
|
+
WHERE tenant_id = p_tenant AND is_active;
|
|
4170
|
+
|
|
4171
|
+
UPDATE public.plg_conversations_providers
|
|
4172
|
+
SET is_active = true, updated_at = now()
|
|
4173
|
+
WHERE tenant_id = p_tenant AND provider = p_provider;
|
|
4174
|
+
|
|
4175
|
+
IF NOT FOUND THEN
|
|
4176
|
+
RAISE EXCEPTION 'conversations_activate_provider: % n\xE3o est\xE1 configurado nesta casa', p_provider;
|
|
4177
|
+
END IF;
|
|
4178
|
+
END $$;
|
|
4179
|
+
|
|
4180
|
+
REVOKE ALL ON FUNCTION public.conversations_activate_provider(uuid, text) FROM PUBLIC, anon;
|
|
4181
|
+
GRANT EXECUTE ON FUNCTION public.conversations_activate_provider(uuid, text) TO authenticated, service_role;
|
|
4182
|
+
|
|
4183
|
+
-- \u2500\u2500 o canal aprende de que provedor ele \xE9 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
4184
|
+
--
|
|
4185
|
+
-- \`plg_conversations_channels.provider\` j\xE1 existe e j\xE1 guarda 'tyxter'. O que
|
|
4186
|
+
-- falta \xE9 a liga\xE7\xE3o com a credencial: um n\xFAmero do Evolution precisa saber de
|
|
4187
|
+
-- QUAL inst\xE2ncia ele saiu, porque a mesma casa pode ter mais de uma.
|
|
4188
|
+
ALTER TABLE public.plg_conversations_channels
|
|
4189
|
+
ADD COLUMN IF NOT EXISTS instance_key text;
|
|
4190
|
+
|
|
4191
|
+
COMMENT ON COLUMN public.plg_conversations_channels.instance_key IS
|
|
4192
|
+
'A inst\xE2ncia do provedor de onde este n\xFAmero sai. Nulo no Tyxter (uma conta por projeto); no Evolution \xE9 o nome da inst\xE2ncia (002).';
|
|
4193
|
+
`;
|
|
4194
|
+
var MIGRATION_003_A_CAMPANHA_SAI_NO_RITMO_QUE_O_NUMERO_AGUENTA = `-- ---------------------------------------------------------------------------
|
|
4195
|
+
-- 003_a_campanha_sai_no_ritmo_que_o_numero_aguenta.sql \u2014 template, campanha,
|
|
4196
|
+
-- log de envio e o ritmo que impede o n\xFAmero de ser banido.
|
|
4197
|
+
--
|
|
4198
|
+
-- O desenho n\xE3o \xE9 inven\xE7\xE3o: saiu de ler Mautic, EspoCRM, Odoo e Twenty, e de
|
|
4199
|
+
-- ler a documenta\xE7\xE3o da Meta em vez do folclore que circula sobre ela. Onde os
|
|
4200
|
+
-- quatro discordam, este arquivo diz qual escolheu e por qu\xEA.
|
|
4201
|
+
--
|
|
4202
|
+
-- \u2500\u2500 tr\xEAs coisas que a Meta documenta e que viram REGRA, n\xE3o ajuste \u2500\u2500\u2500\u2500\u2500\u2500
|
|
4203
|
+
--
|
|
4204
|
+
-- \xB7 131050 (o usu\xE1rio saiu do marketing) NUNCA se repete. Reenviar \xE9 outra
|
|
4205
|
+
-- infra\xE7\xE3o, n\xE3o outra tentativa.
|
|
4206
|
+
-- \xB7 131048 (bloqueado/marcado como spam) \xE9 parada dura. \xC9 o alarme de
|
|
4207
|
+
-- qualidade, n\xE3o um erro de rede.
|
|
4208
|
+
-- \xB7 A janela de 24h s\xF3 abre por mensagem OU chamada DO CLIENTE. Template
|
|
4209
|
+
-- nosso n\xE3o abre janela nenhuma \u2014 e \xE9 o erro que mais se v\xEA em CRM.
|
|
4210
|
+
--
|
|
4211
|
+
-- O resto \u2014 a rampa de aquecimento, a faixa de atraso entre mensagens \u2014 \xE9
|
|
4212
|
+
-- pr\xE1tica de comunidade, n\xE3o documenta\xE7\xE3o. Vira PADR\xC3O EDIT\xC1VEL: quem opera o
|
|
4213
|
+
-- n\xFAmero sabe mais do que uma tabela escrita hoje.
|
|
4214
|
+
--
|
|
4215
|
+
-- \u2500\u2500 por que o log de envio tem \`rotation\` e \`claim_token\` \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
4216
|
+
--
|
|
4217
|
+
-- \`rotation\` \xE9 do Mautic: a chave \xFAnica \xE9 (campanha, contato, rota\xE7\xE3o), e a
|
|
4218
|
+
-- rota\xE7\xE3o sobe quando algu\xE9m REINICIA a campanha de prop\xF3sito. Sem ela, a \xFAnica
|
|
4219
|
+
-- forma de reenviar \xE9 apagar o hist\xF3rico \u2014 e a\xED ningu\xE9m consegue provar o que
|
|
4220
|
+
-- foi mandado.
|
|
4221
|
+
--
|
|
4222
|
+
-- \`claim_token\` + \`claim_expires_at\` \xE9 do Twenty: quem vai enviar ARRENDA a
|
|
4223
|
+
-- linha por cinco minutos. Duas inst\xE2ncias do disparador n\xE3o mandam a mesma
|
|
4224
|
+
-- mensagem duas vezes, e um processo que morre no meio devolve a linha sozinho
|
|
4225
|
+
-- quando o arrendamento vence. Os quatro CRMs resolvem isso com \xEDndice \xFAnico
|
|
4226
|
+
-- mais arrendamento; nenhum usa advisory lock, e nenhum usa outbox.
|
|
4227
|
+
-- ---------------------------------------------------------------------------
|
|
4228
|
+
|
|
4229
|
+
-- \u2500\u2500 quem nunca mais deve ser contatado \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
4230
|
+
--
|
|
4231
|
+
-- A tabela j\xE1 existe e j\xE1 \xE9 chaveada pelo N\xDAMERO e n\xE3o pelo contato \u2014 que \xE9 o
|
|
4232
|
+
-- acerto do Odoo e do Twenty, e o erro do Mautic: n\xFAmero \xE9 reatribu\xEDdo, contato
|
|
4233
|
+
-- \xE9 duplicado, e a supress\xE3o precisa sobreviver aos dois.
|
|
4234
|
+
--
|
|
4235
|
+
-- O que falta \xE9 n\xE3o apagar nunca. O EspoCRM apaga a linha ao reinscrever e
|
|
4236
|
+
-- perde a prova de que algu\xE9m um dia pediu para sair; o Odoo desliga um
|
|
4237
|
+
-- booleano. Seguimos o Odoo.
|
|
4238
|
+
ALTER TABLE public.plg_conversations_optouts
|
|
4239
|
+
ADD COLUMN IF NOT EXISTS active boolean NOT NULL DEFAULT true,
|
|
4240
|
+
ADD COLUMN IF NOT EXISTS revoked_at timestamptz,
|
|
4241
|
+
ADD COLUMN IF NOT EXISTS source text;
|
|
4242
|
+
|
|
4243
|
+
COMMENT ON COLUMN public.plg_conversations_optouts.active IS
|
|
4244
|
+
'Desligar em vez de apagar: a linha \xE9 a prova de que a pessoa pediu para sair, e apag\xE1-la \xE9 perder a prova (003).';
|
|
4245
|
+
|
|
4246
|
+
-- \u2500\u2500 o texto que se manda \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
4247
|
+
--
|
|
4248
|
+
-- Um template aqui N\xC3O \xE9 um template da Meta. \xC9 o texto da casa, com o espelho
|
|
4249
|
+
-- do que o provedor respondeu sobre ele. Assim o mesmo registro serve aos dois:
|
|
4250
|
+
-- no Tyxter ele carrega o nome e o status da aprova\xE7\xE3o; no Evolution, que n\xE3o
|
|
4251
|
+
-- tem o conceito, ele \xE9 s\xF3 o texto \u2014 e a campanha n\xE3o precisa saber a diferen\xE7a.
|
|
4252
|
+
CREATE TABLE IF NOT EXISTS public.plg_conversations_templates (
|
|
4253
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
4254
|
+
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
4255
|
+
name text NOT NULL,
|
|
4256
|
+
language text NOT NULL DEFAULT 'pt_BR',
|
|
4257
|
+
category text NOT NULL DEFAULT 'MARKETING',
|
|
4258
|
+
body text NOT NULL,
|
|
4259
|
+
-- Os nomes das vari\xE1veis, na ordem em que {{1}}, {{2}} aparecem. A Meta s\xF3
|
|
4260
|
+
-- entende posi\xE7\xE3o; gente entende nome. Guardar os dois \xE9 o que deixa a tela
|
|
4261
|
+
-- pedir "cargo" em vez de "vari\xE1vel 3".
|
|
4262
|
+
variables text[] NOT NULL DEFAULT '{}',
|
|
4263
|
+
provider text,
|
|
4264
|
+
provider_template_id text,
|
|
4265
|
+
-- \`draft\` enquanto \xE9 nosso; depois \xE9 o que o provedor respondeu, verbatim.
|
|
4266
|
+
status text NOT NULL DEFAULT 'draft',
|
|
4267
|
+
status_detail text,
|
|
4268
|
+
submitted_at timestamptz,
|
|
4269
|
+
approved_at timestamptz,
|
|
4270
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
4271
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
4272
|
+
);
|
|
4273
|
+
|
|
4274
|
+
CREATE UNIQUE INDEX IF NOT EXISTS plg_conversations_templates_name_uq
|
|
4275
|
+
ON public.plg_conversations_templates (tenant_id, name, language);
|
|
4276
|
+
|
|
4277
|
+
ALTER TABLE public.plg_conversations_templates ENABLE ROW LEVEL SECURITY;
|
|
4278
|
+
ALTER TABLE public.plg_conversations_templates FORCE ROW LEVEL SECURITY;
|
|
4279
|
+
DROP POLICY IF EXISTS plg_conversations_templates_rw ON public.plg_conversations_templates;
|
|
4280
|
+
CREATE POLICY plg_conversations_templates_rw ON public.plg_conversations_templates
|
|
4281
|
+
FOR ALL TO authenticated
|
|
4282
|
+
USING (tenant_id IN (SELECT public.user_tenant_ids()))
|
|
4283
|
+
WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
4284
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_conversations_templates TO authenticated, service_role;
|
|
4285
|
+
|
|
4286
|
+
-- \u2500\u2500 a campanha \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
4287
|
+
CREATE TABLE IF NOT EXISTS public.plg_conversations_campaigns (
|
|
4288
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
4289
|
+
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
4290
|
+
name text NOT NULL,
|
|
4291
|
+
template_id uuid REFERENCES public.plg_conversations_templates(id) ON DELETE SET NULL,
|
|
4292
|
+
channel_id uuid REFERENCES public.plg_conversations_channels(id) ON DELETE SET NULL,
|
|
4293
|
+
provider text,
|
|
4294
|
+
status text NOT NULL DEFAULT 'draft',
|
|
4295
|
+
-- O ritmo. Nasce do padr\xE3o do provedor e \xE9 edit\xE1vel: a rampa \xE9 pr\xE1tica de
|
|
4296
|
+
-- comunidade, e quem opera o n\xFAmero sabe mais que esta tabela.
|
|
4297
|
+
pacing jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
4298
|
+
-- Por que parou, quando parou sozinha. Ver \`crm\` nenhum: \xE9 o alarme de
|
|
4299
|
+
-- qualidade da Meta chegando na tela de quem disparou.
|
|
4300
|
+
paused_reason text,
|
|
4301
|
+
started_at timestamptz,
|
|
4302
|
+
finished_at timestamptz,
|
|
4303
|
+
created_by uuid,
|
|
4304
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
4305
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
4306
|
+
CONSTRAINT plg_conversations_campaigns_status
|
|
4307
|
+
CHECK (status IN ('draft', 'scheduled', 'running', 'paused', 'done', 'cancelled'))
|
|
4308
|
+
);
|
|
4309
|
+
|
|
4310
|
+
ALTER TABLE public.plg_conversations_campaigns ENABLE ROW LEVEL SECURITY;
|
|
4311
|
+
ALTER TABLE public.plg_conversations_campaigns FORCE ROW LEVEL SECURITY;
|
|
4312
|
+
DROP POLICY IF EXISTS plg_conversations_campaigns_rw ON public.plg_conversations_campaigns;
|
|
4313
|
+
CREATE POLICY plg_conversations_campaigns_rw ON public.plg_conversations_campaigns
|
|
4314
|
+
FOR ALL TO authenticated
|
|
4315
|
+
USING (tenant_id IN (SELECT public.user_tenant_ids()))
|
|
4316
|
+
WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
4317
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_conversations_campaigns TO authenticated, service_role;
|
|
4318
|
+
|
|
4319
|
+
-- \u2500\u2500 uma linha por destinat\xE1rio, para sempre \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
4320
|
+
--
|
|
4321
|
+
-- \xC9 o log de envio e a fila ao mesmo tempo. Vale para os dois provedores, e \xE9
|
|
4322
|
+
-- o \xFAnico lugar onde se pode PROVAR o que foi mandado, para quem, quando, e
|
|
4323
|
+
-- por que algu\xE9m N\xC3O foi contatado.
|
|
4324
|
+
CREATE TABLE IF NOT EXISTS public.plg_conversations_campaign_targets (
|
|
4325
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
4326
|
+
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
4327
|
+
campaign_id uuid NOT NULL REFERENCES public.plg_conversations_campaigns(id) ON DELETE CASCADE,
|
|
4328
|
+
-- A pessoa do CRM. \`SET NULL\` e n\xE3o \`CASCADE\`: apagar a pessoa n\xE3o pode
|
|
4329
|
+
-- apagar a prova de que uma mensagem foi enviada a ela.
|
|
4330
|
+
person_id uuid,
|
|
4331
|
+
phone_e164 text NOT NULL,
|
|
4332
|
+
-- Ver o cabe\xE7alho: sobe quando algu\xE9m REINICIA a campanha de prop\xF3sito.
|
|
4333
|
+
rotation integer NOT NULL DEFAULT 1,
|
|
4334
|
+
state text NOT NULL DEFAULT 'queued',
|
|
4335
|
+
skip_reason text,
|
|
4336
|
+
failure_code text,
|
|
4337
|
+
failure_detail text,
|
|
4338
|
+
-- O arrendamento. S\xF3 quem segura o token manda.
|
|
4339
|
+
claim_token uuid,
|
|
4340
|
+
claim_expires_at timestamptz,
|
|
4341
|
+
provider_message_id text,
|
|
4342
|
+
variables jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
4343
|
+
queued_at timestamptz NOT NULL DEFAULT now(),
|
|
4344
|
+
sent_at timestamptz,
|
|
4345
|
+
delivered_at timestamptz,
|
|
4346
|
+
read_at timestamptz,
|
|
4347
|
+
replied_at timestamptz,
|
|
4348
|
+
failed_at timestamptz,
|
|
4349
|
+
CONSTRAINT plg_conversations_campaign_targets_state
|
|
4350
|
+
CHECK (state IN ('queued', 'sending', 'sent', 'delivered', 'read', 'replied', 'failed', 'skipped')),
|
|
4351
|
+
-- O arrendamento existe se e s\xF3 se est\xE1 enviando. Duas verifica\xE7\xF5es porque
|
|
4352
|
+
-- um token sem prazo \xE9 um token que nunca volta.
|
|
4353
|
+
CONSTRAINT plg_conversations_campaign_targets_lease
|
|
4354
|
+
CHECK (state <> 'sending' OR claim_expires_at IS NOT NULL),
|
|
4355
|
+
CONSTRAINT plg_conversations_campaign_targets_lease_pair
|
|
4356
|
+
CHECK ((claim_token IS NULL) = (claim_expires_at IS NULL))
|
|
4357
|
+
);
|
|
4358
|
+
|
|
4359
|
+
-- Uma vez por campanha, por pessoa, por rodada. \xC9 esta linha que impede o
|
|
4360
|
+
-- disparo duplo \u2014 n\xE3o um \`if\` no c\xF3digo do disparador.
|
|
4361
|
+
CREATE UNIQUE INDEX IF NOT EXISTS plg_conversations_campaign_targets_once
|
|
4362
|
+
ON public.plg_conversations_campaign_targets (campaign_id, phone_e164, rotation);
|
|
4363
|
+
|
|
4364
|
+
-- O webhook do provedor chega mais de uma vez, de prop\xF3sito (o Evolution tenta
|
|
4365
|
+
-- 10 vezes em ~50 min). A idempot\xEAncia do recebimento \xE9 este \xEDndice.
|
|
4366
|
+
CREATE UNIQUE INDEX IF NOT EXISTS plg_conversations_campaign_targets_provider_msg
|
|
4367
|
+
ON public.plg_conversations_campaign_targets (tenant_id, provider_message_id)
|
|
4368
|
+
WHERE provider_message_id IS NOT NULL;
|
|
4369
|
+
|
|
4370
|
+
CREATE INDEX IF NOT EXISTS plg_conversations_campaign_targets_pending
|
|
4371
|
+
ON public.plg_conversations_campaign_targets (campaign_id)
|
|
4372
|
+
WHERE state IN ('queued', 'sending');
|
|
4373
|
+
|
|
4374
|
+
-- O ceifeiro: linhas cujo arrendamento venceu voltam para a fila.
|
|
4375
|
+
CREATE INDEX IF NOT EXISTS plg_conversations_campaign_targets_reaper
|
|
4376
|
+
ON public.plg_conversations_campaign_targets (claim_expires_at)
|
|
4377
|
+
WHERE state = 'sending';
|
|
4378
|
+
|
|
4379
|
+
ALTER TABLE public.plg_conversations_campaign_targets ENABLE ROW LEVEL SECURITY;
|
|
4380
|
+
ALTER TABLE public.plg_conversations_campaign_targets FORCE ROW LEVEL SECURITY;
|
|
4381
|
+
DROP POLICY IF EXISTS plg_conversations_campaign_targets_rw ON public.plg_conversations_campaign_targets;
|
|
4382
|
+
CREATE POLICY plg_conversations_campaign_targets_rw ON public.plg_conversations_campaign_targets
|
|
4383
|
+
FOR ALL TO authenticated
|
|
4384
|
+
USING (tenant_id IN (SELECT public.user_tenant_ids()))
|
|
4385
|
+
WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
4386
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_conversations_campaign_targets TO authenticated, service_role;
|
|
4387
|
+
|
|
4388
|
+
-- \u2500\u2500 o ritmo que o n\xFAmero aguenta \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
4389
|
+
--
|
|
4390
|
+
-- Mora no canal e n\xE3o na campanha: o limite \xE9 do N\xDAMERO, e duas campanhas no
|
|
4391
|
+
-- mesmo n\xFAmero somam. Guardar na campanha deixaria cada uma achar que tem a
|
|
4392
|
+
-- cota inteira.
|
|
4393
|
+
ALTER TABLE public.plg_conversations_channels
|
|
4394
|
+
ADD COLUMN IF NOT EXISTS pacing jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
4395
|
+
ADD COLUMN IF NOT EXISTS warmup_day integer,
|
|
4396
|
+
ADD COLUMN IF NOT EXISTS warmup_started_at timestamptz,
|
|
4397
|
+
ADD COLUMN IF NOT EXISTS quality text,
|
|
4398
|
+
ADD COLUMN IF NOT EXISTS quality_at timestamptz;
|
|
4399
|
+
|
|
4400
|
+
COMMENT ON COLUMN public.plg_conversations_channels.pacing IS
|
|
4401
|
+
'Ritmo deste n\xFAmero: por minuto, por hora, por dia, faixa de atraso e janela de hor\xE1rio. Padr\xE3o por provedor, edit\xE1vel \u2014 a rampa \xE9 pr\xE1tica de comunidade, n\xE3o documenta\xE7\xE3o da Meta (003).';
|
|
4402
|
+
|
|
4403
|
+
-- Os padr\xF5es, por provedor. N\xFAmeros da pesquisa; o coment\xE1rio diz a
|
|
4404
|
+
-- proced\xEAncia de cada um, porque "por que 20?" \xE9 a pergunta que sempre volta.
|
|
4405
|
+
CREATE OR REPLACE FUNCTION public.conversations_default_pacing(p_provider text)
|
|
4406
|
+
RETURNS jsonb LANGUAGE sql IMMUTABLE AS $$
|
|
4407
|
+
SELECT CASE p_provider
|
|
4408
|
+
-- Oficial. O teto real \xE9 o tier da Meta (250/2.000/10.000/100.000), que a
|
|
4409
|
+
-- pr\xF3pria API informa por n\xFAmero \u2014 ent\xE3o aqui s\xF3 entra o que a Meta N\xC3O
|
|
4410
|
+
-- controla: o ritmo instant\xE2neo. 1 msg / 6 s para o MESMO destinat\xE1rio \xE9
|
|
4411
|
+
-- documentado; 60/min \xE9 75% do teto de 80/s por seguran\xE7a.
|
|
4412
|
+
WHEN 'tyxter' THEN jsonb_build_object(
|
|
4413
|
+
'per_minute', 60, 'per_hour', 1000, 'per_day', null,
|
|
4414
|
+
'delay_seconds', jsonb_build_array(1, 3),
|
|
4415
|
+
'same_recipient_seconds', 6,
|
|
4416
|
+
'hours', jsonb_build_object('start', '09:00', 'end', '18:00', 'weekdays_only', true))
|
|
4417
|
+
-- N\xE3o oficial. Aqui n\xE3o h\xE1 teto do provedor \u2014 h\xE1 banimento. Os n\xFAmeros s\xE3o
|
|
4418
|
+
-- a moda de treze rampas brasileiras publicadas: 10-30/dia na primeira
|
|
4419
|
+
-- semana, 200-300/dia no regime. Atraso ALEAT\xD3RIO entre 15 e 45 s porque
|
|
4420
|
+
-- intervalo fixo \xE9 assinatura de rob\xF4.
|
|
4421
|
+
WHEN 'evolution' THEN jsonb_build_object(
|
|
4422
|
+
'per_minute', 12, 'per_hour', 300, 'per_day', 250,
|
|
4423
|
+
'delay_seconds', jsonb_build_array(15, 45),
|
|
4424
|
+
'same_recipient_seconds', 6,
|
|
4425
|
+
'batch_size', 50, 'batch_cooldown_minutes', 12,
|
|
4426
|
+
'hours', jsonb_build_object('start', '09:00', 'end', '18:00', 'weekdays_only', true))
|
|
4427
|
+
ELSE '{}'::jsonb END
|
|
4428
|
+
$$;
|
|
4429
|
+
|
|
4430
|
+
COMMENT ON FUNCTION public.conversations_default_pacing(text) IS
|
|
4431
|
+
'O ritmo inicial de cada provedor. Tyxter: o teto \xE9 da Meta, aqui s\xF3 o ritmo instant\xE2neo. Evolution: n\xE3o h\xE1 teto, h\xE1 banimento (003).';
|
|
4432
|
+
|
|
4433
|
+
-- A rampa de aquecimento, em dias. Pr\xE1tica de comunidade, edit\xE1vel \u2014 e a
|
|
4434
|
+
-- fun\xE7\xE3o existe para que o padr\xE3o seja UM lugar e n\xE3o uma constante copiada.
|
|
4435
|
+
CREATE OR REPLACE FUNCTION public.conversations_warmup_allowance(p_day integer)
|
|
4436
|
+
RETURNS integer LANGUAGE sql IMMUTABLE AS $$
|
|
4437
|
+
SELECT CASE
|
|
4438
|
+
WHEN p_day IS NULL THEN NULL -- n\xFAmero sem aquecimento declarado: sem teto extra
|
|
4439
|
+
WHEN p_day <= 3 THEN 20
|
|
4440
|
+
WHEN p_day <= 7 THEN 50
|
|
4441
|
+
WHEN p_day <= 14 THEN 100
|
|
4442
|
+
WHEN p_day <= 21 THEN 200
|
|
4443
|
+
ELSE 300
|
|
4444
|
+
END
|
|
4445
|
+
$$;
|
|
4446
|
+
|
|
4447
|
+
COMMENT ON FUNCTION public.conversations_warmup_allowance(integer) IS
|
|
4448
|
+
'Quantos destinat\xE1rios NOVOS por dia, por dia de aquecimento. Moda de treze rampas brasileiras publicadas \u2014 comunidade, n\xE3o Meta. Padr\xE3o edit\xE1vel (003).';
|
|
4449
|
+
|
|
4450
|
+
-- \u2500\u2500 o que a tela do disparo precisa ver \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
4451
|
+
DROP VIEW IF EXISTS public.v_conversations_campaign_progress;
|
|
4452
|
+
CREATE VIEW public.v_conversations_campaign_progress
|
|
4453
|
+
WITH (security_invoker = true) AS
|
|
4454
|
+
SELECT c.id AS campaign_id,
|
|
4455
|
+
c.tenant_id,
|
|
4456
|
+
c.name,
|
|
4457
|
+
c.status,
|
|
4458
|
+
c.paused_reason,
|
|
4459
|
+
c.provider,
|
|
4460
|
+
count(t.id) AS total,
|
|
4461
|
+
count(*) FILTER (WHERE t.state = 'queued') AS queued,
|
|
4462
|
+
count(*) FILTER (WHERE t.state = 'sending') AS sending,
|
|
4463
|
+
count(*) FILTER (WHERE t.state IN ('sent','delivered','read','replied')) AS sent,
|
|
4464
|
+
count(*) FILTER (WHERE t.state = 'delivered') AS delivered,
|
|
4465
|
+
count(*) FILTER (WHERE t.state = 'read') AS read,
|
|
4466
|
+
count(*) FILTER (WHERE t.state = 'replied') AS replied,
|
|
4467
|
+
count(*) FILTER (WHERE t.state = 'failed') AS failed,
|
|
4468
|
+
count(*) FILTER (WHERE t.state = 'skipped') AS skipped,
|
|
4469
|
+
-- A taxa de resposta \xE9 o sinal que decide se o n\xFAmero sobrevive: a
|
|
4470
|
+
-- pr\xE1tica diz que abaixo de 30% o risco de banimento sobe. Fica na
|
|
4471
|
+
-- view porque \xE9 o n\xFAmero que precisa estar na tela, n\xE3o num relat\xF3rio.
|
|
4472
|
+
CASE WHEN count(*) FILTER (WHERE t.state IN ('sent','delivered','read','replied')) > 0
|
|
4473
|
+
THEN round(100.0 * count(*) FILTER (WHERE t.state = 'replied')
|
|
4474
|
+
/ count(*) FILTER (WHERE t.state IN ('sent','delivered','read','replied')), 1)
|
|
4475
|
+
END AS reply_rate,
|
|
4476
|
+
min(t.sent_at) AS first_sent_at,
|
|
4477
|
+
max(t.sent_at) AS last_sent_at
|
|
4478
|
+
FROM public.plg_conversations_campaigns c
|
|
4479
|
+
LEFT JOIN public.plg_conversations_campaign_targets t ON t.campaign_id = c.id
|
|
4480
|
+
WHERE c.tenant_id IN (SELECT public.user_tenant_ids())
|
|
4481
|
+
GROUP BY c.id, c.tenant_id, c.name, c.status, c.paused_reason, c.provider;
|
|
4482
|
+
|
|
4483
|
+
GRANT SELECT ON public.v_conversations_campaign_progress TO authenticated;
|
|
4484
|
+
|
|
4485
|
+
-- \u2500\u2500 arrendar o pr\xF3ximo lote \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
4486
|
+
--
|
|
4487
|
+
-- \`FOR UPDATE SKIP LOCKED\` \xE9 o que deixa dois disparadores trabalharem a mesma
|
|
4488
|
+
-- campanha sem pisar um no outro, e sem advisory lock. O arrendamento de cinco
|
|
4489
|
+
-- minutos \xE9 o que devolve a linha quando um deles morre no meio.
|
|
4490
|
+
--
|
|
4491
|
+
-- A supress\xE3o \xE9 conferida AQUI, na mesma transa\xE7\xE3o que arrenda. Conferir antes
|
|
4492
|
+
-- e mandar depois deixa uma janela em que algu\xE9m pede para sair e recebe assim
|
|
4493
|
+
-- mesmo \u2014 e \xE9 justamente essa mensagem que vira den\xFAncia.
|
|
4494
|
+
CREATE OR REPLACE FUNCTION public.conversations_claim_targets(
|
|
4495
|
+
p_campaign uuid, p_limit integer DEFAULT 10
|
|
4496
|
+
) RETURNS TABLE (
|
|
4497
|
+
id uuid, phone_e164 text, person_id uuid, variables jsonb, claim_token uuid
|
|
4498
|
+
)
|
|
4499
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
|
|
4500
|
+
AS $$
|
|
4501
|
+
DECLARE v_token uuid := gen_random_uuid(); v_tenant uuid;
|
|
4502
|
+
BEGIN
|
|
4503
|
+
SELECT c.tenant_id INTO v_tenant
|
|
4504
|
+
FROM public.plg_conversations_campaigns c WHERE c.id = p_campaign;
|
|
4505
|
+
IF v_tenant IS NULL THEN RAISE EXCEPTION 'campanha n\xE3o encontrada'; END IF;
|
|
4506
|
+
|
|
4507
|
+
-- TR\xCAS COMANDOS, E N\xC3O UM. A primeira vers\xE3o fazia o ceifeiro, a supress\xE3o e
|
|
4508
|
+
-- o arrendamento em CTEs do mesmo UPDATE, e isso \xE9 uma armadilha: dentro de
|
|
4509
|
+
-- um comando s\xF3, todas as CTEs enxergam o MESMO instant\xE2neo da tabela, e uma
|
|
4510
|
+
-- linha atualizada por duas delas recebe s\xF3 a primeira \u2014 em sil\xEAncio. O
|
|
4511
|
+
-- suprimido vinha marcado E arrendado, e qual dos dois vencia dependia do
|
|
4512
|
+
-- plano que o Postgres escolhesse naquele dia.
|
|
4513
|
+
--
|
|
4514
|
+
-- Separado, cada passo enxerga o resultado do anterior. \xC9 mais lento por um
|
|
4515
|
+
-- par de milissegundos e \xE9 a diferen\xE7a entre uma regra e uma coincid\xEAncia.
|
|
4516
|
+
|
|
4517
|
+
-- 1. O ceifeiro. Quem venceu o arrendamento volta para a fila, sen\xE3o uma
|
|
4518
|
+
-- campanha inteira fica presa num processo que morreu.
|
|
4519
|
+
UPDATE public.plg_conversations_campaign_targets t
|
|
4520
|
+
SET state = 'queued', claim_token = NULL, claim_expires_at = NULL
|
|
4521
|
+
WHERE t.campaign_id = p_campaign AND t.state = 'sending'
|
|
4522
|
+
AND t.claim_expires_at < now();
|
|
4523
|
+
|
|
4524
|
+
-- 2. Quem pediu para sair NUNCA entra num lote. A linha fica marcada em vez
|
|
4525
|
+
-- de sumir: \xE9 assim que se prova que a decis\xE3o foi deliberada.
|
|
4526
|
+
UPDATE public.plg_conversations_campaign_targets t
|
|
4527
|
+
SET state = 'skipped', skip_reason = 'suppressed'
|
|
4528
|
+
WHERE t.campaign_id = p_campaign AND t.state = 'queued'
|
|
4529
|
+
AND EXISTS (SELECT 1 FROM public.plg_conversations_optouts o
|
|
4530
|
+
WHERE o.tenant_id = t.tenant_id
|
|
4531
|
+
AND o.phone_e164 = t.phone_e164
|
|
4532
|
+
AND o.active);
|
|
4533
|
+
|
|
4534
|
+
-- 3. S\xF3 ent\xE3o arrenda. \`SKIP LOCKED\` deixa dois disparadores trabalharem a
|
|
4535
|
+
-- mesma campanha sem pisar um no outro, e sem advisory lock.
|
|
4536
|
+
RETURN QUERY
|
|
4537
|
+
WITH escolhidos AS (
|
|
4538
|
+
SELECT t.id FROM public.plg_conversations_campaign_targets t
|
|
4539
|
+
WHERE t.campaign_id = p_campaign AND t.state = 'queued'
|
|
4540
|
+
ORDER BY t.queued_at
|
|
4541
|
+
LIMIT greatest(1, least(coalesce(p_limit, 10), 100))
|
|
4542
|
+
FOR UPDATE SKIP LOCKED
|
|
4543
|
+
)
|
|
4544
|
+
UPDATE public.plg_conversations_campaign_targets t
|
|
4545
|
+
SET state = 'sending', claim_token = v_token,
|
|
4546
|
+
claim_expires_at = now() + interval '5 minutes'
|
|
4547
|
+
FROM escolhidos e
|
|
4548
|
+
WHERE t.id = e.id
|
|
4549
|
+
RETURNING t.id, t.phone_e164, t.person_id, t.variables, t.claim_token;
|
|
4550
|
+
END $$;
|
|
4551
|
+
|
|
4552
|
+
REVOKE ALL ON FUNCTION public.conversations_claim_targets(uuid, integer) FROM PUBLIC, anon;
|
|
4553
|
+
GRANT EXECUTE ON FUNCTION public.conversations_claim_targets(uuid, integer) TO service_role;
|
|
4554
|
+
`;
|
|
4555
|
+
var MIGRATION_004_A_TELA_SABE_SE_PODE_FALAR_ANTES_DE_DEIXAR_ESCREVER = `-- ---------------------------------------------------------------------------
|
|
4556
|
+
-- 004_a_tela_sabe_se_pode_falar_antes_de_deixar_escrever.sql \u2014 a janela de 24h,
|
|
4557
|
+
-- como DADO, para a tela responder antes de algu\xE9m digitar.
|
|
4558
|
+
--
|
|
4559
|
+
-- \u2500\u2500 o defeito que isto conserta \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
4560
|
+
--
|
|
4561
|
+
-- Na API oficial do WhatsApp, texto livre s\xF3 sai dentro de 24h contadas da
|
|
4562
|
+
-- \xFAltima mensagem DO CLIENTE. Fora dela a Meta recusa, e a recusa chega como um
|
|
4563
|
+
-- c\xF3digo no log \u2014 n\xE3o na tela de quem escreveu. O atendente digita, aperta
|
|
4564
|
+
-- enviar, v\xEA a mensagem aparecer na conversa, e ela nunca chega.
|
|
4565
|
+
--
|
|
4566
|
+
-- Isso \xE9 pior que um erro: \xE9 uma tela que mente. E some inteiro num provedor
|
|
4567
|
+
-- n\xE3o oficial, onde n\xE3o existe janela nenhuma \u2014 ent\xE3o o MESMO campo de texto
|
|
4568
|
+
-- funciona ou falha calado dependendo de qual provedor est\xE1 ativo.
|
|
4569
|
+
--
|
|
4570
|
+
-- A corre\xE7\xE3o n\xE3o \xE9 um aviso depois. \xC9 a tela saber ANTES: janela aberta, texto
|
|
4571
|
+
-- livre; janela fechada, s\xF3 template, e o campo diz isso em vez de aceitar.
|
|
4572
|
+
--
|
|
4573
|
+
-- \u2500\u2500 quem abre a janela \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
4574
|
+
--
|
|
4575
|
+
-- O CLIENTE, e s\xF3 ele. Mensagem nossa n\xE3o abre nada \u2014 nem template aprovado.
|
|
4576
|
+
-- \xC9 o erro mais comum de quem implementa isso, e \xE9 por isso que o c\xE1lculo
|
|
4577
|
+
-- abaixo olha exclusivamente \`direction = 'inbound'\`.
|
|
4578
|
+
--
|
|
4579
|
+
-- (Origem: developers.facebook.com/documentation/business-messaging/whatsapp \u2014
|
|
4580
|
+
-- "The user messages you about the product. This opens a 24 hour customer
|
|
4581
|
+
-- service window." Cliques em an\xFAncio Click-to-WhatsApp abrem 72h; n\xE3o
|
|
4582
|
+
-- distinguimos ainda, e 24h \xE9 o limite conservador dos dois.)
|
|
4583
|
+
-- ---------------------------------------------------------------------------
|
|
4584
|
+
|
|
4585
|
+
DROP VIEW IF EXISTS public.v_conversations_window;
|
|
4586
|
+
CREATE VIEW public.v_conversations_window
|
|
4587
|
+
WITH (security_invoker = true) AS
|
|
4588
|
+
SELECT c.id AS conversation_id,
|
|
4589
|
+
c.tenant_id,
|
|
4590
|
+
c.channel,
|
|
4591
|
+
c.contact_handle,
|
|
4592
|
+
c.contact_person_id,
|
|
4593
|
+
u.last_inbound_at,
|
|
4594
|
+
-- Quando fecha. Nulo = nunca abriu, que \xE9 o caso de todo lead que
|
|
4595
|
+
-- ainda n\xE3o respondeu \u2014 a maioria de uma lista de feira.
|
|
4596
|
+
(u.last_inbound_at + interval '24 hours') AS window_expires_at,
|
|
4597
|
+
(u.last_inbound_at IS NOT NULL
|
|
4598
|
+
AND u.last_inbound_at > now() - interval '24 hours') AS window_open,
|
|
4599
|
+
-- Quanto falta, em minutos. A tela mostra "faltam 3h" e n\xE3o um
|
|
4600
|
+
-- carimbo de data: quem est\xE1 respondendo quer saber se d\xE1 tempo.
|
|
4601
|
+
CASE WHEN u.last_inbound_at IS NOT NULL
|
|
4602
|
+
THEN greatest(0, floor(extract(epoch FROM
|
|
4603
|
+
(u.last_inbound_at + interval '24 hours') - now()) / 60))::int
|
|
4604
|
+
END AS minutes_left,
|
|
4605
|
+
-- Quem pediu para n\xE3o ser mais contatado. Some da tela antes de
|
|
4606
|
+
-- qualquer janela: n\xE3o importa se est\xE1 aberta.
|
|
4607
|
+
EXISTS (SELECT 1 FROM public.plg_conversations_optouts o
|
|
4608
|
+
WHERE o.tenant_id = c.tenant_id
|
|
4609
|
+
AND o.phone_e164 = regexp_replace(coalesce(c.contact_handle,''), '\\D', '', 'g')
|
|
4610
|
+
AND o.active) AS opted_out
|
|
4611
|
+
FROM public.plg_conversations c
|
|
4612
|
+
LEFT JOIN LATERAL (
|
|
4613
|
+
SELECT max(m.at) AS last_inbound_at
|
|
4614
|
+
FROM public.plg_conversation_messages m
|
|
4615
|
+
WHERE m.conversation_id = c.id
|
|
4616
|
+
AND m.direction = 'inbound'
|
|
4617
|
+
) u ON true
|
|
4618
|
+
WHERE c.tenant_id IN (SELECT public.user_tenant_ids());
|
|
4619
|
+
|
|
4620
|
+
GRANT SELECT ON public.v_conversations_window TO authenticated;
|
|
4621
|
+
|
|
4622
|
+
COMMENT ON VIEW public.v_conversations_window IS
|
|
4623
|
+
'Se d\xE1 para mandar texto livre para esta conversa agora. A janela de 24h s\xF3 abre por mensagem DO CLIENTE \u2014 template nosso n\xE3o abre nada (004).';
|
|
4624
|
+
|
|
4625
|
+
-- O \xEDndice que a LATERAL usa. Sem ele, cada linha da caixa varre as mensagens
|
|
4626
|
+
-- da conversa inteira, e a caixa \xE9 justamente a tela que abre o dia todo.
|
|
4627
|
+
CREATE INDEX IF NOT EXISTS plg_conversation_messages_inbound_at
|
|
4628
|
+
ON public.plg_conversation_messages (conversation_id, at DESC)
|
|
4629
|
+
WHERE direction = 'inbound';
|
|
4630
|
+
`;
|
|
2688
4631
|
var MIGRATIONS = [
|
|
2689
4632
|
{ id: "000_baseline", sql: MIGRATION_000_BASELINE },
|
|
2690
|
-
{ id: "001_a_caixa_sabe_quem_falou_por_ultimo", sql: MIGRATION_001_A_CAIXA_SABE_QUEM_FALOU_POR_ULTIMO }
|
|
4633
|
+
{ id: "001_a_caixa_sabe_quem_falou_por_ultimo", sql: MIGRATION_001_A_CAIXA_SABE_QUEM_FALOU_POR_ULTIMO },
|
|
4634
|
+
{ id: "002_a_casa_escolhe_por_onde_o_whatsapp_sai", sql: MIGRATION_002_A_CASA_ESCOLHE_POR_ONDE_O_WHATSAPP_SAI },
|
|
4635
|
+
{ id: "003_a_campanha_sai_no_ritmo_que_o_numero_aguenta", sql: MIGRATION_003_A_CAMPANHA_SAI_NO_RITMO_QUE_O_NUMERO_AGUENTA },
|
|
4636
|
+
{ id: "004_a_tela_sabe_se_pode_falar_antes_de_deixar_escrever", sql: MIGRATION_004_A_TELA_SABE_SE_PODE_FALAR_ANTES_DE_DEIXAR_ESCREVER }
|
|
2691
4637
|
];
|
|
2692
4638
|
|
|
2693
4639
|
// src/index.ts
|
|
@@ -2721,8 +4667,12 @@ function createConversationsPlugin(options) {
|
|
|
2721
4667
|
contactEntityDef: options?.contactEntityDef
|
|
2722
4668
|
};
|
|
2723
4669
|
const dashboardWidgets = createConversationsDashboardWidgets({ store: store2, config });
|
|
2724
|
-
const PageComponent = () =>
|
|
4670
|
+
const PageComponent = () => React9__default.createElement(ConversationsPage, { store: store2, config });
|
|
2725
4671
|
PageComponent.displayName = "ConversationsPage";
|
|
4672
|
+
const CampaignsPageComponent = () => React9__default.createElement(CampaignsView);
|
|
4673
|
+
CampaignsPageComponent.displayName = "ConversationsCampaignsPage";
|
|
4674
|
+
const TemplatesPageComponent = () => React9__default.createElement(TemplatesView);
|
|
4675
|
+
TemplatesPageComponent.displayName = "ConversationsTemplatesPage";
|
|
2726
4676
|
return {
|
|
2727
4677
|
id: "conversations",
|
|
2728
4678
|
defaultAgentRole: "frontdesk",
|
|
@@ -2746,9 +4696,41 @@ function createConversationsPlugin(options) {
|
|
|
2746
4696
|
route: "/conversations",
|
|
2747
4697
|
icon: "MessageCircle",
|
|
2748
4698
|
permission: { feature: "conversations", action: "read" }
|
|
4699
|
+
},
|
|
4700
|
+
{
|
|
4701
|
+
section: options?.navSection ?? "main",
|
|
4702
|
+
position: (options?.navPosition ?? 1) + 1,
|
|
4703
|
+
label: "Campanhas",
|
|
4704
|
+
route: "/conversations/campaigns",
|
|
4705
|
+
icon: "Send",
|
|
4706
|
+
permission: { feature: "conversations", action: "read" }
|
|
4707
|
+
},
|
|
4708
|
+
{
|
|
4709
|
+
section: options?.navSection ?? "main",
|
|
4710
|
+
position: (options?.navPosition ?? 1) + 2,
|
|
4711
|
+
label: "Templates",
|
|
4712
|
+
route: "/conversations/templates",
|
|
4713
|
+
icon: "FileText",
|
|
4714
|
+
permission: { feature: "conversations", action: "read" }
|
|
2749
4715
|
}
|
|
2750
4716
|
],
|
|
2751
4717
|
routes: [
|
|
4718
|
+
{
|
|
4719
|
+
path: "/conversations/templates",
|
|
4720
|
+
component: TemplatesPageComponent,
|
|
4721
|
+
title: "Templates",
|
|
4722
|
+
parentRoute: "/conversations",
|
|
4723
|
+
permission: { feature: "conversations", action: "read" }
|
|
4724
|
+
},
|
|
4725
|
+
{
|
|
4726
|
+
// Antes da rota-pai: a shell ordena por especificidade, mas declarar
|
|
4727
|
+
// a filha primeiro é o que deixa isso legível para quem ler depois.
|
|
4728
|
+
path: "/conversations/campaigns",
|
|
4729
|
+
component: CampaignsPageComponent,
|
|
4730
|
+
title: "Campanhas",
|
|
4731
|
+
parentRoute: "/conversations",
|
|
4732
|
+
permission: { feature: "conversations", action: "read" }
|
|
4733
|
+
},
|
|
2752
4734
|
{
|
|
2753
4735
|
path: "/conversations",
|
|
2754
4736
|
component: PageComponent,
|
|
@@ -2915,6 +4897,6 @@ function createConversationsPlugin(options) {
|
|
|
2915
4897
|
};
|
|
2916
4898
|
}
|
|
2917
4899
|
|
|
2918
|
-
export { PAYMENT_PREFLIGHT_FUNCTION, TYXTER_CONNECTOR_ID, TYXTER_LATENCY_BUDGET_MS, canOfferPayment, createConversationsPlugin, createMockConversationsProvider, createSupabaseConversationsProvider, isWaitingOnUs, listMessagingChannels, openPaymentSetupSession, readPaymentReadiness, tyxterConnectorDef };
|
|
4900
|
+
export { CampaignsView, PAYMENT_PREFLIGHT_FUNCTION, TYXTER_CONNECTOR_ID, TYXTER_LATENCY_BUDGET_MS, TemplatesView, WhatsAppProviders, campaignsApi, canOfferPayment, createConversationsPlugin, createMockConversationsProvider, createSupabaseConversationsProvider, isWaitingOnUs, listMessagingChannels, openPaymentSetupSession, providersApi, readPaymentReadiness, tyxterConnectorDef };
|
|
2919
4901
|
//# sourceMappingURL=index.js.map
|
|
2920
4902
|
//# sourceMappingURL=index.js.map
|