Skip to content

Commit 87d102d

Browse files
authored
Merge PR #538 — a busca não derruba a tela, a ocupação do Google não oferece botão morto, e um byte ruim não condena a planilha
Três consertos achados na própria triagem, todos atingindo qualquer instalação: 1. Buscar um nome comum ('ana', 'silva') ou um DDD no Inbox derrubava a tela — erro de servidor, não lista incompleta. É a tela onde quem atende passa o dia. Vírgula no nome buscado quebrava a sintaxe do filtro. 2. O bloco 'Ocupado' vindo do Google aparecia na lista de Próximos com botões Remarcar/Cancelar que davam erro — controle que a tela oferece e o código ignora. 3. Uma planilha com um único byte estranho era decodificada inteira como windows-1252: 500 nomes destruídos, sobrescrevendo o catálogo, com 'importado com sucesso'. A distinção agora é medida — planilha realmente antiga tem um caractere estranho a cada 5; uma normal com uma aspa do Word, um a cada 11 mil.
2 parents e367df8 + 7569b2a commit 87d102d

15 files changed

Lines changed: 1383 additions & 11 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
impacto: nada_mudou
3+
secao: corrigido
4+
titulo: Buscar no Inbox por um nome com vírgula ou parêntese deixa de derrubar a tela
5+
---
6+
7+
Quem tem clientes cadastrados como "Sobrenome, Nome" — que é como boa parte das
8+
agendas importadas vem — não conseguia buscá-los: a tela dava erro em vez de
9+
lista.
10+
11+
E não era preciso ter a vírgula no cadastro. Bastava o atendente digitá-la na
12+
busca.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
impacto: nada_mudou
3+
secao: corrigido
4+
titulo: Buscar um nome comum no Inbox deixa de derrubar a tela
5+
---
6+
7+
Numa base com muitos contatos, buscar um nome comum — "ana", "silva" — fazia o
8+
Inbox **parar de abrir**, com erro de servidor. Buscar por DDD tinha o mesmo
9+
efeito, porque quatro dígitos casam todos os celulares de uma cidade.
10+
11+
Não era lentidão nem lista incompleta: era a tela quebrando, e justamente onde
12+
quem atende passa o dia.
13+
14+
Agora a lista de contatos que casam é cortada pelo tamanho que cabe na consulta.
15+
Numa busca muito ampla o resultado pode não trazer todos — mas a tela **abre**,
16+
e a busca pelo conteúdo da conversa continua rodando ao lado.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
impacto: nada_mudou
3+
secao: corrigido
4+
titulo: O bloco "Ocupado" da agenda do Google sai da lista de próximos, onde os botões não funcionavam
5+
---
6+
7+
Os horários ocupados na sua agenda pessoal do Google apareciam também na lista
8+
**Próximos**, com **Remarcar** e **Cancelar** ligados — como se fossem
9+
compromissos da empresa. Não eram, e os botões não tinham como funcionar:
10+
clicar em Cancelar dava erro e nada acontecia.
11+
12+
Agora esses blocos aparecem **só na grade**, que é onde servem: mostram o
13+
horário tomado, não abrem e não arrastam. A lista de próximos volta a ter só o
14+
que sua equipe pode remarcar ou cancelar de verdade.
15+
16+
O nome do compromisso particular continua não aparecendo em lugar nenhum.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
impacto: nada_mudou
3+
secao: corrigido
4+
titulo: O compromisso marcado aqui passa a aparecer na Agenda do Google — e o que está ocupado lá aparece aqui
5+
---
6+
7+
Quem conectou a Agenda do Google tinha a integração **ligada e sem efeito nenhum**.
8+
Valia nas duas direções, e nada na tela dizia isso.
9+
10+
**Nada saía daqui.** O compromisso era marcado, o sistema tentava criá-lo lá a
11+
cada cinco minutos, e o Google recusava todas as vezes — por um detalhe de
12+
formato. O erro era registrado só como "HTTP 400", sem o motivo que o Google
13+
mandava junto. Por isso a falha durou tanto: dava para ver que não funcionava, e
14+
não dava para saber por quê. Isso nunca funcionou em instalação nenhuma; os
15+
compromissos já marcados sobem na próxima sincronização.
16+
17+
**E o que estava ocupado lá não era desenhado aqui.** O horário já era
18+
respeitado — ninguém conseguia marcar em cima —, mas o bloco não aparecia na
19+
grade. O dono via a agenda vazia e o horário indisponível ao mesmo tempo. Agora o
20+
bloco aparece, marcado como *Ocupado*.
21+
22+
O **nome** do evento particular continua não aparecendo, de propósito: a agenda
23+
conectada é pessoal de quem atende, e esta tela é vista pela gestão.
24+
25+
**Quando o Google recusa o acesso**, a tela deixa de mandar "tente de novo" —
26+
conselho que não funcionaria, porque a causa costuma ser a API do Google Agenda
27+
desligada no projeto do Google Cloud. Agora ela diz onde ligar.
28+
29+
Para quem opera, nada muda no dia a dia.
30+
31+
O conserto é de @Clalber, que diagnosticou os três defeitos e provou a correção
32+
com tráfego real.

