Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 58 additions & 62 deletions domain/src/coaching_session.rs
Original file line number Diff line number Diff line change
@@ -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<Self, Error> {
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,
Expand All @@ -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(
Expand Down Expand Up @@ -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()
)
}
82 changes: 81 additions & 1 deletion domain/src/gateway/tiptap.rs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -33,3 +34,82 @@ async fn build_auth_headers(config: &Config) -> Result<reqwest::header::HeaderMa
headers.insert(reqwest::header::AUTHORIZATION, auth_value);
Ok(headers)
}

pub struct TiptapDocument {
client: reqwest::Client,
base_url: String,
}

impl TiptapDocument {
pub async fn new(config: &Config) -> Result<Self, Error> {
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
)
}
}
3 changes: 1 addition & 2 deletions entity/src/jwts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
25 changes: 25 additions & 0 deletions entity_api/src/coaching_session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(())
}
}
29 changes: 29 additions & 0 deletions web/src/controller/coaching_session_controller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppState>,
Path(coaching_session_id): Path<Id>,
) -> Result<impl IntoResponse, Error> {
CoachingSessionApi::delete(
app_state.db_conn_ref(),
&app_state.config,
coaching_session_id,
)
.await?;

Ok(Json(ApiResponse::new(StatusCode::NO_CONTENT.into(), ())))
}
41 changes: 41 additions & 0 deletions web/src/protect/coaching_sessions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppState>,
AuthenticatedUser(user): AuthenticatedUser,
Path(coaching_session_id): Path<Id>,
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()
}
}
13 changes: 13 additions & 0 deletions web/src/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
}
Expand Down
Loading