diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..55b3f79 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# Required environment variables. +# See "Configure the JWT secret" in README.md to generate .env with a real value. +JWT_SECRET= + +# PostgreSQL (only needed for the `postgres` profile). +POSTGRES_DB=restoreserve +POSTGRES_USER=restoreserve +POSTGRES_PASSWORD= \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9ded242 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: CI + +on: + push: + branches: [develop, main] + pull_request: + branches: [develop, main] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + env: + JWT_SECRET: ci-only-secret-not-for-production-use-0123456789 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '17' + cache: maven + - name: Run tests + run: ./mvnw -B test \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..30dcb8c --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +target/ +.idea/ +*.iml +.classpath +.factorypath +.project +.settings/ +.env diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..ffcab66 --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip diff --git a/.postman/resources.yaml b/.postman/resources.yaml new file mode 100644 index 0000000..ed24fef --- /dev/null +++ b/.postman/resources.yaml @@ -0,0 +1,9 @@ +# Use this workspace to collaborate +workspace: + id: 13ec3ef0-3bb0-47ac-b761-570cdb795e02 + +# All resources in the `postman/` folder are automatically registered in Local View. +# Point to additional files outside the `postman/` folder to register them individually. Example: +#localResources: +# collections: +# - ../tests/E2E Test Collection/ diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..d4726ae --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,14 @@ +{ + "configurations": [ + { + "type": "java", + "name": "Spring Boot-RestoreserveApplication", + "request": "launch", + "cwd": "${workspaceFolder}", + "mainClass": "com.pachedev.restoreserve.RestoreserveApplication", + "projectName": "restoreserve", + "args": "", + "envFile": "${workspaceFolder}/.env" + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..c5f3f6b --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "java.configuration.updateBuildConfiguration": "interactive" +} \ No newline at end of file diff --git a/HELP.md b/HELP.md deleted file mode 100644 index 8604376..0000000 --- a/HELP.md +++ /dev/null @@ -1,33 +0,0 @@ -# Getting Started - -### Reference Documentation -For further reference, please consider the following sections: - -* [Official Apache Maven documentation](https://maven.apache.org/guides/index.html) -* [Spring Boot Maven Plugin Reference Guide](https://docs.spring.io/spring-boot/3.5.14/maven-plugin) -* [Create an OCI image](https://docs.spring.io/spring-boot/3.5.14/maven-plugin/build-image.html) -* [Spring Boot DevTools](https://docs.spring.io/spring-boot/3.5.14/reference/using/devtools.html) -* [Spring Web](https://docs.spring.io/spring-boot/3.5.14/reference/web/servlet.html) -* [SpringDoc OpenAPI](https://springdoc.org/) -* [Spring Security](https://docs.spring.io/spring-boot/3.5.14/reference/web/spring-security.html) -* [Validation](https://docs.spring.io/spring-boot/3.5.14/reference/io/validation.html) - -### Guides -The following guides illustrate how to use some features concretely: - -* [Building a RESTful Web Service](https://spring.io/guides/gs/rest-service/) -* [Serving Web Content with Spring MVC](https://spring.io/guides/gs/serving-web-content/) -* [Building REST services with Spring](https://spring.io/guides/tutorials/rest/) -* [SpringDoc OpenAPI](https://github.com/springdoc/springdoc-openapi-demos/) -* [Securing a Web Application](https://spring.io/guides/gs/securing-web/) -* [Spring Boot and OAuth2](https://spring.io/guides/tutorials/spring-boot-oauth2/) -* [Authenticating a User with LDAP](https://spring.io/guides/gs/authenticating-ldap/) -* [Validation](https://spring.io/guides/gs/validating-form-input/) - -### Maven Parent overrides - -Due to Maven's design, elements are inherited from the parent POM to the project POM. -While most of the inheritance is fine, it also inherits unwanted elements like `` and `` from the parent. -To prevent this, the project POM contains empty overrides for these elements. -If you manually switch to a different parent and actually want the inheritance, you need to remove those overrides. - diff --git a/README.md b/README.md new file mode 100644 index 0000000..062a0ce --- /dev/null +++ b/README.md @@ -0,0 +1,296 @@ +# RestoReserve API + +[![CI](https://github.com/cloudxdam/restoreserve-api/actions/workflows/ci.yml/badge.svg?branch=develop)](https://github.com/cloudxdam/restoreserve-api/actions/workflows/ci.yml) + +RestoReserve API is a Spring Boot REST API for managing restaurant tables, reservations, and user access with JWT authentication. + +The project started as an academic project and has since been refined with a focus on backend fundamentals: layered architecture, validation, security, persistence, testing, and business rules. + +## Tech Stack + +- Java 17 +- Spring Boot 3.5.14 +- Spring Web +- Spring Security +- Spring Data JPA +- H2 Database (default profile) +- PostgreSQL (`postgres` profile, optional) +- JJWT +- Spring Validation +- Springdoc OpenAPI +- Lombok +- JUnit 5 + Mockito +- Spring Security Test +- Maven +- Docker Compose + +## Main Features + +- User registration and login with JWT-based authentication +- BCrypt password hashing +- Stateless authentication with Spring Security +- Role-based access control for `ADMIN` and `USER` +- Restaurant table management with filtering by status, location, and capacity +- Reservation management with business validations +- Reservation ownership and access control +- Penalization policy for late cancellations +- Automatic user banning after exceeding the penalization threshold +- Admin endpoint to reset penalization points and reactivate users +- Global exception handling +- Swagger / OpenAPI documentation + +## Architecture + +The application follows a layered architecture: + +```text +Controller + ↓ +Service + ↓ +Repository + ↓ +Database +``` + +Controllers handle HTTP requests and validation, services contain business rules, and repositories provide persistence through Spring Data JPA. + +Security is handled separately through Spring Security and a custom JWT authentication filter. + +## Business Rules + +The reservation service implements several domain rules: + +- Reservations must be created for a future date and time. +- A reservation cannot exceed the maximum capacity of the selected table. +- A table cannot have overlapping confirmed reservations. +- Each confirmed reservation occupies a two-hour time window. +- Users with status `BANNED` cannot create new reservations. +- Cancelling a reservation less than two hours before its scheduled time adds 2 penalty points. +- Users exceeding 6 penalty points are moved to `BANNED`. +- Administrators can reset a user's penalization and restore `ACTIVE` status. + +## API Overview + +### Authentication + +- `POST /api/v1/auth/register` — register a new user +- `POST /api/v1/auth/login` — authenticate and receive a JWT token + +### Reservations + +- `POST /api/v1/reservations` — create a reservation for the authenticated user +- `GET /api/v1/reservations` — list reservations accessible to the current user +- `GET /api/v1/reservations/{id}` — get a reservation by ID +- `GET /api/v1/reservations/status/{status}` — filter reservations by status +- `DELETE /api/v1/reservations/{id}` — cancel a reservation + +### Restaurant Tables + +- `POST /api/v1/tables` — create a table (`ADMIN`) +- `GET /api/v1/tables` — list all tables (`ADMIN`) +- `GET /api/v1/tables/{id}` — get a table by ID (`ADMIN`) +- `PUT /api/v1/tables/{id}` — update a table (`ADMIN`) +- `DELETE /api/v1/tables/{id}` — delete a table (`ADMIN`) +- `GET /api/v1/tables/capacity/{pax}` — filter by minimum capacity (`ADMIN`) +- `GET /api/v1/tables/status/{status}` — filter by status (`ADMIN`) +- `GET /api/v1/tables/location/{location}` — filter by location (`ADMIN`) +- `GET /api/v1/tables/location-status?location=...&status=...` — combined filter (`ADMIN`) +- `GET /api/v1/tables/available?status=...&maxPax=...` — filter by status and capacity (`ADMIN`) + +### Users + +- `PATCH /api/v1/users/{id}/reset-penalization` — reset penalization points and restore `ACTIVE` status (`ADMIN`) + +## Validation and Error Handling + +The API uses Bean Validation on request DTOs and a global exception handler to provide consistent error responses for: + +- Validation errors +- Missing resources +- Business rule violations +- Banned users +- Other application-specific exceptions + +Custom exceptions include: + +- `ResourceNotFoundException` +- `BusinessLogicException` +- `BannedUserException` +- `NotVipUserException` + +## Local Run + +### Requirements + +- Java 17 +- Maven Wrapper included in the project +- Docker (only for the optional PostgreSQL profile) + +### Configure the environment + +The application signs its tokens with a key read from the `JWT_SECRET` environment +variable. Neither the key nor the database credentials are stored in the repository, +so the application will not start until you provide them. + +Create a local `.env` file (already ignored by git) with freshly generated values: + +```bash +printf 'JWT_SECRET=%s\nPOSTGRES_DB=restoreserve\nPOSTGRES_USER=restoreserve\nPOSTGRES_PASSWORD=%s\n' \ + "$(openssl rand -base64 64)" "$(openssl rand -hex 16)" > .env +``` + +The command overwrites any existing `.env`. The required variables are documented in +`.env.example`. Never commit `.env`. + +### Start the application (H2, default) + +Linux / macOS: + +```bash +./mvnw spring-boot:run +``` + +Windows: + +```powershell +mvnw.cmd spring-boot:run +``` + +### Start the application with PostgreSQL (optional) + +Start the database and wait until it reports `healthy`: + +```bash +docker compose up -d +docker compose ps +``` + +Then start the application: + +```bash +./mvnw spring-boot:run -Dspring-boot.run.profiles=postgres +``` + +Stop the database when you are done: + +```bash +docker compose down +``` + +Data lives in a named volume and survives container restarts. Use `docker compose down -v` +only if you want to delete it. + +## Swagger UI + +Once the application is running, open: + +```text +http://localhost:8080/swagger-ui/index.html +``` + +The OpenAPI documentation includes Bearer JWT authentication, allowing protected endpoints to be tested directly from Swagger UI. + +## Authentication Flow + +1. Register a user with `POST /api/v1/auth/register` or use the seeded users for local testing. +2. Login with `POST /api/v1/auth/login`. +3. Copy the returned JWT token. +4. In Swagger UI, click `Authorize`. +5. Enter `Bearer `. +6. Call the protected endpoints. + +## Demo Users + +The project includes seeded users for local development. + +### User + +```text +Username: armymoves +Password: 1234 +Role: ROLE_USER +``` + +### Administrator + +```text +Username: admin +Password: Admin1234 +Role: ROLE_ADMIN +``` + +These credentials are intended for local development and demonstration only. + +## Project Structure + +```text +src/main/java/com/pachedev/restoreserve +├── config +├── controller +├── dto +├── exception +├── model +│ ├── entity +│ └── enums +├── repository +├── security +└── service +``` + +## Testing + +The project uses: + +- JUnit 5 +- Mockito +- Spring Boot Test +- Spring Security Test + +Current tests cover application context loading and reservation-service business rules, including validation, banned users, reservation ownership, cancellation, penalization, and automatic banning. + +Run the test suite with: + +```bash +./mvnw test +``` + +## Notes About the Current Setup + +- The default profile runs on an in-memory H2 database; the optional `postgres` profile runs on PostgreSQL through Docker Compose. +- Demo data is seeded on startup by `config/DataSeeder` and is skipped when users already exist, so restarting the application does not duplicate it. +- JWT authentication is implemented with JJWT and Spring Security. +- The project is currently focused on local execution and portfolio presentation. + +## Future Improvements + +- Add more controller, security, and integration tests. +- Containerize the API with a Dockerfile and an `app` service in compose. +- Replace `ddl-auto=update` with Flyway migrations. +- Add production-oriented configuration and observability. + +## Why This Project Matters + +This project goes beyond a basic CRUD application. + +It demonstrates practical backend concepts including: + +- REST API design +- Authentication and authorization +- JWT security +- Role-based access control +- DTO-based request and response handling +- Bean Validation +- JPA persistence +- Business-rule implementation +- Centralized exception handling +- Unit testing and mocking +- API documentation with OpenAPI + +It provides a foundation for demonstrating junior backend development skills with Java and Spring Boot while continuing to evolve toward a more production-oriented architecture. + +## Author + +**Daniel Pacheco** + +GitHub: [cloudxdam](https://github.com/cloudxdam) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..09f6774 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +services: + db: + image: postgres:17-alpine + container_name: restoreserve-db + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:?POSTGRES_DB is not set in .env} + POSTGRES_USER: ${POSTGRES_USER:?POSTGRES_USER is not set in .env} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is not set in .env} + ports: + - "5432:5432" + volumes: + - restoreserve-pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 5s + timeout: 5s + retries: 10 + +volumes: + restoreserve-pgdata: \ No newline at end of file diff --git a/mvnw b/mvnw old mode 100644 new mode 100755 diff --git a/mvnw.cmd b/mvnw.cmd index 92450f9..5761d94 100644 --- a/mvnw.cmd +++ b/mvnw.cmd @@ -1,189 +1,189 @@ -<# : batch portion -@REM ---------------------------------------------------------------------------- -@REM Licensed to the Apache Software Foundation (ASF) under one -@REM or more contributor license agreements. See the NOTICE file -@REM distributed with this work for additional information -@REM regarding copyright ownership. The ASF licenses this file -@REM to you under the Apache License, Version 2.0 (the -@REM "License"); you may not use this file except in compliance -@REM with the License. You may obtain a copy of the License at -@REM -@REM http://www.apache.org/licenses/LICENSE-2.0 -@REM -@REM Unless required by applicable law or agreed to in writing, -@REM software distributed under the License is distributed on an -@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -@REM KIND, either express or implied. See the License for the -@REM specific language governing permissions and limitations -@REM under the License. -@REM ---------------------------------------------------------------------------- - -@REM ---------------------------------------------------------------------------- -@REM Apache Maven Wrapper startup batch script, version 3.3.4 -@REM -@REM Optional ENV vars -@REM MVNW_REPOURL - repo url base for downloading maven distribution -@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven -@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output -@REM ---------------------------------------------------------------------------- - -@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) -@SET __MVNW_CMD__= -@SET __MVNW_ERROR__= -@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% -@SET PSModulePath= -@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( - IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) -) -@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% -@SET __MVNW_PSMODULEP_SAVE= -@SET __MVNW_ARG0_NAME__= -@SET MVNW_USERNAME= -@SET MVNW_PASSWORD= -@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) -@echo Cannot start maven from wrapper >&2 && exit /b 1 -@GOTO :EOF -: end batch / begin powershell #> - -$ErrorActionPreference = "Stop" -if ($env:MVNW_VERBOSE -eq "true") { - $VerbosePreference = "Continue" -} - -# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties -$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl -if (!$distributionUrl) { - Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" -} - -switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { - "maven-mvnd-*" { - $USE_MVND = $true - $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" - $MVN_CMD = "mvnd.cmd" - break - } - default { - $USE_MVND = $false - $MVN_CMD = $script -replace '^mvnw','mvn' - break - } -} - -# apply MVNW_REPOURL and calculate MAVEN_HOME -# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ -if ($env:MVNW_REPOURL) { - $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } - $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" -} -$distributionUrlName = $distributionUrl -replace '^.*/','' -$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' - -$MAVEN_M2_PATH = "$HOME/.m2" -if ($env:MAVEN_USER_HOME) { - $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" -} - -if (-not (Test-Path -Path $MAVEN_M2_PATH)) { - New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null -} - -$MAVEN_WRAPPER_DISTS = $null -if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { - $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" -} else { - $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" -} - -$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" -$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' -$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" - -if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { - Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" - Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" - exit $? -} - -if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { - Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" -} - -# prepare tmp dir -$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile -$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" -$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null -trap { - if ($TMP_DOWNLOAD_DIR.Exists) { - try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } - catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } - } -} - -New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null - -# Download and Install Apache Maven -Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." -Write-Verbose "Downloading from: $distributionUrl" -Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" - -$webclient = New-Object System.Net.WebClient -if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { - $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) -} -[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null - -# If specified, validate the SHA-256 sum of the Maven distribution zip file -$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum -if ($distributionSha256Sum) { - if ($USE_MVND) { - Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." - } - Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash - if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { - Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." - } -} - -# unzip and move -Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null - -# Find the actual extracted directory name (handles snapshots where filename != directory name) -$actualDistributionDir = "" - -# First try the expected directory name (for regular distributions) -$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" -$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" -if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { - $actualDistributionDir = $distributionUrlNameMain -} - -# If not found, search for any directory with the Maven executable (for snapshots) -if (!$actualDistributionDir) { - Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { - $testPath = Join-Path $_.FullName "bin/$MVN_CMD" - if (Test-Path -Path $testPath -PathType Leaf) { - $actualDistributionDir = $_.Name - } - } -} - -if (!$actualDistributionDir) { - Write-Error "Could not find Maven distribution directory in extracted archive" -} - -Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" -Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null -try { - Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null -} catch { - if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { - Write-Error "fail to move MAVEN_HOME" - } -} finally { - try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } - catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } -} - -Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/pom.xml b/pom.xml index a995645..be1bb0e 100644 --- a/pom.xml +++ b/pom.xml @@ -11,14 +11,16 @@ com.pachedev restoreserve 0.0.1-SNAPSHOT - - - + RestoReserve API + Spring Boot REST API for managing restaurant tables, reservations, and JWT-based authentication. + https://github.com/cloudxdam/restoreserve-api - + + Daniel Pacheco + @@ -34,6 +36,25 @@ org.springframework.boot spring-boot-starter-security + + io.jsonwebtoken + jjwt-api + 0.13.0 + + + + io.jsonwebtoken + jjwt-impl + 0.13.0 + runtime + + + + io.jsonwebtoken + jjwt-jackson + 0.13.0 + runtime + org.springframework.boot spring-boot-starter-validation @@ -59,6 +80,11 @@ h2 runtime + + org.postgresql + postgresql + runtime + org.projectlombok lombok @@ -74,6 +100,10 @@ spring-security-test test + + org.springframework.boot + spring-boot-starter-data-jpa + diff --git a/postman/collections/RestoReserve Collection/.resources/definition.yaml b/postman/collections/RestoReserve Collection/.resources/definition.yaml new file mode 100644 index 0000000..4420271 --- /dev/null +++ b/postman/collections/RestoReserve Collection/.resources/definition.yaml @@ -0,0 +1,2 @@ +$kind: collection +name: RestoReserve Collection diff --git a/postman/collections/RestoReserve Collection/Cancel a Reservation (soft delete).request.yaml b/postman/collections/RestoReserve Collection/Cancel a Reservation (soft delete).request.yaml new file mode 100644 index 0000000..d5b905f --- /dev/null +++ b/postman/collections/RestoReserve Collection/Cancel a Reservation (soft delete).request.yaml @@ -0,0 +1,5 @@ +$kind: http-request +name: Cancel a Reservation (soft delete) +url: http://localhost:8080/api/v1/reservations/1 +method: DELETE +order: 4441617885660404 diff --git a/postman/collections/RestoReserve Collection/Create a new reservation.request.yaml b/postman/collections/RestoReserve Collection/Create a new reservation.request.yaml new file mode 100644 index 0000000..7e4d944 --- /dev/null +++ b/postman/collections/RestoReserve Collection/Create a new reservation.request.yaml @@ -0,0 +1,18 @@ +$kind: http-request +name: Create a new reservation +description: Get all reservations +url: http://localhost:8080/api/v1/reservations +method: POST +body: + type: json + content: |- + { + "tableId": 1, + "reservationDate": "2026-05-20T20:00:00", + "numberOfGuests": 2 + } +auth: + type: bearer + credentials: + token: eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiJhcm15bW92ZXMiLCJpYXQiOjE3Nzg1Mjg3MDQsImV4cCI6MTc3ODYxNTEwNH0.skbvHYXliP3y5kspCJGfMsWX5QCZ5UN7JoEvcNqj7rUn6E_Hsu_fypRswr3j8C53 +order: 178851836358 diff --git a/postman/collections/RestoReserve Collection/Create a overlapping reservation.request.yaml b/postman/collections/RestoReserve Collection/Create a overlapping reservation.request.yaml new file mode 100644 index 0000000..58a5569 --- /dev/null +++ b/postman/collections/RestoReserve Collection/Create a overlapping reservation.request.yaml @@ -0,0 +1,18 @@ +$kind: http-request +name: Create a overlapping reservation +description: Get all reservations +url: http://localhost:8080/api/v1/reservations +method: POST +body: + type: json + content: |- + { + "tableId": 2, + "reservationDate": "2026-05-16T15:00:00", + "numberOfGuests": 2 + } +auth: + type: bearer + credentials: + token: eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiJhcm15bW92ZXMiLCJpYXQiOjE3Nzg1Mjg3MDQsImV4cCI6MTc3ODYxNTEwNH0.skbvHYXliP3y5kspCJGfMsWX5QCZ5UN7JoEvcNqj7rUn6E_Hsu_fypRswr3j8C53 +order: 1067421965123421 diff --git a/postman/collections/RestoReserve Collection/Create a past reservation.request.yaml b/postman/collections/RestoReserve Collection/Create a past reservation.request.yaml new file mode 100644 index 0000000..258b191 --- /dev/null +++ b/postman/collections/RestoReserve Collection/Create a past reservation.request.yaml @@ -0,0 +1,19 @@ +$kind: http-request +name: Create a past reservation +description: Get all reservations +url: http://localhost:8080/api/v1/reservations +method: POST +body: + type: json + content: |- + { + "userId": 1, + "tableId": 3, + "reservationDate": "2026-03-10T22:00:00", + "numberOfGuests": 4 + } +auth: + type: bearer + credentials: + token: eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiJhcm15bW92ZXMiLCJpYXQiOjE3Nzg1Mjg3MDQsImV4cCI6MTc3ODYxNTEwNH0.skbvHYXliP3y5kspCJGfMsWX5QCZ5UN7JoEvcNqj7rUn6E_Hsu_fypRswr3j8C53 +order: 950741626747683 diff --git a/postman/collections/RestoReserve Collection/Create a reservation exceeding table capacity.request.yaml b/postman/collections/RestoReserve Collection/Create a reservation exceeding table capacity.request.yaml new file mode 100644 index 0000000..2f84679 --- /dev/null +++ b/postman/collections/RestoReserve Collection/Create a reservation exceeding table capacity.request.yaml @@ -0,0 +1,18 @@ +$kind: http-request +name: Create a reservation exceeding table capacity +description: Get all reservations +url: http://localhost:8080/api/v1/reservations +method: POST +body: + type: json + content: |- + { + "tableId": 2, + "reservationDate": "2026-05-16T15:00:00", + "numberOfGuests": 4 + } +auth: + credentials: + token: eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiJhcm15bW92ZXMiLCJpYXQiOjE3Nzg1Mjg3MDQsImV4cCI6MTc3ODYxNTEwNH0.skbvHYXliP3y5kspCJGfMsWX5QCZ5UN7JoEvcNqj7rUn6E_Hsu_fypRswr3j8C53 + type: bearer +order: 1263142336234000 diff --git a/postman/collections/RestoReserve Collection/Get a reservation by its id.request.yaml b/postman/collections/RestoReserve Collection/Get a reservation by its id.request.yaml new file mode 100644 index 0000000..392dff6 --- /dev/null +++ b/postman/collections/RestoReserve Collection/Get a reservation by its id.request.yaml @@ -0,0 +1,12 @@ +$kind: http-request +name: Get a reservation by its id +url: http://localhost:8080/api/v1/reservations/2 +method: GET +body: + type: json + content: "" +auth: + type: bearer + credentials: + token: eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiJhcm15bW92ZXMiLCJpYXQiOjE3Nzg1Mjg3MDQsImV4cCI6MTc3ODYxNTEwNH0.skbvHYXliP3y5kspCJGfMsWX5QCZ5UN7JoEvcNqj7rUn6E_Hsu_fypRswr3j8C53 +order: 1442191939044361 diff --git a/postman/collections/RestoReserve Collection/Get reservations by status.request.yaml b/postman/collections/RestoReserve Collection/Get reservations by status.request.yaml new file mode 100644 index 0000000..026e0f8 --- /dev/null +++ b/postman/collections/RestoReserve Collection/Get reservations by status.request.yaml @@ -0,0 +1,9 @@ +$kind: http-request +name: Get reservations by status +url: http://localhost:8080/api/v1/reservations/status/CONFIRMED +method: GET +auth: + type: bearer + credentials: + token: eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiJhcm15bW92ZXMiLCJpYXQiOjE3Nzg1Mjg3MDQsImV4cCI6MTc3ODYxNTEwNH0.skbvHYXliP3y5kspCJGfMsWX5QCZ5UN7JoEvcNqj7rUn6E_Hsu_fypRswr3j8C53 +order: 4411268052689732 diff --git a/postman/collections/RestoReserve Collection/Get reservations.request.yaml b/postman/collections/RestoReserve Collection/Get reservations.request.yaml new file mode 100644 index 0000000..546f0d1 --- /dev/null +++ b/postman/collections/RestoReserve Collection/Get reservations.request.yaml @@ -0,0 +1,13 @@ +$kind: http-request +name: Get reservations +url: http://localhost:8080/api/v1/reservations +method: GET +body: + type: json + content: "" +auth: + credentials: + - key: token + value: eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiJhZG1pbiIsImlhdCI6MTc3ODUyODQ0OCwiZXhwIjoxNzc4NjE0ODQ4fQ.JRtbYGO9erQNUcgnmVtOvrSD-TbA81Unf8kYnUtB7rhEcZkjkasQnF5AEPYNCAZY + type: bearer +order: 169061786500 diff --git a/postman/collections/RestoReserve Collection/Get tables.request.yaml b/postman/collections/RestoReserve Collection/Get tables.request.yaml new file mode 100644 index 0000000..cbfbaa0 --- /dev/null +++ b/postman/collections/RestoReserve Collection/Get tables.request.yaml @@ -0,0 +1,5 @@ +$kind: http-request +name: Get tables +url: "" +method: GET +order: 4477808826848290 diff --git a/postman/collections/RestoReserve Collection/Login Admin.request.yaml b/postman/collections/RestoReserve Collection/Login Admin.request.yaml new file mode 100644 index 0000000..8a27a08 --- /dev/null +++ b/postman/collections/RestoReserve Collection/Login Admin.request.yaml @@ -0,0 +1,12 @@ +$kind: http-request +name: Login Admin +url: http://localhost:8080/api/v1/auth/login +method: POST +body: + type: json + content: |- + { + "username": "admin", + "password": "Admin1234" + } +order: 156544645875 diff --git a/postman/collections/RestoReserve Collection/Login user.request.yaml b/postman/collections/RestoReserve Collection/Login user.request.yaml new file mode 100644 index 0000000..87114e1 --- /dev/null +++ b/postman/collections/RestoReserve Collection/Login user.request.yaml @@ -0,0 +1,12 @@ +$kind: http-request +name: Login user +url: http://localhost:8080/api/v1/auth/login +method: POST +body: + type: json + content: |- + { + "username": "armymoves", + "password": "1234" + } +order: 137079644081 diff --git a/postman/collections/RestoReserve Collection/New Request.request.yaml b/postman/collections/RestoReserve Collection/New Request.request.yaml new file mode 100644 index 0000000..a34c741 --- /dev/null +++ b/postman/collections/RestoReserve Collection/New Request.request.yaml @@ -0,0 +1,4 @@ +$kind: http-request +url: "" +method: GET +order: 4481221878910252 diff --git a/postman/collections/RestoReserve Collection/Register new user.request.yaml b/postman/collections/RestoReserve Collection/Register new user.request.yaml new file mode 100644 index 0000000..84b2773 --- /dev/null +++ b/postman/collections/RestoReserve Collection/Register new user.request.yaml @@ -0,0 +1,15 @@ +$kind: http-request +name: Register new user +url: http://localhost:8080/api/v1/auth/register +method: POST +body: + type: json + content: |- + { + "name": "Tanausú Febles", + "username": "ertana", + "telephone": "600000000", + "email": "tana@gmail.com", + "password": "1234" + } +order: 66320542482 diff --git a/postman/globals/workspace.globals.yaml b/postman/globals/workspace.globals.yaml new file mode 100644 index 0000000..e96c6d6 --- /dev/null +++ b/postman/globals/workspace.globals.yaml @@ -0,0 +1,2 @@ +name: Globals +values: [] diff --git a/src/main/java/com/pachedev/restoreserve/config/DataSeeder.java b/src/main/java/com/pachedev/restoreserve/config/DataSeeder.java new file mode 100644 index 0000000..09f7294 --- /dev/null +++ b/src/main/java/com/pachedev/restoreserve/config/DataSeeder.java @@ -0,0 +1,81 @@ +package com.pachedev.restoreserve.config; + +import com.pachedev.restoreserve.model.entity.AppUser; +import com.pachedev.restoreserve.model.entity.Reservation; +import com.pachedev.restoreserve.model.entity.RestaurantTable; +import com.pachedev.restoreserve.model.enums.*; +import com.pachedev.restoreserve.repository.AppUserRepository; +import com.pachedev.restoreserve.repository.ReservationRepository; +import com.pachedev.restoreserve.repository.RestaurantTableRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Component +@RequiredArgsConstructor +public class DataSeeder implements CommandLineRunner { + + private final AppUserRepository userRepository; + private final RestaurantTableRepository restaurantTableRepository; + private final ReservationRepository reservationRepository; + + @Override + @Transactional + public void run(String... args) throws Exception { + if (userRepository.count() > 0) { + return; + } + + RestaurantTable mesa1 = createTable("Mesa 1", 4, TableStatus.AVAILABLE, TableLocation.SALON, false); + RestaurantTable mesa2 = createTable("Mesa 2", 2, TableStatus.RESERVED, TableLocation.TERRACE, false); + RestaurantTable mesa3 = createTable("Mesa 3", 6, TableStatus.AVAILABLE, TableLocation.SALON, false); + RestaurantTable mesa4 = createTable("Mesa 4", 8, TableStatus.RESERVED, TableLocation.TERRACE, false); + + AppUser armymoves = createUser("Armiche Santana", "6123456789", "armiche@gmail.com", "armymoves", + "$2a$12$UfijVlkvrmwpTGERWsfsCe.R5ZIPaJPCP7TmjsnRMYtE8USwgsixi", UserRole.ROLE_USER); + AppUser admin = createUser("Acaymo Bello", "600000000", "acaymo@gmail.com", "admin", + "$2a$12$lAUz1WTEed/7SOsy.ew.OehjCeESngVWkgkc0fqRgZqeo/W4AwM..", UserRole.ROLE_ADMIN); + + createReservation(armymoves, mesa1, LocalDateTime.of(2026, 5, 16, 20, 0), 2); + createReservation(admin, mesa2, LocalDateTime.of(2026, 5, 16, 15, 0), 2); + } + + private RestaurantTable createTable(String name, Integer maxPax, TableStatus status, + TableLocation location, Boolean isVip) { + RestaurantTable table = new RestaurantTable(); + table.setName(name); + table.setMaxPax(maxPax); + table.setStatus(status); + table.setLocation(location); + table.setIsVip(isVip); + return restaurantTableRepository.save(table); + } + + private AppUser createUser(String name, String telephone, String email, String username, + String password, UserRole role) { + AppUser user = new AppUser(); + user.setName(name); + user.setTelephone(telephone); + user.setEmail(email); + user.setUsername(username); + user.setPassword(password); + user.setRole(role); + user.setPenalizationPoints(0); + user.setStatus(UserStatus.ACTIVE); + return userRepository.save(user); + } + + private void createReservation(AppUser user, RestaurantTable table, + LocalDateTime reservationDate, Integer numberOfGuests) { + Reservation reservation = new Reservation(); + reservation.setUser(user); + reservation.setTable(table); + reservation.setReservationDate(reservationDate); + reservation.setNumberOfGuests(numberOfGuests); + reservation.setStatus(ReservationStatus.CONFIRMED); + reservationRepository.save(reservation); + } +} diff --git a/src/main/java/com/pachedev/restoreserve/config/SwaggerConfig.java b/src/main/java/com/pachedev/restoreserve/config/SwaggerConfig.java new file mode 100644 index 0000000..eb37de4 --- /dev/null +++ b/src/main/java/com/pachedev/restoreserve/config/SwaggerConfig.java @@ -0,0 +1,39 @@ +package com.pachedev.restoreserve.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; + +/** + * Configuración de OpenAPI/Swagger para documentar la API y definir el esquema + * JWT. + */ +@Configuration +public class SwaggerConfig { + + /** + * Define la información básica de la API y el esquema de seguridad bearer para + * Swagger. + */ + @Bean + public OpenAPI customOpenAPI() { + + String securitySchemeName = "RestoReserveToken"; + + return new OpenAPI().info(new Info() + .title("RestoReserve API") + .version("1.0") + .description("REST API for managing restaurant tables and reservations")) + .addSecurityItem(new SecurityRequirement().addList(securitySchemeName)) + .components(new Components().addSecuritySchemes(securitySchemeName, new SecurityScheme() + .name(securitySchemeName) + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT"))); + } +} diff --git a/src/main/java/com/pachedev/restoreserve/controller/AppUserController.java b/src/main/java/com/pachedev/restoreserve/controller/AppUserController.java new file mode 100644 index 0000000..b5143a0 --- /dev/null +++ b/src/main/java/com/pachedev/restoreserve/controller/AppUserController.java @@ -0,0 +1,34 @@ +package com.pachedev.restoreserve.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.pachedev.restoreserve.service.AppUserService; + +import lombok.RequiredArgsConstructor; + +/** + * Controlador encargado de gestionar las operaciones relacionadas con los + * usuarios clientes. + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/users") +public class AppUserController { + + private final AppUserService appUserService; + + /** + * Permite a un administrador resetear la penalización de un usuario. + */ + @PatchMapping("/{id}/reset-penalization") + public ResponseEntity resetPenalization(@PathVariable Long id) { + appUserService.resetPenalization(id); + + return ResponseEntity.ok("Los puntos de penalización del usuario con id " + id + + " han sido reiniciados y su estado ha pasado a ser ACTIVE"); + } +} diff --git a/src/main/java/com/pachedev/restoreserve/controller/AuthController.java b/src/main/java/com/pachedev/restoreserve/controller/AuthController.java new file mode 100644 index 0000000..45ccae1 --- /dev/null +++ b/src/main/java/com/pachedev/restoreserve/controller/AuthController.java @@ -0,0 +1,46 @@ +package com.pachedev.restoreserve.controller; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.pachedev.restoreserve.dto.AuthRequestDTO; +import com.pachedev.restoreserve.dto.AuthResponseDTO; +import com.pachedev.restoreserve.dto.RegisterUserRequestDTO; +import com.pachedev.restoreserve.service.AuthService; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +/** + * Controlador encargado de la autenticación y el registro de usuarios. + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/auth") +public class AuthController { + + private final AuthService authService; + + /** + * Autentica a un usuario y devuelve un token JWT si las credenciales son + * válidas. + */ + @PostMapping("/login") + public ResponseEntity login(@Valid @RequestBody AuthRequestDTO dto) { + return ResponseEntity.ok(authService.login(dto)); + } + + /** + * Registra un nuevo usuario cliente en el sistema. + */ + @PostMapping("/register") + public ResponseEntity register(@Valid @RequestBody RegisterUserRequestDTO dto) { + return ResponseEntity.status(HttpStatus.CREATED).body(authService.register(dto)); + } + +} diff --git a/src/main/java/com/pachedev/restoreserve/controller/ReservationController.java b/src/main/java/com/pachedev/restoreserve/controller/ReservationController.java new file mode 100644 index 0000000..fe56477 --- /dev/null +++ b/src/main/java/com/pachedev/restoreserve/controller/ReservationController.java @@ -0,0 +1,84 @@ +package com.pachedev.restoreserve.controller; + +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.pachedev.restoreserve.dto.ReservationRequestDTO; +import com.pachedev.restoreserve.dto.ReservationResponseDTO; +import com.pachedev.restoreserve.model.enums.ReservationStatus; +import com.pachedev.restoreserve.service.ReservationService; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; + +/** + * Controlador encargado de gestionar las operaciones relacionadas con las + * reservas. + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/reservations") +public class ReservationController { + + private final ReservationService reservationService; + + /** + * Crea una nueva reserva asociándola al usuario autenticado. + */ + @PostMapping + public ResponseEntity create(@Valid @RequestBody ReservationRequestDTO dto) { + + // Obtenemos el usuario del contexto de seguridad (JWT) + String currentUsername = SecurityContextHolder.getContext().getAuthentication().getName(); + + ReservationResponseDTO response = reservationService.create(dto, currentUsername); + + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + /** + * Devuelve las reservas visibles para el usuario autenticado según su rol. + */ + @GetMapping + public ResponseEntity> findAll() { + return ResponseEntity.ok(reservationService.findAll()); + } + + /** + * Busca una reserva por su identificador si el usuario tiene permisos para + * verla. + */ + @GetMapping("/{id}") + public ResponseEntity findById(@PathVariable Long id) { + return ResponseEntity.ok(reservationService.findById(id)); + } + + /** + * Devuelve las reservas filtradas por estado según el rol del usuario + * autenticado. + */ + @GetMapping("/status/{status}") + public ResponseEntity> findByStatus(@PathVariable ReservationStatus status) { + return ResponseEntity.ok(reservationService.findByStatus(status)); + } + + /** + * Cancela una reserva existente cambiando su estado a CANCELLED. + */ + @DeleteMapping("/{id}") + public ResponseEntity cancelReservation(@PathVariable Long id) { + reservationService.cancelReservation(id); + + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/pachedev/restoreserve/controller/RestaurantTableController.java b/src/main/java/com/pachedev/restoreserve/controller/RestaurantTableController.java new file mode 100644 index 0000000..dd32c11 --- /dev/null +++ b/src/main/java/com/pachedev/restoreserve/controller/RestaurantTableController.java @@ -0,0 +1,127 @@ +package com.pachedev.restoreserve.controller; + +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +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.PutMapping; +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.RestController; + +import com.pachedev.restoreserve.dto.RestaurantTableRequestDTO; +import com.pachedev.restoreserve.dto.RestaurantTableResponseDTO; +import com.pachedev.restoreserve.model.enums.TableLocation; +import com.pachedev.restoreserve.model.enums.TableStatus; +import com.pachedev.restoreserve.service.RestaurantTableService; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +/** + * Controlador encargado de gestionar las operaciones relacionadas con las + * mesas. + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/tables") +public class RestaurantTableController { + + private final RestaurantTableService restaurantTableService; + + /** + * Crea una nueva mesa en el sistema. + */ + @PostMapping + public ResponseEntity create(@Valid @RequestBody RestaurantTableRequestDTO dto) { + RestaurantTableResponseDTO response = restaurantTableService.create(dto); + + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + /** + * Busca una mesa por su identificador. + */ + @GetMapping("/{id}") + public ResponseEntity findById(@PathVariable Long id) { + return ResponseEntity.ok(restaurantTableService.findById(id)); + } + + /** + * Devuelve la lista completa de mesas que hay registradas. + */ + @GetMapping + public ResponseEntity> findAll() { + return ResponseEntity.ok(restaurantTableService.findAll()); + } + + /** + * Actualiza los datos de una mesa existente. + */ + @PutMapping("/{id}") + public ResponseEntity update(@PathVariable Long id, + @Valid @RequestBody RestaurantTableRequestDTO dto) { + RestaurantTableResponseDTO response = restaurantTableService.update(id, dto); + + return ResponseEntity.ok(response); + } + + /** + * Elimina una mesa por su id. + */ + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Long id) { + restaurantTableService.delete(id); + return ResponseEntity.noContent().build(); + } + + /** + * Devuelve las mesas cuya capacidad es suficiente para el número de comensales + * indicado. + */ + @GetMapping("/capacity/{pax}") + public ResponseEntity> findByMaxPax(@PathVariable Integer pax) { + return ResponseEntity.ok(restaurantTableService.findByMaxPax(pax)); + } + + /** + * Devuelve las mesas filtradas por estado. + */ + @GetMapping("/status/{status}") + public ResponseEntity> findByStatus(@PathVariable TableStatus status) { + return ResponseEntity.ok(restaurantTableService.findByStatus(status)); + } + + /** + * Devuelve las mesas filtradas por ubicación. + */ + @GetMapping("/location/{location}") + public ResponseEntity> findByLocation(@PathVariable TableLocation location) { + return ResponseEntity.ok(restaurantTableService.findByLocation(location)); + } + + /** + * Devuelve las mesas filtradas por ubicación y estado. + * '/location-status?location=SALON&status=AVAILABLE' + */ + @GetMapping("/location-status") + public ResponseEntity> findByLocationAndStatus( + @RequestParam TableLocation location, @RequestParam TableStatus status) { + return ResponseEntity.ok(restaurantTableService.findByLocationAndStatus(location, status)); + } + + /** + * Devuelve las mesas filtradas por estado y capacidad. + * 'localhost:8080/api/v1/tables/available?status=AVAILABLE&maxPax=2' + */ + @GetMapping("/available") + public ResponseEntity> findByStatusAndMaxPax( + @RequestParam TableStatus status, @RequestParam Integer maxPax) { + return ResponseEntity.ok(restaurantTableService.findByStatusAndMaxPaxGreaterThanEqual(status, maxPax)); + } +} diff --git a/src/main/java/com/pachedev/restoreserve/dto/AuthRequestDTO.java b/src/main/java/com/pachedev/restoreserve/dto/AuthRequestDTO.java new file mode 100644 index 0000000..76330c6 --- /dev/null +++ b/src/main/java/com/pachedev/restoreserve/dto/AuthRequestDTO.java @@ -0,0 +1,8 @@ +package com.pachedev.restoreserve.dto; + +import jakarta.validation.constraints.NotBlank; + +public record AuthRequestDTO( + @NotBlank(message = "El nombre de usuario es obligatorio") String username, + @NotBlank(message = "El password es obligatorio") String password) { +} \ No newline at end of file diff --git a/src/main/java/com/pachedev/restoreserve/dto/AuthResponseDTO.java b/src/main/java/com/pachedev/restoreserve/dto/AuthResponseDTO.java new file mode 100644 index 0000000..a856c98 --- /dev/null +++ b/src/main/java/com/pachedev/restoreserve/dto/AuthResponseDTO.java @@ -0,0 +1,5 @@ +package com.pachedev.restoreserve.dto; + +public record AuthResponseDTO( + String token) { +} \ No newline at end of file diff --git a/src/main/java/com/pachedev/restoreserve/dto/RegisterUserRequestDTO.java b/src/main/java/com/pachedev/restoreserve/dto/RegisterUserRequestDTO.java new file mode 100644 index 0000000..ab93c74 --- /dev/null +++ b/src/main/java/com/pachedev/restoreserve/dto/RegisterUserRequestDTO.java @@ -0,0 +1,12 @@ +package com.pachedev.restoreserve.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record RegisterUserRequestDTO( + @NotBlank(message = "El nombre es obligatorio") String name, + @NotBlank(message = "El nombre de usuario es obligatorio") String username, + @NotBlank(message = "El teléfono es obligatorio") String telephone, + @Email(message = "El email introducido no es válido") @NotBlank(message = "El email es obligatorio") String email, + @NotBlank(message = "La contraseña es obligatoria") String password) { +} diff --git a/src/main/java/com/pachedev/restoreserve/dto/ReservationRequestDTO.java b/src/main/java/com/pachedev/restoreserve/dto/ReservationRequestDTO.java new file mode 100644 index 0000000..1465f99 --- /dev/null +++ b/src/main/java/com/pachedev/restoreserve/dto/ReservationRequestDTO.java @@ -0,0 +1,14 @@ +package com.pachedev.restoreserve.dto; + +import java.time.LocalDateTime; + +import jakarta.validation.constraints.Future; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +public record ReservationRequestDTO( + @NotNull(message = "el id de la mesa es obligatorio") Long tableId, + @NotNull(message = "la fecha de reserva es obligatoria") @Future(message = "La reserva debe ser en una fecha futura") LocalDateTime reservationDate, + @NotNull(message = "el número de comensales es obligatorio") @Min(value = 1, message = "El mínimo de comensales es 1") Integer numberOfGuests, + Boolean isVip) { +} diff --git a/src/main/java/com/pachedev/restoreserve/dto/ReservationResponseDTO.java b/src/main/java/com/pachedev/restoreserve/dto/ReservationResponseDTO.java new file mode 100644 index 0000000..b1278eb --- /dev/null +++ b/src/main/java/com/pachedev/restoreserve/dto/ReservationResponseDTO.java @@ -0,0 +1,16 @@ +package com.pachedev.restoreserve.dto; + +import java.time.LocalDateTime; + +import com.pachedev.restoreserve.model.enums.ReservationStatus; + +public record ReservationResponseDTO( + Long id, + String customerName, + String tableName, + LocalDateTime dateTime, + LocalDateTime createdAt, + Integer numberOfGuests, + ReservationStatus status) { + +} \ No newline at end of file diff --git a/src/main/java/com/pachedev/restoreserve/dto/RestaurantTableRequestDTO.java b/src/main/java/com/pachedev/restoreserve/dto/RestaurantTableRequestDTO.java new file mode 100644 index 0000000..dc86759 --- /dev/null +++ b/src/main/java/com/pachedev/restoreserve/dto/RestaurantTableRequestDTO.java @@ -0,0 +1,14 @@ +package com.pachedev.restoreserve.dto; + +import com.pachedev.restoreserve.model.enums.TableLocation; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record RestaurantTableRequestDTO( + + @NotBlank(message = "la numeración de la mesa es obligatoria") String name, + @NotNull(message = "la cantidad máxima de comensales es obligatoria") @Min(value = 1, message = "Mínimo 1 comensal") Integer maxPax, + @NotNull(message = "Es obligatorio introducir la zona (SALON o TERRACE)") TableLocation location) { +} \ No newline at end of file diff --git a/src/main/java/com/pachedev/restoreserve/dto/RestaurantTableResponseDTO.java b/src/main/java/com/pachedev/restoreserve/dto/RestaurantTableResponseDTO.java new file mode 100644 index 0000000..6649a58 --- /dev/null +++ b/src/main/java/com/pachedev/restoreserve/dto/RestaurantTableResponseDTO.java @@ -0,0 +1,13 @@ +package com.pachedev.restoreserve.dto; + +import com.pachedev.restoreserve.model.enums.TableLocation; +import com.pachedev.restoreserve.model.enums.TableStatus; + +public record RestaurantTableResponseDTO( + Long id, + String name, + Integer maxPax, + TableStatus status, + TableLocation location) { + +} \ No newline at end of file diff --git a/src/main/java/com/pachedev/restoreserve/exception/BannedUserException.java b/src/main/java/com/pachedev/restoreserve/exception/BannedUserException.java new file mode 100644 index 0000000..776dd6b --- /dev/null +++ b/src/main/java/com/pachedev/restoreserve/exception/BannedUserException.java @@ -0,0 +1,8 @@ +package com.pachedev.restoreserve.exception; + +public class BannedUserException extends RuntimeException { + + public BannedUserException(String message) { + super(message); + } +} diff --git a/src/main/java/com/pachedev/restoreserve/exception/BusinessLogicException.java b/src/main/java/com/pachedev/restoreserve/exception/BusinessLogicException.java new file mode 100644 index 0000000..2816d30 --- /dev/null +++ b/src/main/java/com/pachedev/restoreserve/exception/BusinessLogicException.java @@ -0,0 +1,8 @@ +package com.pachedev.restoreserve.exception; + +public class BusinessLogicException extends RuntimeException { + + public BusinessLogicException(String message) { + super(message); + } +} diff --git a/src/main/java/com/pachedev/restoreserve/exception/ErrorResponse.java b/src/main/java/com/pachedev/restoreserve/exception/ErrorResponse.java new file mode 100644 index 0000000..2de6655 --- /dev/null +++ b/src/main/java/com/pachedev/restoreserve/exception/ErrorResponse.java @@ -0,0 +1,9 @@ +package com.pachedev.restoreserve.exception; + +import java.time.LocalDateTime; + +public record ErrorResponse( + String errorCode, + String message, + LocalDateTime timestamp) { +} \ No newline at end of file diff --git a/src/main/java/com/pachedev/restoreserve/exception/GlobalExceptionHandler.java b/src/main/java/com/pachedev/restoreserve/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..c799abb --- /dev/null +++ b/src/main/java/com/pachedev/restoreserve/exception/GlobalExceptionHandler.java @@ -0,0 +1,69 @@ +package com.pachedev.restoreserve.exception; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + /** + * Maneja el error cuando no se encuentra un recurso. + */ + @ExceptionHandler(ResourceNotFoundException.class) + public ResponseEntity handleNotFound(ResourceNotFoundException ex) { + ErrorResponse error = new ErrorResponse("NOT_FOUND", ex.getMessage(), LocalDateTime.now()); + + return new ResponseEntity<>(error, HttpStatus.NOT_FOUND); + } + + /** + * Maneja errores de validación de los DTOs (@Valid). + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidation(MethodArgumentNotValidException ex) { + Map errors = new HashMap<>(); + ex.getBindingResult().getFieldErrors().forEach(f -> errors.put(f.getField(), f.getDefaultMessage())); + + return ResponseEntity.badRequest().body(errors); + } + + /** + * Maneja excepciones relacionadas con la lógica de negocio. + */ + @ExceptionHandler(BusinessLogicException.class) + public ResponseEntity handleBusinesLogic(BusinessLogicException ex) { + ErrorResponse error = new ErrorResponse("BUSINESS_LOGIC_ERROR", ex.getMessage(), LocalDateTime.now()); + + return new ResponseEntity<>(error, HttpStatus.CONFLICT); + } + + /** + * Maneja la excepción lanzada al impedir que un usuario en estado BANNED + * realice una reserva + */ + @ExceptionHandler(BannedUserException.class) + public ResponseEntity handleBannedUser(BannedUserException ex) { + ErrorResponse error = new ErrorResponse("BANNED_USER_ERROR", ex.getMessage(), LocalDateTime.now()); + + return new ResponseEntity<>(error, HttpStatus.UNPROCESSABLE_ENTITY); + + } + + /** + * Maneja la excepción lanzada al impedir que un usuario reserve una mesa VIP + * sin serlo. + */ + @ExceptionHandler(NotVipUserException.class) + public ResponseEntity handleNotVip(NotVipUserException ex) { + ErrorResponse error = new ErrorResponse("NOT_VIP_USER", ex.getMessage(), LocalDateTime.now()); + + return new ResponseEntity<>(error, HttpStatus.FORBIDDEN); + } +} \ No newline at end of file diff --git a/src/main/java/com/pachedev/restoreserve/exception/NotVipUserException.java b/src/main/java/com/pachedev/restoreserve/exception/NotVipUserException.java new file mode 100644 index 0000000..74afc08 --- /dev/null +++ b/src/main/java/com/pachedev/restoreserve/exception/NotVipUserException.java @@ -0,0 +1,8 @@ +package com.pachedev.restoreserve.exception; + +public class NotVipUserException extends RuntimeException { + + public NotVipUserException(String message) { + super(message); + } +} diff --git a/src/main/java/com/pachedev/restoreserve/exception/ResourceNotFoundException.java b/src/main/java/com/pachedev/restoreserve/exception/ResourceNotFoundException.java new file mode 100644 index 0000000..9bbd774 --- /dev/null +++ b/src/main/java/com/pachedev/restoreserve/exception/ResourceNotFoundException.java @@ -0,0 +1,8 @@ +package com.pachedev.restoreserve.exception; + +public class ResourceNotFoundException extends RuntimeException { + + public ResourceNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/com/pachedev/restoreserve/model/entity/AppUser.java b/src/main/java/com/pachedev/restoreserve/model/entity/AppUser.java new file mode 100644 index 0000000..08c9e75 --- /dev/null +++ b/src/main/java/com/pachedev/restoreserve/model/entity/AppUser.java @@ -0,0 +1,57 @@ +package com.pachedev.restoreserve.model.entity; + +import com.pachedev.restoreserve.model.enums.UserRole; +import com.pachedev.restoreserve.model.enums.UserStatus; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "users") +@Getter +@Setter +@NoArgsConstructor +public class AppUser { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "name", nullable = false, length = 50) + private String name; + + @Column(name = "telephone", nullable = false, length = 20) + private String telephone; + + @Column(name = "email", nullable = false, unique = true) + private String email; + + @Column(name = "user_name", nullable = false, unique = true, length = 50) + private String username; + + @Column(name = "password", nullable = false) + private String password; + + @Enumerated(EnumType.STRING) + @Column(name = "role", nullable = false) + private UserRole role; + + @Column(name = "active", nullable = false) + private Boolean active = true; + + @Column(name = "penalization_points", nullable = false) + private Integer penalizationPoints; + + @Enumerated(EnumType.STRING) + @Column(name = "user_status", nullable = false) + private UserStatus status; +} diff --git a/src/main/java/com/pachedev/restoreserve/model/entity/Reservation.java b/src/main/java/com/pachedev/restoreserve/model/entity/Reservation.java new file mode 100644 index 0000000..ecca2e9 --- /dev/null +++ b/src/main/java/com/pachedev/restoreserve/model/entity/Reservation.java @@ -0,0 +1,56 @@ +package com.pachedev.restoreserve.model.entity; + +import java.time.LocalDateTime; + +import org.hibernate.annotations.CreationTimestamp; + +import com.pachedev.restoreserve.model.enums.ReservationStatus; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "reservations") +@Getter +@Setter +@NoArgsConstructor +public class Reservation { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "user_id", nullable = false) + private AppUser user; + + @ManyToOne + @JoinColumn(name = "table_id", nullable = false) + private RestaurantTable table; + + @Column(name = "reservation_date", nullable = false) + private LocalDateTime reservationDate; + + @CreationTimestamp + @Column(name = "created_at") + private LocalDateTime createdAt; + + @Column(name = "number_of_guests") + private Integer numberOfGuests; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private ReservationStatus status; + +} diff --git a/src/main/java/com/pachedev/restoreserve/model/entity/RestaurantTable.java b/src/main/java/com/pachedev/restoreserve/model/entity/RestaurantTable.java new file mode 100644 index 0000000..e6571d0 --- /dev/null +++ b/src/main/java/com/pachedev/restoreserve/model/entity/RestaurantTable.java @@ -0,0 +1,45 @@ +package com.pachedev.restoreserve.model.entity; + +import com.pachedev.restoreserve.model.enums.TableLocation; +import com.pachedev.restoreserve.model.enums.TableStatus; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "restaurant_tables") +@Getter +@Setter +@NoArgsConstructor +public class RestaurantTable { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "name", length = 50, nullable = false) + private String name; + + @Column(name = "max_pax", nullable = false) + private Integer maxPax; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private TableStatus status; + + @Enumerated(EnumType.STRING) + @Column(name = "location", nullable = false) + private TableLocation location; + + @Column(name = "is_vip", nullable = false) + private Boolean isVip = false; +} diff --git a/src/main/java/com/pachedev/restoreserve/model/enums/ReservationStatus.java b/src/main/java/com/pachedev/restoreserve/model/enums/ReservationStatus.java new file mode 100644 index 0000000..966e07a --- /dev/null +++ b/src/main/java/com/pachedev/restoreserve/model/enums/ReservationStatus.java @@ -0,0 +1,8 @@ +package com.pachedev.restoreserve.model.enums; + +public enum ReservationStatus { + PENDING, + CONFIRMED, + CANCELLED, + COMPLETED +} diff --git a/src/main/java/com/pachedev/restoreserve/model/enums/TableLocation.java b/src/main/java/com/pachedev/restoreserve/model/enums/TableLocation.java new file mode 100644 index 0000000..377be2f --- /dev/null +++ b/src/main/java/com/pachedev/restoreserve/model/enums/TableLocation.java @@ -0,0 +1,6 @@ +package com.pachedev.restoreserve.model.enums; + +public enum TableLocation { + SALON, + TERRACE +} \ No newline at end of file diff --git a/src/main/java/com/pachedev/restoreserve/model/enums/TableStatus.java b/src/main/java/com/pachedev/restoreserve/model/enums/TableStatus.java new file mode 100644 index 0000000..02f40a6 --- /dev/null +++ b/src/main/java/com/pachedev/restoreserve/model/enums/TableStatus.java @@ -0,0 +1,7 @@ +package com.pachedev.restoreserve.model.enums; + +public enum TableStatus { + AVAILABLE, + RESERVED, + OCCUPIED, +} diff --git a/src/main/java/com/pachedev/restoreserve/model/enums/UserRole.java b/src/main/java/com/pachedev/restoreserve/model/enums/UserRole.java new file mode 100644 index 0000000..cb1969e --- /dev/null +++ b/src/main/java/com/pachedev/restoreserve/model/enums/UserRole.java @@ -0,0 +1,6 @@ +package com.pachedev.restoreserve.model.enums; + +public enum UserRole { + ROLE_USER, + ROLE_ADMIN +} \ No newline at end of file diff --git a/src/main/java/com/pachedev/restoreserve/model/enums/UserStatus.java b/src/main/java/com/pachedev/restoreserve/model/enums/UserStatus.java new file mode 100644 index 0000000..431208f --- /dev/null +++ b/src/main/java/com/pachedev/restoreserve/model/enums/UserStatus.java @@ -0,0 +1,6 @@ +package com.pachedev.restoreserve.model.enums; + +public enum UserStatus { + ACTIVE, + BANNED +} diff --git a/src/main/java/com/pachedev/restoreserve/repository/AppUserRepository.java b/src/main/java/com/pachedev/restoreserve/repository/AppUserRepository.java new file mode 100644 index 0000000..3fdd41f --- /dev/null +++ b/src/main/java/com/pachedev/restoreserve/repository/AppUserRepository.java @@ -0,0 +1,14 @@ +package com.pachedev.restoreserve.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import com.pachedev.restoreserve.model.entity.AppUser; +import java.util.Optional; + +public interface AppUserRepository extends JpaRepository { + + Optional findByUsername(String username); + + boolean existsByUsername(String username); + + boolean existsByEmail (String email); +} \ No newline at end of file diff --git a/src/main/java/com/pachedev/restoreserve/repository/ReservationRepository.java b/src/main/java/com/pachedev/restoreserve/repository/ReservationRepository.java new file mode 100644 index 0000000..6317bc7 --- /dev/null +++ b/src/main/java/com/pachedev/restoreserve/repository/ReservationRepository.java @@ -0,0 +1,19 @@ +package com.pachedev.restoreserve.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.pachedev.restoreserve.model.entity.Reservation; +import com.pachedev.restoreserve.model.enums.ReservationStatus; + +public interface ReservationRepository extends JpaRepository { + + List findByUserId(Long userId); + + List findByStatus(ReservationStatus status); + + List findByTableIdAndStatus(Long tableId, ReservationStatus status); + + List findByUserIdAndStatus(Long id, ReservationStatus status); +} diff --git a/src/main/java/com/pachedev/restoreserve/repository/RestaurantTableRepository.java b/src/main/java/com/pachedev/restoreserve/repository/RestaurantTableRepository.java new file mode 100644 index 0000000..40294f4 --- /dev/null +++ b/src/main/java/com/pachedev/restoreserve/repository/RestaurantTableRepository.java @@ -0,0 +1,34 @@ +package com.pachedev.restoreserve.repository; + +import com.pachedev.restoreserve.model.entity.RestaurantTable; +import com.pachedev.restoreserve.model.enums.TableLocation; +import com.pachedev.restoreserve.model.enums.TableStatus; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface RestaurantTableRepository extends JpaRepository { + + List findByMaxPaxGreaterThanEqual(Integer pax); + + List findByStatus(TableStatus status); + + List findByLocation(TableLocation location); + + List findByLocationAndStatus(TableLocation location, TableStatus status); + + List findByStatusAndMaxPaxGreaterThanEqual(TableStatus status, Integer maxPax); + + /** + * Bloquea la mesa mientras se crea la reserva para evitar que dos reservas + * simultáneas se realicen sobre la misma mesa. + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select t from RestaurantTable t where t.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); +} diff --git a/src/main/java/com/pachedev/restoreserve/security/CustomUserDetailsService.java b/src/main/java/com/pachedev/restoreserve/security/CustomUserDetailsService.java new file mode 100644 index 0000000..866b32b --- /dev/null +++ b/src/main/java/com/pachedev/restoreserve/security/CustomUserDetailsService.java @@ -0,0 +1,37 @@ +package com.pachedev.restoreserve.security; + +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +import com.pachedev.restoreserve.model.entity.AppUser; +import com.pachedev.restoreserve.repository.AppUserRepository; + +import lombok.RequiredArgsConstructor; + +/** + * Servicio necesario para que Spring Security use nuestros usuarios de la base + * de datos en lugar de los que crea automáticamente por defecto. + */ +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final AppUserRepository appUserRepository; + + /** + * Busca al usuario en la base de datos por su username y lo convierte + * en un objeto UserDetails que Spring Security pueda entender. + */ + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + AppUser user = appUserRepository.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("Credenciales inválidas")); + + return User.withUsername(user.getUsername()).password(user.getPassword()) + .authorities(user.getRole().name()).disabled(!user.getActive()).build(); + } + +} \ No newline at end of file diff --git a/src/main/java/com/pachedev/restoreserve/security/JwtAuthenticationFilter.java b/src/main/java/com/pachedev/restoreserve/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..055c30b --- /dev/null +++ b/src/main/java/com/pachedev/restoreserve/security/JwtAuthenticationFilter.java @@ -0,0 +1,74 @@ +package com.pachedev.restoreserve.security; + +import java.io.IOException; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +/** + * Filtro que intercepta todas las peticiones HTTP para validar el token JWT. + * Si el token es correcto, identica al usuario y le da acceso a la aplicación. + */ +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtService jwtService; + private final CustomUserDetailsService customUserDetailsService; + + /** + * Proceso de filtrado principal: + * 1. extrae el token del encabezado Authorization. + * 2. valida la firma y la fecha del token. + * 3. registra al usuario en la lista (contexto) de seguridad. + */ + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + // extraer el encabezado Authorization + String authHeader = request.getHeader("Authorization"); + + // si la solicitud no trae token JWT o no empieza por Bearer pasamos la petición + // al siguiente filtro + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + // extraer el token (7 caracteres-> Bearer + el espacio que precede al token) + String token = authHeader.substring(7); + String username = jwtService.extractUsername(token); + + // si hay un token y no está ya autenticado, buscamos los datos del usuario en + // la base de datos + if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { + UserDetails userDetails = customUserDetailsService.loadUserByUsername(username); + + // Si el token es válido (firmado y no expirado) creamos la credencial de acceso + // para Spring Security + if (jwtService.isTokenValid(token, userDetails.getUsername())) { + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities()); + + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + + // autorizamos al usuario en el sistema + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } + // continuamos con la cadena de filtros de Spring Security + filterChain.doFilter(request, response); + } + +} diff --git a/src/main/java/com/pachedev/restoreserve/security/JwtService.java b/src/main/java/com/pachedev/restoreserve/security/JwtService.java new file mode 100644 index 0000000..abbbe86 --- /dev/null +++ b/src/main/java/com/pachedev/restoreserve/security/JwtService.java @@ -0,0 +1,100 @@ +package com.pachedev.restoreserve.security; + +import java.util.Date; + +import javax.crypto.SecretKey; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; + +/** + * Servicio que se encarga de : + * - generar el token cuando un usuario se loguea + * - extra información del token (como el username) + * - validar que el token sea legítimo y si está vigente + */ + +@Service +public class JwtService { + + /** + * Inyecta el valor de la clave secreta definida en application.properties + */ + @Value("${jwt.secret-key}") + private String secretKey; + + /** + * Inyecta el tiempo de vida del token que hemos definido en milisegundos en + * application.properties + */ + @Value("${jwt.expiration}") + private long jwtExpiration; + + /** + * Crea un nuevo token con el nombre del usuario, la fecha de emisión y la fecha + * de caducidad, firmándolo con la firma digital + */ + public String generateToken(String username) { + + Date now = new Date(); + Date expirationDate = new Date(now.getTime() + jwtExpiration); + + return Jwts.builder() + .subject(username) + .issuedAt(now) + .expiration(expirationDate) + .signWith(getSigningKey()) + .compact(); + } + + /** + * Convierte la clave del application.properties en una firma digital válida + * para el algoritmo. + */ + private SecretKey getSigningKey() { + // Decodifica la cadena de texto que hemos definido en application.properties + byte[] Keybytes = Decoders.BASE64.decode(secretKey); + + // Genera la firma usando el algoritmo HMAC-SHA + return Keys.hmacShaKeyFor(Keybytes); + } + + /** + * Abre el token utilizando la firma digital y extrae su contenido (claims). + */ + private Claims extractClaims(String token) { + return Jwts.parser().verifyWith(getSigningKey()).build().parseSignedClaims(token).getPayload(); + } + + /** + * Extrae el nombre de usuario que hay en el token. + */ + public String extractUsername(String token) { + return extractClaims(token).getSubject(); + } + + /** + * Verifica que la fecha de expiración del token no sea anterior a la actual. + */ + public boolean isTokenExpired(String token) { + Date expirationDate = extractClaims(token).getExpiration(); + + return expirationDate.before(new Date()); + } + + /** + * Verifica que el token pertenece al usuario que lo envía y que no ha expirado + * su fecha de validez. + */ + public boolean isTokenValid(String token, String username) { + String tokenUsername = extractUsername(token); + + return tokenUsername.equals(username) && !isTokenExpired(token); + + } +} diff --git a/src/main/java/com/pachedev/restoreserve/security/SecurityConfig.java b/src/main/java/com/pachedev/restoreserve/security/SecurityConfig.java new file mode 100644 index 0000000..f9440ae --- /dev/null +++ b/src/main/java/com/pachedev/restoreserve/security/SecurityConfig.java @@ -0,0 +1,72 @@ +package com.pachedev.restoreserve.security; + +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; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +import lombok.RequiredArgsConstructor; + +/** + * Configuración de seguridad de la API: + * - Define cómo se protegen los datos (hash). + * - Configura la sesión como stateless mediante JWT. + * - Declara qué usuarios pueden acceder los endpoints. * + */ +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + /** + * sirve para hashear contraseñas (hacerlas ilegibles) y verificar que la + * contraseña que introduce el usuario coincide con el hash guardado, sin + * necesidad de saber cual era la contraseña original + */ + @Bean + PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + /** + * Cadena de filtros de Seguridad de la API, los controles automáticos por donde + * pasará cada petición HTTP: + * + * 1. Desactiva CSRF (no la necesitamos, nuestra API es stateless gracias al + * uso de tokens JWT). + * 2. definimos que la política de sesión es stateless (no guardará la + * sesión en el servidor, cada petición debe traer credenciales). + * 3. declaramos los endpoints a lo que todo el mundo pueda acceder (permitAll). + * 4. declaramos los endpoints a los que sólo puede acceder usuario con rol + * ADMIN. + * 5. declaramos los endpoints a los que pueden acceder los que tengan rol USER + * o ADMIN. + * 6. anyRequest().authenticated() .> cualquier otra ruta requiere usuario + * autenticado. + * 7. addFilterBefore -> antes de pasar el filtro de Spring Security debe pasar + * el que hemos definido nosotros, que leerá el token y autenticará al usuario + */ + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http.csrf(csrf -> csrf.disable()) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests( + auth -> auth + .requestMatchers("/api/v1/auth/login", "/api/v1/auth/register", "/swagger-ui.html", + "/swagger-ui/**", "/v3/api-docs/**") + .permitAll() + .requestMatchers("/api/v1/tables/**", "/api/v1/users/**") + .hasRole("ADMIN") + .requestMatchers("/api/v1/reservations/**").hasAnyRole("USER", "ADMIN") + .anyRequest().authenticated()) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .build(); + } +} diff --git a/src/main/java/com/pachedev/restoreserve/service/AppUserService.java b/src/main/java/com/pachedev/restoreserve/service/AppUserService.java new file mode 100644 index 0000000..d2d9b7a --- /dev/null +++ b/src/main/java/com/pachedev/restoreserve/service/AppUserService.java @@ -0,0 +1,32 @@ +package com.pachedev.restoreserve.service; + +import org.springframework.stereotype.Service; + +import com.pachedev.restoreserve.exception.ResourceNotFoundException; +import com.pachedev.restoreserve.model.entity.AppUser; +import com.pachedev.restoreserve.model.enums.UserStatus; +import com.pachedev.restoreserve.repository.AppUserRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class AppUserService { + + private final AppUserRepository appUserRepository; + + /** + * Reinicia los puntos de penalización de un usuario y cambia su estado a + * ACTIVE. + */ + public void resetPenalization(Long id) { + + AppUser user = appUserRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Usuario con id " + id + " no encontrado")); + + user.setPenalizationPoints(0); + user.setStatus(UserStatus.ACTIVE); + + appUserRepository.save(user); + } +} diff --git a/src/main/java/com/pachedev/restoreserve/service/AuthService.java b/src/main/java/com/pachedev/restoreserve/service/AuthService.java new file mode 100644 index 0000000..600249f --- /dev/null +++ b/src/main/java/com/pachedev/restoreserve/service/AuthService.java @@ -0,0 +1,86 @@ +package com.pachedev.restoreserve.service; + +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import com.pachedev.restoreserve.dto.AuthRequestDTO; +import com.pachedev.restoreserve.dto.AuthResponseDTO; +import com.pachedev.restoreserve.dto.RegisterUserRequestDTO; +import com.pachedev.restoreserve.exception.BusinessLogicException; +import com.pachedev.restoreserve.exception.ResourceNotFoundException; +import com.pachedev.restoreserve.model.entity.AppUser; +import com.pachedev.restoreserve.model.enums.UserRole; +import com.pachedev.restoreserve.model.enums.UserStatus; +import com.pachedev.restoreserve.repository.AppUserRepository; +import com.pachedev.restoreserve.security.JwtService; + +import lombok.RequiredArgsConstructor; + +/** + * Servicio encargado de autenticar usuarios mediante JWT. + * Comprueba que las credenciales introducidas sean correctas y si el login es + * válido genera un token para acceder a los endpoints protegidos de la API. + */ +@Service +@RequiredArgsConstructor +public class AuthService { + + private final AppUserRepository appUserRepository; + private final PasswordEncoder passwordEncoder; + private final JwtService jwtService; + + /** + * Comprueba las credenciales de un usuario y genera un token JWT. + * + * @param dto el username y la contraseña introducidos por el usuario. + * @return token JWT generado tras una autenticación correcta. + */ + public AuthResponseDTO login(AuthRequestDTO dto) { + + AppUser user = appUserRepository.findByUsername(dto.username()) + .orElseThrow(() -> new ResourceNotFoundException("Usuario " + dto.username() + " no encontrado")); + + if (!passwordEncoder.matches(dto.password(), (user.getPassword()))) { + throw new BusinessLogicException("Credenciales no válidas"); + } + + String token = jwtService.generateToken(user.getUsername()); + + return new AuthResponseDTO(token); + } + + /** + * Registra un nuevo usuario cliente validando que el nombre de usuario y el + * email no estén ya en uso. + * + * @param dto datos necesarios para registrar el nuevo usuario. + * @return mensaje de confirmación del registro realizado. + */ + public String register(RegisterUserRequestDTO dto) { + + if (appUserRepository.existsByUsername(dto.username())) { + throw new BusinessLogicException( + "El nombre de usuario " + dto.username() + " ya está registrado. Elija otro."); + } + + if (appUserRepository.existsByEmail(dto.email())) { + throw new BusinessLogicException("El email " + dto.email() + " ya está registrado. Introduzca otro."); + } + + AppUser user = new AppUser(); + user.setName(dto.name()); + user.setUsername(dto.username()); + user.setTelephone(dto.telephone()); + user.setEmail(dto.email()); + user.setPassword(passwordEncoder.encode(dto.password())); + user.setRole(UserRole.ROLE_USER); + user.setActive(true); + user.setPenalizationPoints(0); + user.setStatus(UserStatus.ACTIVE); + + appUserRepository.save(user); + + return "El usuario ha sido registrado correctamente"; + } + +} diff --git a/src/main/java/com/pachedev/restoreserve/service/ReservationService.java b/src/main/java/com/pachedev/restoreserve/service/ReservationService.java new file mode 100644 index 0000000..7dd3c3b --- /dev/null +++ b/src/main/java/com/pachedev/restoreserve/service/ReservationService.java @@ -0,0 +1,291 @@ +package com.pachedev.restoreserve.service; + +import com.pachedev.restoreserve.dto.ReservationRequestDTO; +import com.pachedev.restoreserve.dto.ReservationResponseDTO; +import com.pachedev.restoreserve.exception.BannedUserException; +import com.pachedev.restoreserve.exception.BusinessLogicException; +import com.pachedev.restoreserve.exception.NotVipUserException; +import com.pachedev.restoreserve.exception.ResourceNotFoundException; +import com.pachedev.restoreserve.model.entity.AppUser; +import com.pachedev.restoreserve.model.entity.Reservation; +import com.pachedev.restoreserve.model.entity.RestaurantTable; +import com.pachedev.restoreserve.model.enums.ReservationStatus; +import com.pachedev.restoreserve.model.enums.UserStatus; +import com.pachedev.restoreserve.repository.AppUserRepository; +import com.pachedev.restoreserve.repository.ReservationRepository; +import com.pachedev.restoreserve.repository.RestaurantTableRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ReservationService { + + private final ReservationRepository reservationRepository; + private final RestaurantTableRepository tableRepository; + private final AppUserRepository appUserRepository; + + /** + * Crea una reserva validando: + * - que la fecha sea en el futuro + * - capacidad de la mesa + * - que una mesa previamente reservada estará ocupada por dos horas, por lo + * que la hora de reserva solicitada no entrará en conflicto. + */ + @Transactional + public ReservationResponseDTO create(ReservationRequestDTO dto, String currentUsername) { + + AppUser user = appUserRepository.findByUsername(currentUsername) + .orElseThrow(() -> new ResourceNotFoundException("Usuario " + currentUsername + " no encontrado")); + + if (user.getStatus().equals(UserStatus.BANNED)) { + throw new BannedUserException( + "El usuario se encuentra en estado BANNED debido a múltiples cancelaciones tardías"); + } + + if (dto.reservationDate().isBefore(LocalDateTime.now())) { + throw new BusinessLogicException("La fecha y hora no pueden ser anteriores a la actual"); + } + + RestaurantTable table = tableRepository.findByIdForUpdate(dto.tableId()) + .orElseThrow(() -> new ResourceNotFoundException("Mesa con id " + dto.tableId() + " no encontrada")); + + if (dto.isVip() == true) { + boolean isVipUser = reservationRepository.findByUserIdAndStatus(user.getId(), ReservationStatus.CONFIRMED).size() > 3; + + if (!isVipUser) { + throw new NotVipUserException("Sólo clientes habituales pueden reservar mesas VIP"); + } + + table.setIsVip(true); + } + + if (dto.numberOfGuests() > table.getMaxPax()) { + throw new BusinessLogicException("El número de comensales introducido supera la capacidad de la mesa"); + } + + Reservation conflictingReservation = findConflictingReservation(dto.tableId(), dto.reservationDate()); + if (conflictingReservation != null) { + LocalDateTime start = conflictingReservation.getReservationDate(); + LocalDateTime end = start.plusHours(2); + throw new BusinessLogicException("La mesa con id " + dto.tableId() + " está reservada de " + + start.toLocalTime() + " a " + end.toLocalTime() + + ". Seleccione otra hora."); + } + + Reservation reservation = new Reservation(); + reservation.setUser(user); + reservation.setTable(table); + reservation.setReservationDate(dto.reservationDate()); + reservation.setNumberOfGuests(dto.numberOfGuests()); + reservation.setStatus(ReservationStatus.CONFIRMED); + + Reservation savedReservation = reservationRepository.save(reservation); + + return toResponseDTO(savedReservation); + } + + private ReservationResponseDTO toResponseDTO(Reservation reservation) { + return new ReservationResponseDTO(reservation.getId(), reservation.getUser().getName(), + reservation.getTable().getName(), reservation.getReservationDate(), reservation.getCreatedAt(), + reservation.getNumberOfGuests(), reservation.getStatus()); + + } + + /** + * Comprueba si ya existe una reserva confirmada en la misma mesa dentro de la + * franja horaria de 2 horas. + */ + private Reservation findConflictingReservation(Long tableId, LocalDateTime requestedReservationTime) { + List confirmedReservations = reservationRepository.findByTableIdAndStatus(tableId, + ReservationStatus.CONFIRMED); + + LocalDateTime requestedReservationEnd = requestedReservationTime.plusHours(2); + + for (Reservation reservation : confirmedReservations) { + LocalDateTime confirmedReservationTime = reservation.getReservationDate(); + LocalDateTime confirmedReservationEnd = confirmedReservationTime.plusHours(2); + + if (requestedReservationTime.isBefore(confirmedReservationEnd) + && requestedReservationEnd.isAfter(confirmedReservationTime)) { + return reservation; + } + } + + return null; + } + + /** + * Devuelve las reservas según el rol del usuario autenticado. + * + * ROLE_ADMIN -> todas las reservas. + * ROLE_USER -> solo sus reservas. + */ + public List findAll() { + + AppUser user = getUser(); + + List reservations = new ArrayList<>(); + + if (user.getRole().name().equals("ROLE_ADMIN")) { + reservations = reservationRepository.findAll(); + } else { + reservations = reservationRepository.findByUserId(user.getId()); + } + return toResponseList(reservations); + } + + private List toResponseList(List reservations) { + List response = new ArrayList<>(); + + for (Reservation reservation : reservations) { + response.add(toResponseDTO(reservation)); + } + + return response; + } + + /** + * Devuelve una reserva por su id si el usuario autenticado tiene permisos para + * verla. + */ + public ReservationResponseDTO findById(Long id) { + Reservation reservation = getReservation(id); + + AppUser user = getUser(); + + validateReservationAccess(reservation, user); + + return toResponseDTO(reservation); + } + + /** + * Devuelve las reservas con el estado indicado aplicando restricciones según el + * rol del usuario autenticado. + */ + public List findByStatus(ReservationStatus status) { + AppUser user = getUser(); + + List reservations = new ArrayList<>(); + + if (user.getRole().name().equals("ROLE_ADMIN")) { + reservations = reservationRepository.findByStatus(status); + } else { + reservations = reservationRepository.findByUserIdAndStatus(user.getId(), status); + } + + return toResponseList(reservations); + } + + /** + * Cancela una reserva si el usuario autenticado tiene permisos para + * modificarla. + * + * En caso de cancelación tardía o no-show, le sumamos 2 puntos de penalización + * al cliente. + * Cuando acumule más de 6 puntos, su estado pasará de ACTIVE a BANNED. + */ + @Transactional + public void cancelReservation(Long id) { + Reservation reservation = getReservation(id); + + AppUser user = getUser(); + + validateReservationAccess(reservation, user); + + if (reservation.getStatus() == ReservationStatus.CANCELLED) { + throw new BusinessLogicException("La reserva con id " + id + " ya estaba cancelada"); + } + + LocalDateTime reservationTime = reservation.getReservationDate(); + LocalDateTime limit = reservationTime.minusHours(2); + + AppUser client = reservation.getUser(); + + if (LocalDateTime.now().isAfter(limit)) { + client.setPenalizationPoints(client.getPenalizationPoints() + 2); + + if (client.getPenalizationPoints() > 6) { + client.setStatus(UserStatus.BANNED); + } + + appUserRepository.save(client); + } + + reservation.setStatus(ReservationStatus.CANCELLED); + + reservationRepository.save(reservation); + } + + /** + * Verifica que el usuario autenticado pueda acceder o modificar la reserva + * indicada. + */ + public void validateReservationAccess(Reservation reservation, AppUser user) { + if (!user.getRole().name().equals("ROLE_ADMIN") && !reservation.getUser().getId().equals(user.getId())) { + throw new BusinessLogicException("Su usuario no tiene permisos para acceder a la reserva especificada"); + } + } + + /** + * Obtiene el usuario autenticado actual a partir del contexto de seguridad. + */ + private AppUser getUser() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + String username = authentication.getName(); + + AppUser user = appUserRepository.findByUsername(username) + .orElseThrow(() -> new ResourceNotFoundException("Usuario " + username + " no encontrado")); + return user; + } + + /** + * Busca una reserva por su identificador o lanza una excepción si no existe. + */ + private Reservation getReservation(Long id) { + Reservation reservation = reservationRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Reserva con id " + id + " no encontrada")); + return reservation; + } + + /** + * Modifica el estado de una reserva a COMPLETED si han pasado más de dos horas + * de la hora de la reserva. + */ + @Transactional + public void completeReservation(Long id) { + + Reservation reservation = getReservation(id); + + AppUser user = getUser(); + + validateReservationAccess(reservation, user); + + if (reservation.getStatus() == ReservationStatus.COMPLETED) { + throw new BusinessLogicException("La reserva con id " + id + " ya estaba completada"); + } + + LocalDateTime reservationTime = reservation.getReservationDate(); + LocalDateTime limitToComplete = reservationTime.plusHours(2); + + AppUser client = reservation.getUser(); + + if (LocalDateTime.now().isAfter(limitToComplete)) { + + client.setStatus(UserStatus.BANNED); + + appUserRepository.save(client); + } + + reservation.setStatus(ReservationStatus.COMPLETED); + + reservationRepository.save(reservation); + } +} diff --git a/src/main/java/com/pachedev/restoreserve/service/RestaurantTableService.java b/src/main/java/com/pachedev/restoreserve/service/RestaurantTableService.java new file mode 100644 index 0000000..a3d3a60 --- /dev/null +++ b/src/main/java/com/pachedev/restoreserve/service/RestaurantTableService.java @@ -0,0 +1,152 @@ +package com.pachedev.restoreserve.service; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.stereotype.Service; + +import com.pachedev.restoreserve.dto.RestaurantTableRequestDTO; +import com.pachedev.restoreserve.dto.RestaurantTableResponseDTO; +import com.pachedev.restoreserve.exception.ResourceNotFoundException; +import com.pachedev.restoreserve.model.entity.RestaurantTable; +import com.pachedev.restoreserve.model.enums.TableLocation; +import com.pachedev.restoreserve.model.enums.TableStatus; +import com.pachedev.restoreserve.repository.RestaurantTableRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class RestaurantTableService { + + private final RestaurantTableRepository restaurantTableRepository; + + /** + * Crea una nueva mesa con estado inicial AVAILABLE. + */ + public RestaurantTableResponseDTO create(RestaurantTableRequestDTO dto) { + + RestaurantTable table = new RestaurantTable(); + + table.setName(dto.name()); + table.setMaxPax(dto.maxPax()); + table.setStatus(TableStatus.AVAILABLE); + table.setLocation(dto.location()); + + RestaurantTable savedTable = restaurantTableRepository.save(table); + + return toResponseDTO(savedTable); + } + + /** + * Busca una mesa por su identificador. + */ + public RestaurantTableResponseDTO findById(Long id) { + RestaurantTable table = getTableById(id); + + return toResponseDTO(table); + } + + private RestaurantTable getTableById(Long id) { + RestaurantTable table = restaurantTableRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Mesa con id " + id + " no encontrada")); + return table; + } + + /** + * Devuelve la lista completa de mesas registradas. + */ + public List findAll() { + List tables = restaurantTableRepository.findAll(); + List response = toResponseList(tables); + + return response; + } + + private List toResponseList(List tables) { + List response = new ArrayList<>(); + + for (RestaurantTable r : tables) { + response.add(toResponseDTO(r)); + } + return response; + } + + /** + * Convierte una entidad a un DTO de respuesta. + */ + public RestaurantTableResponseDTO toResponseDTO(RestaurantTable table) { + return new RestaurantTableResponseDTO(table.getId(), table.getName(), table.getMaxPax(), table.getStatus(), + table.getLocation()); + } + + /** + * Actualiza los datos principales de una mesa existente. + */ + public RestaurantTableResponseDTO update(Long id, RestaurantTableRequestDTO dto) { + RestaurantTable table = getTableById(id); + + table.setName(dto.name()); + table.setMaxPax(dto.maxPax()); + table.setLocation(dto.location()); + + RestaurantTable updatedTable = restaurantTableRepository.save(table); + + return toResponseDTO(updatedTable); + } + + public void delete(Long id) { + restaurantTableRepository.delete(getTableById(id)); + } + + /** + * Devuelve las mesas cuya capacidad es mayor o igual al número de comensales + * indicado. + */ + public List findByMaxPax(Integer pax) { + List tables = restaurantTableRepository.findByMaxPaxGreaterThanEqual(pax); + List response = toResponseList(tables); + + return response; + } + + /** + * Devuelve las mesas filtrando por estado. + */ + public List findByStatus(TableStatus status) { + List tables = restaurantTableRepository.findByStatus(status); + List response = toResponseList(tables); + + return response; + } + + /** + * Devuelve las mesas filtrando por ubicación. + */ + public List findByLocation(TableLocation location) { + List tables = restaurantTableRepository.findByLocation(location); + List response = toResponseList(tables); + + return response; + } + + /** + * Devuelve las mesas filtrando por ubicación y estado. + */ + public List findByLocationAndStatus(TableLocation location, TableStatus status) { + List tables = restaurantTableRepository.findByLocationAndStatus(location, status); + List response = toResponseList(tables); + + return response; + } + + /** + * Devuelve las mesas filtrando por estado y capacidad. + */ + public List findByStatusAndMaxPaxGreaterThanEqual(TableStatus status, Integer maxPax) { + List tables = restaurantTableRepository.findByStatusAndMaxPaxGreaterThanEqual(status, maxPax); + List response = toResponseList(tables); + + return response; + } +} diff --git a/src/main/resources/application-postgres.properties b/src/main/resources/application-postgres.properties new file mode 100644 index 0000000..2b7de84 --- /dev/null +++ b/src/main/resources/application-postgres.properties @@ -0,0 +1,3 @@ +spring.datasource.url=jdbc:postgresql://localhost:5432/${POSTGRES_DB:restoreserve} +spring.datasource.username=${POSTGRES_USER:restoreserve} +spring.datasource.password=${POSTGRES_PASSWORD} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index ad91b45..08609f1 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,5 +1,7 @@ spring.application.name=restoreserve +spring.config.import=optional:file:.env[.properties] + spring.datasource.url=jdbc:h2:mem:testdb spring.datasource.username=sa spring.datasource.password= @@ -7,4 +9,6 @@ spring.datasource.password= spring.jpa.hibernate.ddl-auto=update spring.jpa.show-sql=true spring.jpa.properties.hibernate.format_sql=true -spring.jpa.defer-datasource-initialization=true + +jwt.secret-key=${JWT_SECRET} +jwt.expiration=86400000 diff --git a/src/test/java/com/pachedev/restoreserve/service/ReservationServiceTest.java b/src/test/java/com/pachedev/restoreserve/service/ReservationServiceTest.java new file mode 100644 index 0000000..7ae2231 --- /dev/null +++ b/src/test/java/com/pachedev/restoreserve/service/ReservationServiceTest.java @@ -0,0 +1,236 @@ +package com.pachedev.restoreserve.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +import java.time.LocalDateTime; +import java.util.Optional; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; + +import com.pachedev.restoreserve.dto.ReservationRequestDTO; +import com.pachedev.restoreserve.exception.BannedUserException; +import com.pachedev.restoreserve.exception.BusinessLogicException; +import com.pachedev.restoreserve.model.entity.AppUser; +import com.pachedev.restoreserve.model.entity.Reservation; +import com.pachedev.restoreserve.model.entity.RestaurantTable; +import com.pachedev.restoreserve.model.enums.ReservationStatus; +import com.pachedev.restoreserve.model.enums.TableLocation; +import com.pachedev.restoreserve.model.enums.UserRole; +import com.pachedev.restoreserve.model.enums.UserStatus; +import com.pachedev.restoreserve.repository.AppUserRepository; +import com.pachedev.restoreserve.repository.ReservationRepository; +import com.pachedev.restoreserve.repository.RestaurantTableRepository; + +@ExtendWith(MockitoExtension.class) +public class ReservationServiceTest { + + @Mock + private ReservationRepository reservationRepository; + + @Mock + private RestaurantTableRepository tableRepository; + + @Mock + private AppUserRepository appUserRepository; + + @Mock + private Authentication authentication; + + @Mock + private SecurityContext securityContext; + + @InjectMocks + private ReservationService reservationService; + + /** + * Comprobar que no se pueda realizar una reserva con una hora o fecha anterior + * a la actual + */ + @Test + void createPastReservation() { + + AppUser user = new AppUser(); + user.setId(1L); + user.setName("Tanausú Febles"); + user.setTelephone("600000000"); + user.setEmail("ertana@gmail.com"); + user.setUsername("tana"); + user.setPassword("$2a$12$UfijVlkvrmwpTGERWsfsCe.R5ZIPaJPCP7TmjsnRMYtE8USwgsixi"); + user.setRole(UserRole.ROLE_USER); + user.setActive(true); + user.setPenalizationPoints(0); + user.setStatus(UserStatus.ACTIVE); + + when(appUserRepository.findByUsername("tana")).thenReturn(Optional.of(user)); + + LocalDateTime pastTime = LocalDateTime.now().minusHours(1); + ReservationRequestDTO dto = new ReservationRequestDTO(1L, pastTime, 4, false); + + assertThrows(BusinessLogicException.class, () -> reservationService.create(dto, "tana")); + } + + /** + * Comprobar que no se pueda realizar una reserva con una cantidad de comensales + * superior a la que tiene la mesa + */ + @Test + void createReservationExceedingPax() { + + LocalDateTime date = LocalDateTime.now().plusDays(1); + ReservationRequestDTO dto = new ReservationRequestDTO(1L, date, 20, false); + + AppUser user = new AppUser(); + user.setId(1L); + user.setName("Tanausú Febles"); + user.setTelephone("600000000"); + user.setEmail("ertana@gmail.com"); + user.setUsername("tana"); + user.setPassword("$2a$12$UfijVlkvrmwpTGERWsfsCe.R5ZIPaJPCP7TmjsnRMYtE8USwgsixi"); + user.setRole(UserRole.ROLE_USER); + user.setActive(true); + user.setPenalizationPoints(0); + user.setStatus(UserStatus.ACTIVE); + + RestaurantTable table = new RestaurantTable(); + table.setId(1L); + table.setName("salon 001"); + table.setMaxPax(4); + table.setLocation(TableLocation.SALON); + + when(appUserRepository.findByUsername("tana")).thenReturn(Optional.of(user)); + when(tableRepository.findByIdForUpdate(1L)).thenReturn(Optional.of(table)); + + assertThrows(BusinessLogicException.class, () -> reservationService.create(dto, "tana")); + } + + /** + * Comprobar que un usuario con estado BANNED no pueda realizar una reserva + */ + @Test + void createReservationByBannedUser() { + + AppUser user = new AppUser(); + user.setId(1L); + user.setName("Tanausú Febles"); + user.setTelephone("600000000"); + user.setEmail("ertana@gmail.com"); + user.setUsername("tana"); + user.setPassword("$2a$12$UfijVlkvrmwpTGERWsfsCe.R5ZIPaJPCP7TmjsnRMYtE8USwgsixi"); + user.setRole(UserRole.ROLE_USER); + user.setActive(true); + user.setPenalizationPoints(6); + user.setStatus(UserStatus.BANNED); + + when(appUserRepository.findByUsername("tana")).thenReturn(Optional.of(user)); + + LocalDateTime pastTime = LocalDateTime.now().minusHours(1); + ReservationRequestDTO dto = new ReservationRequestDTO(1L, pastTime, 4, false); + + assertThrows(BannedUserException.class, () -> reservationService.create(dto, "tana")); + } + + /** + * Comprobar que se penaliza con 2 puntos a un usuario que cancela de forma + * tardía o no aparece a la hora reservada (no-show) + */ + @Test + void addPenalizationPointsByLateCancellation() { + + AppUser client = new AppUser(); + client.setId(1L); + client.setName("Tanausú Febles"); + client.setTelephone("600000000"); + client.setEmail("ertana@gmail.com"); + client.setUsername("tana"); + client.setPassword("$2a$12$UfijVlkvrmwpTGERWsfsCe.R5ZIPaJPCP7TmjsnRMYtE8USwgsixi"); + client.setRole(UserRole.ROLE_USER); + client.setActive(true); + client.setPenalizationPoints(0); + client.setStatus(UserStatus.ACTIVE); + + RestaurantTable table = new RestaurantTable(); + table.setId(1L); + table.setName("salon 001"); + table.setMaxPax(4); + table.setLocation(TableLocation.SALON); + + Reservation reservation = new Reservation(); + reservation.setId(1L); + reservation.setUser(client); + reservation.setTable(table); + reservation.setReservationDate(LocalDateTime.now().plusHours(1)); + reservation.setNumberOfGuests(4); + reservation.setStatus(ReservationStatus.CONFIRMED); + + when(reservationRepository.findById(reservation.getId())).thenReturn(Optional.of(reservation)); + when(appUserRepository.findByUsername("tana")).thenReturn(Optional.of(client)); + + when(securityContext.getAuthentication()).thenReturn(authentication); + when(authentication.getName()).thenReturn("tana"); + + SecurityContextHolder.setContext(securityContext); + + reservationService.cancelReservation(reservation.getId()); + + assertEquals(2, client.getPenalizationPoints()); + assertEquals(UserStatus.ACTIVE, client.getStatus()); + assertEquals(ReservationStatus.CANCELLED, reservation.getStatus()); + } + + /** + * Comprobar que cambia el estado del cliente a BANNED cuandu supera los 6 + * puntos de penalización por cancelaciones tardías o no aparecer a la hora + * reservada + */ + @Test + void banClientByExceedingSixPenalizationPoints() { + + AppUser client = new AppUser(); + client.setId(1L); + client.setName("Tanausú Febles"); + client.setTelephone("600000000"); + client.setEmail("ertana@gmail.com"); + client.setUsername("tana"); + client.setPassword("$2a$12$UfijVlkvrmwpTGERWsfsCe.R5ZIPaJPCP7TmjsnRMYtE8USwgsixi"); + client.setRole(UserRole.ROLE_USER); + client.setActive(true); + client.setPenalizationPoints(6); + client.setStatus(UserStatus.ACTIVE); + + RestaurantTable table = new RestaurantTable(); + table.setId(1L); + table.setName("salon 001"); + table.setMaxPax(4); + table.setLocation(TableLocation.SALON); + + Reservation reservation = new Reservation(); + reservation.setId(1L); + reservation.setUser(client); + reservation.setTable(table); + reservation.setReservationDate(LocalDateTime.now().plusHours(1)); + reservation.setNumberOfGuests(4); + reservation.setStatus(ReservationStatus.CONFIRMED); + + when(reservationRepository.findById(reservation.getId())).thenReturn(Optional.of(reservation)); + when(appUserRepository.findByUsername("tana")).thenReturn(Optional.of(client)); + + when(securityContext.getAuthentication()).thenReturn(authentication); + when(authentication.getName()).thenReturn("tana"); + + SecurityContextHolder.setContext(securityContext); + + reservationService.cancelReservation(reservation.getId()); + + assertEquals(8, client.getPenalizationPoints()); + assertEquals(UserStatus.BANNED, client.getStatus()); + } +} diff --git a/src/test/java/com/pachedev/restoreserve/service/ReservationServiceTxTest.java b/src/test/java/com/pachedev/restoreserve/service/ReservationServiceTxTest.java new file mode 100644 index 0000000..71d4f2e --- /dev/null +++ b/src/test/java/com/pachedev/restoreserve/service/ReservationServiceTxTest.java @@ -0,0 +1,136 @@ +package com.pachedev.restoreserve.service; + +import com.pachedev.restoreserve.dto.ReservationRequestDTO; +import com.pachedev.restoreserve.exception.BusinessLogicException; +import com.pachedev.restoreserve.exception.NotVipUserException; +import com.pachedev.restoreserve.model.entity.AppUser; +import com.pachedev.restoreserve.model.entity.Reservation; +import com.pachedev.restoreserve.model.entity.RestaurantTable; +import com.pachedev.restoreserve.model.enums.ReservationStatus; +import com.pachedev.restoreserve.model.enums.UserStatus; +import com.pachedev.restoreserve.repository.AppUserRepository; +import com.pachedev.restoreserve.repository.ReservationRepository; +import com.pachedev.restoreserve.repository.RestaurantTableRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.TestConstructor; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; + +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; + +@SpringBootTest(properties = "spring.datasource.url=jdbc:h2:mem:txtestdb") +@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL) +class ReservationServiceTxTest { + + private static final Long SEED_USER_ID = 1L; + private static final Long SEED_RESERVATION_ID = 1L; + private static final Long SEED_TABLE_ID = 1L; + private static final Long FILLER_TABLE_ID = 3L; + + private final ReservationService reservationService; + private final AppUserRepository appUserRepository; + private final RestaurantTableRepository tableRepository; + + @MockitoSpyBean + private ReservationRepository reservationRepository; + + ReservationServiceTxTest(ReservationService reservationService, + AppUserRepository appUserRepository, + RestaurantTableRepository tableRepository) { + this.reservationService = reservationService; + this.appUserRepository = appUserRepository; + this.tableRepository = tableRepository; + } + + @BeforeEach + void seed() { + AppUser user = appUserRepository.findById(SEED_USER_ID).orElseThrow(); + user.setPenalizationPoints(0); + user.setStatus(UserStatus.ACTIVE); + appUserRepository.save(user); + + Reservation reservation = reservationRepository.findById(SEED_RESERVATION_ID).orElseThrow(); + reservation.setStatus(ReservationStatus.CONFIRMED); + reservationRepository.save(reservation); + + RestaurantTable table = tableRepository.findById(SEED_TABLE_ID).orElseThrow(); + table.setIsVip(false); + tableRepository.save(table); + + if (reservationRepository.findByStatus(ReservationStatus.CONFIRMED).size() <= 3) { + RestaurantTable filler = tableRepository.findById(FILLER_TABLE_ID).orElseThrow(); + for (int i = 0; i < 4; i++) { + Reservation r = new Reservation(); + r.setUser(user); + r.setTable(filler); + r.setReservationDate(LocalDateTime.now().plusDays(10 + i)); + r.setNumberOfGuests(2); + r.setStatus(ReservationStatus.CONFIRMED); + reservationRepository.save(r); + } + } + } + + @Test + void createPersistsVipFlagOnTable() { + ReservationRequestDTO dto = new ReservationRequestDTO( + SEED_TABLE_ID, + LocalDateTime.now().plusDays(30).withHour(20).withMinute(0).withSecond(0).withNano(0), + 2, + true); + + reservationService.create(dto, "armymoves"); + + RestaurantTable reloaded = tableRepository.findById(SEED_TABLE_ID).orElseThrow(); + assertTrue(reloaded.getIsVip(), "is_vip debe persistirse en restaurant_tables"); + } + + @Test + @WithMockUser(username = "armymoves") + void cancelRollsBackPenaltyWhenLastWriteFails() { + doThrow(new DataIntegrityViolationException("fallo forzado")) + .when(reservationRepository).save(any(Reservation.class)); + + assertThrows(DataIntegrityViolationException.class, + () -> reservationService.cancelReservation(SEED_RESERVATION_ID)); + + AppUser after = appUserRepository.findById(SEED_USER_ID).orElseThrow(); + assertEquals(0, after.getPenalizationPoints(), + "los puntos deben revertirse si la cancelacion no se completa"); + } + + @Test + void createRejectsDoubleBookingOfSameSlot() { + ReservationRequestDTO slot = new ReservationRequestDTO( + SEED_TABLE_ID, + LocalDateTime.now().plusDays(45).withHour(21).withMinute(0).withSecond(0).withNano(0), + 2, + false); + + reservationService.create(slot, "armymoves"); + + assertThrows(BusinessLogicException.class, + () -> reservationService.create(slot, "armymoves"), + "una segunda reserva en el mismo hueco debe ser rechazada"); + } + + @Test + void createRejectsVipTableForNonHabitualUser() { + ReservationRequestDTO vip = new ReservationRequestDTO( + SEED_TABLE_ID, + LocalDateTime.now().plusDays(60).withHour(19).withMinute(0).withSecond(0).withNano(0), + 2, + true); + + assertThrows(NotVipUserException.class, + () -> reservationService.create(vip, "admin"), + "un usuario sin historial no debe poder reservar mesa VIP"); + } +} diff --git a/target/classes/application.properties b/target/classes/application.properties deleted file mode 100644 index ad91b45..0000000 --- a/target/classes/application.properties +++ /dev/null @@ -1,10 +0,0 @@ -spring.application.name=restoreserve - -spring.datasource.url=jdbc:h2:mem:testdb -spring.datasource.username=sa -spring.datasource.password= - -spring.jpa.hibernate.ddl-auto=update -spring.jpa.show-sql=true -spring.jpa.properties.hibernate.format_sql=true -spring.jpa.defer-datasource-initialization=true diff --git a/target/classes/com/pachedev/restoreserve/RestoreserveApplication.class b/target/classes/com/pachedev/restoreserve/RestoreserveApplication.class deleted file mode 100644 index bae8d0f..0000000 Binary files a/target/classes/com/pachedev/restoreserve/RestoreserveApplication.class and /dev/null differ diff --git a/target/test-classes/com/pachedev/restoreserve/RestoreserveApplicationTests.class b/target/test-classes/com/pachedev/restoreserve/RestoreserveApplicationTests.class deleted file mode 100644 index 5ea6b03..0000000 Binary files a/target/test-classes/com/pachedev/restoreserve/RestoreserveApplicationTests.class and /dev/null differ