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..4d6c556ff --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/api/StudentStudyHistoryController.java @@ -0,0 +1,32 @@ +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 io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +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 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(); + } +} 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..df253f536 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/application/StudentStudyHistoryService.java @@ -0,0 +1,62 @@ +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.StudyHistoryRepository; +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.request.RepositoryUpdateRequest; +import com.gdschongik.gdsc.global.exception.CustomException; +import com.gdschongik.gdsc.global.util.MemberUtil; +import com.gdschongik.gdsc.infra.client.github.GithubClient; +import java.io.IOException; +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 StudyHistoryRepository studyHistoryRepository; + private final AssignmentHistoryRepository assignmentHistoryRepository; + private final StudyHistoryValidator studyHistoryValidator; + + @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); + 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); + } +} 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..89766c38a --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dao/AssignmentHistoryCustomRepository.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.Study; + +public interface AssignmentHistoryCustomRepository { + + boolean existsSubmittedAssignmentByMemberAndStudy(Member member, Study study); +} 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..c212e0f50 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dao/AssignmentHistoryCustomRepositoryImpl.java @@ -0,0 +1,39 @@ +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 com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.study.domain.Study; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +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); + } +} 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..89ed0c714 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/study/dao/AssignmentHistoryRepository.java @@ -0,0 +1,7 @@ +package com.gdschongik.gdsc.domain.study.dao; + +import com.gdschongik.gdsc.domain.study.domain.AssignmentHistory; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AssignmentHistoryRepository + extends JpaRepository, AssignmentHistoryCustomRepository {} 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..52689f528 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 @@ -33,6 +33,8 @@ public class StudyHistory extends BaseEntity { @JoinColumn(name = "study_id") private Study study; + private String repositoryLink; + @Builder(access = AccessLevel.PRIVATE) private StudyHistory(Member mentee, Study study) { this.mentee = mentee; @@ -43,6 +45,13 @@ public static StudyHistory create(Member mentee, Study study) { return StudyHistory.builder().mentee(mentee).study(study).build(); } + /** + * 레포지토리 링크를 업데이트합니다. + */ + public void updateRepositoryLink(String repositoryLink) { + this.repositoryLink = repositoryLink; + } + // 데이터 전달 로직 public boolean isStudyOngoing() { return study.isStudyOngoing(); 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/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/global/common/constant/GithubConstant.java b/src/main/java/com/gdschongik/gdsc/global/common/constant/GithubConstant.java new file mode 100644 index 000000000..fc593abc6 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/global/common/constant/GithubConstant.java @@ -0,0 +1,8 @@ +package com.gdschongik.gdsc.global.common.constant; + +public class GithubConstant { + + public static final String GITHUB_DOMAIN = "github.com/"; + + private GithubConstant() {} +} 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..b17d81072 100644 --- a/src/main/java/com/gdschongik/gdsc/global/exception/ErrorCode.java +++ b/src/main/java/com/gdschongik/gdsc/global/exception/ErrorCode.java @@ -117,6 +117,9 @@ public enum ErrorCode { 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, "레포지토리 소유자가 현재 멤버와 다릅니다."), // Order ORDER_NOT_FOUND(HttpStatus.NOT_FOUND, "주문이 존재하지 않습니다."), @@ -142,7 +145,10 @@ public enum ErrorCode { // Assignment ASSIGNMENT_CAN_NOT_BE_UPDATED(HttpStatus.CONFLICT, "휴강인 과제는 수정할 수 없습니다."), - ASSIGNMENT_DEADLINE_INVALID(HttpStatus.CONFLICT, "과제 마감 기한이 현재보다 빠릅니다."); + ASSIGNMENT_DEADLINE_INVALID(HttpStatus.CONFLICT, "과제 마감 기한이 현재보다 빠릅니다."), + + // Github + GITHUB_REPOSITORY_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 레포지토리입니다."); private final HttpStatus status; private final String message; 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 index e3235dfb7..6085c2c03 100644 --- a/src/main/java/com/gdschongik/gdsc/infra/client/github/GithubClient.java +++ b/src/main/java/com/gdschongik/gdsc/infra/client/github/GithubClient.java @@ -1,6 +1,10 @@ package com.gdschongik.gdsc.infra.client.github; +import com.gdschongik.gdsc.global.exception.CustomException; +import com.gdschongik.gdsc.global.exception.ErrorCode; +import java.io.IOException; import lombok.RequiredArgsConstructor; +import org.kohsuke.github.GHRepository; import org.kohsuke.github.GitHub; import org.springframework.stereotype.Component; @@ -9,4 +13,12 @@ public class GithubClient { private final GitHub github; + + public GHRepository getRepository(String ownerRepo) { + try { + return github.getRepository(ownerRepo); + } catch (IOException e) { + throw new CustomException(ErrorCode.GITHUB_REPOSITORY_NOT_FOUND); + } + } } 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..f72b97dba 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.*; @@ -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()); + } + } }