diff --git a/api-gateway/prisma/migrations/20260902090000_lead_captura/migration.sql b/api-gateway/prisma/migrations/20260902090000_lead_captura/migration.sql new file mode 100644 index 0000000..078f4f1 --- /dev/null +++ b/api-gateway/prisma/migrations/20260902090000_lead_captura/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE "LeadCaptura" ( + "id" TEXT NOT NULL PRIMARY KEY, + "leadId" TEXT NOT NULL, + "oportunidadId" TEXT, + "usuarioId" TEXT NOT NULL, + "nombreLead" TEXT NOT NULL, + "telefono" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateIndex +CREATE UNIQUE INDEX "LeadCaptura_leadId_key" ON "LeadCaptura"("leadId"); + +-- CreateIndex +CREATE INDEX "LeadCaptura_usuarioId_idx" ON "LeadCaptura"("usuarioId"); + diff --git a/api-gateway/prisma/schema.prisma b/api-gateway/prisma/schema.prisma index 37460d2..6621f53 100644 --- a/api-gateway/prisma/schema.prisma +++ b/api-gateway/prisma/schema.prisma @@ -150,6 +150,21 @@ model LeadOportunidad { createdAt DateTime @default(now()) } +// Registro local de qué usuario del ecosistema capturó cada lead (alta de +// afluencia). Monday no guarda al capturista (el creador del item siempre es +// el token del API); esta tabla alimenta "Mis capturas" del panel Mi día. +model LeadCaptura { + id String @id @default(uuid()) + leadId String @unique // item del tablero Leads (Monday) + oportunidadId String? // item del tablero Oportunidades ligado al capturar + usuarioId String // Usuario.id del capturista + nombreLead String + telefono String? + createdAt DateTime @default(now()) + + @@index([usuarioId]) +} + // Estado persistente de las automatizaciones de Monday (round-robin, recordatorios). // Clave-valor genérico: 'rr:indice', 'rr:vendedores', 'recordatorio::', etc. model AutomatizacionState { diff --git a/api-gateway/src/controllers/leads.controller.test.ts b/api-gateway/src/controllers/leads.controller.test.ts index f91b8ac..b7c5b42 100644 --- a/api-gateway/src/controllers/leads.controller.test.ts +++ b/api-gateway/src/controllers/leads.controller.test.ts @@ -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 { + 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 => ({ id: 'lead-1', diff --git a/api-gateway/src/controllers/leads.controller.ts b/api-gateway/src/controllers/leads.controller.ts index c4ff9f6..4af197b 100644 --- a/api-gateway/src/controllers/leads.controller.ts +++ b/api-gateway/src/controllers/leads.controller.ts @@ -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 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, ), ) } diff --git a/api-gateway/src/services/mi-dia.service.test.ts b/api-gateway/src/services/mi-dia.service.test.ts index 6f24bc6..182522e 100644 --- a/api-gateway/src/services/mi-dia.service.test.ts +++ b/api-gateway/src/services/mi-dia.service.test.ts @@ -83,7 +83,7 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => { lead({ id: '6', estado: 'NUEVO', vendedorIds: [OTRO_ID] }), ], }) - const r = await resumenMiDia(monday, vendedor, new Map(), AHORA) + const r = await resumenMiDia(monday, vendedor, new Map(), [], AHORA) expect(r.sinMapeoMonday).toBe(false) expect(r.misLeads.map((l) => l.id)).toEqual(['1', '2']) expect(r.misLeads[1]?.telefono).toBe('4771234567') @@ -101,7 +101,7 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => { actividad({ id: 'a7', fecha: '' }), // sin fecha: no ], }) - const r = await resumenMiDia(monday, vendedor, new Map(), AHORA) + const r = await resumenMiDia(monday, vendedor, new Map(), [], AHORA) // Ordenadas por fecha: vencida primero expect(r.misActividadesHoy.map((a) => a.id)).toEqual(['a3', 'a1', 'a2']) expect(r.conteos.actividadesAbiertas).toBe(5) // abiertas mías: a1,a2,a3,a4,a7 @@ -118,7 +118,7 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => { apartado({ id: 'p6', fechaLimite: '2026-09-01', estado: 'Cancelacion' }), ], }) - const r = await resumenMiDia(monday, vendedor, new Map(), AHORA) + const r = await resumenMiDia(monday, vendedor, new Map(), [], AHORA) expect(r.apartadosPorVencer.map((a) => a.id)).toEqual(['p1', 'p2']) expect(r.apartadosPorVencer[0]?.diasRestantes).toBe(0) expect(r.apartadosPorVencer[1]?.diasRestantes).toBe(7) @@ -130,7 +130,7 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => { actividades: [actividad({ id: 'a1' })], apartados: [apartado({ id: 'p1' })], }) - const r = await resumenMiDia(monday, sinMapeo, new Map(), AHORA) + const r = await resumenMiDia(monday, sinMapeo, new Map(), [], AHORA) expect(r.sinMapeoMonday).toBe(true) expect(r.misLeads).toEqual([]) expect(r.misActividadesHoy).toEqual([]) @@ -148,12 +148,12 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => { lead({ id: '4', createdAt: '2026-08-27T08:00:00', vendedorIds: [OTRO_ID] }), // de otro ], }) - const r = await resumenMiDia(monday, vendedor, new Map(), AHORA) + const r = await resumenMiDia(monday, vendedor, new Map(), [], AHORA) expect(r.conteos.leadsNuevosSemana).toBe(2) }) it('oportunidades se omiten con nota (el board no tiene columna de asesor)', async () => { - const r = await resumenMiDia(mondayMock({}), vendedor, new Map(), AHORA) + const r = await resumenMiDia(mondayMock({}), vendedor, new Map(), [], AHORA) expect(r.misOportunidades).toBeNull() expect(r.notaOportunidades).toContain('asesor') }) @@ -171,10 +171,10 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => { }) const nombres = new Map([[MI_ID, 'Martin Zavala']]) - const rVendedor = await resumenMiDia(monday, vendedor, nombres, AHORA) + const rVendedor = await resumenMiDia(monday, vendedor, nombres, [], AHORA) expect(rVendedor.equipo).toBeUndefined() - const rCoord = await resumenMiDia(monday, coordinador, nombres, AHORA) + const rCoord = await resumenMiDia(monday, coordinador, nombres, [], AHORA) expect(rCoord.equipo?.leadsNuevosSinAsignar).toBe(1) expect(rCoord.equipo?.porVendedor).toEqual([ { mondayUserId: OTRO_ID, nombre: null, leadsAsignados: 2, leadsNuevosSemana: 1 }, @@ -185,10 +185,57 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => { it('corporativo y admin también ven equipo', async () => { const monday = mondayMock({ leads: [lead({ id: '1' })] }) for (const rol of ['corporativo', 'admin']) { - const r = await resumenMiDia(monday, { nombre: 'X', rol, mondayUserId: null }, new Map(), AHORA) + const r = await resumenMiDia(monday, { nombre: 'X', rol, mondayUserId: null }, new Map(), [], AHORA) expect(r.equipo).toBeDefined() } }) + + it('mis capturas: enriquecidas con el estado/origen actual del lead en Monday', async () => { + const monday = mondayMock({ + leads: [lead({ id: '100', nombre: 'Ana López', estado: 'CONTACTADO', origen: 'WhatsApp', vendedorIds: [OTRO_ID] })], + }) + const capturas = [ + { leadId: '100', nombreLead: 'Ana López', telefono: '6641112233', createdAt: new Date('2026-08-27T09:00:00Z') }, + ] + const r = await resumenMiDia(monday, vendedor, new Map(), capturas, AHORA) + // Aunque el lead quedó asignado a OTRO por round-robin, la captura es mía + expect(r.misCapturas).toEqual([ + { + leadId: '100', + nombre: 'Ana López', + telefono: '6641112233', + estado: 'CONTACTADO', + origen: 'WhatsApp', + capturadoEn: '2026-08-27T09:00:00.000Z', + }, + ]) + }) + + it('mis capturas: si el lead ya no existe en Monday, estado y origen van vacíos', async () => { + const monday = mondayMock({}) + const capturas = [ + { leadId: 'borrado-1', nombreLead: 'Lead Borrado', telefono: null, createdAt: new Date('2026-08-26T09:00:00Z') }, + ] + const r = await resumenMiDia(monday, vendedor, new Map(), capturas, AHORA) + expect(r.misCapturas).toEqual([ + { leadId: 'borrado-1', nombre: 'Lead Borrado', telefono: '', estado: '', origen: '', capturadoEn: '2026-08-26T09:00:00.000Z' }, + ]) + }) + + it('mis capturas: ordenadas por fecha de captura descendente', async () => { + const monday = mondayMock({}) + const capturas = [ + { leadId: '1', nombreLead: 'Antiguo', telefono: null, createdAt: new Date('2026-08-20T09:00:00Z') }, + { leadId: '2', nombreLead: 'Reciente', telefono: null, createdAt: new Date('2026-08-27T09:00:00Z') }, + ] + const r = await resumenMiDia(monday, vendedor, new Map(), capturas, AHORA) + expect(r.misCapturas.map((c) => c.leadId)).toEqual(['2', '1']) + }) + + it('sin capturas: misCapturas va vacío', async () => { + const r = await resumenMiDia(mondayMock({}), vendedor, new Map(), [], AHORA) + expect(r.misCapturas).toEqual([]) + }) }) describe('clasificadores de estado', () => { diff --git a/api-gateway/src/services/mi-dia.service.ts b/api-gateway/src/services/mi-dia.service.ts index cdc0af3..05794cd 100644 --- a/api-gateway/src/services/mi-dia.service.ts +++ b/api-gateway/src/services/mi-dia.service.ts @@ -5,6 +5,8 @@ import type { MondayService } from './monday.service.js' * Todo se filtra por su mondayUserId (columna people de cada tablero); si el * usuario aún no tiene cuenta Monday (mondayUserId null), las listas personales * van vacías con sinMapeoMonday: true (las acciones del CRM siguen funcionando). + * "Mis capturas" viene del registro local (tabla LeadCaptura), no de Monday: + * es la lista de leads que el usuario dio de alta, con su estado actual. */ // Estados del board Leads en los que el lead ya no es trabajo activo del vendedor @@ -48,10 +50,20 @@ export interface ApartadoPorVencer { diasRestantes: number } +export interface CapturaMiDia { + leadId: string + nombre: string + telefono: string + estado: string // estado ACTUAL en Monday; '' si el lead ya no existe en el board + origen: string + capturadoEn: string // ISO +} + export interface ResumenMiDia { generadoEn: string sinMapeoMonday: boolean misLeads: LeadMiDia[] + misCapturas: CapturaMiDia[] misActividadesHoy: ActividadMiDia[] apartadosPorVencer: ApartadoPorVencer[] // null = el tablero Oportunidades no tiene columna people de asesor (hoy no existe) @@ -100,6 +112,8 @@ export async function resumenMiDia( usuario: UsuarioMiDia, /** mondayUserId -> nombre para mostrar (usuarios del ecosistema ya mapeados) */ nombresPorMondayId: Map, + /** Capturas locales del usuario (tabla LeadCaptura), ya filtradas por él */ + capturas: { leadId: string; nombreLead: string; telefono: string | null; createdAt: Date }[] = [], ahora: Date = new Date(), ): Promise { const miId = usuario.mondayUserId @@ -130,6 +144,23 @@ export async function resumenMiDia( updatedAt: l.updatedAt, })) + // "Mis capturas" se enriquece con el estado/origen ACTUAL del lead en Monday + // (si ya no existe en el board, estado y origen van ''), más reciente primero + const leadsPorId = new Map(leads.map((l) => [l.id, l])) + const misCapturas: CapturaMiDia[] = capturas + .map((c) => { + const lead = leadsPorId.get(c.leadId) + return { + leadId: c.leadId, + nombre: c.nombreLead, + telefono: c.telefono ?? '', + estado: lead?.estado ?? '', + origen: lead?.origen ?? '', + capturadoEn: c.createdAt.toISOString(), + } + }) + .sort((a, b) => b.capturadoEn.localeCompare(a.capturadoEn)) + const misActividades = sinMapeo ? [] : actividades.filter((a) => a.responsableIds.includes(miId)) const misActividadesHoy: ActividadMiDia[] = misActividades .filter((a) => a.estado.trim().toUpperCase() === 'ABIERTO' && a.fecha !== '' && a.fecha <= manana) @@ -164,6 +195,7 @@ export async function resumenMiDia( generadoEn: ahora.toISOString(), sinMapeoMonday: sinMapeo, misLeads, + misCapturas, misActividadesHoy, apartadosPorVencer, // El tablero Oportunidades no tiene columna people de asesor: no se puede diff --git a/apps/mi-dia/src/lib/mi-dia.ts b/apps/mi-dia/src/lib/mi-dia.ts index 722abb8..7fd775e 100644 --- a/apps/mi-dia/src/lib/mi-dia.ts +++ b/apps/mi-dia/src/lib/mi-dia.ts @@ -27,10 +27,20 @@ export interface ApartadoPorVencer { diasRestantes: number } +export interface CapturaMiDia { + leadId: string + nombre: string + telefono: string + estado: string // estado actual en Monday; '' si el lead ya no existe en el board + origen: string + capturadoEn: string // ISO +} + export interface ResumenMiDia { generadoEn: string sinMapeoMonday: boolean misLeads: LeadMiDia[] + misCapturas: CapturaMiDia[] misActividadesHoy: ActividadMiDia[] apartadosPorVencer: ApartadoPorVencer[] misOportunidades: { porEtapa: Record } | null diff --git a/apps/mi-dia/src/pages/MiDiaPage.tsx b/apps/mi-dia/src/pages/MiDiaPage.tsx index 4e78e1e..60653c6 100644 --- a/apps/mi-dia/src/pages/MiDiaPage.tsx +++ b/apps/mi-dia/src/pages/MiDiaPage.tsx @@ -243,6 +243,8 @@ export default function MiDiaPage() { setCapEmail('') setCapDesarrollo('') setCapNotas('') + // Refresca el resumen para que la captura aparezca al momento en "Mis capturas" + await cargar() } catch (err) { setCapMensaje({ tipo: 'error', texto: err instanceof Error ? err.message : 'No se pudo registrar el lead' }) } finally { @@ -367,6 +369,35 @@ export default function MiDiaPage() { )} +
+ Mis capturas de afluencia + {resumen.misCapturas.length === 0 ? ( + + ) : ( +
    + {resumen.misCapturas.map((c) => ( +
  • +
    +

    {c.nombre}

    +

    + {c.telefono || 'Sin teléfono'} + {c.origen ? ` · ${c.origen}` : ''} +

    +
    +
    + + {c.estado || '—'} + + + {formatoFechaCorta(c.capturadoEn.slice(0, 10))} + +
    +
  • + ))} +
+ )} +
+
Captura rápida de lead