Add admin interface for term management with video upload support

This commit is contained in:
2026-01-17 19:31:39 +01:00
parent 85ae57b95b
commit 7598f26c9c
10 changed files with 1315 additions and 25 deletions

View File

@@ -1,6 +1,14 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../lib/prisma.js';
import { WordType, CefrLevel } from '@prisma/client';
import { WordType, CefrLevel, MediaKind } from '@prisma/client';
import { isAuthenticated, isAdmin } from '../middleware/auth.js';
import { videoUpload, handleUploadError } from '../middleware/upload.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();
@@ -130,5 +138,293 @@ router.get('/:id', async (req: Request, res: Response) => {
}
});
/**
* 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
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
},
});
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);
}
}
// 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 });
}
});
export default router;