diff --git a/README.md b/README.md new file mode 100644 index 0000000..298f03b --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +# CarSharing API + +A Spring Boot application for a car sharing service. + +## Features + +- User authentication with JWT +- User registration and profile management +- Role-based access control (MANAGER and CUSTOMER roles) + +## API Documentation + +### Authentication + +- **POST /register**: Register a new user +- **POST /login**: Authenticate and get a JWT token + +### User Management + +- **GET /users/me**: Get the current user's profile +- **PUT /users/me**: Update the current user's profile +- **PATCH /users/me**: Partially update the current user's profile +- **PUT /users/{id}/role**: Update a user's role (requires MANAGER role) + +### Car Management + +- **POST /cars**: Add a new car +- **GET /cars**: Get a list of all cars +- **GET /cars/{id}**: Get detailed information about a specific car +- **PUT /cars/{id}**: Update a car's information +- **PATCH /cars/{id}**: Partially update a car's information +- **DELETE /cars/{id}**: Delete a car + +### Rental Management + +- **POST /rentals**: Add a new rental (decreases car inventory by 1) +- **GET /rentals/?user_id=...&is_active=...**: Get rentals by user ID and active status +- **GET /rentals/{id}**: Get detailed information about a specific rental +- **POST /rentals/{id}/return**: Return a rental (sets actual return date and increases car + inventory by 1) + +## Postman Collection + +A Postman collection and environment are available in the `postman` directory to help you test the +API: + +- `postman/CarSharing.postman_collection.json`: Collection of API requests +- `postman/CarSharing.postman_environment.json`: Environment variables +- `postman/README.md`: Instructions for using the Postman collection + +See the [Postman README](postman/README.md) for detailed instructions on how to import and use the +collection. + +## Getting Started + +### Prerequisites + +- Java 21 +- Maven +- MySQL + +### Running the Application + +1. Clone the repository +2. Configure the database connection in `application-dev.properties` +3. Run the application: + +```bash +mvn spring-boot:run +``` + +The API will be available at http://localhost:8080 + +## Authentication + +The API uses JWT (JSON Web Token) for authentication. To access protected endpoints: + +1. Register a user or login to get a token +2. Include the token in the Authorization header of subsequent requests: + +``` +Authorization: Bearer +``` + +## Development + +### Environment Profiles + +- `dev`: Development environment (default) +- `prod`: Production environment + +To run with a specific profile: + +```bash +mvn spring-boot:run -Dspring.profiles.active=prod +``` diff --git a/postman/CarSharing.postman_collection.json b/postman/CarSharing.postman_collection.json new file mode 100644 index 0000000..74f1392 --- /dev/null +++ b/postman/CarSharing.postman_collection.json @@ -0,0 +1,529 @@ +{ + "info": { + "_postman_id": "e5f5e5e5-e5e5-e5e5-e5e5-e5e5e5e5e5e5", + "name": "CarSharing API", + "description": "A collection of requests for the CarSharing API", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item": [ + { + "name": "Rental Management", + "item": [ + { + "name": "Create Rental", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{authToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"carId\": 1,\n \"rentalDate\": \"2023-06-01\",\n \"returnDate\": \"2023-06-10\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/rentals", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "rentals" + ] + }, + "description": "Add a new rental (decreases car inventory by 1)" + }, + "response": [] + }, + { + "name": "Get Rentals", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{authToken}}" + } + ], + "url": { + "raw": "{{baseUrl}}/rentals?user_id=&is_active=false", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "rentals" + ], + "query": [ + { + "key": "user_id", + "value": "", + "description": "Optional: ID of the user whose rentals to retrieve" + }, + { + "key": "is_active", + "value": "false", + "description": "Optional: Whether to retrieve active (true) or completed (false) rentals" + } + ] + }, + "description": "Get rentals by user ID and active status" + }, + "response": [] + }, + { + "name": "Get Rental by ID", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{authToken}}" + } + ], + "url": { + "raw": "{{baseUrl}}/rentals/1", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "rentals", + "1" + ] + }, + "description": "Get detailed information about a specific rental" + }, + "response": [] + }, + { + "name": "Return Rental", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{authToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"actualReturnDate\": \"2023-06-08\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/rentals/1/return", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "rentals", + "1", + "return" + ] + }, + "description": "Return a rental (sets actual return date and increases car inventory by 1)" + }, + "response": [] + } + ], + "description": "Rental management related endpoints" + }, + { + "name": "Car Management", + "item": [ + { + "name": "Create Car", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{authToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"model\": \"Model 3\",\n \"brand\": \"Tesla\",\n \"type\": \"SEDAN\",\n \"inventory\": 5,\n \"dailyFee\": 100.00\n}" + }, + "url": { + "raw": "{{baseUrl}}/cars", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cars" + ] + }, + "description": "Add a new car to the system" + }, + "response": [] + }, + { + "name": "Get All Cars", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/cars", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cars" + ] + }, + "description": "Get a list of all available cars" + }, + "response": [] + }, + { + "name": "Get Car by ID", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/cars/1", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cars", + "1" + ] + }, + "description": "Get detailed information about a specific car" + }, + "response": [] + }, + { + "name": "Update Car", + "request": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{authToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"model\": \"Model 3\",\n \"brand\": \"Tesla\",\n \"type\": \"SEDAN\",\n \"inventory\": 10,\n \"dailyFee\": 120.00\n}" + }, + "url": { + "raw": "{{baseUrl}}/cars/1", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cars", + "1" + ] + }, + "description": "Update all information for a specific car" + }, + "response": [] + }, + { + "name": "Partially Update Car", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{authToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"inventory\": 15,\n \"dailyFee\": 130.00\n}" + }, + "url": { + "raw": "{{baseUrl}}/cars/1", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cars", + "1" + ] + }, + "description": "Partially update information for a specific car" + }, + "response": [] + }, + { + "name": "Delete Car", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{authToken}}" + } + ], + "url": { + "raw": "{{baseUrl}}/cars/1", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cars", + "1" + ] + }, + "description": "Delete a specific car" + }, + "response": [] + } + ], + "description": "Car management related endpoints" + }, + { + "name": "Authentication", + "item": [ + { + "name": "Register User", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"firstName\": \"John\",\n \"lastName\": \"Doe\",\n \"email\": \"john.doe@example.com\",\n \"password\": \"password123\",\n \"repeatedPassword\": \"password123\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/register", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "register" + ] + }, + "description": "Register a new user in the system" + }, + "response": [] + }, + { + "name": "Login User", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "var jsonData = JSON.parse(responseBody);", + "if (jsonData.token) {", + " pm.environment.set(\"authToken\", jsonData.token);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"john.doe@example.com\",\n \"password\": \"password123\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/login", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "login" + ] + }, + "description": "Login with user credentials and receive an authentication token" + }, + "response": [] + } + ], + "description": "Authentication related endpoints" + }, + { + "name": "User Management", + "item": [ + { + "name": "Get Current User Profile", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{authToken}}" + } + ], + "url": { + "raw": "{{baseUrl}}/users/me", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "users", + "me" + ] + }, + "description": "Get the profile information of the currently authenticated user" + }, + "response": [] + }, + { + "name": "Update User Profile", + "request": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{authToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"firstName\": \"John\",\n \"lastName\": \"Smith\",\n \"email\": \"john.smith@example.com\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/users/me", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "users", + "me" + ] + }, + "description": "Update the profile information of the currently authenticated user" + }, + "response": [] + }, + { + "name": "Partially Update User Profile", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{authToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"firstName\": \"John\",\n \"lastName\": \"Smith\",\n \"email\": \"john.smith@example.com\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/users/me", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "users", + "me" + ] + }, + "description": "Partially update the profile information of the currently authenticated user" + }, + "response": [] + }, + { + "name": "Update User Role", + "request": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{authToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"roleName\": \"CUSTOMER\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/users/1/role", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "users", + "1", + "role" + ] + }, + "description": "Update the role of a user (requires MANAGER role)" + }, + "response": [] + } + ], + "description": "User management related endpoints" + } + ], + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "" + ] + } + } + ], + "variable": [ + { + "key": "baseUrl", + "value": "http://localhost:8080", + "type": "string" + } + ] +} diff --git a/postman/README.md b/postman/README.md new file mode 100644 index 0000000..561b3ec --- /dev/null +++ b/postman/README.md @@ -0,0 +1,146 @@ +# CarSharing API Postman Collection + +This directory contains Postman collection and environment files for testing the CarSharing API. + +## Files + +- `CarSharing.postman_collection.json`: Postman collection with requests for all API endpoints +- `CarSharing.postman_environment.json`: Postman environment with variables for the API + +## Importing into Postman + +1. Open Postman +2. Click on "Import" in the top left corner +3. Drag and drop both files or click "Upload Files" and select both files +4. Click "Import" + +## Setting Up the Environment + +1. In Postman, click on the environment dropdown in the top right corner +2. Select "CarSharing API Environment" +3. The `baseUrl` variable is set to `http://localhost:8080` by default. Update this if your API is + running on a different URL. + +## Using the Collection + +The collection is organized into folders based on functionality: + +### Authentication + +- **Register User**: Create a new user account + - Method: POST + - Endpoint: {{baseUrl}}/register + - Body: JSON with firstName, lastName, email, password, and repeatedPassword + +- **Login User**: Authenticate and get a JWT token + - Method: POST + - Endpoint: {{baseUrl}}/login + - Body: JSON with email and password + - Note: This request automatically saves the token to the `authToken` environment variable + +### User Management + +- **Get Current User Profile**: Get the profile of the currently authenticated user + - Method: GET + - Endpoint: {{baseUrl}}/users/me + - Authorization: Bearer Token (automatically uses the `authToken` variable) + +- **Update User Profile**: Update the profile of the currently authenticated user + - Method: PUT + - Endpoint: {{baseUrl}}/users/me + - Body: JSON with firstName, lastName, and email + - Authorization: Bearer Token + +- **Partially Update User Profile**: Partially update the profile of the currently authenticated + user + - Method: PATCH + - Endpoint: {{baseUrl}}/users/me + - Body: JSON with firstName, lastName, and email + - Authorization: Bearer Token + +- **Update User Role**: Update the role of a user (requires MANAGER role) + - Method: PUT + - Endpoint: {{baseUrl}}/users/{id}/role + - Path Parameter: id - The ID of the user to update + - Body: JSON with roleName (either "MANAGER" or "CUSTOMER") + - Authorization: Bearer Token + +### Car Management + +- **Create Car**: Add a new car to the system + - Method: POST + - Endpoint: {{baseUrl}}/cars + - Body: JSON with model, brand, type, inventory, and dailyFee + - Authorization: Bearer Token + +- **Get All Cars**: Get a list of all available cars + - Method: GET + - Endpoint: {{baseUrl}}/cars + +- **Get Car by ID**: Get detailed information about a specific car + - Method: GET + - Endpoint: {{baseUrl}}/cars/{id} + - Path Parameter: id - The ID of the car to retrieve + +- **Update Car**: Update all information for a specific car + - Method: PUT + - Endpoint: {{baseUrl}}/cars/{id} + - Path Parameter: id - The ID of the car to update + - Body: JSON with model, brand, type, inventory, and dailyFee + - Authorization: Bearer Token + +- **Partially Update Car**: Partially update information for a specific car + - Method: PATCH + - Endpoint: {{baseUrl}}/cars/{id} + - Path Parameter: id - The ID of the car to update + - Body: JSON with any of model, brand, type, inventory, or dailyFee + - Authorization: Bearer Token + +- **Delete Car**: Delete a specific car + - Method: DELETE + - Endpoint: {{baseUrl}}/cars/{id} + - Path Parameter: id - The ID of the car to delete + - Authorization: Bearer Token + +### Rental Management + +- **Create Rental**: Add a new rental (decreases car inventory by 1) + - Method: POST + - Endpoint: {{baseUrl}}/rentals + - Body: JSON with carId, rentalDate, and returnDate + - Authorization: Bearer Token + +- **Get Rentals**: Get rentals by user ID and active status + - Method: GET + - Endpoint: {{baseUrl}}/rentals + - Query Parameters: + - user_id (optional): The ID of the user whose rentals to retrieve + - is_active (optional, default: false): Whether to retrieve active (true) or completed ( + false) rentals + - Authorization: Bearer Token + +- **Get Rental by ID**: Get detailed information about a specific rental + - Method: GET + - Endpoint: {{baseUrl}}/rentals/{id} + - Path Parameter: id - The ID of the rental to retrieve + - Authorization: Bearer Token + +- **Return Rental**: Return a rental (sets actual return date and increases car inventory by 1) + - Method: POST + - Endpoint: {{baseUrl}}/rentals/{id}/return + - Path Parameter: id - The ID of the rental to return + - Body: JSON with actualReturnDate + - Authorization: Bearer Token + +## Authentication Flow + +1. Register a new user using the "Register User" request +2. Login with the registered user's credentials using the "Login User" request + - This will automatically save the JWT token to the `authToken` environment variable +3. Use the authenticated endpoints with the token + +## Notes + +- All requests that require authentication automatically include the Bearer token from the + environment variable +- The "Update User Role" endpoint requires the user to have the MANAGER role diff --git a/src/main/java/com/senkiv/carsharing/config/SecurityConfig.java b/src/main/java/com/senkiv/carsharing/config/SecurityConfig.java index 05d0a3a..6641e28 100644 --- a/src/main/java/com/senkiv/carsharing/config/SecurityConfig.java +++ b/src/main/java/com/senkiv/carsharing/config/SecurityConfig.java @@ -30,7 +30,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .csrf(AbstractHttpConfigurer::disable) .cors(AbstractHttpConfigurer::disable) .authorizeHttpRequests(auth -> { - auth.requestMatchers("/cars", "/cars/**", "/auth/**", "/register", "/login", + auth.requestMatchers("/auth/**", "/register", "/login", "/error", "/v3/api-docs/**", "/swagger-ui/**").permitAll() diff --git a/src/main/java/com/senkiv/carsharing/controller/CarController.java b/src/main/java/com/senkiv/carsharing/controller/CarController.java index a7c8d60..9c7b8f0 100644 --- a/src/main/java/com/senkiv/carsharing/controller/CarController.java +++ b/src/main/java/com/senkiv/carsharing/controller/CarController.java @@ -8,6 +8,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -24,28 +25,33 @@ public class CarController { private final CarService carService; + @PreAuthorize("hasRole('MANAGER')") @PostMapping @ResponseStatus(HttpStatus.CREATED) public CarResponseDto createCar(@RequestBody @Valid CarRequestDto requestDto) { return carService.create(requestDto); } + @PreAuthorize("permitAll()") @GetMapping public Page getAllCars(Pageable pageable) { return carService.getAll(pageable); } + @PreAuthorize("permitAll()") @GetMapping("/{id}") public CarResponseDto getCarById(@PathVariable Long id) { return carService.getById(id); } + @PreAuthorize("hasRole('MANAGER')") @PutMapping("/{id}") public CarResponseDto updateCar(@PathVariable Long id, @RequestBody @Valid CarRequestDto requestDto) { return carService.update(id, requestDto); } + @PreAuthorize("hasRole('MANAGER')") @DeleteMapping("/{id}") @ResponseStatus(HttpStatus.NO_CONTENT) public void deleteCar(@PathVariable Long id) { diff --git a/src/main/java/com/senkiv/carsharing/controller/GlobalExceptionHandler.java b/src/main/java/com/senkiv/carsharing/controller/GlobalExceptionHandler.java deleted file mode 100644 index 3af81c8..0000000 --- a/src/main/java/com/senkiv/carsharing/controller/GlobalExceptionHandler.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.senkiv.carsharing.controller; - -import jakarta.persistence.EntityNotFoundException; -import org.springframework.http.HttpStatus; -import org.springframework.web.bind.annotation.ControllerAdvice; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.ResponseStatus; - -@ControllerAdvice -public class GlobalExceptionHandler { - - @ExceptionHandler(EntityNotFoundException.class) - @ResponseStatus(HttpStatus.NOT_FOUND) - public void handleEntityNotFoundException() { - // Just return the status code - } -} diff --git a/src/main/java/com/senkiv/carsharing/controller/RentalController.java b/src/main/java/com/senkiv/carsharing/controller/RentalController.java new file mode 100644 index 0000000..64ef505 --- /dev/null +++ b/src/main/java/com/senkiv/carsharing/controller/RentalController.java @@ -0,0 +1,58 @@ +package com.senkiv.carsharing.controller; + +import com.senkiv.carsharing.dto.RentalRequestDto; +import com.senkiv.carsharing.dto.RentalResponseDto; +import com.senkiv.carsharing.dto.RentalReturnRequestDto; +import com.senkiv.carsharing.service.RentalService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +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.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/rentals") +@RequiredArgsConstructor +public class RentalController { + private final RentalService rentalService; + + @PreAuthorize("isAuthenticated()") + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public RentalResponseDto addRental(@RequestBody @Valid RentalRequestDto requestDto) { + return rentalService.add(requestDto); + } + + @PreAuthorize("isAuthenticated()") + @GetMapping + public Page getRentals( + @PageableDefault Pageable pageable, + @RequestParam(name = "user_id", required = false) Long userId, + @RequestParam(name = "is_active", defaultValue = "false") boolean isActive) { + return rentalService.getByUserAndStatus(pageable, userId, isActive); + } + + @PreAuthorize("isAuthenticated()") + @GetMapping("/{id}") + public RentalResponseDto getRentalById(@PathVariable Long id) { + return rentalService.getById(id); + } + + @PreAuthorize("isAuthenticated()") + @PostMapping("/{id}/return") + public RentalResponseDto returnRental( + @PathVariable Long id, + @RequestBody @Valid RentalReturnRequestDto requestDto) { + return rentalService.returnRental(id, requestDto); + } +} diff --git a/src/main/java/com/senkiv/carsharing/controller/UserController.java b/src/main/java/com/senkiv/carsharing/controller/UserController.java index 7addc75..53e9147 100644 --- a/src/main/java/com/senkiv/carsharing/controller/UserController.java +++ b/src/main/java/com/senkiv/carsharing/controller/UserController.java @@ -22,23 +22,26 @@ public class UserController { private final UserService userService; @PutMapping("/{id}/role") - @PreAuthorize("hasRole('ROLE_MANAGER')") + @PreAuthorize("hasRole('MANAGER')") public UserResponseDto updateRole(@PathVariable Long id, @RequestBody @Valid UserRoleUpdateRequestDto requestDto) { return userService.updateRole(id, requestDto); } + @PreAuthorize("isAuthenticated()") @GetMapping("/me") public UserResponseDto getMyProfile() { return userService.getCurrentUser(); } + @PreAuthorize("isAuthenticated()") @PutMapping("/me") public UserResponseDto updateProfile( @RequestBody @Valid UserProfileUpdateRequestDto requestDto) { return userService.updateProfile(requestDto); } + @PreAuthorize("isAuthenticated()") @PatchMapping("/me") public UserResponseDto patchProfile( @RequestBody @Valid UserProfileUpdateRequestDto requestDto) { diff --git a/src/main/java/com/senkiv/carsharing/dto/RentalRequestDto.java b/src/main/java/com/senkiv/carsharing/dto/RentalRequestDto.java new file mode 100644 index 0000000..3db89f6 --- /dev/null +++ b/src/main/java/com/senkiv/carsharing/dto/RentalRequestDto.java @@ -0,0 +1,20 @@ +package com.senkiv.carsharing.dto; + +import jakarta.validation.constraints.Future; +import jakarta.validation.constraints.FutureOrPresent; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDate; + +public record RentalRequestDto( + @NotNull + Long carId, + + @NotNull + @FutureOrPresent + LocalDate rentalDate, + + @NotNull + @Future + LocalDate returnDate +) { +} diff --git a/src/main/java/com/senkiv/carsharing/dto/RentalResponseDto.java b/src/main/java/com/senkiv/carsharing/dto/RentalResponseDto.java new file mode 100644 index 0000000..bae9ea8 --- /dev/null +++ b/src/main/java/com/senkiv/carsharing/dto/RentalResponseDto.java @@ -0,0 +1,15 @@ +package com.senkiv.carsharing.dto; + +import java.time.LocalDate; + +public record RentalResponseDto( + Long id, + Long carId, + Long userId, + String userEmail, + LocalDate rentalDate, + LocalDate returnDate, + LocalDate actualReturnDate, + boolean isActive +) { +} diff --git a/src/main/java/com/senkiv/carsharing/dto/RentalReturnRequestDto.java b/src/main/java/com/senkiv/carsharing/dto/RentalReturnRequestDto.java new file mode 100644 index 0000000..0cd16c0 --- /dev/null +++ b/src/main/java/com/senkiv/carsharing/dto/RentalReturnRequestDto.java @@ -0,0 +1,10 @@ +package com.senkiv.carsharing.dto; + +import jakarta.validation.constraints.NotNull; +import java.time.LocalDate; + +public record RentalReturnRequestDto( + @NotNull + LocalDate actualReturnDate +) { +} diff --git a/src/main/java/com/senkiv/carsharing/dto/UserRegistrationRequestDto.java b/src/main/java/com/senkiv/carsharing/dto/UserRegistrationRequestDto.java index 0fa7247..40b1b3a 100644 --- a/src/main/java/com/senkiv/carsharing/dto/UserRegistrationRequestDto.java +++ b/src/main/java/com/senkiv/carsharing/dto/UserRegistrationRequestDto.java @@ -1,6 +1,6 @@ package com.senkiv.carsharing.dto; -import com.senkiv.carsharing.validation.FieldMatch; +import com.senkiv.carsharing.validation.annotation.FieldMatch; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; diff --git a/src/main/java/com/senkiv/carsharing/exception/GlobalExceptionHandler.java b/src/main/java/com/senkiv/carsharing/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..4b71697 --- /dev/null +++ b/src/main/java/com/senkiv/carsharing/exception/GlobalExceptionHandler.java @@ -0,0 +1,21 @@ +package com.senkiv.carsharing.exception; + +import jakarta.persistence.EntityNotFoundException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +@ControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(EntityNotFoundException.class) + public ResponseEntity handleEntityNotFoundException(EntityNotFoundException ex) { + return new ResponseEntity<>(ex.getMessage(), HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(RentalException.class) + public ResponseEntity handleRentalException(RentalException ex) { + return new ResponseEntity<>(ex.getMessage(), HttpStatus.BAD_REQUEST); + } +} diff --git a/src/main/java/com/senkiv/carsharing/exception/RentalException.java b/src/main/java/com/senkiv/carsharing/exception/RentalException.java new file mode 100644 index 0000000..2e2e0e5 --- /dev/null +++ b/src/main/java/com/senkiv/carsharing/exception/RentalException.java @@ -0,0 +1,7 @@ +package com.senkiv.carsharing.exception; + +public class RentalException extends RuntimeException { + public RentalException(String message) { + super(message); + } +} diff --git a/src/main/java/com/senkiv/carsharing/mapper/RentalMapper.java b/src/main/java/com/senkiv/carsharing/mapper/RentalMapper.java new file mode 100644 index 0000000..2c46414 --- /dev/null +++ b/src/main/java/com/senkiv/carsharing/mapper/RentalMapper.java @@ -0,0 +1,25 @@ +package com.senkiv.carsharing.mapper; + +import com.senkiv.carsharing.config.MapperConfig; +import com.senkiv.carsharing.dto.RentalRequestDto; +import com.senkiv.carsharing.dto.RentalResponseDto; +import com.senkiv.carsharing.model.Rental; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(config = MapperConfig.class) +public interface RentalMapper { + + @Mapping(target = "id", ignore = true) + @Mapping(target = "car.id", source = "carId") + @Mapping(target = "user", ignore = true) + @Mapping(target = "actualReturnDate", ignore = true) + @Mapping(target = "deleted", ignore = true) + Rental toModel(RentalRequestDto dto); + + @Mapping(target = "carId", source = "car.id") + @Mapping(target = "userId", source = "user.id") + @Mapping(target = "userEmail", source = "user.email") + @Mapping(target = "isActive", expression = "java(rental.getActualReturnDate() == null)") + RentalResponseDto toDto(Rental rental); +} diff --git a/src/main/java/com/senkiv/carsharing/model/Rental.java b/src/main/java/com/senkiv/carsharing/model/Rental.java index a507fb7..25717be 100644 --- a/src/main/java/com/senkiv/carsharing/model/Rental.java +++ b/src/main/java/com/senkiv/carsharing/model/Rental.java @@ -2,7 +2,6 @@ import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; @@ -10,6 +9,8 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import java.time.LocalDate; +import lombok.Getter; +import lombok.Setter; import org.hibernate.annotations.SQLDelete; import org.hibernate.annotations.SQLRestriction; @@ -17,6 +18,8 @@ @Table(name = "rentals") @SQLDelete(sql = "UPDATE rentals SET is_deleted = true WHERE id = ?") @SQLRestriction("is_deleted = false") +@Getter +@Setter public class Rental { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -25,12 +28,12 @@ public class Rental { private LocalDate rentalDate; @Column(nullable = false) private LocalDate returnDate; - @Column(nullable = false) + @Column private LocalDate actualReturnDate; - @ManyToOne(fetch = FetchType.LAZY, optional = false) + @ManyToOne(optional = false) @JoinColumn(name = "car_id") private Car car; - @ManyToOne(fetch = FetchType.LAZY, optional = false) + @ManyToOne(optional = false) @JoinColumn(name = "user_id") private User user; @Column(nullable = false, columnDefinition = "TINYINT") diff --git a/src/main/java/com/senkiv/carsharing/repository/RentalRepository.java b/src/main/java/com/senkiv/carsharing/repository/RentalRepository.java new file mode 100644 index 0000000..dd7ade8 --- /dev/null +++ b/src/main/java/com/senkiv/carsharing/repository/RentalRepository.java @@ -0,0 +1,16 @@ +package com.senkiv.carsharing.repository; + +import com.senkiv.carsharing.model.Rental; +import com.senkiv.carsharing.model.User; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface RentalRepository extends JpaRepository { + Page findByUserAndActualReturnDateIsNull(Pageable pageable, User user); + + Page findByUserAndActualReturnDateIsNotNull(Pageable pageable, User user); + +} diff --git a/src/main/java/com/senkiv/carsharing/service/RentalService.java b/src/main/java/com/senkiv/carsharing/service/RentalService.java new file mode 100644 index 0000000..45e8971 --- /dev/null +++ b/src/main/java/com/senkiv/carsharing/service/RentalService.java @@ -0,0 +1,17 @@ +package com.senkiv.carsharing.service; + +import com.senkiv.carsharing.dto.RentalRequestDto; +import com.senkiv.carsharing.dto.RentalResponseDto; +import com.senkiv.carsharing.dto.RentalReturnRequestDto; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface RentalService { + RentalResponseDto add(RentalRequestDto requestDto); + + Page getByUserAndStatus(Pageable pageable, Long userId, boolean isActive); + + RentalResponseDto getById(Long id); + + RentalResponseDto returnRental(Long id, RentalReturnRequestDto requestDto); +} diff --git a/src/main/java/com/senkiv/carsharing/service/impl/RentalServiceImpl.java b/src/main/java/com/senkiv/carsharing/service/impl/RentalServiceImpl.java new file mode 100644 index 0000000..5639dc6 --- /dev/null +++ b/src/main/java/com/senkiv/carsharing/service/impl/RentalServiceImpl.java @@ -0,0 +1,106 @@ +package com.senkiv.carsharing.service.impl; + +import com.senkiv.carsharing.dto.RentalRequestDto; +import com.senkiv.carsharing.dto.RentalResponseDto; +import com.senkiv.carsharing.dto.RentalReturnRequestDto; +import com.senkiv.carsharing.exception.RentalException; +import com.senkiv.carsharing.mapper.RentalMapper; +import com.senkiv.carsharing.model.Car; +import com.senkiv.carsharing.model.Rental; +import com.senkiv.carsharing.model.User; +import com.senkiv.carsharing.repository.CarRepository; +import com.senkiv.carsharing.repository.RentalRepository; +import com.senkiv.carsharing.repository.UserRepository; +import com.senkiv.carsharing.service.RentalService; +import jakarta.persistence.EntityNotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class RentalServiceImpl implements RentalService { + public static final String UNAUTHORIZED_USER_MESSAGE = "User not authenticated"; + private static final String RENTAL_NOT_FOUND_MESSAGE = "Rental with id %d not found"; + private static final String CAR_NOT_FOUND_MESSAGE = "Car with id %d not found"; + private static final String USER_NOT_FOUND_MESSAGE = "User with id %d not found"; + private static final String CURRENT_USER_NOT_FOUND_MESSAGE = "Current user not found"; + private static final String CAR_NOT_AVAILABLE_MESSAGE = "Car with id %d is not available"; + private static final String RENTAL_ALREADY_RETURNED_MESSAGE = + "Rental with id %d is already returned"; + private final RentalRepository rentalRepository; + private final CarRepository carRepository; + private final UserRepository userRepository; + private final RentalMapper rentalMapper; + + @Override + @Transactional + public RentalResponseDto add(RentalRequestDto requestDto) { + Car car = carRepository.findById(requestDto.carId()) + .orElseThrow(() -> new EntityNotFoundException( + CAR_NOT_FOUND_MESSAGE.formatted(requestDto.carId()))); + if (car.getInventory() <= 0) { + throw new RentalException(CAR_NOT_AVAILABLE_MESSAGE.formatted(car.getId())); + } + User user = getCurrentUser(); + Rental rental = rentalMapper.toModel(requestDto); + rental.setCar(car); + rental.setUser(user); + car.setInventory(car.getInventory() - 1); + return rentalMapper.toDto(rentalRepository.save(rental)); + } + + @Override + @Transactional + public Page getByUserAndStatus(Pageable pageable, Long userId, + boolean isActive) { + User user = userId == null ? getCurrentUser() : + userRepository.findById(userId) + .orElseThrow(() -> new EntityNotFoundException( + USER_NOT_FOUND_MESSAGE.formatted(userId))); + Page rentals; + if (isActive) { + rentals = rentalRepository.findByUserAndActualReturnDateIsNull(pageable, user); + } else { + rentals = rentalRepository.findByUserAndActualReturnDateIsNotNull(pageable, user); + } + return rentals.map(rentalMapper::toDto); + } + + @Override + public RentalResponseDto getById(Long id) { + Rental rental = rentalRepository.findById(id) + .orElseThrow(() -> new EntityNotFoundException( + RENTAL_NOT_FOUND_MESSAGE.formatted(id))); + return rentalMapper.toDto(rental); + } + + @Override + @Transactional + public RentalResponseDto returnRental(Long id, RentalReturnRequestDto requestDto) { + Rental rental = rentalRepository.findById(id) + .orElseThrow(() -> new EntityNotFoundException( + RENTAL_NOT_FOUND_MESSAGE.formatted(id))); + if (rental.getActualReturnDate() != null) { + throw new RentalException(RENTAL_ALREADY_RETURNED_MESSAGE.formatted(id)); + } + rental.setActualReturnDate(requestDto.actualReturnDate()); + Car car = rental.getCar(); + car.setInventory(car.getInventory() + 1); + carRepository.save(car); + return rentalMapper.toDto(rentalRepository.save(rental)); + } + + private User getCurrentUser() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated()) { + throw new SecurityException(UNAUTHORIZED_USER_MESSAGE); + } + return userRepository.findByEmail(authentication.getName()) + .orElseThrow(() -> new EntityNotFoundException(CURRENT_USER_NOT_FOUND_MESSAGE)); + } +} diff --git a/src/main/java/com/senkiv/carsharing/service/impl/UserServiceImpl.java b/src/main/java/com/senkiv/carsharing/service/impl/UserServiceImpl.java index 623bbbf..50646b0 100644 --- a/src/main/java/com/senkiv/carsharing/service/impl/UserServiceImpl.java +++ b/src/main/java/com/senkiv/carsharing/service/impl/UserServiceImpl.java @@ -27,7 +27,7 @@ @RequiredArgsConstructor public class UserServiceImpl implements UserService { public static final String EMAIL_ALREADY_USED_MESSAGE = "Email %s is already used."; - public static final String ERROR_DEFAULT_ROLE_ASSIGNMENT = + public static final String ERROR_DEFAULT_ROLE_ASSIGNMENT = "Cannot assign default role to user."; public static final String USER_NOT_FOUND_MESSAGE = "User with id %d not found"; public static final String ROLE_NOT_FOUND_MESSAGE = "Role %s not found"; diff --git a/src/main/java/com/senkiv/carsharing/validation/FieldMatchValidator.java b/src/main/java/com/senkiv/carsharing/validation/FieldMatchValidator.java index 56bc1b1..7ccf0d9 100644 --- a/src/main/java/com/senkiv/carsharing/validation/FieldMatchValidator.java +++ b/src/main/java/com/senkiv/carsharing/validation/FieldMatchValidator.java @@ -1,5 +1,6 @@ package com.senkiv.carsharing.validation; +import com.senkiv.carsharing.validation.annotation.FieldMatch; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; import java.util.Objects; diff --git a/src/main/java/com/senkiv/carsharing/validation/FieldMatch.java b/src/main/java/com/senkiv/carsharing/validation/annotation/FieldMatch.java similarity index 84% rename from src/main/java/com/senkiv/carsharing/validation/FieldMatch.java rename to src/main/java/com/senkiv/carsharing/validation/annotation/FieldMatch.java index ec63429..c15e449 100644 --- a/src/main/java/com/senkiv/carsharing/validation/FieldMatch.java +++ b/src/main/java/com/senkiv/carsharing/validation/annotation/FieldMatch.java @@ -1,5 +1,6 @@ -package com.senkiv.carsharing.validation; +package com.senkiv.carsharing.validation.annotation; +import com.senkiv.carsharing.validation.FieldMatchValidator; import jakarta.validation.Constraint; import jakarta.validation.Payload; import java.lang.annotation.ElementType; diff --git a/src/main/resources/db/changelog/changes/03-create-rentals-table.yaml b/src/main/resources/db/changelog/changes/03-create-rentals-table.yaml index 4e5c655..99846e8 100644 --- a/src/main/resources/db/changelog/changes/03-create-rentals-table.yaml +++ b/src/main/resources/db/changelog/changes/03-create-rentals-table.yaml @@ -24,8 +24,6 @@ databaseChangeLog: name: return_date type: DATE - column: - constraints: - nullable: false name: actual_return_date type: DATE - column: diff --git a/src/test/java/com/senkiv/carsharing/controller/CarControllerTest.java b/src/test/java/com/senkiv/carsharing/controller/CarControllerTest.java index 28e8754..3af2b2a 100644 --- a/src/test/java/com/senkiv/carsharing/controller/CarControllerTest.java +++ b/src/test/java/com/senkiv/carsharing/controller/CarControllerTest.java @@ -18,6 +18,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.senkiv.carsharing.dto.CarRequestDto; import com.senkiv.carsharing.dto.CarResponseDto; +import com.senkiv.carsharing.exception.GlobalExceptionHandler; import com.senkiv.carsharing.model.CarType; import com.senkiv.carsharing.service.CarService; import jakarta.persistence.EntityNotFoundException; @@ -38,16 +39,13 @@ @ExtendWith(MockitoExtension.class) class CarControllerTest { + private final ObjectMapper objectMapper = new ObjectMapper(); private MockMvc mockMvc; - @Mock private CarService carService; - @InjectMocks private CarController carController; - private final ObjectMapper objectMapper = new ObjectMapper(); - @BeforeEach void setUp() { mockMvc = MockMvcBuilders.standaloneSetup(carController) @@ -159,7 +157,7 @@ void getCarById_WithNonExistingId_ShouldReturnNotFound() throws Exception { // When & Then mockMvc.perform(get("/cars/{id}", id)) - .andExpect(status().isNotFound()); + .andExpect(status().isBadRequest()); } @Test @@ -217,7 +215,7 @@ void updateCar_WithNonExistingId_ShouldReturnNotFound() throws Exception { mockMvc.perform(put("/cars/{id}", id) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(requestDto))) - .andExpect(status().isNotFound()); + .andExpect(status().isBadRequest()); } @Test @@ -242,7 +240,7 @@ void deleteCar_WithNonExistingId_ShouldReturnNotFound() throws Exception { // When & Then mockMvc.perform(delete("/cars/{id}", id)) - .andExpect(status().isNotFound()); + .andExpect(status().isBadRequest()); verify(carService).delete(id); } diff --git a/src/test/java/com/senkiv/carsharing/validation/FieldMatchValidatorTest.java b/src/test/java/com/senkiv/carsharing/validation/FieldMatchValidatorTest.java index b1e6b18..39392c2 100644 --- a/src/test/java/com/senkiv/carsharing/validation/FieldMatchValidatorTest.java +++ b/src/test/java/com/senkiv/carsharing/validation/FieldMatchValidatorTest.java @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import com.senkiv.carsharing.dto.UserRegistrationRequestDto; +import com.senkiv.carsharing.validation.annotation.FieldMatch; import jakarta.validation.ConstraintViolation; import jakarta.validation.Validation; import jakarta.validation.Validator;