Parcourir la source

Create routes for sessions

Lee Morgan il y a 4 semaines
Parent
commit
145670bd50

+ 27 - 0
docs/components/schemas/Session.yaml

@@ -0,0 +1,27 @@
+type: object
+required:
+  - id
+  - workout
+  - start_time
+  - end_time
+properties:
+  id:
+    type: string
+    format: uuid
+    description: UUID v7 primary key.
+    example: 0191a23b-dead-7000-beef-000000000005
+  workout:
+    type: string
+    format: uuid
+    description: Foreign key referencing the parent workout.
+    example: 0191a23b-dead-7000-beef-000000000002
+  start_time:
+    type: string
+    format: date-time
+    description: ISO 8601 timestamp when the session started.
+    example: '2026-01-15T09:00:00Z'
+  end_time:
+    type: string
+    format: date-time
+    description: ISO 8601 timestamp when the session ended.
+    example: '2026-01-15T09:45:00Z'

+ 36 - 0
docs/components/schemas/SessionExercise.yaml

@@ -0,0 +1,36 @@
+type: object
+required:
+  - id
+  - exercise
+  - name
+  - exercise_type
+  - position
+properties:
+  id:
+    type: string
+    format: uuid
+    description: UUID v7 primary key for the recorded exercise instance.
+    example: 0191a23b-dead-7000-beef-000000000006
+  exercise:
+    type: string
+    format: uuid
+    description: Foreign key referencing the original exercise from the workout.
+    example: 0191a23b-dead-7000-beef-000000000003
+  name:
+    type: string
+    example: Bench Press
+  notes:
+    type: string
+    nullable: true
+    example: Keep elbows tucked
+  exercise_type:
+    $ref: './ExerciseType.yaml'
+  position:
+    type: integer
+    description: 1-based position of the exercise within its workout.
+    example: 1
+  sets:
+    type: array
+    description: Array of performed sets for this exercise instance.
+    items:
+      $ref: './SessionSet.yaml'

+ 18 - 0
docs/components/schemas/SessionSet.yaml

@@ -0,0 +1,18 @@
+type: object
+properties:
+  reps:
+    type: integer
+    nullable: true
+    example: 12
+  weight:
+    type: number
+    nullable: true
+    example: 120
+  duration_seconds:
+    type: integer
+    nullable: true
+    example: 1800
+  distance_meters:
+    type: number
+    nullable: true
+    example: 5000

+ 23 - 0
docs/openapi.yaml

@@ -14,6 +14,19 @@ tags:
   - name: General
   - name: Users
   - name: Workouts
+  - name: Sessions
+
+x-tagGroups:
+  - name: General
+    tags:
+      - General
+  - name: Users
+    tags:
+      - Users
+  - name: Workouts
+    tags:
+      - Workouts
+      - Sessions
 
 paths:
   /:
@@ -30,6 +43,10 @@ paths:
     $ref: './paths/workout.yaml'
   /workout/{id}:
     $ref: './paths/workout-id.yaml'
+  /workout/{workout_id}/session:
+    $ref: './paths/workout-session.yaml'
+  /workout/{workout_id}/session/{session_id}:
+    $ref: './paths/workout-session-id.yaml'
 
 components:
   securitySchemes:
@@ -43,6 +60,12 @@ components:
       $ref: './components/schemas/Exercise.yaml'
     ExerciseType:
       $ref: './components/schemas/ExerciseType.yaml'
+    Session:
+      $ref: './components/schemas/Session.yaml'
+    SessionExercise:
+      $ref: './components/schemas/SessionExercise.yaml'
+    SessionSet:
+      $ref: './components/schemas/SessionSet.yaml'
     ErrorResponse:
       $ref: './components/schemas/ErrorResponse.yaml'
     SuccessResponse:

+ 194 - 0
docs/paths/workout-session-id.yaml

