elearning/Backend/.windsurf/workflows/file-upload.md

13 KiB

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:

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:

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<UploadResult> {
    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<void> {
    const command = new DeleteObjectCommand({
      Bucket: this.getBucket(folder),
      Key: key
    });

    await s3Client.send(command);
  }

  private getBucket(folder: FolderType): string {
    const bucketMap: Record<FolderType, string> = {
      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:

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:

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<UploadResponse> {
    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<UploadResponse> {
    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:

npm run tsoa:gen

Step 5: Create Upload Routes

Create src/routes/upload.routes.js:

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:

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

# Upload video
curl -X POST http://localhost:4000/api/upload/video \
  -H "Authorization: Bearer <token>" \
  -F "video=@/path/to/video.mp4"

# Upload attachment
curl -X POST http://localhost:4000/api/upload/attachment \
  -H "Authorization: Bearer <token>" \
  -F "file=@/path/to/document.pdf"

Error Handling

Common Errors

  1. File Too Large
{
  "error": {
    "code": "FILE_TOO_LARGE",
    "message": "File size exceeds maximum limit"
  }
}
  1. Invalid File Type
{
  "error": {
    "code": "INVALID_FILE_TYPE",
    "message": "File type not allowed"
  }
}
  1. S3 Upload Failed
{
  "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):

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'
  };
}