Skip to content

Commit 7ae353c

Browse files
Merge pull request #141 from prgrms-aibe-devcourse/feat/#138-review-request-update-UX
feat: PR 메타데이터 로드 실패 시 주니어가 리뷰 요청서 수정/재제출 가능하도록 UX 추가
2 parents d2bdd5f + 02e4c8c commit 7ae353c

12 files changed

Lines changed: 586 additions & 167 deletions

File tree

src/main/java/com/knoc/global/exception/ErrorCode.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ public enum ErrorCode {
4545
// 리뷰 요청서 관련 (Review Request)
4646
REVIEW_REQUEST_ALREADY_EXISTS(409, "이미 해당 주문에 대한 리뷰 요청서가 존재합니다."),
4747
REVIEW_REQUEST_NOT_ALLOWED(403, "결제 완료된 주문만 리뷰 요청서를 작성할 수 있습니다."),
48+
REVIEW_REQUEST_NOT_FOUND(404, "리뷰 요청서가 존재하지 않습니다."),
4849

4950
// 리뷰 리포트 관련 (Review Report)
5051
REVIEW_REQUEST_REQUIRED_FOR_REPORT(400, "리뷰 요청서가 제출된 경우에만 리포트를 작성할 수 있습니다."),
Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
11
package com.knoc.review.controller;
22

3-
import com.knoc.global.exception.BusinessException;
4-
import com.knoc.global.exception.ErrorCode;
5-
import com.knoc.member.Member;
6-
import com.knoc.member.MemberRepository;
73
import com.knoc.review.dto.ReviewRequestCreateRequest;
84
import com.knoc.review.dto.ReviewRequestCreateResponse;
5+
import com.knoc.review.dto.ReviewRequestUpdateRequest;
96
import com.knoc.review.service.ReviewRequestService;
107
import io.swagger.v3.oas.annotations.Operation;
118
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -15,27 +12,30 @@
1512
import org.springframework.security.access.prepost.PreAuthorize;
1613
import org.springframework.security.core.annotation.AuthenticationPrincipal;
1714
import org.springframework.security.core.userdetails.UserDetails;
18-
import org.springframework.web.bind.annotation.PostMapping;
19-
import org.springframework.web.bind.annotation.RequestBody;
20-
import org.springframework.web.bind.annotation.RequestMapping;
21-
import org.springframework.web.bind.annotation.RestController;
15+
import org.springframework.web.bind.annotation.*;
16+
import java.util.Map;
2217

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

3226
@Operation(summary = "리뷰 요청서 생성", description = "주니어가 결제 완료된 주문에 대해 리뷰 요청서를 생성합니다.")
3327
@PostMapping(value = "/request")
3428
public ResponseEntity<ReviewRequestCreateResponse> request(@AuthenticationPrincipal UserDetails userDetails,
3529
@RequestBody @Valid ReviewRequestCreateRequest dto) {
36-
Member junior = memberRepository.findByEmail(userDetails.getUsername())
37-
.orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND));
38-
ReviewRequestCreateResponse response = reviewRequestService.createReviewRequest(dto, junior.getId());
30+
ReviewRequestCreateResponse response = reviewRequestService.createReviewRequest(dto, userDetails.getUsername());
3931
return ResponseEntity.ok(response);
4032
}
33+
34+
@Operation(summary = "리뷰 요청서 수정", description = "주니어가 리포트 작성 전까지 리뷰 요청서(PR 링크/요청 정보)를 수정합니다.")
35+
@PatchMapping("/request")
36+
public ResponseEntity<Map<String, Long>> update(@AuthenticationPrincipal UserDetails userDetails,
37+
@RequestBody @Valid ReviewRequestUpdateRequest req) {
38+
Long orderId = reviewRequestService.updateReviewRequest(userDetails.getUsername(), req);
39+
return ResponseEntity.ok(Map.of("orderId", orderId));
40+
}
4141
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.knoc.review.dto;
2+
3+
import jakarta.validation.constraints.NotBlank;
4+
import jakarta.validation.constraints.NotNull;
5+
6+
public record ReviewRequestUpdateRequest(
7+
@NotNull(message = "orderId는 필수입니다.")
8+
Long orderId,
9+
10+
@NotBlank(message = "githubPrUrl은 필수입니다.")
11+
String githubPrUrl,
12+
13+
@NotBlank(message = "projectContext는 필수입니다.")
14+
String projectContext,
15+
16+
@NotBlank(message = "concernPoint는 필수입니다.")
17+
String concernPoint
18+
) {}

src/main/java/com/knoc/review/entity/ReviewRequest.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,4 +62,10 @@ public ReviewRequest(Order order, String githubPrUrl, String projectContext,
6262
this.additions = additions;
6363
this.deletions = deletions;
6464
}
65+
66+
public void update(String githubPrUrl, String projectContext, String concernPoint) {
67+
this.githubPrUrl = githubPrUrl;
68+
this.projectContext = projectContext;
69+
this.concernPoint = concernPoint;
70+
}
6571
}

