Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/main/java/com/knoc/global/exception/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ public enum ErrorCode {
// 리뷰 요청서 관련 (Review Request)
REVIEW_REQUEST_ALREADY_EXISTS(409, "이미 해당 주문에 대한 리뷰 요청서가 존재합니다."),
REVIEW_REQUEST_NOT_ALLOWED(403, "결제 완료된 주문만 리뷰 요청서를 작성할 수 있습니다."),
REVIEW_REQUEST_NOT_FOUND(404, "리뷰 요청서가 존재하지 않습니다."),

// 리뷰 리포트 관련 (Review Report)
REVIEW_REQUEST_REQUIRED_FOR_REPORT(400, "리뷰 요청서가 제출된 경우에만 리포트를 작성할 수 있습니다."),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
package com.knoc.review.controller;

import com.knoc.global.exception.BusinessException;
import com.knoc.global.exception.ErrorCode;
import com.knoc.member.Member;
import com.knoc.member.MemberRepository;
import com.knoc.review.dto.ReviewRequestCreateRequest;
import com.knoc.review.dto.ReviewRequestCreateResponse;
import com.knoc.review.dto.ReviewRequestUpdateRequest;
import com.knoc.review.service.ReviewRequestService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
Expand All @@ -15,27 +12,30 @@
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;
import java.util.Map;

@Tag(name="Review-Request-Controller",description = "리뷰 요청서 발행 API")
@RestController
@RequiredArgsConstructor
@PreAuthorize("hasRole('USER')")
@RequestMapping(value = "/reviews")
public class ReviewRequestController {
private final MemberRepository memberRepository;
private final ReviewRequestService reviewRequestService;

@Operation(summary = "리뷰 요청서 생성", description = "주니어가 결제 완료된 주문에 대해 리뷰 요청서를 생성합니다.")
@PostMapping(value = "/request")
public ResponseEntity<ReviewRequestCreateResponse> request(@AuthenticationPrincipal UserDetails userDetails,
@RequestBody @Valid ReviewRequestCreateRequest dto) {
Member junior = memberRepository.findByEmail(userDetails.getUsername())
.orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND));
ReviewRequestCreateResponse response = reviewRequestService.createReviewRequest(dto, junior.getId());
ReviewRequestCreateResponse response = reviewRequestService.createReviewRequest(dto, userDetails.getUsername());
return ResponseEntity.ok(response);
}

@Operation(summary = "리뷰 요청서 수정", description = "주니어가 리포트 작성 전까지 리뷰 요청서(PR 링크/요청 정보)를 수정합니다.")
@PatchMapping("/request")
public ResponseEntity<Map<String, Long>> update(@AuthenticationPrincipal UserDetails userDetails,
@RequestBody @Valid ReviewRequestUpdateRequest req) {
Long orderId = reviewRequestService.updateReviewRequest(userDetails.getUsername(), req);
return ResponseEntity.ok(Map.of("orderId", orderId));
}
}
18 changes: 18 additions & 0 deletions src/main/java/com/knoc/review/dto/ReviewRequestUpdateRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.knoc.review.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;

public record ReviewRequestUpdateRequest(
@NotNull(message = "orderId는 필수입니다.")
Long orderId,

@NotBlank(message = "githubPrUrl은 필수입니다.")
String githubPrUrl,

@NotBlank(message = "projectContext는 필수입니다.")
String projectContext,

@NotBlank(message = "concernPoint는 필수입니다.")
String concernPoint
) {}
6 changes: 6 additions & 0 deletions src/main/java/com/knoc/review/entity/ReviewRequest.java
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,10 @@ public ReviewRequest(Order order, String githubPrUrl, String projectContext,
this.additions = additions;
this.deletions = deletions;
}

