diff --git a/Cargo.lock b/Cargo.lock index 154c621..6d5125f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -840,6 +840,7 @@ dependencies = [ "tower_governor", "tracing", "tracing-subscriber", + "utoipa", "uuid", "zip", ] diff --git a/services/cms-service/Cargo.toml b/services/cms-service/Cargo.toml index 4021874..144575d 100644 --- a/services/cms-service/Cargo.toml +++ b/services/cms-service/Cargo.toml @@ -28,6 +28,7 @@ openidconnect.workspace = true anyhow.workspace = true thiserror.workspace = true http.workspace = true +utoipa.workspace = true zip = "0.6" mime_guess = "2.0" base64 = "0.22.1" diff --git a/services/cms-service/migrations/20231219000000_initial_schema.sql b/services/cms-service/migrations/20231219000000_initial_schema.sql index 420f88c..e898029 100644 --- a/services/cms-service/migrations/20231219000000_initial_schema.sql +++ b/services/cms-service/migrations/20231219000000_initial_schema.sql @@ -47,4 +47,4 @@ BEGIN END; $$ language 'plpgsql'; -CREATE TRIGGER update_courses_updated_at BEFORE UPDATE ON courses FOR EACH ROW EXECUTE PROCEDURE update_updated_at_column(); +CREATE TRIGGER update_courses_updated_at BEFORE UPDATE ON courses FOR EACH ROW EXECUTE PROCEDURE update_updated_at_column(); \ No newline at end of file diff --git a/services/cms-service/migrations/20260427000000_add_external_sam_id_to_courses.sql b/services/cms-service/migrations/20260427000000_add_external_sam_id_to_courses.sql new file mode 100644 index 0000000..e7c32de --- /dev/null +++ b/services/cms-service/migrations/20260427000000_add_external_sam_id_to_courses.sql @@ -0,0 +1,6 @@ +ALTER TABLE courses +ADD COLUMN IF NOT EXISTS external_sam_id BIGINT; + +CREATE INDEX IF NOT EXISTS idx_courses_org_external_sam_id +ON courses (organization_id, external_sam_id) +WHERE external_sam_id IS NOT NULL; diff --git a/services/cms-service/src/external_handlers.rs b/services/cms-service/src/external_handlers.rs index f137865..6100f25 100644 --- a/services/cms-service/src/external_handlers.rs +++ b/services/cms-service/src/external_handlers.rs @@ -42,10 +42,11 @@ pub async fn create_course_external( let description = payload.description.as_deref(); let course = sqlx::query_as::<_, Course>( - "INSERT INTO courses (organization_id, title, description, instructor_id, pacing_mode) - VALUES ($1, $2, $3, '00000000-0000-0000-0000-000000000001', $4) RETURNING *" + "INSERT INTO courses (organization_id, external_sam_id, title, description, instructor_id, pacing_mode) + VALUES ($1, $2, $3, $4, '00000000-0000-0000-0000-000000000001', $5) RETURNING *" ) .bind(org_id) + .bind(payload.external_sam_id) .bind(title) .bind(description) .bind(payload.pacing_mode.as_deref().unwrap_or("self_paced")) @@ -94,6 +95,8 @@ pub struct ExternalCreateCoursePayload { pub title: String, pub description: Option, pub pacing_mode: Option, + #[serde(alias = "idcursoabierto", alias = "id_curso_abierto")] + pub external_sam_id: Option, // Selección directa de plantilla opcional pub template_id: Option, // Selección de respaldo (fallback) opcional por nivel/tipo de curso/tipo de test diff --git a/services/cms-service/src/handlers_sam.rs b/services/cms-service/src/handlers_sam.rs index dad7544..196e7c2 100644 --- a/services/cms-service/src/handlers_sam.rs +++ b/services/cms-service/src/handlers_sam.rs @@ -232,10 +232,9 @@ pub async fn sync_sam_assignments( // Obtener el ID del curso de OpenCCB a partir del ID del curso SAM // Esto asume que tienes una tabla de mapeo o que el ID del curso SAM coincide con el external_id del curso en OpenCCB let course_result = sqlx::query_as::<_, (Uuid,)>( - "SELECT id FROM courses WHERE external_sam_id = $1 OR id = $2" + "SELECT id FROM courses WHERE external_sam_id = $1" ) .bind(assignment.id_curso_abierto as i64) - .bind(assignment.id_curso_abierto) .fetch_optional(&pool) .await; @@ -484,10 +483,9 @@ pub async fn sync_all_sam( for assignment in sam_assignments { let course_result = sqlx::query_as::<_, (Uuid,)>( - "SELECT id FROM courses WHERE external_sam_id = $1 OR id = $2" + "SELECT id FROM courses WHERE external_sam_id = $1" ) .bind(assignment.id_curso_abierto as i64) - .bind(assignment.id_curso_abierto) .fetch_optional(&pool) .await; diff --git a/services/cms-service/src/main.rs b/services/cms-service/src/main.rs index c16a5ba..23d65bc 100644 --- a/services/cms-service/src/main.rs +++ b/services/cms-service/src/main.rs @@ -16,6 +16,7 @@ mod handlers_admin; mod handlers_embeddings; mod handlers_sam; mod handlers_plugins; +mod openapi; mod webhooks; use axum::{ @@ -34,6 +35,7 @@ use std::time::Duration; use tower_http::cors::CorsLayer; use tower_http::set_header::SetResponseHeaderLayer; use tower_http::trace::TraceLayer; +use utoipa::OpenApi; #[tokio::main] async fn main() { @@ -594,7 +596,26 @@ async fn main() { get(handlers_branding::get_organization_branding), ); - let public_routes = Router::new() + let public_routes = Router::new() + .route("/api-docs/openapi.json", get(|| async { + axum::Json(openapi::ApiDoc::openapi()) + })) + .route("/scalar", get(|| async { + axum::response::Html(r#" + + + + OpenCCB CMS API + + + + + + + + + "#) + })) .nest("/api/external", api_routes) .route( "/api/assets/s3-proxy/{bucket}/{*key}", diff --git a/services/cms-service/src/openapi.rs b/services/cms-service/src/openapi.rs new file mode 100644 index 0000000..f801bf1 --- /dev/null +++ b/services/cms-service/src/openapi.rs @@ -0,0 +1,107 @@ +#![allow(dead_code)] + +use utoipa::OpenApi; + +#[derive(utoipa::ToSchema, serde::Serialize, serde::Deserialize)] +pub struct ExternalCreateCoursePayloadSchema { + /// Obligatorio. No debe ser vacío. + pub title: String, + /// Opcional: puede omitirse o enviarse como `null`. + pub description: Option, + /// Opcional: puede omitirse o enviarse como `null`. + pub pacing_mode: Option, + /// Opcional: idCursoAbierto del sistema SAM. + /// También se acepta como `idcursoabierto` o `id_curso_abierto` en el payload real. + pub external_sam_id: Option, + /// Opcional: UUID de plantilla específica. + pub template_id: Option, + /// Opcional: fallback de selección de plantilla por nivel. + pub template_level: Option, + /// Opcional: fallback de selección de plantilla por tipo de curso. + pub template_course_type: Option, + /// Opcional: fallback de selección de plantilla por tipo de test. + pub template_test_type: Option, + /// Opcional: título del módulo creado al aplicar plantilla. + pub module_title: Option, + /// Opcional: título de la lección creada al aplicar plantilla. + pub lesson_title: Option, +} + +#[derive(utoipa::ToSchema, serde::Serialize)] +pub struct ExternalCourseEnvelopeSchema { + pub course: serde_json::Value, + pub template_applied: bool, + pub template_id: Option, + pub lesson_id: Option, +} + +#[derive(OpenApi)] +#[openapi( + info( + title = "OpenCCB CMS API", + version = "1.0.0", + description = "API del CMS para gestión interna y endpoints externos por API key.\n\nReglas de nulabilidad y arreglos (aplican a todos los endpoints de esta API):\n- Campos opcionales (Option): pueden omitirse o enviarse como null.\n- Campos de arreglo no opcionales (Vec): deben enviarse como arreglo; pueden ser [] pero no null.\n- Objetos requeridos del payload: no deben enviarse como null." + ), + paths( + create_course_external, + get_course_external, + trigger_transcription_external, + ), + components( + schemas( + ExternalCreateCoursePayloadSchema, + ExternalCourseEnvelopeSchema, + ) + ), + tags( + (name = "External", description = "Integración externa del CMS") + ) +)] +pub struct ApiDoc; + +#[utoipa::path( + post, + path = "/api/external/v1/courses", + tag = "External", + request_body = ExternalCreateCoursePayloadSchema, + responses( + (status = 200, description = "Curso creado exitosamente"), + (status = 400, description = "Payload inválido o plantilla no encontrada"), + (status = 401, description = "X-API-Key inválida o ausente"), + (status = 500, description = "Error interno del servidor") + ), + security(("ApiKey" = [])) +)] +pub fn create_course_external() {} + +#[utoipa::path( + get, + path = "/api/external/v1/courses/{id}", + tag = "External", + params( + ("id" = String, Path, description = "UUID del curso") + ), + responses( + (status = 200, description = "Curso encontrado"), + (status = 401, description = "X-API-Key inválida o ausente"), + (status = 404, description = "Curso no encontrado") + ), + security(("ApiKey" = [])) +)] +pub fn get_course_external() {} + +#[utoipa::path( + post, + path = "/api/external/v1/lessons/{id}/transcribe", + tag = "External", + params( + ("id" = String, Path, description = "UUID de la lección") + ), + responses( + (status = 202, description = "Transcripción encolada"), + (status = 401, description = "X-API-Key inválida o ausente"), + (status = 404, description = "Lección no encontrada") + ), + security(("ApiKey" = [])) +)] +pub fn trigger_transcription_external() {} diff --git a/services/lms-service/migrations/20260427000000_add_external_sam_id_to_courses.sql b/services/lms-service/migrations/20260427000000_add_external_sam_id_to_courses.sql new file mode 100644 index 0000000..e7c32de --- /dev/null +++ b/services/lms-service/migrations/20260427000000_add_external_sam_id_to_courses.sql @@ -0,0 +1,6 @@ +ALTER TABLE courses +ADD COLUMN IF NOT EXISTS external_sam_id BIGINT; + +CREATE INDEX IF NOT EXISTS idx_courses_org_external_sam_id +ON courses (organization_id, external_sam_id) +WHERE external_sam_id IS NOT NULL; diff --git a/services/lms-service/src/handlers.rs b/services/lms-service/src/handlers.rs index 8078c53..093e017 100644 --- a/services/lms-service/src/handlers.rs +++ b/services/lms-service/src/handlers.rs @@ -1009,8 +1009,8 @@ pub async fn ingest_course( // 2. Insertar o actualizar (Upsert) Curso sqlx::query( - "INSERT INTO courses (id, title, description, instructor_id, start_date, end_date, passing_percentage, certificate_template, updated_at, organization_id, pacing_mode, price, currency) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + "INSERT INTO courses (id, title, description, instructor_id, start_date, end_date, passing_percentage, certificate_template, updated_at, organization_id, pacing_mode, price, currency, external_sam_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) ON CONFLICT (id) DO UPDATE SET title = EXCLUDED.title, description = EXCLUDED.description, @@ -1023,7 +1023,8 @@ pub async fn ingest_course( organization_id = EXCLUDED.organization_id, pacing_mode = EXCLUDED.pacing_mode, price = EXCLUDED.price, - currency = EXCLUDED.currency" + currency = EXCLUDED.currency, + external_sam_id = EXCLUDED.external_sam_id" ) .bind(payload.course.id) .bind(&payload.course.title) @@ -1038,6 +1039,7 @@ pub async fn ingest_course( .bind(&payload.course.pacing_mode) .bind(payload.course.price) .bind(&payload.course.currency) + .bind(payload.course.external_sam_id) .execute(&mut *tx) .await .map_err(|e: sqlx::Error| { diff --git a/services/lms-service/src/main.rs b/services/lms-service/src/main.rs index b8e0a94..b393695 100644 --- a/services/lms-service/src/main.rs +++ b/services/lms-service/src/main.rs @@ -470,7 +470,7 @@ async fn main() { - + diff --git a/services/lms-service/src/openapi.rs b/services/lms-service/src/openapi.rs index 545c22e..f5bb3f0 100644 --- a/services/lms-service/src/openapi.rs +++ b/services/lms-service/src/openapi.rs @@ -50,13 +50,26 @@ pub struct LessonSchema { pub created_at: String, } -/// Módulo de curso +/// Módulo de curso dentro de la ingesta. +/// +/// `lessons` puede venir como `[]` (vacío), pero no debe venir `null`. #[derive(utoipa::ToSchema, serde::Serialize)] -pub struct ModuleSchema { +pub struct IngestModuleSchema { pub id: String, pub title: String, pub position: i32, pub created_at: String, + pub lessons: Vec, +} + +/// Instructor asignado al curso. +#[derive(utoipa::ToSchema, serde::Serialize)] +pub struct CourseInstructorSchema { + pub id: String, + pub user_id: String, + /// Ej: "primary", "instructor", "assistant" + pub role: String, + pub created_at: String, } /// Organización @@ -71,6 +84,7 @@ pub struct OrgSchema { #[derive(utoipa::ToSchema, serde::Serialize)] pub struct CourseSchema { pub id: String, + pub external_sam_id: Option, pub title: String, pub description: Option, pub price: f64, @@ -82,12 +96,17 @@ pub struct CourseSchema { /// Payload completo de ingesta de curso — usado por el sistema externo para crear/actualizar cursos #[derive(utoipa::ToSchema, serde::Serialize)] pub struct IngestCourseRequest { + /// Obligatorio. No debe ser `null`. pub organization: OrgSchema, + /// Obligatorio. No debe ser `null`. pub course: CourseSchema, + /// Puede venir como `[]` (vacío), pero no `null`. pub grading_categories: Vec, - pub modules: Vec, - pub lessons: Vec, - pub instructors: Vec, + /// Puede venir como `[]` (vacío), pero no `null`. + /// Las lecciones se envían dentro de cada módulo en `modules[].lessons`. + pub modules: Vec, + /// Opcional. Puede omitirse o venir como `null` o `[]`. + pub instructors: Option>, } /// Entrada del catálogo Tipo Nota @@ -107,7 +126,7 @@ pub struct TipoNotaSchema { info( title = "OpenCCB LMS — API de Integración", version = "1.0.0", - description = "API para integrar plataformas externas: creación de cursos, inscripción de alumnos y sincronización de notas." + description = "API para integrar plataformas externas: creación de cursos, inscripción de alumnos y sincronización de notas.\n\nReglas de nulabilidad y arreglos (aplican a todos los endpoints de esta API):\n- Campos opcionales (Option): pueden omitirse o enviarse como null.\n- Campos de arreglo no opcionales (Vec): deben enviarse como arreglo; pueden ser [] pero no null.\n- Objetos requeridos del payload: no deben enviarse como null.\n- En /ingest, enviar modules: [] reemplaza la estructura del curso y elimina módulos/lecciones previos en LMS." ), paths( ingest_course, @@ -121,8 +140,9 @@ pub struct TipoNotaSchema { IngestCourseRequest, OrgSchema, CourseSchema, - ModuleSchema, + IngestModuleSchema, LessonSchema, + CourseInstructorSchema, GradingCategorySchema, EnrollRequest, GradeSubmissionRequest, diff --git a/shared/common/src/models.rs b/shared/common/src/models.rs index 4aca313..94d2555 100644 --- a/shared/common/src/models.rs +++ b/shared/common/src/models.rs @@ -8,6 +8,8 @@ use uuid::Uuid; pub struct Course { pub id: Uuid, pub organization_id: Uuid, + #[serde(default, alias = "idcursoabierto", alias = "id_curso_abierto")] + pub external_sam_id: Option, pub title: String, pub description: Option, pub instructor_id: Uuid, @@ -31,6 +33,7 @@ impl Default for Course { Self { id: Uuid::new_v4(), organization_id: Uuid::new_v4(), + external_sam_id: None, title: String::new(), description: None, instructor_id: Uuid::new_v4(),