From b2634f97abc7f4474893e0750762243739a87893 Mon Sep 17 00:00:00 2001 From: Consult AS Technology Date: Sun, 6 Sep 2026 09:46:19 +0000 Subject: [PATCH] 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 --- api-gateway/package.json | 1 + .../migration.sql | 17 ++ api-gateway/prisma/schema.prisma | 13 + api-gateway/scripts/seed-config.js | 84 ++++++ api-gateway/src/config/acciones.ts | 79 ++++++ api-gateway/src/config/roles.test.ts | 20 +- api-gateway/src/config/roles.ts | 24 +- .../src/controllers/config.controller.test.ts | 206 +++++++++++++++ .../src/controllers/config.controller.ts | 111 ++++++++ .../controllers/usuarios.controller.test.ts | 1 + .../src/controllers/usuarios.controller.ts | 4 +- api-gateway/src/index.ts | 6 + api-gateway/src/routes/index.ts | 21 ++ api-gateway/src/services/auth.service.test.ts | 1 + api-gateway/src/services/auth.service.ts | 19 +- .../src/services/config.service.test.ts | 235 +++++++++++++++++ api-gateway/src/services/config.service.ts | 194 ++++++++++++++ .../src/services/salud-monday.service.ts | 22 +- apps/cotizador/src/services/auth.ts | 1 + apps/landing/src/App.tsx | 28 +- apps/landing/src/components/Navbar.tsx | 22 +- .../src/components/config/ConfigPage.test.tsx | 72 ++++++ .../src/components/config/ConfigPage.tsx | 68 +++++ .../components/config/ModoTrabajo.test.tsx | 85 +++++++ .../src/components/config/ModoTrabajo.tsx | 155 +++++++++++ .../components/config/RolesPermisos.test.tsx | 85 +++++++ .../src/components/config/RolesPermisos.tsx | 240 ++++++++++++++++++ .../src/components/config/Sistema.test.tsx | 101 ++++++++ .../landing/src/components/config/Sistema.tsx | 224 ++++++++++++++++ .../src/components/config/Usuarios.test.tsx | 100 ++++++++ .../src/components/config/Usuarios.tsx | 233 +++++++++++++++++ apps/landing/src/services/auth.ts | 2 + apps/landing/src/services/config.ts | 119 +++++++++ apps/mi-dia/src/pages/MiDiaPage.tsx | 11 +- apps/mi-dia/src/services/auth.ts | 1 + 35 files changed, 2572 insertions(+), 33 deletions(-) create mode 100644 api-gateway/prisma/migrations/20260906120000_config_sistema_rol/migration.sql create mode 100644 api-gateway/scripts/seed-config.js create mode 100644 api-gateway/src/config/acciones.ts create mode 100644 api-gateway/src/controllers/config.controller.test.ts create mode 100644 api-gateway/src/controllers/config.controller.ts create mode 100644 api-gateway/src/services/config.service.test.ts create mode 100644 api-gateway/src/services/config.service.ts create mode 100644 apps/landing/src/components/config/ConfigPage.test.tsx create mode 100644 apps/landing/src/components/config/ConfigPage.tsx create mode 100644 apps/landing/src/components/config/ModoTrabajo.test.tsx create mode 100644 apps/landing/src/components/config/ModoTrabajo.tsx create mode 100644 apps/landing/src/components/config/RolesPermisos.test.tsx create mode 100644 apps/landing/src/components/config/RolesPermisos.tsx create mode 100644 apps/landing/src/components/config/Sistema.test.tsx create mode 100644 apps/landing/src/components/config/Sistema.tsx create mode 100644 apps/landing/src/components/config/Usuarios.test.tsx create mode 100644 apps/landing/src/components/config/Usuarios.tsx create mode 100644 apps/landing/src/services/config.ts diff --git a/api-gateway/package.json b/api-gateway/package.json index 99733a1..15dfcea 100644 --- a/api-gateway/package.json +++ b/api-gateway/package.json @@ -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", diff --git a/api-gateway/prisma/migrations/20260906120000_config_sistema_rol/migration.sql b/api-gateway/prisma/migrations/20260906120000_config_sistema_rol/migration.sql new file mode 100644 index 0000000..17b9a3c --- /dev/null +++ b/api-gateway/prisma/migrations/20260906120000_config_sistema_rol/migration.sql @@ -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 +); diff --git a/api-gateway/prisma/schema.prisma b/api-gateway/prisma/schema.prisma index d5baacf..7ad60ef 100644 --- a/api-gateway/prisma/schema.prisma +++ b/api-gateway/prisma/schema.prisma @@ -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 diff --git a/api-gateway/scripts/seed-config.js b/api-gateway/scripts/seed-config.js new file mode 100644 index 0000000..213c1e4 --- /dev/null +++ b/api-gateway/scripts/seed-config.js @@ -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()) diff --git a/api-gateway/src/config/acciones.ts b/api-gateway/src/config/acciones.ts new file mode 100644 index 0000000..ee9606c --- /dev/null +++ b/api-gateway/src/config/acciones.ts @@ -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> = { + 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 { + const clave = rol.trim().toLowerCase() + return (ACCIONES_POR_ROL_DEFAULT[clave] ?? ACCIONES_POR_ROL_DEFAULT.vendedor)! +} diff --git a/api-gateway/src/config/roles.test.ts b/api-gateway/src/config/roles.test.ts index 57fa470..bfe619d 100644 --- a/api-gateway/src/config/roles.test.ts +++ b/api-gateway/src/config/roles.test.ts @@ -39,22 +39,22 @@ const ESPERADOS: Record = { } 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]) }) }) diff --git a/api-gateway/src/config/roles.ts b/api-gateway/src/config/roles.ts index d594627..20b400e 100644 --- a/api-gateway/src/config/roles.ts +++ b/api-gateway/src/config/roles.ts @@ -35,7 +35,7 @@ const MODULOS_VENDEDOR: readonly Modulo[] = [ 'chat', ] -const MATRIZ: Record = { +export const MATRIZ: Record = { vendedor: MODULOS_VENDEDOR, administracion_venta: [...MODULOS_VENDEDOR, 'cobranza'], director_ventas: [...MODULOS_VENDEDOR, 'cobranza', 'tablero-comercial'], @@ -52,14 +52,28 @@ const ROLES_LEGACY: Record = { } /** - * 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 { 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]] } diff --git a/api-gateway/src/controllers/config.controller.test.ts b/api-gateway/src/controllers/config.controller.test.ts new file mode 100644 index 0000000..ddfed03 --- /dev/null +++ b/api-gateway/src/controllers/config.controller.test.ts @@ -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 { + 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', + ) + }) +}) diff --git a/api-gateway/src/controllers/config.controller.ts b/api-gateway/src/controllers/config.controller.ts new file mode 100644 index 0000000..a848751 --- /dev/null +++ b/api-gateway/src/controllers/config.controller.ts @@ -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 { + 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 { + 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 { + 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, + 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 { + 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 { + 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 { + 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 { + await configService.resetRoles() + res.status(204).end() +} + +// ── GET /api/config/acciones ─────────────────────────────────────────── + +export async function obtenerAccionesEndpoint(req: Request, res: Response): Promise { + if (!req.usuarioToken) throw new BadRequestError('Falta el token verificado') + const acciones = await configService.obtenerAcciones(req.usuarioToken.rol) + res.json(acciones) +} diff --git a/api-gateway/src/controllers/usuarios.controller.test.ts b/api-gateway/src/controllers/usuarios.controller.test.ts index c822adb..056239d 100644 --- a/api-gateway/src/controllers/usuarios.controller.test.ts +++ b/api-gateway/src/controllers/usuarios.controller.test.ts @@ -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: [], }) }) diff --git a/api-gateway/src/controllers/usuarios.controller.ts b/api-gateway/src/controllers/usuarios.controller.ts index 54da32f..1ba748f 100644 --- a/api-gateway/src/controllers/usuarios.controller.ts +++ b/api-gateway/src/controllers/usuarios.controller.ts @@ -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)) } diff --git a/api-gateway/src/index.ts b/api-gateway/src/index.ts index f625236..fbf7a23 100644 --- a/api-gateway/src/index.ts +++ b/api-gateway/src/index.ts @@ -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})`) diff --git a/api-gateway/src/routes/index.ts b/api-gateway/src/routes/index.ts index 22bc5ec..799b496 100644 --- a/api-gateway/src/routes/index.ts +++ b/api-gateway/src/routes/index.ts @@ -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) diff --git a/api-gateway/src/services/auth.service.test.ts b/api-gateway/src/services/auth.service.test.ts index 2d5e034..6a17f2f 100644 --- a/api-gateway/src/services/auth.service.test.ts +++ b/api-gateway/src/services/auth.service.test.ts @@ -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') }) diff --git a/api-gateway/src/services/auth.service.ts b/api-gateway/src/services/auth.service.ts index 6196b62..603ea4b 100644 --- a/api-gateway/src/services/auth.service.ts +++ b/api-gateway/src/services/auth.service.ts @@ -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 { + 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 ({ + 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[]) => 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', 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 }, + }) + }) +}) diff --git a/api-gateway/src/services/config.service.ts b/api-gateway/src/services/config.service.ts new file mode 100644 index 0000000..ce31696 --- /dev/null +++ b/api-gateway/src/services/config.service.ts @@ -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 | null = null +let cacheExpiresAt = 0 + +function invalidarCache(): void { + cacheMap = null + cacheExpiresAt = 0 +} + +// ── ConfiguracionSistema ─────────────────────────────────────────────── + +/** Todas las configs como Record */ +export async function obtenerConfigs(): Promise> { + const rows = await prisma.configuracionSistema.findMany() + const result: Record = {} + 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): Promise { + 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 { + 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 { + 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 +} + +/** 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 { + 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, + } +} + +/** Upsert de módulos y acciones de un rol */ +export async function actualizarRolConfig( + rol: string, + data: { modulos: string[]; acciones: Record }, +): Promise { + 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 { + 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> { + const row = await prisma.rolConfiguracion.findUnique({ where: { rol } }) + if (!row) return accionesPorRol(rol) + try { + return JSON.parse(row.acciones) as Record + } 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 { + 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 { + // Filtra undefined para que Prisma solo actualice los campos enviados + const clean: Record = {} + 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 }) +} diff --git a/api-gateway/src/services/salud-monday.service.ts b/api-gateway/src/services/salud-monday.service.ts index 77621c3..d536912 100644 --- a/api-gateway/src/services/salud-monday.service.ts +++ b/api-gateway/src/services/salud-monday.service.ts @@ -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 } /** diff --git a/apps/cotizador/src/services/auth.ts b/apps/cotizador/src/services/auth.ts index 70cd037..dc51b66 100644 --- a/apps/cotizador/src/services/auth.ts +++ b/apps/cotizador/src/services/auth.ts @@ -6,6 +6,7 @@ export interface UsuarioSesion { nombre: string rol: string asignacion: string | null + acciones?: string[] } export interface Sesion { diff --git a/apps/landing/src/App.tsx b/apps/landing/src/App.tsx index 75c6cbe..d2bdb42 100644 --- a/apps/landing/src/App.tsx +++ b/apps/landing/src/App.tsx @@ -5,27 +5,33 @@ import { TarjetaModulo } from './components/TarjetaModulo' import { ConfiguracionCuenta } from './components/ConfiguracionCuenta' import BannerModoLocal from './components/BannerModoLocal' import { VistaContactos } from './components/VistaContactos' -import { getToken } from './services/auth' +import { ConfigPage } from './components/config/ConfigPage' +import { getToken, type UsuarioSesion } from './services/auth' import { getMe } from './services/api' -type Vista = 'home' | 'contactos' +type Vista = 'home' | 'contactos' | 'configuraciones' export default function App() { const [vista, setVista] = useState('home') // Módulos permitidos por el rol (GET /auth/me); null = sin sesión → se muestran todos const [permitidos, setPermitidos] = useState(null) + const [usuario, setUsuario] = useState(null) useEffect(() => { let cancelado = false const cargar = () => { if (!getToken()) { setPermitidos(null) + setUsuario(null) return } // Si /me falla se conserva lo anterior (o todos): el login wall de cada app sigue getMe() - .then((usuario) => { - if (!cancelado) setPermitidos(usuario.modulos ?? []) + .then((u) => { + if (!cancelado) { + setPermitidos(u.modulos ?? []) + setUsuario(u) + } }) .catch(() => {}) } @@ -40,16 +46,26 @@ export default function App() { if (vista === 'contactos') { return (
- + setVista('configuraciones')} /> setVista('home')} />
) } + if (vista === 'configuraciones' && usuario?.rol === 'admin') { + return ( +
+ setVista('configuraciones')} /> + + setVista('home')} /> +
+ ) + } + return (
- + setVista('configuraciones')} />
diff --git a/apps/landing/src/components/Navbar.tsx b/apps/landing/src/components/Navbar.tsx index 20ca161..ff7d323 100644 --- a/apps/landing/src/components/Navbar.tsx +++ b/apps/landing/src/components/Navbar.tsx @@ -1,8 +1,13 @@ import { useEffect, useState } from 'react' -import { getSesion } from '../services/auth' +import { getSesion, type UsuarioSesion } from '../services/auth' + +interface Props { + usuario?: UsuarioSesion | null + onConfiguraciones?: () => void +} /** Barra superior: marca a la izquierda, estado de sesión y acceso a configuración a la derecha */ -export function Navbar() { +export function Navbar({ usuario, onConfiguraciones }: Props) { const [conSesion, setConSesion] = useState(() => getSesion() !== null) useEffect(() => { @@ -15,6 +20,8 @@ export function Navbar() { } }, []) + const esAdmin = usuario?.rol === 'admin' + return (
@@ -33,6 +40,17 @@ export function Navbar() { /> {conSesion ? 'Sesión activa' : 'Sin sesión'} + {esAdmin && onConfiguraciones && ( + + )} Promise + +function crearStorageStub(): Storage { + const mapa = new Map() + return { + getItem: (k: string) => mapa.get(k) ?? null, + setItem: (k: string, v: string) => void mapa.set(k, v), + removeItem: (k: string) => void mapa.delete(k), + clear: () => mapa.clear(), + key: () => null, + get length() { + return mapa.size + }, + } as Storage +} + +const respuestaOk = (data: unknown) => + new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }) + +let fetchMock: ReturnType> + +beforeEach(() => { + fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + vi.stubGlobal('localStorage', crearStorageStub()) + // Mockear las llamadas iniciales que los tabs pueden hacer + fetchMock.mockImplementation((input) => { + const url = String(input) + if (url.endsWith('/sistema/estado')) return Promise.resolve(respuestaOk({ mondayOk: true, pendientesSync: 0 })) + if (url.endsWith('/config/sistema')) return Promise.resolve(respuestaOk({ modo_local_forzado: 'false' })) + if (url.endsWith('/config/roles')) return Promise.resolve(respuestaOk([])) + if (url.endsWith('/config/usuarios')) return Promise.resolve(respuestaOk([])) + return Promise.resolve(respuestaOk({})) + }) +}) + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +describe('ConfigPage', () => { + it('renderiza los 4 tabs y muestra "Modo de trabajo" por defecto', () => { + render( {}} />) + expect(screen.getByText('Configuraciones')).toBeTruthy() + expect(screen.getByRole('button', { name: 'Modo de trabajo' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Roles y permisos' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Usuarios' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Sistema' })).toBeTruthy() + // El tab de "Modo de trabajo" está activo por defecto + expect(screen.getByText('Modo de trabajo')).toBeTruthy() + }) + + it('al hacer clic en un tab, muestra su contenido', async () => { + render( {}} />) + fireEvent.click(screen.getByRole('button', { name: 'Roles y permisos' })) + expect(await screen.findByText('Guardar cambios')).toBeTruthy() + expect(screen.getByText('Restaurar valores por defecto')).toBeTruthy() + }) + + it('al hacer clic en "Volver", ejecuta el callback', () => { + const volver = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: /volver/i })) + expect(volver).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/landing/src/components/config/ConfigPage.tsx b/apps/landing/src/components/config/ConfigPage.tsx new file mode 100644 index 0000000..184cfc2 --- /dev/null +++ b/apps/landing/src/components/config/ConfigPage.tsx @@ -0,0 +1,68 @@ +import { useState } from 'react' +import { ModoTrabajo } from './ModoTrabajo' +import { RolesPermisos } from './RolesPermisos' +import { Usuarios } from './Usuarios' +import { Sistema } from './Sistema' + +interface Props { + onVolver: () => void +} + +type Tab = 'modo' | 'roles' | 'usuarios' | 'sistema' + +const TABS: { key: Tab; label: string }[] = [ + { key: 'modo', label: 'Modo de trabajo' }, + { key: 'roles', label: 'Roles y permisos' }, + { key: 'usuarios', label: 'Usuarios' }, + { key: 'sistema', label: 'Sistema' }, +] + +export function ConfigPage({ onVolver }: Props) { + const [tab, setTab] = useState('modo') + + return ( +
+
+ +

+ Configuraciones +

+
+ + {/* Tabs */} + + + {/* Contenido del tab activo */} +
+ {tab === 'modo' && } + {tab === 'roles' && } + {tab === 'usuarios' && } + {tab === 'sistema' && } +
+
+ ) +} diff --git a/apps/landing/src/components/config/ModoTrabajo.test.tsx b/apps/landing/src/components/config/ModoTrabajo.test.tsx new file mode 100644 index 0000000..2ed40c9 --- /dev/null +++ b/apps/landing/src/components/config/ModoTrabajo.test.tsx @@ -0,0 +1,85 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { ModoTrabajo } from './ModoTrabajo' + +type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise + +function crearStorageStub(): Storage { + const mapa = new Map() + return { + getItem: (k: string) => mapa.get(k) ?? null, + setItem: (k: string, v: string) => void mapa.set(k, v), + removeItem: (k: string) => void mapa.delete(k), + clear: () => mapa.clear(), + key: () => null, + get length() { + return mapa.size + }, + } as Storage +} + +const respuestaOk = (data: unknown) => + new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }) + +let fetchMock: ReturnType> + +beforeEach(() => { + fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + vi.stubGlobal('localStorage', crearStorageStub()) +}) + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +describe('ModoTrabajo', () => { + it('muestra el estado de Monday y el toggle', async () => { + fetchMock.mockImplementation((input) => { + const url = String(input) + if (url.includes('/sistema/estado')) return Promise.resolve(respuestaOk({ mondayOk: true, pendientesSync: 0 })) + if (url.includes('/config/sistema')) return Promise.resolve(respuestaOk({ modo_local_forzado: 'false' })) + return Promise.resolve(respuestaOk({})) + }) + + render() + + await waitFor(() => { + expect(screen.getByText(/Conectado|No disponible/)).toBeTruthy() + }, { timeout: 5000 }) + expect(screen.getByRole('switch', { name: /cambiar modo de trabajo/i })).toBeTruthy() + }) + + it('al hacer clic en el toggle, hace PATCH y actualiza el texto', async () => { + fetchMock.mockImplementation((input, init) => { + const url = String(input) + if (url.includes('/sistema/estado')) return Promise.resolve(respuestaOk({ mondayOk: true, pendientesSync: 0 })) + if (url.includes('/config/sistema') && (!init?.method || init.method === 'GET')) { + return Promise.resolve(respuestaOk({ modo_local_forzado: 'false' })) + } + if (url.includes('/config/sistema') && init?.method === 'PATCH') { + return Promise.resolve(respuestaOk({})) + } + return Promise.resolve(respuestaOk({})) + }) + + render() + + await waitFor(() => { + expect(screen.getByRole('switch', { name: /cambiar modo de trabajo/i })).toBeTruthy() + }, { timeout: 5000 }) + + fireEvent.click(screen.getByRole('switch', { name: /cambiar modo de trabajo/i })) + + await waitFor(() => { + const llamadaPatch = fetchMock.mock.calls.find( + ([url, init]) => String(url).includes('/config/sistema') && init?.method === 'PATCH', + ) + expect(llamadaPatch).toBeTruthy() + }, { timeout: 5000 }) + + expect(await screen.findByText('Modo local activado')).toBeTruthy() + }) +}) diff --git a/apps/landing/src/components/config/ModoTrabajo.tsx b/apps/landing/src/components/config/ModoTrabajo.tsx new file mode 100644 index 0000000..7e90a4d --- /dev/null +++ b/apps/landing/src/components/config/ModoTrabajo.tsx @@ -0,0 +1,155 @@ +import { useEffect, useState } from 'react' +import { fetchEstadoSistema, type EstadoSistema } from '../../services/sistema' +import { getConfigSistema, patchConfigSistema } from '../../services/config' +import { API_URL, conAuth } from '../../services/auth' + +export function ModoTrabajo() { + const [estado, setEstado] = useState(null) + const [modoLocal, setModoLocal] = useState(false) + const [cargando, setCargando] = useState(true) + const [guardando, setGuardando] = useState(false) + const [aviso, setAviso] = useState('') + const [error, setError] = useState('') + + useEffect(() => { + let activo = true + const cargar = async () => { + setCargando(true) + try { + const [estadoRes, configRes] = await Promise.all([ + fetchEstadoSistema(), + getConfigSistema(), + ]) + if (!activo) return + setEstado(estadoRes) + setModoLocal(configRes.modo_local_forzado === 'true') + } catch { + if (activo) setError('No se pudo cargar el estado del sistema.') + } finally { + if (activo) setCargando(false) + } + } + void cargar() + return () => { activo = false } + }, []) + + const toggleModo = async () => { + const nuevoModo = !modoLocal + setGuardando(true) + setAviso('') + setError('') + try { + await patchConfigSistema({ modo_local_forzado: String(nuevoModo) }) + setModoLocal(nuevoModo) + setAviso(nuevoModo ? 'Modo local activado' : 'Modo vinculado a Monday restaurado') + } catch { + setError('No se pudo actualizar el modo de trabajo.') + } finally { + setGuardando(false) + } + } + + const reconectar = async () => { + setGuardando(true) + setAviso('') + setError('') + try { + // Intenta reconectar — si el endpoint no existe, simplemente informa + const res = await fetch(`${API_URL}/sistema/reconexion`, conAuth({ method: 'POST' })) + if (res.ok) { + setAviso('Reconexión forzada. El estado se actualizará en unos segundos.') + // Recargar estado después de un breve delay + setTimeout(async () => { + const nuevoEstado = await fetchEstadoSistema() + setEstado(nuevoEstado) + }, 3000) + } else { + setAviso('El endpoint de reconexión no está disponible aún.') + } + } catch { + setAviso('El endpoint de reconexión no está disponible aún.') + } finally { + setGuardando(false) + } + } + + if (cargando) { + return ( +
+

Cargando estado del sistema…

+
+ ) + } + + return ( +
+

Modo de trabajo

+ + {/* Indicador del estado de Monday */} +
+ + + Monday: {estado?.mondayOk ? 'Conectado' : 'No disponible'} + {estado && ` · ${estado.pendientesSync} pendiente(s) de sincronizar`} + +
+ + {/* Toggle de modo */} +
+ +
+

+ {modoLocal ? 'Modo local (sin Monday)' : 'Vinculado a Monday'} +

+

+ {modoLocal + ? 'Las capturas se guardan localmente y se sincronizarán cuando Monday esté disponible.' + : 'Los leads, actividades y contactos se sincronizan con Monday en tiempo real.'} +

+
+
+ + {/* Botón reconectar */} +
+ +
+ + {/* Feedback */} + {aviso && ( +

+ {aviso} +

+ )} + {error && ( +

+ {error} +

+ )} +
+ ) +} diff --git a/apps/landing/src/components/config/RolesPermisos.test.tsx b/apps/landing/src/components/config/RolesPermisos.test.tsx new file mode 100644 index 0000000..e18b217 --- /dev/null +++ b/apps/landing/src/components/config/RolesPermisos.test.tsx @@ -0,0 +1,85 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { RolesPermisos } from './RolesPermisos' + +type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise + +function crearStorageStub(): Storage { + const mapa = new Map() + return { + getItem: (k: string) => mapa.get(k) ?? null, + setItem: (k: string, v: string) => void mapa.set(k, v), + removeItem: (k: string) => void mapa.delete(k), + clear: () => mapa.clear(), + key: () => null, + get length() { + return mapa.size + }, + } as Storage +} + +const ROLES_FAKE = [ + { + rol: 'vendedor', + modulos: JSON.stringify(['mi-dia', 'cotizador']), + acciones: JSON.stringify({ descartar_lead: true, ver_precios: false }), + }, +] + +const respuestaOk = (data: unknown) => + new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }) + +let fetchMock: ReturnType> + +beforeEach(() => { + fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + vi.stubGlobal('localStorage', crearStorageStub()) +}) + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +describe('RolesPermisos', () => { + it('muestra la tabla de roles con los checkboxes correctos', async () => { + fetchMock.mockImplementation((input) => { + const url = String(input) + if (url.endsWith('/config/roles')) return Promise.resolve(respuestaOk(ROLES_FAKE)) + return Promise.resolve(respuestaOk({})) + }) + + render() + expect(await screen.findByText('vendedor')).toBeTruthy() + // Botones de acción + expect(screen.getByRole('button', { name: 'Guardar cambios' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Restaurar valores por defecto' })).toBeTruthy() + }) + + it('al guardar, hace PATCH por cada rol modificado', async () => { + fetchMock.mockImplementation((input, init) => { + const url = String(input) + if (url.endsWith('/config/roles') && (!init?.method || init.method === 'GET')) { + return Promise.resolve(respuestaOk(ROLES_FAKE)) + } + if (url.endsWith('/config/roles/vendedor') && init?.method === 'PATCH') { + return Promise.resolve(respuestaOk({})) + } + return Promise.resolve(respuestaOk({})) + }) + + render() + await screen.findByText('vendedor') + + fireEvent.click(screen.getByRole('button', { name: 'Guardar cambios' })) + + await waitFor(() => { + const llamadaPatch = fetchMock.mock.calls.find( + ([url, init]) => String(url).endsWith('/config/roles/vendedor') && init?.method === 'PATCH', + ) + expect(llamadaPatch).toBeTruthy() + }) + }) +}) diff --git a/apps/landing/src/components/config/RolesPermisos.tsx b/apps/landing/src/components/config/RolesPermisos.tsx new file mode 100644 index 0000000..61a8a3f --- /dev/null +++ b/apps/landing/src/components/config/RolesPermisos.tsx @@ -0,0 +1,240 @@ +import { useEffect, useState, useCallback } from 'react' +import { + getRolesConfig, + patchRolConfig, + resetRoles, + type RolConfig, +} from '../../services/config' + +const MODULOS_DISPONIBLES = [ + 'mi-dia', + 'cotizador', + 'alta-cliente', + 'alta-afluencia', + 'cobranza', + 'chat', + 'mi-tablero', + 'tablero-comercial', + 'contactos', +] + +const ACCIONES_DISPONIBLES = [ + 'descartar_lead', + 'editar_cotizacion', + 'asignar_vendedor', + 'ver_reportes', + 'exportar_pdf', + 'ver_precios', +] + +/** Descripción amigable de cada acción */ +const ACCION_LABELS: Record = { + descartar_lead: 'Descartar leads', + editar_cotizacion: 'Editar cotizaciones', + asignar_vendedor: 'Asignar vendedor', + ver_reportes: 'Ver reportes', + exportar_pdf: 'Exportar PDF', + ver_precios: 'Ver precios', +} + +/** Descripción amigable de cada módulo */ +const MODULO_LABELS: Record = { + 'mi-dia': 'Mi día', + cotizador: 'Cotizador', + 'alta-cliente': 'Alta cliente', + 'alta-afluencia': 'Alta afluencia', + cobranza: 'Cobranza', + chat: 'Chat IA', + 'mi-tablero': 'Mi tablero', + 'tablero-comercial': 'Tablero comercial', + contactos: 'Contactos', +} + +interface RolEstado { + modulos: string[] + acciones: Record +} + +export function RolesPermisos() { + const [roles, setRoles] = useState([]) + const [edicion, setEdicion] = useState>({}) + const [cargando, setCargando] = useState(true) + const [guardando, setGuardando] = useState(false) + const [aviso, setAviso] = useState('') + const [error, setError] = useState('') + + const cargar = useCallback(async () => { + setCargando(true) + setError('') + try { + const data = await getRolesConfig() + setRoles(data) + // Construir estado de edición + const estado: Record = {} + for (const r of data) { + estado[r.rol] = { + modulos: JSON.parse(r.modulos) as string[], + acciones: JSON.parse(r.acciones) as Record, + } + } + setEdicion(estado) + } catch { + setError('No se pudieron cargar los roles.') + } finally { + setCargando(false) + } + }, []) + + useEffect(() => { void cargar() }, [cargar]) + + const toggleModulo = (rol: string, modulo: string) => { + setEdicion((prev) => { + const actual = prev[rol] + if (!actual) return prev + const modulos = actual.modulos.includes(modulo) + ? actual.modulos.filter((m) => m !== modulo) + : [...actual.modulos, modulo] + return { ...prev, [rol]: { ...actual, modulos } } + }) + } + + const toggleAccion = (rol: string, accion: string) => { + setEdicion((prev) => { + const actual = prev[rol] + if (!actual) return prev + const acciones = { ...actual.acciones, [accion]: !actual.acciones[accion] } + return { ...prev, [rol]: { ...actual, acciones } } + }) + } + + const guardarTodo = async () => { + setGuardando(true) + setAviso('') + setError('') + try { + await Promise.all( + Object.entries(edicion).map(([rol, estado]) => + patchRolConfig(rol, { + modulos: estado.modulos, + acciones: estado.acciones, + }), + ), + ) + setAviso('Todos los roles se actualizaron correctamente.') + } catch { + setError('No se pudieron guardar los cambios. Intenta de nuevo.') + } finally { + setGuardando(false) + } + } + + const restaurar = async () => { + if (!window.confirm('¿Restaurar todos los roles a sus valores por defecto?')) return + setGuardando(true) + setAviso('') + setError('') + try { + await resetRoles() + await cargar() + setAviso('Roles restaurados a valores por defecto.') + } catch { + setError('No se pudieron restaurar los roles.') + } finally { + setGuardando(false) + } + } + + if (cargando) { + return ( +
+

Cargando configuración de roles…

+
+ ) + } + + return ( +
+ {/* Botones de acción */} +
+ + +
+ + {/* Feedback */} + {aviso && ( +

{aviso}

+ )} + {error && ( +

{error}

+ )} + + {/* Tabla de roles */} +
+ + + + + {MODULOS_DISPONIBLES.map((m) => ( + + ))} + {ACCIONES_DISPONIBLES.map((a) => ( + + ))} + + + + {roles.map((r) => { + const estado = edicion[r.rol] + if (!estado) return null + return ( + + + {MODULOS_DISPONIBLES.map((m) => ( + + ))} + {ACCIONES_DISPONIBLES.map((a) => ( + + ))} + + ) + })} + +
Rol + {MODULO_LABELS[m] ?? m} + + {ACCION_LABELS[a] ?? a} +
+ {r.rol} + + toggleModulo(r.rol, m)} + className="h-4 w-4 rounded border-[#D1D5DB] text-accent focus:ring-accent/20" + /> + + toggleAccion(r.rol, a)} + className="h-4 w-4 rounded border-[#D1D5DB] text-accent focus:ring-accent/20" + /> +
+
+
+ ) +} diff --git a/apps/landing/src/components/config/Sistema.test.tsx b/apps/landing/src/components/config/Sistema.test.tsx new file mode 100644 index 0000000..271f0e8 --- /dev/null +++ b/apps/landing/src/components/config/Sistema.test.tsx @@ -0,0 +1,101 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { Sistema } from './Sistema' + +type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise + +function crearStorageStub(): Storage { + const mapa = new Map() + return { + getItem: (k: string) => mapa.get(k) ?? null, + setItem: (k: string, v: string) => void mapa.set(k, v), + removeItem: (k: string) => void mapa.delete(k), + clear: () => mapa.clear(), + key: () => null, + get length() { + return mapa.size + }, + } as Storage +} + +const CONFIG_FAKE = { + modo_local_forzado: 'false', + recordatorio_inactividad_dias: '15', + recordatorio_cobranza_hora: '10:00', + sesion_minutos: '720', + max_upload_mb: '20', + mostrar_precios_lista: 'true', + auto_logout: 'false', +} + +const respuestaOk = (data: unknown) => + new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }) + +let fetchMock: ReturnType> + +beforeEach(() => { + fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + vi.stubGlobal('localStorage', crearStorageStub()) +}) + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +describe('Sistema', () => { + it('muestra los campos del formulario con los valores cargados', async () => { + fetchMock.mockImplementation((input) => { + const url = String(input) + if (url.includes('/config/sistema')) return Promise.resolve(respuestaOk(CONFIG_FAKE)) + return Promise.resolve(respuestaOk({})) + }) + + render() + + await waitFor(() => { + expect(screen.getByDisplayValue('15')).toBeTruthy() + expect(screen.getByDisplayValue('10:00')).toBeTruthy() + expect(screen.getByDisplayValue('20')).toBeTruthy() + }, { timeout: 5000 }) + + // Select de TTL — verificar que tiene la opción correcta seleccionada + const select = screen.getByLabelText('Sesión TTL (minutos)') as HTMLSelectElement + expect(select.value).toBe('720') + + expect(screen.getByRole('button', { name: 'Guardar' })).toBeTruthy() + }) + + it('al guardar, hace PATCH con los campos del formulario', async () => { + fetchMock.mockImplementation((input, init) => { + const url = String(input) + if (url.includes('/config/sistema') && (!init?.method || init.method === 'GET')) { + return Promise.resolve(respuestaOk(CONFIG_FAKE)) + } + if (url.includes('/config/sistema') && init?.method === 'PATCH') { + return Promise.resolve(respuestaOk({})) + } + return Promise.resolve(respuestaOk({})) + }) + + render() + + await waitFor(() => { + expect(screen.getByDisplayValue('15')).toBeTruthy() + }, { timeout: 5000 }) + + fireEvent.click(screen.getByRole('button', { name: 'Guardar' })) + + await waitFor(() => { + const llamadaPatch = fetchMock.mock.calls.find( + ([url, init]) => String(url).includes('/config/sistema') && init?.method === 'PATCH', + ) + expect(llamadaPatch).toBeTruthy() + const body = JSON.parse(String(llamadaPatch?.[1]?.body)) + expect(body.recordatorio_inactividad_dias).toBe('15') + expect(body.sesion_minutos).toBe('720') + }, { timeout: 5000 }) + }) +}) diff --git a/apps/landing/src/components/config/Sistema.tsx b/apps/landing/src/components/config/Sistema.tsx new file mode 100644 index 0000000..b5ebdf0 --- /dev/null +++ b/apps/landing/src/components/config/Sistema.tsx @@ -0,0 +1,224 @@ +import { useEffect, useState } from 'react' +import { getConfigSistema, patchConfigSistema } from '../../services/config' + +interface Formulario { + recordatorio_inactividad_dias: string + recordatorio_cobranza_hora: string + sesion_minutos: string + max_upload_mb: string + mostrar_precios_lista: string + auto_logout: string +} + +const TTL_OPTIONS = [ + { value: '240', label: '4 horas' }, + { value: '480', label: '8 horas' }, + { value: '720', label: '12 horas' }, + { value: '1440', label: '24 horas' }, +] + +const labelEstilo = 'block text-xs font-medium text-[#4B5563]' +const inputEstilo = + 'mt-2 w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm text-[#111827] transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20' + +export function Sistema() { + const [form, setForm] = useState({ + recordatorio_inactividad_dias: '10', + recordatorio_cobranza_hora: '09:30', + sesion_minutos: '480', + max_upload_mb: '10', + mostrar_precios_lista: 'true', + auto_logout: 'true', + }) + const [cargando, setCargando] = useState(true) + const [guardando, setGuardando] = useState(false) + const [aviso, setAviso] = useState('') + const [error, setError] = useState('') + + useEffect(() => { + let activo = true + getConfigSistema() + .then((config) => { + if (!activo) return + setForm((prev) => ({ + ...prev, + recordatorio_inactividad_dias: config.recordatorio_inactividad_dias ?? prev.recordatorio_inactividad_dias, + recordatorio_cobranza_hora: config.recordatorio_cobranza_hora ?? prev.recordatorio_cobranza_hora, + sesion_minutos: config.sesion_minutos ?? prev.sesion_minutos, + max_upload_mb: config.max_upload_mb ?? prev.max_upload_mb, + mostrar_precios_lista: config.mostrar_precios_lista ?? prev.mostrar_precios_lista, + auto_logout: config.auto_logout ?? prev.auto_logout, + })) + }) + .catch(() => { + if (activo) setError('No se pudieron cargar las configuraciones.') + }) + .finally(() => { + if (activo) setCargando(false) + }) + return () => { activo = false } + }, []) + + const guardar = async () => { + setGuardando(true) + setAviso('') + setError('') + try { + await patchConfigSistema(form as unknown as Record) + setAviso('Configuración guardada correctamente.') + } catch { + setError('No se pudieron guardar las configuraciones.') + } finally { + setGuardando(false) + } + } + + if (cargando) { + return ( +
+

Cargando configuraciones…

+
+ ) + } + + return ( +
+

Configuración del sistema

+ +
+ {/* Recordatorio inactividad */} +
+ + setForm((f) => ({ ...f, recordatorio_inactividad_dias: e.target.value }))} + className={inputEstilo} + /> +
+ + {/* Hora recordatorio cobranza */} +
+ + setForm((f) => ({ ...f, recordatorio_cobranza_hora: e.target.value }))} + className={inputEstilo} + /> +
+ + {/* Sesión TTL */} +
+ + +
+ + {/* Límite subida */} +
+ + setForm((f) => ({ ...f, max_upload_mb: e.target.value }))} + className={inputEstilo} + /> +
+ + {/* Mostrar precios */} +
+ +
+

