merge main

This commit is contained in:
2026-04-14 23:29:46 -06:00
127 changed files with 5010 additions and 859 deletions

View File

@@ -1,2 +1,4 @@
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
OPENROUTER_API_KEY=sk-or-v1-...
RESEND_API_KEY=re_...

3
backend/.gitignore vendored
View File

@@ -44,7 +44,8 @@ build/
.env
### Project Specific ###
src/test/
!src/test/
!src/test/**
tmp/
uploads/*
!uploads/avatars/

View File

@@ -96,6 +96,12 @@
<version>25.3.0</version>
</dependency>
<dependency>
<groupId>com.resend</groupId>
<artifactId>resend-java</artifactId>
<version>3.1.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>

View File

@@ -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));
}
}

View File

@@ -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();

View File

@@ -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(

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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()));
}

View File

@@ -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));
}

View File

@@ -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}")

View File

@@ -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)) {

View File

@@ -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 +
'}';

View File

@@ -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 +
'}';
}
}

View File

@@ -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 +
'}';

View File

@@ -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());

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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)")

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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();
}
}

View File

@@ -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);
}
});
}
}

View File

@@ -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();

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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());

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;");
}
}

View File

@@ -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
);
}

View File

@@ -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()));

View File

@@ -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()
);

View File

@@ -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);
}

View File

@@ -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)

View File

@@ -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;

View File

@@ -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");
}
}
}
}

View File

@@ -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}

View File

@@ -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;

View File

@@ -0,0 +1 @@
ALTER TABLE purchaseOrder DROP COLUMN status;

View File

@@ -0,0 +1,218 @@
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.exception.ResourceNotFoundException;
import com.petshop.backend.entity.User;
import com.petshop.backend.repository.ActivityLogRepository;
import com.petshop.backend.repository.StoreRepository;
import com.petshop.backend.repository.UserRepository;
import com.petshop.backend.security.AppPrincipal;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.util.List;
import java.util.HashSet;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock private UserRepository userRepository;
@Mock private ActivityLogRepository activityLogRepository;
@Mock private PasswordEncoder passwordEncoder;
@Mock private StoreRepository storeRepository;
private UserService userService;
@BeforeEach
void setUp() {
userService = new UserService(userRepository, activityLogRepository, passwordEncoder, storeRepository);
}
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
}
@Test
void updateUserDeniesEditingAnotherAdmin() {
User actor = user(1L, User.Role.ADMIN);
User target = user(2L, User.Role.ADMIN);
authenticate(actor);
when(userRepository.findById(2L)).thenReturn(Optional.of(target));
when(userRepository.findById(1L)).thenReturn(Optional.of(actor));
UserRequest request = request(User.Role.ADMIN);
assertThrows(AccessDeniedException.class, () -> userService.updateUser(2L, request));
verify(userRepository, never()).save(any(User.class));
}
@Test
void updateUserTreatsWrongScopedRoleAsNotFound() {
User actor = user(1L, User.Role.ADMIN);
User target = user(2L, User.Role.ADMIN);
authenticate(actor);
when(userRepository.findById(2L)).thenReturn(Optional.of(target));
UserRequest request = request(User.Role.CUSTOMER);
assertThrows(ResourceNotFoundException.class, () -> userService.updateUser(2L, request, User.Role.CUSTOMER));
verify(userRepository, never()).save(any(User.class));
}
@Test
void deleteUserDeniesDeletingAnotherAdmin() {
User actor = user(1L, User.Role.ADMIN);
User target = user(2L, User.Role.ADMIN);
authenticate(actor);
when(userRepository.findById(2L)).thenReturn(Optional.of(target));
assertThrows(AccessDeniedException.class, () -> userService.deleteUser(2L));
verify(userRepository, never()).deleteById(anyLong());
}
@Test
void deleteUserDeniesDeletingSelfAdminAccount() {
User actor = user(1L, User.Role.ADMIN);
authenticate(actor);
when(userRepository.findById(1L)).thenReturn(Optional.of(actor));
assertThrows(AccessDeniedException.class, () -> userService.deleteUser(1L));
verify(userRepository, never()).deleteById(anyLong());
}
@Test
void bulkDeleteUsersDeniesMixedAdminTargets() {
User targetAdmin = user(2L, User.Role.ADMIN);
User customer = user(3L, User.Role.CUSTOMER);
when(userRepository.findAllById(eq(new HashSet<>(List.of(2L, 3L))))).thenReturn(List.of(targetAdmin, customer));
BulkDeleteRequest request = new BulkDeleteRequest();
request.setIds(List.of(2L, 3L));
assertThrows(AccessDeniedException.class, () -> userService.bulkDeleteUsers(request));
verify(userRepository, never()).deleteAllById(any());
}
@Test
void bulkDeleteUsersThrowsWhenAnyIdMissing() {
User customer = user(2L, User.Role.CUSTOMER);
when(userRepository.findAllById(any())).thenReturn(List.of(customer));
BulkDeleteRequest request = new BulkDeleteRequest();
request.setIds(List.of(2L, 3L));
assertThrows(ResourceNotFoundException.class, () -> userService.bulkDeleteUsers(request, User.Role.CUSTOMER));
verify(userRepository, never()).deleteAllById(any());
}
@Test
void createUserDeniesWrongRoleForScopedRoute() {
UserRequest request = request(User.Role.ADMIN);
assertThrows(AccessDeniedException.class, () -> userService.createUser(request, User.Role.CUSTOMER));
verify(userRepository, never()).save(any(User.class));
}
@Test
void updateUserAllowsEditingOwnAdminAccount() {
User actor = user(1L, User.Role.ADMIN);
authenticate(actor);
when(userRepository.findById(1L)).thenReturn(Optional.of(actor));
when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0));
UserRequest request = request(User.Role.ADMIN);
request.setFirstName("Updated");
UserResponse response = userService.updateUser(1L, request);
assertEquals("Updated", response.getFirstName());
}
@Test
void updateUserDeniesPromotingAnotherUserToAdmin() {
User actor = user(1L, User.Role.ADMIN);
User target = user(2L, User.Role.CUSTOMER);
authenticate(actor);
when(userRepository.findById(2L)).thenReturn(Optional.of(target));
when(userRepository.findById(1L)).thenReturn(Optional.of(actor));
UserRequest request = request(User.Role.ADMIN);
assertThrows(AccessDeniedException.class, () -> userService.updateUser(2L, request));
verify(userRepository, never()).save(any(User.class));
}
@Test
void scopedUpdateDeniesRoleEscalation() {
User actor = user(1L, User.Role.ADMIN);
User target = user(2L, User.Role.CUSTOMER);
authenticate(actor);
when(userRepository.findById(2L)).thenReturn(Optional.of(target));
UserRequest request = request(User.Role.ADMIN);
assertThrows(AccessDeniedException.class, () -> userService.updateUser(2L, request, User.Role.CUSTOMER));
verify(userRepository, never()).save(any(User.class));
}
private void authenticate(User user) {
AppPrincipal principal = new AppPrincipal(user.getId(), user.getUsername(), user.getRole(), user.getTokenVersion());
var context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(new UsernamePasswordAuthenticationToken(principal, "n/a", principal.getAuthorities()));
SecurityContextHolder.setContext(context);
}
private User user(Long id, User.Role role) {
User user = new User();
user.setId(id);
user.setUsername("user" + id);
user.setFirstName("First");
user.setLastName("Last");
user.setEmail("user" + id + "@example.com");
user.setRole(role);
user.setActive(true);
user.setTokenVersion(0);
return user;
}
private UserRequest request(User.Role role) {
UserRequest request = new UserRequest();
request.setUsername("updated-user");
request.setFirstName("First");
request.setLastName("Last");
request.setEmail("updated@example.com");
request.setRole(role);
request.setActive(true);
return request;
}
}