@fayz-ai/plugin-conversations 0.11.6 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,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);
@@ -201,21 +787,26 @@ function ConversationList({ className }) {
201
787
  onClick: () => select(c.id),
202
788
  className: cn(
203
789
  "flex w-full items-start gap-3 border-b border-border/50 px-3 py-3 text-left transition-colors",
204
- active ? "bg-accent" : "hover:bg-muted/50"
790
+ // `text-accent-foreground` JUNTO com `bg-accent`: o par existe
791
+ // para ser usado em par. Só o fundo trocava, e num tema em que
792
+ // `accent` é quase preto o nome e a prévia continuavam
793
+ // `text-foreground` — escuro sobre escuro, ilegível na conversa
794
+ // ABERTA, que é justamente a que se está lendo.
795
+ active ? "bg-accent text-accent-foreground" : "hover:bg-muted/50"
205
796
  ),
206
797
  children: [
207
798
  /* @__PURE__ */ jsx(Avatar, { name: c.contactName, accent: c.accent, channel: c.channel }),
208
799
  /* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
209
800
  /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2", children: [
210
- /* @__PURE__ */ jsx("span", { className: cn("truncate text-sm text-foreground", unread ? "font-semibold" : "font-medium"), children: c.contactName }),
211
- /* @__PURE__ */ jsx("span", { className: cn("shrink-0 text-[11px]", unread ? "font-semibold text-primary" : "text-muted-foreground"), children: relativeTime(c.lastMessageAt) })
801
+ /* @__PURE__ */ jsx("span", { className: cn("truncate text-sm", active ? "" : "text-foreground", unread ? "font-semibold" : "font-medium"), children: c.contactName }),
802
+ /* @__PURE__ */ jsx("span", { className: cn("shrink-0 text-[11px]", active ? "opacity-70" : unread ? "font-semibold text-primary" : "text-muted-foreground", active && unread && "font-semibold opacity-100"), children: relativeTime(c.lastMessageAt) })
212
803
  ] }),
213
804
  /* @__PURE__ */ jsxs("div", { className: "mt-1 flex items-center gap-2", children: [
214
805
  /* @__PURE__ */ jsx(ChannelBadge, { channel: c.channel }),
215
- c.status !== "open" && /* @__PURE__ */ jsx("span", { className: "text-[10px] uppercase tracking-wide text-muted-foreground/70", children: t(`conversations.status.${c.status}`) })
806
+ c.status !== "open" && /* @__PURE__ */ jsx("span", { className: cn("text-[10px] uppercase tracking-wide", active ? "opacity-70" : "text-muted-foreground/70"), children: t(`conversations.status.${c.status}`) })
216
807
  ] }),
217
808
  /* @__PURE__ */ jsxs("div", { className: "mt-1 flex items-center justify-between gap-2", children: [
218
- /* @__PURE__ */ jsx("span", { className: cn("truncate text-xs", unread ? "text-foreground" : "text-muted-foreground"), children: c.lastMessagePreview }),
809
+ /* @__PURE__ */ jsx("span", { className: cn("truncate text-xs", active ? "opacity-80" : unread ? "text-foreground" : "text-muted-foreground"), children: c.lastMessagePreview }),
219
810
  unread && /* @__PURE__ */ jsx("span", { className: "flex h-[18px] min-w-[18px] shrink-0 items-center justify-center rounded-full bg-primary px-1.5 text-[10px] font-semibold text-primary-foreground", children: c.unreadCount })
220
811
  ] })
221
812
  ] })
@@ -227,6 +818,190 @@ function ConversationList({ className }) {
227
818
  ] })
228
819
  ] });
229
820
  }
