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
+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() {}