feat(crm): Fase 8 — sync bidirecional etapa↔etiqueta, auto-tag de nov…#56
Conversation
…o contato/em conversa, destaque visual do Kanban - etapa_id (Kanban) e crm_lead_etiquetas (tags em Conversas) eram dois campos independentes: arrastar card não atualizava a etiqueta, e trocar etiqueta não movia o card. Agora ambas as ações passam por um único par de helpers (sincronizarEtiquetaComEtapa / aplicarFaseAoLead) que mantém os dois em sincronia nos dois sentidos. - Todo lead novo (manual ou via WhatsApp) recebe a etiqueta/etapa "Novo Contato"; a primeira mensagem no chat (inbound ou outbound) avança automaticamente para "Em conversa" — únicas duas transições automáticas, o resto do funil segue manual. - Botão "‹ Voltar às conversas" na tela de Conexões WhatsApp. - Colunas do Kanban ganham cabeçalho e borda tingidos na cor da etapa, pra destacar a divisão entre fases e o fluxo do funil.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughCRM stage handling is centralized and now synchronizes mirrored labels across lead creation, messaging, activities, label assignment, and Kanban movement. The CRM interface also receives navigation and Kanban styling updates. ChangesCRM stage workflows
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Inbound as crm-inbound
participant Messages as crm-mensagens-actions
participant StageHelpers as crm-etapas
participant Leads as crm_leads
participant Labels as crm_lead_etiquetas
Inbound->>StageHelpers: resolve automatic stages
Inbound->>Leads: create lead with initial stage
Messages->>StageHelpers: advance first message
StageHelpers->>Leads: update stage, order, and status
StageHelpers->>Labels: synchronize mirrored stage label
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 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: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
lib/crm-kanban-actions.ts (1)
41-76: 🔒 Security & Privacy | 🔴 Critical | ⚡ Quick winValidate lead ownership before syncing the etiqueta. The lead update can be a no-op for another tenant’s
lead_id, but execution still reachessincronizarEtiquetaComEtapa, which upserts intocrm_lead_etiquetaswithout checking that the lead belongs tousuario.id. Add an explicitSELECT ... WHERE usuario_id = ...and return early if the lead is missing.🤖 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-kanban-actions.ts` around lines 41 - 76, Before calling sincronizarEtiquetaComEtapa in the lead-move flow, explicitly query crm_leads for leadId constrained by usuario_id = usuario.id and return the existing failure response when no matching lead exists. Keep the update and etiqueta synchronization scoped to the validated tenant-owned lead.
🧹 Nitpick comments (1)
lib/crm-etapas.ts (1)
106-136: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick winDelete-then-upsert ordering risks a zero-tag gap on partial failure.
If the final upsert (Line 130-132) fails after the prior delete (Line 122-128) already removed the lead's other stage tags, the lead is left with no mirrored stage tag at all until the next sync — worse than simply being stale. Also note this function trusts the caller to have already validated that
leadIdbelongs tousuarioId; it only filters bylead_idon thecrm_lead_etiquetaswrites (no ownership check againstusuarioId). This makes it a shared contract that depends entirely on every caller pre-validating lead ownership (see themoverLeadActionfinding inlib/crm-kanban-actions.ts, which does not).Consider upserting the new tag first, then deleting the stale ones, to avoid a transient "no tag" state on failure.
♻️ Reorder writes to avoid a zero-tag window
export async function sincronizarEtiquetaComEtapa( db: SupabaseClient, usuarioId: string, leadId: string, etapa: { id: string; nome: string; cor: string | null } ): Promise<void> { const etiquetaId = await sincronizarEtiquetaDaEtapa(db, usuarioId, etapa) if (!etiquetaId) return + const { error: erroUpsert } = await db + .from("crm_lead_etiquetas") + .upsert({ lead_id: leadId, etiqueta_id: etiquetaId }, { onConflict: "lead_id,etiqueta_id" }) + if (erroUpsert) { + console.error("[crm_lead_etiquetas] sincronizarEtiquetaComEtapa falhou", erroUpsert.message) + return + } + const { data: etiquetasDeEtapa } = await db .from("crm_etiquetas") .select("id") .eq("usuario_id", usuarioId) .not("etapa_id", "is", null) .neq("id", etiquetaId) const idsParaRemover = (etiquetasDeEtapa ?? []).map((e) => e.id as string) if (idsParaRemover.length > 0) { await db .from("crm_lead_etiquetas") .delete() .eq("lead_id", leadId) .in("etiqueta_id", idsParaRemover) } - - const { error } = await db - .from("crm_lead_etiquetas") - .upsert({ lead_id: leadId, etiqueta_id: etiquetaId }, { onConflict: "lead_id,etiqueta_id" }) - if (error) { - console.error("[crm_lead_etiquetas] sincronizarEtiquetaComEtapa falhou", error.message) - } }🤖 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-etapas.ts` around lines 106 - 136, Update sincronizarEtiquetaComEtapa to upsert the current stage tag before deleting stale stage tags, preserving the existing cleanup while preventing a failed upsert from leaving no stage tag. Also validate that leadId belongs to usuarioId within this function before either write, so callers such as moverLeadAction cannot modify another user’s lead associations.
🤖 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 `@lib/crm-etapas.ts`:
- Around line 138-179: Update aplicarFaseAoLead to expose whether the crm_leads
update succeeded, preferably by returning a boolean while preserving its
existing best-effort synchronization behavior. In atribuirEtiquetaAction,
inspect that result in the primary stage-assignment branch and return a failure
response when the phase move fails instead of reporting ok: true; update
secondary callers such as criarFollowUpAction to preserve their current
best-effort behavior.
In `@lib/crm-etiquetas-actions.ts`:
- Around line 98-114: Update the etapa lookup in the etiqueta assignment flow
around aplicarFaseAoLead to filter crm_etapas by ativo = true before applying
the stage. Preserve the existing behavior for active stages and the fallback
when no matching etapa is found.
In `@lib/crm-inbound.ts`:
- Around line 512-527: Update the etapaFallback query in the WhatsApp lead flow
to scope results by usuarioId, allowing only global stages or stages owned by
that user via the same usuario_id filter used by criarLeadManualAction. Preserve
the existing active, ordering, and fallback behavior while preventing another
user’s private etapa from being selected.
---
Outside diff comments:
In `@lib/crm-kanban-actions.ts`:
- Around line 41-76: Before calling sincronizarEtiquetaComEtapa in the lead-move
flow, explicitly query crm_leads for leadId constrained by usuario_id =
usuario.id and return the existing failure response when no matching lead
exists. Keep the update and etiqueta synchronization scoped to the validated
tenant-owned lead.
---
Nitpick comments:
In `@lib/crm-etapas.ts`:
- Around line 106-136: Update sincronizarEtiquetaComEtapa to upsert the current
stage tag before deleting stale stage tags, preserving the existing cleanup
while preventing a failed upsert from leaving no stage tag. Also validate that
leadId belongs to usuarioId within this function before either write, so callers
such as moverLeadAction cannot modify another user’s lead associations.
🪄 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: c46da859-25ba-4bd1-a978-6b98a8b69014
📒 Files selected for processing (9)
app/dashboard/crm/conexoes/page.tsxcomponents/crm/Kanban.tsxlib/crm-atividades-actions.tslib/crm-etapas.tslib/crm-etiquetas-actions.tslib/crm-inbound.tslib/crm-kanban-actions.tslib/crm-leads-actions.tslib/crm-mensagens-actions.ts
| /** | ||
| * Move o lead pra `etapa` (fim da coluna) E sincroniza a etiqueta espelhada | ||
| * — é o mesmo efeito de arrastar o card no Kanban. Usado sempre que a | ||
| * mudança de fase é disparada de FORA do drag-and-drop (escolher etiqueta em | ||
| * Conversas, agendar com categoria = nome de etapa, transições automáticas). | ||
| */ | ||
| export async function aplicarFaseAoLead( | ||
| db: SupabaseClient, | ||
| usuarioId: string, | ||
| leadId: string, | ||
| etapa: { id: string; nome: string; tipo: CrmEtapaRow["tipo"]; cor: string | null } | ||
| ): Promise<void> { | ||
| const { data: ultimoNaColuna } = await db | ||
| .from("crm_leads") | ||
| .select("ordem_na_etapa") | ||
| .eq("etapa_id", etapa.id) | ||
| .order("ordem_na_etapa", { ascending: false }) | ||
| .limit(1) | ||
| .maybeSingle() | ||
| const novaOrdem = ((ultimoNaColuna?.ordem_na_etapa as number) ?? 0) + 1000 | ||
|
|
||
| const patch: Record<string, unknown> = { | ||
| etapa_id: etapa.id, | ||
| ordem_na_etapa: novaOrdem, | ||
| updated_at: new Date().toISOString(), | ||
| } | ||
| if (etapa.tipo === "ganho") patch.status = "ganho" | ||
| else if (etapa.tipo === "perdido") patch.status = "perdido" | ||
| else patch.status = "aberto" | ||
|
|
||
| const { error } = await db | ||
| .from("crm_leads") | ||
| .update(patch) | ||
| .eq("id", leadId) | ||
| .eq("usuario_id", usuarioId) | ||
| if (error) { | ||
| console.error("[crm_leads] aplicarFaseAoLead falhou", error.message) | ||
| return | ||
| } | ||
|
|
||
| await sincronizarEtiquetaComEtapa(db, usuarioId, leadId, etapa) | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Silent failure contract can misrepresent success to end users.
aplicarFaseAoLead never throws and never returns a status — it only logs on error (Line 173-176) or when the internal sync fails. Every current caller treats the await as "done" and reports ok: true regardless. This is defensible for callers where the phase move is a secondary/best-effort side effect (e.g. criarFollowUpAction), but in atribuirEtiquetaAction (lib/crm-etiquetas-actions.ts, Lines 104-113) the phase move is the entire purpose of that early-return branch — if the underlying crm_leads update fails, the action still returns { ok: true }, misleading the user into thinking the tag/stage change succeeded.
Consider having aplicarFaseAoLead return a boolean (or throw) so at least the primary-purpose callers can propagate the real outcome.
🤖 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-etapas.ts` around lines 138 - 179, Update aplicarFaseAoLead to expose
whether the crm_leads update succeeded, preferably by returning a boolean while
preserving its existing best-effort synchronization behavior. In
atribuirEtiquetaAction, inspect that result in the primary stage-assignment
branch and return a failure response when the phase move fails instead of
reporting ok: true; update secondary callers such as criarFollowUpAction to
preserve their current best-effort behavior.
| if (etiqueta.etapa_id) { | ||
| const { data: etapa } = await db | ||
| .from("crm_etapas") | ||
| .select("id, nome, tipo, cor") | ||
| .eq("id", etiqueta.etapa_id as string) | ||
| .maybeSingle() | ||
| if (etapa) { | ||
| await aplicarFaseAoLead(db, usuario.id, leadId, { | ||
| id: etapa.id as string, | ||
| nome: etapa.nome as string, | ||
| tipo: etapa.tipo as "aberta" | "ganho" | "perdido", | ||
| cor: (etapa.cor as string) ?? null, | ||
| }) | ||
| revalidarCrm() | ||
| return { ok: true } | ||
| } | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Etapa lookup doesn't filter ativo = true, unlike the equivalent lookup elsewhere.
sincronizarLeadComEtapaDaCategoria (lib/crm-atividades-actions.ts, Line 199) filters etapas by ativo = true before applying them, but this lookup doesn't. If an etiqueta still references a since-deactivated etapa (etapa_id pointing at a stage no longer shown in the Kanban board), assigning that tag will move the lead into a stage that no column renders — effectively hiding the lead from the pipeline view until someone notices and manually reassigns it.
🛡️ Guard against applying an inactive etapa
const { data: etapa } = await db
.from("crm_etapas")
.select("id, nome, tipo, cor")
.eq("id", etiqueta.etapa_id as string)
+ .eq("ativo", true)
.maybeSingle()📝 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.
| if (etiqueta.etapa_id) { | |
| const { data: etapa } = await db | |
| .from("crm_etapas") | |
| .select("id, nome, tipo, cor") | |
| .eq("id", etiqueta.etapa_id as string) | |
| .maybeSingle() | |
| if (etapa) { | |
| await aplicarFaseAoLead(db, usuario.id, leadId, { | |
| id: etapa.id as string, | |
| nome: etapa.nome as string, | |
| tipo: etapa.tipo as "aberta" | "ganho" | "perdido", | |
| cor: (etapa.cor as string) ?? null, | |
| }) | |
| revalidarCrm() | |
| return { ok: true } | |
| } | |
| } | |
| if (etiqueta.etapa_id) { | |
| const { data: etapa } = await db | |
| .from("crm_etapas") | |
| .select("id, nome, tipo, cor") | |
| .eq("id", etiqueta.etapa_id as string) | |
| .eq("ativo", true) | |
| .maybeSingle() | |
| if (etapa) { | |
| await aplicarFaseAoLead(db, usuario.id, leadId, { | |
| id: etapa.id as string, | |
| nome: etapa.nome as string, | |
| tipo: etapa.tipo as "aberta" | "ganho" | "perdido", | |
| cor: (etapa.cor as string) ?? null, | |
| }) | |
| revalidarCrm() | |
| return { ok: true } | |
| } | |
| } |
🤖 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-etiquetas-actions.ts` around lines 98 - 114, Update the etapa lookup
in the etiqueta assignment flow around aplicarFaseAoLead to filter crm_etapas by
ativo = true before applying the stage. Preserve the existing behavior for
active stages and the fallback when no matching etapa is found.
| if (!etapaInicial) { | ||
| const { data: etapaFallback } = await db | ||
| .from("crm_etapas") | ||
| .select("id, nome, cor") | ||
| .eq("ativo", true) | ||
| .order("ordem", { ascending: true }) | ||
| .limit(1) | ||
| .maybeSingle() | ||
| etapaInicial = etapaFallback | ||
| ? { | ||
| id: etapaFallback.id as string, | ||
| nome: etapaFallback.nome as string, | ||
| cor: (etapaFallback.cor as string) ?? null, | ||
| } | ||
| : null | ||
| } |
There was a problem hiding this comment.
🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win
Fallback query leaks another user's private etapa as a new lead's etapa_id.
Unlike the equivalent fallback in criarLeadManualAction (lib/crm-leads-actions.ts, Line 198), this fallback query has no usuario_id scoping — it's missing the .or(\usuario_id.is.null,usuario_id.eq.${usuarioId}`)filter. That sibling function's own comment explains exactly why this matters: without the filter, a PRIVATE etapa belonging to a *different* user (if it has a lowordem) can be selected as etapaFallbackand get written intoetapa_idfor a lead that isn't even theirs — a cross-tenant leak intocrm_leads.etapa_id`.
This path triggers whenever a user has renamed/deleted their "Novo Contato" stage and a new WhatsApp lead comes in — the exact scenario the sibling fix was designed for.
🔒 Apply the same usuario_id scoping as criarLeadManualAction
if (!etapaInicial) {
const { data: etapaFallback } = await db
.from("crm_etapas")
.select("id, nome, cor")
.eq("ativo", true)
+ .or(`usuario_id.is.null,usuario_id.eq.${usuarioId}`)
.order("ordem", { ascending: true })
.limit(1)
.maybeSingle()📝 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.
| if (!etapaInicial) { | |
| const { data: etapaFallback } = await db | |
| .from("crm_etapas") | |
| .select("id, nome, cor") | |
| .eq("ativo", true) | |
| .order("ordem", { ascending: true }) | |
| .limit(1) | |
| .maybeSingle() | |
| etapaInicial = etapaFallback | |
| ? { | |
| id: etapaFallback.id as string, | |
| nome: etapaFallback.nome as string, | |
| cor: (etapaFallback.cor as string) ?? null, | |
| } | |
| : null | |
| } | |
| if (!etapaInicial) { | |
| const { data: etapaFallback } = await db | |
| .from("crm_etapas") | |
| .select("id, nome, cor") | |
| .eq("ativo", true) | |
| .or(`usuario_id.is.null,usuario_id.eq.${usuarioId}`) | |
| .order("ordem", { ascending: true }) | |
| .limit(1) | |
| .maybeSingle() | |
| etapaInicial = etapaFallback | |
| ? { | |
| id: etapaFallback.id as string, | |
| nome: etapaFallback.nome as string, | |
| cor: (etapaFallback.cor as string) ?? null, | |
| } | |
| : 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 512 - 527, Update the etapaFallback query in
the WhatsApp lead flow to scope results by usuarioId, allowing only global
stages or stages owned by that user via the same usuario_id filter used by
criarLeadManualAction. Preserve the existing active, ordering, and fallback
behavior while preventing another user’s private etapa from being selected.
…o contato/em conversa, destaque visual do Kanban
Summary by CodeRabbit
New Features
Style