merge main
This commit is contained in:
@@ -2,7 +2,9 @@ package com.petshop.backend.controller;
|
||||
|
||||
import com.petshop.backend.dto.activity.ActivityLogResponse;
|
||||
import com.petshop.backend.service.ActivityLogService;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -26,8 +28,10 @@ public class ActivityLogController {
|
||||
@RequestParam(defaultValue = "2000") int limit,
|
||||
@RequestParam(required = false) Long storeId,
|
||||
@RequestParam(required = false) String role,
|
||||
@RequestParam(required = false) String search) {
|
||||
@RequestParam(required = false) String search,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {
|
||||
int safeLimit = Math.min(Math.max(1, limit), 10000);
|
||||
return ResponseEntity.ok(activityLogService.getLogs(safeLimit, storeId, role, search));
|
||||
return ResponseEntity.ok(activityLogService.getLogs(safeLimit, storeId, role, search, startDate, endDate));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ 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;
|
||||
@@ -49,6 +50,7 @@ public class AiChatController {
|
||||
if (request.getMessage() == null || request.getMessage().isBlank()) {
|
||||
return ResponseEntity.badRequest().body(AiChatResponse.fail("Message cannot be empty"));
|
||||
}
|
||||
ContentFilter.validate(request.getMessage());
|
||||
|
||||
User user = getCurrentUser();
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import com.petshop.backend.repository.UserRepository;
|
||||
import com.petshop.backend.security.JwtUtil;
|
||||
import com.petshop.backend.service.ActivityLogService;
|
||||
import com.petshop.backend.service.AvatarStorageService;
|
||||
import com.petshop.backend.service.EmailService;
|
||||
import com.petshop.backend.service.PasswordResetService;
|
||||
import com.petshop.backend.util.AuthenticationHelper;
|
||||
import com.petshop.backend.util.PhoneUtils;
|
||||
@@ -55,8 +56,9 @@ public class AuthController {
|
||||
private final AvatarStorageService avatarStorageService;
|
||||
private final ActivityLogService activityLogService;
|
||||
private final PasswordResetService passwordResetService;
|
||||
private final EmailService emailService;
|
||||
|
||||
public AuthController(AuthenticationManager authenticationManager, UserRepository userRepository, JwtUtil jwtUtil, PasswordEncoder passwordEncoder, AvatarStorageService avatarStorageService, ActivityLogService activityLogService, PasswordResetService passwordResetService) {
|
||||
public AuthController(AuthenticationManager authenticationManager, UserRepository userRepository, JwtUtil jwtUtil, PasswordEncoder passwordEncoder, AvatarStorageService avatarStorageService, ActivityLogService activityLogService, PasswordResetService passwordResetService, EmailService emailService) {
|
||||
this.authenticationManager = authenticationManager;
|
||||
this.userRepository = userRepository;
|
||||
this.jwtUtil = jwtUtil;
|
||||
@@ -64,6 +66,7 @@ public class AuthController {
|
||||
this.avatarStorageService = avatarStorageService;
|
||||
this.activityLogService = activityLogService;
|
||||
this.passwordResetService = passwordResetService;
|
||||
this.emailService = emailService;
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
@@ -108,6 +111,8 @@ public class AuthController {
|
||||
|
||||
User savedUser = userRepository.save(user);
|
||||
|
||||
emailService.sendWelcome(savedUser);
|
||||
|
||||
String token = jwtUtil.generateToken(savedUser);
|
||||
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(new RegisterResponse(
|
||||
|
||||
@@ -3,6 +3,7 @@ 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;
|
||||
@@ -32,30 +33,30 @@ public class CustomerController {
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<UserResponse> getCustomerById(@PathVariable Long id) {
|
||||
return ResponseEntity.ok(userService.getUserById(id));
|
||||
return ResponseEntity.ok(userService.getUserById(id, User.Role.CUSTOMER));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<UserResponse> createCustomer(@Valid @RequestBody UserRequest request) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(userService.createUser(request));
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(userService.createUser(request, User.Role.CUSTOMER));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<UserResponse> updateCustomer(
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody UserRequest request) {
|
||||
return ResponseEntity.ok(userService.updateUser(id, request));
|
||||
return ResponseEntity.ok(userService.updateUser(id, request, User.Role.CUSTOMER));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> deleteCustomer(@PathVariable Long id) {
|
||||
userService.deleteUser(id);
|
||||
userService.deleteUser(id, User.Role.CUSTOMER);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping("/bulk-delete")
|
||||
public ResponseEntity<Void> bulkDeleteCustomers(@Valid @RequestBody BulkDeleteRequest request) {
|
||||
userService.bulkDeleteUsers(request);
|
||||
userService.bulkDeleteUsers(request, User.Role.CUSTOMER);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.petshop.backend.controller;
|
||||
|
||||
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;
|
||||
@@ -31,24 +32,24 @@ public class EmployeeController {
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<UserResponse> getEmployeeById(@PathVariable Long id) {
|
||||
return ResponseEntity.ok(userService.getUserById(id));
|
||||
return ResponseEntity.ok(userService.getUserById(id, User.Role.STAFF));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<UserResponse> createEmployee(@Valid @RequestBody UserRequest request) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(userService.createUser(request));
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(userService.createUser(request, User.Role.STAFF));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<UserResponse> updateEmployee(
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody UserRequest request) {
|
||||
return ResponseEntity.ok(userService.updateUser(id, request));
|
||||
return ResponseEntity.ok(userService.updateUser(id, request, User.Role.STAFF));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> deleteEmployee(@PathVariable Long id) {
|
||||
userService.deleteUser(id);
|
||||
userService.deleteUser(id, User.Role.STAFF);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ public class RefundController {
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')")
|
||||
@PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF')")
|
||||
public ResponseEntity<RefundResponse> createRefund(@Valid @RequestBody RefundRequest request) {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
String role = authentication.getAuthorities().stream()
|
||||
@@ -85,7 +85,7 @@ public class RefundController {
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@PreAuthorize("hasAnyRole('STAFF', 'ADMIN')")
|
||||
@PreAuthorize("hasRole('STAFF')")
|
||||
public ResponseEntity<RefundResponse> updateRefund(@PathVariable Long id, @Valid @RequestBody RefundUpdateRequest request) {
|
||||
return ResponseEntity.ok(refundService.updateRefundStatus(id, request.getStatus()));
|
||||
}
|
||||
|
||||
@@ -28,8 +28,9 @@ public class SaleController {
|
||||
@RequestParam(required = false) String paymentMethod,
|
||||
@RequestParam(required = false) Long storeId,
|
||||
@RequestParam(required = false) Boolean isRefund,
|
||||
@RequestParam(required = false) Long customerId,
|
||||
Pageable pageable) {
|
||||
return ResponseEntity.ok(saleService.getAllSales(q, paymentMethod, storeId, isRefund, pageable));
|
||||
return ResponseEntity.ok(saleService.getAllSales(q, paymentMethod, storeId, isRefund, customerId, pageable));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@@ -39,7 +40,7 @@ public class SaleController {
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAnyRole('STAFF', 'ADMIN')")
|
||||
@PreAuthorize("hasRole('STAFF')")
|
||||
public ResponseEntity<SaleResponse> createSale(@Valid @RequestBody SaleRequest request) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(saleService.createSale(request));
|
||||
}
|
||||
|
||||
@@ -25,8 +25,9 @@ public class ServiceController {
|
||||
@GetMapping
|
||||
public ResponseEntity<Page<ServiceResponse>> getAllServices(
|
||||
@RequestParam(required = false) String q,
|
||||
@RequestParam(required = false) String species,
|
||||
Pageable pageable) {
|
||||
return ResponseEntity.ok(serviceService.getAllServices(q, pageable));
|
||||
return ResponseEntity.ok(serviceService.getAllServices(q, species, pageable));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
|
||||
@@ -24,7 +24,7 @@ public class UserAvatarController {
|
||||
}
|
||||
|
||||
@GetMapping("/{userId}/avatar/file")
|
||||
@PreAuthorize("hasAnyRole('STAFF', 'ADMIN')")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<Resource> getUserAvatarFile(@PathVariable Long userId) {
|
||||
User user = userRepository.findById(userId).orElse(null);
|
||||
if (user == null || !avatarStorageService.hasAvatar(user)) {
|
||||
|
||||
@@ -11,21 +11,19 @@ public class PurchaseOrderResponse {
|
||||
private Long storeId;
|
||||
private String storeName;
|
||||
private LocalDate orderDate;
|
||||
private String status;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public PurchaseOrderResponse() {
|
||||
}
|
||||
|
||||
public PurchaseOrderResponse(Long purchaseOrderId, Long supId, String supplierName, Long storeId, String storeName, LocalDate orderDate, String status, LocalDateTime createdAt, LocalDateTime updatedAt) {
|
||||
public PurchaseOrderResponse(Long purchaseOrderId, Long supId, String supplierName, Long storeId, String storeName, LocalDate orderDate, LocalDateTime createdAt, LocalDateTime updatedAt) {
|
||||
this.purchaseOrderId = purchaseOrderId;
|
||||
this.supId = supId;
|
||||
this.supplierName = supplierName;
|
||||
this.storeId = storeId;
|
||||
this.storeName = storeName;
|
||||
this.orderDate = orderDate;
|
||||
this.status = status;
|
||||
this.createdAt = createdAt;
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
@@ -78,14 +76,6 @@ public class PurchaseOrderResponse {
|
||||
this.orderDate = orderDate;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(String status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
@@ -107,12 +97,12 @@ public class PurchaseOrderResponse {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
PurchaseOrderResponse that = (PurchaseOrderResponse) o;
|
||||
return Objects.equals(purchaseOrderId, that.purchaseOrderId) && Objects.equals(supId, that.supId) && Objects.equals(supplierName, that.supplierName) && Objects.equals(storeId, that.storeId) && Objects.equals(storeName, that.storeName) && Objects.equals(orderDate, that.orderDate) && Objects.equals(status, that.status) && Objects.equals(createdAt, that.createdAt) && Objects.equals(updatedAt, that.updatedAt);
|
||||
return Objects.equals(purchaseOrderId, that.purchaseOrderId) && Objects.equals(supId, that.supId) && Objects.equals(supplierName, that.supplierName) && Objects.equals(storeId, that.storeId) && Objects.equals(storeName, that.storeName) && Objects.equals(orderDate, that.orderDate) && Objects.equals(createdAt, that.createdAt) && Objects.equals(updatedAt, that.updatedAt);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(purchaseOrderId, supId, supplierName, storeId, storeName, orderDate, status, createdAt, updatedAt);
|
||||
return Objects.hash(purchaseOrderId, supId, supplierName, storeId, storeName, orderDate, createdAt, updatedAt);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -124,7 +114,6 @@ public class PurchaseOrderResponse {
|
||||
", storeId=" + storeId +
|
||||
", storeName='" + storeName + '\'' +
|
||||
", orderDate=" + orderDate +
|
||||
", status='" + status + '\'' +
|
||||
", createdAt=" + createdAt +
|
||||
", updatedAt=" + updatedAt +
|
||||
'}';
|
||||
|
||||
@@ -28,6 +28,8 @@ public class SaleRequest {
|
||||
|
||||
private Long cartId;
|
||||
|
||||
private Integer pointsUsed;
|
||||
|
||||
|
||||
public Long getStoreId() {
|
||||
return storeId;
|
||||
@@ -101,6 +103,14 @@ public class SaleRequest {
|
||||
this.cartId = cartId;
|
||||
}
|
||||
|
||||
public Integer getPointsUsed() {
|
||||
return pointsUsed;
|
||||
}
|
||||
|
||||
public void setPointsUsed(Integer pointsUsed) {
|
||||
this.pointsUsed = pointsUsed;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
@@ -115,12 +125,13 @@ public class SaleRequest {
|
||||
Objects.equals(customerId, that.customerId) &&
|
||||
Objects.equals(channel, that.channel) &&
|
||||
Objects.equals(couponId, that.couponId) &&
|
||||
Objects.equals(cartId, that.cartId);
|
||||
Objects.equals(cartId, that.cartId) &&
|
||||
Objects.equals(pointsUsed, that.pointsUsed);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(storeId, paymentMethod, items, isRefund, originalSaleId, customerId, channel, couponId, cartId);
|
||||
return Objects.hash(storeId, paymentMethod, items, isRefund, originalSaleId, customerId, channel, couponId, cartId, pointsUsed);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -135,6 +146,7 @@ public class SaleRequest {
|
||||
", channel='" + channel + '\'' +
|
||||
", couponId=" + couponId +
|
||||
", cartId=" + cartId +
|
||||
", pointsUsed=" + pointsUsed +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,9 +27,6 @@ public class PurchaseOrder {
|
||||
@Column(nullable = false)
|
||||
private LocalDate orderDate;
|
||||
|
||||
@Column(nullable = false, length = 50)
|
||||
private String status;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
@@ -41,11 +38,10 @@ public class PurchaseOrder {
|
||||
public PurchaseOrder() {
|
||||
}
|
||||
|
||||
public PurchaseOrder(Long purchaseOrderId, Supplier supplier, LocalDate orderDate, String status, LocalDateTime createdAt, LocalDateTime updatedAt) {
|
||||
public PurchaseOrder(Long purchaseOrderId, Supplier supplier, LocalDate orderDate, LocalDateTime createdAt, LocalDateTime updatedAt) {
|
||||
this.purchaseOrderId = purchaseOrderId;
|
||||
this.supplier = supplier;
|
||||
this.orderDate = orderDate;
|
||||
this.status = status;
|
||||
this.createdAt = createdAt;
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
@@ -82,14 +78,6 @@ public class PurchaseOrder {
|
||||
this.orderDate = orderDate;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(String status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
@@ -125,7 +113,6 @@ public class PurchaseOrder {
|
||||
"purchaseOrderId=" + purchaseOrderId +
|
||||
", supplier=" + supplier +
|
||||
", orderDate=" + orderDate +
|
||||
", status='" + status + '\'' +
|
||||
", createdAt=" + createdAt +
|
||||
", updatedAt=" + updatedAt +
|
||||
'}';
|
||||
|
||||
@@ -42,9 +42,10 @@ public class GlobalExceptionHandler {
|
||||
errors.put(fieldName, errorMessage);
|
||||
});
|
||||
|
||||
String firstMessage = errors.values().stream().findFirst().orElse("Validation failed");
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("status", HttpStatus.BAD_REQUEST.value());
|
||||
response.put("message", "Validation failed");
|
||||
response.put("message", firstMessage);
|
||||
response.put("errors", errors);
|
||||
response.put("details", buildDetails(ex));
|
||||
response.put("path", request.getRequestURI());
|
||||
|
||||
@@ -40,4 +40,6 @@ public interface AdoptionRepository extends JpaRepository<Adoption, Long> {
|
||||
boolean existsByPet_IdAndAdoptionStatusIgnoreCase(Long petId, String adoptionStatus);
|
||||
|
||||
List<Adoption> findByCustomer_IdAndAdoptionStatusIgnoreCase(Long customerId, String adoptionStatus);
|
||||
|
||||
List<Adoption> findByAdoptionDateAndAdoptionStatusIgnoreCase(LocalDate date, String status);
|
||||
}
|
||||
|
||||
@@ -52,4 +52,6 @@ public interface AppointmentRepository extends JpaRepository<Appointment, Long>
|
||||
List<Appointment> findPastBookedAppointments(@Param("currentDate") LocalDate currentDate, @Param("currentTime") LocalTime currentTime);
|
||||
|
||||
List<Appointment> findByPet_Id(Long petId);
|
||||
|
||||
List<Appointment> findByAppointmentDateAndAppointmentStatusIgnoreCase(LocalDate date, String status);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@ import org.springframework.stereotype.Repository;
|
||||
@Repository
|
||||
public interface ProductRepository extends JpaRepository<Product, Long> {
|
||||
|
||||
boolean existsByProdNameIgnoreCase(String prodName);
|
||||
|
||||
boolean existsByProdNameIgnoreCaseAndProdIdNot(String prodName, Long prodId);
|
||||
|
||||
@Query("SELECT p FROM Product p WHERE " +
|
||||
"(:q IS NULL OR LOWER(p.prodName) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(COALESCE(p.prodDesc, '')) LIKE LOWER(CONCAT('%', :q, '%'))) AND " +
|
||||
"(:categoryId IS NULL OR p.category.categoryId = :categoryId)")
|
||||
|
||||
@@ -22,8 +22,9 @@ public interface SaleRepository extends JpaRepository<Sale, Long> {
|
||||
")) AND " +
|
||||
"(:paymentMethod IS NULL OR LOWER(s.paymentMethod) = LOWER(:paymentMethod)) AND " +
|
||||
"(:isRefund IS NULL OR s.isRefund = :isRefund) AND " +
|
||||
"(:storeId IS NULL OR s.store.storeId = :storeId)")
|
||||
Page<Sale> searchSales(@Param("q") String query, @Param("paymentMethod") String paymentMethod, @Param("storeId") Long storeId, @Param("isRefund") Boolean isRefund, Pageable pageable);
|
||||
"(:storeId IS NULL OR s.store.storeId = :storeId) AND " +
|
||||
"(:customerId IS NULL OR s.customer.id = :customerId)")
|
||||
Page<Sale> searchSales(@Param("q") String query, @Param("paymentMethod") String paymentMethod, @Param("storeId") Long storeId, @Param("isRefund") Boolean isRefund, @Param("customerId") Long customerId, Pageable pageable);
|
||||
|
||||
List<Sale> findByOriginalSaleSaleId(Long originalSaleId);
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ import org.springframework.stereotype.Repository;
|
||||
@Repository
|
||||
public interface ServiceRepository extends JpaRepository<Service, Long> {
|
||||
|
||||
@Query("SELECT s FROM Service s WHERE " +
|
||||
"LOWER(s.serviceName) LIKE LOWER(CONCAT('%', :q, '%')) OR " +
|
||||
"LOWER(s.serviceDesc) LIKE LOWER(CONCAT('%', :q, '%'))")
|
||||
Page<Service> searchServices(@Param("q") String query, Pageable pageable);
|
||||
@Query("SELECT DISTINCT s FROM Service s LEFT JOIN s.species sp WHERE " +
|
||||
"(:q IS NULL OR LOWER(s.serviceName) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(COALESCE(s.serviceDesc, '')) LIKE LOWER(CONCAT('%', :q, '%'))) AND " +
|
||||
"(:species IS NULL OR LOWER(sp) = LOWER(:species))")
|
||||
Page<Service> searchServices(@Param("q") String q, @Param("species") String species, Pageable pageable);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.petshop.backend.security;
|
||||
|
||||
import com.petshop.backend.exception.ApiErrorResponder;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
public class RateLimitFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final Map<String, int[]> RULES = Map.of(
|
||||
"/api/v1/auth/login", new int[]{10, 15},
|
||||
"/api/v1/auth/register", new int[]{5, 60},
|
||||
"/api/v1/auth/forgot-password", new int[]{3, 10},
|
||||
"/api/v1/auth/reset-password", new int[]{10, 15}
|
||||
);
|
||||
|
||||
private final RateLimiterService rateLimiterService;
|
||||
private final ApiErrorResponder apiErrorResponder;
|
||||
|
||||
public RateLimitFilter(RateLimiterService rateLimiterService, ApiErrorResponder apiErrorResponder) {
|
||||
this.rateLimiterService = rateLimiterService;
|
||||
this.apiErrorResponder = apiErrorResponder;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(@NonNull HttpServletRequest request,
|
||||
@NonNull HttpServletResponse response,
|
||||
@NonNull FilterChain filterChain) throws ServletException, IOException {
|
||||
String path = request.getRequestURI();
|
||||
int[] rule = RULES.get(path);
|
||||
|
||||
if (rule != null) {
|
||||
String ip = extractIp(request);
|
||||
String key = path + ":" + ip;
|
||||
if (!rateLimiterService.isAllowed(key, rule[0], Duration.ofMinutes(rule[1]))) {
|
||||
apiErrorResponder.write(response, HttpStatus.TOO_MANY_REQUESTS,
|
||||
"Too many requests. Please try again later.", null, path);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
private String extractIp(HttpServletRequest request) {
|
||||
String forwarded = request.getHeader("X-Forwarded-For");
|
||||
if (forwarded != null && !forwarded.isBlank()) {
|
||||
return forwarded.split(",")[0].trim();
|
||||
}
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.petshop.backend.security;
|
||||
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Deque;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@Service
|
||||
public class RateLimiterService {
|
||||
|
||||
private final Map<String, Deque<Instant>> buckets = new ConcurrentHashMap<>();
|
||||
|
||||
public boolean isAllowed(String key, int maxRequests, Duration window) {
|
||||
Instant now = Instant.now();
|
||||
Instant windowStart = now.minus(window);
|
||||
|
||||
Deque<Instant> timestamps = buckets.computeIfAbsent(key, k -> new ArrayDeque<>());
|
||||
synchronized (timestamps) {
|
||||
while (!timestamps.isEmpty() && timestamps.peekFirst().isBefore(windowStart)) {
|
||||
timestamps.pollFirst();
|
||||
}
|
||||
if (timestamps.size() >= maxRequests) {
|
||||
return false;
|
||||
}
|
||||
timestamps.addLast(now);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelay = 300_000)
|
||||
public void evictStale() {
|
||||
Instant cutoff = Instant.now().minus(Duration.ofHours(2));
|
||||
buckets.entrySet().removeIf(entry -> {
|
||||
Deque<Instant> timestamps = entry.getValue();
|
||||
synchronized (timestamps) {
|
||||
return timestamps.isEmpty() || timestamps.peekLast().isBefore(cutoff);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -31,15 +31,18 @@ import java.util.List;
|
||||
public class SecurityConfig {
|
||||
|
||||
private final JwtAuthenticationFilter jwtAuthFilter;
|
||||
private final RateLimitFilter rateLimitFilter;
|
||||
private final UserDetailsService userDetailsService;
|
||||
private final RestAuthenticationEntryPoint restAuthenticationEntryPoint;
|
||||
private final RestAccessDeniedHandler restAccessDeniedHandler;
|
||||
|
||||
public SecurityConfig(JwtAuthenticationFilter jwtAuthFilter,
|
||||
RateLimitFilter rateLimitFilter,
|
||||
UserDetailsService userDetailsService,
|
||||
RestAuthenticationEntryPoint restAuthenticationEntryPoint,
|
||||
RestAccessDeniedHandler restAccessDeniedHandler) {
|
||||
this.jwtAuthFilter = jwtAuthFilter;
|
||||
this.rateLimitFilter = rateLimitFilter;
|
||||
this.userDetailsService = userDetailsService;
|
||||
this.restAuthenticationEntryPoint = restAuthenticationEntryPoint;
|
||||
this.restAccessDeniedHandler = restAccessDeniedHandler;
|
||||
@@ -76,6 +79,7 @@ public class SecurityConfig {
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authenticationProvider(daoAuthenticationProvider())
|
||||
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
http.addFilterBefore(rateLimitFilter, JwtAuthenticationFilter.class);
|
||||
http.addFilterAfter(activityLoggingFilter, JwtAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
|
||||
@@ -15,6 +15,7 @@ import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@@ -52,6 +53,11 @@ public class ActivityLogService {
|
||||
entry.setStoreNameSnapshot(store != null ? store.getStoreName() : null);
|
||||
entry.setActivity(activity.trim());
|
||||
activityLogRepository.save(entry);
|
||||
log.info("[ACTIVITY] {} | {} | {} | {}",
|
||||
entry.getRoleSnapshot(),
|
||||
entry.getUsernameSnapshot(),
|
||||
entry.getStoreNameSnapshot() != null ? entry.getStoreNameSnapshot() : "no store",
|
||||
entry.getActivity());
|
||||
} catch (Exception ex) {
|
||||
log.warn("Failed to persist activity log", ex);
|
||||
}
|
||||
@@ -65,7 +71,7 @@ public class ActivityLogService {
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<ActivityLogResponse> getLogs(int limit, Long storeId, String role, String search) {
|
||||
public List<ActivityLogResponse> getLogs(int limit, Long storeId, String role, String search, LocalDate startDate, LocalDate endDate) {
|
||||
Specification<ActivityLog> spec = (root, query, cb) -> {
|
||||
List<Predicate> predicates = new ArrayList<>();
|
||||
|
||||
@@ -87,6 +93,14 @@ public class ActivityLogService {
|
||||
predicates.add(searchPredicate);
|
||||
}
|
||||
|
||||
if (startDate != null) {
|
||||
predicates.add(cb.greaterThanOrEqualTo(root.get("logTimestamp"), startDate.atStartOfDay()));
|
||||
}
|
||||
|
||||
if (endDate != null) {
|
||||
predicates.add(cb.lessThan(root.get("logTimestamp"), endDate.plusDays(1).atStartOfDay()));
|
||||
}
|
||||
|
||||
return cb.and(predicates.toArray(new Predicate[0]));
|
||||
};
|
||||
|
||||
@@ -96,9 +110,14 @@ public class ActivityLogService {
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<ActivityLogResponse> getLogs(int limit, Long storeId, String role, String search) {
|
||||
return getLogs(limit, storeId, role, search, null, null);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<ActivityLogResponse> getLogs(int limit) {
|
||||
return getLogs(limit, null, null, null);
|
||||
return getLogs(limit, null, null, null, null, null);
|
||||
}
|
||||
|
||||
private ActivityLogResponse toResponse(ActivityLog entry) {
|
||||
|
||||
@@ -22,6 +22,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class AdoptionService {
|
||||
@@ -39,13 +40,15 @@ public class AdoptionService {
|
||||
private final UserRepository userRepository;
|
||||
private final StoreRepository storeRepository;
|
||||
private final SaleRepository saleRepository;
|
||||
private final EmailService emailService;
|
||||
|
||||
public AdoptionService(AdoptionRepository adoptionRepository, PetRepository petRepository, UserRepository userRepository, StoreRepository storeRepository, SaleRepository saleRepository) {
|
||||
public AdoptionService(AdoptionRepository adoptionRepository, PetRepository petRepository, UserRepository userRepository, StoreRepository storeRepository, SaleRepository saleRepository, EmailService emailService) {
|
||||
this.adoptionRepository = adoptionRepository;
|
||||
this.petRepository = petRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.storeRepository = storeRepository;
|
||||
this.saleRepository = saleRepository;
|
||||
this.emailService = emailService;
|
||||
}
|
||||
|
||||
public Page<AdoptionResponse> getAllAdoptions(String query, Long customerId, String status, Long storeId, LocalDate date, Pageable pageable) {
|
||||
@@ -106,6 +109,9 @@ public class AdoptionService {
|
||||
if (ADOPTION_STATUS_COMPLETED.equalsIgnoreCase(adoptionStatus)) {
|
||||
createSaleForAdoption(adoption, request.getPaymentMethod());
|
||||
}
|
||||
if (ADOPTION_STATUS_PENDING.equalsIgnoreCase(adoptionStatus) || ADOPTION_STATUS_COMPLETED.equalsIgnoreCase(adoptionStatus)) {
|
||||
emailService.sendAdoptionConfirmation(adoption);
|
||||
}
|
||||
return mapToResponse(adoption);
|
||||
}
|
||||
|
||||
@@ -125,6 +131,7 @@ public class AdoptionService {
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Store not found with id: " + request.getSourceStoreId()))
|
||||
: null;
|
||||
boolean wasCompleted = ADOPTION_STATUS_COMPLETED.equalsIgnoreCase(adoption.getAdoptionStatus());
|
||||
String previousStatus = adoption.getAdoptionStatus();
|
||||
String adoptionStatus = normalizeAdoptionStatus(request.getAdoptionStatus());
|
||||
Long currentPetId = adoption.getPet() != null ? adoption.getPet().getPetId() : null;
|
||||
validatePetAvailability(pet, adoption.getAdoptionId(), currentPetId);
|
||||
@@ -144,6 +151,10 @@ public class AdoptionService {
|
||||
if (!wasCompleted && ADOPTION_STATUS_COMPLETED.equalsIgnoreCase(adoptionStatus)) {
|
||||
createSaleForAdoption(adoption, request.getPaymentMethod());
|
||||
}
|
||||
boolean statusChanged = !adoptionStatus.equalsIgnoreCase(previousStatus);
|
||||
if (statusChanged && (ADOPTION_STATUS_PENDING.equalsIgnoreCase(adoptionStatus) || ADOPTION_STATUS_COMPLETED.equalsIgnoreCase(adoptionStatus))) {
|
||||
emailService.sendAdoptionConfirmation(adoption);
|
||||
}
|
||||
return mapToResponse(adoption);
|
||||
}
|
||||
|
||||
@@ -200,15 +211,37 @@ public class AdoptionService {
|
||||
|
||||
@Transactional
|
||||
public void deleteAdoption(Long id) {
|
||||
if (!adoptionRepository.existsById(id)) {
|
||||
throw new ResourceNotFoundException("Adoption not found with id: " + id);
|
||||
}
|
||||
adoptionRepository.deleteById(id);
|
||||
Adoption adoption = adoptionRepository.findById(id)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Adoption not found with id: " + id));
|
||||
Pet pet = adoption.getPet();
|
||||
String status = adoption.getAdoptionStatus();
|
||||
adoptionRepository.delete(adoption);
|
||||
resetPetIfPending(pet, status);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void bulkDeleteAdoptions(BulkDeleteRequest request) {
|
||||
adoptionRepository.deleteAllById(request.getIds());
|
||||
List<Adoption> adoptions = adoptionRepository.findAllById(request.getIds());
|
||||
adoptionRepository.deleteAll(adoptions);
|
||||
for (Adoption adoption : adoptions) {
|
||||
resetPetIfPending(adoption.getPet(), adoption.getAdoptionStatus());
|
||||
}
|
||||
}
|
||||
|
||||
private void resetPetIfPending(Pet pet, String deletedAdoptionStatus) {
|
||||
if (!ADOPTION_STATUS_PENDING.equalsIgnoreCase(deletedAdoptionStatus)) {
|
||||
return;
|
||||
}
|
||||
if (!PET_STATUS_PENDING.equalsIgnoreCase(pet.getPetStatus())) {
|
||||
return;
|
||||
}
|
||||
boolean completedElsewhere = adoptionRepository.existsByPet_IdAndAdoptionStatusIgnoreCase(
|
||||
pet.getPetId(), ADOPTION_STATUS_COMPLETED);
|
||||
if (!completedElsewhere) {
|
||||
pet.setPetStatus(PET_STATUS_AVAILABLE);
|
||||
pet.setOwner(null);
|
||||
petRepository.save(pet);
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeFilter(String value) {
|
||||
@@ -309,7 +342,8 @@ public class AdoptionService {
|
||||
sale.setPaymentMethod(paymentMethod != null && !paymentMethod.isBlank() ? paymentMethod : "Cash");
|
||||
sale.setIsRefund(false);
|
||||
sale.setChannel("ADOPTION");
|
||||
saleRepository.save(sale);
|
||||
Sale savedSale = saleRepository.save(sale);
|
||||
emailService.sendPurchaseReceipt(savedSale);
|
||||
}
|
||||
|
||||
private void syncPetStatus(Pet pet, String adoptionStatus, Long adoptionId, User customer) {
|
||||
|
||||
@@ -3,11 +3,14 @@ package com.petshop.backend.service;
|
||||
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.Adoption;
|
||||
import com.petshop.backend.entity.Appointment;
|
||||
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.repository.AdoptionRepository;
|
||||
import com.petshop.backend.repository.AppointmentRepository;
|
||||
import com.petshop.backend.repository.PetRepository;
|
||||
import com.petshop.backend.repository.ServiceRepository;
|
||||
@@ -26,6 +29,7 @@ import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@@ -36,13 +40,17 @@ public class AppointmentService {
|
||||
private final PetRepository petRepository;
|
||||
private final StoreRepository storeRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final AdoptionRepository adoptionRepository;
|
||||
private final EmailService emailService;
|
||||
|
||||
public AppointmentService(AppointmentRepository appointmentRepository, ServiceRepository serviceRepository, PetRepository petRepository, StoreRepository storeRepository, UserRepository userRepository) {
|
||||
public AppointmentService(AppointmentRepository appointmentRepository, ServiceRepository serviceRepository, PetRepository petRepository, StoreRepository storeRepository, UserRepository userRepository, AdoptionRepository adoptionRepository, EmailService emailService) {
|
||||
this.appointmentRepository = appointmentRepository;
|
||||
this.serviceRepository = serviceRepository;
|
||||
this.petRepository = petRepository;
|
||||
this.storeRepository = storeRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.adoptionRepository = adoptionRepository;
|
||||
this.emailService = emailService;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@@ -118,6 +126,7 @@ public class AppointmentService {
|
||||
validateSpeciesServiceCompatibility(pet, service);
|
||||
|
||||
validateStoreAccess(store.getStoreId(), authenticatedUser);
|
||||
validatePetServiceCompatibility(pet, service);
|
||||
validateAvailability(employee, service, request.getAppointmentDate(), request.getAppointmentTime(), null);
|
||||
|
||||
Appointment appointment = new Appointment();
|
||||
@@ -131,6 +140,7 @@ public class AppointmentService {
|
||||
appointment.setPet(pet);
|
||||
|
||||
appointment = appointmentRepository.save(appointment);
|
||||
emailService.sendAppointmentConfirmation(appointment);
|
||||
return mapToResponse(appointment);
|
||||
}
|
||||
|
||||
@@ -156,6 +166,7 @@ public class AppointmentService {
|
||||
User employee = resolveAppointmentEmployee(request.getEmployeeId(), store.getStoreId());
|
||||
|
||||
validateStoreAccess(store.getStoreId(), authenticatedUser);
|
||||
validatePetServiceCompatibility(pet, service);
|
||||
validateAvailability(employee, service, request.getAppointmentDate(), request.getAppointmentTime(), id);
|
||||
|
||||
appointment.setCustomer(customer);
|
||||
@@ -168,6 +179,7 @@ public class AppointmentService {
|
||||
appointment.setEmployee(employee);
|
||||
|
||||
appointment = appointmentRepository.save(appointment);
|
||||
emailService.sendAppointmentConfirmation(appointment);
|
||||
return mapToResponse(appointment);
|
||||
}
|
||||
|
||||
@@ -243,7 +255,7 @@ public class AppointmentService {
|
||||
return availableSlots;
|
||||
}
|
||||
|
||||
//Update booked status to completed at every midnight
|
||||
//Update booked status to completed at every midnight, and send 24h reminders
|
||||
@Scheduled(cron = "0 0 0 * * ?")
|
||||
@Transactional
|
||||
public void updatePastAppointmentsStatus() {
|
||||
@@ -255,6 +267,20 @@ public class AppointmentService {
|
||||
appointment.setAppointmentStatus("COMPLETED");
|
||||
appointmentRepository.save(appointment);
|
||||
}
|
||||
|
||||
LocalDate tomorrow = currentDate.plusDays(1);
|
||||
|
||||
List<Appointment> tomorrowAppointments = appointmentRepository
|
||||
.findByAppointmentDateAndAppointmentStatusIgnoreCase(tomorrow, "Booked");
|
||||
for (Appointment appointment : tomorrowAppointments) {
|
||||
emailService.sendAppointmentReminder(appointment);
|
||||
}
|
||||
|
||||
List<Adoption> tomorrowAdoptions = adoptionRepository
|
||||
.findByAdoptionDateAndAdoptionStatusIgnoreCase(tomorrow, "Pending");
|
||||
for (Adoption adoption : tomorrowAdoptions) {
|
||||
emailService.sendAdoptionReminder(adoption);
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeFilter(String value) {
|
||||
@@ -265,6 +291,17 @@ public class AppointmentService {
|
||||
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();
|
||||
if (allowed == null || allowed.isEmpty()) return;
|
||||
boolean compatible = allowed.stream().anyMatch(s -> s.equalsIgnoreCase(pet.getPetSpecies()));
|
||||
if (!compatible) {
|
||||
throw new BusinessException(
|
||||
"Service \"" + service.getServiceName() + "\" is not available for " + pet.getPetSpecies());
|
||||
}
|
||||
}
|
||||
|
||||
private void validateAppointmentRequest(AppointmentRequest request) {
|
||||
if ("Booked".equalsIgnoreCase(request.getAppointmentStatus())) {
|
||||
LocalDateTime appointmentDateTime = LocalDateTime.of(request.getAppointmentDate(), request.getAppointmentTime());
|
||||
|
||||
@@ -22,7 +22,6 @@ import java.util.UUID;
|
||||
public class AvatarStorageService {
|
||||
|
||||
private static final String STORED_PREFIX = "/uploads/avatars/";
|
||||
private static final String OWNER_ENDPOINT = "/api/v1/auth/me/avatar/file";
|
||||
|
||||
@Value("${app.upload.base-dir:uploads}")
|
||||
private String uploadBaseDir;
|
||||
@@ -65,7 +64,7 @@ public class AvatarStorageService {
|
||||
}
|
||||
|
||||
public String toOwnerAvatarUrl(User user) {
|
||||
return hasAvatar(user) ? OWNER_ENDPOINT : null;
|
||||
return hasAvatar(user) ? "/api/v1/users/" + user.getId() + "/avatar/file" : null;
|
||||
}
|
||||
|
||||
public String toStoredAvatarUrl(String avatarFilenamePath) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.petshop.backend.entity.Conversation;
|
||||
import com.petshop.backend.entity.Message;
|
||||
import com.petshop.backend.entity.User;
|
||||
import com.petshop.backend.exception.ResourceNotFoundException;
|
||||
import com.petshop.backend.util.ContentFilter;
|
||||
import com.petshop.backend.repository.ConversationRepository;
|
||||
import com.petshop.backend.repository.MessageRepository;
|
||||
import com.petshop.backend.repository.UserRepository;
|
||||
@@ -31,17 +32,20 @@ public class ChatService {
|
||||
private final UserRepository userRepository;
|
||||
private final AvatarStorageService avatarStorageService;
|
||||
private final ChatAttachmentStorageService attachmentStorageService;
|
||||
private final EmailService emailService;
|
||||
|
||||
public ChatService(ConversationRepository conversationRepository,
|
||||
MessageRepository messageRepository,
|
||||
UserRepository userRepository,
|
||||
AvatarStorageService avatarStorageService,
|
||||
ChatAttachmentStorageService attachmentStorageService) {
|
||||
ChatAttachmentStorageService attachmentStorageService,
|
||||
EmailService emailService) {
|
||||
this.conversationRepository = conversationRepository;
|
||||
this.messageRepository = messageRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.avatarStorageService = avatarStorageService;
|
||||
this.attachmentStorageService = attachmentStorageService;
|
||||
this.emailService = emailService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -135,6 +139,8 @@ public class ChatService {
|
||||
}
|
||||
}
|
||||
|
||||
ContentFilter.validate(request.getContent());
|
||||
|
||||
Message message = new Message();
|
||||
message.setConversationId(conversationId);
|
||||
message.setSenderId(userId);
|
||||
@@ -261,13 +267,19 @@ public class ChatService {
|
||||
}
|
||||
|
||||
conversation.setStatus(Conversation.ConversationStatus.valueOf(request.getStatus()));
|
||||
conversation = conversationRepository.save(conversation);
|
||||
Conversation savedConversation = conversationRepository.save(conversation);
|
||||
|
||||
List<Message> messages = messageRepository.findByConversationIdOrderByTimestampAsc(conversationId);
|
||||
|
||||
if (Conversation.ConversationStatus.CLOSED.name().equals(request.getStatus())) {
|
||||
userRepository.findById(savedConversation.getCustomerId()).ifPresent(customer ->
|
||||
emailService.sendChatTranscript(savedConversation, messages, customer));
|
||||
}
|
||||
|
||||
Message last = messages.isEmpty() ? null : messages.get(messages.size() - 1);
|
||||
String lastMessage = last != null && last.getContent() != null ? last.getContent() : "";
|
||||
Long lastSenderId = last != null ? last.getSenderId() : null;
|
||||
return ConversationResponse.fromEntity(conversation, lastMessage, lastSenderId);
|
||||
return ConversationResponse.fromEntity(savedConversation, lastMessage, lastSenderId);
|
||||
}
|
||||
|
||||
public List<MessageResponse> getMessages(Long conversationId, Long userId, User.Role role) {
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
package com.petshop.backend.service;
|
||||
|
||||
import com.petshop.backend.entity.Adoption;
|
||||
import com.petshop.backend.entity.Appointment;
|
||||
import com.petshop.backend.entity.Conversation;
|
||||
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.resend.Resend;
|
||||
import com.resend.services.emails.model.CreateEmailOptions;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class EmailService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(EmailService.class);
|
||||
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("MMMM d, yyyy");
|
||||
private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern("h:mm a");
|
||||
|
||||
private final Resend resend;
|
||||
private final String from;
|
||||
private final String frontendUrl;
|
||||
private final ActivityLogService activityLogService;
|
||||
|
||||
public EmailService(
|
||||
@Value("${resend.api-key}") String apiKey,
|
||||
@Value("${resend.from}") String from,
|
||||
@Value("${app.frontend-url}") String frontendUrl,
|
||||
ActivityLogService activityLogService) {
|
||||
this.resend = new Resend(apiKey);
|
||||
this.from = from;
|
||||
this.frontendUrl = frontendUrl;
|
||||
this.activityLogService = activityLogService;
|
||||
}
|
||||
|
||||
public void sendWelcome(User user) {
|
||||
if (user.getEmail() == null || user.getEmail().isBlank()) return;
|
||||
String subject = "Welcome to PetShop!";
|
||||
String html = """
|
||||
<div style="font-family:sans-serif;max-width:600px;margin:auto">
|
||||
<h2>Welcome to PetShop, %s!</h2>
|
||||
<p>Your account has been created successfully.</p>
|
||||
<p>You can now log in and start exploring our pets, products, and services.</p>
|
||||
<p>Thank you for joining us!</p>
|
||||
</div>""".formatted(firstName(user));
|
||||
send(user.getId(), user.getEmail(), subject, html);
|
||||
}
|
||||
|
||||
public void sendPasswordResetLink(User user, String rawToken) {
|
||||
if (user.getEmail() == null || user.getEmail().isBlank()) return;
|
||||
String link = frontendUrl + "/reset-password?token=" + rawToken;
|
||||
String subject = "Reset your PetShop password";
|
||||
String html = """
|
||||
<div style="font-family:sans-serif;max-width:600px;margin:auto">
|
||||
<h2>Password Reset Request</h2>
|
||||
<p>Hi %s,</p>
|
||||
<p>We received a request to reset your password. Click the button below to proceed.</p>
|
||||
<p><a href="%s" style="background:#2563eb;color:#fff;padding:10px 20px;border-radius:4px;text-decoration:none;display:inline-block">Reset Password</a></p>
|
||||
<p>This link expires in <strong>30 minutes</strong>. If you did not request a reset, you can safely ignore this email.</p>
|
||||
</div>""".formatted(firstName(user), link);
|
||||
send(user.getId(), user.getEmail(), subject, html);
|
||||
}
|
||||
|
||||
public void sendPurchaseReceipt(Sale sale) {
|
||||
User customer = sale.getCustomer();
|
||||
if (customer == null || customer.getEmail() == null || customer.getEmail().isBlank()) return;
|
||||
String subject = "Your PetShop receipt";
|
||||
String html = buildReceiptHtml(sale);
|
||||
send(customer.getId(), customer.getEmail(), subject, html);
|
||||
}
|
||||
|
||||
public void sendAdoptionConfirmation(Adoption adoption) {
|
||||
User customer = adoption.getCustomer();
|
||||
if (customer != null && customer.getEmail() != null && !customer.getEmail().isBlank()) {
|
||||
String subject = "Your adoption — " + adoption.getPet().getPetName();
|
||||
send(customer.getId(), customer.getEmail(), subject, buildAdoptionHtml(adoption, customer));
|
||||
}
|
||||
User employee = adoption.getEmployee();
|
||||
if (employee != null && employee.getEmail() != null && !employee.getEmail().isBlank()) {
|
||||
String subject = "Adoption assigned — " + adoption.getPet().getPetName();
|
||||
send(employee.getId(), employee.getEmail(), subject, buildAdoptionHtml(adoption, customer));
|
||||
}
|
||||
}
|
||||
|
||||
public void sendAdoptionReminder(Adoption adoption) {
|
||||
User customer = adoption.getCustomer();
|
||||
if (customer != null && customer.getEmail() != null && !customer.getEmail().isBlank()) {
|
||||
String subject = "Reminder: adoption tomorrow — " + adoption.getPet().getPetName();
|
||||
send(customer.getId(), customer.getEmail(), subject, buildAdoptionReminderHtml(adoption));
|
||||
}
|
||||
User employee = adoption.getEmployee();
|
||||
if (employee != null && employee.getEmail() != null && !employee.getEmail().isBlank()) {
|
||||
String subject = "Reminder: adoption tomorrow — " + adoption.getPet().getPetName();
|
||||
send(employee.getId(), employee.getEmail(), subject, buildAdoptionReminderHtml(adoption));
|
||||
}
|
||||
}
|
||||
|
||||
public void sendAppointmentConfirmation(Appointment appointment) {
|
||||
User customer = appointment.getCustomer();
|
||||
if (customer != null && customer.getEmail() != null && !customer.getEmail().isBlank()) {
|
||||
String subject = "Appointment confirmed — " + appointment.getService().getServiceName();
|
||||
send(customer.getId(), customer.getEmail(), subject, buildAppointmentHtml(appointment));
|
||||
}
|
||||
User employee = appointment.getEmployee();
|
||||
if (employee != null && employee.getEmail() != null && !employee.getEmail().isBlank()) {
|
||||
String subject = "Appointment assigned — " + appointment.getService().getServiceName();
|
||||
send(employee.getId(), employee.getEmail(), subject, buildAppointmentHtml(appointment));
|
||||
}
|
||||
}
|
||||
|
||||
public void sendAppointmentReminder(Appointment appointment) {
|
||||
User customer = appointment.getCustomer();
|
||||
if (customer != null && customer.getEmail() != null && !customer.getEmail().isBlank()) {
|
||||
String subject = "Reminder: appointment tomorrow — " + appointment.getService().getServiceName();
|
||||
send(customer.getId(), customer.getEmail(), subject, buildAppointmentReminderHtml(appointment));
|
||||
}
|
||||
User employee = appointment.getEmployee();
|
||||
if (employee != null && employee.getEmail() != null && !employee.getEmail().isBlank()) {
|
||||
String subject = "Reminder: appointment tomorrow — " + appointment.getService().getServiceName();
|
||||
send(employee.getId(), employee.getEmail(), subject, buildAppointmentReminderHtml(appointment));
|
||||
}
|
||||
}
|
||||
|
||||
public void sendChatTranscript(Conversation conversation, List<Message> messages, User customer) {
|
||||
if (customer == null || customer.getEmail() == null || customer.getEmail().isBlank()) return;
|
||||
String subject = "Your PetShop support transcript";
|
||||
String html = buildTranscriptHtml(conversation, messages, customer);
|
||||
send(customer.getId(), customer.getEmail(), subject, html);
|
||||
}
|
||||
|
||||
private void send(Long recipientUserId, String to, String subject, String html) {
|
||||
try {
|
||||
CreateEmailOptions options = CreateEmailOptions.builder()
|
||||
.from(from)
|
||||
.to(List.of(to))
|
||||
.subject(subject)
|
||||
.html(html)
|
||||
.build();
|
||||
resend.emails().send(options);
|
||||
activityLogService.record(recipientUserId, "Email sent: " + subject + " → " + to);
|
||||
} catch (Exception ex) {
|
||||
log.error("Failed to send email '{}' to {}: {}", subject, to, ex.getMessage());
|
||||
activityLogService.record(recipientUserId, "Email failed: " + subject + " → " + to);
|
||||
}
|
||||
}
|
||||
|
||||
private String buildReceiptHtml(Sale sale) {
|
||||
StringBuilder rows = new StringBuilder();
|
||||
List<SaleItem> items = sale.getItems();
|
||||
if (items != null) {
|
||||
for (SaleItem item : items) {
|
||||
String name = item.getProduct() != null ? item.getProduct().getProdName() : "—";
|
||||
rows.append("<tr><td style='padding:6px 8px'>").append(esc(name)).append("</td>")
|
||||
.append("<td style='padding:6px 8px;text-align:center'>").append(item.getQuantity()).append("</td>")
|
||||
.append("<td style='padding:6px 8px;text-align:right'>$").append(fmt(item.getUnitPrice())).append("</td>")
|
||||
.append("<td style='padding:6px 8px;text-align:right'>$").append(fmt(item.getUnitPrice().multiply(BigDecimal.valueOf(item.getQuantity())))).append("</td></tr>");
|
||||
}
|
||||
}
|
||||
String date = sale.getSaleDate() != null ? sale.getSaleDate().format(DateTimeFormatter.ofPattern("MMMM d, yyyy h:mm a")) : "—";
|
||||
return """
|
||||
<div style="font-family:sans-serif;max-width:600px;margin:auto">
|
||||
<h2>Thank you for your purchase!</h2>
|
||||
<p><strong>Date:</strong> %s</p>
|
||||
<p><strong>Payment method:</strong> %s</p>
|
||||
<table style="width:100%%;border-collapse:collapse;margin:16px 0">
|
||||
<thead><tr style="background:#f3f4f6">
|
||||
<th style="padding:6px 8px;text-align:left">Item</th>
|
||||
<th style="padding:6px 8px;text-align:center">Qty</th>
|
||||
<th style="padding:6px 8px;text-align:right">Unit price</th>
|
||||
<th style="padding:6px 8px;text-align:right">Total</th>
|
||||
</tr></thead>
|
||||
<tbody>%s</tbody>
|
||||
</table>
|
||||
<p><strong>Subtotal:</strong> $%s</p>
|
||||
%s
|
||||
%s
|
||||
%s
|
||||
<p style="font-size:1.1em"><strong>Total:</strong> $%s</p>
|
||||
%s
|
||||
</div>""".formatted(
|
||||
date,
|
||||
esc(sale.getPaymentMethod()),
|
||||
rows.toString(),
|
||||
fmt(sale.getSubtotalAmount()),
|
||||
discountLine("Coupon discount", sale.getCouponDiscountAmount()),
|
||||
discountLine("Employee discount", sale.getEmployeeDiscountAmount()),
|
||||
discountLine("Loyalty discount", sale.getLoyaltyDiscountAmount()),
|
||||
fmt(sale.getTotalAmount()),
|
||||
sale.getPointsEarned() != null && sale.getPointsEarned() > 0
|
||||
? "<p>You earned <strong>" + sale.getPointsEarned() + " loyalty points</strong> on this order.</p>"
|
||||
: ""
|
||||
);
|
||||
}
|
||||
|
||||
private String buildAdoptionHtml(Adoption adoption, User customer) {
|
||||
String petName = adoption.getPet() != null ? adoption.getPet().getPetName() : "—";
|
||||
String storeName = adoption.getSourceStore() != null ? adoption.getSourceStore().getStoreName() : "—";
|
||||
String date = adoption.getAdoptionDate() != null ? adoption.getAdoptionDate().format(DATE_FMT) : "—";
|
||||
String customerName = customer != null ? firstName(customer) : "—";
|
||||
return """
|
||||
<div style="font-family:sans-serif;max-width:600px;margin:auto">
|
||||
<h2>Adoption update — %s</h2>
|
||||
<p><strong>Customer:</strong> %s</p>
|
||||
<p><strong>Pet:</strong> %s</p>
|
||||
<p><strong>Date:</strong> %s</p>
|
||||
<p><strong>Store:</strong> %s</p>
|
||||
<p><strong>Status:</strong> %s</p>
|
||||
</div>""".formatted(esc(petName), esc(customerName), esc(petName), date, esc(storeName), esc(adoption.getAdoptionStatus()));
|
||||
}
|
||||
|
||||
private String buildAdoptionReminderHtml(Adoption adoption) {
|
||||
String petName = adoption.getPet() != null ? adoption.getPet().getPetName() : "—";
|
||||
String storeName = adoption.getSourceStore() != null ? adoption.getSourceStore().getStoreName() : "—";
|
||||
String date = adoption.getAdoptionDate() != null ? adoption.getAdoptionDate().format(DATE_FMT) : "—";
|
||||
return """
|
||||
<div style="font-family:sans-serif;max-width:600px;margin:auto">
|
||||
<h2>Reminder: adoption tomorrow</h2>
|
||||
<p>This is a reminder that the adoption of <strong>%s</strong> is scheduled for <strong>%s</strong> at <strong>%s</strong>.</p>
|
||||
</div>""".formatted(esc(petName), date, esc(storeName));
|
||||
}
|
||||
|
||||
private String buildAppointmentHtml(Appointment appointment) {
|
||||
String service = appointment.getService() != null ? appointment.getService().getServiceName() : "—";
|
||||
String store = appointment.getStore() != null ? appointment.getStore().getStoreName() : "—";
|
||||
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() : "—";
|
||||
String pet = appointment.getPet() != null ? appointment.getPet().getPetName() : null;
|
||||
return """
|
||||
<div style="font-family:sans-serif;max-width:600px;margin:auto">
|
||||
<h2>Appointment confirmed</h2>
|
||||
<p><strong>Service:</strong> %s</p>
|
||||
<p><strong>Date:</strong> %s</p>
|
||||
<p><strong>Time:</strong> %s</p>
|
||||
<p><strong>Location:</strong> %s</p>
|
||||
<p><strong>Staff:</strong> %s</p>
|
||||
%s
|
||||
</div>""".formatted(esc(service), date, time, esc(store), esc(employee),
|
||||
pet != null ? "<p><strong>Pet:</strong> " + esc(pet) + "</p>" : "");
|
||||
}
|
||||
|
||||
private String buildAppointmentReminderHtml(Appointment appointment) {
|
||||
String service = appointment.getService() != null ? appointment.getService().getServiceName() : "—";
|
||||
String store = appointment.getStore() != null ? appointment.getStore().getStoreName() : "—";
|
||||
String time = appointment.getAppointmentTime() != null ? appointment.getAppointmentTime().format(TIME_FMT) : "—";
|
||||
return """
|
||||
<div style="font-family:sans-serif;max-width:600px;margin:auto">
|
||||
<h2>Reminder: appointment tomorrow</h2>
|
||||
<p>Your <strong>%s</strong> appointment is scheduled for tomorrow at <strong>%s</strong> at <strong>%s</strong>.</p>
|
||||
</div>""".formatted(esc(service), time, esc(store));
|
||||
}
|
||||
|
||||
private String buildTranscriptHtml(Conversation conversation, List<Message> messages, User customer) {
|
||||
StringBuilder rows = new StringBuilder();
|
||||
for (Message msg : messages) {
|
||||
String sender = msg.getSenderId() != null && msg.getSenderId().equals(customer.getId())
|
||||
? firstName(customer) : "Support";
|
||||
String ts = msg.getTimestamp() != null
|
||||
? msg.getTimestamp().format(DateTimeFormatter.ofPattern("MMM d, h:mm a")) : "";
|
||||
String content = msg.getContent() != null ? esc(msg.getContent()) : "";
|
||||
rows.append("<tr><td style='padding:6px 8px;white-space:nowrap'>").append(ts).append("</td>")
|
||||
.append("<td style='padding:6px 8px;white-space:nowrap'>").append(esc(sender)).append("</td>")
|
||||
.append("<td style='padding:6px 8px'>").append(content).append("</td></tr>");
|
||||
}
|
||||
return """
|
||||
<div style="font-family:sans-serif;max-width:700px;margin:auto">
|
||||
<h2>Support chat transcript</h2>
|
||||
<p>Hi %s, here is the transcript of your support conversation.</p>
|
||||
<table style="width:100%%;border-collapse:collapse;margin:16px 0;font-size:0.9em">
|
||||
<thead><tr style="background:#f3f4f6">
|
||||
<th style="padding:6px 8px;text-align:left">Time</th>
|
||||
<th style="padding:6px 8px;text-align:left">Sender</th>
|
||||
<th style="padding:6px 8px;text-align:left">Message</th>
|
||||
</tr></thead>
|
||||
<tbody>%s</tbody>
|
||||
</table>
|
||||
</div>""".formatted(firstName(customer), rows.toString());
|
||||
}
|
||||
|
||||
private String firstName(User user) {
|
||||
if (user.getFirstName() != null && !user.getFirstName().isBlank()) return esc(user.getFirstName());
|
||||
if (user.getFullName() != null && !user.getFullName().isBlank()) return esc(user.getFullName().split("\\s+")[0]);
|
||||
return esc(user.getUsername());
|
||||
}
|
||||
|
||||
private String fmt(BigDecimal value) {
|
||||
return value != null ? String.format("%.2f", value) : "0.00";
|
||||
}
|
||||
|
||||
private String discountLine(String label, BigDecimal value) {
|
||||
if (value == null || value.compareTo(BigDecimal.ZERO) == 0) return "";
|
||||
return "<p><strong>" + label + ":</strong> -$" + fmt(value) + "</p>";
|
||||
}
|
||||
|
||||
private String esc(String s) {
|
||||
if (s == null) return "";
|
||||
return s.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """);
|
||||
}
|
||||
}
|
||||
@@ -28,14 +28,17 @@ public class PasswordResetService {
|
||||
private final PasswordResetTokenRepository passwordResetTokenRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final EmailService emailService;
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
public PasswordResetService(PasswordResetTokenRepository passwordResetTokenRepository,
|
||||
UserRepository userRepository,
|
||||
PasswordEncoder passwordEncoder) {
|
||||
PasswordEncoder passwordEncoder,
|
||||
EmailService emailService) {
|
||||
this.passwordResetTokenRepository = passwordResetTokenRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.emailService = emailService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -66,9 +69,11 @@ public class PasswordResetService {
|
||||
resetToken.setExpiresAt(now.plusMinutes(RESET_TOKEN_MINUTES));
|
||||
passwordResetTokenRepository.save(resetToken);
|
||||
|
||||
emailService.sendPasswordResetLink(managedUser, rawToken);
|
||||
|
||||
return new ForgotPasswordResponse(
|
||||
"If an account matches that username or email, a reset token has been generated.",
|
||||
rawToken
|
||||
"If an account matches that username or email, a reset link has been sent.",
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.petshop.backend.dto.product.ProductRequest;
|
||||
import com.petshop.backend.dto.product.ProductResponse;
|
||||
import com.petshop.backend.entity.Category;
|
||||
import com.petshop.backend.entity.Product;
|
||||
import com.petshop.backend.exception.BusinessException;
|
||||
import com.petshop.backend.exception.ResourceNotFoundException;
|
||||
import com.petshop.backend.repository.CategoryRepository;
|
||||
import com.petshop.backend.repository.ProductRepository;
|
||||
@@ -45,6 +46,10 @@ public class ProductService {
|
||||
|
||||
@Transactional
|
||||
public ProductResponse createProduct(ProductRequest request) {
|
||||
if (productRepository.existsByProdNameIgnoreCase(request.getProdName())) {
|
||||
throw new BusinessException("A product with this name already exists");
|
||||
}
|
||||
|
||||
Category category = categoryRepository.findById(request.getCategoryId())
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Category not found with id: " + request.getCategoryId()));
|
||||
|
||||
@@ -63,6 +68,10 @@ public class ProductService {
|
||||
Product product = productRepository.findById(id)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Product not found with id: " + id));
|
||||
|
||||
if (productRepository.existsByProdNameIgnoreCaseAndProdIdNot(request.getProdName(), id)) {
|
||||
throw new BusinessException("A product with this name already exists");
|
||||
}
|
||||
|
||||
Category category = categoryRepository.findById(request.getCategoryId())
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Category not found with id: " + request.getCategoryId()));
|
||||
|
||||
|
||||
@@ -47,7 +47,6 @@ public class PurchaseOrderService {
|
||||
store != null ? store.getStoreId() : null,
|
||||
store != null ? store.getStoreName() : null,
|
||||
purchaseOrder.getOrderDate(),
|
||||
purchaseOrder.getStatus(),
|
||||
purchaseOrder.getCreatedAt(),
|
||||
purchaseOrder.getUpdatedAt()
|
||||
);
|
||||
|
||||
@@ -31,8 +31,9 @@ public class SaleService {
|
||||
private final UserRepository userRepository;
|
||||
private final CouponRepository couponRepository;
|
||||
private final CartRepository cartRepository;
|
||||
private final EmailService emailService;
|
||||
|
||||
public SaleService(SaleRepository saleRepository, ProductRepository productRepository, StoreRepository storeRepository, InventoryRepository inventoryRepository, UserRepository userRepository, CouponRepository couponRepository, CartRepository cartRepository) {
|
||||
public SaleService(SaleRepository saleRepository, ProductRepository productRepository, StoreRepository storeRepository, InventoryRepository inventoryRepository, UserRepository userRepository, CouponRepository couponRepository, CartRepository cartRepository, EmailService emailService) {
|
||||
this.saleRepository = saleRepository;
|
||||
this.productRepository = productRepository;
|
||||
this.storeRepository = storeRepository;
|
||||
@@ -40,11 +41,12 @@ public class SaleService {
|
||||
this.userRepository = userRepository;
|
||||
this.couponRepository = couponRepository;
|
||||
this.cartRepository = cartRepository;
|
||||
this.emailService = emailService;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Page<SaleResponse> getAllSales(String query, String paymentMethod, Long storeId, Boolean isRefund, Pageable pageable) {
|
||||
Page<Sale> sales = saleRepository.searchSales(normalizeFilter(query), normalizeFilter(paymentMethod), storeId, isRefund, pageable);
|
||||
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);
|
||||
return sales.map(this::mapToResponse);
|
||||
}
|
||||
|
||||
@@ -162,10 +164,41 @@ public class SaleService {
|
||||
}
|
||||
subtotalAmount = subtotalAmount.negate();
|
||||
sale.setSubtotalAmount(subtotalAmount);
|
||||
sale.setTotalAmount(subtotalAmount);
|
||||
sale.setCouponDiscountAmount(BigDecimal.ZERO);
|
||||
|
||||
Sale originalSale = sale.getOriginalSale();
|
||||
BigDecimal originalSubtotal = originalSale.getSubtotalAmount();
|
||||
BigDecimal loyaltyDiscountRefunded = BigDecimal.ZERO;
|
||||
BigDecimal couponDiscountRefunded = BigDecimal.ZERO;
|
||||
BigDecimal refundTotal;
|
||||
|
||||
if (originalSubtotal != null && originalSubtotal.compareTo(BigDecimal.ZERO) > 0) {
|
||||
BigDecimal ratio = subtotalAmount.divide(originalSubtotal, 10, RoundingMode.HALF_UP);
|
||||
refundTotal = originalSale.getTotalAmount().abs().multiply(ratio).negate().setScale(2, RoundingMode.HALF_UP);
|
||||
if (originalSale.getLoyaltyDiscountAmount() != null) {
|
||||
loyaltyDiscountRefunded = originalSale.getLoyaltyDiscountAmount().multiply(ratio).setScale(2, RoundingMode.HALF_UP);
|
||||
}
|
||||
if (originalSale.getCouponDiscountAmount() != null) {
|
||||
couponDiscountRefunded = originalSale.getCouponDiscountAmount().multiply(ratio).setScale(2, RoundingMode.HALF_UP);
|
||||
}
|
||||
User refundCustomer = originalSale.getCustomer();
|
||||
if (refundCustomer != null) {
|
||||
sale.setCustomer(refundCustomer);
|
||||
int pointsToRestore = toPointsUsed(loyaltyDiscountRefunded);
|
||||
int pointsEarnedToReverse = originalSale.getPointsEarned() != null
|
||||
? ratio.multiply(BigDecimal.valueOf(originalSale.getPointsEarned())).setScale(0, RoundingMode.FLOOR).intValue()
|
||||
: 0;
|
||||
int currentPoints = refundCustomer.getLoyaltyPoints() != null ? refundCustomer.getLoyaltyPoints() : 0;
|
||||
refundCustomer.setLoyaltyPoints(Math.max(0, currentPoints + pointsToRestore - pointsEarnedToReverse));
|
||||
userRepository.save(refundCustomer);
|
||||
}
|
||||
} else {
|
||||
refundTotal = subtotalAmount.negate();
|
||||
}
|
||||
|
||||
sale.setTotalAmount(refundTotal);
|
||||
sale.setCouponDiscountAmount(couponDiscountRefunded);
|
||||
sale.setEmployeeDiscountAmount(BigDecimal.ZERO);
|
||||
sale.setLoyaltyDiscountAmount(BigDecimal.ZERO);
|
||||
sale.setLoyaltyDiscountAmount(loyaltyDiscountRefunded);
|
||||
sale.setPointsEarned(0);
|
||||
} else {
|
||||
if (request.getItems() == null || request.getItems().isEmpty()) {
|
||||
@@ -206,18 +239,29 @@ public class SaleService {
|
||||
BigDecimal employeeDiscount = calculateEmployeeDiscount(customer, subtotalAmount.subtract(couponDiscount));
|
||||
sale.setEmployeeDiscountAmount(employeeDiscount);
|
||||
|
||||
boolean useLoyaltyPoints = sale.getCart() != null && Boolean.TRUE.equals(sale.getCart().getPointsApplied());
|
||||
BigDecimal loyaltyDiscount = calculateLoyaltyDiscount(customer, subtotalAmount.subtract(couponDiscount).subtract(employeeDiscount), useLoyaltyPoints);
|
||||
BigDecimal remainingAfterDiscounts = subtotalAmount.subtract(couponDiscount).subtract(employeeDiscount);
|
||||
BigDecimal loyaltyDiscount;
|
||||
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)
|
||||
.min(remainingAfterDiscounts.max(BigDecimal.ZERO))
|
||||
.setScale(2, RoundingMode.HALF_UP);
|
||||
pointsDeducted = request.getPointsUsed();
|
||||
} else {
|
||||
boolean useLoyaltyPoints = sale.getCart() != null && Boolean.TRUE.equals(sale.getCart().getPointsApplied());
|
||||
loyaltyDiscount = calculateLoyaltyDiscount(customer, remainingAfterDiscounts, useLoyaltyPoints);
|
||||
pointsDeducted = toPointsUsed(loyaltyDiscount);
|
||||
}
|
||||
sale.setLoyaltyDiscountAmount(loyaltyDiscount);
|
||||
|
||||
BigDecimal finalTotal = subtotalAmount.subtract(couponDiscount).subtract(employeeDiscount).subtract(loyaltyDiscount);
|
||||
sale.setTotalAmount(finalTotal.max(BigDecimal.ZERO));
|
||||
|
||||
int pointsUsed = toPointsUsed(loyaltyDiscount);
|
||||
sale.setPointsEarned(sale.getTotalAmount().setScale(0, RoundingMode.FLOOR).intValue());
|
||||
if (customer != null) {
|
||||
int currentPoints = customer.getLoyaltyPoints() != null ? customer.getLoyaltyPoints() : 0;
|
||||
int updatedPoints = currentPoints - pointsUsed + sale.getPointsEarned();
|
||||
int updatedPoints = currentPoints - pointsDeducted + sale.getPointsEarned();
|
||||
customer.setLoyaltyPoints(Math.max(updatedPoints, 0));
|
||||
userRepository.save(customer);
|
||||
}
|
||||
@@ -226,6 +270,11 @@ public class SaleService {
|
||||
sale.setItems(saleItems);
|
||||
|
||||
Sale savedSale = saleRepository.save(sale);
|
||||
|
||||
if (!Boolean.TRUE.equals(savedSale.getIsRefund()) && savedSale.getCustomer() != null) {
|
||||
emailService.sendPurchaseReceipt(savedSale);
|
||||
}
|
||||
|
||||
return mapToResponse(savedSale);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,14 +20,10 @@ public class ServiceService {
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Page<ServiceResponse> getAllServices(String query, Pageable pageable) {
|
||||
Page<com.petshop.backend.entity.Service> services;
|
||||
if (query != null && !query.trim().isEmpty()) {
|
||||
services = serviceRepository.searchServices(query, pageable);
|
||||
} else {
|
||||
services = serviceRepository.findAll(pageable);
|
||||
}
|
||||
return services.map(this::mapToResponse);
|
||||
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);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
|
||||
@@ -3,21 +3,27 @@ package com.petshop.backend.service;
|
||||
import com.petshop.backend.dto.common.BulkDeleteRequest;
|
||||
import com.petshop.backend.dto.user.UserRequest;
|
||||
import com.petshop.backend.dto.user.UserResponse;
|
||||
import com.petshop.backend.repository.ActivityLogRepository;
|
||||
import com.petshop.backend.entity.StoreLocation;
|
||||
import com.petshop.backend.entity.User;
|
||||
import com.petshop.backend.repository.ActivityLogRepository;
|
||||
import com.petshop.backend.exception.ResourceNotFoundException;
|
||||
import com.petshop.backend.repository.StoreRepository;
|
||||
import com.petshop.backend.repository.UserRepository;
|
||||
import com.petshop.backend.util.AuthenticationHelper;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.springframework.http.HttpStatus.BAD_REQUEST;
|
||||
import static org.springframework.http.HttpStatus.CONFLICT;
|
||||
@@ -54,13 +60,26 @@ public class UserService {
|
||||
}
|
||||
|
||||
public UserResponse getUserById(Long id) {
|
||||
return getUserById(id, null);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public UserResponse getUserById(Long id, User.Role requiredRole) {
|
||||
User user = userRepository.findById(id)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("User not found with id: " + id));
|
||||
requireRoleOrNotFound(user, requiredRole, id);
|
||||
return mapToResponse(user);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public UserResponse createUser(UserRequest request) {
|
||||
return createUser(request, null);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public UserResponse createUser(UserRequest request, User.Role requiredRole) {
|
||||
requireRequestedRole(request.getRole(), requiredRole);
|
||||
|
||||
User user = new User();
|
||||
user.setUsername(trimToNull(request.getUsername()));
|
||||
if (request.getPassword() != null && !request.getPassword().trim().isEmpty()) {
|
||||
@@ -88,9 +107,18 @@ public class UserService {
|
||||
|
||||
@Transactional
|
||||
public UserResponse updateUser(Long id, UserRequest request) {
|
||||
return updateUser(id, request, null);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public UserResponse updateUser(Long id, UserRequest request, User.Role requiredRole) {
|
||||
User user = userRepository.findById(id)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("User not found with id: " + id));
|
||||
|
||||
requireRoleOrNotFound(user, requiredRole, id);
|
||||
requireRequestedRole(request.getRole(), requiredRole);
|
||||
requireAdminMutationAllowed(user, request.getRole());
|
||||
|
||||
boolean invalidateToken =
|
||||
!Objects.equals(user.getUsername(), request.getUsername())
|
||||
|| user.getRole() != request.getRole()
|
||||
@@ -127,9 +155,17 @@ public class UserService {
|
||||
|
||||
@Transactional
|
||||
public void deleteUser(Long id) {
|
||||
if (!userRepository.existsById(id)) {
|
||||
throw new ResourceNotFoundException("User not found with id: " + id);
|
||||
}
|
||||
deleteUser(id, null);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteUser(Long id, User.Role requiredRole) {
|
||||
User user = userRepository.findById(id)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("User not found with id: " + id));
|
||||
|
||||
requireRoleOrNotFound(user, requiredRole, id);
|
||||
requireAdminDeletionAllowed(user);
|
||||
|
||||
if (activityLogRepository.existsByUser_Id(id)) {
|
||||
throw new ResponseStatusException(CONFLICT, "User cannot be deleted because activity logs exist");
|
||||
}
|
||||
@@ -138,7 +174,27 @@ public class UserService {
|
||||
|
||||
@Transactional
|
||||
public void bulkDeleteUsers(BulkDeleteRequest request) {
|
||||
if (request.getIds() != null && request.getIds().stream().anyMatch(activityLogRepository::existsByUser_Id)) {
|
||||
bulkDeleteUsers(request, null);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void bulkDeleteUsers(BulkDeleteRequest request, User.Role requiredRole) {
|
||||
if (request.getIds() == null || request.getIds().isEmpty()) {
|
||||
throw new ResponseStatusException(BAD_REQUEST, "IDs list cannot be empty");
|
||||
}
|
||||
|
||||
Set<Long> requestedIds = new HashSet<>(request.getIds());
|
||||
ArrayList<User> usersToDelete = new ArrayList<>();
|
||||
userRepository.findAllById(requestedIds).forEach(usersToDelete::add);
|
||||
|
||||
if (usersToDelete.size() != requestedIds.size()) {
|
||||
throw new ResourceNotFoundException("One or more users not found for bulk delete");
|
||||
}
|
||||
|
||||
requireRoleOrNotFound(usersToDelete, requiredRole);
|
||||
requireAdminDeletionAllowed(usersToDelete);
|
||||
|
||||
if (request.getIds().stream().anyMatch(activityLogRepository::existsByUser_Id)) {
|
||||
throw new ResponseStatusException(CONFLICT, "One or more users cannot be deleted because activity logs exist");
|
||||
}
|
||||
userRepository.deleteAllById(request.getIds());
|
||||
@@ -169,6 +225,51 @@ public class UserService {
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Store not found with id: " + storeId));
|
||||
}
|
||||
|
||||
private void requireRoleOrNotFound(User user, User.Role requiredRole, Long id) {
|
||||
if (requiredRole != null && user.getRole() != requiredRole) {
|
||||
throw new ResourceNotFoundException("User not found with id: " + id);
|
||||
}
|
||||
}
|
||||
|
||||
private void requireRoleOrNotFound(Collection<User> users, User.Role requiredRole) {
|
||||
if (requiredRole == null) {
|
||||
return;
|
||||
}
|
||||
for (User user : users) {
|
||||
if (user.getRole() != requiredRole) {
|
||||
throw new ResourceNotFoundException("User not found with id: " + user.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void requireRequestedRole(User.Role requestedRole, User.Role requiredRole) {
|
||||
if (requiredRole != null && requestedRole != requiredRole) {
|
||||
throw new AccessDeniedException("Target user must have role " + requiredRole.name());
|
||||
}
|
||||
}
|
||||
|
||||
private void requireAdminMutationAllowed(User target, User.Role requestedRole) {
|
||||
User actor = AuthenticationHelper.getAuthenticatedUser(userRepository);
|
||||
if ((target.getRole() == User.Role.ADMIN && !actor.getId().equals(target.getId()))
|
||||
|| (requestedRole == User.Role.ADMIN && !actor.getId().equals(target.getId()))) {
|
||||
throw new AccessDeniedException("Admins cannot modify other admin accounts");
|
||||
}
|
||||
}
|
||||
|
||||
private void requireAdminDeletionAllowed(User target) {
|
||||
if (target.getRole() == User.Role.ADMIN) {
|
||||
throw new AccessDeniedException("Admins cannot delete admin accounts");
|
||||
}
|
||||
}
|
||||
|
||||
private void requireAdminDeletionAllowed(Collection<User> targets) {
|
||||
for (User target : targets) {
|
||||
if (target.getRole() == User.Role.ADMIN) {
|
||||
throw new AccessDeniedException("Admins cannot delete admin accounts");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void validateUniquePhone(String phone, Long currentUserId) {
|
||||
if (phone == null || phone.isBlank()) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.petshop.backend.util;
|
||||
|
||||
import com.petshop.backend.exception.BusinessException;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class ContentFilter {
|
||||
|
||||
private static final Pattern SCRIPT_PATTERN = Pattern.compile(
|
||||
"<script|javascript:|on\\w+\\s*=", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
private static final Set<String> PROFANITY = Set.of(
|
||||
"profanityOne", "profanityTwo", "profanityThree"
|
||||
);
|
||||
|
||||
public static void validate(String input) {
|
||||
if (input == null || input.isBlank()) return;
|
||||
if (SCRIPT_PATTERN.matcher(input).find()) {
|
||||
throw new BusinessException("Message contains prohibited content");
|
||||
}
|
||||
String lower = input.toLowerCase(Locale.ROOT);
|
||||
for (String word : PROFANITY) {
|
||||
if (lower.contains(word)) {
|
||||
throw new BusinessException("Message contains prohibited language");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,11 @@ springdoc:
|
||||
app:
|
||||
upload:
|
||||
base-dir: ${UPLOAD_BASE_DIR:uploads}
|
||||
frontend-url: ${FRONTEND_URL:http://localhost:3000}
|
||||
|
||||
resend:
|
||||
api-key: ${RESEND_API_KEY:}
|
||||
from: ${RESEND_FROM:PetShop <onboarding@resend.dev>}
|
||||
|
||||
jwt:
|
||||
secret: ${JWT_SECRET}
|
||||
@@ -60,9 +65,11 @@ stripe:
|
||||
|
||||
openrouter:
|
||||
api-key: ${OPENROUTER_API_KEY:}
|
||||
model: ${OPENROUTER_MODEL:openai/gpt-oss-120b:free}
|
||||
model: ${OPENROUTER_MODEL:openrouter/free}
|
||||
|
||||
logging:
|
||||
file:
|
||||
name: ${LOG_FILE:logs/app.log}
|
||||
level:
|
||||
com.petshop: ${LOG_LEVEL:INFO}
|
||||
org.springframework.security: ${LOG_LEVEL_SECURITY:WARN}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE appointment MODIFY petId BIGINT NULL;
|
||||
ALTER TABLE appointment DROP FOREIGN KEY fk_appointment_pet;
|
||||
ALTER TABLE appointment ADD CONSTRAINT fk_appointment_pet FOREIGN KEY (petId) REFERENCES pet(petId) ON DELETE SET NULL;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE purchaseOrder DROP COLUMN status;
|
||||
Reference in New Issue
Block a user