feat(config): modulo Configuraciones admin + permisos por rol
- Tablas ConfiguracionSistema (7 configs) + RolConfiguracion (6 roles) - Endpoints: config/sistema GET/PATCH, config/roles GET/PATCH, config/usuarios GET/PATCH, config/reset-roles POST, config/acciones GET - modulosDeRol() async con fallback a MATRIZ estatica - auth/me devuelve acciones[] segun rol del usuario - saludMonday.estaDisponible() lee modo_local_forzado de DB (cache 30s) - Landing: vista Configuraciones con 4 tabs (Modo, Roles, Usuarios, Sistema) - Mi-dia: boton Descartar se deshabilita si !descartar_lead - 630 API tests + 24 landing tests + 20 mi-dia tests verdes Seed: npm run seed:config
This commit is contained in:
79
api-gateway/src/config/acciones.ts
Normal file
79
api-gateway/src/config/acciones.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
// Catálogo de acciones granulares disponibles por rol.
|
||||
// Cada acción controla un permiso específico en la UI y el backend.
|
||||
|
||||
export const ACCIONES_DISPONIBLES = [
|
||||
{ clave: 'descartar_lead', nombre: 'Descartar leads', descripcion: 'Puede descartar leads con motivo' },
|
||||
{ clave: 'editar_cotizacion', nombre: 'Editar cotizaciones', descripcion: 'Puede editar cotizaciones existentes' },
|
||||
{ clave: 'asignar_vendedor', nombre: 'Reasignar leads', descripcion: 'Puede reasignar leads a otros vendedores' },
|
||||
{ clave: 'ver_reportes', nombre: 'Ver reportes', descripcion: 'Puede ver dashboards y reportes' },
|
||||
{ clave: 'exportar_pdf', nombre: 'Exportar PDFs', descripcion: 'Puede generar y descargar PDFs' },
|
||||
{ clave: 'ver_precios', nombre: 'Ver precios completos', descripcion: 'Puede ver precios y descuentos en cotizador' },
|
||||
{ clave: 'gestionar_clientes', nombre: 'Gestionar clientes', descripcion: 'Puede gestionar fichas de clientes' },
|
||||
] as const
|
||||
|
||||
export type AccionClave = (typeof ACCIONES_DISPONIBLES)[number]['clave']
|
||||
|
||||
// Acciones por defecto por rol: true = habilitado, false = deshabilitado.
|
||||
// 'admin' tiene todo habilitado por convención.
|
||||
export const ACCIONES_POR_ROL_DEFAULT: Record<string, Record<AccionClave, boolean>> = {
|
||||
vendedor: {
|
||||
descartar_lead: true,
|
||||
editar_cotizacion: false,
|
||||
asignar_vendedor: false,
|
||||
ver_reportes: false,
|
||||
exportar_pdf: true,
|
||||
ver_precios: true,
|
||||
gestionar_clientes: false,
|
||||
},
|
||||
administracion_venta: {
|
||||
descartar_lead: true,
|
||||
editar_cotizacion: true,
|
||||
asignar_vendedor: false,
|
||||
ver_reportes: true,
|
||||
exportar_pdf: true,
|
||||
ver_precios: true,
|
||||
gestionar_clientes: true,
|
||||
},
|
||||
director_ventas: {
|
||||
descartar_lead: true,
|
||||
editar_cotizacion: true,
|
||||
asignar_vendedor: true,
|
||||
ver_reportes: true,
|
||||
exportar_pdf: true,
|
||||
ver_precios: true,
|
||||
gestionar_clientes: true,
|
||||
},
|
||||
escrituracion: {
|
||||
descartar_lead: false,
|
||||
editar_cotizacion: false,
|
||||
asignar_vendedor: false,
|
||||
ver_reportes: false,
|
||||
exportar_pdf: true,
|
||||
ver_precios: true,
|
||||
gestionar_clientes: true,
|
||||
},
|
||||
direccion_general: {
|
||||
descartar_lead: false,
|
||||
editar_cotizacion: false,
|
||||
asignar_vendedor: false,
|
||||
ver_reportes: true,
|
||||
exportar_pdf: true,
|
||||
ver_precios: true,
|
||||
gestionar_clientes: false,
|
||||
},
|
||||
admin: {
|
||||
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) */
|
||||
export function accionesPorRol(rol: string): Record<AccionClave, boolean> {
|
||||
const clave = rol.trim().toLowerCase()
|
||||
return (ACCIONES_POR_ROL_DEFAULT[clave] ?? ACCIONES_POR_ROL_DEFAULT.vendedor)!
|
||||
}
|
||||
@@ -39,22 +39,22 @@ const ESPERADOS: Record<string, string[]> = {
|
||||
}
|
||||
|
||||
describe('modulosDeRol', () => {
|
||||
it.each(Object.entries(ESPERADOS))('rol %s devuelve exactamente su lista', (rol, esperados) => {
|
||||
expect(modulosDeRol(rol)).toEqual(esperados)
|
||||
it.each(Object.entries(ESPERADOS))('rol %s devuelve exactamente su lista', async (rol, esperados) => {
|
||||
expect(await modulosDeRol(rol)).toEqual(esperados)
|
||||
})
|
||||
|
||||
it('roles legacy (coordinador, corporativo) se tratan como director_ventas', () => {
|
||||
expect(modulosDeRol('coordinador')).toEqual(ESPERADOS['director_ventas'])
|
||||
expect(modulosDeRol('corporativo')).toEqual(ESPERADOS['director_ventas'])
|
||||
it('roles legacy (coordinador, corporativo) se tratan como director_ventas', async () => {
|
||||
expect(await modulosDeRol('coordinador')).toEqual(ESPERADOS['director_ventas'])
|
||||
expect(await modulosDeRol('corporativo')).toEqual(ESPERADOS['director_ventas'])
|
||||
})
|
||||
|
||||
it('rol desconocido cae al fallback seguro (vendedor)', () => {
|
||||
expect(modulosDeRol('otro')).toEqual(ESPERADOS['vendedor'])
|
||||
expect(modulosDeRol('')).toEqual(ESPERADOS['vendedor'])
|
||||
it('rol desconocido cae al fallback seguro (vendedor)', async () => {
|
||||
expect(await modulosDeRol('otro')).toEqual(ESPERADOS['vendedor'])
|
||||
expect(await modulosDeRol('')).toEqual(ESPERADOS['vendedor'])
|
||||
})
|
||||
|
||||
it('tolera mayúsculas y espacios', () => {
|
||||
expect(modulosDeRol(' Admin ')).toEqual([...MODULOS])
|
||||
it('tolera mayúsculas y espacios', async () => {
|
||||
expect(await modulosDeRol(' Admin ')).toEqual([...MODULOS])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ const MODULOS_VENDEDOR: readonly Modulo[] = [
|
||||
'chat',
|
||||
]
|
||||
|
||||
const MATRIZ: Record<Rol, readonly Modulo[]> = {
|
||||
export const MATRIZ: Record<Rol, readonly Modulo[]> = {
|
||||
vendedor: MODULOS_VENDEDOR,
|
||||
administracion_venta: [...MODULOS_VENDEDOR, 'cobranza'],
|
||||
director_ventas: [...MODULOS_VENDEDOR, 'cobranza', 'tablero-comercial'],
|
||||
@@ -52,14 +52,28 @@ const ROLES_LEGACY: Record<string, Rol> = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Módulos visibles para un rol. Roles legacy ('coordinador', 'corporativo') se
|
||||
* tratan como director_ventas; un rol desconocido cae a vendedor (fallback
|
||||
* seguro: es el conjunto más chico de módulos operativos).
|
||||
* Módulos visibles para un rol. Intenta leer de RolConfiguracion (DB) y
|
||||
* hace fallback a la MATRIZ estática si no existe registro.
|
||||
*
|
||||
* Roles legacy ('coordinador', 'corporativo') se tratan como director_ventas;
|
||||
* un rol desconocido cae a vendedor (fallback seguro: es el conjunto más chico
|
||||
* de módulos operativos).
|
||||
*/
|
||||
export function modulosDeRol(rol: string): Modulo[] {
|
||||
export async function modulosDeRol(rol: string): Promise<Modulo[]> {
|
||||
const clave = rol.trim().toLowerCase()
|
||||
const rolEfectivo: Rol =
|
||||
(ROLES as readonly string[]).includes(clave) ? (clave as Rol) : (ROLES_LEGACY[clave] ?? 'vendedor')
|
||||
|
||||
try {
|
||||
// Importación dinámica para evitar circular dependency con services
|
||||
const { prisma } = await import('../services/prisma.js')
|
||||
const row = await prisma.rolConfiguracion.findUnique({ where: { rol: clave } })
|
||||
if (row) {
|
||||
return JSON.parse(row.modulos) as Modulo[]
|
||||
}
|
||||
} catch {
|
||||
// Si la tabla aún no existe o hay error de conexión, fallback silencioso
|
||||
}
|
||||
return [...MATRIZ[rolEfectivo]]
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user