Implement chat features

This commit is contained in:
2026-04-09 21:48:39 -06:00
parent 4bd44727a5
commit 6b055c4364
16 changed files with 518 additions and 113 deletions

View File

@@ -0,0 +1,50 @@
package com.petshop.backend.controller;
import com.petshop.backend.service.ChatAttachmentService;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/chat/attachments")
public class ChatAttachmentController {
private final ChatAttachmentService attachmentService;
public ChatAttachmentController(ChatAttachmentService attachmentService) {
this.attachmentService = attachmentService;
}
@PostMapping
@PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')")
public ResponseEntity<?> uploadAttachment(@RequestParam("file") MultipartFile file) {
if (file.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("message", "File is empty"));
}
try {
return ResponseEntity.ok(attachmentService.storeAttachment(file));
} catch (IOException e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message", "Failed to store attachment: " + e.getMessage()));
}
}
@GetMapping("/{filename}")
@PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')")
public ResponseEntity<Resource> getAttachment(@PathVariable String filename) {
try {
Resource resource = attachmentService.loadAttachmentResource(filename);
return ResponseEntity.ok()
.contentType(attachmentService.resolveMediaType(filename))
.body(resource);
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
}

View File

@@ -11,6 +11,7 @@ public class ConversationResponse {
private String status;
private String mode;
private String lastMessage;
private Long lastSenderId;
private LocalDateTime humanRequestedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@@ -18,19 +19,20 @@ public class ConversationResponse {
public ConversationResponse() {
}
public ConversationResponse(Long id, Long customerId, Long staffId, String status, String mode, String lastMessage, LocalDateTime humanRequestedAt, LocalDateTime createdAt, LocalDateTime updatedAt) {
public ConversationResponse(Long id, Long customerId, Long staffId, String status, String mode, String lastMessage, Long lastSenderId, LocalDateTime humanRequestedAt, LocalDateTime createdAt, LocalDateTime updatedAt) {
this.id = id;
this.customerId = customerId;
this.staffId = staffId;
this.status = status;
this.mode = mode;
this.lastMessage = lastMessage;
this.lastSenderId = lastSenderId;
this.humanRequestedAt = humanRequestedAt;
this.createdAt = createdAt;
this.updatedAt = updatedAt;
}
public static ConversationResponse fromEntity(Conversation conversation, String lastMessage) {
public static ConversationResponse fromEntity(Conversation conversation, String lastMessage, Long lastSenderId) {
ConversationResponse response = new ConversationResponse();
response.setId(conversation.getId());
response.setCustomerId(conversation.getCustomerId());
@@ -38,12 +40,21 @@ public class ConversationResponse {
response.setStatus(conversation.getStatus().name());
response.setMode(conversation.getMode().name());
response.setLastMessage(lastMessage);
response.setLastSenderId(lastSenderId);
response.setHumanRequestedAt(conversation.getHumanRequestedAt());
response.setCreatedAt(conversation.getCreatedAt());
response.setUpdatedAt(conversation.getUpdatedAt());
return response;
}
public Long getLastSenderId() {
return lastSenderId;
}
public void setLastSenderId(Long lastSenderId) {
this.lastSenderId = lastSenderId;
}
public Long getId() {
return id;
}

View File

@@ -8,6 +8,7 @@ public class MessageResponse {
private Long id;
private Long conversationId;
private Long senderId;
private String senderAvatarUrl;
private String content;
private LocalDateTime timestamp;
private Boolean isRead;
@@ -19,10 +20,11 @@ public class MessageResponse {
public MessageResponse() {
}
public MessageResponse(Long id, Long conversationId, Long senderId, String content, LocalDateTime timestamp, Boolean isRead) {
public MessageResponse(Long id, Long conversationId, Long senderId, String senderAvatarUrl, String content, LocalDateTime timestamp, Boolean isRead) {
this.id = id;
this.conversationId = conversationId;
this.senderId = senderId;
this.senderAvatarUrl = senderAvatarUrl;
this.content = content;
this.timestamp = timestamp;
this.isRead = isRead;
@@ -48,6 +50,14 @@ public class MessageResponse {
return response;
}
public String getSenderAvatarUrl() {
return senderAvatarUrl;
}
public void setSenderAvatarUrl(String senderAvatarUrl) {
this.senderAvatarUrl = senderAvatarUrl;
}
public Long getId() {
return id;
}

View File

@@ -0,0 +1,56 @@
package com.petshop.backend.service;
import org.springframework.core.io.PathResource;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.UUID;
@Service
public class ChatAttachmentService {
private static final String STORED_PREFIX = "/uploads/chat/";
private final Path chatDirectory = Paths.get("uploads", "chat").toAbsolutePath().normalize();
public record AttachmentMetadata(String url, String fileName, String mimeType, long size) {}
public AttachmentMetadata storeAttachment(MultipartFile file) throws IOException {
Files.createDirectories(chatDirectory);
String originalFilename = file.getOriginalFilename();
String extension = resolveExtension(originalFilename);
String filename = UUID.randomUUID() + extension;
Path filePath = chatDirectory.resolve(filename).normalize();
Files.copy(file.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING);
String url = "/api/v1/chat/attachments/" + filename;
return new AttachmentMetadata(url, originalFilename, file.getContentType(), file.getSize());
}
public Resource loadAttachmentResource(String filename) {
Path filePath = chatDirectory.resolve(filename).normalize();
if (!filePath.startsWith(chatDirectory) || !Files.exists(filePath) || !Files.isRegularFile(filePath)) {
throw new IllegalArgumentException("Attachment file was not found");
}
return new PathResource(filePath);
}
public MediaType resolveMediaType(String filename) {
return MediaTypeFactory.getMediaType(filename).orElse(MediaType.APPLICATION_OCTET_STREAM);
}
private String resolveExtension(String originalFilename) {
if (originalFilename == null) return "";
int idx = originalFilename.lastIndexOf('.');
return idx < 0 ? "" : originalFilename.substring(idx);
}
}

View File

@@ -41,8 +41,10 @@ public class ChatRealtimeService {
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);
com.petshop.backend.entity.Message last = messages.isEmpty() ? null : messages.get(messages.size() - 1);
String lastMessage = last != null && last.getContent() != null ? last.getContent() : "";
Long lastSenderId = last != null ? last.getSenderId() : null;
ConversationResponse response = ConversationResponse.fromEntity(conversation, lastMessage, lastSenderId);
messagingTemplate.convertAndSend("/topic/chat/conversations", response);
sendConversationToCustomerQueue(response);

View File

@@ -15,9 +15,7 @@ import com.petshop.backend.repository.UserRepository;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
@@ -28,16 +26,16 @@ public class ChatService {
private final ConversationRepository conversationRepository;
private final MessageRepository messageRepository;
private final UserRepository userRepository;
private final ChatAttachmentStorageService attachmentStorageService;
private final AvatarStorageService avatarStorageService;
public ChatService(ConversationRepository conversationRepository,
MessageRepository messageRepository,
UserRepository userRepository,
ChatAttachmentStorageService attachmentStorageService) {
AvatarStorageService avatarStorageService) {
this.conversationRepository = conversationRepository;
this.messageRepository = messageRepository;
this.userRepository = userRepository;
this.attachmentStorageService = attachmentStorageService;
this.avatarStorageService = avatarStorageService;
}
@Transactional
@@ -62,7 +60,7 @@ public class ChatService {
message.setIsRead(false);
messageRepository.save(message);
return ConversationResponse.fromEntity(conversation, request.getMessage());
return ConversationResponse.fromEntity(conversation, request.getMessage(), userId);
}
public List<ConversationResponse> getConversations(Long userId, User.Role role) {
@@ -84,7 +82,8 @@ public class ChatService {
List<Message> messages = messageRepository.findByConversationIdOrderByTimestampAsc(conv.getId());
Message last = messages.isEmpty() ? null : messages.get(messages.size() - 1);
String lastMessage = last != null && last.getContent() != null ? last.getContent() : "";
return ConversationResponse.fromEntity(conv, lastMessage);
Long lastSenderId = last != null ? last.getSenderId() : null;
return ConversationResponse.fromEntity(conv, lastMessage, lastSenderId);
})
.collect(Collectors.toList());
}
@@ -105,8 +104,9 @@ public class ChatService {
List<Message> messages = messageRepository.findByConversationIdOrderByTimestampAsc(conversationId);
Message last = messages.isEmpty() ? null : messages.get(messages.size() - 1);
String lastMessage = last != null && last.getContent() != null ? last.getContent() : "";
Long lastSenderId = last != null ? last.getSenderId() : null;
return ConversationResponse.fromEntity(conversation, lastMessage);
return ConversationResponse.fromEntity(conversation, lastMessage, lastSenderId);
}
@Transactional
@@ -147,53 +147,7 @@ public class ChatService {
conversationRepository.save(conversation);
}
return MessageResponse.fromEntity(message);
}
@Transactional
public MessageResponse sendMessageWithAttachment(Long conversationId, Long userId, User.Role role, MultipartFile file, String content) {
Conversation conversation = conversationRepository.findById(conversationId)
.orElseThrow(() -> new ResourceNotFoundException("Conversation not found"));
if (conversation.getStatus() == Conversation.ConversationStatus.CLOSED) {
throw new AccessDeniedException("Conversation is closed");
}
if (!hasConversationAccess(conversation, userId, role)) {
if (role == User.Role.CUSTOMER) {
throw new AccessDeniedException("You can only send messages to your own conversations");
}
if (role == User.Role.STAFF) {
throw new AccessDeniedException("You can only reply to conversations assigned to you or unassigned conversations");
}
}
try {
String attachmentUrl = attachmentStorageService.storeAttachment(file);
Message message = new Message();
message.setConversationId(conversationId);
message.setSenderId(userId);
message.setContent(content);
message.setAttachmentUrl(attachmentUrl);
message.setAttachmentName(file.getOriginalFilename());
message.setAttachmentMimeType(file.getContentType());
message.setAttachmentSizeBytes(file.getSize());
message.setIsRead(false);
message = messageRepository.save(message);
if (role == User.Role.STAFF && conversation.getStaffId() == null) {
conversation.setStaffId(userId);
}
if (role == User.Role.STAFF) {
conversation.setMode(Conversation.ConversationMode.HUMAN);
conversationRepository.save(conversation);
}
return MessageResponse.fromEntity(message);
} catch (IOException e) {
throw new RuntimeException("Failed to store attachment", e);
}
return toMessageResponse(message);
}
@Transactional
@@ -217,7 +171,8 @@ public class ChatService {
List<Message> messages = messageRepository.findByConversationIdOrderByTimestampAsc(conversationId);
Message last = messages.isEmpty() ? null : messages.get(messages.size() - 1);
String lastMessage = last != null && last.getContent() != null ? last.getContent() : "";
return ConversationResponse.fromEntity(conversation, lastMessage);
Long lastSenderId = last != null ? last.getSenderId() : null;
return ConversationResponse.fromEntity(conversation, lastMessage, lastSenderId);
}
@Transactional
@@ -240,7 +195,8 @@ public class ChatService {
List<Message> messages = messageRepository.findByConversationIdOrderByTimestampAsc(conversationId);
Message last = messages.isEmpty() ? null : messages.get(messages.size() - 1);
String lastMessage = last != null && last.getContent() != null ? last.getContent() : "";
return ConversationResponse.fromEntity(conversation, lastMessage);
Long lastSenderId = last != null ? last.getSenderId() : null;
return ConversationResponse.fromEntity(conversation, lastMessage, lastSenderId);
}
public List<MessageResponse> getMessages(Long conversationId, Long userId, User.Role role) {
@@ -258,10 +214,20 @@ public class ChatService {
List<Message> messages = messageRepository.findByConversationIdOrderByTimestampAsc(conversationId);
return messages.stream()
.map(MessageResponse::fromEntity)
.map(this::toMessageResponse)
.collect(Collectors.toList());
}
private MessageResponse toMessageResponse(Message message) {
MessageResponse response = MessageResponse.fromEntity(message);
userRepository.findById(message.getSenderId()).ifPresent(user -> {
if (avatarStorageService.hasAvatar(user)) {
response.setSenderAvatarUrl(avatarStorageService.toOwnerAvatarUrl(user));
}
});
return response;
}
public boolean hasConversationAccess(Long conversationId, Long userId, User.Role role) {
Conversation conversation = conversationRepository.findById(conversationId)
.orElseThrow(() -> new ResourceNotFoundException("Conversation not found"));