diff --git a/services/cms-service/migrations/20260406000010_course_templates.sql b/services/cms-service/migrations/20260406000010_course_templates.sql new file mode 100644 index 0000000..ecb3324 --- /dev/null +++ b/services/cms-service/migrations/20260406000010_course_templates.sql @@ -0,0 +1,15 @@ +-- Course templates for reusable course blueprints +CREATE TABLE IF NOT EXISTS course_templates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + source_course_id UUID REFERENCES courses(id) ON DELETE SET NULL, + name TEXT NOT NULL, + description TEXT, + template_data JSONB NOT NULL, + created_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_course_templates_org_created + ON course_templates (organization_id, created_at DESC); diff --git a/services/cms-service/src/handlers.rs b/services/cms-service/src/handlers.rs index 3ba0242..3de5e5e 100644 --- a/services/cms-service/src/handlers.rs +++ b/services/cms-service/src/handlers.rs @@ -3931,6 +3931,271 @@ pub async fn import_course( Ok(Json(new_course)) } +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct CourseTemplateSummary { + pub id: Uuid, + pub name: String, + pub description: Option, + pub source_course_id: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Deserialize)] +pub struct CreateCourseTemplatePayload { + pub name: String, + pub description: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ApplyCourseTemplatePayload { + pub title: Option, +} + +pub async fn list_course_templates( + Org(org_ctx): Org, + _claims: Claims, + State(pool): State, +) -> Result>, StatusCode> { + let templates = sqlx::query_as::<_, CourseTemplateSummary>( + r#" + SELECT id, name, description, source_course_id, created_at, updated_at + FROM course_templates + WHERE organization_id = $1 + ORDER BY created_at DESC + "#, + ) + .bind(org_ctx.id) + .fetch_all(&pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json(templates)) +} + +pub async fn create_course_template_from_course( + Org(org_ctx): Org, + claims: Claims, + State(pool): State, + Path(course_id): Path, + Json(payload): Json, +) -> Result, StatusCode> { + if claims.role != "admin" && claims.role != "instructor" { + return Err(StatusCode::FORBIDDEN); + } + + let exists = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM courses WHERE id = $1 AND organization_id = $2)", + ) + .bind(course_id) + .bind(org_ctx.id) + .fetch_one(&pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + if !exists { + return Err(StatusCode::NOT_FOUND); + } + + let data = exporter::get_course_data(&pool, course_id) + .await + .map_err(|e| { + tracing::error!("Template export failed: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let template_data = serde_json::to_value(&data).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let template = sqlx::query_as::<_, CourseTemplateSummary>( + r#" + INSERT INTO course_templates ( + organization_id, source_course_id, name, description, template_data, created_by + ) VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, name, description, source_course_id, created_at, updated_at + "#, + ) + .bind(org_ctx.id) + .bind(course_id) + .bind(payload.name) + .bind(payload.description) + .bind(template_data) + .bind(claims.sub) + .fetch_one(&pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + log_action( + &pool, + org_ctx.id, + claims.sub, + "COURSE_TEMPLATE_CREATED", + "CourseTemplate", + template.id, + serde_json::json!({ "source_course_id": course_id }), + ) + .await; + + Ok(Json(template)) +} + +pub async fn apply_course_template( + Org(org_ctx): Org, + claims: Claims, + State(pool): State, + Path(template_id): Path, + Json(payload): Json, +) -> Result, StatusCode> { + if claims.role != "admin" && claims.role != "instructor" { + return Err(StatusCode::FORBIDDEN); + } + + let template_data = sqlx::query_scalar::<_, serde_json::Value>( + "SELECT template_data FROM course_templates WHERE id = $1 AND organization_id = $2", + ) + .bind(template_id) + .bind(org_ctx.id) + .fetch_one(&pool) + .await + .map_err(|_| StatusCode::NOT_FOUND)?; + + let exported: exporter::CourseExport = serde_json::from_value(template_data) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let mut tx = pool + .begin() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let new_title = payload + .title + .unwrap_or_else(|| format!("{} (Desde plantilla)", exported.course.title)); + + let new_course = sqlx::query_as::<_, Course>( + "INSERT INTO courses ( + organization_id, instructor_id, title, pacing_mode, description, + passing_percentage, certificate_template, start_date, end_date + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING *", + ) + .bind(org_ctx.id) + .bind(claims.sub) + .bind(new_title) + .bind(exported.course.pacing_mode) + .bind(exported.course.description) + .bind(exported.course.passing_percentage) + .bind(exported.course.certificate_template) + .bind(exported.course.start_date) + .bind(exported.course.end_date) + .fetch_one(&mut *tx) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let mut cat_map = std::collections::HashMap::new(); + for old_cat in exported.grading_categories { + let new_cat = sqlx::query_as::<_, common::models::GradingCategory>( + "INSERT INTO grading_categories (organization_id, course_id, name, weight, drop_count) + VALUES ($1, $2, $3, $4, $5) + RETURNING *", + ) + .bind(org_ctx.id) + .bind(new_course.id) + .bind(old_cat.name) + .bind(old_cat.weight) + .bind(old_cat.drop_count) + .fetch_one(&mut *tx) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + cat_map.insert(old_cat.id, new_cat.id); + } + + for module_data in exported.modules { + let new_module = sqlx::query_as::<_, Module>( + "INSERT INTO modules (course_id, organization_id, title, position) + VALUES ($1, $2, $3, $4) + RETURNING *", + ) + .bind(new_course.id) + .bind(org_ctx.id) + .bind(module_data.module.title) + .bind(module_data.module.position) + .fetch_one(&mut *tx) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + for lesson in module_data.lessons { + let new_cat_id = lesson + .grading_category_id + .and_then(|id| cat_map.get(&id)) + .cloned(); + + sqlx::query( + "INSERT INTO lessons ( + module_id, organization_id, title, content_type, + content_url, position, is_graded, metadata, summary, + transcription, grading_category_id, max_attempts + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)", + ) + .bind(new_module.id) + .bind(org_ctx.id) + .bind(lesson.title) + .bind(lesson.content_type) + .bind(lesson.content_url) + .bind(lesson.position) + .bind(lesson.is_graded) + .bind(lesson.metadata) + .bind(lesson.summary) + .bind(lesson.transcription) + .bind(new_cat_id) + .bind(lesson.max_attempts) + .execute(&mut *tx) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + } + } + + tx.commit() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + log_action( + &pool, + org_ctx.id, + claims.sub, + "COURSE_TEMPLATE_APPLIED", + "Course", + new_course.id, + serde_json::json!({ "template_id": template_id }), + ) + .await; + + Ok(Json(new_course)) +} + +pub async fn delete_course_template( + Org(org_ctx): Org, + claims: Claims, + State(pool): State, + Path(template_id): Path, +) -> Result { + if claims.role != "admin" && claims.role != "instructor" { + return Err(StatusCode::FORBIDDEN); + } + + let result = sqlx::query("DELETE FROM course_templates WHERE id = $1 AND organization_id = $2") + .bind(template_id) + .bind(org_ctx.id) + .execute(&pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + if result.rows_affected() == 0 { + return Err(StatusCode::NOT_FOUND); + } + + Ok(StatusCode::NO_CONTENT) +} + pub async fn check_course_access( pool: &PgPool, course_id: Uuid, diff --git a/services/cms-service/src/main.rs b/services/cms-service/src/main.rs index 4096c78..9efa063 100644 --- a/services/cms-service/src/main.rs +++ b/services/cms-service/src/main.rs @@ -222,6 +222,13 @@ async fn main() { .route("/courses/generate", post(handlers::generate_course)) .route("/courses/{id}/export", get(handlers::export_course)) .route("/courses/import", post(handlers::import_course)) + .route("/course-templates", get(handlers::list_course_templates)) + .route( + "/course-templates/from-course/{id}", + post(handlers::create_course_template_from_course), + ) + .route("/course-templates/{id}/apply", post(handlers::apply_course_template)) + .route("/course-templates/{id}", delete(handlers::delete_course_template)) .route("/grading", post(handlers::create_grading_category)) .route("/grading/{id}", delete(handlers::delete_grading_category)) .route( diff --git a/web/studio/src/app/course-templates/page.tsx b/web/studio/src/app/course-templates/page.tsx new file mode 100644 index 0000000..50a4e00 --- /dev/null +++ b/web/studio/src/app/course-templates/page.tsx @@ -0,0 +1,219 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import PageLayout from "@/components/PageLayout"; +import { cmsApi, Course, CourseTemplateSummary } from "@/lib/api"; +import { BookOpen, Plus, Trash2, Wand2 } from "lucide-react"; + +export default function CourseTemplatesPage() { + const [courses, setCourses] = useState([]); + const [templates, setTemplates] = useState([]); + const [loading, setLoading] = useState(true); + + const [sourceCourseId, setSourceCourseId] = useState(""); + const [templateName, setTemplateName] = useState(""); + const [templateDescription, setTemplateDescription] = useState(""); + const [creating, setCreating] = useState(false); + + const [instantiatingId, setInstantiatingId] = useState(null); + + const sourceCourse = useMemo( + () => courses.find((c) => c.id === sourceCourseId), + [courses, sourceCourseId] + ); + + async function loadData() { + setLoading(true); + try { + const [coursesData, templatesData] = await Promise.all([ + cmsApi.getCourses(), + cmsApi.listCourseTemplates(), + ]); + setCourses(coursesData); + setTemplates(templatesData); + if (!sourceCourseId && coursesData.length > 0) { + setSourceCourseId(coursesData[0].id); + setTemplateName(`${coursesData[0].title} - Base`); + } + } catch (error) { + console.error("Failed to load course templates page", error); + alert("No se pudieron cargar los datos de plantillas de curso"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + void loadData(); + }, []); + + async function handleCreateTemplate() { + if (!sourceCourseId || !templateName.trim()) { + alert("Selecciona un curso y nombre de plantilla"); + return; + } + + setCreating(true); + try { + await cmsApi.createCourseTemplateFromCourse( + sourceCourseId, + templateName.trim(), + templateDescription.trim() || undefined + ); + setTemplateDescription(""); + await loadData(); + alert("Plantilla de curso creada"); + } catch (error) { + console.error("Failed to create course template", error); + alert("No se pudo crear la plantilla de curso"); + } finally { + setCreating(false); + } + } + + async function handleInstantiateTemplate(template: CourseTemplateSummary) { + const customTitle = prompt( + "Título del nuevo curso (opcional):", + `${template.name} - Copia` + ); + + setInstantiatingId(template.id); + try { + const created = await cmsApi.applyCourseTemplate( + template.id, + customTitle && customTitle.trim().length > 0 ? customTitle.trim() : undefined + ); + alert(`Curso creado desde plantilla: ${created.title}`); + } catch (error) { + console.error("Failed to instantiate template", error); + alert("No se pudo crear el curso desde la plantilla"); + } finally { + setInstantiatingId(null); + } + } + + async function handleDeleteTemplate(template: CourseTemplateSummary) { + const ok = confirm(`¿Eliminar plantilla \"${template.name}\"?`); + if (!ok) return; + + try { + await cmsApi.deleteCourseTemplate(template.id); + await loadData(); + } catch (error) { + console.error("Failed to delete template", error); + alert("No se pudo eliminar la plantilla"); + } + } + + return ( + +
+
+

Nueva plantilla

+ +
+ + +
+ +
+ + setTemplateName(e.target.value)} + className="mt-1 w-full rounded-xl border border-slate-200 dark:border-white/10 bg-white dark:bg-black/20 px-3 py-2" + placeholder="Ej. Inglés A1 - Base" + /> +
+ +
+ +