.github/workflows/e2e.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,9 @@ jobs:
355355
gatilho-de-etapa.spec.ts gatilho-de-caso.spec.ts
356356
marca-logo.spec.ts
357357
historico-de-captacao.spec.ts
358+
agenda-ocupacao-do-google-na-grade.spec.ts
359+
agenda-ocupacao-do-google-no-historico.spec.ts
360+
followup-publicado-abre-no-construtor.spec.ts
358361
automacao-diz-a-verdade.spec.ts
359362
inbox-quem-manda.spec.ts
360363
acervo-de-conhecimento.spec.ts

app/api/v1/conversations/_handler.ts

Lines changed: 121 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,69 @@ import type {
1515
} from "@/lib/schemas";
1616
import type { Conversation } from "@/lib/types/messaging";
1717

18+
/**
19+
* Prepara o termo digitado para viajar dentro de um `or=` do PostgREST.
20+
*
21+
* Exportada para ser testável: o defeito que ela impede é de SINTAXE, e sintaxe
22+
* se verifica sem subir banco. O comportamento contra o PostgREST de verdade
23+
* está em `tests/e2e/`.
24+
*/
25+
export function termoSeguroParaOr(bruto: string): string {
26+
return bruto
27+
.trim()
28+
// curingas do `ilike` (Postgres)
29+
.replace(/[%_]/g, (m) => `\\${m}`)
30+
// gramática do `or=` (PostgREST) — viram o próprio curinga
31+
.replace(/[,()]/g, "*");
32+
}
33+
1834
type SB = SupabaseClient;
1935

