diff --git a/src/main/java/kr/mayb/controller/OrderController.java b/src/main/java/kr/mayb/controller/OrderController.java index c8d3245..d595aff 100644 --- a/src/main/java/kr/mayb/controller/OrderController.java +++ b/src/main/java/kr/mayb/controller/OrderController.java @@ -63,7 +63,7 @@ public ResponseEntity>>> @PatchMapping("orders/{orderId}/members/{memberId}/payment-status") public ResponseEntity> updatePaymentStatus(@PathVariable long orderId, @PathVariable long memberId, - @RequestBody @Valid OrderController.PaymentStatusUpdateRequest request) { + @RequestBody @Valid PaymentStatusUpdateRequest request) { OrderInfo response = orderFacade.updatePaymentStatus(orderId, memberId, request.status()); return Responses.ok(response); diff --git a/src/main/java/kr/mayb/controller/ReviewController.java b/src/main/java/kr/mayb/controller/ReviewController.java new file mode 100644 index 0000000..f6d532e --- /dev/null +++ b/src/main/java/kr/mayb/controller/ReviewController.java @@ -0,0 +1,102 @@ +package kr.mayb.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import kr.mayb.dto.ImageDto; +import kr.mayb.dto.ReviewDto; +import kr.mayb.dto.ReviewRequest; +import kr.mayb.error.BadRequestException; +import kr.mayb.facade.ReviewFacade; +import kr.mayb.security.PermitAll; +import kr.mayb.security.PermitAuthenticated; +import kr.mayb.util.request.PageRequest; +import kr.mayb.util.response.ApiResponse; +import kr.mayb.util.response.PageResponse; +import kr.mayb.util.response.Responses; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +@Tag(name = "Review", description = "리뷰 API") +@RestController +@RequiredArgsConstructor +public class ReviewController { + + private final ReviewFacade reviewFacade; + + @Operation(summary = "상품 리뷰 작성") + @PermitAuthenticated + @PostMapping("/reviews") + public ResponseEntity> writeReview(@RequestPart("review") ReviewRequest request, + @RequestPart(value = "images", required = false) List images) { + if (images.size() > 5) { + throw new BadRequestException("Review images must be less than 5"); + } + + ReviewDto response = reviewFacade.writeReview(request, images); + return Responses.ok(response); + } + + @Operation(summary = "상품 리뷰 상세 조회") + @PermitAll + @GetMapping("/reviews/{reviewId}") + public ResponseEntity> getReview(@PathVariable long reviewId) { + ReviewDto response = reviewFacade.getReview(reviewId); + return Responses.ok(response); + } + + @Operation(summary = "상품 리뷰 조회") + @PermitAll + @GetMapping("/reviews") + public ResponseEntity>> getReviews(@RequestParam(value = "pid") long productId, + PageRequest pageRequest) { + PageResponse response = reviewFacade.getReviews(productId, pageRequest); + return Responses.ok(response); + } + + @Operation(summary = "상품 리뷰 수정") + @PermitAuthenticated + @PutMapping("/reviews/{reviewId}") + public ResponseEntity> updateReview(@PathVariable long reviewId, @RequestBody @Valid ReviewUpdateRequest request) { + ReviewDto response = reviewFacade.updateReview(reviewId, request.content(), request.starRating()); + return Responses.ok(response); + } + + @Operation(summary = "상품 리뷰 이미지 추가 (리뷰 수정 시)") + @PermitAuthenticated + @PostMapping("/reviews/{reviewId}/images") + public ResponseEntity> addReviewImage(@PathVariable long reviewId, @RequestParam("image") MultipartFile image) { + ImageDto response = reviewFacade.addReviewImage(reviewId, image); + return Responses.ok(response); + } + + @Operation(summary = "상품 리뷰 이미지 삭제 (리뷰 수정 시)") + @PermitAuthenticated + @DeleteMapping("/reviews/{reviewId}/images/{imageId}") + public ResponseEntity removeReviewImage(@PathVariable long reviewId, @PathVariable long imageId) { + reviewFacade.removeReviewImage(reviewId, imageId); + return Responses.noContent(); + } + + @Operation(summary = "상품 리뷰 삭제") + @PermitAuthenticated + @DeleteMapping("/reviews/{reviewId}") + public ResponseEntity removeReview(@PathVariable long reviewId) { + reviewFacade.removeReview(reviewId); + return Responses.noContent(); + } + + private record ReviewUpdateRequest( + @NotBlank + String content, + @NotNull + int starRating + ) { + } +} diff --git a/src/main/java/kr/mayb/data/model/Member.java b/src/main/java/kr/mayb/data/model/Member.java index 50e3efc..f3b2234 100644 --- a/src/main/java/kr/mayb/data/model/Member.java +++ b/src/main/java/kr/mayb/data/model/Member.java @@ -6,6 +6,7 @@ import kr.mayb.enums.Gender; import lombok.Getter; import lombok.Setter; +import org.apache.commons.lang3.StringUtils; import org.hibernate.annotations.BatchSize; import java.time.LocalDate; @@ -65,4 +66,15 @@ public class Member extends BaseEntity{ @Column @Enumerated(EnumType.STRING) private AccountStatus status; + + public String getMaskedName() { + if (StringUtils.isBlank(this.name) || this.name.length() <= 1) { + return this.name; + } + + String firstChar = this.name.substring(0, 1); + String masked = this.name.substring(1).replaceAll("\\.", "*"); + + return firstChar + masked; + } } diff --git a/src/main/java/kr/mayb/data/model/Review.java b/src/main/java/kr/mayb/data/model/Review.java new file mode 100644 index 0000000..0d363ae --- /dev/null +++ b/src/main/java/kr/mayb/data/model/Review.java @@ -0,0 +1,51 @@ +package kr.mayb.data.model; + +import jakarta.persistence.*; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.BatchSize; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Getter +@Setter +@Table(schema = "mayb") +@Entity +public class Review extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private long id; + + @Column(nullable = false) + private String content; + + @Min(1) + @Max(5) + @Column(nullable = false) + private int starRating; + + @Column + private String gender; + + @Column + private LocalDateTime scheduledAt; + + @Column + private long productId; + + @Column + private long orderId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @BatchSize(size = 10) + @OneToMany(mappedBy = "review", cascade = CascadeType.ALL) + private List reviewImages = new ArrayList<>(); +} diff --git a/src/main/java/kr/mayb/data/model/ReviewImage.java b/src/main/java/kr/mayb/data/model/ReviewImage.java new file mode 100644 index 0000000..a89fb91 --- /dev/null +++ b/src/main/java/kr/mayb/data/model/ReviewImage.java @@ -0,0 +1,23 @@ +package kr.mayb.data.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Table(schema = "mayb") +@Entity +public class ReviewImage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private long id; + + @Column + private String imageUrl; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "review_id", nullable = false) + private Review review; +} diff --git a/src/main/java/kr/mayb/data/repository/ReviewImageRepository.java b/src/main/java/kr/mayb/data/repository/ReviewImageRepository.java new file mode 100644 index 0000000..c7eb8ca --- /dev/null +++ b/src/main/java/kr/mayb/data/repository/ReviewImageRepository.java @@ -0,0 +1,7 @@ +package kr.mayb.data.repository; + +import kr.mayb.data.model.ReviewImage; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReviewImageRepository extends JpaRepository { +} diff --git a/src/main/java/kr/mayb/data/repository/ReviewRepository.java b/src/main/java/kr/mayb/data/repository/ReviewRepository.java new file mode 100644 index 0000000..d7f6494 --- /dev/null +++ b/src/main/java/kr/mayb/data/repository/ReviewRepository.java @@ -0,0 +1,12 @@ +package kr.mayb.data.repository; + +import kr.mayb.data.model.Review; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReviewRepository extends JpaRepository { + @EntityGraph(attributePaths = {"reviewImages", "member"}) + Page findAllByProductId(long productId, Pageable pageable); +} diff --git a/src/main/java/kr/mayb/dto/ImageDto.java b/src/main/java/kr/mayb/dto/ImageDto.java new file mode 100644 index 0000000..3cf8932 --- /dev/null +++ b/src/main/java/kr/mayb/dto/ImageDto.java @@ -0,0 +1,12 @@ +package kr.mayb.dto; + +import kr.mayb.data.model.ReviewImage; + +public record ImageDto( + long reviewImageId, + String imageUrl +) { + public static ImageDto of(ReviewImage reviewImage) { + return new ImageDto(reviewImage.getId(), reviewImage.getImageUrl()); + } +} diff --git a/src/main/java/kr/mayb/dto/ReviewDto.java b/src/main/java/kr/mayb/dto/ReviewDto.java new file mode 100644 index 0000000..611235c --- /dev/null +++ b/src/main/java/kr/mayb/dto/ReviewDto.java @@ -0,0 +1,53 @@ +package kr.mayb.dto; + +import kr.mayb.data.model.Member; +import kr.mayb.data.model.Review; + +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.util.List; + +public record ReviewDto( + long reviewId, + String content, + String author, + int starRating, + String gender, + LocalDateTime scheduledAt, + long memberId, + List images, + OffsetDateTime createdAt, + boolean isMyReview +) { + private ReviewDto(Review review, List images, Member author, boolean isMyReview) { + this( + review.getId(), + review.getContent(), + author.getMaskedName(), + review.getStarRating(), + review.getGender(), + review.getScheduledAt(), + author.getId(), + images, + review.getCreatedAt(), + isMyReview + ); + } + + public static ReviewDto of(Review review) { + Member author = review.getMember(); + List images = review.getReviewImages().stream().map(ImageDto::of).toList(); + return new ReviewDto(review, images, author, false); + } + + public static ReviewDto of(Review review, long currentMemberId) { + Member author = review.getMember(); + List images = review.getReviewImages().stream().map(ImageDto::of).toList(); + boolean isMyReview = isMyReview(author.getId(), currentMemberId); + return new ReviewDto(review, images, author, isMyReview); + } + + private static boolean isMyReview(long authorId, long currentMemberId) { + return authorId == currentMemberId; + } +} diff --git a/src/main/java/kr/mayb/dto/ReviewRequest.java b/src/main/java/kr/mayb/dto/ReviewRequest.java new file mode 100644 index 0000000..6dc6cc5 --- /dev/null +++ b/src/main/java/kr/mayb/dto/ReviewRequest.java @@ -0,0 +1,17 @@ +package kr.mayb.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record ReviewRequest( + long productId, + + long orderId, + + @Size(min = 1, max = 1000) + @NotBlank + String content, + + int starRating +) { +} diff --git a/src/main/java/kr/mayb/enums/GcsBucketPath.java b/src/main/java/kr/mayb/enums/GcsBucketPath.java index 10993d9..a37c4a2 100644 --- a/src/main/java/kr/mayb/enums/GcsBucketPath.java +++ b/src/main/java/kr/mayb/enums/GcsBucketPath.java @@ -8,6 +8,7 @@ public enum GcsBucketPath { PROFILE("profile/"), PRODUCT_PROFILE("product_profile/"), PRODUCT_DETAIL("product_detail/"), + REVIEW("review/") ; private final String value; @@ -17,6 +18,7 @@ public static String getPath(GcsBucketPath pathType) { case PROFILE -> PROFILE.value; case PRODUCT_PROFILE -> PRODUCT_PROFILE.value; case PRODUCT_DETAIL -> PRODUCT_DETAIL.value; + case REVIEW -> REVIEW.value; default -> throw new BadRequestException("Invalid GcsPathType" + pathType); }; } diff --git a/src/main/java/kr/mayb/enums/ReviewSort.java b/src/main/java/kr/mayb/enums/ReviewSort.java new file mode 100644 index 0000000..43a82f3 --- /dev/null +++ b/src/main/java/kr/mayb/enums/ReviewSort.java @@ -0,0 +1,32 @@ +package kr.mayb.enums; + +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.data.domain.Sort; + +import java.util.Arrays; +import java.util.Optional; + +@RequiredArgsConstructor +public enum ReviewSort { + NEWEST_FIRST("createdAt"), + RATING_DESC("starRating"), + RATING_ASC("starRating"), + ; + + private final String value; + + public static Optional find(String name) { + return Arrays.stream(ReviewSort.values()) + .filter(v -> StringUtils.equalsIgnoreCase(v.name(), name)) + .findFirst(); + } + + public Sort toSortOption() { + return switch (this) { + case NEWEST_FIRST -> Sort.by(Sort.Direction.DESC, NEWEST_FIRST.value); + case RATING_DESC -> Sort.by(Sort.Direction.DESC, RATING_DESC.value); + case RATING_ASC -> Sort.by(Sort.Direction.ASC, RATING_ASC.value); + }; + } +} diff --git a/src/main/java/kr/mayb/facade/ReviewFacade.java b/src/main/java/kr/mayb/facade/ReviewFacade.java new file mode 100644 index 0000000..4b906bd --- /dev/null +++ b/src/main/java/kr/mayb/facade/ReviewFacade.java @@ -0,0 +1,184 @@ +package kr.mayb.facade; + +import jakarta.transaction.Transactional; +import jakarta.validation.constraints.NotBlank; +import kr.mayb.data.model.Member; +import kr.mayb.data.model.Review; +import kr.mayb.data.model.ReviewImage; +import kr.mayb.dto.*; +import kr.mayb.enums.GcsBucketPath; +import kr.mayb.enums.PaymentStatus; +import kr.mayb.error.BadRequestException; +import kr.mayb.error.ExternalApiException; +import kr.mayb.error.ResourceNotFoundException; +import kr.mayb.service.*; +import kr.mayb.util.ContextUtils; +import kr.mayb.util.request.PageRequest; +import kr.mayb.util.response.PageResponse; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.tuple.Pair; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +@Component +@RequiredArgsConstructor +public class ReviewFacade { + + private final ReviewService reviewService; + private final ImageService imageService; + private final MemberService memberService; + private final ProductService productService; + private final OrderService orderService; + + private final ThreadPoolTaskExecutor executor; + + @Transactional + public ReviewDto writeReview(ReviewRequest request, List images) { + MemberDto member = ContextUtils.getCurrentMember() + .orElseThrow(() -> new BadRequestException("Only signed-in members can write reviews.")); + + Member author = memberService.getMember(member.getMemberId()); + Pair orderItem = getOrderedProduct(request.orderId(), request.productId(), author); + + if (images.isEmpty()) { + Review saved = reviewService.save(request, orderItem, List.of(), author); + orderService.updateReviewStatus(orderItem.getLeft()); + return ReviewDto.of(saved, author.getId()); + } + + List imageUrls = uploadImages(images); + Review saved = reviewService.save(request, orderItem, imageUrls, author); + orderService.updateReviewStatus(orderItem.getLeft()); + return ReviewDto.of(saved, author.getId()); + } + + public ReviewDto getReview(long reviewId) { + Review review = reviewService.findById(reviewId) + .orElseThrow(() -> new ResourceNotFoundException("Review not found. : " + reviewId)); + + return ReviewDto.of(review); + } + + public PageResponse getReviews(long productId, PageRequest pageRequest) { + Long currentMemberId = ContextUtils.getCurrentMember() + .map(MemberDto::getMemberId) + .orElse(null); + + Page reviews = reviewService.findAllByProductId(productId, pageRequest); + return PageResponse.of(new PageImpl<>(convertToReviewDto(reviews, currentMemberId), reviews.getPageable(), reviews.getTotalElements())); + } + + public ReviewDto updateReview(long reviewId, @NotBlank String content, int starRating) { + MemberDto member = ContextUtils.loadMember(); + Review review = reviewService.findById(reviewId) + .orElseThrow(() -> new ResourceNotFoundException("Review not found. : " + reviewId)); + + checkAuthor(review.getMember().getId(), member.getMemberId()); + + Review updated = reviewService.update(review, content, starRating); + return ReviewDto.of(updated, member.getMemberId()); + } + + public ImageDto addReviewImage(long reviewId, MultipartFile image) { + MemberDto member = ContextUtils.loadMember(); + Review review = reviewService.findById(reviewId) + .orElseThrow(() -> new ResourceNotFoundException("Review not found. : " + reviewId)); + + checkAuthor(review.getMember().getId(), member.getMemberId()); + + String imageUrl = imageService.upload(image, GcsBucketPath.REVIEW); + ReviewImage saved = reviewService.addImage(review, imageUrl); + + return ImageDto.of(saved); + } + + @Transactional + public void removeReviewImage(long reviewId, long imageId) { + MemberDto member = ContextUtils.loadMember(); + Review review = reviewService.findById(reviewId) + .orElseThrow(() -> new ResourceNotFoundException("Review not found. : " + reviewId)); + + checkAuthor(review.getMember().getId(), member.getMemberId()); + + ReviewImage image = reviewService.findImageById(imageId) + .orElseThrow(() -> new ResourceNotFoundException("Review image not found. : " + imageId)); + + imageService.delete(image.getImageUrl(), GcsBucketPath.REVIEW); + reviewService.removeImage(imageId); + } + + public void removeReview(long reviewId) { + MemberDto member = ContextUtils.loadMember(); + Review review = reviewService.findById(reviewId) + .orElseThrow(() -> new ResourceNotFoundException("Review not found. : " + reviewId)); + + checkAuthor(review.getMember().getId(), member.getMemberId()); + + reviewService.remove(review.getId()); + } + + private void checkAuthor(long authorId, long memberId) { + if (authorId != memberId) { + throw new AccessDeniedException("Only author can update review. : " + memberId); + } + } + + private Pair getOrderedProduct(long orderId, long productId, Member author) { + return orderService.find(orderId, author.getId()) + .filter(order -> order.getProductId() == productId) + .filter(orderOpt -> orderOpt.getPaymentStatus() == PaymentStatus.COMPLETED) + .map(orderOpt -> { + OrderedProductItem productItem = productService.findOrderedProductItem(productId, orderOpt.getId(), orderOpt.getProductScheduleId()); + return Pair.of(orderOpt.getId(), productItem); + }) + .orElseThrow(() -> new BadRequestException("Only members who have purchased the product can write reviews.")); + } + + private List uploadImages(List images) { + // Upload images asynchronously + Map> uploadImageAsyncMap = IntStream.range(0, images.size()) + .boxed() + .collect(Collectors.toMap( + index -> index, + index -> CompletableFuture.supplyAsync(() -> + imageService.upload(images.get(index), GcsBucketPath.REVIEW), executor) + .exceptionally(e -> { + throw new ExternalApiException("Failed to upload review Images" + e.getMessage()); + }) + )); + + // Wait for all asynchronous image uploads to complete, sort by original order, and return the list of URLs. + return uploadImageAsyncMap.entrySet() + .stream() + .sorted(Map.Entry.comparingByKey()) + .map(Map.Entry::getValue) + .map(CompletableFuture::join) + .toList(); + } + + private List convertToReviewDto(Page reviews, Long currentMemberId) { + return reviews.getContent() + .stream() + .filter(Objects::nonNull) + .map(review -> { + if (currentMemberId == null) { + return ReviewDto.of(review); + } + + // Check whether the review is written by the current signIn member + return ReviewDto.of(review, currentMemberId); + }) + .toList(); + } +} diff --git a/src/main/java/kr/mayb/service/ImageService.java b/src/main/java/kr/mayb/service/ImageService.java index 5f6936b..0c90763 100644 --- a/src/main/java/kr/mayb/service/ImageService.java +++ b/src/main/java/kr/mayb/service/ImageService.java @@ -44,8 +44,8 @@ private String generateUniqueFileName() { .toString(); } - public void delete(String profileUrl, GcsBucketPath pathType) { - String uuidName = profileUrl.substring(profileUrl.lastIndexOf("/") + 1); + public void delete(String imageUrl, GcsBucketPath pathType) { + String uuidName = imageUrl.substring(imageUrl.lastIndexOf("/") + 1); String fullBlobName = GcsBucketPath.getPath(pathType) + uuidName; gcsService.delete(fullBlobName); diff --git a/src/main/java/kr/mayb/service/OrderService.java b/src/main/java/kr/mayb/service/OrderService.java index 526f4a7..3b2fd72 100644 --- a/src/main/java/kr/mayb/service/OrderService.java +++ b/src/main/java/kr/mayb/service/OrderService.java @@ -16,6 +16,8 @@ import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; +import java.util.Optional; + @Service @RequiredArgsConstructor public class OrderService { @@ -53,11 +55,23 @@ public Page getOrders(Long productId, PaymentStatus paymentStatus, PageRe @Transactional public Order updatePaymentStatus(long orderId, long memberId, PaymentStatus paymentStatus) { - Order order = orderRepository.findByIdAndMemberId(orderId, memberId) + Order order = find(orderId, memberId) .orElseThrow(() -> new ResourceNotFoundException("There is no Order with orderId and memberId." + orderId + ", " + memberId)); order.setPaymentStatus(paymentStatus); return orderRepository.save(order); } + + @Transactional + public void updateReviewStatus(Long orderId) { + Order order = orderRepository.findById(orderId) + .orElseThrow(() -> new ResourceNotFoundException("There is no Order with orderId." + orderId)); + + order.setHasReviewed(true); + } + + public Optional find(long orderId, long memberId) { + return orderRepository.findByIdAndMemberId(orderId, memberId); + } } diff --git a/src/main/java/kr/mayb/service/ProductService.java b/src/main/java/kr/mayb/service/ProductService.java index 816c26b..d93a1f2 100644 --- a/src/main/java/kr/mayb/service/ProductService.java +++ b/src/main/java/kr/mayb/service/ProductService.java @@ -205,7 +205,7 @@ private ProductGenderPrice getGenderPrice(long priceId, Product product) { .orElseThrow(() -> new ResourceNotFoundException("Price not found: " + priceId)); } - private Product getProduct(long productId) { + public Product getProduct(long productId) { return productRepository.findById(productId) .orElseThrow(() -> new ResourceNotFoundException("Product not found: " + productId)); } diff --git a/src/main/java/kr/mayb/service/ReviewService.java b/src/main/java/kr/mayb/service/ReviewService.java new file mode 100644 index 0000000..a4b0d40 --- /dev/null +++ b/src/main/java/kr/mayb/service/ReviewService.java @@ -0,0 +1,109 @@ +package kr.mayb.service; + +import jakarta.transaction.Transactional; +import kr.mayb.data.model.Member; +import kr.mayb.data.model.Review; +import kr.mayb.data.model.ReviewImage; +import kr.mayb.data.repository.ReviewImageRepository; +import kr.mayb.data.repository.ReviewRepository; +import kr.mayb.dto.OrderedProductItem; +import kr.mayb.dto.ReviewRequest; +import kr.mayb.enums.ReviewSort; +import kr.mayb.util.request.PageRequest; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.tuple.Pair; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class ReviewService { + + private final ReviewRepository reviewRepository; + private final ReviewImageRepository reviewImageRepository; + + @Transactional + public Review save(ReviewRequest request, Pair orderItem, List imageUrls, Member author) { + Long orderId = orderItem.getLeft(); + OrderedProductItem orderedProduct = orderItem.getRight(); + long productId = orderedProduct.product().getId(); + String gender = orderedProduct.genderPrice().getGender(); + LocalDateTime scheduledAt = orderedProduct.schedule().getTimeSlot(); + + Review review = new Review(); + review.setContent(request.content()); + review.setStarRating(request.starRating()); + review.setGender(gender); + review.setScheduledAt(scheduledAt); + review.setProductId(productId); + review.setOrderId(orderId); + review.setMember(author); + + saveImages(review, imageUrls); + + return reviewRepository.save(review); + } + + private void saveImages(Review review, List imageUrls) { + List reviewImages = imageUrls.stream() + .map(url -> { + ReviewImage reviewImage = new ReviewImage(); + reviewImage.setReview(review); + reviewImage.setImageUrl(url); + return reviewImage; + }) + .toList(); + + review.setReviewImages(reviewImages); + } + + public Page findAllByProductId(long productId, PageRequest pageRequest) { + Sort sort = ReviewSort.find(pageRequest.getSort()) + .map(ReviewSort::toSortOption) + .orElse(ReviewSort.NEWEST_FIRST.toSortOption()); + Pageable pageable = pageRequest.toPageable(sort); + + return reviewRepository.findAllByProductId(productId, pageable); + } + + @Transactional + public Review update(Review review, String content, int starRating) { + review.setContent(content); + review.setStarRating(starRating); + + return reviewRepository.save(review); + } + + public Optional findById(long reviewId) { + return reviewRepository.findById(reviewId); + } + + @Transactional + public ReviewImage addImage(Review review, String imageUrl) { + ReviewImage reviewImage = new ReviewImage(); + reviewImage.setReview(review); + reviewImage.setImageUrl(imageUrl); + + return reviewImageRepository.save(reviewImage); + } + + @Transactional + public void removeImage(long imageId) { + reviewImageRepository.deleteById(imageId); + } + + @Transactional + public void remove(long reviewId) { + reviewRepository.deleteById(reviewId); + } + + public Optional findImageById(long imageId) { + return reviewImageRepository.findById(imageId); + } +}