@@ -0,0 +1,194 @@
+get:
+  summary: Get a single full session including all performed exercises
+  operationId: getWorkoutSession
+  tags:
+    - Sessions
+  security:
+    - cookieAuth: []
+  parameters:
+    - name: workout_id
+      in: path
+      required: true
+      description: UUID of the parent workout.
+      schema:
+        type: string
+        format: uuid
+        example: 0191a23b-dead-7000-beef-000000000002
+    - name: session_id
+      in: path
+      required: true
+      description: UUID of the session to retrieve.
+      schema:
+        type: string
+        format: uuid
+        example: 0191a23b-dead-7000-beef-000000000005
+  responses:
+    '200':
+      description: The full session including recorded exercise details.
+      content:
+        application/json:
+          schema:
+            type: object
+            required:
+              - id
+              - workout
+              - start_time
+              - end_time
+              - exercises
+            properties:
+              id:
+                type: string
+                format: uuid
+                example: 0191a23b-dead-7000-beef-000000000005
+              workout:
+                type: string
+                format: uuid
+                example: 0191a23b-dead-7000-beef-000000000002
+              start_time:
+                type: string
+                format: date-time
+                example: '2026-01-15T09:00:00Z'
+              end_time:
+                type: string
+                format: date-time
+                example: '2026-01-15T09:45:00Z'
+              exercises:
+                type: array
+                items:
+                  $ref: '../components/schemas/SessionExercise.yaml'
+          example:
+            id: 0191a23b-dead-7000-beef-000000000005
+            workout: 0191a23b-dead-7000-beef-000000000002
+            start_time: '2026-01-15T09:00:00Z'
+            end_time: '2026-01-15T09:45:00Z'
+            exercises:
+              - id: 0191a23b-dead-7000-beef-000000000006
+                exercise: 0191a23b-dead-7000-beef-000000000003
+                name: Bench Press
+                notes: null
+                exercise_type: WeightedReps
+                position: 1
+                sets:
+                  - reps: 12
+                    weight: 135.0
+                  - reps: 10
+                    weight: 135.0
+                  - reps: 8
+                    weight: 135.0
+              - id: 0191a23b-dead-7000-beef-000000000007
+                exercise: 0191a23b-dead-7000-beef-000000000004
+                name: Push-ups
+                notes: null
+                exercise_type: BodyweightReps
+                position: 2
+                sets:
+                  - reps: 15
+                  - reps: 12
+    '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/session.
+      content:
+        application/json:
+          schema:
+            $ref: '../components/schemas/ErrorResponse.yaml'
+          example:
+            error: You do not have permission to access this workout
+    '404':
+      description: Workout or session not found.
+      content:
+        application/json:
+          schema:
+            $ref: '../components/schemas/ErrorResponse.yaml'
+          example:
+            error: Session not found
+    '500':
+      description: Internal server error.
+      content:
+        application/json:
+          schema:
+            $ref: '../components/schemas/ErrorResponse.yaml'
+
+delete:
+  summary: Delete a session
+  operationId: deleteWorkoutSession
+  tags:
+    - Sessions
+  security:
+    - cookieAuth: []
+  parameters:
+    - name: workout_id
+      in: path
+      required: true
+      description: UUID of the parent workout.
+      schema:
+        type: string
+        format: uuid
+        example: 0191a23b-dead-7000-beef-000000000002
+    - name: session_id
+      in: path
+      required: true
+      description: UUID of the session to delete.
+      schema:
+        type: string
+        format: uuid
+        example: 0191a23b-dead-7000-beef-000000000005
+  responses:
+    '200':
+      description: Session deleted successfully (child exercise records also removed via cascade).
+      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/session.
+      content:
+        application/json:
+          schema:
+            $ref: '../components/schemas/ErrorResponse.yaml'
+          example:
+            error: You do not have permission to access this workout
+    '404':
+      description: Workout or session not found.
+      content:
+        application/json:
+          schema:
+            $ref: '../components/schemas/ErrorResponse.yaml'
+          example:
+            error: Session not found
+    '500':
+      description: Internal server error.
+      content:
+        application/json:
+          schema:
+            $ref: '../components/schemas/ErrorResponse.yaml'

+ 254 - 0
docs/paths/workout-session.yaml

@@ -0,0 +1,254 @@
+get:
+  summary: List sessions for a workout (paginated, filtered by date range)
+  operationId: listWorkoutSessions
+  tags:
+    - Sessions
+  security:
+    - cookieAuth: []
+  parameters:
+    - name: workout_id
+      in: path
+      required: true
+      description: UUID of the workout.
+      schema:
+        type: string
+        format: uuid
+        example: 0191a23b-dead-7000-beef-000000000002
+    - name: start
+      in: query
+      required: false
+      description: Start date (ISO 8601) to filter sessions (on start_time). Beginning of time if omitted.
+      schema:
+        type: string
+        format: date-time
+        example: '2026-01-01T00:00:00Z'
+    - name: end
+      in: query
+      required: false
+      description: End date (ISO 8601) to filter sessions (on start_time). End of time if omitted.
+      schema:
+        type: string
+        format: date-time
+        example: '2026-12-31T23:59:59Z'
+    - name: page
+      in: query
+      required: false
+      description: 1-indexed page number for pagination (defaults to 1). Returns up to 50 results per page.
+      schema:
+        type: integer
+        example: 1
+  responses:
+    '200':
+      description: List of matching sessions plus total count for pagination.
+      content:
+        application/json:
+          schema:
+            type: object
+            required:
+              - sessions
+              - total
+            properties:
+              sessions:
+                type: array
+                items:
+                  $ref: '../components/schemas/Session.yaml'
+              total:
+                type: integer
+                description: Total number of sessions matching the query (for the workout).
+                example: 12
+          example:
+            sessions:
+              - id: 0191a23b-dead-7000-beef-000000000005
+                workout: 0191a23b-dead-7000-beef-000000000002
+                start_time: '2026-01-15T09:00:00Z'
+                end_time: '2026-01-15T09:45:00Z'
+              - id: 0191a23b-dead-7000-beef-000000000008
+                workout: 0191a23b-dead-7000-beef-000000000002
+                start_time: '2026-01-10T10:00:00Z'
+                end_time: '2026-01-10T10:30:00Z'
+            total: 12
+    '400':
+      description: Invalid query parameter (e.g. bad date format).
+      content:
+        application/json:
+          schema:
+            $ref: '../components/schemas/ErrorResponse.yaml'
+          example:
+            error: Invalid start date
+    '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 access 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'
+
+post:
+  summary: Create a new session (after completing the workout on the front-end)
+  operationId: createWorkoutSession
+  tags:
+    - Sessions
+  security:
+    - cookieAuth: []
+  parameters:
+    - name: workout_id
+      in: path
+      required: true
+      description: UUID of the workout this session belongs to.
+      schema:
+        type: string
+        format: uuid
+        example: 0191a23b-dead-7000-beef-000000000002
+  requestBody:
+    required: true
+    content:
+      application/json:
+        schema:
+          type: object
+          required:
+            - start_time
+            - end_time
+            - exercises
+          properties:
+            start_time:
+              type: string
+              format: date-time
+              description: ISO 8601 timestamp when the session started.
+              example: '2026-01-15T09:00:00Z'
+            end_time:
+              type: string
+              format: date-time
+              description: ISO 8601 timestamp when the session ended.
+              example: '2026-01-15T09:45:00Z'
+            exercises:
+              type: array
+              description: List of completed exercises with what was actually performed.
+              items:
+                type: object
+                required:
+                  - exercise_id
+                properties:
+                  exercise_id:
+                    type: string
+                    format: uuid
+                    description: ID of the original exercise definition from the workout.
+                    example: 0191a23b-dead-7000-beef-000000000003
+                  sets:
+                    type: array
+                    nullable: true
+                    description: Array of sets performed for this exercise. Each set can include reps/weight for strength or duration/distance for cardio.
+                    items:
+                      $ref: '../components/schemas/SessionSet.yaml'
+        example:
+          start_time: '2026-01-15T09:00:00Z'
+          end_time: '2026-01-15T09:45:00Z'
+          exercises:
+            - exercise_id: 0191a23b-dead-7000-beef-000000000003
+              sets:
+                - reps: 12
+                  weight: 120
+                - reps: 11
+                  weight: 120
+                - reps: 10
+                  weight: 120
+            - exercise_id: 0191a23b-dead-7000-beef-000000000004
+              sets:
+                - reps: 15
+                - reps: 12
+  responses:
+    '201':
+      description: Session created successfully. Returns the generated session ID.
+      content:
+        application/json:
+          schema:
+            type: object
+            required:
+              - id
+            properties:
+              id:
+                type: string
+                format: uuid
+                example: 0191a23b-dead-7000-beef-000000000005
+    '400':
+      description: Invalid request body.
+      content:
+        application/json:
+          schema:
+            $ref: '../components/schemas/ErrorResponse.yaml'
+          example:
+            error: Invalid request body. Please check your data and try again.
+    '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 access 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: One of the exercises does not belong to the workout.
+      content:
+        application/json:
+          schema:
+            $ref: '../components/schemas/ErrorResponse.yaml'
+          example:
+            error: Exercise does not belong to this workout
+    '500':
+      description: Internal server error.
+      content:
+        application/json:
+          schema:
+            $ref: '../components/schemas/ErrorResponse.yaml'

+ 60 - 1
docs/paths/workout.yaml

@@ -104,18 +104,77 @@ post:
               position: 3
   responses:
     '201':
-      description: Workout created. Returns the generated workout ID.
+      description: Workout created successfully. Returns the full created workout, including generated IDs for the exercises.
       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
+              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: Bench Press
+                    notes:
+                      type: string
+                      nullable: true
+                      example: Keep elbows tucked
+                    exercise_type:
+                      $ref: '../components/schemas/ExerciseType.yaml'
+                    position:
+                      type: integer
+                      description: 1-based position of the exercise within its workout.
+                      example: 1
+          example:
+            id: 0191a23b-dead-7000-beef-000000000002
+            name: Push Day
+            exercises:
+              - id: 0191a23b-dead-7000-beef-000000000003
+                name: Bench Press
+                notes: null
+                exercise_type: WeightedReps
+                position: 1
+              - id: 0191a23b-dead-7000-beef-000000000004
+                name: Push-ups
+                notes: null
+                exercise_type: BodyweightReps
+                position: 2
+              - id: 0191a23b-dead-7000-beef-000000000005
+                name: Treadmill
+                notes: null
+                exercise_type: Cardio
+                position: 3
+    '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.
     '401':
       description: Missing or invalid auth token.
       content:

+ 0 - 10
init.sql

@@ -1,10 +0,0 @@
-CREATE DATABASE torus;
-
-\c torus
-
-\i migrations/0001_create_users.sql
-\i migrations/0002_create_workouts.sql
-\i migrations/0003_create_exercises.sql
-\i migrations/0004_add_user_to_workouts.sql
-\i migrations/0005_add_position_to_exercises.sql
-\i migrations/0006_create_sessions.sql

+ 22 - 4
instructions.txt

@@ -1,7 +1,25 @@
-Create a model for "sessions".
+Create the following routes:
 
-A session is a when a user does a workout. So it should contain a reference to the original workout. Each session should have a list of exercises that the user completed. This could be cardio, weightlifting, etc. Check the exercise.rs model for information on what that should look like.
+POST /workout/{workout_id}/session
+    Creates a new session
+    The session will be administered on the front-end, so that will only create it after the session is complete in order to store the data.
+    Requires user auth (ensure user owns workout)
 
-Basically, a session is going to be when a user does a workout and records exactly what/how much/how long they did each exercise. While workout/exercise models are the outline, session is the actual completed/recorded thing done.
+GET /workout/{workout_id}/session/{session_id}
+    Returns a single, full session
+    Requires user auth (ensure user owns workout/session)
 
-Make sure that you create the sql table file and the rust model. 
+GET /workout/{workout_id}/session
+    Takes in a query
+    Returns an array of all sessions that match the query and are part of the workout
+    Limit to 50 results
+    Return array of results, plus a field that contains the count of all sessions for the workout, for pagination
+    Requires user auth (ensure ownership of workout)
+    Queries:
+        start: date to start searching, optional, beginning of time if not provided
+        end: date to end searching, optional, end of time if not provided
+        page: 1-indexed, used for pagination
+
+DELETE /workout/{workout_id}/session/{session_id}
+    Delete the workout
+    Requires user auth (ensure user owns workout/session)

+ 0 - 4
migrations/0002_create_workouts.sql

@@ -1,4 +0,0 @@
-CREATE TABLE workouts (
-    id   UUID PRIMARY KEY DEFAULT gen_uuid_v7(),
-    name TEXT NOT NULL
-);

