Skip to content

Commit a11d3ce

Browse files
committed
feat(course): add purchase course endpoint
1 parent e805885 commit a11d3ce

File tree

23 files changed

+960
-18
lines changed

23 files changed

+960
-18
lines changed

Cargo.lock

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.nix

Lines changed: 32 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

academy/src/environment/types.rs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,10 @@ use academy_extern_impl::{
4343
render::RenderApiServiceImpl, vat::VatApiServiceImpl,
4444
};
4545
use academy_persistence_postgres::{
46-
PostgresDatabase, coin::PostgresCoinRepository, heart::PostgresHeartRepository,
47-
mfa::PostgresMfaRepository, oauth2::PostgresOAuth2Repository, paypal::PostgresPaypalRepository,
48-
premium::PostgresPremiumRepository, session::PostgresSessionRepository,
49-
user::PostgresUserRepository,
46+
PostgresDatabase, coin::PostgresCoinRepository, course::PostgresCourseRepository,
47+
heart::PostgresHeartRepository, mfa::PostgresMfaRepository, oauth2::PostgresOAuth2Repository,
48+
paypal::PostgresPaypalRepository, premium::PostgresPremiumRepository,
49+
session::PostgresSessionRepository, user::PostgresUserRepository,
5050
};
5151
use academy_shared_impl::{
5252
captcha::CaptchaServiceImpl, fs::FsServiceImpl, hash::HashServiceImpl, id::IdServiceImpl,
@@ -113,6 +113,7 @@ pub type CoinRepo = PostgresCoinRepository;
113113
pub type PaypalRepo = PostgresPaypalRepository;
114114
pub type HeartRepo = PostgresHeartRepository;
115115
pub type PremiumRepo = PostgresPremiumRepository;
116+
pub type CourseRepo = PostgresCourseRepository;
116117

117118
// Auth
118119
pub type Auth =
@@ -233,6 +234,6 @@ pub type PremiumPlan = PremiumPlanServiceImpl;
233234
pub type Premium = PremiumServiceImpl<Time, PremiumPurchase, PremiumRepo>;
234235
pub type PremiumPurchase = PremiumPurchaseServiceImpl<Id, Time, Coin, PremiumPlan, PremiumRepo>;
235236

236-
pub type CourseFeature = CourseFeatureServiceImpl;
237+
pub type CourseFeature = CourseFeatureServiceImpl<Database, Auth, Coin, CourseRepo>;
237238

238239
pub type Internal = InternalServiceImpl<Database, AuthInternal, UserRepo, Coin, Heart, Premium>;

academy_api/rest/src/routes/course.rs

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,41 @@
11
use std::sync::Arc;
22

3-
use academy_core_course_contracts::CourseFeatureService;
3+
use academy_core_course_contracts::{CourseFeatureService, CoursePurchaseError};
4+
use academy_models::course::CourseId;
45
use aide::{
56
axum::{ApiRouter, routing},
67
transform::TransformOperation,
78
};
89
use axum::{
910
Json,
10-
extract::{Query, State},
11+
extract::{Path, Query, State},
1112
http::StatusCode,
1213
response::{IntoResponse, Response},
1314
};
15+
use schemars::JsonSchema;
16+
use serde::Deserialize;
1417

1518
use crate::{
1619
docs::TransformOperationExt,
17-
errors::{internal_server_error, internal_server_error_docs},
18-
models::course::{ApiCourseFilter, ApiCourseUserSummary},
20+
error_code,
21+
errors::{auth_error, auth_error_docs, internal_server_error, internal_server_error_docs},
22+
extractors::auth::ApiToken,
23+
models::{
24+
OkResponse,
25+
course::{ApiCourseFilter, ApiCourseUserSummary},
26+
},
27+
routes::coin::NotEnoughCoinsError,
1928
};
2029

2130
pub const TAG: &str = "Courses";
2231

2332
pub fn router(service: Arc<impl CourseFeatureService>) -> ApiRouter<()> {
2433
ApiRouter::new()
2534
.api_route("/skills/courses", routing::get_with(list, list_docs))
35+
.api_route(
36+
"/skills/course_access/{course_id}",
37+
routing::post_with(purchase, purchase_docs),
38+
)
2639
.with_state(service)
2740
.with_path_items(|op| op.tag(TAG))
2841
}
@@ -48,3 +61,44 @@ fn list_docs(op: TransformOperation) -> TransformOperation {
4861
.add_response::<Vec<ApiCourseUserSummary>>(StatusCode::OK, None)
4962
.with(internal_server_error_docs)
5063
}
64+
65+
#[derive(Deserialize, JsonSchema)]
66+
struct PurchasePath {
67+
course_id: CourseId,
68+
}
69+
70+
async fn purchase(
71+
service: State<Arc<impl CourseFeatureService>>,
72+
token: ApiToken,
73+
Path(PurchasePath { course_id }): Path<PurchasePath>,
74+
) -> Response {
75+
match service.purchase(&token.0, course_id).await {
76+
Ok(()) => Json(OkResponse).into_response(),
77+
Err(CoursePurchaseError::CourseNotFound) => CourseNotFoundError.into_response(),
78+
Err(CoursePurchaseError::CourseIsFree) => CourseIsFreeError.into_response(),
79+
Err(CoursePurchaseError::AlreadyPurchased) => AlreadyPurchasedError.into_response(),
80+
Err(CoursePurchaseError::NotEnoughCoins) => NotEnoughCoinsError.into_response(),
81+
Err(CoursePurchaseError::Auth(err)) => auth_error(err),
82+
Err(CoursePurchaseError::Other(err)) => internal_server_error(err),
83+
}
84+
}
85+
86+
fn purchase_docs(op: TransformOperation) -> TransformOperation {
87+
op.summary("Purchase a course for the authenticated user")
88+
.add_response::<OkResponse>(StatusCode::OK, "The course has been purchased.")
89+
.add_error::<CourseNotFoundError>()
90+
.add_error::<CourseIsFreeError>()
91+
.add_error::<AlreadyPurchasedError>()
92+
.add_error::<NotEnoughCoinsError>()
93+
.with(auth_error_docs)
94+
.with(internal_server_error_docs)
95+
}
96+
97+
error_code! {
98+
/// The course does not exist.
99+
CourseNotFoundError(NOT_FOUND, "Course not found");
100+
/// The course is free.
101+
CourseIsFreeError(FORBIDDEN, "Course is free");
102+
/// The user has already purchased this course.
103+
AlreadyPurchasedError(FORBIDDEN, "Already purchased course");
104+
}

academy_core/course/contracts/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ workspace = true
1212
[dependencies]
1313
academy_models.workspace = true
1414
anyhow.workspace = true
15+
thiserror.workspace = true
Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,38 @@
1-
use academy_models::course::{CourseFilter, CourseUserSummary};
1+
use academy_models::{
2+
auth::{AccessToken, AuthError},
3+
course::{CourseFilter, CourseId, CourseUserSummary},
4+
};
5+
use thiserror::Error;
26

37
pub trait CourseFeatureService: Send + Sync + 'static {
48
/// Return summaries of all courses.
59
fn list(
610
&self,
711
filter: CourseFilter,
812
) -> impl Future<Output = anyhow::Result<Vec<CourseUserSummary>>> + Send;
13+
14+
/// Purchase a course for the authenticated user.
15+
///
16+
/// Requires a verified email address.
17+
fn purchase(
18+
&self,
19+
token: &AccessToken,
20+
course_id: CourseId,
21+
) -> impl Future<Output = Result<(), CoursePurchaseError>> + Send;
22+
}
23+
24+
#[derive(Debug, Error)]
25+
pub enum CoursePurchaseError {
26+
#[error("The course does not exist.")]
27+
CourseNotFound,
28+
#[error("The user has already purchased this course.")]
29+
AlreadyPurchased,
30+
#[error("The course is free.")]
31+
CourseIsFree,
32+
#[error("The user does not have enough coins.")]
33+
NotEnoughCoins,
34+
#[error(transparent)]
35+
Auth(#[from] AuthError),
36+
#[error(transparent)]
37+
Other(#[from] anyhow::Error),
938
}

academy_core/course/impl/Cargo.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,20 @@ repository.workspace = true
1010
workspace = true
1111

1212
[dependencies]
13+
academy_auth_contracts.workspace = true
14+
academy_core_coin_contracts.workspace = true
1315
academy_core_course_contracts.workspace = true
1416
academy_data.workspace = true
1517
academy_di.workspace = true
1618
academy_models.workspace = true
19+
academy_persistence_contracts.workspace = true
1720
academy_utils.workspace = true
1821
anyhow.workspace = true
1922
tracing.workspace = true
2023

2124
[dev-dependencies]
25+
academy_auth_contracts = { workspace = true, features = ["mock"] }
26+
academy_core_coin_contracts = { workspace = true, features = ["mock"] }
2227
academy_demo.workspace = true
28+
academy_persistence_contracts = { workspace = true, features = ["mock"] }
2329
tokio.workspace = true

academy_core/course/impl/src/lib.rs

Lines changed: 84 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,35 @@
1-
use academy_core_course_contracts::CourseFeatureService;
1+
use academy_auth_contracts::{AuthResultExt, AuthService};
2+
use academy_core_coin_contracts::coin::{CoinAddCoinsError, CoinService};
3+
use academy_core_course_contracts::{CourseFeatureService, CoursePurchaseError};
24
use academy_data::course::CourseDataRepository;
35
use academy_di::Build;
4-
use academy_models::course::{CourseFilter, CourseUserSummary};
5-
use academy_utils::trace_instrument;
6+
use academy_models::{
7+
auth::AccessToken,
8+
course::{CourseFilter, CourseId, CourseUserPatchRef, CourseUserSummary},
9+
};
10+
use academy_persistence_contracts::{Database, Transaction, course::CourseRepository};
11+
use academy_utils::{patch::PatchValue, trace_instrument};
612

713
#[cfg(test)]
814
mod tests;
915

1016
#[derive(Debug, Clone, Default, Build)]
11-
pub struct CourseFeatureServiceImpl {
17+
pub struct CourseFeatureServiceImpl<Db, Auth, Coin, CourseRepo> {
18+
db: Db,
19+
auth: Auth,
20+
coin: Coin,
21+
course_repo: CourseRepo,
1222
course_data_repo: CourseDataRepository,
1323
}
1424

15-
impl CourseFeatureService for CourseFeatureServiceImpl {
25+
impl<Db, Auth, Coin, CourseRepo> CourseFeatureService
26+
for CourseFeatureServiceImpl<Db, Auth, Coin, CourseRepo>
27+
where
28+
Db: Database,
29+
Auth: AuthService<Db::Transaction>,
30+
Coin: CoinService<Db::Transaction>,
31+
CourseRepo: CourseRepository<Db::Transaction>,
32+
{
1633
#[trace_instrument(no_ret, skip(self))]
1734
async fn list(&self, filter: CourseFilter) -> anyhow::Result<Vec<CourseUserSummary>> {
1835
let mut courses = self
@@ -47,4 +64,66 @@ impl CourseFeatureService for CourseFeatureServiceImpl {
4764

4865
Ok(courses)
4966
}
67+
68+
#[trace_instrument(skip(self))]
69+
async fn purchase(
70+
&self,
71+
token: &AccessToken,
72+
course_id: CourseId,
73+
) -> Result<(), CoursePurchaseError> {
74+
let auth = self.auth.authenticate(token).await.map_auth_err()?;
75+
auth.ensure_email_verified().map_auth_err()?;
76+
77+
let course = self
78+
.course_data_repo
79+
.get(&course_id)
80+
.ok_or(CoursePurchaseError::CourseNotFound)?;
81+
82+
if course.base.price == 0 {
83+
return Err(CoursePurchaseError::CourseIsFree);
84+
}
85+
86+
let mut txn = self.db.begin_transaction().await?;
87+
88+
let course_user = self
89+
.course_repo
90+
.get_course_user(&mut txn, &course_id, auth.user_id)
91+
.await?;
92+
if course_user.purchased {
93+
return Err(CoursePurchaseError::AlreadyPurchased);
94+
}
95+
96+
let transaction_description = format!("Course \"{}\"", *course.base.title)
97+
.try_into()
98+
.map_err(anyhow::Error::from)?;
99+
self.coin
100+
.add_coins(
101+
&mut txn,
102+
auth.user_id,
103+
-(course.base.price as i64),
104+
false,
105+
Some(transaction_description),
106+
false,
107+
)
108+
.await
109+
.map_err(|err| match err {
110+
CoinAddCoinsError::NotEnoughCoins => CoursePurchaseError::NotEnoughCoins,
111+
CoinAddCoinsError::Other(err) => err.into(),
112+
})?;
113+
114+
self.course_repo
115+
.update_course_user(
116+
&mut txn,
117+
&course_id,
118+
auth.user_id,
119+
CourseUserPatchRef {
120+
purchased: PatchValue::Update(&true),
121+
},
122+
)
123+
.await?;
124+
125+
txn.commit().await?;
126+
127+
Ok(())
128+
}
50129
}

0 commit comments

Comments
 (0)