Add Flash Sale feature for limited-time deals
Problem Context
Product wants to drive engagement with flash sales - limited-time deals with special pricing. Requirements: - Admins can create flash sales with start/end times, discounted prices, and limited quantities - Users can purchase items during active sales (one per user per sale) - System sends email notifications when sales start - Sales automatically activate/deactivate based on schedule - Dashboard shows sale statistics This PR adds the complete flash sale feature across multiple files.
Expert
300 points
File Changes (8)
src/main/java/com/example/entity/FlashSale.java
ADDED
@@ -0 +1 @@
| 1 | +package com.example.entity; |
|
| 2 | + |
|
| 3 | +import jakarta.persistence.*; |
|
| 4 | +import lombok.Getter; |
|
| 5 | +import lombok.Setter; |
|
| 6 | +import java.time.LocalDateTime; |
|
| 7 | +import java.util.Objects; |
|
| 8 | + |
|
| 9 | +@Entity |
|
| 10 | +@Table(name = "flash_sales") |
|
| 11 | +@Getter @Setter |
|
| 12 | +public class FlashSale { |
|
| 13 | + |
|
| 14 | + @Id |
|
| 15 | + @GeneratedValue(strategy = GenerationType.IDENTITY) |
|
| 16 | + private Long id; |
|
| 17 | + |
|
| 18 | + @ManyToOne(fetch = FetchType.LAZY) |
|
| 19 | + private Product product; |
|
| 20 | + |
|
| 21 | + private String name; |
|
| 22 | + private Double originalPrice; |
|
| 23 | + private Double discountedPrice; |
|
| 24 | + private Integer availableQuantity; |
|
| 25 | + private Integer soldQuantity = 0; |
|
| 26 | + |
|
| 27 | + private LocalDateTime startTime; |
|
| 28 | + private LocalDateTime endTime; |
|
| 29 | + |
|
| 30 | + @Enumerated(EnumType.STRING) |
|
| 31 | + private SaleStatus status = SaleStatus.SCHEDULED; |
|
| 32 | + |
|
| 33 | + public boolean isActive() { |
|
| 34 | + LocalDateTime now = LocalDateTime.now(); |
|
| 35 | + return status == SaleStatus.ACTIVE && |
|
| 36 | + now.isAfter(startTime) && now.isBefore(endTime); |
|
| 37 | + } |
|
| 38 | + |
|
| 39 | + public int getRemainingStock() { |
|
| 40 | + return availableQuantity - soldQuantity; |
|
| 41 | + } |
|
| 42 | + |
|
| 43 | + @Override |
|
| 44 | + public boolean equals(Object o) { |
|
| 45 | + if (this == o) return true; |
|
| 46 | + if (o == null || getClass() != o.getClass()) return false; |
|
| 47 | + FlashSale that = (FlashSale) o; |
|
| 48 | + return Objects.equals(name, that.name) && |
|
| 49 | + Objects.equals(discountedPrice, that.discountedPrice) && |
|
| 50 | + Objects.equals(startTime, that.startTime); |
|
| 51 | + } |
|
| 52 | + |
|
| 53 | + @Override |
|
| 54 | + public int hashCode() { |
|
| 55 | + return Objects.hash(name, discountedPrice, startTime); |
|
| 56 | + } |
|
| 57 | +} |
src/main/java/com/example/service/FlashSaleService.java
ADDED
@@ -0 +1 @@
| 1 | +package com.example.service; |
|
| 2 | + |
|
| 3 | +import com.example.entity.FlashSale; |
|
| 4 | +import com.example.entity.SaleStatus; |
|
| 5 | +import com.example.repository.FlashSaleRepository; |
|
| 6 | +import lombok.RequiredArgsConstructor; |
|
| 7 | +import org.springframework.stereotype.Service; |
|
| 8 | + |
|
| 9 | +import java.util.List; |
|
| 10 | + |
|
| 11 | +@Service |
|
| 12 | +@RequiredArgsConstructor |
|
| 13 | +public class FlashSaleService { |
|
| 14 | + |
|
| 15 | + private final FlashSaleRepository flashSaleRepository; |
|
| 16 | + private final NotificationScheduler notificationScheduler; |
|
| 17 | + private final FlashSaleCache flashSaleCache; |
|
| 18 | + |
|
| 19 | + /** |
|
| 20 | + * Create a new flash sale and schedule notifications |
|
| 21 | + */ |
|
| 22 | + public FlashSale createSale(FlashSale sale) { |
|
| 23 | + // Save the flash sale |
|
| 24 | + FlashSale savedSale = flashSaleRepository.save(sale); |
|
| 25 | + |
|
| 26 | + // Schedule notification for sale start |
|
| 27 | + notificationScheduler.scheduleNotification(savedSale); |
|
| 28 | + |
|
| 29 | + // Invalidate cache |
|
| 30 | + flashSaleCache.invalidate(); |
|
| 31 | + |
|
| 32 | + return savedSale; |
|
| 33 | + } |
|
| 34 | + |
|
| 35 | + /** |
|
| 36 | + * Calculate savings for display (original - discounted) |
|
| 37 | + */ |
|
| 38 | + public double calculateSavings(FlashSale sale) { |
|
| 39 | + double savings = sale.getOriginalPrice() - sale.getDiscountedPrice(); |
|
| 40 | + double percentOff = (savings / sale.getOriginalPrice()) * 100.0; |
|
| 41 | + return percentOff; |
|
| 42 | + } |
|
| 43 | + |
|
| 44 | + public List<FlashSale> getActiveSales() { |
|
| 45 | + return flashSaleCache.getActiveSales(); |
|
| 46 | + } |
|
| 47 | +} |
src/main/java/com/example/service/FlashSalePurchaseService.java
ADDED
@@ -0 +1 @@
| 1 | +package com.example.service; |
|
| 2 | + |
|
| 3 | +import com.example.entity.*; |
|
| 4 | +import com.example.repository.*; |
|
| 5 | +import lombok.RequiredArgsConstructor; |
|
| 6 | +import org.springframework.stereotype.Service; |
|
| 7 | +import org.springframework.transaction.annotation.Transactional; |
|
| 8 | + |
|
| 9 | +@Service |
|
| 10 | +@RequiredArgsConstructor |
|
| 11 | +public class FlashSalePurchaseService { |
|
| 12 | + |
|
| 13 | + private final FlashSaleRepository flashSaleRepository; |
|
| 14 | + private final FlashSalePurchaseRepository purchaseRepository; |
|
| 15 | + private final PaymentService paymentService; |
|
| 16 | + |
|
| 17 | + @Transactional |
|
| 18 | + public PurchaseResult processPurchase(Long saleId, Long userId, int quantity) { |
|
| 19 | + FlashSale sale = flashSaleRepository.findById(saleId) |
|
| 20 | + .orElseThrow(() -> new SaleNotFoundException(saleId)); |
|
| 21 | + |
|
| 22 | + // Check if sale is active |
|
| 23 | + if (!sale.isActive()) { |
|
| 24 | + return PurchaseResult.failed("Sale is not currently active"); |
|
| 25 | + } |
|
| 26 | + |
|
| 27 | + // Check if user already purchased |
|
| 28 | + if (purchaseRepository.existsBySaleIdAndUserId(saleId, userId)) { |
|
| 29 | + return PurchaseResult.failed("You have already purchased this item"); |
|
| 30 | + } |
|
| 31 | + |
|
| 32 | + // Check stock availability |
|
| 33 | + if (sale.getRemainingStock() < quantity) { |
|
| 34 | + return PurchaseResult.failed("Not enough stock available"); |
|
| 35 | + } |
|
| 36 | + |
|
| 37 | + // Calculate total |
|
| 38 | + int totalPrice = (int) (sale.getDiscountedPrice() * quantity); |
|
| 39 | + |
|
| 40 | + // Process payment |
|
| 41 | + PaymentResult payment = paymentService.charge(userId, totalPrice); |
|
| 42 | + if (!payment.isSuccessful()) { |
|
| 43 | + return PurchaseResult.failed("Payment failed: " + payment.getError()); |
|
| 44 | + } |
|
| 45 | + |
|
| 46 | + // Update sold quantity |
|
| 47 | + sale.setSoldQuantity(sale.getSoldQuantity() + quantity); |
|
| 48 | + flashSaleRepository.save(sale); |
|
| 49 | + |
|
| 50 | + // Create purchase record |
|
| 51 | + FlashSalePurchase purchase = new FlashSalePurchase(); |
|
| 52 | + purchase.setSaleId(saleId); |
|
| 53 | + purchase.setUserId(userId); |
|
| 54 | + purchase.setQuantity(quantity); |
|
| 55 | + purchase.setTotalPaid(totalPrice); |
|
| 56 | + purchaseRepository.save(purchase); |
|
| 57 | + |
|
| 58 | + return PurchaseResult.success(purchase); |
|
| 59 | + } |
|
| 60 | +} |
src/main/java/com/example/service/FlashSaleNotificationService.java
ADDED
@@ -0 +1 @@
| 1 | +package com.example.service; |
|
| 2 | + |
|
| 3 | +import com.example.entity.FlashSale; |
|
| 4 | +import com.example.entity.User; |
|
| 5 | +import com.example.repository.UserRepository; |
|
| 6 | +import lombok.RequiredArgsConstructor; |
|
| 7 | +import org.springframework.stereotype.Service; |
|
| 8 | + |
|
| 9 | +import java.util.List; |
|
| 10 | +import java.util.Optional; |
|
| 11 | + |
|
| 12 | +@Service |
|
| 13 | +@RequiredArgsConstructor |
|
| 14 | +public class FlashSaleNotificationService { |
|
| 15 | + |
|
| 16 | + private final UserRepository userRepository; |
|
| 17 | + private final EmailService emailService; |
|
| 18 | + |
|
| 19 | + /** |
|
| 20 | + * Send notification to a specific user about a flash sale |
|
| 21 | + */ |
|
| 22 | + public void notifyUser(Long userId, FlashSale sale) { |
|
| 23 | + Optional<User> userOpt = userRepository.findById(userId); |
|
| 24 | + |
|
| 25 | + User user = userOpt.get(); |
|
| 26 | + |
|
| 27 | + String subject = "Flash Sale Alert: " + sale.getName(); |
|
| 28 | + String body = buildEmailBody(sale); |
|
| 29 | + |
|
| 30 | + emailService.send(user.getEmail(), subject, body); |
|
| 31 | + } |
|
| 32 | + |
|
| 33 | + /** |
|
| 34 | + * Notify all users who have opted in to flash sale alerts |
|
| 35 | + */ |
|
| 36 | + public void notifyAllSubscribers(FlashSale sale) { |
|
| 37 | + List<User> subscribers = userRepository.findByFlashSaleAlertsEnabled(true); |
|
| 38 | + |
|
| 39 | + List<String> emails = subscribers.stream() |
|
| 40 | + .map(User::getEmail) |
|
| 41 | + .map(String::toLowerCase) |
|
| 42 | + .distinct() |
|
| 43 | + .toList(); |
|
| 44 | + |
|
| 45 | + String subject = "Flash Sale Starting Now: " + sale.getName(); |
|
| 46 | + String body = buildEmailBody(sale); |
|
| 47 | + |
|
| 48 | + emailService.sendBulk(emails, subject, body); |
|
| 49 | + } |
|
| 50 | + |
|
| 51 | + private String buildEmailBody(FlashSale sale) { |
|
| 52 | + return String.format("Don't miss out! %s is now only $%.2f (was $%.2f)", |
|
| 53 | + sale.getName(), sale.getDiscountedPrice(), sale.getOriginalPrice()); |
|
| 54 | + } |
|
| 55 | +} |
src/main/java/com/example/cache/FlashSaleCache.java
ADDED
@@ -0 +1 @@
| 1 | +package com.example.cache; |
|
| 2 | + |
|
| 3 | +import com.example.entity.FlashSale; |
|
| 4 | +import com.example.entity.SaleStatus; |
|
| 5 | +import com.example.repository.FlashSaleRepository; |
|
| 6 | +import lombok.RequiredArgsConstructor; |
|
| 7 | +import org.springframework.stereotype.Component; |
|
| 8 | + |
|
| 9 | +import java.util.List; |
|
| 10 | + |
|
| 11 | +@Component |
|
| 12 | +@RequiredArgsConstructor |
|
| 13 | +public class FlashSaleCache { |
|
| 14 | + |
|
| 15 | + private final FlashSaleRepository flashSaleRepository; |
|
| 16 | + |
|
| 17 | + private List<FlashSale> cachedActiveSales; |
|
| 18 | + private long lastRefreshTime; |
|
| 19 | + |
|
| 20 | + private static final long CACHE_TTL_MS = 60000; // 1 minute |
|
| 21 | + |
|
| 22 | + public List<FlashSale> getActiveSales() { |
|
| 23 | + if (cachedActiveSales == null || isCacheExpired()) { |
|
| 24 | + cachedActiveSales = flashSaleRepository.findByStatus(SaleStatus.ACTIVE); |
|
| 25 | + lastRefreshTime = System.currentTimeMillis(); |
|
| 26 | + } |
|
| 27 | + return cachedActiveSales; |
|
| 28 | + } |
|
| 29 | + |
|
| 30 | + public void invalidate() { |
|
| 31 | + cachedActiveSales = null; |
|
| 32 | + } |
|
| 33 | + |
|
| 34 | + private boolean isCacheExpired() { |
|
| 35 | + return System.currentTimeMillis() - lastRefreshTime > CACHE_TTL_MS; |
|
| 36 | + } |
|
| 37 | +} |
src/main/java/com/example/scheduler/FlashSaleScheduler.java
ADDED
@@ -0 +1 @@
| 1 | +package com.example.scheduler; |
|
| 2 | + |
|
| 3 | +import com.example.entity.FlashSale; |
|
| 4 | +import com.example.entity.SaleStatus; |
|
| 5 | +import com.example.repository.FlashSaleRepository; |
|
| 6 | +import com.example.service.FlashSaleNotificationService; |
|
| 7 | +import lombok.RequiredArgsConstructor; |
|
| 8 | +import lombok.extern.slf4j.Slf4j; |
|
| 9 | +import org.springframework.scheduling.annotation.Scheduled; |
|
| 10 | +import org.springframework.stereotype.Component; |
|
| 11 | +import org.springframework.transaction.annotation.Transactional; |
|
| 12 | + |
|
| 13 | +import java.time.LocalDateTime; |
|
| 14 | +import java.util.List; |
|
| 15 | + |
|
| 16 | +@Slf4j |
|
| 17 | +@Component |
|
| 18 | +@RequiredArgsConstructor |
|
| 19 | +public class FlashSaleScheduler { |
|
| 20 | + |
|
| 21 | + private final FlashSaleRepository flashSaleRepository; |
|
| 22 | + private final FlashSaleNotificationService notificationService; |
|
| 23 | + |
|
| 24 | + /** |
|
| 25 | + * Check every minute for sales that need to be activated or expired |
|
| 26 | + */ |
|
| 27 | + @Scheduled(fixedRate = 60000) |
|
| 28 | + public void processSaleStatusUpdates() { |
|
| 29 | + LocalDateTime now = LocalDateTime.now(); |
|
| 30 | + |
|
| 31 | + // Activate scheduled sales whose start time has passed |
|
| 32 | + List<FlashSale> toActivate = flashSaleRepository |
|
| 33 | + .findByStatusAndStartTimeBefore(SaleStatus.SCHEDULED, now); |
|
| 34 | + |
|
| 35 | + for (FlashSale sale : toActivate) { |
|
| 36 | + activateSale(sale); |
|
| 37 | + } |
|
| 38 | + |
|
| 39 | + // Expire active sales whose end time has passed |
|
| 40 | + List<FlashSale> toExpire = flashSaleRepository |
|
| 41 | + .findByStatusAndEndTimeBefore(SaleStatus.ACTIVE, now); |
|
| 42 | + |
|
| 43 | + for (FlashSale sale : toExpire) { |
|
| 44 | + expireSale(sale); |
|
| 45 | + } |
|
| 46 | + } |
|
| 47 | + |
|
| 48 | + @Transactional |
|
| 49 | + private void activateSale(FlashSale sale) { |
|
| 50 | + log.info("Activating flash sale: {}", sale.getName()); |
|
| 51 | + sale.setStatus(SaleStatus.ACTIVE); |
|
| 52 | + flashSaleRepository.save(sale); |
|
| 53 | + |
|
| 54 | + // Notify subscribers |
|
| 55 | + notificationService.notifyAllSubscribers(sale); |
|
| 56 | + } |
|
| 57 | + |
|
| 58 | + @Transactional |
|
| 59 | + private void expireSale(FlashSale sale) { |
|
| 60 | + log.info("Expiring flash sale: {}", sale.getName()); |
|
| 61 | + sale.setStatus(SaleStatus.EXPIRED); |
|
| 62 | + flashSaleRepository.save(sale); |
|
| 63 | + } |
|
| 64 | +} |
src/main/java/com/example/service/FlashSaleStatsService.java
ADDED
@@ -0 +1 @@
| 1 | +package com.example.service; |
|
| 2 | + |
|
| 3 | +import com.example.entity.FlashSalePurchase; |
|
| 4 | +import com.example.repository.FlashSalePurchaseRepository; |
|
| 5 | +import lombok.RequiredArgsConstructor; |
|
| 6 | +import org.springframework.stereotype.Service; |
|
| 7 | + |
|
| 8 | +import java.util.Comparator; |
|
| 9 | +import java.util.List; |
|
| 10 | + |
|
| 11 | +@Service |
|
| 12 | +@RequiredArgsConstructor |
|
| 13 | +public class FlashSaleStatsService { |
|
| 14 | + |
|
| 15 | + private final FlashSalePurchaseRepository purchaseRepository; |
|
| 16 | + |
|
| 17 | + public SaleStats calculateStats(Long saleId) { |
|
| 18 | + List<FlashSalePurchase> purchases = purchaseRepository.findBySaleId(saleId); |
|
| 19 | + |
|
| 20 | + int totalRevenue = purchases.stream() |
|
| 21 | + .mapToInt(FlashSalePurchase::getTotalPaid) |
|
| 22 | + .sum(); |
|
| 23 | + |
|
| 24 | + int totalQuantity = purchases.stream() |
|
| 25 | + .mapToInt(FlashSalePurchase::getQuantity) |
|
| 26 | + .sum(); |
|
| 27 | + |
|
| 28 | + double averagePurchase = totalRevenue / purchases.size(); |
|
| 29 | + |
|
| 30 | + int maxPurchase = purchases.stream() |
|
| 31 | + .mapToInt(FlashSalePurchase::getTotalPaid) |
|
| 32 | + .max() |
|
| 33 | + .getAsInt(); |
|
| 34 | + |
|
| 35 | + return new SaleStats(purchases.size(), totalRevenue, totalQuantity, averagePurchase, maxPurchase); |
|
| 36 | + } |
|
| 37 | +} |
src/main/java/com/example/controller/FlashSaleController.java
ADDED
@@ -0 +1 @@
| 1 | +package com.example.controller; |
|
| 2 | + |
|
| 3 | +import com.example.dto.CreateFlashSaleRequest; |
|
| 4 | +import com.example.dto.PurchaseRequest; |
|
| 5 | +import com.example.entity.FlashSale; |
|
| 6 | +import com.example.service.*; |
|
| 7 | +import lombok.RequiredArgsConstructor; |
|
| 8 | +import org.springframework.http.ResponseEntity; |
|
| 9 | +import org.springframework.web.bind.annotation.*; |
|
| 10 | + |
|
| 11 | +import java.util.List; |
|
| 12 | + |
|
| 13 | +@RestController |
|
| 14 | +@RequestMapping("/api/flash-sales") |
|
| 15 | +@RequiredArgsConstructor |
|
| 16 | +public class FlashSaleController { |
|
| 17 | + |
|
| 18 | + private final FlashSaleService flashSaleService; |
|
| 19 | + private final FlashSalePurchaseService purchaseService; |
|
| 20 | + private final FlashSaleStatsService statsService; |
|
| 21 | + |
|
| 22 | + @GetMapping |
|
| 23 | + public List<FlashSale> getActiveSales() { |
|
| 24 | + return flashSaleService.getActiveSales(); |
|
| 25 | + } |
|
| 26 | + |
|
| 27 | + @PostMapping |
|
| 28 | + public ResponseEntity<FlashSale> createSale(@RequestBody CreateFlashSaleRequest request) { |
|
| 29 | + FlashSale sale = new FlashSale(); |
|
| 30 | + sale.setName(request.getName()); |
|
| 31 | + sale.setOriginalPrice(request.getOriginalPrice()); |
|
| 32 | + sale.setDiscountedPrice(request.getDiscountedPrice()); |
|
| 33 | + sale.setAvailableQuantity(request.getQuantity()); |
|
| 34 | + sale.setStartTime(request.getStartTime()); |
|
| 35 | + sale.setEndTime(request.getEndTime()); |
|
| 36 | + |
|
| 37 | + FlashSale created = flashSaleService.createSale(sale); |
|
| 38 | + return ResponseEntity.ok(created); |
|
| 39 | + } |
|
| 40 | + |
|
| 41 | + @PostMapping("/{saleId}/purchase") |
|
| 42 | + public ResponseEntity<PurchaseResult> purchase( |
|
| 43 | + @PathVariable Long saleId, |
|
| 44 | + @RequestBody PurchaseRequest request, |
|
| 45 | + @RequestHeader("X-User-Id") Long userId) { |
|
| 46 | + |
|
| 47 | + PurchaseResult result = purchaseService.processPurchase( |
|
| 48 | + saleId, userId, request.getQuantity()); |
|
| 49 | + |
|
| 50 | + return ResponseEntity.ok(result); |
|
| 51 | + } |
|
| 52 | + |
|
| 53 | + @GetMapping("/{saleId}/stats") |
|
| 54 | + public ResponseEntity<SaleStats> getStats(@PathVariable Long saleId) { |
|
| 55 | + return ResponseEntity.ok(statsService.calculateStats(saleId)); |
|
| 56 | + } |
|
| 57 | +} |
Login Required: You must be registered to submit reviews and receive AI feedback.
Register or
login to start reviewing!
Your Review
Review Tips
- Look for security vulnerabilities (SQL injection, XSS, etc.)
- Check for null pointer exceptions and error handling
- Consider performance implications
- Evaluate code maintainability and readability
- Check for proper resource management
- Look for logic errors or edge cases