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:
parent
d7abb53400
commit
815f3e42eb
352
src/modules/education/controllers/video.controller.ts
Normal file
352
src/modules/education/controllers/video.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ import { Router, RequestHandler } from 'express';
|
|||||||
import * as educationController from './controllers/education.controller';
|
import * as educationController from './controllers/education.controller';
|
||||||
import * as quizController from './controllers/quiz.controller';
|
import * as quizController from './controllers/quiz.controller';
|
||||||
import * as gamificationController from './controllers/gamification.controller';
|
import * as gamificationController from './controllers/gamification.controller';
|
||||||
|
import * as videoController from './controllers/video.controller';
|
||||||
import { requireAuth } from '../../core/guards/auth.guard';
|
import { requireAuth } from '../../core/guards/auth.guard';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@ -336,4 +337,66 @@ router.get('/gamification/leaderboard/nearby', authHandler(requireAuth), authHan
|
|||||||
*/
|
*/
|
||||||
router.get('/gamification/summary', authHandler(requireAuth), authHandler(gamificationController.getGamificationSummary));
|
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 };
|
export { router as educationRouter };
|
||||||
|
|||||||
534
src/modules/education/services/video.service.ts
Normal file
534
src/modules/education/services/video.service.ts
Normal 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();
|
||||||
Loading…
Reference in New Issue
Block a user