import { Router, Request, Response } from 'express'; import { prisma } from '../lib/prisma.js'; import { WordType, CefrLevel, MediaKind } from '@prisma/client'; import { isAuthenticated, isAdmin } from '../middleware/auth.js'; import { videoUpload, handleUploadError } from '../middleware/upload.js'; import { generateGifFromVideo } from '../utils/gifGenerator.js'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const router = Router(); /** * GET /api/terms * Get all terms with optional filtering, search, and pagination * Query params: * - query: search text (searches wordText and normalizedText) * - wordType: filter by word type (NOUN, VERB, etc.) * - cefrLevel: filter by CEFR level (A1, A2, B1, B2, C1, C2) * - page: page number (default: 1) * - limit: items per page (default: 20) */ router.get('/', async (req: Request, res: Response) => { try { const { query = '', wordType, cefrLevel, page = '1', limit = '20', } = req.query; const pageNum = parseInt(page as string, 10); const limitNum = parseInt(limit as string, 10); const skip = (pageNum - 1) * limitNum; // Build where clause const where: any = {}; // Add search filter if (query && typeof query === 'string' && query.trim()) { where.OR = [ { wordText: { contains: query.trim() } }, { normalizedText: { contains: query.trim() } }, ]; } // Add wordType filter if (wordType && typeof wordType === 'string') { const validWordTypes = Object.values(WordType); if (validWordTypes.includes(wordType as WordType)) { where.wordType = wordType as WordType; } } // Add cefrLevel filter if (cefrLevel && typeof cefrLevel === 'string') { const validCefrLevels = Object.values(CefrLevel); if (validCefrLevels.includes(cefrLevel as CefrLevel)) { where.cefrLevel = cefrLevel as CefrLevel; } } // Get total count for pagination const total = await prisma.term.count({ where }); // Get terms with media const terms = await prisma.term.findMany({ where, include: { media: { orderBy: { createdAt: 'asc', }, }, examples: { orderBy: { createdAt: 'asc', }, }, }, orderBy: { wordText: 'asc', }, skip, take: limitNum, }); res.json({ terms, pagination: { page: pageNum, limit: limitNum, total, totalPages: Math.ceil(total / limitNum), }, }); } catch (error: any) { console.error('Error fetching terms:', error); res.status(500).json({ error: 'Failed to fetch terms', message: error.message }); } }); /** * GET /api/terms/:id * Get a single term by ID with all related data */ router.get('/:id', async (req: Request, res: Response) => { try { const { id } = req.params; const term = await prisma.term.findUnique({ where: { id }, include: { media: { orderBy: { createdAt: 'asc', }, }, examples: { orderBy: { createdAt: 'asc', }, }, }, }); if (!term) { return res.status(404).json({ error: 'Term not found' }); } res.json({ term }); } catch (error: any) { console.error('Error fetching term:', error); res.status(500).json({ error: 'Failed to fetch term', message: error.message }); } }); /** * Helper function to normalize text (lowercase, remove diacritics) */ function normalizeText(text: string): string { return text .toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, ''); // Remove diacritics } /** * POST /api/terms * Create a new term (Admin only) */ router.post('/', isAuthenticated, isAdmin, async (req: Request, res: Response) => { try { const { wordText, wordType, cefrLevel, shortDescription, tags, iconAssetId } = req.body; // Validate required fields if (!wordText || !wordType || !cefrLevel) { return res.status(400).json({ error: 'Validation error', message: 'wordText, wordType, and cefrLevel are required', }); } // Validate wordType const validWordTypes = Object.values(WordType); if (!validWordTypes.includes(wordType as WordType)) { return res.status(400).json({ error: 'Validation error', message: `Invalid wordType. Must be one of: ${validWordTypes.join(', ')}`, }); } // Validate cefrLevel const validCefrLevels = Object.values(CefrLevel); if (!validCefrLevels.includes(cefrLevel as CefrLevel)) { return res.status(400).json({ error: 'Validation error', message: `Invalid cefrLevel. Must be one of: ${validCefrLevels.join(', ')}`, }); } // Create term with auto-generated normalizedText const term = await prisma.term.create({ data: { wordText, normalizedText: normalizeText(wordText), wordType: wordType as WordType, cefrLevel: cefrLevel as CefrLevel, shortDescription: shortDescription || null, tags: tags || null, iconAssetId: iconAssetId || null, }, include: { media: true, examples: true, }, }); res.status(201).json({ term }); } catch (error: any) { console.error('Error creating term:', error); res.status(500).json({ error: 'Failed to create term', message: error.message }); } }); /** * PUT /api/terms/:id * Update an existing term (Admin only) */ router.put('/:id', isAuthenticated, isAdmin, async (req: Request, res: Response) => { try { const { id } = req.params; const { wordText, wordType, cefrLevel, shortDescription, tags, iconAssetId } = req.body; // Check if term exists const existingTerm = await prisma.term.findUnique({ where: { id } }); if (!existingTerm) { return res.status(404).json({ error: 'Term not found' }); } // Build update data const updateData: any = {}; if (wordText !== undefined) { updateData.wordText = wordText; updateData.normalizedText = normalizeText(wordText); } if (wordType !== undefined) { const validWordTypes = Object.values(WordType); if (!validWordTypes.includes(wordType as WordType)) { return res.status(400).json({ error: 'Validation error', message: `Invalid wordType. Must be one of: ${validWordTypes.join(', ')}`, }); } updateData.wordType = wordType as WordType; } if (cefrLevel !== undefined) { const validCefrLevels = Object.values(CefrLevel); if (!validCefrLevels.includes(cefrLevel as CefrLevel)) { return res.status(400).json({ error: 'Validation error', message: `Invalid cefrLevel. Must be one of: ${validCefrLevels.join(', ')}`, }); } updateData.cefrLevel = cefrLevel as CefrLevel; } if (shortDescription !== undefined) updateData.shortDescription = shortDescription || null; if (tags !== undefined) updateData.tags = tags || null; if (iconAssetId !== undefined) updateData.iconAssetId = iconAssetId || null; // Update term const term = await prisma.term.update({ where: { id }, data: updateData, include: { media: true, examples: true, }, }); res.json({ term }); } catch (error: any) { console.error('Error updating term:', error); res.status(500).json({ error: 'Failed to update term', message: error.message }); } }); /** * DELETE /api/terms/:id * Delete a term and its associated media (Admin only) */ router.delete('/:id', isAuthenticated, isAdmin, async (req: Request, res: Response) => { try { const { id } = req.params; // Check if term exists and get associated media const term = await prisma.term.findUnique({ where: { id }, include: { media: true }, }); if (!term) { return res.status(404).json({ error: 'Term not found' }); } // Delete associated video files from disk const uploadsDir = path.join(__dirname, '..', '..', 'uploads', 'videos'); for (const media of term.media) { if (media.kind === MediaKind.VIDEO && media.url) { const filename = path.basename(media.url); const filePath = path.join(uploadsDir, filename); try { if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); console.log(`Deleted video file: ${filename}`); } } catch (err) { console.error(`Failed to delete video file ${filename}:`, err); } } } // Delete term (cascade will delete TermMedia records) await prisma.term.delete({ where: { id } }); res.json({ message: 'Term deleted successfully' }); } catch (error: any) { console.error('Error deleting term:', error); res.status(500).json({ error: 'Failed to delete term', message: error.message }); } }); /** * POST /api/terms/:id/media * Upload a video for a term (Admin only) */ router.post( '/:id/media', isAuthenticated, isAdmin, videoUpload.single('video'), handleUploadError, async (req: Request, res: Response) => { try { const { id } = req.params; // Check if term exists const term = await prisma.term.findUnique({ where: { id } }); if (!term) { // Clean up uploaded file if term doesn't exist if (req.file) { fs.unlinkSync(req.file.path); } return res.status(404).json({ error: 'Term not found' }); } // Check if file was uploaded if (!req.file) { return res.status(400).json({ error: 'No video file uploaded' }); } // Create relative URL path const relativeUrl = `/uploads/videos/${req.file.filename}`; // Create TermMedia record for video const media = await prisma.termMedia.create({ data: { termId: id, kind: MediaKind.VIDEO, url: relativeUrl, // TODO: Extract video metadata (duration, dimensions) using ffprobe if available // For now, these fields will be null }, }); // Generate GIF preview from video try { const videoPath = req.file.path; const gifFilename = req.file.filename.replace(/\.(mp4|webm|mov)$/i, '.gif'); const gifsDir = path.join(__dirname, '..', '..', 'uploads', 'gifs'); // Ensure gifs directory exists if (!fs.existsSync(gifsDir)) { fs.mkdirSync(gifsDir, { recursive: true }); } const gifPath = path.join(gifsDir, gifFilename); const gifRelativeUrl = `/uploads/gifs/${gifFilename}`; // Generate GIF (300px width, 10fps, first 3 seconds) await generateGifFromVideo(videoPath, gifPath, { width: 300, fps: 10, duration: 3, startTime: 0, }); // Create TermMedia record for GIF await prisma.termMedia.create({ data: { termId: id, kind: MediaKind.GIF, url: gifRelativeUrl, }, }); console.log(`Generated GIF preview: ${gifRelativeUrl}`); } catch (gifError: any) { // Log error but don't fail the upload if GIF generation fails console.error('Failed to generate GIF preview:', gifError.message); } res.status(201).json({ media }); } catch (error: any) { console.error('Error uploading video:', error); // Clean up uploaded file on error if (req.file) { try { fs.unlinkSync(req.file.path); } catch (err) { console.error('Failed to clean up file:', err); } } res.status(500).json({ error: 'Failed to upload video', message: error.message }); } } ); /** * DELETE /api/terms/:id/media/:mediaId * Delete a video from a term (Admin only) */ router.delete('/:id/media/:mediaId', isAuthenticated, isAdmin, async (req: Request, res: Response) => { try { const { id, mediaId } = req.params; // Check if media exists and belongs to the term const media = await prisma.termMedia.findUnique({ where: { id: mediaId }, }); if (!media) { return res.status(404).json({ error: 'Media not found' }); } if (media.termId !== id) { return res.status(400).json({ error: 'Media does not belong to this term' }); } // Delete video file from disk if it's a video if (media.kind === MediaKind.VIDEO && media.url) { const uploadsDir = path.join(__dirname, '..', '..', 'uploads', 'videos'); const filename = path.basename(media.url); const filePath = path.join(uploadsDir, filename); try { if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); console.log(`Deleted video file: ${filename}`); } } catch (err) { console.error(`Failed to delete video file ${filename}:`, err); } // Also delete corresponding GIF if it exists const gifFilename = filename.replace(/\.(mp4|webm|mov)$/i, '.gif'); const gifsDir = path.join(__dirname, '..', '..', 'uploads', 'gifs'); const gifPath = path.join(gifsDir, gifFilename); const gifRelativeUrl = `/uploads/gifs/${gifFilename}`; try { if (fs.existsSync(gifPath)) { fs.unlinkSync(gifPath); console.log(`Deleted GIF file: ${gifFilename}`); } // Delete GIF media record const gifMedia = await prisma.termMedia.findFirst({ where: { termId: id, kind: MediaKind.GIF, url: gifRelativeUrl, }, }); if (gifMedia) { await prisma.termMedia.delete({ where: { id: gifMedia.id } }); console.log(`Deleted GIF media record: ${gifMedia.id}`); } } catch (err) { console.error(`Failed to delete GIF ${gifFilename}:`, err); } } // Delete GIF file from disk if it's a GIF if (media.kind === MediaKind.GIF && media.url) { const gifsDir = path.join(__dirname, '..', '..', 'uploads', 'gifs'); const filename = path.basename(media.url); const filePath = path.join(gifsDir, filename); try { if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); console.log(`Deleted GIF file: ${filename}`); } } catch (err) { console.error(`Failed to delete GIF file ${filename}:`, err); } } // Delete TermMedia record await prisma.termMedia.delete({ where: { id: mediaId } }); res.json({ message: 'Media deleted successfully' }); } catch (error: any) { console.error('Error deleting media:', error); res.status(500).json({ error: 'Failed to delete media', message: error.message }); } }); /** * POST /api/terms/:id/media/:mediaId/regenerate-gif * Regenerate GIF preview for a video (Admin only) */ router.post('/:id/media/:mediaId/regenerate-gif', isAuthenticated, isAdmin, async (req: Request, res: Response) => { try { const { id, mediaId } = req.params; // Check if media exists and belongs to the term const media = await prisma.termMedia.findUnique({ where: { id: mediaId }, }); if (!media) { return res.status(404).json({ error: 'Media not found' }); } if (media.termId !== id) { return res.status(400).json({ error: 'Media does not belong to this term' }); } if (media.kind !== MediaKind.VIDEO) { return res.status(400).json({ error: 'Can only regenerate GIF from video media' }); } // Get video file path const videoFilename = path.basename(media.url); const videoPath = path.join(__dirname, '..', '..', 'uploads', 'videos', videoFilename); if (!fs.existsSync(videoPath)) { return res.status(404).json({ error: 'Video file not found on disk' }); } // Generate GIF filename and path const gifFilename = videoFilename.replace(/\.(mp4|webm|mov)$/i, '.gif'); const gifsDir = path.join(__dirname, '..', '..', 'uploads', 'gifs'); // Ensure gifs directory exists if (!fs.existsSync(gifsDir)) { fs.mkdirSync(gifsDir, { recursive: true }); } const gifPath = path.join(gifsDir, gifFilename); const gifRelativeUrl = `/uploads/gifs/${gifFilename}`; // Delete old GIF if it exists if (fs.existsSync(gifPath)) { fs.unlinkSync(gifPath); } // Delete old GIF media record if it exists const existingGif = await prisma.termMedia.findFirst({ where: { termId: id, kind: MediaKind.GIF, url: gifRelativeUrl, }, }); if (existingGif) { await prisma.termMedia.delete({ where: { id: existingGif.id } }); } // Generate new GIF (300px width, 10fps, first 3 seconds) await generateGifFromVideo(videoPath, gifPath, { width: 300, fps: 10, duration: 3, startTime: 0, }); // Create new TermMedia record for GIF const gifMedia = await prisma.termMedia.create({ data: { termId: id, kind: MediaKind.GIF, url: gifRelativeUrl, }, }); console.log(`Regenerated GIF preview: ${gifRelativeUrl}`); res.status(201).json({ media: gifMedia }); } catch (error: any) { console.error('Error regenerating GIF:', error); res.status(500).json({ error: 'Failed to regenerate GIF', message: error.message }); } }); export default router;