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:
2026-09-21 07:18:39 +00:00
parent 75956a709f
commit 5e9b2ac456
16 changed files with 259 additions and 71 deletions

View File

@@ -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,

View File

@@ -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"')

View File

@@ -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 () => {

View File

@@ -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 }