Repository: IgnisDa/ryot (language: rust)

Issue:
Updating exercise fails because of key value
The exercises my trainer wants me to do aren't in the database, so I'm trying to add them. I just pasted a bunch of info in to get them in there and now I'm trying to go back in and edit one or two to make them look nicer. I get this error:
`Query Error: error returned from database: duplicate key value violates unique constraint "exercise_pkey"`
When I look in the logs, I get this:
```
2024-12-19 18:59:55.090 UTC [33] ERROR:  duplicate key value violates unique constraint "exercise_pkey"
2024-12-19 18:59:55.090 UTC [33] DETAIL:  Key (id)=(Dumbbell Glute Bridge Floor_reps_and_weight_usr_LWEuKIab_E4T) already exists.
2024-12-19 18:59:55.090 UTC [33] STATEMENT:  INSERT INTO "exercise" ("id", "name", "lot", "level", "source", "force", "muscles", "attributes", "created_by_user_id", "mechanic", "equipment") VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING "id", "name", "lot", "level", "source", "force", "muscles", "attributes", "created_by_user_id", "mechanic", "equipment"
```
Could you change the logic in this to use update instead of insert?:
`UPDATE "exercise" set $column = $value, $column = $value WHERE "id" = $ID`
I think it will end up looking something like that.


Discussion / hints:

