Add activity logging #171
@@ -0,0 +1,79 @@
|
||||
package com.petshop.backend.config;
|
||||
|
||||
import com.petshop.backend.entity.User;
|
||||
import com.petshop.backend.security.AppPrincipal;
|
||||
import com.petshop.backend.service.ActivityLogService;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@Component
|
||||
@Order(Ordered.LOWEST_PRECEDENCE - 20)
|
||||
public class ActivityLoggingFilter extends OncePerRequestFilter {
|
||||
|
||||
private final ActivityLogService activityLogService;
|
||||
|
||||
public ActivityLoggingFilter(ActivityLogService activityLogService) {
|
||||
this.activityLogService = activityLogService;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||
String uri = request.getRequestURI();
|
||||
if (uri == null || uri.isBlank()) {
|
||||
return true;
|
||||
}
|
||||
if (!uri.startsWith("/api/")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String lower = uri.toLowerCase(java.util.Locale.ROOT);
|
||||
return lower.startsWith("/api/v1/health")
|
||||
|| lower.startsWith("/api/v1/activity-logs")
|
||||
|| lower.startsWith("/v3/api-docs")
|
||||
|| lower.startsWith("/swagger-ui")
|
||||
|| lower.startsWith("/ws/");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
|
||||
filterChain.doFilter(request, response);
|
||||
recordActivity(request, response);
|
||||
}
|
||||
|
||||
private void recordActivity(HttpServletRequest request, HttpServletResponse response) {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication == null || !authentication.isAuthenticated()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Long userId = null;
|
||||
User.Role role = null;
|
||||
Object principal = authentication.getPrincipal();
|
||||
if (principal instanceof AppPrincipal appPrincipal) {
|
||||
userId = appPrincipal.getUserId();
|
||||
role = appPrincipal.getRole();
|
||||
} else if (authentication instanceof UsernamePasswordAuthenticationToken token
|
||||
&& token.getPrincipal() instanceof AppPrincipal appPrincipal) {
|
||||
userId = appPrincipal.getUserId();
|
||||
role = appPrincipal.getRole();
|
||||
}
|
||||
|
||||
if (userId == null || role == null || role == User.Role.CUSTOMER) {
|
||||
return;
|
||||
}
|
||||
|
||||
String activity = String.format("%s %s -> %d", request.getMethod(), request.getRequestURI(), response.getStatus());
|
||||
activityLogService.record(userId, activity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.petshop.backend.config;
|
||||
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
public class ActivityLoggingFilterRegistrationConfig {
|
||||
|
||||
@Bean
|
||||
public FilterRegistrationBean<ActivityLoggingFilter> activityLoggingFilterRegistration(ActivityLoggingFilter activityLoggingFilter) {
|
||||
FilterRegistrationBean<ActivityLoggingFilter> registrationBean = new FilterRegistrationBean<>(activityLoggingFilter);
|
||||
registrationBean.setEnabled(false);
|
||||
return registrationBean;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.petshop.backend.config;
|
||||
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.flywaydb.core.api.MigrationVersion;
|
||||
import org.springframework.context.ApplicationContextInitializer;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
import org.springframework.core.env.ConfigurableEnvironment;
|
||||
@@ -38,10 +37,7 @@ public class FlywayContextInitializer implements ApplicationContextInitializer<C
|
||||
Flyway flyway = Flyway.configure()
|
||||
.dataSource(url, username, password)
|
||||
.locations(locations)
|
||||
.baselineOnMigrate(environment.getProperty("spring.flyway.baseline-on-migrate", Boolean.class, false))
|
||||
.baselineVersion(MigrationVersion.fromVersion(environment.getProperty("spring.flyway.baseline-version", "1")))
|
||||
.load();
|
||||
flyway.repair();
|
||||
flyway.migrate();
|
||||
return;
|
||||
} catch (RuntimeException ex) {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.petshop.backend.controller;
|
||||
|
||||
import com.petshop.backend.dto.activity.ActivityLogResponse;
|
||||
import com.petshop.backend.service.ActivityLogService;
|
||||
import java.util.List;
|
||||
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<List<ActivityLogResponse>> getActivityLogs(
|
||||
@RequestParam(defaultValue = "2000") int limit) {
|
||||
int safeLimit = Math.min(Math.max(1, limit), 10000);
|
||||
return ResponseEntity.ok(activityLogService.getLogs(safeLimit));
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,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.service.ActivityLogService;
|
||||
import com.petshop.backend.service.AvatarStorageService;
|
||||
import com.petshop.backend.util.AuthenticationHelper;
|
||||
import com.petshop.backend.util.PhoneUtils;
|
||||
@@ -29,6 +30,8 @@ import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
@@ -38,18 +41,22 @@ import java.util.Map;
|
||||
@RequestMapping("/api/v1/auth")
|
||||
public class AuthController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AuthController.class);
|
||||
|
||||
private final AuthenticationManager authenticationManager;
|
||||
private final UserRepository userRepository;
|
||||
private final JwtUtil jwtUtil;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final AvatarStorageService avatarStorageService;
|
||||
private final ActivityLogService activityLogService;
|
||||
|
||||
public AuthController(AuthenticationManager authenticationManager, UserRepository userRepository, JwtUtil jwtUtil, PasswordEncoder passwordEncoder, AvatarStorageService avatarStorageService) {
|
||||
public AuthController(AuthenticationManager authenticationManager, UserRepository userRepository, JwtUtil jwtUtil, PasswordEncoder passwordEncoder, AvatarStorageService avatarStorageService, ActivityLogService activityLogService) {
|
||||
this.authenticationManager = authenticationManager;
|
||||
this.userRepository = userRepository;
|
||||
this.jwtUtil = jwtUtil;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.avatarStorageService = avatarStorageService;
|
||||
this.activityLogService = activityLogService;
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
@@ -60,18 +67,21 @@ public class AuthController {
|
||||
String phone = normalizePhone(request.getPhone());
|
||||
|
||||
if (userRepository.findByUsername(username).isPresent()) {
|
||||
log.warn("Registration rejected: username already exists ({})", username);
|
||||
Map<String, String> error = new HashMap<>();
|
||||
error.put("message", "Username already exists");
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
|
||||
}
|
||||
|
||||
if (userRepository.findByEmail(email).isPresent()) {
|
||||
log.warn("Registration rejected: email already exists");
|
||||
Map<String, String> error = new HashMap<>();
|
||||
error.put("message", "Email already exists");
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
|
||||
}
|
||||
|
||||
if (phone != null && userRepository.findByPhone(phone).isPresent()) {
|
||||
log.warn("Registration rejected: phone already exists");
|
||||
Map<String, String> error = new HashMap<>();
|
||||
error.put("message", "Phone already exists");
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
|
||||
@@ -113,6 +123,9 @@ public class AuthController {
|
||||
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
|
||||
|
||||
String token = jwtUtil.generateToken(user);
|
||||
if (user.getRole() != User.Role.CUSTOMER) {
|
||||
activityLogService.record(user.getId(), "POST /api/v1/auth/login -> 200");
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(new LoginResponse(
|
||||
token,
|
||||
@@ -121,11 +134,13 @@ public class AuthController {
|
||||
));
|
||||
|
||||
} catch (BadCredentialsException e) {
|
||||
log.warn("Login failed for username {}", request.getUsername());
|
||||
Map<String, String> error = new HashMap<>();
|
||||
error.put("message", "Invalid username or password");
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error);
|
||||
} catch (InternalAuthenticationServiceException e) {
|
||||
if (e.getCause() instanceof DisabledException disabledException) {
|
||||
log.warn("Login denied for disabled user {}", request.getUsername());
|
||||
Map<String, String> error = new HashMap<>();
|
||||
error.put("message", disabledException.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(error);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,18 @@ public class ActivityLog {
|
||||
@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;
|
||||
|
||||
@@ -61,6 +73,38 @@ public class ActivityLog {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
package com.petshop.backend.repository;
|
||||
|
||||
import com.petshop.backend.entity.ActivityLog;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface ActivityLogRepository extends JpaRepository<ActivityLog, Long> {
|
||||
boolean existsByUser_Id(Long userId);
|
||||
|
||||
@Query("select a from ActivityLog a order by a.logTimestamp desc, a.logId desc")
|
||||
List<ActivityLog> findRecent(Pageable pageable);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.petshop.backend.entity.User;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@@ -23,6 +24,16 @@ public class JwtUtil {
|
||||
@Value("${jwt.expiration}")
|
||||
private Long expiration;
|
||||
|
||||
@PostConstruct
|
||||
void validateConfiguration() {
|
||||
if (secret == null || secret.isBlank()) {
|
||||
throw new IllegalStateException("JWT_SECRET must be configured");
|
||||
}
|
||||
if (secret.getBytes(StandardCharsets.UTF_8).length < 32) {
|
||||
throw new IllegalStateException("JWT_SECRET must be at least 32 bytes long");
|
||||
}
|
||||
}
|
||||
|
||||
private SecretKey getSigningKey() {
|
||||
return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@@ -8,12 +8,16 @@ import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.web.access.AccessDeniedHandler;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@Component
|
||||
public class RestAccessDeniedHandler implements AccessDeniedHandler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(RestAccessDeniedHandler.class);
|
||||
|
||||
private final ApiErrorResponder apiErrorResponder;
|
||||
|
||||
public RestAccessDeniedHandler(ApiErrorResponder apiErrorResponder) {
|
||||
@@ -22,6 +26,7 @@ public class RestAccessDeniedHandler implements AccessDeniedHandler {
|
||||
|
||||
@Override
|
||||
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
|
||||
log.warn("Access denied: {} {} - {}", request.getMethod(), request.getRequestURI(), accessDeniedException.getMessage());
|
||||
apiErrorResponder.write(
|
||||
response,
|
||||
HttpStatus.FORBIDDEN,
|
||||
|
||||
@@ -8,12 +8,16 @@ import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@Component
|
||||
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(RestAuthenticationEntryPoint.class);
|
||||
|
||||
private final ApiErrorResponder apiErrorResponder;
|
||||
|
||||
public RestAuthenticationEntryPoint(ApiErrorResponder apiErrorResponder) {
|
||||
@@ -22,6 +26,7 @@ public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {
|
||||
|
||||
@Override
|
||||
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
|
||||
log.warn("Unauthorized request: {} {} - {}", request.getMethod(), request.getRequestURI(), authException.getMessage());
|
||||
apiErrorResponder.write(
|
||||
response,
|
||||
HttpStatus.UNAUTHORIZED,
|
||||
|
||||
@@ -21,6 +21,8 @@ import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import com.petshop.backend.config.ActivityLoggingFilter;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Configuration
|
||||
@@ -44,7 +46,7 @@ public class SecurityConfig {
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http, ActivityLoggingFilter activityLoggingFilter) throws Exception {
|
||||
http.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
@@ -65,6 +67,7 @@ public class SecurityConfig {
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authenticationProvider(daoAuthenticationProvider())
|
||||
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
http.addFilterAfter(activityLoggingFilter, JwtAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
@@ -97,7 +100,7 @@ public class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
|
||||
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
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 org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class ActivityLogService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ActivityLogService.class);
|
||||
|
||||
private final ActivityLogRepository activityLogRepository;
|
||||
private final 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 managedUser = userRepository.findById(userId).orElse(null);
|
||||
if (managedUser == null) {
|
||||
return;
|
||||
}
|
||||
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);
|
||||
} catch (Exception ex) {
|
||||
log.warn("Failed to persist activity log", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public void record(User user, String activity) {
|
||||
if (user == null) {
|
||||
return;
|
||||
}
|
||||
record(user.getId(), activity);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<ActivityLogResponse> getLogs(int limit) {
|
||||
return activityLogRepository.findRecent(PageRequest.of(0, limit)).stream().map(this::toResponse).toList();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.petshop.backend.service;
|
||||
import com.petshop.backend.dto.common.BulkDeleteRequest;
|
||||
import com.petshop.backend.dto.user.UserRequest;
|
||||
import com.petshop.backend.dto.user.UserResponse;
|
||||
import com.petshop.backend.repository.ActivityLogRepository;
|
||||
import com.petshop.backend.entity.StoreLocation;
|
||||
import com.petshop.backend.entity.User;
|
||||
import com.petshop.backend.exception.ResourceNotFoundException;
|
||||
@@ -25,11 +26,13 @@ import static org.springframework.http.HttpStatus.CONFLICT;
|
||||
public class UserService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final ActivityLogRepository activityLogRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final StoreRepository storeRepository;
|
||||
|
||||
public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder, StoreRepository storeRepository) {
|
||||
public UserService(UserRepository userRepository, ActivityLogRepository activityLogRepository, PasswordEncoder passwordEncoder, StoreRepository storeRepository) {
|
||||
this.userRepository = userRepository;
|
||||
this.activityLogRepository = activityLogRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.storeRepository = storeRepository;
|
||||
}
|
||||
@@ -121,11 +124,17 @@ public class UserService {
|
||||
if (!userRepository.existsById(id)) {
|
||||
throw new ResourceNotFoundException("User not found with id: " + id);
|
||||
}
|
||||
if (activityLogRepository.existsByUser_Id(id)) {
|
||||
throw new ResponseStatusException(CONFLICT, "User cannot be deleted because activity logs exist");
|
||||
}
|
||||
userRepository.deleteById(id);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void bulkDeleteUsers(BulkDeleteRequest request) {
|
||||
if (request.getIds() != null && request.getIds().stream().anyMatch(activityLogRepository::existsByUser_Id)) {
|
||||
throw new ResponseStatusException(CONFLICT, "One or more users cannot be deleted because activity logs exist");
|
||||
}
|
||||
userRepository.deleteAllById(request.getIds());
|
||||
}
|
||||
|
||||
|
||||
2
backend/src/main/resources/application-local.yml
Normal file
2
backend/src/main/resources/application-local.yml
Normal file
@@ -0,0 +1,2 @@
|
||||
jwt:
|
||||
secret: ${JWT_SECRET:local-development-jwt-secret-change-me-please-123456}
|
||||
@@ -33,9 +33,7 @@ spring:
|
||||
open-in-view: false
|
||||
|
||||
flyway:
|
||||
enabled: true
|
||||
baseline-on-migrate: true
|
||||
baseline-version: 1
|
||||
enabled: false
|
||||
|
||||
server:
|
||||
port: ${SERVER_PORT:8080}
|
||||
@@ -50,7 +48,7 @@ springdoc:
|
||||
path: /swagger-ui
|
||||
|
||||
jwt:
|
||||
secret: ${JWT_SECRET:change_me_please_make_this_at_least_32_characters_long_for_security}
|
||||
secret: ${JWT_SECRET}
|
||||
expiration: ${JWT_EXPIRATION:86400000}
|
||||
|
||||
stripe:
|
||||
|
||||
@@ -1739,125 +1739,3 @@ INSERT INTO message (id, conversationId, senderId, content, attachmentUrl, attac
|
||||
(118, 30, 8, 'Happy to help. Please share the order number or the pet name involved.', NULL, NULL, NULL, NULL, '2026-03-02 09:05:00', 1),
|
||||
(119, 30, 45, 'Order #1030 is the one I meant, and the pet is Kiki.', NULL, NULL, NULL, NULL, '2026-03-02 09:10:00', 1),
|
||||
(120, 30, 8, 'Thanks, the account and order are now updated on this conversation.', NULL, NULL, NULL, NULL, '2026-03-02 09:15:00', 0);
|
||||
|
||||
INSERT INTO activityLog (logId, userId, storeId, activity, logTimestamp) VALUES
|
||||
(1, 1, 1, 'Reviewed store inventory adjustments.', '2026-01-03 08:00:00'),
|
||||
(2, 2, 2, 'Approved a purchase transaction at the register.', '2026-01-03 17:00:00'),
|
||||
(3, 3, 1, 'Updated a pet availability record.', '2026-01-04 02:00:00'),
|
||||
(4, 4, 1, 'Completed a grooming appointment handoff.', '2026-01-04 11:00:00'),
|
||||
(5, 5, 1, 'Checked a pending adoption record.', '2026-01-04 20:00:00'),
|
||||
(6, 6, 1, 'Reviewed a refund request tied to an original sale.', '2026-01-05 05:00:00'),
|
||||
(7, 7, 2, 'Answered a customer support conversation.', '2026-01-05 14:00:00'),
|
||||
(8, 8, 2, 'Updated a product detail for the catalogue.', '2026-01-05 23:00:00'),
|
||||
(9, 9, 2, 'Reviewed store inventory adjustments.', '2026-01-06 08:00:00'),
|
||||
(10, 10, 2, 'Approved a purchase transaction at the register.', '2026-01-06 17:00:00'),
|
||||
(11, 11, 3, 'Updated a pet availability record.', '2026-01-07 02:00:00'),
|
||||
(12, 12, 3, 'Completed a grooming appointment handoff.', '2026-01-07 11:00:00'),
|
||||
(13, 13, 3, 'Checked a pending adoption record.', '2026-01-07 20:00:00'),
|
||||
(14, 14, 3, 'Reviewed a refund request tied to an original sale.', '2026-01-08 05:00:00'),
|
||||
(15, 1, 1, 'Answered a customer support conversation.', '2026-01-08 14:00:00'),
|
||||
(16, 2, 2, 'Updated a product detail for the catalogue.', '2026-01-08 23:00:00'),
|
||||
(17, 3, 1, 'Reviewed store inventory adjustments.', '2026-01-09 08:00:00'),
|
||||
(18, 4, 1, 'Approved a purchase transaction at the register.', '2026-01-09 17:00:00'),
|
||||
(19, 5, 1, 'Updated a pet availability record.', '2026-01-10 02:00:00'),
|
||||
(20, 6, 1, 'Completed a grooming appointment handoff.', '2026-01-10 11:00:00'),
|
||||
(21, 7, 2, 'Checked a pending adoption record.', '2026-01-10 20:00:00'),
|
||||
(22, 8, 2, 'Reviewed a refund request tied to an original sale.', '2026-01-11 05:00:00'),
|
||||
(23, 9, 2, 'Answered a customer support conversation.', '2026-01-11 14:00:00'),
|
||||
(24, 10, 2, 'Updated a product detail for the catalogue.', '2026-01-11 23:00:00'),
|
||||
(25, 11, 3, 'Reviewed store inventory adjustments.', '2026-01-12 08:00:00'),
|
||||
(26, 12, 3, 'Approved a purchase transaction at the register.', '2026-01-12 17:00:00'),
|
||||
(27, 13, 3, 'Updated a pet availability record.', '2026-01-13 02:00:00'),
|
||||
(28, 14, 3, 'Completed a grooming appointment handoff.', '2026-01-13 11:00:00'),
|
||||
(29, 1, 1, 'Checked a pending adoption record.', '2026-01-13 20:00:00'),
|
||||
(30, 2, 2, 'Reviewed a refund request tied to an original sale.', '2026-01-14 05:00:00'),
|
||||
(31, 3, 1, 'Answered a customer support conversation.', '2026-01-14 14:00:00'),
|
||||
(32, 4, 1, 'Updated a product detail for the catalogue.', '2026-01-14 23:00:00'),
|
||||
(33, 5, 1, 'Reviewed store inventory adjustments.', '2026-01-15 08:00:00'),
|
||||
(34, 6, 1, 'Approved a purchase transaction at the register.', '2026-01-15 17:00:00'),
|
||||
(35, 7, 2, 'Updated a pet availability record.', '2026-01-16 02:00:00'),
|
||||
(36, 8, 2, 'Completed a grooming appointment handoff.', '2026-01-16 11:00:00'),
|
||||
(37, 9, 2, 'Checked a pending adoption record.', '2026-01-16 20:00:00'),
|
||||
(38, 10, 2, 'Reviewed a refund request tied to an original sale.', '2026-01-17 05:00:00'),
|
||||
(39, 11, 3, 'Answered a customer support conversation.', '2026-01-17 14:00:00'),
|
||||
(40, 12, 3, 'Updated a product detail for the catalogue.', '2026-01-17 23:00:00'),
|
||||
(41, 13, 3, 'Reviewed store inventory adjustments.', '2026-01-18 08:00:00'),
|
||||
(42, 14, 3, 'Approved a purchase transaction at the register.', '2026-01-18 17:00:00'),
|
||||
(43, 1, 1, 'Updated a pet availability record.', '2026-01-19 02:00:00'),
|
||||
(44, 2, 2, 'Completed a grooming appointment handoff.', '2026-01-19 11:00:00'),
|
||||
(45, 3, 1, 'Checked a pending adoption record.', '2026-01-19 20:00:00'),
|
||||
(46, 4, 1, 'Reviewed a refund request tied to an original sale.', '2026-01-20 05:00:00'),
|
||||
(47, 5, 1, 'Answered a customer support conversation.', '2026-01-20 14:00:00'),
|
||||
(48, 6, 1, 'Updated a product detail for the catalogue.', '2026-01-20 23:00:00'),
|
||||
(49, 7, 2, 'Reviewed store inventory adjustments.', '2026-01-21 08:00:00'),
|
||||
(50, 8, 2, 'Approved a purchase transaction at the register.', '2026-01-21 17:00:00'),
|
||||
(51, 9, 2, 'Updated a pet availability record.', '2026-01-22 02:00:00'),
|
||||
(52, 10, 2, 'Completed a grooming appointment handoff.', '2026-01-22 11:00:00'),
|
||||
(53, 11, 3, 'Checked a pending adoption record.', '2026-01-22 20:00:00'),
|
||||
(54, 12, 3, 'Reviewed a refund request tied to an original sale.', '2026-01-23 05:00:00'),
|
||||
(55, 13, 3, 'Answered a customer support conversation.', '2026-01-23 14:00:00'),
|
||||
(56, 14, 3, 'Updated a product detail for the catalogue.', '2026-01-23 23:00:00'),
|
||||
(57, 1, 1, 'Reviewed store inventory adjustments.', '2026-01-24 08:00:00'),
|
||||
(58, 2, 2, 'Approved a purchase transaction at the register.', '2026-01-24 17:00:00'),
|
||||
(59, 3, 1, 'Updated a pet availability record.', '2026-01-25 02:00:00'),
|
||||
(60, 4, 1, 'Completed a grooming appointment handoff.', '2026-01-25 11:00:00'),
|
||||
(61, 5, 1, 'Checked a pending adoption record.', '2026-01-25 20:00:00'),
|
||||
(62, 6, 1, 'Reviewed a refund request tied to an original sale.', '2026-01-26 05:00:00'),
|
||||
(63, 7, 2, 'Answered a customer support conversation.', '2026-01-26 14:00:00'),
|
||||
(64, 8, 2, 'Updated a product detail for the catalogue.', '2026-01-26 23:00:00'),
|
||||
(65, 9, 2, 'Reviewed store inventory adjustments.', '2026-01-27 08:00:00'),
|
||||
(66, 10, 2, 'Approved a purchase transaction at the register.', '2026-01-27 17:00:00'),
|
||||
(67, 11, 3, 'Updated a pet availability record.', '2026-01-28 02:00:00'),
|
||||
(68, 12, 3, 'Completed a grooming appointment handoff.', '2026-01-28 11:00:00'),
|
||||
(69, 13, 3, 'Checked a pending adoption record.', '2026-01-28 20:00:00'),
|
||||
(70, 14, 3, 'Reviewed a refund request tied to an original sale.', '2026-01-29 05:00:00'),
|
||||
(71, 1, 1, 'Answered a customer support conversation.', '2026-01-29 14:00:00'),
|
||||
(72, 2, 2, 'Updated a product detail for the catalogue.', '2026-01-29 23:00:00'),
|
||||
(73, 3, 1, 'Reviewed store inventory adjustments.', '2026-01-30 08:00:00'),
|
||||
(74, 4, 1, 'Approved a purchase transaction at the register.', '2026-01-30 17:00:00'),
|
||||
(75, 5, 1, 'Updated a pet availability record.', '2026-01-31 02:00:00'),
|
||||
(76, 6, 1, 'Completed a grooming appointment handoff.', '2026-01-31 11:00:00'),
|
||||
(77, 7, 2, 'Checked a pending adoption record.', '2026-01-31 20:00:00'),
|
||||
(78, 8, 2, 'Reviewed a refund request tied to an original sale.', '2026-02-01 05:00:00'),
|
||||
(79, 9, 2, 'Answered a customer support conversation.', '2026-02-01 14:00:00'),
|
||||
(80, 10, 2, 'Updated a product detail for the catalogue.', '2026-02-01 23:00:00'),
|
||||
(81, 11, 3, 'Reviewed store inventory adjustments.', '2026-02-02 08:00:00'),
|
||||
(82, 12, 3, 'Approved a purchase transaction at the register.', '2026-02-02 17:00:00'),
|
||||
(83, 13, 3, 'Updated a pet availability record.', '2026-02-03 02:00:00'),
|
||||
(84, 14, 3, 'Completed a grooming appointment handoff.', '2026-02-03 11:00:00'),
|
||||
(85, 1, 1, 'Checked a pending adoption record.', '2026-02-03 20:00:00'),
|
||||
(86, 2, 2, 'Reviewed a refund request tied to an original sale.', '2026-02-04 05:00:00'),
|
||||
(87, 3, 1, 'Answered a customer support conversation.', '2026-02-04 14:00:00'),
|
||||
(88, 4, 1, 'Updated a product detail for the catalogue.', '2026-02-04 23:00:00'),
|
||||
(89, 5, 1, 'Reviewed store inventory adjustments.', '2026-02-05 08:00:00'),
|
||||
(90, 6, 1, 'Approved a purchase transaction at the register.', '2026-02-05 17:00:00'),
|
||||
(91, 7, 2, 'Updated a pet availability record.', '2026-02-06 02:00:00'),
|
||||
(92, 8, 2, 'Completed a grooming appointment handoff.', '2026-02-06 11:00:00'),
|
||||
(93, 9, 2, 'Checked a pending adoption record.', '2026-02-06 20:00:00'),
|
||||
(94, 10, 2, 'Reviewed a refund request tied to an original sale.', '2026-02-07 05:00:00'),
|
||||
(95, 11, 3, 'Answered a customer support conversation.', '2026-02-07 14:00:00'),
|
||||
(96, 12, 3, 'Updated a product detail for the catalogue.', '2026-02-07 23:00:00'),
|
||||
(97, 13, 3, 'Reviewed store inventory adjustments.', '2026-02-08 08:00:00'),
|
||||
(98, 14, 3, 'Approved a purchase transaction at the register.', '2026-02-08 17:00:00'),
|
||||
(99, 1, 1, 'Updated a pet availability record.', '2026-02-09 02:00:00'),
|
||||
(100, 2, 2, 'Completed a grooming appointment handoff.', '2026-02-09 11:00:00'),
|
||||
(101, 3, 1, 'Checked a pending adoption record.', '2026-02-09 20:00:00'),
|
||||
(102, 4, 1, 'Reviewed a refund request tied to an original sale.', '2026-02-10 05:00:00'),
|
||||
(103, 5, 1, 'Answered a customer support conversation.', '2026-02-10 14:00:00'),
|
||||
(104, 6, 1, 'Updated a product detail for the catalogue.', '2026-02-10 23:00:00'),
|
||||
(105, 7, 2, 'Reviewed store inventory adjustments.', '2026-02-11 08:00:00'),
|
||||
(106, 8, 2, 'Approved a purchase transaction at the register.', '2026-02-11 17:00:00'),
|
||||
(107, 9, 2, 'Updated a pet availability record.', '2026-02-12 02:00:00'),
|
||||
(108, 10, 2, 'Completed a grooming appointment handoff.', '2026-02-12 11:00:00'),
|
||||
(109, 11, 3, 'Checked a pending adoption record.', '2026-02-12 20:00:00'),
|
||||
(110, 12, 3, 'Reviewed a refund request tied to an original sale.', '2026-02-13 05:00:00'),
|
||||
(111, 13, 3, 'Answered a customer support conversation.', '2026-02-13 14:00:00'),
|
||||
(112, 14, 3, 'Updated a product detail for the catalogue.', '2026-02-13 23:00:00'),
|
||||
(113, 1, 1, 'Reviewed store inventory adjustments.', '2026-02-14 08:00:00'),
|
||||
(114, 2, 2, 'Approved a purchase transaction at the register.', '2026-02-14 17:00:00'),
|
||||
(115, 3, 1, 'Updated a pet availability record.', '2026-02-15 02:00:00'),
|
||||
(116, 4, 1, 'Completed a grooming appointment handoff.', '2026-02-15 11:00:00'),
|
||||
(117, 5, 1, 'Checked a pending adoption record.', '2026-02-15 20:00:00'),
|
||||
(118, 6, 1, 'Reviewed a refund request tied to an original sale.', '2026-02-16 05:00:00'),
|
||||
(119, 7, 2, 'Answered a customer support conversation.', '2026-02-16 14:00:00'),
|
||||
(120, 8, 2, 'Updated a product detail for the catalogue.', '2026-02-16 23:00:00');
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE activityLog
|
||||
ADD COLUMN usernameSnapshot VARCHAR(50) NULL,
|
||||
ADD COLUMN fullNameSnapshot VARCHAR(100) NULL,
|
||||
ADD COLUMN roleSnapshot VARCHAR(20) NULL,
|
||||
ADD COLUMN storeNameSnapshot VARCHAR(100) NULL;
|
||||
|
||||
CREATE INDEX idx_activity_log_timestamp_id ON activityLog(logTimestamp, logId);
|
||||
@@ -7,7 +7,7 @@ Made by **Group 2**, Shiv, Nikitha, Alex, Harkamal.
|
||||
## Requirements
|
||||
|
||||
- IntelliJ IDEA (Community or Ultimate)
|
||||
- Java 17+
|
||||
- Java 25+
|
||||
- Maven (handled through IntelliJ)
|
||||
- Docker and Docker Compose (for the local MySQL container)
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ module org.example.petshopdesktop {
|
||||
opens org.example.petshopdesktop.api.dto.employee to com.fasterxml.jackson.databind;
|
||||
opens org.example.petshopdesktop.api.dto.analytics to com.fasterxml.jackson.databind;
|
||||
opens org.example.petshopdesktop.api.dto.purchaseorder to com.fasterxml.jackson.databind;
|
||||
opens org.example.petshopdesktop.api.dto.activity to com.fasterxml.jackson.databind;
|
||||
|
||||
exports org.example.petshopdesktop;
|
||||
exports org.example.petshopdesktop.controllers;
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package org.example.petshopdesktop.api.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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package org.example.petshopdesktop.api.endpoints;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import org.example.petshopdesktop.api.ApiClient;
|
||||
import org.example.petshopdesktop.api.dto.activity.ActivityLogResponse;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class ActivityLogApi {
|
||||
private static final ActivityLogApi INSTANCE = new ActivityLogApi();
|
||||
private final ApiClient apiClient;
|
||||
|
||||
private ActivityLogApi() {
|
||||
this.apiClient = ApiClient.getInstance();
|
||||
}
|
||||
|
||||
public static ActivityLogApi getInstance() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
public List<ActivityLogResponse> getActivityLogs(int limit) throws Exception {
|
||||
String path = "/api/v1/activity-logs?limit=" + limit;
|
||||
String response = apiClient.getRawResponse(path);
|
||||
return apiClient.getObjectMapper().readValue(
|
||||
response,
|
||||
new TypeReference<List<ActivityLogResponse>>() {}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package org.example.petshopdesktop.controllers;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.event.ActionEvent;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.TableColumn;
|
||||
import javafx.scene.control.TableView;
|
||||
import org.example.petshopdesktop.api.dto.activity.ActivityLogResponse;
|
||||
import org.example.petshopdesktop.api.endpoints.ActivityLogApi;
|
||||
import org.example.petshopdesktop.util.ActivityLogger;
|
||||
import org.example.petshopdesktop.util.TableViewSupport;
|
||||
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
|
||||
public class ActivityLogController {
|
||||
|
||||
@FXML
|
||||
private TableView<ActivityLogResponse> tvActivityLogs;
|
||||
|
||||
@FXML
|
||||
private TableColumn<ActivityLogResponse, Object> colTimestamp;
|
||||
|
||||
@FXML
|
||||
private TableColumn<ActivityLogResponse, String> colUser;
|
||||
|
||||
@FXML
|
||||
private TableColumn<ActivityLogResponse, String> colRole;
|
||||
|
||||
@FXML
|
||||
private TableColumn<ActivityLogResponse, String> colStore;
|
||||
|
||||
@FXML
|
||||
private TableColumn<ActivityLogResponse, String> colActivity;
|
||||
|
||||
@FXML
|
||||
private Button btnRefresh;
|
||||
|
||||
@FXML
|
||||
private Label lblStatus;
|
||||
|
||||
@FXML
|
||||
private Label lblError;
|
||||
|
||||
private final ObservableList<ActivityLogResponse> activityLogs = FXCollections.observableArrayList();
|
||||
private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
private static final int DEFAULT_LIMIT = 2000;
|
||||
|
||||
@FXML
|
||||
public void initialize() {
|
||||
colTimestamp.setCellValueFactory(data -> new javafx.beans.property.SimpleObjectProperty<>(data.getValue().getLogTimestamp()));
|
||||
colTimestamp.setCellFactory(column -> new javafx.scene.control.TableCell<>() {
|
||||
@Override
|
||||
protected void updateItem(Object item, boolean empty) {
|
||||
super.updateItem(item, empty);
|
||||
if (empty || item == null) {
|
||||
setText(null);
|
||||
} else if (item instanceof java.time.LocalDateTime time) {
|
||||
setText(time.format(formatter));
|
||||
} else {
|
||||
setText(item.toString());
|
||||
}
|
||||
}
|
||||
});
|
||||
colUser.setCellValueFactory(data -> new javafx.beans.property.SimpleStringProperty(displayUser(data.getValue())));
|
||||
colRole.setCellValueFactory(data -> new javafx.beans.property.SimpleStringProperty(displayRole(data.getValue())));
|
||||
colStore.setCellValueFactory(data -> new javafx.beans.property.SimpleStringProperty(displayStore(data.getValue())));
|
||||
colActivity.setCellValueFactory(data -> new javafx.beans.property.SimpleStringProperty(nullToBlank(data.getValue().getActivity())));
|
||||
|
||||
TableViewSupport.bindSortedItems(tvActivityLogs, new javafx.collections.transformation.FilteredList<>(activityLogs, a -> true));
|
||||
loadLogs();
|
||||
}
|
||||
|
||||
@FXML
|
||||
void btnRefreshClicked(ActionEvent event) {
|
||||
loadLogs();
|
||||
}
|
||||
|
||||
private void loadLogs() {
|
||||
lblError.setText("");
|
||||
tvActivityLogs.setDisable(true);
|
||||
btnRefresh.setDisable(true);
|
||||
|
||||
new Thread(() -> {
|
||||
try {
|
||||
List<ActivityLogResponse> content = ActivityLogApi.getInstance().getActivityLogs(DEFAULT_LIMIT);
|
||||
List<ActivityLogResponse> safeContent = content != null ? content : List.of();
|
||||
|
||||
Platform.runLater(() -> {
|
||||
activityLogs.setAll(safeContent);
|
||||
tvActivityLogs.setDisable(false);
|
||||
btnRefresh.setDisable(false);
|
||||
TableViewSupport.flashStatus(lblStatus, "Refreshed");
|
||||
});
|
||||
} catch (Exception e) {
|
||||
ActivityLogger.getInstance().logException("ActivityLogController.loadLogs", e, "Loading activity logs");
|
||||
Platform.runLater(() -> {
|
||||
lblError.setText(e.getMessage() == null || e.getMessage().isBlank()
|
||||
? "Could not load activity logs."
|
||||
: "Could not load activity logs: " + e.getMessage());
|
||||
tvActivityLogs.setDisable(false);
|
||||
btnRefresh.setDisable(false);
|
||||
});
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
private String displayUser(ActivityLogResponse log) {
|
||||
if (log == null) {
|
||||
return "";
|
||||
}
|
||||
if (log.getFullNameSnapshot() != null && !log.getFullNameSnapshot().isBlank()) {
|
||||
return log.getFullNameSnapshot();
|
||||
}
|
||||
if (log.getFullName() != null && !log.getFullName().isBlank()) {
|
||||
return log.getFullName();
|
||||
}
|
||||
if (log.getUsernameSnapshot() != null && !log.getUsernameSnapshot().isBlank()) {
|
||||
return log.getUsernameSnapshot();
|
||||
}
|
||||
if (log.getUsername() != null && !log.getUsername().isBlank()) {
|
||||
return log.getUsername();
|
||||
}
|
||||
return log.getUserId() != null ? String.valueOf(log.getUserId()) : "";
|
||||
}
|
||||
|
||||
private String displayRole(ActivityLogResponse log) {
|
||||
if (log == null) {
|
||||
return "";
|
||||
}
|
||||
if (log.getRoleSnapshot() != null && !log.getRoleSnapshot().isBlank()) {
|
||||
return log.getRoleSnapshot();
|
||||
}
|
||||
return nullToBlank(log.getRole());
|
||||
}
|
||||
|
||||
private String displayStore(ActivityLogResponse log) {
|
||||
if (log == null) {
|
||||
return "";
|
||||
}
|
||||
if (log.getStoreNameSnapshot() != null && !log.getStoreNameSnapshot().isBlank()) {
|
||||
return log.getStoreNameSnapshot();
|
||||
}
|
||||
return nullToBlank(log.getStoreName());
|
||||
}
|
||||
|
||||
private String nullToBlank(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,9 @@ public class MainLayoutController {
|
||||
@FXML
|
||||
private Button btnProducts;
|
||||
|
||||
@FXML
|
||||
private Button btnActivityLogs;
|
||||
|
||||
@FXML
|
||||
private Button btnSalesHistory;
|
||||
|
||||
@@ -178,6 +181,12 @@ public class MainLayoutController {
|
||||
updateButtons(btnAnalytics);
|
||||
}
|
||||
|
||||
@FXML
|
||||
void btnActivityLogsClicked(ActionEvent event) {
|
||||
loadView("activity-log-view.fxml");
|
||||
updateButtons(btnActivityLogs);
|
||||
}
|
||||
|
||||
@FXML
|
||||
void btnServicesClicked(ActionEvent event) {
|
||||
loadView("service-view.fxml");
|
||||
@@ -401,6 +410,11 @@ public class MainLayoutController {
|
||||
btnAnalytics.setManaged(canViewAnalytics);
|
||||
}
|
||||
|
||||
if (btnActivityLogs != null) {
|
||||
btnActivityLogs.setVisible(isAdmin);
|
||||
btnActivityLogs.setManaged(isAdmin);
|
||||
}
|
||||
|
||||
btnSalesHistory.setText(isAdmin ? "Sales History" : "Sales");
|
||||
|
||||
// Initial chat state and subscription
|
||||
@@ -451,6 +465,7 @@ public class MainLayoutController {
|
||||
btnPurchaseOrders,
|
||||
btnStaffAccounts,
|
||||
btnAnalytics,
|
||||
btnActivityLogs,
|
||||
btnChat
|
||||
};
|
||||
|
||||
|
||||
@@ -220,16 +220,24 @@
|
||||
<Insets bottom="8.0" left="10.0" right="10.0" top="8.0" />
|
||||
</padding>
|
||||
</Button>
|
||||
<Button fx:id="btnStaffAccounts" alignment="CENTER_LEFT" maxWidth="Infinity" mnemonicParsing="false" onAction="#btnStaffAccountsClicked" style="-fx-background-color: transparent; -fx-background-radius: 8; -fx-cursor: hand; -fx-focus-color: transparent; -fx-faint-focus-color: transparent;" text="User Accounts" textFill="#cbd5e1">
|
||||
<font>
|
||||
<Font name="System" size="12.0" />
|
||||
</font>
|
||||
<padding>
|
||||
<Insets bottom="8.0" left="10.0" right="10.0" top="8.0" />
|
||||
</padding>
|
||||
</Button>
|
||||
<Button fx:id="btnStaffAccounts" alignment="CENTER_LEFT" maxWidth="Infinity" mnemonicParsing="false" onAction="#btnStaffAccountsClicked" style="-fx-background-color: transparent; -fx-background-radius: 8; -fx-cursor: hand; -fx-focus-color: transparent; -fx-faint-focus-color: transparent;" text="User Accounts" textFill="#cbd5e1">
|
||||
<font>
|
||||
<Font name="System" size="12.0" />
|
||||
</font>
|
||||
<padding>
|
||||
<Insets bottom="8.0" left="10.0" right="10.0" top="8.0" />
|
||||
</padding>
|
||||
</Button>
|
||||
<Button fx:id="btnActivityLogs" alignment="CENTER_LEFT" maxWidth="Infinity" mnemonicParsing="false" onAction="#btnActivityLogsClicked" style="-fx-background-color: transparent; -fx-background-radius: 8; -fx-cursor: hand; -fx-focus-color: transparent; -fx-faint-focus-color: transparent;" text="Activity Logs" textFill="#cbd5e1">
|
||||
<font>
|
||||
<Font name="System" size="12.0" />
|
||||
</font>
|
||||
<padding>
|
||||
<Insets bottom="8.0" left="10.0" right="10.0" top="8.0" />
|
||||
</padding>
|
||||
</Button>
|
||||
|
||||
<Button fx:id="btnLogout" alignment="CENTER_LEFT" maxWidth="Infinity" mnemonicParsing="false" onAction="#btnLogoutClicked" style="-fx-background-color: rgba(255,255,255,0.08); -fx-background-radius: 8; -fx-cursor: hand; -fx-focus-color: transparent; -fx-faint-focus-color: transparent;" text="Logout" textFill="#e2e8f0">
|
||||
<Button fx:id="btnLogout" alignment="CENTER_LEFT" maxWidth="Infinity" mnemonicParsing="false" onAction="#btnLogoutClicked" style="-fx-background-color: rgba(255,255,255,0.08); -fx-background-radius: 8; -fx-cursor: hand; -fx-focus-color: transparent; -fx-faint-focus-color: transparent;" text="Logout" textFill="#e2e8f0">
|
||||
<font>
|
||||
<Font name="System" size="12.0" />
|
||||
</font>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?import javafx.geometry.Insets?>
|
||||
<?import javafx.scene.control.Button?>
|
||||
<?import javafx.scene.control.Label?>
|
||||
<?import javafx.scene.control.TableColumn?>
|
||||
<?import javafx.scene.control.TableView?>
|
||||
<?import javafx.scene.layout.HBox?>
|
||||
<?import javafx.scene.layout.VBox?>
|
||||
<?import javafx.scene.text.Font?>
|
||||
|
||||
<VBox spacing="18.0" style="-fx-font-size: 14px;" xmlns="http://javafx.com/javafx/25" xmlns:fx="http://javafx.com/fxml/1" fx:controller="org.example.petshopdesktop.controllers.ActivityLogController">
|
||||
<padding>
|
||||
<Insets bottom="20.0" left="20.0" right="20.0" top="20.0" />
|
||||
</padding>
|
||||
|
||||
<children>
|
||||
<HBox alignment="CENTER_LEFT" spacing="20.0">
|
||||
<children>
|
||||
<Label text="Activity Logs" textFill="#2c3e50">
|
||||
<font>
|
||||
<Font name="System Bold" size="30.0" />
|
||||
</font>
|
||||
</Label>
|
||||
<Button fx:id="btnRefresh" mnemonicParsing="false" onAction="#btnRefreshClicked" prefHeight="44.0" prefWidth="118.0" style="-fx-background-color: #4ECDC4; -fx-cursor: hand; -fx-background-radius: 8;" text="Refresh" textFill="WHITE">
|
||||
<font>
|
||||
<Font name="System Bold" size="14.0" />
|
||||
</font>
|
||||
<padding>
|
||||
<Insets bottom="12.0" left="24.0" right="24.0" top="12.0" />
|
||||
</padding>
|
||||
</Button>
|
||||
</children>
|
||||
</HBox>
|
||||
|
||||
<Label text="Showing most recent 2000 activity records" textFill="#64748b" />
|
||||
|
||||
<TableView fx:id="tvActivityLogs" style="-fx-background-color: white; -fx-background-radius: 12;" VBox.vgrow="ALWAYS">
|
||||
<columns>
|
||||
<TableColumn fx:id="colTimestamp" prefWidth="170.0" text="Timestamp" />
|
||||
<TableColumn fx:id="colUser" prefWidth="170.0" text="User" />
|
||||
<TableColumn fx:id="colRole" prefWidth="90.0" text="Role" />
|
||||
<TableColumn fx:id="colStore" prefWidth="160.0" text="Store" />
|
||||
<TableColumn fx:id="colActivity" prefWidth="520.0" text="Activity" />
|
||||
</columns>
|
||||
</TableView>
|
||||
|
||||
<Label fx:id="lblStatus" text="" textFill="#64748b" visible="false" managed="true" />
|
||||
|
||||
<Label fx:id="lblError" text="" textFill="#FF6B6B" wrapText="true" />
|
||||
</children>
|
||||
</VBox>
|
||||
Reference in New Issue
Block a user