feat: vínculo con usuario Monday en landing + alta prellenada desde leads

API:
- GET /api/monday/usuarios: usuarios activos no-guest del workspace
- PATCH /api/usuarios/me/monday: vincula/desvincula el mondayUserId del
  usuario autenticado (valida contra el workspace)
- GET /api/leads/buscar?q=: búsqueda de leads por nombre/teléfono/email
  (sin acentos, orden updatedAt desc, límite 15)
- aPublico/me ahora incluyen mondayUserId

Landing (/):
- Nueva sección 'Configuración de cuenta': login inline, selector del
  usuario de Monday, guardar/desvincular, cerrar sesión

Alta de clientes (/client):
- Búsqueda de leads de Monday con debounce en la pantalla de entrada;
  al elegir uno prellena nombre/email/celular y guarda leadMondayId
- El picker (borradores + alta nueva + búsqueda de lead) es ahora
  siempre la pantalla de entrada

Tests: api 294, landing 8, alta-clientes 96 — todos verdes
This commit is contained in:
2026-08-28 18:36:08 +00:00
parent d99d90d344
commit 1692dde887
28 changed files with 2501 additions and 41 deletions

View File

@@ -1,5 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import {
buscarLeads,
buscarLeadsEndpoint,
cambiarEstadoLead,
crearLead,
descartarLead,
@@ -131,3 +133,55 @@ describe('descartarLead (POST /api/leads/:itemId/descartar)', () => {
expect(monday.motivos).toEqual([])
})
})
describe('buscarLeads (GET /api/leads/buscar)', () => {
const lead = (overrides: Partial<LeadMonday>): LeadMonday => ({
id: 'lead-1',
nombre: 'Lead Prueba',
createdAt: '2026-08-01T00:00:00Z',
updatedAt: '2026-08-01T00:00:00Z',
estado: 'NUEVO',
origen: '',
telefono: '',
email: '',
vendedorIds: [],
...overrides,
})
it('matchea por nombre, teléfono y email (case-insensitive)', () => {
const leads = [
lead({ id: 'a', nombre: 'MARÍA FERNANDA LÓPEZ' }),
lead({ id: 'b', telefono: '477 123 4567' }),
lead({ id: 'c', email: 'Cliente@Correo.com' }),
lead({ id: 'd', nombre: 'Otro', telefono: '333', email: '' }),
]
expect(buscarLeads(leads, 'fernanda').map((l) => l.id)).toEqual(['a'])
expect(buscarLeads(leads, '123').map((l) => l.id)).toEqual(['b'])
expect(buscarLeads(leads, 'cliente@').map((l) => l.id)).toEqual(['c'])
})
it('ignora acentos tanto en el lead como en la búsqueda', () => {
const leads = [lead({ id: 'a', nombre: 'MARÍA FERNANDA LÓPEZ', email: 'andres@correo.com' })]
expect(buscarLeads(leads, 'maria')).toHaveLength(1)
expect(buscarLeads(leads, 'andrés')).toHaveLength(1)
})
it('ordena por updatedAt descendente y limita a 15', () => {
const leads = Array.from({ length: 20 }, (_, i) =>
lead({ id: `l-${i}`, nombre: `Cliente Común ${i}`, updatedAt: `2026-08-${String(i + 1).padStart(2, '0')}T00:00:00Z` }),
)
const r = buscarLeads(leads, 'común')
expect(r).toHaveLength(15)
expect(r[0]?.id).toBe('l-19') // el más reciente primero
})
it('el endpoint responde 400 con q de menos de 2 caracteres', async () => {
const res = { json: vi.fn(), status: vi.fn() } as unknown as Parameters<typeof buscarLeadsEndpoint>[1]
await expect(
buscarLeadsEndpoint({ query: { q: 'a' } } as unknown as Parameters<typeof buscarLeadsEndpoint>[0], res),
).rejects.toMatchObject({ status: 400 })
await expect(
buscarLeadsEndpoint({ query: {} } as unknown as Parameters<typeof buscarLeadsEndpoint>[0], res),
).rejects.toMatchObject({ status: 400 })
})
})

View File

@@ -35,6 +35,29 @@ const descartarSchema = z.object({
comentario: z.string().trim().min(1).optional(),
})
// ---------- Búsqueda de leads (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()
/**
* Filtra leads por q en nombre/teléfono/email (case-insensitive, sin acentos),
* ordena por actualización reciente y limita a 15 resultados.
*/
export function buscarLeads(leads: LeadMonday[], q: string): LeadMonday[] {
const needle = sinAcentos(q)
return leads
.filter((l) => [l.nombre, l.telefono, l.email].some((campo) => sinAcentos(campo).includes(needle)))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
.slice(0, LIMITE_BUSQUEDA_LEADS)
}
type MondayLeads = Pick<
MondayService,
| 'cambiarValorColumna'
@@ -144,3 +167,23 @@ export async function descartarLeadEndpoint(req: Request, res: Response): Promis
const motivoItemId = await descartarLead(mondayService, itemId, body.motivo, body.comentario)
res.json({ itemId, estado: 'DESCARTADO', motivo: body.motivo, motivoItemId })
}
/**
* GET /api/leads/buscar?q=texto — autocompletado para prellenar el alta de
* clientes desde un lead. q mínimo 2 caracteres; devuelve máximo 15.
*/
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')
const encontrados = buscarLeads(await mondayService.getLeads(), q)
res.json(
encontrados.map((l) => ({
id: l.id,
nombre: l.nombre,
telefono: l.telefono,
email: l.email,
estado: l.estado,
origen: l.origen,
})),
)
}

