feat(mi-dia): seccion 'Mis capturas de afluencia' en el panel del vendedor

Nueva tabla LeadCaptura (quien capturo cada lead, algo que Monday no
guarda porque el creador del item siempre es el token del API). El
POST /api/leads la registra con el usuario del token y el resumen de
Mi dia la enriquece con el estado/origen actual del lead en Monday.
La captura rapida refresca el panel al registrar.
This commit is contained in:
2026-09-02 02:13:13 +00:00
parent 57029bdd3d
commit 895a831ff8
9 changed files with 266 additions and 11 deletions

View File

@@ -1,13 +1,26 @@
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
import {
buscarLeads,
buscarLeadsEndpoint,
cambiarEstadoLead,
crearLead,
crearLeadEndpoint,
descartarLead,
ESTADOS_LEAD_PERMITIDOS,
} from './leads.controller.js'
import type { LeadMonday } from '../services/monday.service.js'
import { mondayService, type LeadMonday } from '../services/monday.service.js'
import { funnelService } from '../services/funnel.service.js'
// ---------- Prisma mockeado (los tests no tocan la BD) ----------
const prismaMock = vi.hoisted(() => ({
leadCaptura: {
create: vi.fn(),
},
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
/** Monday mockeado: registra escrituras y devuelve catálogos controlados */
function mondayMock(
@@ -306,6 +319,69 @@ describe('descartarLead (POST /api/leads/:itemId/descartar)', () => {
})
})
describe('crearLeadEndpoint (POST /api/leads)', () => {
beforeEach(() => {
vi.clearAllMocks()
// El singleton Monday responde como si creara el lead; el funnel liga la oportunidad
vi.spyOn(mondayService, 'buscarLeadPorTelefono').mockResolvedValue(null)
vi.spyOn(mondayService, 'crearLead').mockResolvedValue('lead-nuevo-9')
vi.spyOn(funnelService, 'asegurarOportunidadAfluencia').mockResolvedValue('op-9')
})
function reqFake(overrides: Partial<Request> = {}): Request {
return {
body: { nombre: 'Lead Prueba', telefono: '4771234567' },
usuarioToken: { sub: 'u-1', username: 'asesor uno', rol: 'vendedor' },
...overrides,
} as Request
}
function resFake() {
const json = vi.fn()
const status = vi.fn().mockReturnValue({ json })
return { res: { json, status } as unknown as Response, status, json }
}
it('201: registra la captura local con el usuario del token', async () => {
prismaMock.leadCaptura.create.mockResolvedValue({})
const { res, status, json } = resFake()
await crearLeadEndpoint(reqFake(), res)
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
data: {
leadId: 'lead-nuevo-9',
oportunidadId: 'op-9',
usuarioId: 'u-1',
nombreLead: 'Lead Prueba',
telefono: '4771234567',
},
})
expect(status).toHaveBeenCalledWith(201)
expect(json).toHaveBeenCalledWith({ id: 'lead-nuevo-9', estado: 'NUEVO', oportunidadId: 'op-9' })
})
it('sin token (AUTH_ENABLED=false) no registra captura y responde 201 igual', async () => {
const { res, status } = resFake()
await crearLeadEndpoint(reqFake({ usuarioToken: undefined }), res)
expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled()
expect(status).toHaveBeenCalledWith(201)
})
it('un fallo del registro local no rompe la respuesta (el lead ya quedó en Monday)', async () => {
prismaMock.leadCaptura.create.mockRejectedValue(new Error('SQLite bloqueado'))
const espia = vi.spyOn(console, 'error').mockImplementation(() => {})
const { res, status } = resFake()
await crearLeadEndpoint(reqFake(), res)
expect(status).toHaveBeenCalledWith(201)
espia.mockRestore()
})
})
describe('buscarLeads (GET /api/leads/buscar)', () => {
const lead = (overrides: Partial<LeadMonday>): LeadMonday => ({
id: 'lead-1',

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 { prisma } from '../services/prisma.js'
import { MOTIVOS_NO_COMPRA } from './oportunidades.controller.js'
import { AppError } from '../utils/errors.js'
@@ -236,6 +237,25 @@ export async function crearLeadEndpoint(req: Request, res: Response): Promise<vo
})
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 (req.usuarioToken?.sub) {
try {
await prisma.leadCaptura.create({
data: {
leadId: resultado.id,
oportunidadId: resultado.oportunidadId,
usuarioId: req.usuarioToken.sub,
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)
}
}
res.status(201).json({ id: resultado.id, estado: 'NUEVO', oportunidadId: resultado.oportunidadId })
}

View File

@@ -26,11 +26,18 @@ export async function resumenMiDiaEndpoint(req: Request, res: Response): Promise
mapeados.filter((m) => m.mondayUserId !== null).map((m) => [m.mondayUserId!, m.nombre]),
)
// Capturas de afluencia registradas por el usuario (registro local)
const capturas = await prisma.leadCaptura.findMany({
where: { usuarioId: usuario.id },
orderBy: { createdAt: 'desc' },
})
res.json(
await resumenMiDia(
mondayService,
{ nombre: usuario.nombre, rol: usuario.rol, mondayUserId: usuario.mondayUserId },
nombresPorMondayId,
capturas,
),
)
}