Skip to content

feat(crm): Fase 8 — sync bidirecional etapa↔etiqueta, auto-tag de nov…#56

Merged
sejaanomalo-web merged 1 commit into
mainfrom
claude/crm-fase8-sync-kanban-etiquetas
Jul 12, 2026
Merged

feat(crm): Fase 8 — sync bidirecional etapa↔etiqueta, auto-tag de nov…#56
sejaanomalo-web merged 1 commit into
mainfrom
claude/crm-fase8-sync-kanban-etiquetas

Conversation

@sejaanomalo-web

@sejaanomalo-web sejaanomalo-web commented Jul 12, 2026

Copy link
Copy Markdown
Owner

…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.

Summary by CodeRabbit

  • New Features

    • Added automatic CRM stage assignment for new leads, including “Novo Contato” and “Em conversa” workflows.
    • Moving a lead or assigning a stage-linked label now keeps its mirrored label synchronized.
    • Added automatic progression to “Em conversa” after the first inbound or outbound message.
    • Added a “Voltar para conversas” link on the CRM connections page.
  • Style

    • Refined Kanban column colors, spacing, borders, and drag-and-drop visual feedback.

…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.
@vercel

vercel Bot commented Jul 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
metas-anomalo Ready Ready Preview, Comment Jul 12, 2026 6:13pm

@coderabbitai

coderabbitai Bot commented Jul 12, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

CRM 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.

Changes

CRM stage workflows

Layer / File(s) Summary
Stage application and automation helpers
lib/crm-etapas.ts
Adds centralized stage application, mirrored-label synchronization, automatic-stage lookup, and first-message advancement helpers.
Lead creation and message automation
lib/crm-leads-actions.ts, lib/crm-inbound.ts, lib/crm-mensagens-actions.ts
New manual and inbound leads prefer “Novo Contato”, while first inbound, text, and audio messages can advance leads to “Em conversa”.
Stage and label synchronization in CRM actions
lib/crm-atividades-actions.ts, lib/crm-etiquetas-actions.ts, lib/crm-kanban-actions.ts
Activity categorization, stage-linked labels, and Kanban moves use centralized stage and mirrored-label synchronization.
CRM navigation and Kanban presentation
app/dashboard/crm/conexoes/page.tsx, components/crm/Kanban.tsx
Adds a return-to-conversations link and updates Kanban spacing, stage colors, borders, typography, and list padding.

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
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title matches the main CRM changes: bidirectional stage/tag sync and automatic tagging/advancement flow.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/crm-fase8-sync-kanban-etiquetas

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 win

Validate lead ownership before syncing the etiqueta. The lead update can be a no-op for another tenant’s lead_id, but execution still reaches sincronizarEtiquetaComEtapa, which upserts into crm_lead_etiquetas without checking that the lead belongs to usuario.id. Add an explicit SELECT ... 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 win

Delete-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 leadId belongs to usuarioId; it only filters by lead_id on the crm_lead_etiquetas writes (no ownership check against usuarioId). This makes it a shared contract that depends entirely on every caller pre-validating lead ownership (see the moverLeadAction finding in lib/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

📥 Commits

Reviewing files that changed from the base of the PR and between cbb8ba3 and be6f643.

📒 Files selected for processing (9)
  • app/dashboard/crm/conexoes/page.tsx
  • components/crm/Kanban.tsx
  • lib/crm-atividades-actions.ts
  • lib/crm-etapas.ts
  • lib/crm-etiquetas-actions.ts
  • lib/crm-inbound.ts
  • lib/crm-kanban-actions.ts
  • lib/crm-leads-actions.ts
  • lib/crm-mensagens-actions.ts

Comment thread lib/crm-etapas.ts
Comment on lines +138 to +179
/**
* 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)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ 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.

Comment on lines +98 to +114
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 }
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Suggested change
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.

Comment thread lib/crm-inbound.ts
Comment on lines +512 to +527
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
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 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.

Suggested change
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.

@sejaanomalo-web
sejaanomalo-web merged commit aa789f2 into main Jul 12, 2026
3 checks passed
@sejaanomalo-web
sejaanomalo-web deleted the claude/crm-fase8-sync-kanban-etiquetas branch July 12, 2026 18:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant