diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 42c41a71..233f49b5 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 @@ -46,8 +74,21 @@ jobs: context: ./web push: true tags: ${{ env.FRONTEND_IMAGE }}:latest - build-args: | - NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${{ secrets.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY }} + no-cache: true + + 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 @@ -61,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 }} diff --git a/android/app/src/main/java/com/example/petstoremobile/viewmodels/ActivityLogListViewModel.java b/android/app/src/main/java/com/example/petstoremobile/viewmodels/ActivityLogListViewModel.java index d944de83..aea604b3 100644 --- a/android/app/src/main/java/com/example/petstoremobile/viewmodels/ActivityLogListViewModel.java +++ b/android/app/src/main/java/com/example/petstoremobile/viewmodels/ActivityLogListViewModel.java @@ -8,6 +8,8 @@ import androidx.lifecycle.ViewModel; import com.example.petstoremobile.dtos.ActivityLogDTO; import com.example.petstoremobile.dtos.DropdownDTO; import com.example.petstoremobile.repositories.ActivityLogRepository; + +import java.util.Locale; import com.example.petstoremobile.repositories.StoreRepository; import com.example.petstoremobile.utils.Resource; @@ -89,7 +91,7 @@ public class ActivityLogListViewModel extends ViewModel { } public void setRoleFilter(String role) { - currentRole = "All Roles".equals(role) ? null : role; + currentRole = (role == null || "All Roles".equals(role)) ? null : role.toUpperCase(Locale.ROOT); loadLogs(true); } 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/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/backend-test-results.txt b/backend/backend-test-results.txt new file mode 100644 index 00000000..4f61b2fb --- /dev/null +++ b/backend/backend-test-results.txt @@ -0,0 +1,145 @@ +WARNING: A terminally deprecated method in sun.misc.Unsafe has been called +WARNING: sun.misc.Unsafe::staticFieldBase has been called by com.google.inject.internal.aop.HiddenClassDefiner (file:/nix/store/snv87hz5j78nqiqqamlf1mimbkmcrl6l-maven-3.9.11/maven/lib/guice-5.1.0-classes.jar) +WARNING: Please consider reporting this to the maintainers of class com.google.inject.internal.aop.HiddenClassDefiner +WARNING: sun.misc.Unsafe::staticFieldBase will be removed in a future release +[INFO] Scanning for projects... +[INFO] +[INFO] ------------------------< com.petshop:backend >------------------------- +[INFO] Building PetShop Backend 1.0.0 +[INFO] from pom.xml +[INFO] --------------------------------[ jar ]--------------------------------- +[INFO] +[INFO] --- enforcer:3.5.0:enforce (require-java-25) @ backend --- +[INFO] Rule 0: org.apache.maven.enforcer.rules.version.RequireJavaVersion passed +[INFO] +[INFO] --- resources:3.3.1:resources (default-resources) @ backend --- +[INFO] Copying 2 resources from src/main/resources to target/classes +[INFO] Copying 14 resources from src/main/resources to target/classes +[INFO] +[INFO] --- compiler:3.14.1:compile (default-compile) @ backend --- +[INFO] Nothing to compile - all classes are up to date. +[INFO] +[INFO] --- resources:3.3.1:testResources (default-testResources) @ backend --- +[INFO] skip non existing resourceDirectory /home/user/threaded-parity/backend/src/test/resources +[INFO] +[INFO] --- compiler:3.14.1:testCompile (default-testCompile) @ backend --- +[INFO] Nothing to compile - all classes are up to date. +[INFO] +[INFO] --- surefire:3.5.4:test (default-test) @ backend --- +[INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider +[INFO] +[INFO] ------------------------------------------------------- +[INFO] T E S T S +[INFO] ------------------------------------------------------- +[INFO] Running com.petshop.backend.service.UserServiceTest +Mockito is currently self-attaching to enable the inline-mock-maker. This will no longer work in future releases of the JDK. Please add Mockito as an agent to your build as described in Mockito's documentation: https://javadoc.io/doc/org.mockito/mockito-core/latest/org.mockito/org/mockito/Mockito.html#0.3 +OpenJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended +WARNING: A Java agent has been loaded dynamically (/home/user/.m2/repository/net/bytebuddy/byte-buddy-agent/1.17.8/byte-buddy-agent-1.17.8.jar) +WARNING: If a serviceability tool is in use, please run with -XX:+EnableDynamicAgentLoading to hide this warning +WARNING: If a serviceability tool is not in use, please run with -Djdk.instrument.traceUsage for more information +WARNING: Dynamic loading of agents will be disallowed by default in a future release +[ERROR] Tests run: 10, Failures: 3, Errors: 2, Skipped: 0, Time elapsed: 0.624 s <<< FAILURE! -- in com.petshop.backend.service.UserServiceTest +[ERROR] com.petshop.backend.service.UserServiceTest.scopedUpdateDeniesRoleEscalation -- Time elapsed: 0.007 s <<< FAILURE! +org.opentest4j.AssertionFailedError: Unexpected exception type thrown, expected: but was: + at org.junit.jupiter.api.AssertionFailureBuilder.build(AssertionFailureBuilder.java:158) + at org.junit.jupiter.api.AssertThrows.assertThrows(AssertThrows.java:68) + at org.junit.jupiter.api.AssertThrows.assertThrows(AssertThrows.java:35) + at org.junit.jupiter.api.Assertions.assertThrows(Assertions.java:3223) + at com.petshop.backend.service.UserServiceTest.scopedUpdateDeniesRoleEscalation(UserServiceTest.java:181) +Caused by: com.petshop.backend.exception.ResourceNotFoundException: User not found with id: 2 + at com.petshop.backend.service.UserService.lambda$updateUser$0(UserService.java:113) + at java.base/java.util.Optional.orElseThrow(Optional.java:403) + at com.petshop.backend.service.UserService.updateUser(UserService.java:113) + at com.petshop.backend.service.UserServiceTest.lambda$scopedUpdateDeniesRoleEscalation$0(UserServiceTest.java:181) + at org.junit.jupiter.api.AssertThrows.assertThrows(AssertThrows.java:54) + ... 3 more + +[ERROR] com.petshop.backend.service.UserServiceTest.updateUserTreatsWrongScopedRoleAsNotFound -- Time elapsed: 0.005 s <<< ERROR! +org.mockito.exceptions.misusing.UnnecessaryStubbingException: + +Unnecessary stubbings detected. +Clean & maintainable test code requires zero unnecessary code. +Following stubbings are unnecessary (click to navigate to relevant line of code): + 1. -> at com.petshop.backend.service.UserServiceTest.updateUserTreatsWrongScopedRoleAsNotFound(UserServiceTest.java:75) +Please remove unnecessary stubbings or use 'lenient' strictness. More info: javadoc for UnnecessaryStubbingException class. + at org.mockito.junit.jupiter.MockitoExtension.lambda$afterEach$2(MockitoExtension.java:200) + at java.base/java.util.Optional.ifPresent(Optional.java:178) + at org.mockito.junit.jupiter.MockitoExtension.afterEach(MockitoExtension.java:198) + at java.base/java.util.ArrayList.forEach(ArrayList.java:1604) + at java.base/java.util.ArrayList.forEach(ArrayList.java:1604) + +[ERROR] com.petshop.backend.service.UserServiceTest.updateUserDeniesPromotingAnotherUserToAdmin -- Time elapsed: 0.004 s <<< FAILURE! +org.opentest4j.AssertionFailedError: Unexpected exception type thrown, expected: but was: + at org.junit.jupiter.api.AssertionFailureBuilder.build(AssertionFailureBuilder.java:158) + at org.junit.jupiter.api.AssertThrows.assertThrows(AssertThrows.java:68) + at org.junit.jupiter.api.AssertThrows.assertThrows(AssertThrows.java:35) + at org.junit.jupiter.api.Assertions.assertThrows(Assertions.java:3223) + at com.petshop.backend.service.UserServiceTest.updateUserDeniesPromotingAnotherUserToAdmin(UserServiceTest.java:167) +Caused by: com.petshop.backend.exception.ResourceNotFoundException: User not found with id: 2 + at com.petshop.backend.service.UserService.lambda$updateUser$0(UserService.java:113) + at java.base/java.util.Optional.orElseThrow(Optional.java:403) + at com.petshop.backend.service.UserService.updateUser(UserService.java:113) + at com.petshop.backend.service.UserService.updateUser(UserService.java:107) + at com.petshop.backend.service.UserServiceTest.lambda$updateUserDeniesPromotingAnotherUserToAdmin$0(UserServiceTest.java:167) + at org.junit.jupiter.api.AssertThrows.assertThrows(AssertThrows.java:54) + ... 3 more + +[ERROR] com.petshop.backend.service.UserServiceTest.updateUserAllowsEditingOwnAdminAccount -- Time elapsed: 0.003 s <<< ERROR! +com.petshop.backend.exception.ResourceNotFoundException: User not found with id: 1 + at com.petshop.backend.service.UserService.lambda$updateUser$0(UserService.java:113) + at java.base/java.util.Optional.orElseThrow(Optional.java:403) + at com.petshop.backend.service.UserService.updateUser(UserService.java:113) + at com.petshop.backend.service.UserService.updateUser(UserService.java:107) + at com.petshop.backend.service.UserServiceTest.updateUserAllowsEditingOwnAdminAccount(UserServiceTest.java:152) + +[ERROR] com.petshop.backend.service.UserServiceTest.updateUserDeniesEditingAnotherAdmin -- Time elapsed: 0.005 s <<< FAILURE! +org.opentest4j.AssertionFailedError: Unexpected exception type thrown, expected: but was: + at org.junit.jupiter.api.AssertionFailureBuilder.build(AssertionFailureBuilder.java:158) + at org.junit.jupiter.api.AssertThrows.assertThrows(AssertThrows.java:68) + at org.junit.jupiter.api.AssertThrows.assertThrows(AssertThrows.java:35) + at org.junit.jupiter.api.Assertions.assertThrows(Assertions.java:3223) + at com.petshop.backend.service.UserServiceTest.updateUserDeniesEditingAnotherAdmin(UserServiceTest.java:65) +Caused by: com.petshop.backend.exception.ResourceNotFoundException: User not found with id: 2 + at com.petshop.backend.service.UserService.lambda$updateUser$0(UserService.java:113) + at java.base/java.util.Optional.orElseThrow(Optional.java:403) + at com.petshop.backend.service.UserService.updateUser(UserService.java:113) + at com.petshop.backend.service.UserService.updateUser(UserService.java:107) + at com.petshop.backend.service.UserServiceTest.lambda$updateUserDeniesEditingAnotherAdmin$0(UserServiceTest.java:65) + at org.junit.jupiter.api.AssertThrows.assertThrows(AssertThrows.java:54) + ... 3 more + +[INFO] +[INFO] Results: +[INFO] +[ERROR] Failures: +[ERROR] UserServiceTest.scopedUpdateDeniesRoleEscalation:181 Unexpected exception type thrown, expected: but was: +[ERROR] UserServiceTest.updateUserDeniesEditingAnotherAdmin:65 Unexpected exception type thrown, expected: but was: +[ERROR] UserServiceTest.updateUserDeniesPromotingAnotherUserToAdmin:167 Unexpected exception type thrown, expected: but was: +[ERROR] Errors: +[ERROR] UserServiceTest.updateUserAllowsEditingOwnAdminAccount:152 » ResourceNotFound User not found with id: 1 +[ERROR] UserServiceTest.updateUserTreatsWrongScopedRoleAsNotFound » UnnecessaryStubbing +Unnecessary stubbings detected. +Clean & maintainable test code requires zero unnecessary code. +Following stubbings are unnecessary (click to navigate to relevant line of code): + 1. -> at com.petshop.backend.service.UserServiceTest.updateUserTreatsWrongScopedRoleAsNotFound(UserServiceTest.java:75) +Please remove unnecessary stubbings or use 'lenient' strictness. More info: javadoc for UnnecessaryStubbingException class. +[INFO] +[ERROR] Tests run: 10, Failures: 3, Errors: 2, Skipped: 0 +[INFO] +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD FAILURE +[INFO] ------------------------------------------------------------------------ +[INFO] Total time: 2.040 s +[INFO] Finished at: 2026-04-16T08:04:25-06:00 +[INFO] ------------------------------------------------------------------------ +[ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:3.5.4:test (default-test) on project backend: There are test failures. +[ERROR] +[ERROR] See /home/user/threaded-parity/backend/target/surefire-reports for the individual test results. +[ERROR] See dump files (if any exist) [date].dump, [date]-jvmRun[N].dump and [date].dumpstream. +[ERROR] -> [Help 1] +[ERROR] +[ERROR] To see the full stack trace of the errors, re-run Maven with the -e switch. +[ERROR] Re-run Maven using the -X switch to enable full debug logging. +[ERROR] +[ERROR] For more information about the errors and possible solutions, please read the following articles: +[ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/MojoFailureException 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/ActivityLoggingFilterRegistrationConfig.java b/backend/src/main/java/com/petshop/backend/config/ActivityLoggingFilterRegistrationConfig.java index 17f6fe2d..66427661 100644 --- a/backend/src/main/java/com/petshop/backend/config/ActivityLoggingFilterRegistrationConfig.java +++ b/backend/src/main/java/com/petshop/backend/config/ActivityLoggingFilterRegistrationConfig.java @@ -10,7 +10,7 @@ public class ActivityLoggingFilterRegistrationConfig { @Bean public FilterRegistrationBean activityLoggingFilterRegistration(ActivityLoggingFilter activityLoggingFilter) { FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(activityLoggingFilter); - registrationBean.setEnabled(false); + registrationBean.setEnabled(true); return registrationBean; } } 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/ActivityLogController.java b/backend/src/main/java/com/petshop/backend/controller/ActivityLogController.java new file mode 100644 index 00000000..6d202ddb --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/controller/ActivityLogController.java @@ -0,0 +1,37 @@ +package com.petshop.backend.controller; + +import com.petshop.backend.dto.activity.ActivityLogResponse; +import com.petshop.backend.service.ActivityLogService; +import java.time.LocalDate; +import java.util.List; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/activity-logs") +@PreAuthorize("hasRole('ADMIN')") +public class ActivityLogController { + + private final ActivityLogService activityLogService; + + public ActivityLogController(ActivityLogService activityLogService) { + this.activityLogService = activityLogService; + } + + @GetMapping + public ResponseEntity> getActivityLogs( + @RequestParam(defaultValue = "2000") int limit, + @RequestParam(required = false) Long storeId, + @RequestParam(required = false) String role, + @RequestParam(required = false) String search, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) { + int safeLimit = Math.min(Math.max(1, limit), 10000); + return ResponseEntity.ok(activityLogService.getLogs(safeLimit, storeId, role, search, startDate, endDate)); + } +} 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/controller/SaleController.java b/backend/src/main/java/com/petshop/backend/controller/SaleController.java index dffa63e4..bc40b066 100644 --- a/backend/src/main/java/com/petshop/backend/controller/SaleController.java +++ b/backend/src/main/java/com/petshop/backend/controller/SaleController.java @@ -3,6 +3,7 @@ package com.petshop.backend.controller; import com.petshop.backend.dto.sale.SaleRequest; import com.petshop.backend.dto.sale.SaleResponse; import com.petshop.backend.service.SaleService; +import com.petshop.backend.util.AuthenticationHelper; import jakarta.validation.Valid; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -21,6 +22,13 @@ public class SaleController { this.saleService = saleService; } + @GetMapping("/my") + @PreAuthorize("hasAnyRole('CUSTOMER', 'ADMIN')") + public ResponseEntity> getMyOrders(Pageable pageable) { + Long userId = AuthenticationHelper.getAuthenticatedUserId(); + return ResponseEntity.ok(saleService.getAllSales(null, null, null, false, userId, pageable)); + } + @GetMapping @PreAuthorize("hasAnyRole('STAFF', 'ADMIN')") public ResponseEntity> getAllSales( diff --git a/backend/src/main/java/com/petshop/backend/dto/activity/ActivityLogResponse.java b/backend/src/main/java/com/petshop/backend/dto/activity/ActivityLogResponse.java new file mode 100644 index 00000000..3d5bc890 --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/dto/activity/ActivityLogResponse.java @@ -0,0 +1,126 @@ +package com.petshop.backend.dto.activity; + +import java.time.LocalDateTime; + +public class ActivityLogResponse { + private Long logId; + private Long userId; + private String username; + private String fullName; + private String role; + private Long storeId; + private String storeName; + private String usernameSnapshot; + private String fullNameSnapshot; + private String roleSnapshot; + private String storeNameSnapshot; + private String activity; + private LocalDateTime logTimestamp; + + public ActivityLogResponse() { + } + + public Long getLogId() { + return logId; + } + + public void setLogId(Long logId) { + this.logId = logId; + } + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getFullName() { + return fullName; + } + + public void setFullName(String fullName) { + this.fullName = fullName; + } + + public String getRole() { + return role; + } + + public void setRole(String role) { + this.role = role; + } + + public Long getStoreId() { + return storeId; + } + + public void setStoreId(Long storeId) { + this.storeId = storeId; + } + + public String getStoreName() { + return storeName; + } + + public void setStoreName(String storeName) { + this.storeName = storeName; + } + + public String getUsernameSnapshot() { + return usernameSnapshot; + } + + public void setUsernameSnapshot(String usernameSnapshot) { + this.usernameSnapshot = usernameSnapshot; + } + + public String getFullNameSnapshot() { + return fullNameSnapshot; + } + + public void setFullNameSnapshot(String fullNameSnapshot) { + this.fullNameSnapshot = fullNameSnapshot; + } + + public String getRoleSnapshot() { + return roleSnapshot; + } + + public void setRoleSnapshot(String roleSnapshot) { + this.roleSnapshot = roleSnapshot; + } + + public String getStoreNameSnapshot() { + return storeNameSnapshot; + } + + public void setStoreNameSnapshot(String storeNameSnapshot) { + this.storeNameSnapshot = storeNameSnapshot; + } + + public String getActivity() { + return activity; + } + + public void setActivity(String activity) { + this.activity = activity; + } + + public LocalDateTime getLogTimestamp() { + return logTimestamp; + } + + public void setLogTimestamp(LocalDateTime logTimestamp) { + this.logTimestamp = logTimestamp; + } +} diff --git a/backend/src/main/java/com/petshop/backend/dto/chat/ConversationRequest.java b/backend/src/main/java/com/petshop/backend/dto/chat/ConversationRequest.java index a6ef1db1..8677865a 100644 --- a/backend/src/main/java/com/petshop/backend/dto/chat/ConversationRequest.java +++ b/backend/src/main/java/com/petshop/backend/dto/chat/ConversationRequest.java @@ -1,9 +1,6 @@ package com.petshop.backend.dto.chat; -import jakarta.validation.constraints.NotBlank; - public class ConversationRequest { - @NotBlank(message = "Initial message is required") private String message; public ConversationRequest() { diff --git a/backend/src/main/java/com/petshop/backend/entity/ActivityLog.java b/backend/src/main/java/com/petshop/backend/entity/ActivityLog.java new file mode 100644 index 00000000..b445b7c4 --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/entity/ActivityLog.java @@ -0,0 +1,148 @@ +package com.petshop.backend.entity; + +import jakarta.persistence.*; +import org.hibernate.annotations.Immutable; + +import java.time.LocalDateTime; +import java.util.Objects; + +@Entity +@Immutable +@Table(name = "activityLog") +public class ActivityLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long logId; + + @ManyToOne + @JoinColumn(name = "userId", nullable = false) + private User user; + + @ManyToOne + @JoinColumn(name = "storeId") + private StoreLocation store; + + @Column(length = 50) + private String usernameSnapshot; + + @Column(length = 100) + private String fullNameSnapshot; + + @Column(length = 20) + private String roleSnapshot; + + @Column(length = 100) + private String storeNameSnapshot; + + @Column(nullable = false, columnDefinition = "TEXT") + private String activity; + + @Column(nullable = false) + private LocalDateTime logTimestamp = LocalDateTime.now(); + + public ActivityLog() { + } + + public ActivityLog(Long logId, User user, String activity, LocalDateTime logTimestamp) { + this.logId = logId; + this.user = user; + this.activity = activity; + this.logTimestamp = logTimestamp; + } + + public Long getLogId() { + return logId; + } + + public void setLogId(Long logId) { + this.logId = logId; + } + + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + } + + public StoreLocation getStore() { + return store; + } + + public void setStore(StoreLocation store) { + this.store = store; + } + + public String getUsernameSnapshot() { + return usernameSnapshot; + } + + public void setUsernameSnapshot(String usernameSnapshot) { + this.usernameSnapshot = usernameSnapshot; + } + + public String getFullNameSnapshot() { + return fullNameSnapshot; + } + + public void setFullNameSnapshot(String fullNameSnapshot) { + this.fullNameSnapshot = fullNameSnapshot; + } + + public String getRoleSnapshot() { + return roleSnapshot; + } + + public void setRoleSnapshot(String roleSnapshot) { + this.roleSnapshot = roleSnapshot; + } + + public String getStoreNameSnapshot() { + return storeNameSnapshot; + } + + public void setStoreNameSnapshot(String storeNameSnapshot) { + this.storeNameSnapshot = storeNameSnapshot; + } + + public String getActivity() { + return activity; + } + + public void setActivity(String activity) { + this.activity = activity; + } + + public LocalDateTime getLogTimestamp() { + return logTimestamp; + } + + public void setLogTimestamp(LocalDateTime logTimestamp) { + this.logTimestamp = logTimestamp; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ActivityLog that = (ActivityLog) o; + return Objects.equals(logId, that.logId); + } + + @Override + public int hashCode() { + return Objects.hash(logId); + } + + @Override + public String toString() { + return "ActivityLog{" + + "logId=" + logId + + ", user=" + user + + ", activity='" + activity + '\'' + + ", logTimestamp=" + logTimestamp + + '}'; + } +} diff --git a/backend/src/main/java/com/petshop/backend/repository/ActivityLogRepository.java b/backend/src/main/java/com/petshop/backend/repository/ActivityLogRepository.java new file mode 100644 index 00000000..09bf817a --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/repository/ActivityLogRepository.java @@ -0,0 +1,18 @@ +package com.petshop.backend.repository; + +import com.petshop.backend.entity.ActivityLog; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface ActivityLogRepository extends JpaRepository, JpaSpecificationExecutor { + boolean existsByUser_Id(Long userId); + + @Query("select a from ActivityLog a order by a.logTimestamp desc, a.logId desc") + List findRecent(Pageable pageable); +} 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 e294898f..c5f8d73a 100644 --- a/backend/src/main/java/com/petshop/backend/repository/PetRepository.java +++ b/backend/src/main/java/com/petshop/backend/repository/PetRepository.java @@ -41,11 +41,11 @@ 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 " + - "(: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 " + 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/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/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..a3880071 100644 --- a/backend/src/main/java/com/petshop/backend/service/ActivityLogService.java +++ b/backend/src/main/java/com/petshop/backend/service/ActivityLogService.java @@ -1,38 +1,65 @@ package com.petshop.backend.service; +import com.petshop.backend.dto.activity.ActivityLogResponse; +import com.petshop.backend.entity.ActivityLog; import com.petshop.backend.entity.StoreLocation; import com.petshop.backend.entity.User; +import com.petshop.backend.repository.ActivityLogRepository; import com.petshop.backend.repository.UserRepository; +import jakarta.persistence.criteria.Predicate; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; @Service public class ActivityLogService { - private static final Logger log = LoggerFactory.getLogger("activity"); + private static final Logger log = LoggerFactory.getLogger(ActivityLogService.class); + private final ActivityLogRepository activityLogRepository; private final UserRepository userRepository; - public ActivityLogService(UserRepository userRepository) { + public ActivityLogService(ActivityLogRepository activityLogRepository, UserRepository userRepository) { + this.activityLogRepository = activityLogRepository; this.userRepository = userRepository; } + @Transactional public void record(Long userId, String activity) { if (userId == null || activity == null || activity.isBlank()) { return; } + try { - User user = userRepository.findById(userId).orElse(null); - if (user == null) { + User managedUser = userRepository.findById(userId).orElse(null); + if (managedUser == null) { return; } - StoreLocation store = user.getPrimaryStore(); - String role = user.getRole() != null ? user.getRole().name() : "UNKNOWN"; - String storeName = store != null ? store.getStoreName() : "no store"; - log.info("{} | {} | {} | {}", role, user.getUsername(), storeName, activity.trim()); + StoreLocation store = managedUser.getPrimaryStore(); + ActivityLog entry = new ActivityLog(); + entry.setUser(managedUser); + entry.setStore(store); + entry.setUsernameSnapshot(managedUser.getUsername()); + entry.setFullNameSnapshot(resolveFullName(managedUser)); + entry.setRoleSnapshot(managedUser.getRole() != null ? managedUser.getRole().name() : null); + entry.setStoreNameSnapshot(store != null ? store.getStoreName() : null); + entry.setActivity(activity.trim()); + activityLogRepository.save(entry); + log.info("[ACTIVITY] {} | {} | {} | {}", + entry.getRoleSnapshot(), + entry.getUsernameSnapshot(), + entry.getStoreNameSnapshot() != null ? entry.getStoreNameSnapshot() : "no store", + entry.getActivity()); } catch (Exception ex) { - log.warn("Failed to record activity", ex); + log.warn("Failed to persist activity log", ex); } } @@ -42,4 +69,106 @@ public class ActivityLogService { } record(user.getId(), activity); } + + @Transactional(readOnly = true) + public List getLogs(int limit, Long storeId, String role, String search, LocalDate startDate, LocalDate endDate) { + Specification spec = (root, query, cb) -> { + List predicates = new ArrayList<>(); + + if (storeId != null) { + predicates.add(cb.equal(root.get("store").get("storeId"), storeId)); + } + + if (role != null && !role.isBlank()) { + predicates.add(cb.equal(root.get("roleSnapshot"), role)); + } + + if (search != null && !search.isBlank()) { + String pattern = "%" + search.toLowerCase() + "%"; + Predicate searchPredicate = cb.or( + cb.like(cb.lower(root.get("activity")), pattern), + cb.like(cb.lower(root.get("fullNameSnapshot")), pattern), + cb.like(cb.lower(root.get("usernameSnapshot")), pattern) + ); + predicates.add(searchPredicate); + } + + if (startDate != null) { + predicates.add(cb.greaterThanOrEqualTo(root.get("logTimestamp"), startDate.atStartOfDay())); + } + + if (endDate != null) { + predicates.add(cb.lessThan(root.get("logTimestamp"), endDate.plusDays(1).atStartOfDay())); + } + + return cb.and(predicates.toArray(new Predicate[0])); + }; + + PageRequest pageRequest = PageRequest.of(0, limit, Sort.by(Sort.Direction.DESC, "logTimestamp", "logId")); + return activityLogRepository.findAll(spec, pageRequest).stream() + .map(this::toResponse) + .toList(); + } + + @Transactional(readOnly = true) + public List getLogs(int limit, Long storeId, String role, String search) { + return getLogs(limit, storeId, role, search, null, null); + } + + @Transactional(readOnly = true) + public List getLogs(int limit) { + return getLogs(limit, null, null, null, null, null); + } + + private ActivityLogResponse toResponse(ActivityLog entry) { + ActivityLogResponse response = new ActivityLogResponse(); + response.setLogId(entry.getLogId()); + + if (entry.getUser() != null) { + response.setUserId(entry.getUser().getId()); + response.setUsername(firstNonBlank(entry.getUsernameSnapshot(), entry.getUser().getUsername())); + response.setFullName(firstNonBlank(entry.getFullNameSnapshot(), resolveFullName(entry.getUser()))); + response.setRole(firstNonBlank(entry.getRoleSnapshot(), entry.getUser().getRole() != null ? entry.getUser().getRole().name() : null)); + } + + StoreLocation store = entry.getStore(); + if (store != null) { + response.setStoreId(store.getStoreId()); + response.setStoreName(firstNonBlank(entry.getStoreNameSnapshot(), store.getStoreName())); + } + + response.setUsernameSnapshot(entry.getUsernameSnapshot()); + response.setFullNameSnapshot(entry.getFullNameSnapshot()); + response.setRoleSnapshot(entry.getRoleSnapshot()); + response.setStoreNameSnapshot(entry.getStoreNameSnapshot()); + + response.setActivity(entry.getActivity()); + response.setLogTimestamp(entry.getLogTimestamp()); + return response; + } + + private String resolveFullName(User user) { + if (user == null) { + return null; + } + if (user.getFullName() != null && !user.getFullName().isBlank()) { + return user.getFullName(); + } + String first = user.getFirstName(); + String last = user.getLastName(); + if (first == null || first.isBlank()) { + return last; + } + if (last == null || last.isBlank()) { + return first; + } + return first.trim() + " " + last.trim(); + } + + private String firstNonBlank(String preferred, String fallback) { + if (preferred != null && !preferred.isBlank()) { + return preferred; + } + return fallback; + } } 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 efc5d2c6..315fe509 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); @@ -398,32 +396,6 @@ public class AppointmentService { } } - 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/java/com/petshop/backend/service/ChatService.java b/backend/src/main/java/com/petshop/backend/service/ChatService.java index 138b5006..620f6130 100644 --- a/backend/src/main/java/com/petshop/backend/service/ChatService.java +++ b/backend/src/main/java/com/petshop/backend/service/ChatService.java @@ -59,14 +59,20 @@ public class ChatService { conversation.setMode(Conversation.ConversationMode.AUTOMATED); conversation = conversationRepository.save(conversation); - Message message = new Message(); - message.setConversationId(conversation.getId()); - message.setSenderId(userId); - message.setContent(request.getMessage()); - message.setIsRead(false); - messageRepository.save(message); + User botUser = getBotUser(); + String firstName = user.getFirstName(); + String greeting = (firstName != null && !firstName.isBlank()) + ? "Hi " + firstName + "! I'm Leon's Pet Assistant. Ask me anything about pet care, adoption advice, or your pets." + : "Hi! I'm Leon's Pet Assistant. Ask me anything about pet care, adoption advice, or your pets."; - return ConversationResponse.fromEntity(conversation, request.getMessage(), userId); + Message greetingMsg = new Message(); + greetingMsg.setConversationId(conversation.getId()); + greetingMsg.setSenderId(botUser.getId()); + greetingMsg.setContent(greeting); + greetingMsg.setIsRead(false); + messageRepository.save(greetingMsg); + + return ConversationResponse.fromEntity(conversation, greeting, botUser.getId()); } public List getConversations(Long userId, User.Role role, boolean mine) { diff --git a/backend/src/main/java/com/petshop/backend/service/OpenRouterAiService.java b/backend/src/main/java/com/petshop/backend/service/OpenRouterAiService.java index 026b9a3f..d2e007dc 100644 --- a/backend/src/main/java/com/petshop/backend/service/OpenRouterAiService.java +++ b/backend/src/main/java/com/petshop/backend/service/OpenRouterAiService.java @@ -5,8 +5,10 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.json.JsonMapper; import com.petshop.backend.entity.Conversation; import com.petshop.backend.entity.Message; +import com.petshop.backend.entity.Pet; import com.petshop.backend.entity.User; import com.petshop.backend.repository.MessageRepository; +import com.petshop.backend.repository.PetRepository; import com.petshop.backend.repository.UserRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -31,7 +33,7 @@ public class OpenRouterAiService { @Value("${openrouter.api-key:}") private String apiKey; - @Value("${openrouter.model:openai/gpt-oss-120b:free}") + @Value("${openrouter.model:google/gemma-4-31b-it:free}") private String model; private final String openRouterUrl = "https://openrouter.ai/api/v1/chat/completions"; @@ -39,6 +41,7 @@ public class OpenRouterAiService { private final ChatRealtimeService chatRealtimeService; private final MessageRepository messageRepository; private final UserRepository userRepository; + private final PetRepository petRepository; private final ObjectMapper objectMapper; private final HttpClient httpClient; @@ -46,12 +49,14 @@ public class OpenRouterAiService { ChatService chatService, ChatRealtimeService chatRealtimeService, MessageRepository messageRepository, - UserRepository userRepository + UserRepository userRepository, + PetRepository petRepository ) { this.chatService = chatService; this.chatRealtimeService = chatRealtimeService; this.messageRepository = messageRepository; this.userRepository = userRepository; + this.petRepository = petRepository; this.objectMapper = JsonMapper.builder().findAndAddModules().build(); this.httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) @@ -117,10 +122,15 @@ public class OpenRouterAiService { return; } + User customer = userRepository.findById(conversation.getCustomerId()).orElse(null); + List customerPets = customer != null + ? petRepository.findAllByOwner_IdOrderByPetNameAsc(customer.getId()) + : List.of(); + List> messages = new ArrayList<>(); messages.add(Map.of( "role", "system", - "content", "You are a helpful pet shop assistant. Provide concise and friendly answers. Do not output markdown, just plain text." + "content", buildSystemPrompt(customer, customerPets) )); for (Message message : history) { @@ -177,6 +187,43 @@ public class OpenRouterAiService { } } + private String buildSystemPrompt(User customer, List pets) { + StringBuilder sb = new StringBuilder(); + sb.append("You are Leon's Pet Assistant, a helpful AI for Leon's Pet Store. "); + sb.append("Be concise, friendly, and focused on pet care, adoption, products, and appointments. "); + sb.append("Do not output markdown, just plain text.\n\n"); + + if (customer != null) { + sb.append("Customer profile:\n"); + sb.append("- Name: ").append(customer.getFirstName()).append(" ").append(customer.getLastName()).append("\n"); + if (customer.getLoyaltyPoints() != null) { + sb.append("- Loyalty points: ").append(customer.getLoyaltyPoints()).append("\n"); + } + if (customer.getPrimaryStore() != null && customer.getPrimaryStore().getStoreName() != null) { + sb.append("- Preferred store: ").append(customer.getPrimaryStore().getStoreName()).append("\n"); + } + sb.append("\n"); + } + + if (pets != null && !pets.isEmpty()) { + sb.append("Their registered pets:\n"); + for (Pet pet : pets) { + sb.append("- ").append(pet.getPetName()).append(" (").append(pet.getPetSpecies()); + if (pet.getPetBreed() != null && !pet.getPetBreed().isBlank()) { + sb.append(", ").append(pet.getPetBreed()); + } + if (pet.getPetAge() != null) { + sb.append(", ").append(pet.getPetAge()).append(" yr"); + } + sb.append(")\n"); + } + } else { + sb.append("They have no pets registered yet.\n"); + } + + return sb.toString(); + } + private String resolveRole(Message message, Long botUserId) { if (message.getSenderId() != null && message.getSenderId().equals(botUserId)) { return "assistant"; diff --git a/backend/src/main/java/com/petshop/backend/service/OpenRouterService.java b/backend/src/main/java/com/petshop/backend/service/OpenRouterService.java index 6f851bfd..a70ece30 100644 --- a/backend/src/main/java/com/petshop/backend/service/OpenRouterService.java +++ b/backend/src/main/java/com/petshop/backend/service/OpenRouterService.java @@ -24,7 +24,7 @@ public class OpenRouterService { @Value("${openrouter.api-key:}") private String apiKey; - @Value("${openrouter.model:meta-llama/llama-3.3-70b-instruct:free}") + @Value("${openrouter.model:google/gemma-4-31b-it:free}") private String model; private final ObjectMapper objectMapper = new ObjectMapper(); 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..e3256221 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: @@ -46,14 +50,16 @@ server: springdoc: api-docs: path: /v3/api-docs + enabled: ${SWAGGER_ENABLED:false} swagger-ui: path: /swagger-ui + enabled: ${SWAGGER_ENABLED:false} 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: @@ -82,9 +88,3 @@ logging: com.petshop: ${LOG_LEVEL:INFO} org.springframework.security: ${LOG_LEVEL_SECURITY:WARN} org.springdoc.core.events.SpringDocAppInitializer: ERROR - - jackson: - serialization: - write-dates-as-timestamps: false - deserialization: - fail-on-unknown-properties: false 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/backend/src/main/resources/db/migration/V8__seed_activity_logs.sql b/backend/src/main/resources/db/migration/V8__seed_activity_logs.sql new file mode 100644 index 00000000..aa3fa6d1 --- /dev/null +++ b/backend/src/main/resources/db/migration/V8__seed_activity_logs.sql @@ -0,0 +1,90 @@ +INSERT INTO activityLog (userId, storeId, usernameSnapshot, fullNameSnapshot, roleSnapshot, storeNameSnapshot, activity, logTimestamp) VALUES +(1, 1, 'admin', 'Admin User', 'ADMIN', 'Downtown Branch', 'Logged in | POST /api/v1/auth/login → 200', '2026-01-15 08:02:11'), +(1, 1, 'admin', 'Admin User', 'ADMIN', 'Downtown Branch', 'Created a new pet | POST /api/v1/pets → 201', '2026-01-15 08:15:44'), +(1, 1, 'admin', 'Admin User', 'ADMIN', 'Downtown Branch', 'Created a new product | POST /api/v1/products → 201', '2026-01-15 08:31:07'), +(3, 1, 'staff', 'Staff User', 'STAFF', 'Downtown Branch', 'Logged in | POST /api/v1/auth/login → 200', '2026-01-15 09:00:00'), +(4, 1, 'sara.smith', 'Sara Smith', 'STAFF', 'Downtown Branch', 'Logged in | POST /api/v1/auth/login → 200', '2026-01-15 09:04:22'), +(15, NULL,'customer', 'Test Customer', 'CUSTOMER', NULL, 'Logged in | POST /api/v1/auth/login → 200', '2026-01-15 10:12:33'), +(15, NULL,'customer', 'Test Customer', 'CUSTOMER', NULL, 'Added an item to cart | POST /api/v1/cart/add → 200', '2026-01-15 10:18:05'), +(15, NULL,'customer', 'Test Customer', 'CUSTOMER', NULL, 'Completed a purchase | POST /api/v1/cart/checkout/complete → 200', '2026-01-15 10:25:50'), +(4, 1, 'sara.smith', 'Sara Smith', 'STAFF', 'Downtown Branch', 'Updated appointment #12 | PUT /api/v1/appointments/12 → 200', '2026-01-16 11:05:30'), +(1, 1, 'admin', 'Admin User', 'ADMIN', 'Downtown Branch', 'Logged in | POST /api/v1/auth/login → 200', '2026-01-17 07:58:44'), +(1, 1, 'admin', 'Admin User', 'ADMIN', 'Downtown Branch', 'Updated pet #8 | PUT /api/v1/pets/8 → 200', '2026-01-17 08:10:19'), +(7, 2, 'michael.johnson', 'Michael Johnson', 'STAFF', 'North Branch', 'Logged in | POST /api/v1/auth/login → 200', '2026-01-20 09:01:55'), +(16, NULL,'alex.brown', 'Alex Brown', 'CUSTOMER', NULL, 'Logged in | POST /api/v1/auth/login → 200', '2026-01-20 14:30:22'), +(16, NULL,'alex.brown', 'Alex Brown', 'CUSTOMER', NULL, 'Submitted an adoption request | POST /api/v1/adoptions/request → 201', '2026-01-20 14:45:08'), +(5, 1, 'david.brown', 'David Brown', 'STAFF', 'Downtown Branch', 'Logged in | POST /api/v1/auth/login → 200', '2026-01-22 08:55:00'), +(5, 1, 'david.brown', 'David Brown', 'STAFF', 'Downtown Branch', 'Updated pet #14 | PUT /api/v1/pets/14 → 200', '2026-01-22 09:20:17'), +(2, 2, 'morgan.lee', 'Morgan Lee', 'ADMIN', 'North Branch', 'Logged in | POST /api/v1/auth/login → 200', '2026-01-25 08:00:00'), +(2, 2, 'morgan.lee', 'Morgan Lee', 'ADMIN', 'North Branch', 'Created a new service | POST /api/v1/services → 201', '2026-01-25 08:22:41'), +(2, 2, 'morgan.lee', 'Morgan Lee', 'ADMIN', 'North Branch', 'Created a new employee | POST /api/v1/employees → 201', '2026-01-25 09:05:14'), +(17, NULL,'alex.clark', 'Alex Clark', 'CUSTOMER', NULL, 'Logged in | POST /api/v1/auth/login → 200', '2026-01-28 18:30:00'), +(17, NULL,'alex.clark', 'Alex Clark', 'CUSTOMER', NULL, 'Sent a message to the AI assistant | POST /api/v1/ai-chat/message → 200','2026-01-28 18:35:12'), +(17, NULL,'alex.clark', 'Alex Clark', 'CUSTOMER', NULL, 'Updated their profile | PUT /api/v1/auth/me → 200', '2026-01-28 18:40:55'), +(8, 2, 'emma.davis', 'Emma Davis', 'STAFF', 'North Branch', 'Logged in | POST /api/v1/auth/login → 200', '2026-02-03 09:00:00'), +(8, 2, 'emma.davis', 'Emma Davis', 'STAFF', 'North Branch', 'Completed a purchase | POST /api/v1/cart/checkout/complete → 200', '2026-02-03 10:15:38'), +(1, 1, 'admin', 'Admin User', 'ADMIN', 'Downtown Branch', 'Logged in | POST /api/v1/auth/login → 200', '2026-02-05 07:50:00'), +(1, 1, 'admin', 'Admin User', 'ADMIN', 'Downtown Branch', 'Deleted pet #3 | DELETE /api/v1/pets/3 → 200', '2026-02-05 08:05:33'), +(1, 1, 'admin', 'Admin User', 'ADMIN', 'Downtown Branch', 'Updated product #22 | PUT /api/v1/products/22 → 200', '2026-02-05 08:30:44'), +(18, NULL,'alex.wilson', 'Alex Wilson', 'CUSTOMER', NULL, 'Logged in | POST /api/v1/auth/login → 200', '2026-02-07 12:00:00'), +(18, NULL,'alex.wilson', 'Alex Wilson', 'CUSTOMER', NULL, 'Added an item to cart | POST /api/v1/cart/add → 200', '2026-02-07 12:08:17'), +(18, NULL,'alex.wilson', 'Alex Wilson', 'CUSTOMER', NULL, 'Applied a coupon to cart | POST /api/v1/cart/apply-coupon → 200', '2026-02-07 12:12:05'), +(18, NULL,'alex.wilson', 'Alex Wilson', 'CUSTOMER', NULL, 'Completed a purchase | POST /api/v1/cart/checkout/complete → 200', '2026-02-07 12:20:30'), +(11, 3, 'lisa.williams', 'Lisa Williams', 'STAFF', 'West Side Store', 'Logged in | POST /api/v1/auth/login → 200', '2026-02-10 09:00:00'), +(11, 3, 'lisa.williams', 'Lisa Williams', 'STAFF', 'West Side Store', 'Created a new appointment | POST /api/v1/appointments → 201', '2026-02-10 09:30:22'), +(11, 3, 'lisa.williams', 'Lisa Williams', 'STAFF', 'West Side Store', 'Updated appointment #25 | PUT /api/v1/appointments/25 → 200', '2026-02-10 11:45:09'), +(19, NULL,'alex.martinez', 'Alex Martinez', 'CUSTOMER', NULL, 'Logged in | POST /api/v1/auth/login → 200', '2026-02-14 15:00:00'), +(19, NULL,'alex.martinez', 'Alex Martinez', 'CUSTOMER', NULL, 'Started a new chat conversation | POST /api/v1/chat/conversations → 201','2026-02-14 15:05:44'), +(19, NULL,'alex.martinez', 'Alex Martinez', 'CUSTOMER', NULL, 'Sent a chat message | POST /api/v1/chat/conversations/5/messages → 201', '2026-02-14 15:08:22'), +(3, 1, 'staff', 'Staff User', 'STAFF', 'Downtown Branch', 'Logged in | POST /api/v1/auth/login → 200', '2026-02-17 09:00:00'), +(3, 1, 'staff', 'Staff User', 'STAFF', 'Downtown Branch', 'Uploaded image for pet #31 | POST /api/v1/pets/31/image → 200', '2026-02-17 09:25:11'), +(1, 1, 'admin', 'Admin User', 'ADMIN', 'Downtown Branch', 'Logged in | POST /api/v1/auth/login → 200', '2026-02-20 08:00:00'), +(1, 1, 'admin', 'Admin User', 'ADMIN', 'Downtown Branch', 'Created a new pet | POST /api/v1/pets → 201', '2026-02-20 08:20:35'), +(1, 1, 'admin', 'Admin User', 'ADMIN', 'Downtown Branch', 'Updated user #18 | PUT /api/v1/users/18 → 200', '2026-02-20 09:10:00'), +(20, NULL,'alex.anderson', 'Alex Anderson', 'CUSTOMER', NULL, 'Logged in | POST /api/v1/auth/login → 200', '2026-02-22 19:00:00'), +(20, NULL,'alex.anderson', 'Alex Anderson', 'CUSTOMER', NULL, 'Submitted an adoption request | POST /api/v1/adoptions/request → 201', '2026-02-22 19:15:40'), +(7, 2, 'michael.johnson', 'Michael Johnson', 'STAFF', 'North Branch', 'Logged in | POST /api/v1/auth/login → 200', '2026-03-01 09:00:00'), +(7, 2, 'michael.johnson', 'Michael Johnson', 'STAFF', 'North Branch', 'Completed a purchase | POST /api/v1/cart/checkout/complete → 200', '2026-03-01 10:30:15'), +(7, 2, 'michael.johnson', 'Michael Johnson', 'STAFF', 'North Branch', 'Updated appointment #33 | PUT /api/v1/appointments/33 → 200', '2026-03-01 14:05:22'), +(21, NULL,'alex.taylor', 'Alex Taylor', 'CUSTOMER', NULL, 'Logged in | POST /api/v1/auth/login → 200', '2026-03-05 11:00:00'), +(21, NULL,'alex.taylor', 'Alex Taylor', 'CUSTOMER', NULL, 'Added an item to cart | POST /api/v1/cart/add → 200', '2026-03-05 11:10:30'), +(21, NULL,'alex.taylor', 'Alex Taylor', 'CUSTOMER', NULL, 'Started checkout | POST /api/v1/cart/checkout → 200', '2026-03-05 11:18:44'), +(21, NULL,'alex.taylor', 'Alex Taylor', 'CUSTOMER', NULL, 'Completed a purchase | POST /api/v1/cart/checkout/complete → 200', '2026-03-05 11:22:17'), +(2, 2, 'morgan.lee', 'Morgan Lee', 'ADMIN', 'North Branch', 'Logged in | POST /api/v1/auth/login → 200', '2026-03-08 08:00:00'), +(2, 2, 'morgan.lee', 'Morgan Lee', 'ADMIN', 'North Branch', 'Deleted multiple pets | POST /api/v1/pets/bulk-delete → 200', '2026-03-08 08:30:00'), +(4, 1, 'sara.smith', 'Sara Smith', 'STAFF', 'Downtown Branch', 'Logged in | POST /api/v1/auth/login → 200', '2026-03-10 09:00:00'), +(4, 1, 'sara.smith', 'Sara Smith', 'STAFF', 'Downtown Branch', 'Completed a purchase | POST /api/v1/cart/checkout/complete → 200', '2026-03-10 10:45:33'), +(22, NULL,'alex.parker', 'Alex Parker', 'CUSTOMER', NULL, 'Logged in | POST /api/v1/auth/login → 200', '2026-03-12 16:00:00'), +(22, NULL,'alex.parker', 'Alex Parker', 'CUSTOMER', NULL, 'Sent a message to the AI assistant | POST /api/v1/ai-chat/message → 200','2026-03-12 16:05:22'), +(22, NULL,'alex.parker', 'Alex Parker', 'CUSTOMER', NULL, 'Updated their profile | PUT /api/v1/auth/me → 200', '2026-03-12 16:20:00'), +(1, 1, 'admin', 'Admin User', 'ADMIN', 'Downtown Branch', 'Logged in | POST /api/v1/auth/login → 200', '2026-03-15 07:55:00'), +(1, 1, 'admin', 'Admin User', 'ADMIN', 'Downtown Branch', 'Created a new product | POST /api/v1/products → 201', '2026-03-15 08:10:45'), +(1, 1, 'admin', 'Admin User', 'ADMIN', 'Downtown Branch', 'Updated product #35 | PUT /api/v1/products/35 → 200', '2026-03-15 08:40:12'), +(5, 1, 'david.brown', 'David Brown', 'STAFF', 'Downtown Branch', 'Logged in | POST /api/v1/auth/login → 200', '2026-03-18 09:00:00'), +(5, 1, 'david.brown', 'David Brown', 'STAFF', 'Downtown Branch', 'Updated appointment #45 | PUT /api/v1/appointments/45 → 200', '2026-03-18 09:35:08'), +(23, NULL,'alex.evans', 'Alex Evans', 'CUSTOMER', NULL, 'Logged in | POST /api/v1/auth/login → 200', '2026-03-20 20:00:00'), +(23, NULL,'alex.evans', 'Alex Evans', 'CUSTOMER', NULL, 'Started a new chat conversation | POST /api/v1/chat/conversations → 201','2026-03-20 20:05:30'), +(23, NULL,'alex.evans', 'Alex Evans', 'CUSTOMER', NULL, 'Sent a chat message | POST /api/v1/chat/conversations/9/messages → 201', '2026-03-20 20:08:11'), +(11, 3, 'lisa.williams', 'Lisa Williams', 'STAFF', 'West Side Store', 'Logged in | POST /api/v1/auth/login → 200', '2026-03-24 09:00:00'), +(11, 3, 'lisa.williams', 'Lisa Williams', 'STAFF', 'West Side Store', 'Created a new appointment | POST /api/v1/appointments → 201', '2026-03-24 09:20:44'), +(8, 2, 'emma.davis', 'Emma Davis', 'STAFF', 'North Branch', 'Logged in | POST /api/v1/auth/login → 200', '2026-03-27 09:00:00'), +(8, 2, 'emma.davis', 'Emma Davis', 'STAFF', 'North Branch', 'Updated appointment #52 | PUT /api/v1/appointments/52 → 200', '2026-03-27 10:10:19'), +(8, 2, 'emma.davis', 'Emma Davis', 'STAFF', 'North Branch', 'Completed a purchase | POST /api/v1/cart/checkout/complete → 200', '2026-03-27 11:30:00'), +(1, 1, 'admin', 'Admin User', 'ADMIN', 'Downtown Branch', 'Logged in | POST /api/v1/auth/login → 200', '2026-04-01 08:00:00'), +(1, 1, 'admin', 'Admin User', 'ADMIN', 'Downtown Branch', 'Created a new pet | POST /api/v1/pets → 201', '2026-04-01 08:15:00'), +(1, 1, 'admin', 'Admin User', 'ADMIN', 'Downtown Branch', 'Updated user #25 | PUT /api/v1/users/25 → 200', '2026-04-01 09:00:22'), +(15, NULL,'customer', 'Test Customer', 'CUSTOMER', NULL, 'Logged in | POST /api/v1/auth/login → 200', '2026-04-05 10:00:00'), +(15, NULL,'customer', 'Test Customer', 'CUSTOMER', NULL, 'Added an item to cart | POST /api/v1/cart/add → 200', '2026-04-05 10:15:00'), +(15, NULL,'customer', 'Test Customer', 'CUSTOMER', NULL, 'Completed a purchase | POST /api/v1/cart/checkout/complete → 200', '2026-04-05 10:25:44'), +(3, 1, 'staff', 'Staff User', 'STAFF', 'Downtown Branch', 'Logged in | POST /api/v1/auth/login → 200', '2026-04-07 09:00:00'), +(3, 1, 'staff', 'Staff User', 'STAFF', 'Downtown Branch', 'Updated pet #47 | PUT /api/v1/pets/47 → 200', '2026-04-07 09:40:55'), +(24, NULL,'alex.scott', 'Alex Scott', 'CUSTOMER', NULL, 'Logged in | POST /api/v1/auth/login → 200', '2026-04-09 17:00:00'), +(24, NULL,'alex.scott', 'Alex Scott', 'CUSTOMER', NULL, 'Submitted an adoption request | POST /api/v1/adoptions/request → 201', '2026-04-09 17:20:33'), +(2, 2, 'morgan.lee', 'Morgan Lee', 'ADMIN', 'North Branch', 'Logged in | POST /api/v1/auth/login → 200', '2026-04-12 08:00:00'), +(2, 2, 'morgan.lee', 'Morgan Lee', 'ADMIN', 'North Branch', 'Created a new service | POST /api/v1/services → 201', '2026-04-12 08:25:18'), +(4, 1, 'sara.smith', 'Sara Smith', 'STAFF', 'Downtown Branch', 'Logged in | POST /api/v1/auth/login → 200', '2026-04-14 09:00:00'), +(4, 1, 'sara.smith', 'Sara Smith', 'STAFF', 'Downtown Branch', 'Completed a purchase | POST /api/v1/cart/checkout/complete → 200', '2026-04-14 10:55:30'), +(4, 1, 'sara.smith', 'Sara Smith', 'STAFF', 'Downtown Branch', 'Updated appointment #60 | PUT /api/v1/appointments/60 → 200', '2026-04-14 14:20:00'), +(25, NULL,'alex.adams', 'Alex Adams', 'CUSTOMER', NULL, 'Logged in | POST /api/v1/auth/login → 200', '2026-04-15 11:00:00'), +(25, NULL,'alex.adams', 'Alex Adams', 'CUSTOMER', NULL, 'Added an item to cart | POST /api/v1/cart/add → 200', '2026-04-15 11:10:22'), +(25, NULL,'alex.adams', 'Alex Adams', 'CUSTOMER', NULL, 'Sent a message to the AI assistant | POST /api/v1/ai-chat/message → 200','2026-04-15 11:30:00'); diff --git a/backend/src/main/resources/db/migration/V9__seed_recent_sales.sql b/backend/src/main/resources/db/migration/V9__seed_recent_sales.sql new file mode 100644 index 00000000..23695f6f --- /dev/null +++ b/backend/src/main/resources/db/migration/V9__seed_recent_sales.sql @@ -0,0 +1,51 @@ +INSERT IGNORE INTO sale (saleId, saleDate, totalAmount, paymentMethod, employeeId, storeId, customerId, isRefund, originalSaleId, channel, cartId, couponId, subtotalAmount, couponDiscountAmount, employeeDiscountAmount, pointsEarned) VALUES +(134, '2026-04-10 09:15:00', 57.67, 'Cash', 4, 1, 16, 0, NULL, 'IN_STORE', NULL, NULL, 57.67, 0.00, 0.00, 5), +(135, '2026-04-10 11:30:00', 143.55, 'Card', 8, 2, 17, 0, NULL, 'ONLINE', NULL, NULL, 143.55, 0.00, 0.00, 14), +(136, '2026-04-10 14:45:00', 50.09, 'Cash', 12, 3, 18, 0, NULL, 'IN_STORE', NULL, NULL, 50.09, 0.00, 0.00, 5), +(137, '2026-04-11 10:00:00', 114.48, 'Card', 5, 1, 19, 0, NULL, 'ONLINE', NULL, NULL, 114.48, 0.00, 0.00, 11), +(138, '2026-04-11 13:20:00', 93.55, 'Cash', 9, 2, 20, 0, NULL, 'IN_STORE', NULL, NULL, 93.55, 0.00, 0.00, 9), +(139, '2026-04-12 09:45:00', 100.71, 'Card', 13, 3, 21, 0, NULL, 'ONLINE', NULL, NULL, 100.71, 0.00, 0.00, 10), +(140, '2026-04-12 11:00:00', 51.07, 'Cash', 6, 1, 22, 0, NULL, 'IN_STORE', NULL, NULL, 51.07, 0.00, 0.00, 5), +(141, '2026-04-12 15:30:00', 139.66, 'Card', 7, 2, 23, 0, NULL, 'ONLINE', NULL, NULL, 139.66, 0.00, 0.00, 13), +(142, '2026-04-13 09:00:00', 73.98, 'Cash', 14, 3, 24, 0, NULL, 'IN_STORE', NULL, NULL, 73.98, 0.00, 0.00, 7), +(143, '2026-04-13 12:15:00', 134.76, 'Card', 4, 1, 25, 0, NULL, 'ONLINE', NULL, NULL, 134.76, 0.00, 0.00, 13), +(144, '2026-04-14 10:30:00', 80.40, 'Cash', 10, 2, 26, 0, NULL, 'IN_STORE', NULL, NULL, 80.40, 0.00, 0.00, 8), +(145, '2026-04-14 14:00:00', 125.90, 'Card', 11, 3, 27, 0, NULL, 'ONLINE', NULL, NULL, 125.90, 0.00, 0.00, 12), +(146, '2026-04-15 10:45:00', 80.62, 'Cash', 5, 1, 28, 0, NULL, 'IN_STORE', NULL, NULL, 80.62, 0.00, 0.00, 8), +(147, '2026-04-15 13:00:00', 141.28, 'Card', 8, 2, 29, 0, NULL, 'ONLINE', NULL, NULL, 141.28, 0.00, 0.00, 14), +(148, '2026-04-16 09:30:00', 97.85, 'Cash', 12, 3, 30, 0, NULL, 'IN_STORE', NULL, NULL, 97.85, 0.00, 0.00, 9), +(149, '2026-04-16 11:45:00', 89.36, 'Card', 6, 1, 31, 0, NULL, 'ONLINE', NULL, NULL, 89.36, 0.00, 0.00, 8); + +INSERT IGNORE INTO saleItem (saleItemId, saleId, prodId, quantity, unitPrice) VALUES +(264, 134, 1, 2, 25.09), +(265, 134, 11, 1, 7.49), +(266, 135, 7, 2, 57.62), +(267, 135, 25, 1, 28.31), +(268, 136, 3, 1, 35.93), +(269, 136, 14, 1, 14.16), +(270, 137, 15, 3, 16.38), +(271, 137, 26, 2, 32.67), +(272, 138, 8, 1, 63.05), +(273, 138, 22, 2, 15.25), +(274, 139, 20, 2, 27.49), +(275, 139, 29, 1, 45.73), +(276, 140, 4, 1, 41.36), +(277, 140, 12, 1, 9.71), +(278, 141, 34, 2, 59.42), +(279, 141, 17, 1, 20.82), +(280, 142, 6, 1, 52.20), +(281, 142, 21, 2, 10.89), +(282, 143, 37, 1, 97.56), +(283, 143, 16, 2, 18.60), +(284, 144, 9, 1, 68.47), +(285, 144, 13, 1, 11.93), +(286, 145, 19, 3, 25.27), +(287, 145, 30, 1, 50.09), +(288, 146, 2, 2, 30.51), +(289, 146, 23, 1, 19.60), +(290, 147, 35, 1, 72.13), +(291, 147, 18, 3, 23.05), +(292, 148, 10, 1, 73.89), +(293, 148, 24, 1, 23.96), +(294, 149, 31, 2, 21.29), +(295, 149, 5, 1, 46.78); 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 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; } 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 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/Dockerfile b/web/Dockerfile index e850494d..0e96859c 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -3,8 +3,8 @@ WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . -ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY -ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY +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 node:22-alpine 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) => ( + + ))} + + )} @@ -444,41 +570,44 @@ function AiChatPage() {
-
🐾
+
{isEscalated ? "👤" : "🐾"}
-
Leon's Pet Assistant
-
- Online +
+ {isEscalated ? (hasStaff ? "Support Agent" : "Leon's Pet Store Support") : "Leon's Pet Assistant"} +
+
+ + {isClosed ? "Conversation closed" : isEscalated && hasStaff ? "Support agent connected" : isEscalated ? "Waiting for a support agent..." : "Online"}
{!isEscalated && !isClosed && ( - )} - + {!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!`}

)} @@ -493,7 +622,7 @@ function AiChatPage() { ...(isOwn ? s.messageRowUser : s.messageRowAgent), }} > - {!isOwn &&
🐾
} + {!isOwn &&
{isEscalated ? "👤" : "🐾"}
}
))} {msg.attachmentUrl && ( - + )}
{msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : ""} @@ -530,6 +650,24 @@ function AiChatPage() {
); })} + + {botTyping && !isEscalated && ( +
+
🐾
+
+ + + +
+
+ )} + + {switchingConv && ( +
+ Loading messages… +
+ )} +
@@ -726,6 +864,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", @@ -815,6 +968,60 @@ const s = { cursor: "pointer", whiteSpace: "nowrap", }, + agentAvatar: { + width: 44, + height: 44, + borderRadius: "50%", + background: "linear-gradient(135deg, #444, #666)", + display: "flex", + alignItems: "center", + justifyContent: "center", + fontSize: "1.3rem", + flexShrink: 0, + }, + agentAvatarSmall: { + width: 30, + height: 30, + borderRadius: "50%", + background: "#e0e0e0", + display: "flex", + alignItems: "center", + justifyContent: "center", + fontSize: "0.9rem", + flexShrink: 0, + }, + closeConvBtn: { + background: "white", + border: "2px solid #c0392b", + color: "#c0392b", + borderRadius: 8, + padding: "0.45rem 0.9rem", + fontSize: "0.82rem", + fontWeight: 600, + cursor: "pointer", + whiteSpace: "nowrap", + }, + waitingBanner: { + background: "#fff8f0", + borderBottom: "1px solid #ffe0b2", + padding: "0.6rem 1.25rem", + fontSize: "0.85rem", + color: "#7c4a00", + display: "flex", + alignItems: "center", + gap: "0.5rem", + flexShrink: 0, + }, + waitingSpinner: { + display: "inline-block", + width: 12, + height: 12, + borderRadius: "50%", + border: "2px solid #ff8c00", + borderTopColor: "transparent", + animation: "spin 0.8s linear infinite", + flexShrink: 0, + }, noConvCard: { background: "white", borderRadius: 16, diff --git a/web/app/appointments/page.js b/web/app/appointments/page.js index e596bcde..89bce924 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"]; @@ -381,6 +383,10 @@ function AppointmentsPage() { const [showAddPetModal, setShowAddPetModal] = useState(false); const [cancellingId, setCancellingId] = useState(null); + const [apptSearch, setApptSearch] = useState(""); + const [adoptionSearch, setAdoptionSearch] = useState(""); + const [showPastAppts, setShowPastAppts] = useState(false); + const [showPastAdoptions, setShowPastAdoptions] = useState(false); const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN"; @@ -964,79 +970,164 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";

{canBookAppointments ? "Your Appointments" : "Appointments"}

{loadingAppointments ? (

Loading appointments...

- ) : appointments.length === 0 ? ( -

No appointments yet.

- ) : ( -
- {appointments.map((a) => ( -
-
- {a.serviceName} - - {a.appointmentStatus} - + ) : (() => { + const activeAppts = appointments.filter((a) => a.appointmentStatus?.toLowerCase() === "booked"); + const pastAppts = appointments.filter((a) => a.appointmentStatus?.toLowerCase() !== "booked"); + const q = apptSearch.toLowerCase(); + const filteredActive = activeAppts.filter((a) => + !q || [a.serviceName, a.storeName, a.petName].some((v) => v?.toLowerCase().includes(q)) + ); + return ( + <> + setApptSearch(e.target.value)} + /> + {filteredActive.length === 0 ? ( +

{activeAppts.length === 0 ? "No active appointments." : "No results."}

+ ) : ( +
+ {filteredActive.map((a) => ( +
+
+ {a.serviceName} + + {a.appointmentStatus} + +
+
+ {a.storeName} + {a.appointmentDate} at {formatTime(a.appointmentTime)} +
+ {a.petName && ( +
Pet: {a.petName}
+ )} +
+ +
+
+ ))}
-
- {a.storeName} - {a.appointmentDate} at {formatTime(a.appointmentTime)} + )} + {pastAppts.length > 0 && ( +
+ + {showPastAppts && ( +
+ {pastAppts.map((a) => ( +
+
+ {a.serviceName} + + {a.appointmentStatus} + +
+
+ {a.storeName} + {a.appointmentDate} at {formatTime(a.appointmentTime)} +
+ {a.petName && ( +
Pet: {a.petName}
+ )} +
+ ))} +
+ )}
- {a.petName && ( -
- Pet: {a.petName} -
- )} - {a.appointmentStatus?.toLowerCase() === "booked" && ( -
- -
- )} -
- ))} -
- )} + )} + + ); + })()}

{canBookAppointments ? "Your Adoptions" : "Adoptions"}

{loadingAdoptions ? (

Loading adoptions...

- ) : adoptions.length === 0 ? ( -

No adoption requests yet.

- ) : ( -
- {adoptions.map((a) => ( -
-
- {a.petName} - - {a.adoptionStatus} - + ) : (() => { + const activeAdoptions = adoptions.filter((a) => a.adoptionStatus?.toLowerCase() === "pending"); + const pastAdoptions = adoptions.filter((a) => a.adoptionStatus?.toLowerCase() !== "pending"); + const q = adoptionSearch.toLowerCase(); + const filteredActive = activeAdoptions.filter((a) => + !q || [a.petName, a.sourceStoreName].some((v) => v?.toLowerCase().includes(q)) + ); + return ( + <> + setAdoptionSearch(e.target.value)} + /> + {filteredActive.length === 0 ? ( +

{activeAdoptions.length === 0 ? "No active adoption requests." : "No results."}

+ ) : ( +
+ {filteredActive.map((a) => ( +
+
+ {a.petName} + + {a.adoptionStatus} + +
+
+ {a.sourceStoreName} + {a.adoptionDate} +
+
+ +
+
+ ))}
-
- {a.sourceStoreName} - {a.adoptionDate} + )} + {pastAdoptions.length > 0 && ( +
+ + {showPastAdoptions && ( +
+ {pastAdoptions.map((a) => ( +
+
+ {a.petName} + + {a.adoptionStatus} + +
+
+ {a.sourceStoreName} + {a.adoptionDate} +
+
+ ))} +
+ )}
- {a.adoptionStatus?.toLowerCase() === "pending" && ( -
- -
- )} -
- ))} -
- )} + )} + + ); + })()}
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/chat/page.js b/web/app/chat/page.js index 28e71a96..17d7c42c 100644 --- a/web/app/chat/page.js +++ b/web/app/chat/page.js @@ -4,9 +4,69 @@ 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 isImageFilename(name) { + return /\.(jpe?g|png|gif|webp|bmp|svg)$/i.test(name || ""); +} + +function AttachmentPreview({ url, name, token }) { + const [blobUrl, setBlobUrl] = useState(null); + const isImage = isImageFilename(name); + + useEffect(() => { + if (!url || !token) return; + let objectUrl; + fetch(url, { headers: { Authorization: `Bearer ${token}` } }) + .then((r) => (r.ok ? r.blob() : null)) + .then((blob) => { + if (blob) { + objectUrl = URL.createObjectURL(blob); + setBlobUrl(objectUrl); + } + }) + .catch(() => {}); + return () => { if (objectUrl) URL.revokeObjectURL(objectUrl); }; + }, [url, token]); + + if (isImage) { + return ( +
+ {blobUrl ? ( + {name window.open(blobUrl, "_blank")} + /> + ) : ( + 📎 Loading image… + )} +
+ ); + } + + return ( + + ); +} function ChatPage() { const { user, token, loading: authLoading } = useAuth(); @@ -22,15 +82,18 @@ function ChatPage() { const [error, setError] = useState(null); const [conversations, setConversations] = useState([]); const [convsLoading, setConvsLoading] = useState(false); + const [closedExpanded, setClosedExpanded] = useState(false); const [selectedFile, setSelectedFile] = useState(null); + const [switchingConv, setSwitchingConv] = useState(false); 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); + const initialLoadDoneRef = useRef(false); useEffect(() => { if (!authLoading && !user) { @@ -42,17 +105,17 @@ 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; - const nearBottom = area.scrollHeight - area.scrollTop - area.clientHeight < 80; - if (nearBottom) { - area.scrollTop = area.scrollHeight; - } + const nearBottom = area.scrollHeight - area.scrollTop - area.clientHeight < 150; + if (nearBottom) area.scrollTop = area.scrollHeight; }, [messages]); 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}` }, @@ -67,10 +130,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."); } @@ -114,40 +183,34 @@ 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); - }, [token]); + 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 */ } + }); + 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); + setConversations((prev) => prev.map((c) => c.id === conv.id ? conv : c)); + } catch { /* silent */ } + }); + }; + stompRef.current = client; + client.activate(); + }, [token, user?.role]); useEffect(() => { if (!token || authLoading) return; @@ -194,16 +257,16 @@ function ChatPage() { ]); if (stale) return; setLoadingConv(false); - startPolling(convId); + connectStomp(convId); } init(); return () => { stale = true; - 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(); @@ -347,7 +410,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."); @@ -356,7 +419,7 @@ function ChatPage() { } async function switchConversation(convId) { - if (pollRef.current) clearInterval(pollRef.current); + if (stompRef.current) { stompRef.current.deactivate(); stompRef.current = null; } setMessages([]); setError(null); router.replace(`/chat?id=${convId}`, { scroll: false }); @@ -426,7 +489,7 @@ function ChatPage() {

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) => ( + + ))} + + )} @@ -488,13 +573,15 @@ function ChatPage() { Close Chat )} - + {!isHuman && ( + + )}
@@ -540,16 +627,7 @@ function ChatPage() { ))} {msg.attachmentUrl && ( - + )}
{msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : ""} @@ -563,6 +641,11 @@ function ChatPage() {
); })} + {switchingConv && ( +
+ Loading messages… +
+ )}
@@ -1102,6 +1185,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", diff --git a/web/app/contact/page.js b/web/app/contact/page.js index eb8274e9..1983bd72 100644 --- a/web/app/contact/page.js +++ b/web/app/contact/page.js @@ -38,6 +38,10 @@ export default function ContactPage() { async function handleSend(e) { e.preventDefault(); + if (!token) { + setSendError("Please log in to send a message."); + return; + } setSending(true); setSendError(null); try { @@ -50,7 +54,7 @@ export default function ContactPage() { setSendSuccess(true); setSubject(""); setBody(""); - } catch (err) { + } catch { setSendError("Failed to send message. Please try again."); } finally { setSending(false); @@ -61,21 +65,19 @@ 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

