From 1da991d76d560d915a47240dc657acfa11037bb9 Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Wed, 15 Apr 2026 16:01:32 -0600 Subject: [PATCH] lock all stateful mutations --- .../backend/controller/AuthController.java | 24 ++++++++++++++++--- .../backend/repository/CartRepository.java | 6 +++++ .../backend/repository/CouponRepository.java | 6 +++++ .../repository/InventoryRepository.java | 10 ++++++++ .../PasswordResetTokenRepository.java | 8 +++++++ .../backend/repository/PetRepository.java | 6 +++++ .../backend/repository/UserRepository.java | 6 +++++ .../backend/service/AdoptionService.java | 6 ++--- .../petshop/backend/service/CartService.java | 2 +- .../backend/service/InventoryService.java | 2 +- .../backend/service/PasswordResetService.java | 2 +- .../petshop/backend/service/SaleService.java | 14 +++++++---- .../petshop/backend/service/UserService.java | 2 +- .../db/migration/V6__unique_constraints.sql | 1 + 14 files changed, 81 insertions(+), 14 deletions(-) create mode 100644 backend/src/main/resources/db/migration/V6__unique_constraints.sql diff --git a/backend/src/main/java/com/petshop/backend/controller/AuthController.java b/backend/src/main/java/com/petshop/backend/controller/AuthController.java index 4fb15f77..e278e206 100644 --- a/backend/src/main/java/com/petshop/backend/controller/AuthController.java +++ b/backend/src/main/java/com/petshop/backend/controller/AuthController.java @@ -33,6 +33,7 @@ import org.springframework.security.authentication.InternalAuthenticationService import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -109,7 +110,14 @@ public class AuthController { user.setRole(User.Role.CUSTOMER); user.setActive(true); - User savedUser = userRepository.save(user); + User savedUser; + try { + savedUser = userRepository.save(user); + } catch (DataIntegrityViolationException e) { + Map error = new HashMap<>(); + error.put("message", "Username, email, or phone already exists"); + return ResponseEntity.status(HttpStatus.CONFLICT).body(error); + } emailService.sendWelcome(savedUser); @@ -183,9 +191,12 @@ public class AuthController { return ResponseEntity.ok(toUserInfoResponse(user)); } + @Transactional @PutMapping("/me") public ResponseEntity updateProfile(@Valid @RequestBody ProfileUpdateRequest request) { - User user = getAuthenticatedUser(); + Long userId = AuthenticationHelper.getAuthenticatedUserId(); + User user = userRepository.findByIdForUpdate(userId) + .orElseThrow(() -> new UsernameNotFoundException("User not found")); boolean invalidateToken = false; String username = trimToNull(request.getUsername()); @@ -244,7 +255,14 @@ public class AuthController { user.setTokenVersion(user.getTokenVersion() + 1); } - User updatedUser = userRepository.save(user); + User updatedUser; + try { + updatedUser = userRepository.save(user); + } catch (DataIntegrityViolationException e) { + Map error = new HashMap<>(); + error.put("message", "Username, email, or phone already exists"); + return ResponseEntity.status(HttpStatus.CONFLICT).body(error); + } return ResponseEntity.ok(toUserInfoResponse(updatedUser)); } diff --git a/backend/src/main/java/com/petshop/backend/repository/CartRepository.java b/backend/src/main/java/com/petshop/backend/repository/CartRepository.java index 76ca1b5b..ab5d816a 100644 --- a/backend/src/main/java/com/petshop/backend/repository/CartRepository.java +++ b/backend/src/main/java/com/petshop/backend/repository/CartRepository.java @@ -1,7 +1,9 @@ package com.petshop.backend.repository; import com.petshop.backend.entity.Cart; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -20,4 +22,8 @@ public interface CartRepository extends JpaRepository { Optional findActiveCartByUserAndStore(@Param("userId") Long userId, @Param("storeId") Long storeId, @Param("status") String status); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT c FROM Cart c WHERE c.cartId = :id") + Optional findByIdForUpdate(@Param("id") Long id); } diff --git a/backend/src/main/java/com/petshop/backend/repository/CouponRepository.java b/backend/src/main/java/com/petshop/backend/repository/CouponRepository.java index 4955144b..0034a887 100644 --- a/backend/src/main/java/com/petshop/backend/repository/CouponRepository.java +++ b/backend/src/main/java/com/petshop/backend/repository/CouponRepository.java @@ -1,9 +1,11 @@ package com.petshop.backend.repository; import com.petshop.backend.entity.Coupon; +import jakarta.persistence.LockModeType; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -16,6 +18,10 @@ public interface CouponRepository extends JpaRepository { Optional findByCouponCode(String couponCode); Optional findByCouponCodeIgnoreCase(String couponCode); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT c FROM Coupon c WHERE c.couponId = :id") + Optional findByIdForUpdate(@Param("id") Long id); @Query("SELECT c FROM Coupon c WHERE " + "(:q IS NULL OR LOWER(c.couponCode) LIKE LOWER(CONCAT('%', :q, '%'))) AND " + "(:active IS NULL OR c.active = :active)") diff --git a/backend/src/main/java/com/petshop/backend/repository/InventoryRepository.java b/backend/src/main/java/com/petshop/backend/repository/InventoryRepository.java index 7dd535eb..82f3c797 100644 --- a/backend/src/main/java/com/petshop/backend/repository/InventoryRepository.java +++ b/backend/src/main/java/com/petshop/backend/repository/InventoryRepository.java @@ -1,9 +1,11 @@ package com.petshop.backend.repository; import com.petshop.backend.entity.Inventory; +import jakarta.persistence.LockModeType; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -19,6 +21,14 @@ public interface InventoryRepository extends JpaRepository { @Query("SELECT i FROM Inventory i WHERE i.product.prodId = :productId AND i.store.storeId = :storeId") Optional findByProductIdAndStoreId(@Param("productId") Long productId, @Param("storeId") Long storeId); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT i FROM Inventory i WHERE i.product.prodId = :productId AND i.store.storeId = :storeId") + Optional findByProductIdAndStoreIdForUpdate(@Param("productId") Long productId, @Param("storeId") Long storeId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT i FROM Inventory i WHERE i.inventoryId = :id") + Optional findByIdForUpdate(@Param("id") Long id); + @Query("SELECT i FROM Inventory i LEFT JOIN i.store s WHERE " + "(:q IS NULL OR (" + "LOWER(i.product.prodName) LIKE LOWER(CONCAT('%', :q, '%')) OR " + diff --git a/backend/src/main/java/com/petshop/backend/repository/PasswordResetTokenRepository.java b/backend/src/main/java/com/petshop/backend/repository/PasswordResetTokenRepository.java index 2d34d381..e436fb59 100644 --- a/backend/src/main/java/com/petshop/backend/repository/PasswordResetTokenRepository.java +++ b/backend/src/main/java/com/petshop/backend/repository/PasswordResetTokenRepository.java @@ -1,7 +1,11 @@ package com.petshop.backend.repository; import com.petshop.backend.entity.PasswordResetToken; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.time.LocalDateTime; @@ -14,4 +18,8 @@ public interface PasswordResetTokenRepository extends JpaRepository findByUser_IdAndUsedAtIsNull(Long userId); Optional findByTokenHashAndUsedAtIsNullAndExpiresAtAfter(String tokenHash, LocalDateTime now); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT t FROM PasswordResetToken t WHERE t.tokenHash = :hash AND t.usedAt IS NULL AND t.expiresAt > :now") + Optional findByTokenHashForUpdate(@Param("hash") String hash, @Param("now") LocalDateTime now); } diff --git a/backend/src/main/java/com/petshop/backend/repository/PetRepository.java b/backend/src/main/java/com/petshop/backend/repository/PetRepository.java index e5b5a958..9358fb72 100644 --- a/backend/src/main/java/com/petshop/backend/repository/PetRepository.java +++ b/backend/src/main/java/com/petshop/backend/repository/PetRepository.java @@ -1,9 +1,11 @@ package com.petshop.backend.repository; import com.petshop.backend.entity.Pet; +import jakarta.persistence.LockModeType; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -37,6 +39,10 @@ public interface PetRepository extends JpaRepository { List findAllByOwner_IdOrderByPetNameAsc(Long ownerId); Optional findByIdAndOwner_Id(Long id, Long ownerId); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT p FROM Pet p WHERE p.petId = :id") + Optional findByIdForUpdate(@Param("id") Long id); + @Query("SELECT p FROM Pet p WHERE " + "(:q IS NULL OR LOWER(p.petName) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(p.petSpecies) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(COALESCE(p.petBreed, '')) LIKE LOWER(CONCAT('%', :q, '%'))) AND " + "(:species IS NULL OR LOWER(p.petSpecies) = LOWER(:species)) AND " + diff --git a/backend/src/main/java/com/petshop/backend/repository/UserRepository.java b/backend/src/main/java/com/petshop/backend/repository/UserRepository.java index 592a4a76..022a3162 100644 --- a/backend/src/main/java/com/petshop/backend/repository/UserRepository.java +++ b/backend/src/main/java/com/petshop/backend/repository/UserRepository.java @@ -1,9 +1,11 @@ package com.petshop.backend.repository; import com.petshop.backend.entity.User; +import jakarta.persistence.LockModeType; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -32,6 +34,10 @@ public interface UserRepository extends JpaRepository { "LOWER(COALESCE(u.phone, '')) LIKE LOWER(CONCAT('%', :q, '%'))") Page searchUsers(@Param("q") String query, Pageable pageable); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT u FROM User u WHERE u.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); + @Query("SELECT u FROM User u WHERE u.role = :role AND (" + "LOWER(COALESCE(u.username, '')) LIKE LOWER(CONCAT('%', :q, '%')) OR " + "LOWER(COALESCE(u.firstName, '')) LIKE LOWER(CONCAT('%', :q, '%')) OR " + diff --git a/backend/src/main/java/com/petshop/backend/service/AdoptionService.java b/backend/src/main/java/com/petshop/backend/service/AdoptionService.java index 734f8c63..bfa916de 100644 --- a/backend/src/main/java/com/petshop/backend/service/AdoptionService.java +++ b/backend/src/main/java/com/petshop/backend/service/AdoptionService.java @@ -80,7 +80,7 @@ public class AdoptionService { @Transactional public AdoptionResponse createAdoption(AdoptionRequest request) { - Pet pet = petRepository.findById(request.getPetId()) + Pet pet = petRepository.findByIdForUpdate(request.getPetId()) .orElseThrow(() -> new ResourceNotFoundException("Pet not found with id: " + request.getPetId())); User customer = userRepository.findById(request.getCustomerId()) @@ -120,7 +120,7 @@ public class AdoptionService { Adoption adoption = adoptionRepository.findById(id) .orElseThrow(() -> new ResourceNotFoundException("Adoption not found with id: " + id)); - Pet pet = petRepository.findById(request.getPetId()) + Pet pet = petRepository.findByIdForUpdate(request.getPetId()) .orElseThrow(() -> new ResourceNotFoundException("Pet not found with id: " + request.getPetId())); User customer = userRepository.findById(request.getCustomerId()) @@ -160,7 +160,7 @@ public class AdoptionService { @Transactional public AdoptionResponse requestAdoption(Long customerId, Long petId, Long employeeId, Long sourceStoreId, LocalDate adoptionDate) { - Pet pet = petRepository.findById(petId) + Pet pet = petRepository.findByIdForUpdate(petId) .orElseThrow(() -> new ResourceNotFoundException("Pet not found")); // Verify the pet is actually located at the claimed store diff --git a/backend/src/main/java/com/petshop/backend/service/CartService.java b/backend/src/main/java/com/petshop/backend/service/CartService.java index f2d6ab0b..54844dc3 100644 --- a/backend/src/main/java/com/petshop/backend/service/CartService.java +++ b/backend/src/main/java/com/petshop/backend/service/CartService.java @@ -362,7 +362,7 @@ public class CartService { throw new BusinessException("Unauthorized"); } - Cart cart = cartRepository.findById(cartId) + Cart cart = cartRepository.findByIdForUpdate(cartId) .orElseThrow(() -> new BusinessException("Cart not found")); if (!cart.getUser().getId().equals(userId)) { diff --git a/backend/src/main/java/com/petshop/backend/service/InventoryService.java b/backend/src/main/java/com/petshop/backend/service/InventoryService.java index 884458f9..f7e15068 100644 --- a/backend/src/main/java/com/petshop/backend/service/InventoryService.java +++ b/backend/src/main/java/com/petshop/backend/service/InventoryService.java @@ -61,7 +61,7 @@ public class InventoryService { @Transactional public InventoryResponse updateInventory(Long id, InventoryRequest request) { - Inventory inventory = inventoryRepository.findById(id) + Inventory inventory = inventoryRepository.findByIdForUpdate(id) .orElseThrow(() -> new ResourceNotFoundException("Inventory not found with id: " + id)); Product product = productRepository.findById(request.getProdId()) diff --git a/backend/src/main/java/com/petshop/backend/service/PasswordResetService.java b/backend/src/main/java/com/petshop/backend/service/PasswordResetService.java index 5094f613..2ecee374 100644 --- a/backend/src/main/java/com/petshop/backend/service/PasswordResetService.java +++ b/backend/src/main/java/com/petshop/backend/service/PasswordResetService.java @@ -85,7 +85,7 @@ public class PasswordResetService { } PasswordResetToken token = passwordResetTokenRepository - .findByTokenHashAndUsedAtIsNullAndExpiresAtAfter(hashToken(normalizedToken), LocalDateTime.now()) + .findByTokenHashForUpdate(hashToken(normalizedToken), LocalDateTime.now()) .orElseThrow(() -> new BusinessException("Reset token is invalid or has expired")); User user = token.getUser(); diff --git a/backend/src/main/java/com/petshop/backend/service/SaleService.java b/backend/src/main/java/com/petshop/backend/service/SaleService.java index 37d4e338..0b8a56cc 100644 --- a/backend/src/main/java/com/petshop/backend/service/SaleService.java +++ b/backend/src/main/java/com/petshop/backend/service/SaleService.java @@ -82,7 +82,7 @@ public class SaleService { sale.setChannel(request.getChannel() != null ? request.getChannel() : "IN_STORE"); if (request.getCouponId() != null) { - Coupon coupon = couponRepository.findById(request.getCouponId()) + Coupon coupon = couponRepository.findByIdForUpdate(request.getCouponId()) .orElseThrow(() -> new ResourceNotFoundException("Coupon not found with id: " + request.getCouponId())); validateCoupon(coupon); sale.setCoupon(coupon); @@ -96,7 +96,7 @@ public class SaleService { User customer = null; if (request.getCustomerId() != null) { - customer = userRepository.findById(request.getCustomerId()) + customer = userRepository.findByIdForUpdate(request.getCustomerId()) .orElseThrow(() -> new ResourceNotFoundException("Customer not found with id: " + request.getCustomerId())); sale.setCustomer(customer); } @@ -144,7 +144,7 @@ public class SaleService { " for product: " + product.getProdName()); } - Inventory inventory = inventoryRepository.findByProductIdAndStoreId(itemRequest.getProdId(), store.getStoreId()) + Inventory inventory = inventoryRepository.findByProductIdAndStoreIdForUpdate(itemRequest.getProdId(), store.getStoreId()) .orElseThrow(() -> new ResourceNotFoundException("Inventory not found for product " + itemRequest.getProdId() + " at store " + store.getStoreId())); inventory.setQuantity(inventory.getQuantity() + itemRequest.getQuantity()); @@ -208,7 +208,7 @@ public class SaleService { Product product = productRepository.findById(itemRequest.getProdId()) .orElseThrow(() -> new ResourceNotFoundException("Product not found with id: " + itemRequest.getProdId())); - Inventory inventory = inventoryRepository.findByProductIdAndStoreId(itemRequest.getProdId(), store.getStoreId()) + Inventory inventory = inventoryRepository.findByProductIdAndStoreIdForUpdate(itemRequest.getProdId(), store.getStoreId()) .orElseThrow(() -> new ResourceNotFoundException("Inventory not found for product " + itemRequest.getProdId() + " at store " + store.getStoreId())); if (inventory.getQuantity() < itemRequest.getQuantity()) { @@ -289,6 +289,12 @@ public class SaleService { if (coupon.getEndsAt() != null && now.isAfter(coupon.getEndsAt())) { throw new BusinessException("Coupon has expired"); } + if (coupon.getUsageLimit() != null) { + long used = saleRepository.countByCoupon_CouponId(coupon.getCouponId()); + if (used >= coupon.getUsageLimit()) { + throw new BusinessException("Coupon usage limit has been reached"); + } + } } private BigDecimal calculateCouponDiscount(Coupon coupon, BigDecimal subtotal) { diff --git a/backend/src/main/java/com/petshop/backend/service/UserService.java b/backend/src/main/java/com/petshop/backend/service/UserService.java index 4cf83d35..d557a999 100644 --- a/backend/src/main/java/com/petshop/backend/service/UserService.java +++ b/backend/src/main/java/com/petshop/backend/service/UserService.java @@ -109,7 +109,7 @@ public class UserService { @Transactional public UserResponse updateUser(Long id, UserRequest request, User.Role requiredRole) { - User user = userRepository.findById(id) + User user = userRepository.findByIdForUpdate(id) .orElseThrow(() -> new ResourceNotFoundException("User not found with id: " + id)); requireRoleOrNotFound(user, requiredRole, id); diff --git a/backend/src/main/resources/db/migration/V6__unique_constraints.sql b/backend/src/main/resources/db/migration/V6__unique_constraints.sql new file mode 100644 index 00000000..5e26b311 --- /dev/null +++ b/backend/src/main/resources/db/migration/V6__unique_constraints.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD CONSTRAINT uq_users_phone UNIQUE (phone);