821
+ function useSendWindow(conversationId) {
822
+ const [row, setRow] = React9__default.useState(null);
823
+ const [providers, setProviders] = React9__default.useState(null);
824
+ React9__default.useEffect(() => {
825
+ let alive = true;
826
+ providersApi.list().then((p) => {
827
+ if (alive) setProviders(p);
828
+ }).catch(() => {
829
+ if (alive) setProviders([]);
830
+ });
831
+ return () => {
832
+ alive = false;
833
+ };
834
+ }, []);
835
+ React9__default.useEffect(() => {
836
+ let alive = true;
837
+ if (!conversationId) {
838
+ setRow(null);
839
+ return;
840
+ }
841
+ const sb = getSupabaseClientOptional();
842
+ if (!sb) {
843
+ setRow(null);
844
+ return;
845
+ }
846
+ 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 }) => {
847
+ if (alive) setRow(data ?? null);
848
+ }).catch(() => {
849
+ if (alive) setRow(null);
850
+ });
851
+ return () => {
852
+ alive = false;
853
+ };
854
+ }, [conversationId]);
855
+ const provider = (providers ?? []).find((p) => p.isActive);
856
+ const mode = React9__default.useMemo(() => {
857
+ if (!provider) return { kind: "blocked", reason: "no_provider" };
858
+ if (!provider.capabilities?.requires_template) {
859
+ return { kind: "free", until: null, minutesLeft: null };
860
+ }
861
+ if (row?.window_open) {
862
+ return { kind: "free", until: row.window_expires_at, minutesLeft: row.minutes_left };
863
+ }
864
+ return { kind: "template", reason: row?.last_inbound_at ? "window_closed" : "never_replied" };
865
+ }, [row, provider]);
866
+ return { loading: providers === null, mode, provider };
867
+ }
868
+ function humanLeft(minutes) {
869
+ if (minutes == null) return "";
870
+ if (minutes < 60) return `${minutes} min`;
871
+ const h = Math.floor(minutes / 60);
872
+ return h < 24 ? `${h}h` : `${Math.floor(h / 24)}d`;
873
+ }
874
+ function SendWindowBanner({ state }) {
875
+ const { mode, provider } = state;
876
+ if (mode.kind === "free" && mode.minutesLeft == null) return null;
877
+ if (mode.kind === "free") {
878
+ const urgent = (mode.minutesLeft ?? 0) < 120;
879
+ 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: [
880
+ /* @__PURE__ */ jsx("span", { className: "h-1.5 w-1.5 rounded-full bg-success" }),
881
+ "Janela aberta \u2014 texto livre por mais ",
882
+ humanLeft(mode.minutesLeft)
883
+ ] });
884
+ }
885
+ if (mode.kind === "blocked") {
886
+ return /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 bg-destructive-soft px-4 py-2 text-[11px] text-destructive-soft-foreground", children: [
887
+ /* @__PURE__ */ jsx("span", { className: "h-1.5 w-1.5 rounded-full bg-destructive" }),
888
+ "Nenhum provedor de WhatsApp ativo \u2014 configure em Ajustes \u203A Conversas."
889
+ ] });
890
+ }
891
+ 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: [
892
+ /* @__PURE__ */ jsx("span", { className: "h-1.5 w-1.5 shrink-0 rounded-full bg-warning" }),
893
+ /* @__PURE__ */ jsx("span", { className: "font-medium", children: mode.reason === "never_replied" ? "Esta pessoa nunca respondeu." : "A janela de 24 horas fechou." }),
894
+ /* @__PURE__ */ jsxs("span", { children: [
895
+ "No ",
896
+ provider?.provider ?? "provedor",
897
+ ", texto livre s\xF3 sai dentro de 24h da resposta do cliente. Para falar agora \xE9 preciso um template aprovado."
898
+ ] })
899
+ ] });
900
+ }
901
+ var STATUS = {
902
+ queued: { icon: Clock, label: "na fila", cls: "text-white/60" },
903
+ sent: { icon: Check, label: "enviada", cls: "text-white/70" },
904
+ delivered: { icon: CheckCheck, label: "entregue", cls: "text-white/80" },
905
+ read: { icon: CheckCheck, label: "lida", cls: "text-sky-200" },
906
+ failed: { icon: AlertTriangle, label: "N\xC3O ENVIADA", cls: "text-white" },
907
+ expired: { icon: AlertTriangle, label: "expirou", cls: "text-white" },
908
+ delivery_timeout: { icon: AlertTriangle, label: "sem confirma\xE7\xE3o", cls: "text-white" },
909
+ cancelled: { icon: X, label: "cancelada", cls: "text-white" },
910
+ opted_out: { icon: X, label: "recusada", cls: "text-white" }
911
+ };
912
+ var BAD = /* @__PURE__ */ new Set(["failed", "expired", "cancelled", "opted_out", "delivery_timeout"]);
913
+ function isFailed(m) {
914
+ if (m.direction !== "outbound") return false;
915
+ if (m.deliveryStatus) return BAD.has(m.deliveryStatus);
916
+ if (m.providerMessageId) return false;
917
+ const age = Date.now() - new Date(m.at).getTime();
918
+ return Number.isFinite(age) && age > 12e4;
919
+ }
920
+ function DeliveryMark({ m }) {
921
+ if (m.direction !== "outbound") return null;
922
+ const key = isFailed(m) ? "failed" : m.deliveryStatus ?? "sent";
923
+ const cfg = STATUS[key] ?? STATUS.sent;
924
+ const Icon = cfg.icon;
925
+ return /* @__PURE__ */ jsxs("span", { className: `inline-flex items-center gap-0.5 ${cfg.cls}`, title: cfg.label, children: [
926
+ /* @__PURE__ */ jsx(Icon, { className: "h-3 w-3" }),
927
+ BAD.has(key) && /* @__PURE__ */ jsx("span", { className: "text-[9px] font-semibold uppercase", children: cfg.label })
928
+ ] });
929
+ }
930
+ function MessageMenu({ m }) {
931
+ const [open, setOpen] = React9__default.useState(false);
932
+ const [detail, setDetail] = React9__default.useState(null);
933
+ const [loading, setLoading] = React9__default.useState(false);
934
+ const [err, setErr] = React9__default.useState(null);
935
+ async function load() {
936
+ setOpen(true);
937
+ if (!m.providerMessageId) return;
938
+ setLoading(true);
939
+ setErr(null);
940
+ try {
941
+ const sb = getSupabaseClientOptional();
942
+ const { data, error } = await sb.functions.invoke("conversations-provider", {
943
+ body: { tenantId: getActiveTenantId(), action: "message_detail", providerMessageId: m.providerMessageId }
944
+ });
945
+ if (error) {
946
+ const d = await error.context?.json?.().catch(() => null);
947
+ throw new Error(d?.message ?? error.message);
948
+ }
949
+ if (data?.error) throw new Error(data.message ?? data.error);
950
+ setDetail(data.detail);
951
+ } catch (e) {
952
+ setErr(e?.message ?? "N\xE3o consegui buscar");
953
+ } finally {
954
+ setLoading(false);
955
+ }
956
+ }
957
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
958
+ /* @__PURE__ */ jsx(
959
+ "button",
960
+ {
961
+ onClick: () => void load(),
962
+ "aria-label": "Detalhes da mensagem",
963
+ className: "opacity-0 transition-opacity group-hover:opacity-60 hover:!opacity-100",
964
+ children: /* @__PURE__ */ jsx(MoreVertical, { className: "h-3.5 w-3.5" })
965
+ }
966
+ ),
967
+ 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: [
968
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between border-b px-4 py-3", children: [
969
+ /* @__PURE__ */ jsxs("div", { children: [
970
+ /* @__PURE__ */ jsx("p", { className: "text-sm font-semibold", children: "Detalhes da mensagem" }),
971
+ /* @__PURE__ */ jsxs("p", { className: "text-[11px] text-muted-foreground", children: [
972
+ new Date(m.at).toLocaleString(),
973
+ " \xB7 ",
974
+ m.direction === "outbound" ? "sa\xEDda" : "entrada",
975
+ m.deliveryStatus ? ` \xB7 ${m.deliveryStatus}` : ""
976
+ ] })
977
+ ] }),
978
+ /* @__PURE__ */ jsx("button", { onClick: () => setOpen(false), "aria-label": "Fechar", children: /* @__PURE__ */ jsx(X, { className: "h-4 w-4" }) })
979
+ ] }),
980
+ /* @__PURE__ */ jsxs("div", { className: "max-h-[60vh] overflow-auto p-4 text-xs", children: [
981
+ /* @__PURE__ */ jsx("p", { className: "mb-1 font-medium text-muted-foreground", children: "Nesta base" }),
982
+ /* @__PURE__ */ jsx("pre", { className: "mb-4 overflow-auto rounded bg-muted/40 p-3 text-[11px] leading-relaxed", children: JSON.stringify({
983
+ id: m.id,
984
+ direction: m.direction,
985
+ body: m.body,
986
+ at: m.at,
987
+ author: m.author,
988
+ deliveryStatus: m.deliveryStatus,
989
+ providerMessageId: m.providerMessageId
990
+ }, null, 2) }),
991
+ /* @__PURE__ */ jsx("p", { className: "mb-1 font-medium text-muted-foreground", children: "No provedor" }),
992
+ !m.providerMessageId && // A ausência é a informação: sem id, ninguém do outro lado
993
+ // chegou a aceitar esta mensagem. Ela não atrasou — ela não saiu.
994
+ /* @__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." }),
995
+ loading && /* @__PURE__ */ jsxs("p", { className: "flex items-center gap-2 p-3 text-muted-foreground", children: [
996
+ /* @__PURE__ */ jsx(Loader2, { className: "h-3 w-3 animate-spin" }),
997
+ " buscando\u2026"
998
+ ] }),
999
+ err && /* @__PURE__ */ jsx("p", { className: "rounded bg-destructive-soft p-3 text-[11px] text-destructive-soft-foreground", children: err }),
1000
+ detail != null && /* @__PURE__ */ jsx("pre", { className: "overflow-auto rounded bg-muted/40 p-3 text-[11px] leading-relaxed", children: JSON.stringify(detail, null, 2) })
1001
+ ] })
1002
+ ] }) })
1003
+ ] });
1004
+ }
230
1005
  function buildRows(messages) {
231
1006
  const rows = [];
232
1007
  let lastDay = "";
@@ -246,12 +1021,36 @@ function buildRows(messages) {
246
1021
  }
247
1022
  function MessageThread({ selected, onTogglePanel, panelOpen, onBack, className }) {
248
1023
  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(() => {
1024
+ const { messages, sending, send, setStatus, refreshMessages } = useConversationsStore((s) => s);
1025
+ const [draft, setDraft] = React9__default.useState("");
1026
+ const win = useSendWindow(selected.id);
1027
+ const canType = win.mode.kind === "free";
1028
+ const threadRef = React9__default.useRef(null);
1029
+ React9__default.useEffect(() => {
253
1030
  threadRef.current?.scrollTo({ top: threadRef.current.scrollHeight, behavior: "smooth" });
254
1031
  }, [messages.length, selected.id]);
1032
+ React9__default.useEffect(() => {
1033
+ if (!selected.id) return;
1034
+ let timer = null;
1035
+ const start = () => {
1036
+ if (!timer) timer = setInterval(() => {
1037
+ void refreshMessages();
1038
+ }, 1e4);
1039
+ };
1040
+ const stop = () => {
1041
+ if (timer) {
1042
+ clearInterval(timer);
1043
+ timer = null;
1044
+ }
1045
+ };
1046
+ const onVis = () => document.visibilityState === "visible" ? start() : stop();
1047
+ onVis();
1048
+ document.addEventListener("visibilitychange", onVis);
1049
+ return () => {
1050
+ stop();
1051
+ document.removeEventListener("visibilitychange", onVis);
1052
+ };
1053
+ }, [selected.id, refreshMessages]);
255
1054
  async function handleSend() {
256
1055
  if (!draft.trim()) return;
257
1056
  const body = draft;
@@ -259,7 +1058,7 @@ function MessageThread({ selected, onTogglePanel, panelOpen, onBack, className }
259
1058
  await send(body);
260
1059
  }
261
1060
  const accent = CHANNEL_ACCENT[selected.channel];
262
- const rows = React4__default.useMemo(() => buildRows(messages), [messages]);
1061
+ const rows = React9__default.useMemo(() => buildRows(messages), [messages]);
263
1062
  return /* @__PURE__ */ jsxs("section", { className: cn("flex min-w-0 flex-1 flex-col bg-muted/20", className), children: [
264
1063
  /* @__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
1064
  /* @__PURE__ */ jsxs("div", { className: "flex min-w-0 items-center gap-2 md:gap-3", children: [
@@ -274,15 +1073,10 @@ function MessageThread({ selected, onTogglePanel, panelOpen, onBack, className }
274
1073
  ] })
275
1074
  ] }),
276
1075
  /* @__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: [
1076
+ /* @__PURE__ */ jsxs(Button, { variant: "outline", size: "sm", onClick: () => setStatus("closed"), "aria-label": "Arquivar", children: [
283
1077
  /* @__PURE__ */ jsx(Archive, { className: "h-3.5 w-3.5 sm:mr-1" }),
284
1078
  " ",
285
- /* @__PURE__ */ jsx("span", { className: "hidden sm:inline", children: t("conversations.thread.close") })
1079
+ /* @__PURE__ */ jsx("span", { className: "hidden sm:inline", children: "Arquivar" })
286
1080
  ] }),
287
1081
  /* @__PURE__ */ jsx(
288
1082
  Button,
@@ -303,27 +1097,39 @@ function MessageThread({ selected, onTogglePanel, panelOpen, onBack, className }
303
1097
  }
304
1098
  const m = row.message;
305
1099
  const outbound = m.direction === "outbound";
306
- return /* @__PURE__ */ jsx(
1100
+ const failed = isFailed(m);
1101
+ return /* @__PURE__ */ jsxs(
307
1102
  "div",
308
1103
  {
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
- )
1104
+ className: cn("group flex items-center gap-1", outbound ? "justify-end" : "justify-start", row.startsRun ? "mt-2.5" : "mt-0.5"),
1105
+ children: [
1106
+ outbound && /* @__PURE__ */ jsx(MessageMenu, { m }),
1107
+ /* @__PURE__ */ jsxs(
1108
+ "div",
1109
+ {
1110
+ className: cn(
1111
+ "max-w-[68%] px-3.5 py-2 text-sm shadow-sm",
1112
+ outbound ? "rounded-2xl text-white" : "rounded-2xl bg-card text-foreground",
1113
+ // Tail only on the last bubble of a run, on the sender's side.
1114
+ outbound && row.endsRun && "rounded-br-sm",
1115
+ !outbound && row.endsRun && "rounded-bl-sm",
1116
+ // UMA MENSAGEM QUE FALHOU PRECISA PARECER QUE FALHOU. Duas
1117
+ // ficaram um dia na caixa com a cara de qualquer outra e nunca
1118
+ // chegaram a ninguém não havia como saber olhando.
1119
+ outbound && failed && "ring-2 ring-destructive"
1120
+ ),
1121
+ style: outbound ? { backgroundColor: failed ? "hsl(var(--destructive))" : accent.color } : void 0,
1122
+ children: [
1123
+ /* @__PURE__ */ jsx("p", { className: "whitespace-pre-wrap break-words", children: m.body }),
1124
+ /* @__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: [
1125
+ new Date(m.at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
1126
+ /* @__PURE__ */ jsx(DeliveryMark, { m })
1127
+ ] })
1128
+ ]
1129
+ }
1130
+ ),
1131
+ !outbound && /* @__PURE__ */ jsx(MessageMenu, { m })
1132
+ ]
327
1133
  },
328
1134
  row.id
329
1135
  );
@@ -333,25 +1139,124 @@ function MessageThread({ selected, onTogglePanel, panelOpen, onBack, className }
333
1139
  /* @__PURE__ */ jsx("p", { className: "mt-2 text-sm", children: t("conversations.thread.empty") })
334
1140
  ] })
335
1141
  ] }),
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",
1142
+ /* @__PURE__ */ jsxs("div", { className: "border-t border-border bg-card", children: [
1143
+ /* @__PURE__ */ jsx(SendWindowBanner, { state: win }),
1144
+ /* @__PURE__ */ jsxs("div", { className: "flex items-end gap-2 px-4 py-3", children: [
1145
+ /* @__PURE__ */ jsx(
1146
+ "textarea",
1147
+ {
1148
+ value: draft,
1149
+ onChange: (e) => setDraft(e.target.value),
1150
+ onKeyDown: (e) => {
1151
+ if (e.key === "Enter" && !e.shiftKey) {
1152
+ e.preventDefault();
1153
+ void handleSend();
1154
+ }
1155
+ },
1156
+ rows: 1,
1157
+ disabled: !canType,
1158
+ placeholder: canType ? t("conversations.thread.reply", { channel: CHANNEL_LABELS[selected.channel] }) : win.mode.kind === "blocked" ? "Envio bloqueado" : "Fora da janela \u2014 envie um template",
1159
+ 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"
1160
+ }
1161
+ ),
1162
+ /* @__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" }) })
1163
+ ] })
1164
+ ] })
1165
+ ] });
1166
+ }
1167
+ var TEMP = {
1168
+ hot: { icon: Flame, cls: "bg-destructive-soft text-destructive-soft-foreground", label: "Quente" },
1169
+ warm: { icon: Sun, cls: "bg-warning-soft text-warning-soft-foreground", label: "Morno" },
1170
+ cold: { icon: Snowflake, cls: "bg-info-soft text-info-soft-foreground", label: "Frio" }
1171
+ };
1172
+ function LeadCard({ personId, handle }) {
1173
+ const [lead, setLead] = React9__default.useState(void 0);
1174
+ React9__default.useEffect(() => {
1175
+ let alive = true;
1176
+ const sb = getSupabaseClientOptional();
1177
+ const tenantId = getActiveTenantId();
1178
+ if (!sb || !tenantId || !personId && !handle) {
1179
+ setLead(null);
1180
+ return;
1181
+ }
1182
+ (async () => {
1183
+ try {
1184
+ let q = sb.from("people").select("id, name, metadata, phone").eq("tenant_id", tenantId).eq("kind", "lead").limit(1);
1185
+ q = personId ? q.eq("id", personId) : q.like("phone", `%${String(handle).replace(/\D/g, "").slice(-8)}`);
1186
+ const { data } = await q.maybeSingle();
1187
+ if (!alive) return;
1188
+ if (!data) {
1189
+ setLead(null);
1190
+ return;
1191
+ }
1192
+ const meta = data.metadata ?? {};
1193
+ let score = null;
1194
+ try {
1195
+ const { data: s } = await sb.from("v_crm_lead_score").select("temperature, fit_score, interest_score, reasons").eq("lead_id", data.id).maybeSingle();
1196
+ score = s;
1197
+ } catch {
1198
+ }
1199
+ setLead({
1200
+ id: data.id,
1201
+ name: data.name,
1202
+ company: meta.company ?? null,
1203
+ status: meta.status ?? null,
1204
+ sourceName: meta.sourceName ?? null,
1205
+ temperature: score?.temperature ?? null,
1206
+ fitScore: score?.fit_score ?? null,
1207
+ interestScore: score?.interest_score ?? null,
1208
+ reasons: Array.isArray(score?.reasons) ? score.reasons : []
1209
+ });
1210
+ } catch {
1211
+ if (alive) setLead(null);
1212
+ }
1213
+ })();
1214
+ return () => {
1215
+ alive = false;
1216
+ };
1217
+ }, [personId, handle]);
1218
+ if (lead === void 0) return /* @__PURE__ */ jsx("div", { className: "px-4 py-3 text-xs text-muted-foreground", children: "carregando\u2026" });
1219
+ if (!lead) {
1220
+ 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." }) });
1221
+ }
1222
+ const t = lead.temperature ? TEMP[lead.temperature] : null;
1223
+ const Icon = t?.icon;
1224
+ return /* @__PURE__ */ jsxs("div", { className: "border-t border-border px-4 py-3", children: [
1225
+ /* @__PURE__ */ jsxs("div", { className: "flex items-start justify-between gap-2", children: [
1226
+ /* @__PURE__ */ jsxs("div", { className: "min-w-0", children: [
1227
+ /* @__PURE__ */ jsx("p", { className: "truncate text-sm font-semibold", children: lead.name }),
1228
+ lead.company && /* @__PURE__ */ jsxs("p", { className: "flex items-center gap-1 truncate text-xs text-muted-foreground", children: [
1229
+ /* @__PURE__ */ jsx(Building2, { className: "h-3 w-3 shrink-0" }),
1230
+ " ",
1231
+ lead.company
1232
+ ] })
1233
+ ] }),
1234
+ /* @__PURE__ */ jsxs(
1235
+ "a",
339
1236
  {
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"
1237
+ href: `#/sales/leads/${lead.id}`,
1238
+ 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",
1239
+ children: [
1240
+ "Abrir lead ",
1241
+ /* @__PURE__ */ jsx(ExternalLink, { className: "h-3 w-3" })
1242
+ ]
351
1243
  }
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
- ] }) })
1244
+ )
1245
+ ] }),
1246
+ /* @__PURE__ */ jsxs("div", { className: "mt-2 flex flex-wrap items-center gap-1.5", children: [
1247
+ 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: [
1248
+ /* @__PURE__ */ jsx(Icon, { className: "h-3 w-3" }),
1249
+ " ",
1250
+ t.label,
1251
+ lead.interestScore != null && /* @__PURE__ */ jsx("span", { className: "tabular-nums opacity-70", children: lead.interestScore })
1252
+ ] }),
1253
+ lead.status && /* @__PURE__ */ jsx("span", { className: "rounded-full bg-muted px-2 py-0.5 text-[10px] capitalize text-muted-foreground", children: lead.status }),
1254
+ lead.sourceName && /* @__PURE__ */ jsx("span", { className: "rounded-full bg-muted px-2 py-0.5 text-[10px] text-muted-foreground", children: lead.sourceName })
1255
+ ] }),
1256
+ 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: [
1257
+ /* @__PURE__ */ jsx("span", { className: "text-muted-foreground/40", children: "\u2022" }),
1258
+ /* @__PURE__ */ jsx("span", { children: r })
1259
+ ] }, i)) })
355
1260
  ] });
356
1261
  }
357
1262
  function Section({ icon: Icon, title, children }) {
@@ -379,6 +1284,7 @@ function ContactPanel({ contact, onClose, className }) {
379
1284
  ] }),
380
1285
  /* @__PURE__ */ jsx(ChannelBadge, { channel: contact.channel })
381
1286
  ] }),
