diff --git a/Cargo.lock b/Cargo.lock index eea986b..66136b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -219,6 +219,7 @@ dependencies = [ "chrono", "common", "dotenvy", + "reqwest", "serde", "serde_json", "sqlx", @@ -257,6 +258,16 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -385,6 +396,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "etcetera" version = "0.8.0" @@ -407,6 +428,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "find-msvc-tools" version = "0.1.5" @@ -424,12 +451,33 @@ dependencies = [ "spin", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -546,6 +594,25 @@ dependencies = [ "wasip2", ] +[[package]] +name = "h2" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -672,6 +739,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -681,6 +749,39 @@ dependencies = [ "pin-utils", "smallvec", "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", ] [[package]] @@ -689,14 +790,24 @@ version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" dependencies = [ + "base64", "bytes", + "futures-channel", "futures-core", + "futures-util", "http", "http-body", "hyper", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", + "socket2", + "system-configuration", "tokio", "tower-service", + "tracing", + "windows-registry", ] [[package]] @@ -844,6 +955,22 @@ dependencies = [ "generic-array", ] +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "itoa" version = "1.0.15" @@ -917,6 +1044,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "litemap" version = "0.8.1" @@ -1031,6 +1164,23 @@ dependencies = [ "version_check", ] +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1108,6 +1258,50 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "parking" version = "2.2.1" @@ -1314,6 +1508,46 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +[[package]] +name = "reqwest" +version = "0.12.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b4c14b2d9afca6a60277086b0cc6a6ae0b568f6f7916c943a8cdc79f8be240f" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "ring" version = "0.17.14" @@ -1348,6 +1582,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.35" @@ -1394,12 +1641,44 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "serde" version = "1.0.228" @@ -1815,6 +2094,9 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] name = "synstructure" @@ -1827,6 +2109,40 @@ dependencies = [ "syn", ] +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "2.0.17" @@ -1940,6 +2256,26 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.17" @@ -1995,12 +2331,14 @@ dependencies = [ "http-body-util", "http-range-header", "httpdate", + "iri-string", "mime", "mime_guess", "percent-encoding", "pin-project-lite", "tokio", "tokio-util", + "tower", "tower-layer", "tower-service", "tracing", @@ -2080,6 +2418,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.19.0" @@ -2173,6 +2517,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2207,6 +2560,19 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.106" @@ -2239,6 +2605,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -2308,6 +2684,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" diff --git a/Cargo.toml b/Cargo.toml index 5e890a2..0bc2f03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,3 +25,4 @@ jsonwebtoken = "9.3" bcrypt = "0.17" dotenvy = "0.15" tower-http = { version = "0.6", features = ["cors", "trace", "fs"] } +reqwest = { version = "0.12", features = ["json"] } diff --git a/services/cms-service/Cargo.toml b/services/cms-service/Cargo.toml index e90d581..8a6bc14 100644 --- a/services/cms-service/Cargo.toml +++ b/services/cms-service/Cargo.toml @@ -17,3 +17,4 @@ tracing.workspace = true tracing-subscriber.workspace = true dotenvy.workspace = true tower-http.workspace = true +reqwest.workspace = true diff --git a/services/cms-service/src/handlers.rs b/services/cms-service/src/handlers.rs index c9b653b..267cd5f 100644 --- a/services/cms-service/src/handlers.rs +++ b/services/cms-service/src/handlers.rs @@ -3,12 +3,73 @@ use axum::{ http::StatusCode, Json, }; -use common::models::{Course, Module, Lesson}; +use common::models::{Course, Module, Lesson, PublishedCourse, PublishedModule}; use sqlx::PgPool; use uuid::Uuid; use serde_json::json; use serde::{Deserialize, Serialize}; +pub async fn publish_course( + State(pool): State, + Path(id): Path, +) -> Result { + // 1. Fetch Course + let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1") + .bind(id) + .fetch_one(&pool) + .await + .map_err(|_| StatusCode::NOT_FOUND)?; + + // 2. Fetch Modules + let modules = sqlx::query_as::<_, Module>("SELECT * FROM modules WHERE course_id = $1 ORDER BY position") + .bind(id) + .fetch_all(&pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let mut pub_modules = Vec::new(); + + // 3. Fetch Lessons for each Module + for module in modules { + let lessons = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE module_id = $1 ORDER BY position") + .bind(module.id) + .fetch_all(&pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + pub_modules.push(PublishedModule { + module, + lessons, + }); + } + + let payload = PublishedCourse { + course, + modules: pub_modules, + }; + + // 4. Send to LMS + // Using service name for Docker compatibility + let client = reqwest::Client::new(); + let res = client.post("http://lms-service:3002/ingest") + .json(&payload) + .send() + .await + .map_err(|e| { + tracing::error!("Failed to reach LMS service: {}", e); + StatusCode::BAD_GATEWAY + })?; + + if !res.status().is_success() { + tracing::error!("LMS ingestion failed with status: {}", res.status()); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + + log_action(&pool, Uuid::new_v4(), "PUBLISH", "Course", id, json!({})).await; + + Ok(StatusCode::OK) +} + #[derive(Deserialize)] pub struct ModuleQuery { pub course_id: Option, diff --git a/services/cms-service/src/main.rs b/services/cms-service/src/main.rs index 978f2f8..452efc4 100644 --- a/services/cms-service/src/main.rs +++ b/services/cms-service/src/main.rs @@ -22,6 +22,12 @@ async fn main() { .await .expect("Failed to connect to database"); + // Run migrations automatically + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("Failed to run migrations"); + let cors = CorsLayer::new() .allow_origin(Any) .allow_methods(Any) @@ -30,6 +36,7 @@ async fn main() { let app = Router::new() .route("/courses", get(handlers::get_courses).post(handlers::create_course)) .route("/courses/{id}", get(handlers::get_course)) + .route("/courses/{id}/publish", post(handlers::publish_course)) .route("/modules", get(handlers::get_modules).post(handlers::create_module)) .route("/lessons", get(handlers::get_lessons).post(handlers::create_lesson)) .route("/lessons/{id}", get(handlers::get_lesson).put(handlers::update_lesson)) diff --git a/services/lms-service/migrations/20231219000002_mirrored_content.sql b/services/lms-service/migrations/20231219000002_mirrored_content.sql new file mode 100644 index 0000000..bc6a274 --- /dev/null +++ b/services/lms-service/migrations/20231219000002_mirrored_content.sql @@ -0,0 +1,32 @@ +-- Mirrored schema for courses, modules, and lessons in the LMS +-- This table stores the published version of the content +CREATE TABLE courses ( + id UUID PRIMARY KEY, -- Using the same ID as CMS + title TEXT NOT NULL, + description TEXT, + instructor_id UUID NOT NULL, + start_date TIMESTAMPTZ, + end_date TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE modules ( + id UUID PRIMARY KEY, + course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE, + title TEXT NOT NULL, + position INT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE lessons ( + id UUID PRIMARY KEY, + module_id UUID NOT NULL REFERENCES modules(id) ON DELETE CASCADE, + title TEXT NOT NULL, + content_type TEXT NOT NULL, + content_url TEXT, + transcription JSONB, + metadata JSONB, + position INT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/services/lms-service/src/handlers.rs b/services/lms-service/src/handlers.rs index 67fed07..0802623 100644 --- a/services/lms-service/src/handlers.rs +++ b/services/lms-service/src/handlers.rs @@ -1,9 +1,9 @@ use axum::{ - extract::State, + extract::{State, Path}, http::StatusCode, Json, }; -use common::models::{Course, Enrollment}; +use common::models::{Course, Enrollment, Module, Lesson}; use sqlx::PgPool; use uuid::Uuid; @@ -37,3 +37,136 @@ pub async fn get_course_catalog( Ok(Json(courses)) } + +pub async fn ingest_course( + State(pool): State, + Json(payload): Json, +) -> Result { + let mut tx = pool.begin().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // 1. Upsert Course + sqlx::query( + "INSERT INTO courses (id, title, description, instructor_id, start_date, end_date, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (id) DO UPDATE SET + title = EXCLUDED.title, + description = EXCLUDED.description, + instructor_id = EXCLUDED.instructor_id, + start_date = EXCLUDED.start_date, + end_date = EXCLUDED.end_date, + updated_at = EXCLUDED.updated_at" + ) + .bind(payload.course.id) + .bind(&payload.course.title) + .bind(&payload.course.description) + .bind(payload.course.instructor_id) + .bind(payload.course.start_date) + .bind(payload.course.end_date) + .bind(payload.course.updated_at) + .execute(&mut *tx) + .await + .map_err(|e| { + tracing::error!("Failed to upsert course during ingestion: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + // 2. Clear existing modules and lessons for this course to ensure perfect sync + // Cascading delete on courses(id) handles lessons too + sqlx::query("DELETE FROM modules WHERE course_id = $1") + .bind(payload.course.id) + .execute(&mut *tx) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // 3. Insert Modules and Lessons + for pub_module in payload.modules { + sqlx::query( + "INSERT INTO modules (id, course_id, title, position, created_at) + VALUES ($1, $2, $3, $4, $5)" + ) + .bind(pub_module.module.id) + .bind(payload.course.id) + .bind(&pub_module.module.title) + .bind(pub_module.module.position) + .bind(pub_module.module.created_at) + .execute(&mut *tx) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + for lesson in pub_module.lessons { + sqlx::query( + "INSERT INTO lessons (id, module_id, title, content_type, content_url, transcription, metadata, position, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)" + ) + .bind(lesson.id) + .bind(pub_module.module.id) + .bind(&lesson.title) + .bind(&lesson.content_type) + .bind(&lesson.content_url) + .bind(&lesson.transcription) + .bind(&lesson.metadata) + .bind(lesson.position) + .bind(lesson.created_at) + .execute(&mut *tx) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + } + } + + tx.commit().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(StatusCode::OK) +} + +pub async fn get_course_outline( + State(pool): State, + Path(id): Path, +) -> Result, StatusCode> { + // 1. Fetch Course + let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1") + .bind(id) + .fetch_one(&pool) + .await + .map_err(|_| StatusCode::NOT_FOUND)?; + + // 2. Fetch Modules + let modules = sqlx::query_as::<_, Module>("SELECT * FROM modules WHERE course_id = $1 ORDER BY position") + .bind(id) + .fetch_all(&pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let mut pub_modules = Vec::new(); + + // 3. Fetch Lessons + for module in modules { + let lessons = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE module_id = $1 ORDER BY position") + .bind(module.id) + .fetch_all(&pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + pub_modules.push(common::models::PublishedModule { + module, + lessons, + }); + } + + Ok(Json(common::models::PublishedCourse { + course, + modules: pub_modules, + })) +} + +pub async fn get_lesson_content( + State(pool): State, + Path(id): Path, +) -> Result, StatusCode> { + let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1") + .bind(id) + .fetch_one(&pool) + .await + .map_err(|_| StatusCode::NOT_FOUND)?; + + Ok(Json(lesson)) +} diff --git a/services/lms-service/src/main.rs b/services/lms-service/src/main.rs index ea45ea3..ebf7685 100644 --- a/services/lms-service/src/main.rs +++ b/services/lms-service/src/main.rs @@ -21,9 +21,18 @@ async fn main() { .await .expect("Failed to connect to database"); + // Run migrations automatically + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("Failed to run migrations"); + let app = Router::new() .route("/catalog", get(handlers::get_course_catalog)) .route("/enroll", post(handlers::enroll_user)) + .route("/ingest", post(handlers::ingest_course)) + .route("/courses/{id}/outline", get(handlers::get_course_outline)) + .route("/lessons/{id}", get(handlers::get_lesson_content)) .with_state(pool); let addr = SocketAddr::from(([0, 0, 0, 0], 3002)); diff --git a/shared/common/src/models.rs b/shared/common/src/models.rs index 619f64a..a6a07b5 100644 --- a/shared/common/src/models.rs +++ b/shared/common/src/models.rs @@ -64,3 +64,84 @@ pub struct Asset { pub size_bytes: i64, pub created_at: DateTime, } +#[derive(Debug, Serialize, Deserialize)] +pub struct PublishedCourse { + pub course: Course, + pub modules: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct PublishedModule { + pub module: Module, + pub lessons: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_published_course_serialization() { + let lesson_id = Uuid::new_v4(); + let module_id = Uuid::new_v4(); + let course_id = Uuid::new_v4(); + + let lesson = Lesson { + id: lesson_id, + module_id, + title: "Test Lesson".to_string(), + content_type: "activity".to_string(), + content_url: None, + transcription: None, + metadata: Some(json!({ + "blocks": [ + { + "id": "b1", + "type": "fill-in-the-blanks", + "content": "The capital of France is [[Paris]]." + }, + { + "id": "b2", + "type": "matching", + "pairs": [{"left": "Term", "right": "Definition"}] + } + ] + })), + position: 1, + created_at: Utc::now(), + }; + + let pub_module = PublishedModule { + module: Module { + id: module_id, + course_id, + title: "Test Module".to_string(), + position: 1, + created_at: Utc::now(), + }, + lessons: vec![lesson], + }; + + let pub_course = PublishedCourse { + course: Course { + id: course_id, + title: "Test Course".to_string(), + description: None, + instructor_id: Uuid::new_v4(), + start_date: None, + end_date: None, + created_at: Utc::now(), + updated_at: Utc::now(), + }, + modules: vec![pub_module], + }; + + let serialized = serde_json::to_string(&pub_course).unwrap(); + let deserialized: PublishedCourse = serde_json::from_str(&serialized).unwrap(); + + assert_eq!(pub_course.course.title, deserialized.course.title); + assert_eq!(pub_course.modules.len(), deserialized.modules.len()); + assert_eq!(deserialized.modules[0].lessons[0].title, "Test Lesson"); + } +} diff --git a/web/studio/src/app/courses/[id]/lessons/[lessonId]/page.tsx b/web/studio/src/app/courses/[id]/lessons/[lessonId]/page.tsx index 2f2c9bb..52f8f61 100644 --- a/web/studio/src/app/courses/[id]/lessons/[lessonId]/page.tsx +++ b/web/studio/src/app/courses/[id]/lessons/[lessonId]/page.tsx @@ -6,6 +6,10 @@ import Link from "next/link"; import DescriptionBlock from "@/components/blocks/DescriptionBlock"; import MediaBlock from "@/components/blocks/MediaBlock"; import QuizBlock from "@/components/blocks/QuizBlock"; +import FillInTheBlanksBlock from "@/components/blocks/FillInTheBlanksBlock"; +import MatchingBlock from "@/components/blocks/MatchingBlock"; +import OrderingBlock from "@/components/blocks/OrderingBlock"; +import ShortAnswerBlock from "@/components/blocks/ShortAnswerBlock"; export default function LessonEditor({ params }: { params: { id: string; lessonId: string } }) { const [lesson, setLesson] = useState(null); @@ -58,13 +62,17 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI } }; - const addBlock = (type: 'description' | 'media' | 'quiz') => { + const addBlock = (type: 'description' | 'media' | 'quiz' | 'fill-in-the-blanks' | 'matching' | 'ordering' | 'short-answer') => { const newBlock: Block = { id: Math.random().toString(36).substr(2, 9), type, ...(type === 'description' && { content: "" }), ...(type === 'media' && { url: "", media_type: 'video' as const, config: { maxPlays: 0 } }), ...(type === 'quiz' && { quiz_data: { questions: [] } }), + ...(type === 'fill-in-the-blanks' && { content: "Type your text here with [[blanks]]." }), + ...(type === 'matching' && { pairs: [{ left: "Item 1", right: "Match 1" }] }), + ...(type === 'ordering' && { items: ["Item A", "Item B"] }), + ...(type === 'short-answer' && { prompt: "Question?", correctAnswers: ["Answer"] }), }; setBlocks([...blocks, newBlock]); }; @@ -181,6 +189,43 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI onChange={(updates) => updateBlock(block.id, updates)} /> )} + {block.type === 'fill-in-the-blanks' && ( + updateBlock(block.id, updates)} + /> + )} + {block.type === 'matching' && ( + updateBlock(block.id, updates)} + /> + )} + {block.type === 'ordering' && ( + updateBlock(block.id, updates)} + /> + )} + {block.type === 'short-answer' && ( + updateBlock(block.id, updates)} + /> + )} ))} @@ -211,6 +256,34 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI 💡 Quiz + + + + diff --git a/web/studio/src/app/courses/[id]/page.tsx b/web/studio/src/app/courses/[id]/page.tsx index 563c37a..a73e459 100644 --- a/web/studio/src/app/courses/[id]/page.tsx +++ b/web/studio/src/app/courses/[id]/page.tsx @@ -72,11 +72,28 @@ export default function CourseEditor({ params }: { params: { id: string } }) { } }; + const [isPublishing, setIsPublishing] = useState(false); + + const handlePublish = async () => { + if (!course) return; + setIsPublishing(true); + try { + await cmsApi.publishCourse(params.id); + alert("Course published successfully to LMS!"); + } catch (err) { + console.error("Publish failed:", err); + alert("Failed to publish course. Check if LMS service is reachable."); + } finally { + setIsPublishing(false); + } + }; + if (loading) return
Loading editor...
; if (error) return
{error}
; return (
+ {/* ... navigation ... */}
Courses / @@ -90,7 +107,20 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
- +
diff --git a/web/studio/src/components/blocks/FillInTheBlanksBlock.tsx b/web/studio/src/components/blocks/FillInTheBlanksBlock.tsx new file mode 100644 index 0000000..d7e5ba9 --- /dev/null +++ b/web/studio/src/components/blocks/FillInTheBlanksBlock.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { useState, useMemo } from "react"; + +interface FillInTheBlanksBlockProps { + id: string; + title?: string; + content: string; + editMode: boolean; + onChange: (updates: { title?: string; content?: string }) => void; +} + +export default function FillInTheBlanksBlock({ id, title, content, editMode, onChange }: FillInTheBlanksBlockProps) { + const [userAnswers, setUserAnswers] = useState([]); + const [submitted, setSubmitted] = useState(false); + + // Parse content to find blanks + const parsed = useMemo(() => { + const parts: { type: 'text' | 'blank'; value?: string; index?: number; answer?: string }[] = []; + const answers: string[] = []; + let lastIndex = 0; + const regex = /\[\[(.*?)\]\]/g; + let match; + + while ((match = regex.exec(content)) !== null) { + // Push text before the blank + parts.push({ type: 'text', value: content.substring(lastIndex, match.index) }); + // Push the blank + const answer = match[1]; + parts.push({ type: 'blank', index: answers.length, answer }); + answers.push(answer); + lastIndex = regex.lastIndex; + } + // Push remaining text + parts.push({ type: 'text', value: content.substring(lastIndex) }); + + return { parts, answers }; + }, [content]); + + const handleReset = () => { + setSubmitted(false); + setUserAnswers([]); + }; + + const isCorrect = (index: number) => { + return userAnswers[index]?.trim().toLowerCase() === parsed.answers[index]?.trim().toLowerCase(); + }; + + return ( +
+
+ {editMode ? ( +
+ + onChange({ title: e.target.value })} + placeholder="e.g. Fill the gaps, Quote..." + className="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2 text-sm font-bold focus:border-blue-500/50 focus:outline-none" + /> +
+ ) : ( +

+ {title || "Fill in the Blanks"} +

+ )} +
+ + {editMode ? ( +
+
+ +