diff --git a/backend/src/main/java/com/petshop/backend/config/ActivityLoggingFilter.java b/backend/src/main/java/com/petshop/backend/config/ActivityLoggingFilter.java new file mode 100644 index 00000000..39b11cf2 --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/config/ActivityLoggingFilter.java @@ -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); + } +} diff --git a/backend/src/main/java/com/petshop/backend/config/ActivityLoggingFilterRegistrationConfig.java b/backend/src/main/java/com/petshop/backend/config/ActivityLoggingFilterRegistrationConfig.java new file mode 100644 index 00000000..17f6fe2d --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/config/ActivityLoggingFilterRegistrationConfig.java @@ -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 activityLoggingFilterRegistration(ActivityLoggingFilter activityLoggingFilter) { + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(activityLoggingFilter); + registrationBean.setEnabled(false); + return registrationBean; + } +} diff --git a/backend/src/main/java/com/petshop/backend/config/FlywayContextInitializer.java b/backend/src/main/java/com/petshop/backend/config/FlywayContextInitializer.java index 746d4682..0836a74f 100644 --- a/backend/src/main/java/com/petshop/backend/config/FlywayContextInitializer.java +++ b/backend/src/main/java/com/petshop/backend/config/FlywayContextInitializer.java @@ -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> getActivityLogs( + @RequestParam(defaultValue = "2000") int limit) { + int safeLimit = Math.min(Math.max(1, limit), 10000); + return ResponseEntity.ok(activityLogService.getLogs(safeLimit)); + } +} 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 ec4f822b..c0eff22e 100644 --- a/backend/src/main/java/com/petshop/backend/controller/AuthController.java +++ b/backend/src/main/java/com/petshop/backend/controller/AuthController.java @@ -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 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 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 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 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 error = new HashMap<>(); error.put("message", disabledException.getMessage()); return ResponseEntity.status(HttpStatus.FORBIDDEN).body(error); 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 index 7445de57..04dc79ec 100644 --- a/backend/src/main/java/com/petshop/backend/entity/ActivityLog.java +++ b/backend/src/main/java/com/petshop/backend/entity/ActivityLog.java @@ -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; } diff --git a/backend/src/main/java/com/petshop/backend/repository/ActivityLogRepository.java b/backend/src/main/java/com/petshop/backend/repository/ActivityLogRepository.java index 5c5db4c5..fa79d06a 100644 --- a/backend/src/main/java/com/petshop/backend/repository/ActivityLogRepository.java +++ b/backend/src/main/java/com/petshop/backend/repository/ActivityLogRepository.java @@ -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 { + 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/security/JwtUtil.java b/backend/src/main/java/com/petshop/backend/security/JwtUtil.java index 3d4541bc..b0a5fec5 100644 --- a/backend/src/main/java/com/petshop/backend/security/JwtUtil.java +++ b/backend/src/main/java/com/petshop/backend/security/JwtUtil.java @@ -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)); } diff --git a/backend/src/main/java/com/petshop/backend/security/RestAccessDeniedHandler.java b/backend/src/main/java/com/petshop/backend/security/RestAccessDeniedHandler.java index 2ef240e9..fcaa49bc 100644 --- a/backend/src/main/java/com/petshop/backend/security/RestAccessDeniedHandler.java +++ b/backend/src/main/java/com/petshop/backend/security/RestAccessDeniedHandler.java @@ -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, diff --git a/backend/src/main/java/com/petshop/backend/security/RestAuthenticationEntryPoint.java b/backend/src/main/java/com/petshop/backend/security/RestAuthenticationEntryPoint.java index 2ae541b4..fb28314b 100644 --- a/backend/src/main/java/com/petshop/backend/security/RestAuthenticationEntryPoint.java +++ b/backend/src/main/java/com/petshop/backend/security/RestAuthenticationEntryPoint.java @@ -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, 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 d26e3e84..c197242f 100644 --- a/backend/src/main/java/com/petshop/backend/security/SecurityConfig.java +++ b/backend/src/main/java/com/petshop/backend/security/SecurityConfig.java @@ -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(); } } diff --git a/backend/src/main/java/com/petshop/backend/service/ActivityLogService.java b/backend/src/main/java/com/petshop/backend/service/ActivityLogService.java new file mode 100644 index 00000000..f5cd942c --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/service/ActivityLogService.java @@ -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 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; + } +} 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 a6661cf1..77141b92 100644 --- a/backend/src/main/java/com/petshop/backend/service/UserService.java +++ b/backend/src/main/java/com/petshop/backend/service/UserService.java @@ -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()); } diff --git a/backend/src/main/resources/application-local.yml b/backend/src/main/resources/application-local.yml new file mode 100644 index 00000000..2cdd5910 --- /dev/null +++ b/backend/src/main/resources/application-local.yml @@ -0,0 +1,2 @@ +jwt: + secret: ${JWT_SECRET:local-development-jwt-secret-change-me-please-123456} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 8db2e0af..d16835fb 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -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: diff --git a/backend/src/main/resources/db/migration/V2__seed_data.sql b/backend/src/main/resources/db/migration/V2__seed_data.sql index aab02cc1..970c8b43 100644 --- a/backend/src/main/resources/db/migration/V2__seed_data.sql +++ b/backend/src/main/resources/db/migration/V2__seed_data.sql @@ -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'); diff --git a/backend/src/main/resources/db/migration/V4__activity_log_updates.sql b/backend/src/main/resources/db/migration/V4__activity_log_updates.sql new file mode 100644 index 00000000..03d00660 --- /dev/null +++ b/backend/src/main/resources/db/migration/V4__activity_log_updates.sql @@ -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); diff --git a/desktop/README.md b/desktop/README.md index b525f4b2..878c47fc 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -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) diff --git a/desktop/src/main/java/module-info.java b/desktop/src/main/java/module-info.java index d6cefd63..ecd94fd5 100644 --- a/desktop/src/main/java/module-info.java +++ b/desktop/src/main/java/module-info.java @@ -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; diff --git a/desktop/src/main/java/org/example/petshopdesktop/api/dto/activity/ActivityLogResponse.java b/desktop/src/main/java/org/example/petshopdesktop/api/dto/activity/ActivityLogResponse.java new file mode 100644 index 00000000..482476eb --- /dev/null +++ b/desktop/src/main/java/org/example/petshopdesktop/api/dto/activity/ActivityLogResponse.java @@ -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; + } +} diff --git a/desktop/src/main/java/org/example/petshopdesktop/api/endpoints/ActivityLogApi.java b/desktop/src/main/java/org/example/petshopdesktop/api/endpoints/ActivityLogApi.java new file mode 100644 index 00000000..d4553cb8 --- /dev/null +++ b/desktop/src/main/java/org/example/petshopdesktop/api/endpoints/ActivityLogApi.java @@ -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 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>() {} + ); + } +} diff --git a/desktop/src/main/java/org/example/petshopdesktop/controllers/ActivityLogController.java b/desktop/src/main/java/org/example/petshopdesktop/controllers/ActivityLogController.java new file mode 100644 index 00000000..d593289e --- /dev/null +++ b/desktop/src/main/java/org/example/petshopdesktop/controllers/ActivityLogController.java @@ -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 tvActivityLogs; + + @FXML + private TableColumn colTimestamp; + + @FXML + private TableColumn colUser; + + @FXML + private TableColumn colRole; + + @FXML + private TableColumn colStore; + + @FXML + private TableColumn colActivity; + + @FXML + private Button btnRefresh; + + @FXML + private Label lblStatus; + + @FXML + private Label lblError; + + private final ObservableList 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 content = ActivityLogApi.getInstance().getActivityLogs(DEFAULT_LIMIT); + List 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; + } +} diff --git a/desktop/src/main/java/org/example/petshopdesktop/controllers/MainLayoutController.java b/desktop/src/main/java/org/example/petshopdesktop/controllers/MainLayoutController.java index 69916845..c4af659f 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/controllers/MainLayoutController.java +++ b/desktop/src/main/java/org/example/petshopdesktop/controllers/MainLayoutController.java @@ -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 }; diff --git a/desktop/src/main/resources/org/example/petshopdesktop/main-layout-view.fxml b/desktop/src/main/resources/org/example/petshopdesktop/main-layout-view.fxml index 984aad76..77800377 100644 --- a/desktop/src/main/resources/org/example/petshopdesktop/main-layout-view.fxml +++ b/desktop/src/main/resources/org/example/petshopdesktop/main-layout-view.fxml @@ -220,16 +220,24 @@ - + + - + + + +