codigo0/docs/GENERADOR_CONTENT_PACK.md

10 KiB

📦 GENERADOR DE CONTENT PACK - ESPECIFICACIÓN TÉCNICA

Versión: 1.0.0
Fecha: 2025-01-06
Ubicación: server/src/services/pack-generator.js


🎯 PROPÓSITO

El generador de Content Pack lee todo el contenido publicado desde PostgreSQL y genera un archivo JSON optimizado para consumo offline en la app PWA.


📋 FUNCIONALIDADES

  1. Lectura desde PostgreSQL

    • Lee solo contenido status = 'published'
    • Lee solo última versión (version = latest_version)
    • Incluye recursos asociados
    • Pre-calcula enlaces bidireccionales
  2. Generación de JSON

    • Estructura optimizada
    • Ordenamiento por prioridad
    • Validación de integridad
  3. Cálculo de Hash

    • SHA-256 del contenido (sin metadata)
    • Verificación de integridad
  4. Guardado

    • Guarda en /storage/packs/pack-v{version}.json
    • Crea symlink pack-latest.json
    • Mantiene historial de versiones

🔧 IMPLEMENTACIÓN

Estructura del Servicio

// server/src/services/pack-generator.js

import { query } from '../config/database.js';
import { createHash } from 'crypto';
import { writeFile, symlink } from 'fs/promises';
import { join } from 'path';

class ContentPackGenerator {
  constructor() {
    this.packsDir = join(process.cwd(), 'storage', 'packs');
  }

  /**
   * Genera un nuevo Content Pack
   */
  async generatePack(version, options = {}) {
    const {
      includeDraft = false,
      notes = ''
    } = options;

    // 1. Leer contenido publicado
    const content = await this.loadPublishedContent(includeDraft);
    
    // 2. Leer recursos asociados
    const media = await this.loadMediaResources(content);
    
    // 3. Pre-calcular enlaces
    const links = this.calculateLinks(content);
    
    // 4. Construir pack
    const pack = {
      metadata: {
        version,
        generated_at: new Date().toISOString(),
        source: 'server',
        total_items: this.countItems(content),
        total_resources: media.resources.length,
        notes
      },
      content,
      media,
      links
    };
    
    // 5. Calcular hash
    const hash = this.calculateHash(pack);
    pack.metadata.hash = hash;
    
    // 6. Guardar archivo
    await this.savePack(version, pack);
    
    // 7. Actualizar symlink latest
    await this.updateLatestSymlink(version);
    
    return {
      version,
      hash,
      file_path: join(this.packsDir, `pack-v${version}.json`),
      size_bytes: JSON.stringify(pack).length,
      total_items: pack.metadata.total_items,
      total_resources: pack.metadata.total_resources
    };
  }

  /**
   * Lee contenido publicado desde PostgreSQL
   */
  async loadPublishedContent(includeDraft = false) {
    const statusFilter = includeDraft 
      ? "('draft', 'published')" 
      : "('published')";
    
    const result = await query(`
      SELECT 
        id, type, slug, title, short_title, description,
        clinical_context, level, priority, status,
        source_guideline, source_year, source_url,
        version, latest_version,
        content,
        related_content_ids, related_protocol_ids, 
        related_guide_ids, related_manual_ids,
        tags, category,
        created_at, updated_at
      FROM tes_content.content_items
      WHERE status = ANY(ARRAY[${statusFilter}])
        AND version = latest_version
      ORDER BY 
        CASE priority 
          WHEN 'critica' THEN 1
          WHEN 'alta' THEN 2
          WHEN 'media' THEN 3
          WHEN 'baja' THEN 4
        END,
        title ASC
    `);
    
    // Agrupar por tipo
    const grouped = {
      protocols: [],
      guides: [],
      manuals: [],
      drugs: [],
      checklists: []
    };
    
    result.rows.forEach(item => {
      grouped[`${item.type}s`].push(item);
    });
    
    return grouped;
  }

  /**
   * Lee recursos multimedia asociados
   */
  async loadMediaResources(content) {
    // Obtener todos los IDs de contenido
    const contentIds = [];
    Object.values(content).forEach(items => {
      items.forEach(item => contentIds.push(item.id));
    });
    
    if (contentIds.length === 0) {
      return { resources: [], associations: [] };
    }
    
    // Obtener recursos asociados
    const associationsResult = await query(`
      SELECT 
        cra.id,
        cra.content_item_id,
        cra.media_resource_id,
        cra.section,
        cra.position,
        cra.placement,
        cra.caption,
        cra.is_critical,
        cra.priority
      FROM tes_content.content_resource_associations cra
      WHERE cra.content_item_id = ANY($1)
      ORDER BY cra.content_item_id, cra.position
    `, [contentIds]);
    
    // Obtener IDs únicos de recursos
    const resourceIds = [...new Set(
      associationsResult.rows.map(a => a.media_resource_id)
    )];
    
    if (resourceIds.length === 0) {
      return { resources: [], associations: associationsResult.rows };
    }
    
    // Obtener recursos
    const resourcesResult = await query(`
      SELECT 
        id, type, path, filename, file_url, thumbnail_url,
        title, description, alt_text, caption,
        tags, block, chapter, priority, usage_type,
        width, height, format, file_size,
        duration_seconds, video_format,
        source, attribution,
        uploaded_at
      FROM tes_content.media_resources
      WHERE id = ANY($1)
        AND status = 'published'
      ORDER BY 
        CASE priority 
          WHEN 'critica' THEN 1
          WHEN 'alta' THEN 2
          WHEN 'media' THEN 3
          WHEN 'baja' THEN 4
        END,
        uploaded_at DESC
    `, [resourceIds]);
    
    return {
      resources: resourcesResult.rows,
      associations: associationsResult.rows
    };
  }

