src/Controller/IntegrationController.php line 539

Open in your IDE?
  1. <?php
  2. namespace App\Controller;
  3. use App\Entity\Module;
  4. use App\Entity\Usuario;
  5. use App\Entity\ViewProfile;
  6. use App\Entity\Periodo;
  7. use Doctrine\ORM\EntityManagerInterface;
  8. use Doctrine\Persistence\ManagerRegistry;
  9. use Psr\Log\LoggerInterface;
  10. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  11. use Symfony\Component\HttpFoundation\JsonResponse;
  12. use Symfony\Component\HttpFoundation\Request;
  13. use Symfony\Component\HttpFoundation\Response;
  14. use Symfony\Component\Routing\Annotation\Route;
  15. use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
  16. use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
  17. use Symfony\Component\HttpFoundation\Session\SessionInterface;
  18. use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
  19. use Firebase\JWT\JWT;
  20. use Firebase\JWT\Key;
  21. use Firebase\JWT\ExpiredException;
  22. use Symfony\Component\Routing\Exception\RouteNotFoundException;
  23. #[Route('/api/integration')]
  24. class IntegrationController extends AbstractController
  25. {
  26.     private EntityManagerInterface $em;
  27.     private ManagerRegistry $doctrine;
  28.     private string $secretKey;
  29.     private TokenStorageInterface $tokenStorage;
  30.     private SessionInterface $session;
  31.     private string $systemName;
  32.     private LoggerInterface $logger;
  33.     // Constante para mapeo de iconos
  34.     private const ICON_MAP = [
  35.         'fa-chart-line' => 'DotChartOutlined',
  36.         'fa-users-cog' => 'TeamOutlined',
  37.         'fa-book' => 'BookOutlined',
  38.         'fa-calendar-alt' => 'CalendarOutlined',
  39.         'fa-users' => 'UsergroupAddOutlined',
  40.         'fa-home' => 'HomeOutlined',
  41.         'fa-cogs' => 'SettingOutlined',
  42.         'fa-angle-double-up' => 'ArrowUpOutlined',
  43.         'fa-user' => 'UserOutlined',
  44.         'fa-file' => 'FileOutlined',
  45.         'fa-search' => 'SearchOutlined',
  46.         'fa-bell' => 'BellOutlined',
  47.         'fa-dashboard' => 'DashboardOutlined',
  48.         'fa-appstore' => 'AppstoreOutlined',
  49.         'fa-area-chart' => 'AreaChartOutlined',
  50.         'fa-bar-chart' => 'BarChartOutlined'
  51.     ];
  52.     public function __construct(
  53.         EntityManagerInterface $em,
  54.         ManagerRegistry $doctrine,
  55.         TokenStorageInterface $tokenStorage,
  56.         SessionInterface $session,
  57.         LoggerInterface $logger
  58.     ) {
  59.         $this->em = $em;
  60.         $this->doctrine = $doctrine;
  61.         $this->tokenStorage = $tokenStorage;
  62.         $this->session = $session;
  63.         $this->logger = $logger;
  64.         $this->secretKey = $_ENV['SERVICIOS_ADMINISTRATIVOS_SECRET_KEY'] ?? '';
  65.         $this->systemName = $_ENV['SYSTEM_NAME'] ?? 'UNKNOWN_SYSTEM';
  66.         if (empty($this->secretKey)) {
  67.             throw new \RuntimeException('SERVICIOS_ADMINISTRATIVOS_SECRET_KEY no configurada');
  68.         }
  69.     }
  70.     /**
  71.      * Endpoint principal para SSO Login
  72.      * Valida token JWT y establece sesión siguiendo la misma lógica del login normal
  73.      */
  74.     #[Route('/sso-login', name: 'integration_sso_login', methods: ['GET'])]
  75.     public function ssoLogin(Request $request): Response
  76.     {
  77.         $token = $request->query->get('token');
  78.         $redirectPath = $request->query->get('redirect');
  79.         if (!$token) {
  80.             $this->logger->warning('SSO Login: Token no proporcionado');
  81.             return $this->jsonError('Token SSO es requerido', '', 400);
  82.         }
  83.         try {
  84.             // 1. Decodificar y validar token JWT
  85.             $decoded = $this->decodeAndValidateJWT($token);
  86.             if (!isset($decoded->email)) {
  87.                 throw new \Exception('Token inválido: falta email');
  88.             }
  89.             // 2. Buscar usuario
  90.             $user = $this->findUserByEmail($decoded->email);
  91.             if (!$user) {
  92.                 $this->logger->info('SSO Login: Usuario no encontrado', [
  93.                     'email' => $decoded->email
  94.                 ]);
  95.                 return $this->render('restringido.html.twig', [], new Response('', 403));
  96.             }
  97.             // 3. VALIDACIONES DEL USUARIO (misma lógica que login normal)
  98.             $this->validateUserForLogin($user);
  99.             // 4. VALIDAR SI YA EXISTE SESIÓN ACTIVA VÁLIDA
  100.             if ($this->hasValidSession($user)) {
  101.                 $this->logger->info('SSO Login: Sesión existente válida, redirigiendo', [
  102.                     'user_id' => $user->getIdUsu(),
  103.                     'email' => $user->getCorreo()
  104.                 ]);
  105.                 
  106.                 $targetPath = $this->determineRedirectPath($redirectPath);
  107.                 return $this->redirect($targetPath . '?embedded=1&sso=1');
  108.             }
  109.             // 5. No hay sesión válida, crear una nueva
  110.             $this->logger->info('SSO Login: Creando nueva sesión', [
  111.                 'user_id' => $user->getIdUsu(),
  112.                 'email' => $user->getCorreo()
  113.             ]);
  114.             // ***  Invalidar cualquier sesión previa ***
  115.             $this->invalidatePreviousSession();
  116.             $this->authenticateUserInSession($user, $request);
  117.             
  118.             $targetPath = $this->determineRedirectPath($redirectPath);
  119.             return $this->redirect($targetPath . '?embedded=1&sso=1');
  120.         } catch (ExpiredException $e) {
  121.             $this->logger->warning('SSO Login: Token expirado', [
  122.                 'error' => $e->getMessage()
  123.             ]);
  124.             return $this->jsonError(
  125.                 'Token SSO expirado',
  126.                 'Por favor, vuelve a intentar desde el sistema principal',
  127.                 401
  128.             );
  129.         } catch (CustomUserMessageAuthenticationException $e) {
  130.             // Excepción de validación de usuario
  131.             $this->logger->warning('SSO Login: Validación de usuario falló', [
  132.                 'error' => $e->getMessage()
  133.             ]);
  134.             return $this->render('restringido.html.twig', [
  135.                 'mensaje' => $e->getMessage()
  136.             ], new Response('', 403));
  137.         } catch (\Exception $e) {
  138.             $this->logger->error('SSO Login: Error en autenticación', [
  139.                 'error' => $e->getMessage(),
  140.                 'trace' => $e->getTraceAsString()
  141.             ]);
  142.             
  143.             return $this->jsonError('Error en autenticación SSO', $e->getMessage(), 401);
  144.         }
  145.     }
  146.     /**
  147.      * Verifica si el usuario tiene una sesión activa y válida
  148.      */
  149.     private function hasValidSession(Usuario $user): bool
  150.     {
  151.         $currentToken = $this->tokenStorage->getToken();
  152.         
  153.         if (!$currentToken) {
  154.             return false;
  155.         }
  156.         $sessionUser = $currentToken->getUser();
  157.         
  158.         // Verificar que sea un Usuario válido y que coincida con el email
  159.         if (!$sessionUser instanceof Usuario) {
  160.             return false;
  161.         }
  162.         if ($sessionUser->getCorreo() !== $user->getCorreo()) {
  163.             return false;
  164.         }
  165.         // Verificar que los datos de sesión críticos existan
  166.         if (!$this->session->has('perfil') || !$this->session->has('menu')) {
  167.             $this->logger->warning('Sesión sin datos críticos, regenerando', [
  168.                 'user_id' => $user->getIdUsu()
  169.             ]);
  170.             return false;
  171.         }
  172.         return true;
  173.     }
  174.     /**
  175.      * Decodifica y valida el token JWT
  176.      */
  177.     private function decodeAndValidateJWT(string $token): object
  178.     {
  179.         try {
  180.             $decoded = JWT::decode($token, new Key($this->secretKey, 'HS256'));
  181.             if (!isset($decoded->email) || empty($decoded->email)) {
  182.                 throw new \Exception('Token inválido: falta email');
  183.             }
  184.             if (isset($decoded->exp) && $decoded->exp < time()) {
  185.                 throw new ExpiredException('Token expirado');
  186.             }
  187.             if (isset($decoded->iss)) {
  188.                 $expectedIssuer = $_ENV['JWT_ISSUER'] ?? 'integration-service';
  189.                 if ($decoded->iss !== $expectedIssuer) {
  190.                     $this->logger->warning('Emisor del token inválido', [
  191.                         'expected' => $expectedIssuer,
  192.                         'received' => $decoded->iss
  193.                     ]);
  194.                     throw new \Exception('Emisor del token inválido');
  195.                 }
  196.             }
  197.             if (isset($decoded->system)) {
  198.                 if ($decoded->system !== $this->systemName) {
  199.                     $this->logger->warning('Token no destinado a este sistema', [
  200.                         'expected_system' => $this->systemName,
  201.                         'token_system' => $decoded->system
  202.                     ]);
  203.                     throw new \Exception('Token no destinado a este sistema');
  204.                 }
  205.             }
  206.             $this->logger->debug('Token JWT validado exitosamente', [
  207.                 'email' => $decoded->email,
  208.                 'system' => $decoded->system ?? 'N/A'
  209.             ]);
  210.             return $decoded;
  211.         } catch (ExpiredException $e) {
  212.             throw $e;
  213.         } catch (\Exception $e) {
  214.             $this->logger->error('Error decodificando JWT', [
  215.                 'error' => $e->getMessage()
  216.             ]);
  217.             throw new \Exception('Token inválido: ' . $e->getMessage());
  218.         }
  219.     }
  220.     /**
  221.      * Busca un usuario por email
  222.      */
  223.     private function findUserByEmail(string $email): ?Usuario
  224.     {
  225.         return $this->em->getRepository(Usuario::class)
  226.             ->findOneBy(['correo' => $email]);
  227.     }
  228.     /**
  229.      * Busca un usuario por email o ID
  230.      */
  231.     private function findUserByEmailOrId(?string $email, ?int $userId): ?Usuario
  232.     {
  233.         if ($userId) {
  234.             return $this->em->getRepository(Usuario::class)->find($userId);
  235.         }
  236.         
  237.         if ($email) {
  238.             return $this->findUserByEmail($email);
  239.         }
  240.         return null;
  241.     }
  242.     /**
  243.      * Valida que el usuario pueda iniciar sesión
  244.      * *** CORREGIDO: Misma lógica exacta que LoginAuthenticator ***
  245.      * 
  246.      * @throws CustomUserMessageAuthenticationException si el usuario no puede iniciar sesión
  247.      */
  248.     private function validateUserForLogin(Usuario $user): void
  249.     {
  250.         // Validación 1: Usuario Pendiente
  251.         if ($user->getEstatus() === 'Pendiente') {
  252.             $this->logger->warning('Intento de login SSO con usuario pendiente', [
  253.                 'user_id' => $user->getIdUsu(),
  254.                 'email' => $user->getCorreo()
  255.             ]);
  256.             throw new CustomUserMessageAuthenticationException('Access request pending evaluation.');
  257.         }
  258.         // Validación 2: Usuario Rechazado
  259.         if ($user->getEstatus() === 'Rechazado') {
  260.             $this->logger->warning('Intento de login SSO con usuario rechazado', [
  261.                 'user_id' => $user->getIdUsu(),
  262.                 'email' => $user->getCorreo()
  263.             ]);
  264.             throw new CustomUserMessageAuthenticationException('Access request has been rejected.');
  265.         }
  266.         // Validación 3: Usuario Suspendido
  267.         if ($user->getEstatus() === 'Suspendido') {
  268.             $this->logger->warning('Intento de login SSO con usuario suspendido', [
  269.                 'user_id' => $user->getIdUsu(),
  270.                 'email' => $user->getCorreo()
  271.             ]);
  272.             throw new CustomUserMessageAuthenticationException('Access credentials suspended.');
  273.         }
  274.         $this->logger->debug('Usuario validado correctamente para SSO login', [
  275.             'user_id' => $user->getIdUsu(),
  276.             'email' => $user->getCorreo(),
  277.             'estatus' => $user->getEstatus()
  278.         ]);
  279.     }
  280.     /**
  281.      * Establece la autenticación del usuario en la sesión
  282.      */
  283.     private function authenticateUserInSession(Usuario $user, Request $request): void
  284.     {
  285.         try {
  286.             // *** AGREGADO: Actualizar estatus de "Aceptado" a "Activo" en primer login ***
  287.             $estatusActual = $user->getEstatus();
  288.             if ($estatusActual === "Aceptado") {
  289.                 $user->setEstatus("Activo");
  290.                 $this->em->persist($user);
  291.                 $this->em->flush();
  292.                 
  293.                 $this->logger->info('Estatus actualizado de Aceptado a Activo', [
  294.                     'user_id' => $user->getIdUsu()
  295.                 ]);
  296.             }
  297.             // 1. Crear y establecer token de autenticación
  298.             $token = new UsernamePasswordToken($user, 'main', $user->getRoles());
  299.             $this->tokenStorage->setToken($token);
  300.             // 2. Obtener periodo actual
  301.             $periodo = $this->em->getRepository(Periodo::class)->findOneBy(['actual' => 1]);
  302.             $this->session->set('periodo', $periodo);
  303.         
  304.             // 3. Construir perfil y menú (MISMA LÓGICA QUE LoginAuthenticator)
  305.             $rol = $user->getRol()->getIdRol();
  306.             $acciones = $this->em->getRepository(ViewProfile::class)->findBy(['idRol' => $rol]);
  307.             
  308.             $perfil = [];
  309.             $menu = [];
  310.             
  311.             foreach ($acciones as $accion) {
  312.                 // *** CORREGIDO: Construcción de perfil con índice por idMod ***
  313.                 $perfil[$accion->getIdMod()] = [
  314.                     "idMod" => $accion->getIdMod(),
  315.                     "nombre" => $accion->getModulo(),
  316.                     "submenu" => $accion->getSubmenu(),
  317.                     "idBeh" => $accion->getIdBeh(),
  318.                     "nivel" => $accion->getNivel(),
  319.                     "comportamiento" => $accion->getComportamiento(),
  320.                     "descripcion" => $accion->getDescripcion()
  321.                 ];
  322.                 
  323.                 // *** Construcción del menú lateral ***
  324.                 if ($accion->getNivel() > 0) {
  325.                     if (empty($accion->getSubmenu())) {
  326.                         $menu[$accion->getOrden()] = [
  327.                             "idMenu" => $accion->getIdMenu(),
  328.                             "menu" => $accion->getModulo(),
  329.                             "icono" => $accion->getIcono(),
  330.                             "ruta" => $accion->getRuta(),
  331.                             "submenu" => false
  332.                         ];
  333.                     } else {
  334.                         if (!array_key_exists($accion->getOrden(), $menu)) {
  335.                             $menu[$accion->getOrden()] = [
  336.                                 "idMenu" => $accion->getIdMenu(),
  337.                                 "menu" => $accion->getModulo(),
  338.                                 "icono" => $accion->getIcono(),
  339.                                 "ruta" => "#"
  340.                             ];
  341.                         }
  342.                         $menu[$accion->getOrden()]["submenu"][] = [
  343.                             "idMenu" => $accion->getIdSubmenu(),
  344.                             "menu" => $accion->getSubmenu(),
  345.                             "icono" => "fa-angle-double-up",
  346.                             "ruta" => $accion->getRuta()
  347.                         ];
  348.                     }
  349.                 }
  350.             }
  351.             
  352.             ksort($menu);
  353.             
  354.             // 4. Establecer datos en sesión
  355.             $this->session->set('perfil', $perfil);
  356.             $this->session->set('menu', $menu);
  357.             $this->logger->info('Sesión establecida exitosamente con estructura original', [
  358.                 'user_id' => $user->getIdUsu(),
  359.                 'email' => $user->getCorreo(),
  360.                 'perfil_items' => count($perfil),
  361.                 'menu_items' => count($menu)
  362.             ]);
  363.         }
  364.         catch (\Exception $e) {
  365.             $this->logger->error('Error validando token de integración', [
  366.                 'error' => $e->getMessage()
  367.             ]); 
  368.         } 
  369.     }
  370.     /**
  371.      * Determina la ruta de redirección validando seguridad
  372.      */
  373.     private function determineRedirectPath(?string $redirectPath): string
  374.     {
  375.         if (empty($redirectPath)) {
  376.             return '/';
  377.         }
  378.         $redirectPath = trim($redirectPath);
  379.         // Prevenir redirecciones externas
  380.         if (preg_match('#^(https?:)?//#i', $redirectPath)) {
  381.             $this->logger->warning('Intento de redirección externa bloqueado', [
  382.                 'redirect' => $redirectPath
  383.             ]);
  384.             return '/';
  385.         }
  386.         $redirectPath = ltrim($redirectPath, '/');
  387.         
  388.         try {
  389.             return $this->generateUrl($redirectPath);
  390.         } catch (RouteNotFoundException $e) {
  391.             $this->logger->warning('Ruta Symfony no encontrada', [
  392.                 'redirect' => $redirectPath
  393.             ]);
  394.             
  395.             return '/';
  396.         }
  397.     }
  398.     /**
  399.      * Valida el token de integración en requests API
  400.      */
  401.     private function validateIntegrationToken(Request $request): ?object
  402.     {
  403.         $authHeader = $request->headers->get('Authorization');
  404.         
  405.         if (!$authHeader || !preg_match('/Bearer\s+(.*)$/i', $authHeader, $matches)) {
  406.             $this->logger->warning('Token de integración ausente o malformado');
  407.             return null;
  408.         }
  409.         try {
  410.             return $this->decodeAndValidateJWT($matches[1]);
  411.         } catch (\Exception $e) {
  412.             $this->logger->error('Error validando token de integración', [
  413.                 'error' => $e->getMessage()
  414.             ]);
  415.             return null;
  416.         }
  417.     }
  418.     /**
  419.      * Invalida completamente la sesión anterior
  420.     */
  421.     private function invalidatePreviousSession(): void
  422.     {
  423.         $currentToken = $this->tokenStorage->getToken();
  424.         
  425.         if ($currentToken && $currentToken->getUser() instanceof Usuario) {
  426.             $this->tokenStorage->setToken(null);
  427.             $this->session->invalidate();
  428.             $this->logger->debug('Sesión de otro usuario invalidada completamente');
  429.         } else {
  430.             $this->session->migrate(true);
  431.             $this->logger->debug('ID de sesión regenerado (sin sesión previa)');
  432.         }
  433.     }
  434.     // ==================== ENDPOINTS API ====================
  435.     #[Route('/test', name: 'integration_test', methods: ['GET'])]
  436.     public function test(): JsonResponse
  437.     {
  438.         return new JsonResponse([
  439.             'status' => 'OK',
  440.             'message' => 'Integration endpoint working correctly',
  441.             'system_name' => $this->systemName,
  442.             'timestamp' => date('Y-m-d H:i:s')
  443.         ]);
  444.     }
  445.     #[Route('/check-user', name: 'integration_check_user', methods: ['POST'])]
  446.     public function checkUser(Request $request): JsonResponse
  447.     {
  448.         if (!$this->validateIntegrationToken($request)) {
  449.             return $this->jsonError('Invalid or missing integration token', '', 401);
  450.         }
  451.         $data = json_decode($request->getContent(), true);
  452.         $email = $data['email'] ?? null;
  453.         if (!$email) {
  454.             return $this->jsonError('Email is required', '', 400);
  455.         }
  456.         try {
  457.             $user = $this->findUserByEmail($email);
  458.             if (!$user) {
  459.                 return new JsonResponse([
  460.                     'has_access' => false,
  461.                     'system_name' => $this->systemName,
  462.                     'message' => 'User not found in this system'
  463.                 ]);
  464.             }
  465.             return new JsonResponse([
  466.                 'has_access' => true,
  467.                 'user_id' => $user->getIdUsu(),
  468.                 'email' => $user->getCorreo(),
  469.                 'name' => trim($user->getNombre() . ' ' . $user->getPapellido()),
  470.                 'roles' => [$user->getRol()->getNombre()],
  471.                 'system_name' => $this->systemName
  472.             ]);
  473.         } catch (\Exception $e) {
  474.             $this->logger->error('Error en check-user', [
  475.                 'email' => $email,
  476.                 'error' => $e->getMessage()
  477.             ]);
  478.             return $this->jsonError('Database error', $e->getMessage(), 500);
  479.         }
  480.     }
  481.     #[Route('/get-menu', name: 'integration_get_menu', methods: ['POST'])]
  482.     public function getMenu(Request $request): JsonResponse
  483.     {
  484.         if (!$this->validateIntegrationToken($request)) {
  485.             return $this->jsonError('Invalid or missing integration token', '', 401);
  486.         }
  487.         $data = json_decode($request->getContent(), true);
  488.         $email = $data['email'] ?? null;
  489.         $userId = $data['user_id'] ?? null;
  490.         if (!$email && !$userId) {
  491.             return $this->jsonError('Email or user_id is required', '', 400);
  492.         }
  493.         try {
  494.             $user = $this->findUserByEmailOrId($email, $userId);
  495.             if (!$user) {
  496.                 return new JsonResponse([
  497.                     'menu' => [],
  498.                     'message' => 'User not found'
  499.                 ]);
  500.             }
  501.             $menu = $this->buildUserMenuFromSession($user);
  502.             $frontendMenu = $this->transformMenuToFrontend($menu);
  503.             return new JsonResponse([
  504.                 'user_name' => trim($user->getNombre() . ' ' . $user->getPapellido()),
  505.                 'menu_tree' => $frontendMenu,
  506.                 'user_id' => $user->getIdUsu(),
  507.                 'system_name' => $this->systemName
  508.             ]);
  509.         } catch (\Exception $e) {
  510.             $this->logger->error('Error en get-menu', [
  511.                 'email' => $email,
  512.                 'user_id' => $userId,
  513.                 'error' => $e->getMessage()
  514.             ]);
  515.             return $this->jsonError('Error building menu', $e->getMessage(), 500);
  516.         }
  517.     }
  518.     /**
  519.      * Construye el menú desde la sesión (para API endpoints)
  520.      */
  521.     private function buildUserMenuFromSession(Usuario $user): array
  522.     {
  523.         $rol = $user->getRol()->getIdRol();
  524.         $acciones = $this->em->getRepository(ViewProfile::class)->findBy(['idRol' => $rol]);
  525.         
  526.         $menu = [];
  527.         
  528.         foreach ($acciones as $accion) {
  529.             if ($accion->getNivel() > 0) {
  530.                 if (empty($accion->getSubmenu())) {
  531.                     $menu[$accion->getOrden()] = [
  532.                         "idMenu" => $accion->getIdMenu(),
  533.                         "menu" => $accion->getModulo(),
  534.                         "icono" => $accion->getIcono(),
  535.                         "ruta" => $accion->getRuta(),
  536.                         "submenu" => false
  537.                     ];
  538.                 } else {
  539.                     if (!array_key_exists($accion->getOrden(), $menu)) {
  540.                         $menu[$accion->getOrden()] = [
  541.                             "idMenu" => $accion->getIdMenu(),
  542.                             "menu" => $accion->getModulo(),
  543.                             "icono" => $accion->getIcono(),
  544.                             "ruta" => "#"
  545.                         ];
  546.                     }
  547.                     $menu[$accion->getOrden()]["submenu"][] = [
  548.                         "idMenu" => $accion->getIdSubmenu(),
  549.                         "menu" => $accion->getSubmenu(),
  550.                         "icono" => "fa-angle-double-up",
  551.                         "ruta" => $accion->getRuta()
  552.                     ];
  553.                 }
  554.             }
  555.         }
  556.         
  557.         ksort($menu);
  558.         return array_values($menu);
  559.     }
  560.     /**
  561.      * Transforma el menú al formato del frontend
  562.      */
  563.     private function transformMenuToFrontend(array $menu, int $startKey = 2): array
  564.     {
  565.         $keyCounter = $startKey;
  566.         
  567.         $transform = function ($item) use (&$transform, &$keyCounter) {
  568.             $children = [];
  569.             if (!empty($item['submenu']) && is_array($item['submenu'])) {
  570.                 foreach ($item['submenu'] as $subItem) {
  571.                     $children[] = $transform($subItem);
  572.                 }
  573.             }
  574.             $path = ($item['ruta'] === '#' || $item['ruta'] === null)
  575.                 ? null
  576.                 : '/' . ltrim($item['ruta'], '/');
  577.             return [
  578.                 'key' => $keyCounter++,
  579.                 'label' => $item['menu'] ?? '',
  580.                 'icon' => $this->mapIcon($item['icono'] ?? ''),
  581.                 'path' => $path,
  582.                 'external' => true,
  583.                 'children' => $children
  584.             ];
  585.         };
  586.         $result = array_map($transform, $menu);
  587.         return [[
  588.             'key' => 1,
  589.             'label' => 'Sigmec',
  590.             'icon' => 'UsergroupAddOutlined',
  591.             'path' => null,
  592.             'external' => true,
  593.             'children' => $result
  594.         ]];
  595.     }
  596.     private function mapIcon(string $faIcon): ?string
  597.     {
  598.         return self::ICON_MAP[$faIcon] ?? 'RightOutlined';
  599.     }
  600.     /**
  601.      * Método helper para respuestas de error consistentes
  602.      */
  603.     private function jsonError(string $error, string $message = '', int $status = 400): JsonResponse
  604.     {
  605.         $response = [
  606.             'error' => $error,
  607.             'system_name' => $this->systemName
  608.         ];
  609.         if (!empty($message)) {
  610.             $response['message'] = $message;
  611.         }
  612.         return new JsonResponse($response, $status);
  613.     }
  614. }