feat: add external SAM ID support to courses and update API documentation

This commit is contained in:
2026-04-27 18:01:31 -04:00
parent 63620e9752
commit 553036cb58
13 changed files with 187 additions and 19 deletions
Generated
+1
View File
@@ -840,6 +840,7 @@ dependencies = [
"tower_governor",
"tracing",
"tracing-subscriber",
"utoipa",
"uuid",
"zip",
]
+1
View File
@@ -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"
@@ -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();
@@ -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;
@@ -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<String>,
pub pacing_mode: Option<String>,
#[serde(alias = "idcursoabierto", alias = "id_curso_abierto")]
pub external_sam_id: Option<i64>,
// Selección directa de plantilla opcional
pub template_id: Option<Uuid>,
// Selección de respaldo (fallback) opcional por nivel/tipo de curso/tipo de test
+2 -4
View File
@@ -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;
+22 -1
View File
@@ -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#"
<!doctype html>
<html>
<head>
<title>OpenCCB CMS API</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<script id="api-reference" data-url="./api-docs/openapi.json"></script>
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
</body>
</html>
"#)
}))
.nest("/api/external", api_routes)
.route(
"/api/assets/s3-proxy/{bucket}/{*key}",
+107
View File
@@ -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<String>,
/// Opcional: puede omitirse o enviarse como `null`.
pub pacing_mode: Option<String>,
/// Opcional: idCursoAbierto del sistema SAM.
/// También se acepta como `idcursoabierto` o `id_curso_abierto` en el payload real.
pub external_sam_id: Option<i64>,
/// Opcional: UUID de plantilla específica.
pub template_id: Option<String>,
/// Opcional: fallback de selección de plantilla por nivel.
pub template_level: Option<String>,
/// Opcional: fallback de selección de plantilla por tipo de curso.
pub template_course_type: Option<String>,
/// Opcional: fallback de selección de plantilla por tipo de test.
pub template_test_type: Option<String>,
/// Opcional: título del módulo creado al aplicar plantilla.
pub module_title: Option<String>,
/// Opcional: título de la lección creada al aplicar plantilla.
pub lesson_title: Option<String>,
}
#[derive(utoipa::ToSchema, serde::Serialize)]
pub struct ExternalCourseEnvelopeSchema {
pub course: serde_json::Value,
pub template_applied: bool,
pub template_id: Option<String>,
pub lesson_id: Option<String>,
}
#[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() {}
@@ -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;
+5 -3
View File
@@ -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| {
+1 -1
View File
@@ -470,7 +470,7 @@ async fn main() {
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<script id="api-reference" data-url="/api-docs/openapi.json"></script>
<script id="api-reference" data-url="./api-docs/openapi.json"></script>
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
</body>
</html>
+27 -7
View File
@@ -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<LessonSchema>,
}
/// 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<i64>,
pub title: String,
pub description: Option<String>,
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<GradingCategorySchema>,
pub modules: Vec<ModuleSchema>,
pub lessons: Vec<LessonSchema>,
pub instructors: Vec<String>,
/// Puede venir como `[]` (vacío), pero no `null`.
/// Las lecciones se envían dentro de cada módulo en `modules[].lessons`.
pub modules: Vec<IngestModuleSchema>,
/// Opcional. Puede omitirse o venir como `null` o `[]`.
pub instructors: Option<Vec<CourseInstructorSchema>>,
}
/// 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,
+3
View File
@@ -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<i64>,
pub title: String,
pub description: Option<String>,
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(),