fix(crm): re-captura tras limpieza, bitacora compartida, origen en oportunidades y privacidad del buscador

1. Re-captura: el dedup por telefono/email ya no considera etapas cerradas
   (Descartado, Perdido, Cancelo, Cancelacion Apartado). Los restos de la
   ultima limpieza (17 filas de APARTADO cancelados sin contraparte activa)
   bloqueaban a los vendedores al re-capturar; los registros activos siguen
   bloqueando con 409 como siempre.
2. Bitacora compartida: GET /recordatorios con leadId/clienteClave devuelve las
   notas de TODOS los usuarios de ese cliente (antes cada quien solo veia las
   suyas). Mi dia pide la bitacora con esos filtros; la lista personal sigue
   siendo propia.
3. Origen en oportunidades: el resumen de mi-dia ya devuelve origen y la card
   de cada oportunidad lo muestra (telefono · desarrollo · canal · modelo).
4. Privacidad del buscador: /leads/buscar y /buscar filtran por dueno cuando
   el rol es vendedor (leads por usuarioId, contactos por capturadoPorId,
   cotizaciones por vendedorId). Los roles de supervision siguen viendo todo.
   Ya no aparecen leads de otros asesores con nombre y telefono al teclear.

Verificado en vivo contra el API: 10/10 comprobaciones (re-captura del
telefono cancelado 201, duplicado activo 409, nota del admin visible para la
vendedora, origen presente en 27/27 oportunidades, buscador del vendedor sin
datos ajenos y del admin completo). 716 pruebas del API y 32 de mi-dia en verde.
Los tests de roles ahora usan prisma mockeado (la BD viva cambia cuando el
cliente edita permisos desde la UI).
This commit is contained in:
2026-09-24 09:49:41 +00:00
parent d5276f4466
commit cfc0acb5d3
17 changed files with 269 additions and 39 deletions

View File

