From c9904b18a1bf838ebbacf5aa7c3fb75d5cef89ea Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Wed, 15 Apr 2026 16:10:38 -0600 Subject: [PATCH 01/37] fix stripe key From f68559d02875d14b399c30bd35acafac5e01390a Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Wed, 15 Apr 2026 16:17:46 -0600 Subject: [PATCH 02/37] fix stripe key --- web/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/Dockerfile b/web/Dockerfile index e850494d..4b794483 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -3,7 +3,7 @@ WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . -ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY +ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51TK18lFQ95OLlFb7dNtKXlvhry8IOvHaWJWW7zUNFhicMgyJ2EgAFhiAocxsCyP95IKt7AeQg4cWe5iHF3qoheZyl0034Cd4yij ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY RUN npm run build From feeaed624437f5f84a4ca8d7618a9a4c53446517 Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Wed, 15 Apr 2026 16:21:27 -0600 Subject: [PATCH 03/37] exclude next cache from build --- web/.dockerignore | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 web/.dockerignore diff --git a/web/.dockerignore b/web/.dockerignore new file mode 100644 index 00000000..b90a368f --- /dev/null +++ b/web/.dockerignore @@ -0,0 +1,2 @@ +node_modules +.next From e23f9f9318e03d542553bfddd388be41b31726f4 Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Wed, 15 Apr 2026 16:24:58 -0600 Subject: [PATCH 04/37] hardcode stripe key --- web/Dockerfile | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/web/Dockerfile b/web/Dockerfile index 4b794483..38bd4ff0 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -3,8 +3,7 @@ WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . -ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51TK18lFQ95OLlFb7dNtKXlvhry8IOvHaWJWW7zUNFhicMgyJ2EgAFhiAocxsCyP95IKt7AeQg4cWe5iHF3qoheZyl0034Cd4yij -ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY +ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51TK18lFQ95OLlFb7dNtKXlvhry8IOvHaWJWW7zUNFhicMgyJ2EgAFhiAocxsCyP95IKt7AeQg4cWe5iHF3qoheZyl0034Cd4yij RUN npm run build FROM node:22-alpine From e87bb7bebfa15f42acd22dabbeb44ba503797a63 Mon Sep 17 00:00:00 2001 From: Harkamal Date: Wed, 15 Apr 2026 16:25:14 -0600 Subject: [PATCH 05/37] species service validation (#317) * fix species-service validation * add grooming for hamster, other * expand reptile and other services --- .../backend/service/AppointmentService.java | 28 ------------------- .../db/migration/V7__fix_service_species.sql | 14 ++++++++++ web/app/appointments/page.js | 18 ++++++------ 3 files changed, 24 insertions(+), 36 deletions(-) create mode 100644 backend/src/main/resources/db/migration/V7__fix_service_species.sql diff --git a/backend/src/main/java/com/petshop/backend/service/AppointmentService.java b/backend/src/main/java/com/petshop/backend/service/AppointmentService.java index 999568a8..9c63733a 100644 --- a/backend/src/main/java/com/petshop/backend/service/AppointmentService.java +++ b/backend/src/main/java/com/petshop/backend/service/AppointmentService.java @@ -127,8 +127,6 @@ public class AppointmentService { } } - validateSpeciesServiceCompatibility(pet, service); - validateStoreAccess(store.getStoreId(), authenticatedUser); validatePetServiceCompatibility(pet, service); validateAvailability(employee, service, request.getAppointmentDate(), request.getAppointmentTime(), null); @@ -387,32 +385,6 @@ public class AppointmentService { return true; } - private void validateSpeciesServiceCompatibility(Pet pet, com.petshop.backend.entity.Service service) { - if (pet == null || service == null) return; - String species = pet.getPetSpecies(); - if (species == null) return; - String serviceName = service.getServiceName().toLowerCase(); - - switch (species.toLowerCase()) { - case "bird": - if (!serviceName.contains("wing clipping") && !serviceName.contains("beak and nail")) { - throw new IllegalArgumentException( - "Service '" + service.getServiceName() + "' is not available for birds. " + - "Allowed services: Wing Clipping, Beak and Nail Care."); - } - break; - case "fish": - if (!serviceName.contains("aquarium health")) { - throw new IllegalArgumentException( - "Service '" + service.getServiceName() + "' is not available for fish. " + - "Allowed service: Aquarium Health Check."); - } - break; - default: - break; - } - } - private void validateStoreAccess(Long requestedStoreId, User user) { if (user.getRole() != User.Role.STAFF) { return; diff --git a/backend/src/main/resources/db/migration/V7__fix_service_species.sql b/backend/src/main/resources/db/migration/V7__fix_service_species.sql new file mode 100644 index 00000000..4c22a1b0 --- /dev/null +++ b/backend/src/main/resources/db/migration/V7__fix_service_species.sql @@ -0,0 +1,14 @@ +DELETE FROM service_species WHERE serviceId = 2 AND species = 'Bird'; + +INSERT INTO service_species (serviceId, species) VALUES +(1, 'Guinea Pig'), +(1, 'Hamster'), +(1, 'Other'), +(2, 'Reptile'), +(2, 'Other'), +(3, 'Reptile'), +(3, 'Other'), +(4, 'Reptile'), +(4, 'Other'), +(5, 'Reptile'), +(5, 'Other'); diff --git a/web/app/appointments/page.js b/web/app/appointments/page.js index 58b741f9..876981c6 100644 --- a/web/app/appointments/page.js +++ b/web/app/appointments/page.js @@ -20,20 +20,22 @@ const SPECIES_BREEDS = { Other: ["Other"], }; -// Explicit allowlists for species with restricted service availability. -// Species not listed here may use all services. -const SPECIES_SERVICE_ALLOWLIST = { +const SPECIES_EXCLUSIVE_SERVICES = { Bird: ["wing clipping", "beak and nail"], Fish: ["aquarium health"], }; function getAvailableServices(services, species) { if (!species) return services; - const allowlist = SPECIES_SERVICE_ALLOWLIST[species]; - if (!allowlist) return services; - return services.filter((s) => - allowlist.some((kw) => s.serviceName.toLowerCase().includes(kw)) - ); + return services.filter((s) => { + const name = s.serviceName.toLowerCase(); + for (const [exclusiveSpecies, keywords] of Object.entries(SPECIES_EXCLUSIVE_SERVICES)) { + if (exclusiveSpecies !== species && keywords.some((kw) => name.includes(kw))) { + return false; + } + } + return true; + }); } const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; From f50928fef1d6c762ab255e2bc82da86d0d5625dd Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Wed, 15 Apr 2026 16:25:31 -0600 Subject: [PATCH 06/37] perf: azure deployment optimizations --- .github/workflows/deploy.yml | 62 ++++++++++++++++--- backend/Dockerfile | 2 +- backend/pom.xml | 10 +++ .../petshop/backend/BackendApplication.java | 2 + .../petshop/backend/config/CacheConfig.java | 24 +++++++ .../backend/controller/AuthController.java | 6 +- .../security/JwtAuthenticationFilter.java | 47 +++++++++----- .../security/UserAuthCacheService.java | 29 +++++++++ .../backend/service/ActivityLogService.java | 3 + .../backend/service/PasswordResetService.java | 7 ++- .../petshop/backend/service/UserService.java | 6 +- backend/src/main/resources/application.yml | 8 +++ 12 files changed, 177 insertions(+), 29 deletions(-) create mode 100644 backend/src/main/java/com/petshop/backend/config/CacheConfig.java create mode 100644 backend/src/main/java/com/petshop/backend/security/UserAuthCacheService.java diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 42c41a71..6bcb3472 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -9,21 +9,53 @@ env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: - build-and-deploy: + build-backend: runs-on: ubuntu-latest permissions: contents: read packages: write - id-token: write steps: - name: Checkout uses: actions/checkout@v4.2.2 - - name: Set image names (lowercase) + - name: Set image name (lowercase) run: | OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') echo "BACKEND_IMAGE=ghcr.io/${OWNER}/petshop-backend" >> $GITHUB_ENV + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3.3.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push backend image + uses: docker/build-push-action@v6 + with: + context: ./backend + push: true + tags: ${{ env.BACKEND_IMAGE }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max + + build-frontend: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v4.2.2 + + - name: Set image name (lowercase) + run: | + OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') echo "FRONTEND_IMAGE=ghcr.io/${OWNER}/petshop-web" >> $GITHUB_ENV - name: Log in to GitHub Container Registry @@ -33,12 +65,8 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Build and push backend image - uses: docker/build-push-action@v6 - with: - context: ./backend - push: true - tags: ${{ env.BACKEND_IMAGE }}:latest + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 - name: Build and push frontend image uses: docker/build-push-action@v6 @@ -48,6 +76,22 @@ jobs: tags: ${{ env.FRONTEND_IMAGE }}:latest build-args: | NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${{ secrets.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY }} + cache-from: type=gha + cache-to: type=gha,mode=max + + deploy: + runs-on: ubuntu-latest + needs: [build-backend, build-frontend] + permissions: + contents: read + id-token: write + + steps: + - name: Set image names (lowercase) + run: | + OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') + echo "BACKEND_IMAGE=ghcr.io/${OWNER}/petshop-backend" >> $GITHUB_ENV + echo "FRONTEND_IMAGE=ghcr.io/${OWNER}/petshop-web" >> $GITHUB_ENV - name: Log in to Azure uses: azure/login@v2 diff --git a/backend/Dockerfile b/backend/Dockerfile index ed3552be..e868cdd6 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -10,4 +10,4 @@ FROM eclipse-temurin:25-jre WORKDIR /app COPY --from=build /app/target/*.jar app.jar EXPOSE 8080 -ENTRYPOINT ["java","-jar","app.jar"] +ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-XX:+UseG1GC", "-jar", "app.jar"] diff --git a/backend/pom.xml b/backend/pom.xml index a87811c2..ad292bdb 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -38,6 +38,16 @@ spring-boot-starter-security + + org.springframework.boot + spring-boot-starter-cache + + + + com.github.ben-manes.caffeine + caffeine + + org.springframework.boot spring-boot-starter-validation diff --git a/backend/src/main/java/com/petshop/backend/BackendApplication.java b/backend/src/main/java/com/petshop/backend/BackendApplication.java index f933eb9d..26d18f3c 100644 --- a/backend/src/main/java/com/petshop/backend/BackendApplication.java +++ b/backend/src/main/java/com/petshop/backend/BackendApplication.java @@ -4,10 +4,12 @@ import com.petshop.backend.config.FlywayContextInitializer; import org.springframework.boot.builder.SpringApplicationBuilder; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.web.config.EnableSpringDataWebSupport; +import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableScheduling +@EnableAsync @EnableSpringDataWebSupport(pageSerializationMode = EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO) public class BackendApplication { public static void main(String[] args) { diff --git a/backend/src/main/java/com/petshop/backend/config/CacheConfig.java b/backend/src/main/java/com/petshop/backend/config/CacheConfig.java new file mode 100644 index 00000000..4a8bc6b0 --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/config/CacheConfig.java @@ -0,0 +1,24 @@ +package com.petshop.backend.config; + +import com.github.benmanes.caffeine.cache.Caffeine; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.concurrent.TimeUnit; + +@Configuration +@EnableCaching +public class CacheConfig { + + @Bean + public CacheManager cacheManager() { + CaffeineCacheManager manager = new CaffeineCacheManager("userAuthCache"); + manager.setCaffeine(Caffeine.newBuilder() + .expireAfterWrite(60, TimeUnit.SECONDS) + .maximumSize(1000)); + return manager; + } +} diff --git a/backend/src/main/java/com/petshop/backend/controller/AuthController.java b/backend/src/main/java/com/petshop/backend/controller/AuthController.java index e278e206..1b4ed0bf 100644 --- a/backend/src/main/java/com/petshop/backend/controller/AuthController.java +++ b/backend/src/main/java/com/petshop/backend/controller/AuthController.java @@ -15,6 +15,7 @@ import com.petshop.backend.entity.StoreLocation; import com.petshop.backend.entity.User; import com.petshop.backend.repository.UserRepository; import com.petshop.backend.security.JwtUtil; +import com.petshop.backend.security.UserAuthCacheService; import com.petshop.backend.service.ActivityLogService; import com.petshop.backend.service.AvatarStorageService; import com.petshop.backend.service.EmailService; @@ -58,8 +59,9 @@ public class AuthController { private final ActivityLogService activityLogService; private final PasswordResetService passwordResetService; private final EmailService emailService; + private final UserAuthCacheService userAuthCacheService; - public AuthController(AuthenticationManager authenticationManager, UserRepository userRepository, JwtUtil jwtUtil, PasswordEncoder passwordEncoder, AvatarStorageService avatarStorageService, ActivityLogService activityLogService, PasswordResetService passwordResetService, EmailService emailService) { + public AuthController(AuthenticationManager authenticationManager, UserRepository userRepository, JwtUtil jwtUtil, PasswordEncoder passwordEncoder, AvatarStorageService avatarStorageService, ActivityLogService activityLogService, PasswordResetService passwordResetService, EmailService emailService, UserAuthCacheService userAuthCacheService) { this.authenticationManager = authenticationManager; this.userRepository = userRepository; this.jwtUtil = jwtUtil; @@ -68,6 +70,7 @@ public class AuthController { this.activityLogService = activityLogService; this.passwordResetService = passwordResetService; this.emailService = emailService; + this.userAuthCacheService = userAuthCacheService; } @PostMapping("/register") @@ -263,6 +266,7 @@ public class AuthController { error.put("message", "Username, email, or phone already exists"); return ResponseEntity.status(HttpStatus.CONFLICT).body(error); } + userAuthCacheService.evict(updatedUser.getId()); return ResponseEntity.ok(toUserInfoResponse(updatedUser)); } diff --git a/backend/src/main/java/com/petshop/backend/security/JwtAuthenticationFilter.java b/backend/src/main/java/com/petshop/backend/security/JwtAuthenticationFilter.java index a4caaaef..db7d47fc 100644 --- a/backend/src/main/java/com/petshop/backend/security/JwtAuthenticationFilter.java +++ b/backend/src/main/java/com/petshop/backend/security/JwtAuthenticationFilter.java @@ -2,7 +2,6 @@ package com.petshop.backend.security; import com.petshop.backend.entity.User; import com.petshop.backend.exception.ApiErrorResponder; -import com.petshop.backend.repository.UserRepository; import io.jsonwebtoken.JwtException; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -16,16 +15,18 @@ import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; +import java.util.Date; + @Component public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtUtil jwtUtil; - private final UserRepository userRepository; + private final UserAuthCacheService userAuthCacheService; private final ApiErrorResponder apiErrorResponder; - public JwtAuthenticationFilter(JwtUtil jwtUtil, UserRepository userRepository, ApiErrorResponder apiErrorResponder) { + public JwtAuthenticationFilter(JwtUtil jwtUtil, UserAuthCacheService userAuthCacheService, ApiErrorResponder apiErrorResponder) { this.jwtUtil = jwtUtil; - this.userRepository = userRepository; + this.userAuthCacheService = userAuthCacheService; this.apiErrorResponder = apiErrorResponder; } @@ -44,30 +45,44 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { jwt = authHeader.substring(7); Long userId; + String username; + String roleStr; + Integer jwtTokenVersion; try { userId = jwtUtil.extractUserId(jwt); + username = jwtUtil.extractUsername(jwt); + roleStr = jwtUtil.extractRole(jwt); + jwtTokenVersion = jwtUtil.extractTokenVersion(jwt); } catch (JwtException | IllegalArgumentException ex) { writeUnauthorized(request, response, "Invalid or expired token", ex); return; } if (userId != null && SecurityContextHolder.getContext().getAuthentication() == null) { - User user = userRepository.findById(userId).orElse(null); - if (user == null || user.getActive() == null || !user.getActive()) { - writeUnauthorized(request, response, "User account is inactive", null); - return; - } - if (!jwtUtil.validateToken(jwt, user)) { + if (jwtUtil.extractExpiration(jwt).before(new Date())) { writeUnauthorized(request, response, "Invalid or expired token", null); return; } - AppPrincipal principal = new AppPrincipal( - user.getId(), - user.getUsername(), - user.getRole(), - user.getTokenVersion() - ); + UserAuthCacheService.UserAuthData authData = userAuthCacheService.loadAuthData(userId); + if (authData == null || !Boolean.TRUE.equals(authData.active())) { + writeUnauthorized(request, response, "User account is inactive", null); + return; + } + if (!authData.tokenVersion().equals(jwtTokenVersion)) { + writeUnauthorized(request, response, "Invalid or expired token", null); + return; + } + + User.Role role; + try { + role = User.Role.valueOf(roleStr); + } catch (IllegalArgumentException ex) { + writeUnauthorized(request, response, "Invalid or expired token", ex); + return; + } + + AppPrincipal principal = new AppPrincipal(userId, username, role, jwtTokenVersion); UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( principal, null, diff --git a/backend/src/main/java/com/petshop/backend/security/UserAuthCacheService.java b/backend/src/main/java/com/petshop/backend/security/UserAuthCacheService.java new file mode 100644 index 00000000..69d39a9d --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/security/UserAuthCacheService.java @@ -0,0 +1,29 @@ +package com.petshop.backend.security; + +import com.petshop.backend.repository.UserRepository; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; + +@Service +public class UserAuthCacheService { + + private final UserRepository userRepository; + + public UserAuthCacheService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + public record UserAuthData(Boolean active, Integer tokenVersion) {} + + @Cacheable(value = "userAuthCache", key = "#userId") + public UserAuthData loadAuthData(Long userId) { + return userRepository.findById(userId) + .map(u -> new UserAuthData(u.getActive(), u.getTokenVersion())) + .orElse(null); + } + + @CacheEvict(value = "userAuthCache", key = "#userId") + public void evict(Long userId) { + } +} diff --git a/backend/src/main/java/com/petshop/backend/service/ActivityLogService.java b/backend/src/main/java/com/petshop/backend/service/ActivityLogService.java index 540b9383..c57378a8 100644 --- a/backend/src/main/java/com/petshop/backend/service/ActivityLogService.java +++ b/backend/src/main/java/com/petshop/backend/service/ActivityLogService.java @@ -5,6 +5,7 @@ import com.petshop.backend.entity.User; import com.petshop.backend.repository.UserRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; @Service @@ -18,6 +19,7 @@ public class ActivityLogService { this.userRepository = userRepository; } + @Async public void record(Long userId, String activity) { if (userId == null || activity == null || activity.isBlank()) { return; @@ -36,6 +38,7 @@ public class ActivityLogService { } } + @Async public void record(User user, String activity) { if (user == null) { return; diff --git a/backend/src/main/java/com/petshop/backend/service/PasswordResetService.java b/backend/src/main/java/com/petshop/backend/service/PasswordResetService.java index 2ecee374..f90cf6d5 100644 --- a/backend/src/main/java/com/petshop/backend/service/PasswordResetService.java +++ b/backend/src/main/java/com/petshop/backend/service/PasswordResetService.java @@ -7,6 +7,7 @@ import com.petshop.backend.entity.User; import com.petshop.backend.exception.BusinessException; import com.petshop.backend.repository.PasswordResetTokenRepository; import com.petshop.backend.repository.UserRepository; +import com.petshop.backend.security.UserAuthCacheService; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -29,16 +30,19 @@ public class PasswordResetService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; private final EmailService emailService; + private final UserAuthCacheService userAuthCacheService; private final SecureRandom secureRandom = new SecureRandom(); public PasswordResetService(PasswordResetTokenRepository passwordResetTokenRepository, UserRepository userRepository, PasswordEncoder passwordEncoder, - EmailService emailService) { + EmailService emailService, + UserAuthCacheService userAuthCacheService) { this.passwordResetTokenRepository = passwordResetTokenRepository; this.userRepository = userRepository; this.passwordEncoder = passwordEncoder; this.emailService = emailService; + this.userAuthCacheService = userAuthCacheService; } @Transactional @@ -97,6 +101,7 @@ public class PasswordResetService { user.setPassword(passwordEncoder.encode(newPassword)); user.setTokenVersion(user.getTokenVersion() + 1); userRepository.save(user); + userAuthCacheService.evict(user.getId()); token.setUsedAt(now); passwordResetTokenRepository.save(token); diff --git a/backend/src/main/java/com/petshop/backend/service/UserService.java b/backend/src/main/java/com/petshop/backend/service/UserService.java index 15a57c80..75966114 100644 --- a/backend/src/main/java/com/petshop/backend/service/UserService.java +++ b/backend/src/main/java/com/petshop/backend/service/UserService.java @@ -8,6 +8,7 @@ import com.petshop.backend.entity.User; import com.petshop.backend.exception.ResourceNotFoundException; import com.petshop.backend.repository.StoreRepository; import com.petshop.backend.repository.UserRepository; +import com.petshop.backend.security.UserAuthCacheService; import com.petshop.backend.util.AuthenticationHelper; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -33,11 +34,13 @@ public class UserService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; private final StoreRepository storeRepository; + private final UserAuthCacheService userAuthCacheService; - public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder, StoreRepository storeRepository) { + public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder, StoreRepository storeRepository, UserAuthCacheService userAuthCacheService) { this.userRepository = userRepository; this.passwordEncoder = passwordEncoder; this.storeRepository = storeRepository; + this.userAuthCacheService = userAuthCacheService; } public Page getAllUsers(String query, String role, Pageable pageable) { @@ -147,6 +150,7 @@ public class UserService { } user = userRepository.save(user); + userAuthCacheService.evict(user.getId()); return mapToResponse(user); } diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 016775af..58312155 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -18,6 +18,10 @@ spring: username: ${SPRING_DATASOURCE_USERNAME:petshop} password: ${SPRING_DATASOURCE_PASSWORD:petshop} driver-class-name: com.mysql.cj.jdbc.Driver + hikari: + maximum-pool-size: 20 + minimum-idle: 5 + connection-timeout: 30000 sql: init: @@ -42,6 +46,10 @@ server: address: ${SERVER_ADDRESS:0.0.0.0} servlet: context-path: / + compression: + enabled: true + mime-types: application/json,application/javascript,text/css,text/plain + min-response-size: 1024 springdoc: api-docs: From 726e1ee52df207ad05c0d10165bc8737250e90c0 Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Wed, 15 Apr 2026 16:27:31 -0600 Subject: [PATCH 07/37] fix yaml and swagger defaults --- backend/src/main/resources/application.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 58312155..312e9396 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -54,8 +54,10 @@ server: springdoc: api-docs: path: /v3/api-docs + enabled: ${SWAGGER_ENABLED:false} swagger-ui: path: /swagger-ui + enabled: ${SWAGGER_ENABLED:false} app: upload: @@ -91,6 +93,7 @@ logging: org.springframework.security: ${LOG_LEVEL_SECURITY:WARN} org.springdoc.core.events.SpringDocAppInitializer: ERROR +spring: jackson: serialization: write-dates-as-timestamps: false From 892d0394af2108e47bd1229fde6cd455687c85f7 Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Wed, 15 Apr 2026 16:31:17 -0600 Subject: [PATCH 08/37] force revision on deploy --- .github/workflows/deploy.yml | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6bcb3472..233f49b5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -74,10 +74,7 @@ jobs: context: ./web push: true tags: ${{ env.FRONTEND_IMAGE }}:latest - build-args: | - NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${{ secrets.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY }} - cache-from: type=gha - cache-to: type=gha,mode=max + no-cache: true deploy: runs-on: ubuntu-latest @@ -105,11 +102,13 @@ jobs: az containerapp update \ --name ${{ secrets.AZURE_BACKEND_APP_NAME }} \ --resource-group ${{ secrets.AZURE_RESOURCE_GROUP }} \ - --image ${{ env.BACKEND_IMAGE }}:latest + --image ${{ env.BACKEND_IMAGE }}:latest \ + --revision-suffix r${{ github.run_number }} - name: Deploy frontend run: | az containerapp update \ --name ${{ secrets.AZURE_FRONTEND_APP_NAME }} \ --resource-group ${{ secrets.AZURE_RESOURCE_GROUP }} \ - --image ${{ env.FRONTEND_IMAGE }}:latest + --image ${{ env.FRONTEND_IMAGE }}:latest \ + --revision-suffix r${{ github.run_number }} From b2f3bc117da0a1821f05a9bba403e1884f6e5c4f Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Wed, 15 Apr 2026 16:33:14 -0600 Subject: [PATCH 09/37] fix UserServiceTest constructor --- .../java/com/petshop/backend/service/UserServiceTest.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/src/test/java/com/petshop/backend/service/UserServiceTest.java b/backend/src/test/java/com/petshop/backend/service/UserServiceTest.java index c056e333..165c869b 100644 --- a/backend/src/test/java/com/petshop/backend/service/UserServiceTest.java +++ b/backend/src/test/java/com/petshop/backend/service/UserServiceTest.java @@ -8,6 +8,7 @@ import com.petshop.backend.entity.User; import com.petshop.backend.repository.StoreRepository; import com.petshop.backend.repository.UserRepository; import com.petshop.backend.security.AppPrincipal; +import com.petshop.backend.security.UserAuthCacheService; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -38,12 +39,13 @@ class UserServiceTest { @Mock private UserRepository userRepository; @Mock private PasswordEncoder passwordEncoder; @Mock private StoreRepository storeRepository; + @Mock private UserAuthCacheService userAuthCacheService; private UserService userService; @BeforeEach void setUp() { - userService = new UserService(userRepository, passwordEncoder, storeRepository); + userService = new UserService(userRepository, passwordEncoder, storeRepository, userAuthCacheService); } @AfterEach From 89e6e05e8eacfc0e742feb794e9e81cbffd52c0e Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Wed, 15 Apr 2026 17:04:23 -0600 Subject: [PATCH 10/37] replace chat polling with websocket --- web/Dockerfile | 2 + web/app/ai-chat/page.js | 73 ++++++++-------- web/app/chat/page.js | 73 ++++++++-------- web/context/ChatWidgetContext.js | 91 ++++++++++++-------- web/lib/chatSocket.js | 16 ++++ web/package-lock.json | 137 ++++++++++++++++++++++++++++++- web/package.json | 4 +- 7 files changed, 280 insertions(+), 116 deletions(-) create mode 100644 web/lib/chatSocket.js diff --git a/web/Dockerfile b/web/Dockerfile index 38bd4ff0..61f8cd3d 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -4,6 +4,8 @@ COPY package*.json ./ RUN npm ci COPY . . ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51TK18lFQ95OLlFb7dNtKXlvhry8IOvHaWJWW7zUNFhicMgyJ2EgAFhiAocxsCyP95IKt7AeQg4cWe5iHF3qoheZyl0034Cd4yij +ARG NEXT_PUBLIC_BACKEND_URL +ENV NEXT_PUBLIC_BACKEND_URL=$NEXT_PUBLIC_BACKEND_URL RUN npm run build FROM node:22-alpine diff --git a/web/app/ai-chat/page.js b/web/app/ai-chat/page.js index 8bfe0bb8..a4cd4127 100644 --- a/web/app/ai-chat/page.js +++ b/web/app/ai-chat/page.js @@ -4,9 +4,9 @@ import dynamic from "next/dynamic"; import { useState, useEffect, useRef, useCallback } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { useAuth } from "@/context/AuthContext"; +import { createStompClient } from "@/lib/chatSocket"; const API_BASE = ""; -const POLL_INTERVAL = 2500; function AiChatPage() { const { user, token, loading: authLoading } = useAuth(); @@ -27,7 +27,7 @@ function AiChatPage() { const messagesEndRef = useRef(null); const messagesAreaRef = useRef(null); const inputRef = useRef(null); - const pollRef = useRef(null); + const stompRef = useRef(null); const lastMessageIdRef = useRef(null); const fileInputRef = useRef(null); const lastScrolledIdRef = useRef(null); @@ -100,37 +100,30 @@ function AiChatPage() { } }, [token]); - const startPolling = useCallback((convId) => { - if (pollRef.current) clearInterval(pollRef.current); - pollRef.current = setInterval(async () => { - if (!token || !convId) return; - try { - const [msgsRes, convRes] = await Promise.all([ - fetch(`${API_BASE}/api/v1/chat/conversations/${convId}/messages`, { - headers: { Authorization: `Bearer ${token}` }, - }), - fetch(`${API_BASE}/api/v1/chat/conversations/${convId}`, { - headers: { Authorization: `Bearer ${token}` }, - }), - ]); - if (msgsRes.ok) { - const data = await msgsRes.json(); - if (Array.isArray(data)) { - const lastId = data.length > 0 ? data[data.length - 1].id : null; - if (lastId !== lastMessageIdRef.current) { - lastMessageIdRef.current = lastId; - setMessages(data); - } - } - } - if (convRes.ok) { - const convData = await convRes.json(); - setConversation(convData); - } - } catch { - // silent - } - }, POLL_INTERVAL); + const connectStomp = useCallback((convId) => { + if (stompRef.current) { + stompRef.current.deactivate(); + stompRef.current = null; + } + const client = createStompClient(token); + client.onConnect = () => { + client.subscribe(`/topic/chat/conversations/${convId}`, (frame) => { + try { + const msg = JSON.parse(frame.body); + setMessages((prev) => prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]); + lastMessageIdRef.current = msg.id; + } catch { /* silent */ } + }); + client.subscribe(`/topic/chat/conversations`, (frame) => { + try { + const conv = JSON.parse(frame.body); + if (conv.id === convId) setConversation(conv); + setConversations((prev) => prev.map((c) => c.id === conv.id ? conv : c)); + } catch { /* silent */ } + }); + }; + stompRef.current = client; + client.activate(); }, [token]); useEffect(() => { @@ -191,16 +184,16 @@ function AiChatPage() { fetchConversations(), ]); setLoadingConv(false); - startPolling(convId); + connectStomp(convId); router.replace(`/ai-chat?id=${convId}`, { scroll: false }); } init(); return () => { - if (pollRef.current) clearInterval(pollRef.current); + if (stompRef.current) { stompRef.current.deactivate(); stompRef.current = null; } }; - }, [token, authLoading, conversationIdParam, fetchConversation, fetchMessages, startPolling, fetchConversations, router]); + }, [token, authLoading, conversationIdParam, fetchConversation, fetchMessages, connectStomp, fetchConversations, router]); async function handleSend(e) { e?.preventDefault(); @@ -313,7 +306,7 @@ function AiChatPage() { } async function handleNewConversation() { - if (pollRef.current) clearInterval(pollRef.current); + if (stompRef.current) { stompRef.current.deactivate(); stompRef.current = null; } setError(null); setLoadingConv(true); try { @@ -335,7 +328,7 @@ function AiChatPage() { setConversation(conv); await Promise.all([fetchMessages(conv.id), fetchConversations()]); setLoadingConv(false); - startPolling(conv.id); + connectStomp(conv.id); router.replace(`/ai-chat?id=${conv.id}`, { scroll: false }); } catch { setError("Network error. Please try again."); @@ -344,14 +337,14 @@ function AiChatPage() { } async function switchConversation(convId) { - if (pollRef.current) clearInterval(pollRef.current); + if (stompRef.current) { stompRef.current.deactivate(); stompRef.current = null; } setMessages([]); setError(null); setLoadingConv(true); await fetchConversation(convId); await fetchMessages(convId); setLoadingConv(false); - startPolling(convId); + connectStomp(convId); router.replace(`/ai-chat?id=${convId}`, { scroll: false }); } diff --git a/web/app/chat/page.js b/web/app/chat/page.js index 7feeb5dd..cc3fb717 100644 --- a/web/app/chat/page.js +++ b/web/app/chat/page.js @@ -4,9 +4,9 @@ import dynamic from "next/dynamic"; import { useState, useEffect, useRef, useCallback } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { useAuth } from "@/context/AuthContext"; +import { createStompClient } from "@/lib/chatSocket"; const API_BASE = ""; -const POLL_INTERVAL = 2500; function ChatPage() { const { user, token, loading: authLoading } = useAuth(); @@ -27,7 +27,7 @@ function ChatPage() { const messagesEndRef = useRef(null); const messagesAreaRef = useRef(null); const inputRef = useRef(null); - const pollRef = useRef(null); + const stompRef = useRef(null); const lastMessageIdRef = useRef(null); const fileInputRef = useRef(null); const lastScrolledIdRef = useRef(null); @@ -114,39 +114,30 @@ function ChatPage() { } }, [token]); - const startPolling = useCallback((convId) => { - if (pollRef.current) clearInterval(pollRef.current); - pollRef.current = setInterval(async () => { - if (!token || !convId) return; - try { - const [msgsRes, convRes] = await Promise.all([ - fetch(`${API_BASE}/api/v1/chat/conversations/${convId}/messages`, { - headers: { Authorization: `Bearer ${token}` }, - }), - fetch(`${API_BASE}/api/v1/chat/conversations/${convId}`, { - headers: { Authorization: `Bearer ${token}` }, - }), - ]); - if (msgsRes.ok) { - const data = await msgsRes.json(); - if (Array.isArray(data)) { - const lastId = data.length > 0 ? data[data.length - 1].id : null; - if (lastId !== lastMessageIdRef.current) { - lastMessageIdRef.current = lastId; - setMessages(data); - } - } - } - if (convRes.ok) { - const convData = await convRes.json(); - setConversation(convData); - } - } - - catch { - //Silent - } - }, POLL_INTERVAL); + const connectStomp = useCallback((convId) => { + if (stompRef.current) { + stompRef.current.deactivate(); + stompRef.current = null; + } + const client = createStompClient(token); + client.onConnect = () => { + client.subscribe(`/topic/chat/conversations/${convId}`, (frame) => { + try { + const msg = JSON.parse(frame.body); + setMessages((prev) => prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]); + lastMessageIdRef.current = msg.id; + } catch { /* silent */ } + }); + client.subscribe(`/topic/chat/conversations`, (frame) => { + try { + const conv = JSON.parse(frame.body); + if (conv.id === convId) setConversation(conv); + setConversations((prev) => prev.map((c) => c.id === conv.id ? conv : c)); + } catch { /* silent */ } + }); + }; + stompRef.current = client; + client.activate(); }, [token]); useEffect(() => { @@ -188,15 +179,15 @@ function ChatPage() { fetchConversations(), ]); setLoadingConv(false); - startPolling(convId); + connectStomp(convId); } init(); return () => { - if (pollRef.current) clearInterval(pollRef.current); + if (stompRef.current) { stompRef.current.deactivate(); stompRef.current = null; } }; - }, [token, authLoading, conversationIdParam, fetchConversation, fetchMessages, startPolling, fetchConversations]); + }, [token, authLoading, conversationIdParam, fetchConversation, fetchMessages, connectStomp, fetchConversations]); async function handleSend(e) { e?.preventDefault(); @@ -340,7 +331,7 @@ function ChatPage() { setConversation(conv); await Promise.all([fetchMessages(conv.id), fetchConversations()]); setLoadingConv(false); - startPolling(conv.id); + connectStomp(conv.id); router.replace(`/chat?id=${conv.id}`, { scroll: false }); } catch { setError("Network error. Please try again."); @@ -349,14 +340,14 @@ function ChatPage() { } async function switchConversation(convId) { - if (pollRef.current) clearInterval(pollRef.current); + if (stompRef.current) { stompRef.current.deactivate(); stompRef.current = null; } setMessages([]); setError(null); setLoadingConv(true); await fetchConversation(convId); await fetchMessages(convId); setLoadingConv(false); - startPolling(convId); + connectStomp(convId); router.replace(`/chat?id=${convId}`, { scroll: false }); } diff --git a/web/context/ChatWidgetContext.js b/web/context/ChatWidgetContext.js index 82943654..5eb72f45 100644 --- a/web/context/ChatWidgetContext.js +++ b/web/context/ChatWidgetContext.js @@ -1,6 +1,7 @@ "use client"; import { createContext, useContext, useState, useRef, useCallback, useEffect } from "react"; +import { createStompClient } from "@/lib/chatSocket"; const ChatWidgetContext = createContext(null); const API_BASE = ""; @@ -60,24 +61,30 @@ export function ChatWidgetProvider({ children }) { const [liveSending, setLiveSending] = useState(false); const [switchingToHuman, setSwitchingToHuman] = useState(false); - const pollRef = useRef(null); + const stompRef = useRef(null); const activeConvIdRef = useRef(null); - const tokenRef = useRef(null); // FIX: store token so polling can restart + const tokenRef = useRef(null); - const stopPolling = useCallback(() => { - if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } + const disconnectStomp = useCallback(() => { + if (stompRef.current) { + stompRef.current.deactivate(); + stompRef.current = null; + } }, []); - const fetchLiveMessages = useCallback(async (convId, token) => { - if (!convId || !token) return; - try { - const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${convId}/messages`, { - headers: { Authorization: `Bearer ${token}` }, - }); - if (!res.ok) return; - const data = await res.json(); - if (Array.isArray(data)) setLiveMessages(data); - } catch { /* silent */ } + const subscribeToConversation = useCallback((client, convId) => { + client.subscribe(`/topic/chat/conversations/${convId}`, (frame) => { + try { + const msg = JSON.parse(frame.body); + setLiveMessages((prev) => prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]); + } catch { /* silent */ } + }); + client.subscribe(`/user/queue/chat/conversations`, (frame) => { + try { + const conv = JSON.parse(frame.body); + if (conv.id === convId) setActiveConv(conv); + } catch { /* silent */ } + }); }, []); const loadConversations = useCallback(async (token) => { @@ -97,21 +104,35 @@ export function ChatWidgetProvider({ children }) { const openLiveConversation = useCallback(async (convId, token) => { if (!convId || !token) return; - stopPolling(); - tokenRef.current = token; // FIX: save token for polling restart + disconnectStomp(); + tokenRef.current = token; setActiveConvId(convId); activeConvIdRef.current = convId; setLiveMessages([]); setView("live"); + try { const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${convId}`, { headers: { Authorization: `Bearer ${token}` }, }); if (res.ok) setActiveConv(await res.json()); } catch { /* silent */ } - await fetchLiveMessages(convId, token); - pollRef.current = setInterval(() => fetchLiveMessages(convId, token), 2500); - }, [stopPolling, fetchLiveMessages]); + + try { + const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${convId}/messages`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.ok) { + const data = await res.json(); + if (Array.isArray(data)) setLiveMessages(data); + } + } catch { /* silent */ } + + const client = createStompClient(token); + client.onConnect = () => subscribeToConversation(client, convId); + stompRef.current = client; + client.activate(); + }, [disconnectStomp, subscribeToConversation]); const sendLiveMessage = useCallback(async (text, token, convId) => { if (!text.trim() || liveSending || !token || !convId) return; @@ -122,11 +143,14 @@ export function ChatWidgetProvider({ children }) { headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, body: JSON.stringify({ content: text }), }); - if (res.ok) await fetchLiveMessages(convId, token); + if (res.ok) { + const msg = await res.json(); + setLiveMessages((prev) => prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]); + } } catch { /* silent */ } finally { setLiveSending(false); } - }, [liveSending, fetchLiveMessages]); + }, [liveSending]); const startLiveChat = useCallback(async (token) => { if (!token || switchingToHuman) return; @@ -149,19 +173,22 @@ export function ChatWidgetProvider({ children }) { } }, [switchingToHuman, openLiveConversation]); - // FIX: Single effect that handles both stopping AND restarting polling useEffect(() => { if (!isOpen || view !== "live") { - stopPolling(); - } else if (isOpen && view === "live" && activeConvIdRef.current && tokenRef.current) { - stopPolling(); - fetchLiveMessages(activeConvIdRef.current, tokenRef.current); - pollRef.current = setInterval( - () => fetchLiveMessages(activeConvIdRef.current, tokenRef.current), - 2500 - ); + disconnectStomp(); + } else if (isOpen && view === "live" && activeConvIdRef.current && tokenRef.current && !stompRef.current) { + const convId = activeConvIdRef.current; + const token = tokenRef.current; + const client = createStompClient(token); + client.onConnect = () => subscribeToConversation(client, convId); + stompRef.current = client; + client.activate(); } - }, [isOpen, view, stopPolling, fetchLiveMessages]); + }, [isOpen, view, disconnectStomp, subscribeToConversation]); + + useEffect(() => { + return () => disconnectStomp(); + }, [disconnectStomp]); const toggleOpen = useCallback(() => setIsOpen((o) => !o), []); const openView = useCallback((v) => setView(v), []); @@ -185,4 +212,4 @@ export function useChatWidget() { const ctx = useContext(ChatWidgetContext); if (!ctx) throw new Error("useChatWidget must be used within ChatWidgetProvider"); return ctx; -} \ No newline at end of file +} diff --git a/web/lib/chatSocket.js b/web/lib/chatSocket.js new file mode 100644 index 00000000..72d8a5d0 --- /dev/null +++ b/web/lib/chatSocket.js @@ -0,0 +1,16 @@ +import { Client } from "@stomp/stompjs"; + +const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || ""; + +export function createStompClient(token) { + return new Client({ + webSocketFactory: () => { + const SockJS = require("sockjs-client"); + return new SockJS(`${BACKEND_URL}/ws/chat-sockjs`); + }, + connectHeaders: { Authorization: `Bearer ${token}` }, + reconnectDelay: 5000, + heartbeatIncoming: 10000, + heartbeatOutgoing: 10000, + }); +} diff --git a/web/package-lock.json b/web/package-lock.json index 411fd1f9..b9b378cb 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,11 +8,13 @@ "name": "threaded-pets", "version": "0.1.0", "dependencies": { + "@stomp/stompjs": "^7.3.0", "@stripe/react-stripe-js": "^3.1.1", "@stripe/stripe-js": "^5.5.0", "next": "^16.2.2", "react": "19.2.3", - "react-dom": "19.2.3" + "react-dom": "19.2.3", + "sockjs-client": "^1.6.1" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -1232,6 +1234,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@stomp/stompjs": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@stomp/stompjs/-/stompjs-7.3.0.tgz", + "integrity": "sha512-nKMLoFfJhrQAqkvvKd1vLq/cVBGCMwPRCD0LqW7UT1fecRx9C3GoKEIR2CYwVuErGeZu8w0kFkl2rlhPlqHVgQ==", + "license": "Apache-2.0" + }, "node_modules/@stripe/react-stripe-js": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-3.10.0.tgz", @@ -3474,6 +3482,15 @@ "node": ">=0.10.0" } }, + "node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3535,6 +3552,18 @@ "reusify": "^1.0.4" } }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -3920,6 +3949,12 @@ "hermes-estree": "0.25.1" } }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "license": "MIT" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3957,6 +3992,12 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -4932,7 +4973,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -5402,6 +5442,12 @@ "node": ">=6" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -5494,6 +5540,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -5590,6 +5642,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -5847,6 +5919,34 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sockjs-client": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sockjs-client/-/sockjs-client-1.6.1.tgz", + "integrity": "sha512-2g0tjOR+fRs0amxENLi/q5TiJTqY+WXFOzb5UwXndlK6TO3U/mirZznpx6w34HVMoc3g7cY24yC/ZMIYnDlfkw==", + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "eventsource": "^2.0.2", + "faye-websocket": "^0.11.4", + "inherits": "^2.0.4", + "url-parse": "^1.5.10" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://tidelift.com/funding/github/npm/sockjs-client" + } + }, + "node_modules/sockjs-client/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -6414,6 +6514,39 @@ "punycode": "^2.1.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/web/package.json b/web/package.json index 29d0960e..43f69b4e 100644 --- a/web/package.json +++ b/web/package.json @@ -9,11 +9,13 @@ "lint": "eslint" }, "dependencies": { + "@stomp/stompjs": "^7.3.0", "@stripe/react-stripe-js": "^3.1.1", "@stripe/stripe-js": "^5.5.0", "next": "^16.2.2", "react": "19.2.3", - "react-dom": "19.2.3" + "react-dom": "19.2.3", + "sockjs-client": "^1.6.1" }, "devDependencies": { "@tailwindcss/postcss": "^4", From 5e5c79faff3971c348e28f760201ff5c701e556a Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Wed, 15 Apr 2026 17:25:31 -0600 Subject: [PATCH 11/37] fix duplicate spring key --- backend/src/main/resources/application.yml | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 312e9396..c838f1cb 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -38,6 +38,12 @@ spring: format_sql: true open-in-view: false + jackson: + serialization: + write-dates-as-timestamps: false + deserialization: + fail-on-unknown-properties: false + flyway: enabled: ${FLYWAY_ENABLED:false} @@ -92,10 +98,3 @@ logging: com.petshop: ${LOG_LEVEL:INFO} org.springframework.security: ${LOG_LEVEL_SECURITY:WARN} org.springdoc.core.events.SpringDocAppInitializer: ERROR - -spring: - jackson: - serialization: - write-dates-as-timestamps: false - deserialization: - fail-on-unknown-properties: false From 6b11de24b86b7aae426a2c007f2bbe178ea68a3b Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Wed, 15 Apr 2026 18:01:23 -0600 Subject: [PATCH 12/37] fix JPQL pet query field --- .../main/java/com/petshop/backend/repository/PetRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/main/java/com/petshop/backend/repository/PetRepository.java b/backend/src/main/java/com/petshop/backend/repository/PetRepository.java index 9358fb72..8b1aa178 100644 --- a/backend/src/main/java/com/petshop/backend/repository/PetRepository.java +++ b/backend/src/main/java/com/petshop/backend/repository/PetRepository.java @@ -40,7 +40,7 @@ public interface PetRepository extends JpaRepository { Optional findByIdAndOwner_Id(Long id, Long ownerId); @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query("SELECT p FROM Pet p WHERE p.petId = :id") + @Query("SELECT p FROM Pet p WHERE p.id = :id") Optional findByIdForUpdate(@Param("id") Long id); @Query("SELECT p FROM Pet p WHERE " + From e7d5765ae1b97823c5bebd03aee0139244f6d544 Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Wed, 15 Apr 2026 18:12:16 -0600 Subject: [PATCH 13/37] remove incompatible jackson config --- backend/src/main/resources/application.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index c838f1cb..b3b6888c 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -38,12 +38,6 @@ spring: format_sql: true open-in-view: false - jackson: - serialization: - write-dates-as-timestamps: false - deserialization: - fail-on-unknown-properties: false - flyway: enabled: ${FLYWAY_ENABLED:false} From b5a798adcc269d2b9907333f3fa6d446faf87369 Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Wed, 15 Apr 2026 18:20:18 -0600 Subject: [PATCH 14/37] disable server compression --- backend/src/main/resources/application.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index b3b6888c..ddcfcbe0 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -46,10 +46,6 @@ server: address: ${SERVER_ADDRESS:0.0.0.0} servlet: context-path: / - compression: - enabled: true - mime-types: application/json,application/javascript,text/css,text/plain - min-response-size: 1024 springdoc: api-docs: From 653560ee31d1fa641d85c610fb797a98b4a1129a Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Wed, 15 Apr 2026 18:32:38 -0600 Subject: [PATCH 15/37] fix websocket backend url --- web/Dockerfile | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/web/Dockerfile b/web/Dockerfile index 61f8cd3d..92f92f65 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -4,8 +4,7 @@ COPY package*.json ./ RUN npm ci COPY . . ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51TK18lFQ95OLlFb7dNtKXlvhry8IOvHaWJWW7zUNFhicMgyJ2EgAFhiAocxsCyP95IKt7AeQg4cWe5iHF3qoheZyl0034Cd4yij -ARG NEXT_PUBLIC_BACKEND_URL -ENV NEXT_PUBLIC_BACKEND_URL=$NEXT_PUBLIC_BACKEND_URL +ENV NEXT_PUBLIC_BACKEND_URL=https://petshop-backend.nicepond-c7280126.westus2.azurecontainerapps.io RUN npm run build FROM node:22-alpine From 51829dd833870deba9ec0a11caf641a5a589e967 Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Wed, 15 Apr 2026 18:39:00 -0600 Subject: [PATCH 16/37] fix CORS for production --- .../com/petshop/backend/security/SecurityConfig.java | 10 ++++++++-- backend/src/main/resources/application.yml | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/backend/src/main/java/com/petshop/backend/security/SecurityConfig.java b/backend/src/main/java/com/petshop/backend/security/SecurityConfig.java index fcc05bb4..f154ead1 100644 --- a/backend/src/main/java/com/petshop/backend/security/SecurityConfig.java +++ b/backend/src/main/java/com/petshop/backend/security/SecurityConfig.java @@ -1,5 +1,6 @@ package com.petshop.backend.security; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; @@ -21,6 +22,8 @@ import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import java.util.Arrays; + import com.petshop.backend.config.ActivityLoggingFilter; import java.util.List; @@ -30,6 +33,9 @@ import java.util.List; @EnableMethodSecurity public class SecurityConfig { + @Value("${app.allowed-origins}") + private String allowedOriginsRaw; + private final JwtAuthenticationFilter jwtAuthFilter; private final RateLimitFilter rateLimitFilter; private final UserDetailsService userDetailsService; @@ -101,13 +107,13 @@ public class SecurityConfig { @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); - config.setAllowedOriginPatterns(List.of("http://localhost:*", "http://127.0.0.1:*")); + config.setAllowedOriginPatterns(Arrays.asList(allowedOriginsRaw.split(","))); config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); config.setAllowedHeaders(List.of("*")); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); - + return source; } diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index ddcfcbe0..e3256221 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -59,7 +59,7 @@ app: upload: base-dir: ${UPLOAD_BASE_DIR:uploads} frontend-url: ${FRONTEND_URL:http://localhost:3000} - allowed-origins: ${ALLOWED_ORIGINS:http://localhost:3000,http://localhost:3001,http://127.0.0.1:3000} + allowed-origins: ${ALLOWED_ORIGINS:http://localhost:3000,http://localhost:3001,http://127.0.0.1:3000,https://petshop-web.nicepond-c7280126.westus2.azurecontainerapps.io} azure: storage: From b08d1d29ae02ed8e253df0da9a77c5a93813a4fc Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Wed, 15 Apr 2026 20:57:50 -0600 Subject: [PATCH 17/37] fix customer ws subscription --- web/app/ai-chat/page.js | 7 +++++-- web/app/chat/page.js | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/web/app/ai-chat/page.js b/web/app/ai-chat/page.js index a4cd4127..09ac5ba3 100644 --- a/web/app/ai-chat/page.js +++ b/web/app/ai-chat/page.js @@ -114,7 +114,10 @@ function AiChatPage() { lastMessageIdRef.current = msg.id; } catch { /* silent */ } }); - client.subscribe(`/topic/chat/conversations`, (frame) => { + const convTopic = user?.role === "CUSTOMER" + ? `/user/queue/chat/conversations` + : `/topic/chat/conversations`; + client.subscribe(convTopic, (frame) => { try { const conv = JSON.parse(frame.body); if (conv.id === convId) setConversation(conv); @@ -124,7 +127,7 @@ function AiChatPage() { }; stompRef.current = client; client.activate(); - }, [token]); + }, [token, user?.role]); useEffect(() => { if (!token || authLoading) return; diff --git a/web/app/chat/page.js b/web/app/chat/page.js index cc3fb717..f5b8432f 100644 --- a/web/app/chat/page.js +++ b/web/app/chat/page.js @@ -128,7 +128,10 @@ function ChatPage() { lastMessageIdRef.current = msg.id; } catch { /* silent */ } }); - client.subscribe(`/topic/chat/conversations`, (frame) => { + const convTopic = user?.role === "CUSTOMER" + ? `/user/queue/chat/conversations` + : `/topic/chat/conversations`; + client.subscribe(convTopic, (frame) => { try { const conv = JSON.parse(frame.body); if (conv.id === convId) setConversation(conv); @@ -138,7 +141,7 @@ function ChatPage() { }; stompRef.current = client; client.activate(); - }, [token]); + }, [token, user?.role]); useEffect(() => { if (!token || authLoading) return; From b60db151cb84f4d4645061daffd595dc11db61d8 Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Wed, 15 Apr 2026 21:21:37 -0600 Subject: [PATCH 18/37] fix stripe publishable key --- web/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/Dockerfile b/web/Dockerfile index 92f92f65..0e96859c 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -3,7 +3,7 @@ WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . -ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51TK18lFQ95OLlFb7dNtKXlvhry8IOvHaWJWW7zUNFhicMgyJ2EgAFhiAocxsCyP95IKt7AeQg4cWe5iHF3qoheZyl0034Cd4yij +ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51TK18lFQ95OLlFb7dNtKXlvhry8IOvHaWJWW7zUNFhicMgyJ2EgAFhiAocxsCyP95IKt7AeQg4cWe5iHF3qoheYl0034Cd4yij ENV NEXT_PUBLIC_BACKEND_URL=https://petshop-backend.nicepond-c7280126.westus2.azurecontainerapps.io RUN npm run build From adcb695d85f87fc0d7aa2ccc8485aa43553578ac Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Wed, 15 Apr 2026 21:34:15 -0600 Subject: [PATCH 19/37] fix mobile nav and env examples --- backend/.env.example | 5 +++-- web/.env.example | 1 + web/app/globals.css | 35 ++++++++++++++++++++++++++++++++++- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index efae7eb4..dab52388 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,4 +1,5 @@ +JWT_SECRET= STRIPE_SECRET_KEY=sk_test_... -NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_... OPENROUTER_API_KEY=sk-or-v1-... -RESEND_API_KEY=re_... \ No newline at end of file +RESEND_API_KEY=re_... +RESEND_FROM=PetShop \ No newline at end of file diff --git a/web/.env.example b/web/.env.example index 3d56459f..94230da9 100644 --- a/web/.env.example +++ b/web/.env.example @@ -1,4 +1,5 @@ NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_... +NEXT_PUBLIC_BACKEND_URL=http://localhost:8080 # Backend URL for the API proxy — swap comments to switch between local and remote BACKEND_URL=http://localhost:8080 #BACKEND_URL=https://petshop-backend.nicepond-c7280126.westus2.azurecontainerapps.io diff --git a/web/app/globals.css b/web/app/globals.css index fb521491..bd87fc76 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -2891,7 +2891,8 @@ body { display: flex; align-items: center; gap: 0.5rem; - margin-left: auto; + grid-column: 3; + justify-self: end; } .nav-links, @@ -3105,3 +3106,35 @@ img, video, iframe { .pagination-btn { background: #333; color: white; border: none; border-radius: 8px; padding: 0.5rem 1.2rem; font-size: 0.9rem; font-weight: 600; cursor: pointer; } .pagination-btn:disabled { background: #ccc; cursor: not-allowed; } .pagination-info { font-size: 0.9rem; color: #555; font-weight: 500; } + +@media (max-width: 768px) { + .info-title { + font-size: 2rem; + } + .info-subtitle { + font-size: 1.1rem; + } +} + +@media (max-width: 480px) { + .info-title { + font-size: 1.6rem; + } + .info-subtitle { + font-size: 1rem; + } + .image-links-container { + grid-template-columns: 1fr; + gap: 1rem; + } + .adopt-grid { + grid-template-columns: repeat(2, 1fr); + gap: 0.75rem; + } +} + +@media (max-width: 360px) { + .adopt-grid { + grid-template-columns: 1fr; + } +} From 795adacb579830ef884b309c72491ce33f54e81a Mon Sep 17 00:00:00 2001 From: Harkamal Date: Wed, 15 Apr 2026 22:01:49 -0600 Subject: [PATCH 20/37] mobile layout fixes (#319) From b711635afa8aa47f88257f3f2b335b891476bfdf Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Wed, 15 Apr 2026 22:22:18 -0600 Subject: [PATCH 21/37] fix layout and demo payment --- web/app/cart/page.js | 5 +++++ web/app/globals.css | 28 ++++++++++++++++++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/web/app/cart/page.js b/web/app/cart/page.js index cb378a25..82a3d8bb 100644 --- a/web/app/cart/page.js +++ b/web/app/cart/page.js @@ -57,6 +57,11 @@ function PaymentForm({ clientSecret, totalAmount, onSuccess, onCancel }) {

Total to pay: ${parseFloat(totalAmount).toFixed(2)}

+
+ Demo mode — no real charge. Use test card: + 4242 4242 4242 4242 + · any future date · any 3-digit CVC +
{payError &&

{payError}

}
diff --git a/web/app/globals.css b/web/app/globals.css index bd87fc76..b33b8f62 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -152,7 +152,7 @@ body { .image-links-container { display: grid; - grid-template-columns: repeat(4, 1fr); + grid-template-columns: repeat(3, 1fr); gap: 2rem; justify-content: center; align-items: stretch; @@ -710,6 +710,7 @@ body { margin: 0 auto; padding: 0 2rem 4rem; display: grid; + grid-template-columns: repeat(3, 1fr); gap: 1.5rem; } @@ -884,11 +885,15 @@ body { .slideshow-container { height: 300px; } - + .image-links-container { grid-template-columns: repeat(2, 1fr); gap: 1.5rem; } + + .info-content { + grid-template-columns: 1fr; + } .main-title { font-size: 2rem; @@ -2637,6 +2642,25 @@ body { margin: 0; } +.cart-demo-banner { + background: #fffbeb; + border: 1px solid #fcd34d; + border-radius: 8px; + padding: 0.6rem 0.9rem; + font-size: 0.85rem; + color: #78350f; + line-height: 1.5; +} + +.cart-demo-card { + font-family: monospace; + font-weight: 700; + margin: 0 0.25rem; + background: #fef3c7; + padding: 0.1rem 0.35rem; + border-radius: 4px; +} + .cart-payment-actions { display: flex; flex-direction: column; From 690c35415ba70ef925aab9479f9b57dd12dbf706 Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Wed, 15 Apr 2026 22:30:30 -0600 Subject: [PATCH 22/37] fix nav and pagination --- web/app/adopt/page.js | 35 +++++++++++++++++++++++++++++++---- web/app/globals.css | 19 +++++++++++++------ 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/web/app/adopt/page.js b/web/app/adopt/page.js index 6c2b41aa..3a17654e 100644 --- a/web/app/adopt/page.js +++ b/web/app/adopt/page.js @@ -74,7 +74,7 @@ export default function AdoptPage() { [pets] ); - const ITEMS_PER_PAGE = 20; + const ITEMS_PER_PAGE = 24; const [currentPage, setCurrentPage] = useState(0); const filteredPets = useMemo( @@ -192,15 +192,42 @@ export default function AdoptPage() {
- Page {currentPage + 1} of {totalPages} + {(() => { + const pages = []; + const delta = 2; + const left = Math.max(0, currentPage - delta); + const right = Math.min(totalPages - 1, currentPage + delta); + if (left > 0) { + pages.push(0); + if (left > 1) pages.push("..."); + } + for (let i = left; i <= right; i++) pages.push(i); + if (right < totalPages - 1) { + if (right < totalPages - 2) pages.push("..."); + pages.push(totalPages - 1); + } + return pages.map((p, i) => + p === "..." ? ( + + ) : ( + + ) + ); + })()} ))} + {conversations.some(c => c.status === "CLOSED") && ( + <> + + {closedExpanded && conversations.filter(c => c.status === "CLOSED").map((conv) => ( + + ))} + + )}
)} - + {isEscalated && !isClosed && ( + + )}
+ {isEscalated && !hasStaff && !hasStaffMessage && !isClosed && ( +
+ + A support agent will be with you shortly. You can send messages while you wait. +
+ )} +
{messages.length === 0 && (
-
🐾
+
{isEscalated ? "💬" : "🐾"}

- Hello{user.fullName ? `, ${user.fullName.split(" ")[0]}` : ""}! I'm your pet care assistant. - Ask me about pet recommendations, care tips, supplies, or anything pet-related! + {isEscalated ? "Your conversation has started. A support agent will join soon." : `Hello${user.fullName ? `, ${user.fullName.split(" ")[0]}` : ""}! I'm your pet care assistant. Ask me about pet recommendations, care tips, supplies, or anything pet-related!`}

)} @@ -547,7 +594,7 @@ function AiChatPage() { ...(isOwn ? s.messageRowUser : s.messageRowAgent), }} > - {!isOwn &&
🐾
} + {!isOwn &&
{isEscalated ? "👤" : "🐾"}
}
No conversations yet.

)}
- {conversations.map((conv) => ( + {conversations.filter(c => c.status !== "CLOSED").map((conv) => ( ))} + {conversations.some(c => c.status === "CLOSED") && ( + <> + + {closedExpanded && conversations.filter(c => c.status === "CLOSED").map((conv) => ( + + ))} + + )}
)} - + {!isHuman && ( + + )}
@@ -1145,6 +1173,21 @@ const s = { }, convStatusOpen: { background: "#e6f9ee", color: "#1a7a3c" }, convStatusClosed: { background: "#f0f0f0", color: "#888" }, + convItemClosed: { opacity: 0.7 }, + closedSectionToggle: { + width: "100%", + background: "#f5f5f5", + border: "none", + borderTop: "1px solid #e8e8e8", + padding: "0.5rem 1rem", + fontSize: "0.78rem", + fontWeight: 600, + color: "#666", + cursor: "pointer", + display: "flex", + justifyContent: "space-between", + alignItems: "center", + }, newConvSidebarBtn: { margin: "0.65rem 1rem", background: "#333", From 006023e2895ea48b9f4459e40aa1f624586b9067 Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Wed, 15 Apr 2026 22:51:39 -0600 Subject: [PATCH 25/37] fix adopt search mobile layout --- web/app/globals.css | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/web/app/globals.css b/web/app/globals.css index eab50e73..7ae1c69d 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -3048,13 +3048,20 @@ body { width: 100%; } - .adopt-search-form { + .adopt-controls-row { flex-direction: column; align-items: stretch; } + .adopt-search-form { + flex-direction: column; + align-items: stretch; + width: 100%; + } + .adopt-search-input { max-width: 100%; + width: 100%; } .adopt-search-btn, From 73c4bc6cc7598d05160a21600671c3956918b667 Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Wed, 15 Apr 2026 22:59:18 -0600 Subject: [PATCH 26/37] fix about section spacing and text --- web/app/globals.css | 29 +++++++++++++++++------------ web/app/page.js | 4 ++-- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/web/app/globals.css b/web/app/globals.css index 7ae1c69d..29edace2 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -684,31 +684,36 @@ body { .info-page { background: linear-gradient(to bottom, #f9f9f9, #ffffff); - padding-bottom: 4rem; } .info-hero { text-align: center; - padding: 4rem 2rem 3rem; + padding: 2.5rem 2rem 1.5rem; } .info-title { - font-size: 3rem; + font-size: 1.6rem; color: #333; - margin-bottom: 1rem; + margin-bottom: 0.5rem; font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; } .info-subtitle { - font-size: 1.25rem; - color: #666; - margin-bottom: 1.5rem; + font-size: 1rem; + color: #888; + margin-bottom: 1rem; + max-width: 520px; + margin-left: auto; + margin-right: auto; + line-height: 1.6; } .info-content { max-width: 1200px; margin: 0 auto; - padding: 0 2rem 4rem; + padding: 0 2rem 3rem; display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem; @@ -3147,19 +3152,19 @@ img, video, iframe { @media (max-width: 768px) { .info-title { - font-size: 2rem; + font-size: 1.3rem; } .info-subtitle { - font-size: 1.1rem; + font-size: 0.95rem; } } @media (max-width: 480px) { .info-title { - font-size: 1.6rem; + font-size: 1.2rem; } .info-subtitle { - font-size: 1rem; + font-size: 0.9rem; } .image-links-container { grid-template-columns: 1fr; diff --git a/web/app/page.js b/web/app/page.js index 8b928be1..610ed281 100644 --- a/web/app/page.js +++ b/web/app/page.js @@ -79,8 +79,8 @@ export default function Home() { {/* About Us */}
-

About Leon's Pet Store

-

Your trusted local destination for pet care, adoption, and supplies — built on a love for animals and community.

+

About Us

+

A full-service pet store built on a love for animals and community.

From 1972488eb064b19a8dd1bc6f9405c1dd65834d05 Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Wed, 15 Apr 2026 23:03:54 -0600 Subject: [PATCH 27/37] pet owner search --- .../java/com/petshop/backend/repository/PetRepository.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/main/java/com/petshop/backend/repository/PetRepository.java b/backend/src/main/java/com/petshop/backend/repository/PetRepository.java index 8b1aa178..d301be55 100644 --- a/backend/src/main/java/com/petshop/backend/repository/PetRepository.java +++ b/backend/src/main/java/com/petshop/backend/repository/PetRepository.java @@ -43,8 +43,8 @@ public interface PetRepository extends JpaRepository { @Query("SELECT p FROM Pet p WHERE p.id = :id") Optional findByIdForUpdate(@Param("id") Long id); - @Query("SELECT p FROM Pet p WHERE " + - "(:q IS NULL OR LOWER(p.petName) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(p.petSpecies) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(COALESCE(p.petBreed, '')) LIKE LOWER(CONCAT('%', :q, '%'))) AND " + + @Query("SELECT p FROM Pet p LEFT JOIN p.owner o WHERE " + + "(:q IS NULL OR LOWER(p.petName) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(p.petSpecies) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(COALESCE(p.petBreed, '')) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(COALESCE(o.firstName, '')) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(COALESCE(o.lastName, '')) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(CONCAT(COALESCE(o.firstName, ''), ' ', COALESCE(o.lastName, ''))) LIKE LOWER(CONCAT('%', :q, '%'))) AND " + "(:species IS NULL OR LOWER(p.petSpecies) = LOWER(:species)) AND " + "(:breed IS NULL OR LOWER(COALESCE(p.petBreed, '')) = LOWER(:breed)) AND " + "(:status IS NULL OR LOWER(p.petStatus) = LOWER(:status)) AND " + From 4402d0398f5d4c5263fce4d11fe2732f3ad73dea Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Wed, 15 Apr 2026 23:11:53 -0600 Subject: [PATCH 28/37] fix chat badge on reply --- .../org/example/petshopdesktop/api/ChatRealtimeClient.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/desktop/src/main/java/org/example/petshopdesktop/api/ChatRealtimeClient.java b/desktop/src/main/java/org/example/petshopdesktop/api/ChatRealtimeClient.java index d29d3aab..93f90a73 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/api/ChatRealtimeClient.java +++ b/desktop/src/main/java/org/example/petshopdesktop/api/ChatRealtimeClient.java @@ -90,8 +90,9 @@ public class ChatRealtimeClient implements WebSocket.Listener { for (ConversationResponse conv : globalConversations.values()) { if ("CLOSED".equals(conv.getStatus())) continue; - // Needs pickup - if (conv.getHumanRequestedAt() != null && conv.getStaffId() == null) { + // Needs pickup - only if we haven't already replied + if (conv.getHumanRequestedAt() != null && conv.getStaffId() == null + && (currentUserId == null || !currentUserId.equals(conv.getLastSenderId()))) { return true; } From 95b45c2e541c9bf9065b8eb56e71e1c9bf20c62e Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Wed, 15 Apr 2026 23:12:23 -0600 Subject: [PATCH 29/37] fix contact layout and chat ui --- web/app/ai-chat/page.js | 65 ++++++++++++++++------------- web/app/chat/page.js | 64 +++++++++++++++------------- web/app/contact/page.js | 92 ++++++++++++++++++++--------------------- web/app/globals.css | 4 ++ 4 files changed, 119 insertions(+), 106 deletions(-) diff --git a/web/app/ai-chat/page.js b/web/app/ai-chat/page.js index c059e3f4..6dbf101a 100644 --- a/web/app/ai-chat/page.js +++ b/web/app/ai-chat/page.js @@ -92,6 +92,7 @@ function AiChatPage() { const lastMessageIdRef = useRef(null); const fileInputRef = useRef(null); const lastScrolledIdRef = useRef(null); + const initialLoadDoneRef = useRef(false); useEffect(() => { if (!authLoading && !user) { @@ -103,6 +104,7 @@ function AiChatPage() { if (messages.length === 0) return; const lastMsg = messages[messages.length - 1]; if (lastMsg.id === lastScrolledIdRef.current) return; + if (!initialLoadDoneRef.current) return; lastScrolledIdRef.current = lastMsg.id; const area = messagesAreaRef.current; if (!area) return; @@ -114,6 +116,7 @@ function AiChatPage() { const fetchMessages = useCallback(async (convId) => { if (!token || !convId) return; + initialLoadDoneRef.current = false; try { const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${convId}/messages`, { headers: { Authorization: `Bearer ${token}` }, @@ -122,10 +125,17 @@ function AiChatPage() { const data = await res.json(); if (Array.isArray(data)) { setMessages(data); - if (data.length > 0) lastMessageIdRef.current = data[data.length - 1].id; + if (data.length > 0) { + lastMessageIdRef.current = data[data.length - 1].id; + lastScrolledIdRef.current = data[data.length - 1].id; + } + setTimeout(() => { + const area = messagesAreaRef.current; + if (area) area.scrollTop = area.scrollHeight; + initialLoadDoneRef.current = true; + }, 50); } } catch { - // silent } }, [token]); @@ -491,34 +501,31 @@ function AiChatPage() {
))} - {conversations.some(c => c.status === "CLOSED") && ( - <> - - {closedExpanded && conversations.filter(c => c.status === "CLOSED").map((conv) => ( - - ))} - - )} + {conversations.some(c => c.status === "CLOSED") && ( + <> + + {closedExpanded && conversations.filter(c => c.status === "CLOSED").map((conv) => ( + + ))} + + )} diff --git a/web/app/chat/page.js b/web/app/chat/page.js index 9c8e48f8..dbee3ed3 100644 --- a/web/app/chat/page.js +++ b/web/app/chat/page.js @@ -92,6 +92,7 @@ function ChatPage() { const lastMessageIdRef = useRef(null); const fileInputRef = useRef(null); const lastScrolledIdRef = useRef(null); + const initialLoadDoneRef = useRef(false); useEffect(() => { if (!authLoading && !user) { @@ -103,6 +104,7 @@ function ChatPage() { if (messages.length === 0) return; const lastMsg = messages[messages.length - 1]; if (lastMsg.id === lastScrolledIdRef.current) return; + if (!initialLoadDoneRef.current) return; lastScrolledIdRef.current = lastMsg.id; const area = messagesAreaRef.current; if (!area) return; @@ -114,6 +116,7 @@ function ChatPage() { const fetchMessages = useCallback(async (convId) => { if (!token || !convId) return; + initialLoadDoneRef.current = false; try { const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${convId}/messages`, { headers: { Authorization: `Bearer ${token}` }, @@ -128,10 +131,16 @@ function ChatPage() { if (data.length > 0) { lastMessageIdRef.current = data[data.length - 1].id; + lastScrolledIdRef.current = data[data.length - 1].id; } + setTimeout(() => { + const area = messagesAreaRef.current; + if (area) area.scrollTop = area.scrollHeight; + initialLoadDoneRef.current = true; + }, 50); } - } - + } + catch { setError("Failed to load messages."); } @@ -495,34 +504,31 @@ function ChatPage() { ))} - {conversations.some(c => c.status === "CLOSED") && ( - <> - - {closedExpanded && conversations.filter(c => c.status === "CLOSED").map((conv) => ( - - ))} - - )} + {conversations.some(c => c.status === "CLOSED") && ( + <> + + {closedExpanded && conversations.filter(c => c.status === "CLOSED").map((conv) => ( + + ))} + + )} diff --git a/web/app/contact/page.js b/web/app/contact/page.js index eb8274e9..5ae4b5e4 100644 --- a/web/app/contact/page.js +++ b/web/app/contact/page.js @@ -50,7 +50,7 @@ export default function ContactPage() { setSendSuccess(true); setSubject(""); setBody(""); - } catch (err) { + } catch { setSendError("Failed to send message. Please try again."); } finally { setSending(false); @@ -61,67 +61,63 @@ export default function ContactPage() {

Contact Us

-

Reach the team, find a location, or connect with store personnel.

+

Reach the team, find a location, or send us a message.

-
+
-

General Contact

+

Get in Touch

Email: hello@leonspetstore.com.au

Phone: (03) 9000 0000

Hours: Mon–Sat, 9:00 AM – 6:00 PM

-
- {token && ( -
-

Send Us a Message

- {sendSuccess ? ( -

Your message has been sent. We'll be in touch soon.

- ) : ( -
- -