Add chat websocket flow

This commit is contained in:
2026-03-10 18:56:18 -06:00
parent 1a2f551c06
commit 84c5f3c7b1
7 changed files with 310 additions and 19 deletions

View File

@@ -0,0 +1,140 @@
package com.petshop.backend.config;
import com.petshop.backend.entity.User;
import com.petshop.backend.repository.UserRepository;
import com.petshop.backend.security.JwtUtil;
import com.petshop.backend.service.ChatService;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Component;
import java.security.Principal;
import java.util.Collections;
import java.util.List;
@Component
public class WebSocketAuthChannelInterceptor implements ChannelInterceptor {
private final JwtUtil jwtUtil;
private final UserRepository userRepository;
private final ChatService chatService;
public WebSocketAuthChannelInterceptor(JwtUtil jwtUtil, UserRepository userRepository, ChatService chatService) {
this.jwtUtil = jwtUtil;
this.userRepository = userRepository;
this.chatService = chatService;
}
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
StompCommand command = accessor.getCommand();
if (command == null) {
return message;
}
if (StompCommand.CONNECT.equals(command)) {
String tokenHeader = firstHeader(accessor, "Authorization");
String token = extractToken(tokenHeader != null ? tokenHeader : firstHeader(accessor, "token"));
if (token == null || token.isBlank()) {
throw new IllegalArgumentException("Missing websocket token");
}
String username = jwtUtil.extractUsername(token);
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new IllegalArgumentException("User not found"));
if (user.getActive() == null || !user.getActive()) {
throw new IllegalArgumentException("User account is inactive");
}
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
user.getUsername(),
null,
Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + user.getRole().name()))
);
accessor.setUser(authentication);
return message;
}
Principal principal = accessor.getUser();
if (principal == null) {
throw new IllegalArgumentException("Unauthenticated websocket session");
}
User user = userRepository.findByUsername(principal.getName())
.orElseThrow(() -> new IllegalArgumentException("User not found"));
if (StompCommand.SUBSCRIBE.equals(command)) {
authorizeSubscription(accessor.getDestination(), user);
} else if (StompCommand.SEND.equals(command)) {
authorizeSend(accessor.getDestination(), user);
}
return message;
}
private void authorizeSubscription(String destination, User user) {
if (destination == null || destination.startsWith("/user/queue/")) {
return;
}
if ("/topic/chat/conversations".equals(destination)) {
if (user.getRole() == User.Role.CUSTOMER) {
throw new IllegalArgumentException("Customers cannot subscribe to staff conversation feed");
}
return;
}
Long conversationId = extractConversationId(destination, "/topic/chat/conversations/");
if (conversationId != null && chatService.hasConversationAccess(conversationId, user.getId(), user.getRole())) {
return;
}
throw new IllegalArgumentException("Not authorized to subscribe to destination");
}
private void authorizeSend(String destination, User user) {
Long conversationId = extractConversationId(destination, "/app/chat/conversations/");
if (conversationId != null && destination.endsWith("/messages") && chatService.hasConversationAccess(conversationId, user.getId(), user.getRole())) {
return;
}
throw new IllegalArgumentException("Not authorized to send to destination");
}
private Long extractConversationId(String destination, String prefix) {
if (destination == null || !destination.startsWith(prefix)) {
return null;
}
String suffix = destination.substring(prefix.length());
String[] parts = suffix.split("/");
if (parts.length == 0 || parts[0].isBlank()) {
return null;
}
try {
return Long.parseLong(parts[0]);
} catch (NumberFormatException ex) {
return null;
}
}
private String firstHeader(StompHeaderAccessor accessor, String name) {
List<String> values = accessor.getNativeHeader(name);
return values == null || values.isEmpty() ? null : values.get(0);
}
private String extractToken(String rawValue) {
if (rawValue == null || rawValue.isBlank()) {
return null;
}
return rawValue.startsWith("Bearer ") ? rawValue.substring(7) : rawValue;
}
}

View File

@@ -1,6 +1,7 @@
package com.petshop.backend.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.ChannelRegistration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
@@ -10,15 +11,29 @@ import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerCo
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private final WebSocketAuthChannelInterceptor webSocketAuthChannelInterceptor;
public WebSocketConfig(WebSocketAuthChannelInterceptor webSocketAuthChannelInterceptor) {
this.webSocketAuthChannelInterceptor = webSocketAuthChannelInterceptor;
}
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic", "/queue");
config.setApplicationDestinationPrefixes("/app");
config.setUserDestinationPrefix("/user");
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(webSocketAuthChannelInterceptor);
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws/chat")
.setAllowedOriginPatterns("*");
registry.addEndpoint("/ws/chat-sockjs")
.setAllowedOriginPatterns("*")
.withSockJS();
}

View File

