From c0be2a6903bf44091a13d11e316824067d416268 Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Wed, 15 Apr 2026 23:03:54 -0600 Subject: [PATCH 1/3] pet owner search --- .../java/com/petshop/backend/repository/PetRepository.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/main/java/com/petshop/backend/repository/PetRepository.java b/backend/src/main/java/com/petshop/backend/repository/PetRepository.java index 8b1aa178..d301be55 100644 --- a/backend/src/main/java/com/petshop/backend/repository/PetRepository.java +++ b/backend/src/main/java/com/petshop/backend/repository/PetRepository.java @@ -43,8 +43,8 @@ public interface PetRepository extends JpaRepository { @Query("SELECT p FROM Pet p WHERE p.id = :id") Optional findByIdForUpdate(@Param("id") Long id); - @Query("SELECT p FROM Pet p WHERE " + - "(:q IS NULL OR LOWER(p.petName) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(p.petSpecies) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(COALESCE(p.petBreed, '')) LIKE LOWER(CONCAT('%', :q, '%'))) AND " + + @Query("SELECT p FROM Pet p LEFT JOIN p.owner o WHERE " + + "(:q IS NULL OR LOWER(p.petName) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(p.petSpecies) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(COALESCE(p.petBreed, '')) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(COALESCE(o.firstName, '')) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(COALESCE(o.lastName, '')) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(CONCAT(COALESCE(o.firstName, ''), ' ', COALESCE(o.lastName, ''))) LIKE LOWER(CONCAT('%', :q, '%'))) AND " + "(:species IS NULL OR LOWER(p.petSpecies) = LOWER(:species)) AND " + "(:breed IS NULL OR LOWER(COALESCE(p.petBreed, '')) = LOWER(:breed)) AND " + "(:status IS NULL OR LOWER(p.petStatus) = LOWER(:status)) AND " + From 703402b5b6612ebddde653417984c651cea7ecbf Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Wed, 15 Apr 2026 23:11:53 -0600 Subject: [PATCH 2/3] fix chat badge on reply --- .../org/example/petshopdesktop/api/ChatRealtimeClient.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/desktop/src/main/java/org/example/petshopdesktop/api/ChatRealtimeClient.java b/desktop/src/main/java/org/example/petshopdesktop/api/ChatRealtimeClient.java index d29d3aab..93f90a73 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/api/ChatRealtimeClient.java +++ b/desktop/src/main/java/org/example/petshopdesktop/api/ChatRealtimeClient.java @@ -90,8 +90,9 @@ public class ChatRealtimeClient implements WebSocket.Listener { for (ConversationResponse conv : globalConversations.values()) { if ("CLOSED".equals(conv.getStatus())) continue; - // Needs pickup - if (conv.getHumanRequestedAt() != null && conv.getStaffId() == null) { + // Needs pickup - only if we haven't already replied + if (conv.getHumanRequestedAt() != null && conv.getStaffId() == null + && (currentUserId == null || !currentUserId.equals(conv.getLastSenderId()))) { return true; } From 83e268d6d4e0a051f5eb9995dadb13be33be8fbd Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Wed, 15 Apr 2026 23:19:22 -0600 Subject: [PATCH 3/3] restore activity logs endpoint --- ...tivityLoggingFilterRegistrationConfig.java | 2 +- .../controller/ActivityLogController.java | 37 +++++ .../dto/activity/ActivityLogResponse.java | 126 +++++++++++++++ .../petshop/backend/entity/ActivityLog.java | 148 +++++++++++++++++ .../repository/ActivityLogRepository.java | 18 +++ .../backend/service/ActivityLogService.java | 150 ++++++++++++++++-- 6 files changed, 468 insertions(+), 13 deletions(-) create mode 100644 backend/src/main/java/com/petshop/backend/controller/ActivityLogController.java create mode 100644 backend/src/main/java/com/petshop/backend/dto/activity/ActivityLogResponse.java create mode 100644 backend/src/main/java/com/petshop/backend/entity/ActivityLog.java create mode 100644 backend/src/main/java/com/petshop/backend/repository/ActivityLogRepository.java 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/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/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/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/service/ActivityLogService.java b/backend/src/main/java/com/petshop/backend/service/ActivityLogService.java index c57378a8..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,48 +1,174 @@ 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.scheduling.annotation.Async; +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; } - @Async + @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); } } - @Async public void record(User user, String activity) { if (user == null) { return; } 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; + } }