36+
/**
37+
* Quantos contatos a busca do Inbox casa antes de cortar.
38+
*
39+
* Não é um número estético: os ids viajam DENTRO da querystring do PostgREST
40+
* (`contact_id.in.(<uuid>,<uuid>,…)`), e requisição GET tem teto no gateway.
41+
*/
42+
const TETO_DE_CONTATOS_NA_BUSCA = 120;
43+
44+
/**
45+
* O orçamento de bytes que a lista de ids pode ocupar na URL.
46+
*
47+
* O muro real é 8.192 B na linha de requisição (Kong e nginx, ambos no default),
48+
* e a URL leva mais coisa além dos ids: caminho, `select` com todas as
49+
* `SELECT_COLS`, o filtro de organização, o `order`, o `limit` e o próprio
50+
* `ilike` do termo. Medido neste arquivo, com 1 id a URL já tem 892 B — então o
51+
* que sobra para os ids é o resto, e 5.000 B deixa folga confortável para o
52+
* termo de busca crescer sem que ninguém precise voltar aqui.
53+
*
54+
* Cortar por BYTES e não por quantidade é o que faz esta guarda sobreviver a
55+
* uma coluna nova em `SELECT_COLS` ou a um formato de id diferente.
56+
*/
57+
const ORCAMENTO_DE_IDS_NA_URL = 5_000;
58+
59+
/**
60+
* Corta a lista de ids no que cabe no orçamento da URL.
61+
*
62+
* Devolver menos contatos torna a busca INCOMPLETA — o que é ruim — mas devolver
63+
* todos torna a tela QUEBRADA, com `414` virando `500` na cara do operador. Entre
64+
* uma lista pobre e uma tela que não abre, a lista pobre ganha; e a diferença
65+
* aparece porque a busca por conteúdo (`last_message_preview`) continua rodando
66+
* ao lado, sem depender desta lista.
67+
*/
68+
function idsQueCabemNaURL(ids: string[]): string[] {
69+
const cabem: string[] = [];
70+
let bytes = 0;
71+
for (const id of ids) {
72+
// +1 pela vírgula que separa; o último sobra do lado seguro.
73+
const custo = id.length + 1;
74+
if (bytes + custo > ORCAMENTO_DE_IDS_NA_URL) break;
75+
cabem.push(id);
76+
bytes += custo;
77+
}
78+
return cabem;
79+
}
80+
2081
const SELECT_COLS = `
2182
id, organization_id, contact_id, channel_session_id, channel, status,
2283
status_changed_at, assigned_to_user_id, assigned_to_user_name, assignee_kind, assigned_at, last_inbound_at,
@@ -144,7 +205,44 @@ export async function listConversationsHandler(
144205
}
145206

146207
if (q.search) {
147-
const s = q.search.trim().replace(/[%_]/g, (m) => `\\${m}`);
208+
// ─── O TERMO NÃO PODE QUEBRAR A SINTAXE DO `.or()` ────────────────────
209+
//
210+
// Dois escapes diferentes, para dois parsers diferentes, e eles NÃO se
211+
// substituem:
212+
//
213+
// `%` e `_` são curingas do `ilike` (Postgres) — escapados com `\`.
214+
// `,` `(` `)` são a GRAMÁTICA do `or=` (PostgREST) — e para eles o
215+
// PostgREST não oferece escape nenhum dentro de um valor sem aspas.
216+
//
217+
// Medido contra o PostgREST v14.10 do stack local deste repo, buscando um
218+
// contato que existe:
219+
//
220+
// or=(display_name.ilike.*DIAG, 178*,…) → HTTP 400 PGRST100
221+
// "failed to parse logic tree"
222+
// or=(display_name.ilike.*DIAG* 178*,…) → 200, 3 resultados
223+
//
224+
// Ou seja: um cliente cadastrado como "Sobrenome, Nome" — que é como meia
225+
// agenda de CRM é digitada — DERRUBA a busca do Inbox, não devolve lista
226+
// vazia. E a vírgula não precisa estar no banco: basta o atendente digitá-la.
227+
//
228+
// AS DUAS SAÍDAS ÓBVIAS FORAM MEDIDAS E AS DUAS FALHAM:
229+
//
230+
// aspas duplas no valor .... `ilike."*IAG*"` → 0 resultados contra
231+
// `ilike.*IAG*` → 3. Dentro das aspas o `*`
232+
// deixa de ser curinga; consertaria a sintaxe
233+
// e mataria a busca.
234+
// barra invertida .......... `ilike.*I\,AG*` → HTTP 400. O PostgREST não
235+
// tem escape para a vírgula fora de aspas.
236+
//
237+
// O que sobra, e é o que está aqui: trocar o metacaractere pelo PRÓPRIO
238+
// curinga. "Silva, João" vira `*Silva* João*`, que casa "Silva, João" no
239+
// banco — o `%` cobre a vírgula. A busca fica ligeiramente mais larga, e
240+
// essa direção é a certa: o custo é achar um vizinho a mais; o custo do
241+
// outro lado é a tela em branco com 400.
242+
//
243+
// O controle que impede o degenerado está no teste: termo inexistente
244+
// continua devolvendo ZERO. Sem ele, "troque tudo por `*`" passaria.
245+
const s = termoSeguroParaOr(q.search);
148246

149247
// ─── A BUSCA ALCANÇA O CONTATO, NÃO SÓ A ÚLTIMA MENSAGEM ──────────────
150248
//
@@ -179,9 +277,28 @@ export async function listConversationsHandler(
179277
.or(camposDoContato)
180278
// Teto obrigatório: a lista de ids viaja na URL do PostgREST, e uma busca
181279
// por "a" sem limite estoura a requisição.
182-
.limit(200);
183-
184-
const ids = (contatos ?? []).map((c) => (c as { id: string }).id);
280+
//
281+
// ⚠️ 200 ERA ACIMA DO MURO, e o comentário acima descrevia o perigo certo
282+
// com o número errado. Medido com o `postgrest-js` real e as `SELECT_COLS`
283+
// deste arquivo:
284+
//
285+
// ids= 1 → 892 B ids=186 → 8.098 B
286+
// ids=100 → 4.753 B ids=200 → 8.653 B ← acima de 8.192
287+
//
288+
// Kong 2.8.1 — o gateway que a Supabase põe na frente do PostgREST, e o
289+
// mesmo que o stack local deste repo sobe — devolve `414 URI too long` a
290+
// partir de ~187 ids. E o `error` desta consulta vira `500 internal_error`
291+
// no handler, então o Inbox PARA: buscar "ana" ou "silva" numa base de
292+
// milhares de contatos devolvia a tela quebrada, não uma lista pobre.
293+
//
294+
// O teto agora é de BYTES, não de linhas, porque é byte que estoura. O
295+
// número de ids que cabe é consequência, e continua certo se as colunas
296+
// ou o formato do id mudarem.
297+
.limit(TETO_DE_CONTATOS_NA_BUSCA);
298+
299+
const ids = idsQueCabemNaURL(
300+
(contatos ?? []).map((c) => (c as { id: string }).id),
301+
);
185302
if (ids.length > 0) {
186303
query = query.or(
187304
`last_message_preview.ilike.*${s}*,contact_id.in.(${ids.join(",")})`,

app/app/agenda/_client.tsx

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,22 @@ export function AgendaClient({
225225
[isolada, todos],
226226
);
227227

228+
/**
229+
* O que é ACIONÁVEL — o que a lista "Próximos" pode oferecer botão para fazer.
230+
*
231+
* Ocupação vinda do Google fica de fora: ela é bloco de terceiro, o id é de
232+
* `calendar_external_events`, e as rotas de remarcar/cancelar procuram em
233+
* `calendar_appointments`. Ver o comentário longo no `HistoricoDaAgenda`
234+
* abaixo, com o 404 medido.
235+
*
236+
* A GRADE continua recebendo `agendamentos` inteiro — é lá que a ocupação
237+
* precisa aparecer, e é lá que ela já é desenhada inerte.
238+
*/
239+
const agendamentosAcionaveis = React.useMemo(
240+
() => agendamentos.filter((a) => a.origem !== "google_sync"),
241+
[agendamentos],
242+
);
243+
228244
const passo = visao === "mes" ? 30 : visao === "semana" ? 7 : 1;
229245
// O PADRÃO de formato também muda de idioma, não só o locale: em português
230246
// "d 'de' MMMM" tem a preposição escrita à mão dentro do padrão, e em
@@ -617,8 +633,37 @@ export function AgendaClient({
617633
</SheetContent>
618634
</Sheet>
619635

636+
{/*
637+
⚠️ A LISTA DAQUI NÃO É A MESMA DA GRADE, e a diferença é uma linha.
638+
639+
`GradeDaAgenda` conhece `origem` e desenha o bloco do Google inerte
640+
(`disabled`, sem arraste, rótulo "Ocupado"). `HistoricoDaAgenda` NÃO
641+
conhece origem: ela decide o botão por `disabled={!onRemarcar}`, que é
642+
uma prop do componente inteiro e não da linha. Passar a mesma lista aos
643+
dois faz a ocupação do Google chegar em "Próximos" com **Remarcar e
644+
Cancelar habilitados** — e cancelar responde 404, porque o id é de
645+
`calendar_external_events` e a rota procura em `calendar_appointments`.
646+
647+
Medido pela tela em 2026-09-03, na triagem do PR #474:
648+
649+
DELETE /api/v1/agenda/agendamentos
650+
→ 404 {"error":{"code":"not_found","message":"Agendamento não encontrado."}}
651+
toast vermelho aos 1,5s, o painel continua aberto, a linha continua na
652+
lista, e `calendar_external_events.status` segue `confirmed`.
653+
654+
É o "controle decorativo" que o comentário de `GradeDaAgenda` diz que
655+
esta base já pagou uma vez, replantado no componente irmão — e o #474 o
656+
tornou PERMANENTE: antes dele a linha só existia no primeiro frame da
657+
semana corrente (a semente do servidor) e sumia no primeiro refetch.
658+
659+
Filtrar é o conserto certo, e não é escolha estética: bloco anônimo do
660+
Google não é um compromisso NOSSO. Não há o que remarcar, não há o que
661+
cancelar, e "Ocupado · 15:00–16:00" numa lista de próximos atendimentos
662+
não informa nada que a grade — que O DESENHA no horário — já não diga
663+
melhor. A ocupação continua inteira onde ela serve.
664+
*/}
620665
<HistoricoDaAgenda
621-
agendamentos={agendamentos}
666+
agendamentos={agendamentosAcionaveis}
622667
pessoas={pessoas}
623668
agora={new Date()}
624669
className="max-h-[320px]"

lib/contacts/csv.ts

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,33 @@ import { normalizePhoneBR } from "@/lib/webhooks/inbound";
2626
*
2727
* O desempate não é detecção de charset, é uma prova: `TextDecoder("utf-8")` só
2828
* produz U+FFFD quando o byte-stream NÃO é UTF-8 válido. Ausência de U+FFFD é
29-
* prova de que UTF-8 é a leitura certa; presença é prova de que não é.
29+
* prova de que UTF-8 é a leitura certa.
30+
*
31+
* ⚠️ MAS A PRESENÇA NÃO É PROVA DO CONTRÁRIO, e a primeira versão disto tratava
32+
* como se fosse — a decisão era por ARQUIVO sobre um sinal por BYTE, sem
33+
* proporção. Um único byte inválido no meio de um arquivo perfeitamente UTF-8
34+
* (a aspa curva do Word, 0x92, sobra comum de copiar-colar) jogava as 500 linhas
35+
* boas para o `windows-1252`. Medido, com as funções deste arquivo:
36+
*
37+
* arquivo limpo → utf-8 "Ação" corretos=500 mojibake= 0
38+
* + 1 byte 0x92 no meio → windows-1252 "Ação" corretos= 0 mojibake=500
39+
* antes deste arquivo → "Ação" corretos=500, com U+FFFD=1
40+
*
41+
* Ou seja: no caminho do byte solto, a versão anterior a `file.text()` era
42+
* MELHOR — ela corrompia um caractere, não o arquivo. E o `upsert` por
43+
* `(organization_id, codigo)` sobrescreve os nomes bons que já estavam no
44+
* catálogo, com `erros: []` e 200 OK.
45+
*
46+
* A prova certa é a DENSIDADE, porque as duas causas ficam a três ordens de
47+
* grandeza de distância. Medido no mesmo texto de 500 linhas:
48+
*
49+
* latin-1 de verdade (o que este arquivo conserta) → 1 U+FFFD a cada 5 bytes
50+
* UTF-8 com 1 byte inválido → 1 U+FFFD a cada 11.392
51+
* misto: 499 linhas UTF-8 + 1 linha latin-1 → 1 U+FFFD a cada 5.692
52+
*
53+
* `MAX_BYTES_POR_SUBSTITUICAO` fica no meio dessa distância, e é generoso de
54+
* propósito: errar para o lado do UTF-8 corrompe um caractere; errar para o
55+
* outro corrompe o arquivo inteiro. Os dois erros não custam o mesmo.
3056
*
3157
* O `windows-1252` "consegue" ler qualquer byte, então cair nele sem olhar o
3258
* resultado transformaria um .xlsx renomeado em 300 produtos de nome ilegível.
@@ -39,11 +65,28 @@ import { normalizePhoneBR } from "@/lib/webhooks/inbound";
3965
* planilha — é UTF-8 VÁLIDO, não tem U+FFFD nenhum, e passa limpo. É outro
4066
* defeito, com outra evidência.
4167
*/
68+
/**
69+
* A partir de quantos bytes por substituição o arquivo deixa de ser "latin-1" e
70+
* passa a ser "UTF-8 com um byte ruim".
71+
*
72+
* 100 fica entre as duas causas medidas (5 e 5.692 bytes por U+FFFD) com folga
73+
* de mais de uma ordem de grandeza para cada lado — não é um número escolhido
74+
* para caber num caso, é o meio de um vale largo.
75+
*/
76+
const MAX_BYTES_POR_SUBSTITUICAO = 100;
77+
4278
export function decodificarCsv(bytes: ArrayBuffer | Uint8Array): { texto: string } | { erro: string } {
4379
const buf = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
4480

4581
const utf8 = new TextDecoder("utf-8").decode(buf);
46-
if (!utf8.includes("\uFFFD")) return { texto: semBom(utf8) };
82+
const substituicoes = (utf8.match(/\uFFFD/g) ?? []).length;
83+
// Sem nenhuma: UTF-8 válido, e a prova é completa.
84+
if (substituicoes === 0) return { texto: semBom(utf8) };
85+
// Com poucas: é UTF-8 com sujeira pontual, não outro charset. Trocar de
86+
// decoder aqui estragaria o arquivo inteiro para consertar um caractere.
87+
if (buf.byteLength / substituicoes > MAX_BYTES_POR_SUBSTITUICAO) {
88+
return { texto: semBom(utf8) };
89+
}
4790

4891
const latin = new TextDecoder("windows-1252").decode(buf);
4992
// eslint-disable-next-line no-control-regex -- é exatamente o que se procura

0 commit comments

Comments
 (0)