Fix patch:
diff --git a/apps/frontend/app/routes/_dashboard.fitness.exercises.$action.tsx b/apps/frontend/app/routes/_dashboard.fitness.exercises.$action.tsx
index 86970b4b0d..07c1194d88 100644
--- a/apps/frontend/app/routes/_dashboard.fitness.exercises.$action.tsx
+++ b/apps/frontend/app/routes/_dashboard.fitness.exercises.$action.tsx
@@ -53,7 +53,7 @@ import {
 } from "~/lib/utilities.server";
 
 const searchParamsSchema = z.object({
-	name: z.string().optional(),
+	id: z.string().optional(),
 });
 
 enum Action {
@@ -67,11 +67,11 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
 	const details = await match(action)
 		.with(Action.Create, () => undefined)
 		.with(Action.Update, async () => {
-			invariant(query.name);
+			invariant(query.id);
 			const { exerciseDetails } = await serverGqlService.authenticatedRequest(
 				request,
 				ExerciseDetailsDocument,
-				{ exerciseId: query.name },
+				{ exerciseId: query.id },
 			);
 			return exerciseDetails;
 		})
@@ -110,6 +110,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
 		const intent = getActionIntent(request);
 		return await match(intent)
 			.with(Action.Create, async () => {
+				input.id = "dummy";
 				const { createCustomExercise } =
 					await serverGqlService.authenticatedRequest(
 						request,
@@ -119,13 +120,17 @@ export const action = async ({ request }: ActionFunctionArgs) => {
 				return redirect(getExerciseDetailsPath(createCustomExercise));
 			})
 			.with(Action.Update, async () => {
-				invariant(submission.oldId);
+				const id = submission.id;
+				invariant(id);
 				await serverGqlService.authenticatedRequest(
 					request,
 					UpdateCustomExerciseDocument,
-					{ input: { ...input, oldId: submission.oldId } },
+					{ input },
 				);
-				return redirect($path("/fitness/exercises/list"));
+				const redirectUrl = submission.shouldDelete
+					? $path("/fitness/exercises/list")
+					: getExerciseDetailsPath(id);
+				return redirect(redirectUrl);
 			})
 			.run();
 	} catch (e) {
@@ -147,17 +152,17 @@ const optionalString = z.string().optional();
 const optionalStringArray = z.array(z.string()).optional();
 
 const schema = z.object({
-	oldId: optionalString,
 	name: z.string(),
+	id: optionalString,
+	muscles: optionalString,
+	images: optionalStringArray,
+	instructions: optionalString,
 	lot: z.nativeEnum(ExerciseLot),
+	shouldDelete: zx.BoolAsString.optional(),
 	level: z.nativeEnum(ExerciseLevel),
 	force: z.nativeEnum(ExerciseForce).optional(),
 	mechanic: z.nativeEnum(ExerciseMechanic).optional(),
 	equipment: z.nativeEnum(ExerciseEquipment).optional(),
-	muscles: optionalString,
-	instructions: optionalString,
-	images: optionalStringArray,
-	shouldDelete: zx.BoolAsString.optional(),
 });
 
 export default function Page() {
@@ -169,7 +174,6 @@ export default function Page() {
 	return (
 		<Container>
 			<Form
-				replace
 				method="POST"
 				encType="multipart/form-data"
 				action={withQuery(".", { intent: loaderData.action })}
@@ -178,68 +182,69 @@ export default function Page() {
 					<Title>{title} Exercise</Title>
 					{loaderData.details?.id ? (
 						<input
+							name="id"
 							type="hidden"
-							name="oldId"
 							defaultValue={loaderData.details.id}
 						/>
 					) : null}
 					<TextInput
-						label="Name"
 						required
 						autoFocus
 						name="name"
+						label="Name"
 						defaultValue={loaderData.details?.name}
 					/>
 					<Select
-						label="Type"
-						data={Object.values(ExerciseLot)}
 						required
 						name="lot"
+						label="Type"
+						data={Object.values(ExerciseLot)}
 						defaultValue={loaderData.details?.lot}
+						readOnly={loaderData.action === Action.Update}
 					/>
 					<Group wrap="nowrap">
 						<Select
-							label="Level"
-							data={Object.values(ExerciseLevel)}
 							required
 							name="level"
+							label="Level"
 							w={{ base: "100%", md: "50%" }}
+							data={Object.values(ExerciseLevel)}
 							defaultValue={loaderData.details?.level}
 						/>
 						<Select
-							label="Force"
-							data={Object.values(ExerciseForce)}
 							name="force"
+							label="Force"
 							w={{ base: "100%", md: "50%" }}
+							data={Object.values(ExerciseForce)}
 							defaultValue={loaderData.details?.force}
 						/>
 					</Group>
 					<Group wrap="nowrap">
 						<Select
-							label="Equipment"
-							data={Object.values(ExerciseEquipment)}
 							name="equipment"
+							label="Equipment"
 							w={{ base: "100%", md: "50%" }}
+							data={Object.values(ExerciseEquipment)}
 							defaultValue={loaderData.details?.equipment}
 						/>
 						<Select
-							label="Mechanic"
-							data={Object.values(ExerciseMechanic)}
 							name="mechanic"
+							label="Mechanic"
 							w={{ base: "100%", md: "50%" }}
+							data={Object.values(ExerciseMechanic)}
 							defaultValue={loaderData.details?.mechanic}
 						/>
 					</Group>
 					<MultiSelect
+						name="muscles"
 						label="Muscles"
 						data={Object.values(ExerciseMuscle)}
-						name="muscles"
 						defaultValue={loaderData.details?.muscles}
 					/>
 					<Textarea
+						name="instructions"
 						label="Instructions"
 						description="Separate each instruction with a newline"
-						name="instructions"
 						defaultValue={loaderData.details?.attributes.instructions.join(
 							"\n",
 						)}
@@ -247,24 +252,24 @@ export default function Page() {
 					/>
 					{!fileUploadNotAllowed ? (
 						<FileInput
-							label="Images"
-							name="images"
 							multiple
+							name="images"
+							label="Images"
+							leftSection={<IconPhoto />}
+							accept="image/png,image/jpeg,image/jpg"
 							description={
 								loaderData.details &&
 								"Please re-upload the images while updating the exercise, old ones will be deleted"
 							}
-							accept="image/png,image/jpeg,image/jpg"
-							leftSection={<IconPhoto />}
 						/>
 					) : null}
 					<Group w="100%" grow>
 						{loaderData.details ? (
 							<Button
 								color="red"
+								value="true"
 								type="submit"
 								name="shouldDelete"
-								value="true"
 							>
 								Delete
 							</Button>
diff --git a/apps/frontend/app/routes/_dashboard.fitness.exercises.item.$id._index.tsx b/apps/frontend/app/routes/_dashboard.fitness.exercises.item.$id._index.tsx
index 5865853ed1..a9d9776e18 100644
--- a/apps/frontend/app/routes/_dashboard.fitness.exercises.item.$id._index.tsx
+++ b/apps/frontend/app/routes/_dashboard.fitness.exercises.item.$id._index.tsx
@@ -607,7 +607,7 @@ export default function Page() {
 											to={$path(
 												"/fitness/exercises/:action",
 												{ action: "update" },
-												{ name: loaderData.exerciseDetails.id },
+												{ id: loaderData.exerciseDetails.id },
 											)}
 										>
 											Edit exercise
diff --git a/crates/models/database/src/exercise.rs b/crates/models/database/src/exercise.rs
index a91ca6f690..51133cb29d 100644
--- a/crates/models/database/src/exercise.rs
+++ b/crates/models/database/src/exercise.rs
@@ -28,7 +28,6 @@ use serde::{Deserialize, Serialize};
 #[sea_orm(table_name = "exercise")]
 #[graphql(name = "Exercise", input_name = "ExerciseInput")]
 pub struct Model {
-    #[graphql(skip_input)]
     #[sea_orm(primary_key, auto_increment = false)]
     pub id: String,
     pub name: String,
diff --git a/crates/models/dependent/src/lib.rs b/crates/models/dependent/src/lib.rs
index d1a26a1d73..e70918ad81 100644
--- a/crates/models/dependent/src/lib.rs
+++ b/crates/models/dependent/src/lib.rs
@@ -110,10 +110,9 @@ pub struct UserExerciseDetails {
 
 #[derive(Clone, Debug, Deserialize, Serialize, InputObject)]
 pub struct UpdateCustomExerciseInput {
-    pub old_id: String,
-    pub should_delete: Option<bool>,
     #[graphql(flatten)]
     pub update: exercise::Model,
+    pub should_delete: Option<bool>,
 }
 
 #[derive(Union)]
diff --git a/crates/models/fitness/src/lib.rs b/crates/models/fitness/src/lib.rs
index 614c95c440..4be01d0f01 100644
--- a/crates/models/fitness/src/lib.rs
+++ b/crates/models/fitness/src/lib.rs
@@ -252,7 +252,6 @@ pub struct WorkoutSetRecord {
     pub rpe: Option<u8>,
     pub note: Option<String>,
     pub rest_time: Option<u16>,
-    pub actual_rest_time: Option<i64>,
     pub statistic: WorkoutSetStatistic,
     pub totals: Option<WorkoutSetTotals>,
     pub confirmed_at: Option<DateTimeUtc>,
diff --git a/crates/services/fitness/src/lib.rs b/crates/services/fitness/src/lib.rs
index 4fcdf32869..d643382a95 100644
--- a/crates/services/fitness/src/lib.rs
+++ b/crates/services/fitness/src/lib.rs
@@ -129,7 +129,6 @@ impl FitnessService {
                         totals: None,
                         confirmed_at: None,
                         personal_bests: None,
-                        actual_rest_time: None,
                         rest_time: s.rest_time,
                         statistic: s.statistic,
                     })
@@ -627,12 +626,15 @@ impl FitnessService {
         Ok(())
     }
 
-    async fn change_exercise_name_in_history(
+    async fn change_exercise_id_in_history(
         &self,
         new_name: String,
         old_entity: user_to_entity::Model,
     ) -> Result<()> {
-        for workout in old_entity.exercise_extra_information.unwrap().history {
+        let Some(exercise_extra_information) = old_entity.exercise_extra_information else {
+            return Ok(());
+        };
+        for workout in exercise_extra_information.history {
             let db_workout = Workout::find_by_id(workout.workout_id)
                 .one(&self.0.db)
                 .await?
@@ -654,45 +656,45 @@ impl FitnessService {
         user_id: String,
         input: UpdateCustomExerciseInput,
     ) -> Result<bool> {
-        let entity = UserToEntity::find()
-            .filter(user_to_entity::Column::UserId.eq(&user_id))
-            .filter(user_to_entity::Column::ExerciseId.eq(input.old_id.clone()))
-            .one(&self.0.db)
-            .await?
-            .ok_or_else(|| Error::new("Exercise does not exist"))?;
-        let old_exercise = Exercise::find_by_id(input.old_id.clone())
-            .one(&self.0.db)
-            .await?
-            .unwrap();
-        if input.should_delete.unwrap_or_default() {
-            if !entity
-                .exercise_extra_information
-                .unwrap_or_default()
-                .history
-                .is_empty()
-            {
-                return Err(Error::new(
-                    "Exercise is associated with one or more workouts.",
-                ));
+        let id = input.update.id.clone();
+        let mut update = input.update.clone();
+        let old_exercise = Exercise::find_by_id(&id).one(&self.0.db).await?.unwrap();
+        for image in old_exercise.attributes.internal_images.iter() {
+            if let StoredUrl::S3(key) = image.to_owned() {
+                self.0.file_storage_service.delete_object(key).await;
             }
-            old_exercise.delete(&self.0.db).await?;
-            return Ok(true);
         }
-        if input.old_id != input.update.id
-            && Exercise::find_by_id(input.update.id.clone())
+        if input.should_delete.unwrap_or_default() {
+            let ute = UserToEntity::find()
+                .filter(user_to_entity::Column::UserId.eq(&user_id))
+                .filter(user_to_entity::Column::ExerciseId.eq(&id))
                 .one(&self.0.db)
                 .await?
-                .is_some()
-        {
-            return Err(Error::new("Exercise with the new name already exists."));
-        }
-        for image in old_exercise.attributes.internal_images {
-            if let StoredUrl::S3(key) = image {
-                self.0.file_storage_service.delete_object(key).await;
+                .ok_or_else(|| Error::new("Exercise does not exist"))?;
+            if let Some(exercise_extra_information) = ute.exercise_extra_information {
+                if !exercise_extra_information.history.is_empty() {
+                    return Err(Error::new(
+                        "Exercise is associated with one or more workouts.",
+                    ));
+                }
             }
+            old_exercise.delete(&self.0.db).await?;
+            return Ok(true);
         }
-        self.create_custom_exercise(&user_id, input.update.clone())
-            .await?;
+        update.source = ExerciseSource::Custom;
+        update.created_by_user_id = Some(user_id.clone());
+        update.attributes.internal_images = update
+            .attributes
+            .images
+            .clone()
+            .into_iter()
+            .map(StoredUrl::S3)
+            .collect();
+        update.attributes.images = vec![];
+        let input: exercise::ActiveModel = update.into();
+        let mut input = input.reset_all();
+        input.id = ActiveValue::Unchanged(id);
+        input.update(&self.0.db).await?;
         Ok(true)
     }
 
@@ -782,7 +784,7 @@ impl FitnessService {
             .one(&self.0.db)
             .await?
             .ok_or_else(|| Error::new("Exercise does not exist"))?;
-        self.change_exercise_name_in_history(merge_into, old_entity)
+        self.change_exercise_id_in_history(merge_into, old_entity)
             .await?;
         schedule_user_for_workout_revision(&user_id, &self.0).await?;
         Ok(true)
diff --git a/crates/utils/dependent/src/lib.rs b/crates/utils/dependent/src/lib.rs
index 6dcc0dbc26..f574f2e2ba 100644
--- a/crates/utils/dependent/src/lib.rs
+++ b/crates/utils/dependent/src/lib.rs
@@ -1859,16 +1859,6 @@ pub async fn create_or_update_user_workout(
         ex.sets
             .sort_unstable_by_key(|s| s.confirmed_at.unwrap_or_default());
         for set in ex.sets.iter_mut() {
-            let mut actual_rest_time = None;
-            if exercise_idx != 0
-                && set.confirmed_at.is_some()
-                && first_set_of_exercise_confirmed_at.is_some()
-            {
-                actual_rest_time = Some(
-                    (set.confirmed_at.unwrap() - first_set_of_exercise_confirmed_at.unwrap())
-                        .num_seconds(),
-                );
-            }
             first_set_of_exercise_confirmed_at = set.confirmed_at;
             clean_values(set, &db_ex.lot);
             if let Some(r) = set.statistic.reps {
@@ -1890,7 +1880,6 @@ pub async fn create_or_update_user_workout(
             let mut value = WorkoutSetRecord {
                 lot: set.lot,
                 rpe: set.rpe,
-                actual_rest_time,
                 totals: Some(totals),
                 note: set.note.clone(),
                 rest_time: set.rest_time,
@@ -2089,7 +2078,6 @@ pub async fn create_custom_exercise(
     input: exercise::Model,
     ss: &Arc<SupportingService>,
 ) -> Result<String> {
-    let exercise_id = input.id.clone();
     let mut input = input;
     input.id = generate_exercise_id(&input.name, input.lot, user_id);
     input.created_by_user_id = Some(user_id.clone());
@@ -2103,17 +2091,7 @@ pub async fn create_custom_exercise(
         .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(&ss.db)
-        .await?
-    {
-        None => input.insert(&ss.db).await?,
-        Some(_) => {
-            let input = input.reset_all();
-            input.update(&ss.db).await?
-        }
-    };
+    let exercise = input.insert(&ss.db).await?;
     ryot_log!(debug, "Created custom exercise with id = {}", exercise.id);
     add_entity_to_collection(
         &user_id.clone(),
diff --git a/docs/includes/export-schema.ts b/docs/includes/export-schema.ts
index 856a04df66..5b3ecda542 100644
--- a/docs/includes/export-schema.ts
+++ b/docs/includes/export-schema.ts
@@ -251,7 +251,6 @@ export interface WorkoutSetTotals {
 
 /** Details about the set performed. */
 export interface WorkoutSetRecord {
-	actual_rest_time: number | null;
 	confirmed_at: string | null;
 	/** @type {'normal' | 'warm_up' | 'drop' | 'failure'} */
 	lot: SetLot;
diff --git a/libs/generated/src/graphql/backend/graphql.ts b/libs/generated/src/graphql/backend/graphql.ts
index b2faa81678..0ef118dd0c 100644
--- a/libs/generated/src/graphql/backend/graphql.ts
+++ b/libs/generated/src/graphql/backend/graphql.ts
@@ -2189,7 +2189,6 @@ export type UpdateCustomExerciseInput = {
   mechanic?: InputMaybe<ExerciseMechanic>;
   muscles: Array<ExerciseMuscle>;
   name: Scalars['String']['input'];
-  oldId: Scalars['String']['input'];
   shouldDelete?: InputMaybe<Scalars['Boolean']['input']>;
 };
 
diff --git a/libs/generated/src/graphql/backend/types.generated.ts b/libs/generated/src/graphql/backend/types.generated.ts
index 8d13a7cd77..d87407fb5b 100644
--- a/libs/generated/src/graphql/backend/types.generated.ts
+++ b/libs/generated/src/graphql/backend/types.generated.ts
@@ -2261,7 +2261,6 @@ export type UpdateCustomExerciseInput = {
   mechanic?: InputMaybe<ExerciseMechanic>;
   muscles: Array<ExerciseMuscle>;
   name: Scalars['String']['input'];
-  oldId: Scalars['String']['input'];
   shouldDelete?: InputMaybe<Scalars['Boolean']['input']>;
 };
 


Test patch: