feat(crm): Fase 7 — criar/editar/excluir contato, sincronização com W…#55
Conversation
…hatsApp do celular Permite criar contato manual pelo CRM (nome, telefone, e-mail, escolhendo o número/empresa), editar nome/e-mail e excluir de vez — mensagens, atividades e etiquetas somem junto (cascade já existente no schema). Sincronização com o WhatsApp do celular, dentro do que o protocolo permite (pesquisado contra a documentação da Evolution API antes de implementar — não existe endpoint de criar/editar/apagar contato remotamente, contato é dado do catálogo do próprio celular): - Criar: o contato existe no CRM na hora; vira conversa de verdade no WhatsApp do celular quando a primeira mensagem é enviada (é assim que o WhatsApp funciona — mandar mensagem sincroniza automaticamente pra todos os dispositivos vinculados). Confere best-effort se o número existe no WhatsApp antes de salvar (aviso, não bloqueia). - Excluir: opção de também arquivar a conversa no celular (o mais perto de "excluir" que a API expõe — WhatsApp não tem "apagar contato" remoto). - Editar: só no CRM mesmo — não existe API pra alterar nome salvo no celular ou perfil de terceiros. - Vice-versa (celular → CRM): chat apagado no celular (evento CHATS_DELETE) arquiva o lead automaticamente em vez de sumir sem explicação — fica recuperável em "Ver arquivados" (link novo na sidebar de Conversas). Revisão adversarial (multi-agente) encontrou e corrigiu 8 bugs antes do commit: etapa padrão de lead novo podia vazar etapa PRIVADA de outro usuário (sem scoping por usuario_id), mensagem de erro de telefone duplicado virava oráculo pra descobrir contato de outro usuário, heurística de DDI sem confirmação visual, edições silenciosamente viravam no-op sem avisar, processamento de CHATS_DELETE sem limite/lote, e o mesmo bug de estado preso no menu (⋮) já corrigido no Kanban reapareceu no menu do contato. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughCRM lead lifecycle support now includes manual creation, editing, deletion, restoration, archived-conversation browsing, Evolution chat archiving, and ChangesCRM lead lifecycle
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant Evolution
participant Webhook
participant CRM
participant Dashboard
Evolution->>Webhook: Send CHATS_DELETE event
Webhook->>CRM: Archive matching leads
CRM->>CRM: Insert realtime ping records
Dashboard->>CRM: Load archived leads and count
CRM-->>Dashboard: Return archived conversation data
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (2)
app/dashboard/crm/page.tsx (1)
59-73: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win
contarLeadsArquivados()runs on every page load, even on Kanban/Calendário tabs where it's unused.The "Arquivados (N)" link and
leadsConversasare only rendered inside theaba === "conversas"branch, butcontarLeadsArquivados()(and conditionallyleadsArquivados) is fetched unconditionally in the top-levelPromise.allregardless ofaba. This adds an unnecessary DB round-trip on every Kanban/Calendário page load.♻️ Proposed fix
- listarLeadsInbox(), - verArquivados ? listarLeadsInbox({ arquivados: true }) : Promise.resolve([]), + listarLeadsInbox(), + aba === "conversas" && verArquivados ? listarLeadsInbox({ arquivados: true }) : Promise.resolve([]), listarInstancias(), listarEtapas(), listarEtiquetas(), listarAtividadesCalendario(inicioJanela, fimJanela), listarProximasAtividades(), listarTiposAtividade(), - contarLeadsArquivados(), + aba === "conversas" ? contarLeadsArquivados() : Promise.resolve(0),🤖 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 `@app/dashboard/crm/page.tsx` around lines 59 - 73, Update the data-loading Promise.all around listarLeadsInbox and contarLeadsArquivados so archived leads and their count are fetched only when aba is "conversas" (and use verArquivados for the archived list). For Kanban/Calendário, resolve unused values without invoking the database functions, while preserving leadsConversas behavior in the Conversas branch.lib/crm-leads-actions.ts (1)
237-250: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winMove the WhatsApp lookup off the response path
This best-effort check still blocks “Criar contato” for up to 10s after the lead is already saved. If the warning can be deferred, run it in
after/unstable_after; otherwise shorten the timeout for this call so the action returns faster.🤖 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 237 - 250, Update the contact-creation flow around verificarNumeroWhatsapp so the best-effort WhatsApp lookup no longer blocks the successful response: defer it using the project’s after/unstable_after mechanism while preserving the warning behavior, or reduce this call’s timeout if deferred execution is unavailable. Keep lead persistence, revalidatePath, and the success return independent of the lookup result.
🤖 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/NovoContato.tsx`:
- Around line 47-71: Update salvar() to surface the successful response’s
r.aviso before closing the popover and navigating away, using the existing
persistent notification or message mechanism so the WhatsApp warning remains
visible to the user. Preserve the current error handling and successful lead
navigation, and ensure the warning is handled before fechar().
In `@components/crm/Thread.tsx`:
- Around line 643-660: Update the ⋮ trigger button in MenuContato to use the
same pending-state guard as the other action buttons by adding
disabled={pending} to the button using alternar. Preserve the existing click
behavior and styling.
In `@lib/crm-inbound.ts`:
- Around line 347-353: Update processarChatsDelete’s crm_instancias lookup to
select and validate the ativo field, returning the existing unknown-instance
result when the instance is inactive, matching the guard used by
messages.upsert; preserve the existing behavior for active instances.
In `@lib/crm-leads-actions.ts`:
- Line 163: Update the create-contact validation around emailValido and the
related field-processing branches to reject malformed e-mail values instead of
silently omitting them. Match editarLeadAction’s existing behavior by returning
the established "E-mail inválido." error response when validation returns null,
while preserving normal processing for valid or intentionally absent e-mail
values.
---
Nitpick comments:
In `@app/dashboard/crm/page.tsx`:
- Around line 59-73: Update the data-loading Promise.all around listarLeadsInbox
and contarLeadsArquivados so archived leads and their count are fetched only
when aba is "conversas" (and use verArquivados for the archived list). For
Kanban/Calendário, resolve unused values without invoking the database
functions, while preserving leadsConversas behavior in the Conversas branch.
In `@lib/crm-leads-actions.ts`:
- Around line 237-250: Update the contact-creation flow around
verificarNumeroWhatsapp so the best-effort WhatsApp lookup no longer blocks the
successful response: defer it using the project’s after/unstable_after mechanism
while preserving the warning behavior, or reduce this call’s timeout if deferred
execution is unavailable. Keep lead persistence, revalidatePath, and the success
return independent of the lookup result.
🪄 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: 854baf75-9c08-4e2c-8e98-3ee38ffc0377
📒 Files selected for processing (8)
app/api/crm/wa/webhook/[segredo]/route.tsapp/dashboard/crm/page.tsxcomponents/crm/NovoContato.tsxcomponents/crm/Thread.tsxlib/crm-inbound.tslib/crm-leads-actions.tslib/crm-leads.tslib/evolution.ts
| function salvar() { | ||
| if (!instanciaId) { | ||
| setErro("Conecte um número em Conexões antes de criar um contato.") | ||
| return | ||
| } | ||
| if (!nome.trim() || !telefone.trim()) { | ||
| setErro("Preencha nome e telefone.") | ||
| return | ||
| } | ||
| startTransition(async () => { | ||
| const fd = new FormData() | ||
| fd.set("instancia_id", instanciaId) | ||
| fd.set("nome", nome.trim()) | ||
| fd.set("telefone", telefone.trim()) | ||
| fd.set("email", email.trim()) | ||
| const r = await criarLeadManualAction(fd) | ||
| if (!r.ok) { | ||
| setErro(r.erro ?? "Erro ao criar contato") | ||
| return | ||
| } | ||
| fechar() | ||
| if (r.id) router.push(`/dashboard/crm?view=conversas&lead=${r.id}`) | ||
| router.refresh() | ||
| }) | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
r.aviso is never surfaced to the user — the WhatsApp-existence warning is silently dropped.
criarLeadManualAction returns an aviso field ("Esse número não parece estar no WhatsApp — confira antes de enviar.") specifically to warn the user best-effort, but salvar() only checks r.ok/r.id and immediately closes the popover and navigates away. The entire verification feature described in the PR (warn before sending to a non-WhatsApp number) has no visible effect for the user.
🐛 Proposed fix
+ const [aviso, setAviso] = useState<string | null>(null)
+
function salvar() {
if (!instanciaId) {
setErro("Conecte um número em Conexões antes de criar um contato.")
return
}
if (!nome.trim() || !telefone.trim()) {
setErro("Preencha nome e telefone.")
return
}
startTransition(async () => {
const fd = new FormData()
fd.set("instancia_id", instanciaId)
fd.set("nome", nome.trim())
fd.set("telefone", telefone.trim())
fd.set("email", email.trim())
const r = await criarLeadManualAction(fd)
if (!r.ok) {
setErro(r.erro ?? "Erro ao criar contato")
return
}
+ if (r.aviso) setAviso(r.aviso)
fechar()
if (r.id) router.push(`/dashboard/crm?view=conversas&lead=${r.id}`)
router.refresh()
})
}aviso would then need to be rendered somewhere persistent (e.g. a toast, or held in the thread view via a query param) since the popover closes right after fechar().
🤖 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/NovoContato.tsx` around lines 47 - 71, Update salvar() to
surface the successful response’s r.aviso before closing the popover and
navigating away, using the existing persistent notification or message mechanism
so the WhatsApp warning remains visible to the user. Preserve the current error
handling and successful lead navigation, and ensure the warning is handled
before fechar().
| return ( | ||
| <div style={{ position: "relative", flexShrink: 0 }}> | ||
| <button | ||
| type="button" | ||
| onClick={alternar} | ||
| title="Opções do contato" | ||
| style={{ | ||
| fontSize: 14, | ||
| color: "var(--text-4)", | ||
| padding: "6px 8px", | ||
| border: "0.5px solid rgba(255,255,255,0.15)", | ||
| borderRadius: 8, | ||
| lineHeight: 1, | ||
| }} | ||
| > | ||
| ⋮ | ||
| </button> | ||
|
|
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Trigger button (⋮) isn't disabled while an action is pending, unlike every other button in this menu.
All action buttons in MenuContato (Editar contato, Salvar, Excluir de vez, Tirar do arquivo, Cancelar) use disabled={pending}, but the "⋮" trigger itself doesn't. If a user re-opens/closes the menu (via alternar() → fechar()) while salvarEdicao/excluir/desarquivar is still in flight, fechar() resets modo to "menu" and clears erro before the pending action settles. If that action then fails, setErro(...) is applied to a menu that's already closed and back in "menu" mode — the error message is silently lost and the user has no idea whether the edit/delete/archive actually happened.
🐛 Proposed fix
<button
type="button"
onClick={alternar}
+ disabled={pending}
title="Opções do contato"📝 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.
| return ( | |
| <div style={{ position: "relative", flexShrink: 0 }}> | |
| <button | |
| type="button" | |
| onClick={alternar} | |
| title="Opções do contato" | |
| style={{ | |
| fontSize: 14, | |
| color: "var(--text-4)", | |
| padding: "6px 8px", | |
| border: "0.5px solid rgba(255,255,255,0.15)", | |
| borderRadius: 8, | |
| lineHeight: 1, | |
| }} | |
| > | |
| ⋮ | |
| </button> | |
| return ( | |
| <div style={{ position: "relative", flexShrink: 0 }}> | |
| <button | |
| type="button" | |
| onClick={alternar} | |
| disabled={pending} | |
| title="Opções do contato" | |
| style={{ | |
| fontSize: 14, | |
| color: "var(--text-4)", | |
| padding: "6px 8px", | |
| border: "0.5px solid rgba(255,255,255,0.15)", | |
| borderRadius: 8, | |
| lineHeight: 1, | |
| }} | |
| > | |
| ⋮ | |
| </button> |
🤖 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/Thread.tsx` around lines 643 - 660, Update the ⋮ trigger
button in MenuContato to use the same pending-state guard as the other action
buttons by adding disabled={pending} to the button using alternar. Preserve the
existing click behavior and styling.
| const { data: inst } = await db | ||
| .from("crm_instancias") | ||
| .select("id, empresa_slug") | ||
| .eq("instance_name", instanceName) | ||
| .maybeSingle() | ||
| if (!inst) return { ok: true, info: `instancia desconhecida: ${instanceName}` } | ||
| const empresaSlug = inst.empresa_slug as string |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Map the target file and nearby symbols.
ast-grep outline lib/crm-inbound.ts --view expanded || true
# Show the relevant ranges with line numbers.
sed -n '320,470p' lib/crm-inbound.ts
# Find the sibling handler and any `ativo` checks in this file.
rg -n "processarEventoWebhook|processarChatsDelete|ativo === false|instance_name|crm_instancias" lib/crm-inbound.tsRepository: sejaanomalo-web/metas-anomalo
Length of output: 7153
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect whether crm_instancias includes an ativo field anywhere in the repo.
rg -n "\bativo\b" .Repository: sejaanomalo-web/metas-anomalo
Length of output: 17821
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Lightweight read-only probe: compare the two webhook branches in lib/crm-inbound.ts.
python3 - <<'PY'
from pathlib import Path
p = Path("lib/crm-inbound.ts")
text = p.read_text()
for needle in [
"processarChatsDelete",
"processarEventoWebhook",
"if (!inst || inst.ativo === false)",
"select(\"id, empresa_slug\")",
"select(\"id, empresa_slug, ativo\")",
]:
print(f"{needle}: {text.find(needle)}")
PYRepository: sejaanomalo-web/metas-anomalo
Length of output: 336
🏁 Script executed:
echo helloRepository: sejaanomalo-web/metas-anomalo
Length of output: 173
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Show the chat-delete handler to understand the impact of processing inactive-instance events.
sed -n '300,380p' lib/crm-inbound.ts
# Show the message-upsert handler's instance guard for direct comparison.
sed -n '420,470p' lib/crm-inbound.tsRepository: sejaanomalo-web/metas-anomalo
Length of output: 5012
Ignore inactive instances in processarChatsDelete
CHATS_DELETE still archives leads for deactivated instances, unlike messages.upsert. Add the same ativo guard here.
🤖 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 347 - 353, Update processarChatsDelete’s
crm_instancias lookup to select and validate the ativo field, returning the
existing unknown-instance result when the instance is inactive, matching the
guard used by messages.upsert; preserve the existing behavior for active
instances.
| const instanciaId = String(formData.get("instancia_id") ?? "").trim() | ||
| const nome = String(formData.get("nome") ?? "").trim().slice(0, 80) | ||
| const telefone = normalizarTelefone(String(formData.get("telefone") ?? "")) | ||
| const email = emailValido(String(formData.get("email") ?? "")) |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Invalid e-mail is silently dropped instead of rejected.
emailValido returns null for a malformed e-mail, and there's no validation branch for it (unlike editarLeadAction, which explicitly returns "E-mail inválido."). A user who mistypes an e-mail while creating a contact gets no feedback — the field is just silently discarded.
🐛 Proposed fix
const instanciaId = String(formData.get("instancia_id") ?? "").trim()
const nome = String(formData.get("nome") ?? "").trim().slice(0, 80)
const telefone = normalizarTelefone(String(formData.get("telefone") ?? ""))
- const email = emailValido(String(formData.get("email") ?? ""))
+ const emailBruto = String(formData.get("email") ?? "").trim()
+ if (emailBruto && !emailValido(emailBruto)) {
+ return { ok: false, erro: "E-mail inválido." }
+ }
+ const email = emailBruto ? emailValido(emailBruto) : null
if (!instanciaId) return { ok: false, erro: "Selecione o número/empresa." }Also applies to: 164-166, 237-250
🤖 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` at line 163, Update the create-contact validation
around emailValido and the related field-processing branches to reject malformed
e-mail values instead of silently omitting them. Match editarLeadAction’s
existing behavior by returning the established "E-mail inválido." error response
when validation returns null, while preserving normal processing for valid or
intentionally absent e-mail values.
…hatsApp do celular
Permite criar contato manual pelo CRM (nome, telefone, e-mail, escolhendo o número/empresa), editar nome/e-mail e excluir de vez — mensagens, atividades e etiquetas somem junto (cascade já existente no schema).
Sincronização com o WhatsApp do celular, dentro do que o protocolo permite (pesquisado contra a documentação da Evolution API antes de implementar — não existe endpoint de criar/editar/apagar contato remotamente, contato é dado do catálogo do próprio celular):
Revisão adversarial (multi-agente) encontrou e corrigiu 8 bugs antes do commit: etapa padrão de lead novo podia vazar etapa PRIVADA de outro usuário (sem scoping por usuario_id), mensagem de erro de telefone duplicado virava oráculo pra descobrir contato de outro usuário, heurística de DDI sem confirmação visual, edições silenciosamente viravam no-op sem avisar, processamento de CHATS_DELETE sem limite/lote, e o mesmo bug de estado preso no menu (⋮) já corrigido no Kanban reapareceu no menu do contato.
Summary by CodeRabbit
Novos Recursos
Melhorias