feat(education): Add video upload controller and service (ST4.3.3)

Files created:
- src/modules/education/services/video.service.ts (400+ lines)
- src/modules/education/controllers/video.controller.ts (300+ lines)
- Updated src/modules/education/education.routes.ts

Video Service features:
- initializeUpload(): Create DB record + multipart upload
- completeUpload(): Finalize multipart upload
- abortUpload(): Cancel upload
- getVideoById(), getVideosByCourse(), getVideosByLesson()
- updateVideo(): Update metadata
- deleteVideo(): Soft delete
- updateProcessingStatus(): For processing service
- validateCourseAccess(): Check permissions

Video Controller endpoints:
- POST /videos/upload-init (auth required)
- POST /videos/:id/complete (auth required)
- POST /videos/:id/abort (auth required)
- GET /videos/:id
- GET /courses/:courseId/videos
- GET /lessons/:lessonId/videos
- PATCH /videos/:id (auth required)
- DELETE /videos/:id (auth required)
- POST /videos/:id/processing-status (internal)

Features:
- Multipart upload support (5MB parts)
- Presigned URLs for client-side upload
- Upload progress tracking
- Validation (file size max 2GB, allowed types)
- Course access validation
- Soft delete support

Blocker: BLOCKER-003 (ST4.3 Video Upload Backend)
Epic: OQI-002

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-26 20:29:40 -06:00
parent d7abb53400
commit 815f3e42eb
3 changed files with 949 additions and 0 deletions

View File

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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);
}
}

View File

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

View File

