diff --git a/domain/src/coaching_session.rs b/domain/src/coaching_session.rs index f7d4bcdc..94189565 100644 --- a/domain/src/coaching_session.rs +++ b/domain/src/coaching_session.rs @@ -1,19 +1,38 @@ use crate::coaching_sessions::Model; -use crate::error::{DomainErrorKind, Error, ExternalErrorKind, InternalErrorKind}; -use crate::gateway::tiptap::client as tiptap_client; +use crate::error::{DomainErrorKind, Error, InternalErrorKind}; +use crate::gateway::tiptap::TiptapDocument; use crate::Id; -use chrono::{DurationRound, TimeDelta}; +use chrono::{DurationRound, NaiveDateTime, TimeDelta}; use entity_api::{ coaching_relationship, coaching_session, coaching_sessions, mutate, organization, query, query::IntoQueryFilterMap, }; use log::*; use sea_orm::{DatabaseConnection, IntoActiveModel}; -use serde_json::json; use service::config::Config; pub use entity_api::coaching_session::{find_by_id, find_by_id_with_coaching_relationship}; +#[derive(Debug, Clone)] +struct SessionDate(NaiveDateTime); + +impl SessionDate { + fn new(date: NaiveDateTime) -> Result { + let truncated = date.duration_trunc(TimeDelta::minutes(1)).map_err(|err| { + warn!("Failed to truncate date_time: {:?}", err); + Error { + source: Some(Box::new(err)), + error_kind: DomainErrorKind::Internal(InternalErrorKind::Other), + } + })?; + Ok(Self(truncated)) + } + + fn into_inner(self) -> NaiveDateTime { + self.0 + } +} + pub async fn create( db: &DatabaseConnection, config: &Config, @@ -23,69 +42,20 @@ pub async fn create( coaching_relationship::find_by_id(db, coaching_session_model.coaching_relationship_id) .await?; let organization = organization::find_by_id(db, coaching_relationship.organization_id).await?; - // Remove seconds because all coaching_sessions will be scheduled by the minute - // TODO: we might consider codifying this in the type system at some point. - let date_time = coaching_session_model - .date - .duration_trunc(TimeDelta::minutes(1)) - .map_err(|err| { - warn!("Failed to truncate date_time: {:?}", err); - Error { - source: Some(Box::new(err)), - error_kind: DomainErrorKind::Internal(InternalErrorKind::Other), - } - })?; - coaching_session_model.date = date_time; - let document_name = format!( - "{}.{}.{}-v0", - organization.slug, - coaching_relationship.slug, - Id::new_v4() - ); + + coaching_session_model.date = SessionDate::new(coaching_session_model.date)?.into_inner(); + + let document_name = generate_document_name(&organization.slug, &coaching_relationship.slug); info!( "Attempting to create Tiptap document with name: {}", document_name ); coaching_session_model.collab_document_name = Some(document_name.clone()); - let tiptap_url = config.tiptap_url().ok_or_else(|| { - warn!("Failed to get Tiptap URL from config"); - Error { - source: None, - error_kind: DomainErrorKind::Internal(InternalErrorKind::Other), - } - })?; - let full_url = format!("{}/api/documents/{}?format=json", tiptap_url, document_name); - let client = tiptap_client(config).await?; - - let request = client - .post(full_url) - .json(&json!({"type": "doc", "content": []})); - let response = match request.send().await { - Ok(response) => { - info!("Tiptap response: {:?}", response); - response - } - Err(e) => { - warn!("Failed to send request: {:?}", e); - return Err(e.into()); - } - }; - - // Tiptap's API will return a 200 for successful creation of a new document - // and will return a 409 if the document already exists. We consider both "successful". - if response.status().is_success() || response.status().as_u16() == 409 { - // TODO: Save document_name to record - Ok(coaching_session::create(db, coaching_session_model).await?) - } else { - warn!( - "Failed to create Tiptap document: {}", - response.text().await? - ); - Err(Error { - source: None, - error_kind: DomainErrorKind::External(ExternalErrorKind::Network), - }) - } + + let tiptap = TiptapDocument::new(config).await?; + tiptap.create(&document_name).await?; + + Ok(coaching_session::create(db, coaching_session_model).await?) } pub async fn find_by( @@ -117,3 +87,29 @@ pub async fn update( .await?, ) } + +pub async fn delete(db: &DatabaseConnection, config: &Config, id: Id) -> Result<(), Error> { + let coaching_session = find_by_id(db, id).await?; + let document_name = coaching_session.collab_document_name.ok_or_else(|| { + warn!("Failed to get document name from coaching session"); + Error { + source: None, + error_kind: DomainErrorKind::Internal(InternalErrorKind::Other), + } + })?; + + let tiptap = TiptapDocument::new(config).await?; + tiptap.delete(&document_name).await?; + + coaching_session::delete(db, id).await?; + Ok(()) +} + +fn generate_document_name(organization_slug: &str, relationship_slug: &str) -> String { + format!( + "{}.{}.{}-v0", + organization_slug, + relationship_slug, + Id::new_v4() + ) +} diff --git a/domain/src/gateway/tiptap.rs b/domain/src/gateway/tiptap.rs index 148037ae..7c9918b6 100644 --- a/domain/src/gateway/tiptap.rs +++ b/domain/src/gateway/tiptap.rs @@ -1,5 +1,6 @@ -use crate::error::{DomainErrorKind, Error, InternalErrorKind}; +use crate::error::{DomainErrorKind, Error, ExternalErrorKind, InternalErrorKind}; use log::*; +use serde_json::json; use service::config::Config; /// 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 Result { + let client = client(config).await?; + let base_url = config.tiptap_url().ok_or_else(|| { + warn!("Failed to get Tiptap URL from config"); + Error { + source: None, + error_kind: DomainErrorKind::Internal(InternalErrorKind::Other), + } + })?; + Ok(Self { client, base_url }) + } + + pub async fn create(&self, document_name: &str) -> Result<(), Error> { + let url = self.format_url(document_name); + let response = self + .client + .post(url) + .json(&json!({"type": "doc", "content": []})) + .send() + .await + .map_err(|e| { + warn!("Failed to send request: {:?}", e); + Error { + source: Some(Box::new(e)), + error_kind: DomainErrorKind::External(ExternalErrorKind::Network), + } + })?; + + if response.status().is_success() || response.status().as_u16() == 409 { + Ok(()) + } else { + let error_text = response.text().await.unwrap_or_default(); + warn!("Failed to create Tiptap document: {}", error_text); + Err(Error { + source: None, + error_kind: DomainErrorKind::External(ExternalErrorKind::Network), + }) + } + } + + pub async fn delete(&self, document_name: &str) -> Result<(), Error> { + let url = self.format_url(document_name); + let response = self.client.delete(url).send().await.map_err(|e| { + warn!("Failed to send request: {:?}", e); + Error { + source: Some(Box::new(e)), + error_kind: DomainErrorKind::External(ExternalErrorKind::Network), + } + })?; + + let status = response.status(); + if status.is_success() || status.as_u16() == 404 { + Ok(()) + } else { + warn!( + "Failed to delete Tiptap document: {}, with status: {}", + document_name, status + ); + Err(Error { + source: None, + error_kind: DomainErrorKind::External(ExternalErrorKind::Network), + }) + } + } + + fn format_url(&self, document_name: &str) -> String { + format!( + "{}/api/documents/{}?format=json", + self.base_url, document_name + ) + } +} diff --git a/entity/src/jwts.rs b/entity/src/jwts.rs index 6ffeecf9..0c1ff8d3 100644 --- a/entity/src/jwts.rs +++ b/entity/src/jwts.rs @@ -7,8 +7,7 @@ use utoipa::ToSchema; /// This struct contains two fields: /// /// - `token`: A string representing the JWT. -/// - `sub`: A string representing the subject of the JWT for conveniently accessing -/// the subject without having to decode the JWT. +/// - `sub`: A string representing the subject of the JWT for conveniently accessing the subject without having to decode the JWT. #[derive(Serialize, Debug, ToSchema)] #[schema(as = jwt::Jwt)] // OpenAPI schema pub struct Jwt { diff --git a/entity_api/src/coaching_session.rs b/entity_api/src/coaching_session.rs index 09000810..b2e33234 100644 --- a/entity_api/src/coaching_session.rs +++ b/entity_api/src/coaching_session.rs @@ -58,6 +58,12 @@ pub async fn find_by_id_with_coaching_relationship( error_kind: EntityApiErrorKind::RecordNotFound, }) } + +pub async fn delete(db: &impl ConnectionTrait, coaching_session_id: Id) -> Result<(), Error> { + Entity::delete_by_id(coaching_session_id).exec(db).await?; + Ok(()) +} + #[cfg(test)] // We need to gate seaORM's mock feature behind conditional compilation because // the feature removes the Clone trait implementation from seaORM's DatabaseConnection. @@ -135,4 +141,23 @@ mod tests { Ok(()) } + + #[tokio::test] + async fn delete_deletes_a_single_record() -> Result<(), Error> { + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); + + let coaching_session_id = Id::new_v4(); + let _ = delete(&db, coaching_session_id).await; + + assert_eq!( + db.into_transaction_log(), + [Transaction::from_sql_and_values( + DatabaseBackend::Postgres, + r#"DELETE FROM "refactor_platform"."coaching_sessions" WHERE "coaching_sessions"."id" = $1"#, + [coaching_session_id.into(),] + )] + ); + + Ok(()) + } } diff --git a/web/src/controller/coaching_session_controller.rs b/web/src/controller/coaching_session_controller.rs index 8246577e..5475b1c5 100644 --- a/web/src/controller/coaching_session_controller.rs +++ b/web/src/controller/coaching_session_controller.rs @@ -123,3 +123,32 @@ pub async fn update( CoachingSessionApi::update(app_state.db_conn_ref(), coaching_session_id, params).await?; Ok(Json(ApiResponse::new(StatusCode::NO_CONTENT.into(), ()))) } + +/// DELETE a Coaching Session +#[utoipa::path( + delete, + path = "/coaching_sessions/{id}", + params(ApiVersion, ("id" = Id, Path, description = "Coaching Session ID to Delete")), + responses( + (status = 204, description = "Successfully deleted a Coaching Session", body = ()), + (status = 401, description = "Unauthorized"), + ), + security( + ("cookie_auth" = []) + ) +)] +pub async fn delete( + CompareApiVersion(_v): CompareApiVersion, + AuthenticatedUser(_user): AuthenticatedUser, + State(app_state): State, + Path(coaching_session_id): Path, +) -> Result { + CoachingSessionApi::delete( + app_state.db_conn_ref(), + &app_state.config, + coaching_session_id, + ) + .await?; + + Ok(Json(ApiResponse::new(StatusCode::NO_CONTENT.into(), ()))) +} diff --git a/web/src/protect/coaching_sessions.rs b/web/src/protect/coaching_sessions.rs index cc9a6330..04a76527 100644 --- a/web/src/protect/coaching_sessions.rs +++ b/web/src/protect/coaching_sessions.rs @@ -84,3 +84,44 @@ pub(crate) async fn update( (StatusCode::UNAUTHORIZED, "UNAUTHORIZED").into_response() } } + +/// Checks that coaching session record referenced by `coaching_session_id` +/// * exists +/// * that the authenticated user is associated with it. +/// * that the authenticated user is the coach +/// Intended to be given to axum::middleware::from_fn_with_state in the router +pub(crate) async fn delete( + State(app_state): State, + AuthenticatedUser(user): AuthenticatedUser, + Path(coaching_session_id): Path, + request: Request, + next: Next, +) -> impl IntoResponse { + let coaching_session = + match coaching_session::find_by_id(app_state.db_conn_ref(), coaching_session_id).await { + Ok(session) => session, + Err(e) => { + error!("Authorization error finding coaching session: {:?}", e); + return (StatusCode::NOT_FOUND, "NOT FOUND").into_response(); + } + }; + + let coaching_relationship = match coaching_relationship::find_by_id( + app_state.db_conn_ref(), + coaching_session.coaching_relationship_id, + ) + .await + { + Ok(relationship) => relationship, + Err(e) => { + error!("Authorization error finding coaching relationship: {:?}", e); + return (StatusCode::NOT_FOUND, "NOT FOUND").into_response(); + } + }; + + if coaching_relationship.coach_id == user.id { + next.run(request).await + } else { + (StatusCode::UNAUTHORIZED, "UNAUTHORIZED").into_response() + } +} diff --git a/web/src/router.rs b/web/src/router.rs index 41f24134..057c4b36 100644 --- a/web/src/router.rs +++ b/web/src/router.rs @@ -44,6 +44,7 @@ use self::organization::coaching_relationship_controller; coaching_session_controller::index, coaching_session_controller::create, coaching_session_controller::update, + coaching_session_controller::delete, note_controller::create, note_controller::update, note_controller::index, @@ -197,6 +198,18 @@ pub fn coaching_sessions_routes(app_state: AppState) -> Router { protect::coaching_sessions::update, )), ) + .merge( + // DELETE /coaching_sessions + Router::new() + .route( + "/coaching_sessions/:id", + delete(coaching_session_controller::delete), + ) + .route_layer(from_fn_with_state( + app_state.clone(), + protect::coaching_sessions::delete, + )), + ) .route_layer(login_required!(Backend, login_url = "/login")) .with_state(app_state) }