diff --git a/server/src/docs/asciidoc/exam-like.adoc b/server/src/docs/asciidoc/exam-like.adoc new file mode 100644 index 0000000..ac835ab --- /dev/null +++ b/server/src/docs/asciidoc/exam-like.adoc @@ -0,0 +1,9 @@ +== Exam Like API + +=== 시험 좋아요 등록 + +operation::exam-like-document-test/like[] + +=== 시험 좋아요 취소 + +operation::exam-like-document-test/unlike[] diff --git a/server/src/docs/asciidoc/exam.adoc b/server/src/docs/asciidoc/exam.adoc index 2c48885..1555dc1 100644 --- a/server/src/docs/asciidoc/exam.adoc +++ b/server/src/docs/asciidoc/exam.adoc @@ -38,4 +38,5 @@ operation::exam-document-test/update-title[] === 시험 설명 수정 -operation::exam-document-test/update-description[] \ No newline at end of file +operation::exam-document-test/update-description[] + diff --git a/server/src/docs/asciidoc/index.adoc b/server/src/docs/asciidoc/index.adoc index c3838cc..f3d84e5 100644 --- a/server/src/docs/asciidoc/index.adoc +++ b/server/src/docs/asciidoc/index.adoc @@ -7,6 +7,10 @@ = Fluffy API 명세서 include::auth.adoc[] + include::oauth2.adoc[] + include::exam.adoc[] +include::exam-like.adoc[] + include::submission.adoc[] diff --git a/server/src/main/java/com/fluffy/exam/api/ExamLikeController.java b/server/src/main/java/com/fluffy/exam/api/ExamLikeController.java new file mode 100644 index 0000000..3437336 --- /dev/null +++ b/server/src/main/java/com/fluffy/exam/api/ExamLikeController.java @@ -0,0 +1,38 @@ +package com.fluffy.exam.api; + +import com.fluffy.exam.application.ExamLikeService; +import com.fluffy.global.web.Accessor; +import com.fluffy.global.web.Auth; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class ExamLikeController { + + private final ExamLikeService examLikeService; + + @PostMapping("/api/v1/exams/{examId}/like") + public ResponseEntity like( + @PathVariable Long examId, + @Auth Accessor accessor + ) { + examLikeService.like(examId, accessor); + + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/api/v1/exams/{examId}/like") + public ResponseEntity unlike( + @PathVariable Long examId, + @Auth Accessor accessor + ) { + examLikeService.unlike(examId, accessor); + + return ResponseEntity.ok().build(); + } +} diff --git a/server/src/main/java/com/fluffy/exam/application/ExamLikeService.java b/server/src/main/java/com/fluffy/exam/application/ExamLikeService.java new file mode 100644 index 0000000..1c67ae1 --- /dev/null +++ b/server/src/main/java/com/fluffy/exam/application/ExamLikeService.java @@ -0,0 +1,26 @@ +package com.fluffy.exam.application; + +import com.fluffy.global.web.Accessor; +import com.fluffy.reaction.application.Like; +import com.fluffy.reaction.application.LikeService; +import com.fluffy.reaction.application.LikeTarget; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ExamLikeService { + + private final LikeService likeService; + + @Transactional + public Long like(Long examId, Accessor accessor) { + return likeService.like(new Like(LikeTarget.EXAM, examId), accessor.id()); + } + + @Transactional + public Long unlike(Long examId, Accessor accessor) { + return likeService.removeLike(new Like(LikeTarget.EXAM, examId), accessor.id()); + } +} diff --git a/server/src/main/java/com/fluffy/reaction/application/Like.java b/server/src/main/java/com/fluffy/reaction/application/Like.java new file mode 100644 index 0000000..a0445e4 --- /dev/null +++ b/server/src/main/java/com/fluffy/reaction/application/Like.java @@ -0,0 +1,4 @@ +package com.fluffy.reaction.application; + +public record Like(LikeTarget target, Long targetId) { +} diff --git a/server/src/main/java/com/fluffy/reaction/application/LikeService.java b/server/src/main/java/com/fluffy/reaction/application/LikeService.java new file mode 100644 index 0000000..a555913 --- /dev/null +++ b/server/src/main/java/com/fluffy/reaction/application/LikeService.java @@ -0,0 +1,42 @@ +package com.fluffy.reaction.application; + +import com.fluffy.global.exception.BadRequestException; +import com.fluffy.reaction.domain.Reaction; +import com.fluffy.reaction.domain.ReactionRepository; +import com.fluffy.reaction.domain.ReactionType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class LikeService { + + private final ReactionRepository reactionRepository; + + @Transactional + public Long like(Like like, Long memberId) { + String targetType = like.target().name(); + Long targetId = like.targetId(); + + Reaction reaction = reactionRepository.findByTargetTypeAndTargetIdAndMemberId(targetType, targetId, memberId) + .orElse(reactionRepository.save(new Reaction(targetType, targetId, memberId, ReactionType.LIKE))); + + reaction.active(); + + return reaction.getId(); + } + + @Transactional + public Long removeLike(Like like, Long memberId) { + String targetType = like.target().name(); + Long targetId = like.targetId(); + + Reaction reaction = reactionRepository.findByTargetTypeAndTargetIdAndMemberId(targetType, targetId, memberId) + .orElseThrow(() -> new BadRequestException("좋아요를 한 상태가 아닙니다.")); + + reaction.delete(); + + return reaction.getId(); + } +} diff --git a/server/src/main/java/com/fluffy/reaction/application/LikeTarget.java b/server/src/main/java/com/fluffy/reaction/application/LikeTarget.java new file mode 100644 index 0000000..27b2726 --- /dev/null +++ b/server/src/main/java/com/fluffy/reaction/application/LikeTarget.java @@ -0,0 +1,6 @@ +package com.fluffy.reaction.application; + +public enum LikeTarget { + + EXAM +} diff --git a/server/src/main/java/com/fluffy/reaction/domain/Reaction.java b/server/src/main/java/com/fluffy/reaction/domain/Reaction.java new file mode 100644 index 0000000..70c9a81 --- /dev/null +++ b/server/src/main/java/com/fluffy/reaction/domain/Reaction.java @@ -0,0 +1,72 @@ +package com.fluffy.reaction.domain; + +import com.fluffy.global.persistence.AuditableEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(uniqueConstraints = { + @jakarta.persistence.UniqueConstraint(columnNames = {"targetType", "targetId", "memberId", "type"}) +}) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Reaction extends AuditableEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String targetType; + + @Column(nullable = false) + private Long targetId; + + @Column(nullable = false) + private Long memberId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ReactionType type; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ReactionStatus status; + + public Reaction(String targetType, Long targetId, Long memberId, ReactionType type) { + this(null, targetType, targetId, memberId, type, ReactionStatus.ACTIVE); + } + + public Reaction( + Long id, + String targetType, + Long targetId, + Long memberId, + ReactionType type, + ReactionStatus status + ) { + this.id = id; + this.targetType = targetType; + this.targetId = targetId; + this.memberId = memberId; + this.type = type; + this.status = status; + } + + public void active() { + this.status = ReactionStatus.ACTIVE; + } + + public void delete() { + this.status = ReactionStatus.DELETED; + } +} diff --git a/server/src/main/java/com/fluffy/reaction/domain/ReactionRepository.java b/server/src/main/java/com/fluffy/reaction/domain/ReactionRepository.java new file mode 100644 index 0000000..04fd0c8 --- /dev/null +++ b/server/src/main/java/com/fluffy/reaction/domain/ReactionRepository.java @@ -0,0 +1,9 @@ +package com.fluffy.reaction.domain; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReactionRepository extends JpaRepository { + + Optional findByTargetTypeAndTargetIdAndMemberId(String targetType, Long targetId, Long memberId); +} diff --git a/server/src/main/java/com/fluffy/reaction/domain/ReactionStatus.java b/server/src/main/java/com/fluffy/reaction/domain/ReactionStatus.java new file mode 100644 index 0000000..df5ecd3 --- /dev/null +++ b/server/src/main/java/com/fluffy/reaction/domain/ReactionStatus.java @@ -0,0 +1,6 @@ +package com.fluffy.reaction.domain; + +public enum ReactionStatus { + + ACTIVE, DELETED +} diff --git a/server/src/main/java/com/fluffy/reaction/domain/ReactionType.java b/server/src/main/java/com/fluffy/reaction/domain/ReactionType.java new file mode 100644 index 0000000..9a2f400 --- /dev/null +++ b/server/src/main/java/com/fluffy/reaction/domain/ReactionType.java @@ -0,0 +1,6 @@ +package com.fluffy.reaction.domain; + +public enum ReactionType { + + LIKE +} diff --git a/server/src/main/resources/db/migration/V5__add_reaction_table.sql b/server/src/main/resources/db/migration/V5__add_reaction_table.sql new file mode 100644 index 0000000..c087e0e --- /dev/null +++ b/server/src/main/resources/db/migration/V5__add_reaction_table.sql @@ -0,0 +1,18 @@ +CREATE TABLE reaction +( + id BIGINT GENERATED BY DEFAULT AS IDENTITY, + targetType VARCHAR(20) NOT NULL, + targetId BIGINT NOT NULL, + memberId BIGINT NOT NULL, + type VARCHAR(20) NOT NULL, + status VARCHAR(20) NOT NULL, + created_at TIMESTAMP(6) NOT NULL, + updated_at TIMESTAMP(6) NOT NULL, + PRIMARY KEY (id) +); + +ALTER TABLE reaction + ADD CONSTRAINT fk_member FOREIGN KEY (memberId) REFERENCES member (id); + +ALTER TABLE reaction + ADD CONSTRAINT unique_reaction UNIQUE (targetType, targetId, memberId, type); diff --git a/server/src/test/java/com/fluffy/exam/api/ExamLikeDocumentTest.java b/server/src/test/java/com/fluffy/exam/api/ExamLikeDocumentTest.java new file mode 100644 index 0000000..3e07199 --- /dev/null +++ b/server/src/test/java/com/fluffy/exam/api/ExamLikeDocumentTest.java @@ -0,0 +1,42 @@ +package com.fluffy.exam.api; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fluffy.support.AbstractDocumentTest; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class ExamLikeDocumentTest extends AbstractDocumentTest { + + @Test + @DisplayName("시험에 좋아요를 할 수 있다.") + void like() throws Exception { + mockMvc.perform(post("/api/v1/exams/{examId}/like", 1) + .cookie(new Cookie("accessToken", "{ACCESS_TOKEN}"))) + .andExpect(status().isOk()) + .andDo(restDocs.document( + pathParameters( + parameterWithName("examId").description("시험 ID") + ) + )); + } + + @Test + @DisplayName("시험에 좋아요를 취소할 수 있다.") + void unlike() throws Exception { + mockMvc.perform(post("/api/v1/exams/{examId}/like", 1) + .cookie(new Cookie("accessToken", "{ACCESS_TOKEN}"))) + .andExpect(status().isOk()) + .andDo(restDocs.document( + pathParameters( + parameterWithName("examId").description("시험 ID") + ) + )); + } +} diff --git a/server/src/test/java/com/fluffy/reaction/domain/ReactionTest.java b/server/src/test/java/com/fluffy/reaction/domain/ReactionTest.java new file mode 100644 index 0000000..b99120c --- /dev/null +++ b/server/src/test/java/com/fluffy/reaction/domain/ReactionTest.java @@ -0,0 +1,45 @@ +package com.fluffy.reaction.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class ReactionTest { + + @Test + @DisplayName("반응을 정상적으로 생성할 수 있다.") + void create() { + // when & then + assertThatCode(() -> new Reaction("EXAM", 1L, 1L, ReactionType.LIKE)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("반응을 삭제 상태로 변경할 수 있다.") + void delete() { + // given + Reaction reaction = new Reaction("EXAM", 1L, 1L, ReactionType.LIKE); + + // when + reaction.delete(); + + // then + assertThat(reaction.getStatus()).isEqualTo(ReactionStatus.DELETED); + } + + @Test + @DisplayName("삭제 상태의 반응을 활성 상태로 변경할 수 있다.") + void active() { + // given + Reaction reaction = new Reaction("EXAM", 1L, 1L, ReactionType.LIKE); + reaction.delete(); + + // when + reaction.active(); + + // then + assertThat(reaction.getStatus()).isEqualTo(ReactionStatus.ACTIVE); + } +} diff --git a/server/src/test/java/com/fluffy/support/AbstractControllerTest.java b/server/src/test/java/com/fluffy/support/AbstractControllerTest.java index 2197c6b..1c6bd76 100644 --- a/server/src/test/java/com/fluffy/support/AbstractControllerTest.java +++ b/server/src/test/java/com/fluffy/support/AbstractControllerTest.java @@ -7,6 +7,8 @@ import com.fluffy.auth.api.AuthController; import com.fluffy.auth.application.AuthService; import com.fluffy.exam.api.ExamController; +import com.fluffy.exam.api.ExamLikeController; +import com.fluffy.exam.application.ExamLikeService; import com.fluffy.exam.application.ExamQueryService; import com.fluffy.exam.application.ExamService; import com.fluffy.global.web.Accessor; @@ -27,6 +29,7 @@ @WebMvcTest({ AuthController.class, ExamController.class, + ExamLikeController.class, OAuth2Controller.class, SubmissionController.class, }) @@ -50,6 +53,9 @@ public abstract class AbstractControllerTest { @MockBean protected ExamService examService; + @MockBean + protected ExamLikeService examLikeService; + @MockBean protected ExamQueryService examQueryService; diff --git a/web/src/components/exams/MoveQuestionButtonGroup.tsx b/web/src/components/exams/MoveQuestionButtonGroup.tsx index 7839b97..167275e 100644 --- a/web/src/components/exams/MoveQuestionButtonGroup.tsx +++ b/web/src/components/exams/MoveQuestionButtonGroup.tsx @@ -19,7 +19,7 @@ const MoveQuestionButtonGroup = () => { const params = useParams() as { examId: string }; const examId = Number(params.examId); const navigate = useNavigate(); - const { mutate } = useCreateSubmission(); + const { mutate, isPending } = useCreateSubmission(); const { questionResponses } = useSubmissionStore(); const handleSubmit = () => { @@ -77,7 +77,7 @@ const MoveQuestionButtonGroup = () => { - diff --git a/web/src/components/exams/NewExamButton.tsx b/web/src/components/exams/NewExamButton.tsx index a11d7b3..c8d33ca 100644 --- a/web/src/components/exams/NewExamButton.tsx +++ b/web/src/components/exams/NewExamButton.tsx @@ -17,7 +17,7 @@ import { FaPlusCircle } from 'react-icons/fa'; const NewExamButton = () => { const { isOpen, onOpen, onOpenChange, onClose } = useDisclosure(); const [title, setTitle] = useState(''); - const { mutate } = useCreateExam(); + const { mutate, isPending } = useCreateExam(); const navigate = useNavigate(); const handleCreateNewExam = async () => { @@ -59,7 +59,7 @@ const NewExamButton = () => { - diff --git a/web/src/components/overview/ExamPublishButton.tsx b/web/src/components/overview/ExamPublishButton.tsx index 36c47af..facec29 100644 --- a/web/src/components/overview/ExamPublishButton.tsx +++ b/web/src/components/overview/ExamPublishButton.tsx @@ -19,7 +19,7 @@ interface ExamPublishButtonProps { const ExamPublishButton = ({ examId }: ExamPublishButtonProps) => { const { isOpen, onClose, onOpen } = useDisclosure(); const { questions } = useExamEditorStore(); - const { mutate: publishExamMutate } = usePublishExam(); + const { mutate: publishExamMutate, isPending } = usePublishExam(); const navigate = useNavigate(); const handlePublishExam = () => { @@ -59,7 +59,7 @@ const ExamPublishButton = ({ examId }: ExamPublishButtonProps) => { - diff --git a/web/src/components/questions/ExamManagementQuestionsPanel.tsx b/web/src/components/questions/ExamManagementQuestionsPanel.tsx index ea02204..a601a2c 100644 --- a/web/src/components/questions/ExamManagementQuestionsPanel.tsx +++ b/web/src/components/questions/ExamManagementQuestionsPanel.tsx @@ -30,7 +30,7 @@ const ExamManagementQuestionsPanel = ({ const ExamEditorPanel = ({ examId }: { examId: number }) => { const { data } = useGetExamWithAnswers(examId); const { questionTypeSelectorActive, questions } = useExamEditorStore(); - const { mutate } = useUpdateExamQuestions(); + const { mutate, isPending } = useUpdateExamQuestions(); const handleUpdateQuestions = () => { mutate({ @@ -50,6 +50,7 @@ const ExamEditorPanel = ({ examId }: { examId: number }) => { variant="shadow" onPress={handleUpdateQuestions} className="text-white" + isLoading={isPending} > 임시 저장