diff --git a/Cargo.lock b/Cargo.lock index 97f62c5..6af9b6e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15,6 +15,7 @@ dependencies = [ "academy_core_coin_impl", "academy_core_config_impl", "academy_core_contact_impl", + "academy_core_course_impl", "academy_core_finance_contracts", "academy_core_finance_impl", "academy_core_health_impl", @@ -69,6 +70,7 @@ dependencies = [ "academy_core_coin_contracts", "academy_core_config_contracts", "academy_core_contact_contracts", + "academy_core_course_contracts", "academy_core_finance_contracts", "academy_core_health_contracts", "academy_core_heart_contracts", @@ -245,6 +247,29 @@ dependencies = [ "tracing", ] +[[package]] +name = "academy_core_course_contracts" +version = "0.0.0" +dependencies = [ + "academy_models", + "anyhow", + "mockall", + "thiserror 2.0.11", +] + +[[package]] +name = "academy_core_course_impl" +version = "0.0.0" +dependencies = [ + "academy_auth_contracts", + "academy_core_course_contracts", + "academy_di", + "academy_models", + "academy_persistence_contracts", + "academy_shared_contracts", + "academy_utils", +] + [[package]] name = "academy_core_finance_contracts" version = "0.0.0" diff --git a/Cargo.nix b/Cargo.nix index 3134cdf..a1a2c82 100644 --- a/Cargo.nix +++ b/Cargo.nix @@ -177,6 +177,26 @@ rec { # File a bug if you depend on any for non-debug work! debug = internal.debugCrate { inherit packageId; }; }; + "academy_core_course_contracts" = rec { + packageId = "academy_core_course_contracts"; + build = internal.buildRustCrateWithFeatures { + packageId = "academy_core_course_contracts"; + }; + + # Debug support which might change between releases. + # File a bug if you depend on any for non-debug work! + debug = internal.debugCrate { inherit packageId; }; + }; + "academy_core_course_impl" = rec { + packageId = "academy_core_course_impl"; + build = internal.buildRustCrateWithFeatures { + packageId = "academy_core_course_impl"; + }; + + # Debug support which might change between releases. + # File a bug if you depend on any for non-debug work! + debug = internal.debugCrate { inherit packageId; }; + }; "academy_core_finance_contracts" = rec { packageId = "academy_core_finance_contracts"; build = internal.buildRustCrateWithFeatures { @@ -652,6 +672,10 @@ rec { name = "academy_core_contact_impl"; packageId = "academy_core_contact_impl"; } + { + name = "academy_core_course_impl"; + packageId = "academy_core_course_impl"; + } { name = "academy_core_finance_contracts"; packageId = "academy_core_finance_contracts"; @@ -896,6 +920,10 @@ rec { name = "academy_core_contact_contracts"; packageId = "academy_core_contact_contracts"; } + { + name = "academy_core_course_contracts"; + packageId = "academy_core_course_contracts"; + } { name = "academy_core_finance_contracts"; packageId = "academy_core_finance_contracts"; @@ -1610,6 +1638,76 @@ rec { } ]; + }; + "academy_core_course_contracts" = rec { + crateName = "academy_core_course_contracts"; + version = "0.0.0"; + edition = "2021"; + src = lib.cleanSourceWith { filter = sourceFilter; src = ./academy_core/course/contracts; }; + dependencies = [ + { + name = "academy_models"; + packageId = "academy_models"; + } + { + name = "anyhow"; + packageId = "anyhow"; + usesDefaultFeatures = false; + features = [ "std" ]; + } + { + name = "mockall"; + packageId = "mockall"; + optional = true; + usesDefaultFeatures = false; + } + { + name = "thiserror"; + packageId = "thiserror 2.0.11"; + usesDefaultFeatures = false; + } + ]; + features = { + "mock" = [ "dep:mockall" ]; + }; + resolvedDefaultFeatures = [ "mock" ]; + }; + "academy_core_course_impl" = rec { + crateName = "academy_core_course_impl"; + version = "0.0.0"; + edition = "2021"; + src = lib.cleanSourceWith { filter = sourceFilter; src = ./academy_core/course/impl; }; + dependencies = [ + { + name = "academy_auth_contracts"; + packageId = "academy_auth_contracts"; + } + { + name = "academy_core_course_contracts"; + packageId = "academy_core_course_contracts"; + } + { + name = "academy_di"; + packageId = "academy_di"; + } + { + name = "academy_models"; + packageId = "academy_models"; + } + { + name = "academy_persistence_contracts"; + packageId = "academy_persistence_contracts"; + } + { + name = "academy_shared_contracts"; + packageId = "academy_shared_contracts"; + } + { + name = "academy_utils"; + packageId = "academy_utils"; + } + ]; + }; "academy_core_finance_contracts" = rec { crateName = "academy_core_finance_contracts"; diff --git a/Cargo.toml b/Cargo.toml index 4fec5f6..534e201 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,6 +57,8 @@ academy_core_config_contracts.path = "academy_core/config/contracts" academy_core_config_impl.path = "academy_core/config/impl" academy_core_contact_contracts.path = "academy_core/contact/contracts" academy_core_contact_impl.path = "academy_core/contact/impl" +academy_core_course_contracts.path = "academy_core/course/contracts" +academy_core_course_impl.path = "academy_core/course/impl" academy_core_finance_contracts.path = "academy_core/finance/contracts" academy_core_finance_impl.path = "academy_core/finance/impl" academy_core_health_contracts.path = "academy_core/health/contracts" diff --git a/academy/Cargo.toml b/academy/Cargo.toml index aa44aaf..10aa035 100644 --- a/academy/Cargo.toml +++ b/academy/Cargo.toml @@ -20,6 +20,7 @@ academy_core_coin_contracts.workspace = true academy_core_coin_impl.workspace = true academy_core_config_impl.workspace = true academy_core_contact_impl.workspace = true +academy_core_course_impl.workspace = true academy_core_finance_contracts.workspace = true academy_core_finance_impl.workspace = true academy_core_health_impl.workspace = true diff --git a/academy/src/environment/types.rs b/academy/src/environment/types.rs index affc954..7a0f7be 100644 --- a/academy/src/environment/types.rs +++ b/academy/src/environment/types.rs @@ -8,6 +8,7 @@ use academy_cache_valkey::ValkeyCache; use academy_core_coin_impl::{coin::CoinServiceImpl, CoinFeatureServiceImpl}; use academy_core_config_impl::ConfigFeatureServiceImpl; use academy_core_contact_impl::ContactFeatureServiceImpl; +use academy_core_course_impl::CourseFeatureServiceImpl; use academy_core_finance_impl::{ coin::FinanceCoinServiceImpl, invoice::FinanceInvoiceServiceImpl, FinanceFeatureServiceImpl, }; @@ -41,8 +42,8 @@ use academy_extern_impl::{ vat::VatApiServiceImpl, }; use academy_persistence_postgres::{ - coin::PostgresCoinRepository, heart::PostgresHeartRepository, mfa::PostgresMfaRepository, - oauth2::PostgresOAuth2Repository, paypal::PostgresPaypalRepository, + coin::PostgresCoinRepository, course::PostgresCourseRepository, heart::PostgresHeartRepository, + mfa::PostgresMfaRepository, oauth2::PostgresOAuth2Repository, paypal::PostgresPaypalRepository, premium::PostgresPremiumRepository, session::PostgresSessionRepository, user::PostgresUserRepository, PostgresDatabase, }; @@ -68,6 +69,7 @@ pub type RestServer = academy_api_rest::RestServer< FinanceFeature, HeartFeature, PremiumFeature, + CourseFeature, Internal, >; @@ -113,6 +115,7 @@ pub type CoinRepo = PostgresCoinRepository; pub type PaypalRepo = PostgresPaypalRepository; pub type HeartRepo = PostgresHeartRepository; pub type PremiumRepo = PostgresPremiumRepository; +pub type CourseRepo = PostgresCourseRepository; // Auth pub type Auth = @@ -233,4 +236,6 @@ pub type PremiumPlan = PremiumPlanServiceImpl; pub type Premium = PremiumServiceImpl; pub type PremiumPurchase = PremiumPurchaseServiceImpl; +pub type CourseFeature = CourseFeatureServiceImpl; + pub type Internal = InternalServiceImpl; diff --git a/academy_api/rest/Cargo.toml b/academy_api/rest/Cargo.toml index 588fb86..1fafc52 100644 --- a/academy_api/rest/Cargo.toml +++ b/academy_api/rest/Cargo.toml @@ -15,6 +15,7 @@ academy_auth_contracts.workspace = true academy_core_coin_contracts.workspace = true academy_core_config_contracts.workspace = true academy_core_contact_contracts.workspace = true +academy_core_course_contracts.workspace = true academy_core_finance_contracts.workspace = true academy_core_health_contracts.workspace = true academy_core_heart_contracts.workspace = true diff --git a/academy_api/rest/src/lib.rs b/academy_api/rest/src/lib.rs index 95d70dc..4147be6 100644 --- a/academy_api/rest/src/lib.rs +++ b/academy_api/rest/src/lib.rs @@ -6,6 +6,7 @@ use std::{ use academy_core_coin_contracts::CoinFeatureService; use academy_core_config_contracts::ConfigFeatureService; use academy_core_contact_contracts::ContactFeatureService; +use academy_core_course_contracts::CourseFeatureService; use academy_core_finance_contracts::FinanceFeatureService; use academy_core_health_contracts::HealthFeatureService; use academy_core_heart_contracts::HeartFeatureService; @@ -57,6 +58,7 @@ pub struct RestServer< Finance, Heart, Premium, + Course, Internal, > { _config: RestServerConfig, @@ -72,6 +74,7 @@ pub struct RestServer< finance: Finance, heart: Heart, premium: Premium, + course: Course, internal: Internal, } @@ -101,6 +104,7 @@ impl< Finance, Heart, Premium, + Course, Internal, > RestServer< @@ -116,6 +120,7 @@ impl< Finance, Heart, Premium, + Course, Internal, > where @@ -131,6 +136,7 @@ where Finance: FinanceFeatureService, Heart: HeartFeatureService, Premium: PremiumFeatureService, + Course: CourseFeatureService, Internal: InternalService, { pub async fn serve(self) -> anyhow::Result<()> { @@ -162,6 +168,7 @@ where routes::finance::TAG, routes::heart::TAG, routes::premium::TAG, + routes::course::TAG, routes::internal::TAG, ] .into_iter() @@ -239,6 +246,7 @@ where .merge(routes::finance::router(self.finance.into())) .merge(routes::heart::router(self.heart.into())) .merge(routes::premium::router(self.premium.into())) + .merge(routes::course::router(self.course.into())) .merge(routes::internal::router(self.internal.into())) } } diff --git a/academy_api/rest/src/models/course.rs b/academy_api/rest/src/models/course.rs new file mode 100644 index 0000000..fe73e07 --- /dev/null +++ b/academy_api/rest/src/models/course.rs @@ -0,0 +1,46 @@ +use academy_models::{ + course::{Course, CourseDescription, CourseId, CourseLanguage, CourseTitle}, + url::Url, +}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct ApiCourse { + pub id: CourseId, + pub title: CourseTitle, + pub description: Option, + pub updated_at: i64, + pub language: Option, + pub image: Option, + pub price: u32, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)] +pub enum ApiCourseLanguage { + English, + German, +} + +impl From for ApiCourse { + fn from(value: Course) -> Self { + Self { + id: value.id, + title: value.title, + description: value.description, + updated_at: value.updated_at.timestamp(), + language: value.language.map(Into::into), + image: value.image, + price: value.price, + } + } +} + +impl From for ApiCourseLanguage { + fn from(value: CourseLanguage) -> Self { + match value { + CourseLanguage::English => Self::English, + CourseLanguage::German => Self::German, + } + } +} diff --git a/academy_api/rest/src/models/mod.rs b/academy_api/rest/src/models/mod.rs index 8d91ae7..cb106e4 100644 --- a/academy_api/rest/src/models/mod.rs +++ b/academy_api/rest/src/models/mod.rs @@ -8,6 +8,7 @@ use crate::const_schema; pub mod coin; pub mod contact; +pub mod course; pub mod heart; pub mod oauth2; pub mod premium; diff --git a/academy_api/rest/src/routes/course.rs b/academy_api/rest/src/routes/course.rs new file mode 100644 index 0000000..a483d7c --- /dev/null +++ b/academy_api/rest/src/routes/course.rs @@ -0,0 +1,50 @@ +use std::sync::Arc; + +use academy_core_course_contracts::{CourseFeatureService, CourseListError}; +use aide::{ + axum::{routing, ApiRouter}, + transform::TransformOperation, +}; +use axum::{ + extract::State, + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; + +use crate::{ + docs::TransformOperationExt, + errors::{auth_error, auth_error_docs, internal_server_error, internal_server_error_docs}, + extractors::auth::ApiToken, + models::course::ApiCourse, +}; + +pub const TAG: &str = "Course"; + +pub fn router(service: Arc) -> ApiRouter<()> { + ApiRouter::new() + .api_route("/courses", routing::get_with(list, list_docs)) + .with_state(service) + .with_path_items(|op| op.tag(TAG)) +} + +async fn list(service: State>, token: ApiToken) -> Response { + match service.list(&token.0).await { + Ok(courses) => Json( + courses + .into_iter() + .map(Into::into) + .collect::>(), + ) + .into_response(), + Err(CourseListError::Auth(err)) => auth_error(err), + Err(CourseListError::Other(err)) => internal_server_error(err), + } +} + +fn list_docs(op: TransformOperation) -> TransformOperation { + op.summary("Return all courses") + .add_response::>(StatusCode::OK, None) + .with(auth_error_docs) + .with(internal_server_error_docs) +} diff --git a/academy_api/rest/src/routes/mod.rs b/academy_api/rest/src/routes/mod.rs index 4c68f94..d4204bc 100644 --- a/academy_api/rest/src/routes/mod.rs +++ b/academy_api/rest/src/routes/mod.rs @@ -1,6 +1,7 @@ pub mod coin; pub mod config; pub mod contact; +pub mod course; pub mod finance; pub mod health; pub mod heart; diff --git a/academy_core/course/contracts/Cargo.toml b/academy_core/course/contracts/Cargo.toml new file mode 100644 index 0000000..903e883 --- /dev/null +++ b/academy_core/course/contracts/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "academy_core_course_contracts" +version.workspace = true +edition.workspace = true +publish.workspace = true +homepage.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[features] +mock = ["dep:mockall"] + +[dependencies] +academy_models.workspace = true +anyhow.workspace = true +mockall = { workspace = true, optional = true } +thiserror.workspace = true diff --git a/academy_core/course/contracts/src/lib.rs b/academy_core/course/contracts/src/lib.rs new file mode 100644 index 0000000..ab4dc3c --- /dev/null +++ b/academy_core/course/contracts/src/lib.rs @@ -0,0 +1,117 @@ +use std::future::Future; + +use academy_models::{ + auth::{AccessToken, AuthError}, + course::{ + Course, CourseDescription, CourseId, CourseLanguage, CoursePatch, CourseTitle, + CourseWithSource, + }, + url::Url, +}; +use thiserror::Error; + +pub trait CourseFeatureService: Send + Sync + 'static { + /// Return all courses. + /// + /// Requires admin privileges. + fn list( + &self, + token: &AccessToken, + ) -> impl Future, CourseListError>> + Send; + + /// Return the course with the given id. + /// + /// Requires admin privileges. + fn get( + &self, + token: &AccessToken, + course_id: CourseId, + ) -> impl Future> + Send; + + /// Create a new course. + /// + /// Requires admin privileges. + fn create( + &self, + token: &AccessToken, + request: CourseCreateRequest, + ) -> impl Future> + Send; + + /// Update a course. + /// + /// Requires admin privileges. + fn update( + &self, + token: &AccessToken, + course_id: CourseId, + patch: CoursePatch, + ) -> impl Future> + Send; + + /// Delete a course. + /// + /// Requires admin privileges. + fn delete( + &self, + token: &AccessToken, + course_id: CourseId, + ) -> impl Future> + Send; +} + +#[derive(Debug, Error)] +pub enum CourseListError { + #[error(transparent)] + Auth(#[from] AuthError), + #[error(transparent)] + Other(#[from] anyhow::Error), +} + +#[derive(Debug, Error)] +pub enum CourseGetError { + #[error("The course does not exist.")] + NotFound, + #[error(transparent)] + Auth(#[from] AuthError), + #[error(transparent)] + Other(#[from] anyhow::Error), +} + +#[derive(Debug, Clone)] +pub struct CourseCreateRequest { + pub title: CourseTitle, + pub description: Option, + pub language: Option, + pub image: Option, + pub price: u32, +} + +#[derive(Debug, Error)] +pub enum CourseCreateError { + #[error("A course with the same title already exists.")] + TitleConflict, + #[error(transparent)] + Auth(#[from] AuthError), + #[error(transparent)] + Other(#[from] anyhow::Error), +} + +#[derive(Debug, Error)] +pub enum CourseUpdateError { + #[error("The course does not exist.")] + NotFound, + #[error("A course with the same title already exists.")] + TitleConflict, + #[error(transparent)] + Auth(#[from] AuthError), + #[error(transparent)] + Other(#[from] anyhow::Error), +} + +#[derive(Debug, Error)] +pub enum CourseDeleteError { + #[error("The course does not exist.")] + NotFound, + #[error(transparent)] + Auth(#[from] AuthError), + #[error(transparent)] + Other(#[from] anyhow::Error), +} diff --git a/academy_core/course/impl/Cargo.toml b/academy_core/course/impl/Cargo.toml new file mode 100644 index 0000000..24e4faf --- /dev/null +++ b/academy_core/course/impl/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "academy_core_course_impl" +version.workspace = true +edition.workspace = true +publish.workspace = true +homepage.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +academy_auth_contracts.workspace = true +academy_core_course_contracts.workspace = true +academy_di.workspace = true +academy_models.workspace = true +academy_persistence_contracts.workspace = true +academy_shared_contracts.workspace = true +academy_utils.workspace = true diff --git a/academy_core/course/impl/src/lib.rs b/academy_core/course/impl/src/lib.rs new file mode 100644 index 0000000..a81dda7 --- /dev/null +++ b/academy_core/course/impl/src/lib.rs @@ -0,0 +1,149 @@ +use academy_auth_contracts::{AuthResultExt, AuthService}; +use academy_core_course_contracts::{ + CourseCreateError, CourseCreateRequest, CourseDeleteError, CourseFeatureService, + CourseGetError, CourseListError, CourseUpdateError, +}; +use academy_di::Build; +use academy_models::{ + auth::AccessToken, + course::{Course, CourseId, CoursePatch, CourseWithSource}, +}; +use academy_persistence_contracts::{ + course::{CourseRepoError, CourseRepository}, + Database, Transaction, +}; +use academy_shared_contracts::{id::IdService, time::TimeService}; +use academy_utils::patch::Patch; + +#[derive(Debug, Clone, Build)] +pub struct CourseFeatureServiceImpl { + db: Db, + auth: Auth, + id: Id, + time: Time, + course_repo: CourseRepo, +} + +impl CourseFeatureService + for CourseFeatureServiceImpl +where + Db: Database, + Auth: AuthService, + Id: IdService, + Time: TimeService, + CourseRepo: CourseRepository, +{ + async fn list(&self, token: &AccessToken) -> Result, CourseListError> { + let auth = self.auth.authenticate(token).await.map_auth_err()?; + auth.ensure_admin().map_auth_err()?; + + let mut txn = self.db.begin_transaction().await?; + + self.course_repo.list(&mut txn).await.map_err(Into::into) + } + + async fn get( + &self, + token: &AccessToken, + course_id: CourseId, + ) -> Result { + let auth = self.auth.authenticate(token).await.map_auth_err()?; + auth.ensure_admin().map_auth_err()?; + + let mut txn = self.db.begin_transaction().await?; + + self.course_repo + .get_with_source(&mut txn, course_id) + .await? + .ok_or(CourseGetError::NotFound) + } + + async fn create( + &self, + token: &AccessToken, + request: CourseCreateRequest, + ) -> Result { + let auth = self.auth.authenticate(token).await.map_auth_err()?; + auth.ensure_admin().map_auth_err()?; + + let mut txn = self.db.begin_transaction().await?; + + let course = Course { + id: self.id.generate(), + title: request.title, + description: request.description, + updated_at: self.time.now(), + language: request.language, + image: request.image, + price: request.price, + content: (), + }; + + self.course_repo + .create(&mut txn, &course) + .await + .map_err(|err| match err { + CourseRepoError::TitleConflict => CourseCreateError::TitleConflict, + CourseRepoError::Other(err) => err.into(), + })?; + + txn.commit().await?; + + Ok(course) + } + + async fn update( + &self, + token: &AccessToken, + course_id: CourseId, + patch: CoursePatch, + ) -> Result { + let auth = self.auth.authenticate(token).await.map_auth_err()?; + auth.ensure_admin().map_auth_err()?; + + let mut txn = self.db.begin_transaction().await?; + + let course = self + .course_repo + .get(&mut txn, course_id) + .await? + .ok_or(CourseUpdateError::NotFound)?; + + let patch = patch.minimize(&course); + if patch.is_unchanged() { + return Ok(course); + } + + self.course_repo + .update(&mut txn, course_id, patch.as_ref()) + .await + .map_err(|err| match err { + CourseRepoError::TitleConflict => CourseUpdateError::TitleConflict, + CourseRepoError::Other(err) => err.into(), + })?; + let course = course.update(patch); + + txn.commit().await?; + + Ok(course) + } + + async fn delete( + &self, + token: &AccessToken, + course_id: CourseId, + ) -> Result<(), CourseDeleteError> { + let auth = self.auth.authenticate(token).await.map_auth_err()?; + auth.ensure_admin().map_auth_err()?; + + let mut txn = self.db.begin_transaction().await?; + + if !self.course_repo.delete(&mut txn, course_id).await? { + return Err(CourseDeleteError::NotFound); + } + + txn.commit().await?; + + Ok(()) + } +} diff --git a/academy_models/src/course.rs b/academy_models/src/course.rs new file mode 100644 index 0000000..05fcdd2 --- /dev/null +++ b/academy_models/src/course.rs @@ -0,0 +1,132 @@ +use std::time::Duration; + +use academy_utils::patch::Patch; +use chrono::{DateTime, Utc}; + +use crate::{macros::id, nutype_string, url::Url}; + +id!(CourseId); +id!(CourseAuthorId); +id!(CourseSectionId); +id!(CourseLessonId); + +pub type CourseWithContent = Course>; +pub type CourseSectionWithContent = CourseSection>; + +pub type CourseWithSource = Course>; +pub type CourseSectionWithSource = CourseSection>; +pub type CourseLessonWithSource = CourseLesson; + +#[derive(Debug, Clone, Patch)] +pub struct Course { + #[no_patch] + pub id: CourseId, + pub title: CourseTitle, + pub description: Option, + #[no_patch] + pub updated_at: DateTime, + pub language: Option, + pub image: Option, + pub price: u32, + #[no_patch] + pub content: C, +} + +impl Course { + pub fn with_content(self, content: C2) -> Course { + Course { + id: self.id, + title: self.title, + description: self.description, + updated_at: self.updated_at, + language: self.language, + image: self.image, + price: self.price, + content, + } + } +} + +nutype_string!(CourseTitle(validate(not_empty, len_char_max = 64))); +nutype_string!(CourseDescription(validate(not_empty, len_char_max = 4096))); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CourseLanguage { + English, + German, +} + +#[derive(Debug, Clone)] +pub struct CourseAuthor { + pub id: CourseAuthorId, + pub name: CourseAuthorName, + pub url: Option, +} + +nutype_string!(CourseAuthorName(validate(not_empty, len_char_max = 64))); + +#[derive(Debug, Clone)] +pub struct CourseSection { + pub id: CourseSectionId, + pub course_id: CourseId, + pub title: CourseSectionTitle, + pub description: Option, + pub sort_key: u32, + pub content: C, +} + +impl CourseSection { + pub fn with_content(self, content: C2) -> CourseSection { + CourseSection { + id: self.id, + course_id: self.course_id, + title: self.title, + description: self.description, + sort_key: self.sort_key, + content, + } + } +} + +nutype_string!(CourseSectionTitle(validate(not_empty, len_char_max = 64))); +nutype_string!(CourseSectionDescription(validate( + not_empty, + len_char_max = 4096 +))); + +#[derive(Debug, Clone)] +pub struct CourseLesson { + pub id: CourseLessonId, + pub section_id: CourseSectionId, + pub title: CourseLessonTitle, + pub description: Option, + pub duration: Duration, + pub sort_key: u32, + pub source: S, +} + +nutype_string!(CourseLessonTitle(validate(not_empty, len_char_max = 64))); +nutype_string!(CourseLessonDescription(validate( + not_empty, + len_char_max = 4096 +))); + +#[derive(Debug, Clone)] +pub enum CourseLessonSource { + Youtube(CourseLessonYoutubeSource), + Mp4(CourseLessonMp4Source), +} + +#[derive(Debug, Clone)] +pub struct CourseLessonYoutubeSource { + pub video_id: YoutubeVideoId, +} + +nutype_string!(YoutubeVideoId(validate(not_empty, len_char_max = 16))); + +#[derive(Debug, Clone)] +pub struct CourseLessonMp4Source { + pub path: Mp4VideoPath, +} + +nutype_string!(Mp4VideoPath(validate(not_empty, len_char_max = 256))); diff --git a/academy_models/src/lib.rs b/academy_models/src/lib.rs index a488e94..9a63e6b 100644 --- a/academy_models/src/lib.rs +++ b/academy_models/src/lib.rs @@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize}; pub mod auth; pub mod coin; pub mod contact; +pub mod course; pub mod email_address; pub mod heart; mod macros; diff --git a/academy_persistence/contracts/src/course.rs b/academy_persistence/contracts/src/course.rs new file mode 100644 index 0000000..5a88b26 --- /dev/null +++ b/academy_persistence/contracts/src/course.rs @@ -0,0 +1,58 @@ +use std::future::Future; + +use academy_models::course::{Course, CourseId, CoursePatchRef, CourseWithSource}; +use thiserror::Error; + +#[cfg_attr(feature = "mock", mockall::automock)] +pub trait CourseRepository: Send + Sync + 'static { + /// Return all courses. + fn list(&self, txn: &mut Txn) -> impl Future>> + Send; + + /// Return the course with the given id. + fn get( + &self, + txn: &mut Txn, + course_id: CourseId, + ) -> impl Future>> + Send; + + /// Return the course with the given id. + fn get_with_source( + &self, + txn: &mut Txn, + course_id: CourseId, + ) -> impl Future>> + Send; + + /// Create a new course. + fn create( + &self, + txn: &mut Txn, + course: &Course, + ) -> impl Future> + Send; + + /// Update an existing course. + #[allow(clippy::needless_lifetimes, reason = "automock")] + fn update<'a>( + &self, + txn: &mut Txn, + course_id: CourseId, + patch: CoursePatchRef<'a>, + ) -> impl Future> + Send; + + /// Delete an existing course. + fn delete( + &self, + txn: &mut Txn, + course_id: CourseId, + ) -> impl Future> + Send; +} + +#[derive(Debug, Error)] +pub enum CourseRepoError { + #[error("A course with the same title already exists.")] + TitleConflict, + #[error(transparent)] + Other(#[from] anyhow::Error), +} + +#[cfg(feature = "mock")] +impl MockCourseRepository {} diff --git a/academy_persistence/contracts/src/lib.rs b/academy_persistence/contracts/src/lib.rs index e3de7bc..5f9873a 100644 --- a/academy_persistence/contracts/src/lib.rs +++ b/academy_persistence/contracts/src/lib.rs @@ -1,6 +1,7 @@ use std::future::Future; pub mod coin; +pub mod course; pub mod heart; pub mod mfa; pub mod oauth2; diff --git a/academy_persistence/postgres/clorinde/src/queries.rs b/academy_persistence/postgres/clorinde/src/queries.rs index 88bbed2..4bc73ad 100644 --- a/academy_persistence/postgres/clorinde/src/queries.rs +++ b/academy_persistence/postgres/clorinde/src/queries.rs @@ -1,6 +1,7 @@ // This file was generated with `clorinde`. Do not modify. pub mod coin; +pub mod course; pub mod heart; pub mod mfa; pub mod oauth2; diff --git a/academy_persistence/postgres/clorinde/src/queries/course.rs b/academy_persistence/postgres/clorinde/src/queries/course.rs new file mode 100644 index 0000000..8921ac3 --- /dev/null +++ b/academy_persistence/postgres/clorinde/src/queries/course.rs @@ -0,0 +1,596 @@ +// This file was generated with `clorinde`. Do not modify. + +#[derive(Debug)] +pub struct CreateParams { + pub id: uuid::Uuid, + pub title: T1, + pub description: Option, + pub updated_at: crate::types::time::TimestampTz, + pub language: Option, + pub image: Option, + pub price: i32, +} +#[derive(Debug)] +pub struct UpdateParams { + pub title: Option, + pub clear_description: bool, + pub description: Option, + pub clear_language: bool, + pub language: Option, + pub clear_image: bool, + pub image: Option, + pub price: Option, + pub id: uuid::Uuid, +} +#[derive(Debug, Clone, PartialEq)] +pub struct Course { + pub id: uuid::Uuid, + pub title: String, + pub description: Option, + pub updated_at: crate::types::time::TimestampTz, + pub language: Option, + pub image: Option, + pub price: i32, +} +pub struct CourseBorrowed<'a> { + pub id: uuid::Uuid, + pub title: &'a str, + pub description: Option<&'a str>, + pub updated_at: crate::types::time::TimestampTz, + pub language: Option, + pub image: Option<&'a str>, + pub price: i32, +} +impl<'a> From> for Course { + fn from( + CourseBorrowed { + id, + title, + description, + updated_at, + language, + image, + price, + }: CourseBorrowed<'a>, + ) -> Self { + Self { + id, + title: title.into(), + description: description.map(|v| v.into()), + updated_at, + language, + image: image.map(|v| v.into()), + price, + } + } +} +#[derive(Debug, Clone, PartialEq)] +pub struct CourseSection { + pub id: uuid::Uuid, + pub course_id: uuid::Uuid, + pub title: String, + pub description: Option, + pub sort_key: i32, +} +pub struct CourseSectionBorrowed<'a> { + pub id: uuid::Uuid, + pub course_id: uuid::Uuid, + pub title: &'a str, + pub description: Option<&'a str>, + pub sort_key: i32, +} +impl<'a> From> for CourseSection { + fn from( + CourseSectionBorrowed { + id, + course_id, + title, + description, + sort_key, + }: CourseSectionBorrowed<'a>, + ) -> Self { + Self { + id, + course_id, + title: title.into(), + description: description.map(|v| v.into()), + sort_key, + } + } +} +#[derive(Debug, Clone, PartialEq)] +pub struct CourseLessonWithSource { + pub id: uuid::Uuid, + pub section_id: uuid::Uuid, + pub title: String, + pub description: Option, + pub duration: i32, + pub sort_key: i32, + pub video_id: Option, + pub path: Option, +} +pub struct CourseLessonWithSourceBorrowed<'a> { + pub id: uuid::Uuid, + pub section_id: uuid::Uuid, + pub title: &'a str, + pub description: Option<&'a str>, + pub duration: i32, + pub sort_key: i32, + pub video_id: Option<&'a str>, + pub path: Option<&'a str>, +} +impl<'a> From> for CourseLessonWithSource { + fn from( + CourseLessonWithSourceBorrowed { + id, + section_id, + title, + description, + duration, + sort_key, + video_id, + path, + }: CourseLessonWithSourceBorrowed<'a>, + ) -> Self { + Self { + id, + section_id, + title: title.into(), + description: description.map(|v| v.into()), + duration, + sort_key, + video_id: video_id.map(|v| v.into()), + path: path.map(|v| v.into()), + } + } +} +use crate::client::async_::GenericClient; +use futures::{self, StreamExt, TryStreamExt}; +pub struct CourseQuery<'c, 'a, 's, C: GenericClient, T, const N: usize> { + client: &'c C, + params: [&'a (dyn postgres_types::ToSql + Sync); N], + stmt: &'s mut crate::client::async_::Stmt, + extractor: fn(&tokio_postgres::Row) -> CourseBorrowed, + mapper: fn(CourseBorrowed) -> T, +} +impl<'c, 'a, 's, C, T: 'c, const N: usize> CourseQuery<'c, 'a, 's, C, T, N> +where + C: GenericClient, +{ + pub fn map(self, mapper: fn(CourseBorrowed) -> R) -> CourseQuery<'c, 'a, 's, C, R, N> { + CourseQuery { + client: self.client, + params: self.params, + stmt: self.stmt, + extractor: self.extractor, + mapper, + } + } + pub async fn one(self) -> Result { + let stmt = self.stmt.prepare(self.client).await?; + let row = self.client.query_one(stmt, &self.params).await?; + Ok((self.mapper)((self.extractor)(&row))) + } + pub async fn all(self) -> Result, tokio_postgres::Error> { + self.iter().await?.try_collect().await + } + pub async fn opt(self) -> Result, tokio_postgres::Error> { + let stmt = self.stmt.prepare(self.client).await?; + Ok(self + .client + .query_opt(stmt, &self.params) + .await? + .map(|row| (self.mapper)((self.extractor)(&row)))) + } + pub async fn iter( + self, + ) -> Result< + impl futures::Stream> + 'c, + tokio_postgres::Error, + > { + let stmt = self.stmt.prepare(self.client).await?; + let it = self + .client + .query_raw(stmt, crate::slice_iter(&self.params)) + .await? + .map(move |res| res.map(|row| (self.mapper)((self.extractor)(&row)))) + .into_stream(); + Ok(it) + } +} +pub struct CourseSectionQuery<'c, 'a, 's, C: GenericClient, T, const N: usize> { + client: &'c C, + params: [&'a (dyn postgres_types::ToSql + Sync); N], + stmt: &'s mut crate::client::async_::Stmt, + extractor: fn(&tokio_postgres::Row) -> CourseSectionBorrowed, + mapper: fn(CourseSectionBorrowed) -> T, +} +impl<'c, 'a, 's, C, T: 'c, const N: usize> CourseSectionQuery<'c, 'a, 's, C, T, N> +where + C: GenericClient, +{ + pub fn map( + self, + mapper: fn(CourseSectionBorrowed) -> R, + ) -> CourseSectionQuery<'c, 'a, 's, C, R, N> { + CourseSectionQuery { + client: self.client, + params: self.params, + stmt: self.stmt, + extractor: self.extractor, + mapper, + } + } + pub async fn one(self) -> Result { + let stmt = self.stmt.prepare(self.client).await?; + let row = self.client.query_one(stmt, &self.params).await?; + Ok((self.mapper)((self.extractor)(&row))) + } + pub async fn all(self) -> Result, tokio_postgres::Error> { + self.iter().await?.try_collect().await + } + pub async fn opt(self) -> Result, tokio_postgres::Error> { + let stmt = self.stmt.prepare(self.client).await?; + Ok(self + .client + .query_opt(stmt, &self.params) + .await? + .map(|row| (self.mapper)((self.extractor)(&row)))) + } + pub async fn iter( + self, + ) -> Result< + impl futures::Stream> + 'c, + tokio_postgres::Error, + > { + let stmt = self.stmt.prepare(self.client).await?; + let it = self + .client + .query_raw(stmt, crate::slice_iter(&self.params)) + .await? + .map(move |res| res.map(|row| (self.mapper)((self.extractor)(&row)))) + .into_stream(); + Ok(it) + } +} +pub struct CourseLessonWithSourceQuery<'c, 'a, 's, C: GenericClient, T, const N: usize> { + client: &'c C, + params: [&'a (dyn postgres_types::ToSql + Sync); N], + stmt: &'s mut crate::client::async_::Stmt, + extractor: fn(&tokio_postgres::Row) -> CourseLessonWithSourceBorrowed, + mapper: fn(CourseLessonWithSourceBorrowed) -> T, +} +impl<'c, 'a, 's, C, T: 'c, const N: usize> CourseLessonWithSourceQuery<'c, 'a, 's, C, T, N> +where + C: GenericClient, +{ + pub fn map( + self, + mapper: fn(CourseLessonWithSourceBorrowed) -> R, + ) -> CourseLessonWithSourceQuery<'c, 'a, 's, C, R, N> { + CourseLessonWithSourceQuery { + client: self.client, + params: self.params, + stmt: self.stmt, + extractor: self.extractor, + mapper, + } + } + pub async fn one(self) -> Result { + let stmt = self.stmt.prepare(self.client).await?; + let row = self.client.query_one(stmt, &self.params).await?; + Ok((self.mapper)((self.extractor)(&row))) + } + pub async fn all(self) -> Result, tokio_postgres::Error> { + self.iter().await?.try_collect().await + } + pub async fn opt(self) -> Result, tokio_postgres::Error> { + let stmt = self.stmt.prepare(self.client).await?; + Ok(self + .client + .query_opt(stmt, &self.params) + .await? + .map(|row| (self.mapper)((self.extractor)(&row)))) + } + pub async fn iter( + self, + ) -> Result< + impl futures::Stream> + 'c, + tokio_postgres::Error, + > { + let stmt = self.stmt.prepare(self.client).await?; + let it = self + .client + .query_raw(stmt, crate::slice_iter(&self.params)) + .await? + .map(move |res| res.map(|row| (self.mapper)((self.extractor)(&row)))) + .into_stream(); + Ok(it) + } +} +pub fn list() -> ListStmt { + ListStmt(crate::client::async_::Stmt::new("select * from courses")) +} +pub struct ListStmt(crate::client::async_::Stmt); +impl ListStmt { + pub fn bind<'c, 'a, 's, C: GenericClient>( + &'s mut self, + client: &'c C, + ) -> CourseQuery<'c, 'a, 's, C, Course, 0> { + CourseQuery { + client, + params: [], + stmt: &mut self.0, + extractor: |row| CourseBorrowed { + id: row.get(0), + title: row.get(1), + description: row.get(2), + updated_at: row.get(3), + language: row.get(4), + image: row.get(5), + price: row.get(6), + }, + mapper: |it| Course::from(it), + } + } +} +pub fn get() -> GetStmt { + GetStmt(crate::client::async_::Stmt::new( + "select * from courses where id=$1", + )) +} +pub struct GetStmt(crate::client::async_::Stmt); +impl GetStmt { + pub fn bind<'c, 'a, 's, C: GenericClient>( + &'s mut self, + client: &'c C, + id: &'a uuid::Uuid, + ) -> CourseQuery<'c, 'a, 's, C, Course, 1> { + CourseQuery { + client, + params: [id], + stmt: &mut self.0, + extractor: |row| CourseBorrowed { + id: row.get(0), + title: row.get(1), + description: row.get(2), + updated_at: row.get(3), + language: row.get(4), + image: row.get(5), + price: row.get(6), + }, + mapper: |it| Course::from(it), + } + } +} +pub fn list_sections_by_course() -> ListSectionsByCourseStmt { + ListSectionsByCourseStmt(crate::client::async_::Stmt::new( + "select * from course_sections where course_id=$1 order by sort_key asc", + )) +} +pub struct ListSectionsByCourseStmt(crate::client::async_::Stmt); +impl ListSectionsByCourseStmt { + pub fn bind<'c, 'a, 's, C: GenericClient>( + &'s mut self, + client: &'c C, + course_id: &'a uuid::Uuid, + ) -> CourseSectionQuery<'c, 'a, 's, C, CourseSection, 1> { + CourseSectionQuery { + client, + params: [course_id], + stmt: &mut self.0, + extractor: |row| CourseSectionBorrowed { + id: row.get(0), + course_id: row.get(1), + title: row.get(2), + description: row.get(3), + sort_key: row.get(4), + }, + mapper: |it| CourseSection::from(it), + } + } +} +pub fn list_lessons_with_source_by_course() -> ListLessonsWithSourceByCourseStmt { + ListLessonsWithSourceByCourseStmt(crate::client::async_::Stmt::new( + "with cte as ( select id as section_id from course_sections where course_id=$1 ) select * from course_lessons inner join cte using (section_id) left join course_youtube_lessons using (id) left join course_mp4_lessons using (id) order by sort_key asc", + )) +} +pub struct ListLessonsWithSourceByCourseStmt(crate::client::async_::Stmt); +impl ListLessonsWithSourceByCourseStmt { + pub fn bind<'c, 'a, 's, C: GenericClient>( + &'s mut self, + client: &'c C, + course_id: &'a uuid::Uuid, + ) -> CourseLessonWithSourceQuery<'c, 'a, 's, C, CourseLessonWithSource, 1> { + CourseLessonWithSourceQuery { + client, + params: [course_id], + stmt: &mut self.0, + extractor: |row| CourseLessonWithSourceBorrowed { + id: row.get(0), + section_id: row.get(1), + title: row.get(2), + description: row.get(3), + duration: row.get(4), + sort_key: row.get(5), + video_id: row.get(6), + path: row.get(7), + }, + mapper: |it| CourseLessonWithSource::from(it), + } + } +} +pub fn create() -> CreateStmt { + CreateStmt(crate::client::async_::Stmt::new( + "insert into courses (id, title, description, updated_at, language, image, price) values ($1, $2, $3, $4, $5, $6, $7)", + )) +} +pub struct CreateStmt(crate::client::async_::Stmt); +impl CreateStmt { + pub async fn bind< + 'c, + 'a, + 's, + C: GenericClient, + T1: crate::StringSql, + T2: crate::StringSql, + T3: crate::StringSql, + >( + &'s mut self, + client: &'c C, + id: &'a uuid::Uuid, + title: &'a T1, + description: &'a Option, + updated_at: &'a crate::types::time::TimestampTz, + language: &'a Option, + image: &'a Option, + price: &'a i32, + ) -> Result { + let stmt = self.0.prepare(client).await?; + client + .execute( + stmt, + &[id, title, description, updated_at, language, image, price], + ) + .await + } +} +impl< + 'a, + C: GenericClient + Send + Sync, + T1: crate::StringSql, + T2: crate::StringSql, + T3: crate::StringSql, + > + crate::client::async_::Params< + 'a, + 'a, + 'a, + CreateParams, + std::pin::Pin< + Box> + Send + 'a>, + >, + C, + > for CreateStmt +{ + fn params( + &'a mut self, + client: &'a C, + params: &'a CreateParams, + ) -> std::pin::Pin< + Box> + Send + 'a>, + > { + Box::pin(self.bind( + client, + ¶ms.id, + ¶ms.title, + ¶ms.description, + ¶ms.updated_at, + ¶ms.language, + ¶ms.image, + ¶ms.price, + )) + } +} +pub fn update() -> UpdateStmt { + UpdateStmt(crate::client::async_::Stmt::new( + "update courses set title=coalesce($1, title), description=case when $2 then null else coalesce($3, description) end, language=case when $4 then null else coalesce($5, language) end, image=case when $6 then null else coalesce($7, image) end, price=coalesce($8, price) where id=$9", + )) +} +pub struct UpdateStmt(crate::client::async_::Stmt); +impl UpdateStmt { + pub async fn bind< + 'c, + 'a, + 's, + C: GenericClient, + T1: crate::StringSql, + T2: crate::StringSql, + T3: crate::StringSql, + >( + &'s mut self, + client: &'c C, + title: &'a Option, + clear_description: &'a bool, + description: &'a Option, + clear_language: &'a bool, + language: &'a Option, + clear_image: &'a bool, + image: &'a Option, + price: &'a Option, + id: &'a uuid::Uuid, + ) -> Result { + let stmt = self.0.prepare(client).await?; + client + .execute( + stmt, + &[ + title, + clear_description, + description, + clear_language, + language, + clear_image, + image, + price, + id, + ], + ) + .await + } +} +impl< + 'a, + C: GenericClient + Send + Sync, + T1: crate::StringSql, + T2: crate::StringSql, + T3: crate::StringSql, + > + crate::client::async_::Params< + 'a, + 'a, + 'a, + UpdateParams, + std::pin::Pin< + Box> + Send + 'a>, + >, + C, + > for UpdateStmt +{ + fn params( + &'a mut self, + client: &'a C, + params: &'a UpdateParams, + ) -> std::pin::Pin< + Box> + Send + 'a>, + > { + Box::pin(self.bind( + client, + ¶ms.title, + ¶ms.clear_description, + ¶ms.description, + ¶ms.clear_language, + ¶ms.language, + ¶ms.clear_image, + ¶ms.image, + ¶ms.price, + ¶ms.id, + )) + } +} +pub fn delete() -> DeleteStmt { + DeleteStmt(crate::client::async_::Stmt::new( + "delete from courses where id=$1", + )) +} +pub struct DeleteStmt(crate::client::async_::Stmt); +impl DeleteStmt { + pub async fn bind<'c, 'a, 's, C: GenericClient>( + &'s mut self, + client: &'c C, + id: &'a uuid::Uuid, + ) -> Result { + let stmt = self.0.prepare(client).await?; + client.execute(stmt, &[id]).await + } +} diff --git a/academy_persistence/postgres/clorinde/src/types.rs b/academy_persistence/postgres/clorinde/src/types.rs index d16666d..af81016 100644 --- a/academy_persistence/postgres/clorinde/src/types.rs +++ b/academy_persistence/postgres/clorinde/src/types.rs @@ -8,6 +8,81 @@ pub mod time { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[allow(non_camel_case_types)] +pub enum CourseLanguage { + de, + en, +} +impl<'a> postgres_types::ToSql for CourseLanguage { + fn to_sql( + &self, + ty: &postgres_types::Type, + buf: &mut postgres_types::private::BytesMut, + ) -> Result> { + let s = match *self { + CourseLanguage::de => "de", + CourseLanguage::en => "en", + }; + buf.extend_from_slice(s.as_bytes()); + std::result::Result::Ok(postgres_types::IsNull::No) + } + fn accepts(ty: &postgres_types::Type) -> bool { + if ty.name() != "course_language" { + return false; + } + match *ty.kind() { + postgres_types::Kind::Enum(ref variants) => { + if variants.len() != 2 { + return false; + } + variants.iter().all(|v| match &**v { + "de" => true, + "en" => true, + _ => false, + }) + } + _ => false, + } + } + fn to_sql_checked( + &self, + ty: &postgres_types::Type, + out: &mut postgres_types::private::BytesMut, + ) -> Result> { + postgres_types::__to_sql_checked(self, ty, out) + } +} +impl<'a> postgres_types::FromSql<'a> for CourseLanguage { + fn from_sql( + ty: &postgres_types::Type, + buf: &'a [u8], + ) -> Result> { + match std::str::from_utf8(buf)? { + "de" => Ok(CourseLanguage::de), + "en" => Ok(CourseLanguage::en), + s => Result::Err(Into::into(format!("invalid variant `{}`", s))), + } + } + fn accepts(ty: &postgres_types::Type) -> bool { + if ty.name() != "course_language" { + return false; + } + match *ty.kind() { + postgres_types::Kind::Enum(ref variants) => { + if variants.len() != 2 { + return false; + } + variants.iter().all(|v| match &**v { + "de" => true, + "en" => true, + _ => false, + }) + } + _ => false, + } + } +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[allow(non_camel_case_types)] pub enum PremiumPlan { monthly, yearly, diff --git a/academy_persistence/postgres/migrations/2025-02-04-233403_create_course_tables/down.sql b/academy_persistence/postgres/migrations/2025-02-04-233403_create_course_tables/down.sql new file mode 100644 index 0000000..6511a4c --- /dev/null +++ b/academy_persistence/postgres/migrations/2025-02-04-233403_create_course_tables/down.sql @@ -0,0 +1,4 @@ +drop table course_lessons, course_youtube_lessons, course_mp4_lessons; +drop table course_sections; +drop table courses, course_authors, course_author_courses; +drop type course_language; diff --git a/academy_persistence/postgres/migrations/2025-02-04-233403_create_course_tables/up.sql b/academy_persistence/postgres/migrations/2025-02-04-233403_create_course_tables/up.sql new file mode 100644 index 0000000..54b0073 --- /dev/null +++ b/academy_persistence/postgres/migrations/2025-02-04-233403_create_course_tables/up.sql @@ -0,0 +1,54 @@ +create type course_language as enum ('de', 'en'); + +create table courses ( + id uuid primary key, + title text not null, + description text, + updated_at timestamp with time zone not null, + language course_language, + image text, + price integer not null check (price >= 0) +); +create unique index courses_title_idx on courses (lower(title)); + +create table course_authors ( + id uuid primary key, + name text not null, + url text +); +create unique index course_authors_name_idx on course_authors (lower(name)); + +create table course_author_courses ( + course_id uuid not null references courses(id) on delete cascade, + course_author_id uuid not null references course_authors(id) on delete cascade, + primary key (course_id, course_author_id) +); + +create table course_sections ( + id uuid primary key, + course_id uuid not null references courses(id) on delete cascade, + title text not null, + description text, + sort_key integer unique not null check (sort_key >= 0) +); +create unique index course_sections_title_idx on course_sections (lower(title)); + +create table course_lessons ( + id uuid primary key, + section_id uuid not null references course_sections(id) on delete cascade, + title text not null, + description text, + duration integer not null check (duration >= 0), + sort_key integer unique not null check (sort_key >= 0) +); +create unique index course_lessons_title_idx on course_lessons (lower(title)); + +create table course_youtube_lessons ( + id uuid primary key references course_lessons (id) on delete cascade, + video_id text not null +); + +create table course_mp4_lessons ( + id uuid primary key references course_lessons (id) on delete cascade, + path text not null +); diff --git a/academy_persistence/postgres/queries/course.sql b/academy_persistence/postgres/queries/course.sql new file mode 100644 index 0000000..f659d90 --- /dev/null +++ b/academy_persistence/postgres/queries/course.sql @@ -0,0 +1,39 @@ +--: Course (description?, language?, image?) +--: CourseSection (description?) +--: CourseLessonWithSource (description?, video_id?, path?) + +--! list : Course +select * from courses; + +--! get : Course +select * from courses where id=:id; + +--! list_sections_by_course : CourseSection +select * from course_sections where course_id=:course_id order by sort_key asc; + +--! list_lessons_with_source_by_course : CourseLessonWithSource +with cte as ( + select id as section_id from course_sections where course_id=:course_id +) +select * from course_lessons + inner join cte using (section_id) + left join course_youtube_lessons using (id) + left join course_mp4_lessons using (id) + order by sort_key asc; + +--! create (description?, language?, image?) +insert into courses (id, title, description, updated_at, language, image, price) + values (:id, :title, :description, :updated_at, :language, :image, :price); + +--! update (title?, description?, language?, image?, price?) +update courses + set + title=coalesce(:title, title), + description=case when :clear_description then null else coalesce(:description, description) end, + language=case when :clear_language then null else coalesce(:language, language) end, + image=case when :clear_image then null else coalesce(:image, image) end, + price=coalesce(:price, price) + where id=:id; + +--! delete +delete from courses where id=:id; diff --git a/academy_persistence/postgres/src/course.rs b/academy_persistence/postgres/src/course.rs new file mode 100644 index 0000000..d60be26 --- /dev/null +++ b/academy_persistence/postgres/src/course.rs @@ -0,0 +1,232 @@ +use std::{collections::HashMap, str::FromStr, time::Duration}; + +use academy_di::Build; +use academy_models::course::{ + Course, CourseId, CourseLanguage, CourseLessonMp4Source, CourseLessonSource, + CourseLessonWithSource, CourseLessonYoutubeSource, CoursePatchRef, CourseSection, + CourseWithSource, +}; +use academy_persistence_contracts::course::{CourseRepoError, CourseRepository}; +use academy_utils::trace_instrument; +use clorinde::{client::Params, queries, tokio_postgres, types}; +use futures::{StreamExt, TryStreamExt}; + +use crate::PostgresTransaction; + +#[derive(Debug, Clone, Build)] +pub struct PostgresCourseRepository; + +impl CourseRepository for PostgresCourseRepository { + #[trace_instrument(skip(self, txn))] + async fn list(&self, txn: &mut PostgresTransaction) -> anyhow::Result> { + queries::course::list() + .bind(txn.txn()) + .iter() + .await? + .map(|row| row.map_err(Into::into).and_then(decode_course)) + .try_collect() + .await + } + + #[trace_instrument(skip(self, txn))] + async fn get( + &self, + txn: &mut PostgresTransaction, + course_id: CourseId, + ) -> anyhow::Result> { + queries::course::get() + .bind(txn.txn(), &course_id) + .opt() + .await? + .map(decode_course) + .transpose() + } + + #[trace_instrument(skip(self, txn))] + async fn get_with_source( + &self, + txn: &mut PostgresTransaction, + course_id: CourseId, + ) -> anyhow::Result> { + let Some(course) = queries::course::get() + .bind(txn.txn(), &course_id) + .opt() + .await? + .map(decode_course) + .transpose()? + else { + return Ok(None); + }; + + let mut lessons = HashMap::<_, Vec<_>>::new(); + let mut stream = std::pin::pin!( + queries::course::list_lessons_with_source_by_course() + .bind(txn.txn(), &course_id) + .iter() + .await? + ); + while let Some(row) = stream.try_next().await? { + let lesson = decode_course_lesson_with_source(row)?; + lessons.entry(lesson.section_id).or_default().push(lesson); + } + + let sections = queries::course::list_sections_by_course() + .bind(txn.txn(), &course_id) + .iter() + .await? + .map(|row| row.map_err(Into::into).and_then(decode_course_section)) + .map_ok(|section| { + let lessons = lessons.get(§ion.id).cloned().unwrap_or_default(); + section.with_content(lessons) + }) + .try_collect::>() + .await?; + + Ok(Some(course.with_content(sections))) + } + + #[trace_instrument(skip(self, txn))] + async fn create( + &self, + txn: &mut PostgresTransaction, + course: &Course, + ) -> Result<(), CourseRepoError> { + let params = queries::course::CreateParams { + id: *course.id, + title: &*course.title, + description: course.description.as_deref(), + updated_at: course.updated_at.into(), + language: course.language.map(encode_course_language), + image: course.image.as_ref().map(|x| x.as_str()), + price: course.price.try_into().map_err(anyhow::Error::from)?, + }; + + queries::course::create() + .params(txn.txn(), ¶ms) + .await + .map_err(map_course_repo_error) + .map(|_| ()) + } + + #[trace_instrument(skip(self, txn))] + async fn update( + &self, + txn: &mut PostgresTransaction, + course_id: CourseId, + patch: CoursePatchRef<'_>, + ) -> Result { + let params = queries::course::UpdateParams { + id: *course_id, + title: patch.title.update().map(|x| &**x), + clear_description: patch.description.is_update_and(|x| x.is_none()), + description: patch.description.update().and_then(Option::as_deref), + clear_language: patch.language.is_update_and(|x| x.is_none()), + language: patch + .language + .update() + .and_then(|x| x.map(encode_course_language)), + clear_image: patch.image.is_update_and(|x| x.is_none()), + image: patch + .image + .update() + .and_then(|x| x.as_ref().map(|x| x.as_str())), + price: patch + .price + .update() + .copied() + .map(TryInto::try_into) + .transpose() + .map_err(anyhow::Error::from)?, + }; + + queries::course::update() + .params(txn.txn(), ¶ms) + .await + .map(|n| n != 0) + .map_err(map_course_repo_error) + } + + #[trace_instrument(skip(self, txn))] + async fn delete( + &self, + txn: &mut PostgresTransaction, + course_id: CourseId, + ) -> anyhow::Result { + queries::course::delete() + .bind(txn.txn(), &course_id) + .await + .map(|x| x != 0) + .map_err(Into::into) + } +} + +fn decode_course(value: queries::course::Course) -> anyhow::Result { + Ok(Course { + id: value.id.into(), + title: value.title.try_into()?, + description: value.description.map(TryInto::try_into).transpose()?, + updated_at: value.updated_at.into(), + language: value.language.map(decode_course_language), + image: value.image.as_deref().map(FromStr::from_str).transpose()?, + price: value.price.try_into()?, + content: (), + }) +} + +fn encode_course_language(value: CourseLanguage) -> types::CourseLanguage { + match value { + CourseLanguage::German => types::CourseLanguage::de, + CourseLanguage::English => types::CourseLanguage::en, + } +} + +fn decode_course_language(value: types::CourseLanguage) -> CourseLanguage { + match value { + types::CourseLanguage::de => CourseLanguage::German, + types::CourseLanguage::en => CourseLanguage::English, + } +} + +fn decode_course_section(value: queries::course::CourseSection) -> anyhow::Result { + Ok(CourseSection { + id: value.id.into(), + course_id: value.course_id.into(), + title: value.title.try_into()?, + description: value.description.map(TryInto::try_into).transpose()?, + sort_key: value.sort_key.try_into()?, + content: (), + }) +} + +fn decode_course_lesson_with_source( + value: queries::course::CourseLessonWithSource, +) -> anyhow::Result { + let source = match (value.video_id, value.path) { + (Some(video_id), None) => CourseLessonSource::Youtube(CourseLessonYoutubeSource { + video_id: video_id.try_into()?, + }), + (None, Some(path)) => CourseLessonSource::Mp4(CourseLessonMp4Source { + path: path.try_into()?, + }), + _ => anyhow::bail!("Invalid source of course lesson {}", value.id), + }; + + Ok(CourseLessonWithSource { + id: value.id.into(), + section_id: value.section_id.into(), + title: value.title.try_into()?, + description: value.description.map(TryInto::try_into).transpose()?, + duration: Duration::from_secs(value.duration.try_into()?), + sort_key: value.sort_key.try_into()?, + source, + }) +} + +fn map_course_repo_error(err: tokio_postgres::Error) -> CourseRepoError { + match err.as_db_error() { + Some(err) if err.constraint() == Some("courses_title_idx") => { + CourseRepoError::TitleConflict + } + _ => CourseRepoError::Other(err.into()), + } +} diff --git a/academy_persistence/postgres/src/lib.rs b/academy_persistence/postgres/src/lib.rs index e47c557..5eeddc5 100644 --- a/academy_persistence/postgres/src/lib.rs +++ b/academy_persistence/postgres/src/lib.rs @@ -13,6 +13,7 @@ use ouroboros::self_referencing; use tracing::trace; pub mod coin; +pub mod course; pub mod heart; pub mod mfa; pub mod oauth2;