/** * Rutas para gestión de recursos multimedia */ import express, { Response } from 'express'; import { authenticate, requirePermission, AuthRequest } from '../middleware/auth.js'; import { query } from '../../config/database.js'; import multer from 'multer'; import { join } from 'path'; import { mkdir, writeFile, unlink, stat, readFile } from 'fs/promises'; import { createHash } from 'crypto'; const router = express.Router(); router.use(authenticate); // Configurar multer para upload const storageDir = process.env.MEDIA_STORAGE_DIR || join(process.cwd(), 'storage', 'media'); const upload = multer({ dest: storageDir, limits: { fileSize: 50 * 1024 * 1024, // 50MB }, fileFilter: (_req, file, cb) => { // Permitir imágenes y vídeos const allowedMimes = [ 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml', 'video/mp4', 'video/webm', 'video/ogg', ]; if (allowedMimes.includes(file.mimetype)) { cb(null, true); } else { cb(new Error('Tipo de archivo no permitido') as any, false); } }, }); // Asegurar que existe el directorio mkdir(storageDir, { recursive: true }).catch(console.error); /** * POST /api/media/upload * Subir un recurso multimedia */ router.post('/upload', requirePermission('content:write'), upload.single('file'), async (req: AuthRequest, res: Response) => { try { if (!req.file) { res.status(400).json({ error: 'No se proporcionó archivo' }); return; } const { title, description, alt_text, tags, block, chapter, priority } = req.body; const file = req.file; // Determinar tipo const type = file.mimetype.startsWith('image/') ? 'image' : 'video'; // Generar nombre único const ext = file.originalname.split('.').pop(); const hash = createHash('md5').update(file.path + Date.now()).digest('hex').substring(0, 8); const filename = `${hash}.${ext}`; const finalPath = join(storageDir, filename); // Mover archivo a nombre final await writeFile(finalPath, await readFile(file.path)); await unlink(file.path); // URL pública const fileUrl = `/storage/media/${filename}`; // Obtener metadata del archivo const stats = await stat(finalPath); // width, height, duration se pueden obtener con sharp o similar en el futuro // Insertar en BD const result = await query(` INSERT INTO tes_content.media_resources ( type, path, filename, file_url, title, description, alt_text, tags, block, chapter, priority, file_size, format, status ) VALUES ( $1::tes_content.media_type, $2, $3, $4, $5, $6, $7, $8::text[], $9, $10, $11::tes_content.priority, $12, $13, 'published'::tes_content.content_status ) RETURNING id, type, file_url, title, created_at `, [ type, finalPath, filename, fileUrl, title || file.originalname, description || null, alt_text || null, tags ? (Array.isArray(tags) ? tags : String(tags).split(',').map((t: string) => t.trim())) : [], block || null, chapter || null, priority || 'media', stats.size, ext, ]); res.json({ success: true, resource: result.rows[0], }); } catch (error) { console.error('Error subiendo recurso:', error); const errorMessage = error instanceof Error ? error.message : 'Error desconocido'; res.status(500).json({ error: 'Error subiendo recurso', message: errorMessage }); } }); /** * GET /api/media * Listar recursos multimedia */ router.get('/', requirePermission('content:read'), async (req: AuthRequest, res: Response) => { try { const { type, search, page = '1', pageSize = '20' } = req.query; const pageNum = typeof page === 'string' ? parseInt(page) : 1; const pageSizeNum = typeof pageSize === 'string' ? parseInt(pageSize) : 20; let whereConditions: string[] = []; let params: any[] = []; let paramIndex = 1; if (type) { whereConditions.push(`type = $${paramIndex++}`); params.push(type); } if (search) { whereConditions.push(`(title ILIKE $${paramIndex} OR description ILIKE $${paramIndex} OR alt_text ILIKE $${paramIndex})`); params.push(`%${search}%`); paramIndex++; } const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(' AND ')}` : ''; // Contar total const countResult = await query( `SELECT COUNT(*) as total FROM tes_content.media_resources ${whereClause}`, params ); const total = parseInt(countResult.rows[0].total); // Obtener items const offset = (pageNum - 1) * pageSizeNum; params.push(pageSizeNum, offset); const itemsResult = await query( `SELECT id, type, file_url, title, description, alt_text, tags, priority, status, file_size, format, created_at, updated_at FROM tes_content.media_resources ${whereClause} ORDER BY created_at DESC LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, params ); res.json({ items: itemsResult.rows, total, page: pageNum, pageSize: pageSizeNum, }); } catch (error) { console.error('Error listando recursos:', error); res.status(500).json({ error: 'Error interno del servidor' }); } }); /** * GET /api/media/:id * Obtener un recurso específico */ router.get('/:id', requirePermission('content:read'), async (req: AuthRequest, res: Response) => { try { const { id } = req.params; const result = await query( `SELECT * FROM tes_content.media_resources WHERE id = $1`, [id] ); if (result.rows.length === 0) { res.status(404).json({ error: 'Recurso no encontrado' }); return; } res.json(result.rows[0]); return; } catch (error) { console.error('Error obteniendo recurso:', error); res.status(500).json({ error: 'Error interno del servidor' }); } }); /** * DELETE /api/media/:id * Eliminar un recurso */ router.delete('/:id', requirePermission('content:write'), async (req: AuthRequest, res: Response) => { try { const { id } = req.params; // Obtener información del archivo const result = await query( `SELECT path FROM tes_content.media_resources WHERE id = $1`, [id] ); if (result.rows.length === 0) { res.status(404).json({ error: 'Recurso no encontrado' }); return; } // Eliminar archivo físico try { await unlink(result.rows[0].path); } catch (error) { console.warn('No se pudo eliminar archivo físico:', error); } // Eliminar de BD await query( `DELETE FROM tes_content.media_resources WHERE id = $1`, [id] ); res.json({ success: true }); return; } catch (error) { console.error('Error eliminando recurso:', error); res.status(500).json({ error: 'Error interno del servidor' }); } }); /** * GET /api/media/orphaned * Obtener recursos huérfanos (sin asociaciones) */ router.get('/orphaned/list', requirePermission('content:read'), async (_req: AuthRequest, res: Response) => { try { const result = await query(` SELECT mr.* FROM tes_content.media_resources mr LEFT JOIN tes_content.content_resource_associations cra ON mr.id = cra.media_resource_id WHERE cra.id IS NULL AND mr.status = 'published' ORDER BY mr.created_at DESC `); res.json({ items: result.rows, total: result.rows.length }); } catch (error) { console.error('Error obteniendo recursos huérfanos:', error); res.status(500).json({ error: 'Error interno del servidor' }); } }); export default router;