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>
This commit is contained in:
Lizandro Guarnizo
2026-08-04 10:37:33 -05:00
co-authored by Claude Opus 5
parent 32c209710c
commit dc27184051
8 changed files with 97 additions and 17 deletions
+15
View File
@@ -23,6 +23,21 @@ que solo sirve para ordenar:
El título sale del primer encabezado `#` del archivo. No hay que registrar nada El título sale del primer encabezado `#` del archivo. No hay que registrar nada
en ningún índice: se descubre solo. en ningún índice: se descubre solo.
## Limitar un documento a ciertos roles
Por defecto un documento hereda el permiso de su sección. Para restringirlo más,
se declara al inicio del archivo:
---
roles: enfermero, supervisor
---
# Enfermeros — domicilios
Solo esos roles lo ven; para el resto no aparece en el índice ni es accesible
por URL. Los administradores ven todo, siempre. Una sección que queda sin
documentos visibles no se muestra.
## Contenido que se genera solo ## Contenido que se genera solo
Estos marcadores, en una línea propia, se reemplazan al cargar la página con Estos marcadores, en una línea propia, se reemplazan al cargar la página con
+61 -3
View File
@@ -7,6 +7,14 @@
* Convenciones: * Convenciones:
* docs/<seccion>/<NN>-<slug>.md → el prefijo NN solo ordena, no se muestra * docs/<seccion>/<NN>-<slug>.md → el prefijo NN solo ordena, no se muestra
* El título sale del primer encabezado `# ` del archivo. * 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 final class DocIndex
@@ -23,18 +31,63 @@ final class DocIndex
'resumen' => 'Qué hacer cuando algo falla. Configuraciones críticas.'], '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 public static function dir(): string
{ {
return __DIR__ . '/docs'; 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? */ /** ¿El usuario actual puede ver esta sección? */
public static function puedeVer(string $seccion): bool public static function puedeVer(string $seccion): bool
{ {
$cfg = self::SECCIONES[$seccion] ?? null; $cfg = self::SECCIONES[$seccion] ?? null;
if (!$cfg) return false; if (!$cfg) return false;
if (!$cfg['admin']) return true; if (!$cfg['admin']) return true;
return in_array($_SESSION['admin_user']['role'] ?? '', ['admin', 'superadmin'], true); return self::esAdmin();
} }
/** Secciones visibles para el usuario actual. */ /** Secciones visibles para el usuario actual. */
@@ -60,6 +113,7 @@ final class DocIndex
$docs = []; $docs = [];
foreach (glob($ruta . '/*.md') ?: [] as $archivo) { foreach (glob($ruta . '/*.md') ?: [] as $archivo) {
if (!self::puedeVerDoc($sec, $archivo)) continue;
$base = basename($archivo, '.md'); $base = basename($archivo, '.md');
$slug = preg_replace('/^\d+-/', '', $base); $slug = preg_replace('/^\d+-/', '', $base);
$docs[] = [ $docs[] = [
@@ -71,6 +125,9 @@ final class DocIndex
} }
usort($docs, fn($a, $b) => strcmp($a['orden'], $b['orden'])); 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]; $arbol[$sec] = $cfg + ['docs' => $docs];
} }
return $arbol; return $arbol;
@@ -84,7 +141,8 @@ final class DocIndex
if (!preg_match('/^[a-z0-9-]+$/', $seccion) || !preg_match('/^[a-z0-9-]+$/', $slug)) return null; if (!preg_match('/^[a-z0-9-]+$/', $seccion) || !preg_match('/^[a-z0-9-]+$/', $slug)) return null;
foreach (glob(self::dir() . '/' . $seccion . '/*.md') ?: [] as $archivo) { foreach (glob(self::dir() . '/' . $seccion . '/*.md') ?: [] as $archivo) {
if (preg_replace('/^\d+-/', '', basename($archivo, '.md')) === $slug) return $archivo; if (preg_replace('/^\d+-/', '', basename($archivo, '.md')) !== $slug) continue;
return self::puedeVerDoc($seccion, $archivo) ? $archivo : null;
} }
return null; return null;
} }
@@ -112,7 +170,7 @@ final class DocIndex
$out = []; $out = [];
foreach (self::arbol() as $sec => $cfg) { foreach (self::arbol() as $sec => $cfg) {
foreach ($cfg['docs'] as $doc) { foreach ($cfg['docs'] as $doc) {
$texto = @file_get_contents($doc['archivo']) ?: ''; [, $texto] = self::leer($doc['archivo']);
// Aplanar: sin marcas, sin saltos, compacto // Aplanar: sin marcas, sin saltos, compacto
$texto = preg_replace('/```.*?```/s', ' ', $texto); $texto = preg_replace('/```.*?```/s', ' ', $texto);
$texto = preg_replace('/[#>*_`|-]+/', ' ', $texto); $texto = preg_replace('/[#>*_`|-]+/', ' ', $texto);
@@ -1,3 +1,7 @@
---
roles: recepcionista, lab_recepcion, supervisor
---
# Recepción # Recepción
Guía de la pantalla de recepción del turnero: desde que llamás al paciente hasta que pasa a toma de muestras. Guía de la pantalla de recepción del turnero: desde que llamás al paciente hasta que pasa a toma de muestras.
@@ -1,3 +1,7 @@
---
roles: bacteriologo, supervisor
---
# Toma de muestras # Toma de muestras
Guía de la estación de toma de muestras: atender al paciente, recibir sus muestras y firmar los formularios. Guía de la estación de toma de muestras: atender al paciente, recibir sus muestras y firmar los formularios.
@@ -1,3 +1,7 @@
---
roles: enfermero, supervisor
---
# Enfermeros — domicilios # Enfermeros — domicilios
Guía del portal del enfermero: tus visitas domiciliarias, cómo agendarlas y qué hacer en cada una. Guía del portal del enfermero: tus visitas domiciliarias, cómo agendarlas y qué hacer en cada una.
@@ -1,3 +1,7 @@
---
roles: supervisor
---
# Administración # Administración
Tareas de administrador: usuarios, permisos, configuración y reportes. Tareas de administrador: usuarios, permisos, configuración y reportes.
+3 -13
View File
@@ -5,23 +5,13 @@
* de secciones (técnica, arquitectura, operación) solo para administradores. * de secciones (técnica, arquitectura, operación) solo para administradores.
*/ */
$_sopEsAdmin = in_array($_SESSION['admin_user']['role'] ?? '', ['admin', 'superadmin'], true); // Una sola entrada en el menú: la navegación por secciones y documentos vive
// dentro de la propia página. Tener aquí un enlace por sección hacía que al
// abrir una quedaran dos entradas marcadas como activas a la vez.
$_sopLinks = [ $_sopLinks = [
['name' => 'Documentación', 'icon' => 'fas fa-book', 'route' => '/erp.php?m=soporte&v=documentacion'], ['name' => 'Documentación', 'icon' => 'fas fa-book', 'route' => '/erp.php?m=soporte&v=documentacion'],
['name' => 'Manual de usuario', 'icon' => 'fas fa-book-reader',
'route' => '/erp.php?m=soporte&v=documentacion&s=manual'],
]; ];
if ($_sopEsAdmin) {
$_sopLinks[] = ['name' => 'Documentación técnica', 'icon' => 'fas fa-code',
'route' => '/erp.php?m=soporte&v=documentacion&s=tecnica'];
$_sopLinks[] = ['name' => 'Arquitectura', 'icon' => 'fas fa-sitemap',
'route' => '/erp.php?m=soporte&v=documentacion&s=arquitectura'];
$_sopLinks[] = ['name' => 'Operación y soporte', 'icon' => 'fas fa-life-ring',
'route' => '/erp.php?m=soporte&v=documentacion&s=operacion'];
}
return [ return [
'slug' => 'soporte', 'slug' => 'soporte',
'name' => 'Soporte', 'name' => 'Soporte',
+2 -1
View File
@@ -24,7 +24,8 @@ $cuerpo = '';
$toc = []; $toc = [];
if ($archivo) { if ($archivo) {
$md = Generadores::expandir((string)file_get_contents($archivo)); [, $crudo] = DocIndex::leer($archivo); // descarta la cabecera de permisos
$md = Generadores::expandir($crudo);
$cuerpo = Markdown::render($md); $cuerpo = Markdown::render($md);
$toc = Markdown::indice($md); $toc = Markdown::indice($md);
foreach ($arbol[$seccion]['docs'] ?? [] as $d) { foreach ($arbol[$seccion]['docs'] ?? [] as $d) {