Эх сурвалжийг харах

Create delete and update routes for workouts

Lee Morgan 1 сар өмнө
parent
commit
055742da2f

+ 223 - 0
docs/paths/workout-id.yaml

@@ -103,3 +103,226 @@ get:
         application/json:
           schema:
             $ref: '../components/schemas/ErrorResponse.yaml'
+
+put:
+  summary: Update a workout
+  operationId: updateWorkout
+  tags:
+    - Workouts
+  security:
+    - cookieAuth: []
+  parameters:
+    - name: id
+      in: path
+      required: true
+      description: UUID of the workout to update.
+      schema:
+        type: string
+        format: uuid
+        example: 0191a23b-dead-7000-beef-000000000002
+  requestBody:
+    required: true
+    content:
+      application/json:
+        schema:
+          type: object
+          properties:
+            name:
+              type: string
+              description: New name for the workout.
+              example: Push Day (Heavy)
+            exercises:
+              type: array
+              description: Complete replacement list of exercises. Omit to leave exercises unchanged.
+              items:
+                type: object
+                required:
+                  - name
+                  - exercise_type
+                  - position
+                properties:
+                  name:
+                    type: string
+                    example: Incline Bench Press
+                  exercise_type:
+                    $ref: '../components/schemas/ExerciseType.yaml'
+                  position:
+                    type: integer
+                    description: 1-based position of the exercise within the workout. Must be unique.
+                    example: 1
+        example:
+          name: Push Day (Heavy)
+          exercises:
+            - name: Incline Bench Press
+              exercise_type: WeightedReps
+              position: 1
+            - name: Dips
+              exercise_type: BodyweightReps
+              position: 2
+  responses:
+    '200':
+      description: Workout updated successfully. Returns the full updated workout.
+      content:
+        application/json:
+          schema:
+            type: object
+            required:
+              - id
+              - name
+              - exercises
+            properties:
+              id:
+                type: string
+                format: uuid
+                example: 0191a23b-dead-7000-beef-000000000002
+              name:
+                type: string
+                example: Push Day (Heavy)
+              exercises:
+                type: array
+                items:
+                  type: object
+                  required:
+                    - id
+                    - name
+                    - exercise_type
+                    - position
+                  properties:
+                    id:
+                      type: string
+                      format: uuid
+                      example: 0191a23b-dead-7000-beef-000000000003
+                    name:
+                      type: string
+                      example: Incline Bench Press
+                    notes:
+                      type: string
+                      nullable: true
+                      example: null
+                    exercise_type:
+                      $ref: '../components/schemas/ExerciseType.yaml'
+                    position:
+                      type: integer
+                      example: 1
+          example:
+            id: 0191a23b-dead-7000-beef-000000000002
+            name: Push Day (Heavy)
+            exercises:
+              - id: 0191a23b-dead-7000-beef-000000000005
+                name: Incline Bench Press
+                notes: null
+                exercise_type: WeightedReps
+                position: 1
+              - id: 0191a23b-dead-7000-beef-000000000006
+                name: Dips
+                notes: null
+                exercise_type: BodyweightReps
+                position: 2
+    '401':
+      description: Missing or invalid auth token.
+      content:
+        application/json:
+          schema:
+            $ref: '../components/schemas/ErrorResponse.yaml'
+          examples:
+            missing:
+              summary: No cookie present
+              value:
+                error: Missing auth token
+            invalid:
+              summary: Token invalid or expired
+              value:
+                error: Invalid auth token
+    '403':
+      description: Authenticated user does not own this workout.
+      content:
+        application/json:
+          schema:
+            $ref: '../components/schemas/ErrorResponse.yaml'
+          example:
+            error: You do not have permission to update this workout
+    '404':
+      description: Workout or exercise not found.
+      content:
+        application/json:
+          schema:
+            $ref: '../components/schemas/ErrorResponse.yaml'
+          example:
+            error: Workout not found
+    '422':
+      description: Two or more exercises share the same position.
+      content:
+        application/json:
+          schema:
+            $ref: '../components/schemas/ErrorResponse.yaml'
+          example:
+            error: Two or more exercises share the same position. Each exercise must have a unique position within the workout.
+    '500':
+      description: Internal server error.
+      content:
+        application/json:
+          schema:
+            $ref: '../components/schemas/ErrorResponse.yaml'
+
+delete:
+  summary: Delete a workout
+  operationId: deleteWorkout
+  tags:
+    - Workouts
+  security:
+    - cookieAuth: []
+  parameters:
+    - name: id
+      in: path
+      required: true
+      description: UUID of the workout to delete.
+      schema:
+        type: string
+        format: uuid
+        example: 0191a23b-dead-7000-beef-000000000002
+  responses:
+    '200':
+      description: Workout and all its exercises deleted successfully.
+      content:
+        application/json:
+          schema:
+            $ref: '../components/schemas/SuccessResponse.yaml'
+          example:
+            success: true
+    '401':
+      description: Missing or invalid auth token.
+      content:
+        application/json:
+          schema:
+            $ref: '../components/schemas/ErrorResponse.yaml'
+          examples:
+            missing:
+              summary: No cookie present
+              value:
+                error: Missing auth token
+            invalid:
+              summary: Token invalid or expired
+              value:
+                error: Invalid auth token
+    '403':
+      description: Authenticated user does not own this workout.
+      content:
+        application/json:
+          schema:
+            $ref: '../components/schemas/ErrorResponse.yaml'
+          example:
+            error: You do not have permission to delete this workout
+    '404':
+      description: Workout not found.
+      content:
+        application/json:
+          schema:
+            $ref: '../components/schemas/ErrorResponse.yaml'
+          example:
+            error: Workout not found
+    '500':
+      description: Internal server error.
+      content:
+        application/json:
+          schema:
+            $ref: '../components/schemas/ErrorResponse.yaml'

