Actualizar herramientas y contenidos
This commit is contained in:
parent
f1ba0a0a32
commit
0014c17873
10
.gitignore
vendored
10
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
@ -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
19
EJECUTAR_COMANDO.md
Normal 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
31
ESTADO_ACTUAL.md
Normal 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
|
||||
|
||||
|
|
@ -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:**
|
||||
|
|
|
|||
|
|
@ -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
8602
MANIFESTO_MEDIOS.json
Normal file
File diff suppressed because it is too large
Load diff
8627
MEDIOS_AUDIOVISUALES_FALTANTES_DEFINITIVOS.md
Normal file
8627
MEDIOS_AUDIOVISUALES_FALTANTES_DEFINITIVOS.md
Normal file
File diff suppressed because it is too large
Load diff
530
MEDIOS_REALES_NECESARIOS.md
Normal file
530
MEDIOS_REALES_NECESARIOS.md
Normal 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
|
||||
47
MEDIOS_REALES_NECESARIOS_FILTRADO.md
Normal file
47
MEDIOS_REALES_NECESARIOS_FILTRADO.md
Normal 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
|
||||
36
PLAN_GENERACION_MEDIOS_PRIORIZADO.md
Normal file
36
PLAN_GENERACION_MEDIOS_PRIORIZADO.md
Normal 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
16343
PROMPTS_MEDIOS_FALTANTES.md
Normal file
File diff suppressed because it is too large
Load diff
191
RESUMEN_ADMIN_PANEL.md
Normal file
191
RESUMEN_ADMIN_PANEL.md
Normal 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
130
admin-panel/DIAGNOSTICO.md
Normal 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
97
admin-panel/README.md
Normal 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
14
admin-panel/index.html
Normal 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
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
38
admin-panel/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
||||
78
admin-panel/shared/types/auth.ts
Normal file
78
admin-panel/shared/types/auth.ts
Normal 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',
|
||||
],
|
||||
};
|
||||
|
||||
464
admin-panel/shared/types/content-canonical.ts
Normal file
464
admin-panel/shared/types/content-canonical.ts
Normal 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,
|
||||
};
|
||||
|
||||
357
admin-panel/shared/types/content.ts
Normal file
357
admin-panel/shared/types/content.ts
Normal 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
66
admin-panel/src/App.tsx
Normal 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;
|
||||
|
||||
43
admin-panel/src/components/auth/ProtectedRoute.tsx
Normal file
43
admin-panel/src/components/auth/ProtectedRoute.tsx
Normal 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}</>;
|
||||
}
|
||||
|
||||
404
admin-panel/src/components/content/ResourcesManager.tsx
Normal file
404
admin-panel/src/components/content/ResourcesManager.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
157
admin-panel/src/components/content/ValidationHistory.tsx
Normal file
157
admin-panel/src/components/content/ValidationHistory.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
159
admin-panel/src/components/layout/Layout.tsx
Normal file
159
admin-panel/src/components/layout/Layout.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
113
admin-panel/src/contexts/AuthContext.tsx
Normal file
113
admin-panel/src/contexts/AuthContext.tsx
Normal 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;
|
||||
}
|
||||
|
||||
91
admin-panel/src/hooks/useContentStats.ts
Normal file
91
admin-panel/src/hooks/useContentStats.ts
Normal 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
34
admin-panel/src/index.css
Normal 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
11
admin-panel/src/main.tsx
Normal 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>
|
||||
);
|
||||
|
||||
23
admin-panel/src/pages/AuditPage.tsx
Normal file
23
admin-panel/src/pages/AuditPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
481
admin-panel/src/pages/ChecklistEditorPage.tsx
Normal file
481
admin-panel/src/pages/ChecklistEditorPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
367
admin-panel/src/pages/ContentLibraryPage.tsx
Normal file
367
admin-panel/src/pages/ContentLibraryPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
309
admin-panel/src/pages/ContentPackPage.tsx
Normal file
309
admin-panel/src/pages/ContentPackPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
179
admin-panel/src/pages/DashboardPage.tsx
Normal file
179
admin-panel/src/pages/DashboardPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
647
admin-panel/src/pages/DrugEditorPage.tsx
Normal file
647
admin-panel/src/pages/DrugEditorPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
425
admin-panel/src/pages/DrugManagerPage.tsx
Normal file
425
admin-panel/src/pages/DrugManagerPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
581
admin-panel/src/pages/GuideEditorPage.tsx
Normal file
581
admin-panel/src/pages/GuideEditorPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
admin-panel/src/pages/LoginPage.tsx
Normal file
112
admin-panel/src/pages/LoginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
479
admin-panel/src/pages/MediaManagerPage.tsx
Normal file
479
admin-panel/src/pages/MediaManagerPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
1264
admin-panel/src/pages/ProtocolEditorPage.tsx
Normal file
1264
admin-panel/src/pages/ProtocolEditorPage.tsx
Normal file
File diff suppressed because it is too large
Load diff
549
admin-panel/src/pages/ValidationPage.tsx
Normal file
549
admin-panel/src/pages/ValidationPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
48
admin-panel/src/services/auth.ts
Normal file
48
admin-panel/src/services/auth.ts
Normal 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
63
admin-panel/src/services/content.ts
Normal file
63
admin-panel/src/services/content.ts
Normal 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 });
|
||||
},
|
||||
};
|
||||
|
||||
34
admin-panel/tailwind.config.js
Normal file
34
admin-panel/tailwind.config.js
Normal 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
26
admin-panel/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
|
||||
12
admin-panel/tsconfig.node.json
Normal file
12
admin-panel/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
||||
16
admin-panel/vite.config.ts
Normal file
16
admin-panel/vite.config.ts
Normal 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
1922
auditoria-assets-completa.md
Normal file
File diff suppressed because it is too large
Load diff
52
backend/CONFIGURAR_PASSWORD.md
Normal file
52
backend/CONFIGURAR_PASSWORD.md
Normal 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
25
backend/ENV_TEMPLATE.md
Normal 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
|
||||
```
|
||||
|
||||
46
backend/INSTRUCCIONES_CREAR_USUARIO.md
Normal file
46
backend/INSTRUCCIONES_CREAR_USUARIO.md
Normal 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
102
backend/README.md
Normal 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
|
||||
|
||||
58
backend/config/database.js
Normal file
58
backend/config/database.js
Normal 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();
|
||||
}
|
||||
|
||||
59
backend/config/database.ts
Normal file
59
backend/config/database.ts
Normal 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
54
backend/crear-usuario-y-bd.sh
Executable 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"
|
||||
47
backend/database/migrations/001_create_auth_schema.sql
Normal file
47
backend/database/migrations/001_create_auth_schema.sql
Normal 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';
|
||||
|
||||
217
backend/database/migrations/002_create_drugs_schema.sql
Normal file
217
backend/database/migrations/002_create_drugs_schema.sql
Normal 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
|
||||
-- ============================================
|
||||
|
||||
267
backend/database/migrations/003_create_content_items_schema.sql
Normal file
267
backend/database/migrations/003_create_content_items_schema.sql
Normal 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
3981
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
71
backend/package.json
Normal file
71
backend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
58
backend/scripts/create-auth-tables.js
Normal file
58
backend/scripts/create-auth-tables.js
Normal 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();
|
||||
|
||||
65
backend/scripts/create-full-schema.js
Normal file
65
backend/scripts/create-full-schema.js
Normal 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();
|
||||
|
||||
25
backend/scripts/create-user-db.sh
Executable file
25
backend/scripts/create-user-db.sh
Executable 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)"
|
||||
37
backend/scripts/create-user.sql
Normal file
37
backend/scripts/create-user.sql
Normal 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
94
backend/scripts/db-create.js
Executable 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();
|
||||
|
||||
51
backend/scripts/fix-content-priority-type.js
Normal file
51
backend/scripts/fix-content-priority-type.js
Normal 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();
|
||||
|
||||
601
backend/scripts/migrate-all-content.js
Normal file
601
backend/scripts/migrate-all-content.js
Normal 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();
|
||||
|
||||
549
backend/scripts/migrate-app-content-v2.js
Normal file
549
backend/scripts/migrate-app-content-v2.js
Normal 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();
|
||||
|
||||
421
backend/scripts/migrate-app-content.js
Normal file
421
backend/scripts/migrate-app-content.js
Normal 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();
|
||||
|
||||
86
backend/scripts/migrate-content-items-schema.js
Normal file
86
backend/scripts/migrate-content-items-schema.js
Normal 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();
|
||||
|
||||
190
backend/scripts/migrate-content.js
Executable file
190
backend/scripts/migrate-content.js
Executable 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();
|
||||
|
||||
85
backend/scripts/migrate-drugs-schema.js
Normal file
85
backend/scripts/migrate-drugs-schema.js
Normal 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();
|
||||
|
||||
62
backend/scripts/seed-admin.js
Normal file
62
backend/scripts/seed-admin.js
Normal 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();
|
||||
|
||||
295
backend/scripts/seed-content.js
Normal file
295
backend/scripts/seed-content.js
Normal 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();
|
||||
|
||||
1099
backend/scripts/seed-drugs.js
Normal file
1099
backend/scripts/seed-drugs.js
Normal file
File diff suppressed because it is too large
Load diff
37
backend/scripts/setup-database-manual.sh
Executable file
37
backend/scripts/setup-database-manual.sh
Executable 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 ""
|
||||
849
backend/scripts/sync-content-to-db.js
Normal file
849
backend/scripts/sync-content-to-db.js
Normal 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();
|
||||
|
||||
66
backend/scripts/test-content-endpoint.js
Normal file
66
backend/scripts/test-content-endpoint.js
Normal 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();
|
||||
|
||||
201
backend/scripts/verify-content-missing.js
Executable file
201
backend/scripts/verify-content-missing.js
Executable 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
59
backend/scripts/verify-setup.js
Executable 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();
|
||||
|
||||
65
backend/src/config/cors.ts
Normal file
65
backend/src/config/cors.ts
Normal 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
127
backend/src/config/env.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
|
||||
63
backend/src/config/security.ts
Normal file
63
backend/src/config/security.ts
Normal 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
149
backend/src/index.js
Normal 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
155
backend/src/index.ts
Normal 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);
|
||||
});
|
||||
|
||||
167
backend/src/middleware/auth.ts
Normal file
167
backend/src/middleware/auth.ts
Normal 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] || [];
|
||||
}
|
||||
|
||||
74
backend/src/middleware/rate-limit.ts
Normal file
74
backend/src/middleware/rate-limit.ts
Normal 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,
|
||||
});
|
||||
|
||||
35
backend/src/middleware/request-logger.ts
Normal file
35
backend/src/middleware/request-logger.ts
Normal 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;
|
||||
|
||||
54
backend/src/middleware/security-headers.ts
Normal file
54
backend/src/middleware/security-headers.ts
Normal 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');
|
||||
|
||||
159
backend/src/middleware/validate.ts
Normal file
159
backend/src/middleware/validate.ts
Normal 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
284
backend/src/models/Drug.ts
Normal 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
166
backend/src/routes/auth.ts
Normal 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;
|
||||
|
||||
124
backend/src/routes/content-pack-admin.ts
Normal file
124
backend/src/routes/content-pack-admin.ts
Normal 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;
|
||||
|
||||
115
backend/src/routes/content-pack.ts
Normal file
115
backend/src/routes/content-pack.ts
Normal 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;
|
||||
|
||||
260
backend/src/routes/content-resources.ts
Normal file
260
backend/src/routes/content-resources.ts
Normal 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;
|
||||
|
||||
587
backend/src/routes/content.ts
Normal file
587
backend/src/routes/content.ts
Normal 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
638
backend/src/routes/drugs.ts
Normal 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
Loading…
Reference in a new issue