From 786f9b0e2bc230e296165e7b82ee8734c5fc1223 Mon Sep 17 00:00:00 2001 From: ModernityRejecter Date: Fri, 29 May 2026 13:41:41 +0300 Subject: [PATCH 1/2] feat: implement kitchen live operations with real-time STOMP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add kitchen display system (KDS) with 3-column board (New/Cooking/Ready) - Live elapsed timers, customer notes, order/item-level actions - Toast alerts and sound on new orders - Implement WebSocket/STOMP over SockJS for real-time order updates - JWT auth via channel interceptor, role-gated /topic/kitchen feed - Broadcast after transaction commit to guarantee ordering - Add customer live tracking on "My Orders" page - Subscribe to per-order updates, instant refresh on status changes - Toast alert when order is ready to serve - Live/offline connection indicator - Fix order-status rollup gap: item status changes now drive order status - Orders advance through Received→Cooking→Ready→Served as items progress - Customers see accurate progress bar instead of stuck RECEIVED - Add OrderMapper for consistent order serialization across REST/STOMP - Secure /ws endpoint in SecurityConfig for SockJS handshake - Add realtime.js helper for reusable STOMP client setup with JWT refresh Acceptance criteria met (user stories #5, #6): - Kitchen: orders sorted by placed time, customer notes visible - Live status: real-time notifications about order progress - Board & customer pages both use WebSocket for instant updates, fall back to polling Co-Authored-By: Claude Haiku 4.5 --- .../ramsai/kitchen/config/SecurityConfig.java | 15 +- .../WebSocketAuthChannelInterceptor.java | 72 +++++ .../kitchen/config/WebSocketConfig.java | 51 ++++ .../kitchen/controllers/CartController.java | 6 +- .../controllers/KitchenController.java | 29 +- .../ramsai/kitchen/mappers/OrderMapper.java | 14 + .../kitchen/repositories/OrderRepository.java | 19 ++ .../ramsai/kitchen/services/CartService.java | 6 +- .../services/KitchenBroadcastService.java | 23 ++ .../kitchen/services/KitchenService.java | 115 ++++++- .../ramsai/kitchen/services/OrderService.java | 29 +- src/main/resources/static/css/style.css | 48 +++ src/main/resources/static/js/kitchen.js | 285 ++++++++++++++++++ src/main/resources/static/js/my-orders.js | 105 ++++++- src/main/resources/static/js/realtime.js | 40 +++ src/main/resources/static/kitchen.html | 274 +++++++++++++++++ src/main/resources/static/my-orders.html | 4 + .../kitchen/services/CartServiceTest.java | 4 + 18 files changed, 1095 insertions(+), 44 deletions(-) create mode 100644 src/main/java/com/ramsai/kitchen/config/WebSocketAuthChannelInterceptor.java create mode 100644 src/main/java/com/ramsai/kitchen/config/WebSocketConfig.java create mode 100644 src/main/java/com/ramsai/kitchen/mappers/OrderMapper.java create mode 100644 src/main/java/com/ramsai/kitchen/services/KitchenBroadcastService.java create mode 100644 src/main/resources/static/js/kitchen.js create mode 100644 src/main/resources/static/js/realtime.js create mode 100644 src/main/resources/static/kitchen.html diff --git a/src/main/java/com/ramsai/kitchen/config/SecurityConfig.java b/src/main/java/com/ramsai/kitchen/config/SecurityConfig.java index 2fa9e5a..d83dcd6 100644 --- a/src/main/java/com/ramsai/kitchen/config/SecurityConfig.java +++ b/src/main/java/com/ramsai/kitchen/config/SecurityConfig.java @@ -29,13 +29,14 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers("/api/v1/auth/**").permitAll() // 2. Public Static Resources .requestMatchers( - "/", - "/*.html", - "/static/**", - "/css/**", - "/js/**", - "/images/**", - "/uploads/**", + "/", + "/*.html", + "/static/**", + "/css/**", + "/js/**", + "/images/**", + "/uploads/**", + "/ws/**", "/error" ).permitAll() diff --git a/src/main/java/com/ramsai/kitchen/config/WebSocketAuthChannelInterceptor.java b/src/main/java/com/ramsai/kitchen/config/WebSocketAuthChannelInterceptor.java new file mode 100644 index 0000000..c26a238 --- /dev/null +++ b/src/main/java/com/ramsai/kitchen/config/WebSocketAuthChannelInterceptor.java @@ -0,0 +1,72 @@ +package com.ramsai.kitchen.config; + +import com.ramsai.kitchen.services.JwtService; +import lombok.RequiredArgsConstructor; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.MessagingException; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.messaging.support.MessageHeaderAccessor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.stereotype.Component; + +import java.security.Principal; + +@Component +@RequiredArgsConstructor +public class WebSocketAuthChannelInterceptor implements ChannelInterceptor { + + private static final String KITCHEN_TOPIC = "/topic/kitchen"; + + private final JwtService jwtService; + private final UserDetailsService userDetailsService; + + @Override + public Message preSend(Message message, MessageChannel channel) { + StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); + if (accessor == null) { + return message; + } + + if (StompCommand.CONNECT.equals(accessor.getCommand())) { + accessor.setUser(authenticate(accessor.getFirstNativeHeader("Authorization"))); + } else if (StompCommand.SUBSCRIBE.equals(accessor.getCommand())) { + String destination = accessor.getDestination(); + if (destination != null && destination.startsWith(KITCHEN_TOPIC) && !hasKitchenRole(accessor.getUser())) { + throw new MessagingException("Not authorized to subscribe to the kitchen feed"); + } + } + + return message; + } + + private UsernamePasswordAuthenticationToken authenticate(String authHeader) { + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + throw new MessagingException("Missing Authorization header on STOMP CONNECT"); + } + String jwt = authHeader.substring(7); + String username = jwtService.extractUsername(jwt); + UserDetails userDetails = userDetailsService.loadUserByUsername(username); + if (!jwtService.isTokenValid(jwt, userDetails)) { + throw new MessagingException("Invalid or expired token on STOMP CONNECT"); + } + return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + } + + private boolean hasKitchenRole(Principal principal) { + if (!(principal instanceof UsernamePasswordAuthenticationToken auth)) { + return false; + } + for (GrantedAuthority authority : auth.getAuthorities()) { + if ("ROLE_CHEF".equals(authority.getAuthority()) || "ROLE_MANAGER".equals(authority.getAuthority())) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/com/ramsai/kitchen/config/WebSocketConfig.java b/src/main/java/com/ramsai/kitchen/config/WebSocketConfig.java new file mode 100644 index 0000000..126adc7 --- /dev/null +++ b/src/main/java/com/ramsai/kitchen/config/WebSocketConfig.java @@ -0,0 +1,51 @@ +package com.ramsai.kitchen.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.converter.MappingJackson2MessageConverter; +import org.springframework.messaging.converter.MessageConverter; +import org.springframework.messaging.simp.config.ChannelRegistration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +import java.util.List; + +@Configuration +@EnableWebSocketMessageBroker +@RequiredArgsConstructor +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + private final WebSocketAuthChannelInterceptor authChannelInterceptor; + private final ObjectMapper objectMapper; + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/ws") + .setAllowedOriginPatterns("*") + .withSockJS(); + } + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + registry.enableSimpleBroker("/topic"); + registry.setApplicationDestinationPrefixes("/app"); + } + + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + registration.interceptors(authChannelInterceptor); + } + + // Reuse the Spring-managed ObjectMapper so payloads (e.g. LocalDateTime) serialize + // identically over STOMP and over REST. + @Override + public boolean configureMessageConverters(List messageConverters) { + MappingJackson2MessageConverter converter = new MappingJackson2MessageConverter(); + converter.setObjectMapper(objectMapper); + messageConverters.add(converter); + return false; + } +} diff --git a/src/main/java/com/ramsai/kitchen/controllers/CartController.java b/src/main/java/com/ramsai/kitchen/controllers/CartController.java index dd95dbd..1dade74 100644 --- a/src/main/java/com/ramsai/kitchen/controllers/CartController.java +++ b/src/main/java/com/ramsai/kitchen/controllers/CartController.java @@ -2,8 +2,10 @@ import com.ramsai.kitchen.models.dtos.CartItemRequest; import com.ramsai.kitchen.models.dtos.CartResponse; +import com.ramsai.kitchen.models.dtos.OrderResponse; import com.ramsai.kitchen.models.entities.User; import com.ramsai.kitchen.services.CartService; +import com.ramsai.kitchen.services.KitchenBroadcastService; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -18,6 +20,7 @@ public class CartController { private final CartService cartService; + private final KitchenBroadcastService kitchenBroadcastService; @GetMapping public ResponseEntity> getCart(@AuthenticationPrincipal User user) { @@ -54,7 +57,8 @@ public ResponseEntity> removeItem( public ResponseEntity> checkout( @AuthenticationPrincipal User user, @RequestParam Long tableId) { - cartService.checkout(user.getId(), tableId); + OrderResponse order = cartService.checkout(user.getId(), tableId); + kitchenBroadcastService.broadcastOrderUpdate(order); return ResponseEntity.ok(Map.of( "message", "Checkout successful. Your order is being prepared!" )); diff --git a/src/main/java/com/ramsai/kitchen/controllers/KitchenController.java b/src/main/java/com/ramsai/kitchen/controllers/KitchenController.java index f74df1d..83f0284 100644 --- a/src/main/java/com/ramsai/kitchen/controllers/KitchenController.java +++ b/src/main/java/com/ramsai/kitchen/controllers/KitchenController.java @@ -1,7 +1,10 @@ package com.ramsai.kitchen.controllers; import com.ramsai.kitchen.enums.ItemStatus; +import com.ramsai.kitchen.enums.OrderStatus; import com.ramsai.kitchen.models.dtos.OrderItemResponse; +import com.ramsai.kitchen.models.dtos.OrderResponse; +import com.ramsai.kitchen.services.KitchenBroadcastService; import com.ramsai.kitchen.services.KitchenService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -16,6 +19,16 @@ public class KitchenController { private final KitchenService kitchenService; + private final KitchenBroadcastService kitchenBroadcastService; + + @GetMapping("/board") + public ResponseEntity> getBoard() { + List data = kitchenService.getBoard(); + return ResponseEntity.ok(Map.of( + "data", data, + "message", "Success" + )); + } @GetMapping("/items") public ResponseEntity> getKdsItems(@RequestParam ItemStatus status) { @@ -30,9 +43,23 @@ public ResponseEntity> getKdsItems(@RequestParam ItemStatus public ResponseEntity> updateItemStatus( @PathVariable Long id, @RequestParam ItemStatus status) { - kitchenService.updateItemStatus(id, status); + OrderResponse order = kitchenService.updateItemStatus(id, status); + kitchenBroadcastService.broadcastOrderUpdate(order); return ResponseEntity.ok(Map.of( + "data", order, "message", "Item status updated successfully" )); } + + @PatchMapping("/orders/{id}/status") + public ResponseEntity> updateOrderStatus( + @PathVariable Long id, + @RequestParam OrderStatus status) { + OrderResponse order = kitchenService.advanceOrder(id, status); + kitchenBroadcastService.broadcastOrderUpdate(order); + return ResponseEntity.ok(Map.of( + "data", order, + "message", "Order status updated successfully" + )); + } } diff --git a/src/main/java/com/ramsai/kitchen/mappers/OrderMapper.java b/src/main/java/com/ramsai/kitchen/mappers/OrderMapper.java new file mode 100644 index 0000000..d104201 --- /dev/null +++ b/src/main/java/com/ramsai/kitchen/mappers/OrderMapper.java @@ -0,0 +1,14 @@ +package com.ramsai.kitchen.mappers; + +import com.ramsai.kitchen.models.dtos.OrderResponse; +import com.ramsai.kitchen.models.entities.Order; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring", uses = OrderItemMapper.class) +public interface OrderMapper { + + @Mapping(target = "tableId", source = "table.id") + @Mapping(target = "tableNumber", source = "table.tableNumber") + OrderResponse toResponse(Order order); +} diff --git a/src/main/java/com/ramsai/kitchen/repositories/OrderRepository.java b/src/main/java/com/ramsai/kitchen/repositories/OrderRepository.java index 6229bac..baa9234 100644 --- a/src/main/java/com/ramsai/kitchen/repositories/OrderRepository.java +++ b/src/main/java/com/ramsai/kitchen/repositories/OrderRepository.java @@ -24,4 +24,23 @@ public interface OrderRepository extends JpaRepository { "ORDER BY o.createdAt DESC" ) List findCustomerOrders(Long customerId, OrderStatus excluded); + + @org.springframework.data.jpa.repository.Query( + "SELECT DISTINCT o FROM Order o " + + "LEFT JOIN FETCH o.items i " + + "LEFT JOIN FETCH i.product " + + "LEFT JOIN FETCH o.table " + + "WHERE o.status IN :statuses " + + "ORDER BY o.createdAt ASC" + ) + List findForKitchenByStatuses(List statuses); + + @org.springframework.data.jpa.repository.Query( + "SELECT DISTINCT o FROM Order o " + + "LEFT JOIN FETCH o.items i " + + "LEFT JOIN FETCH i.product " + + "LEFT JOIN FETCH o.table " + + "WHERE o.id = :id" + ) + Optional findByIdForKitchen(Long id); } diff --git a/src/main/java/com/ramsai/kitchen/services/CartService.java b/src/main/java/com/ramsai/kitchen/services/CartService.java index a7ba71e..7cbf7b0 100644 --- a/src/main/java/com/ramsai/kitchen/services/CartService.java +++ b/src/main/java/com/ramsai/kitchen/services/CartService.java @@ -3,9 +3,11 @@ import com.ramsai.kitchen.enums.OrderStatus; import com.ramsai.kitchen.enums.ItemStatus; import com.ramsai.kitchen.mappers.OrderItemMapper; +import com.ramsai.kitchen.mappers.OrderMapper; import com.ramsai.kitchen.models.dtos.CartItemRequest; import com.ramsai.kitchen.models.dtos.CartResponse; import com.ramsai.kitchen.models.dtos.OrderItemResponse; +import com.ramsai.kitchen.models.dtos.OrderResponse; import com.ramsai.kitchen.models.entities.Order; import com.ramsai.kitchen.models.entities.OrderItem; import com.ramsai.kitchen.models.entities.Product; @@ -32,6 +34,7 @@ public class CartService { private final ProductRepository productRepository; private final RestaurantTableRepository tableRepository; private final OrderItemMapper orderItemMapper; + private final OrderMapper orderMapper; private final InventoryService inventoryService; @Transactional @@ -88,7 +91,7 @@ public CartResponse removeItem(Long customerId, Long itemId) { } @Transactional - public void checkout(Long customerId, Long tableId) { + public OrderResponse checkout(Long customerId, Long tableId) { Order cart = orderRepository.findByCustomerIdAndStatus(customerId, OrderStatus.DRAFT) .orElseThrow(() -> new RuntimeException("No active cart found")); @@ -116,6 +119,7 @@ public void checkout(Long customerId, Long tableId) { cart.setStatus(OrderStatus.RECEIVED); orderRepository.save(cart); log.info("Order {} checked out for table {}", cart.getId(), tableId); + return orderMapper.toResponse(cart); } private Order getOrCreateDraftOrder(Long customerId) { diff --git a/src/main/java/com/ramsai/kitchen/services/KitchenBroadcastService.java b/src/main/java/com/ramsai/kitchen/services/KitchenBroadcastService.java new file mode 100644 index 0000000..e4bb7f1 --- /dev/null +++ b/src/main/java/com/ramsai/kitchen/services/KitchenBroadcastService.java @@ -0,0 +1,23 @@ +package com.ramsai.kitchen.services; + +import com.ramsai.kitchen.models.dtos.OrderResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class KitchenBroadcastService { + + private final SimpMessagingTemplate messagingTemplate; + + // Pushes a freshly-saved order to the kitchen board and to anyone tracking that order. + // Called by controllers after the transaction commits. + public void broadcastOrderUpdate(OrderResponse order) { + if (order == null) { + return; + } + messagingTemplate.convertAndSend("/topic/kitchen", order); + messagingTemplate.convertAndSend("/topic/orders/" + order.id(), order); + } +} diff --git a/src/main/java/com/ramsai/kitchen/services/KitchenService.java b/src/main/java/com/ramsai/kitchen/services/KitchenService.java index 569ae72..d94fa77 100644 --- a/src/main/java/com/ramsai/kitchen/services/KitchenService.java +++ b/src/main/java/com/ramsai/kitchen/services/KitchenService.java @@ -1,11 +1,16 @@ package com.ramsai.kitchen.services; import com.ramsai.kitchen.enums.ItemStatus; +import com.ramsai.kitchen.enums.OrderStatus; import com.ramsai.kitchen.exceptions.ResourceNotFoundException; +import com.ramsai.kitchen.mappers.OrderItemMapper; +import com.ramsai.kitchen.mappers.OrderMapper; +import com.ramsai.kitchen.models.dtos.OrderItemResponse; +import com.ramsai.kitchen.models.dtos.OrderResponse; +import com.ramsai.kitchen.models.entities.Order; import com.ramsai.kitchen.models.entities.OrderItem; -import com.ramsai.kitchen.models.entities.Product; import com.ramsai.kitchen.repositories.OrderItemRepository; -import com.ramsai.kitchen.models.dtos.OrderItemResponse; +import com.ramsai.kitchen.repositories.OrderRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -19,8 +24,21 @@ @RequiredArgsConstructor public class KitchenService { + private static final List ACTIVE_KITCHEN_STATUSES = + List.of(OrderStatus.RECEIVED, OrderStatus.COOKING, OrderStatus.READY); + private final OrderItemRepository orderItemRepository; - private final com.ramsai.kitchen.mappers.OrderItemMapper orderItemMapper; + private final OrderRepository orderRepository; + private final OrderItemMapper orderItemMapper; + private final OrderMapper orderMapper; + + @Transactional(readOnly = true) + public List getBoard() { + return orderRepository.findForKitchenByStatuses(ACTIVE_KITCHEN_STATUSES) + .stream() + .map(orderMapper::toResponse) + .collect(Collectors.toList()); + } @Transactional(readOnly = true) public List getKdsItems(ItemStatus status) { @@ -31,15 +49,100 @@ public List getKdsItems(ItemStatus status) { } @Transactional - public void updateItemStatus(Long itemId, ItemStatus newStatus) { - log.info("Attempting to update status for Item {} to {}", itemId, newStatus); + public OrderResponse updateItemStatus(Long itemId, ItemStatus newStatus) { OrderItem item = orderItemRepository.findByIdWithProduct(itemId) .orElseThrow(() -> new ResourceNotFoundException("OrderItem not found with id: " + itemId)); ItemStatus oldStatus = item.getItemStatus(); item.setItemStatus(newStatus); orderItemRepository.save(item); + log.info("Updated Item {} status from {} to {}", itemId, oldStatus, newStatus); + + Order order = loadOrder(item.getOrder().getId()); + return rollUpAndSave(order); + } + + @Transactional + public OrderResponse advanceOrder(Long orderId, OrderStatus targetStatus) { + Order order = loadOrder(orderId); + ItemStatus targetItemStatus = toItemStatus(targetStatus); + if (order.getItems() != null) { + for (OrderItem item : order.getItems()) { + if (shouldAdvance(item.getItemStatus(), targetItemStatus)) { + item.setItemStatus(targetItemStatus); + } + } + } + order.setStatus(deriveOrderStatus(order)); + Order saved = orderRepository.save(order); + log.info("Advanced Order {} to {}", orderId, saved.getStatus()); + return orderMapper.toResponse(saved); + } + + private Order loadOrder(Long orderId) { + return orderRepository.findByIdForKitchen(orderId) + .orElseThrow(() -> new ResourceNotFoundException("Order not found with id: " + orderId)); + } + + private OrderResponse rollUpAndSave(Order order) { + order.setStatus(deriveOrderStatus(order)); + Order saved = orderRepository.save(order); + return orderMapper.toResponse(saved); + } + + // Derives the order-level status from its items so customers see Received/Cooking/Ready/Served move forward. + private OrderStatus deriveOrderStatus(Order order) { + List items = order.getItems(); + if (items == null || items.isEmpty()) { + return order.getStatus(); + } + List active = items.stream() + .filter(i -> i.getItemStatus() != ItemStatus.CANCELLED) + .collect(Collectors.toList()); + if (active.isEmpty()) { + return OrderStatus.CANCELLED; + } + if (active.stream().allMatch(i -> i.getItemStatus() == ItemStatus.SERVED)) { + return OrderStatus.SERVED; + } + if (active.stream().allMatch(i -> i.getItemStatus() == ItemStatus.READY || i.getItemStatus() == ItemStatus.SERVED)) { + return OrderStatus.READY; + } + boolean anyStarted = active.stream().anyMatch(i -> + i.getItemStatus() == ItemStatus.COOKING + || i.getItemStatus() == ItemStatus.READY + || i.getItemStatus() == ItemStatus.SERVED); + return anyStarted ? OrderStatus.COOKING : OrderStatus.RECEIVED; + } + + private ItemStatus toItemStatus(OrderStatus orderStatus) { + return switch (orderStatus) { + case COOKING -> ItemStatus.COOKING; + case READY -> ItemStatus.READY; + case SERVED -> ItemStatus.SERVED; + case CANCELLED -> ItemStatus.CANCELLED; + default -> ItemStatus.PENDING; + }; + } + + // Order-level actions only move items forward; already-finished items are left untouched. + private boolean shouldAdvance(ItemStatus current, ItemStatus target) { + if (current == ItemStatus.CANCELLED) { + return false; + } + if (target == ItemStatus.CANCELLED) { + return true; + } + return rank(current) < rank(target); + } - log.info("Successfully updated Item {} status from {} to {}", itemId, oldStatus, newStatus); + private int rank(ItemStatus status) { + return switch (status) { + case PENDING -> 0; + case COOKING -> 1; + case READY -> 2; + case SERVED -> 3; + case CANCELLED -> -1; + }; } } diff --git a/src/main/java/com/ramsai/kitchen/services/OrderService.java b/src/main/java/com/ramsai/kitchen/services/OrderService.java index 842db1c..d1ca78d 100644 --- a/src/main/java/com/ramsai/kitchen/services/OrderService.java +++ b/src/main/java/com/ramsai/kitchen/services/OrderService.java @@ -1,11 +1,8 @@ package com.ramsai.kitchen.services; import com.ramsai.kitchen.enums.OrderStatus; -import com.ramsai.kitchen.mappers.OrderItemMapper; -import com.ramsai.kitchen.models.dtos.OrderItemResponse; +import com.ramsai.kitchen.mappers.OrderMapper; import com.ramsai.kitchen.models.dtos.OrderResponse; -import com.ramsai.kitchen.models.entities.Order; -import com.ramsai.kitchen.models.entities.RestaurantTable; import com.ramsai.kitchen.repositories.OrderRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -19,33 +16,13 @@ public class OrderService { private final OrderRepository orderRepository; - private final OrderItemMapper orderItemMapper; + private final OrderMapper orderMapper; @Transactional(readOnly = true) public List getMyOrders(Long customerId) { return orderRepository.findCustomerOrders(customerId, OrderStatus.DRAFT) .stream() - .map(this::toResponse) + .map(orderMapper::toResponse) .collect(Collectors.toList()); } - - private OrderResponse toResponse(Order order) { - RestaurantTable table = order.getTable(); - List items = order.getItems() == null - ? List.of() - : order.getItems().stream() - .map(orderItemMapper::toResponse) - .collect(Collectors.toList()); - - return new OrderResponse( - order.getId(), - table != null ? table.getId() : null, - table != null ? table.getTableNumber() : null, - order.getStatus(), - order.getTotalPrice(), - order.getCreatedAt(), - order.getUpdatedAt(), - items - ); - } } diff --git a/src/main/resources/static/css/style.css b/src/main/resources/static/css/style.css index 8f94e3a..556fb1a 100644 --- a/src/main/resources/static/css/style.css +++ b/src/main/resources/static/css/style.css @@ -1227,3 +1227,51 @@ nav ul li a.active { font: inherit; } +/* Toast notifications (kitchen board + live order tracking) */ +.toast-container { + position: fixed; + bottom: 24px; + right: 24px; + display: flex; + flex-direction: column; + gap: 10px; + z-index: 2000; + pointer-events: none; +} +.toast { + background: var(--secondary-color, #2c3e50); + color: #fff; + padding: 12px 18px; + border-radius: 10px; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.25); + font-size: 0.9rem; + font-weight: 600; + display: flex; + align-items: center; + gap: 10px; + opacity: 0; + transform: translateX(20px); + transition: opacity 0.3s ease, transform 0.3s ease; +} +.toast.show { opacity: 1; transform: none; } +.toast i { color: var(--primary-color, #f39c12); } +.toast.toast-ready { background: #1e7f3a; } +.toast.toast-ready i { color: #d2f5dd; } + +/* Live-tracking indicator on the My Orders page */ +.live-indicator { + display: inline-flex; + align-items: center; + gap: 7px; + font-size: 0.78rem; + font-weight: 700; + padding: 4px 11px; + border-radius: 999px; + background: #eee; + color: #777; + vertical-align: middle; +} +.live-indicator .dot { width: 8px; height: 8px; border-radius: 50%; background: #aaa; } +.live-indicator.live { background: #e6f6ec; color: #1e7f3a; } +.live-indicator.live .dot { background: #22c55e; box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.2); } + diff --git a/src/main/resources/static/js/kitchen.js b/src/main/resources/static/js/kitchen.js new file mode 100644 index 0000000..dd5d001 --- /dev/null +++ b/src/main/resources/static/js/kitchen.js @@ -0,0 +1,285 @@ +// Kitchen Display System — live board of active orders (New / Cooking / Ready). +const KDS_COLUMNS = ['RECEIVED', 'COOKING', 'READY']; +const WARN_MINUTES = 10; +const DANGER_MINUTES = 20; +const FALLBACK_POLL_MS = 20000; + +const orders = new Map(); // orderId -> OrderResponse +const knownIds = new Set(); // ids we've already seen (so we only alert on truly new orders) +let flashIds = new Set(); // ids to flash on next render +let connected = false; +let soundEnabled = true; +let audioCtx = null; + +document.addEventListener('DOMContentLoaded', () => { + const token = localStorage.getItem('token') || sessionStorage.getItem('token'); + const role = localStorage.getItem('role') || sessionStorage.getItem('role'); + if (!token) { window.location.href = 'login.html'; return; } + if (role !== 'CHEF' && role !== 'MANAGER') { window.location.href = 'index.html'; return; } + + document.getElementById('refreshBtn').addEventListener('click', loadBoard); + document.getElementById('soundToggle').addEventListener('click', toggleSound); + + loadBoard(); + connectRealtime(); + + setInterval(updateElapsedLabels, 30000); + setInterval(() => { if (!connected) loadBoard(); }, FALLBACK_POLL_MS); +}); + +async function loadBoard() { + try { + const res = await authenticatedFetch('/api/v1/kitchen/board'); + if (!res.ok) throw new Error('board fetch failed'); + const json = await res.json(); + const list = Array.isArray(json.data) ? json.data : []; + orders.clear(); + list.forEach(o => { orders.set(o.id, o); knownIds.add(o.id); }); + renderAll(); + } catch (err) { + console.error('Failed to load kitchen board:', err); + } +} + +function connectRealtime() { + createRealtimeClient({ + onConnect: (client) => { + setConn(true); + client.subscribe('/topic/kitchen', (msg) => { + try { handleOrderEvent(JSON.parse(msg.body)); } catch (e) { console.error(e); } + }); + }, + onDisconnect: () => setConn(false), + onError: (e) => { console.warn('Realtime error', e); setConn(false); } + }); +} + +function handleOrderEvent(order) { + if (!order || order.id == null) return; + const isNew = !knownIds.has(order.id); + + if (!KDS_COLUMNS.includes(order.status)) { + // Order left the active set (served / cancelled) — drop it from the board. + orders.delete(order.id); + knownIds.add(order.id); + renderAll(); + return; + } + + orders.set(order.id, order); + knownIds.add(order.id); + if (isNew) flashIds.add(order.id); + renderAll(); + + if (isNew && order.status === 'RECEIVED') { + showToast(`New order #${order.id}${order.tableNumber != null ? ' · Table ' + order.tableNumber : ''}`); + playBeep(); + } +} + +function renderAll() { + const byColumn = { RECEIVED: [], COOKING: [], READY: [] }; + [...orders.values()].forEach(o => { if (byColumn[o.status]) byColumn[o.status].push(o); }); + + KDS_COLUMNS.forEach(status => { + const list = byColumn[status].sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt)); + document.getElementById(`count-${status}`).textContent = list.length; + const col = document.getElementById(`col-${status}`); + col.innerHTML = list.length + ? list.map(renderCard).join('') + : `
No orders here.
`; + }); + + flashIds.forEach(id => { + const el = document.querySelector(`.kds-card[data-order-id="${id}"]`); + if (el) el.classList.add('flash'); + }); + flashIds = new Set(); +} + +function renderCard(order) { + const items = Array.isArray(order.items) ? order.items : []; + const table = order.tableNumber != null ? `Table ${order.tableNumber}` : ''; + return ` +
+
+
#${order.id}${table}
+ ${formatElapsed(order.createdAt)} +
+
    + ${items.map(renderItem).join('') || '
  • No items.
  • '} +
+ ${renderOrderActions(order)} +
+ `; +} + +function renderItem(item) { + const status = item.itemStatus || 'PENDING'; + const done = status === 'SERVED' || status === 'CANCELLED'; + const note = item.specialNotes + ? `
${escapeHtml(item.specialNotes)}
` + : ''; + return ` +
  • +
    +
    ${item.quantity}×${escapeHtml(item.productName || 'Item')}
    + ${note} +
    +
    + ${prettyStatus(status)} + ${renderItemBtn(item, status)} +
    +
  • + `; +} + +function renderItemBtn(item, status) { + if (status === 'PENDING') return ``; + if (status === 'COOKING') return ``; + if (status === 'READY') return ``; + return ''; +} + +function renderOrderActions(order) { + if (order.status === 'RECEIVED') { + return `
    `; + } + if (order.status === 'COOKING') { + return `
    `; + } + if (order.status === 'READY') { + return `
    `; + } + return ''; +} + +async function advanceItem(itemId, status) { + await patchAndApply(`/api/v1/kitchen/items/${itemId}/status?status=${status}`); +} + +async function advanceOrder(orderId, status) { + await patchAndApply(`/api/v1/kitchen/orders/${orderId}/status?status=${status}`); +} + +// Optimistically apply the server's returned order so the UI updates even if the +// realtime broadcast is delayed; the broadcast (when it arrives) is an idempotent re-apply. +async function patchAndApply(url) { + try { + const res = await authenticatedFetch(url, { method: 'PATCH' }); + if (!res.ok) { showToast('Update failed'); return; } + const json = await res.json(); + if (json.data) handleAppliedUpdate(json.data); + } catch (e) { + console.error('Kitchen update failed:', e); + showToast('Update failed'); + } +} + +function handleAppliedUpdate(order) { + if (!KDS_COLUMNS.includes(order.status)) orders.delete(order.id); + else orders.set(order.id, order); + knownIds.add(order.id); + renderAll(); +} + +function updateElapsedLabels() { + document.querySelectorAll('.kds-elapsed[data-created]').forEach(el => { + const created = el.getAttribute('data-created'); + el.textContent = formatElapsed(created); + el.classList.remove('warn', 'danger'); + const mins = minutesSince(created); + if (mins >= DANGER_MINUTES) el.classList.add('danger'); + else if (mins >= WARN_MINUTES) el.classList.add('warn'); + }); +} + +function setConn(isUp) { + connected = isUp; + const el = document.getElementById('connStatus'); + if (!el) return; + el.className = `kds-conn ${isUp ? 'live' : 'down'}`; + el.innerHTML = ` ${isUp ? 'Live' : 'Reconnecting…'}`; +} + +function toggleSound() { + soundEnabled = !soundEnabled; + const btn = document.getElementById('soundToggle'); + btn.classList.toggle('active', soundEnabled); + btn.innerHTML = soundEnabled + ? ' Sound' + : ' Muted'; + if (soundEnabled) ensureAudio(); +} + +function ensureAudio() { + if (!audioCtx) { + try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) { return; } + } + if (audioCtx.state === 'suspended') audioCtx.resume(); +} + +function playBeep() { + if (!soundEnabled) return; + ensureAudio(); + if (!audioCtx) return; + try { + const osc = audioCtx.createOscillator(); + const gain = audioCtx.createGain(); + osc.connect(gain); + gain.connect(audioCtx.destination); + osc.type = 'sine'; + osc.frequency.value = 880; + gain.gain.setValueAtTime(0.18, audioCtx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.0001, audioCtx.currentTime + 0.45); + osc.start(); + osc.stop(audioCtx.currentTime + 0.45); + } catch (e) { /* ignore */ } +} + +function minutesSince(iso) { + const t = new Date(iso).getTime(); + if (isNaN(t)) return 0; + return Math.max(0, Math.floor((Date.now() - t) / 60000)); +} + +function formatElapsed(iso) { + const mins = minutesSince(iso); + if (mins < 1) return 'just now'; + if (mins < 60) return `${mins}m`; + const h = Math.floor(mins / 60); + return `${h}h ${mins % 60}m`; +} + +function prettyStatus(status) { + if (!status) return ''; + return status.charAt(0) + status.slice(1).toLowerCase(); +} + +function escapeHtml(s) { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function showToast(message) { + let container = document.getElementById('toastContainer'); + if (!container) { + container = document.createElement('div'); + container.id = 'toastContainer'; + container.className = 'toast-container'; + document.body.appendChild(container); + } + const toast = document.createElement('div'); + toast.className = 'toast'; + toast.innerHTML = ` ${escapeHtml(message)}`; + container.appendChild(toast); + setTimeout(() => toast.classList.add('show'), 10); + setTimeout(() => { + toast.classList.remove('show'); + setTimeout(() => toast.remove(), 300); + }, 4500); +} diff --git a/src/main/resources/static/js/my-orders.js b/src/main/resources/static/js/my-orders.js index 8e6aa1d..b0cc1e7 100644 --- a/src/main/resources/static/js/my-orders.js +++ b/src/main/resources/static/js/my-orders.js @@ -1,6 +1,7 @@ const ORDER_STATUS_FLOW = ['RECEIVED', 'COOKING', 'READY', 'SERVED']; const ITEM_STATUS_FLOW = ['PENDING', 'COOKING', 'READY', 'SERVED']; -const REFRESH_INTERVAL_MS = 15000; +// Polling is now a safety net behind realtime push, so it can run less often. +const REFRESH_INTERVAL_MS = 30000; let currentUserId = null; let currentTableId = null; @@ -9,6 +10,13 @@ let expandedHistoryIds = new Set(); let refreshTimer = null; let myReviewsMap = new Map(); +// Realtime live-tracking state +let liveClient = null; +let liveConnected = false; +const orderSubs = new Map(); // orderId -> STOMP subscription +const lastKnownStatus = new Map(); // orderId -> last seen status (to detect transitions) +let liveRefreshTimer = null; + document.addEventListener('DOMContentLoaded', async () => { const token = localStorage.getItem('token') || sessionStorage.getItem('token'); if (!token) { @@ -22,11 +30,13 @@ document.addEventListener('DOMContentLoaded', async () => { } await loadAll(true); + connectLiveTracking(); refreshTimer = setInterval(() => loadAll(false), REFRESH_INTERVAL_MS); }); window.addEventListener('beforeunload', () => { if (refreshTimer) clearInterval(refreshTimer); + if (liveClient) { try { liveClient.deactivate(); } catch (e) { /* ignore */ } } }); async function loadAll(showLoading) { @@ -43,9 +53,12 @@ async function loadAll(showLoading) { currentTableId = occupied ? occupied.id : null; currentTableNumber = occupied ? occupied.tableNumber : null; + const currentOrders = filterCurrentOrders(orders, tables, occupied); renderCurrentTableLabel(); - renderCurrentOrders(filterCurrentOrders(orders, tables, occupied)); + renderCurrentOrders(currentOrders); renderHistoryOrders(filterHistoryOrders(orders, tables, occupied)); + trackOrders(currentOrders); + updateLiveIndicator(); } catch (err) { console.error('Failed to load my orders:', err); showStatus('Could not load your orders. Try again in a moment.', 'error'); @@ -301,6 +314,94 @@ function renderHistoryRow(order) { `; } +// ---- Realtime live tracking (STOMP over SockJS, falls back to polling) ---- + +function connectLiveTracking() { + liveClient = createRealtimeClient({ + onConnect: (client) => { + liveConnected = true; + updateLiveIndicator(); + // Broker subscriptions don't survive a reconnect — re-subscribe to all tracked orders. + orderSubs.clear(); + [...lastKnownStatus.keys()].forEach(id => subscribeOrder(id)); + }, + onDisconnect: () => { liveConnected = false; orderSubs.clear(); updateLiveIndicator(); }, + onError: () => { liveConnected = false; updateLiveIndicator(); } + }); + updateLiveIndicator(); +} + +// Keep STOMP subscriptions aligned with the orders currently shown for this table. +function trackOrders(currentOrders) { + const next = new Set(currentOrders.map(o => o.id)); + currentOrders.forEach(o => { + if (!lastKnownStatus.has(o.id)) lastKnownStatus.set(o.id, o.status); + }); + next.forEach(id => subscribeOrder(id)); + orderSubs.forEach((sub, id) => { + if (!next.has(id)) { + try { sub.unsubscribe(); } catch (e) { /* ignore */ } + orderSubs.delete(id); + lastKnownStatus.delete(id); + } + }); +} + +function subscribeOrder(id) { + if (!liveClient || !liveConnected || orderSubs.has(id)) return; + const sub = liveClient.subscribe(`/topic/orders/${id}`, (msg) => { + try { onOrderEvent(JSON.parse(msg.body)); } catch (e) { /* ignore */ } + }); + orderSubs.set(id, sub); +} + +function onOrderEvent(order) { + if (!order || order.id == null) return; + const prev = lastKnownStatus.get(order.id); + lastKnownStatus.set(order.id, order.status); + if (order.status === 'READY' && prev !== 'READY') { + showToast(`Order #${order.id} is ready to serve!`, 'toast-ready'); + } + scheduleLiveRefresh(); +} + +// Coalesce bursts of events into a single refresh. +function scheduleLiveRefresh() { + if (liveRefreshTimer) return; + liveRefreshTimer = setTimeout(() => { + liveRefreshTimer = null; + loadAll(false); + }, 350); +} + +function updateLiveIndicator() { + const el = document.getElementById('liveIndicator'); + if (!el) return; + if (currentTableId == null) { el.style.display = 'none'; return; } + el.style.display = 'inline-flex'; + el.className = `live-indicator ${liveConnected ? 'live' : ''}`; + el.innerHTML = ` ${liveConnected ? 'Live' : 'Offline'}`; +} + +function showToast(message, extraClass) { + let container = document.getElementById('toastContainer'); + if (!container) { + container = document.createElement('div'); + container.id = 'toastContainer'; + container.className = 'toast-container'; + document.body.appendChild(container); + } + const toast = document.createElement('div'); + toast.className = `toast ${extraClass || ''}`; + toast.innerHTML = ` ${escapeHtml(message)}`; + container.appendChild(toast); + setTimeout(() => toast.classList.add('show'), 10); + setTimeout(() => { + toast.classList.remove('show'); + setTimeout(() => toast.remove(), 300); + }, 5000); +} + // Review Modal Logic function openReviewModal(productId, productName, reviewId = null, existingRating = 5, existingComment = '') { let modal = document.getElementById('reviewModal'); diff --git a/src/main/resources/static/js/realtime.js b/src/main/resources/static/js/realtime.js new file mode 100644 index 0000000..4224cee --- /dev/null +++ b/src/main/resources/static/js/realtime.js @@ -0,0 +1,40 @@ +// realtime.js — shared STOMP/SockJS connection helper for live kitchen + order updates. +// Requires SockJS and @stomp/stompjs (global `StompJs`) to be loaded before this file. + +function getRealtimeToken() { + return localStorage.getItem('token') || sessionStorage.getItem('token'); +} + +// Creates and activates a STOMP client over SockJS, authenticated with the stored JWT. +// callbacks: { onConnect(client), onError(frameOrEvent), onDisconnect() } +// Returns the client (call .deactivate() to close) or null if realtime is unavailable. +function createRealtimeClient(callbacks = {}) { + if (typeof StompJs === 'undefined' || typeof SockJS === 'undefined') { + console.warn('Realtime libraries not loaded; falling back to polling.'); + return null; + } + const token = getRealtimeToken(); + if (!token) return null; + + const client = new StompJs.Client({ + webSocketFactory: () => new SockJS('/ws'), + connectHeaders: { Authorization: `Bearer ${token}` }, + reconnectDelay: 5000, + heartbeatIncoming: 10000, + heartbeatOutgoing: 10000, + debug: () => {} + }); + + // Pick up a freshly-refreshed token (authenticatedFetch rotates it) on every (re)connect. + client.beforeConnect = () => { + client.connectHeaders = { Authorization: `Bearer ${getRealtimeToken()}` }; + }; + + client.onConnect = () => { if (callbacks.onConnect) callbacks.onConnect(client); }; + client.onStompError = (frame) => { if (callbacks.onError) callbacks.onError(frame); }; + client.onWebSocketClose = () => { if (callbacks.onDisconnect) callbacks.onDisconnect(); }; + client.onWebSocketError = (evt) => { if (callbacks.onError) callbacks.onError(evt); }; + + client.activate(); + return client; +} diff --git a/src/main/resources/static/kitchen.html b/src/main/resources/static/kitchen.html new file mode 100644 index 0000000..719cba5 --- /dev/null +++ b/src/main/resources/static/kitchen.html @@ -0,0 +1,274 @@ + + + + + + Kitchen Display | RamsAI Kitchen + + + + + + + + +
    + + +
    + +
    +
    +

    Kitchen Display

    +
    + Connecting… + + +
    +
    + +
    +
    +
    +

    New

    + 0 +
    +
    +
    + +
    +
    +

    Cooking

    + 0 +
    +
    +
    + +
    +
    +

    Ready

    + 0 +
    +
    +
    +
    +
    + + + + + + + + diff --git a/src/main/resources/static/my-orders.html b/src/main/resources/static/my-orders.html index d022711..f7fb405 100644 --- a/src/main/resources/static/my-orders.html +++ b/src/main/resources/static/my-orders.html @@ -44,6 +44,7 @@

    Your Current Table + Live