+ 0 - 4
migrations/0004_add_user_to_workouts.sql

@@ -1,4 +0,0 @@
-TRUNCATE TABLE workouts CASCADE;
-
-ALTER TABLE workouts
-    ADD COLUMN user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE;

+ 0 - 1
migrations/0005_add_position_to_exercises.sql

@@ -1 +0,0 @@
-ALTER TABLE exercises ADD COLUMN position INTEGER NOT NULL DEFAULT 0;

+ 2 - 1
migrations/0003_create_exercises.sql → sql/exercises.sql

@@ -5,5 +5,6 @@ CREATE TABLE exercises (
     workout       UUID          NOT NULL REFERENCES workouts(id) ON DELETE CASCADE,
     name          TEXT          NOT NULL,
     notes         TEXT,
-    exercise_type exercise_type NOT NULL
+    exercise_type exercise_type NOT NULL,
+    position      INTEGER       NOT NULL
 );

+ 8 - 0
sql/init.sql

@@ -0,0 +1,8 @@
+CREATE DATABASE torus;
+
+\c torus
+
+\i users.sql
+\i workouts.sql
+\i exercises.sql
+\i sessions.sql

+ 10 - 4
migrations/0006_create_sessions.sql → sql/sessions.sql

@@ -2,7 +2,8 @@ CREATE TABLE sessions (
     id         UUID        PRIMARY KEY DEFAULT gen_uuid_v7(),
     workout    UUID        NOT NULL REFERENCES workouts(id) ON DELETE CASCADE,
     user_id    UUID        NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+    start_time TIMESTAMPTZ NOT NULL,
+    end_time   TIMESTAMPTZ NOT NULL
 );
 
 CREATE TABLE session_exercises (
@@ -12,10 +13,15 @@ CREATE TABLE session_exercises (
     name             TEXT          NOT NULL,
     notes            TEXT,
     exercise_type    exercise_type NOT NULL,
-    position         INTEGER       NOT NULL,
-    sets             INTEGER,
+    position         INTEGER       NOT NULL
+);
+
+CREATE TABLE session_sets (
+    id               UUID          PRIMARY KEY DEFAULT gen_uuid_v7(),
+    session_exercise UUID          NOT NULL REFERENCES session_exercises(id) ON DELETE CASCADE,
     reps             INTEGER,
     weight           DOUBLE PRECISION,
     duration_seconds INTEGER,
-    distance_meters  DOUBLE PRECISION
+    distance_meters  DOUBLE PRECISION,
+    position         INTEGER       NOT NULL
 );

+ 0 - 0
migrations/0001_create_users.sql → sql/users.sql


+ 5 - 0
sql/workouts.sql

@@ -0,0 +1,5 @@
+CREATE TABLE workouts (
+    id      UUID PRIMARY KEY DEFAULT gen_uuid_v7(),
+    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+    name    TEXT NOT NULL
+);

+ 4 - 0
src/main.rs

@@ -66,6 +66,10 @@ async fn main() -> std::io::Result<()> {
             .service(routes::workouts::get::get_workout)
             .service(routes::workouts::list::list_workouts)
             .service(routes::workouts::update::update_workout)
+            .service(routes::workouts::sessions::create::create_session)
+            .service(routes::workouts::sessions::delete::delete_session)
+            .service(routes::workouts::sessions::get::get_session)
+            .service(routes::workouts::sessions::list::list_sessions)
             .service(routes::docs::get::get_docs)
     })
     .bind((host.as_str(), port))?

+ 7 - 0
src/models/exercise.rs

@@ -29,6 +29,13 @@ impl Exercise {
             .await
     }
 
+    pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Exercise>, sqlx::Error> {
+        sqlx::query_as::<_, Exercise>("SELECT * FROM exercises WHERE id = $1")
+            .bind(id)
+            .fetch_optional(pool)
+            .await
+    }
+
     pub async fn create<'c, E>(
         executor: E,
         workout_id: Uuid,

+ 95 - 21
src/models/session.rs

@@ -10,20 +10,30 @@ pub struct Session {
     pub id: Uuid,
     pub workout: Uuid,
     pub user_id: Uuid,
-    #[serde(with = "time::serde::iso8601")]
-    pub created_at: OffsetDateTime,
+    #[serde(with = "time::serde::rfc3339")]
+    pub start_time: OffsetDateTime,
+    #[serde(with = "time::serde::rfc3339")]
+    pub end_time: OffsetDateTime,
 }
 
 impl Session {
-    pub async fn create<'c, E>(executor: E, workout_id: Uuid, user_id: Uuid) -> Result<Session, sqlx::Error>
+    pub async fn create<'c, E>(
+        executor: E,
+        workout_id: Uuid,
+        user_id: Uuid,
+        start_time: OffsetDateTime,
+        end_time: OffsetDateTime,
+    ) -> Result<Session, sqlx::Error>
     where
         E: sqlx::Executor<'c, Database = Postgres>,
     {
         sqlx::query_as::<_, Session>(
-            "INSERT INTO sessions (workout, user_id) VALUES ($1, $2) RETURNING *",
+            "INSERT INTO sessions (workout, user_id, start_time, end_time) VALUES ($1, $2, $3, $4) RETURNING *",
         )
         .bind(workout_id)
         .bind(user_id)
+        .bind(start_time)
+        .bind(end_time)
         .fetch_one(executor)
         .await
     }
@@ -35,14 +45,50 @@ impl Session {
             .await
     }
 
