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/.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 06f5d02..bf84aac 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
@@ -42,12 +47,37 @@ 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.
+ credits:
+ 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.
+ cs2Username:
+ type: string
+ description: Username in the game CounterStrike 2
UserRole:
type: string
enum:
@@ -58,6 +88,149 @@ components:
properties:
name:
type: string
+ amount:
+ type: integer
+ format: int32
+ UserCreateRequest:
+ type: object
+ required:
+ - username
+ - firstName
+ - lastName
+ - email
+ - password
+ - cs2Username
+ 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!
+ cs2Username:
+ type: string
+ 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
+ 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
+ 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'
+ WinnerResponse:
+ type: object
+ properties:
+ winnerUsername:
+ type: string
+ description: The ID of the primary user for this transaction.
+
paths:
/lobbies:
@@ -116,4 +289,265 @@ 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
+ '412':
+ description: Not enough credits
+ /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
+ 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
+ /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.
+ 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.
+ 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).
+ /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/pom.xml b/domain/pom.xml
index 3c55352..7b9506d 100644
--- a/domain/pom.xml
+++ b/domain/pom.xml
@@ -24,5 +24,17 @@
3.8.1
test
+
+ jakarta.transaction
+ jakarta.transaction-api
+
+
+ org.springframework
+ spring-web
+
+
+ org.springframework
+ spring-context
+
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/Lobby.java b/domain/src/main/java/org/windat/domain/entity/Lobby.java
index 55e7365..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.
@@ -67,6 +74,7 @@ public void addUser(User user){
throw new NullPointerException("User cannot be null");
}
userList.add(user);
+ user.setLobby(this);
}
/**
@@ -79,6 +87,7 @@ public void removeUser(User user){
throw new IllegalArgumentException("User cannot be null");
}
userList.remove(user);
+ user.setLobby(null);
}
/**
@@ -88,7 +97,16 @@ 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;
+ }
+
+ /**
+ * Checks if lobby already has specific user
+ *
+ * @return boolean
+ */
+ public boolean containsUser(User user){
+ return userList.contains(user);
}
/*
@@ -103,6 +121,10 @@ public String getName() {
return name;
}
+ public void setName(String name) {
+ this.name = name;
+ }
+
public List getUserList() {
return userList;
}
@@ -111,6 +133,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;
}
@@ -118,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 e8495e8..4d7cb09 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,9 @@
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;
/**
* Represents a user within the application.
@@ -15,6 +18,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.
*/
@@ -33,12 +42,29 @@ public class User {
*/
private Lobby lobby;
+ /**
+ * Represents user balance in the WinDat platform
+ */
+ private Integer credits;
+
+ private Integer gamesPlayed;
+
+ 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.
* Required for JPA entity instantiation.
*/
public User() {
+ this.credits = 10000;
+ this.gamesPlayed = 0;
+ this.gamesWon = 0;
+ this.gamesLost = 0;
}
/**
@@ -76,4 +102,127 @@ 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;
+ }
+
+ 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;
+ }
+
+ 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;
+ }
+
+ 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/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/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/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/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/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/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/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/repository/LobbyRepository.java b/domain/src/main/java/org/windat/domain/repository/LobbyRepository.java
index 5f17a91..943bc46 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,15 @@
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);
+
+ 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..0081198
--- /dev/null
+++ b/domain/src/main/java/org/windat/domain/repository/UserRepository.java
@@ -0,0 +1,24 @@
+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);
+
+ Optional readOne(Integer id);
+
+ Collection readBest10UsersByWins();
+
+ Optional readUserBySteamUsername(String steamUsername);
+}
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/domain/src/main/java/org/windat/domain/service/LobbyFacade.java b/domain/src/main/java/org/windat/domain/service/LobbyFacade.java
index 151e8c8..e1afe48 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,10 @@
public interface LobbyFacade {
Collection readAll();
+
+ 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 f36b7e6..e3caf19 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,32 @@ 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);
+ }
+
+ @Override
+ @Transactional
+ public Lobby update(Lobby lobby) {
+ return lobbyRepository.update(lobby);
+ }
}
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/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..801426c
--- /dev/null
+++ b/domain/src/main/java/org/windat/domain/service/UserFacade.java
@@ -0,0 +1,25 @@
+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);
+
+// Use this method to retrieve user from the application database
+ 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
new file mode 100644
index 0000000..9928b68
--- /dev/null
+++ b/domain/src/main/java/org/windat/domain/service/UserService.java
@@ -0,0 +1,57 @@
+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);
+ }
+
+ @Override
+ public Optional readOne(Integer id) {
+ return userRepository.readOne(id);
+ }
+
+ @Override
+ public Collection readBest10UsersByWins() {
+ return userRepository.readBest10UsersByWins();
+ }
+
+ @Override
+ public Optional readUserBySteamUsername(String steamUsername) {
+ return userRepository.readUserBySteamUsername(steamUsername);
+ }
+
+}
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/LobbyRestController.java b/inbound-controller-ws/src/main/java/org/windat/ws/controller/LobbyRestController.java
index 98ea199..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
@@ -1,38 +1,136 @@
package org.windat.ws.controller;
+import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.Authentication;
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.*;
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, DuelResultsApi, DuelWinnerApi {
private final LobbyFacade lobbyFacade;
private final LobbyMapper lobbyMapper;
+ private final CreditFacade creditFacade;
+
+ private final UserFacade userFacade;
+ private final UserMapper userMapper;
- public LobbyRestController(LobbyFacade lobbyFacade, LobbyMapper lobbyMapper) {
+ public LobbyRestController(
+ LobbyFacade lobbyFacade,
+ LobbyMapper lobbyMapper,
+ UserFacade userFacade,
+ UserMapper userMapper,
+ CreditFacade creditFacade
+ ) {
this.lobbyFacade = lobbyFacade;
this.lobbyMapper = lobbyMapper;
+ this.userFacade = userFacade;
+ this.userMapper = userMapper;
+ this.creditFacade = creditFacade;
+ }
+
+ /**
+ * 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 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.");
+ }
+
+// Check if user already exist in lobby
+ if (lobby.containsUser(user)) {
+ 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
+ userFacade.update(user);
+
+// Persist updated lobby
+ Lobby updatedLobby = lobbyFacade.update(lobby);
+
+ return ResponseEntity.ok(lobbyMapper.toDto(updatedLobby));
}
@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.status(HttpStatus.CREATED).body(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
@@ -50,4 +148,163 @@ 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));
+ }
+
+// 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
new file mode 100644
index 0000000..692a1d0
--- /dev/null
+++ b/inbound-controller-ws/src/main/java/org/windat/ws/controller/TransactionRestController.java
@@ -0,0 +1,100 @@
+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.CrossOrigin;
+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
new file mode 100644
index 0000000..4bb90c7
--- /dev/null
+++ b/inbound-controller-ws/src/main/java/org/windat/ws/controller/UserRestController.java
@@ -0,0 +1,156 @@
+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.security.core.context.SecurityContextHolder;
+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;
+import org.windat.rest.dto.CreditTransferRequestDto;
+import org.windat.rest.dto.UserCreateRequestDto;
+import org.windat.rest.dto.UserDto;
+import org.windat.ws.mapper.UserMapper;
+
+
+import java.util.*;
+import java.util.stream.Collectors;
+
+@RestController
+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,
+ 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();
+
+ 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.setCs2Username(userCreateRequestDto.getCs2Username());
+
+ 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);
+ }
+ }
+
+ @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 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
+ 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/exceptionHandlers/GlobalExceptionHandler.java b/inbound-controller-ws/src/main/java/org/windat/ws/exceptionHandlers/GlobalExceptionHandler.java
new file mode 100644
index 0000000..612a6ca
--- /dev/null
+++ b/inbound-controller-ws/src/main/java/org/windat/ws/exceptionHandlers/GlobalExceptionHandler.java
@@ -0,0 +1,118 @@
+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.*;
+
+import org.windat.domain.exceptions.LobbyNotFoundException;
+
+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