View File

@@ -0,0 +1,139 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
import type { Usuario } from '@prisma/client'
import {
actualizarVinculoMondayEndpoint,
listarUsuariosMondayEndpoint,
} from './usuarios.controller.js'
import { AppError } from '../utils/errors.js'
// ---------- Monday y Prisma mockeados (los tests no tocan red ni BD) ----------
const mondayMock = vi.hoisted(() => ({ getUsuariosWorkspace: vi.fn() }))
const prismaMock = vi.hoisted(() => ({ usuario: { update: vi.fn() } }))
vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock }))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
const USUARIOS_WORKSPACE = [
{ id: 13676, nombre: 'Erick Peña', email: 'erick@frasa.mx' },
{ id: 20400, nombre: 'Cristina Barrera', email: 'cristina@frasa.mx' },
]
function usuarioFake(overrides: Partial<Usuario> = {}): Usuario {
return {
id: 'u-1',
username: 'erick israel pena ramos',
nombre: 'ERICK ISRAEL PEÑA RAMOS',
passwordHash: 'hash',
rol: 'vendedor',
asignacion: 'MONTEBELLO',
empleadoId: 13676,
mondayUserId: null,
activo: true,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
}
}
function reqFake(overrides: Partial<Request> = {}): Request {
return {
body: {},
usuarioToken: { sub: 'u-1', username: 'erick israel pena ramos', 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 }
}
beforeEach(() => {
vi.clearAllMocks()
mondayMock.getUsuariosWorkspace.mockResolvedValue(USUARIOS_WORKSPACE)
})
describe('listarUsuariosMondayEndpoint (GET /api/monday/usuarios)', () => {
it('devuelve los usuarios del workspace con id numérico', async () => {
const { res, json } = resFake()
await listarUsuariosMondayEndpoint(reqFake(), res)
expect(json).toHaveBeenCalledWith([
{ id: 13676, nombre: 'Erick Peña', email: 'erick@frasa.mx' },
{ id: 20400, nombre: 'Cristina Barrera', email: 'cristina@frasa.mx' },
])
})
it('propaga el error si Monday falla (el error handler responde 5xx)', async () => {
mondayMock.getUsuariosWorkspace.mockRejectedValue(new Error('Monday caído'))
const { res } = resFake()
await expect(listarUsuariosMondayEndpoint(reqFake(), res)).rejects.toThrow('Monday caído')
})
})
describe('actualizarVinculoMondayEndpoint (PATCH /api/usuarios/me/monday)', () => {
it('con id válido guarda el vínculo y devuelve el perfil actualizado', async () => {
prismaMock.usuario.update.mockResolvedValue(usuarioFake({ mondayUserId: 13676 }))
const { res, json } = resFake()
await actualizarVinculoMondayEndpoint(reqFake({ body: { mondayUserId: 13676 } }), res)
expect(prismaMock.usuario.update).toHaveBeenCalledWith({
where: { id: 'u-1' },
data: { mondayUserId: 13676 },
})
expect(json).toHaveBeenCalledWith({
nombre: 'ERICK ISRAEL PEÑA RAMOS',
rol: 'vendedor',
asignacion: 'MONTEBELLO',
mondayUserId: 13676,
})
})
it('con id inexistente en el workspace responde 400 y no toca la BD', async () => {
const { res } = resFake()
const error = await actualizarVinculoMondayEndpoint(
reqFake({ body: { mondayUserId: 99999 } }),
res,
).catch((e: unknown) => e)
expect(error).toBeInstanceOf(AppError)
expect(error).toMatchObject({ status: 400 })
expect(prismaMock.usuario.update).not.toHaveBeenCalled()
})
it('con null desvincula sin consultar Monday', async () => {
prismaMock.usuario.update.mockResolvedValue(usuarioFake({ mondayUserId: null }))
const { res, json } = resFake()
await actualizarVinculoMondayEndpoint(reqFake({ body: { mondayUserId: null } }), res)
expect(mondayMock.getUsuariosWorkspace).not.toHaveBeenCalled()
expect(prismaMock.usuario.update).toHaveBeenCalledWith({
where: { id: 'u-1' },
data: { mondayUserId: null },
})
expect(json).toHaveBeenCalledWith(expect.objectContaining({ mondayUserId: null }))
})
it('sin token verificado: 400', async () => {
const { res } = resFake()
await expect(
actualizarVinculoMondayEndpoint(reqFake({ usuarioToken: undefined }), res),
).rejects.toThrow('Falta el token verificado')
expect(prismaMock.usuario.update).not.toHaveBeenCalled()
})
it('body inválido (string): ZodError (el error handler lo traduce a 400)', async () => {
const { res } = resFake()
await expect(
actualizarVinculoMondayEndpoint(reqFake({ body: { mondayUserId: '13676' } }), res),
).rejects.toThrow()
expect(prismaMock.usuario.update).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,44 @@
import type { Request, Response } from 'express'
import { z } from 'zod'
import { mondayService } from '../services/monday.service.js'
import { prisma } from '../services/prisma.js'
import { aPublico } from '../services/auth.service.js'
import { BadRequestError } from '../utils/errors.js'
/**
* Vínculo usuario del ecosistema ↔ usuario de Monday (people). Es el mapeo
* que alimenta round-robin, mis-leads, actividades y alertas; el usuario lo
* elige él mismo desde la landing (Configuración de cuenta).
*/
/** GET /api/monday/usuarios (Bearer) → usuarios activos del workspace */
export async function listarUsuariosMondayEndpoint(_req: Request, res: Response): Promise<void> {
const usuarios = await mondayService.getUsuariosWorkspace()
res.json(usuarios.map((u) => ({ id: u.id, nombre: u.nombre, email: u.email })))
}
const vinculoMondaySchema = z.object({
mondayUserId: z.number().int().positive().nullable(),
})
/**
* PATCH /api/usuarios/me/monday (Bearer) { mondayUserId: number | null }
* null desvincula. Un id que no existe en el workspace → 400.
*/
export async function actualizarVinculoMondayEndpoint(req: Request, res: Response): Promise<void> {
if (!req.usuarioToken) throw new BadRequestError('Falta el token verificado')
const { mondayUserId } = vinculoMondaySchema.parse(req.body)
if (mondayUserId !== null) {
const usuarios = await mondayService.getUsuariosWorkspace()
if (!usuarios.some((u) => u.id === mondayUserId)) {
throw new BadRequestError(`El usuario de Monday ${mondayUserId} no existe en el workspace`)
}
}
const usuario = await prisma.usuario.update({
where: { id: req.usuarioToken.sub },
data: { mondayUserId },
})
res.json(aPublico(usuario))
}

View File

@@ -45,8 +45,13 @@ import { correrRecordatorios, listarRecordatorios } from '../controllers/recorda
import {
cambiarEstadoLeadEndpoint,
crearLeadEndpoint,
buscarLeadsEndpoint,
descartarLeadEndpoint,
} from '../controllers/leads.controller.js'
import {
actualizarVinculoMondayEndpoint,
listarUsuariosMondayEndpoint,
} from '../controllers/usuarios.controller.js'
import { resumenMiDiaEndpoint } from '../controllers/mi-dia.controller.js'
import { webhookMonday } from '../controllers/webhooks.controller.js'
import {
@@ -121,10 +126,15 @@ apiRouter.post('/oportunidades/:mondayItemId/perder', perderOportunidadEndpoint)
apiRouter.get('/mi-dia/resumen', resumenMiDiaEndpoint)
// GET estático: va ANTES de /leads/:itemId para no colisionar con el parámetro
apiRouter.get('/leads/buscar', buscarLeadsEndpoint)
apiRouter.post('/leads', crearLeadEndpoint)
apiRouter.post('/leads/:itemId/estado', cambiarEstadoLeadEndpoint)
apiRouter.post('/leads/:itemId/descartar', descartarLeadEndpoint)
apiRouter.get('/monday/usuarios', listarUsuariosMondayEndpoint)
apiRouter.patch('/usuarios/me/monday', actualizarVinculoMondayEndpoint)
apiRouter.get('/reportes/resumen', resumenReportes)
apiRouter.post('/webhooks/monday', webhookMonday)

View File

@@ -23,6 +23,7 @@ function usuarioFake(overrides: Partial<Usuario> = {}): Usuario {
rol: 'vendedor',
asignacion: 'MONTEBELLO',
empleadoId: 13676,
mondayUserId: null,
activo: true,
createdAt: new Date(),
updatedAt: new Date(),
@@ -52,6 +53,7 @@ describe('login', () => {
nombre: 'ERICK ISRAEL PEÑA RAMOS',
rol: 'vendedor',
asignacion: 'MONTEBELLO',
mondayUserId: null,
})
expect(verificarToken(resultado.token).username).toBe('erick israel pena ramos')
})

View File

@@ -9,6 +9,8 @@ export interface UsuarioPublico {
nombre: string
rol: string
asignacion: string | null
// User ID de Monday vinculado (null = sin vincular; ver PATCH /usuarios/me/monday)
mondayUserId: number | null
}
export interface LoginResult {
@@ -30,7 +32,12 @@ export function normalizarUsername(nombre: string): string {
}
export function aPublico(usuario: Usuario): UsuarioPublico {
return { nombre: usuario.nombre, rol: usuario.rol, asignacion: usuario.asignacion }
return {
nombre: usuario.nombre,
rol: usuario.rol,
asignacion: usuario.asignacion,
mondayUserId: usuario.mondayUserId,
}
}
/**