feat(leads): la captura desde una app se asigna al capturista

POST /api/leads con sesion y Monday vinculado asigna el lead a quien lo
registro (mas su actividad de contacto) en vez del round-robin; sin
vinculo o sin token sigue el reparto. Bandera rr:skip para que el
webhook create_item no pise la asignacion durante la propagacion de
Monday (la carrera se observo en el smoke). Mensajes de exito en
/hoy y /client reflejan a quien quedo asignado.
This commit is contained in:
2026-09-03 21:26:20 +00:00
parent d59c8c0d7e
commit f382987fbf
9 changed files with 166 additions and 6 deletions

View File

@@ -11,6 +11,7 @@ import {
} from './leads.controller.js'
import { mondayService, type LeadMonday } from '../services/monday.service.js'
import { funnelService } from '../services/funnel.service.js'
import { automatizacionesService } from '../services/automatizaciones.service.js'
// ---------- Prisma mockeado (los tests no tocan la BD) ----------
@@ -18,6 +19,13 @@ const prismaMock = vi.hoisted(() => ({
leadCaptura: {
create: vi.fn(),
},
usuario: {
findUnique: vi.fn(),
},
automatizacionState: {
findUnique: vi.fn(),
upsert: vi.fn(),
},
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
@@ -326,6 +334,11 @@ describe('crearLeadEndpoint (POST /api/leads)', () => {
vi.spyOn(mondayService, 'buscarLeadPorTelefono').mockResolvedValue(null)
vi.spyOn(mondayService, 'crearLead').mockResolvedValue('lead-nuevo-9')
vi.spyOn(funnelService, 'asegurarOportunidadAfluencia').mockResolvedValue('op-9')
vi.spyOn(automatizacionesService, 'asignarACapturista').mockResolvedValue(undefined)
// Default: el capturista NO tiene Monday vinculado → asignación round-robin
prismaMock.usuario.findUnique.mockResolvedValue(null)
prismaMock.automatizacionState.findUnique.mockResolvedValue(null)
prismaMock.automatizacionState.upsert.mockResolvedValue({})
})
function reqFake(overrides: Partial<Request> = {}): Request {
@@ -358,7 +371,37 @@ describe('crearLeadEndpoint (POST /api/leads)', () => {
},
})
expect(status).toHaveBeenCalledWith(201)
expect(json).toHaveBeenCalledWith({ id: 'lead-nuevo-9', estado: 'NUEVO', oportunidadId: 'op-9' })
expect(json).toHaveBeenCalledWith({ id: 'lead-nuevo-9', estado: 'NUEVO', oportunidadId: 'op-9', asignacion: 'round-robin' })
})
it('con Monday vinculado, el lead queda asignado al capturista (no al round-robin)', async () => {
prismaMock.leadCaptura.create.mockResolvedValue({})
prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: 12345 })
const { res, status, json } = resFake()
await crearLeadEndpoint(reqFake(), res)
// Bandera anti-carrera ANTES de asignar + asignación al capturista
expect(prismaMock.automatizacionState.upsert).toHaveBeenCalledWith(
expect.objectContaining({ where: { clave: 'rr:skip:lead-nuevo-9' }, create: { clave: 'rr:skip:lead-nuevo-9', valor: 'capturista' } }),
)
expect(automatizacionesService.asignarACapturista).toHaveBeenCalledWith('lead-nuevo-9', 'Lead Prueba', 12345)
expect(status).toHaveBeenCalledWith(201)
expect(json).toHaveBeenCalledWith({ id: 'lead-nuevo-9', estado: 'NUEVO', oportunidadId: 'op-9', asignacion: 'capturista' })
})
it('un fallo al asignar al capturista no rompe la captura (cae al round-robin)', async () => {
prismaMock.leadCaptura.create.mockResolvedValue({})
prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: 12345 })
vi.spyOn(automatizacionesService, 'asignarACapturista').mockRejectedValue(new Error('Monday caído'))
const espia = vi.spyOn(console, 'error').mockImplementation(() => {})
const { res, status, json } = resFake()
await crearLeadEndpoint(reqFake(), res)
expect(status).toHaveBeenCalledWith(201)
expect(json).toHaveBeenCalledWith({ id: 'lead-nuevo-9', estado: 'NUEVO', oportunidadId: 'op-9', asignacion: 'round-robin' })
espia.mockRestore()
})
it('sin token (AUTH_ENABLED=false) no registra captura y responde 201 igual', async () => {

View File

@@ -3,6 +3,7 @@ import { z } from 'zod'
import { config } from '../config/index.js'
import { mondayService, type LeadMonday, type MondayService } from '../services/monday.service.js'
import { funnelService, type FunnelService } from '../services/funnel.service.js'
import { automatizacionesService, CLAVE_RR_SKIP, prismaStateStore } from '../services/automatizaciones.service.js'
import { prisma } from '../services/prisma.js'
import { MOTIVOS_NO_COMPRA } from './oportunidades.controller.js'
import { AppError } from '../utils/errors.js'
@@ -256,7 +257,33 @@ export async function crearLeadEndpoint(req: Request, res: Response): Promise<vo
console.error(`No se pudo registrar la captura del lead ${resultado.id}:`, err)
}
}
res.status(201).json({ id: resultado.id, estado: 'NUEVO', oportunidadId: resultado.oportunidadId })
// Asignación: si el capturista tiene su usuario Monday vinculado, el lead
// queda asignado a él. La bandera rr:skip se prende ANTES de asignar 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).
let asignacion: 'capturista' | 'round-robin' = 'round-robin'
if (req.usuarioToken?.sub) {
try {
const capturista = await prisma.usuario.findUnique({
where: { id: req.usuarioToken.sub },
select: { mondayUserId: true },
})
if (capturista?.mondayUserId != null) {
await prismaStateStore.set(`${CLAVE_RR_SKIP}${resultado.id}`, 'capturista')
try {
await automatizacionesService.asignarACapturista(resultado.id, body.nombre, capturista.mondayUserId)
asignacion = 'capturista'
} catch (err) {
await prismaStateStore.set(`${CLAVE_RR_SKIP}${resultado.id}`, 'round-robin')
throw err
}
}
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo asignar el lead ${resultado.id} al capturista:`, err)
}
}
res.status(201).json({ id: resultado.id, estado: 'NUEVO', oportunidadId: resultado.oportunidadId, asignacion })
}
/** POST /api/leads/:itemId/estado { estado, fecha? } */