Merge pull request #324 from RecentRunner/refactor/backend-cleanup

backend DRY/KISS cleanup
This commit is contained in:
2026-04-18 08:23:37 -06:00
committed by GitHub
44 changed files with 627 additions and 879 deletions

View File

@@ -1,13 +1,16 @@
package com.petshop.backend;
import com.petshop.backend.config.BusinessProperties;
import com.petshop.backend.config.FlywayContextInitializer;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.data.web.config.EnableSpringDataWebSupport;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableConfigurationProperties(BusinessProperties.class)
@EnableScheduling
@EnableAsync
@EnableSpringDataWebSupport(pageSerializationMode = EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO)

View File

@@ -0,0 +1,25 @@
package com.petshop.backend.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.math.BigDecimal;
import java.time.LocalTime;
@ConfigurationProperties(prefix = "petshop.business")
public record BusinessProperties(
LocalTime openTime,
LocalTime closeTime,
int slotIntervalMinutes,
long maxImageSizeBytes,
BigDecimal employeeDiscountPercent,
int loyaltyPointsPerDollar
) {
public BusinessProperties {
if (openTime == null) openTime = LocalTime.of(9, 0);
if (closeTime == null) closeTime = LocalTime.of(17, 0);
if (slotIntervalMinutes <= 0) slotIntervalMinutes = 30;
if (maxImageSizeBytes <= 0) maxImageSizeBytes = 5 * 1024 * 1024;
if (employeeDiscountPercent == null) employeeDiscountPercent = new BigDecimal("0.10");
if (loyaltyPointsPerDollar <= 0) loyaltyPointsPerDollar = 20;
}
}

View File

