diff --git a/src/modules/education/controllers/video.controller.ts b/src/modules/education/controllers/video.controller.ts new file mode 100644 index 0000000..c975a15 --- /dev/null +++ b/src/modules/education/controllers/video.controller.ts @@ -0,0 +1,352 @@ +// ============================================================================ +// Trading Platform - Video Controller +// ============================================================================ +// REST API endpoints for video upload and management +// Blocker: BLOCKER-003 (ST4.3) +// ============================================================================ + +import { Request, Response, NextFunction } from 'express'; +import { videoService } from '../services/video.service'; +import { logger } from '../../../shared/utils/logger'; + +// ============================================================================ +// POST /api/v1/education/videos/upload-init +// Initialize video upload (multipart) +// ============================================================================ + +export async function initializeVideoUpload( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const userId = (req as any).user?.id; + + if (!userId) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + const { courseId, lessonId, filename, fileSize, contentType, metadata } = req.body; + + // Validation + if (!courseId || !filename || !fileSize || !contentType || !metadata) { + res.status(400).json({ + error: 'Missing required fields: courseId, filename, fileSize, contentType, metadata', + }); + return; + } + + if (!metadata.title || !metadata.description) { + res.status(400).json({ + error: 'Missing required metadata fields: title, description', + }); + return; + } + + // Validate file size (max 2GB) + const maxSize = 2 * 1024 * 1024 * 1024; // 2GB + if (fileSize > maxSize) { + res.status(400).json({ + error: `File too large. Maximum size: ${maxSize / (1024 * 1024 * 1024)}GB`, + }); + return; + } + + // Validate content type + const allowedTypes = ['video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo']; + if (!allowedTypes.includes(contentType)) { + res.status(400).json({ + error: `Invalid content type. Allowed: ${allowedTypes.join(', ')}`, + }); + return; + } + + const result = await videoService.initializeUpload(userId, { + courseId, + lessonId, + filename, + fileSize, + contentType, + metadata, + }); + + res.status(201).json({ + success: true, + data: result, + }); + } catch (error) { + logger.error('Video upload init error', { error }); + next(error); + } +} + +// ============================================================================ +// POST /api/v1/education/videos/:videoId/complete +// Complete multipart upload +// ============================================================================ + +export async function completeVideoUpload( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const userId = (req as any).user?.id; + + if (!userId) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + const { videoId } = req.params; + const { parts } = req.body; + + if (!parts || !Array.isArray(parts) || parts.length === 0) { + res.status(400).json({ + error: 'Missing or invalid parts array', + }); + return; + } + + // Validate parts structure + for (const part of parts) { + if (!part.partNumber || !part.etag) { + res.status(400).json({ + error: 'Invalid part structure. Each part must have partNumber and etag', + }); + return; + } + } + + const video = await videoService.completeUpload(videoId, userId, { parts }); + + res.status(200).json({ + success: true, + data: video, + message: 'Upload completed successfully. Video is being processed.', + }); + } catch (error) { + logger.error('Video upload complete error', { error }); + next(error); + } +} + +// ============================================================================ +// POST /api/v1/education/videos/:videoId/abort +// Abort multipart upload +// ============================================================================ + +export async function abortVideoUpload( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const userId = (req as any).user?.id; + + if (!userId) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + const { videoId } = req.params; + + await videoService.abortUpload(videoId, userId); + + res.status(200).json({ + success: true, + message: 'Upload aborted successfully', + }); + } catch (error) { + logger.error('Video upload abort error', { error }); + next(error); + } +} + +// ============================================================================ +// GET /api/v1/education/videos/:videoId +// Get video details +// ============================================================================ + +export async function getVideo( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const { videoId } = req.params; + + const video = await videoService.getVideoById(videoId); + + res.status(200).json({ + success: true, + data: video, + }); + } catch (error) { + logger.error('Get video error', { error }); + next(error); + } +} + +// ============================================================================ +// GET /api/v1/education/courses/:courseId/videos +// Get all videos for a course +// ============================================================================ + +export async function getCourseVideos( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const { courseId } = req.params; + const userId = (req as any).user?.id; + + const videos = await videoService.getVideosByCourse(courseId, userId); + + res.status(200).json({ + success: true, + data: videos, + }); + } catch (error) { + logger.error('Get course videos error', { error }); + next(error); + } +} + +// ============================================================================ +// GET /api/v1/education/lessons/:lessonId/videos +// Get all videos for a lesson +// ============================================================================ + +export async function getLessonVideos( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const { lessonId } = req.params; + + const videos = await videoService.getVideosByLesson(lessonId); + + res.status(200).json({ + success: true, + data: videos, + }); + } catch (error) { + logger.error('Get lesson videos error', { error }); + next(error); + } +} + +// ============================================================================ +// PATCH /api/v1/education/videos/:videoId +// Update video metadata +// ============================================================================ + +export async function updateVideo( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const userId = (req as any).user?.id; + + if (!userId) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + const { videoId } = req.params; + const { title, description, metadata } = req.body; + + const video = await videoService.updateVideo(videoId, userId, { + title, + description, + metadata, + }); + + res.status(200).json({ + success: true, + data: video, + }); + } catch (error) { + logger.error('Update video error', { error }); + next(error); + } +} + +// ============================================================================ +// DELETE /api/v1/education/videos/:videoId +// Delete video (soft delete) +// ============================================================================ + +export async function deleteVideo( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const userId = (req as any).user?.id; + + if (!userId) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + const { videoId } = req.params; + + await videoService.deleteVideo(videoId, userId); + + res.status(200).json({ + success: true, + message: 'Video deleted successfully', + }); + } catch (error) { + logger.error('Delete video error', { error }); + next(error); + } +} + +// ============================================================================ +// POST /api/v1/education/videos/:videoId/processing-status +// Update video processing status (internal endpoint for processing service) +// ============================================================================ + +export async function updateProcessingStatus( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + // TODO: Add internal service authentication + // For now, this endpoint should only be called by the processing service + + const { videoId } = req.params; + const { status, durationSeconds, cdnUrl, thumbnailUrl, transcodedVersions, error } = req.body; + + if (!status || !['processing', 'ready', 'error'].includes(status)) { + res.status(400).json({ + error: 'Invalid status. Must be: processing, ready, or error', + }); + return; + } + + await videoService.updateProcessingStatus(videoId, status, { + durationSeconds, + cdnUrl, + thumbnailUrl, + transcodedVersions, + error, + }); + + res.status(200).json({ + success: true, + message: 'Processing status updated', + }); + } catch (error) { + logger.error('Update processing status error', { error }); + next(error); + } +} diff --git a/src/modules/education/education.routes.ts b/src/modules/education/education.routes.ts index d38a91e..3707ff0 100644 --- a/src/modules/education/education.routes.ts +++ b/src/modules/education/education.routes.ts @@ -7,6 +7,7 @@ import { Router, RequestHandler } from 'express'; import * as educationController from './controllers/education.controller'; import * as quizController from './controllers/quiz.controller'; import * as gamificationController from './controllers/gamification.controller'; +import * as videoController from './controllers/video.controller'; import { requireAuth } from '../../core/guards/auth.guard'; const router = Router(); @@ -336,4 +337,66 @@ router.get('/gamification/leaderboard/nearby', authHandler(requireAuth), authHan */ router.get('/gamification/summary', authHandler(requireAuth), authHandler(gamificationController.getGamificationSummary)); +// ============================================================================ +// Video Upload Routes +// ============================================================================ + +/** + * POST /api/v1/education/videos/upload-init + * Initialize video upload (multipart) + * Body: { courseId, lessonId?, filename, fileSize, contentType, metadata } + */ +router.post('/videos/upload-init', authHandler(requireAuth), authHandler(videoController.initializeVideoUpload)); + +/** + * POST /api/v1/education/videos/:videoId/complete + * Complete multipart upload + * Body: { parts: [{ partNumber, etag }] } + */ +router.post('/videos/:videoId/complete', authHandler(requireAuth), authHandler(videoController.completeVideoUpload)); + +/** + * POST /api/v1/education/videos/:videoId/abort + * Abort multipart upload + */ +router.post('/videos/:videoId/abort', authHandler(requireAuth), authHandler(videoController.abortVideoUpload)); + +/** + * GET /api/v1/education/videos/:videoId + * Get video details + */ +router.get('/videos/:videoId', videoController.getVideo); + +/** + * GET /api/v1/education/courses/:courseId/videos + * Get all videos for a course + */ +router.get('/courses/:courseId/videos', videoController.getCourseVideos); + +/** + * GET /api/v1/education/lessons/:lessonId/videos + * Get all videos for a lesson + */ +router.get('/lessons/:lessonId/videos', videoController.getLessonVideos); + +/** + * PATCH /api/v1/education/videos/:videoId + * Update video metadata + * Body: { title?, description?, metadata? } + */ +router.patch('/videos/:videoId', authHandler(requireAuth), authHandler(videoController.updateVideo)); + +/** + * DELETE /api/v1/education/videos/:videoId + * Delete video (soft delete) + */ +router.delete('/videos/:videoId', authHandler(requireAuth), authHandler(videoController.deleteVideo)); + +/** + * POST /api/v1/education/videos/:videoId/processing-status + * Update video processing status (internal endpoint) + * Body: { status, durationSeconds?, cdnUrl?, thumbnailUrl?, transcodedVersions?, error? } + */ +router.post('/videos/:videoId/processing-status', videoController.updateProcessingStatus); + export { router as educationRouter }; diff --git a/src/modules/education/services/video.service.ts b/src/modules/education/services/video.service.ts new file mode 100644 index 0000000..785b178 --- /dev/null +++ b/src/modules/education/services/video.service.ts @@ -0,0 +1,534 @@ +// ============================================================================ +// Trading Platform - Video Service +// ============================================================================ +// Video upload, management, and processing for education module +// Blocker: BLOCKER-003 (ST4.3) +// ============================================================================ + +import { db } from '../../../shared/database'; +import { storageService } from '../../../shared/services/storage.service'; +import { logger } from '../../../shared/utils/logger'; +import type { QueryResult } from 'pg'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface VideoMetadata { + title: string; + description: string; + tags: string[]; + language: string; + difficulty: 'beginner' | 'intermediate' | 'advanced' | 'expert'; + transcript?: string; + captions?: Array<{ language: string; url: string }>; +} + +export interface Video { + id: string; + courseId: string; + lessonId?: string; + uploadedBy: string; + title: string; + description: string; + originalFilename: string; + storageProvider: string; + storageBucket: string; + storageKey: string; + storageRegion?: string; + fileSizeBytes: number; + mimeType: string; + durationSeconds?: number; + status: 'uploading' | 'uploaded' | 'processing' | 'ready' | 'error' | 'deleted'; + processingStartedAt?: Date; + processingCompletedAt?: Date; + processingError?: string; + cdnUrl?: string; + thumbnailUrl?: string; + transcodedVersions?: Array<{ + resolution: string; + storageKey: string; + cdnUrl: string; + fileSizeBytes: number; + }>; + metadata: VideoMetadata; + uploadId?: string; + uploadPartsCompleted: number; + uploadPartsTotal?: number; + uploadProgressPercent: number; + createdAt: Date; + updatedAt: Date; + uploadedAt?: Date; + deletedAt?: Date; +} + +export interface InitUploadRequest { + courseId: string; + lessonId?: string; + filename: string; + fileSize: number; + contentType: string; + metadata: VideoMetadata; +} + +export interface InitUploadResponse { + videoId: string; + uploadId: string; + storageKey: string; + presignedUrls?: string[]; + uploadUrl?: string; +} + +export interface CompleteUploadRequest { + parts: Array<{ + partNumber: number; + etag: string; + }>; +} + +// ============================================================================ +// Video Service Class +// ============================================================================ + +export class VideoService { + /** + * Initialize video upload (create DB record + multipart upload) + */ + async initializeUpload( + userId: string, + data: InitUploadRequest + ): Promise { + const { courseId, lessonId, filename, fileSize, contentType, metadata } = data; + + try { + // Validate course exists and user has access + await this.validateCourseAccess(courseId, userId); + + // Generate storage key + const storageKey = storageService.generateKey('videos', filename); + + // Initialize multipart upload in storage + const { uploadId } = await storageService.initMultipartUpload( + storageKey, + contentType, + { + title: metadata.title, + courseId, + userId, + } + ); + + // Calculate number of parts (5MB each) + const partSize = 5 * 1024 * 1024; // 5MB + const totalParts = Math.ceil(fileSize / partSize); + + // Create video record in database + const result = await db.query