added attachments to chat

This commit is contained in:
Alex
2026-04-09 17:39:45 -06:00
parent 3db45bde6c
commit f3932b226d
13 changed files with 485 additions and 44 deletions

View File

@@ -5,17 +5,24 @@ import com.petshop.backend.dto.chat.ConversationResponse;
import com.petshop.backend.dto.chat.MessageRequest;
import com.petshop.backend.dto.chat.MessageResponse;
import com.petshop.backend.dto.chat.UpdateConversationRequest;
import com.petshop.backend.entity.Message;
import com.petshop.backend.entity.User;
import com.petshop.backend.repository.MessageRepository;
import com.petshop.backend.repository.UserRepository;
import com.petshop.backend.service.ChatAttachmentStorageService;
import com.petshop.backend.service.ChatRealtimeService;
import com.petshop.backend.service.ChatService;
import com.petshop.backend.util.AuthenticationHelper;
import jakarta.validation.Valid;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
@@ -26,11 +33,17 @@ public class ChatController {
private final ChatService chatService;
private final ChatRealtimeService chatRealtimeService;
private final UserRepository userRepository;
private final ChatAttachmentStorageService attachmentStorageService;
private final MessageRepository messageRepository;
public ChatController(ChatService chatService, ChatRealtimeService chatRealtimeService, UserRepository userRepository) {
public ChatController(ChatService chatService, ChatRealtimeService chatRealtimeService,
UserRepository userRepository, ChatAttachmentStorageService attachmentStorageService,
MessageRepository messageRepository) {
this.chatService = chatService;
this.chatRealtimeService = chatRealtimeService;
this.userRepository = userRepository;
this.attachmentStorageService = attachmentStorageService;
this.messageRepository = messageRepository;
}
private User getCurrentUser() {
@@ -78,6 +91,44 @@ public class ChatController {
return ResponseEntity.status(HttpStatus.CREATED).body(message);
}
@PostMapping(value = "/conversations/{id}/attachments", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')")
public ResponseEntity<MessageResponse> sendMessageWithAttachment(
@PathVariable Long id,
@RequestParam("file") MultipartFile file) {
User user = getCurrentUser();
MessageResponse message = chatService.sendMessageWithAttachment(id, user.getId(), user.getRole(), file);
chatRealtimeService.publishMessage(id, message);
chatRealtimeService.publishConversationUpdate(id);
return ResponseEntity.status(HttpStatus.CREATED).body(message);
}
@GetMapping("/messages/{messageId}/attachment")
@PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')")
public ResponseEntity<Resource> getMessageAttachment(@PathVariable Long messageId) {
User user = getCurrentUser();
Message message = messageRepository.findById(messageId)
.orElseThrow(() -> new RuntimeException("Message not found"));
if (!chatService.hasConversationAccess(message.getConversationId(), user.getId(), user.getRole())) {
throw new AccessDeniedException("Access denied to this message attachment");
}
if (message.getAttachmentUrl() == null) {
return ResponseEntity.notFound().build();
}
try {
Resource resource = attachmentStorageService.loadAttachmentResource(message.getAttachmentUrl());
return ResponseEntity.ok()
.contentType(attachmentStorageService.resolveMediaType(message.getAttachmentUrl()))
.header("Content-Disposition", "attachment; filename=\"" + message.getAttachmentName() + "\"")
.body(resource);
} catch (IllegalArgumentException ex) {
return ResponseEntity.notFound().build();
}
}
@GetMapping("/conversations/{id}/messages")
@PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')")
public ResponseEntity<List<MessageResponse>> getMessages(@PathVariable Long id) {

View File

@@ -36,7 +36,12 @@ public class MessageResponse {
response.setContent(message.getContent());
response.setTimestamp(message.getTimestamp());
response.setIsRead(message.getIsRead());
response.setAttachmentUrl(message.getAttachmentUrl());
if (message.getAttachmentUrl() != null) {
response.setAttachmentUrl("/api/v1/chat/messages/" + message.getId() + "/attachment");
}
response.setAttachmentName(message.getAttachmentName());
response.setAttachmentMimeType(message.getAttachmentMimeType());
response.setAttachmentSizeBytes(message.getAttachmentSizeBytes());

View File

@@ -0,0 +1,71 @@
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 ChatAttachmentStorageService {
private static final String STORED_PREFIX = "/uploads/chat/";
private final Path chatDirectory = Paths.get("uploads", "chat").toAbsolutePath().normalize();
public String storeAttachment(MultipartFile file) throws IOException {
Files.createDirectories(chatDirectory);
String originalFilename = file.getOriginalFilename();
String extension = "";
if (originalFilename != null && originalFilename.contains(".")) {
extension = originalFilename.substring(originalFilename.lastIndexOf("."));
}
String filename = UUID.randomUUID() + extension;
Path filePath = chatDirectory.resolve(filename).normalize();
Files.copy(file.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING);
return STORED_PREFIX + filename;
}
public Resource loadAttachmentResource(String attachmentUrl) {
Path filePath = resolveStoredPath(attachmentUrl);
if (!Files.exists(filePath) || !Files.isRegularFile(filePath)) {
throw new IllegalArgumentException("Attachment file was not found");
}
return new PathResource(filePath);
}
public MediaType resolveMediaType(String attachmentUrl) {
try {
return MediaTypeFactory.getMediaType(loadAttachmentResource(attachmentUrl)).orElse(MediaType.APPLICATION_OCTET_STREAM);
} catch (IllegalArgumentException ex) {
return MediaType.APPLICATION_OCTET_STREAM;
}
}
private Path resolveStoredPath(String attachmentUrl) {
if (attachmentUrl == null || attachmentUrl.isBlank() || !attachmentUrl.startsWith(STORED_PREFIX)) {
throw new IllegalArgumentException("Invalid attachment URL");
}
String filename = attachmentUrl.substring(STORED_PREFIX.length());
if (filename.isBlank() || filename.contains("/") || filename.contains("\\") || filename.contains("..")) {
throw new IllegalArgumentException("Invalid attachment filename");
}
Path resolved = chatDirectory.resolve(filename).normalize();
if (!resolved.startsWith(chatDirectory)) {
throw new IllegalArgumentException("Invalid attachment path");
}
return resolved;
}
}

View File

@@ -15,7 +15,9 @@ 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;
@@ -26,13 +28,16 @@ public class ChatService {
private final ConversationRepository conversationRepository;
private final MessageRepository messageRepository;
private final UserRepository userRepository;
private final ChatAttachmentStorageService attachmentStorageService;
public ChatService(ConversationRepository conversationRepository,
MessageRepository messageRepository,
UserRepository userRepository) {
UserRepository userRepository,
ChatAttachmentStorageService attachmentStorageService) {
this.conversationRepository = conversationRepository;
this.messageRepository = messageRepository;
this.userRepository = userRepository;
this.attachmentStorageService = attachmentStorageService;
}
@Transactional
@@ -145,6 +150,51 @@ public class ChatService {
return MessageResponse.fromEntity(message);
}
@Transactional
public MessageResponse sendMessageWithAttachment(Long conversationId, Long userId, User.Role role, MultipartFile file) {
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.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);
}
}
@Transactional
public ConversationResponse requestHumanTakeover(Long conversationId, Long userId, User.Role role) {
Conversation conversation = conversationRepository.findById(conversationId)