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:
2026-09-06 09:46:19 +00:00
parent 50e6eca7da
commit b2634f97ab
35 changed files with 2572 additions and 33 deletions

View File

@@ -14,6 +14,7 @@
"test": "vitest run",
"seed:ai": "tsx scripts/seed-ai.ts",
"seed:usuarios": "tsx scripts/seed-usuarios.ts",
"seed:config": "tsx scripts/seed-config.js",
"seed:unidades": "tsx scripts/seed-unidades-locales.js",
"setup:crm": "tsx scripts/setup-crm.ts",
"sync:desarrollos": "tsx scripts/sync-desarrollos.ts",

View File

@@ -0,0 +1,17 @@
-- Tablas de configuración del sistema y permisos por rol.
-- Patrón manual: db execute + migrate resolve. NUNCA migrate dev.
-- CreateTable ConfiguracionSistema
CREATE TABLE IF NOT EXISTS "ConfiguracionSistema" (
"clave" TEXT NOT NULL PRIMARY KEY,
"valor" TEXT NOT NULL,
"updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable RolConfiguracion
CREATE TABLE IF NOT EXISTS "RolConfiguracion" (
"rol" TEXT NOT NULL PRIMARY KEY,
"modulos" TEXT NOT NULL,
"acciones" TEXT NOT NULL,
"updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

View File

@@ -307,6 +307,19 @@ model AIDocumento {
chunks AIChunk[]
}
model ConfiguracionSistema {
clave String @id
valor String
updatedAt DateTime @updatedAt @default(now())
}
model RolConfiguracion {
rol String @id
modulos String // JSON array
acciones String // JSON object
updatedAt DateTime @updatedAt @default(now())
}
model AIChunk {
id String @id @default(uuid())
documentoId String

View File

@@ -0,0 +1,84 @@
/**
* Siembra las tablas ConfiguracionSistema y RolConfiguracion con valores
* iniciales. Idempotente: upsert por clave/rol.
*
* Lee config/acciones.ts (catálogo de acciones) y config/roles.ts (matriz de
* módulos) como fuente de verdad.
*
* Uso: npx tsx scripts/seed-config.js
*/
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import dotenv from 'dotenv'
import { PrismaClient } from '@prisma/client'
import { ACCIONES_POR_ROL_DEFAULT } from '../src/config/acciones.ts'
import { MODULOS } from '../src/config/roles.ts'
const raizApi = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
dotenv.config({ path: [path.resolve(raizApi, '.env'), path.resolve(raizApi, '..', '.env')] })
const prisma = new PrismaClient()
// ── ConfiguracionSistema ───────────────────────────────────────────────
const CONFIGS_DEFAULT = [
{ clave: 'modo_local_forzado', valor: 'false' },
{ clave: 'recordatorio_inactividad_dias', valor: '10' },
{ clave: 'recordatorio_cobranza_hora', valor: '09:30' },
{ clave: 'sesion_minutos', valor: '480' },
{ clave: 'auto_logout', valor: 'true' },
{ clave: 'mostrar_precios_lista', valor: 'true' },
{ clave: 'max_upload_mb', valor: '10' },
]
// ── RolConfiguracion: módulos por rol (matriz de config/roles.ts) ──────
const MODULOS_VENDEDOR = [
'mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat',
]
const MATRIZ_MODULOS = {
vendedor: MODULOS_VENDEDOR,
administracion_venta: [...MODULOS_VENDEDOR, 'cobranza'],
director_ventas: [...MODULOS_VENDEDOR, 'cobranza', 'tablero-comercial'],
escrituracion: [...MODULOS_VENDEDOR, 'cobranza'],
direccion_general: ['mi-dia', 'tablero-comercial', 'cobranza', 'chat'],
admin: [...MODULOS],
}
async function main() {
console.log('⚙️ Sembrando ConfiguracionSistema...')
for (const { clave, valor } of CONFIGS_DEFAULT) {
await prisma.configuracionSistema.upsert({
where: { clave },
create: { clave, valor },
update: { valor },
})
console.log(` ✓ ${clave} = ${valor}`)
}
console.log('\n⚙️ Sembrando RolConfiguracion...')
for (const [rol, modulos] of Object.entries(MATRIZ_MODULOS)) {
const acciones = ACCIONES_POR_ROL_DEFAULT[rol] ?? ACCIONES_POR_ROL_DEFAULT.vendedor
await prisma.rolConfiguracion.upsert({
where: { rol },
create: {
rol,
modulos: JSON.stringify(modulos),
acciones: JSON.stringify(acciones),
},
update: {
modulos: JSON.stringify(modulos),
acciones: JSON.stringify(acciones),
},
})
console.log(` ✓ ${rol}: ${modulos.length} módulos, acciones: ${Object.keys(acciones).length}`)
}
console.log('\n✅ Seed de configuración completado.')
}
main()
.catch((err) => {
console.error('❌ Error en seed:', err)
process.exit(1)
})
.finally(() => prisma.$disconnect())

View 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)!
}

View File

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

View File

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

View File

@@ -0,0 +1,206 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
import {
requireAdmin,
obtenerConfigsEndpoint,
actualizarConfigsEndpoint,
obtenerRolesConfigEndpoint,
actualizarRolConfigEndpoint,
obtenerUsuariosEndpoint,
actualizarUsuarioEndpoint,
resetRolesEndpoint,
obtenerAccionesEndpoint,
} from './config.controller.js'
// ---------- Servicio mockeado ----------
const servicioMock = vi.hoisted(() => ({
obtenerConfigs: vi.fn(),
actualizarConfigs: vi.fn(),
obtenerRolesConfig: vi.fn(),
actualizarRolConfig: vi.fn(),
obtenerUsuarios: vi.fn(),
actualizarUsuario: vi.fn(),
resetRoles: vi.fn(),
obtenerAcciones: vi.fn(),
}))
vi.mock('../services/config.service.js', () => servicioMock)
function reqFake(overrides: Partial<Request> = {}): Request {
return {
body: {},
params: {},
usuarioToken: { sub: 'u-1', username: 'admin', rol: 'admin' },
...overrides,
} as Request
}
function resFake() {
const json = vi.fn()
const end = vi.fn()
const status = vi.fn().mockReturnValue({ json, end })
return { res: { json, status, end } as unknown as Response, status, json, end }
}
beforeEach(() => {
vi.clearAllMocks()
})
// ── requireAdmin ───────────────────────────────────────────────────────
describe('requireAdmin', () => {
it('next() si rol es admin', () => {
const next = vi.fn()
requireAdmin(reqFake(), {} as Response, next)
expect(next).toHaveBeenCalled()
})
it('lanza 403 si rol no es admin', () => {
expect(() =>
requireAdmin(reqFake({ usuarioToken: { sub: 'u-2', username: 'x', rol: 'vendedor' } }), {} as Response, vi.fn()),
).toThrow('Se requiere rol admin')
})
it('lanza 403 si no hay token', () => {
expect(() =>
requireAdmin(reqFake({ usuarioToken: undefined }), {} as Response, vi.fn()),
).toThrow('Se requiere rol admin')
})
})
// ── GET /api/config/sistema ────────────────────────────────────────────
describe('obtenerConfigsEndpoint', () => {
it('200 con todas las configs', async () => {
servicioMock.obtenerConfigs.mockResolvedValue({ a: '1' })
const { res, json } = resFake()
await obtenerConfigsEndpoint(reqFake(), res)
expect(json).toHaveBeenCalledWith({ a: '1' })
})
})
// ── PATCH /api/config/sistema ──────────────────────────────────────────
describe('actualizarConfigsEndpoint', () => {
it('204 tras actualizar configs parciales', async () => {
servicioMock.actualizarConfigs.mockResolvedValue(undefined)
const { res, end } = resFake()
await actualizarConfigsEndpoint(reqFake({ body: { x: '1' } }), res)
expect(servicioMock.actualizarConfigs).toHaveBeenCalledWith({ x: '1' })
expect(end).toHaveBeenCalled()
})
it('400 si body vacío', async () => {
const { res } = resFake()
await expect(actualizarConfigsEndpoint(reqFake({ body: {} }), res)).rejects.toThrow('Body vacío')
})
it('400 si body inválido (ZodError)', async () => {
const { res } = resFake()
await expect(actualizarConfigsEndpoint(reqFake({ body: { x: 123 } }), res)).rejects.toThrow()
})
})
// ── GET /api/config/roles ──────────────────────────────────────────────
describe('obtenerRolesConfigEndpoint', () => {
it('200 con roles parseados', async () => {
servicioMock.obtenerRolesConfig.mockResolvedValue([
{ rol: 'admin', modulos: '["mi-dia"]', acciones: '{"x":true}', updatedAt: new Date() },
])
const { res, json } = resFake()
await obtenerRolesConfigEndpoint(reqFake(), res)
expect(json).toHaveBeenCalledWith([
{ rol: 'admin', modulos: ['mi-dia'], acciones: { x: true }, updatedAt: expect.any(Date) },
])
})
})
// ── PATCH /api/config/roles/:rol ───────────────────────────────────────
describe('actualizarRolConfigEndpoint', () => {
it('204 tras actualizar un rol', async () => {
servicioMock.actualizarRolConfig.mockResolvedValue(undefined)
const { res, end } = resFake()
await actualizarRolConfigEndpoint(
reqFake({ params: { rol: 'vendedor' }, body: { modulos: ['a'], acciones: { b: true } } }),
res,
)
expect(servicioMock.actualizarRolConfig).toHaveBeenCalledWith('vendedor', {
modulos: ['a'],
acciones: { b: true },
})
expect(end).toHaveBeenCalled()
})
it('400 si body inválido (faltan campos)', async () => {
const { res } = resFake()
await expect(
actualizarRolConfigEndpoint(reqFake({ params: { rol: 'x' }, body: { modulos: 'no' } }), res),
).rejects.toThrow()
})
})
// ── GET /api/config/usuarios ───────────────────────────────────────────
describe('obtenerUsuariosEndpoint', () => {
it('200 con la lista de usuarios', async () => {
servicioMock.obtenerUsuarios.mockResolvedValue([{ id: 'u1', nombre: 'A' }])
const { res, json } = resFake()
await obtenerUsuariosEndpoint(reqFake(), res)
expect(json).toHaveBeenCalledWith([{ id: 'u1', nombre: 'A' }])
})
})
// ── PATCH /api/config/usuarios/:id ─────────────────────────────────────
describe('actualizarUsuarioEndpoint', () => {
it('204 tras actualizar un usuario', async () => {
servicioMock.actualizarUsuario.mockResolvedValue(undefined)
const { res, end } = resFake()
await actualizarUsuarioEndpoint(
reqFake({ params: { id: 'u1' }, body: { rol: 'admin' } }),
res,
)
expect(servicioMock.actualizarUsuario).toHaveBeenCalledWith('u1', { rol: 'admin' })
expect(end).toHaveBeenCalled()
})
it('400 si body vacío', async () => {
const { res } = resFake()
await expect(
actualizarUsuarioEndpoint(reqFake({ params: { id: 'u1' }, body: {} }), res),
).rejects.toThrow('Body vacío')
})
})
// ── POST /api/config/reset-roles ───────────────────────────────────────
describe('resetRolesEndpoint', () => {
it('204 tras resetear roles', async () => {
servicioMock.resetRoles.mockResolvedValue(undefined)
const { res, end } = resFake()
await resetRolesEndpoint(reqFake(), res)
expect(servicioMock.resetRoles).toHaveBeenCalled()
expect(end).toHaveBeenCalled()
})
})
// ── GET /api/config/acciones ───────────────────────────────────────────
describe('obtenerAccionesEndpoint', () => {
it('200 con las acciones del rol del usuario', async () => {
servicioMock.obtenerAcciones.mockResolvedValue({ descartar_lead: true })
const { res, json } = resFake()
await obtenerAccionesEndpoint(reqFake(), res)
expect(servicioMock.obtenerAcciones).toHaveBeenCalledWith('admin')
expect(json).toHaveBeenCalledWith({ descartar_lead: true })
})
it('400 sin token verificado', async () => {
const { res } = resFake()
await expect(obtenerAccionesEndpoint(reqFake({ usuarioToken: undefined }), res)).rejects.toThrow(
'Falta el token verificado',
)
})
})

View File

@@ -0,0 +1,111 @@
/**
* Endpoints de administración de configuración.
*
* Protegidos con requireAdmin (rol === 'admin') excepto /acciones que
* cualquiera autenticado puede consultar.
*/
import type { NextFunction, Request, Response } from 'express'
import { z } from 'zod'
import * as configService from '../services/config.service.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')
}
next()
}
// ── GET /api/config/sistema ────────────────────────────────────────────
export async function obtenerConfigsEndpoint(_req: Request, res: Response): Promise<void> {
const configs = await configService.obtenerConfigs()
res.json(configs)
}
// ── PATCH /api/config/sistema ──────────────────────────────────────────
const actualizarConfigsSchema = z.record(z.string(), z.string())
export async function actualizarConfigsEndpoint(req: Request, res: Response): Promise<void> {
const parcial = actualizarConfigsSchema.parse(req.body)
if (Object.keys(parcial).length === 0) throw new BadRequestError('Body vacío')
await configService.actualizarConfigs(parcial)
res.status(204).end()
}
// ── GET /api/config/roles ──────────────────────────────────────────────
export async function obtenerRolesConfigEndpoint(_req: Request, res: Response): Promise<void> {
const roles = await configService.obtenerRolesConfig()
// Parsear JSON para que el frontend reciba objetos
res.json(
roles.map((r) => ({
rol: r.rol,
modulos: JSON.parse(r.modulos) as string[],
acciones: JSON.parse(r.acciones) as Record<string, boolean>,
updatedAt: r.updatedAt,
})),
)
}
// ── PATCH /api/config/roles/:rol ───────────────────────────────────────
const actualizarRolSchema = z.object({
modulos: z.array(z.string()),
acciones: z.record(z.string(), z.boolean()),
})
export async function actualizarRolConfigEndpoint(req: Request, res: Response): Promise<void> {
const rol = req.params.rol as string
if (!rol) throw new BadRequestError('Falta el parámetro :rol')
const body = actualizarRolSchema.parse(req.body)
await configService.actualizarRolConfig(rol, body)
res.status(204).end()
}
// ── GET /api/config/usuarios ───────────────────────────────────────────
export async function obtenerUsuariosEndpoint(_req: Request, res: Response): Promise<void> {
const usuarios = await configService.obtenerUsuarios()
res.json(usuarios)
}
// ── PATCH /api/config/usuarios/:id ─────────────────────────────────────
const actualizarUsuarioSchema = z.object({
rol: z.string().optional(),
asignacion: z.string().nullable().optional(),
activo: z.boolean().optional(),
})
export async function actualizarUsuarioEndpoint(req: Request, res: Response): Promise<void> {
const id = req.params.id as string
if (!id) throw new BadRequestError('Falta el parámetro :id')
const parsed = actualizarUsuarioSchema.parse(req.body)
// 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
if (parsed.asignacion !== undefined) body.asignacion = parsed.asignacion
if (parsed.activo !== undefined) body.activo = parsed.activo
if (Object.keys(body).length === 0) throw new BadRequestError('Body vacío')
await configService.actualizarUsuario(id, body)
res.status(204).end()
}
// ── POST /api/config/reset-roles ───────────────────────────────────────
export async function resetRolesEndpoint(_req: Request, res: Response): Promise<void> {
await configService.resetRoles()
res.status(204).end()
}
// ── GET /api/config/acciones ───────────────────────────────────────────
export async function obtenerAccionesEndpoint(req: Request, res: Response): Promise<void> {
if (!req.usuarioToken) throw new BadRequestError('Falta el token verificado')
const acciones = await configService.obtenerAcciones(req.usuarioToken.rol)
res.json(acciones)
}

