diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index a9b43c184..2a38744c1 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -9,6 +9,8 @@ on: pull_request: branches: - develop + paths: + - 'backend/**' jobs: build: diff --git a/backend/src/main/java/reviewme/config/CorsConfig.java b/backend/src/main/java/reviewme/config/CorsConfig.java new file mode 100644 index 000000000..448e95eb7 --- /dev/null +++ b/backend/src/main/java/reviewme/config/CorsConfig.java @@ -0,0 +1,16 @@ +package reviewme.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class CorsConfig implements WebMvcConfigurer { + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedMethods("*") + .allowedOrigins("*"); + } +} diff --git a/backend/src/main/java/reviewme/global/GlobalExceptionHandler.java b/backend/src/main/java/reviewme/global/GlobalExceptionHandler.java index 1ae2509ee..c00b5b939 100644 --- a/backend/src/main/java/reviewme/global/GlobalExceptionHandler.java +++ b/backend/src/main/java/reviewme/global/GlobalExceptionHandler.java @@ -2,11 +2,24 @@ import java.util.List; import java.util.Map; +import org.springframework.beans.TypeMismatchException; import org.springframework.http.HttpStatus; import org.springframework.http.ProblemDetail; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.BindException; +import org.springframework.validation.method.MethodValidationException; +import org.springframework.web.HttpMediaTypeException; +import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.ServletRequestBindingException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.HandlerMethodValidationException; +import org.springframework.web.multipart.support.MissingServletRequestPartException; +import org.springframework.web.server.MissingRequestValueException; +import org.springframework.web.servlet.NoHandlerFoundException; +import org.springframework.web.servlet.resource.NoResourceFoundException; +import reviewme.global.exception.BadRequestException; import reviewme.global.exception.FieldErrorResponse; import reviewme.global.exception.NotFoundException; @@ -18,8 +31,50 @@ public ProblemDetail handleNotFoundException(NotFoundException ex) { return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getErrorMessage()); } + @ExceptionHandler(BadRequestException.class) + public ProblemDetail handleBadRequestException(BadRequestException ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getErrorMessage()); + } + + @ExceptionHandler(Exception.class) + public ProblemDetail handleException(Exception ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "서버 에러가 발생했습니다."); + } + + // Following exceptions are exceptions that occur in Spring + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ProblemDetail handleHttpRequestMethodNotSupportedException(Exception ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.METHOD_NOT_ALLOWED, "지원하지 않는 HTTP 메서드입니다."); + } + + @ExceptionHandler(HttpMediaTypeException.class) + public ProblemDetail handleHttpMediaTypeException(Exception ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "잘못된 media type 입니다."); + } + + @ExceptionHandler({MissingRequestValueException.class, MissingServletRequestPartException.class}) + public ProblemDetail handleMissingRequestException(Exception ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "필수 요청 데이터가 누락되었습니다."); + } + + @ExceptionHandler({ServletRequestBindingException.class, HttpMessageNotReadableException.class}) + public ProblemDetail handleServletRequestBindingException(Exception ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "요청을 읽을 수 없습니다."); + } + + @ExceptionHandler({MethodValidationException.class, BindException.class, + TypeMismatchException.class, HandlerMethodValidationException.class}) + public ProblemDetail handleRequestFormatException(Exception ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "요청의 형식이 잘못되었습니다."); + } + + @ExceptionHandler({NoHandlerFoundException.class, NoResourceFoundException.class}) + public ProblemDetail handleNoHandlerFoundException(Exception ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, "잘못된 경로의 요청입니다."); + } + @ExceptionHandler(MethodArgumentNotValidException.class) - public ProblemDetail handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) { + public ProblemDetail handleMethodArgumentNotValid(MethodArgumentNotValidException ex) { List fieldErrors = ex.getBindingResult() .getFieldErrors() .stream() diff --git a/backend/src/main/java/reviewme/keyword/contoller/KeywordApi.java b/backend/src/main/java/reviewme/keyword/contoller/KeywordApi.java deleted file mode 100644 index 2f280c7b6..000000000 --- a/backend/src/main/java/reviewme/keyword/contoller/KeywordApi.java +++ /dev/null @@ -1,16 +0,0 @@ -package reviewme.keyword.contoller; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import org.springframework.http.ResponseEntity; -import reviewme.keyword.dto.response.KeywordsResponse; - -@Tag(name = "키워드 관리") -public interface KeywordApi { - - @Operation( - summary = "모든 키워드 조회", - description = "모든 키워드를 조회한다." - ) - ResponseEntity findAllKeywords(); -} diff --git a/backend/src/main/java/reviewme/keyword/contoller/KeywordController.java b/backend/src/main/java/reviewme/keyword/contoller/KeywordController.java deleted file mode 100644 index de47ba8cd..000000000 --- a/backend/src/main/java/reviewme/keyword/contoller/KeywordController.java +++ /dev/null @@ -1,21 +0,0 @@ -package reviewme.keyword.contoller; - -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RestController; -import reviewme.keyword.dto.response.KeywordsResponse; -import reviewme.keyword.service.KeywordService; - -@RestController -@RequiredArgsConstructor -public class KeywordController implements KeywordApi { - - private final KeywordService keywordService; - - @GetMapping("/keywords") - public ResponseEntity findAllKeywords() { - KeywordsResponse response = keywordService.findAllKeywords(); - return ResponseEntity.ok(response); - } -} diff --git a/backend/src/main/java/reviewme/keyword/domain/Keyword.java b/backend/src/main/java/reviewme/keyword/domain/Keyword.java index 5cdefc018..73a66b1b6 100644 --- a/backend/src/main/java/reviewme/keyword/domain/Keyword.java +++ b/backend/src/main/java/reviewme/keyword/domain/Keyword.java @@ -21,16 +21,11 @@ public class Keyword { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(name = "detail", nullable = false) - private String detail; + @Column(name = "content", nullable = false) + private String content; - Keyword(Long id, String detail) { - this.id = id; - this.detail = detail; - } - - public Keyword(String detail) { - this(null, detail); + public Keyword(String content) { + this.content = content; } @Override @@ -42,7 +37,7 @@ public boolean equals(Object o) { return false; } if (id == null) { - return Objects.equals(detail, keyword.detail); + return Objects.equals(content, keyword.content); } return Objects.equals(id, keyword.id); } @@ -50,7 +45,7 @@ public boolean equals(Object o) { @Override public int hashCode() { if (id == null) { - return Objects.hash(detail); + return Objects.hash(content); } return Objects.hash(id); } diff --git a/backend/src/main/java/reviewme/keyword/domain/Keywords.java b/backend/src/main/java/reviewme/keyword/domain/Keywords.java new file mode 100644 index 000000000..4278b9d64 --- /dev/null +++ b/backend/src/main/java/reviewme/keyword/domain/Keywords.java @@ -0,0 +1,50 @@ +package reviewme.keyword.domain; + +import jakarta.persistence.CollectionTable; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Embeddable; +import jakarta.persistence.JoinColumn; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import reviewme.keyword.domain.exception.DuplicateKeywordException; +import reviewme.keyword.domain.exception.KeywordLimitExceedException; + +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Keywords { + + private static final int MAX_KEYWORD_COUNT = 5; + + @ElementCollection + @CollectionTable(name = "review_keyword", joinColumns = @JoinColumn(name = "review_id")) + private Set keywordIds; + + public Keywords(List selectedKeywords) { + if (selectedKeywords.size() > MAX_KEYWORD_COUNT) { + throw new KeywordLimitExceedException(MAX_KEYWORD_COUNT); + } + if (hasDuplicateKeywords(selectedKeywords)) { + throw new DuplicateKeywordException(); + } + this.keywordIds = selectedKeywords.stream() + .map(Keyword::getId) + .collect(Collectors.toSet()); + } + + private boolean hasDuplicateKeywords(List selectedKeywords) { + long distinctKeywordCount = selectedKeywords.stream() + .distinct() + .count(); + return selectedKeywords.size() != distinctKeywordCount; + } + + public Set getKeywordIds() { + return Collections.unmodifiableSet(keywordIds); + } +} diff --git a/backend/src/main/java/reviewme/keyword/domain/SelectedKeywords.java b/backend/src/main/java/reviewme/keyword/domain/SelectedKeywords.java deleted file mode 100644 index 99ac1d40b..000000000 --- a/backend/src/main/java/reviewme/keyword/domain/SelectedKeywords.java +++ /dev/null @@ -1,34 +0,0 @@ -package reviewme.keyword.domain; - -import java.util.Collections; -import java.util.List; -import reviewme.keyword.domain.exception.DuplicateKeywordException; -import reviewme.keyword.domain.exception.KeywordLimitExceedException; - -public class SelectedKeywords { - - private static final int MAX_KEYWORD_COUNT = 5; - - private final List keywords; - - public SelectedKeywords(List selectedKeywords) { - if (selectedKeywords.size() > MAX_KEYWORD_COUNT) { - throw new KeywordLimitExceedException(MAX_KEYWORD_COUNT); - } - if (hasDuplicateKeywords(selectedKeywords)) { - throw new DuplicateKeywordException(); - } - this.keywords = selectedKeywords; - } - - private boolean hasDuplicateKeywords(List selectedKeywords) { - long distinctKeywordCount = selectedKeywords.stream() - .distinct() - .count(); - return selectedKeywords.size() != distinctKeywordCount; - } - - public List getKeywords() { - return Collections.unmodifiableList(keywords); - } -} diff --git a/backend/src/main/java/reviewme/keyword/service/KeywordService.java b/backend/src/main/java/reviewme/keyword/service/KeywordService.java index 86a98500a..9341d0b6a 100644 --- a/backend/src/main/java/reviewme/keyword/service/KeywordService.java +++ b/backend/src/main/java/reviewme/keyword/service/KeywordService.java @@ -16,7 +16,7 @@ public class KeywordService { public KeywordsResponse findAllKeywords() { List responses = keywordRepository.findAll() .stream() - .map(keyword -> new KeywordResponse(keyword.getId(), keyword.getDetail())) + .map(keyword -> new KeywordResponse(keyword.getId(), keyword.getContent())) .toList(); return new KeywordsResponse(responses); } diff --git a/backend/src/main/java/reviewme/member/domain/GithubId.java b/backend/src/main/java/reviewme/member/domain/GithubId.java new file mode 100644 index 000000000..c15a245c2 --- /dev/null +++ b/backend/src/main/java/reviewme/member/domain/GithubId.java @@ -0,0 +1,22 @@ +package reviewme.member.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EqualsAndHashCode(of = "id") +@Getter +public class GithubId { + + @Column(name = "github_id", nullable = false) + private long id; + + public GithubId(long id) { + this.id = id; + } +} diff --git a/backend/src/main/java/reviewme/member/domain/GithubReviewerGroup.java b/backend/src/main/java/reviewme/member/domain/GithubIdReviewerGroup.java similarity index 61% rename from backend/src/main/java/reviewme/member/domain/GithubReviewerGroup.java rename to backend/src/main/java/reviewme/member/domain/GithubIdReviewerGroup.java index 724c6b3c3..5497a76b2 100644 --- a/backend/src/main/java/reviewme/member/domain/GithubReviewerGroup.java +++ b/backend/src/main/java/reviewme/member/domain/GithubIdReviewerGroup.java @@ -1,35 +1,33 @@ package reviewme.member.domain; -import jakarta.persistence.Column; +import jakarta.persistence.Embedded; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; -import jakarta.persistence.Table; import lombok.AccessLevel; +import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; @Entity -@Table(name = "github_reviewer_group") @NoArgsConstructor(access = AccessLevel.PROTECTED) +@EqualsAndHashCode(of = "id") @Getter -public class GithubReviewerGroup { +public class GithubIdReviewerGroup { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(name = "github_id", nullable = false) - private String githubId; + @Embedded + private GithubId githubId; @ManyToOne - @JoinColumn(name = "reviewer_group_id") private ReviewerGroup reviewerGroup; - public GithubReviewerGroup(String githubId, ReviewerGroup reviewerGroup) { + public GithubIdReviewerGroup(GithubId githubId, ReviewerGroup reviewerGroup) { this.githubId = githubId; this.reviewerGroup = reviewerGroup; } diff --git a/backend/src/main/java/reviewme/member/domain/Member.java b/backend/src/main/java/reviewme/member/domain/Member.java index f86f0f67e..0e142fb2d 100644 --- a/backend/src/main/java/reviewme/member/domain/Member.java +++ b/backend/src/main/java/reviewme/member/domain/Member.java @@ -1,11 +1,13 @@ package reviewme.member.domain; import jakarta.persistence.Column; +import jakarta.persistence.Embedded; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; +import java.util.Objects; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -23,11 +25,33 @@ public class Member { @Column(name = "name", nullable = false) private String name; - @Column(name = "github_id", nullable = false) - private String githubId; + @Embedded + private GithubId githubId; - public Member(String name, String githubId) { + public Member(String name, long githubId) { this.name = name; - this.githubId = githubId; + this.githubId = new GithubId(githubId); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof Member member)) { + return false; + } + if (id == null) { + return Objects.equals(githubId, member.githubId); + } + return Objects.equals(id, member.id); + } + + @Override + public int hashCode() { + if (id == null) { + return Objects.hash(githubId); + } + return Objects.hash(id); } } diff --git a/backend/src/main/java/reviewme/member/domain/ReviewerGroup.java b/backend/src/main/java/reviewme/member/domain/ReviewerGroup.java index 02ad4a916..60207d593 100644 --- a/backend/src/main/java/reviewme/member/domain/ReviewerGroup.java +++ b/backend/src/main/java/reviewme/member/domain/ReviewerGroup.java @@ -1,20 +1,30 @@ package reviewme.member.domain; import jakarta.persistence.Column; +import jakarta.persistence.Embedded; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; import jakarta.persistence.Table; import java.time.Duration; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; -import reviewme.member.domain.exception.DescriptionLengthExceededException; +import reviewme.member.domain.exception.InvalidDescriptionLengthException; import reviewme.member.domain.exception.InvalidGroupNameLengthException; +import reviewme.member.domain.exception.SelfReviewException; +import reviewme.review.domain.Review; +import reviewme.review.domain.exception.DeadlineExpiredException; +import reviewme.review.domain.exception.RevieweeMismatchException; +import reviewme.review.exception.GithubReviewerGroupUnAuthorizedException; +import reviewme.review.exception.ReviewAlreadySubmittedException; @Entity @Table(name = "reviewer_group") @@ -30,37 +40,75 @@ public class ReviewerGroup { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @Embedded + private ReviewerGroupGithubIds reviewerGithubIds; + @ManyToOne @JoinColumn(name = "reviewee_id", nullable = false) private Member reviewee; + @OneToMany(mappedBy = "reviewerGroup") + private List reviews; + @Column(name = "group_name", nullable = false) private String groupName; @Column(name = "description", nullable = false) private String description; - @Column(name = "createdAt", nullable = false) - private LocalDateTime createdAt; + @Column(name = "deadline", nullable = false) + private LocalDateTime deadline; + + @Column(name = "thumbnail_url", nullable = false) + private String thumbnailUrl; - public ReviewerGroup(Member reviewee, String groupName, String description, LocalDateTime createdAt) { + public ReviewerGroup(Member reviewee, List reviewerGithubIds, + String groupName, String description, LocalDateTime deadline) { if (groupName.isBlank() || groupName.length() > MAX_GROUP_NAME_LENGTH) { throw new InvalidGroupNameLengthException(MAX_GROUP_NAME_LENGTH); } if (description.length() > MAX_DESCRIPTION_LENGTH) { - throw new DescriptionLengthExceededException(MAX_DESCRIPTION_LENGTH); + throw new InvalidDescriptionLengthException(MAX_DESCRIPTION_LENGTH); + } + if (reviewerGithubIds.contains(reviewee.getGithubId())) { + throw new SelfReviewException(); } this.reviewee = reviewee; + this.reviewerGithubIds = new ReviewerGroupGithubIds(this, reviewerGithubIds); this.groupName = groupName; this.description = description; - this.createdAt = createdAt; + this.deadline = deadline; + this.reviews = new ArrayList<>(); + this.thumbnailUrl = "https://github.com/octocat.png"; } public boolean isDeadlineExceeded(LocalDateTime now) { - return now.isAfter(getDeadline()); + return now.isAfter(deadline); + } + + public void addReview(Review review) { + Member reviewer = review.getReviewer(); + if (isDeadlineExceeded(review.getCreatedAt())) { + throw new DeadlineExpiredException(); + } + if (hasSubmittedReviewBy(reviewer)) { + throw new ReviewAlreadySubmittedException(); + } + if (reviewerGithubIds.doesNotContain(reviewer)) { + throw new GithubReviewerGroupUnAuthorizedException(); + } + if (!review.getReviewee().equals(reviewee)) { + throw new RevieweeMismatchException(); + } + reviews.add(review); + } + + private boolean hasSubmittedReviewBy(Member reviewer) { + return reviews.stream() + .anyMatch(review -> review.isSubmittedBy(reviewer)); } - public LocalDateTime getDeadline() { - return createdAt.plus(DEADLINE_DURATION); + public void addReviewerGithubId(GithubIdReviewerGroup githubIdReviewerGroup) { + reviewerGithubIds.add(githubIdReviewerGroup); } } diff --git a/backend/src/main/java/reviewme/member/domain/ReviewerGroupGithubIds.java b/backend/src/main/java/reviewme/member/domain/ReviewerGroupGithubIds.java new file mode 100644 index 000000000..e61919614 --- /dev/null +++ b/backend/src/main/java/reviewme/member/domain/ReviewerGroupGithubIds.java @@ -0,0 +1,46 @@ +package reviewme.member.domain; + +import jakarta.persistence.Embeddable; +import jakarta.persistence.OneToMany; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import reviewme.member.domain.exception.DuplicateReviewerException; +import reviewme.member.domain.exception.EmptyReviewerException; + +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ReviewerGroupGithubIds { + + @OneToMany(mappedBy = "reviewerGroup") + private Set reviewerGithubIds; + + public ReviewerGroupGithubIds(ReviewerGroup reviewerGroup, List githubIds) { + if (githubIds.isEmpty()) { + throw new EmptyReviewerException(); + } + Set reviewers = githubIds.stream() + .map(githubId -> new GithubIdReviewerGroup(githubId, reviewerGroup)) + .collect(Collectors.toSet()); + if (reviewers.size() != githubIds.size()) { + throw new DuplicateReviewerException(); + } + this.reviewerGithubIds = reviewers; + } + + public void add(GithubIdReviewerGroup githubIdReviewerGroup) { + if (reviewerGithubIds.contains(githubIdReviewerGroup)) { + throw new DuplicateReviewerException(); + } + reviewerGithubIds.add(githubIdReviewerGroup); + } + + public boolean doesNotContain(Member reviewer) { + GithubId githubId = reviewer.getGithubId(); + return reviewerGithubIds.stream() + .map(GithubIdReviewerGroup::getGithubId) + .noneMatch(githubId::equals); + } +} diff --git a/backend/src/main/java/reviewme/member/domain/exception/DuplicateReviewerException.java b/backend/src/main/java/reviewme/member/domain/exception/DuplicateReviewerException.java new file mode 100644 index 000000000..133dee121 --- /dev/null +++ b/backend/src/main/java/reviewme/member/domain/exception/DuplicateReviewerException.java @@ -0,0 +1,10 @@ +package reviewme.member.domain.exception; + +import reviewme.global.exception.BadRequestException; + +public class DuplicateReviewerException extends BadRequestException { + + public DuplicateReviewerException() { + super("리뷰어는 중복될 수 없습니다."); + } +} diff --git a/backend/src/main/java/reviewme/member/domain/exception/EmptyReviewerException.java b/backend/src/main/java/reviewme/member/domain/exception/EmptyReviewerException.java new file mode 100644 index 000000000..3d66df0df --- /dev/null +++ b/backend/src/main/java/reviewme/member/domain/exception/EmptyReviewerException.java @@ -0,0 +1,10 @@ +package reviewme.member.domain.exception; + +import reviewme.global.exception.BadRequestException; + +public class EmptyReviewerException extends BadRequestException { + + public EmptyReviewerException() { + super("리뷰어는 최소 한 명 이상이어야 합니다."); + } +} diff --git a/backend/src/main/java/reviewme/member/domain/exception/DescriptionLengthExceededException.java b/backend/src/main/java/reviewme/member/domain/exception/InvalidDescriptionLengthException.java similarity index 60% rename from backend/src/main/java/reviewme/member/domain/exception/DescriptionLengthExceededException.java rename to backend/src/main/java/reviewme/member/domain/exception/InvalidDescriptionLengthException.java index 701a073b2..35a19cd1f 100644 --- a/backend/src/main/java/reviewme/member/domain/exception/DescriptionLengthExceededException.java +++ b/backend/src/main/java/reviewme/member/domain/exception/InvalidDescriptionLengthException.java @@ -2,9 +2,9 @@ import reviewme.global.exception.BadRequestException; -public class DescriptionLengthExceededException extends BadRequestException { +public class InvalidDescriptionLengthException extends BadRequestException { - public DescriptionLengthExceededException(int maxLength) { + public InvalidDescriptionLengthException(int maxLength) { super("리뷰어 그룹 설명은 %d자 이하로 작성해야 합니다.".formatted(maxLength)); } } diff --git a/backend/src/main/java/reviewme/member/domain/exception/SelfReviewException.java b/backend/src/main/java/reviewme/member/domain/exception/SelfReviewException.java new file mode 100644 index 000000000..289e8bd12 --- /dev/null +++ b/backend/src/main/java/reviewme/member/domain/exception/SelfReviewException.java @@ -0,0 +1,10 @@ +package reviewme.member.domain.exception; + +import reviewme.global.exception.BadRequestException; + +public class SelfReviewException extends BadRequestException { + + public SelfReviewException() { + super("자신을 리뷰어로 추가할 수 없습니다."); + } +} diff --git a/backend/src/main/java/reviewme/member/repository/GithubReviewerGroupRepository.java b/backend/src/main/java/reviewme/member/repository/GithubReviewerGroupRepository.java deleted file mode 100644 index 71ce55744..000000000 --- a/backend/src/main/java/reviewme/member/repository/GithubReviewerGroupRepository.java +++ /dev/null @@ -1,12 +0,0 @@ -package reviewme.member.repository; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; -import reviewme.member.domain.GithubReviewerGroup; -import reviewme.member.domain.ReviewerGroup; - -@Repository -public interface GithubReviewerGroupRepository extends JpaRepository { - - boolean existsByGithubIdAndReviewerGroup(String githubId, ReviewerGroup reviewerGroup); -} diff --git a/backend/src/main/java/reviewme/review/controller/ReviewApi.java b/backend/src/main/java/reviewme/review/controller/ReviewApi.java index 92d772407..ea992c334 100644 --- a/backend/src/main/java/reviewme/review/controller/ReviewApi.java +++ b/backend/src/main/java/reviewme/review/controller/ReviewApi.java @@ -7,7 +7,7 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; import reviewme.review.dto.request.CreateReviewRequest; -import reviewme.review.dto.response.ReviewResponse; +import reviewme.review.dto.response.ReviewDetailResponse; @Tag(name = "리뷰 관리") public interface ReviewApi { @@ -23,5 +23,5 @@ public interface ReviewApi { summary = "리뷰 조회", description = "단일 리뷰를 조회한다." ) - ResponseEntity findReview(@PathVariable long id); + ResponseEntity findReview(@PathVariable long id); } diff --git a/backend/src/main/java/reviewme/review/controller/ReviewController.java b/backend/src/main/java/reviewme/review/controller/ReviewController.java index 7b0832899..955492d8c 100644 --- a/backend/src/main/java/reviewme/review/controller/ReviewController.java +++ b/backend/src/main/java/reviewme/review/controller/ReviewController.java @@ -8,14 +8,15 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import reviewme.review.dto.request.CreateReviewRequest; -import reviewme.review.dto.response.ReviewResponse; +import reviewme.review.dto.response.ReviewDetailResponse; import reviewme.review.service.ReviewService; @RestController @RequiredArgsConstructor -public class ReviewController implements ReviewApi { +public class ReviewController { private final ReviewService reviewService; @@ -26,8 +27,9 @@ public ResponseEntity createReview(@Valid @RequestBody CreateReviewRequest } @GetMapping("/reviews/{id}") - public ResponseEntity findReview(@PathVariable long id) { - ReviewResponse response = reviewService.findReview(id); + public ResponseEntity findReview(@PathVariable long id, + @RequestParam long memberId) { + ReviewDetailResponse response = reviewService.findReview(id, memberId); return ResponseEntity.ok(response); } } diff --git a/backend/src/main/java/reviewme/review/domain/ReviewKeyword.java b/backend/src/main/java/reviewme/review/domain/Question.java similarity index 50% rename from backend/src/main/java/reviewme/review/domain/ReviewKeyword.java rename to backend/src/main/java/reviewme/review/domain/Question.java index 603f7ba21..775457355 100644 --- a/backend/src/main/java/reviewme/review/domain/ReviewKeyword.java +++ b/backend/src/main/java/reviewme/review/domain/Question.java @@ -1,37 +1,29 @@ package reviewme.review.domain; +import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; -import reviewme.keyword.domain.Keyword; @Entity -@Table(name = "review_keyword") +@Table(name = "question") @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter -public class ReviewKeyword { +public class Question { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @ManyToOne - @JoinColumn(name = "review_id") - private Review review; + @Column(name = "content", nullable = false) + private String content; - @ManyToOne - @JoinColumn(name = "keyword_id") - private Keyword keyword; - - public ReviewKeyword(Review review, Keyword keyword) { - this.review = review; - this.keyword = keyword; + public Question(String content) { + this.content = content; } } diff --git a/backend/src/main/java/reviewme/review/domain/Review.java b/backend/src/main/java/reviewme/review/domain/Review.java index 4573f0e41..12e5f1f99 100644 --- a/backend/src/main/java/reviewme/review/domain/Review.java +++ b/backend/src/main/java/reviewme/review/domain/Review.java @@ -1,6 +1,7 @@ package reviewme.review.domain; import jakarta.persistence.Column; +import jakarta.persistence.Embedded; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; @@ -9,11 +10,15 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import java.time.LocalDateTime; +import java.util.List; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; +import reviewme.keyword.domain.Keyword; +import reviewme.keyword.domain.Keywords; import reviewme.member.domain.Member; import reviewme.member.domain.ReviewerGroup; +import reviewme.review.domain.exception.IllegalReviewerException; @Entity @Table(name = "review") @@ -29,16 +34,41 @@ public class Review { @JoinColumn(name = "reviewer_id", nullable = false) private Member reviewer; + @ManyToOne + @JoinColumn(name = "reviewee_id", nullable = false) + private Member reviewee; + @ManyToOne @JoinColumn(name = "reviewer_group_id", nullable = false) private ReviewerGroup reviewerGroup; + @Embedded + private Keywords keywords; + @Column(name = "created_at", nullable = false) private LocalDateTime createdAt; - public Review(Member reviewer, ReviewerGroup reviewerGroup, LocalDateTime createdAt) { + @Column(name = "is_public", nullable = false) + private boolean isPublic; + + public Review(Member reviewer, Member reviewee, ReviewerGroup reviewerGroup, + List keywords, LocalDateTime createdAt) { + if (reviewer.equals(reviewee)) { + throw new IllegalReviewerException(); + } this.reviewer = reviewer; - this.reviewerGroup = reviewerGroup; + this.reviewee = reviewee; + this.keywords = new Keywords(keywords); this.createdAt = createdAt; + reviewerGroup.addReview(this); + this.isPublic = false; + } + + public boolean isSubmittedBy(Member member) { + return reviewer.equals(member); + } + + public boolean isForReviewee(Member member) { + return reviewee.equals(member); } } diff --git a/backend/src/main/java/reviewme/review/domain/ReviewContent.java b/backend/src/main/java/reviewme/review/domain/ReviewContent.java index 9d0369cc9..7d2839f4c 100644 --- a/backend/src/main/java/reviewme/review/domain/ReviewContent.java +++ b/backend/src/main/java/reviewme/review/domain/ReviewContent.java @@ -7,10 +7,12 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToOne; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; +import reviewme.review.domain.exception.InvalidAnswerLengthException; @Entity @Table(name = "review_content") @@ -18,23 +20,38 @@ @Getter public class ReviewContent { + private static final int MIN_ANSWER_LENGTH = 20; + private static final int MAX_ANSWER_LENGTH = 1_000; + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @ManyToOne - @JoinColumn(name = "review_id") + @JoinColumn(name = "review_id", nullable = false) private Review review; - @Column(name = "question", nullable = false) - private String question; + @OneToOne + @JoinColumn(name = "question_id", nullable = false) + private Question question; @Column(name = "answer", nullable = false) private String answer; - public ReviewContent(Review review, String question, String answer) { + public ReviewContent(Review review, Question question, String answer) { + validateAnswerLength(answer); this.review = review; this.question = question; this.answer = answer; } + + private void validateAnswerLength(String answer) { + if (answer.length() < MIN_ANSWER_LENGTH || answer.length() > MAX_ANSWER_LENGTH) { + throw new InvalidAnswerLengthException(MIN_ANSWER_LENGTH, MAX_ANSWER_LENGTH); + } + } + + public String getQuestion() { + return question.getContent(); + } } diff --git a/backend/src/main/java/reviewme/review/domain/exception/IllegalReviewerException.java b/backend/src/main/java/reviewme/review/domain/exception/IllegalReviewerException.java new file mode 100644 index 000000000..dfd8082bc --- /dev/null +++ b/backend/src/main/java/reviewme/review/domain/exception/IllegalReviewerException.java @@ -0,0 +1,10 @@ +package reviewme.review.domain.exception; + +import reviewme.global.exception.BadRequestException; + +public class IllegalReviewerException extends BadRequestException { + + public IllegalReviewerException() { + super("리뷰어와 리뷰이가 같을 수 없습니다."); + } +} diff --git a/backend/src/main/java/reviewme/review/domain/exception/InvalidAnswerLengthException.java b/backend/src/main/java/reviewme/review/domain/exception/InvalidAnswerLengthException.java new file mode 100644 index 000000000..a7a904774 --- /dev/null +++ b/backend/src/main/java/reviewme/review/domain/exception/InvalidAnswerLengthException.java @@ -0,0 +1,10 @@ +package reviewme.review.domain.exception; + +import reviewme.global.exception.BadRequestException; + +public class InvalidAnswerLengthException extends BadRequestException { + + public InvalidAnswerLengthException(int minLength, int maxLength) { + super("답변의 길이는 %d자 이상 %d자 이하여야 합니다.".formatted(minLength, maxLength)); + } +} diff --git a/backend/src/main/java/reviewme/review/domain/exception/RevieweeMismatchException.java b/backend/src/main/java/reviewme/review/domain/exception/RevieweeMismatchException.java new file mode 100644 index 000000000..0cc4ad7db --- /dev/null +++ b/backend/src/main/java/reviewme/review/domain/exception/RevieweeMismatchException.java @@ -0,0 +1,10 @@ +package reviewme.review.domain.exception; + +import reviewme.global.exception.BadRequestException; + +public class RevieweeMismatchException extends BadRequestException { + + public RevieweeMismatchException() { + super("리뷰 대상이 일치하지 않습니다."); + } +} diff --git a/backend/src/main/java/reviewme/review/dto/response/ReviewResponse.java b/backend/src/main/java/reviewme/review/dto/response/ReviewDetailResponse.java similarity index 56% rename from backend/src/main/java/reviewme/review/dto/response/ReviewResponse.java rename to backend/src/main/java/reviewme/review/dto/response/ReviewDetailResponse.java index 8b9006072..737ec0e8c 100644 --- a/backend/src/main/java/reviewme/review/dto/response/ReviewResponse.java +++ b/backend/src/main/java/reviewme/review/dto/response/ReviewDetailResponse.java @@ -1,27 +1,28 @@ package reviewme.review.dto.response; import io.swagger.v3.oas.annotations.media.Schema; -import java.time.LocalDateTime; +import java.time.LocalDate; import java.util.List; -import reviewme.keyword.dto.response.KeywordResponse; -import reviewme.member.dto.response.ReviewerGroupResponse; @Schema(description = "리뷰 응답") -public record ReviewResponse( +public record ReviewDetailResponse( @Schema(description = "리뷰 ID") long id, @Schema(description = "리뷰 생성 시각") - LocalDateTime createdAt, + LocalDate createdAt, + + @Schema(description = "공개 여부") + boolean isPublic, @Schema(description = "리뷰어 그룹") - ReviewerGroupResponse reviewerGroup, + ReviewDetailReviewerGroupResponse reviewerGroup, @Schema(description = "리뷰 내용 목록") - List contents, + List contents, @Schema(description = "선택된 키워드 목록") - List keywords + List keywords ) { } diff --git a/backend/src/main/java/reviewme/review/dto/response/ReviewDetailReviewContentResponse.java b/backend/src/main/java/reviewme/review/dto/response/ReviewDetailReviewContentResponse.java new file mode 100644 index 000000000..df0d108e5 --- /dev/null +++ b/backend/src/main/java/reviewme/review/dto/response/ReviewDetailReviewContentResponse.java @@ -0,0 +1,14 @@ +package reviewme.review.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "리뷰 내용 응답") +public record ReviewDetailReviewContentResponse( + + @Schema(description = "리뷰 문항") + String question, + + @Schema(description = "리뷰 문항에 대한 답변") + String answer +) { +} diff --git a/backend/src/main/java/reviewme/review/dto/response/ReviewDetailReviewerGroupResponse.java b/backend/src/main/java/reviewme/review/dto/response/ReviewDetailReviewerGroupResponse.java new file mode 100644 index 000000000..6e9261003 --- /dev/null +++ b/backend/src/main/java/reviewme/review/dto/response/ReviewDetailReviewerGroupResponse.java @@ -0,0 +1,20 @@ +package reviewme.review.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "리뷰어 그룹 응답") +public record ReviewDetailReviewerGroupResponse( + + @Schema(description = "리뷰어 그룹 아이디") + long id, + + @Schema(description = "리뷰 그룹 이름 (레포지토리명)") + String name, + + @Schema(description = "그룹 소개") + String description, + + @Schema(description = "썸네일 URL") + String thumbnailUrl +) { +} diff --git a/backend/src/main/java/reviewme/review/exception/ReviewNotFoundException.java b/backend/src/main/java/reviewme/review/exception/ReviewNotFoundException.java new file mode 100644 index 000000000..31a81908c --- /dev/null +++ b/backend/src/main/java/reviewme/review/exception/ReviewNotFoundException.java @@ -0,0 +1,10 @@ +package reviewme.review.exception; + +import reviewme.global.exception.NotFoundException; + +public class ReviewNotFoundException extends NotFoundException { + + public ReviewNotFoundException() { + super("리뷰가 존재하지 않습니다."); + } +} diff --git a/backend/src/main/java/reviewme/review/exception/ReviewUnAuthorizedException.java b/backend/src/main/java/reviewme/review/exception/ReviewUnAuthorizedException.java new file mode 100644 index 000000000..1721bd565 --- /dev/null +++ b/backend/src/main/java/reviewme/review/exception/ReviewUnAuthorizedException.java @@ -0,0 +1,10 @@ +package reviewme.review.exception; + +import reviewme.global.exception.UnAuthorizedException; + +public class ReviewUnAuthorizedException extends UnAuthorizedException { + + public ReviewUnAuthorizedException() { + super("리뷰에 대한 권한이 없습니다."); + } +} diff --git a/backend/src/main/java/reviewme/review/repository/ReviewContentRepository.java b/backend/src/main/java/reviewme/review/repository/ReviewContentRepository.java index f9385933c..075a73475 100644 --- a/backend/src/main/java/reviewme/review/repository/ReviewContentRepository.java +++ b/backend/src/main/java/reviewme/review/repository/ReviewContentRepository.java @@ -3,11 +3,10 @@ import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; -import reviewme.review.domain.Review; import reviewme.review.domain.ReviewContent; @Repository public interface ReviewContentRepository extends JpaRepository { - List findByReview(Review review); + List findAllByReviewId(long reviewId); } diff --git a/backend/src/main/java/reviewme/review/repository/ReviewKeywordRepository.java b/backend/src/main/java/reviewme/review/repository/ReviewKeywordRepository.java deleted file mode 100644 index 9e14660e5..000000000 --- a/backend/src/main/java/reviewme/review/repository/ReviewKeywordRepository.java +++ /dev/null @@ -1,15 +0,0 @@ -package reviewme.review.repository; - -import java.util.List; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; -import reviewme.review.domain.Review; -import reviewme.review.domain.ReviewKeyword; - -@Repository -public interface ReviewKeywordRepository extends JpaRepository { - - List findByReview(Review review); - - void deleteAllByReview(Review review); -} diff --git a/backend/src/main/java/reviewme/review/repository/ReviewRepository.java b/backend/src/main/java/reviewme/review/repository/ReviewRepository.java index f00f53bc8..b9c2a19ab 100644 --- a/backend/src/main/java/reviewme/review/repository/ReviewRepository.java +++ b/backend/src/main/java/reviewme/review/repository/ReviewRepository.java @@ -1,11 +1,11 @@ package reviewme.review.repository; -import jakarta.persistence.EntityNotFoundException; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import reviewme.member.domain.Member; import reviewme.member.domain.ReviewerGroup; import reviewme.review.domain.Review; +import reviewme.review.exception.ReviewNotFoundException; @Repository public interface ReviewRepository extends JpaRepository { @@ -13,6 +13,6 @@ public interface ReviewRepository extends JpaRepository { boolean existsByReviewerAndReviewerGroup(Member reviewer, ReviewerGroup reviewerGroup); default Review getReviewById(Long id) { - return findById(id).orElseThrow(EntityNotFoundException::new); + return findById(id).orElseThrow(ReviewNotFoundException::new); } } diff --git a/backend/src/main/java/reviewme/review/service/ReviewKeywordService.java b/backend/src/main/java/reviewme/review/service/ReviewKeywordService.java deleted file mode 100644 index b02011a9a..000000000 --- a/backend/src/main/java/reviewme/review/service/ReviewKeywordService.java +++ /dev/null @@ -1,29 +0,0 @@ -package reviewme.review.service; - -import java.util.List; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import reviewme.keyword.domain.Keyword; -import reviewme.keyword.domain.SelectedKeywords; -import reviewme.review.domain.Review; -import reviewme.review.domain.ReviewKeyword; -import reviewme.review.repository.ReviewKeywordRepository; - -@Service -@RequiredArgsConstructor -public class ReviewKeywordService { - - private final ReviewKeywordRepository reviewKeywordRepository; - - @Transactional - public List attachSelectedKeywordsOnReview(Review review, List selectedKeywords) { - reviewKeywordRepository.deleteAllByReview(review); - SelectedKeywords keywords = new SelectedKeywords(selectedKeywords); - List reviewKeywords = keywords.getKeywords() - .stream() - .map(keyword -> new ReviewKeyword(review, keyword)) - .toList(); - return reviewKeywordRepository.saveAll(reviewKeywords); - } -} diff --git a/backend/src/main/java/reviewme/review/service/ReviewService.java b/backend/src/main/java/reviewme/review/service/ReviewService.java index 58ed24007..38614f6a6 100644 --- a/backend/src/main/java/reviewme/review/service/ReviewService.java +++ b/backend/src/main/java/reviewme/review/service/ReviewService.java @@ -1,31 +1,24 @@ package reviewme.review.service; -import java.time.LocalDateTime; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import reviewme.keyword.domain.Keyword; -import reviewme.keyword.dto.response.KeywordResponse; +import reviewme.keyword.domain.Keywords; import reviewme.keyword.repository.KeywordRepository; import reviewme.member.domain.Member; import reviewme.member.domain.ReviewerGroup; -import reviewme.member.dto.response.MemberResponse; -import reviewme.member.dto.response.ReviewerGroupResponse; -import reviewme.member.repository.GithubReviewerGroupRepository; import reviewme.member.repository.MemberRepository; import reviewme.member.repository.ReviewerGroupRepository; import reviewme.review.domain.Review; import reviewme.review.domain.ReviewContent; -import reviewme.review.domain.ReviewKeyword; -import reviewme.review.domain.exception.DeadlineExpiredException; import reviewme.review.dto.request.CreateReviewRequest; -import reviewme.review.dto.response.ReviewContentResponse; -import reviewme.review.dto.response.ReviewResponse; -import reviewme.review.exception.GithubReviewerGroupUnAuthorizedException; -import reviewme.review.exception.ReviewAlreadySubmittedException; +import reviewme.review.dto.response.ReviewDetailResponse; +import reviewme.review.dto.response.ReviewDetailReviewContentResponse; +import reviewme.review.dto.response.ReviewDetailReviewerGroupResponse; +import reviewme.review.exception.ReviewUnAuthorizedException; import reviewme.review.repository.ReviewContentRepository; -import reviewme.review.repository.ReviewKeywordRepository; import reviewme.review.repository.ReviewRepository; @Service @@ -35,88 +28,51 @@ public class ReviewService { private final ReviewRepository reviewRepository; private final MemberRepository memberRepository; private final ReviewerGroupRepository reviewerGroupRepository; - private final GithubReviewerGroupRepository githubReviewerGroupRepository; private final ReviewContentRepository reviewContentRepository; private final KeywordRepository keywordRepository; - private final ReviewKeywordRepository reviewKeywordRepository; @Transactional public Long createReview(CreateReviewRequest request) { - Member reviewer = memberRepository.getMemberById(request.reviewerId()); - ReviewerGroup reviewerGroup = reviewerGroupRepository.getReviewerGroupById(request.reviewerGroupId()); - - boolean isValidReviewer = githubReviewerGroupRepository.existsByGithubIdAndReviewerGroup( - reviewer.getGithubId(), - reviewerGroup - ); - if (!isValidReviewer) { - throw new GithubReviewerGroupUnAuthorizedException(); - } - if (reviewRepository.existsByReviewerAndReviewerGroup(reviewer, reviewerGroup)) { - throw new ReviewAlreadySubmittedException(); - } - validateIsDeadlinePassed(reviewerGroup); - - Review review = reviewRepository.save(new Review(reviewer, reviewerGroup, LocalDateTime.now())); - - List contents = request.contents() - .stream() - .map(content -> new ReviewContent(review, content.question(), content.answer())) - .toList(); - reviewContentRepository.saveAll(contents); - - List reviewKeywords = request.selectedKeywordIds() - .stream() - .map(keywordRepository::getKeywordById) - .map(keyword -> new ReviewKeyword(review, keyword)) - .toList(); - reviewKeywordRepository.saveAll(reviewKeywords); - - return review.getId(); + return null; } - private void validateIsDeadlinePassed(ReviewerGroup reviewerGroup) { - if (reviewerGroup.isDeadlineExceeded(LocalDateTime.now())) { - throw new DeadlineExpiredException(); + @Transactional(readOnly = true) + public ReviewDetailResponse findReview(long reviewId, long memberId) { + Review review = reviewRepository.getReviewById(reviewId); + Member member = memberRepository.getMemberById(memberId); + if (!review.isForReviewee(member)) { + throw new ReviewUnAuthorizedException(); } - } - public ReviewResponse findReview(long id) { - Review review = reviewRepository.getReviewById(id); ReviewerGroup reviewerGroup = review.getReviewerGroup(); - Member reviewee = reviewerGroup.getReviewee(); + Keywords keywords = review.getKeywords(); + List reviewContents = reviewContentRepository.findAllByReviewId(reviewId); - ReviewerGroupResponse reviewerGroupResponse = new ReviewerGroupResponse( + ReviewDetailReviewerGroupResponse reviewerGroupResponse = new ReviewDetailReviewerGroupResponse( reviewerGroup.getId(), reviewerGroup.getGroupName(), - reviewerGroup.getDeadline(), - new MemberResponse(reviewee.getId(), reviewee.getName()) + reviewerGroup.getDescription(), + reviewerGroup.getThumbnailUrl() ); - - List reviewContents = reviewContentRepository.findByReview(review); - List reviewContentResponse = reviewContents.stream() - .map(reviewContent -> new ReviewContentResponse( - reviewContent.getId(), - reviewContent.getQuestion(), - reviewContent.getAnswer() - ) - ) + List reviewContentResponses = reviewContents.stream() + .map(content -> new ReviewDetailReviewContentResponse( + content.getQuestion(), + content.getAnswer() + )) .toList(); - - List reviewKeywords = reviewKeywordRepository.findByReview(review); - List keywords = reviewKeywords.stream() - .map(ReviewKeyword::getKeyword) - .toList(); - List keywordResponses = keywords.stream() - .map(keyword -> new KeywordResponse(keyword.getId(), keyword.getDetail())) + List keywordContents = keywords.getKeywordIds() + .stream() + .map(keywordRepository::getKeywordById) + .map(Keyword::getContent) .toList(); - return new ReviewResponse( - review.getId(), - review.getCreatedAt(), + return new ReviewDetailResponse( + reviewId, + review.getCreatedAt().toLocalDate(), + review.isPublic(), reviewerGroupResponse, - reviewContentResponse, - keywordResponses + reviewContentResponses, + keywordContents ); } } diff --git a/backend/src/test/java/reviewme/fixture/MemberFixture.java b/backend/src/test/java/reviewme/fixture/MemberFixture.java new file mode 100644 index 000000000..b278ee8ce --- /dev/null +++ b/backend/src/test/java/reviewme/fixture/MemberFixture.java @@ -0,0 +1,23 @@ +package reviewme.fixture; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import reviewme.member.domain.Member; + +@RequiredArgsConstructor +@Getter +public enum MemberFixture { + + 회원_산초("산초", 1L), + 회원_아루("아루", 2L), + 회원_커비("커비", 3L), + 회원_테드("테드", 4L), + ; + + private final String name; + private final long githubId; + + public Member create() { + return new Member(name, githubId); + } +} diff --git a/backend/src/test/java/reviewme/fixture/ReviewerGroupFixture.java b/backend/src/test/java/reviewme/fixture/ReviewerGroupFixture.java index 1cdbde50e..ff4661051 100644 --- a/backend/src/test/java/reviewme/fixture/ReviewerGroupFixture.java +++ b/backend/src/test/java/reviewme/fixture/ReviewerGroupFixture.java @@ -1,21 +1,26 @@ package reviewme.fixture; import java.time.LocalDateTime; +import java.util.List; +import lombok.Getter; import lombok.RequiredArgsConstructor; +import reviewme.member.domain.GithubId; import reviewme.member.domain.Member; import reviewme.member.domain.ReviewerGroup; @RequiredArgsConstructor +@Getter public enum ReviewerGroupFixture { - 리뷰_그룹("리뷰 그룹", "그룹 설명", LocalDateTime.of(2024, 1, 1, 12, 0)), + 데드라인_남은_그룹("데드라인 이전 그룹", "설명", LocalDateTime.now().plusDays(1)), + 데드라인_지난_그룹("데드라인 지난 그룹", "설명", LocalDateTime.now().minusDays(1)), ; private final String groupName; private final String description; - private final LocalDateTime createdAt; + private final LocalDateTime deadline; - public ReviewerGroup create(Member reviewee) { - return new ReviewerGroup(reviewee, groupName, description, createdAt); + public ReviewerGroup create(Member reviewee, List reviewerGithubIds) { + return new ReviewerGroup(reviewee, reviewerGithubIds, groupName, description, deadline); } } diff --git a/backend/src/test/java/reviewme/keyword/domain/SelectedKeywordsTest.java b/backend/src/test/java/reviewme/keyword/domain/KeywordsTest.java similarity index 86% rename from backend/src/test/java/reviewme/keyword/domain/SelectedKeywordsTest.java rename to backend/src/test/java/reviewme/keyword/domain/KeywordsTest.java index cea38494f..0c5a63403 100644 --- a/backend/src/test/java/reviewme/keyword/domain/SelectedKeywordsTest.java +++ b/backend/src/test/java/reviewme/keyword/domain/KeywordsTest.java @@ -10,7 +10,7 @@ import reviewme.keyword.domain.exception.DuplicateKeywordException; import reviewme.keyword.domain.exception.KeywordLimitExceedException; -class SelectedKeywordsTest { +class KeywordsTest { @Test void 키워드는_최대_5개만_선택할_수_있다() { @@ -20,7 +20,7 @@ class SelectedKeywordsTest { .toList(); // when, then - assertDoesNotThrow(() -> new SelectedKeywords(keywords)); + assertDoesNotThrow(() -> new Keywords(keywords)); } @Test @@ -31,7 +31,7 @@ class SelectedKeywordsTest { .toList(); // when, then - assertThatThrownBy(() -> new SelectedKeywords(keywords)) + assertThatThrownBy(() -> new Keywords(keywords)) .isInstanceOf(KeywordLimitExceedException.class); } @@ -44,7 +44,7 @@ class SelectedKeywordsTest { ); // when, then - assertThatThrownBy(() -> new SelectedKeywords(keywords)) + assertThatThrownBy(() -> new Keywords(keywords)) .isInstanceOf(DuplicateKeywordException.class); } } diff --git a/backend/src/test/java/reviewme/member/domain/ReviewerGroupTest.java b/backend/src/test/java/reviewme/member/domain/ReviewerGroupTest.java index 21c63ab9e..006e39cf5 100644 --- a/backend/src/test/java/reviewme/member/domain/ReviewerGroupTest.java +++ b/backend/src/test/java/reviewme/member/domain/ReviewerGroupTest.java @@ -2,48 +2,122 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static reviewme.fixture.MemberFixture.회원_산초; +import static reviewme.fixture.MemberFixture.회원_커비; import java.time.LocalDateTime; +import java.util.List; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; -import reviewme.member.domain.exception.DescriptionLengthExceededException; +import reviewme.member.domain.exception.DuplicateReviewerException; +import reviewme.member.domain.exception.EmptyReviewerException; +import reviewme.member.domain.exception.InvalidDescriptionLengthException; import reviewme.member.domain.exception.InvalidGroupNameLengthException; +import reviewme.member.domain.exception.SelfReviewException; class ReviewerGroupTest { @Test void 리뷰_그룹이_올바르게_생성된다() { // given - Member sancho = new Member("산초", "sancho"); + Member reviewee = 회원_산초.create(); String groupName = "a".repeat(100); String description = "a".repeat(50); - LocalDateTime createdAt = LocalDateTime.of(2024, 7, 17, 12, 0); + LocalDateTime deadline = LocalDateTime.now().plusDays(1); + List reviewerGithubIds = List.of(new GithubId(3)); // when, then - assertDoesNotThrow(() -> new ReviewerGroup(sancho, groupName, description, createdAt)); + assertDoesNotThrow( + () -> new ReviewerGroup(reviewee, reviewerGithubIds, groupName, description, deadline)); } @ParameterizedTest @ValueSource(ints = {0, 101}) - void 리뷰_그룹_이름_길이_제한을_벗어나는_경우_예외를_발생한다(int length) { + void 리뷰_그룹_이름_길이_제한을_벗어날_수_없다(int length) { // given String groupName = "a".repeat(length); - Member sancho = new Member("산초", "sancho"); - LocalDateTime createdAt = LocalDateTime.of(2024, 7, 17, 12, 0); + Member reviewee = 회원_산초.create(); + Member reviewer = 회원_커비.create(); + LocalDateTime deadline = LocalDateTime.now().plusDays(1); + List reviewerGithubIds = List.of(reviewer.getGithubId()); + // when, then - assertThatThrownBy(() -> new ReviewerGroup(sancho, groupName, "설명", createdAt)) + assertThatThrownBy(() -> new ReviewerGroup(reviewee, reviewerGithubIds, groupName, "설명", deadline)) .isInstanceOf(InvalidGroupNameLengthException.class); } @Test - void 리뷰_그룹_설명_길이_제한을_벗어나는_경우_예외를_발생한다() { + void 리뷰_그룹_설명_길이_제한을_벗어날_수_없다() { // given String description = "a".repeat(51); - Member sancho = new Member("산초", "sancho"); - LocalDateTime createdAt = LocalDateTime.of(2024, 7, 17, 12, 0); + Member reviewee = 회원_산초.create(); + Member reviewer = 회원_커비.create(); + LocalDateTime deadline = LocalDateTime.now().plusDays(1); + List reviewerGithubIds = List.of(reviewer.getGithubId()); + + // when, then + assertThatThrownBy(() -> new ReviewerGroup(reviewee, reviewerGithubIds, "그룹 이름", description, deadline)) + .isInstanceOf(InvalidDescriptionLengthException.class); + } + + @Test + void 리뷰어_목록에_리뷰이가_들어갈_수_없다() { + // given + Member reviewee = 회원_산초.create(); + String groupName = "Group"; + String description = "Description"; + LocalDateTime deadline = LocalDateTime.now().plusDays(1); + List reviewerGithubIds = List.of(reviewee.getGithubId()); + + // when, then + assertThatThrownBy(() -> new ReviewerGroup(reviewee, reviewerGithubIds, groupName, description, deadline)) + .isInstanceOf(SelfReviewException.class); + } + + @Test + void 리뷰어_목록이_비어있을_수_없다() { + Member reviewee = 회원_산초.create(); + String groupName = "Group"; + String description = "Description"; + LocalDateTime deadline = LocalDateTime.now().plusDays(1); + List reviewerGithubIds = List.of(); + + // when, then + assertThatThrownBy(() -> new ReviewerGroup(reviewee, reviewerGithubIds, groupName, description, deadline)) + .isInstanceOf(EmptyReviewerException.class); + } + + @Test + void 리뷰어를_중복으로_가지게_그룹을_생성할_수_없다() { + // given + Member reviewer = 회원_산초.create(); + Member reviewee = 회원_커비.create(); + String groupName = "Group"; + String description = "Description"; + LocalDateTime deadline = LocalDateTime.now().plusDays(1); + List reviewerGithubIds = List.of(reviewer.getGithubId(), reviewer.getGithubId()); + + // when, then + assertThatThrownBy(() -> new ReviewerGroup(reviewee, reviewerGithubIds, groupName, description, deadline)) + .isInstanceOf(DuplicateReviewerException.class); + } + + @Test + void 리뷰어를_중복으로_추가할_수_없다() { + // given + Member reviewer = 회원_커비.create(); + Member reviewee = 회원_산초.create(); + + String groupName = "Group"; + String description = "Description"; + LocalDateTime deadline = LocalDateTime.now().plusDays(1); + List reviewerGithubIds = List.of(reviewer.getGithubId()); + ReviewerGroup reviewerGroup = new ReviewerGroup(reviewee, reviewerGithubIds, groupName, description, deadline); + GithubIdReviewerGroup githubIdReviewerGroup = new GithubIdReviewerGroup(reviewee.getGithubId(), reviewerGroup); + // when, then - assertThatThrownBy(() -> new ReviewerGroup(sancho, "그룹 이름", description, createdAt)) - .isInstanceOf(DescriptionLengthExceededException.class); + assertThatThrownBy(() -> reviewerGroup.addReviewerGithubId(githubIdReviewerGroup)) + .isInstanceOf(DuplicateReviewerException.class); } } diff --git a/backend/src/test/java/reviewme/member/repository/GithubReviewerGroupRepositoryTest.java b/backend/src/test/java/reviewme/member/repository/GithubReviewerGroupRepositoryTest.java deleted file mode 100644 index ced6c04ce..000000000 --- a/backend/src/test/java/reviewme/member/repository/GithubReviewerGroupRepositoryTest.java +++ /dev/null @@ -1,65 +0,0 @@ -package reviewme.member.repository; - -import static org.assertj.core.api.Assertions.assertThat; -import static reviewme.fixture.ReviewerGroupFixture.리뷰_그룹; - -import java.util.List; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import reviewme.member.domain.GithubReviewerGroup; -import reviewme.member.domain.Member; -import reviewme.member.domain.ReviewerGroup; - -@DataJpaTest -class GithubReviewerGroupRepositoryTest { - - @Autowired - GithubReviewerGroupRepository githubReviewerGroupRepository; - - @Autowired - ReviewerGroupRepository reviewerGroupRepository; - - @Autowired - MemberRepository memberRepository; - - @Test - void 깃허브_아이디와_리뷰어_그룹이_모두_일치하는_깃허브_리뷰어_그룹이_있는_경우를_확인한다() { // given - // given - String revieweeGithubId = "kirby"; - Member reviewee = new Member("커비", revieweeGithubId); - String reviewerGithubId = "ted"; - Member reviewer = new Member("테드", reviewerGithubId); - memberRepository.saveAll(List.of(reviewee, reviewer)); - - ReviewerGroup reviewerGroup = 리뷰_그룹.create(reviewee); - reviewerGroupRepository.save(reviewerGroup); - githubReviewerGroupRepository.save(new GithubReviewerGroup(reviewerGithubId, reviewerGroup)); - - // when - boolean actual = githubReviewerGroupRepository.existsByGithubIdAndReviewerGroup(reviewerGithubId, reviewerGroup); - - // then - assertThat(actual).isTrue(); - } - - @Test - void 깃허브_아이디와_리뷰어_그룹이_모두_일치하는_깃허브_리뷰어_그룹이_없는_경우를_확인한다() { - // given - String revieweeGithubId = "kirby"; - Member reviewee = new Member("커비", revieweeGithubId); - String reviewerGithubId = "ted"; - Member reviewer = new Member("테드", reviewerGithubId); - memberRepository.saveAll(List.of(reviewee, reviewer)); - - ReviewerGroup reviewerGroup = 리뷰_그룹.create(reviewee); - reviewerGroupRepository.save(reviewerGroup); - githubReviewerGroupRepository.save(new GithubReviewerGroup(reviewerGithubId, reviewerGroup)); - - // when - boolean actual = githubReviewerGroupRepository.existsByGithubIdAndReviewerGroup("aru", reviewerGroup); - - // then - assertThat(actual).isFalse(); - } -} diff --git a/backend/src/test/java/reviewme/review/domain/ReviewContentTest.java b/backend/src/test/java/reviewme/review/domain/ReviewContentTest.java new file mode 100644 index 000000000..6c3ed8047 --- /dev/null +++ b/backend/src/test/java/reviewme/review/domain/ReviewContentTest.java @@ -0,0 +1,34 @@ +package reviewme.review.domain; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import reviewme.review.domain.exception.InvalidAnswerLengthException; + +class ReviewContentTest { + + @ParameterizedTest + @ValueSource(ints = {20, 1000}) + void 리뷰_답변이_범위에_해당하는_경우_올바르게_생성된다(int length) { + // given + Question question = new Question("Question 1"); + String content = "A".repeat(length); + + // when, then + assertDoesNotThrow(() -> new ReviewContent(null, question, content)); + } + + @ParameterizedTest + @ValueSource(ints = {19, 1001}) + void 리뷰_답변이_범위를_벗어나는_경우_예외를_발생한다(int length) { + // given + Question question = new Question("Question 1"); + String content = "A".repeat(length); + + // when, then + assertThatThrownBy(() -> new ReviewContent(null, question, content)) + .isInstanceOf(InvalidAnswerLengthException.class); + } +} diff --git a/backend/src/test/java/reviewme/review/domain/ReviewTest.java b/backend/src/test/java/reviewme/review/domain/ReviewTest.java new file mode 100644 index 000000000..22d356655 --- /dev/null +++ b/backend/src/test/java/reviewme/review/domain/ReviewTest.java @@ -0,0 +1,96 @@ +package reviewme.review.domain; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static reviewme.fixture.KeywordFixture.꼼꼼하게_기록해요; +import static reviewme.fixture.KeywordFixture.의견을_잘_조율해요; +import static reviewme.fixture.MemberFixture.회원_산초; +import static reviewme.fixture.MemberFixture.회원_아루; +import static reviewme.fixture.MemberFixture.회원_커비; +import static reviewme.fixture.ReviewerGroupFixture.데드라인_남은_그룹; +import static reviewme.fixture.ReviewerGroupFixture.데드라인_지난_그룹; + +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.Test; +import reviewme.keyword.domain.Keyword; +import reviewme.member.domain.GithubId; +import reviewme.member.domain.Member; +import reviewme.member.domain.ReviewerGroup; +import reviewme.review.domain.exception.DeadlineExpiredException; +import reviewme.review.domain.exception.IllegalReviewerException; +import reviewme.review.domain.exception.RevieweeMismatchException; +import reviewme.review.exception.GithubReviewerGroupUnAuthorizedException; +import reviewme.review.exception.ReviewAlreadySubmittedException; + +class ReviewTest { + + @Test + void 리뷰어와_리뷰이가_같을_수_없다() { + // given + Member member = 회원_산초.create(); + LocalDateTime createdAt = LocalDateTime.now(); + List keywords = List.of(꼼꼼하게_기록해요.create(), 의견을_잘_조율해요.create()); + + // when, then + assertThatThrownBy(() -> new Review(member, member, null, keywords, createdAt)) + .isInstanceOf(IllegalReviewerException.class); + } + + @Test + void 마감_기한이_지난_그룹에_리뷰를_등록할_수_없다() { + // given + Member reviewer = 회원_산초.create(); + Member reviewee = 회원_아루.create(); + ReviewerGroup reviewerGroup = 데드라인_지난_그룹.create(reviewee, List.of(reviewer.getGithubId())); + LocalDateTime createdAt = LocalDateTime.now(); + List keywords = List.of(); + + // when, then + assertThatThrownBy(() -> new Review(reviewer, reviewee, reviewerGroup, keywords, createdAt)) + .isInstanceOf(DeadlineExpiredException.class); + } + + @Test + void 하나의_리뷰_그룹에_중복으로_리뷰를_등록할_수_없다() { + // given + Member reviewer = 회원_산초.create(); + Member reviewee = 회원_아루.create(); + ReviewerGroup reviewerGroup = 데드라인_남은_그룹.create(reviewee, List.of(reviewer.getGithubId())); + new Review(reviewer, reviewee, reviewerGroup, List.of(), LocalDateTime.now()); + LocalDateTime createdAt = LocalDateTime.now(); + List keywords = List.of(); + + // when, then + assertThatThrownBy(() -> new Review(reviewer, reviewee, reviewerGroup, keywords, createdAt)) + .isInstanceOf(ReviewAlreadySubmittedException.class); + } + + @Test + void 리뷰어로_등록되지_않은_회원은_리뷰를_등록할_수_없다() { + // given + Member reviewer = new Member("reviewer", 1); + Member reviewee = new Member("reviewee", 2); + ReviewerGroup reviewerGroup = 데드라인_남은_그룹.create(reviewee, List.of(new GithubId(3))); + LocalDateTime createdAt = LocalDateTime.now(); + List keywords = List.of(); + + // when, then + assertThatThrownBy(() -> new Review(reviewer, reviewee, reviewerGroup, keywords, createdAt)) + .isInstanceOf(GithubReviewerGroupUnAuthorizedException.class); + } + + @Test + void 그룹_내에서_그룹_밖으로_리뷰를_작성할_수_없다() { + // given + Member reviewer = 회원_산초.create(); + Member reviewee = 회원_아루.create(); + Member other = 회원_커비.create(); + ReviewerGroup reviewerGroup = 데드라인_남은_그룹.create(reviewee, List.of(reviewer.getGithubId())); + LocalDateTime createdAt = LocalDateTime.now(); + List keywords = List.of(); + + // when, then + assertThatThrownBy(() -> new Review(reviewer, other, reviewerGroup, keywords, createdAt)) + .isInstanceOf(RevieweeMismatchException.class); + } +} diff --git a/backend/src/test/java/reviewme/review/repository/ReviewKeywordRepositoryTest.java b/backend/src/test/java/reviewme/review/repository/ReviewKeywordRepositoryTest.java deleted file mode 100644 index bd00c21ae..000000000 --- a/backend/src/test/java/reviewme/review/repository/ReviewKeywordRepositoryTest.java +++ /dev/null @@ -1,63 +0,0 @@ -package reviewme.review.repository; - -import static org.assertj.core.api.Assertions.assertThat; -import static reviewme.fixture.KeywordFixture.꼼꼼하게_기록해요; -import static reviewme.fixture.KeywordFixture.의견을_잘_조율해요; -import static reviewme.fixture.KeywordFixture.회의를_이끌어요; -import static reviewme.fixture.ReviewerGroupFixture.리뷰_그룹; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.stream.Stream; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import reviewme.fixture.KeywordFixture; -import reviewme.keyword.repository.KeywordRepository; -import reviewme.member.domain.Member; -import reviewme.member.domain.ReviewerGroup; -import reviewme.member.repository.MemberRepository; -import reviewme.member.repository.ReviewerGroupRepository; -import reviewme.review.domain.Review; -import reviewme.review.domain.ReviewKeyword; - -@DataJpaTest -class ReviewKeywordRepositoryTest { - - @Autowired - private ReviewKeywordRepository reviewKeywordRepository; - - @Autowired - private KeywordRepository keywordRepository; - - @Autowired - private MemberRepository memberRepository; - - @Autowired - private ReviewerGroupRepository reviewerGroupRepository; - - @Autowired - private ReviewRepository reviewRepository; - - @Test - void 리뷰에_해당하는_키워드를_모두_삭제한다() { - // given - Member sancho = memberRepository.save(new Member("산초", "sancho")); - Member kirby = memberRepository.save(new Member("커비", "kirby")); - ReviewerGroup group = reviewerGroupRepository.save(리뷰_그룹.create(sancho)); - Review review = reviewRepository.save(new Review(kirby, group, LocalDateTime.now())); - List reviewKeywords = Stream.of(꼼꼼하게_기록해요, 회의를_이끌어요, 의견을_잘_조율해요) - .map(KeywordFixture::create) - .map(keywordRepository::save) - .map(keyword -> new ReviewKeyword(review, keyword)) - .toList(); - reviewKeywordRepository.saveAll(reviewKeywords); - - // when - reviewKeywordRepository.deleteAllByReview(review); - - // then - List actual = reviewKeywordRepository.findByReview(review); - assertThat(actual).isEmpty(); - } -} diff --git a/backend/src/test/java/reviewme/review/service/ReviewKeywordServiceTest.java b/backend/src/test/java/reviewme/review/service/ReviewKeywordServiceTest.java deleted file mode 100644 index 298809d78..000000000 --- a/backend/src/test/java/reviewme/review/service/ReviewKeywordServiceTest.java +++ /dev/null @@ -1,93 +0,0 @@ -package reviewme.review.service; - -import static org.assertj.core.api.Assertions.assertThat; -import static reviewme.fixture.KeywordFixture.꼼꼼하게_기록해요; -import static reviewme.fixture.KeywordFixture.의견을_잘_조율해요; -import static reviewme.fixture.KeywordFixture.회의를_이끌어요; -import static reviewme.fixture.ReviewerGroupFixture.리뷰_그룹; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.stream.Stream; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import reviewme.fixture.KeywordFixture; -import reviewme.keyword.domain.Keyword; -import reviewme.keyword.repository.KeywordRepository; -import reviewme.member.domain.Member; -import reviewme.member.domain.ReviewerGroup; -import reviewme.member.repository.MemberRepository; -import reviewme.member.repository.ReviewerGroupRepository; -import reviewme.review.domain.Review; -import reviewme.review.domain.ReviewKeyword; -import reviewme.review.repository.ReviewKeywordRepository; -import reviewme.review.repository.ReviewRepository; -import reviewme.support.ServiceTest; - -@ServiceTest -class ReviewKeywordServiceTest { - - @Autowired - private ReviewKeywordService reviewKeywordService; - - @Autowired - private ReviewKeywordRepository reviewKeywordRepository; - - @Autowired - private KeywordRepository keywordRepository; - - @Autowired - private MemberRepository memberRepository; - - @Autowired - private ReviewerGroupRepository reviewerGroupRepository; - - @Autowired - private ReviewRepository reviewRepository; - - @Test - void 리뷰에_키워드를_추가한다() { - // given - Member sancho = memberRepository.save(new Member("산초", "sancho")); - Member kirby = memberRepository.save(new Member("커비", "kirby")); - ReviewerGroup group = reviewerGroupRepository.save(리뷰_그룹.create(sancho)); - Review review = reviewRepository.save(new Review(kirby, group, LocalDateTime.now())); - - List keywords = Stream.of(꼼꼼하게_기록해요, 회의를_이끌어요, 의견을_잘_조율해요) - .map(KeywordFixture::create) - .map(keywordRepository::save) - .toList(); - List selectedKeywords = List.of(keywords.get(0), keywords.get(1)); - - // when - reviewKeywordService.attachSelectedKeywordsOnReview(review, selectedKeywords); - - // then - List actual = reviewKeywordRepository.findByReview(review); - assertThat(actual).hasSize(2); - } - - @Test - void 키워드가_이미_존재하는_경우_키워드_등록_시_모두_대체된다() { - // given - Member sancho = memberRepository.save(new Member("산초", "sancho")); - Member kirby = memberRepository.save(new Member("커비", "kirby")); - ReviewerGroup group = reviewerGroupRepository.save(리뷰_그룹.create(sancho)); - Review review = reviewRepository.save(new Review(kirby, group, LocalDateTime.now())); - - List keywords = Stream.of(꼼꼼하게_기록해요, 회의를_이끌어요, 의견을_잘_조율해요) - .map(KeywordFixture::create) - .map(keywordRepository::save) - .toList(); - reviewKeywordRepository.save(new ReviewKeyword(review, keywords.get(0))); - List selectedKeywords = List.of(keywords.get(1), keywords.get(2)); - - // when - reviewKeywordService.attachSelectedKeywordsOnReview(review, selectedKeywords); - - // then - List actual = reviewKeywordRepository.findByReview(review); - assertThat(actual).extracting(ReviewKeyword::getKeyword) - .containsExactlyInAnyOrderElementsOf(selectedKeywords); - } -} diff --git a/backend/src/test/java/reviewme/review/service/ReviewServiceTest.java b/backend/src/test/java/reviewme/review/service/ReviewServiceTest.java index 97076c234..20ba55c12 100644 --- a/backend/src/test/java/reviewme/review/service/ReviewServiceTest.java +++ b/backend/src/test/java/reviewme/review/service/ReviewServiceTest.java @@ -1,3 +1,4 @@ +/* package reviewme.review.service; import static org.assertj.core.api.Assertions.assertThat; @@ -22,7 +23,7 @@ import reviewme.review.domain.exception.DeadlineExpiredException; import reviewme.review.dto.request.CreateReviewContentRequest; import reviewme.review.dto.request.CreateReviewRequest; -import reviewme.review.dto.response.ReviewResponse; +import reviewme.review.dto.response.ReviewDetailResponse; import reviewme.review.exception.GithubReviewerGroupUnAuthorizedException; import reviewme.review.exception.ReviewAlreadySubmittedException; import reviewme.review.repository.ReviewContentRepository; @@ -96,7 +97,7 @@ class ReviewServiceTest { Review review = reviewRepository.save(new Review(reviewer, reviewerGroup, LocalDateTime.now())); // when - ReviewResponse response = reviewService.findReview(review.getId()); + ReviewDetailResponse response = reviewService.findReview(review.getId()); // then Long id = response.id(); @@ -164,3 +165,4 @@ class ReviewServiceTest { .isInstanceOf(DeadlineExpiredException.class); } } +*/ diff --git a/frontend/package.json b/frontend/package.json index d5d590207..cb7270ae1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -50,7 +50,8 @@ "html-webpack-plugin": "^5.6.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", - "msw": "^2.3.1", + "jest-fixed-jsdom": "^0.0.2", + "msw": "2.3.2", "postcss-syntax": "^0.36.2", "prettier": "^3.3.2", "stylelint": "^16.7.0", @@ -60,5 +61,10 @@ "webpack": "^5.92.1", "webpack-cli": "^5.1.4", "webpack-dev-server": "^5.0.4" + }, + "msw": { + "workerDirectory": [ + "public" + ] } } diff --git a/frontend/public/index.html b/frontend/public/index.html index ee0fd573b..af6879ad3 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -8,7 +8,7 @@ - Document + reveiw me
diff --git a/frontend/public/mockServiceWorker.js b/frontend/public/mockServiceWorker.js new file mode 100644 index 000000000..6185b3c32 --- /dev/null +++ b/frontend/public/mockServiceWorker.js @@ -0,0 +1,281 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + * - Please do NOT serve this file on production. + */ + +const PACKAGE_VERSION = '2.3.4'; +const INTEGRITY_CHECKSUM = '26357c79639bfa20d64c0efca2a87423'; +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse'); +const activeClientIds = new Set(); + +self.addEventListener('install', function () { + self.skipWaiting(); +}); + +self.addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener('message', async function (event) { + const clientId = event.source.id; + + if (!clientId || !self.clients) { + return; + } + + const client = await self.clients.get(clientId); + + if (!client) { + return; + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }); + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }); + break; + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }); + break; + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId); + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: true, + }); + break; + } + + case 'MOCK_DEACTIVATE': { + activeClientIds.delete(clientId); + break; + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId); + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId; + }); + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister(); + } + + break; + } + } +}); + +self.addEventListener('fetch', function (event) { + const { request } = event; + + // Bypass navigation requests. + if (request.mode === 'navigate') { + return; + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { + return; + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been deleted (still remains active until the next reload). + if (activeClientIds.size === 0) { + return; + } + + // Generate unique request ID. + const requestId = crypto.randomUUID(); + event.respondWith(handleRequest(event, requestId)); +}); + +async function handleRequest(event, requestId) { + const client = await resolveMainClient(event); + const response = await getResponse(event, client, requestId); + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + (async function () { + const responseClone = response.clone(); + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + requestId, + isMockedResponse: IS_MOCKED_RESPONSE in response, + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + body: responseClone.body, + headers: Object.fromEntries(responseClone.headers.entries()), + }, + }, + [responseClone.body], + ); + })(); + } + + return response; +} + +// Resolve the main client for the given event. +// Client that issues a request doesn't necessarily equal the client +// that registered the worker. It's with the latter the worker should +// communicate with during the response resolving phase. +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId); + + if (client?.frameType === 'top-level') { + return client; + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }); + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible'; + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id); + }); +} + +async function getResponse(event, client, requestId) { + const { request } = event; + + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = request.clone(); + + function passthrough() { + const headers = Object.fromEntries(requestClone.headers.entries()); + + // Remove internal MSW request header so the passthrough request + // complies with any potential CORS preflight checks on the server. + // Some servers forbid unknown request headers. + delete headers['x-msw-intention']; + + return fetch(requestClone, { headers }); + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough(); + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough(); + } + + // Notify the client that a request has been intercepted. + const requestBuffer = await request.arrayBuffer(); + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: requestBuffer, + keepalive: request.keepalive, + }, + }, + [requestBuffer], + ); + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data); + } + + case 'PASSTHROUGH': { + return passthrough(); + } + } + + return passthrough(); +} + +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel(); + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error); + } + + resolve(event.data); + }; + + client.postMessage(message, [channel.port2].concat(transferrables.filter(Boolean))); + }); +} + +async function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error(); + } + + const mockedResponse = new Response(response.body, response); + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }); + + return mockedResponse; +} diff --git a/frontend/src/apis/endpoints.ts b/frontend/src/apis/endpoints.ts index 71b5ae7c4..db5232c15 100644 --- a/frontend/src/apis/endpoints.ts +++ b/frontend/src/apis/endpoints.ts @@ -1,8 +1,11 @@ const endPoint = { postingReview: `${process.env.API_BASE_URL}/reviews`, - gettingDetailedReview: (reviewId: number) => `${process.env.API_BASE_URL}/reviews/${reviewId}`, + gettingDetailedReview: (reviewId: number, memberId: number) => + `${process.env.API_BASE_URL}/reviews/${reviewId}?memberId=${memberId}`, + gettingReviewList: (revieweeId: number, lastReviewId: number, memberId: number) => + `${process.env.API_BASE_URL}/reviews?revieweeId=${revieweeId}&lastReviewId=${lastReviewId}&memberId=${memberId}`, gettingInfoToWriteReview: (reviewerGroupId: number) => `/reviewer-groups/${reviewerGroupId}`, - gettingKeyword: `${process.env.API_BASE_URL}/keywords`, + gettingKeyword: `${process.env.API_BASE_URL}keywords`, }; export default endPoint; diff --git a/frontend/src/apis/review.ts b/frontend/src/apis/review.ts index 870d33e04..24e82882b 100644 --- a/frontend/src/apis/review.ts +++ b/frontend/src/apis/review.ts @@ -1,9 +1,9 @@ //리뷰 작성 - import { ReviewData, WritingReviewInfoData } from '@/types'; + import endPoint from './endpoints'; -export const getInfoToWriteReview = async (reviewerGroupId: number) => { +export const getDataToWriteReviewApi = async (reviewerGroupId: number) => { const response = await fetch(endPoint.gettingInfoToWriteReview(reviewerGroupId), { method: 'GET', }); @@ -33,9 +33,9 @@ export const postReviewApi = async ({ reviewData }: { reviewData: ReviewData }) return data; }; -// 상세리뷰 -export const getDetailedReviewApi = async ({ reviewId }: { reviewId: number }) => { - const response = await fetch(endPoint.gettingDetailedReview(reviewId), { +// 상세 리뷰 +export const getDetailedReviewApi = async ({ reviewId, memberId }: { reviewId: number; memberId: number }) => { + const response = await fetch(endPoint.gettingDetailedReview(reviewId, memberId), { method: 'GET', headers: { 'Content-Type': 'application/json', @@ -49,3 +49,31 @@ export const getDetailedReviewApi = async ({ reviewId }: { reviewId: number }) = const data = await response.json(); return data; }; + +// 리뷰 리스트 +export const getReviewListApi = async ({ + revieweeId, + lastReviewId, + memberId, +}: { + revieweeId: number; + lastReviewId: number; + memberId: number; +}) => { + const response = await fetch( + `/api/reviews?revieweeId=${revieweeId}&lastReviewId=${lastReviewId}&memberId=${memberId}`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + + if (!response.ok) { + throw new Error('리뷰 리스트를 불러오는 데 실패했습니다.'); + } + + const data = await response.json(); + return data; +}; diff --git a/frontend/src/assets/lock.svg b/frontend/src/assets/lock.svg index 7c937ffdf..86c8bf0e4 100644 --- a/frontend/src/assets/lock.svg +++ b/frontend/src/assets/lock.svg @@ -1,3 +1,3 @@ - - + + diff --git a/frontend/src/assets/unLock.svg b/frontend/src/assets/unLock.svg index afc143b97..cefd79aa0 100644 --- a/frontend/src/assets/unLock.svg +++ b/frontend/src/assets/unLock.svg @@ -1,3 +1,3 @@ - - + + diff --git a/frontend/src/components/ReviewPreviewCard/index.tsx b/frontend/src/components/ReviewPreviewCard/index.tsx new file mode 100644 index 000000000..551e152f8 --- /dev/null +++ b/frontend/src/components/ReviewPreviewCard/index.tsx @@ -0,0 +1,53 @@ +import GithubLogo from '@/assets/githubLogo.svg'; +import Lock from '@/assets/lock.svg'; +import UnLock from '@/assets/unLock.svg'; + +import * as S from './styles'; + +interface ReviewPreviewCardProps { + reviewerGroup: { + name: string; + }; + createdAt: string; + contentPreview: string; + keywords: { id: number; content: string }[]; + isPublic: boolean; +} + +const ReviewPreviewCard = ({ + reviewerGroup, + createdAt, + contentPreview, + keywords, + isPublic, +}: ReviewPreviewCardProps) => { + return ( + + + +
+ +
+
+ {reviewerGroup.name} + {createdAt} +
+ + + + {isPublic ? '공개' : '비공개'} + + + + {contentPreview} + + {keywords.map((keyword) => ( +
{keyword.content}
+ ))} +
+
+ + ); +}; + +export default ReviewPreviewCard; diff --git a/frontend/src/components/ReviewPreviewCard/styles.ts b/frontend/src/components/ReviewPreviewCard/styles.ts new file mode 100644 index 000000000..d77740341 --- /dev/null +++ b/frontend/src/components/ReviewPreviewCard/styles.ts @@ -0,0 +1,85 @@ +import styled from '@emotion/styled'; + +export const Layout = styled.div` + display: flex; + flex-direction: column; + + width: 100%; + + border: 1px solid ${({ theme }) => theme.colors.lightGray}; + border-radius: 8px; + + &:hover { + cursor: pointer; + border: 1px solid ${({ theme }) => theme.colors.primaryHover}; + + & > div:first-of-type { + background-color: ${({ theme }) => theme.colors.primaryHover}; + } + } +`; + +export const Header = styled.div` + display: flex; + justify-content: space-between; + + height: 6rem; + padding: 1rem 3rem; + + background-color: ${({ theme }) => theme.colors.lightGray}; + border-top-left-radius: 0.8rem; + border-top-right-radius: 0.8rem; +`; + +export const HeaderContainer = styled.div` + display: flex; + gap: 1rem; + + img { + width: 4rem; + } +`; + +export const Title = styled.div` + font-size: 1.6rem; + font-weight: 700; +`; + +export const SubTitle = styled.div` + font-size: 1.2rem; +`; + +export const Visibility = styled.div` + display: flex; + gap: 0.6rem; + align-items: center; + font-size: 1.6rem; + font-weight: 700; + + img { + width: 2rem; + } +`; + +export const Main = styled.div` + display: flex; + flex-direction: column; + gap: 2rem; + + padding: 2rem 3rem; + + font-size: 1.6rem; +`; + +export const Keyword = styled.div` + display: flex; + gap: 3rem; + align-items: center; + font-size: 1.4rem; + + div { + padding: 0.5rem 3rem; + background-color: ${({ theme }) => theme.colors.primaryHover}; + border-radius: 0.8rem; + } +`; diff --git a/frontend/src/components/common/Button/index.tsx b/frontend/src/components/common/Button/index.tsx index 186d4e4e0..5b05a7ba6 100644 --- a/frontend/src/components/common/Button/index.tsx +++ b/frontend/src/components/common/Button/index.tsx @@ -1,4 +1,5 @@ import { ButtonType } from '@/types/styles'; + import * as S from './styles'; interface ButtonProps { diff --git a/frontend/src/components/common/Button/styles.ts b/frontend/src/components/common/Button/styles.ts index 2e4cdad3d..c9542aecb 100644 --- a/frontend/src/components/common/Button/styles.ts +++ b/frontend/src/components/common/Button/styles.ts @@ -1,7 +1,51 @@ +import { css, Theme } from '@emotion/react'; import styled from '@emotion/styled'; import { ButtonType } from '@/types/styles'; +const primaryStyle = (theme: Theme) => css` + color: ${theme.colors.white}; + background-color: ${theme.colors.primary}; + + &:hover { + background-color: ${theme.colors.primaryHover}; + border: 0.1rem solid ${theme.colors.primaryHover}; + } +`; + +const secondaryStyle = (theme: Theme) => css` + color: ${theme.colors.primary}; + background-color: ${theme.colors.white}; + + &:hover { + background-color: ${theme.colors.lightPurple}; + } +`; + +const disabledStyle = (theme: Theme) => css` + pointer-events: none; + color: ${theme.colors.disabledText}; + background-color: ${theme.colors.disabled}; + border-color: ${theme.colors.disabled}; + + &:hover { + background-color: ${theme.colors.disabled}; + } +`; + +const getButtonStyle = (buttonType: ButtonType, theme: Theme) => { + switch (buttonType) { + case 'primary': + return primaryStyle(theme); + case 'secondary': + return secondaryStyle(theme); + case 'disabled': + return disabledStyle(theme); + default: + return css``; + } +}; + export const Button = styled.button<{ buttonType: ButtonType }>` display: flex; align-items: center; @@ -9,17 +53,10 @@ export const Button = styled.button<{ buttonType: ButtonType }>` width: 10rem; height: 4rem; - padding: 2rem; - - color: ${({ theme, buttonType }) => (buttonType === 'primary' ? theme.colors.white : theme.colors.pri)}; + padding: 1rem 2rem; - background-color: ${({ theme, buttonType }) => - buttonType === 'primary' ? theme.colors.primary : theme.colors.white}; border: 0.1rem solid ${({ theme }) => theme.colors.primary}; border-radius: 0.8rem; - &:hover { - color: ${({ theme }) => theme.colors.black}; - background-color: ${({ theme, buttonType }) => (buttonType ? theme.colors.primaryHover : theme.colors.lightGray)}; - } + ${({ buttonType, theme }) => getButtonStyle(buttonType, theme)}; `; diff --git a/frontend/src/components/common/DropDown/index.tsx b/frontend/src/components/common/DropDown/index.tsx new file mode 100644 index 000000000..649afbe7d --- /dev/null +++ b/frontend/src/components/common/DropDown/index.tsx @@ -0,0 +1,24 @@ +import * as S from './styles'; + +interface DropDownProps { + onChange: (value: string) => void; + options: string[]; +} + +const DropDown = ({ onChange, options }: DropDownProps) => { + const handleChange = (e: React.ChangeEvent) => { + onChange(e.target.value); + }; + + return ( + + {options.map((option) => ( + + ))} + + ); +}; + +export default DropDown; diff --git a/frontend/src/components/common/DropDown/styles.ts b/frontend/src/components/common/DropDown/styles.ts new file mode 100644 index 000000000..705a97f89 --- /dev/null +++ b/frontend/src/components/common/DropDown/styles.ts @@ -0,0 +1,11 @@ +import styled from '@emotion/styled'; + +export const Container = styled.select` + width: 12rem; + padding: 8px; + + font-size: 1.6rem; + + border: 1px solid ${({ theme }) => theme.colors.placeholder}; + border-radius: 0.8rem; +`; diff --git a/frontend/src/components/common/MultilineTextViewer/index.tsx b/frontend/src/components/common/MultilineTextViewer/index.tsx new file mode 100644 index 000000000..14fc0f6d7 --- /dev/null +++ b/frontend/src/components/common/MultilineTextViewer/index.tsx @@ -0,0 +1,20 @@ +import React from 'react'; + +interface MultilineTextViewerProps { + text: string; +} + +const MultilineTextViewer = ({ text }: MultilineTextViewerProps) => { + return ( + <> + {text.split('\n').map((line, index) => ( + + {line} +
+
+ ))} + + ); +}; + +export default MultilineTextViewer; diff --git a/frontend/src/components/common/ProjectImg/index.tsx b/frontend/src/components/common/ProjectImg/index.tsx new file mode 100644 index 000000000..9b6860c42 --- /dev/null +++ b/frontend/src/components/common/ProjectImg/index.tsx @@ -0,0 +1,16 @@ +import GithubLogoIcon from '@/assets/githubLogo.svg'; + +import * as S from './styles'; + +export interface ProjectImgProps { + thumbnailUrl?: string; + projectName: string; + $size: string; +} +const ProjectImg = ({ thumbnailUrl, projectName, $size }: ProjectImgProps) => { + const src = thumbnailUrl ?? GithubLogoIcon; + const alt = thumbnailUrl ? `${projectName} 저장소 이미지` : '깃허브 로고'; + return ; +}; + +export default ProjectImg; diff --git a/frontend/src/components/common/ProjectImg/styles.ts b/frontend/src/components/common/ProjectImg/styles.ts new file mode 100644 index 000000000..e125a3316 --- /dev/null +++ b/frontend/src/components/common/ProjectImg/styles.ts @@ -0,0 +1,11 @@ +import styled from '@emotion/styled'; + +interface ImgProps { + $size: string; +} + +export const Img = styled.img` + width: ${(props) => props.$size}; + height: ${(props) => props.$size}; + border-radius: ${({ theme }) => theme.borderRadius.basic}; +`; diff --git a/frontend/src/components/common/ReviewComment/index.tsx b/frontend/src/components/common/ReviewComment/index.tsx new file mode 100644 index 000000000..64351c54f --- /dev/null +++ b/frontend/src/components/common/ReviewComment/index.tsx @@ -0,0 +1,11 @@ +import * as S from './styles'; + +interface ReviewCommentProps { + comment: string; +} + +const ReviewComments = ({ comment }: ReviewCommentProps) => { + return {comment}; +}; + +export default ReviewComments; diff --git a/frontend/src/components/common/ReviewComment/styles.ts b/frontend/src/components/common/ReviewComment/styles.ts new file mode 100644 index 000000000..3073cad59 --- /dev/null +++ b/frontend/src/components/common/ReviewComment/styles.ts @@ -0,0 +1,13 @@ +import styled from '@emotion/styled'; + +export const ReviewComment = styled.p` + width: inherit; + height: 3rem; + margin-top: 1.6rem; + padding-left: 2.5rem; + + font-size: ${({ theme }) => theme.fontSize.basic}; + font-weight: ${({ theme }) => theme.fontWeight.bold}; + + border-left: 0.4rem solid ${({ theme }) => theme.colors.black}; +`; diff --git a/frontend/src/components/common/ReviewDate/index.tsx b/frontend/src/components/common/ReviewDate/index.tsx new file mode 100644 index 000000000..da552daec --- /dev/null +++ b/frontend/src/components/common/ReviewDate/index.tsx @@ -0,0 +1,25 @@ +import ClockIcon from '@/assets/clock.svg'; +import { formatDate } from '@/utils'; + +import * as S from './styles'; + +export interface ReviewDateProps { + date: Date; + dateTitle: string; +} + +const ReviewDate = ({ date, dateTitle }: ReviewDateProps) => { + const { year, month, day } = formatDate(date); + return ( + + + {dateTitle} + : + + {year}-{month}-{day} + + + ); +}; + +export default ReviewDate; diff --git a/frontend/src/components/common/ReviewDate/styles.ts b/frontend/src/components/common/ReviewDate/styles.ts new file mode 100644 index 000000000..7b27f61e1 --- /dev/null +++ b/frontend/src/components/common/ReviewDate/styles.ts @@ -0,0 +1,17 @@ +import styled from '@emotion/styled'; + +export const ClockImg = styled.img` + width: auto; + height: 1.6rem; + margin-right: 0.8rem; +`; + +export const ReviewDate = styled.div` + display: flex; + align-items: center; + font-size: 1.6rem; +`; + +export const Colon = styled.span` + margin: 0 1rem; +`; diff --git a/frontend/src/components/common/SearchInput/styles.ts b/frontend/src/components/common/SearchInput/styles.ts index 76f729a95..c2b76e3a6 100644 --- a/frontend/src/components/common/SearchInput/styles.ts +++ b/frontend/src/components/common/SearchInput/styles.ts @@ -10,5 +10,10 @@ export const Input = styled.input` padding: 1.6rem; border: 1px solid ${({ theme }) => theme.colors.black}; - border-radius: 1.5rem; + border-radius: 0.8rem; + + &::placeholder { + font-size: 1.2rem; + color: ${({ theme }) => theme.colors.placeholder}; + } `; diff --git a/frontend/src/components/common/index.tsx b/frontend/src/components/common/index.tsx index 949cf5f30..856fffe9e 100644 --- a/frontend/src/components/common/index.tsx +++ b/frontend/src/components/common/index.tsx @@ -1 +1,7 @@ +export { default as Button } from './Button'; +export { default as DropDown } from './DropDown'; export { default as SearchInput } from './SearchInput'; +export { default as ProjectImg } from './ProjectImg'; +export { default as ReviewDate } from './ReviewDate'; +export { default as ReviewComment } from './ReviewComment'; +export { default as MultilineTextViewer } from './MultilineTextViewer'; diff --git a/frontend/src/components/index.tsx b/frontend/src/components/index.tsx index deefb9532..a54853d52 100644 --- a/frontend/src/components/index.tsx +++ b/frontend/src/components/index.tsx @@ -1 +1,2 @@ export * from './layouts'; +export * from './common'; diff --git a/frontend/src/components/layouts/PageLayout/styles.ts b/frontend/src/components/layouts/PageLayout/styles.ts index e9ebc1de1..e6e7c64dc 100644 --- a/frontend/src/components/layouts/PageLayout/styles.ts +++ b/frontend/src/components/layouts/PageLayout/styles.ts @@ -1,7 +1,7 @@ import styled from '@emotion/styled'; export const Layout = styled.div` - width: 100vw; + width: 100%; background-color: ${({ theme }) => theme.colors.sidebarBackground}; `; @@ -12,7 +12,7 @@ export const Wrapper = styled.div` display: flex; flex-direction: column; - width: inherit; + width: 100%; background-color: ${({ theme }) => theme.colors.white}; `; diff --git a/frontend/src/components/layouts/Sidebar/index.tsx b/frontend/src/components/layouts/Sidebar/index.tsx index cd4354950..49c54780d 100644 --- a/frontend/src/components/layouts/Sidebar/index.tsx +++ b/frontend/src/components/layouts/Sidebar/index.tsx @@ -10,7 +10,7 @@ import * as S from './styles'; const PATH = { myPage: '/user/mypage', reviewWriting: '/user/review-writing', - allReview: '/user/all-review', + reviewPreviewList: '/user/review-preview-list', detailedReview: '/user/detailed-review', reviewGroupManagement: '/user/review-group-management', }; @@ -26,7 +26,7 @@ const Sidebar = ({ isSidebarOpen, closeSidebar }: SidebarProps) => { const menuItems = [ { path: PATH.myPage, label: PAGE.myPage }, { path: PATH.reviewWriting, label: PAGE.reviewWriting }, - { path: PATH.allReview, label: PAGE.allReview }, + { path: PATH.reviewPreviewList, label: PAGE.reviewPreviewList }, { path: PATH.detailedReview, label: PAGE.detailedReview }, { path: PATH.reviewGroupManagement, label: PAGE.reviewGroupManagement }, ]; diff --git a/frontend/src/components/layouts/Topbar/components/SidebarOpenButton/styles.ts b/frontend/src/components/layouts/Topbar/components/SidebarOpenButton/styles.ts index 6d75aaf1e..91015c335 100644 --- a/frontend/src/components/layouts/Topbar/components/SidebarOpenButton/styles.ts +++ b/frontend/src/components/layouts/Topbar/components/SidebarOpenButton/styles.ts @@ -1,8 +1,8 @@ import styled from '@emotion/styled'; export const HamburgerButton = styled.button` - width: 3.7rem; - height: 3.7rem; + width: 2.3rem; + height: 2.3rem; img { width: 100%; diff --git a/frontend/src/components/layouts/Topbar/index.tsx b/frontend/src/components/layouts/Topbar/index.tsx index 489ee0325..169f7aeeb 100644 --- a/frontend/src/components/layouts/Topbar/index.tsx +++ b/frontend/src/components/layouts/Topbar/index.tsx @@ -18,7 +18,7 @@ const Topbar = ({ openSidebar }: TopbarProps) => { - + diff --git a/frontend/src/components/layouts/Topbar/styles.ts b/frontend/src/components/layouts/Topbar/styles.ts index 69bda354f..fe15a5130 100644 --- a/frontend/src/components/layouts/Topbar/styles.ts +++ b/frontend/src/components/layouts/Topbar/styles.ts @@ -6,8 +6,10 @@ export const Layout = styled.section` box-sizing: border-box; width: 100%; - height: 8rem; - padding: 1.8rem 2.5rem; + height: 7rem; + padding: 2rem 2.5rem; + + border-bottom: 0.1rem solid ${({ theme }) => theme.colors.lightGray}; `; export const Container = styled.div` diff --git a/frontend/src/constants/page.ts b/frontend/src/constants/page.ts index aa0570757..39eda8e32 100644 --- a/frontend/src/constants/page.ts +++ b/frontend/src/constants/page.ts @@ -1,7 +1,7 @@ export const PAGE = { myPage: '마이페이지', reviewWriting: '리뷰 작성하기', - allReview: '전체 리뷰 보기', + reviewPreviewList: '전체 리뷰 보기', detailedReview: '상세 리뷰 보기', reviewGroupManagement: '리뷰 그룹 관리', }; diff --git a/frontend/src/constants/review.ts b/frontend/src/constants/review.ts index 9ec55672d..098ed3375 100644 --- a/frontend/src/constants/review.ts +++ b/frontend/src/constants/review.ts @@ -1,7 +1,10 @@ export const REVIEW = { - questionList: [ - '1. 동료의 개발 역량 향상을 위해 피드백을 남겨 주세요.', - '2. 동료의 소프트 스킬의 성장을 위해 피드백을 남겨 주세요.', - '3. 팀 동료로 근무한다면 같이 일하고 싶은 개발자인가요?', - ], + answerMaxLength: 1000, + answerMinLength: 20, + keywordMaxCount: 5, + keywordMinCount: 1, +}; + +export const REVIEW_MESSAGE = { + answerMaxLength: `최대 ${REVIEW.answerMaxLength}자까지 입력 가능해요.`, }; diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 47302fcaf..693be499b 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -1,14 +1,14 @@ -import App from '@/App'; -import { css, Global } from '@emotion/react'; -import { ThemeProvider } from '@emotion/react'; - +import { Global, ThemeProvider } from '@emotion/react'; import React from 'react'; import ReactDOM from 'react-dom/client'; -import reset from './styles/reset'; -import globalStyles from './styles/globalStyles'; -import ReviewWritingPage from './pages/ReviewWriting'; -import DetailedReviewPage from './pages/DetailedReviewPage'; import { createBrowserRouter, RouterProvider } from 'react-router-dom'; + +import App from '@/App'; + +import DetailedReviewPage from './pages/DetailedReviewPage'; +import ReviewPreviewListPage from './pages/ReviewPreviewListPage'; +import ReviewWritingPage from './pages/ReviewWriting'; +import globalStyles from './styles/globalStyles'; import theme from './styles/theme'; const router = createBrowserRouter([ @@ -20,11 +20,14 @@ const router = createBrowserRouter([ path: 'user', element:
user
, }, - { path: 'user/review-writing', element: , }, + { + path: 'user/review-preview-list', + element: , + }, { path: 'user/detailed-review', element: , @@ -34,11 +37,21 @@ const router = createBrowserRouter([ ]); const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); -root.render( - - - - - - , -); + +async function enableMocking() { + if (process.env.NODE_ENV === 'development') { + const { worker } = await import('./mocks/browser'); + return worker.start(); + } +} + +enableMocking().then(() => { + root.render( + + + + + + , + ); +}); diff --git a/frontend/src/mocks/browser.ts b/frontend/src/mocks/browser.ts new file mode 100644 index 000000000..93c00a0f1 --- /dev/null +++ b/frontend/src/mocks/browser.ts @@ -0,0 +1,8 @@ +/* eslint-disable */ +import { setupWorker } from 'msw/browser'; + +import handlers from './handlers'; + +export const worker = setupWorker(...handlers); + +export default worker; diff --git a/frontend/src/mocks/handlers/index.ts b/frontend/src/mocks/handlers/index.ts new file mode 100644 index 000000000..8bb8e8612 --- /dev/null +++ b/frontend/src/mocks/handlers/index.ts @@ -0,0 +1,5 @@ +import reviewHandler from './review'; + +const handlers = [...reviewHandler]; + +export default handlers; diff --git a/frontend/src/mocks/handlers/review.ts b/frontend/src/mocks/handlers/review.ts new file mode 100644 index 000000000..89d0cfa4c --- /dev/null +++ b/frontend/src/mocks/handlers/review.ts @@ -0,0 +1,14 @@ +import { http, HttpResponse } from 'msw'; + +import endPoint from '@/apis/endpoints'; + +import { DETAILED_REVIEW_MOCK_DATA } from '../mockData/detailedReviewMockData'; + +const getDetailedReview = () => + http.get(endPoint.gettingDetailedReview(0, 1), async ({ request }) => { + return HttpResponse.json(DETAILED_REVIEW_MOCK_DATA); + }); + +const reviewHandler = [getDetailedReview()]; + +export default reviewHandler; diff --git a/frontend/src/mocks/mockData/detailedReviewMockData.ts b/frontend/src/mocks/mockData/detailedReviewMockData.ts new file mode 100644 index 000000000..6aae872b9 --- /dev/null +++ b/frontend/src/mocks/mockData/detailedReviewMockData.ts @@ -0,0 +1,28 @@ +import { DetailReviewData } from '@/types'; + +const ANSWER = + '림순의 바람은 그윽한 산들바람처럼 잔잔하게 흘러갔습니다. \n 눈부신 햇살이 그의 어깨를 감싸며, 푸른 하늘 아래 펼쳐진 들판을 바라보았습니다.\n 그의 마음은 자연의 아름다움 속에서 평온을 찾았고, 그 순간마다 삶의 소중함을 느꼈습니다.\n 그는 늘 그러한 순간들을 기억하며, 미래의 나날들을 기대했습니다. \n 바람은 여전히 그를 감싸며, 그의 마음 속 깊은 곳에 있는 꿈과 희망을 불러일으켰습니다.\n 림순은 미소 지으며 앞으로 나아갔습니다.림순의 바람은 그윽한 산들바람처럼 잔잔하게 흘러갔습니다. \n 눈부신 햇살이 그의 어깨를 감싸며, 푸른 하늘 아래 펼쳐진 들판을 바라보았습니다.\n 그의 마음은 자연의 아름다움 속에서 평온을 찾았고, 그 순간마다 삶의 소중함을 느꼈습니다.\n 그는 늘 그러한 순간들을 기억하며, 미래의 나날들을 기대했습니다. 림순의 바람은 그윽한 산들바람처럼 잔잔하게 흘러갔습니다. \n 눈부신 햇살이 그의 어깨를 감싸며, 푸른 하늘 아래 펼쳐진 들판을 바라보았습니다.\n 그의 마음은 자연의 아름다움 속에서 평온을 찾았고, 그 순간마다 삶의 소중함을 느꼈습니다.\n 그는 늘 그러한 순간들을 기억하며, 미래의 나날들을 기대했습니다. \n 바람은 여전히 그를 감싸며, 그의 마음 속 깊은 곳에 있는 꿈과 희망을 불러일으켰습니다.\n 림순은 미소 지으며 앞으로 나아갔습니다.림순의 바람은 그윽한 산들바람처럼 잔잔하게 흘러갔습니다. \n 눈부신 햇살이 그의 어깨를 감싸며, 푸른 하늘 아래 펼쳐진 들판을 바라보았습니다.\n 그의 마음은 자연의 아름다움 속에서 평온을 찾았고, 그 순간마다 삶의 소중함을 느꼈습니다.\n 그는 늘 그러한 순간들을 기억하며, 미래의 나날들을 기대했습니다. '; + +export const DETAILED_REVIEW_MOCK_DATA: DetailReviewData = { + id: 123456, + createdAt: new Date('2024-07-16'), + reviewerGroup: { + id: 123456, + name: 'review-me', + deadline: new Date('2024-07-01'), + reviewee: { + id: 78910, + name: '바다', + }, + }, + contents: [ + { + id: 23456, + question: '[공개] 동료의 개발 역량 향상을 위해 피드백을 남겨 주세요.', + answer: ANSWER, + }, + { id: 567810, question: '[공개] 동료의 소프트 스킬의 성장을 위해 피드백을 남겨 주세요.', answer: ANSWER }, + { id: 98761, question: '[비공개] 팀 동료로 근무한다면 같이 일 하고 싶은 개발자인가요?', answer: ANSWER }, + ], + keywords: ['친절해요', '친절합니다!', '친절해요요요요요', '친절해해해해해', '친절해요요용'], +}; diff --git a/frontend/src/mocks/server.ts b/frontend/src/mocks/server.ts new file mode 100644 index 000000000..8f041004b --- /dev/null +++ b/frontend/src/mocks/server.ts @@ -0,0 +1,7 @@ +import { setupServer } from 'msw/node'; + +import handlers from './handlers'; + +const server = setupServer(...handlers); + +export default server; diff --git a/frontend/src/pages/DetailedReviewPage/components/KeywordSection/index.tsx b/frontend/src/pages/DetailedReviewPage/components/KeywordSection/index.tsx new file mode 100644 index 000000000..0ee031dae --- /dev/null +++ b/frontend/src/pages/DetailedReviewPage/components/KeywordSection/index.tsx @@ -0,0 +1,24 @@ +import ReviewSectionHeader from '../ReviewSectionHeader'; + +import * as S from './styles'; + +interface KeywordSectionProps { + keywords: string[]; + index: number; +} +const KEY_WORD_HEADER = '키워드'; + +const KeywordSection = ({ keywords, index }: KeywordSectionProps) => { + return ( + + + + {keywords.map((keyword) => ( + {keyword} + ))} + + + ); +}; + +export default KeywordSection; diff --git a/frontend/src/pages/DetailedReviewPage/components/KeywordSection/styles.ts b/frontend/src/pages/DetailedReviewPage/components/KeywordSection/styles.ts new file mode 100644 index 000000000..b3b8900f8 --- /dev/null +++ b/frontend/src/pages/DetailedReviewPage/components/KeywordSection/styles.ts @@ -0,0 +1,31 @@ +import styled from '@emotion/styled'; + +export const KeywordSection = styled.section` + width: 100%; + margin-top: 3.2rem; +`; + +export const KeywordContainer = styled.div` + display: flex; + flex-wrap: wrap; + row-gap: 3.2rem; + column-gap: 2.4rem; +`; + +export const KeywordBox = styled.div` + display: flex; + align-items: center; + + box-sizing: border-box; + height: 5rem; + height: fit-content; + padding: 0.8rem 2.5rem; + + font-size: 1.6rem; + line-height: 2.4rem; + text-align: center; + + background-color: ${({ theme }) => theme.colors.lightPurple}; + border: 0.1rem solid ${({ theme }) => theme.colors.primary}; + border-radius: ${({ theme }) => theme.borderRadius.basic}; +`; diff --git a/frontend/src/pages/DetailedReviewPage/components/LockButton/index.tsx b/frontend/src/pages/DetailedReviewPage/components/LockButton/index.tsx deleted file mode 100644 index 7b2644851..000000000 --- a/frontend/src/pages/DetailedReviewPage/components/LockButton/index.tsx +++ /dev/null @@ -1,30 +0,0 @@ -//import UnlockIcon from '@/assets/Unlock.svg'; -import LockIcon from '@/assets/lock.svg'; -import UnlockIcon from '@/assets/unLock.svg'; - -interface LockButtonProps { - isLock: boolean; - onClick: () => void; -} - -const IMAGE = { - lock: { - src: LockIcon, - alt: 'lock icon', - }, - unlock: { - src: UnlockIcon, - alt: 'unlock icon', - }, -}; - -const LockButton = ({ isLock, onClick }: LockButtonProps) => { - const { src, alt } = isLock ? IMAGE.lock : IMAGE.unlock; - return ( - - ); -}; - -export default LockButton; diff --git a/frontend/src/pages/DetailedReviewPage/components/LockToggle/index.tsx b/frontend/src/pages/DetailedReviewPage/components/LockToggle/index.tsx new file mode 100644 index 000000000..50f44e5d0 --- /dev/null +++ b/frontend/src/pages/DetailedReviewPage/components/LockToggle/index.tsx @@ -0,0 +1,47 @@ +import LockIcon from '@/assets/lock.svg'; +import UnlockIcon from '@/assets/unLock.svg'; + +import * as S from './styles'; + +const IMAGE = { + lock: { + src: LockIcon, + alt: 'lock icon', + text: '비공개', + }, + unlock: { + src: UnlockIcon, + alt: 'unlock icon', + text: '공개', + }, +}; +interface ToggleButtonProps { + name: keyof typeof IMAGE; + $isPublic: boolean; + handleClickToggleButton: () => void; +} + +const ToggleButton = ({ name, $isPublic, handleClickToggleButton }: ToggleButtonProps) => { + const { src, alt, text } = IMAGE[name]; + const $isActive = name === 'lock' ? $isPublic : !$isPublic; + + return ( + + {alt} + {text} + + ); +}; + +interface LockToggleProps extends Omit {} + +const LockToggle = (props: LockToggleProps) => { + return ( + + + + + ); +}; + +export default LockToggle; diff --git a/frontend/src/pages/DetailedReviewPage/components/LockToggle/styles.ts b/frontend/src/pages/DetailedReviewPage/components/LockToggle/styles.ts new file mode 100644 index 000000000..1dadbb62f --- /dev/null +++ b/frontend/src/pages/DetailedReviewPage/components/LockToggle/styles.ts @@ -0,0 +1,40 @@ +import styled from '@emotion/styled'; + +interface ButtonProps { + $isActive: boolean; +} +export const Button = styled.button` + display: flex; + align-items: center; + justify-content: center; + + width: 9rem; + height: 3rem; + padding: 0.8rem 0.8rem; + + font-size: ${({ theme }) => theme.fontSize.small}; + font-weight: ${({ theme }) => theme.fontWeight.bold}; + color: ${(props) => (props.$isActive ? props.theme.colors.black : props.theme.colors.gray)}; + + background-color: ${(props) => (props.$isActive ? props.theme.colors.white : 'transparent')}; + border-radius: ${({ theme }) => theme.borderRadius.basic}; + img { + width: 1.4rem; + height: 1.4rem; + margin-right: 0.4rem; + fill: ${(props) => (props.$isActive ? props.theme.colors.black : props.theme.colors.gray)}; + } +`; + +export const LockToggle = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + + width: 20rem; + height: 4rem; + padding: 0.5rem 0.6rem; + + background-color: ${({ theme }) => theme.colors.lightGray}; + border-radius: ${({ theme }) => theme.borderRadius.basic}; +`; diff --git a/frontend/src/pages/DetailedReviewPage/components/ReviewAnswer/index.tsx b/frontend/src/pages/DetailedReviewPage/components/ReviewAnswer/index.tsx deleted file mode 100644 index 176861cec..000000000 --- a/frontend/src/pages/DetailedReviewPage/components/ReviewAnswer/index.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react'; -import * as S from './styles'; - -interface ReviewAnswerProps { - answer: string; -} - -// NOTE: 개행 문자 처리하는 함수 -const applyNewLine = (text: string): React.ReactNode => { - return text.split('\n').map((line, index) => ( - - {line} -
-
- )); -}; - -const ReviewAnswer = ({ answer }: ReviewAnswerProps) => { - return {applyNewLine(answer)}; -}; - -export default ReviewAnswer; diff --git a/frontend/src/pages/DetailedReviewPage/components/ReviewAnswer/styles.ts b/frontend/src/pages/DetailedReviewPage/components/ReviewAnswer/styles.ts deleted file mode 100644 index a3ce591a5..000000000 --- a/frontend/src/pages/DetailedReviewPage/components/ReviewAnswer/styles.ts +++ /dev/null @@ -1,5 +0,0 @@ -import styled from '@emotion/styled'; - -export const Answer = styled.article` - margin-bottom: 1.5rem; -`; diff --git a/frontend/src/pages/DetailedReviewPage/components/ReviewDescription/index.tsx b/frontend/src/pages/DetailedReviewPage/components/ReviewDescription/index.tsx index 03ac14021..fe0f94355 100644 --- a/frontend/src/pages/DetailedReviewPage/components/ReviewDescription/index.tsx +++ b/frontend/src/pages/DetailedReviewPage/components/ReviewDescription/index.tsx @@ -1,44 +1,37 @@ -import LockButton from '../LockButton'; -import * as S from './styles'; +import { ProjectImg, ReviewDate } from '@/components'; +import { ProjectImgProps } from '@/components/common/ProjectImg'; +import { ReviewDateProps } from '@/components/common/ReviewDate'; -interface ReviewDescriptionItemProps { - title: string; - contents: string; -} -const ReviewDescriptionItem = ({ title, contents }: ReviewDescriptionItemProps) => { - return ( - - {title} - : - {contents} - - ); -}; +import LockToggle from '../LockToggle'; -interface ReviewDescriptionProps { - projectName: string; - createdAt: Date; - isLock: boolean; -} +import * as S from './styles'; -const formatDate = (date: Date) => { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - //const minutes = String(date.getMinutes()).padStart(2, '0'); +const PROJECT_IMAGE_SIZE = '6rem'; - return `${year}/${month}/${day}`; -}; +interface ReviewDescriptionProps extends Omit, Omit { + isPublic: boolean; + handleClickToggleButton: () => void; +} -const ReviewDescription = ({ projectName, createdAt, isLock }: ReviewDescriptionProps) => { +const ReviewDescription = ({ + thumbnailUrl, + projectName, + isPublic, + date, + handleClickToggleButton, +}: ReviewDescriptionProps) => { return ( - - - console.log('lock')} /> - - - + + + + {projectName} + + + + + + ); }; diff --git a/frontend/src/pages/DetailedReviewPage/components/ReviewDescription/styles.ts b/frontend/src/pages/DetailedReviewPage/components/ReviewDescription/styles.ts index ce142dfe3..799be41d7 100644 --- a/frontend/src/pages/DetailedReviewPage/components/ReviewDescription/styles.ts +++ b/frontend/src/pages/DetailedReviewPage/components/ReviewDescription/styles.ts @@ -1,33 +1,27 @@ import styled from '@emotion/styled'; -export const Description = styled.ul` +export const Description = styled.section` + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + height: 6rem; margin: 0; padding-left: 0; `; - -export const ProjectAndLockButtonContainer = styled.div` +export const DescriptionSide = styled.div` display: flex; - justify-content: space-between; - width: 100%; `; -export const ListItem = styled.li` - padding: 0; - list-style: none; - - span { - display: inline-block; - } -`; -export const Title = styled.span` - width: 6rem; - font-size: 1.1rem; - font-weight: bold; -`; -export const Clone = styled.span` - margin: 0 0.5rem; - font-size: 1rem; +export const ProjectNameAndDateContainer = styled.div` + display: flex; + flex-direction: column; + justify-content: flex-start; + margin-left: 1rem; `; -export const Contents = styled.span` - font-size: 1rem; + +export const ProjectName = styled.p` + margin-top: 0; + font-size: ${({ theme }) => theme.fontSize.medium}; + font-weight: ${({ theme }) => theme.fontWeight.bold}; `; diff --git a/frontend/src/pages/DetailedReviewPage/components/ReviewQuestion/index.tsx b/frontend/src/pages/DetailedReviewPage/components/ReviewQuestion/index.tsx deleted file mode 100644 index 7b9921932..000000000 --- a/frontend/src/pages/DetailedReviewPage/components/ReviewQuestion/index.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import * as S from './styles'; - -interface ReviewQuestionProps { - question: string; -} - -const ReviewQuestion = ({ question }: ReviewQuestionProps) => { - return {question}; -}; - -export default ReviewQuestion; diff --git a/frontend/src/pages/DetailedReviewPage/components/ReviewQuestion/styles.ts b/frontend/src/pages/DetailedReviewPage/components/ReviewQuestion/styles.ts deleted file mode 100644 index a230cccfd..000000000 --- a/frontend/src/pages/DetailedReviewPage/components/ReviewQuestion/styles.ts +++ /dev/null @@ -1,7 +0,0 @@ -import styled from '@emotion/styled'; - -export const Question = styled.p` - margin-bottom: 0.5rem; - font-size: 1rem; - font-weight: bold; -`; diff --git a/frontend/src/pages/DetailedReviewPage/components/ReviewSection/index.tsx b/frontend/src/pages/DetailedReviewPage/components/ReviewSection/index.tsx new file mode 100644 index 000000000..c730feeaf --- /dev/null +++ b/frontend/src/pages/DetailedReviewPage/components/ReviewSection/index.tsx @@ -0,0 +1,23 @@ +import { MultilineTextViewer } from '@/components'; + +import ReviewSectionHeader from '../ReviewSectionHeader'; + +import * as S from './styles'; +interface ReviewSectionProps { + question: string; + answer: string; + index: number; +} + +const ReviewSection = ({ question, answer, index }: ReviewSectionProps) => { + return ( + + + + + + + ); +}; + +export default ReviewSection; diff --git a/frontend/src/pages/DetailedReviewPage/components/ReviewSection/styles.ts b/frontend/src/pages/DetailedReviewPage/components/ReviewSection/styles.ts new file mode 100644 index 000000000..eb8917aa8 --- /dev/null +++ b/frontend/src/pages/DetailedReviewPage/components/ReviewSection/styles.ts @@ -0,0 +1,20 @@ +import styled from '@emotion/styled'; + +export const ReviewSection = styled.section` + width: 100%; + margin-top: 3.2rem; +`; + +export const Answer = styled.div` + overflow-y: auto; + + box-sizing: border-box; + width: 100%; + height: 23rem; + padding: 1rem 1.5rem; + + font-size: 1.6rem; + line-height: 2.4rem; + + background-color: ${({ theme }) => theme.colors.lightGray}; +`; diff --git a/frontend/src/pages/DetailedReviewPage/components/ReviewSectionHeader/index.tsx b/frontend/src/pages/DetailedReviewPage/components/ReviewSectionHeader/index.tsx new file mode 100644 index 000000000..ebc2b5a1f --- /dev/null +++ b/frontend/src/pages/DetailedReviewPage/components/ReviewSectionHeader/index.tsx @@ -0,0 +1,16 @@ +import * as S from './styles'; + +interface ReviewSectionHeaderProps { + number: number; + text: string; +} + +const ReviewSectionHeader = ({ number, text }: ReviewSectionHeaderProps) => { + return ( + + {number}. {text} + + ); +}; + +export default ReviewSectionHeader; diff --git a/frontend/src/pages/DetailedReviewPage/components/ReviewSectionHeader/styles.ts b/frontend/src/pages/DetailedReviewPage/components/ReviewSectionHeader/styles.ts new file mode 100644 index 000000000..517c62e27 --- /dev/null +++ b/frontend/src/pages/DetailedReviewPage/components/ReviewSectionHeader/styles.ts @@ -0,0 +1,7 @@ +import styled from '@emotion/styled'; + +export const ReviewSectionHeader = styled.p` + margin-bottom: 1rem; + font-size: 1.6rem; + font-weight: bold; +`; diff --git a/frontend/src/pages/DetailedReviewPage/components/ReviewViewSection/index.tsx b/frontend/src/pages/DetailedReviewPage/components/ReviewViewSection/index.tsx deleted file mode 100644 index fed1624b1..000000000 --- a/frontend/src/pages/DetailedReviewPage/components/ReviewViewSection/index.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import ReviewQuestion from '../ReviewQuestion'; -import ReviewAnswer from '../ReviewAnswer'; -import * as S from './styles'; -interface ReviewSectionProps { - question: string; - answer: string; -} - -const ReviewViewSection = ({ question, answer }: ReviewSectionProps) => { - return ( - - - - - ); -}; - -export default ReviewViewSection; diff --git a/frontend/src/pages/DetailedReviewPage/components/ReviewViewSection/styles.ts b/frontend/src/pages/DetailedReviewPage/components/ReviewViewSection/styles.ts deleted file mode 100644 index 7d22bffdf..000000000 --- a/frontend/src/pages/DetailedReviewPage/components/ReviewViewSection/styles.ts +++ /dev/null @@ -1,5 +0,0 @@ -import styled from '@emotion/styled'; - -export const ReviewSectionContainer = styled.section` - width: 100%; -`; diff --git a/frontend/src/pages/DetailedReviewPage/index.tsx b/frontend/src/pages/DetailedReviewPage/index.tsx index 45860b638..0bd06a37c 100644 --- a/frontend/src/pages/DetailedReviewPage/index.tsx +++ b/frontend/src/pages/DetailedReviewPage/index.tsx @@ -1,78 +1,61 @@ +import { useState, useEffect } from 'react'; +import { useParams } from 'react-router'; import { DetailReviewData } from '@/types'; -import ReviewViewSection from './components/ReviewViewSection'; -import ReviewDescription from './components/ReviewDescription'; -import { useState, useEffect } from 'react'; import { getDetailedReviewApi } from '../../apis/review'; +import ReviewDescription from './components/ReviewDescription'; +import ReviewViewSection from './components/ReviewViewSection'; + const ANSWER = '림순의 바람은 그윽한 산들바람처럼 잔잔하게 흘러갔습니다. \n 눈부신 햇살이 그의 어깨를 감싸며, 푸른 하늘 아래 펼쳐진 들판을 바라보았습니다.\n 그의 마음은 자연의 아름다움 속에서 평온을 찾았고, 그 순간마다 삶의 소중함을 느꼈습니다.\n 그는 늘 그러한 순간들을 기억하며, 미래의 나날들을 기대했습니다. \n 바람은 여전히 그를 감싸며, 그의 마음 속 깊은 곳에 있는 꿈과 희망을 불러일으켰습니다.\n 림순은 미소 지으며 앞으로 나아갔습니다.림순의 바람은 그윽한 산들바람처럼 잔잔하게 흘러갔습니다. \n 눈부신 햇살이 그의 어깨를 감싸며, 푸른 하늘 아래 펼쳐진 들판을 바라보았습니다.\n 그의 마음은 자연의 아름다움 속에서 평온을 찾았고, 그 순간마다 삶의 소중함을 느꼈습니다.\n 그는 늘 그러한 순간들을 기억하며, 미래의 나날들을 기대했습니다. '; -const MOCK_DATA: DetailReviewData = { - id: 123456, - reviewer: { - memberId: 123456, - name: '올리', - }, - createdAt: new Date('2024-07-16'), - reviewerGroup: { - groupId: 123456, - name: 'review-me', - }, - contents: [ - { - question: '1. [공개] 동료의 개발 역량 향상을 위해 피드백을 남겨 주세요.', - answer: ANSWER, - }, - { question: '2. [공개] 동료의 소프트 스킬의 성장을 위해 피드백을 남겨 주세요.', answer: ANSWER }, - { question: '3. [비공개] 팀 동료로 근무한다면 같이 일 하고 싶은 개발자인가요?', answer: ANSWER }, - ], - keywords: [{ id: 1, detail: '친절해요' }], -}; + +const COMMENT = 'VITE 쓰고 싶다.'; const DetailedReviewPage = ({}) => { + const { id } = useParams<{ id: string }>(); + const [detailReview, setDetailReview] = useState(MOCK_DATA); const [isLoading, setIsLoading] = useState(false); - const [errorMessage, setErrorMessage] = useState(''); + const [errorMessage, setErrorMessage] = useState(''); + + useEffect(() => { + const fetchReview = async () => { + try { + const result = await getDetailedReviewApi({ reviewId: Number(id), memberId: 4 }); - const fetch = async () => { - try { - setIsLoading(true); - getDetailedReviewApi({ reviewId: 4 }).then((result) => { setDetailReview(result); setErrorMessage(''); - }); - } catch (error) { - if (error instanceof Error) { - setErrorMessage(error.message); + } catch (error) { + setErrorMessage('리뷰를 불러오는 데 실패했습니다.'); + } finally { + setIsLoading(false); } - } finally { - setIsLoading(false); - } - }; + }; - useEffect(() => { - fetch(); - }, []); + fetchReview(); + }, [id]); if (isLoading) return
Loading...
; if (errorMessage) return
Error: {errorMessage}
; + if (!detailedReview) return
Error: 상세보기 리뷰 데이터를 가져올 수 없어요.
; return ( - <> + console.log('click toggle ')} /> - {detailReview.contents.map((item, index) => ( - + + {detailedReview.contents.map((item, index) => ( + ))} - + + ); }; diff --git a/frontend/src/pages/DetailedReviewPage/styles.ts b/frontend/src/pages/DetailedReviewPage/styles.ts index 2dcecf0f4..c9b901bb3 100644 --- a/frontend/src/pages/DetailedReviewPage/styles.ts +++ b/frontend/src/pages/DetailedReviewPage/styles.ts @@ -1,11 +1,5 @@ import styled from '@emotion/styled'; -export const DetailedReview = styled.div` - box-sizing: border-box; - width: 40rem; - min-height: calc(100vh - 3rem); - margin-top: 3rem; - padding: 1rem; - - border: 1px solid black; +export const DetailedReviewPage = styled.div` + width: ${({ theme }) => theme.formWidth}; `; diff --git a/frontend/src/pages/ReviewPreviewListPage/components/SearchSection/index.tsx b/frontend/src/pages/ReviewPreviewListPage/components/SearchSection/index.tsx new file mode 100644 index 000000000..aece5b0a8 --- /dev/null +++ b/frontend/src/pages/ReviewPreviewListPage/components/SearchSection/index.tsx @@ -0,0 +1,23 @@ +import { Button, DropDown, SearchInput } from '@/components/common'; + +import * as S from './styles'; + +interface SearchSectionProps { + onChange: (value: string) => void; + options: string[]; + placeholder: string; +} + +const SearchSection = ({ onChange, options, placeholder }: SearchSectionProps) => { + return ( + + + +