Skip to content

Update users #117

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 9 commits into
base: main
Choose a base branch
from
Draft
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
)
}
}
56 changes: 47 additions & 9 deletions domain/src/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ use crate::{
};
use chrono::Utc;
pub use entity_api::user::{
create, create_by_organization, find_by_email, find_by_id, find_by_organization, AuthSession,
Backend, Credentials,
create, create_by_organization, find_by_email, find_by_id, find_by_organization,
generate_hash, verify_password, AuthSession, Backend, Credentials,
};
use entity_api::{
coaching_relationship, mutate, organizations_user, query, query::IntoQueryFilterMap, user,
};
use sea_orm::IntoActiveModel;
use sea_orm::{DatabaseConnection, TransactionTrait};
use sea_orm::{DatabaseConnection, TransactionTrait, Value};

pub async fn find_by(
db: &DatabaseConnection,
Expand All @@ -30,13 +30,51 @@ pub async fn update(
params: impl mutate::IntoUpdateMap,
) -> Result<users::Model, Error> {
let existing_user = find_by_id(db, user_id).await?;

let mut params = params.into_update_map();

// Extract and verify the user's password as a security check before allowing any updates
let password_to_verify = params.remove("password")?;
verify_password(&password_to_verify, &existing_user.password).await?;

// After verification passes, proceed with the update
let active_model = existing_user.into_active_model();
Ok(mutate::update::<users::ActiveModel, users::Column>(db, active_model, params).await?)
}

pub async fn update_password(
db: &DatabaseConnection,
user_id: Id,
params: impl mutate::IntoUpdateMap,
) -> Result<users::Model, Error> {
let existing_user = find_by_id(db, user_id).await?;
let mut params = params.into_update_map();

// Remove and verify the user's current password as a security check before allowing any updates
let password_to_verify = params.remove("current_password")?;
verify_password(&password_to_verify, &existing_user.password).await?;

// remove confirm_password
let confirm_password = params.remove("confirm_password")?;

// remove password
let password = params.remove("password")?;
// check password confirmation
if confirm_password != password {
return Err(Error {
source: None,
error_kind: DomainErrorKind::Internal(InternalErrorKind::Other),
});
}

// generate new password hash and insert it back into params overwriting the raw password
params.insert(
"password".to_string(),
Some(Value::String(Some(Box::new(generate_hash(password))))),
);

let active_model = existing_user.into_active_model();
Ok(mutate::update::<users::ActiveModel, users::Column>(
db,
active_model,
params.into_update_map(),
)
.await?)
Ok(mutate::update::<users::ActiveModel, users::Column>(db, active_model, params).await?)
}

// This function is intended to be a temporary solution until we finalize our user experience strategy for assigning a new user
Expand Down
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(())
}
}
2 changes: 2 additions & 0 deletions entity_api/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ pub enum EntityApiErrorKind {
RecordUnauthenticated,
// Errors related to interactions with the database itself. Ex DbError::Conn
SystemError,
// Other errors
Other,
}

impl fmt::Display for Error {
Expand Down
Loading
Loading