--- description: How to handle file uploads (videos, attachments) --- # File Upload Workflow Follow these steps to implement file upload functionality for videos and attachments using TypeScript and TSOA. --- ## Prerequisites - MinIO/S3 configured and running - Dependencies installed: `npm install multer @aws-sdk/client-s3 @aws-sdk/lib-storage` - TypeScript configured --- ## Step 1: Configure S3/MinIO Client Create `src/config/s3.config.ts`: ```typescript import { S3Client } from '@aws-sdk/client-s3'; export const s3Client = new S3Client({ endpoint: process.env.S3_ENDPOINT, region: process.env.S3_REGION || 'us-east-1', credentials: { accessKeyId: process.env.S3_ACCESS_KEY!, secretAccessKey: process.env.S3_SECRET_KEY! }, forcePathStyle: true // Required for MinIO }); ``` --- ## Step 2: Create Upload Service Create `src/services/upload.service.ts`: ```typescript import { s3Client } from '../config/s3.config'; import { Upload } from '@aws-sdk/lib-storage'; import { DeleteObjectCommand } from '@aws-sdk/client-s3'; import { v4 as uuidv4 } from 'uuid'; import path from 'path'; export interface UploadResult { key: string; url: string; fileName: string; fileSize: number; mimeType: string; } type FolderType = 'videos' | 'documents' | 'images' | 'attachments'; class UploadService { async uploadFile(file: Express.Multer.File, folder: FolderType): Promise { const fileExt = path.extname(file.originalname); const fileName = `${Date.now()}-${uuidv4()}${fileExt}`; const key = `${folder}/${fileName}`; const upload = new Upload({ client: s3Client, params: { Bucket: this.getBucket(folder), Key: key, Body: file.buffer, ContentType: file.mimetype } }); await upload.done(); return { key, url: `${process.env.S3_ENDPOINT}/${this.getBucket(folder)}/${key}`, fileName: file.originalname, fileSize: file.size, mimeType: file.mimetype }; } async deleteFile(key: string, folder: FolderType): Promise { const command = new DeleteObjectCommand({ Bucket: this.getBucket(folder), Key: key }); await s3Client.send(command); } private getBucket(folder: FolderType): string { const bucketMap: Record = { videos: process.env.S3_BUCKET_VIDEOS!, documents: process.env.S3_BUCKET_DOCUMENTS!, images: process.env.S3_BUCKET_IMAGES!, attachments: process.env.S3_BUCKET_ATTACHMENTS! }; return bucketMap[folder] || process.env.S3_BUCKET_COURSES!; } validateFileType(file: Express.Multer.File, allowedTypes: string[]): boolean { return allowedTypes.includes(file.mimetype); } validateFileSize(file: Express.Multer.File, maxSize: number): boolean { return file.size <= maxSize; } } export const uploadService = new UploadService(); ``` --- ## Step 3: Create Upload Middleware Create `src/middleware/upload.middleware.ts`: ```typescript import multer from 'multer'; import { Request } from 'express'; // File type validators export const ALLOWED_VIDEO_TYPES = ['video/mp4', 'video/webm', 'video/quicktime']; export const ALLOWED_DOCUMENT_TYPES = [ 'application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ]; export const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif']; // File size limits export const MAX_VIDEO_SIZE = 500 * 1024 * 1024; // 500 MB export const MAX_ATTACHMENT_SIZE = 100 * 1024 * 1024; // 100 MB export const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10 MB // Multer configuration const storage = multer.memoryStorage(); const fileFilter = (allowedTypes: string[]) => ( req: Request, file: Express.Multer.File, cb: multer.FileFilterCallback ) => { if (allowedTypes.includes(file.mimetype)) { cb(null, true); } else { cb(new Error('Invalid file type')); } }; // Upload configurations export const uploadVideo = multer({ storage, limits: { fileSize: MAX_VIDEO_SIZE }, fileFilter: fileFilter(ALLOWED_VIDEO_TYPES) }).single('video'); export const uploadAttachment = multer({ storage, limits: { fileSize: MAX_ATTACHMENT_SIZE }, fileFilter: fileFilter([...ALLOWED_DOCUMENT_TYPES, ...ALLOWED_IMAGE_TYPES]) }).single('file'); export const uploadAttachments = multer({ storage, limits: { fileSize: MAX_ATTACHMENT_SIZE }, fileFilter: fileFilter([...ALLOWED_DOCUMENT_TYPES, ...ALLOWED_IMAGE_TYPES]) }).array('attachments', 10); // Max 10 files export const uploadImage = multer({ storage, limits: { fileSize: MAX_IMAGE_SIZE }, fileFilter: fileFilter(ALLOWED_IMAGE_TYPES) }).single('image'); ``` --- ## Step 4: Create Upload Controller with TSOA Create `src/controllers/upload.controller.ts`: ```typescript import { Request } from 'express'; import { Controller, Post, Route, Tags, Security, UploadedFile, SuccessResponse } from 'tsoa'; import { uploadService } from '../services/upload.service'; interface UploadResponse { video_url?: string; file_size: number; duration?: number | null; file_name?: string; file_path?: string; mime_type?: string; download_url?: string; } @Route('api/upload') @Tags('File Upload') export class UploadController extends Controller { /** * Upload video file * @summary Upload video for lesson (Instructor/Admin only) */ @Post('video') @Security('jwt', ['INSTRUCTOR', 'ADMIN']) @SuccessResponse(200, 'Success') public async uploadVideo( @UploadedFile() file: Express.Multer.File ): Promise { if (!file) { this.setStatus(400); throw new Error('No video file provided'); } const result = await uploadService.uploadFile(file, 'videos'); return { video_url: result.url, file_size: result.fileSize, duration: null // Will be processed later }; } /** * Upload attachment file * @summary Upload attachment for lesson (Instructor/Admin only) */ @Post('attachment') @Security('jwt', ['INSTRUCTOR', 'ADMIN']) @SuccessResponse(200, 'Success') public async uploadAttachment( @UploadedFile() file: Express.Multer.File ): Promise { if (!file) { this.setStatus(400); throw new Error('No file provided'); } const result = await uploadService.uploadFile(file, 'attachments'); return { file_name: result.fileName, file_path: result.key, file_size: result.fileSize, mime_type: result.mimeType, download_url: result.url }; } } ``` --- ## Step 5: Generate TSOA Routes // turbo After creating the upload controller, generate routes and API documentation: ```bash npm run tsoa:gen ``` --- ## Step 5: Create Upload Routes Create `src/routes/upload.routes.js`: ```javascript const express = require('express'); const router = express.Router(); const uploadController = require('../controllers/upload.controller'); const { authenticate, authorize } = require('../middleware/auth'); const { uploadVideo, uploadAttachment } = require('../middleware/upload.middleware'); // Video upload router.post( '/video', authenticate, authorize(['INSTRUCTOR', 'ADMIN']), (req, res, next) => { uploadVideo(req, res, (err) => { if (err) { if (err.message === 'Invalid file type') { return res.status(422).json({ error: { code: 'INVALID_FILE_TYPE', message: 'Only MP4, WebM, and QuickTime videos are allowed' } }); } if (err.code === 'LIMIT_FILE_SIZE') { return res.status(422).json({ error: { code: 'FILE_TOO_LARGE', message: 'Video file size exceeds 500 MB limit' } }); } return res.status(500).json({ error: { code: 'UPLOAD_ERROR', message: err.message } }); } next(); }); }, uploadController.uploadVideo ); // Attachment upload router.post( '/attachment', authenticate, authorize(['INSTRUCTOR', 'ADMIN']), (req, res, next) => { uploadAttachment(req, res, (err) => { if (err) { if (err.message === 'Invalid file type') { return res.status(422).json({ error: { code: 'INVALID_FILE_TYPE', message: 'File type not allowed' } }); } if (err.code === 'LIMIT_FILE_SIZE') { return res.status(422).json({ error: { code: 'FILE_TOO_LARGE', message: 'File size exceeds 100 MB limit' } }); } return res.status(500).json({ error: { code: 'UPLOAD_ERROR', message: err.message } }); } next(); }); }, uploadController.uploadAttachment ); module.exports = router; ``` --- ## Step 6: Integrate with Lesson Creation Update lesson controller to handle file uploads: ```javascript async createLesson(req, res) { try { const { courseId, chapterId } = req.params; // Create lesson const lesson = await lessonService.create({ ...req.body, chapter_id: parseInt(chapterId) }); // Handle video upload if present if (req.files?.video) { const videoResult = await uploadService.uploadFile( req.files.video[0], `courses/${courseId}/lessons/${lesson.id}` ); await lessonService.update(lesson.id, { video_url: videoResult.url }); } // Handle attachments if present if (req.files?.attachments) { const attachments = await Promise.all( req.files.attachments.map(async (file, index) => { const result = await uploadService.uploadFile( file, `lessons/${lesson.id}/attachments` ); return { lesson_id: lesson.id, file_name: result.fileName, file_path: result.key, file_size: result.fileSize, mime_type: result.mimeType, description: req.body.descriptions?.[index] || {}, sort_order: index + 1 }; }) ); await prisma.attachment.createMany({ data: attachments }); } return res.status(201).json(lesson); } catch (error) { console.error('Create lesson error:', error); return res.status(500).json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to create lesson' } }); } } ``` --- ## Step 7: Test File Upload ```bash # Upload video curl -X POST http://localhost:4000/api/upload/video \ -H "Authorization: Bearer " \ -F "video=@/path/to/video.mp4" # Upload attachment curl -X POST http://localhost:4000/api/upload/attachment \ -H "Authorization: Bearer " \ -F "file=@/path/to/document.pdf" ``` --- ## Error Handling ### Common Errors 1. **File Too Large** ```json { "error": { "code": "FILE_TOO_LARGE", "message": "File size exceeds maximum limit" } } ``` 2. **Invalid File Type** ```json { "error": { "code": "INVALID_FILE_TYPE", "message": "File type not allowed" } } ``` 3. **S3 Upload Failed** ```json { "error": { "code": "UPLOAD_FAILED", "message": "Failed to upload file to storage" } } ``` --- ## Checklist - [ ] S3/MinIO client configured - [ ] Upload service created - [ ] Upload middleware configured - [ ] File type validation implemented - [ ] File size validation implemented - [ ] Upload routes created - [ ] Error handling implemented - [ ] File deletion implemented - [ ] Tests written - [ ] Manual testing done --- ## Best Practices 1. **Validate Before Upload**: Check file type and size before uploading 2. **Unique Filenames**: Use UUID to prevent filename conflicts 3. **Organized Storage**: Use folder structure (courses/1/lessons/5/video.mp4) 4. **Error Handling**: Provide clear error messages 5. **Cleanup**: Delete files from S3 when deleting records 6. **Security**: Validate file content, not just extension 7. **Progress**: Consider upload progress for large files --- ## Advanced: Video Processing For video processing (transcoding, thumbnails): ```javascript const ffmpeg = require('fluent-ffmpeg'); async function processVideo(videoPath) { // Generate thumbnail await new Promise((resolve, reject) => { ffmpeg(videoPath) .screenshots({ timestamps: ['00:00:01'], filename: 'thumbnail.jpg', folder: './thumbnails' }) .on('end', resolve) .on('error', reject); }); // Get video duration const metadata = await new Promise((resolve, reject) => { ffmpeg.ffprobe(videoPath, (err, metadata) => { if (err) reject(err); else resolve(metadata); }); }); return { duration: metadata.format.duration, thumbnail: './thumbnails/thumbnail.jpg' }; } ```