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: