Skip to content

Commit 4b18db2

Browse files
authored
Merge pull request #115 from refactor-group/delete_coaching_sessions
2 parents ac68e4b + 961d084 commit 4b18db2

File tree

7 files changed

+248
-65
lines changed

7 files changed

+248
-65
lines changed

domain/src/coaching_session.rs

+58-62
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,38 @@
11
use crate::coaching_sessions::Model;
2-
use crate::error::{DomainErrorKind, Error, ExternalErrorKind, InternalErrorKind};
3-
use crate::gateway::tiptap::client as tiptap_client;
2+
use crate::error::{DomainErrorKind, Error, InternalErrorKind};
3+
use crate::gateway::tiptap::TiptapDocument;
44
use crate::Id;
5-
use chrono::{DurationRound, TimeDelta};
5+
use chrono::{DurationRound, NaiveDateTime, TimeDelta};
66
use entity_api::{
77
coaching_relationship, coaching_session, coaching_sessions, mutate, organization, query,
88
query::IntoQueryFilterMap,
99
};
1010
use log::*;
1111
use sea_orm::{DatabaseConnection, IntoActiveModel};
12-
use serde_json::json;
1312
use service::config::Config;
1413

1514
pub use entity_api::coaching_session::{find_by_id, find_by_id_with_coaching_relationship};
1615

16+
#[derive(Debug, Clone)]
17+
struct SessionDate(NaiveDateTime);
18+
19+
impl SessionDate {
20+
fn new(date: NaiveDateTime) -> Result<Self, Error> {
21+
let truncated = date.duration_trunc(TimeDelta::minutes(1)).map_err(|err| {
22+
warn!("Failed to truncate date_time: {:?}", err);
23+
Error {
24+
source: Some(Box::new(err)),
25+
error_kind: DomainErrorKind::Internal(InternalErrorKind::Other),
26+
}
27+
})?;
28+
Ok(Self(truncated))
29+
}
30+
31+
fn into_inner(self) -> NaiveDateTime {
32+
self.0
33+
}
34+
}
35+
1736
pub async fn create(
1837
db: &DatabaseConnection,
1938
config: &Config,
@@ -23,69 +42,20 @@ pub async fn create(
2342
coaching_relationship::find_by_id(db, coaching_session_model.coaching_relationship_id)
2443
.await?;
2544
let organization = organization::find_by_id(db, coaching_relationship.organization_id).await?;
26-
// Remove seconds because all coaching_sessions will be scheduled by the minute
27-
// TODO: we might consider codifying this in the type system at some point.
28-
let date_time = coaching_session_model
29-
.date
30-
.duration_trunc(TimeDelta::minutes(1))
31-
.map_err(|err| {
32-
warn!("Failed to truncate date_time: {:?}", err);
33-
Error {
34-
source: Some(Box::new(err)),
35-
error_kind: DomainErrorKind::Internal(InternalErrorKind::Other),
36-
}
37-
})?;
38-
coaching_session_model.date = date_time;
39-
let document_name = format!(
40-
"{}.{}.{}-v0",
41-
organization.slug,
42-
coaching_relationship.slug,
43-
Id::new_v4()
44-
);
45+
46+
coaching_session_model.date = SessionDate::new(coaching_session_model.date)?.into_inner();
47+
48+
let document_name = generate_document_name(&organization.slug, &coaching_relationship.slug);
4549
info!(
4650
"Attempting to create Tiptap document with name: {}",
4751
document_name
4852
);
4953
coaching_session_model.collab_document_name = Some(document_name.clone());
50-
let tiptap_url = config.tiptap_url().ok_or_else(|| {
51-
warn!("Failed to get Tiptap URL from config");
52-
Error {
53-
source: None,
54-
error_kind: DomainErrorKind::Internal(InternalErrorKind::Other),
55-
}
56-
})?;
57-
let full_url = format!("{}/api/documents/{}?format=json", tiptap_url, document_name);
58-
let client = tiptap_client(config).await?;
59-
60-
let request = client
61-
.post(full_url)
62-
.json(&json!({"type": "doc", "content": []}));
63-
let response = match request.send().await {
64-
Ok(response) => {
65-
info!("Tiptap response: {:?}", response);
66-
response
67-
}
68-
Err(e) => {
69-
warn!("Failed to send request: {:?}", e);
70-
return Err(e.into());
71-
}
72-
};
73-
74-
// Tiptap's API will return a 200 for successful creation of a new document
75-
// and will return a 409 if the document already exists. We consider both "successful".
76-
if response.status().is_success() || response.status().as_u16() == 409 {
77-
// TODO: Save document_name to record
78-
Ok(coaching_session::create(db, coaching_session_model).await?)
79-
} else {
80-
warn!(
81-
"Failed to create Tiptap document: {}",
82-
response.text().await?
83-
);
84-
Err(Error {
85-
source: None,
86-
error_kind: DomainErrorKind::External(ExternalErrorKind::Network),
87-
})
88-
}
54+
55+
let tiptap = TiptapDocument::new(config).await?;
56+
tiptap.create(&document_name).await?;
57+
58+
Ok(coaching_session::create(db, coaching_session_model).await?)
8959
}
9060

9161
pub async fn find_by(
@@ -117,3 +87,29 @@ pub async fn update(
11787
.await?,
11888
)
11989
}
90+
91+
pub async fn delete(db: &DatabaseConnection, config: &Config, id: Id) -> Result<(), Error> {
92+
let coaching_session = find_by_id(db, id).await?;
93+
let document_name = coaching_session.collab_document_name.ok_or_else(|| {
94+
warn!("Failed to get document name from coaching session");
95+
Error {
96+
source: None,
97+
error_kind: DomainErrorKind::Internal(InternalErrorKind::Other),
98+
}
99+
})?;
100+
101+
let tiptap = TiptapDocument::new(config).await?;
102+
tiptap.delete(&document_name).await?;
103+
104+
coaching_session::delete(db, id).await?;
105+
Ok(())
106+
}
107+
108+
fn generate_document_name(organization_slug: &str, relationship_slug: &str) -> String {
109+
format!(
110+
"{}.{}.{}-v0",
111+
organization_slug,
112+
relationship_slug,
113+
Id::new_v4()
114+
)
115+
}

domain/src/gateway/tiptap.rs

+81-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
use crate::error::{DomainErrorKind, Error, InternalErrorKind};
1+
use crate::error::{DomainErrorKind, Error, ExternalErrorKind, InternalErrorKind};
22
use log::*;
3+
use serde_json::json;
34
use service::config::Config;
45

56
/// HTTP client for making requests to Tiptap. This client is configured with the necessary
@@ -33,3 +34,82 @@ async fn build_auth_headers(config: &Config) -> Result<reqwest::header::HeaderMa
3334
headers.insert(reqwest::header::AUTHORIZATION, auth_value);
3435
Ok(headers)
3536
}
37+
38+
pub struct TiptapDocument {
39+
client: reqwest::Client,
40+
base_url: String,
41+
}
42+
43+
impl TiptapDocument {
44+
pub async fn new(config: &Config) -> Result<Self, Error> {
45+
let client = client(config).await?;
46+
let base_url = config.tiptap_url().ok_or_else(|| {
47+
warn!("Failed to get Tiptap URL from config");
48+
Error {
49+
source: None,
50+
error_kind: DomainErrorKind::Internal(InternalErrorKind::Other),
51+
}
52+
})?;
53+
Ok(Self { client, base_url })
54+
}
55+
56+
pub async fn create(&self, document_name: &str) -> Result<(), Error> {
57+
let url = self.format_url(document_name);
58+
let response = self
59+
.client
60+
.post(url)
61+
.json(&json!({"type": "doc", "content": []}))
62+
.send()
63+
.await
64+
.map_err(|e| {
65+
warn!("Failed to send request: {:?}", e);
66+
Error {
67+
source: Some(Box::new(e)),
68+
error_kind: DomainErrorKind::External(ExternalErrorKind::Network),
69+
}
70+
})?;
71+
72+
if response.status().is_success() || response.status().as_u16() == 409 {
73+
Ok(())
74+
} else {
75+
let error_text = response.text().await.unwrap_or_default();
76+
warn!("Failed to create Tiptap document: {}", error_text);
77+
Err(Error {
78+
source: None,
79+
error_kind: DomainErrorKind::External(ExternalErrorKind::Network),
80+
})
81+
}
82+
}
83+
84+
pub async fn delete(&self, document_name: &str) -> Result<(), Error> {
85+
let url = self.format_url(document_name);
86+
let response = self.client.delete(url).send().await.map_err(|e| {
87+
warn!("Failed to send request: {:?}", e);
88+
Error {
89+
source: Some(Box::new(e)),
90+
error_kind: DomainErrorKind::External(ExternalErrorKind::Network),
91+
}
92+
})?;
93+
94+
let status = response.status();
95+
if status.is_success() || status.as_u16() == 404 {
96+
Ok(())
97+
} else {
98+
warn!(
99+
"Failed to delete Tiptap document: {}, with status: {}",
100+
document_name, status
101+
);
102+
Err(Error {
103+
source: None,
104+
error_kind: DomainErrorKind::External(ExternalErrorKind::Network),
105+
})
106+
}
107+
}
108+
109+
fn format_url(&self, document_name: &str) -> String {
110+
format!(
111+
"{}/api/documents/{}?format=json",
112+
self.base_url, document_name
113+
)
114+
}
115+
}

entity/src/jwts.rs

+1-2
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,7 @@ use utoipa::ToSchema;
77
/// This struct contains two fields:
88
///
99
/// - `token`: A string representing the JWT.
10-
/// - `sub`: A string representing the subject of the JWT for conveniently accessing
11-
/// the subject without having to decode the JWT.
10+
/// - `sub`: A string representing the subject of the JWT for conveniently accessing the subject without having to decode the JWT.
1211
#[derive(Serialize, Debug, ToSchema)]
1312
#[schema(as = jwt::Jwt)] // OpenAPI schema
1413
pub struct Jwt {

entity_api/src/coaching_session.rs

+25
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ pub async fn find_by_id_with_coaching_relationship(
5858
error_kind: EntityApiErrorKind::RecordNotFound,
5959
})
6060
}
61+
62+
pub async fn delete(db: &impl ConnectionTrait, coaching_session_id: Id) -> Result<(), Error> {
63+
Entity::delete_by_id(coaching_session_id).exec(db).await?;
64+
Ok(())
65+
}
66+
6167
#[cfg(test)]
6268
// We need to gate seaORM's mock feature behind conditional compilation because
6369
// the feature removes the Clone trait implementation from seaORM's DatabaseConnection.
@@ -135,4 +141,23 @@ mod tests {
135141

136142
Ok(())
137143
}
144+
145+
#[tokio::test]
146+
async fn delete_deletes_a_single_record() -> Result<(), Error> {
147+
let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection();
148+
149+
let coaching_session_id = Id::new_v4();
150+
let _ = delete(&db, coaching_session_id).await;
151+
152+
assert_eq!(
153+
db.into_transaction_log(),
154+
[Transaction::from_sql_and_values(
155+
DatabaseBackend::Postgres,
156+
r#"DELETE FROM "refactor_platform"."coaching_sessions" WHERE "coaching_sessions"."id" = $1"#,
157+
[coaching_session_id.into(),]
158+
)]
159+
);
160+
161+
Ok(())
162+
}
138163
}

web/src/controller/coaching_session_controller.rs

+29
Original file line numberDiff line numberDiff line change
@@ -123,3 +123,32 @@ pub async fn update(
123123
CoachingSessionApi::update(app_state.db_conn_ref(), coaching_session_id, params).await?;
124124
Ok(Json(ApiResponse::new(StatusCode::NO_CONTENT.into(), ())))
125125
}
126+
127+
/// DELETE a Coaching Session
128+
#[utoipa::path(
129+
delete,
130+
path = "/coaching_sessions/{id}",
131+
params(ApiVersion, ("id" = Id, Path, description = "Coaching Session ID to Delete")),
132+
responses(
133+
(status = 204, description = "Successfully deleted a Coaching Session", body = ()),
134+
(status = 401, description = "Unauthorized"),
135+
),
136+
security(
137+
("cookie_auth" = [])
138+
)
139+
)]
140+
pub async fn delete(
141+
CompareApiVersion(_v): CompareApiVersion,
142+
AuthenticatedUser(_user): AuthenticatedUser,
143+
State(app_state): State<AppState>,
144+
Path(coaching_session_id): Path<Id>,
145+
) -> Result<impl IntoResponse, Error> {
146+
CoachingSessionApi::delete(
147+
app_state.db_conn_ref(),
148+
&app_state.config,
149+
coaching_session_id,
150+
)
151+
.await?;
152+
153+
Ok(Json(ApiResponse::new(StatusCode::NO_CONTENT.into(), ())))
154+
}

web/src/protect/coaching_sessions.rs

+41
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,44 @@ pub(crate) async fn update(
8484
(StatusCode::UNAUTHORIZED, "UNAUTHORIZED").into_response()
8585
}
8686
}
87+
88+
/// Checks that coaching session record referenced by `coaching_session_id`
89+
/// * exists
90+
/// * that the authenticated user is associated with it.
91+
/// * that the authenticated user is the coach
92+
/// Intended to be given to axum::middleware::from_fn_with_state in the router
93+
pub(crate) async fn delete(
94+
State(app_state): State<AppState>,
95+
AuthenticatedUser(user): AuthenticatedUser,
96+
Path(coaching_session_id): Path<Id>,
97+
request: Request,
98+
next: Next,
99+
) -> impl IntoResponse {
100+
let coaching_session =
101+
match coaching_session::find_by_id(app_state.db_conn_ref(), coaching_session_id).await {
102+
Ok(session) => session,
103+
Err(e) => {
104+
error!("Authorization error finding coaching session: {:?}", e);
105+
return (StatusCode::NOT_FOUND, "NOT FOUND").into_response();
106+
}
107+
};
108+
109+
let coaching_relationship = match coaching_relationship::find_by_id(
110+
app_state.db_conn_ref(),
111+
coaching_session.coaching_relationship_id,
112+
)
113+
.await
114+
{
115+
Ok(relationship) => relationship,
116+
Err(e) => {
117+
error!("Authorization error finding coaching relationship: {:?}", e);
118+
return (StatusCode::NOT_FOUND, "NOT FOUND").into_response();
119+
}
120+
};
121+
122+
if coaching_relationship.coach_id == user.id {
123+
next.run(request).await
124+
} else {
125+
(StatusCode::UNAUTHORIZED, "UNAUTHORIZED").into_response()
126+
}
127+
}

web/src/router.rs

+13
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ use self::organization::coaching_relationship_controller;
4444
coaching_session_controller::index,
4545
coaching_session_controller::create,
4646
coaching_session_controller::update,
47+
coaching_session_controller::delete,
4748
note_controller::create,
4849
note_controller::update,
4950
note_controller::index,
@@ -197,6 +198,18 @@ pub fn coaching_sessions_routes(app_state: AppState) -> Router {
197198
protect::coaching_sessions::update,
198199
)),
199200
)
201+
.merge(
202+
// DELETE /coaching_sessions
203+
Router::new()
204+
.route(
205+
"/coaching_sessions/:id",
206+
delete(coaching_session_controller::delete),
207+
)
208+
.route_layer(from_fn_with_state(
209+
app_state.clone(),
210+
protect::coaching_sessions::delete,
211+
)),
212+
)
200213
.route_layer(login_required!(Backend, login_url = "/login"))
201214
.with_state(app_state)
202215
}

0 commit comments

Comments
 (0)