public void update(String githubPrUrl, String projectContext, String concernPoint) {
this.githubPrUrl = githubPrUrl;
this.projectContext = projectContext;
this.concernPoint = concernPoint;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,6 @@

public interface ReviewReportRepository extends JpaRepository<ReviewReport, Long> {
Optional<ReviewReport> findByReviewRequest(ReviewRequest reviewRequest);

boolean existsByReviewRequest_Order_Id(Long id);
}
56 changes: 42 additions & 14 deletions src/main/java/com/knoc/review/service/ReviewRequestService.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@
import com.knoc.chat.entity.MessageType;
import com.knoc.global.exception.BusinessException;
import com.knoc.global.exception.ErrorCode;
import com.knoc.member.Member;
import com.knoc.member.MemberRepository;
import com.knoc.order.entity.Order;
import com.knoc.order.entity.OrderStatus;
import com.knoc.order.repository.OrderRepository;
import com.knoc.review.dto.ReviewRequestCreateRequest;
import com.knoc.review.dto.ReviewRequestCreateResponse;
import com.knoc.review.dto.ReviewRequestUpdateRequest;
import com.knoc.review.entity.ReviewRequest;
import com.knoc.review.repository.ReviewReportRepository;
import com.knoc.review.repository.ReviewRequestRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
Expand All @@ -24,21 +28,11 @@ public class ReviewRequestService {
private final OrderRepository orderRepository;
private final ReviewRequestRepository reviewRequestRepository;
private final ApplicationEventPublisher eventPublisher;
private final MemberRepository memberRepository;
private final ReviewReportRepository reviewReportRepository;

public ReviewRequestCreateResponse createReviewRequest(ReviewRequestCreateRequest dto, Long juniorId) {
// 해당 주문 가져오기
Order order = orderRepository.findById(dto.getOrderId())
.orElseThrow(() -> new BusinessException(ErrorCode.ORDER_NOT_FOUND));

// 주니어 소유 검증
if (!order.getJunior().getId().equals(juniorId)) {
throw new BusinessException(ErrorCode.NOT_JUNIOR_FOR_ORDER);
}

// 상태 검증
if (order.getStatus() != OrderStatus.PAID) {
throw new BusinessException(ErrorCode.REVIEW_REQUEST_NOT_ALLOWED);
}
public ReviewRequestCreateResponse createReviewRequest(ReviewRequestCreateRequest dto, String email) {
Order order = juniorAndOrderValidation(email, dto.getOrderId());

// 중복 검증
if (reviewRequestRepository.existsByOrderId(order.getId())) {
Expand Down Expand Up @@ -71,4 +65,38 @@ public ReviewRequestCreateResponse createReviewRequest(ReviewRequestCreateReques
// 6. 저장된 주문을 클라이언트에게 보여줄 전용 응답 객체(DTO)로 변환
return ReviewRequestCreateResponse.from(reviewRequest);
}

@Transactional
public Long updateReviewRequest(String email, ReviewRequestUpdateRequest req) {
Order order = juniorAndOrderValidation(email, req.orderId());
if (reviewReportRepository.existsByReviewRequest_Order_Id(order.getId())) {
throw new BusinessException(ErrorCode.REVIEW_REPORT_ALREADY_EXISTS);
}
ReviewRequest rr = reviewRequestRepository.findByOrder(order)
.orElseThrow(() -> new BusinessException(ErrorCode.REVIEW_REQUEST_NOT_FOUND));
rr.update(req.githubPrUrl(), req.projectContext(), req.concernPoint());
return order.getId();
}

private Order juniorAndOrderValidation(String email, Long orderId) {
// juniorId 가져오기
Long juniorId = memberRepository.findByEmail(email)
.map(Member::getId)
.orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND));

// 해당 주문 가져오기
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new BusinessException(ErrorCode.ORDER_NOT_FOUND));

// 주니어 소유 검증
if (!order.getJunior().getId().equals(juniorId)) {
throw new BusinessException(ErrorCode.NOT_JUNIOR_FOR_ORDER);
}

// 상태 검증
if (order.getStatus() != OrderStatus.PAID) {
throw new BusinessException(ErrorCode.REVIEW_REQUEST_NOT_ALLOWED);
}
return order;
}
}
122 changes: 122 additions & 0 deletions src/main/resources/static/css/review-request-modal.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/* 공용: 리뷰 요청서 모달 (chat fragment 재사용) */
.pmt-modal {
display: none;
position: fixed;
inset: 0;
z-index: 1100;
align-items: center;
justify-content: center;
padding: 1rem;
}
.pmt-modal.is-open { display: flex; }

.pmt-modal-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.82);
}

.pmt-modal-card {
position: relative;
width: min(560px, calc(100% - 2rem));
background: linear-gradient(160deg, #0e1c35 0%, #091526 60%, #0c1a30 100%);
border: 1px solid #1e3a5a;
border-radius: 20px;
box-shadow: 0 24px 60px rgba(0,0,0,0.65);
padding: 1.6rem 1.6rem 1.2rem;
color: #fff;
}

.pmt-modal-header {
display: flex;
align-items: flex-start;
gap: 0.85rem;
margin-bottom: 1.1rem;
}
.pmt-modal-icon {
flex: 0 0 auto;
width: 44px;
height: 44px;
border-radius: 12px;
background: rgba(59, 130, 246, 0.12);
color: #93c5fd;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.1rem;
}
.pmt-modal-title {
margin: 0 0 0.25rem 0;
font-size: 1.2rem;
font-weight: 800;
}
.pmt-modal-subtitle {
margin: 0;
font-size: 0.8rem;
color: #7aa8cc;
line-height: 1.55;
}

.review-req-label {
display: block;
margin: 0 0 8px;
font-size: 0.72rem;
letter-spacing: 0.08em;
font-weight: 800;
color: rgba(148, 163, 184, 0.95);
text-transform: uppercase;
}
.review-req-input,
.review-req-textarea {
width: 100%;
border: 1px solid rgba(148, 163, 184, 0.22);
background: rgba(15, 23, 42, 0.6);
color: #e5e7eb;
border-radius: 12px;
padding: 0.75rem 0.85rem;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
.review-req-input:focus,
.review-req-textarea:focus {
border-color: rgba(96, 165, 250, 0.55);
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.12);
}
.review-req-textarea { resize: vertical; min-height: 120px; line-height: 1.35; }

.pmt-error {
margin: 0.75rem 0 0 0;
padding: 0.6rem 0.75rem;
font-size: 0.78rem;
background: rgba(255, 82, 82, 0.1);
border: 1px solid rgba(255, 82, 82, 0.35);
border-radius: 8px;
color: #ff7a7a;
}

.pmt-divider { height: 1px; background: #2a2d3e; margin: 1rem 0 0.9rem 0; }
.pmt-modal-footer { display: flex; align-items: center; justify-content: flex-end; gap: 0.75rem; }
.pmt-btn { cursor: pointer; border: none; font-weight: 800; }
.pmt-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.pmt-btn-text {
background: transparent;
color: #9ca3af;
padding: 0.6rem 1rem;
font-size: 0.85rem;
border-radius: 10px;
}
.pmt-btn-text:hover:not(:disabled) { color: #fff; }
.pmt-btn-primary {
display: inline-flex;
align-items: center;
gap: 0.4rem;
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%);
color: #0b1220;
padding: 0.7rem 1.25rem;
font-size: 0.9rem;
border-radius: 999px;
box-shadow: 0 6px 18px rgba(59, 130, 246, 0.42);
}
.pmt-btn-primary:hover:not(:disabled) { filter: brightness(1.05); }
.pmt-btn-arrow { font-size: 1.1rem; line-height: 1; font-weight: 900; transform: translateY(-1px); }

Loading
Loading