diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/api/CommonMemberController.java b/src/main/java/com/gdschongik/gdsc/domain/member/api/CommonMemberController.java index c35303519..62495f14a 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/member/api/CommonMemberController.java +++ b/src/main/java/com/gdschongik/gdsc/domain/member/api/CommonMemberController.java @@ -1,6 +1,7 @@ package com.gdschongik.gdsc.domain.member.api; import com.gdschongik.gdsc.domain.member.application.CommonMemberService; +import com.gdschongik.gdsc.domain.member.dto.response.MemberAccountInfoResponse; import com.gdschongik.gdsc.domain.member.dto.response.MemberDepartmentResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -34,4 +35,11 @@ public ResponseEntity> searchDepartments( List response = commonMemberService.searchDepartments(departmentName); return ResponseEntity.ok().body(response); } + + @Operation(summary = "내 계정 정보 조회", description = "내 계정 정보를 조회합니다.") + @GetMapping("/me/account-info") + public ResponseEntity getAccountInfo() { + MemberAccountInfoResponse response = commonMemberService.getAccountInfo(); + return ResponseEntity.ok().body(response); + } } diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/application/CommonMemberService.java b/src/main/java/com/gdschongik/gdsc/domain/member/application/CommonMemberService.java index 3ae725a12..fb19ae07c 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/member/application/CommonMemberService.java +++ b/src/main/java/com/gdschongik/gdsc/domain/member/application/CommonMemberService.java @@ -5,10 +5,13 @@ import com.gdschongik.gdsc.domain.member.dao.MemberRepository; import com.gdschongik.gdsc.domain.member.domain.Department; import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.member.dto.response.MemberAccountInfoResponse; import com.gdschongik.gdsc.domain.member.dto.response.MemberDepartmentResponse; import com.gdschongik.gdsc.domain.membership.dao.MembershipRepository; import com.gdschongik.gdsc.domain.membership.domain.Membership; import com.gdschongik.gdsc.global.exception.CustomException; +import com.gdschongik.gdsc.global.util.MemberUtil; +import com.gdschongik.gdsc.infra.github.client.GithubClient; import java.util.Arrays; import java.util.List; import lombok.RequiredArgsConstructor; @@ -23,6 +26,8 @@ public class CommonMemberService { private final MembershipRepository membershipRepository; private final MemberRepository memberRepository; + private final MemberUtil memberUtil; + private final GithubClient githubClient; @Transactional(readOnly = true) public List getDepartments() { @@ -81,4 +86,11 @@ public void demoteMemberToAssociateByMembership(Long membershipId) { log.info("[CommonMemberService] 준회원 강등 완료: memberId={}", member.getId()); } + + @Transactional(readOnly = true) + public MemberAccountInfoResponse getAccountInfo() { + Member currentMember = memberUtil.getCurrentMember(); + String githubHandle = githubClient.getGithubHandle(currentMember.getOauthId()); + return MemberAccountInfoResponse.of(currentMember, githubHandle); + } } diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/domain/Member.java b/src/main/java/com/gdschongik/gdsc/domain/member/domain/Member.java index 2270544d3..e2c07099b 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/member/domain/Member.java +++ b/src/main/java/com/gdschongik/gdsc/domain/member/domain/Member.java @@ -345,4 +345,16 @@ public boolean isAssociate() { public boolean isRegular() { return role.equals(REGULAR); } + + public boolean isAdmin() { + return manageRole.equals(ADMIN); + } + + public boolean isMentor() { + return studyRole.equals(MENTOR); + } + + public boolean isStudent() { + return studyRole.equals(STUDENT); + } } diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/dto/response/MemberAccountInfoResponse.java b/src/main/java/com/gdschongik/gdsc/domain/member/dto/response/MemberAccountInfoResponse.java new file mode 100644 index 000000000..285c44634 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/member/dto/response/MemberAccountInfoResponse.java @@ -0,0 +1,9 @@ +package com.gdschongik.gdsc.domain.member.dto.response; + +import com.gdschongik.gdsc.domain.member.domain.Member; + +public record MemberAccountInfoResponse(String name, String githubHandle) { + public static MemberAccountInfoResponse of(Member member, String githubHandle) { + return new MemberAccountInfoResponse(member.getName(), githubHandle); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/api/AdminStudyController.java b/src/main/java/com/gdschongik/gdsc/domain/study/api/AdminStudyController.java index 730bc7ffd..70a2b0438 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/study/api/AdminStudyController.java +++ b/src/main/java/com/gdschongik/gdsc/domain/study/api/AdminStudyController.java @@ -2,11 +2,14 @@ import com.gdschongik.gdsc.domain.study.application.AdminStudyService; import com.gdschongik.gdsc.domain.study.dto.request.StudyCreateRequest; +import com.gdschongik.gdsc.domain.study.dto.response.StudyResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -26,4 +29,11 @@ public ResponseEntity createStudy(@Valid @RequestBody StudyCreateRequest r adminStudyService.createStudyAndStudyDetail(request); return ResponseEntity.ok().build(); } + + @Operation(summary = "전체 스터디 조회", description = "모든 스터디를 조회합니다. 코어멤버만 접근 가능합니다.") + @GetMapping + public ResponseEntity> getStudies() { + List response = adminStudyService.getAllStudies(); + return ResponseEntity.ok(response); + } } diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/api/CommonStudyController.java b/src/main/java/com/gdschongik/gdsc/domain/study/api/CommonStudyController.java new file mode 100644 index 000000000..43c1b0ccd --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/api/CommonStudyController.java @@ -0,0 +1,37 @@ +package com.gdschongik.gdsc.domain.study.api; + +import com.gdschongik.gdsc.domain.study.application.CommonStudyService; +import com.gdschongik.gdsc.domain.study.dto.response.CommonStudyResponse; +import com.gdschongik.gdsc.domain.study.dto.response.StudyAnnouncementResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Common Study", description = "공통 스터디 API입니다.") +@RestController +@RequestMapping("/common/studies") +@RequiredArgsConstructor +public class CommonStudyController { + + private final CommonStudyService commonStudyService; + + @Operation(summary = "스터디 기본 정보 조회", description = "스터디 기본 정보를 조회합니다.") + @GetMapping("/{studyId}") + public ResponseEntity getStudyInformation(@PathVariable Long studyId) { + CommonStudyResponse response = commonStudyService.getStudyInformation(studyId); + return ResponseEntity.ok(response); + } + + @Operation(summary = "스터디 공지 목록 조회", description = "스터디 공지 목록을 조회합니다.") + @GetMapping("/{studyId}/announcements") + public ResponseEntity> getStudyAnnouncements(@PathVariable Long studyId) { + List response = commonStudyService.getStudyAnnouncements(studyId); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/api/MentorStudyController.java b/src/main/java/com/gdschongik/gdsc/domain/study/api/MentorStudyController.java new file mode 100644 index 000000000..69a615e9d --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/api/MentorStudyController.java @@ -0,0 +1,67 @@ +package com.gdschongik.gdsc.domain.study.api; + +import com.gdschongik.gdsc.domain.study.application.MentorStudyService; +import com.gdschongik.gdsc.domain.study.dto.request.StudyAnnouncementCreateUpdateRequest; +import com.gdschongik.gdsc.domain.study.dto.request.StudyUpdateRequest; +import com.gdschongik.gdsc.domain.study.dto.response.MentorStudyResponse; +import com.gdschongik.gdsc.domain.study.dto.response.StudyStudentResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "Mentor Study", description = "멘토 스터디 API입니다.") +@RestController +@RequestMapping("/mentor/studies") +@RequiredArgsConstructor +public class MentorStudyController { + + private final MentorStudyService mentorStudyService; + + @Operation(summary = "스터디 정보 작성", description = "스터디 기본 정보와 상세 정보를 작성합니다.") + @PatchMapping("/{studyId}") + public ResponseEntity updateStudy(@PathVariable Long studyId, @RequestBody StudyUpdateRequest request) { + mentorStudyService.updateStudy(studyId, request); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "내 스터디 조회", description = "내가 멘토로 있는 스터디를 조회합니다.") + @GetMapping("/me") + public ResponseEntity> getStudiesInCharge() { + List response = mentorStudyService.getStudiesInCharge(); + return ResponseEntity.ok(response); + } + + @Operation(summary = "스터디 수강생 명단 조회", description = "해당 스터디의 수강생 명단을 조회합니다") + @GetMapping("/{studyId}/students") + public ResponseEntity> getStudyStudents(@PathVariable Long studyId) { + List response = mentorStudyService.getStudyStudents(studyId); + return ResponseEntity.ok(response); + } + + @Operation(summary = "스터디 공지 생성", description = "스터디의 공지사항을 생성합니다.") + @PostMapping("/{studyId}/announcements") + public ResponseEntity createStudyAnnouncement( + @PathVariable Long studyId, @Valid @RequestBody StudyAnnouncementCreateUpdateRequest request) { + mentorStudyService.createStudyAnnouncement(studyId, request); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "스터디 공지 수정", description = "스터디의 공지사항을 수정합니다.") + @PutMapping("/announcements/{studyAnnouncementId}") + public ResponseEntity updateStudyAnnouncement( + @PathVariable Long studyAnnouncementId, @Valid @RequestBody StudyAnnouncementCreateUpdateRequest request) { + mentorStudyService.updateStudyAnnouncement(studyAnnouncementId, request); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "스터디 공지 삭제", description = "스터디의 공지사항을 삭제합니다.") + @DeleteMapping("/announcements/{studyAnnouncementId}") + public ResponseEntity deleteStudyAnnouncement(@PathVariable Long studyAnnouncementId) { + mentorStudyService.deleteStudyAnnouncement(studyAnnouncementId); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/api/MentorStudyDetailController.java b/src/main/java/com/gdschongik/gdsc/domain/study/api/MentorStudyDetailController.java index e74145482..bc74e9a7b 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/study/api/MentorStudyDetailController.java +++ b/src/main/java/com/gdschongik/gdsc/domain/study/api/MentorStudyDetailController.java @@ -3,6 +3,7 @@ import com.gdschongik.gdsc.domain.study.application.MentorStudyDetailService; import com.gdschongik.gdsc.domain.study.dto.request.AssignmentCreateUpdateRequest; import com.gdschongik.gdsc.domain.study.dto.response.AssignmentResponse; +import com.gdschongik.gdsc.domain.study.dto.response.StudySessionResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @@ -62,4 +63,12 @@ public ResponseEntity cancelStudyAssignment(@PathVariable Long studyDetail mentorStudyDetailService.cancelStudyAssignment(studyDetailId); return ResponseEntity.noContent().build(); } + + // TODO 스터디 세션 워딩을 커리큘럼으로 변경해야함 + @Operation(summary = "스터디 주차별 커리큘럼 목록 조회", description = "멘토가 자신의 스터디 커리큘럼 목록을 조회합니다") + @GetMapping("/sessions") + public ResponseEntity> getStudySessions(@RequestParam(name = "study") Long studyId) { + List response = mentorStudyDetailService.getSessions(studyId); + return ResponseEntity.ok(response); + } } diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/api/StudentStudyController.java b/src/main/java/com/gdschongik/gdsc/domain/study/api/StudentStudyController.java new file mode 100644 index 000000000..0ddff5715 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/api/StudentStudyController.java @@ -0,0 +1,63 @@ +package com.gdschongik.gdsc.domain.study.api; + +import com.gdschongik.gdsc.domain.study.application.StudentStudyService; +import com.gdschongik.gdsc.domain.study.dto.request.StudyAttendCreateRequest; +import com.gdschongik.gdsc.domain.study.dto.response.StudentMyCurrentStudyResponse; +import com.gdschongik.gdsc.domain.study.dto.response.StudyApplicableResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Student Study", description = "사용자 스터디 API입니다.") +@RestController +@RequestMapping("/studies") +@RequiredArgsConstructor +public class StudentStudyController { + + private final StudentStudyService studentStudyService; + + @Operation(summary = "신청 가능한 스터디 조회", description = "모집 기간 중에 있는 스터디를 조회합니다.") + @GetMapping("/apply") + public ResponseEntity getAllApplicableStudies() { + StudyApplicableResponse response = studentStudyService.getAllApplicableStudies(); + return ResponseEntity.ok().body(response); + } + + @Operation(summary = "스터디 수강신청", description = "스터디에 수강신청 합니다. 모집 기간 중이어야 하고, 이미 수강 중인 스터디가 없어야 합니다.") + @PostMapping("/apply/{studyId}") + public ResponseEntity applyStudy(@PathVariable Long studyId) { + studentStudyService.applyStudy(studyId); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "스터디 수강신청 취소", description = "수강신청을 취소합니다. 스터디 수강신청 기간 중에만 취소할 수 있습니다.") + @DeleteMapping("/apply/{studyId}") + public ResponseEntity cancelStudyApply(@PathVariable Long studyId) { + studentStudyService.cancelStudyApply(studyId); + return ResponseEntity.noContent().build(); + } + + @Operation(summary = "스터디 출석체크", description = "스터디에 출석체크합니다. 현재 진행중인 스터디 회차에 출석체크해야 하며, 중복출석체크할 수 없습니다.") + @PostMapping("/study-details/{studyDetailId}/attend") + public ResponseEntity attend( + @PathVariable Long studyDetailId, @Valid @RequestBody StudyAttendCreateRequest request) { + studentStudyService.attend(studyDetailId, request); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "내 수강중인 스터디 조회", description = "나의 수강 중인 스터디를 조회합니다.") + @GetMapping("/me/ongoing") + public ResponseEntity getMyCurrentStudy() { + StudentMyCurrentStudyResponse response = studentStudyService.getMyCurrentStudy(); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/api/StudentStudyDetailController.java b/src/main/java/com/gdschongik/gdsc/domain/study/api/StudentStudyDetailController.java new file mode 100644 index 000000000..ccc11013c --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/api/StudentStudyDetailController.java @@ -0,0 +1,57 @@ +package com.gdschongik.gdsc.domain.study.api; + +import com.gdschongik.gdsc.domain.study.application.StudentStudyDetailService; +import com.gdschongik.gdsc.domain.study.dto.response.AssignmentDashboardResponse; +import com.gdschongik.gdsc.domain.study.dto.response.AssignmentHistoryStatusResponse; +import com.gdschongik.gdsc.domain.study.dto.response.StudyStudentSessionResponse; +import com.gdschongik.gdsc.domain.study.dto.response.StudyTodoResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Student Study Detail", description = "수강자 스터디 상세 API입니다.") +@RestController +@RequestMapping("/study-details") +@RequiredArgsConstructor +public class StudentStudyDetailController { + + private final StudentStudyDetailService studentStudyDetailService; + + @Operation(summary = "내 제출 가능한 과제 조회", description = "나의 제출 가능한 과제를 조회합니다.") + @GetMapping("/assignments/dashboard") + public ResponseEntity getSubmittableAssignments( + @RequestParam(name = "studyId") Long studyId) { + AssignmentDashboardResponse response = studentStudyDetailService.getSubmittableAssignments(studyId); + return ResponseEntity.ok(response); + } + + @Operation(summary = "내 할일 리스트 조회", description = "해당 스터디의 내 할일 리스트를 조회합니다") + @GetMapping("/todo") + public ResponseEntity> getStudyTodoList(@RequestParam(name = "studyId") Long studyId) { + List response = studentStudyDetailService.getStudyTodoList(studyId); + return ResponseEntity.ok(response); + } + + // TODO 스터디 세션 워딩을 커리큘럼으로 변경해야함 + @Operation(summary = "스터디 커리큘럼 조회", description = "해당 스터디의 커리큘럼들을 조회합니다.") + @GetMapping("/sessions") + public ResponseEntity> getStudySessions( + @RequestParam(name = "studyId") Long studyId) { + List response = studentStudyDetailService.getStudySessions(studyId); + return ResponseEntity.ok(response); + } + + @Operation(summary = "이번주 제출해야 할 과제 조회", description = "마감 기한이 이번주까지인 과제를 조회합니다.") + @GetMapping("/assignments/upcoming") + public ResponseEntity> getUpcomingAssignments( + @RequestParam(name = "studyId") Long studyId) { + List response = studentStudyDetailService.getUpcomingAssignments(studyId); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/api/StudentStudyHistoryController.java b/src/main/java/com/gdschongik/gdsc/domain/study/api/StudentStudyHistoryController.java new file mode 100644 index 000000000..48faedcdf --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/api/StudentStudyHistoryController.java @@ -0,0 +1,45 @@ +package com.gdschongik.gdsc.domain.study.api; + +import com.gdschongik.gdsc.domain.study.application.StudentStudyHistoryService; +import com.gdschongik.gdsc.domain.study.dto.request.RepositoryUpdateRequest; +import com.gdschongik.gdsc.domain.study.dto.response.AssignmentHistoryResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.io.IOException; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "Student Study History", description = "사용자 스터디 수강 이력 API입니다.") +@RestController +@RequestMapping("/study-history") +@RequiredArgsConstructor +public class StudentStudyHistoryController { + + private final StudentStudyHistoryService studentStudyHistoryService; + + @Operation(summary = "레포지토리 입력", description = "레포지토리를 입력합니다. 이미 제출한 과제가 있다면 수정할 수 없습니다.") + @PutMapping("/{studyHistoryId}/repository") + public ResponseEntity updateRepository( + @PathVariable Long studyHistoryId, @Valid @RequestBody RepositoryUpdateRequest request) throws IOException { + studentStudyHistoryService.updateRepository(studyHistoryId, request); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "스터디 과제 히스토리 목록 조회", description = "스터디 과제 제출 내역을 조회합니다.") + @GetMapping("/assignments") + public ResponseEntity> getAllAssignmentHistories( + @RequestParam(name = "studyId") Long studyId) { + List response = studentStudyHistoryService.getAllAssignmentHistories(studyId); + return ResponseEntity.ok(response); + } + + @Operation(summary = "과제 제출하기", description = "과제를 제출합니다. 제출된 과제는 채점되어 제출내역에 반영됩니다.") + @PostMapping("/submit") + public ResponseEntity submitAssignment(@RequestParam(name = "studyDetailId") Long studyDetailId) { + studentStudyHistoryService.submitAssignment(studyDetailId); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/api/StudyController.java b/src/main/java/com/gdschongik/gdsc/domain/study/api/StudyController.java deleted file mode 100644 index 79e46d71d..000000000 --- a/src/main/java/com/gdschongik/gdsc/domain/study/api/StudyController.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.gdschongik.gdsc.domain.study.api; - -import com.gdschongik.gdsc.domain.study.application.StudyService; -import com.gdschongik.gdsc.domain.study.dto.response.StudyResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import java.util.List; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@Tag(name = "Study", description = "사용자 스터디 API입니다.") -@RestController -@RequestMapping("/studies") -@RequiredArgsConstructor -public class StudyController { - - private final StudyService studyService; - - @Operation(summary = "신청 가능한 스터디 조회", description = "모집 기간 중에 있는 스터디를 조회합니다.") - @GetMapping("/apply") - public ResponseEntity> getAllApplicableStudies() { - List response = studyService.getAllApplicableStudies(); - return ResponseEntity.ok().body(response); - } - - @Operation(summary = "스터디 수강신청", description = "스터디에 수강신청 합니다. 모집 기간 중이어야 하고, 이미 수강 중인 스터디가 없어야 합니다.") - @PostMapping("/apply/{studyId}") - public ResponseEntity applyStudy(@PathVariable Long studyId) { - studyService.applyStudy(studyId); - return ResponseEntity.ok().build(); - } - - @Operation(summary = "스터디 수강신청 취소", description = "수강신청을 취소합니다. 스터디 수강신청 기간 중에만 취소할 수 있습니다.") - @DeleteMapping("/apply/{studyId}") - public ResponseEntity cancelStudyApply(@PathVariable Long studyId) { - studyService.cancelStudyApply(studyId); - return ResponseEntity.noContent().build(); - } -} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/application/AdminStudyService.java b/src/main/java/com/gdschongik/gdsc/domain/study/application/AdminStudyService.java index 76b9e01e3..91143fa72 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/study/application/AdminStudyService.java +++ b/src/main/java/com/gdschongik/gdsc/domain/study/application/AdminStudyService.java @@ -7,6 +7,7 @@ import com.gdschongik.gdsc.domain.study.domain.Study; import com.gdschongik.gdsc.domain.study.domain.StudyDetail; import com.gdschongik.gdsc.domain.study.dto.request.StudyCreateRequest; +import com.gdschongik.gdsc.domain.study.dto.response.StudyResponse; import com.gdschongik.gdsc.domain.study.factory.StudyDomainFactory; import com.gdschongik.gdsc.global.exception.CustomException; import com.gdschongik.gdsc.global.exception.ErrorCode; @@ -20,7 +21,6 @@ @Slf4j @Service @RequiredArgsConstructor -@Transactional(readOnly = true) public class AdminStudyService { private final StudyRepository studyRepository; @@ -55,4 +55,9 @@ private List createNoneStudyDetail(Study study) { private Member getMemberById(Long memberId) { return memberRepository.findById(memberId).orElseThrow(() -> new CustomException(ErrorCode.MEMBER_NOT_FOUND)); } + + @Transactional(readOnly = true) + public List getAllStudies() { + return studyRepository.findAll().stream().map(StudyResponse::from).toList(); + } } diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/application/CommonStudyService.java b/src/main/java/com/gdschongik/gdsc/domain/study/application/CommonStudyService.java new file mode 100644 index 000000000..6bcf2aae9 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/application/CommonStudyService.java @@ -0,0 +1,54 @@ +package com.gdschongik.gdsc.domain.study.application; + +import static com.gdschongik.gdsc.global.exception.ErrorCode.STUDY_NOT_FOUND; + +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.study.dao.StudyAnnouncementRepository; +import com.gdschongik.gdsc.domain.study.dao.StudyHistoryRepository; +import com.gdschongik.gdsc.domain.study.dao.StudyRepository; +import com.gdschongik.gdsc.domain.study.domain.Study; +import com.gdschongik.gdsc.domain.study.domain.StudyAnnouncement; +import com.gdschongik.gdsc.domain.study.domain.StudyHistory; +import com.gdschongik.gdsc.domain.study.domain.StudyValidator; +import com.gdschongik.gdsc.domain.study.dto.response.CommonStudyResponse; +import com.gdschongik.gdsc.domain.study.dto.response.StudyAnnouncementResponse; +import com.gdschongik.gdsc.global.exception.CustomException; +import com.gdschongik.gdsc.global.util.MemberUtil; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class CommonStudyService { + + private final StudyRepository studyRepository; + private final StudyHistoryRepository studyHistoryRepository; + private final StudyAnnouncementRepository studyAnnouncementRepository; + private final MemberUtil memberUtil; + private final StudyValidator studyValidator; + + @Transactional(readOnly = true) + public CommonStudyResponse getStudyInformation(Long studyId) { + Study study = studyRepository.findById(studyId).orElseThrow(() -> new CustomException(STUDY_NOT_FOUND)); + return CommonStudyResponse.from(study); + } + + @Transactional(readOnly = true) + public List getStudyAnnouncements(Long studyId) { + Member currentMember = memberUtil.getCurrentMember(); + final Study study = studyRepository.findById(studyId).orElseThrow(() -> new CustomException(STUDY_NOT_FOUND)); + Optional studyHistory = studyHistoryRepository.findByStudentAndStudyId(currentMember, studyId); + + studyValidator.validateStudyMentorOrStudent(currentMember, study, studyHistory); + + final List studyAnnouncements = + studyAnnouncementRepository.findAllByStudyIdOrderByCreatedAtDesc(studyId); + + return studyAnnouncements.stream().map(StudyAnnouncementResponse::from).toList(); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/application/MentorStudyDetailService.java b/src/main/java/com/gdschongik/gdsc/domain/study/application/MentorStudyDetailService.java index a6c3c5485..b6bb9dbb5 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/study/application/MentorStudyDetailService.java +++ b/src/main/java/com/gdschongik/gdsc/domain/study/application/MentorStudyDetailService.java @@ -8,6 +8,7 @@ import com.gdschongik.gdsc.domain.study.domain.StudyDetailValidator; import com.gdschongik.gdsc.domain.study.dto.request.AssignmentCreateUpdateRequest; import com.gdschongik.gdsc.domain.study.dto.response.AssignmentResponse; +import com.gdschongik.gdsc.domain.study.dto.response.StudySessionResponse; import com.gdschongik.gdsc.global.exception.CustomException; import com.gdschongik.gdsc.global.util.MemberUtil; import java.util.List; @@ -27,7 +28,7 @@ public class MentorStudyDetailService { @Transactional(readOnly = true) public List getWeeklyAssignments(Long studyId) { - List studyDetails = studyDetailRepository.findAllByStudyId(studyId); + List studyDetails = studyDetailRepository.findAllByStudyIdOrderByWeekAsc(studyId); return studyDetails.stream().map(AssignmentResponse::from).toList(); } @@ -83,4 +84,10 @@ public void updateStudyAssignment(Long studyDetailId, AssignmentCreateUpdateRequ log.info("[MentorStudyDetailService] 과제 수정 완료: studyDetailId={}", studyDetailId); } + + @Transactional(readOnly = true) + public List getSessions(Long studyId) { + List studyDetails = studyDetailRepository.findAllByStudyIdOrderByWeekAsc(studyId); + return studyDetails.stream().map(StudySessionResponse::from).toList(); + } } diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/application/MentorStudyService.java b/src/main/java/com/gdschongik/gdsc/domain/study/application/MentorStudyService.java new file mode 100644 index 000000000..e4204e2b4 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/application/MentorStudyService.java @@ -0,0 +1,148 @@ +package com.gdschongik.gdsc.domain.study.application; + +import static com.gdschongik.gdsc.global.exception.ErrorCode.STUDY_NOT_FOUND; + +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.study.dao.StudyAnnouncementRepository; +import com.gdschongik.gdsc.domain.study.dao.StudyDetailRepository; +import com.gdschongik.gdsc.domain.study.dao.StudyHistoryRepository; +import com.gdschongik.gdsc.domain.study.dao.StudyRepository; +import com.gdschongik.gdsc.domain.study.domain.*; +import com.gdschongik.gdsc.domain.study.domain.Study; +import com.gdschongik.gdsc.domain.study.domain.StudyAnnouncement; +import com.gdschongik.gdsc.domain.study.domain.StudyHistory; +import com.gdschongik.gdsc.domain.study.domain.StudyValidator; +import com.gdschongik.gdsc.domain.study.dto.request.StudyAnnouncementCreateUpdateRequest; +import com.gdschongik.gdsc.domain.study.dto.request.StudySessionCreateRequest; +import com.gdschongik.gdsc.domain.study.dto.request.StudyUpdateRequest; +import com.gdschongik.gdsc.domain.study.dto.response.MentorStudyResponse; +import com.gdschongik.gdsc.domain.study.dto.response.StudyStudentResponse; +import com.gdschongik.gdsc.global.exception.CustomException; +import com.gdschongik.gdsc.global.exception.ErrorCode; +import com.gdschongik.gdsc.global.util.MemberUtil; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class MentorStudyService { + + private final MemberUtil memberUtil; + private final StudyRepository studyRepository; + private final StudyAnnouncementRepository studyAnnouncementRepository; + private final StudyHistoryRepository studyHistoryRepository; + private final StudyValidator studyValidator; + private final StudyDetailRepository studyDetailRepository; + private final StudyDetailValidator studyDetailValidator; + + @Transactional(readOnly = true) + public List getStudiesInCharge() { + Member currentMember = memberUtil.getCurrentMember(); + List myStudies = studyRepository.findAllByMentor(currentMember); + return myStudies.stream().map(MentorStudyResponse::from).toList(); + } + + @Transactional(readOnly = true) + public List getStudyStudents(Long studyId) { + Member currentMember = memberUtil.getCurrentMember(); + Study study = + studyRepository.findById(studyId).orElseThrow(() -> new CustomException(ErrorCode.STUDY_NOT_FOUND)); + + studyValidator.validateStudyMentor(currentMember, study); + List studyHistories = studyHistoryRepository.findByStudyId(studyId); + + return studyHistories.stream().map(StudyStudentResponse::from).toList(); + } + + @Transactional + public void createStudyAnnouncement(Long studyId, StudyAnnouncementCreateUpdateRequest request) { + Member currentMember = memberUtil.getCurrentMember(); + final Study study = studyRepository.getById(studyId); + + studyValidator.validateStudyMentor(currentMember, study); + + StudyAnnouncement studyAnnouncement = + StudyAnnouncement.createStudyAnnouncement(study, request.title(), request.link()); + studyAnnouncementRepository.save(studyAnnouncement); + + log.info("[MentorStudyService] 스터디 공지 생성: studyAnnouncementId={}", studyAnnouncement.getId()); + } + + @Transactional + public void updateStudyAnnouncement(Long studyAnnouncementId, StudyAnnouncementCreateUpdateRequest request) { + Member currentMember = memberUtil.getCurrentMember(); + final StudyAnnouncement studyAnnouncement = studyAnnouncementRepository.getById(studyAnnouncementId); + Study study = studyAnnouncement.getStudy(); + + studyValidator.validateStudyMentor(currentMember, study); + + studyAnnouncement.update(request.title(), request.link()); + studyAnnouncementRepository.save(studyAnnouncement); + + log.info("[MentorStudyService] 스터디 공지 수정 완료: studyAnnouncementId={}", studyAnnouncement.getId()); + } + + @Transactional + public void deleteStudyAnnouncement(Long studyAnnouncementId) { + Member currentMember = memberUtil.getCurrentMember(); + final StudyAnnouncement studyAnnouncement = studyAnnouncementRepository.getById(studyAnnouncementId); + Study study = studyAnnouncement.getStudy(); + + studyValidator.validateStudyMentor(currentMember, study); + + studyAnnouncementRepository.delete(studyAnnouncement); + + log.info("[MentorStudyService] 스터디 공지 삭제 완료: studyAnnouncementId={}", studyAnnouncement.getId()); + } + + // TODO session -> curriculum 변경 + @Transactional + public void updateStudy(Long studyId, StudyUpdateRequest request) { + Member currentMember = memberUtil.getCurrentMember(); + Study study = studyRepository.findById(studyId).orElseThrow(() -> new CustomException(STUDY_NOT_FOUND)); + studyValidator.validateStudyMentor(currentMember, study); + + List studyDetails = studyDetailRepository.findAllByStudyIdOrderByWeekAsc(studyId); + // StudyDetail ID를 추출하여 Set으로 저장 + Set studyDetailIds = studyDetails.stream().map(StudyDetail::getId).collect(Collectors.toSet()); + + // 요청된 StudySessionCreateRequest의 StudyDetail ID를 추출하여 Set으로 저장 + Set requestIds = request.studySessions().stream() + .map(StudySessionCreateRequest::studyDetailId) + .collect(Collectors.toSet()); + + studyDetailValidator.validateUpdateStudyDetail(studyDetailIds, requestIds); + + study.update(request.notionLink(), request.introduction()); + studyRepository.save(study); + log.info("[MentorStudyService] 스터디 기본 정보 수정 완료: studyId={}", studyId); + + updateAllStudyDetailSession(studyDetails, request.studySessions()); + } + + private void updateAllStudyDetailSession( + List studyDetails, List studySessions) { + for (StudyDetail studyDetail : studyDetails) { + Long id = studyDetail.getId(); + StudySessionCreateRequest matchingSession = studySessions.stream() + .filter(session -> session.studyDetailId().equals(id)) + .findFirst() + .get(); + + studyDetail.updateSession( + studyDetail.getStudy().getStartTime(), + matchingSession.title(), + matchingSession.description(), + matchingSession.difficulty(), + matchingSession.status()); + } + studyDetailRepository.saveAll(studyDetails); + log.info("[MentorStudyService] 스터디 상세정보 커리큘럼 작성 완료: studyDetailId={}", studyDetails); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/application/StudentStudyDetailService.java b/src/main/java/com/gdschongik/gdsc/domain/study/application/StudentStudyDetailService.java new file mode 100644 index 000000000..046a1204d --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/application/StudentStudyDetailService.java @@ -0,0 +1,134 @@ +package com.gdschongik.gdsc.domain.study.application; + +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.study.dao.AssignmentHistoryRepository; +import com.gdschongik.gdsc.domain.study.dao.AttendanceRepository; +import com.gdschongik.gdsc.domain.study.dao.StudyDetailRepository; +import com.gdschongik.gdsc.domain.study.dao.StudyHistoryRepository; +import com.gdschongik.gdsc.domain.study.domain.AssignmentHistory; +import com.gdschongik.gdsc.domain.study.domain.Attendance; +import com.gdschongik.gdsc.domain.study.domain.StudyDetail; +import com.gdschongik.gdsc.domain.study.domain.StudyHistory; +import com.gdschongik.gdsc.domain.study.dto.response.AssignmentDashboardResponse; +import com.gdschongik.gdsc.domain.study.dto.response.AssignmentHistoryStatusResponse; +import com.gdschongik.gdsc.domain.study.dto.response.AssignmentSubmittableDto; +import com.gdschongik.gdsc.domain.study.dto.response.StudyStudentSessionResponse; +import com.gdschongik.gdsc.domain.study.dto.response.StudyTodoResponse; +import com.gdschongik.gdsc.global.exception.CustomException; +import com.gdschongik.gdsc.global.exception.ErrorCode; +import com.gdschongik.gdsc.global.util.MemberUtil; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class StudentStudyDetailService { + + private final MemberUtil memberUtil; + private final StudyHistoryRepository studyHistoryRepository; + private final AssignmentHistoryRepository assignmentHistoryRepository; + private final StudyDetailRepository studyDetailRepository; + private final AttendanceRepository attendanceRepository; + + @Transactional(readOnly = true) + public AssignmentDashboardResponse getSubmittableAssignments(Long studyId) { + Member currentMember = memberUtil.getCurrentMember(); + StudyHistory studyHistory = studyHistoryRepository + .findByStudentAndStudyId(currentMember, studyId) + .orElseThrow(() -> new CustomException(ErrorCode.STUDY_HISTORY_NOT_FOUND)); + List assignmentHistories = + assignmentHistoryRepository.findAssignmentHistoriesByStudentAndStudyId(currentMember, studyId); + List studyDetails = studyDetailRepository.findAllByStudyIdOrderByWeekAsc(studyId).stream() + .filter(studyDetail -> + studyDetail.getAssignment().isOpen() && studyDetail.isAssignmentDeadlineRemaining()) + .toList(); + + boolean isAnySubmitted = assignmentHistories.stream().anyMatch(AssignmentHistory::isSubmitted); + List submittableAssignments = studyDetails.stream() + .map(studyDetail -> AssignmentSubmittableDto.of( + studyDetail, getSubmittedAssignment(assignmentHistories, studyDetail))) + .toList(); + + return AssignmentDashboardResponse.of(studyHistory.getRepositoryLink(), isAnySubmitted, submittableAssignments); + } + + @Transactional(readOnly = true) + public List getStudyTodoList(Long studyId) { + Member member = memberUtil.getCurrentMember(); + final List studyDetails = studyDetailRepository.findAllByStudyIdOrderByWeekAsc(studyId); + final List assignmentHistories = + assignmentHistoryRepository.findAssignmentHistoriesByStudentAndStudyId(member, studyId); + final List attendances = attendanceRepository.findByMemberAndStudyId(member, studyId); + + LocalDate now = LocalDate.now(); + List response = new ArrayList<>(); + // 출석체크 정보 (개설 상태이고, 오늘이 출석체크날짜인 것) + studyDetails.stream() + .filter(studyDetail -> studyDetail.getSession().isOpen() + && studyDetail.getAttendanceDay().equals(now)) + .forEach(studyDetail -> response.add(StudyTodoResponse.createAttendanceType( + studyDetail, now, isAttended(attendances, studyDetail)))); + + // 과제 정보 (오늘이 과제 제출 기간에 포함된 과제 정보) + studyDetails.stream() + .filter(studyDetail -> studyDetail.getAssignment().isOpen() + && studyDetail.getAssignment().isDeadlineRemaining()) + .forEach(studyDetail -> response.add(StudyTodoResponse.createAssignmentType( + studyDetail, getSubmittedAssignment(assignmentHistories, studyDetail)))); + return response; + } + + public List getStudySessions(Long studyId) { + Member member = memberUtil.getCurrentMember(); + final List studyDetails = studyDetailRepository.findAllByStudyIdOrderByWeekAsc(studyId); + final List assignmentHistories = + assignmentHistoryRepository.findAssignmentHistoriesByStudentAndStudyId(member, studyId); + final List attendances = attendanceRepository.findByMemberAndStudyId(member, studyId); + + return studyDetails.stream() + .map(studyDetail -> StudyStudentSessionResponse.of( + studyDetail, + getSubmittedAssignment(assignmentHistories, studyDetail), + isAttended(attendances, studyDetail), + LocalDateTime.now())) + .toList(); + } + + private AssignmentHistory getSubmittedAssignment( + List assignmentHistories, StudyDetail studyDetail) { + return assignmentHistories.stream() + .filter(assignmentHistory -> + assignmentHistory.getStudyDetail().getId().equals(studyDetail.getId())) + .findFirst() + .orElse(null); + } + + private boolean isAttended(List attendances, StudyDetail studyDetail) { + return attendances.stream() + .anyMatch(attendance -> attendance.getStudyDetail().getId().equals(studyDetail.getId())); + } + + @Transactional(readOnly = true) + public List getUpcomingAssignments(Long studyId) { + Member currentMember = memberUtil.getCurrentMember(); + List studyDetails = studyDetailRepository.findAllByStudyId(studyId).stream() + .filter(studyDetail -> + studyDetail.getAssignment().isOpen() && studyDetail.isAssignmentDeadlineThisWeek()) + .toList(); + List assignmentHistories = + assignmentHistoryRepository.findAssignmentHistoriesByStudentAndStudyId(currentMember, studyId).stream() + .filter(assignmentHistory -> + assignmentHistory.getStudyDetail().isAssignmentDeadlineThisWeek()) + .toList(); + + return studyDetails.stream() + .map(studyDetail -> AssignmentHistoryStatusResponse.of( + studyDetail, getSubmittedAssignment(assignmentHistories, studyDetail))) + .toList(); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/application/StudentStudyHistoryService.java b/src/main/java/com/gdschongik/gdsc/domain/study/application/StudentStudyHistoryService.java new file mode 100644 index 000000000..8a1eb7b42 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/application/StudentStudyHistoryService.java @@ -0,0 +1,120 @@ +package com.gdschongik.gdsc.domain.study.application; + +import static com.gdschongik.gdsc.global.common.constant.GithubConstant.*; +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; + +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.study.dao.AssignmentHistoryRepository; +import com.gdschongik.gdsc.domain.study.dao.StudyDetailRepository; +import com.gdschongik.gdsc.domain.study.dao.StudyHistoryRepository; +import com.gdschongik.gdsc.domain.study.domain.AssignmentHistory; +import com.gdschongik.gdsc.domain.study.domain.AssignmentHistoryGrader; +import com.gdschongik.gdsc.domain.study.domain.AssignmentSubmissionFetcher; +import com.gdschongik.gdsc.domain.study.domain.Study; +import com.gdschongik.gdsc.domain.study.domain.StudyAssignmentHistoryValidator; +import com.gdschongik.gdsc.domain.study.domain.StudyDetail; +import com.gdschongik.gdsc.domain.study.domain.StudyHistory; +import com.gdschongik.gdsc.domain.study.domain.StudyHistoryValidator; +import com.gdschongik.gdsc.domain.study.dto.request.RepositoryUpdateRequest; +import com.gdschongik.gdsc.domain.study.dto.response.AssignmentHistoryResponse; +import com.gdschongik.gdsc.global.exception.CustomException; +import com.gdschongik.gdsc.global.util.MemberUtil; +import com.gdschongik.gdsc.infra.github.client.GithubClient; +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.kohsuke.github.GHRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class StudentStudyHistoryService { + + private final MemberUtil memberUtil; + private final GithubClient githubClient; + private final StudyDetailRepository studyDetailRepository; + private final StudyHistoryRepository studyHistoryRepository; + private final AssignmentHistoryRepository assignmentHistoryRepository; + private final StudyHistoryValidator studyHistoryValidator; + private final StudyAssignmentHistoryValidator studyAssignmentHistoryValidator; + private final AssignmentHistoryGrader assignmentHistoryGrader; + + @Transactional + public void updateRepository(Long studyHistoryId, RepositoryUpdateRequest request) throws IOException { + Member currentMember = memberUtil.getCurrentMember(); + StudyHistory studyHistory = studyHistoryRepository + .findById(studyHistoryId) + .orElseThrow(() -> new CustomException(STUDY_HISTORY_NOT_FOUND)); + Study study = studyHistory.getStudy(); + + boolean isAnyAssignmentSubmitted = + assignmentHistoryRepository.existsSubmittedAssignmentByMemberAndStudy(currentMember, study); + String ownerRepo = getOwnerRepo(request.repositoryLink()); + GHRepository repository = githubClient.getRepository(ownerRepo); + // TODO: GHRepository 등을 wrapper로 감싸서 테스트 가능하도록 변경 + studyHistoryValidator.validateUpdateRepository( + isAnyAssignmentSubmitted, String.valueOf(repository.getOwner().getId()), currentMember.getOauthId()); + + studyHistory.updateRepositoryLink(request.repositoryLink()); + studyHistoryRepository.save(studyHistory); + + log.info( + "[StudyHistoryService] 레포지토리 입력: studyHistoryId={}, repositoryLink={}", + studyHistory.getId(), + request.repositoryLink()); + } + + private String getOwnerRepo(String repositoryLink) { + int startIndex = repositoryLink.indexOf(GITHUB_DOMAIN) + GITHUB_DOMAIN.length(); + return repositoryLink.substring(startIndex); + } + + @Transactional(readOnly = true) + public List getAllAssignmentHistories(Long studyId) { + Member currentMember = memberUtil.getCurrentMember(); + + return assignmentHistoryRepository.findAssignmentHistoriesByStudentAndStudyId(currentMember, studyId).stream() + .map(AssignmentHistoryResponse::from) + .toList(); + } + + @Transactional + public void submitAssignment(Long studyDetailId) { + Member currentMember = memberUtil.getCurrentMember(); + StudyDetail studyDetail = studyDetailRepository + .findById(studyDetailId) + .orElseThrow(() -> new CustomException(STUDY_DETAIL_NOT_FOUND)); + Optional studyHistory = + studyHistoryRepository.findByStudentAndStudy(currentMember, studyDetail.getStudy()); + LocalDateTime now = LocalDateTime.now(); + + AssignmentHistory assignmentHistory = findOrCreate(currentMember, studyDetail); + + studyAssignmentHistoryValidator.validateSubmitAvailable(studyHistory.isPresent(), now, studyDetail); + + AssignmentSubmissionFetcher fetcher = githubClient.getLatestAssignmentSubmissionFetcher( + studyHistory.get().getRepositoryLink(), Math.toIntExact(studyDetail.getWeek())); + + assignmentHistoryGrader.judge(fetcher, assignmentHistory); + + assignmentHistoryRepository.save(assignmentHistory); + + log.info( + "[StudyHistoryService] 과제 제출: studyDetailId={}, studentId={}, submissionStatus={}, submissionFailureType={}", + studyDetailId, + currentMember.getId(), + assignmentHistory.getSubmissionStatus(), + assignmentHistory.getSubmissionFailureType()); + } + + private AssignmentHistory findOrCreate(Member currentMember, StudyDetail studyDetail) { + return assignmentHistoryRepository + .findByMemberAndStudyDetail(currentMember, studyDetail) + .orElseGet(() -> AssignmentHistory.create(studyDetail, currentMember)); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/application/StudentStudyService.java b/src/main/java/com/gdschongik/gdsc/domain/study/application/StudentStudyService.java new file mode 100644 index 000000000..ba5a997e8 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/application/StudentStudyService.java @@ -0,0 +1,114 @@ +package com.gdschongik.gdsc.domain.study.application; + +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; + +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.study.dao.AttendanceRepository; +import com.gdschongik.gdsc.domain.study.dao.StudyDetailRepository; +import com.gdschongik.gdsc.domain.study.dao.StudyHistoryRepository; +import com.gdschongik.gdsc.domain.study.dao.StudyRepository; +import com.gdschongik.gdsc.domain.study.domain.*; +import com.gdschongik.gdsc.domain.study.domain.Attendance; +import com.gdschongik.gdsc.domain.study.domain.AttendanceValidator; +import com.gdschongik.gdsc.domain.study.dto.request.StudyAttendCreateRequest; +import com.gdschongik.gdsc.domain.study.dto.response.StudentMyCurrentStudyResponse; +import com.gdschongik.gdsc.domain.study.dto.response.StudyApplicableResponse; +import com.gdschongik.gdsc.domain.study.dto.response.StudyResponse; +import com.gdschongik.gdsc.global.exception.CustomException; +import com.gdschongik.gdsc.global.util.MemberUtil; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class StudentStudyService { + + private final MemberUtil memberUtil; + private final StudyRepository studyRepository; + private final StudyDetailRepository studyDetailRepository; + private final StudyHistoryRepository studyHistoryRepository; + private final StudyHistoryValidator studyHistoryValidator; + private final AttendanceRepository attendanceRepository; + private final AttendanceValidator attendanceValidator; + + public StudyApplicableResponse getAllApplicableStudies() { + Member currentMember = memberUtil.getCurrentMember(); + List studyHistories = studyHistoryRepository.findAllByStudent(currentMember); + Optional appliedStudy = studyHistories.stream() + .map(StudyHistory::getStudy) + .filter(Study::isStudyOngoing) + .findFirst(); + List studyResponses = studyRepository.findAll().stream() + .filter(Study::isApplicable) + .map(StudyResponse::from) + .toList(); + + return StudyApplicableResponse.of(appliedStudy.orElse(null), studyResponses); + } + + @Transactional + public void applyStudy(Long studyId) { + Study study = studyRepository.findById(studyId).orElseThrow(() -> new CustomException(STUDY_NOT_FOUND)); + Member currentMember = memberUtil.getCurrentMember(); + + List currentMemberStudyHistories = studyHistoryRepository.findAllByStudent(currentMember); + + studyHistoryValidator.validateApplyStudy(study, currentMemberStudyHistories); + + StudyHistory studyHistory = StudyHistory.create(currentMember, study); + studyHistoryRepository.save(studyHistory); + + log.info("[StudyService] 스터디 수강신청: studyHistoryId={}", studyHistory.getId()); + } + + @Transactional + public void cancelStudyApply(Long studyId) { + Study study = studyRepository.findById(studyId).orElseThrow(() -> new CustomException(STUDY_NOT_FOUND)); + Member currentMember = memberUtil.getCurrentMember(); + + studyHistoryValidator.validateCancelStudyApply(study); + + StudyHistory studyHistory = studyHistoryRepository + .findByStudentAndStudy(currentMember, study) + .orElseThrow(() -> new CustomException(STUDY_HISTORY_NOT_FOUND)); + studyHistoryRepository.delete(studyHistory); + + log.info("[StudyService] 스터디 수강신청 취소: appliedStudyId={}, memberId={}", study.getId(), currentMember.getId()); + } + + @Transactional + public void attend(Long studyDetailId, StudyAttendCreateRequest request) { + final StudyDetail studyDetail = studyDetailRepository + .findById(studyDetailId) + .orElseThrow(() -> new CustomException(STUDY_DETAIL_NOT_FOUND)); + final Member currentMember = memberUtil.getCurrentMember(); + final Study study = studyDetail.getStudy(); + final StudyHistory studyHistory = studyHistoryRepository + .findByStudentAndStudy(currentMember, study) + .orElseThrow(() -> new CustomException(STUDY_HISTORY_NOT_FOUND)); + + attendanceValidator.validateAttendance(studyDetail, request.attendanceNumber(), LocalDate.now()); + + Attendance attendance = Attendance.create(currentMember, studyDetail); + attendanceRepository.save(attendance); + + log.info("[StudyService] 스터디 출석: attendanceId={}", attendance.getId()); + } + + @Transactional(readOnly = true) + public StudentMyCurrentStudyResponse getMyCurrentStudy() { + Member currentMember = memberUtil.getCurrentMember(); + StudyHistory studyHistory = studyHistoryRepository.findAllByStudent(currentMember).stream() + .filter(s -> s.getStudy().isStudyOngoing()) + .findFirst() + .orElse(null); + return StudentMyCurrentStudyResponse.from(studyHistory); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/application/StudyService.java b/src/main/java/com/gdschongik/gdsc/domain/study/application/StudyService.java deleted file mode 100644 index c0ed98955..000000000 --- a/src/main/java/com/gdschongik/gdsc/domain/study/application/StudyService.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.gdschongik.gdsc.domain.study.application; - -import static com.gdschongik.gdsc.global.exception.ErrorCode.*; - -import com.gdschongik.gdsc.domain.member.domain.Member; -import com.gdschongik.gdsc.domain.study.dao.StudyHistoryRepository; -import com.gdschongik.gdsc.domain.study.dao.StudyRepository; -import com.gdschongik.gdsc.domain.study.domain.Study; -import com.gdschongik.gdsc.domain.study.domain.StudyHistory; -import com.gdschongik.gdsc.domain.study.domain.StudyHistoryValidator; -import com.gdschongik.gdsc.domain.study.dto.response.StudyResponse; -import com.gdschongik.gdsc.global.exception.CustomException; -import com.gdschongik.gdsc.global.util.MemberUtil; -import java.util.List; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Slf4j -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class StudyService { - - private final MemberUtil memberUtil; - private final StudyRepository studyRepository; - private final StudyHistoryRepository studyHistoryRepository; - private final StudyHistoryValidator studyHistoryValidator; - - public List getAllApplicableStudies() { - return studyRepository.findAll().stream() - .filter(Study::isApplicable) - .map(StudyResponse::from) - .toList(); - } - - @Transactional - public void applyStudy(Long studyId) { - Study study = studyRepository.findById(studyId).orElseThrow(() -> new CustomException(STUDY_NOT_FOUND)); - Member currentMember = memberUtil.getCurrentMember(); - - List currentMemberStudyHistories = studyHistoryRepository.findAllByMentee(currentMember); - - studyHistoryValidator.validateApplyStudy(study, currentMemberStudyHistories); - - StudyHistory studyHistory = StudyHistory.create(currentMember, study); - studyHistoryRepository.save(studyHistory); - - log.info("[StudyService] 스터디 수강신청: studyHistoryId={}", studyHistory.getId()); - } - - @Transactional - public void cancelStudyApply(Long studyId) { - Study study = studyRepository.findById(studyId).orElseThrow(() -> new CustomException(STUDY_NOT_FOUND)); - Member currentMember = memberUtil.getCurrentMember(); - - studyHistoryValidator.validateCancelStudyApply(study); - - StudyHistory studyHistory = studyHistoryRepository - .findByMenteeAndStudy(currentMember, study) - .orElseThrow(() -> new CustomException(STUDY_HISTORY_NOT_FOUND)); - studyHistoryRepository.delete(studyHistory); - - log.info("[StudyService] 스터디 수강신청 취소: studyId={}, memberId={}", study.getId(), currentMember.getId()); - } -} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/dao/AssignmentHistoryCustomRepository.java b/src/main/java/com/gdschongik/gdsc/domain/study/dao/AssignmentHistoryCustomRepository.java new file mode 100644 index 000000000..108112e6f --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dao/AssignmentHistoryCustomRepository.java @@ -0,0 +1,13 @@ +package com.gdschongik.gdsc.domain.study.dao; + +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.study.domain.AssignmentHistory; +import com.gdschongik.gdsc.domain.study.domain.Study; +import java.util.List; + +public interface AssignmentHistoryCustomRepository { + + boolean existsSubmittedAssignmentByMemberAndStudy(Member member, Study study); + + List findAssignmentHistoriesByStudentAndStudyId(Member member, Long studyId); +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/dao/AssignmentHistoryCustomRepositoryImpl.java b/src/main/java/com/gdschongik/gdsc/domain/study/dao/AssignmentHistoryCustomRepositoryImpl.java new file mode 100644 index 000000000..4c6df554c --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dao/AssignmentHistoryCustomRepositoryImpl.java @@ -0,0 +1,56 @@ +package com.gdschongik.gdsc.domain.study.dao; + +import static com.gdschongik.gdsc.domain.study.domain.AssignmentSubmissionStatus.*; +import static com.gdschongik.gdsc.domain.study.domain.QAssignmentHistory.*; +import static com.gdschongik.gdsc.domain.study.domain.QStudyDetail.studyDetail; + +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.study.domain.AssignmentHistory; +import com.gdschongik.gdsc.domain.study.domain.Study; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class AssignmentHistoryCustomRepositoryImpl implements AssignmentHistoryCustomRepository { + + private final JPAQueryFactory queryFactory; + + @Override + public boolean existsSubmittedAssignmentByMemberAndStudy(Member member, Study study) { + Integer fetchOne = queryFactory + .selectOne() + .from(assignmentHistory) + .where(eqMember(member), eqStudy(study), isSubmitted()) + .fetchFirst(); + + return fetchOne != null; + } + + private BooleanExpression eqMember(Member member) { + return member == null ? null : assignmentHistory.member.eq(member); + } + + private BooleanExpression eqStudy(Study study) { + return study == null ? null : assignmentHistory.studyDetail.study.eq(study); + } + + private BooleanExpression isSubmitted() { + return assignmentHistory.submissionStatus.in(FAILURE, SUCCESS); + } + + @Override + public List findAssignmentHistoriesByStudentAndStudyId(Member currentMember, Long studyId) { + return queryFactory + .selectFrom(assignmentHistory) + .join(assignmentHistory.studyDetail, studyDetail) + .fetchJoin() + .where(eqStudyId(studyId).and(eqMember(currentMember))) + .fetch(); + } + + private BooleanExpression eqStudyId(Long studyId) { + return studyId != null ? studyDetail.study.id.eq(studyId) : null; + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/dao/AssignmentHistoryRepository.java b/src/main/java/com/gdschongik/gdsc/domain/study/dao/AssignmentHistoryRepository.java new file mode 100644 index 000000000..1c882f5ee --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dao/AssignmentHistoryRepository.java @@ -0,0 +1,12 @@ +package com.gdschongik.gdsc.domain.study.dao; + +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.study.domain.AssignmentHistory; +import com.gdschongik.gdsc.domain.study.domain.StudyDetail; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AssignmentHistoryRepository + extends JpaRepository, AssignmentHistoryCustomRepository { + public Optional findByMemberAndStudyDetail(Member member, StudyDetail studyDetail); +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/dao/AttendanceCustomRepository.java b/src/main/java/com/gdschongik/gdsc/domain/study/dao/AttendanceCustomRepository.java new file mode 100644 index 000000000..175472c02 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dao/AttendanceCustomRepository.java @@ -0,0 +1,9 @@ +package com.gdschongik.gdsc.domain.study.dao; + +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.study.domain.Attendance; +import java.util.List; + +public interface AttendanceCustomRepository { + List findByMemberAndStudyId(Member member, Long studyId); +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/dao/AttendanceCustomRepositoryImpl.java b/src/main/java/com/gdschongik/gdsc/domain/study/dao/AttendanceCustomRepositoryImpl.java new file mode 100644 index 000000000..16ed1b15c --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dao/AttendanceCustomRepositoryImpl.java @@ -0,0 +1,36 @@ +package com.gdschongik.gdsc.domain.study.dao; + +import static com.gdschongik.gdsc.domain.member.domain.QMember.member; +import static com.gdschongik.gdsc.domain.study.domain.QAttendance.attendance; +import static com.gdschongik.gdsc.domain.study.domain.QStudyDetail.studyDetail; + +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.study.domain.Attendance; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class AttendanceCustomRepositoryImpl implements AttendanceCustomRepository { + + private final JPAQueryFactory queryFactory; + + @Override + public List findByMemberAndStudyId(Member member, Long studyId) { + return queryFactory + .selectFrom(attendance) + .leftJoin(attendance.studyDetail, studyDetail) + .fetchJoin() + .where(eqMemberId(member.getId()), eqStudyId(studyId)) + .fetch(); + } + + private BooleanExpression eqMemberId(Long memberId) { + return memberId != null ? attendance.student.id.eq(memberId) : null; + } + + private BooleanExpression eqStudyId(Long studyId) { + return studyId != null ? attendance.studyDetail.study.id.eq(studyId) : null; + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/dao/AttendanceRepository.java b/src/main/java/com/gdschongik/gdsc/domain/study/dao/AttendanceRepository.java new file mode 100644 index 000000000..4c8d83d20 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dao/AttendanceRepository.java @@ -0,0 +1,6 @@ +package com.gdschongik.gdsc.domain.study.dao; + +import com.gdschongik.gdsc.domain.study.domain.Attendance; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AttendanceRepository extends JpaRepository, AttendanceCustomRepository {} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/dao/StudyAnnouncementRepository.java b/src/main/java/com/gdschongik/gdsc/domain/study/dao/StudyAnnouncementRepository.java new file mode 100644 index 000000000..3d81940e2 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dao/StudyAnnouncementRepository.java @@ -0,0 +1,17 @@ +package com.gdschongik.gdsc.domain.study.dao; + +import static com.gdschongik.gdsc.global.exception.ErrorCode.STUDY_ANNOUNCEMENT_NOT_FOUND; + +import com.gdschongik.gdsc.domain.study.domain.StudyAnnouncement; +import com.gdschongik.gdsc.global.exception.CustomException; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface StudyAnnouncementRepository extends JpaRepository { + + default StudyAnnouncement getById(Long id) { + return findById(id).orElseThrow(() -> new CustomException(STUDY_ANNOUNCEMENT_NOT_FOUND)); + } + + List findAllByStudyIdOrderByCreatedAtDesc(Long studyId); +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/dao/StudyDetailRepository.java b/src/main/java/com/gdschongik/gdsc/domain/study/dao/StudyDetailRepository.java index 350872753..c3f317f54 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/study/dao/StudyDetailRepository.java +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dao/StudyDetailRepository.java @@ -6,5 +6,7 @@ public interface StudyDetailRepository extends JpaRepository { + List findAllByStudyIdOrderByWeekAsc(Long studyId); + List findAllByStudyId(Long studyId); } diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/dao/StudyHistoryRepository.java b/src/main/java/com/gdschongik/gdsc/domain/study/dao/StudyHistoryRepository.java index f706aea27..4bf4303ea 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/study/dao/StudyHistoryRepository.java +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dao/StudyHistoryRepository.java @@ -9,7 +9,13 @@ public interface StudyHistoryRepository extends JpaRepository { - List findAllByMentee(Member member); + List findByStudyId(Long studyId); - Optional findByMenteeAndStudy(Member member, Study study); + List findAllByStudent(Member member); + + Optional findByStudentAndStudy(Member member, Study study); + + boolean existsByStudentAndStudy(Member member, Study study); + + Optional findByStudentAndStudyId(Member member, Long studyId); } diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/dao/StudyRepository.java b/src/main/java/com/gdschongik/gdsc/domain/study/dao/StudyRepository.java index 0f77c5045..c050df530 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/study/dao/StudyRepository.java +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dao/StudyRepository.java @@ -1,6 +1,18 @@ package com.gdschongik.gdsc.domain.study.dao; +import static com.gdschongik.gdsc.global.exception.ErrorCode.STUDY_NOT_FOUND; + +import com.gdschongik.gdsc.domain.member.domain.Member; import com.gdschongik.gdsc.domain.study.domain.Study; +import com.gdschongik.gdsc.global.exception.CustomException; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; -public interface StudyRepository extends JpaRepository {} +public interface StudyRepository extends JpaRepository { + + default Study getById(Long id) { + return findById(id).orElseThrow(() -> new CustomException(STUDY_NOT_FOUND)); + } + + List findAllByMentor(Member mentor); +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/domain/AssignmentHistory.java b/src/main/java/com/gdschongik/gdsc/domain/study/domain/AssignmentHistory.java index e340b458e..ca6c93fd0 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/study/domain/AssignmentHistory.java +++ b/src/main/java/com/gdschongik/gdsc/domain/study/domain/AssignmentHistory.java @@ -1,7 +1,12 @@ package com.gdschongik.gdsc.domain.study.domain; +import static com.gdschongik.gdsc.domain.study.domain.AssignmentSubmissionStatus.*; +import static com.gdschongik.gdsc.domain.study.domain.SubmissionFailureType.*; +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; + import com.gdschongik.gdsc.domain.common.model.BaseEntity; import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.global.exception.CustomException; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -12,6 +17,7 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import java.time.LocalDateTime; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -40,7 +46,9 @@ public class AssignmentHistory extends BaseEntity { private String commitHash; - private Long contentLength; + private Integer contentLength; + + private LocalDateTime committedAt; @Enumerated(EnumType.STRING) private AssignmentSubmissionStatus submissionStatus; @@ -52,27 +60,54 @@ public class AssignmentHistory extends BaseEntity { private AssignmentHistory( StudyDetail studyDetail, Member member, - String submissionLink, - String commitHash, - Long contentLength, - AssignmentSubmissionStatus submissionStatus) { + AssignmentSubmissionStatus submissionStatus, + SubmissionFailureType submissionFailureType) { this.studyDetail = studyDetail; this.member = member; - this.submissionLink = submissionLink; - this.commitHash = commitHash; - this.contentLength = contentLength; this.submissionStatus = submissionStatus; + this.submissionFailureType = submissionFailureType; } - public static AssignmentHistory create( - StudyDetail studyDetail, Member member, String submissionLink, String commitHash, Long contentLength) { + public static AssignmentHistory create(StudyDetail studyDetail, Member member) { return AssignmentHistory.builder() .studyDetail(studyDetail) .member(member) - .submissionLink(submissionLink) - .commitHash(commitHash) - .contentLength(contentLength) - .submissionStatus(AssignmentSubmissionStatus.PENDING) + .submissionStatus(FAILURE) + .submissionFailureType(NOT_SUBMITTED) .build(); } + + // 데이터 조회 로직 + + public boolean isSubmitted() { + return submissionFailureType != NOT_SUBMITTED; + } + + public boolean isSuccess() { + return submissionStatus == SUCCESS; + } + + // 데이터 변경 로직 + + public void success(String submissionLink, String commitHash, Integer contentLength, LocalDateTime committedAt) { + this.submissionLink = submissionLink; + this.commitHash = commitHash; + this.contentLength = contentLength; + this.committedAt = committedAt; + this.submissionStatus = SUCCESS; + this.submissionFailureType = NONE; + } + + public void fail(SubmissionFailureType submissionFailureType) { + if (submissionFailureType == NOT_SUBMITTED || submissionFailureType == NONE) { + throw new CustomException(ASSIGNMENT_INVALID_FAILURE_TYPE); + } + + this.submissionLink = null; + this.commitHash = null; + this.contentLength = null; + this.committedAt = null; + this.submissionStatus = FAILURE; + this.submissionFailureType = submissionFailureType; + } } diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/domain/AssignmentHistoryGrader.java b/src/main/java/com/gdschongik/gdsc/domain/study/domain/AssignmentHistoryGrader.java new file mode 100644 index 000000000..78ab2c592 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/domain/AssignmentHistoryGrader.java @@ -0,0 +1,52 @@ +package com.gdschongik.gdsc.domain.study.domain; + +import static com.gdschongik.gdsc.domain.study.domain.SubmissionFailureType.*; +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; + +import com.gdschongik.gdsc.global.annotation.DomainService; +import com.gdschongik.gdsc.global.exception.CustomException; +import com.gdschongik.gdsc.global.exception.ErrorCode; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@DomainService +public class AssignmentHistoryGrader { + + private static final int MINIMUM_ASSIGNMENT_CONTENT_LENGTH = 300; + + public void judge(AssignmentSubmissionFetcher assignmentSubmissionFetcher, AssignmentHistory assignmentHistory) { + try { + AssignmentSubmission assignmentSubmission = assignmentSubmissionFetcher.fetch(); + judgeAssignmentSubmission(assignmentSubmission, assignmentHistory); + } catch (CustomException e) { + SubmissionFailureType failureType = translateException(e); + assignmentHistory.fail(failureType); + } + } + + private void judgeAssignmentSubmission( + AssignmentSubmission assignmentSubmission, AssignmentHistory assignmentHistory) { + if (assignmentSubmission.contentLength() < MINIMUM_ASSIGNMENT_CONTENT_LENGTH) { + assignmentHistory.fail(WORD_COUNT_INSUFFICIENT); + return; + } + + assignmentHistory.success( + assignmentSubmission.url(), + assignmentSubmission.commitHash(), + assignmentSubmission.contentLength(), + assignmentSubmission.committedAt()); + } + + private SubmissionFailureType translateException(CustomException e) { + ErrorCode errorCode = e.getErrorCode(); + + if (errorCode == GITHUB_CONTENT_NOT_FOUND) { + return LOCATION_UNIDENTIFIABLE; + } + + log.warn("[AssignmentHistoryGrader] 과제 제출정보 조회 중 알 수 없는 오류 발생: {}", e.getMessage()); + + return UNKNOWN; + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/domain/AssignmentSubmission.java b/src/main/java/com/gdschongik/gdsc/domain/study/domain/AssignmentSubmission.java new file mode 100644 index 000000000..307db0cbe --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/domain/AssignmentSubmission.java @@ -0,0 +1,5 @@ +package com.gdschongik.gdsc.domain.study.domain; + +import java.time.LocalDateTime; + +public record AssignmentSubmission(String url, String commitHash, Integer contentLength, LocalDateTime committedAt) {} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/domain/AssignmentSubmissionFetchExecutor.java b/src/main/java/com/gdschongik/gdsc/domain/study/domain/AssignmentSubmissionFetchExecutor.java new file mode 100644 index 000000000..32eab4e9c --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/domain/AssignmentSubmissionFetchExecutor.java @@ -0,0 +1,8 @@ +package com.gdschongik.gdsc.domain.study.domain; + +import com.gdschongik.gdsc.global.exception.CustomException; + +@FunctionalInterface +public interface AssignmentSubmissionFetchExecutor { + AssignmentSubmission execute(String repo, int week) throws CustomException; +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/domain/AssignmentSubmissionFetcher.java b/src/main/java/com/gdschongik/gdsc/domain/study/domain/AssignmentSubmissionFetcher.java new file mode 100644 index 000000000..3f19e5d12 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/domain/AssignmentSubmissionFetcher.java @@ -0,0 +1,9 @@ +package com.gdschongik.gdsc.domain.study.domain; + +import com.gdschongik.gdsc.global.exception.CustomException; + +public record AssignmentSubmissionFetcher(String repo, int week, AssignmentSubmissionFetchExecutor fetchExecutor) { + public AssignmentSubmission fetch() throws CustomException { + return fetchExecutor.execute(repo, week); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/domain/AssignmentSubmissionStatus.java b/src/main/java/com/gdschongik/gdsc/domain/study/domain/AssignmentSubmissionStatus.java index 4ac62cd11..2bc90a871 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/study/domain/AssignmentSubmissionStatus.java +++ b/src/main/java/com/gdschongik/gdsc/domain/study/domain/AssignmentSubmissionStatus.java @@ -6,10 +6,8 @@ @Getter @RequiredArgsConstructor public enum AssignmentSubmissionStatus { - PENDING("제출 전"), FAILURE("제출 실패"), - SUCCESS("제출 성공"), - CANCELLED("과제 휴강"); + SUCCESS("제출 성공"); private final String value; } diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/domain/Attendance.java b/src/main/java/com/gdschongik/gdsc/domain/study/domain/Attendance.java new file mode 100644 index 000000000..7556fd8be --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/domain/Attendance.java @@ -0,0 +1,48 @@ +package com.gdschongik.gdsc.domain.study.domain; + +import com.gdschongik.gdsc.domain.common.model.BaseEntity; +import com.gdschongik.gdsc.domain.member.domain.Member; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +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 jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(uniqueConstraints = {@UniqueConstraint(columnNames = {"member_id", "study_detail_id"})}) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Attendance extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "attendance_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member student; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "study_detail_id") + private StudyDetail studyDetail; + + @Builder(access = AccessLevel.PRIVATE) + private Attendance(Member student, StudyDetail studyDetail) { + this.student = student; + this.studyDetail = studyDetail; + } + + public static Attendance create(Member student, StudyDetail studyDetail) { + return Attendance.builder().student(student).studyDetail(studyDetail).build(); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/domain/AttendanceValidator.java b/src/main/java/com/gdschongik/gdsc/domain/study/domain/AttendanceValidator.java new file mode 100644 index 000000000..2637fd41a --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/domain/AttendanceValidator.java @@ -0,0 +1,23 @@ +package com.gdschongik.gdsc.domain.study.domain; + +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; + +import com.gdschongik.gdsc.global.annotation.DomainService; +import com.gdschongik.gdsc.global.exception.CustomException; +import java.time.LocalDate; + +@DomainService +public class AttendanceValidator { + public void validateAttendance(StudyDetail studyDetail, String attendanceNumber, LocalDate date) { + // 출석체크 날짜 검증 + LocalDate attendanceDay = studyDetail.getAttendanceDay(); + if (!attendanceDay.equals(date)) { + throw new CustomException(ATTENDANCE_DATE_INVALID); + } + + // 출석체크 번호 검증 + if (!studyDetail.getAttendanceNumber().equals(attendanceNumber)) { + throw new CustomException(ATTENDANCE_NUMBER_MISMATCH); + } + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/domain/Difficulty.java b/src/main/java/com/gdschongik/gdsc/domain/study/domain/Difficulty.java index bea99e59b..98f526bc2 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/study/domain/Difficulty.java +++ b/src/main/java/com/gdschongik/gdsc/domain/study/domain/Difficulty.java @@ -8,7 +8,8 @@ public enum Difficulty { HIGH("상"), MEDIUM("중"), - LOW("하"); + LOW("하"), + BASIC("기초"); private final String value; } diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/domain/Study.java b/src/main/java/com/gdschongik/gdsc/domain/study/domain/Study.java index 0d6639b31..6fe8152b3 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/study/domain/Study.java +++ b/src/main/java/com/gdschongik/gdsc/domain/study/domain/Study.java @@ -21,6 +21,7 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import java.time.DayOfWeek; +import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; import lombok.AccessLevel; @@ -80,6 +81,7 @@ public class Study extends BaseSemesterEntity { private Study( Integer academicYear, SemesterType semesterType, + String title, Member mentor, Period period, Period applicationPeriod, @@ -89,6 +91,7 @@ private Study( LocalTime startTime, LocalTime endTime) { super(academicYear, semesterType); + this.title = title; this.mentor = mentor; this.period = period; this.applicationPeriod = applicationPeriod; @@ -102,6 +105,7 @@ private Study( public static Study createStudy( Integer academicYear, SemesterType semesterType, + String title, Member mentor, Period period, Period applicationPeriod, @@ -116,6 +120,7 @@ public static Study createStudy( return Study.builder() .academicYear(academicYear) .semesterType(semesterType) + .title(title) .mentor(mentor) .period(period) .applicationPeriod(applicationPeriod) @@ -127,6 +132,8 @@ public static Study createStudy( .build(); } + // 검증 로직 + private static void validateApplicationStartDateBeforeSessionStartDate( LocalDateTime applicationStartDate, LocalDateTime startDate) { if (!applicationStartDate.isBefore(startDate)) { @@ -171,4 +178,13 @@ public boolean isApplicable() { public boolean isStudyOngoing() { return period.isOpen(); } + + public LocalDate getStartDate() { + return period.getStartDate().toLocalDate(); + } + + public void update(String link, String introduction) { + notionLink = link; + this.introduction = introduction; + } } diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyNotification.java b/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyAnnouncement.java similarity index 57% rename from src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyNotification.java rename to src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyAnnouncement.java index 125b88f55..f10470bc8 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyNotification.java +++ b/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyAnnouncement.java @@ -10,17 +10,18 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import lombok.AccessLevel; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @Getter @Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class StudyNotification extends BaseEntity { +public class StudyAnnouncement extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "study_notification_id") + @Column(name = "study_announcement_id") private Long id; @ManyToOne(fetch = FetchType.LAZY) @@ -31,4 +32,20 @@ public class StudyNotification extends BaseEntity { @Column(columnDefinition = "TEXT") private String link; + + @Builder(access = AccessLevel.PRIVATE) + public StudyAnnouncement(Study study, String title, String link) { + this.study = study; + this.title = title; + this.link = link; + } + + public static StudyAnnouncement createStudyAnnouncement(Study study, String title, String link) { + return StudyAnnouncement.builder().study(study).title(title).link(link).build(); + } + + public void update(String title, String link) { + this.title = title; + this.link = link; + } } diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyAssignmentHistoryValidator.java b/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyAssignmentHistoryValidator.java new file mode 100644 index 000000000..7bda5b7ff --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyAssignmentHistoryValidator.java @@ -0,0 +1,22 @@ +package com.gdschongik.gdsc.domain.study.domain; + +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; + +import com.gdschongik.gdsc.global.annotation.DomainService; +import com.gdschongik.gdsc.global.exception.CustomException; +import java.time.LocalDateTime; + +@DomainService +public class StudyAssignmentHistoryValidator { + + /** + * 채점을 수행하기 전, 과제 제출이 가능한지 검증합니다. + */ + public void validateSubmitAvailable(boolean isAppliedToStudy, LocalDateTime now, StudyDetail studyDetail) { + if (!isAppliedToStudy) { + throw new CustomException(ASSIGNMENT_STUDY_NOT_APPLIED); + } + + studyDetail.validateAssignmentSubmittable(now); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyDetail.java b/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyDetail.java index 6057fa5ea..41bb4a0b5 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyDetail.java +++ b/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyDetail.java @@ -1,11 +1,17 @@ package com.gdschongik.gdsc.domain.study.domain; +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; + import com.gdschongik.gdsc.domain.common.model.BaseEntity; import com.gdschongik.gdsc.domain.recruitment.domain.vo.Period; import com.gdschongik.gdsc.domain.study.domain.vo.Assignment; import com.gdschongik.gdsc.domain.study.domain.vo.Session; +import com.gdschongik.gdsc.global.exception.CustomException; import jakarta.persistence.*; +import java.time.DayOfWeek; +import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.LocalTime; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -27,7 +33,7 @@ public class StudyDetail extends BaseEntity { private Study study; @Comment("현 주차수") - private Long week; + private Long week; // TODO: Integer로 변경 private String attendanceNumber; @@ -44,7 +50,8 @@ public class StudyDetail extends BaseEntity { @Embedded @AttributeOverride(name = "title", column = @Column(name = "assignment_title")) - @AttributeOverride(name = "difficulty", column = @Column(name = "assignment_difficulty")) + @AttributeOverride(name = "deadline", column = @Column(name = "assignment_deadline")) + @AttributeOverride(name = "descriptionLink", column = @Column(name = "assignment_description_link")) @AttributeOverride(name = "status", column = @Column(name = "assignment_status")) private Assignment assignment; @@ -82,4 +89,49 @@ public void publishAssignment(String title, LocalDateTime deadLine, String descr public void updateAssignment(String title, LocalDateTime deadLine, String descriptionNotionLink) { assignment = Assignment.generateAssignment(title, deadLine, descriptionNotionLink); } + + // 데이터 전달 로직 + + public boolean isAssignmentDeadlineRemaining() { + return assignment.isDeadlineRemaining(); + } + + public boolean isAssignmentDeadlineThisWeek() { + return assignment.isDeadLineThisWeek(); + } + + // 스터디 시작일자 + 현재 주차 * 7 + (스터디 요일 - 스터디 기간 시작 요일) + public LocalDate getAttendanceDay() { + // 스터디 시작일자 + LocalDate startDate = study.getStartDate(); + + // 스터디 요일 + DayOfWeek studyDayOfWeek = study.getDayOfWeek(); + + // 스터디 기간 시작 요일 + DayOfWeek startDayOfWeek = startDate.getDayOfWeek(); + + // 스터디 요일이 스터디 기간 시작 요일보다 앞서면, 다음 주로 넘어가게 처리 + Long daysDifference = Long.valueOf(studyDayOfWeek.getValue() - startDayOfWeek.getValue()); + if (daysDifference < 0) { + daysDifference += 7; + } + + // 현재 주차에 따른 일수 계산 + Long daysToAdd = (week - 1) * 7 + daysDifference; + + return startDate.plusDays(daysToAdd); + } + + public void updateSession( + LocalTime startAt, String title, String description, Difficulty difficulty, StudyStatus status) { + session = Session.generateSession(startAt, title, description, difficulty, status); + } + + public void validateAssignmentSubmittable(LocalDateTime now) { + if (now.isBefore(period.getStartDate())) { + throw new CustomException(ASSIGNMENT_SUBMIT_NOT_STARTED); + } + assignment.validateSubmittable(now); + } } diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyDetailValidator.java b/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyDetailValidator.java index 59ab1afb8..161a1383f 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyDetailValidator.java +++ b/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyDetailValidator.java @@ -7,6 +7,7 @@ import com.gdschongik.gdsc.global.annotation.DomainService; import com.gdschongik.gdsc.global.exception.CustomException; import java.time.LocalDateTime; +import java.util.Set; @DomainService public class StudyDetailValidator { @@ -54,4 +55,16 @@ private void validateUpdateDeadline( throw new CustomException(STUDY_DETAIL_ASSIGNMENT_INVALID_UPDATE_DEADLINE); } } + + public void validateUpdateStudyDetail(Set studyDetails, Set requests) { + // StudyDetail 목록과 요청된 StudySessionCreateRequest 목록의 크기를 먼저 비교 + if (studyDetails.size() != requests.size()) { + throw new CustomException(STUDY_DETAIL_SESSION_SIZE_MISMATCH); + } + + // 두 ID 집합이 동일한지 비교하여 ID 불일치 시 예외를 던짐 + if (!studyDetails.equals(requests)) { + throw new CustomException(STUDY_DETAIL_ID_INVALID); + } + } } diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyHistory.java b/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyHistory.java index a77ad9dd3..52f2fa3f4 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyHistory.java +++ b/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyHistory.java @@ -10,6 +10,8 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -18,6 +20,7 @@ @Getter @Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(uniqueConstraints = {@UniqueConstraint(columnNames = {"member_id", "study_id"})}) public class StudyHistory extends BaseEntity { @Id @@ -27,20 +30,29 @@ public class StudyHistory extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id") - private Member mentee; + private Member student; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "study_id") private Study study; + private String repositoryLink; + @Builder(access = AccessLevel.PRIVATE) - private StudyHistory(Member mentee, Study study) { - this.mentee = mentee; + private StudyHistory(Member student, Study study) { + this.student = student; this.study = study; } - public static StudyHistory create(Member mentee, Study study) { - return StudyHistory.builder().mentee(mentee).study(study).build(); + public static StudyHistory create(Member student, Study study) { + return StudyHistory.builder().student(student).study(study).build(); + } + + /** + * 레포지토리 링크를 업데이트합니다. + */ + public void updateRepositoryLink(String repositoryLink) { + this.repositoryLink = repositoryLink; } // 데이터 전달 로직 diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyHistoryValidator.java b/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyHistoryValidator.java index aed358292..2e2d19d82 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyHistoryValidator.java +++ b/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyHistoryValidator.java @@ -24,6 +24,7 @@ public void validateApplyStudy(Study study, List currentMemberStud } // 이미 듣고 있는 스터디가 있는 경우 + // todo: StudyHistory가 아닌 Study의 isOngoning 호출하도록 수정 boolean isInOngoingStudy = currentMemberStudyHistories.stream().anyMatch(StudyHistory::isStudyOngoing); if (isInOngoingStudy) { @@ -37,4 +38,17 @@ public void validateCancelStudyApply(Study study) { throw new CustomException(STUDY_NOT_CANCELABLE_APPLICATION_PERIOD); } } + + public void validateUpdateRepository( + boolean isAnyAssignmentSubmitted, String repositoryOwnerOauthId, String currentMemberOauthId) { + // 이미 제출한 과제가 있는 경우 + if (isAnyAssignmentSubmitted) { + throw new CustomException(STUDY_HISTORY_REPOSITORY_NOT_UPDATABLE_ASSIGNMENT_ALREADY_SUBMITTED); + } + + // 레포지토리 소유자가 현 멤버가 아닌 경우 + if (!repositoryOwnerOauthId.equals(currentMemberOauthId)) { + throw new CustomException(STUDY_HISTORY_REPOSITORY_NOT_UPDATABLE_OWNER_MISMATCH); + } + } } diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyValidator.java b/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyValidator.java new file mode 100644 index 000000000..93a95deab --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/domain/StudyValidator.java @@ -0,0 +1,45 @@ +package com.gdschongik.gdsc.domain.study.domain; + +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; + +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.global.annotation.DomainService; +import com.gdschongik.gdsc.global.exception.CustomException; +import java.util.Optional; + +@DomainService +public class StudyValidator { + public void validateStudyMentor(Member currentMember, Study study) { + // 어드민인 경우 검증 통과 + if (currentMember.isAdmin()) { + return; + } + + // 멘토인지 검증 + if (!currentMember.isMentor()) { + throw new CustomException(STUDY_ACCESS_NOT_ALLOWED); + } + + // 해당 스터디의 담당 멘토인지 검증 + if (!currentMember.getId().equals(study.getMentor().getId())) { + throw new CustomException(STUDY_MENTOR_INVALID); + } + } + + public void validateStudyMentorOrStudent(Member currentMember, Study study, Optional studyHistory) { + // 어드민인 경우 검증 통과 + if (currentMember.isAdmin()) { + return; + } + + // 해당 스터디의 수강생인지 검증 + if (currentMember.isStudent() && studyHistory.isEmpty()) { + throw new CustomException(STUDY_ACCESS_NOT_ALLOWED); + } + + // 해당 스터디의 담당 멘토인지 검증 + if (!currentMember.getId().equals(study.getMentor().getId())) { + throw new CustomException(STUDY_MENTOR_INVALID); + } + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/domain/SubmissionFailureType.java b/src/main/java/com/gdschongik/gdsc/domain/study/domain/SubmissionFailureType.java index 252b556e9..74e38fb92 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/study/domain/SubmissionFailureType.java +++ b/src/main/java/com/gdschongik/gdsc/domain/study/domain/SubmissionFailureType.java @@ -6,9 +6,12 @@ @Getter @RequiredArgsConstructor public enum SubmissionFailureType { - NOT_SUBMITTED("미제출"), + NONE("실패 없음"), // 제출상태 성공 시 사용 + NOT_SUBMITTED("미제출"), // 기본값 WORD_COUNT_INSUFFICIENT("글자수 부족"), - LOCATION_UNIDENTIFIABLE("위치 확인불가"); + LOCATION_UNIDENTIFIABLE("위치 확인불가"), + UNKNOWN("알 수 없음"), + ; private final String value; } diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/domain/vo/Assignment.java b/src/main/java/com/gdschongik/gdsc/domain/study/domain/vo/Assignment.java index ea480bed8..38be69b1f 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/study/domain/vo/Assignment.java +++ b/src/main/java/com/gdschongik/gdsc/domain/study/domain/vo/Assignment.java @@ -4,12 +4,14 @@ import static com.gdschongik.gdsc.domain.study.domain.StudyStatus.CANCELLED; import static com.gdschongik.gdsc.global.exception.ErrorCode.*; -import com.gdschongik.gdsc.domain.study.domain.Difficulty; import com.gdschongik.gdsc.domain.study.domain.StudyStatus; +import com.gdschongik.gdsc.global.exception.CustomException; import jakarta.persistence.Column; import jakarta.persistence.Embeddable; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; +import java.time.DayOfWeek; +import java.time.LocalDate; import java.time.LocalDateTime; import lombok.AccessLevel; import lombok.Builder; @@ -32,20 +34,15 @@ public class Assignment { @Column(columnDefinition = "TEXT") private String descriptionLink; - @Enumerated(EnumType.STRING) - private Difficulty difficulty; - @Comment("과제 상태") @Enumerated(EnumType.STRING) private StudyStatus status; @Builder(access = AccessLevel.PRIVATE) - private Assignment( - String title, LocalDateTime deadline, String descriptionLink, Difficulty difficulty, StudyStatus status) { + private Assignment(String title, LocalDateTime deadline, String descriptionLink, StudyStatus status) { this.title = title; this.deadline = deadline; this.descriptionLink = descriptionLink; - this.difficulty = difficulty; this.status = status; } @@ -65,4 +62,50 @@ public static Assignment generateAssignment(String title, LocalDateTime deadline .status(StudyStatus.OPEN) .build(); } + + public void validateSubmittable(LocalDateTime now) { + if (status == NONE) { + throw new CustomException(ASSIGNMENT_SUBMIT_NOT_PUBLISHED); + } + + if (status == CANCELLED) { + throw new CustomException(ASSIGNMENT_SUBMIT_CANCELLED); + } + + if (now.isAfter(deadline)) { + throw new CustomException(ASSIGNMENT_SUBMIT_DEADLINE_PASSED); + } + } + + // 데이터 전달 로직 + + public boolean isOpen() { + return status == OPEN; + } + + public boolean isCancelled() { + return status == CANCELLED; + } + + public boolean isNone() { + return status == NONE; + } + + public boolean isDeadlineRemaining() { + LocalDateTime now = LocalDateTime.now(); + return now.isBefore(deadline); + } + + public boolean isDeadLineThisWeek() { + // 현재 날짜와 마감일의 날짜 부분을 비교할 것이므로 LocalDate로 변환 + LocalDate now = LocalDate.now(); + LocalDate startOfWeek = now.with(DayOfWeek.MONDAY); // 이번 주 월요일 + LocalDate endOfWeek = now.with(DayOfWeek.SUNDAY); // 이번 주 일요일 + + // 마감일의 날짜 부분을 가져옴 + LocalDate deadlineDate = deadline.toLocalDate(); + + // 마감일이 이번 주 내에 있는지 확인 + return !deadlineDate.isBefore(startOfWeek) && !deadlineDate.isAfter(endOfWeek); + } } diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/domain/vo/Session.java b/src/main/java/com/gdschongik/gdsc/domain/study/domain/vo/Session.java index d32aba4cc..07a2282c0 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/study/domain/vo/Session.java +++ b/src/main/java/com/gdschongik/gdsc/domain/study/domain/vo/Session.java @@ -5,7 +5,7 @@ import jakarta.persistence.Embeddable; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; -import java.time.LocalDateTime; +import java.time.LocalTime; import lombok.AccessLevel; import lombok.Builder; import lombok.EqualsAndHashCode; @@ -19,7 +19,7 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Session { - private LocalDateTime startAt; + private LocalTime startAt; private String title; @@ -33,8 +33,7 @@ public class Session { private StudyStatus status; @Builder(access = AccessLevel.PRIVATE) - private Session( - LocalDateTime startAt, String title, String description, Difficulty difficulty, StudyStatus status) { + private Session(LocalTime startAt, String title, String description, Difficulty difficulty, StudyStatus status) { this.startAt = startAt; this.title = title; this.description = description; @@ -45,4 +44,20 @@ private Session( public static Session createEmptySession() { return Session.builder().status(StudyStatus.NONE).build(); } + + public static Session generateSession( + LocalTime startAt, String title, String description, Difficulty difficulty, StudyStatus status) { + return Session.builder() + .startAt(startAt) + .title(title) + .description(description) + .difficulty(difficulty) + .status(status) + .build(); + } + + // 데이터 전달 로직 + public boolean isOpen() { + return status == StudyStatus.OPEN; + } } diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/dto/request/RepositoryUpdateRequest.java b/src/main/java/com/gdschongik/gdsc/domain/study/dto/request/RepositoryUpdateRequest.java new file mode 100644 index 000000000..7f311f37b --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dto/request/RepositoryUpdateRequest.java @@ -0,0 +1,5 @@ +package com.gdschongik.gdsc.domain.study.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record RepositoryUpdateRequest(@NotBlank String repositoryLink) {} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/dto/request/StudyAnnouncementCreateUpdateRequest.java b/src/main/java/com/gdschongik/gdsc/domain/study/dto/request/StudyAnnouncementCreateUpdateRequest.java new file mode 100644 index 000000000..12bcff77b --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dto/request/StudyAnnouncementCreateUpdateRequest.java @@ -0,0 +1,8 @@ +package com.gdschongik.gdsc.domain.study.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +public record StudyAnnouncementCreateUpdateRequest( + @NotBlank(message = "공지제목이 비었습니다.") @Schema(description = "공지제목") String title, + @Schema(description = "공지링크") String link) {} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/dto/request/StudyAttendCreateRequest.java b/src/main/java/com/gdschongik/gdsc/domain/study/dto/request/StudyAttendCreateRequest.java new file mode 100644 index 000000000..47c817d29 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dto/request/StudyAttendCreateRequest.java @@ -0,0 +1,13 @@ +package com.gdschongik.gdsc.domain.study.dto.request; + +import static com.gdschongik.gdsc.global.common.constant.RegexConstant.ATTENDANCE_NUMBER; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record StudyAttendCreateRequest( + @NotBlank + @Pattern(regexp = ATTENDANCE_NUMBER, message = "출석번호는 " + ATTENDANCE_NUMBER + " 형식이어야 합니다.") + @Schema(description = "출석번호") + String attendanceNumber) {} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/dto/request/StudyCreateRequest.java b/src/main/java/com/gdschongik/gdsc/domain/study/dto/request/StudyCreateRequest.java index cb5153e3c..a6e859f48 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/study/dto/request/StudyCreateRequest.java +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dto/request/StudyCreateRequest.java @@ -6,6 +6,7 @@ import com.gdschongik.gdsc.domain.study.domain.StudyType; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Future; +import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; import java.time.DayOfWeek; @@ -17,6 +18,7 @@ public record StudyCreateRequest( @NotNull(message = "학년도는 null이 될 수 없습니다.") @Schema(description = "학년도", pattern = ACADEMIC_YEAR) Integer academicYear, @NotNull(message = "학기는 null이 될 수 없습니다.") @Schema(description = "학기") SemesterType semesterType, + @NotBlank(message = "스터디 제목을 입력해 주세요.") @Schema(description = "제목") String title, @NotNull(message = "신청기간 시작일은 null이 될 수 없습니다.") @Schema(description = "신청기간 시작일", pattern = DATE) LocalDate applicationStartDate, @Future @NotNull(message = "신청기간 종료일은 null이 될 수 없습니다.") @Schema(description = "신청기간 종료일", pattern = DATE) @@ -24,9 +26,7 @@ public record StudyCreateRequest( @Positive @NotNull(message = "총 주차수는 null이 될 수 없습니다.") @Schema(description = "총 주차수") Long totalWeek, @Future @NotNull(message = "스터디 시작일은 null이 될 수 없습니다.") @Schema(description = "스터디 시작일", pattern = DATE) LocalDate startDate, - @NotNull(message = "스터디 요일은 null이 될 수 없습니다.") @Schema(description = "스터디 요일", implementation = DayOfWeek.class) - DayOfWeek dayOfWeek, - @NotNull @Schema(description = "스터디 시작 시간", implementation = LocalTime.class) LocalTime studyStartTime, - @NotNull @Schema(description = "스터디 종료 시간", implementation = LocalTime.class) LocalTime studyEndTime, - @NotNull(message = "스터디 타입은 null이 될 수 없습니다.") @Schema(description = "스터디 타입", implementation = StudyType.class) - StudyType studyType) {} + @NotNull(message = "스터디 요일은 null이 될 수 없습니다.") @Schema(description = "스터디 요일") DayOfWeek dayOfWeek, + @NotNull @Schema(description = "스터디 시작 시간") LocalTime studyStartTime, + @NotNull @Schema(description = "스터디 종료 시간") LocalTime studyEndTime, + @NotNull(message = "스터디 타입은 null이 될 수 없습니다.") @Schema(description = "스터디 타입") StudyType studyType) {} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/dto/request/StudySessionCreateRequest.java b/src/main/java/com/gdschongik/gdsc/domain/study/dto/request/StudySessionCreateRequest.java new file mode 100644 index 000000000..fd511dcd0 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dto/request/StudySessionCreateRequest.java @@ -0,0 +1,13 @@ +package com.gdschongik.gdsc.domain.study.dto.request; + +import com.gdschongik.gdsc.domain.study.domain.Difficulty; +import com.gdschongik.gdsc.domain.study.domain.StudyStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record StudySessionCreateRequest( + @NotNull Long studyDetailId, + @Schema(description = "제목") String title, + @Schema(description = "설명") String description, + @Schema(description = "난이도") Difficulty difficulty, + @Schema(description = "휴강 여부") StudyStatus status) {} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/dto/request/StudyUpdateRequest.java b/src/main/java/com/gdschongik/gdsc/domain/study/dto/request/StudyUpdateRequest.java new file mode 100644 index 000000000..326c05831 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dto/request/StudyUpdateRequest.java @@ -0,0 +1,9 @@ +package com.gdschongik.gdsc.domain.study.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +public record StudyUpdateRequest( + @Schema(description = "스터디 소개 페이지 링크") String notionLink, + @Schema(description = "스터디 한 줄 소개") String introduction, + List studySessions) {} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/AssignmentDashboardResponse.java b/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/AssignmentDashboardResponse.java new file mode 100644 index 000000000..12474bb19 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/AssignmentDashboardResponse.java @@ -0,0 +1,14 @@ +package com.gdschongik.gdsc.domain.study.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +public record AssignmentDashboardResponse( + @Schema(description = "레포지토리 링크") String repositoryLink, + @Schema(description = "링크 수정 가능 여부") boolean isLinkEditable, + @Schema(description = "제출 가능한 과제") List submittableAssignments) { + public static AssignmentDashboardResponse of( + String repositoryLink, boolean isAnySubmitted, List submittableAssignments) { + return new AssignmentDashboardResponse(repositoryLink, !isAnySubmitted, submittableAssignments); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/AssignmentHistoryResponse.java b/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/AssignmentHistoryResponse.java new file mode 100644 index 000000000..2c7bbf997 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/AssignmentHistoryResponse.java @@ -0,0 +1,32 @@ +package com.gdschongik.gdsc.domain.study.dto.response; + +import com.gdschongik.gdsc.domain.study.domain.AssignmentHistory; +import com.gdschongik.gdsc.domain.study.domain.AssignmentSubmissionStatus; +import com.gdschongik.gdsc.domain.study.domain.StudyStatus; +import com.gdschongik.gdsc.domain.study.domain.SubmissionFailureType; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; + +public record AssignmentHistoryResponse( + Long assignmentHistoryId, + @Schema(description = "과제 휴강 여부") StudyStatus status, + @Schema(description = "과제 제목") String title, + @Schema(description = "마감 기한") LocalDateTime deadline, + @Schema(description = "과제 명세 링크") String descriptionLink, + @Schema(description = "과제 제출 링크") String submissionLink, + @Schema(description = "과제 제출 상태") AssignmentSubmissionStatus assignmentSubmissionStatus, + @Schema(description = "과제 제출 실패 사유") SubmissionFailureType submissionFailureType, + @Schema(description = "주차") Long week) { + public static AssignmentHistoryResponse from(AssignmentHistory assignmentHistory) { + return new AssignmentHistoryResponse( + assignmentHistory.getId(), + assignmentHistory.getStudyDetail().getAssignment().getStatus(), + assignmentHistory.getStudyDetail().getAssignment().getTitle(), + assignmentHistory.getStudyDetail().getAssignment().getDeadline(), + assignmentHistory.getStudyDetail().getAssignment().getDescriptionLink(), + assignmentHistory.getSubmissionLink(), + assignmentHistory.getSubmissionStatus(), + assignmentHistory.getSubmissionFailureType(), + assignmentHistory.getStudyDetail().getWeek()); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/AssignmentHistoryStatusResponse.java b/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/AssignmentHistoryStatusResponse.java new file mode 100644 index 000000000..3209dc8cc --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/AssignmentHistoryStatusResponse.java @@ -0,0 +1,54 @@ +package com.gdschongik.gdsc.domain.study.dto.response; + +import static com.gdschongik.gdsc.domain.study.domain.SubmissionFailureType.NOT_SUBMITTED; + +import com.gdschongik.gdsc.domain.study.domain.*; +import com.gdschongik.gdsc.domain.study.domain.vo.Assignment; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.annotation.Nullable; +import java.time.LocalDateTime; + +public record AssignmentHistoryStatusResponse( + Long studyDetailId, + @Schema(description = "과제 상태") StudyStatus assignmentStatus, + @Schema(description = "주차") Long week, + @Nullable @Schema(description = "과제 제목") String title, + // TODO 추후 처리 예정 + @Nullable @Schema(description = "과제 제출 상태") AssignmentSubmissionStatus assignmentSubmissionStatus, + @Nullable @Schema(description = "과제 명세 링크") String descriptionLink, + @Nullable @Schema(description = "마감 기한") LocalDateTime deadline, + @Nullable @Schema(description = "과제 제출 링크") String submissionLink, + @Nullable @Schema(description = "과제 제출 실패 사유. 제출 여부도 포함되어 있습니다. 미제출 상태라면 기본 과제 정보만 반환합니다.") + SubmissionFailureType submissionFailureType, + @Nullable @Schema(description = "최종 수정 일시") LocalDateTime committedAt) { + + // 과제 제출이 없는 경우, 과제 정보만 사용하여 AssignmentHistoryStatusResponse 생성 + public static AssignmentHistoryStatusResponse of(StudyDetail studyDetail, AssignmentHistory assignmentHistory) { + if (assignmentHistory == null) { + return new AssignmentHistoryStatusResponse( + studyDetail.getId(), + studyDetail.getAssignment().getStatus(), + studyDetail.getWeek(), + studyDetail.getAssignment().getTitle(), + null, + studyDetail.getAssignment().getDescriptionLink(), + studyDetail.getAssignment().getDeadline(), + null, + NOT_SUBMITTED, + null); + } + + Assignment assignment = studyDetail.getAssignment(); + return new AssignmentHistoryStatusResponse( + studyDetail.getId(), + assignment.getStatus(), + studyDetail.getWeek(), + assignment.getTitle(), + assignmentHistory.getSubmissionStatus(), + assignment.getDescriptionLink(), + assignment.getDeadline(), + assignmentHistory.getSubmissionLink(), + assignmentHistory.getSubmissionFailureType(), + assignmentHistory.getCommittedAt()); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/AssignmentResponse.java b/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/AssignmentResponse.java index 33414fed9..3f0656984 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/AssignmentResponse.java +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/AssignmentResponse.java @@ -4,11 +4,13 @@ import com.gdschongik.gdsc.domain.study.domain.StudyStatus; import com.gdschongik.gdsc.domain.study.domain.vo.Assignment; import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; public record AssignmentResponse( Long studyDetailId, @Schema(description = "과제 제목") String title, - @Schema(description = "마감 기한") String deadline, + @Schema(description = "마감 기한") LocalDateTime deadline, + @Schema(description = "주차") Long week, @Schema(description = "과제 명세 링크") String descriptionLink, @Schema(description = "과제 상태") StudyStatus assignmentStatus) { public static AssignmentResponse from(StudyDetail studyDetail) { @@ -16,7 +18,8 @@ public static AssignmentResponse from(StudyDetail studyDetail) { return new AssignmentResponse( studyDetail.getId(), assignment.getTitle(), - assignment.getDeadline().toString(), + assignment.getDeadline(), + studyDetail.getWeek(), assignment.getDescriptionLink(), assignment.getStatus()); } diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/AssignmentSubmissionStatusResponse.java b/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/AssignmentSubmissionStatusResponse.java new file mode 100644 index 000000000..50fb311df --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/AssignmentSubmissionStatusResponse.java @@ -0,0 +1,26 @@ +package com.gdschongik.gdsc.domain.study.dto.response; + +import com.gdschongik.gdsc.domain.study.domain.AssignmentHistory; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum AssignmentSubmissionStatusResponse { + NOT_SUBMITTED("미제출"), + FAILURE("제출 실패"), + SUCCESS("제출 성공"); + + private final String value; + + public static AssignmentSubmissionStatusResponse from(AssignmentHistory assignmentHistory) { + if (assignmentHistory == null) { + return NOT_SUBMITTED; + } + if (assignmentHistory.isSuccess()) { + return SUCCESS; + } else { + return FAILURE; + } + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/AssignmentSubmittableDto.java b/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/AssignmentSubmittableDto.java new file mode 100644 index 000000000..5935778c8 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/AssignmentSubmittableDto.java @@ -0,0 +1,63 @@ +package com.gdschongik.gdsc.domain.study.dto.response; + +import com.gdschongik.gdsc.domain.study.domain.AssignmentHistory; +import com.gdschongik.gdsc.domain.study.domain.AssignmentSubmissionStatus; +import com.gdschongik.gdsc.domain.study.domain.StudyDetail; +import com.gdschongik.gdsc.domain.study.domain.StudyStatus; +import com.gdschongik.gdsc.domain.study.domain.SubmissionFailureType; +import com.gdschongik.gdsc.domain.study.domain.vo.Assignment; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.annotation.Nullable; +import java.time.LocalDateTime; + +public record AssignmentSubmittableDto( + Long studyDetailId, + @Schema(description = "과제 상태") StudyStatus assignmentStatus, + @Schema(description = "주차") Long week, + @Nullable @Schema(description = "과제 제목") String title, + @Nullable @Schema(description = "과제 제출 상태") AssignmentSubmissionStatus assignmentSubmissionStatus, + @Nullable @Schema(description = "과제 명세 링크") String descriptionLink, + @Nullable @Schema(description = "마감 기한") LocalDateTime deadline, + @Nullable @Schema(description = "과제 제출 링크") String submissionLink, + @Nullable @Schema(description = "과제 제출 실패 사유") SubmissionFailureType submissionFailureType) { + public static AssignmentSubmittableDto of(StudyDetail studyDetail, AssignmentHistory assignmentHistory) { + Assignment assignment = studyDetail.getAssignment(); + + if (assignment.isCancelled()) { + return cancelledAssignment(studyDetail, assignment); + } + + if (assignmentHistory == null) { + return notSubmittedAssignment(studyDetail, assignment); + } + + return new AssignmentSubmittableDto( + studyDetail.getId(), + assignment.getStatus(), + studyDetail.getWeek(), + assignment.getTitle(), + assignmentHistory.getSubmissionStatus(), + assignment.getDescriptionLink(), + assignment.getDeadline(), + assignmentHistory.getSubmissionLink(), + assignmentHistory.getSubmissionFailureType()); + } + + private static AssignmentSubmittableDto cancelledAssignment(StudyDetail studyDetail, Assignment assignment) { + return new AssignmentSubmittableDto( + studyDetail.getId(), assignment.getStatus(), studyDetail.getWeek(), null, null, null, null, null, null); + } + + private static AssignmentSubmittableDto notSubmittedAssignment(StudyDetail studyDetail, Assignment assignment) { + return new AssignmentSubmittableDto( + studyDetail.getId(), + assignment.getStatus(), + studyDetail.getWeek(), + assignment.getTitle(), + AssignmentSubmissionStatus.FAILURE, + assignment.getDescriptionLink(), + assignment.getDeadline(), + null, + SubmissionFailureType.NOT_SUBMITTED); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/AttendanceStatusResponse.java b/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/AttendanceStatusResponse.java new file mode 100644 index 000000000..3eb730bd9 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/AttendanceStatusResponse.java @@ -0,0 +1,29 @@ +package com.gdschongik.gdsc.domain.study.dto.response; + +import com.gdschongik.gdsc.domain.study.domain.StudyDetail; +import java.time.LocalDate; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +// 응답용 enum +@Getter +@RequiredArgsConstructor +public enum AttendanceStatusResponse { + ATTENDED("출석"), + NOT_ATTENDED("미출석"), + BEFORE_ATTENDANCE("출석전"); + + private final String value; + + public static AttendanceStatusResponse of(StudyDetail studyDetail, LocalDate now, boolean isAttended) { + if (studyDetail.getAttendanceDay().isAfter(now)) { + return BEFORE_ATTENDANCE; + } + + if (isAttended) { + return ATTENDED; + } else { + return NOT_ATTENDED; + } + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/CommonStudyResponse.java b/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/CommonStudyResponse.java new file mode 100644 index 000000000..096838a9d --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/CommonStudyResponse.java @@ -0,0 +1,40 @@ +package com.gdschongik.gdsc.domain.study.dto.response; + +import com.gdschongik.gdsc.domain.common.model.SemesterType; +import com.gdschongik.gdsc.domain.recruitment.domain.vo.Period; +import com.gdschongik.gdsc.domain.study.domain.Study; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.DayOfWeek; +import java.time.LocalTime; + +public record CommonStudyResponse( + Long studyId, + @Schema(description = "이름") String title, + @Schema(description = "활동 년도") Integer academicYear, + @Schema(description = "활동 학기") SemesterType semester, + @Schema(description = "종류") String studyType, + @Schema(description = "상세설명 노션 링크") String notionLink, + @Schema(description = "한 줄 소개") String introduction, + @Schema(description = "멘토 이름") String mentorName, + @Schema(description = "스터디 요일") DayOfWeek dayOfWeek, + @Schema(description = "스터디 시작 시간") LocalTime startTime, + @Schema(description = "스터디 종료 시간") LocalTime endTime, + @Schema(description = "총 주차수") Long totalWeek, + @Schema(description = "총 기간") Period period) { + public static CommonStudyResponse from(Study study) { + return new CommonStudyResponse( + study.getId(), + study.getTitle(), + study.getAcademicYear(), + study.getSemesterType(), + study.getStudyType().getValue(), + study.getNotionLink(), + study.getIntroduction(), + study.getMentor().getName(), + study.getDayOfWeek(), + study.getStartTime(), + study.getEndTime(), + study.getTotalWeek(), + study.getPeriod()); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/MentorStudyResponse.java b/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/MentorStudyResponse.java new file mode 100644 index 000000000..7b0e710ec --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/MentorStudyResponse.java @@ -0,0 +1,25 @@ +package com.gdschongik.gdsc.domain.study.dto.response; + +import com.gdschongik.gdsc.domain.study.domain.Study; +import com.gdschongik.gdsc.domain.study.domain.StudyType; +import com.gdschongik.gdsc.global.util.formatter.SemesterFormatter; +import io.swagger.v3.oas.annotations.media.Schema; + +public record MentorStudyResponse( + Long studyId, + @Schema(description = "활동 학기") String semester, + @Schema(description = "이름") String title, + @Schema(description = "종류") StudyType studyType, + @Schema(description = "상세설명 노션 링크") String notionLink, + @Schema(description = "멘토 이름") String mentorName) { + + public static MentorStudyResponse from(Study study) { + return new MentorStudyResponse( + study.getId(), + SemesterFormatter.format(study.getAcademicYear(), study.getSemesterType()), + study.getTitle(), + study.getStudyType(), + study.getNotionLink(), + study.getMentor().getName()); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/StudentMyCurrentStudyResponse.java b/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/StudentMyCurrentStudyResponse.java new file mode 100644 index 000000000..707c68703 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/StudentMyCurrentStudyResponse.java @@ -0,0 +1,12 @@ +package com.gdschongik.gdsc.domain.study.dto.response; + +import com.gdschongik.gdsc.domain.study.domain.StudyHistory; + +public record StudentMyCurrentStudyResponse(Long studyId) { + public static StudentMyCurrentStudyResponse from(StudyHistory studyHistory) { + if (studyHistory == null) { + return new StudentMyCurrentStudyResponse(null); + } + return new StudentMyCurrentStudyResponse(studyHistory.getStudy().getId()); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/StudyAnnouncementResponse.java b/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/StudyAnnouncementResponse.java new file mode 100644 index 000000000..22a95f0d8 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/StudyAnnouncementResponse.java @@ -0,0 +1,20 @@ +package com.gdschongik.gdsc.domain.study.dto.response; + +import com.gdschongik.gdsc.domain.study.domain.StudyAnnouncement; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDate; + +public record StudyAnnouncementResponse( + Long studyAnnounceId, + @Schema(description = "제목") String title, + @Schema(description = "링크") String link, + @Schema(description = "생성 일자") LocalDate createdDate) { + + public static StudyAnnouncementResponse from(StudyAnnouncement studyAnnouncement) { + return new StudyAnnouncementResponse( + studyAnnouncement.getId(), + studyAnnouncement.getTitle(), + studyAnnouncement.getLink(), + studyAnnouncement.getCreatedAt().toLocalDate()); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/StudyApplicableResponse.java b/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/StudyApplicableResponse.java new file mode 100644 index 000000000..6fcaf74d3 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/StudyApplicableResponse.java @@ -0,0 +1,13 @@ +package com.gdschongik.gdsc.domain.study.dto.response; + +import com.gdschongik.gdsc.domain.study.domain.Study; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.annotation.Nullable; +import java.util.List; + +public record StudyApplicableResponse( + @Nullable @Schema(description = "이미 신청한 스터디 id") Long appliedStudyId, List studyResponses) { + public static StudyApplicableResponse of(Study study, List studyResponses) { + return new StudyApplicableResponse(study == null ? null : study.getId(), studyResponses); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/StudyResponse.java b/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/StudyResponse.java index 56b531e81..e1594935b 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/StudyResponse.java +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/StudyResponse.java @@ -1,50 +1,43 @@ package com.gdschongik.gdsc.domain.study.dto.response; +import com.gdschongik.gdsc.domain.common.model.SemesterType; import com.gdschongik.gdsc.domain.study.domain.Study; import io.swagger.v3.oas.annotations.media.Schema; import java.time.DayOfWeek; +import java.time.LocalDateTime; import java.time.LocalTime; -import java.time.format.DateTimeFormatter; public record StudyResponse( Long studyId, + @Schema(description = "학년도") Integer academicYear, + @Schema(description = "학기") SemesterType semesterType, @Schema(description = "이름") String title, @Schema(description = "종류") String studyType, @Schema(description = "상세설명 노션 링크") String notionLink, @Schema(description = "한 줄 소개") String introduction, @Schema(description = "멘토 이름") String mentorName, - @Schema(description = "스터디 시간") String schedule, - @Schema(description = "총 주차수") String totalWeek, - @Schema(description = "개강일") String openingDate) { + @Schema(description = "스터디 요일") DayOfWeek dayOfWeek, + @Schema(description = "스터디 시작 시간") LocalTime startTime, + @Schema(description = "스터디 종료 시간") LocalTime endTime, + @Schema(description = "총 주차수") Long totalWeek, + @Schema(description = "개강일") LocalDateTime openingDate, + @Schema(description = "신청 종료일") LocalDateTime applicationEndDate) { public static StudyResponse from(Study study) { - // todo: 포맷터로 분리 return new StudyResponse( study.getId(), + study.getAcademicYear(), + study.getSemesterType(), study.getTitle(), study.getStudyType().getValue(), study.getNotionLink(), study.getIntroduction(), study.getMentor().getName(), - getSchedule(study.getDayOfWeek(), study.getStartTime()), - study.getTotalWeek().toString() + "주 코스", - DateTimeFormatter.ofPattern("MM.dd").format(study.getPeriod().getStartDate()) + " 개강"); - } - - private static String getSchedule(DayOfWeek dayOfWeek, LocalTime startTime) { - return getKoreanDayOfWeek(dayOfWeek) + startTime.format(DateTimeFormatter.ofPattern("HH")) + "시"; - } - - private static String getKoreanDayOfWeek(DayOfWeek dayOfWeek) { - return switch (dayOfWeek) { - case MONDAY -> "월"; - case TUESDAY -> "화"; - case WEDNESDAY -> "수"; - case THURSDAY -> "목"; - case FRIDAY -> "금"; - case SATURDAY -> "토"; - case SUNDAY -> "일"; - default -> ""; - }; + study.getDayOfWeek(), + study.getStartTime(), + study.getEndTime(), + study.getTotalWeek(), + study.getPeriod().getStartDate(), + study.getApplicationPeriod().getEndDate()); } } diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/StudySessionResponse.java b/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/StudySessionResponse.java new file mode 100644 index 000000000..874b6cfb5 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/StudySessionResponse.java @@ -0,0 +1,21 @@ +package com.gdschongik.gdsc.domain.study.dto.response; + +import com.gdschongik.gdsc.domain.recruitment.domain.vo.Period; +import com.gdschongik.gdsc.domain.study.domain.Difficulty; +import com.gdschongik.gdsc.domain.study.domain.StudyDetail; +import com.gdschongik.gdsc.domain.study.domain.vo.Session; + +public record StudySessionResponse( + Long studyDetailId, Period period, Long week, String title, String description, Difficulty difficulty) { + + public static StudySessionResponse from(StudyDetail studyDetail) { + Session session = studyDetail.getSession(); + return new StudySessionResponse( + studyDetail.getId(), + studyDetail.getPeriod(), + studyDetail.getWeek(), + session.getTitle(), + session.getDescription(), + session.getDifficulty()); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/StudyStudentResponse.java b/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/StudyStudentResponse.java new file mode 100644 index 000000000..fd616fdb2 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/StudyStudentResponse.java @@ -0,0 +1,22 @@ +package com.gdschongik.gdsc.domain.study.dto.response; + +import com.gdschongik.gdsc.domain.study.domain.StudyHistory; +import io.swagger.v3.oas.annotations.media.Schema; + +public record StudyStudentResponse( + @Schema(description = "멤버 아이디") Long memberId, + @Schema(description = "학생 이름") String name, + @Schema(description = "학번") String studentId, + @Schema(description = "디스코드 사용자명") String discordUsername, + @Schema(description = "디스코드 닉네임") String nickname, + @Schema(description = "깃허브 링크") String githubLink) { + public static StudyStudentResponse from(StudyHistory studyHistory) { + return new StudyStudentResponse( + studyHistory.getStudent().getId(), + studyHistory.getStudent().getName(), + studyHistory.getStudent().getStudentId(), + studyHistory.getStudent().getDiscordUsername(), + studyHistory.getStudent().getNickname(), + studyHistory.getRepositoryLink()); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/StudyStudentSessionResponse.java b/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/StudyStudentSessionResponse.java new file mode 100644 index 000000000..b903bd230 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/StudyStudentSessionResponse.java @@ -0,0 +1,44 @@ +package com.gdschongik.gdsc.domain.study.dto.response; + +import static com.gdschongik.gdsc.domain.study.domain.SubmissionFailureType.NOT_SUBMITTED; + +import com.gdschongik.gdsc.domain.recruitment.domain.vo.Period; +import com.gdschongik.gdsc.domain.study.domain.AssignmentHistory; +import com.gdschongik.gdsc.domain.study.domain.Difficulty; +import com.gdschongik.gdsc.domain.study.domain.StudyDetail; +import com.gdschongik.gdsc.domain.study.domain.StudyStatus; +import com.gdschongik.gdsc.domain.study.domain.SubmissionFailureType; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; + +public record StudyStudentSessionResponse( + Long studyDetailId, + @Schema(description = "기간") Period period, + @Schema(description = "주차수") Long week, + @Schema(description = "제목") String title, + @Schema(description = "설명") String description, + @Schema(description = "세션 상태") StudyStatus sessionStatus, + @Schema(description = "난이도") Difficulty difficulty, + @Schema(description = "출석 상태") AttendanceStatusResponse attendanceStatus, + @Schema(description = "과제 개설 상태") StudyStatus assignmentStatus, + @Schema(description = "과제 제출 상태") AssignmentSubmissionStatusResponse assignmentSubmissionStatus, + @Schema(description = "과제 실패 타입") SubmissionFailureType submissionFailureType, + @Schema(description = "과제 제출 링크") String submissionLink) { + + public static StudyStudentSessionResponse of( + StudyDetail studyDetail, AssignmentHistory assignmentHistory, boolean isAttended, LocalDateTime now) { + return new StudyStudentSessionResponse( + studyDetail.getId(), + studyDetail.getPeriod(), + studyDetail.getWeek(), + studyDetail.getSession().getTitle(), + studyDetail.getSession().getDescription(), + studyDetail.getSession().getStatus(), + studyDetail.getSession().getDifficulty(), + AttendanceStatusResponse.of(studyDetail, now.toLocalDate(), isAttended), + studyDetail.getAssignment().getStatus(), + AssignmentSubmissionStatusResponse.from(assignmentHistory), + assignmentHistory != null ? assignmentHistory.getSubmissionFailureType() : NOT_SUBMITTED, + assignmentHistory != null ? assignmentHistory.getSubmissionLink() : null); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/StudyTodoResponse.java b/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/StudyTodoResponse.java new file mode 100644 index 000000000..7399b6181 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dto/response/StudyTodoResponse.java @@ -0,0 +1,53 @@ +package com.gdschongik.gdsc.domain.study.dto.response; + +import static com.gdschongik.gdsc.domain.study.dto.response.StudyTodoResponse.StudyTodoType.ASSIGNMENT; +import static com.gdschongik.gdsc.domain.study.dto.response.StudyTodoResponse.StudyTodoType.ATTENDANCE; + +import com.gdschongik.gdsc.domain.study.domain.AssignmentHistory; +import com.gdschongik.gdsc.domain.study.domain.StudyDetail; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDate; +import java.time.LocalDateTime; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +public record StudyTodoResponse( + Long studyDetailId, + @Schema(description = "현 주차수") Long week, + @Schema(description = "할일 타입") StudyTodoType todoType, + @Schema(description = "마감 시각") LocalDateTime deadLine, + @Schema(description = "출석 상태 (출석타입일 때만 사용)") AttendanceStatusResponse attendanceStatus, + @Schema(description = "과제 제목 (과제타입일 때만 사용)") String assignmentTitle, + @Schema(description = "과제 제출 상태 (과제타입일 때만 사용)") AssignmentSubmissionStatusResponse assignmentSubmissionStatus) { + + public static StudyTodoResponse createAttendanceType(StudyDetail studyDetail, LocalDate now, boolean isAttended) { + return new StudyTodoResponse( + studyDetail.getId(), + studyDetail.getWeek(), + ATTENDANCE, + studyDetail.getAttendanceDay().atTime(23, 59, 59), + AttendanceStatusResponse.of(studyDetail, now, isAttended), + null, + null); + } + + public static StudyTodoResponse createAssignmentType(StudyDetail studyDetail, AssignmentHistory assignmentHistory) { + return new StudyTodoResponse( + studyDetail.getId(), + studyDetail.getWeek(), + ASSIGNMENT, + studyDetail.getAssignment().getDeadline(), + null, + studyDetail.getAssignment().getTitle(), + AssignmentSubmissionStatusResponse.from(assignmentHistory)); + } + + @Getter + @RequiredArgsConstructor + public enum StudyTodoType { + ATTENDANCE("출석"), + ASSIGNMENT("과제"); + + private final String value; + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/factory/StudyDomainFactory.java b/src/main/java/com/gdschongik/gdsc/domain/study/factory/StudyDomainFactory.java index 3c45acb51..a8a610410 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/study/factory/StudyDomainFactory.java +++ b/src/main/java/com/gdschongik/gdsc/domain/study/factory/StudyDomainFactory.java @@ -20,6 +20,7 @@ public Study createNewStudy(StudyCreateRequest request, Member mentor) { return Study.createStudy( request.academicYear(), request.semesterType(), + request.title(), mentor, Period.createPeriod(request.startDate().atStartOfDay(), endDate.atTime(LocalTime.MAX)), Period.createPeriod( diff --git a/src/main/java/com/gdschongik/gdsc/global/common/constant/GithubConstant.java b/src/main/java/com/gdschongik/gdsc/global/common/constant/GithubConstant.java new file mode 100644 index 000000000..2ffd6b0c7 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/global/common/constant/GithubConstant.java @@ -0,0 +1,10 @@ +package com.gdschongik.gdsc.global.common.constant; + +public class GithubConstant { + + public static final String GITHUB_DOMAIN = "github.com/"; + public static final String GITHUB_ASSIGNMENT_PATH = "week%d/WIL.md"; + public static final String GITHUB_USER_API_URL = "https://api.github.com/user/%s"; + + private GithubConstant() {} +} diff --git a/src/main/java/com/gdschongik/gdsc/global/common/constant/RegexConstant.java b/src/main/java/com/gdschongik/gdsc/global/common/constant/RegexConstant.java index 6e7d42c90..afc38dc0b 100644 --- a/src/main/java/com/gdschongik/gdsc/global/common/constant/RegexConstant.java +++ b/src/main/java/com/gdschongik/gdsc/global/common/constant/RegexConstant.java @@ -12,5 +12,7 @@ public class RegexConstant { public static final String DATE = "yyyy-MM-dd"; public static final String ACADEMIC_YEAR = "^[0-9]{4}$"; + public static final String ATTENDANCE_NUMBER = "^[0-9]{4}$"; + private RegexConstant() {} } diff --git a/src/main/java/com/gdschongik/gdsc/global/common/constant/SecurityConstant.java b/src/main/java/com/gdschongik/gdsc/global/common/constant/SecurityConstant.java index 15e402d9d..3c01c44b9 100644 --- a/src/main/java/com/gdschongik/gdsc/global/common/constant/SecurityConstant.java +++ b/src/main/java/com/gdschongik/gdsc/global/common/constant/SecurityConstant.java @@ -8,6 +8,7 @@ public class SecurityConstant { public static final String GITHUB_NAME_ATTR_KEY = "id"; public static final String ACCESS_TOKEN_HEADER_PREFIX = "Bearer "; public static final String OAUTH_REDIRECT_PATH_SEGMENT = "/social-login/redirect"; + public static final String OAUTH_TARGET_URL_PARAM_NAME = "target"; private SecurityConstant() {} } diff --git a/src/main/java/com/gdschongik/gdsc/global/common/constant/UrlConstant.java b/src/main/java/com/gdschongik/gdsc/global/common/constant/UrlConstant.java index a4623c5d8..9e9989f3b 100644 --- a/src/main/java/com/gdschongik/gdsc/global/common/constant/UrlConstant.java +++ b/src/main/java/com/gdschongik/gdsc/global/common/constant/UrlConstant.java @@ -1,5 +1,8 @@ package com.gdschongik.gdsc.global.common.constant; +import java.util.List; +import java.util.stream.Stream; + public class UrlConstant { private UrlConstant() {} @@ -7,13 +10,42 @@ private UrlConstant() {} // 클라이언트 URL public static final String PROD_CLIENT_ONBOARDING_URL = "https://onboarding.gdschongik.com"; public static final String PROD_CLIENT_ADMIN_URL = "https://admin.gdschongik.com"; + public static final String PROD_CLIENT_STUDY_URL = "https://study.gdschongik.com"; + public static final String PROD_CLIENT_STUDY_MENTOR_URL = "https://mentor.study.gdschongik.com"; + public static final String DEV_CLIENT_ONBOARDING_URL = "https://dev-onboarding.gdschongik.com"; public static final String DEV_CLIENT_ADMIN_URL = "https://dev-admin.gdschongik.com"; + public static final String DEV_CLIENT_STUDY_URL = "https://dev-study.gdschongik.com"; + public static final String DEV_CLIENT_STUDY_MENTOR_URL = "https://dev-mentor.study.gdschongik.com"; + + public static final String LOCAL_CLIENT_ONBOARDING_URL = "https://local-onboarding.gdschongik.com"; + public static final String LOCAL_CLIENT_ADMIN_URL = "https://local-admin.gdschongik.com"; + public static final String LOCAL_CLIENT_STUDY_URL = "https://local-study.gdschongik.com"; + public static final String LOCAL_CLIENT_STUDY_MENTOR_URL = "https://local-mentor.study.gdschongik.com"; + public static final String LOCAL_REACT_CLIENT_URL = "http://localhost:3000"; public static final String LOCAL_REACT_CLIENT_SECURE_URL = "https://localhost:3000"; public static final String LOCAL_VITE_CLIENT_URL = "http://localhost:5173"; public static final String LOCAL_VITE_CLIENT_SECURE_URL = "https://localhost:5173"; - public static final String LOCAL_PROXY_CLIENT_ONBOARDING_URL = "https://local-onboarding.gdschongik.com"; + + public static final List PROD_CLIENT_URLS = List.of( + PROD_CLIENT_ONBOARDING_URL, PROD_CLIENT_ADMIN_URL, PROD_CLIENT_STUDY_URL, PROD_CLIENT_STUDY_MENTOR_URL); + + public static final List DEV_CLIENT_URLS = + List.of(DEV_CLIENT_ONBOARDING_URL, DEV_CLIENT_ADMIN_URL, DEV_CLIENT_STUDY_URL, DEV_CLIENT_STUDY_MENTOR_URL); + + public static final List LOCAL_CLIENT_URLS = List.of( + LOCAL_CLIENT_ONBOARDING_URL, + LOCAL_CLIENT_ADMIN_URL, + LOCAL_CLIENT_STUDY_URL, + LOCAL_CLIENT_STUDY_MENTOR_URL, + LOCAL_REACT_CLIENT_URL, + LOCAL_REACT_CLIENT_SECURE_URL, + LOCAL_VITE_CLIENT_URL, + LOCAL_VITE_CLIENT_SECURE_URL); + + public static final List DEV_AND_LOCAL_CLIENT_URLS = + Stream.concat(DEV_CLIENT_URLS.stream(), LOCAL_CLIENT_URLS.stream()).toList(); // 서버 URL public static final String PROD_SERVER_URL = "https://api.gdschongik.com"; diff --git a/src/main/java/com/gdschongik/gdsc/global/config/ObjectMapperConfig.java b/src/main/java/com/gdschongik/gdsc/global/config/ObjectMapperConfig.java new file mode 100644 index 000000000..ce69b3e8a --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/global/config/ObjectMapperConfig.java @@ -0,0 +1,110 @@ +package com.gdschongik.gdsc.global.config; + +import static com.gdschongik.gdsc.global.common.constant.RegexConstant.DATE; +import static com.gdschongik.gdsc.global.common.constant.RegexConstant.DATETIME; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.module.SimpleModule; +import java.io.IOException; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class ObjectMapperConfig { + @Bean + public ObjectMapper objectMapper() { + ObjectMapper mapper = new ObjectMapper(); + SimpleModule module = new SimpleModule(); + + // LocalDate + module.addSerializer(LocalDate.class, new LocalDateSerializer()); + module.addDeserializer(LocalDate.class, new LocalDateDeserializer()); + + // LocalDateTime + module.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer()); + module.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer()); + + // LocalTime + module.addSerializer(LocalTime.class, new LocalTimeSerializer()); + module.addDeserializer(LocalTime.class, new LocalTimeDeserializer()); + + mapper.registerModule(module); + return mapper; + } + + public class LocalDateSerializer extends JsonSerializer { + + @Override + public void serialize(LocalDate value, JsonGenerator generator, SerializerProvider serializers) + throws IOException { + generator.writeString(value.format(DateTimeFormatter.ofPattern(DATE))); + } + } + + public class LocalDateDeserializer extends JsonDeserializer { + @Override + public LocalDate deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) + throws IOException { + return LocalDate.parse(jsonParser.getValueAsString(), DateTimeFormatter.ofPattern(DATE)); + } + } + + public class LocalDateTimeSerializer extends JsonSerializer { + + @Override + public void serialize(LocalDateTime value, JsonGenerator generator, SerializerProvider serializers) + throws IOException { + generator.writeString(value.format(DateTimeFormatter.ofPattern(DATETIME))); + } + } + + public class LocalDateTimeDeserializer extends JsonDeserializer { + @Override + public LocalDateTime deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) + throws IOException { + return LocalDateTime.parse(jsonParser.getValueAsString(), DateTimeFormatter.ofPattern(DATETIME)); + } + } + + public class LocalTimeSerializer extends JsonSerializer { + + @Override + public void serialize(LocalTime value, JsonGenerator generator, SerializerProvider serializers) + throws IOException { + generator.writeStartObject(); + + generator.writeNumberField("hour", value.getHour()); + generator.writeNumberField("minute", value.getMinute()); + generator.writeNumberField("second", value.getSecond()); + generator.writeNumberField("nano", value.getNano()); + + generator.writeEndObject(); + } + } + + public class LocalTimeDeserializer extends JsonDeserializer { + @Override + public LocalTime deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) + throws IOException { + JsonNode node = jsonParser.getCodec().readTree(jsonParser); + + int hour = node.get("hour").asInt(); + int minute = node.get("minute").asInt(); + int second = node.get("second").asInt(); + int nano = node.get("nano").asInt(); + + return LocalTime.of(hour, minute, second, nano); + } + } +} diff --git a/src/main/java/com/gdschongik/gdsc/global/config/WebSecurityConfig.java b/src/main/java/com/gdschongik/gdsc/global/config/WebSecurityConfig.java index 290114147..3dab1279d 100644 --- a/src/main/java/com/gdschongik/gdsc/global/config/WebSecurityConfig.java +++ b/src/main/java/com/gdschongik/gdsc/global/config/WebSecurityConfig.java @@ -11,12 +11,15 @@ import com.gdschongik.gdsc.domain.member.dao.MemberRepository; import com.gdschongik.gdsc.global.annotation.ConditionalOnProfile; import com.gdschongik.gdsc.global.property.BasicAuthProperty; +import com.gdschongik.gdsc.global.security.CustomOAuth2AuthorizationRequestResolver; import com.gdschongik.gdsc.global.security.CustomSuccessHandler; import com.gdschongik.gdsc.global.security.CustomUserService; import com.gdschongik.gdsc.global.security.JwtExceptionFilter; import com.gdschongik.gdsc.global.security.JwtFilter; import com.gdschongik.gdsc.global.util.CookieUtil; import com.gdschongik.gdsc.global.util.EnvironmentUtil; +import java.util.ArrayList; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -29,6 +32,7 @@ import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.logout.LogoutFilter; @@ -47,6 +51,7 @@ public class WebSecurityConfig { private final ObjectMapper objectMapper; private final EnvironmentUtil environmentUtil; private final BasicAuthProperty basicAuthProperty; + private final ClientRegistrationRepository clientRegistrationRepository; private void defaultFilterChain(HttpSecurity http) throws Exception { http.httpBasic(AbstractHttpConfigurer::disable) @@ -92,10 +97,11 @@ public SecurityFilterChain prometheusFilterChain(HttpSecurity http) throws Excep public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { defaultFilterChain(http); - http.oauth2Login( - oauth2 -> oauth2.userInfoEndpoint(userInfo -> userInfo.userService(customUserService(memberRepository))) - .successHandler(customSuccessHandler(jwtService, cookieUtil)) - .failureHandler((request, response, exception) -> response.setStatus(401))); + http.oauth2Login(oauth2 -> oauth2.authorizationEndpoint( + endpoint -> endpoint.authorizationRequestResolver(customOAuth2AuthorizationRequestResolver())) + .userInfoEndpoint(userInfo -> userInfo.userService(customUserService(memberRepository))) + .successHandler(customSuccessHandler(jwtService, cookieUtil)) + .failureHandler((request, response, exception) -> response.setStatus(401))); http.exceptionHandling(exception -> exception.authenticationEntryPoint((request, response, authException) -> response.setStatus(401))); @@ -138,6 +144,11 @@ public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } + @Bean + public CustomOAuth2AuthorizationRequestResolver customOAuth2AuthorizationRequestResolver() { + return new CustomOAuth2AuthorizationRequestResolver(clientRegistrationRepository); + } + @Bean public CustomUserService customUserService(MemberRepository memberRepository) { return new CustomUserService(memberRepository); @@ -163,21 +174,20 @@ public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); if (environmentUtil.isProdProfile()) { - configuration.addAllowedOriginPattern(PROD_CLIENT_ONBOARDING_URL); - configuration.addAllowedOriginPattern(PROD_CLIENT_ADMIN_URL); + configuration.setAllowedOriginPatterns(PROD_CLIENT_URLS); } if (environmentUtil.isDevProfile()) { - configuration.addAllowedOriginPattern(DEV_CLIENT_ONBOARDING_URL); - configuration.addAllowedOriginPattern(DEV_CLIENT_ADMIN_URL); - configuration.addAllowedOriginPattern(LOCAL_REACT_CLIENT_URL); - configuration.addAllowedOriginPattern(LOCAL_REACT_CLIENT_SECURE_URL); - configuration.addAllowedOriginPattern(LOCAL_VITE_CLIENT_URL); - configuration.addAllowedOriginPattern(LOCAL_VITE_CLIENT_SECURE_URL); - configuration.addAllowedOriginPattern(DEV_SERVER_URL); + List urls = new ArrayList<>(); + urls.addAll(DEV_AND_LOCAL_CLIENT_URLS); + urls.add(DEV_SERVER_URL); + urls.add(LOCAL_SERVER_URL); + configuration.setAllowedOriginPatterns(urls); } - configuration.addAllowedOriginPattern(LOCAL_PROXY_CLIENT_ONBOARDING_URL); + if (environmentUtil.isLocalProfile()) { + configuration.setAllowedOriginPatterns(LOCAL_CLIENT_URLS); + } configuration.addAllowedHeader("*"); configuration.addAllowedMethod("*"); diff --git a/src/main/java/com/gdschongik/gdsc/global/exception/ErrorCode.java b/src/main/java/com/gdschongik/gdsc/global/exception/ErrorCode.java index 17686780c..2220ddc33 100644 --- a/src/main/java/com/gdschongik/gdsc/global/exception/ErrorCode.java +++ b/src/main/java/com/gdschongik/gdsc/global/exception/ErrorCode.java @@ -100,6 +100,8 @@ public enum ErrorCode { // Study STUDY_APPLICATION_START_DATE_INVALID(HttpStatus.CONFLICT, "스터디 신청기간 시작일이 스터디 시작일보다 빠릅니다."), STUDY_MENTOR_IS_UNAUTHORIZED(HttpStatus.CONFLICT, "게스트인 회원은 멘토로 지정할 수 없습니다."), + STUDY_ACCESS_NOT_ALLOWED(HttpStatus.FORBIDDEN, "관리자 또는 멘토 역할이 아닌 회원은 이 작업을 수행할 수 없습니다."), + STUDY_MENTOR_INVALID(HttpStatus.CONFLICT, "사용자가 해당 스터디의 멘토가 아닙니다."), ON_OFF_LINE_STUDY_TIME_IS_ESSENTIAL(HttpStatus.CONFLICT, "온오프라인 스터디는 스터디 시간이 필요합니다."), STUDY_TIME_INVALID(HttpStatus.CONFLICT, "스터디종료 시각이 스터디시작 시각보다 빠릅니다."), ASSIGNMENT_STUDY_CAN_NOT_INPUT_STUDY_TIME(HttpStatus.CONFLICT, "과제 스터디는 스터디 시간을 입력할 수 없습니다."), @@ -112,11 +114,22 @@ public enum ErrorCode { STUDY_DETAIL_UPDATE_RESTRICTED_TO_MENTOR(HttpStatus.CONFLICT, "해당 스터디의 멘토만 수정할 수 있습니다."), STUDY_DETAIL_ASSIGNMENT_INVALID_DEADLINE(HttpStatus.CONFLICT, "마감기한이 지난 과제의 마감기한을 수정할 수 없습니다"), STUDY_DETAIL_ASSIGNMENT_INVALID_UPDATE_DEADLINE(HttpStatus.CONFLICT, "수정하려고 하는 과제의 마감기한은 기존의 마감기한보다 빠르면 안됩니다."), - + STUDY_DETAIL_ID_INVALID(HttpStatus.CONFLICT, "수정하려는 스터디 상세정보가 서버에 존재하지 않거나 유효하지 않습니다."), + STUDY_DETAIL_SESSION_SIZE_MISMATCH(HttpStatus.BAD_REQUEST, "스터디 커리큘럼의 총 개수가 일치하지 않습니다."), // StudyHistory STUDY_HISTORY_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 스터디 수강 기록입니다."), STUDY_HISTORY_DUPLICATE(HttpStatus.CONFLICT, "이미 해당 스터디를 신청했습니다."), STUDY_HISTORY_ONGOING_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 진행중인 스터디가 있습니다."), + STUDY_HISTORY_REPOSITORY_NOT_UPDATABLE_ASSIGNMENT_ALREADY_SUBMITTED( + HttpStatus.CONFLICT, "이미 제출한 과제가 있으므로 레포지토리를 수정할 수 없습니다."), + STUDY_HISTORY_REPOSITORY_NOT_UPDATABLE_OWNER_MISMATCH(HttpStatus.CONFLICT, "레포지토리 소유자가 현재 멤버와 다릅니다."), + + // StudyAnnouncement + STUDY_ANNOUNCEMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 스터디 공지입니다."), + + // Attendance + ATTENDANCE_DATE_INVALID(HttpStatus.CONFLICT, "강의일이 아니면 출석체크할 수 없습니다."), + ATTENDANCE_NUMBER_MISMATCH(HttpStatus.CONFLICT, "출석번호가 일치하지 않습니다."), // Order ORDER_NOT_FOUND(HttpStatus.NOT_FOUND, "주문이 존재하지 않습니다."), @@ -141,9 +154,21 @@ public enum ErrorCode { ORDER_FINAL_PAYMENT_AMOUNT_MISMATCH(HttpStatus.CONFLICT, "주문 최종결제금액은 주문총액에서 할인금액을 뺀 값이어야 합니다."), // Assignment - ASSIGNMENT_CAN_NOT_BE_UPDATED(HttpStatus.CONFLICT, "휴강인 과제는 수정할 수 없습니다."), - ASSIGNMENT_DEADLINE_INVALID(HttpStatus.CONFLICT, "과제 마감 기한이 현재보다 빠릅니다."); - + ASSIGNMENT_INVALID_FAILURE_TYPE(HttpStatus.CONFLICT, "유효하지 않은 제출 실패사유입니다."), + ASSIGNMENT_DEADLINE_INVALID(HttpStatus.CONFLICT, "과제 마감 기한이 현재보다 빠릅니다."), + ASSIGNMENT_STUDY_NOT_APPLIED(HttpStatus.CONFLICT, "해당 스터디에 대한 수강신청 기록이 존재하지 않습니다."), + ASSIGNMENT_SUBMIT_NOT_STARTED(HttpStatus.CONFLICT, "아직 과제가 시작되지 않았습니다."), + ASSIGNMENT_SUBMIT_NOT_PUBLISHED(HttpStatus.CONFLICT, "아직 과제가 등록되지 않았습니다."), + ASSIGNMENT_SUBMIT_CANCELLED(HttpStatus.CONFLICT, "과제 휴강 주간에는 과제를 제출할 수 없습니다."), + ASSIGNMENT_SUBMIT_DEADLINE_PASSED(HttpStatus.CONFLICT, "과제 마감 기한이 지났습니다."), + + // Github + GITHUB_REPOSITORY_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 레포지토리입니다."), + GITHUB_CONTENT_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 파일입니다."), + GITHUB_FILE_READ_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "깃허브 파일 읽기에 실패했습니다."), + GITHUB_COMMIT_DATE_FETCH_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "깃허브 커밋 날짜 조회에 실패했습니다."), + GITHUB_USER_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 깃허브 유저입니다."), + ; private final HttpStatus status; private final String message; } diff --git a/src/main/java/com/gdschongik/gdsc/global/security/CustomOAuth2AuthorizationRequestResolver.java b/src/main/java/com/gdschongik/gdsc/global/security/CustomOAuth2AuthorizationRequestResolver.java new file mode 100644 index 000000000..1d3ae33e3 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/global/security/CustomOAuth2AuthorizationRequestResolver.java @@ -0,0 +1,49 @@ +package com.gdschongik.gdsc.global.security; + +import static com.gdschongik.gdsc.global.common.constant.SecurityConstant.*; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.HashMap; +import java.util.Map; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; + +public class CustomOAuth2AuthorizationRequestResolver implements OAuth2AuthorizationRequestResolver { + + private final DefaultOAuth2AuthorizationRequestResolver delegate; + + public CustomOAuth2AuthorizationRequestResolver(ClientRegistrationRepository clientRegistrationRepository) { + this.delegate = + new DefaultOAuth2AuthorizationRequestResolver(clientRegistrationRepository, "/oauth2/authorization"); + } + + @Override + public OAuth2AuthorizationRequest resolve(HttpServletRequest request) { + OAuth2AuthorizationRequest authorizationRequest = delegate.resolve(request); + return authorizationRequest != null ? customizeAuthorizationRequest(request, authorizationRequest) : null; + } + + @Override + public OAuth2AuthorizationRequest resolve(HttpServletRequest request, String clientRegistrationId) { + OAuth2AuthorizationRequest authorizationRequest = delegate.resolve(request, clientRegistrationId); + return authorizationRequest != null ? customizeAuthorizationRequest(request, authorizationRequest) : null; + } + + private OAuth2AuthorizationRequest customizeAuthorizationRequest( + HttpServletRequest request, OAuth2AuthorizationRequest authorizationRequest) { + + String referer = request.getHeader("Referer"); + if (referer == null || referer.isEmpty()) { + return authorizationRequest; + } + + Map additionalParameters = new HashMap<>(); + additionalParameters.put(OAUTH_TARGET_URL_PARAM_NAME, referer); + + return OAuth2AuthorizationRequest.from(authorizationRequest) + .additionalParameters(additionalParameters) + .build(); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/global/security/CustomSuccessHandler.java b/src/main/java/com/gdschongik/gdsc/global/security/CustomSuccessHandler.java index 08c1d687d..41633875e 100644 --- a/src/main/java/com/gdschongik/gdsc/global/security/CustomSuccessHandler.java +++ b/src/main/java/com/gdschongik/gdsc/global/security/CustomSuccessHandler.java @@ -24,7 +24,7 @@ public class CustomSuccessHandler extends SimpleUrlAuthenticationSuccessHandler public CustomSuccessHandler(JwtService jwtService, CookieUtil cookieUtil) { this.jwtService = jwtService; this.cookieUtil = cookieUtil; - setUseReferer(true); + setTargetUrlParameter(OAUTH_TARGET_URL_PARAM_NAME); } @Override diff --git a/src/main/java/com/gdschongik/gdsc/global/util/CookieUtil.java b/src/main/java/com/gdschongik/gdsc/global/util/CookieUtil.java index 2c6d5fb0f..df65ba58b 100644 --- a/src/main/java/com/gdschongik/gdsc/global/util/CookieUtil.java +++ b/src/main/java/com/gdschongik/gdsc/global/util/CookieUtil.java @@ -36,6 +36,7 @@ public void deleteCookie(jakarta.servlet.http.Cookie cookie, HttpServletResponse cookie.setPath("/"); cookie.setValue(""); cookie.setMaxAge(0); + cookie.setDomain(ROOT_DOMAIN); response.addCookie(cookie); } } diff --git a/src/main/java/com/gdschongik/gdsc/global/util/EnvironmentUtil.java b/src/main/java/com/gdschongik/gdsc/global/util/EnvironmentUtil.java index 98cf71ca6..99383ad9f 100644 --- a/src/main/java/com/gdschongik/gdsc/global/util/EnvironmentUtil.java +++ b/src/main/java/com/gdschongik/gdsc/global/util/EnvironmentUtil.java @@ -36,6 +36,10 @@ public boolean isDevAndLocalProfile() { return getActiveProfiles().anyMatch(DEV_AND_LOCAL_ENV::contains); } + public boolean isLocalProfile() { + return getActiveProfiles().anyMatch(LOCAL_ENV::equals); + } + private Stream getActiveProfiles() { return Stream.of(environment.getActiveProfiles()); } diff --git a/src/main/java/com/gdschongik/gdsc/infra/client/github/GithubClient.java b/src/main/java/com/gdschongik/gdsc/infra/client/github/GithubClient.java deleted file mode 100644 index e3235dfb7..000000000 --- a/src/main/java/com/gdschongik/gdsc/infra/client/github/GithubClient.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.gdschongik.gdsc.infra.client.github; - -import lombok.RequiredArgsConstructor; -import org.kohsuke.github.GitHub; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -public class GithubClient { - - private final GitHub github; -} diff --git a/src/main/java/com/gdschongik/gdsc/infra/github/GithubUserRequest.java b/src/main/java/com/gdschongik/gdsc/infra/github/GithubUserRequest.java new file mode 100644 index 000000000..c42c7266c --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/infra/github/GithubUserRequest.java @@ -0,0 +1,58 @@ +package com.gdschongik.gdsc.infra.github; + +import static com.gdschongik.gdsc.global.common.constant.GithubConstant.*; + +import com.gdschongik.gdsc.global.exception.CustomException; +import com.gdschongik.gdsc.global.exception.ErrorCode; +import java.io.InputStream; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.kohsuke.github.connector.GitHubConnectorRequest; + +@RequiredArgsConstructor +public class GithubUserRequest implements GitHubConnectorRequest { + + private final String oauthId; + + @Override + public String method() { + return "GET"; + } + + @Override + public Map> allHeaders() { + return Map.of(); + } + + @Override + public String header(String s) { + return ""; + } + + @Override + public String contentType() { + return ""; + } + + @Override + public InputStream body() { + return null; + } + + @Override + public URL url() { + try { + return new URL(GITHUB_USER_API_URL.formatted(oauthId)); + } catch (MalformedURLException e) { + throw new CustomException(ErrorCode.INTERNAL_SERVER_ERROR); + } + } + + @Override + public boolean hasBody() { + return false; + } +} diff --git a/src/main/java/com/gdschongik/gdsc/infra/github/client/GithubClient.java b/src/main/java/com/gdschongik/gdsc/infra/github/client/GithubClient.java new file mode 100644 index 000000000..b7eb73fb5 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/infra/github/client/GithubClient.java @@ -0,0 +1,110 @@ +package com.gdschongik.gdsc.infra.github.client; + +import static com.gdschongik.gdsc.global.common.constant.GithubConstant.*; +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.gdschongik.gdsc.domain.study.domain.AssignmentSubmission; +import com.gdschongik.gdsc.domain.study.domain.AssignmentSubmissionFetchExecutor; +import com.gdschongik.gdsc.domain.study.domain.AssignmentSubmissionFetcher; +import com.gdschongik.gdsc.global.exception.CustomException; +import com.gdschongik.gdsc.infra.github.GithubUserRequest; +import java.io.IOException; +import java.io.InputStream; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.kohsuke.github.GHCommit; +import org.kohsuke.github.GHContent; +import org.kohsuke.github.GHRepository; +import org.kohsuke.github.GitHub; +import org.kohsuke.github.connector.GitHubConnector; +import org.kohsuke.github.connector.GitHubConnectorResponse; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class GithubClient { + + private final GitHub github; + private final GitHubConnector gitHubConnector = GitHubConnector.DEFAULT; + + public GHRepository getRepository(String ownerRepo) { + try { + return github.getRepository(ownerRepo); + } catch (IOException e) { + throw new CustomException(GITHUB_REPOSITORY_NOT_FOUND); + } + } + + public String getGithubHandle(String oauthId) { + try (GitHubConnectorResponse response = gitHubConnector.send(new GithubUserRequest(oauthId)); + InputStream inputStream = response.bodyStream(); ) { + // api가 login이라는 이름으로 사용자의 github handle을 반환합니다. + return (String) new ObjectMapper().readValue(inputStream, Map.class).get("login"); + } catch (IOException e) { + throw new CustomException(GITHUB_USER_NOT_FOUND); + } + } + + /** + * 직접 요청을 수행하는 대신, fetcher를 통해 요청을 수행합니다. + * 요청 수행 시 발생하는 예외의 경우 과제 채점에 사용되므로, 실제 요청은 채점 로직 내부에서 수행되어야 합니다. + * 따라서 지연 평가가 가능하도록 {@link AssignmentSubmissionFetchExecutor}를 인자로 받습니다. + * 또한, 인자로 전달된 repo와 week가 closure로 캡쳐되지 않도록 fetcher 내부에 컨텍스트로 저장합니다. + */ + public AssignmentSubmissionFetcher getLatestAssignmentSubmissionFetcher(String repo, int week) { + return new AssignmentSubmissionFetcher(repo, week, this::getLatestAssignmentSubmission); + } + + private AssignmentSubmission getLatestAssignmentSubmission(String repo, int week) { + GHRepository ghRepository = getRepository(repo); + String assignmentPath = GITHUB_ASSIGNMENT_PATH.formatted(week); + + // GHContent#getSize() 의 경우 한글 문자열을 byte 단위로 계산하기 때문에, 직접 content를 읽어서 길이를 계산 + GHContent ghContent = getFileContent(ghRepository, assignmentPath); + String content = readFileContent(ghContent); + + GHCommit ghLatestCommit = ghRepository + .queryCommits() + .path(assignmentPath) + .list() + .withPageSize(1) + .iterator() + .next(); + + LocalDateTime committedAt = getCommitDate(ghLatestCommit); + + return new AssignmentSubmission( + ghContent.getHtmlUrl(), ghLatestCommit.getSHA1(), content.length(), committedAt); + } + + private GHContent getFileContent(GHRepository ghRepository, String filePath) { + try { + return ghRepository.getFileContent(filePath); + } catch (IOException e) { + throw new CustomException(GITHUB_CONTENT_NOT_FOUND); + } + } + + private String readFileContent(GHContent ghContent) { + try (InputStream inputStream = ghContent.read()) { + return new String(inputStream.readAllBytes()); + } catch (IOException e) { + throw new CustomException(GITHUB_FILE_READ_FAILED); + } + } + + private LocalDateTime getCommitDate(GHCommit ghLatestCommit) { + try { + return ghLatestCommit + .getCommitDate() + .toInstant() + .atZone(ZoneId.systemDefault()) + .toLocalDateTime(); + } catch (IOException e) { + throw new CustomException(GITHUB_COMMIT_DATE_FETCH_FAILED); + } + } +} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index f86a1d3c2..32b629bc1 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -17,3 +17,4 @@ logging: level: org.springframework.orm.jpa: DEBUG org.springframework.transaction: DEBUG + org.kohsuke.github: debug diff --git a/src/test/java/com/gdschongik/gdsc/domain/study/application/MentorStudyServiceTest.java b/src/test/java/com/gdschongik/gdsc/domain/study/application/MentorStudyServiceTest.java new file mode 100644 index 000000000..7b4707f6d --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/domain/study/application/MentorStudyServiceTest.java @@ -0,0 +1,76 @@ +package com.gdschongik.gdsc.domain.study.application; + +import static com.gdschongik.gdsc.global.common.constant.StudyConstant.*; +import static com.gdschongik.gdsc.global.common.constant.StudyConstant.SESSION_DESCRIPTION; +import static org.assertj.core.api.Assertions.assertThat; + +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.member.domain.MemberRole; +import com.gdschongik.gdsc.domain.recruitment.domain.vo.Period; +import com.gdschongik.gdsc.domain.study.domain.Difficulty; +import com.gdschongik.gdsc.domain.study.domain.Study; +import com.gdschongik.gdsc.domain.study.domain.StudyDetail; +import com.gdschongik.gdsc.domain.study.domain.StudyStatus; +import com.gdschongik.gdsc.domain.study.dto.request.StudySessionCreateRequest; +import com.gdschongik.gdsc.domain.study.dto.request.StudyUpdateRequest; +import com.gdschongik.gdsc.helper.IntegrationTest; +import java.time.LocalDateTime; +import java.util.*; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; + +public class MentorStudyServiceTest extends IntegrationTest { + + @Autowired + private MentorStudyService mentorStudyService; + + @Nested + class 스터디_정보_작성시 { + + @Test + void 성공한다() { + // given + LocalDateTime now = STUDY_START_DATETIME; + Member mentor = createMentor(); + Study study = createNewStudy( + mentor, + 4L, + Period.createPeriod(now.plusDays(5), now.plusDays(10)), + Period.createPeriod(now.minusDays(5), now)); + for (int i = 1; i <= 4; i++) { + Long week = (long) i; + createNewStudyDetail(week, study, now, now.plusDays(7)); + now = now.plusDays(8); + } + logoutAndReloginAs(study.getMentor().getId(), MemberRole.ASSOCIATE); + + List sessionCreateRequests = new ArrayList<>(); + for (int i = 1; i <= study.getTotalWeek(); i++) { + Long id = (long) i; + StudySessionCreateRequest sessionCreateRequest = new StudySessionCreateRequest( + id, SESSION_TITLE + i, SESSION_DESCRIPTION + i, Difficulty.HIGH, StudyStatus.OPEN); + sessionCreateRequests.add(sessionCreateRequest); + } + + StudyUpdateRequest request = + new StudyUpdateRequest(STUDY_NOTION_LINK, STUDY_INTRODUCTION, sessionCreateRequests); + + // when + mentorStudyService.updateStudy(1L, request); + + // then + Study savedStudy = studyRepository.findById(study.getId()).get(); + assertThat(savedStudy.getNotionLink()).isEqualTo(request.notionLink()); + + List studyDetails = studyDetailRepository.findAllByStudyIdOrderByWeekAsc(1L); + for (int i = 0; i < studyDetails.size(); i++) { + StudyDetail studyDetail = studyDetails.get(i); + Long expectedId = studyDetail.getId(); + + assertThat(studyDetail.getId()).isEqualTo(expectedId); + assertThat(studyDetail.getSession().getTitle()).isEqualTo(SESSION_TITLE + expectedId); + assertThat(studyDetail.getSession().getDescription()).isEqualTo(SESSION_DESCRIPTION + expectedId); + } + } + } +} diff --git a/src/test/java/com/gdschongik/gdsc/domain/study/application/StudentStudyHistoryServiceTest.java b/src/test/java/com/gdschongik/gdsc/domain/study/application/StudentStudyHistoryServiceTest.java new file mode 100644 index 000000000..9aa0ad9ca --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/domain/study/application/StudentStudyHistoryServiceTest.java @@ -0,0 +1,88 @@ +package com.gdschongik.gdsc.domain.study.application; + +import static com.gdschongik.gdsc.global.common.constant.StudyConstant.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.member.domain.MemberRole; +import com.gdschongik.gdsc.domain.recruitment.domain.vo.Period; +import com.gdschongik.gdsc.domain.study.dao.AssignmentHistoryRepository; +import com.gdschongik.gdsc.domain.study.dao.StudyHistoryRepository; +import com.gdschongik.gdsc.domain.study.domain.AssignmentHistory; +import com.gdschongik.gdsc.domain.study.domain.AssignmentSubmission; +import com.gdschongik.gdsc.domain.study.domain.AssignmentSubmissionFetcher; +import com.gdschongik.gdsc.domain.study.domain.AssignmentSubmissionStatus; +import com.gdschongik.gdsc.domain.study.domain.Study; +import com.gdschongik.gdsc.domain.study.domain.StudyDetail; +import com.gdschongik.gdsc.domain.study.domain.StudyHistory; +import com.gdschongik.gdsc.helper.IntegrationTest; +import java.time.LocalDateTime; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; + +class StudentStudyHistoryServiceTest extends IntegrationTest { + + @Autowired + private StudentStudyHistoryService studentStudyHistoryService; + + @Autowired + private StudyHistoryRepository studyHistoryRepository; + + @Autowired + private AssignmentHistoryRepository assignmentHistoryRepository; + + private void setCurrentTime(LocalDateTime now) { + try (MockedStatic mock = Mockito.mockStatic(LocalDateTime.class, Mockito.CALLS_REAL_METHODS)) { + mock.when(LocalDateTime::now).thenReturn(now); + } + } + + @Nested + class 과제_제출할때 { + + @Test + void 성공한다() { + // given + Member mentor = createAssociateMember(); + // TODO: LocalDateTime.now() 관련 테스트 정책 논의 필요 + LocalDateTime now = LocalDateTime.now(); // 통합 테스트에서는 LocalDateTime.now()를 사용해야 함 + Study study = createStudy( + mentor, + Period.createPeriod(now.minusWeeks(1), now.plusWeeks(7)), // 스터디 기간: 1주 전 ~ 7주 후 + Period.createPeriod(now.minusWeeks(2), now.minusWeeks(1))); // 수강신청 기간: 2주 전 ~ 1주 전 + StudyDetail studyDetail = + createStudyDetail(study, now.minusDays(6), now.plusDays(1)); // 1주차 기간: 6일 전 ~ 1일 후 + publishAssignment(studyDetail); + + Member student = createRegularMember(); + logoutAndReloginAs(student.getId(), MemberRole.REGULAR); + + // 수강신청 valiadtion 로직이 LocalDateTime.now() 기준으로 동작하기 때문에 직접 수강신청 생성 + StudyHistory studyHistory = StudyHistory.create(student, study); + studyHistory.updateRepositoryLink(REPOSITORY_LINK); + studyHistoryRepository.save(studyHistory); + + // 제출정보 조회 fetcher stubbing + AssignmentSubmissionFetcher mockFetcher = mock(AssignmentSubmissionFetcher.class); + when(mockFetcher.fetch()) + .thenReturn(new AssignmentSubmission(REPOSITORY_LINK, COMMIT_HASH, 500, COMMITTED_AT)); + when(githubClient.getLatestAssignmentSubmissionFetcher(anyString(), anyInt())) + .thenReturn(mockFetcher); + + // when + studentStudyHistoryService.submitAssignment(studyDetail.getId()); + + // then + AssignmentHistory assignmentHistory = + assignmentHistoryRepository.findById(1L).orElseThrow(); + assertThat(assignmentHistory.getSubmissionStatus()).isEqualTo(AssignmentSubmissionStatus.SUCCESS); + assertThat(assignmentHistory.getSubmissionLink()).isEqualTo(REPOSITORY_LINK); + assertThat(assignmentHistory.getCommitHash()).isEqualTo(COMMIT_HASH); + assertThat(assignmentHistory.getContentLength()).isEqualTo(500); + } + } +} diff --git a/src/test/java/com/gdschongik/gdsc/domain/study/application/StudyServiceTest.java b/src/test/java/com/gdschongik/gdsc/domain/study/application/StudentStudyServiceTest.java similarity index 79% rename from src/test/java/com/gdschongik/gdsc/domain/study/application/StudyServiceTest.java rename to src/test/java/com/gdschongik/gdsc/domain/study/application/StudentStudyServiceTest.java index 64177d3a4..62ce8c0bf 100644 --- a/src/test/java/com/gdschongik/gdsc/domain/study/application/StudyServiceTest.java +++ b/src/test/java/com/gdschongik/gdsc/domain/study/application/StudentStudyServiceTest.java @@ -9,10 +9,10 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -public class StudyServiceTest extends IntegrationTest { +public class StudentStudyServiceTest extends IntegrationTest { @Autowired - private StudyService studyService; + private StudentStudyService studentStudyService; @Nested class 스터디_수강신청시 { @@ -20,7 +20,7 @@ class 스터디_수강신청시 { @Test void 존재하지_않는_스터디라면_실패한다() { // when & then - assertThatThrownBy(() -> studyService.applyStudy(1L)) + assertThatThrownBy(() -> studentStudyService.applyStudy(1L)) .isInstanceOf(CustomException.class) .hasMessage(ErrorCode.STUDY_NOT_FOUND.getMessage()); } diff --git a/src/test/java/com/gdschongik/gdsc/domain/study/domain/AssignmentHistoryGraderTest.java b/src/test/java/com/gdschongik/gdsc/domain/study/domain/AssignmentHistoryGraderTest.java new file mode 100644 index 000000000..671488c27 --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/domain/study/domain/AssignmentHistoryGraderTest.java @@ -0,0 +1,100 @@ +package com.gdschongik.gdsc.domain.study.domain; + +import static com.gdschongik.gdsc.global.common.constant.StudyConstant.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import com.gdschongik.gdsc.domain.recruitment.domain.vo.Period; +import com.gdschongik.gdsc.global.exception.CustomException; +import com.gdschongik.gdsc.global.exception.ErrorCode; +import com.gdschongik.gdsc.helper.FixtureHelper; +import java.time.LocalDateTime; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class AssignmentHistoryGraderTest { + + FixtureHelper fixtureHelper = new FixtureHelper(); + AssignmentHistoryGrader grader = new AssignmentHistoryGrader(); + + AssignmentSubmissionFetcher mockFetcher = mock(AssignmentSubmissionFetcher.class); + + // FixtureHelper 래핑 메서드 + private AssignmentHistory createAssignmentHistory() { + Study study = createStudyWithMentor(1L); + StudyDetail studyDetail = fixtureHelper.createStudyDetail( + study, LocalDateTime.now(), LocalDateTime.now().plusDays(7)); + return AssignmentHistory.create(studyDetail, fixtureHelper.createAssociateMember(2L)); + } + + private Study createStudyWithMentor(Long mentorId) { + Period period = Period.createPeriod(STUDY_START_DATETIME, STUDY_END_DATETIME); + Period applicationPeriod = + Period.createPeriod(STUDY_START_DATETIME.minusDays(7), STUDY_START_DATETIME.minusDays(1)); + return fixtureHelper.createStudyWithMentor(mentorId, period, applicationPeriod); + } + + @Nested + class 과제_채점시 { + + @Test + void 과제내용이_최소길이_이상이면_성공_처리된다() { + // given + AssignmentHistory history = createAssignmentHistory(); + AssignmentSubmission validSubmission = new AssignmentSubmission("url", "hash", 500, LocalDateTime.now()); + when(mockFetcher.fetch()).thenReturn(validSubmission); + + // when + grader.judge(mockFetcher, history); + + // then + assertThat(history.getSubmissionStatus()).isEqualTo(AssignmentSubmissionStatus.SUCCESS); + assertThat(history.getSubmissionLink()).isEqualTo("url"); + assertThat(history.getCommitHash()).isEqualTo("hash"); + assertThat(history.getContentLength()).isEqualTo(500); + } + + @Test + void 과제내용이_최소길이_미만이면_실패_처리된다() { + // given + AssignmentHistory history = createAssignmentHistory(); + AssignmentSubmission shortSubmission = new AssignmentSubmission("url", "hash", 200, LocalDateTime.now()); + when(mockFetcher.fetch()).thenReturn(shortSubmission); + + // when + grader.judge(mockFetcher, history); + + // then + assertThat(history.getSubmissionStatus()).isEqualTo(AssignmentSubmissionStatus.FAILURE); + assertThat(history.getSubmissionFailureType()).isEqualTo(SubmissionFailureType.WORD_COUNT_INSUFFICIENT); + } + + @Test + void 해당_위치에_과제파일_미존재시_위치확인불가로_실패_처리된다() { + // given + AssignmentHistory history = createAssignmentHistory(); + when(mockFetcher.fetch()).thenThrow(new CustomException(ErrorCode.GITHUB_CONTENT_NOT_FOUND)); + + // when + grader.judge(mockFetcher, history); + + // then + assertThat(history.getSubmissionStatus()).isEqualTo(AssignmentSubmissionStatus.FAILURE); + assertThat(history.getSubmissionFailureType()).isEqualTo(SubmissionFailureType.LOCATION_UNIDENTIFIABLE); + } + + @Test + void 그외_Github_문제인경우_알수없는오류로_실패_처리된다() { + // given + AssignmentHistory history = createAssignmentHistory(); + when(mockFetcher.fetch()).thenThrow(new CustomException(ErrorCode.GITHUB_FILE_READ_FAILED)); + + // when + grader.judge(mockFetcher, history); + + // then + assertThat(history.getSubmissionStatus()).isEqualTo(AssignmentSubmissionStatus.FAILURE); + assertThat(history.getSubmissionFailureType()).isEqualTo(SubmissionFailureType.UNKNOWN); + } + } +} diff --git a/src/test/java/com/gdschongik/gdsc/domain/study/domain/AssignmentHistoryTest.java b/src/test/java/com/gdschongik/gdsc/domain/study/domain/AssignmentHistoryTest.java new file mode 100644 index 000000000..3c45e14d1 --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/domain/study/domain/AssignmentHistoryTest.java @@ -0,0 +1,165 @@ +package com.gdschongik.gdsc.domain.study.domain; + +import static com.gdschongik.gdsc.global.common.constant.StudyConstant.*; +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; +import static org.assertj.core.api.Assertions.*; + +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.recruitment.domain.vo.Period; +import com.gdschongik.gdsc.global.exception.CustomException; +import com.gdschongik.gdsc.helper.FixtureHelper; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class AssignmentHistoryTest { + + FixtureHelper fixtureHelper = new FixtureHelper(); + + private Member createMember(Long id) { + return fixtureHelper.createAssociateMember(id); + } + + private Study createStudyWithMentor(Long mentorId) { + Period period = Period.createPeriod(STUDY_START_DATETIME, STUDY_END_DATETIME); + Period applicationPeriod = + Period.createPeriod(STUDY_START_DATETIME.minusDays(7), STUDY_START_DATETIME.minusDays(1)); + return fixtureHelper.createStudyWithMentor(mentorId, period, applicationPeriod); + } + + private StudyDetail createStudyDetailWithAssignment(Study study) { + return fixtureHelper.createStudyDetailWithAssignment( + study, STUDY_DETAIL_START_DATETIME, STUDY_DETAIL_END_DATETIME, STUDY_ASSIGNMENT_DEADLINE_DATETIME); + } + + @Nested + class 빈_과제이력_생성할때 { + + @Test + void 제출상태는_FAILURE이다() { + // given + Member member = createMember(1L); + Study study = createStudyWithMentor(1L); + StudyDetail studyDetail = createStudyDetailWithAssignment(study); + + // when + AssignmentHistory assignmentHistory = AssignmentHistory.create(studyDetail, member); + + // then + assertThat(assignmentHistory.getSubmissionStatus()).isEqualTo(AssignmentSubmissionStatus.FAILURE); + } + + @Test + void 실패사유는_NOT_SUBMITTED이다() { + // given + Member member = createMember(1L); + Study study = createStudyWithMentor(1L); + StudyDetail studyDetail = createStudyDetailWithAssignment(study); + + // when + AssignmentHistory assignmentHistory = AssignmentHistory.create(studyDetail, member); + + // then + assertThat(assignmentHistory.getSubmissionFailureType()).isEqualTo(SubmissionFailureType.NOT_SUBMITTED); + } + } + + @Nested + class 과제이력_제출_성공할때 { + + @Test + void 제출상태는_SUCCESS이다() { + // given + Member member = createMember(1L); + Study study = createStudyWithMentor(1L); + StudyDetail studyDetail = createStudyDetailWithAssignment(study); + AssignmentHistory assignmentHistory = AssignmentHistory.create(studyDetail, member); + + // when + assignmentHistory.success(SUBMISSION_LINK, COMMIT_HASH, CONTENT_LENGTH, COMMITTED_AT); + } + + @Test + void 실패사유는_NONE이다() { + // given + Member member = createMember(1L); + Study study = createStudyWithMentor(1L); + StudyDetail studyDetail = createStudyDetailWithAssignment(study); + AssignmentHistory assignmentHistory = AssignmentHistory.create(studyDetail, member); + + // when + assignmentHistory.success(SUBMISSION_LINK, COMMIT_HASH, CONTENT_LENGTH, COMMITTED_AT); + + // then + assertThat(assignmentHistory.getSubmissionFailureType()).isEqualTo(SubmissionFailureType.NONE); + } + } + + @Nested + class 과제이력_제출_실패할때 { + + @Test + void 제출상태는_FAILURE이다() { + // given + Member member = createMember(1L); + Study study = createStudyWithMentor(1L); + StudyDetail studyDetail = createStudyDetailWithAssignment(study); + AssignmentHistory assignmentHistory = AssignmentHistory.create(studyDetail, member); + assignmentHistory.success(SUBMISSION_LINK, COMMIT_HASH, CONTENT_LENGTH, COMMITTED_AT); + + // when + assignmentHistory.fail(SubmissionFailureType.WORD_COUNT_INSUFFICIENT); + + // then + assertThat(assignmentHistory.getSubmissionStatus()).isEqualTo(AssignmentSubmissionStatus.FAILURE); + } + + @Test + void 실패사유는_NOT_SUBMITTED가_될수없다() { + // given + Member member = createMember(1L); + Study study = createStudyWithMentor(1L); + StudyDetail studyDetail = createStudyDetailWithAssignment(study); + AssignmentHistory assignmentHistory = AssignmentHistory.create(studyDetail, member); + assignmentHistory.success(SUBMISSION_LINK, COMMIT_HASH, CONTENT_LENGTH, COMMITTED_AT); + + // when, then + assertThatThrownBy(() -> assignmentHistory.fail(SubmissionFailureType.NOT_SUBMITTED)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ASSIGNMENT_INVALID_FAILURE_TYPE.getMessage()); + } + + @Test + void 실패사유는_NONE이_될수없다() { + // given + Member member = createMember(1L); + Study study = createStudyWithMentor(1L); + StudyDetail studyDetail = createStudyDetailWithAssignment(study); + AssignmentHistory assignmentHistory = AssignmentHistory.create(studyDetail, member); + assignmentHistory.success(SUBMISSION_LINK, COMMIT_HASH, CONTENT_LENGTH, COMMITTED_AT); + + // when, then + assertThatThrownBy(() -> assignmentHistory.fail(SubmissionFailureType.NONE)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ASSIGNMENT_INVALID_FAILURE_TYPE.getMessage()); + } + + @Test + void 기존_제출정보는_삭제된다() { + // given + Member member = createMember(1L); + Study study = createStudyWithMentor(1L); + StudyDetail studyDetail = createStudyDetailWithAssignment(study); + AssignmentHistory assignmentHistory = AssignmentHistory.create(studyDetail, member); + assignmentHistory.success(SUBMISSION_LINK, COMMIT_HASH, CONTENT_LENGTH, COMMITTED_AT); + + // when + assignmentHistory.fail(SubmissionFailureType.WORD_COUNT_INSUFFICIENT); + + // then + assertThat(assignmentHistory.getSubmissionLink()).isNull(); + assertThat(assignmentHistory.getCommitHash()).isNull(); + assertThat(assignmentHistory.getContentLength()).isNull(); + assertThat(assignmentHistory.getCommittedAt()).isNull(); + } + } +} diff --git a/src/test/java/com/gdschongik/gdsc/domain/study/domain/AttendanceValidatorTest.java b/src/test/java/com/gdschongik/gdsc/domain/study/domain/AttendanceValidatorTest.java new file mode 100644 index 000000000..b39091045 --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/domain/study/domain/AttendanceValidatorTest.java @@ -0,0 +1,64 @@ +package com.gdschongik.gdsc.domain.study.domain; + +import static com.gdschongik.gdsc.global.common.constant.StudyConstant.ATTENDANCE_NUMBER; +import static com.gdschongik.gdsc.global.exception.ErrorCode.ATTENDANCE_DATE_INVALID; +import static com.gdschongik.gdsc.global.exception.ErrorCode.ATTENDANCE_NUMBER_MISMATCH; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.recruitment.domain.vo.Period; +import com.gdschongik.gdsc.global.exception.CustomException; +import com.gdschongik.gdsc.helper.FixtureHelper; +import java.time.LocalDate; +import java.time.LocalDateTime; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +public class AttendanceValidatorTest { + FixtureHelper fixtureHelper = new FixtureHelper(); + AttendanceValidator attendanceValidator = new AttendanceValidator(); + + @Nested + class 스터디_출석체크시 { + + @Test + void 출석일자가_아니면_실패한다() { + // given + Member mentor = fixtureHelper.createAssociateMember(1L); + + LocalDateTime now = LocalDateTime.now(); + Period period = Period.createPeriod(now.plusDays(10), now.plusDays(65)); + Period applicationPeriod = Period.createPeriod(now.minusDays(10), now.plusDays(5)); + Study study = fixtureHelper.createStudy(mentor, period, applicationPeriod); + StudyDetail studyDetail = fixtureHelper.createStudyDetail(study, now, now.plusDays(7)); + + LocalDate attendanceDay = studyDetail.getAttendanceDay(); + + // when & then + assertThatThrownBy(() -> attendanceValidator.validateAttendance( + studyDetail, ATTENDANCE_NUMBER, attendanceDay.plusDays(1))) + .isInstanceOf(CustomException.class) + .hasMessage(ATTENDANCE_DATE_INVALID.getMessage()); + } + + @Test + void 출석번호가_다르면_실패한다() { + // given + Member mentor = fixtureHelper.createAssociateMember(1L); + + LocalDateTime now = LocalDateTime.now(); + Period period = Period.createPeriod(now.plusDays(10), now.plusDays(65)); + Period applicationPeriod = Period.createPeriod(now.minusDays(10), now.plusDays(5)); + Study study = fixtureHelper.createStudy(mentor, period, applicationPeriod); + StudyDetail studyDetail = fixtureHelper.createStudyDetail(study, now, now.plusDays(7)); + + LocalDate attendanceDay = studyDetail.getAttendanceDay(); + + // when & then + assertThatThrownBy(() -> + attendanceValidator.validateAttendance(studyDetail, ATTENDANCE_NUMBER + 1, attendanceDay)) + .isInstanceOf(CustomException.class) + .hasMessage(ATTENDANCE_NUMBER_MISMATCH.getMessage()); + } + } +} diff --git a/src/test/java/com/gdschongik/gdsc/domain/study/domain/StudyAssignmentHistoryValidatorTest.java b/src/test/java/com/gdschongik/gdsc/domain/study/domain/StudyAssignmentHistoryValidatorTest.java new file mode 100644 index 000000000..5af2b9699 --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/domain/study/domain/StudyAssignmentHistoryValidatorTest.java @@ -0,0 +1,95 @@ +package com.gdschongik.gdsc.domain.study.domain; + +import static com.gdschongik.gdsc.global.common.constant.StudyConstant.*; +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; +import static org.assertj.core.api.Assertions.*; + +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.recruitment.domain.vo.Period; +import com.gdschongik.gdsc.global.exception.CustomException; +import com.gdschongik.gdsc.helper.FixtureHelper; +import java.time.LocalDateTime; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class StudyAssignmentHistoryValidatorTest { + + private final FixtureHelper fixtureHelper = new FixtureHelper(); + private final StudyAssignmentHistoryValidator validator = new StudyAssignmentHistoryValidator(); + + // FixtureHelper 래핑 메서드 + private Member createMember(Long id) { + return fixtureHelper.createAssociateMember(id); + } + + private Study createStudyWithMentor(Long mentorId) { + Period period = Period.createPeriod(STUDY_START_DATETIME, STUDY_END_DATETIME); + Period applicationPeriod = + Period.createPeriod(STUDY_START_DATETIME.minusDays(7), STUDY_START_DATETIME.minusDays(1)); + return fixtureHelper.createStudyWithMentor(mentorId, period, applicationPeriod); + } + + private StudyDetail createStudyDetailWithAssignment(Study study) { + return fixtureHelper.createStudyDetailWithAssignment( + study, STUDY_DETAIL_START_DATETIME, STUDY_DETAIL_END_DATETIME, STUDY_ASSIGNMENT_DEADLINE_DATETIME); + } + + @Nested + class 과제_제출가능_여부_검증할때 { + + @Test + void 스터디_수강신청_기록이_없다면_실패한다() { + // given + Study study = createStudyWithMentor(1L); + StudyDetail studyDetail = createStudyDetailWithAssignment(study); + boolean isAppliedToStudy = false; + + // when & then + assertThatThrownBy(() -> validator.validateSubmitAvailable( + isAppliedToStudy, STUDY_DETAIL_START_DATETIME, studyDetail)) + .isInstanceOf(CustomException.class) + .hasMessage(ASSIGNMENT_STUDY_NOT_APPLIED.getMessage()); + } + + @Test + void 과제가_시작되지_않았다면_실패한다() { + // given + Study study = createStudyWithMentor(1L); + StudyDetail studyDetail = createStudyDetailWithAssignment(study); + boolean isAppliedToStudy = true; + LocalDateTime beforeStart = STUDY_DETAIL_START_DATETIME.minusDays(1); + + // when & then + assertThatThrownBy(() -> validator.validateSubmitAvailable(isAppliedToStudy, beforeStart, studyDetail)) + .isInstanceOf(CustomException.class) + .hasMessage(ASSIGNMENT_SUBMIT_NOT_STARTED.getMessage()); + } + + @Test + void 과제_마감기한이_지났다면_실패한다() { + // given + Study study = createStudyWithMentor(1L); + StudyDetail studyDetail = createStudyDetailWithAssignment(study); + boolean isAppliedToStudy = true; + LocalDateTime afterDeadline = STUDY_ASSIGNMENT_DEADLINE_DATETIME.plusDays(1); + + // when & then + assertThatThrownBy(() -> validator.validateSubmitAvailable(isAppliedToStudy, afterDeadline, studyDetail)) + .isInstanceOf(CustomException.class) + .hasMessage(ASSIGNMENT_SUBMIT_DEADLINE_PASSED.getMessage()); + } + + @Test + void 모든_조건을_만족하는_경우_성공한다() { + // given + Study study = createStudyWithMentor(1L); + StudyDetail studyDetail = createStudyDetailWithAssignment(study); + boolean isAppliedToStudy = true; + + // when & then + assertThatCode(() -> validator.validateSubmitAvailable( + isAppliedToStudy, STUDY_DETAIL_START_DATETIME, studyDetail)) + .doesNotThrowAnyException(); + } + } +} diff --git a/src/test/java/com/gdschongik/gdsc/domain/study/domain/StudyDetailValidatorTest.java b/src/test/java/com/gdschongik/gdsc/domain/study/domain/StudyDetailValidatorTest.java index 0c536814f..e0e8789f6 100644 --- a/src/test/java/com/gdschongik/gdsc/domain/study/domain/StudyDetailValidatorTest.java +++ b/src/test/java/com/gdschongik/gdsc/domain/study/domain/StudyDetailValidatorTest.java @@ -10,6 +10,9 @@ import com.gdschongik.gdsc.global.exception.CustomException; import com.gdschongik.gdsc.helper.FixtureHelper; import java.time.LocalDateTime; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.LongStream; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -157,4 +160,34 @@ class 과제_수정시 { .hasMessage(STUDY_DETAIL_ASSIGNMENT_INVALID_DEADLINE.getMessage()); } } + + @Nested + class 스터디_상세정보_작성시 { + + @Test + void 존재하는_스터디상세정보_총개수와_요청된_스터디상세정보_총개수가_다르면_실패한다() { + // given + Set studyDetailIds = LongStream.rangeClosed(1, 4).boxed().collect(Collectors.toSet()); + + Set requestIds = LongStream.rangeClosed(1, 5).boxed().collect(Collectors.toSet()); + + // when & then + assertThatThrownBy(() -> studyDetailValidator.validateUpdateStudyDetail(studyDetailIds, requestIds)) + .isInstanceOf(CustomException.class) + .hasMessage(STUDY_DETAIL_SESSION_SIZE_MISMATCH.getMessage()); + } + + @Test + void 요청한_상세정보_id와_기존의_상세정보_id가_맞지_않으면_실패한다() { + // given + Set studyDetailIds = LongStream.rangeClosed(1, 4).boxed().collect(Collectors.toSet()); + + Set requestIds = LongStream.rangeClosed(2, 5).boxed().collect(Collectors.toSet()); + + // when & then + assertThatThrownBy(() -> studyDetailValidator.validateUpdateStudyDetail(studyDetailIds, requestIds)) + .isInstanceOf(CustomException.class) + .hasMessage(STUDY_DETAIL_ID_INVALID.getMessage()); + } + } } diff --git a/src/test/java/com/gdschongik/gdsc/domain/study/domain/StudyHistoryValidatorTest.java b/src/test/java/com/gdschongik/gdsc/domain/study/domain/StudyHistoryValidatorTest.java index 4631ab5da..5e6dcb2f1 100644 --- a/src/test/java/com/gdschongik/gdsc/domain/study/domain/StudyHistoryValidatorTest.java +++ b/src/test/java/com/gdschongik/gdsc/domain/study/domain/StudyHistoryValidatorTest.java @@ -1,5 +1,6 @@ package com.gdschongik.gdsc.domain.study.domain; +import static com.gdschongik.gdsc.global.common.constant.MemberConstant.*; import static com.gdschongik.gdsc.global.exception.ErrorCode.*; import static org.assertj.core.api.Assertions.*; @@ -30,8 +31,8 @@ class 스터디_수강신청시 { Period applicationPeriod = Period.createPeriod(now.minusDays(10), now.plusDays(5)); Study study = fixtureHelper.createStudy(mentor, period, applicationPeriod); - Member mentee = fixtureHelper.createGuestMember(2L); - StudyHistory studyHistory = StudyHistory.create(mentee, study); + Member student = fixtureHelper.createGuestMember(2L); + StudyHistory studyHistory = StudyHistory.create(student, study); // when & then assertThatThrownBy(() -> studyHistoryValidator.validateApplyStudy(study, List.of(studyHistory))) @@ -67,8 +68,8 @@ class 스터디_수강신청시 { Study anotherStudy = fixtureHelper.createStudy(mentor, period, applicationPeriod); - Member mentee = fixtureHelper.createGuestMember(2L); - StudyHistory studyHistory = StudyHistory.create(mentee, anotherStudy); + Member student = fixtureHelper.createGuestMember(2L); + StudyHistory studyHistory = StudyHistory.create(student, anotherStudy); // when & then assertThatThrownBy(() -> studyHistoryValidator.validateApplyStudy(study, List.of(studyHistory))) @@ -96,4 +97,31 @@ class 스터디_수강신청_취소시 { .hasMessage(STUDY_NOT_CANCELABLE_APPLICATION_PERIOD.getMessage()); } } + + @Nested + class 레포지토리_입력시 { + + @Test + void 이미_제출한_과제가_있다면_실패한다() { + // given + boolean isAnyAssignmentSubmitted = true; + + // when & then + assertThatThrownBy(() -> studyHistoryValidator.validateUpdateRepository( + isAnyAssignmentSubmitted, OAUTH_ID, OAUTH_ID)) + .isInstanceOf(CustomException.class) + .hasMessage(STUDY_HISTORY_REPOSITORY_NOT_UPDATABLE_ASSIGNMENT_ALREADY_SUBMITTED.getMessage()); + } + + @Test + void 레포지토리의_소유자와_현재_멤버가_일치하지_않는다면_실패한다() { + // given + String wrongOauthId = "1234567"; + + // when & then + assertThatThrownBy(() -> studyHistoryValidator.validateUpdateRepository(false, wrongOauthId, OAUTH_ID)) + .isInstanceOf(CustomException.class) + .hasMessage(STUDY_HISTORY_REPOSITORY_NOT_UPDATABLE_OWNER_MISMATCH.getMessage()); + } + } } diff --git a/src/test/java/com/gdschongik/gdsc/domain/study/domain/StudyTest.java b/src/test/java/com/gdschongik/gdsc/domain/study/domain/StudyTest.java index 131a58783..bd254bbaa 100644 --- a/src/test/java/com/gdschongik/gdsc/domain/study/domain/StudyTest.java +++ b/src/test/java/com/gdschongik/gdsc/domain/study/domain/StudyTest.java @@ -43,6 +43,7 @@ class 스터디_개설시 { assertThatThrownBy(() -> Study.createStudy( ACADEMIC_YEAR, SEMESTER_TYPE, + STUDY_TITLE, guestMember, period, applicationPeriod, @@ -66,6 +67,7 @@ class 스터디_개설시 { assertThatThrownBy(() -> Study.createStudy( ACADEMIC_YEAR, SEMESTER_TYPE, + STUDY_TITLE, member, period, applicationPeriod, @@ -89,6 +91,7 @@ class 스터디_개설시 { assertThatThrownBy(() -> Study.createStudy( ACADEMIC_YEAR, SEMESTER_TYPE, + STUDY_TITLE, member, period, applicationPeriod, @@ -114,6 +117,7 @@ class 스터디_개설시 { assertThatThrownBy(() -> Study.createStudy( ACADEMIC_YEAR, SEMESTER_TYPE, + STUDY_TITLE, member, period, applicationPeriod, @@ -139,6 +143,7 @@ class 스터디_개설시 { assertThatThrownBy(() -> Study.createStudy( ACADEMIC_YEAR, SEMESTER_TYPE, + STUDY_TITLE, member, period, applicationPeriod, diff --git a/src/test/java/com/gdschongik/gdsc/domain/study/domain/StudyValidatorTest.java b/src/test/java/com/gdschongik/gdsc/domain/study/domain/StudyValidatorTest.java new file mode 100644 index 000000000..602aefaa8 --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/domain/study/domain/StudyValidatorTest.java @@ -0,0 +1,133 @@ +package com.gdschongik.gdsc.domain.study.domain; + +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; +import static org.assertj.core.api.Assertions.*; + +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.recruitment.domain.vo.Period; +import com.gdschongik.gdsc.global.exception.CustomException; +import com.gdschongik.gdsc.helper.FixtureHelper; +import java.time.LocalDateTime; +import java.util.Optional; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +public class StudyValidatorTest { + + FixtureHelper fixtureHelper = new FixtureHelper(); + StudyValidator studyValidator = new StudyValidator(); + + // FixtureHelper 래핑 메서드 + private Member createMentor(Long id) { + return fixtureHelper.createMentor(id); + } + + private Member createMember(Long id) { + return fixtureHelper.createAssociateMember(id); + } + + private Member createAdmin(Long id) { + return fixtureHelper.createAdmin(id); + } + + @Nested + class 스터디_멘토역할_검증시 { + + @Test + void 멘토역할이_아니라면_실패한다() { + // given + Member currentMember = createMember(1L); + Member mentor = createMentor(2L); + LocalDateTime assignmentCreatedDate = LocalDateTime.now().minusDays(1); + Study study = fixtureHelper.createStudy( + mentor, + Period.createPeriod(assignmentCreatedDate.plusDays(5), assignmentCreatedDate.plusDays(10)), + Period.createPeriod(assignmentCreatedDate.minusDays(5), assignmentCreatedDate)); + + // when & then + assertThatThrownBy(() -> studyValidator.validateStudyMentor(currentMember, study)) + .isInstanceOf(CustomException.class) + .hasMessage(STUDY_ACCESS_NOT_ALLOWED.getMessage()); + } + + @Test + void 멘토이지만_자신이_맡은_스터디가_아니라면_실패한다() { + // given + Member currentMember = createMentor(1L); + Member mentor = createMentor(2L); + LocalDateTime assignmentCreatedDate = LocalDateTime.now().minusDays(1); + Study study = fixtureHelper.createStudy( + mentor, + Period.createPeriod(assignmentCreatedDate.plusDays(5), assignmentCreatedDate.plusDays(10)), + Period.createPeriod(assignmentCreatedDate.minusDays(5), assignmentCreatedDate)); + + Study currentMentorStudy = fixtureHelper.createStudy( + currentMember, + Period.createPeriod(assignmentCreatedDate.plusDays(5), assignmentCreatedDate.plusDays(10)), + Period.createPeriod(assignmentCreatedDate.minusDays(5), assignmentCreatedDate)); + + // when & then + assertThat(currentMentorStudy.getMentor().getId()).isEqualTo(currentMember.getId()); + assertThatThrownBy(() -> studyValidator.validateStudyMentor(currentMember, study)) + .isInstanceOf(CustomException.class) + .hasMessage(STUDY_MENTOR_INVALID.getMessage()); + } + + @Test + void 어드민이라면_성공한다() { + // given + Member admin = createAdmin(1L); + Member mentor = createMentor(2L); + LocalDateTime assignmentCreatedDate = LocalDateTime.now().minusDays(1); + Study study = fixtureHelper.createStudy( + mentor, + Period.createPeriod(assignmentCreatedDate.plusDays(5), assignmentCreatedDate.plusDays(10)), + Period.createPeriod(assignmentCreatedDate.minusDays(5), assignmentCreatedDate)); + + // when & then + assertThatCode(() -> studyValidator.validateStudyMentor(admin, study)) + .doesNotThrowAnyException(); + } + } + + @Nested + class 스터디_멘토_또는_학생역할_검증시 { + + @Test + void 수강하지않는_스터디가_아니라면_실패한다() { + // given + Member student = createMember(1L); + Member mentor = createMentor(2L); + LocalDateTime assignmentCreatedDate = LocalDateTime.now().minusDays(1); + Study study = fixtureHelper.createStudy( + mentor, + Period.createPeriod(assignmentCreatedDate.plusDays(5), assignmentCreatedDate.plusDays(10)), + Period.createPeriod(assignmentCreatedDate.minusDays(5), assignmentCreatedDate)); + StudyHistory studyHistory = null; + + // when & then + assertThatThrownBy(() -> studyValidator.validateStudyMentorOrStudent( + student, study, Optional.ofNullable(studyHistory))) + .isInstanceOf(CustomException.class) + .hasMessage(STUDY_ACCESS_NOT_ALLOWED.getMessage()); + } + + @Test + void 멘토이지만_자신이_맡은_스터디가_아니라면_실패한다() { + // given + Member currentMember = createMentor(1L); + Member mentor = createMentor(2L); + LocalDateTime assignmentCreatedDate = LocalDateTime.now().minusDays(1); + Study study = fixtureHelper.createStudy( + mentor, + Period.createPeriod(assignmentCreatedDate.plusDays(5), assignmentCreatedDate.plusDays(10)), + Period.createPeriod(assignmentCreatedDate.minusDays(5), assignmentCreatedDate)); + + // when & then + assertThatThrownBy( + () -> studyValidator.validateStudyMentorOrStudent(currentMember, study, Optional.empty())) + .isInstanceOf(CustomException.class) + .hasMessage(STUDY_MENTOR_INVALID.getMessage()); + } + } +} diff --git a/src/test/java/com/gdschongik/gdsc/global/common/constant/StudyConstant.java b/src/test/java/com/gdschongik/gdsc/global/common/constant/StudyConstant.java index b9839811b..19714d487 100644 --- a/src/test/java/com/gdschongik/gdsc/global/common/constant/StudyConstant.java +++ b/src/test/java/com/gdschongik/gdsc/global/common/constant/StudyConstant.java @@ -2,11 +2,13 @@ import com.gdschongik.gdsc.domain.study.domain.StudyType; import java.time.DayOfWeek; +import java.time.LocalDateTime; import java.time.LocalTime; public class StudyConstant { private StudyConstant() {} + public static final String STUDY_TITLE = "스터디 제목"; public static final Long TOTAL_WEEK = 8L; public static final StudyType ONLINE_STUDY = StudyType.ONLINE; public static final StudyType ASSIGNMENT_STUDY = StudyType.ASSIGNMENT; @@ -20,4 +22,28 @@ private StudyConstant() {} // Assignment public static final String ASSIGNMENT_TITLE = "testTitle"; public static final String DESCRIPTION_LINK = "www.link.com"; + + // Study (2024-09-01 ~ 2024-10-27) + public static final LocalDateTime STUDY_START_DATETIME = LocalDateTime.of(2024, 9, 1, 0, 0); + public static final LocalDateTime STUDY_END_DATETIME = STUDY_START_DATETIME.plusWeeks(8); + public static final String STUDY_NOTION_LINK = "notionLink"; + public static final String STUDY_INTRODUCTION = "introduction"; + + // StudyDetail (1주차: 2024-09-01 ~ 2024-09-08) + public static final LocalDateTime STUDY_DETAIL_START_DATETIME = STUDY_START_DATETIME; + public static final LocalDateTime STUDY_DETAIL_END_DATETIME = STUDY_DETAIL_START_DATETIME.plusWeeks(1); + public static final LocalDateTime STUDY_ASSIGNMENT_DEADLINE_DATETIME = STUDY_DETAIL_END_DATETIME; + + // Session + public static final String SESSION_TITLE = "sessionTitle"; + public static final String SESSION_DESCRIPTION = "sessionDescription"; + + // AssignmentHistory + public static final String SUBMISSION_LINK = "https://github.com/ownername/reponame/blob/main/week1/WIL.md"; + public static final String COMMIT_HASH = "aa11bb22cc33"; + public static final Integer CONTENT_LENGTH = 2000; + public static final LocalDateTime COMMITTED_AT = LocalDateTime.of(2024, 9, 8, 0, 0); + + // StudyHistory + public static final String REPOSITORY_LINK = "ownername/reponame"; } diff --git a/src/test/java/com/gdschongik/gdsc/helper/FixtureHelper.java b/src/test/java/com/gdschongik/gdsc/helper/FixtureHelper.java index f6a4c1cdf..184dec360 100644 --- a/src/test/java/com/gdschongik/gdsc/helper/FixtureHelper.java +++ b/src/test/java/com/gdschongik/gdsc/helper/FixtureHelper.java @@ -1,7 +1,8 @@ package com.gdschongik.gdsc.helper; import static com.gdschongik.gdsc.domain.member.domain.Department.*; -import static com.gdschongik.gdsc.domain.member.domain.Member.*; +import static com.gdschongik.gdsc.domain.member.domain.MemberManageRole.ADMIN; +import static com.gdschongik.gdsc.domain.member.domain.MemberStudyRole.MENTOR; import static com.gdschongik.gdsc.global.common.constant.MemberConstant.*; import static com.gdschongik.gdsc.global.common.constant.RecruitmentConstant.*; import static com.gdschongik.gdsc.global.common.constant.SemesterConstant.*; @@ -46,6 +47,19 @@ public Member createRegularMember(Long id) { return member; } + public Member createAdmin(Long id) { + Member member = createRegularMember(id); + ReflectionTestUtils.setField(member, "manageRole", ADMIN); + return member; + } + + public Member createMentor(Long id) { + Member member = createRegularMember(id); + member.assignToMentor(); + ReflectionTestUtils.setField(member, "studyRole", MENTOR); + return member; + } + public RecruitmentRound createRecruitmentRound( LocalDateTime startDate, LocalDateTime endDate, @@ -71,6 +85,7 @@ public Study createStudy(Member mentor, Period period, Period applicationPeriod) return Study.createStudy( ACADEMIC_YEAR, SEMESTER_TYPE, + STUDY_TITLE, mentor, period, applicationPeriod, @@ -81,7 +96,27 @@ public Study createStudy(Member mentor, Period period, Period applicationPeriod) STUDY_END_TIME); } + public Study createStudyWithMentor(Long mentorId, Period period, Period applicationPeriod) { + Member mentor = createAssociateMember(mentorId); + return createStudy(mentor, period, applicationPeriod); + } + public StudyDetail createStudyDetail(Study study, LocalDateTime startDate, LocalDateTime endDate) { return StudyDetail.createStudyDetail(study, 1L, ATTENDANCE_NUMBER, Period.createPeriod(startDate, endDate)); } + + public StudyDetail createNewStudyDetail( + Long id, Study study, Long week, LocalDateTime startDate, LocalDateTime endDate) { + StudyDetail studyDetail = + StudyDetail.createStudyDetail(study, week, ATTENDANCE_NUMBER, Period.createPeriod(startDate, endDate)); + ReflectionTestUtils.setField(studyDetail, "id", id); + return studyDetail; + } + + public StudyDetail createStudyDetailWithAssignment( + Study study, LocalDateTime startDate, LocalDateTime endDate, LocalDateTime deadline) { + StudyDetail studyDetail = createStudyDetail(study, startDate, endDate); + studyDetail.publishAssignment(ASSIGNMENT_TITLE, deadline, DESCRIPTION_LINK); + return studyDetail; + } } diff --git a/src/test/java/com/gdschongik/gdsc/helper/IntegrationTest.java b/src/test/java/com/gdschongik/gdsc/helper/IntegrationTest.java index 3401a349a..1bfbac94d 100644 --- a/src/test/java/com/gdschongik/gdsc/helper/IntegrationTest.java +++ b/src/test/java/com/gdschongik/gdsc/helper/IntegrationTest.java @@ -36,6 +36,7 @@ import com.gdschongik.gdsc.domain.study.domain.StudyDetail; import com.gdschongik.gdsc.global.security.PrincipalDetails; import com.gdschongik.gdsc.infra.feign.payment.client.PaymentClient; +import com.gdschongik.gdsc.infra.github.client.GithubClient; import java.time.LocalDateTime; import org.junit.jupiter.api.BeforeEach; import org.springframework.beans.factory.annotation.Autowired; @@ -86,6 +87,9 @@ public abstract class IntegrationTest { @MockBean protected PaymentClient paymentClient; + @MockBean + protected GithubClient githubClient; + @MockBean protected DelegateMemberDiscordEventHandler delegateMemberDiscordEventHandler; @@ -165,6 +169,12 @@ protected Member createRegularMember() { return memberRepository.save(member); } + public Member createMentor() { + Member member = createAssociateMember(); + member.assignToMentor(); + return memberRepository.save(member); + } + protected RecruitmentRound createRecruitmentRound() { Recruitment recruitment = createRecruitment(ACADEMIC_YEAR, SEMESTER_TYPE, FEE); @@ -210,6 +220,7 @@ protected Study createStudy(Member mentor, Period period, Period applicationPeri Study study = Study.createStudy( ACADEMIC_YEAR, SEMESTER_TYPE, + STUDY_TITLE, mentor, period, applicationPeriod, @@ -222,9 +233,37 @@ protected Study createStudy(Member mentor, Period period, Period applicationPeri return studyRepository.save(study); } + protected Study createNewStudy(Member mentor, Long totalWeek, Period period, Period applicationPeriod) { + Study study = Study.createStudy( + ACADEMIC_YEAR, + SEMESTER_TYPE, + STUDY_TITLE, + mentor, + period, + applicationPeriod, + totalWeek, + ONLINE_STUDY, + DAY_OF_WEEK, + STUDY_START_TIME, + STUDY_END_TIME); + + return studyRepository.save(study); + } + protected StudyDetail createStudyDetail(Study study, LocalDateTime startDate, LocalDateTime endDate) { StudyDetail studyDetail = StudyDetail.createStudyDetail(study, 1L, ATTENDANCE_NUMBER, Period.createPeriod(startDate, endDate)); return studyDetailRepository.save(studyDetail); } + + protected StudyDetail createNewStudyDetail(Long week, Study study, LocalDateTime startDate, LocalDateTime endDate) { + StudyDetail studyDetail = + StudyDetail.createStudyDetail(study, week, ATTENDANCE_NUMBER, Period.createPeriod(startDate, endDate)); + return studyDetailRepository.save(studyDetail); + } + + protected StudyDetail publishAssignment(StudyDetail studyDetail) { + studyDetail.publishAssignment(ASSIGNMENT_TITLE, studyDetail.getPeriod().getEndDate(), DESCRIPTION_LINK); + return studyDetailRepository.save(studyDetail); + } }