1287
+ /* @__PURE__ */ jsx(LeadCard, { personId: contact.contactPersonId, handle: contact.contactHandle }),
382
1288
  /* @__PURE__ */ jsx(Section, { icon: User, title: t("conversations.contact.details"), children: /* @__PURE__ */ jsxs("dl", { className: "space-y-1.5 text-sm", children: [
383
1289
  /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2", children: [
384
1290
  /* @__PURE__ */ jsx("dt", { className: "text-muted-foreground", children: t("conversations.contact.channel") }),
@@ -428,14 +1334,14 @@ function NewConversationPanel({
428
1334
  const create = useConversationsStore((s) => s.create);
429
1335
  const config = useConversationsConfig();
430
1336
  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(() => {
1337
+ const [channel, setChannel] = React9__default.useState("whatsapp");
1338
+ const [contact, setContact] = React9__default.useState(null);
1339
+ const [typedHandle, setTypedHandle] = React9__default.useState("");
1340
+ const [creatingContact, setCreatingContact] = React9__default.useState(false);
1341
+ const [firstMessage, setFirstMessage] = React9__default.useState("");
1342
+ const [submitting, setSubmitting] = React9__default.useState(false);
1343
+ const [pickerKey, setPickerKey] = React9__default.useState(0);
1344
+ React9__default.useEffect(() => {
439
1345
  if (open) {
440
1346
  setChannel("whatsapp");
441
1347
  setContact(null);
@@ -553,10 +1459,10 @@ function InboxView() {
553
1459
  const t = useTranslation();
554
1460
  const { conversations, selectedId, deselect } = useConversationsStore((s) => s);
555
1461
  const isWidePanel = useMediaQuery("(min-width: 1280px)");
556
- const [panelOpen, setPanelOpen] = React4__default.useState(false);
557
- const [newOpen, setNewOpen] = React4__default.useState(false);
1462
+ const [panelOpen, setPanelOpen] = React9__default.useState(false);
1463
+ const [newOpen, setNewOpen] = React9__default.useState(false);
558
1464
  const selected = conversations.find((c) => c.id === selectedId) ?? null;
559
- React4__default.useEffect(() => {
1465
+ React9__default.useEffect(() => {
560
1466
  setPanelOpen(isWidePanel);
561
1467
  }, [isWidePanel]);
562
1468
  return /* @__PURE__ */ jsxs(Fragment, { children: [
@@ -610,9 +1516,40 @@ function InboxView() {
610
1516
  ] })
611
1517
  ] });
612
1518
  }
1519
+ var CHANNELS2 = ["sms", "whatsapp", "instagram", "email", "webchat"];
1520
+ function requestedContact() {
1521
+ try {
1522
+ const hash = window.location.hash;
1523
+ const qs = hash.includes("?") ? hash.slice(hash.indexOf("?") + 1) : "";
1524
+ if (!qs) return null;
1525
+ const p = new URLSearchParams(qs);
1526
+ const handle = (p.get("handle") ?? "").trim();
1527
+ const name = (p.get("name") ?? "").trim();
1528
+ const personId = (p.get("personId") ?? "").trim() || void 0;
1529
+ if (!handle) return null;
1530
+ const raw = (p.get("channel") ?? "whatsapp").toLowerCase();
1531
+ const channel = CHANNELS2.includes(raw) ? raw : "whatsapp";
1532
+ return { personId, name: name || handle, handle, channel };
1533
+ } catch {
1534
+ return null;
1535
+ }
1536
+ }
613
1537
  function ConversationsPage({ store: store2, config }) {
614
- React4__default.useEffect(() => {
615
- void store2.getState().load();
1538
+ React9__default.useEffect(() => {
1539
+ const wanted = requestedContact();
1540
+ if (!wanted) {
1541
+ void store2.getState().load();
1542
+ return;
1543
+ }
1544
+ void store2.getState().openForPerson(wanted).catch(() => {
1545
+ void store2.getState().load();
1546
+ });
1547
+ try {
1548
+ const hash = window.location.hash;
1549
+ const clean = hash.includes("?") ? hash.slice(0, hash.indexOf("?")) : hash;
1550
+ window.history.replaceState(null, "", clean || "#/conversations");
1551
+ } catch {
1552
+ }
616
1553
  }, []);
617
1554
  return /* @__PURE__ */ jsx(ConversationsContextProvider, { store: store2, config, children: /* @__PURE__ */ jsx(InboxView, {}) });
618
1555
  }
@@ -924,16 +1861,24 @@ function mapMessage(r) {
924
1861
  direction: r.direction ?? "inbound",
925
1862
  body: r.body ?? "",
926
1863
  at: r.at ?? "",
927
- author: r.author ?? ""
1864
+ author: r.author ?? "",
1865
+ deliveryStatus: r.delivery_status ?? null,
1866
+ providerMessageId: r.provider_message_id ?? null
928
1867
  };
929
1868
  }
930
1869
  function createSupabaseConversationsProvider(config) {
1870
+ const DELIVERY = /* @__PURE__ */ new Set(["queued", "sent", "delivered", "read", "failed", "expired", "delivery_timeout", "cancelled", "opted_out"]);
1871
+ function mapDeliveryStatus(raw) {
1872
+ const v = String(raw ?? "").toLowerCase();
1873
+ if (v === "accepted" || v === "received") return "sent";
1874
+ return DELIVERY.has(v) ? v : "sent";
1875
+ }
931
1876
  const selfAuthor = config?.selfAuthor ?? "You";
932
1877
  function resolveTenantId() {
933
1878
  if (!config?.tenantId) return void 0;
934
1879
  return typeof config.tenantId === "function" ? config.tenantId() : config.tenantId;
935
1880
  }
936
- function client2() {
1881
+ function client4() {
937
1882
  const supabase = config?.supabaseClient ?? getSupabaseClientOptional();
938
1883
  if (!supabase) {
939
1884
  throw new Error(
@@ -944,7 +1889,7 @@ function createSupabaseConversationsProvider(config) {
944
1889
  }
945
1890
  return {
946
1891
  async listConversations(query) {
947
- let q = client2().from(T.conversations).select("*");
1892
+ let q = client4().from(T.conversations).select("*");
948
1893
  const tenantId = resolveTenantId();
949
1894
  if (tenantId) q = q.eq("tenant_id", tenantId);
950
1895
  if (query?.channel && query.channel !== "all") {
@@ -967,7 +1912,7 @@ function createSupabaseConversationsProvider(config) {
967
1912
  return (data ?? []).map(mapConversation);
968
1913
  },
969
1914
  async getMessages(conversationId) {
970
- const selected = client2().from(T.messages).select("*");
1915
+ const selected = client4().from(T.messages).select("*");
971
1916
  const filtered = selected.eq(
972
1917
  "conversation_id",
973
1918
  conversationId
@@ -1004,7 +1949,7 @@ function createSupabaseConversationsProvider(config) {
1004
1949
  note: input.note?.trim() || null
1005
1950
  };
1006
1951
  if (tenantId) convRow.tenant_id = tenantId;
1007
- const { data: created, error } = await client2().from(T.conversations).insert(convRow).select().single();
1952
+ const { data: created, error } = await client4().from(T.conversations).insert(convRow).select().single();
1008
1953
  if (error) throw error;
1009
1954
  if (!created) throw new Error("Conversation not created");
1010
1955
  if (firstMessage) {
@@ -1017,14 +1962,14 @@ function createSupabaseConversationsProvider(config) {
1017
1962
  at: now
1018
1963
  };
1019
1964
  if (tenantId) msgRow.tenant_id = tenantId;
1020
- await client2().from(T.messages).insert(msgRow);
1965
+ await client4().from(T.messages).insert(msgRow);
1021
1966
  }
1022
1967
  return mapConversation(created);
1023
1968
  },
1024
1969
  async sendMessage(input) {
1025
1970
  const tenantId = resolveTenantId();
1026
- const convSelected = client2().from(T.conversations).select(
1027
- "channel"
1971
+ const convSelected = client4().from(T.conversations).select(
1972
+ "channel, contact_handle"
1028
1973
  );
1029
1974
  const convFiltered = convSelected.eq(
1030
1975
  "id",
@@ -1042,24 +1987,63 @@ function createSupabaseConversationsProvider(config) {
1042
1987
  at
1043
1988
  };
1044
1989
  if (tenantId) row.tenant_id = tenantId;
1045
- const { data: created, error } = await client2().from(T.messages).insert(row).select().single();
1990
+ const { data: created, error } = await client4().from(T.messages).insert(row).select().single();
1046
1991
  if (error) throw error;
1047
- await client2().from(T.conversations).update({
1992
+ let sentId = null;
1993
+ let sentStatus = null;
1994
+ if (channel === "whatsapp") {
1995
+ const handle = String(conv?.contact_handle ?? "");
1996
+ try {
1997
+ const { data: out, error: sendErr } = await client4().functions.invoke(
1998
+ "conversations-provider",
1999
+ { body: { tenantId, action: "send", to: handle, text: input.body } }
2000
+ );
2001
+ if (sendErr) {
2002
+ const detail = await sendErr.context?.json?.().catch(() => null);
2003
+ throw new Error(detail?.message ?? sendErr.message);
2004
+ }
2005
+ if (out?.error) throw new Error(out.message ?? out.error);
2006
+ sentId = out?.providerMessageId ?? null;
2007
+ sentStatus = out?.queued ? "queued" : mapDeliveryStatus(out?.status);
2008
+ if (created?.id) {
2009
+ await client4().from(T.messages).update({
2010
+ provider_message_id: sentId,
2011
+ // O provedor fala o dialeto dele. A Tyxter devolve `accepted`,
2012
+ // que NÃO existe no CHECK desta coluna — o update explodia, caía
2013
+ // no catch, e a mensagem era marcada como falha mesmo tendo
2014
+ // saído. Foi assim que "opa" apareceu verde e contou como erro.
2015
+ delivery_status: sentStatus
2016
+ }).eq("id", String(created.id));
2017
+ }
2018
+ } catch (err) {
2019
+ if (created?.id) {
2020
+ await client4().from(T.messages).update({
2021
+ delivery_status: "failed"
2022
+ }).eq("id", String(created.id));
2023
+ }
2024
+ throw err;
2025
+ }
2026
+ }
2027
+ await client4().from(T.conversations).update({
1048
2028
  last_message_preview: input.body,
1049
2029
  last_message_at: at,
1050
2030
  unread_count: 0,
1051
2031
  status: "open"
1052
2032
  }).eq("id", input.conversationId);
1053
- return mapMessage(created ?? row);
2033
+ return {
2034
+ ...mapMessage(created ?? row),
2035
+ providerMessageId: sentId,
2036
+ deliveryStatus: sentStatus
2037
+ };
1054
2038
  },
1055
2039
  async markRead(conversationId) {
1056
- const { error } = await client2().from(T.conversations).update({
2040
+ const { error } = await client4().from(T.conversations).update({
1057
2041
  unread_count: 0
1058
2042
  }).eq("id", conversationId);
1059
2043
  if (error) throw error;
1060
2044
  },
1061
2045
  async setStatus(conversationId, status) {
1062
- const updated = client2().from(T.conversations).update({
2046
+ const updated = client4().from(T.conversations).update({
1063
2047
  status
1064
2048
  });
1065
2049
  const filtered = updated.eq("id", conversationId);
@@ -1071,6 +2055,11 @@ function createSupabaseConversationsProvider(config) {
1071
2055
  }
1072
2056
  };
1073
2057
  }
2058
+ function handleKey(h) {
2059
+ const v = (h ?? "").trim();
2060
+ if (!v) return "";
2061
+ return /[@a-z]/i.test(v) ? v.toLowerCase() : v.replace(/\D/g, "");
2062
+ }
1074
2063
  function createConversationsStore(provider) {
1075
2064
  return createStore((set, get) => ({
1076
2065
  conversations: [],
@@ -1099,6 +2088,26 @@ function createConversationsStore(provider) {
1099
2088
  conversations: s.conversations.map((c) => c.id === id ? { ...c, unreadCount: 0 } : c)
1100
2089
  }));
1101
2090
  },
2091
+ /** Relê as mensagens da conversa aberta, sem tocar em mais nada.
2092
+ *
2093
+ * O status de uma mensagem de WhatsApp muda DEPOIS do envio — entregue,
2094
+ * lida, às vezes minutos depois — e quem avisa é o webhook, que escreve no
2095
+ * banco sem a tela saber. Sem isto a caixa mostra para sempre o status do
2096
+ * instante do envio: um tique só, numa mensagem que já foi lida.
2097
+ *
2098
+ * Não usa `select()` de propósito: aquele marca a conversa como lida e
2099
+ * zera o contador, e uma releitura de fundo não é um gesto de ninguém. */
2100
+ async refreshMessages() {
2101
+ const id = get().selectedId;
2102
+ if (!id) return;
2103
+ try {
2104
+ const messages = await provider.getMessages(id);
2105
+ const before = get().messages;
2106
+ const same = before.length === messages.length && before.every((m, i) => m.id === messages[i].id && m.deliveryStatus === messages[i].deliveryStatus);
2107
+ if (!same) set({ messages });
2108
+ } catch {
2109
+ }
2110
+ },
1102
2111
  deselect() {
1103
2112
  set({ selectedId: null, messages: [] });
1104
2113
  },
@@ -1129,6 +2138,31 @@ function createConversationsStore(provider) {
1129
2138
  })();
1130
2139
  return created;
1131
2140
  },
2141
+ // Chegar aqui vindo de fora — da ficha de um lead, por exemplo — não pode
2142
+ // criar uma segunda thread com alguém com quem a casa já conversa. Procura
2143
+ // primeiro pela PESSOA, e só então pelo telefone: o vínculo é a verdade, e
2144
+ // o handle é o que resgata threads criadas antes de o vínculo existir.
2145
+ async openForPerson({ personId, name, handle, channel }) {
2146
+ if (get().conversations.length === 0 && !get().loading) {
2147
+ try {
2148
+ await get().load();
2149
+ } catch {
2150
+ }
2151
+ }
2152
+ const key = handleKey(handle);
2153
+ const existing = get().conversations.find((c) => c.channel === channel && (personId && c.contactPersonId === personId || key !== "" && handleKey(c.contactHandle) === key));
2154
+ if (existing) {
2155
+ set({ channelFilter: "all", search: "" });
2156
+ await get().select(existing.id);
2157
+ return existing;
2158
+ }
2159
+ return get().create({
2160
+ contactName: name,
2161
+ contactPersonId: personId,
2162
+ contactHandle: handle,
2163
+ channel
2164
+ });
2165
+ },
1132
2166
  async send(body) {
1133
2167
  const id = get().selectedId;
1134
2168
  if (!id || !body.trim()) return;
@@ -1138,7 +2172,18 @@ function createConversationsStore(provider) {
1138
2172
  sending: false,
1139
2173
  messages: [...s.messages, created],
1140
2174
  conversations: s.conversations.map(
1141
- (c) => c.id === id ? { ...c, lastMessagePreview: created.body, lastMessageAt: created.at } : c
2175
+ (c) => (
2176
+ // `lastMessageDirection` acompanha a prévia, senão a fila mente. O
2177
+ // gatilho da migration 001 carimba a coluna no banco, mas a lista só
2178
+ // volta a ler no próximo `load()` — e até lá a thread que ACABOU de
2179
+ // ser respondida continua em "esperando nós", que é exatamente a
2180
+ // pergunta que essa coluna existe para responder.
2181
+ //
2182
+ // Antes da 001 isto não aparecia: a coluna vinha sempre indefinida e
2183
+ // `isWaitingOnUs` caía no `unreadCount`. A migration não criou o
2184
+ // defeito, tornou-o visível.
2185
+ c.id === id ? { ...c, lastMessagePreview: created.body, lastMessageAt: created.at, lastMessageDirection: "outbound" } : c
2186
+ )
1142
2187
  )
1143
2188
  }));
1144
2189
  },
@@ -1478,6 +2523,237 @@ function createConversationsDashboardWidgets(ctx) {
1478
2523
  })
1479
2524
  ];
1480
2525
  }
2526
+ var COPY = {
2527
+ tyxter: {
2528
+ name: "Tyxter",
2529
+ what: "API oficial do WhatsApp Business, via revenda.",
2530
+ strength: "Entrega confi\xE1vel, sem risco de banimento. A Meta mede a qualidade do n\xFAmero.",
2531
+ 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.",
2532
+ fields: [
2533
+ { key: "api_key", label: "Chave da API", placeholder: "tx_live_\u2026", secret: true },
2534
+ { key: "default_sender_id", label: "N\xFAmero remetente (id)", placeholder: "cm\u2026" }
2535
+ ]
2536
+ },
2537
+ evolution: {
2538
+ name: "Evolution API",
2539
+ what: "Gateway auto-hospedado, sobre o WhatsApp Web.",
2540
+ strength: "Manda texto livre para quem quiser, sem template e sem janela de 24h.",
2541
+ cost: "O n\xFAmero pode ser banido sem aviso, e a sess\xE3o cai sozinha. Aquecer o n\xFAmero n\xE3o \xE9 opcional aqui.",
2542
+ fields: [
2543
+ { key: "base_url", label: "URL da inst\xE2ncia", placeholder: "https://evo.seudominio.com" },
2544
+ { key: "api_key", label: "Chave da API", placeholder: "sua apikey", secret: true },
2545
+ { key: "instance", label: "Nome da inst\xE2ncia", placeholder: "chefcontrol" }
2546
+ ]
2547
+ }
2548
+ };
2549
+ var ALL = ["tyxter", "evolution"];
2550
+ function StatusChip({ row }) {
2551
+ if (!row || row.status === "unconfigured") {
2552
+ return /* @__PURE__ */ jsx("span", { className: "rounded-full bg-muted px-2 py-0.5 text-[10px] text-muted-foreground", children: "n\xE3o configurado" });
2553
+ }
2554
+ const map = {
2555
+ connected: "bg-success-soft text-success-soft-foreground",
2556
+ connecting: "bg-warning-soft text-warning-soft-foreground",
2557
+ disconnected: "bg-muted text-muted-foreground",
2558
+ error: "bg-destructive-soft text-destructive-soft-foreground"
2559
+ };
2560
+ const label = {
2561
+ connected: "conectado",
2562
+ connecting: "aguardando teste",
2563
+ disconnected: "desconectado",
2564
+ error: "com erro"
2565
+ };
2566
+ 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 });
2567
+ }
2568
+ function NumberRow({ n }) {
2569
+ return /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center gap-x-3 gap-y-1 py-1.5 text-xs", children: [
2570
+ /* @__PURE__ */ jsx("span", { className: `h-1.5 w-1.5 shrink-0 rounded-full ${n.ready ? "bg-success" : "bg-muted-foreground/40"}` }),
2571
+ /* @__PURE__ */ jsx("span", { className: "font-medium tabular-nums", children: n.phone ?? n.id }),
2572
+ n.displayName && /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: n.displayName }),
2573
+ /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground", children: [
2574
+ "qualidade: ",
2575
+ /* @__PURE__ */ jsx("span", { className: "text-foreground", children: n.quality ?? "\u2014" })
2576
+ ] }),
2577
+ /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground", children: [
2578
+ "limite: ",
2579
+ /* @__PURE__ */ jsx("span", { className: "text-foreground", children: n.tier ?? "\u2014" })
2580
+ ] }),
2581
+ n.used24h != null && n.allowance != null && /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground", children: [
2582
+ "24h: ",
2583
+ /* @__PURE__ */ jsxs("span", { className: "text-foreground tabular-nums", children: [
2584
+ n.used24h,
2585
+ "/",
2586
+ n.used24h + n.allowance
2587
+ ] })
2588
+ ] }),
2589
+ !n.ready && /* @__PURE__ */ jsx("span", { className: "text-warning", children: "n\xE3o pronto para enviar" })
2590
+ ] });
2591
+ }
2592
+ function WhatsAppProviders() {
2593
+ const [rows, setRows] = React9__default.useState(null);
2594
+ const [draft, setDraft] = React9__default.useState({});
2595
+ const [busy, setBusy] = React9__default.useState(null);
2596
+ const [numbers, setNumbers] = React9__default.useState({});
2597
+ const [qr, setQr] = React9__default.useState(null);
2598
+ const load = React9__default.useCallback(async () => {
2599
+ try {
2600
+ setRows(await providersApi.list());
2601
+ } catch {
2602
+ setRows([]);
2603
+ }
2604
+ }, []);
2605
+ React9__default.useEffect(() => {
2606
+ void load();
2607
+ }, [load]);
2608
+ const byId = React9__default.useMemo(
2609
+ () => Object.fromEntries((rows ?? []).map((r) => [r.provider, r])),
2610
+ [rows]
2611
+ );
2612
+ function field(p, key) {
2613
+ const row = byId[p];
2614
+ if (key === "api_key") return draft[p]?.[key] ?? "";
2615
+ return draft[p]?.[key] ?? String(row?.config?.[key] ?? "");
2616
+ }
2617
+ function setField(p, key, v) {
2618
+ setDraft((d) => ({ ...d, [p]: { ...d[p] ?? {}, [key]: v } }));
2619
+ }
2620
+ async function run(id, fn) {
2621
+ setBusy(id);
2622
+ try {
2623
+ await fn();
2624
+ } catch (e) {
2625
+ toast.error(e?.message ?? "Falhou");
2626
+ } finally {
2627
+ setBusy(null);
2628
+ }
2629
+ }
2630
+ const save = (p) => run(`save:${p}`, async () => {
2631
+ const d = draft[p] ?? {};
2632
+ const config = {};
2633
+ for (const f of COPY[p].fields) {
2634
+ if (f.secret) continue;
2635
+ const v = field(p, f.key);
2636
+ if (v) config[f.key] = v;
2637
+ }
2638
+ if (p === "tyxter" && !config.api_base_url) config.api_base_url = "https://api.tyxter.com";
2639
+ await providersApi.save(p, config, d.api_key);
2640
+ setDraft((x) => ({ ...x, [p]: { ...x[p] ?? {}, api_key: "" } }));
2641
+ await load();
2642
+ toast.success("Guardado");
2643
+ });
2644
+ const test = (p) => run(`test:${p}`, async () => {
2645
+ const h = await providersApi.health(p);
2646
+ await load();
2647
+ toast[h.ok ? "success" : "error"](h.ok ? `Conectado \u2014 ${h.detail ?? "ok"}` : `N\xE3o conectou: ${h.detail ?? "?"}`);
2648
+ });
2649
+ const refreshNumbers = (p) => run(`numbers:${p}`, async () => {
2650
+ const d = await providersApi.numbers(p);
2651
+ setNumbers((n) => ({ ...n, [p]: d.numbers }));
2652
+ if (d.numbers.length === 0) toast.error("Nenhum n\xFAmero nesta conta");
2653
+ });
2654
+ const activate = (p) => run(`on:${p}`, async () => {
2655
+ await providersApi.activate(p);
2656
+ await load();
2657
+ toast.success(`${COPY[p].name} est\xE1 valendo agora`);
2658
+ });
2659
+ const pair = (p) => run(`qr:${p}`, async () => {
2660
+ const d = await providersApi.pair(p);
2661
+ setQr({ provider: p, image: d.qr, code: d.code });
2662
+ if (!d.qr && !d.code) toast.error("A inst\xE2ncia n\xE3o devolveu QR \u2014 j\xE1 est\xE1 pareada?");
2663
+ });
2664
+ return /* @__PURE__ */ jsxs(
2665
+ SettingsGroup,
2666
+ {
2667
+ title: "WhatsApp \u2014 por onde as mensagens saem",
2668
+ description: "Dois caminhos, um valendo de cada vez. Troque quando quiser; a configura\xE7\xE3o do outro fica guardada.",
2669
+ children: [
2670
+ rows === null && /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 py-6 text-sm text-muted-foreground", children: [
2671
+ /* @__PURE__ */ jsx(Loader2, { className: "h-4 w-4 animate-spin" }),
2672
+ " carregando\u2026"
2673
+ ] }),
2674
+ rows !== null && ALL.map((p) => {
2675
+ const row = byId[p];
2676
+ const copy = COPY[p];
2677
+ const active = Boolean(row?.isActive);
2678
+ const caps = row?.capabilities ?? {};
2679
+ return /* @__PURE__ */ jsxs(
2680
+ "div",
2681
+ {
2682
+ className: `rounded-card border p-4 my-2 ${active ? "border-primary bg-primary/[0.03]" : "bg-card"}`,
2683
+ "data-testid": `provider-${p}`,
2684
+ children: [
2685
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [
2686
+ /* @__PURE__ */ jsx("span", { className: "text-sm font-semibold", children: copy.name }),
2687
+ 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: [
2688
+ /* @__PURE__ */ jsx(Check, { className: "h-3 w-3" }),
2689
+ " valendo agora"
2690
+ ] }),
2691
+ /* @__PURE__ */ jsx(StatusChip, { row }),
2692
+ row?.hasCredential && /* @__PURE__ */ jsxs("span", { className: "inline-flex items-center gap-1 text-[10px] text-muted-foreground", children: [
2693
+ /* @__PURE__ */ jsx(ShieldCheck, { className: "h-3 w-3" }),
2694
+ " chave guardada"
2695
+ ] }),
2696
+ caps.ban_risk === "high" && /* @__PURE__ */ jsxs("span", { className: "inline-flex items-center gap-1 text-[10px] text-warning", children: [
2697
+ /* @__PURE__ */ jsx(ShieldAlert, { className: "h-3 w-3" }),
2698
+ " risco de banimento"
2699
+ ] })
2700
+ ] }),
2701
+ /* @__PURE__ */ jsx("p", { className: "mt-1 text-xs text-muted-foreground", children: copy.what }),
2702
+ /* @__PURE__ */ jsxs("div", { className: "mt-2 grid gap-1 sm:grid-cols-2", children: [
2703
+ /* @__PURE__ */ jsxs("p", { className: "text-[11px] text-success-soft-foreground", children: [
2704
+ "\u2713 ",
2705
+ copy.strength
2706
+ ] }),
2707
+ /* @__PURE__ */ jsxs("p", { className: "text-[11px] text-warning", children: [
2708
+ "! ",
2709
+ copy.cost
2710
+ ] })
2711
+ ] }),
2712
+ /* @__PURE__ */ jsx("div", { className: "mt-3 grid gap-2 sm:grid-cols-2", children: copy.fields.map((f) => /* @__PURE__ */ jsxs("label", { className: "block", children: [
2713
+ /* @__PURE__ */ jsx("span", { className: "text-[11px] text-muted-foreground", children: f.label }),
2714
+ /* @__PURE__ */ jsx(
2715
+ Input,
2716
+ {
2717
+ className: "mt-0.5 h-8 text-sm",
2718
+ type: f.secret ? "password" : "text",
2719
+ autoComplete: "off",
2720
+ placeholder: f.secret && row?.hasCredential ? "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022 (guardada)" : f.placeholder,
2721
+ value: field(p, f.key),
2722
+ onChange: (e) => setField(p, f.key, e.target.value)
2723
+ }
2724
+ )
2725
+ ] }, f.key)) }),
2726
+ /* @__PURE__ */ jsxs("div", { className: "mt-3 flex flex-wrap gap-2", children: [
2727
+ /* @__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" }),
2728
+ /* @__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: [
2729
+ /* @__PURE__ */ jsx(RefreshCw, { className: "mr-1 h-3 w-3" }),
2730
+ " Testar"
2731
+ ] }) }),
2732
+ /* @__PURE__ */ jsx(Button, { size: "sm", variant: "outline", disabled: !row?.hasCredential || busy === `numbers:${p}`, onClick: () => refreshNumbers(p), children: "N\xFAmeros" }),
2733
+ caps.qr_pairing && /* @__PURE__ */ jsxs(Button, { size: "sm", variant: "outline", disabled: !row?.hasCredential || busy === `qr:${p}`, onClick: () => pair(p), children: [
2734
+ /* @__PURE__ */ jsx(QrCode, { className: "mr-1 h-3 w-3" }),
2735
+ " Parear"
2736
+ ] }),
2737
+ !active && /* @__PURE__ */ jsxs(Button, { size: "sm", disabled: !row?.hasCredential || busy === `on:${p}`, onClick: () => activate(p), children: [
2738
+ /* @__PURE__ */ jsx(Zap, { className: "mr-1 h-3 w-3" }),
2739
+ " Usar este"
2740
+ ] })
2741
+ ] }),
2742
+ 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,
2743
+ qr?.provider === p && /* @__PURE__ */ jsxs("div", { className: "mt-3 border-t pt-3", children: [
2744
+ /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground mb-2", children: "Abra o WhatsApp no celular \u2192 Aparelhos conectados \u2192 Conectar aparelho." }),
2745
+ qr.image && /* @__PURE__ */ jsx("img", { src: qr.image, alt: "QR", className: "h-48 w-48 rounded border bg-white p-2" }),
2746
+ qr.code && /* @__PURE__ */ jsx("p", { className: "mt-2 font-mono text-lg tracking-widest", children: qr.code })
2747
+ ] })
2748
+ ]
2749
+ },
2750
+ p
2751
+ );
2752
+ })
2753
+ ]
2754
+ }
2755
+ );
2756
+ }
1481
2757
  function mapChannel(r) {
1482
2758
  return {
1483
2759
  id: String(r.id),
@@ -1564,9 +2840,9 @@ function ChannelRow({ account }) {
1564
2840
  }
1565
2841
  function ConversationsGeneralSettings() {
1566
2842
  const t = useTranslation();
1567
- const [channels, setChannels] = React4.useState(null);
1568
- const [failed, setFailed] = React4.useState(false);
1569
- React4.useEffect(() => {
2843
+ const [channels, setChannels] = React9.useState(null);
2844
+ const [failed, setFailed] = React9.useState(false);
2845
+ React9.useEffect(() => {
1570
2846
  let cancelled = false;
1571
2847
  listMessagingChannels().then((rows) => {
1572
2848
  if (!cancelled) setChannels(rows);
@@ -1580,19 +2856,22 @@ function ConversationsGeneralSettings() {
1580
2856
  cancelled = true;
1581
2857
  };
1582
2858
  }, []);
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
- ) });
2859
+ return /* @__PURE__ */ jsxs("div", { className: "space-y-6", children: [
2860
+ /* @__PURE__ */ jsx(WhatsAppProviders, {}),
2861
+ /* @__PURE__ */ jsxs(
2862
+ SettingsGroup,
2863
+ {
2864
+ title: t("conversations.settings.channels"),
2865
+ description: t("conversations.settings.channelsHelp"),
2866
+ children: [
2867
+ channels === null && /* @__PURE__ */ jsx("p", { className: "py-2 text-sm text-muted-foreground", children: t("conversations.list.loading") }),
2868
+ channels !== null && failed && /* @__PURE__ */ jsx("p", { className: "py-2 text-sm text-muted-foreground", children: t("conversations.settings.channelsUnavailable") }),
2869
+ channels !== null && !failed && channels.length === 0 && /* @__PURE__ */ jsx("p", { className: "py-2 text-sm text-muted-foreground", children: t("conversations.settings.channelsEmpty") }),
2870
+ channels?.map((account) => /* @__PURE__ */ jsx(ChannelRow, { account }, account.id))
2871
+ ]
2872
+ }
2873
+ )
2874
+ ] });
1596
2875
  }
1597
2876
  ConversationsGeneralSettings.displayName = "ConversationsGeneralSettings";
1598
2877
  function ConversationsSettingsTab() {
@@ -1662,7 +2941,7 @@ function buildConversationsOnboarding() {
1662
2941
  };
1663
2942
  }
1664
2943
  var TYXTER_NUMBER_CLAIM_FUNCTION = "tyxter-number-claim";
1665
- function client() {
2944
+ function client3() {
1666
2945
  const supabase = getSupabaseClientOptional();
1667
2946
  if (!supabase) throw new Error("Sem conex\xE3o com o banco para falar com a Tyxter.");
1668
2947
  return supabase;
@@ -1674,7 +2953,7 @@ async function invoke(body) {
1674
2953
  } catch {
1675
2954
  headers = void 0;
1676
2955
  }
1677
- const { data, error } = await client().functions.invoke(TYXTER_NUMBER_CLAIM_FUNCTION, {
2956
+ const { data, error } = await client3().functions.invoke(TYXTER_NUMBER_CLAIM_FUNCTION, {
1678
2957
  body,
1679
2958
  ...headers ? { headers } : {}
1680
2959
  });
@@ -1714,10 +2993,10 @@ var PAYMENT_PREFLIGHT_FUNCTION = "tyxter-payment-preflight";
1714
2993
  async function invoke2(body, tenantId) {
1715
2994
  const supabase = getSupabaseClientOptional();
1716
2995
  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.");
2996
+ const tenant2 = tenantId ?? getActiveTenantId();
2997
+ if (!tenant2) throw new Error("Sem neg\xF3cio selecionado.");
1719
2998
  const { data, error } = await supabase.functions.invoke(PAYMENT_PREFLIGHT_FUNCTION, {
1720
- body: { ...body, tenantId: tenant }
2999
+ body: { ...body, tenantId: tenant2 }
1721
3000
  });
1722
3001
  if (!error) return data;
1723
3002
  let message = "";
@@ -2685,9 +3964,681 @@ CREATE INDEX IF NOT EXISTS idx_plg_conversations_waiting
2685
3964
  ON public.plg_conversations (tenant_id, last_message_at DESC)
2686
3965
  WHERE status = 'open' AND last_message_direction = 'inbound';
2687
3966
  `;
3967
+ var MIGRATION_002_A_CASA_ESCOLHE_POR_ONDE_O_WHATSAPP_SAI = `-- ---------------------------------------------------------------------------
3968
+ -- 002_a_casa_escolhe_por_onde_o_whatsapp_sai.sql \u2014 dois provedores de WhatsApp,
3969
+ -- a credencial de cada um, e a escolha de qual est\xE1 valendo.
3970
+ --
3971
+ -- \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
3972
+ --
3973
+ -- O \`messaging-send\` (WP3) pega a chave assim:
3974
+ --
3975
+ -- const credential = await redeemConnectorCredential({ connectorSlug: 'tyxter', \u2026 })
3976
+ --
3977
+ -- Quinze linhas que chamam a PLATAFORMA Fayz. Funciona para o Tyxter porque a
3978
+ -- chave \xE9 uma s\xF3, da FayaLabs, e a plataforma a serve para qualquer inquilino
3979
+ -- do projeto. N\xE3o funciona para o Evolution, e n\xE3o vai funcionar nunca: o
3980
+ -- Evolution \xE9 auto-hospedado, e cada casa tem a pr\xF3pria URL e a pr\xF3pria chave.
3981
+ -- Credencial POR INQUILINO n\xE3o \xE9 atalho, \xE9 requisito do segundo provedor.
3982
+ --
3983
+ -- Um app que n\xE3o est\xE1 ligado a projeto Fayz nenhum \u2014 como o FullControl hoje \u2014
3984
+ -- tamb\xE9m n\xE3o resgata nada. Aqui a chave tem onde ficar nos dois casos.
3985
+ --
3986
+ -- \u2500\u2500 os dois provedores n\xE3o s\xE3o a mesma coisa, e a tabela diz isso \u2500\u2500\u2500\u2500\u2500\u2500\u2500
3987
+ --
3988
+ -- \`tyxter\` API oficial (Cloud API, via revenda). Iniciar conversa EXIGE
3989
+ -- template aprovado pela Meta; texto livre s\xF3 dentro de 24h da
3990
+ -- \xFAltima mensagem do cliente. Em troca: entrega confi\xE1vel, sem
3991
+ -- risco de banimento, e a Meta responde por n\xFAmero e qualidade.
3992
+ -- \`evolution\` WhatsApp Web, auto-hospedado. N\xE3o tem template nem janela de
3993
+ -- 24h \u2014 manda o que quiser. Em troca: a sess\xE3o morre, o n\xFAmero
3994
+ -- pode ser banido, e ningu\xE9m responde por isso al\xE9m de voc\xEA.
3995
+ --
3996
+ -- \`capabilities\` guarda essa diferen\xE7a como DADO e n\xE3o como \`if\` espalhado pela
3997
+ -- tela. Um bot\xE3o de "submeter template" num provedor que n\xE3o tem template n\xE3o
3998
+ -- pode existir; a tela l\xEA daqui para desabilitar em vez de fingir.
3999
+ --
4000
+ -- \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
4001
+ --
4002
+ -- A tabela nega SELECT a \`authenticated\`. Quem l\xEA \xE9 o \`service_role\`, do lado
4003
+ -- do servidor. A tela sabe se EXISTE credencial e quais s\xE3o os campos n\xE3o
4004
+ -- secretos (a URL do Evolution, o nome da inst\xE2ncia) por uma view; o segredo
4005
+ -- ela nunca v\xEA, nem para reexibir mascarado.
4006
+ -- ---------------------------------------------------------------------------
4007
+
4008
+ -- \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
4009
+ CREATE TABLE IF NOT EXISTS public.plg_conversations_providers (
4010
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
4011
+ provider text NOT NULL,
4012
+ -- N\xE3o secreto: a URL base do Evolution, o nome da inst\xE2ncia, o id do n\xFAmero.
4013
+ -- \xC9 isto que a tela mostra e deixa editar.
4014
+ config jsonb NOT NULL DEFAULT '{}'::jsonb,
4015
+ -- Secreto. Nenhuma pol\xEDtica de leitura alcan\xE7a esta coluna.
4016
+ secrets jsonb NOT NULL DEFAULT '{}'::jsonb,
4017
+ -- O que este provedor SABE fazer. Lido pela tela para desabilitar o que n\xE3o
4018
+ -- existe, em vez de oferecer um bot\xE3o que erra.
4019
+ capabilities jsonb NOT NULL DEFAULT '{}'::jsonb,
4020
+ -- Um s\xF3 por inquilino fica ativo. O outro continua configurado, pronto para
4021
+ -- a troca \u2014 que \xE9 o pedido: poder alternar sem reconfigurar.
4022
+ is_active boolean NOT NULL DEFAULT false,
4023
+ status text NOT NULL DEFAULT 'unconfigured',
4024
+ status_detail text,
4025
+ checked_at timestamptz,
4026
+ created_at timestamptz NOT NULL DEFAULT now(),
4027
+ updated_at timestamptz NOT NULL DEFAULT now(),
4028
+ PRIMARY KEY (tenant_id, provider),
4029
+ CONSTRAINT plg_conversations_providers_provider
4030
+ CHECK (provider IN ('tyxter', 'evolution')),
4031
+ CONSTRAINT plg_conversations_providers_status
4032
+ CHECK (status IN ('unconfigured', 'connecting', 'connected', 'error', 'disconnected'))
4033
+ );
4034
+
4035
+ -- UM ativo por inquilino, garantido pelo banco e n\xE3o pela tela. Duas telas
4036
+ -- abertas em abas diferentes s\xE3o o caso comum, e "o \xFAltimo clique vence" s\xF3 \xE9
4037
+ -- verdade se algu\xE9m impedir o empate.
4038
+ CREATE UNIQUE INDEX IF NOT EXISTS plg_conversations_providers_one_active
4039
+ ON public.plg_conversations_providers (tenant_id)
4040
+ WHERE is_active;
4041
+
4042
+ ALTER TABLE public.plg_conversations_providers ENABLE ROW LEVEL SECURITY;
4043
+ ALTER TABLE public.plg_conversations_providers FORCE ROW LEVEL SECURITY;
4044
+
4045
+ -- Nenhuma pol\xEDtica para \`authenticated\`: o segredo mora numa coluna desta
4046
+ -- tabela, e uma pol\xEDtica de SELECT aqui entregaria a chave ao navegador.
4047
+ --
4048
+ -- E o REVOKE expl\xEDcito, porque a aus\xEAncia de pol\xEDtica N\xC3O \xE9 a \xFAnica linha de
4049
+ -- defesa. O schema \`public\` deste cluster carrega grants amplos de f\xE1brica \u2014
4050
+ -- medido nesta pr\xF3pria tabela logo ap\xF3s cri\xE1-la: sete privil\xE9gios j\xE1
4051
+ -- concedidos a \`authenticated\` sem ningu\xE9m ter pedido. A RLS for\xE7ada barra
4052
+ -- assim mesmo, mas uma pol\xEDtica escrita errada num dia ruim \xE9 tudo o que
4053
+ -- separa a chave do navegador. Duas fechaduras.
4054
+ REVOKE ALL ON public.plg_conversations_providers FROM PUBLIC, anon, authenticated;
4055
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_conversations_providers TO service_role;
4056
+
4057
+ -- \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
4058
+ --
4059
+ -- Tudo menos o segredo. \`has_credential\` responde a \xFAnica pergunta que a tela
4060
+ -- precisa fazer sobre ele: j\xE1 foi posto?
4061
+ -- \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
4062
+ --
4063
+ -- A regra da casa \xE9 \`security_invoker = true\`: a view enxerga o que o leitor
4064
+ -- enxerga, e a RLS da tabela continua valendo. Aqui isso se autoderrota.
4065
+ --
4066
+ -- \`authenticated\` N\xC3O pode ler \`plg_conversations_providers\` \u2014 \xE9 onde o segredo
4067
+ -- mora, e o REVOKE logo acima \xE9 deliberado. Uma view invoker sobre uma tabela
4068
+ -- que o chamador n\xE3o alcan\xE7a devolve \`42501 permission denied\`, que foi
4069
+ -- exatamente o que a tela recebeu no primeiro teste com um JWT de verdade.
4070
+ --
4071
+ -- Ent\xE3o esta view \xE9 definer POR PROJETO, e paga o pre\xE7o sendo ela mesma a
4072
+ -- fronteira: o \`WHERE tenant_id IN (user_tenant_ids())\` abaixo n\xE3o \xE9
4073
+ -- decora\xE7\xE3o, \xE9 a RLS desta view. E a coluna do segredo n\xE3o est\xE1 na lista do
4074
+ -- SELECT \u2014 o que ela n\xE3o seleciona n\xE3o existe para quem l\xEA.
4075
+ DROP VIEW IF EXISTS public.v_conversations_providers;
4076
+ CREATE VIEW public.v_conversations_providers AS
4077
+ SELECT p.tenant_id,
4078
+ p.provider,
4079
+ p.config,
4080
+ p.capabilities,
4081
+ p.is_active,
4082
+ p.status,
4083
+ p.status_detail,
4084
+ p.checked_at,
4085
+ -- Nunca o valor. S\xF3 se existe.
4086
+ (p.secrets ? 'api_key') AS has_credential,
4087
+ p.created_at,
4088
+ p.updated_at
4089
+ FROM public.plg_conversations_providers p
4090
+ WHERE p.tenant_id IN (SELECT public.user_tenant_ids());
4091
+
4092
+ GRANT SELECT ON public.v_conversations_providers TO authenticated;
4093
+
4094
+ COMMENT ON VIEW public.v_conversations_providers IS
4095
+ 'Os provedores de WhatsApp da casa, SEM o segredo. \`has_credential\` diz se a chave foi posta; o valor n\xE3o sai daqui (002).';
4096
+
4097
+ -- \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
4098
+ --
4099
+ -- SECURITY DEFINER porque a tabela nega escrita a \`authenticated\`: \xE9 a \xFAnica
4100
+ -- porta, e ela confere o inquilino antes de abrir. \`search_path\` vazio porque
4101
+ -- uma fun\xE7\xE3o definer sem isso \xE9 uma fun\xE7\xE3o que o chamador pode redirecionar.
4102
+ CREATE OR REPLACE FUNCTION public.conversations_set_provider(
4103
+ p_tenant uuid,
4104
+ p_provider text,
4105
+ p_config jsonb DEFAULT '{}'::jsonb,
4106
+ p_api_key text DEFAULT NULL,
4107
+ p_capabilities jsonb DEFAULT NULL
4108
+ ) RETURNS void
4109
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
4110
+ AS $$
4111
+ DECLARE v_caps jsonb;
4112
+ BEGIN
4113
+ IF p_tenant IS NULL OR NOT (p_tenant IN (SELECT public.user_tenant_ids())) THEN
4114
+ RAISE EXCEPTION 'conversations_set_provider: n\xE3o \xE9 uma casa sua';
4115
+ END IF;
4116
+
4117
+ -- O que cada provedor sabe fazer \xE9 conhecimento do produto, n\xE3o do usu\xE1rio.
4118
+ -- Quem chama pode sobrescrever (um Evolution atr\xE1s de proxy pode ganhar
4119
+ -- capacidades), mas o padr\xE3o descreve a verdade de cada um.
4120
+ v_caps := coalesce(p_capabilities, CASE p_provider
4121
+ WHEN 'tyxter' THEN jsonb_build_object(
4122
+ 'templates', true, -- e s\xE3o OBRIGAT\xD3RIOS para iniciar conversa
4123
+ 'requires_template', true,
4124
+ 'session_window_hours', 24, -- texto livre s\xF3 dentro dela
4125
+ 'delivery_receipts', true,
4126
+ 'qr_pairing', false,
4127
+ 'ban_risk', 'low')
4128
+ WHEN 'evolution' THEN jsonb_build_object(
4129
+ 'templates', false, -- n\xE3o existem: manda texto direto
4130
+ 'requires_template', false,
4131
+ 'session_window_hours', null,
4132
+ 'delivery_receipts', true,
4133
+ 'qr_pairing', true, -- parear \xE9 ler um QR, n\xE3o cadastrar na Meta
4134
+ 'ban_risk', 'high') -- e \xE9 por isso que o aquecimento importa
4135
+ ELSE '{}'::jsonb END);
4136
+
4137
+ INSERT INTO public.plg_conversations_providers AS t
4138
+ (tenant_id, provider, config, capabilities, status,
4139
+ secrets)
4140
+ VALUES
4141
+ (p_tenant, p_provider, coalesce(p_config, '{}'::jsonb), v_caps,
4142
+ CASE WHEN p_api_key IS NULL THEN 'unconfigured' ELSE 'connecting' END,
4143
+ CASE WHEN p_api_key IS NULL THEN '{}'::jsonb
4144
+ ELSE jsonb_build_object('api_key', p_api_key) END)
4145
+ ON CONFLICT (tenant_id, provider) DO UPDATE
4146
+ SET config = coalesce(p_config, t.config),
4147
+ capabilities = v_caps,
4148
+ -- Chave nula \xE9 "n\xE3o mexe", n\xE3o "apaga". Salvar a URL do Evolution sem
4149
+ -- redigitar a chave \xE9 o gesto comum, e apag\xE1-la aqui seria a surpresa.
4150
+ secrets = CASE WHEN p_api_key IS NULL THEN t.secrets
4151
+ ELSE jsonb_set(t.secrets, '{api_key}', to_jsonb(p_api_key)) END,
4152
+ status = CASE WHEN p_api_key IS NULL THEN t.status ELSE 'connecting' END,
4153
+ updated_at = now();
4154
+ END $$;
4155
+
4156
+ REVOKE ALL ON FUNCTION public.conversations_set_provider(uuid, text, jsonb, text, jsonb) FROM PUBLIC, anon;
4157
+ GRANT EXECUTE ON FUNCTION public.conversations_set_provider(uuid, text, jsonb, text, jsonb) TO authenticated, service_role;
4158
+
4159
+ -- \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
4160
+ CREATE OR REPLACE FUNCTION public.conversations_activate_provider(
4161
+ p_tenant uuid, p_provider text
4162
+ ) RETURNS void
4163
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
4164
+ AS $$
4165
+ BEGIN
4166
+ IF p_tenant IS NULL OR NOT (p_tenant IN (SELECT public.user_tenant_ids())) THEN
4167
+ RAISE EXCEPTION 'conversations_activate_provider: n\xE3o \xE9 uma casa sua';
4168
+ END IF;
4169
+
4170
+ -- Desligar ANTES de ligar: o \xEDndice \xFAnico parcial recusaria os dois ativos,
4171
+ -- e numa transa\xE7\xE3o s\xF3 a ordem \xE9 o que decide entre trocar e falhar.
4172
+ UPDATE public.plg_conversations_providers
4173
+ SET is_active = false, updated_at = now()
4174
+ WHERE tenant_id = p_tenant AND is_active;
4175
+
4176
+ UPDATE public.plg_conversations_providers
4177
+ SET is_active = true, updated_at = now()
4178
+ WHERE tenant_id = p_tenant AND provider = p_provider;
4179
+
4180
+ IF NOT FOUND THEN
4181
+ RAISE EXCEPTION 'conversations_activate_provider: % n\xE3o est\xE1 configurado nesta casa', p_provider;
4182
+ END IF;
4183
+ END $$;
4184
+
4185
+ REVOKE ALL ON FUNCTION public.conversations_activate_provider(uuid, text) FROM PUBLIC, anon;
4186
+ GRANT EXECUTE ON FUNCTION public.conversations_activate_provider(uuid, text) TO authenticated, service_role;
4187
+
4188
+ -- \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
4189
+ --
4190
+ -- \`plg_conversations_channels.provider\` j\xE1 existe e j\xE1 guarda 'tyxter'. O que
4191
+ -- falta \xE9 a liga\xE7\xE3o com a credencial: um n\xFAmero do Evolution precisa saber de
4192
+ -- QUAL inst\xE2ncia ele saiu, porque a mesma casa pode ter mais de uma.
4193
+ ALTER TABLE public.plg_conversations_channels
4194
+ ADD COLUMN IF NOT EXISTS instance_key text;
4195
+
4196
+ COMMENT ON COLUMN public.plg_conversations_channels.instance_key IS
4197
+ '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).';
4198
+ `;
4199
+ var MIGRATION_003_A_CAMPANHA_SAI_NO_RITMO_QUE_O_NUMERO_AGUENTA = `-- ---------------------------------------------------------------------------
4200
+ -- 003_a_campanha_sai_no_ritmo_que_o_numero_aguenta.sql \u2014 template, campanha,
4201
+ -- log de envio e o ritmo que impede o n\xFAmero de ser banido.
4202
+ --
4203
+ -- O desenho n\xE3o \xE9 inven\xE7\xE3o: saiu de ler Mautic, EspoCRM, Odoo e Twenty, e de
4204
+ -- ler a documenta\xE7\xE3o da Meta em vez do folclore que circula sobre ela. Onde os
4205
+ -- quatro discordam, este arquivo diz qual escolheu e por qu\xEA.
4206
+ --
4207
+ -- \u2500\u2500 tr\xEAs coisas que a Meta documenta e que viram REGRA, n\xE3o ajuste \u2500\u2500\u2500\u2500\u2500\u2500
4208
+ --
4209
+ -- \xB7 131050 (o usu\xE1rio saiu do marketing) NUNCA se repete. Reenviar \xE9 outra
4210
+ -- infra\xE7\xE3o, n\xE3o outra tentativa.
4211
+ -- \xB7 131048 (bloqueado/marcado como spam) \xE9 parada dura. \xC9 o alarme de
4212
+ -- qualidade, n\xE3o um erro de rede.
4213
+ -- \xB7 A janela de 24h s\xF3 abre por mensagem OU chamada DO CLIENTE. Template
4214
+ -- nosso n\xE3o abre janela nenhuma \u2014 e \xE9 o erro que mais se v\xEA em CRM.
4215
+ --
4216
+ -- O resto \u2014 a rampa de aquecimento, a faixa de atraso entre mensagens \u2014 \xE9
4217
+ -- pr\xE1tica de comunidade, n\xE3o documenta\xE7\xE3o. Vira PADR\xC3O EDIT\xC1VEL: quem opera o
4218
+ -- n\xFAmero sabe mais do que uma tabela escrita hoje.
4219
+ --
4220
+ -- \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
4221
+ --
4222
+ -- \`rotation\` \xE9 do Mautic: a chave \xFAnica \xE9 (campanha, contato, rota\xE7\xE3o), e a
4223
+ -- rota\xE7\xE3o sobe quando algu\xE9m REINICIA a campanha de prop\xF3sito. Sem ela, a \xFAnica
4224
+ -- forma de reenviar \xE9 apagar o hist\xF3rico \u2014 e a\xED ningu\xE9m consegue provar o que
4225
+ -- foi mandado.
4226
+ --
4227
+ -- \`claim_token\` + \`claim_expires_at\` \xE9 do Twenty: quem vai enviar ARRENDA a
4228
+ -- linha por cinco minutos. Duas inst\xE2ncias do disparador n\xE3o mandam a mesma
4229
+ -- mensagem duas vezes, e um processo que morre no meio devolve a linha sozinho
4230
+ -- quando o arrendamento vence. Os quatro CRMs resolvem isso com \xEDndice \xFAnico
4231
+ -- mais arrendamento; nenhum usa advisory lock, e nenhum usa outbox.
4232
+ -- ---------------------------------------------------------------------------
4233
+
4234
+ -- \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
4235
+ --
4236
+ -- A tabela j\xE1 existe e j\xE1 \xE9 chaveada pelo N\xDAMERO e n\xE3o pelo contato \u2014 que \xE9 o
4237
+ -- acerto do Odoo e do Twenty, e o erro do Mautic: n\xFAmero \xE9 reatribu\xEDdo, contato
4238
+ -- \xE9 duplicado, e a supress\xE3o precisa sobreviver aos dois.
4239
+ --
4240
+ -- O que falta \xE9 n\xE3o apagar nunca. O EspoCRM apaga a linha ao reinscrever e
4241
+ -- perde a prova de que algu\xE9m um dia pediu para sair; o Odoo desliga um
4242
+ -- booleano. Seguimos o Odoo.
4243
+ ALTER TABLE public.plg_conversations_optouts
4244
+ ADD COLUMN IF NOT EXISTS active boolean NOT NULL DEFAULT true,
4245
+ ADD COLUMN IF NOT EXISTS revoked_at timestamptz,
4246
+ ADD COLUMN IF NOT EXISTS source text;
4247
+
4248
+ COMMENT ON COLUMN public.plg_conversations_optouts.active IS
4249
+ '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).';
4250
+
4251
+ -- \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
4252
+ --
4253
+ -- Um template aqui N\xC3O \xE9 um template da Meta. \xC9 o texto da casa, com o espelho
4254
+ -- do que o provedor respondeu sobre ele. Assim o mesmo registro serve aos dois:
4255
+ -- no Tyxter ele carrega o nome e o status da aprova\xE7\xE3o; no Evolution, que n\xE3o
4256
+ -- tem o conceito, ele \xE9 s\xF3 o texto \u2014 e a campanha n\xE3o precisa saber a diferen\xE7a.
4257
+ CREATE TABLE IF NOT EXISTS public.plg_conversations_templates (
4258
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
4259
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
4260
+ name text NOT NULL,
4261
+ language text NOT NULL DEFAULT 'pt_BR',
4262
+ category text NOT NULL DEFAULT 'MARKETING',
4263
+ body text NOT NULL,
4264
+ -- Os nomes das vari\xE1veis, na ordem em que {{1}}, {{2}} aparecem. A Meta s\xF3
4265
+ -- entende posi\xE7\xE3o; gente entende nome. Guardar os dois \xE9 o que deixa a tela
4266
+ -- pedir "cargo" em vez de "vari\xE1vel 3".
4267
+ variables text[] NOT NULL DEFAULT '{}',
4268
+ provider text,
4269
+ provider_template_id text,
4270
+ -- \`draft\` enquanto \xE9 nosso; depois \xE9 o que o provedor respondeu, verbatim.
4271
+ status text NOT NULL DEFAULT 'draft',
4272
+ status_detail text,
4273
+ submitted_at timestamptz,
4274
+ approved_at timestamptz,
4275
+ created_at timestamptz NOT NULL DEFAULT now(),
4276
+ updated_at timestamptz NOT NULL DEFAULT now()
4277
+ );
4278
+
4279
+ CREATE UNIQUE INDEX IF NOT EXISTS plg_conversations_templates_name_uq
4280
+ ON public.plg_conversations_templates (tenant_id, name, language);
4281
+
4282
+ ALTER TABLE public.plg_conversations_templates ENABLE ROW LEVEL SECURITY;
4283
+ ALTER TABLE public.plg_conversations_templates FORCE ROW LEVEL SECURITY;
4284
+ DROP POLICY IF EXISTS plg_conversations_templates_rw ON public.plg_conversations_templates;
4285
+ CREATE POLICY plg_conversations_templates_rw ON public.plg_conversations_templates
4286
+ FOR ALL TO authenticated
4287
+ USING (tenant_id IN (SELECT public.user_tenant_ids()))
4288
+ WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
4289
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_conversations_templates TO authenticated, service_role;
4290
+
4291
+ -- \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
4292
+ CREATE TABLE IF NOT EXISTS public.plg_conversations_campaigns (
4293
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
4294
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
4295
+ name text NOT NULL,
4296
+ template_id uuid REFERENCES public.plg_conversations_templates(id) ON DELETE SET NULL,
4297
+ channel_id uuid REFERENCES public.plg_conversations_channels(id) ON DELETE SET NULL,
4298
+ provider text,
4299
+ status text NOT NULL DEFAULT 'draft',
4300
+ -- O ritmo. Nasce do padr\xE3o do provedor e \xE9 edit\xE1vel: a rampa \xE9 pr\xE1tica de
4301
+ -- comunidade, e quem opera o n\xFAmero sabe mais que esta tabela.
4302
+ pacing jsonb NOT NULL DEFAULT '{}'::jsonb,
4303
+ -- Por que parou, quando parou sozinha. Ver \`crm\` nenhum: \xE9 o alarme de
4304
+ -- qualidade da Meta chegando na tela de quem disparou.
4305
+ paused_reason text,
4306
+ started_at timestamptz,
4307
+ finished_at timestamptz,
4308
+ created_by uuid,
4309
+ created_at timestamptz NOT NULL DEFAULT now(),
4310
+ updated_at timestamptz NOT NULL DEFAULT now(),
4311
+ CONSTRAINT plg_conversations_campaigns_status
4312
+ CHECK (status IN ('draft', 'scheduled', 'running', 'paused', 'done', 'cancelled'))
4313
+ );
4314
+
4315
+ ALTER TABLE public.plg_conversations_campaigns ENABLE ROW LEVEL SECURITY;
4316
+ ALTER TABLE public.plg_conversations_campaigns FORCE ROW LEVEL SECURITY;
4317
+ DROP POLICY IF EXISTS plg_conversations_campaigns_rw ON public.plg_conversations_campaigns;
4318
+ CREATE POLICY plg_conversations_campaigns_rw ON public.plg_conversations_campaigns
4319
+ FOR ALL TO authenticated
4320
+ USING (tenant_id IN (SELECT public.user_tenant_ids()))
4321
+ WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
4322
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_conversations_campaigns TO authenticated, service_role;
4323
+
4324
+ -- \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
4325
+ --
4326
+ -- \xC9 o log de envio e a fila ao mesmo tempo. Vale para os dois provedores, e \xE9
4327
+ -- o \xFAnico lugar onde se pode PROVAR o que foi mandado, para quem, quando, e
4328
+ -- por que algu\xE9m N\xC3O foi contatado.
4329
+ CREATE TABLE IF NOT EXISTS public.plg_conversations_campaign_targets (
4330
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
4331
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
4332
+ campaign_id uuid NOT NULL REFERENCES public.plg_conversations_campaigns(id) ON DELETE CASCADE,
4333
+ -- A pessoa do CRM. \`SET NULL\` e n\xE3o \`CASCADE\`: apagar a pessoa n\xE3o pode
4334
+ -- apagar a prova de que uma mensagem foi enviada a ela.
4335
+ person_id uuid,
4336
+ phone_e164 text NOT NULL,
4337
+ -- Ver o cabe\xE7alho: sobe quando algu\xE9m REINICIA a campanha de prop\xF3sito.
4338
+ rotation integer NOT NULL DEFAULT 1,
4339
+ state text NOT NULL DEFAULT 'queued',
4340
+ skip_reason text,
4341
+ failure_code text,
4342
+ failure_detail text,
4343
+ -- O arrendamento. S\xF3 quem segura o token manda.
4344
+ claim_token uuid,
4345
+ claim_expires_at timestamptz,
4346
+ provider_message_id text,
4347
+ variables jsonb NOT NULL DEFAULT '{}'::jsonb,
4348
+ queued_at timestamptz NOT NULL DEFAULT now(),
4349
+ sent_at timestamptz,
4350
+ delivered_at timestamptz,
4351
+ read_at timestamptz,
4352
+ replied_at timestamptz,
4353
+ failed_at timestamptz,
4354
+ CONSTRAINT plg_conversations_campaign_targets_state
4355
+ CHECK (state IN ('queued', 'sending', 'sent', 'delivered', 'read', 'replied', 'failed', 'skipped')),
4356
+ -- O arrendamento existe se e s\xF3 se est\xE1 enviando. Duas verifica\xE7\xF5es porque
4357
+ -- um token sem prazo \xE9 um token que nunca volta.
4358
+ CONSTRAINT plg_conversations_campaign_targets_lease
4359
+ CHECK (state <> 'sending' OR claim_expires_at IS NOT NULL),
4360
+ CONSTRAINT plg_conversations_campaign_targets_lease_pair
4361
+ CHECK ((claim_token IS NULL) = (claim_expires_at IS NULL))
4362
+ );
4363
+
4364
+ -- Uma vez por campanha, por pessoa, por rodada. \xC9 esta linha que impede o
4365
+ -- disparo duplo \u2014 n\xE3o um \`if\` no c\xF3digo do disparador.
4366
+ CREATE UNIQUE INDEX IF NOT EXISTS plg_conversations_campaign_targets_once
4367
+ ON public.plg_conversations_campaign_targets (campaign_id, phone_e164, rotation);
4368
+
4369
+ -- O webhook do provedor chega mais de uma vez, de prop\xF3sito (o Evolution tenta
4370
+ -- 10 vezes em ~50 min). A idempot\xEAncia do recebimento \xE9 este \xEDndice.
4371
+ CREATE UNIQUE INDEX IF NOT EXISTS plg_conversations_campaign_targets_provider_msg
4372
+ ON public.plg_conversations_campaign_targets (tenant_id, provider_message_id)
4373
+ WHERE provider_message_id IS NOT NULL;
4374
+
4375
+ CREATE INDEX IF NOT EXISTS plg_conversations_campaign_targets_pending
4376
+ ON public.plg_conversations_campaign_targets (campaign_id)
4377
+ WHERE state IN ('queued', 'sending');
4378
+
4379
+ -- O ceifeiro: linhas cujo arrendamento venceu voltam para a fila.
4380
+ CREATE INDEX IF NOT EXISTS plg_conversations_campaign_targets_reaper
4381
+ ON public.plg_conversations_campaign_targets (claim_expires_at)
4382
+ WHERE state = 'sending';
4383
+
4384
+ ALTER TABLE public.plg_conversations_campaign_targets ENABLE ROW LEVEL SECURITY;
4385
+ ALTER TABLE public.plg_conversations_campaign_targets FORCE ROW LEVEL SECURITY;
4386
+ DROP POLICY IF EXISTS plg_conversations_campaign_targets_rw ON public.plg_conversations_campaign_targets;
4387
+ CREATE POLICY plg_conversations_campaign_targets_rw ON public.plg_conversations_campaign_targets
4388
+ FOR ALL TO authenticated
4389
+ USING (tenant_id IN (SELECT public.user_tenant_ids()))
4390
+ WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
4391
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_conversations_campaign_targets TO authenticated, service_role;
4392
+
4393
+ -- \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
4394
+ --
4395
+ -- Mora no canal e n\xE3o na campanha: o limite \xE9 do N\xDAMERO, e duas campanhas no
4396
+ -- mesmo n\xFAmero somam. Guardar na campanha deixaria cada uma achar que tem a
4397
+ -- cota inteira.
4398
+ ALTER TABLE public.plg_conversations_channels
4399
+ ADD COLUMN IF NOT EXISTS pacing jsonb NOT NULL DEFAULT '{}'::jsonb,
4400
+ ADD COLUMN IF NOT EXISTS warmup_day integer,
4401
+ ADD COLUMN IF NOT EXISTS warmup_started_at timestamptz,
4402
+ ADD COLUMN IF NOT EXISTS quality text,
4403
+ ADD COLUMN IF NOT EXISTS quality_at timestamptz;
4404
+
4405
+ COMMENT ON COLUMN public.plg_conversations_channels.pacing IS
4406
+ '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).';
4407
+
4408
+ -- Os padr\xF5es, por provedor. N\xFAmeros da pesquisa; o coment\xE1rio diz a
4409
+ -- proced\xEAncia de cada um, porque "por que 20?" \xE9 a pergunta que sempre volta.
4410
+ CREATE OR REPLACE FUNCTION public.conversations_default_pacing(p_provider text)
4411
+ RETURNS jsonb LANGUAGE sql IMMUTABLE AS $$
4412
+ SELECT CASE p_provider
4413
+ -- Oficial. O teto real \xE9 o tier da Meta (250/2.000/10.000/100.000), que a
4414
+ -- pr\xF3pria API informa por n\xFAmero \u2014 ent\xE3o aqui s\xF3 entra o que a Meta N\xC3O
4415
+ -- controla: o ritmo instant\xE2neo. 1 msg / 6 s para o MESMO destinat\xE1rio \xE9
4416
+ -- documentado; 60/min \xE9 75% do teto de 80/s por seguran\xE7a.
4417
+ WHEN 'tyxter' THEN jsonb_build_object(
4418
+ 'per_minute', 60, 'per_hour', 1000, 'per_day', null,
4419
+ 'delay_seconds', jsonb_build_array(1, 3),
4420
+ 'same_recipient_seconds', 6,
4421
+ 'hours', jsonb_build_object('start', '09:00', 'end', '18:00', 'weekdays_only', true))
4422
+ -- N\xE3o oficial. Aqui n\xE3o h\xE1 teto do provedor \u2014 h\xE1 banimento. Os n\xFAmeros s\xE3o
4423
+ -- a moda de treze rampas brasileiras publicadas: 10-30/dia na primeira
4424
+ -- semana, 200-300/dia no regime. Atraso ALEAT\xD3RIO entre 15 e 45 s porque
4425
+ -- intervalo fixo \xE9 assinatura de rob\xF4.
4426
+ WHEN 'evolution' THEN jsonb_build_object(
4427
+ 'per_minute', 12, 'per_hour', 300, 'per_day', 250,
4428
+ 'delay_seconds', jsonb_build_array(15, 45),
4429
+ 'same_recipient_seconds', 6,
4430
+ 'batch_size', 50, 'batch_cooldown_minutes', 12,
4431
+ 'hours', jsonb_build_object('start', '09:00', 'end', '18:00', 'weekdays_only', true))
4432
+ ELSE '{}'::jsonb END
4433
+ $$;
4434
+
4435
+ COMMENT ON FUNCTION public.conversations_default_pacing(text) IS
4436
+ '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).';
4437
+
4438
+ -- A rampa de aquecimento, em dias. Pr\xE1tica de comunidade, edit\xE1vel \u2014 e a
4439
+ -- fun\xE7\xE3o existe para que o padr\xE3o seja UM lugar e n\xE3o uma constante copiada.
4440
+ CREATE OR REPLACE FUNCTION public.conversations_warmup_allowance(p_day integer)
4441
+ RETURNS integer LANGUAGE sql IMMUTABLE AS $$
4442
+ SELECT CASE
4443
+ WHEN p_day IS NULL THEN NULL -- n\xFAmero sem aquecimento declarado: sem teto extra
4444
+ WHEN p_day <= 3 THEN 20
4445
+ WHEN p_day <= 7 THEN 50
4446
+ WHEN p_day <= 14 THEN 100
4447
+ WHEN p_day <= 21 THEN 200
4448
+ ELSE 300
4449
+ END
4450
+ $$;
4451
+
4452
+ COMMENT ON FUNCTION public.conversations_warmup_allowance(integer) IS
4453
+ '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).';
4454
+
4455
+ -- \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
4456
+ DROP VIEW IF EXISTS public.v_conversations_campaign_progress;
4457
+ CREATE VIEW public.v_conversations_campaign_progress
4458
+ WITH (security_invoker = true) AS
4459
+ SELECT c.id AS campaign_id,
4460
+ c.tenant_id,
4461
+ c.name,
4462
+ c.status,
4463
+ c.paused_reason,
4464
+ c.provider,
4465
+ count(t.id) AS total,
4466
+ count(*) FILTER (WHERE t.state = 'queued') AS queued,
4467
+ count(*) FILTER (WHERE t.state = 'sending') AS sending,
4468
+ count(*) FILTER (WHERE t.state IN ('sent','delivered','read','replied')) AS sent,
4469
+ count(*) FILTER (WHERE t.state = 'delivered') AS delivered,
4470
+ count(*) FILTER (WHERE t.state = 'read') AS read,
4471
+ count(*) FILTER (WHERE t.state = 'replied') AS replied,
4472
+ count(*) FILTER (WHERE t.state = 'failed') AS failed,
4473
+ count(*) FILTER (WHERE t.state = 'skipped') AS skipped,
4474
+ -- A taxa de resposta \xE9 o sinal que decide se o n\xFAmero sobrevive: a
4475
+ -- pr\xE1tica diz que abaixo de 30% o risco de banimento sobe. Fica na
4476
+ -- view porque \xE9 o n\xFAmero que precisa estar na tela, n\xE3o num relat\xF3rio.
4477
+ CASE WHEN count(*) FILTER (WHERE t.state IN ('sent','delivered','read','replied')) > 0
4478
+ THEN round(100.0 * count(*) FILTER (WHERE t.state = 'replied')
4479
+ / count(*) FILTER (WHERE t.state IN ('sent','delivered','read','replied')), 1)
4480
+ END AS reply_rate,
4481
+ min(t.sent_at) AS first_sent_at,
4482
+ max(t.sent_at) AS last_sent_at
4483
+ FROM public.plg_conversations_campaigns c
4484
+ LEFT JOIN public.plg_conversations_campaign_targets t ON t.campaign_id = c.id
4485
+ WHERE c.tenant_id IN (SELECT public.user_tenant_ids())
4486
+ GROUP BY c.id, c.tenant_id, c.name, c.status, c.paused_reason, c.provider;
4487
+
4488
+ GRANT SELECT ON public.v_conversations_campaign_progress TO authenticated;
4489
+
4490
+ -- \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
4491
+ --
4492
+ -- \`FOR UPDATE SKIP LOCKED\` \xE9 o que deixa dois disparadores trabalharem a mesma
4493
+ -- campanha sem pisar um no outro, e sem advisory lock. O arrendamento de cinco
4494
+ -- minutos \xE9 o que devolve a linha quando um deles morre no meio.
4495
+ --
4496
+ -- A supress\xE3o \xE9 conferida AQUI, na mesma transa\xE7\xE3o que arrenda. Conferir antes
4497
+ -- e mandar depois deixa uma janela em que algu\xE9m pede para sair e recebe assim
4498
+ -- mesmo \u2014 e \xE9 justamente essa mensagem que vira den\xFAncia.
4499
+ CREATE OR REPLACE FUNCTION public.conversations_claim_targets(
4500
+ p_campaign uuid, p_limit integer DEFAULT 10
4501
+ ) RETURNS TABLE (
4502
+ id uuid, phone_e164 text, person_id uuid, variables jsonb, claim_token uuid
4503
+ )
4504
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
4505
+ AS $$
4506
+ DECLARE v_token uuid := gen_random_uuid(); v_tenant uuid;
4507
+ BEGIN
4508
+ SELECT c.tenant_id INTO v_tenant
4509
+ FROM public.plg_conversations_campaigns c WHERE c.id = p_campaign;
4510
+ IF v_tenant IS NULL THEN RAISE EXCEPTION 'campanha n\xE3o encontrada'; END IF;
4511
+
4512
+ -- TR\xCAS COMANDOS, E N\xC3O UM. A primeira vers\xE3o fazia o ceifeiro, a supress\xE3o e
4513
+ -- o arrendamento em CTEs do mesmo UPDATE, e isso \xE9 uma armadilha: dentro de
4514
+ -- um comando s\xF3, todas as CTEs enxergam o MESMO instant\xE2neo da tabela, e uma
4515
+ -- linha atualizada por duas delas recebe s\xF3 a primeira \u2014 em sil\xEAncio. O
4516
+ -- suprimido vinha marcado E arrendado, e qual dos dois vencia dependia do
4517
+ -- plano que o Postgres escolhesse naquele dia.
4518
+ --
4519
+ -- Separado, cada passo enxerga o resultado do anterior. \xC9 mais lento por um
4520
+ -- par de milissegundos e \xE9 a diferen\xE7a entre uma regra e uma coincid\xEAncia.
4521
+
4522
+ -- 1. O ceifeiro. Quem venceu o arrendamento volta para a fila, sen\xE3o uma
4523
+ -- campanha inteira fica presa num processo que morreu.
4524
+ UPDATE public.plg_conversations_campaign_targets t
4525
+ SET state = 'queued', claim_token = NULL, claim_expires_at = NULL
4526
+ WHERE t.campaign_id = p_campaign AND t.state = 'sending'
4527
+ AND t.claim_expires_at < now();
4528
+
4529
+ -- 2. Quem pediu para sair NUNCA entra num lote. A linha fica marcada em vez
4530
+ -- de sumir: \xE9 assim que se prova que a decis\xE3o foi deliberada.
4531
+ UPDATE public.plg_conversations_campaign_targets t
4532
+ SET state = 'skipped', skip_reason = 'suppressed'
4533
+ WHERE t.campaign_id = p_campaign AND t.state = 'queued'
4534
+ AND EXISTS (SELECT 1 FROM public.plg_conversations_optouts o
4535
+ WHERE o.tenant_id = t.tenant_id
4536
+ AND o.phone_e164 = t.phone_e164
4537
+ AND o.active);
4538
+
4539
+ -- 3. S\xF3 ent\xE3o arrenda. \`SKIP LOCKED\` deixa dois disparadores trabalharem a
4540
+ -- mesma campanha sem pisar um no outro, e sem advisory lock.
4541
+ RETURN QUERY
4542
+ WITH escolhidos AS (
4543
+ SELECT t.id FROM public.plg_conversations_campaign_targets t
4544
+ WHERE t.campaign_id = p_campaign AND t.state = 'queued'
4545
+ ORDER BY t.queued_at
4546
+ LIMIT greatest(1, least(coalesce(p_limit, 10), 100))
4547
+ FOR UPDATE SKIP LOCKED
4548
+ )
4549
+ UPDATE public.plg_conversations_campaign_targets t
4550
+ SET state = 'sending', claim_token = v_token,
4551
+ claim_expires_at = now() + interval '5 minutes'
4552
+ FROM escolhidos e
4553
+ WHERE t.id = e.id
4554
+ RETURNING t.id, t.phone_e164, t.person_id, t.variables, t.claim_token;
4555
+ END $$;
4556
+
4557
+ REVOKE ALL ON FUNCTION public.conversations_claim_targets(uuid, integer) FROM PUBLIC, anon;
4558
+ GRANT EXECUTE ON FUNCTION public.conversations_claim_targets(uuid, integer) TO service_role;
4559
+ `;
4560
+ var MIGRATION_004_A_TELA_SABE_SE_PODE_FALAR_ANTES_DE_DEIXAR_ESCREVER = `-- ---------------------------------------------------------------------------
4561
+ -- 004_a_tela_sabe_se_pode_falar_antes_de_deixar_escrever.sql \u2014 a janela de 24h,
4562
+ -- como DADO, para a tela responder antes de algu\xE9m digitar.
4563
+ --
4564
+ -- \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
4565
+ --
4566
+ -- Na API oficial do WhatsApp, texto livre s\xF3 sai dentro de 24h contadas da
4567
+ -- \xFAltima mensagem DO CLIENTE. Fora dela a Meta recusa, e a recusa chega como um
4568
+ -- c\xF3digo no log \u2014 n\xE3o na tela de quem escreveu. O atendente digita, aperta
4569
+ -- enviar, v\xEA a mensagem aparecer na conversa, e ela nunca chega.
4570
+ --
4571
+ -- Isso \xE9 pior que um erro: \xE9 uma tela que mente. E some inteiro num provedor
4572
+ -- n\xE3o oficial, onde n\xE3o existe janela nenhuma \u2014 ent\xE3o o MESMO campo de texto
4573
+ -- funciona ou falha calado dependendo de qual provedor est\xE1 ativo.
4574
+ --
4575
+ -- A corre\xE7\xE3o n\xE3o \xE9 um aviso depois. \xC9 a tela saber ANTES: janela aberta, texto
4576
+ -- livre; janela fechada, s\xF3 template, e o campo diz isso em vez de aceitar.
4577
+ --
4578
+ -- \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
4579
+ --
4580
+ -- O CLIENTE, e s\xF3 ele. Mensagem nossa n\xE3o abre nada \u2014 nem template aprovado.
4581
+ -- \xC9 o erro mais comum de quem implementa isso, e \xE9 por isso que o c\xE1lculo
4582
+ -- abaixo olha exclusivamente \`direction = 'inbound'\`.
4583
+ --
4584
+ -- (Origem: developers.facebook.com/documentation/business-messaging/whatsapp \u2014
4585
+ -- "The user messages you about the product. This opens a 24 hour customer
4586
+ -- service window." Cliques em an\xFAncio Click-to-WhatsApp abrem 72h; n\xE3o
4587
+ -- distinguimos ainda, e 24h \xE9 o limite conservador dos dois.)
4588
+ -- ---------------------------------------------------------------------------
4589
+
4590
+ DROP VIEW IF EXISTS public.v_conversations_window;
4591
+ CREATE VIEW public.v_conversations_window
4592
+ WITH (security_invoker = true) AS
4593
+ SELECT c.id AS conversation_id,
4594
+ c.tenant_id,
4595
+ c.channel,
4596
+ c.contact_handle,
4597
+ c.contact_person_id,
4598
+ u.last_inbound_at,
4599
+ -- Quando fecha. Nulo = nunca abriu, que \xE9 o caso de todo lead que
4600
+ -- ainda n\xE3o respondeu \u2014 a maioria de uma lista de feira.
4601
+ (u.last_inbound_at + interval '24 hours') AS window_expires_at,
4602
+ (u.last_inbound_at IS NOT NULL
4603
+ AND u.last_inbound_at > now() - interval '24 hours') AS window_open,
4604
+ -- Quanto falta, em minutos. A tela mostra "faltam 3h" e n\xE3o um
4605
+ -- carimbo de data: quem est\xE1 respondendo quer saber se d\xE1 tempo.
4606
+ CASE WHEN u.last_inbound_at IS NOT NULL
4607
+ THEN greatest(0, floor(extract(epoch FROM
4608
+ (u.last_inbound_at + interval '24 hours') - now()) / 60))::int
4609
+ END AS minutes_left,
4610
+ -- Quem pediu para n\xE3o ser mais contatado. Some da tela antes de
4611
+ -- qualquer janela: n\xE3o importa se est\xE1 aberta.
4612
+ EXISTS (SELECT 1 FROM public.plg_conversations_optouts o
4613
+ WHERE o.tenant_id = c.tenant_id
4614
+ AND o.phone_e164 = regexp_replace(coalesce(c.contact_handle,''), '\\D', '', 'g')
4615
+ AND o.active) AS opted_out
4616
+ FROM public.plg_conversations c
4617
+ LEFT JOIN LATERAL (
4618
+ SELECT max(m.at) AS last_inbound_at
4619
+ FROM public.plg_conversation_messages m
4620
+ WHERE m.conversation_id = c.id
4621
+ AND m.direction = 'inbound'
4622
+ ) u ON true
4623
+ WHERE c.tenant_id IN (SELECT public.user_tenant_ids());
4624
+
4625
+ GRANT SELECT ON public.v_conversations_window TO authenticated;
4626
+
4627
+ COMMENT ON VIEW public.v_conversations_window IS
4628
+ '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).';
4629
+
4630
+ -- O \xEDndice que a LATERAL usa. Sem ele, cada linha da caixa varre as mensagens
4631
+ -- da conversa inteira, e a caixa \xE9 justamente a tela que abre o dia todo.
4632
+ CREATE INDEX IF NOT EXISTS plg_conversation_messages_inbound_at
4633
+ ON public.plg_conversation_messages (conversation_id, at DESC)
4634
+ WHERE direction = 'inbound';
4635
+ `;
2688
4636
  var MIGRATIONS = [
2689
4637
  { 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 }
4638
+ { id: "001_a_caixa_sabe_quem_falou_por_ultimo", sql: MIGRATION_001_A_CAIXA_SABE_QUEM_FALOU_POR_ULTIMO },
4639
+ { id: "002_a_casa_escolhe_por_onde_o_whatsapp_sai", sql: MIGRATION_002_A_CASA_ESCOLHE_POR_ONDE_O_WHATSAPP_SAI },
4640
+ { id: "003_a_campanha_sai_no_ritmo_que_o_numero_aguenta", sql: MIGRATION_003_A_CAMPANHA_SAI_NO_RITMO_QUE_O_NUMERO_AGUENTA },
4641
+ { 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
4642
  ];
2692
4643
 
2693
4644
  // src/index.ts
@@ -2721,8 +4672,12 @@ function createConversationsPlugin(options) {
2721
4672
  contactEntityDef: options?.contactEntityDef
2722
4673
  };
2723
4674
  const dashboardWidgets = createConversationsDashboardWidgets({ store: store2, config });
2724
- const PageComponent = () => React4__default.createElement(ConversationsPage, { store: store2, config });
4675
+ const PageComponent = () => React9__default.createElement(ConversationsPage, { store: store2, config });
2725
4676
  PageComponent.displayName = "ConversationsPage";
4677
+ const CampaignsPageComponent = () => React9__default.createElement(CampaignsView);
4678
+ CampaignsPageComponent.displayName = "ConversationsCampaignsPage";
4679
+ const TemplatesPageComponent = () => React9__default.createElement(TemplatesView);
4680
+ TemplatesPageComponent.displayName = "ConversationsTemplatesPage";
2726
4681
  return {
2727
4682
  id: "conversations",
2728
4683
  defaultAgentRole: "frontdesk",
@@ -2746,9 +4701,41 @@ function createConversationsPlugin(options) {
2746
4701
  route: "/conversations",
2747
4702
  icon: "MessageCircle",
2748
4703
  permission: { feature: "conversations", action: "read" }
4704
+ },
4705
+ {
4706
+ section: options?.navSection ?? "main",
4707
+ position: (options?.navPosition ?? 1) + 1,
4708
+ label: "Campanhas",
4709
+ route: "/conversations/campaigns",
4710
+ icon: "Send",
4711
+ permission: { feature: "conversations", action: "read" }
4712
+ },
4713
+ {
4714
+ section: options?.navSection ?? "main",
4715
+ position: (options?.navPosition ?? 1) + 2,
4716
+ label: "Templates",
4717
+ route: "/conversations/templates",
4718
+ icon: "FileText",
4719
+ permission: { feature: "conversations", action: "read" }
2749
4720
  }
2750
4721
  ],
2751
4722
  routes: [
4723
+ {
4724
+ path: "/conversations/templates",
4725
+ component: TemplatesPageComponent,
4726
+ title: "Templates",
4727
+ parentRoute: "/conversations",
4728
+ permission: { feature: "conversations", action: "read" }
4729
+ },
4730
+ {
4731
+ // Antes da rota-pai: a shell ordena por especificidade, mas declarar
4732
+ // a filha primeiro é o que deixa isso legível para quem ler depois.
4733
+ path: "/conversations/campaigns",
4734
+ component: CampaignsPageComponent,
4735
+ title: "Campanhas",
4736
+ parentRoute: "/conversations",
4737
+ permission: { feature: "conversations", action: "read" }
4738
+ },
2752
4739
  {
2753
4740
  path: "/conversations",
2754
4741
  component: PageComponent,
@@ -2915,6 +4902,6 @@ function createConversationsPlugin(options) {
2915
4902
  };
2916
4903
  }
2917
4904
 
2918
- export { PAYMENT_PREFLIGHT_FUNCTION, TYXTER_CONNECTOR_ID, TYXTER_LATENCY_BUDGET_MS, canOfferPayment, createConversationsPlugin, createMockConversationsProvider, createSupabaseConversationsProvider, isWaitingOnUs, listMessagingChannels, openPaymentSetupSession, readPaymentReadiness, tyxterConnectorDef };
4905
+ 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
4906
  //# sourceMappingURL=index.js.map
2920
4907
  //# sourceMappingURL=index.js.map