@@ -15,8 +15,69 @@ import type {
1515} from "@/lib/schemas" ;
1616import 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+
1834type 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+
2081const 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 ( "," ) } )` ,
0 commit comments