@ -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<InitUploadResponse> {
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<Video>(
`INSERT INTO education.videos (
course_id, lesson_id, uploaded_by,
title, description, original_filename,
storage_provider, storage_bucket, storage_key, storage_region,
file_size_bytes, mime_type,
status, upload_id, upload_parts_total, metadata
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
RETURNING *`,
[
courseId,
lessonId || null,
userId,
metadata.title,
metadata.description,
filename,
's3', // or 'r2' based on config
process.env.STORAGE_BUCKET || '',
storageKey,
process.env.STORAGE_REGION || 'us-east-1',
fileSize,
contentType,
'uploading',
uploadId,
totalParts,
JSON.stringify(metadata),
]
);
const video = result.rows[0];
// Generate presigned URLs for each part
const presignedUrls: string[] = [];
for (let i = 1; i <= totalParts; i++) {
const url = await storageService.getPresignedUploadUrl({
key: storageKey,
expiresIn: 3600, // 1 hour
contentType,
});
presignedUrls.push(url);
}
logger.info('Video upload initialized', {
videoId: video.id,
uploadId,
totalParts,
fileSize,
});
return {
videoId: video.id,
uploadId,
storageKey,
presignedUrls,
};
} catch (error) {
logger.error('Failed to initialize video upload', { error, userId, data });
throw new Error(
`Video upload initialization failed: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Complete multipart upload
*/
async completeUpload(
videoId: string,
userId: string,
data: CompleteUploadRequest
): Promise<Video> {
try {
// Get video record
const video = await this.getVideoById(videoId);
// Verify ownership
if (video.uploadedBy !== userId) {
throw new Error('Unauthorized: You do not own this video');
}
// Verify status
if (video.status !== 'uploading') {
throw new Error(`Invalid status: Expected 'uploading', got '${video.status}'`);
}
// Complete multipart upload in storage
await storageService.completeMultipartUpload(
video.storageKey,
video.uploadId!,
data.parts.map((p) => ({
PartNumber: p.partNumber,
ETag: p.etag,
}))
);
// Update video status
const result = await db.query<Video>(
`UPDATE education.videos
SET status = 'uploaded',
uploaded_at = NOW(),
upload_progress_percent = 100,
updated_at = NOW()
WHERE id = $1
RETURNING *`,
[videoId]
);
const updatedVideo = result.rows[0];
logger.info('Video upload completed', {
videoId,
storageKey: video.storageKey,
});
// TODO: Trigger video processing job (transcoding, thumbnail generation)
// await this.triggerVideoProcessing(videoId);
return updatedVideo;
} catch (error) {
logger.error('Failed to complete video upload', { error, videoId });
// Update video status to error
await db.query(
`UPDATE education.videos
SET status = 'error',
processing_error = $1,
updated_at = NOW()
WHERE id = $2`,
[error instanceof Error ? error.message : 'Upload completion failed', videoId]
);
throw new Error(
`Video upload completion failed: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Abort multipart upload
*/
async abortUpload(videoId: string, userId: string): Promise<void> {
try {
const video = await this.getVideoById(videoId);
// Verify ownership
if (video.uploadedBy !== userId) {
throw new Error('Unauthorized: You do not own this video');
}
// Abort multipart upload in storage
if (video.uploadId) {
await storageService.abortMultipartUpload(video.storageKey, video.uploadId);
}
// Soft delete video record
await db.query(
`UPDATE education.videos
SET status = 'deleted',
deleted_at = NOW(),
updated_at = NOW()
WHERE id = $1`,
[videoId]
);
logger.info('Video upload aborted', { videoId });
} catch (error) {
logger.error('Failed to abort video upload', { error, videoId });
throw new Error(
`Video upload abort failed: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Get video by ID
*/
async getVideoById(videoId: string): Promise<Video> {
const result = await db.query<Video>(
`SELECT * FROM education.videos WHERE id = $1 AND deleted_at IS NULL`,
[videoId]
);
if (result.rows.length === 0) {
throw new Error('Video not found');
}
return result.rows[0];
}
/**
* Get videos for a course
*/
async getVideosByCourse(courseId: string, userId?: string): Promise<Video[]> {
// If userId provided, verify access
if (userId) {
await this.validateCourseAccess(courseId, userId);
}
const result = await db.query<Video>(
`SELECT * FROM education.videos
WHERE course_id = $1 AND deleted_at IS NULL
ORDER BY created_at DESC`,
[courseId]
);
return result.rows;
}
/**
* Get videos for a lesson
*/
async getVideosByLesson(lessonId: string): Promise<Video[]> {
const result = await db.query<Video>(
`SELECT * FROM education.videos
WHERE lesson_id = $1 AND deleted_at IS NULL
ORDER BY created_at DESC`,
[lessonId]
);
return result.rows;
}
/**
* Update video metadata
*/
async updateVideo(
videoId: string,
userId: string,
updates: Partial<Pick<Video, 'title' | 'description' | 'metadata'>>
): Promise<Video> {
try {
const video = await this.getVideoById(videoId);
// Verify ownership
if (video.uploadedBy !== userId) {
throw new Error('Unauthorized: You do not own this video');
}
const fields: string[] = [];
const values: any[] = [];
let paramIndex = 1;
if (updates.title !== undefined) {
fields.push(`title = $${paramIndex++}`);
values.push(updates.title);
}
if (updates.description !== undefined) {
fields.push(`description = $${paramIndex++}`);
values.push(updates.description);
}
if (updates.metadata !== undefined) {
fields.push(`metadata = $${paramIndex++}`);
values.push(JSON.stringify(updates.metadata));
}
if (fields.length === 0) {
return video; // No updates
}
fields.push(`updated_at = NOW()`);
values.push(videoId);
const result = await db.query<Video>(
`UPDATE education.videos
SET ${fields.join(', ')}
WHERE id = $${paramIndex}
RETURNING *`,
values
);
logger.info('Video updated', { videoId });
return result.rows[0];
} catch (error) {
logger.error('Failed to update video', { error, videoId });
throw new Error(
`Video update failed: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Delete video (soft delete)
*/
async deleteVideo(videoId: string, userId: string): Promise<void> {
try {
const video = await this.getVideoById(videoId);
// Verify ownership or admin
if (video.uploadedBy !== userId) {
// TODO: Check if user is admin
throw new Error('Unauthorized: You do not own this video');
}
// Soft delete
await db.query(
`SELECT education.soft_delete_video($1)`,
[videoId]
);
logger.info('Video deleted', { videoId });
// TODO: Schedule storage cleanup job (delete files from S3/R2)
} catch (error) {
logger.error('Failed to delete video', { error, videoId });
throw new Error(
`Video deletion failed: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Update video processing status
*/
async updateProcessingStatus(
videoId: string,
status: 'processing' | 'ready' | 'error',
data?: {
durationSeconds?: number;
cdnUrl?: string;
thumbnailUrl?: string;
transcodedVersions?: Video['transcodedVersions'];
error?: string;
}
): Promise<void> {
try {
const fields: string[] = [`status = $1`];
const values: any[] = [status];
let paramIndex = 2;
if (status === 'processing') {
fields.push(`processing_started_at = NOW()`);
} else if (status === 'ready') {
fields.push(`processing_completed_at = NOW()`);
}
if (data?.durationSeconds !== undefined) {
fields.push(`duration_seconds = $${paramIndex++}`);
values.push(data.durationSeconds);
}
if (data?.cdnUrl) {
fields.push(`cdn_url = $${paramIndex++}`);
values.push(data.cdnUrl);
}
if (data?.thumbnailUrl) {
fields.push(`thumbnail_url = $${paramIndex++}`);
values.push(data.thumbnailUrl);
}
if (data?.transcodedVersions) {
fields.push(`transcoded_versions = $${paramIndex++}`);
values.push(JSON.stringify(data.transcodedVersions));
}
if (data?.error) {
fields.push(`processing_error = $${paramIndex++}`);
values.push(data.error);
}
fields.push(`updated_at = NOW()`);
values.push(videoId);
await db.query(
`UPDATE education.videos
SET ${fields.join(', ')}
WHERE id = $${paramIndex}`,
values
);
logger.info('Video processing status updated', { videoId, status });
} catch (error) {
logger.error('Failed to update video processing status', { error, videoId });
throw error;
}
}
/**
* Validate course access (user is instructor or enrolled)
*/
private async validateCourseAccess(courseId: string, userId: string): Promise<void> {
const result = await db.query(
`SELECT EXISTS(
SELECT 1 FROM education.courses
WHERE id = $1 AND (
instructor_id = $2 OR
EXISTS(
SELECT 1 FROM education.enrollments
WHERE course_id = $1 AND user_id = $2 AND status = 'active'
)
)
) as has_access`,
[courseId, userId]
);
if (!result.rows[0].has_access) {
throw new Error('Access denied: You do not have access to this course');
}
}
}
// ============================================================================
// Default Instance
// ============================================================================
export const videoService = new VideoService();