-    pub async fn find_by_user(pool: &PgPool, user_id: Uuid) -> Result<Vec<Session>, sqlx::Error> {
+    pub async fn find_by_workout_paginated(
+        pool: &PgPool,
+        workout_id: Uuid,
+        start: Option<OffsetDateTime>,
+        end: Option<OffsetDateTime>,
+        limit: i64,
+        offset: i64,
+    ) -> Result<Vec<Session>, sqlx::Error> {
         sqlx::query_as::<_, Session>(
-            "SELECT * FROM sessions WHERE user_id = $1 ORDER BY created_at DESC",
+            r#"SELECT * FROM sessions 
+            WHERE workout = $1 
+            AND ($2::timestamptz IS NULL OR start_time >= $2) 
+            AND ($3::timestamptz IS NULL OR start_time <= $3) 
+            ORDER BY start_time DESC 
+            LIMIT $4 OFFSET $5"#,
         )
-        .bind(user_id)
+        .bind(workout_id)
+        .bind(start)
+        .bind(end)
+        .bind(limit)
+        .bind(offset)
         .fetch_all(pool)
         .await
     }
+
+    pub async fn count_by_workout(
+        pool: &PgPool,
+        workout_id: Uuid,
+        start: Option<OffsetDateTime>,
+        end: Option<OffsetDateTime>,
+    ) -> Result<i64, sqlx::Error> {
+        let row: (i64,) = sqlx::query_as(
+            r#"SELECT COUNT(*) FROM sessions 
+            WHERE workout = $1 
+            AND ($2::timestamptz IS NULL OR start_time >= $2) 
+            AND ($3::timestamptz IS NULL OR start_time <= $3)"#,
+        )
+        .bind(workout_id)
+        .bind(start)
+        .bind(end)
+        .fetch_one(pool)
+        .await?;
+        Ok(row.0)
+    }
 }
 
 #[derive(sqlx::FromRow, Serialize, Clone)]
@@ -54,11 +100,6 @@ pub struct SessionExercise {
     pub notes: Option<String>,
     pub exercise_type: ExerciseType,
     pub position: i32,
-    pub sets: Option<i32>,
-    pub reps: Option<i32>,
-    pub weight: Option<f64>,
-    pub duration_seconds: Option<i32>,
-    pub distance_meters: Option<f64>,
 }
 
 impl SessionExercise {
@@ -77,19 +118,12 @@ impl SessionExercise {
         notes: Option<&str>,
         exercise_type: ExerciseType,
         position: i32,
-        sets: Option<i32>,
-        reps: Option<i32>,
-        weight: Option<f64>,
-        duration_seconds: Option<i32>,
-        distance_meters: Option<f64>,
     ) -> Result<SessionExercise, sqlx::Error>
     where
         E: sqlx::Executor<'c, Database = Postgres>,
     {
         sqlx::query_as::<_, SessionExercise>(
-            r#"INSERT INTO session_exercises
-            (session, exercise, name, notes, exercise_type, position, sets, reps, weight, duration_seconds, distance_meters)
-            VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *"#,
+            "INSERT INTO session_exercises (session, exercise, name, notes, exercise_type, position) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *",
         )
         .bind(session_id)
         .bind(exercise_id)
@@ -97,11 +131,51 @@ impl SessionExercise {
         .bind(notes)
         .bind(exercise_type)
         .bind(position)
-        .bind(sets)
+        .fetch_one(executor)
+        .await
+    }
+}
+
+#[derive(sqlx::FromRow, Serialize, Clone)]
+pub struct SessionSet {
+    pub id: Uuid,
+    pub session_exercise: Uuid,
+    pub reps: Option<i32>,
+    pub weight: Option<f64>,
+    pub duration_seconds: Option<i32>,
+    pub distance_meters: Option<f64>,
+    pub position: i32,
+}
+
+impl SessionSet {
+    pub async fn find_by_session_exercise(pool: &PgPool, session_exercise_id: Uuid) -> Result<Vec<SessionSet>, sqlx::Error> {
+        sqlx::query_as::<_, SessionSet>("SELECT * FROM session_sets WHERE session_exercise = $1 ORDER BY position")
+            .bind(session_exercise_id)
+            .fetch_all(pool)
+            .await
+    }
+
+    pub async fn create<'c, E>(
+        executor: E,
+        session_exercise_id: Uuid,
+        reps: Option<i32>,
+        weight: Option<f64>,
+        duration_seconds: Option<i32>,
+        distance_meters: Option<f64>,
+        position: i32,
+    ) -> Result<SessionSet, sqlx::Error>
+    where
+        E: sqlx::Executor<'c, Database = Postgres>,
+    {
+        sqlx::query_as::<_, SessionSet>(
+            "INSERT INTO session_sets (session_exercise, reps, weight, duration_seconds, distance_meters, position) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *",
+        )
+        .bind(session_exercise_id)
         .bind(reps)
         .bind(weight)
         .bind(duration_seconds)
         .bind(distance_meters)
+        .bind(position)
         .fetch_one(executor)
         .await
     }

+ 46 - 2
src/routes/workouts/create.rs

@@ -1,7 +1,9 @@
 use actix_web::{post, web, HttpResponse};
-use serde::Deserialize;
+use serde::{Deserialize, Serialize};
 use serde_json::json;
 use sqlx::PgPool;
+use std::collections::HashSet;
+use uuid::Uuid;
 
 use crate::auth::AuthUser;
 use crate::models::exercise::{Exercise, ExerciseType};
@@ -20,12 +22,40 @@ pub struct CreateWorkoutInput {
     pub exercises: Vec<CreateExerciseInput>,
 }
 
+#[derive(Serialize)]
+struct ExerciseResponse {
+    id: Uuid,
+    name: String,
+    notes: Option<String>,
+    exercise_type: ExerciseType,
+    position: i32,
+}
+
+impl From<Exercise> for ExerciseResponse {
+    fn from(e: Exercise) -> Self {
+        Self {
+            id: e.id,
+            name: e.name,
+            notes: e.notes,
+            exercise_type: e.exercise_type,
+            position: e.position,
+        }
+    }
+}
+
 #[post("/workout")]
 pub async fn create_workout(
     pool: web::Data<PgPool>,
     auth: AuthUser,
     body: web::Json<CreateWorkoutInput>,
 ) -> HttpResponse {
+    let positions: HashSet<i32> = body.exercises.iter().map(|e| e.position).collect();
+    if positions.len() != body.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) => {
@@ -56,5 +86,19 @@ pub async fn create_workout(
         return HttpResponse::InternalServerError().json(json!({"error": "An error occurred"}));
     }
 
-    HttpResponse::Created().json(json!({"id": workout.id}))
+    let exercises = match Exercise::find_by_workout(pool.get_ref(), workout.id).await {
+        Ok(e) => e,
+        Err(e) => {
+            eprintln!("create_workout fetch exercises error: {e}");
+            return HttpResponse::InternalServerError().json(json!({"error": "An error occurred"}));
+        }
+    };
+
+    let exercises: Vec<ExerciseResponse> = exercises.into_iter().map(Into::into).collect();
+
+    HttpResponse::Created().json(json!({
+        "id": workout.id,
+        "name": workout.name,
+        "exercises": exercises,
+    }))
 }

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

@@ -2,4 +2,5 @@ pub mod create;
 pub mod delete;
 pub mod get;
 pub mod list;
+pub mod sessions;
 pub mod update;

+ 143 - 0
src/routes/workouts/sessions/create.rs

@@ -0,0 +1,143 @@
+use actix_web::{post, web, HttpResponse};
+use serde::Deserialize;
+use serde_json::json;
+use sqlx::PgPool;
+use time::OffsetDateTime;
+use uuid::Uuid;
+
+use crate::auth::AuthUser;
+use crate::models::exercise::Exercise;
+use crate::models::session::{Session, SessionExercise, SessionSet};
+use crate::models::workout::Workout;
+
+#[derive(Deserialize, Clone)]
+pub struct SetInput {
+    pub reps: Option<i32>,
+    pub weight: Option<f64>,
+    pub duration_seconds: Option<i32>,
+    pub distance_meters: Option<f64>,
+}
+
+#[derive(Deserialize)]
+pub struct CreateSessionExerciseInput {
+    pub exercise_id: Uuid,
+    pub sets: Option<Vec<SetInput>>,
+}
+
+#[derive(Deserialize)]
+pub struct CreateSessionInput {
+    #[serde(with = "time::serde::rfc3339")]
+    pub start_time: OffsetDateTime,
+    #[serde(with = "time::serde::rfc3339")]
+    pub end_time: OffsetDateTime,
+    pub exercises: Vec<CreateSessionExerciseInput>,
+}
+
+#[post("/workout/{workout_id}/session")]
+pub async fn create_session(
+    pool: web::Data<PgPool>,
+    auth: AuthUser,
+    path: web::Path<Uuid>,
+    body: web::Json<CreateSessionInput>,
+) -> HttpResponse {
+    let workout_id = path.into_inner();
+
+    let workout = match Workout::find_by_id(pool.get_ref(), workout_id).await {
+        Ok(Some(w)) => w,
+        Ok(None) => return HttpResponse::NotFound().json(json!({"error": "Workout not found"})),
+        Err(e) => {
+            eprintln!("create_session find workout 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 access this workout"}));
+    }
+
+    // Validate all referenced exercises belong to this workout (before tx)
+    for exercise_input in &body.exercises {
+        let ex = match Exercise::find_by_id(pool.get_ref(), exercise_input.exercise_id).await {
+            Ok(Some(e)) => e,
+            Ok(None) => return HttpResponse::NotFound().json(json!({"error": "Exercise not found"})),
+            Err(e) => {
+                eprintln!("create_session find exercise error: {e}");
+                return HttpResponse::InternalServerError().json(json!({"error": "An error occurred"}));
+            }
+        };
+        if ex.workout != workout_id {
+            return HttpResponse::UnprocessableEntity()
+                .json(json!({"error": "Exercise does not belong to this workout"}));
+        }
+    }
+
+    let mut tx = match pool.begin().await {
+        Ok(t) => t,
+        Err(e) => {
+            eprintln!("create_session begin tx error: {e}");
+            return HttpResponse::InternalServerError().json(json!({"error": "An error occurred"}));
+        }
+    };
+
+    let session = match Session::create(&mut *tx, workout_id, auth.0.id, body.start_time, body.end_time).await {
+        Ok(s) => s,
+        Err(e) => {
+            eprintln!("create_session insert session error: {e}");
+            return HttpResponse::InternalServerError()
+                .json(json!({"error": "Failed to create session"}));
+        }
+    };
+
+    for exercise_input in &body.exercises {
+        let ex = match Exercise::find_by_id(pool.get_ref(), exercise_input.exercise_id).await {
+            Ok(Some(e)) => e,
+            _ => continue,
+        };
+        let session_exercise = match SessionExercise::create(
+            &mut *tx,
+            session.id,
+            ex.id,
+            &ex.name,
+            ex.notes.as_deref(),
+            ex.exercise_type.clone(),
+            ex.position,
+        )
+        .await
+        {
+            Ok(se) => se,
+            Err(e) => {
+                eprintln!("create_session insert exercise error: {e}");
+                return HttpResponse::InternalServerError()
+                    .json(json!({"error": "Failed to create session exercise"}));
+            }
+        };
+
+        if let Some(sets) = &exercise_input.sets {
+            for (idx, set) in sets.iter().enumerate() {
+                if let Err(e) = SessionSet::create(
+                    &mut *tx,
+                    session_exercise.id,
+                    set.reps,
+                    set.weight,
+                    set.duration_seconds,
+                    set.distance_meters,
+                    idx as i32 + 1,
+                )
+                .await
+                {
+                    eprintln!("create_session insert set error: {e}");
+                    return HttpResponse::InternalServerError()
+                        .json(json!({"error": "Failed to create session set"}));
+                }
+            }
+        }
+    }
+
+    if let Err(e) = tx.commit().await {
+        eprintln!("create_session commit error: {e}");
+        return HttpResponse::InternalServerError().json(json!({"error": "An error occurred"}));
+    }
+
+    HttpResponse::Created().json(json!({"id": session.id}))
+}

+ 56 - 0
src/routes/workouts/sessions/delete.rs

@@ -0,0 +1,56 @@
+use actix_web::{delete, web, HttpResponse};
+use serde_json::json;
+use sqlx::PgPool;
+use uuid::Uuid;
+
+use crate::auth::AuthUser;
+use crate::models::session::Session;
+use crate::models::workout::Workout;
+
+#[delete("/workout/{workout_id}/session/{session_id}")]
+pub async fn delete_session(
+    pool: web::Data<PgPool>,
+    auth: AuthUser,
+    path: web::Path<(Uuid, Uuid)>,
+) -> HttpResponse {
+    let (workout_id, session_id) = path.into_inner();
+
+    let workout = match Workout::find_by_id(pool.get_ref(), workout_id).await {
+        Ok(Some(w)) => w,
+        Ok(None) => return HttpResponse::NotFound().json(json!({"error": "Workout not found"})),
+        Err(e) => {
+            eprintln!("delete_session find workout 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 access this workout"}));
+    }
+
+    let session = match Session::find_by_id(pool.get_ref(), session_id).await {
+        Ok(Some(s)) => s,
+        Ok(None) => return HttpResponse::NotFound().json(json!({"error": "Session not found"})),
+        Err(e) => {
+            eprintln!("delete_session find error: {e}");
+            return HttpResponse::InternalServerError().json(json!({"error": "An error occurred"}));
+        }
+    };
+
+    if session.workout != workout_id {
+        return HttpResponse::NotFound().json(json!({"error": "Session not found"}));
+    }
+
+    match sqlx::query("DELETE FROM sessions WHERE id = $1")
+        .bind(session_id)
+        .execute(pool.get_ref())
+        .await
+    {
+        Ok(_) => HttpResponse::Ok().json(json!({"success": true})),
+        Err(e) => {
+            eprintln!("delete_session error: {e}");
+            HttpResponse::InternalServerError().json(json!({"error": "Failed to delete session"}))
+        }
+    }
+}

+ 127 - 0
src/routes/workouts/sessions/get.rs

@@ -0,0 +1,127 @@
+use actix_web::{get, web, HttpResponse};
+use serde::Serialize;
+use serde_json::json;
+use sqlx::PgPool;
+use time::OffsetDateTime;
+use uuid::Uuid;
+
+use crate::auth::AuthUser;
+use crate::models::exercise::ExerciseType;
+use crate::models::session::{Session, SessionExercise, SessionSet};
+use crate::models::workout::Workout;
+
+#[derive(Serialize)]
+struct SessionSetResponse {
+    reps: Option<i32>,
+    weight: Option<f64>,
+    duration_seconds: Option<i32>,
+    distance_meters: Option<f64>,
+}
+
+#[derive(Serialize)]
+struct SessionExerciseResponse {
+    id: Uuid,
+    exercise: Uuid,
+    name: String,
+    notes: Option<String>,
+    exercise_type: ExerciseType,
+    position: i32,
+    sets: Vec<SessionSetResponse>,
+}
+
+impl From<SessionExercise> for SessionExerciseResponse {
+    fn from(e: SessionExercise) -> Self {
+        Self {
+            id: e.id,
+            exercise: e.exercise,
+            name: e.name,
+            notes: e.notes,
+            exercise_type: e.exercise_type,
+            position: e.position,
+            sets: vec![], // populated later
+        }
+    }
+}
+
+#[derive(Serialize)]
+struct FullSessionResponse {
+    id: Uuid,
+    workout: Uuid,
+    #[serde(with = "time::serde::rfc3339")]
+    start_time: OffsetDateTime,
+    #[serde(with = "time::serde::rfc3339")]
+    end_time: OffsetDateTime,
+    exercises: Vec<SessionExerciseResponse>,
+}
+
+#[get("/workout/{workout_id}/session/{session_id}")]
+pub async fn get_session(
+    pool: web::Data<PgPool>,
+    auth: AuthUser,
+    path: web::Path<(Uuid, Uuid)>,
+) -> HttpResponse {
+    let (workout_id, session_id) = path.into_inner();
+
+    let workout = match Workout::find_by_id(pool.get_ref(), workout_id).await {
+        Ok(Some(w)) => w,
+        Ok(None) => return HttpResponse::NotFound().json(json!({"error": "Workout not found"})),
+        Err(e) => {
+            eprintln!("get_session find workout 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 access this workout"}));
+    }
+
+    let session = match Session::find_by_id(pool.get_ref(), session_id).await {
+        Ok(Some(s)) => s,
+        Ok(None) => return HttpResponse::NotFound().json(json!({"error": "Session not found"})),
+        Err(e) => {
+            eprintln!("get_session find error: {e}");
+            return HttpResponse::InternalServerError().json(json!({"error": "An error occurred"}));
+        }
+    };
+
+    if session.workout != workout_id {
+        return HttpResponse::NotFound().json(json!({"error": "Session not found"}));
+    }
+
+    let exercises = match SessionExercise::find_by_session(pool.get_ref(), session_id).await {
+        Ok(e) => e,
+        Err(e) => {
+            eprintln!("get_session exercises error: {e}");
+            return HttpResponse::InternalServerError().json(json!({"error": "An error occurred"}));
+        }
+    };
+
+    let mut exercises_resp: Vec<SessionExerciseResponse> = exercises.into_iter().map(Into::into).collect();
+
+    for ex_resp in &mut exercises_resp {
+        let sets = match SessionSet::find_by_session_exercise(pool.get_ref(), ex_resp.id).await {
+            Ok(s) => s,
+            Err(e) => {
+                eprintln!("get_session sets error: {e}");
+                return HttpResponse::InternalServerError().json(json!({"error": "An error occurred"}));
+            }
+        };
+        ex_resp.sets = sets.into_iter().map(|s| SessionSetResponse {
+            reps: s.reps,
+            weight: s.weight,
+            duration_seconds: s.duration_seconds,
+            distance_meters: s.distance_meters,
+        }).collect();
+    }
+
+    let response = FullSessionResponse {
+        id: session.id,
+        workout: session.workout,
+        start_time: session.start_time,
+        end_time: session.end_time,
+        exercises: exercises_resp,
+    };
+
+    HttpResponse::Ok().json(response)
+}