src/main/java/com/knoc/review/repository/ReviewReportRepository.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,6 @@
88

99
public interface ReviewReportRepository extends JpaRepository<ReviewReport, Long> {
1010
Optional<ReviewReport> findByReviewRequest(ReviewRequest reviewRequest);
11+
12+
boolean existsByReviewRequest_Order_Id(Long id);
1113
}

src/main/java/com/knoc/review/service/ReviewRequestService.java

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,16 @@
44
import com.knoc.chat.entity.MessageType;
55
import com.knoc.global.exception.BusinessException;
66
import com.knoc.global.exception.ErrorCode;
7+
import com.knoc.member.Member;
8+
import com.knoc.member.MemberRepository;
79
import com.knoc.order.entity.Order;
810
import com.knoc.order.entity.OrderStatus;
911
import com.knoc.order.repository.OrderRepository;
1012
import com.knoc.review.dto.ReviewRequestCreateRequest;
1113
import com.knoc.review.dto.ReviewRequestCreateResponse;
14+
import com.knoc.review.dto.ReviewRequestUpdateRequest;
1215
import com.knoc.review.entity.ReviewRequest;
16+
import com.knoc.review.repository.ReviewReportRepository;
1317
import com.knoc.review.repository.ReviewRequestRepository;
1418
import lombok.RequiredArgsConstructor;
1519
import org.springframework.context.ApplicationEventPublisher;
@@ -24,21 +28,11 @@ public class ReviewRequestService {
2428
private final OrderRepository orderRepository;
2529
private final ReviewRequestRepository reviewRequestRepository;
2630
private final ApplicationEventPublisher eventPublisher;
31+
private final MemberRepository memberRepository;
32+
private final ReviewReportRepository reviewReportRepository;
2733

28-
public ReviewRequestCreateResponse createReviewRequest(ReviewRequestCreateRequest dto, Long juniorId) {
29-
// 해당 주문 가져오기
30-
Order order = orderRepository.findById(dto.getOrderId())
31-
.orElseThrow(() -> new BusinessException(ErrorCode.ORDER_NOT_FOUND));
32-
33-
// 주니어 소유 검증
34-
if (!order.getJunior().getId().equals(juniorId)) {
35-
throw new BusinessException(ErrorCode.NOT_JUNIOR_FOR_ORDER);
36-
}
37-
38-
// 상태 검증
39-
if (order.getStatus() != OrderStatus.PAID) {
40-
throw new BusinessException(ErrorCode.REVIEW_REQUEST_NOT_ALLOWED);
41-
}
34+
public ReviewRequestCreateResponse createReviewRequest(ReviewRequestCreateRequest dto, String email) {
35+
Order order = juniorAndOrderValidation(email, dto.getOrderId());
4236

4337
// 중복 검증
4438
if (reviewRequestRepository.existsByOrderId(order.getId())) {
@@ -71,4 +65,38 @@ public ReviewRequestCreateResponse createReviewRequest(ReviewRequestCreateReques
7165
// 6. 저장된 주문을 클라이언트에게 보여줄 전용 응답 객체(DTO)로 변환
7266
return ReviewRequestCreateResponse.from(reviewRequest);
7367
}
68+
69+
@Transactional
70+
public Long updateReviewRequest(String email, ReviewRequestUpdateRequest req) {
71+
Order order = juniorAndOrderValidation(email, req.orderId());
72+
if (reviewReportRepository.existsByReviewRequest_Order_Id(order.getId())) {
73+
throw new BusinessException(ErrorCode.REVIEW_REPORT_ALREADY_EXISTS);
74+
}
75+
ReviewRequest rr = reviewRequestRepository.findByOrder(order)
76+
.orElseThrow(() -> new BusinessException(ErrorCode.REVIEW_REQUEST_NOT_FOUND));
77+
rr.update(req.githubPrUrl(), req.projectContext(), req.concernPoint());
78+
return order.getId();
79+
}
80+
81+
private Order juniorAndOrderValidation(String email, Long orderId) {
82+
// juniorId 가져오기
83+
Long juniorId = memberRepository.findByEmail(email)
84+
.map(Member::getId)
85+
.orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND));
86+
87+
// 해당 주문 가져오기
88+
Order order = orderRepository.findById(orderId)
89+
.orElseThrow(() -> new BusinessException(ErrorCode.ORDER_NOT_FOUND));
90+
91+
// 주니어 소유 검증
92+
if (!order.getJunior().getId().equals(juniorId)) {
93+
throw new BusinessException(ErrorCode.NOT_JUNIOR_FOR_ORDER);
94+
}
95+
96+
// 상태 검증
97+
if (order.getStatus() != OrderStatus.PAID) {
98+
throw new BusinessException(ErrorCode.REVIEW_REQUEST_NOT_ALLOWED);
99+
}
100+
return order;
101+
}
74102
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/* 공용: 리뷰 요청서 모달 (chat fragment 재사용) */
2+
.pmt-modal {
3+
display: none;
4+
position: fixed;
5+
inset: 0;
6+
z-index: 1100;
7+
align-items: center;
8+
justify-content: center;
9+
padding: 1rem;
10+
}
11+
.pmt-modal.is-open { display: flex; }
12+
13+
.pmt-modal-backdrop {
14+
position: absolute;
15+
inset: 0;
16+
background: rgba(0, 0, 0, 0.82);
17+
}
18+
19+
.pmt-modal-card {
20+
position: relative;
21+
width: min(560px, calc(100% - 2rem));
22+
background: linear-gradient(160deg, #0e1c35 0%, #091526 60%, #0c1a30 100%);
23+
border: 1px solid #1e3a5a;
24+
border-radius: 20px;
25+
box-shadow: 0 24px 60px rgba(0,0,0,0.65);
26+
padding: 1.6rem 1.6rem 1.2rem;
27+
color: #fff;
28+
}
29+
30+
.pmt-modal-header {
31+
display: flex;
32+
align-items: flex-start;
33+
gap: 0.85rem;
34+
margin-bottom: 1.1rem;
35+
}
36+
.pmt-modal-icon {
37+
flex: 0 0 auto;
38+
width: 44px;
39+
height: 44px;
40+
border-radius: 12px;
41+
background: rgba(59, 130, 246, 0.12);
42+
color: #93c5fd;
43+
display: flex;
44+
align-items: center;
45+
justify-content: center;
46+
font-size: 1.1rem;
47+
}
48+
.pmt-modal-title {
49+
margin: 0 0 0.25rem 0;
50+
font-size: 1.2rem;
51+
font-weight: 800;
52+
}
53+
.pmt-modal-subtitle {
54+
margin: 0;
55+
font-size: 0.8rem;
56+
color: #7aa8cc;
57+
line-height: 1.55;
58+
}
59+
60+
.review-req-label {
61+
display: block;
62+
margin: 0 0 8px;
63+
font-size: 0.72rem;
64+
letter-spacing: 0.08em;
65+
font-weight: 800;
66+
color: rgba(148, 163, 184, 0.95);
67+
text-transform: uppercase;
68+
}
69+
.review-req-input,
70+
.review-req-textarea {
71+
width: 100%;
72+
border: 1px solid rgba(148, 163, 184, 0.22);
73+
background: rgba(15, 23, 42, 0.6);
74+
color: #e5e7eb;
75+
border-radius: 12px;
76+
padding: 0.75rem 0.85rem;
77+
outline: none;
78+
transition: border-color 0.15s, box-shadow 0.15s;
79+
}
80+
.review-req-input:focus,
81+
.review-req-textarea:focus {
82+
border-color: rgba(96, 165, 250, 0.55);
83+
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.12);
84+
}
85+
.review-req-textarea { resize: vertical; min-height: 120px; line-height: 1.35; }
86+
87+
.pmt-error {
88+
margin: 0.75rem 0 0 0;
89+
padding: 0.6rem 0.75rem;
90+
font-size: 0.78rem;
91+
background: rgba(255, 82, 82, 0.1);
92+
border: 1px solid rgba(255, 82, 82, 0.35);
93+
border-radius: 8px;
94+
color: #ff7a7a;
95+
}
96+
97+
.pmt-divider { height: 1px; background: #2a2d3e; margin: 1rem 0 0.9rem 0; }
98+
.pmt-modal-footer { display: flex; align-items: center; justify-content: flex-end; gap: 0.75rem; }
99+
.pmt-btn { cursor: pointer; border: none; font-weight: 800; }
100+
.pmt-btn:disabled { opacity: 0.6; cursor: not-allowed; }
101+
.pmt-btn-text {
102+
background: transparent;
103+
color: #9ca3af;
104+
padding: 0.6rem 1rem;
105+
font-size: 0.85rem;
106+
border-radius: 10px;
107+
}
108+
.pmt-btn-text:hover:not(:disabled) { color: #fff; }
109+
.pmt-btn-primary {
110+
display: inline-flex;
111+
align-items: center;
112+
gap: 0.4rem;
113+
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%);
114+
color: #0b1220;
115+
padding: 0.7rem 1.25rem;
116+
font-size: 0.9rem;
117+
border-radius: 999px;
118+
box-shadow: 0 6px 18px rgba(59, 130, 246, 0.42);
119+
}
120+
.pmt-btn-primary:hover:not(:disabled) { filter: brightness(1.05); }
121+
.pmt-btn-arrow { font-size: 1.1rem; line-height: 1; font-weight: 900; transform: translateY(-1px); }
122+

0 commit comments

Comments
 (0)