fix(auditoria completa): bloqueos de Mi Dia, cobranza (saldos y fechas), captura (departamento y rol), contactos (privacidad), cotizador (QR) y validaciones de API
This commit is contained in:
@@ -1,10 +1,31 @@
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
import { z } from 'zod'
|
||||
import * as capturaService from '../services/captura-departamento.service.js'
|
||||
import { DEPARTAMENTOS } from '../config/campos-departamento.js'
|
||||
import { CAMPOS_POR_DEPARTAMENTO, DEPARTAMENTOS } from '../config/campos-departamento.js'
|
||||
import { AppError, BadRequestError } from '../utils/errors.js'
|
||||
import { esAdminOManager } from '../utils/roles.js'
|
||||
|
||||
// ── Validación de campos por departamento ────────────────────────────
|
||||
|
||||
/**
|
||||
* Cada campo debe pertenecer al departamento que se está guardando: si el
|
||||
* cliente manda un campo de otro departamento (p. ej. una captura de Obra
|
||||
* enviada como Ventas) se rechaza en vez de crear una fila basura.
|
||||
*/
|
||||
function validarCamposDelDepartamento(
|
||||
departamento: string,
|
||||
registros: { unidad: string; campo: string; valor: string | null }[],
|
||||
): void {
|
||||
const permitidos = new Set((CAMPOS_POR_DEPARTAMENTO[departamento] ?? []).map((c) => c.campo))
|
||||
const invalidos = registros.filter((r) => !permitidos.has(r.campo))
|
||||
if (invalidos.length > 0) {
|
||||
const lista = invalidos.slice(0, 3).map((r) => `${r.campo} (unidad ${r.unidad})`).join(', ')
|
||||
throw new BadRequestError(
|
||||
`El campo no pertenece al departamento ${departamento}: ${lista}${invalidos.length > 3 ? '…' : ''}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Middleware admin ─────────────────────────────────────────────────
|
||||
|
||||
function requireAdmin(req: Request, _res: Response, next: NextFunction): void {
|
||||
@@ -68,6 +89,7 @@ export async function guardarEndpoint(req: Request, res: Response): Promise<void
|
||||
if (!DEPARTAMENTOS.includes(body.departamento as typeof DEPARTAMENTOS[number])) {
|
||||
throw new BadRequestError(`Departamento inválido: ${body.departamento}`)
|
||||
}
|
||||
validarCamposDelDepartamento(body.departamento, [{ unidad: body.unidadCodigo, campo: body.campo, valor: body.valor ?? null }])
|
||||
|
||||
await capturaService.guardarCampo(
|
||||
body.desarrollo,
|
||||
@@ -102,6 +124,10 @@ export async function bulkEndpoint(req: Request, res: Response): Promise<void> {
|
||||
if (!DEPARTAMENTOS.includes(body.departamento as typeof DEPARTAMENTOS[number])) {
|
||||
throw new BadRequestError(`Departamento inválido: ${body.departamento}`)
|
||||
}
|
||||
validarCamposDelDepartamento(
|
||||
body.departamento,
|
||||
body.registros.map((r) => ({ unidad: r.unidad, campo: r.campo, valor: r.valor ?? null })),
|
||||
)
|
||||
|
||||
await capturaService.guardarBulk(
|
||||
body.desarrollo,
|
||||
|
||||
@@ -1,7 +1,26 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import { detalleContactoLocal, listarContactosLocal, exportarCSV } from '../services/contactos-local.service.js'
|
||||
import {
|
||||
detalleContactoLocal,
|
||||
esContactoDelUsuario,
|
||||
listarContactosLocal,
|
||||
exportarCSV,
|
||||
} from '../services/contactos-local.service.js'
|
||||
import { perfilCompleto } from '../services/contactos-detalle.service.js'
|
||||
import { NotFoundError } from '../utils/errors.js'
|
||||
import { esAdminOManager } from '../utils/roles.js'
|
||||
|
||||
/** Roles que ven el directorio completo de contactos (no sólo los suyos) */
|
||||
const ROLES_VEN_TODO = ['admin', 'manager', 'direccion_general', 'director_ventas', 'administracion_venta']
|
||||
|
||||
/**
|
||||
* ¿Este token ve todos los contactos? Sin token (AUTH_ENABLED=false, modo
|
||||
* local) se permite todo, igual que hace /mi-dia/resumen; con token, sólo los
|
||||
* roles de dirección ven el directorio completo (un vendedor ve lo suyo).
|
||||
*/
|
||||
function veTodoElDirectorio(token: { sub?: string; rol?: string } | undefined): boolean {
|
||||
if (token === undefined) return true
|
||||
return esAdminOManager(token.rol) || (token.rol !== undefined && ROLES_VEN_TODO.includes(token.rol))
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/contactos (Bearer) → contactos locales (tabla Cliente + LeadCaptura:
|
||||
@@ -12,7 +31,9 @@ import { NotFoundError } from '../utils/errors.js'
|
||||
export async function listarContactosEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const paginaParam = Number(req.query['pagina'])
|
||||
const pagina = Number.isFinite(paginaParam) && paginaParam >= 1 ? Math.floor(paginaParam) : 1
|
||||
res.json(await listarContactosLocal(req.usuarioToken?.sub, pagina))
|
||||
// Dirección/administración ven el directorio completo; un vendedor sólo lo suyo
|
||||
const usuarioId = veTodoElDirectorio(req.usuarioToken) ? undefined : req.usuarioToken?.sub
|
||||
res.json(await listarContactosLocal(usuarioId, pagina))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -23,7 +44,13 @@ export async function listarContactosEndpoint(req: Request, res: Response): Prom
|
||||
* cotizaciones locales).
|
||||
*/
|
||||
export async function detalleContactoEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const detalle = await detalleContactoLocal(String(req.params['id']))
|
||||
const contactoId = String(req.params['id'])
|
||||
// Sin permiso de directorio, el contacto debe ser suyo
|
||||
const sub = req.usuarioToken?.sub
|
||||
if (!veTodoElDirectorio(req.usuarioToken) && (!sub || !(await esContactoDelUsuario(contactoId, sub)))) {
|
||||
throw new NotFoundError('Contacto no encontrado')
|
||||
}
|
||||
const detalle = await detalleContactoLocal(contactoId)
|
||||
if (detalle === null) throw new NotFoundError('Contacto no encontrado')
|
||||
res.json(detalle)
|
||||
}
|
||||
@@ -34,6 +61,10 @@ export async function detalleContactoEndpoint(req: Request, res: Response): Prom
|
||||
*/
|
||||
export async function perfilCompletoEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const contactoId = String(req.params['id'])
|
||||
const sub = req.usuarioToken?.sub
|
||||
if (!veTodoElDirectorio(req.usuarioToken) && (!sub || !(await esContactoDelUsuario(contactoId, sub)))) {
|
||||
throw new NotFoundError('Contacto no encontrado')
|
||||
}
|
||||
const perfil = await perfilCompleto(contactoId)
|
||||
if (perfil === null) throw new NotFoundError('Contacto no encontrado')
|
||||
res.json(perfil)
|
||||
@@ -47,6 +78,7 @@ export async function exportarContactosEndpoint(req: Request, res: Response): Pr
|
||||
const csv = await exportarCSV({
|
||||
desarrollo: (req.query['desarrollo'] as string) ?? undefined,
|
||||
estatus: (req.query['estatus'] as string) ?? undefined,
|
||||
...(veTodoElDirectorio(req.usuarioToken) ? {} : { usuarioId: req.usuarioToken?.sub }),
|
||||
})
|
||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8')
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="contactos.csv"')
|
||||
|
||||
@@ -113,16 +113,15 @@ describe('resumenMiDiaEndpoint (100% local)', () => {
|
||||
expect(JSON.stringify(resumen)).not.toContain('pendienteSync')
|
||||
})
|
||||
|
||||
it('los usuarios del equipo solo se consultan para roles coordinador/admin/corporativo', async () => {
|
||||
await resumenMiDiaEndpoint(reqFake, resFake().res)
|
||||
expect(prismaMock.usuario.findMany).not.toHaveBeenCalled()
|
||||
|
||||
prismaMock.usuario.findUnique.mockResolvedValue({ ...usuarioFake, rol: 'coordinador' })
|
||||
it('los usuarios se consultan SIEMPRE (resuelven el vendedor asignado de cada fila)', async () => {
|
||||
// Aunque el rol no tenga sección de equipo, los nombres son necesarios para
|
||||
// mostrar "vendedor asignado" y el capturista en las tarjetas y la bitácora.
|
||||
await resumenMiDiaEndpoint(reqFake, resFake().res)
|
||||
expect(prismaMock.usuario.findMany).toHaveBeenCalledWith({
|
||||
where: { activo: true },
|
||||
select: { id: true, nombre: true, rol: true, activo: true },
|
||||
})
|
||||
expect(prismaMock.usuario.findUnique).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('un error de BD se propaga (no se traga nada)', async () => {
|
||||
|
||||
@@ -53,15 +53,15 @@ export async function resumenMiDiaEndpoint(req: Request, res: Response): Promise
|
||||
tipo: a.tipo,
|
||||
}))
|
||||
|
||||
// Usuarios activos (para la sección equipo, solo coordinador/admin)
|
||||
let usuarios: UsuarioLocal[] = []
|
||||
if (ROLES_CON_EQUIPO.includes(usuario.rol)) {
|
||||
const usuariosRaw = await prisma.usuario.findMany({
|
||||
where: { activo: true },
|
||||
select: { id: true, nombre: true, rol: true, activo: true },
|
||||
})
|
||||
usuarios = usuariosRaw.map((u) => ({ id: u.id, nombre: u.nombre, rol: u.rol, activo: u.activo }))
|
||||
}
|
||||
// Usuarios activos: se cargan SIEMPRE. La sección Equipo sólo se arma para
|
||||
// los roles con equipo, pero el nombre del vendedor asignado y el capturista
|
||||
// de cada fila se resuelven con esta lista (sin ella la bitácora mostraba
|
||||
// "sin asignar" a los propios vendedores).
|
||||
const usuariosRaw = await prisma.usuario.findMany({
|
||||
where: { activo: true },
|
||||
select: { id: true, nombre: true, rol: true, activo: true },
|
||||
})
|
||||
const usuarios: UsuarioLocal[] = usuariosRaw.map((u) => ({ id: u.id, nombre: u.nombre, rol: u.rol, activo: u.activo }))
|
||||
|
||||
const usuarioMiDia = { nombre: usuario.nombre, rol: usuario.rol }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user