Mostrar precios en PDF

+

Controla si se muestra el descuento en el PDF del cotizador.

+
+
+ + {/* Logout automático */} +
+ +
+

Logout automático

+

Cerrar sesión automáticamente al recibir 401.

+
+
+
+ + {/* Botón guardar */} +
+ +
+ + {/* Feedback */} + {aviso && ( +

{aviso}

+ )} + {error && ( +

{error}

+ )} +
+ ) +} diff --git a/apps/landing/src/components/config/Usuarios.test.tsx b/apps/landing/src/components/config/Usuarios.test.tsx new file mode 100644 index 0000000..d359183 --- /dev/null +++ b/apps/landing/src/components/config/Usuarios.test.tsx @@ -0,0 +1,100 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { Usuarios } from './Usuarios' + +type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise + +function crearStorageStub(): Storage { + const mapa = new Map() + return { + getItem: (k: string) => mapa.get(k) ?? null, + setItem: (k: string, v: string) => void mapa.set(k, v), + removeItem: (k: string) => void mapa.delete(k), + clear: () => mapa.clear(), + key: () => null, + get length() { + return mapa.size + }, + } as Storage +} + +const USUARIOS_FAKE = [ + { id: '1', username: 'admin01', nombre: 'Admin User', rol: 'admin', asignacion: null, activo: true, mondayUserId: null }, + { id: '2', username: 'vend01', nombre: 'Vendedor Uno', rol: 'vendedor', asignacion: 'MONTEBELLO', activo: true, mondayUserId: 123 }, +] + +const respuestaOk = (data: unknown) => + new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }) + +let fetchMock: ReturnType> + +beforeEach(() => { + fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + vi.stubGlobal('localStorage', crearStorageStub()) +}) + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +describe('Usuarios', () => { + it('muestra la tabla de usuarios cargados', async () => { + fetchMock.mockImplementation((input) => { + const url = String(input) + if (url.endsWith('/config/usuarios')) return Promise.resolve(respuestaOk(USUARIOS_FAKE)) + return Promise.resolve(respuestaOk({})) + }) + + render() + expect(await screen.findByText('Admin User')).toBeTruthy() + expect(screen.getByText('Vendedor Uno')).toBeTruthy() + expect(screen.getByLabelText('Buscar usuario')).toBeTruthy() + }) + + it('la búsqueda filtra usuarios por nombre', async () => { + fetchMock.mockImplementation((input) => { + const url = String(input) + if (url.endsWith('/config/usuarios')) return Promise.resolve(respuestaOk(USUARIOS_FAKE)) + return Promise.resolve(respuestaOk({})) + }) + + render() + await screen.findByText('Admin User') + + fireEvent.change(screen.getByLabelText('Buscar usuario'), { target: { value: 'Admin' } }) + + expect(screen.getByText('Admin User')).toBeTruthy() + expect(screen.queryByText('Vendedor Uno')).toBeNull() + }) + + it('al cambiar el select de rol, hace PATCH inmediato', async () => { + fetchMock.mockImplementation((input, init) => { + const url = String(input) + if (url.endsWith('/config/usuarios') && (!init?.method || init.method === 'GET')) { + return Promise.resolve(respuestaOk(USUARIOS_FAKE)) + } + if (url.endsWith('/config/usuarios/2') && init?.method === 'PATCH') { + return Promise.resolve(respuestaOk({})) + } + return Promise.resolve(respuestaOk({})) + }) + + render() + await screen.findByText('Vendedor Uno') + + // Encontrar el select de rol para el segundo usuario (Vendedor Uno) + const selects = screen.getAllByRole('combobox') + // El primer select es el de Admin (rol), el tercero es el de Vendedor (rol) + fireEvent.change(selects[2], { target: { value: 'director_ventas' } }) + + await waitFor(() => { + const llamadaPatch = fetchMock.mock.calls.find( + ([url, init]) => String(url).endsWith('/config/usuarios/2') && init?.method === 'PATCH', + ) + expect(llamadaPatch).toBeTruthy() + }) + }) +}) diff --git a/apps/landing/src/components/config/Usuarios.tsx b/apps/landing/src/components/config/Usuarios.tsx new file mode 100644 index 0000000..469247b --- /dev/null +++ b/apps/landing/src/components/config/Usuarios.tsx @@ -0,0 +1,233 @@ +import { useCallback, useEffect, useState } from 'react' +import { + getUsuariosConfig, + patchUsuarioConfig, + type UsuarioConfig, +} from '../../services/config' + +const ROLES = [ + 'vendedor', + 'administracion_venta', + 'director_ventas', + 'escrituracion', + 'direccion_general', + 'admin', +] as const + +const ASIGNACIONES = [ + 'ATELIER', + 'RESIDENCIAL', + 'MONTEBELLO', + 'BROKER', + 'ACORPORATIVO', + '', +] as const + +const ROL_LABELS: Record = { + vendedor: 'Vendedor', + administracion_venta: 'Administración venta', + director_ventas: 'Director ventas', + escrituracion: 'Escrituración', + direccion_general: 'Dirección general', + admin: 'Admin', +} + +const ASIGNACION_LABELS: Record = { + ATELIER: 'Atelier', + RESIDENCIAL: 'Residencial', + MONTEBELLO: 'Montebello', + BROKER: 'Broker', + ACORPORATIVO: 'A Corporativo', + '': 'Sin asignación', +} + +export function Usuarios() { + const [usuarios, setUsuarios] = useState([]) + const [cargando, setCargando] = useState(true) + const [error, setError] = useState('') + const [busqueda, setBusqueda] = useState('') + // feedback por usuario: { [usuarioId]: 'ok' | 'error' | null } + const [feedback, setFeedback] = useState>({}) + + const cargar = useCallback(async () => { + setCargando(true) + setError('') + try { + const data = await getUsuariosConfig() + setUsuarios(data) + } catch { + setError('No se pudieron cargar los usuarios.') + } finally { + setCargando(false) + } + }, []) + + useEffect(() => { void cargar() }, [cargar]) + + const mostrarFeedback = (id: string, tipo: 'ok' | 'error') => { + setFeedback((prev) => ({ ...prev, [id]: tipo })) + setTimeout(() => setFeedback((prev) => { + const copy = { ...prev } + delete copy[id] + return copy + }), 2000) + } + + const cambiarRol = async (id: string, nuevoRol: string) => { + try { + await patchUsuarioConfig(id, { rol: nuevoRol }) + setUsuarios((prev) => + prev.map((u) => (u.id === id ? { ...u, rol: nuevoRol } : u)), + ) + mostrarFeedback(id, 'ok') + } catch { + mostrarFeedback(id, 'error') + } + } + + const cambiarAsignacion = async (id: string, nuevaAsignacion: string) => { + const valor = nuevaAsignacion || null + try { + await patchUsuarioConfig(id, { asignacion: valor }) + setUsuarios((prev) => + prev.map((u) => (u.id === id ? { ...u, asignacion: valor } : u)), + ) + mostrarFeedback(id, 'ok') + } catch { + mostrarFeedback(id, 'error') + } + } + + const toggleActivo = async (id: string, activo: boolean) => { + try { + await patchUsuarioConfig(id, { activo }) + setUsuarios((prev) => + prev.map((u) => (u.id === id ? { ...u, activo } : u)), + ) + mostrarFeedback(id, 'ok') + } catch { + mostrarFeedback(id, 'error') + } + } + + const filtrados = usuarios.filter((u) => { + const q = busqueda.toLowerCase() + return ( + u.nombre.toLowerCase().includes(q) || + u.username.toLowerCase().includes(q) + ) + }) + + if (cargando) { + return ( +
+

Cargando usuarios…

+
+ ) + } + + return ( +
+ {error && ( +

{error}

+ )} + + {/* Búsqueda */} +
+ + setBusqueda(e.target.value)} + placeholder="Nombre o usuario…" + className="mt-2 w-full max-w-sm rounded-lg border border-[#D1D5DB] bg-white px-3 py-2 text-sm text-[#111827] transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20" + /> +
+ + {/* Tabla */} +
+ + + + + + + + + + + + {filtrados.length === 0 ? ( + + + + ) : ( + filtrados.map((u) => ( + + + + + + + + )) + )} + +
NombreUsuarioRolAsignaciónActivo
+ No se encontraron usuarios. +
{u.nombre}{u.username} + + + + + +
+
+ + {/* Feedback inline */} + {Object.entries(feedback).map(([id, tipo]) => ( +

+ {tipo === 'ok' ? '✓ Guardado' : '✗ Error al guardar'} +

+ ))} +
+ ) +} diff --git a/apps/landing/src/services/auth.ts b/apps/landing/src/services/auth.ts index 4db7c09..7150440 100644 --- a/apps/landing/src/services/auth.ts +++ b/apps/landing/src/services/auth.ts @@ -10,6 +10,8 @@ export interface UsuarioSesion { // Módulos del ecosistema visibles para el rol (matriz del API; presente en // /auth/me y /auth/login desde sep-2026) modulos?: string[] + // Acciones permitidas por el rol (solo admin ve las de todos los roles) + acciones?: string[] } export interface Sesion { diff --git a/apps/landing/src/services/config.ts b/apps/landing/src/services/config.ts new file mode 100644 index 0000000..48d90e6 --- /dev/null +++ b/apps/landing/src/services/config.ts @@ -0,0 +1,119 @@ +import { API_URL, conAuth, manejar401 } from './auth' + +// ─── Config Sistema ────────────────────────────────────────────── + +export interface ConfigSistema { + [clave: string]: string +} + +/** GET /config/sistema — todas las configuraciones del sistema */ +export async function getConfigSistema(): Promise { + const resp = await fetch(`${API_URL}/config/sistema`, conAuth()) + manejar401(resp) + if (!resp.ok) throw new Error(`HTTP ${resp.status}`) + return (await resp.json()) as ConfigSistema +} + +/** PATCH /config/sistema — actualiza configs (parcial) */ +export async function patchConfigSistema(data: Record): Promise { + const resp = await fetch( + `${API_URL}/config/sistema`, + conAuth({ + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }), + ) + manejar401(resp) + if (!resp.ok) throw new Error(`HTTP ${resp.status}`) +} + +// ─── Roles ─────────────────────────────────────────────────────── + +export interface RolConfig { + rol: string + modulos: string // JSON array string + acciones: string // JSON object string +} + +/** GET /config/roles — todas las configuraciones de rol */ +export async function getRolesConfig(): Promise { + const resp = await fetch(`${API_URL}/config/roles`, conAuth()) + manejar401(resp) + if (!resp.ok) throw new Error(`HTTP ${resp.status}`) + return (await resp.json()) as RolConfig[] +} + +/** PATCH /config/roles/:rol — actualiza módulos y acciones de un rol */ +export async function patchRolConfig( + rol: string, + data: { modulos: string[]; acciones: Record }, +): Promise { + const resp = await fetch( + `${API_URL}/config/roles/${encodeURIComponent(rol)}`, + conAuth({ + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }), + ) + manejar401(resp) + if (!resp.ok) throw new Error(`HTTP ${resp.status}`) +} + +/** POST /config/reset-roles — restaura valores por defecto */ +export async function resetRoles(): Promise { + const resp = await fetch( + `${API_URL}/config/reset-roles`, + conAuth({ method: 'POST' }), + ) + manejar401(resp) + if (!resp.ok) throw new Error(`HTTP ${resp.status}`) +} + +// ─── Usuarios ──────────────────────────────────────────────────── + +export interface UsuarioConfig { + id: string + username: string + nombre: string + rol: string + asignacion: string | null + activo: boolean + mondayUserId: number | null +} + +/** GET /config/usuarios — lista de usuarios con rol/asignación/activo */ +export async function getUsuariosConfig(): Promise { + const resp = await fetch(`${API_URL}/config/usuarios`, conAuth()) + manejar401(resp) + if (!resp.ok) throw new Error(`HTTP ${resp.status}`) + return (await resp.json()) as UsuarioConfig[] +} + +/** PATCH /config/usuarios/:id — cambia rol/asignación/activo */ +export async function patchUsuarioConfig( + id: string, + data: { rol?: string; asignacion?: string | null; activo?: boolean }, +): Promise { + const resp = await fetch( + `${API_URL}/config/usuarios/${encodeURIComponent(id)}`, + conAuth({ + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }), + ) + manejar401(resp) + if (!resp.ok) throw new Error(`HTTP ${resp.status}`) +} + +// ─── Acciones ──────────────────────────────────────────────────── + +/** GET /config/acciones — acciones disponibles para el rol del usuario actual */ +export async function getAcciones(): Promise> { + const resp = await fetch(`${API_URL}/config/acciones`, conAuth()) + manejar401(resp) + if (!resp.ok) throw new Error(`HTTP ${resp.status}`) + return (await resp.json()) as Record +} diff --git a/apps/mi-dia/src/pages/MiDiaPage.tsx b/apps/mi-dia/src/pages/MiDiaPage.tsx index 7f61676..1dd9934 100644 --- a/apps/mi-dia/src/pages/MiDiaPage.tsx +++ b/apps/mi-dia/src/pages/MiDiaPage.tsx @@ -173,6 +173,7 @@ function FilaOportunidad({ onAfluencia, onDescartar, enviando, + acciones, }: { op: OportunidadMiDia onEtapa: (etapa: string, fecha?: string) => void @@ -180,6 +181,7 @@ function FilaOportunidad({ onAfluencia: () => void onDescartar: () => void enviando: boolean + acciones?: string[] }) { const [conCita, setConCita] = useState(false) const [fechaCita, setFechaCita] = useState('') @@ -264,7 +266,7 @@ function FilaOportunidad({ )}