docschore: cleanup, update deps, refactor data schemas

This commit is contained in:
Javier 2026-02-27 15:37:22 +01:00
parent 975892ecd4
commit e6e87322df
326 changed files with 12654 additions and 69900 deletions

261
.ai-assistant.md Normal file
View file

@ -0,0 +1,261 @@
# Instrucciones para Asistentes de Código - EMERGES TES
**Instrucciones persistentes para asistentes de código (IA) que trabajan en este proyecto.**
**Última actualización:** 2025-02-02
---
## 🎯 Objetivo
Este documento proporciona **reglas conceptuales**, **buenas prácticas** y **advertencias** para que asistentes de código trabajen eficazmente en este proyecto sin inventar funcionalidades o malinterpretar el dominio.
---
## 🚨 REGLA DE ORO
**Si una funcionalidad no está implementada ni documentada explícitamente, debe considerarse INEXISTENTE.**
---
## 📋 Reglas Conceptuales
### 1. Entender el Dominio Antes de Proponer Cambios
**Antes de proponer cualquier cambio:**
1. ✅ Leer `SPEC.md` para entender el dominio
2. ✅ Revisar `README_ARCHITECTURE.md` para arquitectura
3. ✅ Consultar `docs/QUE_FALTA.md` para tareas pendientes
4. ✅ Verificar `.cursorrules` para reglas de código
**NO proponer cambios sin entender el contexto.**
### 2. No Inventar Funcionalidades
**CRÍTICO:** Un asistente **NO debe**:
- ❌ Asumir que existe una funcionalidad no documentada
- ❌ Crear entidades que no existen en el dominio
- ❌ Añadir features "porque sería útil" sin documentación previa
- ❌ Confundir tickets técnicos con entidades de negocio
**Ejemplo incorrecto:**
```typescript
// ❌ INCORRECTO: Inventar sistema de tickets de soporte
interface SupportTicket {
id: string;
// Esto NO existe en el dominio
}
```
**Ejemplo correcto:**
```typescript
// ✅ CORRECTO: Usar entidades que existen
import type { ContentItem } from '../domain/entities/ContentItem.js';
```
### 3. Separar Tickets de Entidades
**IMPORTANTE:** Los "tickets" (TICKET-001, TICKET-002, etc.) son **tareas técnicas de desarrollo**, NO entidades del dominio.
- ❌ **NO crear** entidades llamadas "Ticket"
- ❌ **NO añadir** lógica de negocio para "tickets"
- ✅ **SÍ usar** tickets como referencia a tareas técnicas
**Entidades reales del dominio:**
- `ContentItem` - Protocolos, guías, manuales
- `Drug` - Fármacos
- `GlossaryTerm` - Términos del glosario
- `MediaResource` - Medios audiovisuales
- `MedicalReview` - Revisiones médicas
### 4. Respetar Clean Architecture
**Backend sigue Clean Architecture:**
```
Domain Layer (NO depende de nadie)
Application Layer (solo depende de Domain)
Infrastructure Layer (depende de Domain + Application)
Presentation Layer (depende de Application + Domain)
```
**Reglas:**
- ✅ Domain: NO depende de nadie
- ✅ Application: Solo depende de Domain
- ✅ Infrastructure: Depende de Domain y Application
- ✅ Presentation: Depende de Application y Domain
**NO invertir estas dependencias.**
---
## ✅ Buenas Prácticas para Proponer Cambios
### 1. Antes de Proponer
**Checklist:**
- [ ] ¿Está documentado en `SPEC.md` o `README_TODO.md`?
- [ ] ¿Respeto `.cursorrules`?
- [ ] ¿Uso entidades existentes del dominio?
- [ ] ¿No invento funcionalidades nuevas?
- [ ] ¿Sigo Clean Architecture (backend)?
### 2. Al Proponer Funcionalidades Nuevas
**Proceso:**
1. ✅ **Documentar primero** en `SPEC.md`
2. ✅ **Justificar** la necesidad
3. ✅ **Explicar** impacto arquitectónico
4. ✅ **Definir** entidades en `domain/entities/` si aplica
5. ✅ **Proponer** implementación siguiendo Clean Architecture
### 3. Al Implementar Código
**Reglas:**
- ✅ **TypeScript estricto:** NO usar `any`
- ✅ **Validación Zod:** Validar todos los inputs
- ✅ **Inmutabilidad:** Propiedades `readonly` en entidades
- ✅ **Early returns:** Guard clauses para errores
- ✅ **Funciones pequeñas:** Máximo 20-30 líneas
- ✅ **Tests:** Añadir tests para código nuevo
---
## ⚠️ Advertencias Importantes
### 1. No Asumir Sistemas de Negocio No Documentados
**Ejemplos de lo que NO existe:**
- ❌ Sistema de tickets de soporte/incidencias
- ❌ Sistema de mensajería entre usuarios
- ❌ Sistema de roles avanzados (más allá de admin/reviewer/validator)
- ❌ Sistema de permisos granulares por recurso
**Si se necesita:** Documentar en `SPEC.md` primero, luego implementar.
### 2. No Confundir Nombres con Funcionalidades
**Ejemplos:**
- Ver "TICKET-013" → NO significa que existe entidad "Ticket"
- Ver "validation" → Verificar contexto (validación médica de contenido, no tickets)
- Ver "review" → Verificar si es `MedicalReview` (entidad) o revisión de código
### 3. Verificar Antes de Modificar
**Antes de modificar entidades:**
1. ✅ Buscar todas las referencias en el código
2. ✅ Verificar dependencias en otros módulos
3. ✅ Comprobar tests que puedan romperse
4. ✅ Documentar cambios en `SPEC.md` si son significativos
---
## 🔍 Cómo Trabajar con Este Proyecto
### Paso 1: Entender el Contexto
**Leer en este orden:**
1. `README.md` - Descripción general
2. `SPEC.md` - Especificación maestra
3. `README_ARCHITECTURE.md` - Arquitectura
4. `.cursorrules` - Reglas de código
### Paso 2: Identificar la Tarea
**Verificar:**
- ¿Está en `README_TODO.md`?
- ¿Está en `docs/QUE_FALTA.md`?
- ¿Está documentada en `SPEC.md`?
**Si NO está documentada:** NO asumir, preguntar o documentar primero.
### Paso 3: Implementar
**Seguir:**
- `.cursorrules` para reglas de código
- `README_ARCHITECTURE.md` para arquitectura
- `README_DEV.md` para buenas prácticas
### Paso 4: Validar
**Checklist:**
- [ ] ¿No uso `any`?
- [ ] ¿Valido inputs con Zod?
- [ ] ¿Sigo Clean Architecture?
- [ ] ¿Añado tests?
- [ ] ¿Documento cambios significativos?
---
## 📚 Referencias Rápidas
### Documentos Principales
- **`SPEC.md`** - Especificación maestra (fuente de verdad)
- **`.cursorrules`** - Reglas de código y arquitectura
- **`README_ARCHITECTURE.md`** - Arquitectura detallada
- **`README_DEV.md`** - Guía de desarrollo
- **`README_TODO.md`** - Tareas pendientes
### Entidades del Dominio
- `ContentItem` - `backend/src/domain/entities/ContentItem.ts`
- `Drug` - `backend/src/domain/entities/Drug.ts`
- `GlossaryTerm` - `backend/src/domain/entities/GlossaryTerm.ts`
- `MediaResource` - `backend/src/domain/entities/MediaResource.ts`
- `MedicalReview` - `backend/src/domain/entities/MedicalReview.ts`
### Tickets Técnicos
- **NO son** entidades del dominio
- **SÍ son** tareas técnicas de desarrollo
- **Documentados en:** `docs/QUE_FALTA.md`
---
## ✅ Checklist Antes de Proponer Cambios
Antes de proponer cualquier cambio, verificar:
- [ ] ¿Entiendo el dominio leyendo `SPEC.md`?
- [ ] ¿Respeto `.cursorrules`?
- [ ] ¿No invento funcionalidades no documentadas?
- [ ] ¿Uso entidades existentes del dominio?
- [ ] ¿No confundo tickets con entidades?
- [ ] ¿Sigo Clean Architecture (backend)?
- [ ] ¿Valido inputs con Zod?
- [ ] ¿Añado tests para código nuevo?
- [ ] ¿Documento cambios significativos?
---
## 🎯 Resumen Ejecutivo
**Para asistentes de código:**
1. ✅ **Leer primero:** `SPEC.md`, `README_ARCHITECTURE.md`, `.cursorrules`
2. ✅ **No inventar:** Si no está documentado, no existe
3. ✅ **Respetar arquitectura:** Clean Architecture en backend
4. ✅ **Validar código:** TypeScript estricto, Zod, tests
5. ✅ **Documentar cambios:** Especialmente cambios arquitectónicos
**Regla de oro:** Si una funcionalidad no está implementada ni documentada explícitamente, debe considerarse **inexistente**.
---
**Última actualización:** 2025-02-02

View file

@ -1,8 +1,8 @@
# Cursor Rules - EMERGES TES # Cursor Rules - EMERGES TES
## Arquitectura Clean Architecture + TypeScript + PostgreSQL ## Arquitectura Clean Architecture + TypeScript + PostgreSQL
**Última actualización:** 2025-01-25 **Última actualización:** 2025-01-29
**Versión:** 2.0 **Versión:** 3.0
--- ---
@ -74,6 +74,8 @@ shared/ → Types, Errors, Utils
### Reglas de Dependencias ### Reglas de Dependencias
- ✅ **Regla de Dependencia:** Las dependencias deben apuntar siempre hacia adentro (hacia el Dominio)
- ✅ **Ninguna capa interna puede conocer detalles de una capa externa**
- ✅ Domain: NO depende de nadie - ✅ Domain: NO depende de nadie
- ✅ Application: Solo depende de Domain - ✅ Application: Solo depende de Domain
- ✅ Infrastructure: Depende de Domain y Application - ✅ Infrastructure: Depende de Domain y Application
@ -85,11 +87,15 @@ shared/ → Types, Errors, Utils
### TypeScript ### TypeScript
- ✅ **PROHIBIDO el uso de `any`** - Todos los tipos deben ser estrictos y explícitos
- ✅ **Tipado Estricto:** Todos los tipos deben estar definidos explícitamente en `/types` o dentro del dominio
- ✅ Usar tipos explícitos, evitar `any` - ✅ Usar tipos explícitos, evitar `any`
- ✅ Usar `readonly` para propiedades inmutables - ✅ Usar `readonly` para propiedades inmutables
- ✅ Preferir `interface` sobre `type` para objetos extensibles - ✅ Preferir `interface` sobre `type` para objetos extensibles
- ✅ Usar `type` para unions, intersections, primitives - ✅ Usar `type` para unions, intersections, primitives
- ✅ NO usar `@ts-ignore` sin comentario explicativo - ✅ NO usar `@ts-ignore` sin comentario explicativo
- ✅ **Higiene de datos:** Si un tipo no puede ser inferido, definirlo explícitamente
- ✅ **Tipos en dominio:** Tipos de dominio deben estar en `domain/types/` o dentro de entidades
### Entidades de Dominio ### Entidades de Dominio
@ -150,20 +156,34 @@ export class ContentStatus {
- ✅ En `infrastructure/mappers/` - ✅ En `infrastructure/mappers/`
- ✅ Métodos estáticos `toDomain()` y `toPersistence()` - ✅ Métodos estáticos `toDomain()` y `toPersistence()`
- ✅ Conversión de tipos (string ↔ Date, snake_case ↔ camelCase) - ✅ Conversión de tipos (string ↔ Date, snake_case ↔ camelCase)
- ✅ **PROHIBIDO:** Pasar entidades de base de datos directamente a la UI
- ✅ **OBLIGATORIO:** Implementar Mappers en capa de infraestructura para traducir datos de persistencia a entidades de dominio
- ✅ **Validación Zod:** Todos los mappers deben validar datos con Zod antes de convertir a dominio
```typescript ```typescript
// ✅ CORRECTO // ✅ CORRECTO - Con validación Zod
import { z } from 'zod';
const ContentItemRowSchema = z.object({
id: z.string().min(1),
status: z.string(),
created_at: z.string(),
// ...
});
class ContentItemMapper { class ContentItemMapper {
static toDomain(row: any): ContentItem { static toDomain(row: unknown): ContentItem {
// Validar con Zod antes de convertir
const validated = ContentItemRowSchema.parse(row);
return { return {
id: row.id, id: validated.id,
status: row.status as ContentStatusType, status: validated.status as ContentStatusType,
createdAt: row.created_at, // Ya es string ISO createdAt: validated.created_at, // Ya es string ISO
// ... // ...
}; };
} }
static toPersistence(item: ContentItem): any { static toPersistence(item: ContentItem): Record<string, unknown> {
return { return {
id: item.id, id: item.id,
status: item.status, status: item.status,
@ -186,6 +206,7 @@ class ContentItemMapper {
- ✅ Usar `DomainError`, `ValidationError`, `BusinessRuleError` - ✅ Usar `DomainError`, `ValidationError`, `BusinessRuleError`
- ✅ Incluir código y contexto - ✅ Incluir código y contexto
- ✅ NO silenciar errores con `catch` vacío - ✅ NO silenciar errores con `catch` vacío
- ✅ **Early Returns para errores:** Usar retornos tempranos en lugar de anidar condiciones
```typescript ```typescript
// ✅ CORRECTO // ✅ CORRECTO
@ -206,6 +227,8 @@ throw new BusinessRuleError('Slug ya existe', {
- ✅ Una sola responsabilidad - ✅ Una sola responsabilidad
- ✅ Nombres descriptivos - ✅ Nombres descriptivos
- ✅ Parámetros máximo 3-4, usar objetos si hay más - ✅ Parámetros máximo 3-4, usar objetos si hay más
- ✅ **Early Returns obligatorios:** Usar retornos tempranos para manejar condiciones de error o datos no definidos
- ✅ **Regla de los 15 minutos:** Si la lógica no puede entenderse en 15 minutos, simplificar o dividir en módulos más pequeños
### Base de Datos ### Base de Datos
@ -220,11 +243,13 @@ throw new BusinessRuleError('Slug ya existe', {
### Nomenclatura ### Nomenclatura
- **Archivos TypeScript:** `kebab-case.ts` o `PascalCase.ts` para componentes - **Archivos TypeScript:** `kebab-case.ts` o `kebab-case.tsx` para componentes React
- **Carpetas:** `kebab-case` - **Componentes React:** `kebab-case.tsx` (ej. `rcp-protocol-view.tsx`, `drug-card.tsx`)
- **Carpetas:** `kebab-case` (siempre)
- **Tipos/Interfaces:** `PascalCase` - **Tipos/Interfaces:** `PascalCase`
- **Funciones:** `camelCase` - **Funciones:** `camelCase`
- **Constantes:** `UPPER_SNAKE_CASE` - **Constantes:** `UPPER_SNAKE_CASE`
- **Event Handlers:** Prefijar con `handle` (ej. `handleClick`, `handleSubmit`)
### Estructura ### Estructura
@ -285,6 +310,16 @@ presentation/routes/
- Generar IDs dentro de entidades - Generar IDs dentro de entidades
- Usar `Date` nativo en entidades - Usar `Date` nativo en entidades
- Usar `ReadonlyArray<T>` (usar `readonly T[]`) - Usar `ReadonlyArray<T>` (usar `readonly T[]`)
- **Componentes de fetching sin estados de loading/error**
- **Acceder a propiedades sin verificar que el objeto existe** (usar optional chaining o guard clauses)
- **Código que requiere más de 15 minutos para entender** (simplificar o dividir)
- **Pasar entidades de BD directamente a UI** (usar mappers)
- **Casos de uso que invocan otros casos de uso** (lógica circular)
- **Event handlers sin prefijo `handle`**
- **Nomenclatura incorrecta** (usar kebab-case para componentes)
- **TODOs, FIXMEs, comentarios de cierre** en código final
- **Commits no convencionales** o mensajes >60 caracteres
- **Falta de validación Zod** en entradas externas
--- ---
@ -357,7 +392,7 @@ Antes de hacer commit, verificar:
- [ ] Tests escritos y pasando - [ ] Tests escritos y pasando
- [ ] Sin código duplicado - [ ] Sin código duplicado
- [ ] Sin imports no usados - [ ] Sin imports no usados
- [ ] Sin `any` en TypeScript - [ ] **Sin `any` en TypeScript** (PROHIBIDO)
- [ ] Entidades inmutables (`readonly`) - [ ] Entidades inmutables (`readonly`)
- [ ] Manejo de errores apropiado - [ ] Manejo de errores apropiado
- [ ] Logs de auditoría donde corresponda - [ ] Logs de auditoría donde corresponda
@ -366,6 +401,18 @@ Antes de hacer commit, verificar:
- [ ] Fechas como strings ISO 8601 - [ ] Fechas como strings ISO 8601
- [ ] Arrays como `readonly T[]` - [ ] Arrays como `readonly T[]`
- [ ] Mappers separados en Infrastructure - [ ] Mappers separados en Infrastructure
- [ ] **Componentes de fetching tienen estados de loading y error**
- [ ] **Early returns para condiciones de error y datos undefined**
- [ ] **Código comprensible en menos de 15 minutos** (si no, simplificar)
- [ ] **Guard clauses aplicadas antes de acceder a propiedades**
- [ ] **Nomenclatura kebab-case para componentes React**
- [ ] **Event handlers prefijados con `handle`**
- [ ] **Mappers validan con Zod antes de convertir a dominio**
- [ ] **Casos de uso no invocan otros casos de uso**
- [ ] **Accesibilidad AA cumplida (roles ARIA, navegación por teclado)**
- [ ] **Semántica HTML apropiada**
- [ ] **Sin TODOs ni marcadores en código final**
- [ ] **Commits convencionales con mensajes <60 caracteres**
--- ---
@ -378,4 +425,156 @@ Antes de hacer commit, verificar:
--- ---
## 🛡️ REGLAS DE SEGURIDAD Y ROBUSTEZ
### Higiene de Datos
- ✅ **PROHIBIDO el uso de `any`** - Todos los tipos deben ser estrictos
- ✅ Validar todos los datos de entrada antes de procesarlos
- ✅ Usar tipos explícitos en lugar de inferencia cuando hay ambigüedad
- ✅ Verificar que arrays/objetos existen antes de acceder a propiedades
### Estados Obligatorios en Fetching
- ✅ **OBLIGATORIO:** Todos los hooks/componentes que obtienen datos deben manejar:
- Estado `loading` (mientras se cargan los datos)
- Estado `error` (si falla la carga)
- Estado `success` (datos disponibles)
- Estado `not_found` (recurso no encontrado)
- ✅ Usar Discriminated Unions para type safety
- ✅ Renderizar componentes de fallback (`PageLoader`, `NotFound`) según el estado
- ✅ **Estados de Carga y Error:** Todos los componentes que realicen obtención de datos deben incluir obligatoriamente estados de loading y error
### Andragogía clínica y Stress-Ready Design (112/061)
La interfaz debe actuar como **socio cognitivo**, no como manual digital: reducir carga cognitiva y respetar la autonomía del facultativo en emergencias.
- ✅ **Orientación al problema:** Estructurar por problemas clínicos (ej. "Parada Cardiorrespiratoria"); contenido accionable ("qué hacer") en menos de 2 clics.
- ✅ **Autonomía:** Navegación no lineal (saltar a dosis/algoritmos sin secuencia rígida); Offline-First cuando sea posible.
- ✅ **Experiencia previa:** Terminología y modelos familiares (estándares clínicos); resúmenes visuales + detalle expandible para quien lo necesite.
- ✅ **Procesamiento sensorial / estrés:** Jerarquía visual (Lookability): elementos críticos (alertas, dosis) visualmente dominantes; multimodalidad (señales auditivas, diagramas claros) cuando aplique.
- ✅ **Simulación y maestría:** Modos de práctica/simulación y feedback inmediato (checklists, confirmaciones) para transferir habilidades al mundo real.
Referencia completa: `docs/ANDRAGOGIA_STRESS_READY_112.md`
### Seguridad con Early Returns
- ✅ **SIEMPRE usar retornos tempranos** para manejar:
- Datos `undefined` o `null`
- Condiciones de error
- Validaciones fallidas
- Estados de carga
- ✅ Evitar anidar condiciones profundamente
- ✅ Aplicar guard clauses al inicio de funciones/componentes
```typescript
// ✅ CORRECTO - Early returns
function processData(data: Data | undefined): Result {
if (!data) return { error: 'Datos no disponibles' };
if (!data.id) return { error: 'ID requerido' };
if (data.status === 'error') return { error: data.message };
// Solo aquí procesamos datos válidos
return { success: true, result: transform(data) };
}
// ❌ INCORRECTO - Anidación profunda
function processData(data: Data | undefined): Result {
if (data) {
if (data.id) {
if (data.status !== 'error') {
return { success: true, result: transform(data) };
}
}
}
return { error: 'Error' };
}
```
### Regla de los 15 Minutos
- ✅ **Código debe ser comprensible en 15 minutos**
- ✅ Si una función/componente es demasiado compleja:
- Dividirla en funciones más pequeñas
- Extraer lógica a hooks personalizados
- Crear componentes intermedios
- Documentar la lógica compleja con comentarios claros
- ✅ **Aplicar a IA:** Si Cursor propone una lógica que no puede ser comprendida por un humano en menos de 15 minutos, debe ser simplificada o dividida en submódulos
- ✅ Evitar "deuda de comprensión" - código que solo el autor entiende
```typescript
// ✅ CORRECTO - Función simple y clara
function calculateTotal(items: Item[]): number {
if (!items || items.length === 0) return 0;
return items.reduce((sum, item) => sum + item.price, 0);
}
// ❌ INCORRECTO - Demasiado compleja, requiere más de 15 min para entender
function processComplexData(data: any): any {
// 50 líneas de lógica compleja mezclada...
}
```
---
## 📝 GOBERNANZA DEL CÓDIGO IA
### Higiene de Commits
- ✅ **Commits Convencionales:** Usar siempre formato convencional (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`)
- ✅ **Mensajes Cortos:** Mantener los mensajes de commit por debajo de 60 caracteres
- ✅ **Descripción Opcional:** Si se necesita más contexto, usar cuerpo del commit después de línea vacía
```bash
# ✅ CORRECTO
feat: añadir guard clauses en Farmacos.tsx
fix: corregir acceso inseguro a drug.id
docs: actualizar SPEC.md con decisiones técnicas
# ❌ INCORRECTO
Added guard clauses to Farmacos component # ❌ No convencional
fix: corregir el problema de acceso inseguro a la propiedad id del objeto drug que causaba errores de runtime # ❌ >60 caracteres
```
### Documentación Continua
- ✅ **Actualizar SPEC.md:** Por cada cambio mayor, actualizar `SPEC.md` con decisiones técnicas
- ✅ **Registro de Decisiones:** Mantener ADL (Architecture Decision Log) para decisiones arquitectónicas importantes
- ✅ **Memoria del Proyecto:** La IA debe mantener la "memoria" del proyecto actualizando documentación
### Prohibición de Marcadores
- ❌ **NO dejar TODOs** en código final
- ❌ **NO dejar comentarios de llaves de cierre** (ej. `} // end function`)
- ❌ **NO dejar marcadores de posición** (ej. `// FIXME`, `// HACK`, `// XXX`)
- ✅ Si hay trabajo pendiente, crear ticket o documentar en SPEC.md
```typescript
// ❌ INCORRECTO - Marcadores prohibidos
function calculateDose() {
// TODO: añadir validación de peso
return weight * dose;
} // end calculateDose
// ✅ CORRECTO - Código limpio
function calculateDose(weight: number, dose: number): number {
if (weight <= 0) throw new ValidationError('Peso debe ser positivo');
return weight * dose;
}
```
### Checklist antes de aceptar cambios (IA)
**Antes de que el usuario acepte cualquier cambio propuesto, la IA debe verificar y responder explícitamente:**
1. **Clean Architecture:** ¿El código cumple con la Clean Architecture y las capas definidas? (Domain → Application → Infrastructure → Presentation; sin dependencias invertidas.)
2. **Pruebas:** ¿Se han incluido pruebas unitarias con una cobertura mínima del 80% para el código nuevo o modificado?
3. **Andragogía / UX:** ¿La interfaz reduce la latencia cognitiva siguiendo los principios de andragogía? (Información crítica visible, estados loading/error, fallbacks.)
4. **Seguridad / PII:** ¿Se han enmascarado los datos sensibles (PII) y evitado claves API en texto plano?
- ✅ Documento completo: `docs/CHECKLIST_ANTES_ACEPTAR_CAMBIOS.md`
- ✅ Si algún punto no se cumple, indicarlo y proponer corrección o excepción documentada.
---
**Fin de Cursor Rules** **Fin de Cursor Rules**

View file

@ -38,10 +38,14 @@ _BACKUP_MD/
MANUAL_TES_DIGITAL/ MANUAL_TES_DIGITAL/
imagenes-pendientes/ imagenes-pendientes/
# Scripts (no necesario en producción) # Scripts (no necesario en producción) - EXCEPTO verify-build.js
scripts/ scripts/*.sh
scripts/*.ts
scripts/deploy/
scripts/consolidated/
!scripts/verify-build.js
*.py *.py
*.sh
!deploy-docker.sh !deploy-docker.sh
# Configuraciones de desarrollo # Configuraciones de desarrollo

109
DEPLOY_LAB.md Normal file
View file

@ -0,0 +1,109 @@
# Despliegue EMERGES TES (guia-tes) en LAB
Despliegue en el mismo servidor que TalentOS (`root@207.180.226.141`), usando el mismo método (Docker, docker-compose, rsync) pero en puerto distinto.
## Parámetros
| Variable | Valor por defecto | Descripción |
|--------------|-------------------|--------------------------------------------|
| Puerto host | 8608 | Puerto donde escucha la app en el servidor |
| Ruta remota | `/srv/lab/stacks/guia-tes` | Stack en el servidor |
| Puerto contenedor | 8607 | Interno (serve) |
> TalentOS usa puerto 3000; EMERGES TES usa 8608 por defecto (sin conflicto).
---
## 1. Configurar puerto (opcional)
Para usar otro puerto distinto de 8608:
```bash
# En local o en el servidor, crear/editar .env
echo "APP_PORT=8609" > .env
```
---
## 2. Subir código al servidor
Desde tu máquina local (en el directorio del proyecto):
```bash
chmod +x scripts/subir-al-servidor.sh
./scripts/subir-al-servidor.sh
```
Usa `rsync` para copiar el código. Excluye `node_modules`, `dist`, `coverage`, `.git`.
---
## 3. Deploy en el servidor
### Opción A: Desde local (SSH remoto)
```bash
ssh root@207.180.226.141 "cd /srv/lab/stacks/guia-tes && ./scripts/deploy-lab.sh"
```
### Opción B: Conectando al servidor
```bash
ssh root@207.180.226.141
cd /srv/lab/stacks/guia-tes
chmod +x scripts/deploy-lab.sh
./scripts/deploy-lab.sh
```
El script hace:
1. `docker compose -f docker-compose.lab.yml build --no-cache`
2. `docker compose -f docker-compose.lab.yml up -d`
---
## 4. Verificación
```bash
# Ver contenedor
docker ps | grep emerges_tes_app
# Ver logs
docker compose -f docker-compose.lab.yml logs -f emerges-tes
# Verificar puerto
ss -tlnp | grep 8608
```
Acceso: `http://207.180.226.141:8608`
---
## Comandos útiles
| Acción | Comando |
|-------------|--------------------------------------------------------------|
| Ver logs | `docker compose -f docker-compose.lab.yml logs -f emerges-tes` |
| Reiniciar | `docker compose -f docker-compose.lab.yml restart emerges-tes` |
| Detener | `docker compose -f docker-compose.lab.yml down` |
| Rebuild | `docker compose -f docker-compose.lab.yml build --no-cache && docker compose -f docker-compose.lab.yml up -d` |
---
## Flujo completo (subida + deploy)
```bash
# 1. Local: subir código
./scripts/subir-al-servidor.sh
# 2. Servidor: build y up
ssh root@207.180.226.141 "cd /srv/lab/stacks/guia-tes && ./scripts/deploy-lab.sh"
```
---
## Notas
- **No se reutiliza** la configuración ni los puertos de TalentOS (3000).
- Se sigue el mismo patrón: Docker, rsync, `build --no-cache`, `up -d`.
- El Dockerfile hace multi-stage build; el contenedor final sirve estáticos con `serve` en el puerto 8607 interno, mapeado al puerto configurado en el host.

View file

@ -1,20 +0,0 @@
# ⚡ 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 y base de datos según configuración
- ✅ Crea la base de datos `emerges_tes`
- ✅ Crea el esquema `emerges_content`
- ✅ Da todos los permisos necesarios
- ⚠️ **IMPORTANTE:** Configura las credenciales en el script antes de ejecutar
**Después de ejecutarlo, avísame y continúo automáticamente con:**
- Verificar conexión
- Crear tablas (migraciones)
- Migrar contenido

View file

@ -1,96 +0,0 @@
# 📊 ESTADO ACTUAL - FASE 1
## ✅ COMPLETADO
### Backend y Base de Datos
1. ✅ **Dependencias instaladas** (`npm install` en backend)
2. ✅ **Archivo `.env` configurado** con credenciales de base de datos
- Configuración almacenada en `.env` (no versionado)
- Ver `backend/.env.example` para estructura
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** (23 items: 5 protocolos, 9 guías, 6 fármacos, 3 checklists)
### Frontend - Funcionalidades Críticas
7. ✅ **Persistencia de Favoritos** (2026-01-24)
- Hook `useFavorites.ts` con localStorage
- Página de favoritos completa
- Integrado en protocolos y fármacos
8. ✅ **Historial de Búsquedas** (2026-01-24)
- Hook `useSearchHistory.ts` con sessionStorage
- Muestra últimas 3 búsquedas en home
- Máximo 20 búsquedas, evita duplicados
9. ✅ **Error Boundaries** (2026-01-24)
- Componente `ErrorBoundary.tsx` completo
- Captura errores síncronos y promesas rechazadas
- Logging global en `main.tsx`
- Página de error personalizada
10. ✅ **Páginas de Ajustes y Acerca** (2026-01-23)
- Página `/ajustes` con configuración de tema
- Página `/acerca` con información y disclaimer
11. ✅ **Placeholders Visuales** (2026-01-24)
- 13 archivos placeholder SVG: 8 ABCDE + 5 RCP/DESA
- Contenido descriptivo y funcional
- Listos para reemplazar con diseño profesional
- Top 5 críticas: Algoritmo RCP, RCP paso a paso, Posición manos, Profundidad, DESA
12. ✅ **Disclaimer Legal** (2026-01-24)
- DisclaimerModal en primera carga
- Footer con disclaimer resumido
- Enlaces a documentos legales
13. ✅ **Vademécum Expandido** (2026-01-24)
- +15 fármacos críticos añadidos
- Total: 6 → 21 fármacos (60%)
- Incluye: Noradrenalina, Furosemida, Nitroglicerina, Fentanilo, Ketamina, Adenosina, Lidocaína, Flumazenilo, Ipratropio, Dobutamina, Aspirina, Glucosa IV, Metilprednisolona, Ácido Tranexámico, Hidrocortisona
14. ✅ **Protocolos Críticos Completos** (2026-01-24/25)
- +21 protocolos críticos nuevos
- Total: 9 → 30 protocolos (50%)
- Incluye: Politrauma, TCE, Manejo Vía Aérea Básica, Ventilación Ambú, Shock Anafiláctico, Vía Aérea Definitiva, Trauma Torácico, Uso Torniquetes, EAP, Shock Cardiogénico, SCA Completo, Trauma Abdominal, Trauma Extremidades, Quemaduras, Lesión Medular, IRA, EPOC, Bradicardia, Taquicardia, Alteración Consciencia, Hipoglucemia
15. ✅ **Imágenes Críticas RCP/OVACE** (2026-01-24)
- +10 placeholders SVG funcionales
- Total: 14 → 24 imágenes
- Incluye: Algoritmo RCP comentado, RCP paso a paso, Posición manos, Profundidad, DESA, OVACE adulto/pediátrico/lactantes, RCP pediátrica, Tabla constantes vitales
## ✅ 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.
**Frontend**: Funcionalidades core implementadas (favoritos, historial, error handling, disclaimer).
**Contenido Médico**: 60 protocolos operativos ✅ COMPLETO (100%)
**Guías de Refuerzo**: 10 guías × 8 secciones = 80 archivos ✅
**Vademécum**: 35 fármacos (100%) ✅ COMPLETO - +29 nuevos añadidos hoy
**Medios Visuales**: 24 placeholders ✅ +10 nuevas hoy (13 ABCDE + 5 RCP + 5 OVACE + 1 tabla)
**Legal**: Disclaimer implementado y visible ✅
**Compilación**: Sin errores de linter ✅
**Bloqueadores**: 1 de 3 activos (solo validación médica)
**MVP Core**: ✅ FUNCIONAL | ✅ Contenido core 70%
**Última actualización**: 2026-01-25 02:00 (sesión completa: 40 protocolos + VADEMÉCUM 100% + imágenes críticas)
## 📊 INVENTARIO COMPLETO ACTUALIZADO
Ver documentos de inventario detallado:
- `docs/INDICE_MAESTRO_COMPLETO.md` - Lista completa de protocolos, guías y fármacos
- `docs/QUE_FALTA_RESUMEN.md` - Resumen ejecutivo de lo que falta
- `docs/INDICE_VISUAL_COMPLETO.md` - Índice visual con progreso
- `docs/TOP_20_IMAGENES_PRIORITARIAS.md` - 20 imágenes críticas priorizadas
**Progreso real verificado:**
- Protocolos operativos: 40/60 (67%) ✅ +31 nuevos críticos hoy
- Guías de refuerzo: 10/40 (25%) - 80 archivos markdown
- Fármacos: 35/35 (100%) ✅ COMPLETO - +29 nuevos añadidos hoy
- Imágenes críticas: 24 placeholders ✅ +10 nuevas hoy
- **Progreso general: ~60% del contenido planificado**
## 📁 ARCHIVOS IMPORTANTES
- `backend/.env` - Configuración de base de datos
- `backend/crear-usuario-y-bd.sh` - Script para crear usuario (EJECUTAR ESTE)
- `database/migrations/001_create_schema.sql` - Esquema de tablas
- `database/migrations/002_create_functions.sql` - Funciones y triggers

View file

@ -1,98 +0,0 @@
# 📁 Estructura del Proyecto guia-tes
## Carpetas Principales
```
guia-tes/
├── 📂 assets/ # Recursos multimedia (imágenes, videos, slides)
│ ├── checklists_app/
│ ├── consent_privacy/
│ ├── images/ # Imágenes organizadas por bloques (bloque_00 a bloque_08)
│ ├── slides/ # Presentaciones organizadas por bloques
│ ├── templates/
│ └── videos/ # Videos organizados por bloques
├── 📂 src/ # Código fuente de la aplicación React
│ ├── components/ # Componentes React organizados por categoría
│ │ ├── communication-scripts/
│ │ ├── content/
│ │ ├── decision-trees/
│ │ ├── drugs/
│ │ ├── layout/
│ │ ├── manual/
│ │ ├── material-checklists/
│ │ ├── procedures/
│ │ ├── references/
│ │ ├── shared/
│ │ ├── telephone-protocols/
│ │ ├── tools/
│ │ └── ui/
│ ├── data/ # Datos y configuraciones
│ ├── hooks/ # Custom React hooks
│ ├── lib/ # Utilidades de librerías
│ ├── pages/ # Páginas principales de la aplicación
│ └── utils/ # Funciones utilitarias
├── 📂 public/ # Archivos públicos estáticos
│ ├── assets/ # Recursos públicos (diagramas, infografías)
│ └── manual/ # Archivos Markdown del manual (93 archivos)
│ ├── BLOQUE_0_FUNDAMENTOS/
│ ├── BLOQUE_1_PROCEDIMIENTOS_BASICOS/
│ ├── BLOQUE_2_MATERIAL_E_INMOVILIZACION/
│ ├── BLOQUE_3_MATERIAL_SANITARIO_Y_OXIGENOTERAPIA/
│ ├── BLOQUE_4_SOPORTE_VITAL_BASICO_Y_RCP/
│ ├── BLOQUE_5_PROTOCOLOS_TRANSTELEFONICOS/
│ ├── BLOQUE_6_FARMACOLOGIA/
│ ├── BLOQUE_7_CONDUCCION_Y_SEGURIDAD_VIAL/
│ ├── BLOQUE_8_GESTION_OPERATIVA_Y_DOCUMENTACION/
│ ├── BLOQUE_9_MEDICINA_EMERGENCIAS_APLICADA/
│ ├── BLOQUE_10_SITUACIONES_ESPECIALES/
│ ├── BLOQUE_11_PROTOCOLOS_TRAUMA/
│ ├── BLOQUE_12_MARCO_LEGAL_ETICO_PROFESIONAL/
│ ├── BLOQUE_13_COMUNICACION_RELACION_PACIENTE/
│ ├── BLOQUE_14_SEGURIDAD_PERSONAL_SALUD_TES/
│ └── BLOQUE_15_ALTERACIONES_PSIQUIATRICAS_Y_CONTENCION/
├── 📂 scripts/ # Scripts de utilidad y automatización
├── 📂 docs/ # Documentación del proyecto
│ ├── archivo/
│ └── consolidado/
├── 📂 dist/ # Archivos compilados para producción
└── 📂 node_modules/ # Dependencias de Node.js (no editar)
```
## Archivos Principales en la Raíz
- `package.json` - Configuración del proyecto y dependencias
- `vite.config.ts` - Configuración de Vite (build tool)
- `tsconfig.json` - Configuración de TypeScript
- `tailwind.config.ts` - Configuración de Tailwind CSS
- `index.html` - Punto de entrada HTML
- `manifest.json` - Configuración PWA
- Scripts de despliegue: `deploy.sh`, `docker.sh`
- Scripts de limpieza: `cleanup.sh`
- Scripts de utilidad: `integrate-assets.py`, `generate-docs.py`
## Estadísticas
- **Total archivos:** ~1,232
- **Total carpetas:** ~229
- **Archivos del manual:** 93 archivos .md
- **Componentes React:** ~85 componentes
## Cómo Ver la Estructura
1. **Desde la terminal:**
```bash
tree -L 2
```
2. **Desde el explorador de archivos:**
- Abre la carpeta `/home/planetazuzu/guia-tes`
- Si no ves carpetas ocultas, presiona `Ctrl+H` para mostrarlas
3. **Ver este archivo:**
```bash
cat ESTRUCTURA.md
```

View file

@ -1,93 +0,0 @@
═══════════════════════════════════════════════════════════
ESTRUCTURA DEL PROYECTO: guia-tes
═══════════════════════════════════════════════════════════
📁 CARPETAS PRINCIPALES:
───────────────────────────────────────────────────────────
📂 assets/ (0 archivos, 136K)
📂 dist/ (186 archivos, 12M)
📂 docs/ (17 archivos, 232K)
📂 node_modules/ (26629 archivos, 322M)
📂 public/ (149 archivos, 10M)
📂 scripts/ (12 archivos, 116K)
📂 src/ (137 archivos, 1,3M)
📄 ARCHIVOS PRINCIPALES EN LA RAÍZ:
───────────────────────────────────────────────────────────
📄 abrir-carpeta.sh (1,2K)
📄 cleanup_completo.sh (16K)
📄 components.json (414)
📄 deploy.sh (4,0K)
📄 docker-compose.prod.yml (995)
📄 docker-compose.yml (654)
📄 ecosystem.config.js (852)
📄 eslint.config.js (765)
📄 manifest.json (33K)
📄 package.json (3,3K)
📄 package-lock.json (339K)
📄 postcss.config.js (81)
📄 README.md (1,3K)
📄 reorganizar_proyecto.sh (3,0K)
📄 tailwind.config.ts (3,9K)
📄 tsconfig.app.json (652)
📄 tsconfig.json (369)
📄 tsconfig.node.json (481)
📄 vite.config.ts (6,2K)
📄 vite-plugin-manifest.ts (2,1K)
📄 webhook-deploy.sh (1,4K)
═══════════════════════════════════════════════════════════
ESTRUCTURA DETALLADA DE CARPETAS IMPORTANTES
═══════════════════════════════════════════════════════════
📁 src/ (código fuente):
src
src/components
src/components/communication-scripts
src/components/content
src/components/decision-trees
src/components/drugs
src/components/layout
src/components/manual
src/components/material-checklists
src/components/procedures
src/components/references
src/components/shared
src/components/telephone-protocols
src/components/tools
src/components/ui
src/data
src/hooks
src/lib
src/pages
src/utils
📁 public/ (archivos públicos):
public
public/assets
public/assets/diagramas
public/assets/infografias
public/manual
public/manual/BLOQUE_0_FUNDAMENTOS
public/manual/BLOQUE_10_SITUACIONES_ESPECIALES
public/manual/BLOQUE_11_PROTOCOLOS_TRAUMA
public/manual/BLOQUE_12_MARCO_LEGAL_ETICO_PROFESIONAL
public/manual/BLOQUE_13_COMUNICACION_RELACION_PACIENTE
public/manual/BLOQUE_14_SEGURIDAD_PERSONAL_SALUD_TES
public/manual/BLOQUE_15_ALTERACIONES_PSIQUIATRICAS_Y_CONTENCION
public/manual/BLOQUE_1_PROCEDIMIENTOS_BASICOS
public/manual/BLOQUE_2_MATERIAL_E_INMOVILIZACION
public/manual/BLOQUE_3_MATERIAL_SANITARIO_Y_OXIGENOTERAPIA
public/manual/BLOQUE_4_SOPORTE_VITAL_BASICO_Y_RCP
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_Y_DOCUMENTACION
public/manual/BLOQUE_9_MEDICINA_EMERGENCIAS_APLICADA
═══════════════════════════════════════════════════════════
RESUMEN
═══════════════════════════════════════════════════════════
Total archivos: 1233
Total carpetas: 229

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,36 +0,0 @@
# 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.

224
README.md
View file

@ -1,45 +1,210 @@
# EMERGES TES - Protocolo Rápido # EMERGES TES - Guía Digital de Protocolos de Emergencias
Aplicación PWA para protocolos médicos de emergencia. **Aplicación web progresiva (PWA)** diseñada como herramienta de referencia rápida para **Técnicos de Emergencias Sanitarias (TES)** y profesionales de emergencias médicas.
## 🚑 Características ---
- **Protocolos de emergencia** (RCP, vía aérea, shock, etc.) ## 🎯 Objetivo Funcional
- **Vademécum de fármacos** con dosis, indicaciones y contraindicaciones
- **Calculadoras médicas** (Glasgow, perfusiones) **EMERGES TES** es un **socio cognitivo** que reduce la carga cognitiva en emergencias médicas proporcionando:
- **Guías de actuación en escena** (seguridad, ABCDE, triage)
- **Diseño optimizado para móvil** y uso nocturno - ✅ Acceso rápido a información crítica (< 2 clics)
- **Funciona offline** (PWA) - ✅ Protocolos operativos estructurados (RCP, vía aérea, shock, etc.)
- ✅ Vademécum de fármacos con dosis, indicaciones y contraindicaciones
- ✅ Calculadoras médicas (Glasgow, perfusiones, dosis pediátricas)
- ✅ Guías formativas asociadas a protocolos
- ✅ Funcionalidad **offline-first** (funciona sin conexión)
- ✅ Diseño optimizado para uso bajo presión y estrés
**No es:**
- ❌ Un sistema de diagnóstico automático
- ❌ Una herramienta de IA que toma decisiones clínicas
- ❌ Un sustituto de la formación reglada del profesional
- ❌ Un reemplazo del criterio clínico
---
## 📊 Estado Actual del Proyecto
**Estado:** En desarrollo activo
### ✅ Completado
- **Frontend PWA:** React 19 + TypeScript, funcional con Service Worker
- **Backend API:** Express + PostgreSQL con Clean Architecture
- **Protocolos:** 50+ protocolos operativos estructurados
- **Fármacos:** 100+ fármacos con dosis y especificaciones
- **Guías formativas:** Guías de refuerzo asociadas a protocolos
- **Herramientas clínicas:** Checklists, calculadoras, pathways
- **Validación médica:** Workflow completo de revisión y aprobación
- **Glosario:** Backend completo con ~74 términos migrados
- **Medios:** Sistema de gestión de imágenes/vídeos/documentos
- **Tests:** Tests unitarios backend (servicios) y tests integración API
### ⚠️ En Progreso / Pendiente
- **Frontend glosario:** La app aún no consume `GET /api/glossary` (usa datos locales)
- **Cobertura frontend:** Objetivo 80% (en aumento)
- **Contenido:** Categoría "Escena" vacía en protocolos (ver `docs/CONTENIDO_FALTANTE.md`)
**Documentación detallada:** Ver `docs/QUE_FALTA.md` y `docs/CONTENIDO_FALTANTE.md`
---
## ⚠️ ACLARACIÓN FUNDAMENTAL: ¿Qué son los "Tickets"?
### Los tickets NO son funcionalidad de negocio
**IMPORTANTE:** En este proyecto, los **"tickets"** (TICKET-001, TICKET-002, etc.) **NO** son una funcionalidad de negocio.
- ❌ **NO existe** un sistema de tickets de soporte, incidencias o solicitudes de usuarios
- ❌ **NO hay** entidades llamadas "tickets" en el código
- ❌ **NO hay** lógica de negocio asociada a tickets
### Los tickets son tareas técnicas de desarrollo
Los tickets son **únicamente** una forma de dividir, organizar y seguir las **tareas pendientes de desarrollo** de la aplicación.
- ✅ Son equivalentes a **issues** o **tickets técnicos** de JIRA / GitHub
- ✅ Representan **tareas técnicas** o **pasos de desarrollo**
- ✅ Sirven para **planificación y seguimiento** del trabajo
- ✅ Están documentados en `docs/QUE_FALTA.md` y `docs/BACKLOG_MICRO_TICKETS.md`
**Ejemplo:** TICKET-013 significa "Implementar ValidationService para workflow de validación médica" (tarea técnica completada).
### Entidades reales del dominio
Las entidades reales del dominio de la aplicación son:
- **ContentItem:** Protocolos, guías, manuales, checklists
- **Drug:** Fármacos con especificaciones técnicas
- **GlossaryTerm:** Términos médicos del glosario
- **MediaResource:** Imágenes, vídeos, documentos
- **MedicalReview:** Revisiones médicas de contenido
**Ninguna de estas entidades se llama "ticket" ni tiene relación con tickets.**
### Si en el futuro se añade un sistema de tickets de negocio
Si en el futuro se añade un sistema de tickets de soporte/incidencias como **nueva funcionalidad**, deberá tratarse como una **FEATURE independiente**, no implementada actualmente.
---
## 🛠️ Stack Tecnológico ## 🛠️ Stack Tecnológico
- **React 18** + **TypeScript 5.8** ### Frontend
- **Vite 5.4** - Build tool - **React 19** + **TypeScript 5.8**
- **Vite 7** - Build tool
- **Tailwind CSS 3.4** + **shadcn/ui** - UI Framework - **Tailwind CSS 3.4** + **shadcn/ui** - UI Framework
- **React Router 6.3** - Navegación SPA - **React Router 6.3** - Navegación SPA
- **PWA** - Service Worker + Manifest - **PWA** - Service Worker + Manifest
- **Vitest** - Testing
## 📦 Instalación ### Backend
- **Node.js** + **TypeScript**
- **Express 4.18** - Framework web
- **PostgreSQL** - Base de datos relacional
- **Redis** - Caché (opcional)
- **Zod** - Validación de esquemas
- **Vitest** + **Supertest** - Testing
### Arquitectura
- **Clean Architecture** en backend (Domain → Application → Infrastructure → Presentation)
- **Arquitectura funcional React** en frontend
- **Type Safety estricto** (sin `any`)
---
## 🚀 Instalación y Ejecución
### Requisitos previos
- Node.js 20+
- PostgreSQL 14+
- Redis (opcional, para caché)
### Instalación
```bash ```bash
# Clonar repositorio
git clone [url-del-repositorio]
cd guia-tes
# Instalar dependencias frontend
npm install npm install
npm run dev # Desarrollo (localhost:8096)
npm run build # Producción # Instalar dependencias backend
cd backend
npm install
cd ..
``` ```
## 🚀 Despliegue Principal ### Ejecución
- **Servidor:** PM2 en puerto 8607 #### Solo frontend (desarrollo)
- **Docker:** `docker-compose up --build` ```bash
- **CI/CD:** GitHub Actions npm run dev
# Abre en http://localhost:8096
```
#### Frontend + Backend local (con Docker)
```bash
npm run dev:local
# Inicia PostgreSQL + Redis en Docker
# Frontend: http://localhost:8096
# Backend: http://localhost:3000
```
#### Backend solo
```bash
cd backend
npm run dev
# Backend en http://localhost:3000
```
### Build producción
```bash
# Frontend
npm run build
# Backend
cd backend
npm run build
```
**Documentación detallada:** Ver `docs/DESPLIEGUE_LOCAL.md` para configuración completa con Docker.
---
## 📚 Documentación ## 📚 Documentación
Ver `docs/consolidado/` para documentación completa: ### Documentación principal
- Despliegue (Docker, PM2, GitHub Actions) - **`SPEC.md`** - Especificación maestra del proyecto (fuente de verdad)
- PWA y Service Worker - **`.cursorrules`** - Reglas de desarrollo y arquitectura
- Estado de funcionalidades - **`docs/QUE_FALTA.md`** - Estado de tickets técnicos y tareas pendientes
- Análisis técnico - **`docs/CONTENIDO_FALTANTE.md`** - Contenido faltante (protocolos, guías, glosario)
### Documentación para desarrolladores
- **`README_DEV.md`** - Reglas de desarrollo y principios
- **`README_ARCHITECTURE.md`** - Arquitectura del sistema
- **`README_TODO.md`** - Tareas pendientes con prioridades
### Documentación técnica
- **`docs/BACKLOG_MICRO_TICKETS.md`** - Backlog de fases ejecutadas
- **`docs/ANDRAGOGIA_STRESS_READY_112.md`** - Principios de diseño UX
- **`docs/CHECKLIST_ANTES_ACEPTAR_CAMBIOS.md`** - Checklist de calidad
---
## 🔄 Evolución de la Arquitectura
**Nota importante:** La arquitectura puede evolucionar según las necesidades del proyecto.
- Las decisiones arquitectónicas están documentadas en `SPEC.md` y `.cursorrules`
- Cualquier cambio arquitectónico debe documentarse explícitamente
- Se prioriza la mantenibilidad y claridad del código
---
## 📄 Licencia ## 📄 Licencia
@ -47,6 +212,15 @@ Ver `docs/consolidado/` para documentación completa:
--- ---
## 👥 Contribución
Este proyecto está en desarrollo activo. Para contribuir:
1. Leer `README_DEV.md` para reglas de desarrollo
2. Revisar `README_ARCHITECTURE.md` para entender la arquitectura
3. Consultar `README_TODO.md` para tareas pendientes
4. Seguir las reglas definidas en `.cursorrules`
---
**Desarrollado para Técnicos de Emergencias Sanitarias** **Desarrollado para Técnicos de Emergencias Sanitarias**

401
README_ARCHITECTURE.md Normal file
View file

@ -0,0 +1,401 @@
# Arquitectura del Sistema - EMERGES TES
**Arquitectura actual, componentes principales y entidades del dominio.**
**Última actualización:** 2025-02-02
---
## 🏗️ Arquitectura General
### Estructura del Proyecto
```
guia-tes/
├── src/ # Frontend (React + TypeScript)
├── backend/ # Backend (Express + PostgreSQL)
├── admin-panel/ # Panel de administración (React)
├── public/ # Assets estáticos, manual
├── docs/ # Documentación
└── scripts/ # Scripts de utilidad
```
### Separación Frontend / Backend
- **Frontend:** Aplicación PWA independiente, funciona offline
- **Backend:** API REST para gestión de contenido (admin)
- **Admin Panel:** Interfaz de administración separada
---
## 🎯 Arquitectura Backend (Clean Architecture)
### Estructura de Capas
```
backend/src/
├── domain/ # Domain Layer (NO depende de nadie)
│ ├── entities/ # Entidades de dominio
│ ├── value-objects/ # Value Objects
│ └── repositories/ # Interfaces de repositorios
├── application/ # Application Layer (depende de Domain)
│ └── services/ # Servicios de aplicación
├── infrastructure/ # Infrastructure Layer (depende de Domain + Application)
│ ├── repositories/ # Implementaciones de repositorios
│ ├── mappers/ # Mappers Domain ↔ Persistence
│ └── database/ # Configuración BD
├── presentation/ # Presentation Layer (depende de Application + Domain)
│ └── routes/ # Rutas Express (API endpoints)
├── shared/ # Código compartido
│ ├── schemas/ # Schemas Zod
│ └── errors/ # Errores personalizados
└── middleware/ # Middleware Express
```
### Reglas de Dependencias
```
Domain Layer
Application Layer
Infrastructure Layer
Presentation Layer
```
**Regla:** Las dependencias apuntan siempre hacia adentro (hacia el Dominio).
- ✅ **Domain:** NO depende de nadie
- ✅ **Application:** Solo depende de Domain
- ✅ **Infrastructure:** Depende de Domain y Application
- ✅ **Presentation:** Depende de Application y Domain
---
## 📦 Componentes Principales
### 1. Domain Layer
#### Entidades de Dominio
**ContentItem** (`domain/entities/ContentItem.ts`)
- Protocolos, guías, manuales, checklists
- Estados: `draft`, `in_review`, `approved`, `published`, `archived`
- Ciclo de vida: Validación médica (submit → approve/reject → publish)
**Drug** (`domain/entities/Drug.ts`)
- Fármacos con especificaciones técnicas
- Categorías: cardiovascular, respiratorio, neurológico, etc.
- Dosis adulto/pediátrica, vías de administración
**GlossaryTerm** (`domain/entities/GlossaryTerm.ts`)
- Términos médicos del glosario
- Categorías: pharmaceutical, anatomical, clinical, procedural
- Relaciones con otros términos
**MediaResource** (`domain/entities/MediaResource.ts`)
- Imágenes, vídeos, documentos
- Asociación a bloques/capítulos del manual
- Metadatos (título, descripción, alt text)
**MedicalReview** (`domain/entities/MedicalReview.ts`)
- Revisiones médicas de contenido
- Estados: `pending`, `approved`, `rejected`
- Comentarios y validación clínica
#### Value Objects
**ContentStatus** (`domain/value-objects/ContentStatus.ts`)
- Estados válidos y transiciones permitidas
- Métodos: `canTransitionTo()`, `fromString()`
**ContentPriority** (`domain/value-objects/ContentPriority.ts`)
- Prioridades: `critica`, `alta`, `media`, `baja`
#### Repository Interfaces
- `IContentRepository` - Operaciones CRUD de contenido
- `IDrugRepository` - Operaciones CRUD de fármacos
- `IGlossaryRepository` - Operaciones CRUD de glosario
- `IValidationRepository` - Operaciones de validación médica
- `IMediaRepository` - Operaciones CRUD de medios
### 2. Application Layer
#### Servicios de Aplicación
**ContentService** (`application/services/ContentService.ts`)
- Lógica de negocio para contenido
- Validaciones complejas (unicidad, dependencias)
- Orquestación de casos de uso
**GlossaryService** (`application/services/GlossaryService.ts`)
- Lógica de negocio para glosario
- Búsqueda y filtrado
- Validaciones de términos
**ValidationService** (`application/services/ValidationService.ts`)
- Workflow de validación médica
- Transiciones de estado usando `ContentStatus`
- Registro de auditoría
**StatsService** (`application/services/StatsService.ts`)
- Estadísticas de contenido, validación, medios
- Caché de estadísticas
**DrugService** (`application/services/DrugService.ts`)
- Lógica de negocio para fármacos
- Validaciones y búsqueda
### 3. Infrastructure Layer
#### Repositorios (Implementaciones)
- `ContentRepository` - PostgreSQL para contenido
- `DrugRepository` - PostgreSQL para fármacos
- `GlossaryRepository` - PostgreSQL para glosario
- `ValidationRepository` - PostgreSQL para validación
- `MediaRepository` - PostgreSQL para medios
#### Mappers
- `ContentItemMapper` - Domain ↔ Persistence
- `DrugMapper` - Domain ↔ Persistence
- `GlossaryTermMapper` - Domain ↔ Persistence
**Regla:** Los mappers validan con Zod antes de convertir a dominio.
### 4. Presentation Layer
#### Rutas API (`presentation/routes/`)
- `content.ts` - CRUD de contenido (`/api/content/*`)
- `drugs.ts` - CRUD de fármacos (`/api/drugs/*`)
- `glossary.ts` - CRUD de glosario (`/api/glossary/*`)
- `validation.ts` - Validación médica (`/api/validation/*`)
- `media.ts` - Gestión de medios (`/api/media/*`)
- `stats.ts` - Estadísticas (`/api/stats/*`)
#### Middleware
- `auth.ts` - Autenticación JWT
- `validate.ts` - Validación de requests con Zod
- `rate-limit.ts` - Rate limiting
---
## 🎨 Arquitectura Frontend
### Estructura
```
src/
├── components/ # Componentes React
│ ├── content/ # Componentes de contenido
│ ├── drugs/ # Componentes de fármacos
│ ├── guide/ # Componentes de guías
│ ├── interactive/ # Componentes interactivos
│ └── ui/ # Componentes UI (shadcn/ui)
├── pages/ # Páginas/rutas
├── hooks/ # Hooks personalizados
├── services/ # Servicios (adaptadores, búsqueda)
├── data/ # Datos estáticos (protocolos, fármacos)
├── utils/ # Utilidades
└── types/ # Tipos TypeScript
```
### Patrones
- **Arquitectura funcional React** (no Clean Architecture estricta)
- **Componentes funcionales** con hooks
- **Datos estáticos embebidos** (offline-first)
- **Service Worker** para caché agresivo
---
## 🗄️ Base de Datos
### PostgreSQL
**Esquema:** `tes_content`
**Tablas principales:**
- `content_items` - Protocolos, guías, manuales
- `drugs` - Fármacos
- `glossary_terms` - Términos del glosario
- `media_resources` - Medios audiovisuales
- `audit_logs` - Logs de auditoría
- `users` - Usuarios (admin)
- `content_versions` - Versiones de contenido
- `content_resource_associations` - Asociaciones contenido-medios
**Migraciones:** `backend/database/migrations/`
---
## 🔄 Flujos Principales
### 1. Workflow de Validación Médica
```
ContentItem (draft)
↓ submit
ContentItem (in_review)
↓ approve/reject
ContentItem (approved/draft)
↓ publish (si approved)
ContentItem (published)
```
**Implementación:**
- `ValidationService` orquesta transiciones
- `ContentStatus` valida transiciones permitidas
- `ValidationRepository` persiste cambios
- `audit_logs` registra acciones
### 2. Gestión de Contenido
```
Admin crea/edita ContentItem
ContentService valida
ContentRepository persiste
Mapper convierte Domain ↔ Persistence
```
### 3. Búsqueda y Filtrado
```
Frontend → API endpoint
Service aplica lógica de negocio
Repository consulta PostgreSQL
Mapper convierte a Domain
Service retorna DTO
```
---
## 🔐 Seguridad
### Autenticación
- **JWT** para autenticación de usuarios admin
- **Roles:** `admin`, `reviewer`, `validator`
- **Middleware:** `authenticate`, `requirePermission`
### Validación
- **Zod** en todos los puntos de entrada (API, forms)
- **Validación de tipos** en runtime
- **Sanitización** de inputs
### Rate Limiting
- **express-rate-limit** en endpoints públicos
- **Límites** configurables por endpoint
---
## 🧪 Testing
### Backend
- **Tests unitarios:** Servicios con repos mockeados
- **Tests integración:** API con `supertest`
- **Cobertura objetivo:** 80%+
**Tests:**
- `backend/src/application/services/__tests__/`
- `backend/src/__tests__/api.integration.test.ts`
### Frontend
- **Tests unitarios:** Componentes, hooks, utils
- **Vitest** como framework de testing
- **Cobertura objetivo:** 80%
**Tests:**
- `src/**/*.test.ts`, `src/**/*.test.tsx`
---
## 📊 Entidades con Ciclo de Vida
### ContentItem
**Estados:** `draft``in_review``approved` / `published``archived`
**Transiciones:**
- `draft``in_review` (submit)
- `in_review``approved` (approve)
- `in_review``published` (approve + publish)
- `in_review``draft` (reject)
- `approved``published` (publish)
- Cualquier estado → `archived` (archive)
**Implementación:**
- `ContentStatus` value object valida transiciones
- `ValidationService` orquesta workflow
- `audit_logs` registra cambios
### MedicalReview
**Estados:** `pending``approved` / `rejected`
**Ciclo de vida:** Asociado a `ContentItem`, no independiente.
---
## ⚠️ Separación: Tickets vs Modelo de Dominio
### Tickets (Artefactos de Planificación)
- **TICKET-001** a **TICKET-019**: Tareas técnicas de desarrollo
- **NO son** entidades del dominio
- **NO tienen** representación en código
- **Documentados en:** `docs/QUE_FALTA.md`
### Modelo de Dominio (Entidades Reales)
- **ContentItem**, **Drug**, **GlossaryTerm**, **MediaResource**, **MedicalReview**
- **SÍ son** entidades del dominio
- **SÍ tienen** representación en código (`domain/entities/`)
- **Documentados en:** `SPEC.md`, este documento
---
## 🔮 Arquitectura Prevista (Evolución)
### Mantenimiento de Clean Architecture
- ✅ Separación de capas ya implementada
- ✅ Reglas de dependencias respetadas
- ⚠️ Posibles mejoras: más tests, más documentación
### Posibles Evoluciones
- **Event Sourcing:** Para auditoría completa (futuro)
- **CQRS:** Separar lectura/escritura si escala (futuro)
- **Microservicios:** Si el sistema crece significativamente (futuro)
**Nota:** Estas evoluciones deben documentarse en `SPEC.md` antes de implementarse.
---
## 📚 Referencias
- **Especificación maestra:** `SPEC.md`
- **Reglas de código:** `.cursorrules`
- **Tareas pendientes:** `README_TODO.md`
- **Guía de desarrollo:** `README_DEV.md`
---
**Última actualización:** 2025-02-02

289
README_DEV.md Normal file
View file

@ -0,0 +1,289 @@
# Guía de Desarrollo - EMERGES TES
**Reglas, principios y buenas prácticas para desarrolladores y asistentes de código.**
---
## 🎯 Objetivo de este Documento
Este documento establece las **reglas de desarrollo**, **principios a respetar** y **qué puede y qué no puede modificar** un asistente de código o desarrollador humano.
**Regla de oro:** Si una funcionalidad no está implementada ni documentada explícitamente, debe considerarse **inexistente**.
---
## 🚫 Normas Fundamentales
### 1. NO Asumir Funcionalidades Inexistentes
**CRÍTICO:** Un asistente de código **NO debe**:
- ❌ Asumir que existe una funcionalidad que no está documentada
- ❌ Inventar sistemas de negocio no implementados
- ❌ Crear entidades que no existen en el dominio
- ❌ Añadir features "porque sería útil" sin documentación previa
**Ejemplo incorrecto:**
```typescript
// ❌ INCORRECTO: Asumir que existe un sistema de tickets de soporte
interface SupportTicket {
id: string;
// ... esto NO existe en el dominio
}
```
**Ejemplo correcto:**
```typescript
// ✅ CORRECTO: Usar entidades que existen en el dominio
import type { ContentItem } from '../domain/entities/ContentItem.js';
```
### 2. NO Confundir Tickets con Entidades
**IMPORTANTE:** Los "tickets" (TICKET-001, TICKET-002, etc.) son **tareas técnicas de desarrollo**, NO entidades del dominio.
- ❌ **NO crear** entidades llamadas "Ticket"
- ❌ **NO añadir** lógica de negocio para "tickets"
- ✅ **SÍ usar** tickets como referencia a tareas técnicas (ver `docs/QUE_FALTA.md`)
**Entidades reales del dominio:**
- `ContentItem` - Protocolos, guías, manuales
- `Drug` - Fármacos
- `GlossaryTerm` - Términos del glosario
- `MediaResource` - Medios audiovisuales
- `MedicalReview` - Revisiones médicas
### 3. NO Modificar Arquitectura sin Documentar
**Antes de cambiar la arquitectura:**
1. ✅ Documentar el cambio en `SPEC.md`
2. ✅ Actualizar `.cursorrules` si afecta reglas de código
3. ✅ Justificar el cambio con razones técnicas claras
---
## ✅ Qué SÍ Puede Hacer un Asistente
### 1. Implementar Funcionalidades Documentadas
Un asistente **SÍ puede** implementar funcionalidades que estén:
- ✅ Documentadas en `SPEC.md`
- ✅ Listadas en `docs/QUE_FALTA.md` como tarea pendiente
- ✅ Definidas en `README_TODO.md` con especificaciones claras
### 2. Mejorar Código Existente
Un asistente **SÍ puede**:
- ✅ Refactorizar código siguiendo `.cursorrules`
- ✅ Añadir tests para aumentar cobertura
- ✅ Corregir bugs documentados
- ✅ Optimizar rendimiento sin cambiar arquitectura
### 3. Añadir Tests
Un asistente **SÍ puede**:
- ✅ Crear tests unitarios para servicios
- ✅ Crear tests de integración para API
- ✅ Aumentar cobertura de tests frontend
- ✅ Añadir tests para casos edge
### 4. Documentar Código
Un asistente **SÍ puede**:
- ✅ Añadir comentarios JSDoc
- ✅ Documentar funciones complejas
- ✅ Actualizar READMEs con información precisa
- ✅ Crear documentación técnica
---
## ❌ Qué NO Puede Hacer un Asistente
### 1. Inventar Funcionalidades
**NO debe:**
- ❌ Crear sistemas de tickets de soporte sin documentación previa
- ❌ Añadir features no documentadas en SPEC.md
- ❌ Asumir funcionalidades "porque sería lógico"
- ❌ Crear entidades que no existen en el dominio
### 2. Cambiar Arquitectura sin Justificación
**NO debe:**
- ❌ Cambiar de Clean Architecture a otra arquitectura sin documentar
- ❌ Modificar reglas de dependencias entre capas sin justificar
- ❌ Introducir frameworks nuevos sin evaluar impacto
### 3. Ignorar Reglas de Código
**NO debe:**
- ❌ Usar `any` en TypeScript (prohibido en `.cursorrules`)
- ❌ Mutar objetos directamente (inmutabilidad requerida)
- ❌ Saltarse validación Zod en puntos de entrada
- ❌ Crear funciones de más de 30 líneas sin dividir
### 4. Modificar Entidades sin Entender el Dominio
**NO debe:**
- ❌ Cambiar nombres de entidades sin justificar
- ❌ Añadir campos a entidades sin documentar en SPEC.md
- ❌ Eliminar campos de entidades sin verificar dependencias
---
## 📋 Principios de Desarrollo
### 1. Type Safety Estricto
- ✅ **PROHIBIDO** el uso de `any`
- ✅ Todos los tipos deben ser explícitos
- ✅ Usar tipos del dominio (`ContentItem`, `Drug`, etc.)
### 2. Inmutabilidad
- ✅ Todas las propiedades de entidades son `readonly`
- ✅ No mutar objetos directamente
- ✅ Crear nuevos objetos en lugar de modificar existentes
### 3. Validación Estricta
- ✅ Validar todos los inputs con Zod
- ✅ Validaciones básicas en entidades (`create()`)
- ✅ Validaciones complejas en servicios
### 4. Clean Architecture (Backend)
- ✅ Domain Layer: NO depende de nadie
- ✅ Application Layer: Solo depende de Domain
- ✅ Infrastructure Layer: Depende de Domain y Application
- ✅ Presentation Layer: Depende de Application y Domain
### 5. Early Returns
- ✅ Usar guard clauses para errores
- ✅ Evitar anidación profunda
- ✅ Retornar temprano en casos de error
### 6. Funciones Pequeñas
- ✅ Máximo 20-30 líneas por función
- ✅ Una sola responsabilidad
- ✅ Nombres descriptivos
---
## 🔍 Instrucciones para Trabajar con Asistentes de IA
### Para el Desarrollador Humano
1. **Proporcionar contexto claro:**
- Especificar qué funcionalidad implementar
- Referenciar documentación relevante (SPEC.md, tickets)
- Indicar restricciones arquitectónicas
2. **Validar propuestas:**
- Revisar que no invente funcionalidades
- Verificar que respete `.cursorrules`
- Comprobar que no cambie arquitectura sin justificar
3. **Revisar código generado:**
- Verificar tipos TypeScript (sin `any`)
- Comprobar validación Zod en inputs
- Revisar que siga Clean Architecture
### Para el Asistente de IA
1. **Antes de proponer cambios:**
- Leer `SPEC.md` para entender el dominio
- Revisar `.cursorrules` para reglas de código
- Consultar `README_ARCHITECTURE.md` para arquitectura
- Verificar `docs/QUE_FALTA.md` para tareas pendientes
2. **Al proponer funcionalidades nuevas:**
- Documentar en SPEC.md primero
- Justificar la necesidad
- Explicar impacto arquitectónico
3. **Al implementar código:**
- Seguir `.cursorrules` estrictamente
- Usar tipos del dominio existentes
- Añadir validación Zod donde corresponda
- Crear tests para código nuevo
---
## ⚠️ Advertencias Importantes
### 1. No Introducir Sistemas de Negocio No Documentados
**Ejemplo de lo que NO hacer:**
```typescript
// ❌ INCORRECTO: Inventar sistema de tickets de soporte
interface SupportTicket {
id: string;
userId: string;
// ... esto NO existe en el dominio
}
```
**Si se necesita esta funcionalidad:**
1. Documentar en `SPEC.md` primero
2. Definir entidades en `domain/entities/`
3. Crear repositorios y servicios
4. Implementar API endpoints
5. Añadir tests
### 2. No Asumir Funcionalidades por Nombre
**Ejemplo:**
- Ver "TICKET-013" en código → NO significa que existe entidad "Ticket"
- Ver "validation" → Verificar qué significa en el contexto (puede ser validación médica de contenido, no tickets)
### 3. Verificar Antes de Modificar
**Antes de modificar entidades:**
1. Buscar todas las referencias en el código
2. Verificar dependencias en otros módulos
3. Comprobar tests que puedan romperse
4. Documentar cambios en SPEC.md si son significativos
---
## 📚 Referencias
- **`SPEC.md`** - Especificación maestra (fuente de verdad)
- **`.cursorrules`** - Reglas de código y arquitectura
- **`README_ARCHITECTURE.md`** - Arquitectura detallada
- **`docs/QUE_FALTA.md`** - Tareas pendientes
- **`docs/CONTENIDO_FALTANTE.md`** - Contenido faltante
---
## ✅ Checklist Antes de Aceptar Cambios
Antes de aceptar cambios propuestos por un asistente:
- [ ] ¿Respeta `.cursorrules`?
- [ ] ¿No inventa funcionalidades no documentadas?
- [ ] ¿No usa `any` en TypeScript?
- [ ] ¿Valida inputs con Zod?
- [ ] ¿Sigue Clean Architecture (backend)?
- [ ] ¿Añade tests para código nuevo?
- [ ] ¿Documenta cambios significativos en SPEC.md?
---
**Última actualización:** 2025-02-02

272
README_FUTURE.md Normal file
View file

@ -0,0 +1,272 @@
# Ideas y Funcionalidades Futuras - EMERGES TES
**Ideas o funcionalidades futuras que NO están implementadas actualmente.**
**Última actualización:** 2025-02-02
---
## ⚠️ ACLARACIÓN IMPORTANTE
**Todas las funcionalidades listadas aquí NO están implementadas.**
- ❌ **NO existen** en el código actual
- ❌ **NO están** documentadas en `SPEC.md` como funcionalidades actuales
- ❌ **NO deben** asumirse como existentes por asistentes de código
**Si se decide implementar alguna de estas ideas:**
1. Documentar en `SPEC.md` primero
2. Definir entidades en `domain/entities/` si aplica
3. Crear repositorios y servicios
4. Implementar API endpoints
5. Añadir tests
6. Actualizar este documento
---
## 💡 Ideas Futuras
### 1. Sistema de Notificaciones
**Descripción:** Sistema para notificar a usuarios sobre actualizaciones de protocolos, nuevos fármacos, etc.
**Estado:** NO implementado
**Componentes potenciales:**
- Entidad `Notification` (no existe)
- Servicio de notificaciones (no existe)
- Endpoints de notificaciones (no existen)
- Frontend de notificaciones (no existe)
**Consideraciones:**
- Requeriría sistema de usuarios más completo
- Podría usar Service Worker para notificaciones push
- Necesitaría definir qué eventos generan notificaciones
---
### 2. Sistema de Favoritos/Bookmarks
**Descripción:** Permitir a usuarios marcar protocolos/fármacos como favoritos para acceso rápido.
**Estado:** Parcialmente implementado en frontend (`useFavorites.ts`), pero sin persistencia backend
**Componentes actuales:**
- Hook `useFavorites` en frontend (localStorage)
- **NO existe** entidad `Favorite` en backend
- **NO existe** API para sincronizar favoritos
**Consideraciones:**
- Requeriría sistema de usuarios autenticados
- Podría sincronizar favoritos entre dispositivos
- Necesitaría migración de datos si usuarios ya tienen favoritos locales
---
### 3. Sistema de Comentarios/Anotaciones
**Descripción:** Permitir a usuarios añadir comentarios o anotaciones personales a protocolos/fármacos.
**Estado:** NO implementado
**Componentes potenciales:**
- Entidad `UserAnnotation` (no existe)
- Servicio de anotaciones (no existe)
- Endpoints de anotaciones (no existen)
- Frontend de anotaciones (no existe)
**Consideraciones:**
- Requeriría sistema de usuarios
- Podría ser útil para notas personales del profesional
- Necesitaría definir privacidad (personales vs compartidas)
---
### 4. Sistema de Versiones y Historial de Cambios
**Descripción:** Sistema completo de versionado con historial detallado de cambios y posibilidad de revertir.
**Estado:** Parcialmente implementado (`content_versions`, `content_change_log`), pero sin UI completa
**Componentes actuales:**
- Tabla `content_versions` en BD
- Tabla `content_change_log` en BD
- **NO existe** UI para ver historial completo
- **NO existe** funcionalidad de revertir versiones
**Consideraciones:**
- Ya existe infraestructura de BD
- Necesitaría UI en admin panel
- Podría añadir comparación de versiones (diff)
---
### 5. Sistema de Estadísticas de Uso
**Descripción:** Tracking de qué protocolos/fármacos se consultan más, tiempo de uso, etc.
**Estado:** NO implementado
**Componentes potenciales:**
- Entidad `UsageLog` (no existe)
- Servicio de analytics (no existe)
- Endpoints de tracking (no existen)
- Dashboard de estadísticas (no existe)
**Consideraciones:**
- Requeriría definir qué eventos trackear
- Necesitaría considerar privacidad (datos anonimizados)
- Podría usar herramientas externas (Google Analytics) o propio sistema
---
### 6. Sistema de Exportación/Importación
**Descripción:** Permitir exportar/importar contenido en formatos estándar (JSON, CSV, etc.).
**Estado:** Parcialmente implementado (scripts de migración), pero sin UI
**Componentes actuales:**
- Scripts de migración (`backend/scripts/migrate-*.js`)
- **NO existe** UI para exportar/importar desde admin panel
- **NO existe** API para exportar/importar
**Consideraciones:**
- Ya existe lógica de migración en scripts
- Podría exponerse como funcionalidad del admin panel
- Necesitaría validación de datos importados
---
### 7. Sistema de Búsqueda Avanzada
**Descripción:** Búsqueda con filtros avanzados, búsqueda semántica, sugerencias, etc.
**Estado:** Búsqueda básica implementada, avanzada NO
**Componentes actuales:**
- Búsqueda básica en frontend (`useSearch.ts`)
- Búsqueda básica en backend (`GlossaryService.search()`)
- **NO existe** búsqueda semántica
- **NO existe** búsqueda con filtros avanzados en UI
**Consideraciones:**
- Podría usar herramientas de búsqueda full-text de PostgreSQL
- Podría integrar búsqueda semántica con embeddings
- Necesitaría definir qué campos son buscables
---
### 8. Sistema de Colaboración en Contenido
**Descripción:** Múltiples usuarios editando contenido simultáneamente, comentarios en revisiones, etc.
**Estado:** NO implementado
**Componentes potenciales:**
- Sistema de locks para edición simultánea (no existe)
- Comentarios en revisiones (no existe)
- Notificaciones de cambios (no existe)
**Consideraciones:**
- Requeriría sistema de usuarios más completo
- Podría usar WebSockets para colaboración en tiempo real
- Necesitaría definir permisos de edición
---
### 9. Sistema de Certificaciones/Formación
**Descripción:** Sistema para certificar que usuarios han completado formación sobre protocolos.
**Estado:** NO implementado
**Componentes potenciales:**
- Entidad `Certification` (no existe)
- Entidad `Course` (no existe)
- Sistema de quizzes/evaluaciones (no existe)
**Consideraciones:**
- Requeriría sistema de usuarios completo
- Podría integrar con SCORM (ya existe generador SCORM)
- Necesitaría definir qué certifica cada curso
---
### 10. Sistema de Alertas/Recordatorios
**Descripción:** Alertas sobre cambios en protocolos que el usuario usa frecuentemente, recordatorios de formación, etc.
**Estado:** NO implementado
**Componentes potenciales:**
- Sistema de alertas (no existe)
- Sistema de recordatorios (no existe)
- Preferencias de usuario para alertas (no existe)
**Consideraciones:**
- Requeriría sistema de usuarios
- Podría usar Service Worker para notificaciones push
- Necesitaría definir qué eventos generan alertas
---
## 🔄 Funcionalidades Parcialmente Implementadas
### Favoritos
- ✅ Hook `useFavorites` en frontend (localStorage)
- ❌ Backend para sincronizar favoritos
- ❌ API para favoritos
**Para completar:** Añadir entidad `Favorite` en backend, API endpoints, sincronización.
### Versiones
- ✅ Tablas `content_versions` y `content_change_log` en BD
- ❌ UI para ver historial completo
- ❌ Funcionalidad de revertir versiones
**Para completar:** Añadir UI en admin panel, endpoints para historial, funcionalidad de revertir.
---
## 📋 Criterios para Implementar Ideas Futuras
Antes de implementar cualquier idea de esta lista:
1. ✅ **Documentar en SPEC.md** como funcionalidad nueva
2. ✅ **Definir entidades** en `domain/entities/` si aplica
3. ✅ **Crear repositorios y servicios** siguiendo Clean Architecture
4. ✅ **Implementar API endpoints** con validación Zod
5. ✅ **Añadir tests** (unitarios + integración)
6. ✅ **Actualizar este documento** marcando como implementado
---
## 🚫 Lo que NO Existe (y NO debe Asumirse)
### Sistemas NO Implementados
- ❌ Sistema de tickets de soporte/incidencias
- ❌ Sistema de mensajería entre usuarios
- ❌ Sistema de roles avanzados (más allá de admin/reviewer/validator)
- ❌ Sistema de permisos granulares por recurso
- ❌ Sistema de workflows personalizables
- ❌ Sistema de integraciones con sistemas externos (HL7, FHIR, etc.)
**Si se necesita alguno de estos sistemas:** Debe tratarse como nueva feature, documentarse en SPEC.md y seguir el proceso de desarrollo normal.
---
## 📚 Referencias
- **Especificación maestra:** `SPEC.md`
- **Arquitectura:** `README_ARCHITECTURE.md`
- **Tareas pendientes:** `README_TODO.md`
- **Guía de desarrollo:** `README_DEV.md`
---
**Última actualización:** 2025-02-02

198
README_TODO.md Normal file
View file

@ -0,0 +1,198 @@
# Tareas Pendientes - EMERGES TES
**Lista realista de tareas pendientes con prioridades y referencias a tickets técnicos.**
**Última actualización:** 2025-02-02
---
## ⚠️ ACLARACIÓN: Tickets vs Funcionalidades
**IMPORTANTE:** Los "tickets" (TICKET-XXX) son **tareas técnicas de desarrollo**, NO funcionalidades de negocio.
- Los tickets están documentados en `docs/QUE_FALTA.md`
- Los tickets técnicos completados: TICKET-001 a TICKET-019
- **NO existen** entidades "Ticket" en el código
- **NO existe** un sistema de tickets de soporte/incidencias
---
## 🔴 Prioridad Alta
### Ninguna tarea de prioridad alta pendiente
Todos los tickets técnicos críticos están completados. Ver `docs/QUE_FALTA.md` para detalles.
---
## 🟡 Prioridad Media
### 1. Frontend Glosario - Consumir API Backend
**Descripción:** La aplicación frontend aún no consume `GET /api/glossary`. Actualmente usa datos locales en `src/data/pharmaceutical-terminology.ts`.
**Estado:** Backend completo (API + ~74 términos migrados), frontend pendiente.
**Tareas:**
- [ ] Crear componente/página "Glosario" en frontend
- [ ] Implementar hook/service para consumir `GET /api/glossary`
- [ ] Migrar o unificar con datos locales si aplica
- [ ] Añadir tests para nuevo componente
**Referencias:**
- Backend API: `backend/src/routes/glossary.ts`
- Frontend datos locales: `src/data/pharmaceutical-terminology.ts`
- Migración backend: `backend/scripts/fixtures/glossary-migration.json`
**Impacto:** Mejora UX, unifica fuente de datos.
---
### 2. Cobertura Frontend - Objetivo 80%
**Descripción:** Aumentar cobertura de tests frontend al 80% (objetivo documentado).
**Estado:** Tests en aumento, cobertura actual por medir.
**Tareas:**
- [ ] Medir cobertura actual: `npm run test -- --run --coverage`
- [ ] Identificar componentes/hooks/utils sin tests
- [ ] Añadir tests para componentes críticos
- [ ] Añadir tests para hooks personalizados
- [ ] Añadir tests para utilidades
**Referencias:**
- Configuración tests: `vite.config.ts`
- Tests existentes: `src/**/*.test.ts`, `src/**/*.test.tsx`
- Objetivo: Documentado en TICKET-019
**Impacto:** Mayor confiabilidad del código, facilita refactorización.
---
## 🟢 Prioridad Baja / Futuro
### 3. Contenido - Categoría Escena en Protocolos
**Descripción:** La categoría "Escena" está vacía en la app (`src/data/procedures/categories/escena.ts`). El contenido existe en el manual pero no como protocolos operativos listados.
**Estado:** Contenido en manual, no listado en app.
**Tareas (contenido, no código):**
- [ ] Decidir si Escena debe tener protocolos operativos en la app
- [ ] Si sí: Añadir protocolos (Seguridad escena, ABCDE operativo, Triage START)
- [ ] Si no: Documentar que está vacía a propósito
**Referencias:**
- Manual: `public/manual/BLOQUE_01_2_ABCDE_OPERATIVO.md`, `BLOQUE_01_4_TRIAGE_START.md`
- App: `src/data/procedures/categories/escena.ts`
- Documentación: `docs/PROTOCOLOS_GUIAS_FALTANTES.md`, `docs/CONTENIDO_FALTANTE.md`
**Impacto:** Mejora navegación y acceso a contenido de escena.
---
### 4. Contenido - Ampliar Glosario
**Descripción:** Añadir más términos al glosario según el manual (otros bloques, términos clínicos no solo farmacológicos).
**Estado:** ~74 términos farmacológicos en backend, posibilidad de ampliar.
**Tareas (contenido, no código):**
- [ ] Revisar manual para identificar términos faltantes
- [ ] Definir categorías adicionales si aplica (clínico, escena, RCP)
- [ ] Añadir términos al backend mediante migración o panel admin
- [ ] Validar términos con expertos clínicos
**Referencias:**
- Backend: `backend/scripts/fixtures/glossary-migration.json`
- Schema: `backend/src/shared/schemas/glossary.ts`
- Documentación: `docs/CONTENIDO_FALTANTE.md`
**Impacto:** Glosario más completo, mejor referencia para usuarios.
---
### 5. Optimización - Performance Frontend
**Descripción:** Optimizar rendimiento del frontend (lazy loading, code splitting, bundle size).
**Estado:** Funcional, optimizaciones posibles.
**Tareas:**
- [ ] Analizar bundle size actual
- [ ] Implementar lazy loading para rutas pesadas
- [ ] Optimizar imágenes (WebP, lazy loading)
- [ ] Revisar y optimizar Service Worker cache strategy
**Referencias:**
- Configuración Vite: `vite.config.ts`
- Service Worker: `src/hooks/useServiceWorker.ts`
- Build: `npm run build`
**Impacto:** Mejor tiempo de carga, mejor experiencia offline.
---
### 6. Documentación - Actualizar SPEC.md
**Descripción:** Mantener SPEC.md actualizado con decisiones arquitectónicas recientes.
**Estado:** SPEC.md actualizado parcialmente, algunas decisiones recientes pueden faltar.
**Tareas:**
- [ ] Revisar cambios arquitectónicos recientes
- [ ] Documentar decisiones en SPEC.md
- [ ] Actualizar gaps identificados si aplica
- [ ] Sincronizar con `.cursorrules`
**Referencias:**
- SPEC.md: `SPEC.md`
- Cursor rules: `.cursorrules`
- Backlog: `docs/BACKLOG_MICRO_TICKETS.md`
**Impacto:** Documentación precisa, facilita onboarding.
---
## 📋 Separación: Tareas Técnicas vs Contenido
### Tareas Técnicas (Código)
Estas tareas requieren desarrollo de código:
- ✅ Frontend Glosario - Consumir API Backend
- ✅ Cobertura Frontend - Objetivo 80%
- ✅ Optimización - Performance Frontend
- ✅ Documentación - Actualizar SPEC.md
### Tareas de Contenido (Datos)
Estas tareas requieren añadir/editar contenido, no código:
- 📝 Contenido - Categoría Escena en Protocolos
- 📝 Contenido - Ampliar Glosario
**Nota:** Las tareas de contenido pueden requerir soporte técnico (p. ej. migraciones de BD), pero el trabajo principal es añadir datos.
---
## 🔗 Referencias
- **Tickets técnicos completados:** `docs/QUE_FALTA.md`
- **Contenido faltante:** `docs/CONTENIDO_FALTANTE.md`
- **Backlog de fases:** `docs/BACKLOG_MICRO_TICKETS.md`
- **Especificación maestra:** `SPEC.md`
---
## 📝 Notas
- Las tareas están ordenadas por prioridad (Alta → Media → Baja)
- Las tareas de contenido están marcadas explícitamente
- Las referencias a tickets técnicos están claramente separadas de funcionalidades de negocio
- Si una tarea no está aquí, no está pendiente (o está en fase de planificación)
---
**Última actualización:** 2025-02-02

View file

@ -1,191 +0,0 @@
# 🎛️ 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=tu_usuario_aqui
# DB_PASSWORD=tu_password_seguro_aqui
# 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`

View file

@ -1,184 +0,0 @@
# 📋 Resumen de Cambios - Fase 1: Refactorización y Limpieza
**Fecha:** 2025-01-25
**Tickets completados:** 1.1, 1.2, 1.3, 1.4, 1.5
---
## ✅ Cambios Principales
### 1. Estructura Clean Architecture (Ticket 1.1)
- ✅ Creada estructura de carpetas en `backend/src/`
- ✅ Definidas interfaces de repositorios en `domain/repositories/`
- ✅ Creadas entidades de dominio en `domain/entities/`
- ✅ Creados value objects en `domain/value-objects/`
- ✅ Estructura preparada para Clean Architecture
### 2. Schemas Zod Compartidos (Ticket 1.2)
- ✅ Creados schemas en `backend/src/shared/schemas/`
- ✅ Schemas para: Content, Drugs, Glossary, Media, Validation
- ✅ Validadores actualizados para usar schemas compartidos
- ✅ Tipos TypeScript generados desde schemas
### 3. Refactorización drugs.ts (Ticket 1.3)
- ✅ Archivo dividido de 1362 líneas → 8 archivos modulares
- ✅ Estructura: `src/data/drugs/`
- `types.ts` - Tipos e interfaces
- `utils.ts` - Funciones helper
- `index.ts` - Módulo principal
- `categories/` - 6 archivos por categoría
- ✅ Compatibilidad mantenida con código existente
### 4. Refactorización procedures.ts (Ticket 1.4)
- ✅ Archivo dividido de 3583 líneas → 6 archivos modulares
- ✅ Estructura: `src/data/procedures/`
- `types.ts` - Tipos e interfaces
- `utils.ts` - Funciones helper
- `index.ts` - Módulo principal
- `categories/` - 3 archivos por categoría principal
- ✅ Compatibilidad mantenida con código existente
### 5. Eliminación de Duplicidades (Ticket 1.5)
- ✅ Creadas utilidades genéricas en `src/utils/`
- `filter.ts` - Funciones genéricas de filtrado
- `validation.ts` - Funciones genéricas de validación
- ✅ Eliminadas ~50 líneas de código duplicado
- ✅ Validadores consolidados usando utilidades genéricas
---
## 📁 Archivos Nuevos Creados
### Backend - Clean Architecture
```
backend/src/
├── domain/
│ ├── entities/
│ ├── value-objects/
│ └── repositories/
├── shared/
│ ├── schemas/
│ ├── types/
│ └── errors/
└── validators/
├── glossary.ts
├── media.ts
└── index.ts
```
### Frontend - Refactorización
```
src/data/
├── drugs/
│ ├── types.ts
│ ├── utils.ts
│ ├── index.ts
│ └── categories/
└── procedures/
├── types.ts
├── utils.ts
├── index.ts
└── categories/
src/utils/
├── filter.ts
└── validation.ts
src/hooks/
├── useDrugFilters.ts
├── useProcedureFilters.ts
├── useGenericFilter.ts
└── useSearch.ts
```
### Documentación
```
docs/
├── DECISIONES_TECNICAS_CONSOLIDADAS.md
├── CASOS_BORDE.md
├── ERRORES_CRITICOS_MEDICOS.md
├── LOGS_AUDITORIA.md
├── SEPARACION_CAPAS_LOGICA_NEGOCIO.md
├── SISTEMA_VALIDACION_DOSIS.md
├── SISTEMA_VALIDACION_PROTOCOLOS.md
└── TESTING_MOCKS_DATOS_MEDICOS.md
```
---
## 🗑️ Archivos Eliminados
### Scripts Obsoletos (10 archivos)
- `scripts/cleanup-safe.sh`
- `scripts/desplegar.sh`
- `scripts/webhook-deploy.sh`
- `scripts/mover_imagenes_pwa.sh`
- `scripts/verificacion_final.sh`
- `scripts/prepare-validation-test.sh`
- `scripts/auditoria_assets.sh`
- `scripts/fix-asset-names.sh`
- `scripts/optimize-bloque-00-images.sh`
- `scripts/copiar-guias-markdown.sh`
- `scripts/deploy/resolver-conflicto-merge.sh` ⭐ (eliminado hoy)
### Documentación Obsoleta (80+ archivos)
- Múltiples archivos de auditorías y checklists obsoletos
- Documentación de fases completadas
- Reportes de normalización ya aplicados
---
## 📊 Estadísticas
- **Archivos modificados:** ~20 archivos
- **Archivos nuevos:** ~40 archivos
- **Archivos eliminados:** ~90 archivos
- **Líneas de código eliminadas:** ~28,000 líneas (principalmente docs obsoletos)
- **Líneas de código nuevas:** ~5,000 líneas (código refactorizado)
- **Build:** ✅ Exitoso sin errores
---
## ✅ Verificaciones Realizadas
- ✅ Build de producción exitoso
- ✅ Sin errores de TypeScript
- ✅ Estructura de carpetas correcta
- ✅ Compatibilidad con código existente mantenida
- ✅ Scripts obsoletos eliminados
---
## 🚀 Próximos Pasos Recomendados
1. **Probar aplicación localmente**
```bash
npm run dev
```
2. **Verificar funcionalidades**
- Navegación de fármacos
- Navegación de procedimientos
- Búsqueda
- Filtros
3. **Hacer commit de cambios**
```bash
git add .
git commit -m "refactor: Fase 1 - Clean Architecture y refactorización completa"
```
4. **Push a GitHub y servidor**
```bash
git push origin main
git push production main
```
---
## ⚠️ Notas Importantes
- Los archivos `drugs.ts` y `procedures.ts` originales se mantienen para compatibilidad
- La nueva estructura modular está lista para uso futuro
- Los schemas Zod están preparados para validación en backend
- La estructura Clean Architecture está lista para implementación completa

1093
SPEC.md

File diff suppressed because it is too large Load diff

View file

@ -36,8 +36,11 @@ export default function MediaManagerPage() {
const [typeFilter, setTypeFilter] = useState<'all' | 'image' | 'video'>('all'); const [typeFilter, setTypeFilter] = useState<'all' | 'image' | 'video'>('all');
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [orphanedCount, setOrphanedCount] = useState(0); const [orphanedCount, setOrphanedCount] = useState(0);
const [loadError, setLoadError] = useState<string | null>(null);
const [showUpload, setShowUpload] = useState(false); const [showUpload, setShowUpload] = useState(false);
const [uploadFile, setUploadFile] = useState<File | null>(null); const [uploadFile, setUploadFile] = useState<File | null>(null);
const [uploadError, setUploadError] = useState<string | null>(null);
const [uploadValidationDetails, setUploadValidationDetails] = useState<{ path: string; message: string }[] | null>(null);
const [uploadData, setUploadData] = useState({ const [uploadData, setUploadData] = useState({
title: '', title: '',
description: '', description: '',
@ -48,6 +51,7 @@ export default function MediaManagerPage() {
const loadResources = async () => { const loadResources = async () => {
setIsLoading(true); setIsLoading(true);
setLoadError(null);
try { try {
const token = localStorage.getItem('admin_token'); const token = localStorage.getItem('admin_token');
const params = new URLSearchParams({ const params = new URLSearchParams({
@ -63,10 +67,14 @@ export default function MediaManagerPage() {
}, },
}); });
const data = await response.json(); const data = await response.json();
if (!response.ok) {
setLoadError(data.error || 'Error al cargar recursos');
return;
}
setResources(data.items || []); setResources(data.items || []);
setTotal(data.total || 0); setTotal(data.total || 0);
} catch (error) { } catch (error) {
console.error('Error cargando recursos:', error); setLoadError('Error de conexión al cargar recursos');
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@ -96,6 +104,8 @@ export default function MediaManagerPage() {
if (!uploadFile) return; if (!uploadFile) return;
setIsUploading(true); setIsUploading(true);
setUploadError(null);
setUploadValidationDetails(null);
try { try {
const token = localStorage.getItem('admin_token'); const token = localStorage.getItem('admin_token');
const formData = new FormData(); const formData = new FormData();
@ -114,6 +124,8 @@ export default function MediaManagerPage() {
body: formData, body: formData,
}); });
const data = await response.json();
if (response.ok) { if (response.ok) {
setShowUpload(false); setShowUpload(false);
setUploadFile(null); setUploadFile(null);
@ -127,12 +139,16 @@ export default function MediaManagerPage() {
await loadResources(); await loadResources();
await loadOrphanedCount(); await loadOrphanedCount();
} else { } else {
const error = await response.json(); setUploadError(data.error || 'Error al subir archivo');
alert(`Error: ${error.error || 'Error al subir archivo'}`); if (Array.isArray(data.details)) {
setUploadValidationDetails(data.details);
} else {
setUploadValidationDetails(null);
}
} }
} catch (error) { } catch (error) {
console.error('Error subiendo archivo:', error); setUploadError('Error de conexión al subir archivo');
alert('Error al subir archivo'); setUploadValidationDetails(null);
} finally { } finally {
setIsUploading(false); setIsUploading(false);
} }
@ -213,7 +229,20 @@ export default function MediaManagerPage() {
{showUpload && hasPermission('content:write') && ( {showUpload && hasPermission('content:write') && (
<div className="bg-card border border-border rounded-xl p-6 space-y-4"> <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> <h2 className="text-xl font-semibold text-foreground">Subir Nuevo Recurso</h2>
{uploadError && (
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-4 space-y-2" role="alert">
<p className="text-red-500 font-medium">{uploadError}</p>
{uploadValidationDetails && uploadValidationDetails.length > 0 && (
<ul className="text-sm text-red-500/90 list-disc list-inside">
{uploadValidationDetails.map((d, i) => (
<li key={i}>{d.path ? `${d.path}: ` : ''}{d.message}</li>
))}
</ul>
)}
</div>
)}
<div> <div>
<label className="block text-sm font-medium text-muted-foreground mb-1"> <label className="block text-sm font-medium text-muted-foreground mb-1">
Archivo * Archivo *
@ -308,6 +337,8 @@ export default function MediaManagerPage() {
onClick={() => { onClick={() => {
setShowUpload(false); setShowUpload(false);
setUploadFile(null); setUploadFile(null);
setUploadError(null);
setUploadValidationDetails(null);
setUploadData({ setUploadData({
title: '', title: '',
description: '', description: '',
@ -363,7 +394,17 @@ export default function MediaManagerPage() {
{/* Lista de recursos */} {/* Lista de recursos */}
<div className="bg-card border border-border rounded-xl overflow-hidden"> <div className="bg-card border border-border rounded-xl overflow-hidden">
{isLoading ? ( {loadError ? (
<div className="p-8 text-center space-y-4">
<p className="text-red-500">{loadError}</p>
<button
onClick={() => loadResources()}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
>
Reintentar
</button>
</div>
) : isLoading ? (
<div className="p-8 text-center text-muted-foreground">Cargando...</div> <div className="p-8 text-center text-muted-foreground">Cargando...</div>
) : resources.length === 0 ? ( ) : resources.length === 0 ? (
<div className="p-8 text-center text-muted-foreground"> <div className="p-8 text-center text-muted-foreground">

View file

@ -0,0 +1,60 @@
-- ============================================
-- MIGRACIÓN 004: Esquema de Glosario (tes_content)
-- ============================================
-- TICKET-007: Schema de BD para glosario
-- Crea la tabla glossary_terms en tes_content.
-- Reutiliza tes_content.content_status para estado.
--
-- Schema tes_content ya existe (003); content_status ya existe.
-- ============================================
-- TABLA: glossary_terms
-- Propósito: Términos del glosario médico (farmacológico, anatómico, clínico, procedural)
-- ============================================
CREATE TABLE IF NOT EXISTS tes_content.glossary_terms (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
term VARCHAR(200) NOT NULL,
abbreviation VARCHAR(50),
category VARCHAR(50) NOT NULL,
definition TEXT NOT NULL,
context VARCHAR(500),
examples TEXT[],
related_terms UUID[],
source VARCHAR(200),
status tes_content.content_status NOT NULL DEFAULT 'draft',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by UUID,
CONSTRAINT chk_glossary_category CHECK (category IN ('pharmaceutical', 'anatomical', 'clinical', 'procedural'))
);
-- Índices
CREATE INDEX IF NOT EXISTS idx_glossary_terms_category ON tes_content.glossary_terms(category);
CREATE INDEX IF NOT EXISTS idx_glossary_terms_status ON tes_content.glossary_terms(status);
CREATE INDEX IF NOT EXISTS idx_glossary_terms_term_lower ON tes_content.glossary_terms(LOWER(term));
CREATE UNIQUE INDEX IF NOT EXISTS idx_glossary_terms_term_category ON tes_content.glossary_terms(LOWER(term), category);
CREATE INDEX IF NOT EXISTS idx_glossary_terms_updated_at ON tes_content.glossary_terms(updated_at);
-- Búsqueda full-text en term y definition
CREATE INDEX IF NOT EXISTS idx_glossary_terms_fts ON tes_content.glossary_terms
USING GIN (to_tsvector('spanish', term || ' ' || COALESCE(definition, '')));
-- Función updated_at en tes_content (idempotente)
CREATE OR REPLACE FUNCTION tes_content.update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER update_glossary_terms_updated_at
BEFORE UPDATE ON tes_content.glossary_terms
FOR EACH ROW
EXECUTE FUNCTION tes_content.update_updated_at_column();
-- Comentarios
COMMENT ON TABLE tes_content.glossary_terms IS 'Términos del glosario médico (farmacológico, anatómico, clínico, procedural)';

View file

@ -6,9 +6,14 @@
"auditLog": "/home/planetazuzu/guia-tes/backend/logs/.9160ab2066478e51d30817557dcf504ed0910fa3-audit.json", "auditLog": "/home/planetazuzu/guia-tes/backend/logs/.9160ab2066478e51d30817557dcf504ed0910fa3-audit.json",
"files": [ "files": [
{ {
"date": 1768472335189, "date": 1769941357200,
"name": "/home/planetazuzu/guia-tes/backend/logs/rejections-2026-01-15.log", "name": "/home/planetazuzu/guia-tes/backend/logs/rejections-2026-02-01.log",
"hash": "6093931da931f5318aa70f081a47df36bce91b1f207d2e1367c020dbdcd88cb5" "hash": "1c593871d46d08b0b8fd50bb8053f55abc8f9f475ce311df936d531c2780e3f1"
},
{
"date": 1770036440754,
"name": "/home/planetazuzu/guia-tes/backend/logs/rejections-2026-02-02.log",
"hash": "53a9ec321e7fe80bfe9f9b53c423e0c1ed9b7ef0c3503db5220b003a3d74f9cd"
} }
], ],
"hashType": "sha256" "hashType": "sha256"

View file

@ -6,9 +6,14 @@
"auditLog": "/home/planetazuzu/guia-tes/backend/logs/.959d4fb921fae5f0cc8187c09059e661e8b44766-audit.json", "auditLog": "/home/planetazuzu/guia-tes/backend/logs/.959d4fb921fae5f0cc8187c09059e661e8b44766-audit.json",
"files": [ "files": [
{ {
"date": 1768472335172, "date": 1769941357158,
"name": "/home/planetazuzu/guia-tes/backend/logs/exceptions-2026-01-15.log", "name": "/home/planetazuzu/guia-tes/backend/logs/exceptions-2026-02-01.log",
"hash": "8ad6bc1e411234832498b2472d8c455693f15424e6454da23b96b50951030f09" "hash": "33fc9160d2a60b6cf41e7aeb33e5b95a57a8167ab1defc5c81ac096955bec051"
},
{
"date": 1770036440735,
"name": "/home/planetazuzu/guia-tes/backend/logs/exceptions-2026-02-02.log",
"hash": "eee9c35fd390b1c53de81c6683935d20703f8cb511f53ca737381c36a0af1314"
} }
], ],
"hashType": "sha256" "hashType": "sha256"

View file

@ -6,9 +6,14 @@
"auditLog": "/home/planetazuzu/guia-tes/backend/logs/.b29b64f49be2319de5ceb3502f5d85462c330ddd-audit.json", "auditLog": "/home/planetazuzu/guia-tes/backend/logs/.b29b64f49be2319de5ceb3502f5d85462c330ddd-audit.json",
"files": [ "files": [
{ {
"date": 1768472335118, "date": 1769941357094,
"name": "/home/planetazuzu/guia-tes/backend/logs/error-2026-01-15.log", "name": "/home/planetazuzu/guia-tes/backend/logs/error-2026-02-01.log",
"hash": "133dfedfdb7bfc12c2e2d822d62cf30fbfffed1b09a9f0ff3580c9ea5a7bf523" "hash": "67d80626b8220d5e92b91162a4ccf375f1f94b5a2119a28bc0e23be647581ad2"
},
{
"date": 1770036440566,
"name": "/home/planetazuzu/guia-tes/backend/logs/error-2026-02-02.log",
"hash": "75333d6614b159489c35f4331a48f70ef058e430ea7c36cf2e2f2a035b845157"
} }
], ],
"hashType": "sha256" "hashType": "sha256"

View file

@ -6,9 +6,14 @@
"auditLog": "/home/planetazuzu/guia-tes/backend/logs/.ca9cb60f17ec5c8f33fda0e74731d61f7be2f69f-audit.json", "auditLog": "/home/planetazuzu/guia-tes/backend/logs/.ca9cb60f17ec5c8f33fda0e74731d61f7be2f69f-audit.json",
"files": [ "files": [
{ {
"date": 1768472335131, "date": 1769941357125,
"name": "/home/planetazuzu/guia-tes/backend/logs/combined-2026-01-15.log", "name": "/home/planetazuzu/guia-tes/backend/logs/combined-2026-02-01.log",
"hash": "70c3fafdb444f295e03e3cbe9ed5a6e97088fce576ec5fcbd8fa62b4d86148fa" "hash": "6d5356d6c4477ac7b8de799009aba9c06aceea27c3dc793d124250552efbe6b6"
},
{
"date": 1770036440628,
"name": "/home/planetazuzu/guia-tes/backend/logs/combined-2026-02-02.log",
"hash": "fc9ff45841a4e448bce06b54c089f08531aadd111d9c2875ac58aacc9b0da09d"
} }
], ],
"hashType": "sha256" "hashType": "sha256"

View file

@ -1,12 +0,0 @@
{"environment":"development","level":"info","message":"🔒 Validando configuración de seguridad...","service":"emerges-tes-backend","timestamp":"2026-01-15T10:21:04.997Z"}
{"environment":"development","level":"info","message":"✅ Configuración validada correctamente","service":"emerges-tes-backend","timestamp":"2026-01-15T10:21:05.006Z"}
{"environment":"development","level":"info","message":"🚀 EMERGES TES Backend API iniciado","nodeVersion":"v20.19.6","port":"3000","service":"emerges-tes-backend","timestamp":"2026-01-15T10:21:05.042Z"}
{"auth":"http://localhost:3000/api/auth","content":"http://localhost:3000/api/content","environment":"development","health":"http://localhost:3000/health","level":"info","message":"📍 Endpoints disponibles","service":"emerges-tes-backend","timestamp":"2026-01-15T10:21:05.043Z"}
{"environment":"development","level":"info","message":"✅ Conexión a base de datos establecida","service":"emerges-tes-backend","timestamp":"2026-01-15T10:21:05.210Z"}
{"environment":"development","ip":"::ffff:127.0.0.1","level":"info","message":"HTTP Request","method":"GET","responseTime":"55ms","service":"emerges-tes-backend","statusCode":200,"timestamp":"2026-01-15T10:21:06.080Z","url":"/health","userAgent":"curl/7.81.0"}
{"environment":"development","ip":"::1","level":"info","message":"HTTP Request","method":"GET","responseTime":"43ms","service":"emerges-tes-backend","statusCode":200,"timestamp":"2026-01-15T10:43:23.819Z","url":"/","userAgent":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"}
{"environment":"development","ip":"::1","level":"info","message":"HTTP Request","method":"GET","responseTime":"6ms","service":"emerges-tes-backend","statusCode":404,"timestamp":"2026-01-15T10:43:26.900Z","url":"/service-worker.js","userAgent":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"}
{"environment":"development","ip":"::1","level":"info","message":"HTTP Request","method":"GET","responseTime":"12ms","service":"emerges-tes-backend","statusCode":304,"timestamp":"2026-01-15T10:43:31.565Z","url":"/","userAgent":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"}
{"environment":"development","ip":"::1","level":"info","message":"HTTP Request","method":"GET","responseTime":"1ms","service":"emerges-tes-backend","statusCode":404,"timestamp":"2026-01-15T10:43:33.636Z","url":"/service-worker.js","userAgent":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"}
{"environment":"development","ip":"::1","level":"info","message":"HTTP Request","method":"GET","service":"emerges-tes-backend","statusCode":404,"timestamp":"2026-01-15T10:44:02.988Z","url":"/service-worker.js","userAgent":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"}
{"environment":"development","level":"info","message":"SIGTERM recibido, cerrando servidor...","service":"emerges-tes-backend","timestamp":"2026-01-15T21:10:20.343Z"}

View file

@ -0,0 +1,18 @@
{"environment":"development","level":"info","message":"🔒 Validando configuración de seguridad...","service":"emerges-tes-backend","timestamp":"2026-02-01T10:22:51.703Z"}
{"environment":"development","level":"info","message":"✅ Configuración validada correctamente","service":"emerges-tes-backend","timestamp":"2026-02-01T10:22:51.727Z"}
{"environment":"development","level":"info","message":"🚀 EMERGES TES Backend API iniciado","nodeVersion":"v22.22.0","port":"3000","service":"emerges-tes-backend","timestamp":"2026-02-01T10:22:51.773Z"}
{"auth":"http://localhost:3000/api/auth","content":"http://localhost:3000/api/content","environment":"development","health":"http://localhost:3000/health","level":"info","message":"📍 Endpoints disponibles","service":"emerges-tes-backend","timestamp":"2026-02-01T10:22:51.774Z"}
{"environment":"development","level":"info","message":"✅ Conexión a base de datos establecida","service":"emerges-tes-backend","timestamp":"2026-02-01T10:22:52.406Z"}
{"environment":"development","level":"info","message":"SIGTERM recibido, cerrando servidor...","service":"emerges-tes-backend","timestamp":"2026-02-01T13:19:40.614Z"}
{"environment":"development","level":"info","message":"🔒 Validando configuración de seguridad...","service":"emerges-tes-backend","timestamp":"2026-02-01T13:19:59.355Z"}
{"environment":"development","level":"info","message":"✅ Configuración validada correctamente","service":"emerges-tes-backend","timestamp":"2026-02-01T13:19:59.367Z"}
{"environment":"development","level":"info","message":"🚀 EMERGES TES Backend API iniciado","nodeVersion":"v22.22.0","port":"3000","service":"emerges-tes-backend","timestamp":"2026-02-01T13:19:59.415Z"}
{"auth":"http://localhost:3000/api/auth","content":"http://localhost:3000/api/content","environment":"development","health":"http://localhost:3000/health","level":"info","message":"📍 Endpoints disponibles","service":"emerges-tes-backend","timestamp":"2026-02-01T13:19:59.422Z"}
{"environment":"development","level":"info","message":"✅ Conexión a base de datos establecida","service":"emerges-tes-backend","timestamp":"2026-02-01T13:19:59.716Z"}
{"environment":"development","level":"info","message":"SIGTERM recibido, cerrando servidor...","service":"emerges-tes-backend","timestamp":"2026-02-01T13:20:03.142Z"}
{"environment":"development","level":"info","message":"🔒 Validando configuración de seguridad...","service":"emerges-tes-backend","timestamp":"2026-02-01T13:20:08.890Z"}
{"environment":"development","level":"info","message":"✅ Configuración validada correctamente","service":"emerges-tes-backend","timestamp":"2026-02-01T13:20:08.910Z"}
{"environment":"development","level":"info","message":"🚀 EMERGES TES Backend API iniciado","nodeVersion":"v22.22.0","port":"3000","service":"emerges-tes-backend","timestamp":"2026-02-01T13:20:08.969Z"}
{"auth":"http://localhost:3000/api/auth","content":"http://localhost:3000/api/content","environment":"development","health":"http://localhost:3000/health","level":"info","message":"📍 Endpoints disponibles","service":"emerges-tes-backend","timestamp":"2026-02-01T13:20:08.971Z"}
{"environment":"development","level":"info","message":"✅ Conexión a base de datos establecida","service":"emerges-tes-backend","timestamp":"2026-02-01T13:20:09.202Z"}
{"environment":"development","level":"info","message":"SIGTERM recibido, cerrando servidor...","service":"emerges-tes-backend","timestamp":"2026-02-01T17:08:43.569Z"}

View file

@ -0,0 +1,26 @@
{"environment":"test","ip":"::ffff:127.0.0.1","level":"info","message":"HTTP Request","method":"GET","responseTime":"13ms","service":"emerges-tes-backend","statusCode":200,"timestamp":"2026-02-02T12:47:37.154Z","url":"/"}
{"environment":"test","ip":"::ffff:127.0.0.1","level":"info","message":"HTTP Request","method":"GET","responseTime":"3ms","service":"emerges-tes-backend","statusCode":200,"timestamp":"2026-02-02T12:47:37.211Z","url":"/health"}
{"action":"list","endpoint":"/api/glossary","environment":"test","level":"error","message":"Error occurred Cannot read properties of undefined (reading 'total')","method":"GET","service":"emerges-tes-backend","stack":"TypeError: Cannot read properties of undefined (reading 'total')\n at GlossaryRepository.findAll (/home/planetazuzu/guia-tes/backend/src/infrastructure/repositories/GlossaryRepository.ts:54:71)\n at processTicksAndRejections (node:internal/process/task_queues:105:5)\n at GlossaryService.findAll (/home/planetazuzu/guia-tes/backend/src/application/services/GlossaryService.ts:43:30)\n at /home/planetazuzu/guia-tes/backend/src/routes/glossary.ts:52:20","timestamp":"2026-02-02T12:47:37.306Z"}
{"environment":"test","ip":"::ffff:127.0.0.1","level":"info","message":"HTTP Request","method":"GET","responseTime":"107ms","service":"emerges-tes-backend","statusCode":500,"timestamp":"2026-02-02T12:47:37.336Z","url":"/"}
{"action":"list","endpoint":"/api/glossary","environment":"test","level":"error","message":"Error occurred Cannot read properties of undefined (reading 'total')","method":"GET","service":"emerges-tes-backend","stack":"TypeError: Cannot read properties of undefined (reading 'total')\n at GlossaryRepository.findAll (/home/planetazuzu/guia-tes/backend/src/infrastructure/repositories/GlossaryRepository.ts:54:71)\n at processTicksAndRejections (node:internal/process/task_queues:105:5)\n at GlossaryService.findAll (/home/planetazuzu/guia-tes/backend/src/application/services/GlossaryService.ts:43:30)\n at /home/planetazuzu/guia-tes/backend/src/routes/glossary.ts:52:20","timestamp":"2026-02-02T12:47:37.437Z"}
{"environment":"test","ip":"::ffff:127.0.0.1","level":"info","message":"HTTP Request","method":"GET","responseTime":"13ms","service":"emerges-tes-backend","statusCode":500,"timestamp":"2026-02-02T12:47:37.440Z","url":"/?page=1&pageSize=5"}
{"environment":"test","ip":"::ffff:127.0.0.1","level":"info","message":"HTTP Request","method":"GET","responseTime":"3ms","service":"emerges-tes-backend","statusCode":401,"timestamp":"2026-02-02T12:47:37.488Z","url":"/validation"}
{"environment":"test","ip":"::ffff:127.0.0.1","level":"info","message":"HTTP Request","method":"GET","responseTime":"35ms","service":"emerges-tes-backend","statusCode":401,"timestamp":"2026-02-02T12:47:37.691Z","url":"/dashboard"}
{"environment":"test","ip":"::ffff:127.0.0.1","level":"info","message":"HTTP Request","method":"GET","responseTime":"23ms","service":"emerges-tes-backend","statusCode":200,"timestamp":"2026-02-02T12:49:35.738Z","url":"/"}
{"environment":"test","ip":"::ffff:127.0.0.1","level":"info","message":"HTTP Request","method":"GET","responseTime":"5ms","service":"emerges-tes-backend","statusCode":200,"timestamp":"2026-02-02T12:49:35.877Z","url":"/health"}
{"environment":"test","ip":"::ffff:127.0.0.1","level":"info","message":"HTTP Request","method":"GET","responseTime":"11ms","service":"emerges-tes-backend","statusCode":200,"timestamp":"2026-02-02T12:49:35.966Z","url":"/"}
{"environment":"test","ip":"::ffff:127.0.0.1","level":"info","message":"HTTP Request","method":"GET","responseTime":"4ms","service":"emerges-tes-backend","statusCode":200,"timestamp":"2026-02-02T12:49:35.997Z","url":"/?page=1&pageSize=5"}
{"environment":"test","ip":"::ffff:127.0.0.1","level":"info","message":"HTTP Request","method":"GET","responseTime":"1ms","service":"emerges-tes-backend","statusCode":401,"timestamp":"2026-02-02T12:49:36.021Z","url":"/validation"}
{"environment":"test","ip":"::ffff:127.0.0.1","level":"info","message":"HTTP Request","method":"GET","service":"emerges-tes-backend","statusCode":401,"timestamp":"2026-02-02T12:49:36.038Z","url":"/dashboard"}
{"environment":"test","ip":"::ffff:127.0.0.1","level":"info","message":"HTTP Request","method":"GET","responseTime":"12ms","service":"emerges-tes-backend","statusCode":200,"timestamp":"2026-02-02T12:50:01.918Z","url":"/"}
{"environment":"test","ip":"::ffff:127.0.0.1","level":"info","message":"HTTP Request","method":"GET","responseTime":"3ms","service":"emerges-tes-backend","statusCode":200,"timestamp":"2026-02-02T12:50:02.019Z","url":"/health"}
{"environment":"test","ip":"::ffff:127.0.0.1","level":"info","message":"HTTP Request","method":"GET","responseTime":"13ms","service":"emerges-tes-backend","statusCode":200,"timestamp":"2026-02-02T12:50:02.062Z","url":"/"}
{"environment":"test","ip":"::ffff:127.0.0.1","level":"info","message":"HTTP Request","method":"GET","responseTime":"5ms","service":"emerges-tes-backend","statusCode":200,"timestamp":"2026-02-02T12:50:02.098Z","url":"/?page=1&pageSize=5"}
{"environment":"test","ip":"::ffff:127.0.0.1","level":"info","message":"HTTP Request","method":"GET","responseTime":"2ms","service":"emerges-tes-backend","statusCode":401,"timestamp":"2026-02-02T12:50:02.124Z","url":"/validation"}
{"environment":"test","ip":"::ffff:127.0.0.1","level":"info","message":"HTTP Request","method":"GET","responseTime":"2ms","service":"emerges-tes-backend","statusCode":401,"timestamp":"2026-02-02T12:50:02.149Z","url":"/dashboard"}
{"environment":"test","ip":"::ffff:127.0.0.1","level":"info","message":"HTTP Request","method":"GET","responseTime":"13ms","service":"emerges-tes-backend","statusCode":200,"timestamp":"2026-02-02T15:34:16.753Z","url":"/"}
{"environment":"test","ip":"::ffff:127.0.0.1","level":"info","message":"HTTP Request","method":"GET","responseTime":"7ms","service":"emerges-tes-backend","statusCode":200,"timestamp":"2026-02-02T15:34:16.929Z","url":"/health"}
{"environment":"test","ip":"::ffff:127.0.0.1","level":"info","message":"HTTP Request","method":"GET","responseTime":"98ms","service":"emerges-tes-backend","statusCode":200,"timestamp":"2026-02-02T15:34:17.096Z","url":"/"}
{"environment":"test","ip":"::ffff:127.0.0.1","level":"info","message":"HTTP Request","method":"GET","responseTime":"6ms","service":"emerges-tes-backend","statusCode":200,"timestamp":"2026-02-02T15:34:17.363Z","url":"/?page=1&pageSize=5"}
{"environment":"test","ip":"::ffff:127.0.0.1","level":"info","message":"HTTP Request","method":"GET","responseTime":"1ms","service":"emerges-tes-backend","statusCode":401,"timestamp":"2026-02-02T15:34:17.410Z","url":"/validation"}
{"environment":"test","ip":"::ffff:127.0.0.1","level":"info","message":"HTTP Request","method":"GET","responseTime":"1ms","service":"emerges-tes-backend","statusCode":401,"timestamp":"2026-02-02T15:34:17.515Z","url":"/dashboard"}

View file

@ -0,0 +1,2 @@
{"action":"list","endpoint":"/api/glossary","environment":"test","level":"error","message":"Error occurred Cannot read properties of undefined (reading 'total')","method":"GET","service":"emerges-tes-backend","stack":"TypeError: Cannot read properties of undefined (reading 'total')\n at GlossaryRepository.findAll (/home/planetazuzu/guia-tes/backend/src/infrastructure/repositories/GlossaryRepository.ts:54:71)\n at processTicksAndRejections (node:internal/process/task_queues:105:5)\n at GlossaryService.findAll (/home/planetazuzu/guia-tes/backend/src/application/services/GlossaryService.ts:43:30)\n at /home/planetazuzu/guia-tes/backend/src/routes/glossary.ts:52:20","timestamp":"2026-02-02T12:47:37.306Z"}
{"action":"list","endpoint":"/api/glossary","environment":"test","level":"error","message":"Error occurred Cannot read properties of undefined (reading 'total')","method":"GET","service":"emerges-tes-backend","stack":"TypeError: Cannot read properties of undefined (reading 'total')\n at GlossaryRepository.findAll (/home/planetazuzu/guia-tes/backend/src/infrastructure/repositories/GlossaryRepository.ts:54:71)\n at processTicksAndRejections (node:internal/process/task_queues:105:5)\n at GlossaryService.findAll (/home/planetazuzu/guia-tes/backend/src/application/services/GlossaryService.ts:43:30)\n at /home/planetazuzu/guia-tes/backend/src/routes/glossary.ts:52:20","timestamp":"2026-02-02T12:47:37.437Z"}

File diff suppressed because it is too large Load diff

1201
backend/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -25,7 +25,11 @@
"seed:drugs": "node scripts/seed-drugs.js", "seed:drugs": "node scripts/seed-drugs.js",
"migrate:all": "node scripts/migrate-all-content.js", "migrate:all": "node scripts/migrate-all-content.js",
"migrate:drugs": "node scripts/migrate-drugs-schema.js", "migrate:drugs": "node scripts/migrate-drugs-schema.js",
"migrate:content-items": "node scripts/migrate-content-items-schema.js" "migrate:content-items": "node scripts/migrate-content-items-schema.js",
"migrate:glossary": "node scripts/migrate-glossary-schema.js",
"migrate:glossary-data": "node scripts/migrate-glossary-from-frontend.js",
"test": "vitest run",
"test:watch": "vitest"
}, },
"keywords": [ "keywords": [
"emerges", "emerges",
@ -65,7 +69,9 @@
"@types/pg": "^8.10.9", "@types/pg": "^8.10.9",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@types/winston": "^2.4.4", "@types/winston": "^2.4.4",
"supertest": "^7.2.2",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^5.9.3" "typescript": "^5.9.3",
"vitest": "^4.0.0"
} }
} }

View file

@ -0,0 +1,559 @@
{
"terms": [
{
"term": "Gramo",
"abbreviation": "g",
"category": "pharmaceutical",
"definition": "Unidad base de peso",
"context": "unidades",
"source": "Manual TES Digital - Bloque 6"
},
{
"term": "Miligramo",
"abbreviation": "mg",
"category": "pharmaceutical",
"definition": "Milésima parte de un gramo (0.001 g). Usada para la mayoría de fármacos TES (AAS, adrenalina)",
"context": "unidades",
"source": "Manual TES Digital - Bloque 6"
},
{
"term": "Microgramo",
"abbreviation": "mcg (o µg)",
"category": "pharmaceutical",
"definition": "Millonésima parte de un gramo (0.000001 g)",
"context": "Usar siempre \"mcg\". \"µg\" puede confundirse con \"mg\" (error x1000)",
"source": "Manual TES Digital - Bloque 6"
},
{
"term": "Litro",
"abbreviation": "L",
"category": "pharmaceutical",
"definition": "Unidad de volumen",
"context": "unidades",
"source": "Manual TES Digital - Bloque 6"
},
{
"term": "Mililitro",
"abbreviation": "ml",
"category": "pharmaceutical",
"definition": "Milésima parte de un litro (0.001 L). Usada para volúmenes inyectables o nebulizados",
"context": "unidades",
"source": "Manual TES Digital - Bloque 6"
},
{
"term": "Unidades Internacionales",
"abbreviation": "UI",
"category": "pharmaceutical",
"definition": "Usada para algunos fármacos (insulina, heparina)",
"context": "Escribir siempre \"UI\", nunca solo \"U\" (puede leerse como un cero)",
"source": "Manual TES Digital - Bloque 6"
},
{
"term": "Kilogramo",
"abbreviation": "kg",
"category": "pharmaceutical",
"definition": "Unidad de peso para cálculo de dosis pediátricas. Fundamental",
"context": "unidades",
"source": "Manual TES Digital - Bloque 6"
},
{
"term": "Miliequivalente",
"abbreviation": "mEq",
"category": "pharmaceutical",
"definition": "Unidad química usada en electrolitos",
"context": "unidades",
"source": "Manual TES Digital - Bloque 6"
},
{
"term": "Centímetro cúbico",
"abbreviation": "cc",
"category": "pharmaceutical",
"definition": "Evitar. Usar \"ml\" (son equivalentes, pero \"ml\" es el estándar)",
"context": "unidades",
"source": "Manual TES Digital - Bloque 6"
},
{
"term": "Cada… horas",
"abbreviation": "c/… (ej., c/4h, c/8h)",
"category": "pharmaceutical",
"definition": "Frecuencia de administración (ej., cada 4 horas, cada 8 horas)",
"context": "unidades",
"source": "Manual TES Digital - Bloque 6"
},
{
"term": "Si opus sit (si fuera necesario)",
"abbreviation": "s.o.s.",
"category": "pharmaceutical",
"definition": "Solo administrar si se presentan síntomas específicos",
"context": "unidades",
"source": "Manual TES Digital - Bloque 6"
},
{
"term": "Vía Oral / Por vía Oral",
"abbreviation": "VO / PO",
"category": "pharmaceutical",
"definition": "Por boca, tragando",
"context": "vias",
"source": "Manual TES Digital - Bloque 6"
},
{
"term": "Sublingual",
"abbreviation": "SL",
"category": "pharmaceutical",
"definition": "Bajo la lengua (ej., nitroglicerina)",
"context": "vias",
"source": "Manual TES Digital - Bloque 6"
},
{
"term": "Intramuscular",
"abbreviation": "IM",
"category": "pharmaceutical",
"definition": "Inyección en músculo (ej., adrenalina para anafilaxia en vasto externo)",
"context": "vias",
"source": "Manual TES Digital - Bloque 6"
},
{
"term": "Subcutánea",
"abbreviation": "SC",
"category": "pharmaceutical",
"definition": "Inyección en tejido subcutáneo (ej., insulina, glucagón alternativo)",
"context": "vias",
"source": "Manual TES Digital - Bloque 6"
},
{
"term": "Intravenosa",
"abbreviation": "IV",
"category": "pharmaceutical",
"definition": "Directamente en vena. Procedimiento generalmente fuera del alcance TES básico",
"context": "vias",
"source": "Manual TES Digital - Bloque 6"
},
{
"term": "Intraósea",
"abbreviation": "IO",
"category": "pharmaceutical",
"definition": "En cavidad medular de un hueso (ej., tibia proximal). Alternativa a IV en PCR",
"context": "vias",
"source": "Manual TES Digital - Bloque 6"
},
{
"term": "Intranasal",
"abbreviation": "IN",
"category": "pharmaceutical",
"definition": "A través de la mucosa nasal (ej., naloxona nasal)",
"context": "vias",
"source": "Manual TES Digital - Bloque 6"
},
{
"term": "Nebulizada",
"abbreviation": "NEB",
"category": "pharmaceutical",
"definition": "Convertida en aerosol e inhalada (ej., salbutamol)",
"context": "vias",
"source": "Manual TES Digital - Bloque 6"
},
{
"term": "Inhalador de Dosis Medida",
"abbreviation": "MDI",
"category": "pharmaceutical",
"definition": "Metered Dose Inhaler. Debe usarse con cámara espaciadora",
"context": "vias",
"source": "Manual TES Digital - Bloque 6"
},
{
"term": "Tópica",
"abbreviation": "Tóp.",
"category": "pharmaceutical",
"definition": "Aplicada sobre la piel",
"context": "vias",
"source": "Manual TES Digital - Bloque 6"
},
{
"term": "Ácido Acetilsalicílico",
"abbreviation": "AAS",
"category": "pharmaceutical",
"definition": "Antiagregante plaquetario. Dosis en SCA: 150-300 mg masticados",
"context": "conceptos",
"source": "Manual TES Digital - Bloque 6"
},
{
"term": "Adrenalina (Epinefrina)",
"abbreviation": "ADR",
"category": "pharmaceutical",
"definition": "Verificar concentración: 1:1000 (1 mg/ml) para IM, 1:10.000 (0.1 mg/ml) para IV/IO",
"context": "Concentración diferente según uso. Leer etiqueta EN VOZ ALTA",
"source": "Manual TES Digital - Bloque 6"
},
{
"term": "Anatomical Therapeutic Chemical",
"abbreviation": "ATC",
"category": "pharmaceutical",
"definition": "Clasificación internacional de fármacos. No se usa en documentación operativa diaria",
"context": "conceptos",
"source": "Manual TES Digital - Bloque 6"
},
{
"term": "Bag-Valve-Mask (Bolsa-Válvula-Mascarilla)",
"abbreviation": "BVM",
"category": "pharmaceutical",
"definition": "Dispositivo para ventilación manual. No es un fármaco, pero es término clave en soporte",
"context": "conceptos",
"source": "Manual TES Digital - Bloque 6"
},
{
"term": "Contraindicación Absoluta",
"abbreviation": "CPA",
"category": "pharmaceutical",
"definition": "Situación en la que nunca se debe administrar un fármaco (ej., alergia al AAS)",
"context": "conceptos",
"source": "Manual TES Digital - Bloque 6"
},
{
"term": "Contraindicación Relativa",
"abbreviation": "CPR",
"category": "pharmaceutical",
"definition": "Situación que exige precaución extrema, pero donde el beneficio puede superar el riesgo (valoración médica)",
"context": "conceptos",
"source": "Manual TES Digital - Bloque 6"
},
{
"term": "Desfibrilador Externo Automático/Semiautomático",
"abbreviation": "DEA/DESA",
"category": "pharmaceutical",
"definition": "Dispositivo para desfibrilación",
"context": "conceptos",
"source": "Manual TES Digital - Bloque 6"
},
{
"term": "Efecto Adverso",
"abbreviation": "EA",
"category": "pharmaceutical",
"definition": "Reacción no deseada a un fármaco. Debe documentarse",
"context": "conceptos",
"source": "Manual TES Digital - Bloque 6"
},
{
"term": "Enfermedad Pulmonar Obstructiva Crónica",
"abbreviation": "EPOC",
"category": "pharmaceutical",
"definition": "Paciente con objetivo de SpO₂ 88-92% con oxigenoterapia",
"context": "conceptos",
"source": "Manual TES Digital - Bloque 6"
},
{
"term": "Frasco o Frasco-ampolla",
"abbreviation": "Fco.",
"category": "pharmaceutical",
"definition": "Presentación del fármaco",
"context": "conceptos",
"source": "Manual TES Digital - Bloque 6"
},
{
"term": "Hipoglucemia",
"abbreviation": "HIPO",
"category": "pharmaceutical",
"definition": "Bajo nivel de glucosa en sangre. Glucagón IM si inconsciente",
"context": "conceptos",
"source": "Manual TES Digital - Bloque 6"
},
{
"term": "Indicación",
"abbreviation": "Ind.",
"category": "pharmaceutical",
"definition": "Razón clínica para administrar un fármaco",
"context": "conceptos",
"source": "Manual TES Digital - Bloque 6"
},
{
"term": "Parada Cardiorrespiratoria",
"abbreviation": "PCR",
"category": "pharmaceutical",
"definition": "Contexto para uso de adrenalina 1:10.000 IV/IO",
"context": "conceptos",
"source": "Manual TES Digital - Bloque 6"
},
{
"term": "Síndrome Coronario Agudo",
"abbreviation": "SCA",
"category": "pharmaceutical",
"definition": "Indicación para AAS masticado",
"context": "conceptos",
"source": "Manual TES Digital - Bloque 6"
},
{
"term": "Soporte Vital Básico/Avanzado",
"abbreviation": "SVB/SVA",
"category": "pharmaceutical",
"definition": "",
"context": "conceptos",
"source": "Manual TES Digital - Bloque 6"
},
{
"term": "Técnico en Emergencias Sanitarias",
"abbreviation": "TES",
"category": "pharmaceutical",
"definition": "Tú",
"context": "conceptos",
"source": "Manual TES Digital - Bloque 6"
},
{
"term": "Anterior / Ventral",
"category": "anatomical",
"definition": "Hacia la parte delantera del cuerpo",
"context": "Herida incisa en región anterior del muslo",
"source": "Manual TES Digital - Bloque 2"
},
{
"term": "Posterior / Dorsal",
"category": "anatomical",
"definition": "Hacia la parte trasera del cuerpo",
"context": "Hematoma en región posterior del tórax",
"source": "Manual TES Digital - Bloque 2"
},
{
"term": "Superior / Craneal / Cefálico",
"category": "anatomical",
"definition": "Hacia la cabeza",
"context": "El dolor se irradia en dirección craneal",
"source": "Manual TES Digital - Bloque 2"
},
{
"term": "Inferior / Caudal",
"category": "anatomical",
"definition": "Hacia los pies",
"context": "Edema en extremidad inferior izquierda",
"source": "Manual TES Digital - Bloque 2"
},
{
"term": "Medial",
"category": "anatomical",
"definition": "Hacia la línea media del cuerpo",
"context": "Pulso medial al tendón de Aquiles (tibial posterior)",
"source": "Manual TES Digital - Bloque 2"
},
{
"term": "Lateral",
"category": "anatomical",
"definition": "Alejado de la línea media del cuerpo",
"context": "Deformidad lateral en tercio medio de clavícula",
"source": "Manual TES Digital - Bloque 2"
},
{
"term": "Proximal",
"category": "anatomical",
"definition": "Más cerca del punto de unión con el tronco",
"context": "Fractura en el tercio proximal del húmero",
"source": "Manual TES Digital - Bloque 2"
},
{
"term": "Distal",
"category": "anatomical",
"definition": "Más lejos del punto de unión con el tronco",
"context": "Cuerpo extraño en falange distal",
"source": "Manual TES Digital - Bloque 2"
},
{
"term": "Supino",
"category": "anatomical",
"definition": "Posición del paciente: acostado sobre su espalda, cara hacia arriba",
"context": "Colocar al paciente supino para valoración ABC",
"source": "Manual TES Digital - Bloque 2"
},
{
"term": "Prono",
"category": "anatomical",
"definition": "Posición del paciente: acostado sobre su estómago",
"context": "Encontrar al paciente prono. Realizar giro en bloque",
"source": "Manual TES Digital - Bloque 2"
},
{
"term": "Posición de Fowler",
"category": "anatomical",
"definition": "Paciente semisentado",
"context": "Paciente consciente con disnea, para facilitar la respiración",
"source": "Manual TES Digital - Bloque 2"
},
{
"term": "Trendelenburg",
"category": "anatomical",
"definition": "Paciente supino con la cabeza por debajo del plano de los pies",
"context": "Uso en controversia. Históricamente para shock, actualmente se desaconseja salvo indicación específica",
"source": "Manual TES Digital - Bloque 2"
},
{
"term": "Antishock (Trendelenburg Modificada)",
"category": "anatomical",
"definition": "Paciente supino con las extremidades inferiores elevadas 15-30º",
"context": "Valoración y manejo inicial del shock (sin trauma craneal/RA medular)",
"source": "Manual TES Digital - Bloque 2"
},
{
"term": "Decúbito Lateral (PLS)",
"category": "anatomical",
"definition": "De lado, con vía aérea protegida",
"context": "Paciente inconsciente con respiración espontánea",
"source": "Manual TES Digital - Bloque 2"
},
{
"term": "Apófisis mastoides",
"category": "anatomical",
"definition": "Prominencia ósea detrás de la oreja - Referencia para colocación de collarín, evitar compresión durante inmovilización",
"context": "cabeza_cuello",
"source": "Manual TES Digital - Bloque 2"
},
{
"term": "Ángulo mandibular",
"category": "anatomical",
"definition": "Punto donde la mandíbula forma un ángulo hacia atrás - Punto de referencia superior para medir talla de collarín cervical",
"context": "cabeza_cuello",
"source": "Manual TES Digital - Bloque 2"
},
{
"term": "Clavícula",
"category": "anatomical",
"definition": "Hueso horizontal en la parte superior del tórax, entre esternón y hombro - Referencia para colocación de collarín (parte inferior)",
"context": "cabeza_cuello",
"source": "Manual TES Digital - Bloque 2"
},
{
"term": "Espina de la escápula",
"category": "anatomical",
"definition": "Proyección ósea posterior del omóplato - Referencia para inmovilización de hombro, punto de apoyo",
"context": "cabeza_cuello",
"source": "Manual TES Digital - Bloque 2"
},
{
"term": "Escotadura supraesternal",
"category": "anatomical",
"definition": "Depresión superior del esternón - Referencia para evaluación de vía aérea, referencia anatómica",
"context": "torax",
"source": "Manual TES Digital - Bloque 2"
},
{
"term": "Ángulo de Louis (2ª costilla)",
"category": "anatomical",
"definition": "Unión del manubrio y cuerpo del esternón - Referencia para localización de espacios intercostales, colocación de electrodos",
"context": "torax",
"source": "Manual TES Digital - Bloque 2"
},
{
"term": "Apéndice xifoides",
"category": "anatomical",
"definition": "Extremo inferior del esternón - Referencia para compresiones torácicas, límite superior del abdomen",
"context": "torax",
"source": "Manual TES Digital - Bloque 2"
},
{
"term": "Borde costal",
"category": "anatomical",
"definition": "Borde inferior de las costillas - Referencia para evaluación abdominal, límites anatómicos",
"context": "torax",
"source": "Manual TES Digital - Bloque 2"
},
{
"term": "Cresta ilíaca",
"category": "anatomical",
"definition": "Borde superior del hueso ilíaco, palpable en la cintura - Referencia para colocación de cinturón pélvico, referencia para inmovilización pélvica",
"context": "pelvis",
"source": "Manual TES Digital - Bloque 2"
},
{
"term": "Espina ilíaca anterosuperior (EIAS)",
"category": "anatomical",
"definition": "Proyección anterior de la cresta ilíaca - Referencia anatómica para evaluaciones y procedimientos pélvicos",
"context": "pelvis",
"source": "Manual TES Digital - Bloque 2"
},
{
"term": "Sínfisis del pubis",
"category": "anatomical",
"definition": "Unión anterior de los huesos pélvicos - Referencia para evaluación pélvica, límite anterior de la pelvis",
"context": "pelvis",
"source": "Manual TES Digital - Bloque 2"
},
{
"term": "Tubérculo del mayor (húmero)",
"category": "anatomical",
"definition": "Prominencia ósea en la parte superior del húmero - Referencia para inmovilización de brazo, puntos de referencia",
"context": "extremidades",
"source": "Manual TES Digital - Bloque 2"
},
{
"term": "Epicóndilos",
"category": "anatomical",
"definition": "Prominencias óseas en la parte inferior del húmero, en el codo - Referencia para inmovilización de codo, puntos de presión que requieren acolchado",
"context": "extremidades",
"source": "Manual TES Digital - Bloque 2"
},
{
"term": "Cabeza del peroné",
"category": "anatomical",
"definition": "Extremo superior del peroné, lateral a la rodilla - Referencia para inmovilización de rodilla, punto de riesgo del nervio peroneo",
"context": "extremidades",
"source": "Manual TES Digital - Bloque 2"
},
{
"term": "Maléolos (tobillo)",
"category": "anatomical",
"definition": "Prominencias óseas medial y lateral del tobillo - Referencia para inmovilización de tobillo, pulso tibial posterior (detrás del maléolo medial)",
"context": "extremidades",
"source": "Manual TES Digital - Bloque 2"
},
{
"term": "Observación Global",
"category": "anatomical",
"definition": "Antes de tocar, observar la postura antálgica, deformidades evidentes, asimetrías. Pensar: \"¿Qué estructura interna puede estar lesionada?\"",
"context": "aplicacion",
"source": "Manual TES Digital - Bloque 2"
},
{
"term": "Comunicación Inicial",
"category": "anatomical",
"definition": "Usar términos anatómicos correctos desde el primer mensaje a coordinación: \"Hombre 40 años, supino en calzada. Deformidad evidente en tercio distal de tibia derecha, actitud antálgica\"",
"context": "aplicacion",
"source": "Manual TES Digital - Bloque 2"
},
{
"term": "Palpación Sistemática - Circulación",
"category": "anatomical",
"definition": "Durante la \"C\" (Circulación): Palpar el pulso femoral (ingle), pedio (empeine) y tibial posterior (detrás del maléolo medial). Conocer su ubicación exacta",
"context": "aplicacion",
"source": "Manual TES Digital - Bloque 2"
},
{
"term": "Palpación Sistemática - Neurológico",
"category": "anatomical",
"definition": "Durante la \"D\" (Discapacidad - Neurológico): Evaluar sensibilidad y movimiento en dermatomas y miótomos clave. Decir \"Mueva los dedos de los pies\" (no \"mueva lo de abajo\")",
"context": "aplicacion",
"source": "Manual TES Digital - Bloque 2"
},
{
"term": "Palpación Sistemática - Exposición",
"category": "anatomical",
"definition": "Durante la Exposición: Palpar la columna vertebral, las crestas ilíacas y todas las extremidades buscando puntos dolorosos, crepitación o inestabilidad",
"context": "aplicacion",
"source": "Manual TES Digital - Bloque 2"
},
{
"term": "Descripción Precisa de Lesiones",
"category": "anatomical",
"definition": "Utilizar la terminología para ser inequívoco: \"Herida punzante de 3 cm, longitudinal, en borde medial del antebrazo derecho, 10 cm distal al pliegue del codo\". Evitar \"herida en el brazo\"",
"context": "aplicacion",
"source": "Manual TES Digital - Bloque 2"
},
{
"term": "Toma de Decisiones de Inmovilización",
"category": "anatomical",
"definition": "Basarse en la anatomía: El collarín cervical se ajusta desde la base del mentón hasta la parte superior del tórax. El cinturón pélvico es a nivel de los trocánteres mayores. Las férulas deben inmovilizar la articulación proximal y distal a la fractura",
"context": "aplicacion",
"source": "Manual TES Digital - Bloque 2"
},
{
"term": "Documentación",
"category": "anatomical",
"definition": "Registrar en la Hoja de Intervención utilizando la nomenclatura correcta. Es la base legal y clínica de la actuación",
"context": "aplicacion",
"source": "Manual TES Digital - Bloque 2"
}
]
}

View file

@ -0,0 +1,94 @@
/**
* Genera backend/scripts/fixtures/glossary-migration.json desde los datos del frontend.
* TICKET-012: Migrar glosarios del frontend al backend.
*
* Ejecutar desde la raíz del repo: npx tsx backend/scripts/generate-glossary-fixture.ts
*/
import { writeFileSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
interface TermRow {
term: string;
abbreviation?: string;
category: 'pharmaceutical' | 'anatomical';
definition: string;
context?: string;
source?: string;
}
async function main() {
const terms: TermRow[] = [];
const { pharmaceuticalTerminology } = await import('../../src/data/pharmaceutical-terminology.js');
const { anatomicalTerminology } = await import('../../src/data/anatomical-terminology.js');
for (const item of [
...pharmaceuticalTerminology.units,
...pharmaceuticalTerminology.routes,
...pharmaceuticalTerminology.concepts,
]) {
terms.push({
term: item.fullTerm,
abbreviation: item.abbreviation || undefined,
category: 'pharmaceutical',
definition: item.explanation,
context: item.danger || item.category,
source: 'Manual TES Digital - Bloque 6',
});
}
for (const item of anatomicalTerminology.directionalTerms) {
terms.push({
term: item.term,
category: 'anatomical',
definition: item.definition,
context: item.example || item.category,
source: 'Manual TES Digital - Bloque 2',
});
}
for (const item of anatomicalTerminology.positions) {
terms.push({
term: item.name,
category: 'anatomical',
definition: item.description,
context: item.indication,
source: 'Manual TES Digital - Bloque 2',
});
}
for (const item of anatomicalTerminology.landmarks) {
terms.push({
term: item.name,
category: 'anatomical',
definition: `${item.location} - ${item.purpose}`,
context: item.region,
source: 'Manual TES Digital - Bloque 2',
});
}
for (const item of anatomicalTerminology.applicationSteps) {
terms.push({
term: item.title,
category: 'anatomical',
definition: item.instruction,
context: 'aplicacion',
source: 'Manual TES Digital - Bloque 2',
});
}
const fixturesDir = join(__dirname, 'fixtures');
mkdirSync(fixturesDir, { recursive: true });
const outPath = join(fixturesDir, 'glossary-migration.json');
writeFileSync(outPath, JSON.stringify({ terms }, null, 2), 'utf-8');
console.log(`✅ Generados ${terms.length} términos en ${outPath}`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View file

@ -0,0 +1,112 @@
#!/usr/bin/env node
/**
* TICKET-012: Migrar glosarios del frontend al backend.
* Lee backend/scripts/fixtures/glossary-migration.json e inserta en tes_content.glossary_terms.
*
* Requisitos: tabla glossary_terms creada (npm run migrate:glossary).
* Opcional: generar fixture antes con npx tsx backend/scripts/generate-glossary-fixture.ts (desde raíz del repo).
*
* Uso: cd backend && node scripts/migrate-glossary-from-frontend.js
*/
import { readFile } from 'fs/promises';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { query } from '../config/database.js';
import 'dotenv/config';
const __dirname = dirname(fileURLToPath(import.meta.url));
const fixturePath = join(__dirname, 'fixtures', 'glossary-migration.json');
const SYSTEM_USER_ID = '00000000-0000-0000-0000-000000000001';
async function getFirstUserId() {
const tables = ['tes_content.users', 'emerges_content.users'];
for (const table of tables) {
try {
const result = await query(
`SELECT id FROM ${table} WHERE is_active = true LIMIT 1`
);
if (result.rows.length > 0) {
return result.rows[0].id;
}
} catch {
continue;
}
}
return SYSTEM_USER_ID;
}
async function termExists(term, category) {
const result = await query(
`SELECT 1 FROM tes_content.glossary_terms WHERE LOWER(term) = LOWER($1) AND category = $2 LIMIT 1`,
[term, category]
);
return result.rows.length > 0;
}
async function main() {
console.log('🔧 TICKET-012: Migración de glosarios frontend → backend\n');
let data;
try {
const raw = await readFile(fixturePath, 'utf-8');
data = JSON.parse(raw);
} catch (err) {
console.error('❌ No se encontró o no se pudo leer', fixturePath);
console.error(' Genera el fixture desde la raíz del repo:');
console.error(' npx tsx backend/scripts/generate-glossary-fixture.ts\n');
process.exit(1);
}
const terms = data.terms || [];
if (terms.length === 0) {
console.log('⚠️ El fixture no contiene términos. Nada que migrar.');
process.exit(0);
}
const userId = await getFirstUserId();
if (userId === SYSTEM_USER_ID) {
console.log('⚠️ No se encontró usuario en BD; usando ID de sistema para created_by.\n');
}
let inserted = 0;
let skipped = 0;
for (const row of terms) {
const { term, abbreviation, category, definition, context, source } = row;
if (!term || !definition || !category) {
skipped++;
continue;
}
if (!['pharmaceutical', 'anatomical', 'clinical', 'procedural'].includes(category)) {
skipped++;
continue;
}
const exists = await termExists(term, category);
if (exists) {
skipped++;
continue;
}
await query(
`INSERT INTO tes_content.glossary_terms (
id, term, abbreviation, category, definition, context, source, status,
created_at, updated_at, created_by, updated_by
) VALUES (
uuid_generate_v4(), $1, $2, $3, $4, $5, $6, 'published'::tes_content.content_status,
NOW(), NOW(), $7, $7
)`,
[term, abbreviation || null, category, definition, context || null, source || null, userId]
);
inserted++;
}
console.log(`✅ Migración completada: ${inserted} insertados, ${skipped} omitidos (ya existían o inválidos).\n`);
}
main().catch((err) => {
console.error('❌ Error:', err.message);
process.exit(1);
});

View file

@ -0,0 +1,55 @@
#!/usr/bin/env node
/**
* Script para ejecutar la migración 004: Glossary Schema (TICKET-007)
* Ejecuta: backend/database/migrations/004_create_glossary_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/004_create_glossary_schema.sql');
async function runMigration() {
try {
console.log('🔧 Ejecutando migración: Glossary Schema (tes_content.glossary_terms)\n');
const migrationSql = await readFile(migrationPath, 'utf-8');
console.log('📝 Ejecutando SQL...\n');
try {
await query(migrationSql);
console.log('✅ Migración ejecutada correctamente\n');
} catch (error) {
if (error.code === '42P07' || error.code === '42710' || error.message?.includes('already exists')) {
console.log('⚠️ Algunos objetos ya existen (normal si ya se ejecutó antes)\n');
} else {
throw error;
}
}
const tablesResult = await query(`
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'tes_content' AND table_name = 'glossary_terms'
`);
if (tablesResult.rows.length > 0) {
console.log(' ✅ tabla glossary_terms creada');
} else {
console.log(' ⚠️ Tabla glossary_terms no encontrada');
}
console.log('\n✅ Migración del glosario completada.\n');
} catch (error) {
console.error('❌ Error ejecutando migración:', error.message);
process.exit(1);
}
}
runMigration();

View file

@ -0,0 +1,97 @@
/**
* Tests de integración API (TICKET-018)
* Ejercitan endpoints HTTP con la app Express; BD mockeada para CI.
*/
import { describe, it, expect, vi, beforeAll } from 'vitest';
import request from 'supertest';
import { createApp } from '../app.js';
vi.mock('../../config/database.js', () => ({
query: vi.fn().mockImplementation((text: string) => {
if (typeof text === 'string' && text.includes('COUNT(*)')) {
return Promise.resolve({ rows: [{ total: '0' }], rowCount: 0 });
}
return Promise.resolve({ rows: [], rowCount: 0 });
}),
testConnection: vi.fn().mockResolvedValue(true),
pool: {},
closePool: vi.fn().mockResolvedValue(undefined),
}));
describe('API Integration', () => {
let app: ReturnType<typeof createApp>;
beforeAll(() => {
app = createApp();
});
describe('GET /', () => {
it('devuelve 200 y mensaje de API', async () => {
const res = await request(app).get('/');
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('message', 'EMERGES TES Backend API');
expect(res.body).toHaveProperty('version', '1.0.0');
expect(res.body.endpoints).toMatchObject({
auth: '/api/auth',
content: '/api/content',
glossary: '/api/glossary',
health: '/health',
});
});
});
describe('GET /health', () => {
it('devuelve 200 y estado de salud', async () => {
const res = await request(app).get('/health');
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('status');
expect(['ok', 'degraded']).toContain(res.body.status);
expect(res.body).toHaveProperty('timestamp');
expect(res.body).toHaveProperty('database');
expect(res.body).toHaveProperty('uptime');
expect(res.body).toHaveProperty('memory');
});
});
describe('GET /api/glossary', () => {
it('devuelve 200 y lista de términos (público)', async () => {
const res = await request(app).get('/api/glossary');
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('items');
expect(Array.isArray(res.body.items)).toBe(true);
expect(res.body).toHaveProperty('total');
expect(typeof res.body.total).toBe('number');
});
});
describe('GET /api/glossary con query', () => {
it('acepta page y pageSize', async () => {
const res = await request(app)
.get('/api/glossary')
.query({ page: 1, pageSize: 5 });
expect(res.status).toBe(200);
expect(res.body.items.length).toBeLessThanOrEqual(5);
expect(res.body).toHaveProperty('page', 1);
expect(res.body).toHaveProperty('pageSize', 5);
});
});
describe('Rutas que requieren auth', () => {
it('GET /api/stats/validation sin token devuelve 401', async () => {
const res = await request(app).get('/api/stats/validation');
expect(res.status).toBe(401);
});
it('GET /api/validation/dashboard sin token devuelve 401', async () => {
const res = await request(app).get('/api/validation/dashboard');
expect(res.status).toBe(401);
});
});
});

86
backend/src/app.ts Normal file
View file

@ -0,0 +1,86 @@
/**
* Factory de la aplicación Express (para servidor y tests de integración).
* No arranca el servidor ni valida env; eso queda en index.ts.
*/
import express from 'express';
import cors from 'cors';
import { join } from 'path';
import { testConnection } from '../config/database.js';
import { getCorsConfig } from './config/cors.js';
import { securityHeaders } from './middleware/security-headers.js';
import { generalLimiter } from './middleware/rate-limit.js';
import requestLogger from './middleware/request-logger.js';
import authRoutes from './routes/auth.js';
import contentRoutes from './routes/content.js';
import statsRoutes from './routes/stats.js';
import contentPackRoutes from './routes/content-pack.js';
import contentPackAdminRoutes from './routes/content-pack-admin.js';
import mediaRoutes from './routes/media.js';
import contentResourcesRoutes from './routes/content-resources.js';
import scormRoutes from './routes/scorm.js';
import validationRoutes from './routes/validation.js';
import drugsRoutes from './routes/drugs.js';
import glossaryRoutes from './routes/glossary.js';
import webhookRoutes from './routes/webhook.js';
import healthRoutes from './routes/health.js';
export function createApp(): express.Express {
const app = express();
app.use(securityHeaders);
app.use(cors(getCorsConfig()));
app.use(generalLimiter);
app.use(requestLogger);
app.use(express.json({ limit: '10mb' }));
app.use('/storage/media', express.static(join(process.cwd(), 'storage', 'media')));
app.get('/health', async (_req, res) => {
const dbStart = Date.now();
const dbConnected = await testConnection();
const dbResponseTime = Date.now() - dbStart;
res.json({
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),
},
});
});
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',
drugs: '/api/drugs',
glossary: '/api/glossary',
health: '/health',
},
});
});
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/glossary', glossaryRoutes);
app.use('/api/webhook', webhookRoutes);
app.use('/api/health', healthRoutes);
return app;
}

View file

@ -0,0 +1,6 @@
/**
* Application Layer
* Servicios y casos de uso; las rutas delegan aquí.
*/
export * from './services/index.js';

View file

@ -0,0 +1,307 @@
/**
* ContentService - Application Layer
* Lógica de negocio de contenido (protocolos, guías, manuales, checklists).
* Delega persistencia en IContentRepository (Infrastructure).
*/
import { query } from '../../../config/database.js';
import type { IContentRepository, ContentFilters } from '../../domain/repositories/IContentRepository.js';
import type { ContentItem } from '../../domain/entities/ContentItem.js';
import { ContentStatus } from '../../domain/value-objects/ContentStatus.js';
import { ContentPriority } from '../../domain/value-objects/ContentPriority.js';
import type { CreateContentInput, UpdateContentInput } from '../../shared/schemas/content.js';
export interface ListContentFilters {
type?: string;
level?: string;
status?: string;
category?: string;
search?: string;
page?: number;
pageSize?: number;
}
export interface ListContentResult {
items: Array<{
id: string;
type: string;
level: string;
title: string;
shortTitle?: string;
description?: string;
status: string;
version: number;
latestVersion: number;
createdAt: string;
updatedAt: string;
createdBy?: string;
updatedBy?: string;
}>;
total: number;
page: number;
pageSize: number;
}
export interface ContentItemDetail {
[key: string]: unknown;
content?: Record<string, unknown> & { markdown?: string };
}
export interface InvalidateCacheFn {
(): Promise<void>;
}
/**
* Servicio de aplicación para contenido.
* Depende de IContentRepository y función de invalidación de caché.
*/
export class ContentService {
constructor(
private readonly contentRepo: IContentRepository,
private readonly invalidateCache: InvalidateCacheFn
) {}
private contentItemToListRow(item: ContentItem): ListContentResult['items'][number] {
return {
id: item.id,
type: item.type,
level: item.level,
title: item.title,
shortTitle: item.shortTitle,
description: item.description,
status: item.status.toString(),
version: item.version,
latestVersion: item.latestVersion,
createdAt: item.createdAt instanceof Date ? item.createdAt.toISOString() : String(item.createdAt),
updatedAt: item.updatedAt instanceof Date ? item.updatedAt.toISOString() : String(item.updatedAt),
createdBy: item.createdBy,
updatedBy: item.updatedBy,
};
}
private contentItemToDetail(item: ContentItem): ContentItemDetail {
return {
id: item.id,
type: item.type,
slug: item.slug,
level: item.level,
title: item.title,
shortTitle: item.shortTitle,
description: item.description,
content: item.content,
category: item.category,
subcategory: item.subcategory,
priority: item.priority.toString(),
ageGroup: item.ageGroup,
status: item.status.toString(),
version: item.version,
latestVersion: item.latestVersion,
createdAt: item.createdAt,
updatedAt: item.updatedAt,
createdBy: item.createdBy,
updatedBy: item.updatedBy,
};
}
async list(filters: ListContentFilters): Promise<ListContentResult> {
const page = filters.page ?? 1;
const pageSize = filters.pageSize ?? 20;
const pageNum = typeof page === 'number' ? page : parseInt(String(page), 10) || 1;
const pageSizeNum = typeof pageSize === 'number' ? pageSize : parseInt(String(pageSize), 10) || 20;
const repoFilters: ContentFilters = {
type: filters.type,
level: filters.level,
status: filters.status,
category: filters.category,
search: filters.search,
page: pageNum,
pageSize: pageSizeNum,
};
const { items, total } = await this.contentRepo.findAll(repoFilters);
const listItems = items.map((item) => this.contentItemToListRow(item));
return { items: listItems, total, page: pageNum, pageSize: pageSizeNum };
}
async getById(id: string): Promise<ContentItemDetail | null> {
const item = await this.contentRepo.findById(id);
if (!item) return null;
return this.contentItemToDetail(item);
}
async create(
id: string,
input: CreateContentInput,
userId: string
): Promise<{ id: string }> {
const existing = await this.contentRepo.findById(id);
if (existing) {
throw new Error('ID_ALREADY_EXISTS');
}
const statusStr = (input as Record<string, unknown>).status as string | undefined ?? 'draft';
const priorityStr = input.priority ?? 'media';
const now = new Date();
const contentItem: ContentItem = {
id,
type: input.type,
slug: input.slug ?? id,
level: input.level,
title: input.title,
shortTitle: input.shortTitle,
description: input.description,
content: (input.content ?? {}) as Record<string, unknown>,
category: input.category,
subcategory: input.subcategory,
priority: ContentPriority.fromString(priorityStr),
ageGroup: input.ageGroup,
status: ContentStatus.fromString(statusStr),
version: 1,
latestVersion: 1,
createdAt: now,
updatedAt: now,
createdBy: userId,
updatedBy: userId,
tags: input.tags,
};
await this.contentRepo.save(contentItem);
await this.invalidateCache();
return { id };
}
async update(
id: string,
input: UpdateContentInput,
userId: string
): Promise<{ id: string; version: number }> {
const current = await this.contentRepo.findById(id);
if (!current) {
throw new Error('CONTENT_NOT_FOUND');
}
const inputRecord = input as Record<string, unknown>;
const content = input.content ?? current.content;
const statusStr = (inputRecord.status as string | undefined) ?? current.status.toString();
const priorityStr = (input.priority ?? current.priority.toString()) as string;
const merged: ContentItem = {
...current,
title: input.title ?? current.title,
shortTitle: input.shortTitle ?? current.shortTitle,
description: input.description ?? current.description,
content: content as Record<string, unknown>,
category: input.category ?? current.category,
subcategory: input.subcategory ?? current.subcategory,
priority: ContentPriority.fromString(priorityStr),
ageGroup: input.ageGroup ?? current.ageGroup,
status: ContentStatus.fromString(statusStr),
updatedAt: new Date(),
updatedBy: userId,
tags: input.tags ?? current.tags,
};
const saved = await this.contentRepo.save(merged);
await this.invalidateCache();
return { id, version: saved.latestVersion };
}
async getVersions(id: string): Promise<{ versions: unknown[] }> {
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]
);
return { versions: result.rows };
}
async validate(id: string, approved: boolean, userId: string): Promise<{ id: string; status: string }> {
const result = await query(
`SELECT latest_version, current_version_id FROM tes_content.content_items WHERE id = $1`,
[id]
);
if (result.rows.length === 0) {
throw new Error('CONTENT_NOT_FOUND');
}
const item = result.rows[0] as { current_version_id: string | null };
const newStatus = approved ? 'approved' : 'in_review';
await query(
`UPDATE tes_content.content_items
SET status = $1, validated_by = $2, validated_at = NOW()
WHERE id = $3`,
[newStatus, userId, id]
);
if (item.current_version_id) {
await query(
`UPDATE tes_content.content_versions
SET validated_by = $1, validated_at = NOW()
WHERE version_id = $2`,
[userId, item.current_version_id]
);
}
await query(
`INSERT INTO tes_content.content_change_log
(content_item_id, user_id, action, details)
VALUES ($1, $2, $3, $4)`,
[id, userId, approved ? 'approve' : 'validate', JSON.stringify({ approved })]
);
return { id, status: newStatus };
}
async getPackLatest(): Promise<{
version: string;
timestamp: string;
hash: string;
protocols: unknown[];
guides: unknown[];
manuals: unknown[];
drugs: unknown[];
checklists: unknown[];
}> {
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`
);
const pack = {
version: '1.0.0',
timestamp: new Date().toISOString(),
hash: '',
protocols: [] as unknown[],
guides: [] as unknown[],
manuals: [] as unknown[],
drugs: [] as unknown[],
checklists: [] as unknown[],
};
for (const row of result.rows as Record<string, unknown>[]) {
const content = (row.json_content as Record<string, unknown>) || {};
if (row.markdown_content) {
content.markdown = row.markdown_content;
}
const itemData = { ...row, content };
const type = row.type as string;
if (type === 'protocol') pack.protocols.push(itemData);
else if (type === 'guide') pack.guides.push(itemData);
else if (type === 'manual') pack.manuals.push(itemData);
else if (type === 'drug') pack.drugs.push(itemData);
else if (type === 'checklist') pack.checklists.push(itemData);
}
return pack;
}
}

View file

@ -0,0 +1,405 @@
/**
* DrugService - Application Layer
* Lógica de negocio del vademécum (fármacos).
* Delega persistencia en IDrugRepository; versionado (drug_versions) sigue con query.
*/
import { query } from '../../../config/database.js';
import { randomUUID } from 'crypto';
import { validateDrug, normalizeDrug, createDrugSnapshot, compareDrugs } from '../../models/Drug.js';
import type { Drug as ModelDrug } from '../../models/Drug.js';
import type { IDrugRepository, DrugFilters } from '../../domain/repositories/IDrugRepository.js';
import type { Drug as DomainDrug } from '../../domain/entities/Drug.js';
import { ContentStatus } from '../../domain/value-objects/ContentStatus.js';
export interface DrugListFilters {
category?: string;
line?: string;
frequency?: string;
status?: string;
search?: string;
page?: number | string;
limit?: number | string;
}
export interface DrugListResult {
drugs: unknown[];
pagination: { page: number; limit: number; total: number; totalPages: number };
}
export interface DrugValidationResult {
valid: boolean;
errors: string[];
}
/**
* Convierte Drug de dominio a fila para API (snake_case).
*/
function drugToResponseRow(drug: DomainDrug): Record<string, unknown> {
return {
id: drug.id,
slug: drug.slug,
generic_name: drug.genericName,
trade_name: drug.tradeName ?? null,
category: drug.category,
line: drug.line,
frequency: drug.frequency,
presentation: drug.presentation,
adult_dose: drug.adultDose,
pediatric_dose: drug.pediatricDose ?? null,
routes: [...drug.routes],
dilution: drug.dilution ?? null,
indications: [...drug.indications],
contraindications: [...drug.contraindications],
side_effects: drug.sideEffects ?? null,
antidote: drug.antidote ?? null,
notes: [...drug.notes],
critical_points: [...drug.criticalPoints],
source: drug.source ?? null,
status: drug.status.toString(),
version: drug.version,
latest_version: drug.latestVersion,
created_by: drug.createdBy,
created_at: drug.createdAt,
updated_by: drug.updatedBy ?? null,
updated_at: drug.updatedAt,
};
}
/**
* Convierte Drug de dominio a modelo (snake_case) para validateDrug/normalizeDrug/compareDrugs.
*/
function domainDrugToModel(drug: DomainDrug): ModelDrug {
return {
id: drug.id,
slug: drug.slug,
generic_name: drug.genericName,
trade_name: drug.tradeName ?? null,
category: drug.category,
line: drug.line,
frequency: drug.frequency,
presentation: drug.presentation,
adult_dose: drug.adultDose,
pediatric_dose: drug.pediatricDose ?? null,
routes: [...drug.routes],
dilution: drug.dilution ?? null,
indications: [...drug.indications],
contraindications: [...drug.contraindications],
side_effects: drug.sideEffects ?? null,
antidote: drug.antidote ?? null,
notes: [...drug.notes],
critical_points: [...drug.criticalPoints],
source: drug.source ?? null,
status: drug.status.toString() as ModelDrug['status'],
version: drug.version,
latest_version: drug.latestVersion,
created_by: drug.createdBy,
created_at: drug.createdAt,
updated_by: drug.updatedBy ?? undefined,
updated_at: drug.updatedAt,
};
}
/**
* Construye Drug de dominio desde modelo normalizado (create/update).
*/
function buildDomainDrugFromModel(normalized: ModelDrug, id: string, userId: string): DomainDrug {
const now = new Date();
return {
id,
slug: normalized.slug ?? id,
genericName: normalized.generic_name,
tradeName: normalized.trade_name ?? undefined,
category: normalized.category as DomainDrug['category'],
line: normalized.line,
frequency: normalized.frequency,
presentation: normalized.presentation,
adultDose: normalized.adult_dose,
pediatricDose: normalized.pediatric_dose ?? undefined,
routes: (normalized.routes ?? []) as DomainDrug['routes'],
dilution: normalized.dilution ?? undefined,
indications: normalized.indications ?? [],
contraindications: normalized.contraindications ?? [],
sideEffects: normalized.side_effects ?? undefined,
antidote: normalized.antidote ?? undefined,
notes: normalized.notes ?? [],
criticalPoints: normalized.critical_points ?? [],
source: normalized.source ?? undefined,
status: ContentStatus.fromString(normalized.status ?? 'draft'),
version: normalized.version ?? '1.0.0',
latestVersion: normalized.latest_version ?? normalized.version ?? '1.0.0',
createdAt: now,
updatedAt: now,
createdBy: userId,
updatedBy: userId,
};
}
/**
* Servicio de aplicación para fármacos.
*/
export class DrugService {
constructor(private readonly drugRepo: IDrugRepository) {}
async list(filters: DrugListFilters): Promise<DrugListResult> {
const pageNum = typeof filters.page === 'string' ? parseInt(filters.page, 10) : (filters.page ?? 1);
const limitNum = typeof filters.limit === 'string' ? parseInt(filters.limit, 10) : (filters.limit ?? 50);
const repoFilters: DrugFilters = {
category: filters.category,
line: filters.line as DrugFilters['line'],
frequency: filters.frequency as DrugFilters['frequency'],
status: filters.status,
search: filters.search,
page: pageNum,
pageSize: limitNum,
};
const { items, total } = await this.drugRepo.findAll(repoFilters);
const drugs = items.map(drugToResponseRow);
return {
drugs,
pagination: {
page: pageNum,
limit: limitNum,
total,
totalPages: Math.ceil(total / limitNum),
},
};
}
async getById(id: string): Promise<Record<string, unknown> | null> {
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 drug = isUUID
? await this.drugRepo.findById(id)
: await this.drugRepo.findBySlug(id);
if (!drug) return null;
return drugToResponseRow(drug);
}
validateAndNormalize(drugData: Partial<ModelDrug>): { normalized: ModelDrug; validation: DrugValidationResult } {
const normalized = normalizeDrug(drugData);
const validation = validateDrug(normalized);
return { normalized, validation };
}
async create(drugData: Partial<ModelDrug>, userId: string): Promise<{ drug: Record<string, unknown> }> {
const { normalized, validation } = this.validateAndNormalize(drugData);
if (!validation.valid) {
const err = new Error('VALIDATION_ERROR') as Error & { details?: string[] };
err.details = validation.errors;
throw err;
}
let slug = normalized.slug;
if (!slug) {
slug = (normalized.generic_name ?? '')
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
if (await this.drugRepo.existsBySlug(slug)) {
throw new Error('SLUG_ALREADY_EXISTS');
}
const drugId = randomUUID();
const domainDrug = buildDomainDrugFromModel({ ...normalized, slug }, drugId, userId);
await this.drugRepo.save(domainDrug);
const versionId = randomUUID();
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',
userId,
]
);
await query(
'UPDATE tes_content.drugs SET current_version_id = $1 WHERE id = $2',
[versionId, drugId]
);
const saved = await this.drugRepo.findById(drugId);
return { drug: saved ? drugToResponseRow(saved) : drugToResponseRow(domainDrug) };
}
async update(id: string, drugData: Partial<ModelDrug>, userId: string, canEdit: (row: Record<string, unknown>) => boolean): Promise<{ drug: Record<string, unknown> }> {
const current = await this.drugRepo.findById(id);
if (!current) {
throw new Error('DRUG_NOT_FOUND');
}
if (!canEdit(drugToResponseRow(current))) {
throw new Error('FORBIDDEN');
}
const oldRow = domainDrugToModel(current);
const { normalized, validation } = this.validateAndNormalize({ ...oldRow, ...drugData });
if (!validation.valid) {
const err = new Error('VALIDATION_ERROR') as Error & { details?: string[] };
err.details = validation.errors;
throw err;
}
const changes = compareDrugs(oldRow, normalized);
let newVersion = current.version ?? '1.0.0';
if (changes.fields_changed.length > 0) {
const [major, minor, patch] = newVersion.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}`;
}
}
const baseMerged = buildDomainDrugFromModel(normalized, id, userId);
const domainMerged: DomainDrug = {
...baseMerged,
version: newVersion,
latestVersion: newVersion,
updatedBy: userId,
};
await this.drugRepo.save(domainMerged);
if (changes.fields_changed.length > 0) {
const versionId = randomUUID();
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,
userId,
]
);
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]
);
await query(
'UPDATE tes_content.drugs SET current_version_id = $1 WHERE id = $2',
[versionId, id]
);
}
const saved = await this.drugRepo.findById(id);
return { drug: saved ? drugToResponseRow(saved) : drugToResponseRow(domainMerged) };
}
async submit(id: string, userId: string): Promise<{ drug: Record<string, unknown> }> {
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`,
[userId, id]
);
if (result.rows.length === 0) {
throw new Error('DRUG_NOT_FOUND_OR_NOT_DRAFT');
}
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, userId]
);
return { drug: result.rows[0] as Record<string, unknown> };
}
async approve(id: string, userId: string, notes?: string): Promise<{ drug: Record<string, unknown> }> {
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`,
[userId, id]
);
if (result.rows.length === 0) {
throw new Error('DRUG_NOT_FOUND_OR_NOT_IN_REVIEW');
}
await query(
`INSERT INTO tes_content.audit_logs (entity_type, entity_id, action, user_id, metadata)
VALUES ('drug', $1, 'approve', $2, $3::jsonb)`,
[id, userId, JSON.stringify({ notes: notes ?? null })]
);
return { drug: result.rows[0] as Record<string, unknown> };
}
async publish(id: string, userId: string): Promise<{ drug: Record<string, unknown> }> {
const existing = await query(
'SELECT id, status, pediatric_dose FROM tes_content.drugs WHERE id = $1',
[id]
);
if (existing.rows.length === 0) {
throw new Error('DRUG_NOT_FOUND');
}
const drug = existing.rows[0] as { status: string; pediatric_dose: string | null };
if (drug.status !== 'approved') {
throw new Error('DRUG_MUST_BE_APPROVED');
}
if (!drug.pediatric_dose || drug.pediatric_dose.trim() === '') {
throw new Error('PEDIATRIC_DOSE_REQUIRED');
}
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`,
[userId, id]
);
await query(
`INSERT INTO tes_content.audit_logs (entity_type, entity_id, action, user_id, metadata)
VALUES ('drug', $1, 'publish', $2, '{}'::jsonb)`,
[id, userId]
);
return { drug: result.rows[0] as Record<string, unknown> };
}
async getVersions(id: string): Promise<{ versions: unknown[] }> {
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]
);
return { versions: result.rows };
}
}

View file

@ -0,0 +1,109 @@
/**
* GlossaryService - Application Layer
* Lógica de negocio del glosario. Delega persistencia en IGlossaryRepository.
*/
import { randomUUID } from 'crypto';
import type { IGlossaryRepository, GlossaryFilters } from '../../domain/repositories/IGlossaryRepository.js';
import type { GlossaryTerm } from '../../domain/entities/GlossaryTerm.js';
import { ContentStatus } from '../../domain/value-objects/ContentStatus.js';
import type {
CreateGlossaryTermInput,
UpdateGlossaryTermInput,
ListGlossaryQuery,
} from '../../shared/schemas/glossary.js';
export interface GlossaryListResult {
items: GlossaryTerm[];
total: number;
page: number;
pageSize: number;
}
export class GlossaryService {
constructor(private readonly glossaryRepo: IGlossaryRepository) {}
async findById(id: string): Promise<GlossaryTerm | null> {
return this.glossaryRepo.findById(id);
}
async findByTerm(term: string): Promise<GlossaryTerm | null> {
return this.glossaryRepo.findByTerm(term);
}
async findAll(filters: ListGlossaryQuery): Promise<GlossaryListResult> {
const page = filters.page ?? 1;
const pageSize = filters.pageSize ?? 20;
const repoFilters: GlossaryFilters = {
category: filters.category,
search: filters.search,
page,
pageSize,
};
const { items, total } = await this.glossaryRepo.findAll(repoFilters);
return { items, total, page, pageSize };
}
async search(query: string): Promise<GlossaryTerm[]> {
return this.glossaryRepo.search(query);
}
async create(input: CreateGlossaryTermInput, userId: string): Promise<GlossaryTerm> {
const existing = await this.glossaryRepo.findByTerm(input.term);
if (existing && existing.category === input.category) {
throw new Error('GLOSSARY_TERM_ALREADY_EXISTS');
}
const id = randomUUID();
const now = new Date();
const term: GlossaryTerm = {
id,
term: input.term,
abbreviation: input.abbreviation,
category: input.category,
definition: input.definition,
context: input.context,
examples: input.examples,
relatedTerms: input.relatedTerms,
source: input.source,
status: ContentStatus.DRAFT,
createdAt: now,
updatedAt: now,
createdBy: userId,
updatedBy: userId,
};
return this.glossaryRepo.save(term);
}
async update(input: UpdateGlossaryTermInput, userId: string): Promise<GlossaryTerm> {
const current = await this.glossaryRepo.findById(input.id);
if (!current) {
throw new Error('GLOSSARY_TERM_NOT_FOUND');
}
const updated: GlossaryTerm = {
...current,
term: input.term ?? current.term,
abbreviation: input.abbreviation !== undefined ? input.abbreviation : current.abbreviation,
category: input.category ?? current.category,
definition: input.definition ?? current.definition,
context: input.context !== undefined ? input.context : current.context,
examples: input.examples !== undefined ? input.examples : current.examples,
relatedTerms: input.relatedTerms !== undefined ? input.relatedTerms : current.relatedTerms,
source: input.source !== undefined ? input.source : current.source,
updatedAt: new Date(),
updatedBy: userId,
};
return this.glossaryRepo.save(updated);
}
async delete(id: string): Promise<void> {
const current = await this.glossaryRepo.findById(id);
if (!current) {
throw new Error('GLOSSARY_TERM_NOT_FOUND');
}
await this.glossaryRepo.delete(id);
}
}

View file

@ -0,0 +1,257 @@
/**
* StatsService - Application Layer
* Lógica de negocio de estadísticas (contenido, validación, media).
* Las rutas delegan aquí; usa caché inyectable.
*/
import { query } from '../../../config/database.js';
export interface CacheAdapter {
get<T>(key: string): Promise<T | null>;
set(key: string, value: unknown, ttlSeconds?: number): Promise<void>;
}
const CACHE_KEY_CONTENT = 'stats:content';
const CACHE_KEY_VALIDATION = 'stats:validation';
const CACHE_KEY_MEDIA = 'stats:media';
const CACHE_TTL = 300;
export interface ContentStatsResponse {
total: number;
byType: Record<string, number>;
byStatus: Record<string, number>;
byLevel: Record<string, number>;
byPriority: Record<string, number>;
publishedByType: Record<string, number>;
protocols: number;
protocolsPublished: number;
guides: number;
guidesPublished: number;
drugs: number;
drugsPublished: number;
checklists: number;
checklistsPublished: number;
}
export interface ValidationStatsResponse {
pending: number;
byStatus: Record<string, number>;
recentActivity: unknown[];
avgValidationTime: string | null;
rejectionsLast30Days: number;
mostRejected: unknown[];
}
export interface MediaStatsResponse {
total: number;
byType: Record<string, number>;
orphaned: number;
totalSize: number;
fileCount: number;
}
interface StatsRow {
count: string;
[key: string]: unknown;
}
/**
* Servicio de aplicación para estadísticas.
*/
export class StatsService {
constructor(private readonly cache: CacheAdapter) {}
async getContentStats(): Promise<ContentStatsResponse> {
const cached = await this.cache.get<ContentStatsResponse>(CACHE_KEY_CONTENT);
if (cached) return cached;
const [total, byType, byStatus, byLevel, byPriority] = await Promise.all([
query('SELECT COUNT(*) as count FROM tes_content.content_items'),
query('SELECT type, COUNT(*) as count FROM tes_content.content_items GROUP BY type'),
query('SELECT status, COUNT(*) as count FROM tes_content.content_items GROUP BY status'),
query('SELECT level, COUNT(*) as count FROM tes_content.content_items GROUP BY level'),
query('SELECT priority, COUNT(*) as count FROM tes_content.content_items GROUP BY priority'),
]);
const publishedByType = await query(
`SELECT type, COUNT(*) as count FROM tes_content.content_items
WHERE status = 'published'::tes_content.content_status GROUP BY type`
);
const [drugsTotal, drugsPublished] = await Promise.all([
query('SELECT COUNT(*) as count FROM tes_content.drugs'),
query('SELECT COUNT(*) as count FROM tes_content.drugs WHERE status = \'published\'::tes_content.content_status'),
]);
const publishedCounts = (publishedByType.rows as StatsRow[]).reduce(
(acc: Record<string, number>, row) => {
acc[row.type as string] = parseInt(row.count, 10);
return acc;
},
{}
);
const byTypeObj = (byType.rows as StatsRow[]).reduce(
(acc: Record<string, number>, row) => {
acc[row.type as string] = parseInt(row.count, 10);
return acc;
},
{}
);
const stats: ContentStatsResponse = {
total: parseInt((total.rows[0] as StatsRow).count, 10) + parseInt((drugsTotal.rows[0] as StatsRow).count, 10),
byType: { ...byTypeObj, drug: parseInt((drugsTotal.rows[0] as StatsRow).count, 10) },
byStatus: (byStatus.rows as StatsRow[]).reduce(
(acc: Record<string, number>, row) => {
acc[row.status as string] = parseInt(row.count, 10);
return acc;
},
{}
),
byLevel: (byLevel.rows as StatsRow[]).reduce(
(acc: Record<string, number>, row) => {
acc[row.level as string] = parseInt(row.count, 10);
return acc;
},
{}
),
byPriority: (byPriority.rows as StatsRow[]).reduce(
(acc: Record<string, number>, row) => {
acc[row.priority as string] = parseInt(row.count, 10);
return acc;
},
{}
),
publishedByType: {
...publishedCounts,
drug: parseInt((drugsPublished.rows[0] as StatsRow).count, 10),
},
protocols: byTypeObj.protocol ?? 0,
protocolsPublished: publishedCounts.protocol ?? 0,
guides: byTypeObj.guide ?? 0,
guidesPublished: publishedCounts.guide ?? 0,
drugs: parseInt((drugsTotal.rows[0] as StatsRow).count, 10),
drugsPublished: parseInt((drugsPublished.rows[0] as StatsRow).count, 10),
checklists: byTypeObj.checklist ?? 0,
checklistsPublished: publishedCounts.checklist ?? 0,
};
await this.cache.set(CACHE_KEY_CONTENT, stats, CACHE_TTL);
return stats;
}
async getValidationStats(): Promise<ValidationStatsResponse> {
const cached = await this.cache.get<ValidationStatsResponse>(CACHE_KEY_VALIDATION);
if (cached) return cached;
const [pending, byStatus, recentActivity, avgTime] = await Promise.all([
query(`
SELECT COUNT(*) as count FROM tes_content.content_items
WHERE status = 'in_review'::tes_content.content_status
`),
query(`
SELECT status, COUNT(*) as count FROM tes_content.content_items
WHERE status IN ('draft', 'in_review', 'approved', 'published') GROUP BY status
`),
query(`
SELECT al.action, al.created_at, ci.title, ci.type, u.username
FROM tes_content.audit_logs al
JOIN tes_content.content_items ci ON al.entity_id = ci.id
LEFT JOIN tes_content.users u ON al.user_id = u.id
WHERE al.entity_type = 'content_item'
AND al.action IN ('submit', 'approve', 'reject', 'publish')
AND al.created_at >= NOW() - INTERVAL '30 days'
ORDER BY al.created_at DESC LIMIT 30
`),
query(`
WITH validation_times AS (
SELECT ci.id,
MIN(CASE WHEN al.action = 'submit' THEN al.created_at END) as submitted_at,
MIN(CASE WHEN al.action IN ('approve', 'reject') THEN al.created_at END) as validated_at
FROM tes_content.content_items ci
JOIN tes_content.audit_logs al ON al.entity_id = ci.id
WHERE al.entity_type = 'content_item' AND al.action IN ('submit', 'approve', 'reject')
GROUP BY ci.id
HAVING MIN(CASE WHEN al.action = 'submit' THEN al.created_at END) IS NOT NULL
AND MIN(CASE WHEN al.action IN ('approve', 'reject') THEN al.created_at END) IS NOT NULL
)
SELECT AVG(EXTRACT(EPOCH FROM (validated_at - submitted_at)) / 86400) as avg_days FROM validation_times
`),
]);
const rejections = await query(`
SELECT COUNT(*) as count FROM tes_content.audit_logs
WHERE entity_type = 'content_item' AND action = 'reject'
AND created_at >= NOW() - INTERVAL '30 days'
`);
const mostRejected = await query(`
SELECT ci.id, ci.title, ci.type, COUNT(*) as rejection_count
FROM tes_content.content_items ci
JOIN tes_content.audit_logs al ON al.entity_id = ci.id
WHERE al.entity_type = 'content_item' AND al.action = 'reject'
GROUP BY ci.id, ci.title, ci.type ORDER BY rejection_count DESC LIMIT 5
`);
const recentActivityMapped = (recentActivity.rows as { action: string; created_at: string; title?: string; type?: string; username?: string }[]).map((row) => ({
...row,
timestamp: row.created_at,
}));
const stats: ValidationStatsResponse = {
pending: parseInt((pending.rows[0] as StatsRow).count, 10),
byStatus: (byStatus.rows as StatsRow[]).reduce(
(acc: Record<string, number>, row) => {
acc[row.status as string] = parseInt(row.count, 10);
return acc;
},
{}
),
recentActivity: recentActivityMapped,
avgValidationTime:
avgTime.rows[0] && (avgTime.rows[0] as { avg_days?: string }).avg_days
? parseFloat((avgTime.rows[0] as { avg_days: string }).avg_days).toFixed(1)
: null,
rejectionsLast30Days: parseInt((rejections.rows[0] as StatsRow).count, 10),
mostRejected: mostRejected.rows,
};
await this.cache.set(CACHE_KEY_VALIDATION, stats, CACHE_TTL);
return stats;
}
async getMediaStats(): Promise<MediaStatsResponse> {
const cached = await this.cache.get<MediaStatsResponse>(CACHE_KEY_MEDIA);
if (cached) return cached;
const [total, byType, orphaned, totalSize] = await Promise.all([
query('SELECT COUNT(*) as count FROM tes_content.media_resources'),
query('SELECT type, COUNT(*) as count FROM tes_content.media_resources GROUP BY type'),
query(`
SELECT COUNT(*) as count FROM tes_content.media_resources mr
LEFT JOIN tes_content.content_resource_associations cra ON mr.id = cra.media_resource_id
WHERE cra.id IS NULL
`),
query(`
SELECT COUNT(*) as file_count, SUM(COALESCE((metadata->>'size')::bigint, 0)) as total_size
FROM tes_content.media_resources
`),
]);
const sizeRow = totalSize.rows[0] as { file_count?: string; total_size?: string };
const stats: MediaStatsResponse = {
total: parseInt((total.rows[0] as StatsRow).count, 10),
byType: (byType.rows as StatsRow[]).reduce(
(acc: Record<string, number>, row) => {
acc[row.type as string] = parseInt(row.count, 10);
return acc;
},
{}
),
orphaned: parseInt((orphaned.rows[0] as StatsRow).count, 10),
totalSize: sizeRow?.total_size ? parseInt(sizeRow.total_size, 10) : 0,
fileCount: parseInt(sizeRow?.file_count ?? '0', 10),
};
await this.cache.set(CACHE_KEY_MEDIA, stats, CACHE_TTL);
return stats;
}
}

View file

@ -0,0 +1,156 @@
/**
* ValidationService - Application Layer
* Workflow de validación médica: transiciones de estado y auditoría.
* Usa ContentStatus para reglas de transición; delega persistencia a IValidationRepository.
*/
import { ContentStatus } from '../../domain/value-objects/ContentStatus.js';
import type {
IValidationRepository,
PendingValidationFilters,
PendingItem,
ValidationHistoryEntry,
} from '../../domain/repositories/IValidationRepository.js';
export type ValidationActionResult =
| { ok: true; status: string; message: string }
| { ok: false; code: 'NOT_FOUND' | 'INVALID_TRANSITION' | 'VALIDATION_ERROR'; message: string };
export interface ValidationServicePendingResult {
items: PendingItem[];
total: number;
}
export class ValidationService {
constructor(private readonly validationRepo: IValidationRepository) {}
async submit(contentId: string, userId: string, notes?: string | null): Promise<ValidationActionResult> {
const content = await this.validationRepo.getContentForValidation(contentId);
if (!content) return { ok: false, code: 'NOT_FOUND', message: 'Contenido no encontrado' };
const current = ContentStatus.fromString(content.status);
const target = ContentStatus.IN_REVIEW;
if (!current.canTransitionTo(target)) {
return {
ok: false,
code: 'INVALID_TRANSITION',
message: 'Solo se puede enviar contenido en estado "draft"',
};
}
await this.validationRepo.updateContentStatus(contentId, 'in_review', userId);
await this.validationRepo.insertAuditLog('content_item', contentId, 'submit', userId, {
notes: notes ?? null,
previous_status: 'draft',
});
return {
ok: true,
status: 'in_review',
message: 'Contenido enviado a revisión',
};
}
async approve(
contentId: string,
userId: string,
notes?: string | null,
publish?: boolean
): Promise<ValidationActionResult> {
const content = await this.validationRepo.getContentForValidation(contentId);
if (!content) return { ok: false, code: 'NOT_FOUND', message: 'Contenido no encontrado' };
const newStatus = publish ? 'published' : 'approved';
const current = ContentStatus.fromString(content.status);
const target = ContentStatus.fromString(newStatus);
if (!current.canTransitionTo(target)) {
return {
ok: false,
code: 'INVALID_TRANSITION',
message: 'Solo se puede aprobar contenido en estado "in_review"',
};
}
await this.validationRepo.updateContentStatus(contentId, newStatus, userId, {
validatedBy: userId,
});
await this.validationRepo.insertAuditLog('content_item', contentId, 'approve', userId, {
notes: notes ?? null,
previous_status: 'in_review',
published: publish ?? false,
});
return {
ok: true,
status: newStatus,
message: publish ? 'Contenido aprobado y publicado' : 'Contenido aprobado',
};
}
async reject(contentId: string, userId: string, notes?: string | null): Promise<ValidationActionResult> {
if (notes == null || String(notes).trim().length === 0) {
return {
ok: false,
code: 'VALIDATION_ERROR',
message: 'Las notas de rechazo son obligatorias',
};
}
const content = await this.validationRepo.getContentForValidation(contentId);
if (!content) return { ok: false, code: 'NOT_FOUND', message: 'Contenido no encontrado' };
const current = ContentStatus.fromString(content.status);
const target = ContentStatus.DRAFT;
if (!current.canTransitionTo(target)) {
return {
ok: false,
code: 'INVALID_TRANSITION',
message: 'Solo se puede rechazar contenido en estado "in_review"',
};
}
await this.validationRepo.updateContentStatus(contentId, 'draft', userId);
await this.validationRepo.insertAuditLog('content_item', contentId, 'reject', userId, {
notes,
previous_status: 'in_review',
});
return {
ok: true,
status: 'draft',
message: 'Contenido rechazado y devuelto a borrador',
};
}
async publish(contentId: string, userId: string): Promise<ValidationActionResult> {
const content = await this.validationRepo.getContentForValidation(contentId);
if (!content) return { ok: false, code: 'NOT_FOUND', message: 'Contenido no encontrado' };
if (content.status !== 'approved') {
return {
ok: false,
code: 'INVALID_TRANSITION',
message: 'Solo se puede publicar contenido en estado "approved"',
};
}
await this.validationRepo.updateContentStatus(contentId, 'published', userId);
await this.validationRepo.insertAuditLog('content_item', contentId, 'publish', userId, {
previous_status: 'approved',
});
return {
ok: true,
status: 'published',
message: 'Contenido publicado',
};
}
async getPending(filters: PendingValidationFilters): Promise<ValidationServicePendingResult> {
return this.validationRepo.getPendingItems(filters);
}
async getHistory(contentId: string): Promise<ValidationHistoryEntry[]> {
return this.validationRepo.getHistory(contentId);
}
}

View file

@ -0,0 +1,160 @@
/**
* Tests unitarios para ContentService (TICKET-017)
* Repositorio y invalidateCache mockeados; se prueba list, getById, create.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ContentService } from '../ContentService.js';
import type { IContentRepository } from '../../../domain/repositories/IContentRepository.js';
import type { ContentItem } from '../../../domain/entities/ContentItem.js';
import { ContentStatus } from '../../../domain/value-objects/ContentStatus.js';
import { ContentPriority } from '../../../domain/value-objects/ContentPriority.js';
function createMockContentItem(overrides: Partial<ContentItem> = {}): ContentItem {
return {
id: 'item-1',
type: 'protocol',
slug: 'rcp-adulto',
level: 'operativo',
title: 'RCP Adulto',
content: {},
priority: ContentPriority.fromString('alta'),
status: ContentStatus.fromString('draft'),
version: 1,
latestVersion: 1,
createdAt: new Date(),
updatedAt: new Date(),
createdBy: 'user-1',
updatedBy: 'user-1',
...overrides,
};
}
describe('ContentService', () => {
let mockRepo: IContentRepository;
let invalidateCache: () => Promise<void>;
let service: ContentService;
beforeEach(() => {
mockRepo = {
findById: vi.fn(),
findBySlug: vi.fn(),
findAll: vi.fn(),
save: vi.fn(),
delete: vi.fn(),
existsBySlug: vi.fn(),
};
invalidateCache = vi.fn().mockResolvedValue(undefined);
service = new ContentService(mockRepo, invalidateCache);
});
describe('list', () => {
it('delega en el repositorio y mapea items a list row', async () => {
const item = createMockContentItem({ id: 'c1', title: 'Protocolo' });
vi.mocked(mockRepo.findAll).mockResolvedValue({ items: [item], total: 1 });
const result = await service.list({});
expect(mockRepo.findAll).toHaveBeenCalledWith(
expect.objectContaining({ page: 1, pageSize: 20 })
);
expect(result.items).toHaveLength(1);
expect(result.items[0].id).toBe('c1');
expect(result.items[0].title).toBe('Protocolo');
expect(result.total).toBe(1);
expect(result.page).toBe(1);
expect(result.pageSize).toBe(20);
});
it('pasa type, level, status al repositorio', async () => {
vi.mocked(mockRepo.findAll).mockResolvedValue({ items: [], total: 0 });
await service.list({
type: 'protocol',
level: 'operativo',
status: 'published',
page: 2,
pageSize: 10,
});
expect(mockRepo.findAll).toHaveBeenCalledWith({
type: 'protocol',
level: 'operativo',
status: 'published',
page: 2,
pageSize: 10,
});
});
});
describe('getById', () => {
it('devuelve detalle si existe', async () => {
const item = createMockContentItem({ id: 'c1', title: 'RCP' });
vi.mocked(mockRepo.findById).mockResolvedValue(item);
const result = await service.getById('c1');
expect(mockRepo.findById).toHaveBeenCalledWith('c1');
expect(result).not.toBeNull();
expect(result?.id).toBe('c1');
expect(result?.title).toBe('RCP');
});
it('devuelve null si no existe', async () => {
vi.mocked(mockRepo.findById).mockResolvedValue(null);
const result = await service.getById('missing');
expect(result).toBeNull();
});
});
describe('create', () => {
it('lanza si el id ya existe', async () => {
vi.mocked(mockRepo.findById).mockResolvedValue(
createMockContentItem({ id: 'existing' })
);
await expect(
service.create(
'existing',
{
type: 'protocol',
level: 'operativo',
title: 'Título',
slug: 'slug',
},
'user-1'
)
).rejects.toThrow('ID_ALREADY_EXISTS');
expect(mockRepo.save).not.toHaveBeenCalled();
});
it('guarda y llama invalidateCache si id no existe', async () => {
vi.mocked(mockRepo.findById).mockResolvedValue(null);
vi.mocked(mockRepo.save).mockResolvedValue(
createMockContentItem({ id: 'new-id' })
);
const result = await service.create(
'new-id',
{
type: 'protocol',
level: 'operativo',
title: 'Nuevo protocolo',
slug: 'nuevo-protocolo',
},
'user-1'
);
expect(mockRepo.save).toHaveBeenCalled();
const savedArg = vi.mocked(mockRepo.save).mock.calls[0][0];
expect(savedArg.id).toBe('new-id');
expect(savedArg.title).toBe('Nuevo protocolo');
expect(savedArg.createdBy).toBe('user-1');
expect(invalidateCache).toHaveBeenCalled();
expect(result.id).toBe('new-id');
});
});
});

View file

@ -0,0 +1,214 @@
/**
* Tests unitarios para GlossaryService (TICKET-017)
* Repositorio mockeado; se prueba lógica de aplicación.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { GlossaryService } from '../GlossaryService.js';
import type { IGlossaryRepository } from '../../../domain/repositories/IGlossaryRepository.js';
import type { GlossaryTerm } from '../../../domain/entities/GlossaryTerm.js';
import { ContentStatus } from '../../../domain/value-objects/ContentStatus.js';
function createMockTerm(overrides: Partial<GlossaryTerm> = {}): GlossaryTerm {
return {
id: 'term-1',
term: 'PCR',
abbreviation: 'PCR',
category: 'clinical',
definition: 'Parada cardiorrespiratoria',
status: ContentStatus.DRAFT,
createdAt: new Date(),
updatedAt: new Date(),
createdBy: 'user-1',
...overrides,
};
}
describe('GlossaryService', () => {
let mockRepo: IGlossaryRepository;
let service: GlossaryService;
beforeEach(() => {
mockRepo = {
findById: vi.fn(),
findByTerm: vi.fn(),
findAll: vi.fn(),
search: vi.fn(),
save: vi.fn(),
delete: vi.fn(),
};
service = new GlossaryService(mockRepo);
});
describe('findById', () => {
it('devuelve el término si existe', async () => {
const term = createMockTerm({ id: 't1' });
vi.mocked(mockRepo.findById).mockResolvedValue(term);
const result = await service.findById('t1');
expect(mockRepo.findById).toHaveBeenCalledWith('t1');
expect(result).toEqual(term);
});
it('devuelve null si no existe', async () => {
vi.mocked(mockRepo.findById).mockResolvedValue(null);
const result = await service.findById('inexistente');
expect(result).toBeNull();
});
});
describe('findByTerm', () => {
it('delega en el repositorio y devuelve el resultado', async () => {
const term = createMockTerm({ term: 'RCP' });
vi.mocked(mockRepo.findByTerm).mockResolvedValue(term);
const result = await service.findByTerm('RCP');
expect(mockRepo.findByTerm).toHaveBeenCalledWith('RCP');
expect(result).toEqual(term);
});
});
describe('findAll', () => {
it('aplica page y pageSize por defecto y devuelve items y total', async () => {
const items = [createMockTerm()];
vi.mocked(mockRepo.findAll).mockResolvedValue({ items, total: 1 });
const result = await service.findAll({});
expect(mockRepo.findAll).toHaveBeenCalledWith(
expect.objectContaining({ page: 1, pageSize: 20 })
);
expect(result.items).toEqual(items);
expect(result.total).toBe(1);
expect(result.page).toBe(1);
expect(result.pageSize).toBe(20);
});
it('pasa category y search al repositorio', async () => {
vi.mocked(mockRepo.findAll).mockResolvedValue({ items: [], total: 0 });
await service.findAll({
category: 'pharmaceutical',
search: 'ácido',
page: 2,
pageSize: 10,
});
expect(mockRepo.findAll).toHaveBeenCalledWith({
category: 'pharmaceutical',
search: 'ácido',
page: 2,
pageSize: 10,
});
});
});
describe('search', () => {
it('delega en el repositorio', async () => {
const items = [createMockTerm()];
vi.mocked(mockRepo.search).mockResolvedValue(items);
const result = await service.search('PCR');
expect(mockRepo.search).toHaveBeenCalledWith('PCR');
expect(result).toEqual(items);
});
});
describe('create', () => {
it('lanza si ya existe término en la misma categoría', async () => {
const existing = createMockTerm({ term: 'PCR', category: 'clinical' });
vi.mocked(mockRepo.findByTerm).mockResolvedValue(existing);
await expect(
service.create(
{ term: 'PCR', category: 'clinical', definition: 'Def' },
'user-1'
)
).rejects.toThrow('GLOSSARY_TERM_ALREADY_EXISTS');
expect(mockRepo.save).not.toHaveBeenCalled();
});
it('crea y guarda si no existe o es otra categoría', async () => {
vi.mocked(mockRepo.findByTerm).mockResolvedValue(null);
const saved = createMockTerm({ id: 'new-id', term: 'PCR' });
vi.mocked(mockRepo.save).mockResolvedValue(saved);
const result = await service.create(
{
term: 'PCR',
category: 'clinical',
definition: 'Parada cardiorrespiratoria',
abbreviation: 'PCR',
},
'user-1'
);
expect(mockRepo.findByTerm).toHaveBeenCalledWith('PCR');
expect(mockRepo.save).toHaveBeenCalled();
const savedArg = vi.mocked(mockRepo.save).mock.calls[0][0];
expect(savedArg.term).toBe('PCR');
expect(savedArg.category).toBe('clinical');
expect(savedArg.definition).toBe('Parada cardiorrespiratoria');
expect(savedArg.createdBy).toBe('user-1');
expect(result).toEqual(saved);
});
});
describe('update', () => {
it('lanza si el término no existe', async () => {
vi.mocked(mockRepo.findById).mockResolvedValue(null);
await expect(
service.update(
{ id: 'missing', definition: 'Nueva def' },
'user-1'
)
).rejects.toThrow('GLOSSARY_TERM_NOT_FOUND');
expect(mockRepo.save).not.toHaveBeenCalled();
});
it('actualiza y guarda con campos parciales', async () => {
const current = createMockTerm({ id: 't1', definition: 'Vieja' });
vi.mocked(mockRepo.findById).mockResolvedValue(current);
const updated = { ...current, definition: 'Nueva def', updatedBy: 'user-1' };
vi.mocked(mockRepo.save).mockResolvedValue(updated as GlossaryTerm);
const result = await service.update(
{ id: 't1', definition: 'Nueva def' },
'user-1'
);
expect(mockRepo.save).toHaveBeenCalled();
const savedArg = vi.mocked(mockRepo.save).mock.calls[0][0];
expect(savedArg.definition).toBe('Nueva def');
expect(savedArg.updatedBy).toBe('user-1');
expect(result).toEqual(updated);
});
});
describe('delete', () => {
it('lanza si el término no existe', async () => {
vi.mocked(mockRepo.findById).mockResolvedValue(null);
await expect(service.delete('missing')).rejects.toThrow(
'GLOSSARY_TERM_NOT_FOUND'
);
expect(mockRepo.delete).not.toHaveBeenCalled();
});
it('elimina si existe', async () => {
vi.mocked(mockRepo.findById).mockResolvedValue(createMockTerm({ id: 't1' }));
await service.delete('t1');
expect(mockRepo.delete).toHaveBeenCalledWith('t1');
});
});
});

View file

@ -0,0 +1,239 @@
/**
* Tests unitarios para ValidationService (TICKET-013 / TICKET-017)
* Repositorio mockeado; se prueba workflow y ContentStatus.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ValidationService } from '../ValidationService.js';
import type { IValidationRepository } from '../../../domain/repositories/IValidationRepository.js';
describe('ValidationService', () => {
let mockRepo: IValidationRepository;
let service: ValidationService;
beforeEach(() => {
mockRepo = {
getContentForValidation: vi.fn(),
updateContentStatus: vi.fn(),
insertAuditLog: vi.fn(),
getPendingItems: vi.fn(),
getHistory: vi.fn(),
};
service = new ValidationService(mockRepo);
});
describe('submit', () => {
it('envía a revisión cuando contenido está en draft', async () => {
vi.mocked(mockRepo.getContentForValidation).mockResolvedValue({
id: 'c1',
status: 'draft',
});
const result = await service.submit('c1', 'user-1', 'notas');
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.status).toBe('in_review');
expect(result.message).toContain('revisión');
}
expect(mockRepo.updateContentStatus).toHaveBeenCalledWith('c1', 'in_review', 'user-1');
expect(mockRepo.insertAuditLog).toHaveBeenCalledWith(
'content_item',
'c1',
'submit',
'user-1',
expect.objectContaining({ previous_status: 'draft' })
);
});
it('devuelve NOT_FOUND si el contenido no existe', async () => {
vi.mocked(mockRepo.getContentForValidation).mockResolvedValue(null);
const result = await service.submit('c-inexistente', 'user-1');
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe('NOT_FOUND');
}
expect(mockRepo.updateContentStatus).not.toHaveBeenCalled();
});
it('devuelve INVALID_TRANSITION si no está en draft', async () => {
vi.mocked(mockRepo.getContentForValidation).mockResolvedValue({
id: 'c1',
status: 'in_review',
});
const result = await service.submit('c1', 'user-1');
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe('INVALID_TRANSITION');
}
expect(mockRepo.updateContentStatus).not.toHaveBeenCalled();
});
});
describe('approve', () => {
it('aprueba sin publicar (in_review → approved)', async () => {
vi.mocked(mockRepo.getContentForValidation).mockResolvedValue({
id: 'c1',
status: 'in_review',
});
const result = await service.approve('c1', 'user-1', 'ok', false);
expect(result.ok).toBe(true);
if (result.ok) expect(result.status).toBe('approved');
expect(mockRepo.updateContentStatus).toHaveBeenCalledWith('c1', 'approved', 'user-1', {
validatedBy: 'user-1',
});
});
it('aprueba y publica (in_review → published)', async () => {
vi.mocked(mockRepo.getContentForValidation).mockResolvedValue({
id: 'c1',
status: 'in_review',
});
const result = await service.approve('c1', 'user-1', undefined, true);
expect(result.ok).toBe(true);
if (result.ok) expect(result.status).toBe('published');
expect(mockRepo.updateContentStatus).toHaveBeenCalledWith('c1', 'published', 'user-1', {
validatedBy: 'user-1',
});
});
it('devuelve INVALID_TRANSITION si no está in_review', async () => {
vi.mocked(mockRepo.getContentForValidation).mockResolvedValue({
id: 'c1',
status: 'draft',
});
const result = await service.approve('c1', 'user-1');
expect(result.ok).toBe(false);
if (!result.ok) expect(result.code).toBe('INVALID_TRANSITION');
});
});
describe('reject', () => {
it('rechaza y devuelve a draft cuando hay notas', async () => {
vi.mocked(mockRepo.getContentForValidation).mockResolvedValue({
id: 'c1',
status: 'in_review',
});
const result = await service.reject('c1', 'user-1', 'Falta referencia');
expect(result.ok).toBe(true);
if (result.ok) expect(result.status).toBe('draft');
expect(mockRepo.updateContentStatus).toHaveBeenCalledWith('c1', 'draft', 'user-1');
expect(mockRepo.insertAuditLog).toHaveBeenCalledWith(
'content_item',
'c1',
'reject',
'user-1',
expect.objectContaining({ notes: 'Falta referencia', previous_status: 'in_review' })
);
});
it('devuelve VALIDATION_ERROR si no hay notas', async () => {
vi.mocked(mockRepo.getContentForValidation).mockResolvedValue({
id: 'c1',
status: 'in_review',
});
const result = await service.reject('c1', 'user-1', '');
expect(result.ok).toBe(false);
if (!result.ok) expect(result.code).toBe('VALIDATION_ERROR');
expect(mockRepo.updateContentStatus).not.toHaveBeenCalled();
});
it('devuelve VALIDATION_ERROR si notes es null', async () => {
const result = await service.reject('c1', 'user-1', undefined);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.code).toBe('VALIDATION_ERROR');
});
});
describe('publish', () => {
it('publica cuando está approved', async () => {
vi.mocked(mockRepo.getContentForValidation).mockResolvedValue({
id: 'c1',
status: 'approved',
});
const result = await service.publish('c1', 'user-1');
expect(result.ok).toBe(true);
if (result.ok) expect(result.status).toBe('published');
expect(mockRepo.updateContentStatus).toHaveBeenCalledWith('c1', 'published', 'user-1');
});
it('devuelve INVALID_TRANSITION si no está approved', async () => {
vi.mocked(mockRepo.getContentForValidation).mockResolvedValue({
id: 'c1',
status: 'in_review',
});
const result = await service.publish('c1', 'user-1');
expect(result.ok).toBe(false);
if (!result.ok) expect(result.code).toBe('INVALID_TRANSITION');
});
});
describe('getPending', () => {
it('delega al repositorio y devuelve items y total', async () => {
const items = [
{
id: 'c1',
type: 'protocol',
slug: 'p1',
title: 'P1',
short_title: null,
description: null,
status: 'in_review',
priority: 'alta',
level: 'operativo',
created_at: '2025-01-01',
updated_at: '2025-01-02',
created_by_username: 'u1',
updated_by_username: null,
},
];
vi.mocked(mockRepo.getPendingItems).mockResolvedValue({ items, total: 1 });
const result = await service.getPending({ type: 'protocol' });
expect(mockRepo.getPendingItems).toHaveBeenCalledWith({ type: 'protocol' });
expect(result.items).toHaveLength(1);
expect(result.total).toBe(1);
});
});
describe('getHistory', () => {
it('delega al repositorio y devuelve historial', async () => {
const history = [
{
id: 'a1',
action: 'submit',
timestamp: '2025-01-01T10:00:00Z',
metadata: {},
username: 'u1',
},
];
vi.mocked(mockRepo.getHistory).mockResolvedValue(history);
const result = await service.getHistory('c1');
expect(mockRepo.getHistory).toHaveBeenCalledWith('c1');
expect(result).toHaveLength(1);
expect(result[0].action).toBe('submit');
});
});
});

View file

@ -0,0 +1,19 @@
/**
* Application Layer - Services
* Exporta servicios de aplicación para uso desde rutas.
*/
export { ContentService } from './ContentService.js';
export type { ListContentFilters, ListContentResult, ContentItemDetail, InvalidateCacheFn } from './ContentService.js';
export { DrugService } from './DrugService.js';
export type { DrugListFilters, DrugListResult, DrugValidationResult } from './DrugService.js';
export { StatsService } from './StatsService.js';
export type { CacheAdapter, ContentStatsResponse, ValidationStatsResponse, MediaStatsResponse } from './StatsService.js';
export { GlossaryService } from './GlossaryService.js';
export type { GlossaryListResult } from './GlossaryService.js';
export { ValidationService } from './ValidationService.js';
export type { ValidationActionResult, ValidationServicePendingResult } from './ValidationService.js';

View file

@ -4,6 +4,6 @@
export type { ContentItem } from './ContentItem.js'; export type { ContentItem } from './ContentItem.js';
export type { Drug } from './Drug.js'; export type { Drug } from './Drug.js';
export type { GlossaryTerm } from './GlossaryTerm.js'; export type { GlossaryTerm, GlossaryCategory } from './GlossaryTerm.js';
export type { MediaResource } from './MediaResource.js'; export type { MediaResource } from './MediaResource.js';
export type { MedicalReview, ReviewComment } from './MedicalReview.js'; export type { MedicalReview, ReviewComment } from './MedicalReview.js';

View file

@ -0,0 +1,64 @@
/**
* Repository Interface para validación de contenido
* Domain Layer - Contrato para persistencia del workflow de validación
*/
export interface ContentForValidation {
readonly id: string;
readonly status: string;
}
export interface PendingValidationFilters {
type?: string;
priority?: string;
}
export interface PendingItem {
readonly id: string;
readonly type: string;
readonly slug: string;
readonly title: string;
readonly short_title: string | null;
readonly description: string | null;
readonly status: string;
readonly priority: string;
readonly level: string;
readonly created_at: string;
readonly updated_at: string;
readonly created_by_username: string | null;
readonly updated_by_username: string | null;
}
export interface ValidationHistoryEntry {
readonly id: string;
readonly action: string;
readonly timestamp: string;
readonly metadata: unknown;
readonly username: string | null;
}
export interface IValidationRepository {
getContentForValidation(id: string): Promise<ContentForValidation | null>;
updateContentStatus(
contentId: string,
status: string,
updatedBy: string,
options?: { validatedBy?: string }
): Promise<void>;
insertAuditLog(
entityType: string,
entityId: string,
action: string,
userId: string,
metadata: Record<string, unknown>
): Promise<void>;
getPendingItems(filters: PendingValidationFilters): Promise<{
items: PendingItem[];
total: number;
}>;
getHistory(contentId: string): Promise<ValidationHistoryEntry[]>;
}

View file

@ -7,3 +7,10 @@ export type { IDrugRepository, DrugFilters } from './IDrugRepository.js';
export type { IGlossaryRepository, GlossaryFilters } from './IGlossaryRepository.js'; export type { IGlossaryRepository, GlossaryFilters } from './IGlossaryRepository.js';
export type { IMediaRepository, MediaFilters } from './IMediaRepository.js'; export type { IMediaRepository, MediaFilters } from './IMediaRepository.js';
export type { IReviewRepository } from './IReviewRepository.js'; export type { IReviewRepository } from './IReviewRepository.js';
export type {
IValidationRepository,
ContentForValidation,
PendingValidationFilters,
PendingItem,
ValidationHistoryEntry,
} from './IValidationRepository.js';

View file

@ -23,7 +23,7 @@ export class ContentStatus {
canTransitionTo(target: ContentStatus): boolean { canTransitionTo(target: ContentStatus): boolean {
const transitions: Record<string, string[]> = { const transitions: Record<string, string[]> = {
'draft': ['in_review', 'archived'], 'draft': ['in_review', 'archived'],
'in_review': ['approved', 'draft'], // draft si se rechaza 'in_review': ['approved', 'draft', 'published'], // draft si se rechaza; published si aprobar+publicar
'approved': ['published', 'draft'], 'approved': ['published', 'draft'],
'published': ['archived'], 'published': ['archived'],
'archived': [] // No se puede cambiar desde archivado 'archived': [] // No se puede cambiar desde archivado

View file

@ -7,115 +7,24 @@
* La implementación completa se hará progresivamente. * La implementación completa se hará progresivamente.
*/ */
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import { join } from 'path';
import { testConnection } from '../config/database.js'; import { testConnection } from '../config/database.js';
import { validateSecurityConfig } from './config/security.js'; import { validateSecurityConfig } from './config/security.js';
import { validateEnv } from './config/env.js'; import { validateEnv } from './config/env.js';
import { getCorsConfig } from './config/cors.js'; import { createApp } from './app.js';
import { securityHeaders } from './middleware/security-headers.js';
import { generalLimiter } from './middleware/rate-limit.js';
import logger, { logError } from './utils/logger.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(); dotenv.config();
// ✅ VALIDACIÓN CRÍTICA DE SEGURIDAD AL STARTUP // ✅ VALIDACIÓN CRÍTICA DE SEGURIDAD AL STARTUP
// Si alguna validación falla, la app no arranca
logger.info('🔒 Validando configuración de seguridad...'); logger.info('🔒 Validando configuración de seguridad...');
validateSecurityConfig(); validateSecurityConfig();
validateEnv(); validateEnv();
logger.info('✅ Configuración validada correctamente'); logger.info('✅ Configuración validada correctamente');
const app = express(); const app = createApp();
const PORT = process.env.PORT || process.env.API_PORT || 3000; 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 () => { app.listen(PORT, async () => {
logger.info('🚀 EMERGES TES Backend API iniciado', { logger.info('🚀 EMERGES TES Backend API iniciado', {
port: PORT, port: PORT,

View file

@ -0,0 +1,2 @@
export * from './repositories/index.js';
export * from './mappers/index.js';

View file

@ -0,0 +1,111 @@
/**
* ContentItemMapper - Infrastructure Layer
* Convierte entre Domain (ContentItem) y Persistence (DB rows).
*/
import type { ContentItem } from '../../domain/entities/ContentItem.js';
import { ContentStatus } from '../../domain/value-objects/ContentStatus.js';
import { ContentPriority } from '../../domain/value-objects/ContentPriority.js';
export interface ContentItemRow {
id: string;
type: string;
slug?: string;
level: string;
title: string;
short_title?: string | null;
description?: string | null;
content?: unknown;
json_content?: unknown;
content_markdown?: string | null;
markdown_content?: string | null;
category?: string | null;
subcategory?: string | null;
priority?: string | null;
age_group?: string | null;
status: string;
version: number;
latest_version: number;
validated_by?: string | null;
validated_at?: Date | string | null;
created_at: Date | string;
updated_at: Date | string;
created_by: string;
updated_by: string;
tags?: string[] | null;
}
/**
* Convierte una fila de BD a entidad de dominio.
*/
export function toDomain(row: ContentItemRow): ContentItem {
const status = typeof row.status === 'string' ? ContentStatus.fromString(row.status) : ContentStatus.DRAFT;
const priority =
row.priority && typeof row.priority === 'string'
? ContentPriority.fromString(row.priority)
: ContentPriority.MEDIA;
const content =
row.json_content != null
? (row.json_content as Record<string, unknown>)
: row.content != null
? (row.content as Record<string, unknown>)
: {};
const contentWithMarkdown = { ...content } as Record<string, unknown>;
const markdown = row.markdown_content ?? row.content_markdown;
if (markdown) contentWithMarkdown.markdown = markdown;
const createdAt = row.created_at instanceof Date ? row.created_at : new Date(String(row.created_at));
const updatedAt = row.updated_at instanceof Date ? row.updated_at : new Date(String(row.updated_at));
return {
id: row.id,
type: row.type as ContentItem['type'],
slug: row.slug ?? row.id,
level: row.level as ContentItem['level'],
title: row.title,
shortTitle: row.short_title ?? undefined,
description: row.description ?? undefined,
content: contentWithMarkdown,
contentMarkdown: row.content_markdown ?? row.markdown_content ?? undefined,
category: row.category ?? undefined,
subcategory: row.subcategory ?? undefined,
priority,
ageGroup: (row.age_group as ContentItem['ageGroup']) ?? undefined,
status,
version: Number(row.version),
latestVersion: Number(row.latest_version),
validatedBy: row.validated_by ?? undefined,
validatedAt: row.validated_at ? new Date(String(row.validated_at)) : undefined,
createdAt,
updatedAt,
createdBy: row.created_by,
updatedBy: row.updated_by,
tags: row.tags ?? undefined,
};
}
/**
* Convierte entidad de dominio a objeto para persistencia (snake_case).
*/
export function toPersistence(item: ContentItem): Record<string, unknown> {
return {
id: item.id,
type: item.type,
slug: item.slug,
level: item.level,
title: item.title,
short_title: item.shortTitle ?? null,
description: item.description ?? null,
category: item.category ?? null,
subcategory: item.subcategory ?? null,
priority: item.priority.toString(),
age_group: item.ageGroup ?? null,
status: item.status.toString(),
version: item.version,
latest_version: item.latestVersion,
created_by: item.createdBy,
updated_by: item.updatedBy,
};
}

View file

@ -0,0 +1,106 @@
/**
* DrugMapper - Infrastructure Layer
* Convierte entre Domain (Drug) y Persistence (DB rows).
*/
import type { Drug, AdministrationRoute, DrugCategory } from '../../domain/entities/Drug.js';
import { ContentStatus } from '../../domain/value-objects/ContentStatus.js';
export interface DrugRow {
id: string;
slug: string;
generic_name: string;
trade_name?: string | null;
category: string;
line: string;
frequency: string;
presentation: string;
adult_dose: string;
pediatric_dose?: string | null;
routes?: string[] | null;
dilution?: string | null;
indications?: string[] | null;
contraindications?: string[] | null;
side_effects?: string | null;
antidote?: string | null;
notes?: string[] | null;
critical_points?: string[] | null;
source?: string | null;
status: string;
version: string;
latest_version: string;
created_at: Date | string;
updated_at: Date | string;
created_by: string;
updated_by?: string | null;
}
/**
* Convierte una fila de BD a entidad de dominio.
*/
export function toDomain(row: DrugRow): Drug {
const status = typeof row.status === 'string' ? ContentStatus.fromString(row.status) : ContentStatus.DRAFT;
const createdAt = row.created_at instanceof Date ? row.created_at : new Date(String(row.created_at));
const updatedAt = row.updated_at instanceof Date ? row.updated_at : new Date(String(row.updated_at));
return {
id: row.id,
slug: row.slug,
genericName: row.generic_name,
tradeName: row.trade_name ?? undefined,
category: row.category as DrugCategory,
line: row.line === 'first' || row.line === 'second' ? row.line : 'first',
frequency: row.frequency === 'high' || row.frequency === 'medium' || row.frequency === 'low' ? row.frequency : 'medium',
presentation: row.presentation,
adultDose: row.adult_dose,
pediatricDose: row.pediatric_dose ?? undefined,
routes: (row.routes ?? []) as AdministrationRoute[],
dilution: row.dilution ?? undefined,
indications: row.indications ?? [],
contraindications: row.contraindications ?? [],
sideEffects: row.side_effects ?? undefined,
antidote: row.antidote ?? undefined,
notes: row.notes ?? [],
criticalPoints: row.critical_points ?? [],
source: row.source ?? undefined,
status,
version: row.version ?? '1.0.0',
latestVersion: row.latest_version ?? row.version ?? '1.0.0',
createdAt,
updatedAt,
createdBy: row.created_by,
updatedBy: row.updated_by ?? undefined,
};
}
/**
* Convierte entidad de dominio a objeto para persistencia (snake_case).
*/
export function toPersistence(drug: Drug): Record<string, unknown> {
return {
id: drug.id,
slug: drug.slug,
generic_name: drug.genericName,
trade_name: drug.tradeName ?? null,
category: drug.category,
line: drug.line,
frequency: drug.frequency,
presentation: drug.presentation,
adult_dose: drug.adultDose,
pediatric_dose: drug.pediatricDose ?? null,
routes: [...drug.routes],
dilution: drug.dilution ?? null,
indications: [...drug.indications],
contraindications: [...drug.contraindications],
side_effects: drug.sideEffects ?? null,
antidote: drug.antidote ?? null,
notes: [...drug.notes],
critical_points: [...drug.criticalPoints],
source: drug.source ?? null,
status: drug.status.toString(),
version: drug.version,
latest_version: drug.latestVersion,
created_by: drug.createdBy,
updated_by: drug.updatedBy ?? drug.createdBy,
};
}

View file

@ -0,0 +1,66 @@
/**
* GlossaryTermMapper - Infrastructure Layer
* Convierte entre Domain (GlossaryTerm) y Persistence (DB rows).
*/
import type { GlossaryTerm } from '../../domain/entities/GlossaryTerm.js';
import { ContentStatus } from '../../domain/value-objects/ContentStatus.js';
export interface GlossaryTermRow {
id: string;
term: string;
abbreviation?: string | null;
category: string;
definition: string;
context?: string | null;
examples?: string[] | null;
related_terms?: string[] | null;
source?: string | null;
status: string;
created_at: Date | string;
updated_at: Date | string;
created_by: string;
updated_by?: string | null;
}
export function toDomain(row: GlossaryTermRow): GlossaryTerm {
const status = typeof row.status === 'string' ? ContentStatus.fromString(row.status) : ContentStatus.DRAFT;
const createdAt = row.created_at instanceof Date ? row.created_at : new Date(String(row.created_at));
const updatedAt = row.updated_at instanceof Date ? row.updated_at : new Date(String(row.updated_at));
return {
id: row.id,
term: row.term,
abbreviation: row.abbreviation ?? undefined,
category: row.category as GlossaryTerm['category'],
definition: row.definition,
context: row.context ?? undefined,
examples: row.examples ?? undefined,
relatedTerms: row.related_terms ?? undefined,
source: row.source ?? undefined,
status,
createdAt,
updatedAt,
createdBy: row.created_by,
updatedBy: row.updated_by ?? undefined,
};
}
export function toPersistence(term: GlossaryTerm): Record<string, unknown> {
return {
id: term.id,
term: term.term,
abbreviation: term.abbreviation ?? null,
category: term.category,
definition: term.definition,
context: term.context ?? null,
examples: term.examples ?? null,
related_terms: term.relatedTerms ?? null,
source: term.source ?? null,
status: term.status.toString(),
created_at: term.createdAt,
updated_at: term.updatedAt,
created_by: term.createdBy,
updated_by: term.updatedBy ?? null,
};
}

View file

@ -0,0 +1,3 @@
export { toDomain, toPersistence, type ContentItemRow } from './ContentItemMapper.js';
export { toDomain as drugToDomain, toPersistence as drugToPersistence, type DrugRow } from './DrugMapper.js';
export { toDomain as glossaryTermToDomain, toPersistence as glossaryTermToPersistence, type GlossaryTermRow } from './GlossaryTermMapper.js';

View file

@ -0,0 +1,203 @@
/**
* ContentRepository - Infrastructure Layer
* Implementación de IContentRepository usando PostgreSQL.
*/
import { query } from '../../../config/database.js';
import type { IContentRepository, ContentFilters } from '../../domain/repositories/IContentRepository.js';
import type { ContentItem } from '../../domain/entities/ContentItem.js';
import { toDomain, toPersistence, type ContentItemRow } from '../mappers/ContentItemMapper.js';
import { randomUUID } from 'crypto';
export class ContentRepository implements IContentRepository {
async findById(id: string): Promise<ContentItem | null> {
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.id = $1`,
[id]
);
if (result.rows.length === 0) return null;
const row = result.rows[0] as Record<string, unknown>;
const merged = { ...row, json_content: row.json_content ?? row.content, markdown_content: row.markdown_content ?? row.content_markdown };
return toDomain(merged as ContentItemRow);
}
async findBySlug(slug: string): Promise<ContentItem | null> {
const result = await query(
`SELECT * FROM tes_content.content_items WHERE slug = $1`,
[slug]
);
if (result.rows.length === 0) return null;
return toDomain(result.rows[0] as ContentItemRow);
}
async findAll(filters: ContentFilters): Promise<{ items: ContentItem[]; total: number }> {
const { type, level, status, category, search, page = 1, pageSize = 20 } = filters;
const whereConditions: string[] = [];
const params: unknown[] = [];
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 countResult = await query(
`SELECT COUNT(*) as total FROM tes_content.content_items ${whereClause}`,
params
);
const total = parseInt((countResult.rows[0] as { total: string }).total, 10);
const offset = (page - 1) * pageSize;
params.push(pageSize, offset);
const itemsResult = await query(
`SELECT * FROM tes_content.content_items
${whereClause}
ORDER BY updated_at DESC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
params
);
const items = (itemsResult.rows as ContentItemRow[]).map(toDomain);
return { items, total };
}
async save(content: ContentItem): Promise<ContentItem> {
const existing = await query(`SELECT id FROM tes_content.content_items WHERE id = $1`, [content.id]);
const p = toPersistence(content);
const contentObj = content.content as Record<string, unknown>;
const markdown = contentObj?.markdown as string | undefined;
if (existing.rows.length === 0) {
await query(
`INSERT INTO tes_content.content_items
(id, type, slug, 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, $12, 1, 1, $13, $13)`,
[
p.id,
p.type,
p.slug,
p.level,
p.title,
p.short_title,
p.description,
p.category,
p.subcategory,
p.priority,
p.age_group,
p.status,
p.created_by,
]
);
const versionId = randomUUID();
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, content.id, 1, JSON.stringify(content.content), markdown ?? null, content.createdBy]
);
await query(
`UPDATE tes_content.content_items SET current_version_id = $1 WHERE id = $2`,
[versionId, content.id]
);
await query(
`INSERT INTO tes_content.content_change_log (content_item_id, version_id, user_id, action, details)
VALUES ($1, $2, $3, 'create', $4)`,
[content.id, versionId, content.createdBy, JSON.stringify({ title: content.title, type: content.type, level: content.level })]
);
} else {
const currentResult = await query(
`SELECT latest_version FROM tes_content.content_items WHERE id = $1`,
[content.id]
);
const current = currentResult.rows[0] as { latest_version: number };
const newVersion = current.latest_version + 1;
await query(
`UPDATE tes_content.content_items
SET title = $1, short_title = $2, description = $3, category = $4, subcategory = $5,
priority = $6, age_group = $7, status = $8, latest_version = $9, updated_by = $10, updated_at = NOW()
WHERE id = $11`,
[
p.title,
p.short_title,
p.description,
p.category,
p.subcategory,
p.priority,
p.age_group,
p.status,
newVersion,
p.updated_by,
content.id,
]
);
const versionId = randomUUID();
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,
content.id,
newVersion,
JSON.stringify(content.content),
markdown ?? null,
`Actualización a versión ${newVersion}`,
content.updatedBy,
]
);
await query(
`UPDATE tes_content.content_items SET current_version_id = $1 WHERE id = $2`,
[versionId, content.id]
);
await query(
`INSERT INTO tes_content.content_change_log (content_item_id, version_id, user_id, action, details)
VALUES ($1, $2, $3, 'update', $4)`,
[content.id, versionId, content.updatedBy, JSON.stringify({ version: newVersion })]
);
}
const saved = await this.findById(content.id);
if (!saved) throw new Error('CONTENT_NOT_SAVED');
return saved;
}
async delete(id: string): Promise<void> {
await query(`DELETE FROM tes_content.content_versions WHERE content_item_id = $1`, [id]);
await query(`DELETE FROM tes_content.content_change_log WHERE content_item_id = $1`, [id]);
await query(`DELETE FROM tes_content.content_items WHERE id = $1`, [id]);
}
async existsBySlug(slug: string, excludeId?: string): Promise<boolean> {
const sql = excludeId
? `SELECT 1 FROM tes_content.content_items WHERE slug = $1 AND id != $2 LIMIT 1`
: `SELECT 1 FROM tes_content.content_items WHERE slug = $1 LIMIT 1`;
const params = excludeId ? [slug, excludeId] : [slug];
const result = await query(sql, params);
return result.rows.length > 0;
}
}

View file

@ -0,0 +1,206 @@
/**
* DrugRepository - Infrastructure Layer
* Implementación de IDrugRepository usando PostgreSQL.
*/
import { query } from '../../../config/database.js';
import type { IDrugRepository, DrugFilters } from '../../domain/repositories/IDrugRepository.js';
import type { Drug } from '../../domain/entities/Drug.js';
import { toDomain, toPersistence, type DrugRow } from '../mappers/DrugMapper.js';
export class DrugRepository implements IDrugRepository {
async findById(id: string): Promise<Drug | null> {
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
FROM tes_content.drugs WHERE id = $1`,
[id]
);
if (result.rows.length === 0) return null;
return toDomain(result.rows[0] as DrugRow);
}
async findBySlug(slug: string): Promise<Drug | null> {
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
FROM tes_content.drugs WHERE slug = $1`,
[slug]
);
if (result.rows.length === 0) return null;
return toDomain(result.rows[0] as DrugRow);
}
async findAll(filters: DrugFilters): Promise<{ items: Drug[]; total: number }> {
const { category, line, frequency, status, search, page = 1, pageSize = 50 } = filters;
let whereClause = 'WHERE 1=1';
const params: unknown[] = [];
let paramIndex = 1;
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++;
}
if (search) {
whereClause += ` AND (
generic_name ILIKE $${paramIndex} OR
trade_name ILIKE $${paramIndex} OR
category ILIKE $${paramIndex}
)`;
params.push(`%${search}%`);
paramIndex++;
}
const countResult = await query(
`SELECT COUNT(*) as total FROM tes_content.drugs ${whereClause}`,
params
);
const total = parseInt((countResult.rows[0] as { total: string }).total, 10);
const offset = (page - 1) * pageSize;
params.push(pageSize, offset);
const itemsResult = 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
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
);
const items = (itemsResult.rows as DrugRow[]).map(toDomain);
return { items, total };
}
async save(drug: Drug): Promise<Drug> {
const existing = await this.findById(drug.id);
const p = toPersistence(drug);
if (!existing) {
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, $24
)`,
[
p.id,
p.slug,
p.generic_name,
p.trade_name,
p.category,
p.line,
p.frequency,
p.presentation,
p.adult_dose,
p.pediatric_dose,
p.routes,
p.dilution,
p.indications,
p.contraindications,
p.side_effects,
p.antidote,
p.notes,
p.critical_points,
p.source,
p.status,
p.version,
p.latest_version,
p.created_by,
p.updated_by,
]
);
} else {
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()
WHERE id = $20`,
[
p.generic_name,
p.trade_name,
p.category,
p.line,
p.frequency,
p.presentation,
p.adult_dose,
p.pediatric_dose,
p.routes,
p.dilution,
p.indications,
p.contraindications,
p.side_effects,
p.antidote,
p.notes,
p.critical_points,
p.source,
p.version,
p.updated_by,
drug.id,
]
);
}
const saved = await this.findById(drug.id);
if (!saved) throw new Error('DRUG_NOT_SAVED');
return saved;
}
async delete(id: string): Promise<void> {
await query('DELETE FROM tes_content.drug_versions WHERE drug_id = $1', [id]);
await query('DELETE FROM tes_content.drugs WHERE id = $1', [id]);
}
async existsBySlug(slug: string, excludeId?: string): Promise<boolean> {
const sql = excludeId
? 'SELECT 1 FROM tes_content.drugs WHERE slug = $1 AND id != $2 LIMIT 1'
: 'SELECT 1 FROM tes_content.drugs WHERE slug = $1 LIMIT 1';
const params = excludeId ? [slug, excludeId] : [slug];
const result = await query(sql, params);
return result.rows.length > 0;
}
}

View file

@ -0,0 +1,144 @@
/**
* GlossaryRepository - Infrastructure Layer
* Implementación de IGlossaryRepository usando PostgreSQL.
*/
import { query } from '../../../config/database.js';
import type { IGlossaryRepository, GlossaryFilters } from '../../domain/repositories/IGlossaryRepository.js';
import type { GlossaryTerm } from '../../domain/entities/GlossaryTerm.js';
import { toDomain, toPersistence, type GlossaryTermRow } from '../mappers/GlossaryTermMapper.js';
export class GlossaryRepository implements IGlossaryRepository {
async findById(id: string): Promise<GlossaryTerm | null> {
const result = await query(
`SELECT * FROM tes_content.glossary_terms WHERE id = $1`,
[id]
);
if (result.rows.length === 0) return null;
return toDomain(result.rows[0] as GlossaryTermRow);
}
async findByTerm(term: string): Promise<GlossaryTerm | null> {
const result = await query(
`SELECT * FROM tes_content.glossary_terms
WHERE LOWER(term) = LOWER($1) OR LOWER(abbreviation) = LOWER($1)
LIMIT 1`,
[term]
);
if (result.rows.length === 0) return null;
return toDomain(result.rows[0] as GlossaryTermRow);
}
async findAll(filters: GlossaryFilters): Promise<{ items: GlossaryTerm[]; total: number }> {
const { category, search, page = 1, pageSize = 20 } = filters;
const whereConditions: string[] = [];
const params: unknown[] = [];
let paramIndex = 1;
if (category) {
whereConditions.push(`category = $${paramIndex++}`);
params.push(category);
}
if (search) {
whereConditions.push(`(term ILIKE $${paramIndex} OR definition ILIKE $${paramIndex})`);
params.push(`%${search}%`);
paramIndex++;
}
const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(' AND ')}` : '';
const countResult = await query(
`SELECT COUNT(*) as total FROM tes_content.glossary_terms ${whereClause}`,
params
);
const total = parseInt((countResult.rows[0] as { total: string }).total, 10);
const offset = (page - 1) * pageSize;
params.push(pageSize, offset);
const itemsResult = await query(
`SELECT * FROM tes_content.glossary_terms
${whereClause}
ORDER BY term ASC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
params
);
const items = (itemsResult.rows as GlossaryTermRow[]).map(toDomain);
return { items, total };
}
async search(searchQuery: string): Promise<GlossaryTerm[]> {
const result = await query(
`SELECT * FROM tes_content.glossary_terms
WHERE to_tsvector('spanish', term || ' ' || COALESCE(definition, '')) @@ plainto_tsquery('spanish', $1)
ORDER BY ts_rank(to_tsvector('spanish', term || ' ' || COALESCE(definition, '')), plainto_tsquery('spanish', $1)) DESC
LIMIT 50`,
[searchQuery]
);
return (result.rows as GlossaryTermRow[]).map(toDomain);
}
async save(term: GlossaryTerm): Promise<GlossaryTerm> {
const row = toPersistence(term);
const existing = await query(
`SELECT id FROM tes_content.glossary_terms WHERE id = $1`,
[term.id]
);
if (existing.rows.length === 0) {
await query(
`INSERT INTO tes_content.glossary_terms (
id, term, abbreviation, category, definition, context,
examples, related_terms, source, status,
created_at, updated_at, created_by, updated_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::tes_content.content_status, $11, $12, $13, $14)`,
[
row.id,
row.term,
row.abbreviation,
row.category,
row.definition,
row.context,
row.examples,
row.related_terms,
row.source,
row.status,
row.created_at,
row.updated_at,
row.created_by,
row.updated_by,
]
);
} else {
await query(
`UPDATE tes_content.glossary_terms SET
term = $2, abbreviation = $3, category = $4, definition = $5, context = $6,
examples = $7, related_terms = $8, source = $9, status = $10::tes_content.content_status,
updated_at = $11, updated_by = $12
WHERE id = $1`,
[
row.id,
row.term,
row.abbreviation,
row.category,
row.definition,
row.context,
row.examples,
row.related_terms,
row.source,
row.status,
row.updated_at,
row.updated_by,
]
);
}
const saved = await this.findById(term.id);
if (!saved) throw new Error('GlossaryTerm no encontrado tras guardar');
return saved;
}
async delete(id: string): Promise<void> {
await query(`DELETE FROM tes_content.glossary_terms WHERE id = $1`, [id]);
}
}

View file

@ -0,0 +1,138 @@
/**
* ValidationRepository - Infrastructure Layer
* Implementación de IValidationRepository usando PostgreSQL.
*/
import { query } from '../../../config/database.js';
import type {
IValidationRepository,
ContentForValidation,
PendingValidationFilters,
PendingItem,
ValidationHistoryEntry,
} from '../../domain/repositories/IValidationRepository.js';
export class ValidationRepository implements IValidationRepository {
async getContentForValidation(id: string): Promise<ContentForValidation | null> {
const result = await query(
`SELECT id, status FROM tes_content.content_items WHERE id = $1`,
[id]
);
if (result.rows.length === 0) return null;
const row = result.rows[0] as { id: string; status: string };
return { id: row.id, status: row.status };
}
async updateContentStatus(
contentId: string,
status: string,
updatedBy: string,
options?: { validatedBy?: string }
): Promise<void> {
if (options?.validatedBy) {
await query(
`UPDATE tes_content.content_items
SET status = $1::tes_content.content_status,
validated_by = $2,
validated_at = NOW(),
updated_at = NOW(),
updated_by = $2
WHERE id = $3`,
[status, options.validatedBy, contentId]
);
} else {
await query(
`UPDATE tes_content.content_items
SET status = $1::tes_content.content_status,
updated_at = NOW(),
updated_by = $2
WHERE id = $3`,
[status, updatedBy, contentId]
);
}
}
async insertAuditLog(
entityType: string,
entityId: string,
action: string,
userId: string,
metadata: Record<string, unknown>
): Promise<void> {
await query(
`INSERT INTO tes_content.audit_logs (
entity_type, entity_id, action, user_id, metadata
) VALUES ($1, $2, $3, $4, $5::jsonb)`,
[entityType, entityId, action, userId, JSON.stringify(metadata)]
);
}
async getPendingItems(filters: PendingValidationFilters): Promise<{
items: PendingItem[];
total: number;
}> {
const whereConditions = ["status = 'in_review'::tes_content.content_status"];
const params: unknown[] = [];
let paramIndex = 1;
if (filters.type) {
whereConditions.push(`type = $${paramIndex++}`);
params.push(filters.type);
}
if (filters.priority) {
whereConditions.push(`priority = $${paramIndex++}`);
params.push(filters.priority);
}
const sql = `
SELECT
ci.id, ci.type, ci.slug, ci.title, ci.short_title, ci.description,
ci.status, ci.priority, ci.level,
ci.created_at, ci.updated_at,
u_created.username AS created_by_username,
u_updated.username AS updated_by_username
FROM tes_content.content_items ci
LEFT JOIN tes_content.users u_created ON ci.created_by = u_created.id
LEFT JOIN tes_content.users u_updated ON ci.updated_by = u_updated.id
WHERE ${whereConditions.join(' AND ')}
ORDER BY
CASE ci.priority
WHEN 'critica' THEN 1
WHEN 'alta' THEN 2
WHEN 'media' THEN 3
WHEN 'baja' THEN 4
END,
ci.created_at ASC
`;
const result = await query(sql, params);
const items = (result.rows as PendingItem[]).map((row) => ({
...row,
short_title: row.short_title ?? null,
description: row.description ?? null,
created_by_username: row.created_by_username ?? null,
updated_by_username: row.updated_by_username ?? null,
}));
return { items, total: items.length };
}
async getHistory(contentId: string): Promise<ValidationHistoryEntry[]> {
const result = await query(
`SELECT al.id, al.action, al.created_at, al.metadata, u.username
FROM tes_content.audit_logs al
LEFT JOIN tes_content.users u ON al.user_id = u.id
WHERE al.entity_type = 'content_item' AND al.entity_id = $1
AND al.action IN ('submit', 'approve', 'reject', 'publish')
ORDER BY al.created_at DESC`,
[contentId]
);
return (result.rows as { id: string; action: string; created_at: string; metadata: unknown; username: string | null }[]).map(
(row) => ({
id: row.id,
action: row.action,
timestamp: row.created_at,
metadata: row.metadata,
username: row.username ?? null,
})
);
}
}

View file

@ -0,0 +1,4 @@
export { ContentRepository } from './ContentRepository.js';
export { DrugRepository } from './DrugRepository.js';
export { GlossaryRepository } from './GlossaryRepository.js';
export { ValidationRepository } from './ValidationRepository.js';

View file

@ -1,186 +1,79 @@
/** /**
* Rutas de gestión de contenido * Rutas de gestión de contenido
* Delegan en Application Layer (ContentService).
*/ */
import express, { Request, Response } from 'express'; import express, { Response } from 'express';
import { query } from '../../config/database.js';
import { authenticate, requirePermission, AuthRequest } from '../middleware/auth.js'; import { authenticate, requirePermission, AuthRequest } from '../middleware/auth.js';
import { randomUUID as uuidv4 } from 'crypto'; import { randomUUID } from 'crypto';
import { validateBody, validateQuery, validateParams } from '../middleware/validate.js'; import { validateBody, validateQuery, validateParams } from '../middleware/validate.js';
import { createContentSchema, updateContentSchema, listContentQuerySchema, contentIdSchema } from '../validators/content.js'; // TypeScript import { createContentSchema, updateContentSchema, listContentQuerySchema, contentIdSchema } from '../validators/content.js';
import { contentWriteLimiter } from '../middleware/rate-limit.js'; import { contentWriteLimiter } from '../middleware/rate-limit.js';
import cache from '../services/cache.js'; import cache from '../services/cache.js';
import logger, { logError } from '../utils/logger.js'; import logger from '../utils/logger.js';
import { sendServerError, sendNotFound } from '../utils/http-responses.js';
import { ContentService } from '../application/services/ContentService.js';
import { ContentRepository } from '../infrastructure/repositories/ContentRepository.js';
const router = express.Router();
/**
* Invalidar caché relacionado con contenido
* Se llama cuando se crea, actualiza o publica contenido
*/
async function invalidateContentCache(): Promise<void> { async function invalidateContentCache(): Promise<void> {
// Invalidar content packs
await cache.invalidatePattern('content-pack:*'); await cache.invalidatePattern('content-pack:*');
// Invalidar stats
await cache.invalidatePattern('stats:*'); await cache.invalidatePattern('stats:*');
} }
// Todas las rutas requieren autenticación const contentRepo = new ContentRepository();
const contentService = new ContentService(contentRepo, invalidateContentCache);
const router = express.Router();
router.use(authenticate); router.use(authenticate);
/** /**
* GET /api/content * GET /api/content
* Listar contenido con filtros * Listar contenido con filtros
* Validación de query parameters con Zod
*/ */
router.get('/', requirePermission('content:read'), validateQuery(listContentQuerySchema), async (req: AuthRequest, res: Response) => { router.get('/', requirePermission('content:read'), validateQuery(listContentQuerySchema), async (req: AuthRequest, res: Response) => {
try { try {
// ✅ Query parameters ya validados por Zod middleware const filters = req.query as Parameters<ContentService['list']>[0];
const { const result = await contentService.list(filters);
type, res.json(result);
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) { } catch (error) {
logError(error as Error, { endpoint: '/api/content', method: 'GET', action: 'list' }); sendServerError(res, error, undefined, { endpoint: '/api/content', method: 'GET', action: 'list' });
res.status(500).json({ error: 'Error interno del servidor' }); }
});
/**
* GET /api/content/pack/latest
* Último content pack publicado (ruta específica antes de /:id)
*/
router.get('/pack/latest', async (_req: express.Request, res: Response) => {
try {
const pack = await contentService.getPackLatest();
res.json(pack);
} catch (error) {
sendServerError(res, error, undefined, { endpoint: '/api/content/pack/latest', method: 'GET' });
} }
}); });
/** /**
* GET /api/content/:id * GET /api/content/:id
* Obtener contenido por 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) => { router.get('/:id', requirePermission('content:read'), validateParams(contentIdSchema), async (req: AuthRequest, res: Response) => {
try { try {
// ✅ ID ya validado por Zod middleware
const { id } = req.params; const { id } = req.params;
const item = await contentService.getById(id);
const result = await query( if (!item) {
`SELECT ci.*, cv.content, cv.change_summary sendNotFound(res, 'Contenido no encontrado');
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; return;
} }
res.json(item);
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) { } catch (error) {
logError(error as Error, { endpoint: '/api/content', method: 'GET' }); sendServerError(res, error, undefined, { endpoint: '/api/content', method: 'GET' });
res.status(500).json({ error: 'Error interno del servidor' });
} }
}); });
/** /**
* POST /api/content * POST /api/content
* Crear nuevo contenido * 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) => { router.post('/', requirePermission('content:write'), contentWriteLimiter, validateBody(createContentSchema), async (req: AuthRequest, res: Response) => {
try { try {
@ -188,79 +81,17 @@ router.post('/', requirePermission('content:write'), contentWriteLimiter, valida
res.status(401).json({ error: 'No autenticado' }); res.status(401).json({ error: 'No autenticado' });
return; return;
} }
const body = req.body as Record<string, unknown>;
// ✅ Datos ya validados por Zod middleware const id = (body.id as string) ?? randomUUID();
const { const { id: _omit, ...input } = body;
id, const result = await contentService.create(id, input as Parameters<ContentService['create']>[1], req.user.id);
type, logger.info('Contenido creado', { contentId: result.id, userId: req.user.id, type: (input as { type?: string }).type });
level, res.status(201).json({ id: result.id, message: 'Contenido creado exitosamente' });
title, } catch (error) {
shortTitle, if ((error as Error).message === 'ID_ALREADY_EXISTS') {
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' }); res.status(409).json({ error: 'ID ya existe' });
return; 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 }); logError(error as Error, { endpoint: '/api/content', method: 'POST', userId: req.user?.id });
res.status(500).json({ error: 'Error interno del servidor' }); res.status(500).json({ error: 'Error interno del servidor' });
} }
@ -269,8 +100,6 @@ router.post('/', requirePermission('content:write'), contentWriteLimiter, valida
/** /**
* PUT /api/content/:id * PUT /api/content/:id
* Actualizar contenido * 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) => { router.put('/:id', requirePermission('content:write'), contentWriteLimiter, validateParams(contentIdSchema), validateBody(updateContentSchema), async (req: AuthRequest, res: Response) => {
try { try {
@ -278,142 +107,16 @@ router.put('/:id', requirePermission('content:write'), contentWriteLimiter, vali
res.status(401).json({ error: 'No autenticado' }); res.status(401).json({ error: 'No autenticado' });
return; return;
} }
// ✅ ID y body ya validados por Zod middleware
const { id } = req.params; const { id } = req.params;
const { const result = await contentService.update(id, req.body as Parameters<ContentService['update']>[1], req.user.id);
title, logger.info('Contenido actualizado', { contentId: id, version: result.version, userId: req.user.id });
shortTitle, res.json({ id: result.id, version: result.version, message: 'Contenido actualizado exitosamente' });
description, } catch (error) {
content, if ((error as Error).message === 'CONTENT_NOT_FOUND') {
category, sendNotFound(res, 'Contenido no encontrado');
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; return;
} }
sendServerError(res, error, undefined, { endpoint: '/api/content/:id', method: 'PUT', userId: req.user?.id });
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' });
} }
}); });
@ -424,22 +127,10 @@ router.put('/:id', requirePermission('content:write'), contentWriteLimiter, vali
router.get('/:id/versions', requirePermission('content:read'), async (req: AuthRequest, res: Response) => { router.get('/:id/versions', requirePermission('content:read'), async (req: AuthRequest, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
const result = await contentService.getVersions(id);
const result = await query( res.json(result);
`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) { } catch (error) {
logError(error as Error, { endpoint: '/api/content/:id/versions', method: 'GET' }); sendServerError(res, error, undefined, { endpoint: '/api/content/:id/versions', method: 'GET' });
res.status(500).json({ error: 'Error interno del servidor' });
} }
}); });
@ -453,135 +144,21 @@ router.post('/:id/validate', requirePermission('content:validate'), async (req:
res.status(401).json({ error: 'No autenticado' }); res.status(401).json({ error: 'No autenticado' });
return; return;
} }
const { id } = req.params; const { id } = req.params;
const { approved } = req.body; const { approved } = req.body as { approved?: boolean };
const result = await contentService.validate(id, approved === true, req.user.id);
const result = await query( res.json({
`SELECT latest_version, current_version_id id: result.id,
FROM tes_content.content_items status: result.status,
WHERE id = $1`, message: result.status === 'approved' ? 'Contenido aprobado' : 'Contenido marcado para revisión',
[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) { } catch (error) {
logError(error as Error, { endpoint: '/api/content/:id/validate', method: 'POST', userId: req.user?.id }); if ((error as Error).message === 'CONTENT_NOT_FOUND') {
res.status(500).json({ error: 'Error interno del servidor' }); sendNotFound(res, 'Contenido no encontrado');
} return;
});
/**
* 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;
}
} }
sendServerError(res, error, undefined, { endpoint: '/api/content/:id/validate', method: 'POST', userId: req.user?.id });
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; export default router;

View file

@ -1,118 +1,27 @@
/** /**
* RUTAS API: Drugs (Vademécum TES) * RUTAS API: Drugs (Vademécum TES)
* * Delegan en Application Layer (DrugService).
* 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 express, { Request, Response } from 'express';
import { query } from '../../config/database.js';
import { authenticate, requirePermission, AuthRequest } from '../middleware/auth.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 { validateQuery } from '../middleware/validate.js';
import { listDrugsQuerySchema } from '../validators/drugs.js'; // TypeScript import { listDrugsQuerySchema } from '../validators/drugs.js';
// import { contentWriteLimiter } from '../middleware/rate-limit.js'; // No usado en esta ruta import { DrugService } from '../application/services/DrugService.js';
import { DrugRepository } from '../infrastructure/repositories/DrugRepository.js';
const router = express.Router(); const router = express.Router();
const drugRepo = new DrugRepository();
const drugService = new DrugService(drugRepo);
/** /**
* GET /api/drugs * 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) => { router.get('/', validateQuery(listDrugsQuerySchema), async (req: Request, res: Response) => {
try { try {
// ✅ Query parameters ya validados por Zod middleware const filters = req.query as Parameters<DrugService['list']>[0];
const { const result = await drugService.list(filters);
category, res.json(result);
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) { } catch (error) {
console.error('Error obteniendo fármacos:', error); console.error('Error obteniendo fármacos:', error);
res.status(500).json({ error: 'Error obteniendo fármacos' }); res.status(500).json({ error: 'Error obteniendo fármacos' });
@ -121,36 +30,16 @@ router.get('/', validateQuery(listDrugsQuerySchema), async (req: Request, res: R
/** /**
* GET /api/drugs/:id * GET /api/drugs/:id
* Obtiene un fármaco por ID o slug
*/ */
router.get('/:id', async (req: Request, res: Response) => { router.get('/:id', async (req: Request, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
const drug = await drugService.getById(id);
// Intentar buscar por UUID o slug if (!drug) {
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' }); res.status(404).json({ error: 'Fármaco no encontrado' });
return; return;
} }
res.json(drug);
res.json(result.rows[0]);
return;
} catch (error) { } catch (error) {
console.error('Error obteniendo fármaco:', error); console.error('Error obteniendo fármaco:', error);
res.status(500).json({ error: 'Error obteniendo fármaco' }); res.status(500).json({ error: 'Error obteniendo fármaco' });
@ -159,7 +48,6 @@ router.get('/:id', async (req: Request, res: Response) => {
/** /**
* POST /api/drugs * POST /api/drugs
* Crea un nuevo fármaco (draft)
*/ */
router.post('/', authenticate, requirePermission('content:create'), async (req: AuthRequest, res: Response) => { router.post('/', authenticate, requirePermission('content:create'), async (req: AuthRequest, res: Response) => {
try { try {
@ -167,115 +55,18 @@ router.post('/', authenticate, requirePermission('content:create'), async (req:
res.status(401).json({ error: 'No autenticado' }); res.status(401).json({ error: 'No autenticado' });
return; return;
} }
const result = await drugService.create(req.body as Parameters<DrugService['create']>[0], req.user.id);
const drugData = req.body; res.status(201).json({ message: 'Fármaco creado correctamente', drug: result.drug });
} catch (error) {
// Normalizar y validar const err = error as Error & { details?: string[] };
const normalized = normalizeDrug(drugData); if (err.message === 'VALIDATION_ERROR') {
const validation = validateDrug(normalized); res.status(400).json({ error: 'Datos inválidos', details: err.details ?? [] });
if (!validation.valid) {
res.status(400).json({
error: 'Datos inválidos',
details: validation.errors
});
return; return;
} }
if (err.message === 'SLUG_ALREADY_EXISTS') {
// 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' }); res.status(409).json({ error: 'Ya existe un fármaco con ese slug' });
return; 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); console.error('Error creando fármaco:', error);
res.status(500).json({ error: 'Error creando fármaco' }); res.status(500).json({ error: 'Error creando fármaco' });
} }
@ -283,7 +74,6 @@ router.post('/', authenticate, requirePermission('content:create'), async (req:
/** /**
* PUT /api/drugs/:id * PUT /api/drugs/:id
* Actualiza un fármaco existente
*/ */
router.put('/:id', authenticate, requirePermission('content:edit'), async (req: AuthRequest, res: Response) => { router.put('/:id', authenticate, requirePermission('content:edit'), async (req: AuthRequest, res: Response) => {
try { try {
@ -291,158 +81,27 @@ router.put('/:id', authenticate, requirePermission('content:edit'), async (req:
res.status(401).json({ error: 'No autenticado' }); res.status(401).json({ error: 'No autenticado' });
return; return;
} }
const { id } = req.params; const { id } = req.params;
const drugData = req.body; const user = req.user as { id: string; permissions?: { admin?: { manage_content?: boolean } } };
const canEdit = (row: Record<string, unknown>) =>
// Verificar que existe row.created_by === user.id || Boolean(user.permissions?.admin?.manage_content);
const existing = await query( const result = await drugService.update(id, req.body as Parameters<DrugService['update']>[1], user.id, canEdit);
'SELECT * FROM tes_content.drugs WHERE id = $1', res.json({ message: 'Fármaco actualizado correctamente', drug: result.drug });
[id] } catch (error) {
); const err = error as Error;
if (err.message === 'DRUG_NOT_FOUND') {
if (existing.rows.length === 0) {
res.status(404).json({ error: 'Fármaco no encontrado' }); res.status(404).json({ error: 'Fármaco no encontrado' });
return; return;
} }
if (err.message === 'FORBIDDEN') {
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' }); res.status(403).json({ error: 'No tienes permiso para editar este fármaco' });
return; return;
} }
if (err.message === 'VALIDATION_ERROR') {
// Normalizar y validar const e = error as Error & { details?: string[] };
const normalized = normalizeDrug({ ...oldDrug, ...drugData }); res.status(400).json({ error: 'Datos inválidos', details: e.details ?? [] });
const validation = validateDrug(normalized);
if (!validation.valid) {
res.status(400).json({
error: 'Datos inválidos',
details: validation.errors
});
return; 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); console.error('Error actualizando fármaco:', error);
res.status(500).json({ error: 'Error actualizando fármaco' }); res.status(500).json({ error: 'Error actualizando fármaco' });
} }
@ -450,7 +109,6 @@ router.put('/:id', authenticate, requirePermission('content:edit'), async (req:
/** /**
* POST /api/drugs/:id/submit * 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) => { router.post('/:id/submit', authenticate, requirePermission('content:submit'), async (req: AuthRequest, res: Response) => {
try { try {
@ -458,36 +116,14 @@ router.post('/:id/submit', authenticate, requirePermission('content:submit'), as
res.status(401).json({ error: 'No autenticado' }); res.status(401).json({ error: 'No autenticado' });
return; return;
} }
const { id } = req.params; const { id } = req.params;
const result = await drugService.submit(id, req.user.id);
const result = await query( res.json({ message: 'Fármaco enviado a revisión', drug: result.drug });
`UPDATE tes_content.drugs } catch (error) {
SET status = 'in_review'::tes_content.content_status, if ((error as Error).message === 'DRUG_NOT_FOUND_OR_NOT_DRAFT') {
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' }); res.status(404).json({ error: 'Fármaco no encontrado o no está en estado draft' });
return; 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); console.error('Error enviando fármaco a revisión:', error);
res.status(500).json({ error: 'Error enviando fármaco a revisión' }); res.status(500).json({ error: 'Error enviando fármaco a revisión' });
} }
@ -495,7 +131,6 @@ router.post('/:id/submit', authenticate, requirePermission('content:submit'), as
/** /**
* POST /api/drugs/:id/approve * POST /api/drugs/:id/approve
* Aprueba un fármaco
*/ */
router.post('/:id/approve', authenticate, requirePermission('validation:approve'), async (req: AuthRequest, res: Response) => { router.post('/:id/approve', authenticate, requirePermission('validation:approve'), async (req: AuthRequest, res: Response) => {
try { try {
@ -503,37 +138,15 @@ router.post('/:id/approve', authenticate, requirePermission('validation:approve'
res.status(401).json({ error: 'No autenticado' }); res.status(401).json({ error: 'No autenticado' });
return; return;
} }
const { id } = req.params; const { id } = req.params;
const { notes } = req.body; const { notes } = (req.body as { notes?: string }) ?? {};
const result = await drugService.approve(id, req.user.id, notes);
const result = await query( res.json({ message: 'Fármaco aprobado', drug: result.drug });
`UPDATE tes_content.drugs } catch (error) {
SET status = 'approved'::tes_content.content_status, if ((error as Error).message === 'DRUG_NOT_FOUND_OR_NOT_IN_REVIEW') {
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' }); res.status(404).json({ error: 'Fármaco no encontrado o no está en estado válido para aprobar' });
return; return;
} }
res.json({
message: 'Fármaco aprobado',
drug: result.rows[0]
});
} catch (error) {
console.error('Error aprobando fármaco:', error); console.error('Error aprobando fármaco:', error);
res.status(500).json({ error: 'Error aprobando fármaco' }); res.status(500).json({ error: 'Error aprobando fármaco' });
} }
@ -541,7 +154,6 @@ router.post('/:id/approve', authenticate, requirePermission('validation:approve'
/** /**
* POST /api/drugs/:id/publish * POST /api/drugs/:id/publish
* Publica un fármaco
*/ */
router.post('/:id/publish', authenticate, requirePermission('content:publish'), async (req: AuthRequest, res: Response) => { router.post('/:id/publish', authenticate, requirePermission('content:publish'), async (req: AuthRequest, res: Response) => {
try { try {
@ -549,56 +161,23 @@ router.post('/:id/publish', authenticate, requirePermission('content:publish'),
res.status(401).json({ error: 'No autenticado' }); res.status(401).json({ error: 'No autenticado' });
return; return;
} }
const { id } = req.params; const { id } = req.params;
const result = await drugService.publish(id, req.user.id);
// Verificar que está aprobado y tiene pediatric_dose res.json({ message: 'Fármaco publicado correctamente', drug: result.drug });
const existing = await query( } catch (error) {
'SELECT id, status, pediatric_dose FROM tes_content.drugs WHERE id = $1', const err = error as Error;
[id] if (err.message === 'DRUG_NOT_FOUND') {
);
if (existing.rows.length === 0) {
res.status(404).json({ error: 'Fármaco no encontrado' }); res.status(404).json({ error: 'Fármaco no encontrado' });
return; return;
} }
if (err.message === 'DRUG_MUST_BE_APPROVED') {
const drug = existing.rows[0];
if (drug.status !== 'approved') {
res.status(400).json({ error: 'El fármaco debe estar aprobado para publicar' }); res.status(400).json({ error: 'El fármaco debe estar aprobado para publicar' });
return; return;
} }
if (err.message === 'PEDIATRIC_DOSE_REQUIRED') {
if (!drug.pediatric_dose || drug.pediatric_dose.trim() === '') {
res.status(400).json({ error: 'pediatric_dose es obligatorio para publicar' }); res.status(400).json({ error: 'pediatric_dose es obligatorio para publicar' });
return; 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); console.error('Error publicando fármaco:', error);
res.status(500).json({ error: 'Error publicando fármaco' }); res.status(500).json({ error: 'Error publicando fármaco' });
} }
@ -606,28 +185,12 @@ router.post('/:id/publish', authenticate, requirePermission('content:publish'),
/** /**
* GET /api/drugs/:id/versions * GET /api/drugs/:id/versions
* Obtiene historial de versiones de un fármaco
*/ */
router.get('/:id/versions', authenticate, async (req: AuthRequest, res: Response) => { router.get('/:id/versions', authenticate, async (req: AuthRequest, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
const result = await drugService.getVersions(id);
const result = await query( res.json(result);
`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) { } catch (error) {
console.error('Error obteniendo versiones:', error); console.error('Error obteniendo versiones:', error);
res.status(500).json({ error: 'Error obteniendo versiones' }); res.status(500).json({ error: 'Error obteniendo versiones' });
@ -635,4 +198,3 @@ router.get('/:id/versions', authenticate, async (req: AuthRequest, res: Response
}); });
export default router; export default router;

View file

@ -0,0 +1,182 @@
/**
* Rutas API: Glosario (TICKET-011)
* Delegan en GlossaryService (Application Layer).
*/
import express, { Response } from 'express';
import { authenticate, requirePermission, AuthRequest } from '../middleware/auth.js';
import { validateBody, validateQuery, validateParams } from '../middleware/validate.js';
import {
createGlossaryTermSchema,
updateGlossaryTermSchema,
listGlossaryQuerySchema,
searchGlossarySchema,
glossaryIdSchema,
} from '../validators/glossary.js';
import { GlossaryService } from '../application/services/GlossaryService.js';
import { GlossaryRepository } from '../infrastructure/repositories/GlossaryRepository.js';
import type { GlossaryTerm } from '../domain/entities/GlossaryTerm.js';
import { sendServerError, sendNotFound } from '../utils/http-responses.js';
const glossaryRepo = new GlossaryRepository();
const glossaryService = new GlossaryService(glossaryRepo);
function termToJson(term: GlossaryTerm): Record<string, unknown> {
return {
id: term.id,
term: term.term,
abbreviation: term.abbreviation,
category: term.category,
definition: term.definition,
context: term.context,
examples: term.examples,
relatedTerms: term.relatedTerms,
source: term.source,
status: term.status.toString(),
createdAt: term.createdAt instanceof Date ? term.createdAt.toISOString() : String(term.createdAt),
updatedAt: term.updatedAt instanceof Date ? term.updatedAt.toISOString() : String(term.updatedAt),
createdBy: term.createdBy,
updatedBy: term.updatedBy,
};
}
const router = express.Router();
/**
* GET /api/glossary
* Listar términos con filtros (público)
*/
router.get('/', validateQuery(listGlossaryQuerySchema), async (req: express.Request, res: Response) => {
try {
const filters = req.query as Parameters<GlossaryService['findAll']>[0];
const result = await glossaryService.findAll(filters);
res.json({
items: result.items.map(termToJson),
total: result.total,
page: result.page,
pageSize: result.pageSize,
});
} catch (error) {
logError(error as Error, { endpoint: '/api/glossary', method: 'GET', action: 'list' });
res.status(500).json({ error: 'Error al listar el glosario' });
}
});
/**
* GET /api/glossary/search
* Búsqueda full-text (público). Debe ir antes de /:id
*/
router.get('/search', validateQuery(searchGlossarySchema), async (req: express.Request, res: Response) => {
try {
const { query: searchQuery } = req.query as { query: string };
const items = await glossaryService.search(searchQuery);
res.json({ items: items.map(termToJson) });
} catch (error) {
sendServerError(res, error, 'Error en la búsqueda del glosario', { endpoint: '/api/glossary/search', method: 'GET' });
}
});
/**
* GET /api/glossary/:id
* Obtener término por ID (público)
*/
router.get('/:id', validateParams(glossaryIdSchema), async (req: express.Request, res: Response) => {
try {
const { id } = req.params;
const term = await glossaryService.findById(id);
if (!term) {
sendNotFound(res, 'Término no encontrado');
return;
}
res.json(termToJson(term));
} catch (error) {
sendServerError(res, error, 'Error al obtener el término', { endpoint: '/api/glossary/:id', method: 'GET' });
}
});
/**
* POST /api/glossary
* Crear término (autenticado)
*/
router.post(
'/',
authenticate,
requirePermission('content:create'),
validateBody(createGlossaryTermSchema),
async (req: AuthRequest, res: Response) => {
try {
if (!req.user) {
res.status(401).json({ error: 'No autenticado' });
return;
}
const term = await glossaryService.create(req.body, req.user.id);
res.status(201).json(termToJson(term));
} catch (error) {
const err = error as Error;
if (err.message === 'GLOSSARY_TERM_ALREADY_EXISTS') {
res.status(409).json({ error: 'Ya existe un término con ese nombre en esa categoría' });
return;
}
logError(err, { endpoint: '/api/glossary', method: 'POST' });
res.status(500).json({ error: 'Error al crear el término' });
}
}
);
/**
* PUT /api/glossary/:id
* Actualizar término (autenticado)
*/
router.put(
'/:id',
authenticate,
requirePermission('content:edit'),
validateParams(glossaryIdSchema),
validateBody(updateGlossaryTermSchema),
async (req: AuthRequest, res: Response) => {
try {
if (!req.user) {
res.status(401).json({ error: 'No autenticado' });
return;
}
const { id } = req.params;
const body = req.body as Parameters<GlossaryService['update']>[0];
const term = await glossaryService.update({ ...body, id }, req.user.id);
res.json(termToJson(term));
} catch (error) {
const err = error as Error;
if (err.message === 'GLOSSARY_TERM_NOT_FOUND') {
sendNotFound(res, 'Término no encontrado');
return;
}
sendServerError(res, err, 'Error al actualizar el término', { endpoint: '/api/glossary/:id', method: 'PUT' });
}
}
);
/**
* DELETE /api/glossary/:id
* Eliminar término (autenticado)
*/
router.delete(
'/:id',
authenticate,
requirePermission('content:write'),
validateParams(glossaryIdSchema),
async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
await glossaryService.delete(id);
res.status(204).send();
} catch (error) {
const err = error as Error;
if (err.message === 'GLOSSARY_TERM_NOT_FOUND') {
sendNotFound(res, 'Término no encontrado');
return;
}
sendServerError(res, err, 'Error al eliminar el término', { endpoint: '/api/glossary/:id', method: 'DELETE' });
}
}
);
export default router;

View file

@ -1,5 +1,6 @@
/** /**
* Rutas para gestión de recursos multimedia * Rutas para gestión de recursos multimedia
* TICKET-015: upload con validación Zod, sanitización de nombre, fileFilter tipado
*/ */
import express, { Response } from 'express'; import express, { Response } from 'express';
@ -9,6 +10,8 @@ import multer from 'multer';
import { join } from 'path'; import { join } from 'path';
import { mkdir, writeFile, unlink, stat, readFile } from 'fs/promises'; import { mkdir, writeFile, unlink, stat, readFile } from 'fs/promises';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import { uploadMediaBodySchema } from '../shared/schemas/media.js';
import { sendServerError, sendNotFound } from '../utils/http-responses.js';
const router = express.Router(); const router = express.Router();
@ -22,7 +25,6 @@ const upload = multer({
fileSize: 50 * 1024 * 1024, // 50MB fileSize: 50 * 1024 * 1024, // 50MB
}, },
fileFilter: (_req, file, cb) => { fileFilter: (_req, file, cb) => {
// Permitir imágenes y vídeos
const allowedMimes = [ const allowedMimes = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml', 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml',
'video/mp4', 'video/webm', 'video/ogg', 'video/mp4', 'video/webm', 'video/ogg',
@ -30,11 +32,17 @@ const upload = multer({
if (allowedMimes.includes(file.mimetype)) { if (allowedMimes.includes(file.mimetype)) {
cb(null, true); cb(null, true);
} else { } else {
cb(new Error('Tipo de archivo no permitido') as any, false); cb(new Error('Tipo de archivo no permitido'));
} }
}, },
}); });
/** Sanitiza extensión: solo alfanuméricos (evita path traversal y caracteres raros) */
function safeExtension(originalName: string): string {
const ext = originalName.split('.').pop()?.toLowerCase() ?? '';
return /^[a-z0-9]+$/.test(ext) ? ext : 'bin';
}
// Asegurar que existe el directorio // Asegurar que existe el directorio
mkdir(storageDir, { recursive: true }).catch(console.error); mkdir(storageDir, { recursive: true }).catch(console.error);
@ -49,14 +57,32 @@ router.post('/upload', requirePermission('content:write'), upload.single('file')
return; return;
} }
const { title, description, alt_text, tags, block, chapter, priority } = req.body; const parsed = uploadMediaBodySchema.safeParse(req.body);
const meta = parsed.success ? parsed.data : {};
const title = meta.title ?? req.file.originalname;
const description = meta.description ?? null;
const alt_text = meta.alt_text ?? null;
const tags = Array.isArray(meta.tags) ? meta.tags : [];
const block = meta.block ?? null;
const chapter = meta.chapter ?? null;
const priority = meta.priority ?? 'media';
if (!parsed.success) {
res.status(400).json({
error: 'Error de validación',
details: parsed.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message })),
});
return;
}
const file = req.file; const file = req.file;
// Determinar tipo // Determinar tipo
const type = file.mimetype.startsWith('image/') ? 'image' : 'video'; const type = file.mimetype.startsWith('image/') ? 'image' : 'video';
// Generar nombre único // Generar nombre único y sanitizar extensión (TICKET-015)
const ext = file.originalname.split('.').pop(); const rawExt = file.originalname.split('.').pop() ?? '';
const ext = safeExtension(file.originalname);
const hash = createHash('md5').update(file.path + Date.now()).digest('hex').substring(0, 8); const hash = createHash('md5').update(file.path + Date.now()).digest('hex').substring(0, 8);
const filename = `${hash}.${ext}`; const filename = `${hash}.${ext}`;
const finalPath = join(storageDir, filename); const finalPath = join(storageDir, filename);
@ -99,13 +125,13 @@ router.post('/upload', requirePermission('content:write'), upload.single('file')
finalPath, finalPath,
filename, filename,
fileUrl, fileUrl,
title || file.originalname, title,
description || null, description,
alt_text || null, alt_text,
tags ? (Array.isArray(tags) ? tags : String(tags).split(',').map((t: string) => t.trim())) : [], tags,
block || null, block,
chapter || null, chapter,
priority || 'media', priority,
stats.size, stats.size,
ext, ext,
]); ]);
@ -181,8 +207,7 @@ router.get('/', requirePermission('content:read'), async (req: AuthRequest, res:
pageSize: pageSizeNum, pageSize: pageSizeNum,
}); });
} catch (error) { } catch (error) {
console.error('Error listando recursos:', error); sendServerError(res, error, undefined, { endpoint: '/api/media', method: 'GET' });
res.status(500).json({ error: 'Error interno del servidor' });
} }
}); });
@ -226,7 +251,7 @@ router.delete('/:id', requirePermission('content:write'), async (req: AuthReques
); );
if (result.rows.length === 0) { if (result.rows.length === 0) {
res.status(404).json({ error: 'Recurso no encontrado' }); sendNotFound(res, 'Recurso no encontrado');
return; return;
} }
@ -268,8 +293,7 @@ router.get('/orphaned/list', requirePermission('content:read'), async (_req: Aut
res.json({ items: result.rows, total: result.rows.length }); res.json({ items: result.rows, total: result.rows.length });
} catch (error) { } catch (error) {
console.error('Error obteniendo recursos huérfanos:', error); sendServerError(res, error, undefined, { endpoint: '/api/media/orphaned/list', method: 'GET' });
res.status(500).json({ error: 'Error interno del servidor' });
} }
}); });

View file

@ -1,168 +1,24 @@
/** /**
* Rutas de estadísticas * Rutas de estadísticas
* Delegan en Application Layer (StatsService).
*/ */
import express, { Response } from 'express'; import express, { Response } from 'express';
import { query } from '../../config/database.js';
import { authenticate, requirePermission, AuthRequest } from '../middleware/auth.js'; import { authenticate, requirePermission, AuthRequest } from '../middleware/auth.js';
import cache from '../services/cache.js'; import cache from '../services/cache.js';
import { StatsService } from '../application/services/StatsService.js';
const router = express.Router(); const router = express.Router();
const statsService = new StatsService(cache);
router.use(authenticate); router.use(authenticate);
// Claves de caché para stats
const CACHE_KEY_CONTENT_STATS = 'stats:content';
const CACHE_KEY_VALIDATION_STATS = 'stats:validation';
const CACHE_KEY_MEDIA_STATS = 'stats:media';
const CACHE_TTL_STATS = 300; // 5 minutos
interface StatsRow {
count: string;
[key: string]: any;
}
interface ContentStatsResponse {
total: number;
byType: Record<string, number>;
byStatus: Record<string, number>;
byLevel: Record<string, number>;
byPriority: Record<string, number>;
publishedByType: Record<string, number>;
protocols: number;
protocolsPublished: number;
guides: number;
guidesPublished: number;
drugs: number;
drugsPublished: number;
checklists: number;
checklistsPublished: number;
}
interface ValidationStatsResponse {
pending: number;
byStatus: Record<string, number>;
recentActivity: any[];
avgValidationTime: string | null;
rejectionsLast30Days: number;
mostRejected: any[];
}
interface MediaStatsResponse {
total: number;
byType: Record<string, number>;
orphaned: number;
totalSize: number;
fileCount: number;
}
/** /**
* GET /api/stats/content * GET /api/stats/content
* Estadísticas generales de contenido
*/ */
router.get('/content', requirePermission('content:read'), async (_req: AuthRequest, res: Response<ContentStatsResponse | { error: string }>) => { router.get('/content', requirePermission('content:read'), async (_req: AuthRequest, res: Response) => {
try { try {
// Intentar obtener de caché primero const stats = await statsService.getContentStats();
const cachedStats = await cache.get<ContentStatsResponse>(CACHE_KEY_CONTENT_STATS);
if (cachedStats) {
res.json(cachedStats);
return;
}
const [total, byType, byStatus, byLevel, byPriority] = await Promise.all([
// Total de contenido
query(`SELECT COUNT(*) as count FROM tes_content.content_items`),
// Por tipo
query(`
SELECT type, COUNT(*) as count
FROM tes_content.content_items
GROUP BY type
`),
// Por estado
query(`
SELECT status, COUNT(*) as count
FROM tes_content.content_items
GROUP BY status
`),
// Por nivel
query(`
SELECT level, COUNT(*) as count
FROM tes_content.content_items
GROUP BY level
`),
// Por prioridad
query(`
SELECT priority, COUNT(*) as count
FROM tes_content.content_items
GROUP BY priority
`),
]);
// Obtener conteos publicados por tipo (solo content_items, no drugs)
const publishedByType = await query(`
SELECT type, COUNT(*) as count
FROM tes_content.content_items
WHERE status = 'published'::tes_content.content_status
GROUP BY type
`);
// Obtener fármacos desde tabla separada
const [drugsTotal, drugsPublished] = await Promise.all([
query(`SELECT COUNT(*) as count FROM tes_content.drugs`),
query(`SELECT COUNT(*) as count FROM tes_content.drugs WHERE status = 'published'::tes_content.content_status`)
]);
const publishedCounts = publishedByType.rows.reduce((acc: Record<string, number>, row: StatsRow) => {
acc[row.type] = parseInt(row.count);
return acc;
}, {});
const byTypeObj = byType.rows.reduce((acc: Record<string, number>, row: StatsRow) => {
acc[row.type] = parseInt(row.count);
return acc;
}, {});
const stats: ContentStatsResponse = {
total: parseInt(total.rows[0].count) + parseInt(drugsTotal.rows[0].count),
byType: {
...byTypeObj,
drug: parseInt(drugsTotal.rows[0].count),
},
byStatus: byStatus.rows.reduce((acc: Record<string, number>, row: StatsRow) => {
acc[row.status] = parseInt(row.count);
return acc;
}, {}),
byLevel: byLevel.rows.reduce((acc: Record<string, number>, row: StatsRow) => {
acc[row.level] = parseInt(row.count);
return acc;
}, {}),
byPriority: byPriority.rows.reduce((acc: Record<string, number>, row: StatsRow) => {
acc[row.priority] = parseInt(row.count);
return acc;
}, {}),
// Añadir conteos publicados por tipo para facilitar el frontend
publishedByType: {
...publishedCounts,
drug: parseInt(drugsPublished.rows[0].count),
},
// Formato compatible con el frontend
protocols: byTypeObj.protocol || 0,
protocolsPublished: publishedCounts.protocol || 0,
guides: byTypeObj.guide || 0,
guidesPublished: publishedCounts.guide || 0,
drugs: parseInt(drugsTotal.rows[0].count),
drugsPublished: parseInt(drugsPublished.rows[0].count),
checklists: byTypeObj.checklist || 0,
checklistsPublished: publishedCounts.checklist || 0,
};
// Almacenar en caché
await cache.set(CACHE_KEY_CONTENT_STATS, stats, CACHE_TTL_STATS);
res.json(stats); res.json(stats);
} catch (error) { } catch (error) {
console.error('Error obteniendo estadísticas:', error); console.error('Error obteniendo estadísticas:', error);
@ -172,113 +28,10 @@ router.get('/content', requirePermission('content:read'), async (_req: AuthReque
/** /**
* GET /api/stats/validation * GET /api/stats/validation
* Estadísticas de validación
*/ */
router.get('/validation', requirePermission('content:validate'), async (_req: AuthRequest, res: Response<ValidationStatsResponse | { error: string }>) => { router.get('/validation', requirePermission('content:validate'), async (_req: AuthRequest, res: Response) => {
try { try {
// Intentar obtener de caché primero const stats = await statsService.getValidationStats();
const cachedStats = await cache.get<ValidationStatsResponse>(CACHE_KEY_VALIDATION_STATS);
if (cachedStats) {
res.json(cachedStats);
return;
}
const [pending, byStatus, recentActivity, avgTime] = await Promise.all([
// Contenido pendiente
query(`
SELECT COUNT(*) as count
FROM tes_content.content_items
WHERE status = 'in_review'::tes_content.content_status
`),
// Por estado de validación
query(`
SELECT status, COUNT(*) as count
FROM tes_content.content_items
WHERE status IN ('draft', 'in_review', 'approved', 'published')
GROUP BY status
`),
// Actividad reciente (últimas 30 validaciones)
query(`
SELECT
al.action,
al.timestamp,
ci.title,
ci.type,
u.username
FROM tes_content.audit_logs al
JOIN tes_content.content_items ci ON al.entity_id = ci.id
LEFT JOIN tes_content.users u ON al.user_id = u.id
WHERE al.entity_type = 'content_item'
AND al.action IN ('submit', 'approve', 'reject', 'publish')
AND al.timestamp >= NOW() - INTERVAL '30 days'
ORDER BY al.timestamp DESC
LIMIT 30
`),
// Tiempo promedio de validación (días)
query(`
WITH validation_times AS (
SELECT
ci.id,
MIN(CASE WHEN al.action = 'submit' THEN al.timestamp END) as submitted_at,
MIN(CASE WHEN al.action IN ('approve', 'reject') THEN al.timestamp END) as validated_at
FROM tes_content.content_items ci
JOIN tes_content.audit_logs al ON al.entity_id = ci.id
WHERE al.entity_type = 'content_item'
AND al.action IN ('submit', 'approve', 'reject')
GROUP BY ci.id
HAVING MIN(CASE WHEN al.action = 'submit' THEN al.timestamp END) IS NOT NULL
AND MIN(CASE WHEN al.action IN ('approve', 'reject') THEN al.timestamp END) IS NOT NULL
)
SELECT AVG(EXTRACT(EPOCH FROM (validated_at - submitted_at)) / 86400) as avg_days
FROM validation_times
`),
]);
// Estadísticas de rechazos
const rejections = await query(`
SELECT COUNT(*) as count
FROM tes_content.audit_logs
WHERE entity_type = 'content_item'
AND action = 'reject'
AND timestamp >= NOW() - INTERVAL '30 days'
`);
// Contenido más rechazado
const mostRejected = await query(`
SELECT
ci.id,
ci.title,
ci.type,
COUNT(*) as rejection_count
FROM tes_content.content_items ci
JOIN tes_content.audit_logs al ON al.entity_id = ci.id
WHERE al.entity_type = 'content_item'
AND al.action = 'reject'
GROUP BY ci.id, ci.title, ci.type
ORDER BY rejection_count DESC
LIMIT 5
`);
const stats: ValidationStatsResponse = {
pending: parseInt(pending.rows[0].count),
byStatus: byStatus.rows.reduce((acc: Record<string, number>, row: StatsRow) => {
acc[row.status] = parseInt(row.count);
return acc;
}, {}),
recentActivity: recentActivity.rows,
avgValidationTime: avgTime.rows[0] && avgTime.rows[0].avg_days
? parseFloat(avgTime.rows[0].avg_days).toFixed(1)
: null,
rejectionsLast30Days: parseInt(rejections.rows[0].count),
mostRejected: mostRejected.rows,
};
// Almacenar en caché
await cache.set(CACHE_KEY_VALIDATION_STATS, stats, CACHE_TTL_STATS);
res.json(stats); res.json(stats);
} catch (error) { } catch (error) {
console.error('Error obteniendo estadísticas de validación:', error); console.error('Error obteniendo estadísticas de validación:', error);
@ -288,61 +41,10 @@ router.get('/validation', requirePermission('content:validate'), async (_req: Au
/** /**
* GET /api/stats/media * GET /api/stats/media
* Estadísticas de recursos multimedia
*/ */
router.get('/media', requirePermission('content:read'), async (_req: AuthRequest, res: Response<MediaStatsResponse | { error: string }>) => { router.get('/media', requirePermission('content:read'), async (_req: AuthRequest, res: Response) => {
try { try {
// Intentar obtener de caché primero const stats = await statsService.getMediaStats();
const cachedStats = await cache.get<MediaStatsResponse>(CACHE_KEY_MEDIA_STATS);
if (cachedStats) {
res.json(cachedStats);
return;
}
const [total, byType, orphaned, totalSize] = await Promise.all([
// Total de recursos
query(`SELECT COUNT(*) as count FROM tes_content.media_resources`),
// Por tipo
query(`
SELECT type, COUNT(*) as count
FROM tes_content.media_resources
GROUP BY type
`),
// Recursos huérfanos
query(`
SELECT COUNT(*) as count
FROM tes_content.media_resources mr
LEFT JOIN tes_content.content_resource_associations cra ON mr.id = cra.media_resource_id
WHERE cra.id IS NULL
`),
// Tamaño total (estimado)
query(`
SELECT
COUNT(*) as file_count,
SUM(COALESCE((metadata->>'size')::bigint, 0)) as total_size
FROM tes_content.media_resources
`),
]);
const stats: MediaStatsResponse = {
total: parseInt(total.rows[0].count),
byType: byType.rows.reduce((acc: Record<string, number>, row: StatsRow) => {
acc[row.type] = parseInt(row.count);
return acc;
}, {}),
orphaned: parseInt(orphaned.rows[0].count),
totalSize: totalSize.rows[0]?.total_size
? parseInt(totalSize.rows[0].total_size)
: 0,
fileCount: parseInt(totalSize.rows[0]?.file_count || '0'),
};
// Almacenar en caché
await cache.set(CACHE_KEY_MEDIA_STATS, stats, CACHE_TTL_STATS);
res.json(stats); res.json(stats);
} catch (error) { } catch (error) {
console.error('Error obteniendo estadísticas de media:', error); console.error('Error obteniendo estadísticas de media:', error);
@ -351,4 +53,3 @@ router.get('/media', requirePermission('content:read'), async (_req: AuthRequest
}); });
export default router; export default router;

View file

@ -1,17 +1,38 @@
/** /**
* Rutas para validación de contenido * Rutas para validación de contenido
* *
* Permite a revisores y validadores aprobar/rechazar contenido * Permite a revisores y validadores aprobar/rechazar contenido.
* Delegan lógica en ValidationService y StatsService.
*/ */
import express, { Response } from 'express'; import express, { Response } from 'express';
import { authenticate, requirePermission, AuthRequest } from '../middleware/auth.js'; import { authenticate, requirePermission, AuthRequest } from '../middleware/auth.js';
import { query } from '../../config/database.js'; import { StatsService } from '../application/services/StatsService.js';
import { ValidationService } from '../application/services/ValidationService.js';
import { ValidationRepository } from '../infrastructure/repositories/ValidationRepository.js';
import cache from '../services/cache.js';
import { sendServerError, sendNotFound, sendBadRequest } from '../utils/http-responses.js';
const router = express.Router(); const router = express.Router();
const statsService = new StatsService(cache);
const validationRepo = new ValidationRepository();
const validationService = new ValidationService(validationRepo);
router.use(authenticate); router.use(authenticate);
/**
* GET /api/validation/dashboard
* Estadísticas para el dashboard de validación (pendientes, por estado, actividad reciente)
*/
router.get('/dashboard', requirePermission('content:validate'), async (_req: AuthRequest, res: Response) => {
try {
const stats = await statsService.getValidationStats();
res.json(stats);
} catch (error) {
sendServerError(res, error, undefined, { endpoint: '/api/validation/dashboard', method: 'GET' });
}
});
/** /**
* POST /api/validation/submit/:contentId * POST /api/validation/submit/:contentId
* Enviar contenido a revisión (draft in_review) * Enviar contenido a revisión (draft in_review)
@ -19,150 +40,53 @@ router.use(authenticate);
router.post('/submit/:contentId', requirePermission('content:submit'), async (req: AuthRequest, res: Response) => { router.post('/submit/:contentId', requirePermission('content:submit'), async (req: AuthRequest, res: Response) => {
try { try {
if (!req.user) { if (!req.user) {
res.status(401).json({ error: 'No autenticado' }); sendBadRequest(res, 'No autenticado');
return; return;
} }
const { contentId } = req.params; const { contentId } = req.params;
const { notes } = req.body; const notes = req.body?.notes;
// Verificar que el contenido existe y pertenece al usuario o es editable const result = await validationService.submit(contentId, req.user.id, notes);
const contentCheck = await query( if (!result.ok) {
`SELECT id, status, created_by FROM tes_content.content_items WHERE id = $1`, if (result.code === 'NOT_FOUND') {
[contentId] sendNotFound(res, result.message);
); return;
}
if (contentCheck.rows.length === 0) { sendBadRequest(res, result.message);
res.status(404).json({ error: 'Contenido no encontrado' });
return; return;
} }
res.json({ success: true, message: result.message, status: result.status });
const content = contentCheck.rows[0];
// Solo se puede enviar si está en draft
if (content.status !== 'draft') {
res.status(400).json({
error: 'Solo se puede enviar contenido en estado "draft"'
});
return;
}
// Actualizar estado
await query(
`UPDATE tes_content.content_items
SET status = 'in_review'::tes_content.content_status,
updated_at = NOW(),
updated_by = $1
WHERE id = $2`,
[req.user.id, contentId]
);
// Registrar en audit log
await query(
`INSERT INTO tes_content.audit_logs (
entity_type, entity_id, action, user_id, user_role, metadata
) VALUES (
'content_item', $1, 'submit', $2, $3, $4::jsonb
)`,
[
contentId,
req.user.id,
req.user.role,
JSON.stringify({ notes: notes || null, previous_status: 'draft' })
]
);
res.json({
success: true,
message: 'Contenido enviado a revisión',
status: 'in_review',
});
return;
} catch (error) { } catch (error) {
console.error('Error enviando a revisión:', error); sendServerError(res, error, undefined, { endpoint: '/api/validation/submit', method: 'POST' });
res.status(500).json({ error: 'Error interno del servidor' });
return;
} }
}); });
/** /**
* POST /api/validation/approve/:contentId * POST /api/validation/approve/:contentId
* Aprobar contenido (in_review approved) * Aprobar contenido (in_review approved o published)
*/ */
router.post('/approve/:contentId', requirePermission('content:approve'), async (req: AuthRequest, res: Response) => { router.post('/approve/:contentId', requirePermission('content:approve'), async (req: AuthRequest, res: Response) => {
try { try {
if (!req.user) { if (!req.user) {
res.status(401).json({ error: 'No autenticado' }); sendBadRequest(res, 'No autenticado');
return; return;
} }
const { contentId } = req.params; const { contentId } = req.params;
const { notes, publish } = req.body; const notes = req.body?.notes;
const publish = req.body?.publish;
// Verificar que el contenido existe const result = await validationService.approve(contentId, req.user.id, notes, publish);
const contentCheck = await query( if (!result.ok) {
`SELECT id, status FROM tes_content.content_items WHERE id = $1`, if (result.code === 'NOT_FOUND') {
[contentId] sendNotFound(res, result.message);
); return;
}
if (contentCheck.rows.length === 0) { sendBadRequest(res, result.message);
res.status(404).json({ error: 'Contenido no encontrado' });
return; return;
} }
res.json({ success: true, message: result.message, status: result.status });
const content = contentCheck.rows[0];
// Solo se puede aprobar si está en in_review
if (content.status !== 'in_review') {
res.status(400).json({
error: 'Solo se puede aprobar contenido en estado "in_review"'
});
}
// Determinar nuevo estado
const newStatus = publish ? 'published' : 'approved';
// Actualizar estado
await query(
`UPDATE tes_content.content_items
SET status = $1::tes_content.content_status,
validated_by = $2,
validated_at = NOW(),
updated_at = NOW(),
updated_by = $2
WHERE id = $3`,
[newStatus, req.user.id, contentId]
);
// Registrar en audit log
await query(
`INSERT INTO tes_content.audit_logs (
entity_type, entity_id, action, user_id, user_role, metadata
) VALUES (
'content_item', $1, 'approve', $2, $3, $4::jsonb
)`,
[
contentId,
req.user.id,
req.user.role,
JSON.stringify({
notes: notes || null,
previous_status: 'in_review',
published: publish || false
})
]
);
res.json({
success: true,
message: publish ? 'Contenido aprobado y publicado' : 'Contenido aprobado',
status: newStatus,
});
return;
} catch (error) { } catch (error) {
console.error('Error aprobando contenido:', error); sendServerError(res, error, undefined, { endpoint: '/api/validation/approve', method: 'POST' });
res.status(500).json({ error: 'Error interno del servidor' });
return;
} }
}); });
@ -173,78 +97,24 @@ router.post('/approve/:contentId', requirePermission('content:approve'), async (
router.post('/reject/:contentId', requirePermission('content:approve'), async (req: AuthRequest, res: Response) => { router.post('/reject/:contentId', requirePermission('content:approve'), async (req: AuthRequest, res: Response) => {
try { try {
if (!req.user) { if (!req.user) {
res.status(401).json({ error: 'No autenticado' }); sendBadRequest(res, 'No autenticado');
return; return;
} }
const { contentId } = req.params; const { contentId } = req.params;
const { notes } = req.body; const notes = req.body?.notes;
if (!notes) { const result = await validationService.reject(contentId, req.user.id, notes);
res.status(400).json({ error: 'Las notas de rechazo son obligatorias' }); if (!result.ok) {
if (result.code === 'NOT_FOUND') {
sendNotFound(res, result.message);
return;
}
sendBadRequest(res, result.message);
return; return;
} }
res.json({ success: true, message: result.message, status: result.status });
// Verificar que el contenido existe
const contentCheck = await query(
`SELECT id, status FROM tes_content.content_items WHERE id = $1`,
[contentId]
);
if (contentCheck.rows.length === 0) {
res.status(404).json({ error: 'Contenido no encontrado' });
return;
}
const content = contentCheck.rows[0];
// Solo se puede rechazar si está en in_review
if (content.status !== 'in_review') {
res.status(400).json({
error: 'Solo se puede rechazar contenido en estado "in_review"'
});
return;
return;
}
// Actualizar estado
await query(
`UPDATE tes_content.content_items
SET status = 'draft'::tes_content.content_status,
updated_at = NOW(),
updated_by = $1
WHERE id = $2`,
[req.user.id, contentId]
);
// Registrar en audit log
await query(
`INSERT INTO tes_content.audit_logs (
entity_type, entity_id, action, user_id, user_role, metadata
) VALUES (
'content_item', $1, 'reject', $2, $3, $4::jsonb
)`,
[
contentId,
req.user.id,
req.user.role,
JSON.stringify({
notes,
previous_status: 'in_review'
})
]
);
res.json({
success: true,
message: 'Contenido rechazado y devuelto a borrador',
status: 'draft',
});
return;
} catch (error) { } catch (error) {
console.error('Error rechazando contenido:', error); sendServerError(res, error, undefined, { endpoint: '/api/validation/reject', method: 'POST' });
res.status(500).json({ error: 'Error interno del servidor' });
return;
} }
}); });
@ -255,67 +125,23 @@ router.post('/reject/:contentId', requirePermission('content:approve'), async (r
router.post('/publish/:contentId', requirePermission('content:publish'), async (req: AuthRequest, res: Response) => { router.post('/publish/:contentId', requirePermission('content:publish'), async (req: AuthRequest, res: Response) => {
try { try {
if (!req.user) { if (!req.user) {
res.status(401).json({ error: 'No autenticado' }); sendBadRequest(res, 'No autenticado');
return; return;
} }
const { contentId } = req.params; const { contentId } = req.params;
// Verificar que el contenido existe const result = await validationService.publish(contentId, req.user.id);
const contentCheck = await query( if (!result.ok) {
`SELECT id, status FROM tes_content.content_items WHERE id = $1`, if (result.code === 'NOT_FOUND') {
[contentId] sendNotFound(res, result.message);
); return;
}
if (contentCheck.rows.length === 0) { sendBadRequest(res, result.message);
res.status(404).json({ error: 'Contenido no encontrado' });
return; return;
} }
res.json({ success: true, message: result.message, status: result.status });
const content = contentCheck.rows[0];
// Solo se puede publicar si está approved
if (content.status !== 'approved') {
res.status(400).json({
error: 'Solo se puede publicar contenido en estado "approved"'
});
}
// Actualizar estado
await query(
`UPDATE tes_content.content_items
SET status = 'published'::tes_content.content_status,
updated_at = NOW(),
updated_by = $1
WHERE id = $2`,
[req.user.id, contentId]
);
// Registrar en audit log
await query(
`INSERT INTO tes_content.audit_logs (
entity_type, entity_id, action, user_id, user_role, metadata
) VALUES (
'content_item', $1, 'publish', $2, $3, $4::jsonb
)`,
[
contentId,
req.user.id,
req.user.role,
JSON.stringify({ previous_status: 'approved' })
]
);
res.json({
success: true,
message: 'Contenido publicado',
status: 'published',
});
return;
} catch (error) { } catch (error) {
console.error('Error publicando contenido:', error); sendServerError(res, error, undefined, { endpoint: '/api/validation/publish', method: 'POST' });
res.status(500).json({ error: 'Error interno del servidor' });
return;
} }
}); });
@ -325,52 +151,12 @@ router.post('/publish/:contentId', requirePermission('content:publish'), async (
*/ */
router.get('/pending', requirePermission('content:validate'), async (req: AuthRequest, res: Response) => { router.get('/pending', requirePermission('content:validate'), async (req: AuthRequest, res: Response) => {
try { try {
const { type, priority } = req.query; const type = req.query.type as string | undefined;
const priority = req.query.priority as string | undefined;
let whereConditions = ["status = 'in_review'::tes_content.content_status"]; const result = await validationService.getPending({ type, priority });
let params = []; res.json({ items: result.items, total: result.total });
let paramIndex = 1;
if (type) {
whereConditions.push(`type = $${paramIndex++}`);
params.push(type);
}
if (priority) {
whereConditions.push(`priority = $${paramIndex++}`);
params.push(priority);
}
const result = await query(
`SELECT
ci.id, ci.type, ci.slug, ci.title, ci.short_title, ci.description,
ci.status, ci.priority, ci.level,
ci.created_at, ci.updated_at,
u_created.username as created_by_username,
u_updated.username as updated_by_username
FROM tes_content.content_items ci
LEFT JOIN tes_content.users u_created ON ci.created_by = u_created.id
LEFT JOIN tes_content.users u_updated ON ci.updated_by = u_updated.id
WHERE ${whereConditions.join(' AND ')}
ORDER BY
CASE ci.priority
WHEN 'critica' THEN 1
WHEN 'alta' THEN 2
WHEN 'media' THEN 3
WHEN 'baja' THEN 4
END,
ci.created_at ASC`,
params
);
res.json({
items: result.rows,
total: result.rows.length,
});
} catch (error) { } catch (error) {
console.error('Error obteniendo contenido pendiente:', error); sendServerError(res, error, undefined, { endpoint: '/api/validation/pending', method: 'GET' });
res.status(500).json({ error: 'Error interno del servidor' });
return;
} }
}); });
@ -381,28 +167,11 @@ router.get('/pending', requirePermission('content:validate'), async (req: AuthRe
router.get('/history/:contentId', requirePermission('content:read'), async (req: AuthRequest, res: Response) => { router.get('/history/:contentId', requirePermission('content:read'), async (req: AuthRequest, res: Response) => {
try { try {
const { contentId } = req.params; const { contentId } = req.params;
const history = await validationService.getHistory(contentId);
const result = await query( res.json({ history });
`SELECT
al.id, al.action, al.timestamp, al.metadata,
u.username, u.role
FROM tes_content.audit_logs al
LEFT JOIN tes_content.users u ON al.user_id = u.id
WHERE al.entity_type = 'content_item' AND al.entity_id = $1
AND al.action IN ('submit', 'approve', 'reject', 'publish')
ORDER BY al.timestamp DESC`,
[contentId]
);
res.json({
history: result.rows,
});
} catch (error) { } catch (error) {
console.error('Error obteniendo historial:', error); sendServerError(res, error, undefined, { endpoint: '/api/validation/history', method: 'GET' });
res.status(500).json({ error: 'Error interno del servidor' });
return;
} }
}); });
export default router; export default router;

View file

@ -0,0 +1,221 @@
/**
* Tests de validación Zod para schemas compartidos (TICKET-003)
* Casos válidos e inválidos para ContentItem, Drug, GlossaryTerm, MediaResource y CRUD.
*/
import { describe, it, expect } from 'vitest';
import {
createContentSchema,
updateContentSchema,
listContentQuerySchema,
createDrugSchema,
updateDrugSchema,
listDrugsQuerySchema,
createGlossaryTermSchema,
updateGlossaryTermSchema,
listGlossaryQuerySchema,
createMediaResourceSchema,
updateMediaResourceSchema,
listMediaQuerySchema,
submitForReviewSchema,
approveContentSchema,
rejectContentSchema,
uuidSchema,
contentStatusSchema,
slugSchema,
} from '../index.js';
const validUuid = '550e8400-e29b-41d4-a716-446655440000';
describe('common schemas', () => {
it('uuidSchema: acepta UUID válido', () => {
expect(uuidSchema.parse(validUuid)).toBe(validUuid);
});
it('uuidSchema: rechaza string no UUID', () => {
expect(() => uuidSchema.parse('not-a-uuid')).toThrow();
expect(() => uuidSchema.parse('')).toThrow();
});
it('contentStatusSchema: acepta estados válidos', () => {
['draft', 'in_review', 'approved', 'published', 'archived'].forEach((s) => {
expect(contentStatusSchema.parse(s)).toBe(s);
});
});
it('contentStatusSchema: rechaza estado inválido', () => {
expect(() => contentStatusSchema.parse('invalid')).toThrow();
});
it('slugSchema: acepta slug válido', () => {
expect(slugSchema.parse('rcp-adulto')).toBe('rcp-adulto');
expect(slugSchema.parse('via-aerea-1')).toBe('via-aerea-1');
});
it('slugSchema: rechaza slug inválido', () => {
expect(() => slugSchema.parse('RCP')).toThrow();
expect(() => slugSchema.parse('con espacios')).toThrow();
});
});
describe('content schemas', () => {
it('createContentSchema: acepta payload válido tipo protocol', () => {
const valid = {
type: 'protocol',
slug: 'test-protocol',
level: 'operativo',
title: 'Protocolo test',
priority: 'alta',
ageGroup: 'adulto',
content: {
steps: [{ order: 1, type: 'obligatory', content: 'Paso 1' }],
},
};
expect(createContentSchema.parse(valid)).toEqual(valid);
});
it('createContentSchema: rechaza sin campos obligatorios', () => {
expect(() => createContentSchema.parse({})).toThrow();
expect(() => createContentSchema.parse({ type: 'protocol', slug: 'x', level: 'operativo', title: 'T' })).toThrow(); // content faltante
});
it('updateContentSchema: acepta parcial con id', () => {
const valid = { id: validUuid, title: 'Nuevo título' };
expect(updateContentSchema.parse(valid)).toEqual(valid);
});
it('updateContentSchema: rechaza sin id', () => {
expect(() => updateContentSchema.parse({ title: 'T' })).toThrow();
});
it('listContentQuerySchema: acepta query vacía con defaults', () => {
const r = listContentQuerySchema.parse({});
expect(r.page).toBe(1);
expect(r.pageSize).toBe(20);
});
it('listContentQuerySchema: acepta filtros válidos', () => {
const r = listContentQuerySchema.parse({ type: 'protocol', status: 'published', page: 2, pageSize: 10 });
expect(r.type).toBe('protocol');
expect(r.page).toBe(2);
});
});
describe('drugs schemas', () => {
it('createDrugSchema: acepta payload válido', () => {
const valid = {
slug: 'adrenalina',
genericName: 'Adrenalina',
category: 'cardiovascular',
line: 'first',
frequency: 'high',
presentation: '1 mg/ml',
adultDose: '1 mg IV',
routes: ['IV'],
indications: ['Parada cardiorrespiratoria'],
contraindications: [],
notes: [],
criticalPoints: [],
};
expect(createDrugSchema.parse(valid)).toEqual(valid);
});
it('createDrugSchema: rechaza sin routes', () => {
const invalid = {
slug: 'adrenalina',
genericName: 'Adrenalina',
category: 'cardiovascular',
line: 'first',
frequency: 'high',
presentation: '1 mg/ml',
adultDose: '1 mg IV',
routes: [],
indications: [],
contraindications: [],
notes: [],
criticalPoints: [],
};
expect(() => createDrugSchema.parse(invalid)).toThrow();
});
it('updateDrugSchema: acepta parcial con id', () => {
expect(updateDrugSchema.parse({ id: validUuid, genericName: 'Nuevo nombre' })).toMatchObject({ id: validUuid, genericName: 'Nuevo nombre' });
});
it('listDrugsQuerySchema: acepta query con defaults', () => {
const r = listDrugsQuerySchema.parse({});
expect(r.page).toBe(1);
expect(r.pageSize).toBe(20);
});
});
describe('glossary schemas', () => {
it('createGlossaryTermSchema: acepta payload válido', () => {
const valid = {
term: 'RCP',
category: 'clinical',
definition: 'Reanimación cardiopulmonar',
};
expect(createGlossaryTermSchema.parse(valid)).toEqual(valid);
});
it('createGlossaryTermSchema: rechaza term vacío', () => {
expect(() => createGlossaryTermSchema.parse({ term: '', category: 'clinical', definition: 'x' })).toThrow();
});
it('updateGlossaryTermSchema: acepta parcial con id', () => {
expect(updateGlossaryTermSchema.parse({ id: validUuid, definition: 'Nueva def' })).toMatchObject({ id: validUuid, definition: 'Nueva def' });
});
it('listGlossaryQuerySchema: acepta query con defaults', () => {
const r = listGlossaryQuerySchema.parse({});
expect(r.page).toBe(1);
expect(r.pageSize).toBe(20);
});
});
describe('media schemas', () => {
it('createMediaResourceSchema: acepta payload válido', () => {
const valid = {
type: 'image',
filename: 'foto.png',
tags: [],
};
expect(createMediaResourceSchema.parse(valid)).toEqual(valid);
});
it('createMediaResourceSchema: rechaza type inválido', () => {
expect(() => createMediaResourceSchema.parse({ type: 'otro', filename: 'x' })).toThrow();
});
it('updateMediaResourceSchema: acepta parcial con id', () => {
expect(updateMediaResourceSchema.parse({ id: validUuid, title: 'Título' })).toMatchObject({ id: validUuid, title: 'Título' });
});
it('listMediaQuerySchema: acepta query con defaults', () => {
const r = listMediaQuerySchema.parse({});
expect(r.page).toBe(1);
expect(r.pageSize).toBe(20);
});
});
describe('validation schemas', () => {
it('submitForReviewSchema: acepta contentId y notes opcional', () => {
expect(submitForReviewSchema.parse({ contentId: validUuid })).toEqual({ contentId: validUuid });
expect(submitForReviewSchema.parse({ contentId: validUuid, notes: 'Revisar' })).toMatchObject({ contentId: validUuid, notes: 'Revisar' });
});
it('approveContentSchema: acepta contentId y publish opcional', () => {
expect(approveContentSchema.parse({ contentId: validUuid })).toMatchObject({ contentId: validUuid, publish: false });
});
it('rejectContentSchema: exige al menos un comentario', () => {
expect(() => rejectContentSchema.parse({ contentId: validUuid, comments: [] })).toThrow();
expect(
rejectContentSchema.parse({
contentId: validUuid,
comments: [{ comment: 'Rechazado', type: 'correction', severity: 'high' }],
})
).toBeDefined();
});
});

View file

@ -189,12 +189,25 @@ export const createContentSchema = z.object({
export type CreateContentInput = z.infer<typeof createContentSchema>; export type CreateContentInput = z.infer<typeof createContentSchema>;
/** /**
* Schema para actualizar contenido * Schema para actualizar contenido (campos opcionales + id obligatorio)
* No usa .partial() sobre createContentSchema porque Zod 4 no permite .partial() en schemas con refinements.
*/ */
export const updateContentSchema = createContentSchema.partial().extend({ export const updateContentSchema = z.object({
id: uuidSchema id: uuidSchema,
type: contentTypeSchema.optional(),
slug: slugSchema.optional(),
level: contentLevelSchema.optional(),
title: nonEmptyStringSchema.max(500).optional(),
shortTitle: z.string().max(200).optional(),
description: z.string().max(2000).optional(),
content: z.record(z.string(), z.unknown()).optional(),
contentMarkdown: z.string().optional(),
category: z.string().max(100).optional(),
subcategory: z.string().max(100).optional(),
priority: contentPrioritySchema.optional(),
ageGroup: ageGroupSchema.optional(),
tags: stringArraySchema.optional()
}).superRefine((data, ctx) => { }).superRefine((data, ctx) => {
// Si se actualiza type, content debe coincidir
if (data.type && data.content) { if (data.type && data.content) {
try { try {
if (data.type === 'protocol') { if (data.type === 'protocol') {

View file

@ -100,3 +100,12 @@ export const searchGlossarySchema = z.object({
}); });
export type SearchGlossaryInput = z.infer<typeof searchGlossarySchema>; export type SearchGlossaryInput = z.infer<typeof searchGlossarySchema>;
/**
* Schema para ID de término (params de URL)
*/
export const glossaryIdSchema = z.object({
id: uuidSchema
});
export type GlossaryIdParams = z.infer<typeof glossaryIdSchema>;

View file

@ -1,5 +1,6 @@
/** /**
* Exportar todos los schemas Zod compartidos * Exportar todos los schemas Zod compartidos (TICKET-003)
* Tipos y schemas reutilizables entre backend y frontend.
*/ */
// Common schemas // Common schemas
@ -11,3 +12,9 @@ export * from './drugs.js';
export * from './glossary.js'; export * from './glossary.js';
export * from './media.js'; export * from './media.js';
export * from './validation.js'; export * from './validation.js';
// Aliases de tipos para API/respuestas (nombres de entidad)
export type { ContentItemInput as ContentItem } from './content.js';
export type { DrugInput as Drug } from './drugs.js';
export type { GlossaryTermInput as GlossaryTerm } from './glossary.js';
export type { MediaResourceInput as MediaResource } from './media.js';

View file

@ -129,3 +129,26 @@ export const uploadMediaSchema = z.object({
}); });
export type UploadMediaInput = z.infer<typeof uploadMediaSchema>; export type UploadMediaInput = z.infer<typeof uploadMediaSchema>;
/**
* Schema para body del multipart upload (campos como string)
* TICKET-015: validación de metadatos en upload
*/
export const uploadMediaBodySchema = z.object({
title: z.string().max(500).optional().transform((s) => (s?.trim() || undefined)),
description: z.string().max(2000).optional().transform((s) => (s?.trim() || undefined)),
alt_text: z.string().max(500).optional().transform((s) => (s?.trim() || undefined)),
tags: z
.string()
.optional()
.transform((s) =>
s ? s.split(',').map((t) => t.trim()).filter(Boolean) : []
),
block: z.string().max(100).optional().transform((s) => (s?.trim() || undefined)),
chapter: z.string().max(100).optional().transform((s) => (s?.trim() || undefined)),
priority: z
.string()
.optional()
.transform((s) => (s && ['baja', 'media', 'alta', 'critica'].includes(s) ? s : undefined)),
});
export type UploadMediaBodyInput = z.infer<typeof uploadMediaBodySchema>;

View file

@ -0,0 +1,46 @@
/**
* Helpers para respuestas HTTP (TICKET-006)
* Centraliza patrones repetidos: 404, 500 con logging.
*/
import { Response } from 'express';
import { logError } from './logger.js';
const DEFAULT_500_MESSAGE = 'Error interno del servidor';
const DEFAULT_404_MESSAGE = 'Recurso no encontrado';
/**
* Envía 500, registra el error y finaliza la respuesta.
*/
export function sendServerError(
res: Response,
error: unknown,
message: string = DEFAULT_500_MESSAGE,
context?: Record<string, unknown>
): void {
const err = error instanceof Error ? error : new Error(String(error));
logError(err, context as Record<string, unknown>);
res.status(500).json({ error: message });
}
/**
* Envía 404 y finaliza la respuesta.
*/
export function sendNotFound(res: Response, message: string = DEFAULT_404_MESSAGE): void {
res.status(404).json({ error: message });
}
/**
* Envía 400 con mensaje y opcionalmente detalles (ej. validación Zod).
*/
export function sendBadRequest(
res: Response,
message: string,
details?: unknown
): void {
if (details !== undefined) {
res.status(400).json({ error: message, details });
} else {
res.status(400).json({ error: message });
}
}

View file

@ -8,8 +8,10 @@ export {
updateGlossaryTermSchema, updateGlossaryTermSchema,
listGlossaryQuerySchema, listGlossaryQuerySchema,
searchGlossarySchema, searchGlossarySchema,
glossaryIdSchema,
type CreateGlossaryTermInput, type CreateGlossaryTermInput,
type UpdateGlossaryTermInput, type UpdateGlossaryTermInput,
type ListGlossaryQuery, type ListGlossaryQuery,
type SearchGlossaryInput type SearchGlossaryInput,
type GlossaryIdParams
} from '../shared/schemas/glossary.js'; } from '../shared/schemas/glossary.js';

21
backend/vitest.config.ts Normal file
View file

@ -0,0 +1,21 @@
import { defineConfig } from 'vitest/config';
import path from 'path';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/shared/schemas/**/*.ts'],
exclude: ['src/**/*.test.ts', 'src/**/*.d.ts'],
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
{"result":[{"scriptId":"1224","url":"file:///home/planetazuzu/guia-tes/src/test/setup.ts","functions":[{"functionName":"","ranges":[{"startOffset":0,"endOffset":4641,"count":1}],"isBlockCoverage":true},{"functionName":"","ranges":[{"startOffset":13,"endOffset":4641,"count":1}],"isBlockCoverage":true},{"functionName":"","ranges":[{"startOffset":785,"endOffset":833,"count":4}],"isBlockCoverage":true},{"functionName":"value","ranges":[{"startOffset":979,"endOffset":1265,"count":0}],"isBlockCoverage":false},{"functionName":"observe","ranges":[{"startOffset":1384,"endOffset":1396,"count":0}],"isBlockCoverage":false},{"functionName":"unobserve","ranges":[{"startOffset":1401,"endOffset":1415,"count":0}],"isBlockCoverage":false},{"functionName":"disconnect","ranges":[{"startOffset":1420,"endOffset":1435,"count":0}],"isBlockCoverage":false},{"functionName":"observe","ranges":[{"startOffset":1564,"endOffset":1576,"count":0}],"isBlockCoverage":false},{"functionName":"unobserve","ranges":[{"startOffset":1581,"endOffset":1595,"count":0}],"isBlockCoverage":false},{"functionName":"disconnect","ranges":[{"startOffset":1600,"endOffset":1615,"count":0}],"isBlockCoverage":false},{"functionName":"IntersectionObserver","ranges":[{"startOffset":1620,"endOffset":1635,"count":0}],"isBlockCoverage":false}],"startOffset":209},{"scriptId":"1540","url":"file:///home/planetazuzu/guia-tes/src/hooks/useProtocolRelations.test.ts","functions":[{"functionName":"","ranges":[{"startOffset":0,"endOffset":11178,"count":1}],"isBlockCoverage":true},{"functionName":"","ranges":[{"startOffset":13,"endOffset":11178,"count":1}],"isBlockCoverage":true},{"functionName":"","ranges":[{"startOffset":430,"endOffset":1194,"count":1}],"isBlockCoverage":true},{"functionName":"","ranges":[{"startOffset":499,"endOffset":1186,"count":4},{"startOffset":548,"endOffset":709,"count":0},{"startOffset":747,"endOffset":896,"count":0}],"isBlockCoverage":true},{"functionName":"","ranges":[{"startOffset":1628,"endOffset":4153,"count":1}],"isBlockCoverage":true},{"functionName":"","ranges":[{"startOffset":1675,"endOffset":1736,"count":4}],"isBlockCoverage":true},{"functionName":"","ranges":[{"startOffset":1794,"endOffset":2001,"count":1}],"isBlockCoverage":true},{"functionName":"","ranges":[{"startOffset":1854,"endOffset":1912,"count":2}],"isBlockCoverage":true},{"functionName":"","ranges":[{"startOffset":2079,"endOffset":2602,"count":1}],"isBlockCoverage":true},{"functionName":"","ranges":[{"startOffset":2145,"endOffset":2203,"count":2}],"isBlockCoverage":true},{"functionName":"","ranges":[{"startOffset":2244,"endOffset":2348,"count":1}],"isBlockCoverage":true},{"functionName":"","ranges":[{"startOffset":2668,"endOffset":3151,"count":1}],"isBlockCoverage":true},{"functionName":"","ranges":[{"startOffset":2734,"endOffset":2778,"count":2}],"isBlockCoverage":true},{"functionName":"","ranges":[{"startOffset":2819,"endOffset":2923,"count":1}],"isBlockCoverage":true},{"functionName":"","ranges":[{"startOffset":3228,"endOffset":4149,"count":1}],"isBlockCoverage":true},{"functionName":"__vi_import_0__.renderHook.initialProps.id","ranges":[{"startOffset":3304,"endOffset":3354,"count":4}],"isBlockCoverage":true},{"functionName":"","ranges":[{"startOffset":3487,"endOffset":3587,"count":1}],"isBlockCoverage":true},{"functionName":"","ranges":[{"startOffset":3861,"endOffset":4141,"count":1}],"isBlockCoverage":true}],"startOffset":209},{"scriptId":"1541","url":"file:///home/planetazuzu/guia-tes/src/hooks/useProtocolRelations.ts","functions":[{"functionName":"","ranges":[{"startOffset":0,"endOffset":5409,"count":1}],"isBlockCoverage":true},{"functionName":"","ranges":[{"startOffset":13,"endOffset":5409,"count":1}],"isBlockCoverage":true},{"functionName":"","ranges":[{"startOffset":257,"endOffset":311,"count":10},{"startOffset":301,"endOffset":309,"count":0}],"isBlockCoverage":true},{"functionName":"useProtocolRelations","ranges":[{"startOffset":861,"endOffset":1902,"count":10}],"isBlockCoverage":true},{"functionName":"","ranges":[{"startOffset":1046,"endOffset":1852,"count":5},{"startOffset":1112,"endOffset":1145,"count":4},{"startOffset":1146,"endOffset":1179,"count":4},{"startOffset":1181,"endOffset":1346,"count":1},{"startOffset":1346,"endOffset":1521,"count":4},{"startOffset":1523,"endOffset":1642,"count":4},{"startOffset":1642,"endOffset":1846,"count":0}],"isBlockCoverage":true}],"startOffset":209}]}

View file

@ -1,224 +0,0 @@
body, html {
margin:0; padding: 0;
height: 100%;
}
body {
font-family: Helvetica Neue, Helvetica, Arial;
font-size: 14px;
color:#333;
}
.small { font-size: 12px; }
*, *:after, *:before {
-webkit-box-sizing:border-box;
-moz-box-sizing:border-box;
box-sizing:border-box;
}
h1 { font-size: 20px; margin: 0;}
h2 { font-size: 14px; }
pre {
font: 12px/1.4 Consolas, "Liberation Mono", Menlo, Courier, monospace;
margin: 0;
padding: 0;
-moz-tab-size: 2;
-o-tab-size: 2;
tab-size: 2;
}
a { color:#0074D9; text-decoration:none; }
a:hover { text-decoration:underline; }
.strong { font-weight: bold; }
.space-top1 { padding: 10px 0 0 0; }
.pad2y { padding: 20px 0; }
.pad1y { padding: 10px 0; }
.pad2x { padding: 0 20px; }
.pad2 { padding: 20px; }
.pad1 { padding: 10px; }
.space-left2 { padding-left:55px; }
.space-right2 { padding-right:20px; }
.center { text-align:center; }
.clearfix { display:block; }
.clearfix:after {
content:'';
display:block;
height:0;
clear:both;
visibility:hidden;
}
.fl { float: left; }
@media only screen and (max-width:640px) {
.col3 { width:100%; max-width:100%; }
.hide-mobile { display:none!important; }
}
.quiet {
color: #7f7f7f;
color: rgba(0,0,0,0.5);
}
.quiet a { opacity: 0.7; }
.fraction {
font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace;
font-size: 10px;
color: #555;
background: #E8E8E8;
padding: 4px 5px;
border-radius: 3px;
vertical-align: middle;
}
div.path a:link, div.path a:visited { color: #333; }
table.coverage {
border-collapse: collapse;
margin: 10px 0 0 0;
padding: 0;
}
table.coverage td {
margin: 0;
padding: 0;
vertical-align: top;
}
table.coverage td.line-count {
text-align: right;
padding: 0 5px 0 20px;
}
table.coverage td.line-coverage {
text-align: right;
padding-right: 10px;
min-width:20px;
}
table.coverage td span.cline-any {
display: inline-block;
padding: 0 5px;
width: 100%;
}
.missing-if-branch {
display: inline-block;
margin-right: 5px;
border-radius: 3px;
position: relative;
padding: 0 4px;
background: #333;
color: yellow;
}
.skip-if-branch {
display: none;
margin-right: 10px;
position: relative;
padding: 0 4px;
background: #ccc;
color: white;
}
.missing-if-branch .typ, .skip-if-branch .typ {
color: inherit !important;
}
.coverage-summary {
border-collapse: collapse;
width: 100%;
}
.coverage-summary tr { border-bottom: 1px solid #bbb; }
.keyline-all { border: 1px solid #ddd; }
.coverage-summary td, .coverage-summary th { padding: 10px; }
.coverage-summary tbody { border: 1px solid #bbb; }
.coverage-summary td { border-right: 1px solid #bbb; }
.coverage-summary td:last-child { border-right: none; }
.coverage-summary th {
text-align: left;
font-weight: normal;
white-space: nowrap;
}
.coverage-summary th.file { border-right: none !important; }
.coverage-summary th.pct { }
.coverage-summary th.pic,
.coverage-summary th.abs,
.coverage-summary td.pct,
.coverage-summary td.abs { text-align: right; }
.coverage-summary td.file { white-space: nowrap; }
.coverage-summary td.pic { min-width: 120px !important; }
.coverage-summary tfoot td { }
.coverage-summary .sorter {
height: 10px;
width: 7px;
display: inline-block;
margin-left: 0.5em;
background: url(sort-arrow-sprite.png) no-repeat scroll 0 0 transparent;
}
.coverage-summary .sorted .sorter {
background-position: 0 -20px;
}
.coverage-summary .sorted-desc .sorter {
background-position: 0 -10px;
}
.status-line { height: 10px; }
/* yellow */
.cbranch-no { background: yellow !important; color: #111; }
/* dark red */
.red.solid, .status-line.low, .low .cover-fill { background:#C21F39 }
.low .chart { border:1px solid #C21F39 }
.highlighted,
.highlighted .cstat-no, .highlighted .fstat-no, .highlighted .cbranch-no{
background: #C21F39 !important;
}
/* medium red */
.cstat-no, .fstat-no, .cbranch-no, .cbranch-no { background:#F6C6CE }
/* light red */
.low, .cline-no { background:#FCE1E5 }
/* light green */
.high, .cline-yes { background:rgb(230,245,208) }
/* medium green */
.cstat-yes { background:rgb(161,215,106) }
/* dark green */
.status-line.high, .high .cover-fill { background:rgb(77,146,33) }
.high .chart { border:1px solid rgb(77,146,33) }
/* dark yellow (gold) */
.status-line.medium, .medium .cover-fill { background: #f9cd0b; }
.medium .chart { border:1px solid #f9cd0b; }
/* light yellow */
.medium { background: #fff4c2; }
.cstat-skip { background: #ddd; color: #111; }
.fstat-skip { background: #ddd; color: #111 !important; }
.cbranch-skip { background: #ddd !important; color: #111; }
span.cline-neutral { background: #eaeaea; }
.coverage-summary td.empty {
opacity: .5;
padding-top: 4px;
padding-bottom: 4px;
line-height: 1;
color: #888;
}
.cover-fill, .cover-empty {
display:inline-block;
height: 12px;
}
.chart {
line-height: 0;
}
.cover-empty {
background: white;
}
.cover-full {
border-right: none !important;
}
pre.prettyprint {
border: none !important;
padding: 0 !important;
margin: 0 !important;
}
.com { color: #999 !important; }
.ignore-none { color: #999; font-weight: normal; }
.wrapper {
min-height: 100%;
height: auto !important;
height: 100%;
margin: 0 auto -48px;
}
.footer, .push {
height: 48px;
}

View file

@ -1,87 +0,0 @@
/* eslint-disable */
var jumpToCode = (function init() {
// Classes of code we would like to highlight in the file view
var missingCoverageClasses = ['.cbranch-no', '.cstat-no', '.fstat-no'];
// Elements to highlight in the file listing view
var fileListingElements = ['td.pct.low'];
// We don't want to select elements that are direct descendants of another match
var notSelector = ':not(' + missingCoverageClasses.join('):not(') + ') > '; // becomes `:not(a):not(b) > `
// Selector that finds elements on the page to which we can jump
var selector =
fileListingElements.join(', ') +
', ' +
notSelector +
missingCoverageClasses.join(', ' + notSelector); // becomes `:not(a):not(b) > a, :not(a):not(b) > b`
// The NodeList of matching elements
var missingCoverageElements = document.querySelectorAll(selector);
var currentIndex;
function toggleClass(index) {
missingCoverageElements
.item(currentIndex)
.classList.remove('highlighted');
missingCoverageElements.item(index).classList.add('highlighted');
}
function makeCurrent(index) {
toggleClass(index);
currentIndex = index;
missingCoverageElements.item(index).scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'center'
});
}
function goToPrevious() {
var nextIndex = 0;
if (typeof currentIndex !== 'number' || currentIndex === 0) {
nextIndex = missingCoverageElements.length - 1;
} else if (missingCoverageElements.length > 1) {
nextIndex = currentIndex - 1;
}
makeCurrent(nextIndex);
}
function goToNext() {
var nextIndex = 0;
if (
typeof currentIndex === 'number' &&
currentIndex < missingCoverageElements.length - 1
) {
nextIndex = currentIndex + 1;
}
makeCurrent(nextIndex);
}
return function jump(event) {
if (
document.getElementById('fileSearch') === document.activeElement &&
document.activeElement != null
) {
// if we're currently focused on the search input, we don't want to navigate
return;
}
switch (event.which) {
case 78: // n
case 74: // j
goToNext();
break;
case 66: // b
case 75: // k
case 80: // p
goToPrevious();
break;
}
};
})();
window.addEventListener('keydown', jumpToCode);

View file

@ -1,406 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<title>Code coverage report for components/ErrorBoundary.tsx</title>
<meta charset="utf-8" />
<link rel="stylesheet" href="../prettify.css" />
<link rel="stylesheet" href="../base.css" />
<link rel="shortcut icon" type="image/x-icon" href="../favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style type='text/css'>
.coverage-summary .sorter {
background-image: url(../sort-arrow-sprite.png);
}
</style>
</head>
<body>
<div class='wrapper'>
<div class='pad1'>
<h1><a href="../index.html">All files</a> / <a href="index.html">components</a> ErrorBoundary.tsx</h1>
<div class='clearfix'>
<div class='fl pad1y space-right2'>
<span class="strong">83.33% </span>
<span class="quiet">Statements</span>
<span class='fraction'>10/12</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="quiet">Branches</span>
<span class='fraction'>9/9</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">66.66% </span>
<span class="quiet">Functions</span>
<span class='fraction'>4/6</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">83.33% </span>
<span class="quiet">Lines</span>
<span class='fraction'>10/12</span>
</div>
</div>
<p class="quiet">
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
</p>
<template id="filterTemplate">
<div class="quiet">
Filter:
<input type="search" id="fileSearch">
</div>
</template>
</div>
<div class='status-line high'></div>
<pre><table class="coverage">
<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
<a name='L2'></a><a href='#L2'>2</a>
<a name='L3'></a><a href='#L3'>3</a>
<a name='L4'></a><a href='#L4'>4</a>
<a name='L5'></a><a href='#L5'>5</a>
<a name='L6'></a><a href='#L6'>6</a>
<a name='L7'></a><a href='#L7'>7</a>
<a name='L8'></a><a href='#L8'>8</a>
<a name='L9'></a><a href='#L9'>9</a>
<a name='L10'></a><a href='#L10'>10</a>
<a name='L11'></a><a href='#L11'>11</a>
<a name='L12'></a><a href='#L12'>12</a>
<a name='L13'></a><a href='#L13'>13</a>
<a name='L14'></a><a href='#L14'>14</a>
<a name='L15'></a><a href='#L15'>15</a>
<a name='L16'></a><a href='#L16'>16</a>
<a name='L17'></a><a href='#L17'>17</a>
<a name='L18'></a><a href='#L18'>18</a>
<a name='L19'></a><a href='#L19'>19</a>
<a name='L20'></a><a href='#L20'>20</a>
<a name='L21'></a><a href='#L21'>21</a>
<a name='L22'></a><a href='#L22'>22</a>
<a name='L23'></a><a href='#L23'>23</a>
<a name='L24'></a><a href='#L24'>24</a>
<a name='L25'></a><a href='#L25'>25</a>
<a name='L26'></a><a href='#L26'>26</a>
<a name='L27'></a><a href='#L27'>27</a>
<a name='L28'></a><a href='#L28'>28</a>
<a name='L29'></a><a href='#L29'>29</a>
<a name='L30'></a><a href='#L30'>30</a>
<a name='L31'></a><a href='#L31'>31</a>
<a name='L32'></a><a href='#L32'>32</a>
<a name='L33'></a><a href='#L33'>33</a>
<a name='L34'></a><a href='#L34'>34</a>
<a name='L35'></a><a href='#L35'>35</a>
<a name='L36'></a><a href='#L36'>36</a>
<a name='L37'></a><a href='#L37'>37</a>
<a name='L38'></a><a href='#L38'>38</a>
<a name='L39'></a><a href='#L39'>39</a>
<a name='L40'></a><a href='#L40'>40</a>
<a name='L41'></a><a href='#L41'>41</a>
<a name='L42'></a><a href='#L42'>42</a>
<a name='L43'></a><a href='#L43'>43</a>
<a name='L44'></a><a href='#L44'>44</a>
<a name='L45'></a><a href='#L45'>45</a>
<a name='L46'></a><a href='#L46'>46</a>
<a name='L47'></a><a href='#L47'>47</a>
<a name='L48'></a><a href='#L48'>48</a>
<a name='L49'></a><a href='#L49'>49</a>
<a name='L50'></a><a href='#L50'>50</a>
<a name='L51'></a><a href='#L51'>51</a>
<a name='L52'></a><a href='#L52'>52</a>
<a name='L53'></a><a href='#L53'>53</a>
<a name='L54'></a><a href='#L54'>54</a>
<a name='L55'></a><a href='#L55'>55</a>
<a name='L56'></a><a href='#L56'>56</a>
<a name='L57'></a><a href='#L57'>57</a>
<a name='L58'></a><a href='#L58'>58</a>
<a name='L59'></a><a href='#L59'>59</a>
<a name='L60'></a><a href='#L60'>60</a>
<a name='L61'></a><a href='#L61'>61</a>
<a name='L62'></a><a href='#L62'>62</a>
<a name='L63'></a><a href='#L63'>63</a>
<a name='L64'></a><a href='#L64'>64</a>
<a name='L65'></a><a href='#L65'>65</a>
<a name='L66'></a><a href='#L66'>66</a>
<a name='L67'></a><a href='#L67'>67</a>
<a name='L68'></a><a href='#L68'>68</a>
<a name='L69'></a><a href='#L69'>69</a>
<a name='L70'></a><a href='#L70'>70</a>
<a name='L71'></a><a href='#L71'>71</a>
<a name='L72'></a><a href='#L72'>72</a>
<a name='L73'></a><a href='#L73'>73</a>
<a name='L74'></a><a href='#L74'>74</a>
<a name='L75'></a><a href='#L75'>75</a>
<a name='L76'></a><a href='#L76'>76</a>
<a name='L77'></a><a href='#L77'>77</a>
<a name='L78'></a><a href='#L78'>78</a>
<a name='L79'></a><a href='#L79'>79</a>
<a name='L80'></a><a href='#L80'>80</a>
<a name='L81'></a><a href='#L81'>81</a>
<a name='L82'></a><a href='#L82'>82</a>
<a name='L83'></a><a href='#L83'>83</a>
<a name='L84'></a><a href='#L84'>84</a>
<a name='L85'></a><a href='#L85'>85</a>
<a name='L86'></a><a href='#L86'>86</a>
<a name='L87'></a><a href='#L87'>87</a>
<a name='L88'></a><a href='#L88'>88</a>
<a name='L89'></a><a href='#L89'>89</a>
<a name='L90'></a><a href='#L90'>90</a>
<a name='L91'></a><a href='#L91'>91</a>
<a name='L92'></a><a href='#L92'>92</a>
<a name='L93'></a><a href='#L93'>93</a>
<a name='L94'></a><a href='#L94'>94</a>
<a name='L95'></a><a href='#L95'>95</a>
<a name='L96'></a><a href='#L96'>96</a>
<a name='L97'></a><a href='#L97'>97</a>
<a name='L98'></a><a href='#L98'>98</a>
<a name='L99'></a><a href='#L99'>99</a>
<a name='L100'></a><a href='#L100'>100</a>
<a name='L101'></a><a href='#L101'>101</a>
<a name='L102'></a><a href='#L102'>102</a>
<a name='L103'></a><a href='#L103'>103</a>
<a name='L104'></a><a href='#L104'>104</a>
<a name='L105'></a><a href='#L105'>105</a>
<a name='L106'></a><a href='#L106'>106</a>
<a name='L107'></a><a href='#L107'>107</a>
<a name='L108'></a><a href='#L108'>108</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">12x</span>
<span class="cline-any cline-yes">12x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">10x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">5x</span>
<span class="cline-any cline-yes">5x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">26x</span>
<span class="cline-any cline-yes">15x</span>
<span class="cline-any cline-yes">3x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">12x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-no">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">11x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span></td><td class="text"><pre class="prettyprint lang-js">import React, { Component, ErrorInfo, ReactNode } from 'react';
import { AlertTriangle, Home, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
&nbsp;
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
&nbsp;
interface State {
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
}
&nbsp;
class ErrorBoundary extends Component&lt;Props, State&gt; {
constructor(props: Props) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null,
};
}
&nbsp;
static getDerivedStateFromError(error: Error): State {
return {
hasError: true,
error,
errorInfo: null,
};
}
&nbsp;
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo);
this.setState({
error,
errorInfo,
});
}
&nbsp;
handleReset = <span class="fstat-no" title="function not covered" >() =&gt; {</span>
<span class="cstat-no" title="statement not covered" > this.setState({</span>
hasError: false,
error: null,
errorInfo: null,
});
};
&nbsp;
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
&nbsp;
return (
&lt;div className="min-h-screen bg-background flex items-center justify-center p-4"&gt;
&lt;div className="max-w-md w-full space-y-6"&gt;
&lt;div className="text-center"&gt;
&lt;AlertTriangle className="w-16 h-16 text-destructive mx-auto mb-4" /&gt;
&lt;h1 className="text-2xl font-bold text-foreground mb-2"&gt;
Algo salió mal
&lt;/h1&gt;
&lt;p className="text-muted-foreground mb-6"&gt;
La aplicación encontró un error inesperado. Por favor, intenta recargar la página.
&lt;/p&gt;
&lt;/div&gt;
&nbsp;
{import.meta.env.DEV &amp;&amp; this.state.error &amp;&amp; (
&lt;div className="p-4 bg-muted border border-border rounded-lg"&gt;
&lt;p className="text-sm font-mono text-destructive mb-2"&gt;
{this.state.error.toString()}
&lt;/p&gt;
{this.state.errorInfo &amp;&amp; (
&lt;pre className="text-xs text-muted-foreground overflow-auto max-h-40"&gt;
{this.state.errorInfo.componentStack}
&lt;/pre&gt;
)}
&lt;/div&gt;
)}
&nbsp;
&lt;div className="flex flex-col gap-3"&gt;
&lt;Button onClick={this.handleReset} className="w-full"&gt;
&lt;RefreshCw className="w-4 h-4 mr-2" /&gt;
Intentar de nuevo
&lt;/Button&gt;
&lt;Button
variant="outline"
className="w-full"
onClick={<span class="fstat-no" title="function not covered" >() =&gt; {</span>
<span class="cstat-no" title="statement not covered" > window.location.href = '/';</span>
}}
&gt;
&lt;Home className="w-4 h-4 mr-2" /&gt;
Ir al inicio
&lt;/Button&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
);
}
&nbsp;
return this.props.children;
}
}
&nbsp;
export default ErrorBoundary;
&nbsp;</pre></td></tr></table></pre>
<div class='push'></div><!-- for sticky footer -->
</div><!-- /wrapper -->
<div class='footer quiet pad2 space-top1 center small'>
Code coverage generated by
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
at 2026-01-11T10:26:27.205Z
</div>
<script src="../prettify.js"></script>
<script>
window.onload = function () {
prettyPrint();
};
</script>
<script src="../sorter.js"></script>
<script src="../block-navigation.js"></script>
</body>
</html>

File diff suppressed because it is too large Load diff

View file

@ -1,116 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<title>Code coverage report for components/content</title>
<meta charset="utf-8" />
<link rel="stylesheet" href="../../prettify.css" />
<link rel="stylesheet" href="../../base.css" />
<link rel="shortcut icon" type="image/x-icon" href="../../favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style type='text/css'>
.coverage-summary .sorter {
background-image: url(../../sort-arrow-sprite.png);
}
</style>
</head>
<body>
<div class='wrapper'>
<div class='pad1'>
<h1><a href="../../index.html">All files</a> components/content</h1>
<div class='clearfix'>
<div class='fl pad1y space-right2'>
<span class="strong">40.65% </span>
<span class="quiet">Statements</span>
<span class='fraction'>37/91</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">22.78% </span>
<span class="quiet">Branches</span>
<span class='fraction'>18/79</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">42.42% </span>
<span class="quiet">Functions</span>
<span class='fraction'>14/33</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">41.11% </span>
<span class="quiet">Lines</span>
<span class='fraction'>37/90</span>
</div>
</div>
<p class="quiet">
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
</p>
<template id="filterTemplate">
<div class="quiet">
Filter:
<input type="search" id="fileSearch">
</div>
</template>
</div>
<div class='status-line low'></div>
<div class="pad1">
<table class="coverage-summary">
<thead>
<tr>
<th data-col="file" data-fmt="html" data-html="true" class="file">File</th>
<th data-col="pic" data-type="number" data-fmt="html" data-html="true" class="pic"></th>
<th data-col="statements" data-type="number" data-fmt="pct" class="pct">Statements</th>
<th data-col="statements_raw" data-type="number" data-fmt="html" class="abs"></th>
<th data-col="branches" data-type="number" data-fmt="pct" class="pct">Branches</th>
<th data-col="branches_raw" data-type="number" data-fmt="html" class="abs"></th>
<th data-col="functions" data-type="number" data-fmt="pct" class="pct">Functions</th>
<th data-col="functions_raw" data-type="number" data-fmt="html" class="abs"></th>
<th data-col="lines" data-type="number" data-fmt="pct" class="pct">Lines</th>
<th data-col="lines_raw" data-type="number" data-fmt="html" class="abs"></th>
</tr>
</thead>
<tbody><tr>
<td class="file low" data-value="MarkdownViewer.tsx"><a href="MarkdownViewer.tsx.html">MarkdownViewer.tsx</a></td>
<td data-value="40.65" class="pic low">
<div class="chart"><div class="cover-fill" style="width: 40%"></div><div class="cover-empty" style="width: 60%"></div></div>
</td>
<td data-value="40.65" class="pct low">40.65%</td>
<td data-value="91" class="abs low">37/91</td>
<td data-value="22.78" class="pct low">22.78%</td>
<td data-value="79" class="abs low">18/79</td>
<td data-value="42.42" class="pct low">42.42%</td>
<td data-value="33" class="abs low">14/33</td>
<td data-value="41.11" class="pct low">41.11%</td>
<td data-value="90" class="abs low">37/90</td>
</tr>
</tbody>
</table>
</div>
<div class='push'></div><!-- for sticky footer -->
</div><!-- /wrapper -->
<div class='footer quiet pad2 space-top1 center small'>
Code coverage generated by
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
at 2026-01-11T10:26:27.205Z
</div>
<script src="../../prettify.js"></script>
<script>
window.onload = function () {
prettyPrint();
};
</script>
<script src="../../sorter.js"></script>
<script src="../../block-navigation.js"></script>
</body>
</html>

View file

@ -1,116 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<title>Code coverage report for components</title>
<meta charset="utf-8" />
<link rel="stylesheet" href="../prettify.css" />
<link rel="stylesheet" href="../base.css" />
<link rel="shortcut icon" type="image/x-icon" href="../favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style type='text/css'>
.coverage-summary .sorter {
background-image: url(../sort-arrow-sprite.png);
}
</style>
</head>
<body>
<div class='wrapper'>
<div class='pad1'>
<h1><a href="../index.html">All files</a> components</h1>
<div class='clearfix'>
<div class='fl pad1y space-right2'>
<span class="strong">83.33% </span>
<span class="quiet">Statements</span>
<span class='fraction'>10/12</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="quiet">Branches</span>
<span class='fraction'>9/9</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">66.66% </span>
<span class="quiet">Functions</span>
<span class='fraction'>4/6</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">83.33% </span>
<span class="quiet">Lines</span>
<span class='fraction'>10/12</span>
</div>
</div>
<p class="quiet">
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
</p>
<template id="filterTemplate">
<div class="quiet">
Filter:
<input type="search" id="fileSearch">
</div>
</template>
</div>
<div class='status-line high'></div>
<div class="pad1">
<table class="coverage-summary">
<thead>
<tr>
<th data-col="file" data-fmt="html" data-html="true" class="file">File</th>
<th data-col="pic" data-type="number" data-fmt="html" data-html="true" class="pic"></th>
<th data-col="statements" data-type="number" data-fmt="pct" class="pct">Statements</th>
<th data-col="statements_raw" data-type="number" data-fmt="html" class="abs"></th>
<th data-col="branches" data-type="number" data-fmt="pct" class="pct">Branches</th>
<th data-col="branches_raw" data-type="number" data-fmt="html" class="abs"></th>
<th data-col="functions" data-type="number" data-fmt="pct" class="pct">Functions</th>
<th data-col="functions_raw" data-type="number" data-fmt="html" class="abs"></th>
<th data-col="lines" data-type="number" data-fmt="pct" class="pct">Lines</th>
<th data-col="lines_raw" data-type="number" data-fmt="html" class="abs"></th>
</tr>
</thead>
<tbody><tr>
<td class="file high" data-value="ErrorBoundary.tsx"><a href="ErrorBoundary.tsx.html">ErrorBoundary.tsx</a></td>
<td data-value="83.33" class="pic high">
<div class="chart"><div class="cover-fill" style="width: 83%"></div><div class="cover-empty" style="width: 17%"></div></div>
</td>
<td data-value="83.33" class="pct high">83.33%</td>
<td data-value="12" class="abs high">10/12</td>
<td data-value="100" class="pct high">100%</td>
<td data-value="9" class="abs high">9/9</td>
<td data-value="66.66" class="pct medium">66.66%</td>
<td data-value="6" class="abs medium">4/6</td>
<td data-value="83.33" class="pct high">83.33%</td>
<td data-value="12" class="abs high">10/12</td>
</tr>
</tbody>
</table>
</div>
<div class='push'></div><!-- for sticky footer -->
</div><!-- /wrapper -->
<div class='footer quiet pad2 space-top1 center small'>
Code coverage generated by
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
at 2026-01-11T10:26:27.205Z
</div>
<script src="../prettify.js"></script>
<script>
window.onload = function () {
prettyPrint();
};
</script>
<script src="../sorter.js"></script>
<script src="../block-navigation.js"></script>
</body>
</html>

View file

@ -1,226 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<title>Code coverage report for components/ui/button.tsx</title>
<meta charset="utf-8" />
<link rel="stylesheet" href="../../prettify.css" />
<link rel="stylesheet" href="../../base.css" />
<link rel="shortcut icon" type="image/x-icon" href="../../favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style type='text/css'>
.coverage-summary .sorter {
background-image: url(../../sort-arrow-sprite.png);
}
</style>
</head>
<body>
<div class='wrapper'>
<div class='pad1'>
<h1><a href="../../index.html">All files</a> / <a href="index.html">components/ui</a> button.tsx</h1>
<div class='clearfix'>
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="quiet">Statements</span>
<span class='fraction'>5/5</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">66.66% </span>
<span class="quiet">Branches</span>
<span class='fraction'>2/3</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="quiet">Functions</span>
<span class='fraction'>1/1</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="quiet">Lines</span>
<span class='fraction'>5/5</span>
</div>
</div>
<p class="quiet">
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
</p>
<template id="filterTemplate">
<div class="quiet">
Filter:
<input type="search" id="fileSearch">
</div>
</template>
</div>
<div class='status-line high'></div>
<pre><table class="coverage">
<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
<a name='L2'></a><a href='#L2'>2</a>
<a name='L3'></a><a href='#L3'>3</a>
<a name='L4'></a><a href='#L4'>4</a>
<a name='L5'></a><a href='#L5'>5</a>
<a name='L6'></a><a href='#L6'>6</a>
<a name='L7'></a><a href='#L7'>7</a>
<a name='L8'></a><a href='#L8'>8</a>
<a name='L9'></a><a href='#L9'>9</a>
<a name='L10'></a><a href='#L10'>10</a>
<a name='L11'></a><a href='#L11'>11</a>
<a name='L12'></a><a href='#L12'>12</a>
<a name='L13'></a><a href='#L13'>13</a>
<a name='L14'></a><a href='#L14'>14</a>
<a name='L15'></a><a href='#L15'>15</a>
<a name='L16'></a><a href='#L16'>16</a>
<a name='L17'></a><a href='#L17'>17</a>
<a name='L18'></a><a href='#L18'>18</a>
<a name='L19'></a><a href='#L19'>19</a>
<a name='L20'></a><a href='#L20'>20</a>
<a name='L21'></a><a href='#L21'>21</a>
<a name='L22'></a><a href='#L22'>22</a>
<a name='L23'></a><a href='#L23'>23</a>
<a name='L24'></a><a href='#L24'>24</a>
<a name='L25'></a><a href='#L25'>25</a>
<a name='L26'></a><a href='#L26'>26</a>
<a name='L27'></a><a href='#L27'>27</a>
<a name='L28'></a><a href='#L28'>28</a>
<a name='L29'></a><a href='#L29'>29</a>
<a name='L30'></a><a href='#L30'>30</a>
<a name='L31'></a><a href='#L31'>31</a>
<a name='L32'></a><a href='#L32'>32</a>
<a name='L33'></a><a href='#L33'>33</a>
<a name='L34'></a><a href='#L34'>34</a>
<a name='L35'></a><a href='#L35'>35</a>
<a name='L36'></a><a href='#L36'>36</a>
<a name='L37'></a><a href='#L37'>37</a>
<a name='L38'></a><a href='#L38'>38</a>
<a name='L39'></a><a href='#L39'>39</a>
<a name='L40'></a><a href='#L40'>40</a>
<a name='L41'></a><a href='#L41'>41</a>
<a name='L42'></a><a href='#L42'>42</a>
<a name='L43'></a><a href='#L43'>43</a>
<a name='L44'></a><a href='#L44'>44</a>
<a name='L45'></a><a href='#L45'>45</a>
<a name='L46'></a><a href='#L46'>46</a>
<a name='L47'></a><a href='#L47'>47</a>
<a name='L48'></a><a href='#L48'>48</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">24x</span>
<span class="cline-any cline-yes">24x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span></td><td class="text"><pre class="prettyprint lang-js">import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
&nbsp;
import { cn } from "@/lib/utils";
&nbsp;
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&amp;_svg]:pointer-events-none [&amp;_svg]:size-4 [&amp;_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
&nbsp;
export interface ButtonProps
extends React.ButtonHTMLAttributes&lt;HTMLButtonElement&gt;,
VariantProps&lt;typeof buttonVariants&gt; {
asChild?: boolean;
}
&nbsp;
const Button = React.forwardRef&lt;HTMLButtonElement, ButtonProps&gt;(
({ className, variant, size, asChild = false, ...props }, ref) =&gt; {
const Comp = asChild ? <span class="branch-0 cbranch-no" title="branch not covered" >Slot : "</span>button";
return &lt;Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} /&gt;;
},
);
Button.displayName = "Button";
&nbsp;
export { Button, buttonVariants };
&nbsp;</pre></td></tr></table></pre>
<div class='push'></div><!-- for sticky footer -->
</div><!-- /wrapper -->
<div class='footer quiet pad2 space-top1 center small'>
Code coverage generated by
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
at 2026-01-11T10:26:27.205Z
</div>
<script src="../../prettify.js"></script>
<script>
window.onload = function () {
prettyPrint();
};
</script>
<script src="../../sorter.js"></script>
<script src="../../block-navigation.js"></script>
</body>
</html>

View file

@ -1,116 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<title>Code coverage report for components/ui</title>
<meta charset="utf-8" />
<link rel="stylesheet" href="../../prettify.css" />
<link rel="stylesheet" href="../../base.css" />
<link rel="shortcut icon" type="image/x-icon" href="../../favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style type='text/css'>
.coverage-summary .sorter {
background-image: url(../../sort-arrow-sprite.png);
}
</style>
</head>
<body>
<div class='wrapper'>
<div class='pad1'>
<h1><a href="../../index.html">All files</a> components/ui</h1>
<div class='clearfix'>
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="quiet">Statements</span>
<span class='fraction'>5/5</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">66.66% </span>
<span class="quiet">Branches</span>
<span class='fraction'>2/3</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="quiet">Functions</span>
<span class='fraction'>1/1</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="quiet">Lines</span>
<span class='fraction'>5/5</span>
</div>
</div>
<p class="quiet">
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
</p>
<template id="filterTemplate">
<div class="quiet">
Filter:
<input type="search" id="fileSearch">
</div>
</template>
</div>
<div class='status-line high'></div>
<div class="pad1">
<table class="coverage-summary">
<thead>
<tr>
<th data-col="file" data-fmt="html" data-html="true" class="file">File</th>
<th data-col="pic" data-type="number" data-fmt="html" data-html="true" class="pic"></th>
<th data-col="statements" data-type="number" data-fmt="pct" class="pct">Statements</th>
<th data-col="statements_raw" data-type="number" data-fmt="html" class="abs"></th>
<th data-col="branches" data-type="number" data-fmt="pct" class="pct">Branches</th>
<th data-col="branches_raw" data-type="number" data-fmt="html" class="abs"></th>
<th data-col="functions" data-type="number" data-fmt="pct" class="pct">Functions</th>
<th data-col="functions_raw" data-type="number" data-fmt="html" class="abs"></th>
<th data-col="lines" data-type="number" data-fmt="pct" class="pct">Lines</th>
<th data-col="lines_raw" data-type="number" data-fmt="html" class="abs"></th>
</tr>
</thead>
<tbody><tr>
<td class="file high" data-value="button.tsx"><a href="button.tsx.html">button.tsx</a></td>
<td data-value="100" class="pic high">
<div class="chart"><div class="cover-fill cover-full" style="width: 100%"></div><div class="cover-empty" style="width: 0%"></div></div>
</td>
<td data-value="100" class="pct high">100%</td>
<td data-value="5" class="abs high">5/5</td>
<td data-value="66.66" class="pct medium">66.66%</td>
<td data-value="3" class="abs medium">2/3</td>
<td data-value="100" class="pct high">100%</td>
<td data-value="1" class="abs high">1/1</td>
<td data-value="100" class="pct high">100%</td>
<td data-value="5" class="abs high">5/5</td>
</tr>
</tbody>
</table>
</div>
<div class='push'></div><!-- for sticky footer -->
</div><!-- /wrapper -->
<div class='footer quiet pad2 space-top1 center small'>
Code coverage generated by
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
at 2026-01-11T10:26:27.205Z
</div>
<script src="../../prettify.js"></script>
<script>
window.onload = function () {
prettyPrint();
};
</script>
<script src="../../sorter.js"></script>
<script src="../../block-navigation.js"></script>
</body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -1,116 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<title>Code coverage report for data</title>
<meta charset="utf-8" />
<link rel="stylesheet" href="../prettify.css" />
<link rel="stylesheet" href="../base.css" />
<link rel="shortcut icon" type="image/x-icon" href="../favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style type='text/css'>
.coverage-summary .sorter {
background-image: url(../sort-arrow-sprite.png);
}
</style>
</head>
<body>
<div class='wrapper'>
<div class='pad1'>
<h1><a href="../index.html">All files</a> data</h1>
<div class='clearfix'>
<div class='fl pad1y space-right2'>
<span class="strong">41.66% </span>
<span class="quiet">Statements</span>
<span class='fraction'>5/12</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">0% </span>
<span class="quiet">Branches</span>
<span class='fraction'>0/2</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">0% </span>
<span class="quiet">Functions</span>
<span class='fraction'>0/7</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">50% </span>
<span class="quiet">Lines</span>
<span class='fraction'>5/10</span>
</div>
</div>
<p class="quiet">
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
</p>
<template id="filterTemplate">
<div class="quiet">
Filter:
<input type="search" id="fileSearch">
</div>
</template>
</div>
<div class='status-line low'></div>
<div class="pad1">
<table class="coverage-summary">
<thead>
<tr>
<th data-col="file" data-fmt="html" data-html="true" class="file">File</th>
<th data-col="pic" data-type="number" data-fmt="html" data-html="true" class="pic"></th>
<th data-col="statements" data-type="number" data-fmt="pct" class="pct">Statements</th>
<th data-col="statements_raw" data-type="number" data-fmt="html" class="abs"></th>
<th data-col="branches" data-type="number" data-fmt="pct" class="pct">Branches</th>
<th data-col="branches_raw" data-type="number" data-fmt="html" class="abs"></th>
<th data-col="functions" data-type="number" data-fmt="pct" class="pct">Functions</th>
<th data-col="functions_raw" data-type="number" data-fmt="html" class="abs"></th>
<th data-col="lines" data-type="number" data-fmt="pct" class="pct">Lines</th>
<th data-col="lines_raw" data-type="number" data-fmt="html" class="abs"></th>
</tr>
</thead>
<tbody><tr>
<td class="file low" data-value="image-registry.ts"><a href="image-registry.ts.html">image-registry.ts</a></td>
<td data-value="41.66" class="pic low">
<div class="chart"><div class="cover-fill" style="width: 41%"></div><div class="cover-empty" style="width: 59%"></div></div>
</td>
<td data-value="41.66" class="pct low">41.66%</td>
<td data-value="12" class="abs low">5/12</td>
<td data-value="0" class="pct low">0%</td>
<td data-value="2" class="abs low">0/2</td>
<td data-value="0" class="pct low">0%</td>
<td data-value="7" class="abs low">0/7</td>
<td data-value="50" class="pct medium">50%</td>
<td data-value="10" class="abs medium">5/10</td>
</tr>
</tbody>
</table>
</div>
<div class='push'></div><!-- for sticky footer -->
</div><!-- /wrapper -->
<div class='footer quiet pad2 space-top1 center small'>
Code coverage generated by
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
at 2026-01-11T10:26:27.205Z
</div>
<script src="../prettify.js"></script>
<script>
window.onload = function () {
prettyPrint();
};
</script>
<script src="../sorter.js"></script>
<script src="../block-navigation.js"></script>
</body>
</html>

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