feat: rol manager, arreglos de reportes (cancelacion/afluencias), credito unico, cliente enkontrol, vendedor en bitacora y actualizar afluencia existente
This commit is contained in:
@@ -70,6 +70,17 @@ export const ACCIONES_POR_ROL_DEFAULT: Record<string, Record<AccionClave, boolea
|
||||
ver_precios: true,
|
||||
gestionar_clientes: true,
|
||||
},
|
||||
// manager: mismas acciones operativas que admin (no gestiona usuarios/permisos,
|
||||
// eso se controla por endpoint, no por acción)
|
||||
manager: {
|
||||
descartar_lead: true,
|
||||
editar_cotizacion: true,
|
||||
asignar_vendedor: true,
|
||||
ver_reportes: true,
|
||||
exportar_pdf: true,
|
||||
ver_precios: true,
|
||||
gestionar_clientes: true,
|
||||
},
|
||||
}
|
||||
|
||||
/** Devuelve las acciones de un rol (o vacío si el rol no existe) */
|
||||
|
||||
@@ -24,6 +24,7 @@ export const ROLES = [
|
||||
'director_ventas',
|
||||
'escrituracion',
|
||||
'direccion_general',
|
||||
'manager',
|
||||
'admin',
|
||||
] as const
|
||||
export type Rol = (typeof ROLES)[number]
|
||||
@@ -48,6 +49,8 @@ export const MATRIZ: Record<Rol, readonly Modulo[]> = {
|
||||
director_ventas: [...MODULOS_VENDEDOR, 'cobranza', 'tablero-comercial'],
|
||||
escrituracion: [...MODULOS_VENDEDOR, 'cobranza'],
|
||||
direccion_general: ['mi-dia', 'tablero-comercial', 'cobranza', 'chat', 'clientes-maestro', 'captura', 'soporte'],
|
||||
// manager: mismos módulos que admin, sin poder crear usuarios ni editar permisos
|
||||
manager: MODULOS,
|
||||
admin: MODULOS,
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { z } from 'zod'
|
||||
import { prisma } from '../services/prisma.js'
|
||||
import { chat, indexarDocumento, type FuenteCitada } from '../services/ai.service.js'
|
||||
import { AppError, NotFoundError } from '../utils/errors.js'
|
||||
import { esAdminOManager } from '../utils/roles.js'
|
||||
|
||||
const chatSchema = z.object({
|
||||
sessionId: z.string().uuid().nullish(),
|
||||
@@ -68,7 +69,7 @@ export async function chatIAStream(req: Request, res: Response): Promise<void> {
|
||||
export function filtroConversaciones(
|
||||
token: { sub: string; rol: string } | undefined,
|
||||
): { usuarioId: string } | undefined {
|
||||
if (!token || token.rol === 'admin') return undefined
|
||||
if (!token || esAdminOManager(token.rol)) return undefined
|
||||
return { usuarioId: token.sub }
|
||||
}
|
||||
|
||||
@@ -132,7 +133,7 @@ export async function eliminarConversacion(req: Request, res: Response): Promise
|
||||
const conversacion = await prisma.aIConversacion.findUnique({ where: { id } })
|
||||
if (!conversacion) throw new NotFoundError('Conversación no encontrada')
|
||||
const token = req.usuarioToken
|
||||
if (token && token.rol !== 'admin' && conversacion.usuarioId !== token.sub) {
|
||||
if (token && !esAdminOManager(token.rol) && conversacion.usuarioId !== token.sub) {
|
||||
throw new AppError(403, 'Solo puedes borrar tus conversaciones')
|
||||
}
|
||||
await prisma.aIConversacion.delete({ where: { id } }) // cascade: mensajes
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { prisma } from '../services/prisma.js'
|
||||
import { esAdminOManager } from '../utils/roles.js'
|
||||
import { AppError, NotFoundError } from '../utils/errors.js'
|
||||
|
||||
// ---------- Borradores del alta de clientes ----------
|
||||
@@ -15,7 +16,7 @@ const guardarBorradorSchema = z.object({
|
||||
|
||||
/** sub del JWT; con AUTH_ENABLED=false (tests) cae a un id fijo */
|
||||
const usuarioDe = (req: Request): string => req.usuarioToken?.sub ?? 'anon'
|
||||
const esAdmin = (req: Request): boolean => req.usuarioToken?.rol === 'admin'
|
||||
const esAdmin = (req: Request): boolean => esAdminOManager(req.usuarioToken?.rol)
|
||||
|
||||
/** 404 si no existe, 403 si es de otro usuario (salvo admin) */
|
||||
async function borradorPropio(req: Request, id: string) {
|
||||
|
||||
@@ -3,12 +3,13 @@ import { z } from 'zod'
|
||||
import * as capturaService from '../services/captura-departamento.service.js'
|
||||
import { DEPARTAMENTOS } from '../config/campos-departamento.js'
|
||||
import { AppError, BadRequestError } from '../utils/errors.js'
|
||||
import { esAdminOManager } from '../utils/roles.js'
|
||||
|
||||
// ── Middleware admin ─────────────────────────────────────────────────
|
||||
|
||||
function requireAdmin(req: Request, _res: Response, next: NextFunction): void {
|
||||
if (req.usuarioToken?.rol !== 'admin') {
|
||||
throw new AppError(403, 'Se requiere rol admin')
|
||||
if (!esAdminOManager(req.usuarioToken?.rol)) {
|
||||
throw new AppError(403, 'Se requiere rol admin o manager')
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
/**
|
||||
* Endpoints de administración de configuración.
|
||||
*
|
||||
* Protegidos con requireAdmin (rol === 'admin') excepto /acciones que
|
||||
* cualquiera autenticado puede consultar.
|
||||
* Protegidos con requireAdmin (admin o manager) excepto /acciones que
|
||||
* cualquiera autenticado puede consultar. Las operaciones sensibles —crear
|
||||
* usuarios y editar permisos de roles— usan requireAdminEstricto (solo admin):
|
||||
* el rol manager no las puede hacer.
|
||||
*/
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
import { z } from 'zod'
|
||||
import * as configService from '../services/config.service.js'
|
||||
import { esRolValido } from '../config/roles.js'
|
||||
import { esAdmin, esAdminOManager } from '../utils/roles.js'
|
||||
import { AppError, BadRequestError } from '../utils/errors.js'
|
||||
|
||||
// ── Middleware admin ────────────────────────────────────────────────────
|
||||
|
||||
export function requireAdmin(req: Request, _res: Response, next: NextFunction): void {
|
||||
if (req.usuarioToken?.rol !== 'admin') {
|
||||
throw new AppError(403, 'Se requiere rol admin')
|
||||
if (!esAdminOManager(req.usuarioToken?.rol)) {
|
||||
throw new AppError(403, 'Se requiere rol admin o manager')
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
/** Solo admin: crear usuarios y editar permisos de roles (manager NO) */
|
||||
export function requireAdminEstricto(req: Request, _res: Response, next: NextFunction): void {
|
||||
if (!esAdmin(req.usuarioToken?.rol)) {
|
||||
throw new AppError(403, 'Solo un administrador puede hacer esta operación')
|
||||
}
|
||||
next()
|
||||
}
|
||||
@@ -86,6 +97,10 @@ export async function actualizarUsuarioEndpoint(req: Request, res: Response): Pr
|
||||
const id = req.params.id as string
|
||||
if (!id) throw new BadRequestError('Falta el parámetro :id')
|
||||
const parsed = actualizarUsuarioSchema.parse(req.body)
|
||||
// El rol es un permiso: solo el administrador puede cambiarlo (manager no)
|
||||
if (parsed.rol !== undefined && !esAdmin(req.usuarioToken?.rol)) {
|
||||
throw new AppError(403, 'Solo un administrador puede cambiar el rol de un usuario')
|
||||
}
|
||||
// Filtra undefined para enviar solo campos con valor
|
||||
const body: { rol?: string; asignacion?: string | null; activo?: boolean } = {}
|
||||
if (parsed.rol !== undefined) body.rol = parsed.rol
|
||||
|
||||
@@ -20,7 +20,11 @@ export async function resumenMiDiaEndpoint(req: Request, res: Response): Promise
|
||||
if (!usuario || !usuario.activo) throw new AppError(401, 'Credenciales inválidas')
|
||||
|
||||
// Capturas de afluencia: admin ve todas, otros solo las suyas
|
||||
const esAdmin = usuario.rol === 'admin' || usuario.rol === 'direccion_general' || usuario.rol === 'director_ventas'
|
||||
const esAdmin =
|
||||
usuario.rol === 'admin' ||
|
||||
usuario.rol === 'manager' ||
|
||||
usuario.rol === 'direccion_general' ||
|
||||
usuario.rol === 'director_ventas'
|
||||
const capturas = await prisma.leadCaptura.findMany({
|
||||
where: esAdmin ? {} : { usuarioId: usuario.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
|
||||
@@ -16,7 +16,7 @@ import { VOCABULARIO } from '../config/vocabulario.js'
|
||||
|
||||
export function requireRolReportes(req: Request, _res: Response, next: NextFunction): void {
|
||||
const rol = req.usuarioToken?.rol
|
||||
if (rol !== 'admin' && rol !== 'director_ventas' && rol !== 'direccion_general') {
|
||||
if (rol !== 'admin' && rol !== 'manager' && rol !== 'director_ventas' && rol !== 'direccion_general') {
|
||||
throw new AppError(403, 'No tienes acceso a los reportes')
|
||||
}
|
||||
next()
|
||||
@@ -145,8 +145,20 @@ export async function reporteAfluencias(req: Request, res: Response): Promise<vo
|
||||
desarrollo: c.desarrollo ?? '',
|
||||
modelo: c.modelo ?? '',
|
||||
etapa: c.etapa,
|
||||
califica: perfil['califica'] ?? '',
|
||||
vendedor: nombrePorId.get(c.usuarioId) ?? '(sin asignar)',
|
||||
// Perfilamiento completo capturado en el alta de afluencia
|
||||
numDependientes: perfil['numDependientes'] ?? '',
|
||||
edad: perfil['edad'] ?? '',
|
||||
estadoCivil: perfil['estadoCivil'] ?? '',
|
||||
tipoEmpleo: perfil['tipoEmpleo'] ?? '',
|
||||
ocupacion: perfil['ocupacion'] ?? '',
|
||||
rangoSalarial: perfil['rangoSalarial'] ?? '',
|
||||
credito: perfil['credito'] ?? '',
|
||||
tipoEconomia: perfil['tipoEconomia'] ?? '',
|
||||
califica: perfil['califica'] ?? '',
|
||||
loQuierePara: perfil['loQuierePara'] ?? '',
|
||||
medioContacto: perfil['medioContacto'] ?? '',
|
||||
notas: perfil['notas'] ?? '',
|
||||
fecha: c.createdAt.toISOString(),
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -105,6 +105,7 @@ import {
|
||||
import { estadoSistema } from '../controllers/sistema.controller.js'
|
||||
import {
|
||||
requireAdmin,
|
||||
requireAdminEstricto,
|
||||
obtenerConfigsEndpoint,
|
||||
actualizarConfigsEndpoint,
|
||||
obtenerRolesConfigEndpoint,
|
||||
@@ -306,11 +307,11 @@ apiRouter.get('/auditoria', requireAdmin, listarAuditoriaEndpoint)
|
||||
apiRouter.get('/config/sistema', requireAdmin, obtenerConfigsEndpoint)
|
||||
apiRouter.patch('/config/sistema', requireAdmin, actualizarConfigsEndpoint)
|
||||
apiRouter.get('/config/roles', requireAdmin, obtenerRolesConfigEndpoint)
|
||||
apiRouter.patch('/config/roles/:rol', requireAdmin, actualizarRolConfigEndpoint)
|
||||
apiRouter.patch('/config/roles/:rol', requireAdminEstricto, actualizarRolConfigEndpoint)
|
||||
apiRouter.get('/config/usuarios', requireAdmin, obtenerUsuariosEndpoint)
|
||||
apiRouter.patch('/config/usuarios/:id', requireAdmin, actualizarUsuarioEndpoint)
|
||||
apiRouter.post('/config/usuarios', requireAdmin, crearUsuarioEndpoint)
|
||||
apiRouter.post('/config/reset-roles', requireAdmin, resetRolesEndpoint)
|
||||
apiRouter.post('/config/usuarios', requireAdminEstricto, crearUsuarioEndpoint)
|
||||
apiRouter.post('/config/reset-roles', requireAdminEstricto, resetRolesEndpoint)
|
||||
apiRouter.get('/config/acciones', obtenerAccionesEndpoint)
|
||||
|
||||
// ── Captura por departamentos ────────────────────────────────────────
|
||||
|
||||
@@ -450,7 +450,9 @@ export interface ContextoUsuario {
|
||||
|
||||
/** admin / director_ventas / direccion_general ven los datos de todos */
|
||||
export function esDireccion(rol: string | undefined): boolean {
|
||||
return rol === undefined || rol === 'admin' || rol === 'director_ventas' || rol === 'direccion_general'
|
||||
return (
|
||||
rol === undefined || rol === 'admin' || rol === 'manager' || rol === 'director_ventas' || rol === 'direccion_general'
|
||||
)
|
||||
}
|
||||
|
||||
const FASES_APARTADO: readonly string[] = VOCABULARIO.FASES_APARTADO
|
||||
|
||||
@@ -16,7 +16,7 @@ async function validarFechasBloqueadas(
|
||||
registros: { unidad: string; campo: string; valor: string | null }[],
|
||||
rol: string,
|
||||
): Promise<void> {
|
||||
if (rol === 'admin') return
|
||||
if (rol === 'admin' || rol === 'manager') return
|
||||
const bloqueados = CAMPOS_FECHA_BLOQUEADOS[departamento]
|
||||
if (!bloqueados) return
|
||||
const candidatos = registros.filter((r) => bloqueados.has(r.campo))
|
||||
|
||||
@@ -162,8 +162,8 @@ describe('resetRoles', () => {
|
||||
prismaMock.rolConfiguracion.create.mockResolvedValue({})
|
||||
await resetRoles()
|
||||
expect(prismaMock.rolConfiguracion.deleteMany).toHaveBeenCalled()
|
||||
// MATRIZ tiene 6 roles
|
||||
expect(prismaMock.rolConfiguracion.create).toHaveBeenCalledTimes(6)
|
||||
// MATRIZ tiene 7 roles (incluye manager)
|
||||
expect(prismaMock.rolConfiguracion.create).toHaveBeenCalledTimes(7)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ export const ESTADOS_FINALES_APARTADO = [
|
||||
'TITULADO',
|
||||
]
|
||||
|
||||
const ROLES_CON_EQUIPO = ['coordinador', 'corporativo', 'admin']
|
||||
const ROLES_CON_EQUIPO = ['coordinador', 'corporativo', 'admin', 'manager']
|
||||
|
||||
const MS_DIA = 24 * 60 * 60 * 1000
|
||||
|
||||
@@ -70,6 +70,8 @@ export interface LeadMiDia {
|
||||
estado: string
|
||||
origen: string
|
||||
desarrollo?: string
|
||||
/** Vendedor asignado (nombre); '' si no tiene */
|
||||
vendedor: string
|
||||
updatedAt: string
|
||||
/** Tablero del item: 'lead' (captura ligera) u 'oportunidad' (afluencia) */
|
||||
fuente: 'lead' | 'oportunidad'
|
||||
@@ -139,6 +141,8 @@ export interface ApartadoMiDia {
|
||||
email: string
|
||||
origen: string
|
||||
desarrollo: string
|
||||
/** Vendedor asignado (nombre); '' si no tiene */
|
||||
vendedor: string
|
||||
unidadCodigo: string
|
||||
estado: string
|
||||
fechaLimite: string // 'YYYY-MM-DD' ('' si no tiene)
|
||||
@@ -290,6 +294,7 @@ export function resumenMiDiaLocal(
|
||||
estado: c.etapa,
|
||||
origen: c.origen ?? '',
|
||||
desarrollo: c.desarrollo ?? '',
|
||||
vendedor: nombrePorUsuarioId.get(c.usuarioId) ?? '',
|
||||
updatedAt: c.updatedAt.toISOString(),
|
||||
fuente: 'lead' as const,
|
||||
ultimoCambio: ultimoCambioLocal(c, nombrePorUsuarioId.get(c.usuarioId) ?? ''),
|
||||
@@ -326,6 +331,7 @@ export function resumenMiDiaLocal(
|
||||
etapa: c.etapa,
|
||||
desarrollo: c.desarrollo ?? '',
|
||||
modeloInteres: c.modelo ?? '',
|
||||
vendedor: nombrePorUsuarioId.get(c.usuarioId) ?? '',
|
||||
unidadCodigo,
|
||||
updatedAt: c.updatedAt.toISOString(),
|
||||
ultimoCambio: ultimoCambioLocal(c, nombrePorUsuarioId.get(c.usuarioId) ?? ''),
|
||||
@@ -350,6 +356,7 @@ export function resumenMiDiaLocal(
|
||||
email: c.email ?? '',
|
||||
origen: c.origen ?? '',
|
||||
desarrollo: c.desarrollo ?? '',
|
||||
vendedor: nombrePorUsuarioId.get(c.usuarioId) ?? '',
|
||||
unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '',
|
||||
estado: c.etapa,
|
||||
fechaLimite: c.fechaLimite ? c.fechaLimite.toISOString().slice(0, 10) : '',
|
||||
@@ -446,7 +453,7 @@ export function resumenMiDiaLocal(
|
||||
const actividadesAbiertas = actividades.filter((a) => a.estado.toUpperCase() === 'ABIERTA').length
|
||||
|
||||
// --- Equipo (solo coordinador/admin/corporativo) ---
|
||||
const ROLES_CON_EQUIPO = ['coordinador', 'corporativo', 'admin']
|
||||
const ROLES_CON_EQUIPO = ['coordinador', 'corporativo', 'admin', 'manager']
|
||||
let equipo: NonNullable<ResumenMiDia['equipo']> | undefined
|
||||
|
||||
if (ROLES_CON_EQUIPO.includes(usuario.rol) && usuarios.length > 0) {
|
||||
@@ -507,6 +514,7 @@ export function resumenMiDiaLocal(
|
||||
email: c.email ?? '',
|
||||
origen: c.origen ?? '',
|
||||
desarrollo: c.desarrollo ?? '',
|
||||
vendedor: nombrePorUsuarioId.get(c.usuarioId) ?? '',
|
||||
unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '',
|
||||
estado: c.etapa,
|
||||
fechaLimite: c.fechaLimite ? c.fechaLimite.toISOString().slice(0, 10) : '',
|
||||
|
||||
15
api-gateway/src/utils/roles.ts
Normal file
15
api-gateway/src/utils/roles.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Helpers de rol compartidos: el rol `manager` tiene los mismos permisos
|
||||
* operativos que el administrador (puede todo lo del día a día) pero NO puede
|
||||
* crear usuarios ni editar permisos de roles — eso queda solo para `admin`.
|
||||
*/
|
||||
|
||||
/** Administrador puro: único que gestiona usuarios y permisos */
|
||||
export function esAdmin(rol: string | undefined): boolean {
|
||||
return rol === 'admin'
|
||||
}
|
||||
|
||||
/** Admin o manager: permisos operativos completos */
|
||||
export function esAdminOManager(rol: string | undefined): boolean {
|
||||
return rol === 'admin' || rol === 'manager'
|
||||
}
|
||||
Reference in New Issue
Block a user