diff --git a/petshop-api.postman_collection.json b/petshop-api.postman_collection.json index 6ecec217..d95a5b86 100644 --- a/petshop-api.postman_collection.json +++ b/petshop-api.postman_collection.json @@ -167,7 +167,7 @@ ], "body": { "mode": "raw", - "raw": "{\n \"username\": \"newcustomer{{$timestamp}}\",\n \"password\": \"password123\",\n \"email\": \"new{{$timestamp}}@example.com\",\n \"fullName\": \"New Customer\"\n}" + "raw": "{\n \"username\": \"newcustomer{{$timestamp}}\",\n \"password\": \"password123\",\n \"email\": \"new{{$timestamp}}@example.com\",\n \"phone\": \"+1-555-01{{$randomInt}}\",\n \"fullName\": \"New Customer\"\n}" } }, "event": [ @@ -2987,6 +2987,111 @@ } ] }, + { + "name": "List Staff Users", + "request": { + "method": "GET", + "url": "{{baseUrl}}/api/v1/users?role=STAFF", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{adminToken}}", + "type": "text" + } + ] + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status code is 200', function () {", + " pm.response.to.have.status(200);", + "});", + "var jsonData = pm.response.json();", + "pm.test('All returned users are STAFF', function () {", + " pm.expect(jsonData.content.every(function (user) { return user.role === 'STAFF'; })).to.be.true;", + "});" + ] + } + } + ] + }, + { + "name": "List Admin Users", + "request": { + "method": "GET", + "url": "{{baseUrl}}/api/v1/users?role=ADMIN", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{adminToken}}", + "type": "text" + } + ] + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status code is 200', function () {", + " pm.response.to.have.status(200);", + "});", + "var jsonData = pm.response.json();", + "pm.test('All returned users are ADMIN', function () {", + " pm.expect(jsonData.content.every(function (user) { return user.role === 'ADMIN'; })).to.be.true;", + "});" + ] + } + } + ] + }, + { + "name": "List Customer Users", + "request": { + "method": "GET", + "url": "{{baseUrl}}/api/v1/users?role=CUSTOMER", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{adminToken}}", + "type": "text" + } + ] + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status code is 200', function () {", + " pm.response.to.have.status(200);", + "});", + "var jsonData = pm.response.json();", + "pm.test('All returned users are CUSTOMER', function () {", + " pm.expect(jsonData.content.every(function (user) { return user.role === 'CUSTOMER'; })).to.be.true;", + "});" + ] + } + } + ] + }, { "name": "Get User", "request": { diff --git a/src/main/java/com/petshop/backend/config/DataInitializer.java b/src/main/java/com/petshop/backend/config/DataInitializer.java index cff00f6c..4a8c7470 100644 --- a/src/main/java/com/petshop/backend/config/DataInitializer.java +++ b/src/main/java/com/petshop/backend/config/DataInitializer.java @@ -35,6 +35,7 @@ public class DataInitializer implements CommandLineRunner { admin.setPassword(passwordEncoder.encode("admin123")); admin.setEmail("admin@petshop.com"); admin.setFullName("Admin User"); + admin.setPhone("000-000-1000"); admin.setRole(User.Role.ADMIN); admin.setActive(true); admin = userRepository.save(admin); @@ -51,6 +52,10 @@ public class DataInitializer implements CommandLineRunner { admin.setEmail("admin@petshop.com"); updated = true; } + if (admin.getPhone() == null || admin.getPhone().isEmpty()) { + admin.setPhone("000-000-1000"); + updated = true; + } if (admin.getActive() == null) { admin.setActive(true); updated = true; @@ -75,6 +80,7 @@ public class DataInitializer implements CommandLineRunner { staff.setPassword(passwordEncoder.encode("staff123")); staff.setEmail("staff@petshop.com"); staff.setFullName("Staff User"); + staff.setPhone("000-000-1001"); staff.setRole(User.Role.STAFF); staff.setActive(true); staff = userRepository.save(staff); @@ -91,6 +97,10 @@ public class DataInitializer implements CommandLineRunner { staff.setEmail("staff@petshop.com"); updated = true; } + if (staff.getPhone() == null || staff.getPhone().isEmpty()) { + staff.setPhone("000-000-1001"); + updated = true; + } if (staff.getActive() == null) { staff.setActive(true); updated = true; @@ -115,6 +125,7 @@ public class DataInitializer implements CommandLineRunner { customer.setPassword(passwordEncoder.encode("customer123")); customer.setEmail("customer@petshop.com"); customer.setFullName("Test Customer"); + customer.setPhone("000-000-1002"); customer.setRole(User.Role.CUSTOMER); customer.setActive(true); customer = userRepository.save(customer); @@ -131,6 +142,10 @@ public class DataInitializer implements CommandLineRunner { customer.setEmail("customer@petshop.com"); updated = true; } + if (customer.getPhone() == null || customer.getPhone().isEmpty()) { + customer.setPhone("000-000-1002"); + updated = true; + } if (customer.getActive() == null) { customer.setActive(true); updated = true; diff --git a/src/main/java/com/petshop/backend/config/TomcatPathToleranceConfig.java b/src/main/java/com/petshop/backend/config/TomcatPathToleranceConfig.java new file mode 100644 index 00000000..9a89c5ab --- /dev/null +++ b/src/main/java/com/petshop/backend/config/TomcatPathToleranceConfig.java @@ -0,0 +1,19 @@ +package com.petshop.backend.config; + +import org.springframework.boot.tomcat.servlet.TomcatServletWebServerFactory; +import org.springframework.boot.web.server.WebServerFactoryCustomizer; +import org.springframework.stereotype.Component; + +@Component +public class TomcatPathToleranceConfig implements WebServerFactoryCustomizer { + + @Override + public void customize(TomcatServletWebServerFactory factory) { + factory.addConnectorCustomizers(connector -> { + connector.setAllowBackslash(true); + connector.setEncodedReverseSolidusHandling("decode"); + connector.setProperty("relaxedPathChars", "\\"); + connector.setProperty("relaxedQueryChars", "\\"); + }); + } +} diff --git a/src/main/java/com/petshop/backend/config/TrailingSlashNormalizationFilter.java b/src/main/java/com/petshop/backend/config/TrailingSlashNormalizationFilter.java new file mode 100644 index 00000000..38ececb9 --- /dev/null +++ b/src/main/java/com/petshop/backend/config/TrailingSlashNormalizationFilter.java @@ -0,0 +1,91 @@ +package com.petshop.backend.config; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class TrailingSlashNormalizationFilter extends OncePerRequestFilter { + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String requestUri = request.getRequestURI(); + if (requestUri == null || requestUri.isBlank()) { + return true; + } + return requestUri.equals(normalizePath(requestUri)); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + String normalizedUri = normalizePath(request.getRequestURI()); + String normalizedServletPath = normalizePath(request.getServletPath()); + String normalizedPathInfo = normalizePath(request.getPathInfo()); + + HttpServletRequestWrapper wrapper = new HttpServletRequestWrapper(request) { + @Override + public String getRequestURI() { + return normalizedUri; + } + + @Override + public StringBuffer getRequestURL() { + String original = super.getRequestURL().toString(); + int schemeSeparator = original.indexOf("://"); + int pathStart = schemeSeparator >= 0 ? original.indexOf('/', schemeSeparator + 3) : original.indexOf('/'); + if (pathStart < 0) { + return new StringBuffer(original); + } + String prefix = original.substring(0, pathStart); + return new StringBuffer(prefix + normalizedUri); + } + + @Override + public String getServletPath() { + return normalizedServletPath; + } + + @Override + public String getPathInfo() { + return normalizedPathInfo; + } + }; + + filterChain.doFilter(wrapper, response); + } + + private String normalizePath(String value) { + if (value == null) { + return null; + } + String normalized = value.replace('\\', '/'); + while (normalized.contains("//")) { + normalized = normalized.replace("//", "/"); + } + if (shouldLowercase(normalized)) { + normalized = normalized.toLowerCase(java.util.Locale.ROOT); + } + int end = normalized.length(); + while (end > 1 && normalized.charAt(end - 1) == '/') { + end--; + } + return normalized.substring(0, end); + } + + private boolean shouldLowercase(String path) { + String lower = path.toLowerCase(java.util.Locale.ROOT); + return lower.startsWith("/api/") + || lower.equals("/api") + || lower.startsWith("/ws/") + || lower.equals("/ws"); + } +} diff --git a/src/main/java/com/petshop/backend/config/WebSocketAuthChannelInterceptor.java b/src/main/java/com/petshop/backend/config/WebSocketAuthChannelInterceptor.java index b62dfe34..c7f23fc4 100644 --- a/src/main/java/com/petshop/backend/config/WebSocketAuthChannelInterceptor.java +++ b/src/main/java/com/petshop/backend/config/WebSocketAuthChannelInterceptor.java @@ -5,6 +5,7 @@ import com.petshop.backend.repository.UserRepository; import com.petshop.backend.security.AppPrincipal; import com.petshop.backend.security.JwtUtil; import com.petshop.backend.service.ChatService; +import io.jsonwebtoken.JwtException; import org.springframework.messaging.Message; import org.springframework.messaging.MessageChannel; import org.springframework.messaging.simp.stomp.StompCommand; @@ -14,7 +15,10 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticatio import org.springframework.stereotype.Component; import java.security.Principal; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; +import java.util.Map; @Component public class WebSocketAuthChannelInterceptor implements ChannelInterceptor { @@ -45,7 +49,7 @@ public class WebSocketAuthChannelInterceptor implements ChannelInterceptor { throw new IllegalArgumentException("Missing websocket token"); } - Long userId = jwtUtil.extractUserId(token); + Long userId = extractUserId(token); User user = userId == null ? null : userRepository.findById(userId).orElse(null); if (user == null) { throw new IllegalArgumentException("User not found"); @@ -73,15 +77,15 @@ public class WebSocketAuthChannelInterceptor implements ChannelInterceptor { return message; } + if (StompCommand.DISCONNECT.equals(command) || StompCommand.UNSUBSCRIBE.equals(command)) { + return message; + } + User user = resolveUser(accessor.getUser(), accessor); if (user == null) { throw new IllegalArgumentException("Unauthenticated websocket session"); } - if (StompCommand.DISCONNECT.equals(command) || StompCommand.UNSUBSCRIBE.equals(command)) { - return message; - } - if (StompCommand.SUBSCRIBE.equals(command)) { authorizeSubscription(accessor.getDestination(), user); } else if (StompCommand.SEND.equals(command)) { @@ -118,15 +122,22 @@ public class WebSocketAuthChannelInterceptor implements ChannelInterceptor { return null; } - Long userId = jwtUtil.extractUserId(token); + Long userId = extractUserId(token); User user = userId == null ? null : userRepository.findById(userId).orElse(null); - if (user == null || user.getActive() == null || !user.getActive() || !jwtUtil.validateToken(token, user)) { + if (user == null) { throw new IllegalArgumentException("User not found"); } + if (user.getActive() == null || !user.getActive()) { + throw new IllegalArgumentException("User account is inactive"); + } + if (!jwtUtil.validateToken(token, user)) { + throw new IllegalArgumentException("Invalid websocket token"); + } return user; } private void authorizeSubscription(String destination, User user) { + destination = normalizeDestination(destination); if (destination == null || destination.startsWith("/user/queue/")) { return; } @@ -147,6 +158,7 @@ public class WebSocketAuthChannelInterceptor implements ChannelInterceptor { } private void authorizeSend(String destination, User user) { + destination = normalizeDestination(destination); Long conversationId = extractConversationId(destination, "/app/chat/conversations/"); if (conversationId != null && destination.endsWith("/messages") && chatService.hasConversationAccess(conversationId, user.getId(), user.getRole())) { return; @@ -175,13 +187,51 @@ public class WebSocketAuthChannelInterceptor implements ChannelInterceptor { private String firstHeader(StompHeaderAccessor accessor, String name) { List values = accessor.getNativeHeader(name); - return values == null || values.isEmpty() ? null : values.get(0); + if (values != null && !values.isEmpty()) { + return values.get(0); + } + for (String headerName : accessor.toNativeHeaderMap().keySet()) { + if (headerName.equalsIgnoreCase(name)) { + List alternateValues = accessor.getNativeHeader(headerName); + return alternateValues == null || alternateValues.isEmpty() ? null : alternateValues.get(0); + } + } + return null; } private String extractToken(String rawValue) { if (rawValue == null || rawValue.isBlank()) { return null; } - return rawValue.startsWith("Bearer ") ? rawValue.substring(7) : rawValue; + String normalized = rawValue.trim(); + return normalized.regionMatches(true, 0, "Bearer ", 0, 7) ? normalized.substring(7) : normalized; + } + + private String normalizeDestination(String destination) { + if (destination == null || destination.isBlank()) { + return destination; + } + String normalized = destination.replace('\\', '/'); + while (normalized.contains("//")) { + normalized = normalized.replace("//", "/"); + } + return normalized.toLowerCase(Locale.ROOT); + } + + private Long extractUserId(String token) { + try { + return jwtUtil.extractUserId(token); + } catch (JwtException | IllegalArgumentException ex) { + throw new IllegalArgumentException("Invalid websocket token: " + ex.getMessage(), ex); + } + } + + public Map buildErrorPayload(Exception ex, String destination, Principal principal) { + Map response = new LinkedHashMap<>(); + response.put("message", ex.getMessage() == null || ex.getMessage().isBlank() ? "WebSocket request failed" : ex.getMessage()); + response.put("details", ex.getClass().getSimpleName()); + response.put("destination", normalizeDestination(destination)); + response.put("authenticated", principal != null); + return response; } } diff --git a/src/main/java/com/petshop/backend/config/WebSocketConfig.java b/src/main/java/com/petshop/backend/config/WebSocketConfig.java index 27526bec..67dc1048 100644 --- a/src/main/java/com/petshop/backend/config/WebSocketConfig.java +++ b/src/main/java/com/petshop/backend/config/WebSocketConfig.java @@ -33,8 +33,13 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws/chat") .setAllowedOriginPatterns("*"); + registry.addEndpoint("/ws/chat/") + .setAllowedOriginPatterns("*"); registry.addEndpoint("/ws/chat-sockjs") .setAllowedOriginPatterns("*") .withSockJS(); + registry.addEndpoint("/ws/chat-sockjs/") + .setAllowedOriginPatterns("*") + .withSockJS(); } } diff --git a/src/main/java/com/petshop/backend/controller/AuthController.java b/src/main/java/com/petshop/backend/controller/AuthController.java index 717ca7fa..2bd2b47d 100644 --- a/src/main/java/com/petshop/backend/controller/AuthController.java +++ b/src/main/java/com/petshop/backend/controller/AuthController.java @@ -74,11 +74,19 @@ public class AuthController { return ResponseEntity.status(HttpStatus.CONFLICT).body(error); } + String phone = trimToNull(request.getPhone()); + if (phone != null && userRepository.findByPhone(phone).isPresent()) { + Map error = new HashMap<>(); + error.put("message", "Phone already exists"); + return ResponseEntity.status(HttpStatus.CONFLICT).body(error); + } + User user = new User(); user.setUsername(request.getUsername()); user.setPassword(passwordEncoder.encode(request.getPassword())); user.setEmail(request.getEmail()); user.setFullName(request.getFullName()); + user.setPhone(phone); user.setRole(User.Role.CUSTOMER); user.setActive(true); @@ -93,6 +101,7 @@ public class AuthController { savedUser.getId(), savedUser.getUsername(), savedUser.getEmail(), + savedUser.getPhone(), savedUser.getRole().name(), token )); @@ -145,6 +154,7 @@ public class AuthController { user.getUsername(), user.getEmail(), user.getFullName(), + user.getPhone(), user.getAvatarUrl(), user.getRole().name(), employeeStore != null ? employeeStore.getStore().getStoreId() : null, @@ -180,6 +190,20 @@ public class AuthController { user.setFullName(request.getFullName()); } + if (request.getPhone() != null) { + String phone = trimToNull(request.getPhone()); + if (!java.util.Objects.equals(phone, user.getPhone())) { + if (phone != null && userRepository.findByPhone(phone) + .filter(existing -> !existing.getId().equals(user.getId())) + .isPresent()) { + Map error = new HashMap<>(); + error.put("message", "Phone already exists"); + return ResponseEntity.status(HttpStatus.CONFLICT).body(error); + } + user.setPhone(phone); + } + } + if (request.getPassword() != null && !request.getPassword().isEmpty()) { user.setPassword(passwordEncoder.encode(request.getPassword())); invalidateToken = true; @@ -190,6 +214,7 @@ public class AuthController { } User updatedUser = userRepository.save(user); + userBusinessLinkageService.syncLinkedRecords(updatedUser); EmployeeStore employeeStore = resolveEmployeeStore(updatedUser); @@ -198,6 +223,7 @@ public class AuthController { updatedUser.getUsername(), updatedUser.getEmail(), updatedUser.getFullName(), + updatedUser.getPhone(), updatedUser.getAvatarUrl(), updatedUser.getRole().name(), employeeStore != null ? employeeStore.getStore().getStoreId() : null, @@ -215,6 +241,14 @@ public class AuthController { .orElse(null); } + private String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + @PostMapping("/me/avatar") public ResponseEntity uploadAvatar(@RequestParam("avatar") MultipartFile file) { User user = getAuthenticatedUser(); diff --git a/src/main/java/com/petshop/backend/controller/ChatWebSocketController.java b/src/main/java/com/petshop/backend/controller/ChatWebSocketController.java index d7f1e3b6..ed0a3718 100644 --- a/src/main/java/com/petshop/backend/controller/ChatWebSocketController.java +++ b/src/main/java/com/petshop/backend/controller/ChatWebSocketController.java @@ -1,5 +1,6 @@ package com.petshop.backend.controller; +import com.petshop.backend.config.WebSocketAuthChannelInterceptor; import com.petshop.backend.dto.chat.MessageRequest; import com.petshop.backend.dto.chat.MessageResponse; import com.petshop.backend.entity.User; @@ -10,6 +11,7 @@ import com.petshop.backend.service.ChatRealtimeService; import com.petshop.backend.service.ChatService; import jakarta.validation.Valid; import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.MessageExceptionHandler; import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.handler.annotation.Payload; import org.springframework.messaging.simp.SimpMessageHeaderAccessor; @@ -17,6 +19,9 @@ import org.springframework.messaging.simp.annotation.SendToUser; import org.springframework.stereotype.Controller; import java.security.Principal; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; @Controller public class ChatWebSocketController { @@ -25,12 +30,20 @@ public class ChatWebSocketController { private final ChatRealtimeService chatRealtimeService; private final UserRepository userRepository; private final JwtUtil jwtUtil; + private final WebSocketAuthChannelInterceptor webSocketAuthChannelInterceptor; - public ChatWebSocketController(ChatService chatService, ChatRealtimeService chatRealtimeService, UserRepository userRepository, JwtUtil jwtUtil) { + public ChatWebSocketController( + ChatService chatService, + ChatRealtimeService chatRealtimeService, + UserRepository userRepository, + JwtUtil jwtUtil, + WebSocketAuthChannelInterceptor webSocketAuthChannelInterceptor + ) { this.chatService = chatService; this.chatRealtimeService = chatRealtimeService; this.userRepository = userRepository; this.jwtUtil = jwtUtil; + this.webSocketAuthChannelInterceptor = webSocketAuthChannelInterceptor; } @MessageMapping("/chat/conversations/{id}/messages") @@ -42,6 +55,12 @@ public class ChatWebSocketController { chatRealtimeService.publishConversationUpdate(id); } + @MessageExceptionHandler({IllegalArgumentException.class, RuntimeException.class}) + @SendToUser("/queue/chat/errors") + public Map handleMessageException(Exception ex, SimpMessageHeaderAccessor headerAccessor) { + return webSocketAuthChannelInterceptor.buildErrorPayload(ex, headerAccessor.getDestination(), headerAccessor.getUser()); + } + private User resolveUser(SimpMessageHeaderAccessor headerAccessor) { Principal principal = headerAccessor.getUser(); if (principal instanceof org.springframework.security.authentication.UsernamePasswordAuthenticationToken authenticationToken @@ -55,20 +74,50 @@ public class ChatWebSocketController { .orElseThrow(() -> new IllegalArgumentException("User not found")); } - String tokenHeader = headerAccessor.getFirstNativeHeader("Authorization"); + String tokenHeader = firstHeader(headerAccessor, "Authorization"); if (tokenHeader == null || tokenHeader.isBlank()) { - tokenHeader = headerAccessor.getFirstNativeHeader("token"); + tokenHeader = firstHeader(headerAccessor, "token"); } if (tokenHeader == null || tokenHeader.isBlank()) { throw new IllegalArgumentException("User not authenticated"); } - String token = tokenHeader.startsWith("Bearer ") ? tokenHeader.substring(7) : tokenHeader; - Long userId = jwtUtil.extractUserId(token); + String token = extractToken(tokenHeader); + Long userId; + try { + userId = jwtUtil.extractUserId(token); + } catch (RuntimeException ex) { + throw new IllegalArgumentException("Invalid websocket token", ex); + } User user = userId == null ? null : userRepository.findById(userId).orElse(null); - if (user == null || user.getActive() == null || !user.getActive() || !jwtUtil.validateToken(token, user)) { + if (user == null) { throw new IllegalArgumentException("User not found"); } + if (user.getActive() == null || !user.getActive()) { + throw new IllegalArgumentException("User account is inactive"); + } + if (!jwtUtil.validateToken(token, user)) { + throw new IllegalArgumentException("Invalid websocket token"); + } return user; } + + private String firstHeader(SimpMessageHeaderAccessor headerAccessor, String name) { + List values = headerAccessor.getNativeHeader(name); + if (values != null && !values.isEmpty()) { + return values.get(0); + } + Map> headers = headerAccessor.toNativeHeaderMap(); + for (Map.Entry> entry : headers.entrySet()) { + if (entry.getKey().equalsIgnoreCase(name)) { + return entry.getValue() == null || entry.getValue().isEmpty() ? null : entry.getValue().get(0); + } + } + return null; + } + + private String extractToken(String rawValue) { + String normalized = rawValue.trim(); + return normalized.regionMatches(true, 0, "Bearer ", 0, 7) ? normalized.substring(7) : normalized; + } } diff --git a/src/main/java/com/petshop/backend/controller/EmployeeController.java b/src/main/java/com/petshop/backend/controller/EmployeeController.java new file mode 100644 index 00000000..1c567623 --- /dev/null +++ b/src/main/java/com/petshop/backend/controller/EmployeeController.java @@ -0,0 +1,49 @@ +package com.petshop.backend.controller; + +import com.petshop.backend.dto.employee.EmployeeRequest; +import com.petshop.backend.dto.employee.EmployeeResponse; +import com.petshop.backend.service.EmployeeService; +import jakarta.validation.Valid; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/employees") +@PreAuthorize("hasRole('ADMIN')") +public class EmployeeController { + private final EmployeeService employeeService; + + public EmployeeController(EmployeeService employeeService) { + this.employeeService = employeeService; + } + + @GetMapping + public ResponseEntity> getAllEmployees(@RequestParam(required = false) String q, Pageable pageable) { + return ResponseEntity.ok(employeeService.getAllEmployees(q, pageable)); + } + + @GetMapping("/{id}") + public ResponseEntity getEmployeeById(@PathVariable Long id) { + return ResponseEntity.ok(employeeService.getEmployeeById(id)); + } + + @PostMapping + public ResponseEntity createEmployee(@Valid @RequestBody EmployeeRequest request) { + return ResponseEntity.status(HttpStatus.CREATED).body(employeeService.createEmployee(request)); + } + + @PutMapping("/{id}") + public ResponseEntity updateEmployee(@PathVariable Long id, @Valid @RequestBody EmployeeRequest request) { + return ResponseEntity.ok(employeeService.updateEmployee(id, request)); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteEmployee(@PathVariable Long id) { + employeeService.deleteEmployee(id); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/petshop/backend/controller/UserController.java b/src/main/java/com/petshop/backend/controller/UserController.java index b1ece730..8f7e07c3 100644 --- a/src/main/java/com/petshop/backend/controller/UserController.java +++ b/src/main/java/com/petshop/backend/controller/UserController.java @@ -3,6 +3,7 @@ package com.petshop.backend.controller; import com.petshop.backend.dto.common.BulkDeleteRequest; import com.petshop.backend.dto.user.UserRequest; import com.petshop.backend.dto.user.UserResponse; +import com.petshop.backend.entity.User; import com.petshop.backend.service.UserService; import jakarta.validation.Valid; import org.springframework.data.domain.Page; @@ -26,8 +27,9 @@ public class UserController { @GetMapping public ResponseEntity> getAllUsers( @RequestParam(required = false) String q, + @RequestParam(required = false) String role, Pageable pageable) { - return ResponseEntity.ok(userService.getAllUsers(q, pageable)); + return ResponseEntity.ok(userService.getAllUsers(q, role, pageable)); } @GetMapping("/{id}") diff --git a/src/main/java/com/petshop/backend/dto/auth/ProfileUpdateRequest.java b/src/main/java/com/petshop/backend/dto/auth/ProfileUpdateRequest.java index ae7d6270..58959678 100644 --- a/src/main/java/com/petshop/backend/dto/auth/ProfileUpdateRequest.java +++ b/src/main/java/com/petshop/backend/dto/auth/ProfileUpdateRequest.java @@ -14,6 +14,9 @@ public class ProfileUpdateRequest { @Size(max = 100, message = "Full name must not exceed 100 characters") private String fullName; + @Size(max = 20, message = "Phone must not exceed 20 characters") + private String phone; + @Size(min = 6, message = "Password must be at least 6 characters") private String password; @@ -41,6 +44,14 @@ public class ProfileUpdateRequest { this.fullName = fullName; } + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + public String getPassword() { return password; } @@ -57,12 +68,13 @@ public class ProfileUpdateRequest { return Objects.equals(username, that.username) && Objects.equals(email, that.email) && Objects.equals(fullName, that.fullName) && + Objects.equals(phone, that.phone) && Objects.equals(password, that.password); } @Override public int hashCode() { - return Objects.hash(username, email, fullName, password); + return Objects.hash(username, email, fullName, phone, password); } @Override @@ -71,6 +83,7 @@ public class ProfileUpdateRequest { "username='" + username + '\'' + ", email='" + email + '\'' + ", fullName='" + fullName + '\'' + + ", phone='" + phone + '\'' + ", password='" + password + '\'' + '}'; } diff --git a/src/main/java/com/petshop/backend/dto/auth/RegisterRequest.java b/src/main/java/com/petshop/backend/dto/auth/RegisterRequest.java index 07775bad..2791746c 100644 --- a/src/main/java/com/petshop/backend/dto/auth/RegisterRequest.java +++ b/src/main/java/com/petshop/backend/dto/auth/RegisterRequest.java @@ -22,6 +22,10 @@ public class RegisterRequest { @Size(max = 100, message = "Full name must not exceed 100 characters") private String fullName; + @NotBlank(message = "Phone is required") + @Size(max = 20, message = "Phone must not exceed 20 characters") + private String phone; + public String getUsername() { return username; } @@ -54,6 +58,14 @@ public class RegisterRequest { this.fullName = fullName; } + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -62,12 +74,13 @@ public class RegisterRequest { return Objects.equals(username, that.username) && Objects.equals(password, that.password) && Objects.equals(email, that.email) && - Objects.equals(fullName, that.fullName); + Objects.equals(fullName, that.fullName) && + Objects.equals(phone, that.phone); } @Override public int hashCode() { - return Objects.hash(username, password, email, fullName); + return Objects.hash(username, password, email, fullName, phone); } @Override @@ -77,6 +90,7 @@ public class RegisterRequest { ", password='" + password + '\'' + ", email='" + email + '\'' + ", fullName='" + fullName + '\'' + + ", phone='" + phone + '\'' + '}'; } } diff --git a/src/main/java/com/petshop/backend/dto/auth/RegisterResponse.java b/src/main/java/com/petshop/backend/dto/auth/RegisterResponse.java index b31370cb..7e016985 100644 --- a/src/main/java/com/petshop/backend/dto/auth/RegisterResponse.java +++ b/src/main/java/com/petshop/backend/dto/auth/RegisterResponse.java @@ -6,16 +6,18 @@ public class RegisterResponse { private Long id; private String username; private String email; + private String phone; private String role; private String token; public RegisterResponse() { } - public RegisterResponse(Long id, String username, String email, String role, String token) { + public RegisterResponse(Long id, String username, String email, String phone, String role, String token) { this.id = id; this.username = username; this.email = email; + this.phone = phone; this.role = role; this.token = token; } @@ -44,6 +46,14 @@ public class RegisterResponse { this.email = email; } + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + public String getRole() { return role; } @@ -68,13 +78,14 @@ public class RegisterResponse { return Objects.equals(id, that.id) && Objects.equals(username, that.username) && Objects.equals(email, that.email) && + Objects.equals(phone, that.phone) && Objects.equals(role, that.role) && Objects.equals(token, that.token); } @Override public int hashCode() { - return Objects.hash(id, username, email, role, token); + return Objects.hash(id, username, email, phone, role, token); } @Override @@ -83,6 +94,7 @@ public class RegisterResponse { "id=" + id + ", username='" + username + '\'' + ", email='" + email + '\'' + + ", phone='" + phone + '\'' + ", role='" + role + '\'' + ", token='" + token + '\'' + '}'; diff --git a/src/main/java/com/petshop/backend/dto/auth/UserInfoResponse.java b/src/main/java/com/petshop/backend/dto/auth/UserInfoResponse.java index 1f15daf8..ba714a49 100644 --- a/src/main/java/com/petshop/backend/dto/auth/UserInfoResponse.java +++ b/src/main/java/com/petshop/backend/dto/auth/UserInfoResponse.java @@ -7,6 +7,7 @@ public class UserInfoResponse { private String username; private String email; private String fullName; + private String phone; private String avatarUrl; private String role; private Long storeId; @@ -15,11 +16,12 @@ public class UserInfoResponse { public UserInfoResponse() { } - public UserInfoResponse(Long id, String username, String email, String fullName, String avatarUrl, String role, Long storeId, String storeName) { + public UserInfoResponse(Long id, String username, String email, String fullName, String phone, String avatarUrl, String role, Long storeId, String storeName) { this.id = id; this.username = username; this.email = email; this.fullName = fullName; + this.phone = phone; this.avatarUrl = avatarUrl; this.role = role; this.storeId = storeId; @@ -58,6 +60,14 @@ public class UserInfoResponse { this.fullName = fullName; } + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + public String getAvatarUrl() { return avatarUrl; } @@ -99,6 +109,7 @@ public class UserInfoResponse { Objects.equals(username, that.username) && Objects.equals(email, that.email) && Objects.equals(fullName, that.fullName) && + Objects.equals(phone, that.phone) && Objects.equals(avatarUrl, that.avatarUrl) && Objects.equals(role, that.role) && Objects.equals(storeId, that.storeId) && @@ -107,7 +118,7 @@ public class UserInfoResponse { @Override public int hashCode() { - return Objects.hash(id, username, email, fullName, avatarUrl, role, storeId, storeName); + return Objects.hash(id, username, email, fullName, phone, avatarUrl, role, storeId, storeName); } @Override @@ -117,6 +128,7 @@ public class UserInfoResponse { ", username='" + username + '\'' + ", email='" + email + '\'' + ", fullName='" + fullName + '\'' + + ", phone='" + phone + '\'' + ", avatarUrl='" + avatarUrl + '\'' + ", role='" + role + '\'' + ", storeId=" + storeId + diff --git a/src/main/java/com/petshop/backend/dto/customer/CustomerRequest.java b/src/main/java/com/petshop/backend/dto/customer/CustomerRequest.java index d982be81..ded898e3 100644 --- a/src/main/java/com/petshop/backend/dto/customer/CustomerRequest.java +++ b/src/main/java/com/petshop/backend/dto/customer/CustomerRequest.java @@ -14,8 +14,6 @@ public class CustomerRequest { @Email(message = "Invalid email format") private String email; - private String phone; - public String getFirstName() { return firstName; } @@ -40,14 +38,6 @@ public class CustomerRequest { this.email = email; } - public String getPhone() { - return phone; - } - - public void setPhone(String phone) { - this.phone = phone; - } - @Override public boolean equals(Object o) { if (this == o) return true; @@ -55,13 +45,12 @@ public class CustomerRequest { CustomerRequest that = (CustomerRequest) o; return Objects.equals(firstName, that.firstName) && Objects.equals(lastName, that.lastName) && - Objects.equals(email, that.email) && - Objects.equals(phone, that.phone); + Objects.equals(email, that.email); } @Override public int hashCode() { - return Objects.hash(firstName, lastName, email, phone); + return Objects.hash(firstName, lastName, email); } @Override @@ -70,7 +59,6 @@ public class CustomerRequest { "firstName='" + firstName + '\'' + ", lastName='" + lastName + '\'' + ", email='" + email + '\'' + - ", phone='" + phone + '\'' + '}'; } } diff --git a/src/main/java/com/petshop/backend/dto/customer/CustomerResponse.java b/src/main/java/com/petshop/backend/dto/customer/CustomerResponse.java index 7b25f17a..bd05bf76 100644 --- a/src/main/java/com/petshop/backend/dto/customer/CustomerResponse.java +++ b/src/main/java/com/petshop/backend/dto/customer/CustomerResponse.java @@ -8,19 +8,17 @@ public class CustomerResponse { private String firstName; private String lastName; private String email; - private String phone; private LocalDateTime createdAt; private LocalDateTime updatedAt; public CustomerResponse() { } - public CustomerResponse(Long customerId, String firstName, String lastName, String email, String phone, LocalDateTime createdAt, LocalDateTime updatedAt) { + public CustomerResponse(Long customerId, String firstName, String lastName, String email, LocalDateTime createdAt, LocalDateTime updatedAt) { this.customerId = customerId; this.firstName = firstName; this.lastName = lastName; this.email = email; - this.phone = phone; this.createdAt = createdAt; this.updatedAt = updatedAt; } @@ -57,14 +55,6 @@ public class CustomerResponse { this.email = email; } - public String getPhone() { - return phone; - } - - public void setPhone(String phone) { - this.phone = phone; - } - public LocalDateTime getCreatedAt() { return createdAt; } @@ -86,12 +76,12 @@ public class CustomerResponse { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; CustomerResponse that = (CustomerResponse) o; - return Objects.equals(customerId, that.customerId) && Objects.equals(firstName, that.firstName) && Objects.equals(lastName, that.lastName) && Objects.equals(email, that.email) && Objects.equals(phone, that.phone) && Objects.equals(createdAt, that.createdAt) && Objects.equals(updatedAt, that.updatedAt); + return Objects.equals(customerId, that.customerId) && Objects.equals(firstName, that.firstName) && Objects.equals(lastName, that.lastName) && Objects.equals(email, that.email) && Objects.equals(createdAt, that.createdAt) && Objects.equals(updatedAt, that.updatedAt); } @Override public int hashCode() { - return Objects.hash(customerId, firstName, lastName, email, phone, createdAt, updatedAt); + return Objects.hash(customerId, firstName, lastName, email, createdAt, updatedAt); } @Override @@ -101,7 +91,6 @@ public class CustomerResponse { ", firstName='" + firstName + '\'' + ", lastName='" + lastName + '\'' + ", email='" + email + '\'' + - ", phone='" + phone + '\'' + ", createdAt=" + createdAt + ", updatedAt=" + updatedAt + '}'; diff --git a/src/main/java/com/petshop/backend/dto/employee/EmployeeRequest.java b/src/main/java/com/petshop/backend/dto/employee/EmployeeRequest.java new file mode 100644 index 00000000..f5fb9020 --- /dev/null +++ b/src/main/java/com/petshop/backend/dto/employee/EmployeeRequest.java @@ -0,0 +1,51 @@ +package com.petshop.backend.dto.employee; + +import com.petshop.backend.entity.User; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public class EmployeeRequest { + @NotBlank(message = "Username is required") + @Size(min = 3, max = 50, message = "Username must be between 3 and 50 characters") + private String username; + + @Size(min = 6, message = "Password must be at least 6 characters") + private String password; + + @NotBlank(message = "First name is required") + private String firstName; + + @NotBlank(message = "Last name is required") + private String lastName; + + @Email(message = "Invalid email format") + private String email; + + @NotBlank(message = "Phone is required") + @Size(max = 20, message = "Phone must not exceed 20 characters") + private String phone; + + @NotNull(message = "Role is required") + private User.Role role; + + private Boolean active = true; + + public String getUsername() { return username; } + public void setUsername(String username) { this.username = username; } + public String getPassword() { return password; } + public void setPassword(String password) { this.password = password; } + public String getFirstName() { return firstName; } + public void setFirstName(String firstName) { this.firstName = firstName; } + public String getLastName() { return lastName; } + public void setLastName(String lastName) { this.lastName = lastName; } + public String getEmail() { return email; } + public void setEmail(String email) { this.email = email; } + public String getPhone() { return phone; } + public void setPhone(String phone) { this.phone = phone; } + public User.Role getRole() { return role; } + public void setRole(User.Role role) { this.role = role; } + public Boolean getActive() { return active; } + public void setActive(Boolean active) { this.active = active; } +} diff --git a/src/main/java/com/petshop/backend/dto/employee/EmployeeResponse.java b/src/main/java/com/petshop/backend/dto/employee/EmployeeResponse.java new file mode 100644 index 00000000..a159fc35 --- /dev/null +++ b/src/main/java/com/petshop/backend/dto/employee/EmployeeResponse.java @@ -0,0 +1,43 @@ +package com.petshop.backend.dto.employee; + +import java.time.LocalDateTime; + +public class EmployeeResponse { + private Long employeeId; + private Long userId; + private String username; + private String firstName; + private String lastName; + private String fullName; + private String email; + private String phone; + private String role; + private Boolean active; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public Long getEmployeeId() { return employeeId; } + public void setEmployeeId(Long employeeId) { this.employeeId = employeeId; } + public Long getUserId() { return userId; } + public void setUserId(Long userId) { this.userId = userId; } + public String getUsername() { return username; } + public void setUsername(String username) { this.username = username; } + public String getFirstName() { return firstName; } + public void setFirstName(String firstName) { this.firstName = firstName; } + public String getLastName() { return lastName; } + public void setLastName(String lastName) { this.lastName = lastName; } + public String getFullName() { return fullName; } + public void setFullName(String fullName) { this.fullName = fullName; } + public String getEmail() { return email; } + public void setEmail(String email) { this.email = email; } + public String getPhone() { return phone; } + public void setPhone(String phone) { this.phone = phone; } + public String getRole() { return role; } + public void setRole(String role) { this.role = role; } + public Boolean getActive() { return active; } + public void setActive(Boolean active) { this.active = active; } + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/src/main/java/com/petshop/backend/dto/user/UserRequest.java b/src/main/java/com/petshop/backend/dto/user/UserRequest.java index 72b1dbfb..09a9036d 100644 --- a/src/main/java/com/petshop/backend/dto/user/UserRequest.java +++ b/src/main/java/com/petshop/backend/dto/user/UserRequest.java @@ -21,6 +21,9 @@ public class UserRequest { @Email(message = "Invalid email format") private String email; + @Size(max = 20, message = "Phone must not exceed 20 characters") + private String phone; + @NotNull(message = "Role is required") private User.Role role; @@ -58,6 +61,14 @@ public class UserRequest { this.email = email; } + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + public User.Role getRole() { return role; } @@ -83,13 +94,14 @@ public class UserRequest { Objects.equals(password, that.password) && Objects.equals(fullName, that.fullName) && Objects.equals(email, that.email) && + Objects.equals(phone, that.phone) && role == that.role && Objects.equals(active, that.active); } @Override public int hashCode() { - return Objects.hash(username, password, fullName, email, role, active); + return Objects.hash(username, password, fullName, email, phone, role, active); } @Override @@ -99,6 +111,7 @@ public class UserRequest { ", password='" + password + '\'' + ", fullName='" + fullName + '\'' + ", email='" + email + '\'' + + ", phone='" + phone + '\'' + ", role=" + role + ", active=" + active + '}'; diff --git a/src/main/java/com/petshop/backend/dto/user/UserResponse.java b/src/main/java/com/petshop/backend/dto/user/UserResponse.java index 8b383366..9d7167c2 100644 --- a/src/main/java/com/petshop/backend/dto/user/UserResponse.java +++ b/src/main/java/com/petshop/backend/dto/user/UserResponse.java @@ -8,6 +8,7 @@ public class UserResponse { private String username; private String fullName; private String email; + private String phone; private String role; private Boolean active; private LocalDateTime createdAt; @@ -16,11 +17,12 @@ public class UserResponse { public UserResponse() { } - public UserResponse(Long id, String username, String fullName, String email, String role, Boolean active, LocalDateTime createdAt, LocalDateTime updatedAt) { + public UserResponse(Long id, String username, String fullName, String email, String phone, String role, Boolean active, LocalDateTime createdAt, LocalDateTime updatedAt) { this.id = id; this.username = username; this.fullName = fullName; this.email = email; + this.phone = phone; this.role = role; this.active = active; this.createdAt = createdAt; @@ -59,6 +61,14 @@ public class UserResponse { this.email = email; } + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + public String getRole() { return role; } @@ -96,12 +106,12 @@ public class UserResponse { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; UserResponse that = (UserResponse) o; - return Objects.equals(id, that.id) && Objects.equals(username, that.username) && Objects.equals(fullName, that.fullName) && Objects.equals(email, that.email) && Objects.equals(role, that.role) && Objects.equals(active, that.active) && Objects.equals(createdAt, that.createdAt) && Objects.equals(updatedAt, that.updatedAt); + return Objects.equals(id, that.id) && Objects.equals(username, that.username) && Objects.equals(fullName, that.fullName) && Objects.equals(email, that.email) && Objects.equals(phone, that.phone) && Objects.equals(role, that.role) && Objects.equals(active, that.active) && Objects.equals(createdAt, that.createdAt) && Objects.equals(updatedAt, that.updatedAt); } @Override public int hashCode() { - return Objects.hash(id, username, fullName, email, role, active, createdAt, updatedAt); + return Objects.hash(id, username, fullName, email, phone, role, active, createdAt, updatedAt); } @Override @@ -111,6 +121,7 @@ public class UserResponse { ", username='" + username + '\'' + ", fullName='" + fullName + '\'' + ", email='" + email + '\'' + + ", phone='" + phone + '\'' + ", role='" + role + '\'' + ", active=" + active + ", createdAt=" + createdAt + diff --git a/src/main/java/com/petshop/backend/entity/Customer.java b/src/main/java/com/petshop/backend/entity/Customer.java index 1cfa858b..09035619 100644 --- a/src/main/java/com/petshop/backend/entity/Customer.java +++ b/src/main/java/com/petshop/backend/entity/Customer.java @@ -27,9 +27,6 @@ public class Customer { @Column(nullable = false, length = 100) private String email; - @Column(nullable = false, length = 20) - private String phone; - @CreationTimestamp @Column(name = "created_at", updatable = false) private LocalDateTime createdAt; @@ -41,13 +38,12 @@ public class Customer { public Customer() { } - public Customer(Long customerId, Long userId, String firstName, String lastName, String email, String phone, LocalDateTime createdAt, LocalDateTime updatedAt) { + public Customer(Long customerId, Long userId, String firstName, String lastName, String email, LocalDateTime createdAt, LocalDateTime updatedAt) { this.customerId = customerId; this.userId = userId; this.firstName = firstName; this.lastName = lastName; this.email = email; - this.phone = phone; this.createdAt = createdAt; this.updatedAt = updatedAt; } @@ -92,14 +88,6 @@ public class Customer { this.email = email; } - public String getPhone() { - return phone; - } - - public void setPhone(String phone) { - this.phone = phone; - } - public LocalDateTime getCreatedAt() { return createdAt; } @@ -137,7 +125,6 @@ public class Customer { ", firstName='" + firstName + '\'' + ", lastName='" + lastName + '\'' + ", email='" + email + '\'' + - ", phone='" + phone + '\'' + ", createdAt=" + createdAt + ", updatedAt=" + updatedAt + '}'; diff --git a/src/main/java/com/petshop/backend/entity/Employee.java b/src/main/java/com/petshop/backend/entity/Employee.java index 9e825a61..c88216f6 100644 --- a/src/main/java/com/petshop/backend/entity/Employee.java +++ b/src/main/java/com/petshop/backend/entity/Employee.java @@ -27,9 +27,6 @@ public class Employee { @Column(nullable = false, length = 100) private String email; - @Column(nullable = false, length = 20) - private String phone; - @Column(nullable = false, length = 50) private String role; @@ -47,13 +44,12 @@ public class Employee { public Employee() { } - public Employee(Long employeeId, Long userId, String firstName, String lastName, String email, String phone, String role, Boolean isActive, LocalDateTime createdAt, LocalDateTime updatedAt) { + public Employee(Long employeeId, Long userId, String firstName, String lastName, String email, String role, Boolean isActive, LocalDateTime createdAt, LocalDateTime updatedAt) { this.employeeId = employeeId; this.userId = userId; this.firstName = firstName; this.lastName = lastName; this.email = email; - this.phone = phone; this.role = role; this.isActive = isActive; this.createdAt = createdAt; @@ -100,14 +96,6 @@ public class Employee { this.email = email; } - public String getPhone() { - return phone; - } - - public void setPhone(String phone) { - this.phone = phone; - } - public String getRole() { return role; } @@ -161,7 +149,6 @@ public class Employee { ", firstName='" + firstName + '\'' + ", lastName='" + lastName + '\'' + ", email='" + email + '\'' + - ", phone='" + phone + '\'' + ", role='" + role + '\'' + ", isActive=" + isActive + ", createdAt=" + createdAt + diff --git a/src/main/java/com/petshop/backend/entity/User.java b/src/main/java/com/petshop/backend/entity/User.java index 54bd202e..cdec2754 100644 --- a/src/main/java/com/petshop/backend/entity/User.java +++ b/src/main/java/com/petshop/backend/entity/User.java @@ -27,6 +27,9 @@ public class User { @Column(length = 100) private String fullName; + @Column(length = 20) + private String phone; + @Column(length = 255) private String avatarUrl; @@ -55,12 +58,13 @@ public class User { public User() { } - public User(Long id, String username, String password, String email, String fullName, String avatarUrl, Role role, Boolean active, Integer tokenVersion, LocalDateTime createdAt, LocalDateTime updatedAt) { + public User(Long id, String username, String password, String email, String fullName, String phone, String avatarUrl, Role role, Boolean active, Integer tokenVersion, LocalDateTime createdAt, LocalDateTime updatedAt) { this.id = id; this.username = username; this.password = password; this.email = email; this.fullName = fullName; + this.phone = phone; this.avatarUrl = avatarUrl; this.role = role; this.active = active; @@ -109,6 +113,14 @@ public class User { this.fullName = fullName; } + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + public String getAvatarUrl() { return avatarUrl; } @@ -178,6 +190,7 @@ public class User { ", password='" + password + '\'' + ", email='" + email + '\'' + ", fullName='" + fullName + '\'' + + ", phone='" + phone + '\'' + ", avatarUrl='" + avatarUrl + '\'' + ", role=" + role + ", active=" + active + diff --git a/src/main/java/com/petshop/backend/exception/ApiErrorResponder.java b/src/main/java/com/petshop/backend/exception/ApiErrorResponder.java new file mode 100644 index 00000000..39f4d66c --- /dev/null +++ b/src/main/java/com/petshop/backend/exception/ApiErrorResponder.java @@ -0,0 +1,32 @@ +package com.petshop.backend.exception; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.time.LocalDateTime; + +@Component +public class ApiErrorResponder { + + private final ObjectMapper objectMapper = JsonMapper.builder().findAndAddModules().build(); + + public void write(HttpServletResponse response, HttpStatus status, String message, String details, String path) throws IOException { + response.setStatus(status.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + objectMapper.writeValue( + response.getWriter(), + new ApiErrorResponse( + status.value(), + message, + details, + path, + LocalDateTime.now() + ) + ); + } +} diff --git a/src/main/java/com/petshop/backend/exception/ApiErrorResponse.java b/src/main/java/com/petshop/backend/exception/ApiErrorResponse.java new file mode 100644 index 00000000..b3aea542 --- /dev/null +++ b/src/main/java/com/petshop/backend/exception/ApiErrorResponse.java @@ -0,0 +1,12 @@ +package com.petshop.backend.exception; + +import java.time.LocalDateTime; + +public record ApiErrorResponse( + int status, + String message, + String details, + String path, + LocalDateTime timestamp +) { +} diff --git a/src/main/java/com/petshop/backend/exception/GlobalExceptionHandler.java b/src/main/java/com/petshop/backend/exception/GlobalExceptionHandler.java index bfcfe03d..b41f8789 100644 --- a/src/main/java/com/petshop/backend/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/petshop/backend/exception/GlobalExceptionHandler.java @@ -1,12 +1,15 @@ package com.petshop.backend.exception; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.dao.DataIntegrityViolationException; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.server.ResponseStatusException; import java.time.LocalDateTime; import java.util.HashMap; @@ -16,27 +19,17 @@ import java.util.Map; public class GlobalExceptionHandler { @ExceptionHandler(ResourceNotFoundException.class) - public ResponseEntity handleResourceNotFound(ResourceNotFoundException ex) { - ErrorResponse error = new ErrorResponse( - HttpStatus.NOT_FOUND.value(), - ex.getMessage(), - LocalDateTime.now() - ); - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error); + public ResponseEntity handleResourceNotFound(ResourceNotFoundException ex, HttpServletRequest request) { + return buildErrorResponse(HttpStatus.NOT_FOUND, ex.getMessage(), ex, request); } @ExceptionHandler(BusinessException.class) - public ResponseEntity handleBusinessException(BusinessException ex) { - ErrorResponse error = new ErrorResponse( - HttpStatus.BAD_REQUEST.value(), - ex.getMessage(), - LocalDateTime.now() - ); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error); + public ResponseEntity handleBusinessException(BusinessException ex, HttpServletRequest request) { + return buildErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage(), ex, request); } @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity> handleValidationExceptions(MethodArgumentNotValidException ex) { + public ResponseEntity> handleValidationExceptions(MethodArgumentNotValidException ex, HttpServletRequest request) { Map errors = new HashMap<>(); ex.getBindingResult().getAllErrors().forEach((error) -> { String fieldName = ((FieldError) error).getField(); @@ -46,51 +39,74 @@ public class GlobalExceptionHandler { Map response = new HashMap<>(); response.put("status", HttpStatus.BAD_REQUEST.value()); + response.put("message", "Validation failed"); response.put("errors", errors); + response.put("details", buildDetails(ex)); + response.put("path", request.getRequestURI()); response.put("timestamp", LocalDateTime.now()); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); } @ExceptionHandler(org.springframework.security.access.AccessDeniedException.class) - public ResponseEntity handleAccessDeniedException(org.springframework.security.access.AccessDeniedException ex) { - ErrorResponse error = new ErrorResponse( - HttpStatus.FORBIDDEN.value(), - ex.getMessage(), - LocalDateTime.now() - ); - return ResponseEntity.status(HttpStatus.FORBIDDEN).body(error); + public ResponseEntity handleAccessDeniedException(org.springframework.security.access.AccessDeniedException ex, HttpServletRequest request) { + return buildErrorResponse(HttpStatus.FORBIDDEN, ex.getMessage(), ex, request); } @ExceptionHandler(IllegalArgumentException.class) - public ResponseEntity handleIllegalArgumentException(IllegalArgumentException ex) { - ErrorResponse error = new ErrorResponse( - HttpStatus.BAD_REQUEST.value(), - ex.getMessage(), - LocalDateTime.now() - ); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error); + public ResponseEntity handleIllegalArgumentException(IllegalArgumentException ex, HttpServletRequest request) { + return buildErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage(), ex, request); } @ExceptionHandler(DataIntegrityViolationException.class) - public ResponseEntity handleDataIntegrityViolationException(DataIntegrityViolationException ex) { - ErrorResponse error = new ErrorResponse( - HttpStatus.BAD_REQUEST.value(), - "Operation violates existing data relationships", - LocalDateTime.now() - ); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error); + public ResponseEntity handleDataIntegrityViolationException(DataIntegrityViolationException ex, HttpServletRequest request) { + return buildErrorResponse(HttpStatus.BAD_REQUEST, "Operation violates existing data relationships", ex, request); + } + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException ex, HttpServletRequest request) { + String message = "Invalid value for parameter: " + ex.getName(); + if (ex.getValue() != null) { + message += " (" + ex.getValue() + ")"; + } + return buildErrorResponse(HttpStatus.BAD_REQUEST, message, ex, request); + } + + @ExceptionHandler(ResponseStatusException.class) + public ResponseEntity handleResponseStatusException(ResponseStatusException ex, HttpServletRequest request) { + String message = ex.getReason() != null ? ex.getReason() : ex.getMessage(); + return buildErrorResponse(HttpStatus.valueOf(ex.getStatusCode().value()), message, ex, request); } @ExceptionHandler(Exception.class) - public ResponseEntity handleGenericException(Exception ex) { - ErrorResponse error = new ErrorResponse( - HttpStatus.INTERNAL_SERVER_ERROR.value(), - "An unexpected error occurred: " + ex.getMessage(), + public ResponseEntity handleGenericException(Exception ex, HttpServletRequest request) { + String message = ex.getMessage() == null || ex.getMessage().isBlank() + ? "Unexpected server error" + : ex.getMessage(); + return buildErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR, message, ex, request); + } + + private ResponseEntity buildErrorResponse(HttpStatus status, String message, Exception ex, HttpServletRequest request) { + ApiErrorResponse error = new ApiErrorResponse( + status.value(), + message, + buildDetails(ex), + request.getRequestURI(), LocalDateTime.now() ); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error); + return ResponseEntity.status(status).body(error); + } + + private String buildDetails(Exception ex) { + Throwable rootCause = ex; + while (rootCause.getCause() != null && rootCause.getCause() != rootCause) { + rootCause = rootCause.getCause(); + } + + String rootMessage = rootCause.getMessage(); + if (rootMessage == null || rootMessage.isBlank()) { + return rootCause.getClass().getSimpleName(); + } + return rootCause.getClass().getSimpleName() + ": " + rootMessage; } } - -record ErrorResponse(int status, String message, LocalDateTime timestamp) {} diff --git a/src/main/java/com/petshop/backend/repository/CustomerRepository.java b/src/main/java/com/petshop/backend/repository/CustomerRepository.java index f4baa3f9..56e03dbc 100644 --- a/src/main/java/com/petshop/backend/repository/CustomerRepository.java +++ b/src/main/java/com/petshop/backend/repository/CustomerRepository.java @@ -21,6 +21,6 @@ public interface CustomerRepository extends JpaRepository { "LOWER(c.firstName) LIKE LOWER(CONCAT('%', :q, '%')) OR " + "LOWER(c.lastName) LIKE LOWER(CONCAT('%', :q, '%')) OR " + "LOWER(c.email) LIKE LOWER(CONCAT('%', :q, '%')) OR " + - "LOWER(c.phone) LIKE LOWER(CONCAT('%', :q, '%'))") + "EXISTS (SELECT u FROM User u WHERE u.id = c.userId AND LOWER(COALESCE(u.phone, '')) LIKE LOWER(CONCAT('%', :q, '%')))") Page searchCustomers(@Param("q") String query, Pageable pageable); } diff --git a/src/main/java/com/petshop/backend/repository/EmployeeRepository.java b/src/main/java/com/petshop/backend/repository/EmployeeRepository.java index bcb4b138..cfbf715f 100644 --- a/src/main/java/com/petshop/backend/repository/EmployeeRepository.java +++ b/src/main/java/com/petshop/backend/repository/EmployeeRepository.java @@ -1,7 +1,11 @@ package com.petshop.backend.repository; import com.petshop.backend.entity.Employee; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.List; @@ -11,4 +15,14 @@ import java.util.Optional; public interface EmployeeRepository extends JpaRepository { Optional findByUserId(Long userId); List findAllByEmail(String email); + + @Query("SELECT e FROM Employee e WHERE " + + "LOWER(e.firstName) LIKE LOWER(CONCAT('%', :q, '%')) OR " + + "LOWER(e.lastName) LIKE LOWER(CONCAT('%', :q, '%')) OR " + + "LOWER(e.email) LIKE LOWER(CONCAT('%', :q, '%')) OR " + + "LOWER(e.role) LIKE LOWER(CONCAT('%', :q, '%')) OR " + + "EXISTS (SELECT u FROM User u WHERE u.id = e.userId AND (" + + "LOWER(u.username) LIKE LOWER(CONCAT('%', :q, '%')) OR " + + "LOWER(COALESCE(u.phone, '')) LIKE LOWER(CONCAT('%', :q, '%'))))") + Page searchEmployees(@Param("q") String query, Pageable pageable); } diff --git a/src/main/java/com/petshop/backend/repository/UserRepository.java b/src/main/java/com/petshop/backend/repository/UserRepository.java index 17e4356a..6bec352f 100644 --- a/src/main/java/com/petshop/backend/repository/UserRepository.java +++ b/src/main/java/com/petshop/backend/repository/UserRepository.java @@ -14,9 +14,21 @@ import java.util.Optional; public interface UserRepository extends JpaRepository { Optional findByUsername(String username); Optional findByEmail(String email); + Optional findByPhone(String phone); boolean existsByUsername(String username); + Page findByRole(User.Role role, Pageable pageable); @Query("SELECT u FROM User u WHERE " + - "LOWER(u.username) LIKE LOWER(CONCAT('%', :q, '%'))") + "LOWER(u.username) LIKE LOWER(CONCAT('%', :q, '%')) OR " + + "LOWER(COALESCE(u.fullName, '')) LIKE LOWER(CONCAT('%', :q, '%')) OR " + + "LOWER(COALESCE(u.email, '')) LIKE LOWER(CONCAT('%', :q, '%')) OR " + + "LOWER(COALESCE(u.phone, '')) LIKE LOWER(CONCAT('%', :q, '%'))") Page searchUsers(@Param("q") String query, Pageable pageable); + + @Query("SELECT u FROM User u WHERE u.role = :role AND (" + + "LOWER(u.username) LIKE LOWER(CONCAT('%', :q, '%')) OR " + + "LOWER(COALESCE(u.fullName, '')) LIKE LOWER(CONCAT('%', :q, '%')) OR " + + "LOWER(COALESCE(u.email, '')) LIKE LOWER(CONCAT('%', :q, '%')) OR " + + "LOWER(COALESCE(u.phone, '')) LIKE LOWER(CONCAT('%', :q, '%')))") + Page searchUsersByRole(@Param("q") String query, @Param("role") User.Role role, Pageable pageable); } diff --git a/src/main/java/com/petshop/backend/security/JwtAuthenticationFilter.java b/src/main/java/com/petshop/backend/security/JwtAuthenticationFilter.java index 8d311f74..a4caaaef 100644 --- a/src/main/java/com/petshop/backend/security/JwtAuthenticationFilter.java +++ b/src/main/java/com/petshop/backend/security/JwtAuthenticationFilter.java @@ -1,7 +1,9 @@ package com.petshop.backend.security; import com.petshop.backend.entity.User; +import com.petshop.backend.exception.ApiErrorResponder; import com.petshop.backend.repository.UserRepository; +import io.jsonwebtoken.JwtException; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -14,17 +16,17 @@ import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; -import java.time.LocalDateTime; - @Component public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtUtil jwtUtil; private final UserRepository userRepository; + private final ApiErrorResponder apiErrorResponder; - public JwtAuthenticationFilter(JwtUtil jwtUtil, UserRepository userRepository) { + public JwtAuthenticationFilter(JwtUtil jwtUtil, UserRepository userRepository, ApiErrorResponder apiErrorResponder) { this.jwtUtil = jwtUtil; this.userRepository = userRepository; + this.apiErrorResponder = apiErrorResponder; } @Override @@ -41,16 +43,22 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { } jwt = authHeader.substring(7); - Long userId = jwtUtil.extractUserId(jwt); + Long userId; + try { + userId = jwtUtil.extractUserId(jwt); + } catch (JwtException | IllegalArgumentException ex) { + writeUnauthorized(request, response, "Invalid or expired token", ex); + return; + } if (userId != null && SecurityContextHolder.getContext().getAuthentication() == null) { User user = userRepository.findById(userId).orElse(null); if (user == null || user.getActive() == null || !user.getActive()) { - writeUnauthorized(response, "User account is inactive"); + writeUnauthorized(request, response, "User account is inactive", null); return; } if (!jwtUtil.validateToken(jwt, user)) { - writeUnauthorized(response, "Invalid or expired token"); + writeUnauthorized(request, response, "Invalid or expired token", null); return; } @@ -71,11 +79,8 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { filterChain.doFilter(request, response); } - private void writeUnauthorized(HttpServletResponse response, String message) throws IOException { - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - response.setContentType("application/json"); - response.getWriter().write( - "{\"status\":401,\"message\":\"" + message + "\",\"timestamp\":\"" + LocalDateTime.now() + "\"}" - ); + private void writeUnauthorized(HttpServletRequest request, HttpServletResponse response, String message, Exception ex) throws IOException { + String details = ex == null ? message : ex.getClass().getSimpleName() + ": " + ex.getMessage(); + apiErrorResponder.write(response, org.springframework.http.HttpStatus.UNAUTHORIZED, message, details, request.getRequestURI()); } } diff --git a/src/main/java/com/petshop/backend/security/RestAccessDeniedHandler.java b/src/main/java/com/petshop/backend/security/RestAccessDeniedHandler.java new file mode 100644 index 00000000..2ef240e9 --- /dev/null +++ b/src/main/java/com/petshop/backend/security/RestAccessDeniedHandler.java @@ -0,0 +1,33 @@ +package com.petshop.backend.security; + +import com.petshop.backend.exception.ApiErrorResponder; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class RestAccessDeniedHandler implements AccessDeniedHandler { + + private final ApiErrorResponder apiErrorResponder; + + public RestAccessDeniedHandler(ApiErrorResponder apiErrorResponder) { + this.apiErrorResponder = apiErrorResponder; + } + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { + apiErrorResponder.write( + response, + HttpStatus.FORBIDDEN, + "Access Denied", + accessDeniedException.getClass().getSimpleName() + ": " + accessDeniedException.getMessage(), + request.getRequestURI() + ); + } +} diff --git a/src/main/java/com/petshop/backend/security/RestAuthenticationEntryPoint.java b/src/main/java/com/petshop/backend/security/RestAuthenticationEntryPoint.java new file mode 100644 index 00000000..2ae541b4 --- /dev/null +++ b/src/main/java/com/petshop/backend/security/RestAuthenticationEntryPoint.java @@ -0,0 +1,33 @@ +package com.petshop.backend.security; + +import com.petshop.backend.exception.ApiErrorResponder; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ApiErrorResponder apiErrorResponder; + + public RestAuthenticationEntryPoint(ApiErrorResponder apiErrorResponder) { + this.apiErrorResponder = apiErrorResponder; + } + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { + apiErrorResponder.write( + response, + HttpStatus.UNAUTHORIZED, + "Authentication required", + authException.getClass().getSimpleName() + ": " + authException.getMessage(), + request.getRequestURI() + ); + } +} diff --git a/src/main/java/com/petshop/backend/security/SecurityConfig.java b/src/main/java/com/petshop/backend/security/SecurityConfig.java index 0a893c18..00ce63f8 100644 --- a/src/main/java/com/petshop/backend/security/SecurityConfig.java +++ b/src/main/java/com/petshop/backend/security/SecurityConfig.java @@ -25,10 +25,19 @@ public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthFilter; private final UserDetailsService userDetailsService; + private final RestAuthenticationEntryPoint restAuthenticationEntryPoint; + private final RestAccessDeniedHandler restAccessDeniedHandler; - public SecurityConfig(JwtAuthenticationFilter jwtAuthFilter, UserDetailsService userDetailsService) { + public SecurityConfig( + JwtAuthenticationFilter jwtAuthFilter, + UserDetailsService userDetailsService, + RestAuthenticationEntryPoint restAuthenticationEntryPoint, + RestAccessDeniedHandler restAccessDeniedHandler + ) { this.jwtAuthFilter = jwtAuthFilter; this.userDetailsService = userDetailsService; + this.restAuthenticationEntryPoint = restAuthenticationEntryPoint; + this.restAccessDeniedHandler = restAccessDeniedHandler; } @Bean @@ -47,6 +56,10 @@ public class SecurityConfig { .requestMatchers(HttpMethod.GET, "/api/v1/appointments/availability").permitAll() .anyRequest().authenticated() ) + .exceptionHandling(ex -> ex + .authenticationEntryPoint(restAuthenticationEntryPoint) + .accessDeniedHandler(restAccessDeniedHandler) + ) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authenticationProvider(daoAuthenticationProvider()) .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); diff --git a/src/main/java/com/petshop/backend/service/CustomerService.java b/src/main/java/com/petshop/backend/service/CustomerService.java index 47fa3c4c..040be22a 100644 --- a/src/main/java/com/petshop/backend/service/CustomerService.java +++ b/src/main/java/com/petshop/backend/service/CustomerService.java @@ -6,6 +6,7 @@ import com.petshop.backend.dto.customer.CustomerResponse; import com.petshop.backend.entity.Customer; import com.petshop.backend.exception.ResourceNotFoundException; import com.petshop.backend.repository.CustomerRepository; +import com.petshop.backend.repository.UserRepository; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -15,9 +16,11 @@ import org.springframework.transaction.annotation.Transactional; public class CustomerService { private final CustomerRepository customerRepository; + private final UserRepository userRepository; - public CustomerService(CustomerRepository customerRepository) { + public CustomerService(CustomerRepository customerRepository, UserRepository userRepository) { this.customerRepository = customerRepository; + this.userRepository = userRepository; } public Page getAllCustomers(String query, Pageable pageable) { @@ -42,9 +45,9 @@ public class CustomerService { customer.setFirstName(request.getFirstName()); customer.setLastName(request.getLastName()); customer.setEmail(request.getEmail()); - customer.setPhone(request.getPhone()); customer = customerRepository.save(customer); + syncLinkedUser(customer); return mapToResponse(customer); } @@ -56,9 +59,9 @@ public class CustomerService { customer.setFirstName(request.getFirstName()); customer.setLastName(request.getLastName()); customer.setEmail(request.getEmail()); - customer.setPhone(request.getPhone()); customer = customerRepository.save(customer); + syncLinkedUser(customer); return mapToResponse(customer); } @@ -81,9 +84,19 @@ public class CustomerService { customer.getFirstName(), customer.getLastName(), customer.getEmail(), - customer.getPhone(), customer.getCreatedAt(), customer.getUpdatedAt() ); } + + private void syncLinkedUser(Customer customer) { + if (customer.getUserId() == null) { + return; + } + userRepository.findById(customer.getUserId()).ifPresent(user -> { + user.setEmail(customer.getEmail()); + user.setFullName((customer.getFirstName() + " " + customer.getLastName()).trim()); + userRepository.save(user); + }); + } } diff --git a/src/main/java/com/petshop/backend/service/EmployeeService.java b/src/main/java/com/petshop/backend/service/EmployeeService.java new file mode 100644 index 00000000..baf83bb8 --- /dev/null +++ b/src/main/java/com/petshop/backend/service/EmployeeService.java @@ -0,0 +1,183 @@ +package com.petshop.backend.service; + +import com.petshop.backend.dto.employee.EmployeeRequest; +import com.petshop.backend.dto.employee.EmployeeResponse; +import com.petshop.backend.entity.Employee; +import com.petshop.backend.entity.User; +import com.petshop.backend.exception.ResourceNotFoundException; +import com.petshop.backend.repository.EmployeeRepository; +import com.petshop.backend.repository.UserRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import static org.springframework.http.HttpStatus.CONFLICT; + +@Service +public class EmployeeService { + private final EmployeeRepository employeeRepository; + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final UserBusinessLinkageService userBusinessLinkageService; + + public EmployeeService(EmployeeRepository employeeRepository, UserRepository userRepository, PasswordEncoder passwordEncoder, UserBusinessLinkageService userBusinessLinkageService) { + this.employeeRepository = employeeRepository; + this.userRepository = userRepository; + this.passwordEncoder = passwordEncoder; + this.userBusinessLinkageService = userBusinessLinkageService; + } + + public Page getAllEmployees(String query, Pageable pageable) { + Page employees; + if (query != null && !query.trim().isEmpty()) { + employees = employeeRepository.searchEmployees(query, pageable); + } else { + employees = employeeRepository.findAll(pageable); + } + return employees.map(this::mapToResponse); + } + + public EmployeeResponse getEmployeeById(Long id) { + Employee employee = employeeRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Employee not found with id: " + id)); + return mapToResponse(employee); + } + + @Transactional + public EmployeeResponse createEmployee(EmployeeRequest request) { + validateRole(request.getRole()); + if (request.getPassword() == null || request.getPassword().trim().length() < 6) { + throw new IllegalArgumentException("Password must be at least 6 characters"); + } + if (userRepository.findByUsername(request.getUsername()).isPresent()) { + throw new ResponseStatusException(CONFLICT, "Username already exists"); + } + if (request.getEmail() != null && userRepository.findByEmail(request.getEmail()).isPresent()) { + throw new ResponseStatusException(CONFLICT, "Email already exists"); + } + String phone = trimToNull(request.getPhone()); + if (phone != null && userRepository.findByPhone(phone).isPresent()) { + throw new ResponseStatusException(CONFLICT, "Phone already exists"); + } + + User user = new User(); + user.setUsername(request.getUsername()); + user.setPassword(passwordEncoder.encode(request.getPassword())); + user.setFullName(fullName(request)); + user.setEmail(request.getEmail()); + user.setPhone(phone); + user.setRole(request.getRole()); + user.setActive(request.getActive() != null ? request.getActive() : true); + user = userRepository.save(user); + + Employee employee = userBusinessLinkageService.ensureLinkedEmployee(user); + return mapToResponse(employee, user); + } + + @Transactional + public EmployeeResponse updateEmployee(Long id, EmployeeRequest request) { + Employee employee = employeeRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Employee not found with id: " + id)); + User user = requireLinkedUser(employee); + + validateRole(request.getRole()); + if (!user.getUsername().equals(request.getUsername()) && userRepository.findByUsername(request.getUsername()).isPresent()) { + throw new ResponseStatusException(CONFLICT, "Username already exists"); + } + if (!java.util.Objects.equals(user.getEmail(), request.getEmail()) && request.getEmail() != null && userRepository.findByEmail(request.getEmail()).isPresent()) { + throw new ResponseStatusException(CONFLICT, "Email already exists"); + } + String phone = trimToNull(request.getPhone()); + Long currentUserId = user.getId(); + if (!java.util.Objects.equals(user.getPhone(), phone)) { + userRepository.findByPhone(phone) + .filter(existing -> !existing.getId().equals(currentUserId)) + .ifPresent(existing -> { throw new ResponseStatusException(CONFLICT, "Phone already exists"); }); + } + + user.setUsername(request.getUsername()); + if (request.getPassword() != null && !request.getPassword().trim().isEmpty()) { + user.setPassword(passwordEncoder.encode(request.getPassword())); + user.setTokenVersion(user.getTokenVersion() + 1); + } + user.setEmail(request.getEmail()); + user.setPhone(phone); + user.setFullName(fullName(request)); + user.setRole(request.getRole()); + user.setActive(request.getActive() != null ? request.getActive() : true); + user = userRepository.save(user); + + employee = userBusinessLinkageService.ensureLinkedEmployee(user); + return mapToResponse(employee, user); + } + + @Transactional + public void deleteEmployee(Long id) { + Employee employee = employeeRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Employee not found with id: " + id)); + if (employee.getUserId() != null && userRepository.existsById(employee.getUserId())) { + userRepository.deleteById(employee.getUserId()); + return; + } + employeeRepository.deleteById(id); + } + + private EmployeeResponse mapToResponse(Employee employee) { + User user = employee.getUserId() == null ? null : userRepository.findById(employee.getUserId()).orElse(null); + return mapToResponse(employee, user); + } + + private EmployeeResponse mapToResponse(Employee employee, User user) { + EmployeeResponse response = new EmployeeResponse(); + response.setEmployeeId(employee.getEmployeeId()); + response.setUserId(user != null ? user.getId() : employee.getUserId()); + response.setUsername(user != null ? user.getUsername() : null); + response.setFirstName(employee.getFirstName()); + response.setLastName(employee.getLastName()); + response.setFullName(user != null ? user.getFullName() : fullName(employee)); + response.setEmail(user != null ? user.getEmail() : employee.getEmail()); + response.setPhone(user != null ? user.getPhone() : null); + response.setRole(user != null ? user.getRole().name() : normalizeRole(employee.getRole())); + response.setActive(user != null ? user.getActive() : employee.getIsActive()); + response.setCreatedAt(employee.getCreatedAt()); + response.setUpdatedAt(employee.getUpdatedAt()); + return response; + } + + private User requireLinkedUser(Employee employee) { + if (employee.getUserId() == null) { + throw new ResourceNotFoundException("Employee user account not found"); + } + return userRepository.findById(employee.getUserId()) + .orElseThrow(() -> new ResourceNotFoundException("Employee user account not found")); + } + + private void validateRole(User.Role role) { + if (role != User.Role.STAFF && role != User.Role.ADMIN) { + throw new IllegalArgumentException("Employee role must be STAFF or ADMIN"); + } + } + + private String fullName(EmployeeRequest request) { + return (request.getFirstName().trim() + " " + request.getLastName().trim()).trim(); + } + + private String fullName(Employee employee) { + return (employee.getFirstName().trim() + " " + employee.getLastName().trim()).trim(); + } + + private String normalizeRole(String role) { + return role == null ? null : role.trim().toUpperCase(java.util.Locale.ROOT); + } + + private String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} diff --git a/src/main/java/com/petshop/backend/service/UserBusinessLinkageService.java b/src/main/java/com/petshop/backend/service/UserBusinessLinkageService.java index 81b4738f..05751688 100644 --- a/src/main/java/com/petshop/backend/service/UserBusinessLinkageService.java +++ b/src/main/java/com/petshop/backend/service/UserBusinessLinkageService.java @@ -25,88 +25,104 @@ public class UserBusinessLinkageService { @Transactional public Employee ensureLinkedEmployee(User user) { - // Check if already linked if (user.getId() != null) { var existing = employeeRepository.findByUserId(user.getId()); if (existing.isPresent()) { - return existing.get(); + return syncEmployee(existing.get(), user); } } - // Check for email matches List emailMatches = employeeRepository.findAllByEmail(user.getEmail()); - // If exactly one match exists and has no userId, link it if (emailMatches.size() == 1) { Employee employee = emailMatches.get(0); if (employee.getUserId() == null) { employee.setUserId(user.getId()); - return employeeRepository.save(employee); + return syncEmployee(employee, user); } } - // Otherwise create a new linked Employee Employee newEmployee = new Employee(); newEmployee.setUserId(user.getId()); newEmployee.setEmail(user.getEmail()); - // Split fullName into firstName and lastName String[] nameParts = splitFullName(user.getFullName()); newEmployee.setFirstName(nameParts[0]); newEmployee.setLastName(nameParts[1]); - // Set required fields with deterministic values - newEmployee.setPhone("000-000-0000"); newEmployee.setIsActive(true); - // Map role based on user role if (user.getRole() == User.Role.ADMIN) { newEmployee.setRole("Manager"); } else if (user.getRole() == User.Role.STAFF) { newEmployee.setRole("Staff"); } else { - newEmployee.setRole("Staff"); // fallback + newEmployee.setRole("Staff"); } - return employeeRepository.save(newEmployee); + return syncEmployee(newEmployee, user); } @Transactional public Customer ensureLinkedCustomer(User user) { - // Check if already linked if (user.getId() != null) { var existing = customerRepository.findByUserId(user.getId()); if (existing.isPresent()) { - return existing.get(); + return syncCustomer(existing.get(), user); } } - // Check for email matches List emailMatches = customerRepository.findAllByEmail(user.getEmail()); - // If exactly one match exists and has no userId, link it if (emailMatches.size() == 1) { Customer customer = emailMatches.get(0); if (customer.getUserId() == null) { customer.setUserId(user.getId()); - return customerRepository.save(customer); + return syncCustomer(customer, user); } } - // Otherwise create a new linked Customer Customer newCustomer = new Customer(); newCustomer.setUserId(user.getId()); newCustomer.setEmail(user.getEmail()); - // Split fullName into firstName and lastName String[] nameParts = splitFullName(user.getFullName()); newCustomer.setFirstName(nameParts[0]); newCustomer.setLastName(nameParts[1]); - // Set required fields with deterministic values - newCustomer.setPhone("000-000-0001"); + return syncCustomer(newCustomer, user); + } - return customerRepository.save(newCustomer); + @Transactional + public void syncLinkedRecords(User user) { + if (user.getRole() == User.Role.CUSTOMER) { + ensureLinkedCustomer(user); + return; + } + ensureLinkedEmployee(user); + } + + private Employee syncEmployee(Employee employee, User user) { + employee.setUserId(user.getId()); + employee.setEmail(user.getEmail()); + String[] nameParts = splitFullName(user.getFullName()); + employee.setFirstName(nameParts[0]); + employee.setLastName(nameParts[1]); + if (user.getRole() == User.Role.ADMIN) { + employee.setRole("Manager"); + } else { + employee.setRole("Staff"); + } + return employeeRepository.save(employee); + } + + private Customer syncCustomer(Customer customer, User user) { + customer.setUserId(user.getId()); + customer.setEmail(user.getEmail()); + String[] nameParts = splitFullName(user.getFullName()); + customer.setFirstName(nameParts[0]); + customer.setLastName(nameParts[1]); + return customerRepository.save(customer); } private String[] splitFullName(String fullName) { @@ -118,11 +134,9 @@ public class UserBusinessLinkageService { int spaceIndex = trimmed.indexOf(' '); if (spaceIndex == -1) { - // Single token return new String[]{trimmed, "User"}; } - // Multiple tokens String firstName = trimmed.substring(0, spaceIndex).trim(); String lastName = trimmed.substring(spaceIndex + 1).trim(); diff --git a/src/main/java/com/petshop/backend/service/UserService.java b/src/main/java/com/petshop/backend/service/UserService.java index ee705a8a..3c219172 100644 --- a/src/main/java/com/petshop/backend/service/UserService.java +++ b/src/main/java/com/petshop/backend/service/UserService.java @@ -11,6 +11,12 @@ import org.springframework.data.domain.Pageable; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Locale; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.CONFLICT; @Service public class UserService { @@ -25,10 +31,16 @@ public class UserService { this.userBusinessLinkageService = userBusinessLinkageService; } - public Page getAllUsers(String query, Pageable pageable) { + public Page getAllUsers(String query, String role, Pageable pageable) { + User.Role parsedRole = parseRole(role); Page users; - if (query != null && !query.trim().isEmpty()) { + boolean hasQuery = query != null && !query.trim().isEmpty(); + if (hasQuery && parsedRole != null) { + users = userRepository.searchUsersByRole(query, parsedRole, pageable); + } else if (hasQuery) { users = userRepository.searchUsers(query, pageable); + } else if (parsedRole != null) { + users = userRepository.findByRole(parsedRole, pageable); } else { users = userRepository.findAll(pageable); } @@ -48,17 +60,15 @@ public class UserService { user.setPassword(passwordEncoder.encode(request.getPassword())); user.setFullName(request.getFullName()); user.setEmail(request.getEmail()); + user.setPhone(trimToNull(request.getPhone())); user.setRole(request.getRole()); user.setActive(request.getActive() != null ? request.getActive() : true); + validateUniquePhone(user.getPhone(), null); + user = userRepository.save(user); - // Create or link business entity based on role - if (user.getRole() == User.Role.STAFF || user.getRole() == User.Role.ADMIN) { - userBusinessLinkageService.ensureLinkedEmployee(user); - } else if (user.getRole() == User.Role.CUSTOMER) { - userBusinessLinkageService.ensureLinkedCustomer(user); - } + userBusinessLinkageService.syncLinkedRecords(user); return mapToResponse(user); } @@ -80,6 +90,11 @@ public class UserService { } user.setFullName(request.getFullName()); user.setEmail(request.getEmail()); + String phone = trimToNull(request.getPhone()); + if (!java.util.Objects.equals(user.getPhone(), phone)) { + validateUniquePhone(phone, user.getId()); + } + user.setPhone(phone); user.setRole(request.getRole()); user.setActive(request.getActive() != null ? request.getActive() : true); if (invalidateToken) { @@ -87,6 +102,7 @@ public class UserService { } user = userRepository.save(user); + userBusinessLinkageService.syncLinkedRecords(user); return mapToResponse(user); } @@ -109,10 +125,42 @@ public class UserService { response.setUsername(user.getUsername()); response.setFullName(user.getFullName()); response.setEmail(user.getEmail()); + response.setPhone(user.getPhone()); response.setRole(user.getRole().toString()); response.setActive(user.getActive()); response.setCreatedAt(user.getCreatedAt()); response.setUpdatedAt(user.getUpdatedAt()); return response; } + + private void validateUniquePhone(String phone, Long currentUserId) { + if (phone == null || phone.isBlank()) { + return; + } + userRepository.findByPhone(phone) + .filter(existing -> !existing.getId().equals(currentUserId)) + .ifPresent(existing -> { + throw new ResponseStatusException(CONFLICT, "Phone already exists"); + }); + } + + private String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private User.Role parseRole(String role) { + String normalizedRole = trimToNull(role); + if (normalizedRole == null) { + return null; + } + try { + return User.Role.valueOf(normalizedRole.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ex) { + throw new ResponseStatusException(BAD_REQUEST, "Invalid value for parameter: role"); + } + } } diff --git a/src/main/resources/db/migration/V6__user_phone.sql b/src/main/resources/db/migration/V6__user_phone.sql new file mode 100644 index 00000000..95478fdc --- /dev/null +++ b/src/main/resources/db/migration/V6__user_phone.sql @@ -0,0 +1,8 @@ +ALTER TABLE users + ADD COLUMN phone VARCHAR(20) NULL AFTER fullName; + +UPDATE users u +LEFT JOIN customer c ON c.user_id = u.id +LEFT JOIN employee e ON e.user_id = u.id +SET u.phone = COALESCE(NULLIF(c.phone, ''), NULLIF(e.phone, ''), u.phone) +WHERE u.phone IS NULL OR u.phone = ''; diff --git a/src/main/resources/db/migration/V7__employee_customer_phone_cutover.sql b/src/main/resources/db/migration/V7__employee_customer_phone_cutover.sql new file mode 100644 index 00000000..fa922a82 --- /dev/null +++ b/src/main/resources/db/migration/V7__employee_customer_phone_cutover.sql @@ -0,0 +1,11 @@ +UPDATE users u +LEFT JOIN customer c ON c.user_id = u.id +LEFT JOIN employee e ON e.user_id = u.id +SET u.phone = COALESCE(NULLIF(u.phone, ''), NULLIF(c.phone, ''), NULLIF(e.phone, '')) +WHERE u.phone IS NULL OR u.phone = ''; + +ALTER TABLE customer + DROP COLUMN phone; + +ALTER TABLE employee + DROP COLUMN phone; diff --git a/src/test/java/com/petshop/backend/security/JwtAuthenticationFilterTest.java b/src/test/java/com/petshop/backend/security/JwtAuthenticationFilterTest.java index fa8b429c..4d7ce01a 100644 --- a/src/test/java/com/petshop/backend/security/JwtAuthenticationFilterTest.java +++ b/src/test/java/com/petshop/backend/security/JwtAuthenticationFilterTest.java @@ -1,6 +1,7 @@ package com.petshop.backend.security; import com.petshop.backend.entity.User; +import com.petshop.backend.exception.ApiErrorResponder; import com.petshop.backend.repository.UserRepository; import jakarta.servlet.FilterChain; import org.junit.jupiter.api.AfterEach; @@ -42,7 +43,7 @@ class JwtAuthenticationFilterTest { User user = buildUser(); String token = jwtUtil.generateToken(user); AtomicBoolean chainCalled = new AtomicBoolean(false); - JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtUtil, userRepositoryFor(user)); + JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtUtil, userRepositoryFor(user), new ApiErrorResponder()); MockHttpServletRequest request = new MockHttpServletRequest(); request.addHeader("Authorization", "Bearer " + token); @@ -63,7 +64,7 @@ class JwtAuthenticationFilterTest { User user = buildUser(); user.setActive(false); String token = jwtUtil.generateToken(user); - JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtUtil, userRepositoryFor(user)); + JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtUtil, userRepositoryFor(user), new ApiErrorResponder()); MockHttpServletRequest request = new MockHttpServletRequest(); request.addHeader("Authorization", "Bearer " + token); @@ -73,6 +74,8 @@ class JwtAuthenticationFilterTest { }); assertEquals(401, response.getStatus()); + assertTrue(response.getContentAsString().contains("\"message\":\"User account is inactive\"")); + assertTrue(response.getContentAsString().contains("\"path\":\"\"")); assertNull(SecurityContextHolder.getContext().getAuthentication()); } @@ -81,7 +84,7 @@ class JwtAuthenticationFilterTest { User user = buildUser(); String token = jwtUtil.generateToken(user); user.setTokenVersion(4); - JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtUtil, userRepositoryFor(user)); + JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtUtil, userRepositoryFor(user), new ApiErrorResponder()); MockHttpServletRequest request = new MockHttpServletRequest(); request.addHeader("Authorization", "Bearer " + token); @@ -91,6 +94,7 @@ class JwtAuthenticationFilterTest { }); assertEquals(401, response.getStatus()); + assertTrue(response.getContentAsString().contains("\"message\":\"Invalid or expired token\"")); assertNull(SecurityContextHolder.getContext().getAuthentication()); }