From 5c654d39d364e96ed3568b72a14f83206ea28d9e Mon Sep 17 00:00:00 2001
From: Sohail Ahmad <67598673+sohail24@users.noreply.github.com>
Date: Sun, 30 Aug 2026 10:14:15 +0530
Subject: [PATCH 1/4] feat: implement notification-service with RabbitMQ
consumption and WebSocket STOMP support
---
.gitignore | 38 +++
backend/pom.xml | 6 +
.../config/RabbitMQProducerConfig.java | 58 ++++
.../orders/service/OrderService.java | 64 +++-
backend/src/main/resources/application.yml | 6 +
docker-compose.yml | 52 ++-
.../.mvn/wrapper/maven-wrapper.properties | 3 +
notification-service/Dockerfile | 4 +
notification-service/mvnw | 295 ++++++++++++++++++
notification-service/mvnw.cmd | 189 +++++++++++
notification-service/pom.xml | 65 ++++
.../NotificationServiceApplication.java | 11 +
.../notification/config/RabbitMQConfig.java | 120 +++++++
.../notification/config/WebSocketConfig.java | 37 +++
.../quickmenu/notification/dto/BellEvent.java | 44 +++
.../notification/dto/OrderEvent.java | 42 +++
.../listener/OrderEventListener.java | 79 +++++
.../src/main/resources/application.yml | 25 ++
18 files changed, 1124 insertions(+), 14 deletions(-)
create mode 100644 .gitignore
create mode 100644 backend/src/main/java/com/quickmenu/config/RabbitMQProducerConfig.java
create mode 100644 notification-service/.mvn/wrapper/maven-wrapper.properties
create mode 100644 notification-service/Dockerfile
create mode 100644 notification-service/mvnw
create mode 100644 notification-service/mvnw.cmd
create mode 100644 notification-service/pom.xml
create mode 100644 notification-service/src/main/java/com/quickmenu/notification/NotificationServiceApplication.java
create mode 100644 notification-service/src/main/java/com/quickmenu/notification/config/RabbitMQConfig.java
create mode 100644 notification-service/src/main/java/com/quickmenu/notification/config/WebSocketConfig.java
create mode 100644 notification-service/src/main/java/com/quickmenu/notification/dto/BellEvent.java
create mode 100644 notification-service/src/main/java/com/quickmenu/notification/dto/OrderEvent.java
create mode 100644 notification-service/src/main/java/com/quickmenu/notification/listener/OrderEventListener.java
create mode 100644 notification-service/src/main/resources/application.yml
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f66f483
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,38 @@
+HELP.md
+target/
+.mvn/wrapper/maven-wrapper.jar
+!**/src/main/**/target/
+!**/src/test/**/target/
+
+
+
+### STS ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+build/
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### VS Code ###
+.vscode/
+
+### SECRETS ###
+.env
diff --git a/backend/pom.xml b/backend/pom.xml
index 61be84d..489c3b1 100644
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -169,6 +169,12 @@
stripe-java
28.3.0
+
+
+
+ org.springframework.boot
+ spring-boot-starter-amqp
+
diff --git a/backend/src/main/java/com/quickmenu/config/RabbitMQProducerConfig.java b/backend/src/main/java/com/quickmenu/config/RabbitMQProducerConfig.java
new file mode 100644
index 0000000..4608421
--- /dev/null
+++ b/backend/src/main/java/com/quickmenu/config/RabbitMQProducerConfig.java
@@ -0,0 +1,58 @@
+package com.quickmenu.config;
+
+import org.springframework.amqp.core.TopicExchange;
+import org.springframework.amqp.rabbit.core.RabbitTemplate;
+import org.springframework.amqp.rabbit.connection.ConnectionFactory;
+import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * RabbitMQ producer configuration for the backend (monolith).
+ *
+ * We only PRODUCE here — we do not declare queues or bindings.
+ * Queue/binding declarations are the responsibility of the CONSUMER
+ * (notification-service), following the principle that the consumer
+ * owns its queue topology.
+ *
+ * We DO declare the Topic Exchange here because the producer needs it
+ * to exist before publishing. If notification-service also declares the
+ * same durable exchange, RabbitMQ treats that as idempotent — no error.
+ */
+@Configuration
+public class RabbitMQProducerConfig {
+
+ public static final String EXCHANGE = "quickmenu.events";
+
+ // Routing keys — these tell RabbitMQ which queues to deliver to
+ public static final String ORDER_PLACED_CASH = "order.placed.cash";
+ public static final String ORDER_PLACED_ONLINE = "order.placed.online";
+ public static final String ORDER_STATUS_UPDATED = "order.status.updated";
+
+ /**
+ * Declare the topic exchange. Durable = survives RabbitMQ restart.
+ * The notification-service declares the same exchange — this is fine,
+ * RabbitMQ is idempotent for exchange declarations with the same config.
+ */
+ @Bean
+ public TopicExchange quickmenuEventsExchange() {
+ return new TopicExchange(EXCHANGE, true, false);
+ }
+
+ /**
+ * Use JSON serialization for messages instead of Java serialization.
+ * This is critical for cross-service compatibility — notification-service
+ * is a different JVM and cannot deserialize Java-serialized objects.
+ */
+ @Bean
+ public Jackson2JsonMessageConverter producerJacksonConverter() {
+ return new Jackson2JsonMessageConverter();
+ }
+
+ @Bean
+ public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) {
+ RabbitTemplate template = new RabbitTemplate(connectionFactory);
+ template.setMessageConverter(producerJacksonConverter());
+ return template;
+ }
+}
diff --git a/backend/src/main/java/com/quickmenu/orders/service/OrderService.java b/backend/src/main/java/com/quickmenu/orders/service/OrderService.java
index ce71d07..1330eb9 100644
--- a/backend/src/main/java/com/quickmenu/orders/service/OrderService.java
+++ b/backend/src/main/java/com/quickmenu/orders/service/OrderService.java
@@ -11,7 +11,8 @@
import com.quickmenu.orders.model.OrderItem;
import com.quickmenu.orders.repo.OrderItemRepository;
import com.quickmenu.orders.repo.OrderRepository;
-import org.springframework.messaging.simp.SimpMessagingTemplate;
+import org.springframework.amqp.rabbit.core.RabbitTemplate;
+import com.quickmenu.config.RabbitMQProducerConfig;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.stripe.model.PaymentIntent;
@@ -30,7 +31,7 @@ public class OrderService {
private final OrderRepository orderRepository;
private final OrderItemRepository orderItemRepository;
private final DishRepository dishRepository;
- private final SimpMessagingTemplate messagingTemplate;
+ private final RabbitTemplate rabbitTemplate;
private final TableRepository tableRepository;
private final BellService bellService;
private final com.quickmenu.orders.strategy.DiscountService discountService;
@@ -45,7 +46,7 @@ public class OrderService {
public OrderService(OrderRepository orderRepository,
OrderItemRepository orderItemRepository,
DishRepository dishRepository,
- SimpMessagingTemplate messagingTemplate,
+ RabbitTemplate rabbitTemplate,
TableRepository tableRepository,
BellService bellService,
com.quickmenu.orders.strategy.DiscountService discountService,
@@ -53,7 +54,7 @@ public OrderService(OrderRepository orderRepository,
this.orderRepository = orderRepository;
this.orderItemRepository = orderItemRepository;
this.dishRepository = dishRepository;
- this.messagingTemplate = messagingTemplate;
+ this.rabbitTemplate = rabbitTemplate;
this.tableRepository = tableRepository;
this.bellService = bellService;
this.discountService = discountService;
@@ -158,10 +159,14 @@ public Map placeOrder(String restaurantId, OrderDto.CreateOrderR
Map orderPayload = enrichOrderForResponse(saved);
- // IMPORTANT: Only notify staff via STOMP if it's CASH (immediate)
- // For ONLINE, we notify in verifyPayment() after payment is successful
+ // Only publish event for CASH (immediate) orders.
+ // For ONLINE, we publish in verifyPayment() after Stripe confirms payment.
if (saved.getPaymentMethod() != Order.PaymentMethod.ONLINE) {
- messagingTemplate.convertAndSend("/topic/restaurants/" + restaurantId + "/orders", orderPayload);
+ rabbitTemplate.convertAndSend(
+ RabbitMQProducerConfig.EXCHANGE,
+ RabbitMQProducerConfig.ORDER_PLACED_CASH,
+ buildOrderEvent("ORDER_PLACED", restaurantId, saved, orderPayload)
+ );
}
return orderPayload;
@@ -235,14 +240,35 @@ public Order updateOrderStatus(String restaurantId, String orderId, Order.Status
}
Map orderPayload = enrichOrderForResponse(updated);
- messagingTemplate.convertAndSend("/topic/restaurants/" + restaurantId + "/orders", orderPayload);
+ rabbitTemplate.convertAndSend(
+ RabbitMQProducerConfig.EXCHANGE,
+ RabbitMQProducerConfig.ORDER_STATUS_UPDATED,
+ buildOrderEvent("STATUS_UPDATED", restaurantId, updated, orderPayload)
+ );
return updated;
}
- // Simple event DTO
+ // Simple event DTO (kept for backward compat if anything references it)
public static record OrderEvent(String orderId, String tableId, String type) { }
+ /**
+ * Builds the full event map published to RabbitMQ.
+ * The payload is the enriched order — same map previously sent via STOMP directly.
+ * notification-service deserializes this and forwards it to the WebSocket topic.
+ */
+ private java.util.Map buildOrderEvent(
+ String eventType, String restaurantId, Order order, Map payload) {
+ java.util.Map event = new java.util.HashMap<>();
+ event.put("eventType", eventType);
+ event.put("orderId", order.getId());
+ event.put("restaurantId", restaurantId);
+ event.put("tableId", order.getTableId());
+ event.put("status", order.getStatus() != null ? order.getStatus().toString() : null);
+ event.put("payload", payload);
+ return event;
+ }
+
/**
* Enrich order with item details (dishName, etc.)
@@ -353,7 +379,11 @@ public Map verifyPayment(String restaurantId, String orderId, Or
orderRepository.save(order);
Map orderPayload = enrichOrderForResponse(order);
- messagingTemplate.convertAndSend("/topic/restaurants/" + restaurantId + "/orders", orderPayload);
+ rabbitTemplate.convertAndSend(
+ RabbitMQProducerConfig.EXCHANGE,
+ RabbitMQProducerConfig.ORDER_PLACED_ONLINE,
+ buildOrderEvent("ORDER_PLACED", restaurantId, order, orderPayload)
+ );
return orderPayload;
} else {
@@ -427,7 +457,11 @@ public Map appendOrderItems(String restaurantId, String orderId,
Order updated = orderRepository.save(order);
Map orderPayload = enrichOrderForResponse(updated);
- messagingTemplate.convertAndSend("/topic/restaurants/" + restaurantId + "/orders", orderPayload);
+ rabbitTemplate.convertAndSend(
+ RabbitMQProducerConfig.EXCHANGE,
+ RabbitMQProducerConfig.ORDER_STATUS_UPDATED,
+ buildOrderEvent("ITEMS_APPENDED", restaurantId, updated, orderPayload)
+ );
return orderPayload;
}
@@ -456,9 +490,13 @@ public Map completeOrderPayment(String restaurantId, String orde
Order updated = orderRepository.save(order);
Map orderPayload = enrichOrderForResponse(updated);
-
+
if (updated.getPaymentMethod() != Order.PaymentMethod.ONLINE) {
- messagingTemplate.convertAndSend("/topic/restaurants/" + restaurantId + "/orders", orderPayload);
+ rabbitTemplate.convertAndSend(
+ RabbitMQProducerConfig.EXCHANGE,
+ RabbitMQProducerConfig.ORDER_STATUS_UPDATED,
+ buildOrderEvent("PAYMENT_COMPLETED", restaurantId, updated, orderPayload)
+ );
}
return orderPayload;
diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml
index beabe51..e594933 100644
--- a/backend/src/main/resources/application.yml
+++ b/backend/src/main/resources/application.yml
@@ -1,6 +1,12 @@
spring:
application:
name: quick-menu
+
+ rabbitmq:
+ host: ${RABBITMQ_HOST:localhost}
+ port: 5672
+ username: guest
+ password: guest
config:
import:
- optional:file:.env[.properties]
diff --git a/docker-compose.yml b/docker-compose.yml
index 8bf0b87..8091bab 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -13,13 +13,46 @@ services:
volumes:
- postgres_data:/var/lib/postgresql/data
+ rabbitmq:
+ image: rabbitmq:3.13-management
+ container_name: quickmenu-rabbitmq
+ ports:
+ - "5672:5672" # AMQP protocol (Spring connects here)
+ - "15672:15672" # Management UI (browser)
+ environment:
+ RABBITMQ_DEFAULT_USER: guest
+ RABBITMQ_DEFAULT_PASS: guest
+ volumes:
+ - rabbitmq_data:/var/lib/rabbitmq
+ healthcheck:
+ test: ["CMD", "rabbitmq-diagnostics", "ping"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+
+ redis:
+ image: redis:7-alpine
+ container_name: quickmenu-redis
+ ports:
+ - "6379:6379"
+ volumes:
+ - redis_data:/data
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: quickmenu-backend
depends_on:
- - db
+ db:
+ condition: service_started
+ rabbitmq:
+ condition: service_healthy
env_file:
- ./backend/.env
environment:
@@ -27,8 +60,25 @@ services:
SPRING_DATASOURCE_USERNAME: user
SPRING_DATASOURCE_PASSWORD: password
SPRING_JPA_HIBERNATE_DDL_AUTO: update
+ RABBITMQ_HOST: rabbitmq
ports:
- "8080:8080"
+ notification-service:
+ build:
+ context: ./notification-service
+ dockerfile: Dockerfile
+ container_name: quickmenu-notification
+ depends_on:
+ rabbitmq:
+ condition: service_healthy
+ environment:
+ RABBITMQ_HOST: rabbitmq
+ ports:
+ - "8084:8084"
+
+
volumes:
postgres_data:
+ rabbitmq_data:
+ redis_data:
diff --git a/notification-service/.mvn/wrapper/maven-wrapper.properties b/notification-service/.mvn/wrapper/maven-wrapper.properties
new file mode 100644
index 0000000..c0bcafe
--- /dev/null
+++ b/notification-service/.mvn/wrapper/maven-wrapper.properties
@@ -0,0 +1,3 @@
+wrapperVersion=3.3.4
+distributionType=only-script
+distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip
diff --git a/notification-service/Dockerfile b/notification-service/Dockerfile
new file mode 100644
index 0000000..2816413
--- /dev/null
+++ b/notification-service/Dockerfile
@@ -0,0 +1,4 @@
+FROM eclipse-temurin:17-jre-alpine
+WORKDIR /app
+COPY target/*.jar app.jar
+ENTRYPOINT ["java", "-jar", "app.jar"]
diff --git a/notification-service/mvnw b/notification-service/mvnw
new file mode 100644
index 0000000..bd8896b
--- /dev/null
+++ b/notification-service/mvnw
@@ -0,0 +1,295 @@
+#!/bin/sh
+# ----------------------------------------------------------------------------
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+# ----------------------------------------------------------------------------
+
+# ----------------------------------------------------------------------------
+# Apache Maven Wrapper startup batch script, version 3.3.4
+#
+# Optional ENV vars
+# -----------------
+# JAVA_HOME - location of a JDK home dir, required when download maven via java source
+# MVNW_REPOURL - repo url base for downloading maven distribution
+# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
+# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
+# ----------------------------------------------------------------------------
+
+set -euf
+[ "${MVNW_VERBOSE-}" != debug ] || set -x
+
+# OS specific support.
+native_path() { printf %s\\n "$1"; }
+case "$(uname)" in
+CYGWIN* | MINGW*)
+ [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
+ native_path() { cygpath --path --windows "$1"; }
+ ;;
+esac
+
+# set JAVACMD and JAVACCMD
+set_java_home() {
+ # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
+ if [ -n "${JAVA_HOME-}" ]; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ]; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ JAVACCMD="$JAVA_HOME/jre/sh/javac"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ JAVACCMD="$JAVA_HOME/bin/javac"
+
+ if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
+ echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
+ echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
+ return 1
+ fi
+ fi
+ else
+ JAVACMD="$(
+ 'set' +e
+ 'unset' -f command 2>/dev/null
+ 'command' -v java
+ )" || :
+ JAVACCMD="$(
+ 'set' +e
+ 'unset' -f command 2>/dev/null
+ 'command' -v javac
+ )" || :
+
+ if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
+ echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
+ return 1
+ fi
+ fi
+}
+
+# hash string like Java String::hashCode
+hash_string() {
+ str="${1:-}" h=0
+ while [ -n "$str" ]; do
+ char="${str%"${str#?}"}"
+ h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
+ str="${str#?}"
+ done
+ printf %x\\n $h
+}
+
+verbose() { :; }
+[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
+
+die() {
+ printf %s\\n "$1" >&2
+ exit 1
+}
+
+trim() {
+ # MWRAPPER-139:
+ # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
+ # Needed for removing poorly interpreted newline sequences when running in more
+ # exotic environments such as mingw bash on Windows.
+ printf "%s" "${1}" | tr -d '[:space:]'
+}
+
+scriptDir="$(dirname "$0")"
+scriptName="$(basename "$0")"
+
+# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
+while IFS="=" read -r key value; do
+ case "${key-}" in
+ distributionUrl) distributionUrl=$(trim "${value-}") ;;
+ distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
+ esac
+done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
+[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
+
+case "${distributionUrl##*/}" in
+maven-mvnd-*bin.*)
+ MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
+ case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
+ *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
+ :Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
+ :Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
+ :Linux*x86_64*) distributionPlatform=linux-amd64 ;;
+ *)
+ echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
+ distributionPlatform=linux-amd64
+ ;;
+ esac
+ distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
+ ;;
+maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
+*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
+esac
+
+# apply MVNW_REPOURL and calculate MAVEN_HOME
+# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/
+[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
+distributionUrlName="${distributionUrl##*/}"
+distributionUrlNameMain="${distributionUrlName%.*}"
+distributionUrlNameMain="${distributionUrlNameMain%-bin}"
+MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
+MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
+
+exec_maven() {
+ unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
+ exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
+}
+
+if [ -d "$MAVEN_HOME" ]; then
+ verbose "found existing MAVEN_HOME at $MAVEN_HOME"
+ exec_maven "$@"
+fi
+
+case "${distributionUrl-}" in
+*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
+*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
+esac
+
+# prepare tmp dir
+if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
+ clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
+ trap clean HUP INT TERM EXIT
+else
+ die "cannot create temp dir"
+fi
+
+mkdir -p -- "${MAVEN_HOME%/*}"
+
+# Download and Install Apache Maven
+verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
+verbose "Downloading from: $distributionUrl"
+verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
+
+# select .zip or .tar.gz
+if ! command -v unzip >/dev/null; then
+ distributionUrl="${distributionUrl%.zip}.tar.gz"
+ distributionUrlName="${distributionUrl##*/}"
+fi
+
+# verbose opt
+__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
+[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
+
+# normalize http auth
+case "${MVNW_PASSWORD:+has-password}" in
+'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
+has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
+esac
+
+if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
+ verbose "Found wget ... using wget"
+ wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
+elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
+ verbose "Found curl ... using curl"
+ curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
+elif set_java_home; then
+ verbose "Falling back to use Java to download"
+ javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
+ targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
+ cat >"$javaSource" <<-END
+ public class Downloader extends java.net.Authenticator
+ {
+ protected java.net.PasswordAuthentication getPasswordAuthentication()
+ {
+ return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
+ }
+ public static void main( String[] args ) throws Exception
+ {
+ setDefault( new Downloader() );
+ java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
+ }
+ }
+ END
+ # For Cygwin/MinGW, switch paths to Windows format before running javac and java
+ verbose " - Compiling Downloader.java ..."
+ "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
+ verbose " - Running Downloader.java ..."
+ "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
+fi
+
+# If specified, validate the SHA-256 sum of the Maven distribution zip file
+if [ -n "${distributionSha256Sum-}" ]; then
+ distributionSha256Result=false
+ if [ "$MVN_CMD" = mvnd.sh ]; then
+ echo "Checksum validation is not supported for maven-mvnd." >&2
+ echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
+ exit 1
+ elif command -v sha256sum >/dev/null; then
+ if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
+ distributionSha256Result=true
+ fi
+ elif command -v shasum >/dev/null; then
+ if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
+ distributionSha256Result=true
+ fi
+ else
+ echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
+ echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
+ exit 1
+ fi
+ if [ $distributionSha256Result = false ]; then
+ echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
+ echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
+ exit 1
+ fi
+fi
+
+# unzip and move
+if command -v unzip >/dev/null; then
+ unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
+else
+ tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
+fi
+
+# Find the actual extracted directory name (handles snapshots where filename != directory name)
+actualDistributionDir=""
+
+# First try the expected directory name (for regular distributions)
+if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
+ if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
+ actualDistributionDir="$distributionUrlNameMain"
+ fi
+fi
+
+# If not found, search for any directory with the Maven executable (for snapshots)
+if [ -z "$actualDistributionDir" ]; then
+ # enable globbing to iterate over items
+ set +f
+ for dir in "$TMP_DOWNLOAD_DIR"/*; do
+ if [ -d "$dir" ]; then
+ if [ -f "$dir/bin/$MVN_CMD" ]; then
+ actualDistributionDir="$(basename "$dir")"
+ break
+ fi
+ fi
+ done
+ set -f
+fi
+
+if [ -z "$actualDistributionDir" ]; then
+ verbose "Contents of $TMP_DOWNLOAD_DIR:"
+ verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
+ die "Could not find Maven distribution directory in extracted archive"
+fi
+
+verbose "Found extracted Maven distribution directory: $actualDistributionDir"
+printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
+mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
+
+clean || :
+exec_maven "$@"
diff --git a/notification-service/mvnw.cmd b/notification-service/mvnw.cmd
new file mode 100644
index 0000000..92450f9
--- /dev/null
+++ b/notification-service/mvnw.cmd
@@ -0,0 +1,189 @@
+<# : batch portion
+@REM ----------------------------------------------------------------------------
+@REM Licensed to the Apache Software Foundation (ASF) under one
+@REM or more contributor license agreements. See the NOTICE file
+@REM distributed with this work for additional information
+@REM regarding copyright ownership. The ASF licenses this file
+@REM to you under the Apache License, Version 2.0 (the
+@REM "License"); you may not use this file except in compliance
+@REM with the License. You may obtain a copy of the License at
+@REM
+@REM http://www.apache.org/licenses/LICENSE-2.0
+@REM
+@REM Unless required by applicable law or agreed to in writing,
+@REM software distributed under the License is distributed on an
+@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+@REM KIND, either express or implied. See the License for the
+@REM specific language governing permissions and limitations
+@REM under the License.
+@REM ----------------------------------------------------------------------------
+
+@REM ----------------------------------------------------------------------------
+@REM Apache Maven Wrapper startup batch script, version 3.3.4
+@REM
+@REM Optional ENV vars
+@REM MVNW_REPOURL - repo url base for downloading maven distribution
+@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
+@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
+@REM ----------------------------------------------------------------------------
+
+@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
+@SET __MVNW_CMD__=
+@SET __MVNW_ERROR__=
+@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
+@SET PSModulePath=
+@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
+ IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
+)
+@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
+@SET __MVNW_PSMODULEP_SAVE=
+@SET __MVNW_ARG0_NAME__=
+@SET MVNW_USERNAME=
+@SET MVNW_PASSWORD=
+@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
+@echo Cannot start maven from wrapper >&2 && exit /b 1
+@GOTO :EOF
+: end batch / begin powershell #>
+
+$ErrorActionPreference = "Stop"
+if ($env:MVNW_VERBOSE -eq "true") {
+ $VerbosePreference = "Continue"
+}
+
+# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
+$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
+if (!$distributionUrl) {
+ Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
+}
+
+switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
+ "maven-mvnd-*" {
+ $USE_MVND = $true
+ $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
+ $MVN_CMD = "mvnd.cmd"
+ break
+ }
+ default {
+ $USE_MVND = $false
+ $MVN_CMD = $script -replace '^mvnw','mvn'
+ break
+ }
+}
+
+# apply MVNW_REPOURL and calculate MAVEN_HOME
+# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/
+if ($env:MVNW_REPOURL) {
+ $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
+ $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
+}
+$distributionUrlName = $distributionUrl -replace '^.*/',''
+$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
+
+$MAVEN_M2_PATH = "$HOME/.m2"
+if ($env:MAVEN_USER_HOME) {
+ $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
+}
+
+if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
+ New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
+}
+
+$MAVEN_WRAPPER_DISTS = $null
+if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
+ $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
+} else {
+ $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
+}
+
+$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
+$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
+$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
+
+if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
+ Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
+ Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
+ exit $?
+}
+
+if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
+ Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
+}
+
+# prepare tmp dir
+$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
+$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
+$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
+trap {
+ if ($TMP_DOWNLOAD_DIR.Exists) {
+ try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
+ catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
+ }
+}
+
+New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
+
+# Download and Install Apache Maven
+Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
+Write-Verbose "Downloading from: $distributionUrl"
+Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
+
+$webclient = New-Object System.Net.WebClient
+if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
+ $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
+}
+[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
+$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
+
+# If specified, validate the SHA-256 sum of the Maven distribution zip file
+$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
+if ($distributionSha256Sum) {
+ if ($USE_MVND) {
+ Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
+ }
+ Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
+ if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
+ Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
+ }
+}
+
+# unzip and move
+Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
+
+# Find the actual extracted directory name (handles snapshots where filename != directory name)
+$actualDistributionDir = ""
+
+# First try the expected directory name (for regular distributions)
+$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
+$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
+if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
+ $actualDistributionDir = $distributionUrlNameMain
+}
+
+# If not found, search for any directory with the Maven executable (for snapshots)
+if (!$actualDistributionDir) {
+ Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
+ $testPath = Join-Path $_.FullName "bin/$MVN_CMD"
+ if (Test-Path -Path $testPath -PathType Leaf) {
+ $actualDistributionDir = $_.Name
+ }
+ }
+}
+
+if (!$actualDistributionDir) {
+ Write-Error "Could not find Maven distribution directory in extracted archive"
+}
+
+Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
+Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
+try {
+ Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
+} catch {
+ if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
+ Write-Error "fail to move MAVEN_HOME"
+ }
+} finally {
+ try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
+ catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
+}
+
+Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
diff --git a/notification-service/pom.xml b/notification-service/pom.xml
new file mode 100644
index 0000000..2388272
--- /dev/null
+++ b/notification-service/pom.xml
@@ -0,0 +1,65 @@
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.2.5
+
+
+
+ com.quickmenu
+ notification-service
+ 0.0.1-SNAPSHOT
+ notification-service
+ QuickMenu Notification Service — RabbitMQ consumer + WebSocket STOMP push
+
+
+ 17
+ UTF-8
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-websocket
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-amqp
+
+
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+
diff --git a/notification-service/src/main/java/com/quickmenu/notification/NotificationServiceApplication.java b/notification-service/src/main/java/com/quickmenu/notification/NotificationServiceApplication.java
new file mode 100644
index 0000000..7be21de
--- /dev/null
+++ b/notification-service/src/main/java/com/quickmenu/notification/NotificationServiceApplication.java
@@ -0,0 +1,11 @@
+package com.quickmenu.notification;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class NotificationServiceApplication {
+ public static void main(String[] args) {
+ SpringApplication.run(NotificationServiceApplication.class, args);
+ }
+}
diff --git a/notification-service/src/main/java/com/quickmenu/notification/config/RabbitMQConfig.java b/notification-service/src/main/java/com/quickmenu/notification/config/RabbitMQConfig.java
new file mode 100644
index 0000000..a9627c1
--- /dev/null
+++ b/notification-service/src/main/java/com/quickmenu/notification/config/RabbitMQConfig.java
@@ -0,0 +1,120 @@
+package com.quickmenu.notification.config;
+
+import org.springframework.amqp.core.*;
+import org.springframework.amqp.rabbit.config.SimpleRabbitListenerContainerFactory;
+import org.springframework.amqp.rabbit.connection.ConnectionFactory;
+import org.springframework.amqp.rabbit.core.RabbitTemplate;
+import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * Declares the RabbitMQ topology this service needs.
+ *
+ * Exchange: quickmenu.events (topic) — shared with backend
+ * Queue: notification.orders.queue — this service reads from
+ * Binding: order.placed.* and order.status.* route into the queue
+ *
+ * Dead Letter Exchange (DLX):
+ * If a message is rejected or times out, it lands in quickmenu.dlq
+ * instead of disappearing. This is what guarantees at-least-once delivery.
+ */
+@Configuration
+public class RabbitMQConfig {
+
+ // ── Exchange names ────────────────────────────────────────────────────────
+ public static final String EXCHANGE = "quickmenu.events";
+ public static final String DLX = "quickmenu.dlx";
+
+ // ── Queue names ───────────────────────────────────────────────────────────
+ public static final String ORDERS_QUEUE = "notification.orders.queue";
+ public static final String BELLS_QUEUE = "notification.bells.queue";
+ public static final String DLQ = "quickmenu.dlq";
+
+ // ── Routing keys this service cares about ─────────────────────────────────
+ public static final String ORDER_PLACED_KEY = "order.placed.*"; // matches order.placed.cash, order.placed.online
+ public static final String ORDER_STATUS_KEY = "order.status.*"; // matches order.status.updated, etc.
+ public static final String BELL_KEY = "bell.ring";
+
+ // ── Topic Exchange — backend publishes here ───────────────────────────────
+ @Bean
+ public TopicExchange quickmenuEventsExchange() {
+ return ExchangeBuilder.topicExchange(EXCHANGE).durable(true).build();
+ }
+
+ // ── Dead Letter Exchange — failed messages land here ─────────────────────
+ @Bean
+ public DirectExchange deadLetterExchange() {
+ return ExchangeBuilder.directExchange(DLX).durable(true).build();
+ }
+
+ // ── Dead Letter Queue ─────────────────────────────────────────────────────
+ @Bean
+ public Queue deadLetterQueue() {
+ return QueueBuilder.durable(DLQ).build();
+ }
+
+ @Bean
+ public Binding dlqBinding(Queue deadLetterQueue, DirectExchange deadLetterExchange) {
+ return BindingBuilder.bind(deadLetterQueue).to(deadLetterExchange).with(DLQ);
+ }
+
+ // ── Orders Queue ──────────────────────────────────────────────────────────
+ // x-dead-letter-exchange tells RabbitMQ: if a message is rejected,
+ // forward it to our DLX instead of dropping it.
+ @Bean
+ public Queue ordersQueue() {
+ return QueueBuilder.durable(ORDERS_QUEUE)
+ .withArgument("x-dead-letter-exchange", DLX)
+ .withArgument("x-dead-letter-routing-key", DLQ)
+ .build();
+ }
+
+ @Bean
+ public Binding ordersPlacedBinding(Queue ordersQueue, TopicExchange quickmenuEventsExchange) {
+ return BindingBuilder.bind(ordersQueue).to(quickmenuEventsExchange).with(ORDER_PLACED_KEY);
+ }
+
+ @Bean
+ public Binding ordersStatusBinding(Queue ordersQueue, TopicExchange quickmenuEventsExchange) {
+ return BindingBuilder.bind(ordersQueue).to(quickmenuEventsExchange).with(ORDER_STATUS_KEY);
+ }
+
+ // ── Bells Queue ───────────────────────────────────────────────────────────
+ @Bean
+ public Queue bellsQueue() {
+ return QueueBuilder.durable(BELLS_QUEUE)
+ .withArgument("x-dead-letter-exchange", DLX)
+ .withArgument("x-dead-letter-routing-key", DLQ)
+ .build();
+ }
+
+ @Bean
+ public Binding bellsBinding(Queue bellsQueue, TopicExchange quickmenuEventsExchange) {
+ return BindingBuilder.bind(bellsQueue).to(quickmenuEventsExchange).with(BELL_KEY);
+ }
+
+ // ── JSON message converter ────────────────────────────────────────────────
+ // Tells Spring AMQP to serialize/deserialize messages as JSON
+ // instead of Java serialization (which is brittle across services)
+ @Bean
+ public Jackson2JsonMessageConverter jsonMessageConverter() {
+ return new Jackson2JsonMessageConverter();
+ }
+
+ @Bean
+ public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) {
+ RabbitTemplate template = new RabbitTemplate(connectionFactory);
+ template.setMessageConverter(jsonMessageConverter());
+ return template;
+ }
+
+ @Bean
+ public SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory(
+ ConnectionFactory connectionFactory) {
+ SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory();
+ factory.setConnectionFactory(connectionFactory);
+ factory.setMessageConverter(jsonMessageConverter());
+ return factory;
+ }
+}
diff --git a/notification-service/src/main/java/com/quickmenu/notification/config/WebSocketConfig.java b/notification-service/src/main/java/com/quickmenu/notification/config/WebSocketConfig.java
new file mode 100644
index 0000000..7e3c03b
--- /dev/null
+++ b/notification-service/src/main/java/com/quickmenu/notification/config/WebSocketConfig.java
@@ -0,0 +1,37 @@
+package com.quickmenu.notification.config;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.messaging.simp.config.MessageBrokerRegistry;
+import org.springframework.web.socket.config.annotation.*;
+
+/**
+ * Mirrors the WebSocketConfig from the monolith exactly.
+ *
+ * /ws → SockJS endpoint (used by browsers via the frontend)
+ * /websocket → pure WebSocket endpoint (used by Postman / direct STOMP clients)
+ * /topic → prefix for server-to-client push topics
+ * /app → prefix for client-to-server @MessageMapping methods (not used here, but kept for symmetry)
+ */
+@Configuration
+@EnableWebSocketMessageBroker
+public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
+
+ @Override
+ public void configureMessageBroker(MessageBrokerRegistry registry) {
+ // In-memory broker handles subscriptions to /topic/... and /queue/...
+ registry.enableSimpleBroker("/topic", "/queue");
+ registry.setApplicationDestinationPrefixes("/app");
+ }
+
+ @Override
+ public void registerStompEndpoints(StompEndpointRegistry registry) {
+ // Pure WebSocket — for Postman or direct STOMP clients
+ registry.addEndpoint("/websocket")
+ .setAllowedOriginPatterns("*");
+
+ // SockJS fallback — for browsers
+ registry.addEndpoint("/ws")
+ .setAllowedOriginPatterns("*")
+ .withSockJS();
+ }
+}
diff --git a/notification-service/src/main/java/com/quickmenu/notification/dto/BellEvent.java b/notification-service/src/main/java/com/quickmenu/notification/dto/BellEvent.java
new file mode 100644
index 0000000..504adc5
--- /dev/null
+++ b/notification-service/src/main/java/com/quickmenu/notification/dto/BellEvent.java
@@ -0,0 +1,44 @@
+package com.quickmenu.notification.dto;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
+/**
+ * BellEvent DTO — received from RabbitMQ when a table rings for a waiter.
+ * Currently published by BellService in the backend monolith (Day 1).
+ * Will be published by the standalone bell-service in Day 2.
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class BellEvent {
+
+ private String eventType; // BELL_CREATED, BELL_ACKED
+ private String bellId;
+ private String restaurantId;
+ private String tableId;
+ private String tableName;
+ private String message;
+ private String createdAt;
+
+ public BellEvent() {}
+
+ public String getEventType() { return eventType; }
+ public String getBellId() { return bellId; }
+ public String getRestaurantId() { return restaurantId; }
+ public String getTableId() { return tableId; }
+ public String getTableName() { return tableName; }
+ public String getMessage() { return message; }
+ public String getCreatedAt() { return createdAt; }
+
+ public void setEventType(String eventType) { this.eventType = eventType; }
+ public void setBellId(String bellId) { this.bellId = bellId; }
+ public void setRestaurantId(String restaurantId) { this.restaurantId = restaurantId; }
+ public void setTableId(String tableId) { this.tableId = tableId; }
+ public void setTableName(String tableName) { this.tableName = tableName; }
+ public void setMessage(String message) { this.message = message; }
+ public void setCreatedAt(String createdAt) { this.createdAt = createdAt; }
+
+ @Override
+ public String toString() {
+ return "BellEvent{type=" + eventType + ", bellId=" + bellId
+ + ", restaurantId=" + restaurantId + ", tableId=" + tableId + "}";
+ }
+}
diff --git a/notification-service/src/main/java/com/quickmenu/notification/dto/OrderEvent.java b/notification-service/src/main/java/com/quickmenu/notification/dto/OrderEvent.java
new file mode 100644
index 0000000..20a4348
--- /dev/null
+++ b/notification-service/src/main/java/com/quickmenu/notification/dto/OrderEvent.java
@@ -0,0 +1,42 @@
+package com.quickmenu.notification.dto;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
+/**
+ * Mirrors the OrderEvent published by backend's OrderService.
+ *
+ * @JsonIgnoreProperties(ignoreUnknown = true) means if the backend adds
+ * new fields later, this service won't crash — resilient by design.
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class OrderEvent {
+
+ private String eventType; // ORDER_PLACED, STATUS_UPDATED, PAYMENT_VERIFIED, etc.
+ private String orderId;
+ private String restaurantId;
+ private String tableId;
+ private String status;
+ private Object payload; // the full enriched order map
+
+ public OrderEvent() {}
+
+ public String getEventType() { return eventType; }
+ public String getOrderId() { return orderId; }
+ public String getRestaurantId() { return restaurantId; }
+ public String getTableId() { return tableId; }
+ public String getStatus() { return status; }
+ public Object getPayload() { return payload; }
+
+ public void setEventType(String eventType) { this.eventType = eventType; }
+ public void setOrderId(String orderId) { this.orderId = orderId; }
+ public void setRestaurantId(String restaurantId) { this.restaurantId = restaurantId; }
+ public void setTableId(String tableId) { this.tableId = tableId; }
+ public void setStatus(String status) { this.status = status; }
+ public void setPayload(Object payload) { this.payload = payload; }
+
+ @Override
+ public String toString() {
+ return "OrderEvent{type=" + eventType + ", orderId=" + orderId
+ + ", restaurantId=" + restaurantId + ", status=" + status + "}";
+ }
+}
diff --git a/notification-service/src/main/java/com/quickmenu/notification/listener/OrderEventListener.java b/notification-service/src/main/java/com/quickmenu/notification/listener/OrderEventListener.java
new file mode 100644
index 0000000..43eec7b
--- /dev/null
+++ b/notification-service/src/main/java/com/quickmenu/notification/listener/OrderEventListener.java
@@ -0,0 +1,79 @@
+package com.quickmenu.notification.listener;
+
+import com.quickmenu.notification.config.RabbitMQConfig;
+import com.quickmenu.notification.dto.BellEvent;
+import com.quickmenu.notification.dto.OrderEvent;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.amqp.rabbit.annotation.RabbitListener;
+import org.springframework.messaging.simp.SimpMessagingTemplate;
+import org.springframework.stereotype.Component;
+
+/**
+ * Listens to RabbitMQ queues and pushes STOMP WebSocket frames to connected browsers.
+ *
+ * This is the ONLY class that touches SimpMessagingTemplate in this service.
+ * The backend (OrderService) no longer calls SimpMessagingTemplate directly —
+ * it publishes to RabbitMQ and this service handles the push asynchronously.
+ *
+ * Why this matters (interview answer):
+ * "OrderService's HTTP response no longer waits for WebSocket delivery.
+ * They are decoupled. If notification-service is down, the order still saves
+ * and the message queues in RabbitMQ until the service recovers."
+ */
+@Component
+public class OrderEventListener {
+
+ private static final Logger log = LoggerFactory.getLogger(OrderEventListener.class);
+
+ private final SimpMessagingTemplate messagingTemplate;
+
+ public OrderEventListener(SimpMessagingTemplate messagingTemplate) {
+ this.messagingTemplate = messagingTemplate;
+ }
+
+ /**
+ * Listens to the orders queue.
+ * Routing keys that land here: order.placed.cash, order.placed.online, order.status.*
+ */
+ @RabbitListener(queues = RabbitMQConfig.ORDERS_QUEUE)
+ public void handleOrderEvent(OrderEvent event) {
+ log.info("[NOTIFICATION] Received order event: type={}, orderId={}, restaurantId={}",
+ event.getEventType(), event.getOrderId(), event.getRestaurantId());
+
+ if (event.getRestaurantId() == null) {
+ log.warn("[NOTIFICATION] Order event missing restaurantId — skipping push");
+ return;
+ }
+
+ String topic = "/topic/restaurants/" + event.getRestaurantId() + "/orders";
+
+ // Push the full payload (the enriched order map) to all subscribed browsers
+ Object pushPayload = event.getPayload() != null ? event.getPayload() : event;
+ messagingTemplate.convertAndSend(topic, pushPayload);
+
+ log.info("[NOTIFICATION] Pushed order event to STOMP topic: {}", topic);
+ }
+
+ /**
+ * Listens to the bells queue.
+ * Routing key: bell.ring
+ * Currently: published by BellService in the backend monolith.
+ * Day 2: will be published by the standalone bell-service.
+ */
+ @RabbitListener(queues = RabbitMQConfig.BELLS_QUEUE)
+ public void handleBellEvent(BellEvent event) {
+ log.info("[NOTIFICATION] Received bell event: type={}, bellId={}, restaurantId={}, tableId={}",
+ event.getEventType(), event.getBellId(), event.getRestaurantId(), event.getTableId());
+
+ if (event.getRestaurantId() == null) {
+ log.warn("[NOTIFICATION] Bell event missing restaurantId — skipping push");
+ return;
+ }
+
+ String topic = "/topic/restaurants/" + event.getRestaurantId() + "/bells";
+ messagingTemplate.convertAndSend(topic, event);
+
+ log.info("[NOTIFICATION] Pushed bell event to STOMP topic: {}", topic);
+ }
+}
diff --git a/notification-service/src/main/resources/application.yml b/notification-service/src/main/resources/application.yml
new file mode 100644
index 0000000..b19bb42
--- /dev/null
+++ b/notification-service/src/main/resources/application.yml
@@ -0,0 +1,25 @@
+spring:
+ application:
+ name: notification-service
+
+ rabbitmq:
+ host: ${RABBITMQ_HOST:localhost}
+ port: 5672
+ username: guest
+ password: guest
+
+server:
+ port: 8084
+
+# Health check endpoint for Docker
+management:
+ endpoints:
+ web:
+ exposure:
+ include: health
+ endpoint:
+ health:
+ show-details: always
+
+websocket:
+ allowed-origins: "*"
From 1734ce4c5d32b6570d3c9b5983867e818d5127ab Mon Sep 17 00:00:00 2001
From: Sohail Ahmad <67598673+sohail24@users.noreply.github.com>
Date: Sun, 30 Aug 2026 11:32:47 +0530
Subject: [PATCH 2/4] feat: implement JWT-based security and refactor
BellService to use Redis and RabbitMQ for distributed rate limiting and event
notifications.
---
backend/pom.xml | 6 +
.../auth/controller/AuthController.java | 29 ++-
.../security/JwtAuthenticationFilter.java | 36 ++--
.../auth/security/JwtTokenProvider.java | 4 +
.../quickmenu/bell/service/BellService.java | 172 ++++++++++--------
.../com/quickmenu/config/SecurityConfig.java | 10 +-
backend/src/main/resources/application.yml | 5 +
docker-compose.yml | 3 +
8 files changed, 172 insertions(+), 93 deletions(-)
diff --git a/backend/pom.xml b/backend/pom.xml
index 489c3b1..d94355b 100644
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -175,6 +175,12 @@
org.springframework.boot
spring-boot-starter-amqp
+
+
+
+ org.springframework.boot
+ spring-boot-starter-data-redis
+
diff --git a/backend/src/main/java/com/quickmenu/auth/controller/AuthController.java b/backend/src/main/java/com/quickmenu/auth/controller/AuthController.java
index 0722589..5ad5137 100644
--- a/backend/src/main/java/com/quickmenu/auth/controller/AuthController.java
+++ b/backend/src/main/java/com/quickmenu/auth/controller/AuthController.java
@@ -28,6 +28,10 @@
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import java.time.Duration;
+import java.util.Date;
+
@RestController
@RequestMapping("/api/auth")
@Tag(name = "Authentication", description = "Endpoints for Authentication")
@@ -38,6 +42,7 @@ public class AuthController {
private final JwtTokenProvider tokenProvider;
private final UserService userService;
private final EmailSender emailSender;
+ private final StringRedisTemplate redisTemplate;
// Simple in-memory storage for reset tokens (email -> token)
private final Map resetTokens = new ConcurrentHashMap<>();
@@ -45,11 +50,33 @@ public class AuthController {
public AuthController(AuthenticationManager authenticationManager,
JwtTokenProvider tokenProvider,
UserService userService,
- EmailSender emailSender) {
+ EmailSender emailSender,
+ StringRedisTemplate redisTemplate) {
this.authenticationManager = authenticationManager;
this.tokenProvider = tokenProvider;
this.userService = userService;
this.emailSender = emailSender;
+ this.redisTemplate = redisTemplate;
+ }
+
+ @PostMapping("/logout")
+ @Operation(summary = "Logout Endpoint", description = "Revokes the JWT token by adding it to Redis blacklist")
+ public ResponseEntity> logout(@RequestHeader(value = "Authorization", required = false) String authHeader) {
+ if (authHeader != null && authHeader.startsWith("Bearer ")) {
+ String token = authHeader.substring(7);
+ try {
+ Date expiration = tokenProvider.getExpiration(token);
+ long remainingMillis = expiration.getTime() - System.currentTimeMillis();
+
+ if (remainingMillis > 0) {
+ String blacklistKey = "jwt:blacklist:" + token;
+ redisTemplate.opsForValue().set(blacklistKey, "revoked", Duration.ofMillis(remainingMillis));
+ }
+ } catch (Exception e) {
+ // If token is already invalid/expired, nothing to blacklist
+ }
+ }
+ return ResponseEntity.ok(Map.of("message", "Logged out successfully"));
}
@PostMapping(value = "/signup", produces = MediaType.APPLICATION_JSON_VALUE)
diff --git a/backend/src/main/java/com/quickmenu/auth/security/JwtAuthenticationFilter.java b/backend/src/main/java/com/quickmenu/auth/security/JwtAuthenticationFilter.java
index 5fbb1a4..a250ddd 100644
--- a/backend/src/main/java/com/quickmenu/auth/security/JwtAuthenticationFilter.java
+++ b/backend/src/main/java/com/quickmenu/auth/security/JwtAuthenticationFilter.java
@@ -6,6 +6,7 @@
import jakarta.servlet.http.HttpServletResponse;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
+import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
@@ -19,11 +20,14 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider tokenProvider;
private final CustomUserDetailsService userDetailsService;
+ private final StringRedisTemplate redisTemplate;
public JwtAuthenticationFilter(JwtTokenProvider tokenProvider,
- CustomUserDetailsService userDetailsService) {
+ CustomUserDetailsService userDetailsService,
+ StringRedisTemplate redisTemplate) {
this.tokenProvider = tokenProvider;
this.userDetailsService = userDetailsService;
+ this.redisTemplate = redisTemplate;
}
@Override
@@ -33,18 +37,26 @@ protected void doFilterInternal(HttpServletRequest request,
String token = resolveToken(request);
if (StringUtils.hasText(token) && tokenProvider.validateToken(token)) {
- try {
- Jws claimsJws = tokenProvider.parseClaims(token);
- String email = claimsJws.getBody().get("email", String.class);
-
- UserDetails userDetails = userDetailsService.loadUserByUsername(email);
- UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(
- userDetails, null, userDetails.getAuthorities());
- auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
- SecurityContextHolder.getContext().setAuthentication(auth);
- } catch (Exception e) {
- // invalid token - ignore and continue
+ // Check Redis blacklist
+ String blacklistKey = "jwt:blacklist:" + token;
+ Boolean isBlacklisted = redisTemplate.hasKey(blacklistKey);
+
+ if (Boolean.TRUE.equals(isBlacklisted)) {
SecurityContextHolder.clearContext();
+ } else {
+ try {
+ Jws claimsJws = tokenProvider.parseClaims(token);
+ String email = claimsJws.getPayload().get("email", String.class);
+
+ UserDetails userDetails = userDetailsService.loadUserByUsername(email);
+ UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(
+ userDetails, null, userDetails.getAuthorities());
+ auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
+ SecurityContextHolder.getContext().setAuthentication(auth);
+ } catch (Exception e) {
+ // invalid token - ignore and continue
+ SecurityContextHolder.clearContext();
+ }
}
}
diff --git a/backend/src/main/java/com/quickmenu/auth/security/JwtTokenProvider.java b/backend/src/main/java/com/quickmenu/auth/security/JwtTokenProvider.java
index 02c3ae6..855dc09 100644
--- a/backend/src/main/java/com/quickmenu/auth/security/JwtTokenProvider.java
+++ b/backend/src/main/java/com/quickmenu/auth/security/JwtTokenProvider.java
@@ -51,4 +51,8 @@ public boolean validateToken(String token) {
public Jws parseClaims(String token) {
return Jwts.parser().verifyWith(key).build().parseSignedClaims(token);
}
+
+ public Date getExpiration(String token) {
+ return parseClaims(token).getPayload().getExpiration();
+ }
}
diff --git a/backend/src/main/java/com/quickmenu/bell/service/BellService.java b/backend/src/main/java/com/quickmenu/bell/service/BellService.java
index 2e8490a..0d788c1 100644
--- a/backend/src/main/java/com/quickmenu/bell/service/BellService.java
+++ b/backend/src/main/java/com/quickmenu/bell/service/BellService.java
@@ -3,122 +3,125 @@
import com.quickmenu.bell.model.BellEvent;
import com.quickmenu.bell.model.BellEvent.Status;
import com.quickmenu.bell.repo.BellEventRepository;
+import com.quickmenu.config.RabbitMQProducerConfig;
import com.quickmenu.menu.service.TableService;
+import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Value;
-import org.springframework.messaging.simp.SimpMessagingTemplate;
+import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
+import java.time.Duration;
import java.time.Instant;
+import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
-import java.util.concurrent.ConcurrentHashMap;
+/**
+ * BellService — refactored to use Redis + RabbitMQ.
+ *
+ * TWO problems solved vs. the original:
+ *
+ * 1. Cooldown was in-memory (ConcurrentHashMap):
+ * - Lost on restart
+ * - Each backend instance had its own separate map — two instances = bypass the rate limit
+ * - Fixed: Redis TTL key "bell:cooldown:{restaurantId}:{tableId}" shared across all instances
+ *
+ * 2. Notification was via SimpMessagingTemplate (direct WebSocket):
+ * - Tight coupling — BellService had to know about WebSocket
+ * - Fixed: Publish a BellEvent to RabbitMQ "bell.ring" routing key
+ * notification-service consumes it and pushes the STOMP frame
+ */
@Service
public class BellService {
+ // Redis key pattern: bell:cooldown:{restaurantId}:{tableId}
+ private static final String COOLDOWN_KEY = "bell:cooldown:%s:%s";
+ // Tracks how many times the bell was rung in the current window
+ private static final String STREAK_KEY = "bell:streak:%s:%s";
+
+ // RabbitMQ routing key — notification-service binds "bell.ring" to its queue
+ public static final String BELL_RING_ROUTING_KEY = "bell.ring";
+
private final BellEventRepository bellRepo;
- private final SimpMessagingTemplate messagingTemplate;
+ private final RabbitTemplate rabbitTemplate;
+ private final StringRedisTemplate redisTemplate;
private final TableService tableService;
- // In-memory record for exponential backoff; key = restaurantId:tableId
- private final Map usageRecords = new ConcurrentHashMap<>();
-
private final long initialCooldown;
private final long maxCooldown;
- private final long resetWindowMinutes;
public BellService(BellEventRepository bellRepo,
- SimpMessagingTemplate messagingTemplate,
+ RabbitTemplate rabbitTemplate,
+ StringRedisTemplate redisTemplate,
TableService tableService,
@Value("${app.bell.cooldown-seconds:20}") long initialCooldown,
- @Value("${app.bell.max-cooldown-seconds:600}") long maxCooldown,
- @Value("${app.bell.reset-window-minutes:5}") long resetWindowMinutes) {
+ @Value("${app.bell.max-cooldown-seconds:600}") long maxCooldown) {
this.bellRepo = bellRepo;
- this.messagingTemplate = messagingTemplate;
+ this.rabbitTemplate = rabbitTemplate;
+ this.redisTemplate = redisTemplate;
this.tableService = tableService;
this.initialCooldown = initialCooldown;
this.maxCooldown = maxCooldown;
- this.resetWindowMinutes = resetWindowMinutes;
}
/**
- * Create a bell event if not rate-limited by exponential backoff.
+ * Ring the bell for a table.
+ *
+ * Rate limiting logic (now in Redis):
+ * - "bell:cooldown:{restaurantId}:{tableId}" key exists → still in cooldown → reject
+ * - Key absent → allow, save bell, publish to RabbitMQ, set cooldown TTL
+ * - "bell:streak:{restaurantId}:{tableId}" tracks consecutive rings → exponential backoff TTL
*/
public BellEvent createBell(String restaurantId, String tableId, String message, String source) {
tableService.getTable(restaurantId, tableId);
- String key = restaurantId + ":" + tableId;
- Instant now = Instant.now();
-
- // Atomic update of usage record
- BellUsage usage = usageRecords.compute(key, (k, v) -> {
- if (v == null || now.isAfter(v.lastRingAt.plus(java.time.Duration.ofMinutes(resetWindowMinutes)))) {
- return new BellUsage(now, 0); // Reset after inactivity or new entry
- }
- return v;
- });
-
- long currentCooldown = calculateCooldown(usage.count);
- if (usage.count > 0 && now.isBefore(usage.lastRingAt.plusSeconds(currentCooldown))) {
- long waitTime = (usage.lastRingAt.plusSeconds(currentCooldown).getEpochSecond()) - now.getEpochSecond();
- throw new IllegalStateException("Please wait " + waitTime + " seconds before ringing again.");
+ String cooldownKey = String.format(COOLDOWN_KEY, restaurantId, tableId);
+ String streakKey = String.format(STREAK_KEY, restaurantId, tableId);
+
+ // Check cooldown: if key exists in Redis, the table is still in cooldown
+ Boolean inCooldown = redisTemplate.hasKey(cooldownKey);
+ if (Boolean.TRUE.equals(inCooldown)) {
+ Long ttl = redisTemplate.getExpire(cooldownKey);
+ long waitSeconds = ttl != null ? ttl : initialCooldown;
+ throw new IllegalStateException("Please wait " + waitSeconds + " seconds before ringing again.");
}
- // persist
+ // Persist the bell event to DB
BellEvent event = BellEvent.builder()
.restaurantId(restaurantId)
.tableId(tableId)
.message(message)
.source(source == null ? "QR" : source)
.status(Status.PENDING)
- .createdAt(now)
+ .createdAt(Instant.now())
.delivered(false)
.attempts(0)
.build();
-
BellEvent saved = bellRepo.save(event);
- // Update usage after successful persisting
- usage.lastRingAt = now;
- usage.count++;
-
- // publish to WebSocket
- try {
- var payload = Map.of(
- "id", saved.getId(),
- "tableId", saved.getTableId(),
- "tableName", saved.getTableName() != null ? saved.getTableName() : "",
- "message", saved.getMessage(),
- "createdAt", saved.getCreatedAt().toString(),
- "type", "BELL_CREATED"
- );
- messagingTemplate.convertAndSend("/topic/restaurants/" + restaurantId + "/bells", payload);
- saved.setDelivered(true);
- saved.setAttempts(saved.getAttempts() == null ? 1 : saved.getAttempts() + 1);
- bellRepo.save(saved);
- } catch (Exception ex) {
- saved.setAttempts(saved.getAttempts() == null ? 1 : saved.getAttempts() + 1);
- bellRepo.save(saved);
- }
+ // Increment streak counter (how many consecutive rings in this window)
+ // INCR is atomic in Redis — safe under concurrent load
+ Long streak = redisTemplate.opsForValue().increment(streakKey);
+ if (streak == null) streak = 1L;
- return saved;
- }
+ // Calculate exponential backoff cooldown based on streak count
+ // Ring 1 → 20s, Ring 2 → 40s, Ring 3 → 80s … capped at maxCooldown
+ long cooldownSeconds = Math.min(initialCooldown * (long) Math.pow(2, streak - 1), maxCooldown);
- private long calculateCooldown(int count) {
- if (count <= 0) return 0;
- // 20, 40, 80, 160...
- long cooldown = initialCooldown * (long) Math.pow(2, count - 1);
- return Math.min(cooldown, maxCooldown);
- }
+ // Set cooldown TTL — when this key expires, the next ring is allowed
+ redisTemplate.expire(streakKey, Duration.ofSeconds(cooldownSeconds * 2));
+ redisTemplate.opsForValue().set(cooldownKey, "1", Duration.ofSeconds(cooldownSeconds));
- private static class BellUsage {
- Instant lastRingAt;
- int count;
+ // Publish to RabbitMQ instead of pushing WebSocket directly.
+ // notification-service consumes "bell.ring" and pushes the STOMP frame.
+ Map bellPayload = buildBellPayload(saved, "BELL_CREATED");
+ rabbitTemplate.convertAndSend(RabbitMQProducerConfig.EXCHANGE, BELL_RING_ROUTING_KEY, bellPayload);
- BellUsage(Instant lastRingAt, int count) {
- this.lastRingAt = lastRingAt;
- this.count = count;
- }
+ saved.setDelivered(true);
+ saved.setAttempts(1);
+ bellRepo.save(saved);
+
+ return saved;
}
public BellEvent ackBell(String restaurantId, String bellId, String ackBy) {
@@ -131,17 +134,30 @@ public BellEvent ackBell(String restaurantId, String bellId, String ackBy) {
e.setAckAt(Instant.now());
BellEvent updated = bellRepo.save(e);
- var payload = Map.of(
- "id", updated.getId(),
- "tableId", updated.getTableId(),
- "tableName", updated.getTableName() != null ? updated.getTableName() : "",
- "type", "BELL_ACKED",
- "ackBy", updated.getAckBy(),
- "ackAt", updated.getAckAt().toString()
- );
- messagingTemplate.convertAndSend("/topic/restaurants/" + restaurantId + "/bells", payload);
+ // Ack is also published via RabbitMQ so notification-service can push BELL_ACKED
+ Map ackPayload = new HashMap<>();
+ ackPayload.put("eventType", "BELL_ACKED");
+ ackPayload.put("restaurantId", restaurantId);
+ ackPayload.put("id", updated.getId());
+ ackPayload.put("tableId", updated.getTableId());
+ ackPayload.put("tableName", updated.getTableName() != null ? updated.getTableName() : "");
+ ackPayload.put("ackBy", updated.getAckBy());
+ ackPayload.put("ackAt", updated.getAckAt().toString());
+
+ rabbitTemplate.convertAndSend(RabbitMQProducerConfig.EXCHANGE, BELL_RING_ROUTING_KEY, ackPayload);
return updated;
}
-}
+ private Map buildBellPayload(BellEvent saved, String eventType) {
+ Map payload = new HashMap<>();
+ payload.put("eventType", eventType);
+ payload.put("bellId", saved.getId());
+ payload.put("restaurantId", saved.getRestaurantId());
+ payload.put("tableId", saved.getTableId());
+ payload.put("tableName", saved.getTableName() != null ? saved.getTableName() : "");
+ payload.put("message", saved.getMessage() != null ? saved.getMessage() : "");
+ payload.put("createdAt", saved.getCreatedAt().toString());
+ return payload;
+ }
+}
diff --git a/backend/src/main/java/com/quickmenu/config/SecurityConfig.java b/backend/src/main/java/com/quickmenu/config/SecurityConfig.java
index d43ae64..1fa0600 100644
--- a/backend/src/main/java/com/quickmenu/config/SecurityConfig.java
+++ b/backend/src/main/java/com/quickmenu/config/SecurityConfig.java
@@ -28,24 +28,30 @@
import org.springframework.beans.factory.annotation.Value;
+import org.springframework.data.redis.core.StringRedisTemplate;
+
@EnableMethodSecurity
@Configuration
public class SecurityConfig {
private final JwtTokenProvider tokenProvider;
private final CustomUserDetailsService userDetailsService;
+ private final StringRedisTemplate redisTemplate;
@Value("${app.cors.allowed-origins}")
private List allowedOrigins;
- public SecurityConfig(JwtTokenProvider tokenProvider, CustomUserDetailsService userDetailsService) {
+ public SecurityConfig(JwtTokenProvider tokenProvider,
+ CustomUserDetailsService userDetailsService,
+ StringRedisTemplate redisTemplate) {
this.tokenProvider = tokenProvider;
this.userDetailsService = userDetailsService;
+ this.redisTemplate = redisTemplate;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
- JwtAuthenticationFilter jwtFilter = new JwtAuthenticationFilter(tokenProvider, userDetailsService);
+ JwtAuthenticationFilter jwtFilter = new JwtAuthenticationFilter(tokenProvider, userDetailsService, redisTemplate);
http
.httpBasic(AbstractHttpConfigurer::disable)
diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml
index e594933..9f60b40 100644
--- a/backend/src/main/resources/application.yml
+++ b/backend/src/main/resources/application.yml
@@ -7,6 +7,11 @@ spring:
port: 5672
username: guest
password: guest
+
+ data:
+ redis:
+ host: ${REDIS_HOST:localhost}
+ port: 6379
config:
import:
- optional:file:.env[.properties]
diff --git a/docker-compose.yml b/docker-compose.yml
index 8091bab..ce8b72b 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -53,6 +53,8 @@ services:
condition: service_started
rabbitmq:
condition: service_healthy
+ redis:
+ condition: service_healthy
env_file:
- ./backend/.env
environment:
@@ -61,6 +63,7 @@ services:
SPRING_DATASOURCE_PASSWORD: password
SPRING_JPA_HIBERNATE_DDL_AUTO: update
RABBITMQ_HOST: rabbitmq
+ REDIS_HOST: redis
ports:
- "8080:8080"
From de5493d975b7b9b5b157a7eaa0191e4cf0187f7b Mon Sep 17 00:00:00 2001
From: Sohail Ahmad <67598673+sohail24@users.noreply.github.com>
Date: Sun, 30 Aug 2026 17:06:50 +0530
Subject: [PATCH 3/4] feat: implement Spring Cloud Gateway with reactive JWT
authentication and update docker-compose to route traffic through the new
gateway service.
---
.../.mvn/wrapper/maven-wrapper.properties | 3 +
api-gateway/Dockerfile | 4 +
api-gateway/mvnw | 295 ++++++++++++++++++
api-gateway/mvnw.cmd | 189 +++++++++++
api-gateway/pom.xml | 90 ++++++
.../quickmenu/gateway/GatewayApplication.java | 11 +
.../gateway/filter/JwtAuthGatewayFilter.java | 174 +++++++++++
.../src/main/resources/application.yml | 50 +++
docker-compose.yml | 20 +-
9 files changed, 835 insertions(+), 1 deletion(-)
create mode 100644 api-gateway/.mvn/wrapper/maven-wrapper.properties
create mode 100644 api-gateway/Dockerfile
create mode 100644 api-gateway/mvnw
create mode 100644 api-gateway/mvnw.cmd
create mode 100644 api-gateway/pom.xml
create mode 100644 api-gateway/src/main/java/com/quickmenu/gateway/GatewayApplication.java
create mode 100644 api-gateway/src/main/java/com/quickmenu/gateway/filter/JwtAuthGatewayFilter.java
create mode 100644 api-gateway/src/main/resources/application.yml
diff --git a/api-gateway/.mvn/wrapper/maven-wrapper.properties b/api-gateway/.mvn/wrapper/maven-wrapper.properties
new file mode 100644
index 0000000..c0bcafe
--- /dev/null
+++ b/api-gateway/.mvn/wrapper/maven-wrapper.properties
@@ -0,0 +1,3 @@
+wrapperVersion=3.3.4
+distributionType=only-script
+distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip
diff --git a/api-gateway/Dockerfile b/api-gateway/Dockerfile
new file mode 100644
index 0000000..2816413
--- /dev/null
+++ b/api-gateway/Dockerfile
@@ -0,0 +1,4 @@
+FROM eclipse-temurin:17-jre-alpine
+WORKDIR /app
+COPY target/*.jar app.jar
+ENTRYPOINT ["java", "-jar", "app.jar"]
diff --git a/api-gateway/mvnw b/api-gateway/mvnw
new file mode 100644
index 0000000..bd8896b
--- /dev/null
+++ b/api-gateway/mvnw
@@ -0,0 +1,295 @@
+#!/bin/sh
+# ----------------------------------------------------------------------------
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+# ----------------------------------------------------------------------------
+
+# ----------------------------------------------------------------------------
+# Apache Maven Wrapper startup batch script, version 3.3.4
+#
+# Optional ENV vars
+# -----------------
+# JAVA_HOME - location of a JDK home dir, required when download maven via java source
+# MVNW_REPOURL - repo url base for downloading maven distribution
+# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
+# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
+# ----------------------------------------------------------------------------
+
+set -euf
+[ "${MVNW_VERBOSE-}" != debug ] || set -x
+
+# OS specific support.
+native_path() { printf %s\\n "$1"; }
+case "$(uname)" in
+CYGWIN* | MINGW*)
+ [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
+ native_path() { cygpath --path --windows "$1"; }
+ ;;
+esac
+
+# set JAVACMD and JAVACCMD
+set_java_home() {
+ # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
+ if [ -n "${JAVA_HOME-}" ]; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ]; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ JAVACCMD="$JAVA_HOME/jre/sh/javac"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ JAVACCMD="$JAVA_HOME/bin/javac"
+
+ if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
+ echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
+ echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
+ return 1
+ fi
+ fi
+ else
+ JAVACMD="$(
+ 'set' +e
+ 'unset' -f command 2>/dev/null
+ 'command' -v java
+ )" || :
+ JAVACCMD="$(
+ 'set' +e
+ 'unset' -f command 2>/dev/null
+ 'command' -v javac
+ )" || :
+
+ if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
+ echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
+ return 1
+ fi
+ fi
+}
+
+# hash string like Java String::hashCode
+hash_string() {
+ str="${1:-}" h=0
+ while [ -n "$str" ]; do
+ char="${str%"${str#?}"}"
+ h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
+ str="${str#?}"
+ done
+ printf %x\\n $h
+}
+
+verbose() { :; }
+[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
+
+die() {
+ printf %s\\n "$1" >&2
+ exit 1
+}
+
+trim() {
+ # MWRAPPER-139:
+ # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
+ # Needed for removing poorly interpreted newline sequences when running in more
+ # exotic environments such as mingw bash on Windows.
+ printf "%s" "${1}" | tr -d '[:space:]'
+}
+
+scriptDir="$(dirname "$0")"
+scriptName="$(basename "$0")"
+
+# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
+while IFS="=" read -r key value; do
+ case "${key-}" in
+ distributionUrl) distributionUrl=$(trim "${value-}") ;;
+ distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
+ esac
+done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
+[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
+
+case "${distributionUrl##*/}" in
+maven-mvnd-*bin.*)
+ MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
+ case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
+ *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
+ :Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
+ :Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
+ :Linux*x86_64*) distributionPlatform=linux-amd64 ;;
+ *)
+ echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
+ distributionPlatform=linux-amd64
+ ;;
+ esac
+ distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
+ ;;
+maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
+*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
+esac
+
+# apply MVNW_REPOURL and calculate MAVEN_HOME
+# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/
+[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
+distributionUrlName="${distributionUrl##*/}"
+distributionUrlNameMain="${distributionUrlName%.*}"
+distributionUrlNameMain="${distributionUrlNameMain%-bin}"
+MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
+MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
+
+exec_maven() {
+ unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
+ exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
+}
+
+if [ -d "$MAVEN_HOME" ]; then
+ verbose "found existing MAVEN_HOME at $MAVEN_HOME"
+ exec_maven "$@"
+fi
+
+case "${distributionUrl-}" in
+*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
+*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
+esac
+
+# prepare tmp dir
+if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
+ clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
+ trap clean HUP INT TERM EXIT
+else
+ die "cannot create temp dir"
+fi
+
+mkdir -p -- "${MAVEN_HOME%/*}"
+
+# Download and Install Apache Maven
+verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
+verbose "Downloading from: $distributionUrl"
+verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
+
+# select .zip or .tar.gz
+if ! command -v unzip >/dev/null; then
+ distributionUrl="${distributionUrl%.zip}.tar.gz"
+ distributionUrlName="${distributionUrl##*/}"
+fi
+
+# verbose opt
+__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
+[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
+
+# normalize http auth
+case "${MVNW_PASSWORD:+has-password}" in
+'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
+has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
+esac
+
+if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
+ verbose "Found wget ... using wget"
+ wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
+elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
+ verbose "Found curl ... using curl"
+ curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
+elif set_java_home; then
+ verbose "Falling back to use Java to download"
+ javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
+ targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
+ cat >"$javaSource" <<-END
+ public class Downloader extends java.net.Authenticator
+ {
+ protected java.net.PasswordAuthentication getPasswordAuthentication()
+ {
+ return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
+ }
+ public static void main( String[] args ) throws Exception
+ {
+ setDefault( new Downloader() );
+ java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
+ }
+ }
+ END
+ # For Cygwin/MinGW, switch paths to Windows format before running javac and java
+ verbose " - Compiling Downloader.java ..."
+ "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
+ verbose " - Running Downloader.java ..."
+ "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
+fi
+
+# If specified, validate the SHA-256 sum of the Maven distribution zip file
+if [ -n "${distributionSha256Sum-}" ]; then
+ distributionSha256Result=false
+ if [ "$MVN_CMD" = mvnd.sh ]; then
+ echo "Checksum validation is not supported for maven-mvnd." >&2
+ echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
+ exit 1
+ elif command -v sha256sum >/dev/null; then
+ if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
+ distributionSha256Result=true
+ fi
+ elif command -v shasum >/dev/null; then
+ if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
+ distributionSha256Result=true
+ fi
+ else
+ echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
+ echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
+ exit 1
+ fi
+ if [ $distributionSha256Result = false ]; then
+ echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
+ echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
+ exit 1
+ fi
+fi
+
+# unzip and move
+if command -v unzip >/dev/null; then
+ unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
+else
+ tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
+fi
+
+# Find the actual extracted directory name (handles snapshots where filename != directory name)
+actualDistributionDir=""
+
+# First try the expected directory name (for regular distributions)
+if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
+ if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
+ actualDistributionDir="$distributionUrlNameMain"
+ fi
+fi
+
+# If not found, search for any directory with the Maven executable (for snapshots)
+if [ -z "$actualDistributionDir" ]; then
+ # enable globbing to iterate over items
+ set +f
+ for dir in "$TMP_DOWNLOAD_DIR"/*; do
+ if [ -d "$dir" ]; then
+ if [ -f "$dir/bin/$MVN_CMD" ]; then
+ actualDistributionDir="$(basename "$dir")"
+ break
+ fi
+ fi
+ done
+ set -f
+fi
+
+if [ -z "$actualDistributionDir" ]; then
+ verbose "Contents of $TMP_DOWNLOAD_DIR:"
+ verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
+ die "Could not find Maven distribution directory in extracted archive"
+fi
+
+verbose "Found extracted Maven distribution directory: $actualDistributionDir"
+printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
+mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
+
+clean || :
+exec_maven "$@"
diff --git a/api-gateway/mvnw.cmd b/api-gateway/mvnw.cmd
new file mode 100644
index 0000000..92450f9
--- /dev/null
+++ b/api-gateway/mvnw.cmd
@@ -0,0 +1,189 @@
+<# : batch portion
+@REM ----------------------------------------------------------------------------
+@REM Licensed to the Apache Software Foundation (ASF) under one
+@REM or more contributor license agreements. See the NOTICE file
+@REM distributed with this work for additional information
+@REM regarding copyright ownership. The ASF licenses this file
+@REM to you under the Apache License, Version 2.0 (the
+@REM "License"); you may not use this file except in compliance
+@REM with the License. You may obtain a copy of the License at
+@REM
+@REM http://www.apache.org/licenses/LICENSE-2.0
+@REM
+@REM Unless required by applicable law or agreed to in writing,
+@REM software distributed under the License is distributed on an
+@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+@REM KIND, either express or implied. See the License for the
+@REM specific language governing permissions and limitations
+@REM under the License.
+@REM ----------------------------------------------------------------------------
+
+@REM ----------------------------------------------------------------------------
+@REM Apache Maven Wrapper startup batch script, version 3.3.4
+@REM
+@REM Optional ENV vars
+@REM MVNW_REPOURL - repo url base for downloading maven distribution
+@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
+@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
+@REM ----------------------------------------------------------------------------
+
+@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
+@SET __MVNW_CMD__=
+@SET __MVNW_ERROR__=
+@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
+@SET PSModulePath=
+@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
+ IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
+)
+@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
+@SET __MVNW_PSMODULEP_SAVE=
+@SET __MVNW_ARG0_NAME__=
+@SET MVNW_USERNAME=
+@SET MVNW_PASSWORD=
+@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
+@echo Cannot start maven from wrapper >&2 && exit /b 1
+@GOTO :EOF
+: end batch / begin powershell #>
+
+$ErrorActionPreference = "Stop"
+if ($env:MVNW_VERBOSE -eq "true") {
+ $VerbosePreference = "Continue"
+}
+
+# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
+$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
+if (!$distributionUrl) {
+ Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
+}
+
+switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
+ "maven-mvnd-*" {
+ $USE_MVND = $true
+ $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
+ $MVN_CMD = "mvnd.cmd"
+ break
+ }
+ default {
+ $USE_MVND = $false
+ $MVN_CMD = $script -replace '^mvnw','mvn'
+ break
+ }
+}
+
+# apply MVNW_REPOURL and calculate MAVEN_HOME
+# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/
+if ($env:MVNW_REPOURL) {
+ $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
+ $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
+}
+$distributionUrlName = $distributionUrl -replace '^.*/',''
+$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
+
+$MAVEN_M2_PATH = "$HOME/.m2"
+if ($env:MAVEN_USER_HOME) {
+ $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
+}
+
+if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
+ New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
+}
+
+$MAVEN_WRAPPER_DISTS = $null
+if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
+ $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
+} else {
+ $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
+}
+
+$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
+$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
+$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
+
+if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
+ Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
+ Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
+ exit $?
+}
+
+if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
+ Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
+}
+
+# prepare tmp dir
+$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
+$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
+$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
+trap {
+ if ($TMP_DOWNLOAD_DIR.Exists) {
+ try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
+ catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
+ }
+}
+
+New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
+
+# Download and Install Apache Maven
+Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
+Write-Verbose "Downloading from: $distributionUrl"
+Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
+
+$webclient = New-Object System.Net.WebClient
+if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
+ $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
+}
+[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
+$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
+
+# If specified, validate the SHA-256 sum of the Maven distribution zip file
+$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
+if ($distributionSha256Sum) {
+ if ($USE_MVND) {
+ Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
+ }
+ Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
+ if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
+ Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
+ }
+}
+
+# unzip and move
+Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
+
+# Find the actual extracted directory name (handles snapshots where filename != directory name)
+$actualDistributionDir = ""
+
+# First try the expected directory name (for regular distributions)
+$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
+$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
+if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
+ $actualDistributionDir = $distributionUrlNameMain
+}
+
+# If not found, search for any directory with the Maven executable (for snapshots)
+if (!$actualDistributionDir) {
+ Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
+ $testPath = Join-Path $_.FullName "bin/$MVN_CMD"
+ if (Test-Path -Path $testPath -PathType Leaf) {
+ $actualDistributionDir = $_.Name
+ }
+ }
+}
+
+if (!$actualDistributionDir) {
+ Write-Error "Could not find Maven distribution directory in extracted archive"
+}
+
+Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
+Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
+try {
+ Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
+} catch {
+ if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
+ Write-Error "fail to move MAVEN_HOME"
+ }
+} finally {
+ try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
+ catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
+}
+
+Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
diff --git a/api-gateway/pom.xml b/api-gateway/pom.xml
new file mode 100644
index 0000000..75137d3
--- /dev/null
+++ b/api-gateway/pom.xml
@@ -0,0 +1,90 @@
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.3.5
+
+
+
+ com.quickmenu
+ api-gateway
+ 0.0.1-SNAPSHOT
+ api-gateway
+ QuickMenu API Gateway — Spring Cloud Gateway with JWT validation and Redis blacklist
+
+
+ 17
+ UTF-8
+ 2023.0.3
+
+
+
+
+
+
+ org.springframework.cloud
+ spring-cloud-dependencies
+ ${spring-cloud.version}
+ pom
+ import
+
+
+
+
+
+
+
+ org.springframework.cloud
+ spring-cloud-starter-gateway
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-data-redis-reactive
+
+
+
+
+ io.jsonwebtoken
+ jjwt-api
+ 0.12.5
+
+
+ io.jsonwebtoken
+ jjwt-impl
+ 0.12.5
+ runtime
+
+
+ io.jsonwebtoken
+ jjwt-jackson
+ 0.12.5
+ runtime
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+
diff --git a/api-gateway/src/main/java/com/quickmenu/gateway/GatewayApplication.java b/api-gateway/src/main/java/com/quickmenu/gateway/GatewayApplication.java
new file mode 100644
index 0000000..7202741
--- /dev/null
+++ b/api-gateway/src/main/java/com/quickmenu/gateway/GatewayApplication.java
@@ -0,0 +1,11 @@
+package com.quickmenu.gateway;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class GatewayApplication {
+ public static void main(String[] args) {
+ SpringApplication.run(GatewayApplication.class, args);
+ }
+}
diff --git a/api-gateway/src/main/java/com/quickmenu/gateway/filter/JwtAuthGatewayFilter.java b/api-gateway/src/main/java/com/quickmenu/gateway/filter/JwtAuthGatewayFilter.java
new file mode 100644
index 0000000..25a0e86
--- /dev/null
+++ b/api-gateway/src/main/java/com/quickmenu/gateway/filter/JwtAuthGatewayFilter.java
@@ -0,0 +1,174 @@
+package com.quickmenu.gateway.filter;
+
+import io.jsonwebtoken.JwtException;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.security.Keys;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.cloud.gateway.filter.GatewayFilterChain;
+import org.springframework.cloud.gateway.filter.GlobalFilter;
+import org.springframework.core.Ordered;
+import org.springframework.data.redis.core.ReactiveStringRedisTemplate;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.server.reactive.ServerHttpRequest;
+import org.springframework.stereotype.Component;
+import org.springframework.util.AntPathMatcher;
+import org.springframework.web.server.ServerWebExchange;
+import reactor.core.publisher.Mono;
+
+import javax.crypto.SecretKey;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+
+/**
+ * Global reactive JWT filter for Spring Cloud Gateway.
+ *
+ * Runs BEFORE every request is forwarded to a downstream service.
+ * Two checks per protected request:
+ * 1. Redis blacklist lookup — O(1), rejects logged-out tokens immediately
+ * 2. JWT signature validation — ensures token was issued by our backend
+ *
+ * Why this is better than checking in each microservice:
+ * "Cross-cutting concerns like auth belong at the gateway edge.
+ * Downstream services (backend, notification, bell) never see
+ * invalid or revoked tokens — they can trust incoming requests."
+ */
+@Component
+public class JwtAuthGatewayFilter implements GlobalFilter, Ordered {
+
+ private static final Logger log = LoggerFactory.getLogger(JwtAuthGatewayFilter.class);
+
+ private final ReactiveStringRedisTemplate redisTemplate;
+ private final SecretKey signingKey;
+
+ // Paths that do NOT require a JWT token
+ private static final List PUBLIC_PATHS = List.of(
+ "/api/auth/login",
+ "/api/auth/signup",
+ "/api/auth/logout",
+ "/api/auth/forgot-password",
+ "/api/auth/reset-password",
+ "/api/demo/**",
+ "/actuator/**",
+ "/ws/**",
+ "/websocket/**"
+ );
+
+ // Paths that are public for specific HTTP methods only
+ // (handled via AntPathMatcher against the full path)
+ private static final List PUBLIC_GET_PATTERNS = List.of(
+ "/api/*/menu",
+ "/api/*/menu/**",
+ "/api/orders/**",
+ "/api/restaurants/**"
+ );
+
+ private static final List PUBLIC_POST_PATTERNS = List.of(
+ "/api/*/orders",
+ "/api/*/orders/*/verify",
+ "/api/*/orders/*/items",
+ "/api/*/orders/*/complete",
+ "/api/restaurants/*/tables/*/bell"
+ );
+
+ private static final List PUBLIC_DELETE_PATTERNS = List.of(
+ "/api/*/orders/*/cancel"
+ );
+
+ private final AntPathMatcher pathMatcher = new AntPathMatcher();
+
+ public JwtAuthGatewayFilter(
+ ReactiveStringRedisTemplate redisTemplate,
+ @Value("${jwt.secret}") String jwtSecret) {
+ this.redisTemplate = redisTemplate;
+ this.signingKey = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8));
+ }
+
+ @Override
+ public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) {
+ ServerHttpRequest request = exchange.getRequest();
+ String path = request.getPath().value();
+ String method = request.getMethod().name();
+
+ // Always allow public paths regardless of method
+ if (isPublicPath(path, method)) {
+ return chain.filter(exchange);
+ }
+
+ // Extract Bearer token
+ String authHeader = request.getHeaders().getFirst("Authorization");
+ if (authHeader == null || !authHeader.startsWith("Bearer ")) {
+ log.warn("[GATEWAY] Missing or malformed Authorization header for: {} {}", method, path);
+ return reject(exchange, HttpStatus.UNAUTHORIZED, "Missing authorization token");
+ }
+
+ String token = authHeader.substring(7);
+
+ // Step 1: Check Redis blacklist REACTIVELY (non-blocking)
+ String blacklistKey = "jwt:blacklist:" + token;
+ return redisTemplate.hasKey(blacklistKey)
+ .flatMap(isBlacklisted -> {
+ if (Boolean.TRUE.equals(isBlacklisted)) {
+ log.warn("[GATEWAY] Blacklisted token attempted on: {} {}", method, path);
+ return reject(exchange, HttpStatus.UNAUTHORIZED, "Token has been revoked");
+ }
+
+ // Step 2: Validate JWT signature locally (no network call needed)
+ if (!validateToken(token)) {
+ log.warn("[GATEWAY] Invalid JWT signature on: {} {}", method, path);
+ return reject(exchange, HttpStatus.UNAUTHORIZED, "Invalid token");
+ }
+
+ log.debug("[GATEWAY] Authenticated request forwarded: {} {}", method, path);
+ return chain.filter(exchange);
+ });
+ }
+
+ private boolean isPublicPath(String path, String method) {
+ // Unconditionally public paths
+ for (String pattern : PUBLIC_PATHS) {
+ if (pathMatcher.match(pattern, path)) return true;
+ }
+ // Method-specific public paths
+ if ("GET".equalsIgnoreCase(method)) {
+ for (String pattern : PUBLIC_GET_PATTERNS) {
+ if (pathMatcher.match(pattern, path)) return true;
+ }
+ }
+ if ("POST".equalsIgnoreCase(method)) {
+ for (String pattern : PUBLIC_POST_PATTERNS) {
+ if (pathMatcher.match(pattern, path)) return true;
+ }
+ }
+ if ("DELETE".equalsIgnoreCase(method)) {
+ for (String pattern : PUBLIC_DELETE_PATTERNS) {
+ if (pathMatcher.match(pattern, path)) return true;
+ }
+ }
+ return false;
+ }
+
+ private boolean validateToken(String token) {
+ try {
+ Jwts.parser().verifyWith(signingKey).build().parseSignedClaims(token);
+ return true;
+ } catch (JwtException | IllegalArgumentException e) {
+ return false;
+ }
+ }
+
+ private Mono reject(ServerWebExchange exchange, HttpStatus status, String message) {
+ exchange.getResponse().setStatusCode(status);
+ exchange.getResponse().getHeaders().add("Content-Type", "application/json");
+ var body = exchange.getResponse().bufferFactory()
+ .wrap(("{\"status\":" + status.value() + ",\"error\":\"" + message + "\"}").getBytes());
+ return exchange.getResponse().writeWith(Mono.just(body));
+ }
+
+ @Override
+ public int getOrder() {
+ // Run before all other filters (lower number = higher priority)
+ return -1;
+ }
+}
diff --git a/api-gateway/src/main/resources/application.yml b/api-gateway/src/main/resources/application.yml
new file mode 100644
index 0000000..8dc19c9
--- /dev/null
+++ b/api-gateway/src/main/resources/application.yml
@@ -0,0 +1,50 @@
+spring:
+ application:
+ name: api-gateway
+
+ data:
+ redis:
+ host: ${REDIS_HOST:localhost}
+ port: 6379
+
+ cloud:
+ gateway:
+ routes:
+
+ # ── WebSocket / STOMP routes → notification-service ──────────────────
+ # MUST be declared first — Gateway matches routes top-to-bottom.
+ # If /api/** came first, /ws/** would never match.
+ - id: websocket-routes
+ uri: http://${NOTIFICATION_HOST:localhost}:8084
+ predicates:
+ - Path=/ws/**, /websocket/**
+
+ # ── All API + actuator routes → backend ──────────────────────────────
+ # In Day 2 Step 2, we will add a specific bell-service route
+ # ABOVE this catch-all. For now, backend handles everything.
+ - id: backend-routes
+ uri: http://${BACKEND_HOST:localhost}:8083
+ predicates:
+ - Path=/api/**, /actuator/**
+
+# JWT secret — MUST match the secret in backend/application.yml
+jwt:
+ secret: ${JWT_SECRET:change-this-to-a-long-secure-secret-at-least-32-chars}
+
+server:
+ port: 8080
+
+# Expose health check for Docker
+management:
+ endpoints:
+ web:
+ exposure:
+ include: health
+ endpoint:
+ health:
+ show-details: always
+
+logging:
+ level:
+ com.quickmenu.gateway: INFO
+ org.springframework.cloud.gateway: WARN
diff --git a/docker-compose.yml b/docker-compose.yml
index ce8b72b..6b0df15 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -64,8 +64,9 @@ services:
SPRING_JPA_HIBERNATE_DDL_AUTO: update
RABBITMQ_HOST: rabbitmq
REDIS_HOST: redis
+ SERVER_PORT: 8083 # moves backend off :8080 so gateway can take it
ports:
- - "8080:8080"
+ - "8083:8083" # still exposed for direct debugging
notification-service:
build:
@@ -80,8 +81,25 @@ services:
ports:
- "8084:8084"
+ api-gateway:
+ build:
+ context: ./api-gateway
+ dockerfile: Dockerfile
+ container_name: quickmenu-gateway
+ depends_on:
+ redis:
+ condition: service_healthy
+ environment:
+ REDIS_HOST: redis
+ BACKEND_HOST: backend
+ NOTIFICATION_HOST: notification-service
+ JWT_SECRET: "change-this-to-a-long-secure-secret-at-least-32-chars"
+ ports:
+ - "8080:8080" # public entry point — all client traffic goes here
+
volumes:
postgres_data:
rabbitmq_data:
redis_data:
+
From 0226e8bd5718bc0d16d72f92d013c1f30db31990 Mon Sep 17 00:00:00 2001
From: Sohail Ahmad <67598673+sohail24@users.noreply.github.com>
Date: Mon, 31 Aug 2026 14:19:13 +0530
Subject: [PATCH 4/4] feat(microservices): migrate to distributed architecture
with API gateway and extracted bell service - Add Spring Cloud Gateway
(api-gateway:8080) for reactive routing, edge JWT validation, and centralized
CORS management. - Implement edge JWT blacklist filter using Reactive Redis
(ReactiveStringRedisTemplate) for instant logout revocation. - Extract Bell
domain from monolith into standalone microservice (bell-service:8085) with
Redis TTL rate-limiting and RabbitMQ integration. - Decouple OrderService and
BellService using RabbitMQ Topic Exchange (quickmenu.events) and DLQ
topology. - Add notification-service:8084 as standalone AMQP consumer pushing
real-time STOMP WebSocket frames to staff dashboard. - Enforce
single-responsibility CORS pattern at Gateway edge and add
CorsHeaderSuppressorFilter to eliminate duplicate Access-Control-Allow-Origin
headers. - Configure dynamic server.port (${SERVER_PORT}) and Java 17 target
bytecode compatibility across all microservices.
---
api-gateway/pom.xml | 8 +
.../gateway/filter/JwtAuthGatewayFilter.java | 5 +-
.../src/main/resources/application.yml | 28 +-
.../com/quickmenu/config/SecurityConfig.java | 2 +-
backend/src/main/resources/application.yml | 2 +-
.../.mvn/wrapper/maven-wrapper.properties | 3 +
bell-service/Dockerfile | 4 +
bell-service/mvnw | 295 ++++++++++++++++++
bell-service/mvnw.cmd | 189 +++++++++++
bell-service/pom.xml | 125 ++++++++
.../bell/BellServiceApplication.java | 11 +
.../quickmenu/bell/config/RabbitMQConfig.java | 36 +++
.../quickmenu/bell/config/SecurityConfig.java | 52 +++
.../bell/controller/BellAdminController.java | 77 +++++
.../bell/controller/BellController.java | 51 +++
.../com/quickmenu/bell/model/BellEvent.java | 63 ++++
.../bell/repo/BellEventRepository.java | 16 +
.../bell/security/JwtAuthFilter.java | 81 +++++
.../quickmenu/bell/service/BellService.java | 139 +++++++++
.../src/main/resources/application.yml | 48 +++
docker-compose.yml | 23 ++
notification-service/pom.xml | 8 +
.../config/CorsHeaderSuppressorFilter.java | 51 +++
.../notification/config/WebSocketConfig.java | 4 +-
.../src/main/resources/application.yml | 2 +-
25 files changed, 1309 insertions(+), 14 deletions(-)
create mode 100644 bell-service/.mvn/wrapper/maven-wrapper.properties
create mode 100644 bell-service/Dockerfile
create mode 100644 bell-service/mvnw
create mode 100644 bell-service/mvnw.cmd
create mode 100644 bell-service/pom.xml
create mode 100644 bell-service/src/main/java/com/quickmenu/bell/BellServiceApplication.java
create mode 100644 bell-service/src/main/java/com/quickmenu/bell/config/RabbitMQConfig.java
create mode 100644 bell-service/src/main/java/com/quickmenu/bell/config/SecurityConfig.java
create mode 100644 bell-service/src/main/java/com/quickmenu/bell/controller/BellAdminController.java
create mode 100644 bell-service/src/main/java/com/quickmenu/bell/controller/BellController.java
create mode 100644 bell-service/src/main/java/com/quickmenu/bell/model/BellEvent.java
create mode 100644 bell-service/src/main/java/com/quickmenu/bell/repo/BellEventRepository.java
create mode 100644 bell-service/src/main/java/com/quickmenu/bell/security/JwtAuthFilter.java
create mode 100644 bell-service/src/main/java/com/quickmenu/bell/service/BellService.java
create mode 100644 bell-service/src/main/resources/application.yml
create mode 100644 notification-service/src/main/java/com/quickmenu/notification/config/CorsHeaderSuppressorFilter.java
diff --git a/api-gateway/pom.xml b/api-gateway/pom.xml
index 75137d3..4dc95d2 100644
--- a/api-gateway/pom.xml
+++ b/api-gateway/pom.xml
@@ -81,6 +81,14 @@
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+ 17
+ 17
+
+
org.springframework.boot
spring-boot-maven-plugin
diff --git a/api-gateway/src/main/java/com/quickmenu/gateway/filter/JwtAuthGatewayFilter.java b/api-gateway/src/main/java/com/quickmenu/gateway/filter/JwtAuthGatewayFilter.java
index 25a0e86..a740949 100644
--- a/api-gateway/src/main/java/com/quickmenu/gateway/filter/JwtAuthGatewayFilter.java
+++ b/api-gateway/src/main/java/com/quickmenu/gateway/filter/JwtAuthGatewayFilter.java
@@ -50,6 +50,7 @@ public class JwtAuthGatewayFilter implements GlobalFilter, Ordered {
"/api/auth/forgot-password",
"/api/auth/reset-password",
"/api/demo/**",
+ "/api/health",
"/actuator/**",
"/ws/**",
"/websocket/**"
@@ -91,8 +92,8 @@ public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String path = request.getPath().value();
String method = request.getMethod().name();
- // Always allow public paths regardless of method
- if (isPublicPath(path, method)) {
+ // Always allow CORS preflight (OPTIONS) and public paths
+ if ("OPTIONS".equalsIgnoreCase(method) || isPublicPath(path, method)) {
return chain.filter(exchange);
}
diff --git a/api-gateway/src/main/resources/application.yml b/api-gateway/src/main/resources/application.yml
index 8dc19c9..4d73101 100644
--- a/api-gateway/src/main/resources/application.yml
+++ b/api-gateway/src/main/resources/application.yml
@@ -9,32 +9,46 @@ spring:
cloud:
gateway:
+ globalcors:
+ add-to-simple-url-handler-mapping: true
+ cors-configurations:
+ '[/**]':
+ allowedOrigins: "http://localhost:5173"
+ allowedMethods:
+ - GET
+ - POST
+ - PUT
+ - PATCH
+ - DELETE
+ - OPTIONS
+ allowedHeaders: "*"
+ allowCredentials: true
routes:
# ── WebSocket / STOMP routes → notification-service ──────────────────
- # MUST be declared first — Gateway matches routes top-to-bottom.
- # If /api/** came first, /ws/** would never match.
- id: websocket-routes
uri: http://${NOTIFICATION_HOST:localhost}:8084
predicates:
- Path=/ws/**, /websocket/**
- # ── All API + actuator routes → backend ──────────────────────────────
- # In Day 2 Step 2, we will add a specific bell-service route
- # ABOVE this catch-all. For now, backend handles everything.
+ # ── Bell routes → bell-service ────────────────────────────────────────
+ - id: bell-service-routes
+ uri: http://${BELL_HOST:localhost}:8085
+ predicates:
+ - Path=/api/restaurants/*/tables/*/bell, /api/*/bells, /api/*/bells/**
+
+ # ── All remaining API routes → backend ────────────────────────────────
- id: backend-routes
uri: http://${BACKEND_HOST:localhost}:8083
predicates:
- Path=/api/**, /actuator/**
-# JWT secret — MUST match the secret in backend/application.yml
jwt:
secret: ${JWT_SECRET:change-this-to-a-long-secure-secret-at-least-32-chars}
server:
port: 8080
-# Expose health check for Docker
management:
endpoints:
web:
diff --git a/backend/src/main/java/com/quickmenu/config/SecurityConfig.java b/backend/src/main/java/com/quickmenu/config/SecurityConfig.java
index 1fa0600..dea674a 100644
--- a/backend/src/main/java/com/quickmenu/config/SecurityConfig.java
+++ b/backend/src/main/java/com/quickmenu/config/SecurityConfig.java
@@ -56,7 +56,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
http
.httpBasic(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
- .cors(Customizer.withDefaults())
+ .cors(AbstractHttpConfigurer::disable)
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.headers(headers -> headers.frameOptions(frame -> frame.sameOrigin()))
diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml
index 9f60b40..cf71a4c 100644
--- a/backend/src/main/resources/application.yml
+++ b/backend/src/main/resources/application.yml
@@ -55,7 +55,7 @@ spring:
max-request-size: 10MB
server:
- port: 8080
+ port: ${SERVER_PORT:8080}
tomcat:
# Accept POST bodies up to ~10MB (bytes)
max-http-post-size: 10485760
diff --git a/bell-service/.mvn/wrapper/maven-wrapper.properties b/bell-service/.mvn/wrapper/maven-wrapper.properties
new file mode 100644
index 0000000..c0bcafe
--- /dev/null
+++ b/bell-service/.mvn/wrapper/maven-wrapper.properties
@@ -0,0 +1,3 @@
+wrapperVersion=3.3.4
+distributionType=only-script
+distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip
diff --git a/bell-service/Dockerfile b/bell-service/Dockerfile
new file mode 100644
index 0000000..2816413
--- /dev/null
+++ b/bell-service/Dockerfile
@@ -0,0 +1,4 @@
+FROM eclipse-temurin:17-jre-alpine
+WORKDIR /app
+COPY target/*.jar app.jar
+ENTRYPOINT ["java", "-jar", "app.jar"]
diff --git a/bell-service/mvnw b/bell-service/mvnw
new file mode 100644
index 0000000..bd8896b
--- /dev/null
+++ b/bell-service/mvnw
@@ -0,0 +1,295 @@
+#!/bin/sh
+# ----------------------------------------------------------------------------
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+# ----------------------------------------------------------------------------
+
+# ----------------------------------------------------------------------------
+# Apache Maven Wrapper startup batch script, version 3.3.4
+#
+# Optional ENV vars
+# -----------------
+# JAVA_HOME - location of a JDK home dir, required when download maven via java source
+# MVNW_REPOURL - repo url base for downloading maven distribution
+# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
+# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
+# ----------------------------------------------------------------------------
+
+set -euf
+[ "${MVNW_VERBOSE-}" != debug ] || set -x
+
+# OS specific support.
+native_path() { printf %s\\n "$1"; }
+case "$(uname)" in
+CYGWIN* | MINGW*)
+ [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
+ native_path() { cygpath --path --windows "$1"; }
+ ;;
+esac
+
+# set JAVACMD and JAVACCMD
+set_java_home() {
+ # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
+ if [ -n "${JAVA_HOME-}" ]; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ]; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ JAVACCMD="$JAVA_HOME/jre/sh/javac"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ JAVACCMD="$JAVA_HOME/bin/javac"
+
+ if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
+ echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
+ echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
+ return 1
+ fi
+ fi
+ else
+ JAVACMD="$(
+ 'set' +e
+ 'unset' -f command 2>/dev/null
+ 'command' -v java
+ )" || :
+ JAVACCMD="$(
+ 'set' +e
+ 'unset' -f command 2>/dev/null
+ 'command' -v javac
+ )" || :
+
+ if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
+ echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
+ return 1
+ fi
+ fi
+}
+
+# hash string like Java String::hashCode
+hash_string() {
+ str="${1:-}" h=0
+ while [ -n "$str" ]; do
+ char="${str%"${str#?}"}"
+ h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
+ str="${str#?}"
+ done
+ printf %x\\n $h
+}
+
+verbose() { :; }
+[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
+
+die() {
+ printf %s\\n "$1" >&2
+ exit 1
+}
+
+trim() {
+ # MWRAPPER-139:
+ # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
+ # Needed for removing poorly interpreted newline sequences when running in more
+ # exotic environments such as mingw bash on Windows.
+ printf "%s" "${1}" | tr -d '[:space:]'
+}
+
+scriptDir="$(dirname "$0")"
+scriptName="$(basename "$0")"
+
+# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
+while IFS="=" read -r key value; do
+ case "${key-}" in
+ distributionUrl) distributionUrl=$(trim "${value-}") ;;
+ distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
+ esac
+done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
+[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
+
+case "${distributionUrl##*/}" in
+maven-mvnd-*bin.*)
+ MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
+ case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
+ *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
+ :Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
+ :Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
+ :Linux*x86_64*) distributionPlatform=linux-amd64 ;;
+ *)
+ echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
+ distributionPlatform=linux-amd64
+ ;;
+ esac
+ distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
+ ;;
+maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
+*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
+esac
+
+# apply MVNW_REPOURL and calculate MAVEN_HOME
+# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/
+[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
+distributionUrlName="${distributionUrl##*/}"
+distributionUrlNameMain="${distributionUrlName%.*}"
+distributionUrlNameMain="${distributionUrlNameMain%-bin}"
+MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
+MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
+
+exec_maven() {
+ unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
+ exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
+}
+
+if [ -d "$MAVEN_HOME" ]; then
+ verbose "found existing MAVEN_HOME at $MAVEN_HOME"
+ exec_maven "$@"
+fi
+
+case "${distributionUrl-}" in
+*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
+*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
+esac
+
+# prepare tmp dir
+if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
+ clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
+ trap clean HUP INT TERM EXIT
+else
+ die "cannot create temp dir"
+fi
+
+mkdir -p -- "${MAVEN_HOME%/*}"
+
+# Download and Install Apache Maven
+verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
+verbose "Downloading from: $distributionUrl"
+verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
+
+# select .zip or .tar.gz
+if ! command -v unzip >/dev/null; then
+ distributionUrl="${distributionUrl%.zip}.tar.gz"
+ distributionUrlName="${distributionUrl##*/}"
+fi
+
+# verbose opt
+__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
+[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
+
+# normalize http auth
+case "${MVNW_PASSWORD:+has-password}" in
+'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
+has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
+esac
+
+if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
+ verbose "Found wget ... using wget"
+ wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
+elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
+ verbose "Found curl ... using curl"
+ curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
+elif set_java_home; then
+ verbose "Falling back to use Java to download"
+ javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
+ targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
+ cat >"$javaSource" <<-END
+ public class Downloader extends java.net.Authenticator
+ {
+ protected java.net.PasswordAuthentication getPasswordAuthentication()
+ {
+ return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
+ }
+ public static void main( String[] args ) throws Exception
+ {
+ setDefault( new Downloader() );
+ java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
+ }
+ }
+ END
+ # For Cygwin/MinGW, switch paths to Windows format before running javac and java
+ verbose " - Compiling Downloader.java ..."
+ "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
+ verbose " - Running Downloader.java ..."
+ "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
+fi
+
+# If specified, validate the SHA-256 sum of the Maven distribution zip file
+if [ -n "${distributionSha256Sum-}" ]; then
+ distributionSha256Result=false
+ if [ "$MVN_CMD" = mvnd.sh ]; then
+ echo "Checksum validation is not supported for maven-mvnd." >&2
+ echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
+ exit 1
+ elif command -v sha256sum >/dev/null; then
+ if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
+ distributionSha256Result=true
+ fi
+ elif command -v shasum >/dev/null; then
+ if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
+ distributionSha256Result=true
+ fi
+ else
+ echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
+ echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
+ exit 1
+ fi
+ if [ $distributionSha256Result = false ]; then
+ echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
+ echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
+ exit 1
+ fi
+fi
+
+# unzip and move
+if command -v unzip >/dev/null; then
+ unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
+else
+ tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
+fi
+
+# Find the actual extracted directory name (handles snapshots where filename != directory name)
+actualDistributionDir=""
+
+# First try the expected directory name (for regular distributions)
+if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
+ if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
+ actualDistributionDir="$distributionUrlNameMain"
+ fi
+fi
+
+# If not found, search for any directory with the Maven executable (for snapshots)
+if [ -z "$actualDistributionDir" ]; then
+ # enable globbing to iterate over items
+ set +f
+ for dir in "$TMP_DOWNLOAD_DIR"/*; do
+ if [ -d "$dir" ]; then
+ if [ -f "$dir/bin/$MVN_CMD" ]; then
+ actualDistributionDir="$(basename "$dir")"
+ break
+ fi
+ fi
+ done
+ set -f
+fi
+
+if [ -z "$actualDistributionDir" ]; then
+ verbose "Contents of $TMP_DOWNLOAD_DIR:"
+ verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
+ die "Could not find Maven distribution directory in extracted archive"
+fi
+
+verbose "Found extracted Maven distribution directory: $actualDistributionDir"
+printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
+mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
+
+clean || :
+exec_maven "$@"
diff --git a/bell-service/mvnw.cmd b/bell-service/mvnw.cmd
new file mode 100644
index 0000000..92450f9
--- /dev/null
+++ b/bell-service/mvnw.cmd
@@ -0,0 +1,189 @@
+<# : batch portion
+@REM ----------------------------------------------------------------------------
+@REM Licensed to the Apache Software Foundation (ASF) under one
+@REM or more contributor license agreements. See the NOTICE file
+@REM distributed with this work for additional information
+@REM regarding copyright ownership. The ASF licenses this file
+@REM to you under the Apache License, Version 2.0 (the
+@REM "License"); you may not use this file except in compliance
+@REM with the License. You may obtain a copy of the License at
+@REM
+@REM http://www.apache.org/licenses/LICENSE-2.0
+@REM
+@REM Unless required by applicable law or agreed to in writing,
+@REM software distributed under the License is distributed on an
+@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+@REM KIND, either express or implied. See the License for the
+@REM specific language governing permissions and limitations
+@REM under the License.
+@REM ----------------------------------------------------------------------------
+
+@REM ----------------------------------------------------------------------------
+@REM Apache Maven Wrapper startup batch script, version 3.3.4
+@REM
+@REM Optional ENV vars
+@REM MVNW_REPOURL - repo url base for downloading maven distribution
+@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
+@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
+@REM ----------------------------------------------------------------------------
+
+@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
+@SET __MVNW_CMD__=
+@SET __MVNW_ERROR__=
+@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
+@SET PSModulePath=
+@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
+ IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
+)
+@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
+@SET __MVNW_PSMODULEP_SAVE=
+@SET __MVNW_ARG0_NAME__=
+@SET MVNW_USERNAME=
+@SET MVNW_PASSWORD=
+@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
+@echo Cannot start maven from wrapper >&2 && exit /b 1
+@GOTO :EOF
+: end batch / begin powershell #>
+
+$ErrorActionPreference = "Stop"
+if ($env:MVNW_VERBOSE -eq "true") {
+ $VerbosePreference = "Continue"
+}
+
+# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
+$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
+if (!$distributionUrl) {
+ Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
+}
+
+switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
+ "maven-mvnd-*" {
+ $USE_MVND = $true
+ $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
+ $MVN_CMD = "mvnd.cmd"
+ break
+ }
+ default {
+ $USE_MVND = $false
+ $MVN_CMD = $script -replace '^mvnw','mvn'
+ break
+ }
+}
+
+# apply MVNW_REPOURL and calculate MAVEN_HOME
+# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/
+if ($env:MVNW_REPOURL) {
+ $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
+ $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
+}
+$distributionUrlName = $distributionUrl -replace '^.*/',''
+$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
+
+$MAVEN_M2_PATH = "$HOME/.m2"
+if ($env:MAVEN_USER_HOME) {
+ $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
+}
+
+if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
+ New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
+}
+
+$MAVEN_WRAPPER_DISTS = $null
+if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
+ $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
+} else {
+ $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
+}
+
+$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
+$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
+$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
+
+if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
+ Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
+ Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
+ exit $?
+}
+
+if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
+ Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
+}
+
+# prepare tmp dir
+$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
+$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
+$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
+trap {
+ if ($TMP_DOWNLOAD_DIR.Exists) {
+ try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
+ catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
+ }
+}
+
+New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
+
+# Download and Install Apache Maven
+Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
+Write-Verbose "Downloading from: $distributionUrl"
+Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
+
+$webclient = New-Object System.Net.WebClient
+if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
+ $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
+}
+[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
+$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
+
+# If specified, validate the SHA-256 sum of the Maven distribution zip file
+$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
+if ($distributionSha256Sum) {
+ if ($USE_MVND) {
+ Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
+ }
+ Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
+ if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
+ Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
+ }
+}
+
+# unzip and move
+Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
+
+# Find the actual extracted directory name (handles snapshots where filename != directory name)
+$actualDistributionDir = ""
+
+# First try the expected directory name (for regular distributions)
+$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
+$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
+if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
+ $actualDistributionDir = $distributionUrlNameMain
+}
+
+# If not found, search for any directory with the Maven executable (for snapshots)
+if (!$actualDistributionDir) {
+ Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
+ $testPath = Join-Path $_.FullName "bin/$MVN_CMD"
+ if (Test-Path -Path $testPath -PathType Leaf) {
+ $actualDistributionDir = $_.Name
+ }
+ }
+}
+
+if (!$actualDistributionDir) {
+ Write-Error "Could not find Maven distribution directory in extracted archive"
+}
+
+Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
+Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
+try {
+ Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
+} catch {
+ if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
+ Write-Error "fail to move MAVEN_HOME"
+ }
+} finally {
+ try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
+ catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
+}
+
+Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
diff --git a/bell-service/pom.xml b/bell-service/pom.xml
new file mode 100644
index 0000000..0eea3df
--- /dev/null
+++ b/bell-service/pom.xml
@@ -0,0 +1,125 @@
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.3.5
+
+
+
+ com.quickmenu
+ bell-service
+ 0.0.1-SNAPSHOT
+ bell-service
+ QuickMenu Bell Service — handles customer bell rings and staff acknowledgements
+
+
+ 17
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-data-jpa
+
+
+
+
+ org.postgresql
+ postgresql
+ runtime
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-security
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-amqp
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-data-redis
+
+
+
+
+ io.jsonwebtoken
+ jjwt-api
+ 0.12.5
+
+
+ io.jsonwebtoken
+ jjwt-impl
+ 0.12.5
+ runtime
+
+
+ io.jsonwebtoken
+ jjwt-jackson
+ 0.12.5
+ runtime
+
+
+
+
+ org.projectlombok
+ lombok
+ true
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+ org.projectlombok
+ lombok
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+ 17
+ 17
+
+
+ org.projectlombok
+ lombok
+
+
+
+
+
+
+
diff --git a/bell-service/src/main/java/com/quickmenu/bell/BellServiceApplication.java b/bell-service/src/main/java/com/quickmenu/bell/BellServiceApplication.java
new file mode 100644
index 0000000..69aaf30
--- /dev/null
+++ b/bell-service/src/main/java/com/quickmenu/bell/BellServiceApplication.java
@@ -0,0 +1,11 @@
+package com.quickmenu.bell;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class BellServiceApplication {
+ public static void main(String[] args) {
+ SpringApplication.run(BellServiceApplication.class, args);
+ }
+}
diff --git a/bell-service/src/main/java/com/quickmenu/bell/config/RabbitMQConfig.java b/bell-service/src/main/java/com/quickmenu/bell/config/RabbitMQConfig.java
new file mode 100644
index 0000000..1207c77
--- /dev/null
+++ b/bell-service/src/main/java/com/quickmenu/bell/config/RabbitMQConfig.java
@@ -0,0 +1,36 @@
+package com.quickmenu.bell.config;
+
+import org.springframework.amqp.core.TopicExchange;
+import org.springframework.amqp.rabbit.connection.ConnectionFactory;
+import org.springframework.amqp.rabbit.core.RabbitTemplate;
+import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * RabbitMQ producer config for bell-service.
+ * Same exchange as the backend — both produce to "quickmenu.events".
+ * Multiple producers on the same exchange is perfectly valid in AMQP.
+ */
+@Configuration
+public class RabbitMQConfig {
+
+ public static final String EXCHANGE = "quickmenu.events";
+
+ @Bean
+ public TopicExchange quickmenuEventsExchange() {
+ return new TopicExchange(EXCHANGE, true, false);
+ }
+
+ @Bean
+ public Jackson2JsonMessageConverter jacksonConverter() {
+ return new Jackson2JsonMessageConverter();
+ }
+
+ @Bean
+ public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) {
+ RabbitTemplate template = new RabbitTemplate(connectionFactory);
+ template.setMessageConverter(jacksonConverter());
+ return template;
+ }
+}
diff --git a/bell-service/src/main/java/com/quickmenu/bell/config/SecurityConfig.java b/bell-service/src/main/java/com/quickmenu/bell/config/SecurityConfig.java
new file mode 100644
index 0000000..3d05320
--- /dev/null
+++ b/bell-service/src/main/java/com/quickmenu/bell/config/SecurityConfig.java
@@ -0,0 +1,52 @@
+package com.quickmenu.bell.config;
+
+import com.quickmenu.bell.security.JwtAuthFilter;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.HttpMethod;
+import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+
+/**
+ * Security configuration for bell-service.
+ *
+ * Simpler than the backend's SecurityConfig because:
+ * - No login/signup endpoints
+ * - The gateway already validated the JWT; bell-service just reads the claims
+ * from the token to establish SecurityContext for @PreAuthorize
+ *
+ * Public: POST bell ring (customers use this via QR code, no token)
+ * Protected: GET /bells, PATCH /bells/{id}/ack (staff only — ADMIN or STAFF role)
+ */
+@Configuration
+@EnableMethodSecurity
+public class SecurityConfig {
+
+ private final JwtAuthFilter jwtAuthFilter;
+
+ public SecurityConfig(JwtAuthFilter jwtAuthFilter) {
+ this.jwtAuthFilter = jwtAuthFilter;
+ }
+
+ @Bean
+ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
+ http
+ .csrf(AbstractHttpConfigurer::disable)
+ .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
+ .authorizeHttpRequests(auth -> auth
+ // Public: customers ring the bell (no auth)
+ .requestMatchers(HttpMethod.POST, "/api/restaurants/*/tables/*/bell").permitAll()
+ // Public: actuator health
+ .requestMatchers("/actuator/**").permitAll()
+ // Everything else requires authentication
+ .anyRequest().authenticated()
+ )
+ .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
+
+ return http.build();
+ }
+}
diff --git a/bell-service/src/main/java/com/quickmenu/bell/controller/BellAdminController.java b/bell-service/src/main/java/com/quickmenu/bell/controller/BellAdminController.java
new file mode 100644
index 0000000..a59fc44
--- /dev/null
+++ b/bell-service/src/main/java/com/quickmenu/bell/controller/BellAdminController.java
@@ -0,0 +1,77 @@
+package com.quickmenu.bell.controller;
+
+import com.quickmenu.bell.model.BellEvent;
+import com.quickmenu.bell.repo.BellEventRepository;
+import com.quickmenu.bell.service.BellService;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Map;
+import java.util.Optional;
+
+@RestController
+@RequestMapping("/api/{restaurantId}/bells")
+public class BellAdminController {
+
+ private final BellEventRepository bellEventRepository;
+ private final BellService bellService;
+
+ public BellAdminController(BellEventRepository bellEventRepository, BellService bellService) {
+ this.bellEventRepository = bellEventRepository;
+ this.bellService = bellService;
+ }
+
+ /**
+ * GET /api/{restaurantId}/bells
+ * Staff/Admin list of bell events, paged and optionally filtered by status.
+ */
+ @GetMapping
+ @PreAuthorize("hasAuthority('ROLE_ADMIN') or hasAuthority('ROLE_STAFF')")
+ public ResponseEntity> list(@PathVariable String restaurantId,
+ @RequestParam(defaultValue = "0") int page,
+ @RequestParam(defaultValue = "20") int size,
+ @RequestParam(required = false) BellEvent.Status status) {
+ Pageable pageable = PageRequest.of(page, size);
+ Page p = (status == null)
+ ? bellEventRepository.findByRestaurantId(restaurantId, pageable)
+ : bellEventRepository.findByRestaurantIdAndStatus(restaurantId, status, pageable);
+ return ResponseEntity.ok(p);
+ }
+
+ /**
+ * PATCH /api/{restaurantId}/bells/{bellId}/ack
+ * Staff acknowledges a bell — marks ACKED, records who acked and when.
+ */
+ @PatchMapping("/{bellId}/ack")
+ @PreAuthorize("hasAuthority('ROLE_ADMIN') or hasAuthority('ROLE_STAFF')")
+ public ResponseEntity> ack(@PathVariable String restaurantId,
+ @PathVariable String bellId) {
+ try {
+ String ackBy = extractPrincipal().orElse("staff");
+ BellEvent updated = bellService.ackBell(restaurantId, bellId, ackBy);
+ return ResponseEntity.ok(Map.of(
+ "id", updated.getId(),
+ "status", updated.getStatus(),
+ "ackBy", updated.getAckBy(),
+ "ackAt", Optional.ofNullable(updated.getAckAt()).map(Object::toString).orElse(null)
+ ));
+ } catch (IllegalArgumentException ex) {
+ return ResponseEntity.notFound().build();
+ } catch (Exception ex) {
+ return ResponseEntity.status(500).body(Map.of("error", "Internal server error"));
+ }
+ }
+
+ private Optional extractPrincipal() {
+ Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+ if (auth == null || !auth.isAuthenticated()) return Optional.empty();
+ String name = auth.getName();
+ return (name != null && !name.isBlank()) ? Optional.of(name) : Optional.empty();
+ }
+}
diff --git a/bell-service/src/main/java/com/quickmenu/bell/controller/BellController.java b/bell-service/src/main/java/com/quickmenu/bell/controller/BellController.java
new file mode 100644
index 0000000..ef8ee1f
--- /dev/null
+++ b/bell-service/src/main/java/com/quickmenu/bell/controller/BellController.java
@@ -0,0 +1,51 @@
+package com.quickmenu.bell.controller;
+
+import com.quickmenu.bell.model.BellEvent;
+import com.quickmenu.bell.service.BellService;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.net.URI;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/restaurants/{restaurantId}/tables")
+public class BellController {
+
+ private final BellService bellService;
+
+ public BellController(BellService bellService) {
+ this.bellService = bellService;
+ }
+
+ /**
+ * POST /api/restaurants/{restaurantId}/tables/{tableId}/bell
+ * Public endpoint — customers ring the bell via QR code. No JWT required.
+ * Rate-limited by Redis TTL cooldown in BellService.
+ */
+ @PostMapping("/{tableId}/bell")
+ public ResponseEntity> ringBell(@PathVariable String restaurantId,
+ @PathVariable String tableId,
+ @RequestBody(required = false) Map payload) {
+ String message = payload == null ? null : payload.get("message");
+ try {
+ BellEvent event = bellService.createBell(restaurantId, tableId, message, "QR");
+ return ResponseEntity
+ .created(URI.create("/api/restaurants/" + restaurantId + "/bells/" + event.getId()))
+ .body(Map.of(
+ "id", event.getId(),
+ "timestamp", event.getCreatedAt().toString(),
+ "success", true
+ ));
+ } catch (IllegalStateException ex) {
+ return ResponseEntity.status(429).body(Map.of(
+ "error", "Too many requests",
+ "message", ex.getMessage()
+ ));
+ } catch (IllegalArgumentException ex) {
+ return ResponseEntity.badRequest().body(Map.of("error", ex.getMessage()));
+ } catch (Exception ex) {
+ return ResponseEntity.status(500).body(Map.of("error", "Internal server error"));
+ }
+ }
+}
diff --git a/bell-service/src/main/java/com/quickmenu/bell/model/BellEvent.java b/bell-service/src/main/java/com/quickmenu/bell/model/BellEvent.java
new file mode 100644
index 0000000..915930b
--- /dev/null
+++ b/bell-service/src/main/java/com/quickmenu/bell/model/BellEvent.java
@@ -0,0 +1,63 @@
+package com.quickmenu.bell.model;
+
+import jakarta.persistence.*;
+import lombok.*;
+import org.hibernate.annotations.GenericGenerator;
+
+import java.time.Instant;
+
+@Entity
+@Table(name = "bell_events", indexes = {
+ @Index(name = "idx_bell_rest_status_created", columnList = "restaurant_id, status, created_at")
+})
+@Getter
+@Setter
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class BellEvent {
+
+ public enum Status {
+ PENDING, ACKED, TIMEOUT
+ }
+
+ @Id
+ @GeneratedValue(generator = "uuid2")
+ @GenericGenerator(name = "uuid2", strategy = "org.hibernate.id.UUIDGenerator")
+ @Column(length = 36)
+ private String id;
+
+ @Column(name = "restaurant_id", nullable = false)
+ private String restaurantId;
+
+ @Column(name = "table_id", nullable = false)
+ private String tableId;
+
+ @org.hibernate.annotations.Formula("(SELECT t.name FROM restaurant_tables t WHERE t.id = table_id)")
+ private String tableName;
+
+ @Column(length = 2000)
+ private String message;
+
+ @Column(name = "source")
+ private String source; // "QR", "WEB", "APP"
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "status", length = 20)
+ private Status status = Status.PENDING;
+
+ @Column(name = "created_at", nullable = false)
+ private Instant createdAt = Instant.now();
+
+ @Column(name = "ack_by")
+ private String ackBy;
+
+ @Column(name = "ack_at")
+ private Instant ackAt;
+
+ @Column(name = "delivered")
+ private Boolean delivered = false;
+
+ @Column(name = "attempts")
+ private Integer attempts = 0;
+}
diff --git a/bell-service/src/main/java/com/quickmenu/bell/repo/BellEventRepository.java b/bell-service/src/main/java/com/quickmenu/bell/repo/BellEventRepository.java
new file mode 100644
index 0000000..6956989
--- /dev/null
+++ b/bell-service/src/main/java/com/quickmenu/bell/repo/BellEventRepository.java
@@ -0,0 +1,16 @@
+package com.quickmenu.bell.repo;
+
+import com.quickmenu.bell.model.BellEvent;
+import com.quickmenu.bell.model.BellEvent.Status;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.time.Instant;
+import java.util.List;
+
+public interface BellEventRepository extends JpaRepository {
+ Page findByRestaurantId(String restaurantId, Pageable pageable);
+ Page findByRestaurantIdAndStatus(String restaurantId, Status status, Pageable pageable);
+ List findByRestaurantIdAndStatusAndCreatedAtAfter(String restaurantId, Status status, Instant since);
+}
diff --git a/bell-service/src/main/java/com/quickmenu/bell/security/JwtAuthFilter.java b/bell-service/src/main/java/com/quickmenu/bell/security/JwtAuthFilter.java
new file mode 100644
index 0000000..98e45a3
--- /dev/null
+++ b/bell-service/src/main/java/com/quickmenu/bell/security/JwtAuthFilter.java
@@ -0,0 +1,81 @@
+package com.quickmenu.bell.security;
+
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.security.Keys;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import javax.crypto.SecretKey;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+
+/**
+ * JWT filter for bell-service.
+ *
+ * Note: The gateway has ALREADY validated this token and checked the Redis blacklist.
+ * This filter's job is narrower — just parse the JWT claims to populate the
+ * SecurityContext so @PreAuthorize("hasRole('ADMIN')") works in BellAdminController.
+ *
+ * We do NOT check Redis blacklist here because:
+ * 1. The gateway already rejected blacklisted tokens before they reach us
+ * 2. bell-service doesn't need a Redis dependency just for this purpose
+ * (it uses Redis for rate limiting, but that's a separate concern)
+ *
+ * Interview point: "Defense-in-depth would mean checking the blacklist here too.
+ * For this demo, we trust the gateway as the security perimeter."
+ */
+@Component
+public class JwtAuthFilter extends OncePerRequestFilter {
+
+ private final SecretKey signingKey;
+
+ public JwtAuthFilter(@Value("${jwt.secret}") String jwtSecret) {
+ this.signingKey = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8));
+ }
+
+ @Override
+ protected void doFilterInternal(HttpServletRequest request,
+ HttpServletResponse response,
+ FilterChain filterChain) throws ServletException, IOException {
+ String authHeader = request.getHeader("Authorization");
+
+ if (StringUtils.hasText(authHeader) && authHeader.startsWith("Bearer ")) {
+ String token = authHeader.substring(7);
+ try {
+ Claims claims = Jwts.parser()
+ .verifyWith(signingKey)
+ .build()
+ .parseSignedClaims(token)
+ .getPayload();
+
+ String role = claims.get("role", String.class);
+ String subject = claims.getSubject();
+
+ List authorities = role != null
+ ? List.of(new SimpleGrantedAuthority(role))
+ : List.of();
+
+ UsernamePasswordAuthenticationToken auth =
+ new UsernamePasswordAuthenticationToken(subject, null, authorities);
+ SecurityContextHolder.getContext().setAuthentication(auth);
+
+ } catch (Exception e) {
+ // invalid token — let SecurityFilterChain reject the request
+ SecurityContextHolder.clearContext();
+ }
+ }
+
+ filterChain.doFilter(request, response);
+ }
+}
diff --git a/bell-service/src/main/java/com/quickmenu/bell/service/BellService.java b/bell-service/src/main/java/com/quickmenu/bell/service/BellService.java
new file mode 100644
index 0000000..f421467
--- /dev/null
+++ b/bell-service/src/main/java/com/quickmenu/bell/service/BellService.java
@@ -0,0 +1,139 @@
+package com.quickmenu.bell.service;
+
+import com.quickmenu.bell.config.RabbitMQConfig;
+import com.quickmenu.bell.model.BellEvent;
+import com.quickmenu.bell.model.BellEvent.Status;
+import com.quickmenu.bell.repo.BellEventRepository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.amqp.rabbit.core.RabbitTemplate;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.stereotype.Service;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * BellService — extracted from the backend monolith into its own deployable.
+ *
+ * What changed vs the monolith version:
+ * - Package: com.quickmenu.bell.service (was com.quickmenu.bell.service in backend — same!)
+ * - Config import: RabbitMQConfig now lives in bell-service config package
+ * - TableService dependency REMOVED — bell-service validates the table directly via DB query.
+ * In a fully decoupled design, we'd call a REST API exposed by the menu/table service.
+ * For this interview demo, we query the shared DB directly (shared DB pattern).
+ *
+ * Redis keys:
+ * bell:cooldown:{restaurantId}:{tableId} → expires after cooldown seconds
+ * bell:streak:{restaurantId}:{tableId} → count of consecutive rings
+ */
+@Service
+public class BellService {
+
+ private static final Logger log = LoggerFactory.getLogger(BellService.class);
+
+ private static final String COOLDOWN_KEY = "bell:cooldown:%s:%s";
+ private static final String STREAK_KEY = "bell:streak:%s:%s";
+ public static final String BELL_RING_ROUTING_KEY = "bell.ring";
+
+ private final BellEventRepository bellRepo;
+ private final RabbitTemplate rabbitTemplate;
+ private final StringRedisTemplate redisTemplate;
+
+ private final long initialCooldown;
+ private final long maxCooldown;
+
+ public BellService(BellEventRepository bellRepo,
+ RabbitTemplate rabbitTemplate,
+ StringRedisTemplate redisTemplate,
+ @Value("${app.bell.cooldown-seconds:20}") long initialCooldown,
+ @Value("${app.bell.max-cooldown-seconds:600}") long maxCooldown) {
+ this.bellRepo = bellRepo;
+ this.rabbitTemplate = rabbitTemplate;
+ this.redisTemplate = redisTemplate;
+ this.initialCooldown = initialCooldown;
+ this.maxCooldown = maxCooldown;
+ }
+
+ public BellEvent createBell(String restaurantId, String tableId, String message, String source) {
+ String cooldownKey = String.format(COOLDOWN_KEY, restaurantId, tableId);
+ String streakKey = String.format(STREAK_KEY, restaurantId, tableId);
+
+ Boolean inCooldown = redisTemplate.hasKey(cooldownKey);
+ if (Boolean.TRUE.equals(inCooldown)) {
+ Long ttl = redisTemplate.getExpire(cooldownKey);
+ long waitSeconds = ttl != null ? ttl : initialCooldown;
+ throw new IllegalStateException("Please wait " + waitSeconds + " seconds before ringing again.");
+ }
+
+ BellEvent event = BellEvent.builder()
+ .restaurantId(restaurantId)
+ .tableId(tableId)
+ .message(message)
+ .source(source == null ? "QR" : source)
+ .status(Status.PENDING)
+ .createdAt(Instant.now())
+ .delivered(false)
+ .attempts(0)
+ .build();
+ BellEvent saved = bellRepo.save(event);
+
+ // Exponential backoff via streak counter in Redis
+ Long streak = redisTemplate.opsForValue().increment(streakKey);
+ if (streak == null) streak = 1L;
+
+ long cooldownSeconds = Math.min(initialCooldown * (long) Math.pow(2, streak - 1), maxCooldown);
+ redisTemplate.expire(streakKey, Duration.ofSeconds(cooldownSeconds * 2));
+ redisTemplate.opsForValue().set(cooldownKey, "1", Duration.ofSeconds(cooldownSeconds));
+
+ // Publish to RabbitMQ — notification-service will push STOMP frame
+ Map payload = buildPayload(saved, "BELL_CREATED");
+ rabbitTemplate.convertAndSend(RabbitMQConfig.EXCHANGE, BELL_RING_ROUTING_KEY, payload);
+ log.info("[BELL-SERVICE] Published BELL_CREATED event for table {} in restaurant {}", tableId, restaurantId);
+
+ saved.setDelivered(true);
+ saved.setAttempts(1);
+ bellRepo.save(saved);
+ return saved;
+ }
+
+ public BellEvent ackBell(String restaurantId, String bellId, String ackBy) {
+ BellEvent e = bellRepo.findById(bellId)
+ .filter(ev -> Objects.equals(ev.getRestaurantId(), restaurantId))
+ .orElseThrow(() -> new IllegalArgumentException("Bell event not found"));
+
+ e.setStatus(Status.ACKED);
+ e.setAckBy(ackBy);
+ e.setAckAt(Instant.now());
+ BellEvent updated = bellRepo.save(e);
+
+ Map ackPayload = new HashMap<>();
+ ackPayload.put("eventType", "BELL_ACKED");
+ ackPayload.put("restaurantId", restaurantId);
+ ackPayload.put("id", updated.getId());
+ ackPayload.put("tableId", updated.getTableId());
+ ackPayload.put("tableName", updated.getTableName() != null ? updated.getTableName() : "");
+ ackPayload.put("ackBy", updated.getAckBy());
+ ackPayload.put("ackAt", updated.getAckAt().toString());
+
+ rabbitTemplate.convertAndSend(RabbitMQConfig.EXCHANGE, BELL_RING_ROUTING_KEY, ackPayload);
+ log.info("[BELL-SERVICE] Published BELL_ACKED event for bell {} by {}", bellId, ackBy);
+ return updated;
+ }
+
+ private Map buildPayload(BellEvent saved, String eventType) {
+ Map p = new HashMap<>();
+ p.put("eventType", eventType);
+ p.put("bellId", saved.getId());
+ p.put("restaurantId", saved.getRestaurantId());
+ p.put("tableId", saved.getTableId());
+ p.put("tableName", saved.getTableName() != null ? saved.getTableName() : "");
+ p.put("message", saved.getMessage() != null ? saved.getMessage() : "");
+ p.put("createdAt", saved.getCreatedAt().toString());
+ return p;
+ }
+}
diff --git a/bell-service/src/main/resources/application.yml b/bell-service/src/main/resources/application.yml
new file mode 100644
index 0000000..5667156
--- /dev/null
+++ b/bell-service/src/main/resources/application.yml
@@ -0,0 +1,48 @@
+spring:
+ application:
+ name: bell-service
+
+ datasource:
+ url: jdbc:postgresql://${DB_HOST:localhost}:5432/quickmenu
+ username: ${DB_USERNAME:user}
+ password: ${DB_PASSWORD:password}
+ driver-class-name: org.postgresql.Driver
+
+ jpa:
+ hibernate:
+ ddl-auto: validate # IMPORTANT: validate only — never recreate the shared table
+ show-sql: false
+ properties:
+ hibernate:
+ dialect: org.hibernate.dialect.PostgreSQLDialect
+
+ rabbitmq:
+ host: ${RABBITMQ_HOST:localhost}
+ port: 5672
+ username: guest
+ password: guest
+
+ data:
+ redis:
+ host: ${REDIS_HOST:localhost}
+ port: 6379
+
+server:
+ port: ${SERVER_PORT:8085}
+
+jwt:
+ secret: ${JWT_SECRET:change-this-to-a-long-secure-secret-at-least-32-chars}
+
+app:
+ bell:
+ cooldown-seconds: 20
+ max-cooldown-seconds: 600
+
+management:
+ endpoints:
+ web:
+ exposure:
+ include: health
+ endpoint:
+ health:
+ show-details: always
diff --git a/docker-compose.yml b/docker-compose.yml
index 6b0df15..0c334f2 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -93,10 +93,33 @@ services:
REDIS_HOST: redis
BACKEND_HOST: backend
NOTIFICATION_HOST: notification-service
+ BELL_HOST: bell-service
JWT_SECRET: "change-this-to-a-long-secure-secret-at-least-32-chars"
ports:
- "8080:8080" # public entry point — all client traffic goes here
+ bell-service:
+ build:
+ context: ./bell-service
+ dockerfile: Dockerfile
+ container_name: quickmenu-bell
+ depends_on:
+ db:
+ condition: service_started
+ rabbitmq:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ environment:
+ DB_HOST: db
+ DB_USERNAME: user
+ DB_PASSWORD: password
+ RABBITMQ_HOST: rabbitmq
+ REDIS_HOST: redis
+ JWT_SECRET: "change-this-to-a-long-secure-secret-at-least-32-chars"
+ ports:
+ - "8085:8085" # exposed for direct debugging
+
volumes:
postgres_data:
diff --git a/notification-service/pom.xml b/notification-service/pom.xml
index 2388272..42da7b9 100644
--- a/notification-service/pom.xml
+++ b/notification-service/pom.xml
@@ -56,6 +56,14 @@
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+ 17
+ 17
+
+
org.springframework.boot
spring-boot-maven-plugin
diff --git a/notification-service/src/main/java/com/quickmenu/notification/config/CorsHeaderSuppressorFilter.java b/notification-service/src/main/java/com/quickmenu/notification/config/CorsHeaderSuppressorFilter.java
new file mode 100644
index 0000000..0614dd5
--- /dev/null
+++ b/notification-service/src/main/java/com/quickmenu/notification/config/CorsHeaderSuppressorFilter.java
@@ -0,0 +1,51 @@
+package com.quickmenu.notification.config;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.servlet.http.HttpServletResponseWrapper;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.IOException;
+
+/**
+ * Strips Access-Control-Allow-Origin headers produced by Spring WebSocket (SockJS).
+ * Since the API Gateway handles CORS at the edge, notification-service must NOT
+ * send its own CORS headers to avoid duplicate header errors in the browser.
+ */
+@Component
+@Order(Ordered.HIGHEST_PRECEDENCE)
+public class CorsHeaderSuppressorFilter extends OncePerRequestFilter {
+
+ @Override
+ protected void doFilterInternal(HttpServletRequest request,
+ HttpServletResponse response,
+ FilterChain filterChain) throws ServletException, IOException {
+
+ HttpServletResponseWrapper wrappedResponse = new HttpServletResponseWrapper(response) {
+ @Override
+ public void setHeader(String name, String value) {
+ if ("Access-Control-Allow-Origin".equalsIgnoreCase(name) ||
+ "Access-Control-Allow-Credentials".equalsIgnoreCase(name)) {
+ return; // Ignore CORS headers emitted by SockJS internal handlers
+ }
+ super.setHeader(name, value);
+ }
+
+ @Override
+ public void addHeader(String name, String value) {
+ if ("Access-Control-Allow-Origin".equalsIgnoreCase(name) ||
+ "Access-Control-Allow-Credentials".equalsIgnoreCase(name)) {
+ return; // Ignore CORS headers emitted by SockJS internal handlers
+ }
+ super.addHeader(name, value);
+ }
+ };
+
+ filterChain.doFilter(request, wrappedResponse);
+ }
+}
diff --git a/notification-service/src/main/java/com/quickmenu/notification/config/WebSocketConfig.java b/notification-service/src/main/java/com/quickmenu/notification/config/WebSocketConfig.java
index 7e3c03b..dfaee4c 100644
--- a/notification-service/src/main/java/com/quickmenu/notification/config/WebSocketConfig.java
+++ b/notification-service/src/main/java/com/quickmenu/notification/config/WebSocketConfig.java
@@ -27,11 +27,11 @@ public void configureMessageBroker(MessageBrokerRegistry registry) {
public void registerStompEndpoints(StompEndpointRegistry registry) {
// Pure WebSocket — for Postman or direct STOMP clients
registry.addEndpoint("/websocket")
- .setAllowedOriginPatterns("*");
+ .setAllowedOrigins("http://localhost:5173");
// SockJS fallback — for browsers
registry.addEndpoint("/ws")
- .setAllowedOriginPatterns("*")
+ .setAllowedOrigins("http://localhost:5173")
.withSockJS();
}
}
diff --git a/notification-service/src/main/resources/application.yml b/notification-service/src/main/resources/application.yml
index b19bb42..b08606a 100644
--- a/notification-service/src/main/resources/application.yml
+++ b/notification-service/src/main/resources/application.yml
@@ -9,7 +9,7 @@ spring:
password: guest
server:
- port: 8084
+ port: ${SERVER_PORT:8084}
# Health check endpoint for Docker
management: