diff --git a/README.md b/README.md new file mode 100644 index 0000000..c275a5d --- /dev/null +++ b/README.md @@ -0,0 +1,438 @@ +# Workout Logger Demo + +A RESTful API for tracking workouts, exercises, and fitness progress. + +## Features + +- User registration and authentication +- Athlete profile management +- Exercise library with predefined and custom exercises +- Workout tracking with sets, reps, weight, duration, and distance +- Search and filter capabilities for workouts and exercises + +## Technology Stack + +- **Backend**: Kotlin with Spring Boot +- **Database**: PostgreSQL (H2 for development) +- **Security**: Spring Security +- **Build Tool**: Gradle with Kotlin DSL + +## API Documentation + +### Authentication + +#### Register a new user + +``` +POST /api/users/register +``` + +Request body: +```json +{ + "username": "john_doe", + "email": "john@example.com", + "password": "password123" +} +``` + +#### Login + +``` +POST /api/auth/login +``` + +Request body: +```json +{ + "username": "john_doe", + "password": "password123" +} +``` + +### User Management + +#### Get all users + +``` +GET /api/users +``` + +#### Get user by ID + +``` +GET /api/users/{id} +``` + +#### Update user + +``` +PUT /api/users/{id} +``` + +Request body: +```json +{ + "username": "john_updated", + "email": "john_updated@example.com", + "password": "newpassword123" +} +``` + +#### Delete user + +``` +DELETE /api/users/{id} +``` + +### Athlete Management + +#### Get all athletes + +``` +GET /api/athletes +``` + +#### Get athlete by ID + +``` +GET /api/athletes/{id} +``` + +#### Get athlete by user ID + +``` +GET /api/athletes/user/{userId} +``` + +#### Create athlete + +``` +POST /api/athletes +``` + +Request body: +```json +{ + "userId": 1, + "height": 180.5, + "weight": 75.0, + "fitnessGoals": "Build muscle and improve endurance" +} +``` + +#### Update athlete + +``` +PUT /api/athletes/{id} +``` + +Request body: +```json +{ + "height": 181.0, + "weight": 74.5, + "fitnessGoals": "Maintain weight and improve strength" +} +``` + +#### Update athlete by user ID + +``` +PUT /api/athletes/user/{userId} +``` + +Request body: +```json +{ + "height": 181.0, + "weight": 74.5, + "fitnessGoals": "Maintain weight and improve strength" +} +``` + +#### Delete athlete + +``` +DELETE /api/athletes/{id} +``` + +### Exercise Management + +#### Get all exercises + +``` +GET /api/exercises +``` + +#### Get exercise by ID + +``` +GET /api/exercises/{id} +``` + +#### Get exercises by category + +``` +GET /api/exercises/category/{category} +``` + +Categories: STRENGTH, CARDIO, FLEXIBILITY, BALANCE + +#### Get custom exercises for user + +``` +GET /api/exercises/user/{userId}/custom +``` + +#### Get all available exercises for user + +``` +GET /api/exercises/user/{userId}/available +``` + +#### Search exercises by name + +``` +GET /api/exercises/search?searchTerm=push +``` + +#### Create exercise + +``` +POST /api/exercises +``` + +Request body: +```json +{ + "name": "Custom Push-up", + "description": "A variation of the standard push-up", + "category": "STRENGTH", + "muscleGroups": ["CHEST", "SHOULDERS", "TRICEPS"], + "isCustom": true, + "userId": 1 +} +``` + +#### Update exercise + +``` +PUT /api/exercises/{id} +``` + +Request body: +```json +{ + "name": "Modified Push-up", + "description": "An updated description", + "category": "STRENGTH", + "muscleGroups": ["CHEST", "SHOULDERS", "TRICEPS", "CORE"] +} +``` + +#### Delete exercise + +``` +DELETE /api/exercises/{id} +``` + +### Workout Management + +#### Get all workouts + +``` +GET /api/workouts +``` + +#### Get workout by ID + +``` +GET /api/workouts/{id} +``` + +#### Get workouts by user ID + +``` +GET /api/workouts/user/{userId} +``` + +#### Get workouts by date range + +``` +GET /api/workouts/user/{userId}/date-range?startDate=2023-01-01&endDate=2023-12-31 +``` + +#### Get recent workouts + +``` +GET /api/workouts/user/{userId}/recent?limit=5 +``` + +#### Search workouts by name + +``` +GET /api/workouts/search?userId=1&searchTerm=morning +``` + +#### Create workout + +``` +POST /api/workouts +``` + +Request body: +```json +{ + "name": "Morning Routine", + "userId": 1, + "date": "2023-05-15", + "durationSeconds": 1800, + "notes": "Felt great today" +} +``` + +#### Update workout + +``` +PUT /api/workouts/{id} +``` + +Request body: +```json +{ + "name": "Updated Routine", + "date": "2023-05-16", + "durationSeconds": 2000, + "notes": "Modified workout" +} +``` + +#### Delete workout + +``` +DELETE /api/workouts/{id} +``` + +### Workout Exercise Management + +#### Get workout exercises + +``` +GET /api/workouts/{workoutId}/exercises +``` + +#### Add exercise to workout + +``` +POST /api/workouts/{workoutId}/exercises +``` + +Request body: +```json +{ + "exerciseId": 1, + "order": 1 +} +``` + +#### Update workout exercise + +``` +PUT /api/workouts/exercises/{workoutExerciseId} +``` + +Request body: +```json +{ + "order": 2 +} +``` + +#### Remove exercise from workout + +``` +DELETE /api/workouts/exercises/{workoutExerciseId} +``` + +### Exercise Set Management + +#### Get exercise sets + +``` +GET /api/workouts/exercises/{workoutExerciseId}/sets +``` + +#### Add set to exercise + +``` +POST /api/workouts/exercises/{workoutExerciseId}/sets +``` + +Request body: +```json +{ + "reps": 12, + "weight": 50.0, + "durationSeconds": null, + "distance": null, + "completed": true, + "order": 1 +} +``` + +#### Update exercise set + +``` +PUT /api/workouts/sets/{setId} +``` + +Request body: +```json +{ + "reps": 15, + "weight": 55.0, + "completed": true, + "order": 1 +} +``` + +#### Delete exercise set + +``` +DELETE /api/workouts/sets/{setId} +``` + +## Getting Started + +### Prerequisites + +- JDK 17 or higher +- Docker (for PostgreSQL) + +### Running the Application + +1. Clone the repository +2. Start the PostgreSQL database: + ``` + docker-compose up -d + ``` +3. Run the application: + ``` + ./gradlew bootRun + ``` +4. The API will be available at http://localhost:8080 + +## Development + +### Building the Application + +``` +./gradlew build +``` + +### Running Tests + +``` +./gradlew test +``` \ No newline at end of file diff --git a/src/main/kotlin/com/example/workoutloggerdemo/config/SecurityConfig.kt b/src/main/kotlin/com/example/workoutloggerdemo/config/SecurityConfig.kt new file mode 100644 index 0000000..36d093b --- /dev/null +++ b/src/main/kotlin/com/example/workoutloggerdemo/config/SecurityConfig.kt @@ -0,0 +1,40 @@ +package com.example.workoutloggerdemo.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.http.SessionCreationPolicy +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.security.web.SecurityFilterChain + +@Configuration +@EnableWebSecurity +class SecurityConfig { + + @Bean + fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http + .csrf { it.disable() } + .authorizeHttpRequests { authorize -> + authorize + .requestMatchers("/api/users/register").permitAll() + .requestMatchers("/api/auth/login").permitAll() + .requestMatchers("/h2-console/**").permitAll() // For development only + .anyRequest().permitAll() // For development, allow all requests without authentication + // In production, use: .anyRequest().authenticated() + } + .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } + .headers { headers -> + headers.frameOptions { it.sameOrigin() } // For H2 console + } + + return http.build() + } + + @Bean + fun passwordEncoder(): PasswordEncoder { + return BCryptPasswordEncoder() + } +} diff --git a/src/main/kotlin/com/example/workoutloggerdemo/controller/AthleteController.kt b/src/main/kotlin/com/example/workoutloggerdemo/controller/AthleteController.kt new file mode 100644 index 0000000..87284d3 --- /dev/null +++ b/src/main/kotlin/com/example/workoutloggerdemo/controller/AthleteController.kt @@ -0,0 +1,110 @@ +package com.example.workoutloggerdemo.controller + +import com.example.workoutloggerdemo.model.Athlete +import com.example.workoutloggerdemo.service.AthleteService +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* +import org.springframework.web.server.ResponseStatusException + +@RestController +@RequestMapping("/api/athletes") +class AthleteController(private val athleteService: AthleteService) { + + @GetMapping + fun getAllAthletes(): ResponseEntity> { + return ResponseEntity.ok(athleteService.getAllAthletes()) + } + + @GetMapping("/{id}") + fun getAthleteById(@PathVariable id: Long): ResponseEntity { + return try { + ResponseEntity.ok(athleteService.findById(id)) + } catch (e: NoSuchElementException) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, e.message) + } + } + + @GetMapping("/user/{userId}") + fun getAthleteByUserId(@PathVariable userId: Long): ResponseEntity { + return try { + ResponseEntity.ok(athleteService.findByUserId(userId)) + } catch (e: NoSuchElementException) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, e.message) + } + } + + @PostMapping + fun createAthlete(@RequestBody request: CreateAthleteRequest): ResponseEntity { + return try { + val athlete = athleteService.createAthlete( + userId = request.userId, + height = request.height, + weight = request.weight, + fitnessGoals = request.fitnessGoals + ) + ResponseEntity.status(HttpStatus.CREATED).body(athlete) + } catch (e: IllegalArgumentException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message) + } + } + + @PutMapping("/{id}") + fun updateAthlete( + @PathVariable id: Long, + @RequestBody request: UpdateAthleteRequest + ): ResponseEntity { + return try { + val athlete = athleteService.updateAthlete( + id = id, + height = request.height, + weight = request.weight, + fitnessGoals = request.fitnessGoals + ) + ResponseEntity.ok(athlete) + } catch (e: NoSuchElementException) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, e.message) + } + } + + @PutMapping("/user/{userId}") + fun updateAthleteByUserId( + @PathVariable userId: Long, + @RequestBody request: UpdateAthleteRequest + ): ResponseEntity { + return try { + val athlete = athleteService.updateAthleteByUserId( + userId = userId, + height = request.height, + weight = request.weight, + fitnessGoals = request.fitnessGoals + ) + ResponseEntity.ok(athlete) + } catch (e: NoSuchElementException) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, e.message) + } + } + + @DeleteMapping("/{id}") + fun deleteAthlete(@PathVariable id: Long): ResponseEntity { + return try { + athleteService.deleteAthlete(id) + ResponseEntity.noContent().build() + } catch (e: NoSuchElementException) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, e.message) + } + } +} + +data class CreateAthleteRequest( + val userId: Long, + val height: Double? = null, + val weight: Double? = null, + val fitnessGoals: String? = null +) + +data class UpdateAthleteRequest( + val height: Double? = null, + val weight: Double? = null, + val fitnessGoals: String? = null +) \ No newline at end of file diff --git a/src/main/kotlin/com/example/workoutloggerdemo/controller/AuthController.kt b/src/main/kotlin/com/example/workoutloggerdemo/controller/AuthController.kt new file mode 100644 index 0000000..751fb58 --- /dev/null +++ b/src/main/kotlin/com/example/workoutloggerdemo/controller/AuthController.kt @@ -0,0 +1,56 @@ +package com.example.workoutloggerdemo.controller + +import com.example.workoutloggerdemo.service.UserService +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.security.authentication.BadCredentialsException +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ResponseStatusException + +@RestController +@RequestMapping("/api/auth") +class AuthController( + private val userService: UserService, + private val passwordEncoder: PasswordEncoder +) { + + @PostMapping("/login") + fun login(@RequestBody request: LoginRequest): ResponseEntity { + try { + val user = userService.findByUsername(request.username) + + if (!passwordEncoder.matches(request.password, user.passwordHash)) { + throw BadCredentialsException("Invalid password") + } + + // In a real application, we would generate and return a JWT token here + // For simplicity, we'll just return a success message with the user ID + + return ResponseEntity.ok(LoginResponse( + userId = user.id!!, + username = user.username, + message = "Login successful" + )) + + } catch (e: NoSuchElementException) { + throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid username or password") + } catch (e: BadCredentialsException) { + throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid username or password") + } + } +} + +data class LoginRequest( + val username: String, + val password: String +) + +data class LoginResponse( + val userId: Long, + val username: String, + val message: String +) \ No newline at end of file diff --git a/src/main/kotlin/com/example/workoutloggerdemo/controller/ExerciseController.kt b/src/main/kotlin/com/example/workoutloggerdemo/controller/ExerciseController.kt new file mode 100644 index 0000000..81a85f5 --- /dev/null +++ b/src/main/kotlin/com/example/workoutloggerdemo/controller/ExerciseController.kt @@ -0,0 +1,121 @@ +package com.example.workoutloggerdemo.controller + +import com.example.workoutloggerdemo.model.Exercise +import com.example.workoutloggerdemo.model.ExerciseCategory +import com.example.workoutloggerdemo.model.MuscleGroup +import com.example.workoutloggerdemo.service.ExerciseService +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* +import org.springframework.web.server.ResponseStatusException + +@RestController +@RequestMapping("/api/exercises") +class ExerciseController(private val exerciseService: ExerciseService) { + + @GetMapping + fun getAllExercises(): ResponseEntity> { + return ResponseEntity.ok(exerciseService.getAllExercises()) + } + + @GetMapping("/{id}") + fun getExerciseById(@PathVariable id: Long): ResponseEntity { + return try { + ResponseEntity.ok(exerciseService.findById(id)) + } catch (e: NoSuchElementException) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, e.message) + } + } + + @GetMapping("/category/{category}") + fun getExercisesByCategory(@PathVariable category: ExerciseCategory): ResponseEntity> { + return ResponseEntity.ok(exerciseService.findByCategory(category)) + } + + @GetMapping("/user/{userId}/custom") + fun getCustomExercisesForUser(@PathVariable userId: Long): ResponseEntity> { + return try { + ResponseEntity.ok(exerciseService.findCustomExercisesForUser(userId)) + } catch (e: IllegalArgumentException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message) + } + } + + @GetMapping("/user/{userId}/available") + fun getAvailableExercisesForUser(@PathVariable userId: Long): ResponseEntity> { + return try { + ResponseEntity.ok(exerciseService.findAllAvailableForUser(userId)) + } catch (e: IllegalArgumentException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message) + } + } + + @GetMapping("/search") + fun searchExercisesByName(@RequestParam searchTerm: String): ResponseEntity> { + return ResponseEntity.ok(exerciseService.searchByName(searchTerm)) + } + + @PostMapping + fun createExercise(@RequestBody request: CreateExerciseRequest): ResponseEntity { + return try { + val exercise = exerciseService.createExercise( + name = request.name, + description = request.description, + category = request.category, + muscleGroups = request.muscleGroups, + isCustom = request.isCustom, + userId = request.userId + ) + ResponseEntity.status(HttpStatus.CREATED).body(exercise) + } catch (e: IllegalArgumentException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message) + } + } + + @PutMapping("/{id}") + fun updateExercise( + @PathVariable id: Long, + @RequestBody request: UpdateExerciseRequest + ): ResponseEntity { + return try { + val exercise = exerciseService.updateExercise( + id = id, + name = request.name, + description = request.description, + category = request.category, + muscleGroups = request.muscleGroups + ) + ResponseEntity.ok(exercise) + } catch (e: NoSuchElementException) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, e.message) + } + } + + @DeleteMapping("/{id}") + fun deleteExercise(@PathVariable id: Long): ResponseEntity { + return try { + exerciseService.deleteExercise(id) + ResponseEntity.noContent().build() + } catch (e: NoSuchElementException) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, e.message) + } catch (e: IllegalArgumentException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message) + } + } +} + +data class CreateExerciseRequest( + val name: String, + val description: String, + val category: ExerciseCategory, + val muscleGroups: List, + val isCustom: Boolean = false, + val userId: Long? = null +) + +data class UpdateExerciseRequest( + val name: String? = null, + val description: String? = null, + val category: ExerciseCategory? = null, + val muscleGroups: List? = null +) \ No newline at end of file diff --git a/src/main/kotlin/com/example/workoutloggerdemo/controller/UserController.kt b/src/main/kotlin/com/example/workoutloggerdemo/controller/UserController.kt new file mode 100644 index 0000000..093bcd3 --- /dev/null +++ b/src/main/kotlin/com/example/workoutloggerdemo/controller/UserController.kt @@ -0,0 +1,83 @@ +package com.example.workoutloggerdemo.controller + +import com.example.workoutloggerdemo.model.User +import com.example.workoutloggerdemo.service.UserService +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* +import org.springframework.web.server.ResponseStatusException + +@RestController +@RequestMapping("/api/users") +class UserController(private val userService: UserService) { + + @GetMapping + fun getAllUsers(): ResponseEntity> { + return ResponseEntity.ok(userService.getAllUsers()) + } + + @GetMapping("/{id}") + fun getUserById(@PathVariable id: Long): ResponseEntity { + return try { + ResponseEntity.ok(userService.findById(id)) + } catch (e: NoSuchElementException) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, e.message) + } + } + + @PostMapping("/register") + fun registerUser(@RequestBody request: RegisterUserRequest): ResponseEntity { + return try { + val user = userService.createUser( + username = request.username, + email = request.email, + password = request.password + ) + ResponseEntity.status(HttpStatus.CREATED).body(user) + } catch (e: IllegalArgumentException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message) + } + } + + @PutMapping("/{id}") + fun updateUser( + @PathVariable id: Long, + @RequestBody request: UpdateUserRequest + ): ResponseEntity { + return try { + val user = userService.updateUser( + id = id, + username = request.username, + email = request.email, + password = request.password + ) + ResponseEntity.ok(user) + } catch (e: NoSuchElementException) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, e.message) + } catch (e: IllegalArgumentException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message) + } + } + + @DeleteMapping("/{id}") + fun deleteUser(@PathVariable id: Long): ResponseEntity { + return try { + userService.deleteUser(id) + ResponseEntity.noContent().build() + } catch (e: NoSuchElementException) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, e.message) + } + } +} + +data class RegisterUserRequest( + val username: String, + val email: String, + val password: String +) + +data class UpdateUserRequest( + val username: String? = null, + val email: String? = null, + val password: String? = null +) \ No newline at end of file diff --git a/src/main/kotlin/com/example/workoutloggerdemo/controller/WorkoutController.kt b/src/main/kotlin/com/example/workoutloggerdemo/controller/WorkoutController.kt new file mode 100644 index 0000000..d019d4b --- /dev/null +++ b/src/main/kotlin/com/example/workoutloggerdemo/controller/WorkoutController.kt @@ -0,0 +1,282 @@ +package com.example.workoutloggerdemo.controller + +import com.example.workoutloggerdemo.model.ExerciseSet +import com.example.workoutloggerdemo.model.Workout +import com.example.workoutloggerdemo.model.WorkoutExercise +import com.example.workoutloggerdemo.service.WorkoutService +import org.springframework.format.annotation.DateTimeFormat +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* +import org.springframework.web.server.ResponseStatusException +import java.time.Duration +import java.time.LocalDate + +@RestController +@RequestMapping("/api/workouts") +class WorkoutController(private val workoutService: WorkoutService) { + + @GetMapping + fun getAllWorkouts(): ResponseEntity> { + return ResponseEntity.ok(workoutService.getAllWorkouts()) + } + + @GetMapping("/{id}") + fun getWorkoutById(@PathVariable id: Long): ResponseEntity { + return try { + ResponseEntity.ok(workoutService.findById(id)) + } catch (e: NoSuchElementException) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, e.message) + } + } + + @GetMapping("/user/{userId}") + fun getWorkoutsByUserId(@PathVariable userId: Long): ResponseEntity> { + return try { + ResponseEntity.ok(workoutService.findByUserId(userId)) + } catch (e: IllegalArgumentException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message) + } + } + + @GetMapping("/user/{userId}/date-range") + fun getWorkoutsByDateRange( + @PathVariable userId: Long, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) startDate: LocalDate, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) endDate: LocalDate + ): ResponseEntity> { + return try { + ResponseEntity.ok(workoutService.findByUserIdAndDateRange(userId, startDate, endDate)) + } catch (e: IllegalArgumentException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message) + } + } + + @GetMapping("/user/{userId}/recent") + fun getRecentWorkouts( + @PathVariable userId: Long, + @RequestParam(defaultValue = "10") limit: Int + ): ResponseEntity> { + return try { + ResponseEntity.ok(workoutService.findRecentWorkouts(userId, limit)) + } catch (e: IllegalArgumentException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message) + } + } + + @GetMapping("/search") + fun searchWorkouts( + @RequestParam userId: Long, + @RequestParam searchTerm: String + ): ResponseEntity> { + return try { + ResponseEntity.ok(workoutService.searchWorkoutsByName(userId, searchTerm)) + } catch (e: IllegalArgumentException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message) + } + } + + @PostMapping + fun createWorkout(@RequestBody request: CreateWorkoutRequest): ResponseEntity { + return try { + val workout = workoutService.createWorkout( + name = request.name, + userId = request.userId, + date = request.date, + duration = Duration.ofSeconds(request.durationSeconds), + notes = request.notes + ) + ResponseEntity.status(HttpStatus.CREATED).body(workout) + } catch (e: IllegalArgumentException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message) + } + } + + @PutMapping("/{id}") + fun updateWorkout( + @PathVariable id: Long, + @RequestBody request: UpdateWorkoutRequest + ): ResponseEntity { + return try { + val workout = workoutService.updateWorkout( + id = id, + name = request.name, + date = request.date, + duration = request.durationSeconds?.let { Duration.ofSeconds(it) }, + notes = request.notes + ) + ResponseEntity.ok(workout) + } catch (e: NoSuchElementException) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, e.message) + } + } + + @DeleteMapping("/{id}") + fun deleteWorkout(@PathVariable id: Long): ResponseEntity { + return try { + workoutService.deleteWorkout(id) + ResponseEntity.noContent().build() + } catch (e: NoSuchElementException) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, e.message) + } + } + + // Workout Exercise endpoints + + @GetMapping("/{workoutId}/exercises") + fun getWorkoutExercises(@PathVariable workoutId: Long): ResponseEntity> { + return try { + ResponseEntity.ok(workoutService.getWorkoutExercises(workoutId)) + } catch (e: NoSuchElementException) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, e.message) + } + } + + @PostMapping("/{workoutId}/exercises") + fun addExerciseToWorkout( + @PathVariable workoutId: Long, + @RequestBody request: AddExerciseRequest + ): ResponseEntity { + return try { + val workoutExercise = workoutService.addExerciseToWorkout( + workoutId = workoutId, + exerciseId = request.exerciseId, + order = request.order + ) + ResponseEntity.status(HttpStatus.CREATED).body(workoutExercise) + } catch (e: NoSuchElementException) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, e.message) + } + } + + @PutMapping("/exercises/{workoutExerciseId}") + fun updateWorkoutExercise( + @PathVariable workoutExerciseId: Long, + @RequestBody request: UpdateWorkoutExerciseRequest + ): ResponseEntity { + return try { + val workoutExercise = workoutService.updateWorkoutExercise( + id = workoutExerciseId, + order = request.order + ) + ResponseEntity.ok(workoutExercise) + } catch (e: NoSuchElementException) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, e.message) + } + } + + @DeleteMapping("/exercises/{workoutExerciseId}") + fun removeExerciseFromWorkout(@PathVariable workoutExerciseId: Long): ResponseEntity { + return try { + workoutService.removeExerciseFromWorkout(workoutExerciseId) + ResponseEntity.noContent().build() + } catch (e: NoSuchElementException) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, e.message) + } + } + + // Exercise Set endpoints + + @GetMapping("/exercises/{workoutExerciseId}/sets") + fun getExerciseSets(@PathVariable workoutExerciseId: Long): ResponseEntity> { + return try { + ResponseEntity.ok(workoutService.getExerciseSets(workoutExerciseId)) + } catch (e: NoSuchElementException) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, e.message) + } + } + + @PostMapping("/exercises/{workoutExerciseId}/sets") + fun addSetToExercise( + @PathVariable workoutExerciseId: Long, + @RequestBody request: AddSetRequest + ): ResponseEntity { + return try { + val exerciseSet = workoutService.addSetToExercise( + workoutExerciseId = workoutExerciseId, + reps = request.reps, + weight = request.weight, + duration = request.durationSeconds?.let { Duration.ofSeconds(it) }, + distance = request.distance, + completed = request.completed ?: false, + order = request.order + ) + ResponseEntity.status(HttpStatus.CREATED).body(exerciseSet) + } catch (e: NoSuchElementException) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, e.message) + } + } + + @PutMapping("/sets/{setId}") + fun updateExerciseSet( + @PathVariable setId: Long, + @RequestBody request: UpdateSetRequest + ): ResponseEntity { + return try { + val exerciseSet = workoutService.updateExerciseSet( + id = setId, + reps = request.reps, + weight = request.weight, + duration = request.durationSeconds?.let { Duration.ofSeconds(it) }, + distance = request.distance, + completed = request.completed, + order = request.order + ) + ResponseEntity.ok(exerciseSet) + } catch (e: NoSuchElementException) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, e.message) + } + } + + @DeleteMapping("/sets/{setId}") + fun deleteExerciseSet(@PathVariable setId: Long): ResponseEntity { + return try { + workoutService.deleteExerciseSet(setId) + ResponseEntity.noContent().build() + } catch (e: NoSuchElementException) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, e.message) + } + } +} + +data class CreateWorkoutRequest( + val name: String, + val userId: Long, + val date: LocalDate, + val durationSeconds: Long, + val notes: String? = null +) + +data class UpdateWorkoutRequest( + val name: String? = null, + val date: LocalDate? = null, + val durationSeconds: Long? = null, + val notes: String? = null +) + +data class AddExerciseRequest( + val exerciseId: Long, + val order: Int +) + +data class UpdateWorkoutExerciseRequest( + val order: Int? = null +) + +data class AddSetRequest( + val reps: Int? = null, + val weight: Double? = null, + val durationSeconds: Long? = null, + val distance: Double? = null, + val completed: Boolean? = false, + val order: Int +) + +data class UpdateSetRequest( + val reps: Int? = null, + val weight: Double? = null, + val durationSeconds: Long? = null, + val distance: Double? = null, + val completed: Boolean? = null, + val order: Int? = null +) \ No newline at end of file diff --git a/src/main/kotlin/com/example/workoutloggerdemo/exception/GlobalExceptionHandler.kt b/src/main/kotlin/com/example/workoutloggerdemo/exception/GlobalExceptionHandler.kt new file mode 100644 index 0000000..9ebfe6d --- /dev/null +++ b/src/main/kotlin/com/example/workoutloggerdemo/exception/GlobalExceptionHandler.kt @@ -0,0 +1,69 @@ +package com.example.workoutloggerdemo.exception + +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.ControllerAdvice +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.context.request.WebRequest +import org.springframework.web.server.ResponseStatusException +import java.time.LocalDateTime + +@ControllerAdvice +class GlobalExceptionHandler { + + @ExceptionHandler(NoSuchElementException::class) + fun handleNoSuchElementException(ex: NoSuchElementException, request: WebRequest): ResponseEntity { + val errorResponse = ErrorResponse( + timestamp = LocalDateTime.now(), + status = HttpStatus.NOT_FOUND.value(), + error = "Not Found", + message = ex.message ?: "Resource not found", + path = request.getDescription(false).substring(4) // Remove "uri=" prefix + ) + return ResponseEntity(errorResponse, HttpStatus.NOT_FOUND) + } + + @ExceptionHandler(IllegalArgumentException::class) + fun handleIllegalArgumentException(ex: IllegalArgumentException, request: WebRequest): ResponseEntity { + val errorResponse = ErrorResponse( + timestamp = LocalDateTime.now(), + status = HttpStatus.BAD_REQUEST.value(), + error = "Bad Request", + message = ex.message ?: "Invalid request parameters", + path = request.getDescription(false).substring(4) + ) + return ResponseEntity(errorResponse, HttpStatus.BAD_REQUEST) + } + + @ExceptionHandler(ResponseStatusException::class) + fun handleResponseStatusException(ex: ResponseStatusException, request: WebRequest): ResponseEntity { + val errorResponse = ErrorResponse( + timestamp = LocalDateTime.now(), + status = ex.statusCode.value(), + error = ex.statusCode.toString(), + message = ex.reason ?: "An error occurred", + path = request.getDescription(false).substring(4) + ) + return ResponseEntity(errorResponse, ex.statusCode) + } + + @ExceptionHandler(Exception::class) + fun handleGlobalException(ex: Exception, request: WebRequest): ResponseEntity { + val errorResponse = ErrorResponse( + timestamp = LocalDateTime.now(), + status = HttpStatus.INTERNAL_SERVER_ERROR.value(), + error = "Internal Server Error", + message = ex.message ?: "An unexpected error occurred", + path = request.getDescription(false).substring(4) + ) + return ResponseEntity(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR) + } +} + +data class ErrorResponse( + val timestamp: LocalDateTime, + val status: Int, + val error: String, + val message: String, + val path: String +) \ No newline at end of file diff --git a/src/main/kotlin/com/example/workoutloggerdemo/model/Athlete.kt b/src/main/kotlin/com/example/workoutloggerdemo/model/Athlete.kt index 4bfb4dc..7e0cc38 100644 --- a/src/main/kotlin/com/example/workoutloggerdemo/model/Athlete.kt +++ b/src/main/kotlin/com/example/workoutloggerdemo/model/Athlete.kt @@ -1,4 +1,17 @@ package com.example.workoutloggerdemo.model -class Athlete { -} \ No newline at end of file +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Table +import java.time.LocalDateTime + +@Table("athletes") +data class Athlete( + @Id + val id: Long? = null, + val userId: Long, + val height: Double? = null, + val weight: Double? = null, + val fitnessGoals: String? = null, + val createdAt: LocalDateTime = LocalDateTime.now(), + val updatedAt: LocalDateTime = LocalDateTime.now() +) diff --git a/src/main/kotlin/com/example/workoutloggerdemo/model/Exercise.kt b/src/main/kotlin/com/example/workoutloggerdemo/model/Exercise.kt index d28bb71..1837aeb 100644 --- a/src/main/kotlin/com/example/workoutloggerdemo/model/Exercise.kt +++ b/src/main/kotlin/com/example/workoutloggerdemo/model/Exercise.kt @@ -1,4 +1,36 @@ package com.example.workoutloggerdemo.model -class Exercise { -} \ No newline at end of file +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Table +import java.time.LocalDateTime + +enum class ExerciseCategory { + STRENGTH, + CARDIO, + FLEXIBILITY, + BALANCE +} + +enum class MuscleGroup { + CHEST, + BACK, + SHOULDERS, + BICEPS, + TRICEPS, + LEGS, + CORE, + FULL_BODY +} + +@Table("exercises") +data class Exercise( + @Id + val id: Long? = null, + val name: String, + val description: String, + val category: ExerciseCategory, + val muscleGroups: List, + val isCustom: Boolean = false, + val userId: Long? = null, // Null for predefined exercises + val createdAt: LocalDateTime = LocalDateTime.now() +) diff --git a/src/main/kotlin/com/example/workoutloggerdemo/model/ExerciseSet.kt b/src/main/kotlin/com/example/workoutloggerdemo/model/ExerciseSet.kt new file mode 100644 index 0000000..f21a03b --- /dev/null +++ b/src/main/kotlin/com/example/workoutloggerdemo/model/ExerciseSet.kt @@ -0,0 +1,18 @@ +package com.example.workoutloggerdemo.model + +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Table +import java.time.Duration + +@Table("exercise_sets") +data class ExerciseSet( + @Id + val id: Long? = null, + val workoutExerciseId: Long, + val reps: Int? = null, + val weight: Double? = null, + val duration: Duration? = null, + val distance: Double? = null, + val completed: Boolean = false, + val order: Int +) \ No newline at end of file diff --git a/src/main/kotlin/com/example/workoutloggerdemo/model/User.kt b/src/main/kotlin/com/example/workoutloggerdemo/model/User.kt new file mode 100644 index 0000000..cea71eb --- /dev/null +++ b/src/main/kotlin/com/example/workoutloggerdemo/model/User.kt @@ -0,0 +1,16 @@ +package com.example.workoutloggerdemo.model + +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Table +import java.time.LocalDateTime + +@Table("users") +data class User( + @Id + val id: Long? = null, + val username: String, + val email: String, + val passwordHash: String, + val createdAt: LocalDateTime = LocalDateTime.now(), + val updatedAt: LocalDateTime = LocalDateTime.now() +) \ No newline at end of file diff --git a/src/main/kotlin/com/example/workoutloggerdemo/model/Workout.kt b/src/main/kotlin/com/example/workoutloggerdemo/model/Workout.kt index 2830b83..8a036ef 100644 --- a/src/main/kotlin/com/example/workoutloggerdemo/model/Workout.kt +++ b/src/main/kotlin/com/example/workoutloggerdemo/model/Workout.kt @@ -1,4 +1,19 @@ package com.example.workoutloggerdemo.model -class Workout { -} \ No newline at end of file +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Table +import java.time.Duration +import java.time.LocalDate +import java.time.LocalDateTime + +@Table("workouts") +data class Workout( + @Id + val id: Long? = null, + val name: String, + val userId: Long, + val date: LocalDate, + val duration: Duration, + val notes: String? = null, + val createdAt: LocalDateTime = LocalDateTime.now() +) diff --git a/src/main/kotlin/com/example/workoutloggerdemo/model/WorkoutExercise.kt b/src/main/kotlin/com/example/workoutloggerdemo/model/WorkoutExercise.kt new file mode 100644 index 0000000..40998af --- /dev/null +++ b/src/main/kotlin/com/example/workoutloggerdemo/model/WorkoutExercise.kt @@ -0,0 +1,13 @@ +package com.example.workoutloggerdemo.model + +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Table + +@Table("workout_exercises") +data class WorkoutExercise( + @Id + val id: Long? = null, + val workoutId: Long, + val exerciseId: Long, + val order: Int +) \ No newline at end of file diff --git a/src/main/kotlin/com/example/workoutloggerdemo/repository/AthleteRepository.kt b/src/main/kotlin/com/example/workoutloggerdemo/repository/AthleteRepository.kt new file mode 100644 index 0000000..ff801d6 --- /dev/null +++ b/src/main/kotlin/com/example/workoutloggerdemo/repository/AthleteRepository.kt @@ -0,0 +1,16 @@ +package com.example.workoutloggerdemo.repository + +import com.example.workoutloggerdemo.model.Athlete +import org.springframework.data.jdbc.repository.query.Query +import org.springframework.data.repository.CrudRepository +import org.springframework.data.repository.query.Param +import org.springframework.stereotype.Repository +import java.util.Optional + +@Repository +interface AthleteRepository : CrudRepository { + fun findByUserId(userId: Long): Optional + + @Query("SELECT EXISTS(SELECT 1 FROM athletes WHERE user_id = :userId)") + fun existsByUserId(@Param("userId") userId: Long): Boolean +} \ No newline at end of file diff --git a/src/main/kotlin/com/example/workoutloggerdemo/repository/ExerciseRepository.kt b/src/main/kotlin/com/example/workoutloggerdemo/repository/ExerciseRepository.kt new file mode 100644 index 0000000..c6c7a79 --- /dev/null +++ b/src/main/kotlin/com/example/workoutloggerdemo/repository/ExerciseRepository.kt @@ -0,0 +1,21 @@ +package com.example.workoutloggerdemo.repository + +import com.example.workoutloggerdemo.model.Exercise +import com.example.workoutloggerdemo.model.ExerciseCategory +import org.springframework.data.jdbc.repository.query.Query +import org.springframework.data.repository.CrudRepository +import org.springframework.data.repository.query.Param +import org.springframework.stereotype.Repository + +@Repository +interface ExerciseRepository : CrudRepository { + fun findByCategory(category: ExerciseCategory): List + + fun findByIsCustomAndUserId(isCustom: Boolean, userId: Long?): List + + @Query("SELECT * FROM exercises WHERE is_custom = false OR user_id = :userId") + fun findAllAvailableForUser(@Param("userId") userId: Long): List + + @Query("SELECT * FROM exercises WHERE name ILIKE CONCAT('%', :searchTerm, '%')") + fun searchByName(@Param("searchTerm") searchTerm: String): List +} \ No newline at end of file diff --git a/src/main/kotlin/com/example/workoutloggerdemo/repository/ExerciseSetRepository.kt b/src/main/kotlin/com/example/workoutloggerdemo/repository/ExerciseSetRepository.kt new file mode 100644 index 0000000..f63697d --- /dev/null +++ b/src/main/kotlin/com/example/workoutloggerdemo/repository/ExerciseSetRepository.kt @@ -0,0 +1,32 @@ +package com.example.workoutloggerdemo.repository + +import com.example.workoutloggerdemo.model.ExerciseSet +import org.springframework.data.jdbc.repository.query.Query +import org.springframework.data.repository.CrudRepository +import org.springframework.data.repository.query.Param +import org.springframework.stereotype.Repository + +@Repository +interface ExerciseSetRepository : CrudRepository { + fun findByWorkoutExerciseId(workoutExerciseId: Long): List + + fun findByWorkoutExerciseIdOrderByOrder(workoutExerciseId: Long): List + + @Query("DELETE FROM exercise_sets WHERE workout_exercise_id = :workoutExerciseId") + fun deleteByWorkoutExerciseId(@Param("workoutExerciseId") workoutExerciseId: Long) + + @Query(""" + SELECT es.* FROM exercise_sets es + JOIN workout_exercises we ON es.workout_exercise_id = we.id + JOIN workouts w ON we.workout_id = w.id + WHERE w.user_id = :userId + """) + fun findByUserId(@Param("userId") userId: Long): List + + @Query(""" + SELECT es.* FROM exercise_sets es + JOIN workout_exercises we ON es.workout_exercise_id = we.id + WHERE we.workout_id = :workoutId + """) + fun findByWorkoutId(@Param("workoutId") workoutId: Long): List +} \ No newline at end of file diff --git a/src/main/kotlin/com/example/workoutloggerdemo/repository/UserRepository.kt b/src/main/kotlin/com/example/workoutloggerdemo/repository/UserRepository.kt new file mode 100644 index 0000000..00d1354 --- /dev/null +++ b/src/main/kotlin/com/example/workoutloggerdemo/repository/UserRepository.kt @@ -0,0 +1,21 @@ +package com.example.workoutloggerdemo.repository + +import com.example.workoutloggerdemo.model.User +import org.springframework.data.jdbc.repository.query.Query +import org.springframework.data.repository.CrudRepository +import org.springframework.data.repository.query.Param +import org.springframework.stereotype.Repository +import java.util.Optional + +@Repository +interface UserRepository : CrudRepository { + fun findByUsername(username: String): Optional + + fun findByEmail(email: String): Optional + + @Query("SELECT EXISTS(SELECT 1 FROM users WHERE username = :username)") + fun existsByUsername(@Param("username") username: String): Boolean + + @Query("SELECT EXISTS(SELECT 1 FROM users WHERE email = :email)") + fun existsByEmail(@Param("email") email: String): Boolean +} \ No newline at end of file diff --git a/src/main/kotlin/com/example/workoutloggerdemo/repository/WorkoutExerciseRepository.kt b/src/main/kotlin/com/example/workoutloggerdemo/repository/WorkoutExerciseRepository.kt new file mode 100644 index 0000000..374774c --- /dev/null +++ b/src/main/kotlin/com/example/workoutloggerdemo/repository/WorkoutExerciseRepository.kt @@ -0,0 +1,20 @@ +package com.example.workoutloggerdemo.repository + +import com.example.workoutloggerdemo.model.WorkoutExercise +import org.springframework.data.jdbc.repository.query.Query +import org.springframework.data.repository.CrudRepository +import org.springframework.data.repository.query.Param +import org.springframework.stereotype.Repository + +@Repository +interface WorkoutExerciseRepository : CrudRepository { + fun findByWorkoutId(workoutId: Long): List + + fun findByWorkoutIdOrderByOrder(workoutId: Long): List + + @Query("DELETE FROM workout_exercises WHERE workout_id = :workoutId") + fun deleteByWorkoutId(@Param("workoutId") workoutId: Long) + + @Query("SELECT we.* FROM workout_exercises we JOIN workouts w ON we.workout_id = w.id WHERE w.user_id = :userId") + fun findByUserId(@Param("userId") userId: Long): List +} \ No newline at end of file diff --git a/src/main/kotlin/com/example/workoutloggerdemo/repository/WorkoutRepository.kt b/src/main/kotlin/com/example/workoutloggerdemo/repository/WorkoutRepository.kt new file mode 100644 index 0000000..c007ba8 --- /dev/null +++ b/src/main/kotlin/com/example/workoutloggerdemo/repository/WorkoutRepository.kt @@ -0,0 +1,21 @@ +package com.example.workoutloggerdemo.repository + +import com.example.workoutloggerdemo.model.Workout +import org.springframework.data.jdbc.repository.query.Query +import org.springframework.data.repository.CrudRepository +import org.springframework.data.repository.query.Param +import org.springframework.stereotype.Repository +import java.time.LocalDate + +@Repository +interface WorkoutRepository : CrudRepository { + fun findByUserId(userId: Long): List + + fun findByUserIdAndDateBetween(userId: Long, startDate: LocalDate, endDate: LocalDate): List + + @Query("SELECT * FROM workouts WHERE user_id = :userId ORDER BY date DESC LIMIT :limit") + fun findRecentWorkoutsByUserId(@Param("userId") userId: Long, @Param("limit") limit: Int): List + + @Query("SELECT * FROM workouts WHERE user_id = :userId AND name ILIKE CONCAT('%', :searchTerm, '%')") + fun searchByNameForUser(@Param("userId") userId: Long, @Param("searchTerm") searchTerm: String): List +} \ No newline at end of file diff --git a/src/main/kotlin/com/example/workoutloggerdemo/service/AthleteService.kt b/src/main/kotlin/com/example/workoutloggerdemo/service/AthleteService.kt new file mode 100644 index 0000000..739c247 --- /dev/null +++ b/src/main/kotlin/com/example/workoutloggerdemo/service/AthleteService.kt @@ -0,0 +1,78 @@ +package com.example.workoutloggerdemo.service + +import com.example.workoutloggerdemo.model.Athlete +import com.example.workoutloggerdemo.repository.AthleteRepository +import com.example.workoutloggerdemo.repository.UserRepository +import org.springframework.stereotype.Service +import java.time.LocalDateTime + +@Service +class AthleteService( + private val athleteRepository: AthleteRepository, + private val userRepository: UserRepository +) { + fun findById(id: Long): Athlete { + return athleteRepository.findById(id).orElseThrow { NoSuchElementException("Athlete not found with id: $id") } + } + + fun findByUserId(userId: Long): Athlete { + return athleteRepository.findByUserId(userId).orElseThrow { NoSuchElementException("Athlete not found for user id: $userId") } + } + + fun createAthlete(userId: Long, height: Double? = null, weight: Double? = null, fitnessGoals: String? = null): Athlete { + if (!userRepository.existsById(userId)) { + throw IllegalArgumentException("User not found with id: $userId") + } + + if (athleteRepository.existsByUserId(userId)) { + throw IllegalArgumentException("Athlete already exists for user id: $userId") + } + + val athlete = Athlete( + userId = userId, + height = height, + weight = weight, + fitnessGoals = fitnessGoals + ) + + return athleteRepository.save(athlete) + } + + fun updateAthlete(id: Long, height: Double? = null, weight: Double? = null, fitnessGoals: String? = null): Athlete { + val athlete = findById(id) + + val updatedAthlete = athlete.copy( + height = height ?: athlete.height, + weight = weight ?: athlete.weight, + fitnessGoals = fitnessGoals ?: athlete.fitnessGoals, + updatedAt = LocalDateTime.now() + ) + + return athleteRepository.save(updatedAthlete) + } + + fun updateAthleteByUserId(userId: Long, height: Double? = null, weight: Double? = null, fitnessGoals: String? = null): Athlete { + val athlete = findByUserId(userId) + + val updatedAthlete = athlete.copy( + height = height ?: athlete.height, + weight = weight ?: athlete.weight, + fitnessGoals = fitnessGoals ?: athlete.fitnessGoals, + updatedAt = LocalDateTime.now() + ) + + return athleteRepository.save(updatedAthlete) + } + + fun deleteAthlete(id: Long) { + if (!athleteRepository.existsById(id)) { + throw NoSuchElementException("Athlete not found with id: $id") + } + + athleteRepository.deleteById(id) + } + + fun getAllAthletes(): List { + return athleteRepository.findAll().toList() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/example/workoutloggerdemo/service/ExerciseService.kt b/src/main/kotlin/com/example/workoutloggerdemo/service/ExerciseService.kt new file mode 100644 index 0000000..4bfcf38 --- /dev/null +++ b/src/main/kotlin/com/example/workoutloggerdemo/service/ExerciseService.kt @@ -0,0 +1,103 @@ +package com.example.workoutloggerdemo.service + +import com.example.workoutloggerdemo.model.Exercise +import com.example.workoutloggerdemo.model.ExerciseCategory +import com.example.workoutloggerdemo.model.MuscleGroup +import com.example.workoutloggerdemo.repository.ExerciseRepository +import com.example.workoutloggerdemo.repository.UserRepository +import org.springframework.stereotype.Service + +@Service +class ExerciseService( + private val exerciseRepository: ExerciseRepository, + private val userRepository: UserRepository +) { + fun findById(id: Long): Exercise { + return exerciseRepository.findById(id).orElseThrow { NoSuchElementException("Exercise not found with id: $id") } + } + + fun findByCategory(category: ExerciseCategory): List { + return exerciseRepository.findByCategory(category) + } + + fun findCustomExercisesForUser(userId: Long): List { + if (!userRepository.existsById(userId)) { + throw IllegalArgumentException("User not found with id: $userId") + } + + return exerciseRepository.findByIsCustomAndUserId(true, userId) + } + + fun findAllAvailableForUser(userId: Long): List { + if (!userRepository.existsById(userId)) { + throw IllegalArgumentException("User not found with id: $userId") + } + + return exerciseRepository.findAllAvailableForUser(userId) + } + + fun searchByName(searchTerm: String): List { + return exerciseRepository.searchByName(searchTerm) + } + + fun createExercise( + name: String, + description: String, + category: ExerciseCategory, + muscleGroups: List, + isCustom: Boolean = false, + userId: Long? = null + ): Exercise { + if (isCustom && userId == null) { + throw IllegalArgumentException("User ID is required for custom exercises") + } + + if (userId != null && !userRepository.existsById(userId)) { + throw IllegalArgumentException("User not found with id: $userId") + } + + val exercise = Exercise( + name = name, + description = description, + category = category, + muscleGroups = muscleGroups, + isCustom = isCustom, + userId = userId + ) + + return exerciseRepository.save(exercise) + } + + fun updateExercise( + id: Long, + name: String? = null, + description: String? = null, + category: ExerciseCategory? = null, + muscleGroups: List? = null + ): Exercise { + val exercise = findById(id) + + val updatedExercise = exercise.copy( + name = name ?: exercise.name, + description = description ?: exercise.description, + category = category ?: exercise.category, + muscleGroups = muscleGroups ?: exercise.muscleGroups + ) + + return exerciseRepository.save(updatedExercise) + } + + fun deleteExercise(id: Long) { + val exercise = findById(id) + + if (!exercise.isCustom) { + throw IllegalArgumentException("Cannot delete predefined exercise") + } + + exerciseRepository.deleteById(id) + } + + fun getAllExercises(): List { + return exerciseRepository.findAll().toList() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/example/workoutloggerdemo/service/UserService.kt b/src/main/kotlin/com/example/workoutloggerdemo/service/UserService.kt new file mode 100644 index 0000000..b9d72f6 --- /dev/null +++ b/src/main/kotlin/com/example/workoutloggerdemo/service/UserService.kt @@ -0,0 +1,68 @@ +package com.example.workoutloggerdemo.service + +import com.example.workoutloggerdemo.model.User +import com.example.workoutloggerdemo.repository.UserRepository +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.stereotype.Service +import java.time.LocalDateTime + +@Service +class UserService( + private val userRepository: UserRepository, + private val passwordEncoder: PasswordEncoder +) { + fun findById(id: Long): User { + return userRepository.findById(id).orElseThrow { NoSuchElementException("User not found with id: $id") } + } + + fun findByUsername(username: String): User { + return userRepository.findByUsername(username).orElseThrow { NoSuchElementException("User not found with username: $username") } + } + + fun findByEmail(email: String): User { + return userRepository.findByEmail(email).orElseThrow { NoSuchElementException("User not found with email: $email") } + } + + fun createUser(username: String, email: String, password: String): User { + if (userRepository.existsByUsername(username)) { + throw IllegalArgumentException("Username already exists") + } + + if (userRepository.existsByEmail(email)) { + throw IllegalArgumentException("Email already exists") + } + + val user = User( + username = username, + email = email, + passwordHash = passwordEncoder.encode(password) + ) + + return userRepository.save(user) + } + + fun updateUser(id: Long, username: String? = null, email: String? = null, password: String? = null): User { + val user = findById(id) + + val updatedUser = user.copy( + username = username ?: user.username, + email = email ?: user.email, + passwordHash = if (password != null) passwordEncoder.encode(password) else user.passwordHash, + updatedAt = LocalDateTime.now() + ) + + return userRepository.save(updatedUser) + } + + fun deleteUser(id: Long) { + if (!userRepository.existsById(id)) { + throw NoSuchElementException("User not found with id: $id") + } + + userRepository.deleteById(id) + } + + fun getAllUsers(): List { + return userRepository.findAll().toList() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/example/workoutloggerdemo/service/WorkoutService.kt b/src/main/kotlin/com/example/workoutloggerdemo/service/WorkoutService.kt new file mode 100644 index 0000000..7fb1039 --- /dev/null +++ b/src/main/kotlin/com/example/workoutloggerdemo/service/WorkoutService.kt @@ -0,0 +1,238 @@ +package com.example.workoutloggerdemo.service + +import com.example.workoutloggerdemo.model.ExerciseSet +import com.example.workoutloggerdemo.model.Workout +import com.example.workoutloggerdemo.model.WorkoutExercise +import com.example.workoutloggerdemo.repository.ExerciseRepository +import com.example.workoutloggerdemo.repository.ExerciseSetRepository +import com.example.workoutloggerdemo.repository.UserRepository +import com.example.workoutloggerdemo.repository.WorkoutExerciseRepository +import com.example.workoutloggerdemo.repository.WorkoutRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.Duration +import java.time.LocalDate + +@Service +class WorkoutService( + private val workoutRepository: WorkoutRepository, + private val workoutExerciseRepository: WorkoutExerciseRepository, + private val exerciseSetRepository: ExerciseSetRepository, + private val exerciseRepository: ExerciseRepository, + private val userRepository: UserRepository +) { + fun findById(id: Long): Workout { + return workoutRepository.findById(id).orElseThrow { NoSuchElementException("Workout not found with id: $id") } + } + + fun findByUserId(userId: Long): List { + if (!userRepository.existsById(userId)) { + throw IllegalArgumentException("User not found with id: $userId") + } + + return workoutRepository.findByUserId(userId) + } + + fun findByUserIdAndDateRange(userId: Long, startDate: LocalDate, endDate: LocalDate): List { + if (!userRepository.existsById(userId)) { + throw IllegalArgumentException("User not found with id: $userId") + } + + return workoutRepository.findByUserIdAndDateBetween(userId, startDate, endDate) + } + + fun findRecentWorkouts(userId: Long, limit: Int = 10): List { + if (!userRepository.existsById(userId)) { + throw IllegalArgumentException("User not found with id: $userId") + } + + return workoutRepository.findRecentWorkoutsByUserId(userId, limit) + } + + fun searchWorkoutsByName(userId: Long, searchTerm: String): List { + if (!userRepository.existsById(userId)) { + throw IllegalArgumentException("User not found with id: $userId") + } + + return workoutRepository.searchByNameForUser(userId, searchTerm) + } + + @Transactional + fun createWorkout( + name: String, + userId: Long, + date: LocalDate, + duration: Duration, + notes: String? = null + ): Workout { + if (!userRepository.existsById(userId)) { + throw IllegalArgumentException("User not found with id: $userId") + } + + val workout = Workout( + name = name, + userId = userId, + date = date, + duration = duration, + notes = notes + ) + + return workoutRepository.save(workout) + } + + @Transactional + fun updateWorkout( + id: Long, + name: String? = null, + date: LocalDate? = null, + duration: Duration? = null, + notes: String? = null + ): Workout { + val workout = findById(id) + + val updatedWorkout = workout.copy( + name = name ?: workout.name, + date = date ?: workout.date, + duration = duration ?: workout.duration, + notes = notes ?: workout.notes + ) + + return workoutRepository.save(updatedWorkout) + } + + @Transactional + fun deleteWorkout(id: Long) { + if (!workoutRepository.existsById(id)) { + throw NoSuchElementException("Workout not found with id: $id") + } + + // Cascade delete will handle workout exercises and exercise sets + workoutRepository.deleteById(id) + } + + fun getAllWorkouts(): List { + return workoutRepository.findAll().toList() + } + + // Workout Exercise methods + + fun getWorkoutExercises(workoutId: Long): List { + if (!workoutRepository.existsById(workoutId)) { + throw NoSuchElementException("Workout not found with id: $workoutId") + } + + return workoutExerciseRepository.findByWorkoutIdOrderByOrder(workoutId) + } + + @Transactional + fun addExerciseToWorkout(workoutId: Long, exerciseId: Long, order: Int): WorkoutExercise { + if (!workoutRepository.existsById(workoutId)) { + throw NoSuchElementException("Workout not found with id: $workoutId") + } + + if (!exerciseRepository.existsById(exerciseId)) { + throw NoSuchElementException("Exercise not found with id: $exerciseId") + } + + val workoutExercise = WorkoutExercise( + workoutId = workoutId, + exerciseId = exerciseId, + order = order + ) + + return workoutExerciseRepository.save(workoutExercise) + } + + @Transactional + fun updateWorkoutExercise(id: Long, order: Int? = null): WorkoutExercise { + val workoutExercise = workoutExerciseRepository.findById(id) + .orElseThrow { NoSuchElementException("Workout exercise not found with id: $id") } + + val updatedWorkoutExercise = workoutExercise.copy( + order = order ?: workoutExercise.order + ) + + return workoutExerciseRepository.save(updatedWorkoutExercise) + } + + @Transactional + fun removeExerciseFromWorkout(workoutExerciseId: Long) { + if (!workoutExerciseRepository.existsById(workoutExerciseId)) { + throw NoSuchElementException("Workout exercise not found with id: $workoutExerciseId") + } + + // Cascade delete will handle exercise sets + workoutExerciseRepository.deleteById(workoutExerciseId) + } + + // Exercise Set methods + + fun getExerciseSets(workoutExerciseId: Long): List { + if (!workoutExerciseRepository.existsById(workoutExerciseId)) { + throw NoSuchElementException("Workout exercise not found with id: $workoutExerciseId") + } + + return exerciseSetRepository.findByWorkoutExerciseIdOrderByOrder(workoutExerciseId) + } + + @Transactional + fun addSetToExercise( + workoutExerciseId: Long, + reps: Int? = null, + weight: Double? = null, + duration: Duration? = null, + distance: Double? = null, + completed: Boolean = false, + order: Int + ): ExerciseSet { + if (!workoutExerciseRepository.existsById(workoutExerciseId)) { + throw NoSuchElementException("Workout exercise not found with id: $workoutExerciseId") + } + + val exerciseSet = ExerciseSet( + workoutExerciseId = workoutExerciseId, + reps = reps, + weight = weight, + duration = duration, + distance = distance, + completed = completed, + order = order + ) + + return exerciseSetRepository.save(exerciseSet) + } + + @Transactional + fun updateExerciseSet( + id: Long, + reps: Int? = null, + weight: Double? = null, + duration: Duration? = null, + distance: Double? = null, + completed: Boolean? = null, + order: Int? = null + ): ExerciseSet { + val exerciseSet = exerciseSetRepository.findById(id) + .orElseThrow { NoSuchElementException("Exercise set not found with id: $id") } + + val updatedExerciseSet = exerciseSet.copy( + reps = reps ?: exerciseSet.reps, + weight = weight ?: exerciseSet.weight, + duration = duration ?: exerciseSet.duration, + distance = distance ?: exerciseSet.distance, + completed = completed ?: exerciseSet.completed, + order = order ?: exerciseSet.order + ) + + return exerciseSetRepository.save(updatedExerciseSet) + } + + @Transactional + fun deleteExerciseSet(id: Long) { + if (!exerciseSetRepository.existsById(id)) { + throw NoSuchElementException("Exercise set not found with id: $id") + } + + exerciseSetRepository.deleteById(id) + } +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 52c7aa2..f970181 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,15 @@ spring.application.name=workout-logger-demo + +# Database Configuration +spring.datasource.url=jdbc:postgresql://localhost:5432/mydatabase +spring.datasource.username=myuser +spring.datasource.password=secret +spring.datasource.driver-class-name=org.postgresql.Driver + +# JPA/Hibernate Configuration +spring.sql.init.mode=always +spring.sql.init.schema-locations=classpath:schema.sql +spring.sql.init.data-locations=classpath:data.sql + +# Server Configuration +server.port=8080 diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql new file mode 100644 index 0000000..32c8dbe --- /dev/null +++ b/src/main/resources/data.sql @@ -0,0 +1,55 @@ +-- Sample Users +INSERT INTO users (username, email, password_hash) VALUES +('john_doe', 'john@example.com', '$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG'), -- password: password +('jane_smith', 'jane@example.com', '$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG') -- password: password +ON CONFLICT (username) DO NOTHING; + +-- Sample Athletes +INSERT INTO athletes (user_id, height, weight, fitness_goals) VALUES +(1, 180.5, 75.0, 'Build muscle and improve endurance'), +(2, 165.0, 60.0, 'Lose weight and tone muscles') +ON CONFLICT DO NOTHING; + +-- Sample Exercises +INSERT INTO exercises (name, description, category, muscle_groups, is_custom, user_id) VALUES +('Push-up', 'A classic bodyweight exercise that targets the chest, shoulders, and triceps', 'STRENGTH', 'CHEST,SHOULDERS,TRICEPS', FALSE, NULL), +('Squat', 'A compound exercise that targets the legs and glutes', 'STRENGTH', 'LEGS', FALSE, NULL), +('Plank', 'An isometric core exercise that improves stability', 'STRENGTH', 'CORE', FALSE, NULL), +('Running', 'Cardiovascular exercise that improves endurance', 'CARDIO', 'FULL_BODY', FALSE, NULL), +('Custom Kettlebell Swing', 'A dynamic exercise using a kettlebell', 'STRENGTH', 'BACK,LEGS', TRUE, 1) +ON CONFLICT DO NOTHING; + +-- Sample Workouts +INSERT INTO workouts (name, user_id, date, duration, notes) VALUES +('Morning Routine', 1, CURRENT_DATE - 7, 1800, 'Felt great, increased weight on squats'), +('Cardio Session', 1, CURRENT_DATE - 3, 2400, 'Focused on maintaining steady pace'), +('Full Body Workout', 2, CURRENT_DATE - 5, 3600, 'Increased reps on all exercises') +ON CONFLICT DO NOTHING; + +-- Sample Workout Exercises +INSERT INTO workout_exercises (workout_id, exercise_id, "order") VALUES +(1, 1, 1), -- Push-up in Morning Routine +(1, 2, 2), -- Squat in Morning Routine +(1, 3, 3), -- Plank in Morning Routine +(2, 4, 1), -- Running in Cardio Session +(3, 1, 1), -- Push-up in Full Body Workout +(3, 2, 2), -- Squat in Full Body Workout +(3, 3, 3), -- Plank in Full Body Workout +(3, 4, 4) -- Running in Full Body Workout +ON CONFLICT DO NOTHING; + +-- Sample Exercise Sets +INSERT INTO exercise_sets (workout_exercise_id, reps, weight, duration, distance, completed, "order") VALUES +(1, 15, NULL, NULL, NULL, TRUE, 1), +(1, 12, NULL, NULL, NULL, TRUE, 2), +(1, 10, NULL, NULL, NULL, TRUE, 3), +(2, 12, 60.0, NULL, NULL, TRUE, 1), +(2, 10, 70.0, NULL, NULL, TRUE, 2), +(3, NULL, NULL, 60, NULL, TRUE, 1), +(4, NULL, NULL, 1200, 5.0, TRUE, 1), +(5, 12, NULL, NULL, NULL, TRUE, 1), +(5, 10, NULL, NULL, NULL, TRUE, 2), +(6, 15, 50.0, NULL, NULL, TRUE, 1), +(7, NULL, NULL, 45, NULL, TRUE, 1), +(8, NULL, NULL, 900, 3.0, TRUE, 1) +ON CONFLICT DO NOTHING; \ No newline at end of file diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql new file mode 100644 index 0000000..ebda015 --- /dev/null +++ b/src/main/resources/schema.sql @@ -0,0 +1,63 @@ +-- Users table +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + username VARCHAR(50) NOT NULL UNIQUE, + email VARCHAR(100) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Athletes table +CREATE TABLE IF NOT EXISTS athletes ( + id SERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id), + height DOUBLE PRECISION, + weight DOUBLE PRECISION, + fitness_goals TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Exercises table +CREATE TABLE IF NOT EXISTS exercises ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description TEXT NOT NULL, + category VARCHAR(20) NOT NULL, + muscle_groups TEXT NOT NULL, + is_custom BOOLEAN NOT NULL DEFAULT FALSE, + user_id BIGINT REFERENCES users(id), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Workouts table +CREATE TABLE IF NOT EXISTS workouts ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + user_id BIGINT NOT NULL REFERENCES users(id), + date DATE NOT NULL, + duration BIGINT NOT NULL, -- Duration in seconds + notes TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Workout Exercises table +CREATE TABLE IF NOT EXISTS workout_exercises ( + id SERIAL PRIMARY KEY, + workout_id BIGINT NOT NULL REFERENCES workouts(id) ON DELETE CASCADE, + exercise_id BIGINT NOT NULL REFERENCES exercises(id), + "order" INT NOT NULL +); + +-- Exercise Sets table +CREATE TABLE IF NOT EXISTS exercise_sets ( + id SERIAL PRIMARY KEY, + workout_exercise_id BIGINT NOT NULL REFERENCES workout_exercises(id) ON DELETE CASCADE, + reps INT, + weight DOUBLE PRECISION, + duration BIGINT, -- Duration in seconds + distance DOUBLE PRECISION, + completed BOOLEAN NOT NULL DEFAULT FALSE, + "order" INT NOT NULL +); \ No newline at end of file