diff --git a/backend/src/main/java/com/mentora/backend/chat/controller/ChatController.java b/backend/src/main/java/com/mentora/backend/chat/controller/ChatController.java index ee72f69..e8e1baf 100644 --- a/backend/src/main/java/com/mentora/backend/chat/controller/ChatController.java +++ b/backend/src/main/java/com/mentora/backend/chat/controller/ChatController.java @@ -1,21 +1,19 @@ package com.mentora.backend.chat.controller; -//import com.mentora.backend.auth.service.AuthService; import com.mentora.backend.chat.dto.ChatListDto; import com.mentora.backend.chat.dto.ChatRenameDto; +import com.mentora.backend.chat.dto.ChatRenameRequest; import com.mentora.backend.chat.dto.ChatResponseDto; -import com.mentora.backend.chat.dto.UserMessageDto; import com.mentora.backend.chat.service.ChatListService; import com.mentora.backend.chat.service.ChatService; -import org.springframework.http.MediaType; +import com.mentora.backend.security.CustomOAuth2UserService; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import org.springframework.web.multipart.MultipartFile; -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.util.List; import java.util.Map; -import java.util.NoSuchElementException; import java.util.UUID; @RestController @@ -23,62 +21,57 @@ public class ChatController { private final ChatService chatService; - // private final AuthService authService; + private final CustomOAuth2UserService oAuth2UserService; private final ChatListService chatListService; -// public ChatController(ChatService chatService, AuthService authService, ChatListService chatListService, AgentGateway agentGateway) { -// -// this.chatService = chatService; -// this.authService = authService; -// this.chatListService = chatListService; -// } + public ChatController(ChatService chatService, CustomOAuth2UserService oAuth2UserService, ChatListService chatListService) { + this.chatService = chatService; + this.chatListService = chatListService; + this.oAuth2UserService = oAuth2UserService; + } -// @GetMapping("/chats_history") -// public ResponseEntity>> getChatsHistory() { -// String currentUserId = authService.currentUserId(); -// Map> chatListMap = chatListService.getChatList(currentUserId); -// return ResponseEntity.ok(chatListMap); -// -// } + @GetMapping("/chats_history") + public ResponseEntity>> getChatsHistory() { + String currentUserId = oAuth2UserService.currentUserId(); + Map> chatListMap = chatListService.getChatList(currentUserId); + return ResponseEntity.ok(chatListMap); + } -// @GetMapping("/search") -// public ResponseEntity> searchChats(@RequestParam(required = false) String title) { -// String currentUserId = authService.currentUserId(); -// List searchResults = chatService.searchChatsByTitle(currentUserId, title); -// return ResponseEntity.ok(searchResults); -// } + @GetMapping("/search") + public ResponseEntity> searchChats(@RequestParam(required = false) String title) { + String currentUserId = oAuth2UserService.currentUserId(); + List searchResults = chatService.searchChatsByTitle(currentUserId, title); + return ResponseEntity.ok(searchResults); + } @DeleteMapping("/delete/{chatId}") - public ResponseEntity deleteChatId(@PathVariable UUID chatId) { - try { - chatService.DeleteChatById(chatId); - return ResponseEntity.noContent().build(); - } catch (NoSuchElementException e) { - return ResponseEntity.notFound().build(); - } + public ResponseEntity deleteChatId(@PathVariable @NotNull UUID chatId) { + String currentUserId = oAuth2UserService.currentUserId(); + chatService.deleteChatById(currentUserId, chatId); + return ResponseEntity.noContent().build(); } @PutMapping("/rename/{chatId}") - public ResponseEntity renameChat(@PathVariable UUID chatId, @RequestParam String newTitle) { - ChatRenameDto responseDto = chatService.ChatRenameById(chatId, newTitle); + public ResponseEntity renameChat( + @PathVariable @NotNull UUID chatId, + @Valid @RequestBody ChatRenameRequest request + ) { + String currentUserId = oAuth2UserService.currentUserId(); + ChatRenameDto responseDto = chatService.chatRenameById(currentUserId, chatId, request.getNewTitle()); return ResponseEntity.ok(responseDto); } + @GetMapping("/history/{chatId}") + public ResponseEntity getChatHistory(@PathVariable @NotNull UUID chatId) { + String currentUserId = oAuth2UserService.currentUserId(); + ChatResponseDto chatHistory = chatService.getChatHistoryById(currentUserId, chatId); + return ResponseEntity.ok(chatHistory); + } -// @GetMapping("/history/{chatId}") -// public ResponseEntity getChatHistory(@PathVariable UUID chatId) { -// String currentUserId = authService.currentUserId(); -// ChatResponseDto chatHistory = chatService.getChatHistoryById(currentUserId, chatId); -// return ResponseEntity.ok(chatHistory); -// } - -// @DeleteMapping("/deleteAllChats/") -// public ResponseEntity deleteAllChats() { -// String currentUserId = authService.currentUserId(); -// chatService.deleteAllChatsByUserId(currentUserId); -// return ResponseEntity.ok("All Chats Deleted Successfully"); -// } - - - -} + @DeleteMapping("/deleteAllChats/") + public ResponseEntity deleteAllChats() { + String currentUserId = oAuth2UserService.currentUserId(); + chatService.deleteAllChatsByUserId(currentUserId); + return ResponseEntity.ok("All Chats Deleted Successfully"); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/mentora/backend/chat/dto/ChatRenameRequest.java b/backend/src/main/java/com/mentora/backend/chat/dto/ChatRenameRequest.java new file mode 100644 index 0000000..41963e0 --- /dev/null +++ b/backend/src/main/java/com/mentora/backend/chat/dto/ChatRenameRequest.java @@ -0,0 +1,27 @@ +package com.mentora.backend.chat.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * DTO for chat rename requests with validation. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ChatRenameRequest { + + public static final String TITLE_PATTERN = "^[a-zA-Z0-9\\s\\-_.,!?()]+$"; + + @NotBlank(message = "Title cannot be blank") + @Size(min = 1, max = 255, message = "Title must be between 1 and 255 characters") + @Pattern( + regexp = ChatRenameRequest.TITLE_PATTERN, + message = "Title contains invalid characters. Only letters, numbers, spaces, and basic punctuation are allowed" + ) + private String newTitle; +} \ No newline at end of file diff --git a/backend/src/main/java/com/mentora/backend/chat/service/ChatService.java b/backend/src/main/java/com/mentora/backend/chat/service/ChatService.java index 8b4c66a..0228adb 100644 --- a/backend/src/main/java/com/mentora/backend/chat/service/ChatService.java +++ b/backend/src/main/java/com/mentora/backend/chat/service/ChatService.java @@ -1,20 +1,16 @@ package com.mentora.backend.chat.service; - import com.mentora.backend.chat.dto.ChatRenameDto; import com.mentora.backend.chat.dto.ChatResponseDto; -import com.mentora.backend.chat.dto.UserMessageDto; +import com.mentora.backend.chat.dto.ChatListDto; import com.mentora.backend.chat.model.Chat; import com.mentora.backend.chat.model.Message; import com.mentora.backend.chat.repository.ChatRepository; import com.mentora.backend.enums.MessageSender; -import com.mentora.backend.chat.dto.ChatListDto; +import com.mentora.backend.error.ChatNotFoundException; +import com.mentora.backend.error.UnauthorizedAccessException; import jakarta.transaction.Transactional; -import org.springframework.http.codec.ServerSentEvent; import org.springframework.stereotype.Service; -import org.springframework.web.multipart.MultipartFile; -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; -import reactor.core.publisher.Flux; import java.util.*; import java.util.stream.Collectors; @@ -36,9 +32,25 @@ private Chat createNewChat(String userId, String text) { } private Chat findExistingChat(UUID chatId) { - return chatRepository.findById(chatId).orElseThrow(() -> new NoSuchElementException("Chat not found.")); + return chatRepository.findById(chatId) + .orElseThrow(() -> new ChatNotFoundException(chatId)); } + /** + * Validates that the current user owns the chat. + * Throws UnauthorizedAccessException if the user doesn't own the chat. + * + * @param chat the chat to validate + * @param userId the current user's ID + * @throws UnauthorizedAccessException if user doesn't own the chat + */ + private void validateChatOwnership(Chat chat, String userId) { + if (!chat.getUserId().equals(userId)) { + throw new UnauthorizedAccessException( + "You don't have permission to access this chat" + ); + } + } private Message createUserMessage(String messageText, String modelName) { Message message = new Message(); @@ -64,35 +76,68 @@ private String generateTitleFromMessage(String messageText) { return String.join(" ", Arrays.copyOfRange(words, 0, Math.min(words.length, 3))); } + /** + * Deletes a chat by ID with authorization check. + * + * @param userId the current user's ID + * @param chatId the ID of the chat to delete + * @throws ChatNotFoundException if chat not found + * @throws UnauthorizedAccessException if user doesn't own the chat + */ @Transactional - public void DeleteChatById(UUID id) { - if (chatRepository.existsById(id)) { - chatRepository.deleteById(id); - } else { - throw new NoSuchElementException("Chat not found."); - } + public void deleteChatById(String userId, UUID chatId) { + Chat chat = chatRepository.findById(chatId) + .orElseThrow(() -> new ChatNotFoundException(chatId)); + + validateChatOwnership(chat, userId); + + chatRepository.delete(chat); } + /** + * Deletes all chats belonging to a user. + * + * @param userId the user's ID + */ @Transactional public void deleteAllChatsByUserId(String userId) { chatRepository.deleteAllByUserId(userId); } + /** + * Renames a chat with authorization check and validation. + * + * @param userId the current user's ID + * @param chatId the ID of the chat to rename + * @param newTitle the new title (already validated by controller) + * @return ChatRenameDto containing the updated chat info + * @throws ChatNotFoundException if chat not found + * @throws UnauthorizedAccessException if user doesn't own the chat + */ @Transactional - public ChatRenameDto ChatRenameById(UUID id, String title) { - if (title == null || title.trim().isEmpty()) { - throw new IllegalArgumentException("Title cannot be empty."); - } - Chat chat = chatRepository.findById(id).orElseThrow(() -> new NoSuchElementException("Chat not found.")); + public ChatRenameDto chatRenameById(String userId, UUID chatId, String newTitle) { + Chat chat = chatRepository.findById(chatId) + .orElseThrow(() -> new ChatNotFoundException(chatId)); + + validateChatOwnership(chat, userId); - chat.setTitle(title.trim()); + chat.setTitle(newTitle.trim()); chatRepository.save(chat); - return ChatRenameDto.builder().chatId(chat.getId()).newTitle(chat.getTitle()).build(); + return ChatRenameDto.builder() + .chatId(chat.getId()) + .newTitle(chat.getTitle()) + .build(); } + /** + * Searches chats by title for a specific user. + * + * @param userId the user's ID + * @param title the search query (optional) + * @return list of matching chats + */ public List searchChatsByTitle(String userId, String title) { - List chats; if (title == null || title.trim().isEmpty()) { @@ -104,19 +149,32 @@ public List searchChatsByTitle(String userId, String title) { chats = chatRepository.findByUserIdAndTitleContainingIgnoreCaseOrderByUpdatedAtDesc(userId, title); } - return chats.stream().map(chat -> ChatListDto.builder().chatId(chat.getId()).title(chat.getTitle()).build()).collect(Collectors.toList()); + return chats.stream() + .map(chat -> ChatListDto.builder() + .chatId(chat.getId()) + .title(chat.getTitle()) + .build()) + .collect(Collectors.toList()); } - + /** + * Gets chat history with authorization check. + * + * @param userId the current user's ID + * @param chatId the chat ID + * @return ChatResponseDto containing chat history + * @throws ChatNotFoundException if chat not found + * @throws UnauthorizedAccessException if user doesn't own the chat + */ public ChatResponseDto getChatHistoryById(String userId, UUID chatId) { - Chat chat = chatRepository.findById(chatId).orElseThrow(() -> new RuntimeException("Chat not found")); + Chat chat = chatRepository.findById(chatId) + .orElseThrow(() -> new ChatNotFoundException(chatId)); + validateChatOwnership(chat, userId); - if (!chat.getUserId().equals(userId)) { - throw new RuntimeException("Unauthorized access to chat"); - } - - return ChatResponseDto.builder().chatId(chat.getId()).newMessages(chat.getMessages()).build(); + return ChatResponseDto.builder() + .chatId(chat.getId()) + .newMessages(chat.getMessages()) + .build(); } - } \ No newline at end of file diff --git a/backend/src/main/java/com/mentora/backend/error/ChatNotFoundException.java b/backend/src/main/java/com/mentora/backend/error/ChatNotFoundException.java new file mode 100644 index 0000000..d392940 --- /dev/null +++ b/backend/src/main/java/com/mentora/backend/error/ChatNotFoundException.java @@ -0,0 +1,22 @@ +package com.mentora.backend.error; + +import java.util.UUID; + +/** + * Exception thrown when a chat is not found. + * This is used for 404 Not Found responses. + */ +public class ChatNotFoundException extends RuntimeException { + + public ChatNotFoundException(UUID chatId) { + super("Chat not found: " + chatId); + } + + public ChatNotFoundException(String message) { + super(message); + } + + public ChatNotFoundException(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/mentora/backend/error/GlobalExceptionHandler.java b/backend/src/main/java/com/mentora/backend/error/GlobalExceptionHandler.java index 49a7c43..33abfc0 100644 --- a/backend/src/main/java/com/mentora/backend/error/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/mentora/backend/error/GlobalExceptionHandler.java @@ -19,7 +19,15 @@ public class GlobalExceptionHandler { public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException exception) { log.debug("Validation error: {}", exception.getMessage()); - final var responseBody = ResponseError.builder().errorCode("VALIDATION_ERROR").errorMessages(exception.getFieldErrors().stream().map(fieldError -> ResponseError.ErrorMessage.builder().field(fieldError.getField()).message(fieldError.getDefaultMessage()).build()).toList()).build(); + final var responseBody = ResponseError.builder() + .errorCode("VALIDATION_ERROR") + .errorMessages(exception.getFieldErrors().stream() + .map(fieldError -> ResponseError.ErrorMessage.builder() + .field(fieldError.getField()) + .message(fieldError.getDefaultMessage()) + .build()) + .toList()) + .build(); return ResponseEntity.badRequest().body(responseBody); } @@ -28,18 +36,92 @@ public ResponseEntity handleMethodArgumentNotValidException(Metho public ResponseEntity handleFileValidationException(FileValidationException exception) { log.debug("File validation error: {}", exception.getMessage()); - final var responseBody = FileValidationErrorResponse.builder().error("FILE_VALIDATION_FAILED").message("Some files could not be processed").details(exception.getErrors().stream().map(error -> FileValidationErrorResponse.FileErrorDetail.builder().fileName(error.getFileName()).error(error.getErrorCode()).message(error.getMessage()).maxSize(error.getMaxSize()).build()).toList()).build(); + final var responseBody = FileValidationErrorResponse.builder() + .error("FILE_VALIDATION_FAILED") + .message("Some files could not be processed") + .details(exception.getErrors().stream() + .map(error -> FileValidationErrorResponse.FileErrorDetail.builder() + .fileName(error.getFileName()) + .error(error.getErrorCode()) + .message(error.getMessage()) + .maxSize(error.getMaxSize()) + .build()) + .toList()) + .build(); return ResponseEntity.badRequest().body(responseBody); } + @ExceptionHandler(UnauthorizedAccessException.class) + public ResponseEntity handleUnauthorizedAccessException(UnauthorizedAccessException exception) { + log.warn("Unauthorized access attempt: {}", exception.getMessage()); + + ResponseError responseBody = ResponseError.builder() + .errorCode("FORBIDDEN") + .errorMessages(List.of( + ResponseError.ErrorMessage.builder() + .field("authorization") + .message(exception.getMessage()) + .build() + )) + .build(); + + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(responseBody); + } + + @ExceptionHandler(ChatNotFoundException.class) + public ResponseEntity handleChatNotFoundException(ChatNotFoundException exception) { + log.debug("Chat not found: {}", exception.getMessage()); + + ResponseError responseBody = ResponseError.builder() + .errorCode("CHAT_NOT_FOUND") + .errorMessages(List.of( + ResponseError.ErrorMessage.builder() + .field("chatId") + .message(exception.getMessage()) + .build() + )) + .build(); + + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(responseBody); + } + + @ExceptionHandler(UserNotAuthenticatedException.class) + public ResponseEntity handleUserNotAuthenticatedException(UserNotAuthenticatedException exception) { + log.warn("Authentication required: {}", exception.getMessage()); + + ResponseError responseBody = ResponseError.builder() + .errorCode("AUTHENTICATION_REQUIRED") + .errorMessages(List.of( + ResponseError.ErrorMessage.builder() + .field("authentication") + .message(exception.getMessage() != null ? exception.getMessage() : "Authentication required") + .build() + )) + .build(); + + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(responseBody); + } + @ExceptionHandler(RestClientException.class) public ResponseEntity handleRestClientException(RestClientException exception) { log.error("REST client error: {}", exception.getMessage()); - String message = (exception instanceof HttpClientErrorException httpEx && !httpEx.getResponseBodyAsString().isBlank()) ? httpEx.getResponseBodyAsString() : (exception.getMessage() == null || exception.getMessage().isBlank()) ? "A REST client error occurred." : exception.getMessage(); - - ResponseError responseBody = ResponseError.builder().errorCode("REST_CLIENT_ERROR").errorMessages(List.of(ResponseError.ErrorMessage.builder().field("externalService").message(message).build())).build(); + String message = (exception instanceof HttpClientErrorException httpEx && !httpEx.getResponseBodyAsString().isBlank()) + ? httpEx.getResponseBodyAsString() + : (exception.getMessage() == null || exception.getMessage().isBlank()) + ? "A REST client error occurred." + : exception.getMessage(); + + ResponseError responseBody = ResponseError.builder() + .errorCode("REST_CLIENT_ERROR") + .errorMessages(List.of( + ResponseError.ErrorMessage.builder() + .field("externalService") + .message(message) + .build() + )) + .build(); return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(responseBody); } @@ -48,9 +130,19 @@ public ResponseEntity handleRestClientException(RestClientExcepti public ResponseEntity handleRuntimeException(RuntimeException exception) { log.error("Runtime error: {}", exception.getMessage(), exception); - String message = exception.getMessage() != null && !exception.getMessage().isBlank() ? exception.getMessage() : "An unexpected error occurred while processing your request."; - - ResponseError responseBody = ResponseError.builder().errorCode("PROCESSING_ERROR").errorMessages(List.of(ResponseError.ErrorMessage.builder().field("general").message(message).build())).build(); + String message = exception.getMessage() != null && !exception.getMessage().isBlank() + ? exception.getMessage() + : "An unexpected error occurred while processing your request."; + + ResponseError responseBody = ResponseError.builder() + .errorCode("PROCESSING_ERROR") + .errorMessages(List.of( + ResponseError.ErrorMessage.builder() + .field("general") + .message(message) + .build() + )) + .build(); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(responseBody); } diff --git a/backend/src/main/java/com/mentora/backend/error/UnauthorizedAccessException.java b/backend/src/main/java/com/mentora/backend/error/UnauthorizedAccessException.java new file mode 100644 index 0000000..cb17992 --- /dev/null +++ b/backend/src/main/java/com/mentora/backend/error/UnauthorizedAccessException.java @@ -0,0 +1,16 @@ +package com.mentora.backend.error; + +/** + * Exception thrown when a user attempts to access a resource they don't own. + * This is used for authorization failures (403 Forbidden). + */ +public class UnauthorizedAccessException extends RuntimeException { + + public UnauthorizedAccessException(String message) { + super(message); + } + + public UnauthorizedAccessException(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/mentora/backend/error/UserNotAuthenticatedException.java b/backend/src/main/java/com/mentora/backend/error/UserNotAuthenticatedException.java index 626bc50..53fc076 100644 --- a/backend/src/main/java/com/mentora/backend/error/UserNotAuthenticatedException.java +++ b/backend/src/main/java/com/mentora/backend/error/UserNotAuthenticatedException.java @@ -1,7 +1,19 @@ package com.mentora.backend.error; +/** + * Exception thrown when a user is not authenticated. + */ public class UserNotAuthenticatedException extends RuntimeException { + public UserNotAuthenticatedException() { - super("User is not authenticated"); + super("User not authenticated"); + } + + public UserNotAuthenticatedException(String message) { + super(message); + } + + public UserNotAuthenticatedException(String message, Throwable cause) { + super(message, cause); } -} +} \ No newline at end of file diff --git a/backend/src/main/java/com/mentora/backend/security/CustomOAuth2UserService.java b/backend/src/main/java/com/mentora/backend/security/CustomOAuth2UserService.java index 253803d..ec740ba 100644 --- a/backend/src/main/java/com/mentora/backend/security/CustomOAuth2UserService.java +++ b/backend/src/main/java/com/mentora/backend/security/CustomOAuth2UserService.java @@ -1,10 +1,15 @@ package com.mentora.backend.security; -import com.mentora.backend.user.User; +import com.mentora.backend.error.UserNotAuthenticatedException; import com.mentora.backend.user.UserRepository; +import com.mentora.backend.user.model.Users; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Service; @@ -26,14 +31,78 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic String email = oauth2User.getAttribute("email"); String picture = oauth2User.getAttribute("picture"); - User user = new User(); + Users user = userRepository.findById(id).orElse(new Users()); user.setId(id); - user.setName(name); user.setEmail(email); + user.setName(name); user.setPicture(picture); userRepository.save(user); return oauth2User; } -} + + public String currentUserId() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null || !authentication.isAuthenticated()) { + throw new UserNotAuthenticatedException(); + } + + if (!(authentication instanceof OAuth2AuthenticationToken oauthToken)) { + throw new UserNotAuthenticatedException(); + } + + Object principal = oauthToken.getPrincipal(); + if (!(principal instanceof OidcUser oidcUser)) { + throw new UserNotAuthenticatedException("Principal is not an OidcUser"); + } + + return oidcUser.getAttribute("sub"); + } + + public String currentUserName() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null || !authentication.isAuthenticated()) { + throw new UserNotAuthenticatedException(); + } + + if (!(authentication instanceof OAuth2AuthenticationToken oauthToken)) { + throw new UserNotAuthenticatedException(); + } + + Object principal = oauthToken.getPrincipal(); + if (!(principal instanceof OidcUser oidcUser)) { + throw new UserNotAuthenticatedException("Principal is not an OidcUser"); + } + + return oidcUser.getAttribute("name"); + } + + public String currentUserEmail() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null || !authentication.isAuthenticated()) { + throw new UserNotAuthenticatedException(); + } + + if (!(authentication instanceof OAuth2AuthenticationToken oauthToken)) { + throw new UserNotAuthenticatedException(); + } + + Object principal = oauthToken.getPrincipal(); + if (!(principal instanceof OidcUser oidcUser)) { + throw new UserNotAuthenticatedException("Principal is not an OidcUser"); + } + + // Try to get email from 'email' claim first, fallback to 'preferred_username' + String email = oidcUser.getAttribute("email"); + if (email != null && !email.isEmpty()) { + return email; + } + + // Fallback to preferred_username (may not always be an email) + return oidcUser.getAttribute("preferred_username"); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/mentora/backend/user/User.java b/backend/src/main/java/com/mentora/backend/user/User.java deleted file mode 100644 index 28bd1eb..0000000 --- a/backend/src/main/java/com/mentora/backend/user/User.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.mentora.backend.user; - -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import lombok.Getter; -import lombok.Setter; - -@Entity -@Table(name = "users") -@Getter -@Setter -public class User { - - @Id - private String id; - private String name; - private String email; - private String picture; -} diff --git a/backend/src/main/java/com/mentora/backend/user/UserRepository.java b/backend/src/main/java/com/mentora/backend/user/UserRepository.java index 53e5ad2..763555b 100644 --- a/backend/src/main/java/com/mentora/backend/user/UserRepository.java +++ b/backend/src/main/java/com/mentora/backend/user/UserRepository.java @@ -1,8 +1,9 @@ package com.mentora.backend.user; +import com.mentora.backend.user.model.Users; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @Repository -public interface UserRepository extends JpaRepository { +public interface UserRepository extends JpaRepository { } diff --git a/backend/src/main/java/com/mentora/backend/user/UserService.java b/backend/src/main/java/com/mentora/backend/user/UserService.java new file mode 100644 index 0000000..016ba64 --- /dev/null +++ b/backend/src/main/java/com/mentora/backend/user/UserService.java @@ -0,0 +1,98 @@ +package com.mentora.backend.user; + +import com.mentora.backend.user.model.Users; +import jakarta.transaction.Transactional; +import org.springframework.stereotype.Service; + +import java.util.NoSuchElementException; + +/** + * Service layer for User-related business logic. + * Handles streak management and user operations with proper transaction management. + */ +@Service +public class UserService { + + private final UserRepository userRepository; + + public UserService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + /** + * Updates the user's streak based on their activity. + * This method is transactional - changes are automatically persisted. + * + * @param userId the ID of the user + * @throws NoSuchElementException if user not found + */ + @Transactional + public void updateUserStreak(String userId) { + Users user = userRepository.findById(userId) + .orElseThrow(() -> new NoSuchElementException("User not found: " + userId)); + + user.updateStreak(); + } + + /** + * Uses a streak freeze for the user. + * This method is transactional - changes are automatically persisted. + * + * @param userId the ID of the user + * @throws NoSuchElementException if user not found + * @throws IllegalStateException if user has no streak freezes available + */ + @Transactional + public void useStreakFreeze(String userId) { + Users user = userRepository.findById(userId) + .orElseThrow(() -> new NoSuchElementException("User not found: " + userId)); + + if (!user.canUseStreakFreeze()) { + throw new IllegalStateException("No streak freezes available"); + } + + user.useStreakFreeze(); + } + + /** + * Gets the current streak count for a user. + * + * @param userId the ID of the user + * @return the current streak count + * @throws NoSuchElementException if user not found + */ + public Integer getCurrentStreak(String userId) { + Users user = userRepository.findById(userId) + .orElseThrow(() -> new NoSuchElementException("User not found: " + userId)); + + return user.getCurrentStreak(); + } + + /** + * Gets the longest streak count for a user. + * + * @param userId the ID of the user + * @return the longest streak count + * @throws NoSuchElementException if user not found + */ + public Integer getLongestStreak(String userId) { + Users user = userRepository.findById(userId) + .orElseThrow(() -> new NoSuchElementException("User not found: " + userId)); + + return user.getLongestStreak(); + } + + /** + * Gets the remaining streak freeze count for a user. + * + * @param userId the ID of the user + * @return the number of streak freezes available + * @throws NoSuchElementException if user not found + */ + public Integer getStreakFreezeCount(String userId) { + Users user = userRepository.findById(userId) + .orElseThrow(() -> new NoSuchElementException("User not found: " + userId)); + + return user.getStreakFreezeCount(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/mentora/backend/user/model/Users.java b/backend/src/main/java/com/mentora/backend/user/model/Users.java index e24dac2..74bac26 100644 --- a/backend/src/main/java/com/mentora/backend/user/model/Users.java +++ b/backend/src/main/java/com/mentora/backend/user/model/Users.java @@ -2,15 +2,12 @@ import jakarta.persistence.*; import jakarta.validation.constraints.NotNull; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; +import lombok.*; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.UpdateTimestamp; import java.time.LocalDate; import java.util.Date; -import java.util.UUID; @Entity @Table(name = "users") @@ -19,17 +16,16 @@ @AllArgsConstructor public class Users { @Id - @GeneratedValue(strategy = GenerationType.UUID) @Column(name = "id") @NotNull - private UUID id; + private String id; @Column(name = "email", unique = true) @NotNull private String email; - @Column(name = "username") - private String username; + @Column(name = "name") + private String name; @Column(name = "current_streak", nullable = false, columnDefinition = "integer default 0") private Integer currentStreak = 0; @@ -41,6 +37,12 @@ public class Users { private LocalDate lastActivityDate; @Column(name = "streak_freeze_count", nullable = false, columnDefinition = "integer default 0") + + /** + * The number of streak freezes available to the user. + *

+ * This value is currently hardcoded to 5. It can be fetched from configuration later on for flexibility. + */ private Integer streakFreezeCount = 5; @CreationTimestamp @@ -53,6 +55,8 @@ public class Users { @Temporal(TemporalType.TIMESTAMP) private Date updatedAt; + private String picture; + public void updateStreak() { LocalDate today = LocalDate.now();