Implement chat features
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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"));
|
||||
|
||||
Reference in New Issue
Block a user