@@ -7,6 +7,7 @@ import com.petshop.backend.dto.chat.MessageResponse;
import com.petshop.backend.entity.User;
import com.petshop.backend.repository.CustomerRepository;
import com.petshop.backend.repository.UserRepository;
import com.petshop.backend.service.ChatRealtimeService;
import com.petshop.backend.service.ChatService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
@@ -24,11 +25,13 @@ import java.util.List;
public class ChatController {
private final ChatService chatService;
private final ChatRealtimeService chatRealtimeService;
private final UserRepository userRepository;
private final CustomerRepository customerRepository;
public ChatController(ChatService chatService, UserRepository userRepository, CustomerRepository customerRepository) {
public ChatController(ChatService chatService, ChatRealtimeService chatRealtimeService, UserRepository userRepository, CustomerRepository customerRepository) {
this.chatService = chatService;
this.chatRealtimeService = chatRealtimeService;
this.userRepository = userRepository;
this.customerRepository = customerRepository;
}
@@ -44,6 +47,7 @@ public class ChatController {
public ResponseEntity<ConversationResponse> createConversation(@Valid @RequestBody ConversationRequest request) {
User user = getCurrentUser();
ConversationResponse response = chatService.createConversation(user.getId(), request);
chatRealtimeService.publishNewConversation(response);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@@ -70,6 +74,8 @@ public class ChatController {
@Valid @RequestBody MessageRequest request) {
User user = getCurrentUser();
MessageResponse message = chatService.sendMessage(id, user.getId(), user.getRole(), request);
chatRealtimeService.publishMessage(id, message);
chatRealtimeService.publishConversationUpdate(id);
return ResponseEntity.status(HttpStatus.CREATED).body(message);
}

View File

@@ -0,0 +1,39 @@
package com.petshop.backend.controller;
import com.petshop.backend.dto.chat.MessageRequest;
import com.petshop.backend.dto.chat.MessageResponse;
import com.petshop.backend.entity.User;
import com.petshop.backend.repository.UserRepository;
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.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.simp.annotation.SendToUser;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Controller;
@Controller
public class ChatWebSocketController {
private final ChatService chatService;
private final ChatRealtimeService chatRealtimeService;
private final UserRepository userRepository;
public ChatWebSocketController(ChatService chatService, ChatRealtimeService chatRealtimeService, UserRepository userRepository) {
this.chatService = chatService;
this.chatRealtimeService = chatRealtimeService;
this.userRepository = userRepository;
}
@MessageMapping("/chat/conversations/{id}/messages")
@SendToUser("/queue/chat/errors")
public void sendMessage(@DestinationVariable Long id, @Valid @Payload MessageRequest request, Authentication authentication) {
User user = userRepository.findByUsername(authentication.getName())
.orElseThrow(() -> new IllegalArgumentException("User not found"));
MessageResponse message = chatService.sendMessage(id, user.getId(), user.getRole(), request);
chatRealtimeService.publishMessage(id, message);
chatRealtimeService.publishConversationUpdate(id);
}
}

View File

@@ -38,6 +38,7 @@ public class SecurityConfig {
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/auth/login", "/api/v1/auth/register").permitAll()
.requestMatchers("/api/v1/health").permitAll()
.requestMatchers("/ws/chat/**", "/ws/chat-sockjs/**").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/pets/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/products/**").permitAll()

View File

@@ -0,0 +1,75 @@
package com.petshop.backend.service;
import com.petshop.backend.dto.chat.ConversationResponse;
import com.petshop.backend.dto.chat.MessageResponse;
import com.petshop.backend.entity.Conversation;
import com.petshop.backend.entity.Customer;
import com.petshop.backend.entity.User;
import com.petshop.backend.exception.ResourceNotFoundException;
import com.petshop.backend.repository.ConversationRepository;
import com.petshop.backend.repository.CustomerRepository;
import com.petshop.backend.repository.MessageRepository;
import com.petshop.backend.repository.UserRepository;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class ChatRealtimeService {
private final SimpMessagingTemplate messagingTemplate;
private final ConversationRepository conversationRepository;
private final MessageRepository messageRepository;
private final CustomerRepository customerRepository;
private final UserRepository userRepository;
public ChatRealtimeService(SimpMessagingTemplate messagingTemplate, ConversationRepository conversationRepository, MessageRepository messageRepository, CustomerRepository customerRepository, UserRepository userRepository) {
this.messagingTemplate = messagingTemplate;
this.conversationRepository = conversationRepository;
this.messageRepository = messageRepository;
this.customerRepository = customerRepository;
this.userRepository = userRepository;
}
public void publishNewConversation(ConversationResponse conversation) {
messagingTemplate.convertAndSend("/topic/chat/conversations", conversation);
sendConversationToCustomerQueue(conversation);
}
public void publishMessage(Long conversationId, MessageResponse message) {
messagingTemplate.convertAndSend("/topic/chat/conversations/" + conversationId, message);
}
public void publishConversationUpdate(Long conversationId) {
Conversation conversation = conversationRepository.findById(conversationId)
.orElseThrow(() -> new ResourceNotFoundException("Conversation not found"));
List<com.petshop.backend.entity.Message> messages = messageRepository.findByConversationIdOrderByTimestampAsc(conversationId);
String lastMessage = messages.isEmpty() ? "" : messages.get(messages.size() - 1).getContent();
ConversationResponse response = ConversationResponse.fromEntity(conversation, lastMessage);
messagingTemplate.convertAndSend("/topic/chat/conversations", response);
sendConversationToCustomerQueue(response);
sendConversationToStaffQueue(response);
}
private void sendConversationToCustomerQueue(ConversationResponse conversation) {
Customer customer = customerRepository.findById(conversation.getCustomerId())
.orElseThrow(() -> new ResourceNotFoundException("Customer not found"));
if (customer.getUserId() == null) {
return;
}
User customerUser = userRepository.findById(customer.getUserId())
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
messagingTemplate.convertAndSendToUser(customerUser.getUsername(), "/queue/chat/conversations", conversation);
}
private void sendConversationToStaffQueue(ConversationResponse conversation) {
if (conversation.getStaffId() == null) {
return;
}
User staffUser = userRepository.findById(conversation.getStaffId())
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
messagingTemplate.convertAndSendToUser(staffUser.getUsername(), "/queue/chat/conversations", conversation);
}
}

View File

@@ -94,14 +94,11 @@ public class ChatService {
Conversation conversation = conversationRepository.findById(conversationId)
.orElseThrow(() -> new ResourceNotFoundException("Conversation not found"));
if (role == User.Role.CUSTOMER) {
Customer customer = customerRepository.findByUserId(userId)
.orElseThrow(() -> new ResourceNotFoundException("Customer record not found for user"));
if (!conversation.getCustomerId().equals(customer.getCustomerId())) {
if (!hasConversationAccess(conversation, userId, role)) {
if (role == User.Role.CUSTOMER) {
throw new AccessDeniedException("You can only view your own conversations");
}
} else if (role == User.Role.STAFF) {
if (conversation.getStaffId() != null && !conversation.getStaffId().equals(userId)) {
if (role == User.Role.STAFF) {
throw new AccessDeniedException("You can only view conversations assigned to you or unassigned conversations");
}
}
@@ -117,14 +114,11 @@ public class ChatService {
Conversation conversation = conversationRepository.findById(conversationId)
.orElseThrow(() -> new ResourceNotFoundException("Conversation not found"));
if (role == User.Role.CUSTOMER) {
Customer customer = customerRepository.findByUserId(userId)
.orElseThrow(() -> new ResourceNotFoundException("Customer record not found for user"));
if (!conversation.getCustomerId().equals(customer.getCustomerId())) {
if (!hasConversationAccess(conversation, userId, role)) {
if (role == User.Role.CUSTOMER) {
throw new AccessDeniedException("You can only send messages to your own conversations");
}
} else if (role == User.Role.STAFF) {
if (conversation.getStaffId() != null && !conversation.getStaffId().equals(userId)) {
if (role == User.Role.STAFF) {
throw new AccessDeniedException("You can only reply to conversations assigned to you or unassigned conversations");
}
}
@@ -148,14 +142,11 @@ public class ChatService {
Conversation conversation = conversationRepository.findById(conversationId)
.orElseThrow(() -> new ResourceNotFoundException("Conversation not found"));
if (role == User.Role.CUSTOMER) {
Customer customer = customerRepository.findByUserId(userId)
.orElseThrow(() -> new ResourceNotFoundException("Customer record not found for user"));
if (!conversation.getCustomerId().equals(customer.getCustomerId())) {
if (!hasConversationAccess(conversation, userId, role)) {
if (role == User.Role.CUSTOMER) {
throw new AccessDeniedException("You can only view messages from your own conversations");
}
} else if (role == User.Role.STAFF) {
if (conversation.getStaffId() != null && !conversation.getStaffId().equals(userId)) {
if (role == User.Role.STAFF) {
throw new AccessDeniedException("You can only view messages from conversations assigned to you or unassigned conversations");
}
}
@@ -165,4 +156,28 @@ public class ChatService {
.map(MessageResponse::fromEntity)
.collect(Collectors.toList());
}
public boolean hasConversationAccess(Long conversationId, Long userId, User.Role role) {
Conversation conversation = conversationRepository.findById(conversationId)
.orElseThrow(() -> new ResourceNotFoundException("Conversation not found"));
return hasConversationAccess(conversation, userId, role);
}
private boolean hasConversationAccess(Conversation conversation, Long userId, User.Role role) {
if (role == User.Role.ADMIN) {
return true;
}
if (role == User.Role.CUSTOMER) {
Customer customer = customerRepository.findByUserId(userId)
.orElseThrow(() -> new ResourceNotFoundException("Customer record not found for user"));
return conversation.getCustomerId().equals(customer.getCustomerId());
}
if (role == User.Role.STAFF) {
return conversation.getStaffId() == null || conversation.getStaffId().equals(userId);
}
return false;
}
}