+
+

Send Us a Message

{sendSuccess ? (

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

) : ( @@ -100,7 +102,7 @@ export default function ContactPage() { onChange={(e) => setBody(e.target.value)} required maxLength={2000} - rows={6} + rows={5} /> {sendError &&

{sendError}

} @@ -110,18 +112,14 @@ export default function ContactPage() { )}
- )} +

Store Locations

{loading &&

Loading locations...

} - {error &&

Failed to load locations: {error}

} - - {!loading && !error && locations.length === 0 && ( -

No store locations found.

- )} + {!loading && !error && locations.length === 0 &&

No store locations found.

} {!loading && !error && locations.length > 0 && (
diff --git a/web/app/globals.css b/web/app/globals.css index 0ba62bfb..bcb053a8 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -154,7 +154,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; @@ -685,33 +685,39 @@ body { } .info-page { - min-height: 100vh; background: linear-gradient(to bottom, #f9f9f9, #ffffff); } .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 1.5rem; display: grid; + grid-template-columns: repeat(3, 1fr); gap: 1.5rem; } @@ -733,6 +739,7 @@ body { padding-left: 1.2rem; display: grid; gap: 0.5rem; + list-style-type: disc; } .info-card-grid { @@ -886,11 +893,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; @@ -1028,8 +1039,7 @@ body { align-items: center; gap: 0.5rem; justify-self: end; - padding-left: 1.5rem; - flex-shrink: 0; + min-width: 0; } .nav-greeting { @@ -1706,6 +1716,49 @@ body { cursor: default; } +.appt-search { + width: 100%; + padding: 0.5rem 0.75rem; + margin-bottom: 0.75rem; + border: 1px solid #e0e0e0; + border-radius: 8px; + font-size: 0.9rem; + background: #fff; + box-sizing: border-box; +} + +.appt-search:focus { + outline: none; + border-color: #e68672; +} + +.appt-past-section { + margin-top: 1rem; +} + +.appt-past-toggle { + background: none; + border: none; + padding: 0; + font-size: 0.85rem; + color: #888; + cursor: pointer; + text-decoration: underline; + margin-bottom: 0.75rem; +} + +.appt-past-toggle:hover { + color: #555; +} + +.appt-list--past { + opacity: 0.7; +} + +.appt-card--past { + background: #f9f9f9; +} + /* Adoption Pet Selection */ .appt-adopt-grid { @@ -2024,6 +2077,68 @@ body { color: #333; } +.profile-orders-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.profile-order-card { + border: 1px solid #f0f0f0; + border-radius: 10px; + padding: 0.85rem 1rem; + background: #fafafa; +} + +.profile-order-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.25rem; +} + +.profile-order-date { + font-size: 0.85rem; + color: #555; + font-weight: 600; +} + +.profile-order-total { + font-size: 0.95rem; + font-weight: 700; + color: #222; +} + +.profile-order-meta { + display: flex; + gap: 1rem; + font-size: 0.78rem; + color: #999; + margin-bottom: 0.5rem; +} + +.profile-order-items { + margin: 0.25rem 0 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 0.2rem; + border-top: 1px solid #ececec; + padding-top: 0.5rem; +} + +.profile-order-items li { + display: flex; + justify-content: space-between; + font-size: 0.82rem; + color: #444; +} + +.profile-order-item-price { + color: #888; +} + /* Store Selector */ .nav-store-select { @@ -2037,6 +2152,7 @@ body { margin-right: 0.5rem; outline: none; transition: background 0.2s ease; + max-width: 160px; } .nav-store-select option { @@ -2639,6 +2755,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; @@ -2827,7 +2962,10 @@ body { .nav-auth { gap: 0.35rem; - padding-left: 0.5rem; + } + + .nav-greeting { + display: none; } } @@ -2893,7 +3031,8 @@ body { display: flex; align-items: center; gap: 0.5rem; - margin-left: auto; + grid-column: 3; + justify-self: end; } .nav-links, @@ -3022,13 +3161,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, @@ -3094,6 +3240,10 @@ img, video, iframe { max-width: 100%; } +.contact-layout { display: grid; grid-template-columns: 1fr 2fr; gap: 1.5rem; max-width: 1200px; margin: 0 auto; padding: 0 2rem 3rem; } +@media (max-width: 768px) { .contact-layout { grid-template-columns: 1fr; } } +.contact-form-section { margin-top: 1.5rem; border-top: 1px solid #f0f0f0; padding-top: 1.5rem; } +.contact-form-section h3 { margin: 0 0 1rem; font-size: 1rem; color: #333; } .contact-form { display: flex; flex-direction: column; gap: 1rem; } .contact-label { display: flex; flex-direction: column; gap: 0.4rem; font-weight: 500; color: #333; font-size: 0.95rem; } .contact-input, .contact-textarea { border: 1px solid #ddd; border-radius: 8px; padding: 0.6rem 0.8rem; font-size: 0.95rem; font-family: inherit; resize: vertical; } @@ -3103,7 +3253,43 @@ img, video, iframe { .contact-submit-btn:disabled { opacity: 0.6; cursor: not-allowed; } .contact-error { color: #c0392b; font-size: 0.9rem; } .contact-success { color: #166534; background: #dcfce7; border: 1px solid #bbf7d0; border-radius: 8px; padding: 0.75rem 1rem; } -.pagination-controls { display: flex; align-items: center; justify-content: center; gap: 1rem; padding: 1.5rem 1rem; } -.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-controls { display: flex; align-items: center; justify-content: center; gap: 0.4rem; padding: 1.5rem 1rem; flex-wrap: wrap; } +.pagination-btn { background: #e8e8e8; color: #333; border: none; border-radius: 8px; padding: 0.5rem 0.9rem; font-size: 0.9rem; font-weight: 600; cursor: pointer; transition: background 0.15s; } +.pagination-btn:hover:not(:disabled) { background: #d0d0d0; } +.pagination-btn:disabled { background: #f0f0f0; color: #aaa; cursor: not-allowed; } +.pagination-btn--active { background: #e68672; color: white; } +.pagination-btn--active:hover { background: #d4705e; } +.pagination-ellipsis { padding: 0.5rem 0.25rem; color: #888; font-weight: 600; } .pagination-info { font-size: 0.9rem; color: #555; font-weight: 500; } + +@media (max-width: 768px) { + .info-title { + font-size: 1.3rem; + } + .info-subtitle { + font-size: 0.95rem; + } +} + +@media (max-width: 480px) { + .info-title { + font-size: 1.2rem; + } + .info-subtitle { + font-size: 0.9rem; + } + .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; + } +} 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.

diff --git a/web/app/profile/page.js b/web/app/profile/page.js index a96d9f59..9463821f 100644 --- a/web/app/profile/page.js +++ b/web/app/profile/page.js @@ -25,6 +25,8 @@ export default function ProfilePage() { const [pets, setPets] = useState([]); const [loadingPets, setLoadingPets] = useState(false); + const [orders, setOrders] = useState([]); + const [loadingOrders, setLoadingOrders] = useState(false); const [showForm, setShowForm] = useState(false); const [editingPet, setEditingPet] = useState(null); const [petName, setPetName] = useState(""); @@ -120,11 +122,27 @@ export default function ProfilePage() { }; }, [clearPetImageObjectUrls]); + const loadOrders = useCallback(async () => { + if (!token) return; + setLoadingOrders(true); + try { + const res = await fetch(`${API_BASE}/api/v1/sales/my?size=20&sort=saleDate,desc`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) return; + const data = await res.json(); + setOrders(data.content ?? []); + } catch { } finally { + setLoadingOrders(false); + } + }, [token]); + useEffect(() => { if (user?.role === "CUSTOMER" || user?.role === "ADMIN") { loadPets(); + loadOrders(); } - }, [user, loadPets]); + }, [user, loadPets, loadOrders]); useEffect(() => { let objectUrl = null; @@ -642,6 +660,46 @@ export default function ProfilePage() { )}
)} + + {(user.role === "CUSTOMER" || user.role === "ADMIN") && ( +
+
+

Order History

+
+ {loadingOrders ? ( +

Loading orders...

+ ) : orders.length === 0 ? ( +

No orders yet.

+ ) : ( +
+ {orders.map((order) => ( +
+
+ + {new Date(order.saleDate).toLocaleDateString([], { year: "numeric", month: "short", day: "numeric" })} + + ${Number(order.totalAmount).toFixed(2)} +
+
+ {order.storeName} + {order.paymentMethod && {order.paymentMethod}} +
+ {order.items?.length > 0 && ( +
    + {order.items.map((item) => ( +
  • + {item.productName} × {item.quantity} + ${(Number(item.unitPrice) * item.quantity).toFixed(2)} +
  • + ))} +
+ )} +
+ ))} +
+ )} +
+ )}
); } 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",