feat(crm): WhatsApp só como viewer (anti-bloqueio) + ficha do lead rica#67
Conversation
Muda a estratégia anti-banimento: o CRM deixa de enviar/receber mensagem pela Evolution (o principal vetor de bloqueio do número) e passa a usar o WhatsApp só como "viewer". Conversar de fato vira 1 clique pro wa.me, fora do app. Envio removido de vez: - evolution.ts: removidas as funções de envio de texto/áudio/mídia e o download de mídia; sobrou só o que faz o viewer (perfil nome/foto, verificar número, admin da instância). - crm-mensagens-actions.ts: sem enviarMensagem/enviarAudio; sobrou só marcarLeadComoLido (zera não-lidas ao abrir a ficha). - Thread.tsx (tela de conversa) removido. Webhook 100% passivo (sem chamada de API por mensagem): - crm-inbound.ts: mensagem recebida é só gravada pra alimentar a lista (última interação/prévia/badge) — sem baixar mídia nem buscar foto. - CONTACTS_UPSERT agora auto-cria lead de contato NOVO adicionado no celular (guardas: lote pequeno + fora da janela de sync inicial), pra não inundar o CRM com o despejo da agenda ao conectar. Ficha do lead como tela principal (no lugar da conversa): - FichaContato.tsx: cabeçalho (avatar/nome/telefone sincronizados) + botão "Conversar no WhatsApp" (wa.me) + ficha embutida. - InformacoesContato.tsx enriquecido: Instagram, Facebook e histórico de reuniões (data + anotação, repetível); modo embutido (cabecalho=false). - Migração 20260720: colunas instagram/facebook/reunioes em crm_leads. - excluirLead não mexe mais no WhatsApp (viewer não edita o celular). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughThe CRM now presents selected leads through an enriched contact profile instead of a conversation thread. It adds social links and meeting history, removes outbound and media Evolution operations, and conditionally creates leads from incremental contact updates. ChangesCRM viewer and contact profile transition
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant CRMPage
participant FichaContato
participant InformacoesContato
participant editarInformacoesContatoAction
participant crm_leads
CRMPage->>FichaContato: render selected lead
FichaContato->>InformacoesContato: display contact details
InformacoesContato->>editarInformacoesContatoAction: submit contact fields
editarInformacoesContatoAction->>crm_leads: persist normalized data
editarInformacoesContatoAction-->>InformacoesContato: return save result
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 6
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@components/crm/FichaContato.tsx`:
- Around line 171-179: Update the synchronization flow in the useEffect and the
update-button handler to share a single in-flight guard, setting it before
either automatic or forced sincronizarContatoAction request and clearing it when
the request finishes. Use this guard to disable the button while either request
is pending, preventing concurrent synchronization calls.
- Around line 159-160: Replace the useTransition-based request tracking in
components/crm/FichaContato.tsx lines 159-160 with explicit useState locks, and
wrap the async save/sync/delete/desarquive handlers at lines 330-370 in
try/finally so each lock is set before the request and always cleared afterward.
Apply the same request-lock pattern to the async actions in
components/crm/InformacoesContato.tsx lines 111-129; ensure all affected
controls remain disabled until their awaited server action completes.
In `@lib/crm-inbound.ts`:
- Around line 346-355: Update the crm_instancias query in the contact-event
handling flow to select and require ativo to be true before processing the
instance. Preserve the existing unknown-instance response for missing or
inactive instances, preventing subsequent contact updates or lead creation.
- Around line 446-452: Update the initial-sync gating around conectadoEm and
foraDaJanelaInicial so a missing conectado_em timestamp is not considered
outside the initial window. Require a valid connection timestamp to establish
that the initial sync window has elapsed before ehAdicaoIncremental can permit
automatic creation; preserve the existing time-window and batch-size checks
otherwise.
- Around line 494-516: Update the insert error handling in the lead-creation
flow around the crm_leads insert and errLead. Continue only for duplicate-key
errors where errLead.code is "23505"; for other errors or a missing novo record,
surface the failure instead of silently treating it as processed.
In `@lib/crm-leads-actions.ts`:
- Around line 58-84: Atualize normalizarReunioes para validar datas reais por
meio de conversão e round-trip em UTC, rejeitando valores como 2026-99-99
enquanto preserva o formato YYYY-MM-DD. Ordene as reuniões por data em ordem
decrescente antes de aplicar o limite de 200 entradas.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 74348c2e-8fa8-4084-bc00-13e87a5577a8
📒 Files selected for processing (10)
app/dashboard/crm/page.tsxcomponents/crm/FichaContato.tsxcomponents/crm/InformacoesContato.tsxcomponents/crm/Thread.tsxlib/crm-inbound.tslib/crm-leads-actions.tslib/crm-leads.tslib/crm-mensagens-actions.tslib/evolution.tssupabase/migrations/20260720_crm_fase8_viewer_reunioes_redes.sql
💤 Files with no reviewable changes (1)
- components/crm/Thread.tsx
| const [pendingNome, startNome] = useTransition() | ||
| const [pendingSync, startSync] = useTransition() |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
fd -a '^package\.json$' --exec sh -c \
'echo "== $1 =="; jq "{react: .dependencies.react, next: .dependencies.next}" "$1"' sh {}
rg -n -C3 \
'useTransition|startTransition\s*\(\s*async|startNome|startSync' \
components/crm/InformacoesContato.tsx \
components/crm/FichaContato.tsxRepository: sejaanomalo-web/metas-anomalo
Length of output: 5798
🏁 Script executed:
#!/bin/bash
sed -n '200,310p' components/crm/FichaContato.tsx
printf '\n----\n'
sed -n '344,390p' components/crm/FichaContato.tsx
printf '\n----\n'
sed -n '100,145p' components/crm/InformacoesContato.tsxRepository: sejaanomalo-web/metas-anomalo
Length of output: 6484
🌐 Web query:
React 19 useTransition async callback isPending active across async transition callbacks documentation
💡 Result:
In React 19, the useTransition hook supports async callbacks, commonly referred to as Actions [1][2]. When you pass an async function to the startTransition returned by useTransition, the isPending state is designed to accurately track the duration of the async transition [3][1]. Specifically, the isPending flag becomes true as soon as the transition starts [4] and remains true while the async function (and any state updates scheduled within it) is processing [1][4]. React effectively includes awaited async calls within the transition context, and isPending will switch to false only after the transition work is complete and the final UI state is rendered [3][1][4]. Key technical details to note: 1. Automatic Inclusion: Unlike the standalone startTransition function—which often requires explicit re-wrapping of state updates occurring after an await—the useTransition hook is designed to automatically handle state updates that occur after an await within the provided async callback, ensuring they remain part of the transition [5][6]. 2. isPending Behavior: The isPending flag is intended to provide immediate feedback to the user [4]. It is managed as an urgent state, ensuring that loading indicators (like spinners or disabled button states) can trigger immediately without waiting for the transition's background rendering work to finish [4]. 3. Potential Caveats: While the React 19 API supports this flow natively, developers should be aware that highly frequent urgent updates can potentially interrupt or delay the completion of a transition, which may keep isPending true for longer than expected if the transition is repeatedly abandoned or starved [4]. Additionally, ensuring the hook is correctly utilized in the component lifecycle is important for consistent behavior; some edge cases in early versions were reported where improper placement of the hook could impact state reset [7]. In summary, for standard async operations (such as API calls inside an Action), you can rely on isPending to correctly reflect the loading state throughout the lifecycle of the async task and its subsequent UI updates [3][1].
Citations:
- 1: https://react.dev/blog/2024/12/05/react-19
- 2: https://github.com/facebook/react/blob/ee0855f427832e899767f7659c5289364218ab9e/CHANGELOG.md
- 3: https://react.dev/reference/react/useTransition
- 4: https://subhsen.dev/blog/starttransition-does-not-defer
- 5: docs: update startTransition async guidance for React 19 reactjs/react.dev#8496
- 6: reactjs/react.dev@a502465
- 7: [React 19] useTransition()'s pending state does not go back to false (revision 94eed63c49-20240425) react/react#28923
🌐 Web query:
React 18 useTransition async callback await isPending behavior pending resets before await
💡 Result:
In React 18, the isPending state provided by useTransition does not natively track the entire lifecycle of an asynchronous callback [1][2]. When you pass an async function to startTransition, React only marks the synchronous code executed before the first await as part of the transition [3][4][5]. As a result, isPending will revert to false as soon as the execution hits the first await keyword, rather than waiting for the asynchronous operation to complete [1][6][2]. Additionally, any state updates scheduled after an await are not automatically treated as part of the transition [4][1][5]. To ensure those updates are correctly marked as transitions and to keep the pending state active, you must manually wrap the subsequent state updates in a nested startTransition call [4][7][1]. This behavior is a known limitation in React 18 due to the loss of the asynchronous execution context [1][2]. This was addressed in React 19, where startTransition and useTransition were updated to natively support asynchronous actions, allowing isPending to remain true for the duration of the entire asynchronous operation [6][8][9]. If you are working in a React 18 environment, the manual nested startTransition pattern is the standard workaround [4][7][5].
Citations:
- 1: https://stackoverflow.com/questions/79923964/react-19-usetransition-behaves-weirdly-with-multiple-async-updates-to-state
- 2: Initial (client-only) async actions support react/react#26621
- 3: https://react.dev/reference/react/useTransition
- 4: https://react.dev/reference/react/startTransition
- 5: https://uk.react.dev/reference/react/useTransition
- 6: https://www.rubrik.com/blog/architecture/26/2/async-react-building-non-blocking-uis-with-usetransition-and-useactionstate
- 7: useTransition(): startTransition after await vercel/next.js#79549
- 8: https://blog.openreplay.com/react-19-async-transitions/
- 9: https://github.com/typescript-cheatsheets/react/blob/main/docs/basic/getting-started/concurrent.md
Use an explicit request lock for these async actions.
startTransition(async () => …) on React 18.3.1 won’t keep isPending true through the awaited server action, so these controls can re-enable early and allow duplicate saves/syncs/deletes/desarquives.
components/crm/FichaContato.tsx#L159-L160components/crm/FichaContato.tsx#L330-L370components/crm/InformacoesContato.tsx#L111-L129
Use useState + try/finally for the lock, or upgrade the React/Next stack together.
📍 Affects 2 files
components/crm/FichaContato.tsx#L159-L160(this comment)components/crm/FichaContato.tsx#L330-L370components/crm/InformacoesContato.tsx#L111-L129
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@components/crm/FichaContato.tsx` around lines 159 - 160, Replace the
useTransition-based request tracking in components/crm/FichaContato.tsx lines
159-160 with explicit useState locks, and wrap the async
save/sync/delete/desarquive handlers at lines 330-370 in try/finally so each
lock is set before the request and always cleared afterward. Apply the same
request-lock pattern to the async actions in
components/crm/InformacoesContato.tsx lines 111-129; ensure all affected
controls remain disabled until their awaited server action completes.
| useEffect(() => { | ||
| if (jaTentouRef.current) return | ||
| if (lead.nome && lead.foto_url) return | ||
| jaTentouRef.current = true | ||
| sincronizarContatoAction(lead.id).then((r) => { | ||
| if (r.ok) router.refresh() | ||
| }) | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, [lead.id]) |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Prevent automatic and forced synchronization from overlapping.
The automatic request does not set pendingSync, so the update button stays enabled and can launch a concurrent { forcar: true } request. Use one shared in-flight guard and disable the button for both synchronization paths.
Also applies to: 293-303
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@components/crm/FichaContato.tsx` around lines 171 - 179, Update the
synchronization flow in the useEffect and the update-button handler to share a
single in-flight guard, setting it before either automatic or forced
sincronizarContatoAction request and clearing it when the request finishes. Use
this guard to disable the button while either request is pending, preventing
concurrent synchronization calls.
| const { data: inst } = await db | ||
| .from("crm_instancias") | ||
| .select("id, empresa_slug") | ||
| .select("id, empresa_slug, usuario_id, usuario_nome, conectado_em") | ||
| .eq("instance_name", instanceName) | ||
| .maybeSingle() | ||
| if (!inst) return { ok: true, info: `instancia desconhecida: ${instanceName}` } | ||
| const instanciaId = inst.id as string | ||
| const empresaSlug = inst.empresa_slug as string | ||
| const usuarioId = inst.usuario_id as string | ||
| const usuarioNome = (inst.usuario_nome as string) ?? null |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Reject contact events from inactive instances.
Unlike the message path, this query never checks crm_instancias.ativo. A delayed webhook from an inactive instance can therefore update contacts and create leads.
Proposed fix
const { data: inst } = await db
.from("crm_instancias")
- .select("id, empresa_slug, usuario_id, usuario_nome, conectado_em")
+ .select("id, empresa_slug, usuario_id, usuario_nome, conectado_em, ativo")
.eq("instance_name", instanceName)
.maybeSingle()
- if (!inst) return { ok: true, info: `instancia desconhecida: ${instanceName}` }
+ if (!inst || inst.ativo === false) {
+ return { ok: true, info: `instancia desconhecida/inativa: ${instanceName}` }
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const { data: inst } = await db | |
| .from("crm_instancias") | |
| .select("id, empresa_slug") | |
| .select("id, empresa_slug, usuario_id, usuario_nome, conectado_em") | |
| .eq("instance_name", instanceName) | |
| .maybeSingle() | |
| if (!inst) return { ok: true, info: `instancia desconhecida: ${instanceName}` } | |
| const instanciaId = inst.id as string | |
| const empresaSlug = inst.empresa_slug as string | |
| const usuarioId = inst.usuario_id as string | |
| const usuarioNome = (inst.usuario_nome as string) ?? null | |
| const { data: inst } = await db | |
| .from("crm_instancias") | |
| .select("id, empresa_slug, usuario_id, usuario_nome, conectado_em, ativo") | |
| .eq("instance_name", instanceName) | |
| .maybeSingle() | |
| if (!inst || inst.ativo === false) { | |
| return { ok: true, info: `instancia desconhecida/inativa: ${instanceName}` } | |
| } | |
| const instanciaId = inst.id as string | |
| const empresaSlug = inst.empresa_slug as string | |
| const usuarioId = inst.usuario_id as string | |
| const usuarioNome = (inst.usuario_nome as string) ?? null |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/crm-inbound.ts` around lines 346 - 355, Update the crm_instancias query
in the contact-event handling flow to select and require ativo to be true before
processing the instance. Preserve the existing unknown-instance response for
missing or inactive instances, preventing subsequent contact updates or lead
creation.
| const conectadoEm = inst.conectado_em | ||
| ? new Date(inst.conectado_em as string).getTime() | ||
| : null | ||
| const foraDaJanelaInicial = | ||
| conectadoEm === null || Date.now() - conectadoEm > JANELA_SYNC_INICIAL_MS | ||
| const ehAdicaoIncremental = | ||
| podeCriar && foraDaJanelaInicial && lista.length <= LIMIAR_ADICAO_INCREMENTAL |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Do not treat a missing connection timestamp as outside initial sync.
Line 450 permits automatic creation when conectado_em is null—the exact case where completion of the initial catalog dump cannot be established. Repeated small batches could then populate the CRM unintentionally.
Proposed fix
const foraDaJanelaInicial =
- conectadoEm === null || Date.now() - conectadoEm > JANELA_SYNC_INICIAL_MS
+ conectadoEm !== null &&
+ Number.isFinite(conectadoEm) &&
+ Date.now() - conectadoEm > JANELA_SYNC_INICIAL_MS📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const conectadoEm = inst.conectado_em | |
| ? new Date(inst.conectado_em as string).getTime() | |
| : null | |
| const foraDaJanelaInicial = | |
| conectadoEm === null || Date.now() - conectadoEm > JANELA_SYNC_INICIAL_MS | |
| const ehAdicaoIncremental = | |
| podeCriar && foraDaJanelaInicial && lista.length <= LIMIAR_ADICAO_INCREMENTAL | |
| const foraDaJanelaInicial = | |
| conectadoEm !== null && | |
| Number.isFinite(conectadoEm) && | |
| Date.now() - conectadoEm > JANELA_SYNC_INICIAL_MS |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/crm-inbound.ts` around lines 446 - 452, Update the initial-sync gating
around conectadoEm and foraDaJanelaInicial so a missing conectado_em timestamp
is not considered outside the initial window. Require a valid connection
timestamp to establish that the initial sync window has elapsed before
ehAdicaoIncremental can permit automatic creation; preserve the existing
time-window and batch-size checks otherwise.
| const { data: novo, error: errLead } = await db | ||
| .from("crm_leads") | ||
| .insert({ | ||
| empresa_slug: empresaSlug, | ||
| empresa_nome: empresaNome, | ||
| usuario_id: usuarioId, | ||
| usuario_nome: usuarioNome, | ||
| telefone_e164: l.telefone_e164, | ||
| nome: l.nome, | ||
| // nome_manual=false: veio do WhatsApp, entao sync futuro pode | ||
| // atualizar (so o nome digitado no CRM vira manual). | ||
| nome_manual: false, | ||
| foto_url: l.foto_url, | ||
| origem: "whatsapp", | ||
| etapa_id: etapaInicial?.id ?? null, | ||
| status: "aberto", | ||
| ultima_interacao_em: agora, | ||
| nao_lidas: 0, | ||
| }) | ||
| .select("id") | ||
| .single() | ||
| // 23505 = outra entrega criou em paralelo; ignora (idempotente). | ||
| if (errLead || !novo) continue |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo '--- lib/crm-inbound.ts around target lines ---'
nl -ba lib/crm-inbound.ts | sed -n '430,560p'
echo
echo '--- search for errLead / 23505 / processed response ---'
rg -n 'errLead|23505|ok: false|erro:|processed|continue' lib/crm-inbound.ts
echo
echo '--- search for webhook route / caller references ---'
rg -n 'crm-inbound|crm_leads|webhook|ok: false|erro:' -S .Repository: sejaanomalo-web/metas-anomalo
Length of output: 254
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo '--- lib/crm-inbound.ts around target lines ---'
python3 - <<'PY'
from pathlib import Path
path = Path('lib/crm-inbound.ts')
for i, line in enumerate(path.read_text().splitlines(), 1):
if 430 <= i <= 560:
print(f"{i:4d} {line}")
PY
echo
echo '--- search for errLead / 23505 / processed response ---'
rg -n 'errLead|23505|ok: false|erro:|processed|continue' lib/crm-inbound.ts
echo
echo '--- search for webhook route / caller references ---'
rg -n 'crm-inbound|crm_leads|webhook|ok: false|erro:' -S .Repository: sejaanomalo-web/metas-anomalo
Length of output: 50385
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo '--- processarEventoWebhook section ---'
python3 - <<'PY'
from pathlib import Path
path = Path('lib/crm-inbound.ts')
for i, line in enumerate(path.read_text().splitlines(), 1):
if 611 <= i <= 690:
print(f"{i:4d} {line}")
PY
echo
echo '--- later insert-error handling in lib/crm-inbound.ts ---'
python3 - <<'PY'
from pathlib import Path
path = Path('lib/crm-inbound.ts')
for i, line in enumerate(path.read_text().splitlines(), 1):
if 740 <= i <= 790:
print(f"{i:4d} {line}")
PY
echo
echo '--- callers of processarEventoWebhook ---'
rg -n 'processarEventoWebhook\(' -S .
echo
echo '--- route files mentioning crm-inbound ---'
fd -t f -a 'crm-inbound*' .
rg -n 'processarEventoWebhook|webhook|crm-inbound' app lib pages src .next 2>/dev/nullRepository: sejaanomalo-web/metas-anomalo
Length of output: 10106
🏁 Script executed:
#!/bin/bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
path = Path('app/api/crm/wa/webhook/[segredo]/route.ts')
for i, line in enumerate(path.read_text().splitlines(), 1):
if 1 <= i <= 140:
print(f"{i:4d} {line}")
PYRepository: sejaanomalo-web/metas-anomalo
Length of output: 4055
Only ignore duplicate-key insert errors. if (errLead || !novo) continue treats every insert failure as a successful no-op, so the webhook records the event as processed and drops the lead creation silently. Handle errLead.code === "23505" explicitly and surface the other cases.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/crm-inbound.ts` around lines 494 - 516, Update the insert error handling
in the lead-creation flow around the crm_leads insert and errLead. Continue only
for duplicate-key errors where errLead.code is "23505"; for other errors or a
missing novo record, surface the failure instead of silently treating it as
processed.
| /** Normaliza uma reunião (data YYYY-MM-DD + anotação). Descarta linhas sem | ||
| * data nem anotação. Data inválida vira "" (a UI valida antes; o servidor só | ||
| * não confia). Até 200 reuniões por lead — teto defensivo. */ | ||
| function normalizarReunioes(bruto: string): ReuniaoNota[] | null { | ||
| let parsed: unknown | ||
| try { | ||
| parsed = JSON.parse(bruto || "[]") | ||
| } catch { | ||
| return null | ||
| } | ||
| if (!Array.isArray(parsed)) return null | ||
| return parsed | ||
| .map((r): ReuniaoNota => { | ||
| const item = (r ?? {}) as Record<string, unknown> | ||
| const id = | ||
| String(item.id ?? "").slice(0, 60) || | ||
| `reuniao_${Math.random().toString(36).slice(2)}` | ||
| const dataBruta = String(item.data ?? "").trim().slice(0, 10) | ||
| const data = /^\d{4}-\d{2}-\d{2}$/.test(dataBruta) ? dataBruta : "" | ||
| return { | ||
| id, | ||
| data, | ||
| anotacao: String(item.anotacao ?? "").trim().slice(0, 5000), | ||
| } | ||
| }) | ||
| .filter((r) => r.data || r.anotacao) | ||
| .slice(0, 200) |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
Validate real calendar dates and enforce newest-first ordering.
The regex accepts values such as 2026-99-99, and the returned array is never sorted despite the CrmLeadRow contract. Validate through a UTC date round-trip, then sort descending before applying the 200-entry limit.
Proposed fix
+function dataIsoValida(valor: string): boolean {
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(valor)) return false
+ const data = new Date(`${valor}T00:00:00Z`)
+ return !Number.isNaN(data.getTime()) &&
+ data.toISOString().slice(0, 10) === valor
+}
+
function normalizarReunioes(bruto: string): ReuniaoNota[] | null {
@@
- const data = /^\d{4}-\d{2}-\d{2}$/.test(dataBruta) ? dataBruta : ""
+ const data = dataIsoValida(dataBruta) ? dataBruta : ""
@@
})
.filter((r) => r.data || r.anotacao)
+ .sort((a, b) => b.data.localeCompare(a.data))
.slice(0, 200)
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| /** Normaliza uma reunião (data YYYY-MM-DD + anotação). Descarta linhas sem | |
| * data nem anotação. Data inválida vira "" (a UI valida antes; o servidor só | |
| * não confia). Até 200 reuniões por lead — teto defensivo. */ | |
| function normalizarReunioes(bruto: string): ReuniaoNota[] | null { | |
| let parsed: unknown | |
| try { | |
| parsed = JSON.parse(bruto || "[]") | |
| } catch { | |
| return null | |
| } | |
| if (!Array.isArray(parsed)) return null | |
| return parsed | |
| .map((r): ReuniaoNota => { | |
| const item = (r ?? {}) as Record<string, unknown> | |
| const id = | |
| String(item.id ?? "").slice(0, 60) || | |
| `reuniao_${Math.random().toString(36).slice(2)}` | |
| const dataBruta = String(item.data ?? "").trim().slice(0, 10) | |
| const data = /^\d{4}-\d{2}-\d{2}$/.test(dataBruta) ? dataBruta : "" | |
| return { | |
| id, | |
| data, | |
| anotacao: String(item.anotacao ?? "").trim().slice(0, 5000), | |
| } | |
| }) | |
| .filter((r) => r.data || r.anotacao) | |
| .slice(0, 200) | |
| /** Normaliza uma reunião (data YYYY-MM-DD + anotação). Descarta linhas sem | |
| * data nem anotação. Data inválida vira "" (a UI valida antes; o servidor só | |
| * não confia). Até 200 reuniões por lead — teto defensivo. */ | |
| function dataIsoValida(valor: string): boolean { | |
| if (!/^\d{4}-\d{2}-\d{2}$/.test(valor)) return false | |
| const data = new Date(`${valor}T00:00:00Z`) | |
| return !Number.isNaN(data.getTime()) && | |
| data.toISOString().slice(0, 10) === valor | |
| } | |
| function normalizarReunioes(bruto: string): ReuniaoNota[] | null { | |
| let parsed: unknown | |
| try { | |
| parsed = JSON.parse(bruto || "[]") | |
| } catch { | |
| return null | |
| } | |
| if (!Array.isArray(parsed)) return null | |
| return parsed | |
| .map((r): ReuniaoNota => { | |
| const item = (r ?? {}) as Record<string, unknown> | |
| const id = | |
| String(item.id ?? "").slice(0, 60) || | |
| `reuniao_${Math.random().toString(36).slice(2)}` | |
| const dataBruta = String(item.data ?? "").trim().slice(0, 10) | |
| const data = dataIsoValida(dataBruta) ? dataBruta : "" | |
| return { | |
| id, | |
| data, | |
| anotacao: String(item.anotacao ?? "").trim().slice(0, 5000), | |
| } | |
| }) | |
| .filter((r) => r.data || r.anotacao) | |
| .sort((a, b) => b.data.localeCompare(a.data)) | |
| .slice(0, 200) |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/crm-leads-actions.ts` around lines 58 - 84, Atualize normalizarReunioes
para validar datas reais por meio de conversão e round-trip em UTC, rejeitando
valores como 2026-99-99 enquanto preserva o formato YYYY-MM-DD. Ordene as
reuniões por data em ordem decrescente antes de aplicar o limite de 200
entradas.
Muda a estratégia anti-banimento: o CRM deixa de enviar/receber mensagem pela Evolution (o principal vetor de bloqueio do número) e passa a usar o WhatsApp só como "viewer". Conversar de fato vira 1 clique pro wa.me, fora do app.
Envio removido de vez:
Webhook 100% passivo (sem chamada de API por mensagem):
Ficha do lead como tela principal (no lugar da conversa):
Summary by CodeRabbit
New Features
Changes