View File

@@ -93,6 +93,7 @@ describe('actualizarVinculoMondayEndpoint (PATCH /api/usuarios/me/monday)', () =
asignacion: 'MONTEBELLO',
mondayUserId: 13676,
modulos: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat'],
acciones: [],
})
})

View File

@@ -41,7 +41,7 @@ export async function actualizarVinculoMondayEndpoint(req: Request, res: Respons
where: { id: req.usuarioToken.sub },
data: { mondayUserId },
})
res.json(aPublico(usuario))
res.json(await aPublico(usuario))
}
const cambiarRolSchema = z.object({
@@ -65,5 +65,5 @@ export async function actualizarRolEndpoint(req: Request, res: Response): Promis
where: { id: req.params.id as string },
data: { rol },
})
res.json(aPublico(usuario))
res.json(await aPublico(usuario))
}

View File

@@ -2,6 +2,7 @@ import cron from 'node-cron'
import { crearApp } from './app.js'
import { config } from './config/index.js'
import { automatizacionesService } from './services/automatizaciones.service.js'
import * as configService from './services/config.service.js'
import { mondayService } from './services/monday.service.js'
import { recordatoriosCobranzaService } from './services/recordatorios-cobranza.service.js'
import { saludMonday } from './services/salud-monday.service.js'
@@ -129,6 +130,11 @@ if (config.automatizaciones.enabled) {
console.log(`Recordatorios de cobranza activos (cron: "${config.recordatoriosCobranza.cron}")`)
}
// Precarga de configuraciones desde DB (modo_local_forzado, etc.)
void configService.refrescarCache().catch((err: unknown) =>
console.error('[startup] Error precargando config cache:', err),
)
app.listen(config.port, () => {
// eslint-disable-next-line no-console
console.log(`API Gateway FRASA escuchando en http://localhost:${config.port} (${config.nodeEnv})`)

View File

@@ -82,6 +82,17 @@ import {
import { loginEndpoint, meEndpoint, cambiarPasswordEndpoint } from '../controllers/auth.controller.js'
import { resumenReportes } from '../controllers/reportes.controller.js'
import { estadoSistema } from '../controllers/sistema.controller.js'
import {
requireAdmin,
obtenerConfigsEndpoint,
actualizarConfigsEndpoint,
obtenerRolesConfigEndpoint,
actualizarRolConfigEndpoint,
obtenerUsuariosEndpoint,
actualizarUsuarioEndpoint,
resetRolesEndpoint,
obtenerAccionesEndpoint,
} from '../controllers/config.controller.js'
export const apiRouter = Router()
@@ -190,3 +201,13 @@ apiRouter.get('/ai/conversations/:id', obtenerConversacion)
apiRouter.post('/ai/documents', subirDocumento)
apiRouter.get('/ai/documents', listarDocumentosIA)
apiRouter.delete('/ai/documents/:id', eliminarDocumento)
// ── Configuración (admin) ──────────────────────────────────────────────
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.get('/config/usuarios', requireAdmin, obtenerUsuariosEndpoint)
apiRouter.patch('/config/usuarios/:id', requireAdmin, actualizarUsuarioEndpoint)
apiRouter.post('/config/reset-roles', requireAdmin, resetRolesEndpoint)
apiRouter.get('/config/acciones', obtenerAccionesEndpoint)

View File

@@ -55,6 +55,7 @@ describe('login', () => {
asignacion: 'MONTEBELLO',
mondayUserId: null,
modulos: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat'],
acciones: [],
})
expect(verificarToken(resultado.token).username).toBe('erick israel pena ramos')
})

View File

@@ -5,6 +5,7 @@ import { prisma } from './prisma.js'
import { config } from '../config/index.js'
import { modulosDeRol, type Modulo } from '../config/roles.js'
import { AppError } from '../utils/errors.js'
import * as configService from './config.service.js'
export interface UsuarioPublico {
nombre: string
@@ -14,6 +15,8 @@ export interface UsuarioPublico {
mondayUserId: number | null
// Módulos del ecosistema visibles para el rol (matriz de config/roles.ts)
modulos: Modulo[]
// Acciones granulares del rol (catálogo de config/acciones.ts)
acciones: string[]
}
export interface LoginResult {
@@ -34,13 +37,23 @@ export function normalizarUsername(nombre: string): string {
.replace(/\s+/g, ' ')
}
export function aPublico(usuario: Usuario): UsuarioPublico {
export async function aPublico(usuario: Usuario): Promise<UsuarioPublico> {
let acciones: string[] = []
try {
const acc = await configService.obtenerAcciones(usuario.rol)
acciones = Object.entries(acc)
.filter(([, v]) => v)
.map(([k]) => k)
} catch {
// Si falla la lectura de config, devuelve acciones vacías (no rompe)
}
return {
nombre: usuario.nombre,
rol: usuario.rol,
asignacion: usuario.asignacion,
mondayUserId: usuario.mondayUserId,
modulos: modulosDeRol(usuario.rol),
modulos: await modulosDeRol(usuario.rol),
acciones,
}
}
@@ -64,7 +77,7 @@ export async function login(username: string, password: string): Promise<LoginRe
config.auth.jwtSecret,
{ expiresIn: config.auth.jwtExpiresIn } as jwt.SignOptions,
)
return { token, usuario: aPublico(usuario) }
return { token, usuario: await aPublico(usuario) }
}
/** Payload del JWT emitido por login() */

View File

@@ -0,0 +1,235 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// ---------- Prisma mockeado ----------
const prismaMock = vi.hoisted(() => ({
configuracionSistema: {
findMany: vi.fn(),
upsert: vi.fn(),
},
rolConfiguracion: {
findMany: vi.fn(),
findUnique: vi.fn(),
upsert: vi.fn(),
create: vi.fn(),
deleteMany: vi.fn(),
},
usuario: {
findMany: vi.fn(),
update: vi.fn(),
},
$transaction: vi.fn(async (ops: Promise<unknown>[]) => Promise.all(ops)),
}))
vi.mock('./prisma.js', () => ({ prisma: prismaMock }))
import {
obtenerConfigs,
actualizarConfigs,
obtenerConfig,
obtenerConfigSync,
refrescarCache,
resetCacheForTesting,
obtenerRolesConfig,
obtenerRolConfig,
actualizarRolConfig,
resetRoles,
obtenerAcciones,
obtenerUsuarios,
actualizarUsuario,
} from './config.service.js'
beforeEach(() => {
vi.clearAllMocks()
resetCacheForTesting()
})
// ── ConfiguracionSistema ───────────────────────────────────────────────
describe('obtenerConfigs', () => {
it('devuelve todas las configs como Record<clave, valor>', async () => {
prismaMock.configuracionSistema.findMany.mockResolvedValue([
{ clave: 'a', valor: '1', updatedAt: new Date() },
{ clave: 'b', valor: '2', updatedAt: new Date() },
])
const result = await obtenerConfigs()
expect(result).toEqual({ a: '1', b: '2' })
})
it('devuelve objeto vacío si no hay configs', async () => {
prismaMock.configuracionSistema.findMany.mockResolvedValue([])
expect(await obtenerConfigs()).toEqual({})
})
})
describe('actualizarConfigs', () => {
it('upsert cada clave y refresca el cache', async () => {
prismaMock.configuracionSistema.findMany.mockResolvedValue([])
prismaMock.configuracionSistema.upsert.mockResolvedValue({})
await actualizarConfigs({ x: '1', y: '2' })
expect(prismaMock.configuracionSistema.upsert).toHaveBeenCalledTimes(2)
})
})
describe('obtenerConfig + obtenerConfigSync (cache)', () => {
it('obtenerConfig consulta DB en primera llamada y cachea', async () => {
prismaMock.configuracionSistema.findMany.mockResolvedValue([
{ clave: 'cache_k', valor: 'cache_v', updatedAt: new Date() },
])
const callsBefore = prismaMock.configuracionSistema.findMany.mock.calls.length
const v1 = await obtenerConfig('cache_k')
expect(v1).toBe('cache_v')
expect(prismaMock.configuracionSistema.findMany.mock.calls.length).toBe(callsBefore + 1)
// Segunda llamada usa cache (no vuelve a consultar DB)
const v2 = await obtenerConfig('cache_k')
expect(v2).toBe('cache_v')
expect(prismaMock.configuracionSistema.findMany.mock.calls.length).toBe(callsBefore + 1)
})
it('obtenerConfigSync lee del cache si ya fue inicializado', async () => {
prismaMock.configuracionSistema.findMany.mockResolvedValue([
{ clave: 'sync_k', valor: 'sync_v', updatedAt: new Date() },
])
await refrescarCache()
expect(obtenerConfigSync('sync_k')).toBe('sync_v')
})
it('refrescarCache recarga desde DB', async () => {
prismaMock.configuracionSistema.findMany.mockResolvedValue([
{ clave: 'nuevo', valor: 'si', updatedAt: new Date() },
])
await refrescarCache()
expect(obtenerConfigSync('nuevo')).toBe('si')
})
})
// ── RolConfiguracion ───────────────────────────────────────────────────
describe('obtenerRolesConfig', () => {
it('devuelve todos los roles ordenados', async () => {
prismaMock.rolConfiguracion.findMany.mockResolvedValue([
{ rol: 'admin', modulos: '[]', acciones: '{}', updatedAt: new Date() },
])
const result = await obtenerRolesConfig()
expect(result).toHaveLength(1)
expect(result[0]!.rol).toBe('admin')
})
})
describe('obtenerRolConfig', () => {
it('parsea JSON de modulos y acciones', async () => {
prismaMock.rolConfiguracion.findUnique.mockResolvedValue({
rol: 'vendedor',
modulos: JSON.stringify(['mi-dia', 'cotizador']),
acciones: JSON.stringify({ descartar_lead: true }),
updatedAt: new Date(),
})
const result = await obtenerRolConfig('vendedor')
expect(result).toEqual({
modulos: ['mi-dia', 'cotizador'],
acciones: { descartar_lead: true },
})
})
it('devuelve null si el rol no existe', async () => {
prismaMock.rolConfiguracion.findUnique.mockResolvedValue(null)
expect(await obtenerRolConfig('inexistente')).toBeNull()
})
})
describe('actualizarRolConfig', () => {
it('upsert con JSON serializado', async () => {
prismaMock.rolConfiguracion.upsert.mockResolvedValue({})
await actualizarRolConfig('admin', {
modulos: ['all'],
acciones: { todo: true },
})
expect(prismaMock.rolConfiguracion.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { rol: 'admin' },
create: expect.objectContaining({
modulos: JSON.stringify(['all']),
acciones: JSON.stringify({ todo: true }),
}),
}),
)
})
})
describe('resetRoles', () => {
it('borra todas las configs y re-siembra desde MATRIZ + defaults', async () => {
prismaMock.rolConfiguracion.deleteMany.mockResolvedValue({ count: 0 })
prismaMock.rolConfiguracion.create.mockResolvedValue({})
await resetRoles()
expect(prismaMock.rolConfiguracion.deleteMany).toHaveBeenCalled()
// MATRIZ tiene 6 roles
expect(prismaMock.rolConfiguracion.create).toHaveBeenCalledTimes(6)
})
})
// ── Acciones por rol ───────────────────────────────────────────────────
describe('obtenerAcciones', () => {
it('lee de DB si existe el rol', async () => {
prismaMock.rolConfiguracion.findUnique.mockResolvedValue({
rol: 'vendedor',
modulos: '[]',
acciones: JSON.stringify({ descartar_lead: true, ver_reportes: false }),
updatedAt: new Date(),
})
const acc = await obtenerAcciones('vendedor')
expect(acc.descartar_lead).toBe(true)
expect(acc.ver_reportes).toBe(false)
})
it('fallback a defaults si el rol no existe en DB', async () => {
prismaMock.rolConfiguracion.findUnique.mockResolvedValue(null)
const acc = await obtenerAcciones('vendedor')
expect(acc.descartar_lead).toBe(true)
expect(acc.ver_reportes).toBe(false)
})
it('fallback a defaults si JSON está corrupto', async () => {
prismaMock.rolConfiguracion.findUnique.mockResolvedValue({
rol: 'vendedor',
modulos: '[]',
acciones: 'no-json',
updatedAt: new Date(),
})
const acc = await obtenerAcciones('vendedor')
expect(acc.descartar_lead).toBe(true)
})
})
// ── Usuarios ───────────────────────────────────────────────────────────
describe('obtenerUsuarios', () => {
it('devuelve usuarios sin passwordHash', async () => {
prismaMock.usuario.findMany.mockResolvedValue([
{ id: 'u1', username: 'a', nombre: 'A', rol: 'vendedor', asignacion: null, activo: true, mondayUserId: null },
])
const result = await obtenerUsuarios()
expect(result).toHaveLength(1)
expect(result[0]).not.toHaveProperty('passwordHash')
})
})
describe('actualizarUsuario', () => {
it('actualiza solo los campos enviados', async () => {
prismaMock.usuario.update.mockResolvedValue({})
await actualizarUsuario('u1', { rol: 'admin' })
expect(prismaMock.usuario.update).toHaveBeenCalledWith({
where: { id: 'u1' },
data: { rol: 'admin' },
})
})
it('actualiza asignacion y activo', async () => {
prismaMock.usuario.update.mockResolvedValue({})
await actualizarUsuario('u1', { asignacion: null, activo: false })
expect(prismaMock.usuario.update).toHaveBeenCalledWith({
where: { id: 'u1' },
data: { asignacion: null, activo: false },
})
})
})

