Files
whatsapp/modules/soporte/DocIndex.php
T
Lizandro GuarnizoandClaude Opus 5 dc27184051 Documentación: visibilidad por rol en cada documento y una sola entrada de menú
Un documento puede declarar qué roles lo ven mediante una cabecera al inicio
del archivo; sin ella hereda el permiso de su sección. Los administradores ven
todo. Se aplica tanto al índice como al acceso directo por URL, y una sección
que queda sin documentos visibles deja de mostrarse.

Con esto un recepcionista ve solo el manual de recepción, un bacteriólogo el de
toma de muestras y un enfermero el suyo, en vez del manual completo.

El módulo declaraba cinco enlaces que apuntaban todos a la misma vista, así que
al abrir una sección quedaban dos entradas marcadas como activas a la vez. Queda
una sola, "Documentación": la navegación por secciones ya vive dentro de la
página.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 10:37:33 -05:00

190 lines
6.9 KiB
PHP

<?php
/**
* modules/soporte/DocIndex.php
* Descubre los documentos en docs/, arma el árbol de navegación y resuelve el
* control de acceso por sección.
*
* Convenciones:
* docs/<seccion>/<NN>-<slug>.md → el prefijo NN solo ordena, no se muestra
* El título sale del primer encabezado `# ` del archivo.
*
* Un documento puede restringir su visibilidad con una cabecera al inicio:
*
* ---
* roles: enfermero, recepcionista
* ---
*
* Sin cabecera, hereda el permiso de su sección. Los administradores ven todo.
*/
final class DocIndex
{
/** Secciones, en orden de aparición. `admin` = solo administradores. */
public const SECCIONES = [
'manual' => ['titulo' => 'Manual de usuario', 'icono' => 'fas fa-book-reader', 'admin' => false,
'resumen' => 'Cómo usar el sistema, paso a paso, según tu rol.'],
'tecnica' => ['titulo' => 'Documentación técnica','icono' => 'fas fa-code', 'admin' => true,
'resumen' => 'Cada módulo por dentro: tablas, endpoints y dependencias.'],
'arquitectura'=> ['titulo' => 'Arquitectura', 'icono' => 'fas fa-sitemap', 'admin' => true,
'resumen' => 'Cómo está armado el sistema y por qué.'],
'operacion' => ['titulo' => 'Operación y soporte', 'icono' => 'fas fa-life-ring', 'admin' => true,
'resumen' => 'Qué hacer cuando algo falla. Configuraciones críticas.'],
];
/** Roles que ven todo, sin importar lo que declare cada documento. */
public const ROLES_TOTALES = ['admin', 'superadmin'];
public static function dir(): string
{
return __DIR__ . '/docs';
}
private static function rolActual(): string
{
return $_SESSION['admin_user']['role'] ?? '';
}
private static function esAdmin(): bool
{
return in_array(self::rolActual(), self::ROLES_TOTALES, true);
}
/**
* Separa la cabecera del cuerpo de un documento.
* @return array{0: array<string,string>, 1: string} [metadatos, cuerpo]
*/
public static function leer(string $archivo): array
{
$texto = (string)@file_get_contents($archivo);
if (!preg_match('/^---\R(.*?)\R---\R?(.*)$/s', $texto, $m)) {
return [[], $texto];
}
$meta = [];
foreach (preg_split('/\R/', $m[1]) as $linea) {
if (preg_match('/^\s*([\w-]+)\s*:\s*(.*)$/', $linea, $kv)) {
$meta[strtolower($kv[1])] = trim($kv[2]);
}
}
return [$meta, ltrim($m[2])];
}
/** ¿El usuario actual puede ver este documento concreto? */
public static function puedeVerDoc(string $seccion, string $archivo): bool
{
if (self::esAdmin()) return true;
if (!self::puedeVer($seccion)) return false;
[$meta] = self::leer($archivo);
if (empty($meta['roles'])) return true; // sin cabecera: hereda la sección
$permitidos = array_filter(array_map('trim', explode(',', strtolower($meta['roles']))));
return in_array(strtolower(self::rolActual()), $permitidos, true);
}
/** ¿El usuario actual puede ver esta sección? */
public static function puedeVer(string $seccion): bool
{
$cfg = self::SECCIONES[$seccion] ?? null;
if (!$cfg) return false;
if (!$cfg['admin']) return true;
return self::esAdmin();
}
/** Secciones visibles para el usuario actual. */
public static function seccionesVisibles(): array
{
return array_filter(
self::SECCIONES,
fn($s) => self::puedeVer($s),
ARRAY_FILTER_USE_KEY
);
}
/**
* Árbol completo: [seccion => ['titulo'=>..,'docs'=>[['slug','titulo','archivo'],...]]]
* Solo incluye lo que el usuario puede ver.
*/
public static function arbol(): array
{
$arbol = [];
foreach (self::seccionesVisibles() as $sec => $cfg) {
$ruta = self::dir() . '/' . $sec;
if (!is_dir($ruta)) continue;
$docs = [];
foreach (glob($ruta . '/*.md') ?: [] as $archivo) {
if (!self::puedeVerDoc($sec, $archivo)) continue;
$base = basename($archivo, '.md');
$slug = preg_replace('/^\d+-/', '', $base);
$docs[] = [
'slug' => $slug,
'titulo' => self::titulo($archivo, $slug),
'archivo' => $archivo,
'orden' => $base,
];
}
usort($docs, fn($a, $b) => strcmp($a['orden'], $b['orden']));
// Una sección sin documentos visibles no se muestra
if (!$docs) continue;
$arbol[$sec] = $cfg + ['docs' => $docs];
}
return $arbol;
}
/** Ruta del archivo pedido, o null si no existe o no hay acceso. */
public static function resolver(string $seccion, string $slug): ?string
{
if (!self::puedeVer($seccion)) return null;
// Evitar traversal: los slugs son [a-z0-9-]
if (!preg_match('/^[a-z0-9-]+$/', $seccion) || !preg_match('/^[a-z0-9-]+$/', $slug)) return null;
foreach (glob(self::dir() . '/' . $seccion . '/*.md') ?: [] as $archivo) {
if (preg_replace('/^\d+-/', '', basename($archivo, '.md')) !== $slug) continue;
return self::puedeVerDoc($seccion, $archivo) ? $archivo : null;
}
return null;
}
/** Primer encabezado `# ` del archivo; si no hay, el slug capitalizado. */
private static function titulo(string $archivo, string $slugFallback): string
{
$fh = @fopen($archivo, 'r');
if ($fh) {
$lineas = 0;
while (($l = fgets($fh)) !== false && $lineas++ < 30) {
if (preg_match('/^#\s+(.+)$/', trim($l), $m)) { fclose($fh); return trim($m[1]); }
}
fclose($fh);
}
return ucfirst(str_replace('-', ' ', $slugFallback));
}
/**
* Índice para el buscador: un registro por documento con su texto plano.
* Solo incluye secciones visibles para el usuario actual.
*/
public static function indiceBusqueda(): array
{
$out = [];
foreach (self::arbol() as $sec => $cfg) {
foreach ($cfg['docs'] as $doc) {
[, $texto] = self::leer($doc['archivo']);
// Aplanar: sin marcas, sin saltos, compacto
$texto = preg_replace('/```.*?```/s', ' ', $texto);
$texto = preg_replace('/[#>*_`|-]+/', ' ', $texto);
$texto = preg_replace('/\s+/', ' ', $texto);
$out[] = [
's' => $sec,
'u' => $doc['slug'],
't' => $doc['titulo'],
'c' => $cfg['titulo'],
'x' => mb_substr(trim($texto), 0, 4000),
];
}
}
return $out;
}
}