diff --git a/ddl/schemas/education/tables/15-videos.sql b/ddl/schemas/education/tables/15-videos.sql new file mode 100644 index 0000000..e8aa1dd --- /dev/null +++ b/ddl/schemas/education/tables/15-videos.sql @@ -0,0 +1,150 @@ +-- ===================================================== +-- TABLE: education.videos +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: ET-EDU-008-video-upload-architecture.md +-- Blocker: BLOCKER-003 (ST4.3) +-- ===================================================== + +CREATE TABLE education.videos ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Relaciones + course_id UUID NOT NULL REFERENCES education.courses(id) ON DELETE CASCADE, + lesson_id UUID REFERENCES education.lessons(id) ON DELETE SET NULL, + uploaded_by UUID NOT NULL REFERENCES core.users(id) ON DELETE RESTRICT, + + -- Información básica + title VARCHAR(200) NOT NULL, + description TEXT, + original_filename VARCHAR(500) NOT NULL, + + -- Storage (S3/R2) + storage_provider VARCHAR(50) NOT NULL DEFAULT 's3', -- 's3', 'r2', 'cloudflare_stream' + storage_bucket VARCHAR(200) NOT NULL, + storage_key VARCHAR(500) NOT NULL, -- S3/R2 key (path) + storage_region VARCHAR(50), + + -- File info + file_size_bytes BIGINT NOT NULL, + mime_type VARCHAR(100) NOT NULL DEFAULT 'video/mp4', + duration_seconds INTEGER, -- Detectado después de upload + + -- Status & Processing + status VARCHAR(50) NOT NULL DEFAULT 'uploading', + -- 'uploading', 'uploaded', 'processing', 'ready', 'error', 'deleted' + processing_started_at TIMESTAMPTZ, + processing_completed_at TIMESTAMPTZ, + processing_error TEXT, + + -- CDN & URLs + cdn_url VARCHAR(1000), -- URL pública del video (CDN) + thumbnail_url VARCHAR(1000), + + -- Transcoded versions (múltiples resoluciones) + transcoded_versions JSONB, + -- Ejemplo: [ + -- {resolution: "1080p", storage_key: "...", cdn_url: "...", file_size_bytes: 123456}, + -- {resolution: "720p", storage_key: "...", cdn_url: "...", file_size_bytes: 67890}, + -- {resolution: "480p", storage_key: "...", cdn_url: "...", file_size_bytes: 34567} + -- ] + + -- Metadata educativo + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + -- Ejemplo: { + -- "tags": ["trading", "stocks", "technical-analysis"], + -- "language": "en", + -- "difficulty": "intermediate", + -- "captions": [{language: "en", url: "...srt"}, {language: "es", url: "...srt"}], + -- "transcript": "Full text transcript...", + -- "video_codec": "h264", + -- "audio_codec": "aac", + -- "bitrate_kbps": 5000, + -- "fps": 30, + -- "resolution": "1920x1080" + -- } + + -- Multipart upload tracking + upload_id VARCHAR(500), -- AWS/R2 multipart upload ID + upload_parts_completed INTEGER DEFAULT 0, + upload_parts_total INTEGER, + upload_progress_percent INTEGER DEFAULT 0, + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + uploaded_at TIMESTAMPTZ, -- Cuando se completó el upload + deleted_at TIMESTAMPTZ, -- Soft delete + + -- Constraints + CONSTRAINT valid_status CHECK (status IN ('uploading', 'uploaded', 'processing', 'ready', 'error', 'deleted')), + CONSTRAINT valid_storage_provider CHECK (storage_provider IN ('s3', 'r2', 'cloudflare_stream')), + CONSTRAINT valid_progress CHECK (upload_progress_percent >= 0 AND upload_progress_percent <= 100), + CONSTRAINT positive_duration CHECK (duration_seconds IS NULL OR duration_seconds > 0), + CONSTRAINT positive_file_size CHECK (file_size_bytes > 0) +); + +-- Índices +CREATE INDEX idx_videos_course ON education.videos(course_id); +CREATE INDEX idx_videos_lesson ON education.videos(lesson_id); +CREATE INDEX idx_videos_uploader ON education.videos(uploaded_by); +CREATE INDEX idx_videos_status ON education.videos(status); +CREATE INDEX idx_videos_created ON education.videos(created_at DESC); +CREATE INDEX idx_videos_storage_key ON education.videos(storage_key); + +-- Índice GIN para búsqueda en metadata (tags, language, etc.) +CREATE INDEX idx_videos_metadata ON education.videos USING GIN (metadata jsonb_path_ops); + +-- Índice para soft delete (excluir deleted) +CREATE INDEX idx_videos_active ON education.videos(id) WHERE deleted_at IS NULL; + +-- Índice compuesto para queries frecuentes +CREATE INDEX idx_videos_course_status ON education.videos(course_id, status) WHERE deleted_at IS NULL; + +-- Comentarios +COMMENT ON TABLE education.videos IS 'Videos educativos con soporte de multipart upload, transcoding y CDN'; +COMMENT ON COLUMN education.videos.storage_key IS 'S3/R2 object key (ruta completa del archivo)'; +COMMENT ON COLUMN education.videos.status IS 'uploading: En proceso de upload | uploaded: Upload completo | processing: Transcoding en progreso | ready: Listo para uso | error: Falló processing | deleted: Soft deleted'; +COMMENT ON COLUMN education.videos.transcoded_versions IS 'Array de versiones transcodificadas en diferentes resoluciones (1080p, 720p, 480p, etc.)'; +COMMENT ON COLUMN education.videos.metadata IS 'Metadata educativo: tags, language, difficulty, captions, transcript, codecs, etc.'; +COMMENT ON COLUMN education.videos.upload_id IS 'AWS/R2 multipart upload ID para tracking de upload en progreso'; +COMMENT ON COLUMN education.videos.cdn_url IS 'URL pública del video servido desde CDN (CloudFront/Cloudflare)'; +COMMENT ON COLUMN education.videos.thumbnail_url IS 'URL del thumbnail generado automáticamente del video'; +COMMENT ON COLUMN education.videos.deleted_at IS 'Soft delete: NULL = activo, NOT NULL = eliminado'; + +-- Función para actualizar updated_at automáticamente +CREATE OR REPLACE FUNCTION education.update_videos_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_update_videos_updated_at + BEFORE UPDATE ON education.videos + FOR EACH ROW + EXECUTE FUNCTION education.update_videos_updated_at(); + +-- Función helper para soft delete +CREATE OR REPLACE FUNCTION education.soft_delete_video(video_uuid UUID) +RETURNS VOID AS $$ +BEGIN + UPDATE education.videos + SET + deleted_at = NOW(), + status = 'deleted', + updated_at = NOW() + WHERE id = video_uuid AND deleted_at IS NULL; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION education.soft_delete_video IS 'Soft delete de un video (marca deleted_at en lugar de eliminar el registro)'; + +-- View para videos activos (excluye soft deleted) +CREATE OR REPLACE VIEW education.active_videos AS +SELECT * FROM education.videos +WHERE deleted_at IS NULL; + +COMMENT ON VIEW education.active_videos IS 'Vista de videos activos (excluye soft deleted)';