diff --git a/docs/db/refactor_platform_rs.dbml b/docs/db/refactor_platform_rs.dbml index 352456c..85f1dc6 100644 --- a/docs/db/refactor_platform_rs.dbml +++ b/docs/db/refactor_platform_rs.dbml @@ -48,6 +48,8 @@ Table refactor_platform.overarching_goals { coaching_session_id uuid [note: 'The coaching session that an overarching goal is associated with'] title varchar [note: 'A short description of an overarching goal'] body varchar [note: 'Main text of the overarching goal supporting Markdown'] + status status [not null] + status_changed_at timestamptz completed_at timestamptz [note: 'The date and time an overarching goal was completed'] created_at timestamptz [not null, default: `now()`] updated_at timestamptz [not null, default: `now()`, note: 'The last date and time fields were changed'] diff --git a/docs/db/refactor_platform_rs.png b/docs/db/refactor_platform_rs.png index a42487b..1a31cc7 100644 Binary files a/docs/db/refactor_platform_rs.png and b/docs/db/refactor_platform_rs.png differ diff --git a/entity/src/overarching_goals.rs b/entity/src/overarching_goals.rs index 85dd471..fee6b87 100644 --- a/entity/src/overarching_goals.rs +++ b/entity/src/overarching_goals.rs @@ -1,5 +1,6 @@ //! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.3 +use crate::status::Status; use crate::Id; use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; @@ -17,6 +18,10 @@ pub struct Model { pub title: Option, pub body: Option, #[serde(skip_deserializing)] + pub status: Status, + #[serde(skip_deserializing)] + pub status_changed_at: Option, + #[serde(skip_deserializing)] pub completed_at: Option, #[serde(skip_deserializing)] pub created_at: DateTimeWithTimeZone, diff --git a/entity/src/status.rs b/entity/src/status.rs index bc0a573..9d7a5a1 100644 --- a/entity/src/status.rs +++ b/entity/src/status.rs @@ -19,3 +19,15 @@ impl std::default::Default for Status { Self::InProgress } } + +impl From<&str> for Status { + fn from(value: &str) -> Self { + match value { + "not_started" => Self::NotStarted, + "in_progress" => Self::InProgress, + "completed" => Self::Completed, + "wont_do" => Self::WontDo, + _ => Self::InProgress, + } + } +} diff --git a/entity_api/src/action.rs b/entity_api/src/action.rs index 066c78f..307c0db 100644 --- a/entity_api/src/action.rs +++ b/entity_api/src/action.rs @@ -1,7 +1,7 @@ use super::error::{EntityApiErrorCode, Error}; use crate::uuid_parse_str; use entity::actions::{self, ActiveModel, Entity, Model}; -use entity::Id; +use entity::{status::Status, Id}; use sea_orm::{ entity::prelude::*, ActiveValue::{Set, Unchanged}, @@ -65,6 +65,42 @@ pub async fn update(db: &DatabaseConnection, id: Id, model: Model) -> Result Result { + let result = Entity::find_by_id(id).one(db).await?; + + match result { + Some(action) => { + debug!("Existing Action model to be Updated: {:?}", action); + + let active_model: ActiveModel = ActiveModel { + id: Unchanged(action.id), + coaching_session_id: Unchanged(action.coaching_session_id), + user_id: Unchanged(action.user_id), + body: Unchanged(action.body), + due_by: Unchanged(action.due_by), + status: Set(status), + status_changed_at: Set(Some(chrono::Utc::now().into())), + updated_at: Set(chrono::Utc::now().into()), + created_at: Unchanged(action.created_at), + }; + + Ok(active_model.update(db).await?.try_into_model()?) + } + None => { + error!("Action with id {} not found", id); + + Err(Error { + inner: None, + error_code: EntityApiErrorCode::RecordNotFound, + }) + } + } +} + pub async fn find_by_id(db: &DatabaseConnection, id: Id) -> Result, Error> { match Entity::find_by_id(id).one(db).await { Ok(Some(action)) => { @@ -179,6 +215,59 @@ mod tests { Ok(()) } + #[tokio::test] + async fn update_status_returns_an_updated_action_model() -> Result<(), Error> { + let now = chrono::Utc::now(); + + let action_model = Model { + id: Id::new_v4(), + coaching_session_id: Id::new_v4(), + due_by: Some(now.into()), + body: Some("This is a action".to_owned()), + user_id: Id::new_v4(), + status_changed_at: None, + status: Default::default(), + created_at: now.into(), + updated_at: now.into(), + }; + + let updated_action_model = Model { + id: Id::new_v4(), + coaching_session_id: Id::new_v4(), + due_by: Some(now.into()), + body: Some("This is a action".to_owned()), + user_id: Id::new_v4(), + status_changed_at: None, + status: Status::Completed, + created_at: now.into(), + updated_at: now.into(), + }; + + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results(vec![ + vec![action_model.clone()], + vec![updated_action_model.clone()], + ]) + .into_connection(); + + let action = update_status(&db, action_model.id, Status::Completed).await?; + + assert_eq!(action.status, Status::Completed); + + Ok(()) + } + + #[tokio::test] + async fn update_status_returns_error_when_action_not_found() -> Result<(), Error> { + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); + + let result = update_status(&db, Id::new_v4(), Status::Completed).await; + + assert_eq!(result.is_err(), true); + + Ok(()) + } + #[tokio::test] async fn find_by_returns_all_actions_associated_with_coaching_session() -> Result<(), Error> { let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); diff --git a/entity_api/src/overarching_goal.rs b/entity_api/src/overarching_goal.rs index 67de874..91db558 100644 --- a/entity_api/src/overarching_goal.rs +++ b/entity_api/src/overarching_goal.rs @@ -1,7 +1,7 @@ use super::error::{EntityApiErrorCode, Error}; use crate::uuid_parse_str; use entity::overarching_goals::{self, ActiveModel, Entity, Model}; -use entity::Id; +use entity::{status::Status, Id}; use sea_orm::{ entity::prelude::*, ActiveModelTrait, @@ -56,6 +56,8 @@ pub async fn update(db: &DatabaseConnection, id: Id, model: Model) -> Result Result Result { + let result = Entity::find_by_id(id).one(db).await?; + + match result { + Some(overarching_goal) => { + debug!( + "Existing Overarching Goal model to be Updated: {:?}", + overarching_goal + ); + + let active_model: ActiveModel = ActiveModel { + id: Unchanged(overarching_goal.id), + coaching_session_id: Unchanged(overarching_goal.coaching_session_id), + user_id: Unchanged(overarching_goal.user_id), + body: Unchanged(overarching_goal.body), + title: Unchanged(overarching_goal.title), + status: Set(status), + status_changed_at: Set(Some(chrono::Utc::now().into())), + completed_at: Unchanged(overarching_goal.completed_at), + updated_at: Set(chrono::Utc::now().into()), + created_at: Unchanged(overarching_goal.created_at), + }; + + Ok(active_model.update(db).await?.try_into_model()?) + } + None => { + error!("Overarching Goal with id {} not found", id); + + Err(Error { + inner: None, + error_code: EntityApiErrorCode::RecordNotFound, + }) + } + } +} + pub async fn find_by_id(db: &DatabaseConnection, id: Id) -> Result, Error> { match Entity::find_by_id(id).one(db).await { Ok(Some(overarching_goal)) => { @@ -148,6 +190,8 @@ mod tests { coaching_session_id: Some(Id::new_v4()), title: Some("title".to_owned()), body: Some("This is a overarching_goal".to_owned()), + status_changed_at: None, + status: Default::default(), completed_at: Some(now.into()), created_at: now.into(), updated_at: now.into(), @@ -176,6 +220,8 @@ mod tests { body: Some("This is a overarching_goal".to_owned()), user_id: Id::new_v4(), completed_at: Some(now.into()), + status_changed_at: None, + status: Default::default(), created_at: now.into(), updated_at: now.into(), }; @@ -199,6 +245,62 @@ mod tests { Ok(()) } + #[tokio::test] + async fn update_status_returns_an_updated_overarching_goal_model() -> Result<(), Error> { + let now = chrono::Utc::now(); + + let overarching_goal_model = Model { + id: Id::new_v4(), + coaching_session_id: Some(Id::new_v4()), + title: Some("title".to_owned()), + body: Some("This is a overarching_goal".to_owned()), + user_id: Id::new_v4(), + completed_at: Some(now.into()), + status_changed_at: None, + status: Default::default(), + created_at: now.into(), + updated_at: now.into(), + }; + + let updated_overarching_goal_model = Model { + id: Id::new_v4(), + coaching_session_id: Some(Id::new_v4()), + title: Some("title".to_owned()), + body: Some("This is a overarching_goal".to_owned()), + user_id: Id::new_v4(), + completed_at: Some(now.into()), + status_changed_at: Some(now.into()), + status: Status::Completed, + created_at: now.into(), + updated_at: now.into(), + }; + + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results(vec![ + vec![overarching_goal_model.clone()], + vec![updated_overarching_goal_model.clone()], + ]) + .into_connection(); + + let overarching_goal = + update_status(&db, overarching_goal_model.id, Status::Completed).await?; + + assert_eq!(overarching_goal.status, Status::Completed); + + Ok(()) + } + + #[tokio::test] + async fn update_status_returns_error_when_overarching_goal_not_found() -> Result<(), Error> { + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); + + let result = update_status(&db, Id::new_v4(), Status::Completed).await; + + assert_eq!(result.is_err(), true); + + Ok(()) + } + #[tokio::test] async fn find_by_returns_all_overarching_goals_associated_with_coaching_session( ) -> Result<(), Error> { @@ -217,7 +319,7 @@ mod tests { db.into_transaction_log(), [Transaction::from_sql_and_values( DatabaseBackend::Postgres, - r#"SELECT "overarching_goals"."id", "overarching_goals"."coaching_session_id", "overarching_goals"."user_id", "overarching_goals"."title", "overarching_goals"."body", "overarching_goals"."completed_at", "overarching_goals"."created_at", "overarching_goals"."updated_at" FROM "refactor_platform"."overarching_goals" WHERE "overarching_goals"."coaching_session_id" = $1"#, + r#"SELECT "overarching_goals"."id", "overarching_goals"."coaching_session_id", "overarching_goals"."user_id", "overarching_goals"."title", "overarching_goals"."body", CAST("overarching_goals"."status" AS text), "overarching_goals"."status_changed_at", "overarching_goals"."completed_at", "overarching_goals"."created_at", "overarching_goals"."updated_at" FROM "refactor_platform"."overarching_goals" WHERE "overarching_goals"."coaching_session_id" = $1"#, [coaching_session_id.into()] )] ); diff --git a/migration/src/refactor_platform_rs.sql b/migration/src/refactor_platform_rs.sql index ba6049d..74b0a67 100644 --- a/migration/src/refactor_platform_rs.sql +++ b/migration/src/refactor_platform_rs.sql @@ -1,6 +1,6 @@ --- SQL dump generated using DBML (dbml-lang.org) +-- SQL dump generated using DBML (dbml.dbdiagram.io) -- Database: PostgreSQL --- Generated at: 2024-09-21T03:25:20.880Z +-- Generated at: 2024-09-25T08:10:38.833Z CREATE TYPE "status" AS ENUM ( @@ -55,6 +55,8 @@ CREATE TABLE "refactor_platform"."overarching_goals" ( "coaching_session_id" uuid, "title" varchar, "body" varchar, + "status" status NOT NULL, + "status_changed_at" timestamptz, "completed_at" timestamptz, "created_at" timestamptz NOT NULL DEFAULT (now()), "updated_at" timestamptz NOT NULL DEFAULT (now()) diff --git a/web/src/controller/action_controller.rs b/web/src/controller/action_controller.rs index 9448d4b..abb684b 100644 --- a/web/src/controller/action_controller.rs +++ b/web/src/controller/action_controller.rs @@ -113,6 +113,41 @@ pub async fn update( Ok(Json(ApiResponse::new(StatusCode::OK.into(), action))) } +#[utoipa::path( + put, + path = "/actions/{id}/status", + params( + ApiVersion, + ("id" = Id, Path, description = "Id of action to update"), + ("value" = Option, Query, description = "Status value to update"), + ), + request_body = entity::actions::Model, + responses( + (status = 200, description = "Successfully Updated Action", body = [entity::actions::Model]), + (status = 401, description = "Unauthorized"), + (status = 405, description = "Method not allowed") + ), + security( + ("cookie_auth" = []) + ) +)] +pub async fn update_status( + CompareApiVersion(_v): CompareApiVersion, + AuthenticatedUser(_user): AuthenticatedUser, + Query(status): Query, + Path(id): Path, + State(app_state): State, +) -> Result { + debug!("PUT Update Action Status with id: {}", id); + + let action = + ActionApi::update_status(app_state.db_conn_ref(), id, status.as_str().into()).await?; + + debug!("Updated Action: {:?}", action); + + Ok(Json(ApiResponse::new(StatusCode::OK.into(), action))) +} + #[utoipa::path( get, path = "/actions", diff --git a/web/src/controller/overarching_goal_controller.rs b/web/src/controller/overarching_goal_controller.rs index d46c4b2..9cb568d 100644 --- a/web/src/controller/overarching_goal_controller.rs +++ b/web/src/controller/overarching_goal_controller.rs @@ -125,6 +125,45 @@ pub async fn update( ))) } +#[utoipa::path( + put, + path = "/overarching_goals/{id}/status", + params( + ApiVersion, + ("id" = Id, Path, description = "Id of overarching goal to update"), + ("value" = Option, Query, description = "Status value to update"), + ), + request_body = entity::actions::Model, + responses( + (status = 200, description = "Successfully Updated Overarching Goal", body = [entity::overarching_goals::Model]), + (status = 401, description = "Unauthorized"), + (status = 405, description = "Method not allowed") + ), + security( + ("cookie_auth" = []) + ) +)] +pub async fn update_status( + CompareApiVersion(_v): CompareApiVersion, + AuthenticatedUser(_user): AuthenticatedUser, + Query(status): Query, + Path(id): Path, + State(app_state): State, +) -> Result { + debug!("PUT Update Overarching Goal Status with id: {}", id); + + let overarching_goal = + OverarchingGoalApi::update_status(app_state.db_conn_ref(), id, status.as_str().into()) + .await?; + + debug!("Updated Overarching Goal: {:?}", overarching_goal); + + Ok(Json(ApiResponse::new( + StatusCode::OK.into(), + overarching_goal, + ))) +} + #[utoipa::path( get, path = "/overarching_goals", diff --git a/web/src/router.rs b/web/src/router.rs index 7d1fefa..17c352e 100644 --- a/web/src/router.rs +++ b/web/src/router.rs @@ -30,6 +30,7 @@ use utoipa_rapidoc::RapiDoc; action_controller::update, action_controller::index, action_controller::read, + action_controller::update_status, agreement_controller::create, agreement_controller::update, agreement_controller::index, @@ -48,6 +49,7 @@ use utoipa_rapidoc::RapiDoc; overarching_goal_controller::update, overarching_goal_controller::index, overarching_goal_controller::read, + overarching_goal_controller::update_status, user_session_controller::login, user_session_controller::logout, organization::coaching_relationship_controller::index, @@ -123,6 +125,7 @@ fn action_routes(app_state: AppState) -> Router { .route("/actions/:id", put(action_controller::update)) .route("/actions", get(action_controller::index)) .route("/actions/:id", get(action_controller::read)) + .route("/actions/:id/status", put(action_controller::update_status)) .route_layer(login_required!(Backend, login_url = "/login")) .with_state(app_state) } @@ -184,6 +187,10 @@ pub fn overarching_goal_routes(app_state: AppState) -> Router { "/overarching_goals/:id", get(overarching_goal_controller::read), ) + .route( + "/overarching_goals/:id/status", + put(overarching_goal_controller::update_status), + ) .route_layer(login_required!(Backend, login_url = "/login")) .with_state(app_state) }