414 lines
10 KiB
Markdown
414 lines
10 KiB
Markdown
|
|
# 📦 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**
|
||
|
|
|