@@ -26,7 +26,7 @@ describe('buscarEndpoint (GET /api/buscar)', () => {
buscarMock.mockResolvedValue([])
const r = res()
await buscarEndpoint(req({ q: 'María' }), r)
expect(buscarMock).toHaveBeenCalledWith('María', 20)
expect(buscarMock).toHaveBeenCalledWith('María', 20, undefined)
expect(r.json).toHaveBeenCalledWith([])
})
@@ -34,25 +34,47 @@ describe('buscarEndpoint (GET /api/buscar)', () => {
buscarMock.mockResolvedValue([])
const r = res()
await buscarEndpoint(req({}), r)
expect(buscarMock).toHaveBeenCalledWith('', 20)
expect(buscarMock).toHaveBeenCalledWith('', 20, undefined)
})
it('limite válido del query string', async () => {
buscarMock.mockResolvedValue([])
const r = res()
await buscarEndpoint(req({ q: 'x', limite: '10' }), r)
expect(buscarMock).toHaveBeenCalledWith('x', 10)
expect(buscarMock).toHaveBeenCalledWith('x', 10, undefined)
})
it('limite inválido o > 50 se clampa a 20', async () => {
buscarMock.mockResolvedValue([])
const r = res()
await buscarEndpoint(req({ q: 'x', limite: 'abc' }), r)
expect(buscarMock).toHaveBeenCalledWith('x', 20)
expect(buscarMock).toHaveBeenCalledWith('x', 20, undefined)
const r2 = res()
await buscarEndpoint(req({ q: 'x', limite: '100' }), r2)
expect(buscarMock).toHaveBeenCalledWith('x', 50)
expect(buscarMock).toHaveBeenCalledWith('x', 50, undefined)
})
it('vendedor: pasa alcance soloPropio (sólo ve sus propios registros)', async () => {
buscarMock.mockResolvedValue([])
const r = res()
const reqVendedor = {
query: { q: 'Ana' },
usuarioToken: { sub: 'u-9', username: 'v', rol: 'vendedor' },
} as unknown as Request
await buscarEndpoint(reqVendedor, r)
expect(buscarMock).toHaveBeenCalledWith('Ana', 20, { usuarioId: 'u-9', soloPropio: true })
})
it('admin: pasa alcance sin filtro (sigue viendo todo el universo)', async () => {
buscarMock.mockResolvedValue([])
const r = res()
const reqAdmin = {
query: { q: 'Ana' },
usuarioToken: { sub: 'u-1', username: 'a', rol: 'admin' },
} as unknown as Request
await buscarEndpoint(reqAdmin, r)
expect(buscarMock).toHaveBeenCalledWith('Ana', 20, { usuarioId: 'u-1', soloPropio: false })
})
it('devuelve los resultados del service como JSON', async () => {

View File

@@ -8,6 +8,13 @@ import { buscar } from '../services/buscar.service.js'
export async function buscarEndpoint(req: Request, res: Response): Promise<void> {
const q = typeof req.query.q === 'string' ? req.query.q : ''
const limite = typeof req.query.limite === 'string' ? Math.min(parseInt(req.query.limite, 10) || 20, 50) : 20
const resultados = await buscar(q, limite)
// Privacidad: el vendedor sólo encuentra sus propios leads, contactos y
// cotizaciones; los roles de supervisión siguen viendo todo el universo.
const soloPropio = req.usuarioToken?.rol === 'vendedor'
const resultados = await buscar(
q,
limite,
req.usuarioToken ? { usuarioId: req.usuarioToken.sub, soloPropio } : undefined,
)
res.json(resultados)
}

View File

@@ -363,5 +363,13 @@ export async function moverLeadEndpoint(req: Request, res: Response): Promise<vo
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')
res.json(await crmLocal.buscarLocales(q))
// Privacidad: el vendedor sólo encuentra sus propios leads/contactos; los
// roles de supervisión siguen viendo todo el universo.
const soloPropio = req.usuarioToken?.rol === 'vendedor'
res.json(
await crmLocal.buscarLocales(
q,
req.usuarioToken ? { usuarioId: req.usuarioToken.sub, soloPropio } : undefined,
),
)
}

View File

@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const svcMock = vi.hoisted(() => ({
crear: vi.fn(),
listar: vi.fn(),
listarPorCliente: vi.fn(),
completar: vi.fn(),
eliminar: vi.fn(),
proximos: vi.fn(),
@@ -72,6 +73,33 @@ describe('recordatorio-vendedor.controller', () => {
expect(res.json).toHaveBeenCalledWith([])
})
it('bitácora: con leadId devuelve las notas de TODOS los usuarios (listarPorCliente)', async () => {
svcMock.listarPorCliente.mockResolvedValue([{ id: 'n1', titulo: 'Nota de otro asesor' }])
const res = resJson()
await controller.listar(
reqConToken('u-1', { query: { leadId: 'loc_9' } }),
res as never,
)
expect(svcMock.listarPorCliente).toHaveBeenCalledWith({ leadId: 'loc_9', clienteClave: null })
expect(svcMock.listar).not.toHaveBeenCalled()
expect(res.json).toHaveBeenCalledWith([{ id: 'n1', titulo: 'Nota de otro asesor' }])
})
it('bitácora: con clienteClave (y sin leadId) también usa listarPorCliente', async () => {
svcMock.listarPorCliente.mockResolvedValue([])
const res = resJson()
await controller.listar(
reqConToken('u-1', { query: { clienteClave: 'ana prueba|4771234567' } }),
res as never,
)
expect(svcMock.listarPorCliente).toHaveBeenCalledWith({ leadId: null, clienteClave: 'ana prueba|4771234567' })
expect(svcMock.listar).not.toHaveBeenCalled()
})
it('completar: llama al service con el id', async () => {
svcMock.completar.mockResolvedValue({ id: 'r1', completado: true })
const res = resJson()

View File

@@ -39,6 +39,17 @@ export async function listarRecordatoriosEndpoint(req: Request, res: Response):
const usuarioId = req.usuarioToken?.sub
if (!usuarioId) throw new Error('Auth requerida')
// Bitácora: cuando piden las notas de un lead/cliente concreto, se devuelven
// TODAS las notas de ese cliente (de todos los usuarios). Así cualquier
// usuario que abra la bitácora ve el seguimiento completo, no sólo las
// notas que él mismo escribió.
const leadId = (req.query['leadId'] as string) ?? null
const clienteClave = (req.query['clienteClave'] as string) ?? null
if (leadId !== null || clienteClave !== null) {
res.json(await recordatorioVendedorService.listarPorCliente({ leadId, clienteClave }))
return
}
const completadosStr = req.query['completados'] as string | undefined
const opts: { completados?: boolean; desde?: string | null; hasta?: string | null; clienteClave?: string | null } = {
desde: (req.query['desde'] as string) ?? null,
@@ -89,6 +100,14 @@ export function crearController(svc: RecordatorioVendedorService) {
listar: async (req: Request, res: Response): Promise<void> => {
const usuarioId = req.usuarioToken?.sub
if (!usuarioId) throw new Error('Auth requerida')
// Bitácora: con leadId/clienteClave devuelve las notas de TODOS los
// usuarios (el seguimiento del cliente es compartido)
const leadId = (req.query['leadId'] as string) ?? null
const clienteClave = (req.query['clienteClave'] as string) ?? null
if (leadId !== null || clienteClave !== null) {
res.json(await svc.listarPorCliente({ leadId, clienteClave }))
return
}
const completadosStr = req.query['completados'] as string | undefined
const opts: {
completados?: boolean