desktop chat notifications #274
@@ -30,7 +30,7 @@ public class RefundController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')")
|
@PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF')")
|
||||||
public ResponseEntity<RefundResponse> createRefund(@Valid @RequestBody RefundRequest request) {
|
public ResponseEntity<RefundResponse> createRefund(@Valid @RequestBody RefundRequest request) {
|
||||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||||
String role = authentication.getAuthorities().stream()
|
String role = authentication.getAuthorities().stream()
|
||||||
@@ -85,7 +85,7 @@ public class RefundController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
@PreAuthorize("hasAnyRole('STAFF', 'ADMIN')")
|
@PreAuthorize("hasRole('STAFF')")
|
||||||
public ResponseEntity<RefundResponse> updateRefund(@PathVariable Long id, @Valid @RequestBody RefundUpdateRequest request) {
|
public ResponseEntity<RefundResponse> updateRefund(@PathVariable Long id, @Valid @RequestBody RefundUpdateRequest request) {
|
||||||
return ResponseEntity.ok(refundService.updateRefundStatus(id, request.getStatus()));
|
return ResponseEntity.ok(refundService.updateRefundStatus(id, request.getStatus()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ public class SaleController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@PreAuthorize("hasAnyRole('STAFF', 'ADMIN')")
|
@PreAuthorize("hasRole('STAFF')")
|
||||||
public ResponseEntity<SaleResponse> createSale(@Valid @RequestBody SaleRequest request) {
|
public ResponseEntity<SaleResponse> createSale(@Valid @RequestBody SaleRequest request) {
|
||||||
return ResponseEntity.status(HttpStatus.CREATED).body(saleService.createSale(request));
|
return ResponseEntity.status(HttpStatus.CREATED).body(saleService.createSale(request));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,9 +42,10 @@ public class GlobalExceptionHandler {
|
|||||||
errors.put(fieldName, errorMessage);
|
errors.put(fieldName, errorMessage);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
String firstMessage = errors.values().stream().findFirst().orElse("Validation failed");
|
||||||
Map<String, Object> response = new HashMap<>();
|
Map<String, Object> response = new HashMap<>();
|
||||||
response.put("status", HttpStatus.BAD_REQUEST.value());
|
response.put("status", HttpStatus.BAD_REQUEST.value());
|
||||||
response.put("message", "Validation failed");
|
response.put("message", firstMessage);
|
||||||
response.put("errors", errors);
|
response.put("errors", errors);
|
||||||
response.put("details", buildDetails(ex));
|
response.put("details", buildDetails(ex));
|
||||||
response.put("path", request.getRequestURI());
|
response.put("path", request.getRequestURI());
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import org.springframework.stereotype.Repository;
|
|||||||
@Repository
|
@Repository
|
||||||
public interface ProductRepository extends JpaRepository<Product, Long> {
|
public interface ProductRepository extends JpaRepository<Product, Long> {
|
||||||
|
|
||||||
|
boolean existsByProdNameIgnoreCase(String prodName);
|
||||||
|
|
||||||
|
boolean existsByProdNameIgnoreCaseAndProdIdNot(String prodName, Long prodId);
|
||||||
|
|
||||||
@Query("SELECT p FROM Product p WHERE " +
|
@Query("SELECT p FROM Product p WHERE " +
|
||||||
"(:q IS NULL OR LOWER(p.prodName) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(COALESCE(p.prodDesc, '')) LIKE LOWER(CONCAT('%', :q, '%'))) AND " +
|
"(:q IS NULL OR LOWER(p.prodName) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(COALESCE(p.prodDesc, '')) LIKE LOWER(CONCAT('%', :q, '%'))) AND " +
|
||||||
"(:categoryId IS NULL OR p.category.categoryId = :categoryId)")
|
"(:categoryId IS NULL OR p.category.categoryId = :categoryId)")
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.petshop.backend.dto.product.ProductRequest;
|
|||||||
import com.petshop.backend.dto.product.ProductResponse;
|
import com.petshop.backend.dto.product.ProductResponse;
|
||||||
import com.petshop.backend.entity.Category;
|
import com.petshop.backend.entity.Category;
|
||||||
import com.petshop.backend.entity.Product;
|
import com.petshop.backend.entity.Product;
|
||||||
|
import com.petshop.backend.exception.BusinessException;
|
||||||
import com.petshop.backend.exception.ResourceNotFoundException;
|
import com.petshop.backend.exception.ResourceNotFoundException;
|
||||||
import com.petshop.backend.repository.CategoryRepository;
|
import com.petshop.backend.repository.CategoryRepository;
|
||||||
import com.petshop.backend.repository.ProductRepository;
|
import com.petshop.backend.repository.ProductRepository;
|
||||||
@@ -45,6 +46,10 @@ public class ProductService {
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public ProductResponse createProduct(ProductRequest request) {
|
public ProductResponse createProduct(ProductRequest request) {
|
||||||
|
if (productRepository.existsByProdNameIgnoreCase(request.getProdName())) {
|
||||||
|
throw new BusinessException("A product with this name already exists");
|
||||||
|
}
|
||||||
|
|
||||||
Category category = categoryRepository.findById(request.getCategoryId())
|
Category category = categoryRepository.findById(request.getCategoryId())
|
||||||
.orElseThrow(() -> new ResourceNotFoundException("Category not found with id: " + request.getCategoryId()));
|
.orElseThrow(() -> new ResourceNotFoundException("Category not found with id: " + request.getCategoryId()));
|
||||||
|
|
||||||
@@ -63,6 +68,10 @@ public class ProductService {
|
|||||||
Product product = productRepository.findById(id)
|
Product product = productRepository.findById(id)
|
||||||
.orElseThrow(() -> new ResourceNotFoundException("Product not found with id: " + id));
|
.orElseThrow(() -> new ResourceNotFoundException("Product not found with id: " + id));
|
||||||
|
|
||||||
|
if (productRepository.existsByProdNameIgnoreCaseAndProdIdNot(request.getProdName(), id)) {
|
||||||
|
throw new BusinessException("A product with this name already exists");
|
||||||
|
}
|
||||||
|
|
||||||
Category category = categoryRepository.findById(request.getCategoryId())
|
Category category = categoryRepository.findById(request.getCategoryId())
|
||||||
.orElseThrow(() -> new ResourceNotFoundException("Category not found with id: " + request.getCategoryId()));
|
.orElseThrow(() -> new ResourceNotFoundException("Category not found with id: " + request.getCategoryId()));
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ openrouter:
|
|||||||
model: ${OPENROUTER_MODEL:openrouter/free}
|
model: ${OPENROUTER_MODEL:openrouter/free}
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
|
file:
|
||||||
|
name: ${LOG_FILE:log.txt}
|
||||||
level:
|
level:
|
||||||
com.petshop: ${LOG_LEVEL:INFO}
|
com.petshop: ${LOG_LEVEL:INFO}
|
||||||
org.springframework.security: ${LOG_LEVEL_SECURITY:WARN}
|
org.springframework.security: ${LOG_LEVEL_SECURITY:WARN}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import javafx.fxml.FXMLLoader;
|
|||||||
import javafx.scene.Scene;
|
import javafx.scene.Scene;
|
||||||
import javafx.scene.image.Image;
|
import javafx.scene.image.Image;
|
||||||
import javafx.stage.Stage;
|
import javafx.stage.Stage;
|
||||||
|
import org.example.petshopdesktop.util.DesktopNotificationService;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
@@ -26,5 +27,9 @@ public class PetShopApplication extends Application {
|
|||||||
|
|
||||||
stage.setScene(scene);
|
stage.setScene(scene);
|
||||||
stage.show();
|
stage.show();
|
||||||
|
|
||||||
|
DesktopNotificationService.getInstance().init(
|
||||||
|
stage.getIcons().isEmpty() ? null : stage.getIcons().get(0)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import org.example.petshopdesktop.api.dto.chat.ConversationResponse;
|
|||||||
import org.example.petshopdesktop.api.dto.chat.MessageRequest;
|
import org.example.petshopdesktop.api.dto.chat.MessageRequest;
|
||||||
import org.example.petshopdesktop.api.dto.chat.MessageResponse;
|
import org.example.petshopdesktop.api.dto.chat.MessageResponse;
|
||||||
import org.example.petshopdesktop.auth.UserSession;
|
import org.example.petshopdesktop.auth.UserSession;
|
||||||
|
import org.example.petshopdesktop.util.DesktopNotificationService;
|
||||||
|
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.net.http.HttpClient;
|
import java.net.http.HttpClient;
|
||||||
@@ -72,6 +73,16 @@ public class ChatRealtimeClient implements WebSocket.Listener {
|
|||||||
updateNotificationState();
|
updateNotificationState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void markConversationReplied(Long conversationId, Long senderId) {
|
||||||
|
synchronized (lock) {
|
||||||
|
ConversationResponse conv = globalConversations.get(conversationId);
|
||||||
|
if (conv != null) {
|
||||||
|
conv.setLastSenderId(senderId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateNotificationState();
|
||||||
|
}
|
||||||
|
|
||||||
public boolean hasActionableChats() {
|
public boolean hasActionableChats() {
|
||||||
synchronized (lock) {
|
synchronized (lock) {
|
||||||
UserSession session = UserSession.getInstance();
|
UserSession session = UserSession.getInstance();
|
||||||
@@ -333,7 +344,13 @@ public class ChatRealtimeClient implements WebSocket.Listener {
|
|||||||
if (messageListener != null) {
|
if (messageListener != null) {
|
||||||
messageListener.accept(message);
|
messageListener.accept(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Long currentUserId = UserSession.getInstance().getUserId();
|
||||||
|
if (message.getSenderId() != null && !message.getSenderId().equals(currentUserId)) {
|
||||||
|
DesktopNotificationService.getInstance()
|
||||||
|
.notifyNewMessage(message.getSenderDisplayName(), message.getContent());
|
||||||
|
}
|
||||||
|
|
||||||
// Also update globalConversation last sender if this is the active conversation
|
// Also update globalConversation last sender if this is the active conversation
|
||||||
synchronized (lock) {
|
synchronized (lock) {
|
||||||
ConversationResponse conv = globalConversations.get(message.getConversationId());
|
ConversationResponse conv = globalConversations.get(message.getConversationId());
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ public class ChatController {
|
|||||||
MessageResponse response = ChatApi.getInstance().sendMessage(convId, request);
|
MessageResponse response = ChatApi.getInstance().sendMessage(convId, request);
|
||||||
Platform.runLater(() -> {
|
Platform.runLater(() -> {
|
||||||
btnSend.setDisable(false);
|
btnSend.setDisable(false);
|
||||||
|
realtimeClient.markConversationReplied(convId, UserSession.getInstance().getUserId());
|
||||||
appendMessageIfSelected(response);
|
appendMessageIfSelected(response);
|
||||||
if (selectedAttachmentFile != null) {
|
if (selectedAttachmentFile != null) {
|
||||||
clearLocalAttachment();
|
clearLocalAttachment();
|
||||||
@@ -380,6 +381,7 @@ public class ChatController {
|
|||||||
List<ConversationResponse> response = ChatApi.getInstance().listConversations();
|
List<ConversationResponse> response = ChatApi.getInstance().listConversations();
|
||||||
response.sort(Comparator.comparing(ChatController::conversationSortTime, Comparator.nullsLast(Comparator.reverseOrder())));
|
response.sort(Comparator.comparing(ChatController::conversationSortTime, Comparator.nullsLast(Comparator.reverseOrder())));
|
||||||
Platform.runLater(() -> {
|
Platform.runLater(() -> {
|
||||||
|
realtimeClient.initializeState(response);
|
||||||
conversations.setAll(response);
|
conversations.setAll(response);
|
||||||
refreshSections();
|
refreshSections();
|
||||||
restoreSelection();
|
restoreSelection();
|
||||||
@@ -471,6 +473,7 @@ public class ChatController {
|
|||||||
.findFirst()
|
.findFirst()
|
||||||
.ifPresent(conversation -> {
|
.ifPresent(conversation -> {
|
||||||
conversation.setLastMessage(message.getContent());
|
conversation.setLastMessage(message.getContent());
|
||||||
|
conversation.setLastSenderId(message.getSenderId());
|
||||||
conversation.setUpdatedAt(message.getTimestamp());
|
conversation.setUpdatedAt(message.getTimestamp());
|
||||||
conversations.sort(Comparator.comparing(ChatController::conversationSortTime, Comparator.nullsLast(Comparator.reverseOrder())));
|
conversations.sort(Comparator.comparing(ChatController::conversationSortTime, Comparator.nullsLast(Comparator.reverseOrder())));
|
||||||
refreshSections();
|
refreshSections();
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package org.example.petshopdesktop.util;
|
||||||
|
|
||||||
|
import javafx.embed.swing.SwingFXUtils;
|
||||||
|
import javafx.scene.image.Image;
|
||||||
|
|
||||||
|
import java.awt.SystemTray;
|
||||||
|
import java.awt.Toolkit;
|
||||||
|
import java.awt.TrayIcon;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
|
||||||
|
public class DesktopNotificationService {
|
||||||
|
private static final DesktopNotificationService INSTANCE = new DesktopNotificationService();
|
||||||
|
|
||||||
|
private TrayIcon trayIcon;
|
||||||
|
private boolean trayInitialized = false;
|
||||||
|
|
||||||
|
private DesktopNotificationService() {}
|
||||||
|
|
||||||
|
public static DesktopNotificationService getInstance() {
|
||||||
|
return INSTANCE;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void init(Image appIcon) {
|
||||||
|
if (!SystemTray.isSupported()) return;
|
||||||
|
try {
|
||||||
|
BufferedImage image;
|
||||||
|
if (appIcon != null) {
|
||||||
|
image = SwingFXUtils.fromFXImage(appIcon, null);
|
||||||
|
} else {
|
||||||
|
image = new BufferedImage(16, 16, BufferedImage.TYPE_INT_ARGB);
|
||||||
|
}
|
||||||
|
trayIcon = new TrayIcon(image, "Leon's Pet Store");
|
||||||
|
trayIcon.setImageAutoSize(true);
|
||||||
|
SystemTray.getSystemTray().add(trayIcon);
|
||||||
|
trayInitialized = true;
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void notifyNewMessage(String senderName, String content) {
|
||||||
|
Thread t = new Thread(() -> {
|
||||||
|
Toolkit.getDefaultToolkit().beep();
|
||||||
|
showNotification(senderName, content);
|
||||||
|
});
|
||||||
|
t.setDaemon(true);
|
||||||
|
t.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showNotification(String senderName, String content) {
|
||||||
|
String title = senderName != null ? senderName : "New Message";
|
||||||
|
String body = content != null
|
||||||
|
? (content.length() > 100 ? content.substring(0, 100) + "..." : content)
|
||||||
|
: "Attachment";
|
||||||
|
|
||||||
|
if (trayInitialized && trayIcon != null) {
|
||||||
|
trayIcon.displayMessage(title, body, TrayIcon.MessageType.INFO);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
new ProcessBuilder("notify-send", title, body).start();
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user