- ✅ Ticket 1.1: Estructura Clean Architecture en backend - ✅ Ticket 1.2: Schemas Zod compartidos - ✅ Ticket 1.3: Refactorización drugs.ts (1362 → 8 archivos modulares) - ✅ Ticket 1.4: Refactorización procedures.ts (3583 → 6 archivos modulares) - ✅ Ticket 1.5: Eliminación de duplicidades (~50 líneas) Cambios principales: - Creada estructura Clean Architecture en backend/src/ - Schemas Zod compartidos en backend/src/shared/schemas/ - Refactorización modular de drugs y procedures - Utilidades genéricas en src/utils/ (filter, validation) - Eliminados scripts obsoletos y documentación antigua - Corregidos errores: QueryClient, import test-error-handling - Build verificado y funcionando correctamente
16 KiB
16 KiB
📋 Sistema de Validación de Protocolos
🎯 Objetivo
Crear un sistema para validar la secuencia de pasos de protocolos y las dependencias entre protocolos.
📋 Reglas de Negocio
1. Validación de Secuencia de Pasos
Reglas de Secuencia
- ✅ Los pasos deben estar numerados secuencialmente (1, 2, 3...)
- ✅ No puede haber pasos faltantes en la secuencia
- ✅ Los pasos deben tener un orden lógico (no se puede hacer paso 3 antes del paso 1)
- ✅ Pasos críticos deben estar marcados y no pueden omitirse
- ✅ Pasos opcionales deben estar claramente marcados
Tipos de Pasos
- Obligatorio: Debe ejecutarse siempre
- Condicional: Se ejecuta si se cumple condición
- Opcional: Puede omitirse según contexto
- Crítico: Bloquea el protocolo si se omite
2. Dependencias entre Protocolos
Tipos de Dependencias
- Prerequisito: Protocolo A debe completarse antes de Protocolo B
- Siguiente: Protocolo A normalmente lleva a Protocolo B
- Alternativo: Protocolo A o Protocolo B (mutuamente excluyentes)
- Complementario: Protocolo A se usa junto con Protocolo B
🏗️ Arquitectura
Domain Layer
// domain/entities/Protocol.ts
export interface Protocol {
readonly id: string;
readonly title: string;
readonly shortTitle: string;
readonly category: Category;
readonly priority: Priority;
readonly ageGroup: AgeGroup;
readonly steps: readonly ProtocolStep[];
readonly prerequisites: readonly string[]; // IDs de protocolos prerequisito
readonly nextProtocols: readonly string[]; // IDs de protocolos siguientes
readonly alternatives: readonly string[]; // IDs de protocolos alternativos
readonly complements: readonly string[]; // IDs de protocolos complementarios
readonly criticalSteps: readonly number[]; // Índices de pasos críticos
readonly status: ContentStatus;
}
export interface ProtocolStep {
readonly order: number; // Orden en la secuencia (1, 2, 3...)
readonly type: 'obligatory' | 'conditional' | 'optional' | 'critical';
readonly content: string;
readonly condition?: string; // Condición para pasos condicionales
readonly equipment?: readonly string[];
readonly drugs?: readonly string[];
readonly warnings?: readonly string[];
readonly estimatedTime?: number; // Segundos estimados
}
// domain/value-objects/ProtocolSequence.ts
export class ProtocolSequence {
private constructor(private readonly steps: readonly ProtocolStep[]) {}
static create(steps: ProtocolStep[]): ProtocolSequence {
// Validar secuencia
const sortedSteps = [...steps].sort((a, b) => a.order - b.order);
// Verificar que no hay huecos
for (let i = 0; i < sortedSteps.length; i++) {
if (sortedSteps[i].order !== i + 1) {
throw new Error(`Paso faltante en secuencia: esperado ${i + 1}, encontrado ${sortedSteps[i].order}`);
}
}
// Verificar que no hay duplicados
const orders = sortedSteps.map(s => s.order);
const uniqueOrders = new Set(orders);
if (orders.length !== uniqueOrders.size) {
throw new Error('Hay pasos con el mismo número de orden');
}
return new ProtocolSequence(sortedSteps);
}
validateExecution(executedSteps: number[]): ProtocolValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
// Verificar que todos los pasos críticos están ejecutados
const criticalSteps = this.steps
.filter(s => s.type === 'critical')
.map(s => s.order);
const missingCritical = criticalSteps.filter(step => !executedSteps.includes(step));
if (missingCritical.length > 0) {
errors.push(
`Pasos críticos no ejecutados: ${missingCritical.join(', ')}`
);
}
// Verificar que todos los pasos obligatorios están ejecutados
const obligatorySteps = this.steps
.filter(s => s.type === 'obligatory')
.map(s => s.order);
const missingObligatory = obligatorySteps.filter(step => !executedSteps.includes(step));
if (missingObligatory.length > 0) {
errors.push(
`Pasos obligatorios no ejecutados: ${missingObligatory.join(', ')}`
);
}
// Verificar orden de ejecución
const sortedExecuted = [...executedSteps].sort((a, b) => a - b);
if (JSON.stringify(executedSteps) !== JSON.stringify(sortedExecuted)) {
warnings.push('Los pasos no se ejecutaron en orden secuencial');
}
// Verificar pasos condicionales
for (const step of this.steps) {
if (step.type === 'conditional' && executedSteps.includes(step.order)) {
if (!step.condition) {
warnings.push(`Paso ${step.order} es condicional pero no tiene condición definida`);
}
}
}
return {
valid: errors.length === 0,
errors,
warnings,
executedSteps,
missingSteps: this.steps
.map(s => s.order)
.filter(order => !executedSteps.includes(order))
};
}
getNextStep(currentStep?: number): ProtocolStep | null {
if (currentStep === undefined) {
return this.steps[0] || null;
}
const currentIndex = this.steps.findIndex(s => s.order === currentStep);
if (currentIndex === -1) return null;
return this.steps[currentIndex + 1] || null;
}
getStep(order: number): ProtocolStep | null {
return this.steps.find(s => s.order === order) || null;
}
}
export interface ProtocolValidationResult {
valid: boolean;
errors: string[];
warnings: string[];
executedSteps: number[];
missingSteps: number[];
}
// domain/value-objects/ProtocolDependency.ts
export class ProtocolDependency {
private constructor(
readonly protocolId: string,
readonly dependencyType: 'prerequisite' | 'next' | 'alternative' | 'complement',
readonly targetProtocolId: string,
readonly required: boolean
) {}
static createPrerequisite(
protocolId: string,
prerequisiteId: string,
required: boolean = true
): ProtocolDependency {
return new ProtocolDependency(
protocolId,
'prerequisite',
prerequisiteId,
required
);
}
static createNext(
protocolId: string,
nextProtocolId: string
): ProtocolDependency {
return new ProtocolDependency(
protocolId,
'next',
nextProtocolId,
false
);
}
static createAlternative(
protocolId: string,
alternativeId: string
): ProtocolDependency {
return new ProtocolDependency(
protocolId,
'alternative',
alternativeId,
false
);
}
static createComplement(
protocolId: string,
complementId: string
): ProtocolDependency {
return new ProtocolDependency(
protocolId,
'complement',
complementId,
false
);
}
validate(
protocolExecuted: boolean,
targetProtocolExecuted: boolean
): { valid: boolean; error?: string } {
switch (this.dependencyType) {
case 'prerequisite':
if (this.required && protocolExecuted && !targetProtocolExecuted) {
return {
valid: false,
error: `Protocolo ${this.targetProtocolId} debe ejecutarse antes de ${this.protocolId}`
};
}
break;
case 'alternative':
if (protocolExecuted && targetProtocolExecuted) {
return {
valid: false,
error: `Protocolos ${this.protocolId} y ${this.targetProtocolId} son mutuamente excluyentes`
};
}
break;
}
return { valid: true };
}
}
Application Layer
// application/services/ProtocolValidationService.ts
export class ProtocolValidationService {
constructor(
private readonly protocolRepository: IProtocolRepository,
private readonly dependencyRepository: IDependencyRepository
) {}
async validateProtocolSequence(
protocolId: string,
executedSteps: number[]
): Promise<ProtocolValidationResult> {
const protocol = await this.protocolRepository.findById(protocolId);
if (!protocol) {
throw new Error(`Protocolo ${protocolId} no encontrado`);
}
const sequence = ProtocolSequence.create(protocol.steps);
return sequence.validateExecution(executedSteps);
}
async validateProtocolDependencies(
protocolId: string,
executedProtocols: string[]
): Promise<DependencyValidationResult> {
const protocol = await this.protocolRepository.findById(protocolId);
if (!protocol) {
throw new Error(`Protocolo ${protocolId} no encontrado`);
}
const dependencies = await this.dependencyRepository.findByProtocol(protocolId);
const errors: string[] = [];
const warnings: string[] = [];
// Validar prerequisitos
for (const prereqId of protocol.prerequisites) {
if (!executedProtocols.includes(prereqId)) {
const prereq = await this.protocolRepository.findById(prereqId);
errors.push(
`Protocolo prerequisito "${prereq?.title || prereqId}" no ha sido ejecutado`
);
}
}
// Validar alternativos
for (const altId of protocol.alternatives) {
if (executedProtocols.includes(altId)) {
const alt = await this.protocolRepository.findById(altId);
warnings.push(
`Protocolo alternativo "${alt?.title || altId}" ya fue ejecutado. ` +
`Estos protocolos son mutuamente excluyentes.`
);
}
}
// Sugerir protocolos siguientes
const suggestedNext: string[] = [];
for (const nextId of protocol.nextProtocols) {
if (!executedProtocols.includes(nextId)) {
suggestedNext.push(nextId);
}
}
return {
valid: errors.length === 0,
errors,
warnings,
suggestedNext
};
}
async getProtocolExecutionPath(
startProtocolId: string,
context: ProtocolContext
): Promise<ProtocolExecutionPath> {
const path: ProtocolExecutionPath = {
protocols: [],
totalEstimatedTime: 0,
warnings: []
};
const visited = new Set<string>();
const queue: string[] = [startProtocolId];
while (queue.length > 0) {
const protocolId = queue.shift()!;
if (visited.has(protocolId)) continue;
visited.add(protocolId);
const protocol = await this.protocolRepository.findById(protocolId);
if (!protocol) continue;
// Validar prerequisitos
const depValidation = await this.validateProtocolDependencies(
protocolId,
path.protocols.map(p => p.id)
);
if (!depValidation.valid) {
path.warnings.push(...depValidation.errors);
continue; // Saltar si faltan prerequisitos
}
// Calcular tiempo estimado
const estimatedTime = protocol.steps.reduce(
(sum, step) => sum + (step.estimatedTime || 30),
0
);
path.protocols.push({
id: protocol.id,
title: protocol.title,
estimatedTime
});
path.totalEstimatedTime += estimatedTime;
// Agregar protocolos siguientes a la cola
for (const nextId of protocol.nextProtocols) {
if (!visited.has(nextId) && !queue.includes(nextId)) {
queue.push(nextId);
}
}
}
return path;
}
}
export interface DependencyValidationResult {
valid: boolean;
errors: string[];
warnings: string[];
suggestedNext: string[];
}
export interface ProtocolExecutionPath {
protocols: Array<{
id: string;
title: string;
estimatedTime: number;
}>;
totalEstimatedTime: number;
warnings: string[];
}
export interface ProtocolContext {
ageGroup: AgeGroup;
priority: Priority;
availableEquipment?: string[];
availableDrugs?: string[];
}
🗄️ Esquema de Base de Datos
-- Tabla de pasos de protocolo
CREATE TABLE tes_content.protocol_steps (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
protocol_id VARCHAR(100) NOT NULL REFERENCES tes_content.content_items(id),
step_order INTEGER NOT NULL,
step_type VARCHAR(20) NOT NULL DEFAULT 'obligatory',
content TEXT NOT NULL,
condition TEXT,
equipment TEXT[],
drugs TEXT[],
warnings TEXT[],
estimated_time_seconds INTEGER,
CONSTRAINT chk_step_type CHECK (step_type IN ('obligatory', 'conditional', 'optional', 'critical')),
CONSTRAINT unique_protocol_step_order UNIQUE (protocol_id, step_order)
);
CREATE INDEX idx_protocol_steps_protocol_id ON tes_content.protocol_steps(protocol_id);
CREATE INDEX idx_protocol_steps_order ON tes_content.protocol_steps(protocol_id, step_order);
-- Tabla de dependencias entre protocolos
CREATE TABLE tes_content.protocol_dependencies (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
protocol_id VARCHAR(100) NOT NULL REFERENCES tes_content.content_items(id),
dependency_type VARCHAR(20) NOT NULL,
target_protocol_id VARCHAR(100) NOT NULL REFERENCES tes_content.content_items(id),
required BOOLEAN NOT NULL DEFAULT false,
notes TEXT,
CONSTRAINT chk_dependency_type CHECK (
dependency_type IN ('prerequisite', 'next', 'alternative', 'complement')
),
CONSTRAINT chk_no_self_dependency CHECK (protocol_id != target_protocol_id)
);
CREATE INDEX idx_protocol_dependencies_protocol ON tes_content.protocol_dependencies(protocol_id);
CREATE INDEX idx_protocol_dependencies_target ON tes_content.protocol_dependencies(target_protocol_id);
CREATE INDEX idx_protocol_dependencies_type ON tes_content.protocol_dependencies(dependency_type);
✅ Casos de Uso
Caso 1: Validar ejecución de protocolo
const validation = await protocolValidationService.validateProtocolSequence(
'rcp-adulto-svb',
[1, 2, 3, 4, 5] // Pasos ejecutados
);
if (!validation.valid) {
console.error('Errores:', validation.errors);
// Bloquear continuación del protocolo
}
Caso 2: Validar dependencias antes de iniciar protocolo
const depValidation = await protocolValidationService.validateProtocolDependencies(
'rcp-adulto-sva',
['rcp-adulto-svb'] // Protocolos ya ejecutados
);
if (!depValidation.valid) {
throw new Error(`No se puede ejecutar: ${depValidation.errors.join(', ')}`);
}
Caso 3: Obtener ruta de ejecución sugerida
const path = await protocolValidationService.getProtocolExecutionPath(
'rcp-adulto-svb',
{
ageGroup: 'adulto',
priority: 'critico'
}
);
console.log(`Ruta sugerida: ${path.protocols.map(p => p.title).join(' → ')}`);
console.log(`Tiempo estimado: ${path.totalEstimatedTime} segundos`);
🧪 Tests
describe('ProtocolValidationService', () => {
describe('validateProtocolSequence', () => {
it('should reject if critical steps are missing', async () => {
const result = await service.validateProtocolSequence(
'rcp-adulto-svb',
[1, 2, 3] // Faltan pasos críticos 4 y 5
);
expect(result.valid).toBe(false);
expect(result.errors).toContain(expect.stringContaining('críticos'));
});
it('should warn if steps are out of order', async () => {
const result = await service.validateProtocolSequence(
'rcp-adulto-svb',
[1, 3, 2, 4, 5] // Orden incorrecto
);
expect(result.warnings).toContain(expect.stringContaining('orden'));
});
});
describe('validateProtocolDependencies', () => {
it('should reject if prerequisites are missing', async () => {
const result = await service.validateProtocolDependencies(
'rcp-adulto-sva',
[] // No hay protocolos ejecutados
);
expect(result.valid).toBe(false);
expect(result.errors).toContain(expect.stringContaining('prerequisito'));
});
});
});
Fin del documento