diff --git a/Cargo.lock b/Cargo.lock index ffbbd6b031..402248baac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2166,10 +2166,47 @@ dependencies = [ [[package]] name = "fitness_resolver" version = "0.1.0" +dependencies = [ + "async-graphql", + "common_models", + "database_models", + "dependent_models", + "fitness_models", + "fitness_service", + "sea-orm", + "traits", +] [[package]] name = "fitness_service" version = "0.1.0" +dependencies = [ + "anyhow", + "apalis", + "application_utils", + "async-graphql", + "background", + "common_models", + "common_utils", + "config", + "const-str", + "database_models", + "database_utils", + "dependent_models", + "enums", + "file_storage_service", + "fitness_models", + "itertools 0.13.0", + "migrations", + "nanoid", + "reqwest 0.11.23", + "rust_decimal", + "rust_decimal_macros", + "sea-orm", + "sea-query", + "slug", + "tracing", +] [[package]] name = "flate2" @@ -4849,7 +4886,6 @@ dependencies = [ "chrono", "chrono-tz", "config", - "const-str", "convert_case", "csv", "data-encoding", diff --git a/Cargo.toml b/Cargo.toml index 2fee079dd9..8863c446fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,6 +68,7 @@ sea-query = "=0.31.0" serde = { version = "=1.0.204", features = ["derive"] } serde_json = "=1.0.120" serde_with = { version = "=3.9.0", features = ["chrono_0_4"] } +slug = "=0.1.5" strum = { version = "=0.26.2", features = ["derive"] } # FIXME: Upgrade once https://github.com/seanmonstar/reqwest/pull/1620 is merged reqwest = { git = "https://github.com/thomasqueirozb/reqwest", branch = "base_url", features = [ diff --git a/apps/backend/Cargo.toml b/apps/backend/Cargo.toml index ca36712cc1..4c7a2efcbe 100644 --- a/apps/backend/Cargo.toml +++ b/apps/backend/Cargo.toml @@ -19,7 +19,6 @@ chrono = { workspace = true } chrono-tz = { workspace = true } config = { path = "../../crates/config" } convert_case = { workspace = true } -const-str = { workspace = true } csv = "=1.3.0" data-encoding = "=2.6.0" dotenvy = "=0.15.7" @@ -50,7 +49,7 @@ serde_json = { workspace = true } serde_with = { workspace = true } serde-xml-rs = "=0.6.0" services = { path = "../../crates/services" } -slug = "=0.1.5" +slug = { workspace = true } tokio = { workspace = true } tower = { version = "=0.4.13", features = ["buffer"] } tower-http = { version = "=0.5.2", features = ["catch-panic", "cors", "trace"] } diff --git a/apps/backend/src/app_background.rs b/apps/backend/src/app_background.rs index b8f34f9acd..eb7248a8a6 100644 --- a/apps/backend/src/app_background.rs +++ b/apps/backend/src/app_background.rs @@ -5,12 +5,9 @@ use background::{ApplicationJob, CoreApplicationJob}; use chrono::DateTime; use chrono_tz::Tz; use models::CommitMediaInput; -use services::ExporterService; +use services::{ExerciseService, ExporterService}; -use crate::{ - fitness::resolver::ExerciseService, importer::ImporterService, - miscellaneous::MiscellaneousService, -}; +use crate::{importer::ImporterService, miscellaneous::MiscellaneousService}; // Cron Jobs diff --git a/apps/backend/src/app_utils.rs b/apps/backend/src/app_utils.rs index db356e9d7f..3d5e089d0a 100644 --- a/apps/backend/src/app_utils.rs +++ b/apps/backend/src/app_utils.rs @@ -8,13 +8,12 @@ use openidconnect::{ reqwest::async_http_client, ClientId, ClientSecret, IssuerUrl, RedirectUrl, }; -use resolvers::{ExporterMutation, ExporterQuery}; +use resolvers::{ExerciseMutation, ExerciseQuery, ExporterMutation, ExporterQuery}; use sea_orm::DatabaseConnection; -use services::{ExporterService, FileStorageService}; +use services::{ExerciseService, ExporterService, FileStorageService}; use utils::FRONTEND_OAUTH_ENDPOINT; use crate::{ - fitness::resolver::{ExerciseMutation, ExerciseQuery, ExerciseService}, importer::{ImporterMutation, ImporterQuery, ImporterService}, miscellaneous::{MiscellaneousMutation, MiscellaneousQuery, MiscellaneousService}, }; diff --git a/apps/backend/src/fitness/mod.rs b/apps/backend/src/fitness/mod.rs deleted file mode 100644 index 77eb00f297..0000000000 --- a/apps/backend/src/fitness/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod resolver; - -mod logic; diff --git a/apps/backend/src/fitness/resolver.rs b/apps/backend/src/fitness/resolver.rs deleted file mode 100644 index fed2d439c8..0000000000 --- a/apps/backend/src/fitness/resolver.rs +++ /dev/null @@ -1,868 +0,0 @@ -use std::sync::Arc; - -use apalis::prelude::{MemoryStorage, MessageQueue}; -use async_graphql::{Context, Enum, Error, InputObject, Object, Result, SimpleObject}; -use background::{ApplicationJob, CoreApplicationJob}; -use enums::{ - ExerciseEquipment, ExerciseForce, ExerciseLevel, ExerciseLot, ExerciseMechanic, ExerciseMuscle, - ExerciseSource, -}; -use itertools::Itertools; -use migrations::AliasedExercise; -use models::{ - collection, collection_to_entity, exercise, - prelude::{CollectionToEntity, Exercise, UserMeasurement, UserToEntity, Workout}, - user_measurement, user_to_entity, workout, ChangeCollectionToEntityInput, DefaultCollection, - ExerciseAttributes, ExerciseCategory, ExerciseListItem, GithubExercise, - GithubExerciseAttributes, SearchDetails, SearchInput, SearchResults, StoredUrl, - UserExerciseInput, UserMeasurementsListInput, UserToExerciseHistoryExtraInformation, - UserWorkoutDetails, UserWorkoutInput, UserWorkoutSetRecord, -}; -use sea_orm::{ - prelude::DateTimeUtc, ActiveModelTrait, ActiveValue, ColumnTrait, DatabaseConnection, - EntityTrait, Iterable, ModelTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, - QueryTrait, RelationTrait, -}; -use sea_query::{extension::postgres::PgExpr, Alias, Condition, Expr, Func, JoinType}; -use serde::{Deserialize, Serialize}; -use services::FileStorageService; -use slug::slugify; -use traits::AuthProvider; -use utils::{ - add_entity_to_collection, entity_in_collections, ilike_sql, user_by_id, user_measurements_list, - workout_details, GraphqlRepresentation, -}; - -use super::logic::{calculate_and_commit, delete_existing_workout}; - -const EXERCISE_DB_URL: &str = "https://raw.githubusercontent.com/yuhonas/free-exercise-db/main"; -const JSON_URL: &str = const_str::concat!(EXERCISE_DB_URL, "/dist/exercises.json"); -const IMAGES_PREFIX_URL: &str = const_str::concat!(EXERCISE_DB_URL, "/exercises"); - -#[derive(Debug, Serialize, Deserialize, InputObject, Clone)] -struct ExerciseListFilter { - #[graphql(name = "type")] - lot: Option, - level: Option, - force: Option, - mechanic: Option, - equipment: Option, - muscle: Option, - collection: Option, -} - -#[derive(Debug, Serialize, Deserialize, Enum, Clone, PartialEq, Eq, Copy, Default)] -enum ExerciseSortBy { - Name, - #[default] - LastPerformed, - TimesPerformed, -} - -#[derive(Debug, Serialize, Deserialize, InputObject, Clone)] -struct ExercisesListInput { - search: SearchInput, - filter: Option, - sort_by: Option, -} - -#[derive(Debug, Serialize, Deserialize, SimpleObject, Clone)] -struct ExerciseParameters { - /// All filters applicable to an exercises query. - filters: ExerciseFilters, - download_required: bool, -} - -#[derive(Debug, Serialize, Deserialize, SimpleObject, Clone)] -struct ExerciseFilters { - #[graphql(name = "type")] - lot: Vec, - level: Vec, - force: Vec, - mechanic: Vec, - equipment: Vec, - muscle: Vec, -} - -#[derive(Debug, Serialize, Deserialize, SimpleObject, Clone)] -struct UserExerciseDetails { - details: Option, - history: Option>, - collections: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize, InputObject)] -struct UpdateUserWorkoutInput { - id: String, - start_time: Option, - end_time: Option, -} - -#[derive(Clone, Debug, Deserialize, Serialize, InputObject)] -struct UpdateCustomExerciseInput { - old_name: String, - should_delete: Option, - #[graphql(flatten)] - update: exercise::Model, -} - -#[derive(Default)] -pub struct ExerciseQuery; - -impl AuthProvider for ExerciseQuery {} - -#[Object] -impl ExerciseQuery { - /// Get all the parameters related to exercises. - async fn exercise_parameters(&self, gql_ctx: &Context<'_>) -> Result { - let service = gql_ctx.data_unchecked::>(); - service.exercise_parameters().await - } - - /// Get a paginated list of exercises in the database. - async fn exercises_list( - &self, - gql_ctx: &Context<'_>, - input: ExercisesListInput, - ) -> Result> { - let service = gql_ctx.data_unchecked::>(); - let user_id = self.user_id_from_ctx(gql_ctx).await?; - service.exercises_list(user_id, input).await - } - - /// Get a paginated list of workouts done by the user. - async fn user_workouts_list( - &self, - gql_ctx: &Context<'_>, - input: SearchInput, - ) -> Result> { - let service = gql_ctx.data_unchecked::>(); - let user_id = self.user_id_from_ctx(gql_ctx).await?; - service.user_workouts_list(user_id, input).await - } - - /// Get details about an exercise. - async fn exercise_details( - &self, - gql_ctx: &Context<'_>, - exercise_id: String, - ) -> Result { - let service = gql_ctx.data_unchecked::>(); - service.exercise_details(exercise_id).await - } - - /// Get details about a workout. - async fn workout_details( - &self, - gql_ctx: &Context<'_>, - workout_id: String, - ) -> Result { - let service = gql_ctx.data_unchecked::>(); - let user_id = self.user_id_from_ctx(gql_ctx).await?; - service.workout_details(&user_id, workout_id).await - } - - /// Get information about an exercise for a user. - async fn user_exercise_details( - &self, - gql_ctx: &Context<'_>, - exercise_id: String, - ) -> Result { - let service = gql_ctx.data_unchecked::>(); - let user_id = self.user_id_from_ctx(gql_ctx).await?; - service.user_exercise_details(user_id, exercise_id).await - } - - /// Get all the measurements for a user. - async fn user_measurements_list( - &self, - gql_ctx: &Context<'_>, - input: UserMeasurementsListInput, - ) -> Result> { - let service = gql_ctx.data_unchecked::>(); - let user_id = self.user_id_from_ctx(gql_ctx).await?; - service.user_measurements_list(&user_id, input).await - } -} - -#[derive(Default)] -pub struct ExerciseMutation; - -impl AuthProvider for ExerciseMutation { - fn is_mutation(&self) -> bool { - true - } -} - -#[Object] -impl ExerciseMutation { - /// Create a user measurement. - async fn create_user_measurement( - &self, - gql_ctx: &Context<'_>, - input: user_measurement::Model, - ) -> Result { - let service = gql_ctx.data_unchecked::>(); - let user_id = self.user_id_from_ctx(gql_ctx).await?; - service.create_user_measurement(&user_id, input).await - } - - /// Delete a user measurement. - async fn delete_user_measurement( - &self, - gql_ctx: &Context<'_>, - timestamp: DateTimeUtc, - ) -> Result { - let service = gql_ctx.data_unchecked::>(); - let user_id = self.user_id_from_ctx(gql_ctx).await?; - service.delete_user_measurement(user_id, timestamp).await - } - - /// Take a user workout, process it and commit it to database. - async fn create_user_workout( - &self, - gql_ctx: &Context<'_>, - input: UserWorkoutInput, - ) -> Result { - let service = gql_ctx.data_unchecked::>(); - let user_id = self.user_id_from_ctx(gql_ctx).await?; - service.create_user_workout(&user_id, input).await - } - - /// Change the details about a user's workout. - async fn update_user_workout( - &self, - gql_ctx: &Context<'_>, - input: UpdateUserWorkoutInput, - ) -> Result { - let service = gql_ctx.data_unchecked::>(); - let user_id = self.user_id_from_ctx(gql_ctx).await?; - service.update_user_workout(user_id, input).await - } - - /// Delete a workout and remove all exercise associations. - async fn delete_user_workout(&self, gql_ctx: &Context<'_>, workout_id: String) -> Result { - let service = gql_ctx.data_unchecked::>(); - let user_id = self.user_id_from_ctx(gql_ctx).await?; - service.delete_user_workout(user_id, workout_id).await - } - - /// Create a custom exercise. - async fn create_custom_exercise( - &self, - gql_ctx: &Context<'_>, - input: exercise::Model, - ) -> Result { - let service = gql_ctx.data_unchecked::>(); - let user_id = self.user_id_from_ctx(gql_ctx).await?; - service.create_custom_exercise(user_id, input).await - } - - /// Update a custom exercise. - async fn update_custom_exercise( - &self, - gql_ctx: &Context<'_>, - input: UpdateCustomExerciseInput, - ) -> Result { - let service = gql_ctx.data_unchecked::>(); - let user_id = self.user_id_from_ctx(gql_ctx).await?; - service.update_custom_exercise(user_id, input).await - } -} - -pub struct ExerciseService { - db: DatabaseConnection, - config: Arc, - file_storage_service: Arc, - perform_application_job: MemoryStorage, - perform_core_application_job: MemoryStorage, -} - -impl ExerciseService { - pub fn new( - db: &DatabaseConnection, - config: Arc, - file_storage_service: Arc, - perform_application_job: &MemoryStorage, - perform_core_application_job: &MemoryStorage, - ) -> Self { - Self { - config, - db: db.clone(), - file_storage_service, - perform_application_job: perform_application_job.clone(), - perform_core_application_job: perform_core_application_job.clone(), - } - } -} - -impl ExerciseService { - async fn exercise_parameters(&self) -> Result { - let download_required = Exercise::find().count(&self.db).await? == 0; - Ok(ExerciseParameters { - filters: ExerciseFilters { - lot: ExerciseLot::iter().collect_vec(), - level: ExerciseLevel::iter().collect_vec(), - force: ExerciseForce::iter().collect_vec(), - mechanic: ExerciseMechanic::iter().collect_vec(), - equipment: ExerciseEquipment::iter().collect_vec(), - muscle: ExerciseMuscle::iter().collect_vec(), - }, - download_required, - }) - } - - async fn get_all_exercises_from_dataset(&self) -> Result> { - let data = reqwest::get(JSON_URL) - .await - .unwrap() - .json::>() - .await - .unwrap(); - Ok(data - .into_iter() - .map(|e| GithubExercise { - attributes: GithubExerciseAttributes { - images: e - .attributes - .images - .into_iter() - .map(|i| format!("{}/{}", IMAGES_PREFIX_URL, i)) - .collect(), - ..e.attributes - }, - ..e - }) - .collect()) - } - - async fn exercise_details(&self, exercise_id: String) -> Result { - let maybe_exercise = Exercise::find_by_id(exercise_id).one(&self.db).await?; - match maybe_exercise { - None => Err(Error::new("Exercise with the given ID could not be found.")), - Some(e) => Ok(e.graphql_representation(&self.file_storage_service).await?), - } - } - - async fn workout_details( - &self, - user_id: &String, - workout_id: String, - ) -> Result { - workout_details(&self.db, &self.file_storage_service, user_id, workout_id).await - } - - async fn user_exercise_details( - &self, - user_id: String, - exercise_id: String, - ) -> Result { - let collections = entity_in_collections( - &self.db, - &user_id, - None, - None, - None, - Some(exercise_id.clone()), - None, - ) - .await?; - let mut resp = UserExerciseDetails { - details: None, - history: None, - collections, - }; - if let Some(association) = UserToEntity::find() - .filter(user_to_entity::Column::UserId.eq(user_id)) - .filter(user_to_entity::Column::ExerciseId.eq(exercise_id)) - .one(&self.db) - .await? - { - let user_to_exercise_extra_information = association - .exercise_extra_information - .clone() - .unwrap_or_default(); - resp.history = Some(user_to_exercise_extra_information.history); - resp.details = Some(association); - } - Ok(resp) - } - - async fn user_workouts_list( - &self, - user_id: String, - input: SearchInput, - ) -> Result> { - let page = input.page.unwrap_or(1); - let query = Workout::find() - .filter(workout::Column::UserId.eq(user_id)) - .apply_if(input.query, |query, v| { - query.filter(Expr::col(workout::Column::Name).ilike(ilike_sql(&v))) - }) - .order_by_desc(workout::Column::EndTime); - let total = query.clone().count(&self.db).await?; - let total: i32 = total.try_into().unwrap(); - let data = query.paginate(&self.db, self.config.frontend.page_size.try_into().unwrap()); - let items = data.fetch_page((page - 1).try_into().unwrap()).await?; - let next_page = if total - (page * self.config.frontend.page_size) > 0 { - Some(page + 1) - } else { - None - }; - Ok(SearchResults { - details: SearchDetails { total, next_page }, - items, - }) - } - - async fn exercises_list( - &self, - user_id: String, - input: ExercisesListInput, - ) -> Result> { - let ex = Alias::new("exercise"); - let etu = Alias::new("user_to_entity"); - let order_by_col = match input.sort_by { - None => Expr::col((ex, exercise::Column::Id)), - Some(sb) => match sb { - // DEV: This is just a small hack to reduce duplicated code. We - // are ordering by name for the other `sort_by` anyway. - ExerciseSortBy::Name => Expr::val("1"), - ExerciseSortBy::TimesPerformed => Expr::expr(Func::coalesce([ - Expr::col(( - etu.clone(), - user_to_entity::Column::ExerciseNumTimesInteracted, - )) - .into(), - Expr::val(0).into(), - ])), - ExerciseSortBy::LastPerformed => Expr::expr(Func::coalesce([ - Expr::col((etu.clone(), user_to_entity::Column::LastUpdatedOn)).into(), - // DEV: For some reason this does not work without explicit casting on postgres - Func::cast_as(Expr::val("1900-01-01"), Alias::new("timestamptz")).into(), - ])), - }, - }; - let query = Exercise::find() - .column_as( - Expr::col(( - etu.clone(), - user_to_entity::Column::ExerciseNumTimesInteracted, - )), - "num_times_interacted", - ) - .column_as( - Expr::col((etu, user_to_entity::Column::LastUpdatedOn)), - "last_updated_on", - ) - .apply_if(input.filter, |query, q| { - query - .apply_if(q.lot, |q, v| q.filter(exercise::Column::Lot.eq(v))) - .apply_if(q.muscle, |q, v| { - q.filter( - Expr::expr(Func::cast_as( - Expr::col(exercise::Column::Muscles), - Alias::new("text"), - )) - .ilike(ilike_sql(&v.to_string())), - ) - }) - .apply_if(q.level, |q, v| q.filter(exercise::Column::Level.eq(v))) - .apply_if(q.force, |q, v| q.filter(exercise::Column::Force.eq(v))) - .apply_if(q.mechanic, |q, v| { - q.filter(exercise::Column::Mechanic.eq(v)) - }) - .apply_if(q.equipment, |q, v| { - q.filter(exercise::Column::Equipment.eq(v)) - }) - .apply_if(q.collection, |q, v| { - q.left_join(CollectionToEntity) - .filter(collection_to_entity::Column::CollectionId.eq(v)) - }) - }) - .apply_if(input.search.query, |query, v| { - query.filter( - Condition::any() - .add( - Expr::col((AliasedExercise::Table, AliasedExercise::Id)) - .ilike(ilike_sql(&v)), - ) - .add(Expr::col(exercise::Column::Identifier).ilike(slugify(v))), - ) - }) - .join( - JoinType::LeftJoin, - user_to_entity::Relation::Exercise - .def() - .rev() - .on_condition(move |_left, right| { - Condition::all() - .add(Expr::col((right, user_to_entity::Column::UserId)).eq(&user_id)) - }), - ) - .order_by_desc(order_by_col) - .order_by_asc(exercise::Column::Id); - let total = query.clone().count(&self.db).await?; - let total: i32 = total.try_into().unwrap(); - let data = query - .into_model::() - .paginate(&self.db, self.config.frontend.page_size.try_into().unwrap()); - let mut items = vec![]; - for ex in data - .fetch_page((input.search.page.unwrap() - 1).try_into().unwrap()) - .await? - { - let gql_repr = ex - .graphql_representation(&self.file_storage_service) - .await?; - items.push(gql_repr); - } - let next_page = - if total - ((input.search.page.unwrap()) * self.config.frontend.page_size) > 0 { - Some(input.search.page.unwrap() + 1) - } else { - None - }; - Ok(SearchResults { - details: SearchDetails { total, next_page }, - items, - }) - } - - #[tracing::instrument(skip(self))] - pub async fn deploy_update_exercise_library_job(&self) -> Result { - let exercises = self.get_all_exercises_from_dataset().await?; - for exercise in exercises { - self.perform_application_job - .clone() - .enqueue(ApplicationJob::UpdateGithubExerciseJob(exercise)) - .await - .unwrap(); - } - Ok(true) - } - - #[tracing::instrument(skip(self, ex))] - pub async fn update_github_exercise(&self, ex: GithubExercise) -> Result<()> { - let attributes = ExerciseAttributes { - instructions: ex.attributes.instructions, - internal_images: ex - .attributes - .images - .into_iter() - .map(StoredUrl::Url) - .collect(), - images: vec![], - }; - let mut muscles = ex.attributes.primary_muscles; - muscles.extend(ex.attributes.secondary_muscles); - if let Some(e) = Exercise::find() - .filter(exercise::Column::Identifier.eq(&ex.identifier)) - .filter(exercise::Column::Source.eq(ExerciseSource::Github)) - .one(&self.db) - .await? - { - tracing::debug!( - "Updating existing exercise with identifier: {}", - ex.identifier - ); - let mut db_ex: exercise::ActiveModel = e.into(); - db_ex.attributes = ActiveValue::Set(attributes); - db_ex.muscles = ActiveValue::Set(muscles); - db_ex.update(&self.db).await?; - } else { - let lot = match ex.attributes.category { - ExerciseCategory::Cardio => ExerciseLot::DistanceAndDuration, - ExerciseCategory::Stretching | ExerciseCategory::Plyometrics => { - ExerciseLot::Duration - } - ExerciseCategory::Strongman - | ExerciseCategory::OlympicWeightlifting - | ExerciseCategory::Strength - | ExerciseCategory::Powerlifting => ExerciseLot::RepsAndWeight, - }; - let db_exercise = exercise::ActiveModel { - id: ActiveValue::Set(ex.name), - source: ActiveValue::Set(ExerciseSource::Github), - identifier: ActiveValue::Set(Some(ex.identifier)), - muscles: ActiveValue::Set(muscles), - attributes: ActiveValue::Set(attributes), - lot: ActiveValue::Set(lot), - level: ActiveValue::Set(ex.attributes.level), - force: ActiveValue::Set(ex.attributes.force), - equipment: ActiveValue::Set(ex.attributes.equipment), - mechanic: ActiveValue::Set(ex.attributes.mechanic), - created_by_user_id: ActiveValue::Set(None), - }; - let created_exercise = db_exercise.insert(&self.db).await?; - tracing::debug!("Created new exercise with id: {}", created_exercise.id); - } - Ok(()) - } - - async fn user_measurements_list( - &self, - user_id: &String, - input: UserMeasurementsListInput, - ) -> Result> { - user_measurements_list(&self.db, user_id, input).await - } - - pub async fn create_user_measurement( - &self, - user_id: &String, - mut input: user_measurement::Model, - ) -> Result { - input.user_id = user_id.to_owned(); - let um: user_measurement::ActiveModel = input.into(); - let um = um.insert(&self.db).await?; - Ok(um.timestamp) - } - - async fn delete_user_measurement( - &self, - user_id: String, - timestamp: DateTimeUtc, - ) -> Result { - if let Some(m) = UserMeasurement::find_by_id((timestamp, user_id)) - .one(&self.db) - .await? - { - m.delete(&self.db).await?; - Ok(true) - } else { - Ok(false) - } - } - - #[tracing::instrument(skip(self, input))] - pub async fn create_user_workout( - &self, - user_id: &String, - input: UserWorkoutInput, - ) -> Result { - let preferences = user_by_id(&self.db, user_id).await?.preferences; - let identifier = calculate_and_commit( - input, - user_id, - &self.db, - preferences.fitness.exercises.save_history, - ) - .await?; - Ok(identifier) - } - - async fn update_user_workout( - &self, - user_id: String, - input: UpdateUserWorkoutInput, - ) -> Result { - if let Some(wkt) = Workout::find() - .filter(workout::Column::UserId.eq(user_id)) - .filter(workout::Column::Id.eq(input.id)) - .one(&self.db) - .await? - { - let mut new_wkt: workout::ActiveModel = wkt.into(); - if let Some(d) = input.start_time { - new_wkt.start_time = ActiveValue::Set(d); - } - if let Some(d) = input.end_time { - new_wkt.end_time = ActiveValue::Set(d); - } - if new_wkt.is_changed() { - new_wkt.update(&self.db).await?; - Ok(true) - } else { - Ok(false) - } - } else { - Err(Error::new("Workout does not exist for user")) - } - } - - async fn create_custom_exercise( - &self, - user_id: String, - input: exercise::Model, - ) -> Result { - let exercise_id = input.id.clone(); - let mut input = input; - input.created_by_user_id = Some(user_id.clone()); - input.source = ExerciseSource::Custom; - input.attributes.internal_images = input - .attributes - .images - .clone() - .into_iter() - .map(StoredUrl::S3) - .collect(); - input.attributes.images = vec![]; - let input: exercise::ActiveModel = input.into(); - let exercise = match Exercise::find_by_id(exercise_id) - .filter(exercise::Column::Source.eq(ExerciseSource::Custom)) - .one(&self.db) - .await? - { - None => input.insert(&self.db).await?, - Some(_) => { - let input = input.reset_all(); - input.update(&self.db).await? - } - }; - add_entity_to_collection( - &self.db, - &user_id.clone(), - ChangeCollectionToEntityInput { - creator_user_id: user_id, - collection_name: DefaultCollection::Custom.to_string(), - exercise_id: Some(exercise.id.clone()), - ..Default::default() - }, - &self.perform_core_application_job, - ) - .await?; - Ok(exercise.id) - } - - pub async fn delete_user_workout(&self, user_id: String, workout_id: String) -> Result { - if let Some(wkt) = Workout::find_by_id(workout_id) - .filter(workout::Column::UserId.eq(&user_id)) - .one(&self.db) - .await? - { - delete_existing_workout(wkt, &self.db, user_id).await?; - Ok(true) - } else { - Err(Error::new("Workout does not exist for user")) - } - } - - pub async fn re_evaluate_user_workouts(&self, user_id: String) -> Result<()> { - UserToEntity::delete_many() - .filter(user_to_entity::Column::UserId.eq(&user_id)) - .filter(user_to_entity::Column::ExerciseId.is_not_null()) - .exec(&self.db) - .await?; - let workouts = Workout::find() - .filter(workout::Column::UserId.eq(&user_id)) - .order_by_asc(workout::Column::EndTime) - .all(&self.db) - .await?; - let total = workouts.len(); - for (idx, workout) in workouts.into_iter().enumerate() { - workout.clone().delete(&self.db).await?; - let workout_input = self.db_workout_to_workout_input(workout); - self.create_user_workout(&user_id, workout_input).await?; - tracing::debug!("Re-evaluated workout: {}/{}", idx + 1, total); - } - Ok(()) - } - - pub fn db_workout_to_workout_input(&self, user_workout: workout::Model) -> UserWorkoutInput { - UserWorkoutInput { - name: user_workout.name, - id: Some(user_workout.id), - end_time: user_workout.end_time, - start_time: user_workout.start_time, - assets: user_workout.information.assets, - repeated_from: user_workout.repeated_from, - comment: user_workout.information.comment, - exercises: user_workout - .information - .exercises - .into_iter() - .map(|e| UserExerciseInput { - exercise_id: e.name, - sets: e - .sets - .into_iter() - .map(|s| UserWorkoutSetRecord { - lot: s.lot, - note: s.note, - statistic: s.statistic, - confirmed_at: s.confirmed_at, - }) - .collect(), - notes: e.notes, - rest_time: e.rest_time, - assets: e.assets, - superset_with: e.superset_with, - }) - .collect(), - } - } - - async fn update_custom_exercise( - &self, - user_id: String, - input: UpdateCustomExerciseInput, - ) -> Result { - let entities = UserToEntity::find() - .filter(user_to_entity::Column::UserId.eq(&user_id)) - .filter(user_to_entity::Column::ExerciseId.eq(input.old_name.clone())) - .all(&self.db) - .await?; - let old_exercise = Exercise::find_by_id(input.old_name.clone()) - .one(&self.db) - .await? - .unwrap(); - if input.should_delete.unwrap_or_default() { - for entity in entities { - if !entity - .exercise_extra_information - .unwrap_or_default() - .history - .is_empty() - { - return Err(Error::new( - "Exercise is associated with one or more workouts.", - )); - } - } - old_exercise.delete(&self.db).await?; - return Ok(true); - } - if input.old_name != input.update.id { - if Exercise::find_by_id(input.update.id.clone()) - .one(&self.db) - .await? - .is_some() - { - return Err(Error::new("Exercise with the new name already exists.")); - } - Exercise::update_many() - .col_expr(exercise::Column::Id, Expr::value(input.update.id.clone())) - .filter(exercise::Column::Id.eq(input.old_name.clone())) - .exec(&self.db) - .await?; - for entity in entities { - for workout in entity.exercise_extra_information.unwrap().history { - let db_workout = Workout::find_by_id(workout.workout_id) - .one(&self.db) - .await? - .unwrap(); - let mut summary = db_workout.summary.clone(); - let mut information = db_workout.information.clone(); - summary.exercises[workout.idx].id = input.update.id.clone(); - information.exercises[workout.idx].name = input.update.id.clone(); - let mut db_workout: workout::ActiveModel = db_workout.into(); - db_workout.summary = ActiveValue::Set(summary); - db_workout.information = ActiveValue::Set(information); - db_workout.update(&self.db).await?; - } - } - } - for image in old_exercise.attributes.internal_images { - match image { - StoredUrl::S3(key) => { - self.file_storage_service.delete_object(key).await; - } - _ => continue, - } - } - self.create_custom_exercise(user_id, input.update.clone()) - .await?; - Ok(true) - } -} diff --git a/apps/backend/src/importer/generic_json.rs b/apps/backend/src/importer/generic_json.rs index c79b7c7fcd..0f159b8b3e 100644 --- a/apps/backend/src/importer/generic_json.rs +++ b/apps/backend/src/importer/generic_json.rs @@ -4,8 +4,9 @@ use async_graphql::Result; use enums::ImportSource; use itertools::Itertools; use models::{CompleteExport, DeployJsonImportInput}; +use services::ExerciseService; -use crate::{fitness::resolver::ExerciseService, importer::ImportResult}; +use crate::importer::ImportResult; pub async fn import( input: DeployJsonImportInput, diff --git a/apps/backend/src/importer/mod.rs b/apps/backend/src/importer/mod.rs index 5ec8f5f4aa..246cfc890d 100644 --- a/apps/backend/src/importer/mod.rs +++ b/apps/backend/src/importer/mod.rs @@ -15,10 +15,11 @@ use models::{ }; use rust_decimal_macros::dec; use sea_orm::{ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, QueryFilter, QueryOrder}; +use services::ExerciseService; use traits::{AuthProvider, TraceOk}; use utils::user_by_id; -use crate::{fitness::resolver::ExerciseService, miscellaneous::MiscellaneousService}; +use crate::miscellaneous::MiscellaneousService; mod audiobookshelf; mod generic_json; diff --git a/apps/backend/src/main.rs b/apps/backend/src/main.rs index 9712426134..7d65534fc9 100644 --- a/apps/backend/src/main.rs +++ b/apps/backend/src/main.rs @@ -60,7 +60,6 @@ use crate::{ mod app_background; mod app_utils; -mod fitness; mod importer; mod miscellaneous; mod routes; diff --git a/crates/models/dependent/src/lib.rs b/crates/models/dependent/src/lib.rs index adf86d5a84..4148ac2618 100644 --- a/crates/models/dependent/src/lib.rs +++ b/crates/models/dependent/src/lib.rs @@ -1,6 +1,7 @@ -use async_graphql::{OutputType, SimpleObject}; +use async_graphql::{InputObject, OutputType, SimpleObject}; use common_models::SearchDetails; -use database_models::{collection, user_measurement, workout}; +use database_models::{collection, exercise, user_measurement, user_to_entity, workout}; +use fitness_models::UserToExerciseHistoryExtraInformation; use schematic::Schematic; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; @@ -50,3 +51,18 @@ pub struct UserWorkoutDetails { pub details: workout::Model, pub collections: Vec, } + +#[derive(Debug, Serialize, Deserialize, SimpleObject, Clone)] +pub struct UserExerciseDetails { + pub details: Option, + pub history: Option>, + pub collections: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, InputObject)] +pub struct UpdateCustomExerciseInput { + pub old_name: String, + pub should_delete: Option, + #[graphql(flatten)] + pub update: exercise::Model, +} diff --git a/crates/models/fitness/src/lib.rs b/crates/models/fitness/src/lib.rs index 20e8aa8a32..ed8476c994 100644 --- a/crates/models/fitness/src/lib.rs +++ b/crates/models/fitness/src/lib.rs @@ -3,7 +3,7 @@ use std::{collections::HashMap, sync::Arc}; use application_utils::GraphqlRepresentation; use async_graphql::{Enum, InputObject, Result as GraphqlResult, SimpleObject}; use async_trait::async_trait; -use common_models::StoredUrl; +use common_models::{SearchInput, StoredUrl}; use derive_more::{Add, AddAssign, Sum}; use enums::{ ExerciseEquipment, ExerciseForce, ExerciseLevel, ExerciseLot, ExerciseMechanic, ExerciseMuscle, @@ -544,3 +544,55 @@ impl UserWorkoutSetRecord { self.statistic = stats; } } + +#[derive(Debug, Serialize, Deserialize, InputObject, Clone)] +pub struct ExerciseListFilter { + #[graphql(name = "type")] + pub lot: Option, + pub level: Option, + pub force: Option, + pub mechanic: Option, + pub equipment: Option, + pub muscle: Option, + pub collection: Option, +} + +#[derive(Debug, Serialize, Deserialize, Enum, Clone, PartialEq, Eq, Copy, Default)] +pub enum ExerciseSortBy { + Name, + #[default] + LastPerformed, + TimesPerformed, +} + +#[derive(Debug, Serialize, Deserialize, InputObject, Clone)] +pub struct ExercisesListInput { + pub search: SearchInput, + pub filter: Option, + pub sort_by: Option, +} + +#[derive(Debug, Serialize, Deserialize, SimpleObject, Clone)] +pub struct ExerciseParameters { + /// All filters applicable to an exercises query. + pub filters: ExerciseFilters, + pub download_required: bool, +} + +#[derive(Debug, Serialize, Deserialize, SimpleObject, Clone)] +pub struct ExerciseFilters { + #[graphql(name = "type")] + pub lot: Vec, + pub level: Vec, + pub force: Vec, + pub mechanic: Vec, + pub equipment: Vec, + pub muscle: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, InputObject)] +pub struct UpdateUserWorkoutInput { + pub id: String, + pub start_time: Option, + pub end_time: Option, +} diff --git a/crates/resolvers/fitness/Cargo.toml b/crates/resolvers/fitness/Cargo.toml index 3791b04bd8..04cdc58320 100644 --- a/crates/resolvers/fitness/Cargo.toml +++ b/crates/resolvers/fitness/Cargo.toml @@ -4,3 +4,11 @@ version = "0.1.0" edition = "2021" [dependencies] +async-graphql = { workspace = true } +common_models = { path = "../../models/common" } +database_models = { path = "../../models/database" } +dependent_models = { path = "../../models/dependent" } +fitness_service = { path = "../../services/fitness" } +fitness_models = { path = "../../models/fitness" } +sea-orm = { workspace = true } +traits = { path = "../../traits" } diff --git a/crates/resolvers/fitness/src/lib.rs b/crates/resolvers/fitness/src/lib.rs index 8b13789179..f21d652c79 100644 --- a/crates/resolvers/fitness/src/lib.rs +++ b/crates/resolvers/fitness/src/lib.rs @@ -1 +1,179 @@ +use std::sync::Arc; +use async_graphql::{Context, Object, Result}; +use common_models::SearchInput; +use database_models::{exercise, user_measurement, workout}; +use dependent_models::{ + SearchResults, UpdateCustomExerciseInput, UserExerciseDetails, UserWorkoutDetails, +}; +use fitness_models::{ + ExerciseListItem, ExerciseParameters, ExercisesListInput, UpdateUserWorkoutInput, + UserMeasurementsListInput, UserWorkoutInput, +}; +use fitness_service::ExerciseService; +use sea_orm::prelude::DateTimeUtc; +use traits::AuthProvider; + +#[derive(Default)] +pub struct ExerciseQuery; + +impl AuthProvider for ExerciseQuery {} + +#[Object] +impl ExerciseQuery { + /// Get all the parameters related to exercises. + async fn exercise_parameters(&self, gql_ctx: &Context<'_>) -> Result { + let service = gql_ctx.data_unchecked::>(); + service.exercise_parameters().await + } + + /// Get a paginated list of exercises in the database. + async fn exercises_list( + &self, + gql_ctx: &Context<'_>, + input: ExercisesListInput, + ) -> Result> { + let service = gql_ctx.data_unchecked::>(); + let user_id = self.user_id_from_ctx(gql_ctx).await?; + service.exercises_list(user_id, input).await + } + + /// Get a paginated list of workouts done by the user. + async fn user_workouts_list( + &self, + gql_ctx: &Context<'_>, + input: SearchInput, + ) -> Result> { + let service = gql_ctx.data_unchecked::>(); + let user_id = self.user_id_from_ctx(gql_ctx).await?; + service.user_workouts_list(user_id, input).await + } + + /// Get details about an exercise. + async fn exercise_details( + &self, + gql_ctx: &Context<'_>, + exercise_id: String, + ) -> Result { + let service = gql_ctx.data_unchecked::>(); + service.exercise_details(exercise_id).await + } + + /// Get details about a workout. + async fn workout_details( + &self, + gql_ctx: &Context<'_>, + workout_id: String, + ) -> Result { + let service = gql_ctx.data_unchecked::>(); + let user_id = self.user_id_from_ctx(gql_ctx).await?; + service.workout_details(&user_id, workout_id).await + } + + /// Get information about an exercise for a user. + async fn user_exercise_details( + &self, + gql_ctx: &Context<'_>, + exercise_id: String, + ) -> Result { + let service = gql_ctx.data_unchecked::>(); + let user_id = self.user_id_from_ctx(gql_ctx).await?; + service.user_exercise_details(user_id, exercise_id).await + } + + /// Get all the measurements for a user. + async fn user_measurements_list( + &self, + gql_ctx: &Context<'_>, + input: UserMeasurementsListInput, + ) -> Result> { + let service = gql_ctx.data_unchecked::>(); + let user_id = self.user_id_from_ctx(gql_ctx).await?; + service.user_measurements_list(&user_id, input).await + } +} + +#[derive(Default)] +pub struct ExerciseMutation; + +impl AuthProvider for ExerciseMutation { + fn is_mutation(&self) -> bool { + true + } +} + +#[Object] +impl ExerciseMutation { + /// Create a user measurement. + async fn create_user_measurement( + &self, + gql_ctx: &Context<'_>, + input: user_measurement::Model, + ) -> Result { + let service = gql_ctx.data_unchecked::>(); + let user_id = self.user_id_from_ctx(gql_ctx).await?; + service.create_user_measurement(&user_id, input).await + } + + /// Delete a user measurement. + async fn delete_user_measurement( + &self, + gql_ctx: &Context<'_>, + timestamp: DateTimeUtc, + ) -> Result { + let service = gql_ctx.data_unchecked::>(); + let user_id = self.user_id_from_ctx(gql_ctx).await?; + service.delete_user_measurement(user_id, timestamp).await + } + + /// Take a user workout, process it and commit it to database. + async fn create_user_workout( + &self, + gql_ctx: &Context<'_>, + input: UserWorkoutInput, + ) -> Result { + let service = gql_ctx.data_unchecked::>(); + let user_id = self.user_id_from_ctx(gql_ctx).await?; + service.create_user_workout(&user_id, input).await + } + + /// Change the details about a user's workout. + async fn update_user_workout( + &self, + gql_ctx: &Context<'_>, + input: UpdateUserWorkoutInput, + ) -> Result { + let service = gql_ctx.data_unchecked::>(); + let user_id = self.user_id_from_ctx(gql_ctx).await?; + service.update_user_workout(user_id, input).await + } + + /// Delete a workout and remove all exercise associations. + async fn delete_user_workout(&self, gql_ctx: &Context<'_>, workout_id: String) -> Result { + let service = gql_ctx.data_unchecked::>(); + let user_id = self.user_id_from_ctx(gql_ctx).await?; + service.delete_user_workout(user_id, workout_id).await + } + + /// Create a custom exercise. + async fn create_custom_exercise( + &self, + gql_ctx: &Context<'_>, + input: exercise::Model, + ) -> Result { + let service = gql_ctx.data_unchecked::>(); + let user_id = self.user_id_from_ctx(gql_ctx).await?; + service.create_custom_exercise(user_id, input).await + } + + /// Update a custom exercise. + async fn update_custom_exercise( + &self, + gql_ctx: &Context<'_>, + input: UpdateCustomExerciseInput, + ) -> Result { + let service = gql_ctx.data_unchecked::>(); + let user_id = self.user_id_from_ctx(gql_ctx).await?; + service.update_custom_exercise(user_id, input).await + } +} diff --git a/crates/services/fitness/Cargo.toml b/crates/services/fitness/Cargo.toml index f52f05b3eb..eeebccc554 100644 --- a/crates/services/fitness/Cargo.toml +++ b/crates/services/fitness/Cargo.toml @@ -4,3 +4,28 @@ version = "0.1.0" edition = "2021" [dependencies] +anyhow = { workspace = true } +apalis = { workspace = true } +application_utils = { path = "../../utils/application" } +async-graphql = { workspace = true } +background = { path = "../../background" } +common_models = { path = "../../models/common" } +common_utils = { path = "../../utils/common" } +config = { path = "../../config" } +const-str = { workspace = true } +database_models = { path = "../../models/database" } +database_utils = { path = "../../utils/database" } +dependent_models = { path = "../../models/dependent" } +enums = { path = "../../enums" } +file_storage_service = { path = "../../services/file_storage" } +fitness_models = { path = "../../models/fitness" } +itertools = { workspace = true } +migrations = { path = "../../migrations" } +nanoid = { workspace = true } +reqwest = { workspace = true } +rust_decimal = { workspace = true } +rust_decimal_macros = { workspace = true } +sea-orm = { workspace = true } +sea-query = { workspace = true } +slug = { workspace = true } +tracing = { workspace = true } diff --git a/crates/services/fitness/src/lib.rs b/crates/services/fitness/src/lib.rs index 8b13789179..302fc41065 100644 --- a/crates/services/fitness/src/lib.rs +++ b/crates/services/fitness/src/lib.rs @@ -1 +1,646 @@ +use std::sync::Arc; +use apalis::prelude::{MemoryStorage, MessageQueue}; +use application_utils::GraphqlRepresentation; +use async_graphql::{Error, Result}; +use background::{ApplicationJob, CoreApplicationJob}; +use common_models::{ + ChangeCollectionToEntityInput, DefaultCollection, SearchDetails, SearchInput, StoredUrl, +}; +use database_models::{ + collection_to_entity, exercise, + prelude::{CollectionToEntity, Exercise, UserMeasurement, UserToEntity, Workout}, + user_measurement, user_to_entity, workout, +}; +use database_utils::{ + add_entity_to_collection, entity_in_collections, ilike_sql, user_by_id, user_measurements_list, + workout_details, +}; +use dependent_models::{ + SearchResults, UpdateCustomExerciseInput, UserExerciseDetails, UserWorkoutDetails, +}; +use enums::{ + ExerciseEquipment, ExerciseForce, ExerciseLevel, ExerciseLot, ExerciseMechanic, ExerciseMuscle, + ExerciseSource, +}; +use file_storage_service::FileStorageService; +use fitness_models::{ + ExerciseAttributes, ExerciseCategory, ExerciseFilters, ExerciseListItem, ExerciseParameters, + ExerciseSortBy, ExercisesListInput, GithubExercise, GithubExerciseAttributes, + UpdateUserWorkoutInput, UserExerciseInput, UserMeasurementsListInput, UserWorkoutInput, + UserWorkoutSetRecord, +}; +use itertools::Itertools; +use migrations::AliasedExercise; +use sea_orm::{ + prelude::DateTimeUtc, ActiveModelTrait, ActiveValue, ColumnTrait, DatabaseConnection, + EntityTrait, Iterable, ModelTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, + QueryTrait, RelationTrait, +}; +use sea_query::{extension::postgres::PgExpr, Alias, Condition, Expr, Func, JoinType}; +use slug::slugify; + +use logic::{calculate_and_commit, delete_existing_workout}; + +mod logic; + +const EXERCISE_DB_URL: &str = "https://raw.githubusercontent.com/yuhonas/free-exercise-db/main"; +const JSON_URL: &str = const_str::concat!(EXERCISE_DB_URL, "/dist/exercises.json"); +const IMAGES_PREFIX_URL: &str = const_str::concat!(EXERCISE_DB_URL, "/exercises"); + +pub struct ExerciseService { + db: DatabaseConnection, + config: Arc, + file_storage_service: Arc, + perform_application_job: MemoryStorage, + perform_core_application_job: MemoryStorage, +} + +impl ExerciseService { + pub fn new( + db: &DatabaseConnection, + config: Arc, + file_storage_service: Arc, + perform_application_job: &MemoryStorage, + perform_core_application_job: &MemoryStorage, + ) -> Self { + Self { + config, + db: db.clone(), + file_storage_service, + perform_application_job: perform_application_job.clone(), + perform_core_application_job: perform_core_application_job.clone(), + } + } +} + +impl ExerciseService { + pub async fn exercise_parameters(&self) -> Result { + let download_required = Exercise::find().count(&self.db).await? == 0; + Ok(ExerciseParameters { + filters: ExerciseFilters { + lot: ExerciseLot::iter().collect_vec(), + level: ExerciseLevel::iter().collect_vec(), + force: ExerciseForce::iter().collect_vec(), + mechanic: ExerciseMechanic::iter().collect_vec(), + equipment: ExerciseEquipment::iter().collect_vec(), + muscle: ExerciseMuscle::iter().collect_vec(), + }, + download_required, + }) + } + + async fn get_all_exercises_from_dataset(&self) -> Result> { + let data = reqwest::get(JSON_URL) + .await + .unwrap() + .json::>() + .await + .unwrap(); + Ok(data + .into_iter() + .map(|e| GithubExercise { + attributes: GithubExerciseAttributes { + images: e + .attributes + .images + .into_iter() + .map(|i| format!("{}/{}", IMAGES_PREFIX_URL, i)) + .collect(), + ..e.attributes + }, + ..e + }) + .collect()) + } + + pub async fn exercise_details(&self, exercise_id: String) -> Result { + let maybe_exercise = Exercise::find_by_id(exercise_id).one(&self.db).await?; + match maybe_exercise { + None => Err(Error::new("Exercise with the given ID could not be found.")), + Some(e) => Ok(e.graphql_representation(&self.file_storage_service).await?), + } + } + + pub async fn workout_details( + &self, + user_id: &String, + workout_id: String, + ) -> Result { + workout_details(&self.db, &self.file_storage_service, user_id, workout_id).await + } + + pub async fn user_exercise_details( + &self, + user_id: String, + exercise_id: String, + ) -> Result { + let collections = entity_in_collections( + &self.db, + &user_id, + None, + None, + None, + Some(exercise_id.clone()), + None, + ) + .await?; + let mut resp = UserExerciseDetails { + details: None, + history: None, + collections, + }; + if let Some(association) = UserToEntity::find() + .filter(user_to_entity::Column::UserId.eq(user_id)) + .filter(user_to_entity::Column::ExerciseId.eq(exercise_id)) + .one(&self.db) + .await? + { + let user_to_exercise_extra_information = association + .exercise_extra_information + .clone() + .unwrap_or_default(); + resp.history = Some(user_to_exercise_extra_information.history); + resp.details = Some(association); + } + Ok(resp) + } + + pub async fn user_workouts_list( + &self, + user_id: String, + input: SearchInput, + ) -> Result> { + let page = input.page.unwrap_or(1); + let query = Workout::find() + .filter(workout::Column::UserId.eq(user_id)) + .apply_if(input.query, |query, v| { + query.filter(Expr::col(workout::Column::Name).ilike(ilike_sql(&v))) + }) + .order_by_desc(workout::Column::EndTime); + let total = query.clone().count(&self.db).await?; + let total: i32 = total.try_into().unwrap(); + let data = query.paginate(&self.db, self.config.frontend.page_size.try_into().unwrap()); + let items = data.fetch_page((page - 1).try_into().unwrap()).await?; + let next_page = if total - (page * self.config.frontend.page_size) > 0 { + Some(page + 1) + } else { + None + }; + Ok(SearchResults { + details: SearchDetails { total, next_page }, + items, + }) + } + + pub async fn exercises_list( + &self, + user_id: String, + input: ExercisesListInput, + ) -> Result> { + let ex = Alias::new("exercise"); + let etu = Alias::new("user_to_entity"); + let order_by_col = match input.sort_by { + None => Expr::col((ex, exercise::Column::Id)), + Some(sb) => match sb { + // DEV: This is just a small hack to reduce duplicated code. We + // are ordering by name for the other `sort_by` anyway. + ExerciseSortBy::Name => Expr::val("1"), + ExerciseSortBy::TimesPerformed => Expr::expr(Func::coalesce([ + Expr::col(( + etu.clone(), + user_to_entity::Column::ExerciseNumTimesInteracted, + )) + .into(), + Expr::val(0).into(), + ])), + ExerciseSortBy::LastPerformed => Expr::expr(Func::coalesce([ + Expr::col((etu.clone(), user_to_entity::Column::LastUpdatedOn)).into(), + // DEV: For some reason this does not work without explicit casting on postgres + Func::cast_as(Expr::val("1900-01-01"), Alias::new("timestamptz")).into(), + ])), + }, + }; + let query = Exercise::find() + .column_as( + Expr::col(( + etu.clone(), + user_to_entity::Column::ExerciseNumTimesInteracted, + )), + "num_times_interacted", + ) + .column_as( + Expr::col((etu, user_to_entity::Column::LastUpdatedOn)), + "last_updated_on", + ) + .apply_if(input.filter, |query, q| { + query + .apply_if(q.lot, |q, v| q.filter(exercise::Column::Lot.eq(v))) + .apply_if(q.muscle, |q, v| { + q.filter( + Expr::expr(Func::cast_as( + Expr::col(exercise::Column::Muscles), + Alias::new("text"), + )) + .ilike(ilike_sql(&v.to_string())), + ) + }) + .apply_if(q.level, |q, v| q.filter(exercise::Column::Level.eq(v))) + .apply_if(q.force, |q, v| q.filter(exercise::Column::Force.eq(v))) + .apply_if(q.mechanic, |q, v| { + q.filter(exercise::Column::Mechanic.eq(v)) + }) + .apply_if(q.equipment, |q, v| { + q.filter(exercise::Column::Equipment.eq(v)) + }) + .apply_if(q.collection, |q, v| { + q.left_join(CollectionToEntity) + .filter(collection_to_entity::Column::CollectionId.eq(v)) + }) + }) + .apply_if(input.search.query, |query, v| { + query.filter( + Condition::any() + .add( + Expr::col((AliasedExercise::Table, AliasedExercise::Id)) + .ilike(ilike_sql(&v)), + ) + .add(Expr::col(exercise::Column::Identifier).ilike(slugify(v))), + ) + }) + .join( + JoinType::LeftJoin, + user_to_entity::Relation::Exercise + .def() + .rev() + .on_condition(move |_left, right| { + Condition::all() + .add(Expr::col((right, user_to_entity::Column::UserId)).eq(&user_id)) + }), + ) + .order_by_desc(order_by_col) + .order_by_asc(exercise::Column::Id); + let total = query.clone().count(&self.db).await?; + let total: i32 = total.try_into().unwrap(); + let data = query + .into_model::() + .paginate(&self.db, self.config.frontend.page_size.try_into().unwrap()); + let mut items = vec![]; + for ex in data + .fetch_page((input.search.page.unwrap() - 1).try_into().unwrap()) + .await? + { + let gql_repr = ex + .graphql_representation(&self.file_storage_service) + .await?; + items.push(gql_repr); + } + let next_page = + if total - ((input.search.page.unwrap()) * self.config.frontend.page_size) > 0 { + Some(input.search.page.unwrap() + 1) + } else { + None + }; + Ok(SearchResults { + details: SearchDetails { total, next_page }, + items, + }) + } + + #[tracing::instrument(skip(self))] + pub async fn deploy_update_exercise_library_job(&self) -> Result { + let exercises = self.get_all_exercises_from_dataset().await?; + for exercise in exercises { + self.perform_application_job + .clone() + .enqueue(ApplicationJob::UpdateGithubExerciseJob(exercise)) + .await + .unwrap(); + } + Ok(true) + } + + #[tracing::instrument(skip(self, ex))] + pub async fn update_github_exercise(&self, ex: GithubExercise) -> Result<()> { + let attributes = ExerciseAttributes { + instructions: ex.attributes.instructions, + internal_images: ex + .attributes + .images + .into_iter() + .map(StoredUrl::Url) + .collect(), + images: vec![], + }; + let mut muscles = ex.attributes.primary_muscles; + muscles.extend(ex.attributes.secondary_muscles); + if let Some(e) = Exercise::find() + .filter(exercise::Column::Identifier.eq(&ex.identifier)) + .filter(exercise::Column::Source.eq(ExerciseSource::Github)) + .one(&self.db) + .await? + { + tracing::debug!( + "Updating existing exercise with identifier: {}", + ex.identifier + ); + let mut db_ex: exercise::ActiveModel = e.into(); + db_ex.attributes = ActiveValue::Set(attributes); + db_ex.muscles = ActiveValue::Set(muscles); + db_ex.update(&self.db).await?; + } else { + let lot = match ex.attributes.category { + ExerciseCategory::Cardio => ExerciseLot::DistanceAndDuration, + ExerciseCategory::Stretching | ExerciseCategory::Plyometrics => { + ExerciseLot::Duration + } + ExerciseCategory::Strongman + | ExerciseCategory::OlympicWeightlifting + | ExerciseCategory::Strength + | ExerciseCategory::Powerlifting => ExerciseLot::RepsAndWeight, + }; + let db_exercise = exercise::ActiveModel { + id: ActiveValue::Set(ex.name), + source: ActiveValue::Set(ExerciseSource::Github), + identifier: ActiveValue::Set(Some(ex.identifier)), + muscles: ActiveValue::Set(muscles), + attributes: ActiveValue::Set(attributes), + lot: ActiveValue::Set(lot), + level: ActiveValue::Set(ex.attributes.level), + force: ActiveValue::Set(ex.attributes.force), + equipment: ActiveValue::Set(ex.attributes.equipment), + mechanic: ActiveValue::Set(ex.attributes.mechanic), + created_by_user_id: ActiveValue::Set(None), + }; + let created_exercise = db_exercise.insert(&self.db).await?; + tracing::debug!("Created new exercise with id: {}", created_exercise.id); + } + Ok(()) + } + + pub async fn user_measurements_list( + &self, + user_id: &String, + input: UserMeasurementsListInput, + ) -> Result> { + user_measurements_list(&self.db, user_id, input).await + } + + pub async fn create_user_measurement( + &self, + user_id: &String, + mut input: user_measurement::Model, + ) -> Result { + input.user_id = user_id.to_owned(); + let um: user_measurement::ActiveModel = input.into(); + let um = um.insert(&self.db).await?; + Ok(um.timestamp) + } + + pub async fn delete_user_measurement( + &self, + user_id: String, + timestamp: DateTimeUtc, + ) -> Result { + if let Some(m) = UserMeasurement::find_by_id((timestamp, user_id)) + .one(&self.db) + .await? + { + m.delete(&self.db).await?; + Ok(true) + } else { + Ok(false) + } + } + + #[tracing::instrument(skip(self, input))] + pub async fn create_user_workout( + &self, + user_id: &String, + input: UserWorkoutInput, + ) -> Result { + let preferences = user_by_id(&self.db, user_id).await?.preferences; + let identifier = calculate_and_commit( + input, + user_id, + &self.db, + preferences.fitness.exercises.save_history, + ) + .await?; + Ok(identifier) + } + + pub async fn update_user_workout( + &self, + user_id: String, + input: UpdateUserWorkoutInput, + ) -> Result { + if let Some(wkt) = Workout::find() + .filter(workout::Column::UserId.eq(user_id)) + .filter(workout::Column::Id.eq(input.id)) + .one(&self.db) + .await? + { + let mut new_wkt: workout::ActiveModel = wkt.into(); + if let Some(d) = input.start_time { + new_wkt.start_time = ActiveValue::Set(d); + } + if let Some(d) = input.end_time { + new_wkt.end_time = ActiveValue::Set(d); + } + if new_wkt.is_changed() { + new_wkt.update(&self.db).await?; + Ok(true) + } else { + Ok(false) + } + } else { + Err(Error::new("Workout does not exist for user")) + } + } + + pub async fn create_custom_exercise( + &self, + user_id: String, + input: exercise::Model, + ) -> Result { + let exercise_id = input.id.clone(); + let mut input = input; + input.created_by_user_id = Some(user_id.clone()); + input.source = ExerciseSource::Custom; + input.attributes.internal_images = input + .attributes + .images + .clone() + .into_iter() + .map(StoredUrl::S3) + .collect(); + input.attributes.images = vec![]; + let input: exercise::ActiveModel = input.into(); + let exercise = match Exercise::find_by_id(exercise_id) + .filter(exercise::Column::Source.eq(ExerciseSource::Custom)) + .one(&self.db) + .await? + { + None => input.insert(&self.db).await?, + Some(_) => { + let input = input.reset_all(); + input.update(&self.db).await? + } + }; + add_entity_to_collection( + &self.db, + &user_id.clone(), + ChangeCollectionToEntityInput { + creator_user_id: user_id, + collection_name: DefaultCollection::Custom.to_string(), + exercise_id: Some(exercise.id.clone()), + ..Default::default() + }, + &self.perform_core_application_job, + ) + .await?; + Ok(exercise.id) + } + + pub async fn delete_user_workout(&self, user_id: String, workout_id: String) -> Result { + if let Some(wkt) = Workout::find_by_id(workout_id) + .filter(workout::Column::UserId.eq(&user_id)) + .one(&self.db) + .await? + { + delete_existing_workout(wkt, &self.db, user_id).await?; + Ok(true) + } else { + Err(Error::new("Workout does not exist for user")) + } + } + + pub async fn re_evaluate_user_workouts(&self, user_id: String) -> Result<()> { + UserToEntity::delete_many() + .filter(user_to_entity::Column::UserId.eq(&user_id)) + .filter(user_to_entity::Column::ExerciseId.is_not_null()) + .exec(&self.db) + .await?; + let workouts = Workout::find() + .filter(workout::Column::UserId.eq(&user_id)) + .order_by_asc(workout::Column::EndTime) + .all(&self.db) + .await?; + let total = workouts.len(); + for (idx, workout) in workouts.into_iter().enumerate() { + workout.clone().delete(&self.db).await?; + let workout_input = self.db_workout_to_workout_input(workout); + self.create_user_workout(&user_id, workout_input).await?; + tracing::debug!("Re-evaluated workout: {}/{}", idx + 1, total); + } + Ok(()) + } + + pub fn db_workout_to_workout_input(&self, user_workout: workout::Model) -> UserWorkoutInput { + UserWorkoutInput { + name: user_workout.name, + id: Some(user_workout.id), + end_time: user_workout.end_time, + start_time: user_workout.start_time, + assets: user_workout.information.assets, + repeated_from: user_workout.repeated_from, + comment: user_workout.information.comment, + exercises: user_workout + .information + .exercises + .into_iter() + .map(|e| UserExerciseInput { + exercise_id: e.name, + sets: e + .sets + .into_iter() + .map(|s| UserWorkoutSetRecord { + lot: s.lot, + note: s.note, + statistic: s.statistic, + confirmed_at: s.confirmed_at, + }) + .collect(), + notes: e.notes, + rest_time: e.rest_time, + assets: e.assets, + superset_with: e.superset_with, + }) + .collect(), + } + } + + pub async fn update_custom_exercise( + &self, + user_id: String, + input: UpdateCustomExerciseInput, + ) -> Result { + let entities = UserToEntity::find() + .filter(user_to_entity::Column::UserId.eq(&user_id)) + .filter(user_to_entity::Column::ExerciseId.eq(input.old_name.clone())) + .all(&self.db) + .await?; + let old_exercise = Exercise::find_by_id(input.old_name.clone()) + .one(&self.db) + .await? + .unwrap(); + if input.should_delete.unwrap_or_default() { + for entity in entities { + if !entity + .exercise_extra_information + .unwrap_or_default() + .history + .is_empty() + { + return Err(Error::new( + "Exercise is associated with one or more workouts.", + )); + } + } + old_exercise.delete(&self.db).await?; + return Ok(true); + } + if input.old_name != input.update.id { + if Exercise::find_by_id(input.update.id.clone()) + .one(&self.db) + .await? + .is_some() + { + return Err(Error::new("Exercise with the new name already exists.")); + } + Exercise::update_many() + .col_expr(exercise::Column::Id, Expr::value(input.update.id.clone())) + .filter(exercise::Column::Id.eq(input.old_name.clone())) + .exec(&self.db) + .await?; + for entity in entities { + for workout in entity.exercise_extra_information.unwrap().history { + let db_workout = Workout::find_by_id(workout.workout_id) + .one(&self.db) + .await? + .unwrap(); + let mut summary = db_workout.summary.clone(); + let mut information = db_workout.information.clone(); + summary.exercises[workout.idx].id = input.update.id.clone(); + information.exercises[workout.idx].name = input.update.id.clone(); + let mut db_workout: workout::ActiveModel = db_workout.into(); + db_workout.summary = ActiveValue::Set(summary); + db_workout.information = ActiveValue::Set(information); + db_workout.update(&self.db).await?; + } + } + } + for image in old_exercise.attributes.internal_images { + match image { + StoredUrl::S3(key) => { + self.file_storage_service.delete_object(key).await; + } + _ => continue, + } + } + self.create_custom_exercise(user_id, input.update.clone()) + .await?; + Ok(true) + } +} diff --git a/apps/backend/src/fitness/logic.rs b/crates/services/fitness/src/logic.rs similarity index 96% rename from apps/backend/src/fitness/logic.rs rename to crates/services/fitness/src/logic.rs index d8d5d78e0f..969351c82b 100644 --- a/apps/backend/src/fitness/logic.rs +++ b/crates/services/fitness/src/logic.rs @@ -1,12 +1,15 @@ use anyhow::{bail, Result}; -use enums::ExerciseLot; -use models::{ +use common_utils::LengthVec; +use database_models::{ prelude::{Exercise, UserToEntity, Workout}, - user_to_entity, workout, ExerciseBestSetRecord, ProcessedExercise, - UserToExerciseBestSetExtraInformation, UserToExerciseExtraInformation, - UserToExerciseHistoryExtraInformation, UserWorkoutInput, WorkoutInformation, - WorkoutOrExerciseTotals, WorkoutSetPersonalBest, WorkoutSetRecord, WorkoutSetTotals, - WorkoutSummary, WorkoutSummaryExercise, + user_to_entity, workout, +}; +use enums::ExerciseLot; +use fitness_models::{ + ExerciseBestSetRecord, ProcessedExercise, UserToExerciseBestSetExtraInformation, + UserToExerciseExtraInformation, UserToExerciseHistoryExtraInformation, UserWorkoutInput, + WorkoutInformation, WorkoutOrExerciseTotals, WorkoutSetPersonalBest, WorkoutSetRecord, + WorkoutSetTotals, WorkoutSummary, WorkoutSummaryExercise, }; use nanoid::nanoid; use rust_decimal_macros::dec; @@ -14,7 +17,6 @@ use sea_orm::{ ActiveModelTrait, ActiveValue, ColumnTrait, DatabaseConnection, EntityTrait, ModelTrait, QueryFilter, }; -use utils::LengthVec; fn get_best_set_index(records: &[WorkoutSetRecord]) -> Option { records