ReviewService.java
package com.rbctcsworld.ecommerce.review;
import com.rbctcsworld.ecommerce.auth.AppUser;
import com.rbctcsworld.ecommerce.auth.UserRepository;
import com.rbctcsworld.ecommerce.common.exception.BusinessRuleException;
import com.rbctcsworld.ecommerce.common.exception.ConflictException;
import com.rbctcsworld.ecommerce.common.exception.ForbiddenException;
import com.rbctcsworld.ecommerce.common.exception.NotFoundException;
import com.rbctcsworld.ecommerce.order.OrderRepository;
import com.rbctcsworld.ecommerce.order.OrderStatus;
import com.rbctcsworld.ecommerce.product.Product;
import com.rbctcsworld.ecommerce.product.ProductRepository;
import com.rbctcsworld.ecommerce.review.ReviewDtos.ProductReviews;
import com.rbctcsworld.ecommerce.review.ReviewDtos.RatingSummary;
import com.rbctcsworld.ecommerce.review.ReviewDtos.ReviewResponse;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Clock;
import java.time.LocalDateTime;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Business rules:
* 1. Rating 1..5, title <= 100, body <= 2000 characters (400). Blank title/body are stored as null.
* 2. Verified purchase only: the customer must have RECEIVED the product (an order with it that is
* DELIVERED, RETURN_REQUESTED or RETURNED). Otherwise 403.
* 3. One review per customer per product (409). The customer may edit or delete it.
* 4. Another customer's review id behaves as "not found" (404, IDOR protection).
* 5. Only PUBLISHED reviews are listed and counted. An admin can hide / re-publish (moderation);
* editing a hidden review does not publish it again.
* 6. The product's averageRating (1 decimal, HALF_UP) and reviewCount are recalculated in the same
* transaction as every change, while the product row is locked (SELECT ... FOR UPDATE), so two
* reviews saved at the same moment can never leave a wrong average.
* 7. Public responses show a masked reviewer name, never the e-mail address.
*/
@Service
@Transactional
public class ReviewService {
static final Set<String> RECEIVED = Set.of(OrderStatus.DELIVERED, OrderStatus.RETURN_REQUESTED, OrderStatus.RETURNED);
public static final List<String> SORTS = List.of("newest", "highest", "lowest");
private final ReviewRepository reviews;
private final ProductRepository products;
private final OrderRepository orders;
private final UserRepository users;
private final Clock clock;
public ReviewService(ReviewRepository reviews, ProductRepository products, OrderRepository orders,
UserRepository users, Clock clock) {
this.reviews = reviews;
this.products = products;
this.orders = orders;
this.users = users;
this.clock = clock;
}
// ---------- customer ----------
public ReviewResponse create(String email, Long productId, int rating, String title, String body) {
AppUser user = user(email);
Product product = products.lockById(productId)
.filter(Product::isActive)
.orElseThrow(() -> new NotFoundException("Product not found: " + productId));
if (!orders.hasReceived(user.getId(), productId, RECEIVED)) {
throw new ForbiddenException("Only customers who received this product can review it");
}
if (reviews.existsByUserIdAndProductId(user.getId(), productId)) {
throw new ConflictException("You have already reviewed this product - edit your review instead");
}
Review r = reviews.saveAndFlush(new Review(productId, user.getId(), rating, clean(title), clean(body), now()));
recalculate(product);
return toResponse(r, email);
}
public ReviewResponse update(String email, Long reviewId, int rating, String title, String body) {
AppUser user = user(email);
Review r = owned(reviewId, user.getId());
Product product = lockedProduct(r.getProductId());
r.edit(rating, clean(title), clean(body), now());
reviews.saveAndFlush(r);
recalculate(product);
return toResponse(r, email);
}
public void delete(String email, Long reviewId) {
AppUser user = user(email);
Review r = owned(reviewId, user.getId());
Product product = lockedProduct(r.getProductId());
reviews.delete(r);
reviews.flush();
recalculate(product);
}
@Transactional(readOnly = true)
public List<ReviewResponse> mine(String email) {
return reviews.findByUserIdOrderByIdDesc(user(email).getId()).stream().map(r -> toResponse(r, email)).toList();
}
// ---------- public ----------
@Transactional(readOnly = true)
public ProductReviews forProduct(Long productId, String sort) {
String s = sort == null || sort.isBlank() ? "newest" : sort.trim().toLowerCase();
if (!SORTS.contains(s)) {
throw new BusinessRuleException("sort must be one of " + SORTS);
}
Product product = products.findByIdAndActiveTrue(productId)
.orElseThrow(() -> new NotFoundException("Product not found: " + productId));
List<Review> published = reviews.findByProductIdAndStatus(productId, Review.PUBLISHED);
Map<Long, String> names = reviewerNames(published);
List<ReviewResponse> list = published.stream()
.sorted(comparator(s))
.map(r -> toResponse(r, names.get(r.getUserId())))
.toList();
RatingSummary summary = summarize(reviews.ratingHistogram(productId));
return new ProductReviews(product.getId(), summary.average(), summary.count(), summary.distribution(), list);
}
// ---------- admin moderation ----------
@Transactional(readOnly = true)
public List<ReviewResponse> adminList(String status) {
List<Review> list;
if (status == null || status.isBlank()) {
list = reviews.findAllByOrderByIdDesc();
} else {
String st = status.trim().toUpperCase();
if (!st.equals(Review.PUBLISHED) && !st.equals(Review.HIDDEN)) {
throw new BusinessRuleException("status must be PUBLISHED or HIDDEN");
}
list = reviews.findByStatusOrderByIdDesc(st);
}
Map<Long, String> names = reviewerNames(list);
return list.stream().map(r -> toResponse(r, names.get(r.getUserId()))).toList();
}
public ReviewResponse moderate(Long reviewId, String newStatus) {
Review r = reviews.findById(reviewId).orElseThrow(() -> new NotFoundException("Review not found: " + reviewId));
Product product = lockedProduct(r.getProductId());
r.setStatus(newStatus);
reviews.saveAndFlush(r);
recalculate(product);
return toResponse(r, users.findById(r.getUserId()).map(AppUser::getEmail).orElse(null));
}
// ---------- rating maths (pure, unit tested) ----------
/** Turns [rating, count] rows into average (1 decimal, HALF_UP), total count and a 5..1 distribution. */
static RatingSummary summarize(List<Object[]> histogram) {
Map<Integer, Long> distribution = new LinkedHashMap<>();
for (int star = 5; star >= 1; star--) distribution.put(star, 0L);
long count = 0;
long sum = 0;
for (Object[] row : histogram) {
int star = ((Number) row[0]).intValue();
long n = ((Number) row[1]).longValue();
distribution.put(star, n);
count += n;
sum += star * n;
}
BigDecimal average = count == 0
? BigDecimal.ZERO.setScale(1)
: BigDecimal.valueOf(sum).divide(BigDecimal.valueOf(count), 1, RoundingMode.HALF_UP);
return new RatingSummary(average, (int) count, distribution);
}
/** "jane.doe@x.com" -> "ja***". Short local parts keep only the first character. */
static String mask(String email) {
if (email == null || email.isBlank()) return "Customer";
String local = email.substring(0, Math.max(email.indexOf('@'), 0));
if (local.isEmpty()) local = email;
return (local.length() <= 2 ? local.substring(0, 1) : local.substring(0, 2)) + "***";
}
// ---------- helpers ----------
private void recalculate(Product product) {
RatingSummary s = summarize(reviews.ratingHistogram(product.getId()));
product.applyRating(s.average(), s.count());
products.save(product);
}
private static Comparator<Review> comparator(String sort) {
Comparator<Review> newest = Comparator.comparing(Review::getCreatedAt).thenComparing(Review::getId).reversed();
return switch (sort) {
case "highest" -> Comparator.comparingInt(Review::getRating).reversed().thenComparing(newest);
case "lowest" -> Comparator.comparingInt(Review::getRating).thenComparing(newest);
default -> newest;
};
}
private Map<Long, String> reviewerNames(List<Review> list) {
Set<Long> ids = list.stream().map(Review::getUserId).collect(Collectors.toSet());
return users.findAllById(ids).stream().collect(Collectors.toMap(AppUser::getId, AppUser::getEmail, (a, b) -> a));
}
private Product lockedProduct(Long productId) {
return products.lockById(productId).orElseThrow(() -> new NotFoundException("Product not found: " + productId));
}
private Review owned(Long reviewId, Long userId) {
return reviews.findByIdAndUserId(reviewId, userId)
.orElseThrow(() -> new NotFoundException("Review not found: " + reviewId));
}
private ReviewResponse toResponse(Review r, String reviewerEmail) {
return new ReviewResponse(r.getId(), r.getProductId(), r.getRating(), r.getTitle(), r.getBody(),
mask(reviewerEmail), true, r.getStatus(), r.getCreatedAt(), r.getUpdatedAt());
}
private static String clean(String s) {
return s == null || s.isBlank() ? null : s.trim();
}
private LocalDateTime now() {
return LocalDateTime.now(clock);
}
private AppUser user(String email) {
return users.findByEmail(email).orElseThrow(() -> new NotFoundException("User not found"));
}
}