Add admin interface for term management with video upload support
This commit is contained in:
87
packages/backend/src/middleware/upload.ts
Normal file
87
packages/backend/src/middleware/upload.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import fs from 'fs';
|
||||
import { Request } from 'express';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Ensure uploads directory exists
|
||||
const uploadsDir = path.join(__dirname, '..', '..', 'uploads', 'videos');
|
||||
if (!fs.existsSync(uploadsDir)) {
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Configure storage
|
||||
const storage = multer.diskStorage({
|
||||
destination: (_req, _file, cb) => {
|
||||
cb(null, uploadsDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
// Generate unique filename with timestamp
|
||||
const termId = (req.params as any).id || 'unknown';
|
||||
const timestamp = Date.now();
|
||||
const ext = path.extname(file.originalname);
|
||||
const basename = path.basename(file.originalname, ext)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, '-')
|
||||
.substring(0, 30);
|
||||
|
||||
const filename = `${basename}-${termId.substring(0, 8)}-${timestamp}${ext}`;
|
||||
cb(null, filename);
|
||||
},
|
||||
});
|
||||
|
||||
// File filter to validate MIME types
|
||||
const fileFilter = (_req: Request, file: Express.Multer.File, cb: multer.FileFilterCallback) => {
|
||||
const allowedMimeTypes = [
|
||||
'video/mp4',
|
||||
'video/webm',
|
||||
'video/quicktime', // .mov files
|
||||
];
|
||||
|
||||
if (allowedMimeTypes.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error(`Invalid file type. Only MP4, WebM, and MOV videos are allowed. Received: ${file.mimetype}`));
|
||||
}
|
||||
};
|
||||
|
||||
// Get max file size from environment or default to 100MB
|
||||
const maxFileSize = parseInt(process.env.MAX_VIDEO_SIZE || '104857600', 10); // 100MB in bytes
|
||||
|
||||
// Create multer upload instance
|
||||
export const videoUpload = multer({
|
||||
storage,
|
||||
fileFilter,
|
||||
limits: {
|
||||
fileSize: maxFileSize,
|
||||
},
|
||||
});
|
||||
|
||||
// Error handler middleware for multer errors
|
||||
export const handleUploadError = (err: any, _req: Request, res: any, next: any) => {
|
||||
if (err instanceof multer.MulterError) {
|
||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||
return res.status(400).json({
|
||||
error: 'File too large',
|
||||
message: `Maximum file size is ${maxFileSize / 1024 / 1024}MB`,
|
||||
});
|
||||
}
|
||||
return res.status(400).json({
|
||||
error: 'Upload error',
|
||||
message: err.message,
|
||||
});
|
||||
}
|
||||
|
||||
if (err) {
|
||||
return res.status(400).json({
|
||||
error: 'Upload failed',
|
||||
message: err.message,
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -16,7 +16,10 @@ const app = express();
|
||||
const PORT = parseInt(process.env.PORT || '3000', 10);
|
||||
|
||||
// Middleware
|
||||
app.use(helmet());
|
||||
app.use(helmet({
|
||||
crossOriginResourcePolicy: { policy: "cross-origin" },
|
||||
contentSecurityPolicy: false, // Disable CSP to allow video playback
|
||||
}));
|
||||
app.use(cors({
|
||||
origin: process.env.FRONTEND_URL || 'http://localhost:5173',
|
||||
credentials: true,
|
||||
|
||||
Reference in New Issue
Block a user