diff --git a/backend/scripts/healthCheck.sh b/backend/scripts/healthCheck.sh index b6202bdab..1972de9a8 100644 --- a/backend/scripts/healthCheck.sh +++ b/backend/scripts/healthCheck.sh @@ -1,10 +1,10 @@ #!/bin/bash echo "> Health check 시작" -echo "> curl -s http://localhost:8080/actuator/health" +echo "> curl -s http://localhost:9100/actuator/health" for RETRY_COUNT in {1..15} do - RESPONSE=$(curl -s http://localhost:8080/actuator/health) + RESPONSE=$(curl -s http://localhost:9100/actuator/health) UP_COUNT=$(echo $RESPONSE | grep 'UP' | wc -l) if [ $UP_COUNT -ge 1 ] diff --git a/backend/src/main/java/corea/auth/controller/LoginController.java b/backend/src/main/java/corea/auth/controller/LoginController.java index 86b35e480..e2ccfa706 100644 --- a/backend/src/main/java/corea/auth/controller/LoginController.java +++ b/backend/src/main/java/corea/auth/controller/LoginController.java @@ -10,6 +10,8 @@ import corea.auth.service.GithubOAuthProvider; import corea.auth.service.LoginService; import corea.auth.service.LogoutService; +import corea.member.dto.MemberRoleResponse; +import corea.member.service.MemberService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; @@ -27,14 +29,17 @@ public class LoginController implements LoginControllerSpecification { private final GithubOAuthProvider githubOAuthProvider; private final LoginService loginService; private final LogoutService logoutService; + private final MemberService memberService; @PostMapping("/login") public ResponseEntity login(@RequestBody LoginRequest loginRequest) { GithubUserInfo userInfo = githubOAuthProvider.getUserInfo(loginRequest.code()); TokenInfo tokenInfo = loginService.login(userInfo); + MemberRoleResponse memberRoleResponse = memberService.getMemberRoleWithGithubUserId(userInfo.id()); + return ResponseEntity.ok() .header(AUTHORIZATION_HEADER, tokenInfo.accessToken()) - .body(new LoginResponse(tokenInfo.refreshToken(), userInfo)); + .body(new LoginResponse(tokenInfo.refreshToken(), userInfo, memberRoleResponse.role())); } @PostMapping("/refresh") diff --git a/backend/src/main/java/corea/auth/dto/LoginResponse.java b/backend/src/main/java/corea/auth/dto/LoginResponse.java index 932097515..aa5e3eb13 100644 --- a/backend/src/main/java/corea/auth/dto/LoginResponse.java +++ b/backend/src/main/java/corea/auth/dto/LoginResponse.java @@ -5,5 +5,6 @@ @Schema(description = "로그인/로그인 유지를 위한 정보 전달") public record LoginResponse(@Schema(description = "리프레시 JWT 토큰", example = "O1234567COREAREFRESH") String refreshToken, - GithubUserInfo userInfo) { + GithubUserInfo userInfo, + String memberRole) { } diff --git a/backend/src/main/java/corea/exception/ExceptionType.java b/backend/src/main/java/corea/exception/ExceptionType.java index 4892f9336..d61877058 100644 --- a/backend/src/main/java/corea/exception/ExceptionType.java +++ b/backend/src/main/java/corea/exception/ExceptionType.java @@ -10,6 +10,8 @@ public enum ExceptionType { NOT_PARTICIPATED_ROOM(HttpStatus.BAD_REQUEST, "아직 참여하지 않은 방입니다."), ROOM_STATUS_INVALID(HttpStatus.BAD_REQUEST, "방이 마감되었습니다."), MEMBER_IS_NOT_MANAGER(HttpStatus.BAD_REQUEST, "매니저가 아닙니다."), + MEMBER_IS_NOT_REVIEWER(HttpStatus.BAD_REQUEST, "리뷰어로만 참여할 수 없습니다."), + MEMBER_IS_NOT_BOTH(HttpStatus.BAD_REQUEST, "리뷰어로만 참여할 수 있습니다."), ROOM_PARTICIPANT_EXCEED(HttpStatus.BAD_REQUEST, "방 참여 인원 수가 최대입니다."), PARTICIPANT_SIZE_LACK(HttpStatus.BAD_REQUEST, "참여 인원이 부족하여 매칭을 진행할 수 없습니다."), PARTICIPANT_SIZE_LACK_DUE_TO_PULL_REQUEST(HttpStatus.BAD_REQUEST, "pull request 미제출로 인해 인원이 부족하여 매칭을 진행할 수 없습니다."), diff --git a/backend/src/main/java/corea/global/config/RestClientConfig.java b/backend/src/main/java/corea/global/config/RestClientConfig.java index edbee4df8..9a3810a22 100644 --- a/backend/src/main/java/corea/global/config/RestClientConfig.java +++ b/backend/src/main/java/corea/global/config/RestClientConfig.java @@ -2,14 +2,22 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.web.client.RestClient; +import java.time.Duration; + @Configuration public class RestClientConfig { @Bean - RestClient restClient() { + public RestClient restClient() { + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(Duration.ofSeconds(5)); + requestFactory.setReadTimeout(Duration.ofSeconds(10)); + return RestClient.builder() + .requestFactory(requestFactory) .build(); } } diff --git a/backend/src/main/java/corea/matching/strategy/ReviewerPreemptiveMatchingStrategy.java b/backend/src/main/java/corea/matching/strategy/ReviewerPreemptiveMatchingStrategy.java index 942f66035..95f9332f5 100644 --- a/backend/src/main/java/corea/matching/strategy/ReviewerPreemptiveMatchingStrategy.java +++ b/backend/src/main/java/corea/matching/strategy/ReviewerPreemptiveMatchingStrategy.java @@ -6,7 +6,6 @@ import corea.member.domain.Member; import corea.participation.domain.Participation; import lombok.RequiredArgsConstructor; -import org.springframework.context.annotation.Primary; import org.springframework.stereotype.Component; import java.util.*; diff --git a/backend/src/main/java/corea/matchresult/domain/FailedMatching.java b/backend/src/main/java/corea/matchresult/domain/FailedMatching.java index a10ab8388..ca2ac208d 100644 --- a/backend/src/main/java/corea/matchresult/domain/FailedMatching.java +++ b/backend/src/main/java/corea/matchresult/domain/FailedMatching.java @@ -1,6 +1,7 @@ package corea.matchresult.domain; import corea.exception.ExceptionType; +import corea.global.BaseTimeEntity; import jakarta.persistence.*; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -11,7 +12,7 @@ @AllArgsConstructor @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter -public class FailedMatching { +public class FailedMatching extends BaseTimeEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/backend/src/main/java/corea/matchresult/domain/MatchResultReader.java b/backend/src/main/java/corea/matchresult/domain/MatchResultReader.java index b2296cc45..62f529fb0 100644 --- a/backend/src/main/java/corea/matchresult/domain/MatchResultReader.java +++ b/backend/src/main/java/corea/matchresult/domain/MatchResultReader.java @@ -12,7 +12,7 @@ public class MatchResultReader { private final MatchResultRepository matchResultRepository; - public MatchResult findOne(long roomId,long reviewerId,long revieweeId) { + public MatchResult findOne(long roomId, long reviewerId, long revieweeId) { return matchResultRepository.findByRoomIdAndReviewerIdAndRevieweeId(roomId, reviewerId, revieweeId) .orElseThrow(() -> new CoreaException(ExceptionType.NOT_MATCHED_MEMBER)); } diff --git a/backend/src/main/java/corea/matchresult/domain/MatchResultWriter.java b/backend/src/main/java/corea/matchresult/domain/MatchResultWriter.java index 8c89de909..13caefa45 100644 --- a/backend/src/main/java/corea/matchresult/domain/MatchResultWriter.java +++ b/backend/src/main/java/corea/matchresult/domain/MatchResultWriter.java @@ -14,9 +14,9 @@ public class MatchResultWriter { private final MatchResultRepository matchResultRepository; - public void reviewComplete(MatchResult matchResult, String prLink) { + public void reviewComplete(MatchResult matchResult, String reviewLink) { matchResult.reviewComplete(); - matchResult.updateReviewLink(prLink); + matchResult.updateReviewLink(reviewLink); } public MatchResult completeDevelopFeedback(long roomId, long deliverId, long receiverId) { diff --git a/backend/src/main/java/corea/member/domain/AuthRole.java b/backend/src/main/java/corea/member/domain/AuthRole.java new file mode 100644 index 000000000..a899676ab --- /dev/null +++ b/backend/src/main/java/corea/member/domain/AuthRole.java @@ -0,0 +1,11 @@ +package corea.member.domain; + +public enum AuthRole { + + REVIEWEE, + REVIEWER; + + public boolean isReviewer() { + return this == REVIEWER; + } +} diff --git a/backend/src/main/java/corea/member/domain/MemberReader.java b/backend/src/main/java/corea/member/domain/MemberReader.java index 62749fa63..d92effff9 100644 --- a/backend/src/main/java/corea/member/domain/MemberReader.java +++ b/backend/src/main/java/corea/member/domain/MemberReader.java @@ -3,6 +3,7 @@ import corea.exception.CoreaException; import corea.exception.ExceptionType; import corea.member.repository.MemberRepository; +import corea.member.repository.ReviewerRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -13,9 +14,14 @@ public class MemberReader { private final MemberRepository memberRepository; + private final ReviewerRepository reviewerRepository; public Member findOne(long memberId) { return memberRepository.findById(memberId) .orElseThrow(() -> new CoreaException(ExceptionType.MEMBER_NOT_FOUND)); } + + public boolean isReviewer(String githubUserId) { + return reviewerRepository.existsByGithubUserId(githubUserId); + } } diff --git a/backend/src/main/java/corea/member/domain/MemberRole.java b/backend/src/main/java/corea/member/domain/MemberRole.java index 99fb95cc2..5079d2dda 100644 --- a/backend/src/main/java/corea/member/domain/MemberRole.java +++ b/backend/src/main/java/corea/member/domain/MemberRole.java @@ -24,6 +24,10 @@ public boolean isReviewer() { return this == REVIEWER; } + public boolean isBoth(){ + return this == BOTH; + } + public ParticipationStatus getParticipationStatus() { return switch (this) { case REVIEWER, REVIEWEE, BOTH -> ParticipationStatus.PARTICIPATED; diff --git a/backend/src/main/java/corea/member/domain/Reviewer.java b/backend/src/main/java/corea/member/domain/Reviewer.java new file mode 100644 index 000000000..15e373b1e --- /dev/null +++ b/backend/src/main/java/corea/member/domain/Reviewer.java @@ -0,0 +1,24 @@ +package corea.member.domain; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import static jakarta.persistence.GenerationType.IDENTITY; + +@Entity +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Reviewer { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + private String githubUserId; +} diff --git a/backend/src/main/java/corea/member/dto/MemberRoleResponse.java b/backend/src/main/java/corea/member/dto/MemberRoleResponse.java new file mode 100644 index 000000000..a066b66b1 --- /dev/null +++ b/backend/src/main/java/corea/member/dto/MemberRoleResponse.java @@ -0,0 +1,11 @@ +package corea.member.dto; + +import corea.member.domain.AuthRole; + +public record MemberRoleResponse(String role) { + + public static MemberRoleResponse from(boolean isReviewer) { + AuthRole role = isReviewer ? AuthRole.REVIEWER : AuthRole.REVIEWEE; + return new MemberRoleResponse(role.name()); + } +} diff --git a/backend/src/main/java/corea/member/repository/ReviewerRepository.java b/backend/src/main/java/corea/member/repository/ReviewerRepository.java new file mode 100644 index 000000000..76c11425e --- /dev/null +++ b/backend/src/main/java/corea/member/repository/ReviewerRepository.java @@ -0,0 +1,9 @@ +package corea.member.repository; + +import corea.member.domain.Reviewer; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReviewerRepository extends JpaRepository { + + boolean existsByGithubUserId(String githubUserId); +} diff --git a/backend/src/main/java/corea/member/service/MemberService.java b/backend/src/main/java/corea/member/service/MemberService.java new file mode 100644 index 000000000..bf943e88b --- /dev/null +++ b/backend/src/main/java/corea/member/service/MemberService.java @@ -0,0 +1,20 @@ +package corea.member.service; + +import corea.member.domain.MemberReader; +import corea.member.dto.MemberRoleResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MemberService { + + private final MemberReader memberReader; + + public MemberRoleResponse getMemberRoleWithGithubUserId(String githubUserId) { + boolean isReviewer = memberReader.isReviewer(githubUserId); + return MemberRoleResponse.from(isReviewer); + } +} diff --git a/backend/src/main/java/corea/participation/service/ParticipationService.java b/backend/src/main/java/corea/participation/service/ParticipationService.java index ae0bf8e85..b64944287 100644 --- a/backend/src/main/java/corea/participation/service/ParticipationService.java +++ b/backend/src/main/java/corea/participation/service/ParticipationService.java @@ -3,6 +3,7 @@ import corea.exception.CoreaException; import corea.exception.ExceptionType; import corea.member.domain.Member; +import corea.member.domain.MemberReader; import corea.member.domain.MemberRole; import corea.member.repository.MemberRepository; import corea.participation.domain.Participation; @@ -25,6 +26,8 @@ public class ParticipationService { private final ParticipationWriter participationWriter; private final RoomRepository roomRepository; private final MemberRepository memberRepository; + //TODO: memberRepository -> Reader/Writer + private final MemberReader memberReader; @Transactional public ParticipationResponse participate(ParticipationRequest request) { @@ -35,7 +38,9 @@ public ParticipationResponse participate(ParticipationRequest request) { private Participation saveParticipation(ParticipationRequest request) { Member member = memberRepository.findById(request.memberId()) .orElseThrow(() -> new CoreaException(ExceptionType.MEMBER_NOT_FOUND)); + MemberRole memberRole = MemberRole.from(request.role()); + validateRole(member, memberRole); Room room = getRoom(request.roomId()); return participationWriter.create(room, member, memberRole, request.matchingSize()); @@ -60,6 +65,17 @@ private void validateMemberExist(long memberId) { } } + private void validateRole(Member member, MemberRole memberRole) { + boolean isReviewer = memberReader.isReviewer(member.getGithubUserId()); + + if (!isReviewer && memberRole.isReviewer()) { + throw new CoreaException(ExceptionType.MEMBER_IS_NOT_REVIEWER); + } + if (isReviewer && memberRole.isBoth()) { + throw new CoreaException(ExceptionType.MEMBER_IS_NOT_BOTH); + } + } + private Room getRoom(long roomId) { return roomRepository.findById(roomId) .orElseThrow(() -> new CoreaException(ExceptionType.ROOM_NOT_FOUND)); diff --git a/backend/src/main/java/corea/review/infrastructure/GithubCommentClient.java b/backend/src/main/java/corea/review/infrastructure/GithubCommentClient.java new file mode 100644 index 000000000..1fef4834a --- /dev/null +++ b/backend/src/main/java/corea/review/infrastructure/GithubCommentClient.java @@ -0,0 +1,69 @@ +package corea.review.infrastructure; + +import corea.auth.infrastructure.GithubProperties; +import corea.review.dto.GithubPullRequestReview; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import java.util.Arrays; +import java.util.List; +import java.util.Random; +import java.util.stream.Stream; + +import static org.springframework.http.MediaType.APPLICATION_JSON; + +@EnableConfigurationProperties(GithubProperties.class) +@Component +public class GithubCommentClient { + + private static final Random RANDOM = new Random(); + + private final RestClient restClient; + private final GithubPullRequestUrlExchanger githubPullRequestUrlExchanger; + private final List personalAccessTokens; + + public GithubCommentClient(RestClient restClient, GithubPullRequestUrlExchanger githubPullRequestUrlExchanger, GithubProperties githubProperties) { + this.restClient = restClient; + this.githubPullRequestUrlExchanger = githubPullRequestUrlExchanger; + this.personalAccessTokens = githubProperties.pullRequest() + .tokens(); + } + + public List getPullRequestComments(String prLink) { + String commentApiUrl = githubPullRequestUrlExchanger.pullRequestUrlToComment(prLink); + + return Stream.iterate(1, page -> page + 1) + .map(page -> getPullRequestCommentsForPage(page, commentApiUrl)) + .takeWhile(this::hasMoreComments) + .flatMap(Arrays::stream) + .toList(); + } + + private GithubPullRequestReview[] getPullRequestCommentsForPage(int page, String commentApiUrl) { + String url = buildPageUrl(page, commentApiUrl); + + return restClient.get() + .uri(url) + .header(HttpHeaders.AUTHORIZATION, getRandomPersonalAccessToken()) + .accept(APPLICATION_JSON) + .retrieve() + .body(GithubPullRequestReview[].class); + } + + private String buildPageUrl(int page, String commentApiUrl) { + return commentApiUrl + "?page=" + page + "&per_page=100"; + } + + private boolean hasMoreComments(GithubPullRequestReview[] comments) { + return comments.length > 0; + } + + private String getRandomPersonalAccessToken() { + if (personalAccessTokens.isEmpty()) { + return ""; + } + return "Bearer " + personalAccessTokens.get(RANDOM.nextInt(personalAccessTokens.size())); + } +} diff --git a/backend/src/main/java/corea/auth/infrastructure/GithubPullRequestUrlExchanger.java b/backend/src/main/java/corea/review/infrastructure/GithubPullRequestUrlExchanger.java similarity index 60% rename from backend/src/main/java/corea/auth/infrastructure/GithubPullRequestUrlExchanger.java rename to backend/src/main/java/corea/review/infrastructure/GithubPullRequestUrlExchanger.java index 50e2ed209..ec3bf657d 100644 --- a/backend/src/main/java/corea/auth/infrastructure/GithubPullRequestUrlExchanger.java +++ b/backend/src/main/java/corea/review/infrastructure/GithubPullRequestUrlExchanger.java @@ -1,11 +1,13 @@ -package corea.auth.infrastructure; +package corea.review.infrastructure; import corea.exception.CoreaException; import corea.exception.ExceptionType; +import org.springframework.stereotype.Component; import java.util.List; import java.util.stream.IntStream; +@Component public class GithubPullRequestUrlExchanger { private static final String REVIEW_API_PREFIX = "api.github.com/repos"; @@ -13,21 +15,20 @@ public class GithubPullRequestUrlExchanger { private static final String HTTP_SECURE_PREFIX = "https://"; private static final String URL_DELIMITER = "/"; private static final String GITHUB_PULL_REQUEST_REVIEW_API_SUFFIX = "reviews"; + private static final String GITHUB_PULL_REQUEST_COMMENT_API_SUFFIX = "comments"; private static final String GITHUB_PULL_REQUEST_DOMAIN = "pull"; private static final String GITHUB_PULL_REQUEST_API_DOMAIN = "pulls"; + private static final String GITHUB_PULL_REQUEST_COMMENT_DOMAIN = "issues"; private static final int DOMAIN_PREFIX_INDEX = 0; private static final int GITHUB_PULL_REQUEST_URL_INDEX = 3; private static final int VALID_URL_SPLIT_COUNT = 5; - private GithubPullRequestUrlExchanger() { - } - - public static String pullRequestUrlToReview(String prLink) { + public String pullRequestUrlToReview(String prLink) { validatePrLink(prLink); return prLinkToReviewApiLink(prLink); } - private static void validatePrLink(String prUrl) { + private void validatePrLink(String prUrl) { if (prUrl == null || !prUrl.startsWith(HTTP_SECURE_PREFIX)) { throw new CoreaException(ExceptionType.INVALID_PULL_REQUEST_URL); } @@ -40,7 +41,7 @@ private static void validatePrLink(String prUrl) { } } - private static String prLinkToReviewApiLink(String prLink) { + private String prLinkToReviewApiLink(String prLink) { String[] splitPrLink = prLink.replaceFirst(HTTP_SECURE_PREFIX + GITHUB_PREFIX, "").split(URL_DELIMITER); List apiUrlComponents = IntStream.range(0, splitPrLink.length) .mapToObj(i -> filterPullUrlToApiUrl(splitPrLink, i)) @@ -49,10 +50,31 @@ private static String prLinkToReviewApiLink(String prLink) { String.join(URL_DELIMITER, apiUrlComponents) + URL_DELIMITER + GITHUB_PULL_REQUEST_REVIEW_API_SUFFIX; } - private static String filterPullUrlToApiUrl(String[] splitPrLink, int index) { + private String filterPullUrlToApiUrl(String[] splitPrLink, int index) { if (index != GITHUB_PULL_REQUEST_URL_INDEX) { return splitPrLink[index]; } return GITHUB_PULL_REQUEST_API_DOMAIN; } + + public String pullRequestUrlToComment(String prLink) { + validatePrLink(prLink); + return prLinkToCommentApiLink(prLink); + } + + private String prLinkToCommentApiLink(String prLink) { + String[] splitPrLink = prLink.replaceFirst(HTTP_SECURE_PREFIX + GITHUB_PREFIX, "").split(URL_DELIMITER); + List apiUrlComponents = IntStream.range(0, splitPrLink.length) + .mapToObj(i -> filterPullUrlToCommentUrl(splitPrLink, i)) + .toList(); + return HTTP_SECURE_PREFIX + REVIEW_API_PREFIX + + String.join(URL_DELIMITER, apiUrlComponents) + URL_DELIMITER + GITHUB_PULL_REQUEST_COMMENT_API_SUFFIX; + } + + private String filterPullUrlToCommentUrl(String[] splitPrLink, int index) { + if (index != GITHUB_PULL_REQUEST_URL_INDEX) { + return splitPrLink[index]; + } + return GITHUB_PULL_REQUEST_COMMENT_DOMAIN; + } } diff --git a/backend/src/main/java/corea/review/infrastructure/GithubReviewClient.java b/backend/src/main/java/corea/review/infrastructure/GithubReviewClient.java index f5f8cbdd0..423346897 100644 --- a/backend/src/main/java/corea/review/infrastructure/GithubReviewClient.java +++ b/backend/src/main/java/corea/review/infrastructure/GithubReviewClient.java @@ -1,28 +1,69 @@ package corea.review.infrastructure; import corea.auth.infrastructure.GithubProperties; -import corea.auth.infrastructure.GithubPullRequestUrlExchanger; import corea.review.dto.GithubPullRequestReview; -import lombok.RequiredArgsConstructor; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.http.HttpHeaders; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; +import java.util.Arrays; +import java.util.List; +import java.util.Random; +import java.util.stream.Stream; + import static org.springframework.http.MediaType.APPLICATION_JSON; @EnableConfigurationProperties(GithubProperties.class) @Component -@RequiredArgsConstructor public class GithubReviewClient { + private static final Random RANDOM = new Random(); + private final RestClient restClient; + private final GithubPullRequestUrlExchanger githubPullRequestUrlExchanger; + private final List personalAccessTokens; + + public GithubReviewClient(RestClient restClient, GithubPullRequestUrlExchanger githubPullRequestUrlExchanger, GithubProperties githubProperties) { + this.restClient = restClient; + this.githubPullRequestUrlExchanger = githubPullRequestUrlExchanger; + this.personalAccessTokens = githubProperties.pullRequest() + .tokens(); + } + + public List getPullRequestReviews(String prLink) { + String reviewApiUrl = githubPullRequestUrlExchanger.pullRequestUrlToReview(prLink); + + return Stream.iterate(1, page -> page + 1) + .map(page -> getPullRequestReviewsForPage(page, reviewApiUrl)) + .takeWhile(this::hasMoreReviews) + .flatMap(Arrays::stream) + .toList(); + } + + private GithubPullRequestReview[] getPullRequestReviewsForPage(int page, String reviewApiUrl) { + String url = buildPageUrl(page, reviewApiUrl); - public GithubPullRequestReview[] getReviewLink(String prLink) { - String url = GithubPullRequestUrlExchanger.pullRequestUrlToReview(prLink); return restClient.get() .uri(url) + .header(HttpHeaders.AUTHORIZATION, getRandomPersonalAccessToken()) .accept(APPLICATION_JSON) .retrieve() .body(GithubPullRequestReview[].class); } + + private String buildPageUrl(int page, String reviewApiUrl) { + return reviewApiUrl + "?page=" + page + "&per_page=100"; + } + + private boolean hasMoreReviews(GithubPullRequestReview[] reviews) { + return reviews.length > 0; + } + + private String getRandomPersonalAccessToken() { + if (personalAccessTokens.isEmpty()) { + return ""; + } + return "Bearer " + personalAccessTokens.get(RANDOM.nextInt(personalAccessTokens.size())); + } } diff --git a/backend/src/main/java/corea/review/infrastructure/GithubReviewProvider.java b/backend/src/main/java/corea/review/infrastructure/GithubReviewProvider.java index 0b3aca546..668bbd162 100644 --- a/backend/src/main/java/corea/review/infrastructure/GithubReviewProvider.java +++ b/backend/src/main/java/corea/review/infrastructure/GithubReviewProvider.java @@ -5,23 +5,37 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; -import java.util.Arrays; +import java.util.List; +import java.util.Map; import java.util.function.Function; import java.util.stream.Collectors; +import java.util.stream.Stream; @Component @RequiredArgsConstructor public class GithubReviewProvider { private final GithubReviewClient reviewClient; + private final GithubCommentClient commentClient; - public GithubPullRequestReviewInfo getReviewWithPrLink(String prLink) { - final GithubPullRequestReview[] result = reviewClient.getReviewLink(prLink); - return new GithubPullRequestReviewInfo(Arrays.stream(result) + public GithubPullRequestReviewInfo provideReviewInfo(String prLink) { + //TODO: getPullRequestReviews, getPullRequestComments에서 prLink를 중복으로 검증하고 있음. + List reviews = reviewClient.getPullRequestReviews(prLink); + List comments = commentClient.getPullRequestComments(prLink); + + Map result = collectPullRequestReviews(reviews, comments); + return new GithubPullRequestReviewInfo(result); + } + + private Map collectPullRequestReviews(List reviews, List comments) { + return Stream.concat( + reviews.stream(), + comments.stream() + ) .collect(Collectors.toMap( GithubPullRequestReview::getGithubUserId, Function.identity(), (x, y) -> x - ))); + )); } } diff --git a/backend/src/main/java/corea/review/service/ReviewService.java b/backend/src/main/java/corea/review/service/ReviewService.java index 2f095ce8c..040bb6e5a 100644 --- a/backend/src/main/java/corea/review/service/ReviewService.java +++ b/backend/src/main/java/corea/review/service/ReviewService.java @@ -6,6 +6,7 @@ import corea.matchresult.domain.MatchResultReader; import corea.matchresult.domain.MatchResultWriter; import corea.review.dto.GithubPullRequestReview; +import corea.review.dto.GithubPullRequestReviewInfo; import corea.review.infrastructure.GithubReviewProvider; import corea.room.domain.RoomReader; import corea.room.domain.RoomStatus; @@ -34,15 +35,16 @@ public void completeReview(long roomId, long reviewerId, long revieweeId) { throw new CoreaException(ExceptionType.ROOM_STATUS_INVALID); } MatchResult matchResult = matchResultReader.findOne(roomId, reviewerId, revieweeId); - String prLink = getPrReviewLink(matchResult.getPrLink(), matchResult.getReviewerGithubId()); - matchResultWriter.reviewComplete(matchResult, prLink); + String reviewLink = getPrReviewLink(matchResult.getPrLink(), matchResult.getReviewerGithubId()); + matchResultWriter.reviewComplete(matchResult, reviewLink); log.info("리뷰 완료[{매칭 ID({}), 리뷰어 ID({}, 리뷰이 ID({})", matchResult.getId(), reviewerId, revieweeId); } private String getPrReviewLink(String prLink, String reviewerGithubId) { - return githubReviewProvider.getReviewWithPrLink(prLink) - .findWithGithubUserId(reviewerGithubId) + GithubPullRequestReviewInfo reviewInfo = githubReviewProvider.provideReviewInfo(prLink); + + return reviewInfo.findWithGithubUserId(reviewerGithubId) .map(GithubPullRequestReview::html_url) .orElseThrow(() -> new CoreaException(ExceptionType.NOT_COMPLETE_GITHUB_REVIEW)); } diff --git a/backend/src/main/java/corea/room/dto/RoomUpdateRequest.java b/backend/src/main/java/corea/room/dto/RoomUpdateRequest.java index b1a0a586a..19d6a83c1 100644 --- a/backend/src/main/java/corea/room/dto/RoomUpdateRequest.java +++ b/backend/src/main/java/corea/room/dto/RoomUpdateRequest.java @@ -4,7 +4,6 @@ import corea.member.domain.Member; import corea.room.domain.Room; import corea.room.domain.RoomClassification; -import corea.room.domain.RoomStatus; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; diff --git a/backend/src/main/java/corea/room/service/RoomService.java b/backend/src/main/java/corea/room/service/RoomService.java index c6dc1d52e..63846d184 100644 --- a/backend/src/main/java/corea/room/service/RoomService.java +++ b/backend/src/main/java/corea/room/service/RoomService.java @@ -1,7 +1,5 @@ package corea.room.service; -import corea.exception.CoreaException; -import corea.exception.ExceptionType; import corea.matchresult.repository.MatchResultRepository; import corea.member.domain.Member; import corea.member.domain.MemberReader; @@ -23,6 +21,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Objects; @Slf4j @Service @@ -81,10 +80,12 @@ public RoomParticipantResponses findParticipants(long roomId, long memberId) { Collections.shuffle(participants); - return new RoomParticipantResponses(participants.stream() + List roomParticipantResponses = participants.stream() .limit(RANDOM_DISPLAY_PARTICIPANTS_SIZE) .map(participation -> getRoomParticipantResponse(roomId, participation)) - .toList(), participants.size()); + .filter(Objects::nonNull) + .toList(); + return new RoomParticipantResponses(roomParticipantResponses, participants.size()); } private boolean isValidParticipant(Participation participation, long memberId) { @@ -102,7 +103,7 @@ private RoomParticipantResponse getRoomParticipantResponse(long roomId, Particip .getGithubUserId(), matchResult.getReviewee() .getUsername(), matchResult.getPrLink(), matchResult.getReviewee() .getThumbnailUrl())) - .orElseThrow(() -> new CoreaException(ExceptionType.MEMBER_NOT_FOUND)); + .orElse(null); } public RoomResponse getRoomById(long roomId) { diff --git a/backend/src/main/java/corea/scheduler/service/MatchingExecutor.java b/backend/src/main/java/corea/scheduler/service/MatchingExecutor.java index 71b067d4e..7688eec9a 100644 --- a/backend/src/main/java/corea/scheduler/service/MatchingExecutor.java +++ b/backend/src/main/java/corea/scheduler/service/MatchingExecutor.java @@ -38,8 +38,7 @@ public void match(long roomId) { return null; }); } catch (CoreaException e) { - log.warn("매칭 실행 중 에러 발생: {}", e.getMessage(), e); - recordMatchingFailure(roomId, e.getExceptionType()); + recordMatchingFailure(roomId, e); } } @@ -50,12 +49,12 @@ private void startMatching(long roomId) { matchingService.match(roomId, pullRequestInfo); } - private void recordMatchingFailure(long roomId, ExceptionType exceptionType) { + private void recordMatchingFailure(long roomId, CoreaException e) { //TODO: 위와 동일 TransactionTemplate template = new TransactionTemplate(transactionManager); template.execute(status -> { updateRoomStatusToFail(roomId); - saveFailedMatching(roomId, exceptionType); + saveFailedMatching(roomId, e); return null; }); } @@ -65,9 +64,11 @@ private void updateRoomStatusToFail(long roomId) { room.updateStatusToFail(); } - private void saveFailedMatching(long roomId, ExceptionType exceptionType) { - FailedMatching failedMatching = new FailedMatching(roomId, exceptionType); + private void saveFailedMatching(long roomId, CoreaException e) { + FailedMatching failedMatching = new FailedMatching(roomId, e.getExceptionType()); failedMatchingRepository.save(failedMatching); + + log.info("매칭 실행 중 에러 발생. 방 아이디={}, 실패 원인={}", roomId, e.getMessage(), e); } private Room getRoom(long roomId) { diff --git a/backend/src/main/resources/config b/backend/src/main/resources/config index 7af7af1e0..7918cfd62 160000 --- a/backend/src/main/resources/config +++ b/backend/src/main/resources/config @@ -1 +1 @@ -Subproject commit 7af7af1e09c3410d6c652ab93741835577a7e49b +Subproject commit 7918cfd62b8d269d695deda0a2957b9884899aa6 diff --git a/backend/src/test/java/corea/fixture/MemberFixture.java b/backend/src/test/java/corea/fixture/MemberFixture.java index 7677efe26..07c48a430 100644 --- a/backend/src/test/java/corea/fixture/MemberFixture.java +++ b/backend/src/test/java/corea/fixture/MemberFixture.java @@ -2,6 +2,7 @@ import corea.member.domain.Member; import corea.member.domain.Profile; +import corea.member.domain.Reviewer; import java.util.List; import java.util.stream.IntStream; @@ -168,18 +169,27 @@ public static Member MEMBER_PORORO_GITHUB() { ); } + public static Reviewer MEMBER_YOUNGSU_REVIEWER() { + return new Reviewer( + null, + "98307410" + ); + } + public static List SEVEN_MEMBERS() { return List.of(MEMBER_PORORO(), MEMBER_ASH(), MEMBER_YOUNGSU(), MEMBER_CHOCO(), MEMBER_MOVIN(), MEMBER_TENTEN()); } public static List CREATE_MEMBERS(int index) { - return IntStream.range(0, index).mapToObj(idx -> new Member( - "name : " + (idx + 10), - "https://avatars.githubusercontent.com/u/98307410?v=4", - null, - "jcoding-play@gmail.com", - false, - "119468757" - )).toList(); + return IntStream.range(0, index) + .mapToObj(idx -> new Member( + "name : " + (idx + 10), + "https://avatars.githubusercontent.com/u/98307410?v=4", + null, + "jcoding-play@gmail.com", + false, + "119468757" + )) + .toList(); } } diff --git a/backend/src/test/java/corea/member/repository/ProfileRepositoryTest.java b/backend/src/test/java/corea/member/repository/ProfileRepositoryTest.java index 0c01ddd40..981af8264 100644 --- a/backend/src/test/java/corea/member/repository/ProfileRepositoryTest.java +++ b/backend/src/test/java/corea/member/repository/ProfileRepositoryTest.java @@ -22,7 +22,7 @@ class ProfileRepositoryTest { @DisplayName("사용자가 쓴 피드백의 개수를 셀 수 있다.") void findDeliverCountByProfile() { Profile profile = new Profile(3, 2, 1, 5.0f, 5.0f); - memberRepository.save(MemberFixture.MEMBER_PORORO(profile)); + profileRepository.save(profile); long result = profileRepository.findDeliverCountByProfile(profile); diff --git a/backend/src/test/java/corea/participation/service/ParticipationServiceTest.java b/backend/src/test/java/corea/participation/service/ParticipationServiceTest.java index 8eb75c6fa..0f07efb40 100644 --- a/backend/src/test/java/corea/participation/service/ParticipationServiceTest.java +++ b/backend/src/test/java/corea/participation/service/ParticipationServiceTest.java @@ -7,19 +7,20 @@ import corea.member.domain.Member; import corea.member.domain.MemberRole; import corea.member.repository.MemberRepository; +import corea.member.repository.ReviewerRepository; import corea.participation.domain.Participation; import corea.participation.domain.ParticipationStatus; import corea.participation.dto.ParticipationRequest; import corea.participation.repository.ParticipationRepository; import corea.room.domain.Room; import corea.room.repository.RoomRepository; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; import org.springframework.beans.factory.annotation.Autowired; import static corea.fixture.MemberFixture.MEMBER_YOUNGSU; +import static corea.fixture.MemberFixture.MEMBER_YOUNGSU_REVIEWER; import static org.assertj.core.api.Assertions.*; @ServiceTest @@ -34,20 +35,59 @@ class ParticipationServiceTest { @Autowired private MemberRepository memberRepository; + @Autowired + private ReviewerRepository reviewerRepository; + @Autowired private ParticipationRepository participationRepository; - @ParameterizedTest - @CsvSource({"both", "reviewer"}) - @DisplayName("멤버가 방에 참여한다.") - void participate(String role) { + @AfterEach + void tearDown() { + reviewerRepository.deleteAll(); + } + + @Test + @DisplayName("멤버가 BOTH 로 방에 참여한다.") + void participate_with_both() { + Member member = memberRepository.save(MEMBER_YOUNGSU()); + Room room = roomRepository.save(RoomFixture.ROOM_DOMAIN(member)); + + assertThatCode(() -> participationService.participate(new ParticipationRequest(room.getId(), member.getId(), "both", 2))) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("리뷰어가 아닌데 reviewer 로 참여시 예외를 발생한다.") + void throw_exception_when_participate_reviewer_with_not_reviewer() { + Member member = memberRepository.save(MEMBER_YOUNGSU()); + Room room = roomRepository.save(RoomFixture.ROOM_DOMAIN(member)); + + assertThatCode(() -> participationService.participate(new ParticipationRequest(room.getId(), member.getId(), "reviewer", 2))) + .isInstanceOf(CoreaException.class); + } + + @Test + @DisplayName("리뷰어가 reviewer 로 방에 참여한다.") + void participate_with_reviewer() { Member member = memberRepository.save(MEMBER_YOUNGSU()); + reviewerRepository.save(MEMBER_YOUNGSU_REVIEWER()); Room room = roomRepository.save(RoomFixture.ROOM_DOMAIN(member)); - assertThatCode(() -> participationService.participate(new ParticipationRequest(room.getId(), member.getId(), role, 2))) + assertThatCode(() -> participationService.participate(new ParticipationRequest(room.getId(), member.getId(), "reviewer", 2))) .doesNotThrowAnyException(); } + @Test + @DisplayName("리뷰어가 both 로 참여시 예외를 발생한다.") + void throw_exception_when_participate_both_with_not_both() { + Member member = memberRepository.save(MEMBER_YOUNGSU()); + reviewerRepository.save(MEMBER_YOUNGSU_REVIEWER()); + Room room = roomRepository.save(RoomFixture.ROOM_DOMAIN(member)); + + assertThatCode(() -> participationService.participate(new ParticipationRequest(room.getId(), member.getId(), "both", 2))) + .isInstanceOf(CoreaException.class); + } + @Test @DisplayName("ID에 해당하는 방이 없으면 예외를 발생한다.") void participate_throw_exception_when_roomId_not_exist() { diff --git a/backend/src/test/java/corea/review/infrastructure/GithubCommentClientTest.java b/backend/src/test/java/corea/review/infrastructure/GithubCommentClientTest.java new file mode 100644 index 000000000..b63ff9b0b --- /dev/null +++ b/backend/src/test/java/corea/review/infrastructure/GithubCommentClientTest.java @@ -0,0 +1,30 @@ +package corea.review.infrastructure; + +import corea.review.dto.GithubPullRequestReview; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@Disabled +@SpringBootTest +class GithubCommentClientTest { + + @Autowired + private GithubCommentClient githubCommentClient; + + @Test + @DisplayName("해당 PR 링크에 존재하는 커멘트들을 가져온다.") + void getPullRequestComments() { + String prLink = "https://github.com/youngsu5582/github-api-test/pull/1"; + + List comments = githubCommentClient.getPullRequestComments(prLink); + + assertThat(comments).hasSize(2); + } +} diff --git a/backend/src/test/java/corea/auth/infrastructure/GithubPullRequestUrlExchangerTest.java b/backend/src/test/java/corea/review/infrastructure/GithubPullRequestUrlExchangerTest.java similarity index 76% rename from backend/src/test/java/corea/auth/infrastructure/GithubPullRequestUrlExchangerTest.java rename to backend/src/test/java/corea/review/infrastructure/GithubPullRequestUrlExchangerTest.java index 5148d7446..aac952461 100644 --- a/backend/src/test/java/corea/auth/infrastructure/GithubPullRequestUrlExchangerTest.java +++ b/backend/src/test/java/corea/review/infrastructure/GithubPullRequestUrlExchangerTest.java @@ -1,6 +1,8 @@ -package corea.auth.infrastructure; +package corea.review.infrastructure; import corea.exception.CoreaException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -10,15 +12,23 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +@Disabled class GithubPullRequestUrlExchangerTest { + private GithubPullRequestUrlExchanger githubPullRequestUrlExchanger; + + @BeforeEach + void setUp() { + githubPullRequestUrlExchanger = new GithubPullRequestUrlExchanger(); + } + @Test @DisplayName("올바른 Pull Request URL 을 상응하는 Github API 요청 주소로 변환한다.") void test() { String test = "https://github.com/woowacourse-teams/2024-corea/pull/1"; String expected = "https://api.github.com/repos/woowacourse-teams/2024-corea/pulls/1/reviews"; - assertThat(GithubPullRequestUrlExchanger.pullRequestUrlToReview(test)).isEqualTo(expected); + assertThat(githubPullRequestUrlExchanger.pullRequestUrlToReview(test)).isEqualTo(expected); } @Test @@ -26,7 +36,7 @@ void test() { void validate1() { String test = "http://github.com/woowacourse-teams/2024-corea/pull/1"; - assertThatThrownBy(() -> GithubPullRequestUrlExchanger.pullRequestUrlToReview(test)) + assertThatThrownBy(() -> githubPullRequestUrlExchanger.pullRequestUrlToReview(test)) .isInstanceOf(CoreaException.class); } @@ -34,7 +44,7 @@ void validate1() { @NullAndEmptySource @DisplayName("빈 주소에 대해 예외를 발생한다.") void validate2(String test) { - assertThatThrownBy(() -> GithubPullRequestUrlExchanger.pullRequestUrlToReview(test)) + assertThatThrownBy(() -> githubPullRequestUrlExchanger.pullRequestUrlToReview(test)) .isInstanceOf(CoreaException.class); } @@ -46,7 +56,7 @@ void validate2(String test) { "https://github.com/woowacourse-teams/2024-corea/pull"}) @DisplayName("올바르지 않은 Pull Request 주소에 대해 예외를 발생한다.") void validate3(String test) { - assertThatThrownBy(() -> GithubPullRequestUrlExchanger.pullRequestUrlToReview(test)) + assertThatThrownBy(() -> githubPullRequestUrlExchanger.pullRequestUrlToReview(test)) .isInstanceOf(CoreaException.class); } } diff --git a/backend/src/test/java/corea/auth/infrastructure/GithubPullRequestReviewClientTest.java b/backend/src/test/java/corea/review/infrastructure/GithubReviewClientTest.java similarity index 65% rename from backend/src/test/java/corea/auth/infrastructure/GithubPullRequestReviewClientTest.java rename to backend/src/test/java/corea/review/infrastructure/GithubReviewClientTest.java index 3a38c0897..ad401996b 100644 --- a/backend/src/test/java/corea/auth/infrastructure/GithubPullRequestReviewClientTest.java +++ b/backend/src/test/java/corea/review/infrastructure/GithubReviewClientTest.java @@ -1,32 +1,35 @@ -package corea.auth.infrastructure; +package corea.review.infrastructure; import corea.review.dto.GithubPullRequestReview; -import corea.review.infrastructure.GithubReviewClient; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import java.util.List; + import static org.assertj.core.api.Assertions.assertThat; +@Disabled @SpringBootTest -class GithubPullRequestReviewClientTest { +class GithubReviewClientTest { @Autowired private GithubReviewClient client; @Test @DisplayName("해당 PR 링크에 존재하는 리뷰들을 가져온다.") - void getReviewLink() { + void getPullRequestReviews() { String prLink1 = "https://github.com/woowacourse-teams/2024-corea/pull/10"; String prLink2 = "https://github.com/woowacourse-teams/2024-corea/pull/96"; String prLink3 = "https://github.com/woowacourse-teams/2024-corea/pull/114"; String prLink4 = "https://github.com/woowacourse-teams/2024-corea/pull/8"; - GithubPullRequestReview[] reviews1 = client.getReviewLink(prLink1); - GithubPullRequestReview[] reviews2 = client.getReviewLink(prLink2); - GithubPullRequestReview[] reviews3 = client.getReviewLink(prLink3); - GithubPullRequestReview[] reviews4 = client.getReviewLink(prLink4); + List reviews1 = client.getPullRequestReviews(prLink1); + List reviews2 = client.getPullRequestReviews(prLink2); + List reviews3 = client.getPullRequestReviews(prLink3); + List reviews4 = client.getPullRequestReviews(prLink4); assertThat(reviews1).hasSize(1); assertThat(reviews2).hasSize(8); diff --git a/backend/src/test/java/corea/review/infrastructure/GithubReviewProviderTest.java b/backend/src/test/java/corea/review/infrastructure/GithubReviewProviderTest.java index 72dfef61d..61165dae0 100644 --- a/backend/src/test/java/corea/review/infrastructure/GithubReviewProviderTest.java +++ b/backend/src/test/java/corea/review/infrastructure/GithubReviewProviderTest.java @@ -2,12 +2,15 @@ import corea.review.dto.GithubPullRequestReview; import corea.review.dto.GithubPullRequestReviewInfo; -import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import static org.assertj.core.api.Assertions.assertThat; + +@Disabled @SpringBootTest class GithubReviewProviderTest { @@ -15,24 +18,58 @@ class GithubReviewProviderTest { private GithubReviewProvider githubReviewProvider; @Test - @DisplayName("리뷰한 사람의 리뷰 링크를 찾을 수 있다.") - void getReviewWithPrLink() { - GithubPullRequestReviewInfo result = githubReviewProvider.getReviewWithPrLink("https://github.com/youngsu5582/github-api-test/pull/5"); + @DisplayName("한 사람이 하나의 리뷰를 남겼을 시, 리뷰한 사람의 리뷰 링크를 찾는다.") + void provideReviewInfo1() { + GithubPullRequestReviewInfo result = githubReviewProvider.provideReviewInfo("https://github.com/youngsu5582/github-api-test/pull/5"); - // 입력된 pr에 무빈이 남긴 리뷰 -> 1개만 존재 - GithubPullRequestReview review = result.findWithGithubUserId("80106238").get(); + // 입력된 pr에 텐텐이 남긴 리뷰 -> 1개 존재 + GithubPullRequestReview review = result.findWithGithubUserId("63334368").get(); + + assertThat(review.html_url()).isEqualTo("https://github.com/youngsu5582/github-api-test/pull/5#pullrequestreview-2362410991"); + } + + @Test + @DisplayName("한 사람이 하나의 커멘트를 남겼을 시, 리뷰한 사람의 커멘트 링크를 찾는다.") + void provideReviewInfo2() { + GithubPullRequestReviewInfo result = githubReviewProvider.provideReviewInfo("https://github.com/youngsu5582/github-api-test/pull/10"); + + // 입력된 pr에 뽀로로가 남긴 커멘트 -> 1개 존재 + GithubPullRequestReview review = result.findWithGithubUserId("119468757").get(); - Assertions.assertThat(review.html_url()).isEqualTo("https://github.com/youngsu5582/github-api-test/pull/5#pullrequestreview-2327171078"); + assertThat(review.html_url()).isEqualTo("https://github.com/youngsu5582/github-api-test/pull/10#issuecomment-2429806517"); } @Test @DisplayName("한 사람이 여러 리뷰를 남겼을 시, 첫번째 리뷰 링크를 찾는다.") - void getReviewWithPrLink_duplicatedKey() { - GithubPullRequestReviewInfo result = githubReviewProvider.getReviewWithPrLink("https://github.com/youngsu5582/github-api-test/pull/5"); + void provideReviewInfo3() { + GithubPullRequestReviewInfo result = githubReviewProvider.provideReviewInfo("https://github.com/youngsu5582/github-api-test/pull/5"); + + // 입력된 pr에 무빈이 남긴 리뷰 -> 2개 존재 + GithubPullRequestReview review = result.findWithGithubUserId("80106238").get(); + + assertThat(review.html_url()).isEqualTo("https://github.com/youngsu5582/github-api-test/pull/5#pullrequestreview-2327171078"); + } + + @Test + @DisplayName("한 사람이 여러 커멘트를 남겼을 시, 첫번째 커멘트 링크를 찾는다.") + void provideReviewInfo4() { + GithubPullRequestReviewInfo result = githubReviewProvider.provideReviewInfo("https://github.com/youngsu5582/github-api-test/pull/3"); + + // 입력된 pr에 뽀로로가 남긴 커멘트 -> 2개 존재 + GithubPullRequestReview review = result.findWithGithubUserId("119468757").get(); + + assertThat(review.html_url()).isEqualTo("https://github.com/youngsu5582/github-api-test/pull/3#issuecomment-2429811119"); + } + + @Test + @DisplayName("한 사람이 여러 리뷰와 커멘트를 남겼을 시, 첫번째 리뷰 링크를 찾는다.") + void provideReviewInfo5() { + GithubPullRequestReviewInfo result = githubReviewProvider.provideReviewInfo("https://github.com/youngsu5582/github-api-test/pull/1"); // 입력된 pr에 뽀로로가 남긴 리뷰 -> 2개 존재 + // 입력된 pr에 뽀로로가 남긴 커멘트 -> 2개 존재 GithubPullRequestReview review = result.findWithGithubUserId("119468757").get(); - Assertions.assertThat(review.html_url()).isEqualTo("https://github.com/youngsu5582/github-api-test/pull/5#pullrequestreview-2327172283"); + assertThat(review.html_url()).isEqualTo("https://github.com/youngsu5582/github-api-test/pull/1#pullrequestreview-2383980194"); } } diff --git a/backend/src/test/java/corea/review/service/ReviewServiceTest.java b/backend/src/test/java/corea/review/service/ReviewServiceTest.java index eac9ef099..713bca658 100644 --- a/backend/src/test/java/corea/review/service/ReviewServiceTest.java +++ b/backend/src/test/java/corea/review/service/ReviewServiceTest.java @@ -13,7 +13,8 @@ import corea.member.domain.Member; import corea.member.repository.MemberRepository; import corea.review.dto.GithubPullRequestReview; -import corea.review.infrastructure.GithubReviewClient; +import corea.review.dto.GithubPullRequestReviewInfo; +import corea.review.infrastructure.GithubReviewProvider; import corea.room.domain.Room; import corea.room.repository.RoomRepository; import org.assertj.core.api.InstanceOfAssertFactories; @@ -23,6 +24,9 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.transaction.annotation.Transactional; +import java.util.Collections; +import java.util.Map; + import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.anyString; @@ -44,7 +48,7 @@ class ReviewServiceTest { private MatchResultRepository matchResultRepository; @MockBean - private GithubReviewClient githubReviewClient; + private GithubReviewProvider githubReviewProvider; @Test @Transactional @@ -55,18 +59,21 @@ void completeReview() { Room room = roomRepository.save(RoomFixture.ROOM_DOMAIN_WITH_PROGRESS(memberRepository.save(MemberFixture.MEMBER_ROOM_MANAGER_JOYSON()))); MatchResult matchResult = matchResultRepository.save(MatchResultFixture.MATCH_RESULT_DOMAIN(room.getId(), reviewer, reviewee)); - when(githubReviewClient.getReviewLink(anyString())) - .thenReturn(new GithubPullRequestReview[]{ - new GithubPullRequestReview( - "id", - new GithubUserInfo( - reviewer.getUsername(), - reviewer.getName(), - reviewer.getThumbnailUrl(), - reviewer.getEmail(), - String.valueOf(reviewer.getGithubUserId())), - "html_url")} - ); + when(githubReviewProvider.provideReviewInfo(anyString())) + .thenReturn(new GithubPullRequestReviewInfo( + Map.of( + reviewer.getGithubUserId(), + new GithubPullRequestReview( + "id", + new GithubUserInfo( + reviewer.getUsername(), + reviewer.getName(), + reviewer.getThumbnailUrl(), + reviewer.getEmail(), + reviewer.getGithubUserId()), + "html_url") + ))); + reviewService.completeReview(room.getId(), reviewer.getId(), reviewee.getId()); assertThat(matchResult.getReviewStatus()).isEqualTo(ReviewStatus.COMPLETE); @@ -80,7 +87,8 @@ void notCompleteReview() { Room room = roomRepository.save(RoomFixture.ROOM_DOMAIN_WITH_PROGRESS(memberRepository.save(MemberFixture.MEMBER_ROOM_MANAGER_JOYSON()))); matchResultRepository.save(MatchResultFixture.MATCH_RESULT_DOMAIN(room.getId(), reviewer, reviewee)); - when(githubReviewClient.getReviewLink(anyString())).thenReturn(new GithubPullRequestReview[]{}); + when(githubReviewProvider.provideReviewInfo(anyString())) + .thenReturn(new GithubPullRequestReviewInfo(Collections.emptyMap())); assertThatThrownBy(() -> reviewService.completeReview(room.getId(), reviewer.getId(), reviewee.getId())) .asInstanceOf(InstanceOfAssertFactories.type(CoreaException.class)) diff --git a/frontend/public/index.html b/frontend/public/index.html index 4da635dc1..1300b15dd 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -16,7 +16,7 @@ property="og:description" content="주니어 개발자들이 서로 코드리뷰하고 피드백 받을 수 있는 플랫폼" /> - + @@ -39,5 +39,6 @@
+
diff --git a/frontend/public/logo.webp b/frontend/public/logo.png similarity index 100% rename from frontend/public/logo.webp rename to frontend/public/logo.png diff --git a/frontend/src/@types/icon.ts b/frontend/src/@types/icon.ts index cd7228f47..1c1674cd5 100644 --- a/frontend/src/@types/icon.ts +++ b/frontend/src/@types/icon.ts @@ -20,6 +20,8 @@ type IconKind = | "arrowDropDown" | "arrowDropUp" | "arrowRenew" - | "check"; + | "check" + | "menu" + | "close"; export default IconKind; diff --git a/frontend/src/apis/auth.api.ts b/frontend/src/apis/auth.api.ts index 663e8a793..5e01674d2 100644 --- a/frontend/src/apis/auth.api.ts +++ b/frontend/src/apis/auth.api.ts @@ -1,12 +1,11 @@ import apiClient from "./apiClient"; import { API_ENDPOINTS } from "./endpoints"; +import { UserInfoResponse } from "@/hooks/mutations/useMutateAuth"; import { UserInfo } from "@/@types/userInfo"; import { serverUrl } from "@/config/serverUrl"; import MESSAGES from "@/constants/message"; -export const postLogin = async ( - code: string, -): Promise<{ accessToken: string; refreshToken: string; userInfo: UserInfo }> => { +export const postLogin = async (code: string): Promise => { const response = await fetch(`${serverUrl}${API_ENDPOINTS.LOGIN}`, { method: "POST", headers: { @@ -26,12 +25,13 @@ export const postLogin = async ( const authBody = text ? JSON.parse(text) : response; const refreshToken = authBody.refreshToken; const userInfo = authBody.userInfo as UserInfo; + const memberRole = authBody.memberRole as string; if (!accessToken) { throw new Error(MESSAGES.ERROR.POST_LOGIN); } - return { accessToken, refreshToken, userInfo }; + return { accessToken, refreshToken, userInfo, memberRole }; }; export const postLogout = async (): Promise => { diff --git a/frontend/src/assets/index.ts b/frontend/src/assets/index.ts index 36cc6da44..d7fc49472 100644 --- a/frontend/src/assets/index.ts +++ b/frontend/src/assets/index.ts @@ -23,4 +23,12 @@ export { default as rank1 } from "@/assets/ranking/rank-1.svg"; export { default as rank2 } from "@/assets/ranking/rank-2.svg"; export { default as rank3 } from "@/assets/ranking/rank-3.svg"; +export { default as question_with_color } from "@/assets/intro/question_with_color.svg"; +export { default as puzzle_with_people_color } from "@/assets/intro/puzzle_with_people_color.svg"; +export { default as step1_pic } from "@/assets/intro/step1_pic.webp"; +export { default as step2_pic } from "@/assets/intro/step2_pic.webp"; +export { default as step3_pic } from "@/assets/intro/step3_pic.webp"; +export { default as step4_pic } from "@/assets/intro/step4_pic.webp"; +export { default as step5_pic } from "@/assets/intro/step5_pic.webp"; + export { default as mainLogo } from "@/assets/coreaLogo/coreaMainLogo.svg"; diff --git a/frontend/src/assets/intro/puzzle_with_people_color.svg b/frontend/src/assets/intro/puzzle_with_people_color.svg new file mode 100644 index 000000000..e9570b449 --- /dev/null +++ b/frontend/src/assets/intro/puzzle_with_people_color.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/src/assets/intro/question_with_color.svg b/frontend/src/assets/intro/question_with_color.svg new file mode 100644 index 000000000..5301c9219 --- /dev/null +++ b/frontend/src/assets/intro/question_with_color.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/intro/step1_pic.webp b/frontend/src/assets/intro/step1_pic.webp new file mode 100644 index 000000000..24550221b Binary files /dev/null and b/frontend/src/assets/intro/step1_pic.webp differ diff --git a/frontend/src/assets/intro/step2_pic.webp b/frontend/src/assets/intro/step2_pic.webp new file mode 100644 index 000000000..829485ff6 Binary files /dev/null and b/frontend/src/assets/intro/step2_pic.webp differ diff --git a/frontend/src/assets/intro/step3_pic.webp b/frontend/src/assets/intro/step3_pic.webp new file mode 100644 index 000000000..0a8bf4974 Binary files /dev/null and b/frontend/src/assets/intro/step3_pic.webp differ diff --git a/frontend/src/assets/intro/step4_pic.webp b/frontend/src/assets/intro/step4_pic.webp new file mode 100644 index 000000000..fc45d1503 Binary files /dev/null and b/frontend/src/assets/intro/step4_pic.webp differ diff --git a/frontend/src/assets/intro/step5_pic.webp b/frontend/src/assets/intro/step5_pic.webp new file mode 100644 index 000000000..48e3a17b2 Binary files /dev/null and b/frontend/src/assets/intro/step5_pic.webp differ diff --git a/frontend/src/components/common/Toast/Toast.tsx b/frontend/src/components/common/Toast/Toast.tsx index 742df80d4..3ad8b4df8 100644 --- a/frontend/src/components/common/Toast/Toast.tsx +++ b/frontend/src/components/common/Toast/Toast.tsx @@ -29,7 +29,12 @@ const Toast = () => { } return createPortal( - + {toastInfo.message} , toastContainer, diff --git a/frontend/src/components/common/button/Button.style.ts b/frontend/src/components/common/button/Button.style.ts index 8b580429f..4a35dede6 100644 --- a/frontend/src/components/common/button/Button.style.ts +++ b/frontend/src/components/common/button/Button.style.ts @@ -29,6 +29,10 @@ export const ButtonContainer = styled.button<{ `; const variantStyles = { + default: css` + color: ${({ theme }) => theme.COLOR.black}; + background-color: ${({ theme }) => theme.COLOR.white}; + `, primary: css` background-color: ${({ theme }) => theme.COLOR.primary2}; `, diff --git a/frontend/src/components/common/button/Button.tsx b/frontend/src/components/common/button/Button.tsx index d7d49cc17..23c99275b 100644 --- a/frontend/src/components/common/button/Button.tsx +++ b/frontend/src/components/common/button/Button.tsx @@ -1,7 +1,7 @@ import { ButtonHTMLAttributes } from "react"; import * as S from "@/components/common/button/Button.style"; -export type ButtonVariant = "primary" | "secondary" | "disable" | "confirm" | "error"; +export type ButtonVariant = "default" | "primary" | "secondary" | "disable" | "confirm" | "error"; export type ButtonSize = "xSmall" | "small" | "medium" | "large"; export interface ButtonProps extends ButtonHTMLAttributes { diff --git a/frontend/src/components/common/checkbox/Checkbox.style.ts b/frontend/src/components/common/checkbox/Checkbox.style.ts index d55eb3a70..839a9c7d9 100644 --- a/frontend/src/components/common/checkbox/Checkbox.style.ts +++ b/frontend/src/components/common/checkbox/Checkbox.style.ts @@ -1,17 +1,19 @@ import styled from "styled-components"; import { VisuallyHidden } from "@/styles/common"; -export const CheckboxLabel = styled.label` - cursor: pointer; +export const CheckboxLabel = styled.label<{ readonly: boolean }>` + cursor: ${({ readonly }) => (readonly ? "not-allowed" : "pointer")}; position: relative; display: flex; gap: 0.5rem; align-items: center; + + color: ${({ theme, readonly }) => (readonly ? theme.COLOR.grey2 : theme.COLOR.black)}; `; -export const CheckboxStyle = styled.div<{ checked: boolean }>` +export const CheckboxStyle = styled.div<{ checked: boolean; readonly: boolean }>` display: flex; align-items: center; justify-content: center; @@ -19,8 +21,13 @@ export const CheckboxStyle = styled.div<{ checked: boolean }>` width: 16px; height: 16px; - background-color: ${({ theme, checked }) => (checked ? theme.COLOR.primary2 : theme.COLOR.white)}; - border: 2px solid ${({ theme }) => theme.COLOR.primary2}; + background-color: ${({ theme, checked, readonly }) => { + if (readonly) return theme.COLOR.grey2; + if (checked) return theme.COLOR.primary2; + return theme.COLOR.white; + }}; + border: 2px solid + ${({ theme, readonly }) => (readonly ? theme.COLOR.grey2 : theme.COLOR.primary2)}; border-radius: 2px; `; diff --git a/frontend/src/components/common/checkbox/Checkbox.tsx b/frontend/src/components/common/checkbox/Checkbox.tsx index 0c1faa9c8..4edb14e07 100644 --- a/frontend/src/components/common/checkbox/Checkbox.tsx +++ b/frontend/src/components/common/checkbox/Checkbox.tsx @@ -6,13 +6,14 @@ interface CheckboxProps { id: string; label: string; checked: boolean; - onChange: (e: React.ChangeEvent) => void; + readonly?: boolean; + onChange?: (e: React.ChangeEvent) => void; } -const Checkbox = ({ id, label, checked, onChange }: CheckboxProps) => { +const Checkbox = ({ id, label, checked, readonly = false, onChange }: CheckboxProps) => { return ( - - + + {checked && } diff --git a/frontend/src/components/common/focusTrap/FocusTrap.tsx b/frontend/src/components/common/focusTrap/FocusTrap.tsx index 2282ccdd3..d27556060 100644 --- a/frontend/src/components/common/focusTrap/FocusTrap.tsx +++ b/frontend/src/components/common/focusTrap/FocusTrap.tsx @@ -36,6 +36,12 @@ const FocusTrap = (props: FocusTrapProps) => { ref: focusTrapRef, }); + const updateFocusableElements = () => { + if (focusTrapRef.current) { + focusableElements.current = getFocusableElements(focusTrapRef.current); + } + }; + const focusNextElement = () => { currentFocusIndex.current = (currentFocusIndex.current + 1) % focusableElements.current.length; focusableElements.current[currentFocusIndex.current]?.focus(); @@ -81,9 +87,23 @@ const FocusTrap = (props: FocusTrapProps) => { focusableElements.current = getFocusableElements(focusTrapRef.current); } + const observer = new MutationObserver(() => { + updateFocusableElements(); + }); + + if (focusTrapRef.current) { + observer.observe(focusTrapRef.current, { + childList: true, + subtree: true, + attributes: true, + }); + updateFocusableElements(); + } + document.addEventListener("keydown", handleKeyPress); return () => { + observer.disconnect(); focusableElements.current = []; document.removeEventListener("keydown", handleKeyPress); }; diff --git a/frontend/src/components/common/header/Header.style.ts b/frontend/src/components/common/header/Header.style.ts index 981d174fd..0511da113 100644 --- a/frontend/src/components/common/header/Header.style.ts +++ b/frontend/src/components/common/header/Header.style.ts @@ -49,6 +49,10 @@ export const HeaderNavBarContainer = styled.ul` display: flex; gap: 1rem; align-items: center; + + ${media.small` + display: none; + `} `; export const HeaderItem = styled.li<{ $isMain: boolean }>` @@ -71,3 +75,12 @@ export const HeaderItem = styled.li<{ $isMain: boolean }>` border-bottom: 3px solid ${({ theme }) => theme.COLOR.black}; } `; + +// 사이드바 +export const SideNavBarContainer = styled.div` + display: none; + + ${media.small` + display: block; + `} +`; diff --git a/frontend/src/components/common/header/Header.tsx b/frontend/src/components/common/header/Header.tsx index 04c7301d1..af29b49bc 100644 --- a/frontend/src/components/common/header/Header.tsx +++ b/frontend/src/components/common/header/Header.tsx @@ -1,10 +1,19 @@ -import ProfileDropdown from "./ProfileDropdown"; import { useEffect, useState } from "react"; import { Link, useLocation } from "react-router-dom"; +import Button from "@/components/common/button/Button"; import * as S from "@/components/common/header/Header.style"; +import ProfileDropdown from "@/components/common/header/ProfileDropdown"; +import SideNavBar from "@/components/common/header/SideNavBar"; +import Icon from "@/components/common/icon/Icon"; import { githubAuthUrl } from "@/config/githubAuthUrl"; +const MOBILE_BREAKPOINT = 639; + const headerItems = [ + { + name: "소개", + path: "/intro", + }, { name: "코드리뷰가이드", path: "/guide", @@ -18,9 +27,30 @@ const headerItems = [ const Header = () => { const { pathname } = useLocation(); const [isSelect, setIsSelect] = useState(""); + const [isSideNavOpen, setIsSideNavOpen] = useState(false); + const [isMobile, setIsMobile] = useState(window.innerWidth <= MOBILE_BREAKPOINT); const isLoggedIn = !!localStorage.getItem("accessToken"); const isMain = pathname === "/"; + useEffect(() => { + const handleResize = () => { + const mobile = window.innerWidth <= MOBILE_BREAKPOINT; + setIsMobile(mobile); + + if (!mobile && isSideNavOpen) { + setIsSideNavOpen(false); + } + }; + + window.addEventListener("resize", handleResize); + + handleResize(); + + return () => { + window.removeEventListener("resize", handleResize); + }; + }, [isSideNavOpen]); + useEffect(() => { const currentItem = headerItems.find((item) => item.path === pathname); if (currentItem) { @@ -28,7 +58,11 @@ const Header = () => { } else { setIsSelect(""); } - }, [pathname, headerItems]); + }, [pathname]); + + const toggleSideNav = () => { + setIsSideNavOpen(!isSideNavOpen); + }; return ( @@ -38,6 +72,15 @@ const Header = () => { + + + 문의 + + {headerItems.map((item) => ( { )} + + + + {isMobile && ( + + )} ); }; diff --git a/frontend/src/components/common/header/SideNavBar.style.ts b/frontend/src/components/common/header/SideNavBar.style.ts new file mode 100644 index 000000000..692a52902 --- /dev/null +++ b/frontend/src/components/common/header/SideNavBar.style.ts @@ -0,0 +1,146 @@ +import styled, { css, keyframes } from "styled-components"; + +const fadeIn = keyframes` + 0% { + opacity: 0; + transform: translateX(100%); + } + 100% { + opacity: 1; + transform: translate(0); + } +`; + +const fadeOut = keyframes` + 0% { + opacity: 1; + transform: translate(0); + } + 100% { + opacity: 0; + transform: translate(100%); + } +`; + +export const BackDrop = styled.div<{ $isOpen: boolean }>` + position: fixed; + top: 0; + left: 0; + + width: 100vw; + height: 100vh; + + visibility: ${({ $isOpen }) => ($isOpen ? "visible" : "hidden")}; + opacity: 0.4; + background-color: ${({ theme }) => theme.COLOR.black}; +`; + +export const SideNavBarContainer = styled.div<{ $isOpen: boolean; $isClosing: boolean }>` + position: fixed; + top: 0; + right: ${({ $isOpen }) => ($isOpen ? "0" : "-60%")}; + + width: 60%; + height: 100vh; + + border-radius: 10px 0 0 10px; + + overflow: hidden auto; + + background-color: ${({ theme }) => theme.COLOR.white}; + + ${({ $isOpen, $isClosing }) => css` + ${$isOpen && + css` + animation: ${fadeIn} 0.5s ease backwards; + `} + + ${$isClosing && + css` + animation: ${fadeOut} 0.5s ease backwards; + `} + `} +`; + +export const TopSection = styled.div` + display: flex; + flex-direction: column; + gap: 1rem; + + padding: 1rem; + + background: linear-gradient(to right, rgb(255 250 245 / 100%), rgb(230 230 255 / 100%)); +`; + +export const ProfileWrapper = styled.div` + display: flex; + gap: 1rem; + align-items: center; + width: 100%; +`; + +export const ProfileInfo = styled.div` + display: flex; + flex-direction: column; + gap: 0.4rem; + color: ${({ theme }) => theme.COLOR.black}; + + strong { + font: ${({ theme }) => theme.TEXT.medium_bold}; + } + + span { + font: ${({ theme }) => theme.TEXT.semiSmall}; + } +`; + +export const NavItems = styled.ul` + display: flex; + flex-direction: column; + gap: 0.6rem; + + width: 100%; + padding: 1rem; + + list-style-type: none; +`; + +export const NavItem = styled.li` + cursor: pointer; + + display: flex; + align-items: center; + justify-content: center; + + width: 100%; + height: 42px; + + font: ${({ theme }) => theme.TEXT.small}; + color: ${({ theme }) => theme.COLOR.black}; + text-align: center; + + transition: 0.4s background-color; + + &:hover, + &:focus { + /* background-color: ${({ theme }) => theme.COLOR.primary1}; */ + border: 1px solid ${({ theme }) => theme.COLOR.grey1}; + border-radius: 5px; + } + + a { + display: flex; + align-items: center; + justify-content: center; + + width: 100%; + height: 100%; + + color: inherit; + text-decoration: none; + } +`; + +export const LogoutButton = styled(NavItem)` + color: ${({ theme }) => theme.COLOR.black}; +`; diff --git a/frontend/src/components/common/header/SideNavBar.tsx b/frontend/src/components/common/header/SideNavBar.tsx new file mode 100644 index 000000000..8daf40ab2 --- /dev/null +++ b/frontend/src/components/common/header/SideNavBar.tsx @@ -0,0 +1,172 @@ +import { useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { Link } from "react-router-dom"; +import useMutateAuth from "@/hooks/mutations/useMutateAuth"; +import Button from "@/components/common/button/Button"; +import * as S from "@/components/common/header/SideNavBar.style"; +import Icon from "@/components/common/icon/Icon"; +import Profile from "@/components/common/profile/Profile"; +import { githubAuthUrl } from "@/config/githubAuthUrl"; + +const portalElement = document.getElementById("sideNavBar") as HTMLElement; + +const loggedInMenuItems = [ + { + name: "마이페이지", + path: "/profile", + }, + { + name: "피드백 모아보기", + path: "/feedback", + }, + { + name: "서비스 소개", + path: "/intro", + }, + { + name: "코드리뷰가이드", + path: "/guide", + }, +]; + +const commonMenuItems = [ + { + name: "서비스 소개", + path: "/intro", + }, + { + name: "코드리뷰가이드", + path: "/guide", + }, +]; + +interface SideNavBarProps { + isOpen: boolean; + onClose: () => void; + isLoggedIn: boolean; +} + +const SideNavBar = ({ isOpen, onClose, isLoggedIn }: SideNavBarProps) => { + const [isClosing, setIsClosing] = useState(false); + const userInfo = JSON.parse(localStorage.getItem("userInfo") || "{}"); + const { postLogoutMutation } = useMutateAuth(); + + useEffect(() => { + if (isOpen) { + document.body.style.overflow = "hidden"; + setIsClosing(false); + } + + return () => { + document.body.style.overflow = "auto"; + }; + }, [isOpen]); + + const handleClose = () => { + setIsClosing(true); + setTimeout(() => { + onClose(); + setIsClosing(false); + }, 500); + }; + + const handleLogoutClick = () => { + postLogoutMutation.mutate(); + onClose(); + }; + + const sideNavContent = ( + + {isLoggedIn ? ( + <> + + + + + + {userInfo.name} + {userInfo.email !== "" ? userInfo.email : "email 비공개"} + + + { + if (e.key === "Enter") handleLogoutClick(); + }} + > + 로그아웃 + + + + + {loggedInMenuItems.map((item) => ( + + + {item.name} + + + ))} + + + 문의 + + + + + ) : ( + <> + + + + + 로그인 + + + + + + {commonMenuItems.map((item) => ( + + + {item.name} + + + ))} + + + 문의 + + + + + )} + + ); + + return createPortal( + <> + + {sideNavContent} + , + portalElement, + ); +}; + +export default SideNavBar; diff --git a/frontend/src/components/common/icon/Icon.tsx b/frontend/src/components/common/icon/Icon.tsx index d83f5b448..2dc013478 100644 --- a/frontend/src/components/common/icon/Icon.tsx +++ b/frontend/src/components/common/icon/Icon.tsx @@ -14,9 +14,11 @@ import { MdAutorenew, MdCalendarMonth, MdCheck, + MdClear, MdExpandMore, MdInfoOutline, MdInsertLink, + MdMenu, MdOutlineArrowDropDown, MdOutlineArrowDropUp, MdOutlineCreate, @@ -51,6 +53,8 @@ const ICON: { [key in IconKind]: IconType } = { arrowDropUp: MdOutlineArrowDropUp, arrowRenew: MdAutorenew, check: MdCheck, + menu: MdMenu, + close: MdClear, }; interface IconProps { diff --git a/frontend/src/components/common/iconRadioButton/IconRadioButton.style.ts b/frontend/src/components/common/iconRadioButton/IconRadioButton.style.ts index e55ca427a..0c66b2d3d 100644 --- a/frontend/src/components/common/iconRadioButton/IconRadioButton.style.ts +++ b/frontend/src/components/common/iconRadioButton/IconRadioButton.style.ts @@ -5,7 +5,7 @@ interface IconRadioButtonBoxProps { $color?: string; } -export const IconRadioButtonContainer = styled.label` +export const IconRadioButtonLabel = styled.label` cursor: pointer; display: flex; diff --git a/frontend/src/components/common/iconRadioButton/IconRadioButton.tsx b/frontend/src/components/common/iconRadioButton/IconRadioButton.tsx index 90b217208..df402462a 100644 --- a/frontend/src/components/common/iconRadioButton/IconRadioButton.tsx +++ b/frontend/src/components/common/iconRadioButton/IconRadioButton.tsx @@ -25,25 +25,21 @@ const IconRadioButton = ({ }; return ( - + - + {children} - {text} - + {text} + ); }; diff --git a/frontend/src/components/common/modal/Modal.tsx b/frontend/src/components/common/modal/Modal.tsx index 9b762a9f8..d3b7a0ca8 100644 --- a/frontend/src/components/common/modal/Modal.tsx +++ b/frontend/src/components/common/modal/Modal.tsx @@ -18,6 +18,22 @@ export interface ModalProps { const Modal = ({ isOpen, onClose, hasCloseButton = true, style, children }: ModalProps) => { const [isClosing, setIsClosing] = useState(false); useEffect(() => { + [...document.body.children].forEach((element) => { + if (element.id === "toast") return; + + if (element.id === "modal") { + element.removeAttribute("aria-hidden"); + return; + } + + if (isOpen) { + element.setAttribute("aria-hidden", "true"); + return; + } + + element.removeAttribute("aria-hidden"); + }); + if (isOpen) { document.body.style.overflow = "hidden"; } else { @@ -58,11 +74,14 @@ const Modal = ({ isOpen, onClose, hasCloseButton = true, style, children }: Moda }} >
+ {hasCloseButton && ( + + × + + )} {children} - {hasCloseButton && ×}
- , , portalElement, diff --git a/frontend/src/components/common/textarea/Textarea.tsx b/frontend/src/components/common/textarea/Textarea.tsx index 54fd7d738..7ef197d16 100644 --- a/frontend/src/components/common/textarea/Textarea.tsx +++ b/frontend/src/components/common/textarea/Textarea.tsx @@ -29,7 +29,7 @@ export const Textarea = ({ {...rest} /> {showCharCount && ( - + {value.toString().length} {rest.maxLength ? ` / ${rest.maxLength}자` : ""} diff --git a/frontend/src/components/feedback/evaluationPointBar/EvaluationPointBar.tsx b/frontend/src/components/feedback/evaluationPointBar/EvaluationPointBar.tsx index 8ae03f901..e0fe65d04 100644 --- a/frontend/src/components/feedback/evaluationPointBar/EvaluationPointBar.tsx +++ b/frontend/src/components/feedback/evaluationPointBar/EvaluationPointBar.tsx @@ -62,10 +62,12 @@ const EvaluationPointBar = ({ onKeyDown={(e) => { if (e.key === "Enter") handleRadioChange(option.value); }} + aria-labelledby={`score-${option.value}`} > - + + ))} diff --git a/frontend/src/components/feedback/feedbackForm/FeedbackForm.style.ts b/frontend/src/components/feedback/feedbackForm/FeedbackForm.style.ts index 9c32350c6..e1e29cf3f 100644 --- a/frontend/src/components/feedback/feedbackForm/FeedbackForm.style.ts +++ b/frontend/src/components/feedback/feedbackForm/FeedbackForm.style.ts @@ -10,13 +10,13 @@ export const FeedbackFormContainer = styled.div` gap: 4rem; `; -export const ItemContainer = styled.div` +export const ItemContainer = styled.fieldset` display: flex; flex-direction: column; gap: 1.6rem; `; -export const ModalQuestion = styled.p` +export const ModalQuestion = styled.legend` display: flex; flex-wrap: wrap; gap: 0.4rem; @@ -24,9 +24,10 @@ export const ModalQuestion = styled.p` font: ${({ theme }) => theme.TEXT.small_bold}; color: ${({ theme }) => theme.COLOR.grey4}; +`; - span { - font: ${({ theme }) => theme.TEXT.semiSmall}; - color: ${({ theme }) => theme.COLOR.error}; - } +export const Required = styled.span` + padding-top: 0.5rem; + font: ${({ theme }) => theme.TEXT.semiSmall}; + color: ${({ theme }) => theme.COLOR.error}; `; diff --git a/frontend/src/components/feedback/feedbackForm/RevieweeFeedbackForm.tsx b/frontend/src/components/feedback/feedbackForm/RevieweeFeedbackForm.tsx index 605f7abb6..f3204ccba 100644 --- a/frontend/src/components/feedback/feedbackForm/RevieweeFeedbackForm.tsx +++ b/frontend/src/components/feedback/feedbackForm/RevieweeFeedbackForm.tsx @@ -31,10 +31,8 @@ const RevieweeFeedbackForm = ({ formState, onChange, modalType }: RevieweeFeedba return ( - - 리뷰이의 개발 역량 향상을 위해 코드를 평가 해주세요. - *필수입력 - + 리뷰이의 개발 역량 향상을 위해 코드를 평가 해주세요. + *필수입력 onChange("evaluationPoint", value)} @@ -43,10 +41,8 @@ const RevieweeFeedbackForm = ({ formState, onChange, modalType }: RevieweeFeedba - - 위와 같이 선택한 이유를 알려주세요. (1개 이상 선택) - *필수입력 - + 위와 같이 선택한 이유를 알려주세요. (1개 이상 선택) + *필수입력 onChange("feedbackKeywords", value)} @@ -57,10 +53,8 @@ const RevieweeFeedbackForm = ({ formState, onChange, modalType }: RevieweeFeedba - - 리뷰이의 코드를 추천하시나요? (비공개 항목) - *필수입력 - + 리뷰이의 코드를 추천하시나요? (비공개 항목) + *필수입력 onChange("recommendationPoint", value)} @@ -71,7 +65,7 @@ const RevieweeFeedbackForm = ({ formState, onChange, modalType }: RevieweeFeedba 추가적으로 하고 싶은 피드백이 있다면 남겨 주세요.