From 09dea11c5da700d61a497744695762611e4978ef Mon Sep 17 00:00:00 2001 From: Alex McLeod Date: Tue, 29 Oct 2024 13:47:13 +1300 Subject: [PATCH] Alex/add caregivers (#25) * Alex/form submission history (#20) * added history of every form submission * added history to the router * created history search * deleted file i accidentally created * added tokens and caregiver adding function (#21) * Add jager to default deployment * Fix tracing and logging Least disorganized git repository * remove unneeded imports * added form finding for caregivers * added find caregivers listing, and remove caregiver functionality * add medication tracking (#22) * add medication tracking also some other files got hit by `cargo fmt` :shrug: * forgot `$set` * improved history and other features * fixed cookie issue * re-added medication --------- Co-authored-by: Chris Graham Co-authored-by: LeavesOfFall <131100486+LeavesOfFall@users.noreply.github.com> --- .dockerignore | 97 ++++++++++++++ .github/workflows/fly-deploy.yml | 18 +++ Cargo.toml | 14 +- Dockerfile | 1 + fly.toml | 22 +++ src/app/auth/middleware.rs | 4 +- src/app/auth/mod.rs | 3 +- src/app/auth/utils.rs | 6 +- src/app/caregiver/add.rs | 20 +-- src/app/caregiver/find.rs | 96 +++++++++++++ src/app/caregiver/generate.rs | 4 +- src/app/caregiver/mod.rs | 34 ++++- src/app/caregiver/remove.rs | 68 ++++++++++ src/app/form/create.rs | 15 ++- src/app/form/find.rs | 222 +++++++++++++++++++++++++++++-- src/app/form/history.rs | 91 +++++++++++++ src/app/form/mod.rs | 6 +- src/app/form/submit.rs | 10 +- src/app/medication/add.rs | 53 ++++++++ src/app/medication/find.rs | 77 +++++++++++ src/app/medication/mod.rs | 24 ++++ src/app/medication/remove.rs | 45 +++++++ src/app/medication/update.rs | 50 +++++++ src/app/mod.rs | 15 ++- src/app/models/dto/caregiver.rs | 6 + src/app/models/dto/form.rs | 1 + src/app/models/dto/medication.rs | 31 +++++ src/app/models/dto/mod.rs | 1 + src/app/models/mod.rs | 68 ++++++---- src/config.rs | 18 +++ src/metrics/logging.rs | 44 ++++-- 31 files changed, 1067 insertions(+), 97 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/fly-deploy.yml create mode 100644 fly.toml create mode 100644 src/app/caregiver/find.rs create mode 100644 src/app/caregiver/remove.rs create mode 100644 src/app/form/history.rs create mode 100644 src/app/medication/add.rs create mode 100644 src/app/medication/find.rs create mode 100644 src/app/medication/mod.rs create mode 100644 src/app/medication/remove.rs create mode 100644 src/app/medication/update.rs create mode 100644 src/app/models/dto/medication.rs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9cac581 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,97 @@ +# flyctl launch added from .gitignore +# no compiled +**\debug +**\target +target + +# If you commit keys I will personally remove you from this earth +**\.env + +**\**\*.rs.bk +**\*.pdb + +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +**\.idea\**\workspace.xml +**\.idea\**\tasks.xml +**\.idea\**\usage.statistics.xml +**\.idea\**\dictionaries +**\.idea\**\shelf + +# AWS User-specific +**\.idea\**\aws.xml + +# Generated files +**\.idea\**\contentModel.xml + +# Sensitive or high-churn files +**\.idea\**\dataSources +**\.idea\**\dataSources.ids +**\.idea\**\dataSources.local.xml +**\.idea\**\sqlDataSources.xml +**\.idea\**\dynamic.xml +**\.idea\**\uiDesigner.xml +**\.idea\**\dbnavigator.xml + +# Gradle +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr +**\.idea\**\gradle.xml +**\.idea\**\libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +**\cmake-build-* + +# Mongo Explorer plugin +**\.idea\**\mongoSettings.xml + +# File-based project format +**\*.iws + +# IntelliJ +**\out + +# mpeltonen/sbt-idea plugin +**\.idea_modules + +# JIRA plugin +**\atlassian-ide-plugin.xml + +# Cursive Clojure plugin +**\.idea\replstate.xml + +# SonarLint plugin +**\.idea\sonarlint + +# Crashlytics plugin (for Android Studio and IntelliJ) +**\com_crashlytics_export_strings.xml +**\crashlytics.properties +**\crashlytics-build.properties +**\fabric.properties + +# Editor-based Rest Client +**\.idea\httpRequests + +# Android studio 3.1+ serialized cache file +**\.idea\caches\build_file_checksums.ser +fly.toml diff --git a/.github/workflows/fly-deploy.yml b/.github/workflows/fly-deploy.yml new file mode 100644 index 0000000..b0c246e --- /dev/null +++ b/.github/workflows/fly-deploy.yml @@ -0,0 +1,18 @@ +# See https://fly.io/docs/app-guides/continuous-deployment-with-github-actions/ + +name: Fly Deploy +on: + push: + branches: + - main +jobs: + deploy: + name: Deploy app + runs-on: ubuntu-latest + concurrency: deploy-group # optional: ensure only one action runs at a time + steps: + - uses: actions/checkout@v4 + - uses: superfly/flyctl-actions/setup-flyctl@master + - run: flyctl deploy --remote-only + env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} diff --git a/Cargo.toml b/Cargo.toml index 2e0ef9b..6845458 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [features] -default = ["local_log", "journal_log"] +default = ["local_log", "journal_log", "jaeger_tracing"] local_log = [] journal_log = [] jaeger_tracing = [] @@ -20,10 +20,13 @@ serde_json = "1.0.120" tracing = { version = "0.1" } tracing-subscriber = { version = "0.3" } tracing-journald = { version = "0.3" } +tracing-opentelemetry = "0.27.0" +opentelemetry = { version = "0.26", features = ["trace", "metrics"] } +opentelemetry-stdout = { version = "0.26", features = ["trace", "metrics"] } +opentelemetry_sdk = { version = "0.26.0", features = ["rt-tokio", "trace"] } +opentelemetry-otlp = { version = "0.26.0", features = ["grpc-tonic", "metrics"] } +opentelemetry-semantic-conventions = "0.26.0" -opentelemetry = "0.17.0" -tracing-opentelemetry = "0.17.2" -opentelemetry-jaeger = "0.16.0" mongodb = "3.0.1" dotenvy = "0.15.7" anyhow = { version = "1.0.86", features = [] } @@ -37,4 +40,5 @@ rand = "0.8.5" rust-argon2 = "2.1.0" tower-cookies = "0.10.0" jsonwebtoken = "9.3.0" -bson = "2.13.0" +bson = { version = "2.13.0", features = ["chrono-0_4"] } +chrono-humanize = "0.2.3" diff --git a/Dockerfile b/Dockerfile index 0116e80..b5aedf5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,4 +19,5 @@ COPY --from=build /app/app /usr/local/bin/ HEALTHCHECK --start-period=10s --interval=10s --timeout=3s CMD ["bash", "./healthcheck.sh"] +EXPOSE 8080 ENTRYPOINT ["/usr/local/bin/app"] \ No newline at end of file diff --git a/fly.toml b/fly.toml new file mode 100644 index 0000000..38e8b42 --- /dev/null +++ b/fly.toml @@ -0,0 +1,22 @@ +# fly.toml app configuration file generated for parkinsons-pulse-service on 2024-10-23T11:24:44+13:00 +# +# See https://fly.io/docs/reference/configuration/ for information about how to use this file. +# + +app = 'parkinsons-pulse-service' +primary_region = 'syd' + +[build] + +[http_service] + internal_port = 8080 + force_https = true + auto_stop_machines = 'stop' + auto_start_machines = true + min_machines_running = 0 + processes = ['app'] + +[[vm]] + memory = '1gb' + cpu_kind = 'shared' + cpus = 1 diff --git a/src/app/auth/middleware.rs b/src/app/auth/middleware.rs index 34206be..8e7cde3 100644 --- a/src/app/auth/middleware.rs +++ b/src/app/auth/middleware.rs @@ -7,7 +7,7 @@ use mongodb::bson::oid::ObjectId; use tower_cookies::Cookies; use tracing::info; -use crate::app::{auth::utils::decode_jwt, auth::LoginUserBody, models::User}; +use crate::app::{auth::utils::decode_jwt, models::User}; #[derive(serde::Deserialize, serde::Serialize, Clone, Debug)] pub struct UserClaims { @@ -17,6 +17,7 @@ pub struct UserClaims { pub last_name: String, // national_health_identifer: String, pub email_address: String, + pub is_patient: bool, } impl TryInto for User { @@ -27,6 +28,7 @@ impl TryInto for User { first_name: self.first_name, last_name: self.last_name, email_address: self.email_address, + is_patient: self.is_patient, }) } } diff --git a/src/app/auth/mod.rs b/src/app/auth/mod.rs index 6bf0fa6..9b04d61 100644 --- a/src/app/auth/mod.rs +++ b/src/app/auth/mod.rs @@ -167,7 +167,8 @@ pub async fn login( info!("{:#?}", auth_cookie); cookies.add(auth_cookie); - StatusCode::OK.into_response() + (StatusCode::OK, Json(user_without_password)).into_response() + } async fn logout(cookies: Cookies) -> Response { diff --git a/src/app/auth/utils.rs b/src/app/auth/utils.rs index 6b00495..853e9d9 100644 --- a/src/app/auth/utils.rs +++ b/src/app/auth/utils.rs @@ -23,11 +23,11 @@ impl<'a> AuthCookieBuilder<'a> { pub fn new(value: String) -> Self { Self { cookie: CookieBuilder::new("auth_token", value) - // .domain(config::get_domain()) + .domain(config::get_domain()) .path("/") .http_only(true) - .same_site(tower_cookies::cookie::SameSite::Lax) - .secure(false), + .same_site(tower_cookies::cookie::SameSite::None) + .secure(config::get_is_production() == "PRODUCTION"), } } #[must_use] diff --git a/src/app/caregiver/add.rs b/src/app/caregiver/add.rs index b4ca207..2a45832 100644 --- a/src/app/caregiver/add.rs +++ b/src/app/caregiver/add.rs @@ -21,25 +21,7 @@ use crate::app::{ }, }; -async fn delete_expired_tokens( - collection: &Collection, -) -> mongodb::error::Result<()> { - let now = DateTime::now(); - - let filter = doc! { - "expired_by": { - "$lt": now - } - }; - - let delete_result = collection.delete_many(filter).await?; - info!( - "Deleted {} expired caregiver tokens", - delete_result.deleted_count - ); - - Ok(()) -} +use super::delete_expired_tokens; #[tracing::instrument] #[axum::debug_handler] diff --git a/src/app/caregiver/find.rs b/src/app/caregiver/find.rs new file mode 100644 index 0000000..8b8b31a --- /dev/null +++ b/src/app/caregiver/find.rs @@ -0,0 +1,96 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use bson::DateTime; +use chrono::Utc; +use futures::StreamExt; +use mongodb::{ + bson::{doc, oid::ObjectId, to_document}, + Collection, Cursor, Database, +}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use tracing::info; + +use crate::app::{ + auth::{self, middleware::Auth}, + models::{ + dto::{caregiver::CaregiverTokenPath, form::CreateFormPayload}, + CaregiverToken, Form, User, + }, +}; + +#[derive(Serialize, Deserialize)] +struct CaregiverInfo { + id: ObjectId, + first_name: String, + last_name: String, + email_address: String, +} + +#[tracing::instrument] +#[axum::debug_handler] +pub async fn find_caregiver(State(db): State, Auth(auth): Auth) -> Response { + let Some(auth) = auth else { + return ( + StatusCode::UNAUTHORIZED, + String::from("You must be signed in to find caregivers"), + ) + .into_response(); + }; + + // Perform the aggregation using $lookup + let pipeline = vec![ + doc! { + "$match": { + "_id": auth.id + } + }, + doc! { + "$lookup": { + "from": "users", // The caregivers collection + "localField": "caregivers", // The field in users that holds caregiver IDs + "foreignField": "_id", // The field in caregivers that matches the user_id + "as": "caregivers_info" // The result will be stored in this field + } + }, + doc! { + "$project": { + "caregivers_info._id": 1, + "caregivers_info.first_name": 1, // Only retrieve caregiver ID and name + "caregivers_info.last_name": 1, // Only retrieve caregiver ID and name + "caregivers_info.email_address": 1, // Only retrieve caregiver ID and name + } + }, + ]; + + let Ok(mut cursor): Result, mongodb::error::Error> = + db.collection::("users").aggregate(pipeline).await + else { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + String::from("could not find caregivers"), + ) + .into_response(); + }; + + let Some(Ok(caregiver)) = cursor.next().await else { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + String::from("could not find caregiver list"), + ) + .into_response(); + }; + + let Some(caregiver_info) = caregiver.get("caregivers_info") else { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + String::from("cannot extract caregiver information"), + ) + .into_response(); + }; + (StatusCode::OK, Json(caregiver_info)).into_response() +} diff --git a/src/app/caregiver/generate.rs b/src/app/caregiver/generate.rs index 336830f..90594b1 100644 --- a/src/app/caregiver/generate.rs +++ b/src/app/caregiver/generate.rs @@ -17,13 +17,15 @@ use crate::app::{ models::{dto::form::CreateFormPayload, CaregiverToken, Form, User}, }; +use super::delete_expired_tokens; + #[tracing::instrument] #[axum::debug_handler] pub async fn generate(State(db): State, Auth(auth): Auth) -> Response { let Some(auth) = auth else { return ( StatusCode::UNAUTHORIZED, - String::from("You must be signed in to create a form"), + String::from("You must be signed in to generate a caregiver token"), ) .into_response(); }; diff --git a/src/app/caregiver/mod.rs b/src/app/caregiver/mod.rs index f020055..29ae791 100644 --- a/src/app/caregiver/mod.rs +++ b/src/app/caregiver/mod.rs @@ -1,15 +1,43 @@ pub mod add; +pub mod find; pub mod generate; +pub mod remove; use axum::{ - routing::{get, post}, + routing::{delete, get, post}, Router, }; +use bson::{doc, DateTime}; +use mongodb::Collection; -use super::AppState; +use crate::app::auth::info; + +use super::{models::CaregiverToken, AppState}; + +async fn delete_expired_tokens( + collection: &Collection, +) -> mongodb::error::Result<()> { + let now = DateTime::now(); + + let filter = doc! { + "expired_by": { + "$lt": now + } + }; + + let delete_result = collection.delete_many(filter).await?; + tracing::info!( + "Deleted {} expired caregiver tokens", + delete_result.deleted_count + ); + + Ok(()) +} pub fn router() -> Router { Router::new() - .route("/generate", post(generate::generate)) + .route("/list", get(find::find_caregiver)) + .route("/generate", get(generate::generate)) .route("/add/:token", post(add::add_caregiver)) + .route("/remove/:caregiver_id", delete(remove::remove_caregiver)) } diff --git a/src/app/caregiver/remove.rs b/src/app/caregiver/remove.rs new file mode 100644 index 0000000..2b92468 --- /dev/null +++ b/src/app/caregiver/remove.rs @@ -0,0 +1,68 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use bson::DateTime; +use chrono::Utc; +use mongodb::{ + bson::{doc, oid::ObjectId, to_document}, + Collection, Database, +}; +use serde_json::json; +use tracing::info; + +use crate::app::{ + auth::{self, middleware::Auth}, + models::{ + dto::{ + caregiver::{CaregiverTokenPath, RemoveCaregiverPath}, + form::CreateFormPayload, + }, + CaregiverToken, Form, User, + }, +}; + +#[tracing::instrument] +#[axum::debug_handler] +pub async fn remove_caregiver( + State(db): State, + Auth(auth): Auth, + Path(path): Path, +) -> Response { + let Some(auth) = auth else { + return ( + StatusCode::UNAUTHORIZED, + String::from("You must be signed in to create a form"), + ) + .into_response(); + }; + + + + match db + .collection::("users") + .find_one_and_update( + doc! { + "_id": auth.id + }, + doc! { + "$pull": { + "caregivers": path.caregiver_id + } + }, + ) + .await + { + Ok(..) => StatusCode::OK.into_response(), + Err(err) => { + tracing::error!("{:#?}", err); + ( + StatusCode::INTERNAL_SERVER_ERROR, + String::from("Could not remove caregiver"), + ) + } + .into_response(), + } +} diff --git a/src/app/form/create.rs b/src/app/form/create.rs index aed4678..d45d69d 100644 --- a/src/app/form/create.rs +++ b/src/app/form/create.rs @@ -5,14 +5,14 @@ use axum::{ Json, }; use mongodb::{ - bson::{doc, oid::ObjectId, to_document}, + bson::{doc, oid::ObjectId}, Database, }; use serde_json::json; use crate::app::{ - auth::{self, middleware::Auth}, - models::{dto::form::CreateFormPayload, Form, User}, + auth::middleware::Auth, + models::{dto::form::CreateFormPayload, Form}, }; #[tracing::instrument] @@ -31,7 +31,14 @@ pub async fn create_form( }; let id = ObjectId::new(); - let form = Form::from(id, payload.title, auth.id, auth.id, payload.questions); + let form = Form::from( + id, + payload.title, + payload.description, + auth.id, + auth.id, + payload.questions, + ); let result = db.collection::
("forms").insert_one(form).await; match result { Ok(..) => (StatusCode::OK, Json(json! ({ "created_id": id }))).into_response(), diff --git a/src/app/form/find.rs b/src/app/form/find.rs index c46ae8f..f569c1e 100644 --- a/src/app/form/find.rs +++ b/src/app/form/find.rs @@ -1,21 +1,26 @@ +use std::{borrow::BorrowMut, str::FromStr, time::SystemTime}; + use axum::{ extract::{Path, State}, http::StatusCode, response::{IntoResponse, Response}, Json, }; +use chrono::{Duration, Utc}; +use chrono_humanize::HumanTime; use futures::StreamExt; use mongodb::{ bson::{doc, oid::ObjectId, to_document}, - Database, + Cursor, Database, }; +use serde::{Deserialize, Serialize}; use serde_json::json; use crate::app::{ auth::{self, middleware::Auth}, models::{ dto::form::{CreateFormPayload, FindPath}, - Form, User, + Event, Form, FormSubmitted, User, }, }; @@ -38,11 +43,30 @@ pub async fn find( .collection::("forms") .find_one(doc! { "_id": path.form_id, - "user_id": auth.id }) .await; + match result { - Ok(data) => (StatusCode::OK, Json(data)).into_response(), + Ok(data) => { + match &data { + Some(form) => { + let Ok(Some(user)) = db + .collection::("users") + .find_one(doc! { + "_id": form.user_id + }) + .await + else { + return (StatusCode::UNAUTHORIZED).into_response(); + }; + if !(user.caregivers.contains(&auth.id) || user.id == Some(auth.id)) { + return (StatusCode::UNAUTHORIZED).into_response(); + } + } + None => {} + }; + (StatusCode::OK, Json(data)).into_response() + } Err(e) => { tracing::error!(error = %e, "Error occurred while querying database"); StatusCode::INTERNAL_SERVER_ERROR.into_response() @@ -50,35 +74,213 @@ pub async fn find( } } +async fn get_users_forms( + user_id: ObjectId, + db: &Database, +) -> Result, mongodb::error::Error> { + let result = db + .collection::("forms") + .find(doc! { + "user_id": user_id + }) + .await; + + match result { + Ok(mut data) => { + let mut forms: Vec = Vec::new(); + while let Some(Ok(form)) = data.next().await { + forms.push(form); + } + Ok(forms) + } + Err(e) => { + tracing::error!(error = %e, "Error occurred while querying database"); + Err(e) + } + } +} + #[tracing::instrument] #[axum::debug_handler] pub async fn find_all(State(db): State, Auth(auth): Auth) -> Response { let Some(auth) = auth else { return ( StatusCode::UNAUTHORIZED, - String::from("You must be signed in to create a form"), + String::from("You must be signed in to find forms"), + ) + .into_response(); + }; + + let Ok(mut patients): Result, mongodb::error::Error> = db + .clone() + .collection("users") + .find(doc! { + "caregivers": auth.id + }) + .await + else { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + String::from("could not query database"), ) .into_response(); }; + let mut forms: Vec = Vec::new(); + + let Ok(own_forms) = get_users_forms(auth.id, &db).await else { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + String::from("could not find user forms"), + ) + .into_response(); + }; + + forms.extend(own_forms); + + while let Some(Ok(patient)) = &patients.borrow_mut().next().await { + let Some(patient_id) = patient.id else { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + String::from("patient has no id"), + ) + .into_response(); + }; + let Ok(patient_forms) = get_users_forms(patient_id, &db).await else { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + String::from("could not find patients forms"), + ) + .into_response(); + }; + forms.extend(patient_forms); + } + + (StatusCode::OK, Json(forms)).into_response() +} + +#[derive(Serialize, Deserialize)] +struct Symptom { + title: String, + description: Option, + status: String, + id: Option, + recently_completed: bool, +} + +async fn get_users_symptoms( + user_id: ObjectId, + db: &Database, +) -> Result, mongodb::error::Error> { let result = db .collection::("forms") .find(doc! { - "user_id": auth.id + "user_id": user_id }) .await; match result { Ok(mut data) => { - let mut forms: Vec = Vec::new(); + let mut symptoms: Vec = Vec::new(); while let Some(Ok(form)) = data.next().await { - forms.push(form); + let mut most_recent_submission = None; + for event in form.events { + match event { + Event::FormSubmitted(FormSubmitted { submitted_at, .. }) => { + most_recent_submission = Some(match most_recent_submission { + None => submitted_at, + Some(best_time) => { + if submitted_at > best_time { + submitted_at + } else { + best_time + } + } + }) + } + _ => {} + } + } + + let symptom: Symptom = Symptom { + title: form.title, + description: form.description, + status: match most_recent_submission { + None => String::from("Never updated"), + Some(time) => HumanTime::from(time.to_system_time()).to_string(), + }, + id: form.id, + recently_completed: match most_recent_submission { + None => false, + Some(time) => Utc::now() - time.to_chrono() < Duration::hours(36), + }, + }; + symptoms.push(symptom); } - (StatusCode::OK, Json(forms)).into_response() + Ok(symptoms) } Err(e) => { tracing::error!(error = %e, "Error occurred while querying database"); - StatusCode::INTERNAL_SERVER_ERROR.into_response() + Err(e) } } } + +#[tracing::instrument] +#[axum::debug_handler] +pub async fn symptom_list(State(db): State, Auth(auth): Auth) -> Response { + let Some(auth) = auth else { + return ( + StatusCode::UNAUTHORIZED, + String::from("You must be signed in to find forms"), + ) + .into_response(); + }; + + let Ok(mut patients): Result, mongodb::error::Error> = db + .clone() + .collection("users") + .find(doc! { + "caregivers": auth.id + }) + .await + else { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + String::from("could not query database"), + ) + .into_response(); + }; + + let mut symptoms: Vec = Vec::new(); + + let Ok(own_symptoms) = get_users_symptoms(auth.id, &db).await else { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + String::from("could not find user forms"), + ) + .into_response(); + }; + + symptoms.extend(own_symptoms); + + while let Some(Ok(patient)) = &patients.borrow_mut().next().await { + let Some(patient_id) = patient.id else { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + String::from("patient has no id"), + ) + .into_response(); + }; + let Ok(patient_symptoms) = get_users_symptoms(patient_id, &db).await else { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + String::from("could not find patients forms"), + ) + .into_response(); + }; + symptoms.extend(patient_symptoms); + } + + (StatusCode::OK, Json(symptoms)).into_response() +} diff --git a/src/app/form/history.rs b/src/app/form/history.rs new file mode 100644 index 0000000..ca912f5 --- /dev/null +++ b/src/app/form/history.rs @@ -0,0 +1,91 @@ +use std::cmp::Ordering; + +use axum::{ + extract::State, + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use futures::TryStreamExt; +use mongodb::{ + bson::{doc, oid::ObjectId, DateTime}, + Database, +}; +use serde::{Deserialize, Serialize}; + +use crate::app::{ + auth::middleware::Auth, + models::{Event, Form, Question, QuestionAndAnswer}, +}; +#[derive(Serialize, Deserialize)] +struct FormSubmittedWithForm { + pub id: Option, + pub user_id: Option, + pub title: String, + pub created_by: ObjectId, + pub created_at: DateTime, + pub questions: Vec, + pub answers: Vec, + pub submitted_at: DateTime, + pub submitted_by: ObjectId, +} + +#[tracing::instrument] +#[axum::debug_handler] +pub async fn history(State(db): State, Auth(auth): Auth) -> Response { + let Some(auth) = auth else { + return ( + StatusCode::UNAUTHORIZED, + String::from("You must be signed in to view a form"), + ) + .into_response(); + }; + + let result = db + .collection::("forms") + .find(doc! { + "user_id": auth.id + }) + .await; + + match result { + Ok(data) => { + let mut res = match data.try_collect::>().await { + Err(..) => Vec::new(), + Ok(forms) => forms + .iter() + .map(|form| { + form.events.iter().filter_map(|event: &Event| match event { + Event::FormSubmitted(form_submitted) => Some(FormSubmittedWithForm { + id: form.id, + user_id: form.user_id, + title: form.title.clone(), + created_by: form.created_by, + created_at: form.created_at, + questions: form.questions.clone(), + answers: form_submitted.answers.clone(), + submitted_at: form_submitted.submitted_at, + submitted_by: form_submitted.submitted_by, + }), + _ => None, + }) + }) + .flatten() + .collect::>(), + }; + res.sort_by(|a, b| { + if a.submitted_at.to_chrono().timestamp() > b.submitted_at.to_chrono().timestamp() { + Ordering::Less + } else { + Ordering::Greater + } + }); + (StatusCode::OK, Json(res)).into_response() + } + + Err(e) => { + tracing::error!(error = %e, "Error occurred while querying database"); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } +} diff --git a/src/app/form/mod.rs b/src/app/form/mod.rs index 42c8494..ab076e0 100644 --- a/src/app/form/mod.rs +++ b/src/app/form/mod.rs @@ -1,5 +1,6 @@ mod create; mod find; +mod history; mod submit; use axum::{ @@ -7,7 +8,8 @@ use axum::{ Router, }; use create::create_form; -use find::{find, find_all}; +use find::{find, find_all, symptom_list}; +use history::history; use submit::submit; use super::AppState; @@ -17,5 +19,7 @@ pub fn router() -> Router { .route("/create", post(create_form)) .route("/find/:form_id", get(find)) .route("/find", get(find_all)) + .route("/symptoms", get(symptom_list)) .route("/submit/:form_id", post(submit)) + .route("/history", get(history)) } diff --git a/src/app/form/submit.rs b/src/app/form/submit.rs index 71369fc..c7fb81c 100644 --- a/src/app/form/submit.rs +++ b/src/app/form/submit.rs @@ -12,8 +12,8 @@ use mongodb::{ use crate::app::{ auth::middleware::Auth, models::{ - dto::form::{CreateFormPayload, SubmitPath, SubmitPayload}, - Form, User, + dto::form::{SubmitPath, SubmitPayload}, + Form, }, }; @@ -46,10 +46,13 @@ pub async fn submit( return StatusCode::BAD_REQUEST.into_response(); } }; + + tracing::info!("answers_document = {:#?}", answers_document); + let result = db .collection::("forms") .update_one( - doc! { "user_id": auth.id, "_id": path.form_id }, + doc! { "_id": path.form_id }, doc! { "$push": { "events": { "FormSubmitted": { "answers": answers_document, "submitted_by": auth.id, @@ -60,6 +63,7 @@ pub async fn submit( match result { Ok(result) => { if result.modified_count == 0 { + tracing::error!("No update to the database {:#?}", result); return StatusCode::BAD_REQUEST.into_response(); } StatusCode::OK.into_response() diff --git a/src/app/medication/add.rs b/src/app/medication/add.rs new file mode 100644 index 0000000..8d71090 --- /dev/null +++ b/src/app/medication/add.rs @@ -0,0 +1,53 @@ +use axum::{ + extract::State, + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use mongodb::{ + bson::{doc, oid::ObjectId}, + Database, +}; +use serde_json::json; + +use crate::app::{ + auth::middleware::Auth, + models::{dto::medication::AddMedicationPayload, MedicationTrackerEntry}, +}; + +// TODO!: input validation wrt. string length, etc +#[tracing::instrument] +#[axum::debug_handler] +pub async fn add_medication( + State(db): State, + Auth(auth): Auth, + Json(payload): Json, +) -> Response { + let Some(auth) = auth else { + return ( + StatusCode::UNAUTHORIZED, + String::from("You must be signed in to add a medication"), + ) + .into_response(); + }; + + let id = ObjectId::new(); + let medication = MedicationTrackerEntry::from( + id, + auth.id, + payload.medication_name, + payload.dose, + payload.timing, + ); + let result = db + .collection::("medications") + .insert_one(medication) + .await; + match result { + Ok(..) => (StatusCode::OK, Json(json! ({ "created_id": id }))).into_response(), + Err(e) => { + tracing::error!(error = %e, "Error occurred while querying database"); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } +} diff --git a/src/app/medication/find.rs b/src/app/medication/find.rs new file mode 100644 index 0000000..ed8f005 --- /dev/null +++ b/src/app/medication/find.rs @@ -0,0 +1,77 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use futures::StreamExt; +use mongodb::{bson::doc, Database}; + +use crate::app::{ + auth::middleware::Auth, + models::{dto::medication::FindPath, MedicationTrackerEntry}, +}; + +#[tracing::instrument] +#[axum::debug_handler] +pub async fn find_medication( + State(db): State, + Auth(auth): Auth, + Path(path): Path, +) -> Response { + let Some(auth) = auth else { + return ( + StatusCode::UNAUTHORIZED, + String::from("You must be signed in to access medication tracker entries"), + ) + .into_response(); + }; + + let result = db + .collection::("medications") + .find_one(doc! { + "_id": path.medication_id, + "user_id": auth.id + }) + .await; + match result { + Ok(data) => (StatusCode::OK, Json(data)).into_response(), + Err(e) => { + tracing::error!(error = %e, "Error occurred while querying database"); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } +} + +#[tracing::instrument] +#[axum::debug_handler] +pub async fn find_all_medications(State(db): State, Auth(auth): Auth) -> Response { + let Some(auth) = auth else { + return ( + StatusCode::UNAUTHORIZED, + String::from("You must be signed in to access medication tracker entries"), + ) + .into_response(); + }; + + let result = db + .collection::("medications") + .find(doc! { + "user_id": auth.id + }) + .await; + + match result { + Ok(mut data) => { + let mut medications: Vec = Vec::new(); + while let Some(Ok(medication)) = data.next().await { + medications.push(medication); + } + (StatusCode::OK, Json(medications)).into_response() + } + Err(e) => { + tracing::error!(error = %e, "Error occurred while querying database"); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } +} diff --git a/src/app/medication/mod.rs b/src/app/medication/mod.rs new file mode 100644 index 0000000..e3966fa --- /dev/null +++ b/src/app/medication/mod.rs @@ -0,0 +1,24 @@ +mod add; +mod find; +mod remove; +mod update; + +use add::add_medication; +use axum::{ + routing::{get, post}, + Router, +}; +use find::{find_all_medications, find_medication}; +use remove::remove_medication; +use update::update_medication; + +use super::AppState; + +pub fn router() -> Router { + Router::new() + .route("/find/:medication_id", get(find_medication)) + .route("/find", get(find_all_medications)) + .route("/add", post(add_medication)) + .route("/update/:medication_id", post(update_medication)) + .route("/remove/:medication_id", post(remove_medication)) +} diff --git a/src/app/medication/remove.rs b/src/app/medication/remove.rs new file mode 100644 index 0000000..6e419d8 --- /dev/null +++ b/src/app/medication/remove.rs @@ -0,0 +1,45 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use mongodb::{bson::doc, Database}; + +use crate::app::{ + auth::middleware::Auth, + models::{dto::medication::RemovePath, MedicationTrackerEntry}, +}; + +// TODO!: input validation wrt. string length, etc +#[tracing::instrument] +#[axum::debug_handler] +pub async fn remove_medication( + State(db): State, + Auth(auth): Auth, + Path(path): Path, +) -> Response { + let Some(auth) = auth else { + return ( + StatusCode::UNAUTHORIZED, + String::from("You must be signed in to remove a medication"), + ) + .into_response(); + }; + + let result = db + .collection::("medications") + .delete_one(doc! {"_id": path.medication_id, "user_id": auth.id }) + .await; + match result { + Ok(result) => { + if result.deleted_count == 0 { + return StatusCode::BAD_REQUEST.into_response(); + } + StatusCode::OK.into_response() + } + Err(e) => { + tracing::error!(error = %e, "Error occurred while querying database"); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } +} diff --git a/src/app/medication/update.rs b/src/app/medication/update.rs new file mode 100644 index 0000000..e3fec0c --- /dev/null +++ b/src/app/medication/update.rs @@ -0,0 +1,50 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use mongodb::{bson::doc, Database}; + +use crate::app::{ + auth::middleware::Auth, + models::{ + dto::medication::{AddMedicationPayload, UpdatePath}, + MedicationTrackerEntry, + }, +}; + +// TODO!: input validation wrt. string length, etc +#[tracing::instrument] +#[axum::debug_handler] +pub async fn update_medication( + State(db): State, + Auth(auth): Auth, + Path(path): Path, + Json(payload): Json, +) -> Response { + let Some(auth) = auth else { + return ( + StatusCode::UNAUTHORIZED, + String::from("You must be signed in to update a medication"), + ) + .into_response(); + }; + + let result = db.collection::("medications") + .update_one(doc! {"_id": path.medication_id, "user_id": auth.id }, + doc! { "$set": { "medication_name": payload.medication_name, "dose": payload.dose, "timing": payload.timing }} ) + .await; + match result { + Ok(result) => { + if result.modified_count == 0 { + return StatusCode::BAD_REQUEST.into_response(); + } + StatusCode::OK.into_response() + } + Err(e) => { + tracing::error!(error = %e, "Error occurred while querying database"); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 559f877..40d13f9 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,10 +1,12 @@ pub mod auth; pub mod caregiver; pub mod form; +pub mod medication; pub mod models; use axum::extract::{Path, State}; use axum::http::Method; +use axum_extra::headers::Origin; use dotenvy::dotenv; use models::{CaregiverToken, User}; use mongodb::options::IndexOptions; @@ -12,7 +14,7 @@ use mongodb::IndexModel; use std::time::Duration; use anyhow::Context; -use axum::http::header::{AUTHORIZATION, CONTENT_TYPE}; +use axum::http::header::{AUTHORIZATION, CONTENT_TYPE, ORIGIN}; use axum::{ extract::{FromRef, MatchedPath}, http::{Request, StatusCode}, @@ -55,9 +57,15 @@ impl AppState { create_unique_email_address_index(&db) .await .inspect_err( - |e| tracing::error!(error = %e, "Failed to create unique index on email address"), + |e| tracing::error!(error = %e, "Failed to create unique index on caregiver tokens"), ) - .with_context(|| String::from("Failed to create unique index on email address"))?; + .with_context(|| String::from("Failed to create unique index on caregiver token"))?; + create_unique_caregiver_token_index(&db) + .await + .inspect_err( + |e| tracing::error!(error = %e, "Failed to create unique index on caregiver token"), + ) + .with_context(|| String::from("Failed to create unique index on caregiver token"))?; tracing::info!("Connected to database at {database_url}"); Ok(AppState { db }) } @@ -91,6 +99,7 @@ pub async fn run() { .nest("/form", form::router()) .nest("/auth", auth::router()) .nest("/caregiver", caregiver::router()) + .nest("/medication", medication::router()) .with_state(app_state) .layer(CookieManagerLayer::new()) .layer( diff --git a/src/app/models/dto/caregiver.rs b/src/app/models/dto/caregiver.rs index a2a5a87..639ed68 100644 --- a/src/app/models/dto/caregiver.rs +++ b/src/app/models/dto/caregiver.rs @@ -1,6 +1,12 @@ +use bson::oid::ObjectId; use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Clone, Debug)] pub struct CaregiverTokenPath { pub token: String, } + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct RemoveCaregiverPath { + pub caregiver_id: ObjectId, +} diff --git a/src/app/models/dto/form.rs b/src/app/models/dto/form.rs index cbb1385..6c2d3e3 100644 --- a/src/app/models/dto/form.rs +++ b/src/app/models/dto/form.rs @@ -7,6 +7,7 @@ use crate::app::models::{Question, QuestionAndAnswer}; pub struct CreateFormPayload { // TODO!: get user id from request header instead once we can do that - don't let people mess with each other's forms pub title: String, + pub description: Option, pub questions: Vec, } diff --git a/src/app/models/dto/medication.rs b/src/app/models/dto/medication.rs new file mode 100644 index 0000000..77e43bd --- /dev/null +++ b/src/app/models/dto/medication.rs @@ -0,0 +1,31 @@ +use bson::oid::ObjectId; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct AddMedicationPayload { + pub medication_name: String, + pub dose: String, + pub timing: String, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct UpdateMedicationPayload { + pub medication_name: String, + pub dose: String, + pub timing: String, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct FindPath { + pub medication_id: ObjectId, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct UpdatePath { + pub medication_id: ObjectId, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct RemovePath { + pub medication_id: ObjectId, +} diff --git a/src/app/models/dto/mod.rs b/src/app/models/dto/mod.rs index aeb934d..a93831a 100644 --- a/src/app/models/dto/mod.rs +++ b/src/app/models/dto/mod.rs @@ -1,3 +1,4 @@ pub mod caregiver; pub mod form; +pub mod medication; pub mod user; diff --git a/src/app/models/mod.rs b/src/app/models/mod.rs index 4791516..6306184 100644 --- a/src/app/models/mod.rs +++ b/src/app/models/mod.rs @@ -53,6 +53,7 @@ impl User { /// user_id: Some(ObjectId::new()), /// title: String::from("Tremors"), /// created_by: ObjectId::new(), +/// description: None, /// created_at: DateTime::now(), /// questions: vec![ /// Question::Multichoice(MultichoiceQuestion { @@ -115,6 +116,7 @@ pub struct Form { pub user_id: Option, /// Title of the form for clients pub title: String, + pub description: Option, pub created_by: ObjectId, pub created_at: DateTime, /// List of questions in the form @@ -128,6 +130,7 @@ impl Form { pub fn from( id: ObjectId, title: String, + description: Option, created_by: ObjectId, user_id: ObjectId, mut questions: Vec, @@ -140,12 +143,7 @@ impl Form { option.id = Some(ObjectId::new()); } } - Question::MultichoiceSlider(ref mut question) => { - question.id = Some(ObjectId::new()); - for option in &mut question.options { - option.id = Some(ObjectId::new()); - } - } + Question::Slider(ref mut question) => { question.id = Some(ObjectId::new()); } @@ -157,6 +155,7 @@ impl Form { Self { id: Some(id), title, + description, created_by, user_id: Some(user_id), created_at: DateTime::now(), @@ -200,9 +199,6 @@ pub struct FormSubmitted { pub enum Question { /// This is a list of multiple choices for a question Multichoice(MultichoiceQuestion), - /// This is a multichoice slider with options - /// such as Very Bad, Bad, Okay, Good, Very Good - MultichoiceSlider(MultichoiceSliderQuestion), /// This is a numeric slider Slider(SliderQuestion), /// This is for free form questions where the client may type whatever @@ -210,9 +206,7 @@ pub enum Question { } /// ID of choice in the questions that is selected -pub type MultichoiceAnswer = ObjectId; -/// ID of choice in the questions that is selected -pub type MultichoiceSliderAnswer = ObjectId; +pub type MultichoiceAnswer = Vec; /// Numerical value that the user selects pub type SliderAnswer = f64; /// String for the answer that the client types @@ -222,7 +216,6 @@ pub type FreeFormAnswer = String; #[derive(Serialize, Deserialize, Clone, Debug)] pub enum QuestionAndAnswer { Multichoice(ObjectId, MultichoiceAnswer), - MultichoiceSlider(ObjectId, MultichoiceSliderAnswer), Slider(ObjectId, SliderAnswer), FreeForm(ObjectId, FreeFormAnswer), } @@ -246,22 +239,9 @@ pub struct SliderQuestion { pub low: f64, pub high: f64, pub step: f64, -} -#[derive(Serialize, Deserialize, Clone, Debug, Default)] -pub struct MultichoiceSliderQuestion { - #[serde(skip_serializing_if = "Option::is_none")] - pub id: Option, - pub title: String, - pub options: Vec, - pub min_selected: u64, - pub max_selected: u64, -} - -#[derive(Serialize, Deserialize, Clone, Debug, Default)] -pub struct MultichoiceSliderQuestionOption { - pub name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub id: Option, + pub highest_message: Option, + pub middle_message: Option, + pub lowest_message: Option, } #[derive(Serialize, Deserialize, Clone, Debug, Default)] @@ -308,3 +288,33 @@ impl CaregiverToken { } } } + +/// A medication tracking entry for a user, consisting of the medication name, dose, and timing. +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct MedicationTrackerEntry { + #[serde(rename = "_id")] + id: ObjectId, + pub user_id: ObjectId, + medication_name: String, + dose: String, + timing: String, +} + +impl MedicationTrackerEntry { + #[must_use] + pub fn from( + id: ObjectId, + user_id: ObjectId, + medication_name: String, + dose: String, + timing: String, + ) -> Self { + Self { + id, + user_id, + medication_name, + dose, + timing, + } + } +} diff --git a/src/config.rs b/src/config.rs index 6282f9a..363a11b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -3,6 +3,7 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use axum::http::HeaderValue; const DEFAULT_APT_PORT: u16 = 4444; +const DEFAULT_ENV: &str = "PRODUCTION"; const DEFAULT_DATABASE_URL: &str = "mongodb://localhost:27017"; /// Get set database url @@ -41,6 +42,23 @@ pub fn get_jwt_secret() -> String { std::env::var("JWT_SECRET").expect("JWT_SECRET environment variable must be set") } +pub fn get_optl_collecter_address() -> String { + std::env::var("OPTL_COLLECTOR").unwrap_or("http://localhost:4317".to_string()) +} +/// Get set is_production +/// +/// Returns port set by env `ENV`. If it is unable to retrieve +/// +/// # Panics +/// This function panics if the `DEFAULT_ENV` is not a valid port. +pub fn get_is_production() -> String { + std::env::var("ENV").unwrap_or_else(|e| { + tracing::warn!(DEFAULT_ENV, error = %e, + "Unable to retrieve ENV; falling back to default port"); + DEFAULT_ENV.to_string() + }) +} + /// Get set api port /// /// Returns port set by env `API_PORT`. If it is unable to retrieve diff --git a/src/metrics/logging.rs b/src/metrics/logging.rs index 8ef3e05..9974530 100644 --- a/src/metrics/logging.rs +++ b/src/metrics/logging.rs @@ -1,7 +1,16 @@ -#[cfg(feature = "jaeger_tracing")] -use opentelemetry::global; +use opentelemetry::{global, trace::TracerProvider, KeyValue}; +use opentelemetry_otlp::WithExportConfig; +use opentelemetry_sdk::{ + runtime, + trace::{BatchConfig, Config}, + Resource, +}; +use opentelemetry_semantic_conventions::attribute::SERVICE_NAME; +use tracing_opentelemetry::OpenTelemetryLayer; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, Layer}; +use crate::config::get_optl_collecter_address; + /// Initializes tracing and logging for service /// /// # Features @@ -13,21 +22,27 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, Layer}; /// pub fn init() { let mut layers = Vec::new(); - + let mut opentelemetry_layers = Vec::new(); #[cfg(feature = "jaeger_tracing")] { - global::set_text_map_propagator(opentelemetry_jaeger::Propagator::new()); - - let jaeger_tracer = opentelemetry_jaeger::new_pipeline() - .with_service_name(env!("CARGO_PKG_NAME")) - .install_simple() - .expect("Failed to generate Jaeger tracing pipeline"); - - let jaeger_layer = tracing_opentelemetry::layer() - .with_tracer(jaeger_tracer) - .boxed(); + let optl_tracer = + opentelemetry_otlp::new_pipeline() + .tracing() + .with_trace_config(Config::default().with_resource(Resource::new(vec![ + KeyValue::new(SERVICE_NAME, "parkinsons_pulse_service"), + ]))) + .with_exporter( + opentelemetry_otlp::new_exporter() + .tonic() + .with_endpoint(get_optl_collecter_address()), + ) + .with_batch_config(BatchConfig::default()) + .install_batch(runtime::Tokio) + .expect("Failed to initialize tracer provider."); - layers.push(jaeger_layer); + global::set_tracer_provider(optl_tracer.clone()); + let opentelemetry_layer = OpenTelemetryLayer::new(optl_tracer.tracer("otel-subscriber")); + opentelemetry_layers.push(opentelemetry_layer); } #[cfg(feature = "local_log")] @@ -44,6 +59,7 @@ pub fn init() { tracing_subscriber::registry() .with(layers) + .with(opentelemetry_layers) .try_init() .expect("Could not init tracing registry");