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:
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
139
api-gateway/src/controllers/usuarios.controller.test.ts
Normal file
139
api-gateway/src/controllers/usuarios.controller.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
44
api-gateway/src/controllers/usuarios.controller.ts
Normal file
44
api-gateway/src/controllers/usuarios.controller.ts
Normal 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))
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user