+ 11 - 0
instructions.txt

@@ -1,3 +1,14 @@
 Create the following routes:
 
+PUT /workout/{workout_id}
+    This route will allow the user to update the change the name of the workout
+    This route will allow the user to change the name of each exercise and the position/ordering of each exercise
+    Ensure that positions are unique without the workout. So no two exercises within the workout should contain the same value
+    This route will allow the user to delete any exercises
+    This route will allow the user to add new exercises
+    Any error messages should contain a user readable message
+    Ensure logged in user is the owner of the workout
 
+DELETE /workout/{workout_id}
+    Ensure logged in user is owner of workout
+    This route simply deletes the workout and all corresponding exercises

+ 11 - 1
src/main.rs

@@ -1,4 +1,5 @@
-use actix_web::{get, web, App, HttpServer, Responder};
+use actix_web::{get, web, App, HttpResponse, HttpServer, Responder};
+use serde_json::json;
 use sqlx::PgPool;
 use std::env;
 
@@ -45,17 +46,26 @@ async fn main() -> std::io::Result<()> {
     println!("Starting server on {}:{} ({})", host, port, app_env);
 
     HttpServer::new(move || {
+        let json_cfg = web::JsonConfig::default().error_handler(|_err, _req| {
+            let response = HttpResponse::BadRequest()
+                .json(json!({"error": "Invalid request body. Please check your data and try again."}));
+            actix_web::error::InternalError::from_response(_err, response).into()
+        });
+
         App::new()
             .app_data(web::Data::new(pool.clone()))
             .app_data(config.clone())
+            .app_data(json_cfg)
             .service(index)
             .service(routes::users::create::create_user)
             .service(routes::users::login::login)
             .service(routes::users::logout::logout)
             .service(routes::users::get::get_user)
             .service(routes::workouts::create::create_workout)
+            .service(routes::workouts::delete::delete_workout)
             .service(routes::workouts::get::get_workout)
             .service(routes::workouts::list::list_workouts)
+            .service(routes::workouts::update::update_workout)
     })
     .bind((host.as_str(), port))?
     .run()

+ 42 - 0
src/routes/workouts/delete.rs

@@ -0,0 +1,42 @@
+use actix_web::{delete, web, HttpResponse};
+use serde_json::json;
+use sqlx::PgPool;
+use uuid::Uuid;
+
+use crate::auth::AuthUser;
+use crate::models::workout::Workout;
+
+#[delete("/workout/{id}")]
+pub async fn delete_workout(
+    pool: web::Data<PgPool>,
+    auth: AuthUser,
+    path: web::Path<Uuid>,
+) -> HttpResponse {
+    let id = path.into_inner();
+
+    let workout = match Workout::find_by_id(pool.get_ref(), id).await {
+        Ok(Some(w)) => w,
+        Ok(None) => return HttpResponse::NotFound().json(json!({"error": "Workout not found"})),
+        Err(e) => {
+            eprintln!("delete_workout find error: {e}");
+            return HttpResponse::InternalServerError().json(json!({"error": "An error occurred"}));
+        }
+    };
+
+    if workout.user_id != auth.0.id {
+        return HttpResponse::Forbidden()
+            .json(json!({"error": "You do not have permission to delete this workout"}));
+    }
+
+    match sqlx::query("DELETE FROM workouts WHERE id = $1")
+        .bind(id)
+        .execute(pool.get_ref())
+        .await
+    {
+        Ok(_) => HttpResponse::Ok().json(json!({"success": true})),
+        Err(e) => {
+            eprintln!("delete_workout error: {e}");
+            HttpResponse::InternalServerError().json(json!({"error": "Failed to delete workout"}))
+        }
+    }
+}

+ 2 - 0
src/routes/workouts/mod.rs

@@ -1,3 +1,5 @@
 pub mod create;
+pub mod delete;
 pub mod get;
 pub mod list;
+pub mod update;

+ 136 - 0
src/routes/workouts/update.rs

@@ -0,0 +1,136 @@
+use actix_web::{put, web, HttpResponse};
+use serde::Deserialize;
+use serde_json::json;
+use sqlx::PgPool;
+use std::collections::HashSet;
+use uuid::Uuid;
+
+use crate::auth::AuthUser;
+use crate::models::exercise::{Exercise, ExerciseType};
+use crate::models::workout::Workout;
+
+#[derive(Deserialize)]
+pub struct ExerciseInput {
+    pub name: String,
+    pub exercise_type: ExerciseType,
+    pub position: i32,
+}
+
+#[derive(Deserialize)]
+pub struct UpdateWorkoutInput {
+    pub name: Option<String>,
+    pub exercises: Option<Vec<ExerciseInput>>,
+}
+
+#[put("/workout/{id}")]
+pub async fn update_workout(
+    pool: web::Data<PgPool>,
+    auth: AuthUser,
+    path: web::Path<Uuid>,
+    body: web::Json<UpdateWorkoutInput>,
+) -> HttpResponse {
+    let id = path.into_inner();
+
+    let workout = match Workout::find_by_id(pool.get_ref(), id).await {
+        Ok(Some(w)) => w,
+        Ok(None) => return HttpResponse::NotFound().json(json!({"error": "Workout not found"})),
+        Err(e) => {
+            eprintln!("update_workout find error: {e}");
+            return HttpResponse::InternalServerError().json(json!({"error": "An error occurred"}));
+        }
+    };
+
+    if workout.user_id != auth.0.id {
+        return HttpResponse::Forbidden()
+            .json(json!({"error": "You do not have permission to update this workout"}));
+    }
+
+    if let Some(exercises) = &body.exercises {
+        let positions: HashSet<i32> = exercises.iter().map(|e| e.position).collect();
+        if positions.len() != exercises.len() {
+            return HttpResponse::UnprocessableEntity().json(json!({
+                "error": "Two or more exercises share the same position. Each exercise must have a unique position within the workout."
+            }));
+        }
+    }
+
+    let mut tx = match pool.begin().await {
+        Ok(t) => t,
+        Err(e) => {
+            eprintln!("update_workout begin tx error: {e}");
+            return HttpResponse::InternalServerError().json(json!({"error": "An error occurred"}));
+        }
+    };
+
+    if let Some(name) = &body.name {
+        if let Err(e) = sqlx::query("UPDATE workouts SET name = $1 WHERE id = $2")
+            .bind(name)
+            .bind(id)
+            .execute(&mut *tx)
+            .await
+        {
+            eprintln!("update_workout name error: {e}");
+            return HttpResponse::InternalServerError()
+                .json(json!({"error": "Failed to update workout"}));
+        }
+    }
+
+    if let Some(exercises) = &body.exercises {
+        if let Err(e) = sqlx::query("DELETE FROM exercises WHERE workout = $1")
+            .bind(id)
+            .execute(&mut *tx)
+            .await
+        {
+            eprintln!("update_workout delete exercises error: {e}");
+            return HttpResponse::InternalServerError()
+                .json(json!({"error": "Failed to update exercises"}));
+        }
+
+        for exercise in exercises {
+            if let Err(e) = sqlx::query(
+                "INSERT INTO exercises (workout, name, exercise_type, position) VALUES ($1, $2, $3, $4)",
+            )
+            .bind(id)
+            .bind(&exercise.name)
+            .bind(exercise.exercise_type.clone())
+            .bind(exercise.position)
+            .execute(&mut *tx)
+            .await
+            {
+                eprintln!("update_workout insert exercise error: {e}");
+                return HttpResponse::InternalServerError()
+                    .json(json!({"error": "Failed to update exercises"}));
+            }
+        }
+    }
+
+    if let Err(e) = tx.commit().await {
+        eprintln!("update_workout commit error: {e}");
+        return HttpResponse::InternalServerError().json(json!({"error": "An error occurred"}));
+    }
+
+    let workout = match Workout::find_by_id(pool.get_ref(), id).await {
+        Ok(Some(w)) => w,
+        _ => return HttpResponse::InternalServerError().json(json!({"error": "An error occurred"})),
+    };
+
+    let exercises = match Exercise::find_by_workout(pool.get_ref(), id).await {
+        Ok(e) => e,
+        Err(e) => {
+            eprintln!("update_workout fetch exercises error: {e}");
+            return HttpResponse::InternalServerError().json(json!({"error": "An error occurred"}));
+        }
+    };
+
+    HttpResponse::Ok().json(json!({
+        "id": workout.id,
+        "name": workout.name,
+        "exercises": exercises.iter().map(|e| json!({
+            "id": e.id,
+            "name": e.name,
+            "notes": e.notes,
+            "exercise_type": e.exercise_type,
+            "position": e.position,
+        })).collect::<Vec<_>>(),
+    }))
+}