# 📦 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 ```javascript // 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**