diff --git a/src/main/java/com/flytrap/venusplanner/api/join_request/business/service/JoinRequestCurdFacadeService.java b/src/main/java/com/flytrap/venusplanner/api/join_request/business/service/JoinRequestCurdFacadeService.java new file mode 100644 index 0000000..d01124d --- /dev/null +++ b/src/main/java/com/flytrap/venusplanner/api/join_request/business/service/JoinRequestCurdFacadeService.java @@ -0,0 +1,76 @@ +package com.flytrap.venusplanner.api.join_request.business.service; + +import static com.flytrap.venusplanner.api.join_request.exception.JoinRequestExceptionType.DuplicateJoinRequestException; +import static com.flytrap.venusplanner.api.join_request.exception.JoinRequestExceptionType.JoinRequestAlreadyHandledException; +import static com.flytrap.venusplanner.api.study.exception.StudyExceptionType.StudyAlreadyJoinedException; +import static com.flytrap.venusplanner.api.study.exception.StudyExceptionType.StudyMismatchException; + +import com.flytrap.venusplanner.api.join_request.domain.JoinRequest; +import com.flytrap.venusplanner.api.member_study.business.service.MemberStudyValidator; +import com.flytrap.venusplanner.api.study.business.service.StudyValidator; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class JoinRequestCurdFacadeService { + + private final JoinRequestUpdater joinRequestUpdater; + private final JoinRequestValidator joinRequestValidator; + private final StudyValidator studyValidator; + private final MemberStudyValidator memberStudyValidator; + + @Transactional + public JoinRequest createJoinRequest(Long studyId, Long memberId) { + studyValidator.validateStudyExists(studyId); + + if (memberStudyValidator.validateMemberBelongsToStudy(memberId, studyId)) { + throw StudyAlreadyJoinedException(); + } + + if (joinRequestValidator.validateWaitingJoinRequestExists(studyId, memberId)) { + throw DuplicateJoinRequestException(); + } + + return joinRequestUpdater.saveJoinRequest(studyId, memberId); + } + + @Transactional + public void acceptJoinRequest(Long studyId, Long requestId, Long memberId) { + studyValidator.validateStudyExists(studyId); + memberStudyValidator.validateMemberCanAcceptJoinRequest(memberId, studyId); + + JoinRequest joinRequest = joinRequestValidator.findById(requestId); + + if (!joinRequest.validateStudyIdMatch(studyId)) { + throw StudyMismatchException("요청한 스터디 ID와 가입 요청의 스터디 ID가 일치하지 않습니다."); + } + + if (!joinRequest.isWaiting()) { + throw JoinRequestAlreadyHandledException(); + } + + joinRequest.accept(); + } + + @Transactional + public void rejectJoinRequest(Long studyId, Long requestId, Long memberId) { + studyValidator.validateStudyExists(studyId); + memberStudyValidator.validateMemberCanRejectJoinRequest(memberId, studyId); + + JoinRequest joinRequest = joinRequestValidator.findById(requestId); + + if (!joinRequest.validateStudyIdMatch(studyId)) { + throw StudyMismatchException("요청한 스터디 ID와 가입 요청의 스터디 ID가 일치하지 않습니다."); + } + + if (!joinRequest.isWaiting()) { + throw JoinRequestAlreadyHandledException(); + } + + joinRequest.reject(); + } + +} diff --git a/src/main/java/com/flytrap/venusplanner/api/join_request/business/service/JoinRequestService.java b/src/main/java/com/flytrap/venusplanner/api/join_request/business/service/JoinRequestService.java new file mode 100644 index 0000000..b76a5ce --- /dev/null +++ b/src/main/java/com/flytrap/venusplanner/api/join_request/business/service/JoinRequestService.java @@ -0,0 +1,36 @@ +package com.flytrap.venusplanner.api.join_request.business.service; + +import static com.flytrap.venusplanner.api.join_request.exception.JoinRequestExceptionType.JoinRequestNotFoundException; + +import com.flytrap.venusplanner.api.join_request.domain.JoinRequest; +import com.flytrap.venusplanner.api.join_request.domain.JoinRequestState; +import com.flytrap.venusplanner.api.join_request.infrastructure.repository.JoinRequestRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class JoinRequestService implements JoinRequestUpdater, JoinRequestValidator { + + private final JoinRequestRepository joinRequestRepository; + + @Override + @Transactional + public JoinRequest saveJoinRequest(Long studyId, Long memberId) { + return joinRequestRepository.save(JoinRequest.create(studyId, memberId)); + } + + @Override + public boolean validateWaitingJoinRequestExists(Long studyId, Long memberId) { + return joinRequestRepository.existsByMemberIdAndStudyIdAndState( + memberId, studyId, JoinRequestState.WAIT); + } + + @Override + public JoinRequest findById(Long joinRequestId) { + return joinRequestRepository.findById(joinRequestId) + .orElseThrow(() -> JoinRequestNotFoundException(joinRequestId)); + } +} diff --git a/src/main/java/com/flytrap/venusplanner/api/join_request/business/service/JoinRequestUpdater.java b/src/main/java/com/flytrap/venusplanner/api/join_request/business/service/JoinRequestUpdater.java new file mode 100644 index 0000000..b33edab --- /dev/null +++ b/src/main/java/com/flytrap/venusplanner/api/join_request/business/service/JoinRequestUpdater.java @@ -0,0 +1,8 @@ +package com.flytrap.venusplanner.api.join_request.business.service; + +import com.flytrap.venusplanner.api.join_request.domain.JoinRequest; + +public interface JoinRequestUpdater { + + JoinRequest saveJoinRequest(Long studyId, Long memberId); +} diff --git a/src/main/java/com/flytrap/venusplanner/api/join_request/business/service/JoinRequestValidator.java b/src/main/java/com/flytrap/venusplanner/api/join_request/business/service/JoinRequestValidator.java new file mode 100644 index 0000000..c434c06 --- /dev/null +++ b/src/main/java/com/flytrap/venusplanner/api/join_request/business/service/JoinRequestValidator.java @@ -0,0 +1,8 @@ +package com.flytrap.venusplanner.api.join_request.business.service; + +import com.flytrap.venusplanner.api.join_request.domain.JoinRequest; + +public interface JoinRequestValidator { + boolean validateWaitingJoinRequestExists(Long studyId, Long memberId); + JoinRequest findById(Long joinRequestId); +} diff --git a/src/main/java/com/flytrap/venusplanner/api/join_request/domain/JoinRequest.java b/src/main/java/com/flytrap/venusplanner/api/join_request/domain/JoinRequest.java new file mode 100644 index 0000000..5095816 --- /dev/null +++ b/src/main/java/com/flytrap/venusplanner/api/join_request/domain/JoinRequest.java @@ -0,0 +1,65 @@ +package com.flytrap.venusplanner.api.join_request.domain; + +import com.flytrap.venusplanner.global.entity.TimeAuditingBaseEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.validation.constraints.NotNull; +import java.util.Objects; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class JoinRequest extends TimeAuditingBaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotNull + private Long memberId; + + @NotNull + private Long studyId; + + @Enumerated(EnumType.STRING) + private JoinRequestState state; + + @Builder + private JoinRequest(Long memberId, Long studyId, JoinRequestState state) { + this.memberId = memberId; + this.studyId = studyId; + this.state = state; + } + + public static JoinRequest create(Long studyId, Long memberId) { + return JoinRequest.builder() + .studyId(studyId).memberId(memberId) + .state(JoinRequestState.WAIT) + .build(); + } + + public boolean isWaiting() { + return this.state == JoinRequestState.WAIT; + } + + public boolean validateStudyIdMatch(Long studyId) { + return Objects.equals(this.studyId, studyId); + } + + public void accept() { + this.state = JoinRequestState.ACCEPT; + } + + public void reject() { + this.state = JoinRequestState.REJECT; + } + +} diff --git a/src/main/java/com/flytrap/venusplanner/api/join_request/domain/JoinRequestState.java b/src/main/java/com/flytrap/venusplanner/api/join_request/domain/JoinRequestState.java new file mode 100644 index 0000000..6649184 --- /dev/null +++ b/src/main/java/com/flytrap/venusplanner/api/join_request/domain/JoinRequestState.java @@ -0,0 +1,5 @@ +package com.flytrap.venusplanner.api.join_request.domain; + +public enum JoinRequestState { + WAIT, ACCEPT, REJECT +} diff --git a/src/main/java/com/flytrap/venusplanner/api/join_request/exception/JoinRequestExceptionType.java b/src/main/java/com/flytrap/venusplanner/api/join_request/exception/JoinRequestExceptionType.java new file mode 100644 index 0000000..db35fe4 --- /dev/null +++ b/src/main/java/com/flytrap/venusplanner/api/join_request/exception/JoinRequestExceptionType.java @@ -0,0 +1,21 @@ +package com.flytrap.venusplanner.api.join_request.exception; + +import com.flytrap.venusplanner.api.join_request.domain.JoinRequest; +import com.flytrap.venusplanner.global.exception.CustomException; +import com.flytrap.venusplanner.global.exception.GeneralExceptionType; + +public class JoinRequestExceptionType extends GeneralExceptionType { + + public static CustomException JoinRequestNotFoundException(Long joinRequestId) { + return DomainNotFoundException(JoinRequest.class, joinRequestId); + } + + public static CustomException DuplicateJoinRequestException() { + return DuplicateDomainException("이미 가입 요청된 상태입니다."); + } + + public static CustomException JoinRequestAlreadyHandledException() { + return DuplicateDomainException("이미 처리된 가입 요청입니다"); + } + +} diff --git a/src/main/java/com/flytrap/venusplanner/api/join_request/infrastructure/repository/JoinRequestRepository.java b/src/main/java/com/flytrap/venusplanner/api/join_request/infrastructure/repository/JoinRequestRepository.java new file mode 100644 index 0000000..d154c71 --- /dev/null +++ b/src/main/java/com/flytrap/venusplanner/api/join_request/infrastructure/repository/JoinRequestRepository.java @@ -0,0 +1,10 @@ +package com.flytrap.venusplanner.api.join_request.infrastructure.repository; + +import com.flytrap.venusplanner.api.join_request.domain.JoinRequest; +import com.flytrap.venusplanner.api.join_request.domain.JoinRequestState; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface JoinRequestRepository extends JpaRepository { + + boolean existsByMemberIdAndStudyIdAndState(Long memberId, Long studyId, JoinRequestState state); +} diff --git a/src/main/java/com/flytrap/venusplanner/api/join_request/presentation/controller/JoinRequestController.java b/src/main/java/com/flytrap/venusplanner/api/join_request/presentation/controller/JoinRequestController.java new file mode 100644 index 0000000..f270bfb --- /dev/null +++ b/src/main/java/com/flytrap/venusplanner/api/join_request/presentation/controller/JoinRequestController.java @@ -0,0 +1,56 @@ +package com.flytrap.venusplanner.api.join_request.presentation.controller; + +import com.flytrap.venusplanner.api.join_request.business.service.JoinRequestCurdFacadeService; +import com.flytrap.venusplanner.api.join_request.domain.JoinRequest; +import com.flytrap.venusplanner.api.join_request.presentation.dto.response.JoinRequestCreateResponse; +import com.flytrap.venusplanner.global.auth.annotation.SignIn; +import com.flytrap.venusplanner.global.auth.dto.SessionMember; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class JoinRequestController { + + private final JoinRequestCurdFacadeService joinRequestCrudService; + + @PostMapping("/api/v1/studies/{studyId}/join-requests") + public ResponseEntity requestToJoinStudy( + @PathVariable Long studyId, + @SignIn SessionMember sessionMember + ) { + JoinRequest joinRequest = joinRequestCrudService + .createJoinRequest(studyId, sessionMember.id()); + + return ResponseEntity.status(HttpStatus.CREATED) + .body(new JoinRequestCreateResponse(joinRequest.getId())); + } + + @PatchMapping("/api/v1/studies/{studyId}/join-requests/{requestId}/accept") + public ResponseEntity acceptJoinRequest( + @PathVariable Long studyId, + @PathVariable Long requestId, + @SignIn SessionMember sessionMember + ) { + joinRequestCrudService.acceptJoinRequest(studyId, requestId, sessionMember.id()); + + return ResponseEntity.ok().build(); + } + + @PatchMapping("/api/v1/studies/{studyId}/join-requests/{requestId}/reject") + public ResponseEntity rejectJoinRequest( + @PathVariable Long studyId, + @PathVariable Long requestId, + @SignIn SessionMember sessionMember + ) { + joinRequestCrudService.rejectJoinRequest(studyId, requestId, sessionMember.id()); + + return ResponseEntity.ok().build(); + } + +} diff --git a/src/main/java/com/flytrap/venusplanner/api/join_request/presentation/dto/response/JoinRequestCreateResponse.java b/src/main/java/com/flytrap/venusplanner/api/join_request/presentation/dto/response/JoinRequestCreateResponse.java new file mode 100644 index 0000000..8cfa7e3 --- /dev/null +++ b/src/main/java/com/flytrap/venusplanner/api/join_request/presentation/dto/response/JoinRequestCreateResponse.java @@ -0,0 +1,6 @@ +package com.flytrap.venusplanner.api.join_request.presentation.dto.response; + +public record JoinRequestCreateResponse( + Long id +) { +} diff --git a/src/main/java/com/flytrap/venusplanner/api/member_study/business/service/MemberStudyService.java b/src/main/java/com/flytrap/venusplanner/api/member_study/business/service/MemberStudyService.java index 487ff28..bb77d28 100644 --- a/src/main/java/com/flytrap/venusplanner/api/member_study/business/service/MemberStudyService.java +++ b/src/main/java/com/flytrap/venusplanner/api/member_study/business/service/MemberStudyService.java @@ -1,5 +1,7 @@ package com.flytrap.venusplanner.api.member_study.business.service; +import static com.flytrap.venusplanner.global.exception.GeneralExceptionType.ForbiddenException; + import com.flytrap.venusplanner.api.member_study.domain.MemberStudy; import com.flytrap.venusplanner.api.member_study.infrastructure.repository.MemberStudyRepository; import com.flytrap.venusplanner.api.study.domain.Study; @@ -11,7 +13,7 @@ @Service @RequiredArgsConstructor @Transactional(readOnly = true) -public class MemberStudyService { +public class MemberStudyService implements MemberStudyValidator { private final MemberStudyRepository memberStudyRepository; @@ -22,4 +24,29 @@ public void saveLeaderStudy(SessionMember leader, Study study) { ); memberStudyRepository.save(memberStudy); } + + @Override + public boolean validateMemberBelongsToStudy(Long memberId, Long studyId) { + return memberStudyRepository.existsByStudyIdAndMemberId(studyId, memberId); + } + + @Override + public void validateMemberCanAcceptJoinRequest(Long memberId, Long studyId) { + MemberStudy memberStudy = memberStudyRepository.findByStudyIdAndMemberId(studyId, memberId) + .orElseThrow(() -> ForbiddenException("스터디 멤버가 아닙니다.")); + + if (!memberStudy.canAcceptStudyJoinRequest()) { + throw ForbiddenException("수락 권한이 없습니다."); + } + } + + @Override + public void validateMemberCanRejectJoinRequest(Long memberId, Long studyId) { + MemberStudy memberStudy = memberStudyRepository.findByStudyIdAndMemberId(studyId, memberId) + .orElseThrow(() -> ForbiddenException("스터디 멤버가 아닙니다.")); + + if (!memberStudy.canAcceptStudyJoinRequest()) { + throw ForbiddenException("거절 권한이 없습니다."); + } + } } diff --git a/src/main/java/com/flytrap/venusplanner/api/member_study/business/service/MemberStudyValidator.java b/src/main/java/com/flytrap/venusplanner/api/member_study/business/service/MemberStudyValidator.java new file mode 100644 index 0000000..9480a7d --- /dev/null +++ b/src/main/java/com/flytrap/venusplanner/api/member_study/business/service/MemberStudyValidator.java @@ -0,0 +1,8 @@ +package com.flytrap.venusplanner.api.member_study.business.service; + +public interface MemberStudyValidator { + + boolean validateMemberBelongsToStudy(Long memberId, Long studyId); + void validateMemberCanAcceptJoinRequest(Long memberId, Long studyId); + void validateMemberCanRejectJoinRequest(Long memberId, Long studyId); +} diff --git a/src/main/java/com/flytrap/venusplanner/api/member_study/domain/MemberStudy.java b/src/main/java/com/flytrap/venusplanner/api/member_study/domain/MemberStudy.java index a0cb6a9..da5aed1 100644 --- a/src/main/java/com/flytrap/venusplanner/api/member_study/domain/MemberStudy.java +++ b/src/main/java/com/flytrap/venusplanner/api/member_study/domain/MemberStudy.java @@ -9,6 +9,7 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.ManyToOne; +import java.util.Objects; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -47,4 +48,16 @@ public static MemberStudy fromLeader(Long leaderId, Study study) { .permissionId(Permission.EDIT.getId()) .build(); } + + public boolean isLeader() { + return Objects.equals(rollId, Roll.LEADER.getId()); + } + + public boolean canAcceptStudyJoinRequest() { + return isLeader(); + } + + public boolean canRejectStudyJoinRequest() { + return isLeader(); + } } diff --git a/src/main/java/com/flytrap/venusplanner/api/member_study/infrastructure/repository/MemberStudyRepository.java b/src/main/java/com/flytrap/venusplanner/api/member_study/infrastructure/repository/MemberStudyRepository.java index 11546ea..67664ca 100644 --- a/src/main/java/com/flytrap/venusplanner/api/member_study/infrastructure/repository/MemberStudyRepository.java +++ b/src/main/java/com/flytrap/venusplanner/api/member_study/infrastructure/repository/MemberStudyRepository.java @@ -1,9 +1,13 @@ package com.flytrap.venusplanner.api.member_study.infrastructure.repository; import com.flytrap.venusplanner.api.member_study.domain.MemberStudy; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @Repository public interface MemberStudyRepository extends JpaRepository { + + boolean existsByStudyIdAndMemberId(Long studyId, Long memberId); + Optional findByStudyIdAndMemberId(Long studyId, Long memberId); } diff --git a/src/main/java/com/flytrap/venusplanner/api/study/business/service/StudyService.java b/src/main/java/com/flytrap/venusplanner/api/study/business/service/StudyService.java index 50ae1eb..e54d3eb 100644 --- a/src/main/java/com/flytrap/venusplanner/api/study/business/service/StudyService.java +++ b/src/main/java/com/flytrap/venusplanner/api/study/business/service/StudyService.java @@ -1,5 +1,7 @@ package com.flytrap.venusplanner.api.study.business.service; +import static com.flytrap.venusplanner.api.study.exception.StudyExceptionType.StudyNotFoundException; + import com.flytrap.venusplanner.api.study.domain.Study; import com.flytrap.venusplanner.api.study.infrastructure.repository.StudyRepository; import lombok.RequiredArgsConstructor; @@ -9,7 +11,7 @@ @Service @RequiredArgsConstructor @Transactional(readOnly = true) -public class StudyService { +public class StudyService implements StudyValidator { private final StudyRepository studyRepository; @@ -19,7 +21,13 @@ public Study saveStudy(Study study) { } public Study findById(Long studyId) { - //TODO: optional null 처리 - return studyRepository.findById(studyId).get(); + return studyRepository.findById(studyId).orElseThrow(() -> StudyNotFoundException(studyId)); + } + + @Override + public void validateStudyExists(Long studyId) { + if (!studyRepository.existsById(studyId)) { + throw StudyNotFoundException(studyId); + } } } diff --git a/src/main/java/com/flytrap/venusplanner/api/study/business/service/StudyValidator.java b/src/main/java/com/flytrap/venusplanner/api/study/business/service/StudyValidator.java new file mode 100644 index 0000000..039aa63 --- /dev/null +++ b/src/main/java/com/flytrap/venusplanner/api/study/business/service/StudyValidator.java @@ -0,0 +1,5 @@ +package com.flytrap.venusplanner.api.study.business.service; + +public interface StudyValidator { + void validateStudyExists(Long studyId); +} diff --git a/src/main/java/com/flytrap/venusplanner/api/study/exception/StudyExceptionType.java b/src/main/java/com/flytrap/venusplanner/api/study/exception/StudyExceptionType.java new file mode 100644 index 0000000..7b24417 --- /dev/null +++ b/src/main/java/com/flytrap/venusplanner/api/study/exception/StudyExceptionType.java @@ -0,0 +1,22 @@ +package com.flytrap.venusplanner.api.study.exception; + +import com.flytrap.venusplanner.api.study.domain.Study; +import com.flytrap.venusplanner.global.exception.CustomException; +import com.flytrap.venusplanner.global.exception.GeneralExceptionType; +import org.springframework.http.HttpStatus; + +public class StudyExceptionType extends GeneralExceptionType { + + public static CustomException StudyNotFoundException(Long studyId) { + return DomainNotFoundException(Study.class, studyId); + } + + public static CustomException StudyMismatchException(String message) { + return GeneralException(HttpStatus.BAD_REQUEST, message); + } + + public static CustomException StudyAlreadyJoinedException() { + return DuplicateDomainException("이미 스터디에 가입되어 있습니다."); + } + +} diff --git a/src/main/java/com/flytrap/venusplanner/global/exception/GeneralExceptionType.java b/src/main/java/com/flytrap/venusplanner/global/exception/GeneralExceptionType.java index 262f0ed..b241d99 100644 --- a/src/main/java/com/flytrap/venusplanner/global/exception/GeneralExceptionType.java +++ b/src/main/java/com/flytrap/venusplanner/global/exception/GeneralExceptionType.java @@ -44,6 +44,16 @@ public static CustomException DomainNotFoundException(Class domainClazz, Long ); } + /** + * 어떤 도메인을 중복으로 생성할 수 없을 때 사용하는 예외입니다. + * + * @param message 예외 메세지 + * @return CustomException 객체 + */ + public static CustomException DuplicateDomainException(String message) { + return new CustomException(HttpStatus.CONFLICT, message); + } + /** * 주어진 Enum 타입에 존재하지 않는 이름을 사용했을 때 발생하는 예외입니다. * diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 84471d7..8ae6435 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -1,4 +1,4 @@ ---USE venus_planner; +-- USE venus_planner; CREATE TABLE `roll` ( @@ -83,3 +83,12 @@ CREATE TABLE `permission` `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, `name` VARCHAR(10) NOT NULL COMMENT 'none/edit/members' ); + +CREATE TABLE `join_request` +( + `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `member_id` BIGINT NOT NULL, + `study_id` BIGINT NOT NULL, + `created_time` TIMESTAMP NOT NULL, + `state` ENUM('WAIT','REJECT','ACCEPT') NOT NULL +)