+ 119 - 0
src/routes/workouts/sessions/list.rs

@@ -0,0 +1,119 @@
+use actix_web::{get, web, HttpResponse};
+use serde::{Deserialize, Serialize};
+use serde_json::json;
+use sqlx::PgPool;
+use time::format_description::well_known::Rfc3339;
+use time::OffsetDateTime;
+use uuid::Uuid;
+
+use crate::auth::AuthUser;
+use crate::models::session::{Session, Session as SessionModel};
+use crate::models::workout::Workout;
+
+#[derive(Serialize)]
+struct SessionResponse {
+    id: Uuid,
+    workout: Uuid,
+    #[serde(with = "time::serde::rfc3339")]
+    start_time: OffsetDateTime,
+    #[serde(with = "time::serde::rfc3339")]
+    end_time: OffsetDateTime,
+}
+
+impl From<Session> for SessionResponse {
+    fn from(s: Session) -> Self {
+        Self {
+            id: s.id,
+            workout: s.workout,
+            start_time: s.start_time,
+            end_time: s.end_time,
+        }
+    }
+}
+
+#[derive(Deserialize)]
+struct ListSessionsQuery {
+    start: Option<String>,
+    end: Option<String>,
+    page: Option<u32>,
+}
+
+#[get("/workout/{workout_id}/session")]
+pub async fn list_sessions(
+    pool: web::Data<PgPool>,
+    auth: AuthUser,
+    path: web::Path<Uuid>,
+    query: web::Query<ListSessionsQuery>,
+) -> HttpResponse {
+    let workout_id = path.into_inner();
+
+    let workout = match Workout::find_by_id(pool.get_ref(), workout_id).await {
+        Ok(Some(w)) => w,
+        Ok(None) => return HttpResponse::NotFound().json(json!({"error": "Workout not found"})),
+        Err(e) => {
+            eprintln!("list_sessions find workout 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 access this workout"}));
+    }
+
+    let start = match &query.start {
+        Some(s) => match OffsetDateTime::parse(s, &Rfc3339) {
+            Ok(dt) => Some(dt),
+            Err(_) => {
+                return HttpResponse::BadRequest().json(json!({"error": "Invalid start date"}));
+            }
+        },
+        None => None,
+    };
+
+    let end = match &query.end {
+        Some(s) => match OffsetDateTime::parse(s, &Rfc3339) {
+            Ok(dt) => Some(dt),
+            Err(_) => {
+                return HttpResponse::BadRequest().json(json!({"error": "Invalid end date"}));
+            }
+        },
+        None => None,
+    };
+
+    let page = query.page.unwrap_or(1).max(1) as i64;
+    let limit: i64 = 50;
+    let offset = (page - 1) * limit;
+
+    let total = match SessionModel::count_by_workout(pool.get_ref(), workout_id, start, end).await {
+        Ok(t) => t,
+        Err(e) => {
+            eprintln!("list_sessions count error: {e}");
+            return HttpResponse::InternalServerError().json(json!({"error": "An error occurred"}));
+        }
+    };
+
+    let sessions = match SessionModel::find_by_workout_paginated(
+        pool.get_ref(),
+        workout_id,
+        start,
+        end,
+        limit,
+        offset,
+    )
+    .await
+    {
+        Ok(s) => s,
+        Err(e) => {
+            eprintln!("list_sessions find error: {e}");
+            return HttpResponse::InternalServerError().json(json!({"error": "An error occurred"}));
+        }
+    };
+
+    let sessions: Vec<SessionResponse> = sessions.into_iter().map(Into::into).collect();
+
+    HttpResponse::Ok().json(json!({
+        "sessions": sessions,
+        "total": total,
+    }))
+}

+ 4 - 0
src/routes/workouts/sessions/mod.rs

@@ -0,0 +1,4 @@
+pub mod create;
+pub mod delete;
+pub mod get;
+pub mod list;