@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/index.js CHANGED
@@ -1,19 +1,605 @@
1
- import * as React4 from 'react';
2
- import React4__default, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
3
- import { createConnectionStore, connectionRuns, connectionStatus, getSupabaseClientOptional, useActiveTenantId, registerTranslations, useTranslation, getActiveTenantId, countByTenant, CONNECTOR_RUNTIME_TOKEN_HEADER, connectorRuntimeToken, errorMessage } from '@fayz-ai/core';
4
- import { useStore } from 'zustand';
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 { Loader2, ExternalLink, SquarePen, MessageSquare, Globe, Mail, Instagram, Phone, Search, Inbox, ChevronLeft, Clock, Archive, PanelRight, Send, X, User, MapPin, Tag, StickyNote, Link2 } from 'lucide-react';
7
- import { Input, Button, toast, defineKpiWidget, defineTableWidget, PageHeaderActions, cn, Badge, Skeleton, KpiCard, TableWidget } from '@fayz-ai/ui';
8
- import { PluginSettingsPanel, SettingsGroup, PermissionGate, useLimitGuard, RightRailPage, ContactPicker, invalidateLimit } from '@fayz-ai/admin';
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 = React4__default.createContext(null);
16
- var ConfigContext = React4__default.createContext(DEFAULT_CONVERSATIONS_CONFIG);
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 = React4__default.useContext(StoreContext);
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 React4__default.useContext(ConfigContext);
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] = React4__default.useState(
649
+ const [matches, setMatches] = React9__default.useState(
64
650
  () => typeof window !== "undefined" && window.matchMedia(query).matches
65
651
  );
66
- React4__default.useEffect(() => {
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] = React4__default.useState("");
251
- const threadRef = React4__default.useRef(null);
252
- React4__default.useEffect(() => {
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 = React4__default.useMemo(() => buildRows(messages), [messages]);
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("snoozed"), "aria-label": t("conversations.thread.snooze"), children: [
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: t("conversations.thread.close") })
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
- return /* @__PURE__ */ jsx(
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: /* @__PURE__ */ jsxs(
311
- "div",
312
- {
313
- className: cn(
314
- "max-w-[68%] px-3.5 py-2 text-sm shadow-sm",
315
- outbound ? "rounded-2xl text-white" : "rounded-2xl bg-card text-foreground",
316
- // Tail only on the last bubble of a run, on the sender's side.
317
- outbound && row.endsRun && "rounded-br-sm",
318
- !outbound && row.endsRun && "rounded-bl-sm"
319
- ),
320
- style: outbound ? { backgroundColor: accent.color } : void 0,
321
- children: [
322
- /* @__PURE__ */ jsx("p", { className: "whitespace-pre-wrap break-words", children: m.body }),
323
- /* @__PURE__ */ jsx("div", { className: cn("mt-0.5 text-right text-[10px]", outbound ? "text-white/70" : "text-muted-foreground"), children: new Date(m.at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) })
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__ */ jsx("div", { className: "border-t border-border bg-card px-4 py-3", children: /* @__PURE__ */ jsxs("div", { className: "flex items-end gap-2", children: [
337
- /* @__PURE__ */ jsx(
338
- "textarea",
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
- value: draft,
341
- onChange: (e) => setDraft(e.target.value),
342
- onKeyDown: (e) => {
343
- if (e.key === "Enter" && !e.shiftKey) {
344
- e.preventDefault();
345
- void handleSend();
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
- /* @__PURE__ */ jsx(Button, { onClick: () => void handleSend(), disabled: sending || !draft.trim(), "aria-label": t("conversations.thread.send"), children: /* @__PURE__ */ jsx(Send, { className: "h-4 w-4" }) })
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] = React4__default.useState("whatsapp");
432
- const [contact, setContact] = React4__default.useState(null);
433
- const [typedHandle, setTypedHandle] = React4__default.useState("");
434
- const [creatingContact, setCreatingContact] = React4__default.useState(false);
435
- const [firstMessage, setFirstMessage] = React4__default.useState("");
436
- const [submitting, setSubmitting] = React4__default.useState(false);
437
- const [pickerKey, setPickerKey] = React4__default.useState(0);
438
- React4__default.useEffect(() => {
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] = React4__default.useState(false);
557
- const [newOpen, setNewOpen] = React4__default.useState(false);
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
- React4__default.useEffect(() => {
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
- React4__default.useEffect(() => {
615
- void store2.getState().load();
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 client2() {
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 = client2().from(T.conversations).select("*");
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 = client2().from(T.messages).select("*");
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 client2().from(T.conversations).insert(convRow).select().single();
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 client2().from(T.messages).insert(msgRow);
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 = client2().from(T.conversations).select(
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 client2().from(T.messages).insert(row).select().single();
1985
+ const { data: created, error } = await client4().from(T.messages).insert(row).select().single();
1046
1986
  if (error) throw error;
1047
- await client2().from(T.conversations).update({
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 mapMessage(created ?? row);
2028
+ return {
2029
+ ...mapMessage(created ?? row),
2030
+ providerMessageId: sentId,
2031
+ deliveryStatus: sentStatus
2032
+ };
1054
2033
  },
1055
2034
  async markRead(conversationId) {
1056
- const { error } = await client2().from(T.conversations).update({
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 = client2().from(T.conversations).update({
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) => c.id === id ? { ...c, lastMessagePreview: created.body, lastMessageAt: created.at } : 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] = React4.useState(null);
1568
- const [failed, setFailed] = React4.useState(false);
1569
- React4.useEffect(() => {
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__ */ jsx("div", { className: "space-y-6", children: /* @__PURE__ */ jsxs(
1584
- SettingsGroup,
1585
- {
1586
- title: t("conversations.settings.channels"),
1587
- description: t("conversations.settings.channelsHelp"),
1588
- children: [
1589
- channels === null && /* @__PURE__ */ jsx("p", { className: "py-2 text-sm text-muted-foreground", children: t("conversations.list.loading") }),
1590
- channels !== null && failed && /* @__PURE__ */ jsx("p", { className: "py-2 text-sm text-muted-foreground", children: t("conversations.settings.channelsUnavailable") }),
1591
- channels !== null && !failed && channels.length === 0 && /* @__PURE__ */ jsx("p", { className: "py-2 text-sm text-muted-foreground", children: t("conversations.settings.channelsEmpty") }),
1592
- channels?.map((account) => /* @__PURE__ */ jsx(ChannelRow, { account }, account.id))
1593
- ]
1594
- }
1595
- ) });
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 client() {
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 client().functions.invoke(TYXTER_NUMBER_CLAIM_FUNCTION, {
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 tenant = tenantId ?? getActiveTenantId();
1718
- if (!tenant) throw new Error("Sem neg\xF3cio selecionado.");
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: tenant }
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 = () => React4__default.createElement(ConversationsPage, { store: store2, config });
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