From b60c6fd685ee2a5037b9cf8992eef059fe7cfbb8 Mon Sep 17 00:00:00 2001 From: Mykyta Prykhodko Date: Sat, 24 May 2025 23:28:38 +0200 Subject: [PATCH 01/11] Implemented creation of lobby Implemented retrieve one lobby by id Implemented mapper dtoToEntity for lobby --- domain/pom.xml | 4 +++ .../java/org/windat/domain/entity/Lobby.java | 18 +++++++++++- .../domain/repository/LobbyRepository.java | 3 ++ .../windat/domain/service/LobbyFacade.java | 5 ++++ .../windat/domain/service/LobbyService.java | 28 +++++++++++++++++++ .../ws/controller/LobbyRestController.java | 13 +++++++-- .../org/windat/ws/mapper/LobbyMapper.java | 7 +++++ .../adapter/JpaLobbyRepositoryAdapter.java | 7 +++++ .../src/main/resources/application.yaml | 4 +-- 9 files changed, 84 insertions(+), 5 deletions(-) diff --git a/domain/pom.xml b/domain/pom.xml index 3c55352..814e413 100644 --- a/domain/pom.xml +++ b/domain/pom.xml @@ -24,5 +24,9 @@ 3.8.1 test + + jakarta.transaction + jakarta.transaction-api + diff --git a/domain/src/main/java/org/windat/domain/entity/Lobby.java b/domain/src/main/java/org/windat/domain/entity/Lobby.java index 55e7365..80d3cfa 100644 --- a/domain/src/main/java/org/windat/domain/entity/Lobby.java +++ b/domain/src/main/java/org/windat/domain/entity/Lobby.java @@ -88,7 +88,7 @@ public void removeUser(User user){ * Note: The logic `userList.size() > 2` indicates that the lobby can hold a maximum of 2 users. */ public boolean isFull(){ - return this.userList.size() > 2; + return this.userList.size() >= 2; } /* @@ -103,6 +103,10 @@ public String getName() { return name; } + public void setName(String name) { + this.name = name; + } + public List getUserList() { return userList; } @@ -111,6 +115,18 @@ public Date getCreated() { return created; } + public void setCreated(Date created) { + this.created = created; + } + + public void setUpdated(Date updated) { + this.updated = updated; + } + + public void setClosed(Date closed) { + this.closed = closed; + } + public Date getUpdated() { return updated; } diff --git a/domain/src/main/java/org/windat/domain/repository/LobbyRepository.java b/domain/src/main/java/org/windat/domain/repository/LobbyRepository.java index 5f17a91..25bdd41 100644 --- a/domain/src/main/java/org/windat/domain/repository/LobbyRepository.java +++ b/domain/src/main/java/org/windat/domain/repository/LobbyRepository.java @@ -3,10 +3,13 @@ import org.windat.domain.entity.Lobby; import java.util.Collection; +import java.util.Optional; public interface LobbyRepository { Collection readAll(); Lobby create(Lobby lobby); + + Optional readOne(Integer id); } diff --git a/domain/src/main/java/org/windat/domain/service/LobbyFacade.java b/domain/src/main/java/org/windat/domain/service/LobbyFacade.java index 151e8c8..5a88c9a 100644 --- a/domain/src/main/java/org/windat/domain/service/LobbyFacade.java +++ b/domain/src/main/java/org/windat/domain/service/LobbyFacade.java @@ -3,6 +3,7 @@ import org.windat.domain.entity.Lobby; import java.util.Collection; +import java.util.Optional; // Note for myself: @@ -10,4 +11,8 @@ public interface LobbyFacade { Collection readAll(); + + Lobby create(Lobby lobby); + + Optional readOne(Integer lobbyId); } diff --git a/domain/src/main/java/org/windat/domain/service/LobbyService.java b/domain/src/main/java/org/windat/domain/service/LobbyService.java index f36b7e6..ca2e436 100644 --- a/domain/src/main/java/org/windat/domain/service/LobbyService.java +++ b/domain/src/main/java/org/windat/domain/service/LobbyService.java @@ -1,9 +1,14 @@ package org.windat.domain.service; +import jakarta.transaction.Transactional; import org.windat.domain.entity.Lobby; import org.windat.domain.repository.LobbyRepository; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.Collection; +import java.util.Date; +import java.util.Optional; public class LobbyService implements LobbyFacade { @@ -17,4 +22,27 @@ public LobbyService(LobbyRepository lobbyRepository) { public Collection readAll(){ return lobbyRepository.readAll(); } + + @Override + @Transactional + public Lobby create(Lobby lobby) { + Date now = new Date(); + lobby.setCreated(now); + lobby.setUpdated(now); + + +// Set the lobby closure time so it will be next day (in 24 hours) + Instant closedInstant = now.toInstant().plus(1, ChronoUnit.DAYS); + Date closedDate = Date.from(closedInstant); + lobby.setClosed(closedDate); + + return lobbyRepository.create(lobby); + } + + + @Override + public Optional readOne(Integer lobbyId) { + return lobbyRepository.readOne(lobbyId); + } + } diff --git a/inbound-controller-ws/src/main/java/org/windat/ws/controller/LobbyRestController.java b/inbound-controller-ws/src/main/java/org/windat/ws/controller/LobbyRestController.java index 98ea199..e1ef74a 100644 --- a/inbound-controller-ws/src/main/java/org/windat/ws/controller/LobbyRestController.java +++ b/inbound-controller-ws/src/main/java/org/windat/ws/controller/LobbyRestController.java @@ -27,12 +27,21 @@ public LobbyRestController(LobbyFacade lobbyFacade, LobbyMapper lobbyMapper) { @Override public ResponseEntity createLobby(LobbyCreateRequestDTODto lobbyCreateRequestDTODto) { - return null; + Lobby newLobby = this.lobbyMapper.dtoToEntity(lobbyCreateRequestDTODto); + Lobby savedLobby = this.lobbyFacade.create(newLobby); + LobbyDto lobbyDto = this.lobbyMapper.toDto(savedLobby); + return ResponseEntity.ok(lobbyDto); } @Override public ResponseEntity getOneLobby(Integer lobbyId) { - return null; + if (this.lobbyFacade.readOne(lobbyId).isPresent()) { + Lobby retrievedLobby = this.lobbyFacade.readOne(lobbyId).get(); + LobbyDto lobbyDto = this.lobbyMapper.toDto(retrievedLobby); + return ResponseEntity.ok(lobbyDto); + } else { + return ResponseEntity.notFound().build(); + } } @Override diff --git a/inbound-controller-ws/src/main/java/org/windat/ws/mapper/LobbyMapper.java b/inbound-controller-ws/src/main/java/org/windat/ws/mapper/LobbyMapper.java index 5cac576..c01fc8c 100644 --- a/inbound-controller-ws/src/main/java/org/windat/ws/mapper/LobbyMapper.java +++ b/inbound-controller-ws/src/main/java/org/windat/ws/mapper/LobbyMapper.java @@ -4,6 +4,7 @@ import org.mapstruct.Mapping; import org.mapstruct.Named; import org.windat.domain.entity.Lobby; +import org.windat.rest.dto.LobbyCreateRequestDTODto; import org.windat.rest.dto.LobbyDto; import java.time.OffsetDateTime; @@ -25,4 +26,10 @@ default OffsetDateTime map(Date date) { } return date.toInstant().atOffset(ZoneOffset.UTC); } + + @Mapping(source = "name", target = "name") + @Mapping(target = "created", ignore = true) + @Mapping(target = "updated", ignore = true) + @Mapping(target = "closed", ignore = true) + Lobby dtoToEntity(LobbyCreateRequestDTODto dto); } \ No newline at end of file diff --git a/outbound-repository-jpa/src/main/java/org/windat/jpa/adapter/JpaLobbyRepositoryAdapter.java b/outbound-repository-jpa/src/main/java/org/windat/jpa/adapter/JpaLobbyRepositoryAdapter.java index 8a82953..3c68530 100644 --- a/outbound-repository-jpa/src/main/java/org/windat/jpa/adapter/JpaLobbyRepositoryAdapter.java +++ b/outbound-repository-jpa/src/main/java/org/windat/jpa/adapter/JpaLobbyRepositoryAdapter.java @@ -6,6 +6,7 @@ import org.windat.jpa.repository.LobbySpringDataRepository; import java.util.Collection; +import java.util.Optional; @Repository public class JpaLobbyRepositoryAdapter implements LobbyRepository { @@ -25,4 +26,10 @@ public Collection readAll() { public Lobby create(Lobby lobby) { return lobbySpringDataRepository.save(lobby); } + + + @Override + public Optional readOne(Integer id) { + return lobbySpringDataRepository.findById(id); + } } diff --git a/springboot/src/main/resources/application.yaml b/springboot/src/main/resources/application.yaml index 97a2dcf..73ec009 100644 --- a/springboot/src/main/resources/application.yaml +++ b/springboot/src/main/resources/application.yaml @@ -3,8 +3,8 @@ spring: # oauth2: # resourceserver: # jwt: -# issuer-uri: http://localhost:8082/realms/FSA -# jwk-set-uri: http://localhost:8082/realms/FSA/protocol/openid-connect/certs +# issuer-uri: http://localhost:8082/realms/WinDat +# jwk-set-uri: http://localhost:8082/realms/WinDat/protocol/openid-connect/certs # datasource: # url: jdbc:postgresql://localhost:6969/windat_db # username: admin From 75bde2bd8f40956e0bdce26949bfe1bc3d1a4e8e Mon Sep 17 00:00:00 2001 From: Mykyta Prykhodko Date: Sun, 25 May 2025 16:38:52 +0200 Subject: [PATCH 02/11] Implemented feature to add users to the lobby --- .../main/resources/openapi/windatOpenApi.yaml | 40 ++++++++- domain/pom.xml | 4 + .../java/org/windat/domain/entity/Lobby.java | 10 +++ .../java/org/windat/domain/entity/User.java | 40 +++++++++ .../domain/exceptions/LobbyFullException.java | 11 +++ .../exceptions/LobbyNotFoundException.java | 11 +++ .../UserAlreadyInLobbyException.java | 11 +++ .../domain/repository/LobbyRepository.java | 2 + .../domain/repository/UserRepository.java | 18 ++++ .../windat/domain/service/LobbyFacade.java | 2 + .../windat/domain/service/LobbyService.java | 5 ++ .../org/windat/domain/service/UserFacade.java | 18 ++++ .../windat/domain/service/UserService.java | 41 +++++++++ .../ws/controller/LobbyRestController.java | 85 +++++++++++++++++-- .../GlobalExceptionHandler.java | 72 ++++++++++++++++ .../java/org/windat/ws/mapper/DateMapper.java | 16 ++++ .../org/windat/ws/mapper/LobbyMapper.java | 24 ++++-- .../java/org/windat/ws/mapper/UserMapper.java | 54 ++++++++++++ .../org/windat/ws/security/JwtConverter.java | 9 +- .../adapter/JpaLobbyRepositoryAdapter.java | 5 ++ .../jpa/adapter/JpaUserRepositoryAdapter.java | 39 +++++++++ .../repository/UserSpringDataRepository.java | 11 +++ .../src/main/resources/persistence/orm.xml | 9 +- .../windat/main/DateBeanConfiguration.java | 15 ++++ .../windat/main/UserBeanConfiguration.java | 38 +++++++++ 25 files changed, 567 insertions(+), 23 deletions(-) create mode 100644 domain/src/main/java/org/windat/domain/exceptions/LobbyFullException.java create mode 100644 domain/src/main/java/org/windat/domain/exceptions/LobbyNotFoundException.java create mode 100644 domain/src/main/java/org/windat/domain/exceptions/UserAlreadyInLobbyException.java create mode 100644 domain/src/main/java/org/windat/domain/repository/UserRepository.java create mode 100644 domain/src/main/java/org/windat/domain/service/UserFacade.java create mode 100644 domain/src/main/java/org/windat/domain/service/UserService.java create mode 100644 inbound-controller-ws/src/main/java/org/windat/ws/exceptionHandlers/GlobalExceptionHandler.java create mode 100644 inbound-controller-ws/src/main/java/org/windat/ws/mapper/DateMapper.java create mode 100644 inbound-controller-ws/src/main/java/org/windat/ws/mapper/UserMapper.java create mode 100644 outbound-repository-jpa/src/main/java/org/windat/jpa/adapter/JpaUserRepositoryAdapter.java create mode 100644 outbound-repository-jpa/src/main/java/org/windat/jpa/repository/UserSpringDataRepository.java create mode 100644 springboot/src/main/java/org/windat/main/DateBeanConfiguration.java create mode 100644 springboot/src/main/java/org/windat/main/UserBeanConfiguration.java diff --git a/api-spec/src/main/resources/openapi/windatOpenApi.yaml b/api-spec/src/main/resources/openapi/windatOpenApi.yaml index 06f5d02..7b0b0e9 100644 --- a/api-spec/src/main/resources/openapi/windatOpenApi.yaml +++ b/api-spec/src/main/resources/openapi/windatOpenApi.yaml @@ -42,12 +42,18 @@ components: id: type: integer format: int32 + keycloakId: + type: string + format: uuid + description: Unique keycloak identifier loginName: type: string userRoleEnum: $ref: '#/components/schemas/UserRole' - lobby: - $ref: '#/components/schemas/Lobby' + lobbyId: + type: integer + format: int32 + description: ID of the lobby the user is currently associated with. UserRole: type: string enum: @@ -116,4 +122,32 @@ paths: schema: $ref: '#/components/schemas/Lobby' '404': - description: Lobby not found \ No newline at end of file + description: Lobby not found + /lobbies/{lobbyId}/users: + post: + summary: Add user to lobby + operationId: addUserToLobby + tags: + - Lobbies + parameters: + - name: lobbyId + in: path + required: true + schema: + type: integer + format: int32 + description: ID of the lobby to add the user to + responses: + '200': + description: User was successfully added to lobby + content: + application/json: + schema: + $ref: '#/components/schemas/Lobby' + '401': + description: Unauthorized — user not authenticated + '404': + description: Lobby not found + '409': + description: User is already in the lobby + diff --git a/domain/pom.xml b/domain/pom.xml index 814e413..beaf68e 100644 --- a/domain/pom.xml +++ b/domain/pom.xml @@ -28,5 +28,9 @@ jakarta.transaction jakarta.transaction-api + + org.springframework + spring-web + diff --git a/domain/src/main/java/org/windat/domain/entity/Lobby.java b/domain/src/main/java/org/windat/domain/entity/Lobby.java index 80d3cfa..b4ff951 100644 --- a/domain/src/main/java/org/windat/domain/entity/Lobby.java +++ b/domain/src/main/java/org/windat/domain/entity/Lobby.java @@ -67,6 +67,7 @@ public void addUser(User user){ throw new NullPointerException("User cannot be null"); } userList.add(user); + user.setLobby(this); } /** @@ -91,6 +92,15 @@ public boolean isFull(){ return this.userList.size() >= 2; } + /** + * Checks if lobby already has specific user + * + * @return boolean + */ + public boolean containsUser(User user){ + return userList.contains(user); + } + /* * Getters for the class attributes */ diff --git a/domain/src/main/java/org/windat/domain/entity/User.java b/domain/src/main/java/org/windat/domain/entity/User.java index e8495e8..5589e3e 100644 --- a/domain/src/main/java/org/windat/domain/entity/User.java +++ b/domain/src/main/java/org/windat/domain/entity/User.java @@ -2,6 +2,8 @@ import org.windat.domain.UserRole; +import java.util.UUID; + /** * Represents a user within the application. * This class encapsulates the state and behavior of a user, including their unique @@ -15,6 +17,12 @@ public class User { */ private int id; + /** + * Unique keycloak identifier + * Used to find specific user in keycloak database + */ + private UUID keycloakId; + /** * The login name of the user, used for authentication and identification. */ @@ -76,4 +84,36 @@ public UserRole getUserRoleEnum() { public Lobby getLobby() { return lobby; } + + /** + * Gets id of the lobby user is currently in + * @return lobby id + */ + public Integer getCurrentLobbyId(){ + return (this.lobby != null) ? this.lobby.getId() : null; + } + + + /** + * + */ + public UUID getKeycloakId() { + return keycloakId; + } + + public void setKeycloakId(UUID keycloakId) { + this.keycloakId = keycloakId; + } + + public void setLoginName(String loginName) { + this.loginName = loginName; + } + + public void setUserRoleEnum(UserRole userRoleEnum) { + this.userRoleEnum = userRoleEnum; + } + + public void setLobby(Lobby lobby) { + this.lobby = lobby; + } } \ No newline at end of file diff --git a/domain/src/main/java/org/windat/domain/exceptions/LobbyFullException.java b/domain/src/main/java/org/windat/domain/exceptions/LobbyFullException.java new file mode 100644 index 0000000..cb09721 --- /dev/null +++ b/domain/src/main/java/org/windat/domain/exceptions/LobbyFullException.java @@ -0,0 +1,11 @@ +package org.windat.domain.exceptions; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.BAD_REQUEST) +public class LobbyFullException extends RuntimeException { + public LobbyFullException(String message) { + super(message); + } +} diff --git a/domain/src/main/java/org/windat/domain/exceptions/LobbyNotFoundException.java b/domain/src/main/java/org/windat/domain/exceptions/LobbyNotFoundException.java new file mode 100644 index 0000000..fa0f99d --- /dev/null +++ b/domain/src/main/java/org/windat/domain/exceptions/LobbyNotFoundException.java @@ -0,0 +1,11 @@ +package org.windat.domain.exceptions; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.NOT_FOUND) +public class LobbyNotFoundException extends RuntimeException { + public LobbyNotFoundException(String message) { + super(message); + } +} diff --git a/domain/src/main/java/org/windat/domain/exceptions/UserAlreadyInLobbyException.java b/domain/src/main/java/org/windat/domain/exceptions/UserAlreadyInLobbyException.java new file mode 100644 index 0000000..57c6686 --- /dev/null +++ b/domain/src/main/java/org/windat/domain/exceptions/UserAlreadyInLobbyException.java @@ -0,0 +1,11 @@ +package org.windat.domain.exceptions; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.CONFLICT) // 409 +public class UserAlreadyInLobbyException extends RuntimeException { + public UserAlreadyInLobbyException(String message) { + super(message); + } +} diff --git a/domain/src/main/java/org/windat/domain/repository/LobbyRepository.java b/domain/src/main/java/org/windat/domain/repository/LobbyRepository.java index 25bdd41..943bc46 100644 --- a/domain/src/main/java/org/windat/domain/repository/LobbyRepository.java +++ b/domain/src/main/java/org/windat/domain/repository/LobbyRepository.java @@ -12,4 +12,6 @@ public interface LobbyRepository { Lobby create(Lobby lobby); Optional readOne(Integer id); + + Lobby update(Lobby lobby); } diff --git a/domain/src/main/java/org/windat/domain/repository/UserRepository.java b/domain/src/main/java/org/windat/domain/repository/UserRepository.java new file mode 100644 index 0000000..6ff603a --- /dev/null +++ b/domain/src/main/java/org/windat/domain/repository/UserRepository.java @@ -0,0 +1,18 @@ +package org.windat.domain.repository; + +import org.windat.domain.entity.User; + +import java.util.Collection; +import java.util.Optional; +import java.util.UUID; + +public interface UserRepository { + + Collection readAll(); + + User create(User user); + + Optional readOne(UUID uuid); + + User update(User user); +} diff --git a/domain/src/main/java/org/windat/domain/service/LobbyFacade.java b/domain/src/main/java/org/windat/domain/service/LobbyFacade.java index 5a88c9a..e1afe48 100644 --- a/domain/src/main/java/org/windat/domain/service/LobbyFacade.java +++ b/domain/src/main/java/org/windat/domain/service/LobbyFacade.java @@ -15,4 +15,6 @@ public interface LobbyFacade { Lobby create(Lobby lobby); Optional readOne(Integer lobbyId); + + Lobby update(Lobby lobby); } diff --git a/domain/src/main/java/org/windat/domain/service/LobbyService.java b/domain/src/main/java/org/windat/domain/service/LobbyService.java index ca2e436..e3caf19 100644 --- a/domain/src/main/java/org/windat/domain/service/LobbyService.java +++ b/domain/src/main/java/org/windat/domain/service/LobbyService.java @@ -45,4 +45,9 @@ public Optional readOne(Integer lobbyId) { return lobbyRepository.readOne(lobbyId); } + @Override + @Transactional + public Lobby update(Lobby lobby) { + return lobbyRepository.update(lobby); + } } diff --git a/domain/src/main/java/org/windat/domain/service/UserFacade.java b/domain/src/main/java/org/windat/domain/service/UserFacade.java new file mode 100644 index 0000000..0d3c71e --- /dev/null +++ b/domain/src/main/java/org/windat/domain/service/UserFacade.java @@ -0,0 +1,18 @@ +package org.windat.domain.service; + +import org.windat.domain.entity.User; + +import java.util.Collection; +import java.util.Optional; +import java.util.UUID; + +public interface UserFacade { + + Collection readAll(); + + User create(User user); + + Optional readOne(UUID uuid); + + User update(User user); +} diff --git a/domain/src/main/java/org/windat/domain/service/UserService.java b/domain/src/main/java/org/windat/domain/service/UserService.java new file mode 100644 index 0000000..bb2e55d --- /dev/null +++ b/domain/src/main/java/org/windat/domain/service/UserService.java @@ -0,0 +1,41 @@ +package org.windat.domain.service; + +import jakarta.transaction.Transactional; +import org.windat.domain.entity.User; +import org.windat.domain.repository.UserRepository; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public class UserService implements UserFacade{ + + private final UserRepository userRepository; + + public UserService(UserRepository userRepository){ + this.userRepository = userRepository; + } + + @Override + public Collection readAll() { + return userRepository.readAll(); + } + + @Override + @Transactional + public User create(User user) { + return userRepository.create(user); + } + + @Override + public Optional readOne(UUID uuid) { + return userRepository.readOne(uuid); + } + + @Override + @Transactional + public User update(User user) { + return userRepository.update(user); + } +} diff --git a/inbound-controller-ws/src/main/java/org/windat/ws/controller/LobbyRestController.java b/inbound-controller-ws/src/main/java/org/windat/ws/controller/LobbyRestController.java index e1ef74a..99fc809 100644 --- a/inbound-controller-ws/src/main/java/org/windat/ws/controller/LobbyRestController.java +++ b/inbound-controller-ws/src/main/java/org/windat/ws/controller/LobbyRestController.java @@ -1,28 +1,101 @@ package org.windat.ws.controller; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.RestController; +import org.windat.domain.UserRole; import org.windat.domain.entity.Lobby; +import org.windat.domain.entity.User; +import org.windat.domain.exceptions.LobbyFullException; +import org.windat.domain.exceptions.LobbyNotFoundException; +import org.windat.domain.exceptions.UserAlreadyInLobbyException; import org.windat.domain.service.LobbyFacade; +import org.windat.domain.service.UserFacade; import org.windat.rest.api.LobbiesApi; import org.windat.rest.dto.LobbyCreateRequestDTODto; import org.windat.rest.dto.LobbyDto; +import org.windat.rest.dto.UserDto; import org.windat.ws.mapper.LobbyMapper; +import org.windat.ws.mapper.UserMapper; -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; +import java.util.*; import java.util.stream.Collectors; +import org.springframework.security.core.context.SecurityContextHolder; + @RestController -public class LobbyRestController implements LobbiesApi{ +public class LobbyRestController implements LobbiesApi { private final LobbyFacade lobbyFacade; private final LobbyMapper lobbyMapper; - public LobbyRestController(LobbyFacade lobbyFacade, LobbyMapper lobbyMapper) { + private final UserFacade userFacade; + private final UserMapper userMapper; + + public LobbyRestController( + LobbyFacade lobbyFacade, + LobbyMapper lobbyMapper, + UserFacade userFacade, + UserMapper userMapper + ) { this.lobbyFacade = lobbyFacade; this.lobbyMapper = lobbyMapper; + this.userFacade = userFacade; + this.userMapper = userMapper; + } + + /** + * Retrieve user from JWT token. If exists in application database, add to lobby. + * If not, create this user in application database and then add to the lobby. + * + * @param lobbyId The ID of the lobby to add the user to. + * @return ResponseEntity containing the updated LobbyDto or an error status. + * @throws LobbyNotFoundException if the lobby does not exist. + * @throws LobbyFullException if the lobby is full. + * @throws UserAlreadyInLobbyException if the user is already in the lobby. + */ + @Override + public ResponseEntity addUserToLobby(Integer lobbyId) { + +// Get user from jwt token + UserDto userDto = (UserDto) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + UUID keycloakId = userDto.getKeycloakId(); + String username = userDto.getLoginName(); + +// Get lobby or throw error if optional returns null + Lobby lobby = lobbyFacade.readOne(lobbyId).orElseThrow( + () -> new LobbyNotFoundException("Lobby with ID " + lobbyId + " not found.") + ); + +// Get users if exists in application database or create new if not + User user = userFacade.readOne(keycloakId).orElseGet(() -> { + User newUser = new User(); + newUser.setLoginName(username); + newUser.setKeycloakId(keycloakId); + newUser.setUserRoleEnum(UserRole.USER_ROLE); + + return userFacade.create(newUser); + }); + +// Check if lobby is full + if (lobby.isFull()) { + throw new LobbyFullException("Lobby is already full."); + } + +// Check if user already exist in lobby + if (lobby.containsUser(user)) { + throw new UserAlreadyInLobbyException("User " + username + " is already in lobby."); + } + + lobby.addUser(user); + +// Persist user updates + userFacade.update(user); + +// Persist updated lobby + Lobby updatedLobby = lobbyFacade.update(lobby); + + return ResponseEntity.ok(lobbyMapper.toDto(updatedLobby)); } @Override @@ -30,7 +103,7 @@ public ResponseEntity createLobby(LobbyCreateRequestDTODto lobbyCreate Lobby newLobby = this.lobbyMapper.dtoToEntity(lobbyCreateRequestDTODto); Lobby savedLobby = this.lobbyFacade.create(newLobby); LobbyDto lobbyDto = this.lobbyMapper.toDto(savedLobby); - return ResponseEntity.ok(lobbyDto); + return ResponseEntity.status(HttpStatus.CREATED).body(lobbyDto); } @Override diff --git a/inbound-controller-ws/src/main/java/org/windat/ws/exceptionHandlers/GlobalExceptionHandler.java b/inbound-controller-ws/src/main/java/org/windat/ws/exceptionHandlers/GlobalExceptionHandler.java new file mode 100644 index 0000000..ab249bb --- /dev/null +++ b/inbound-controller-ws/src/main/java/org/windat/ws/exceptionHandlers/GlobalExceptionHandler.java @@ -0,0 +1,72 @@ +package org.windat.ws.exceptionHandlers; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.windat.domain.exceptions.LobbyNotFoundException; + +import org.windat.domain.exceptions.LobbyFullException; +import org.windat.domain.exceptions.LobbyNotFoundException; +import org.windat.domain.exceptions.UserAlreadyInLobbyException; + +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.Map; + +// Annotation for global exception handler +@ControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(LobbyNotFoundException.class) + @ResponseStatus(HttpStatus.NOT_FOUND) + public ResponseEntity handleLobbyNotFoundException(LobbyNotFoundException exception) { + Map body = new LinkedHashMap<>(); + body.put("timestamp", LocalDateTime.now()); + body.put("status", HttpStatus.NOT_FOUND.value()); + body.put("error", HttpStatus.NOT_FOUND.getReasonPhrase()); + body.put("message", exception.getMessage()); + + return new ResponseEntity<>(body, HttpStatus.NOT_FOUND); + } + + @ExceptionHandler(LobbyFullException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) // 400 + public ResponseEntity handleLobbyFullException(LobbyFullException exception) { + Map body = new LinkedHashMap<>(); + body.put("timestamp", LocalDateTime.now()); + body.put("status", HttpStatus.BAD_REQUEST.value()); + body.put("error", HttpStatus.BAD_REQUEST.getReasonPhrase()); + body.put("message", exception.getMessage()); + + return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(UserAlreadyInLobbyException.class) + @ResponseStatus(HttpStatus.CONFLICT) // 409 + public ResponseEntity handleUserAlreadyInLobbyException(UserAlreadyInLobbyException exception) { + Map body = new LinkedHashMap<>(); + body.put("timestamp", LocalDateTime.now()); + body.put("status", HttpStatus.CONFLICT.value()); + body.put("error", HttpStatus.CONFLICT.getReasonPhrase()); + body.put("message", exception.getMessage()); + + return new ResponseEntity<>(body, HttpStatus.CONFLICT); + } + + // General error handler + @ExceptionHandler(Exception.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public ResponseEntity handleAllUncaughtException(Exception exception) { + Map body = new LinkedHashMap<>(); + body.put("timestamp", LocalDateTime.now()); + body.put("status", HttpStatus.INTERNAL_SERVER_ERROR.value()); + body.put("error", "Internal Server Error"); + +// Possible error message for client + body.put("message", "An unexpected error occurred. Please try again later."); + + return new ResponseEntity<>(body, HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/inbound-controller-ws/src/main/java/org/windat/ws/mapper/DateMapper.java b/inbound-controller-ws/src/main/java/org/windat/ws/mapper/DateMapper.java new file mode 100644 index 0000000..813ef48 --- /dev/null +++ b/inbound-controller-ws/src/main/java/org/windat/ws/mapper/DateMapper.java @@ -0,0 +1,16 @@ +package org.windat.ws.mapper; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.Date; + +public interface DateMapper { + + default OffsetDateTime map(Date date) { + return date == null ? null : date.toInstant().atOffset(ZoneOffset.UTC); + } + + default Date map(OffsetDateTime offsetDateTime) { + return offsetDateTime == null ? null : Date.from(offsetDateTime.toInstant()); + } +} diff --git a/inbound-controller-ws/src/main/java/org/windat/ws/mapper/LobbyMapper.java b/inbound-controller-ws/src/main/java/org/windat/ws/mapper/LobbyMapper.java index c01fc8c..fc332d7 100644 --- a/inbound-controller-ws/src/main/java/org/windat/ws/mapper/LobbyMapper.java +++ b/inbound-controller-ws/src/main/java/org/windat/ws/mapper/LobbyMapper.java @@ -4,27 +4,32 @@ import org.mapstruct.Mapping; import org.mapstruct.Named; import org.windat.domain.entity.Lobby; +import org.windat.domain.entity.User; import org.windat.rest.dto.LobbyCreateRequestDTODto; import org.windat.rest.dto.LobbyDto; +import org.windat.rest.dto.UserDto; import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.util.Date; +import java.util.List; +import java.util.stream.Collectors; -@Mapper(componentModel = "spring") +@Mapper(componentModel = "spring", uses = {DateMapper.class, UserMapper.class}) public interface LobbyMapper { @Mapping(source = "created", target = "created", qualifiedByName = "dateToOffsetDateTime") @Mapping(source = "updated", target = "updated", qualifiedByName = "dateToOffsetDateTime") @Mapping(source = "closed", target = "closed", qualifiedByName = "dateToOffsetDateTime") + @Mapping(target = "userList", qualifiedByName = "toDtoShallowList") // Це викликає toDtoShallow, який тепер поверне lobbyId LobbyDto toDto(Lobby lobby); - @Named("dateToOffsetDateTime") - default OffsetDateTime map(Date date) { - if (date == null) { - return null; - } - return date.toInstant().atOffset(ZoneOffset.UTC); + @Named("toDtoShallowList") + static List toDtoShallowList(List users) { + if (users == null) return null; + return users.stream() + .map(UserMapper::toDtoShallow) + .collect(Collectors.toList()); } @Mapping(source = "name", target = "name") @@ -32,4 +37,9 @@ default OffsetDateTime map(Date date) { @Mapping(target = "updated", ignore = true) @Mapping(target = "closed", ignore = true) Lobby dtoToEntity(LobbyCreateRequestDTODto dto); + + @Named("dateToOffsetDateTime") + static OffsetDateTime map(Date date) { + return date == null ? null : date.toInstant().atOffset(ZoneOffset.UTC); + } } \ No newline at end of file diff --git a/inbound-controller-ws/src/main/java/org/windat/ws/mapper/UserMapper.java b/inbound-controller-ws/src/main/java/org/windat/ws/mapper/UserMapper.java new file mode 100644 index 0000000..c9c9edb --- /dev/null +++ b/inbound-controller-ws/src/main/java/org/windat/ws/mapper/UserMapper.java @@ -0,0 +1,54 @@ +package org.windat.ws.mapper; + +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.Named; +import org.windat.domain.UserRole; +import org.windat.domain.entity.User; +import org.windat.rest.dto.UserDto; +import org.windat.rest.dto.UserRoleDto; + + +@Mapper(componentModel = "spring", uses = {DateMapper.class}) +public interface UserMapper { + + @Mapping(source = "lobby.id", target = "lobbyId") + UserDto toDto(User user); + + User toUser(UserDto userDto); + + @Named("toDtoShallow") + static UserDto toDtoShallow(User user) { + if (user == null) return null; + + UserDto dto = new UserDto(); + dto.setId(user.getId()); + dto.setKeycloakId(user.getKeycloakId()); + dto.setLoginName(user.getLoginName()); + dto.setUserRoleEnum(toUserRoleDto(user.getUserRoleEnum())); + dto.setLobbyId(user.getCurrentLobbyId()); + return dto; + } + + // Enum mapping from domain to DTO + static UserRoleDto toUserRoleDto(UserRole role) { + if (role == null) { + return null; + } + return switch (role) { + case USER_ROLE -> UserRoleDto.USER_ROLE; + case ADMIN_ROLE -> UserRoleDto.ADMIN_ROLE; + }; + } + + // Enum mapping from DTO to domain + default UserRole toUserRole(UserRoleDto dtoRole) { + if (dtoRole == null) { + return null; + } + return switch (dtoRole) { + case USER_ROLE -> UserRole.USER_ROLE; + case ADMIN_ROLE -> UserRole.ADMIN_ROLE; + }; + } +} \ No newline at end of file diff --git a/inbound-controller-ws/src/main/java/org/windat/ws/security/JwtConverter.java b/inbound-controller-ws/src/main/java/org/windat/ws/security/JwtConverter.java index 3ef3bfc..1c8ef5b 100644 --- a/inbound-controller-ws/src/main/java/org/windat/ws/security/JwtConverter.java +++ b/inbound-controller-ws/src/main/java/org/windat/ws/security/JwtConverter.java @@ -27,10 +27,11 @@ public Object getCredentials() { @Override public Object getPrincipal() { UserDto userDto = new UserDto(); -// userDto.setEmail(source.getClaimAsString("email")); -// userDto.setName(source.getClaimAsString("given_name")); -// userDto.setRola(getRole()); - userDto.setLoginName(source.getClaimAsString("login_name")); + userDto.setKeycloakId(UUID.fromString(source.getSubject())); + userDto.setLoginName(source.getClaim("preferred_username")); +// I will comment out this for now +// Letter need to implement role checking and do thing based on that +// userDto.setUserRoleEnum(UserRoleDto.USER_ROLE); return userDto; } diff --git a/outbound-repository-jpa/src/main/java/org/windat/jpa/adapter/JpaLobbyRepositoryAdapter.java b/outbound-repository-jpa/src/main/java/org/windat/jpa/adapter/JpaLobbyRepositoryAdapter.java index 3c68530..fcd11ef 100644 --- a/outbound-repository-jpa/src/main/java/org/windat/jpa/adapter/JpaLobbyRepositoryAdapter.java +++ b/outbound-repository-jpa/src/main/java/org/windat/jpa/adapter/JpaLobbyRepositoryAdapter.java @@ -32,4 +32,9 @@ public Lobby create(Lobby lobby) { public Optional readOne(Integer id) { return lobbySpringDataRepository.findById(id); } + + @Override + public Lobby update(Lobby lobby) { + return lobbySpringDataRepository.save(lobby); + } } diff --git a/outbound-repository-jpa/src/main/java/org/windat/jpa/adapter/JpaUserRepositoryAdapter.java b/outbound-repository-jpa/src/main/java/org/windat/jpa/adapter/JpaUserRepositoryAdapter.java new file mode 100644 index 0000000..a26d8ea --- /dev/null +++ b/outbound-repository-jpa/src/main/java/org/windat/jpa/adapter/JpaUserRepositoryAdapter.java @@ -0,0 +1,39 @@ +package org.windat.jpa.adapter; + +import org.windat.domain.entity.User; +import org.windat.domain.repository.UserRepository; +import org.windat.jpa.repository.UserSpringDataRepository; + +import java.util.Collection; +import java.util.Optional; +import java.util.UUID; + +public class JpaUserRepositoryAdapter implements UserRepository { + + private final UserSpringDataRepository userSpringDataRepository; + + public JpaUserRepositoryAdapter(UserSpringDataRepository userSpringDataRepository) { + this.userSpringDataRepository = userSpringDataRepository; + } + + @Override + public Collection readAll() { + return userSpringDataRepository.findAll(); + } + + @Override + public User create(User user) { + return userSpringDataRepository.save(user); + } + + @Override + public Optional readOne(UUID uuid) { + return userSpringDataRepository.findByKeycloakId(uuid); + } + + @Override + public User update(User user) { + return userSpringDataRepository.save(user); + } + +} diff --git a/outbound-repository-jpa/src/main/java/org/windat/jpa/repository/UserSpringDataRepository.java b/outbound-repository-jpa/src/main/java/org/windat/jpa/repository/UserSpringDataRepository.java new file mode 100644 index 0000000..9dad9e4 --- /dev/null +++ b/outbound-repository-jpa/src/main/java/org/windat/jpa/repository/UserSpringDataRepository.java @@ -0,0 +1,11 @@ +package org.windat.jpa.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.windat.domain.entity.User; + +import java.util.Optional; +import java.util.UUID; + +public interface UserSpringDataRepository extends JpaRepository { + Optional findByKeycloakId(UUID keycloakId); +} diff --git a/outbound-repository-jpa/src/main/resources/persistence/orm.xml b/outbound-repository-jpa/src/main/resources/persistence/orm.xml index 2e8b492..2943a92 100644 --- a/outbound-repository-jpa/src/main/resources/persistence/orm.xml +++ b/outbound-repository-jpa/src/main/resources/persistence/orm.xml @@ -49,15 +49,18 @@ + + + - - - + + + diff --git a/springboot/src/main/java/org/windat/main/DateBeanConfiguration.java b/springboot/src/main/java/org/windat/main/DateBeanConfiguration.java new file mode 100644 index 0000000..5263127 --- /dev/null +++ b/springboot/src/main/java/org/windat/main/DateBeanConfiguration.java @@ -0,0 +1,15 @@ +package org.windat.main; + +import org.springframework.context.annotation.Bean; +import org.springframework.stereotype.Component; +import org.windat.ws.mapper.DateMapper; + +@Component +public class DateBeanConfiguration { + + @Bean + public DateMapper dateMapper() { + return new DateMapper() { + }; + } +} diff --git a/springboot/src/main/java/org/windat/main/UserBeanConfiguration.java b/springboot/src/main/java/org/windat/main/UserBeanConfiguration.java new file mode 100644 index 0000000..5df3ccd --- /dev/null +++ b/springboot/src/main/java/org/windat/main/UserBeanConfiguration.java @@ -0,0 +1,38 @@ +package org.windat.main; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.context.annotation.Bean; +import org.springframework.data.jpa.repository.support.JpaRepositoryFactory; +import org.springframework.stereotype.Component; +import org.windat.domain.repository.UserRepository; +import org.windat.domain.service.UserFacade; +import org.windat.domain.service.UserService; +import org.windat.jpa.adapter.JpaUserRepositoryAdapter; +import org.windat.jpa.repository.UserSpringDataRepository; + +/* +Manual creation and configuration of Users beans + */ +@Component +public class UserBeanConfiguration { + + @PersistenceContext + private EntityManager entityManager; + + @Bean + public UserFacade userFacade(UserRepository userRepository) { + return new UserService(userRepository); + } + + @Bean + public UserRepository userRepository(UserSpringDataRepository userSpringDataRepository) { + return new JpaUserRepositoryAdapter(userSpringDataRepository); + } + + @Bean + public UserSpringDataRepository userSpringDataRepository(){ + JpaRepositoryFactory jpaRepositoryFactory = new JpaRepositoryFactory(entityManager); + return jpaRepositoryFactory.getRepository(UserSpringDataRepository.class); + } +} From aad9eda47972512b8ccd8fc4ae612a9d4c0c1180 Mon Sep 17 00:00:00 2001 From: Mykyta Prykhodko Date: Sun, 25 May 2025 20:41:51 +0200 Subject: [PATCH 03/11] Added findOne() method for user. Return user from application database --- .../java/org/windat/domain/repository/UserRepository.java | 2 ++ .../src/main/java/org/windat/domain/service/UserFacade.java | 3 +++ .../src/main/java/org/windat/domain/service/UserService.java | 5 +++++ .../org/windat/jpa/adapter/JpaUserRepositoryAdapter.java | 5 +++++ .../org/windat/jpa/repository/UserSpringDataRepository.java | 2 +- 5 files changed, 16 insertions(+), 1 deletion(-) diff --git a/domain/src/main/java/org/windat/domain/repository/UserRepository.java b/domain/src/main/java/org/windat/domain/repository/UserRepository.java index 6ff603a..10d7161 100644 --- a/domain/src/main/java/org/windat/domain/repository/UserRepository.java +++ b/domain/src/main/java/org/windat/domain/repository/UserRepository.java @@ -15,4 +15,6 @@ public interface UserRepository { Optional readOne(UUID uuid); User update(User user); + + Optional readOne(Integer id); } diff --git a/domain/src/main/java/org/windat/domain/service/UserFacade.java b/domain/src/main/java/org/windat/domain/service/UserFacade.java index 0d3c71e..afd9263 100644 --- a/domain/src/main/java/org/windat/domain/service/UserFacade.java +++ b/domain/src/main/java/org/windat/domain/service/UserFacade.java @@ -15,4 +15,7 @@ public interface UserFacade { Optional readOne(UUID uuid); User update(User user); + +// Use this method to retrieve user from the application database + Optional readOne(Integer id); } diff --git a/domain/src/main/java/org/windat/domain/service/UserService.java b/domain/src/main/java/org/windat/domain/service/UserService.java index bb2e55d..dcd8097 100644 --- a/domain/src/main/java/org/windat/domain/service/UserService.java +++ b/domain/src/main/java/org/windat/domain/service/UserService.java @@ -38,4 +38,9 @@ public Optional readOne(UUID uuid) { public User update(User user) { return userRepository.update(user); } + + @Override + public Optional readOne(Integer id) { + return userRepository.readOne(id); + } } diff --git a/outbound-repository-jpa/src/main/java/org/windat/jpa/adapter/JpaUserRepositoryAdapter.java b/outbound-repository-jpa/src/main/java/org/windat/jpa/adapter/JpaUserRepositoryAdapter.java index a26d8ea..17186fb 100644 --- a/outbound-repository-jpa/src/main/java/org/windat/jpa/adapter/JpaUserRepositoryAdapter.java +++ b/outbound-repository-jpa/src/main/java/org/windat/jpa/adapter/JpaUserRepositoryAdapter.java @@ -36,4 +36,9 @@ public User update(User user) { return userSpringDataRepository.save(user); } + @Override + public Optional readOne(Integer id) { + return userSpringDataRepository.findById(id); + } + } diff --git a/outbound-repository-jpa/src/main/java/org/windat/jpa/repository/UserSpringDataRepository.java b/outbound-repository-jpa/src/main/java/org/windat/jpa/repository/UserSpringDataRepository.java index 9dad9e4..87fa375 100644 --- a/outbound-repository-jpa/src/main/java/org/windat/jpa/repository/UserSpringDataRepository.java +++ b/outbound-repository-jpa/src/main/java/org/windat/jpa/repository/UserSpringDataRepository.java @@ -6,6 +6,6 @@ import java.util.Optional; import java.util.UUID; -public interface UserSpringDataRepository extends JpaRepository { +public interface UserSpringDataRepository extends JpaRepository { Optional findByKeycloakId(UUID keycloakId); } From b540f9382a8d230912ec1af444413053686b80d8 Mon Sep 17 00:00:00 2001 From: Mykyta Prykhodko Date: Sun, 25 May 2025 23:51:37 +0200 Subject: [PATCH 04/11] Added creation of user Using Keycloak Admin API and creation of application user --- .gitignore | 3 +- .../main/resources/openapi/windatOpenApi.yaml | 59 +++++++++- inbound-controller-ws/pom.xml | 5 + .../ws/controller/UserRestController.java | 106 ++++++++++++++++++ .../GlobalExceptionHandler.java | 26 ++--- springboot/pom.xml | 5 + .../main/KeycloakAdminConfiguration.java | 34 ++++++ .../org/windat/main/WinDatApplication.java | 9 ++ .../src/main/resources/application.yaml | 10 +- 9 files changed, 241 insertions(+), 16 deletions(-) create mode 100644 inbound-controller-ws/src/main/java/org/windat/ws/controller/UserRestController.java create mode 100644 springboot/src/main/java/org/windat/main/KeycloakAdminConfiguration.java diff --git a/.gitignore b/.gitignore index bdb542a..69bf892 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,5 @@ terraform/resource-group/.terraform/ terraform/container-registry/.terraform/ terraform/kubernetes/.terraform terraform/public-ip/.terraform -terraform/psql/.terraform \ No newline at end of file +terraform/psql/.terraform +/.env.dev diff --git a/api-spec/src/main/resources/openapi/windatOpenApi.yaml b/api-spec/src/main/resources/openapi/windatOpenApi.yaml index 7b0b0e9..cb0ee81 100644 --- a/api-spec/src/main/resources/openapi/windatOpenApi.yaml +++ b/api-spec/src/main/resources/openapi/windatOpenApi.yaml @@ -64,6 +64,39 @@ components: properties: name: type: string + UserCreateRequest: + type: object + required: + - username + - firstName + - lastName + - email + - password + properties: +# That will be username on the page + username: + type: string + description: User's chosen username + example: ThatWillBeUsername + firstName: + type: string + description: User's first name + example: Test + lastName: + type: string + description: User's last name + example: Admin + email: + type: string + format: email + description: User's email address + example: ThatWillBeUsername@gmail.com + password: + type: string + format: password + description: User's chosen password + minLength: 8 + example: StrongPassword123! paths: /lobbies: @@ -150,4 +183,28 @@ paths: description: Lobby not found '409': description: User is already in the lobby - + /users: + post: + summary: Create user in keycloak and save it to the application database + operationId: createUser + tags: + - Users + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UserCreateRequest' + responses: + '201': + description: User created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '400': + description: Invalid request payload (missing required fields, invalid email format) + '409': + description: User with provided username or email already exists + '500': + description: Internal server error \ No newline at end of file diff --git a/inbound-controller-ws/pom.xml b/inbound-controller-ws/pom.xml index 5fa791a..77e6af8 100644 --- a/inbound-controller-ws/pom.xml +++ b/inbound-controller-ws/pom.xml @@ -63,6 +63,11 @@ 3.8.1 test + + org.keycloak + keycloak-admin-client + 23.0.0 + diff --git a/inbound-controller-ws/src/main/java/org/windat/ws/controller/UserRestController.java b/inbound-controller-ws/src/main/java/org/windat/ws/controller/UserRestController.java new file mode 100644 index 0000000..1793c16 --- /dev/null +++ b/inbound-controller-ws/src/main/java/org/windat/ws/controller/UserRestController.java @@ -0,0 +1,106 @@ +package org.windat.ws.controller; + +import jakarta.ws.rs.core.Response; +import org.keycloak.admin.client.Keycloak; +import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.admin.client.resource.UsersResource; +import org.keycloak.representations.idm.CredentialRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RestController; +import org.windat.domain.UserRole; +import org.windat.domain.entity.User; +import org.windat.domain.service.UserFacade; +import org.windat.rest.api.UsersApi; +import org.windat.rest.dto.UserCreateRequestDto; +import org.windat.rest.dto.UserDto; +import org.windat.ws.mapper.UserMapper; + + +import java.util.*; + +@RestController +public class UserRestController implements UsersApi { + + private final UserFacade userFacade; + private final UserMapper userMapper; + private final Keycloak keycloakAdminClient; + + public UserRestController( + UserFacade userFacade, + UserMapper userMapper, + Keycloak keycloakAdminClient + ) { + this.userFacade = userFacade; + this.userMapper = userMapper; + this.keycloakAdminClient = keycloakAdminClient; + } + + @Override + public ResponseEntity createUser(UserCreateRequestDto userCreateRequestDto) { +// Creating representtion of user for the Keycloak API + UserRepresentation userRepresentation = new UserRepresentation(); + + userRepresentation.setUsername(userCreateRequestDto.getUsername()); + userRepresentation.setEmail(userCreateRequestDto.getEmail()); + userRepresentation.setFirstName(userCreateRequestDto.getFirstName()); + userRepresentation.setLastName(userCreateRequestDto.getLastName()); + userRepresentation.setEmailVerified(false); + userRepresentation.setEnabled(true); + +// Adding credential + CredentialRepresentation credentialRepresentation = new CredentialRepresentation(); + + credentialRepresentation.setTemporary(false); + credentialRepresentation.setType(CredentialRepresentation.PASSWORD); + credentialRepresentation.setValue(userCreateRequestDto.getPassword()); + userRepresentation.setCredentials(Collections.singletonList(credentialRepresentation)); + +// Idk about attributes. For now I will leave it blank + userRepresentation.setAttributes(Collections.emptyMap()); + +// Call KeycloakAPI to create user + RealmResource realmsResource = keycloakAdminClient.realm("WinDat"); + UsersResource usersResource = realmsResource.users(); + +// I think error occures here + Response response = usersResource.create(userRepresentation); + if (response.getStatus() == 401 ){ + System.out.println("User creation failed"); + } +// Successful creation of user + if (response.getStatus() == 201) { + +// Get keycloak id of user + String path = response.getLocation().getPath(); + String keycloakUserId = path.substring(path.lastIndexOf('/') + 1); + UUID keycloakUuid = UUID.fromString(keycloakUserId); + +// Save user in application database + User user = new User(); + user.setKeycloakId(keycloakUuid); + user.setLoginName(userCreateRequestDto.getUsername()); + user.setUserRoleEnum(UserRole.USER_ROLE); + user.setLobby(null); + + User persistedUser = userFacade.create(user); + +// Return successful response + UserDto persistedUserDto = userMapper.toDto(persistedUser); + return ResponseEntity.status(HttpStatus.CREATED).body(persistedUserDto); + + } else if (response.getStatus() == 409) { +// 409 conflict + throw new IllegalArgumentException("User with this username or email already exists in Keycloak."); + } else { + String errorMessage = "Failed to create user in Keycloak. Status: " + response.getStatus(); + try { + errorMessage += ", Error: " + response.readEntity(String.class); + } catch (Exception e) { +// pass + } + throw new RuntimeException(errorMessage); + } + } +} diff --git a/inbound-controller-ws/src/main/java/org/windat/ws/exceptionHandlers/GlobalExceptionHandler.java b/inbound-controller-ws/src/main/java/org/windat/ws/exceptionHandlers/GlobalExceptionHandler.java index ab249bb..f27af5a 100644 --- a/inbound-controller-ws/src/main/java/org/windat/ws/exceptionHandlers/GlobalExceptionHandler.java +++ b/inbound-controller-ws/src/main/java/org/windat/ws/exceptionHandlers/GlobalExceptionHandler.java @@ -56,17 +56,17 @@ public ResponseEntity handleUserAlreadyInLobbyException(UserAlreadyInLob } // General error handler - @ExceptionHandler(Exception.class) - @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) - public ResponseEntity handleAllUncaughtException(Exception exception) { - Map body = new LinkedHashMap<>(); - body.put("timestamp", LocalDateTime.now()); - body.put("status", HttpStatus.INTERNAL_SERVER_ERROR.value()); - body.put("error", "Internal Server Error"); - -// Possible error message for client - body.put("message", "An unexpected error occurred. Please try again later."); - - return new ResponseEntity<>(body, HttpStatus.INTERNAL_SERVER_ERROR); - } +// @ExceptionHandler(Exception.class) +// @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) +// public ResponseEntity handleAllUncaughtException(Exception exception) { +// Map body = new LinkedHashMap<>(); +// body.put("timestamp", LocalDateTime.now()); +// body.put("status", HttpStatus.INTERNAL_SERVER_ERROR.value()); +// body.put("error", "Internal Server Error"); +// +//// Possible error message for client +// body.put("message", "An unexpected error occurred. Please try again later."); +// +// return new ResponseEntity<>(body, HttpStatus.INTERNAL_SERVER_ERROR); +// } } diff --git a/springboot/pom.xml b/springboot/pom.xml index 2ffe933..0d47bfa 100644 --- a/springboot/pom.xml +++ b/springboot/pom.xml @@ -50,6 +50,11 @@ org.springframework.security spring-security-oauth2-jose + + io.github.cdimascio + dotenv-java + 2.3.2 + diff --git a/springboot/src/main/java/org/windat/main/KeycloakAdminConfiguration.java b/springboot/src/main/java/org/windat/main/KeycloakAdminConfiguration.java new file mode 100644 index 0000000..89fa805 --- /dev/null +++ b/springboot/src/main/java/org/windat/main/KeycloakAdminConfiguration.java @@ -0,0 +1,34 @@ +package org.windat.main; + +import org.keycloak.admin.client.Keycloak; +import org.keycloak.admin.client.KeycloakBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class KeycloakAdminConfiguration { + // Admins credentials + @Value("${keycloak.auth-server-url}") + private String authServerUrl; + + @Value("${keycloak.realm}") + private String realm; + + @Value("${keycloak.admin-cli.client-id}") + private String adminClientId; + + @Value("${keycloak.admin-cli.client-secret}") + private String adminClientSecret; + + @Bean + public Keycloak keycloakAdminClient() { + return KeycloakBuilder.builder() + .serverUrl(authServerUrl) + .realm(realm) + .clientId(adminClientId) + .clientSecret(adminClientSecret) + .grantType("client_credentials") + .build(); + } +} diff --git a/springboot/src/main/java/org/windat/main/WinDatApplication.java b/springboot/src/main/java/org/windat/main/WinDatApplication.java index cbebb1a..dcbe7c2 100644 --- a/springboot/src/main/java/org/windat/main/WinDatApplication.java +++ b/springboot/src/main/java/org/windat/main/WinDatApplication.java @@ -1,5 +1,6 @@ package org.windat.main; +import io.github.cdimascio.dotenv.Dotenv; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.ComponentScan; @@ -12,6 +13,14 @@ public class WinDatApplication { public static void main(String[] args) { + Dotenv dotenv = Dotenv.configure() +// Root of the project + .directory(System.getProperty("user.dir")) +// Name of the env file + .filename(".env.dev") + .load(); + + dotenv.entries().forEach(entry -> System.setProperty(entry.getKey(), entry.getValue())); SpringApplication.run(WinDatApplication.class, args); } } diff --git a/springboot/src/main/resources/application.yaml b/springboot/src/main/resources/application.yaml index 73ec009..ad6af20 100644 --- a/springboot/src/main/resources/application.yaml +++ b/springboot/src/main/resources/application.yaml @@ -30,4 +30,12 @@ management: endpoints: web: exposure: - include: "*" \ No newline at end of file + include: "*" + +keycloak: +# Change this url in production + auth-server-url: http://localhost:8082 + realm: WinDat + admin-cli: + client-id: ${KEYCLOAK_ADMIN_CLIENT_ID} + client-secret: ${KEYCLOAK_ADMIN_CLIENT_SECRET} \ No newline at end of file From 56476dbc47084d2965820b3e9acb150b732b4218 Mon Sep 17 00:00:00 2001 From: Mykyta Prykhodko Date: Mon, 26 May 2025 20:43:27 +0200 Subject: [PATCH 05/11] Added functionality for user to leave lobby --- .../main/resources/openapi/windatOpenApi.yaml | 54 +++++++++++ .../java/org/windat/domain/entity/Lobby.java | 1 + .../java/org/windat/domain/entity/User.java | 4 + .../UserIsNotInAnyLobbyException.java | 11 +++ .../exceptions/UserNotFoundException.java | 11 +++ .../ws/controller/LobbyRestController.java | 97 ++++++++++++++++++- .../GlobalExceptionHandler.java | 28 +++++- .../org/windat/ws/security/JwtConverter.java | 44 +++++++++ 8 files changed, 244 insertions(+), 6 deletions(-) create mode 100644 domain/src/main/java/org/windat/domain/exceptions/UserIsNotInAnyLobbyException.java create mode 100644 domain/src/main/java/org/windat/domain/exceptions/UserNotFoundException.java diff --git a/api-spec/src/main/resources/openapi/windatOpenApi.yaml b/api-spec/src/main/resources/openapi/windatOpenApi.yaml index cb0ee81..52554c8 100644 --- a/api-spec/src/main/resources/openapi/windatOpenApi.yaml +++ b/api-spec/src/main/resources/openapi/windatOpenApi.yaml @@ -183,6 +183,60 @@ paths: description: Lobby not found '409': description: User is already in the lobby + /lobbies/me: + delete: + summary: Remove authenticated user from their current lobby + operationId: removeAuthenticatedUserFromLobby + tags: + - Lobbies + responses: + '200': + description: Authenticated user was successfully removed from their lobby. Returns the updated lobby object or an empty object if no lobby. + content: + application/json: + schema: + $ref: '#/components/schemas/Lobby' + '401': + description: Unauthorized. User not authenticated. + '404': + description: Authenticated user is not currently in any lobby. + '500': + description: Internal server error. + /lobbies/{lobbyId}/users/{userId}: + delete: + summary: Remove a specific user from lobby (Admin access required) + operationId: removeUserFromLobbyAsAdmin + tags: + - Lobbies + - Admin + parameters: + - name: lobbyId + in: path + required: true + schema: + type: integer + format: int32 + description: ID of the lobby from which to remove the user + - name: userId + in: path + required: true + schema: + type: integer + format: int32 + description: ID of the user to remove from the lobby + responses: + '200': + description: User was successfully removed from lobby + content: + application/json: + schema: + $ref: '#/components/schemas/Lobby' + '401': + description: Unauthorized — user not authenticated + '403': + description: Forbidden — authenticated user does not have admin privileges + '404': + description: Lobby or User not found in this lobby /users: post: summary: Create user in keycloak and save it to the application database diff --git a/domain/src/main/java/org/windat/domain/entity/Lobby.java b/domain/src/main/java/org/windat/domain/entity/Lobby.java index b4ff951..50f36a7 100644 --- a/domain/src/main/java/org/windat/domain/entity/Lobby.java +++ b/domain/src/main/java/org/windat/domain/entity/Lobby.java @@ -80,6 +80,7 @@ public void removeUser(User user){ throw new IllegalArgumentException("User cannot be null"); } userList.remove(user); + user.setLobby(null); } /** diff --git a/domain/src/main/java/org/windat/domain/entity/User.java b/domain/src/main/java/org/windat/domain/entity/User.java index 5589e3e..f971aba 100644 --- a/domain/src/main/java/org/windat/domain/entity/User.java +++ b/domain/src/main/java/org/windat/domain/entity/User.java @@ -116,4 +116,8 @@ public void setUserRoleEnum(UserRole userRoleEnum) { public void setLobby(Lobby lobby) { this.lobby = lobby; } + + public boolean hasAnyLobby(){ + return this.lobby != null; + } } \ No newline at end of file diff --git a/domain/src/main/java/org/windat/domain/exceptions/UserIsNotInAnyLobbyException.java b/domain/src/main/java/org/windat/domain/exceptions/UserIsNotInAnyLobbyException.java new file mode 100644 index 0000000..9e65b69 --- /dev/null +++ b/domain/src/main/java/org/windat/domain/exceptions/UserIsNotInAnyLobbyException.java @@ -0,0 +1,11 @@ +package org.windat.domain.exceptions; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.BAD_REQUEST) //400 +public class UserIsNotInAnyLobbyException extends RuntimeException { + public UserIsNotInAnyLobbyException(String message) { + super(message); + } +} diff --git a/domain/src/main/java/org/windat/domain/exceptions/UserNotFoundException.java b/domain/src/main/java/org/windat/domain/exceptions/UserNotFoundException.java new file mode 100644 index 0000000..5d1f1db --- /dev/null +++ b/domain/src/main/java/org/windat/domain/exceptions/UserNotFoundException.java @@ -0,0 +1,11 @@ +package org.windat.domain.exceptions; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.NOT_FOUND) // 404 +public class UserNotFoundException extends RuntimeException { + public UserNotFoundException(String message) { + super(message); + } +} diff --git a/inbound-controller-ws/src/main/java/org/windat/ws/controller/LobbyRestController.java b/inbound-controller-ws/src/main/java/org/windat/ws/controller/LobbyRestController.java index 99fc809..29ac4c4 100644 --- a/inbound-controller-ws/src/main/java/org/windat/ws/controller/LobbyRestController.java +++ b/inbound-controller-ws/src/main/java/org/windat/ws/controller/LobbyRestController.java @@ -2,13 +2,13 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.RestController; import org.windat.domain.UserRole; import org.windat.domain.entity.Lobby; import org.windat.domain.entity.User; -import org.windat.domain.exceptions.LobbyFullException; -import org.windat.domain.exceptions.LobbyNotFoundException; -import org.windat.domain.exceptions.UserAlreadyInLobbyException; +import org.windat.domain.exceptions.*; import org.windat.domain.service.LobbyFacade; import org.windat.domain.service.UserFacade; import org.windat.rest.api.LobbiesApi; @@ -132,4 +132,95 @@ public ResponseEntity> listLobbies() { // If it is not null, then there will be array with lobbies return ResponseEntity.ok(lobbyDtos); } + + /** + * Allows an authenticated user to remove themselves from their current lobby. + * The lobby ID is retrieved from the user's current association. + * + * @return ResponseEntity containing the updated LobbyDto (representing the lobby after removal) + * or an error status. + * @throws UserNotFoundException if the authenticated user is not found in the application database. + * @throws UserIsNotInAnyLobbyException if the authenticated user is not currently associated with any lobby. + */ + @Override + public ResponseEntity removeAuthenticatedUserFromLobby() { + +// Get keycloak user from jwt token + UserDto userDto = (UserDto) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + UUID keycloakId = userDto.getKeycloakId(); + +// Get application user from keycloakId + User applicationUser = userFacade.readOne(keycloakId).orElseThrow( + () -> new UserNotFoundException("User with " + keycloakId + " not found in the application database.") + ); + +// Check if user is in any lobby + if (!applicationUser.hasAnyLobby()) { + throw new UserIsNotInAnyLobbyException("User " + applicationUser.getLoginName() + " is not in any lobby."); + } + +// Get lobby form the user + Lobby lobby = applicationUser.getLobby(); + +// Remove this user from the lobby + lobby.removeUser(applicationUser); +// Persist updated lobby + lobbyFacade.update(lobby); + +// Explicitly set user lobby to null + applicationUser.setLobby(null); +// Persist updated user + userFacade.update(applicationUser); + + return ResponseEntity.ok(lobbyMapper.toDto(lobby)); + } + + /** + * Allows an administrator to remove a specific user from a specific lobby. + * This operation requires admin privileges. + * + * @param lobbyId The ID of the lobby from which to remove the user. + * @param userId Application user id. + * + * @return ResponseEntity containing the updated LobbyDto or an error status. + * @throws LobbyNotFoundException if the specified lobby does not exist. + * @throws UserNotFoundException if the user to be removed is not found in the application database. + * @throws UserIsNotInAnyLobbyException if the specified user is not in the specified lobby. + * * @throws org.springframework.security.access.AccessDeniedException if the authenticated user does not have ADMIN_ROLE. + */ + @Override + public ResponseEntity removeUserFromLobbyAsAdmin(Integer lobbyId, Integer userId) { + + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || authentication.getAuthorities().stream() + .noneMatch(a -> a.getAuthority().equals("ROLE_ADMIN_ROLE"))) { + throw new org.springframework.security.access.AccessDeniedException("Access Denied: Only ADMIN_ROLE users can perform this operation."); + } + +// Get the lobby + Lobby lobby = lobbyFacade.readOne(lobbyId) + .orElseThrow(() -> new LobbyNotFoundException("Lobby with ID " + lobbyId + " not found.")); + +// Get user + User userToRemove = userFacade.readOne(userId) + .orElseThrow(() -> new UserNotFoundException("User with Keycloak ID '" + userId + "' not found in the application database.")); + + +// Check if users is in this specific lobby + if (!userToRemove.hasAnyLobby() || !(userToRemove.getLobby().getId() == lobby.getId())) { + throw new UserIsNotInAnyLobbyException("User '" + userToRemove.getLoginName() + "' (ID: " + userToRemove.getKeycloakId() + ") is not in lobby with ID " + lobbyId + "."); + } + +// Remove user from the lobby + lobby.removeUser(userToRemove); +// Persist changes + lobbyFacade.update(lobby); + +// Explicitly set user lobby to null + userToRemove.setLobby(null); +// Persist changes + userFacade.update(userToRemove); + + return ResponseEntity.ok(lobbyMapper.toDto(lobby)); + } } diff --git a/inbound-controller-ws/src/main/java/org/windat/ws/exceptionHandlers/GlobalExceptionHandler.java b/inbound-controller-ws/src/main/java/org/windat/ws/exceptionHandlers/GlobalExceptionHandler.java index f27af5a..2b982ba 100644 --- a/inbound-controller-ws/src/main/java/org/windat/ws/exceptionHandlers/GlobalExceptionHandler.java +++ b/inbound-controller-ws/src/main/java/org/windat/ws/exceptionHandlers/GlobalExceptionHandler.java @@ -5,11 +5,9 @@ import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; -import org.windat.domain.exceptions.LobbyNotFoundException; +import org.windat.domain.exceptions.*; -import org.windat.domain.exceptions.LobbyFullException; import org.windat.domain.exceptions.LobbyNotFoundException; -import org.windat.domain.exceptions.UserAlreadyInLobbyException; import java.time.LocalDateTime; import java.util.LinkedHashMap; @@ -55,6 +53,30 @@ public ResponseEntity handleUserAlreadyInLobbyException(UserAlreadyInLob return new ResponseEntity<>(body, HttpStatus.CONFLICT); } + @ExceptionHandler(UserNotFoundException.class) + @ResponseStatus(HttpStatus.NOT_FOUND) // 404 + public ResponseEntity handleUserNotFoundException(UserNotFoundException exception) { + Map body = new LinkedHashMap<>(); + body.put("timestamp", LocalDateTime.now()); + body.put("status", HttpStatus.NOT_FOUND.value()); + body.put("error", HttpStatus.NOT_FOUND.getReasonPhrase()); + body.put("message", exception.getMessage()); + + return new ResponseEntity<>(body, HttpStatus.NOT_FOUND); + } + + @ExceptionHandler(UserIsNotInAnyLobbyException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) // 404 + public ResponseEntity handleUserNotInAnyLobbyException(UserIsNotInAnyLobbyException exception) { + Map body = new LinkedHashMap<>(); + body.put("timestamp", LocalDateTime.now()); + body.put("status", HttpStatus.BAD_REQUEST.value()); + body.put("error", HttpStatus.BAD_REQUEST.getReasonPhrase()); + body.put("message", exception.getMessage()); + + return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST); + } + // General error handler // @ExceptionHandler(Exception.class) // @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) diff --git a/inbound-controller-ws/src/main/java/org/windat/ws/security/JwtConverter.java b/inbound-controller-ws/src/main/java/org/windat/ws/security/JwtConverter.java index 1c8ef5b..71584ff 100644 --- a/inbound-controller-ws/src/main/java/org/windat/ws/security/JwtConverter.java +++ b/inbound-controller-ws/src/main/java/org/windat/ws/security/JwtConverter.java @@ -1,6 +1,8 @@ package org.windat.ws.security; import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.oauth2.jwt.Jwt; import org.windat.rest.dto.UserDto; import org.windat.rest.dto.UserRoleDto; @@ -35,6 +37,48 @@ public Object getPrincipal() { return userDto; } + @Override + public Collection getAuthorities() { + Collection authorities = new HashSet<>(); // Використовуємо HashSet для уникнення дублікатів + +// Get real roles from jwt + if (source.hasClaim("realm_access")) { + Map realmAccess = source.getClaimAsMap("realm_access"); + if (realmAccess != null && realmAccess.containsKey("roles")) { + Object rolesObject = realmAccess.get("roles"); + if (rolesObject instanceof List) { + ((List) rolesObject).stream() + .filter(String.class::isInstance) + .map(String.class::cast) +// Add prefix role + .map(roleName -> new SimpleGrantedAuthority("ROLE_" + roleName)) + .forEach(authorities::add); + } + } + } + +// Get client roles from token + if (source.hasClaim("resource_access")) { + Map resourceAccess = source.getClaimAsMap("resource_access"); +// Here it is hardcoded for the fsa-client client + if (resourceAccess != null && resourceAccess.containsKey("fsa-client")) { + Map accountClient = (Map) resourceAccess.get("account"); + if (accountClient.containsKey("roles")) { + Object rolesObject = accountClient.get("roles"); + if (rolesObject instanceof List) { + ((List) rolesObject).stream() + .filter(String.class::isInstance) + .map(String.class::cast) + .map(roleName -> new SimpleGrantedAuthority("ROLE_" + roleName)) + .forEach(authorities::add); + } + } + } + } + + return authorities; + } + private UserRoleDto getRole() { Map realmAccess = source.getClaimAsMap("realm_access"); if (realmAccess == null || realmAccess.get("roles") == null) return null; From 85f42887c942fbe6cb0dd23d6ea144de8515ca8c Mon Sep 17 00:00:00 2001 From: Mykyta Prykhodko Date: Tue, 27 May 2025 21:54:36 +0200 Subject: [PATCH 06/11] Added secrets for deployment. Added credit as currency on the platform. Added functionality to get CreditsTransaction. Implemented Transaction History and more --- .../main/resources/openapi/windatOpenApi.yaml | 171 +++++++++++++- .../domain/entity/CreditTransaction.java | 211 ++++++++++++++++++ .../java/org/windat/domain/entity/User.java | 65 +++++- .../windat/domain/enums/TransactionType.java | 11 + .../windat/domain/{ => enums}/UserRole.java | 2 +- .../InsufficientCreditsException.java | 31 +++ .../domain/repository/CreditRepository.java | 19 ++ .../windat/domain/service/CreditFacade.java | 23 ++ .../windat/domain/service/CreditService.java | 104 +++++++++ .../ws/controller/LobbyRestController.java | 3 +- .../controller/TransactionRestController.java | 99 ++++++++ .../ws/controller/UserRestController.java | 27 ++- .../ws/mapper/CreditTransactionMapper.java | 39 ++++ .../java/org/windat/ws/mapper/DateMapper.java | 4 + .../org/windat/ws/mapper/LobbyMapper.java | 5 - .../java/org/windat/ws/mapper/UserMapper.java | 3 +- k8s/02-secrets.yaml | 15 ++ k8s/app-be/05-deployment.yaml | 10 + .../adapter/JpaCreditRepositoryAdapter.java | 44 ++++ .../CreditSpringDataRepository.java | 14 ++ .../src/main/resources/persistence/orm.xml | 43 +++- .../windat/main/CreditBeanConfiguration.java | 40 ++++ 22 files changed, 967 insertions(+), 16 deletions(-) create mode 100644 domain/src/main/java/org/windat/domain/entity/CreditTransaction.java create mode 100644 domain/src/main/java/org/windat/domain/enums/TransactionType.java rename domain/src/main/java/org/windat/domain/{ => enums}/UserRole.java (63%) create mode 100644 domain/src/main/java/org/windat/domain/exceptions/InsufficientCreditsException.java create mode 100644 domain/src/main/java/org/windat/domain/repository/CreditRepository.java create mode 100644 domain/src/main/java/org/windat/domain/service/CreditFacade.java create mode 100644 domain/src/main/java/org/windat/domain/service/CreditService.java create mode 100644 inbound-controller-ws/src/main/java/org/windat/ws/controller/TransactionRestController.java create mode 100644 inbound-controller-ws/src/main/java/org/windat/ws/mapper/CreditTransactionMapper.java create mode 100644 outbound-repository-jpa/src/main/java/org/windat/jpa/adapter/JpaCreditRepositoryAdapter.java create mode 100644 outbound-repository-jpa/src/main/java/org/windat/jpa/repository/CreditSpringDataRepository.java create mode 100644 springboot/src/main/java/org/windat/main/CreditBeanConfiguration.java diff --git a/api-spec/src/main/resources/openapi/windatOpenApi.yaml b/api-spec/src/main/resources/openapi/windatOpenApi.yaml index 52554c8..ad6f7fa 100644 --- a/api-spec/src/main/resources/openapi/windatOpenApi.yaml +++ b/api-spec/src/main/resources/openapi/windatOpenApi.yaml @@ -54,6 +54,10 @@ components: type: integer format: int32 description: ID of the lobby the user is currently associated with. + credits: + type: integer + format: int32 + description: Current credit balance of the user. UserRole: type: string enum: @@ -97,6 +101,93 @@ components: description: User's chosen password minLength: 8 example: StrongPassword123! + CreditTransferRequest: + type: object + required: + - receiverId + - amount + properties: + receiverId: + type: integer + format: int32 + description: The ID of the user who will receive the credits. + amount: + type: integer + format: int32 + minimum: 1 + description: The amount of credits to transfer. + description: + type: string + description: Optional description for the transfer. + nullable: true +# This dto will be returned from the /transaction endpoint + CreditTransaction: + type: object + properties: + id: + type: integer + format: int32 + description: Unique identifier for the transaction. + userId: + type: integer + format: int32 + description: Id of user who initiated transaction + amount: + type: integer + format: int32 + description: Amount of credits transacted (positive for gain, negative for loss). + transactionTime: + type: string + format: date-time + description: Timestamp of the transaction. + description: + type: string + description: Description of the transaction (e.g., "Win from match"). + relatedUserId: + type: integer + format: int32 + nullable: true + type: + $ref: '#/components/schemas/TransactionType' +# Enum for the transaction types + TransactionType: + type: string + enum: + - MATCH_WIN + - MATCH_LOSS + - INITIAL_BONUS + - ADMIN_ADJUSTMENT + - MONTHLY_BONUS + - USER_TRANSFER_IN + - USER_TRANSFER_OUT +# Dto to create a transaction for post /transactions endpoint + CreditTransactionRequest: + type: object + required: + - userId + - amount + - type + properties: + userId: + type: integer + format: int32 + description: The ID of the primary user for this transaction. + amount: + type: integer + format: int32 + description: The amount of credits for this transaction (positive or negative). + description: + type: string + description: Optional description of the transaction. + nullable: true + relatedUserId: + type: integer + format: int32 + description: The ID of the related user, if applicable. + nullable: true + type: + $ref: '#/components/schemas/TransactionType' + paths: /lobbies: @@ -261,4 +352,82 @@ paths: '409': description: User with provided username or email already exists '500': - description: Internal server error \ No newline at end of file + description: Internal server error + /users/me/transfer: + post: + summary: Transfer credits from one user to another. + operationId: transferCredits + tags: + - Users + - Credits + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreditTransferRequest' + responses: + '200': + description: Credits successfully transferred. Returns the updated sender's user object. + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '400': + description: Invalid request (e.g., negative amount, senderId equals receiverId). + '401': + description: Unauthorized - user not authenticated. + '403': + description: Forbidden - authenticated user is not the sender or does not have sufficient privileges. + '404': + description: Sender or Receiver user not found. + '409': + description: Insufficient credits for the transfer. + /transactions: + get: + summary: Get user's credit transaction history. + operationId: getCreditTransactions + tags: + - Credits + parameters: + - name: userId + in: query + schema: + type: integer + format: int32 + description: Optional user ID to filter transactions. If not provided, returns transactions for authenticated user. + responses: + '200': + description: List of credit transactions. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/CreditTransaction' + post: + summary: Record a credit transaction (e.g., admin adjustment, match win/loss). + description: This endpoint is primarily for system-initiated transactions (like match outcomes or admin adjustments). Direct user-to-user transfers should use /users/{senderId}/transfer. + operationId: createCreditTransaction + tags: + - Credits + - Admin + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreditTransactionRequest' + responses: + '201': + description: Transaction recorded successfully. + content: + application/json: + schema: + $ref: '#/components/schemas/CreditTransaction' + '400': + description: Invalid request. + '401': + description: Unauthorized. + '403': + description: Forbidden (if admin-only). \ No newline at end of file diff --git a/domain/src/main/java/org/windat/domain/entity/CreditTransaction.java b/domain/src/main/java/org/windat/domain/entity/CreditTransaction.java new file mode 100644 index 0000000..393d837 --- /dev/null +++ b/domain/src/main/java/org/windat/domain/entity/CreditTransaction.java @@ -0,0 +1,211 @@ +package org.windat.domain.entity; + +import org.windat.domain.enums.TransactionType; + +import java.time.LocalDateTime; +import java.util.Objects; + +/** + * Represents a single credit transaction within the WinDat platform. + * This entity records the details of any credit change for a user, + * providing a comprehensive history of how credits are gained or lost. + */ +public class CreditTransaction { + + /** + * Unique identifier for the credit transaction. + * Serves as the primary key in the database. + */ + private Integer id; + + /** + * The {@link User} entity who is the primary participant (receiver or giver) + * of this credit transaction. + */ + private User user; + + /** + * The amount of credits involved in this transaction. + * Can be positive (for gains) or negative (for losses). + */ + private Integer amount; + + /** + * The date and time when this transaction occurred. + */ + private LocalDateTime transactionTime; + + /** + * A descriptive text explaining the nature of the transaction. + * Examples: "Win from match", "Loss in match", "Starting bonus", "Monthly bonus", "Admin adjustment". + */ + private String description; + + /** + * A reference to another {@link User} entity involved in the transaction, if applicable. + * For example, in a match, this could be the opponent. Can be {@code null}. + */ + private User relatedUser; + + /** + * The specific type of the transaction, represented by a {@link TransactionType} enumeration. + * Defines the category of the credit change (e.g., MATCH_WIN, MATCH_LOSS, INITIAL_BONUS). + */ + private TransactionType type; + + /** + * Default constructor for Hibernate (JPA) to map entities. + * Initializes the {@code transactionTime} to the current {@link LocalDateTime}. + */ + public CreditTransaction() { + this.transactionTime = LocalDateTime.now(); + } + + /** + * Constructs a new CreditTransaction with specified details. + * + * @param user The primary user involved in the transaction (receiver/giver). Must not be null. + * @param amount The amount of credits for this transaction. Can be positive or negative. Must not be null. + * @param description A descriptive text for the transaction. + * @param relatedUser The secondary user involved in the transaction (e.g., opponent). Can be null. + * @param type The type of the transaction. Must not be null. + * @throws NullPointerException if user, amount, or type is null. + * @throws IllegalArgumentException if description is null or empty. + */ + public CreditTransaction(User user, Integer amount, String description, User relatedUser, TransactionType type) { + this.user = Objects.requireNonNull(user, "User cannot be null."); + this.amount = Objects.requireNonNull(amount, "Amount cannot be null."); +// Description can be null + this.description = description; +// relatedUser can be null + this.relatedUser = relatedUser; + this.type = Objects.requireNonNull(type, "Transaction type cannot be null."); +// Set current transactionTime + this.transactionTime = LocalDateTime.now(); + } + + /** + * Retrieves the unique identifier of the credit transaction. + * + * @return The ID of the transaction. + */ + public Integer getId() { + return id; + } + + /** + * Retrieves the primary user involved in this transaction. + * + * @return The {@link User} entity associated with this transaction. + */ + public User getUser() { + return user; + } + + /** + * Retrieves the amount of credits involved in this transaction. + * + * @return The credit amount (positive for gains, negative for losses). + */ + public Integer getAmount() { + return amount; + } + + /** + * Retrieves the date and time when this transaction occurred. + * + * @return The {@link LocalDateTime} of the transaction. + */ + public LocalDateTime getTransactionTime() { + return transactionTime; + } + + /** + * Retrieves the description of the transaction. + * + * @return A string describing the transaction. + */ + public String getDescription() { + return description; + } + + /** + * Retrieves the secondary user involved in the transaction, if any. + * + * @return The {@link User} entity who is the related participant, or {@code null}. + */ + public User getRelatedUser() { + return relatedUser; + } + + /** + * Retrieves the type of this credit transaction. + * + * @return The {@link TransactionType} of the transaction. + */ + public TransactionType getType() { + return type; + } + + /** + * Sets the primary user for this transaction. + * + * @param user The {@link User} entity to set. Must not be null. + * @throws NullPointerException if the provided user is null. + */ + public void setUser(User user) { + this.user = Objects.requireNonNull(user, "User cannot be null."); + } + + /** + * Sets the amount of credits for this transaction. + * + * @param amount The credit amount to set. Must not be null. + * @throws NullPointerException if the provided amount is null. + */ + public void setAmount(Integer amount) { + this.amount = Objects.requireNonNull(amount, "Amount cannot be null."); + } + + /** + * Sets the date and time for this transaction. + * + * @param transactionTime The {@link LocalDateTime} to set. Must not be null. + * @throws NullPointerException if the provided transactionTime is null. + */ + public void setTransactionTime(LocalDateTime transactionTime) { + this.transactionTime = Objects.requireNonNull(transactionTime, "Transaction time cannot be null."); + } + + /** + * Sets the description for this transaction. + * + * @param description The description string to set. Must not be null or empty. + * @throws IllegalArgumentException if the provided description is null or empty. + */ + public void setDescription(String description) { + if (description == null || description.trim().isEmpty()) { + throw new IllegalArgumentException("Description cannot be null or empty."); + } + this.description = description; + } + + /** + * Sets the secondary user involved in the transaction. + * + * @param relatedUser The {@link User} entity to set as the related participant. Can be {@code null}. + */ + public void setRelatedUser(User relatedUser) { + this.relatedUser = relatedUser; + } + + /** + * Sets the type of this credit transaction. + * + * @param type The {@link TransactionType} to set. Must not be null. + * @throws NullPointerException if the provided type is null. + */ + public void setType(TransactionType type) { + this.type = Objects.requireNonNull(type, "Transaction type cannot be null."); + } +} \ No newline at end of file diff --git a/domain/src/main/java/org/windat/domain/entity/User.java b/domain/src/main/java/org/windat/domain/entity/User.java index f971aba..4a57027 100644 --- a/domain/src/main/java/org/windat/domain/entity/User.java +++ b/domain/src/main/java/org/windat/domain/entity/User.java @@ -1,6 +1,7 @@ package org.windat.domain.entity; -import org.windat.domain.UserRole; +import org.windat.domain.enums.UserRole; +import org.windat.domain.exceptions.InsufficientCreditsException; import java.util.UUID; @@ -41,12 +42,19 @@ public class User { */ private Lobby lobby; + /** + * Represents user balance in the WinDat platform + */ + private Integer credits; + /** * Default constructor for Hibernate to map entities. + * Initializes a new User instance, setting the initial credit balance to 10,000. * Required for JPA entity instantiation. */ public User() { + this.credits = 10000; } /** @@ -120,4 +128,59 @@ public void setLobby(Lobby lobby) { public boolean hasAnyLobby(){ return this.lobby != null; } + + /** + * Retrieves the current credit balance of the user. + * + * @return The total number of credits the user possesses. + */ + public Integer getCredits() { + return credits; + } + + /** + * Sets the credit balance for the user. + * This method should be used cautiously, typically for initial setup or administrative adjustments. + * For adding/deducting credits, use {@link #addCredits(Integer)} and {@link #deductCredits(Integer)}. + * + * @param credits The new credit balance to set. Must not be null and must be non-negative. + * @throws IllegalArgumentException if the provided credits value is null or negative. + */ + public void setCredits(Integer credits) { + if (credits == null || credits < 0) { + throw new IllegalArgumentException("Credits cannot be null or negative."); + } + this.credits = credits; + } + + /** + * Adds a specified amount of credits to the user's current balance. + * + * @param amount The amount of credits to add. Must be a positive integer. + * @throws IllegalArgumentException if the provided amount is negative. + */ + public void addCredits(Integer amount) { + if (amount < 0) { + throw new IllegalArgumentException("Amount to add must be positive."); + } + this.credits += amount; + } + + + /** + * Deducts a specified amount of credits from the user's current balance. + * + * @param amount The amount of credits to deduct. Must be a positive integer. + * @throws IllegalArgumentException if the provided amount is negative or null. + * @throws InsufficientCreditsException if the user's current credit balance is less than the amount to deduct. + */ + public void deductCredits(Integer amount) { + if (amount < 0) { + throw new IllegalArgumentException("Amount to deduct must be positive."); + } + if (this.credits < amount) { + throw new InsufficientCreditsException("User has insufficient credits."); + } + this.credits -= amount; + } } \ No newline at end of file diff --git a/domain/src/main/java/org/windat/domain/enums/TransactionType.java b/domain/src/main/java/org/windat/domain/enums/TransactionType.java new file mode 100644 index 0000000..17ef1b4 --- /dev/null +++ b/domain/src/main/java/org/windat/domain/enums/TransactionType.java @@ -0,0 +1,11 @@ +package org.windat.domain.enums; + +public enum TransactionType { + MATCH_WIN, + MATCH_LOSS, + INITIAL_BONUS, + ADMIN_ADJUSTMENT, + MONTHLY_BONUS, + USER_TRANSFER_IN, + USER_TRANSFER_OUT +} diff --git a/domain/src/main/java/org/windat/domain/UserRole.java b/domain/src/main/java/org/windat/domain/enums/UserRole.java similarity index 63% rename from domain/src/main/java/org/windat/domain/UserRole.java rename to domain/src/main/java/org/windat/domain/enums/UserRole.java index a08b1c7..1d01975 100644 --- a/domain/src/main/java/org/windat/domain/UserRole.java +++ b/domain/src/main/java/org/windat/domain/enums/UserRole.java @@ -1,4 +1,4 @@ -package org.windat.domain; +package org.windat.domain.enums; public enum UserRole { USER_ROLE, diff --git a/domain/src/main/java/org/windat/domain/exceptions/InsufficientCreditsException.java b/domain/src/main/java/org/windat/domain/exceptions/InsufficientCreditsException.java new file mode 100644 index 0000000..f7efb8e --- /dev/null +++ b/domain/src/main/java/org/windat/domain/exceptions/InsufficientCreditsException.java @@ -0,0 +1,31 @@ +package org.windat.domain.exceptions; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +/** + * Exception thrown when an operation requires a certain amount of credits, + * but the user's current balance is insufficient. + */ +@ResponseStatus(HttpStatus.BAD_REQUEST) // Повертає 400 Bad Request, якщо виняток не перехоплено явно +public class InsufficientCreditsException extends RuntimeException { + + /** + * Constructs a new InsufficientCreditsException with the specified detail message. + * + * @param message the detail message (which is saved for later retrieval by the {@link #getMessage()} method). + */ + public InsufficientCreditsException(String message) { + super(message); + } + + /** + * Constructs a new InsufficientCreditsException with the specified detail message and cause. + * + * @param message the detail message. + * @param cause the cause (which is saved for later retrieval by the {@link #getCause()} method). + */ + public InsufficientCreditsException(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/domain/src/main/java/org/windat/domain/repository/CreditRepository.java b/domain/src/main/java/org/windat/domain/repository/CreditRepository.java new file mode 100644 index 0000000..08d39f0 --- /dev/null +++ b/domain/src/main/java/org/windat/domain/repository/CreditRepository.java @@ -0,0 +1,19 @@ +package org.windat.domain.repository; + +import org.windat.domain.entity.CreditTransaction; + +import java.util.Collection; +import java.util.Optional; + +public interface CreditRepository { + + Collection readAll(); + + CreditTransaction create(CreditTransaction creditTransaction); + + Optional readOne(Integer id); + + CreditTransaction update(CreditTransaction creditTransaction); + + Collection readByUserId(Integer userId); +} diff --git a/domain/src/main/java/org/windat/domain/service/CreditFacade.java b/domain/src/main/java/org/windat/domain/service/CreditFacade.java new file mode 100644 index 0000000..b12f902 --- /dev/null +++ b/domain/src/main/java/org/windat/domain/service/CreditFacade.java @@ -0,0 +1,23 @@ +package org.windat.domain.service; + +import org.windat.domain.entity.CreditTransaction; +import org.windat.domain.entity.User; + +import java.util.Collection; +import java.util.Optional; +import java.util.UUID; + +public interface CreditFacade { + + User transferCredits(UUID senderId, Integer receiverId, Integer amount, String description); + + Collection readAll(); + + CreditTransaction create(CreditTransaction creditTransaction); + + Optional readOne(Integer id); + + CreditTransaction update(CreditTransaction creditTransaction); + + Collection readAllTransactionsForSpecificUser(Integer userId); +} diff --git a/domain/src/main/java/org/windat/domain/service/CreditService.java b/domain/src/main/java/org/windat/domain/service/CreditService.java new file mode 100644 index 0000000..73471b0 --- /dev/null +++ b/domain/src/main/java/org/windat/domain/service/CreditService.java @@ -0,0 +1,104 @@ +package org.windat.domain.service; + +import jakarta.transaction.Transactional; +import org.windat.domain.entity.CreditTransaction; +import org.windat.domain.entity.User; +import org.windat.domain.enums.TransactionType; +import org.windat.domain.exceptions.UserNotFoundException; +import org.windat.domain.repository.CreditRepository; +import org.windat.domain.repository.UserRepository; + +import java.util.*; + +public class CreditService implements CreditFacade { + + private final UserRepository userRepository; + private final CreditRepository creditRepository; + + public CreditService(UserRepository userRepository, CreditRepository creditRepository) { + this.userRepository = userRepository; + this.creditRepository = creditRepository; + } + + @Override + @Transactional + public User transferCredits(UUID senderId, Integer receiverId, Integer amount, String description) { + Objects.requireNonNull(senderId, "Sender Keycloak ID cannot be null."); + Objects.requireNonNull(receiverId, "Receiver ID cannot be null."); + Objects.requireNonNull(amount, "Amount cannot be null."); + + if (amount <= 0) { + throw new IllegalArgumentException("Transfer amount must be positive."); + } + + + User sender = userRepository.readOne(senderId) + .orElseThrow(() -> new UserNotFoundException("Sender user not found with Keycloak ID: " + senderId)); + +// Get receiver using application id, not keycloakId + User receiver = userRepository.readOne(receiverId) + .orElseThrow(() -> new UserNotFoundException("Receiver user not found with ID: " + receiverId)); + + if (sender.getId() == receiver.getId()) { + throw new IllegalArgumentException("Cannot transfer credits to self."); + } + + sender.deductCredits(amount); + receiver.addCredits(amount); + + userRepository.update(sender); + userRepository.update(receiver); + + CreditTransaction senderTransaction = new CreditTransaction( + sender, +// Negative value to be written off + -amount, + description != null ? description : "Transfer to " + receiver.getLoginName(), + receiver, + TransactionType.USER_TRANSFER_OUT + ); + + creditRepository.create(senderTransaction); + + CreditTransaction receiverTx = new CreditTransaction( + receiver, +// Positive value for accrual + amount, + description != null ? description : "Transfer from " + sender.getLoginName(), + sender, + TransactionType.USER_TRANSFER_IN + ); + + creditRepository.create(receiverTx); + +// Return updated sender + return sender; + } + + @Override + public Collection readAll() { + return creditRepository.readAll(); + } + + @Override + @Transactional + public CreditTransaction create(CreditTransaction creditTransaction) { + return creditRepository.create(creditTransaction); + } + + @Override + public Optional readOne(Integer id) { + return creditRepository.readOne(id); + } + + @Override + @Transactional + public CreditTransaction update(CreditTransaction creditTransaction) { + return creditRepository.update(creditTransaction); + } + + @Override + public Collection readAllTransactionsForSpecificUser(Integer userId) { + return creditRepository.readByUserId(userId); + } +} diff --git a/inbound-controller-ws/src/main/java/org/windat/ws/controller/LobbyRestController.java b/inbound-controller-ws/src/main/java/org/windat/ws/controller/LobbyRestController.java index 29ac4c4..f22c2b1 100644 --- a/inbound-controller-ws/src/main/java/org/windat/ws/controller/LobbyRestController.java +++ b/inbound-controller-ws/src/main/java/org/windat/ws/controller/LobbyRestController.java @@ -2,10 +2,9 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.RestController; -import org.windat.domain.UserRole; +import org.windat.domain.enums.UserRole; import org.windat.domain.entity.Lobby; import org.windat.domain.entity.User; import org.windat.domain.exceptions.*; diff --git a/inbound-controller-ws/src/main/java/org/windat/ws/controller/TransactionRestController.java b/inbound-controller-ws/src/main/java/org/windat/ws/controller/TransactionRestController.java new file mode 100644 index 0000000..d83e6ca --- /dev/null +++ b/inbound-controller-ws/src/main/java/org/windat/ws/controller/TransactionRestController.java @@ -0,0 +1,99 @@ +package org.windat.ws.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.RestController; +import org.windat.domain.entity.CreditTransaction; +import org.windat.domain.entity.User; +import org.windat.domain.service.CreditFacade; +import org.windat.domain.service.UserFacade; +import org.windat.rest.api.TransactionsApi; +import org.windat.rest.dto.CreditTransactionDto; +import org.windat.rest.dto.CreditTransactionRequestDto; +import org.windat.rest.dto.UserDto; +import org.windat.ws.mapper.CreditTransactionMapper; + +import java.util.Collection; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +@RestController +public class TransactionRestController implements TransactionsApi { + + private final CreditFacade creditFacade; + private final UserFacade userFacade; + private final CreditTransactionMapper creditTransactionMapper; + + public TransactionRestController( + CreditFacade creditFacade, + UserFacade userFacade, + CreditTransactionMapper creditTransactionMapper + ) { + this.creditFacade = creditFacade; + this.userFacade = userFacade; + this.creditTransactionMapper = creditTransactionMapper; + } + +// This method is not implemented yet + @Override + public ResponseEntity createCreditTransaction(CreditTransactionRequestDto creditTransactionRequestDto) { + return ResponseEntity.badRequest().build(); + } + +// You need admin right for this endpoint to see all transactions +// Or you can see your own transactions + @Override + public ResponseEntity> getCreditTransactions(Integer userId) { + +// Get authentication details + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + UserDto principalUserDto = (UserDto) authentication.getPrincipal(); + UUID authenticatedUserKeycloakId = principalUserDto.getKeycloakId(); + + User applicationUser = userFacade.readOne(authenticatedUserKeycloakId).orElseThrow( + () -> new IllegalStateException("Authenticated user not found in application database.") + ); + + Integer authenticatedUserId = applicationUser.getId(); // Assuming UserDto has getId() + + + boolean isAdmin = authentication.getAuthorities().stream() + .anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN_ROLE")); + + Collection transactions; + + if (userId != null) { + // User explicitly requested transactions for a specific userId + if (userId.equals(authenticatedUserId)) { + // User requesting their own transactions (userId matches authenticated user's ID) + transactions = creditFacade.readAllTransactionsForSpecificUser(userId); + } else { + // User requesting another user's transactions. Check for admin rights. + if (isAdmin) { + transactions = creditFacade.readAllTransactionsForSpecificUser(userId); + } else { + // Not admin and requesting someone else's transactions + return ResponseEntity.status(403).build(); // Forbidden + } + } + } else { + // userId is null: + // If admin, return ALL transactions. + // If not admin, return transactions for the currently authenticated user. + if (isAdmin) { + transactions = creditFacade.readAll(); // Admin gets ALL transactions + } else { + transactions = creditFacade.readAllTransactionsForSpecificUser(applicationUser.getId()); + } + } + + // Convert domain entities to DTOs + List transactionDtos = transactions.stream() + .map(creditTransactionMapper::toDto) + .collect(Collectors.toList()); + + return ResponseEntity.ok(transactionDtos); + } +} diff --git a/inbound-controller-ws/src/main/java/org/windat/ws/controller/UserRestController.java b/inbound-controller-ws/src/main/java/org/windat/ws/controller/UserRestController.java index 1793c16..f9feb10 100644 --- a/inbound-controller-ws/src/main/java/org/windat/ws/controller/UserRestController.java +++ b/inbound-controller-ws/src/main/java/org/windat/ws/controller/UserRestController.java @@ -8,11 +8,14 @@ import org.keycloak.representations.idm.UserRepresentation; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.RestController; -import org.windat.domain.UserRole; +import org.windat.domain.enums.UserRole; import org.windat.domain.entity.User; +import org.windat.domain.service.CreditFacade; import org.windat.domain.service.UserFacade; import org.windat.rest.api.UsersApi; +import org.windat.rest.dto.CreditTransferRequestDto; import org.windat.rest.dto.UserCreateRequestDto; import org.windat.rest.dto.UserDto; import org.windat.ws.mapper.UserMapper; @@ -26,18 +29,22 @@ public class UserRestController implements UsersApi { private final UserFacade userFacade; private final UserMapper userMapper; private final Keycloak keycloakAdminClient; + private final CreditFacade creditFacade; public UserRestController( UserFacade userFacade, UserMapper userMapper, - Keycloak keycloakAdminClient + Keycloak keycloakAdminClient, + CreditFacade creditFacade ) { this.userFacade = userFacade; this.userMapper = userMapper; this.keycloakAdminClient = keycloakAdminClient; + this.creditFacade = creditFacade; } @Override +// TODO: REFACTOR THIS CODE. ENCAPSULATE THIS INTO SERVICE SO IT SUITS HEXAGONAL ARCHITECTURE public ResponseEntity createUser(UserCreateRequestDto userCreateRequestDto) { // Creating representtion of user for the Keycloak API UserRepresentation userRepresentation = new UserRepresentation(); @@ -103,4 +110,20 @@ public ResponseEntity createUser(UserCreateRequestDto userCreateRequest throw new RuntimeException(errorMessage); } } + + @Override + public ResponseEntity transferCredits(CreditTransferRequestDto creditTransferRequestDto) { +// Get user from jwt token + UserDto userDto = (UserDto) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + UUID senderKeycloakId = userDto.getKeycloakId(); + + User updatedSenderDomainUser = creditFacade.transferCredits( + senderKeycloakId, + creditTransferRequestDto.getReceiverId(), + creditTransferRequestDto.getAmount(), + creditTransferRequestDto.getDescription() + ); + + return ResponseEntity.ok(userMapper.toDto(updatedSenderDomainUser)); + } } diff --git a/inbound-controller-ws/src/main/java/org/windat/ws/mapper/CreditTransactionMapper.java b/inbound-controller-ws/src/main/java/org/windat/ws/mapper/CreditTransactionMapper.java new file mode 100644 index 0000000..0aa9033 --- /dev/null +++ b/inbound-controller-ws/src/main/java/org/windat/ws/mapper/CreditTransactionMapper.java @@ -0,0 +1,39 @@ +package org.windat.ws.mapper; + +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.windat.domain.entity.CreditTransaction; +import org.windat.domain.enums.TransactionType; +import org.windat.rest.dto.CreditTransactionDto; +import org.windat.rest.dto.TransactionTypeDto; + +import java.util.Collection; +import java.util.List; + + +@Mapper(componentModel = "spring", uses = {DateMapper.class, UserMapper.class}) +public interface CreditTransactionMapper { + + @Mapping(source = "user.id", target = "userId") + @Mapping(source = "relatedUser.id", target = "relatedUserId") + @Mapping(source = "transactionTime", target = "transactionTime", qualifiedByName = "dateToOffsetDateTime") + @Mapping(source = "type", target = "type") + CreditTransactionDto toDto(CreditTransaction entity); + + + List toDtoList(Collection entities); + + default TransactionTypeDto mapTransactionType(TransactionType domainType) { + if (domainType == null) { + return null; + } + return TransactionTypeDto.valueOf(domainType.name()); + } + + default TransactionType mapTransactionType(TransactionTypeDto dtoType) { + if (dtoType == null) { + return null; + } + return TransactionType.valueOf(dtoType.name()); + } +} diff --git a/inbound-controller-ws/src/main/java/org/windat/ws/mapper/DateMapper.java b/inbound-controller-ws/src/main/java/org/windat/ws/mapper/DateMapper.java index 813ef48..3084a73 100644 --- a/inbound-controller-ws/src/main/java/org/windat/ws/mapper/DateMapper.java +++ b/inbound-controller-ws/src/main/java/org/windat/ws/mapper/DateMapper.java @@ -1,15 +1,19 @@ package org.windat.ws.mapper; +import org.mapstruct.Named; + import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.util.Date; public interface DateMapper { + @Named("dateToOffsetDateTime") default OffsetDateTime map(Date date) { return date == null ? null : date.toInstant().atOffset(ZoneOffset.UTC); } + @Named("offsetDateTimeToDate") default Date map(OffsetDateTime offsetDateTime) { return offsetDateTime == null ? null : Date.from(offsetDateTime.toInstant()); } diff --git a/inbound-controller-ws/src/main/java/org/windat/ws/mapper/LobbyMapper.java b/inbound-controller-ws/src/main/java/org/windat/ws/mapper/LobbyMapper.java index fc332d7..2891b3b 100644 --- a/inbound-controller-ws/src/main/java/org/windat/ws/mapper/LobbyMapper.java +++ b/inbound-controller-ws/src/main/java/org/windat/ws/mapper/LobbyMapper.java @@ -37,9 +37,4 @@ static List toDtoShallowList(List users) { @Mapping(target = "updated", ignore = true) @Mapping(target = "closed", ignore = true) Lobby dtoToEntity(LobbyCreateRequestDTODto dto); - - @Named("dateToOffsetDateTime") - static OffsetDateTime map(Date date) { - return date == null ? null : date.toInstant().atOffset(ZoneOffset.UTC); - } } \ No newline at end of file diff --git a/inbound-controller-ws/src/main/java/org/windat/ws/mapper/UserMapper.java b/inbound-controller-ws/src/main/java/org/windat/ws/mapper/UserMapper.java index c9c9edb..48e7ff4 100644 --- a/inbound-controller-ws/src/main/java/org/windat/ws/mapper/UserMapper.java +++ b/inbound-controller-ws/src/main/java/org/windat/ws/mapper/UserMapper.java @@ -3,7 +3,7 @@ import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.Named; -import org.windat.domain.UserRole; +import org.windat.domain.enums.UserRole; import org.windat.domain.entity.User; import org.windat.rest.dto.UserDto; import org.windat.rest.dto.UserRoleDto; @@ -15,6 +15,7 @@ public interface UserMapper { @Mapping(source = "lobby.id", target = "lobbyId") UserDto toDto(User user); + @Mapping(target = "lobby", ignore = true) User toUser(UserDto userDto); @Named("toDtoShallow") diff --git a/k8s/02-secrets.yaml b/k8s/02-secrets.yaml index 3a1608e..11e9a70 100644 --- a/k8s/02-secrets.yaml +++ b/k8s/02-secrets.yaml @@ -69,3 +69,18 @@ type: Opaque data: runner-registration-token: "" runner-token: Z2xydC1LZlJzblZScVNVazdqUEdGRkpOZQ== + +--- + +apiVersion: v1 +kind: Secret +metadata: + name: keycloak-app-admin + namespace: app + annotations: + kubernetes.io/service-account.name: default +type: Opaque +data: + client-id: ZnNhLWNsaWVudA== + client-secret: KioqKioqKioqKg== + diff --git a/k8s/app-be/05-deployment.yaml b/k8s/app-be/05-deployment.yaml index 7d540dd..4cd0aa8 100644 --- a/k8s/app-be/05-deployment.yaml +++ b/k8s/app-be/05-deployment.yaml @@ -34,6 +34,16 @@ spec: secretKeyRef: name: postgres-secret key: db_password + - name: KEYCLOAK_ADMIN_CLIENT_ID + valueFrom: + secretKeyRef: + name: keycloak-app-admin + key: client-id + - name: KEYCLOAK_ADMIN_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: keycloak-app-admin + key: client-secret - name: ISSUER_URI value: http://4.245.186.27/auth/realms/WinDat - name: JWT_URI diff --git a/outbound-repository-jpa/src/main/java/org/windat/jpa/adapter/JpaCreditRepositoryAdapter.java b/outbound-repository-jpa/src/main/java/org/windat/jpa/adapter/JpaCreditRepositoryAdapter.java new file mode 100644 index 0000000..84d70c3 --- /dev/null +++ b/outbound-repository-jpa/src/main/java/org/windat/jpa/adapter/JpaCreditRepositoryAdapter.java @@ -0,0 +1,44 @@ +package org.windat.jpa.adapter; + +import org.springframework.stereotype.Repository; +import org.windat.domain.entity.CreditTransaction; +import org.windat.domain.repository.CreditRepository; +import org.windat.jpa.repository.CreditSpringDataRepository; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +@Repository +public class JpaCreditRepositoryAdapter implements CreditRepository { + + private final CreditSpringDataRepository creditSpringDataRepository; + + public JpaCreditRepositoryAdapter(CreditSpringDataRepository creditSpringDataRepository) { + this.creditSpringDataRepository = creditSpringDataRepository; + } + @Override + public Collection readAll() { + return creditSpringDataRepository.findAll(); + } + + @Override + public CreditTransaction create(CreditTransaction creditTransaction) { + return creditSpringDataRepository.save(creditTransaction); + } + + @Override + public Optional readOne(Integer id) { + return creditSpringDataRepository.findById(id); + } + + @Override + public CreditTransaction update(CreditTransaction creditTransaction) { + return creditSpringDataRepository.save(creditTransaction); + } + + @Override + public Collection readByUserId(Integer userId) { + return creditSpringDataRepository.findByUserId(userId); + } +} diff --git a/outbound-repository-jpa/src/main/java/org/windat/jpa/repository/CreditSpringDataRepository.java b/outbound-repository-jpa/src/main/java/org/windat/jpa/repository/CreditSpringDataRepository.java new file mode 100644 index 0000000..3ae9f4d --- /dev/null +++ b/outbound-repository-jpa/src/main/java/org/windat/jpa/repository/CreditSpringDataRepository.java @@ -0,0 +1,14 @@ +package org.windat.jpa.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.windat.domain.entity.CreditTransaction; + +import java.util.Collection; +import java.util.Optional; + +public interface CreditSpringDataRepository extends JpaRepository { + @Query("SELECT ct FROM CreditTransaction ct WHERE ct.user.id = :userId OR ct.relatedUser.id = :userId") + Collection findByUserId(@Param("userId") Integer userId); +} diff --git a/outbound-repository-jpa/src/main/resources/persistence/orm.xml b/outbound-repository-jpa/src/main/resources/persistence/orm.xml index 2943a92..ae63baf 100644 --- a/outbound-repository-jpa/src/main/resources/persistence/orm.xml +++ b/outbound-repository-jpa/src/main/resources/persistence/orm.xml @@ -56,12 +56,49 @@ + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + STRING + + + + + + + + + + + + \ No newline at end of file diff --git a/springboot/src/main/java/org/windat/main/CreditBeanConfiguration.java b/springboot/src/main/java/org/windat/main/CreditBeanConfiguration.java new file mode 100644 index 0000000..b69a8ef --- /dev/null +++ b/springboot/src/main/java/org/windat/main/CreditBeanConfiguration.java @@ -0,0 +1,40 @@ +package org.windat.main; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.context.annotation.Bean; +import org.springframework.data.jpa.repository.support.JpaRepositoryFactory; +import org.springframework.stereotype.Component; +import org.windat.domain.repository.CreditRepository; +import org.windat.domain.repository.UserRepository; +import org.windat.domain.service.CreditFacade; +import org.windat.domain.service.CreditService; +import org.windat.jpa.adapter.JpaCreditRepositoryAdapter; +import org.windat.jpa.repository.CreditSpringDataRepository; + +/* +Manual creation and configuration of Credit beans + */ +@Component +public class CreditBeanConfiguration { + + @PersistenceContext + private EntityManager entityManager; + + @Bean + public CreditFacade creditFacade(CreditRepository creditRepository, UserRepository userRepository) { + return new CreditService(userRepository, creditRepository); + } + + @Bean + public CreditRepository creditRepository(CreditSpringDataRepository creditSpringDataRepository) { + return new JpaCreditRepositoryAdapter(creditSpringDataRepository); + } + + @Bean + public CreditSpringDataRepository creditSpringDataRepository() { + JpaRepositoryFactory jpaRepositoryFactory = new JpaRepositoryFactory(entityManager); + return jpaRepositoryFactory.getRepository(CreditSpringDataRepository.class); + } + +} From dc3d705617fe2b10aac507d2eec20a352c2640b6 Mon Sep 17 00:00:00 2001 From: Mykyta Prykhodko Date: Tue, 27 May 2025 22:56:10 +0200 Subject: [PATCH 07/11] Added leader board functionality --- .../main/resources/openapi/windatOpenApi.yaml | 30 +++++++++++++++++ .../java/org/windat/domain/entity/User.java | 32 +++++++++++++++++++ .../domain/repository/UserRepository.java | 2 ++ .../org/windat/domain/service/UserFacade.java | 2 ++ .../windat/domain/service/UserService.java | 6 ++++ .../ws/controller/UserRestController.java | 12 +++++++ .../jpa/adapter/JpaUserRepositoryAdapter.java | 4 +++ .../repository/UserSpringDataRepository.java | 3 ++ .../src/main/resources/persistence/orm.xml | 9 ++++++ 9 files changed, 100 insertions(+) diff --git a/api-spec/src/main/resources/openapi/windatOpenApi.yaml b/api-spec/src/main/resources/openapi/windatOpenApi.yaml index ad6f7fa..e55bdcb 100644 --- a/api-spec/src/main/resources/openapi/windatOpenApi.yaml +++ b/api-spec/src/main/resources/openapi/windatOpenApi.yaml @@ -58,6 +58,18 @@ components: type: integer format: int32 description: Current credit balance of the user. + gamesPlayed: + type: integer + format: int32 + description: Total number of games played by the user. + gamesWon: + type: integer + format: int32 + description: Total number of games won by the user. + gamesLost: + type: integer + format: int32 + description: Total number of games lost by the user. UserRole: type: string enum: @@ -353,6 +365,24 @@ paths: description: User with provided username or email already exists '500': description: Internal server error + /users/leaderboard: + get: + summary: Get the top 10 users for the leaderboard, sorted by wins. + operationId: getLeaderboard + tags: + - Users + - Leaderboard + responses: + '200': + description: List of the top 10 users on the leaderboard. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' + '500': + description: Internal server error. /users/me/transfer: post: summary: Transfer credits from one user to another. diff --git a/domain/src/main/java/org/windat/domain/entity/User.java b/domain/src/main/java/org/windat/domain/entity/User.java index 4a57027..ce47cd9 100644 --- a/domain/src/main/java/org/windat/domain/entity/User.java +++ b/domain/src/main/java/org/windat/domain/entity/User.java @@ -47,6 +47,11 @@ public class User { */ private Integer credits; + private Integer gamesPlayed; + + private Integer gamesWon; + + private Integer gamesLost; /** * Default constructor for Hibernate to map entities. * Initializes a new User instance, setting the initial credit balance to 10,000. @@ -55,6 +60,9 @@ public class User { public User() { this.credits = 10000; + this.gamesPlayed = 0; + this.gamesWon = 0; + this.gamesLost = 0; } /** @@ -183,4 +191,28 @@ public void deductCredits(Integer amount) { } this.credits -= amount; } + + public Integer getGamesPlayed() { + return gamesPlayed; + } + + public void setGamesPlayed(Integer gamesPlayed) { + this.gamesPlayed = gamesPlayed; + } + + public Integer getGamesWon() { + return gamesWon; + } + + public void setGamesWon(Integer gamesWon) { + this.gamesWon = gamesWon; + } + + public Integer getGamesLost() { + return gamesLost; + } + + public void setGamesLost(Integer gamesLost) { + this.gamesLost = gamesLost; + } } \ No newline at end of file diff --git a/domain/src/main/java/org/windat/domain/repository/UserRepository.java b/domain/src/main/java/org/windat/domain/repository/UserRepository.java index 10d7161..d10dba0 100644 --- a/domain/src/main/java/org/windat/domain/repository/UserRepository.java +++ b/domain/src/main/java/org/windat/domain/repository/UserRepository.java @@ -17,4 +17,6 @@ public interface UserRepository { User update(User user); Optional readOne(Integer id); + + Collection readBest10UsersByWins(); } diff --git a/domain/src/main/java/org/windat/domain/service/UserFacade.java b/domain/src/main/java/org/windat/domain/service/UserFacade.java index afd9263..e42f477 100644 --- a/domain/src/main/java/org/windat/domain/service/UserFacade.java +++ b/domain/src/main/java/org/windat/domain/service/UserFacade.java @@ -18,4 +18,6 @@ public interface UserFacade { // Use this method to retrieve user from the application database Optional readOne(Integer id); + + Collection readBest10UsersByWins(); } diff --git a/domain/src/main/java/org/windat/domain/service/UserService.java b/domain/src/main/java/org/windat/domain/service/UserService.java index dcd8097..5942c2e 100644 --- a/domain/src/main/java/org/windat/domain/service/UserService.java +++ b/domain/src/main/java/org/windat/domain/service/UserService.java @@ -43,4 +43,10 @@ public User update(User user) { public Optional readOne(Integer id) { return userRepository.readOne(id); } + + @Override + public Collection readBest10UsersByWins() { + return userRepository.readBest10UsersByWins(); + } + } diff --git a/inbound-controller-ws/src/main/java/org/windat/ws/controller/UserRestController.java b/inbound-controller-ws/src/main/java/org/windat/ws/controller/UserRestController.java index f9feb10..e2f3de0 100644 --- a/inbound-controller-ws/src/main/java/org/windat/ws/controller/UserRestController.java +++ b/inbound-controller-ws/src/main/java/org/windat/ws/controller/UserRestController.java @@ -22,6 +22,7 @@ import java.util.*; +import java.util.stream.Collectors; @RestController public class UserRestController implements UsersApi { @@ -111,6 +112,17 @@ public ResponseEntity createUser(UserCreateRequestDto userCreateRequest } } + @Override + public ResponseEntity> getLeaderboard() { + Collection topUsers = userFacade.readBest10UsersByWins(); + + List userDtos = topUsers.stream() + .map(userMapper::toDto) + .collect(Collectors.toList()); + + return ResponseEntity.ok(userDtos); + } + @Override public ResponseEntity transferCredits(CreditTransferRequestDto creditTransferRequestDto) { // Get user from jwt token diff --git a/outbound-repository-jpa/src/main/java/org/windat/jpa/adapter/JpaUserRepositoryAdapter.java b/outbound-repository-jpa/src/main/java/org/windat/jpa/adapter/JpaUserRepositoryAdapter.java index 17186fb..6c4a67b 100644 --- a/outbound-repository-jpa/src/main/java/org/windat/jpa/adapter/JpaUserRepositoryAdapter.java +++ b/outbound-repository-jpa/src/main/java/org/windat/jpa/adapter/JpaUserRepositoryAdapter.java @@ -41,4 +41,8 @@ public Optional readOne(Integer id) { return userSpringDataRepository.findById(id); } + @Override + public Collection readBest10UsersByWins() { + return userSpringDataRepository.findTop10ByOrderByGamesWonDesc(); + } } diff --git a/outbound-repository-jpa/src/main/java/org/windat/jpa/repository/UserSpringDataRepository.java b/outbound-repository-jpa/src/main/java/org/windat/jpa/repository/UserSpringDataRepository.java index 87fa375..371da9c 100644 --- a/outbound-repository-jpa/src/main/java/org/windat/jpa/repository/UserSpringDataRepository.java +++ b/outbound-repository-jpa/src/main/java/org/windat/jpa/repository/UserSpringDataRepository.java @@ -3,9 +3,12 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.windat.domain.entity.User; +import java.util.List; import java.util.Optional; import java.util.UUID; public interface UserSpringDataRepository extends JpaRepository { Optional findByKeycloakId(UUID keycloakId); + + List findTop10ByOrderByGamesWonDesc(); } diff --git a/outbound-repository-jpa/src/main/resources/persistence/orm.xml b/outbound-repository-jpa/src/main/resources/persistence/orm.xml index ae63baf..8142e8a 100644 --- a/outbound-repository-jpa/src/main/resources/persistence/orm.xml +++ b/outbound-repository-jpa/src/main/resources/persistence/orm.xml @@ -59,6 +59,15 @@ + + + + + + + + + From a4f50ddb205a37ae68cb5864516b11d90ae38afb Mon Sep 17 00:00:00 2001 From: Mykyta Prykhodko Date: Tue, 27 May 2025 23:24:10 +0200 Subject: [PATCH 08/11] Added monthly rewards feature --- domain/pom.xml | 4 + .../domain/service/MonthlyRewardService.java | 107 ++++++++++++++++++ .../windat/main/UserBeanConfiguration.java | 20 ++++ .../org/windat/main/WinDatApplication.java | 3 + .../src/main/resources/application.yaml | 18 ++- 5 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 domain/src/main/java/org/windat/domain/service/MonthlyRewardService.java diff --git a/domain/pom.xml b/domain/pom.xml index beaf68e..7b9506d 100644 --- a/domain/pom.xml +++ b/domain/pom.xml @@ -32,5 +32,9 @@ org.springframework spring-web + + org.springframework + spring-context + diff --git a/domain/src/main/java/org/windat/domain/service/MonthlyRewardService.java b/domain/src/main/java/org/windat/domain/service/MonthlyRewardService.java new file mode 100644 index 0000000..d58d001 --- /dev/null +++ b/domain/src/main/java/org/windat/domain/service/MonthlyRewardService.java @@ -0,0 +1,107 @@ +package org.windat.domain.service; + +import jakarta.transaction.Transactional; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.windat.domain.entity.CreditTransaction; +import org.windat.domain.entity.User; +import org.windat.domain.enums.TransactionType; + +import java.time.LocalDateTime; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class MonthlyRewardService { + + private final UserFacade userFacade; + private final CreditFacade creditFacade; + +// Get values from the properties +// Set default to 10 if not found + @Value("${rewards.leaderboard.top-players-count:10}") + private int topPlayersCount; + +// Use a map to store prizes for different ranks + private final Map prizes = new HashMap<>(); + + public MonthlyRewardService(UserFacade userFacade, CreditFacade creditFacade, + @Value("${rewards.leaderboard.prizes.1st-place:0}") int p1, + @Value("${rewards.leaderboard.prizes.2nd-place:0}") int p2, + @Value("${rewards.leaderboard.prizes.3rd-place:0}") int p3, + @Value("${rewards.leaderboard.prizes.4th-place:0}") int p4, + @Value("${rewards.leaderboard.prizes.5th-place:0}") int p5, + @Value("${rewards.leaderboard.prizes.6th-place:0}") int p6, + @Value("${rewards.leaderboard.prizes.7th-place:0}") int p7, + @Value("${rewards.leaderboard.prizes.8th-place:0}") int p8, + @Value("${rewards.leaderboard.prizes.9th-place:0}") int p9, + @Value("${rewards.leaderboard.prizes.10th-place:0}") int p10 + ) { + this.userFacade = userFacade; + this.creditFacade = creditFacade; + +// Populate the prizes map + prizes.put(1, p1); + prizes.put(2, p2); + prizes.put(3, p3); + prizes.put(4, p4); + prizes.put(5, p5); + prizes.put(6, p6); + prizes.put(7, p7); + prizes.put(8, p8); + prizes.put(9, p9); + prizes.put(10, p10); + } + + /** + * This method is scheduled to run at 23:59:00 on the last day of every month. + * It identifies the top players and distributes monthly rewards. + */ +// cron = "0 59 23 L * ?" means: at 23:59:00, on the last day of the month, every month, any day of the week. +// L means "last day of the month" + @Scheduled(cron = "0 59 23 L * ?") + @Transactional // Ensure the entire reward distribution is atomic + public void distributeMonthlyRewards() { + System.out.println("Starting monthly reward distribution at " + LocalDateTime.now()); + + + List topUsers = userFacade.readBest10UsersByWins() + .stream() + .limit(topPlayersCount) + .toList(); + + if (topUsers.isEmpty()) { + System.out.println("No users on the leaderboard to distribute rewards."); + return; + } + + for (int i = 0; i < topUsers.size(); i++) { + User user = topUsers.get(i); + int rank = i + 1; + Integer rewardAmount = prizes.getOrDefault(rank, 0); // Get reward for rank, default to 0 + + if (rewardAmount > 0) { +// Add credits to the user's balance + user.addCredits(rewardAmount); + userFacade.update(user); + +// Record the transaction + CreditTransaction rewardTransaction = new CreditTransaction(); + rewardTransaction.setAmount(rewardAmount); + rewardTransaction.setUser(user); + rewardTransaction.setTransactionTime(LocalDateTime.now()); + rewardTransaction.setDescription("Monthly leaderboard reward for rank " + rank); + rewardTransaction.setType(TransactionType.MONTHLY_BONUS); + + creditFacade.create(rewardTransaction); + +// TODO: change this form sout to use some logger + System.out.println("Distributed " + rewardAmount + " credits to " + user.getLoginName() + " (Rank " + rank + ")"); + } else { + System.out.println("No reward defined for rank " + rank + " or beyond for " + user.getLoginName()); + } + } + System.out.println("Finished monthly reward distribution."); + } +} diff --git a/springboot/src/main/java/org/windat/main/UserBeanConfiguration.java b/springboot/src/main/java/org/windat/main/UserBeanConfiguration.java index 5df3ccd..b2e82b7 100644 --- a/springboot/src/main/java/org/windat/main/UserBeanConfiguration.java +++ b/springboot/src/main/java/org/windat/main/UserBeanConfiguration.java @@ -2,10 +2,13 @@ import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.data.jpa.repository.support.JpaRepositoryFactory; import org.springframework.stereotype.Component; import org.windat.domain.repository.UserRepository; +import org.windat.domain.service.CreditFacade; +import org.windat.domain.service.MonthlyRewardService; import org.windat.domain.service.UserFacade; import org.windat.domain.service.UserService; import org.windat.jpa.adapter.JpaUserRepositoryAdapter; @@ -35,4 +38,21 @@ public UserSpringDataRepository userSpringDataRepository(){ JpaRepositoryFactory jpaRepositoryFactory = new JpaRepositoryFactory(entityManager); return jpaRepositoryFactory.getRepository(UserSpringDataRepository.class); } + + @Bean + public MonthlyRewardService monthlyRewardService(UserFacade userFacade, CreditFacade creditFacade, + @Value("${rewards.leaderboard.prizes.1st-place:0}") int p1, + @Value("${rewards.leaderboard.prizes.2nd-place:0}") int p2, + @Value("${rewards.leaderboard.prizes.3rd-place:0}") int p3, + @Value("${rewards.leaderboard.prizes.4th-place:0}") int p4, + @Value("${rewards.leaderboard.prizes.5th-place:0}") int p5, + @Value("${rewards.leaderboard.prizes.6th-place:0}") int p6, + @Value("${rewards.leaderboard.prizes.7th-place:0}") int p7, + @Value("${rewards.leaderboard.prizes.8th-place:0}") int p8, + @Value("${rewards.leaderboard.prizes.9th-place:0}") int p9, + @Value("${rewards.leaderboard.prizes.10th-place:0}") int p10, + @Value("${rewards.leaderboard.top-players-count:10}") int topPlayersCount + ) { + return new MonthlyRewardService(userFacade, creditFacade, p1, p2, p3, p4, p5, p6, p7, p8, p9, p10); + } } diff --git a/springboot/src/main/java/org/windat/main/WinDatApplication.java b/springboot/src/main/java/org/windat/main/WinDatApplication.java index dcbe7c2..b477907 100644 --- a/springboot/src/main/java/org/windat/main/WinDatApplication.java +++ b/springboot/src/main/java/org/windat/main/WinDatApplication.java @@ -5,10 +5,13 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.ComponentScan; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @ComponentScan(basePackages = {"org.windat.*"}) //@EnableJpaRepositories("org.windat.jpa.repository") +// For monthly prizes +@EnableScheduling public class WinDatApplication { public static void main(String[] args) diff --git a/springboot/src/main/resources/application.yaml b/springboot/src/main/resources/application.yaml index ad6af20..5f326cc 100644 --- a/springboot/src/main/resources/application.yaml +++ b/springboot/src/main/resources/application.yaml @@ -38,4 +38,20 @@ keycloak: realm: WinDat admin-cli: client-id: ${KEYCLOAK_ADMIN_CLIENT_ID} - client-secret: ${KEYCLOAK_ADMIN_CLIENT_SECRET} \ No newline at end of file + client-secret: ${KEYCLOAK_ADMIN_CLIENT_SECRET} + +rewards: + leaderboard: + top-players-count: 10 + prizes: +# 50000 credits for 1st + 1st-place: 50000 + 2nd-place: 30000 + 3rd-place: 20000 + 4th-place: 10000 + 5th-place: 7500 + 6th-place: 5000 + 7th-place: 2500 + 8th-place: 1000 + 9th-place: 500 + 10th-place: 250 \ No newline at end of file From 00333fc733c9137ec1f9c72b88eb50f13c99fa45 Mon Sep 17 00:00:00 2001 From: Mykyta Prykhodko Date: Wed, 28 May 2025 12:10:02 +0200 Subject: [PATCH 09/11] Creation of user now do not require authorization --- .../src/main/java/org/windat/ws/security/SecurityConfig.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/inbound-controller-ws/src/main/java/org/windat/ws/security/SecurityConfig.java b/inbound-controller-ws/src/main/java/org/windat/ws/security/SecurityConfig.java index d779874..dcacdb9 100644 --- a/inbound-controller-ws/src/main/java/org/windat/ws/security/SecurityConfig.java +++ b/inbound-controller-ws/src/main/java/org/windat/ws/security/SecurityConfig.java @@ -1,5 +1,6 @@ package org.windat.ws.security; +import org.springframework.http.HttpMethod; import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -26,6 +27,7 @@ SecurityFilterChain securityFilterChain(HttpSecurity http, JwtDecoder jwtDecoder private void configureAuthorizationRules(AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry auth) { auth + .requestMatchers(HttpMethod.POST, "/users").permitAll() .requestMatchers(EndpointRequest.toAnyEndpoint()).permitAll() .anyRequest().authenticated(); } From 329b1ef9a8d6e10939d7e621b46501b602d812e8 Mon Sep 17 00:00:00 2001 From: Mykyta Prykhodko Date: Thu, 29 May 2025 15:46:37 +0200 Subject: [PATCH 10/11] Integrated functionality to get winners from the CS2 server session and writing transaction to it. --- .../main/resources/openapi/windatOpenApi.yaml | 73 +++++++++++++++- .../java/org/windat/domain/entity/Lobby.java | 24 ++++++ .../java/org/windat/domain/entity/User.java | 10 +++ .../domain/repository/UserRepository.java | 2 + .../org/windat/domain/service/UserFacade.java | 2 + .../windat/domain/service/UserService.java | 5 ++ .../ws/controller/LobbyRestController.java | 85 +++++++++++++++++-- .../controller/TransactionRestController.java | 1 + .../ws/controller/UserRestController.java | 1 + .../windat/ws/security/SecurityConfig.java | 2 + .../jpa/adapter/JpaUserRepositoryAdapter.java | 5 ++ .../repository/UserSpringDataRepository.java | 2 + .../src/main/resources/persistence/orm.xml | 8 ++ 13 files changed, 214 insertions(+), 6 deletions(-) diff --git a/api-spec/src/main/resources/openapi/windatOpenApi.yaml b/api-spec/src/main/resources/openapi/windatOpenApi.yaml index e55bdcb..a97ea3c 100644 --- a/api-spec/src/main/resources/openapi/windatOpenApi.yaml +++ b/api-spec/src/main/resources/openapi/windatOpenApi.yaml @@ -12,6 +12,7 @@ components: - id - name - created + - amount properties: id: type: integer @@ -20,6 +21,10 @@ components: name: type: string description: Name of the lobby + amount: + type: integer + format: int32 + description: Amount at which the game is played created: type: string format: date-time @@ -70,6 +75,9 @@ components: type: integer format: int32 description: Total number of games lost by the user. + cs2Username: + type: string + description: Username in the game CounterStrike 2 UserRole: type: string enum: @@ -80,6 +88,9 @@ components: properties: name: type: string + amount: + type: integer + format: int32 UserCreateRequest: type: object required: @@ -88,6 +99,7 @@ components: - lastName - email - password + - cs2Username properties: # That will be username on the page username: @@ -113,6 +125,8 @@ components: description: User's chosen password minLength: 8 example: StrongPassword123! + cs2Username: + type: string CreditTransferRequest: type: object required: @@ -132,6 +146,17 @@ components: type: string description: Optional description for the transfer. nullable: true + DuelResultPayload: + type: object + properties: + winner: + type: string + description: Winners cs2 match name + loser: + type: string + description: Loser cs2 match name + + # This dto will be returned from the /transaction endpoint CreditTransaction: type: object @@ -199,6 +224,12 @@ components: nullable: true type: $ref: '#/components/schemas/TransactionType' + WinnerResponse: + type: object + properties: + winnerUsername: + type: string + description: The ID of the primary user for this transaction. paths: @@ -460,4 +491,44 @@ paths: '401': description: Unauthorized. '403': - description: Forbidden (if admin-only). \ No newline at end of file + description: Forbidden (if admin-only). + /duel-results: + post: + summary: Get result of cs2 duel + operationId: getResultOfCs2Duel + tags: + - Lobbies + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DuelResultPayload' + responses: + '200': + description: Successfully retrieve winner + '500': + description: Server error + /duel-winner/{lobbyId}: + get: + summary: Get winner of cs2 duel + operationId: getWinnerOfCs2Duel + parameters: + - name: lobbyId + in: path + required: true + schema: + type: integer + format: int32 + description: ID of the lobby from which you need to get winner + tags: + - Lobbies + responses: + '200': + description: Transaction recorded successfully. + content: + application/json: + schema: + $ref: '#/components/schemas/WinnerResponse' + '500': + description: Server error diff --git a/domain/src/main/java/org/windat/domain/entity/Lobby.java b/domain/src/main/java/org/windat/domain/entity/Lobby.java index 50f36a7..57ebd3c 100644 --- a/domain/src/main/java/org/windat/domain/entity/Lobby.java +++ b/domain/src/main/java/org/windat/domain/entity/Lobby.java @@ -50,6 +50,13 @@ public Lobby(){ */ private Date closed; + /** + * Describes amount of credits required from each player to put into game + */ + private Integer amount; + + private String lobbyWinnerUsername; + /** * List of users currently participating in the lobby. * Represents the active users within the game session. @@ -145,4 +152,21 @@ public Date getUpdated() { public Date getClosed() { return closed; } + + + public Integer getAmount() { + return amount; + } + + public void setAmount(Integer amount) { + this.amount = amount; + } + + public String getLobbyWinnerUsername() { + return lobbyWinnerUsername; + } + + public void setLobbyWinnerUsername(String lobbyWinnerUsername) { + this.lobbyWinnerUsername = lobbyWinnerUsername; + } } \ No newline at end of file diff --git a/domain/src/main/java/org/windat/domain/entity/User.java b/domain/src/main/java/org/windat/domain/entity/User.java index ce47cd9..4d7cb09 100644 --- a/domain/src/main/java/org/windat/domain/entity/User.java +++ b/domain/src/main/java/org/windat/domain/entity/User.java @@ -52,6 +52,8 @@ public class User { private Integer gamesWon; private Integer gamesLost; + + private String cs2Username; /** * Default constructor for Hibernate to map entities. * Initializes a new User instance, setting the initial credit balance to 10,000. @@ -215,4 +217,12 @@ public Integer getGamesLost() { public void setGamesLost(Integer gamesLost) { this.gamesLost = gamesLost; } + + public String getCs2Username() { + return cs2Username; + } + + public void setCs2Username(String cs2Username) { + this.cs2Username = cs2Username; + } } \ No newline at end of file diff --git a/domain/src/main/java/org/windat/domain/repository/UserRepository.java b/domain/src/main/java/org/windat/domain/repository/UserRepository.java index d10dba0..0081198 100644 --- a/domain/src/main/java/org/windat/domain/repository/UserRepository.java +++ b/domain/src/main/java/org/windat/domain/repository/UserRepository.java @@ -19,4 +19,6 @@ public interface UserRepository { Optional readOne(Integer id); Collection readBest10UsersByWins(); + + Optional readUserBySteamUsername(String steamUsername); } diff --git a/domain/src/main/java/org/windat/domain/service/UserFacade.java b/domain/src/main/java/org/windat/domain/service/UserFacade.java index e42f477..801426c 100644 --- a/domain/src/main/java/org/windat/domain/service/UserFacade.java +++ b/domain/src/main/java/org/windat/domain/service/UserFacade.java @@ -20,4 +20,6 @@ public interface UserFacade { Optional readOne(Integer id); Collection readBest10UsersByWins(); + + Optional readUserBySteamUsername(String steamUsername); } diff --git a/domain/src/main/java/org/windat/domain/service/UserService.java b/domain/src/main/java/org/windat/domain/service/UserService.java index 5942c2e..9928b68 100644 --- a/domain/src/main/java/org/windat/domain/service/UserService.java +++ b/domain/src/main/java/org/windat/domain/service/UserService.java @@ -49,4 +49,9 @@ public Collection readBest10UsersByWins() { return userRepository.readBest10UsersByWins(); } + @Override + public Optional readUserBySteamUsername(String steamUsername) { + return userRepository.readUserBySteamUsername(steamUsername); + } + } diff --git a/inbound-controller-ws/src/main/java/org/windat/ws/controller/LobbyRestController.java b/inbound-controller-ws/src/main/java/org/windat/ws/controller/LobbyRestController.java index f22c2b1..cea9c53 100644 --- a/inbound-controller-ws/src/main/java/org/windat/ws/controller/LobbyRestController.java +++ b/inbound-controller-ws/src/main/java/org/windat/ws/controller/LobbyRestController.java @@ -3,17 +3,21 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.bind.annotation.RestController; +import org.windat.domain.entity.CreditTransaction; +import org.windat.domain.enums.TransactionType; import org.windat.domain.enums.UserRole; import org.windat.domain.entity.Lobby; import org.windat.domain.entity.User; import org.windat.domain.exceptions.*; +import org.windat.domain.service.CreditFacade; import org.windat.domain.service.LobbyFacade; import org.windat.domain.service.UserFacade; +import org.windat.rest.api.DuelResultsApi; +import org.windat.rest.api.DuelWinnerApi; import org.windat.rest.api.LobbiesApi; -import org.windat.rest.dto.LobbyCreateRequestDTODto; -import org.windat.rest.dto.LobbyDto; -import org.windat.rest.dto.UserDto; +import org.windat.rest.dto.*; import org.windat.ws.mapper.LobbyMapper; import org.windat.ws.mapper.UserMapper; @@ -23,10 +27,11 @@ import org.springframework.security.core.context.SecurityContextHolder; @RestController -public class LobbyRestController implements LobbiesApi { +public class LobbyRestController implements LobbiesApi, DuelResultsApi, DuelWinnerApi { private final LobbyFacade lobbyFacade; private final LobbyMapper lobbyMapper; + private final CreditFacade creditFacade; private final UserFacade userFacade; private final UserMapper userMapper; @@ -35,12 +40,14 @@ public LobbyRestController( LobbyFacade lobbyFacade, LobbyMapper lobbyMapper, UserFacade userFacade, - UserMapper userMapper + UserMapper userMapper, + CreditFacade creditFacade ) { this.lobbyFacade = lobbyFacade; this.lobbyMapper = lobbyMapper; this.userFacade = userFacade; this.userMapper = userMapper; + this.creditFacade = creditFacade; } /** @@ -222,4 +229,72 @@ public ResponseEntity removeUserFromLobbyAsAdmin(Integer lobbyId, Inte return ResponseEntity.ok(lobbyMapper.toDto(lobby)); } + +// Make transaction based on winner of the game +// User sends money to winner + @Override + public ResponseEntity getResultOfCs2Duel(DuelResultPayloadDto duelResultPayloadDto) { + String winner = duelResultPayloadDto.getWinner(); + String loser = duelResultPayloadDto.getLoser(); + +// Get loser + User loserUser = userFacade.readUserBySteamUsername(loser).orElseThrow( + () -> new UserNotFoundException("User not found") + ); + +// Get winner + User winnerUser = userFacade.readUserBySteamUsername(winner).orElseThrow( + () -> new UserNotFoundException("User not found") + ); + +// Check if users are in same lobby + if (loserUser.getLobby().getId() == winnerUser.getLobby().getId()) { + creditFacade.transferCredits( + loserUser.getKeycloakId(), + winnerUser.getId(), + loserUser.getLobby().getAmount(), + "Transaction after game match" + ); + } +// + + Lobby lobby = winnerUser.getLobby(); + lobby.setLobbyWinnerUsername(winnerUser.getLoginName()); + lobbyFacade.update(lobby); + + CreditTransaction transactionFromLoser = new CreditTransaction( + loserUser, + -loserUser.getLobby().getAmount(), + "Transaction after game match", + winnerUser, + TransactionType.MATCH_LOSS + ); + + CreditTransaction transactionFromWinner = new CreditTransaction( + winnerUser, + winnerUser.getLobby().getAmount(), + "Transaction after game match", + loserUser, + TransactionType.MATCH_WIN + ); + + creditFacade.create(transactionFromLoser); + creditFacade.create(transactionFromWinner); + + return null; + } + + +// Endpoint to send winner to the frontend + @Override + public ResponseEntity getWinnerOfCs2Duel(Integer lobbyId) { + Lobby lobby = lobbyFacade.readOne(lobbyId).orElseThrow( + () -> new LobbyNotFoundException("Lobby with ID " + lobbyId + " not found.") + ); + + WinnerResponseDto winnerResponseDto = new WinnerResponseDto(); + winnerResponseDto.setWinnerUsername(lobby.getLobbyWinnerUsername()); + + return ResponseEntity.ok(winnerResponseDto); + } } diff --git a/inbound-controller-ws/src/main/java/org/windat/ws/controller/TransactionRestController.java b/inbound-controller-ws/src/main/java/org/windat/ws/controller/TransactionRestController.java index d83e6ca..692a1d0 100644 --- a/inbound-controller-ws/src/main/java/org/windat/ws/controller/TransactionRestController.java +++ b/inbound-controller-ws/src/main/java/org/windat/ws/controller/TransactionRestController.java @@ -3,6 +3,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.bind.annotation.RestController; import org.windat.domain.entity.CreditTransaction; import org.windat.domain.entity.User; diff --git a/inbound-controller-ws/src/main/java/org/windat/ws/controller/UserRestController.java b/inbound-controller-ws/src/main/java/org/windat/ws/controller/UserRestController.java index e2f3de0..7554f78 100644 --- a/inbound-controller-ws/src/main/java/org/windat/ws/controller/UserRestController.java +++ b/inbound-controller-ws/src/main/java/org/windat/ws/controller/UserRestController.java @@ -91,6 +91,7 @@ public ResponseEntity createUser(UserCreateRequestDto userCreateRequest user.setLoginName(userCreateRequestDto.getUsername()); user.setUserRoleEnum(UserRole.USER_ROLE); user.setLobby(null); + user.setCs2Username(userCreateRequestDto.getCs2Username()); User persistedUser = userFacade.create(user); diff --git a/inbound-controller-ws/src/main/java/org/windat/ws/security/SecurityConfig.java b/inbound-controller-ws/src/main/java/org/windat/ws/security/SecurityConfig.java index dcacdb9..e18d0f7 100644 --- a/inbound-controller-ws/src/main/java/org/windat/ws/security/SecurityConfig.java +++ b/inbound-controller-ws/src/main/java/org/windat/ws/security/SecurityConfig.java @@ -28,6 +28,8 @@ SecurityFilterChain securityFilterChain(HttpSecurity http, JwtDecoder jwtDecoder private void configureAuthorizationRules(AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry auth) { auth .requestMatchers(HttpMethod.POST, "/users").permitAll() + .requestMatchers(HttpMethod.POST, "/duel-results").permitAll() + .requestMatchers(HttpMethod.GET, "/users/leaderboard").permitAll() .requestMatchers(EndpointRequest.toAnyEndpoint()).permitAll() .anyRequest().authenticated(); } diff --git a/outbound-repository-jpa/src/main/java/org/windat/jpa/adapter/JpaUserRepositoryAdapter.java b/outbound-repository-jpa/src/main/java/org/windat/jpa/adapter/JpaUserRepositoryAdapter.java index 6c4a67b..d4aa141 100644 --- a/outbound-repository-jpa/src/main/java/org/windat/jpa/adapter/JpaUserRepositoryAdapter.java +++ b/outbound-repository-jpa/src/main/java/org/windat/jpa/adapter/JpaUserRepositoryAdapter.java @@ -45,4 +45,9 @@ public Optional readOne(Integer id) { public Collection readBest10UsersByWins() { return userSpringDataRepository.findTop10ByOrderByGamesWonDesc(); } + + @Override + public Optional readUserBySteamUsername(String steamUsername) { + return userSpringDataRepository.findByCs2Username(steamUsername); + } } diff --git a/outbound-repository-jpa/src/main/java/org/windat/jpa/repository/UserSpringDataRepository.java b/outbound-repository-jpa/src/main/java/org/windat/jpa/repository/UserSpringDataRepository.java index 371da9c..a6c7422 100644 --- a/outbound-repository-jpa/src/main/java/org/windat/jpa/repository/UserSpringDataRepository.java +++ b/outbound-repository-jpa/src/main/java/org/windat/jpa/repository/UserSpringDataRepository.java @@ -11,4 +11,6 @@ public interface UserSpringDataRepository extends JpaRepository { Optional findByKeycloakId(UUID keycloakId); List findTop10ByOrderByGamesWonDesc(); + + Optional findByCs2Username(String username); } diff --git a/outbound-repository-jpa/src/main/resources/persistence/orm.xml b/outbound-repository-jpa/src/main/resources/persistence/orm.xml index 8142e8a..e9992e0 100644 --- a/outbound-repository-jpa/src/main/resources/persistence/orm.xml +++ b/outbound-repository-jpa/src/main/resources/persistence/orm.xml @@ -30,6 +30,12 @@ + + + + + + @@ -68,6 +74,8 @@ + + From 2f985d557a02994ce7a35a1b3456a2b800a244bb Mon Sep 17 00:00:00 2001 From: Mykyta Prykhodko Date: Fri, 30 May 2025 01:49:32 +0200 Subject: [PATCH 11/11] Added new validations --- .keycloak/realms/realm-fsa.json | 2 +- .../main/resources/openapi/windatOpenApi.yaml | 19 +++++++++++++++ .../UserAlreadyHasLobbyWithNameException.java | 11 +++++++++ ...UserDoesNotHaveEnoughBalanceException.java | 11 +++++++++ .../ws/controller/LobbyRestController.java | 12 +++++++++- .../ws/controller/UserRestController.java | 14 +++++++++++ .../GlobalExceptionHandler.java | 24 +++++++++++++++++++ 7 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 domain/src/main/java/org/windat/domain/exceptions/UserAlreadyHasLobbyWithNameException.java create mode 100644 domain/src/main/java/org/windat/domain/exceptions/UserDoesNotHaveEnoughBalanceException.java diff --git a/.keycloak/realms/realm-fsa.json b/.keycloak/realms/realm-fsa.json index 553ea64..352fa76 100644 --- a/.keycloak/realms/realm-fsa.json +++ b/.keycloak/realms/realm-fsa.json @@ -1,6 +1,6 @@ [ { "id" : "83130e73-2e9d-416a-b998-6ff8db0e7935", - "realm" : "FSA", + "realm" : "WinDat", "notBefore" : 0, "defaultSignatureAlgorithm" : "RS256", "revokeRefreshToken" : false, diff --git a/api-spec/src/main/resources/openapi/windatOpenApi.yaml b/api-spec/src/main/resources/openapi/windatOpenApi.yaml index a97ea3c..bf84aac 100644 --- a/api-spec/src/main/resources/openapi/windatOpenApi.yaml +++ b/api-spec/src/main/resources/openapi/windatOpenApi.yaml @@ -317,6 +317,8 @@ paths: description: Lobby not found '409': description: User is already in the lobby + '412': + description: Not enough credits /lobbies/me: delete: summary: Remove authenticated user from their current lobby @@ -396,6 +398,23 @@ paths: description: User with provided username or email already exists '500': description: Internal server error + /users/me: + get: + summary: Get authenticated application user + operationId: getOneAuthenticatedApplicationUser + tags: + - Users + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '404': + description: User not found + '500': + description: Internal server error /users/leaderboard: get: summary: Get the top 10 users for the leaderboard, sorted by wins. diff --git a/domain/src/main/java/org/windat/domain/exceptions/UserAlreadyHasLobbyWithNameException.java b/domain/src/main/java/org/windat/domain/exceptions/UserAlreadyHasLobbyWithNameException.java new file mode 100644 index 0000000..d0e183d --- /dev/null +++ b/domain/src/main/java/org/windat/domain/exceptions/UserAlreadyHasLobbyWithNameException.java @@ -0,0 +1,11 @@ +package org.windat.domain.exceptions; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.CONFLICT) +public class UserAlreadyHasLobbyWithNameException extends RuntimeException { + public UserAlreadyHasLobbyWithNameException(String message) { + super(message); + } +} diff --git a/domain/src/main/java/org/windat/domain/exceptions/UserDoesNotHaveEnoughBalanceException.java b/domain/src/main/java/org/windat/domain/exceptions/UserDoesNotHaveEnoughBalanceException.java new file mode 100644 index 0000000..84edfc5 --- /dev/null +++ b/domain/src/main/java/org/windat/domain/exceptions/UserDoesNotHaveEnoughBalanceException.java @@ -0,0 +1,11 @@ +package org.windat.domain.exceptions; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.PRECONDITION_FAILED) // 412 +public class UserDoesNotHaveEnoughBalanceException extends RuntimeException { + public UserDoesNotHaveEnoughBalanceException(String message) { + super(message); + } +} diff --git a/inbound-controller-ws/src/main/java/org/windat/ws/controller/LobbyRestController.java b/inbound-controller-ws/src/main/java/org/windat/ws/controller/LobbyRestController.java index cea9c53..bbab127 100644 --- a/inbound-controller-ws/src/main/java/org/windat/ws/controller/LobbyRestController.java +++ b/inbound-controller-ws/src/main/java/org/windat/ws/controller/LobbyRestController.java @@ -3,7 +3,6 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; -import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.bind.annotation.RestController; import org.windat.domain.entity.CreditTransaction; import org.windat.domain.enums.TransactionType; @@ -83,6 +82,11 @@ public ResponseEntity addUserToLobby(Integer lobbyId) { return userFacade.create(newUser); }); +// Check if user is currently in different lobby + if (user.hasAnyLobby()){ + throw new UserAlreadyHasLobbyWithNameException("You are already in the lobby with name " + + user.getLobby().getName() + "."); + } // Check if lobby is full if (lobby.isFull()) { throw new LobbyFullException("Lobby is already full."); @@ -93,6 +97,12 @@ public ResponseEntity addUserToLobby(Integer lobbyId) { throw new UserAlreadyInLobbyException("User " + username + " is already in lobby."); } +// Check if user has enough credits to play in this lobby + if (user.getCredits() <= lobby.getAmount()) { + throw new UserDoesNotHaveEnoughBalanceException("You do not have enough credits to join this lobby. You need " + + (lobby.getAmount() - user.getCredits()) + " credits more."); + } + lobby.addUser(user); // Persist user updates diff --git a/inbound-controller-ws/src/main/java/org/windat/ws/controller/UserRestController.java b/inbound-controller-ws/src/main/java/org/windat/ws/controller/UserRestController.java index 7554f78..4bb90c7 100644 --- a/inbound-controller-ws/src/main/java/org/windat/ws/controller/UserRestController.java +++ b/inbound-controller-ws/src/main/java/org/windat/ws/controller/UserRestController.java @@ -12,6 +12,7 @@ import org.springframework.web.bind.annotation.RestController; import org.windat.domain.enums.UserRole; import org.windat.domain.entity.User; +import org.windat.domain.exceptions.UserNotFoundException; import org.windat.domain.service.CreditFacade; import org.windat.domain.service.UserFacade; import org.windat.rest.api.UsersApi; @@ -124,6 +125,19 @@ public ResponseEntity> getLeaderboard() { return ResponseEntity.ok(userDtos); } + @Override + public ResponseEntity getOneAuthenticatedApplicationUser() { + // Get user from jwt token + UserDto userDto = (UserDto) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + UUID keycloakId = userDto.getKeycloakId(); + + User user = userFacade.readOne(keycloakId).orElseThrow( + () -> new UserNotFoundException("User with keycloakId " + keycloakId + " not found") + ); + + return ResponseEntity.ok(userMapper.toDto(user)); + } + @Override public ResponseEntity transferCredits(CreditTransferRequestDto creditTransferRequestDto) { // Get user from jwt token diff --git a/inbound-controller-ws/src/main/java/org/windat/ws/exceptionHandlers/GlobalExceptionHandler.java b/inbound-controller-ws/src/main/java/org/windat/ws/exceptionHandlers/GlobalExceptionHandler.java index 2b982ba..612a6ca 100644 --- a/inbound-controller-ws/src/main/java/org/windat/ws/exceptionHandlers/GlobalExceptionHandler.java +++ b/inbound-controller-ws/src/main/java/org/windat/ws/exceptionHandlers/GlobalExceptionHandler.java @@ -77,6 +77,30 @@ public ResponseEntity handleUserNotInAnyLobbyException(UserIsNotInAnyLob return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST); } + @ExceptionHandler(UserDoesNotHaveEnoughBalanceException.class) + @ResponseStatus(HttpStatus.PRECONDITION_FAILED) // 412 + public ResponseEntity userDoesNotHaveEnoughCredits(UserDoesNotHaveEnoughBalanceException exception) { + Map body = new LinkedHashMap<>(); + body.put("timestamp", LocalDateTime.now()); + body.put("status", HttpStatus.PRECONDITION_FAILED.value()); + body.put("error", HttpStatus.PRECONDITION_FAILED.getReasonPhrase()); + body.put("message", exception.getMessage()); + + return new ResponseEntity<>(body, HttpStatus.PRECONDITION_FAILED); + } + + @ExceptionHandler(UserAlreadyHasLobbyWithNameException.class) + @ResponseStatus(HttpStatus.CONFLICT) // 409 + public ResponseEntity handleUserAlreadyHasLobbyWithNameException(UserAlreadyHasLobbyWithNameException exception) { + Map body = new LinkedHashMap<>(); + body.put("timestamp", LocalDateTime.now()); + body.put("status", HttpStatus.CONFLICT.value()); + body.put("error", HttpStatus.CONFLICT.getReasonPhrase()); + body.put("message", exception.getMessage()); + + return new ResponseEntity<>(body, HttpStatus.CONFLICT); + } + // General error handler // @ExceptionHandler(Exception.class) // @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)