View File

@@ -0,0 +1,194 @@
/**
* Servicio de configuración del sistema.
*
* Lee/escribe las tablas ConfiguracionSistema y RolConfiguracion, y opera
* sobre usuarios (rol, asignación, activo). Singleton con cache de 30 s en
* `obtenerConfig` para no golpear la DB en cada request.
*/
import { prisma } from './prisma.js'
import { accionesPorRol, type AccionClave } from '../config/acciones.js'
import { MATRIZ } from '../config/roles.js'
import type { Modulo } from '../config/roles.js'
// ── Cache de 30 s para obtenerConfig ────────────────────────────────────
const CACHE_TTL_MS = 30_000
let cacheMap: Record<string, string> | null = null
let cacheExpiresAt = 0
function invalidarCache(): void {
cacheMap = null
cacheExpiresAt = 0
}
// ── ConfiguracionSistema ───────────────────────────────────────────────
/** Todas las configs como Record<clave, valor> */
export async function obtenerConfigs(): Promise<Record<string, string>> {
const rows = await prisma.configuracionSistema.findMany()
const result: Record<string, string> = {}
for (const r of rows) result[r.clave] = r.valor
return result
}
/** Upsert parcial de configs (solo las claves enviadas) */
export async function actualizarConfigs(parcial: Record<string, string>): Promise<void> {
await prisma.$transaction(
Object.entries(parcial).map(([clave, valor]) =>
prisma.configuracionSistema.upsert({
where: { clave },
create: { clave, valor },
update: { valor },
}),
),
)
await refrescarCache()
}
/** Una config individual (cache 30 s). Devuelve null si la clave no existe. */
export async function obtenerConfig(clave: string): Promise<string | null> {
const now = Date.now()
if (cacheMap && now < cacheExpiresAt) return cacheMap[clave] ?? null
cacheMap = await obtenerConfigs()
cacheExpiresAt = now + CACHE_TTL_MS
return cacheMap[clave] ?? null
}
/**
* Versión síncrona de obtenerConfig: lee del cache en memoria.
* Devuelve undefined si el cache no ha sido inicializado aún (el caller
* debe usar el fallback apropiado, p. ej. process.env).
* Se alimenta con `refrescarCache()` (llamar en startup y al cambiar configs).
*/
export function obtenerConfigSync(clave: string): string | undefined {
if (!cacheMap) return undefined
return cacheMap[clave]
}
/** Fuerza la recarga del cache desde la DB (llamar en startup y al guardar configs) */
export async function refrescarCache(): Promise<void> {
cacheMap = await obtenerConfigs()
cacheExpiresAt = Date.now() + CACHE_TTL_MS
}
/** Resetea el cache (para tests) */
export function resetCacheForTesting(): void {
cacheMap = null
cacheExpiresAt = 0
}
// ── RolConfiguracion ───────────────────────────────────────────────────
export interface RolConfigData {
modulos: string[]
acciones: Record<string, boolean>
}
/** Todas las configs de rol (sin parsear JSON) */
export async function obtenerRolesConfig() {
return prisma.rolConfiguracion.findMany({ orderBy: { rol: 'asc' } })
}
/** Config de un rol (JSON parseado) */
export async function obtenerRolConfig(rol: string): Promise<RolConfigData | null> {
const row = await prisma.rolConfiguracion.findUnique({ where: { rol } })
if (!row) return null
return {
modulos: JSON.parse(row.modulos) as string[],
acciones: JSON.parse(row.acciones) as Record<string, boolean>,
}
}
/** Upsert de módulos y acciones de un rol */
export async function actualizarRolConfig(
rol: string,
data: { modulos: string[]; acciones: Record<string, boolean> },
): Promise<void> {
await prisma.rolConfiguracion.upsert({
where: { rol },
create: {
rol,
modulos: JSON.stringify(data.modulos),
acciones: JSON.stringify(data.acciones),
},
update: {
modulos: JSON.stringify(data.modulos),
acciones: JSON.stringify(data.acciones),
},
})
}
/** Borra todas las RolConfiguracion y re-siembra desde MATRIZ + ACCIONES_POR_ROL_DEFAULT */
export async function resetRoles(): Promise<void> {
await prisma.rolConfiguracion.deleteMany()
const entries = Object.entries(MATRIZ) as [string, readonly Modulo[]][]
await prisma.$transaction(
entries.map(([rol, modulos]) => {
const acciones = accionesPorRol(rol)
return prisma.rolConfiguracion.create({
data: {
rol,
modulos: JSON.stringify([...modulos]),
acciones: JSON.stringify(acciones),
},
})
}),
)
}
// ── Acciones por rol ───────────────────────────────────────────────────
/** Las acciones booleanas de un rol (lee de DB; fallback a defaults) */
export async function obtenerAcciones(rol: string): Promise<Record<AccionClave, boolean>> {
const row = await prisma.rolConfiguracion.findUnique({ where: { rol } })
if (!row) return accionesPorRol(rol)
try {
return JSON.parse(row.acciones) as Record<AccionClave, boolean>
} catch {
return accionesPorRol(rol)
}
}
// ── Usuarios ───────────────────────────────────────────────────────────
export interface UsuarioListado {
id: string
username: string
nombre: string
rol: string
asignacion: string | null
activo: boolean
mondayUserId: number | null
}
/** Lista de usuarios sin passwordHash */
export async function obtenerUsuarios(): Promise<UsuarioListado[]> {
const rows = await prisma.usuario.findMany({
select: {
id: true,
username: true,
nombre: true,
rol: true,
asignacion: true,
activo: true,
mondayUserId: true,
},
orderBy: { nombre: 'asc' },
})
return rows
}
/** Actualiza rol, asignación y/o activo de un usuario */
export async function actualizarUsuario(
id: string,
data: { rol?: string; asignacion?: string | null; activo?: boolean },
): Promise<void> {
// Filtra undefined para que Prisma solo actualice los campos enviados
const clean: Record<string, unknown> = {}
if (data.rol !== undefined) clean.rol = data.rol
if (data.asignacion !== undefined) clean.asignacion = data.asignacion
if (data.activo !== undefined) clean.activo = data.activo
await prisma.usuario.update({ where: { id }, data: clean as never })
}

