From 801b7dc872c4f34c61a63dc9ead1fb0ae4d57adc Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Thu, 9 Apr 2026 21:48:39 -0600 Subject: [PATCH 1/3] Implement chat features --- .../controller/ChatAttachmentController.java | 50 ++++++ .../dto/chat/ConversationResponse.java | 15 +- .../backend/dto/chat/MessageResponse.java | 12 +- .../service/ChatAttachmentService.java | 56 ++++++ .../backend/service/ChatRealtimeService.java | 6 +- .../petshop/backend/service/ChatService.java | 82 +++------ .../api/ChatRealtimeClient.java | 75 +++++++++ .../api/dto/chat/ChatAttachmentResponse.java | 43 +++++ .../api/dto/chat/ConversationResponse.java | 9 + .../api/dto/chat/MessageResponse.java | 9 + .../petshopdesktop/api/endpoints/ChatApi.java | 6 + .../controllers/ChatController.java | 159 +++++++++++++----- .../controllers/MainLayoutController.java | 27 ++- .../util/FilePickerSupport.java | 47 ++++++ .../petshopdesktop/main-layout-view.fxml | 25 ++- .../petshopdesktop/modelviews/chat-view.fxml | 10 +- 16 files changed, 518 insertions(+), 113 deletions(-) create mode 100644 backend/src/main/java/com/petshop/backend/controller/ChatAttachmentController.java create mode 100644 backend/src/main/java/com/petshop/backend/service/ChatAttachmentService.java create mode 100644 desktop/src/main/java/org/example/petshopdesktop/api/dto/chat/ChatAttachmentResponse.java diff --git a/backend/src/main/java/com/petshop/backend/controller/ChatAttachmentController.java b/backend/src/main/java/com/petshop/backend/controller/ChatAttachmentController.java new file mode 100644 index 00000000..ba91d1f2 --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/controller/ChatAttachmentController.java @@ -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 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(); + } + } +} diff --git a/backend/src/main/java/com/petshop/backend/dto/chat/ConversationResponse.java b/backend/src/main/java/com/petshop/backend/dto/chat/ConversationResponse.java index 86078ab2..cef833cd 100644 --- a/backend/src/main/java/com/petshop/backend/dto/chat/ConversationResponse.java +++ b/backend/src/main/java/com/petshop/backend/dto/chat/ConversationResponse.java @@ -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; } diff --git a/backend/src/main/java/com/petshop/backend/dto/chat/MessageResponse.java b/backend/src/main/java/com/petshop/backend/dto/chat/MessageResponse.java index 2c39e8f1..ce0d413b 100644 --- a/backend/src/main/java/com/petshop/backend/dto/chat/MessageResponse.java +++ b/backend/src/main/java/com/petshop/backend/dto/chat/MessageResponse.java @@ -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; } diff --git a/backend/src/main/java/com/petshop/backend/service/ChatAttachmentService.java b/backend/src/main/java/com/petshop/backend/service/ChatAttachmentService.java new file mode 100644 index 00000000..a6ae312d --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/service/ChatAttachmentService.java @@ -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); + } +} diff --git a/backend/src/main/java/com/petshop/backend/service/ChatRealtimeService.java b/backend/src/main/java/com/petshop/backend/service/ChatRealtimeService.java index b5b808c2..d6b9d380 100644 --- a/backend/src/main/java/com/petshop/backend/service/ChatRealtimeService.java +++ b/backend/src/main/java/com/petshop/backend/service/ChatRealtimeService.java @@ -41,8 +41,10 @@ public class ChatRealtimeService { Conversation conversation = conversationRepository.findById(conversationId) .orElseThrow(() -> new ResourceNotFoundException("Conversation not found")); List 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); diff --git a/backend/src/main/java/com/petshop/backend/service/ChatService.java b/backend/src/main/java/com/petshop/backend/service/ChatService.java index 6ac092ce..15f18a47 100644 --- a/backend/src/main/java/com/petshop/backend/service/ChatService.java +++ b/backend/src/main/java/com/petshop/backend/service/ChatService.java @@ -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 getConversations(Long userId, User.Role role) { @@ -84,7 +82,8 @@ public class ChatService { List 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 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 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 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 getMessages(Long conversationId, Long userId, User.Role role) { @@ -258,10 +214,20 @@ public class ChatService { List 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")); diff --git a/desktop/src/main/java/org/example/petshopdesktop/api/ChatRealtimeClient.java b/desktop/src/main/java/org/example/petshopdesktop/api/ChatRealtimeClient.java index fcb93c0c..0462fe6b 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/api/ChatRealtimeClient.java +++ b/desktop/src/main/java/org/example/petshopdesktop/api/ChatRealtimeClient.java @@ -11,7 +11,9 @@ import java.net.http.WebSocket; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.time.Duration; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; @@ -39,6 +41,10 @@ public class ChatRealtimeClient implements WebSocket.Listener { private Consumer statusListener; private volatile String currentStatus = "Chat disconnected"; + private final Map globalConversations = new HashMap<>(); + private final List> notificationListeners = new ArrayList<>(); + private boolean lastNotificationState = false; + private ChatRealtimeClient() { this.httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) @@ -49,6 +55,59 @@ public class ChatRealtimeClient implements WebSocket.Listener { return INSTANCE; } + public void addNotificationListener(Consumer listener) { + synchronized (lock) { + notificationListeners.add(listener); + listener.accept(lastNotificationState); + } + } + + public void initializeState(List conversations) { + synchronized (lock) { + globalConversations.clear(); + for (ConversationResponse conv : conversations) { + globalConversations.put(conv.getId(), conv); + } + } + updateNotificationState(); + } + + public boolean hasActionableChats() { + synchronized (lock) { + UserSession session = UserSession.getInstance(); + Long currentUserId = session.getUserId(); + for (ConversationResponse conv : globalConversations.values()) { + if ("CLOSED".equals(conv.getStatus())) continue; + + // Needs pickup + if (conv.getHumanRequestedAt() != null && conv.getStaffId() == null) { + return true; + } + + // Needs reply (assigned to me and last sender was someone else - customer) + if (currentUserId != null && currentUserId.equals(conv.getStaffId())) { + if (conv.getLastSenderId() != null && !conv.getLastSenderId().equals(currentUserId)) { + return true; + } + } + } + return false; + } + } + + private void updateNotificationState() { + boolean currentState = hasActionableChats(); + List> listeners; + synchronized (lock) { + if (currentState == lastNotificationState) return; + lastNotificationState = currentState; + listeners = new ArrayList<>(notificationListeners); + } + for (Consumer listener : listeners) { + listener.accept(currentState); + } + } + public void setConversationListener(Consumer conversationListener) { this.conversationListener = conversationListener; } @@ -230,6 +289,8 @@ public class ChatRealtimeClient implements WebSocket.Listener { destinationBySubscription.clear(); conversationsSubscriptionId = null; conversationMessagesSubscriptionId = null; + globalConversations.clear(); + updateNotificationState(); } private void handleFrame(String frame) { @@ -272,11 +333,25 @@ public class ChatRealtimeClient implements WebSocket.Listener { if (messageListener != null) { messageListener.accept(message); } + + // Also update globalConversation last sender if this is the active conversation + synchronized (lock) { + ConversationResponse conv = globalConversations.get(message.getConversationId()); + if (conv != null) { + conv.setLastMessage(message.getContent()); + conv.setLastSenderId(message.getSenderId()); + } + } + updateNotificationState(); } else { ConversationResponse conversation = ApiClient.getInstance().getObjectMapper().readValue(bodyPart, ConversationResponse.class); + synchronized (lock) { + globalConversations.put(conversation.getId(), conversation); + } if (conversationListener != null) { conversationListener.accept(conversation); } + updateNotificationState(); } } catch (Exception e) { publishStatus("Chat update failed"); diff --git a/desktop/src/main/java/org/example/petshopdesktop/api/dto/chat/ChatAttachmentResponse.java b/desktop/src/main/java/org/example/petshopdesktop/api/dto/chat/ChatAttachmentResponse.java new file mode 100644 index 00000000..d8b70c11 --- /dev/null +++ b/desktop/src/main/java/org/example/petshopdesktop/api/dto/chat/ChatAttachmentResponse.java @@ -0,0 +1,43 @@ +package org.example.petshopdesktop.api.dto.chat; + +public class ChatAttachmentResponse { + private String url; + private String fileName; + private String mimeType; + private long size; + + public ChatAttachmentResponse() { + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getFileName() { + return fileName; + } + + public void setFileName(String fileName) { + this.fileName = fileName; + } + + public String getMimeType() { + return mimeType; + } + + public void setMimeType(String mimeType) { + this.mimeType = mimeType; + } + + public long getSize() { + return size; + } + + public void setSize(long size) { + this.size = size; + } +} diff --git a/desktop/src/main/java/org/example/petshopdesktop/api/dto/chat/ConversationResponse.java b/desktop/src/main/java/org/example/petshopdesktop/api/dto/chat/ConversationResponse.java index 1bcdba17..30a24fbb 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/api/dto/chat/ConversationResponse.java +++ b/desktop/src/main/java/org/example/petshopdesktop/api/dto/chat/ConversationResponse.java @@ -9,6 +9,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; @@ -16,6 +17,14 @@ public class ConversationResponse { public ConversationResponse() { } + public Long getLastSenderId() { + return lastSenderId; + } + + public void setLastSenderId(Long lastSenderId) { + this.lastSenderId = lastSenderId; + } + public Long getId() { return id; } diff --git a/desktop/src/main/java/org/example/petshopdesktop/api/dto/chat/MessageResponse.java b/desktop/src/main/java/org/example/petshopdesktop/api/dto/chat/MessageResponse.java index 096a6e58..84e64605 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/api/dto/chat/MessageResponse.java +++ b/desktop/src/main/java/org/example/petshopdesktop/api/dto/chat/MessageResponse.java @@ -6,6 +6,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; @@ -41,6 +42,14 @@ public class MessageResponse { this.senderId = senderId; } + public String getSenderAvatarUrl() { + return senderAvatarUrl; + } + + public void setSenderAvatarUrl(String senderAvatarUrl) { + this.senderAvatarUrl = senderAvatarUrl; + } + public String getContent() { return content; } diff --git a/desktop/src/main/java/org/example/petshopdesktop/api/endpoints/ChatApi.java b/desktop/src/main/java/org/example/petshopdesktop/api/endpoints/ChatApi.java index f6429731..b9898820 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/api/endpoints/ChatApi.java +++ b/desktop/src/main/java/org/example/petshopdesktop/api/endpoints/ChatApi.java @@ -2,11 +2,13 @@ package org.example.petshopdesktop.api.endpoints; import com.fasterxml.jackson.core.type.TypeReference; import org.example.petshopdesktop.api.ApiClient; +import org.example.petshopdesktop.api.dto.chat.ChatAttachmentResponse; import org.example.petshopdesktop.api.dto.chat.ConversationRequest; import org.example.petshopdesktop.api.dto.chat.ConversationResponse; import org.example.petshopdesktop.api.dto.chat.MessageRequest; import org.example.petshopdesktop.api.dto.chat.MessageResponse; +import java.nio.file.Path; import java.util.List; public class ChatApi { @@ -42,4 +44,8 @@ public class ChatApi { public MessageResponse sendMessage(Long conversationId, MessageRequest request) throws Exception { return apiClient.post("/api/v1/chat/conversations/" + conversationId + "/messages", request, MessageResponse.class); } + + public ChatAttachmentResponse uploadAttachment(Path filePath) throws Exception { + return apiClient.postMultipart("/api/v1/chat/attachments", "file", filePath, ChatAttachmentResponse.class); + } } diff --git a/desktop/src/main/java/org/example/petshopdesktop/controllers/ChatController.java b/desktop/src/main/java/org/example/petshopdesktop/controllers/ChatController.java index ed60335d..48a90ed8 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/controllers/ChatController.java +++ b/desktop/src/main/java/org/example/petshopdesktop/controllers/ChatController.java @@ -3,6 +3,7 @@ package org.example.petshopdesktop.controllers; import javafx.application.Platform; import javafx.collections.FXCollections; import javafx.collections.ObservableList; +import javafx.event.ActionEvent; import javafx.fxml.FXML; import javafx.scene.control.Button; import javafx.scene.control.Label; @@ -15,7 +16,11 @@ import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; import javafx.scene.layout.Region; import javafx.scene.layout.VBox; +import javafx.scene.paint.ImagePattern; +import javafx.scene.shape.Circle; +import javafx.scene.image.Image; import org.example.petshopdesktop.api.ChatRealtimeClient; +import org.example.petshopdesktop.api.dto.chat.ChatAttachmentResponse; import org.example.petshopdesktop.api.dto.chat.ConversationResponse; import org.example.petshopdesktop.api.dto.chat.MessageRequest; import org.example.petshopdesktop.api.dto.chat.MessageResponse; @@ -53,6 +58,9 @@ public class ChatController { @FXML private Button btnRefresh; + @FXML + private Button btnAttachment; + @FXML private Label lblConversationTitle; @@ -63,6 +71,7 @@ public class ChatController { private final Map customerLabels = new HashMap<>(); private final ChatRealtimeClient realtimeClient = ChatRealtimeClient.getInstance(); private ConversationResponse selectedConversation; + private ChatAttachmentResponse selectedAttachment; @FXML public void initialize() { @@ -78,7 +87,19 @@ public class ChatController { } Label title = new Label(getConversationTitle(item)); - title.setStyle("-fx-font-weight: bold; -fx-text-fill: #1f2937;"); + + // Bold title if needs attention + UserSession session = UserSession.getInstance(); + Long currentUserId = session.getUserId(); + boolean needsPickup = item.getHumanRequestedAt() != null && item.getStaffId() == null; + boolean needsReply = currentUserId != null && currentUserId.equals(item.getStaffId()) + && item.getLastSenderId() != null && !item.getLastSenderId().equals(currentUserId); + + if (needsPickup || needsReply) { + title.setStyle("-fx-font-weight: bold; -fx-text-fill: #FF6B6B;"); + } else { + title.setStyle("-fx-font-weight: bold; -fx-text-fill: #1f2937;"); + } Label preview = new Label(item.getLastMessage() == null ? "" : item.getLastMessage()); preview.setStyle("-fx-text-fill: #64748b;"); preview.setWrapText(true); @@ -134,14 +155,78 @@ public class ChatController { } String content = txtMessage.getText() == null ? "" : txtMessage.getText().trim(); - if (content.isEmpty()) { + if (content.isEmpty() && selectedAttachment == null) { return; } + Long convId = selectedConversation.getId(); + var attachment = selectedAttachment; + txtMessage.clear(); btnSend.setDisable(true); + selectedAttachment = null; + btnAttachment.setText("📎"); + btnAttachment.setStyle("-fx-background-color: #e2e8f0; -fx-background-radius: 12; -fx-text-fill: #475569; -fx-cursor: hand;"); + lblChatStatus.setText("Sending message..."); - sendMessage(selectedConversation.getId(), content); + + new Thread(() -> { + try { + MessageRequest request = new MessageRequest(content); + if (attachment != null) { + request.setAttachmentUrl(attachment.getUrl()); + request.setAttachmentName(attachment.getFileName()); + request.setAttachmentMimeType(attachment.getMimeType()); + request.setAttachmentSizeBytes(attachment.getSize()); + } + MessageResponse response = ChatApi.getInstance().sendMessage(convId, request); + Platform.runLater(() -> { + btnSend.setDisable(false); + appendMessageIfSelected(response); + lblChatStatus.setText("Message sent"); + }); + } catch (Exception e) { + Platform.runLater(() -> { + txtMessage.setText(content); + btnSend.setDisable(false); + selectedAttachment = attachment; + btnAttachment.setText("📎 " + attachment.getFileName()); + lblChatStatus.setText("Chat send failed"); + ActivityLogger.getInstance().logException( + "ChatController.sendMessage", + e, + "Sending chat message for conversation " + convId); + }); + } + }).start(); + } + + @FXML + void btnAttachmentClicked(ActionEvent event) { + java.io.File file = org.example.petshopdesktop.util.FilePickerSupport.pickAnyFile(btnAttachment.getScene().getWindow()); + if (file == null) return; + + btnAttachment.setDisable(true); + lblChatStatus.setText("Uploading attachment..."); + + new Thread(() -> { + try { + var response = ChatApi.getInstance().uploadAttachment(file.toPath()); + Platform.runLater(() -> { + selectedAttachment = response; + btnAttachment.setText("📎 " + response.getFileName()); + btnAttachment.setStyle("-fx-background-color: #dcfce7; -fx-background-radius: 12; -fx-text-fill: #166534; -fx-cursor: hand;"); + lblChatStatus.setText("File ready to send"); + btnAttachment.setDisable(false); + }); + } catch (Exception e) { + ActivityLogger.getInstance().logException("ChatController.btnAttachmentClicked", e, "Uploading chat attachment"); + Platform.runLater(() -> { + lblChatStatus.setText("Upload failed"); + btnAttachment.setDisable(false); + }); + } + }).start(); } private void loadCustomers() { @@ -207,31 +292,6 @@ public class ChatController { }).start(); } - private void sendMessage(Long conversationId, String content) { - new Thread(() -> { - try { - MessageResponse response = ChatApi.getInstance().sendMessage(conversationId, new MessageRequest(content)); - Platform.runLater(() -> { - btnSend.setDisable(false); - appendMessageIfSelected(response); - if (selectedConversation != null && selectedConversation.getId().equals(conversationId)) { - lblChatStatus.setText("Message sent"); - } - }); - } catch (Exception e) { - Platform.runLater(() -> { - txtMessage.setText(content); - btnSend.setDisable(false); - lblChatStatus.setText("Chat send failed"); - ActivityLogger.getInstance().logException( - "ChatController.sendMessage", - e, - "Sending chat message for conversation " + conversationId); - }); - } - }).start(); - } - private void renderMessages(List messages) { vbMessages.getChildren().clear(); for (MessageResponse message : messages) { @@ -307,6 +367,20 @@ public class ChatController { private HBox createMessageBubble(MessageResponse message) { boolean mine = message.getSenderId() != null && message.getSenderId().equals(UserSession.getInstance().getUserId()); + + Circle avatar = new Circle(16); + avatar.setFill(javafx.scene.paint.Color.web(mine ? "#0f766e" : "#cbd5e1")); + if (message.getSenderAvatarUrl() != null && !message.getSenderAvatarUrl().isBlank()) { + try { + String fullUrl = org.example.petshopdesktop.api.ApiConfig.getInstance().getBaseUrl() + message.getSenderAvatarUrl(); + Image img = new Image(fullUrl, true); + img.errorProperty().addListener((obs, old, err) -> { + if (err) avatar.setFill(javafx.scene.paint.Color.web(mine ? "#0f766e" : "#cbd5e1")); + }); + avatar.setFill(new ImagePattern(img)); + } catch (Exception ignored) {} + } + Label author = new Label(resolveAuthorLabel(message)); author.setStyle("-fx-font-weight: bold; -fx-text-fill: " + (mine ? "#ffffff" : "#1f2937") + ";"); @@ -324,17 +398,23 @@ public class ChatController { } if (message.getAttachmentUrl() != null && !message.getAttachmentUrl().isBlank()) { - String attachmentLabel = message.getAttachmentName(); - if (attachmentLabel == null || attachmentLabel.isBlank()) { - attachmentLabel = "Attachment"; - } + String attachmentLabel = "📎 " + (message.getAttachmentName() == null || message.getAttachmentName().isBlank() ? "Attachment" : message.getAttachmentName()); if (message.getAttachmentSizeBytes() != null && message.getAttachmentSizeBytes() > 0) { attachmentLabel = attachmentLabel + " (" + formatSize(message.getAttachmentSizeBytes()) + ")"; } - Label attachment = new Label(attachmentLabel); - attachment.setWrapText(true); - attachment.setStyle("-fx-text-fill: " + (mine ? "#dbeafe" : "#0f766e") + "; -fx-underline: true;"); - bubble.getChildren().add(attachment); + Button attachmentBtn = new Button(attachmentLabel); + attachmentBtn.setStyle("-fx-background-color: " + (mine ? "rgba(255,255,255,0.15)" : "rgba(0,0,0,0.05)") + "; -fx-text-fill: " + (mine ? "#ffffff" : "#0f766e") + "; -fx-cursor: hand; -fx-background-radius: 8; -fx-padding: 6 10;"); + attachmentBtn.setOnAction(e -> { + try { + String fullUrl = org.example.petshopdesktop.api.ApiConfig.getInstance().getBaseUrl() + message.getAttachmentUrl(); + if (java.awt.Desktop.isDesktopSupported()) { + java.awt.Desktop.getDesktop().browse(new java.net.URI(fullUrl)); + } + } catch (Exception ex) { + ActivityLogger.getInstance().logException("ChatController.attachmentOpen", ex, "Opening attachment URL"); + } + }); + bubble.getChildren().add(attachmentBtn); } bubble.getChildren().add(timestamp); @@ -346,10 +426,13 @@ public class ChatController { Region spacer = new Region(); HBox.setHgrow(spacer, Priority.ALWAYS); HBox container = new HBox(12); + container.setAlignment(javafx.geometry.Pos.BOTTOM_LEFT); + if (mine) { - container.getChildren().addAll(spacer, bubble); + container.getChildren().addAll(spacer, bubble, avatar); + container.setAlignment(javafx.geometry.Pos.BOTTOM_RIGHT); } else { - container.getChildren().addAll(bubble, spacer); + container.getChildren().addAll(avatar, bubble, spacer); } return container; } diff --git a/desktop/src/main/java/org/example/petshopdesktop/controllers/MainLayoutController.java b/desktop/src/main/java/org/example/petshopdesktop/controllers/MainLayoutController.java index 1b10ce19..69916845 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/controllers/MainLayoutController.java +++ b/desktop/src/main/java/org/example/petshopdesktop/controllers/MainLayoutController.java @@ -17,6 +17,7 @@ import javafx.scene.paint.Color; import javafx.scene.paint.ImagePattern; import javafx.scene.shape.Circle; import javafx.stage.Stage; +import org.example.petshopdesktop.api.endpoints.ChatApi; import org.example.petshopdesktop.api.ChatRealtimeClient; import org.example.petshopdesktop.api.dto.auth.AvatarUploadResponse; import org.example.petshopdesktop.api.dto.auth.UserInfoResponse; @@ -55,7 +56,10 @@ public class MainLayoutController { private Button btnInventory; @FXML - private Button btnLogout; + private Button btnChat; + + @FXML + private Circle chatBadge; @FXML private Button btnPets; @@ -85,7 +89,7 @@ public class MainLayoutController { private Button btnAnalytics; @FXML - private Button btnChat; + private Button btnLogout; @FXML private StackPane logoContainer; @@ -263,6 +267,14 @@ public class MainLayoutController { refreshProfileHeader(); applyRBAC(); + ChatRealtimeClient.getInstance().addNotificationListener(hasActionable -> { + Platform.runLater(() -> { + if (chatBadge != null) { + chatBadge.setVisible(hasActionable); + } + }); + }); + UserSession session = UserSession.getInstance(); if (session.isAdmin()) { loadView("analytics-view.fxml"); @@ -391,7 +403,16 @@ public class MainLayoutController { btnSalesHistory.setText(isAdmin ? "Sales History" : "Sales"); - + // Initial chat state and subscription + new Thread(() -> { + try { + var conversations = ChatApi.getInstance().listConversations(); + ChatRealtimeClient.getInstance().initializeState(conversations); + ChatRealtimeClient.getInstance().subscribeToConversations(); + } catch (Exception e) { + ActivityLogger.getInstance().logException("MainLayoutController.applyRBAC", e, "Initializing chat notifications"); + } + }).start(); } private void loadView(String fxmlFile) { diff --git a/desktop/src/main/java/org/example/petshopdesktop/util/FilePickerSupport.java b/desktop/src/main/java/org/example/petshopdesktop/util/FilePickerSupport.java index 313c78e9..b81c764a 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/util/FilePickerSupport.java +++ b/desktop/src/main/java/org/example/petshopdesktop/util/FilePickerSupport.java @@ -24,6 +24,13 @@ public final class FilePickerSupport { return pickImageFileWithJavaFx(ownerWindow); } + public static File pickAnyFile(Window ownerWindow) { + if (shouldUseAwtPicker()) { + return pickAnyFileWithSwing(); + } + return pickAnyFileWithJavaFx(ownerWindow); + } + private static boolean shouldUseAwtPicker() { if (GraphicsEnvironment.isHeadless()) { return false; @@ -40,6 +47,12 @@ public final class FilePickerSupport { return chooser.showOpenDialog(ownerWindow); } + private static File pickAnyFileWithJavaFx(Window ownerWindow) { + FileChooser chooser = new FileChooser(); + chooser.setTitle("Choose File Attachment"); + return chooser.showOpenDialog(ownerWindow); + } + private static File pickImageFileWithSwing() { AtomicReference selectedFile = new AtomicReference<>(); Runnable dialogTask = () -> { @@ -74,4 +87,38 @@ public final class FilePickerSupport { return selectedFile.get(); } + + private static File pickAnyFileWithSwing() { + AtomicReference selectedFile = new AtomicReference<>(); + Runnable dialogTask = () -> { + try { + UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()); + } catch (Exception ignored) { + } + + JFileChooser chooser = new JFileChooser(); + chooser.setDialogTitle("Choose File Attachment"); + chooser.setAcceptAllFileFilterUsed(true); + + int result = chooser.showOpenDialog((Component) null); + if (result == JFileChooser.APPROVE_OPTION) { + selectedFile.set(chooser.getSelectedFile()); + } + }; + + try { + if (java.awt.EventQueue.isDispatchThread()) { + dialogTask.run(); + } else { + java.awt.EventQueue.invokeAndWait(dialogTask); + } + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + return null; + } catch (InvocationTargetException ex) { + throw new IllegalStateException("Failed to open Swing file picker", ex.getCause()); + } + + return selectedFile.get(); + } } diff --git a/desktop/src/main/resources/org/example/petshopdesktop/main-layout-view.fxml b/desktop/src/main/resources/org/example/petshopdesktop/main-layout-view.fxml index b7b209c5..984aad76 100644 --- a/desktop/src/main/resources/org/example/petshopdesktop/main-layout-view.fxml +++ b/desktop/src/main/resources/org/example/petshopdesktop/main-layout-view.fxml @@ -2,6 +2,8 @@ + + @@ -123,14 +125,21 @@ - + + + + + + + + diff --git a/desktop/src/main/resources/org/example/petshopdesktop/modelviews/chat-view.fxml b/desktop/src/main/resources/org/example/petshopdesktop/modelviews/chat-view.fxml index e425ec67..b55fd5c9 100644 --- a/desktop/src/main/resources/org/example/petshopdesktop/modelviews/chat-view.fxml +++ b/desktop/src/main/resources/org/example/petshopdesktop/modelviews/chat-view.fxml @@ -61,8 +61,16 @@