Skip to content

Commit 8209887

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

File tree

27 files changed

+1094
-22
lines changed

27 files changed

+1094
-22
lines changed

Cargo.lock

Lines changed: 6 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: 45 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: 7 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,7 @@ 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 =
238+
CourseFeatureServiceImpl<Database, Auth, Coin, TemplateEmail, UserRepo, CourseRepo>;
237239

238240
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+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{% extends "base" %}
2+
{% block title %}Kaufbestätigung{% endblock title %}
3+
{% block content %}
4+
<p>
5+
Danke für den Kauf des Kurses "{{ title }}"!
6+
Wir wünschen dir viel Spaß und Erfolg damit.
7+
</p>
8+
{% endblock content %}

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: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,23 @@ 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
18+
academy_email_contracts.workspace = true
1619
academy_models.workspace = true
20+
academy_persistence_contracts.workspace = true
21+
academy_templates_contracts.workspace = true
1722
academy_utils.workspace = true
1823
anyhow.workspace = true
1924
tracing.workspace = true
2025

2126
[dev-dependencies]
27+
academy_auth_contracts = { workspace = true, features = ["mock"] }
28+
academy_core_coin_contracts = { workspace = true, features = ["mock"] }
2229
academy_demo.workspace = true
30+
academy_email_contracts = { workspace = true, features = ["mock"] }
31+
academy_persistence_contracts = { workspace = true, features = ["mock"] }
2332
tokio.workspace = true

0 commit comments

Comments
 (0)