@@ -4,8 +4,6 @@ import com.petshop.backend.dto.adoption.AdoptionRequest;
import com.petshop.backend.dto.adoption.AdoptionResponse;
import com.petshop.backend.dto.adoption.CustomerAdoptionRequest;
import com.petshop.backend.dto.common.BulkDeleteRequest;
import com.petshop.backend.entity.User;
import com.petshop.backend.repository.UserRepository;
import com.petshop.backend.service.AdoptionService;
import com.petshop.backend.util.AuthenticationHelper;
import jakarta.validation.Valid;
@@ -14,8 +12,6 @@ import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
@@ -25,11 +21,11 @@ import java.time.LocalDate;
public class AdoptionController {
private final AdoptionService adoptionService;
private final UserRepository userRepository;
private final AuthenticationHelper authHelper;
public AdoptionController(AdoptionService adoptionService, UserRepository userRepository) {
public AdoptionController(AdoptionService adoptionService, AuthenticationHelper authHelper) {
this.adoptionService = adoptionService;
this.userRepository = userRepository;
this.authHelper = authHelper;
}
@GetMapping
@@ -41,17 +37,8 @@ public class AdoptionController {
@RequestParam(required = false) Long storeId,
@RequestParam(required = false) String date,
Pageable pageable) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String role = authentication.getAuthorities().stream()
.findFirst()
.map(authority -> authority.getAuthority().replace("ROLE_", ""))
.orElse(null);
Long effectiveCustomerId = customerId;
if (role != null && role.equals("CUSTOMER")) {
User user = AuthenticationHelper.getAuthenticatedUser(userRepository);
effectiveCustomerId = user.getId();
}
Long effectiveCustomerId = authHelper.getEffectiveCustomerId(customerId);
LocalDate adoptionDate = (date != null && !date.isBlank()) ? LocalDate.parse(date) : null;
@@ -61,18 +48,7 @@ public class AdoptionController {
@GetMapping("/{id}")
@PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')")
public ResponseEntity<AdoptionResponse> getAdoptionById(@PathVariable Long id) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String role = authentication.getAuthorities().stream()
.findFirst()
.map(authority -> authority.getAuthority().replace("ROLE_", ""))
.orElse(null);
Long customerId = null;
if (role != null && role.equals("CUSTOMER")) {
User user = AuthenticationHelper.getAuthenticatedUser(userRepository);
customerId = user.getId();
}
Long customerId = authHelper.getCustomerIdOrNull();
return ResponseEntity.ok(adoptionService.getAdoptionById(id, customerId));
}
@@ -85,27 +61,15 @@ public class AdoptionController {
@PostMapping("/request")
@PreAuthorize("hasAnyRole('CUSTOMER', 'ADMIN')")
public ResponseEntity<AdoptionResponse> requestAdoption(@Valid @RequestBody CustomerAdoptionRequest request) {
User user = AuthenticationHelper.getAuthenticatedUser(userRepository);
return ResponseEntity.status(HttpStatus.CREATED).body(
adoptionService.requestAdoption(user.getId(), request.getPetId(), request.getEmployeeId(), request.getSourceStoreId(), request.getAdoptionDate())
adoptionService.requestAdoption(authHelper.getAuthenticatedUser().getId(), request.getPetId(), request.getEmployeeId(), request.getSourceStoreId(), request.getAdoptionDate())
);
}
@PatchMapping("/{id}/cancel")
@PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')")
public ResponseEntity<AdoptionResponse> cancelAdoption(@PathVariable Long id) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String role = authentication.getAuthorities().stream()
.findFirst()
.map(authority -> authority.getAuthority().replace("ROLE_", ""))
.orElse(null);
Long customerId = null;
if ("CUSTOMER".equals(role)) {
User user = AuthenticationHelper.getAuthenticatedUser(userRepository);
customerId = user.getId();
}
Long customerId = authHelper.getCustomerIdOrNull();
return ResponseEntity.ok(adoptionService.cancelAdoption(id, customerId));
}

View File

@@ -5,14 +5,12 @@ import com.petshop.backend.dto.ai.AiChatResponse;
import com.petshop.backend.entity.Pet;
import com.petshop.backend.entity.User;
import com.petshop.backend.repository.PetRepository;
import com.petshop.backend.repository.UserRepository;
import com.petshop.backend.service.OpenRouterService;
import com.petshop.backend.util.AuthenticationHelper;
import com.petshop.backend.util.ContentFilter;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.web.bind.annotation.*;
import java.util.Collections;
@@ -24,24 +22,14 @@ public class AiChatController {
private final OpenRouterService openRouterService;
private final PetRepository petRepository;
private final UserRepository userRepository;
private final AuthenticationHelper authHelper;
public AiChatController(OpenRouterService openRouterService,
PetRepository petRepository,
UserRepository userRepository) {
AuthenticationHelper authHelper) {
this.openRouterService = openRouterService;
this.petRepository = petRepository;
this.userRepository = userRepository;
}
private User getCurrentUser() {
try {
return AuthenticationHelper.getAuthenticatedUser(userRepository);
}
catch (RuntimeException ex) {
throw new UsernameNotFoundException(ex.getMessage(), ex);
}
this.authHelper = authHelper;
}
@PostMapping("/message")
@@ -52,15 +40,13 @@ public class AiChatController {
}
ContentFilter.validate(request.getMessage());
User user = getCurrentUser();
User user = authHelper.getAuthenticatedUser();
List<Pet> userPets;
try {
userPets = petRepository.findAllByOwner_IdAndPetStatusInOrderByPetNameAsc(
user.getId(), List.of("Adopted", "Owned"));
}
catch (Exception e) {
} catch (Exception e) {
userPets = Collections.emptyList();
}
@@ -72,15 +58,9 @@ public class AiChatController {
);
return ResponseEntity.ok(AiChatResponse.ok(aiReply));
}
catch (IllegalStateException e) {
} catch (IllegalStateException e) {
return ResponseEntity.status(503).body(AiChatResponse.fail("AI service is not configured. Please contact support."));
}
catch (Exception e) {
} catch (Exception e) {
return ResponseEntity.status(502).body(AiChatResponse.fail("AI service is temporarily unavailable. Please try again later."));
}
}

View File

@@ -1,8 +1,6 @@
package com.petshop.backend.controller;
import com.petshop.backend.dto.analytics.DashboardResponse;
import com.petshop.backend.entity.User;
import com.petshop.backend.repository.UserRepository;
import com.petshop.backend.service.AnalyticsService;
import com.petshop.backend.util.AuthenticationHelper;
import org.springframework.format.annotation.DateTimeFormat;
@@ -20,11 +18,11 @@ import java.time.LocalDate;
public class AnalyticsController {
private final AnalyticsService analyticsService;
private final UserRepository userRepository;
private final AuthenticationHelper authHelper;
public AnalyticsController(AnalyticsService analyticsService, UserRepository userRepository) {
public AnalyticsController(AnalyticsService analyticsService, AuthenticationHelper authHelper) {
this.analyticsService = analyticsService;
this.userRepository = userRepository;
this.authHelper = authHelper;
}
@GetMapping("/dashboard")
@@ -41,7 +39,7 @@ public class AnalyticsController {
if (top < 1 || top > 50) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "top must be between 1 and 50");
}
User user = AuthenticationHelper.getAuthenticatedUser(userRepository);
var user = authHelper.getAuthenticatedUser();
java.time.LocalDateTime endDateTime = endDate != null ? endDate.plusDays(1).atStartOfDay() : null;
return ResponseEntity.ok(analyticsService.getDashboardData(days, top, user, paymentMethod, storeId, channel, endDateTime));
}

View File

@@ -3,8 +3,6 @@ package com.petshop.backend.controller;
import com.petshop.backend.dto.appointment.AppointmentRequest;
import com.petshop.backend.dto.appointment.AppointmentResponse;
import com.petshop.backend.dto.common.BulkDeleteRequest;
import com.petshop.backend.entity.User;
import com.petshop.backend.repository.UserRepository;
import com.petshop.backend.service.AppointmentService;
import com.petshop.backend.util.AuthenticationHelper;
import jakarta.validation.Valid;
@@ -13,8 +11,6 @@ import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
@@ -25,11 +21,11 @@ import java.util.List;
public class AppointmentController {
private final AppointmentService appointmentService;
private final UserRepository userRepository;
private final AuthenticationHelper authHelper;
public AppointmentController(AppointmentService appointmentService, UserRepository userRepository) {
public AppointmentController(AppointmentService appointmentService, AuthenticationHelper authHelper) {
this.appointmentService = appointmentService;
this.userRepository = userRepository;
this.authHelper = authHelper;
}
@GetMapping
@@ -43,17 +39,7 @@ public class AppointmentController {
@RequestParam(required = false) Long employeeId,
Pageable pageable) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String role = authentication.getAuthorities().stream()
.findFirst()
.map(authority -> authority.getAuthority().replace("ROLE_", ""))
.orElse(null);
Long effectiveCustomerId = customerId;
if ("CUSTOMER".equals(role)) {
User user = AuthenticationHelper.getAuthenticatedUser(userRepository);
effectiveCustomerId = user.getId();
}
Long effectiveCustomerId = authHelper.getEffectiveCustomerId(customerId);
LocalDate appointmentDate = (date != null && !date.isBlank()) ? LocalDate.parse(date) : null;
@@ -64,33 +50,15 @@ public class AppointmentController {
@GetMapping("/{id}")
@PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')")
public ResponseEntity<AppointmentResponse> getAppointmentById(@PathVariable Long id) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String role = authentication.getAuthorities().stream()
.findFirst()
.map(authority -> authority.getAuthority().replace("ROLE_", ""))
.orElse(null);
Long customerId = null;
if (role != null && role.equals("CUSTOMER")) {
User user = AuthenticationHelper.getAuthenticatedUser(userRepository);
customerId = user.getId();
}
Long customerId = authHelper.getCustomerIdOrNull();
return ResponseEntity.ok(appointmentService.getAppointmentById(id, customerId));
}
@PostMapping
@PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')")
public ResponseEntity<AppointmentResponse> createAppointment(@Valid @RequestBody AppointmentRequest request) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String role = authentication.getAuthorities().stream()
.findFirst()
.map(authority -> authority.getAuthority().replace("ROLE_", ""))
.orElse(null);
if ("CUSTOMER".equals(role)) {
User user = AuthenticationHelper.getAuthenticatedUser(userRepository);
if (!request.getCustomerId().equals(user.getId())) {
if (AuthenticationHelper.isCustomer()) {
if (!request.getCustomerId().equals(authHelper.getAuthenticatedUser().getId())) {
throw new org.springframework.security.access.AccessDeniedException("You can only create appointments for yourself");
}
}
@@ -101,18 +69,7 @@ public class AppointmentController {
@PatchMapping("/{id}/cancel")
@PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')")
public ResponseEntity<AppointmentResponse> cancelAppointment(@PathVariable Long id) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String role = authentication.getAuthorities().stream()
.findFirst()
.map(authority -> authority.getAuthority().replace("ROLE_", ""))
.orElse(null);
Long customerId = null;
if ("CUSTOMER".equals(role)) {
User user = AuthenticationHelper.getAuthenticatedUser(userRepository);
customerId = user.getId();
}
Long customerId = authHelper.getCustomerIdOrNull();
return ResponseEntity.ok(appointmentService.cancelAppointment(id, customerId));
}

View File

@@ -13,6 +13,9 @@ import com.petshop.backend.dto.auth.ResetPasswordResponse;
import com.petshop.backend.dto.auth.UserInfoResponse;
import com.petshop.backend.entity.StoreLocation;
import com.petshop.backend.entity.User;
import com.petshop.backend.exception.BusinessException;
import com.petshop.backend.exception.ConflictException;
import com.petshop.backend.exception.ResourceNotFoundException;
import com.petshop.backend.repository.UserRepository;
import com.petshop.backend.security.JwtUtil;
import com.petshop.backend.security.UserAuthCacheService;
@@ -21,7 +24,8 @@ import com.petshop.backend.service.AvatarStorageService;
import com.petshop.backend.service.EmailService;
import com.petshop.backend.service.PasswordResetService;
import com.petshop.backend.util.AuthenticationHelper;
import com.petshop.backend.util.PhoneUtils;
import com.petshop.backend.util.ImageValidationUtil;
import com.petshop.backend.util.StringUtils;
import jakarta.validation.Valid;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
@@ -60,8 +64,9 @@ public class AuthController {
private final PasswordResetService passwordResetService;
private final EmailService emailService;
private final UserAuthCacheService userAuthCacheService;
private final AuthenticationHelper authHelper;
public AuthController(AuthenticationManager authenticationManager, UserRepository userRepository, JwtUtil jwtUtil, PasswordEncoder passwordEncoder, AvatarStorageService avatarStorageService, ActivityLogService activityLogService, PasswordResetService passwordResetService, EmailService emailService, UserAuthCacheService userAuthCacheService) {
public AuthController(AuthenticationManager authenticationManager, UserRepository userRepository, JwtUtil jwtUtil, PasswordEncoder passwordEncoder, AvatarStorageService avatarStorageService, ActivityLogService activityLogService, PasswordResetService passwordResetService, EmailService emailService, UserAuthCacheService userAuthCacheService, AuthenticationHelper authHelper) {
this.authenticationManager = authenticationManager;
this.userRepository = userRepository;
this.jwtUtil = jwtUtil;
@@ -71,35 +76,30 @@ public class AuthController {
this.passwordResetService = passwordResetService;
this.emailService = emailService;
this.userAuthCacheService = userAuthCacheService;
this.authHelper = authHelper;
}
@PostMapping("/register")
public ResponseEntity<?> register(@Valid @RequestBody RegisterRequest request) {
String username = trimToNull(request.getUsername());
String email = trimToNull(request.getEmail());
String firstName = trimToNull(request.getFirstName());
String lastName = trimToNull(request.getLastName());
String phone = normalizePhone(request.getPhone());
public ResponseEntity<RegisterResponse> register(@Valid @RequestBody RegisterRequest request) {
String username = StringUtils.trimToNull(request.getUsername());
String email = StringUtils.trimToNull(request.getEmail());
String firstName = StringUtils.trimToNull(request.getFirstName());
String lastName = StringUtils.trimToNull(request.getLastName());
String phone = StringUtils.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);
throw new ConflictException("Username already exists");
}
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);
throw new ConflictException("Email already exists");
}
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);
throw new ConflictException("Phone already exists");
}
User user = new User();
@@ -108,7 +108,7 @@ public class AuthController {
user.setEmail(email);
user.setFirstName(firstName);
user.setLastName(lastName);
user.setFullName(joinFullName(firstName, lastName));
user.setFullName(StringUtils.fullName(firstName, lastName));
user.setPhone(phone);
user.setRole(User.Role.CUSTOMER);
user.setActive(true);
@@ -117,9 +117,7 @@ public class AuthController {
try {
savedUser = userRepository.save(user);
} catch (DataIntegrityViolationException e) {
Map<String, String> error = new HashMap<>();
error.put("message", "Username, email, or phone already exists");
return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
throw new ConflictException("Username, email, or phone already exists");
}
emailService.sendWelcome(savedUser);
@@ -137,7 +135,7 @@ public class AuthController {
}
@PostMapping("/login")
public ResponseEntity<?> login(@Valid @RequestBody LoginRequest request) {
public ResponseEntity<LoginResponse> login(@Valid @RequestBody LoginRequest request) {
try {
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword())
@@ -159,21 +157,15 @@ 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);
throw e;
} 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);
throw disabledException;
}
throw e;
} catch (DisabledException e) {
Map<String, String> error = new HashMap<>();
error.put("message", e.getMessage());
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(error);
throw e;
}
}
@@ -190,60 +182,54 @@ public class AuthController {
@Transactional(readOnly = true)
@GetMapping("/me")
public ResponseEntity<UserInfoResponse> getCurrentUser() {
User user = getAuthenticatedUser();
User user = authHelper.getAuthenticatedUser();
return ResponseEntity.ok(toUserInfoResponse(user));
}
@Transactional
@PutMapping("/me")
public ResponseEntity<?> updateProfile(@Valid @RequestBody ProfileUpdateRequest request) {
public ResponseEntity<UserInfoResponse> updateProfile(@Valid @RequestBody ProfileUpdateRequest request) {
Long userId = AuthenticationHelper.getAuthenticatedUserId();
User user = userRepository.findByIdForUpdate(userId)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
boolean invalidateToken = false;
String username = trimToNull(request.getUsername());
String username = StringUtils.trimToNull(request.getUsername());
if (username != null && !username.equals(user.getUsername())) {
if (userRepository.findByUsername(username).isPresent()) {
Map<String, String> error = new HashMap<>();
error.put("message", "Username already exists");
return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
throw new ConflictException("Username already exists");
}
user.setUsername(username);
invalidateToken = true;
}
String email = trimToNull(request.getEmail());
String email = StringUtils.trimToNull(request.getEmail());
if (email != null && !email.equals(user.getEmail())) {
if (userRepository.findByEmail(email).isPresent()) {
Map<String, String> error = new HashMap<>();
error.put("message", "Email already exists");
return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
throw new ConflictException("Email already exists");
}
user.setEmail(email);
}
String firstName = trimToNull(request.getFirstName());
String firstName = StringUtils.trimToNull(request.getFirstName());
if (firstName != null) {
user.setFirstName(firstName);
}
String lastName = trimToNull(request.getLastName());
String lastName = StringUtils.trimToNull(request.getLastName());
if (lastName != null) {
user.setLastName(lastName);
}
if (firstName != null || lastName != null) {
user.setFullName(joinFullName(user.getFirstName(), user.getLastName()));
user.setFullName(StringUtils.fullName(user.getFirstName(), user.getLastName()));
}
if (request.getPhone() != null) {
String phone = normalizePhone(request.getPhone());
String phone = StringUtils.normalizePhone(request.getPhone());
if (!java.util.Objects.equals(phone, user.getPhone())) {
if (phone != null && userRepository.findByPhone(phone)
.filter(existing -> !existing.getId().equals(user.getId()))
.isPresent()) {
Map<String, String> error = new HashMap<>();
error.put("message", "Phone already exists");
return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
throw new ConflictException("Phone already exists");
}
user.setPhone(phone);
}
@@ -262,9 +248,7 @@ public class AuthController {
try {
updatedUser = userRepository.save(user);
} catch (DataIntegrityViolationException e) {
Map<String, String> error = new HashMap<>();
error.put("message", "Username, email, or phone already exists");
return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
throw new ConflictException("Username, email, or phone already exists");
}
userAuthCacheService.evict(updatedUser.getId());
return ResponseEntity.ok(toUserInfoResponse(updatedUser));
@@ -275,7 +259,7 @@ public class AuthController {
Long customerId = user.getRole() == User.Role.CUSTOMER ? user.getId() : null;
String fullName = user.getFullName();
if (fullName == null || fullName.isBlank()) {
fullName = joinFullName(user.getFirstName(), user.getLastName());
fullName = StringUtils.fullName(user.getFirstName(), user.getLastName());
}
return new UserInfoResponse(
user.getId(),
@@ -294,90 +278,26 @@ public class AuthController {
);
}
private String trimToNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private String normalizePhone(String value) {
return trimToNull(PhoneUtils.normalize(trimToNull(value)));
}
private NameParts splitFullName(String value) {
String normalized = trimToNull(value);
if (normalized == null) {
throw new IllegalArgumentException("Full name is required");
}
String[] parts = normalized.split("\\s+", 2);
String firstName = parts[0];
String lastName = parts.length > 1 ? parts[1] : "";
return new NameParts(firstName, lastName, joinFullName(firstName, lastName));
}
private String joinFullName(String firstName, String lastName) {
String first = trimToNull(firstName);
String last = trimToNull(lastName);
if (first == null) {
return last == null ? null : last;
}
if (last == null) {
return first;
}
return first + " " + last;
}
private record NameParts(String firstName, String lastName, String fullName) {
}
@PostMapping("/me/avatar")
public ResponseEntity<?> uploadAvatar(@RequestParam("avatar") MultipartFile file) {
User user = getAuthenticatedUser();
public ResponseEntity<AvatarUploadResponse> uploadAvatar(@RequestParam("avatar") MultipartFile file) throws IOException {
User user = authHelper.getAuthenticatedUser();
if (file.isEmpty()) {
Map<String, String> error = new HashMap<>();
error.put("message", "Please select a file to upload");
return ResponseEntity.badRequest().body(error);
}
ImageValidationUtil.validate(file);
if (file.getSize() > 5 * 1024 * 1024) {
Map<String, String> error = new HashMap<>();
error.put("message", "File size must not exceed 5MB");
return ResponseEntity.badRequest().body(error);
}
avatarStorageService.deleteAvatar(user);
String avatarPath = avatarStorageService.storeAvatar(file);
user.setAvatarUrl(avatarPath);
userRepository.save(user);
String contentType = file.getContentType();
if (contentType == null || (!contentType.equals("image/jpeg") && !contentType.equals("image/png") && !contentType.equals("image/gif"))) {
Map<String, String> error = new HashMap<>();
error.put("message", "Only JPG, PNG, and GIF images are allowed");
return ResponseEntity.badRequest().body(error);
}
try {
avatarStorageService.deleteAvatar(user);
String avatarPath = avatarStorageService.storeAvatar(file);
user.setAvatarUrl(avatarPath);
userRepository.save(user);
return ResponseEntity.ok(new AvatarUploadResponse(avatarStorageService.toOwnerAvatarUrl(user), "Avatar uploaded successfully"));
} catch (IOException e) {
Map<String, String> error = new HashMap<>();
error.put("message", "Failed to upload avatar: " + e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
}
return ResponseEntity.ok(new AvatarUploadResponse(avatarStorageService.toOwnerAvatarUrl(user), "Avatar uploaded successfully"));
}
@GetMapping("/me/avatar")
public ResponseEntity<?> getAvatar() {
User user = getAuthenticatedUser();
User user = authHelper.getAuthenticatedUser();
if (!avatarStorageService.hasAvatar(user)) {
Map<String, String> error = new HashMap<>();
error.put("message", "No avatar uploaded");
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
throw new ResourceNotFoundException("No avatar uploaded");
}
Map<String, String> response = new HashMap<>();
@@ -387,7 +307,7 @@ public class AuthController {
@GetMapping("/me/avatar/file")
public ResponseEntity<Resource> getAvatarFile() {
User user = getAuthenticatedUser();
User user = authHelper.getAuthenticatedUser();
if (!avatarStorageService.hasAvatar(user)) {
return ResponseEntity.notFound().build();
@@ -404,12 +324,13 @@ public class AuthController {
@DeleteMapping("/me/avatar")
public ResponseEntity<?> deleteAvatar() {
User user = getAuthenticatedUser();
User user = authHelper.getAuthenticatedUser();
if (avatarStorageService.hasAvatar(user)) {
try {
avatarStorageService.deleteAvatar(user);
} catch (IOException e) {
log.warn("Failed to delete avatar for user {}: {}", user.getId(), e.getMessage());
}
user.setAvatarUrl(null);
userRepository.save(user);
@@ -428,11 +349,4 @@ public class AuthController {
return ResponseEntity.ok(response);
}
private User getAuthenticatedUser() {
try {
return AuthenticationHelper.getAuthenticatedUser(userRepository);
} catch (RuntimeException ex) {
throw new UsernameNotFoundException(ex.getMessage(), ex);
}
}
}

View File

@@ -9,7 +9,6 @@ import com.petshop.backend.entity.Message;
import com.petshop.backend.entity.User;
import com.petshop.backend.exception.ResourceNotFoundException;
import com.petshop.backend.repository.MessageRepository;
import com.petshop.backend.repository.UserRepository;
import com.petshop.backend.service.ChatAttachmentStorageService;
import com.petshop.backend.service.ChatRealtimeService;
import com.petshop.backend.service.ChatService;
@@ -22,7 +21,6 @@ import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@@ -35,33 +33,25 @@ public class ChatController {
private final ChatService chatService;
private final ChatRealtimeService chatRealtimeService;
private final OpenRouterAiService openRouterAiService;
private final UserRepository userRepository;
private final AuthenticationHelper authHelper;
private final ChatAttachmentStorageService attachmentStorageService;
private final MessageRepository messageRepository;
public ChatController(ChatService chatService, ChatRealtimeService chatRealtimeService,
OpenRouterAiService openRouterAiService, UserRepository userRepository, ChatAttachmentStorageService attachmentStorageService,
public ChatController(ChatService chatService, ChatRealtimeService chatRealtimeService,
OpenRouterAiService openRouterAiService, AuthenticationHelper authHelper, ChatAttachmentStorageService attachmentStorageService,
MessageRepository messageRepository) {
this.chatService = chatService;
this.chatRealtimeService = chatRealtimeService;
this.openRouterAiService = openRouterAiService;
this.userRepository = userRepository;
this.authHelper = authHelper;
this.attachmentStorageService = attachmentStorageService;
this.messageRepository = messageRepository;
}
private User getCurrentUser() {
try {
return AuthenticationHelper.getAuthenticatedUser(userRepository);
} catch (RuntimeException ex) {
throw new UsernameNotFoundException(ex.getMessage(), ex);
}
}
@PostMapping("/conversations")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<ConversationResponse> createConversation(@Valid @RequestBody ConversationRequest request) {
User user = getCurrentUser();
User user = authHelper.getAuthenticatedUser();
ConversationResponse response = chatService.createConversation(user.getId(), request);
chatRealtimeService.publishNewConversation(response);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
@@ -71,7 +61,7 @@ public class ChatController {
@PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')")
public ResponseEntity<List<ConversationResponse>> getConversations(
@RequestParam(required = false, defaultValue = "false") boolean mine) {
User user = getCurrentUser();
User user = authHelper.getAuthenticatedUser();
List<ConversationResponse> conversations = chatService.getConversations(user.getId(), user.getRole(), mine);
return ResponseEntity.ok(conversations);
}
@@ -79,7 +69,7 @@ public class ChatController {
@GetMapping("/conversations/{id}")
@PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')")
public ResponseEntity<ConversationResponse> getConversation(@PathVariable Long id) {
User user = getCurrentUser();
User user = authHelper.getAuthenticatedUser();
ConversationResponse conversation = chatService.getConversation(id, user.getId(), user.getRole());
return ResponseEntity.ok(conversation);
}
@@ -89,7 +79,7 @@ public class ChatController {
public ResponseEntity<MessageResponse> sendMessage(
@PathVariable Long id,
@Valid @RequestBody MessageRequest request) {
User user = getCurrentUser();
User user = authHelper.getAuthenticatedUser();
MessageResponse message = chatService.sendMessage(id, user.getId(), user.getRole(), request);
chatRealtimeService.publishMessage(id, message);
chatRealtimeService.publishConversationUpdate(id);
@@ -103,7 +93,7 @@ public class ChatController {
@PathVariable Long id,
@RequestParam("file") MultipartFile file,
@RequestParam(value = "content", required = false) String content) {
User user = getCurrentUser();
User user = authHelper.getAuthenticatedUser();
MessageResponse message = chatService.sendMessageWithAttachment(id, user.getId(), user.getRole(), file, content);
chatRealtimeService.publishMessage(id, message);
chatRealtimeService.publishConversationUpdate(id);
@@ -114,7 +104,7 @@ public class ChatController {
@GetMapping("/messages/{messageId}/attachment")
@PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')")
public ResponseEntity<Resource> getMessageAttachment(@PathVariable Long messageId) {
User user = getCurrentUser();
User user = authHelper.getAuthenticatedUser();
Message message = messageRepository.findById(messageId)
.orElseThrow(() -> new ResourceNotFoundException("Message not found with id: " + messageId));
@@ -140,7 +130,7 @@ public class ChatController {
@GetMapping("/conversations/{id}/messages")
@PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')")
public ResponseEntity<List<MessageResponse>> getMessages(@PathVariable Long id) {
User user = getCurrentUser();
User user = authHelper.getAuthenticatedUser();
List<MessageResponse> messages = chatService.getMessages(id, user.getId(), user.getRole());
return ResponseEntity.ok(messages);
}
@@ -148,7 +138,7 @@ public class ChatController {
@PostMapping("/conversations/{id}/request-human")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<ConversationResponse> requestHumanTakeover(@PathVariable Long id) {
User user = getCurrentUser();
User user = authHelper.getAuthenticatedUser();
ConversationResponse conversation = chatService.requestHumanTakeover(id, user.getId(), user.getRole());
chatRealtimeService.publishConversationUpdate(id);
return ResponseEntity.ok(conversation);
@@ -157,7 +147,7 @@ public class ChatController {
@PutMapping("/conversations/{id}")
@PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')")
public ResponseEntity<ConversationResponse> updateConversation(@PathVariable Long id, @Valid @RequestBody UpdateConversationRequest request) {
User user = getCurrentUser();
User user = authHelper.getAuthenticatedUser();
ConversationResponse conversation = chatService.updateConversation(id, user.getId(), user.getRole(), request);
chatRealtimeService.publishConversationUpdate(id);
return ResponseEntity.ok(conversation);

View File

@@ -3,6 +3,7 @@ package com.petshop.backend.controller;
import com.petshop.backend.dto.common.DropdownOption;
import com.petshop.backend.entity.User;
import com.petshop.backend.repository.*;
import com.petshop.backend.util.StringUtils;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
@@ -68,7 +69,7 @@ public class DropdownController {
public ResponseEntity<List<DropdownOption>> getCustomers() {
return ResponseEntity.ok(
userRepository.findByRoleAndActiveTrue(User.Role.CUSTOMER).stream()
.map(u -> new DropdownOption(u.getId(), u.getFirstName() + " " + u.getLastName()))
.map(u -> new DropdownOption(u.getId(), StringUtils.fullName(u.getFirstName(), u.getLastName())))
.sorted(Comparator.comparing(DropdownOption::getLabel, String.CASE_INSENSITIVE_ORDER))
.collect(Collectors.toList())
);
@@ -90,7 +91,7 @@ public class DropdownController {
return ResponseEntity.ok(
userRepository.findByRoleAndActiveTrue(User.Role.CUSTOMER).stream()
.filter(u -> customersWithPets.contains(u.getId()))
.map(u -> new DropdownOption(u.getId(), u.getFirstName() + " " + u.getLastName()))
.map(u -> new DropdownOption(u.getId(), StringUtils.fullName(u.getFirstName(), u.getLastName())))
.sorted(Comparator.comparing(DropdownOption::getLabel, String.CASE_INSENSITIVE_ORDER))
.collect(Collectors.toList())
);
@@ -190,7 +191,7 @@ public class DropdownController {
}
return ResponseEntity.ok(
employees.stream()
.map(u -> new DropdownOption(u.getId(), u.getFirstName() + " " + u.getLastName()))
.map(u -> new DropdownOption(u.getId(), StringUtils.fullName(u.getFirstName(), u.getLastName())))
.sorted(Comparator.comparing(DropdownOption::getLabel, String.CASE_INSENSITIVE_ORDER))
.collect(Collectors.toList())
);

View File

@@ -2,8 +2,6 @@ package com.petshop.backend.controller;
import com.petshop.backend.dto.pet.MyPetRequest;
import com.petshop.backend.dto.pet.MyPetResponse;
import com.petshop.backend.entity.User;
import com.petshop.backend.repository.UserRepository;
import com.petshop.backend.service.PetService;
import com.petshop.backend.util.AuthenticationHelper;
import jakarta.validation.Valid;
@@ -22,7 +20,6 @@ import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/my-pets")
@@ -30,47 +27,34 @@ import java.util.Map;
public class MyPetController {
private final PetService petService;
private final UserRepository userRepository;
public MyPetController(PetService petService, UserRepository userRepository) {
public MyPetController(PetService petService) {
this.petService = petService;
this.userRepository = userRepository;
}
@GetMapping
public ResponseEntity<List<MyPetResponse>> getMyPets(@RequestParam(required = false) String status) {
return ResponseEntity.ok(petService.getMyPets(currentUserId(), status));
return ResponseEntity.ok(petService.getMyPets(AuthenticationHelper.getAuthenticatedUserId(), status));
}
@PostMapping
public ResponseEntity<MyPetResponse> createMyPet(@Valid @RequestBody MyPetRequest request) {
return ResponseEntity.ok(petService.createMyPet(currentUserId(), request));
return ResponseEntity.ok(petService.createMyPet(AuthenticationHelper.getAuthenticatedUserId(), request));
}
@PutMapping("/{id}")
public ResponseEntity<MyPetResponse> updateMyPet(@PathVariable Long id, @Valid @RequestBody MyPetRequest request) {
return ResponseEntity.ok(petService.updateMyPet(currentUserId(), id, request));
return ResponseEntity.ok(petService.updateMyPet(AuthenticationHelper.getAuthenticatedUserId(), id, request));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteMyPet(@PathVariable Long id) {
petService.deleteMyPet(currentUserId(), id);
petService.deleteMyPet(AuthenticationHelper.getAuthenticatedUserId(), id);
return ResponseEntity.noContent().build();
}
@PostMapping("/{id}/image")
public ResponseEntity<?> uploadMyPetImage(@PathVariable Long id, @RequestParam("image") MultipartFile image) {
try {
return ResponseEntity.ok(petService.uploadMyPetImage(currentUserId(), id, image));
} catch (IllegalArgumentException ex) {
return ResponseEntity.badRequest().body(Map.of("message", ex.getMessage()));
} catch (IOException ex) {
return ResponseEntity.badRequest().body(Map.of("message", "Failed to upload pet image: " + ex.getMessage()));
}
}
private Long currentUserId() {
User user = AuthenticationHelper.getAuthenticatedUser(userRepository);
return user.getId();
public ResponseEntity<MyPetResponse> uploadMyPetImage(@PathVariable Long id, @RequestParam("image") MultipartFile image) throws IOException {
return ResponseEntity.ok(petService.uploadMyPetImage(AuthenticationHelper.getAuthenticatedUserId(), id, image));
}
}

View File

@@ -2,14 +2,11 @@ package com.petshop.backend.controller;
import com.petshop.backend.dto.pet.PetResponse;
import com.petshop.backend.entity.User;
import com.petshop.backend.security.AppPrincipal;
import com.petshop.backend.service.PetService;
import com.petshop.backend.util.AuthenticationHelper;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -20,8 +17,6 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/pets")
@@ -35,20 +30,20 @@ public class PetImageController {
@PostMapping("/{id}/image")
@PreAuthorize("hasAnyRole('STAFF', 'ADMIN')")
public ResponseEntity<?> uploadPetImage(@PathVariable Long id, @RequestParam("image") MultipartFile image) {
try {
PetResponse response = petService.uploadPetImage(id, image);
return ResponseEntity.ok(response);
} catch (IllegalArgumentException ex) {
return badRequest(ex.getMessage());
} catch (IOException ex) {
return badRequest("Failed to upload pet image: " + ex.getMessage());
}
public ResponseEntity<PetResponse> uploadPetImage(@PathVariable Long id, @RequestParam("image") MultipartFile image) throws IOException {
return ResponseEntity.ok(petService.uploadPetImage(id, image));
}
@GetMapping("/{id}/image")
public ResponseEntity<Resource> getPetImage(@PathVariable Long id) {
PetService.ImagePayload payload = petService.loadPetImage(id, currentUserId(), currentUserRole());
Long userId = null;
User.Role role = null;
try {
userId = AuthenticationHelper.getAuthenticatedUserId();
role = AuthenticationHelper.getAuthenticatedRole();
} catch (Exception ignored) {
}
PetService.ImagePayload payload = petService.loadPetImage(id, userId, role);
return ResponseEntity.ok().contentType(payload.mediaType()).body(payload.resource());
}
@@ -57,34 +52,4 @@ public class PetImageController {
public ResponseEntity<PetResponse> deletePetImage(@PathVariable Long id) {
return ResponseEntity.ok(petService.deletePetImage(id));
}
private ResponseEntity<Map<String, String>> badRequest(String message) {
Map<String, String> error = new HashMap<>();
error.put("message", message);
return ResponseEntity.badRequest().body(error);
}
private Long currentUserId() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()) {
return null;
}
Object principal = authentication.getPrincipal();
if (principal instanceof AppPrincipal appPrincipal) {
return appPrincipal.getUserId();
}
return null;
}
private User.Role currentUserRole() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()) {
return null;
}
Object principal = authentication.getPrincipal();
if (principal instanceof AppPrincipal appPrincipal) {
return appPrincipal.getRole();
}
return null;
}
}

View File

@@ -15,8 +15,6 @@ import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/products")
@@ -30,15 +28,8 @@ public class ProductImageController {
@PostMapping("/{id}/image")
@PreAuthorize("hasAnyRole('STAFF', 'ADMIN')")
public ResponseEntity<?> uploadProductImage(@PathVariable Long id, @RequestParam("image") MultipartFile image) {
try {
ProductResponse response = productService.uploadProductImage(id, image);
return ResponseEntity.ok(response);
} catch (IllegalArgumentException ex) {
return badRequest(ex.getMessage());
} catch (IOException ex) {
return badRequest("Failed to upload product image: " + ex.getMessage());
}
public ResponseEntity<ProductResponse> uploadProductImage(@PathVariable Long id, @RequestParam("image") MultipartFile image) throws IOException {
return ResponseEntity.ok(productService.uploadProductImage(id, image));
}
@GetMapping("/{id}/image")
@@ -52,10 +43,4 @@ public class ProductImageController {
public ResponseEntity<ProductResponse> deleteProductImage(@PathVariable Long id) {
return ResponseEntity.ok(productService.deleteProductImage(id));
}
private ResponseEntity<Map<String, String>> badRequest(String message) {
Map<String, String> error = new HashMap<>();
error.put("message", message);
return ResponseEntity.badRequest().body(error);
}
}

View File

@@ -3,16 +3,12 @@ package com.petshop.backend.controller;
import com.petshop.backend.dto.refund.RefundRequest;
import com.petshop.backend.dto.refund.RefundResponse;
import com.petshop.backend.dto.refund.RefundUpdateRequest;
import com.petshop.backend.entity.User;
import com.petshop.backend.repository.UserRepository;
import com.petshop.backend.service.RefundService;
import com.petshop.backend.util.AuthenticationHelper;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@@ -22,46 +18,24 @@ import java.util.List;
public class RefundController {
private final RefundService refundService;
private final UserRepository userRepository;
private final AuthenticationHelper authHelper;
public RefundController(RefundService refundService, UserRepository userRepository) {
public RefundController(RefundService refundService, AuthenticationHelper authHelper) {
this.refundService = refundService;
this.userRepository = userRepository;
this.authHelper = authHelper;
}
@PostMapping
@PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF')")
public ResponseEntity<RefundResponse> createRefund(@Valid @RequestBody RefundRequest request) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String role = authentication.getAuthorities().stream()
.findFirst()
.map(authority -> authority.getAuthority().replace("ROLE_", ""))
.orElse(null);
Long customerId = null;
if (role != null && role.equals("CUSTOMER")) {
User user = AuthenticationHelper.getAuthenticatedUser(userRepository);
customerId = user.getId();
}
Long customerId = authHelper.getCustomerIdOrNull();
return ResponseEntity.status(HttpStatus.CREATED).body(refundService.createRefund(request, customerId));
}
@GetMapping
@PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')")
public ResponseEntity<List<RefundResponse>> getAllRefunds() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String role = authentication.getAuthorities().stream()
.findFirst()
.map(authority -> authority.getAuthority().replace("ROLE_", ""))
.orElse(null);
Long customerId = null;
if (role != null && role.equals("CUSTOMER")) {
User user = AuthenticationHelper.getAuthenticatedUser(userRepository);
customerId = user.getId();
}
Long customerId = authHelper.getCustomerIdOrNull();
List<RefundResponse> refunds = refundService.getAllRefunds(customerId);
return ResponseEntity.ok(refunds);
}
@@ -69,18 +43,7 @@ public class RefundController {
@GetMapping("/{id}")
@PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')")
public ResponseEntity<RefundResponse> getRefundById(@PathVariable Long id) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String role = authentication.getAuthorities().stream()
.findFirst()
.map(authority -> authority.getAuthority().replace("ROLE_", ""))
.orElse(null);
Long customerId = null;
if (role != null && role.equals("CUSTOMER")) {
User user = AuthenticationHelper.getAuthenticatedUser(userRepository);
customerId = user.getId();
}
Long customerId = authHelper.getCustomerIdOrNull();
return ResponseEntity.ok(refundService.getRefundById(id, customerId));
}

View File

@@ -1,10 +1,12 @@
package com.petshop.backend.controller;
import com.petshop.backend.entity.User;
import com.petshop.backend.exception.BusinessException;
import com.petshop.backend.exception.ResourceNotFoundException;
import com.petshop.backend.repository.UserRepository;
import com.petshop.backend.service.AvatarStorageService;
import com.petshop.backend.util.ImageValidationUtil;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
@@ -15,6 +17,8 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.HashMap;
@@ -24,6 +28,8 @@ import java.util.Map;
@RequestMapping("/api/v1/users")
public class UserAvatarController {
private static final Logger log = LoggerFactory.getLogger(UserAvatarController.class);
private final UserRepository userRepository;
private final AvatarStorageService avatarStorageService;
@@ -53,29 +59,10 @@ public class UserAvatarController {
@PostMapping("/{userId}/avatar")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<?> uploadUserAvatar(@PathVariable Long userId, @RequestParam("avatar") MultipartFile file) {
User user = userRepository.findById(userId).orElse(null);
if (user == null) {
return ResponseEntity.notFound().build();
}
User user = userRepository.findById(userId)
.orElseThrow(() -> new ResourceNotFoundException("User not found with id: " + userId));
if (file.isEmpty()) {
Map<String, String> error = new HashMap<>();
error.put("message", "Please select a file to upload");
return ResponseEntity.badRequest().body(error);
}
if (file.getSize() > 5 * 1024 * 1024) {
Map<String, String> error = new HashMap<>();
error.put("message", "File size must not exceed 5MB");
return ResponseEntity.badRequest().body(error);
}
String contentType = file.getContentType();
if (contentType == null || (!contentType.equals("image/jpeg") && !contentType.equals("image/png") && !contentType.equals("image/gif"))) {
Map<String, String> error = new HashMap<>();
error.put("message", "Only JPG, PNG, and GIF images are allowed");
return ResponseEntity.badRequest().body(error);
}
ImageValidationUtil.validate(file);
try {
avatarStorageService.deleteAvatar(user);
@@ -88,31 +75,26 @@ public class UserAvatarController {
result.put("message", "Avatar uploaded successfully");
return ResponseEntity.ok(result);
} catch (IOException e) {
Map<String, String> error = new HashMap<>();
error.put("message", "Failed to upload avatar: " + e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
throw new BusinessException("Failed to upload avatar: " + e.getMessage());
}
}
@DeleteMapping("/{userId}/avatar")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<?> deleteUserAvatar(@PathVariable Long userId) {
User user = userRepository.findById(userId).orElse(null);
if (user == null) {
return ResponseEntity.notFound().build();
}
User user = userRepository.findById(userId)
.orElseThrow(() -> new ResourceNotFoundException("User not found with id: " + userId));
try {
avatarStorageService.deleteAvatar(user);
user.setAvatarUrl(null);
userRepository.save(user);
Map<String, String> result = new HashMap<>();
result.put("message", "Avatar removed successfully");
return ResponseEntity.ok(result);
} catch (IOException e) {
Map<String, String> error = new HashMap<>();
error.put("message", "Failed to remove avatar: " + e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
log.warn("Failed to delete avatar for user {}: {}", userId, e.getMessage());
}
user.setAvatarUrl(null);
userRepository.save(user);
Map<String, String> result = new HashMap<>();
result.put("message", "Avatar removed successfully");
return ResponseEntity.ok(result);
}
}

View File

@@ -3,7 +3,6 @@ package com.petshop.backend.controller;
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.entity.User;
import com.petshop.backend.service.UserService;
import jakarta.validation.Valid;
import org.springframework.data.domain.Page;

View File

@@ -0,0 +1,7 @@
package com.petshop.backend.exception;
public class ConflictException extends RuntimeException {
public ConflictException(String message) {
super(message);
}
}

View File

@@ -2,9 +2,12 @@ package com.petshop.backend.exception;
import com.petshop.backend.service.PetService;
import jakarta.servlet.http.HttpServletRequest;
import java.io.IOException;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.core.PropertyReferenceException;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.DisabledException;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
@@ -33,6 +36,11 @@ public class GlobalExceptionHandler {
return buildErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage(), ex, request);
}
@ExceptionHandler(ConflictException.class)
public ResponseEntity<ApiErrorResponse> handleConflictException(ConflictException ex, HttpServletRequest request) {
return buildErrorResponse(HttpStatus.CONFLICT, ex.getMessage(), ex, request);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidationExceptions(MethodArgumentNotValidException ex, HttpServletRequest request) {
Map<String, String> errors = new HashMap<>();
@@ -54,6 +62,16 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
}
@ExceptionHandler(BadCredentialsException.class)
public ResponseEntity<ApiErrorResponse> handleBadCredentials(BadCredentialsException ex, HttpServletRequest request) {
return buildErrorResponse(HttpStatus.UNAUTHORIZED, "Invalid username or password", ex, request);
}
@ExceptionHandler(DisabledException.class)
public ResponseEntity<ApiErrorResponse> handleDisabledException(DisabledException ex, HttpServletRequest request) {
return buildErrorResponse(HttpStatus.FORBIDDEN, ex.getMessage(), ex, request);
}
@ExceptionHandler(org.springframework.security.access.AccessDeniedException.class)
public ResponseEntity<ApiErrorResponse> handleAccessDeniedException(org.springframework.security.access.AccessDeniedException ex, HttpServletRequest request) {
return buildErrorResponse(HttpStatus.FORBIDDEN, ex.getMessage(), ex, request);
@@ -104,6 +122,11 @@ public class GlobalExceptionHandler {
return buildErrorResponse(HttpStatus.FORBIDDEN, "Access to this pet image is not allowed", ex, request);
}
@ExceptionHandler(IOException.class)
public ResponseEntity<ApiErrorResponse> handleIOException(IOException ex, HttpServletRequest request) {
return buildErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage(), ex, request);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiErrorResponse> handleGenericException(Exception ex, HttpServletRequest request) {
String message = ex.getMessage() == null || ex.getMessage().isBlank()

View File

@@ -6,6 +6,7 @@ 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 com.petshop.backend.util.StringUtils;
import jakarta.persistence.criteria.Predicate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -48,7 +49,10 @@ public class ActivityLogService {
entry.setUser(managedUser);
entry.setStore(store);
entry.setUsernameSnapshot(managedUser.getUsername());
entry.setFullNameSnapshot(resolveFullName(managedUser));
String fullName = managedUser.getFullName() != null && !managedUser.getFullName().isBlank()
? managedUser.getFullName()
: StringUtils.fullName(managedUser.getFirstName(), managedUser.getLastName());
entry.setFullNameSnapshot(fullName);
entry.setRoleSnapshot(managedUser.getRole() != null ? managedUser.getRole().name() : null);
entry.setStoreNameSnapshot(store != null ? store.getStoreName() : null);
entry.setActivity(activity.trim());
@@ -127,7 +131,10 @@ public class ActivityLogService {
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())));
String liveName = entry.getUser().getFullName() != null && !entry.getUser().getFullName().isBlank()
? entry.getUser().getFullName()
: StringUtils.fullName(entry.getUser().getFirstName(), entry.getUser().getLastName());
response.setFullName(firstNonBlank(entry.getFullNameSnapshot(), liveName));
response.setRole(firstNonBlank(entry.getRoleSnapshot(), entry.getUser().getRole() != null ? entry.getUser().getRole().name() : null));
}
@@ -147,24 +154,6 @@ public class ActivityLogService {
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;

View File

@@ -1,6 +1,7 @@
package com.petshop.backend.service;
import com.petshop.backend.dto.adoption.AdoptionRequest;
import com.petshop.backend.util.StringUtils;
import com.petshop.backend.dto.adoption.AdoptionResponse;
import com.petshop.backend.dto.common.BulkDeleteRequest;
import com.petshop.backend.entity.Adoption;
@@ -8,6 +9,7 @@ import com.petshop.backend.entity.Pet;
import com.petshop.backend.entity.Sale;
import com.petshop.backend.entity.StoreLocation;
import com.petshop.backend.entity.User;
import com.petshop.backend.exception.BusinessException;
import com.petshop.backend.exception.ResourceNotFoundException;
import com.petshop.backend.event.AdoptionConfirmedEvent;
import com.petshop.backend.event.SaleReceiptEvent;
@@ -54,9 +56,10 @@ public class AdoptionService {
this.eventPublisher = eventPublisher;
}
@Transactional(readOnly = true)
public Page<AdoptionResponse> getAllAdoptions(String query, Long customerId, String status, Long storeId, LocalDate date, Pageable pageable) {
String normalizedQuery = normalizeFilter(query);
String normalizedStatus = normalizeFilter(status);
String normalizedQuery = StringUtils.trimToNull(query);
String normalizedStatus = StringUtils.trimToNull(status);
Page<Adoption> adoptions = adoptionRepository.searchAdoptions(
normalizedQuery,
@@ -70,6 +73,7 @@ public class AdoptionService {
return adoptions.map(this::mapToResponse);
}
@Transactional(readOnly = true)
public AdoptionResponse getAdoptionById(Long id, Long customerId) {
Adoption adoption = adoptionRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Adoption not found with id: " + id));
@@ -96,7 +100,7 @@ public class AdoptionService {
String adoptionStatus = normalizeAdoptionStatus(request.getAdoptionStatus());
validatePetAvailability(pet, null, null);
if (ADOPTION_STATUS_COMPLETED.equalsIgnoreCase(adoptionStatus) && request.getAdoptionDate() != null && request.getAdoptionDate().isAfter(LocalDate.now())) {
throw new IllegalArgumentException("Cannot mark a future-dated adoption as Completed");
throw new BusinessException("Cannot mark a future-dated adoption as Completed");
}
Adoption adoption = new Adoption();
@@ -139,7 +143,7 @@ public class AdoptionService {
Long currentPetId = adoption.getPet() != null ? adoption.getPet().getPetId() : null;
validatePetAvailability(pet, adoption.getAdoptionId(), currentPetId);
if (ADOPTION_STATUS_COMPLETED.equalsIgnoreCase(adoptionStatus) && request.getAdoptionDate() != null && request.getAdoptionDate().isAfter(LocalDate.now())) {
throw new IllegalArgumentException("Cannot mark a future-dated adoption as Completed");
throw new BusinessException("Cannot mark a future-dated adoption as Completed");
}
adoption.setPet(pet);
@@ -168,7 +172,7 @@ public class AdoptionService {
// Verify the pet is actually located at the claimed store
if (pet.getStore() == null || !pet.getStore().getStoreId().equals(sourceStoreId)) {
throw new IllegalArgumentException("The specified pet is not located at the selected store.");
throw new BusinessException("The specified pet is not located at the selected store.");
}
// Verify the pet is available for adoption
@@ -203,7 +207,7 @@ public class AdoptionService {
}
if (!ADOPTION_STATUS_PENDING.equalsIgnoreCase(adoption.getAdoptionStatus())) {
throw new IllegalArgumentException("Only pending adoptions can be cancelled");
throw new BusinessException("Only pending adoptions can be cancelled");
}
adoption.setAdoptionStatus(ADOPTION_STATUS_CANCELLED);
@@ -247,14 +251,6 @@ public class AdoptionService {
}
}
private String normalizeFilter(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private AdoptionResponse mapToResponse(Adoption adoption) {
StoreLocation sourceStore = adoption.getSourceStore();
return new AdoptionResponse(
@@ -262,9 +258,9 @@ public class AdoptionService {
adoption.getPet().getPetId(),
adoption.getPet().getPetName(),
adoption.getCustomer().getId(),
adoption.getCustomer().getFirstName() + " " + adoption.getCustomer().getLastName(),
StringUtils.fullName(adoption.getCustomer().getFirstName(), adoption.getCustomer().getLastName()),
adoption.getEmployee().getId(),
adoption.getEmployee().getFirstName() + " " + adoption.getEmployee().getLastName(),
StringUtils.fullName(adoption.getEmployee().getFirstName(), adoption.getEmployee().getLastName()),
sourceStore != null ? sourceStore.getStoreId() : null,
sourceStore != null ? sourceStore.getStoreName() : null,
adoption.getAdoptionDate(),
@@ -280,7 +276,7 @@ public class AdoptionService {
User employee = userRepository.findById(requestedEmployeeId)
.orElseThrow(() -> new ResourceNotFoundException("Employee not found with id: " + requestedEmployeeId));
if (!isAssignableUser(employee)) {
throw new IllegalArgumentException("Selected employee is not assignable for adoption work");
throw new BusinessException("Selected employee is not assignable for adoption work");
}
return employee;
}
@@ -295,7 +291,7 @@ public class AdoptionService {
private String normalizeAdoptionStatus(String adoptionStatus) {
if (adoptionStatus == null) {
throw new IllegalArgumentException("Adoption status is required");
throw new BusinessException("Adoption status is required");
}
String trimmedStatus = adoptionStatus.trim();
if (ADOPTION_STATUS_PENDING.equalsIgnoreCase(trimmedStatus)) {
@@ -310,7 +306,7 @@ public class AdoptionService {
if (ADOPTION_STATUS_MISSED.equalsIgnoreCase(trimmedStatus)) {
return ADOPTION_STATUS_MISSED;
}
throw new IllegalArgumentException("Adoption status must be Pending, Completed, Cancelled, or Missed");
throw new BusinessException("Adoption status must be Pending, Completed, Cancelled, or Missed");
}
private void validatePetAvailability(Pet pet, Long adoptionId, Long currentPetId) {
@@ -319,11 +315,11 @@ public class AdoptionService {
? adoptionRepository.existsByPet_IdAndAdoptionStatusIgnoreCase(pet.getPetId(), ADOPTION_STATUS_COMPLETED)
: adoptionRepository.existsByPet_IdAndAdoptionStatusIgnoreCaseAndAdoptionIdNot(pet.getPetId(), ADOPTION_STATUS_COMPLETED, adoptionId);
if (adoptedElsewhere) {
throw new IllegalArgumentException("Selected pet has already been adopted");
throw new BusinessException("Selected pet has already been adopted");
}
if (!samePetAsCurrentAdoption && !PET_STATUS_AVAILABLE.equalsIgnoreCase(pet.getPetStatus())) {
throw new IllegalArgumentException("Selected pet is not available for adoption");
throw new BusinessException("Selected pet is not available for adoption");
}
}

View File

@@ -8,6 +8,7 @@ import com.petshop.backend.entity.User;
import com.petshop.backend.repository.InventoryRepository;
import com.petshop.backend.repository.ProductRepository;
import com.petshop.backend.repository.SaleRepository;
import com.petshop.backend.util.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -181,12 +182,12 @@ public class AnalyticsService {
if (sale.getIsRefund()) {
continue;
}
String employeeName = sale.getEmployee().getFirstName() + " " + sale.getEmployee().getLastName();
String employeeName = StringUtils.fullName(sale.getEmployee().getFirstName(), sale.getEmployee().getLastName());
employeeRevenue.merge(employeeName, sale.getTotalAmount(), BigDecimal::add);
}
if (user.getRole() == User.Role.STAFF && employeeRevenue.isEmpty()) {
String employeeName = user.getFirstName() + " " + user.getLastName();
String employeeName = StringUtils.fullName(user.getFirstName(), user.getLastName());
employeeRevenue.put(employeeName, BigDecimal.ZERO);
}

View File

@@ -19,7 +19,9 @@ import com.petshop.backend.repository.PetRepository;
import com.petshop.backend.repository.ServiceRepository;
import com.petshop.backend.repository.StoreRepository;
import com.petshop.backend.repository.UserRepository;
import com.petshop.backend.config.BusinessProperties;
import com.petshop.backend.util.AuthenticationHelper;
import com.petshop.backend.util.StringUtils;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
@@ -46,8 +48,9 @@ public class AppointmentService {
private final UserRepository userRepository;
private final AdoptionRepository adoptionRepository;
private final ApplicationEventPublisher eventPublisher;
private final BusinessProperties businessProperties;
public AppointmentService(AppointmentRepository appointmentRepository, ServiceRepository serviceRepository, PetRepository petRepository, StoreRepository storeRepository, UserRepository userRepository, AdoptionRepository adoptionRepository, ApplicationEventPublisher eventPublisher) {
public AppointmentService(AppointmentRepository appointmentRepository, ServiceRepository serviceRepository, PetRepository petRepository, StoreRepository storeRepository, UserRepository userRepository, AdoptionRepository adoptionRepository, ApplicationEventPublisher eventPublisher, BusinessProperties businessProperties) {
this.appointmentRepository = appointmentRepository;
this.serviceRepository = serviceRepository;
this.petRepository = petRepository;
@@ -55,6 +58,7 @@ public class AppointmentService {
this.userRepository = userRepository;
this.adoptionRepository = adoptionRepository;
this.eventPublisher = eventPublisher;
this.businessProperties = businessProperties;
}
@Transactional(readOnly = true)
@@ -67,8 +71,8 @@ public class AppointmentService {
LocalDate date,
Pageable pageable) {
String normalizedQuery = normalizeFilter(query);
String normalizedStatus = normalizeFilter(status);
String normalizedQuery = StringUtils.trimToNull(query);
String normalizedStatus = StringUtils.trimToNull(status);
Page<Appointment> appointments = appointmentRepository.searchAppointments(
normalizedQuery,
@@ -116,14 +120,14 @@ public class AppointmentService {
// Customers must supply a pet that is Adopted and owned by them
if (User.Role.CUSTOMER.equals(authenticatedUser.getRole())) {
if (pet == null) {
throw new IllegalArgumentException("A pet must be selected for your appointment");
throw new BusinessException("A pet must be selected for your appointment");
}
if (pet.getOwner() == null || !pet.getOwner().getId().equals(authenticatedUser.getId())) {
throw new IllegalArgumentException("The selected pet does not belong to your account");
throw new BusinessException("The selected pet does not belong to your account");
}
String petStatus = pet.getPetStatus();
if (!"Owned".equalsIgnoreCase(petStatus) && !"Adopted".equalsIgnoreCase(petStatus)) {
throw new IllegalArgumentException("Only your own pets can be booked for appointments");
throw new BusinessException("Only your own pets can be booked for appointments");
}
}
@@ -198,7 +202,7 @@ public class AppointmentService {
String status = appointment.getAppointmentStatus();
if (!"Booked".equalsIgnoreCase(status) && !"Scheduled".equalsIgnoreCase(status)) {
throw new IllegalArgumentException("Only booked or scheduled appointments can be cancelled");
throw new BusinessException("Only booked or scheduled appointments can be cancelled");
}
appointment.setAppointmentStatus("Cancelled");
@@ -239,8 +243,8 @@ public class AppointmentService {
.collect(Collectors.groupingBy(a -> a.getEmployee().getId()));
List<String> availableSlots = new ArrayList<>();
LocalTime startTime = LocalTime.of(9, 0);
LocalTime endTime = LocalTime.of(17, 0);
LocalTime startTime = businessProperties.openTime();
LocalTime endTime = businessProperties.closeTime();
LocalTime latestStart = endTime.minusMinutes(service.getServiceDuration());
LocalTime currentTime = startTime;
@@ -254,7 +258,7 @@ public class AppointmentService {
if (anyEmployeeAvailable) {
availableSlots.add(currentTime.toString());
}
currentTime = currentTime.plusMinutes(30);
currentTime = currentTime.plusMinutes(businessProperties.slotIntervalMinutes());
}
return availableSlots;
@@ -288,14 +292,6 @@ public class AppointmentService {
}
}
private String normalizeFilter(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private void validatePetServiceCompatibility(Pet pet, com.petshop.backend.entity.Service service) {
if (pet == null) return;
Set<String> allowed = service.getSpecies();
@@ -311,7 +307,7 @@ public class AppointmentService {
if ("Booked".equalsIgnoreCase(request.getAppointmentStatus())) {
LocalDateTime appointmentDateTime = LocalDateTime.of(request.getAppointmentDate(), request.getAppointmentTime());
if (appointmentDateTime.isBefore(LocalDateTime.now())) {
throw new IllegalArgumentException("Booked appointments must be scheduled in the future");
throw new BusinessException("Booked appointments must be scheduled in the future");
}
}
}
@@ -326,17 +322,25 @@ public class AppointmentService {
AppointmentResponse response = new AppointmentResponse();
response.setAppointmentId(appointment.getAppointmentId());
response.setCustomerId(appointment.getCustomer().getId());
response.setCustomerName(appointment.getCustomer().getFirstName() + " " + appointment.getCustomer().getLastName());
response.setStoreId(appointment.getStore().getStoreId());
response.setStoreName(appointment.getStore().getStoreName());
response.setServiceId(appointment.getService().getServiceId());
response.setServiceName(appointment.getService().getServiceName());
if (appointment.getCustomer() != null) {
response.setCustomerId(appointment.getCustomer().getId());
response.setCustomerName(StringUtils.fullName(appointment.getCustomer().getFirstName(), appointment.getCustomer().getLastName()));
}
if (appointment.getStore() != null) {
response.setStoreId(appointment.getStore().getStoreId());
response.setStoreName(appointment.getStore().getStoreName());
}
if (appointment.getService() != null) {
response.setServiceId(appointment.getService().getServiceId());
response.setServiceName(appointment.getService().getServiceName());
}
response.setAppointmentDate(appointment.getAppointmentDate());
response.setAppointmentTime(appointment.getAppointmentTime());
response.setAppointmentStatus(appointment.getAppointmentStatus());
response.setEmployeeId(appointment.getEmployee().getId());
response.setEmployeeName(appointment.getEmployee().getFirstName() + " " + appointment.getEmployee().getLastName());
if (appointment.getEmployee() != null) {
response.setEmployeeId(appointment.getEmployee().getId());
response.setEmployeeName(StringUtils.fullName(appointment.getEmployee().getFirstName(), appointment.getEmployee().getLastName()));
}
response.setPetName(pet != null ? pet.getPetName() : null);
response.setPetId(pet != null ? pet.getPetId() : null);
response.setCreatedAt(appointment.getCreatedAt());
@@ -355,7 +359,7 @@ public class AppointmentService {
boolean assignedToStore = assignableUsers.stream()
.anyMatch(u -> u.getId().equals(requestedEmployeeId));
if (!assignedToStore) {
throw new IllegalArgumentException("Selected employee is not assignable for the selected store");
throw new BusinessException("Selected employee is not assignable for the selected store");
}
return employee;
}
@@ -369,7 +373,7 @@ public class AppointmentService {
List<Appointment> existingAppointments = appointmentRepository
.findByEmployeeIdAndAppointmentDate(employee.getId(), date);
if (!isSlotAvailable(existingAppointments, service, time, appointmentIdToIgnore)) {
throw new IllegalArgumentException("The selected employee is already booked for this time slot");
throw new BusinessException("The selected employee is already booked for this time slot");
}
}
@@ -393,7 +397,7 @@ public class AppointmentService {
List<Appointment> existingAppointments = appointmentRepository
.findByPetIdAndAppointmentDate(pet.getPetId(), date);
if (!isSlotAvailable(existingAppointments, service, time, appointmentIdToIgnore)) {
throw new IllegalArgumentException("This pet already has an appointment during this time slot");
throw new BusinessException("This pet already has an appointment during this time slot");
}
}

View File

@@ -7,12 +7,13 @@ import com.petshop.backend.entity.*;
import com.petshop.backend.exception.BusinessException;
import com.petshop.backend.exception.ResourceNotFoundException;
import com.petshop.backend.repository.*;
import com.petshop.backend.config.BusinessProperties;
import com.stripe.Stripe;
import com.stripe.exception.StripeException;
import com.stripe.model.PaymentIntent;
import com.stripe.param.PaymentIntentCreateParams;
import jakarta.annotation.PostConstruct;
import jakarta.transaction.Transactional;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@@ -24,16 +25,16 @@ import java.util.List;
@Service
public class CartService {
private static final int LOYALTY_POINTS_PER_DOLLAR = 20;
private final CartRepository cartRepository;
private final CartItemRepository cartItemRepository;
private final UserRepository userRepository;
private final StoreRepository storeRepository;
private final ProductRepository productRepository;
private final CouponRepository couponRepository;
private final CouponService couponService;
private final SaleRepository saleRepository;
private final SaleService saleService;
private final BusinessProperties businessProperties;
@Value("${stripe.secret-key:}")
private String stripeSecretKey;
@@ -44,16 +45,20 @@ public class CartService {
StoreRepository storeRepository,
ProductRepository productRepository,
CouponRepository couponRepository,
CouponService couponService,
SaleRepository saleRepository,
SaleService saleService) {
SaleService saleService,
BusinessProperties businessProperties) {
this.cartRepository = cartRepository;
this.cartItemRepository = cartItemRepository;
this.userRepository = userRepository;
this.storeRepository = storeRepository;
this.productRepository = productRepository;
this.couponRepository = couponRepository;
this.couponService = couponService;
this.saleRepository = saleRepository;
this.saleService = saleService;
this.businessProperties = businessProperties;
}
@PostConstruct
@@ -188,31 +193,12 @@ public class CartService {
Coupon coupon = couponRepository.findByCouponCodeIgnoreCase(couponCode)
.orElseThrow(() -> new BusinessException("Invalid coupon code"));
LocalDateTime now = LocalDateTime.now();
if (!coupon.getActive()) {
throw new BusinessException("Coupon is no longer active");
}
if (coupon.getStartsAt() != null && now.isBefore(coupon.getStartsAt())) {
throw new BusinessException("Coupon is not yet valid");
}
if (coupon.getEndsAt() != null && now.isAfter(coupon.getEndsAt())) {
throw new BusinessException("Coupon has expired");
}
couponService.validateCouponForUse(coupon);
if (coupon.getMinOrderAmount() != null && cart.getSubtotalAmount().compareTo(coupon.getMinOrderAmount()) < 0) {
throw new BusinessException("Minimum order amount of $" + coupon.getMinOrderAmount() + " required");
}
if (coupon.getUsageLimit() != null) {
long used = saleRepository.countByCoupon_CouponId(coupon.getCouponId());
if (used >= coupon.getUsageLimit()) {
throw new BusinessException("Coupon usage limit has been reached");
}
}
cart.setCoupon(coupon);
recalculate(cart);
@@ -450,80 +436,45 @@ public class CartService {
}
}
private void recalculate(Cart cart) {
private record CartTotals(BigDecimal subtotal, BigDecimal discount, BigDecimal pointsDiscount, BigDecimal total) {}
private CartTotals computeTotals(Cart cart) {
List<CartItem> items = cartItemRepository.findByCartCartId(cart.getCartId());
BigDecimal subtotal = items.stream()
.map(i -> i.getUnitPrice().multiply(BigDecimal.valueOf(i.getQuantity())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
cart.setSubtotalAmount(subtotal);
BigDecimal discount = BigDecimal.ZERO;
Coupon coupon = cart.getCoupon();
if (coupon != null) {
if (isPercentageType(coupon.getDiscountType())) {
discount = subtotal.multiply(coupon.getDiscountValue())
.divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
} else if (isFixedType(coupon.getDiscountType())) {
discount = coupon.getDiscountValue().min(subtotal);
}
}
discount = discount.max(BigDecimal.ZERO).min(subtotal);
cart.setDiscountAmount(discount);
BigDecimal discount = couponService.calculateDiscount(cart.getCoupon(), subtotal)
.max(BigDecimal.ZERO).min(subtotal);
BigDecimal remainingAfterCoupon = subtotal.subtract(discount).max(BigDecimal.ZERO);
BigDecimal pointsDiscount = calculatePointsDiscount(cart.getUser(), remainingAfterCoupon, Boolean.TRUE.equals(cart.getPointsApplied()));
cart.setPointsDiscountAmount(pointsDiscount);
cart.setTotalAmount(remainingAfterCoupon.subtract(pointsDiscount).max(BigDecimal.ZERO));
BigDecimal total = remainingAfterCoupon.subtract(pointsDiscount).max(BigDecimal.ZERO);
return new CartTotals(subtotal, discount, pointsDiscount, total);
}
private void recalculate(Cart cart) {
CartTotals totals = computeTotals(cart);
cart.setSubtotalAmount(totals.subtotal());
cart.setDiscountAmount(totals.discount());
cart.setPointsDiscountAmount(totals.pointsDiscount());
cart.setTotalAmount(totals.total());
cartRepository.save(cart);
}
private BigDecimal recalculateTotalAmount(Cart cart) {
List<CartItem> items = cartItemRepository.findByCartCartId(cart.getCartId());
BigDecimal subtotal = items.stream()
.map(i -> i.getUnitPrice().multiply(BigDecimal.valueOf(i.getQuantity())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal discount = BigDecimal.ZERO;
Coupon coupon = cart.getCoupon();
if (coupon != null) {
if (isPercentageType(coupon.getDiscountType())) {
discount = subtotal.multiply(coupon.getDiscountValue())
.divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
} else if (isFixedType(coupon.getDiscountType())) {
discount = coupon.getDiscountValue().min(subtotal);
}
}
discount = discount.max(BigDecimal.ZERO).min(subtotal);
BigDecimal remainingAfterCoupon = subtotal.subtract(discount).max(BigDecimal.ZERO);
BigDecimal pointsDiscount = calculatePointsDiscount(cart.getUser(), remainingAfterCoupon, Boolean.TRUE.equals(cart.getPointsApplied()));
return remainingAfterCoupon.subtract(pointsDiscount).max(BigDecimal.ZERO);
return computeTotals(cart).total();
}
private boolean isPercentageType(String discountType) {
return "PERCENTAGE".equalsIgnoreCase(discountType) || "PERCENT".equalsIgnoreCase(discountType);
}
private boolean isFixedType(String discountType) {
return "FIXED".equalsIgnoreCase(discountType) || "FLAT".equalsIgnoreCase(discountType);
}
private BigDecimal calculatePointsDiscount(User user, BigDecimal remainingAmount, boolean pointsApplied) {
if (!pointsApplied || user == null || remainingAmount.compareTo(BigDecimal.ZERO) <= 0) {
return BigDecimal.ZERO;
}
int availablePoints = user.getLoyaltyPoints() != null ? user.getLoyaltyPoints() : 0;
int wholeDollars = availablePoints / LOYALTY_POINTS_PER_DOLLAR;
int wholeDollars = availablePoints / businessProperties.loyaltyPointsPerDollar();
if (wholeDollars <= 0) {
return BigDecimal.ZERO;
}

View File

@@ -1,5 +1,7 @@
package com.petshop.backend.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
@@ -21,6 +23,8 @@ import java.util.UUID;
@Service
public class CatalogImageStorageService {
private static final Logger log = LoggerFactory.getLogger(CatalogImageStorageService.class);
private static final String PET_PREFIX = "/uploads/pets/";
private static final String PRODUCT_PREFIX = "/uploads/products/";
private static final String BLOB_PETS = "pets";
@@ -106,6 +110,24 @@ public class CatalogImageStorageService {
}
}
public void deletePetImageIfPresent(String storedPath) {
if (storedPath == null || storedPath.isBlank()) return;
try {
deletePetImage(storedPath);
} catch (IOException | IllegalArgumentException e) {
log.warn("Failed to delete pet image {}: {}", storedPath, e.getMessage());
}
}
public void deleteProductImageIfPresent(String storedPath) {
if (storedPath == null || storedPath.isBlank()) return;
try {
deleteProductImage(storedPath);
} catch (IOException | IllegalArgumentException e) {
log.warn("Failed to delete product image {}: {}", storedPath, e.getMessage());
}
}
private String extractFilename(String storedPath, String prefix) {
if (storedPath == null || !storedPath.startsWith(prefix)) throw new IllegalArgumentException("Image file was not found");
String filename = storedPath.substring(prefix.length());

View File

@@ -1,6 +1,7 @@
package com.petshop.backend.service;
import com.petshop.backend.dto.category.CategoryRequest;
import com.petshop.backend.util.StringUtils;
import com.petshop.backend.dto.category.CategoryResponse;
import com.petshop.backend.dto.common.BulkDeleteRequest;
import com.petshop.backend.entity.Category;
@@ -20,11 +21,13 @@ public class CategoryService {
this.categoryRepository = categoryRepository;
}
@Transactional(readOnly = true)
public Page<CategoryResponse> getAllCategories(String query, String type, Pageable pageable) {
return categoryRepository.searchCategories(normalizeFilter(query), normalizeFilter(type), pageable)
return categoryRepository.searchCategories(StringUtils.trimToNull(query), StringUtils.trimToNull(type), pageable)
.map(this::mapToResponse);
}
@Transactional(readOnly = true)
public CategoryResponse getCategoryById(Long id) {
Category category = categoryRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Category not found with id: " + id));
@@ -76,11 +79,4 @@ public class CategoryService {
);
}
private String normalizeFilter(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
}

View File

@@ -8,6 +8,7 @@ import com.petshop.backend.dto.chat.UpdateConversationRequest;
import com.petshop.backend.entity.Conversation;
import com.petshop.backend.entity.Message;
import com.petshop.backend.entity.User;
import com.petshop.backend.exception.BusinessException;
import com.petshop.backend.exception.ResourceNotFoundException;
import com.petshop.backend.util.ContentFilter;
import com.petshop.backend.repository.ConversationRepository;
@@ -229,7 +230,7 @@ public class ChatService {
return toMessageResponse(message);
} catch (IOException e) {
throw new RuntimeException("Failed to store attachment", e);
throw new BusinessException("Failed to store attachment");
}
}

View File

@@ -4,32 +4,43 @@ import com.petshop.backend.dto.common.BulkDeleteRequest;
import com.petshop.backend.dto.common.CouponRequest;
import com.petshop.backend.dto.common.CouponResponse;
import com.petshop.backend.entity.Coupon;
import com.petshop.backend.exception.BusinessException;
import com.petshop.backend.exception.ResourceNotFoundException;
import com.petshop.backend.repository.CouponRepository;
import com.petshop.backend.repository.SaleRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
@Service
public class CouponService {
private final CouponRepository couponRepository;
private final SaleRepository saleRepository;
public CouponService(CouponRepository couponRepository) {
public CouponService(CouponRepository couponRepository, SaleRepository saleRepository) {
this.couponRepository = couponRepository;
this.saleRepository = saleRepository;
}
@Transactional(readOnly = true)
public Page<CouponResponse> getAllCoupons(String query, Boolean active, Pageable pageable) {
return couponRepository.searchCoupons(query, active, pageable).map(this::mapToResponse);
}
@Transactional(readOnly = true)
public CouponResponse getCouponById(Long id) {
Coupon coupon = couponRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Coupon not found with id: " + id));
return mapToResponse(coupon);
}
@Transactional(readOnly = true)
public CouponResponse getCouponByCode(String code) {
Coupon coupon = couponRepository.findByCouponCode(code)
.orElseThrow(() -> new ResourceNotFoundException("Coupon not found with code: " + code));
@@ -39,7 +50,7 @@ public class CouponService {
@Transactional
public CouponResponse createCoupon(CouponRequest request) {
if (couponRepository.findByCouponCode(request.getCouponCode()).isPresent()) {
throw new IllegalArgumentException("Coupon code already exists: " + request.getCouponCode());
throw new BusinessException("Coupon code already exists: " + request.getCouponCode());
}
Coupon coupon = new Coupon();
@@ -55,7 +66,7 @@ public class CouponService {
couponRepository.findByCouponCode(request.getCouponCode()).ifPresent(existing -> {
if (!existing.getCouponId().equals(id)) {
throw new IllegalArgumentException("Coupon code already exists: " + request.getCouponCode());
throw new BusinessException("Coupon code already exists: " + request.getCouponCode());
}
});
@@ -88,6 +99,57 @@ public class CouponService {
coupon.setUsageLimit(request.getUsageLimit());
}
public void validateCouponForUse(Coupon coupon) {
if (!Boolean.TRUE.equals(coupon.getActive())) {
throw new BusinessException("Coupon is no longer active");
}
LocalDateTime now = LocalDateTime.now();
if (coupon.getStartsAt() != null && now.isBefore(coupon.getStartsAt())) {
throw new BusinessException("Coupon is not yet valid");
}
if (coupon.getEndsAt() != null && now.isAfter(coupon.getEndsAt())) {
throw new BusinessException("Coupon has expired");
}
if (coupon.getUsageLimit() != null) {
long used = saleRepository.countByCoupon_CouponId(coupon.getCouponId());
if (used >= coupon.getUsageLimit()) {
throw new BusinessException("Coupon usage limit has been reached");
}
}
}
public BigDecimal calculateDiscount(Coupon coupon, BigDecimal subtotal) {
if (coupon == null || subtotal.compareTo(BigDecimal.ZERO) <= 0) {
return BigDecimal.ZERO;
}
if (coupon.getMinOrderAmount() != null && subtotal.compareTo(coupon.getMinOrderAmount()) < 0) {
return BigDecimal.ZERO;
}
BigDecimal discount = BigDecimal.ZERO;
String type = coupon.getDiscountType();
if (isPercentageType(type)) {
discount = subtotal.multiply(coupon.getDiscountValue()
.divide(new BigDecimal("100"), 4, RoundingMode.HALF_UP));
} else if (isFixedType(type)) {
discount = coupon.getDiscountValue();
}
return discount.min(subtotal).setScale(2, RoundingMode.HALF_UP);
}
public static boolean isPercentageType(String discountType) {
if (discountType == null) return false;
String type = discountType.trim();
return "PERCENTAGE".equalsIgnoreCase(type) || "PERCENT".equalsIgnoreCase(type);
}
public static boolean isFixedType(String discountType) {
if (discountType == null) return false;
String type = discountType.trim();
return "FIXED".equalsIgnoreCase(type) || "FLAT".equalsIgnoreCase(type);
}
private CouponResponse mapToResponse(Coupon coupon) {
return new CouponResponse(
coupon.getCouponId(),

View File

@@ -7,6 +7,7 @@ import com.petshop.backend.entity.Message;
import com.petshop.backend.entity.Sale;
import com.petshop.backend.entity.SaleItem;
import com.petshop.backend.entity.User;
import com.petshop.backend.util.StringUtils;
import com.resend.Resend;
import com.resend.services.emails.model.CreateEmailOptions;
import org.slf4j.Logger;
@@ -246,7 +247,7 @@ public class EmailService {
String date = appointment.getAppointmentDate() != null ? appointment.getAppointmentDate().format(DATE_FMT) : "";
String time = appointment.getAppointmentTime() != null ? appointment.getAppointmentTime().format(TIME_FMT) : "";
String employee = appointment.getEmployee() != null
? appointment.getEmployee().getFirstName() + " " + appointment.getEmployee().getLastName() : "";
? StringUtils.fullName(appointment.getEmployee().getFirstName(), appointment.getEmployee().getLastName()) : "";
String pet = appointment.getPet() != null ? appointment.getPet().getPetName() : null;
return """
<div style="font-family:sans-serif;max-width:600px;margin:auto">

View File

@@ -1,6 +1,7 @@
package com.petshop.backend.service;
import com.petshop.backend.dto.common.BulkDeleteRequest;
import com.petshop.backend.util.StringUtils;
import com.petshop.backend.dto.inventory.InventoryRequest;
import com.petshop.backend.dto.inventory.InventoryResponse;
import com.petshop.backend.entity.Inventory;
@@ -28,12 +29,14 @@ public class InventoryService {
this.storeRepository = storeRepository;
}
@Transactional(readOnly = true)
public Page<InventoryResponse> getAllInventory(String query, Long storeId, Pageable pageable) {
String normalizedQuery = normalizeFilter(query);
String normalizedQuery = StringUtils.trimToNull(query);
Page<Inventory> inventory = inventoryRepository.searchInventory(normalizedQuery, storeId, pageable);
return inventory.map(this::mapToResponse);
}
@Transactional(readOnly = true)
public InventoryResponse getInventoryById(Long id) {
Inventory inventory = inventoryRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Inventory not found with id: " + id));
@@ -93,14 +96,6 @@ public class InventoryService {
inventoryRepository.deleteAllById(request.getIds());
}
private String normalizeFilter(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private InventoryResponse mapToResponse(Inventory inventory) {
StoreLocation store = inventory.getStore();
return new InventoryResponse(

View File

@@ -6,6 +6,7 @@ import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.petshop.backend.dto.ai.AiChatRequest;
import com.petshop.backend.entity.Pet;
import com.petshop.backend.exception.BusinessException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@@ -52,7 +53,7 @@ public class OpenRouterService {
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new RuntimeException("OpenRouter API returned status " + response.statusCode() + ": " + response.body());
throw new BusinessException("OpenRouter API returned status " + response.statusCode() + ": " + response.body());
}
return extractContent(response.body());
@@ -60,10 +61,10 @@ public class OpenRouterService {
catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("AI request was interrupted", e);
throw new BusinessException("AI request was interrupted");
}
catch (Exception e) {
throw new RuntimeException("Failed to call OpenRouter API: " + e.getMessage(), e);
throw new BusinessException("Failed to call OpenRouter API: " + e.getMessage());
}
}
@@ -132,6 +133,6 @@ public class OpenRouterService {
return choices.get(0).path("message").path("content").asText();
}
throw new RuntimeException("No content in OpenRouter response: " + responseBody);
throw new BusinessException("No content in OpenRouter response");
}
}

View File

@@ -6,6 +6,7 @@ import com.petshop.backend.entity.PasswordResetToken;
import com.petshop.backend.entity.User;
import com.petshop.backend.exception.BusinessException;
import com.petshop.backend.repository.PasswordResetTokenRepository;
import com.petshop.backend.util.StringUtils;
import com.petshop.backend.repository.UserRepository;
import com.petshop.backend.security.UserAuthCacheService;
import org.springframework.security.crypto.password.PasswordEncoder;
@@ -47,7 +48,7 @@ public class PasswordResetService {
@Transactional
public ForgotPasswordResponse createResetToken(String usernameOrEmail) {
String normalized = trimToNull(usernameOrEmail);
String normalized = StringUtils.trimToNull(usernameOrEmail);
if (normalized == null) {
throw new BusinessException("Username or email is required");
}
@@ -83,7 +84,7 @@ public class PasswordResetService {
@Transactional
public ResetPasswordResponse resetPassword(String rawToken, String newPassword) {
String normalizedToken = trimToNull(rawToken);
String normalizedToken = StringUtils.trimToNull(rawToken);
if (normalizedToken == null) {
throw new BusinessException("Reset token is required");
}
@@ -136,11 +137,4 @@ public class PasswordResetService {
}
}
private String trimToNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
}

View File

@@ -1,6 +1,8 @@
package com.petshop.backend.service;
import com.petshop.backend.dto.common.BulkDeleteRequest;
import com.petshop.backend.util.ImageValidationUtil;
import com.petshop.backend.util.StringUtils;
import com.petshop.backend.dto.pet.MyPetRequest;
import com.petshop.backend.dto.pet.MyPetResponse;
import com.petshop.backend.dto.pet.PetRequest;
@@ -9,6 +11,7 @@ import com.petshop.backend.entity.Adoption;
import com.petshop.backend.entity.Pet;
import com.petshop.backend.entity.StoreLocation;
import com.petshop.backend.entity.User;
import com.petshop.backend.exception.BusinessException;
import com.petshop.backend.exception.ResourceNotFoundException;
import com.petshop.backend.security.AppPrincipal;
import com.petshop.backend.repository.AdoptionRepository;
@@ -29,7 +32,7 @@ import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.List;
import java.util.Locale;
@Service
public class PetService {
@@ -52,10 +55,10 @@ public class PetService {
@Transactional(readOnly = true)
public Page<PetResponse> getAllPets(String query, String species, String breed, String status, Long storeId, Long customerId, Pageable pageable) {
String normalizedQuery = normalizeFilter(query);
String normalizedSpecies = normalizeFilter(species);
String normalizedBreed = normalizeFilter(breed);
String normalizedStatus = normalizeFilter(status);
String normalizedQuery = StringUtils.trimToNull(query);
String normalizedSpecies = StringUtils.trimToNull(species);
String normalizedBreed = StringUtils.trimToNull(breed);
String normalizedStatus = StringUtils.trimToNull(status);
CurrentViewer viewer = getCurrentViewer();
Page<Pet> pets;
@@ -125,7 +128,7 @@ public class PetService {
boolean hasBooked = linkedAppointments.stream()
.anyMatch(a -> "Booked".equalsIgnoreCase(a.getAppointmentStatus()));
if (hasBooked) {
throw new IllegalArgumentException(
throw new BusinessException(
"Your pet has a booked appointment. Please cancel the appointment before removing your pet from our database.");
}
// Nullify the pet reference on non-booked appointments to avoid FK constraint violations
@@ -133,7 +136,7 @@ public class PetService {
appt.setPet(null);
}
appointmentRepository.saveAll(linkedAppointments);
deleteStoredImageIfPresent(pet.getImageUrl());
catalogImageStorageService.deletePetImageIfPresent(pet.getImageUrl());
petRepository.delete(pet);
}
@@ -141,7 +144,7 @@ public class PetService {
public MyPetResponse uploadMyPetImage(Long ownerUserId, Long petId, MultipartFile file) throws IOException {
validateImageFile(file);
Pet pet = findOwnedPet(ownerUserId, petId);
deleteStoredImageIfPresent(pet.getImageUrl());
catalogImageStorageService.deletePetImageIfPresent(pet.getImageUrl());
pet.setImageUrl(catalogImageStorageService.storePetImage(file));
return mapToMyPetResponse(petRepository.save(pet));
}
@@ -182,13 +185,13 @@ public class PetService {
public void deletePet(Long id) {
Pet pet = petRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Pet not found with id: " + id));
deleteStoredImageIfPresent(pet.getImageUrl());
catalogImageStorageService.deletePetImageIfPresent(pet.getImageUrl());
petRepository.delete(pet);
}
@Transactional
public void bulkDeletePets(BulkDeleteRequest request) {
petRepository.findAllById(request.getIds()).forEach(pet -> deleteStoredImageIfPresent(pet.getImageUrl()));
petRepository.findAllById(request.getIds()).forEach(pet -> catalogImageStorageService.deletePetImageIfPresent(pet.getImageUrl()));
petRepository.deleteAllById(request.getIds());
}
@@ -196,7 +199,7 @@ public class PetService {
public PetResponse uploadPetImage(Long id, MultipartFile file) throws IOException {
validateImageFile(file);
Pet pet = findPet(id);
deleteStoredImageIfPresent(pet.getImageUrl());
catalogImageStorageService.deletePetImageIfPresent(pet.getImageUrl());
pet.setImageUrl(catalogImageStorageService.storePetImage(file));
return mapToResponse(petRepository.save(pet));
}
@@ -204,7 +207,7 @@ public class PetService {
@Transactional
public PetResponse deletePetImage(Long id) {
Pet pet = findPet(id);
deleteStoredImageIfPresent(pet.getImageUrl());
catalogImageStorageService.deletePetImageIfPresent(pet.getImageUrl());
pet.setImageUrl(null);
return mapToResponse(petRepository.save(pet));
}
@@ -280,19 +283,9 @@ public class PetService {
private void validateImageFile(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new IllegalArgumentException("Please select an image to upload");
}
if (file.getSize() > 5 * 1024 * 1024) {
throw new IllegalArgumentException("Image file size must be less than 5MB");
}
String contentType = file.getContentType();
if (contentType == null) {
throw new IllegalArgumentException("Only JPG, PNG, and GIF images are allowed");
}
String normalized = contentType.toLowerCase(Locale.ROOT);
if (!normalized.equals("image/jpeg") && !normalized.equals("image/png") && !normalized.equals("image/gif")) {
throw new IllegalArgumentException("Only JPG, PNG, and GIF images are allowed");
throw new BusinessException("Please select an image to upload");
}
ImageValidationUtil.validate(file);
}
private Pet findOwnedPet(Long ownerUserId, Long petId) {
@@ -300,16 +293,6 @@ public class PetService {
.orElseThrow(() -> new ResourceNotFoundException("Pet not found with id: " + petId));
}
private void deleteStoredImageIfPresent(String storedImagePath) {
if (storedImagePath == null || storedImagePath.isBlank()) {
return;
}
try {
catalogImageStorageService.deletePetImage(storedImagePath);
} catch (IOException | IllegalArgumentException ignored) {
}
}
private String normalizeStatus(String status) {
return status == null ? "" : status.trim();
}
@@ -322,14 +305,6 @@ public class PetService {
return status == null || "available".equalsIgnoreCase(status) || "adopted".equalsIgnoreCase(status) || "owned".equalsIgnoreCase(status);
}
private String normalizeFilter(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private PetResponse mapToResponse(Pet pet) {
User owner = pet.getOwner();
StoreLocation store = pet.getStore();
@@ -345,7 +320,7 @@ public class PetService {
pet.getCreatedAt(),
pet.getUpdatedAt(),
owner != null ? owner.getId() : null,
owner != null ? owner.getFirstName() + " " + owner.getLastName() : null,
owner != null ? StringUtils.fullName(owner.getFirstName(), owner.getLastName()) : null,
store != null ? store.getStoreId() : null,
store != null ? store.getStoreName() : null
);

View File

@@ -1,6 +1,8 @@
package com.petshop.backend.service;
import com.petshop.backend.dto.common.BulkDeleteRequest;
import com.petshop.backend.util.ImageValidationUtil;
import com.petshop.backend.util.StringUtils;
import com.petshop.backend.dto.product.ProductRequest;
import com.petshop.backend.dto.product.ProductResponse;
import com.petshop.backend.entity.Category;
@@ -18,7 +20,7 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.Locale;
@Service
public class ProductService {
@@ -33,11 +35,13 @@ public class ProductService {
this.catalogImageStorageService = catalogImageStorageService;
}
@Transactional(readOnly = true)
public Page<ProductResponse> getAllProducts(String query, Long categoryId, Pageable pageable) {
return productRepository.searchProducts(normalizeFilter(query), categoryId, pageable)
return productRepository.searchProducts(StringUtils.trimToNull(query), categoryId, pageable)
.map(this::mapToResponse);
}
@Transactional(readOnly = true)
public ProductResponse getProductById(Long id) {
Product product = productRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Product not found with id: " + id));
@@ -87,13 +91,13 @@ public class ProductService {
@Transactional
public void deleteProduct(Long id) {
Product product = findProduct(id);
deleteStoredImageIfPresent(product.getImageUrl());
catalogImageStorageService.deleteProductImageIfPresent(product.getImageUrl());
productRepository.delete(product);
}
@Transactional
public void bulkDeleteProducts(BulkDeleteRequest request) {
productRepository.findAllById(request.getIds()).forEach(product -> deleteStoredImageIfPresent(product.getImageUrl()));
productRepository.findAllById(request.getIds()).forEach(product -> catalogImageStorageService.deleteProductImageIfPresent(product.getImageUrl()));
productRepository.deleteAllById(request.getIds());
}
@@ -101,7 +105,7 @@ public class ProductService {
public ProductResponse uploadProductImage(Long id, MultipartFile file) throws IOException {
validateImageFile(file);
Product product = findProduct(id);
deleteStoredImageIfPresent(product.getImageUrl());
catalogImageStorageService.deleteProductImageIfPresent(product.getImageUrl());
product.setImageUrl(catalogImageStorageService.storeProductImage(file));
return mapToResponse(productRepository.save(product));
}
@@ -109,11 +113,12 @@ public class ProductService {
@Transactional
public ProductResponse deleteProductImage(Long id) {
Product product = findProduct(id);
deleteStoredImageIfPresent(product.getImageUrl());
catalogImageStorageService.deleteProductImageIfPresent(product.getImageUrl());
product.setImageUrl(null);
return mapToResponse(productRepository.save(product));
}
@Transactional(readOnly = true)
public ImagePayload loadProductImage(Long id) {
Product product = findProduct(id);
if (product.getImageUrl() == null || product.getImageUrl().isBlank()) {
@@ -131,29 +136,9 @@ public class ProductService {
private void validateImageFile(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new IllegalArgumentException("Please select an image to upload");
}
if (file.getSize() > 5 * 1024 * 1024) {
throw new IllegalArgumentException("Image file size must be less than 5MB");
}
String contentType = file.getContentType();
if (contentType == null) {
throw new IllegalArgumentException("Only JPG, PNG, and GIF images are allowed");
}
String normalized = contentType.toLowerCase(Locale.ROOT);
if (!normalized.equals("image/jpeg") && !normalized.equals("image/png") && !normalized.equals("image/gif")) {
throw new IllegalArgumentException("Only JPG, PNG, and GIF images are allowed");
}
}
private void deleteStoredImageIfPresent(String storedImagePath) {
if (storedImagePath == null || storedImagePath.isBlank()) {
return;
}
try {
catalogImageStorageService.deleteProductImage(storedImagePath);
} catch (IOException | IllegalArgumentException ignored) {
throw new BusinessException("Please select an image to upload");
}
ImageValidationUtil.validate(file);
}
private ProductResponse mapToResponse(Product product) {
@@ -173,11 +158,4 @@ public class ProductService {
public record ImagePayload(Resource resource, MediaType mediaType) {
}
private String normalizeFilter(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
}

View File

@@ -1,6 +1,7 @@
package com.petshop.backend.service;
import com.petshop.backend.dto.productsupplier.BulkDeleteProductSupplierRequest;
import com.petshop.backend.util.StringUtils;
import com.petshop.backend.dto.productsupplier.ProductSupplierRequest;
import com.petshop.backend.dto.productsupplier.ProductSupplierResponse;
import com.petshop.backend.entity.Product;
@@ -33,13 +34,15 @@ public class ProductSupplierService {
this.supplierRepository = supplierRepository;
}
@Transactional(readOnly = true)
public Page<ProductSupplierResponse> getAllProductSuppliers(String query, Long productId, Long supplierId, Pageable pageable) {
String normalizedQuery = normalizeFilter(query);
String normalizedQuery = StringUtils.trimToNull(query);
Pageable mappedPageable = mapSortProperties(pageable);
Page<ProductSupplier> productSuppliers = productSupplierRepository.searchProductSuppliers(normalizedQuery, productId, supplierId, mappedPageable);
return productSuppliers.map(this::mapToResponse);
}
@Transactional(readOnly = true)
public ProductSupplierResponse getProductSupplierById(Long productId, Long supplierId) {
ProductSupplier.ProductSupplierId id = new ProductSupplier.ProductSupplierId(productId, supplierId);
ProductSupplier productSupplier = productSupplierRepository.findById(id)
@@ -97,14 +100,6 @@ public class ProductSupplierService {
});
}
private String normalizeFilter(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private Pageable mapSortProperties(Pageable pageable) {
if (pageable.getSort().isUnsorted()) {
return pageable;

View File

@@ -1,6 +1,7 @@
package com.petshop.backend.service;
import com.petshop.backend.dto.purchaseorder.PurchaseOrderResponse;
import com.petshop.backend.util.StringUtils;
import com.petshop.backend.entity.PurchaseOrder;
import com.petshop.backend.entity.StoreLocation;
import com.petshop.backend.exception.ResourceNotFoundException;
@@ -8,6 +9,7 @@ import com.petshop.backend.repository.PurchaseOrderRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class PurchaseOrderService {
@@ -18,26 +20,20 @@ public class PurchaseOrderService {
this.purchaseOrderRepository = purchaseOrderRepository;
}
@Transactional(readOnly = true)
public Page<PurchaseOrderResponse> getAllPurchaseOrders(String query, Long storeId, Pageable pageable) {
String normalizedQuery = normalizeFilter(query);
String normalizedQuery = StringUtils.trimToNull(query);
Page<PurchaseOrder> purchaseOrders = purchaseOrderRepository.searchPurchaseOrders(normalizedQuery, storeId, pageable);
return purchaseOrders.map(this::mapToResponse);
}
@Transactional(readOnly = true)
public PurchaseOrderResponse getPurchaseOrderById(Long id) {
PurchaseOrder purchaseOrder = purchaseOrderRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("PurchaseOrder not found with id: " + id));
return mapToResponse(purchaseOrder);
}
private String normalizeFilter(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private PurchaseOrderResponse mapToResponse(PurchaseOrder purchaseOrder) {
StoreLocation store = purchaseOrder.getStore();
return new PurchaseOrderResponse(

View File

@@ -8,6 +8,8 @@ import com.petshop.backend.exception.ResourceNotFoundException;
import com.petshop.backend.event.SaleReceiptEvent;
import com.petshop.backend.repository.*;
import com.petshop.backend.util.AuthenticationHelper;
import com.petshop.backend.config.BusinessProperties;
import com.petshop.backend.util.StringUtils;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
@@ -23,32 +25,33 @@ import java.util.List;
@Service
public class SaleService {
private static final BigDecimal EMPLOYEE_DISCOUNT_PERCENT = new BigDecimal("0.10");
private static final int LOYALTY_POINTS_PER_DOLLAR = 20;
private final SaleRepository saleRepository;
private final ProductRepository productRepository;
private final StoreRepository storeRepository;
private final InventoryRepository inventoryRepository;
private final UserRepository userRepository;
private final CouponRepository couponRepository;
private final CouponService couponService;
private final CartRepository cartRepository;
private final ApplicationEventPublisher eventPublisher;
private final BusinessProperties businessProperties;
public SaleService(SaleRepository saleRepository, ProductRepository productRepository, StoreRepository storeRepository, InventoryRepository inventoryRepository, UserRepository userRepository, CouponRepository couponRepository, CartRepository cartRepository, ApplicationEventPublisher eventPublisher) {
public SaleService(SaleRepository saleRepository, ProductRepository productRepository, StoreRepository storeRepository, InventoryRepository inventoryRepository, UserRepository userRepository, CouponRepository couponRepository, CouponService couponService, CartRepository cartRepository, ApplicationEventPublisher eventPublisher, BusinessProperties businessProperties) {
this.saleRepository = saleRepository;
this.productRepository = productRepository;
this.storeRepository = storeRepository;
this.inventoryRepository = inventoryRepository;
this.userRepository = userRepository;
this.couponRepository = couponRepository;
this.couponService = couponService;
this.cartRepository = cartRepository;
this.eventPublisher = eventPublisher;
this.businessProperties = businessProperties;
}
@Transactional(readOnly = true)
public Page<SaleResponse> getAllSales(String query, String paymentMethod, Long storeId, Boolean isRefund, Long customerId, Pageable pageable) {
Page<Sale> sales = saleRepository.searchSales(normalizeFilter(query), normalizeFilter(paymentMethod), storeId, isRefund, customerId, pageable);
Page<Sale> sales = saleRepository.searchSales(StringUtils.trimToNull(query), StringUtils.trimToNull(paymentMethod), storeId, isRefund, customerId, pageable);
return sales.map(this::mapToResponse);
}
@@ -86,7 +89,7 @@ public class SaleService {
if (request.getCouponId() != null) {
Coupon coupon = couponRepository.findByIdForUpdate(request.getCouponId())
.orElseThrow(() -> new ResourceNotFoundException("Coupon not found with id: " + request.getCouponId()));
validateCoupon(coupon);
couponService.validateCouponForUse(coupon);
sale.setCoupon(coupon);
}
@@ -236,7 +239,7 @@ public class SaleService {
}
sale.setSubtotalAmount(subtotalAmount);
BigDecimal couponDiscount = calculateCouponDiscount(sale.getCoupon(), subtotalAmount);
BigDecimal couponDiscount = couponService.calculateDiscount(sale.getCoupon(), subtotalAmount);
sale.setCouponDiscountAmount(couponDiscount);
BigDecimal employeeDiscount = calculateEmployeeDiscount(customer, subtotalAmount.subtract(couponDiscount));
@@ -247,7 +250,7 @@ public class SaleService {
int pointsDeducted;
if (request.getPointsUsed() != null && request.getPointsUsed() > 0) {
loyaltyDiscount = BigDecimal.valueOf(request.getPointsUsed())
.divide(BigDecimal.valueOf(LOYALTY_POINTS_PER_DOLLAR), 2, RoundingMode.HALF_UP)
.divide(BigDecimal.valueOf(businessProperties.loyaltyPointsPerDollar()), 2, RoundingMode.HALF_UP)
.min(remainingAfterDiscounts.max(BigDecimal.ZERO))
.setScale(2, RoundingMode.HALF_UP);
pointsDeducted = request.getPointsUsed();
@@ -282,52 +285,13 @@ public class SaleService {
return mapToResponse(savedSale);
}
private void validateCoupon(Coupon coupon) {
if (!Boolean.TRUE.equals(coupon.getActive())) {
throw new BusinessException("Coupon is not active");
}
LocalDateTime now = LocalDateTime.now();
if (coupon.getStartsAt() != null && now.isBefore(coupon.getStartsAt())) {
throw new BusinessException("Coupon has not started yet");
}
if (coupon.getEndsAt() != null && now.isAfter(coupon.getEndsAt())) {
throw new BusinessException("Coupon has expired");
}
if (coupon.getUsageLimit() != null) {
long used = saleRepository.countByCoupon_CouponId(coupon.getCouponId());
if (used >= coupon.getUsageLimit()) {
throw new BusinessException("Coupon usage limit has been reached");
}
}
}
private BigDecimal calculateCouponDiscount(Coupon coupon, BigDecimal subtotal) {
if (coupon == null || subtotal.compareTo(BigDecimal.ZERO) <= 0) {
return BigDecimal.ZERO;
}
if (coupon.getMinOrderAmount() != null && subtotal.compareTo(coupon.getMinOrderAmount()) < 0) {
return BigDecimal.ZERO;
}
BigDecimal discount = BigDecimal.ZERO;
String type = coupon.getDiscountType().trim().toUpperCase();
if ("PERCENTAGE".equals(type) || "PERCENT".equals(type)) {
discount = subtotal.multiply(coupon.getDiscountValue().divide(new BigDecimal("100"), 4, RoundingMode.HALF_UP));
} else if ("FIXED".equals(type)) {
discount = coupon.getDiscountValue();
}
return discount.min(subtotal).setScale(2, RoundingMode.HALF_UP);
}
private BigDecimal calculateEmployeeDiscount(User customer, BigDecimal remainingAmount) {
if (customer == null || remainingAmount.compareTo(BigDecimal.ZERO) <= 0) {
return BigDecimal.ZERO;
}
if (customer.getRole() == User.Role.STAFF || customer.getRole() == User.Role.ADMIN) {
return remainingAmount.multiply(EMPLOYEE_DISCOUNT_PERCENT).setScale(2, RoundingMode.HALF_UP);
return remainingAmount.multiply(businessProperties.employeeDiscountPercent()).setScale(2, RoundingMode.HALF_UP);
}
return BigDecimal.ZERO;
@@ -339,7 +303,7 @@ public class SaleService {
}
int availablePoints = customer.getLoyaltyPoints() != null ? customer.getLoyaltyPoints() : 0;
int wholeDollars = availablePoints / LOYALTY_POINTS_PER_DOLLAR;
int wholeDollars = availablePoints / businessProperties.loyaltyPointsPerDollar();
if (wholeDollars <= 0) {
return BigDecimal.ZERO;
}
@@ -354,7 +318,7 @@ public class SaleService {
if (loyaltyDiscount == null || loyaltyDiscount.compareTo(BigDecimal.ZERO) <= 0) {
return 0;
}
return loyaltyDiscount.setScale(0, RoundingMode.DOWN).intValue() * LOYALTY_POINTS_PER_DOLLAR;
return loyaltyDiscount.setScale(0, RoundingMode.DOWN).intValue() * businessProperties.loyaltyPointsPerDollar();
}
private User resolveWebsiteSaleEmployee(Long storeId) {
@@ -367,8 +331,11 @@ public class SaleService {
SaleResponse response = new SaleResponse();
response.setSaleId(sale.getSaleId());
response.setSaleDate(sale.getSaleDate());
response.setEmployeeId(sale.getEmployee().getId());
response.setEmployeeName(sale.getEmployee().getFirstName() + " " + sale.getEmployee().getLastName());
if (sale.getEmployee() != null) {
response.setEmployeeId(sale.getEmployee().getId());
response.setEmployeeName(StringUtils.fullName(sale.getEmployee().getFirstName(), sale.getEmployee().getLastName()));
}
if (sale.getStore() != null) {
response.setStoreId(sale.getStore().getStoreId());
@@ -377,7 +344,7 @@ public class SaleService {
if (sale.getCustomer() != null) {
response.setCustomerId(sale.getCustomer().getId());
response.setCustomerName(sale.getCustomer().getFirstName() + " " + sale.getCustomer().getLastName());
response.setCustomerName(StringUtils.fullName(sale.getCustomer().getFirstName(), sale.getCustomer().getLastName()));
}
response.setTotalAmount(sale.getTotalAmount());
@@ -416,14 +383,6 @@ public class SaleService {
return response;
}
private String normalizeFilter(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
String normalizePaymentMethod(String paymentMethod) {
if (paymentMethod == null) {
return null;

View File

@@ -5,6 +5,7 @@ import com.petshop.backend.dto.service.ServiceRequest;
import com.petshop.backend.dto.service.ServiceResponse;
import com.petshop.backend.exception.ResourceNotFoundException;
import com.petshop.backend.repository.ServiceRepository;
import com.petshop.backend.util.StringUtils;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
@@ -21,9 +22,8 @@ public class ServiceService {
@Transactional(readOnly = true)
public Page<ServiceResponse> getAllServices(String query, String species, Pageable pageable) {
String q = (query != null && !query.trim().isEmpty()) ? query.trim() : null;
String sp = (species != null && !species.trim().isEmpty()) ? species.trim() : null;
return serviceRepository.searchServices(q, sp, pageable).map(this::mapToResponse);
return serviceRepository.searchServices(StringUtils.trimToNull(query), StringUtils.trimToNull(species), pageable)
.map(this::mapToResponse);
}
@Transactional(readOnly = true)
@@ -36,14 +36,7 @@ public class ServiceService {
@Transactional
public ServiceResponse createService(ServiceRequest request) {
com.petshop.backend.entity.Service service = new com.petshop.backend.entity.Service();
service.setServiceName(request.getServiceName());
service.setServiceDesc(request.getServiceDesc());
service.setServicePrice(request.getServicePrice());
service.setServiceDuration(request.getServiceDuration());
if (request.getSpecies() != null) {
service.setSpecies(request.getSpecies());
}
applyFields(service, request);
service = serviceRepository.save(service);
return mapToResponse(service);
}
@@ -52,15 +45,7 @@ public class ServiceService {
public ServiceResponse updateService(Long id, ServiceRequest request) {
com.petshop.backend.entity.Service service = serviceRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Service not found with id: " + id));
service.setServiceName(request.getServiceName());
service.setServiceDesc(request.getServiceDesc());
service.setServicePrice(request.getServicePrice());
service.setServiceDuration(request.getServiceDuration());
if (request.getSpecies() != null) {
service.setSpecies(request.getSpecies());
}
applyFields(service, request);
service = serviceRepository.save(service);
return mapToResponse(service);
}
@@ -78,6 +63,16 @@ public class ServiceService {
serviceRepository.deleteAllById(request.getIds());
}
private void applyFields(com.petshop.backend.entity.Service service, ServiceRequest request) {
service.setServiceName(request.getServiceName());
service.setServiceDesc(request.getServiceDesc());
service.setServicePrice(request.getServicePrice());
service.setServiceDuration(request.getServiceDuration());
if (request.getSpecies() != null) {
service.setSpecies(request.getSpecies());
}
}
private ServiceResponse mapToResponse(com.petshop.backend.entity.Service service) {
return new ServiceResponse(
service.getServiceId(),

View File

@@ -6,6 +6,7 @@ import com.petshop.backend.dto.store.StoreResponse;
import com.petshop.backend.entity.StoreLocation;
import com.petshop.backend.exception.ResourceNotFoundException;
import com.petshop.backend.repository.StoreRepository;
import com.petshop.backend.util.StringUtils;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
@@ -20,16 +21,16 @@ public class StoreService {
this.storeRepository = storeRepository;
}
@Transactional(readOnly = true)
public Page<StoreResponse> getAllStores(String query, Pageable pageable) {
Page<StoreLocation> stores;
if (query != null && !query.trim().isEmpty()) {
stores = storeRepository.searchStores(query, pageable);
} else {
stores = storeRepository.findAll(pageable);
}
String q = StringUtils.trimToNull(query);
Page<StoreLocation> stores = q != null
? storeRepository.searchStores(q, pageable)
: storeRepository.findAll(pageable);
return stores.map(this::mapToResponse);
}
@Transactional(readOnly = true)
public StoreResponse getStoreById(Long id) {
StoreLocation store = storeRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Store not found with id: " + id));
@@ -39,12 +40,7 @@ public class StoreService {
@Transactional
public StoreResponse createStore(StoreRequest request) {
StoreLocation store = new StoreLocation();
store.setStoreName(request.getStoreName());
store.setAddress(request.getAddress());
store.setPhone(request.getPhone());
store.setEmail(request.getEmail());
store.setImageUrl(request.getImageUrl());
applyFields(store, request);
store = storeRepository.save(store);
return mapToResponse(store);
}
@@ -53,13 +49,7 @@ public class StoreService {
public StoreResponse updateStore(Long id, StoreRequest request) {
StoreLocation store = storeRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Store not found with id: " + id));
store.setStoreName(request.getStoreName());
store.setAddress(request.getAddress());
store.setPhone(request.getPhone());
store.setEmail(request.getEmail());
store.setImageUrl(request.getImageUrl());
applyFields(store, request);
store = storeRepository.save(store);
return mapToResponse(store);
}
@@ -77,6 +67,14 @@ public class StoreService {
storeRepository.deleteAllById(request.getIds());
}
private void applyFields(StoreLocation store, StoreRequest request) {
store.setStoreName(request.getStoreName());
store.setAddress(request.getAddress());
store.setPhone(request.getPhone());
store.setEmail(request.getEmail());
store.setImageUrl(request.getImageUrl());
}
private StoreResponse mapToResponse(StoreLocation store) {
return new StoreResponse(
store.getStoreId(),

View File

@@ -6,6 +6,7 @@ import com.petshop.backend.dto.supplier.SupplierResponse;
import com.petshop.backend.entity.Supplier;
import com.petshop.backend.exception.ResourceNotFoundException;
import com.petshop.backend.repository.SupplierRepository;
import com.petshop.backend.util.StringUtils;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
@@ -20,16 +21,16 @@ public class SupplierService {
this.supplierRepository = supplierRepository;
}
@Transactional(readOnly = true)
public Page<SupplierResponse> getAllSuppliers(String query, Pageable pageable) {
Page<Supplier> suppliers;
if (query != null && !query.trim().isEmpty()) {
suppliers = supplierRepository.searchSuppliers(query, pageable);
} else {
suppliers = supplierRepository.findAll(pageable);
}
String q = StringUtils.trimToNull(query);
Page<Supplier> suppliers = q != null
? supplierRepository.searchSuppliers(q, pageable)
: supplierRepository.findAll(pageable);
return suppliers.map(this::mapToResponse);
}
@Transactional(readOnly = true)
public SupplierResponse getSupplierById(Long id) {
Supplier supplier = supplierRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Supplier not found with id: " + id));
@@ -39,12 +40,7 @@ public class SupplierService {
@Transactional
public SupplierResponse createSupplier(SupplierRequest request) {
Supplier supplier = new Supplier();
supplier.setSupCompany(request.getSupCompany());
supplier.setSupContactFirstName(request.getSupContactFirstName());
supplier.setSupContactLastName(request.getSupContactLastName());
supplier.setSupEmail(request.getSupEmail());
supplier.setSupPhone(request.getSupPhone());
applyFields(supplier, request);
supplier = supplierRepository.save(supplier);
return mapToResponse(supplier);
}
@@ -53,13 +49,7 @@ public class SupplierService {
public SupplierResponse updateSupplier(Long id, SupplierRequest request) {
Supplier supplier = supplierRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Supplier not found with id: " + id));
supplier.setSupCompany(request.getSupCompany());
supplier.setSupContactFirstName(request.getSupContactFirstName());
supplier.setSupContactLastName(request.getSupContactLastName());
supplier.setSupEmail(request.getSupEmail());
supplier.setSupPhone(request.getSupPhone());
applyFields(supplier, request);
supplier = supplierRepository.save(supplier);
return mapToResponse(supplier);
}
@@ -77,6 +67,14 @@ public class SupplierService {
supplierRepository.deleteAllById(request.getIds());
}
private void applyFields(Supplier supplier, SupplierRequest request) {
supplier.setSupCompany(request.getSupCompany());
supplier.setSupContactFirstName(request.getSupContactFirstName());
supplier.setSupContactLastName(request.getSupContactLastName());
supplier.setSupEmail(request.getSupEmail());
supplier.setSupPhone(request.getSupPhone());
}
private SupplierResponse mapToResponse(Supplier supplier) {
return new SupplierResponse(
supplier.getSupId(),

View File

@@ -10,6 +10,7 @@ import com.petshop.backend.repository.StoreRepository;
import com.petshop.backend.repository.UserRepository;
import com.petshop.backend.security.UserAuthCacheService;
import com.petshop.backend.util.AuthenticationHelper;
import com.petshop.backend.util.StringUtils;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.security.access.AccessDeniedException;
@@ -43,6 +44,7 @@ public class UserService {
this.userAuthCacheService = userAuthCacheService;
}
@Transactional(readOnly = true)
public Page<UserResponse> getAllUsers(String query, String role, Pageable pageable) {
User.Role parsedRole = parseRole(role);
Page<User> users;
@@ -59,6 +61,7 @@ public class UserService {
return users.map(this::mapToResponse);
}
@Transactional(readOnly = true)
public UserResponse getUserById(Long id) {
return getUserById(id, null);
}
@@ -81,7 +84,7 @@ public class UserService {
requireRequestedRole(request.getRole(), requiredRole);
User user = new User();
user.setUsername(trimToNull(request.getUsername()));
user.setUsername(StringUtils.trimToNull(request.getUsername()));
if (request.getPassword() != null && !request.getPassword().trim().isEmpty()) {
user.setPassword(passwordEncoder.encode(request.getPassword()));
}
@@ -89,9 +92,9 @@ public class UserService {
user.setLastName(request.getLastName());
user.setFullName(request.getFullName());
user.setEmail(request.getEmail());
user.setPhone(trimToNull(request.getPhone()));
user.setPhone(StringUtils.normalizePhone(request.getPhone()));
user.setRole(request.getRole());
user.setStaffRole(trimToNull(request.getStaffRole()));
user.setStaffRole(StringUtils.trimToNull(request.getStaffRole()));
user.setPrimaryStore(resolveStore(request.getPrimaryStoreId()));
user.setActive(request.getActive() != null ? request.getActive() : true);
if (request.getLoyaltyPoints() != null) {
@@ -124,7 +127,7 @@ public class UserService {
|| user.getRole() != request.getRole()
|| !user.getActive().equals(request.getActive() != null ? request.getActive() : true);
user.setUsername(trimToNull(request.getUsername()));
user.setUsername(StringUtils.trimToNull(request.getUsername()));
if (request.getPassword() != null && !request.getPassword().trim().isEmpty()) {
user.setPassword(passwordEncoder.encode(request.getPassword()));
invalidateToken = true;
@@ -133,13 +136,13 @@ public class UserService {
user.setLastName(request.getLastName());
user.setFullName(request.getFullName());
user.setEmail(request.getEmail());
String phone = trimToNull(request.getPhone());
String phone = StringUtils.normalizePhone(request.getPhone());
if (!Objects.equals(user.getPhone(), phone)) {
validateUniquePhone(phone, user.getId());
}
user.setPhone(phone);
user.setRole(request.getRole());
user.setStaffRole(trimToNull(request.getStaffRole()));
user.setStaffRole(StringUtils.trimToNull(request.getStaffRole()));
user.setPrimaryStore(resolveStore(request.getPrimaryStoreId()));
user.setActive(request.getActive() != null ? request.getActive() : true);
if (request.getLoyaltyPoints() != null) {
@@ -277,16 +280,8 @@ public class UserService {
});
}
private String trimToNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private User.Role parseRole(String role) {
String normalizedRole = trimToNull(role);
String normalizedRole = StringUtils.trimToNull(role);
if (normalizedRole == null) {
return null;
}

View File

@@ -3,17 +3,26 @@ package com.petshop.backend.util;
import com.petshop.backend.entity.User;
import com.petshop.backend.repository.UserRepository;
import com.petshop.backend.security.AppPrincipal;
import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component;
@Component
public class AuthenticationHelper {
private final UserRepository userRepository;
public AuthenticationHelper(UserRepository userRepository) {
this.userRepository = userRepository;
}
public static Authentication getAuthentication() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()) {
throw new RuntimeException("No authenticated user found");
throw new AuthenticationCredentialsNotFoundException("No authenticated user found");
}
return authentication;
}
@@ -23,24 +32,47 @@ public class AuthenticationHelper {
if (principal instanceof AppPrincipal appPrincipal) {
return appPrincipal;
}
throw new RuntimeException("Authenticated principal is not supported");
throw new AuthenticationServiceException("Authenticated principal is not supported");
}
public static Long getAuthenticatedUserId() {
return getAuthenticatedPrincipal().getUserId();
}
public static User.Role getAuthenticatedRole() {
return getAuthenticatedPrincipal().getRole();
}
public static boolean isCustomer() {
return getAuthenticatedPrincipal().getRole() == User.Role.CUSTOMER;
}
public User getAuthenticatedUser() {
return getAuthenticatedUser(userRepository);
}
public Long getCustomerIdOrNull() {
if (!isCustomer()) {
return null;
}
return getAuthenticatedUser().getId();
}
public Long getEffectiveCustomerId(Long requestedCustomerId) {
return isCustomer() ? getAuthenticatedUser().getId() : requestedCustomerId;
}
public static User getAuthenticatedUser(UserRepository userRepository) {
Authentication authentication = getAuthentication();
Object principal = authentication.getPrincipal();
if (principal instanceof AppPrincipal appPrincipal) {
return userRepository.findById(appPrincipal.getUserId())
.orElseThrow(() -> new RuntimeException("User not found: " + appPrincipal.getUserId()));
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + appPrincipal.getUserId()));
}
String username = authentication.getName();
return userRepository.findByUsername(username)
.orElseThrow(() -> new RuntimeException("User not found: " + username));
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
}
}

View File

@@ -0,0 +1,34 @@
package com.petshop.backend.util;
import com.petshop.backend.exception.BusinessException;
import org.springframework.web.multipart.MultipartFile;
import java.util.Locale;
public final class ImageValidationUtil {
public static final long DEFAULT_MAX_IMAGE_SIZE = 5 * 1024 * 1024;
private ImageValidationUtil() {}
public static void validate(MultipartFile file) {
validate(file, DEFAULT_MAX_IMAGE_SIZE);
}
public static void validate(MultipartFile file, long maxSizeBytes) {
if (file.isEmpty()) {
throw new BusinessException("Please select an image to upload");
}
if (file.getSize() > maxSizeBytes) {
throw new BusinessException("Image file size must be less than 5MB");
}
String contentType = file.getContentType();
if (contentType == null) {
throw new BusinessException("Only JPG, PNG, and GIF images are allowed");
}
String normalized = contentType.toLowerCase(Locale.ROOT);
if (!normalized.equals("image/jpeg") && !normalized.equals("image/png") && !normalized.equals("image/gif")) {
throw new BusinessException("Only JPG, PNG, and GIF images are allowed");
}
}
}

View File

@@ -0,0 +1,30 @@
package com.petshop.backend.util;
public final class StringUtils {
private StringUtils() {}
public static String trimToNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
public static String fullName(String firstName, String lastName) {
String first = trimToNull(firstName);
String last = trimToNull(lastName);
if (first == null) {
return last;
}
if (last == null) {
return first;
}
return first + " " + last;
}
public static String normalizePhone(String value) {
return trimToNull(PhoneUtils.normalize(trimToNull(value)));
}
}

View File

@@ -61,6 +61,15 @@ app:
frontend-url: ${FRONTEND_URL:http://localhost:3000}
allowed-origins: ${ALLOWED_ORIGINS:http://localhost:3000,http://localhost:3001,http://127.0.0.1:3000,https://petshop-web.nicepond-c7280126.westus2.azurecontainerapps.io}
petshop:
business:
open-time: "09:00"
close-time: "17:00"
slot-interval-minutes: 30
max-image-size-bytes: 5242880
employee-discount-percent: 0.10
loyalty-points-per-dollar: 20
azure:
storage:
connection-string: ${AZURE_STORAGE_CONNECTION_STRING:}

View File

@@ -59,7 +59,7 @@ class UserServiceTest {
User target = user(2L, User.Role.ADMIN);
authenticate(actor);
when(userRepository.findById(2L)).thenReturn(Optional.of(target));
when(userRepository.findByIdForUpdate(2L)).thenReturn(Optional.of(target));
when(userRepository.findById(1L)).thenReturn(Optional.of(actor));
UserRequest request = request(User.Role.ADMIN);
@@ -74,7 +74,7 @@ class UserServiceTest {
User target = user(2L, User.Role.ADMIN);
authenticate(actor);
when(userRepository.findById(2L)).thenReturn(Optional.of(target));
when(userRepository.findByIdForUpdate(2L)).thenReturn(Optional.of(target));
UserRequest request = request(User.Role.CUSTOMER);
@@ -145,6 +145,7 @@ class UserServiceTest {
User actor = user(1L, User.Role.ADMIN);
authenticate(actor);
when(userRepository.findByIdForUpdate(1L)).thenReturn(Optional.of(actor));
when(userRepository.findById(1L)).thenReturn(Optional.of(actor));
when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0));
@@ -161,7 +162,7 @@ class UserServiceTest {
User target = user(2L, User.Role.CUSTOMER);
authenticate(actor);
when(userRepository.findById(2L)).thenReturn(Optional.of(target));
when(userRepository.findByIdForUpdate(2L)).thenReturn(Optional.of(target));
when(userRepository.findById(1L)).thenReturn(Optional.of(actor));
UserRequest request = request(User.Role.ADMIN);
@@ -176,7 +177,7 @@ class UserServiceTest {
User target = user(2L, User.Role.CUSTOMER);
authenticate(actor);
when(userRepository.findById(2L)).thenReturn(Optional.of(target));
when(userRepository.findByIdForUpdate(2L)).thenReturn(Optional.of(target));
UserRequest request = request(User.Role.ADMIN);