diff --git a/docs/02-definicion-modulos/OQI-002-education/especificaciones/ET-EDU-008-video-upload-multipart.md b/docs/02-definicion-modulos/OQI-002-education/especificaciones/ET-EDU-008-video-upload-multipart.md new file mode 100644 index 0000000..3b59c52 --- /dev/null +++ b/docs/02-definicion-modulos/OQI-002-education/especificaciones/ET-EDU-008-video-upload-multipart.md @@ -0,0 +1,1142 @@ +--- +id: "ET-EDU-008" +title: "Multipart Video Upload System" +epic: "OQI-002" +type: "Especificacion Tecnica" +status: "implemented" +priority: "P0" +blocker: "BLOCKER-003" +version: "1.0.0" +created: "2026-01-26" +updated: "2026-01-26" +--- + +# ET-EDU-008: Multipart Video Upload System + +**Epic:** OQI-002 - Módulo Educativo +**Blocker:** BLOCKER-003 (ST4.3) +**Prioridad:** P0 - CRÍTICO +**Estado:** ✅ Implemented (MVP - 89% complete) + +--- + +## Resumen Ejecutivo + +Sistema completo de carga de videos educativos usando multipart upload a S3/R2 con backend Node.js y frontend React. Soporta archivos de hasta 2GB con upload directo al storage usando presigned URLs, procesamiento asíncrono (transcoding, thumbnails), y tracking completo del progreso. + +**Características Principales:** +- ✅ Multipart upload (5MB parts) para videos grandes +- ✅ Upload directo a S3/R2 (no pasa por backend) +- ✅ Presigned URLs con expiración de 1 hora +- ✅ Upload paralelo (max 3 parts simultáneos) +- ✅ Progress tracking en tiempo real +- ✅ Metadata completo (título, descripción, tags, dificultad) +- ⚠️ Video processing (MVP - mock implementation) +- 🔄 Future: FFmpeg/MediaConvert/Cloudflare Stream + +--- + +## Arquitectura General + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ VIDEO UPLOAD FLOW │ +└──────────────────────────────────────────────────────────────────┘ + +┌─────────────┐ ┌─────────┐ +│ Browser │ │ S3/R2 │ +│ (React) │ │ Storage │ +└──────┬──────┘ └────┬────┘ + │ │ + │ 1. POST /videos/upload-init │ + │ {courseId, metadata, fileSize} │ + │ ──────────────────────────────────────┐ │ + │ │ │ + │ ┌────────────▼──────────┐ │ + │ │ Backend (Express.js) │ │ + │ │ video.controller.ts │ │ + │ └────────────┬──────────┘ │ + │ │ │ + │ ┌────────────▼──────────┐ │ + │ │ video.service.ts │ │ + │ │ - Validate course │ │ + │ │ - Generate key │ │ + │ │ - Calculate parts │ │ + │ └────────────┬──────────┘ │ + │ │ │ + │ ┌────────────▼──────────┐ │ + │ │ storage.service.ts │ │ + │ │ - Init multipart │ │ + │ ◄────────────────────────│ - Create presigned │ │ + │ {videoId, uploadId, │ URLs (1 per part) │ │ + │ presignedUrls[]} └────────────┬──────────┘ │ + │ │ │ + │ ┌────────────▼──────────┐ │ + │ │ PostgreSQL DB │ │ + │ │ INSERT video record │ │ + │ │ status='uploading' │ │ + │ └───────────────────────┘ │ + │ │ + │ 2. Split file into 5MB parts │ + │ Upload directly to S3/R2 │ + │ ─────────────────────────────────────────────────────▶ + │ │ + │ ◄───────────────────────────────────────────────────── + │ ETag for each part │ + │ │ + │ 3. POST /videos/:id/complete │ + │ {parts: [{partNumber, etag}]} │ + │ ──────────────────────────────────────┐ │ + │ │ │ + │ ┌────────────▼──────────┐ │ + │ │ Backend │ │ + │ │ - Verify ownership │ │ + │ │ - Complete multipart │ │ + │ │ - Update DB status │ │ + │ │ - Trigger processing │──┐ + │ └────────────┬──────────┘ │ + │ │ │ + │ ◄──────────────────────── │ │ + │ {video, status='uploaded'} │ │ + │ │ │ + │ ▼ │ + │ ┌────────────────────────┐ │ + │ │ video-processing.service│ │ + │ │ (Future: FFmpeg/Cloud) │ │ + │ │ - Transcode resolutions│ │ + │ │ - Generate thumbnail │ │ + │ │ - Extract metadata │ │ + │ └────────────────────────┘ │ + └──────────────────────────────────────────────────────┘ +``` + +--- + +## 1. Database Schema + +### Table: `education.videos` + +```sql +CREATE TABLE education.videos ( + -- Identity + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + course_id UUID NOT NULL REFERENCES education.courses(id), + lesson_id UUID REFERENCES education.lessons(id), + uploaded_by UUID NOT NULL REFERENCES core.users(id), + + -- Video Info + title VARCHAR(200) NOT NULL, + description TEXT, + original_filename VARCHAR(255) NOT NULL, + + -- Storage + storage_provider VARCHAR(50) DEFAULT 's3', -- 's3' or 'r2' + storage_bucket VARCHAR(200) NOT NULL, + storage_key VARCHAR(500) NOT NULL UNIQUE, + storage_region VARCHAR(50), + + -- File Properties + file_size_bytes BIGINT NOT NULL, + mime_type VARCHAR(100) NOT NULL, + duration_seconds INTEGER, + + -- Upload Status + status VARCHAR(50) DEFAULT 'uploading', + -- 'uploading', 'uploaded', 'processing', 'ready', 'error', 'deleted' + upload_id VARCHAR(500), -- S3 multipart upload ID + upload_parts_completed INTEGER DEFAULT 0, + upload_parts_total INTEGER, + upload_progress_percent INTEGER DEFAULT 0, + + -- Processing Status + processing_started_at TIMESTAMPTZ, + processing_completed_at TIMESTAMPTZ, + processing_error TEXT, + + -- CDN & Variants + cdn_url VARCHAR(1000), -- Primary video URL + thumbnail_url VARCHAR(1000), -- Thumbnail image + transcoded_versions JSONB, -- Array of {resolution, url, size} + + -- Metadata + metadata JSONB NOT NULL DEFAULT '{}', + -- {title, description, tags[], language, difficulty, transcript, captions[]} + + -- Timestamps + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + uploaded_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, + + -- Constraints + CONSTRAINT videos_status_check CHECK (status IN ( + 'uploading', 'uploaded', 'processing', 'ready', 'error', 'deleted' + )), + CONSTRAINT videos_progress_check CHECK ( + upload_progress_percent >= 0 AND upload_progress_percent <= 100 + ) +); + +-- Indexes +CREATE INDEX idx_videos_course ON education.videos(course_id) + WHERE deleted_at IS NULL; + +CREATE INDEX idx_videos_lesson ON education.videos(lesson_id) + WHERE deleted_at IS NULL; + +CREATE INDEX idx_videos_status ON education.videos(status) + WHERE deleted_at IS NULL; + +CREATE INDEX idx_videos_metadata ON education.videos USING GIN(metadata); + +-- Soft Delete Function +CREATE OR REPLACE FUNCTION education.soft_delete_video(video_id UUID) +RETURNS VOID AS $$ +BEGIN + UPDATE education.videos + SET status = 'deleted', + deleted_at = NOW(), + updated_at = NOW() + WHERE id = video_id; +END; +$$ LANGUAGE plpgsql; +``` + +**Metadata JSONB Structure:** +```typescript +interface VideoMetadata { + title: string; + description: string; + tags: string[]; // e.g., ["trading", "technical-analysis"] + language: string; // e.g., "en", "es" + difficulty: 'beginner' | 'intermediate' | 'advanced' | 'expert'; + transcript?: string; // Full text transcript + captions?: { // Multi-language captions + language: string; + url: string; // VTT/SRT file URL + }[]; +} +``` + +**Transcoded Versions JSONB Structure:** +```typescript +interface TranscodedVersion { + resolution: string; // "1080p", "720p", "480p" + storageKey: string; // S3 key for transcoded file + cdnUrl: string; // Public CDN URL + fileSizeBytes: number; // File size in bytes + width: number; // Video width (e.g., 1920) + height: number; // Video height (e.g., 1080) +} +``` + +--- + +## 2. Backend Implementation + +### 2.1 Storage Service + +**File:** `apps/backend/src/shared/services/storage.service.ts` + +```typescript +export class StorageService { + private client: S3Client; + + constructor() { + this.client = new S3Client({ + region: process.env.STORAGE_REGION || 'us-east-1', + endpoint: process.env.STORAGE_ENDPOINT, // For R2 + credentials: { + accessKeyId: process.env.STORAGE_ACCESS_KEY!, + secretAccessKey: process.env.STORAGE_SECRET_KEY!, + }, + }); + } + + // Initialize multipart upload + async initMultipartUpload( + key: string, + contentType?: string, + metadata?: Record + ): Promise<{ uploadId: string; key: string }> { + const command = new CreateMultipartUploadCommand({ + Bucket: this.config.bucket, + Key: key, + ContentType: contentType, + Metadata: metadata, + }); + + const response = await this.client.send(command); + return { uploadId: response.UploadId!, key }; + } + + // Get presigned URL for part upload + async getPresignedUploadUrl(options: { + key: string; + expiresIn: number; + contentType?: string; + }): Promise { + const command = new PutObjectCommand({ + Bucket: this.config.bucket, + Key: options.key, + ContentType: options.contentType, + }); + + return await getSignedUrl(this.client, command, { + expiresIn: options.expiresIn, + }); + } + + // Complete multipart upload + async completeMultipartUpload( + key: string, + uploadId: string, + parts: CompletedPart[] + ): Promise<{ key: string; url: string }> { + const command = new CompleteMultipartUploadCommand({ + Bucket: this.config.bucket, + Key: key, + UploadId: uploadId, + MultipartUpload: { Parts: parts }, + }); + + await this.client.send(command); + return { key, url: this.getPublicUrl(key) }; + } + + // Abort multipart upload (cleanup) + async abortMultipartUpload(key: string, uploadId: string): Promise { + const command = new AbortMultipartUploadCommand({ + Bucket: this.config.bucket, + Key: key, + UploadId: uploadId, + }); + + await this.client.send(command); + } +} +``` + +**Key Concepts:** +- **Multipart Upload:** Split large files into parts (5MB each) +- **Presigned URLs:** Client uploads directly to S3/R2 without backend proxy +- **Upload ID:** Unique identifier for multipart upload session +- **ETags:** S3 returns ETag for each part (required for completion) + +### 2.2 Video Service + +**File:** `apps/backend/src/modules/education/services/video.service.ts` + +```typescript +export class VideoService { + async initializeUpload( + userId: string, + data: InitUploadRequest + ): Promise { + // 1. Validate course access + await this.validateCourseAccess(data.courseId, userId); + + // 2. Generate storage key + const storageKey = storageService.generateKey('videos', data.filename); + + // 3. Initialize multipart upload + const { uploadId } = await storageService.initMultipartUpload( + storageKey, + data.contentType, + { title: data.metadata.title, courseId: data.courseId, userId } + ); + + // 4. Calculate number of parts (5MB each) + const PART_SIZE = 5 * 1024 * 1024; + const totalParts = Math.ceil(data.fileSize / PART_SIZE); + + // 5. Create video record in database + const video = await db.query