Add admin interface for term management with video upload support
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user