feat(mi-dia): app /hoy + CRM ligero para vendedores
- App Mi día: KPIs, mis actividades, mis leads con acciones rápidas (contactado/intento/agenda cita/descartar con motivo), captura rápida de lead (409 en duplicado por teléfono), vista equipo para coordinadores - API: GET /api/mi-dia/resumen, POST /api/leads, /leads/:id/estado, /leads/:id/descartar; mapeo Usuario.mondayUserId (seed listo para cuando inviten a los vendedores a Monday) - Landing: tarjeta Mi día; tests api 188, mi-dia 8, landing 4
This commit is contained in:
146
api-gateway/src/controllers/leads.controller.ts
Normal file
146
api-gateway/src/controllers/leads.controller.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { config } from '../config/index.js'
|
||||
import { mondayService, type LeadMonday, type MondayService } from '../services/monday.service.js'
|
||||
import { MOTIVOS_NO_COMPRA } from './oportunidades.controller.js'
|
||||
import { AppError } from '../utils/errors.js'
|
||||
|
||||
/**
|
||||
* CRM ligero sobre el tablero Leads: captura rápida y acciones del vendedor.
|
||||
* Los labels de estado/origen son los reales del board (verificados contra
|
||||
* Monday); la whitelist evita escribir labels que el status no tenga dados de alta.
|
||||
*/
|
||||
|
||||
// Transiciones que el vendedor puede hacer desde la app Mi día
|
||||
export const ESTADOS_LEAD_PERMITIDOS = ['CONTACTADO', 'Intento de contacto', 'AGENDA CITA', 'CALIFICADO'] as const
|
||||
|
||||
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(),
|
||||
notas: 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(),
|
||||
})
|
||||
|
||||
const descartarSchema = z.object({
|
||||
motivo: z.enum(MOTIVOS_NO_COMPRA),
|
||||
comentario: z.string().trim().min(1).optional(),
|
||||
})
|
||||
|
||||
type MondayLeads = Pick<
|
||||
MondayService,
|
||||
| 'cambiarValorColumna'
|
||||
| 'crearLead'
|
||||
| 'crearMotivoNoCompra'
|
||||
| 'buscarLeadPorTelefono'
|
||||
| 'labelsDeColumna'
|
||||
| 'obtenerNombreItem'
|
||||
>
|
||||
|
||||
export type ResultadoCrearLead = { id: string } | { duplicado: LeadMonday }
|
||||
|
||||
/**
|
||||
* Crea un lead en Monday (Estado NUEVO). Si viene teléfono y ya existe un lead
|
||||
* con ese número, no crea nada y devuelve { duplicado } (el endpoint → 409).
|
||||
* El origen se valida contra los labels reales de la columna del board.
|
||||
*/
|
||||
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: existente }
|
||||
}
|
||||
|
||||
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(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
const id = await monday.crearLead({
|
||||
nombre: datos.nombre,
|
||||
telefono: datos.telefono,
|
||||
email: datos.email,
|
||||
origenLabel: datos.origen,
|
||||
notas: datos.notas,
|
||||
})
|
||||
return { id }
|
||||
}
|
||||
|
||||
/** Cambia el estado del lead (whitelist) y, en AGENDA CITA, puede setear la Fecha */
|
||||
export async function cambiarEstadoLead(
|
||||
monday: MondayLeads,
|
||||
itemId: string,
|
||||
estado: (typeof ESTADOS_LEAD_PERMITIDOS)[number],
|
||||
fecha?: string,
|
||||
): Promise<void> {
|
||||
const boardLeads = config.monday.boards.leads
|
||||
await monday.cambiarValorColumna(boardLeads, itemId, 'Estado', estado)
|
||||
if (estado === 'AGENDA CITA' && fecha !== undefined) {
|
||||
await monday.cambiarValorColumna(boardLeads, itemId, 'Fecha', fecha)
|
||||
}
|
||||
}
|
||||
|
||||
/** Estado DESCARTADO + registro en el catálogo Motivos No Compra (+ Notas opcional) */
|
||||
export async function descartarLead(
|
||||
monday: MondayLeads,
|
||||
itemId: string,
|
||||
motivo: string,
|
||||
comentario?: string,
|
||||
): Promise<string> {
|
||||
const boardLeads = config.monday.boards.leads
|
||||
const nombreItem = await monday.obtenerNombreItem(itemId)
|
||||
await monday.cambiarValorColumna(boardLeads, itemId, 'Estado', 'DESCARTADO')
|
||||
if (comentario !== undefined) {
|
||||
await monday.cambiarValorColumna(boardLeads, itemId, 'Notas', comentario)
|
||||
}
|
||||
return monday.crearMotivoNoCompra({
|
||||
nombre: `${motivo} — ${nombreItem}`,
|
||||
categoria: motivo,
|
||||
...(comentario !== undefined ? { descripcion: comentario } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
/** POST /api/leads { nombre, telefono?, email?, origen?, notas? } */
|
||||
export async function crearLeadEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const body = crearLeadSchema.parse(req.body)
|
||||
const resultado = await crearLead(mondayService, body)
|
||||
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
|
||||
}
|
||||
res.status(201).json({ id: resultado.id, estado: 'NUEVO' })
|
||||
}
|
||||
|
||||
/** POST /api/leads/:itemId/estado { estado, fecha? } */
|
||||
export async function cambiarEstadoLeadEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const body = cambiarEstadoSchema.parse(req.body)
|
||||
const itemId = String(req.params['itemId'])
|
||||
await cambiarEstadoLead(mondayService, itemId, body.estado, body.fecha)
|
||||
res.json({ itemId, estado: body.estado, ...(body.fecha !== undefined ? { fecha: body.fecha } : {}) })
|
||||
}
|
||||
|
||||
/** POST /api/leads/:itemId/descartar { motivo, comentario? } */
|
||||
export async function descartarLeadEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const body = descartarSchema.parse(req.body)
|
||||
const itemId = String(req.params['itemId'])
|
||||
const motivoItemId = await descartarLead(mondayService, itemId, body.motivo, body.comentario)
|
||||
res.json({ itemId, estado: 'DESCARTADO', motivo: body.motivo, motivoItemId })
|
||||
}
|
||||
Reference in New Issue
Block a user