View File

@@ -7,7 +7,8 @@
* local) en lugar de pagar los reintentos por request; un cron sondea cada 60 s
* y al recuperarse dispara el sync worker.
*
* MODO_LOCAL_FORZADO=true fuerza modo local aunque Monday responda (pruebas).
* MODO_LOCAL_FORZADO=true (env var o DB) fuerza modo local aunque Monday
* responda (pruebas / toggle admin). La DB se cachea 30 s en configService.
*
* El ping se inyecta con `registrarPing()` (wiring en index.ts) para no
* importar monday.service aquí y evitar ciclos de imports.
@@ -45,9 +46,24 @@ export class SaludMondayService {
return this.mondayOk
}
/** true si los callers pueden hablar con Monday ahora mismo */
/**
* true si los callers pueden hablar con Monday ahora mismo.
* Lee modo_local_forzado de la DB (via cache 30 s en configService) y de
* process.env como fallback. Síncrono: la DB se precarga al iniciar y se
* invalida al cambiar configs.
*/
estaDisponible(): boolean {
return this.mondayOk && process.env.MODO_LOCAL_FORZADO !== 'true'
// Lazy import para no crear ciclos en tests (configService importa prisma)
let forzadoDb = false
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const cs = require('./config.service.js') as typeof import('./config.service.js')
forzadoDb = cs.obtenerConfigSync('modo_local_forzado') === 'true'
} catch {
// configService no disponible (tests aislados, DB no inicializada)
}
const forzadoEnv = process.env.MODO_LOCAL_FORZADO === 'true'
return this.mondayOk && !forzadoDb && !forzadoEnv
}
/**