Actualizar herramientas y contenidos

This commit is contained in:
planetazuzu 2026-01-19 09:10:16 +01:00
parent f1ba0a0a32
commit 0014c17873
726 changed files with 163024 additions and 7318 deletions

10
.gitignore vendored
View file

@ -44,3 +44,13 @@ __pycache__/
*.bak
*.backup
backup_manual_pre_limpieza/
# Build outputs (añadido por auditoría de limpieza)
dist/
# Logs (añadido por auditoría de limpieza)
logs/*.log
# Backup files (añadido por auditoría de limpieza)
*.backup
*.bak

View file

@ -1,155 +0,0 @@
# 🚀 Comandos de Despliegue
## Estado Actual
✅ **Aplicación desplegada y corriendo**
- **Puerto:** 8607
- **URL:** http://localhost:8607
- **Gestor:** PM2
- **Estado:** Online
## Comandos Útiles
### Ver estado de la aplicación
```bash
pm2 list
pm2 status emerges-tes
pm2 info emerges-tes
```
### Ver logs
```bash
# Ver logs en tiempo real
pm2 logs emerges-tes
# Ver últimas 50 líneas
pm2 logs emerges-tes --lines 50 --nostream
# Ver solo errores
pm2 logs emerges-tes --err
```
### Gestionar la aplicación
```bash
# Reiniciar
pm2 restart emerges-tes
# Detener
pm2 stop emerges-tes
# Iniciar
pm2 start emerges-tes
# Eliminar del gestor PM2
pm2 delete emerges-tes
```
### Monitor en tiempo real
```bash
pm2 monit
```
### Guardar configuración PM2
```bash
pm2 save
```
## Opciones de Despliegue
### 1. Deploy con PM2 (Actual)
```bash
./deploy.sh --skip-git
```
- Puerto: 8607
- Gestión automática de procesos
- Reinicio automático
### 2. Deploy con Docker
```bash
./deploy-docker.sh --skip-git
```
- Puerto: 8607
- Contenedor aislado
- Opciones adicionales:
- `--rebuild` - Reconstruir imagen desde cero
- `--stop` - Detener contenedor
- `--logs` - Ver logs
### 3. Servidor de preview (desarrollo)
```bash
npm run preview
```
- Puerto: 4173
- Solo para pruebas locales
- Se detiene al cerrar terminal
### 4. Script interactivo
```bash
./desplegar.sh
```
- Menú interactivo con todas las opciones
## Verificar que funciona
### Desde el navegador
Abre: http://localhost:8607
### Desde la terminal
```bash
curl http://localhost:8607
```
### Verificar puerto
```bash
netstat -tlnp | grep 8607
# o
ss -tlnp | grep 8607
```
## Solución de Problemas
### Si la aplicación no responde
```bash
# Ver logs de errores
pm2 logs emerges-tes --err
# Reiniciar
pm2 restart emerges-tes
# Verificar que el puerto esté libre
lsof -i :8607
```
### Si necesitas cambiar el puerto
Edita `ecosystem.config.cjs` y cambia el puerto en:
- `args: 'serve -s dist -l [NUEVO_PUERTO]'`
- `PORT: [NUEVO_PUERTO]`
Luego reinicia:
```bash
pm2 restart emerges-tes
```
### Si necesitas reconstruir
```bash
npm run build
pm2 restart emerges-tes
```
## Acceso Remoto
Si quieres acceder desde otra máquina en la misma red:
1. Verifica tu IP local:
```bash
hostname -I
# o
ip addr show
```
2. Accede desde otro dispositivo usando:
```
http://[TU_IP_LOCAL]:8607
```
**Nota:** Asegúrate de que el firewall permita conexiones en el puerto 8607.

View file

@ -1,73 +0,0 @@
# 🚀 Comandos para Push Manual
Como la autenticación SSH requiere interacción, ejecuta estos comandos **en tu terminal**:
## Opción 1: Usar el script automático
```bash
cd /home/planetazuzu/guia-tes
./scripts/push-produccion.sh
```
Este script:
1. Instala `sshpass` si es necesario
2. Copia tu clave SSH al servidor
3. Hace el push
---
## Opción 2: Comandos manuales paso a paso
### Paso 1: Instalar sshpass (si no está instalado)
```bash
sudo apt-get install sshpass
```
### Paso 2: Copiar clave SSH al servidor
```bash
cat ~/.ssh/id_ed25519.pub | sshpass -p "941259018a" ssh -o StrictHostKeyChecking=no root@207.180.226.141 "mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"
```
### Paso 3: Probar conexión
```bash
sshpass -p "941259018a" ssh -o StrictHostKeyChecking=no root@207.180.226.141 "echo 'Conexión exitosa'"
```
### Paso 4: Hacer push
```bash
cd /home/planetazuzu/guia-tes
git push production main
```
---
## Opción 3: Sin sshpass (más seguro a largo plazo)
### Paso 1: Copiar clave manualmente (te pedirá la contraseña)
```bash
ssh-copy-id root@207.180.226.141
# Contraseña: 941259018a
```
### Paso 2: Hacer push (ya no pedirá contraseña)
```bash
cd /home/planetazuzu/guia-tes
git push production main
```
---
## ✅ Estado Actual
- ✅ Clave SSH generada: `~/.ssh/id_ed25519`
- ✅ Commit listo: `6df53a2`
- ⏳ Push pendiente: ejecuta uno de los métodos arriba
---
## 🔒 Seguridad
Después del primer push exitoso, puedes:
1. Eliminar la contraseña del script (ya no será necesaria)
2. La clave SSH permitirá acceso sin contraseña

19
EJECUTAR_COMANDO.md Normal file
View file

@ -0,0 +1,19 @@
# ⚡ COMANDO PARA EJECUTAR
Ejecuta este comando en tu terminal:
```bash
cd /home/planetazuzu/guia-tes/backend
bash crear-usuario-y-bd.sh
```
Este script:
- ✅ Crea el usuario `planetazuzu` con password `Monforte.1977`
- ✅ Crea la base de datos `emerges_tes`
- ✅ Crea el esquema `emerges_content`
- ✅ Da todos los permisos necesarios
**Después de ejecutarlo, avísame y continúo automáticamente con:**
- Verificar conexión
- Crear tablas (migraciones)
- Migrar contenido

31
ESTADO_ACTUAL.md Normal file
View file

@ -0,0 +1,31 @@
# 📊 ESTADO ACTUAL - FASE 1
## ✅ COMPLETADO
1. ✅ **Dependencias instaladas** (`npm install` en backend)
2. ✅ **Archivo `.env` configurado** con tus credenciales:
- Usuario: `planetazuzu`
- Password: `Monforte.1977`
- Base de datos: `emerges_tes`
3. ✅ **Scripts creados**:
- `backend/scripts/verify-setup.js` - Verificar conexión
- `backend/scripts/db-create.js` - Crear tablas
- `backend/scripts/migrate-content.js` - Migrar contenido
- `backend/scripts/create-user.sql` - SQL para crear usuario
- `backend/crear-usuario-y-bd.sh` - Script bash para ejecutar
4. ✅ **Conexión verificada** a PostgreSQL
5. ✅ **Migraciones ejecutadas** (esquema y funciones)
6. ✅ **Contenido migrado** (protocolos y fármacos)
## ✅ ESTADO ACTUAL
La base de datos `emerges_tes` y el esquema `emerges_content` ya están creados,
las migraciones se ejecutaron correctamente y el contenido fue migrado.
## 📁 ARCHIVOS IMPORTANTES
- `backend/.env` - Configuración de base de datos
- `backend/crear-usuario-y-bd.sh` - Script para crear usuario (EJECUTAR ESTE)
- `database/migrations/001_create_schema.sql` - Esquema de tablas
- `database/migrations/002_create_functions.sql` - Funciones y triggers

View file

@ -58,7 +58,6 @@ guia-tes/
│ ├── archivo/
│ └── consolidado/
├── 📂 dist/ # Archivos compilados para producción
├── 📂 config_backup/ # Configuraciones de respaldo
└── 📂 node_modules/ # Dependencias de Node.js (no editar)
```
@ -70,9 +69,9 @@ guia-tes/
- `tailwind.config.ts` - Configuración de Tailwind CSS
- `index.html` - Punto de entrada HTML
- `manifest.json` - Configuración PWA
- Scripts de despliegue: `deploy.sh`, `deploy-docker.sh`
- Scripts de limpieza: `cleanup_project.sh`, `cleanup_completo.sh`
- Scripts de utilidad: `integrate_assets.py`, `generar_documentos_word.py`
- Scripts de despliegue: `deploy.sh`, `docker.sh`
- Scripts de limpieza: `cleanup.sh`
- Scripts de utilidad: `integrate-assets.py`, `generate-docs.py`
## Estadísticas
@ -85,7 +84,7 @@ guia-tes/
1. **Desde la terminal:**
```bash
./mostrar-estructura.sh
tree -L 2
```
2. **Desde el explorador de archivos:**

View file

@ -5,7 +5,6 @@
📁 CARPETAS PRINCIPALES:
───────────────────────────────────────────────────────────
📂 assets/ (0 archivos, 136K)
📂 config_backup/ (5 archivos, 24K)
📂 dist/ (186 archivos, 12M)
📂 docs/ (17 archivos, 232K)
📂 node_modules/ (26629 archivos, 322M)
@ -17,31 +16,23 @@
───────────────────────────────────────────────────────────
📄 abrir-carpeta.sh (1,2K)
📄 cleanup_completo.sh (16K)
📄 cleanup_project.sh (8,5K)
📄 components.json (414)
📄 deploy-docker.sh (4,5K)
📄 deploy.sh (4,0K)
📄 docker-compose.prod.yml (995)
📄 docker-compose.yml (654)
📄 ecosystem.config.js (852)
📄 eslint.config.js (765)
📄 generar_documentos_word.py (13K)
📄 integrate_assets.py (15K)
📄 manifest.json (33K)
📄 mostrar-estructura.sh (2,9K)
📄 package.json (3,3K)
📄 package-lock.json (339K)
📄 postcss.config.js (81)
📄 README.md (1,3K)
📄 reorganizar_proyecto.sh (3,0K)
📄 servir-local.sh (149)
📄 tailwind.config.ts (3,9K)
📄 tailwind.config.ts (3,9K)
📄 tsconfig.app.json (652)
📄 tsconfig.json (369)
📄 tsconfig.node.json (481)
📄 vite.config.ts (6,2K)
📄 vite.config.ts (6,2K)
📄 vite-plugin-manifest.ts (2,1K)
📄 webhook-deploy.sh (1,4K)
@ -91,46 +82,9 @@
public/manual/BLOQUE_5_PROTOCOLOS_TRANSTELEFONICOS
public/manual/BLOQUE_6_FARMACOLOGIA
public/manual/BLOQUE_7_CONDUCCION_Y_SEGURIDAD_VIAL
public/manual/BLOQUE_8_GESTION_OPERATIVA
public/manual/BLOQUE_8_GESTION_OPERATIVA_Y_DOCUMENTACION
public/manual/BLOQUE_9_MEDICINA_EMERGENCIAS_APLICADA
📁 assets/ (recursos multimedia):
assets
assets/checklists_app
assets/consent_privacy
assets/images
assets/images/bloque_00
assets/images/bloque_01
assets/images/bloque_02
assets/images/bloque_03
assets/images/bloque_04
assets/images/bloque_05
assets/images/bloque_06
assets/images/bloque_07
assets/images/bloque_08
assets/slides
assets/slides/bloque_00
assets/slides/bloque_01
assets/slides/bloque_02
assets/slides/bloque_03
assets/slides/bloque_04
assets/slides/bloque_05
assets/slides/bloque_06
assets/slides/bloque_07
assets/slides/bloque_08
assets/templates
assets/videos
assets/videos/bloque_00
assets/videos/bloque_01
assets/videos/bloque_02
assets/videos/bloque_03
assets/videos/bloque_04
assets/videos/bloque_05
assets/videos/bloque_06
assets/videos/bloque_07
assets/videos/bloque_08
═══════════════════════════════════════════════════════════
RESUMEN
═══════════════════════════════════════════════════════════

8602
MANIFESTO_MEDIOS.json Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

530
MEDIOS_REALES_NECESARIOS.md Normal file
View file

@ -0,0 +1,530 @@
# Medios reales necesarios (derivado de referencias en docs/)
Este listado incluye referencias en documentación que no son placeholders evidentes.
## Medios reales necesarios
- rcp_adulto_paso_a_paso.png | /assets/infografias/bloque-4-rcp/rcp_adulto_paso_a_paso.png | /home/planetazuzu/guia-tes/docs/MEDIOS_AUDIOVISUALES_FALTANTES.md, línea 172
- diagrama_abcde_paso_a_paso_completo.svg | public/assets/infografias/bloque-0-fundamentos/diagrama_abcde_paso_a_paso_completo.svg | /home/planetazuzu/guia-tes/docs/MEDIOS_AUDIOVISUALES_FALTANTES.md, línea 48
- diagrama_abcde_paso_a_paso_completo.svg | diagrama_abcde_paso_a_paso_completo.svg | /home/planetazuzu/guia-tes/docs/MEDIOS_AUDIOVISUALES_FALTANTES.md, línea 53
- diagrama_abcde_paso_a_paso_completo.svg | /assets/infografias/bloque-0-fundamentos/diagrama_abcde_paso_a_paso_completo.svg | /home/planetazuzu/guia-tes/docs/MEDIOS_AUDIOVISUALES_FALTANTES.md, línea 59
- rcp_adulto_paso_a_paso.png | public/assets/infografias/bloque-4-rcp/rcp_adulto_paso_a_paso.png | /home/planetazuzu/guia-tes/docs/MEDIOS_AUDIOVISUALES_FALTANTES.md, línea 150
- rcp_adulto_paso_a_paso.png | /assets/infografias/bloque-4-rcp/rcp_adulto_paso_a_paso.png | /home/planetazuzu/guia-tes/docs/MEDIOS_AUDIOVISUALES_FALTANTES.md, línea 161
- rcp_adulto_paso_a_paso.png | /assets/infografias/bloque-4-rcp/rcp_adulto_paso_a_paso.png | /home/planetazuzu/guia-tes/docs/MEDIOS_AUDIOVISUALES_FALTANTES.md, línea 172
- rcp_pediatrica_paso_a_paso.png | public/assets/infografias/bloque-4-rcp/rcp_pediatrica_paso_a_paso.png | /home/planetazuzu/guia-tes/docs/MEDIOS_AUDIOVISUALES_FALTANTES.md, línea 177
- rcp_lactantes_paso_a_paso.png | public/assets/infografias/bloque-4-rcp/rcp_lactantes_paso_a_paso.png | /home/planetazuzu/guia-tes/docs/MEDIOS_AUDIOVISUALES_FALTANTES.md, línea 185
- diagrama_uso_desa.png | public/assets/infografias/bloque-4-rcp/diagrama_uso_desa.png | /home/planetazuzu/guia-tes/docs/MEDIOS_AUDIOVISUALES_FALTANTES.md, línea 193
- diagrama_uso_desa.png | diagrama_uso_desa.png | /home/planetazuzu/guia-tes/docs/MEDIOS_AUDIOVISUALES_FALTANTES.md, línea 198
- ovace_adulto.png | public/assets/infografias/bloque-4-rcp/ovace_adulto.png | /home/planetazuzu/guia-tes/docs/MEDIOS_AUDIOVISUALES_FALTANTES.md, línea 205
- ovace_adulto.png | ovace_adulto.png | /home/planetazuzu/guia-tes/docs/MEDIOS_AUDIOVISUALES_FALTANTES.md, línea 210
- ovace_pediatrica.png | public/assets/infografias/bloque-4-rcp/ovace_pediatrica.png | /home/planetazuzu/guia-tes/docs/MEDIOS_AUDIOVISUALES_FALTANTES.md, línea 217
- ovace_lactantes.png | public/assets/infografias/bloque-4-rcp/ovace_lactantes.png | /home/planetazuzu/guia-tes/docs/MEDIOS_AUDIOVISUALES_FALTANTES.md, línea 225
- flujo_rcp_adulto_telefono.svg | flujo_rcp_adulto_telefono.svg | /home/planetazuzu/guia-tes/docs/MEDIOS_AUDIOVISUALES_FALTANTES.md, línea 250
- rcp_posicion_manos_adulto.png | .supabase.co/storage/v1/object/public/infografias/rcp/rcp_posicion_manos_adulto.png | /home/planetazuzu/guia-tes/docs/CONTENT_PACK_SPEC.md, línea 361
- rcp_posicion_manos_adulto.png | rcp_posicion_manos_adulto.png | /home/planetazuzu/guia-tes/docs/CONTENT_PACK_SPEC.md, línea 362
- rcp_posicion_manos_adulto.png | /assets/infografias/rcp/rcp_posicion_manos_adulto.png | /home/planetazuzu/guia-tes/docs/CONTENT_PACK_SPEC.md, línea 363
- rcp_profundidad_compresiones.png | .supabase.co/storage/v1/object/public/infografias/rcp/rcp_profundidad_compresiones.png | /home/planetazuzu/guia-tes/docs/CONTENT_PACK_SPEC.md, línea 384
- rcp_profundidad_compresiones.png | rcp_profundidad_compresiones.png | /home/planetazuzu/guia-tes/docs/CONTENT_PACK_SPEC.md, línea 385
- rcp_profundidad_compresiones.png | /assets/infografias/rcp/rcp_profundidad_compresiones.png | /home/planetazuzu/guia-tes/docs/CONTENT_PACK_SPEC.md, línea 386
- rcp_adulto_svb.mp4 | .supabase.co/storage/v1/object/public/videos/rcp/rcp_adulto_svb.mp4 | /home/planetazuzu/guia-tes/docs/CONTENT_PACK_SPEC.md, línea 407
- rcp_adulto_svb_thumb.jpg | .supabase.co/storage/v1/object/public/videos/rcp/rcp_adulto_svb_thumb.jpg | /home/planetazuzu/guia-tes/docs/CONTENT_PACK_SPEC.md, línea 408
- rcp_adulto_svb.mp4 | rcp_adulto_svb.mp4 | /home/planetazuzu/guia-tes/docs/CONTENT_PACK_SPEC.md, línea 409
- rcp_adulto_svb.mp4 | /assets/videos/rcp/rcp_adulto_svb.mp4 | /home/planetazuzu/guia-tes/docs/CONTENT_PACK_SPEC.md, línea 410
- ABCDE.png | ABCDE.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 60
- ABCDE.png | ABCDE.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 61
- ABCDE.png | ABCDE.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 415
- ABCDE.png | ABCDE.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 416
- ABCDE.png | ABCDE.png | /home/planetazuzu/guia-tes/docs/SEMANA_2_COMPLETADA.md, línea 42
- rcp_page.png | rcp_page.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MODERNIZACION_TECNOLOGICA.md, línea 946
- diagrama_abcde_paso_a_paso_completo.svg | bloque-0-fundamentos/diagrama_abcde_paso_a_paso_completo.svg | /home/planetazuzu/guia-tes/docs/MEDIOS_FALTANTES_TABLA_RAPIDA.md, línea 12
- rcp_adulto_paso_a_paso.png | bloque-4-rcp/rcp_adulto_paso_a_paso.png | /home/planetazuzu/guia-tes/docs/MEDIOS_FALTANTES_TABLA_RAPIDA.md, línea 20
- rcp_pediatrica_paso_a_paso.png | bloque-4-rcp/rcp_pediatrica_paso_a_paso.png | /home/planetazuzu/guia-tes/docs/MEDIOS_FALTANTES_TABLA_RAPIDA.md, línea 21
- rcp_lactantes_paso_a_paso.png | bloque-4-rcp/rcp_lactantes_paso_a_paso.png | /home/planetazuzu/guia-tes/docs/MEDIOS_FALTANTES_TABLA_RAPIDA.md, línea 22
- diagrama_uso_desa.png | bloque-4-rcp/diagrama_uso_desa.png | /home/planetazuzu/guia-tes/docs/MEDIOS_FALTANTES_TABLA_RAPIDA.md, línea 23
- ovace_adulto.png | bloque-4-rcp/ovace_adulto.png | /home/planetazuzu/guia-tes/docs/MEDIOS_FALTANTES_TABLA_RAPIDA.md, línea 24
- ovace_pediatrica.png | bloque-4-rcp/ovace_pediatrica.png | /home/planetazuzu/guia-tes/docs/MEDIOS_FALTANTES_TABLA_RAPIDA.md, línea 25
- ovace_lactantes.png | bloque-4-rcp/ovace_lactantes.png | /home/planetazuzu/guia-tes/docs/MEDIOS_FALTANTES_TABLA_RAPIDA.md, línea 26
- rcp_posicion_manos_adulto.png | rcp_posicion_manos_adulto.png | /home/planetazuzu/guia-tes/docs/PLAN_TECNICO_SISTEMA_CONTENIDO.md, línea 98
- rcp_posicion_manos_adulto.png | rcp_posicion_manos_adulto.png | /home/planetazuzu/guia-tes/docs/PLAN_TECNICO_SISTEMA_CONTENIDO.md, línea 666
- rcp_adulto_svb.mp4 | rcp_adulto_svb.mp4 | /home/planetazuzu/guia-tes/docs/PLAN_TECNICO_SISTEMA_CONTENIDO.md, línea 669
- ABCDE.png | ABCDE.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 59
- ABCDE.png | ABCDE.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 89
- priorizacion_vital_enfoque_abcde.png | priorizacion_vital_enfoque_abcde.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 89
- seleccion_talla_collarin_2.png | public/assets/infografias/bloque-2-inmovilizacion/seleccion_talla_collarin_2.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 191
- algoritmo_rcp_comentado.svg | /assets/infografias/bloque-4-rcp/algoritmo_rcp_comentado.svg | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 231
- compresiones_incorrectas.png | /assets/infografias/bloque-4-rcp/compresiones_incorrectas.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 232
- compresiones_correctas.png | /assets/infografias/bloque-4-rcp/compresiones_correctas.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 233
- descompresion_incompleta.png | /assets/infografias/bloque-4-rcp/descompresion_incompleta.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 234
- descompresion_completa.png | /assets/infografias/bloque-4-rcp/descompresion_completa.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 235
- resumen_rcp_adulto_svb.png | /assets/infografias/bloque-4-rcp/resumen_rcp_adulto_svb.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 236
- rcp_posicion_manos_adulto.png | .supabase.co/storage/v1/object/public/infografias/rcp/rcp_posicion_manos_adulto.png | /home/planetazuzu/guia-tes/docs/CONTENT_MODEL.md, línea 225
- rcp_posicion_manos_adulto.png | rcp_posicion_manos_adulto.png | /home/planetazuzu/guia-tes/docs/CONTENT_MODEL.md, línea 226
- rcp_posicion_manos_adulto.png | /assets/infografias/rcp/rcp_posicion_manos_adulto.png | /home/planetazuzu/guia-tes/docs/CONTENT_MODEL.md, línea 227
- rcp_adulto_svb.mp4 | .supabase.co/storage/v1/object/public/videos/rcp/rcp_adulto_svb.mp4 | /home/planetazuzu/guia-tes/docs/CONTENT_MODEL.md, línea 249
- rcp_adulto_svb_thumb.jpg | .supabase.co/storage/v1/object/public/videos/rcp/rcp_adulto_svb_thumb.jpg | /home/planetazuzu/guia-tes/docs/CONTENT_MODEL.md, línea 250
- rcp_adulto_svb.mp4 | rcp_adulto_svb.mp4 | /home/planetazuzu/guia-tes/docs/CONTENT_MODEL.md, línea 251
- rcp_adulto_svb.mp4 | /assets/videos/rcp/rcp_adulto_svb.mp4 | /home/planetazuzu/guia-tes/docs/CONTENT_MODEL.md, línea 252
- rcp_posicion_manos_adulto.png | rcp_posicion_manos_adulto.png | /home/planetazuzu/guia-tes/docs/API_ENDPOINTS_ESPECIFICACION.md, línea 504
- rcp_posicion_manos_adulto.png | /media/images/rcp/rcp_posicion_manos_adulto.png | /home/planetazuzu/guia-tes/docs/API_ENDPOINTS_ESPECIFICACION.md, línea 505
- introduccion_rcp_adulto_svb.png | /assets/infografias/bloque-4-rcp/introduccion_rcp_adulto_svb.png | /home/planetazuzu/guia-tes/docs/consolidado/SECCION_01_RCP_ADULTO_SVB.md, línea 41
- introduccion_rcp_adulto_svb.png | /assets/infografias/bloque-4-rcp/introduccion_rcp_adulto_svb.png | /home/planetazuzu/guia-tes/docs/consolidado/SECCION_01_RCP_ADULTO_SVB.md, línea 41
## Medios con ambigüedad (revisión manual)
- imagen_presion_directa.jpg | imagen_presion_directa.jpg | /home/planetazuzu/guia-tes/docs/DISENO_FUNCIONAL_PANEL_ADMINISTRACION.md, línea 247
- tabla_rangos_normales_constantes_vitales.png | public/assets/infografias/bloque-0-fundamentos/tabla_rangos_normales_constantes_vitales.png | /home/planetazuzu/guia-tes/docs/MEDIOS_AUDIOVISUALES_FALTANTES.md, línea 23
- tabla_rangos_normales_constantes_vitales.png | tabla_rangos_normales_constantes_vitales.png | /home/planetazuzu/guia-tes/docs/MEDIOS_AUDIOVISUALES_FALTANTES.md, línea 28
- tabla_rangos_normales_constantes_vitales.png | /assets/infografias/bloque-0-fundamentos/tabla_rangos_normales_constantes_vitales.png | /home/planetazuzu/guia-tes/docs/MEDIOS_AUDIOVISUALES_FALTANTES.md, línea 43
- tabla_escala_glasgow.png | public/assets/infografias/bloque-0-fundamentos/tabla_escala_glasgow.png | /home/planetazuzu/guia-tes/docs/MEDIOS_AUDIOVISUALES_FALTANTES.md, línea 70
- tabla_escala_glasgow.png | tabla_escala_glasgow.png | /home/planetazuzu/guia-tes/docs/MEDIOS_AUDIOVISUALES_FALTANTES.md, línea 75
- diagrama_start_completo.svg | public/assets/infografias/bloque-0-fundamentos/diagrama_start_completo.svg | /home/planetazuzu/guia-tes/docs/MEDIOS_AUDIOVISUALES_FALTANTES.md, línea 82
- guia_inmovilizacion_manual.png | public/assets/infografias/bloque-2-inmovilizacion/guia_inmovilizacion_manual.png | /home/planetazuzu/guia-tes/docs/MEDIOS_AUDIOVISUALES_FALTANTES.md, línea 94
- guia_inmovilizacion_manual.png | guia_inmovilizacion_manual.png | /home/planetazuzu/guia-tes/docs/MEDIOS_AUDIOVISUALES_FALTANTES.md, línea 99
- diagrama_uso_tablero_espinal.png | public/assets/infografias/bloque-2-inmovilizacion/diagrama_uso_tablero_espinal.png | /home/planetazuzu/guia-tes/docs/MEDIOS_AUDIOVISUALES_FALTANTES.md, línea 106
- infografia_transferencias_seguras.png | public/assets/infografias/bloque-2-inmovilizacion/infografia_transferencias_seguras.png | /home/planetazuzu/guia-tes/docs/MEDIOS_AUDIOVISUALES_FALTANTES.md, línea 114
- guia_aspiracion.png | public/assets/infografias/bloque-3-material-sanitario/guia_aspiracion.png | /home/planetazuzu/guia-tes/docs/MEDIOS_AUDIOVISUALES_FALTANTES.md, línea 126
- guia_aspiracion.png | guia_aspiracion.png | /home/planetazuzu/guia-tes/docs/MEDIOS_AUDIOVISUALES_FALTANTES.md, línea 131
- organizacion_maletin.png | public/assets/infografias/bloque-3-material-sanitario/organizacion_maletin.png | /home/planetazuzu/guia-tes/docs/MEDIOS_AUDIOVISUALES_FALTANTES.md, línea 138
- rcp_adulto_paso_a_paso.png | rcp_adulto_paso_a_paso.png | /home/planetazuzu/guia-tes/docs/MEDIOS_AUDIOVISUALES_FALTANTES.md, línea 155
- farmacologia_basica_visual.png | public/assets/infografias/bloque-6-farmacologia/farmacologia_basica_visual.png | /home/planetazuzu/guia-tes/docs/MEDIOS_AUDIOVISUALES_FALTANTES.md, línea 260
- farmacologia_basica_visual.png | farmacologia_basica_visual.png | /home/planetazuzu/guia-tes/docs/MEDIOS_AUDIOVISUALES_FALTANTES.md, línea 267
- tabla_dosis_pediatricas.png | public/assets/infografias/bloque-6-farmacologia/tabla_dosis_pediatricas.png | /home/planetazuzu/guia-tes/docs/MEDIOS_AUDIOVISUALES_FALTANTES.md, línea 273
- vias_administracion.png | public/assets/infografias/bloque-6-farmacologia/vias_administracion.png | /home/planetazuzu/guia-tes/docs/MEDIOS_AUDIOVISUALES_FALTANTES.md, línea 282
- tema_descripcion.png | tema_descripcion.png | /home/planetazuzu/guia-tes/docs/MEDIOS_AUDIOVISUALES_FALTANTES.md, línea 320
- ABCDE_PIRAMIDE_PRIORIDAD_VITAL.png | /assets/infografias/bloque-0-fundamentos/ABCDE_PIRAMIDE_PRIORIDAD_VITAL.png | /home/planetazuzu/guia-tes/docs/INSTRUCCIONES_SUBIR_MEDIOS_VISUALES.md, línea 163
- ABCDE_COMPARACION_DESORDEN_VS_ESTRUCTURA.png | /assets/infografias/bloque-0-fundamentos/ABCDE_COMPARACION_DESORDEN_VS_ESTRUCTURA.png | /home/planetazuzu/guia-tes/docs/INSTRUCCIONES_SUBIR_MEDIOS_VISUALES.md, línea 171
- ABCDE_FLUJO_DETERIORO_FISIOLOGICO.png | /assets/infografias/bloque-0-fundamentos/ABCDE_FLUJO_DETERIORO_FISIOLOGICO.png | /home/planetazuzu/guia-tes/docs/INSTRUCCIONES_SUBIR_MEDIOS_VISUALES.md, línea 179
- ABCDE_PIRAMIDE_PRIORIDAD_VITAL.png | public/assets/infografias/bloque-0-fundamentos/ABCDE_PIRAMIDE_PRIORIDAD_VITAL.png | /home/planetazuzu/guia-tes/docs/INSTRUCCIONES_SUBIR_MEDIOS_VISUALES.md, línea 40
- ABCDE_PIRAMIDE_PRIORIDAD_VITAL.png | ABCDE_PIRAMIDE_PRIORIDAD_VITAL.png | /home/planetazuzu/guia-tes/docs/INSTRUCCIONES_SUBIR_MEDIOS_VISUALES.md, línea 44
- ABCDE_PIRAMIDE_PRIORIDAD_VITAL.png | public/assets/infografias/bloque-0-fundamentos/ABCDE_PIRAMIDE_PRIORIDAD_VITAL.png | /home/planetazuzu/guia-tes/docs/INSTRUCCIONES_SUBIR_MEDIOS_VISUALES.md, línea 51
- ABCDE_PIRAMIDE_PRIORIDAD_VITAL.png | ABCDE_PIRAMIDE_PRIORIDAD_VITAL.png | /home/planetazuzu/guia-tes/docs/INSTRUCCIONES_SUBIR_MEDIOS_VISUALES.md, línea 97
- ABCDE_PIRAMIDE_PRIORIDAD_VITAL.svg | ABCDE_PIRAMIDE_PRIORIDAD_VITAL.svg | /home/planetazuzu/guia-tes/docs/INSTRUCCIONES_SUBIR_MEDIOS_VISUALES.md, línea 101
- ABCDE_COMPARACION_DESORDEN_VS_ESTRUCTURA.png | ABCDE_COMPARACION_DESORDEN_VS_ESTRUCTURA.png | /home/planetazuzu/guia-tes/docs/INSTRUCCIONES_SUBIR_MEDIOS_VISUALES.md, línea 106
- ABCDE_COMPARACION_DESORDEN_VS_ESTRUCTURA.svg | ABCDE_COMPARACION_DESORDEN_VS_ESTRUCTURA.svg | /home/planetazuzu/guia-tes/docs/INSTRUCCIONES_SUBIR_MEDIOS_VISUALES.md, línea 110
- ABCDE_FLUJO_DETERIORO_FISIOLOGICO.png | ABCDE_FLUJO_DETERIORO_FISIOLOGICO.png | /home/planetazuzu/guia-tes/docs/INSTRUCCIONES_SUBIR_MEDIOS_VISUALES.md, línea 115
- ABCDE_FLUJO_DETERIORO_FISIOLOGICO.svg | ABCDE_FLUJO_DETERIORO_FISIOLOGICO.svg | /home/planetazuzu/guia-tes/docs/INSTRUCCIONES_SUBIR_MEDIOS_VISUALES.md, línea 119
- .png | .png | /home/planetazuzu/guia-tes/docs/INSTRUCCIONES_SUBIR_MEDIOS_VISUALES.md, línea 127
- ABCDE_PIRAMIDE_PRIORIDAD_VITAL.png | /assets/infografias/bloque-0-fundamentos/ABCDE_PIRAMIDE_PRIORIDAD_VITAL.png | /home/planetazuzu/guia-tes/docs/INSTRUCCIONES_SUBIR_MEDIOS_VISUALES.md, línea 163
- ABCDE_COMPARACION_DESORDEN_VS_ESTRUCTURA.png | /assets/infografias/bloque-0-fundamentos/ABCDE_COMPARACION_DESORDEN_VS_ESTRUCTURA.png | /home/planetazuzu/guia-tes/docs/INSTRUCCIONES_SUBIR_MEDIOS_VISUALES.md, línea 171
- ABCDE_FLUJO_DETERIORO_FISIOLOGICO.png | /assets/infografias/bloque-0-fundamentos/ABCDE_FLUJO_DETERIORO_FISIOLOGICO.png | /home/planetazuzu/guia-tes/docs/INSTRUCCIONES_SUBIR_MEDIOS_VISUALES.md, línea 179
- ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.svg | /assets/infografias/bloque-0-fundamentos/ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.svg | /home/planetazuzu/guia-tes/docs/VERIFICACION_RUTAS_ASSETS_BLOQUE_0.md, línea 133
- ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.svg | ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.svg | /home/planetazuzu/guia-tes/docs/VERIFICACION_RUTAS_ASSETS_BLOQUE_0.md, línea 26
- TES.svg | TES.svg | /home/planetazuzu/guia-tes/docs/VERIFICACION_RUTAS_ASSETS_BLOQUE_0.md, línea 27
- diagrama_seleccion_dispositivo_oxigenoterapia.png | diagrama_seleccion_dispositivo_oxigenoterapia.png | /home/planetazuzu/guia-tes/docs/VERIFICACION_RUTAS_ASSETS_BLOQUE_0.md, línea 28
- fast_transtelefonico.png | fast_transtelefonico.png | /home/planetazuzu/guia-tes/docs/VERIFICACION_RUTAS_ASSETS_BLOQUE_0.md, línea 29
- flujo_desa_telefono.png | flujo_desa_telefono.png | /home/planetazuzu/guia-tes/docs/VERIFICACION_RUTAS_ASSETS_BLOQUE_0.md, línea 30
- flujo_rcp_transtelefonica.png | flujo_rcp_transtelefonica.png | /home/planetazuzu/guia-tes/docs/VERIFICACION_RUTAS_ASSETS_BLOQUE_0.md, línea 31
- guia_colocacion_dispositivos_oxigenoterapia.png | guia_colocacion_dispositivos_oxigenoterapia.png | /home/planetazuzu/guia-tes/docs/VERIFICACION_RUTAS_ASSETS_BLOQUE_0.md, línea 32
- START.svg | START.svg | /home/planetazuzu/guia-tes/docs/VERIFICACION_RUTAS_ASSETS_BLOQUE_0.md, línea 33
- tabla_rangos_fio2_oxigenoterapia.png | tabla_rangos_fio2_oxigenoterapia.png | /home/planetazuzu/guia-tes/docs/VERIFICACION_RUTAS_ASSETS_BLOQUE_0.md, línea 34
- tabla_rangos_fio2_oxigenoterapia1.png | tabla_rangos_fio2_oxigenoterapia1.png | /home/planetazuzu/guia-tes/docs/VERIFICACION_RUTAS_ASSETS_BLOQUE_0.md, línea 35
- ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.svg | ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.svg | /home/planetazuzu/guia-tes/docs/VERIFICACION_RUTAS_ASSETS_BLOQUE_0.md, línea 46
- ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.png | ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.png | /home/planetazuzu/guia-tes/docs/VERIFICACION_RUTAS_ASSETS_BLOQUE_0.md, línea 49
- ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.png | /assets/infografias/bloque-0-fundamentos/ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.png | /home/planetazuzu/guia-tes/docs/VERIFICACION_RUTAS_ASSETS_BLOQUE_0.md, línea 61
- ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.svg | /assets/infografias/bloque-0-fundamentos/ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.svg | /home/planetazuzu/guia-tes/docs/VERIFICACION_RUTAS_ASSETS_BLOQUE_0.md, línea 64
- ABCDE_PIRAMIDE_PRIORIDAD_VITAL.png | ABCDE_PIRAMIDE_PRIORIDAD_VITAL.png | /home/planetazuzu/guia-tes/docs/VERIFICACION_RUTAS_ASSETS_BLOQUE_0.md, línea 79
- ABCDE_COMPARACION_DESORDEN_VS_ESTRUCTURA.png | ABCDE_COMPARACION_DESORDEN_VS_ESTRUCTURA.png | /home/planetazuzu/guia-tes/docs/VERIFICACION_RUTAS_ASSETS_BLOQUE_0.md, línea 80
- ABCDE_FLUJO_DETERIORO_FISIOLOGICO.png | ABCDE_FLUJO_DETERIORO_FISIOLOGICO.png | /home/planetazuzu/guia-tes/docs/VERIFICACION_RUTAS_ASSETS_BLOQUE_0.md, línea 81
- ABCDE_PIRAMIDE_PRIORIDAD_VITAL.png | /assets/infografias/bloque-0-fundamentos/ABCDE_PIRAMIDE_PRIORIDAD_VITAL.png | /home/planetazuzu/guia-tes/docs/VERIFICACION_RUTAS_ASSETS_BLOQUE_0.md, línea 84
- ABCDE_COMPARACION_DESORDEN_VS_ESTRUCTURA.png | /assets/infografias/bloque-0-fundamentos/ABCDE_COMPARACION_DESORDEN_VS_ESTRUCTURA.png | /home/planetazuzu/guia-tes/docs/VERIFICACION_RUTAS_ASSETS_BLOQUE_0.md, línea 85
- ABCDE_FLUJO_DETERIORO_FISIOLOGICO.png | /assets/infografias/bloque-0-fundamentos/ABCDE_FLUJO_DETERIORO_FISIOLOGICO.png | /home/planetazuzu/guia-tes/docs/VERIFICACION_RUTAS_ASSETS_BLOQUE_0.md, línea 86
- TES.svg | TES.svg | /home/planetazuzu/guia-tes/docs/VERIFICACION_RUTAS_ASSETS_BLOQUE_0.md, línea 95
- ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.png | /assets/infografias/bloque-0-fundamentos/ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.png | /home/planetazuzu/guia-tes/docs/VERIFICACION_RUTAS_ASSETS_BLOQUE_0.md, línea 96
- START.svg | START.svg | /home/planetazuzu/guia-tes/docs/VERIFICACION_RUTAS_ASSETS_BLOQUE_0.md, línea 97
- ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.svg | /assets/infografias/bloque-0-fundamentos/ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.svg | /home/planetazuzu/guia-tes/docs/VERIFICACION_RUTAS_ASSETS_BLOQUE_0.md, línea 121
- ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.svg | /assets/infografias/bloque-0-fundamentos/ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.svg | /home/planetazuzu/guia-tes/docs/VERIFICACION_RUTAS_ASSETS_BLOQUE_0.md, línea 133
- .png | .png | /home/planetazuzu/guia-tes/docs/VERIFICACION_RUTAS_ASSETS_BLOQUE_0.md, línea 147
- .png | .png | /home/planetazuzu/guia-tes/docs/VERIFICACION_RUTAS_ASSETS_BLOQUE_0.md, línea 148
- hemorragia_presion_directa.jpg | hemorragia_presion_directa.jpg | /home/planetazuzu/guia-tes/docs/FASE_C_MODELO_DATOS_CANONICO.md, línea 212
- ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.png | /assets/infografias/bloque-0-fundamentos/ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.png | /home/planetazuzu/guia-tes/docs/INSTRUCCIONES_SUBIR_INFOGRAFIA.md, línea 145
- ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.svg | /assets/infografias/bloque-0-fundamentos/ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.svg | /home/planetazuzu/guia-tes/docs/INSTRUCCIONES_SUBIR_INFOGRAFIA.md, línea 50
- ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.png | /assets/infografias/bloque-0-fundamentos/ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.png | /home/planetazuzu/guia-tes/docs/INSTRUCCIONES_SUBIR_INFOGRAFIA.md, línea 55
- ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.png | ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.png | /home/planetazuzu/guia-tes/docs/INSTRUCCIONES_SUBIR_INFOGRAFIA.md, línea 13
- ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.jpg | ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.jpg | /home/planetazuzu/guia-tes/docs/INSTRUCCIONES_SUBIR_INFOGRAFIA.md, línea 14
- ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.svg | ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.svg | /home/planetazuzu/guia-tes/docs/INSTRUCCIONES_SUBIR_INFOGRAFIA.md, línea 15
- ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL_V2.png | ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL_V2.png | /home/planetazuzu/guia-tes/docs/INSTRUCCIONES_SUBIR_INFOGRAFIA.md, línea 25
- ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.png | public/assets/infografias/bloque-0-fundamentos/ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.png | /home/planetazuzu/guia-tes/docs/INSTRUCCIONES_SUBIR_INFOGRAFIA.md, línea 35
- ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.svg | /assets/infografias/bloque-0-fundamentos/ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.svg | /home/planetazuzu/guia-tes/docs/INSTRUCCIONES_SUBIR_INFOGRAFIA.md, línea 50
- ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.png | /assets/infografias/bloque-0-fundamentos/ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.png | /home/planetazuzu/guia-tes/docs/INSTRUCCIONES_SUBIR_INFOGRAFIA.md, línea 55
- ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.png | /assets/infografias/bloque-0-fundamentos/ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.png | /home/planetazuzu/guia-tes/docs/INSTRUCCIONES_SUBIR_INFOGRAFIA.md, línea 70
- ABCDE_PIRAMIDE_PRIORIDAD_VITAL.png | /assets/infografias/bloque-0-fundamentos/ABCDE_PIRAMIDE_PRIORIDAD_VITAL.png | /home/planetazuzu/guia-tes/docs/MEDIOS_VISUALES_SECCION_2_ABCDE.md, línea 508
- ABCDE_COMPARACION_DESORDEN_VS_ESTRUCTURA.png | /assets/infografias/bloque-0-fundamentos/ABCDE_COMPARACION_DESORDEN_VS_ESTRUCTURA.png | /home/planetazuzu/guia-tes/docs/MEDIOS_VISUALES_SECCION_2_ABCDE.md, línea 510
- ABCDE_FLUJO_DETERIORO_FISIOLOGICO.png | /assets/infografias/bloque-0-fundamentos/ABCDE_FLUJO_DETERIORO_FISIOLOGICO.png | /home/planetazuzu/guia-tes/docs/MEDIOS_VISUALES_SECCION_2_ABCDE.md, línea 512
- ABCDE_PIRAMIDE_PRIORIDAD_VITAL.png | ABCDE_PIRAMIDE_PRIORIDAD_VITAL.png | /home/planetazuzu/guia-tes/docs/MEDIOS_VISUALES_SECCION_2_ABCDE.md, línea 497
- ABCDE_COMPARACION_DESORDEN_VS_ESTRUCTURA.png | ABCDE_COMPARACION_DESORDEN_VS_ESTRUCTURA.png | /home/planetazuzu/guia-tes/docs/MEDIOS_VISUALES_SECCION_2_ABCDE.md, línea 498
- ABCDE_FLUJO_DETERIORO_FISIOLOGICO.png | ABCDE_FLUJO_DETERIORO_FISIOLOGICO.png | /home/planetazuzu/guia-tes/docs/MEDIOS_VISUALES_SECCION_2_ABCDE.md, línea 499
- ABCDE_PIRAMIDE_PRIORIDAD_VITAL.png | /assets/infografias/bloque-0-fundamentos/ABCDE_PIRAMIDE_PRIORIDAD_VITAL.png | /home/planetazuzu/guia-tes/docs/MEDIOS_VISUALES_SECCION_2_ABCDE.md, línea 508
- ABCDE_COMPARACION_DESORDEN_VS_ESTRUCTURA.png | /assets/infografias/bloque-0-fundamentos/ABCDE_COMPARACION_DESORDEN_VS_ESTRUCTURA.png | /home/planetazuzu/guia-tes/docs/MEDIOS_VISUALES_SECCION_2_ABCDE.md, línea 510
- ABCDE_FLUJO_DETERIORO_FISIOLOGICO.png | /assets/infografias/bloque-0-fundamentos/ABCDE_FLUJO_DETERIORO_FISIOLOGICO.png | /home/planetazuzu/guia-tes/docs/MEDIOS_VISUALES_SECCION_2_ABCDE.md, línea 512
- ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.png | ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 5
- ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.png | /assets/infografias/bloque-0-fundamentos/ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 8
- ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-0-fundamentos/ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 9
- favicon.svg | favicon.svg | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 25
- favicon.svg | /home/planetazuzu/guia-tes/public/favicon.svg | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 26
- icon_512.png | icon_512.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 30
- icon_512.png | /home/planetazuzu/guia-tes/public/icon_512.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 31
- icon_512_maskable.png | icon_512_maskable.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 35
- icon_512_maskable.png | /home/planetazuzu/guia-tes/public/icon_512_maskable.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 36
- icon_192_maskable.png | icon_192_maskable.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 40
- icon_192_maskable.png | /home/planetazuzu/guia-tes/public/icon_192_maskable.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 41
- icon_192.png | icon_192.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 45
- icon_192.png | /home/planetazuzu/guia-tes/public/icon_192.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 46
- Emergencias.png | Emergencias.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 55
- Emergencias.png | Emergencias.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 56
- velocidad.png | velocidad.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 65
- velocidad.png | velocidad.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 66
- posicion_tes_inmovilizacion_manual.png | posicion_tes_inmovilizacion_manual.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 70
- posicion_tes_inmovilizacion_manual.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-2-inmovilizacion/posicion_tes_inmovilizacion_manual.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 71
- componentes_colchon_vacio.png | componentes_colchon_vacio.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 75
- componentes_colchon_vacio.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-2-inmovilizacion/componentes_colchon_vacio.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 76
- errores_frecuentes_collarin_cervical.png | errores_frecuentes_collarin_cervical.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 80
- errores_frecuentes_collarin_cervical.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-2-inmovilizacion/errores_frecuentes_collarin_cervical.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 81
- seleccion_talla_collarin_medicion_anatomica.png | seleccion_talla_collarin_medicion_anatomica.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 85
- seleccion_talla_collarin_medicion_anatomica.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-2-inmovilizacion/seleccion_talla_collarin_medicion_anatomica.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 86
- seleccion_talla_collarin_tabla_tallas.png | seleccion_talla_collarin_tabla_tallas.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 90
- seleccion_talla_collarin_tabla_tallas.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-2-inmovilizacion/seleccion_talla_collarin_tabla_tallas.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 91
- colocacion_collarin_paso_5_verificacion.png | colocacion_collarin_paso_5_verificacion.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 95
- colocacion_collarin_paso_5_verificacion.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-2-inmovilizacion/colocacion_collarin_paso_5_verificacion.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 96
- seleccion_talla_collarin_cervical1.png | seleccion_talla_collarin_cervical1.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 100
- seleccion_talla_collarin_cervical1.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-2-inmovilizacion/seleccion_talla_collarin_cervical1.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 101
- colocacion_collarin_paso_6_liberacion_controlada.png | colocacion_collarin_paso_6_liberacion_controlada.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 105
- colocacion_collarin_paso_6_liberacion_controlada.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-2-inmovilizacion/colocacion_collarin_paso_6_liberacion_controlada.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 106
- colocacion_collarin_paso_4_ajuste_cierres.png | colocacion_collarin_paso_4_ajuste_cierres.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 110
- colocacion_collarin_paso_4_ajuste_cierres.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-2-inmovilizacion/colocacion_collarin_paso_4_ajuste_cierres.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 111
- seleccion_talla_collarin_error_demasiado_grande.png | seleccion_talla_collarin_error_demasiado_grande.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 115
- seleccion_talla_collarin_error_demasiado_grande.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-2-inmovilizacion/seleccion_talla_collarin_error_demasiado_grande.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 116
- tecnica_sujecion_manual_cervical.png | tecnica_sujecion_manual_cervical.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 120
- tecnica_sujecion_manual_cervical.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-2-inmovilizacion/tecnica_sujecion_manual_cervical.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 121
- seleccion_talla_collarin_cervical.png | seleccion_talla_collarin_cervical.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 125
- seleccion_talla_collarin_cervical.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-2-inmovilizacion/seleccion_talla_collarin_cervical.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 126
- colocacion_collarin_paso_2_parte_posterior.png | colocacion_collarin_paso_2_parte_posterior.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 130
- colocacion_collarin_paso_2_parte_posterior.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-2-inmovilizacion/colocacion_collarin_paso_2_parte_posterior.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 131
- componentes_camilla_cuchara.png | componentes_camilla_cuchara.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 135
- componentes_camilla_cuchara.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-2-inmovilizacion/componentes_camilla_cuchara.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 136
- componentes_tablero_espinal.png | componentes_tablero_espinal.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 140
- componentes_tablero_espinal.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-2-inmovilizacion/componentes_tablero_espinal.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 141
- colocacion_colchon_vacio_paso_a_paso.png | colocacion_colchon_vacio_paso_a_paso.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 145
- colocacion_colchon_vacio_paso_a_paso.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-2-inmovilizacion/colocacion_colchon_vacio_paso_a_paso.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 146
- situaciones_que_requieren_inmovilizacion.png | situaciones_que_requieren_inmovilizacion.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 150
- situaciones_que_requieren_inmovilizacion.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-2-inmovilizacion/situaciones_que_requieren_inmovilizacion.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 151
- colocacion_collarin_paso_1_preparacion.png | colocacion_collarin_paso_1_preparacion.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 155
- colocacion_collarin_paso_1_preparacion.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-2-inmovilizacion/colocacion_collarin_paso_1_preparacion.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 156
- colocacion_collarin_paso_3_parte_anterior.png | colocacion_collarin_paso_3_parte_anterior.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 160
- colocacion_collarin_paso_3_parte_anterior.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-2-inmovilizacion/colocacion_collarin_paso_3_parte_anterior.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 161
- secuencia_transicion_inmovilizacion.png | secuencia_transicion_inmovilizacion.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 165
- secuencia_transicion_inmovilizacion.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-2-inmovilizacion/secuencia_transicion_inmovilizacion.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 166
- componentes_sistema_inmovilizacion.png | componentes_sistema_inmovilizacion.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 170
- componentes_sistema_inmovilizacion.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-2-inmovilizacion/componentes_sistema_inmovilizacion.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 171
- verificaciones_post_colocacion_collarin.png | verificaciones_post_colocacion_collarin.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 175
- verificaciones_post_colocacion_collarin.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-2-inmovilizacion/verificaciones_post_colocacion_collarin.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 176
- coordinacion_equipo_inmovilizacion.png | coordinacion_equipo_inmovilizacion.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 180
- coordinacion_equipo_inmovilizacion.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-2-inmovilizacion/coordinacion_equipo_inmovilizacion.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 181
- uso_correcto_pulsioximetro.png | uso_correcto_pulsioximetro.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 185
- uso_correcto_pulsioximetro.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-3-material-sanitario/uso_correcto_pulsioximetro.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 186
- configuracion_maxima_fio2_bolsa_mascarilla.png | configuracion_maxima_fio2_bolsa_mascarilla.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 190
- configuracion_maxima_fio2_bolsa_mascarilla.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-3-material-sanitario/configuracion_maxima_fio2_bolsa_mascarilla.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 191
- canulas_guedel_nasofaringea.png | canulas_guedel_nasofaringea.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 195
- canulas_guedel_nasofaringea.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-3-material-sanitario/canulas_guedel_nasofaringea.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 196
- uso_correcto_ambu.png | uso_correcto_ambu.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 200
- uso_correcto_ambu.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-3-material-sanitario/uso_correcto_ambu.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 201
- dispositivos_supragloticos_guia.png | dispositivos_supragloticos_guia.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 205
- dispositivos_supragloticos_guia.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-3-material-sanitario/dispositivos_supragloticos_guia.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 206
- uso_correcto_tensiometro.png | uso_correcto_tensiometro.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 210
- uso_correcto_tensiometro.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-3-material-sanitario/uso_correcto_tensiometro.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 211
- interpretacion_constantes_semaforo.png | interpretacion_constantes_semaforo.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 215
- interpretacion_constantes_semaforo.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-3-material-sanitario/interpretacion_constantes_semaforo.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 216
- ventilacion_medios_fortuna.png | ventilacion_medios_fortuna.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 220
- ventilacion_medios_fortuna.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-3-material-sanitario/ventilacion_medios_fortuna.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 221
- registro_constantes_vitales.png | registro_constantes_vitales.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 225
- registro_constantes_vitales.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-3-material-sanitario/registro_constantes_vitales.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 226
- configuracion_gps_antes_de_salir.png | configuracion_gps_antes_de_salir.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 230
- configuracion_gps_antes_de_salir.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-7-conduccion/configuracion_gps_antes_de_salir.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 231
- flujo_rcp_transtelefonica.png | flujo_rcp_transtelefonica.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 235
- flujo_rcp_transtelefonica.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-0-fundamentos/flujo_rcp_transtelefonica.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 236
- farmacologia_basica_visual.png | farmacologia_basica_visual.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 240
- farmacologia_basica_visual.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-0-fundamentos/farmacologia_basica_visual.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 241
- flujo_desa_telefono.png | flujo_desa_telefono.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 245
- flujo_desa_telefono.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-0-fundamentos/flujo_desa_telefono.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 246
- tabla_dosis_pediatricas.png | tabla_dosis_pediatricas.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 250
- tabla_dosis_pediatricas.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-0-fundamentos/tabla_dosis_pediatricas.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 251
- sistema_abcde_prioridades_emergencias.png | sistema_abcde_prioridades_emergencias.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 255
- sistema_abcde_prioridades_emergencias.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-0-fundamentos/sistema_abcde_prioridades_emergencias.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 256
- tabla_rangos_fio2_oxigenoterapia1.png | tabla_rangos_fio2_oxigenoterapia1.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 260
- tabla_rangos_fio2_oxigenoterapia1.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-0-fundamentos/tabla_rangos_fio2_oxigenoterapia1.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 261
- vias_administracion.png | vias_administracion.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 265
- vias_administracion.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-0-fundamentos/vias_administracion.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 266
- sistema_abcde_prioridades_emergencias.webp | sistema_abcde_prioridades_emergencias.webp | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 270
- sistema_abcde_prioridades_emergencias.webp | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-0-fundamentos/sistema_abcde_prioridades_emergencias.webp | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 271
- rcp_adulto_paso_a_paso.png | rcp_adulto_paso_a_paso.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 275
- rcp_adulto_paso_a_paso.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-0-fundamentos/rcp_adulto_paso_a_paso.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 276
- ovace_pediatrica.png | ovace_pediatrica.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 280
- ovace_pediatrica.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-0-fundamentos/ovace_pediatrica.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 281
- guia_colocacion_dispositivos_oxigenoterapia.png | guia_colocacion_dispositivos_oxigenoterapia.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 285
- guia_colocacion_dispositivos_oxigenoterapia.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-0-fundamentos/guia_colocacion_dispositivos_oxigenoterapia.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 286
- tabla_escala_glasgow.png | tabla_escala_glasgow.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 290
- tabla_escala_glasgow.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-0-fundamentos/tabla_escala_glasgow.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 291
- el_orden_importa_maeious_que_la_velocidad.png | el_orden_importa_maeious_que_la_velocidad.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 295
- el_orden_importa_maeious_que_la_velocidad.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-0-fundamentos/el_orden_importa_maeious_que_la_velocidad.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 296
- diagrama_flujo_start_triaje_es.svg | diagrama_flujo_start_triaje_es.svg | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 300
- diagrama_flujo_start_triaje_es.svg | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-0-fundamentos/diagrama_flujo_start_triaje_es.svg | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 301
- tabla_rangos_normales_constantes_vitales.png | tabla_rangos_normales_constantes_vitales.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 305
- tabla_rangos_normales_constantes_vitales.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-0-fundamentos/tabla_rangos_normales_constantes_vitales.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 306
- tabla_rangos_fio2_oxigenoterapia.png | tabla_rangos_fio2_oxigenoterapia.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 310
- tabla_rangos_fio2_oxigenoterapia.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-0-fundamentos/tabla_rangos_fio2_oxigenoterapia.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 311
- priorizaciaeioun_vital_el_enfoque_abcde.png | priorizaciaeioun_vital_el_enfoque_abcde.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 315
- priorizaciaeioun_vital_el_enfoque_abcde.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-0-fundamentos/priorizaciaeioun_vital_el_enfoque_abcde.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 316
- fast_transtelefonico.png | fast_transtelefonico.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 320
- fast_transtelefonico.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-0-fundamentos/fast_transtelefonico.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 321
- ovace_lactantes.png | ovace_lactantes.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 325
- ovace_lactantes.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-0-fundamentos/ovace_lactantes.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 326
- abcde_introduccion_estructura_mental.svg | abcde_introduccion_estructura_mental.svg | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 330
- abcde_introduccion_estructura_mental.svg | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-0-fundamentos/abcde_introduccion_estructura_mental.svg | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 331
- el_orden_importa_maeious_que_la_velocidad.webp | el_orden_importa_maeious_que_la_velocidad.webp | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 335
- el_orden_importa_maeious_que_la_velocidad.webp | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-0-fundamentos/el_orden_importa_maeious_que_la_velocidad.webp | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 336
- priorizaciaeioun_vital_el_enfoque_abcde.webp | priorizaciaeioun_vital_el_enfoque_abcde.webp | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 340
- priorizaciaeioun_vital_el_enfoque_abcde.webp | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-0-fundamentos/priorizaciaeioun_vital_el_enfoque_abcde.webp | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 341
- diagrama_seleccion_dispositivo_oxigenoterapia.png | diagrama_seleccion_dispositivo_oxigenoterapia.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 345
- diagrama_seleccion_dispositivo_oxigenoterapia.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-0-fundamentos/diagrama_seleccion_dispositivo_oxigenoterapia.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 346
- diagrama_decisiones_eticas_urgencias.png | diagrama_decisiones_eticas_urgencias.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 350
- diagrama_decisiones_eticas_urgencias.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-12-marco-legal/diagrama_decisiones_eticas_urgencias.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 351
- diagrama_decisiones_eticas.png | diagrama_decisiones_eticas.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 355
- diagrama_decisiones_eticas.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-12-marco-legal/diagrama_decisiones_eticas.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 356
- ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.png | ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 362
- ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.png | /assets/infografias/bloque-0-fundamentos/ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 363
- favicon.svg | favicon.svg | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 380
- favicon.svg | /home/planetazuzu/guia-tes/public/favicon.svg | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 381
- icon_512.png | icon_512.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 385
- icon_512.png | /home/planetazuzu/guia-tes/public/icon_512.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 386
- icon_512_maskable.png | icon_512_maskable.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 390
- icon_512_maskable.png | /home/planetazuzu/guia-tes/public/icon_512_maskable.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 391
- icon_192_maskable.png | icon_192_maskable.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 395
- icon_192_maskable.png | /home/planetazuzu/guia-tes/public/icon_192_maskable.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 396
- icon_192.png | icon_192.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 400
- icon_192.png | /home/planetazuzu/guia-tes/public/icon_192.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 401
- Emergencias.png | Emergencias.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 410
- Emergencias.png | Emergencias.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 411
- velocidad.png | velocidad.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 420
- velocidad.png | velocidad.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 421
- posicion_tes_inmovilizacion_manual.png | posicion_tes_inmovilizacion_manual.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 425
- posicion_tes_inmovilizacion_manual.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-2-inmovilizacion/posicion_tes_inmovilizacion_manual.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 426
- componentes_colchon_vacio.png | componentes_colchon_vacio.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 430
- componentes_colchon_vacio.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-2-inmovilizacion/componentes_colchon_vacio.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 431
- errores_frecuentes_collarin_cervical.png | errores_frecuentes_collarin_cervical.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 435
- errores_frecuentes_collarin_cervical.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-2-inmovilizacion/errores_frecuentes_collarin_cervical.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 436
- seleccion_talla_collarin_medicion_anatomica.png | seleccion_talla_collarin_medicion_anatomica.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 440
- seleccion_talla_collarin_medicion_anatomica.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-2-inmovilizacion/seleccion_talla_collarin_medicion_anatomica.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 441
- seleccion_talla_collarin_tabla_tallas.png | seleccion_talla_collarin_tabla_tallas.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 445
- seleccion_talla_collarin_tabla_tallas.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-2-inmovilizacion/seleccion_talla_collarin_tabla_tallas.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 446
- colocacion_collarin_paso_5_verificacion.png | colocacion_collarin_paso_5_verificacion.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 450
- colocacion_collarin_paso_5_verificacion.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-2-inmovilizacion/colocacion_collarin_paso_5_verificacion.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 451
- seleccion_talla_collarin_cervical1.png | seleccion_talla_collarin_cervical1.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 455
- seleccion_talla_collarin_cervical1.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-2-inmovilizacion/seleccion_talla_collarin_cervical1.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 456
- colocacion_collarin_paso_6_liberacion_controlada.png | colocacion_collarin_paso_6_liberacion_controlada.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 460
- colocacion_collarin_paso_6_liberacion_controlada.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-2-inmovilizacion/colocacion_collarin_paso_6_liberacion_controlada.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 461
- colocacion_collarin_paso_4_ajuste_cierres.png | colocacion_collarin_paso_4_ajuste_cierres.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 465
- colocacion_collarin_paso_4_ajuste_cierres.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-2-inmovilizacion/colocacion_collarin_paso_4_ajuste_cierres.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 466
- seleccion_talla_collarin_error_demasiado_grande.png | seleccion_talla_collarin_error_demasiado_grande.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 470
- seleccion_talla_collarin_error_demasiado_grande.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-2-inmovilizacion/seleccion_talla_collarin_error_demasiado_grande.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 471
- tecnica_sujecion_manual_cervical.png | tecnica_sujecion_manual_cervical.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 475
- tecnica_sujecion_manual_cervical.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-2-inmovilizacion/tecnica_sujecion_manual_cervical.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 476
- seleccion_talla_collarin_cervical.png | seleccion_talla_collarin_cervical.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 480
- seleccion_talla_collarin_cervical.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-2-inmovilizacion/seleccion_talla_collarin_cervical.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 481
- colocacion_collarin_paso_2_parte_posterior.png | colocacion_collarin_paso_2_parte_posterior.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 485
- colocacion_collarin_paso_2_parte_posterior.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-2-inmovilizacion/colocacion_collarin_paso_2_parte_posterior.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 486
- componentes_camilla_cuchara.png | componentes_camilla_cuchara.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 490
- componentes_camilla_cuchara.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-2-inmovilizacion/componentes_camilla_cuchara.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 491
- componentes_tablero_espinal.png | componentes_tablero_espinal.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 495
- componentes_tablero_espinal.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-2-inmovilizacion/componentes_tablero_espinal.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 496
- colocacion_colchon_vacio_paso_a_paso.png | colocacion_colchon_vacio_paso_a_paso.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 500
- colocacion_colchon_vacio_paso_a_paso.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-2-inmovilizacion/colocacion_colchon_vacio_paso_a_paso.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 501
- situaciones_que_requieren_inmovilizacion.png | situaciones_que_requieren_inmovilizacion.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 505
- situaciones_que_requieren_inmovilizacion.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-2-inmovilizacion/situaciones_que_requieren_inmovilizacion.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 506
- colocacion_collarin_paso_1_preparacion.png | colocacion_collarin_paso_1_preparacion.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 510
- colocacion_collarin_paso_1_preparacion.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-2-inmovilizacion/colocacion_collarin_paso_1_preparacion.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 511
- colocacion_collarin_paso_3_parte_anterior.png | colocacion_collarin_paso_3_parte_anterior.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 515
- colocacion_collarin_paso_3_parte_anterior.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-2-inmovilizacion/colocacion_collarin_paso_3_parte_anterior.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 516
- secuencia_transicion_inmovilizacion.png | secuencia_transicion_inmovilizacion.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 520
- secuencia_transicion_inmovilizacion.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-2-inmovilizacion/secuencia_transicion_inmovilizacion.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 521
- componentes_sistema_inmovilizacion.png | componentes_sistema_inmovilizacion.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 525
- componentes_sistema_inmovilizacion.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-2-inmovilizacion/componentes_sistema_inmovilizacion.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 526
- verificaciones_post_colocacion_collarin.png | verificaciones_post_colocacion_collarin.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 530
- verificaciones_post_colocacion_collarin.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-2-inmovilizacion/verificaciones_post_colocacion_collarin.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 531
- coordinacion_equipo_inmovilizacion.png | coordinacion_equipo_inmovilizacion.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 535
- coordinacion_equipo_inmovilizacion.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-2-inmovilizacion/coordinacion_equipo_inmovilizacion.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 536
- uso_correcto_pulsioximetro.png | uso_correcto_pulsioximetro.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 540
- uso_correcto_pulsioximetro.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-3-material-sanitario/uso_correcto_pulsioximetro.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 541
- configuracion_maxima_fio2_bolsa_mascarilla.png | configuracion_maxima_fio2_bolsa_mascarilla.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 545
- configuracion_maxima_fio2_bolsa_mascarilla.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-3-material-sanitario/configuracion_maxima_fio2_bolsa_mascarilla.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 546
- canulas_guedel_nasofaringea.png | canulas_guedel_nasofaringea.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 550
- canulas_guedel_nasofaringea.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-3-material-sanitario/canulas_guedel_nasofaringea.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 551
- uso_correcto_ambu.png | uso_correcto_ambu.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 555
- uso_correcto_ambu.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-3-material-sanitario/uso_correcto_ambu.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 556
- dispositivos_supragloticos_guia.png | dispositivos_supragloticos_guia.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 560
- dispositivos_supragloticos_guia.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-3-material-sanitario/dispositivos_supragloticos_guia.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 561
- uso_correcto_tensiometro.png | uso_correcto_tensiometro.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 565
- uso_correcto_tensiometro.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-3-material-sanitario/uso_correcto_tensiometro.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 566
- interpretacion_constantes_semaforo.png | interpretacion_constantes_semaforo.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 570
- interpretacion_constantes_semaforo.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-3-material-sanitario/interpretacion_constantes_semaforo.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 571
- ventilacion_medios_fortuna.png | ventilacion_medios_fortuna.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 575
- ventilacion_medios_fortuna.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-3-material-sanitario/ventilacion_medios_fortuna.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 576
- registro_constantes_vitales.png | registro_constantes_vitales.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 580
- registro_constantes_vitales.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-3-material-sanitario/registro_constantes_vitales.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 581
- configuracion_gps_antes_de_salir.png | configuracion_gps_antes_de_salir.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 585
- configuracion_gps_antes_de_salir.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-7-conduccion/configuracion_gps_antes_de_salir.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 586
- flujo_rcp_transtelefonica.png | flujo_rcp_transtelefonica.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 590
- flujo_rcp_transtelefonica.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-0-fundamentos/flujo_rcp_transtelefonica.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 591
- farmacologia_basica_visual.png | farmacologia_basica_visual.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 595
- farmacologia_basica_visual.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-0-fundamentos/farmacologia_basica_visual.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 596
- flujo_desa_telefono.png | flujo_desa_telefono.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 600
- flujo_desa_telefono.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-0-fundamentos/flujo_desa_telefono.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 601
- tabla_dosis_pediatricas.png | tabla_dosis_pediatricas.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 605
- tabla_dosis_pediatricas.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-0-fundamentos/tabla_dosis_pediatricas.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 606
- sistema_abcde_prioridades_emergencias.png | sistema_abcde_prioridades_emergencias.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 610
- sistema_abcde_prioridades_emergencias.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-0-fundamentos/sistema_abcde_prioridades_emergencias.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 611
- tabla_rangos_fio2_oxigenoterapia1.png | tabla_rangos_fio2_oxigenoterapia1.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 615
- tabla_rangos_fio2_oxigenoterapia1.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-0-fundamentos/tabla_rangos_fio2_oxigenoterapia1.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 616
- vias_administracion.png | vias_administracion.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 620
- vias_administracion.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-0-fundamentos/vias_administracion.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 621
- sistema_abcde_prioridades_emergencias.webp | sistema_abcde_prioridades_emergencias.webp | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 625
- sistema_abcde_prioridades_emergencias.webp | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-0-fundamentos/sistema_abcde_prioridades_emergencias.webp | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 626
- rcp_adulto_paso_a_paso.png | rcp_adulto_paso_a_paso.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 630
- rcp_adulto_paso_a_paso.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-0-fundamentos/rcp_adulto_paso_a_paso.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 631
- ovace_pediatrica.png | ovace_pediatrica.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 635
- ovace_pediatrica.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-0-fundamentos/ovace_pediatrica.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 636
- guia_colocacion_dispositivos_oxigenoterapia.png | guia_colocacion_dispositivos_oxigenoterapia.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 640
- guia_colocacion_dispositivos_oxigenoterapia.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-0-fundamentos/guia_colocacion_dispositivos_oxigenoterapia.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 641
- tabla_escala_glasgow.png | tabla_escala_glasgow.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 645
- tabla_escala_glasgow.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-0-fundamentos/tabla_escala_glasgow.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 646
- el_orden_importa_maeious_que_la_velocidad.png | el_orden_importa_maeious_que_la_velocidad.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 650
- el_orden_importa_maeious_que_la_velocidad.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-0-fundamentos/el_orden_importa_maeious_que_la_velocidad.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 651
- diagrama_flujo_start_triaje_es.svg | diagrama_flujo_start_triaje_es.svg | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 655
- diagrama_flujo_start_triaje_es.svg | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-0-fundamentos/diagrama_flujo_start_triaje_es.svg | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 656
- tabla_rangos_normales_constantes_vitales.png | tabla_rangos_normales_constantes_vitales.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 660
- tabla_rangos_normales_constantes_vitales.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-0-fundamentos/tabla_rangos_normales_constantes_vitales.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 661
- tabla_rangos_fio2_oxigenoterapia.png | tabla_rangos_fio2_oxigenoterapia.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 665
- tabla_rangos_fio2_oxigenoterapia.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-0-fundamentos/tabla_rangos_fio2_oxigenoterapia.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 666
- priorizaciaeioun_vital_el_enfoque_abcde.png | priorizaciaeioun_vital_el_enfoque_abcde.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 670
- priorizaciaeioun_vital_el_enfoque_abcde.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-0-fundamentos/priorizaciaeioun_vital_el_enfoque_abcde.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 671
- fast_transtelefonico.png | fast_transtelefonico.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 675
- fast_transtelefonico.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-0-fundamentos/fast_transtelefonico.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 676
- ovace_lactantes.png | ovace_lactantes.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 680
- ovace_lactantes.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-0-fundamentos/ovace_lactantes.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 681
- abcde_introduccion_estructura_mental.svg | abcde_introduccion_estructura_mental.svg | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 685
- abcde_introduccion_estructura_mental.svg | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-0-fundamentos/abcde_introduccion_estructura_mental.svg | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 686
- el_orden_importa_maeious_que_la_velocidad.webp | el_orden_importa_maeious_que_la_velocidad.webp | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 690
- el_orden_importa_maeious_que_la_velocidad.webp | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-0-fundamentos/el_orden_importa_maeious_que_la_velocidad.webp | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 691
- priorizaciaeioun_vital_el_enfoque_abcde.webp | priorizaciaeioun_vital_el_enfoque_abcde.webp | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 695
- priorizaciaeioun_vital_el_enfoque_abcde.webp | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-0-fundamentos/priorizaciaeioun_vital_el_enfoque_abcde.webp | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 696
- diagrama_seleccion_dispositivo_oxigenoterapia.png | diagrama_seleccion_dispositivo_oxigenoterapia.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 700
- diagrama_seleccion_dispositivo_oxigenoterapia.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-0-fundamentos/diagrama_seleccion_dispositivo_oxigenoterapia.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 701
- diagrama_decisiones_eticas_urgencias.png | diagrama_decisiones_eticas_urgencias.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 705
- diagrama_decisiones_eticas_urgencias.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-12-marco-legal/diagrama_decisiones_eticas_urgencias.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 706
- diagrama_decisiones_eticas.png | diagrama_decisiones_eticas.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 710
- diagrama_decisiones_eticas.png | /home/planetazuzu/guia-tes/public/assets/infografias/bloque-12-marco-legal/diagrama_decisiones_eticas.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 711
- hemorragia_presion_directa.jpg | hemorragia_presion_directa.jpg | /home/planetazuzu/guia-tes/docs/MODELO_DATOS_CANONICO_DEFINITIVO.md, línea 305
- velocidad.png | velocidad.png | /home/planetazuzu/guia-tes/docs/SEMANA_2_COMPLETADA.md, línea 41
- Emergencias.png | Emergencias.png | /home/planetazuzu/guia-tes/docs/SEMANA_2_COMPLETADA.md, línea 43
- TES.svg | TES.svg | /home/planetazuzu/guia-tes/docs/SEMANA_2_COMPLETADA.md, línea 65
- algoritmo_operativo_del_tes.svg | algoritmo_operativo_del_tes.svg | /home/planetazuzu/guia-tes/docs/SEMANA_2_COMPLETADA.md, línea 65
- START.svg | START.svg | /home/planetazuzu/guia-tes/docs/SEMANA_2_COMPLETADA.md, línea 66
- resumen_visual_del_algoritmo_start.svg | resumen_visual_del_algoritmo_start.svg | /home/planetazuzu/guia-tes/docs/SEMANA_2_COMPLETADA.md, línea 66
- ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.svg | ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.svg | /home/planetazuzu/guia-tes/docs/SEMANA_2_COMPLETADA.md, línea 67
- abcde_introduccion_estructura_mental.svg | abcde_introduccion_estructura_mental.svg | /home/planetazuzu/guia-tes/docs/SEMANA_2_COMPLETADA.md, línea 67
- ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.png | public/assets/infografias/bloque-0-fundamentos/ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.png | /home/planetazuzu/guia-tes/docs/PROXIMOS_PASOS_POST_INFOGRAFIA.md, línea 17
- tabla_rangos_normales_constantes_vitales.png | bloque-0-fundamentos/tabla_rangos_normales_constantes_vitales.png | /home/planetazuzu/guia-tes/docs/MEDIOS_FALTANTES_TABLA_RAPIDA.md, línea 11
- tabla_escala_glasgow.png | bloque-0-fundamentos/tabla_escala_glasgow.png | /home/planetazuzu/guia-tes/docs/MEDIOS_FALTANTES_TABLA_RAPIDA.md, línea 13
- diagrama_start_completo.svg | bloque-0-fundamentos/diagrama_start_completo.svg | /home/planetazuzu/guia-tes/docs/MEDIOS_FALTANTES_TABLA_RAPIDA.md, línea 14
- guia_inmovilizacion_manual.png | bloque-2-inmovilizacion/guia_inmovilizacion_manual.png | /home/planetazuzu/guia-tes/docs/MEDIOS_FALTANTES_TABLA_RAPIDA.md, línea 15
- diagrama_uso_tablero_espinal.png | bloque-2-inmovilizacion/diagrama_uso_tablero_espinal.png | /home/planetazuzu/guia-tes/docs/MEDIOS_FALTANTES_TABLA_RAPIDA.md, línea 16
- infografia_transferencias_seguras.png | bloque-2-inmovilizacion/infografia_transferencias_seguras.png | /home/planetazuzu/guia-tes/docs/MEDIOS_FALTANTES_TABLA_RAPIDA.md, línea 17
- guia_aspiracion.png | bloque-3-material-sanitario/guia_aspiracion.png | /home/planetazuzu/guia-tes/docs/MEDIOS_FALTANTES_TABLA_RAPIDA.md, línea 18
- organizacion_maletin.png | bloque-3-material-sanitario/organizacion_maletin.png | /home/planetazuzu/guia-tes/docs/MEDIOS_FALTANTES_TABLA_RAPIDA.md, línea 19
- farmacologia_basica_visual.png | bloque-6-farmacologia/farmacologia_basica_visual.png | /home/planetazuzu/guia-tes/docs/MEDIOS_FALTANTES_TABLA_RAPIDA.md, línea 28
- tabla_dosis_pediatricas.png | bloque-6-farmacologia/tabla_dosis_pediatricas.png | /home/planetazuzu/guia-tes/docs/MEDIOS_FALTANTES_TABLA_RAPIDA.md, línea 29
- vias_administracion.png | bloque-6-farmacologia/vias_administracion.png | /home/planetazuzu/guia-tes/docs/MEDIOS_FALTANTES_TABLA_RAPIDA.md, línea 30
- ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.svg | public/assets/infografias/bloque-0-fundamentos/ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.svg | /home/planetazuzu/guia-tes/docs/INFOGRAFIA_ABCDE_INTEGRADA.md, línea 13
- ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.svg | /assets/infografias/bloque-0-fundamentos/ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.svg | /home/planetazuzu/guia-tes/docs/INFOGRAFIA_ABCDE_INTEGRADA.md, línea 33
- ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.svg | public/assets/infografias/bloque-0-fundamentos/ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.svg | /home/planetazuzu/guia-tes/docs/INFOGRAFIA_ABCDE_INTEGRADA.md, línea 151
- 2.png | 2.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 40
- tabla_rangos_fio2_oxigenoterapia1.png | tabla_rangos_fio2_oxigenoterapia1.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 42
- 1.png | 1.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 42
- velocidad.png | velocidad.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 58
- Emergencias.png | Emergencias.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 60
- img.png | img.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 82
- img.png | img.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 84
- optimized.png | optimized.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 84
- velocidad.png | velocidad.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 88
- el_orden_importa_mas_que_la_velocidad.png | el_orden_importa_mas_que_la_velocidad.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 88
- Emergencias.png | Emergencias.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 90
- sistema_abcde_prioridades_emergencias.png | sistema_abcde_prioridades_emergencias.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 90
- 2.png | 2.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 173
- 1.png | 1.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 174
- 1.png | 1.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 175
- 1.png | 1.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 176
- 2.png | 2.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 181
- 1.png | 1.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 181
- 2.png | 2.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 190
- img.png | img.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 269
- tabla_rangos_fio2_oxigenoterapia.png | tabla_rangos_fio2_oxigenoterapia.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 285
- tabla_rangos_fio2_oxigenoterapia1.png | tabla_rangos_fio2_oxigenoterapia1.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 286
- seleccion_talla_collarin_cervical.png | seleccion_talla_collarin_cervical.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 287
- seleccion_talla_collarin_cervical1.png | seleccion_talla_collarin_cervical1.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 288
- 2.png | 2.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 289
- componentes_sistema_inmovilizacion.png | componentes_sistema_inmovilizacion.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 290
- 1.png | 1.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 291
- posicion_tes_inmovilizacion_manual.png | posicion_tes_inmovilizacion_manual.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 292
- 1.png | 1.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 293
- 1.png | 1.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 299
- 2.png | 2.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 299
- img.png | img.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 536
- imagen_400w.webp | /assets/infografias/imagen_400w.webp | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 703
- imagen_800w.webp | /assets/infografias/imagen_800w.webp | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 704
- imagen_1200w.webp | /assets/infografias/imagen_1200w.webp | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 705
- TES.svg | TES.svg | /home/planetazuzu/guia-tes/docs/consolidado/SISTEMA_MEDIOS_VISUALES.md, línea 17
- diagrama_seleccion_dispositivo_oxigenoterapia.png | diagrama_seleccion_dispositivo_oxigenoterapia.png | /home/planetazuzu/guia-tes/docs/consolidado/SISTEMA_MEDIOS_VISUALES.md, línea 18
- colocacion_collarin_paso_1_preparacion.png | colocacion_collarin_paso_1_preparacion.png | /home/planetazuzu/guia-tes/docs/consolidado/SISTEMA_MEDIOS_VISUALES.md, línea 21
- seleccion_talla_collarin_cervical.png | seleccion_talla_collarin_cervical.png | /home/planetazuzu/guia-tes/docs/consolidado/SISTEMA_MEDIOS_VISUALES.md, línea 22
- canulas_guedel_nasofaringea.png | canulas_guedel_nasofaringea.png | /home/planetazuzu/guia-tes/docs/consolidado/SISTEMA_MEDIOS_VISUALES.md, línea 25
- canulas_guedel_nasofaringea.png | /assets/infografias/bloque-3-material-sanitario/canulas_guedel_nasofaringea.png | /home/planetazuzu/guia-tes/docs/consolidado/SISTEMA_MEDIOS_VISUALES.md, línea 41
- TES.svg | TES.svg | /home/planetazuzu/guia-tes/docs/consolidado/SISTEMA_MEDIOS_VISUALES.md, línea 104
- seleccion_talla_collarin_cervical.png | /assets/infografias/bloque-2-inmovilizacion/seleccion_talla_collarin_cervical.png | /home/planetazuzu/guia-tes/docs/consolidado/SISTEMA_MEDIOS_VISUALES.md, línea 350
- ABCDE_ALGORITMO_COMPLETO.png | /assets/infografias/bloque-0-fundamentos/ABCDE_ALGORITMO_COMPLETO.png | /home/planetazuzu/guia-tes/docs/consolidado/SECCION_03_ABCDE_OPERATIVO.md, línea 11
- ABCDE_ALGORITMO_COMPLETO.png | /assets/infografias/bloque-0-fundamentos/ABCDE_ALGORITMO_COMPLETO.png | /home/planetazuzu/guia-tes/docs/consolidado/SECCION_03_ABCDE_OPERATIVO.md, línea 11
- ABCDE_IMAGEN_01_ESCANEO_INICIAL.jpg | /assets/infografias/bloque-0-fundamentos/ABCDE_IMAGEN_01_ESCANEO_INICIAL.jpg | /home/planetazuzu/guia-tes/docs/consolidado/SECCION_04_ABCDE_OPERATIVO.md, línea 107
- ABCDE_IMAGEN_02_PRIORIDAD_VITAL.jpg | /assets/infografias/bloque-0-fundamentos/ABCDE_IMAGEN_02_PRIORIDAD_VITAL.jpg | /home/planetazuzu/guia-tes/docs/consolidado/SECCION_04_ABCDE_OPERATIVO.md, línea 124
- ABCDE_IMAGEN_03_TRANSICION_CONTROLADO.jpg | /assets/infografias/bloque-0-fundamentos/ABCDE_IMAGEN_03_TRANSICION_CONTROLADO.jpg | /home/planetazuzu/guia-tes/docs/consolidado/SECCION_04_ABCDE_OPERATIVO.md, línea 141
- ABCDE_IMAGEN_04_REEVALUACION_CICLO.jpg | /assets/infografias/bloque-0-fundamentos/ABCDE_IMAGEN_04_REEVALUACION_CICLO.jpg | /home/planetazuzu/guia-tes/docs/consolidado/SECCION_04_ABCDE_OPERATIVO.md, línea 158
- ABCDE_IMAGEN_05_VISION_GLOBAL.jpg | /assets/infografias/bloque-0-fundamentos/ABCDE_IMAGEN_05_VISION_GLOBAL.jpg | /home/planetazuzu/guia-tes/docs/consolidado/SECCION_04_ABCDE_OPERATIVO.md, línea 175
- ABCDE_IMAGEN_01_ESCANEO_INICIAL.jpg | /assets/infografias/bloque-0-fundamentos/ABCDE_IMAGEN_01_ESCANEO_INICIAL.jpg | /home/planetazuzu/guia-tes/docs/consolidado/SECCION_04_ABCDE_OPERATIVO.md, línea 107
- ABCDE_IMAGEN_02_PRIORIDAD_VITAL.jpg | /assets/infografias/bloque-0-fundamentos/ABCDE_IMAGEN_02_PRIORIDAD_VITAL.jpg | /home/planetazuzu/guia-tes/docs/consolidado/SECCION_04_ABCDE_OPERATIVO.md, línea 124
- ABCDE_IMAGEN_03_TRANSICION_CONTROLADO.jpg | /assets/infografias/bloque-0-fundamentos/ABCDE_IMAGEN_03_TRANSICION_CONTROLADO.jpg | /home/planetazuzu/guia-tes/docs/consolidado/SECCION_04_ABCDE_OPERATIVO.md, línea 141
- ABCDE_IMAGEN_04_REEVALUACION_CICLO.jpg | /assets/infografias/bloque-0-fundamentos/ABCDE_IMAGEN_04_REEVALUACION_CICLO.jpg | /home/planetazuzu/guia-tes/docs/consolidado/SECCION_04_ABCDE_OPERATIVO.md, línea 158
- ABCDE_IMAGEN_05_VISION_GLOBAL.jpg | /assets/infografias/bloque-0-fundamentos/ABCDE_IMAGEN_05_VISION_GLOBAL.jpg | /home/planetazuzu/guia-tes/docs/consolidado/SECCION_04_ABCDE_OPERATIVO.md, línea 175
- favicon.svg | favicon.svg | /home/planetazuzu/guia-tes/docs/consolidado/CHECKLIST_PWA_COMPLETA.md, línea 16
- icon_192.png | icon_192.png | /home/planetazuzu/guia-tes/docs/consolidado/CHECKLIST_PWA_COMPLETA.md, línea 16
- icon_512.png | icon_512.png | /home/planetazuzu/guia-tes/docs/consolidado/CHECKLIST_PWA_COMPLETA.md, línea 16
- icon_192.png | public/icon_192.png | /home/planetazuzu/guia-tes/docs/consolidado/CHECKLIST_PWA_COMPLETA.md, línea 62
- icon_512.png | public/icon_512.png | /home/planetazuzu/guia-tes/docs/consolidado/CHECKLIST_PWA_COMPLETA.md, línea 62
- icon_192_maskable.png | public/icon_192_maskable.png | /home/planetazuzu/guia-tes/docs/consolidado/CHECKLIST_PWA_COMPLETA.md, línea 62
- icon_512_maskable.png | public/icon_512_maskable.png | /home/planetazuzu/guia-tes/docs/consolidado/CHECKLIST_PWA_COMPLETA.md, línea 62
- ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.svg | /assets/infografias/bloque-0-fundamentos/ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.svg | /home/planetazuzu/guia-tes/docs/consolidado/SECCION_01_ABCDE_OPERATIVO.md, línea 61
- ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.svg | /assets/infografias/bloque-0-fundamentos/ABCDE_INTRODUCCION_ESTRUCTURA_MENTAL.svg | /home/planetazuzu/guia-tes/docs/consolidado/SECCION_01_ABCDE_OPERATIVO.md, línea 61
- ABCDE_ERROR_01_SALTARSE_LETRAS.png | /assets/infografias/bloque-0-fundamentos/ABCDE_ERROR_01_SALTARSE_LETRAS.png | /home/planetazuzu/guia-tes/docs/consolidado/SECCION_05_ABCDE_OPERATIVO.md, línea 21
- ABCDE_ERROR_02_ATASCARSE_LETRA.png | /assets/infografias/bloque-0-fundamentos/ABCDE_ERROR_02_ATASCARSE_LETRA.png | /home/planetazuzu/guia-tes/docs/consolidado/SECCION_05_ABCDE_OPERATIVO.md, línea 85
- ABCDE_ERROR_03_VISIBLE_SOBRE_VITAL.png | /assets/infografias/bloque-0-fundamentos/ABCDE_ERROR_03_VISIBLE_SOBRE_VITAL.png | /home/planetazuzu/guia-tes/docs/consolidado/SECCION_05_ABCDE_OPERATIVO.md, línea 149
- ABCDE_ERROR_04_NO_REEVALUAR.png | /assets/infografias/bloque-0-fundamentos/ABCDE_ERROR_04_NO_REEVALUAR.png | /home/planetazuzu/guia-tes/docs/consolidado/SECCION_05_ABCDE_OPERATIVO.md, línea 211
- ABCDE_ERROR_05_PERDER_VISION_GLOBAL.png | /assets/infografias/bloque-0-fundamentos/ABCDE_ERROR_05_PERDER_VISION_GLOBAL.png | /home/planetazuzu/guia-tes/docs/consolidado/SECCION_05_ABCDE_OPERATIVO.md, línea 275
- ABCDE_SINTESIS_ESTRUCTURA_PROTECCION.png | /assets/infografias/bloque-0-fundamentos/ABCDE_SINTESIS_ESTRUCTURA_PROTECCION.png | /home/planetazuzu/guia-tes/docs/consolidado/SECCION_05_ABCDE_OPERATIVO.md, línea 337
- ABCDE_ERROR_01_SALTARSE_LETRAS.png | /assets/infografias/bloque-0-fundamentos/ABCDE_ERROR_01_SALTARSE_LETRAS.png | /home/planetazuzu/guia-tes/docs/consolidado/SECCION_05_ABCDE_OPERATIVO.md, línea 21
- ABCDE_ERROR_02_ATASCARSE_LETRA.png | /assets/infografias/bloque-0-fundamentos/ABCDE_ERROR_02_ATASCARSE_LETRA.png | /home/planetazuzu/guia-tes/docs/consolidado/SECCION_05_ABCDE_OPERATIVO.md, línea 85
- ABCDE_ERROR_03_VISIBLE_SOBRE_VITAL.png | /assets/infografias/bloque-0-fundamentos/ABCDE_ERROR_03_VISIBLE_SOBRE_VITAL.png | /home/planetazuzu/guia-tes/docs/consolidado/SECCION_05_ABCDE_OPERATIVO.md, línea 149
- ABCDE_ERROR_04_NO_REEVALUAR.png | /assets/infografias/bloque-0-fundamentos/ABCDE_ERROR_04_NO_REEVALUAR.png | /home/planetazuzu/guia-tes/docs/consolidado/SECCION_05_ABCDE_OPERATIVO.md, línea 211
- ABCDE_ERROR_05_PERDER_VISION_GLOBAL.png | /assets/infografias/bloque-0-fundamentos/ABCDE_ERROR_05_PERDER_VISION_GLOBAL.png | /home/planetazuzu/guia-tes/docs/consolidado/SECCION_05_ABCDE_OPERATIVO.md, línea 275
- ABCDE_SINTESIS_ESTRUCTURA_PROTECCION.png | /assets/infografias/bloque-0-fundamentos/ABCDE_SINTESIS_ESTRUCTURA_PROTECCION.png | /home/planetazuzu/guia-tes/docs/consolidado/SECCION_05_ABCDE_OPERATIVO.md, línea 337
- favicon.svg | favicon.svg | /home/planetazuzu/guia-tes/docs/consolidado/ANALISIS_TECNOLOGICO_PROYECTO.md, línea 122
- ABCDE_RESUMEN_FLUJO_MENTAL_CONTINUO.png | /assets/infografias/bloque-0-fundamentos/ABCDE_RESUMEN_FLUJO_MENTAL_CONTINUO.png | /home/planetazuzu/guia-tes/docs/consolidado/SECCION_08_ABCDE_OPERATIVO.md, línea 10
- ABCDE_RESUMEN_FLUJO_MENTAL_CONTINUO.png | /assets/infografias/bloque-0-fundamentos/ABCDE_RESUMEN_FLUJO_MENTAL_CONTINUO.png | /home/planetazuzu/guia-tes/docs/consolidado/SECCION_08_ABCDE_OPERATIVO.md, línea 10
- ABCDE_PIRAMIDE_PRIORIDAD_VITAL.png | /assets/infografias/bloque-0-fundamentos/ABCDE_PIRAMIDE_PRIORIDAD_VITAL.png | /home/planetazuzu/guia-tes/docs/consolidado/SECCION_02_ABCDE_OPERATIVO.md, línea 93
- ABCDE_COMPARACION_DESORDEN_VS_ESTRUCTURA.png | /assets/infografias/bloque-0-fundamentos/ABCDE_COMPARACION_DESORDEN_VS_ESTRUCTURA.png | /home/planetazuzu/guia-tes/docs/consolidado/SECCION_02_ABCDE_OPERATIVO.md, línea 112
- ABCDE_FLUJO_DETERIORO_FISIOLOGICO.png | /assets/infografias/bloque-0-fundamentos/ABCDE_FLUJO_DETERIORO_FISIOLOGICO.png | /home/planetazuzu/guia-tes/docs/consolidado/SECCION_02_ABCDE_OPERATIVO.md, línea 143
- ABCDE_PIRAMIDE_PRIORIDAD_VITAL.png | /assets/infografias/bloque-0-fundamentos/ABCDE_PIRAMIDE_PRIORIDAD_VITAL.png | /home/planetazuzu/guia-tes/docs/consolidado/SECCION_02_ABCDE_OPERATIVO.md, línea 93
- ABCDE_COMPARACION_DESORDEN_VS_ESTRUCTURA.png | /assets/infografias/bloque-0-fundamentos/ABCDE_COMPARACION_DESORDEN_VS_ESTRUCTURA.png | /home/planetazuzu/guia-tes/docs/consolidado/SECCION_02_ABCDE_OPERATIVO.md, línea 112
- ABCDE_FLUJO_DETERIORO_FISIOLOGICO.png | /assets/infografias/bloque-0-fundamentos/ABCDE_FLUJO_DETERIORO_FISIOLOGICO.png | /home/planetazuzu/guia-tes/docs/consolidado/SECCION_02_ABCDE_OPERATIVO.md, línea 143

View file

@ -0,0 +1,47 @@
# Medios reales necesarios (filtrado, sin placeholders)
- rcp_adulto_paso_a_paso.png | /assets/infografias/bloque-4-rcp/rcp_adulto_paso_a_paso.png | /home/planetazuzu/guia-tes/docs/MEDIOS_AUDIOVISUALES_FALTANTES.md, línea 172
- rcp_adulto_paso_a_paso.png | public/assets/infografias/bloque-4-rcp/rcp_adulto_paso_a_paso.png | /home/planetazuzu/guia-tes/docs/MEDIOS_AUDIOVISUALES_FALTANTES.md, línea 150
- rcp_adulto_paso_a_paso.png | /assets/infografias/bloque-4-rcp/rcp_adulto_paso_a_paso.png | /home/planetazuzu/guia-tes/docs/MEDIOS_AUDIOVISUALES_FALTANTES.md, línea 161
- rcp_adulto_paso_a_paso.png | /assets/infografias/bloque-4-rcp/rcp_adulto_paso_a_paso.png | /home/planetazuzu/guia-tes/docs/MEDIOS_AUDIOVISUALES_FALTANTES.md, línea 172
- rcp_pediatrica_paso_a_paso.png | public/assets/infografias/bloque-4-rcp/rcp_pediatrica_paso_a_paso.png | /home/planetazuzu/guia-tes/docs/MEDIOS_AUDIOVISUALES_FALTANTES.md, línea 177
- rcp_lactantes_paso_a_paso.png | public/assets/infografias/bloque-4-rcp/rcp_lactantes_paso_a_paso.png | /home/planetazuzu/guia-tes/docs/MEDIOS_AUDIOVISUALES_FALTANTES.md, línea 185
- diagrama_uso_desa.png | public/assets/infografias/bloque-4-rcp/diagrama_uso_desa.png | /home/planetazuzu/guia-tes/docs/MEDIOS_AUDIOVISUALES_FALTANTES.md, línea 193
- ovace_adulto.png | public/assets/infografias/bloque-4-rcp/ovace_adulto.png | /home/planetazuzu/guia-tes/docs/MEDIOS_AUDIOVISUALES_FALTANTES.md, línea 205
- ovace_pediatrica.png | public/assets/infografias/bloque-4-rcp/ovace_pediatrica.png | /home/planetazuzu/guia-tes/docs/MEDIOS_AUDIOVISUALES_FALTANTES.md, línea 217
- ovace_lactantes.png | public/assets/infografias/bloque-4-rcp/ovace_lactantes.png | /home/planetazuzu/guia-tes/docs/MEDIOS_AUDIOVISUALES_FALTANTES.md, línea 225
- rcp_posicion_manos_adulto.png | .supabase.co/storage/v1/object/public/infografias/rcp/rcp_posicion_manos_adulto.png | /home/planetazuzu/guia-tes/docs/CONTENT_PACK_SPEC.md, línea 361
- rcp_posicion_manos_adulto.png | /assets/infografias/rcp/rcp_posicion_manos_adulto.png | /home/planetazuzu/guia-tes/docs/CONTENT_PACK_SPEC.md, línea 363
- rcp_profundidad_compresiones.png | .supabase.co/storage/v1/object/public/infografias/rcp/rcp_profundidad_compresiones.png | /home/planetazuzu/guia-tes/docs/CONTENT_PACK_SPEC.md, línea 384
- rcp_profundidad_compresiones.png | /assets/infografias/rcp/rcp_profundidad_compresiones.png | /home/planetazuzu/guia-tes/docs/CONTENT_PACK_SPEC.md, línea 386
- rcp_adulto_svb.mp4 | .supabase.co/storage/v1/object/public/videos/rcp/rcp_adulto_svb.mp4 | /home/planetazuzu/guia-tes/docs/CONTENT_PACK_SPEC.md, línea 407
- rcp_adulto_svb_thumb.jpg | .supabase.co/storage/v1/object/public/videos/rcp/rcp_adulto_svb_thumb.jpg | /home/planetazuzu/guia-tes/docs/CONTENT_PACK_SPEC.md, línea 408
- rcp_adulto_svb.mp4 | /assets/videos/rcp/rcp_adulto_svb.mp4 | /home/planetazuzu/guia-tes/docs/CONTENT_PACK_SPEC.md, línea 410
- ABCDE.png | ABCDE.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 60
- ABCDE.png | ABCDE.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 61
- ABCDE.png | ABCDE.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 415
- ABCDE.png | ABCDE.png | /home/planetazuzu/guia-tes/docs/AUDITORIA_ASSETS_MULTIMEDIA.json, línea 416
- ABCDE.png | ABCDE.png | /home/planetazuzu/guia-tes/docs/SEMANA_2_COMPLETADA.md, línea 42
- rcp_adulto_paso_a_paso.png | bloque-4-rcp/rcp_adulto_paso_a_paso.png | /home/planetazuzu/guia-tes/docs/MEDIOS_FALTANTES_TABLA_RAPIDA.md, línea 20
- rcp_pediatrica_paso_a_paso.png | bloque-4-rcp/rcp_pediatrica_paso_a_paso.png | /home/planetazuzu/guia-tes/docs/MEDIOS_FALTANTES_TABLA_RAPIDA.md, línea 21
- rcp_lactantes_paso_a_paso.png | bloque-4-rcp/rcp_lactantes_paso_a_paso.png | /home/planetazuzu/guia-tes/docs/MEDIOS_FALTANTES_TABLA_RAPIDA.md, línea 22
- diagrama_uso_desa.png | bloque-4-rcp/diagrama_uso_desa.png | /home/planetazuzu/guia-tes/docs/MEDIOS_FALTANTES_TABLA_RAPIDA.md, línea 23
- ovace_adulto.png | bloque-4-rcp/ovace_adulto.png | /home/planetazuzu/guia-tes/docs/MEDIOS_FALTANTES_TABLA_RAPIDA.md, línea 24
- ovace_pediatrica.png | bloque-4-rcp/ovace_pediatrica.png | /home/planetazuzu/guia-tes/docs/MEDIOS_FALTANTES_TABLA_RAPIDA.md, línea 25
- ovace_lactantes.png | bloque-4-rcp/ovace_lactantes.png | /home/planetazuzu/guia-tes/docs/MEDIOS_FALTANTES_TABLA_RAPIDA.md, línea 26
- ABCDE.png | ABCDE.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 59
- ABCDE.png | ABCDE.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 89
- algoritmo_rcp_comentado.svg | /assets/infografias/bloque-4-rcp/algoritmo_rcp_comentado.svg | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 231
- compresiones_incorrectas.png | /assets/infografias/bloque-4-rcp/compresiones_incorrectas.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 232
- compresiones_correctas.png | /assets/infografias/bloque-4-rcp/compresiones_correctas.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 233
- descompresion_incompleta.png | /assets/infografias/bloque-4-rcp/descompresion_incompleta.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 234
- descompresion_completa.png | /assets/infografias/bloque-4-rcp/descompresion_completa.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 235
- resumen_rcp_adulto_svb.png | /assets/infografias/bloque-4-rcp/resumen_rcp_adulto_svb.png | /home/planetazuzu/guia-tes/docs/ANALISIS_MEDIOS_SUPERFLUOS.md, línea 236
- rcp_posicion_manos_adulto.png | .supabase.co/storage/v1/object/public/infografias/rcp/rcp_posicion_manos_adulto.png | /home/planetazuzu/guia-tes/docs/CONTENT_MODEL.md, línea 225
- rcp_posicion_manos_adulto.png | /assets/infografias/rcp/rcp_posicion_manos_adulto.png | /home/planetazuzu/guia-tes/docs/CONTENT_MODEL.md, línea 227
- rcp_adulto_svb.mp4 | .supabase.co/storage/v1/object/public/videos/rcp/rcp_adulto_svb.mp4 | /home/planetazuzu/guia-tes/docs/CONTENT_MODEL.md, línea 249
- rcp_adulto_svb_thumb.jpg | .supabase.co/storage/v1/object/public/videos/rcp/rcp_adulto_svb_thumb.jpg | /home/planetazuzu/guia-tes/docs/CONTENT_MODEL.md, línea 250
- rcp_adulto_svb.mp4 | /assets/videos/rcp/rcp_adulto_svb.mp4 | /home/planetazuzu/guia-tes/docs/CONTENT_MODEL.md, línea 252
- rcp_posicion_manos_adulto.png | /media/images/rcp/rcp_posicion_manos_adulto.png | /home/planetazuzu/guia-tes/docs/API_ENDPOINTS_ESPECIFICACION.md, línea 505
- introduccion_rcp_adulto_svb.png | /assets/infografias/bloque-4-rcp/introduccion_rcp_adulto_svb.png | /home/planetazuzu/guia-tes/docs/consolidado/SECCION_01_RCP_ADULTO_SVB.md, línea 41
- introduccion_rcp_adulto_svb.png | /assets/infografias/bloque-4-rcp/introduccion_rcp_adulto_svb.png | /home/planetazuzu/guia-tes/docs/consolidado/SECCION_01_RCP_ADULTO_SVB.md, línea 41

View file

@ -0,0 +1,36 @@
# Sistema de generación de medios audiovisuales Prioridad A
Este documento define prompts y criterios de salida para la generación sistemática de medios críticos.
## Estándar de salida
- Resolución imágenes: 1920x1080 (mínimo)
- Formato imágenes: PNG o JPG según nombre objetivo
- Vídeos: MP4 H.264, 1080p, 30fps, 30-60s
- Sin logos, sin marcas de agua, sin texto excesivo
- Estilo educativo TES, alta legibilidad en móvil
## Prioridad A Medios críticos (12)
### B01_1.1_colocación_manguito_ta_y_pulsioxímetro.jpg
- Tipo: Imagen
- Ruta final esperada: assets/images/bloque_01/B01_1.1_colocación_manguito_ta_y_pulsioxímetro.jpg
- Prompt sugerido:
Fotografía clínica realista, entorno prehospitalario TES, mostrando la colocación correcta del manguito de tensión arterial y el pulsioxímetro en un paciente adulto, con manos del TES colocando ambos dispositivos de forma correcta. Iluminación neutral, alta nitidez, sin logos. Enfoque en el procedimiento, plano medio, fondo limpio.
### B01_1.1_vídeo_toma_ta_manual_y_errores_típicos.mp4
- Tipo: Vídeo
- Ruta final esperada: assets/videos/bloque_01/B01_1.1_vídeo_toma_ta_manual_y_errores_típicos.mp4
- Prompt sugerido:
Vídeo educativo corto (30-60s) mostrando toma manual de tensión arterial con esfigmomanómetro y estetoscopio. Incluir 2-3 errores típicos (manguito mal colocado, brazo sin soporte, estetoscopio mal posicionado) y la corrección inmediata. Estilo demostrativo TES, fondo neutro, sin logos, texto breve en pantalla para señalar errores.
### B01_1.2_diagrama_abcde_operativo.png
- Tipo: Imagen
- Ruta final esperada: assets/images/bloque_01/B01_1.2_diagrama_abcde_operativo.png
- Prompt sugerido:
Infografía clínica en español con diagrama ABCDE operativo. Diseño limpio, fondo claro, títulos grandes: A (Vía aérea), B (Respiración), C (Circulación), D (Neurológico), E (Exposición). Usar iconos simples y flechas de secuencia. Estilo educativo TES, alto contraste, legible en móvil.
### B01_1.4_diagrama_start_flujo_simple.png
- Tipo: Imagen
- Ruta final esperada: assets/images/bloque_01/B01_1.4_diagrama_start_flujo_simple.png
- Prompt sugerido:
Diagrama de flujo START para triaje en español, versión simplificada. Caja inicial con “¿Respira?”, ramas con colores de triaje (rojo, amarillo, verde, negro). Iconos mínimos, tipografía grande, estilo infografía clínica TES, legible en móvil.

16343
PROMPTS_MEDIOS_FALTANTES.md Normal file

File diff suppressed because it is too large Load diff

191
RESUMEN_ADMIN_PANEL.md Normal file
View file

@ -0,0 +1,191 @@
# 🎛️ RESUMEN: ADMIN PANEL - SISTEMA COMPLETO
## ✅ IMPLEMENTADO
### 1. Modelo de Datos Extendido ✅
**Ubicación**: `admin-panel/shared/types/content.ts`
- ✅ Interfaces TypeScript para Protocol, Guide, Manual, Drug, Checklist
- ✅ Extensión del modelo existente sin romper compatibilidad
- ✅ ContentPack para distribución
- ✅ Tipos de autenticación y autorización
### 2. Backend API Completo ✅
**Ubicación**: `backend/src/`
#### Autenticación
- ✅ `routes/auth.js` - Login, JWT, verificación
- ✅ `middleware/auth.js` - Autenticación y permisos
- ✅ RBAC con 5 roles (super_admin, editor_clinico, editor_formativo, revisor, viewer)
#### Gestión de Contenido
- ✅ `routes/content.js` - CRUD completo
- GET /api/content - Listar con filtros
- GET /api/content/:id - Obtener por ID
- POST /api/content - Crear
- PUT /api/content/:id - Actualizar
- GET /api/content/:id/versions - Historial
- POST /api/content/:id/validate - Validar
- GET /api/content/pack/latest - Content pack público
#### Scripts
- ✅ `scripts/seed-admin.js` - Crear usuario admin
- ✅ `scripts/seed-content.js` - Crear contenido de ejemplo
### 3. Integración en App Principal ✅
**Ubicación**: `src/services/content-pack.ts`
- ✅ Servicio de content pack
- ✅ Sistema de "override" (pack > local)
- ✅ Cache offline
- ✅ Funciones para obtener contenido con override
- ✅ **NO modifica** `procedures.ts` ni `drugs.ts`
### 4. Seed Data ✅
**Contenido de ejemplo creado**:
- ✅ **3 Checklists**:
- Electrodos/Parches DESA
- Preparación Intubación
- RCP Checklist
- ✅ **2 Protocolos Extendidos**:
- RCP Adulto SVB (con checklist, dosis inline, fuentes)
- Shock Hemorrágico (con dosis inline, fuentes)
---
## 🚧 PENDIENTE (Admin Panel UI)
La estructura del Admin Panel está creada, pero los componentes React están pendientes:
- [ ] Dashboard con estadísticas
- [ ] Biblioteca de contenido
- [ ] Editores especializados (Protocolo, Checklist, Guía, Vademécum)
- [ ] Vista de auditoría
- [ ] Gestión de fuentes
**Nota**: El backend está completo y funcional. El Admin Panel UI se puede desarrollar progresivamente.
---
## 🚀 INICIO RÁPIDO
### Backend
```bash
cd backend
# 1. Instalar dependencias
npm install
# 2. Configurar .env (ver backend/ENV_TEMPLATE.md)
# DB_USER=planetazuzu
# DB_PASSWORD=Monforte.1977
# DB_NAME=emerges_tes
# JWT_SECRET=tu-secret-key-aqui
# 3. Crear usuario y BD (requiere sudo)
bash crear-usuario-y-bd.sh
# 4. Crear tablas
npm run db:create
# 5. Crear usuario admin
npm run seed:admin
# 6. Crear contenido de ejemplo
npm run seed:content
# 7. Iniciar servidor
npm run dev
```
**Credenciales por defecto**:
- Email: `admin@emerges-tes.local`
- Password: `Admin123!`
### Admin Panel (cuando esté implementado)
```bash
cd admin-panel
npm install
npm run dev
```
---
## 📁 ARCHIVOS CREADOS
### Modelo de Datos
- `admin-panel/shared/types/content.ts` - Interfaces de contenido
- `admin-panel/shared/types/auth.ts` - Tipos de autenticación
### Backend
- `backend/src/routes/auth.js` - Rutas de autenticación
- `backend/src/routes/content.js` - Rutas de contenido
- `backend/src/middleware/auth.js` - Middleware de auth
- `backend/scripts/seed-admin.js` - Seed de usuario admin
- `backend/scripts/seed-content.js` - Seed de contenido
### Integración
- `src/services/content-pack.ts` - Servicio de content pack
### Documentación
- `docs/ADMIN_PANEL_IMPLEMENTACION.md` - Documentación completa
- `docs/CHECKLIST_VERIFICACION_ADMIN_PANEL.md` - Checklist de verificación
- `admin-panel/README.md` - README del admin panel
---
## ✅ RESTRICCIONES CUMPLIDAS
- ✅ **NO se modifica** `src/data/procedures.ts` ni `searchProcedures()`
- ✅ **NO se modifica** `src/data/drugs.ts` ni `searchDrugs()`
- ✅ **NO rompe PWA offline** - Content pack funciona offline
- ✅ **NO cambia rutas existentes** - Compatibilidad total
- ✅ **Versionado completo** - Todo contenido es versionado
---
## 🔐 ROLES Y PERMISOS
| Rol | Permisos |
|-----|----------|
| **super_admin** | Acceso total |
| **editor_clinico** | Editar protocolos, fármacos, checklists |
| **editor_formativo** | Editar guías y manuales |
| **revisor** | Revisar y validar |
| **viewer** | Solo lectura |
---
## 📝 PRÓXIMOS PASOS
1. **Completar Admin Panel UI** (componentes React)
2. **Integrar content pack** en componentes existentes de la app
3. **Tests automatizados**
4. **Documentación de API**
---
## 🎉 ESTADO
✅ **Backend completo y funcional**
✅ **Modelo de datos diseñado**
✅ **Sistema de content pack implementado**
✅ **Seed data creado**
🚧 **Admin Panel UI pendiente** (estructura lista)
---
## 📚 DOCUMENTACIÓN
- **Implementación completa**: `docs/ADMIN_PANEL_IMPLEMENTACION.md`
- **Checklist de verificación**: `docs/CHECKLIST_VERIFICACION_ADMIN_PANEL.md`
- **README Admin Panel**: `admin-panel/README.md`

130
admin-panel/DIAGNOSTICO.md Normal file
View file

@ -0,0 +1,130 @@
# 🔍 Diagnóstico del Panel de Administración
## Pasos para diagnosticar el problema
### 1. Abre la consola del navegador
- Presiona `F12` o `Ctrl+Shift+I`
- Ve a la pestaña **Console**
### 2. Ejecuta este código en la consola:
```javascript
// Diagnóstico completo
(async () => {
console.log('🔍 DIAGNÓSTICO DEL PANEL DE ADMINISTRACIÓN\n');
// 1. Verificar token
const token = localStorage.getItem('admin_token');
console.log('1⃣ Token en localStorage:', token ? token.substring(0, 30) + '...' : '❌ NO HAY TOKEN');
// 2. Verificar usuario
const user = localStorage.getItem('admin_user');
console.log('2⃣ Usuario en localStorage:', user ? JSON.parse(user).email : '❌ NO HAY USUARIO');
// 3. Probar endpoint de stats
const API_URL = 'http://localhost:3000';
try {
const statsResponse = await fetch(`${API_URL}/api/stats/content`, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
console.log('\n3⃣ Respuesta de /api/stats/content:');
console.log(' Status:', statsResponse.status);
if (statsResponse.ok) {
const data = await statsResponse.json();
console.log(' ✅ Datos recibidos:');
console.log(' Protocolos:', data.protocols);
console.log(' Guías:', data.guides);
console.log(' Fármacos:', data.drugs);
console.log(' Checklists:', data.checklists);
} else {
const error = await statsResponse.json();
console.log(' ❌ Error:', error);
}
} catch (err) {
console.log(' ❌ Error de red:', err.message);
}
// 4. Probar endpoint de content
try {
const contentResponse = await fetch(`${API_URL}/api/content`, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
console.log('\n4⃣ Respuesta de /api/content:');
console.log(' Status:', contentResponse.status);
if (contentResponse.ok) {
const data = await contentResponse.json();
console.log(' ✅ Datos recibidos:');
console.log(' Total items:', data.total);
console.log(' Items en página:', data.items?.length || 0);
} else {
const error = await contentResponse.json();
console.log(' ❌ Error:', error);
}
} catch (err) {
console.log(' ❌ Error de red:', err.message);
}
// 5. Verificar backend
try {
const healthResponse = await fetch(`${API_URL}/health`);
const health = await healthResponse.json();
console.log('\n5⃣ Estado del backend:');
console.log(' Status:', health.status);
console.log(' Database:', health.database);
} catch (err) {
console.log(' ❌ Backend no responde:', err.message);
}
console.log('\n✅ Diagnóstico completo');
})();
```
### 3. Copia y pega el resultado aquí
### 4. Si el token no existe o está expirado:
Ejecuta esto para hacer login:
```javascript
(async () => {
const API_URL = 'http://localhost:3000';
try {
const response = await fetch(`${API_URL}/api/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
email: 'admin@emerges-tes.local',
password: 'Admin123!'
})
});
if (response.ok) {
const data = await response.json();
localStorage.setItem('admin_token', data.token);
localStorage.setItem('admin_user', JSON.stringify(data.user));
console.log('✅ Login exitoso');
console.log('Usuario:', data.user.email);
console.log('Token guardado');
location.reload(); // Recargar página
} else {
const error = await response.json();
console.error('❌ Error de login:', error);
}
} catch (err) {
console.error('❌ Error de red:', err);
}
})();
```

97
admin-panel/README.md Normal file
View file

@ -0,0 +1,97 @@
# 🎛️ Admin Panel - EMERGES TES
Panel de administración para gestionar contenido (protocolos, guías, manual, vademécum, checklists) sin modificar el código de la app.
## 🏗️ Arquitectura
- **Frontend**: React + Vite + TypeScript
- **Backend**: Node.js + Express + PostgreSQL
- **Autenticación**: JWT
- **RBAC**: Roles y permisos granulares
## 🚀 Inicio Rápido
### Backend
```bash
cd backend
npm install
npm run db:create # Crear BD y tablas
npm run seed:admin # Crear usuario admin
npm run seed:content # Crear contenido de ejemplo
npm run dev # Iniciar servidor (puerto 3000)
```
### Admin Panel
```bash
cd admin-panel
npm install
npm run dev # Iniciar en http://localhost:5174
```
### Credenciales por defecto
- **Email**: `admin@emerges-tes.local`
- **Password**: `Admin123!`
- **Role**: `super_admin`
⚠️ **IMPORTANTE**: Cambiar la contraseña después del primer login.
## 📁 Estructura
```
admin-panel/
├── src/
│ ├── components/ # Componentes React
│ │ ├── dashboard/ # Dashboard principal
│ │ ├── content/ # Editores de contenido
│ │ ├── audit/ # Auditoría y versiones
│ │ └── common/ # Componentes compartidos
│ ├── pages/ # Páginas principales
│ ├── hooks/ # Custom hooks
│ ├── services/ # API services
│ └── utils/ # Utilidades
├── shared/
│ └── types/ # TypeScript types compartidos
└── package.json
```
## 🔐 Roles y Permisos
- **super_admin**: Acceso total
- **editor_clinico**: Editar protocolos, fármacos, checklists
- **editor_formativo**: Editar guías formativas
- **revisor**: Revisar y validar contenido
- **viewer**: Solo lectura
## 📝 Funcionalidades
- ✅ Dashboard con estadísticas
- ✅ Biblioteca de contenido con filtros
- ✅ Editor de Protocolo con vista previa "modo TES"
- ✅ Editor de Checklist reutilizable
- ✅ Editor de Guías Markdown con preview
- ✅ Manager de Vademécum
- ✅ Pantalla de Fuentes y Actualizaciones
- ✅ Auditoría (logs + comparar versiones + revertir)
- ✅ Sistema de versionado
- ✅ Validación de contenido
## 🔄 Integración con App Principal
El content pack se consume como "override" del contenido local:
1. La app intenta obtener el último pack publicado desde `/api/content/pack/latest`
2. Si existe y está validado, lo usa
3. Si no, usa los datos locales actuales (`src/data/`)
4. Funciona offline usando cache del último pack
## 🧪 Tests
```bash
npm test # Tests unitarios
npm run test:e2e # Tests end-to-end
npm run test:manual # Checklist manual de verificación
```

14
admin-panel/index.html Normal file
View file

@ -0,0 +1,14 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Admin Panel - EMERGES TES</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

6039
admin-panel/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

38
admin-panel/package.json Normal file
View file

@ -0,0 +1,38 @@
{
"name": "emerges-tes-admin-panel",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"axios": "^1.6.2",
"react-markdown": "^9.0.1",
"remark-gfm": "^4.0.0",
"rehype-highlight": "^7.0.0",
"lucide-react": "^0.294.0",
"date-fns": "^2.30.0"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"eslint": "^8.55.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"postcss": "^8.4.32",
"tailwindcss": "^3.3.6",
"typescript": "^5.2.2",
"vite": "^5.0.8"
}
}

View file

@ -0,0 +1,78 @@
/**
* TIPOS DE AUTENTICACIÓN Y AUTORIZACIÓN
*/
export type UserRole =
| 'super_admin' // Acceso total
| 'editor_clinico' // Editar protocolos, fármacos, checklists
| 'editor_formativo' // Editar guías formativas
| 'revisor' // Revisar y validar contenido
| 'viewer'; // Solo lectura
export interface User {
id: string;
email: string;
username: string;
role: UserRole;
isActive: boolean;
createdAt: string;
lastLogin?: string;
}
export interface LoginRequest {
email: string;
password: string;
}
export interface LoginResponse {
token: string;
user: User;
expiresIn: number; // Segundos
}
export interface AuthContext {
user: User | null;
token: string | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
hasPermission: (permission: string) => boolean;
}
// Permisos por rol
export const ROLE_PERMISSIONS: Record<UserRole, string[]> = {
super_admin: [
'content:read',
'content:write',
'content:delete',
'content:validate',
'content:publish',
'users:read',
'users:write',
'users:delete',
'audit:read',
'system:configure',
],
editor_clinico: [
'content:read',
'content:write:protocol',
'content:write:drug',
'content:write:checklist',
'content:submit',
],
editor_formativo: [
'content:read',
'content:write:guide',
'content:write:manual',
'content:submit',
],
revisor: [
'content:read',
'content:validate',
'content:approve',
'audit:read',
],
viewer: [
'content:read',
],
};

View file

@ -0,0 +1,464 @@
/**
* MODELO DE DATOS CANÓNICO - SISTEMA DE CONTENIDO EXTERNO
*
* FASE 4: Base de Contenido
*
* Este modelo es completamente desacoplado del código de la app.
* No modifica procedures.ts, drugs.ts ni ningún componente existente.
*
* Diseñado para:
* - Durabilidad (10+ años)
* - Uso real de TES en guardia
* - Formación continua
* - Referencia profesional
*
* @version 1.0.0
* @date 2025-01-06
*/
// ============================================
// ENUMS Y TIPOS BASE
// ============================================
export type ContentType = 'protocol' | 'guide' | 'manual' | 'drug' | 'checklist';
export type UsageType = 'operativo' | 'formativo' | 'referencia';
export type Priority = 'critica' | 'alta' | 'media' | 'baja';
export type ContentStatus = 'draft' | 'in_review' | 'approved' | 'published' | 'archived';
export type MediaType = 'image' | 'video';
export type ClinicalContext =
| 'RCP'
| 'OVACE'
| 'ABCDE'
| 'TRIAGE'
| 'GLASGOW'
| 'ICTUS'
| 'SHOCK'
| 'TRAUMA'
| 'OXIGENOTERAPIA'
| 'VIA_AEREA'
| 'FARMACOLOGIA'
| 'OTROS';
export type SourceGuideline =
| 'ERC'
| 'SEMES'
| 'AHA'
| 'INTERNO'
| 'MANUAL_TES_DIGITAL';
export type AuditAction =
| 'create'
| 'update'
| 'delete'
| 'validate'
| 'approve'
| 'publish'
| 'archive'
| 'revert';
export type UserRole =
| 'tes'
| 'medico'
| 'formador'
| 'editor'
| 'admin';
// ============================================
// CONTENT ITEM (Base de todo contenido)
// ============================================
/**
* ContentItem: Entidad base para todo contenido del sistema
*
* Representa protocolos, guías, manuales, fármacos y checklists
* de forma unificada y extensible.
*/
export interface ContentItem {
// Identificación única
id: string; // UUID v4
type: ContentType; // Tipo de contenido
slug: string; // Slug para URLs (ej: "rcp-adulto-svb")
// Metadatos básicos
title: string; // Título completo
short_title?: string; // Título corto (para UI)
description?: string; // Descripción breve
// Clasificación clínica
clinical_context: ClinicalContext; // Contexto clínico principal
usage_type: UsageType; // Tipo de uso
priority: Priority; // Prioridad clínica
// Estado y validación
status: ContentStatus; // Estado del contenido
source_guideline: SourceGuideline; // Fuente clínica
source_year?: number; // Año de la guía fuente
source_url?: string; // URL de la guía fuente
// Validación clínica
validated_by?: string; // ID del validador
validated_at?: string; // ISO timestamp
validator_role?: UserRole; // Rol del validador
validation_expires_at?: string; // Fecha de expiración de validación
// Versionado
version: string; // Versión semántica (ej: "1.2.3")
latest_version: string; // Última versión disponible
// Contenido específico (JSON flexible según tipo)
content: ContentItemContent; // Contenido específico del tipo
// Relaciones
related_content_ids?: string[]; // IDs de contenido relacionado
related_protocol_ids?: string[]; // IDs de protocolos relacionados (si no es protocolo)
related_guide_ids?: string[]; // IDs de guías relacionadas (si no es guía)
related_manual_ids?: string[]; // IDs de manuales relacionados (si no es manual)
// Tags y categorización
tags: string[]; // Tags para búsqueda
category?: string; // Categoría temática
// Auditoría
created_by: string; // ID del creador
created_at: string; // ISO timestamp
updated_by?: string; // ID del último editor
updated_at: string; // ISO timestamp
// Metadatos adicionales
metadata?: Record<string, any>; // Metadatos flexibles
}
/**
* Contenido específico según tipo de ContentItem
*/
export type ContentItemContent =
| ProtocolContent
| GuideContent
| ManualContent
| DrugContent
| ChecklistContent;
// ============================================
// PROTOCOL CONTENT (Operativo)
// ============================================
export interface ProtocolContent {
// Pasos operativos
steps: ProtocolStep[];
// Checklist integrado
checklist?: {
enabled: boolean;
title?: string;
items: ChecklistItem[];
};
// Dosis inline
inline_doses?: InlineDose[];
// Herramientas de contexto
context_tools?: ContextTool[];
// Fuentes clínicas
clinical_sources?: ClinicalSource[];
// Campos de compatibilidad (legacy)
warnings?: string[];
key_points?: string[];
equipment?: string[];
drugs?: string[]; // Referencias a fármacos
// Metadatos específicos
age_group?: 'adulto' | 'pediatrico' | 'neonatal' | 'todos';
estimated_duration?: string; // Duración estimada (ej: "5-10 min")
}
export interface ProtocolStep {
order: number; // Orden del paso (1, 2, 3...)
text: string; // Texto del paso
critical?: boolean; // Si es paso crítico (no saltable)
equipment?: string[]; // Equipamiento necesario
time_estimate?: string; // Tiempo estimado (ej: "30-60s")
notes?: string; // Notas internas
}
export interface ChecklistItem {
id: string; // ID único del item
text: string; // Texto del item
order: number; // Orden
critical?: boolean; // Si es crítico
category?: string; // Categoría (ej: "Preparación", "Verificación")
}
export interface InlineDose {
drug_id: string; // ID del fármaco
drug_name: string; // Nombre del fármaco
adult_dose: string; // Dosis adulto
pediatric_dose?: string; // Dosis pediátrica
route: string; // Vía de administración
timing?: string; // Cuándo administrar
context?: string; // Contexto específico
}
export interface ContextTool {
id: string;
name: string;
type: 'calculator' | 'algorithm' | 'reference' | 'checklist';
url?: string;
description?: string;
}
export interface ClinicalSource {
organization: string; // ERC, SEMES, AHA, etc.
guideline: string; // Nombre de la guía
year: number; // Año
url?: string; // URL
section?: string; // Sección específica
}
// ============================================
// GUIDE CONTENT (Formativo)
// ============================================
export interface GuideContent {
// Secciones (siempre 8)
sections: GuideSection[];
// Relaciones
related_protocol_id?: string; // Protocolo operativo relacionado
related_manual_ids?: string[]; // Capítulos de manual relacionados
// Metadatos formativos
learning_objectives?: string[];
prerequisites?: string[];
target_audience?: string[];
estimated_time?: string; // Tiempo total estimado
}
export interface GuideSection {
numero: number; // 1-8
titulo: string;
markdown: string; // Contenido Markdown
estimated_time?: string; // Tiempo estimado de lectura
resources?: {
images?: string[]; // IDs de MediaResource
videos?: string[]; // IDs de MediaResource
links?: Array<{ title: string; url: string }>;
};
}
// ============================================
// MANUAL CONTENT (Referencia)
// ============================================
export interface ManualContent {
markdown: string; // Contenido Markdown completo
tags: string[]; // Tags para búsqueda
related_protocol_ids?: string[]; // IDs de protocolos relacionados
related_guide_ids?: string[]; // IDs de guías relacionadas
references?: ClinicalSource[]; // Referencias clínicas
// Estructura jerárquica
block?: string; // Bloque del manual (ej: "BLOQUE_01")
section?: string; // Sección dentro del bloque
order?: number; // Orden dentro del bloque
}
// ============================================
// DRUG CONTENT (Referencia)
// ============================================
export interface DrugContent {
generic_name: string;
trade_name: string;
category: string;
presentation: string;
// Dosis
adult_dose: string;
pediatric_dose?: string;
routes: string[]; // Vías de administración
dilution?: string;
// Indicaciones y contraindicaciones
indications: string[];
contraindications: string[];
side_effects?: string[];
antidote?: string;
// Notas
notes?: string[];
critical_points?: string[];
source?: string;
}
// ============================================
// CHECKLIST CONTENT (Operativo)
// ============================================
export interface ChecklistContent {
items: ChecklistItem[];
description?: string;
estimated_time?: string;
applicable_protocol_ids?: string[]; // IDs de protocolos donde se puede usar
tags?: string[];
}
// ============================================
// MEDIA RESOURCE (Imágenes y Vídeos)
// ============================================
/**
* MediaResource: Recurso multimedia (imagen o vídeo)
*
* Almacenado en Supabase Storage, referenciado por URL.
*/
export interface MediaResource {
// Identificación
id: string; // UUID v4
type: MediaType; // 'image' | 'video'
// Archivo
file_url: string; // URL completa del archivo (Supabase Storage)
thumbnail_url?: string; // URL del thumbnail (para vídeos)
filename: string; // Nombre del archivo original
path: string; // Ruta relativa (ej: "/assets/infografias/rcp/...")
// Metadatos
title: string; // Título del recurso
description?: string; // Descripción
alt_text: string; // Texto alternativo (accesibilidad)
caption?: string; // Caption opcional
// Clasificación
tags: string[]; // Tags para búsqueda
block?: string; // Bloque temático (ej: "bloque-0-fundamentos")
chapter?: string; // Capítulo relacionado
priority: Priority; // Prioridad del recurso
usage_type: UsageType[]; // ['operativo', 'formativo'] - dónde se usa
// Dimensiones (imágenes)
width?: number; // Ancho en píxeles
height?: number; // Alto en píxeles
format?: string; // 'png' | 'svg' | 'jpg' | 'webp'
file_size?: number; // Tamaño en bytes
// Duración (vídeos)
duration_seconds?: number; // Duración en segundos
video_format?: string; // 'mp4' | 'webm'
// Fuente
source?: string; // Fuente del recurso
attribution?: string; // Atribución si es necesario
// Estado
status: 'draft' | 'approved' | 'published';
// Auditoría
uploaded_by: string; // ID del usuario
uploaded_at: string; // ISO timestamp
updated_at: string; // ISO timestamp
}
// ============================================
// CONTENT RESOURCE ASSOCIATION
// ============================================
/**
* ContentResourceAssociation: Asociación entre contenido y recursos
*
* Define dónde y cómo se muestra un recurso en un contenido específico.
*/
export interface ContentResourceAssociation {
// Identificación
id: string; // UUID v4
content_item_id: string; // ID del ContentItem
media_resource_id: string; // ID del MediaResource
// Contexto de asociación
section?: string; // Sección específica (ej: "pasos", "checklist", "guia_seccion_3")
position?: number; // Posición en el contenido (orden)
placement?: 'inline' | 'before' | 'after' | 'modal'; // Dónde se muestra
caption?: string; // Caption específico para este contexto
// Metadatos
is_critical: boolean; // Si es obligatorio para el contenido
priority: Priority; // Prioridad de esta asociación
// Auditoría
created_at: string; // ISO timestamp
}
// ============================================
// CONTENT VERSION
// ============================================
/**
* ContentVersion: Versión histórica de un ContentItem
*
* Permite versionado semántico y rollback.
*/
export interface ContentVersion {
// Identificación
id: string; // UUID v4
content_item_id: string; // ID del ContentItem
version: string; // Versión semántica (ej: "1.2.3")
// Contenido
content: ContentItemContent; // Contenido de esta versión
// Metadatos
change_summary: string; // Resumen de cambios
is_breaking?: boolean; // Si es cambio breaking
created_by: string; // ID del creador
created_at: string; // ISO timestamp
// Estado
is_active: boolean; // Si es la versión activa
}
// ============================================
// AUDIT LOG
// ============================================
/**
* AuditLog: Registro de auditoría de todas las acciones
*
* Trazabilidad completa de cambios en el sistema.
*/
export interface AuditLog {
// Identificación
id: string; // UUID v4
entity_type: 'content_item' | 'media_resource' | 'association' | 'version';
entity_id: string; // ID de la entidad
// Acción
action: AuditAction; // Tipo de acción
user_id: string; // ID del usuario
user_role: UserRole; // Rol del usuario
// Metadatos
metadata?: Record<string, any>; // Metadatos adicionales (cambios, valores anteriores, etc.)
// Timestamp
timestamp: string; // ISO timestamp
}
// ============================================
// EXPORTS
// ============================================
export type {
ContentItem,
ContentItemContent,
ProtocolContent,
GuideContent,
ManualContent,
DrugContent,
ChecklistContent,
MediaResource,
ContentResourceAssociation,
ContentVersion,
AuditLog,
};

View file

@ -0,0 +1,357 @@
/**
* MODELO DE DATOS EXTENDIDO - ADMIN PANEL
*
* Este modelo extiende el modelo existente sin romper compatibilidad.
* Los tipos base (Procedure, Drug) se mantienen intactos.
*
* ESTRATEGIA: Content Pack como "override" del contenido local
*/
// ============================================
// TIPOS BASE (Compatibles con src/data/)
// ============================================
export type Priority = 'critico' | 'alto' | 'medio' | 'bajo';
export type AgeGroup = 'adulto' | 'pediatrico' | 'neonatal' | 'todos';
export type Category = 'soporte_vital' | 'patologias' | 'escena';
export type ContentType = 'protocol' | 'guide' | 'manual' | 'drug' | 'checklist' | 'resource';
export type ContentLevel = 'operativo' | 'formativo' | 'referencia';
export type ContentStatus = 'draft' | 'in_review' | 'approved' | 'published' | 'archived';
export type AdministrationRoute = 'IV' | 'IM' | 'SC' | 'IO' | 'Nebulizado' | 'SL' | 'Rectal' | 'Nasal';
// ============================================
// PROTOCOL (Operativo) - EXTENDIDO
// ============================================
export interface ProtocolStep {
order: number;
text: string;
critical?: boolean; // Paso crítico (no saltable)
equipment?: string[]; // Equipamiento necesario para este paso
timeEstimate?: string; // Tiempo estimado (ej: "30-60s")
notes?: string; // Notas internas para editores
}
export interface ProtocolChecklistItem {
id: string;
text: string;
order: number;
reusableChecklistId?: string; // Referencia a checklist reutilizable
}
export interface InlineDose {
drugId: string;
drugName: string;
adultDose: string;
pediatricDose?: string;
route: AdministrationRoute;
dilution?: string;
timing?: string; // Cuándo administrar (ej: "cada 3-5 min")
context?: string; // Contexto específico (ej: "en PCR", "en anafilaxia")
}
export interface ProtocolContextTool {
id: string;
name: string;
type: 'calculator' | 'algorithm' | 'reference' | 'checklist';
url?: string;
description?: string;
}
export interface ClinicalSource {
organization: string; // ERC, SEMES, AHA, etc.
guideline: string; // Nombre de la guía
year: number;
url?: string;
section?: string; // Sección específica
}
export interface ProtocolContent {
// Pasos rápidos (estructura mejorada)
pasosRapidos: ProtocolStep[];
// Checklist integrado
checklist?: {
enabled: boolean;
items: ProtocolChecklistItem[];
title?: string;
};
// Dosis inline
dosisInline?: InlineDose[];
// Herramientas de contexto
herramientasContexto?: ProtocolContextTool[];
// Fuentes clínicas
fuentes?: ClinicalSource[];
// Metadatos adicionales
warnings: string[];
keyPoints?: string[];
equipment?: string[];
drugs?: string[]; // Referencias a fármacos
// Versionado
version: number;
lastUpdated: string;
updatedBy?: string;
}
export interface Protocol extends BaseContentItem {
type: 'protocol';
level: 'operativo';
content: ProtocolContent;
// Campos específicos de protocolo
category: Category;
subcategory?: string;
priority: Priority;
ageGroup: AgeGroup;
}
// ============================================
// GUIDE (Formativo) - EXTENDIDO
// ============================================
export interface GuideSection {
numero: number; // 1-8
titulo: string;
markdown: string; // Contenido Markdown
resources?: {
images?: string[];
videos?: string[];
links?: Array<{ title: string; url: string }>;
};
estimatedTime?: string; // Tiempo estimado de lectura
}
export interface GuideRelation {
protocolId?: string; // Protocolo operativo relacionado
manualChapterId?: string; // Capítulo de manual relacionado
relatedGuideIds?: string[]; // Otras guías relacionadas
}
export interface GuideContent {
sections: GuideSection[]; // Siempre 8 secciones
relations: GuideRelation;
metadata?: {
learningObjectives?: string[];
prerequisites?: string[];
targetAudience?: string[];
};
}
export interface Guide extends BaseContentItem {
type: 'guide';
level: 'formativo';
content: GuideContent;
// Relaciones
protocoloOperativo?: {
titulo: string;
ruta: string;
};
}
// ============================================
// MANUAL CHAPTER (Referencia)
// ============================================
export interface ManualChapterContent {
markdown: string;
tags: string[]; // Tags para búsqueda y categorización
relatedProtocols?: string[]; // IDs de protocolos relacionados
relatedGuides?: string[]; // IDs de guías relacionadas
references?: ClinicalSource[];
}
export interface ManualChapter extends BaseContentItem {
type: 'manual';
level: 'referencia';
content: ManualChapterContent;
// Estructura jerárquica
block?: string; // Bloque del manual (ej: "BLOQUE_01")
section?: string; // Sección dentro del bloque
order?: number; // Orden dentro del bloque
}
// ============================================
// DRUG (Vademécum) - EXTENDIDO
// ============================================
export interface DrugFrequency {
context: string; // "PCR", "Anafilaxia", "Crisis asmática"
frequency: 'first_line' | 'second_line' | 'alternative' | 'rescue';
priority?: number; // Prioridad dentro del contexto (1 = más importante)
}
export interface DrugContext {
indication: string; // Indicación específica
dose: string; // Dosis para esta indicación
route: AdministrationRoute;
timing?: string; // Cuándo/cómo administrar
notes?: string[]; // Notas específicas para esta indicación
}
export interface DrugContent {
// Estructura normalizada
genericName: string;
tradeName: string;
category: string;
presentation: string;
// Dosis
adultDose: string;
pediatricDose?: string;
routes: AdministrationRoute[];
dilution?: string;
// Indicaciones y contraindicaciones
indications: string[];
contraindications: string[];
sideEffects?: string[];
antidote?: string;
// Nuevos campos
frecuenciaUso: DrugFrequency[]; // Frecuencia de uso por contexto
contextos: DrugContext[]; // Contextos específicos de uso
primeraLinea?: string[]; // Indicaciones donde es primera línea
segundaLinea?: string[]; // Indicaciones donde es segunda línea
// Campos existentes
notes?: string[];
criticalPoints?: string[];
source?: string;
}
export interface Drug extends BaseContentItem {
type: 'drug';
level: 'referencia';
content: DrugContent;
}
// ============================================
// CHECKLIST REUTILIZABLE
// ============================================
export interface ChecklistItem {
id: string;
text: string;
order: number;
critical?: boolean; // Item crítico (no saltable)
category?: string; // Categoría del item (ej: "Preparación", "Verificación")
}
export interface ChecklistContent {
items: ChecklistItem[];
description?: string;
estimatedTime?: string;
applicableProtocols?: string[]; // IDs de protocolos donde se puede usar
tags?: string[];
}
export interface ChecklistReusable extends BaseContentItem {
type: 'checklist';
level: 'operativo';
content: ChecklistContent;
}
// ============================================
// BASE CONTENT ITEM
// ============================================
export interface BaseContentItem {
id: string; // ID estable (inmutable)
type: ContentType;
level: ContentLevel;
// Metadatos básicos
title: string;
shortTitle?: string;
description?: string;
icon?: string;
// Versionado
version: number;
latestVersion: number;
// Estado y validación
status: ContentStatus;
validatedBy?: string;
validatedAt?: string;
clinicalSource?: string;
// Auditoría
createdBy?: string;
createdAt: string;
updatedBy?: string;
updatedAt: string;
// Relaciones
relations?: {
protocols?: string[];
guides?: string[];
manuals?: string[];
drugs?: string[];
checklists?: string[];
};
}
// ============================================
// CONTENT PACK (Para la app)
// ============================================
export interface ContentPack {
version: string; // Versión del pack (ej: "1.2.3")
timestamp: string; // ISO timestamp
hash: string; // Hash del contenido para verificar integridad
// Contenido
protocols?: Protocol[];
guides?: Guide[];
manuals?: ManualChapter[];
drugs?: Drug[];
checklists?: ChecklistReusable[];
// Metadatos
publishedBy?: string;
publishedAt?: string;
clinicalSource?: string;
notes?: string;
}
// ============================================
// API RESPONSES
// ============================================
export interface ContentListResponse {
items: BaseContentItem[];
total: number;
page: number;
pageSize: number;
}
export interface ContentVersion {
versionId: string;
version: number;
content: any; // Contenido de esta versión
status: ContentStatus;
changeSummary?: string;
createdBy: string;
createdAt: string;
validatedBy?: string;
validatedAt?: string;
}
export interface AuditLog {
logId: string;
contentItemId: string;
versionId?: string;
userId: string;
action: 'create' | 'update' | 'delete' | 'validate' | 'approve' | 'publish' | 'revert';
details?: Record<string, any>;
timestamp: string;
}

66
admin-panel/src/App.tsx Normal file
View file

@ -0,0 +1,66 @@
/**
* Admin Panel - EMERGES TES
*
* Panel de administración para gestionar contenido
*/
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider } from './contexts/AuthContext';
import { ProtectedRoute } from './components/auth/ProtectedRoute';
import LoginPage from './pages/LoginPage';
import DashboardPage from './pages/DashboardPage';
import ContentLibraryPage from './pages/ContentLibraryPage';
import ProtocolEditorPage from './pages/ProtocolEditorPage';
import ChecklistEditorPage from './pages/ChecklistEditorPage';
import GuideEditorPage from './pages/GuideEditorPage';
import DrugManagerPage from './pages/DrugManagerPage';
import DrugEditorPage from './pages/DrugEditorPage';
import ContentPackPage from './pages/ContentPackPage';
import MediaManagerPage from './pages/MediaManagerPage';
import ValidationPage from './pages/ValidationPage';
import AuditPage from './pages/AuditPage';
import Layout from './components/layout/Layout';
function App() {
return (
<BrowserRouter>
<AuthProvider>
<Routes>
{/* Login (público) */}
<Route path="/login" element={<LoginPage />} />
{/* Rutas protegidas */}
<Route
path="/*"
element={
<ProtectedRoute>
<Layout>
<Routes>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/content" element={<ContentLibraryPage />} />
<Route path="/content/protocol/:id?" element={<ProtocolEditorPage />} />
<Route path="/content/checklist/:id?" element={<ChecklistEditorPage />} />
<Route path="/content/guide/:id?" element={<GuideEditorPage />} />
<Route path="/content/drug/:id?" element={<DrugManagerPage />} />
<Route path="/drugs" element={<DrugManagerPage />} />
<Route path="/drugs/new" element={<DrugEditorPage />} />
<Route path="/drugs/:id" element={<DrugEditorPage />} />
<Route path="/drugs/:id/edit" element={<DrugEditorPage />} />
<Route path="/content-pack" element={<ContentPackPage />} />
<Route path="/media" element={<MediaManagerPage />} />
<Route path="/validation" element={<ValidationPage />} />
<Route path="/audit" element={<AuditPage />} />
</Routes>
</Layout>
</ProtectedRoute>
}
/>
</Routes>
</AuthProvider>
</BrowserRouter>
);
}
export default App;

View file

@ -0,0 +1,43 @@
/**
* Componente para proteger rutas que requieren autenticación
*/
import { Navigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
interface ProtectedRouteProps {
children: React.ReactNode;
requiredPermission?: string;
}
export function ProtectedRoute({ children, requiredPermission }: ProtectedRouteProps) {
const { user, isLoading, hasPermission } = useAuth();
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-muted-foreground">Cargando...</div>
</div>
);
}
if (!user) {
return <Navigate to="/login" replace />;
}
if (requiredPermission && !hasPermission(requiredPermission)) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<h2 className="text-2xl font-bold mb-2">Acceso Denegado</h2>
<p className="text-muted-foreground">
No tienes permisos para acceder a esta sección.
</p>
</div>
</div>
);
}
return <>{children}</>;
}

View file

@ -0,0 +1,404 @@
/**
* Componente para gestionar recursos multimedia asociados a contenido
*/
import { useState, useEffect } from 'react';
import { Image, Video, Trash2, X, Search, Link2 } from 'lucide-react';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
interface MediaResource {
id: string;
type: 'image' | 'video';
file_url: string;
title: string;
description?: string;
alt_text?: string;
thumbnail_url?: string;
}
interface ResourceAssociation {
id: string;
media_resource_id: string;
section: string;
position: number;
placement: string;
caption?: string;
is_critical: boolean;
priority: string;
type: 'image' | 'video';
file_url: string;
title: string;
description?: string;
alt_text?: string;
}
interface ResourcesManagerProps {
contentId: string;
resources: ResourceAssociation[];
onResourcesChange: (resources: ResourceAssociation[]) => void;
showSelector: boolean;
onCloseSelector: () => void;
}
export default function ResourcesManager({
contentId,
resources,
onResourcesChange,
showSelector,
onCloseSelector,
}: ResourcesManagerProps) {
const [availableResources, setAvailableResources] = useState<MediaResource[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [selectedResource, setSelectedResource] = useState<MediaResource | null>(null);
const [associationData, setAssociationData] = useState({
section: 'general',
placement: 'inline',
caption: '',
is_critical: false,
priority: 'media',
});
// Cargar recursos asociados
useEffect(() => {
if (contentId) {
loadAssociatedResources();
}
}, [contentId]);
// Cargar recursos disponibles cuando se abre el selector
useEffect(() => {
if (showSelector) {
loadAvailableResources();
}
}, [showSelector, searchQuery]);
const loadAssociatedResources = async () => {
if (!contentId) return;
try {
const token = localStorage.getItem('admin_token');
const response = await fetch(`${API_URL}/api/content/${contentId}/resources`, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
const data = await response.json();
onResourcesChange(data.associations || []);
} catch (error) {
console.error('Error cargando recursos asociados:', error);
}
};
const loadAvailableResources = async () => {
setIsLoading(true);
try {
const token = localStorage.getItem('admin_token');
const params = new URLSearchParams({ page: '1', pageSize: '50' });
if (searchQuery) params.append('search', searchQuery);
const response = await fetch(`${API_URL}/api/media?${params}`, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
const data = await response.json();
setAvailableResources(data.items || []);
} catch (error) {
console.error('Error cargando recursos:', error);
} finally {
setIsLoading(false);
}
};
const handleAssociate = async () => {
if (!selectedResource || !contentId) return;
try {
const token = localStorage.getItem('admin_token');
const response = await fetch(`${API_URL}/api/content/${contentId}/resources`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
resource_id: selectedResource.id,
...associationData,
}),
});
if (response.ok) {
await loadAssociatedResources();
setSelectedResource(null);
setAssociationData({
section: 'general',
placement: 'inline',
caption: '',
is_critical: false,
priority: 'media',
});
onCloseSelector();
} else {
const error = await response.json();
alert(`Error: ${error.error || 'Error al asociar recurso'}`);
}
} catch (error) {
console.error('Error asociando recurso:', error);
alert('Error al asociar recurso');
}
};
const handleRemove = async (associationId: string) => {
if (!confirm('¿Estás seguro de eliminar esta asociación?')) return;
try {
const token = localStorage.getItem('admin_token');
const response = await fetch(
`${API_URL}/api/content/${contentId}/resources/${associationId}`,
{
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`,
},
}
);
if (response.ok) {
await loadAssociatedResources();
} else {
alert('Error al eliminar asociación');
}
} catch (error) {
console.error('Error eliminando asociación:', error);
alert('Error al eliminar asociación');
}
};
return (
<>
{/* Lista de recursos asociados */}
{resources.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<p>No hay recursos asociados. Haz clic en "Asociar Recurso" para comenzar.</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{resources.map((resource) => (
<div
key={resource.id}
className="border border-border rounded-lg overflow-hidden hover:shadow-lg transition-shadow"
>
<div className="aspect-video bg-muted flex items-center justify-center relative">
{resource.type === 'image' ? (
<img
src={`${API_URL}${resource.file_url}`}
alt={resource.alt_text || resource.title}
className="w-full h-full object-cover"
/>
) : (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Video className="w-12 h-12" />
<span className="text-sm">Vídeo</span>
</div>
)}
<div className="absolute top-2 right-2">
{resource.type === 'image' ? (
<Image className="w-4 h-4 text-white drop-shadow" />
) : (
<Video className="w-4 h-4 text-white drop-shadow" />
)}
</div>
</div>
<div className="p-4 space-y-2">
<h3 className="font-medium text-foreground truncate">{resource.title}</h3>
{resource.caption && (
<p className="text-sm text-muted-foreground line-clamp-2">{resource.caption}</p>
)}
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Sección: {resource.section}</span>
{resource.is_critical && (
<span className="px-2 py-0.5 bg-red-500/20 text-red-500 rounded text-xs">
Crítico
</span>
)}
</div>
<div className="flex gap-2 pt-2">
<button
onClick={() => window.open(`${API_URL}${resource.file_url}`, '_blank')}
className="flex-1 px-3 py-1.5 border border-border rounded-lg hover:bg-muted transition-colors text-sm flex items-center justify-center gap-1"
>
<Link2 className="w-3 h-3" />
Ver
</button>
<button
onClick={() => handleRemove(resource.id)}
className="px-3 py-1.5 border border-red-500/20 text-red-500 rounded-lg hover:bg-red-500/10 transition-colors"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</div>
</div>
))}
</div>
)}
{/* Modal selector de recursos */}
{showSelector && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-card border border-border rounded-xl w-full max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
<div className="p-4 border-b border-border flex items-center justify-between">
<h3 className="text-xl font-semibold text-foreground">Seleccionar Recurso</h3>
<button
onClick={onCloseSelector}
className="p-2 hover:bg-muted rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Búsqueda */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Buscar recursos..."
className="w-full pl-10 pr-4 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
{/* Lista de recursos */}
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : availableResources.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No se encontraron recursos
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{availableResources.map((resource) => (
<div
key={resource.id}
onClick={() => setSelectedResource(resource)}
className={`border rounded-lg overflow-hidden cursor-pointer transition-all ${
selectedResource?.id === resource.id
? 'border-primary ring-2 ring-primary'
: 'border-border hover:border-primary/50'
}`}
>
<div className="aspect-video bg-muted flex items-center justify-center relative">
{resource.type === 'image' ? (
<img
src={`${API_URL}${resource.file_url}`}
alt={resource.alt_text || resource.title}
className="w-full h-full object-cover"
/>
) : (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Video className="w-12 h-12" />
<span className="text-sm">Vídeo</span>
</div>
)}
</div>
<div className="p-2">
<p className="text-sm font-medium text-foreground truncate">
{resource.title}
</p>
</div>
</div>
))}
</div>
)}
{/* Formulario de asociación */}
{selectedResource && (
<div className="mt-4 p-4 bg-muted/50 border border-border rounded-lg space-y-3">
<h4 className="font-medium text-foreground">Configurar Asociación</h4>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-muted-foreground mb-1">Sección</label>
<select
value={associationData.section}
onChange={(e) =>
setAssociationData({ ...associationData, section: e.target.value })
}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary text-sm"
>
<option value="general">General</option>
<option value="pasos">Pasos</option>
<option value="checklist">Checklist</option>
<option value="dosis">Dosis</option>
<option value="header">Encabezado</option>
</select>
</div>
<div>
<label className="block text-xs text-muted-foreground mb-1">Ubicación</label>
<select
value={associationData.placement}
onChange={(e) =>
setAssociationData({ ...associationData, placement: e.target.value })
}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary text-sm"
>
<option value="inline">Inline</option>
<option value="header">Header</option>
<option value="sidebar">Sidebar</option>
</select>
</div>
</div>
<div>
<label className="block text-xs text-muted-foreground mb-1">Caption</label>
<input
type="text"
value={associationData.caption}
onChange={(e) =>
setAssociationData({ ...associationData, caption: e.target.value })
}
placeholder="Descripción opcional"
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary text-sm"
/>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={associationData.is_critical}
onChange={(e) =>
setAssociationData({ ...associationData, is_critical: e.target.checked })
}
className="form-checkbox"
/>
<span className="text-sm text-muted-foreground">Crítico</span>
</label>
</div>
</div>
)}
</div>
<div className="p-4 border-t border-border flex justify-end gap-2">
<button
onClick={onCloseSelector}
className="px-4 py-2 border border-border rounded-lg hover:bg-muted transition-colors"
>
Cancelar
</button>
<button
onClick={handleAssociate}
disabled={!selectedResource}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Asociar
</button>
</div>
</div>
</div>
)}
</>
);
}

View file

@ -0,0 +1,157 @@
/**
* Componente para mostrar historial de validación
*/
import { useState, useEffect } from 'react';
import { Clock, CheckCircle, XCircle, Send, FileText } from 'lucide-react';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
interface ValidationHistoryItem {
id: string;
action: string;
timestamp: string;
metadata: any;
username?: string;
role?: string;
}
interface ValidationHistoryProps {
contentId: string;
}
export default function ValidationHistory({ contentId }: ValidationHistoryProps) {
const [history, setHistory] = useState<ValidationHistoryItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
useEffect(() => {
if (isExpanded && contentId) {
loadHistory();
}
}, [isExpanded, contentId]);
const loadHistory = async () => {
setIsLoading(true);
try {
const token = localStorage.getItem('admin_token');
const response = await fetch(`${API_URL}/api/validation/history/${contentId}`, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
const data = await response.json();
setHistory(data.history || []);
} catch (error) {
console.error('Error cargando historial:', error);
} finally {
setIsLoading(false);
}
};
const getActionIcon = (action: string) => {
switch (action) {
case 'submit':
return Send;
case 'approve':
return CheckCircle;
case 'reject':
return XCircle;
case 'publish':
return FileText;
default:
return Clock;
}
};
const getActionColor = (action: string) => {
switch (action) {
case 'submit':
return 'text-blue-500';
case 'approve':
return 'text-green-500';
case 'reject':
return 'text-red-500';
case 'publish':
return 'text-purple-500';
default:
return 'text-muted-foreground';
}
};
const getActionLabel = (action: string) => {
const labels = {
submit: 'Enviado a revisión',
approve: 'Aprobado',
reject: 'Rechazado',
publish: 'Publicado',
};
return labels[action as keyof typeof labels] || action;
};
if (!contentId) return null;
return (
<div className="bg-muted/50 border border-border rounded-lg p-4">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between text-left"
>
<h3 className="font-medium text-foreground flex items-center gap-2">
<Clock className="w-4 h-4" />
Historial de Validación
</h3>
<span className="text-sm text-muted-foreground">
{history.length} evento{history.length !== 1 ? 's' : ''}
</span>
</button>
{isExpanded && (
<div className="mt-4 space-y-3">
{isLoading ? (
<div className="text-center text-muted-foreground py-4">
Cargando historial...
</div>
) : history.length === 0 ? (
<div className="text-center text-muted-foreground py-4">
No hay historial de validación
</div>
) : (
history.map((item) => {
const Icon = getActionIcon(item.action);
return (
<div
key={item.id}
className="flex items-start gap-3 p-3 bg-background rounded-lg border border-border"
>
<Icon className={`w-5 h-5 mt-0.5 ${getActionColor(item.action)}`} />
<div className="flex-1">
<div className="flex items-center justify-between">
<p className="font-medium text-foreground">
{getActionLabel(item.action)}
</p>
<p className="text-xs text-muted-foreground">
{new Date(item.timestamp).toLocaleString('es-ES')}
</p>
</div>
{item.username && (
<p className="text-sm text-muted-foreground mt-1">
Por {item.username} ({item.role})
</p>
)}
{item.metadata?.notes && (
<p className="text-sm text-muted-foreground mt-2 p-2 bg-muted rounded">
{item.metadata.notes}
</p>
)}
</div>
</div>
);
})
)}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,159 @@
/**
* Layout principal del admin panel
*/
import { ReactNode } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import {
LayoutDashboard,
FileText,
BookOpen,
Pill,
CheckSquare,
FileSearch,
Package,
Image,
ShieldCheck,
LogOut,
Menu,
X,
} from 'lucide-react';
import { useState } from 'react';
interface LayoutProps {
children: ReactNode;
}
export default function Layout({ children }: LayoutProps) {
const { user, logout } = useAuth();
const location = useLocation();
const [sidebarOpen, setSidebarOpen] = useState(false);
const navigation = [
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
{ name: 'Biblioteca', href: '/content', icon: FileText },
{ name: 'Vademécum', href: '/drugs', icon: Pill },
{ name: 'Recursos', href: '/media', icon: Image },
{ name: 'Content Pack', href: '/content-pack', icon: Package },
{ name: 'Validación', href: '/validation', icon: ShieldCheck },
{ name: 'Nuevo Checklist', href: '/content/checklist', icon: CheckSquare },
{ name: 'Auditoría', href: '/audit', icon: FileSearch },
];
return (
<div className="min-h-screen bg-background">
{/* Sidebar móvil */}
<div
className={`fixed inset-0 z-40 lg:hidden ${
sidebarOpen ? 'block' : 'hidden'
}`}
>
<div
className="fixed inset-0 bg-black/50"
onClick={() => setSidebarOpen(false)}
/>
<div className="fixed inset-y-0 left-0 w-64 bg-card border-r border-border">
<SidebarContent
navigation={navigation}
location={location}
user={user}
logout={logout}
onClose={() => setSidebarOpen(false)}
/>
</div>
</div>
{/* Sidebar desktop */}
<div className="hidden lg:fixed lg:inset-y-0 lg:flex lg:w-64">
<div className="flex flex-col w-64 bg-card border-r border-border">
<SidebarContent
navigation={navigation}
location={location}
user={user}
logout={logout}
/>
</div>
</div>
{/* Main content */}
<div className="lg:pl-64">
{/* Top bar */}
<div className="sticky top-0 z-30 bg-card border-b border-border px-4 py-3 flex items-center justify-between">
<button
onClick={() => setSidebarOpen(true)}
className="lg:hidden p-2 rounded-lg hover:bg-muted"
>
<Menu className="w-5 h-5" />
</button>
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground">
{user?.username} ({user?.role})
</span>
<button
onClick={logout}
className="p-2 rounded-lg hover:bg-muted text-muted-foreground"
>
<LogOut className="w-5 h-5" />
</button>
</div>
</div>
{/* Page content */}
<main>{children}</main>
</div>
</div>
);
}
function SidebarContent({
navigation,
location,
user,
logout,
onClose,
}: {
navigation: any[];
location: any;
user: any;
logout: () => void;
onClose?: () => void;
}) {
return (
<>
<div className="flex items-center justify-between p-4 border-b border-border">
<h1 className="text-xl font-bold text-foreground">EMERGES TES</h1>
{onClose && (
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-muted"
>
<X className="w-5 h-5" />
</button>
)}
</div>
<nav className="flex-1 p-4 space-y-1">
{navigation.map((item) => {
const Icon = item.icon;
const isActive = location.pathname === item.href;
return (
<Link
key={item.name}
to={item.href}
onClick={onClose}
className={`flex items-center gap-3 px-4 py-2.5 rounded-lg transition-colors ${
isActive
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
<Icon className="w-5 h-5" />
<span className="font-medium">{item.name}</span>
</Link>
);
})}
</nav>
</>
);
}

View file

@ -0,0 +1,113 @@
/**
* Contexto de autenticación
*/
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { authService } from '../services/auth';
import type { User, LoginRequest } from '../../shared/types/auth';
interface AuthContextType {
user: User | null;
token: string | null;
isLoading: boolean;
login: (credentials: LoginRequest) => Promise<void>;
logout: () => void;
hasPermission: (permission: string) => boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
// Cargar token y usuario desde localStorage al iniciar
useEffect(() => {
const storedToken = localStorage.getItem('admin_token');
const storedUser = localStorage.getItem('admin_user');
if (storedToken && storedUser) {
setToken(storedToken);
setUser(JSON.parse(storedUser));
// Verificar que el token sigue siendo válido
authService.verifyToken(storedToken).catch(() => {
logout();
});
}
setIsLoading(false);
}, []);
const login = async (credentials: LoginRequest) => {
const response = await authService.login(credentials);
setToken(response.token);
setUser(response.user);
localStorage.setItem('admin_token', response.token);
localStorage.setItem('admin_user', JSON.stringify(response.user));
};
const logout = () => {
setToken(null);
setUser(null);
localStorage.removeItem('admin_token');
localStorage.removeItem('admin_user');
};
const hasPermission = (permission: string): boolean => {
if (!user) return false;
const rolePermissions: Record<string, string[]> = {
super_admin: ['*'],
editor_clinico: [
'content:read',
'content:write:protocol',
'content:write:drug',
'content:write:checklist',
'content:submit',
],
editor_formativo: [
'content:read',
'content:write:guide',
'content:write:manual',
'content:submit',
],
revisor: [
'content:read',
'content:validate',
'content:approve',
'audit:read',
],
viewer: ['content:read'],
};
const permissions = rolePermissions[user.role] || [];
return permissions.includes(permission) || permissions.includes('*');
};
return (
<AuthContext.Provider
value={{
user,
token,
isLoading,
login,
logout,
hasPermission,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

View file

@ -0,0 +1,91 @@
/**
* Hook para obtener estadísticas de contenido
*/
import { useState, useEffect } from 'react';
import axios from 'axios';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
const api = axios.create({
baseURL: `${API_URL}/api`,
headers: {
'Content-Type': 'application/json',
},
});
// Interceptor para añadir token
api.interceptors.request.use((config) => {
const token = localStorage.getItem('admin_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
export function useContentStats() {
const [stats, setStats] = useState({
protocols: 0,
protocolsPublished: 0,
guides: 0,
guidesPublished: 0,
drugs: 0,
drugsPublished: 0,
checklists: 0,
checklistsPublished: 0,
});
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
async function fetchStats() {
try {
const response = await api.get('/stats/content');
const data = response.data;
console.log('📊 Estadísticas recibidas:', data);
// El backend ahora devuelve el formato esperado directamente
setStats({
protocols: data.protocols || 0,
protocolsPublished: data.protocolsPublished || 0,
guides: data.guides || 0,
guidesPublished: data.guidesPublished || 0,
drugs: data.drugs || 0,
drugsPublished: data.drugsPublished || 0,
checklists: data.checklists || 0,
checklistsPublished: data.checklistsPublished || 0,
});
console.log('✅ Estadísticas cargadas correctamente');
} catch (error: any) {
console.error('❌ Error cargando estadísticas:', error);
console.error('Detalles del error:', error.response?.data || error.message);
console.error('Status:', error.response?.status);
// Si es error 401, el token puede estar expirado
if (error.response?.status === 401) {
console.warn('⚠️ Token expirado o inválido. Por favor, inicia sesión nuevamente.');
}
// Mantener valores por defecto (0)
setStats({
protocols: 0,
protocolsPublished: 0,
guides: 0,
guidesPublished: 0,
drugs: 0,
drugsPublished: 0,
checklists: 0,
checklistsPublished: 0,
});
} finally {
setIsLoading(false);
}
}
fetchStats();
}, []);
return { stats, isLoading };
}

34
admin-panel/src/index.css Normal file
View file

@ -0,0 +1,34 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: hsl(var(--background));
color: hsl(var(--foreground));
}

11
admin-panel/src/main.tsx Normal file
View file

@ -0,0 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View file

@ -0,0 +1,23 @@
/**
* Página de Auditoría
*/
export default function AuditPage() {
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Auditoría</h1>
<p className="text-muted-foreground mt-1">
Logs de cambios y versiones
</p>
</div>
<div className="bg-card border border-border rounded-xl p-6">
<p className="text-muted-foreground">
Funcionalidad en desarrollo...
</p>
</div>
</div>
);
}

View file

@ -0,0 +1,481 @@
/**
* Editor de Checklist Reutilizable
*
* Permite crear y editar checklists reutilizables (electrodos DESA, preparación IOT, etc.)
*/
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Save, Plus, Trash2, GripVertical, AlertCircle, CheckCircle, Eye, ArrowLeft } from 'lucide-react';
import { contentService } from '../services/content';
import { useAuth } from '../contexts/AuthContext';
import type { ChecklistReusable, ChecklistItem } from '../../shared/types/content';
export default function ChecklistEditorPage() {
const { id } = useParams<{ id?: string }>();
const navigate = useNavigate();
const { hasPermission } = useAuth();
const isEdit = !!id;
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [showPreview, setShowPreview] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
// Estado del checklist
const [checklist, setChecklist] = useState<Partial<ChecklistReusable>>({
id: id || '',
type: 'checklist',
level: 'operativo',
title: '',
shortTitle: '',
description: '',
status: 'draft',
content: {
items: [],
description: '',
estimatedTime: '',
applicableProtocols: [],
tags: [],
},
});
// Cargar checklist existente
useEffect(() => {
if (isEdit && id) {
setIsLoading(true);
contentService
.getById(id)
.then((data: any) => {
setChecklist({
...data,
content: data.content || {
items: [],
description: '',
estimatedTime: '',
applicableProtocols: [],
tags: [],
},
});
})
.catch((error) => {
console.error('Error cargando checklist:', error);
setErrors({ general: 'Error al cargar el checklist' });
})
.finally(() => setIsLoading(false));
}
}, [id, isEdit]);
// Validación
const validate = (): boolean => {
const newErrors: Record<string, string> = {};
if (!checklist.id?.trim()) {
newErrors.id = 'ID es requerido';
}
if (!checklist.title?.trim()) {
newErrors.title = 'Título es requerido';
}
if (!checklist.content?.items || checklist.content.items.length === 0) {
newErrors.items = 'Debe tener al menos un item';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// Guardar
const handleSave = async () => {
if (!validate()) {
return;
}
if (!hasPermission('content:write:checklist')) {
setErrors({ general: 'No tienes permisos para editar checklists' });
return;
}
setIsSaving(true);
try {
const payload = {
id: checklist.id,
type: 'checklist',
level: 'operativo',
title: checklist.title,
shortTitle: checklist.shortTitle,
description: checklist.description,
content: checklist.content,
status: checklist.status || 'draft',
};
if (isEdit) {
await contentService.update(id!, payload);
} else {
await contentService.create(payload);
}
navigate('/content');
} catch (error: any) {
console.error('Error guardando checklist:', error);
setErrors({ general: error.response?.data?.error || 'Error al guardar' });
} finally {
setIsSaving(false);
}
};
// Gestión de items
const addItem = () => {
const newItem: ChecklistItem = {
id: `item-${Date.now()}`,
text: '',
order: (checklist.content?.items?.length || 0) + 1,
critical: false,
};
setChecklist({
...checklist,
content: {
...checklist.content!,
items: [...(checklist.content?.items || []), newItem],
},
});
};
const removeItem = (itemId: string) => {
const items = checklist.content?.items?.filter((item) => item.id !== itemId) || [];
// Reordenar
items.forEach((item, index) => {
item.order = index + 1;
});
setChecklist({
...checklist,
content: {
...checklist.content!,
items,
},
});
};
const updateItem = (itemId: string, updates: Partial<ChecklistItem>) => {
const items = checklist.content?.items?.map((item) =>
item.id === itemId ? { ...item, ...updates } : item
) || [];
setChecklist({
...checklist,
content: {
...checklist.content!,
items,
},
});
};
const moveItem = (itemId: string, direction: 'up' | 'down') => {
const items = [...(checklist.content?.items || [])];
const index = items.findIndex((item) => item.id === itemId);
if (index === -1) return;
const newIndex = direction === 'up' ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= items.length) return;
[items[index], items[newIndex]] = [items[newIndex], items[index]];
items.forEach((item, i) => {
item.order = i + 1;
});
setChecklist({
...checklist,
content: {
...checklist.content!,
items,
},
});
};
if (isLoading) {
return (
<div className="p-6">
<div className="text-muted-foreground">Cargando checklist...</div>
</div>
);
}
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<button
onClick={() => navigate('/content')}
className="flex items-center gap-2 text-muted-foreground hover:text-foreground mb-2"
>
<ArrowLeft className="w-4 h-4" />
<span>Volver</span>
</button>
<h1 className="text-3xl font-bold text-foreground">
{isEdit ? 'Editar Checklist' : 'Nuevo Checklist'}
</h1>
<p className="text-muted-foreground mt-1">
Crea y edita checklists reutilizables para protocolos
</p>
</div>
<div className="flex gap-2">
<button
onClick={() => setShowPreview(!showPreview)}
className="px-4 py-2 border border-border rounded-lg hover:bg-muted transition-colors flex items-center gap-2"
>
<Eye className="w-4 h-4" />
{showPreview ? 'Ocultar' : 'Vista'} Previa
</button>
<button
onClick={handleSave}
disabled={isSaving}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center gap-2 disabled:opacity-50"
>
<Save className="w-4 h-4" />
{isSaving ? 'Guardando...' : 'Guardar'}
</button>
</div>
</div>
{errors.general && (
<div className="bg-destructive/10 border border-destructive/30 rounded-lg p-4 flex items-center gap-2 text-destructive">
<AlertCircle className="w-5 h-5" />
<span>{errors.general}</span>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Formulario */}
<div className="space-y-6">
{/* Metadatos básicos */}
<div className="bg-card border border-border rounded-xl p-6 space-y-4">
<h2 className="text-xl font-semibold text-foreground">Información Básica</h2>
<div>
<label className="block text-sm font-medium mb-1">
ID <span className="text-destructive">*</span>
</label>
<input
type="text"
value={checklist.id || ''}
onChange={(e) => setChecklist({ ...checklist, id: e.target.value })}
disabled={isEdit}
className="w-full px-4 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="checklist-electrodos-desa"
/>
{errors.id && <p className="text-sm text-destructive mt-1">{errors.id}</p>}
</div>
<div>
<label className="block text-sm font-medium mb-1">
Título <span className="text-destructive">*</span>
</label>
<input
type="text"
value={checklist.title || ''}
onChange={(e) => setChecklist({ ...checklist, title: e.target.value })}
className="w-full px-4 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Colocación de Electrodos DESA"
/>
{errors.title && <p className="text-sm text-destructive mt-1">{errors.title}</p>}
</div>
<div>
<label className="block text-sm font-medium mb-1">Título Corto</label>
<input
type="text"
value={checklist.shortTitle || ''}
onChange={(e) => setChecklist({ ...checklist, shortTitle: e.target.value })}
className="w-full px-4 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Electrodos DESA"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Descripción</label>
<textarea
value={checklist.description || ''}
onChange={(e) => setChecklist({ ...checklist, description: e.target.value })}
rows={3}
className="w-full px-4 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Checklist para la correcta colocación de electrodos..."
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Tiempo Estimado</label>
<input
type="text"
value={checklist.content?.estimatedTime || ''}
onChange={(e) =>
setChecklist({
...checklist,
content: { ...checklist.content!, estimatedTime: e.target.value },
})
}
className="w-full px-4 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="30-60 segundos"
/>
</div>
</div>
{/* Items del Checklist */}
<div className="bg-card border border-border rounded-xl p-6 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-foreground">Items del Checklist</h2>
<button
onClick={addItem}
className="px-3 py-1.5 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center gap-2 text-sm"
>
<Plus className="w-4 h-4" />
Añadir Item
</button>
</div>
{errors.items && (
<p className="text-sm text-destructive">{errors.items}</p>
)}
<div className="space-y-3">
{checklist.content?.items?.map((item, index) => (
<div
key={item.id}
className="bg-muted/50 border border-border rounded-lg p-4 space-y-3"
>
<div className="flex items-start gap-3">
<div className="flex flex-col gap-1 pt-2">
<button
onClick={() => moveItem(item.id, 'up')}
disabled={index === 0}
className="p-1 hover:bg-muted rounded disabled:opacity-50"
>
<GripVertical className="w-4 h-4 text-muted-foreground" />
</button>
<span className="text-xs text-muted-foreground text-center w-6">
{item.order}
</span>
<button
onClick={() => moveItem(item.id, 'down')}
disabled={index === (checklist.content?.items?.length || 0) - 1}
className="p-1 hover:bg-muted rounded disabled:opacity-50"
>
<GripVertical className="w-4 h-4 text-muted-foreground" />
</button>
</div>
<div className="flex-1 space-y-2">
<input
type="text"
value={item.text}
onChange={(e) => updateItem(item.id, { text: e.target.value })}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary text-sm"
placeholder="Texto del item..."
/>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={item.critical || false}
onChange={(e) => updateItem(item.id, { critical: e.target.checked })}
className="rounded"
/>
<span className="text-muted-foreground">Crítico</span>
</label>
<input
type="text"
value={item.category || ''}
onChange={(e) => updateItem(item.id, { category: e.target.value })}
className="flex-1 px-3 py-1.5 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary text-sm"
placeholder="Categoría (opcional)"
/>
</div>
</div>
<button
onClick={() => removeItem(item.id)}
className="p-2 text-destructive hover:bg-destructive/10 rounded-lg transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
{(!checklist.content?.items || checklist.content.items.length === 0) && (
<div className="text-center py-8 text-muted-foreground">
<p>No hay items. Haz clic en "Añadir Item" para comenzar.</p>
</div>
)}
</div>
</div>
</div>
{/* Vista Previa */}
{showPreview && (
<div className="bg-card border border-border rounded-xl p-6 space-y-4">
<h2 className="text-xl font-semibold text-foreground">Vista Previa</h2>
<div className="space-y-4">
<div>
<h3 className="font-semibold text-foreground mb-2">
{checklist.title || 'Título del Checklist'}
</h3>
{checklist.description && (
<p className="text-sm text-muted-foreground mb-4">{checklist.description}</p>
)}
{checklist.content?.estimatedTime && (
<p className="text-xs text-muted-foreground mb-4">
Tiempo estimado: {checklist.content.estimatedTime}
</p>
)}
</div>
<div className="space-y-2">
{checklist.content?.items?.map((item, index) => (
<div
key={item.id}
className={`flex items-center gap-4 p-3 rounded-lg border-2 ${
item.critical
? 'bg-red-500/10 border-red-500/30'
: 'bg-muted/50 border-border'
}`}
>
<div
className={`w-10 h-10 rounded-lg border-2 flex items-center justify-center flex-shrink-0 ${
item.critical
? 'bg-red-500 border-red-500 text-white'
: 'bg-card border-muted-foreground text-muted-foreground'
}`}
>
{index + 1}
</div>
<div className="flex-1">
<p className="font-medium text-foreground">{item.text || 'Item sin texto'}</p>
{item.category && (
<p className="text-xs text-muted-foreground mt-1">
Categoría: {item.category}
</p>
)}
</div>
{item.critical && (
<span className="px-2 py-1 bg-red-500/20 text-red-600 dark:text-red-400 rounded text-xs font-medium">
Crítico
</span>
)}
</div>
))}
{(!checklist.content?.items || checklist.content.items.length === 0) && (
<p className="text-sm text-muted-foreground text-center py-4">
Añade items para ver la vista previa
</p>
)}
</div>
</div>
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,367 @@
/**
* Biblioteca de Contenido
*
* Vista tipo tabla con filtros para gestionar todo el contenido
*/
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Plus, Search, Filter, FileText, BookOpen, Pill, CheckSquare, Eye, Edit, Send } from 'lucide-react';
import { contentService } from '../services/content';
import { useAuth } from '../contexts/AuthContext';
import type { BaseContentItem, ContentType, ContentStatus } from '../../shared/types/content';
export default function ContentLibraryPage() {
const { hasPermission } = useAuth();
const [items, setItems] = useState<BaseContentItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(20);
// Filtros
const [typeFilter, setTypeFilter] = useState<ContentType | 'all'>('all');
const [statusFilter, setStatusFilter] = useState<ContentStatus | 'all'>('all');
const [searchQuery, setSearchQuery] = useState('');
const [submittingItem, setSubmittingItem] = useState<string | null>(null);
// Cargar contenido
const loadContent = async () => {
setIsLoading(true);
try {
const response = await contentService.list({
type: typeFilter !== 'all' ? typeFilter : undefined,
status: statusFilter !== 'all' ? statusFilter : undefined,
search: searchQuery || undefined,
page,
pageSize,
});
setItems(response.items || []);
setTotal(response.total || 0);
// Debug: mostrar en consola
if (response.items && response.items.length > 0) {
console.log(`✅ Cargados ${response.items.length} items de ${response.total} total`);
} else {
console.warn('⚠️ No se encontraron items. Verifica filtros y autenticación.');
}
} catch (error: any) {
console.error('❌ Error cargando contenido:', error);
console.error('Detalles:', error.response?.data || error.message);
// Si es error 401, redirigir a login
if (error.response?.status === 401) {
console.warn('⚠️ Token expirado o inválido. Redirigiendo a login...');
localStorage.removeItem('admin_token');
localStorage.removeItem('admin_user');
window.location.href = '/login';
}
setItems([]);
setTotal(0);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadContent();
}, [typeFilter, statusFilter, searchQuery, page]);
const handleSubmitForReview = async (itemId: string) => {
if (!confirm('¿Enviar este contenido a revisión?')) return;
setSubmittingItem(itemId);
try {
const token = localStorage.getItem('admin_token');
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
const response = await fetch(`${API_URL}/api/validation/submit/${itemId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({}),
});
if (response.ok) {
await loadContent();
} else {
const error = await response.json();
alert(`Error: ${error.error || 'Error al enviar a revisión'}`);
}
} catch (error) {
console.error('Error enviando a revisión:', error);
alert('Error al enviar a revisión');
} finally {
setSubmittingItem(null);
}
};
const getTypeIcon = (type: ContentType) => {
switch (type) {
case 'protocol':
return FileText;
case 'guide':
return BookOpen;
case 'drug':
return Pill;
case 'checklist':
return CheckSquare;
default:
return FileText;
}
};
const getStatusColor = (status: ContentStatus) => {
switch (status) {
case 'published':
return 'bg-green-500/20 text-green-600 dark:text-green-400';
case 'approved':
return 'bg-blue-500/20 text-blue-600 dark:text-blue-400';
case 'in_review':
return 'bg-yellow-500/20 text-yellow-600 dark:text-yellow-400';
case 'draft':
return 'bg-gray-500/20 text-gray-600 dark:text-gray-400';
default:
return 'bg-gray-500/20 text-gray-600 dark:text-gray-400';
}
};
const getEditRoute = (item: BaseContentItem) => {
switch (item.type) {
case 'protocol':
return `/content/protocol/${item.id}`;
case 'checklist':
return `/content/checklist/${item.id}`;
case 'guide':
return `/content/guide/${item.id}`;
case 'drug':
return `/content/drug/${item.id}`;
default:
return '/content';
}
};
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">Biblioteca de Contenido</h1>
<p className="text-muted-foreground mt-1">
Gestiona protocolos, guías, fármacos y checklists
</p>
</div>
<div className="flex gap-2">
{hasPermission('content:write:protocol') && (
<Link
to="/content/protocol"
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Nuevo Protocolo
</Link>
)}
{hasPermission('content:write:checklist') && (
<Link
to="/content/checklist"
className="px-4 py-2 border border-border rounded-lg hover:bg-muted transition-colors flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Nuevo Checklist
</Link>
)}
</div>
</div>
{/* Filtros */}
<div className="bg-card border border-border rounded-xl p-4 space-y-4">
<div className="flex flex-col md:flex-row gap-4">
{/* Búsqueda */}
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setPage(1);
}}
placeholder="Buscar por título..."
className="w-full pl-10 pr-4 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
</div>
{/* Filtro por tipo */}
<div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-muted-foreground" />
<select
value={typeFilter}
onChange={(e) => {
setTypeFilter(e.target.value as ContentType | 'all');
setPage(1);
}}
className="px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="all">Todos los tipos</option>
<option value="protocol">Protocolos</option>
<option value="guide">Guías</option>
<option value="drug">Fármacos</option>
<option value="checklist">Checklists</option>
</select>
</div>
{/* Filtro por estado */}
<select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value as ContentStatus | 'all');
setPage(1);
}}
className="px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="all">Todos los estados</option>
<option value="draft">Borrador</option>
<option value="in_review">En revisión</option>
<option value="approved">Aprobado</option>
<option value="published">Publicado</option>
</select>
</div>
</div>
{/* Tabla de contenido */}
<div className="bg-card border border-border rounded-xl overflow-hidden">
{isLoading ? (
<div className="p-8 text-center text-muted-foreground">Cargando...</div>
) : items.length === 0 ? (
<div className="p-8 text-center space-y-2">
<p className="text-muted-foreground">
No se encontró contenido con los filtros seleccionados
</p>
<p className="text-sm text-muted-foreground">
Total en base de datos: {total} items
</p>
{(typeFilter !== 'all' || statusFilter !== 'all' || searchQuery) && (
<button
onClick={() => {
setTypeFilter('all');
setStatusFilter('all');
setSearchQuery('');
}}
className="mt-4 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
>
Limpiar filtros
</button>
)}
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted/50 border-b border-border">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Tipo</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Título</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Estado</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Versión</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Actualizado</th>
<th className="px-4 py-3 text-right text-sm font-medium text-foreground">Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{items.map((item) => {
const Icon = getTypeIcon(item.type);
return (
<tr key={item.id} className="hover:bg-muted/50 transition-colors">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Icon className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground capitalize">{item.type}</span>
</div>
</td>
<td className="px-4 py-3">
<div>
<div className="font-medium text-foreground">{item.title}</div>
{item.shortTitle && (
<div className="text-sm text-muted-foreground">{item.shortTitle}</div>
)}
</div>
</td>
<td className="px-4 py-3">
<span
className={`px-2 py-1 rounded text-xs font-medium ${getStatusColor(
item.status
)}`}
>
{item.status}
</span>
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">
v{item.version}
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">
{new Date(item.updatedAt).toLocaleDateString('es-ES')}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
{hasPermission('content:read') && (
<Link
to={getEditRoute(item)}
className="p-2 hover:bg-muted rounded-lg transition-colors"
title="Ver/Editar"
>
<Eye className="w-4 h-4 text-muted-foreground" />
</Link>
)}
{hasPermission('content:submit') && item.status === 'draft' && (
<button
onClick={() => handleSubmitForReview(item.id)}
disabled={submittingItem === item.id}
className="p-2 hover:bg-muted rounded-lg transition-colors disabled:opacity-50"
title="Enviar a revisión"
>
<Send className={`w-4 h-4 text-muted-foreground ${submittingItem === item.id ? 'animate-pulse' : ''}`} />
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Paginación */}
{total > pageSize && (
<div className="px-4 py-3 border-t border-border flex items-center justify-between">
<div className="text-sm text-muted-foreground">
Mostrando {(page - 1) * pageSize + 1} - {Math.min(page * pageSize, total)} de {total}
</div>
<div className="flex gap-2">
<button
onClick={() => setPage(page - 1)}
disabled={page === 1}
className="px-3 py-1.5 border border-border rounded-lg hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
>
Anterior
</button>
<button
onClick={() => setPage(page + 1)}
disabled={page * pageSize >= total}
className="px-3 py-1.5 border border-border rounded-lg hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
>
Siguiente
</button>
</div>
</div>
)}
</>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,309 @@
/**
* Página de gestión de Content Pack
*
* Permite generar, listar y descargar Content Packs
*/
import { useState, useEffect } from 'react';
import { Download, Package, RefreshCw, CheckCircle, AlertCircle } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
interface ContentPack {
filename: string;
version: string;
total_items: number;
generated_at: string;
hash: string;
size: number;
is_latest: boolean;
}
export default function ContentPackPage() {
const { hasPermission } = useAuth();
const [packs, setPacks] = useState<ContentPack[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
const [newVersion, setNewVersion] = useState('1.0.0');
const [includeDraft, setIncludeDraft] = useState(false);
const [notes, setNotes] = useState('');
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const loadPacks = async () => {
setIsLoading(true);
try {
const token = localStorage.getItem('admin_token');
const response = await fetch(`${API_URL}/api/admin/content-pack/list`, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
const data = await response.json();
setPacks(data.packs || []);
} catch (error) {
console.error('Error cargando packs:', error);
setMessage({ type: 'error', text: 'Error al cargar Content Packs' });
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadPacks();
}, []);
const generatePack = async () => {
if (!newVersion) {
setMessage({ type: 'error', text: 'La versión es requerida' });
return;
}
setIsGenerating(true);
setMessage(null);
try {
const token = localStorage.getItem('admin_token');
const response = await fetch(`${API_URL}/api/admin/content-pack/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
version: newVersion,
includeDraft,
notes,
}),
});
const data = await response.json();
if (response.ok) {
setMessage({
type: 'success',
text: `Content Pack v${data.pack.version} generado exitosamente (${data.pack.total_items} items)`
});
setNewVersion('');
setNotes('');
await loadPacks();
} else {
setMessage({ type: 'error', text: data.error || 'Error al generar pack' });
}
} catch (error) {
console.error('Error generando pack:', error);
setMessage({ type: 'error', text: 'Error al generar Content Pack' });
} finally {
setIsGenerating(false);
}
};
const downloadPack = (pack: ContentPack) => {
const url = `${API_URL}/api/content-pack/${pack.version}.json`;
window.open(url, '_blank');
};
if (!hasPermission('content:read')) {
return (
<div className="p-6">
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-4 text-red-500">
No tienes permisos para ver esta página
</div>
</div>
);
}
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Content Pack</h1>
<p className="text-muted-foreground mt-1">
Genera y gestiona Content Packs para la app
</p>
</div>
{/* Mensaje */}
{message && (
<div
className={`p-4 rounded-lg border ${
message.type === 'success'
? 'bg-green-500/10 border-green-500/20 text-green-500'
: 'bg-red-500/10 border-red-500/20 text-red-500'
}`}
>
<div className="flex items-center gap-2">
{message.type === 'success' ? (
<CheckCircle className="w-5 h-5" />
) : (
<AlertCircle className="w-5 h-5" />
)}
<span>{message.text}</span>
</div>
</div>
)}
{/* Generar nuevo pack */}
{hasPermission('content:write') && (
<div className="bg-card border border-border rounded-xl p-6 space-y-4">
<h2 className="text-xl font-semibold text-foreground flex items-center gap-2">
<Package className="w-5 h-5" />
Generar Nuevo Content Pack
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Versión (semver)
</label>
<input
type="text"
value={newVersion}
onChange={(e) => setNewVersion(e.target.value)}
placeholder="1.0.0"
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div className="flex items-end">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={includeDraft}
onChange={(e) => setIncludeDraft(e.target.checked)}
className="form-checkbox"
/>
<span className="text-sm text-muted-foreground">
Incluir borradores
</span>
</label>
</div>
</div>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Notas (opcional)
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Notas sobre esta versión..."
rows={2}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<button
onClick={generatePack}
disabled={isGenerating || !newVersion}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
>
{isGenerating ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
Generando...
</>
) : (
<>
<Package className="w-4 h-4" />
Generar Pack
</>
)}
</button>
</div>
)}
{/* Lista de packs */}
<div className="bg-card border border-border rounded-xl overflow-hidden">
<div className="p-4 border-b border-border flex items-center justify-between">
<h2 className="text-xl font-semibold text-foreground flex items-center gap-2">
<Package className="w-5 h-5" />
Content Packs Generados
</h2>
<button
onClick={loadPacks}
disabled={isLoading}
className="p-2 hover:bg-muted rounded-lg transition-colors"
title="Actualizar"
>
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
</button>
</div>
{isLoading ? (
<div className="p-8 text-center text-muted-foreground">Cargando...</div>
) : packs.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
No hay Content Packs generados aún
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted/50 border-b border-border">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">
Versión
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">
Items
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">
Generado
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">
Hash
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">
Tamaño
</th>
<th className="px-4 py-3 text-right text-sm font-medium text-foreground">
Acciones
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{packs.map((pack) => (
<tr key={pack.filename} className="hover:bg-muted/50 transition-colors">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<span className="font-medium text-foreground">v{pack.version}</span>
{pack.is_latest && (
<span className="px-2 py-0.5 bg-primary/20 text-primary rounded text-xs font-medium">
Latest
</span>
)}
</div>
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">
{pack.total_items} items
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">
{new Date(pack.generated_at).toLocaleString('es-ES')}
</td>
<td className="px-4 py-3">
<code className="text-xs text-muted-foreground font-mono">
{pack.hash.substring(0, 16)}...
</code>
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">
{(pack.size / 1024).toFixed(2)} KB
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => downloadPack(pack)}
className="p-2 hover:bg-muted rounded-lg transition-colors"
title="Descargar"
>
<Download className="w-4 h-4 text-muted-foreground" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,179 @@
/**
* Dashboard principal
*/
import { useState, useEffect } from 'react';
import { useContentStats } from '../hooks/useContentStats';
import { FileText, BookOpen, Pill, CheckSquare, Users, Clock, ShieldCheck, AlertCircle } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import { Link } from 'react-router-dom';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
export default function DashboardPage() {
const { stats, isLoading } = useContentStats();
const { hasPermission } = useAuth();
const [validationStats, setValidationStats] = useState<any>(null);
const [pendingCount, setPendingCount] = useState(0);
useEffect(() => {
if (hasPermission('content:validate')) {
loadValidationStats();
}
}, []);
const loadValidationStats = async () => {
try {
const token = localStorage.getItem('admin_token');
const response = await fetch(`${API_URL}/api/stats/validation`, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
const data = await response.json();
setValidationStats(data);
setPendingCount(data.pending || 0);
} catch (error) {
console.error('Error cargando estadísticas de validación:', error);
}
};
if (isLoading) {
return <div className="p-6">Cargando estadísticas...</div>;
}
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Dashboard</h1>
<p className="text-muted-foreground mt-1">
Resumen del contenido y actividad
</p>
</div>
{/* Notificación de contenido pendiente */}
{hasPermission('content:validate') && pendingCount > 0 && (
<div className="bg-yellow-500/10 border border-yellow-500/20 rounded-xl p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<AlertCircle className="w-5 h-5 text-yellow-500" />
<div>
<p className="font-medium text-foreground">
{pendingCount} contenido{pendingCount !== 1 ? 's' : ''} pendiente{pendingCount !== 1 ? 's' : ''} de validación
</p>
<p className="text-sm text-muted-foreground">
Revisa y aprueba el contenido en la página de Validación
</p>
</div>
</div>
<Link
to="/validation"
className="px-4 py-2 bg-yellow-500 text-white rounded-lg hover:bg-yellow-600 transition-colors flex items-center gap-2"
>
<ShieldCheck className="w-4 h-4" />
Ir a Validación
</Link>
</div>
</div>
)}
{/* Estadísticas */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
icon={FileText}
title="Protocolos"
value={stats.protocols}
subtitle={`${stats.protocolsPublished} publicados`}
/>
<StatCard
icon={BookOpen}
title="Guías"
value={stats.guides}
subtitle={`${stats.guidesPublished} publicadas`}
/>
<StatCard
icon={Pill}
title="Fármacos"
value={stats.drugs}
subtitle={`${stats.drugsPublished} publicados`}
/>
<StatCard
icon={CheckSquare}
title="Checklists"
value={stats.checklists}
subtitle={`${stats.checklistsPublished} publicados`}
/>
</div>
{/* Estadísticas de validación */}
{hasPermission('content:validate') && validationStats && (
<div className="bg-card border border-border rounded-xl p-6">
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
<ShieldCheck className="w-5 h-5" />
Estadísticas de Validación
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className="text-sm text-muted-foreground">Pendientes</p>
<p className="text-2xl font-bold text-foreground mt-1">
{validationStats.pending || 0}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Aprobados</p>
<p className="text-2xl font-bold text-foreground mt-1">
{validationStats.byStatus?.approved || 0}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Tiempo Promedio</p>
<p className="text-2xl font-bold text-foreground mt-1">
{validationStats.avgValidationTime
? `${validationStats.avgValidationTime} días`
: 'N/A'}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Rechazos (30d)</p>
<p className="text-2xl font-bold text-foreground mt-1">
{validationStats.rejectionsLast30Days || 0}
</p>
</div>
</div>
</div>
)}
{/* Actividad reciente */}
<div className="bg-card border border-border rounded-xl p-6">
<h2 className="text-xl font-semibold mb-4">Actividad Reciente</h2>
<div className="space-y-2 text-sm text-muted-foreground">
<p>Funcionalidad en desarrollo...</p>
</div>
</div>
</div>
);
}
function StatCard({
icon: Icon,
title,
value,
subtitle,
}: {
icon: any;
title: string;
value: number;
subtitle: string;
}) {
return (
<div className="bg-card border border-border rounded-xl p-6">
<div className="flex items-center justify-between mb-2">
<Icon className="w-5 h-5 text-muted-foreground" />
<span className="text-2xl font-bold text-foreground">{value}</span>
</div>
<h3 className="font-medium text-foreground">{title}</h3>
<p className="text-sm text-muted-foreground mt-1">{subtitle}</p>
</div>
);
}

View file

@ -0,0 +1,647 @@
/**
* Editor de Fármaco (Vademécum TES)
*
* Editor completo para crear/editar fármacos
* Basado en: docs/VADEMECUM_COMPLETO_TES.md
*/
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Save, X, Send, CheckCircle, Plus, Trash2 } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
interface DrugFormData {
generic_name: string;
trade_name?: string;
category: string;
line: 'first' | 'second';
frequency: 'high' | 'medium' | 'low';
presentation: string;
adult_dose: string;
pediatric_dose?: string;
routes: string[];
dilution?: string;
indications: string[];
contraindications: string[];
side_effects?: string;
antidote?: string;
notes: string[];
critical_points: string[];
source?: string;
status: 'draft' | 'in_review' | 'approved' | 'published' | 'archived';
}
const ROUTES_OPTIONS = ['IV', 'IO', 'IM', 'Subcutánea', 'Oral', 'Rectal', 'Intranasal', 'Nebulización', 'MDI'];
const CATEGORIES = [
'cardiovascular',
'respiratorio',
'neurologico',
'analgesico',
'fluidos',
'antidoto',
'hemostatico',
'diuretico',
'corticosteroide',
'antiepileptico',
'anestesico',
'metabolico',
'antiagregante',
];
export default function DrugEditorPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { hasPermission } = useAuth();
const isNew = id === 'new';
const [isLoading, setIsLoading] = useState(!isNew);
const [isSaving, setIsSaving] = useState(false);
const [formData, setFormData] = useState<DrugFormData>({
generic_name: '',
trade_name: '',
category: 'cardiovascular',
line: 'first',
frequency: 'high',
presentation: '',
adult_dose: '',
pediatric_dose: '',
routes: [],
dilution: '',
indications: [],
contraindications: [],
side_effects: '',
antidote: '',
notes: [],
critical_points: [],
source: '',
status: 'draft',
});
// Cargar fármaco si es edición
useEffect(() => {
if (!isNew && id) {
loadDrug(id);
}
}, [id, isNew]);
const loadDrug = async (drugId: string) => {
setIsLoading(true);
try {
const token = localStorage.getItem('admin_token');
const response = await fetch(`${API_URL}/api/drugs/${drugId}`, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (response.ok) {
const drug = await response.json();
setFormData({
generic_name: drug.generic_name || '',
trade_name: drug.trade_name || '',
category: drug.category || 'cardiovascular',
line: drug.line || 'first',
frequency: drug.frequency || 'high',
presentation: drug.presentation || '',
adult_dose: drug.adult_dose || '',
pediatric_dose: drug.pediatric_dose || '',
routes: drug.routes || [],
dilution: drug.dilution || '',
indications: drug.indications || [],
contraindications: drug.contraindications || [],
side_effects: drug.side_effects || '',
antidote: drug.antidote || '',
notes: drug.notes || [],
critical_points: drug.critical_points || [],
source: drug.source || '',
status: drug.status || 'draft',
});
} else {
alert('Error cargando fármaco');
navigate('/drugs');
}
} catch (error) {
console.error('Error cargando fármaco:', error);
alert('Error cargando fármaco');
} finally {
setIsLoading(false);
}
};
const handleSave = async () => {
setIsSaving(true);
try {
const token = localStorage.getItem('admin_token');
const url = isNew ? `${API_URL}/api/drugs` : `${API_URL}/api/drugs/${id}`;
const method = isNew ? 'POST' : 'PUT';
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify(formData),
});
if (response.ok) {
const data = await response.json();
if (isNew) {
navigate(`/drugs/${data.drug.id}/edit`);
} else {
alert('Fármaco guardado correctamente');
}
} else {
const error = await response.json();
alert(`Error: ${error.error || 'Error al guardar'}\n${error.details?.join('\n') || ''}`);
}
} catch (error) {
console.error('Error guardando fármaco:', error);
alert('Error al guardar fármaco');
} finally {
setIsSaving(false);
}
};
const handleSubmit = async () => {
if (!confirm('¿Enviar este fármaco a revisión?')) return;
try {
const token = localStorage.getItem('admin_token');
const response = await fetch(`${API_URL}/api/drugs/${id}/submit`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
});
if (response.ok) {
alert('Fármaco enviado a revisión');
navigate('/drugs');
} else {
const error = await response.json();
alert(`Error: ${error.error || 'Error al enviar a revisión'}`);
}
} catch (error) {
console.error('Error enviando a revisión:', error);
alert('Error al enviar a revisión');
}
};
const addArrayItem = (field: 'routes' | 'indications' | 'contraindications' | 'notes' | 'critical_points') => {
setFormData(prev => ({
...prev,
[field]: [...prev[field], ''],
}));
};
const updateArrayItem = (
field: 'routes' | 'indications' | 'contraindications' | 'notes' | 'critical_points',
index: number,
value: string
) => {
setFormData(prev => ({
...prev,
[field]: prev[field].map((item, i) => i === index ? value : item),
}));
};
const removeArrayItem = (
field: 'routes' | 'indications' | 'contraindications' | 'notes' | 'critical_points',
index: number
) => {
setFormData(prev => ({
...prev,
[field]: prev[field].filter((_, i) => i !== index),
}));
};
if (isLoading) {
return <div className="p-6">Cargando fármaco...</div>;
}
return (
<div className="p-6 space-y-6 max-w-5xl mx-auto">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">
{isNew ? 'Nuevo Fármaco' : `Editar: ${formData.generic_name}`}
</h1>
<p className="text-muted-foreground mt-1">
{isNew ? 'Crear nuevo fármaco en el vademécum' : 'Editar información del fármaco'}
</p>
</div>
<div className="flex gap-2">
<button
onClick={() => navigate('/drugs')}
className="px-4 py-2 bg-background border border-border rounded-lg hover:bg-muted transition-colors flex items-center gap-2"
>
<X className="w-4 h-4" />
Cancelar
</button>
{!isNew && hasPermission('content:submit') && formData.status === 'draft' && (
<button
onClick={handleSubmit}
className="px-4 py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600 transition-colors flex items-center gap-2"
>
<Send className="w-4 h-4" />
Enviar a Revisión
</button>
)}
{!isNew && hasPermission('validation:approve') && formData.status === 'in_review' && (
<button
onClick={async () => {
if (!confirm('¿Aprobar este fármaco?')) return;
try {
const token = localStorage.getItem('admin_token');
const response = await fetch(`${API_URL}/api/drugs/${id}/approve`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ notes: '' }),
});
if (response.ok) {
alert('Fármaco aprobado');
navigate('/drugs');
} else {
const error = await response.json();
alert(`Error: ${error.error || 'Error al aprobar'}`);
}
} catch (error) {
console.error('Error aprobando fármaco:', error);
alert('Error al aprobar fármaco');
}
}}
className="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors flex items-center gap-2"
>
<CheckCircle className="w-4 h-4" />
Aprobar
</button>
)}
{!isNew && hasPermission('content:publish') && formData.status === 'approved' && (
<button
onClick={async () => {
if (!confirm('¿Publicar este fármaco? (Requiere pediatric_dose)')) return;
try {
const token = localStorage.getItem('admin_token');
const response = await fetch(`${API_URL}/api/drugs/${id}/publish`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
});
if (response.ok) {
alert('Fármaco publicado');
navigate('/drugs');
} else {
const error = await response.json();
alert(`Error: ${error.error || 'Error al publicar'}`);
}
} catch (error) {
console.error('Error publicando fármaco:', error);
alert('Error al publicar fármaco');
}
}}
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors flex items-center gap-2"
>
<CheckCircle className="w-4 h-4" />
Publicar
</button>
)}
<button
onClick={handleSave}
disabled={isSaving}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center gap-2 disabled:opacity-50"
>
<Save className="w-4 h-4" />
{isSaving ? 'Guardando...' : 'Guardar'}
</button>
</div>
</div>
{/* Información Básica */}
<div className="bg-card border border-border rounded-xl p-6 space-y-4">
<h2 className="text-xl font-semibold text-foreground">Información Básica</h2>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1">
Nombre Genérico <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.generic_name}
onChange={(e) => setFormData(prev => ({ ...prev, generic_name: e.target.value }))}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Ej: Adrenalina"
/>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">Nombre Comercial</label>
<input
type="text"
value={formData.trade_name}
onChange={(e) => setFormData(prev => ({ ...prev, trade_name: e.target.value }))}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Ej: Adrenalina 1mg/1ml"
/>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">
Categoría <span className="text-red-500">*</span>
</label>
<select
value={formData.category}
onChange={(e) => setFormData(prev => ({ ...prev, category: e.target.value }))}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
>
{CATEGORIES.map(cat => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">
Línea <span className="text-red-500">*</span>
</label>
<select
value={formData.line}
onChange={(e) => setFormData(prev => ({ ...prev, line: e.target.value as 'first' | 'second' }))}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="first">Primera línea</option>
<option value="second">Segunda línea</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">
Frecuencia <span className="text-red-500">*</span>
</label>
<select
value={formData.frequency}
onChange={(e) => setFormData(prev => ({ ...prev, frequency: e.target.value as 'high' | 'medium' | 'low' }))}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="high">Alta</option>
<option value="medium">Media</option>
<option value="low">Baja</option>
</select>
</div>
</div>
</div>
{/* Presentación y Dosificación */}
<div className="bg-card border border-border rounded-xl p-6 space-y-4">
<h2 className="text-xl font-semibold text-foreground">Presentación y Dosificación</h2>
<div>
<label className="block text-sm font-medium text-foreground mb-1">
Presentación <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.presentation}
onChange={(e) => setFormData(prev => ({ ...prev, presentation: e.target.value }))}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Ej: 1mg/1ml ampolla"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1">
Dosis Adulto <span className="text-red-500">*</span>
</label>
<textarea
value={formData.adult_dose}
onChange={(e) => setFormData(prev => ({ ...prev, adult_dose: e.target.value }))}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
rows={2}
placeholder="Ej: 1mg IV/IO cada 3-5 min"
/>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">
Dosis Pediátrica {formData.status === 'published' && <span className="text-red-500">*</span>}
</label>
<textarea
value={formData.pediatric_dose}
onChange={(e) => setFormData(prev => ({ ...prev, pediatric_dose: e.target.value }))}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
rows={2}
placeholder="Ej: 0.01mg/kg IV/IO"
/>
{formData.status === 'published' && !formData.pediatric_dose && (
<p className="text-xs text-red-500 mt-1">Obligatorio para publicar</p>
)}
</div>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">Vías de Administración</label>
<div className="flex flex-wrap gap-2">
{ROUTES_OPTIONS.map(route => (
<label key={route} className="flex items-center gap-2 px-3 py-2 bg-background border border-border rounded-lg cursor-pointer hover:bg-muted">
<input
type="checkbox"
checked={formData.routes.includes(route)}
onChange={(e) => {
if (e.target.checked) {
setFormData(prev => ({ ...prev, routes: [...prev.routes, route] }));
} else {
setFormData(prev => ({ ...prev, routes: prev.routes.filter(r => r !== route) }));
}
}}
className="rounded"
/>
<span className="text-sm">{route}</span>
</label>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">Dilución</label>
<input
type="text"
value={formData.dilution}
onChange={(e) => setFormData(prev => ({ ...prev, dilution: e.target.value }))}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Ej: Diluir en 20ml SF 0.9%"
/>
</div>
</div>
{/* Indicaciones y Contraindicaciones */}
<div className="bg-card border border-border rounded-xl p-6 space-y-4">
<h2 className="text-xl font-semibold text-foreground">Indicaciones y Contraindicaciones</h2>
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-foreground">Indicaciones</label>
<button
onClick={() => addArrayItem('indications')}
className="p-1 text-primary hover:bg-primary/10 rounded"
>
<Plus className="w-4 h-4" />
</button>
</div>
{formData.indications.map((indication, index) => (
<div key={index} className="flex gap-2 mb-2">
<input
type="text"
value={indication}
onChange={(e) => updateArrayItem('indications', index, e.target.value)}
className="flex-1 px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Ej: Parada cardiorrespiratoria (RCP)"
/>
<button
onClick={() => removeArrayItem('indications', index)}
className="p-2 text-red-500 hover:bg-red-500/10 rounded"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-foreground">Contraindicaciones</label>
<button
onClick={() => addArrayItem('contraindications')}
className="p-1 text-primary hover:bg-primary/10 rounded"
>
<Plus className="w-4 h-4" />
</button>
</div>
{formData.contraindications.map((contraindication, index) => (
<div key={index} className="flex gap-2 mb-2">
<input
type="text"
value={contraindication}
onChange={(e) => updateArrayItem('contraindications', index, e.target.value)}
className="flex-1 px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Ej: Hipertensión arterial severa"
/>
<button
onClick={() => removeArrayItem('contraindications', index)}
className="p-2 text-red-500 hover:bg-red-500/10 rounded"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">Efectos Adversos</label>
<textarea
value={formData.side_effects}
onChange={(e) => setFormData(prev => ({ ...prev, side_effects: e.target.value }))}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
rows={3}
placeholder="Ej: Taquicardia, hipertensión, arritmias..."
/>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">Antídoto</label>
<input
type="text"
value={formData.antidote}
onChange={(e) => setFormData(prev => ({ ...prev, antidote: e.target.value }))}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Ej: Naloxona (para opioides)"
/>
</div>
</div>
{/* Información Específica TES */}
<div className="bg-card border border-border rounded-xl p-6 space-y-4">
<h2 className="text-xl font-semibold text-foreground">Información Específica TES</h2>
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-foreground">Notas</label>
<button
onClick={() => addArrayItem('notes')}
className="p-1 text-primary hover:bg-primary/10 rounded"
>
<Plus className="w-4 h-4" />
</button>
</div>
{formData.notes.map((note, index) => (
<div key={index} className="flex gap-2 mb-2">
<textarea
value={note}
onChange={(e) => updateArrayItem('notes', index, e.target.value)}
className="flex-1 px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
rows={2}
placeholder="Ej: En RCP, administrar cada 3-5 minutos"
/>
<button
onClick={() => removeArrayItem('notes', index)}
className="p-2 text-red-500 hover:bg-red-500/10 rounded"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-foreground">Puntos Críticos TES</label>
<button
onClick={() => addArrayItem('critical_points')}
className="p-1 text-primary hover:bg-primary/10 rounded"
>
<Plus className="w-4 h-4" />
</button>
</div>
{formData.critical_points.map((point, index) => (
<div key={index} className="flex gap-2 mb-2">
<textarea
value={point}
onChange={(e) => updateArrayItem('critical_points', index, e.target.value)}
className="flex-1 px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
rows={2}
placeholder="Ej: Verificar dosis según peso en pediatría"
/>
<button
onClick={() => removeArrayItem('critical_points', index)}
className="p-2 text-red-500 hover:bg-red-500/10 rounded"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">Fuente</label>
<input
type="text"
value={formData.source}
onChange={(e) => setFormData(prev => ({ ...prev, source: e.target.value }))}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Ej: Manual TES Digital, ERC 2021"
/>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,425 @@
/**
* Manager de Vademécum TES
*
* Gestión completa de fármacos del vademécum
* Basado en: docs/VADEMECUM_COMPLETO_TES.md
*/
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Plus, Search, Filter, Pill, Eye, Edit, Send, CheckCircle, XCircle, Clock, FileSpreadsheet } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
interface Drug {
id: string;
slug: string;
generic_name: string;
trade_name?: string;
category: string;
line: 'first' | 'second';
frequency: 'high' | 'medium' | 'low';
presentation: string;
adult_dose: string;
pediatric_dose?: string;
routes: string[];
dilution?: string;
indications: string[];
contraindications: string[];
side_effects?: string;
antidote?: string;
notes: string[];
critical_points: string[];
source?: string;
status: 'draft' | 'submitted' | 'in_review' | 'approved' | 'published' | 'archived';
version: string;
created_at: string;
updated_at: string;
}
export default function DrugManagerPage() {
const { hasPermission } = useAuth();
const [drugs, setDrugs] = useState<Drug[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(20);
// Filtros
const [categoryFilter, setCategoryFilter] = useState<string>('all');
const [lineFilter, setLineFilter] = useState<'first' | 'second' | 'all'>('all');
const [frequencyFilter, setFrequencyFilter] = useState<'high' | 'medium' | 'low' | 'all'>('all');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [searchQuery, setSearchQuery] = useState('');
const [submittingId, setSubmittingId] = useState<string | null>(null);
// Cargar fármacos
const loadDrugs = async () => {
setIsLoading(true);
try {
const token = localStorage.getItem('admin_token');
const params = new URLSearchParams({
page: page.toString(),
limit: pageSize.toString(),
});
if (categoryFilter !== 'all') params.append('category', categoryFilter);
if (lineFilter !== 'all') params.append('line', lineFilter);
if (frequencyFilter !== 'all') params.append('frequency', frequencyFilter);
if (statusFilter !== 'all') params.append('status', statusFilter);
if (searchQuery) params.append('search', searchQuery);
const response = await fetch(`${API_URL}/api/drugs?${params}`, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (response.ok) {
const data = await response.json();
setDrugs(data.drugs || []);
setTotal(data.pagination?.total || 0);
} else {
console.error('Error cargando fármacos');
}
} catch (error) {
console.error('Error cargando fármacos:', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadDrugs();
}, [categoryFilter, lineFilter, frequencyFilter, statusFilter, searchQuery, page]);
const handleSubmit = async (drugId: string) => {
if (!confirm('¿Enviar este fármaco a revisión?')) return;
setSubmittingId(drugId);
try {
const token = localStorage.getItem('admin_token');
const response = await fetch(`${API_URL}/api/drugs/${drugId}/submit`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
});
if (response.ok) {
await loadDrugs();
} else {
const error = await response.json();
alert(`Error: ${error.error || 'Error al enviar a revisión'}`);
}
} catch (error) {
console.error('Error enviando a revisión:', error);
alert('Error al enviar a revisión');
} finally {
setSubmittingId(null);
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'published':
return 'bg-green-500/20 text-green-600 dark:text-green-400';
case 'approved':
return 'bg-blue-500/20 text-blue-600 dark:text-blue-400';
case 'in_review':
return 'bg-yellow-500/20 text-yellow-600 dark:text-yellow-400';
case 'draft':
return 'bg-gray-500/20 text-gray-600 dark:text-gray-400';
case 'archived':
return 'bg-red-500/20 text-red-600 dark:text-red-400';
default:
return 'bg-gray-500/20 text-gray-600 dark:text-gray-400';
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'published':
return CheckCircle;
case 'approved':
return CheckCircle;
case 'in_review':
return Clock;
case 'draft':
return Edit;
case 'archived':
return XCircle;
default:
return Edit;
}
};
const getFrequencyBadge = (frequency: string) => {
const colors = {
high: 'bg-red-500/20 text-red-600 dark:text-red-400',
medium: 'bg-orange-500/20 text-orange-600 dark:text-orange-400',
low: 'bg-yellow-500/20 text-yellow-600 dark:text-yellow-400',
};
return colors[frequency as keyof typeof colors] || 'bg-gray-500/20 text-gray-600';
};
const getLineBadge = (line: string) => {
return line === 'first'
? 'bg-purple-500/20 text-purple-600 dark:text-purple-400'
: 'bg-indigo-500/20 text-indigo-600 dark:text-indigo-400';
};
const categories = [
'cardiovascular',
'respiratorio',
'neurologico',
'analgesico',
'fluidos',
'antidoto',
'hemostatico',
'diuretico',
'corticosteroide',
'antiepileptico',
'anestesico',
'metabolico',
'antiagregante',
];
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">Vademécum TES</h1>
<p className="text-muted-foreground mt-1">
Gestión de fármacos del vademécum (35 fármacos)
</p>
</div>
{hasPermission('content:create') && (
<Link
to="/drugs/new"
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Nuevo Fármaco
</Link>
)}
</div>
{/* Filtros */}
<div className="bg-card border border-border rounded-xl p-4 space-y-4">
<div className="flex flex-wrap gap-4">
{/* Búsqueda avanzada */}
<div className="flex-1 min-w-[200px]">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
placeholder="Buscar por nombre genérico, comercial, categoría..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
</div>
{/* Filtro categoría */}
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="px-4 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="all">Todas las categorías</option>
{categories.map(cat => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
{/* Filtro línea */}
<select
value={lineFilter}
onChange={(e) => setLineFilter(e.target.value as any)}
className="px-4 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="all">Todas las líneas</option>
<option value="first">Primera línea</option>
<option value="second">Segunda línea</option>
</select>
{/* Filtro frecuencia */}
<select
value={frequencyFilter}
onChange={(e) => setFrequencyFilter(e.target.value as any)}
className="px-4 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="all">Todas las frecuencias</option>
<option value="high">Alta</option>
<option value="medium">Media</option>
<option value="low">Baja</option>
</select>
{/* Filtro estado */}
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-4 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="all">Todos los estados</option>
<option value="draft">Borrador</option>
<option value="in_review">En revisión</option>
<option value="approved">Aprobado</option>
<option value="published">Publicado</option>
<option value="archived">Archivado</option>
</select>
</div>
</div>
{/* Estadísticas rápidas */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-card border border-border rounded-xl p-4">
<div className="text-sm text-muted-foreground">Total Fármacos</div>
<div className="text-2xl font-bold text-foreground">{total}</div>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<div className="text-sm text-muted-foreground">Publicados</div>
<div className="text-2xl font-bold text-green-600 dark:text-green-400">
{drugs.filter(d => d.status === 'published').length}
</div>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<div className="text-sm text-muted-foreground">En Revisión</div>
<div className="text-2xl font-bold text-yellow-600 dark:text-yellow-400">
{drugs.filter(d => d.status === 'in_review').length}
</div>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<div className="text-sm text-muted-foreground">Borradores</div>
<div className="text-2xl font-bold text-gray-600 dark:text-gray-400">
{drugs.filter(d => d.status === 'draft').length}
</div>
</div>
</div>
{/* Tabla de fármacos */}
<div className="bg-card border border-border rounded-xl overflow-hidden">
{isLoading ? (
<div className="p-8 text-center text-muted-foreground">Cargando fármacos...</div>
) : drugs.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">No se encontraron fármacos</div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted/50">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Fármaco</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Categoría</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Línea</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Frecuencia</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Estado</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Versión</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{drugs.map((drug) => {
const StatusIcon = getStatusIcon(drug.status);
return (
<tr key={drug.id} className="hover:bg-muted/30">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Pill className="w-4 h-4 text-muted-foreground" />
<div>
<div className="font-medium text-foreground">{drug.generic_name}</div>
{drug.trade_name && (
<div className="text-sm text-muted-foreground">{drug.trade_name}</div>
)}
</div>
</div>
</td>
<td className="px-4 py-3 text-sm text-foreground capitalize">{drug.category}</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded text-xs font-medium ${getLineBadge(drug.line)}`}>
{drug.line === 'first' ? '1ª Línea' : '2ª Línea'}
</span>
</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded text-xs font-medium ${getFrequencyBadge(drug.frequency)}`}>
{drug.frequency === 'high' ? 'Alta' : drug.frequency === 'medium' ? 'Media' : 'Baja'}
</span>
</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded text-xs font-medium flex items-center gap-1 w-fit ${getStatusColor(drug.status)}`}>
<StatusIcon className="w-3 h-3" />
{drug.status}
</span>
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">{drug.version}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Link
to={`/drugs/${drug.id}`}
className="p-1.5 text-muted-foreground hover:text-foreground transition-colors"
title="Ver detalles"
>
<Eye className="w-4 h-4" />
</Link>
{hasPermission('content:edit') && (
<Link
to={`/drugs/${drug.id}/edit`}
className="p-1.5 text-muted-foreground hover:text-foreground transition-colors"
title="Editar"
>
<Edit className="w-4 h-4" />
</Link>
)}
{hasPermission('content:submit') && drug.status === 'draft' && (
<button
onClick={() => handleSubmit(drug.id)}
disabled={submittingId === drug.id}
className="p-1.5 text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
title="Enviar a revisión"
>
<Send className="w-4 h-4" />
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Paginación */}
{total > pageSize && (
<div className="px-4 py-3 border-t border-border flex items-center justify-between">
<div className="text-sm text-muted-foreground">
Mostrando {(page - 1) * pageSize + 1} - {Math.min(page * pageSize, total)} de {total}
</div>
<div className="flex gap-2">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
className="px-3 py-1 bg-background border border-border rounded hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
>
Anterior
</button>
<button
onClick={() => setPage(p => p + 1)}
disabled={page * pageSize >= total}
className="px-3 py-1 bg-background border border-border rounded hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
>
Siguiente
</button>
</div>
</div>
)}
</>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,581 @@
/**
* Editor de Guía Formativa
*
* Permite crear y editar guías formativas con:
* - 8 secciones configurables
* - Asociación de recursos multimedia
* - Enlaces a protocolos operativos
* - Exportación SCORM
*/
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Save,
Plus,
Trash2,
ArrowLeft,
BookOpen,
Image as ImageIcon,
Link2,
Eye,
Download,
Package,
} from 'lucide-react';
import { contentService } from '../services/content';
import { useAuth } from '../contexts/AuthContext';
import ResourcesManager from '../components/content/ResourcesManager';
import ValidationHistory from '../components/content/ValidationHistory';
interface GuideSection {
id: string;
title: string;
content: string; // Markdown
order: number;
}
export default function GuideEditorPage() {
const { id } = useParams<{ id?: string }>();
const navigate = useNavigate();
const { hasPermission } = useAuth();
const isEdit = !!id;
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [showPreview, setShowPreview] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const [activeTab, setActiveTab] = useState<'basic' | 'sections' | 'resources' | 'links'>('basic');
const [associatedResources, setAssociatedResources] = useState<any[]>([]);
const [showResourceSelector, setShowResourceSelector] = useState(false);
const [isGeneratingSCORM, setIsGeneratingSCORM] = useState(false);
const [scormMessage, setScormMessage] = useState<{ type: 'success' | 'error'; text: string; downloadUrl?: string } | null>(null);
// Estado de la guía
const [guide, setGuide] = useState({
id: id || '',
type: 'guide',
level: 'formativo',
title: '',
shortTitle: '',
description: '',
icono: 'book',
scormAvailable: false,
protocoloOperativo: null as string | null,
secciones: [] as GuideSection[],
status: 'draft',
priority: 'media',
});
// Cargar guía existente
useEffect(() => {
if (isEdit && id) {
setIsLoading(true);
contentService
.getById(id)
.then((data: any) => {
setGuide({
...data,
secciones: data.content?.secciones || [],
protocoloOperativo: data.content?.protocoloOperativo || null,
});
// Cargar recursos asociados
loadAssociatedResources(id);
})
.catch((error) => {
console.error('Error cargando guía:', error);
setErrors({ general: 'Error al cargar la guía' });
})
.finally(() => setIsLoading(false));
}
}, [id, isEdit]);
const loadAssociatedResources = async (contentId: string) => {
try {
const token = localStorage.getItem('admin_token');
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
const response = await fetch(`${API_URL}/api/content/${contentId}/resources`, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
const data = await response.json();
setAssociatedResources(data.associations || []);
} catch (error) {
console.error('Error cargando recursos asociados:', error);
}
};
const handleSave = async () => {
if (!guide.title) {
setErrors({ title: 'El título es requerido' });
return;
}
setIsSaving(true);
setErrors({});
try {
const contentData = {
icono: guide.icono,
scormAvailable: guide.scormAvailable,
secciones: guide.secciones,
protocoloOperativo: guide.protocoloOperativo,
};
if (isEdit && id) {
await contentService.update(id, {
title: guide.title,
shortTitle: guide.shortTitle,
description: guide.description,
level: guide.level,
status: guide.status,
priority: guide.priority,
content: contentData,
});
} else {
await contentService.create({
type: 'guide',
title: guide.title,
shortTitle: guide.shortTitle,
description: guide.description,
level: guide.level,
status: guide.status,
priority: guide.priority,
content: contentData,
});
}
navigate('/content');
} catch (error: any) {
console.error('Error guardando guía:', error);
setErrors({ general: error.message || 'Error al guardar la guía' });
} finally {
setIsSaving(false);
}
};
const handleGenerateSCORM = async () => {
if (!id) return;
setIsGeneratingSCORM(true);
setScormMessage(null);
try {
const token = localStorage.getItem('admin_token');
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
const response = await fetch(`${API_URL}/api/scorm/generate/${id}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ version: guide.version || '1.0.0' }),
});
const data = await response.json();
if (response.ok) {
setScormMessage({
type: 'success',
text: `Paquete SCORM generado exitosamente (${(data.size / 1024 / 1024).toFixed(2)} MB)`,
downloadUrl: `${API_URL}${data.downloadUrl}`,
});
} else {
setScormMessage({
type: 'error',
text: data.error || 'Error al generar paquete SCORM',
});
}
} catch (error) {
console.error('Error generando SCORM:', error);
setScormMessage({
type: 'error',
text: 'Error al generar paquete SCORM',
});
} finally {
setIsGeneratingSCORM(false);
}
};
const addSection = () => {
const newSection: GuideSection = {
id: `section-${Date.now()}`,
title: `Sección ${guide.secciones.length + 1}`,
content: '',
order: guide.secciones.length + 1,
};
setGuide({
...guide,
secciones: [...guide.secciones, newSection],
});
};
const updateSection = (sectionId: string, updates: Partial<GuideSection>) => {
setGuide({
...guide,
secciones: guide.secciones.map((s) =>
s.id === sectionId ? { ...s, ...updates } : s
),
});
};
const removeSection = (sectionId: string) => {
setGuide({
...guide,
secciones: guide.secciones.filter((s) => s.id !== sectionId),
});
};
const reorderSection = (sectionId: string, direction: 'up' | 'down') => {
const index = guide.secciones.findIndex((s) => s.id === sectionId);
if (index === -1) return;
const newIndex = direction === 'up' ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= guide.secciones.length) return;
const newSections = [...guide.secciones];
[newSections[index], newSections[newIndex]] = [
newSections[newIndex],
newSections[index],
];
newSections.forEach((s, i) => {
s.order = i + 1;
});
setGuide({ ...guide, secciones: newSections });
};
if (isLoading) {
return (
<div className="p-6">
<div className="text-center text-muted-foreground">Cargando guía...</div>
</div>
);
}
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<button
onClick={() => navigate('/content')}
className="mb-4 flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Volver
</button>
<h1 className="text-3xl font-bold text-foreground">
{isEdit ? 'Editar Guía' : 'Nueva Guía'}
</h1>
<p className="text-muted-foreground mt-1">
{isEdit ? 'Modifica la guía formativa' : 'Crea una nueva guía formativa'}
</p>
</div>
<div className="flex gap-2">
<button
onClick={() => setShowPreview(!showPreview)}
className="px-4 py-2 border border-border rounded-lg hover:bg-muted transition-colors flex items-center gap-2"
>
<Eye className="w-4 h-4" />
{showPreview ? 'Ocultar' : 'Mostrar'} Preview
</button>
<button
onClick={handleSave}
disabled={isSaving}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
>
<Save className="w-4 h-4" />
{isSaving ? 'Guardando...' : 'Guardar'}
</button>
</div>
</div>
{errors.general && (
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-4 text-red-500">
{errors.general}
</div>
)}
{scormMessage && (
<div
className={`p-4 rounded-lg border ${
scormMessage.type === 'success'
? 'bg-green-500/10 border-green-500/20 text-green-500'
: 'bg-red-500/10 border-red-500/20 text-red-500'
}`}
>
<div className="flex items-center justify-between">
<span>{scormMessage.text}</span>
{scormMessage.type === 'success' && (
<a
href={scormMessage.downloadUrl}
download
className="ml-4 px-3 py-1 bg-green-500 text-white rounded hover:bg-green-600 transition-colors flex items-center gap-2 text-sm"
>
<Download className="w-4 h-4" />
Descargar
</a>
)}
<button
onClick={() => setScormMessage(null)}
className="ml-4 text-current opacity-70 hover:opacity-100"
>
×
</button>
</div>
</div>
)}
<div className="space-y-6">
{/* Tabs */}
<div className="bg-card border border-border rounded-xl p-2">
<div className="flex gap-2 overflow-x-auto">
{[
{ id: 'basic', label: 'Básico' },
{ id: 'sections', label: 'Secciones' },
{ id: 'resources', label: 'Recursos' },
{ id: 'links', label: 'Enlaces' },
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap ${
activeTab === tab.id
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-muted'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
{/* Tab: Básico */}
{activeTab === 'basic' && (
<div className="bg-card border border-border rounded-xl p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Título *
</label>
<input
type="text"
value={guide.title}
onChange={(e) => setGuide({ ...guide, title: e.target.value })}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Ej: ABCDE Operativo"
/>
{errors.title && (
<p className="text-sm text-red-500 mt-1">{errors.title}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Título Corto
</label>
<input
type="text"
value={guide.shortTitle}
onChange={(e) => setGuide({ ...guide, shortTitle: e.target.value })}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="ABCDE"
/>
</div>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Descripción
</label>
<textarea
value={guide.description}
onChange={(e) => setGuide({ ...guide, description: e.target.value })}
rows={3}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Descripción de la guía formativa"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Estado
</label>
<select
value={guide.status}
onChange={(e) => setGuide({ ...guide, status: e.target.value })}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="draft">Borrador</option>
<option value="in_review">En Revisión</option>
<option value="approved">Aprobado</option>
<option value="published">Publicado</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Prioridad
</label>
<select
value={guide.priority}
onChange={(e) => setGuide({ ...guide, priority: e.target.value })}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="critica">Crítica</option>
<option value="alta">Alta</option>
<option value="media">Media</option>
<option value="baja">Baja</option>
</select>
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="scormAvailable"
checked={guide.scormAvailable}
onChange={(e) => setGuide({ ...guide, scormAvailable: e.target.checked })}
className="form-checkbox"
/>
<label htmlFor="scormAvailable" className="text-sm text-muted-foreground">
Disponible para exportación SCORM
</label>
</div>
</div>
)}
{/* Tab: Secciones */}
{activeTab === 'sections' && (
<div className="bg-card border border-border rounded-xl p-6 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-foreground">Secciones de la Guía</h2>
<button
onClick={addSection}
className="px-3 py-1.5 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center gap-2 text-sm"
>
<Plus className="w-4 h-4" />
Añadir Sección
</button>
</div>
<div className="space-y-4">
{guide.secciones.map((section, index) => (
<div
key={section.id}
className="bg-muted/50 border border-border rounded-lg p-4 space-y-3"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-muted-foreground">
Sección {section.order}
</span>
<button
onClick={() => reorderSection(section.id, 'up')}
disabled={index === 0}
className="p-1 hover:bg-muted rounded disabled:opacity-50"
title="Mover arriba"
>
</button>
<button
onClick={() => reorderSection(section.id, 'down')}
disabled={index === guide.secciones.length - 1}
className="p-1 hover:bg-muted rounded disabled:opacity-50"
title="Mover abajo"
>
</button>
</div>
<button
onClick={() => removeSection(section.id)}
className="p-2 text-red-500 hover:bg-red-500/10 rounded-lg transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<div>
<label className="block text-xs text-muted-foreground mb-1">Título</label>
<input
type="text"
value={section.title}
onChange={(e) => updateSection(section.id, { title: e.target.value })}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary text-sm"
placeholder="Título de la sección"
/>
</div>
<div>
<label className="block text-xs text-muted-foreground mb-1">
Contenido (Markdown)
</label>
<textarea
value={section.content}
onChange={(e) => updateSection(section.id, { content: e.target.value })}
rows={8}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary text-sm font-mono"
placeholder="Escribe el contenido en Markdown..."
/>
</div>
</div>
))}
{guide.secciones.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
<p>No hay secciones. Haz clic en "Añadir Sección" para comenzar.</p>
</div>
)}
</div>
</div>
)}
{/* Tab: Recursos */}
{activeTab === 'resources' && (
<div className="bg-card border border-border rounded-xl p-6 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-foreground">Recursos Multimedia</h2>
<button
onClick={() => setShowResourceSelector(true)}
className="px-3 py-1.5 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center gap-2 text-sm"
>
<Plus className="w-4 h-4" />
Asociar Recurso
</button>
</div>
<ResourcesManager
contentId={id || ''}
resources={associatedResources}
onResourcesChange={setAssociatedResources}
showSelector={showResourceSelector}
onCloseSelector={() => setShowResourceSelector(false)}
/>
</div>
)}
{/* Tab: Enlaces */}
{activeTab === 'links' && (
<div className="bg-card border border-border rounded-xl p-6 space-y-4">
<h2 className="text-xl font-semibold text-foreground">Enlaces a Protocolos</h2>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Protocolo Operativo Asociado
</label>
<input
type="text"
value={guide.protocoloOperativo || ''}
onChange={(e) => setGuide({ ...guide, protocoloOperativo: e.target.value || null })}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="ID del protocolo (ej: rcp-adulto-svb)"
/>
<p className="text-xs text-muted-foreground mt-1">
ID del protocolo operativo relacionado con esta guía formativa
</p>
</div>
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,112 @@
/**
* Página de login
*/
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { LogIn, AlertCircle } from 'lucide-react';
export default function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
await login({ email, password });
navigate('/dashboard');
} catch (err: any) {
setError(err.response?.data?.error || 'Error al iniciar sesión');
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<div className="w-full max-w-md">
<div className="bg-card border border-border rounded-xl p-8 space-y-6">
{/* Header */}
<div className="text-center space-y-2">
<div className="w-16 h-16 bg-primary/20 rounded-xl flex items-center justify-center mx-auto">
<LogIn className="w-8 h-8 text-primary" />
</div>
<h1 className="text-2xl font-bold text-foreground">
Admin Panel
</h1>
<p className="text-muted-foreground">
EMERGES TES - Gestión de Contenido
</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="bg-destructive/10 border border-destructive/30 rounded-lg p-3 flex items-center gap-2 text-destructive">
<AlertCircle className="w-4 h-4" />
<span className="text-sm">{error}</span>
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-4 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="admin@emerges-tes.local"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium mb-1">
Contraseña
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full px-4 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="••••••••"
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full py-2.5 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Iniciando sesión...' : 'Iniciar Sesión'}
</button>
</form>
{/* Credenciales por defecto */}
<div className="pt-4 border-t border-border">
<p className="text-xs text-muted-foreground text-center">
Credenciales por defecto:
</p>
<p className="text-xs text-muted-foreground text-center mt-1">
admin@emerges-tes.local / Admin123!
</p>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,479 @@
/**
* Gestor de Recursos Multimedia
*
* Permite subir, gestionar y asociar imágenes/vídeos a contenido
*/
import { useState, useEffect } from 'react';
import { Upload, Image, Video, Trash2, Search, Filter, Link2, AlertCircle } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
interface MediaResource {
id: string;
type: 'image' | 'video';
file_url: string;
title: string;
description?: string;
alt_text?: string;
tags?: string[];
priority: string;
status: string;
file_size: number;
format: string;
created_at: string;
}
export default function MediaManagerPage() {
const { hasPermission } = useAuth();
const [resources, setResources] = useState<MediaResource[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(20);
const [typeFilter, setTypeFilter] = useState<'all' | 'image' | 'video'>('all');
const [searchQuery, setSearchQuery] = useState('');
const [orphanedCount, setOrphanedCount] = useState(0);
const [showUpload, setShowUpload] = useState(false);
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [uploadData, setUploadData] = useState({
title: '',
description: '',
alt_text: '',
tags: '',
priority: 'media',
});
const loadResources = async () => {
setIsLoading(true);
try {
const token = localStorage.getItem('admin_token');
const params = new URLSearchParams({
page: page.toString(),
pageSize: pageSize.toString(),
});
if (typeFilter !== 'all') params.append('type', typeFilter);
if (searchQuery) params.append('search', searchQuery);
const response = await fetch(`${API_URL}/api/media?${params}`, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
const data = await response.json();
setResources(data.items || []);
setTotal(data.total || 0);
} catch (error) {
console.error('Error cargando recursos:', error);
} finally {
setIsLoading(false);
}
};
const loadOrphanedCount = async () => {
try {
const token = localStorage.getItem('admin_token');
const response = await fetch(`${API_URL}/api/media/orphaned/list`, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
const data = await response.json();
setOrphanedCount(data.total || 0);
} catch (error) {
console.error('Error cargando recursos huérfanos:', error);
}
};
useEffect(() => {
loadResources();
loadOrphanedCount();
}, [page, typeFilter, searchQuery]);
const handleUpload = async () => {
if (!uploadFile) return;
setIsUploading(true);
try {
const token = localStorage.getItem('admin_token');
const formData = new FormData();
formData.append('file', uploadFile);
formData.append('title', uploadData.title || uploadFile.name);
formData.append('description', uploadData.description);
formData.append('alt_text', uploadData.alt_text);
formData.append('tags', uploadData.tags);
formData.append('priority', uploadData.priority);
const response = await fetch(`${API_URL}/api/media/upload`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
},
body: formData,
});
if (response.ok) {
setShowUpload(false);
setUploadFile(null);
setUploadData({
title: '',
description: '',
alt_text: '',
tags: '',
priority: 'media',
});
await loadResources();
await loadOrphanedCount();
} else {
const error = await response.json();
alert(`Error: ${error.error || 'Error al subir archivo'}`);
}
} catch (error) {
console.error('Error subiendo archivo:', error);
alert('Error al subir archivo');
} finally {
setIsUploading(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm('¿Estás seguro de eliminar este recurso?')) return;
try {
const token = localStorage.getItem('admin_token');
const response = await fetch(`${API_URL}/api/media/${id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (response.ok) {
await loadResources();
await loadOrphanedCount();
} else {
alert('Error al eliminar recurso');
}
} catch (error) {
console.error('Error eliminando recurso:', error);
alert('Error al eliminar recurso');
}
};
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
};
if (!hasPermission('content:read')) {
return (
<div className="p-6">
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-4 text-red-500">
No tienes permisos para ver esta página
</div>
</div>
);
}
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">Recursos Multimedia</h1>
<p className="text-muted-foreground mt-1">
Gestiona imágenes y vídeos para el contenido
</p>
</div>
{hasPermission('content:write') && (
<button
onClick={() => setShowUpload(!showUpload)}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center gap-2"
>
<Upload className="w-4 h-4" />
Subir Recurso
</button>
)}
</div>
{/* Alerta recursos huérfanos */}
{orphanedCount > 0 && (
<div className="bg-yellow-500/10 border border-yellow-500/20 rounded-lg p-4 flex items-center gap-2 text-yellow-500">
<AlertCircle className="w-5 h-5" />
<span>
{orphanedCount} recurso{orphanedCount !== 1 ? 's' : ''} huérfano{orphanedCount !== 1 ? 's' : ''}
(sin asociar a contenido)
</span>
</div>
)}
{/* Formulario de upload */}
{showUpload && hasPermission('content:write') && (
<div className="bg-card border border-border rounded-xl p-6 space-y-4">
<h2 className="text-xl font-semibold text-foreground">Subir Nuevo Recurso</h2>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Archivo *
</label>
<input
type="file"
accept="image/*,video/*"
onChange={(e) => setUploadFile(e.target.files?.[0] || null)}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Título
</label>
<input
type="text"
value={uploadData.title}
onChange={(e) => setUploadData({ ...uploadData, title: e.target.value })}
placeholder="Título del recurso"
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Prioridad
</label>
<select
value={uploadData.priority}
onChange={(e) => setUploadData({ ...uploadData, priority: e.target.value })}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="critica">Crítica</option>
<option value="alta">Alta</option>
<option value="media">Media</option>
<option value="baja">Baja</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Descripción
</label>
<textarea
value={uploadData.description}
onChange={(e) => setUploadData({ ...uploadData, description: e.target.value })}
placeholder="Descripción del recurso"
rows={2}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Alt Text (para imágenes)
</label>
<input
type="text"
value={uploadData.alt_text}
onChange={(e) => setUploadData({ ...uploadData, alt_text: e.target.value })}
placeholder="Texto alternativo"
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Tags (separados por comas)
</label>
<input
type="text"
value={uploadData.tags}
onChange={(e) => setUploadData({ ...uploadData, tags: e.target.value })}
placeholder="tag1, tag2, tag3"
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div className="flex gap-2">
<button
onClick={handleUpload}
disabled={!uploadFile || isUploading}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isUploading ? 'Subiendo...' : 'Subir'}
</button>
<button
onClick={() => {
setShowUpload(false);
setUploadFile(null);
setUploadData({
title: '',
description: '',
alt_text: '',
tags: '',
priority: 'media',
});
}}
className="px-4 py-2 border border-border rounded-lg hover:bg-muted transition-colors"
>
Cancelar
</button>
</div>
</div>
)}
{/* Filtros */}
<div className="bg-card border border-border rounded-xl p-4 space-y-4">
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setPage(1);
}}
placeholder="Buscar por título, descripción..."
className="w-full pl-10 pr-4 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
</div>
<div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-muted-foreground" />
<select
value={typeFilter}
onChange={(e) => {
setTypeFilter(e.target.value as 'all' | 'image' | 'video');
setPage(1);
}}
className="px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="all">Todos los tipos</option>
<option value="image">Imágenes</option>
<option value="video">Vídeos</option>
</select>
</div>
</div>
</div>
{/* Lista de recursos */}
<div className="bg-card border border-border rounded-xl overflow-hidden">
{isLoading ? (
<div className="p-8 text-center text-muted-foreground">Cargando...</div>
) : resources.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
No se encontraron recursos con los filtros seleccionados
</div>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 p-4">
{resources.map((resource) => (
<div
key={resource.id}
className="border border-border rounded-lg overflow-hidden hover:shadow-lg transition-shadow"
>
<div className="aspect-video bg-muted flex items-center justify-center relative">
{resource.type === 'image' ? (
<img
src={`${API_URL}${resource.file_url}`}
alt={resource.alt_text || resource.title}
className="w-full h-full object-cover"
/>
) : (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Video className="w-12 h-12" />
<span className="text-sm">Vídeo</span>
</div>
)}
<div className="absolute top-2 right-2">
{resource.type === 'image' ? (
<Image className="w-4 h-4 text-white drop-shadow" />
) : (
<Video className="w-4 h-4 text-white drop-shadow" />
)}
</div>
</div>
<div className="p-4 space-y-2">
<h3 className="font-medium text-foreground truncate">{resource.title}</h3>
{resource.description && (
<p className="text-sm text-muted-foreground line-clamp-2">
{resource.description}
</p>
)}
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{formatFileSize(resource.file_size)}</span>
<span>{resource.format.toUpperCase()}</span>
</div>
{resource.tags && resource.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{resource.tags.slice(0, 3).map((tag, i) => (
<span
key={i}
className="px-2 py-0.5 bg-muted text-xs rounded text-muted-foreground"
>
{tag}
</span>
))}
</div>
)}
<div className="flex gap-2 pt-2">
<button
onClick={() => window.open(`${API_URL}${resource.file_url}`, '_blank')}
className="flex-1 px-3 py-1.5 border border-border rounded-lg hover:bg-muted transition-colors text-sm flex items-center justify-center gap-1"
>
<Link2 className="w-3 h-3" />
Ver
</button>
{hasPermission('content:write') && (
<button
onClick={() => handleDelete(resource.id)}
className="px-3 py-1.5 border border-red-500/20 text-red-500 rounded-lg hover:bg-red-500/10 transition-colors"
>
<Trash2 className="w-3 h-3" />
</button>
)}
</div>
</div>
</div>
))}
</div>
{/* Paginación */}
{total > pageSize && (
<div className="px-4 py-3 border-t border-border flex items-center justify-between">
<div className="text-sm text-muted-foreground">
Mostrando {((page - 1) * pageSize) + 1} - {Math.min(page * pageSize, total)} de {total} recursos
</div>
<div className="flex gap-2">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
className="px-3 py-1 border border-border rounded-lg hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Anterior
</button>
<span className="px-3 py-1 text-sm text-muted-foreground flex items-center">
Página {page} de {Math.ceil(total / pageSize)}
</span>
<button
onClick={() => setPage(p => Math.min(Math.ceil(total / pageSize), p + 1))}
disabled={page >= Math.ceil(total / pageSize)}
className="px-3 py-1 border border-border rounded-lg hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Siguiente
</button>
</div>
</div>
)}
</>
)}
</div>
</div>
);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,549 @@
/**
* Página de Validación de Contenido
*
* Permite a revisores y validadores aprobar/rechazar contenido
*/
import { useState, useEffect } from 'react';
import { CheckCircle, XCircle, Send, Eye, Clock, AlertCircle, BarChart3 } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import { useNavigate } from 'react-router-dom';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
interface PendingContent {
id: string;
type: string;
slug: string;
title: string;
shortTitle?: string;
description?: string;
status: string;
priority: string;
level: string;
created_at: string;
updated_at: string;
created_by_username?: string;
updated_by_username?: string;
}
export default function ValidationPage() {
const { hasPermission, user } = useAuth();
const navigate = useNavigate();
const [pendingItems, setPendingItems] = useState<PendingContent[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [typeFilter, setTypeFilter] = useState<string>('all');
const [priorityFilter, setPriorityFilter] = useState<string>('all');
const [selectedItem, setSelectedItem] = useState<PendingContent | null>(null);
const [actionNotes, setActionNotes] = useState('');
const [publishOnApprove, setPublishOnApprove] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [validationStats, setValidationStats] = useState<any>(null);
const [isLoadingStats, setIsLoadingStats] = useState(false);
useEffect(() => {
loadPendingItems();
if (hasPermission('content:validate')) {
loadValidationStats();
}
}, [typeFilter, priorityFilter]);
const loadValidationStats = async () => {
setIsLoadingStats(true);
try {
const token = localStorage.getItem('admin_token');
const response = await fetch(`${API_URL}/api/stats/validation`, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
const data = await response.json();
setValidationStats(data);
} catch (error) {
console.error('Error cargando estadísticas:', error);
} finally {
setIsLoadingStats(false);
}
};
const loadPendingItems = async () => {
setIsLoading(true);
try {
const token = localStorage.getItem('admin_token');
const params = new URLSearchParams();
if (typeFilter !== 'all') params.append('type', typeFilter);
if (priorityFilter !== 'all') params.append('priority', priorityFilter);
const response = await fetch(`${API_URL}/api/validation/pending?${params}`, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
const data = await response.json();
setPendingItems(data.items || []);
} catch (error) {
console.error('Error cargando contenido pendiente:', error);
setMessage({ type: 'error', text: 'Error al cargar contenido pendiente' });
} finally {
setIsLoading(false);
}
};
const handleApprove = async () => {
if (!selectedItem) return;
setIsProcessing(true);
setMessage(null);
try {
const token = localStorage.getItem('admin_token');
const response = await fetch(`${API_URL}/api/validation/approve/${selectedItem.id}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
notes: actionNotes,
publish: publishOnApprove,
}),
});
const data = await response.json();
if (response.ok) {
setMessage({ type: 'success', text: data.message });
setSelectedItem(null);
setActionNotes('');
setPublishOnApprove(false);
await loadPendingItems();
} else {
setMessage({ type: 'error', text: data.error || 'Error al aprobar contenido' });
}
} catch (error) {
console.error('Error aprobando contenido:', error);
setMessage({ type: 'error', text: 'Error al aprobar contenido' });
} finally {
setIsProcessing(false);
}
};
const handleReject = async () => {
if (!selectedItem) return;
if (!actionNotes.trim()) {
setMessage({ type: 'error', text: 'Las notas de rechazo son obligatorias' });
return;
}
setIsProcessing(true);
setMessage(null);
try {
const token = localStorage.getItem('admin_token');
const response = await fetch(`${API_URL}/api/validation/reject/${selectedItem.id}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
notes: actionNotes,
}),
});
const data = await response.json();
if (response.ok) {
setMessage({ type: 'success', text: data.message });
setSelectedItem(null);
setActionNotes('');
await loadPendingItems();
} else {
setMessage({ type: 'error', text: data.error || 'Error al rechazar contenido' });
}
} catch (error) {
console.error('Error rechazando contenido:', error);
setMessage({ type: 'error', text: 'Error al rechazar contenido' });
} finally {
setIsProcessing(false);
}
};
const getPriorityColor = (priority: string) => {
const colors = {
critica: 'bg-red-500/20 text-red-500',
alta: 'bg-orange-500/20 text-orange-500',
media: 'bg-yellow-500/20 text-yellow-500',
baja: 'bg-blue-500/20 text-blue-500',
};
return colors[priority as keyof typeof colors] || colors.media;
};
const getTypeLabel = (type: string) => {
const labels = {
protocol: 'Protocolo',
guide: 'Guía',
drug: 'Fármaco',
checklist: 'Checklist',
manual: 'Manual',
};
return labels[type as keyof typeof labels] || type;
};
if (!hasPermission('content:validate')) {
return (
<div className="p-6">
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-4 text-red-500">
No tienes permisos para validar contenido
</div>
</div>
);
}
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Validación de Contenido</h1>
<p className="text-muted-foreground mt-1">
Revisa y aprueba contenido pendiente de validación
</p>
</div>
{/* Estadísticas de Validación */}
{validationStats && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-card border border-border rounded-xl p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Pendientes</p>
<p className="text-2xl font-bold text-foreground mt-1">
{validationStats.pending || 0}
</p>
</div>
<Clock className="w-8 h-8 text-yellow-500 opacity-50" />
</div>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Aprobados</p>
<p className="text-2xl font-bold text-foreground mt-1">
{validationStats.byStatus?.approved || 0}
</p>
</div>
<CheckCircle className="w-8 h-8 text-green-500 opacity-50" />
</div>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Tiempo Promedio</p>
<p className="text-2xl font-bold text-foreground mt-1">
{validationStats.avgValidationTime
? `${validationStats.avgValidationTime} días`
: 'N/A'}
</p>
</div>
<AlertCircle className="w-8 h-8 text-blue-500 opacity-50" />
</div>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Rechazos (30d)</p>
<p className="text-2xl font-bold text-foreground mt-1">
{validationStats.rejectionsLast30Days || 0}
</p>
</div>
<XCircle className="w-8 h-8 text-red-500 opacity-50" />
</div>
</div>
</div>
)}
{message && (
<div
className={`p-4 rounded-lg border ${
message.type === 'success'
? 'bg-green-500/10 border-green-500/20 text-green-500'
: 'bg-red-500/10 border-red-500/20 text-red-500'
}`}
>
{message.text}
</div>
)}
{/* Estadísticas de Validación */}
{validationStats && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-card border border-border rounded-xl p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Pendientes</p>
<p className="text-2xl font-bold text-foreground mt-1">
{validationStats.pending || 0}
</p>
</div>
<Clock className="w-8 h-8 text-yellow-500 opacity-50" />
</div>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Aprobados</p>
<p className="text-2xl font-bold text-foreground mt-1">
{validationStats.byStatus?.approved || 0}
</p>
</div>
<CheckCircle className="w-8 h-8 text-green-500 opacity-50" />
</div>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Tiempo Promedio</p>
<p className="text-2xl font-bold text-foreground mt-1">
{validationStats.avgValidationTime
? `${validationStats.avgValidationTime} días`
: 'N/A'}
</p>
</div>
<AlertCircle className="w-8 h-8 text-blue-500 opacity-50" />
</div>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Rechazos (30d)</p>
<p className="text-2xl font-bold text-foreground mt-1">
{validationStats.rejectionsLast30Days || 0}
</p>
</div>
<XCircle className="w-8 h-8 text-red-500 opacity-50" />
</div>
</div>
</div>
)}
{/* Filtros */}
<div className="bg-card border border-border rounded-xl p-4 space-y-4">
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1">
<label className="block text-sm font-medium text-muted-foreground mb-1">
Tipo
</label>
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="all">Todos los tipos</option>
<option value="protocol">Protocolos</option>
<option value="guide">Guías</option>
<option value="drug">Fármacos</option>
<option value="checklist">Checklists</option>
</select>
</div>
<div className="flex-1">
<label className="block text-sm font-medium text-muted-foreground mb-1">
Prioridad
</label>
<select
value={priorityFilter}
onChange={(e) => setPriorityFilter(e.target.value)}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="all">Todas las prioridades</option>
<option value="critica">Crítica</option>
<option value="alta">Alta</option>
<option value="media">Media</option>
<option value="baja">Baja</option>
</select>
</div>
</div>
</div>
{/* Lista de contenido pendiente */}
<div className="bg-card border border-border rounded-xl overflow-hidden">
{isLoading ? (
<div className="p-8 text-center text-muted-foreground">Cargando...</div>
) : pendingItems.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
<CheckCircle className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>No hay contenido pendiente de validación</p>
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted/50 border-b border-border">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">
Tipo
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">
Título
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">
Prioridad
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">
Creado por
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">
Fecha
</th>
<th className="px-4 py-3 text-right text-sm font-medium text-foreground">
Acciones
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{pendingItems.map((item) => (
<tr key={item.id} className="hover:bg-muted/50 transition-colors">
<td className="px-4 py-3">
<span className="text-sm text-muted-foreground capitalize">
{getTypeLabel(item.type)}
</span>
</td>
<td className="px-4 py-3">
<div>
<div className="font-medium text-foreground">{item.title}</div>
{item.shortTitle && (
<div className="text-sm text-muted-foreground">{item.shortTitle}</div>
)}
</div>
</td>
<td className="px-4 py-3">
<span
className={`px-2 py-1 rounded text-xs font-medium ${getPriorityColor(
item.priority
)}`}
>
{item.priority}
</span>
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">
{item.created_by_username || 'N/A'}
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">
{new Date(item.created_at).toLocaleDateString('es-ES')}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => navigate(`/content/${item.type}/${item.id}`)}
className="p-2 hover:bg-muted rounded-lg transition-colors"
title="Ver/Editar"
>
<Eye className="w-4 h-4 text-muted-foreground" />
</button>
<button
onClick={() => setSelectedItem(item)}
className="px-3 py-1.5 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors text-sm flex items-center gap-2"
>
<CheckCircle className="w-4 h-4" />
Validar
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="px-4 py-3 border-t border-border bg-muted/30">
<div className="text-sm text-muted-foreground">
Total: {pendingItems.length} item{pendingItems.length !== 1 ? 's' : ''} pendiente
{pendingItems.length !== 1 ? 's' : ''}
</div>
</div>
</>
)}
</div>
{/* Modal de validación */}
{selectedItem && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-card border border-border rounded-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-foreground">
Validar: {selectedItem.title}
</h2>
<button
onClick={() => {
setSelectedItem(null);
setActionNotes('');
setPublishOnApprove(false);
}}
className="p-2 hover:bg-muted rounded-lg transition-colors"
>
×
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Notas (opcional para aprobación, obligatorio para rechazo)
</label>
<textarea
value={actionNotes}
onChange={(e) => setActionNotes(e.target.value)}
rows={4}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Añade notas sobre la validación..."
/>
</div>
{hasPermission('content:publish') && (
<div className="flex items-center gap-2">
<input
type="checkbox"
id="publishOnApprove"
checked={publishOnApprove}
onChange={(e) => setPublishOnApprove(e.target.checked)}
className="form-checkbox"
/>
<label htmlFor="publishOnApprove" className="text-sm text-muted-foreground">
Publicar automáticamente al aprobar
</label>
</div>
)}
<div className="flex gap-2 pt-4 border-t border-border">
<button
onClick={handleApprove}
disabled={isProcessing}
className="flex-1 px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2"
>
<CheckCircle className="w-4 h-4" />
Aprobar
</button>
<button
onClick={handleReject}
disabled={isProcessing || !actionNotes.trim()}
className="flex-1 px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2"
>
<XCircle className="w-4 h-4" />
Rechazar
</button>
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,48 @@
/**
* Servicio de autenticación
*/
import axios from 'axios';
import type { LoginRequest, LoginResponse, User } from '../../shared/types/auth';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
const api = axios.create({
baseURL: `${API_URL}/api`,
headers: {
'Content-Type': 'application/json',
},
});
// Interceptor para añadir token a las peticiones
api.interceptors.request.use((config) => {
const token = localStorage.getItem('admin_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
export const authService = {
async login(credentials: LoginRequest): Promise<LoginResponse> {
const response = await api.post('/auth/login', credentials);
return response.data;
},
async getCurrentUser(): Promise<User> {
const response = await api.get('/auth/me');
return response.data.user;
},
async verifyToken(token: string): Promise<boolean> {
try {
const response = await api.get('/auth/me', {
headers: { Authorization: `Bearer ${token}` },
});
return response.status === 200;
} catch {
return false;
}
},
};

View file

@ -0,0 +1,63 @@
/**
* Servicio de contenido
*/
import axios from 'axios';
import type { BaseContentItem, ContentListResponse } from '../../shared/types/content';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
const api = axios.create({
baseURL: `${API_URL}/api`,
headers: {
'Content-Type': 'application/json',
},
});
// Interceptor para añadir token
api.interceptors.request.use((config) => {
const token = localStorage.getItem('admin_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
export const contentService = {
async list(params?: {
type?: string;
level?: string;
status?: string;
page?: number;
pageSize?: number;
search?: string;
}): Promise<ContentListResponse> {
const response = await api.get('/content', { params });
return response.data;
},
async getById(id: string): Promise<BaseContentItem> {
const response = await api.get(`/content/${id}`);
return response.data;
},
async create(data: any): Promise<BaseContentItem> {
const response = await api.post('/content', data);
return response.data;
},
async update(id: string, data: any): Promise<BaseContentItem> {
const response = await api.put(`/content/${id}`, data);
return response.data;
},
async getVersions(id: string): Promise<any[]> {
const response = await api.get(`/content/${id}/versions`);
return response.data.versions;
},
async validate(id: string, approved: boolean): Promise<void> {
await api.post(`/content/${id}/validate`, { approved });
},
};

View file

@ -0,0 +1,34 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
border: "hsl(var(--border))",
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
},
},
},
plugins: [],
}

26
admin-panel/tsconfig.json Normal file
View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src", "../shared"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View file

@ -0,0 +1,12 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

View file

@ -0,0 +1,16 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5174,
},
});

1922
auditoria-assets-completa.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,52 @@
# ⚠️ CONFIGURACIÓN REQUERIDA
Para continuar con la FASE 1, necesitas configurar el password de PostgreSQL.
## Opción 1: Editar .env manualmente
```bash
cd backend
nano .env # o tu editor preferido
```
Completar la línea:
```
DB_PASSWORD=tu_password_postgres_aqui
```
## Opción 2: Si no tienes password (solo desarrollo local)
Si PostgreSQL está configurado sin password (trust authentication), puedes dejar vacío o usar:
```bash
cd backend
echo 'DB_PASSWORD=' >> .env
```
## Opción 3: Crear usuario específico (recomendado)
```bash
# Conectar como postgres
sudo -u postgres psql
# Crear usuario y base de datos
CREATE USER emerges_tes WITH PASSWORD 'password_seguro';
CREATE DATABASE emerges_tes OWNER emerges_tes;
GRANT ALL PRIVILEGES ON DATABASE emerges_tes TO emerges_tes;
\q
```
Luego en .env:
```
DB_USER=emerges_tes
DB_PASSWORD=password_seguro
```
## Verificar conexión
Después de configurar, probar:
```bash
cd backend
node -e "import('dotenv').then(d => d.default.config()); import('./config/database.js').then(m => m.testConnection())"
```

25
backend/ENV_TEMPLATE.md Normal file
View file

@ -0,0 +1,25 @@
# Configuración de Variables de Entorno
Crear archivo `.env` en `backend/` con el siguiente contenido:
```env
# Base de Datos PostgreSQL
DB_HOST=localhost
DB_PORT=5432
DB_NAME=emerges_tes
DB_USER=postgres
DB_PASSWORD=tu_password_aqui
# API Server
API_PORT=3000
API_HOST=localhost
NODE_ENV=development
# JWT Secret (generar con: openssl rand -base64 32)
JWT_SECRET=tu_jwt_secret_muy_seguro_aqui
JWT_EXPIRES_IN=24h
# CORS (múltiples orígenes separados por comas)
CORS_ORIGINS=http://localhost:8096,http://localhost:5174,http://localhost:5173
```

View file

@ -0,0 +1,46 @@
# 🔧 INSTRUCCIONES: Crear Usuario PostgreSQL
El usuario `planetazuzu` no existe en PostgreSQL. Necesitas crearlo primero.
## Opción 1: Ejecutar Script SQL (Recomendado)
```bash
cd backend
sudo -u postgres psql -f scripts/create-user.sql
```
Este script:
- ✅ Crea el usuario `planetazuzu` con password `Monforte.1977`
- ✅ Crea la base de datos `emerges_tes`
- ✅ Da todos los permisos necesarios
## Opción 2: Manual (si prefieres)
```bash
sudo -u postgres psql
```
Luego ejecutar en psql:
```sql
CREATE USER planetazuzu WITH PASSWORD 'Monforte.1977';
CREATE DATABASE emerges_tes OWNER planetazuzu;
GRANT ALL PRIVILEGES ON DATABASE emerges_tes TO planetazuzu;
\c emerges_tes
CREATE SCHEMA IF NOT EXISTS emerges_content;
GRANT ALL ON SCHEMA emerges_content TO planetazuzu;
\q
```
## Después de crear el usuario
Verificar conexión:
```bash
cd backend
npm run verify
```
Si funciona, continuar con:
```bash
npm run db:create # Crear tablas
npm run migrate # Migrar contenido
```

102
backend/README.md Normal file
View file

@ -0,0 +1,102 @@
# EMERGES TES - Backend API
Backend para gestión de contenido de EMERGES TES.
## 🚀 Inicio Rápido
### 1. Instalar dependencias
```bash
cd backend
npm install
```
### 2. Configurar variables de entorno
```bash
cp .env.example .env
# Editar .env con tus credenciales de PostgreSQL
```
### 3. Crear base de datos
```bash
npm run db:create
```
Este comando:
- Crea la base de datos `emerges_tes` si no existe
- Ejecuta todas las migraciones SQL
- Crea el esquema completo
### 4. Migrar contenido inicial
```bash
npm run migrate
```
Este comando migra el contenido de `src/data/*.ts` a PostgreSQL.
### 5. Iniciar servidor
```bash
npm run dev
```
El servidor estará disponible en `http://localhost:3000`
## 📁 Estructura
```
backend/
├── src/
│ ├── api/ # Endpoints de la API (FASE 2+)
│ ├── db/ # Utilidades de base de datos
│ ├── migrations/ # Migraciones de datos
│ └── utils/ # Utilidades
├── config/
│ └── database.js # Configuración de PostgreSQL
├── scripts/
│ ├── db-create.js # Crear BD y ejecutar migraciones
│ └── migrate-content.js # Migrar contenido TypeScript → PostgreSQL
└── package.json
```
## 🔧 Scripts Disponibles
- `npm run dev` - Iniciar servidor en modo desarrollo
- `npm start` - Iniciar servidor en producción
- `npm run db:create` - Crear base de datos y ejecutar migraciones
- `npm run migrate` - Migrar contenido desde TypeScript
## 📊 Estado de Implementación
### ✅ FASE 1: Infraestructura Base (Actual)
- ✅ Estructura de directorios
- ✅ Scripts SQL de creación de esquema
- ✅ Configuración de PostgreSQL
- ✅ Scripts de migración básicos
- ✅ Servidor Express básico
### ⏳ FASE 2: API REST (Próxima)
- Endpoints GET para lectura
- Sincronización de contenido
- Cache y optimización
### ⏳ FASE 3: Panel Admin (Futuro)
- Autenticación
- Editores de contenido
- Validación clínica
## 🔐 Seguridad
- Las credenciales de BD deben estar en `.env` (no commitear)
- JWT para autenticación (FASE 3)
- Validación de entrada en todos los endpoints
## 📝 Notas
- Este backend es independiente de la app React
- La app React solo LEE contenido (pull-only)
- El panel admin (futuro) será quien ESCRIBA contenido

View file

@ -0,0 +1,58 @@
/**
* Configuración de conexión a PostgreSQL
*
* FASE 1: Infraestructura Base
*
* IMPORTANTE: Usar variables de entorno para credenciales
*/
import pg from 'pg';
import dotenv from 'dotenv';
dotenv.config();
const { Pool } = pg;
/**
* Pool de conexiones a PostgreSQL
*/
export const pool = new Pool({
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432', 10),
database: process.env.DB_NAME || 'emerges_tes',
user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || '',
max: 20, // Máximo de conexiones en el pool
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
/**
* Test de conexión
*/
export async function testConnection() {
try {
const result = await pool.query('SELECT NOW()');
console.log('✅ Conexión a PostgreSQL exitosa:', result.rows[0].now);
return true;
} catch (error) {
console.error('❌ Error conectando a PostgreSQL:', error.message);
return false;
}
}
/**
* Función helper para ejecutar queries
* Envuelve pool.query para mantener compatibilidad
*/
export async function query(text, params) {
return await pool.query(text, params);
}
/**
* Cerrar pool de conexiones
*/
export async function closePool() {
await pool.end();
}

View file

@ -0,0 +1,59 @@
/**
* Configuración de conexión a PostgreSQL
*
* FASE 1: Infraestructura Base
*
* IMPORTANTE: Usar variables de entorno para credenciales
*/
import pg from 'pg';
import dotenv from 'dotenv';
dotenv.config();
const { Pool } = pg;
/**
* Pool de conexiones a PostgreSQL
*/
export const pool = new Pool({
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432', 10),
database: process.env.DB_NAME || 'emerges_tes',
user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || '',
max: 20, // Máximo de conexiones en el pool
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
/**
* Test de conexión
*/
export async function testConnection(): Promise<boolean> {
try {
const result = await pool.query('SELECT NOW()');
console.log('✅ Conexión a PostgreSQL exitosa:', result.rows[0].now);
return true;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error('❌ Error conectando a PostgreSQL:', errorMessage);
return false;
}
}
/**
* Función helper para ejecutar queries
* Envuelve pool.query para mantener compatibilidad
*/
export async function query(text: string, params?: any[]): Promise<pg.QueryResult> {
return await pool.query(text, params);
}
/**
* Cerrar pool de conexiones
*/
export async function closePool(): Promise<void> {
await pool.end();
}

54
backend/crear-usuario-y-bd.sh Executable file
View file

@ -0,0 +1,54 @@
#!/bin/bash
# Script para crear usuario y base de datos PostgreSQL
# Ejecutar: bash crear-usuario-y-bd.sh
echo "🔧 Creando usuario y base de datos PostgreSQL..."
echo ""
# Copiar SQL a /tmp para que postgres pueda acceder
cat > /tmp/create-user-emerges.sql << 'SQL'
-- Crear usuario si no existe
DO $$
BEGIN
IF NOT EXISTS (SELECT FROM pg_user WHERE usename = 'planetazuzu') THEN
CREATE USER planetazuzu WITH PASSWORD 'Monforte.1977';
RAISE NOTICE 'Usuario planetazuzu creado';
ELSE
RAISE NOTICE 'Usuario planetazuzu ya existe';
ALTER USER planetazuzu WITH PASSWORD 'Monforte.1977';
END IF;
END
$$;
-- Crear base de datos si no existe
SELECT 'CREATE DATABASE emerges_tes OWNER planetazuzu'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'emerges_tes')\gexec
-- Dar permisos
GRANT ALL PRIVILEGES ON DATABASE emerges_tes TO planetazuzu;
-- Conectar a la base de datos y dar permisos en el esquema
\c emerges_tes
-- Crear esquema si no existe
CREATE SCHEMA IF NOT EXISTS emerges_content;
-- Dar permisos en el esquema
GRANT ALL ON SCHEMA emerges_content TO planetazuzu;
ALTER DEFAULT PRIVILEGES IN SCHEMA emerges_content GRANT ALL ON TABLES TO planetazuzu;
ALTER DEFAULT PRIVILEGES IN SCHEMA emerges_content GRANT ALL ON SEQUENCES TO planetazuzu;
\q
SQL
# Ejecutar con sudo
echo "Ejecutando SQL (requiere contraseña de sudo)..."
sudo -u postgres psql -f /tmp/create-user-emerges.sql
# Limpiar
rm -f /tmp/create-user-emerges.sql
echo ""
echo "✅ Proceso completado"
echo ""
echo "Verificar con: cd backend && npm run verify"

View file

@ -0,0 +1,47 @@
-- ============================================
-- MIGRACIÓN 001: Crear Schema y Tabla de Usuarios
-- ============================================
--
-- Crea el schema emerges_content y la tabla de usuarios
-- necesaria para autenticación del admin panel
--
-- Crear schema si no existe
CREATE SCHEMA IF NOT EXISTS emerges_content;
-- Tabla de usuarios
CREATE TABLE IF NOT EXISTS emerges_content.users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE NOT NULL,
username TEXT NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'editor',
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_login TIMESTAMPTZ
);
-- Índices
CREATE INDEX IF NOT EXISTS idx_users_email ON emerges_content.users(email);
CREATE INDEX IF NOT EXISTS idx_users_role ON emerges_content.users(role);
CREATE INDEX IF NOT EXISTS idx_users_is_active ON emerges_content.users(is_active);
-- Trigger para actualizar updated_at
CREATE OR REPLACE FUNCTION emerges_content.update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER update_users_updated_at
BEFORE UPDATE ON emerges_content.users
FOR EACH ROW
EXECUTE FUNCTION emerges_content.update_updated_at_column();
-- Comentarios
COMMENT ON TABLE emerges_content.users IS 'Usuarios del sistema de administración';
COMMENT ON COLUMN emerges_content.users.role IS 'Rol del usuario: super_admin, editor_clinico, editor_formativo, revisor, viewer';

View file

@ -0,0 +1,217 @@
-- ============================================
-- MIGRACIÓN 002: Esquema de Vademécum TES (Drugs)
-- ============================================
--
-- Crea las tablas necesarias para el módulo de vademécum TES
-- Basado en: docs/VADEMECUM_COMPLETO_TES.md
--
-- IMPORTANTE: Este módulo es SOLO capa de REFERENCIA
-- NO modifica tablas existentes, solo añade nuevas
--
-- ============================================
-- ENUM: drug_line (Primera línea / Segunda línea)
-- ============================================
CREATE TYPE tes_content.drug_line AS ENUM (
'first', -- Primera línea (uso frecuente)
'second' -- Segunda línea (uso menos frecuente)
);
-- ============================================
-- ENUM: drug_frequency (Frecuencia de uso)
-- ============================================
CREATE TYPE tes_content.drug_frequency AS ENUM (
'high', -- Uso frecuente
'medium', -- Uso medio
'low' -- Uso poco frecuente
);
-- ============================================
-- TABLA: drugs
-- ============================================
CREATE TABLE tes_content.drugs (
-- Identificación
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
slug TEXT UNIQUE NOT NULL, -- Identificador legible único (ej: "adrenalina")
-- Información básica
generic_name TEXT NOT NULL, -- Nombre genérico (ej: "Adrenalina")
trade_name TEXT, -- Nombre comercial (ej: "Adrenalina 1mg/1ml")
-- Clasificación
category TEXT NOT NULL, -- Categoría farmacológica (ej: "cardiovascular", "respiratorio")
line tes_content.drug_line NOT NULL, -- Primera línea o segunda línea
frequency tes_content.drug_frequency NOT NULL, -- Frecuencia de uso
-- Presentación y dosificación
presentation TEXT NOT NULL, -- Presentación (ej: "1mg/1ml ampolla")
adult_dose TEXT NOT NULL, -- Dosis adulto (ej: "1mg IV/IO cada 3-5 min")
pediatric_dose TEXT, -- Dosis pediátrica (nullable pero validable)
routes TEXT[] DEFAULT '{}', -- Vías de administración (ej: ["IV", "IO", "IM"])
dilution TEXT, -- Dilución (si aplica)
-- Indicaciones y contraindicaciones
indications TEXT[] DEFAULT '{}', -- Indicaciones clínicas
contraindications TEXT[] DEFAULT '{}', -- Contraindicaciones
side_effects TEXT, -- Efectos adversos
antidote TEXT, -- Antídoto (si aplica)
-- Información específica TES
notes TEXT[] DEFAULT '{}', -- Notas importantes
critical_points TEXT[] DEFAULT '{}', -- Puntos críticos para TES
source TEXT, -- Fuente (ej: "Manual TES Digital")
-- Estado y validación
status tes_content.content_status NOT NULL DEFAULT 'draft',
-- Versionado
version TEXT NOT NULL DEFAULT '1.0.0',
latest_version TEXT NOT NULL DEFAULT '1.0.0',
current_version_id UUID, -- FK a drug_versions (versión actual)
-- Auditoría
created_by UUID NOT NULL REFERENCES tes_content.users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by UUID REFERENCES tes_content.users(id),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
published_by UUID REFERENCES tes_content.users(id),
published_at TIMESTAMPTZ,
-- Metadatos adicionales
metadata JSONB DEFAULT '{}',
-- Constraints
CONSTRAINT valid_version_format CHECK (version ~ '^\d+\.\d+\.\d+$'),
CONSTRAINT valid_latest_version_format CHECK (latest_version ~ '^\d+\.\d+\.\d+$'),
CONSTRAINT pediatric_dose_required_when_published CHECK (
(status = 'published'::tes_content.content_status AND pediatric_dose IS NOT NULL) OR
(status != 'published'::tes_content.content_status)
)
);
-- ============================================
-- TABLA: drug_versions
-- ============================================
CREATE TABLE tes_content.drug_versions (
-- Identificación
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
drug_id UUID NOT NULL REFERENCES tes_content.drugs(id) ON DELETE CASCADE,
version TEXT NOT NULL, -- Versión semántica (ej: "1.2.3")
-- Snapshot completo del fármaco
drug_snapshot JSONB NOT NULL, -- Snapshot completo del fármaco en esta versión
-- Cambios
change_summary TEXT NOT NULL, -- Resumen de cambios
change_details JSONB, -- Detalles de cambios (campos modificados, valores antiguos/nuevos)
-- Tipo de cambio
change_type TEXT NOT NULL DEFAULT 'patch', -- 'major' | 'minor' | 'patch'
is_breaking BOOLEAN DEFAULT false, -- ¿Es cambio incompatible?
-- Auditoría
created_by UUID NOT NULL REFERENCES tes_content.users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
published_at TIMESTAMPTZ,
published_by UUID REFERENCES tes_content.users(id),
-- Estado
is_active BOOLEAN NOT NULL DEFAULT false, -- ¿Es la versión activa?
-- Constraints
CONSTRAINT valid_version_format CHECK (version ~ '^\d+\.\d+\.\d+$'),
CONSTRAINT unique_drug_version UNIQUE (drug_id, version)
);
-- ============================================
-- ÍNDICES
-- ============================================
-- drugs
CREATE INDEX idx_drugs_slug ON tes_content.drugs(slug);
CREATE INDEX idx_drugs_category ON tes_content.drugs(category);
CREATE INDEX idx_drugs_line ON tes_content.drugs(line);
CREATE INDEX idx_drugs_frequency ON tes_content.drugs(frequency);
CREATE INDEX idx_drugs_status ON tes_content.drugs(status);
CREATE INDEX idx_drugs_generic_name ON tes_content.drugs USING GIN(to_tsvector('spanish', generic_name));
CREATE INDEX idx_drugs_published ON tes_content.drugs(status, updated_at DESC) WHERE status = 'published';
-- drug_versions
CREATE INDEX idx_drug_versions_drug_id ON tes_content.drug_versions(drug_id);
CREATE INDEX idx_drug_versions_version ON tes_content.drug_versions(version);
CREATE INDEX idx_drug_versions_active ON tes_content.drug_versions(is_active) WHERE is_active = true;
CREATE INDEX idx_drug_versions_created_at ON tes_content.drug_versions(created_at DESC);
-- ============================================
-- TRIGGERS
-- ============================================
-- Trigger para actualizar updated_at automáticamente
CREATE TRIGGER update_drugs_updated_at
BEFORE UPDATE ON tes_content.drugs
FOR EACH ROW
EXECUTE FUNCTION tes_content.update_updated_at_column();
-- ============================================
-- VISTAS ÚTILES
-- ============================================
-- Vista: Fármacos publicados
CREATE OR REPLACE VIEW tes_content.published_drugs AS
SELECT
d.id,
d.slug,
d.generic_name,
d.trade_name,
d.category,
d.line,
d.frequency,
d.presentation,
d.adult_dose,
d.pediatric_dose,
d.routes,
d.dilution,
d.indications,
d.contraindications,
d.side_effects,
d.antidote,
d.notes,
d.critical_points,
d.source,
d.version,
d.created_at,
d.updated_at
FROM tes_content.drugs d
WHERE d.status = 'published'::tes_content.content_status
AND d.version = d.latest_version;
-- Vista: Estadísticas de fármacos
CREATE OR REPLACE VIEW tes_content.drug_stats AS
SELECT
category,
line,
frequency,
status,
COUNT(*) as count,
COUNT(CASE WHEN status = 'published' THEN 1 END) as published_count
FROM tes_content.drugs
GROUP BY category, line, frequency, status;
-- ============================================
-- COMENTARIOS
-- ============================================
COMMENT ON TABLE tes_content.drugs IS 'Vademécum TES: Fármacos de referencia para técnicos en emergencias sanitarias';
COMMENT ON COLUMN tes_content.drugs.line IS 'Primera línea (uso frecuente) o segunda línea (uso menos frecuente)';
COMMENT ON COLUMN tes_content.drugs.frequency IS 'Frecuencia de uso: alta, media o baja';
COMMENT ON COLUMN tes_content.drugs.pediatric_dose IS 'Dosis pediátrica. Obligatoria cuando status = published';
COMMENT ON TABLE tes_content.drug_versions IS 'Versiones históricas de fármacos para versionado y rollback';
-- ============================================
-- FIN DE LA MIGRACIÓN
-- ============================================

View file

@ -0,0 +1,267 @@
-- ============================================
-- MIGRACIÓN 003: Esquema de Content Items (tes_content)
-- ============================================
--
-- Crea las tablas necesarias para content_items en tes_content
-- Unifica el schema para que Content Pack Generator funcione correctamente
--
-- IMPORTANTE: Este schema es compatible con el Content Pack Generator
-- que busca tes_content.content_items
--
-- Crear schema si no existe
CREATE SCHEMA IF NOT EXISTS tes_content;
-- Extensión para UUIDs
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- ============================================
-- ENUM: content_status
-- ============================================
CREATE TYPE tes_content.content_status AS ENUM (
'draft',
'in_review',
'approved',
'published',
'archived'
);
-- ============================================
-- ENUM: content_priority
-- ============================================
CREATE TYPE tes_content.content_priority AS ENUM (
'critica',
'alta',
'media',
'baja'
);
-- ============================================
-- TABLA: content_items
-- Propósito: Almacena todos los tipos de contenido (protocolos, guías, etc.)
-- ============================================
CREATE TABLE IF NOT EXISTS tes_content.content_items (
-- Identificación
id VARCHAR(100) PRIMARY KEY,
type VARCHAR(50) NOT NULL,
slug VARCHAR(200) UNIQUE NOT NULL,
level VARCHAR(50) NOT NULL,
-- Contenido
title VARCHAR(500) NOT NULL,
short_title VARCHAR(200),
description TEXT,
content JSONB NOT NULL,
content_markdown TEXT,
-- Metadatos
category VARCHAR(100),
subcategory VARCHAR(100),
priority tes_content.content_priority,
age_group VARCHAR(20),
clinical_context TEXT,
source_guideline VARCHAR(200),
-- Tags y clasificación
tags TEXT[],
-- Versionado
version INTEGER NOT NULL DEFAULT 1,
latest_version INTEGER NOT NULL DEFAULT 1,
-- Estado y validación
status tes_content.content_status NOT NULL DEFAULT 'draft',
validated_by UUID,
validated_at TIMESTAMPTZ,
clinical_source VARCHAR(200),
quality_score INTEGER CHECK (quality_score IS NULL OR (quality_score >= 0 AND quality_score <= 100)),
-- Revisión
reviewed_by UUID,
reviewed_at TIMESTAMPTZ,
-- Auditoría
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by UUID NOT NULL,
-- Constraints
CONSTRAINT chk_type CHECK (type IN ('protocol', 'guide', 'manual', 'checklist')),
CONSTRAINT chk_level CHECK (level IN ('operativo', 'formativo', 'referencia'))
);
-- Índices para content_items
CREATE INDEX IF NOT EXISTS idx_content_items_type ON tes_content.content_items(type);
CREATE INDEX IF NOT EXISTS idx_content_items_level ON tes_content.content_items(level);
CREATE INDEX IF NOT EXISTS idx_content_items_status ON tes_content.content_items(status);
CREATE INDEX IF NOT EXISTS idx_content_items_category ON tes_content.content_items(category);
CREATE INDEX IF NOT EXISTS idx_content_items_slug ON tes_content.content_items(slug);
CREATE INDEX IF NOT EXISTS idx_content_items_validated_at ON tes_content.content_items(validated_at);
CREATE INDEX IF NOT EXISTS idx_content_items_updated_at ON tes_content.content_items(updated_at);
CREATE INDEX IF NOT EXISTS idx_content_items_content_gin ON tes_content.content_items USING GIN (content);
CREATE INDEX IF NOT EXISTS idx_content_items_title_fts ON tes_content.content_items USING GIN (to_tsvector('spanish', title));
-- ============================================
-- TABLA: content_versions
-- Propósito: Historial de versiones de contenido
-- ============================================
CREATE TABLE IF NOT EXISTS tes_content.content_versions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
content_id VARCHAR(100) NOT NULL,
version INTEGER NOT NULL,
-- Snapshot del contenido
content JSONB NOT NULL,
content_markdown TEXT,
title VARCHAR(500) NOT NULL,
-- Metadatos de la versión
status tes_content.content_status NOT NULL,
validated_by UUID,
validated_at TIMESTAMPTZ,
clinical_source VARCHAR(200),
-- Cambios
change_summary TEXT,
changed_fields TEXT[],
-- Auditoría
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID NOT NULL,
-- Foreign key
CONSTRAINT fk_content_versions_content_id
FOREIGN KEY (content_id)
REFERENCES tes_content.content_items(id)
ON DELETE CASCADE,
-- Unique constraint
CONSTRAINT uq_content_versions_content_version
UNIQUE (content_id, version)
);
CREATE INDEX IF NOT EXISTS idx_content_versions_content_id ON tes_content.content_versions(content_id);
CREATE INDEX IF NOT EXISTS idx_content_versions_version ON tes_content.content_versions(version);
-- ============================================
-- TABLA: media_resources
-- Propósito: Recursos multimedia (imágenes, videos)
-- ============================================
CREATE TABLE IF NOT EXISTS tes_content.media_resources (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
type VARCHAR(50) NOT NULL,
path VARCHAR(500) NOT NULL,
filename VARCHAR(255) NOT NULL,
file_url VARCHAR(500),
thumbnail_url VARCHAR(500),
-- Metadatos
title VARCHAR(500),
description TEXT,
alt_text VARCHAR(500),
caption TEXT,
tags TEXT[],
-- Clasificación
block VARCHAR(100),
chapter VARCHAR(100),
priority tes_content.content_priority,
-- Propiedades del archivo
width INTEGER,
height INTEGER,
format VARCHAR(50),
file_size BIGINT,
duration_seconds INTEGER,
video_format VARCHAR(50),
-- Estado
status tes_content.content_status NOT NULL DEFAULT 'draft',
-- Auditoría
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by UUID NOT NULL,
CONSTRAINT chk_media_type CHECK (type IN ('image', 'video', 'audio', 'document'))
);
CREATE INDEX IF NOT EXISTS idx_media_resources_type ON tes_content.media_resources(type);
CREATE INDEX IF NOT EXISTS idx_media_resources_status ON tes_content.media_resources(status);
CREATE INDEX IF NOT EXISTS idx_media_resources_tags ON tes_content.media_resources USING GIN (tags);
-- ============================================
-- TABLA: content_resource_associations
-- Propósito: Asociaciones entre contenido y recursos multimedia
-- ============================================
CREATE TABLE IF NOT EXISTS tes_content.content_resource_associations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
content_item_id VARCHAR(100) NOT NULL,
media_resource_id UUID NOT NULL,
-- Contexto de la asociación
section VARCHAR(100),
position INTEGER NOT NULL DEFAULT 0,
placement VARCHAR(50),
caption TEXT,
is_critical BOOLEAN NOT NULL DEFAULT false,
priority tes_content.content_priority,
-- Auditoría
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID NOT NULL,
-- Foreign keys
CONSTRAINT fk_cra_content_item_id
FOREIGN KEY (content_item_id)
REFERENCES tes_content.content_items(id)
ON DELETE CASCADE,
CONSTRAINT fk_cra_media_resource_id
FOREIGN KEY (media_resource_id)
REFERENCES tes_content.media_resources(id)
ON DELETE CASCADE,
CONSTRAINT chk_placement CHECK (placement IN ('before', 'after', 'inline', 'sidebar'))
);
CREATE INDEX IF NOT EXISTS idx_cra_content_item_id ON tes_content.content_resource_associations(content_item_id);
CREATE INDEX IF NOT EXISTS idx_cra_media_resource_id ON tes_content.content_resource_associations(media_resource_id);
CREATE INDEX IF NOT EXISTS idx_cra_position ON tes_content.content_resource_associations(content_item_id, position);
-- ============================================
-- TABLA: audit_logs
-- Propósito: Registro de auditoría de cambios
-- ============================================
CREATE TABLE IF NOT EXISTS tes_content.audit_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL,
entity_type VARCHAR(50) NOT NULL,
entity_id VARCHAR(100) NOT NULL,
action VARCHAR(50) NOT NULL,
changes JSONB,
metadata JSONB,
ip_address INET,
user_agent TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_action CHECK (action IN ('create', 'update', 'delete', 'submit', 'approve', 'reject', 'publish', 'archive'))
);
CREATE INDEX IF NOT EXISTS idx_audit_logs_user_id ON tes_content.audit_logs(user_id);
CREATE INDEX IF NOT EXISTS idx_audit_logs_entity ON tes_content.audit_logs(entity_type, entity_id);
CREATE INDEX IF NOT EXISTS idx_audit_logs_action ON tes_content.audit_logs(action);
CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON tes_content.audit_logs(created_at);
-- ============================================
-- COMENTARIOS
-- ============================================
COMMENT ON SCHEMA tes_content IS 'Schema principal para contenido de TES';
COMMENT ON TABLE tes_content.content_items IS 'Tabla principal de contenido (protocolos, guías, etc.)';
COMMENT ON TABLE tes_content.content_versions IS 'Historial de versiones de contenido';
COMMENT ON TABLE tes_content.media_resources IS 'Recursos multimedia (imágenes, videos)';
COMMENT ON TABLE tes_content.content_resource_associations IS 'Asociaciones entre contenido y recursos multimedia';
COMMENT ON TABLE tes_content.audit_logs IS 'Registro de auditoría de cambios';

3981
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

71
backend/package.json Normal file
View file

@ -0,0 +1,71 @@
{
"name": "emerges-tes-backend",
"version": "1.0.0",
"description": "Backend API para gestión de contenido de EMERGES TES",
"type": "module",
"main": "src/index.js",
"scripts": {
"build": "tsc",
"build:watch": "tsc --watch",
"dev": "tsx watch src/index.js",
"start": "tsx src/index.js",
"sync-content": "node scripts/sync-content-to-db.js",
"sync-content:dry-run": "node scripts/sync-content-to-db.js --dry-run",
"sync-content:protocols": "node scripts/sync-content-to-db.js --type=protocols",
"sync-content:drugs": "node scripts/sync-content-to-db.js --type=drugs",
"sync-content:guides": "node scripts/sync-content-to-db.js --type=guides",
"sync-content:force": "node scripts/sync-content-to-db.js --force",
"verify": "node scripts/verify-setup.js",
"migrate": "node scripts/migrate-content.js",
"db:create": "node scripts/db-create.js",
"db:migrate": "node scripts/db-migrate.js",
"db:seed": "node scripts/db-seed.js",
"seed:admin": "node scripts/seed-admin.js",
"seed:content": "node scripts/seed-content.js",
"seed:drugs": "node scripts/seed-drugs.js",
"migrate:all": "node scripts/migrate-all-content.js",
"migrate:drugs": "node scripts/migrate-drugs-schema.js",
"migrate:content-items": "node scripts/migrate-content-items-schema.js"
},
"keywords": [
"emerges",
"tes",
"sanitario",
"api"
],
"author": "",
"license": "UNLICENSED",
"dependencies": {
"@types/multer": "^2.0.0",
"archiver": "^7.0.1",
"bcrypt": "^5.1.1",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-rate-limit": "^8.2.1",
"helmet": "^8.1.0",
"ioredis": "^5.9.1",
"jsonwebtoken": "^9.0.2",
"marked": "^17.0.1",
"multer": "^2.0.2",
"pg": "^8.11.3",
"uuid": "^13.0.0",
"winston": "^3.19.0",
"winston-daily-rotate-file": "^5.0.0",
"zod": "^4.3.5"
},
"devDependencies": {
"@types/archiver": "^7.0.0",
"@types/bcrypt": "^5.0.2",
"@types/cors": "^2.8.19",
"@types/express": "^4.17.21",
"@types/ioredis": "^4.28.10",
"@types/jsonwebtoken": "^9.0.5",
"@types/node": "^20.10.0",
"@types/pg": "^8.10.9",
"@types/uuid": "^10.0.0",
"@types/winston": "^2.4.4",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}
}

View file

@ -0,0 +1,58 @@
#!/usr/bin/env node
/**
* Script para crear schema y tabla de usuarios para autenticación
*
* Uso: node scripts/create-auth-tables.js
*/
import { readFile } from 'fs/promises';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { query } from '../config/database.js';
import dotenv from 'dotenv';
dotenv.config();
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
async function createAuthTables() {
try {
console.log('🔧 Creando schema y tabla de usuarios...\n');
// Leer migración
const migrationPath = join(__dirname, '..', 'database', 'migrations', '001_create_auth_schema.sql');
const migrationSQL = await readFile(migrationPath, 'utf-8');
// Ejecutar migración
await query(migrationSQL);
console.log('✅ Schema emerges_content creado');
console.log('✅ Tabla users creada');
console.log('✅ Índices creados');
console.log('✅ Triggers creados\n');
// Verificar que la tabla existe
const checkTable = await query(`
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'emerges_content'
AND table_name = 'users'
`);
if (checkTable.rows.length > 0) {
console.log('✅ Verificación: Tabla users existe\n');
} else {
console.log('⚠️ Advertencia: Tabla users no encontrada después de creación\n');
}
} catch (error) {
console.error('❌ Error creando tablas:', error.message);
console.error('Detalles:', error);
process.exit(1);
}
}
createAuthTables();

View file

@ -0,0 +1,65 @@
/**
* Script para crear el schema completo tes_content
* Ejecuta el SQL del schema completo
*/
import { readFile } from 'fs/promises';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { query } from '../config/database.js';
import 'dotenv/config';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const schemaPath = join(__dirname, '../../docs/SERVER_DATABASE_SCHEMA.sql');
async function createFullSchema() {
try {
console.log('🔧 Creando schema completo tes_content...\n');
// Leer el SQL completo
const schemaSql = await readFile(schemaPath, 'utf-8');
console.log('📝 Ejecutando SQL completo...\n');
try {
// Ejecutar todo el SQL de una vez
await query(schemaSql);
console.log('✅ SQL ejecutado correctamente\n');
} catch (error) {
// Ignorar errores de "ya existe"
if (error.code === '42P07' || error.code === '42710' || error.message.includes('already exists')) {
console.log('⚠️ Algunos objetos ya existen (esto es normal)\n');
} else {
throw error;
}
}
// Verificar tablas creadas
console.log('📊 Verificando tablas creadas...');
const tablesResult = await query(`
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'tes_content'
ORDER BY table_name
`);
if (tablesResult.rows.length > 0) {
console.log(' Tablas encontradas:');
tablesResult.rows.forEach(row => {
console.log(`${row.table_name}`);
});
} else {
console.log(' ⚠️ No se encontraron tablas');
}
console.log('\n✅ Schema tes_content creado correctamente!');
} catch (error) {
console.error('❌ Error creando schema:', error.message);
process.exit(1);
}
}
createFullSchema();

View file

@ -0,0 +1,25 @@
#!/bin/bash
# Script para crear usuario y base de datos
# Se ejecuta con sudo -u postgres psql
cat << 'SQL' | sudo -u postgres psql
-- Crear usuario si no existe
DO \$\$
BEGIN
IF NOT EXISTS (SELECT FROM pg_user WHERE usename = 'planetazuzu') THEN
CREATE USER planetazuzu WITH PASSWORD 'Monforte.1977';
END IF;
END
\$\$;
-- Crear base de datos si no existe
SELECT 'CREATE DATABASE emerges_tes OWNER planetazuzu'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'emerges_tes')\gexec
-- Dar permisos
GRANT ALL PRIVILEGES ON DATABASE emerges_tes TO planetazuzu;
\q
SQL
echo "✅ Usuario y base de datos creados (o ya existían)"

View file

@ -0,0 +1,37 @@
-- Script SQL para crear usuario y base de datos
-- EJECUTAR: sudo -u postgres psql -f scripts/create-user.sql
-- Crear usuario si no existe
DO $$
BEGIN
IF NOT EXISTS (SELECT FROM pg_user WHERE usename = 'planetazuzu') THEN
CREATE USER planetazuzu WITH PASSWORD 'Monforte.1977';
RAISE NOTICE 'Usuario planetazuzu creado';
ELSE
RAISE NOTICE 'Usuario planetazuzu ya existe';
-- Actualizar password por si acaso
ALTER USER planetazuzu WITH PASSWORD 'Monforte.1977';
END IF;
END
$$;
-- Crear base de datos si no existe
SELECT 'CREATE DATABASE emerges_tes OWNER planetazuzu'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'emerges_tes')\gexec
-- Dar permisos
GRANT ALL PRIVILEGES ON DATABASE emerges_tes TO planetazuzu;
-- Conectar a la base de datos y dar permisos en el esquema
\c emerges_tes
-- Crear esquema si no existe (se creará en migración, pero por si acaso)
CREATE SCHEMA IF NOT EXISTS emerges_content;
-- Dar permisos en el esquema
GRANT ALL ON SCHEMA emerges_content TO planetazuzu;
ALTER DEFAULT PRIVILEGES IN SCHEMA emerges_content GRANT ALL ON TABLES TO planetazuzu;
ALTER DEFAULT PRIVILEGES IN SCHEMA emerges_content GRANT ALL ON SEQUENCES TO planetazuzu;
\q

94
backend/scripts/db-create.js Executable file
View file

@ -0,0 +1,94 @@
#!/usr/bin/env node
/**
* Script para crear la base de datos y ejecutar migraciones
*
* Uso: node scripts/db-create.js
*
* IMPORTANTE: Requiere PostgreSQL instalado y configurado
*/
import { readFile } from 'fs/promises';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import pg from 'pg';
import dotenv from 'dotenv';
dotenv.config();
const { Client } = pg;
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const projectRoot = join(__dirname, '..', '..');
async function main() {
const client = new Client({
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432', 10),
database: 'postgres', // Conectar a postgres para crear la BD
user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || '',
});
try {
await client.connect();
console.log('✅ Conectado a PostgreSQL');
// Crear base de datos si no existe
const dbName = process.env.DB_NAME || 'emerges_tes';
const dbExists = await client.query(
`SELECT 1 FROM pg_database WHERE datname = $1`,
[dbName]
);
if (dbExists.rows.length === 0) {
await client.query(`CREATE DATABASE ${dbName}`);
console.log(`✅ Base de datos '${dbName}' creada`);
} else {
console.log(` Base de datos '${dbName}' ya existe`);
}
await client.end();
// Conectar a la base de datos creada
const dbClient = new Client({
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432', 10),
database: dbName,
user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || '',
});
await dbClient.connect();
console.log(`✅ Conectado a base de datos '${dbName}'`);
// Ejecutar migraciones
console.log('\n📦 Ejecutando migraciones...\n');
// Migración 001: Crear esquema
const migration001 = await readFile(
join(projectRoot, 'database', 'migrations', '001_create_schema.sql'),
'utf-8'
);
await dbClient.query(migration001);
console.log('✅ Migración 001: Esquema creado');
// Migración 002: Funciones y triggers
const migration002 = await readFile(
join(projectRoot, 'database', 'migrations', '002_create_functions.sql'),
'utf-8'
);
await dbClient.query(migration002);
console.log('✅ Migración 002: Funciones y triggers creados');
await dbClient.end();
console.log('\n🎉 Base de datos configurada correctamente\n');
} catch (error) {
console.error('❌ Error:', error.message);
process.exit(1);
}
}
main();

View file

@ -0,0 +1,51 @@
#!/usr/bin/env node
/**
* Script para crear el tipo content_priority si no existe
*/
import { query } from '../config/database.js';
import 'dotenv/config';
async function fixContentPriorityType() {
try {
console.log('🔧 Verificando tipo content_priority...\n');
// Verificar si existe
const checkResult = await query(`
SELECT typname
FROM pg_type
WHERE typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'tes_content')
AND typname = 'content_priority'
`);
if (checkResult.rows.length > 0) {
console.log('✅ El tipo content_priority ya existe\n');
return;
}
console.log('📝 Creando tipo content_priority...\n');
// Crear el tipo
await query(`
CREATE TYPE tes_content.content_priority AS ENUM (
'critica',
'alta',
'media',
'baja'
)
`);
console.log('✅ Tipo content_priority creado correctamente\n');
} catch (error) {
if (error.code === '42P07' || error.message.includes('already exists')) {
console.log('✅ El tipo ya existe (ignorando error)\n');
} else {
console.error('❌ Error creando tipo:', error.message);
throw error;
}
}
}
fixContentPriorityType();

View file

@ -0,0 +1,601 @@
/**
* Script de migración COMPLETA: Importa TODO el contenido de la app al backend
*
* Migra:
* - Todos los procedimientos (procedures.ts)
* - Todos los fármacos (drugs.ts) - 6 fármacos completos
* - Checklists de material (material-checklists.ts)
* - Guías de refuerzo (guides-index.ts)
*/
import { query } from '../config/database.js';
import 'dotenv/config';
import { readFile } from 'fs/promises';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Mapeo de prioridades
const priorityMap = {
'critico': 'critica',
'alto': 'alta',
'medio': 'media',
'bajo': 'baja'
};
// Mapeo de categorías de procedimientos → clinical_context
const procedureCategoryMap = {
'soporte_vital': {
'rcp': 'RCP',
'via_aerea': 'VIA_AEREA',
'shock': 'SHOCK'
},
'patologias': 'OTROS',
'escena': 'OTROS'
};
/**
* Obtiene ID del admin
*/
async function getAdminId() {
let result = await query(
`SELECT id FROM tes_content.users WHERE role = 'super_admin' LIMIT 1`
);
if (result.rows.length === 0) {
result = await query(
`SELECT id FROM emerges_content.users WHERE role = 'super_admin' LIMIT 1`
);
}
if (result.rows.length === 0) {
console.log('⚠️ No se encontró usuario admin, creando uno temporal...');
const tempResult = await query(
`INSERT INTO tes_content.users (id, email, username, password_hash, role, is_active)
VALUES (gen_random_uuid(), 'admin@emerges-tes.local', 'admin', 'temp', 'super_admin', true)
RETURNING id`
);
return tempResult.rows[0].id;
}
return result.rows[0].id;
}
/**
* Lee y parsea procedures.ts usando regex mejorado
*/
async function loadProcedures() {
const proceduresPath = join(__dirname, '../../src/data/procedures.ts');
const content = await readFile(proceduresPath, 'utf-8');
const procedures = [];
const procedureRegex = /{\s*id:\s*['"]([^'"]+)['"],\s*title:\s*['"]([^'"]+)['"],\s*shortTitle:\s*['"]([^'"]+)['"],\s*category:\s*['"]([^'"]+)['"],\s*(?:subcategory:\s*['"]([^'"]*)?['"],\s*)?priority:\s*['"]([^'"]+)['"],\s*ageGroup:\s*['"]([^'"]+)['"],\s*steps:\s*\[([\s\S]*?)\],\s*warnings:\s*\[([\s\S]*?)\],\s*(?:keyPoints:\s*\[([\s\S]*?)\],)?\s*(?:equipment:\s*\[([\s\S]*?)\],)?\s*(?:drugs:\s*\[([\s\S]*?)\],)?\s*}/g;
let match;
while ((match = procedureRegex.exec(content)) !== null) {
const [, id, title, shortTitle, category, subcategory, priority, ageGroup, stepsStr, warningsStr, keyPointsStr, equipmentStr, drugsStr] = match;
const steps = extractArray(stepsStr);
const warnings = extractArray(warningsStr);
const keyPoints = keyPointsStr ? extractArray(keyPointsStr) : [];
const equipment = equipmentStr ? extractArray(equipmentStr) : [];
const drugs = drugsStr ? extractArray(drugsStr) : [];
procedures.push({
id,
title,
shortTitle,
category,
subcategory: subcategory || null,
priority,
ageGroup,
steps,
warnings,
keyPoints,
equipment,
drugs
});
}
return procedures;
}
/**
* Lee y parsea drugs.ts - VERSIÓN MEJORADA para todos los fármacos
*/
async function loadDrugs() {
const drugsPath = join(__dirname, '../../src/data/drugs.ts');
const content = await readFile(drugsPath, 'utf-8');
const drugs = [];
// Regex mejorado que captura todos los campos, incluyendo arrays complejos
const drugBlockRegex = /{\s*id:\s*['"]([^'"]+)['"],\s*(?:source:\s*['"]([^'"]*)?['"],\s*)?genericName:\s*['"]([^'"]+)['"],\s*tradeName:\s*['"]([^'"]+)['"],\s*category:\s*['"]([^'"]+)['"],\s*presentation:\s*['"]([^'"]+)['"],\s*adultDose:\s*['"]([^'"]+)['"],\s*(?:pediatricDose:\s*['"]([^'"]*)?['"],)?\s*routes:\s*\[([\s\S]*?)\],\s*(?:dilution:\s*['"]([^'"]*)?['"],)?\s*indications:\s*\[([\s\S]*?)\],\s*contraindications:\s*\[([\s\S]*?)\],\s*(?:sideEffects:\s*\[([\s\S]*?)\],)?\s*(?:antidote:\s*['"]([^'"]*)?['"],)?\s*(?:notes:\s*\[([\s\S]*?)\],)?\s*(?:criticalPoints:\s*\[([\s\S]*?)\],)?\s*(?:source:\s*['"]([^'"]*)?['"])?\s*}/g;
let match;
while ((match = drugBlockRegex.exec(content)) !== null) {
const [, id, source1, genericName, tradeName, category, presentation, adultDose, pediatricDose, routesStr, dilution, indicationsStr, contraindicationsStr, sideEffectsStr, antidote, notesStr, criticalPointsStr, source2] = match;
const routes = extractArray(routesStr);
const indications = extractArray(indicationsStr);
const contraindications = extractArray(contraindicationsStr);
const sideEffects = sideEffectsStr ? extractArray(sideEffectsStr) : [];
const notes = notesStr ? extractArray(notesStr) : [];
const criticalPoints = criticalPointsStr ? extractArray(criticalPointsStr) : [];
const source = source2 || source1 || null;
drugs.push({
id,
genericName,
tradeName,
category,
presentation,
adultDose,
pediatricDose: pediatricDose || null,
routes,
dilution: dilution || null,
indications,
contraindications,
sideEffects,
antidote: antidote || null,
notes,
criticalPoints,
source
});
}
return drugs;
}
/**
* Lee y parsea guides-index.ts
*/
async function loadGuides() {
const guidesPath = join(__dirname, '../../src/data/guides-index.ts');
const content = await readFile(guidesPath, 'utf-8');
const guides = [];
// Buscar cada objeto guía usando un patrón más simple
// Buscar: id: "...", titulo: "...", descripcion: "...", icono: "..."
const guidePattern = /id:\s*["']([^"']+)["'],\s*titulo:\s*["']([^"']+)["'],\s*descripcion:\s*["']([^"']+)["'],\s*icono:\s*["']([^"']+)["']/g;
let match;
while ((match = guidePattern.exec(content)) !== null) {
const [, id, titulo, descripcion, icono] = match;
// Buscar scormAvailable después del icono
const afterIcono = content.substring(match.index + match[0].length);
const scormMatch = afterIcono.match(/scormAvailable:\s*(true|false)/);
const scormAvailable = scormMatch ? scormMatch[1] === 'true' : false;
guides.push({
id,
titulo,
descripcion,
icono,
scormAvailable,
seccionesCount: 8 // Todas las guías tienen 8 secciones
});
}
return guides;
}
/**
* Lee y parsea material-checklists.ts
*/
async function loadChecklists() {
const checklistsPath = join(__dirname, '../../src/data/material-checklists.ts');
const content = await readFile(checklistsPath, 'utf-8');
const checklists = [];
// Buscar definiciones de checklists
const checklistRegex = /export const (\w+): MaterialChecklist = ([\s\S]*?);/g;
let match;
while ((match = checklistRegex.exec(content)) !== null) {
const [, varName, checklistContent] = match;
// Extraer campos básicos
const idMatch = checklistContent.match(/id:\s*['"]([^'"]+)['"]/);
const titleMatch = checklistContent.match(/title:\s*['"]([^'"]+)['"]/);
const shortTitleMatch = checklistContent.match(/shortTitle:\s*['"]([^'"]+)['"]/);
const phaseMatch = checklistContent.match(/phase:\s*['"]([^'"]+)['"]/);
const descMatch = checklistContent.match(/description:\s*['"]([^'"]+)['"]/);
if (idMatch && titleMatch) {
checklists.push({
id: idMatch[1],
title: titleMatch[1],
shortTitle: shortTitleMatch ? shortTitleMatch[1] : titleMatch[1],
phase: phaseMatch ? phaseMatch[1] : null,
description: descMatch ? descMatch[1] : '',
content: checklistContent // Guardar contenido completo para procesar después
});
}
}
return checklists;
}
/**
* Extrae elementos de un array de strings
*/
function extractArray(str) {
if (!str) return [];
const matches = str.match(/['"]([^'"]+)['"]/g);
return matches ? matches.map(m => m.replace(/['"]/g, '')) : [];
}
/**
* Inserta un procedimiento en la BD
*/
async function insertProcedure(procedure, adminId) {
let clinicalContext = 'OTROS';
if (procedure.subcategory && procedureCategoryMap[procedure.category]?.[procedure.subcategory]) {
clinicalContext = procedureCategoryMap[procedure.category][procedure.subcategory];
}
const content = {
steps: procedure.steps.map((step, index) => ({
id: `step-${index + 1}`,
order: index + 1,
text: step,
critical: false
})),
warnings: procedure.warnings || [],
keyPoints: procedure.keyPoints || [],
equipment: procedure.equipment || [],
drugs: procedure.drugs || []
};
const result = await query(`
INSERT INTO tes_content.content_items (
id, type, slug, title, short_title, description,
clinical_context, level, priority, status,
source_guideline, version, latest_version,
content, tags, category,
created_by, updated_by
) VALUES (
gen_random_uuid(),
'protocol',
$1,
$2,
$3,
$4,
$5::tes_content.clinical_context,
'operativo'::tes_content.usage_type,
$6::tes_content.priority,
'published'::tes_content.content_status,
'INTERNO'::tes_content.source_guideline,
'1.0.0',
'1.0.0',
$7::jsonb,
$8::text[],
$9,
$10,
$10
)
ON CONFLICT (slug) DO UPDATE SET
title = EXCLUDED.title,
short_title = EXCLUDED.short_title,
description = EXCLUDED.description,
content = EXCLUDED.content,
updated_by = EXCLUDED.updated_by,
updated_at = NOW()
RETURNING id, slug, title
`, [
procedure.id,
procedure.title,
procedure.shortTitle,
`Protocolo operativo: ${procedure.title}`,
clinicalContext,
priorityMap[procedure.priority] || 'media',
JSON.stringify(content),
[procedure.category, procedure.subcategory].filter(Boolean),
procedure.category,
adminId
]);
return result.rows[0];
}
/**
* Inserta un fármaco en la BD
*/
async function insertDrug(drug, adminId) {
const content = {
presentation: drug.presentation,
adultDose: drug.adultDose,
pediatricDose: drug.pediatricDose || null,
routes: drug.routes,
dilution: drug.dilution || null,
indications: drug.indications,
contraindications: drug.contraindications,
sideEffects: drug.sideEffects || [],
antidote: drug.antidote || null,
notes: drug.notes || [],
criticalPoints: drug.criticalPoints || []
};
const result = await query(`
INSERT INTO tes_content.content_items (
id, type, slug, title, short_title, description,
clinical_context, level, priority, status,
source_guideline, version, latest_version,
content, tags, category,
created_by, updated_by
) VALUES (
gen_random_uuid(),
'drug',
$1,
$2,
$3,
$4,
'FARMACOLOGIA'::tes_content.clinical_context,
'operativo'::tes_content.usage_type,
'alta'::tes_content.priority,
'published'::tes_content.content_status,
'INTERNO'::tes_content.source_guideline,
'1.0.0',
'1.0.0',
$5::jsonb,
$6::text[],
$7,
$8,
$8
)
ON CONFLICT (slug) DO UPDATE SET
title = EXCLUDED.title,
short_title = EXCLUDED.short_title,
description = EXCLUDED.description,
content = EXCLUDED.content,
updated_by = EXCLUDED.updated_by,
updated_at = NOW()
RETURNING id, slug, title
`, [
drug.id,
drug.genericName,
drug.tradeName,
`Fármaco: ${drug.genericName} (${drug.tradeName})`,
JSON.stringify(content),
[drug.category, 'farmacologia'].filter(Boolean),
drug.category,
adminId
]);
return result.rows[0];
}
/**
* Inserta una guía en la BD
*/
async function insertGuide(guide, adminId) {
const content = {
description: guide.descripcion,
icono: guide.icono,
scormAvailable: guide.scormAvailable,
seccionesCount: guide.seccionesCount,
secciones: [] // Se puede expandir después con las secciones reales
};
const result = await query(`
INSERT INTO tes_content.content_items (
id, type, slug, title, short_title, description,
clinical_context, level, priority, status,
source_guideline, version, latest_version,
content, tags, category,
created_by, updated_by
) VALUES (
gen_random_uuid(),
'guide',
$1,
$2,
$3,
$4,
'OTROS'::tes_content.clinical_context,
'formativo'::tes_content.usage_type,
'alta'::tes_content.priority,
'published'::tes_content.content_status,
'INTERNO'::tes_content.source_guideline,
'1.0.0',
'1.0.0',
$5::jsonb,
$6::text[],
'guide',
$7,
$7
)
ON CONFLICT (slug) DO UPDATE SET
title = EXCLUDED.title,
short_title = EXCLUDED.short_title,
description = EXCLUDED.description,
content = EXCLUDED.content,
updated_by = EXCLUDED.updated_by,
updated_at = NOW()
RETURNING id, slug, title
`, [
guide.id,
guide.titulo,
guide.titulo,
guide.descripcion,
JSON.stringify(content),
['guide', 'formativo', guide.icono].filter(Boolean),
adminId
]);
return result.rows[0];
}
/**
* Inserta un checklist en la BD
*/
async function insertChecklist(checklist, adminId) {
const content = {
phase: checklist.phase,
description: checklist.description,
sections: [] // Se puede expandir después
};
const result = await query(`
INSERT INTO tes_content.content_items (
id, type, slug, title, short_title, description,
clinical_context, level, priority, status,
source_guideline, version, latest_version,
content, tags, category,
created_by, updated_by
) VALUES (
gen_random_uuid(),
'checklist',
$1,
$2,
$3,
$4,
'OTROS'::tes_content.clinical_context,
'operativo'::tes_content.usage_type,
'media'::tes_content.priority,
'published'::tes_content.content_status,
'INTERNO'::tes_content.source_guideline,
'1.0.0',
'1.0.0',
$5::jsonb,
$6::text[],
'checklist',
$7,
$7
)
ON CONFLICT (slug) DO UPDATE SET
title = EXCLUDED.title,
short_title = EXCLUDED.short_title,
description = EXCLUDED.description,
content = EXCLUDED.content,
updated_by = EXCLUDED.updated_by,
updated_at = NOW()
RETURNING id, slug, title
`, [
checklist.id,
checklist.title,
checklist.shortTitle,
checklist.description,
JSON.stringify(content),
['checklist', checklist.phase].filter(Boolean),
adminId
]);
return result.rows[0];
}
/**
* Función principal
*/
async function migrateAllContent() {
try {
console.log('🔄 Iniciando migración COMPLETA de contenido...\n');
await query('SELECT 1');
console.log('✅ Conexión a base de datos establecida\n');
const adminId = await getAdminId();
console.log(`✅ Usuario admin: ${adminId}\n`);
// 1. PROCEDIMIENTOS
console.log('📋 Migrando PROCEDIMIENTOS...');
const procedures = await loadProcedures();
console.log(` Encontrados ${procedures.length} procedimientos\n`);
for (const procedure of procedures) {
try {
const result = await insertProcedure(procedure, adminId);
console.log(`${result.slug}: ${result.title}`);
} catch (error) {
console.error(` ❌ Error migrando ${procedure.id}:`, error.message);
}
}
console.log('');
// 2. FÁRMACOS (TODOS)
console.log('💊 Migrando FÁRMACOS (TODOS)...');
const drugs = await loadDrugs();
console.log(` Encontrados ${drugs.length} fármacos\n`);
for (const drug of drugs) {
try {
const result = await insertDrug(drug, adminId);
console.log(`${result.slug}: ${result.title}`);
} catch (error) {
console.error(` ❌ Error migrando ${drug.id}:`, error.message);
}
}
console.log('');
// 3. GUÍAS
console.log('📚 Migrando GUÍAS...');
try {
const guides = await loadGuides();
console.log(` Encontradas ${guides.length} guías\n`);
for (const guide of guides) {
try {
const result = await insertGuide(guide, adminId);
console.log(`${result.slug}: ${result.title}`);
} catch (error) {
console.error(` ❌ Error migrando ${guide.id}:`, error.message);
}
}
} catch (error) {
console.log(` ⚠️ No se pudieron cargar guías: ${error.message}`);
}
console.log('');
// 4. CHECKLISTS
console.log('✅ Migrando CHECKLISTS...');
try {
const checklists = await loadChecklists();
console.log(` Encontrados ${checklists.length} checklists\n`);
for (const checklist of checklists) {
try {
const result = await insertChecklist(checklist, adminId);
console.log(`${result.slug}: ${result.title}`);
} catch (error) {
console.error(` ❌ Error migrando ${checklist.id}:`, error.message);
}
}
} catch (error) {
console.log(` ⚠️ No se pudieron cargar checklists: ${error.message}`);
}
console.log('');
// RESUMEN FINAL
console.log('📊 RESUMEN FINAL:');
const stats = await query(`
SELECT
type,
COUNT(*) as total,
COUNT(CASE WHEN status = 'published' THEN 1 END) as published
FROM tes_content.content_items
GROUP BY type
ORDER BY type
`);
stats.rows.forEach(row => {
console.log(` ${row.type}: ${row.total} total, ${row.published} publicados`);
});
const total = stats.rows.reduce((sum, row) => sum + parseInt(row.total), 0);
console.log(`\n 📦 TOTAL: ${total} items migrados\n`);
console.log('✅ Migración COMPLETA finalizada exitosamente!');
} catch (error) {
console.error('❌ Error durante la migración:', error);
process.exit(1);
}
}
migrateAllContent();

View file

@ -0,0 +1,549 @@
/**
* Script de migración V2: Importa contenido real de la app al backend
*
* Lee procedures.ts y drugs.ts usando import dinámico
*/
import { query } from '../config/database.js';
import 'dotenv/config';
import { createRequire } from 'module';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const require = createRequire(import.meta.url);
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Mapeo de prioridades (app → BD)
const priorityMap = {
'critico': 'critica',
'alto': 'alta',
'medio': 'media',
'bajo': 'baja'
};
// Mapeo de categorías de procedimientos → clinical_context
const procedureCategoryMap = {
'soporte_vital': {
'rcp': 'RCP',
'via_aerea': 'VIA_AEREA',
'shock': 'SHOCK'
},
'patologias': 'OTROS',
'escena': 'OTROS'
};
/**
* Obtiene ID del admin
*/
async function getAdminId() {
// Intentar primero en tes_content
let result = await query(
`SELECT id FROM tes_content.users WHERE role = 'super_admin' LIMIT 1`
);
// Si no existe, buscar en emerges_content (schema anterior)
if (result.rows.length === 0) {
result = await query(
`SELECT id FROM emerges_content.users WHERE role = 'super_admin' LIMIT 1`
);
}
// Si aún no existe, crear uno temporal
if (result.rows.length === 0) {
console.log('⚠️ No se encontró usuario admin, creando uno temporal...');
const tempResult = await query(
`INSERT INTO tes_content.users (id, email, username, password_hash, role, is_active)
VALUES (gen_random_uuid(), 'admin@emerges-tes.local', 'admin', 'temp', 'super_admin', true)
RETURNING id`
);
return tempResult.rows[0].id;
}
return result.rows[0].id;
}
/**
* Carga procedimientos desde el archivo TypeScript
* Usa require para importar el módulo
*/
async function loadProcedures() {
try {
// Intentar importar directamente
const proceduresModule = await import('../../src/data/procedures.ts');
return proceduresModule.procedures || [];
} catch (error) {
console.log('⚠️ No se pudo importar directamente, usando datos hardcodeados...');
// Datos hardcodeados como fallback
return getHardcodedProcedures();
}
}
/**
* Carga fármacos desde el archivo TypeScript
*/
async function loadDrugs() {
try {
const drugsModule = await import('../../src/data/drugs.ts');
return drugsModule.drugs || [];
} catch (error) {
console.log('⚠️ No se pudo importar directamente, usando datos hardcodeados...');
return getHardcodedDrugs();
}
}
/**
* Procedimientos hardcodeados (fallback)
*/
function getHardcodedProcedures() {
return [
{
id: 'rcp-adulto-svb',
title: 'RCP Adulto - Soporte Vital Básico',
shortTitle: 'RCP Adulto SVB',
category: 'soporte_vital',
subcategory: 'rcp',
priority: 'critico',
ageGroup: 'adulto',
steps: [
'Garantizar seguridad de la escena',
'Comprobar consciencia: estimular y preguntar "¿Se encuentra bien?"',
'Si no responde, llamar inmediatamente al 112',
'Abrir vía aérea: maniobra frente-mentón',
'Comprobar respiración: VER-OÍR-SENTIR (máx. 10 segundos)',
'Si no respira normal: iniciar RCP',
'Iniciar compresiones torácicas: 30 compresiones',
'Dar 2 ventilaciones de rescate',
'Continuar ciclos 30:2 sin interrupción',
'Solicitar DEA cuando esté disponible',
],
warnings: [
'Profundidad compresiones: 5-6 cm',
'Frecuencia: 100-120 compresiones/min',
'Permitir descompresión completa',
'Minimizar interrupciones (<10 seg)',
'Cambiar reanimador cada 2 min',
],
keyPoints: [
'Compresiones de calidad salvan vidas',
'No interrumpir para pulso hasta que haya signos de vida',
'La desfibrilación precoz aumenta supervivencia',
],
equipment: ['DEA', 'Bolsa-mascarilla', 'Cánula orofaríngea'],
drugs: ['Adrenalina 1mg'],
},
{
id: 'rcp-adulto-sva',
title: 'RCP Adulto - Soporte Vital Avanzado',
shortTitle: 'RCP Adulto SVA',
category: 'soporte_vital',
subcategory: 'rcp',
priority: 'critico',
ageGroup: 'adulto',
steps: [
'Continuar RCP 30:2 mientras se prepara monitorización',
'Colocar monitor/desfibrilador y analizar ritmo',
'Ritmo desfibrilable (FV/TVSP): descarga 150-200J bifásico',
'Reiniciar RCP inmediatamente 2 minutos',
'Obtener acceso IV/IO',
'Administrar adrenalina 1mg IV cada 3-5 min (tras 3ª descarga si DF)',
'Considerar amiodarona 300mg IV si FV/TVSP refractaria',
'Asegurar vía aérea avanzada cuando sea posible',
'Buscar y tratar causas reversibles (4H y 4T)',
'Si ROSC: cuidados post-parada',
],
warnings: [
'Minimizar interrupciones de compresiones',
'Adrenalina en ritmos no DF: lo antes posible',
'Amiodarona: 150mg adicionales si persiste FV/TVSP',
'Capnografía: objetivo ETCO2 >10 mmHg',
],
keyPoints: [
'4H: Hipoxia, Hipovolemia, Hipo/Hiperpotasemia, Hipotermia',
'4T: Neumotórax a Tensión, Taponamiento, Tóxicos, TEP',
],
equipment: ['Monitor/Desfibrilador', 'Material IOT', 'Acceso venoso'],
drugs: ['Adrenalina', 'Amiodarona', 'Atropina'],
},
{
id: 'rcp-pediatrico',
title: 'RCP Pediátrico - SVB',
shortTitle: 'RCP Pediátrico',
category: 'soporte_vital',
subcategory: 'rcp',
priority: 'critico',
ageGroup: 'pediatrico',
steps: [
'Garantizar seguridad de la escena',
'Comprobar consciencia',
'Si no responde, llamar inmediatamente al 112',
'Abrir vía aérea: maniobra frente-mentón',
'Comprobar respiración (máx. 10 segundos)',
'Si no respira normal: iniciar RCP',
'Dar 5 ventilaciones de rescate iniciales',
'Comprobar signos de vida/pulso (máx. 10 seg)',
'Si no hay signos de vida: 15 compresiones torácicas',
'Continuar con ciclos 15:2',
],
warnings: [
'Lactante (<1 año): compresiones con 2 dedos',
'Niño (1-8 años): talón de una mano',
'Profundidad: 1/3 del tórax (4cm lactante, 5cm niño)',
'Frecuencia: 100-120/min',
],
keyPoints: [
'La causa más frecuente es respiratoria',
'Las 5 ventilaciones iniciales son cruciales',
'Ratio 15:2 para profesionales',
],
},
{
id: 'obstruccion-via-aerea',
title: 'Obstrucción de Vía Aérea - OVACE',
shortTitle: 'OVACE',
category: 'soporte_vital',
subcategory: 'via_aerea',
priority: 'critico',
ageGroup: 'todos',
steps: [
'Valorar gravedad: ¿Puede toser, hablar, respirar?',
'OBSTRUCCIÓN LEVE: animar a toser, vigilar',
'OBSTRUCCIÓN GRAVE consciente: 5 golpes interescapulares',
'Si no se resuelve: 5 compresiones abdominales (Heimlich)',
'Alternar 5 golpes + 5 compresiones hasta resolución',
'Si pierde consciencia: iniciar RCP',
'Antes de ventilar: revisar boca y extraer objeto visible',
],
warnings: [
'En embarazadas y obesos: compresiones torácicas',
'Lactantes: 5 golpes en espalda + 5 compresiones torácicas',
'NO hacer barrido digital a ciegas',
'Derivar siempre tras maniobras de Heimlich',
],
keyPoints: [
'La tos es el mecanismo más efectivo',
'No interferir si la tos es efectiva',
],
},
{
id: 'shock-hemorragico',
title: 'Shock Hemorrágico',
shortTitle: 'Shock Hemorrágico',
category: 'soporte_vital',
subcategory: 'shock',
priority: 'critico',
ageGroup: 'adulto',
steps: [
'Control de hemorragia externa: presión directa',
'Torniquete si hemorragia en extremidad no controlable',
'Oxigenoterapia alto flujo',
'Canalizar 2 vías IV gruesas (14-16G)',
'Fluidos: cristaloides tibios (objetivo TAS 80-90 mmHg)',
'Posición Trendelenburg si no hay TCE',
'Evitar hipotermia: mantas térmicas',
'Traslado urgente a hospital útil',
'Considerar ácido tranexámico 1g IV',
],
warnings: [
'Hipotensión permisiva: TAS 80-90 mmHg',
'Excepto en TCE: mantener TAS >90 mmHg',
'Evitar sobrecarga de fluidos',
'Torniquete: anotar hora de colocación',
],
keyPoints: [
'Clase I: <15% pérdida, FC normal, TA normal',
'Clase II: 15-30%, taquicardia, TA normal',
'Clase III: 30-40%, taquicardia, hipotensión',
'Clase IV: >40%, bradicardia, shock severo',
],
equipment: ['Torniquete', 'Agentes hemostáticos', 'Mantas térmicas'],
drugs: ['Ácido tranexámico', 'Cristaloides'],
},
];
}
/**
* Fármacos hardcodeados (fallback)
*/
function getHardcodedDrugs() {
return [
{
id: 'oxigeno',
genericName: 'Oxígeno (O₂)',
tradeName: 'Oxígeno medicinal',
category: 'oxigenoterapia',
presentation: 'Gas medicinal. Balas de 2L, 5L, 10L, 15L. Concentración variable según dispositivo.',
adultDose: 'Mascarilla con reservorio: 10-15 L/min (FiO₂ ~85%). Mascarilla simple: 5-10 L/min (FiO₂ ~40-60%). Gafas nasales: 1-6 L/min (FiO₂ 24-44%).',
pediatricDose: 'Ajustar por respuesta. Gafas nasales: 1-4 L/min. Mascarilla simple: 5-8 L/min. En lactantes, evitar flujos >4L/min por riesgo de retinopatía.',
routes: ['Inhalatoria'],
indications: [
'Hipoxia (SpO₂ <94%)',
'Parada cardiorrespiratoria',
'Ictus',
'Síndrome Coronario Agudo',
'Trauma grave',
],
contraindications: [
'En EPOC conocida con riesgo de hipercapnia: usar Venturi 28% y titular a SpO₂ 88-92%',
],
notes: [
'NO es un fármaco inocuo',
'Humedecer si uso prolongado >2h',
'En EPOC conocida: usar Venturi 28% y titular a SpO₂ 88-92%',
],
criticalPoints: [
'Terapia, no placebo. Usarlo con indicación y precaución en EPOC',
'En EPOC conocida con riesgo de hipercapnia, usar Venturi 28% y titular a SpO₂ 88-92%',
],
},
{
id: 'adrenalina',
genericName: 'Adrenalina (Epinefrina)',
tradeName: 'Adrenalina Braun®',
category: 'cardiovascular',
presentation: 'ANAFILAXIA: Ampolla 1 mg/1 ml (1:1000). PCR: Ampolla 1 mg/10 ml (1:10.000). ¡LEER ETIQUETA EN VOZ ALTA!',
adultDose: 'ANAFILAXIA: 0.5 mg IM (0.5 ml de ampolla 1:1000). Repetir a los 5 min si no mejora. PCR: 1 mg IV/IO (10 ml de ampolla 1:10.000) cada 3-5 min.',
pediatricDose: 'ANAFILAXIA: 0.01 mg/kg IM (Máx. 0.5 mg). Ej: 20kg → 0.2 mg = 0.2 ml. PCR: 0.01 mg/kg IV/IO (o según protocolo local).',
routes: ['IM', 'IV', 'IO'],
dilution: 'ANAFILAXIA: Sin diluir. PCR: Sin diluir (usar ampolla 1:10.000 directamente).',
indications: [
'Anafilaxia grave: Reacción alérgica sistémica con afectación respiratoria (estridor, sibilancias, disnea) y/o cardiovascular (hipotensión, taquicardia, colapso)',
'Parada cardiorrespiratoria: Cualquier ritmo (FV/TVSP, AESP, ACR) como parte del algoritmo SVA, una vez establecida vía IV/IO',
],
contraindications: [
'No hay contraindicaciones absolutas en emergencias vitales',
'Paciente anciano o con cardiopatía isquémica: El beneficio en anafilaxia grave supera el riesgo. Administrar y monitorizar estrechamente',
],
sideEffects: ['Temblor', 'Taquicardia', 'Palidez', 'HTA', 'Arritmias'],
notes: [
'⚠️ CONCENTRACIÓN CRÍTICA: 1:1000 (1 mg/ml) para Anafilaxia IM. 1:10.000 (0.1 mg/ml) para PCR IV/IO',
'ANAFILAXIA: Fármaco salvavidas. Administración IM precoz es la intervención más importante. No esperar',
],
criticalPoints: [
'⚠️ ERROR CRÍTICO: Confundir 1:1000 con 1:10.000. Administrar 1 mg/ml (1:1000) por vía IV en PCR equivale a 10 mg (LETAL)',
'Anafilaxia: 1:1000 IM en el MUSLO. 0.5 mg adultos, 0.01 mg/kg niños. Repetir a los 5 min si no mejora',
'PCR: 1:10.000 IV/IO. 1 mg adultos, 0.01 mg/kg niños. Cada 3-5 min',
],
},
];
}
/**
* Inserta un procedimiento en la BD
*/
async function insertProcedure(procedure, adminId) {
// Determinar clinical_context
let clinicalContext = 'OTROS';
if (procedure.subcategory && procedureCategoryMap[procedure.category]?.[procedure.subcategory]) {
clinicalContext = procedureCategoryMap[procedure.category][procedure.subcategory];
}
// Construir contenido JSONB
const content = {
steps: procedure.steps.map((step, index) => ({
id: `step-${index + 1}`,
order: index + 1,
text: step,
critical: false
})),
warnings: procedure.warnings || [],
keyPoints: procedure.keyPoints || [],
equipment: procedure.equipment || [],
drugs: procedure.drugs || []
};
const result = await query(`
INSERT INTO tes_content.content_items (
id, type, slug, title, short_title, description,
clinical_context, level, priority, status,
source_guideline, version, latest_version,
content, tags, category,
created_by, updated_by
) VALUES (
gen_random_uuid(),
'protocol',
$1,
$2,
$3,
$4,
$5::tes_content.clinical_context,
'operativo'::tes_content.usage_type,
$6::tes_content.priority,
'published'::tes_content.content_status,
'INTERNO'::tes_content.source_guideline,
'1.0.0',
'1.0.0',
$7::jsonb,
$8::text[],
$9,
$10,
$10
)
ON CONFLICT (slug) DO UPDATE SET
title = EXCLUDED.title,
short_title = EXCLUDED.short_title,
description = EXCLUDED.description,
content = EXCLUDED.content,
updated_by = EXCLUDED.updated_by,
updated_at = NOW()
RETURNING id, slug, title
`, [
procedure.id,
procedure.title,
procedure.shortTitle,
`Protocolo operativo: ${procedure.title}`,
clinicalContext,
priorityMap[procedure.priority] || 'media',
JSON.stringify(content),
[procedure.category, procedure.subcategory].filter(Boolean),
procedure.category,
adminId
]);
return result.rows[0];
}
/**
* Inserta un fármaco en la BD
*/
async function insertDrug(drug, adminId) {
// Construir contenido JSONB
const content = {
presentation: drug.presentation,
adultDose: drug.adultDose,
pediatricDose: drug.pediatricDose || null,
routes: drug.routes,
dilution: drug.dilution || null,
indications: drug.indications,
contraindications: drug.contraindications,
sideEffects: drug.sideEffects || [],
antidote: drug.antidote || null,
notes: drug.notes || [],
criticalPoints: drug.criticalPoints || []
};
const result = await query(`
INSERT INTO tes_content.content_items (
id, type, slug, title, short_title, description,
clinical_context, level, priority, status,
source_guideline, version, latest_version,
content, tags, category,
created_by, updated_by
) VALUES (
gen_random_uuid(),
'drug',
$1,
$2,
$3,
$4,
'FARMACOLOGIA'::tes_content.clinical_context,
'operativo'::tes_content.usage_type,
'alta'::tes_content.priority,
'published'::tes_content.content_status,
'INTERNO'::tes_content.source_guideline,
'1.0.0',
'1.0.0',
$5::jsonb,
$6::text[],
$7,
$8,
$8
)
ON CONFLICT (slug) DO UPDATE SET
title = EXCLUDED.title,
short_title = EXCLUDED.short_title,
description = EXCLUDED.description,
content = EXCLUDED.content,
updated_by = EXCLUDED.updated_by,
updated_at = NOW()
RETURNING id, slug, title
`, [
drug.id,
drug.genericName,
drug.tradeName,
`Fármaco: ${drug.genericName} (${drug.tradeName})`,
JSON.stringify(content),
[drug.category, 'farmacologia'].filter(Boolean),
drug.category,
adminId
]);
return result.rows[0];
}
/**
* Función principal
*/
async function migrateAppContent() {
try {
console.log('🔄 Iniciando migración de contenido de la app al backend...\n');
// Verificar conexión
await query('SELECT 1');
console.log('✅ Conexión a base de datos establecida\n');
// Obtener admin ID
const adminId = await getAdminId();
console.log(`✅ Usuario admin encontrado: ${adminId}\n`);
// Cargar procedimientos
console.log('📋 Cargando procedimientos...');
const procedures = await loadProcedures();
console.log(` Encontrados ${procedures.length} procedimientos\n`);
// Migrar procedimientos
console.log('💾 Migrando procedimientos a la base de datos...');
for (const procedure of procedures) {
try {
const result = await insertProcedure(procedure, adminId);
console.log(`${result.slug}: ${result.title}`);
} catch (error) {
console.error(` ❌ Error migrando ${procedure.id}:`, error.message);
}
}
console.log('');
// Cargar fármacos
console.log('💊 Cargando fármacos...');
const drugs = await loadDrugs();
console.log(` Encontrados ${drugs.length} fármacos\n`);
// Migrar fármacos
console.log('💾 Migrando fármacos a la base de datos...');
for (const drug of drugs) {
try {
const result = await insertDrug(drug, adminId);
console.log(`${result.slug}: ${result.title}`);
} catch (error) {
console.error(` ❌ Error migrando ${drug.id}:`, error.message);
}
}
console.log('');
// Resumen
console.log('📊 Resumen de migración:');
const stats = await query(`
SELECT
type,
COUNT(*) as total,
COUNT(CASE WHEN status = 'published' THEN 1 END) as published
FROM tes_content.content_items
WHERE type IN ('protocol', 'drug')
GROUP BY type
`);
stats.rows.forEach(row => {
console.log(` ${row.type}: ${row.total} total, ${row.published} publicados`);
});
console.log('\n✅ Migración completada exitosamente!');
} catch (error) {
console.error('❌ Error durante la migración:', error);
process.exit(1);
}
}
// Ejecutar
migrateAppContent();

View file

@ -0,0 +1,421 @@
/**
* Script de migración: Importa contenido real de la app al backend
*
* Lee procedures.ts y drugs.ts y los migra a la base de datos
*/
import { query } from '../config/database.js';
import 'dotenv/config';
import { readFile } from 'fs/promises';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { randomUUID } from 'crypto';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Mapeo de prioridades (app → BD)
const priorityMap = {
'critico': 'critica',
'alto': 'alta',
'medio': 'media',
'bajo': 'baja'
};
// Mapeo de categorías de procedimientos → clinical_context
const procedureCategoryMap = {
'soporte_vital': {
'rcp': 'RCP',
'via_aerea': 'VIA_AEREA',
'shock': 'SHOCK'
},
'patologias': 'OTROS',
'escena': 'OTROS'
};
// Mapeo de categorías de fármacos → clinical_context
const drugCategoryMap = {
'cardiovascular': 'OTROS',
'respiratorio': 'OTROS',
'neurologico': 'OTROS',
'analgesia': 'OTROS',
'oxigenoterapia': 'OTROS',
'otros': 'OTROS'
};
// Mapeo de source_guideline
const sourceGuidelineMap = {
'ERC': 'ERC',
'SEMES': 'SEMES',
'AHA': 'AHA',
'INTERNO': 'INTERNO',
'MANUAL_TES_DIGITAL': 'INTERNO'
};
/**
* Obtiene ID del admin
*/
async function getAdminId() {
const result = await query(
`SELECT id FROM tes_content.users WHERE role = 'super_admin' LIMIT 1`
);
if (result.rows.length === 0) {
throw new Error('No se encontró usuario admin. Ejecuta primero: node scripts/seed-admin.js');
}
return result.rows[0].id;
}
/**
* Lee y parsea procedures.ts
*/
async function loadProcedures() {
const proceduresPath = join(__dirname, '../../src/data/procedures.ts');
const content = await readFile(proceduresPath, 'utf-8');
// Extraer el array de procedimientos usando regex
const arrayMatch = content.match(/export const procedures: Procedure\[\] = \[([\s\S]*)\];/);
if (!arrayMatch) {
throw new Error('No se pudo encontrar el array de procedimientos');
}
// Evaluar el contenido (cuidado: esto requiere que el código sea válido)
// Mejor usar un parser, pero para simplicidad usaremos eval en un contexto controlado
const proceduresCode = `[${arrayMatch[1]}]`;
// Reemplazar valores que no son JSON válido
const jsonLike = proceduresCode
.replace(/'/g, '"')
.replace(/(\w+):/g, '"$1":')
.replace(/,\s*}/g, '}')
.replace(/,\s*]/g, ']');
try {
const procedures = JSON.parse(jsonLike);
return procedures;
} catch (error) {
// Si falla el parseo simple, intentar extraer manualmente
console.log('⚠️ Parseo automático falló, extrayendo manualmente...');
return extractProceduresManually(content);
}
}
/**
* Extrae procedimientos manualmente del código TypeScript
*/
function extractProceduresManually(content) {
const procedures = [];
const procedureRegex = /\{\s*id:\s*['"]([^'"]+)['"],\s*title:\s*['"]([^'"]+)['"],\s*shortTitle:\s*['"]([^'"]+)['"],\s*category:\s*['"]([^'"]+)['"],\s*subcategory:\s*['"]([^'"]*)?['"],\s*priority:\s*['"]([^'"]+)['"],\s*ageGroup:\s*['"]([^'"]+)['"],\s*steps:\s*\[([\s\S]*?)\],\s*warnings:\s*\[([\s\S]*?)\],\s*(?:keyPoints:\s*\[([\s\S]*?)\],)?\s*(?:equipment:\s*\[([\s\S]*?)\],)?\s*(?:drugs:\s*\[([\s\S]*?)\],)?\s*\}/g;
let match;
while ((match = procedureRegex.exec(content)) !== null) {
const [, id, title, shortTitle, category, subcategory, priority, ageGroup, stepsStr, warningsStr, keyPointsStr, equipmentStr, drugsStr] = match;
// Extraer arrays
const steps = extractArray(stepsStr);
const warnings = extractArray(warningsStr);
const keyPoints = keyPointsStr ? extractArray(keyPointsStr) : [];
const equipment = equipmentStr ? extractArray(equipmentStr) : [];
const drugs = drugsStr ? extractArray(drugsStr) : [];
procedures.push({
id,
title,
shortTitle,
category,
subcategory: subcategory || null,
priority,
ageGroup,
steps,
warnings,
keyPoints,
equipment,
drugs
});
}
return procedures;
}
/**
* Extrae elementos de un array de strings
*/
function extractArray(str) {
if (!str) return [];
const matches = str.match(/['"]([^'"]+)['"]/g);
return matches ? matches.map(m => m.replace(/['"]/g, '')) : [];
}
/**
* Lee y parsea drugs.ts
*/
async function loadDrugs() {
const drugsPath = join(__dirname, '../../src/data/drugs.ts');
const content = await readFile(drugsPath, 'utf-8');
// Extraer manualmente usando regex
return extractDrugsManually(content);
}
/**
* Extrae fármacos manualmente del código TypeScript
*/
function extractDrugsManually(content) {
const drugs = [];
const drugBlockRegex = /\{\s*id:\s*['"]([^'"]+)['"],\s*genericName:\s*['"]([^'"]+)['"],\s*tradeName:\s*['"]([^'"]+)['"],\s*category:\s*['"]([^'"]+)['"],\s*presentation:\s*['"]([^'"]+)['"],\s*adultDose:\s*['"]([^'"]+)['"],\s*(?:pediatricDose:\s*['"]([^'"]*)?['"],)?\s*routes:\s*\[([\s\S]*?)\],\s*(?:dilution:\s*['"]([^'"]*)?['"],)?\s*indications:\s*\[([\s\S]*?)\],\s*contraindications:\s*\[([\s\S]*?)\],\s*(?:sideEffects:\s*\[([\s\S]*?)\],)?\s*(?:antidote:\s*['"]([^'"]*)?['"],)?\s*(?:notes:\s*\[([\s\S]*?)\],)?\s*(?:criticalPoints:\s*\[([\s\S]*?)\],)?\s*(?:source:\s*['"]([^'"]*)?['"])?\s*\}/g;
let match;
while ((match = drugBlockRegex.exec(content)) !== null) {
const [, id, genericName, tradeName, category, presentation, adultDose, pediatricDose, routesStr, dilution, indicationsStr, contraindicationsStr, sideEffectsStr, antidote, notesStr, criticalPointsStr, source] = match;
const routes = extractArray(routesStr);
const indications = extractArray(indicationsStr);
const contraindications = extractArray(contraindicationsStr);
const sideEffects = sideEffectsStr ? extractArray(sideEffectsStr) : [];
const notes = notesStr ? extractArray(notesStr) : [];
const criticalPoints = criticalPointsStr ? extractArray(criticalPointsStr) : [];
drugs.push({
id,
genericName,
tradeName,
category,
presentation,
adultDose,
pediatricDose: pediatricDose || null,
routes,
dilution: dilution || null,
indications,
contraindications,
sideEffects,
antidote: antidote || null,
notes,
criticalPoints,
source: source || null
});
}
return drugs;
}
/**
* Inserta un procedimiento en la BD
*/
async function insertProcedure(procedure, adminId) {
// Determinar clinical_context
let clinicalContext = 'OTROS';
if (procedure.subcategory && procedureCategoryMap[procedure.category]?.[procedure.subcategory]) {
clinicalContext = procedureCategoryMap[procedure.category][procedure.subcategory];
}
// Construir contenido JSONB
const content = {
steps: procedure.steps.map((step, index) => ({
id: `step-${index + 1}`,
order: index + 1,
text: step,
critical: false
})),
warnings: procedure.warnings || [],
keyPoints: procedure.keyPoints || [],
equipment: procedure.equipment || [],
drugs: procedure.drugs || []
};
// Determinar source_guideline (por defecto INTERNO)
const sourceGuideline = 'INTERNO';
const result = await query(`
INSERT INTO tes_content.content_items (
id, type, slug, title, short_title, description,
clinical_context, level, priority, status,
source_guideline, version, latest_version,
content, tags, category,
created_by, updated_by
) VALUES (
gen_random_uuid(),
'protocol',
$1,
$2,
$3,
$4,
$5::tes_content.clinical_context,
'operativo'::tes_content.usage_type,
$6::tes_content.priority,
'published'::tes_content.content_status,
$7::tes_content.source_guideline,
'1.0.0',
'1.0.0',
$8::jsonb,
$9::text[],
$10,
$11,
$11
)
ON CONFLICT (slug) DO UPDATE SET
title = EXCLUDED.title,
short_title = EXCLUDED.short_title,
description = EXCLUDED.description,
content = EXCLUDED.content,
updated_by = EXCLUDED.updated_by,
updated_at = NOW()
RETURNING id, slug, title
`, [
procedure.id,
procedure.title,
procedure.shortTitle,
`Protocolo operativo: ${procedure.title}`,
clinicalContext,
priorityMap[procedure.priority] || 'media',
sourceGuideline,
JSON.stringify(content),
[procedure.category, procedure.subcategory].filter(Boolean),
procedure.category
]);
return result.rows[0];
}
/**
* Inserta un fármaco en la BD
*/
async function insertDrug(drug, adminId) {
// Construir contenido JSONB
const content = {
presentation: drug.presentation,
adultDose: drug.adultDose,
pediatricDose: drug.pediatricDose || null,
routes: drug.routes,
dilution: drug.dilution || null,
indications: drug.indications,
contraindications: drug.contraindications,
sideEffects: drug.sideEffects || [],
antidote: drug.antidote || null,
notes: drug.notes || [],
criticalPoints: drug.criticalPoints || []
};
const result = await query(`
INSERT INTO tes_content.content_items (
id, type, slug, title, short_title, description,
clinical_context, level, priority, status,
source_guideline, version, latest_version,
content, tags, category,
created_by, updated_by
) VALUES (
gen_random_uuid(),
'drug',
$1,
$2,
$3,
$4,
'FARMACOLOGIA'::tes_content.clinical_context,
'operativo'::tes_content.usage_type,
'alta'::tes_content.priority,
'published'::tes_content.content_status,
'INTERNO'::tes_content.source_guideline,
'1.0.0',
'1.0.0',
$5::jsonb,
$6::text[],
$7,
$8,
$8
)
ON CONFLICT (slug) DO UPDATE SET
title = EXCLUDED.title,
short_title = EXCLUDED.short_title,
description = EXCLUDED.description,
content = EXCLUDED.content,
updated_by = EXCLUDED.updated_by,
updated_at = NOW()
RETURNING id, slug, title
`, [
drug.id,
drug.genericName,
drug.tradeName,
`Fármaco: ${drug.genericName} (${drug.tradeName})`,
JSON.stringify(content),
[drug.category, 'farmacologia'].filter(Boolean),
drug.category,
adminId
]);
return result.rows[0];
}
/**
* Función principal
*/
async function migrateAppContent() {
try {
console.log('🔄 Iniciando migración de contenido de la app al backend...\n');
// Verificar conexión
await query('SELECT 1');
console.log('✅ Conexión a base de datos establecida\n');
// Obtener admin ID
const adminId = await getAdminId();
console.log(`✅ Usuario admin encontrado: ${adminId}\n`);
// Cargar procedimientos
console.log('📋 Cargando procedimientos desde src/data/procedures.ts...');
const procedures = await loadProcedures();
console.log(` Encontrados ${procedures.length} procedimientos\n`);
// Migrar procedimientos
console.log('💾 Migrando procedimientos a la base de datos...');
for (const procedure of procedures) {
try {
const result = await insertProcedure(procedure, adminId);
console.log(`${result.slug}: ${result.title}`);
} catch (error) {
console.error(` ❌ Error migrando ${procedure.id}:`, error.message);
}
}
console.log('');
// Cargar fármacos
console.log('💊 Cargando fármacos desde src/data/drugs.ts...');
const drugs = await loadDrugs();
console.log(` Encontrados ${drugs.length} fármacos\n`);
// Migrar fármacos
console.log('💾 Migrando fármacos a la base de datos...');
for (const drug of drugs) {
try {
const result = await insertDrug(drug, adminId);
console.log(`${result.slug}: ${result.title}`);
} catch (error) {
console.error(` ❌ Error migrando ${drug.id}:`, error.message);
}
}
console.log('');
// Resumen
console.log('📊 Resumen de migración:');
const stats = await query(`
SELECT
type,
COUNT(*) as total,
COUNT(CASE WHEN status = 'published' THEN 1 END) as published
FROM tes_content.content_items
WHERE type IN ('protocol', 'drug')
GROUP BY type
`);
stats.rows.forEach(row => {
console.log(` ${row.type}: ${row.total} total, ${row.published} publicados`);
});
console.log('\n✅ Migración completada exitosamente!');
} catch (error) {
console.error('❌ Error durante la migración:', error);
process.exit(1);
}
}
// Ejecutar
migrateAppContent();

View file

@ -0,0 +1,86 @@
#!/usr/bin/env node
/**
* Script para ejecutar la migración 003: Content Items Schema
*
* Ejecuta: backend/database/migrations/003_create_content_items_schema.sql
*/
import { readFile } from 'fs/promises';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { query } from '../config/database.js';
import 'dotenv/config';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const migrationPath = join(__dirname, '../database/migrations/003_create_content_items_schema.sql');
async function runMigration() {
try {
console.log('🔧 Ejecutando migración: Content Items Schema (tes_content)\n');
// Leer el SQL
const migrationSql = await readFile(migrationPath, 'utf-8');
console.log('📝 Ejecutando SQL...\n');
try {
// Ejecutar todo el SQL
await query(migrationSql);
console.log('✅ Migración ejecutada correctamente\n');
} catch (error) {
// Ignorar errores de "ya existe"
if (error.code === '42P07' || error.code === '42710' || error.message.includes('already exists')) {
console.log('⚠️ Algunos objetos ya existen (esto es normal si ya se ejecutó antes)\n');
} else {
throw error;
}
}
// Verificar tablas creadas
console.log('📊 Verificando tablas creadas...');
const tablesResult = await query(`
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'tes_content'
AND table_name IN ('content_items', 'content_versions', 'media_resources', 'content_resource_associations', 'audit_logs')
ORDER BY table_name
`);
if (tablesResult.rows.length > 0) {
console.log(' Tablas encontradas:');
tablesResult.rows.forEach(row => {
console.log(`${row.table_name}`);
});
} else {
console.log(' ⚠️ No se encontraron las tablas esperadas');
}
// Verificar enums creados
console.log('\n📊 Verificando enums creados...');
const enumsResult = await query(`
SELECT typname
FROM pg_type
WHERE typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'tes_content')
AND typname IN ('content_status', 'content_priority')
ORDER BY typname
`);
if (enumsResult.rows.length > 0) {
console.log(' Enums encontrados:');
enumsResult.rows.forEach(row => {
console.log(`${row.typname}`);
});
}
console.log('\n✅ Migración del esquema de content_items completada!\n');
} catch (error) {
console.error('❌ Error ejecutando migración:', error.message);
console.error(error);
process.exit(1);
}
}
runMigration();

View file

@ -0,0 +1,190 @@
#!/usr/bin/env node
/**
* Script de migración: TypeScript PostgreSQL
*
* FASE 1: Infraestructura Base
*
* Este script lee el contenido de src/data/*.ts y lo migra a PostgreSQL
*
* Uso: node scripts/migrate-content.js
*
* IMPORTANTE: Ejecutar después de crear el esquema de BD
*/
import { readFile } from 'fs/promises';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { pool } from '../config/database.js';
import dotenv from 'dotenv';
dotenv.config();
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const projectRoot = join(__dirname, '..', '..');
/**
* Migrar protocolos desde procedures.ts
*/
async function migrateProtocols() {
try {
// Leer procedures.ts dinámicamente
const proceduresPath = join(projectRoot, 'src', 'data', 'procedures.ts');
const proceduresContent = await readFile(proceduresPath, 'utf-8');
// Extraer array de procedures (parsing básico)
// NOTA: En producción usar parser TypeScript real
const proceduresMatch = proceduresContent.match(/export const procedures[^=]*=\s*(\[[\s\S]*?\]);/);
if (!proceduresMatch) {
console.log('⚠️ No se encontraron procedures en procedures.ts');
return;
}
// Evaluar el array (cuidado: solo en desarrollo, en producción usar parser)
const procedures = eval(proceduresMatch[1]);
console.log(`\n📋 Migrando ${procedures.length} protocolos...\n`);
for (const proc of procedures) {
const content = {
steps: proc.steps || [],
warnings: proc.warnings || [],
keyPoints: proc.keyPoints || [],
equipment: proc.equipment || [],
drugs: proc.drugs || [],
};
await pool.query(
`INSERT INTO emerges_content.content_items (
id, type, level, title, short_title, content,
category, subcategory, priority, age_group,
version, status, created_by, updated_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
ON CONFLICT (id) DO NOTHING`,
[
proc.id,
'protocol',
'operativo',
proc.title,
proc.shortTitle,
JSON.stringify(content),
proc.category,
proc.subcategory || null,
proc.priority,
proc.ageGroup,
1,
'validated', // Asumir validado para contenido migrado
'migration-script',
'migration-script'
]
);
console.log(`${proc.id}`);
}
console.log(`\n${procedures.length} protocolos migrados\n`);
} catch (error) {
console.error('❌ Error migrando protocolos:', error.message);
throw error;
}
}
/**
* Migrar fármacos desde drugs.ts
*/
async function migrateDrugs() {
try {
const drugsPath = join(projectRoot, 'src', 'data', 'drugs.ts');
const drugsContent = await readFile(drugsPath, 'utf-8');
const drugsMatch = drugsContent.match(/export const drugs[^=]*=\s*(\[[\s\S]*?\]);/);
if (!drugsMatch) {
console.log('⚠️ No se encontraron drugs en drugs.ts');
return;
}
const drugs = eval(drugsMatch[1]);
console.log(`\n💊 Migrando ${drugs.length} fármacos...\n`);
for (const drug of drugs) {
const content = {
genericName: drug.genericName,
tradeName: drug.tradeName,
presentation: drug.presentation || '',
adultDose: drug.adultDose,
pediatricDose: drug.pediatricDose || null,
routes: drug.routes || [],
dilution: drug.dilution || null,
indications: drug.indications || [],
contraindications: drug.contraindications || [],
sideEffects: drug.sideEffects || [],
antidote: drug.antidote || null,
notes: drug.notes || [],
criticalPoints: drug.criticalPoints || [],
source: drug.source || null,
};
await pool.query(
`INSERT INTO emerges_content.content_items (
id, type, level, title, short_title, content,
category, version, status, created_by, updated_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT (id) DO NOTHING`,
[
drug.id,
'drug',
'operativo',
drug.genericName,
drug.tradeName,
JSON.stringify(content),
drug.category,
1,
'validated',
'migration-script',
'migration-script'
]
);
console.log(`${drug.id}`);
}
console.log(`\n${drugs.length} fármacos migrados\n`);
} catch (error) {
console.error('❌ Error migrando fármacos:', error.message);
throw error;
}
}
/**
* Función principal
*/
async function main() {
try {
console.log('\n🚀 Iniciando migración de contenido...\n');
// Test de conexión
await pool.query('SELECT 1');
console.log('✅ Conexión a PostgreSQL establecida\n');
// Migrar protocolos
await migrateProtocols();
// Migrar fármacos
await migrateDrugs();
console.log('🎉 Migración completada exitosamente\n');
await pool.end();
} catch (error) {
console.error('\n❌ Error en migración:', error.message);
console.error(error);
process.exit(1);
}
}
main();

View file

@ -0,0 +1,85 @@
/**
* Script para ejecutar la migración del esquema de drugs
*
* Ejecuta: backend/database/migrations/002_create_drugs_schema.sql
*/
import { readFile } from 'fs/promises';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { query } from '../config/database.js';
import 'dotenv/config';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const migrationPath = join(__dirname, '../database/migrations/002_create_drugs_schema.sql');
async function runMigration() {
try {
console.log('🔧 Ejecutando migración: Esquema de Vademécum TES (Drugs)\n');
// Leer el SQL
const migrationSql = await readFile(migrationPath, 'utf-8');
console.log('📝 Ejecutando SQL...\n');
try {
// Ejecutar todo el SQL
await query(migrationSql);
console.log('✅ Migración ejecutada correctamente\n');
} catch (error) {
// Ignorar errores de "ya existe"
if (error.code === '42P07' || error.code === '42710' || error.message.includes('already exists')) {
console.log('⚠️ Algunos objetos ya existen (esto es normal si ya se ejecutó antes)\n');
} else {
throw error;
}
}
// Verificar tablas creadas
console.log('📊 Verificando tablas creadas...');
const tablesResult = await query(`
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'tes_content'
AND table_name IN ('drugs', 'drug_versions')
ORDER BY table_name
`);
if (tablesResult.rows.length > 0) {
console.log(' Tablas encontradas:');
tablesResult.rows.forEach(row => {
console.log(`${row.table_name}`);
});
} else {
console.log(' ⚠️ No se encontraron las tablas drugs o drug_versions');
}
// Verificar enums creados
console.log('\n📊 Verificando enums creados...');
const enumsResult = await query(`
SELECT typname
FROM pg_type
WHERE typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'tes_content')
AND typname IN ('drug_line', 'drug_frequency')
ORDER BY typname
`);
if (enumsResult.rows.length > 0) {
console.log(' Enums encontrados:');
enumsResult.rows.forEach(row => {
console.log(`${row.typname}`);
});
}
console.log('\n✅ Migración del esquema de drugs completada!\n');
} catch (error) {
console.error('❌ Error ejecutando migración:', error.message);
console.error(error);
process.exit(1);
}
}
runMigration();

View file

@ -0,0 +1,62 @@
/**
* Script para crear usuario administrador inicial
*/
import bcrypt from 'bcrypt';
import { query } from '../config/database.js';
import 'dotenv/config';
const DEFAULT_ADMIN = {
email: 'admin@emerges-tes.local',
username: 'admin',
password: 'Admin123!',
role: 'super_admin',
};
async function seedAdmin() {
try {
console.log('🌱 Creando usuario administrador...');
// Verificar si ya existe
const existing = await query(
`SELECT id FROM tes_content.users WHERE email = $1`,
[DEFAULT_ADMIN.email]
);
if (existing.rows.length > 0) {
console.log('⚠️ Usuario administrador ya existe, actualizando contraseña...');
const passwordHash = await bcrypt.hash(DEFAULT_ADMIN.password, 10);
await query(
`UPDATE tes_content.users
SET password_hash = $1, is_active = true
WHERE email = $2`,
[passwordHash, DEFAULT_ADMIN.email]
);
console.log('✅ Contraseña actualizada');
return;
}
// Hash de contraseña
const passwordHash = await bcrypt.hash(DEFAULT_ADMIN.password, 10);
// Crear usuario
await query(
`INSERT INTO tes_content.users
(id, email, username, password_hash, role, is_active)
VALUES (gen_random_uuid(), $1, $2, $3, $4, true)`,
[DEFAULT_ADMIN.email, DEFAULT_ADMIN.username, passwordHash, DEFAULT_ADMIN.role]
);
console.log('✅ Usuario administrador creado:');
console.log(` Email: ${DEFAULT_ADMIN.email}`);
console.log(` Password: ${DEFAULT_ADMIN.password}`);
console.log(` Role: ${DEFAULT_ADMIN.role}`);
console.log('\n⚠ IMPORTANTE: Cambiar la contraseña después del primer login');
} catch (error) {
console.error('❌ Error creando usuario administrador:', error);
process.exit(1);
}
}
seedAdmin();

View file

@ -0,0 +1,295 @@
/**
* Script para crear contenido de ejemplo (seed data)
*/
import { query } from '../config/database.js';
import 'dotenv/config';
import { randomUUID as uuidv4 } from 'crypto';
// Obtener ID del admin (asumiendo que existe)
async function getAdminId() {
const result = await query(
`SELECT id FROM emerges_content.users WHERE role = 'super_admin' LIMIT 1`
);
return result.rows[0]?.id || uuidv4();
}
async function seedContent() {
try {
console.log('🌱 Creando contenido de ejemplo...');
const adminId = await getAdminId();
// 1. Checklist: Electrodos/Parches DESA
const checklistElectrodos = {
id: 'checklist-electrodos-desa',
type: 'checklist',
level: 'operativo',
title: 'Colocación de Electrodos/Parches DESA',
shortTitle: 'Electrodos DESA',
description: 'Checklist para la correcta colocación de electrodos en desfibrilación',
content: {
items: [
{ id: '1', text: 'Verificar que el paciente está en superficie seca y no metálica', order: 1, critical: true },
{ id: '2', text: 'Secar el tórax si está húmedo', order: 2, critical: true },
{ id: '3', text: 'Rasurar vello excesivo si es necesario', order: 3 },
{ id: '4', text: 'Colocar parche derecho: debajo de la clavícula derecha', order: 4, critical: true },
{ id: '5', text: 'Colocar parche izquierdo: línea axilar media izquierda', order: 5, critical: true },
{ id: '6', text: 'Verificar que los parches están bien adheridos', order: 6, critical: true },
{ id: '7', text: 'Conectar cables al DESA', order: 7, critical: true },
{ id: '8', text: 'Asegurar que nadie toca al paciente durante análisis', order: 8, critical: true },
],
description: 'Secuencia de pasos para colocación correcta de electrodos en desfibrilación',
estimatedTime: '30-60 segundos',
applicableProtocols: ['rcp-adulto-svb', 'rcp-adulto-sva'],
tags: ['DESA', 'desfibrilación', 'electrodos'],
},
};
await insertContentItem(checklistElectrodos, adminId);
console.log(' ✅ Checklist: Electrodos DESA');
// 2. Checklist: Preparación Intubación
const checklistIntubacion = {
id: 'checklist-preparacion-intubacion',
type: 'checklist',
level: 'operativo',
title: 'Preparación para Intubación Orotraqueal',
shortTitle: 'Preparación IOT',
description: 'Checklist completo de preparación antes de intubación',
content: {
items: [
{ id: '1', text: 'Verificar material: laringoscopio, tubos, estilete', order: 1, critical: true, category: 'Material' },
{ id: '2', text: 'Comprobar fuente de luz del laringoscopio', order: 2, critical: true, category: 'Material' },
{ id: '3', text: 'Preparar tubo endotraqueal (talla adecuada)', order: 3, critical: true, category: 'Material' },
{ id: '4', text: 'Preparar estilete (si se usa)', order: 4, category: 'Material' },
{ id: '5', text: 'Preparar jeringa para balón', order: 5, category: 'Material' },
{ id: '6', text: 'Verificar aspiración funcionando', order: 6, critical: true, category: 'Verificación' },
{ id: '7', text: 'Preparar fármacos: sedación y relajante', order: 7, critical: true, category: 'Fármacos' },
{ id: '8', text: 'Verificar monitorización: SpO₂, ECG, capnografía', order: 8, critical: true, category: 'Monitorización' },
{ id: '9', text: 'Posicionar paciente: alineación cabeza-cuello-tórax', order: 9, critical: true, category: 'Posicionamiento' },
{ id: '10', text: 'Preoxigenación: 3-5 minutos con mascarilla con reservorio', order: 10, critical: true, category: 'Preoxigenación' },
],
description: 'Checklist completo para preparación segura de intubación orotraqueal',
estimatedTime: '5-10 minutos',
applicableProtocols: ['via-aerea', 'rcp-adulto-sva'],
tags: ['IOT', 'intubación', 'vía aérea avanzada'],
},
};
await insertContentItem(checklistIntubacion, adminId);
console.log(' ✅ Checklist: Preparación Intubación');
// 3. Checklist: RCP Checklist (versión mejorada)
const checklistRCP = {
id: 'checklist-rcp-adulto-svb',
type: 'checklist',
level: 'operativo',
title: 'Checklist RCP Adulto SVB',
shortTitle: 'RCP SVB Checklist',
description: 'Checklist interactivo para RCP básico en adultos',
content: {
items: [
{ id: '1', text: 'Seguridad de la escena', order: 1, critical: true },
{ id: '2', text: 'Comprobación de respuesta', order: 2, critical: true },
{ id: '3', text: 'Apertura de vía aérea', order: 3, critical: true },
{ id: '4', text: 'Comprobación de respiración (<10s)', order: 4, critical: true },
{ id: '5', text: 'Activación de emergencias', order: 5, critical: true },
{ id: '6', text: 'Inicio de compresiones', order: 6, critical: true },
{ id: '7', text: 'Colocación del DEA', order: 7, critical: true },
{ id: '8', text: 'Análisis y descarga si indicada', order: 8, critical: true },
{ id: '9', text: 'RCP de alta calidad continua', order: 9, critical: true },
{ id: '10', text: 'Reevaluación cada 2 minutos', order: 10, critical: true },
],
description: 'Checklist esencial para RCP básico en adultos',
estimatedTime: 'Continuo',
applicableProtocols: ['rcp-adulto-svb'],
tags: ['RCP', 'SVB', 'reanimación'],
},
};
await insertContentItem(checklistRCP, adminId);
console.log(' ✅ Checklist: RCP Adulto SVB');
// 4. Protocolo de ejemplo: RCP Adulto SVB (extendido)
const protocolRCP = {
id: 'rcp-adulto-svb-extended',
type: 'protocol',
level: 'operativo',
title: 'RCP Adulto SVB - Versión Extendida',
shortTitle: 'RCP SVB Ext',
description: 'Protocolo RCP SVB con checklist integrado y dosis inline',
category: 'soporte_vital',
subcategory: 'rcp',
priority: 'critico',
ageGroup: 'adulto',
content: {
pasosRapidos: [
{ order: 1, text: 'Garantizar seguridad de la escena', critical: true, timeEstimate: '5-10s' },
{ order: 2, text: 'Comprobar consciencia: estimular y preguntar "¿Se encuentra bien?"', critical: true, timeEstimate: '5s' },
{ order: 3, text: 'Si no responde, llamar inmediatamente al 112', critical: true, timeEstimate: '10s' },
{ order: 4, text: 'Abrir vía aérea: maniobra frente-mentón', critical: true, timeEstimate: '5s' },
{ order: 5, text: 'Comprobar respiración: VER-OÍR-SENTIR (máx. 10 segundos)', critical: true, timeEstimate: '10s' },
],
checklist: {
enabled: true,
items: [
{ id: '1', text: 'Seguridad de la escena', order: 1, reusableChecklistId: null },
{ id: '2', text: 'Comprobación de respuesta', order: 2, reusableChecklistId: null },
{ id: '3', text: 'Apertura de vía aérea', order: 3, reusableChecklistId: null },
],
title: 'Checklist RCP SVB',
},
dosisInline: [
{
drugId: 'adrenalina',
drugName: 'Adrenalina',
adultDose: '1 mg IV/IO cada 3-5 min',
route: 'IV',
timing: 'Cada 3-5 minutos en PCR',
context: 'En PCR, una vez establecida vía IV/IO',
},
],
herramientasContexto: [
{
id: 'calc-dosis-pediatrica',
name: 'Calculadora de Dosis Pediátrica',
type: 'calculator',
description: 'Calcula dosis por peso para pacientes pediátricos',
},
],
fuentes: [
{
organization: 'ERC',
guideline: 'European Resuscitation Council Guidelines 2021',
year: 2021,
section: 'Adult Basic Life Support',
},
],
warnings: [
'Profundidad compresiones: 5-6 cm',
'Frecuencia: 100-120 compresiones/min',
],
keyPoints: [
'Compresiones de calidad salvan vidas',
'No interrumpir para pulso hasta que haya signos de vida',
],
equipment: ['DEA', 'Bolsa-mascarilla'],
drugs: ['Adrenalina'],
version: 1,
lastUpdated: new Date().toISOString(),
},
};
await insertContentItem(protocolRCP, adminId);
console.log(' ✅ Protocolo: RCP Adulto SVB Extendido');
// 5. Protocolo de ejemplo: Shock Hemorrágico (extendido)
const protocolShock = {
id: 'shock-hemorragico-extended',
type: 'protocol',
level: 'operativo',
title: 'Shock Hemorrágico - Versión Extendida',
shortTitle: 'Shock Hemorrágico Ext',
description: 'Protocolo de shock hemorrágico con checklist y dosis inline',
category: 'soporte_vital',
subcategory: 'shock',
priority: 'critico',
ageGroup: 'adulto',
content: {
pasosRapidos: [
{ order: 1, text: 'Control de hemorragia externa: presión directa', critical: true, timeEstimate: 'Inmediato' },
{ order: 2, text: 'Torniquete si hemorragia en extremidad no controlable', critical: true, timeEstimate: '30-60s' },
{ order: 3, text: 'Oxigenoterapia alto flujo', critical: true, timeEstimate: '30s' },
],
dosisInline: [
{
drugId: 'acido-tranexamico',
drugName: 'Ácido Tranexámico',
adultDose: '1g IV en bolo lento (10 min)',
route: 'IV',
timing: 'Lo antes posible en hemorragia grave',
context: 'En hemorragia traumática grave',
},
],
fuentes: [
{
organization: 'SEMES',
guideline: 'Protocolo de Shock Hemorrágico',
year: 2023,
},
],
warnings: [
'Hipotensión permisiva: TAS 80-90 mmHg',
'Excepto en TCE: mantener TAS >90 mmHg',
],
equipment: ['Torniquete', 'Agentes hemostáticos'],
drugs: ['Ácido tranexámico'],
version: 1,
lastUpdated: new Date().toISOString(),
},
};
await insertContentItem(protocolShock, adminId);
console.log(' ✅ Protocolo: Shock Hemorrágico Extendido');
console.log('\n✅ Contenido de ejemplo creado exitosamente');
} catch (error) {
console.error('❌ Error creando contenido de ejemplo:', error);
process.exit(1);
}
}
async function insertContentItem(item, userId) {
// Verificar si ya existe
const existing = await query(
`SELECT id FROM emerges_content.content_items WHERE id = $1`,
[item.id]
);
if (existing.rows.length > 0) {
console.log(` ⚠️ ${item.id} ya existe, saltando...`);
return;
}
// Insertar item
await query(
`INSERT INTO emerges_content.content_items
(id, type, level, title, short_title, description, category, subcategory,
priority, age_group, status, version, latest_version, created_by, updated_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'draft', 1, 1, $11, $11)`,
[
item.id,
item.type,
item.level,
item.title,
item.shortTitle,
item.description,
item.category,
item.subcategory,
item.priority,
item.ageGroup,
userId,
]
);
// Insertar versión
const versionId = uuidv4();
await query(
`INSERT INTO emerges_content.content_versions
(version_id, content_item_id, version_number, json_content, created_by)
VALUES ($1, $2, 1, $3, $4)`,
[versionId, item.id, 1, JSON.stringify(item.content), userId]
);
// Actualizar current_version_id
await query(
`UPDATE emerges_content.content_items
SET current_version_id = $1
WHERE id = $2`,
[versionId, item.id]
);
}
seedContent();

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,37 @@
#!/bin/bash
# Script para crear usuario y base de datos
# EJECUTAR MANUALMENTE: sudo -u postgres psql < este_archivo.sql
cat << 'SQL'
-- Crear usuario si no existe
DO $$
BEGIN
IF NOT EXISTS (SELECT FROM pg_user WHERE usename = 'planetazuzu') THEN
CREATE USER planetazuzu WITH PASSWORD 'Monforte.1977';
RAISE NOTICE 'Usuario planetazuzu creado';
ELSE
RAISE NOTICE 'Usuario planetazuzu ya existe';
END IF;
END
$$;
-- Crear base de datos si no existe
SELECT 'CREATE DATABASE emerges_tes OWNER planetazuzu'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'emerges_tes')\gexec
-- Dar permisos
GRANT ALL PRIVILEGES ON DATABASE emerges_tes TO planetazuzu;
-- Conectar a la base de datos y dar permisos en el esquema
\c emerges_tes
GRANT ALL ON SCHEMA emerges_content TO planetazuzu;
ALTER DEFAULT PRIVILEGES IN SCHEMA emerges_content GRANT ALL ON TABLES TO planetazuzu;
ALTER DEFAULT PRIVILEGES IN SCHEMA emerges_content GRANT ALL ON SEQUENCES TO planetazuzu;
\q
SQL
echo ""
echo "📝 Para ejecutar este script:"
echo " sudo -u postgres psql < scripts/setup-database-manual.sh"
echo ""

View file

@ -0,0 +1,849 @@
/**
* SCRIPT DE SINCRONIZACIÓN MASIVA DE CONTENIDO
*
* Sincroniza contenido desde archivos locales (procedures.ts, drugs.ts, guides-index.ts)
* hacia la base de datos PostgreSQL de forma masiva e inteligente.
*
* Características:
* - Migración masiva (no uno por uno)
* - Idempotente (puede ejecutarse múltiples veces)
* - Modo dry-run (ver qué haría sin ejecutar)
* - Detección de cambios (solo actualiza lo necesario)
* - Genera reportes detallados
* - Maneja relaciones bidireccionales
* - Soporta mejoras incrementales
*
* Uso:
* node backend/scripts/sync-content-to-db.js # Ejecutar
* node backend/scripts/sync-content-to-db.js --dry-run # Ver qué haría
* node backend/scripts/sync-content-to-db.js --type=protocols # Solo protocolos
* node backend/scripts/sync-content-to-db.js --force # Forzar actualización
*/
import { query } from '../config/database.js';
import { readFile } from 'fs/promises';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { createHash } from 'crypto';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Configuración
const DRY_RUN = process.argv.includes('--dry-run');
const FORCE = process.argv.includes('--force');
const TYPE_FILTER = process.argv.find(arg => arg.startsWith('--type='))?.split('=')[1];
// Estadísticas
const stats = {
protocols: { created: 0, updated: 0, skipped: 0, errors: 0 },
drugs: { created: 0, updated: 0, skipped: 0, errors: 0 },
guides: { created: 0, updated: 0, skipped: 0, errors: 0 },
checklists: { created: 0, updated: 0, skipped: 0, errors: 0 },
relations: { created: 0, skipped: 0, errors: 0 },
};
/**
* Obtener usuario admin para operaciones
*/
async function getAdminUser() {
const result = await query(
`SELECT id FROM tes_content.users WHERE role = 'super_admin' LIMIT 1`
);
if (result.rows.length === 0) {
throw new Error('No se encontró usuario admin. Ejecuta seed-admin.js primero.');
}
return result.rows[0].id;
}
/**
* Calcular hash de contenido para detectar cambios
*/
function calculateContentHash(content) {
const contentString = JSON.stringify(content, Object.keys(content).sort());
return createHash('sha256').update(contentString).digest('hex');
}
/**
* Mapear prioridad local a prioridad BD
*/
function mapPriority(priority) {
const map = {
'critico': 'critica',
'alto': 'alta',
'medio': 'media',
'bajo': 'baja',
};
return map[priority] || 'media';
}
/**
* Mapear contexto clínico desde categoría
*/
function mapClinicalContext(category, subcategory) {
const contextMap = {
'soporte_vital': 'RCP',
'patologias': 'OTROS',
'escena': 'ABCDE',
'trauma': 'TRAUMA',
'shock': 'SHOCK',
'ictus': 'ICTUS',
'via_aerea': 'VIA_AEREA',
'oxigenoterapia': 'OXIGENOTERAPIA',
};
if (subcategory === 'rcp') return 'RCP';
if (subcategory === 'ovace') return 'OVACE';
if (subcategory === 'shock') return 'SHOCK';
if (subcategory === 'ictus') return 'ICTUS';
return contextMap[category] || 'OTROS';
}
/**
* Sincronizar un protocolo
*/
async function syncProtocol(procedure, adminId) {
try {
const content = {
steps: procedure.steps || [],
warnings: procedure.warnings || [],
keyPoints: procedure.keyPoints || [],
equipment: procedure.equipment || [],
drugs: procedure.drugs || [],
category: procedure.category,
subcategory: procedure.subcategory,
ageGroup: procedure.ageGroup || 'adulto',
};
const contentHash = calculateContentHash(content);
const clinicalContext = mapClinicalContext(procedure.category, procedure.subcategory);
const priority = mapPriority(procedure.priority);
// Verificar si existe
const existing = await query(
`SELECT id, content, updated_at FROM tes_content.content_items
WHERE slug = $1 AND type = 'protocol'`,
[procedure.id]
);
if (existing.rows.length > 0) {
const existingItem = existing.rows[0];
const existingContent = existingItem.content;
const existingHash = calculateContentHash(existingContent);
// Si el hash es igual y no es force, saltar
if (contentHash === existingHash && !FORCE) {
stats.protocols.skipped++;
return { action: 'skipped', id: existingItem.id };
}
// Actualizar
if (!DRY_RUN) {
await query(
`UPDATE tes_content.content_items SET
title = $1,
short_title = $2,
description = $3,
clinical_context = $4,
level = 'operativo',
priority = $5::tes_content.priority,
content = $6::jsonb,
tags = $7::text[],
category = $8,
subcategory = $9,
age_group = $10,
updated_by = $11,
updated_at = NOW()
WHERE slug = $12 AND type = 'protocol'`,
[
procedure.title,
procedure.shortTitle || null,
`Protocolo operativo: ${procedure.title}`,
clinicalContext || null,
priority || 'media',
JSON.stringify(content),
[procedure.category, procedure.subcategory].filter(Boolean),
procedure.category || null,
procedure.subcategory || null,
procedure.ageGroup || 'adulto',
adminId,
procedure.id,
]
);
}
stats.protocols.updated++;
return { action: 'updated', id: existingItem.id };
}
// Crear nuevo
if (!DRY_RUN) {
const result = await query(
`INSERT INTO tes_content.content_items (
id, type, slug, title, short_title, description,
clinical_context, level, priority, status,
source_guideline, version, latest_version,
content, tags, category, subcategory, age_group,
created_by, updated_by
) VALUES (
$1, 'protocol', $2, $3, $4, $5,
$6, 'operativo', $7::tes_content.content_priority,
'published'::tes_content.content_status,
'INTERNO', 1, 1,
$8::jsonb,
$9::text[],
$10,
$11,
$12,
$13, $13
)
RETURNING id, slug, title`,
[
procedure.id, // Usar el id como primary key
procedure.id, // slug
procedure.title,
procedure.shortTitle || null,
`Protocolo operativo: ${procedure.title}`,
clinicalContext || null,
priority || 'media',
JSON.stringify(content),
[procedure.category, procedure.subcategory].filter(Boolean),
procedure.category || null,
procedure.subcategory || null,
procedure.ageGroup || 'adulto',
adminId,
]
);
stats.protocols.created++;
return { action: 'created', id: result.rows[0].id };
}
stats.protocols.created++;
return { action: 'created', id: null };
} catch (error) {
console.error(`❌ Error sincronizando protocolo ${procedure.id}:`, error.message);
stats.protocols.errors++;
return { action: 'error', error: error.message };
}
}
/**
* Sincronizar un fármaco
*/
async function syncDrug(drug, adminId) {
try {
const content = {
genericName: drug.genericName,
tradeName: drug.tradeName,
category: drug.category,
presentation: drug.presentation,
adultDose: drug.adultDose,
pediatricDose: drug.pediatricDose,
routes: drug.routes || [],
dilution: drug.dilution,
indications: drug.indications || [],
contraindications: drug.contraindications || [],
sideEffects: drug.sideEffects,
antidote: drug.antidote,
notes: drug.notes,
criticalPoints: drug.criticalPoints,
source: drug.source,
};
const contentHash = calculateContentHash(content);
const priority = mapPriority('medio'); // Fármacos generalmente media prioridad
// Verificar si existe
const existing = await query(
`SELECT id, content FROM tes_content.content_items
WHERE slug = $1 AND type = 'drug'`,
[drug.id]
);
if (existing.rows.length > 0) {
const existingItem = existing.rows[0];
const existingHash = calculateContentHash(existingItem.content);
if (contentHash === existingHash && !FORCE) {
stats.drugs.skipped++;
return { action: 'skipped', id: existingItem.id };
}
if (!DRY_RUN) {
await query(
`UPDATE tes_content.content_items SET
title = $1,
short_title = $2,
description = $3,
clinical_context = 'FARMACOLOGIA'::tes_content.clinical_context,
level = 'referencia'::tes_content.usage_type,
priority = $4::tes_content.priority,
content = $5::jsonb,
tags = $6::text[],
category = $7,
updated_by = $8,
updated_at = NOW()
WHERE id = $9`,
[
drug.genericName,
drug.tradeName,
`Fármaco: ${drug.genericName}`,
priority,
JSON.stringify(content),
[drug.category, ...(drug.indications || [])].filter(Boolean),
drug.category,
adminId,
existingItem.id,
]
);
}
stats.drugs.updated++;
return { action: 'updated', id: existingItem.id };
}
if (!DRY_RUN) {
const result = await query(
`INSERT INTO tes_content.content_items (
id, type, slug, title, short_title, description,
clinical_context, level, priority, status,
source_guideline, version, latest_version,
content, tags, category,
created_by, updated_by
) VALUES (
gen_random_uuid(),
'drug',
$1, $2, $3, $4,
'FARMACOLOGIA'::tes_content.clinical_context,
'referencia'::tes_content.usage_type,
$5::tes_content.priority,
'published'::tes_content.content_status,
'INTERNO'::tes_content.source_guideline,
'1.0.0', '1.0.0',
$6::jsonb,
$7::text[],
$8,
$9, $9
)
RETURNING id, slug, title`,
[
drug.id,
drug.genericName,
drug.tradeName,
`Fármaco: ${drug.genericName}`,
priority,
JSON.stringify(content),
[drug.category, ...(drug.indications || [])].filter(Boolean),
drug.category,
adminId,
]
);
stats.drugs.created++;
return { action: 'created', id: result.rows[0].id };
}
stats.drugs.created++;
return { action: 'created', id: null };
} catch (error) {
console.error(`❌ Error sincronizando fármaco ${drug.id}:`, error.message);
stats.drugs.errors++;
return { action: 'error', error: error.message };
}
}
/**
* Sincronizar una guía
*/
async function syncGuide(guide, adminId) {
try {
const content = {
titulo: guide.titulo,
descripcion: guide.descripcion,
icono: guide.icono,
secciones: guide.secciones || [],
protocoloOperativo: guide.protocoloOperativo,
scormAvailable: guide.scormAvailable || false,
};
const contentHash = calculateContentHash(content);
// Verificar si existe
const existing = await query(
`SELECT id, content FROM tes_content.content_items
WHERE slug = $1 AND type = 'guide'`,
[guide.id]
);
if (existing.rows.length > 0) {
const existingItem = existing.rows[0];
const existingHash = calculateContentHash(existingItem.content);
if (contentHash === existingHash && !FORCE) {
stats.guides.skipped++;
return { action: 'skipped', id: existingItem.id };
}
if (!DRY_RUN) {
await query(
`UPDATE tes_content.content_items SET
title = $1,
description = $2,
clinical_context = 'OTROS'::tes_content.clinical_context,
level = 'formativo'::tes_content.usage_type,
priority = 'alta'::tes_content.priority,
content = $3::jsonb,
tags = $4::text[],
updated_by = $5,
updated_at = NOW()
WHERE id = $6`,
[
guide.titulo,
guide.descripcion,
JSON.stringify(content),
['guia', 'formativo', guide.id],
adminId,
existingItem.id,
]
);
}
stats.guides.updated++;
return { action: 'updated', id: existingItem.id };
}
if (!DRY_RUN) {
const result = await query(
`INSERT INTO tes_content.content_items (
id, type, slug, title, description,
clinical_context, level, priority, status,
source_guideline, version, latest_version,
content, tags,
created_by, updated_by
) VALUES (
gen_random_uuid(),
'guide',
$1, $2, $3,
'OTROS'::tes_content.clinical_context,
'formativo'::tes_content.usage_type,
'alta'::tes_content.priority,
'published'::tes_content.content_status,
'INTERNO'::tes_content.source_guideline,
'1.0.0', '1.0.0',
$4::jsonb,
$5::text[],
$6, $6
)
RETURNING id, slug, title`,
[
guide.id,
guide.titulo,
guide.descripcion,
JSON.stringify(content),
['guia', 'formativo', guide.id],
adminId,
]
);
stats.guides.created++;
return { action: 'created', id: result.rows[0].id };
}
stats.guides.created++;
return { action: 'created', id: null };
} catch (error) {
console.error(`❌ Error sincronizando guía ${guide.id}:`, error.message);
stats.guides.errors++;
return { action: 'error', error: error.message };
}
}
/**
* Sincronizar relaciones bidireccionales desde protocol-guide-manual-mapping
*/
async function syncRelations(adminId) {
try {
console.log('📋 Sincronizando relaciones bidireccionales...');
// Verificar si existe tabla content_relations
const tableExists = await query(`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'tes_content'
AND table_name = 'content_relations'
)
`);
if (!tableExists.rows[0].exists) {
console.log(' ⚠️ Tabla content_relations no existe aún');
console.log(' Ejecuta las migraciones primero (ver VALIDACION_TECNICA_FASE_B_C.md)');
stats.relations.skipped++;
return;
}
// Importar mapping
try {
const mappingModule = await import('../../src/data/protocol-guide-manual-mapping.ts?t=' + Date.now());
const mappings = mappingModule.protocolGuideManualMapping || [];
let relationsCreated = 0;
for (const mapping of mappings) {
if (!mapping.protocoloId || !mapping.guiaId) continue;
// Buscar IDs de contenido
const protocolResult = await query(
`SELECT id FROM tes_content.content_items WHERE slug = $1 AND type = 'protocol'`,
[mapping.protocoloId]
);
const guideResult = await query(
`SELECT id FROM tes_content.content_items WHERE slug = $1 AND type = 'guide'`,
[mapping.guiaId]
);
if (protocolResult.rows.length === 0 || guideResult.rows.length === 0) {
continue; // Contenido no existe aún
}
const protocolId = protocolResult.rows[0].id;
const guideId = guideResult.rows[0].id;
// Verificar si relación ya existe
const existing = await query(
`SELECT id FROM tes_content.content_relations
WHERE source_content_id = $1 AND target_content_id = $2
AND relation_type = 'protocol_to_guide'`,
[protocolId, guideId]
);
if (existing.rows.length > 0) {
continue; // Ya existe
}
// Crear relación bidireccional
if (!DRY_RUN) {
// Relación protocolo → guía
await query(
`INSERT INTO tes_content.content_relations
(source_content_id, target_content_id, relation_type, is_bidirectional, created_by)
VALUES ($1, $2, 'protocol_to_guide', true, $3)`,
[protocolId, guideId, adminId]
);
// Relación guía → protocolo (bidireccional)
await query(
`INSERT INTO tes_content.content_relations
(source_content_id, target_content_id, relation_type, is_bidirectional, created_by)
VALUES ($1, $2, 'guide_to_protocol', true, $3)`,
[guideId, protocolId, adminId]
);
}
relationsCreated++;
}
stats.relations.created = relationsCreated;
console.log(`${relationsCreated} relaciones creadas`);
} catch (error) {
console.log(' ⚠️ No se pudieron cargar relaciones automáticamente');
console.log(' Las relaciones se pueden gestionar desde el admin panel');
}
} catch (error) {
console.error('❌ Error sincronizando relaciones:', error.message);
stats.relations.errors++;
}
}
/**
* Extraer array de strings desde código TypeScript
*/
function extractArray(str) {
if (!str) return [];
return str
.split(',')
.map(s => s.trim().replace(/^['"]|['"]$/g, ''))
.filter(Boolean);
}
/**
* Cargar procedures desde archivo
*/
async function loadProcedures() {
const proceduresPath = join(__dirname, '../../src/data/procedures.ts');
const content = await readFile(proceduresPath, 'utf-8');
const procedures = [];
const procedureRegex = /{\s*id:\s*['"]([^'"]+)['"],\s*title:\s*['"]([^'"]+)['"],\s*shortTitle:\s*['"]([^'"]+)['"],\s*category:\s*['"]([^'"]+)['"],\s*(?:subcategory:\s*['"]([^'"]*)?['"],\s*)?priority:\s*['"]([^'"]+)['"],\s*ageGroup:\s*['"]([^'"]+)['"],\s*steps:\s*\[([\s\S]*?)\],\s*warnings:\s*\[([\s\S]*?)\],\s*(?:keyPoints:\s*\[([\s\S]*?)\],)?\s*(?:equipment:\s*\[([\s\S]*?)\],)?\s*(?:drugs:\s*\[([\s\S]*?)\],)?\s*}/g;
let match;
while ((match = procedureRegex.exec(content)) !== null) {
const [, id, title, shortTitle, category, subcategory, priority, ageGroup, stepsStr, warningsStr, keyPointsStr, equipmentStr, drugsStr] = match;
procedures.push({
id,
title,
shortTitle,
category,
subcategory: subcategory || null,
priority,
ageGroup,
steps: extractArray(stepsStr),
warnings: extractArray(warningsStr),
keyPoints: keyPointsStr ? extractArray(keyPointsStr) : [],
equipment: equipmentStr ? extractArray(equipmentStr) : [],
drugs: drugsStr ? extractArray(drugsStr) : [],
});
}
return procedures;
}
/**
* Cargar drugs desde archivo
*/
async function loadDrugs() {
const drugsPath = join(__dirname, '../../src/data/drugs.ts');
const content = await readFile(drugsPath, 'utf-8');
const drugs = [];
const drugBlockRegex = /{\s*id:\s*['"]([^'"]+)['"],\s*(?:source:\s*['"]([^'"]*)?['"],\s*)?genericName:\s*['"]([^'"]+)['"],\s*tradeName:\s*['"]([^'"]+)['"],\s*category:\s*['"]([^'"]+)['"],\s*presentation:\s*['"]([^'"]+)['"],\s*adultDose:\s*['"]([^'"]+)['"],\s*(?:pediatricDose:\s*['"]([^'"]*)?['"],)?\s*routes:\s*\[([\s\S]*?)\],\s*(?:dilution:\s*['"]([^'"]*)?['"],)?\s*indications:\s*\[([\s\S]*?)\],\s*contraindications:\s*\[([\s\S]*?)\],\s*(?:sideEffects:\s*\[([\s\S]*?)\],)?\s*(?:antidote:\s*['"]([^'"]*)?['"],)?\s*(?:notes:\s*\[([\s\S]*?)\],)?\s*(?:criticalPoints:\s*\[([\s\S]*?)\],)?\s*(?:source:\s*['"]([^'"]*)?['"])?\s*}/g;
let match;
while ((match = drugBlockRegex.exec(content)) !== null) {
const [, id, source1, genericName, tradeName, category, presentation, adultDose, pediatricDose, routesStr, dilution, indicationsStr, contraindicationsStr, sideEffectsStr, antidote, notesStr, criticalPointsStr, source2] = match;
drugs.push({
id,
genericName,
tradeName,
category,
presentation,
adultDose,
pediatricDose: pediatricDose || null,
routes: extractArray(routesStr),
dilution: dilution || null,
indications: extractArray(indicationsStr),
contraindications: extractArray(contraindicationsStr),
sideEffects: sideEffectsStr ? extractArray(sideEffectsStr) : [],
antidote: antidote || null,
notes: notesStr ? extractArray(notesStr) : [],
criticalPoints: criticalPointsStr ? extractArray(criticalPointsStr) : [],
source: source2 || source1 || null,
});
}
return drugs;
}
/**
* Cargar guides desde archivo
*/
async function loadGuides() {
const guidesPath = join(__dirname, '../../src/data/guides-index.ts');
const content = await readFile(guidesPath, 'utf-8');
const guides = [];
const guidePattern = /id:\s*["']([^"']+)["'],\s*titulo:\s*["']([^"']+)["'],\s*descripcion:\s*["']([^"']+)["'],\s*icono:\s*["']([^"']+)["']/g;
let match;
while ((match = guidePattern.exec(content)) !== null) {
const [, id, titulo, descripcion, icono] = match;
// Buscar scormAvailable después del icono
const afterIcono = content.substring(match.index + match[0].length);
const scormMatch = afterIcono.match(/scormAvailable:\s*(true|false)/);
const scormAvailable = scormMatch ? scormMatch[1] === 'true' : false;
// Buscar secciones (simplificado - extraer estructura básica)
const seccionesMatch = afterIcono.match(/secciones:\s*\[([\s\S]*?)\]/);
const secciones = seccionesMatch ? [] : []; // Por ahora vacío, se puede mejorar
guides.push({
id,
titulo,
descripcion,
icono,
scormAvailable,
secciones: secciones,
protocoloOperativo: null, // Se puede extraer si existe
});
}
return guides;
}
/**
* Cargar contenido desde archivos locales
*/
async function loadLocalContent() {
const content = {
procedures: [],
drugs: [],
guides: [],
};
try {
// Cargar procedures
if (!TYPE_FILTER || TYPE_FILTER === 'protocols') {
content.procedures = await loadProcedures();
}
// Cargar drugs
if (!TYPE_FILTER || TYPE_FILTER === 'drugs') {
content.drugs = await loadDrugs();
}
// Cargar guides
if (!TYPE_FILTER || TYPE_FILTER === 'guides') {
content.guides = await loadGuides();
}
} catch (error) {
console.error('❌ Error cargando contenido local:', error.message);
throw error;
}
return content;
}
/**
* Función principal
*/
async function main() {
console.log('🚀 SCRIPT DE SINCRONIZACIÓN MASIVA DE CONTENIDO\n');
console.log('═══════════════════════════════════════════════\n');
if (DRY_RUN) {
console.log('⚠️ MODO DRY-RUN: No se realizarán cambios en la BD\n');
}
if (FORCE) {
console.log('⚡ MODO FORCE: Se actualizarán todos los items (incluso sin cambios)\n');
}
if (TYPE_FILTER) {
console.log(`🔍 FILTRO: Solo sincronizando tipo "${TYPE_FILTER}"\n`);
}
try {
// 1. Obtener usuario admin
console.log('👤 Obteniendo usuario admin...');
const adminId = await getAdminUser();
console.log(` ✅ Usuario admin: ${adminId}\n`);
// 2. Cargar contenido local
console.log('📂 Cargando contenido desde archivos locales...');
const localContent = await loadLocalContent();
console.log(` ✅ Protocolos: ${localContent.procedures.length}`);
console.log(` ✅ Fármacos: ${localContent.drugs.length}`);
console.log(` ✅ Guías: ${localContent.guides.length}\n`);
// 3. Sincronizar protocolos
if (localContent.procedures.length > 0 && (!TYPE_FILTER || TYPE_FILTER === 'protocols')) {
console.log('📋 Sincronizando protocolos...');
for (const procedure of localContent.procedures) {
const result = await syncProtocol(procedure, adminId);
if (result.action === 'created') {
console.log(` ✅ Creado: ${procedure.title}`);
} else if (result.action === 'updated') {
console.log(` 🔄 Actualizado: ${procedure.title}`);
} else if (result.action === 'skipped') {
console.log(` ⏭️ Saltado: ${procedure.title} (sin cambios)`);
}
}
console.log('');
}
// 4. Sincronizar fármacos
if (localContent.drugs.length > 0 && (!TYPE_FILTER || TYPE_FILTER === 'drugs')) {
console.log('💊 Sincronizando fármacos...');
for (const drug of localContent.drugs) {
const result = await syncDrug(drug, adminId);
if (result.action === 'created') {
console.log(` ✅ Creado: ${drug.genericName}`);
} else if (result.action === 'updated') {
console.log(` 🔄 Actualizado: ${drug.genericName}`);
} else if (result.action === 'skipped') {
console.log(` ⏭️ Saltado: ${drug.genericName} (sin cambios)`);
}
}
console.log('');
}
// 5. Sincronizar guías
if (localContent.guides.length > 0 && (!TYPE_FILTER || TYPE_FILTER === 'guides')) {
console.log('📚 Sincronizando guías...');
for (const guide of localContent.guides) {
const result = await syncGuide(guide, adminId);
if (result.action === 'created') {
console.log(` ✅ Creado: ${guide.titulo}`);
} else if (result.action === 'updated') {
console.log(` 🔄 Actualizado: ${guide.titulo}`);
} else if (result.action === 'skipped') {
console.log(` ⏭️ Saltado: ${guide.titulo} (sin cambios)`);
}
}
console.log('');
}
// 6. Sincronizar relaciones (opcional)
if (!TYPE_FILTER) {
await syncRelations(adminId);
console.log('');
}
// 7. Mostrar resumen
console.log('═══════════════════════════════════════════════');
console.log('📊 RESUMEN DE SINCRONIZACIÓN\n');
const totalCreated = stats.protocols.created + stats.drugs.created + stats.guides.created;
const totalUpdated = stats.protocols.updated + stats.drugs.updated + stats.guides.updated;
const totalSkipped = stats.protocols.skipped + stats.drugs.skipped + stats.guides.skipped;
const totalErrors = stats.protocols.errors + stats.drugs.errors + stats.guides.errors;
console.log('Protocolos:');
console.log(` ✅ Creados: ${stats.protocols.created}`);
console.log(` 🔄 Actualizados: ${stats.protocols.updated}`);
console.log(` ⏭️ Saltados: ${stats.protocols.skipped}`);
console.log(` ❌ Errores: ${stats.protocols.errors}\n`);
console.log('Fármacos:');
console.log(` ✅ Creados: ${stats.drugs.created}`);
console.log(` 🔄 Actualizados: ${stats.drugs.updated}`);
console.log(` ⏭️ Saltados: ${stats.drugs.skipped}`);
console.log(` ❌ Errores: ${stats.drugs.errors}\n`);
console.log('Guías:');
console.log(` ✅ Creadas: ${stats.guides.created}`);
console.log(` 🔄 Actualizadas: ${stats.guides.updated}`);
console.log(` ⏭️ Saltadas: ${stats.guides.skipped}`);
console.log(` ❌ Errores: ${stats.guides.errors}\n`);
console.log('═══════════════════════════════════════════════');
console.log(`TOTAL: ${totalCreated} creados, ${totalUpdated} actualizados, ${totalSkipped} saltados, ${totalErrors} errores\n`);
if (DRY_RUN) {
console.log('⚠️ MODO DRY-RUN: No se realizaron cambios reales\n');
} else {
console.log('✅ Sincronización completada\n');
}
} catch (error) {
console.error('\n❌ Error en sincronización:', error);
process.exit(1);
}
}
// Ejecutar
main();

View file

@ -0,0 +1,66 @@
/**
* Script para probar el endpoint de contenido
*/
import axios from 'axios';
const API_URL = 'http://localhost:3000';
async function testContentEndpoint() {
try {
console.log('🧪 Probando endpoint de contenido...\n');
// 1. Login para obtener token
console.log('1⃣ Haciendo login...');
const loginResponse = await axios.post(`${API_URL}/api/auth/login`, {
email: 'admin@emerges-tes.local',
password: 'Admin123!'
});
const token = loginResponse.data.token;
console.log(' ✅ Login exitoso\n');
// 2. Obtener contenido
console.log('2⃣ Obteniendo contenido...');
const contentResponse = await axios.get(`${API_URL}/api/content`, {
headers: {
'Authorization': `Bearer ${token}`
},
params: {
page: 1,
pageSize: 20
}
});
const { items, total } = contentResponse.data;
console.log(` ✅ Encontrados ${total} items\n`);
console.log('📋 Contenido encontrado:\n');
// Agrupar por tipo
const byType = {};
items.forEach(item => {
if (!byType[item.type]) {
byType[item.type] = [];
}
byType[item.type].push(item);
});
Object.keys(byType).forEach(type => {
console.log(` ${type.toUpperCase()} (${byType[type].length}):`);
byType[type].forEach(item => {
console.log(` - ${item.title} (${item.status})`);
});
console.log('');
});
console.log('✅ Test completado exitosamente!');
} catch (error) {
console.error('❌ Error:', error.response?.data || error.message);
process.exit(1);
}
}
testContentEndpoint();

View file

@ -0,0 +1,201 @@
#!/usr/bin/env node
/**
* Script de verificación de contenido faltante
*
* Compara el contenido local (procedures.ts, drugs.ts, guides-index.ts, material-checklists.ts)
* con el contenido en la base de datos para identificar qué falta.
*
* Uso:
* node backend/scripts/verify-content-missing.js
*/
import dotenv from 'dotenv';
import { query } from '../config/database.js';
import { readFile } from 'fs/promises';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
dotenv.config();
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const REPO_ROOT = join(__dirname, '../..');
// IDs esperados basados en archivos locales
const EXPECTED_PROTOCOLS = [
'rcp-adulto-svb',
'rcp-adulto-sva',
'rcp-pediatrico',
'obstruccion-via-aerea',
'shock-hemorragico',
];
const EXPECTED_DRUGS = [
'oxigeno',
'adrenalina',
'amiodarona',
'atropina',
'midazolam',
'salbutamol',
];
const EXPECTED_GUIDES = [
'abcde-operativo',
'rcp-adulto-svb',
'desa-adulto',
'ovace-adulto',
'ovace-pediatrica',
'parada-respiratoria',
'pcr-traumatica',
'rcp-lactantes',
'rcp-pediatrica',
'reconocimiento-pcr',
];
const EXPECTED_CHECKLISTS = [
'inicio-turno-material',
'pre-escena-rapido',
'post-servicio-cierre',
];
async function getContentFromDB(type) {
const typeMap = {
protocol: 'protocol',
guide: 'guide',
checklist: 'checklist',
};
const dbType = typeMap[type] || type;
const result = await query(`
SELECT slug, title, status
FROM tes_content.content_items
WHERE type = $1::tes_content.content_type
AND version = latest_version
ORDER BY slug
`, [dbType]);
return result.rows.map(row => row.slug);
}
async function getDrugsFromDB() {
const result = await query(`
SELECT slug, generic_name, status
FROM tes_content.drugs
ORDER BY slug
`);
return result.rows.map(row => row.slug);
}
async function main() {
console.log('\n🔍 Verificando contenido faltante en la base de datos...\n');
try {
// Protocolos
console.log('📋 PROTOCOLOS:');
const dbProtocols = await getContentFromDB('protocol');
const missingProtocols = EXPECTED_PROTOCOLS.filter(id => !dbProtocols.includes(id));
const extraProtocols = dbProtocols.filter(id => !EXPECTED_PROTOCOLS.includes(id));
console.log(` Esperados: ${EXPECTED_PROTOCOLS.length}`);
console.log(` En DB: ${dbProtocols.length}`);
console.log(` Faltantes: ${missingProtocols.length}`);
if (missingProtocols.length > 0) {
console.log(` ❌ Faltantes: ${missingProtocols.join(', ')}`);
} else {
console.log(` ✅ Todos los protocolos están en la base de datos`);
}
if (extraProtocols.length > 0) {
console.log(` Extra en DB: ${extraProtocols.join(', ')}`);
}
// Fármacos
console.log('\n💊 FÁRMACOS:');
const dbDrugs = await getDrugsFromDB();
const missingDrugs = EXPECTED_DRUGS.filter(id => !dbDrugs.includes(id));
const extraDrugs = dbDrugs.filter(id => !EXPECTED_DRUGS.includes(id));
console.log(` Esperados: ${EXPECTED_DRUGS.length}`);
console.log(` En DB: ${dbDrugs.length}`);
console.log(` Faltantes: ${missingDrugs.length}`);
if (missingDrugs.length > 0) {
console.log(` ❌ Faltantes: ${missingDrugs.join(', ')}`);
} else {
console.log(` ✅ Todos los fármacos están en la base de datos`);
}
if (extraDrugs.length > 0) {
console.log(` Extra en DB: ${extraDrugs.join(', ')}`);
}
// Guías
console.log('\n📚 GUÍAS:');
const dbGuides = await getContentFromDB('guide');
const missingGuides = EXPECTED_GUIDES.filter(id => !dbGuides.includes(id));
const extraGuides = dbGuides.filter(id => !EXPECTED_GUIDES.includes(id));
console.log(` Esperados: ${EXPECTED_GUIDES.length}`);
console.log(` En DB: ${dbGuides.length}`);
console.log(` Faltantes: ${missingGuides.length}`);
if (missingGuides.length > 0) {
console.log(` ❌ Faltantes: ${missingGuides.join(', ')}`);
} else {
console.log(` ✅ Todas las guías están en la base de datos`);
}
if (extraGuides.length > 0) {
console.log(` Extra en DB: ${extraGuides.join(', ')}`);
}
// Checklists
console.log('\n✅ CHECKLISTS:');
const dbChecklists = await getContentFromDB('checklist');
const missingChecklists = EXPECTED_CHECKLISTS.filter(id => !dbChecklists.includes(id));
const extraChecklists = dbChecklists.filter(id => !EXPECTED_CHECKLISTS.includes(id));
console.log(` Esperados: ${EXPECTED_CHECKLISTS.length}`);
console.log(` En DB: ${dbChecklists.length}`);
console.log(` Faltantes: ${missingChecklists.length}`);
if (missingChecklists.length > 0) {
console.log(` ❌ Faltantes: ${missingChecklists.join(', ')}`);
} else {
console.log(` ✅ Todos los checklists están en la base de datos`);
}
if (extraChecklists.length > 0) {
console.log(` Extra en DB: ${extraChecklists.join(', ')}`);
}
// Resumen
const totalMissing =
missingProtocols.length +
missingDrugs.length +
missingGuides.length +
missingChecklists.length;
console.log('\n📊 RESUMEN:');
console.log(` Total faltante: ${totalMissing} items`);
if (totalMissing > 0) {
console.log('\n💡 Para sincronizar el contenido faltante, ejecuta:');
console.log(' node backend/scripts/sync-content-to-db.js\n');
} else {
console.log('\n✅ ¡Todo el contenido local está en la base de datos!\n');
}
} catch (error) {
console.error('\n❌ Error verificando contenido:', error.message);
console.error(error);
process.exit(1);
}
}
main();

59
backend/scripts/verify-setup.js Executable file
View file

@ -0,0 +1,59 @@
#!/usr/bin/env node
/**
* Script de verificación de setup
*
* Verifica que todo esté configurado correctamente antes de ejecutar migraciones
*/
import dotenv from 'dotenv';
import { testConnection } from '../config/database.js';
dotenv.config();
async function main() {
console.log('\n🔍 Verificando configuración...\n');
// Verificar variables de entorno
const required = ['DB_HOST', 'DB_PORT', 'DB_NAME', 'DB_USER'];
const missing = required.filter(key => !process.env[key]);
if (missing.length > 0) {
console.error('❌ Variables de entorno faltantes:', missing.join(', '));
console.error(' Editar backend/.env con los valores correctos\n');
process.exit(1);
}
if (!process.env.DB_PASSWORD) {
console.warn('⚠️ DB_PASSWORD no configurado');
console.warn(' Si PostgreSQL no requiere password, dejar vacío puede funcionar');
console.warn(' Si requiere password, configurarlo en backend/.env\n');
}
console.log('✅ Variables de entorno configuradas');
console.log(` DB_HOST: ${process.env.DB_HOST}`);
console.log(` DB_PORT: ${process.env.DB_PORT}`);
console.log(` DB_NAME: ${process.env.DB_NAME}`);
console.log(` DB_USER: ${process.env.DB_USER}`);
console.log(` DB_PASSWORD: ${process.env.DB_PASSWORD ? '***' : '(vacío)'}\n`);
// Verificar conexión
console.log('🔌 Probando conexión a PostgreSQL...\n');
const connected = await testConnection();
if (connected) {
console.log('\n✅ ¡Todo listo! Puedes continuar con:');
console.log(' npm run db:create\n');
process.exit(0);
} else {
console.log('\n❌ No se pudo conectar a PostgreSQL');
console.log('\n💡 Soluciones:');
console.log(' 1. Verificar que PostgreSQL está corriendo: sudo systemctl status postgresql');
console.log(' 2. Verificar credenciales en backend/.env');
console.log(' 3. Ver guía en backend/CONFIGURAR_PASSWORD.md\n');
process.exit(1);
}
}
main();

View file

@ -0,0 +1,65 @@
/**
* Configuración CORS Mejorada
* Limita orígenes incluso en desarrollo por seguridad
*/
import { CorsOptions } from 'cors';
/**
* Obtener configuración CORS basada en entorno
*/
export function getCorsConfig(): CorsOptions {
const isDevelopment = process.env.NODE_ENV !== 'production';
// Orígenes por defecto para desarrollo
const defaultDevOrigins = [
'http://localhost:8096',
'http://localhost:5174',
'http://localhost:5173',
];
// Obtener orígenes permitidos de env o usar defaults
const envOrigins = process.env.CORS_ORIGINS
? process.env.CORS_ORIGINS.split(',').map(origin => origin.trim())
: [];
const allowedOrigins = envOrigins.length > 0 ? envOrigins : defaultDevOrigins;
// En producción, CORS_ORIGINS es requerido
if (!isDevelopment && allowedOrigins.length === 0) {
console.error('❌ CRÍTICO: CORS_ORIGINS no configurado en producción');
console.error(' Configurar CORS_ORIGINS en .env con orígenes permitidos separados por coma');
console.error(' Ejemplo: CORS_ORIGINS=https://app.example.com,https://admin.example.com');
process.exit(1);
}
console.log(`✅ CORS configurado para ${isDevelopment ? 'desarrollo' : 'producción'}`);
console.log(` Orígenes permitidos: ${allowedOrigins.join(', ')}`);
return {
origin: (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => {
// Permitir requests sin origen solo en desarrollo (mobile apps, Postman, etc.)
if (!origin) {
if (isDevelopment) {
return callback(null, true);
} else {
return callback(new Error('Origen no proporcionado - requerido en producción'));
}
}
// Verificar si el origen está en la lista permitida
if (allowedOrigins.includes(origin)) {
callback(null, true);
} else {
console.warn(`⚠️ Intento de acceso desde origen no permitido: ${origin}`);
callback(new Error(`Origen ${origin} no permitido por CORS`));
}
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
exposedHeaders: ['X-Total-Count', 'X-Page-Count'],
maxAge: 86400, // 24 horas
};
}

127
backend/src/config/env.ts Normal file
View file

@ -0,0 +1,127 @@
/**
* Validación de Variables de Entorno
* Valida todas las variables requeridas al startup
*/
import dotenv from 'dotenv';
dotenv.config();
interface EnvConfig {
db: {
host: string;
port: number;
database: string;
user: string;
password: string;
};
jwt: {
secret: string;
expiresIn: string;
};
cors: {
origins: string[];
};
webhook: {
secret: string;
};
redis: {
url: string;
};
nodeEnv: string;
}
/**
* Variables de entorno requeridas por categoría
*/
const REQUIRED_ENV_VARS = {
database: [
'DB_HOST',
'DB_NAME',
'DB_USER',
'DB_PASSWORD',
],
security: [
'JWT_SECRET',
],
optional: [
'DB_PORT',
'JWT_EXPIRES_IN',
'CORS_ORIGINS',
'WEBHOOK_SECRET',
'REDIS_URL',
'NODE_ENV',
],
};
/**
* Validar variables de entorno requeridas
*/
export function validateEnv(): EnvConfig {
const missing: string[] = [];
const warnings: string[] = [];
// Validar variables requeridas
Object.entries(REQUIRED_ENV_VARS).forEach(([category, vars]) => {
vars.forEach(key => {
if (!process.env[key]) {
if (category !== 'optional') {
missing.push(`${key} (${category})`);
} else {
warnings.push(`${key} (${category})`);
}
}
});
});
// Validar formatos específicos
if (process.env.JWT_SECRET && process.env.JWT_SECRET.length < 32) {
missing.push('JWT_SECRET debe tener al menos 32 caracteres');
}
if (process.env.DB_PORT && isNaN(parseInt(process.env.DB_PORT, 10))) {
missing.push('DB_PORT debe ser un número válido');
}
// Mostrar advertencias
if (warnings.length > 0 && process.env.NODE_ENV === 'production') {
console.warn('⚠️ Variables opcionales no configuradas (recomendadas en producción):');
warnings.forEach(key => console.warn(` - ${key}`));
}
// Si faltan variables críticas, salir
if (missing.length > 0) {
console.error('❌ CRÍTICO: Variables de entorno faltantes o inválidas:');
missing.forEach(key => console.error(` - ${key}`));
console.error('\n⚠ Configura estas variables en el archivo .env\n');
process.exit(1);
}
console.log('✅ Variables de entorno validadas correctamente');
return {
db: {
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432', 10),
database: process.env.DB_NAME || 'emerges_tes',
user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || '',
},
jwt: {
secret: process.env.JWT_SECRET || '',
expiresIn: process.env.JWT_EXPIRES_IN || '24h',
},
cors: {
origins: process.env.CORS_ORIGINS
? process.env.CORS_ORIGINS.split(',').map(o => o.trim())
: [],
},
webhook: {
secret: process.env.WEBHOOK_SECRET || '',
},
redis: {
url: process.env.REDIS_URL || 'redis://localhost:6379',
},
nodeEnv: process.env.NODE_ENV || 'development',
};
}

View file

@ -0,0 +1,63 @@
/**
* Configuración de Seguridad
* Valida variables de entorno críticas al startup
*/
import dotenv from 'dotenv';
dotenv.config();
interface SecurityConfig {
JWT_SECRET: string;
JWT_EXPIRES_IN: string;
WEBHOOK_SECRET: string;
}
/**
* Validar configuración de seguridad
* Si alguna variable crítica falta, la app no arranca
*/
export function validateSecurityConfig(): SecurityConfig {
const errors: string[] = [];
// JWT_SECRET es crítico - debe existir y no ser el valor por defecto
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET) {
errors.push('JWT_SECRET no está configurado en .env');
console.error('❌ CRÍTICO: JWT_SECRET no configurado');
console.error(' Generar con: openssl rand -base64 32');
} else if (JWT_SECRET === 'emerges-tes-secret-key-change-in-production') {
errors.push('JWT_SECRET está usando el valor por defecto inseguro');
console.error('❌ CRÍTICO: JWT_SECRET usa valor por defecto inseguro');
console.error(' Generar un secret seguro con: openssl rand -base64 32');
console.error(' Y actualizarlo en .env');
} else if (JWT_SECRET.length < 32) {
errors.push('JWT_SECRET debe tener al menos 32 caracteres');
console.error('❌ CRÍTICO: JWT_SECRET demasiado corto (mínimo 32 caracteres)');
}
// WEBHOOK_SECRET es crítico en producción
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
if (process.env.NODE_ENV === 'production' && !WEBHOOK_SECRET) {
errors.push('WEBHOOK_SECRET no configurado en producción');
console.error('❌ CRÍTICO: WEBHOOK_SECRET no configurado en producción');
}
// Si hay errores críticos, salir
if (errors.length > 0) {
console.error('\n🚨 ERRORES DE SEGURIDAD ENCONTRADOS:');
errors.forEach((error, index) => {
console.error(` ${index + 1}. ${error}`);
});
console.error('\n⚠ La aplicación no puede iniciarse con estos errores de seguridad.\n');
process.exit(1);
}
console.log('✅ Variables de seguridad validadas correctamente');
return {
JWT_SECRET: JWT_SECRET || '',
JWT_EXPIRES_IN: process.env.JWT_EXPIRES_IN || '24h',
WEBHOOK_SECRET: WEBHOOK_SECRET || '',
};
}

149
backend/src/index.js Normal file
View file

@ -0,0 +1,149 @@
/**
* EMERGES TES - Backend API Server
*
* FASE 1: Infraestructura Base
*
* IMPORTANTE: Este es un esqueleto básico.
* La implementación completa se hará progresivamente.
*/
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import { join } from 'path';
import { testConnection } from '../config/database.js';
import { validateSecurityConfig } from './config/security.js';
import { validateEnv } from './config/env.js';
import { getCorsConfig } from './config/cors.js';
import { securityHeaders } from './middleware/security-headers.js';
import { generalLimiter } from './middleware/rate-limit.js';
import logger, { logError } from './utils/logger.js';
import requestLogger from './middleware/request-logger.js';
import authRoutes from './routes/auth.js'; // TypeScript
import contentRoutes from './routes/content.js'; // TypeScript
import statsRoutes from './routes/stats.js'; // TypeScript
import contentPackRoutes from './routes/content-pack.js'; // TypeScript
import contentPackAdminRoutes from './routes/content-pack-admin.js'; // TypeScript
import mediaRoutes from './routes/media.js'; // TypeScript
import contentResourcesRoutes from './routes/content-resources.js'; // TypeScript
import scormRoutes from './routes/scorm.js'; // TypeScript
import validationRoutes from './routes/validation.js'; // TypeScript
import drugsRoutes from './routes/drugs.js'; // TypeScript
import webhookRoutes from './routes/webhook.js'; // TypeScript
dotenv.config();
// ✅ VALIDACIÓN CRÍTICA DE SEGURIDAD AL STARTUP
// Si alguna validación falla, la app no arranca
logger.info('🔒 Validando configuración de seguridad...');
validateSecurityConfig();
validateEnv();
logger.info('✅ Configuración validada correctamente');
const app = express();
const PORT = process.env.PORT || process.env.API_PORT || 3000;
// ✅ SECURITY HEADERS (Helmet.js)
app.use(securityHeaders);
// ✅ CORS MEJORADO (con validación de orígenes)
app.use(cors(getCorsConfig()));
// ✅ RATE LIMITING GENERAL
app.use(generalLimiter);
// ✅ REQUEST LOGGING
app.use(requestLogger);
// ✅ JSON PARSING
app.use(express.json({ limit: '10mb' })); // Limitar tamaño de payload
// Servir archivos estáticos de media
app.use('/storage/media', express.static(join(process.cwd(), 'storage', 'media')));
// Health check básico
app.get('/health', async (req, res) => {
const dbConnected = await testConnection();
const health = {
status: 'ok',
timestamp: new Date().toISOString(),
database: dbConnected ? 'connected' : 'disconnected',
uptime: process.uptime(),
memory: {
used: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
total: Math.round(process.memoryUsage().heapTotal / 1024 / 1024),
},
};
if (!dbConnected) {
logger.warn('Health check: Database disconnected');
}
res.json(health);
});
// Root endpoint
app.get('/', (req, res) => {
res.json({
message: 'EMERGES TES Backend API',
version: '1.0.0',
endpoints: {
auth: '/api/auth',
content: '/api/content',
stats: '/api/stats',
contentPack: '/api/content-pack',
health: '/health',
},
});
});
// API Routes
app.use('/api/auth', authRoutes);
app.use('/api/content', contentRoutes);
app.use('/api/stats', statsRoutes);
app.use('/api/content-pack', contentPackRoutes);
app.use('/api/admin/content-pack', contentPackAdminRoutes);
app.use('/api/media', mediaRoutes);
app.use('/api/content', contentResourcesRoutes);
app.use('/api/scorm', scormRoutes);
app.use('/api/validation', validationRoutes);
app.use('/api/drugs', drugsRoutes);
app.use('/api/webhook', webhookRoutes);
// Iniciar servidor
app.listen(PORT, async () => {
logger.info('🚀 EMERGES TES Backend API iniciado', {
port: PORT,
environment: process.env.NODE_ENV || 'development',
nodeVersion: process.version,
});
logger.info('📍 Endpoints disponibles', {
health: `http://localhost:${PORT}/health`,
auth: `http://localhost:${PORT}/api/auth`,
content: `http://localhost:${PORT}/api/content`,
});
// Test de conexión a BD
const dbConnected = await testConnection();
if (dbConnected) {
logger.info('✅ Conexión a base de datos establecida');
} else {
logger.error('❌ Error conectando a base de datos');
}
});
// Manejo de errores
process.on('SIGTERM', async () => {
logger.info('SIGTERM recibido, cerrando servidor...');
process.exit(0);
});
process.on('unhandledRejection', (error) => {
logError(error as Error, { type: 'unhandledRejection' });
});
process.on('uncaughtException', (error) => {
logError(error, { type: 'uncaughtException' });
process.exit(1);
});

155
backend/src/index.ts Normal file
View file

@ -0,0 +1,155 @@
/**
* EMERGES TES - Backend API Server
*
* FASE 1: Infraestructura Base
*
* IMPORTANTE: Este es un esqueleto básico.
* La implementación completa se hará progresivamente.
*/
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import { join } from 'path';
import { testConnection } from '../config/database.js';
import { validateSecurityConfig } from './config/security.js';
import { validateEnv } from './config/env.js';
import { getCorsConfig } from './config/cors.js';
import { securityHeaders } from './middleware/security-headers.js';
import { generalLimiter } from './middleware/rate-limit.js';
import logger, { logError } from './utils/logger.js';
import requestLogger from './middleware/request-logger.js';
import authRoutes from './routes/auth.js'; // TypeScript
import contentRoutes from './routes/content.js'; // TypeScript
import statsRoutes from './routes/stats.js'; // TypeScript
import contentPackRoutes from './routes/content-pack.js'; // TypeScript
import contentPackAdminRoutes from './routes/content-pack-admin.js'; // TypeScript
import mediaRoutes from './routes/media.js'; // TypeScript
import contentResourcesRoutes from './routes/content-resources.js'; // TypeScript
import scormRoutes from './routes/scorm.js'; // TypeScript
import validationRoutes from './routes/validation.js'; // TypeScript
import drugsRoutes from './routes/drugs.js'; // TypeScript
import webhookRoutes from './routes/webhook.js'; // TypeScript
import healthRoutes from './routes/health.js'; // TypeScript
dotenv.config();
// ✅ VALIDACIÓN CRÍTICA DE SEGURIDAD AL STARTUP
// Si alguna validación falla, la app no arranca
logger.info('🔒 Validando configuración de seguridad...');
validateSecurityConfig();
validateEnv();
logger.info('✅ Configuración validada correctamente');
const app = express();
const PORT = process.env.PORT || process.env.API_PORT || 3000;
// ✅ SECURITY HEADERS (Helmet.js)
app.use(securityHeaders);
// ✅ CORS MEJORADO (con validación de orígenes)
app.use(cors(getCorsConfig()));
// ✅ RATE LIMITING GENERAL
app.use(generalLimiter);
// ✅ REQUEST LOGGING
app.use(requestLogger);
// ✅ JSON PARSING
app.use(express.json({ limit: '10mb' })); // Limitar tamaño de payload
// Servir archivos estáticos de media
app.use('/storage/media', express.static(join(process.cwd(), 'storage', 'media')));
// Health check básico (mantener para compatibilidad)
app.get('/health', async (_req, res) => {
const dbStart = Date.now();
const dbConnected = await testConnection();
const dbResponseTime = Date.now() - dbStart;
const health = {
status: dbConnected ? 'ok' : 'degraded',
timestamp: new Date().toISOString(),
database: dbConnected ? 'connected' : 'disconnected',
databaseResponseTime: dbResponseTime,
uptime: process.uptime(),
memory: {
used: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
total: Math.round(process.memoryUsage().heapTotal / 1024 / 1024),
},
};
if (!dbConnected) {
logger.warn('Health check: Database disconnected');
}
res.json(health);
});
// Root endpoint
app.get('/', (_req, res) => {
res.json({
message: 'EMERGES TES Backend API',
version: '1.0.0',
endpoints: {
auth: '/api/auth',
content: '/api/content',
stats: '/api/stats',
contentPack: '/api/content-pack',
health: '/health',
},
});
});
// API Routes
app.use('/api/auth', authRoutes);
app.use('/api/content', contentRoutes);
app.use('/api/stats', statsRoutes);
app.use('/api/content-pack', contentPackRoutes);
app.use('/api/admin/content-pack', contentPackAdminRoutes);
app.use('/api/media', mediaRoutes);
app.use('/api/content', contentResourcesRoutes);
app.use('/api/scorm', scormRoutes);
app.use('/api/validation', validationRoutes);
app.use('/api/drugs', drugsRoutes);
app.use('/api/webhook', webhookRoutes);
app.use('/api/health', healthRoutes);
// Iniciar servidor
app.listen(PORT, async () => {
logger.info('🚀 EMERGES TES Backend API iniciado', {
port: PORT,
environment: process.env.NODE_ENV || 'development',
nodeVersion: process.version,
});
logger.info('📍 Endpoints disponibles', {
health: `http://localhost:${PORT}/health`,
auth: `http://localhost:${PORT}/api/auth`,
content: `http://localhost:${PORT}/api/content`,
});
// Test de conexión a BD
const dbConnected = await testConnection();
if (dbConnected) {
logger.info('✅ Conexión a base de datos establecida');
} else {
logger.error('❌ Error conectando a base de datos');
}
});
// Manejo de errores
process.on('SIGTERM', async () => {
logger.info('SIGTERM recibido, cerrando servidor...');
process.exit(0);
});
process.on('unhandledRejection', (error: unknown) => {
logError(error instanceof Error ? error : new Error(String(error)), { type: 'unhandledRejection' });
});
process.on('uncaughtException', (error) => {
logError(error, { type: 'uncaughtException' });
process.exit(1);
});

View file

@ -0,0 +1,167 @@
/**
* Middleware de autenticación y autorización
*/
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { query } from '../../config/database.js';
import { validateSecurityConfig } from '../config/security.js';
// ✅ VALIDACIÓN DE JWT_SECRET (sin fallback débil)
const { JWT_SECRET } = validateSecurityConfig();
export interface User {
id: string;
email: string;
username: string;
role: string;
is_active: boolean;
}
export interface AuthRequest extends Request {
user?: User;
}
interface JwtPayload {
userId: string;
[key: string]: any;
}
type Permission = string;
/**
* Middleware para verificar token JWT
*/
export const authenticate = async (
req: AuthRequest,
res: Response,
next: NextFunction
): Promise<void> => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
res.status(401).json({ error: 'Token no proporcionado' });
return;
}
const token = authHeader.substring(7);
const decoded = jwt.verify(token, JWT_SECRET) as JwtPayload;
// Verificar que el usuario existe y está activo
const result = await query(
`SELECT id, email, username, role, is_active
FROM tes_content.users
WHERE id = $1 AND is_active = true`,
[decoded.userId]
);
if (result.rows.length === 0) {
res.status(401).json({ error: 'Usuario no válido o inactivo' });
return;
}
req.user = result.rows[0] as User;
next();
} catch (error) {
if (error instanceof Error) {
if (error.name === 'JsonWebTokenError') {
res.status(401).json({ error: 'Token inválido' });
return;
}
if (error.name === 'TokenExpiredError') {
res.status(401).json({ error: 'Token expirado' });
return;
}
}
res.status(500).json({ error: 'Error de autenticación' });
}
};
/**
* Middleware para verificar permisos
*/
export const requirePermission = (permission: Permission) => {
return (req: AuthRequest, res: Response, next: NextFunction): void => {
if (!req.user) {
res.status(401).json({ error: 'No autenticado' });
return;
}
const role = req.user.role;
const permissions = getRolePermissions(role);
if (!permissions.includes(permission) && !permissions.includes('*')) {
res.status(403).json({ error: 'Permiso denegado' });
return;
}
next();
};
};
/**
* Obtener permisos por rol
*/
function getRolePermissions(role: string): Permission[] {
const permissions: Record<string, Permission[]> = {
super_admin: ['*'],
admin: [
'content:read',
'content:write',
'content:write:protocol',
'content:write:guide',
'content:write:drug',
'content:write:checklist',
'content:write:manual',
'content:submit',
'content:validate',
'content:approve',
'content:publish',
'audit:read',
'audit:write',
],
editor_clinico: [
'content:read',
'content:write:protocol',
'content:write:drug',
'content:write:checklist',
'content:submit',
],
editor_formativo: [
'content:read',
'content:write:guide',
'content:write:manual',
'content:submit',
],
tes_validador: [
'content:read',
'content:validate',
'content:approve',
'audit:read',
],
formador: [
'content:read',
'content:write:guide',
'content:write:manual',
'content:submit',
],
medico: [
'content:read',
'content:validate',
'content:approve',
'content:publish',
'audit:read',
],
revisor: [
'content:read',
'content:validate',
'content:approve',
'audit:read',
],
viewer: ['content:read'],
};
return permissions[role] || [];
}

View file

@ -0,0 +1,74 @@
/**
* Rate Limiting Middleware
* Protege endpoints contra abuso y ataques de fuerza bruta
*/
import rateLimit, { RateLimitRequestHandler } from 'express-rate-limit';
/**
* Rate limiter general para toda la API
* 100 requests por 15 minutos por IP
*/
export const generalLimiter: RateLimitRequestHandler = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutos
max: 100, // 100 requests por IP
message: {
error: 'Demasiadas peticiones desde esta IP, intenta en 15 minutos',
retryAfter: 900, // segundos
},
standardHeaders: true, // Retorna rate limit info en headers `RateLimit-*`
legacyHeaders: false, // No usa `X-RateLimit-*` headers
skip: (req) => {
// Saltar rate limiting para health checks
return req.path === '/health';
},
});
/**
* Rate limiter estricto para endpoints de autenticación
* 5 intentos por 15 minutos por IP
* Previene ataques de fuerza bruta
*/
export const authLimiter: RateLimitRequestHandler = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutos
max: 5, // Solo 5 intentos de login por IP
message: {
error: 'Demasiados intentos de login, intenta en 15 minutos',
retryAfter: 900,
},
standardHeaders: true,
legacyHeaders: false,
skipSuccessfulRequests: true, // No contar requests exitosos
skipFailedRequests: false, // Contar solo los fallidos
});
/**
* Rate limiter medio para creación de contenido
* 20 creaciones por hora por IP
*/
export const contentWriteLimiter: RateLimitRequestHandler = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hora
max: 20, // 20 creaciones por hora
message: {
error: 'Límite de creación de contenido alcanzado, intenta en 1 hora',
retryAfter: 3600,
},
standardHeaders: true,
legacyHeaders: false,
});
/**
* Rate limiter para endpoints de validación
* 50 validaciones por hora por IP
*/
export const validationLimiter: RateLimitRequestHandler = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hora
max: 50, // 50 validaciones por hora
message: {
error: 'Límite de validaciones alcanzado, intenta en 1 hora',
retryAfter: 3600,
},
standardHeaders: true,
legacyHeaders: false,
});

View file

@ -0,0 +1,35 @@
/**
* Middleware de logging de requests HTTP
*
* Registra todas las peticiones HTTP con información relevante
* incluyendo método, URL, status code, tiempo de respuesta, etc.
*/
import { Request, Response, NextFunction } from 'express';
import { logRequest } from '../utils/logger.js';
/**
* Middleware para logging de requests
*/
export const requestLogger = (req: Request, res: Response, next: NextFunction): void => {
const startTime = Date.now();
// Interceptar el método end de response para calcular tiempo de respuesta
const originalEnd = res.end.bind(res);
res.end = function (chunk?: any, encoding?: any, cb?: any) {
const responseTime = Date.now() - startTime;
logRequest(req, res, responseTime);
if (typeof chunk === 'function') {
return originalEnd(chunk);
}
if (typeof encoding === 'function') {
return originalEnd(chunk, encoding);
}
return originalEnd(chunk, encoding, cb);
};
next();
};
export default requestLogger;

View file

@ -0,0 +1,54 @@
/**
* Security Headers Middleware
* Configura headers de seguridad HTTP usando Helmet.js
*/
import helmet from 'helmet';
import { RequestHandler } from 'express';
/**
* Configuración de Helmet para headers de seguridad
*/
export const securityHeaders: RequestHandler = helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"], // 'unsafe-inline' necesario para algunos frameworks
scriptSrc: ["'self'"],
imgSrc: ["'self'", "data:", "https:"], // Permitir imágenes desde cualquier HTTPS
connectSrc: ["'self'"],
fontSrc: ["'self'", "data:"],
objectSrc: ["'none'"], // No permitir object, embed, applet
mediaSrc: ["'self'"],
frameSrc: ["'none'"], // No permitir iframes
upgradeInsecureRequests: process.env.NODE_ENV === 'production' ? [] : null, // Solo en producción
},
},
hsts: {
maxAge: 31536000, // 1 año
includeSubDomains: true,
preload: true,
},
crossOriginEmbedderPolicy: false, // Deshabilitado para compatibilidad con algunas librerías
crossOriginResourcePolicy: {
policy: "cross-origin" // Permitir recursos cross-origin (necesario para assets)
},
crossOriginOpenerPolicy: {
policy: "same-origin-allow-popups" // Permitir popups del mismo origen
},
frameguard: {
action: 'deny' // No permitir iframes
},
noSniff: true, // Prevenir MIME type sniffing
xssFilter: true, // XSS filter (deprecado pero compatible)
referrerPolicy: {
policy: "strict-origin-when-cross-origin"
},
// Deshabilitar algunos headers problemáticos en desarrollo
...(process.env.NODE_ENV === 'development' && {
contentSecurityPolicy: false, // Deshabilitar CSP en desarrollo para facilitar debugging
}),
});
console.log('✅ Security headers configurados con Helmet.js');

View file

@ -0,0 +1,159 @@
/**
* Middleware de validación usando Zod
* Valida request body, query params y params según schemas
*/
import { Request, Response, NextFunction } from 'express';
import { ZodSchema, ZodError } from 'zod';
interface ValidationConfig {
body?: ZodSchema;
query?: ZodSchema;
params?: ZodSchema;
}
interface ValidationError {
path: string;
message: string;
}
/**
* Validar request body
*/
export function validateBody(schema: ZodSchema) {
return (req: Request, res: Response, next: NextFunction): void => {
try {
// Validar y transformar datos
const validated = schema.parse(req.body);
req.body = validated;
next();
} catch (error) {
if (error instanceof ZodError) {
// Formatear errores de Zod para respuesta
const errors: ValidationError[] = error.issues.map((err) => ({
path: err.path.join('.'),
message: err.message,
}));
res.status(400).json({
error: 'Error de validación',
details: errors,
});
return;
}
// Error inesperado
console.error('Error en validación:', error);
res.status(500).json({
error: 'Error interno de validación',
});
}
};
}
/**
* Validar query parameters
*/
export function validateQuery(schema: ZodSchema) {
return (req: Request, res: Response, next: NextFunction): void => {
try {
const validated = schema.parse(req.query);
req.query = validated as typeof req.query;
next();
} catch (error) {
if (error instanceof ZodError) {
const errors: ValidationError[] = error.issues.map((err) => ({
path: err.path.join('.'),
message: err.message,
}));
res.status(400).json({
error: 'Error de validación en query parameters',
details: errors,
});
return;
}
console.error('Error en validación de query:', error);
res.status(500).json({
error: 'Error interno de validación',
});
}
};
}
/**
* Validar route parameters
*/
export function validateParams(schema: ZodSchema) {
return (req: Request, res: Response, next: NextFunction): void => {
try {
const validated = schema.parse(req.params);
req.params = validated as typeof req.params;
next();
} catch (error) {
if (error instanceof ZodError) {
const errors: ValidationError[] = error.issues.map((err) => ({
path: err.path.join('.'),
message: err.message,
}));
res.status(400).json({
error: 'Error de validación en parámetros de ruta',
details: errors,
});
return;
}
console.error('Error en validación de params:', error);
res.status(500).json({
error: 'Error interno de validación',
});
}
};
}
/**
* Validar combinación de body, query y params
*/
export function validateRequest(validationConfig: ValidationConfig) {
return (req: Request, res: Response, next: NextFunction): void => {
try {
// Validar body si existe schema
if (validationConfig.body) {
req.body = validationConfig.body.parse(req.body);
}
// Validar query si existe schema
if (validationConfig.query) {
req.query = validationConfig.query.parse(req.query) as typeof req.query;
}
// Validar params si existe schema
if (validationConfig.params) {
req.params = validationConfig.params.parse(req.params) as typeof req.params;
}
next();
} catch (error) {
if (error instanceof ZodError) {
const errors: ValidationError[] = error.issues.map((err) => ({
path: err.path.join('.'),
message: err.message,
}));
res.status(400).json({
error: 'Error de validación',
details: errors,
});
return;
}
console.error('Error en validación:', error);
res.status(500).json({
error: 'Error interno de validación',
});
}
};
}

284
backend/src/models/Drug.ts Normal file
View file

@ -0,0 +1,284 @@
/**
* MODELO: Drug (Vademécum TES)
*
* Representa un fármaco del vademécum TES
* Basado en: docs/VADEMECUM_COMPLETO_TES.md
*/
export type DrugLine = 'first' | 'second';
export type DrugFrequency = 'high' | 'medium' | 'low';
export type ContentStatus = 'draft' | 'in_review' | 'approved' | 'published' | 'archived';
export interface Drug {
id?: string;
slug?: string;
generic_name: string;
trade_name?: string | null;
category: string;
line: DrugLine;
frequency: DrugFrequency;
presentation: string;
adult_dose: string;
pediatric_dose?: string | null;
routes: string[];
dilution?: string | null;
indications: string[];
contraindications: string[];
side_effects?: string | null;
antidote?: string | null;
notes: string[];
critical_points: string[];
source?: string | null;
status?: ContentStatus;
version?: string;
latest_version?: string;
metadata?: Record<string, any>;
created_by?: string;
updated_by?: string;
created_at?: Date;
updated_at?: Date;
published_by?: string;
published_at?: Date;
}
export interface DrugValidationResult {
valid: boolean;
errors: string[];
}
export interface DrugChange {
field: string;
old_value: any;
new_value: any;
}
export interface DrugComparison {
added: any[];
modified: DrugChange[];
deleted: any[];
fields_changed: string[];
change_type: 'major' | 'minor' | 'patch';
is_breaking: boolean;
}
export interface DrugSnapshot {
id?: string;
slug?: string;
generic_name: string;
trade_name?: string | null;
category: string;
line: DrugLine;
frequency: DrugFrequency;
presentation: string;
adult_dose: string;
pediatric_dose?: string | null;
routes: string[];
dilution?: string | null;
indications: string[];
contraindications: string[];
side_effects?: string | null;
antidote?: string | null;
notes: string[];
critical_points: string[];
source?: string | null;
version?: string;
metadata?: Record<string, any>;
}
/**
* Validar estructura de un fármaco
*/
export function validateDrug(drug: Partial<Drug>): DrugValidationResult {
const errors: string[] = [];
// Campos obligatorios
if (!drug.generic_name || drug.generic_name.trim() === '') {
errors.push('generic_name es obligatorio');
}
if (!drug.category || drug.category.trim() === '') {
errors.push('category es obligatorio');
}
if (!drug.line || !['first', 'second'].includes(drug.line)) {
errors.push('line debe ser "first" o "second"');
}
if (!drug.frequency || !['high', 'medium', 'low'].includes(drug.frequency)) {
errors.push('frequency debe ser "high", "medium" o "low"');
}
if (!drug.presentation || drug.presentation.trim() === '') {
errors.push('presentation es obligatorio');
}
if (!drug.adult_dose || drug.adult_dose.trim() === '') {
errors.push('adult_dose es obligatorio');
}
// Validación específica: pediatric_dose requerido si status = published
if (drug.status === 'published' && (!drug.pediatric_dose || drug.pediatric_dose.trim() === '')) {
errors.push('pediatric_dose es obligatorio cuando status = published');
}
// Validar arrays
if (!Array.isArray(drug.routes)) {
errors.push('routes debe ser un array');
}
if (!Array.isArray(drug.indications)) {
errors.push('indications debe ser un array');
}
if (!Array.isArray(drug.contraindications)) {
errors.push('contraindications debe ser un array');
}
if (!Array.isArray(drug.notes)) {
errors.push('notes debe ser un array');
}
if (!Array.isArray(drug.critical_points)) {
errors.push('critical_points debe ser un array');
}
// Validar versión semántica
if (drug.version && !/^\d+\.\d+\.\d+$/.test(drug.version)) {
errors.push('version debe seguir formato semántico (ej: 1.0.0)');
}
return {
valid: errors.length === 0,
errors
};
}
/**
* Normalizar fármaco antes de guardar
*/
export function normalizeDrug(drug: Partial<Drug>): Drug {
return {
...drug,
// Asegurar arrays
routes: Array.isArray(drug.routes) ? drug.routes : [],
indications: Array.isArray(drug.indications) ? drug.indications : [],
contraindications: Array.isArray(drug.contraindications) ? drug.contraindications : [],
notes: Array.isArray(drug.notes) ? drug.notes : [],
critical_points: Array.isArray(drug.critical_points) ? drug.critical_points : [],
// Normalizar strings
generic_name: drug.generic_name?.trim() || '',
trade_name: drug.trade_name?.trim() || null,
category: drug.category?.trim() || '',
presentation: drug.presentation?.trim() || '',
adult_dose: drug.adult_dose?.trim() || '',
pediatric_dose: drug.pediatric_dose?.trim() || null,
dilution: drug.dilution?.trim() || null,
side_effects: drug.side_effects?.trim() || null,
antidote: drug.antidote?.trim() || null,
source: drug.source?.trim() || null,
// Valores por defecto
status: drug.status || 'draft',
version: drug.version || '1.0.0',
latest_version: drug.latest_version || drug.version || '1.0.0',
metadata: drug.metadata || {},
// Campos requeridos (TypeScript)
line: drug.line || 'first',
frequency: drug.frequency || 'medium',
} as Drug;
}
/**
* Crear snapshot de fármaco para versionado
*/
export function createDrugSnapshot(drug: Partial<Drug>): DrugSnapshot {
return {
id: drug.id,
slug: drug.slug,
generic_name: drug.generic_name || '',
trade_name: drug.trade_name || null,
category: drug.category || '',
line: drug.line || 'first',
frequency: drug.frequency || 'medium',
presentation: drug.presentation || '',
adult_dose: drug.adult_dose || '',
pediatric_dose: drug.pediatric_dose || null,
routes: Array.isArray(drug.routes) ? drug.routes : [],
dilution: drug.dilution || null,
indications: Array.isArray(drug.indications) ? drug.indications : [],
contraindications: Array.isArray(drug.contraindications) ? drug.contraindications : [],
side_effects: drug.side_effects || null,
antidote: drug.antidote || null,
notes: Array.isArray(drug.notes) ? drug.notes : [],
critical_points: Array.isArray(drug.critical_points) ? drug.critical_points : [],
source: drug.source || null,
version: drug.version,
metadata: drug.metadata || {},
};
}
/**
* Comparar dos fármacos para detectar cambios
*/
export function compareDrugs(oldDrug: Partial<Drug>, newDrug: Partial<Drug>): DrugComparison {
const changes: Omit<DrugComparison, 'change_type' | 'is_breaking'> = {
added: [],
modified: [],
deleted: [],
fields_changed: []
};
// Comparar campos básicos
const fields: (keyof Drug)[] = [
'generic_name', 'trade_name', 'category', 'line', 'frequency',
'presentation', 'adult_dose', 'pediatric_dose', 'dilution',
'side_effects', 'antidote', 'source'
];
fields.forEach(field => {
const oldValue = oldDrug[field];
const newValue = newDrug[field];
if (oldValue !== newValue) {
changes.fields_changed.push(field);
changes.modified.push({
field,
old_value: oldValue,
new_value: newValue
});
}
});
// Comparar arrays
const arrayFields: (keyof Drug)[] = ['routes', 'indications', 'contraindications', 'notes', 'critical_points'];
arrayFields.forEach(field => {
const oldArray = Array.isArray(oldDrug[field]) ? oldDrug[field] as any[] : [];
const newArray = Array.isArray(newDrug[field]) ? newDrug[field] as any[] : [];
if (JSON.stringify(oldArray.sort()) !== JSON.stringify(newArray.sort())) {
changes.fields_changed.push(field);
changes.modified.push({
field,
old_value: oldArray,
new_value: newArray
});
}
});
// Determinar tipo de cambio
let changeType: 'major' | 'minor' | 'patch' = 'patch';
if (changes.fields_changed.some(f => ['generic_name', 'category', 'adult_dose'].includes(f))) {
changeType = 'major';
} else if (changes.fields_changed.length > 0) {
changeType = 'minor';
}
return {
...changes,
change_type: changeType,
is_breaking: changeType === 'major'
};
}

166
backend/src/routes/auth.ts Normal file
View file

@ -0,0 +1,166 @@
/**
* Rutas de autenticación
*/
import express, { Request, Response } from 'express';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { query } from '../../config/database.js';
import { validateSecurityConfig } from '../config/security.js';
import { authLimiter } from '../middleware/rate-limit.js';
import { validateBody } from '../middleware/validate.js';
import { loginSchema } from '../validators/auth.js'; // TypeScript
const router = express.Router();
// ✅ VALIDACIÓN DE JWT_SECRET (sin fallback débil)
const securityConfig = validateSecurityConfig();
const JWT_SECRET: string = securityConfig.JWT_SECRET;
const JWT_EXPIRES_IN: string = securityConfig.JWT_EXPIRES_IN;
interface LoginRequest {
email: string;
password: string;
}
interface LoginResponse {
token: string;
user: {
id: string;
email: string;
username: string;
role: string;
};
expiresIn: number;
}
interface UserRow {
id: string;
email: string;
username: string;
password_hash: string;
role: string;
is_active: boolean;
created_at?: Date;
last_login?: Date;
}
interface JwtPayload {
userId: string;
email: string;
role: string;
}
/**
* POST /api/auth/login
* Login de usuario
* Rate limiting: 5 intentos por 15 minutos por IP
* Validación de inputs con Zod
*/
router.post('/login', authLimiter, validateBody(loginSchema), async (req: Request<{}, LoginResponse, LoginRequest>, res: Response<LoginResponse | { error: string }>) => {
try {
// ✅ Datos ya validados por Zod middleware
const { email, password } = req.body;
// Buscar usuario
const result = await query(
`SELECT id, email, username, password_hash, role, is_active
FROM tes_content.users
WHERE email = $1`,
[email.toLowerCase()]
);
if (result.rows.length === 0) {
res.status(401).json({ error: 'Credenciales inválidas' });
return;
}
const user = result.rows[0] as UserRow;
if (!user.is_active) {
res.status(401).json({ error: 'Usuario inactivo' });
return;
}
// Verificar contraseña
const validPassword = await bcrypt.compare(password, user.password_hash);
if (!validPassword) {
res.status(401).json({ error: 'Credenciales inválidas' });
return;
}
// Actualizar último login
await query(
`UPDATE tes_content.users
SET last_login = NOW()
WHERE id = $1`,
[user.id]
);
// Generar token
// JWT_SECRET está validado al startup, nunca será vacío
if (!JWT_SECRET || JWT_SECRET.length === 0) {
throw new Error('JWT_SECRET no configurado');
}
const payload: JwtPayload = { userId: user.id, email: user.email, role: user.role };
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN } as jwt.SignOptions);
// Retornar respuesta
res.json({
token,
user: {
id: user.id,
email: user.email,
username: user.username,
role: user.role,
},
expiresIn: 24 * 60 * 60, // 24 horas en segundos
});
} catch (error) {
console.error('Error en login:', error);
res.status(500).json({ error: 'Error interno del servidor' });
}
});
/**
* GET /api/auth/me
* Obtener información del usuario actual
*/
router.get('/me', async (req: Request, res: Response) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
res.status(401).json({ error: 'Token no proporcionado' });
return;
}
const token = authHeader.substring(7);
// ✅ JWT_SECRET validado al startup, sin fallback débil
const decoded = jwt.verify(token, JWT_SECRET) as JwtPayload;
const result = await query(
`SELECT id, email, username, role, is_active, created_at, last_login
FROM tes_content.users
WHERE id = $1`,
[decoded.userId]
);
if (result.rows.length === 0) {
res.status(401).json({ error: 'Usuario no encontrado' });
return;
}
res.json({ user: result.rows[0] as UserRow });
} catch (error) {
if (error instanceof Error) {
if (error.name === 'JsonWebTokenError') {
res.status(401).json({ error: 'Token inválido' });
return;
}
}
res.status(500).json({ error: 'Error interno del servidor' });
}
});
export default router;

View file

@ -0,0 +1,124 @@
/**
* Rutas ADMIN para Content Pack
*
* Endpoints autenticados para generar y gestionar Content Packs
*/
import express, { Response } from 'express';
import { writeFile, mkdir, readdir, stat, readFile } from 'fs/promises';
import { join } from 'path';
import { authenticate, requirePermission, AuthRequest } from '../middleware/auth.js';
import packGenerator from '../services/pack-generator.js'; // TypeScript
const router = express.Router();
router.use(authenticate);
const packsDir = process.env.PACKS_DIR || join(process.cwd(), 'storage', 'packs');
/**
* POST /api/admin/content-pack/generate
* Genera un nuevo Content Pack y lo guarda en disco
*/
router.post('/generate', requirePermission('content:write'), async (req: AuthRequest, res: Response) => {
try {
const { version, includeDraft = false, notes = '' } = req.body;
if (!version) {
res.status(400).json({ error: 'Versión requerida' });
return;
}
// Generar pack
console.log(`Generando Content Pack v${version}...`);
const pack = await packGenerator.generatePack(version, {
includeDraft,
notes
});
// Asegurar que existe el directorio
await mkdir(packsDir, { recursive: true });
// Guardar pack versionado
const versionedPath = join(packsDir, `pack-v${version}.json`);
await writeFile(versionedPath, JSON.stringify(pack, null, 2), 'utf-8');
// Crear/actualizar symlink a latest
const latestPath = join(packsDir, 'pack-latest.json');
try {
await writeFile(latestPath, JSON.stringify(pack, null, 2), 'utf-8');
} catch (error) {
console.warn('No se pudo crear symlink, guardando directamente:', error);
}
console.log(`✅ Content Pack v${version} generado: ${pack.metadata.total_items} items`);
res.json({
success: true,
pack: {
version: pack.metadata.version,
total_items: pack.metadata.total_items,
total_resources: pack.metadata.total_resources,
hash: pack.metadata.hash,
generated_at: pack.metadata.generated_at,
},
paths: {
versioned: versionedPath,
latest: latestPath,
}
});
return;
} catch (error) {
console.error('Error generando Content Pack:', error);
const errorMessage = error instanceof Error ? error.message : 'Error desconocido';
res.status(500).json({
error: 'Error generando Content Pack',
message: errorMessage
});
}
});
/**
* GET /api/admin/content-pack/list
* Lista todos los Content Packs generados
*/
router.get('/list', requirePermission('content:read'), async (_req: AuthRequest, res: Response) => {
try {
const files = await readdir(packsDir);
const packs: any[] = [];
for (const file of files) {
if (file.startsWith('pack-') && file.endsWith('.json')) {
const filePath = join(packsDir, file);
const stats = await stat(filePath);
const content = await readFile(filePath, 'utf-8');
const pack = JSON.parse(content);
packs.push({
filename: file,
version: pack.metadata.version,
total_items: pack.metadata.total_items,
generated_at: pack.metadata.generated_at,
hash: pack.metadata.hash,
size: stats.size,
is_latest: file === 'pack-latest.json',
});
}
}
packs.sort((a, b) => new Date(b.generated_at).getTime() - new Date(a.generated_at).getTime());
res.json({ packs });
return;
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
res.json({ packs: [] });
return;
}
console.error('Error listando Content Packs:', error);
res.status(500).json({ error: 'Error interno del servidor' });
}
});
export default router;

View file

@ -0,0 +1,115 @@
/**
* Rutas para Content Pack
*
* Endpoints públicos (sin autenticación) para servir el Content Pack
*/
import express, { Request, Response } from 'express';
import { readFile } from 'fs/promises';
import { join } from 'path';
import packGenerator from '../services/pack-generator.js'; // TypeScript
import cache from '../services/cache.js';
const router = express.Router();
const packsDir = process.env.PACKS_DIR || join(process.cwd(), 'storage', 'packs');
// Claves de caché
const CACHE_KEY_LATEST = 'content-pack:latest';
const CACHE_KEY_VERSION = (version: string) => `content-pack:v${version}`;
const CACHE_TTL_CONTENT_PACK = 3600; // 1 hora
/**
* GET /api/content-pack/latest.json
* Obtiene el Content Pack más reciente
*/
router.get('/latest.json', async (req: Request, res: Response) => {
try {
// Intentar obtener de caché Redis primero
let pack = await cache.get(CACHE_KEY_LATEST);
if (!pack) {
// Si no está en caché, intentar leer desde archivo
try {
const packPath = join(packsDir, 'pack-latest.json');
const packContent = await readFile(packPath, 'utf-8');
pack = JSON.parse(packContent);
} catch (error) {
// Si no existe archivo, generarlo on-the-fly
console.log('Generando Content Pack on-the-fly...');
pack = await packGenerator.generatePack('1.0.0', { includeDraft: false });
}
// Almacenar en caché Redis
await cache.set(CACHE_KEY_LATEST, pack, CACHE_TTL_CONTENT_PACK);
}
// Headers para cache HTTP
const etag = pack.metadata.hash;
const ifNoneMatch = req.headers['if-none-match'];
if (ifNoneMatch === etag) {
res.status(304).end(); // Not Modified
return;
}
res.set({
'Content-Type': 'application/json',
'ETag': etag,
'Cache-Control': 'public, max-age=3600', // Cache 1 hora
});
res.json(pack);
return;
} catch (error) {
console.error('Error sirviendo Content Pack:', error);
const errorMessage = error instanceof Error ? error.message : 'Error desconocido';
res.status(500).json({
error: 'Error generando Content Pack',
message: errorMessage
});
}
});
/**
* GET /api/content-pack/:version.json
* Obtiene un Content Pack específico por versión
*/
router.get('/:version.json', async (req: Request, res: Response) => {
try {
const { version } = req.params;
const cacheKey = CACHE_KEY_VERSION(version);
// Intentar obtener de caché Redis primero
let pack = await cache.get(cacheKey);
if (!pack) {
// Si no está en caché, leer desde archivo
const packPath = join(packsDir, `pack-v${version}.json`);
const packContent = await readFile(packPath, 'utf-8');
pack = JSON.parse(packContent);
// Almacenar en caché Redis
await cache.set(cacheKey, pack, CACHE_TTL_CONTENT_PACK);
}
res.set({
'Content-Type': 'application/json',
'ETag': pack.metadata.hash,
'Cache-Control': 'public, max-age=3600',
});
res.json(pack);
return;
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
res.status(404).json({ error: 'Versión no encontrada' });
return;
}
console.error('Error sirviendo Content Pack:', error);
res.status(500).json({ error: 'Error interno del servidor' });
}
});
export default router;

View file

@ -0,0 +1,260 @@
/**
* Rutas para asociar recursos multimedia a contenido
*/
import express, { Response } from 'express';
import { authenticate, requirePermission, AuthRequest } from '../middleware/auth.js';
import { query } from '../../config/database.js';
const router = express.Router();
router.use(authenticate);
/**
* POST /api/content/:contentId/resources
* Asociar un recurso a un contenido
*/
router.post('/:contentId/resources', requirePermission('content:write'), async (req: AuthRequest, res: Response) => {
try {
const { contentId } = req.params;
const { resource_id, section, position, placement, caption, is_critical, priority } = req.body;
if (!resource_id) {
res.status(400).json({ error: 'resource_id es requerido' });
return;
}
// Verificar que el contenido existe
const contentCheck = await query(
`SELECT id FROM tes_content.content_items WHERE id = $1`,
[contentId]
);
if (contentCheck.rows.length === 0) {
res.status(404).json({ error: 'Contenido no encontrado' });
return;
}
// Verificar que el recurso existe
const resourceCheck = await query(
`SELECT id FROM tes_content.media_resources WHERE id = $1`,
[resource_id]
);
if (resourceCheck.rows.length === 0) {
res.status(404).json({ error: 'Recurso no encontrado' });
return;
}
// Obtener siguiente posición si no se especifica
let finalPosition = position;
if (!finalPosition) {
const maxPosResult = await query(
`SELECT COALESCE(MAX(position), 0) as max_pos
FROM tes_content.content_resource_associations
WHERE content_item_id = $1 AND section = $2`,
[contentId, section || 'general']
);
finalPosition = parseInt(maxPosResult.rows[0].max_pos) + 1;
}
// Insertar asociación
const result = await query(`
INSERT INTO tes_content.content_resource_associations (
content_item_id, media_resource_id, section, position,
placement, caption, is_critical, priority
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8::tes_content.priority
)
RETURNING id, content_item_id, media_resource_id, section, position,
placement, caption, is_critical, priority
`, [
contentId,
resource_id,
section || 'general',
finalPosition,
placement || 'inline',
caption || null,
is_critical || false,
priority || 'media',
]);
res.json({
success: true,
association: result.rows[0],
});
return;
} catch (error) {
console.error('Error asociando recurso:', error);
const errorMessage = error instanceof Error ? error.message : 'Error desconocido';
res.status(500).json({
error: 'Error asociando recurso',
message: errorMessage
});
}
});
/**
* GET /api/content/:contentId/resources
* Obtener recursos asociados a un contenido
*/
router.get('/:contentId/resources', requirePermission('content:read'), async (req: AuthRequest, res: Response) => {
try {
const { contentId } = req.params;
const { section } = req.query;
let whereClause = 'WHERE cra.content_item_id = $1';
let params = [contentId];
let paramIndex = 2;
if (section) {
const sectionStr = typeof section === 'string' ? section : String(section);
whereClause += ` AND cra.section = $${paramIndex++}`;
params.push(sectionStr);
}
const result = await query(`
SELECT
cra.id,
cra.content_item_id,
cra.media_resource_id,
cra.section,
cra.position,
cra.placement,
cra.caption,
cra.is_critical,
cra.priority,
mr.type,
mr.file_url,
mr.title,
mr.description,
mr.alt_text,
mr.thumbnail_url
FROM tes_content.content_resource_associations cra
INNER JOIN tes_content.media_resources mr ON cra.media_resource_id = mr.id
${whereClause}
ORDER BY cra.section, cra.position
`, params);
res.json({
associations: result.rows,
total: result.rows.length,
});
} catch (error) {
console.error('Error obteniendo recursos asociados:', error);
res.status(500).json({ error: 'Error interno del servidor' });
}
});
/**
* DELETE /api/content/:contentId/resources/:associationId
* Eliminar asociación
*/
router.delete('/:contentId/resources/:associationId', requirePermission('content:write'), async (req: AuthRequest, res: Response) => {
try {
const { contentId, associationId } = req.params;
// Verificar que la asociación pertenece al contenido
const checkResult = await query(
`SELECT id FROM tes_content.content_resource_associations
WHERE id = $1 AND content_item_id = $2`,
[associationId, contentId]
);
if (checkResult.rows.length === 0) {
res.status(404).json({ error: 'Asociación no encontrada' });
return;
}
// Eliminar asociación
await query(
`DELETE FROM tes_content.content_resource_associations WHERE id = $1`,
[associationId]
);
res.json({ success: true });
return;
} catch (error) {
console.error('Error eliminando asociación:', error);
res.status(500).json({ error: 'Error interno del servidor' });
}
});
/**
* PUT /api/content/:contentId/resources/:associationId
* Actualizar asociación
*/
router.put('/:contentId/resources/:associationId', requirePermission('content:write'), async (req: AuthRequest, res: Response) => {
try {
const { contentId, associationId } = req.params;
const { section, position, placement, caption, is_critical, priority } = req.body;
// Verificar que la asociación pertenece al contenido
const checkResult = await query(
`SELECT id FROM tes_content.content_resource_associations
WHERE id = $1 AND content_item_id = $2`,
[associationId, contentId]
);
if (checkResult.rows.length === 0) {
res.status(404).json({ error: 'Asociación no encontrada' });
return;
}
// Construir UPDATE dinámico
const updates: string[] = [];
const params: any[] = [];
let paramIndex = 1;
if (section !== undefined) {
updates.push(`section = $${paramIndex++}`);
params.push(section);
}
if (position !== undefined) {
updates.push(`position = $${paramIndex++}`);
params.push(position);
}
if (placement !== undefined) {
updates.push(`placement = $${paramIndex++}`);
params.push(placement);
}
if (caption !== undefined) {
updates.push(`caption = $${paramIndex++}`);
params.push(caption);
}
if (is_critical !== undefined) {
updates.push(`is_critical = $${paramIndex++}`);
params.push(is_critical);
}
if (priority !== undefined) {
updates.push(`priority = $${paramIndex++}::tes_content.priority`);
params.push(priority);
}
if (updates.length === 0) {
res.status(400).json({ error: 'No hay campos para actualizar' });
return;
}
params.push(associationId);
const result = await query(
`UPDATE tes_content.content_resource_associations
SET ${updates.join(', ')}, updated_at = NOW()
WHERE id = $${paramIndex}
RETURNING *`,
params
);
res.json({
success: true,
association: result.rows[0],
});
return;
} catch (error) {
console.error('Error actualizando asociación:', error);
res.status(500).json({ error: 'Error interno del servidor' });
}
});
export default router;

View file

@ -0,0 +1,587 @@
/**
* Rutas de gestión de contenido
*/
import express, { Request, Response } from 'express';
import { query } from '../../config/database.js';
import { authenticate, requirePermission, AuthRequest } from '../middleware/auth.js';
import { randomUUID as uuidv4 } from 'crypto';
import { validateBody, validateQuery, validateParams } from '../middleware/validate.js';
import { createContentSchema, updateContentSchema, listContentQuerySchema, contentIdSchema } from '../validators/content.js'; // TypeScript
import { contentWriteLimiter } from '../middleware/rate-limit.js';
import cache from '../services/cache.js';
import logger, { logError } from '../utils/logger.js';
const router = express.Router();
/**
* Invalidar caché relacionado con contenido
* Se llama cuando se crea, actualiza o publica contenido
*/
async function invalidateContentCache(): Promise<void> {
// Invalidar content packs
await cache.invalidatePattern('content-pack:*');
// Invalidar stats
await cache.invalidatePattern('stats:*');
}
// Todas las rutas requieren autenticación
router.use(authenticate);
/**
* GET /api/content
* Listar contenido con filtros
* Validación de query parameters con Zod
*/
router.get('/', requirePermission('content:read'), validateQuery(listContentQuerySchema), async (req: AuthRequest, res: Response) => {
try {
// ✅ Query parameters ya validados por Zod middleware
const {
type,
level,
status,
category,
page = 1,
pageSize = 20,
search,
} = req.query;
let whereConditions = [];
let params = [];
let paramIndex = 1;
if (type) {
whereConditions.push(`type = $${paramIndex++}`);
params.push(type);
}
if (level) {
whereConditions.push(`level = $${paramIndex++}`);
params.push(level);
}
if (status) {
whereConditions.push(`status = $${paramIndex++}`);
params.push(status);
}
if (category) {
whereConditions.push(`category = $${paramIndex++}`);
params.push(category);
}
if (search) {
whereConditions.push(`(title ILIKE $${paramIndex} OR short_title ILIKE $${paramIndex})`);
params.push(`%${search}%`);
paramIndex++;
}
const whereClause = whereConditions.length > 0
? `WHERE ${whereConditions.join(' AND ')}`
: '';
const pageNum = typeof page === 'string' ? parseInt(page) : (typeof page === 'number' ? page : 1);
const pageSizeNum = typeof pageSize === 'string' ? parseInt(pageSize) : (typeof pageSize === 'number' ? pageSize : 20);
const offset = (pageNum - 1) * pageSizeNum;
// Contar total
const countResult = await query(
`SELECT COUNT(*) as total
FROM tes_content.content_items
${whereClause}`,
params
);
const total = parseInt(countResult.rows[0].total);
// Obtener items
params.push(pageSizeNum, offset);
const itemsResult = await query(
`SELECT id, type, level, title, short_title, description, status,
version, latest_version, created_at, updated_at, created_by, updated_by
FROM tes_content.content_items
${whereClause}
ORDER BY updated_at DESC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
params
);
// Mapear campos de snake_case a camelCase para el frontend
const items = itemsResult.rows.map(item => ({
id: item.id,
type: item.type,
level: item.level,
title: item.title,
shortTitle: item.short_title,
description: item.description,
status: item.status,
version: item.version,
latestVersion: item.latest_version,
createdAt: item.created_at,
updatedAt: item.updated_at,
createdBy: item.created_by,
updatedBy: item.updated_by,
}));
res.json({
items,
total,
page: pageNum,
pageSize: pageSizeNum,
});
} catch (error) {
logError(error as Error, { endpoint: '/api/content', method: 'GET', action: 'list' });
res.status(500).json({ error: 'Error interno del servidor' });
}
});
/**
* GET /api/content/:id
* Obtener contenido por ID
* Validación de parámetro ID con Zod
*/
router.get('/:id', requirePermission('content:read'), validateParams(contentIdSchema), async (req: AuthRequest, res: Response) => {
try {
// ✅ ID ya validado por Zod middleware
const { id } = req.params;
const result = await query(
`SELECT ci.*, cv.content, cv.change_summary
FROM tes_content.content_items ci
LEFT JOIN tes_content.content_versions cv
ON ci.id = cv.content_item_id AND ci.latest_version = cv.version
WHERE ci.id = $1`,
[id]
);
if (result.rows.length === 0) {
res.status(404).json({ error: 'Contenido no encontrado' });
return;
}
const item = result.rows[0];
// Construir objeto de respuesta
const content = item.json_content || {};
if (item.markdown_content) {
content.markdown = item.markdown_content;
}
res.json({
...item,
content,
});
} catch (error) {
logError(error as Error, { endpoint: '/api/content', method: 'GET' });
res.status(500).json({ error: 'Error interno del servidor' });
}
});
/**
* POST /api/content
* Crear nuevo contenido
* Rate limiting: 20 creaciones por hora por IP
* Validación de inputs con Zod
*/
router.post('/', requirePermission('content:write'), contentWriteLimiter, validateBody(createContentSchema), async (req: AuthRequest, res: Response) => {
try {
if (!req.user) {
res.status(401).json({ error: 'No autenticado' });
return;
}
// ✅ Datos ya validados por Zod middleware
const {
id,
type,
level,
title,
shortTitle,
description,
content,
category,
subcategory,
priority,
ageGroup,
status = 'draft',
} = req.body;
// Verificar que el ID no existe
const existing = await query(
`SELECT id FROM tes_content.content_items WHERE id = $1`,
[id]
);
if (existing.rows.length > 0) {
res.status(409).json({ error: 'ID ya existe' });
return;
}
// Insertar item
await query(
`INSERT INTO tes_content.content_items
(id, type, level, title, short_title, description, category, subcategory,
priority, age_group, status, version, latest_version, created_by, updated_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 1, 1, $12, $12)`,
[id, type, level, title, shortTitle, description, category, subcategory,
priority, ageGroup, status, req.user.id]
);
// Insertar versión inicial
const versionId = uuidv4();
await query(
`INSERT INTO tes_content.content_versions
(version_id, content_item_id, version_number, json_content, markdown_content, created_by)
VALUES ($1, $2, 1, $3, $4, $5)`,
[versionId, id, 1, JSON.stringify(content), content.markdown || null, req.user.id]
);
// Actualizar current_version_id
await query(
`UPDATE tes_content.content_items
SET current_version_id = $1
WHERE id = $2`,
[versionId, id]
);
// Log de auditoría
await query(
`INSERT INTO tes_content.content_change_log
(content_item_id, version_id, user_id, action, details)
VALUES ($1, $2, $3, 'create', $4)`,
[id, versionId, req.user.id, JSON.stringify({ title, type, level })]
);
// Invalidar caché relacionado con contenido
await invalidateContentCache();
logger.info('Contenido creado', { contentId: id, userId: req.user.id, type });
res.status(201).json({
id,
message: 'Contenido creado exitosamente',
});
} catch (error) {
logError(error as Error, { endpoint: '/api/content', method: 'POST', userId: req.user?.id });
res.status(500).json({ error: 'Error interno del servidor' });
}
});
/**
* PUT /api/content/:id
* Actualizar contenido
* Rate limiting: 20 actualizaciones por hora por IP
* Validación de inputs con Zod
*/
router.put('/:id', requirePermission('content:write'), contentWriteLimiter, validateParams(contentIdSchema), validateBody(updateContentSchema), async (req: AuthRequest, res: Response) => {
try {
if (!req.user) {
res.status(401).json({ error: 'No autenticado' });
return;
}
// ✅ ID y body ya validados por Zod middleware
const { id } = req.params;
const {
title,
shortTitle,
description,
content,
category,
subcategory,
priority,
ageGroup,
status,
changeSummary,
} = req.body;
// Obtener item actual
const currentResult = await query(
`SELECT latest_version, status FROM tes_content.content_items WHERE id = $1`,
[id]
);
if (currentResult.rows.length === 0) {
res.status(404).json({ error: 'Contenido no encontrado' });
return;
}
const current = currentResult.rows[0];
const newVersion = current.latest_version + 1;
// Actualizar item
const updateFields = [];
const updateParams = [];
let paramIndex = 1;
if (title !== undefined) {
updateFields.push(`title = $${paramIndex++}`);
updateParams.push(title);
}
if (shortTitle !== undefined) {
updateFields.push(`short_title = $${paramIndex++}`);
updateParams.push(shortTitle);
}
if (description !== undefined) {
updateFields.push(`description = $${paramIndex++}`);
updateParams.push(description);
}
if (category !== undefined) {
updateFields.push(`category = $${paramIndex++}`);
updateParams.push(category);
}
if (subcategory !== undefined) {
updateFields.push(`subcategory = $${paramIndex++}`);
updateParams.push(subcategory);
}
if (priority !== undefined) {
updateFields.push(`priority = $${paramIndex++}`);
updateParams.push(priority);
}
if (ageGroup !== undefined) {
updateFields.push(`age_group = $${paramIndex++}`);
updateParams.push(ageGroup);
}
if (status !== undefined) {
updateFields.push(`status = $${paramIndex++}`);
updateParams.push(status);
}
updateFields.push(`latest_version = $${paramIndex++}`);
updateParams.push(newVersion);
updateFields.push(`updated_by = $${paramIndex++}`);
updateParams.push(req.user.id);
updateParams.push(id);
await query(
`UPDATE tes_content.content_items
SET ${updateFields.join(', ')}, updated_at = NOW()
WHERE id = $${paramIndex}`,
updateParams
);
// Crear nueva versión
if (content) {
const versionId = uuidv4();
await query(
`INSERT INTO tes_content.content_versions
(version_id, content_item_id, version_number, json_content, markdown_content,
change_summary, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
versionId,
id,
newVersion,
JSON.stringify(content),
content.markdown || null,
changeSummary || `Actualización a versión ${newVersion}`,
req.user.id,
]
);
// Actualizar current_version_id
await query(
`UPDATE tes_content.content_items
SET current_version_id = $1
WHERE id = $2`,
[versionId, id]
);
// Log de auditoría
await query(
`INSERT INTO tes_content.content_change_log
(content_item_id, version_id, user_id, action, details)
VALUES ($1, $2, $3, 'update', $4)`,
[
id,
versionId,
req.user.id,
JSON.stringify({ version: newVersion, changeSummary }),
]
);
}
// Invalidar caché relacionado con contenido
await invalidateContentCache();
logger.info('Contenido actualizado', { contentId: id, version: newVersion, userId: req.user.id });
res.json({
id,
version: newVersion,
message: 'Contenido actualizado exitosamente',
});
return;
} catch (error) {
logError(error as Error, { endpoint: '/api/content/:id', method: 'PUT', userId: req.user?.id });
res.status(500).json({ error: 'Error interno del servidor' });
}
});
/**
* GET /api/content/:id/versions
* Obtener historial de versiones
*/
router.get('/:id/versions', requirePermission('content:read'), async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
const result = await query(
`SELECT cv.version_id, cv.version_number, cv.change_summary,
cv.created_by, cv.created_at, cv.validated_by, cv.validated_at,
u.username as created_by_username
FROM tes_content.content_versions cv
LEFT JOIN tes_content.users u ON cv.created_by = u.id
WHERE cv.content_item_id = $1
ORDER BY cv.version_number DESC`,
[id]
);
res.json({ versions: result.rows });
} catch (error) {
logError(error as Error, { endpoint: '/api/content/:id/versions', method: 'GET' });
res.status(500).json({ error: 'Error interno del servidor' });
}
});
/**
* POST /api/content/:id/validate
* Validar contenido
*/
router.post('/:id/validate', requirePermission('content:validate'), async (req: AuthRequest, res: Response) => {
try {
if (!req.user) {
res.status(401).json({ error: 'No autenticado' });
return;
}
const { id } = req.params;
const { approved } = req.body;
const result = await query(
`SELECT latest_version, current_version_id
FROM tes_content.content_items
WHERE id = $1`,
[id]
);
if (result.rows.length === 0) {
res.status(404).json({ error: 'Contenido no encontrado' });
return;
}
const item = result.rows[0];
const newStatus = approved ? 'approved' : 'in_review';
// Actualizar estado
await query(
`UPDATE tes_content.content_items
SET status = $1, validated_by = $2, validated_at = NOW()
WHERE id = $3`,
[newStatus, req.user.id, id]
);
// Actualizar versión
if (item.current_version_id) {
await query(
`UPDATE tes_content.content_versions
SET validated_by = $1, validated_at = NOW()
WHERE version_id = $2`,
[req.user.id, item.current_version_id]
);
}
// Log de auditoría
await query(
`INSERT INTO tes_content.content_change_log
(content_item_id, user_id, action, details)
VALUES ($1, $2, $3, $4)`,
[id, req.user.id, approved ? 'approve' : 'validate', JSON.stringify({ approved })]
);
res.json({
id,
status: newStatus,
message: approved ? 'Contenido aprobado' : 'Contenido marcado para revisión',
});
} catch (error) {
logError(error as Error, { endpoint: '/api/content/:id/validate', method: 'POST', userId: req.user?.id });
res.status(500).json({ error: 'Error interno del servidor' });
}
});
/**
* GET /api/content/pack/latest
* Obtener último content pack publicado
*/
router.get('/pack/latest', async (_req: Request, res: Response) => {
try {
// Obtener todos los items publicados
const result = await query(
`SELECT ci.*, cv.json_content, cv.markdown_content
FROM tes_content.content_items ci
LEFT JOIN tes_content.content_versions cv
ON ci.id = cv.content_item_id AND ci.latest_version = cv.version_number
WHERE ci.status = 'published'
ORDER BY ci.updated_at DESC`
);
// Organizar por tipo
const pack: {
version: string;
timestamp: string;
hash: string;
protocols: any[];
guides: any[];
manuals: any[];
drugs: any[];
checklists: any[];
} = {
version: '1.0.0',
timestamp: new Date().toISOString(),
hash: '', // TODO: Calcular hash
protocols: [],
guides: [],
manuals: [],
drugs: [],
checklists: [],
};
for (const item of result.rows) {
const content = item.json_content || {};
if (item.markdown_content) {
content.markdown = item.markdown_content;
}
const itemData = {
...item,
content,
};
switch (item.type) {
case 'protocol':
pack.protocols.push(itemData);
break;
case 'guide':
pack.guides.push(itemData);
break;
case 'manual':
pack.manuals.push(itemData);
break;
case 'drug':
pack.drugs.push(itemData as any);
break;
case 'checklist':
pack.checklists.push(itemData as any);
break;
}
}
res.json(pack);
} catch (error) {
logError(error as Error, { endpoint: '/api/content/:id/pack', method: 'GET' });
res.status(500).json({ error: 'Error interno del servidor' });
}
});
export default router;

638
backend/src/routes/drugs.ts Normal file
View file

@ -0,0 +1,638 @@
/**
* RUTAS API: Drugs (Vademécum TES)
*
* Endpoints REST para gestión de fármacos del vademécum
* Basado en: docs/VADEMECUM_COMPLETO_TES.md
*/
import express, { Request, Response } from 'express';
import { query } from '../../config/database.js';
import { authenticate, requirePermission, AuthRequest } from '../middleware/auth.js';
import { validateDrug, normalizeDrug, createDrugSnapshot, compareDrugs } from '../models/Drug.js'; // TypeScript
import { randomUUID as uuidv4 } from 'crypto';
import { validateQuery } from '../middleware/validate.js';
import { listDrugsQuerySchema } from '../validators/drugs.js'; // TypeScript
// import { contentWriteLimiter } from '../middleware/rate-limit.js'; // No usado en esta ruta
const router = express.Router();
/**
* GET /api/drugs
* Lista fármacos con filtros opcionales
* Validación de query parameters con Zod
*/
router.get('/', validateQuery(listDrugsQuerySchema), async (req: Request, res: Response) => {
try {
// ✅ Query parameters ya validados por Zod middleware
const {
category,
line,
frequency,
status,
search,
page = '1',
limit = '50'
} = req.query;
const pageNum = typeof page === 'string' ? parseInt(page) : 1;
const limitNum = typeof limit === 'string' ? parseInt(limit) : 50;
let whereClause = 'WHERE 1=1';
const params = [];
let paramIndex = 1;
// Filtros
if (category) {
whereClause += ` AND category = $${paramIndex}`;
params.push(category);
paramIndex++;
}
if (line) {
whereClause += ` AND line = $${paramIndex}::tes_content.drug_line`;
params.push(line);
paramIndex++;
}
if (frequency) {
whereClause += ` AND frequency = $${paramIndex}::tes_content.drug_frequency`;
params.push(frequency);
paramIndex++;
}
if (status) {
whereClause += ` AND status = $${paramIndex}::tes_content.content_status`;
params.push(status);
paramIndex++;
}
// Búsqueda por texto
if (search) {
whereClause += ` AND (
generic_name ILIKE $${paramIndex} OR
trade_name ILIKE $${paramIndex} OR
category ILIKE $${paramIndex}
)`;
params.push(`%${search}%`);
paramIndex++;
}
// Contar total
const countResult = await query(
`SELECT COUNT(*) as total FROM tes_content.drugs ${whereClause}`,
params
);
const total = parseInt(countResult.rows[0].total);
// Obtener fármacos
const offset = (pageNum - 1) * limitNum;
const drugsResult = await query(
`SELECT
id, slug, generic_name, trade_name, category, line, frequency,
presentation, adult_dose, pediatric_dose, routes, dilution,
indications, contraindications, side_effects, antidote,
notes, critical_points, source, status, version, latest_version,
created_at, updated_at, published_at
FROM tes_content.drugs
${whereClause}
ORDER BY
CASE line WHEN 'first' THEN 1 ELSE 2 END,
CASE frequency WHEN 'high' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END,
generic_name ASC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
[...params, limitNum, offset]
);
res.json({
drugs: drugsResult.rows,
pagination: {
page: pageNum,
limit: limitNum,
total,
totalPages: Math.ceil(total / limitNum)
}
});
return;
} catch (error) {
console.error('Error obteniendo fármacos:', error);
res.status(500).json({ error: 'Error obteniendo fármacos' });
}
});
/**
* GET /api/drugs/:id
* Obtiene un fármaco por ID o slug
*/
router.get('/:id', async (req: Request, res: Response) => {
try {
const { id } = req.params;
// Intentar buscar por UUID o slug
const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id);
const whereClause = isUUID ? 'id = $1' : 'slug = $1';
const result = await query(
`SELECT
id, slug, generic_name, trade_name, category, line, frequency,
presentation, adult_dose, pediatric_dose, routes, dilution,
indications, contraindications, side_effects, antidote,
notes, critical_points, source, status, version, latest_version,
created_by, created_at, updated_by, updated_at,
published_by, published_at, metadata
FROM tes_content.drugs
WHERE ${whereClause}`,
[id]
);
if (result.rows.length === 0) {
res.status(404).json({ error: 'Fármaco no encontrado' });
return;
}
res.json(result.rows[0]);
return;
} catch (error) {
console.error('Error obteniendo fármaco:', error);
res.status(500).json({ error: 'Error obteniendo fármaco' });
}
});
/**
* POST /api/drugs
* Crea un nuevo fármaco (draft)
*/
router.post('/', authenticate, requirePermission('content:create'), async (req: AuthRequest, res: Response) => {
try {
if (!req.user) {
res.status(401).json({ error: 'No autenticado' });
return;
}
const drugData = req.body;
// Normalizar y validar
const normalized = normalizeDrug(drugData);
const validation = validateDrug(normalized);
if (!validation.valid) {
res.status(400).json({
error: 'Datos inválidos',
details: validation.errors
});
return;
}
// Generar slug si no existe
if (!normalized.slug) {
normalized.slug = normalized.generic_name
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
// Verificar que el slug no exista
const existing = await query(
'SELECT id FROM tes_content.drugs WHERE slug = $1',
[normalized.slug]
);
if (existing.rows.length > 0) {
res.status(409).json({ error: 'Ya existe un fármaco con ese slug' });
return;
}
// Insertar fármaco
const drugId = uuidv4();
const result = await query(
`INSERT INTO tes_content.drugs (
id, slug, generic_name, trade_name, category, line, frequency,
presentation, adult_dose, pediatric_dose, routes, dilution,
indications, contraindications, side_effects, antidote,
notes, critical_points, source, status, version, latest_version,
created_by, updated_by
) VALUES (
$1, $2, $3, $4, $5, $6::tes_content.drug_line, $7::tes_content.drug_frequency,
$8, $9, $10, $11::text[], $12,
$13::text[], $14::text[], $15, $16,
$17::text[], $18::text[], $19, $20::tes_content.content_status,
$21, $22, $23, $23
)
RETURNING id, slug, generic_name, status, version`,
[
drugId,
normalized.slug,
normalized.generic_name,
normalized.trade_name,
normalized.category,
normalized.line,
normalized.frequency,
normalized.presentation,
normalized.adult_dose,
normalized.pediatric_dose,
normalized.routes,
normalized.dilution,
normalized.indications,
normalized.contraindications,
normalized.side_effects,
normalized.antidote,
normalized.notes,
normalized.critical_points,
normalized.source,
normalized.status,
normalized.version,
normalized.latest_version,
req.user.id
]
);
// Crear versión inicial
const versionId = uuidv4();
const snapshot = createDrugSnapshot({ ...normalized, id: drugId });
await query(
`INSERT INTO tes_content.drug_versions (
id, drug_id, version, drug_snapshot, change_summary, change_type, created_by
) VALUES ($1, $2, $3, $4::jsonb, $5, $6, $7)`,
[
versionId,
drugId,
normalized.version,
JSON.stringify(snapshot),
'Creación inicial',
'major',
req.user.id
]
);
// Actualizar current_version_id
await query(
'UPDATE tes_content.drugs SET current_version_id = $1 WHERE id = $2',
[versionId, drugId]
);
res.status(201).json({
message: 'Fármaco creado correctamente',
drug: result.rows[0]
});
} catch (error) {
console.error('Error creando fármaco:', error);
res.status(500).json({ error: 'Error creando fármaco' });
}
});
/**
* PUT /api/drugs/:id
* Actualiza un fármaco existente
*/
router.put('/:id', authenticate, requirePermission('content:edit'), async (req: AuthRequest, res: Response) => {
try {
if (!req.user) {
res.status(401).json({ error: 'No autenticado' });
return;
}
const { id } = req.params;
const drugData = req.body;
// Verificar que existe
const existing = await query(
'SELECT * FROM tes_content.drugs WHERE id = $1',
[id]
);
if (existing.rows.length === 0) {
res.status(404).json({ error: 'Fármaco no encontrado' });
return;
}
const oldDrug = existing.rows[0];
// Verificar permisos (solo el creador o admin puede editar)
if (oldDrug.created_by !== req.user.id && !(req.user as any).permissions?.admin?.manage_content) {
res.status(403).json({ error: 'No tienes permiso para editar este fármaco' });
return;
}
// Normalizar y validar
const normalized = normalizeDrug({ ...oldDrug, ...drugData });
const validation = validateDrug(normalized);
if (!validation.valid) {
res.status(400).json({
error: 'Datos inválidos',
details: validation.errors
});
return;
}
// Comparar cambios para determinar nueva versión
const changes = compareDrugs(oldDrug, normalized);
let newVersion = oldDrug.version;
if (changes.fields_changed.length > 0) {
const [major, minor, patch] = oldDrug.version.split('.').map(Number);
if (changes.change_type === 'major') {
newVersion = `${major + 1}.0.0`;
} else if (changes.change_type === 'minor') {
newVersion = `${major}.${minor + 1}.0`;
} else {
newVersion = `${major}.${minor}.${patch + 1}`;
}
}
// Actualizar fármaco
await query(
`UPDATE tes_content.drugs SET
generic_name = $1,
trade_name = $2,
category = $3,
line = $4::tes_content.drug_line,
frequency = $5::tes_content.drug_frequency,
presentation = $6,
adult_dose = $7,
pediatric_dose = $8,
routes = $9::text[],
dilution = $10,
indications = $11::text[],
contraindications = $12::text[],
side_effects = $13,
antidote = $14,
notes = $15::text[],
critical_points = $16::text[],
source = $17,
version = $18,
latest_version = $18,
updated_by = $19,
updated_at = NOW(),
metadata = $20::jsonb
WHERE id = $21`,
[
normalized.generic_name,
normalized.trade_name,
normalized.category,
normalized.line,
normalized.frequency,
normalized.presentation,
normalized.adult_dose,
normalized.pediatric_dose,
normalized.routes,
normalized.dilution,
normalized.indications,
normalized.contraindications,
normalized.side_effects,
normalized.antidote,
normalized.notes,
normalized.critical_points,
normalized.source,
newVersion,
req.user.id,
JSON.stringify(normalized.metadata),
id
]
);
// Crear nueva versión si hay cambios
if (changes.fields_changed.length > 0) {
const versionId = uuidv4();
const snapshot = createDrugSnapshot({ ...normalized, id });
await query(
`INSERT INTO tes_content.drug_versions (
id, drug_id, version, drug_snapshot, change_summary, change_details,
change_type, is_breaking, created_by
) VALUES ($1, $2, $3, $4::jsonb, $5, $6::jsonb, $7, $8, $9)`,
[
versionId,
id,
newVersion,
JSON.stringify(snapshot),
changes.fields_changed.join(', '),
JSON.stringify(changes),
changes.change_type,
changes.is_breaking,
req.user.id
]
);
// Desactivar versión anterior y activar nueva
await query(
'UPDATE tes_content.drug_versions SET is_active = false WHERE drug_id = $1',
[id]
);
await query(
'UPDATE tes_content.drug_versions SET is_active = true WHERE id = $1',
[versionId]
);
// Actualizar current_version_id
await query(
'UPDATE tes_content.drugs SET current_version_id = $1 WHERE id = $2',
[versionId, id]
);
}
// Obtener fármaco actualizado
const updated = await query(
'SELECT * FROM tes_content.drugs WHERE id = $1',
[id]
);
res.json({
message: 'Fármaco actualizado correctamente',
drug: updated.rows[0]
});
return;
} catch (error) {
console.error('Error actualizando fármaco:', error);
res.status(500).json({ error: 'Error actualizando fármaco' });
}
});
/**
* POST /api/drugs/:id/submit
* Envía un fármaco a revisión
*/
router.post('/:id/submit', authenticate, requirePermission('content:submit'), async (req: AuthRequest, res: Response) => {
try {
if (!req.user) {
res.status(401).json({ error: 'No autenticado' });
return;
}
const { id } = req.params;
const result = await query(
`UPDATE tes_content.drugs
SET status = 'in_review'::tes_content.content_status,
updated_by = $1,
updated_at = NOW()
WHERE id = $2 AND status = 'draft'::tes_content.content_status
RETURNING id, status`,
[req.user.id, id]
);
if (result.rows.length === 0) {
res.status(404).json({ error: 'Fármaco no encontrado o no está en estado draft' });
return;
}
// Registrar en audit_logs
await query(
`INSERT INTO tes_content.audit_logs (entity_type, entity_id, action, user_id, metadata)
VALUES ('drug', $1, 'submit', $2, '{"status": "in_review"}'::jsonb)`,
[id, req.user.id]
);
res.json({
message: 'Fármaco enviado a revisión',
drug: result.rows[0]
});
} catch (error) {
console.error('Error enviando fármaco a revisión:', error);
res.status(500).json({ error: 'Error enviando fármaco a revisión' });
}
});
/**
* POST /api/drugs/:id/approve
* Aprueba un fármaco
*/
router.post('/:id/approve', authenticate, requirePermission('validation:approve'), async (req: AuthRequest, res: Response) => {
try {
if (!req.user) {
res.status(401).json({ error: 'No autenticado' });
return;
}
const { id } = req.params;
const { notes } = req.body;
const result = await query(
`UPDATE tes_content.drugs
SET status = 'approved'::tes_content.content_status,
updated_by = $1,
updated_at = NOW()
WHERE id = $2 AND status = 'in_review'::tes_content.content_status
RETURNING id, status`,
[req.user.id, id]
);
// Registrar en audit_logs
await query(
`INSERT INTO tes_content.audit_logs (entity_type, entity_id, action, user_id, metadata)
VALUES ('drug', $1, 'approve', $2, $3::jsonb)`,
[id, req.user.id, JSON.stringify({ notes: notes || null })]
);
if (result.rows.length === 0) {
res.status(404).json({ error: 'Fármaco no encontrado o no está en estado válido para aprobar' });
return;
}
res.json({
message: 'Fármaco aprobado',
drug: result.rows[0]
});
} catch (error) {
console.error('Error aprobando fármaco:', error);
res.status(500).json({ error: 'Error aprobando fármaco' });
}
});
/**
* POST /api/drugs/:id/publish
* Publica un fármaco
*/
router.post('/:id/publish', authenticate, requirePermission('content:publish'), async (req: AuthRequest, res: Response) => {
try {
if (!req.user) {
res.status(401).json({ error: 'No autenticado' });
return;
}
const { id } = req.params;
// Verificar que está aprobado y tiene pediatric_dose
const existing = await query(
'SELECT id, status, pediatric_dose FROM tes_content.drugs WHERE id = $1',
[id]
);
if (existing.rows.length === 0) {
res.status(404).json({ error: 'Fármaco no encontrado' });
return;
}
const drug = existing.rows[0];
if (drug.status !== 'approved') {
res.status(400).json({ error: 'El fármaco debe estar aprobado para publicar' });
return;
}
if (!drug.pediatric_dose || drug.pediatric_dose.trim() === '') {
res.status(400).json({ error: 'pediatric_dose es obligatorio para publicar' });
return;
}
const result = await query(
`UPDATE tes_content.drugs
SET status = 'published'::tes_content.content_status,
published_by = $1,
published_at = NOW(),
updated_by = $1,
updated_at = NOW()
WHERE id = $2
RETURNING id, status, published_at`,
[req.user.id, id]
);
// Registrar en audit_logs
await query(
`INSERT INTO tes_content.audit_logs (entity_type, entity_id, action, user_id, metadata)
VALUES ('drug', $1, 'publish', $2, '{}'::jsonb)`,
[id, req.user.id]
);
res.json({
message: 'Fármaco publicado correctamente',
drug: result.rows[0]
});
} catch (error) {
console.error('Error publicando fármaco:', error);
res.status(500).json({ error: 'Error publicando fármaco' });
}
});
/**
* GET /api/drugs/:id/versions
* Obtiene historial de versiones de un fármaco
*/
router.get('/:id/versions', authenticate, async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
const result = await query(
`SELECT
dv.id, dv.version, dv.drug_snapshot, dv.change_summary,
dv.change_details, dv.change_type, dv.is_breaking,
dv.is_active, dv.created_at, dv.created_by,
u.username as created_by_username
FROM tes_content.drug_versions dv
LEFT JOIN tes_content.users u ON dv.created_by = u.id
WHERE dv.drug_id = $1
ORDER BY dv.created_at DESC`,
[id]
);
res.json({
versions: result.rows
});
} catch (error) {
console.error('Error obteniendo versiones:', error);
res.status(500).json({ error: 'Error obteniendo versiones' });
}
});
export default router;

Some files were not shown because too many files have changed in this diff Show more