  /**
   * Pre-calcula enlaces bidireccionales
   */
  calculateLinks(content) {
    const links = {
      protocol_to_guide: [],
      guide_to_protocol: [],
      manual_to_protocols: [],
      manual_to_guides: []
    };
    
    // Protocolo → Guía
    content.protocols.forEach(protocol => {
      if (protocol.related_guide_ids && protocol.related_guide_ids.length > 0) {
        protocol.related_guide_ids.forEach(guideId => {
          const guide = content.guides.find(g => g.id === guideId);
          if (guide) {
            links.protocol_to_guide.push({
              protocol_id: protocol.id,
              protocol_slug: protocol.slug,
              guide_id: guide.id,
              guide_slug: guide.slug
            });
          }
        });
      }
    });
    
    // Guía → Protocolo
    content.guides.forEach(guide => {
      if (guide.related_protocol_ids && guide.related_protocol_ids.length > 0) {
        guide.related_protocol_ids.forEach(protocolId => {
          const protocol = content.protocols.find(p => p.id === protocolId);
          if (protocol) {
            links.guide_to_protocol.push({
              guide_id: guide.id,
              guide_slug: guide.slug,
              protocol_id: protocol.id,
              protocol_slug: protocol.slug
            });
          }
        });
      }
    });
    
    // Manual → Protocolos/Guías
    content.manuals.forEach(manual => {
      if (manual.related_protocol_ids && manual.related_protocol_ids.length > 0) {
        links.manual_to_protocols.push({
          manual_id: manual.id,
          manual_slug: manual.slug,
          protocol_ids: manual.related_protocol_ids
        });
      }
      if (manual.related_guide_ids && manual.related_guide_ids.length > 0) {
        links.manual_to_guides.push({
          manual_id: manual.id,
          manual_slug: manual.slug,
          guide_ids: manual.related_guide_ids
        });
      }
    });
    
    return links;
  }

  /**
   * Calcula hash SHA-256 del contenido
   */
  calculateHash(pack) {
    // Hash solo del contenido (sin metadata)
    const contentToHash = {
      content: pack.content,
      media: pack.media,
      links: pack.links
    };
    
    const contentString = JSON.stringify(contentToHash, null, 0);
    const hash = createHash('sha256').update(contentString).digest('hex');
    
    return `sha256:${hash}`;
  }

  /**
   * Guarda el pack en disco
   */
  async savePack(version, pack) {
    const filename = `pack-v${version}.json`;
    const filepath = join(this.packsDir, filename);
    
    await writeFile(filepath, JSON.stringify(pack, null, 2), 'utf-8');
    
    return filepath;
  }

  /**
   * Actualiza symlink pack-latest.json
   */
  async updateLatestSymlink(version) {
    const latestPath = join(this.packsDir, 'pack-latest.json');
    const versionPath = join(this.packsDir, `pack-v${version}.json`);
    
    // Eliminar symlink anterior si existe
    try {
      await unlink(latestPath);
    } catch (error) {
      // No existe, continuar
    }
    
    // Crear nuevo symlink
    await symlink(`pack-v${version}.json`, latestPath);
  }

  /**
   * Cuenta items totales
   */
  countItems(content) {
    return Object.values(content).reduce((sum, items) => sum + items.length, 0);
  }
}

export default new ContentPackGenerator();

📊 FLUJO DE GENERACIÓN

1. Admin hace clic en "Generar Pack"
   ↓
2. POST /api/admin/pack/generate
   ↓
3. pack-generator.generatePack(version)
   ↓
4. loadPublishedContent() → PostgreSQL
   ↓
5. loadMediaResources() → PostgreSQL
   ↓
6. calculateLinks() → Pre-calcula enlaces
   ↓
7. calculateHash() → SHA-256
   ↓
8. savePack() → /storage/packs/pack-v{version}.json
   ↓
9. updateLatestSymlink() → pack-latest.json
   ↓
10. Retorna metadata del pack generado

VALIDACIONES

Antes de Generar

  • Versión sigue semver (1.2.3)
  • No existe pack con esa versión
  • Hay contenido publicado
  • Base de datos conectada

Después de Generar

  • Archivo JSON válido
  • Hash calculado correctamente
  • Symlink creado
  • Tamaño razonable (< 10MB)

🔍 OPTIMIZACIONES

  1. Compresión (Opcional)

    • Gzip del JSON antes de servir
    • App descomprime al descargar
  2. Incremental (Futuro)

    • Solo cambios desde última versión
    • Patch en lugar de pack completo
  3. CDN (Futuro)

    • Servir packs desde CDN
    • Cache distribuido

Fin del Documento