10 KiB
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
-
Lectura desde PostgreSQL
- Lee solo contenido
status = 'published' - Lee solo última versión (
version = latest_version) - Incluye recursos asociados
- Pre-calcula enlaces bidireccionales
- Lee solo contenido
-
Generación de JSON
- Estructura optimizada
- Ordenamiento por prioridad
- Validación de integridad
-
Cálculo de Hash
- SHA-256 del contenido (sin metadata)
- Verificación de integridad
-
Guardado
- Guarda en
/storage/packs/pack-v{version}.json - Crea symlink
pack-latest.json - Mantiene historial de versiones
- Guarda en
🔧 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
-
Compresión (Opcional)
- Gzip del JSON antes de servir
- App descomprime al descargar
-
Incremental (Futuro)
- Solo cambios desde última versión
- Patch en lugar de pack completo
-
CDN (Futuro)
- Servir packs desde CDN
- Cache distribuido
Fin del Documento