Files
FRASA-CAS/api-gateway/src/controllers/leads.controller.ts
Consult AS Technology 586498c845 fix(flow): LeadCaptura para oportunidades y apartados en flujo Monday
- convertirLeadEndpoint: crea LeadCaptura OPORTUNIDADES al convertir lead
- escaneoOportunidades: crea LeadCaptura APARTADO al crear apartado
- Mi día: esMio para apartados incluye oportunidadId del usuario
- Flujo completo verificado: Lead → Contactado → Agendo → Aparto
2026-09-07 19:55:56 +00:00

799 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Request, Response } from 'express'
import { z } from 'zod'
import { config } from '../config/index.js'
import { mondayService, type LeadMonday, type MondayService, type OportunidadMonday } from '../services/monday.service.js'
import { moverAGrupoSegunEstado } from '../services/grupos-estado.service.js'
import { automatizacionesService, CLAVE_RR_SKIP, prismaStateStore } from '../services/automatizaciones.service.js'
import { prisma } from '../services/prisma.js'
import { saludMonday } from '../services/salud-monday.service.js'
import { crmLocal, encolarSyncSeguro, esIdLocal } from '../services/crm-local.service.js'
import * as auditoria from '../services/auditoria.service.js'
import { esFalloMonday } from '../services/sync-monday.service.js'
import { MOTIVOS_NO_COMPRA, SUBCATEGORIAS_NO_COMPRA } from './oportunidades.controller.js'
import { AppError } from '../utils/errors.js'
import { NotFoundError } from '../utils/errors.js'
/**
* Captura y acciones del CRM ligero, sobre DOS tableros:
* - Tablero Leads (POST /api/leads): captura rápida de /hoy y captura manual
* desde la UI de Monday. Item ligero (nombre, teléfono, email, origen,
* desarrollo, notas). Los leads NO crean oportunidad.
* - Tablero Oportunidades (POST /api/afluencias): el Alta Afluencia (forma
* completa de /client) — la afluencia ES la oportunidad en etapa
* 'Afluencia', con todo el perfil.
* Las acciones del vendedor (/estado, /descartar) y la búsqueda del picker del
* alta conviven con ambos tableros vía el parámetro `fuente`.
* Los labels se validan/resuelven contra los reales de cada board (los
* dropdown y status de Monday no auto-crean labels al escribir).
*/
// Transiciones que el vendedor puede hacer desde la app Mi día
export const ESTADOS_LEAD_PERMITIDOS = ['CONTACTADO', 'Intento de contacto', 'INTERESADO', 'AGENDA CITA', 'CALIFICADO'] as const
// Traducción de la whitelist (valores que mandan los frontends) a los labels
// reales de la columna status 'Estado' de cada board
const LABEL_ESTADO_LEADS: Record<(typeof ESTADOS_LEAD_PERMITIDOS)[number], string> = {
CONTACTADO: 'Contactado',
'Intento de contacto': 'Intento de contacto',
INTERESADO: 'Interesado',
'AGENDA CITA': 'Agenda Cita',
CALIFICADO: 'Calificado',
}
const LABEL_ESTADO_OPORTUNIDAD: Record<(typeof ESTADOS_LEAD_PERMITIDOS)[number], string> = {
CONTACTADO: 'Contactado',
'Intento de contacto': 'Intento de contacto',
INTERESADO: 'Interesado', // no existe en el board Oportunidades: se rechaza abajo
'AGENDA CITA': 'Agendo Cita',
CALIFICADO: 'Calificado',
}
export type FuenteLead = 'lead' | 'oportunidad'
const fuenteSchema = z.enum(['lead', 'oportunidad']).optional()
/** Captura rápida de lead (tablero Leads): solo los campos ligeros */
const crearLeadSchema = z.object({
nombre: z.string().trim().min(1, 'El nombre es obligatorio'),
telefono: z.string().trim().min(1).optional(),
email: z.string().trim().email('Email inválido').optional(),
origen: z.string().trim().min(1).optional(),
desarrollo: z.string().trim().min(1).optional(),
notas: z.string().trim().min(1).optional(),
})
/** Alta Afluencia (tablero Oportunidades): captura con perfil completo */
const crearAfluenciaSchema = z.object({
nombre: z.string().trim().min(1, 'El nombre es obligatorio'),
telefono: z.string().trim().min(1).optional(),
email: z.string().trim().email('Email inválido').optional(),
origen: z.string().trim().min(1).optional(),
desarrollo: z.string().trim().min(1).optional(),
notas: z.string().trim().min(1).optional(),
// Con oportunidadId (botón "Afluencia" de Mis oportunidades en Mi día): se
// ACTUALIZA esa oportunidad con el perfil capturado en vez de crear una nueva
oportunidadId: z.string().trim().min(1).optional(),
// Campos de calificación de la afluencia (opcionales; los labels fijos del
// front coinciden con los sembrados en Monday, no se validan contra el board)
modelo: z.string().trim().min(1).optional(),
medioContacto: z.string().trim().min(1).optional(),
edad: z.coerce.number().int().min(15).max(120).optional(),
estadoCivil: z.string().trim().min(1).optional(),
tipoEmpleo: z.string().trim().min(1).optional(),
ocupacion: z.string().trim().min(1).optional(),
rangoSalarial: z.string().trim().min(1).optional(),
credito: z.string().trim().min(1).optional(),
tipoEconomia: z.string().trim().min(1).optional(),
califica: z.string().trim().min(1).optional(),
loQuierePara: z.string().trim().min(1).optional(),
})
const cambiarEstadoSchema = z.object({
estado: z.enum(ESTADOS_LEAD_PERMITIDOS),
fecha: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, 'La fecha debe ser YYYY-MM-DD')
.optional(),
// Tablero del item: 'lead' = tablero Leads; 'oportunidad' = Oportunidades
// (default por compat: las afluencias nuevas viven ahí)
fuente: fuenteSchema,
})
const descartarSchema = z.object({
motivo: z.enum(MOTIVOS_NO_COMPRA),
// Subcategoría del catálogo de Monday (debe pertenecer a la categoría elegida)
subcategoria: z.string().trim().min(1).optional(),
comentario: z.string().trim().min(1).optional(),
fuente: fuenteSchema,
}).refine(
(b) => b.subcategoria === undefined || SUBCATEGORIAS_NO_COMPRA[b.motivo].includes(b.subcategoria),
{ message: 'La subcategoría no pertenece a la categoría elegida', path: ['subcategoria'] },
)
// ---------- Búsqueda dual (prellenado del alta de clientes) ----------
const LIMITE_BUSQUEDA_LEADS = 15
/** Minúsculas y sin diacríticos, para comparar nombres/teléfonos con acentos */
const sinAcentos = (s: string): string =>
s
.normalize('NFD')
.replace(/[̀-ͯ]/g, '') // diacríticos U+0300–U+036F
.toLowerCase()
/** Resultado del picker del alta: conviven Leads (captura ligera) y Oportunidades (alta afluencia) */
export interface ResultadoBusquedaLead {
id: string
nombre: string
telefono: string
email: string
estado: string
origen: string
fuente: FuenteLead
/** Desarrollo de interés ('' si no tiene); prellena afluencia/cotizador */
desarrollo: string
/** Modelo de interés ('' si no tiene) */
modeloInteres: string
}
/**
* Filtra por q en nombre/teléfono/email (case-insensitive, sin acentos) en
* AMBOS tableros — Leads y Oportunidades —, ordena por actualización reciente
* y limita a 15 resultados combinados.
*/
export function buscarLeads(leads: LeadMonday[], oportunidades: OportunidadMonday[], q: string): ResultadoBusquedaLead[] {
const needle = sinAcentos(q)
const candidatos = [
...leads.map((l) => ({
id: l.id,
nombre: l.nombre,
telefono: l.telefono,
email: l.email,
estado: l.estado,
origen: l.origen,
desarrollo: l.desarrollo ?? '',
modeloInteres: l.modeloInteres ?? '',
updatedAt: l.updatedAt,
fuente: 'lead' as const,
})),
...oportunidades.map((o) => ({
id: o.id,
nombre: o.nombre,
telefono: o.telefono,
email: o.email,
estado: o.etapa,
origen: o.origen,
desarrollo: o.desarrollo ?? '',
modeloInteres: o.modeloInteres ?? '',
updatedAt: o.updatedAt,
fuente: 'oportunidad' as const,
})),
]
return candidatos
.filter((c) => [c.nombre, c.telefono, c.email].some((campo) => sinAcentos(campo).includes(needle)))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
.slice(0, LIMITE_BUSQUEDA_LEADS)
.map(({ updatedAt: _updatedAt, ...resto }) => resto)
}
type MondayLeads = Pick<
MondayService,
| 'cambiarValorColumna'
| 'crearLead'
| 'crearOportunidadAfluencia'
| 'actualizarOportunidadAfluencia'
| 'crearMotivoNoCompra'
| 'buscarLeadPorTelefono'
| 'buscarOportunidadPorTelefono'
| 'getLeads'
| 'labelsDeColumna'
| 'obtenerNombreItem'
| 'columnaIdPorTitulo'
| 'getItemConGrupo'
| 'moverItemAGrupo'
>
export type ResultadoCrearLead =
| { id: string; oportunidadId: string | null; actualizada?: boolean }
| { duplicado: { id: string; nombre: string } }
/**
* Crea un lead en el tablero Leads (Estado NUEVO + Fecha de hoy; captura
* rápida ligera, SIN oportunidad). Si viene teléfono y ya existe un lead con
* ese número, no crea nada y devuelve { duplicado } (el endpoint → 409).
* Origen y desarrollo se validan contra los labels reales del board Leads.
*/
export async function crearLead(
monday: MondayLeads,
datos: z.infer<typeof crearLeadSchema>,
): Promise<ResultadoCrearLead> {
const boardLeads = config.monday.boards.leads
if (datos.telefono !== undefined) {
const existente = await monday.buscarLeadPorTelefono(datos.telefono)
if (existente) return { duplicado: { id: existente.id, nombre: existente.nombre } }
}
if (datos.origen !== undefined) {
const labels = await monday.labelsDeColumna(boardLeads, 'Origen')
if (!labels.includes(datos.origen)) {
throw new AppError(400, `Origen inválido: "${datos.origen}". Labels válidos: ${labels.join(', ')}`)
}
}
if (datos.desarrollo !== undefined) {
const labels = await monday.labelsDeColumna(boardLeads, 'Desarrollo')
if (!labels.includes(datos.desarrollo)) {
throw new AppError(400, `Desarrollo inválido: "${datos.desarrollo}". Labels válidos: ${labels.join(', ')}`)
}
}
const id = await monday.crearLead({
nombre: datos.nombre,
telefono: datos.telefono,
email: datos.email,
origenLabel: datos.origen,
desarrolloLabel: datos.desarrollo,
notas: datos.notas,
})
return { id, oportunidadId: null }
}
/**
* Crea la afluencia en Monday: UN item en el tablero Oportunidades con Estado
* 'Afluencia' y todo el perfil capturado. Si viene teléfono y ya existe una
* oportunidad con ese número, no crea nada y devuelve { duplicado } (el
* endpoint → 409). Origen (dropdown 'Origen del Lead') y desarrollo (status
* 'Desarrollo') se validan contra los labels reales del board.
* Tras crearla se mueve al grupo que corresponde a su etapa (Monday crea el
* item en el grupo top del tablero, hoy "Perdidos", y el webhook de change no
* dispara en la creación); un fallo ahí solo se loguea.
*/
export async function crearAfluencia(
monday: MondayLeads,
datos: z.infer<typeof crearAfluenciaSchema>,
): Promise<ResultadoCrearLead> {
const boardOportunidades = config.monday.boards.oportunidades
// Con oportunidadId: el formulario venía prellenado desde Mis oportunidades
// (Mi día) → se ACTUALIZA esa oportunidad con el perfil completo y queda en
// etapa 'Afluencia'; no se crea duplicado ni se valida teléfono.
if (datos.oportunidadId !== undefined) {
await monday.actualizarOportunidadAfluencia(datos.oportunidadId, {
nombre: datos.nombre,
telefono: datos.telefono,
email: datos.email,
desarrolloLabel: datos.desarrollo,
origenLabel: datos.origen,
modeloLabel: datos.modelo,
medioContacto: datos.medioContacto,
edad: datos.edad,
estadoCivilLabel: datos.estadoCivil,
tipoEmpleoLabel: datos.tipoEmpleo,
ocupacion: datos.ocupacion,
rangoSalarial: datos.rangoSalarial,
creditoLabel: datos.credito,
tipoEconomiaLabel: datos.tipoEconomia,
calificaLabel: datos.califica,
loQuierePara: datos.loQuierePara,
notas: datos.notas,
})
try {
await moverAGrupoSegunEstado(monday, boardOportunidades, datos.oportunidadId)
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo mover al grupo correcto la oportunidad ${datos.oportunidadId}:`, err)
}
return { id: datos.oportunidadId, oportunidadId: datos.oportunidadId, actualizada: true }
}
if (datos.telefono !== undefined) {
const existente = await monday.buscarOportunidadPorTelefono(datos.telefono)
if (existente) return { duplicado: { id: existente.id, nombre: existente.nombre } }
}
if (datos.origen !== undefined) {
const labels = await monday.labelsDeColumna(boardOportunidades, 'Origen del Lead')
if (!labels.includes(datos.origen)) {
throw new AppError(400, `Origen inválido: "${datos.origen}". Labels válidos: ${labels.join(', ')}`)
}
}
if (datos.desarrollo !== undefined) {
const labels = await monday.labelsDeColumna(boardOportunidades, 'Desarrollo')
if (!labels.includes(datos.desarrollo)) {
throw new AppError(400, `Desarrollo inválido: "${datos.desarrollo}". Labels válidos: ${labels.join(', ')}`)
}
}
const id = await monday.crearOportunidadAfluencia({
nombre: datos.nombre,
telefono: datos.telefono,
email: datos.email,
desarrolloLabel: datos.desarrollo,
origenLabel: datos.origen,
modeloLabel: datos.modelo,
medioContacto: datos.medioContacto,
edad: datos.edad,
estadoCivilLabel: datos.estadoCivil,
tipoEmpleoLabel: datos.tipoEmpleo,
ocupacion: datos.ocupacion,
rangoSalarial: datos.rangoSalarial,
creditoLabel: datos.credito,
tipoEconomiaLabel: datos.tipoEconomia,
calificaLabel: datos.califica,
loQuierePara: datos.loQuierePara,
notas: datos.notas,
})
try {
await moverAGrupoSegunEstado(monday, boardOportunidades, id)
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo mover al grupo correcto la oportunidad ${id}:`, err)
}
return { id, oportunidadId: id }
}
/**
* Cambia el estado del item (whitelist → label real del board correspondiente
* según `fuente`); en AGENDA CITA puede setear la columna Fecha.
*/
export async function cambiarEstadoLead(
monday: MondayLeads,
itemId: string,
estado: (typeof ESTADOS_LEAD_PERMITIDOS)[number],
fecha?: string,
fuente: FuenteLead = 'oportunidad',
): Promise<void> {
const esLead = fuente === 'lead'
// 'Interesado' solo existe en el tablero Leads (no es etapa del funnel)
if (estado === 'INTERESADO' && !esLead) {
throw new AppError(400, 'La etapa Interesado solo aplica a leads del tablero Leads')
}
const boardId = esLead ? config.monday.boards.leads : config.monday.boards.oportunidades
const label = esLead ? LABEL_ESTADO_LEADS[estado] : LABEL_ESTADO_OPORTUNIDAD[estado]
await monday.cambiarValorColumna(boardId, itemId, 'Estado', label)
if (estado === 'AGENDA CITA' && fecha !== undefined) {
await monday.cambiarValorColumna(boardId, itemId, 'Fecha', fecha)
}
}
/**
* Descarte del item según `fuente`:
* - 'oportunidad': Estado 'Perdido' + dropdown Motivo No Compra (label ya
* sembrado; si no existiera se omite) + comentario en COMENTARIOS.
* - 'lead': Estado 'Descartado' + comentario en Notas.
* Ambos registran el motivo (y la subcategoría si viene) en el tablero
* catálogo Motivos No Compra.
*/
export async function descartarLead(
monday: MondayLeads,
itemId: string,
motivo: string,
comentario?: string,
fuente: FuenteLead = 'oportunidad',
subcategoria?: string,
): Promise<string> {
const nombreItem = await monday.obtenerNombreItem(itemId)
if (fuente === 'lead') {
const boardLeads = config.monday.boards.leads
await monday.cambiarValorColumna(boardLeads, itemId, 'Estado', 'Descartado')
if (comentario !== undefined) {
await monday.cambiarValorColumna(boardLeads, itemId, 'Notas', comentario)
}
} else {
const boardOportunidades = config.monday.boards.oportunidades
await monday.cambiarValorColumna(boardOportunidades, itemId, 'Estado', 'Perdido')
if (comentario !== undefined) {
await monday.cambiarValorColumna(boardOportunidades, itemId, 'COMENTARIOS', comentario)
}
const labels = await monday.labelsDeColumna(boardOportunidades, 'Motivo No Compra')
if (labels.includes(motivo)) {
await monday.cambiarValorColumna(boardOportunidades, itemId, 'Motivo No Compra', motivo)
} else {
// eslint-disable-next-line no-console
console.log(`El motivo "${motivo}" no es label del dropdown 'Motivo No Compra'; se omite`)
}
}
return monday.crearMotivoNoCompra({
nombre: `${motivo}${subcategoria !== undefined ? ` / ${subcategoria}` : ''} — ${nombreItem}`,
categoria: motivo,
...(subcategoria !== undefined ? { subcategoria } : {}),
...(comentario !== undefined ? { descripcion: comentario } : {}),
})
}
/**
* Self-assign del capturista con bandera anti-carrera rr:skip (para que el
* webhook create_item no lo reparta por round-robin mientras Monday propaga la
* escritura; si la asignación falla, se apaga y cae al reparto — y queda
* encolada como ASIGNAR_PERSONA para reintentarla cuando Monday vuelva).
* Devuelve cómo quedó la asignación.
*/
async function asignarCapturistaSiAplica(
usuarioId: string | undefined,
itemId: string,
nombre: string,
board: 'leads' | 'oportunidades',
): Promise<'capturista' | 'round-robin'> {
if (usuarioId === undefined) return 'round-robin'
try {
const capturista = await prisma.usuario.findUnique({
where: { id: usuarioId },
select: { mondayUserId: true },
})
if (capturista?.mondayUserId != null) {
await prismaStateStore.set(`${CLAVE_RR_SKIP}${itemId}`, 'capturista')
try {
await automatizacionesService.asignarACapturista(itemId, nombre, capturista.mondayUserId, board)
return 'capturista'
} catch (err) {
await prismaStateStore.set(`${CLAVE_RR_SKIP}${itemId}`, 'round-robin')
// Best-effort: el worker la reintenta al drenar la cola
await encolarSyncSeguro('ASIGNAR_PERSONA', { board, mondayUserId: capturista.mondayUserId }, itemId)
throw err
}
}
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo asignar el item ${itemId} al capturista:`, err)
}
return 'round-robin'
}
/** POST /api/leads { nombre, telefono?, email?, origen?, desarrollo?, notas? } — captura rápida (tablero Leads) */
export async function crearLeadEndpoint(req: Request, res: Response): Promise<void> {
const body = crearLeadSchema.parse(req.body)
const usuarioSub = req.usuarioToken?.sub
// Modo local: Monday caído (flag) → directo a crmLocal; Monday vivo → flujo
// actual con fallback a local si la llamada lanza 502. La respuesta mantiene
// la misma forma en ambos caminos (el front no se entera).
let modoLocal = !saludMonday.estaDisponible()
let resultado: ResultadoCrearLead
if (modoLocal) {
resultado = await crmLocal.crearLead(body, usuarioSub)
} else {
try {
resultado = await crearLead(mondayService, body)
} catch (err) {
if (!esFalloMonday(err)) throw err
modoLocal = true
resultado = await crmLocal.crearLead(body, usuarioSub)
}
}
if ('duplicado' in resultado) {
res.status(409).json({
error: 'Ya existe un lead con ese teléfono',
leadId: resultado.duplicado.id,
nombre: resultado.duplicado.nombre,
})
return
}
if (modoLocal) {
// Sin round-robin en modo local: el capturista queda asignado (su
// ASIGNAR_PERSONA viaja encolada tras el CREATE)
res.status(201).json({
id: resultado.id,
estado: 'NUEVO',
oportunidadId: null,
asignacion: usuarioSub !== undefined ? 'capturista' : 'round-robin',
})
return
}
// Registro local del capturista para "Mis capturas" del panel Mi día (Monday
// no guarda quién dio de alta el lead). Un fallo aquí no rompe la respuesta:
// el lead ya quedó creado en Monday.
if (usuarioSub) {
try {
await prisma.leadCaptura.create({
data: {
leadId: resultado.id,
oportunidadId: null, // los leads ya no generan oportunidad
usuarioId: usuarioSub,
nombreLead: body.nombre,
telefono: body.telefono ?? null,
},
})
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo registrar la captura del lead ${resultado.id}:`, err)
}
}
const asignacion = await asignarCapturistaSiAplica(usuarioSub, resultado.id, body.nombre, 'leads')
res.status(201).json({ id: resultado.id, estado: 'NUEVO', oportunidadId: null, asignacion })
auditoria.registrar(usuarioSub ?? 'sistema', 'CREAR_LEAD', 'lead', resultado.id)
}
/** POST /api/afluencias { nombre, telefono?, ..., perfil completo } — Alta Afluencia (tablero Oportunidades) */
export async function crearAfluenciaEndpoint(req: Request, res: Response): Promise<void> {
const body = crearAfluenciaSchema.parse(req.body)
const usuarioSub = req.usuarioToken?.sub
// Modo local: Monday caído (flag) → crmLocal (crear o actualizar); fallback
// a local tras 502. Misma forma de respuesta en ambos caminos.
let modoLocal = !saludMonday.estaDisponible()
let resultado: ResultadoCrearLead
if (modoLocal) {
resultado = await crmLocal.crearAfluencia(body, usuarioSub)
} else {
try {
resultado = await crearAfluencia(mondayService, body)
} catch (err) {
if (!esFalloMonday(err)) throw err
modoLocal = true
resultado = await crmLocal.crearAfluencia(body, usuarioSub)
}
}
if ('duplicado' in resultado) {
res.status(409).json({
error: 'Ya existe un lead con ese teléfono',
leadId: resultado.duplicado.id,
nombre: resultado.duplicado.nombre,
})
return
}
// Actualización de oportunidad existente (botón "Afluencia" de Mi día): no
// hay captura nueva que registrar ni round-robin que correr
if (resultado.actualizada === true) {
res.json({ id: resultado.id, estado: 'Afluencia', oportunidadId: resultado.oportunidadId, actualizada: true })
return
}
if (modoLocal) {
res.status(201).json({
id: resultado.id,
estado: 'Afluencia',
oportunidadId: resultado.oportunidadId,
asignacion: usuarioSub !== undefined ? 'capturista' : 'round-robin',
})
return
}
// Registro local del capturista para "Mis capturas" del panel Mi día.
// leadId = el id de la oportunidad (la afluencia ES la oportunidad). Un
// fallo aquí no rompe la respuesta: la afluencia ya quedó creada en Monday.
if (usuarioSub) {
try {
await prisma.leadCaptura.create({
data: {
leadId: resultado.id,
oportunidadId: resultado.oportunidadId,
usuarioId: usuarioSub,
nombreLead: body.nombre,
telefono: body.telefono ?? null,
},
})
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo registrar la captura de la afluencia ${resultado.id}:`, err)
}
}
// Auto-mapping leadId = oportunidadId en LeadOportunidad: el alta de
// clientes (moverAApartoConDedup) encuentra así la oportunidad a mover a
// 'Aparto' tanto en ligas históricas como en el modelo nuevo.
try {
await prisma.leadOportunidad.create({
data: { leadId: resultado.id, oportunidadId: resultado.oportunidadId ?? resultado.id },
})
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo guardar el mapping de la oportunidad ${resultado.id}:`, err)
}
const asignacion = await asignarCapturistaSiAplica(usuarioSub, resultado.id, body.nombre, 'oportunidades')
res.status(201).json({ id: resultado.id, estado: 'Afluencia', oportunidadId: resultado.oportunidadId, asignacion })
}
/**
* Cambio de estado en modo local: guarda la etapa (label real del board) en la
* fila local y encola CAMBIO_ETAPA. Con id Monday sin fila local (fallback 502
* sobre items viejos) crea la fila "sombra" para que Mi día lo refleje.
*/
async function cambiarEstadoLocal(
itemId: string,
estado: (typeof ESTADOS_LEAD_PERMITIDOS)[number],
fecha: string | undefined,
fuente: FuenteLead,
usuarioId: string | undefined,
): Promise<void> {
const esLead = fuente === 'lead'
if (estado === 'INTERESADO' && !esLead) {
throw new AppError(400, 'La etapa Interesado solo aplica a leads del tablero Leads')
}
const label = esLead ? LABEL_ESTADO_LEADS[estado] : LABEL_ESTADO_OPORTUNIDAD[estado]
await crmLocal.cambiarEstado(itemId, label, esLead ? 'LEADS' : 'OPORTUNIDADES', {
fecha,
usuarioId,
sombra: !esIdLocal(itemId),
})
}
/** POST /api/leads/:itemId/estado { estado, fecha?, fuente? } */
export async function cambiarEstadoLeadEndpoint(req: Request, res: Response): Promise<void> {
const body = cambiarEstadoSchema.parse(req.body)
const itemId = String(req.params['itemId'])
const fuente = body.fuente ?? 'oportunidad'
// Los ids loc_* siempre van al path local (aún no existen en Monday); con el
// flag caído todo va a local; con Monday vivo, fallback a local tras 502.
if (esIdLocal(itemId) || !saludMonday.estaDisponible()) {
await cambiarEstadoLocal(itemId, body.estado, body.fecha, fuente, req.usuarioToken?.sub)
} else {
try {
await cambiarEstadoLead(mondayService, itemId, body.estado, body.fecha, body.fuente)
} catch (err) {
if (!esFalloMonday(err)) throw err
await cambiarEstadoLocal(itemId, body.estado, body.fecha, fuente, req.usuarioToken?.sub)
}
}
res.json({ itemId, estado: body.estado, ...(body.fecha !== undefined ? { fecha: body.fecha } : {}) })
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'CAMBIAR_ESTADO', 'lead', itemId, body.estado)
}
/** POST /api/leads/:itemId/descartar { motivo, subcategoria?, comentario?, fuente? } */
export async function descartarLeadEndpoint(req: Request, res: Response): Promise<void> {
const body = descartarSchema.parse(req.body)
const itemId = String(req.params['itemId'])
const fuente = body.fuente ?? 'oportunidad'
let motivoItemId: string | null = null
if (esIdLocal(itemId) || !saludMonday.estaDisponible()) {
await crmLocal.descartar(itemId, body.motivo, {
fuente,
subcategoria: body.subcategoria,
comentario: body.comentario,
usuarioId: req.usuarioToken?.sub,
sombra: !esIdLocal(itemId),
})
} else {
try {
motivoItemId = await descartarLead(mondayService, itemId, body.motivo, body.comentario, body.fuente, body.subcategoria)
} catch (err) {
if (!esFalloMonday(err)) throw err
await crmLocal.descartar(itemId, body.motivo, {
fuente,
subcategoria: body.subcategoria,
comentario: body.comentario,
usuarioId: req.usuarioToken?.sub,
sombra: !esIdLocal(itemId),
})
}
}
res.json({ itemId, estado: body.fuente === 'lead' ? 'Descartado' : 'Perdido', motivo: body.motivo, motivoItemId })
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'DESCARTAR_LEAD', 'lead', itemId, body.motivo)
}
// ---------- Conversión lead → oportunidad (botón "Agendo" de Mis leads) ----------
const convertirSchema = z.object({
fecha: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, 'La fecha debe ser YYYY-MM-DD')
.optional(),
})
/**
* Convierte un lead del tablero Leads en oportunidad (botón "Agendo" de Mis
* leads): crea el item en Oportunidades con etapa 'Agendo Cita' y los datos
* del lead (los labels se omiten si no existen en el board destino), asigna
* como Asesor al vendedor del lead (o a quien convierte), marca el lead como
* 'Calificado' (sale de Mis leads) y guarda el mapping LeadOportunidad para
* que el alta de clientes la encuentre. Si el lead ya fue convertido → 409.
*/
export async function convertirLead(
monday: MondayLeads,
itemId: string,
usuarioId?: string,
fecha?: string,
): Promise<{ oportunidadId: string }> {
const existente = await prisma.leadOportunidad.findUnique({ where: { leadId: itemId } })
if (existente) throw new AppError(409, 'El lead ya fue convertido en oportunidad')
const lead = (await monday.getLeads()).find((l) => l.id === itemId)
if (!lead) throw new NotFoundError('Lead no encontrado en el tablero Leads')
const boardOportunidades = config.monday.boards.oportunidades
// Los labels solo se copian si existen en el board destino (dropdown/status
// no auto-crean labels al escribir)
const [labelsOrigen, labelsDesarrollo, labelsModelo] = await Promise.all([
monday.labelsDeColumna(boardOportunidades, 'Origen del Lead'),
monday.labelsDeColumna(boardOportunidades, 'Desarrollo'),
monday.labelsDeColumna(boardOportunidades, 'Modelo de Interés'),
])
const oportunidadId = await monday.crearOportunidadAfluencia({
nombre: lead.nombre,
estadoLabel: 'Agendo Cita',
telefono: lead.telefono || undefined,
email: lead.email || undefined,
origenLabel: lead.origen && labelsOrigen.includes(lead.origen) ? lead.origen : undefined,
desarrolloLabel: lead.desarrollo && labelsDesarrollo.includes(lead.desarrollo) ? lead.desarrollo : undefined,
modeloLabel: lead.modeloInteres && labelsModelo.includes(lead.modeloInteres) ? lead.modeloInteres : undefined,
})
if (fecha !== undefined) {
await monday.cambiarValorColumna(boardOportunidades, oportunidadId, 'Fecha', fecha)
}
// Asesor de la oportunidad: el vendedor del lead, o quien la convierte
let asesorId = lead.vendedorIds[0]
if (asesorId === undefined && usuarioId !== undefined) {
const usuario = await prisma.usuario.findUnique({ where: { id: usuarioId }, select: { mondayUserId: true } })
asesorId = usuario?.mondayUserId ?? undefined
}
if (asesorId !== undefined) {
// Bandera anti-carrera: el webhook create_item no debe round-robinnearla
await prismaStateStore.set(`${CLAVE_RR_SKIP}${oportunidadId}`, 'capturista')
try {
await monday.cambiarValorColumna(boardOportunidades, oportunidadId, 'Asesor', asesorId)
} catch (err) {
await prismaStateStore.set(`${CLAVE_RR_SKIP}${oportunidadId}`, 'round-robin')
throw err
}
}
// El lead queda Calificado (convertido: sale de Mis leads) y ambos items a su grupo
await monday.cambiarValorColumna(config.monday.boards.leads, itemId, 'Estado', 'Calificado')
await moverAGrupoSegunEstado(monday, config.monday.boards.leads, itemId)
await moverAGrupoSegunEstado(monday, boardOportunidades, oportunidadId)
await prisma.leadOportunidad.create({ data: { leadId: itemId, oportunidadId } })
return { oportunidadId }
}
/** POST /api/leads/:itemId/convertir { fecha? } */
export async function convertirLeadEndpoint(req: Request, res: Response): Promise<void> {
const body = convertirSchema.parse(req.body ?? {})
const itemId = String(req.params['itemId'])
const usuarioSub = req.usuarioToken?.sub
// Modo local (id loc_* o Monday caído / fallback 502): la conversión crea la
// oportunidad local en 'Agendo Cita' y encola CONVERSION. Nota: en el
// fallback la fecha de la cita viaja en el CAMBIO_ETAPA posterior del front,
// como en el flujo normal.
if (esIdLocal(itemId) || !saludMonday.estaDisponible()) {
const oportunidad = await crmLocal.convertirLead(itemId, usuarioSub)
res.status(201).json({ leadId: itemId, oportunidadId: oportunidad.leadId, etapa: 'Agendo Cita' })
auditoria.registrar(usuarioSub ?? 'sistema', 'CONVERTIR_LEAD', 'lead', itemId)
return
}
try {
const { oportunidadId } = await convertirLead(mondayService, itemId, usuarioSub, body.fecha)
// Crear LeadCaptura local para la oportunidad (Mi día las necesita)
try {
const leadLocal = await prisma.leadCaptura.findFirst({ where: { leadId: itemId } })
await prisma.leadCaptura.upsert({
where: { leadId: oportunidadId },
update: { board: 'OPORTUNIDADES', etapa: 'Agendo Cita' },
create: {
leadId: oportunidadId,
board: 'OPORTUNIDADES',
etapa: 'Agendo Cita',
nombreLead: leadLocal?.nombreLead ?? '',
telefono: leadLocal?.telefono ?? null,
email: leadLocal?.email ?? null,
usuarioId: usuarioSub ?? 'sistema',
mondayItemId: oportunidadId,
pendienteSync: false,
},
})
} catch { /* best effort */ }
res.status(201).json({ leadId: itemId, oportunidadId, etapa: 'Agendo Cita' })
auditoria.registrar(usuarioSub ?? 'sistema', 'CONVERTIR_LEAD', 'lead', itemId)
} catch (err) {
if (!esFalloMonday(err)) throw err
const oportunidad = await crmLocal.convertirLead(itemId, usuarioSub)
res.status(201).json({ leadId: itemId, oportunidadId: oportunidad.leadId, etapa: 'Agendo Cita' })
auditoria.registrar(usuarioSub ?? 'sistema', 'CONVERTIR_LEAD', 'lead', itemId)
}
}
/**
* GET /api/leads/buscar?q=texto — autocompletado para prellenar el alta de
* clientes. Busca en AMBOS tableros (Leads + Oportunidades); q mínimo 2
* caracteres; devuelve máximo 15, cada uno con su `fuente`.
* Con Monday caído busca en lo local (LeadCaptura + Cliente), misma forma.
*/
export async function buscarLeadsEndpoint(req: Request, res: Response): Promise<void> {
const q = String(req.query['q'] ?? '').trim()
if (q.length < 2) throw new AppError(400, 'La búsqueda requiere al menos 2 caracteres')
if (!saludMonday.estaDisponible()) {
res.json(await crmLocal.buscarLocales(q))
return
}
const [leads, oportunidades] = await Promise.all([mondayService.getLeads(), mondayService.getOportunidades()])
res.json(buscarLeads(leads, oportunidades, q))
}