feat(mejoras): 14 mejoras al CRM Frasa
Fase 1 - UI: dark mode, skeletons, toasts Fase 2 - Filtros: avanzados Mi día, búsqueda global Ctrl+K Fase 3 - Datos: export Excel, notas apartados, auditoría Fase 4 - Dashboard: gráficas vendedor + métricas uso Fase 5 - Responsive: tablets optimizadas Fase 6 - PWA: IndexedDB offline, push notifications Backend: 3 tablas nuevas, 6 services, 6 endpoints nuevos 721 API tests + fronts verdes.
This commit is contained in:
@@ -28,6 +28,9 @@ const prismaMock = vi.hoisted(() => ({
|
||||
syncPendiente: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
auditoriaLog: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
}))
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { saludMonday } from '../services/salud-monday.service.js'
|
||||
import { crmLocal, encolarSyncSeguro, esIdLocal } from '../services/crm-local.service.js'
|
||||
import { esFalloMonday } from '../services/sync-monday.service.js'
|
||||
import { checkboxParaTipoDocumento } from './clientes.controller.js'
|
||||
import * as auditoria from '../services/auditoria.service.js'
|
||||
|
||||
/**
|
||||
* Pipeline Enkontrol del tablero Apartado: las fases que el vendedor puede
|
||||
@@ -76,6 +77,7 @@ export async function cambiarEstadoApartadoEndpoint(req: Request, res: Response)
|
||||
}
|
||||
}
|
||||
res.json({ itemId, estado: body.estado })
|
||||
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'CAMBIAR_ESTADO', 'apartado', itemId, body.estado)
|
||||
}
|
||||
|
||||
// ---------- Precarga de documentos del expediente (desde "Mis apartados") ----------
|
||||
|
||||
93
api-gateway/src/controllers/auditoria.controller.test.ts
Normal file
93
api-gateway/src/controllers/auditoria.controller.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Request, Response } from 'express'
|
||||
import { AppError } from '../utils/errors.js'
|
||||
|
||||
// ---------- Prisma mockeado ----------
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
auditoriaLog: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
||||
|
||||
import { listarAuditoriaEndpoint } from './auditoria.controller.js'
|
||||
|
||||
function reqFake(query?: Record<string, string>, token?: { sub: string; rol: string }): Request {
|
||||
return { query, usuarioToken: token } as unknown 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()
|
||||
})
|
||||
|
||||
describe('listarAuditoriaEndpoint (GET /api/auditoria)', () => {
|
||||
it('retorna logs de auditoría para admin', async () => {
|
||||
const logsFake = [{ id: 'log-1', usuarioId: 'u1', accion: 'CREAR_LEAD', entidad: 'lead', entidadId: 'l1', detalle: null, createdAt: new Date() }]
|
||||
prismaMock.auditoriaLog.findMany.mockResolvedValue(logsFake)
|
||||
const { res, json } = resFake()
|
||||
|
||||
await listarAuditoriaEndpoint(reqFake({}, { sub: 'admin1', rol: 'admin' }), res)
|
||||
expect(json).toHaveBeenCalledWith(logsFake)
|
||||
})
|
||||
|
||||
it('aplica filtros de entidad y usuarioId', async () => {
|
||||
prismaMock.auditoriaLog.findMany.mockResolvedValue([])
|
||||
const { res } = resFake()
|
||||
|
||||
await listarAuditoriaEndpoint(
|
||||
reqFake({ entidad: 'lead', usuarioId: 'u1' }, { sub: 'admin1', rol: 'admin' }),
|
||||
res,
|
||||
)
|
||||
expect(prismaMock.auditoriaLog.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({ entidad: 'lead', usuarioId: 'u1' }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('aplica filtro de fechas', async () => {
|
||||
prismaMock.auditoriaLog.findMany.mockResolvedValue([])
|
||||
const { res } = resFake()
|
||||
|
||||
await listarAuditoriaEndpoint(
|
||||
reqFake({ desde: '2026-09-01', hasta: '2026-09-07' }, { sub: 'admin1', rol: 'admin' }),
|
||||
res,
|
||||
)
|
||||
expect(prismaMock.auditoriaLog.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
createdAt: expect.objectContaining({ gte: expect.any(Date), lte: expect.any(Date) }),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('respeta el límite personalizado', async () => {
|
||||
prismaMock.auditoriaLog.findMany.mockResolvedValue([])
|
||||
const { res } = resFake()
|
||||
|
||||
await listarAuditoriaEndpoint(
|
||||
reqFake({ limite: '10' }, { sub: 'admin1', rol: 'admin' }),
|
||||
res,
|
||||
)
|
||||
expect(prismaMock.auditoriaLog.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ take: 10 }),
|
||||
)
|
||||
})
|
||||
|
||||
it('retorna array vacío si no hay logs', async () => {
|
||||
prismaMock.auditoriaLog.findMany.mockResolvedValue([])
|
||||
const { res, json } = resFake()
|
||||
|
||||
await listarAuditoriaEndpoint(reqFake({}, { sub: 'admin1', rol: 'admin' }), res)
|
||||
expect(json).toHaveBeenCalledWith([])
|
||||
})
|
||||
})
|
||||
31
api-gateway/src/controllers/auditoria.controller.ts
Normal file
31
api-gateway/src/controllers/auditoria.controller.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { buscar } from '../services/auditoria.service.js'
|
||||
|
||||
const buscarSchema = z.object({
|
||||
entidad: z.string().optional(),
|
||||
entidadId: z.string().optional(),
|
||||
usuarioId: z.string().optional(),
|
||||
desde: z.string().optional(),
|
||||
hasta: z.string().optional(),
|
||||
limite: z.coerce.number().int().min(1).max(500).optional(),
|
||||
})
|
||||
|
||||
/** GET /api/auditoria — admin only: listar logs de auditoría con filtros */
|
||||
export async function listarAuditoriaEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const query = buscarSchema.parse(req.query)
|
||||
const filtros: {
|
||||
entidad?: string
|
||||
entidadId?: string
|
||||
usuarioId?: string
|
||||
desde?: Date
|
||||
hasta?: Date
|
||||
} = {}
|
||||
if (query.entidad !== undefined) filtros.entidad = query.entidad
|
||||
if (query.entidadId !== undefined) filtros.entidadId = query.entidadId
|
||||
if (query.usuarioId !== undefined) filtros.usuarioId = query.usuarioId
|
||||
if (query.desde !== undefined) filtros.desde = new Date(query.desde)
|
||||
if (query.hasta !== undefined) filtros.hasta = new Date(query.hasta)
|
||||
const logs = await buscar(filtros, query.limite ?? 100)
|
||||
res.json(logs)
|
||||
}
|
||||
67
api-gateway/src/controllers/buscar.controller.test.ts
Normal file
67
api-gateway/src/controllers/buscar.controller.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Request, Response } from 'express'
|
||||
|
||||
// ---------- Service mockeado ----------
|
||||
|
||||
const buscarMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('../services/buscar.service.js', () => ({ buscar: buscarMock }))
|
||||
|
||||
import { buscarEndpoint } from './buscar.controller.js'
|
||||
|
||||
function req(query: Record<string, string> = {}): Request {
|
||||
return { query } as unknown as Request
|
||||
}
|
||||
function res(): Response {
|
||||
const json = vi.fn()
|
||||
return { json } as unknown as Response
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('buscarEndpoint (GET /api/buscar)', () => {
|
||||
it('extrae q del query string y llama buscar', async () => {
|
||||
buscarMock.mockResolvedValue([])
|
||||
const r = res()
|
||||
await buscarEndpoint(req({ q: 'María' }), r)
|
||||
expect(buscarMock).toHaveBeenCalledWith('María', 20)
|
||||
expect(r.json).toHaveBeenCalledWith([])
|
||||
})
|
||||
|
||||
it('q vacío (no string) → termino vacío', async () => {
|
||||
buscarMock.mockResolvedValue([])
|
||||
const r = res()
|
||||
await buscarEndpoint(req({}), r)
|
||||
expect(buscarMock).toHaveBeenCalledWith('', 20)
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
const r2 = res()
|
||||
await buscarEndpoint(req({ q: 'x', limite: '100' }), r2)
|
||||
expect(buscarMock).toHaveBeenCalledWith('x', 50)
|
||||
})
|
||||
|
||||
it('devuelve los resultados del service como JSON', async () => {
|
||||
const fakeResults = [
|
||||
{ tipo: 'lead', id: 'l1', nombre: 'Test', detalle: '', url: '/app/#leads' },
|
||||
]
|
||||
buscarMock.mockResolvedValue(fakeResults)
|
||||
const r = res()
|
||||
await buscarEndpoint(req({ q: 'Test' }), r)
|
||||
expect(r.json).toHaveBeenCalledWith(fakeResults)
|
||||
})
|
||||
})
|
||||
13
api-gateway/src/controllers/buscar.controller.ts
Normal file
13
api-gateway/src/controllers/buscar.controller.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import { buscar } from '../services/buscar.service.js'
|
||||
|
||||
/**
|
||||
* GET /api/buscar?q=... — búsqueda global (leads, contactos, cotizaciones,
|
||||
* unidades). Requiere autenticación; devuelve array de BuscadoItem[].
|
||||
*/
|
||||
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)
|
||||
res.json(resultados)
|
||||
}
|
||||
@@ -17,9 +17,12 @@ const prismaMock = vi.hoisted(() => ({
|
||||
usuario: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
syncPendiente: {
|
||||
syncPendiente: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
auditoriaLog: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
}))
|
||||
|
||||
const mondayMock = vi.hoisted(() => ({
|
||||
|
||||
@@ -13,6 +13,7 @@ import { encolarSyncSeguro, esIdLocal } from '../services/crm-local.service.js'
|
||||
import { saludMonday } from '../services/salud-monday.service.js'
|
||||
import { esFalloMonday } from '../services/sync-monday.service.js'
|
||||
import { NotFoundError, BadRequestError } from '../utils/errors.js'
|
||||
import * as auditoria from '../services/auditoria.service.js'
|
||||
|
||||
// ---------- Alta de clientes ----------
|
||||
|
||||
@@ -420,6 +421,7 @@ export async function crearCliente(req: Request, res: Response): Promise<void> {
|
||||
}
|
||||
|
||||
res.status(201).json({ ...cliente, mondayContactoId, mondayApartadoId })
|
||||
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'CREAR_CLIENTE', 'cliente', cliente.id)
|
||||
}
|
||||
|
||||
// ---------- Alta progresiva (guardado etapa por etapa del wizard) ----------
|
||||
|
||||
@@ -12,7 +12,7 @@ const prismaMock = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
syncPendiente: {
|
||||
syncPendiente: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
}))
|
||||
@@ -30,6 +30,10 @@ const funnelMock = vi.hoisted(() => ({
|
||||
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
||||
vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock }))
|
||||
vi.mock('../services/funnel.service.js', () => ({ funnelService: funnelMock }))
|
||||
vi.mock('../services/auditoria.service.js', () => ({
|
||||
registrar: vi.fn(),
|
||||
buscar: vi.fn(),
|
||||
}))
|
||||
|
||||
import { crearCotizacion } from './cotizaciones.controller.js'
|
||||
import { saludMonday } from '../services/salud-monday.service.js'
|
||||
|
||||
@@ -8,9 +8,12 @@ const prismaMock = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
syncPendiente: {
|
||||
syncPendiente: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
auditoriaLog: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
}))
|
||||
|
||||
const mondayMock = vi.hoisted(() => ({
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { prisma } from '../services/prisma.js'
|
||||
import { encolarSyncSeguro } from '../services/crm-local.service.js'
|
||||
import { NotFoundError } from '../utils/errors.js'
|
||||
import * as auditoria from '../services/auditoria.service.js'
|
||||
import { folioCotizacion, vigenciaCotizacion } from '../utils/folio.js'
|
||||
|
||||
const crearCotizacionSchema = z.object({
|
||||
@@ -130,6 +131,7 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
|
||||
}
|
||||
|
||||
res.status(201).json({ ...cotizacion, folio: folioCotizacion(cotizacion.id), mondayItemId, propuesta })
|
||||
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'CREAR_COTIZACION', 'cotizacion', cotizacion.id)
|
||||
}
|
||||
|
||||
/** GET /api/cotizaciones/mias — últimas 50 del vendedor autenticado (sub del JWT) */
|
||||
|
||||
@@ -47,6 +47,9 @@ const prismaMock = vi.hoisted(() => ({
|
||||
cliente: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
auditoriaLog: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
}))
|
||||
|
||||
@@ -473,6 +476,7 @@ describe('descartarLead (POST /api/leads/:itemId/descartar)', () => {
|
||||
describe('crearLeadEndpoint (POST /api/leads)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
prismaMock.auditoriaLog.create.mockResolvedValue({})
|
||||
// El singleton Monday responde como si creara el lead en el tablero Leads
|
||||
vi.spyOn(mondayService, 'buscarLeadPorTelefono').mockResolvedValue(null)
|
||||
vi.spyOn(mondayService, 'crearLead').mockResolvedValue('lead-nuevo-9')
|
||||
@@ -569,6 +573,7 @@ describe('crearLeadEndpoint (POST /api/leads)', () => {
|
||||
describe('crearAfluenciaEndpoint (POST /api/afluencias)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
prismaMock.auditoriaLog.create.mockResolvedValue({})
|
||||
// El singleton Monday responde como si creara la afluencia en Oportunidades
|
||||
vi.spyOn(mondayService, 'buscarOportunidadPorTelefono').mockResolvedValue(null)
|
||||
vi.spyOn(mondayService, 'crearOportunidadAfluencia').mockResolvedValue('op-nueva-9')
|
||||
@@ -747,6 +752,7 @@ describe('convertirLead (POST /api/leads/:itemId/convertir) — botón "Agendo"
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
prismaMock.auditoriaLog.create.mockResolvedValue({})
|
||||
prismaMock.leadOportunidad.findUnique.mockResolvedValue(null)
|
||||
prismaMock.leadOportunidad.create.mockResolvedValue({})
|
||||
prismaMock.automatizacionState.upsert.mockResolvedValue({})
|
||||
@@ -827,6 +833,7 @@ describe('ruteo modo local (Monday caído o fallback 502)', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
prismaMock.auditoriaLog.create.mockResolvedValue({})
|
||||
prismaMock.leadCaptura.findFirst.mockResolvedValue(null) // sin duplicados locales
|
||||
prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
|
||||
prismaMock.leadCaptura.findMany.mockResolvedValue([])
|
||||
|
||||
@@ -7,6 +7,7 @@ import { automatizacionesService, CLAVE_RR_SKIP, prismaStateStore } from '../ser
|
||||
import { prisma } from '../services/prisma.js'
|
||||
import { saludMonday } from '../services/salud-monday.service.js'
|
||||
import { crmLocal, encolarSyncSeguro, esIdLocal } from '../services/crm-local.service.js'
|
||||
import * as auditoria from '../services/auditoria.service.js'
|
||||
import { esFalloMonday } from '../services/sync-monday.service.js'
|
||||
import { MOTIVOS_NO_COMPRA, SUBCATEGORIAS_NO_COMPRA } from './oportunidades.controller.js'
|
||||
import { AppError } from '../utils/errors.js'
|
||||
@@ -500,6 +501,7 @@ export async function crearLeadEndpoint(req: Request, res: Response): Promise<vo
|
||||
}
|
||||
const asignacion = await asignarCapturistaSiAplica(usuarioSub, resultado.id, body.nombre, 'leads')
|
||||
res.status(201).json({ id: resultado.id, estado: 'NUEVO', oportunidadId: null, asignacion })
|
||||
auditoria.registrar(usuarioSub ?? 'sistema', 'CREAR_LEAD', 'lead', resultado.id)
|
||||
}
|
||||
|
||||
/** POST /api/afluencias { nombre, telefono?, ..., perfil completo } — Alta Afluencia (tablero Oportunidades) */
|
||||
@@ -620,6 +622,7 @@ export async function cambiarEstadoLeadEndpoint(req: Request, res: Response): Pr
|
||||
}
|
||||
}
|
||||
res.json({ itemId, estado: body.estado, ...(body.fecha !== undefined ? { fecha: body.fecha } : {}) })
|
||||
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'CAMBIAR_ESTADO', 'lead', itemId, body.estado)
|
||||
}
|
||||
|
||||
/** POST /api/leads/:itemId/descartar { motivo, subcategoria?, comentario?, fuente? } */
|
||||
@@ -651,6 +654,7 @@ export async function descartarLeadEndpoint(req: Request, res: Response): Promis
|
||||
}
|
||||
}
|
||||
res.json({ itemId, estado: body.fuente === 'lead' ? 'Descartado' : 'Perdido', motivo: body.motivo, motivoItemId })
|
||||
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'DESCARTAR_LEAD', 'lead', itemId, body.motivo)
|
||||
}
|
||||
|
||||
// ---------- Conversión lead → oportunidad (botón "Agendo" de Mis leads) ----------
|
||||
@@ -742,15 +746,18 @@ export async function convertirLeadEndpoint(req: Request, res: Response): Promis
|
||||
if (esIdLocal(itemId) || !saludMonday.estaDisponible()) {
|
||||
const oportunidad = await crmLocal.convertirLead(itemId, usuarioSub)
|
||||
res.status(201).json({ leadId: itemId, oportunidadId: oportunidad.leadId, etapa: 'Agendo Cita' })
|
||||
auditoria.registrar(usuarioSub ?? 'sistema', 'CONVERTIR_LEAD', 'lead', itemId)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { oportunidadId } = await convertirLead(mondayService, itemId, usuarioSub, body.fecha)
|
||||
res.status(201).json({ leadId: itemId, oportunidadId, etapa: 'Agendo Cita' })
|
||||
auditoria.registrar(usuarioSub ?? 'sistema', 'CONVERTIR_LEAD', 'lead', itemId)
|
||||
} catch (err) {
|
||||
if (!esFalloMonday(err)) throw err
|
||||
const oportunidad = await crmLocal.convertirLead(itemId, usuarioSub)
|
||||
res.status(201).json({ leadId: itemId, oportunidadId: oportunidad.leadId, etapa: 'Agendo Cita' })
|
||||
auditoria.registrar(usuarioSub ?? 'sistema', 'CONVERTIR_LEAD', 'lead', itemId)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
69
api-gateway/src/controllers/notas.controller.test.ts
Normal file
69
api-gateway/src/controllers/notas.controller.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Request, Response } from 'express'
|
||||
|
||||
// ---------- Prisma mockeado ----------
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
notaApartado: {
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
||||
|
||||
import { crearNotaEndpoint, listarNotasEndpoint } from './notas.controller.js'
|
||||
|
||||
function reqFake(params: Record<string, string>, body?: unknown, token?: { sub: string }): Request {
|
||||
return { params, body, usuarioToken: token } as unknown 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()
|
||||
})
|
||||
|
||||
describe('crearNotaEndpoint (POST /api/apartados/:id/notas)', () => {
|
||||
it('crea una nota y retorna 201', async () => {
|
||||
const notaFake = { id: 'nota-1', apartadoId: 'apt-1', usuarioId: 'u1', contenido: 'Nota test', createdAt: new Date() }
|
||||
prismaMock.notaApartado.create.mockResolvedValue(notaFake)
|
||||
const { res, status, json } = resFake()
|
||||
|
||||
await crearNotaEndpoint(reqFake({ id: 'apt-1' }, { contenido: 'Nota test' }, { sub: 'u1' }), res)
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
expect(json).toHaveBeenCalledWith(notaFake)
|
||||
})
|
||||
|
||||
it('rechaza contenido vacío', async () => {
|
||||
const { res } = resFake()
|
||||
await expect(
|
||||
crearNotaEndpoint(reqFake({ id: 'apt-1' }, { contenido: '' }, { sub: 'u1' }), res),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('listarNotasEndpoint (GET /api/apartados/:id/notas)', () => {
|
||||
it('retorna las notas del apartado', async () => {
|
||||
const notasFake = [
|
||||
{ id: 'nota-1', apartadoId: 'apt-1', usuarioId: 'u1', contenido: 'A', createdAt: new Date() },
|
||||
]
|
||||
prismaMock.notaApartado.findMany.mockResolvedValue(notasFake)
|
||||
const { res, json } = resFake()
|
||||
|
||||
await listarNotasEndpoint(reqFake({ id: 'apt-1' }), res)
|
||||
expect(json).toHaveBeenCalledWith(notasFake)
|
||||
})
|
||||
|
||||
it('retorna array vacío si no hay notas', async () => {
|
||||
prismaMock.notaApartado.findMany.mockResolvedValue([])
|
||||
const { res, json } = resFake()
|
||||
|
||||
await listarNotasEndpoint(reqFake({ id: 'apt-inexistente' }), res)
|
||||
expect(json).toHaveBeenCalledWith([])
|
||||
})
|
||||
})
|
||||
23
api-gateway/src/controllers/notas.controller.ts
Normal file
23
api-gateway/src/controllers/notas.controller.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { crearNota, listarNotas } from '../services/nota-apartado.service.js'
|
||||
|
||||
const crearNotaSchema = z.object({
|
||||
contenido: z.string().trim().min(1, 'El contenido es obligatorio'),
|
||||
})
|
||||
|
||||
/** POST /api/apartados/:id/notas — crear nota del apartado */
|
||||
export async function crearNotaEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const body = crearNotaSchema.parse(req.body)
|
||||
const apartadoId = String(req.params['id'])
|
||||
const usuarioId = req.usuarioToken?.sub ?? 'desconocido'
|
||||
const nota = await crearNota(apartadoId, usuarioId, body.contenido)
|
||||
res.status(201).json(nota)
|
||||
}
|
||||
|
||||
/** GET /api/apartados/:id/notas — listar notas del apartado */
|
||||
export async function listarNotasEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const apartadoId = String(req.params['id'])
|
||||
const notas = await listarNotas(apartadoId)
|
||||
res.json(notas)
|
||||
}
|
||||
@@ -26,6 +26,9 @@ const prismaMock = vi.hoisted(() => ({
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
auditoriaLog: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
}))
|
||||
|
||||
@@ -170,6 +173,7 @@ describe('ruteo modo local (endpoints de oportunidades)', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
prismaMock.auditoriaLog.create.mockResolvedValue({})
|
||||
prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
|
||||
prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ ...data }))
|
||||
prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => ({ ...data }))
|
||||
|
||||
@@ -7,6 +7,7 @@ import { automatizacionesService } from '../services/automatizaciones.service.js
|
||||
import { saludMonday } from '../services/salud-monday.service.js'
|
||||
import { crmLocal, esIdLocal } from '../services/crm-local.service.js'
|
||||
import { esFalloMonday } from '../services/sync-monday.service.js'
|
||||
import * as auditoria from '../services/auditoria.service.js'
|
||||
|
||||
/** Categorías del dropdown "Motivo No Compra" (vocabulario CRM FRASA) */
|
||||
export const MOTIVOS_NO_COMPRA = [
|
||||
@@ -96,6 +97,7 @@ export async function perderOportunidadEndpoint(req: Request, res: Response): Pr
|
||||
}
|
||||
}
|
||||
res.json({ mondayItemId, etapa: 'Perdido', motivo: body.motivo, motivoItemId })
|
||||
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'PERDER_OPORTUNIDAD', 'oportunidad', mondayItemId, body.motivo)
|
||||
}
|
||||
|
||||
// ---------- Cambio de etapa desde Mi día ----------
|
||||
@@ -200,4 +202,5 @@ export async function cambiarEtapaOportunidadEndpoint(req: Request, res: Respons
|
||||
}
|
||||
}
|
||||
res.json({ itemId, etapa: body.etapa, ...(body.fecha !== undefined ? { fecha: body.fecha } : {}) })
|
||||
auditoria.registrar(usuarioSub ?? 'sistema', 'CAMBIAR_ETAPA', 'oportunidad', itemId, body.etapa)
|
||||
}
|
||||
|
||||
41
api-gateway/src/controllers/push.controller.ts
Normal file
41
api-gateway/src/controllers/push.controller.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
registrarSuscripcion,
|
||||
eliminarSuscripcion,
|
||||
obtenerVapidPublicKey,
|
||||
} from '../services/push.service.js'
|
||||
|
||||
const suscripcionSchema = z.object({
|
||||
endpoint: z.string().url(),
|
||||
p256dh: z.string().min(1),
|
||||
auth: z.string().min(1),
|
||||
})
|
||||
|
||||
/** POST /api/push/suscribir — registrar suscripción push del navegador */
|
||||
export async function suscribirEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const usuarioId = req.usuarioToken?.sub
|
||||
if (!usuarioId) {
|
||||
res.status(401).json({ error: 'Token requerido' })
|
||||
return
|
||||
}
|
||||
const body = suscripcionSchema.parse(req.body)
|
||||
await registrarSuscripcion(usuarioId, body)
|
||||
res.json({ ok: true })
|
||||
}
|
||||
|
||||
/** POST /api/push/desuscribir — eliminar suscripción push */
|
||||
export async function desuscribirEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const usuarioId = req.usuarioToken?.sub
|
||||
if (!usuarioId) {
|
||||
res.status(401).json({ error: 'Token requerido' })
|
||||
return
|
||||
}
|
||||
await eliminarSuscripcion(usuarioId)
|
||||
res.json({ ok: true })
|
||||
}
|
||||
|
||||
/** GET /api/push/vapid-key — devuelve la VAPID public key (pública) */
|
||||
export function vapidKeyEndpoint(_req: Request, res: Response): void {
|
||||
res.json({ publicKey: obtenerVapidPublicKey() })
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import type { Request, Response } from 'express'
|
||||
import { AppError } from '../utils/errors.js'
|
||||
|
||||
// ---------- Mocks ----------
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
usuario: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
||||
|
||||
// Mock del servicio: permitimos que construirMiTablero funcione real; solo
|
||||
// mockeamos obtenerMiTablero y obtenerMetricasUso para no tocar la BD
|
||||
const servicioMock = vi.hoisted(() => ({
|
||||
obtenerMiTablero: vi.fn(),
|
||||
obtenerMetricasUso: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../services/reporte-mi-tablero.service.js', () => servicioMock)
|
||||
|
||||
import { miTableroEndpoint, metricasUsoEndpoint, requireAdminOrCoordinador } from './reporte-mi-tablero.controller.js'
|
||||
|
||||
function resFake() {
|
||||
const json = vi.fn()
|
||||
const status = vi.fn().mockReturnValue({ json })
|
||||
return { res: { json, status } as unknown as Response, status, json }
|
||||
}
|
||||
|
||||
const usuarioFake = {
|
||||
id: 'u-1',
|
||||
nombre: 'Vendedor Test',
|
||||
rol: 'vendedor',
|
||||
activo: true,
|
||||
mondayUserId: 100,
|
||||
}
|
||||
|
||||
const tableroFake = {
|
||||
generadoEn: '2026-09-07T12:00:00.000Z',
|
||||
leadsPorDia: [],
|
||||
conversion: { leads: 5, oportunidades: 3, apartados: 1, vendidos: 1 },
|
||||
ventasMes: { monto: 850000, unidades: 1 },
|
||||
porOrigen: [],
|
||||
porDesarrollo: [],
|
||||
actividadesHoy: 2,
|
||||
actividadesPendientes: 5,
|
||||
}
|
||||
|
||||
const metricasFake = {
|
||||
generadoEn: '2026-09-07T12:00:00.000Z',
|
||||
usuarios: [],
|
||||
}
|
||||
|
||||
// ---------- miTableroEndpoint ----------
|
||||
|
||||
describe('miTableroEndpoint', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake)
|
||||
servicioMock.obtenerMiTablero.mockResolvedValue(tableroFake)
|
||||
})
|
||||
|
||||
it('devuelve el tablero del vendedor logueado', async () => {
|
||||
const { res, json } = resFake()
|
||||
const req = { usuarioToken: { sub: 'u-1' } } as unknown as Request
|
||||
await miTableroEndpoint(req, res)
|
||||
expect(json).toHaveBeenCalledWith(tableroFake)
|
||||
expect(servicioMock.obtenerMiTablero).toHaveBeenCalledWith('u-1')
|
||||
})
|
||||
|
||||
it('lanza 401 si el usuario no existe', async () => {
|
||||
prismaMock.usuario.findUnique.mockResolvedValue(null)
|
||||
const req = { usuarioToken: { sub: 'u-unknown' } } as unknown as Request
|
||||
const { res } = resFake()
|
||||
await expect(miTableroEndpoint(req, res)).rejects.toThrow(AppError)
|
||||
})
|
||||
|
||||
it('lanza 401 si el usuario está inactivo', async () => {
|
||||
prismaMock.usuario.findUnique.mockResolvedValue({ ...usuarioFake, activo: false })
|
||||
const req = { usuarioToken: { sub: 'u-1' } } as unknown as Request
|
||||
const { res } = resFake()
|
||||
await expect(miTableroEndpoint(req, res)).rejects.toThrow(AppError)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------- metricasUsoEndpoint ----------
|
||||
|
||||
describe('metricasUsoEndpoint', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
servicioMock.obtenerMetricasUso.mockResolvedValue(metricasFake)
|
||||
})
|
||||
|
||||
it('devuelve métricas generales para admin', async () => {
|
||||
const { res, json } = resFake()
|
||||
const req = { usuarioToken: { sub: 'u-admin', rol: 'admin' } } as unknown as Request
|
||||
await metricasUsoEndpoint(req, res)
|
||||
expect(json).toHaveBeenCalledWith(metricasFake)
|
||||
// Admin no filtra por usuario
|
||||
expect(servicioMock.obtenerMetricasUso).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
|
||||
it('filtra por usuarioId para coordinador', async () => {
|
||||
const { res, json } = resFake()
|
||||
const req = { usuarioToken: { sub: 'u-coord', rol: 'coordinador' } } as unknown as Request
|
||||
await metricasUsoEndpoint(req, res)
|
||||
expect(servicioMock.obtenerMetricasUso).toHaveBeenCalledWith('u-coord')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------- requireAdminOrCoordinador ----------
|
||||
|
||||
describe('requireAdminOrCoordinador', () => {
|
||||
it('llama next() para admin', () => {
|
||||
const next = vi.fn()
|
||||
const req = { usuarioToken: { rol: 'admin' } } as unknown as Request
|
||||
requireAdminOrCoordinador(req, {} as Response, next)
|
||||
expect(next).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('llama next() para coordinador', () => {
|
||||
const next = vi.fn()
|
||||
const req = { usuarioToken: { rol: 'coordinador' } } as unknown as Request
|
||||
requireAdminOrCoordinador(req, {} as Response, next)
|
||||
expect(next).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lanza 403 para vendedor', () => {
|
||||
const req = { usuarioToken: { rol: 'vendedor' } } as unknown as Request
|
||||
expect(() => requireAdminOrCoordinador(req, {} as Response, vi.fn())).toThrow(AppError)
|
||||
})
|
||||
|
||||
it('lanza 403 para corporativo', () => {
|
||||
const req = { usuarioToken: { rol: 'corporativo' } } as unknown as Request
|
||||
expect(() => requireAdminOrCoordinador(req, {} as Response, vi.fn())).toThrow(AppError)
|
||||
})
|
||||
})
|
||||
52
api-gateway/src/controllers/reporte-mi-tablero.controller.ts
Normal file
52
api-gateway/src/controllers/reporte-mi-tablero.controller.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
import { obtenerMiTablero, obtenerMetricasUso } from '../services/reporte-mi-tablero.service.js'
|
||||
import { prisma } from '../services/prisma.js'
|
||||
import { AppError } from '../utils/errors.js'
|
||||
|
||||
// ---------- GET /api/reportes/mi-tablero ----------
|
||||
|
||||
/**
|
||||
* Tablero personal del vendedor logueado: leads por día (30 días), conversión,
|
||||
* ventas del mes, rendimiento por origen, ranking de desarrollos y actividades.
|
||||
*/
|
||||
export async function miTableroEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const sub = req.usuarioToken?.sub
|
||||
const usuario = sub
|
||||
? await prisma.usuario.findUnique({ where: { id: sub } })
|
||||
: await prisma.usuario.findUnique({ where: { username: 'admin' } })
|
||||
if (!usuario || !usuario.activo) throw new AppError(401, 'Credenciales inválidas')
|
||||
|
||||
const tablero = await obtenerMiTablero(usuario.id)
|
||||
res.json(tablero)
|
||||
}
|
||||
|
||||
// ---------- GET /api/reportes/metricas-uso ----------
|
||||
|
||||
/** Middleware: admin o coordinador */
|
||||
export function requireAdminOrCoordinador(
|
||||
req: Request,
|
||||
_res: Response,
|
||||
next: NextFunction,
|
||||
): void {
|
||||
const rol = req.usuarioToken?.rol
|
||||
if (rol !== 'admin' && rol !== 'coordinador') {
|
||||
throw new AppError(403, 'Se requiere rol admin o coordinador')
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
/**
|
||||
* Métricas de uso por usuario: leads capturados (hoy, semana, mes), tasa de
|
||||
* descarte, actividades completadas vs pendientes.
|
||||
* Admin ve todos; coordinador ve solo su equipo.
|
||||
*/
|
||||
export async function metricasUsoEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const rol = req.usuarioToken?.rol
|
||||
const sub = req.usuarioToken?.sub
|
||||
|
||||
// Admin ve todos; coordinador podría filtrarse por equipo futuro, pero por
|
||||
// ahora admin y coordinador ven lo mismo (todos los usuarios activos)
|
||||
const usuarioId = rol === 'coordinador' && sub ? sub : undefined
|
||||
const metricas = await obtenerMetricasUso(usuarioId)
|
||||
res.json(metricas)
|
||||
}
|
||||
Reference in New Issue
Block a user