Files
whatsapp/core/ModuleRegistry.php
2026-04-16 23:07:53 -05:00

364 lines
13 KiB
PHP

<?php
/**
* core/ModuleRegistry.php
* Catálogo dinámico de módulos del ERP.
*
* Fuentes de datos (prioridad en orden):
* 1. Base de datos — tabla system_modules (si la migración 004 se ejecutó)
* 2. Archivos modules/{slug}/module.php (escaneo de la carpeta modules/)
* 3. Constante SYSTEM_MODULES de config.php (fallback mínimo)
*
* Los datos de BD prevalecen sobre los descriptores locales para los campos
* is_active y sort_order (el administrador puede cambiarlos via UI).
*
* CACHÉ: estática por request (no Redis necesario).
*/
class ModuleRegistry
{
/** @var array<string, array>|null Cache del registro completo */
private static ?array $registry = null;
/** Orden de visualización de categorías en el sidebar */
private const CATEGORY_ORDER = ['bot', 'lab', 'formularios', 'turnero', 'clinico', 'reportes', 'sistema'];
/** Etiquetas e íconos de cada categoría */
private const CATEGORIES = [
'bot' => ['label' => 'WhatsApp', 'icon' => 'fab fa-whatsapp'],
'lab' => ['label' => 'Domicilios', 'icon' => 'fas fa-house-medical'],
'formularios'=> ['label' => 'Formulario', 'icon' => 'fas fa-file-invoice'],
'turnero' => ['label' => 'Turnero', 'icon' => 'fas fa-ticket-alt'],
'clinico' => ['label' => 'Clínico', 'icon' => 'fas fa-vials'],
'reportes' => ['label' => 'Reportes', 'icon' => 'fas fa-chart-bar'],
'sistema' => ['label' => 'Sistema', 'icon' => 'fas fa-cog'],
];
// ─── API pública ────────────────────────────────────────────────────────
/**
* Retorna todos los módulos registrados (activos e inactivos).
*
* @return array<string, array> Indexado por slug.
*/
public static function getAll(): array
{
self::load();
return self::$registry;
}
/**
* Retorna solo los módulos ACTIVOS que el usuario actual puede ver.
*
* IMPORTANTE: consulta role_modules desde BD en tiempo real para que los
* cambios hechos en lab_usuarios.php se reflejen sin necesidad de re-login.
*
* Excepción: superadmin y admin sin role_id tienen acceso total (retrocompat).
*
* @return array<string, array> Indexado por slug.
*/
public static function forCurrentUser(): array
{
self::load();
$allowed = self::liveUserModules(); // null = acceso total
$result = [];
foreach (self::$registry as $slug => $mod) {
if (!$mod['is_active']) {
continue;
}
// null = superadmin / admin legacy → ve todo
if ($allowed !== null && !in_array($slug, $allowed, true)) {
continue;
}
$result[$slug] = $mod;
}
return $result;
}
/**
* Obtiene los slugs de módulos permitidos para el usuario actual consultando
* role_modules en BD (ignora la caché de sesión para ser siempre fresco).
*
* Retorna null si el usuario tiene acceso total (superadmin / admin legacy).
*
* @return string[]|null
*/
private static function liveUserModules(): ?array
{
$role = $_SESSION['admin_user']['role'] ?? 'admin';
$roleId = $_SESSION['admin_user']['role_id'] ?? null;
// Superadmin → acceso total siempre
if ($role === 'superadmin') {
return null;
}
// Admin sin role_id asignado → retrocompat, acceso total
if ($role === 'admin' && !$roleId) {
return null;
}
// Sin role_id en cualquier otro rol → usar sesión como fallback
if (!$roleId) {
return $_SESSION['admin_user']['modules'] ?? [];
}
// Consulta en tiempo real: reflejan cambios en lab_usuarios.php al instante
try {
$db = Database::getInstance();
$pdo = $db->getConnection();
$stmt = $pdo->prepare(
"SELECT module_slug FROM role_modules WHERE role_id = ?"
);
$stmt->execute([(int) $roleId]);
return array_column($stmt->fetchAll(PDO::FETCH_ASSOC), 'module_slug');
} catch (Exception $e) {
error_log("ModuleRegistry::liveUserModules error: " . $e->getMessage());
// Fallback a sesión si la BD no responde
return $_SESSION['admin_user']['modules'] ?? [];
}
}
/**
* Retorna módulos activos agrupados por categoría para el sidebar.
* Solo incluye módulos accesibles por el usuario actual.
*
* @return array ['bot' => ['label'=>'WhatsApp','icon'=>'...','modules'=>[...]], ...]
*/
public static function forSidebar(): array
{
$userModules = self::forCurrentUser();
// Agrupar por categoría
$grouped = [];
foreach ($userModules as $slug => $mod) {
$cat = $mod['category'] ?? 'sistema';
if (!isset($grouped[$cat])) {
$catMeta = self::CATEGORIES[$cat] ?? ['label' => ucfirst($cat), 'icon' => 'fas fa-cube'];
$grouped[$cat] = [
'label' => $catMeta['label'],
'icon' => $catMeta['icon'],
'modules' => [],
];
}
$grouped[$cat]['modules'][$slug] = $mod;
}
// Ordenar grupos según CATEGORY_ORDER
$ordered = [];
foreach (self::CATEGORY_ORDER as $cat) {
if (isset($grouped[$cat])) {
// Ordenar módulos dentro de la categoría por sort_order
uasort($grouped[$cat]['modules'], fn($a, $b) => $a['sort_order'] <=> $b['sort_order']);
$ordered[$cat] = $grouped[$cat];
}
}
// Categorías extra no definidas en CATEGORY_ORDER al final
foreach ($grouped as $cat => $data) {
if (!isset($ordered[$cat])) {
$ordered[$cat] = $data;
}
}
return $ordered;
}
/**
* Retorna el descriptor de un módulo por slug, o null si no existe.
*/
public static function get(string $slug): ?array
{
self::load();
return self::$registry[$slug] ?? null;
}
/**
* Activa o desactiva un módulo en la base de datos.
*
* @throws RuntimeException Si no se puede conectar a la BD.
*/
public static function setActive(string $slug, bool $active): bool
{
try {
$db = Database::getInstance();
$pdo = $db->getConnection();
$stmt = $pdo->prepare(
"UPDATE system_modules SET is_active = ? WHERE slug = ?"
);
$ok = $stmt->execute([(int) $active, $slug]);
if ($ok) {
self::$registry = null; // invalidar caché
}
return $ok;
} catch (Exception $e) {
error_log("ModuleRegistry::setActive error: " . $e->getMessage());
return false;
}
}
/**
* Actualiza el sort_order de un módulo.
*/
public static function setSortOrder(string $slug, int $order): bool
{
try {
$db = Database::getInstance();
$pdo = $db->getConnection();
$stmt = $pdo->prepare(
"UPDATE system_modules SET sort_order = ? WHERE slug = ?"
);
$ok = $stmt->execute([$order, $slug]);
if ($ok) {
self::$registry = null;
}
return $ok;
} catch (Exception $e) {
error_log("ModuleRegistry::setSortOrder error: " . $e->getMessage());
return false;
}
}
/**
* Invalida la caché para forzar recarga en la próxima llamada.
* Útil después de cambios por el administrador.
*/
public static function flush(): void
{
self::$registry = null;
}
// ─── Carga interna ──────────────────────────────────────────────────────
private static function load(): void
{
if (self::$registry !== null) {
return;
}
self::$registry = [];
// 1. Escanear module.php locales para obtener metadatos base
self::loadFromFiles();
// 2. Sobreescribir is_active / sort_order desde la BD (si existe)
self::mergeFromDatabase();
// 3. Fallback: añadir slugs de SYSTEM_MODULES que no tengan module.php
self::fillFromConstant();
}
/**
* Escanea modules/{slug}/module.php y carga sus descriptores.
*/
private static function loadFromFiles(): void
{
if (!defined('APP_ROOT')) {
return;
}
$pattern = APP_ROOT . '/modules/*/module.php';
foreach (glob($pattern) ?: [] as $file) {
$data = @include $file;
if (is_array($data) && isset($data['slug'])) {
self::$registry[$data['slug']] = self::normalize($data);
}
}
}
/**
* Lee la tabla system_modules y sobreescribe campos controlados por el admin.
*/
private static function mergeFromDatabase(): void
{
try {
$db = Database::getInstance();
$pdo = $db->getConnection();
// Verificar si existe la tabla
$check = $pdo->query("SHOW TABLES LIKE 'system_modules'");
if (!$check || $check->rowCount() === 0) {
return;
}
$rows = $pdo->query(
"SELECT slug, name, icon, category, route, is_active, sort_order, oleada, description
FROM system_modules ORDER BY sort_order ASC"
)->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $row) {
$slug = $row['slug'];
if (isset(self::$registry[$slug])) {
// Actualizar solo los campos que maneja el admin
self::$registry[$slug]['is_active'] = (bool)(int)$row['is_active'];
self::$registry[$slug]['sort_order'] = (int)$row['sort_order'];
// Si en BD hay datos más completos, usar los de BD
if (!empty($row['name'])) self::$registry[$slug]['name'] = $row['name'];
if (!empty($row['icon'])) self::$registry[$slug]['icon'] = $row['icon'];
if (!empty($row['category'])) self::$registry[$slug]['category'] = $row['category'];
if (!empty($row['route'])) self::$registry[$slug]['route'] = $row['route'];
if (!empty($row['description'])) self::$registry[$slug]['description'] = $row['description'];
} else {
// Módulo solo en BD (sin module.php local)
self::$registry[$slug] = self::normalize([
'slug' => $slug,
'name' => $row['name'],
'icon' => $row['icon'],
'category' => $row['category'],
'route' => $row['route'],
'is_active' => (bool)(int)$row['is_active'],
'sort_order' => (int)$row['sort_order'],
'oleada' => (int)$row['oleada'],
'description' => $row['description'],
]);
}
}
} catch (Exception $e) {
// BD no disponible → seguir con datos de archivos
error_log("ModuleRegistry DB merge error: " . $e->getMessage());
}
}
/**
* Agrega módulos del array SYSTEM_MODULES que no estén ya registrados.
* Garantiza compatibilidad si no se ejecutó la migración 004.
*/
private static function fillFromConstant(): void
{
if (!defined('SYSTEM_MODULES')) {
return;
}
foreach (SYSTEM_MODULES as $slug => $name) {
if (!isset(self::$registry[$slug])) {
self::$registry[$slug] = self::normalize([
'slug' => $slug,
'name' => $name,
'icon' => 'fas fa-cube',
'category' => 'sistema',
'route' => null,
'is_active' => true,
'sort_order' => 99,
'oleada' => 0,
]);
}
}
}
/**
* Normaliza un descriptor garantizando que todos los campos existen.
*/
private static function normalize(array $data): array
{
return [
'slug' => $data['slug'] ?? '',
'name' => $data['name'] ?? ucfirst($data['slug'] ?? ''),
'icon' => $data['icon'] ?? 'fas fa-cube',
'category' => $data['category'] ?? 'sistema',
'route' => $data['route'] ?? null,
'is_active' => (bool)($data['is_active'] ?? true),
'sort_order' => (int)($data['sort_order'] ?? 99),
'oleada' => (int)($data['oleada'] ?? 0),
'description' => $data['description'] ?? '',
'links' => $data['links'] ?? [],
];
}
}