diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubApplicationApi.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubApplicationApi.java index 6c5b0e11..b6acaab5 100644 --- a/src/main/java/gg/agit/konect/domain/club/controller/ClubApplicationApi.java +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubApplicationApi.java @@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.RequestParam; import gg.agit.konect.domain.club.dto.ClubApplicationAnswersResponse; +import gg.agit.konect.domain.club.dto.ClubMemberApplicationAnswersResponse; import gg.agit.konect.domain.club.dto.ClubApplicationsResponse; import gg.agit.konect.domain.club.dto.ClubApplyQuestionsReplaceRequest; import gg.agit.konect.domain.club.dto.ClubApplyQuestionsResponse; @@ -115,6 +116,29 @@ ResponseEntity getApprovedMemberApplicationAnswe @UserId Integer requesterId ); + @Operation(summary = "승인된 회원들의 지원서를 리스트로 조회한다.", description = """ + - 동아리 관리자만 해당 동아리의 승인된 회원 지원서를 리스트로 조회할 수 있습니다. + - 승인된 회원별 최신 지원서 답변을 리스트로 반환합니다. + - 정렬 기준: APPLIED_AT(신청 일시), STUDENT_NUMBER(학번), NAME(이름) + - 정렬 방향: ASC(오름차순), DESC(내림차순) + - 기본 정렬: 신청 일시 오래된 순 (APPLIED_AT ASC) + + ## 에러 + - FORBIDDEN_CLUB_MANAGER_ACCESS (403): 동아리 매니저 권한이 없습니다. + - NOT_FOUND_CLUB (404): 동아리를 찾을 수 없습니다. + """) + @GetMapping("/{clubId}/member-applications/answers") + ResponseEntity getApprovedMemberApplicationAnswersList( + @PathVariable(name = "clubId") Integer clubId, + @Min(value = 1, message = "페이지 번호는 1 이상이어야 합니다.") + @RequestParam(defaultValue = "1") Integer page, + @Min(value = 1, message = "페이지 당 항목 수는 1 이상이어야 합니다.") + @RequestParam(defaultValue = "10") Integer limit, + @RequestParam(defaultValue = "APPLIED_AT") ClubApplicationSortBy sortBy, + @RequestParam(defaultValue = "ASC") Sort.Direction sortDirection, + @UserId Integer requesterId + ); + @Operation(summary = "동아리 지원 답변을 조회한다.", description = """ - 동아리 관리자만 해당 동아리의 지원 답변을 조회할 수 있습니다. diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubApplicationController.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubApplicationController.java index 3518d079..188c042a 100644 --- a/src/main/java/gg/agit/konect/domain/club/controller/ClubApplicationController.java +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubApplicationController.java @@ -11,6 +11,7 @@ import gg.agit.konect.domain.club.dto.ClubApplicationAnswersResponse; import gg.agit.konect.domain.club.dto.ClubApplicationCondition; +import gg.agit.konect.domain.club.dto.ClubMemberApplicationAnswersResponse; import gg.agit.konect.domain.club.dto.ClubApplicationsResponse; import gg.agit.konect.domain.club.dto.ClubApplyQuestionsReplaceRequest; import gg.agit.konect.domain.club.dto.ClubApplyQuestionsResponse; @@ -87,6 +88,21 @@ public ResponseEntity getApprovedMemberApplicati return ResponseEntity.ok(response); } + @Override + public ResponseEntity getApprovedMemberApplicationAnswersList( + @PathVariable(name = "clubId") Integer clubId, + @RequestParam(defaultValue = "1") Integer page, + @RequestParam(defaultValue = "10") Integer limit, + @RequestParam(defaultValue = "APPLIED_AT") ClubApplicationSortBy sortBy, + @RequestParam(defaultValue = "ASC") Sort.Direction sortDirection, + @UserId Integer requesterId + ) { + ClubApplicationCondition condition = new ClubApplicationCondition(page, limit, sortBy, sortDirection); + ClubMemberApplicationAnswersResponse response = + clubApplicationService.getApprovedMemberApplicationAnswersList(clubId, requesterId, condition); + return ResponseEntity.ok(response); + } + @Override public ResponseEntity getClubApplicationAnswers( @PathVariable(name = "clubId") Integer clubId, diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubMemberApplicationAnswersResponse.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubMemberApplicationAnswersResponse.java new file mode 100644 index 00000000..6fa44d5b --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubMemberApplicationAnswersResponse.java @@ -0,0 +1,40 @@ +package gg.agit.konect.domain.club.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import org.springframework.data.domain.Page; + +import gg.agit.konect.domain.club.model.ClubApply; +import io.swagger.v3.oas.annotations.media.Schema; + +public record ClubMemberApplicationAnswersResponse( + @Schema(description = "지원서 총 개수", example = "3", requiredMode = REQUIRED) + Long totalCount, + + @Schema(description = "현재 페이지에서 조회된 지원서 개수", example = "3", requiredMode = REQUIRED) + Integer currentCount, + + @Schema(description = "최대 페이지", example = "2", requiredMode = REQUIRED) + Integer totalPage, + + @Schema(description = "현재 페이지", example = "1", requiredMode = REQUIRED) + Integer currentPage, + + @Schema(description = "승인된 회원 지원서 목록", requiredMode = REQUIRED) + List applications +) { + public static ClubMemberApplicationAnswersResponse from( + Page page, + List applications + ) { + return new ClubMemberApplicationAnswersResponse( + page.getTotalElements(), + page.getNumberOfElements(), + page.getTotalPages(), + page.getNumber() + 1, + applications + ); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/repository/ClubApplyAnswerRepository.java b/src/main/java/gg/agit/konect/domain/club/repository/ClubApplyAnswerRepository.java index acc9fdb4..b71c5466 100644 --- a/src/main/java/gg/agit/konect/domain/club/repository/ClubApplyAnswerRepository.java +++ b/src/main/java/gg/agit/konect/domain/club/repository/ClubApplyAnswerRepository.java @@ -20,4 +20,13 @@ public interface ClubApplyAnswerRepository extends Repository findAllByApplyIdWithQuestion(@Param("applyId") Integer applyId); + + @Query(""" + SELECT answer + FROM ClubApplyAnswer answer + JOIN FETCH answer.question question + WHERE answer.apply.id IN :applyIds + ORDER BY answer.apply.id ASC, question.displayOrder ASC, question.id ASC + """) + List findAllByApplyIdsWithQuestion(@Param("applyIds") List applyIds); } diff --git a/src/main/java/gg/agit/konect/domain/club/repository/ClubApplyQueryRepository.java b/src/main/java/gg/agit/konect/domain/club/repository/ClubApplyQueryRepository.java index 002f5435..69dd1281 100644 --- a/src/main/java/gg/agit/konect/domain/club/repository/ClubApplyQueryRepository.java +++ b/src/main/java/gg/agit/konect/domain/club/repository/ClubApplyQueryRepository.java @@ -16,6 +16,7 @@ import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.JPAExpressions; +import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; import gg.agit.konect.domain.club.dto.ClubApplicationCondition; @@ -158,21 +159,7 @@ public Page findApprovedMemberApplicationsByClubId( condition.sortDirection() ); - BooleanExpression isClubMember = isAlreadyClubMember(clubId); - BooleanExpression activeUserOnly = user.deletedAt.isNull(); - BooleanExpression approvedOnly = clubApply.status.eq(ClubApplyStatus.APPROVED); - BooleanExpression latestApprovedApplicationOnly = isLatestApprovedApplicationByUser(clubId); - - List content = jpaQueryFactory - .selectFrom(clubApply) - .join(clubApply.user, user).fetchJoin() - .where( - clubApply.club.id.eq(clubId), - activeUserOnly, - isClubMember, - approvedOnly, - latestApprovedApplicationOnly - ) + List content = approvedMemberApplicationBaseQuery(clubId) .orderBy(orderSpecifier) .offset(pageable.getOffset()) .limit(pageable.getPageSize()) @@ -181,18 +168,30 @@ public Page findApprovedMemberApplicationsByClubId( Long total = jpaQueryFactory .select(clubApply.count()) .from(clubApply) - .where( - clubApply.club.id.eq(clubId), - activeUserOnly, - isClubMember, - approvedOnly, - latestApprovedApplicationOnly - ) + .join(clubApply.user, user) + .where(approvedMemberApplicationPredicates(clubId)) .fetchOne(); return new PageImpl<>(content, pageable, total != null ? total : 0L); } + private JPAQuery approvedMemberApplicationBaseQuery(Integer clubId) { + return jpaQueryFactory + .selectFrom(clubApply) + .join(clubApply.user, user).fetchJoin() + .where(approvedMemberApplicationPredicates(clubId)); + } + + private BooleanExpression[] approvedMemberApplicationPredicates(Integer clubId) { + return new BooleanExpression[] { + clubApply.club.id.eq(clubId), + user.deletedAt.isNull(), + isAlreadyClubMember(clubId), + clubApply.status.eq(ClubApplyStatus.APPROVED), + isLatestApprovedApplicationByUser(clubId) + }; + } + private BooleanExpression isAlreadyClubMember(Integer clubId) { return JPAExpressions .selectOne() diff --git a/src/main/java/gg/agit/konect/domain/club/repository/ClubApplyQuestionRepository.java b/src/main/java/gg/agit/konect/domain/club/repository/ClubApplyQuestionRepository.java index 076a56d8..747c3bfc 100644 --- a/src/main/java/gg/agit/konect/domain/club/repository/ClubApplyQuestionRepository.java +++ b/src/main/java/gg/agit/konect/domain/club/repository/ClubApplyQuestionRepository.java @@ -33,6 +33,20 @@ List findAllVisibleAtApplyTime( @Param("appliedAt") LocalDateTime appliedAt ); + @Query(""" + SELECT question + FROM ClubApplyQuestion question + WHERE question.club.id = :clubId + AND question.createdAt <= :maxAppliedAt + AND (question.deletedAt IS NULL OR question.deletedAt > :minAppliedAt) + ORDER BY question.displayOrder ASC, question.id ASC + """) + List findAllCandidatesVisibleBetweenApplyTimes( + @Param("clubId") Integer clubId, + @Param("minAppliedAt") LocalDateTime minAppliedAt, + @Param("maxAppliedAt") LocalDateTime maxAppliedAt + ); + ClubApplyQuestion save(ClubApplyQuestion question); List saveAll(Iterable questions); diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubApplicationService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubApplicationService.java index 2ff1dfda..b48bae89 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubApplicationService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubApplicationService.java @@ -21,6 +21,7 @@ import gg.agit.konect.domain.chat.service.ChatRoomMembershipService; import gg.agit.konect.domain.club.dto.ClubApplicationAnswersResponse; import gg.agit.konect.domain.club.dto.ClubApplicationCondition; +import gg.agit.konect.domain.club.dto.ClubMemberApplicationAnswersResponse; import gg.agit.konect.domain.club.dto.ClubApplicationsResponse; import gg.agit.konect.domain.club.dto.ClubAppliedClubsResponse; import gg.agit.konect.domain.club.dto.ClubApplyQuestionsReplaceRequest; @@ -118,11 +119,64 @@ public ClubApplicationAnswersResponse getApprovedMemberApplicationAnswers( clubMemberRepository.getByClubIdAndUserId(clubId, targetUserId); ClubApply clubApply = clubApplyRepository.getLatestApprovedByClubIdAndUserId(clubId, targetUserId); - List questions = - clubApplyQuestionRepository.findAllVisibleAtApplyTime(clubId, clubApply.getCreatedAt()); - List answers = clubApplyAnswerRepository.findAllByApplyIdWithQuestion(clubApply.getId()); + return toClubApplicationAnswersResponse(clubId, clubApply); + } - return ClubApplicationAnswersResponse.of(clubApply, questions, answers); + public ClubMemberApplicationAnswersResponse getApprovedMemberApplicationAnswersList( + Integer clubId, + Integer requesterId, + ClubApplicationCondition condition + ) { + clubRepository.getById(clubId); + + clubPermissionValidator.validateManagerAccess(clubId, requesterId); + + Page approvedApplicationsPage = + clubApplyQueryRepository.findApprovedMemberApplicationsByClubId(clubId, condition); + List approvedApplications = approvedApplicationsPage.getContent(); + + if (approvedApplications.isEmpty()) { + return ClubMemberApplicationAnswersResponse.from(approvedApplicationsPage, List.of()); + } + + List applyIds = approvedApplications.stream() + .map(ClubApply::getId) + .toList(); + List appliedAts = approvedApplications.stream() + .map(ClubApply::getCreatedAt) + .distinct() + .toList(); + + LocalDateTime minAppliedAt = appliedAts.stream() + .min(LocalDateTime::compareTo) + .orElseThrow(() -> new IllegalStateException("지원서 신청 시점이 비어 있습니다.")); + LocalDateTime maxAppliedAt = appliedAts.stream() + .max(LocalDateTime::compareTo) + .orElseThrow(() -> new IllegalStateException("지원서 신청 시점이 비어 있습니다.")); + + Map> answersByApplyId = clubApplyAnswerRepository + .findAllByApplyIdsWithQuestion(applyIds) + .stream() + .collect(Collectors.groupingBy(answer -> answer.getApply().getId())); + List questionCandidates = clubApplyQuestionRepository + .findAllCandidatesVisibleBetweenApplyTimes(clubId, minAppliedAt, maxAppliedAt); + Map> questionsByAppliedAt = appliedAts.stream() + .collect(Collectors.toMap( + appliedAt -> appliedAt, + appliedAt -> questionCandidates.stream() + .filter(question -> isVisibleAtApplyTime(question, appliedAt)) + .toList() + )); + + List responses = approvedApplications.stream() + .map(application -> toClubApplicationAnswersResponse( + application, + questionsByAppliedAt.getOrDefault(application.getCreatedAt(), List.of()), + answersByApplyId.getOrDefault(application.getId(), List.of()) + )) + .toList(); + + return ClubMemberApplicationAnswersResponse.from(approvedApplicationsPage, responses); } public ClubApplicationAnswersResponse getClubApplicationAnswers( @@ -135,13 +189,40 @@ public ClubApplicationAnswersResponse getClubApplicationAnswers( clubPermissionValidator.validateManagerAccess(clubId, userId); ClubApply clubApply = clubApplyRepository.getByIdAndClubId(applicationId, clubId); + return toClubApplicationAnswersResponse(clubId, clubApply); + } + + private ClubApplicationAnswersResponse toClubApplicationAnswersResponse(Integer clubId, ClubApply clubApply) { + List answers = clubApplyAnswerRepository.findAllByApplyIdWithQuestion(clubApply.getId()); + return toClubApplicationAnswersResponse(clubId, clubApply, answers); + } + + private ClubApplicationAnswersResponse toClubApplicationAnswersResponse( + Integer clubId, + ClubApply clubApply, + List answers + ) { List questions = clubApplyQuestionRepository.findAllVisibleAtApplyTime(clubId, clubApply.getCreatedAt()); - List answers = clubApplyAnswerRepository.findAllByApplyIdWithQuestion(applicationId); + return toClubApplicationAnswersResponse(clubApply, questions, answers); + } + + private ClubApplicationAnswersResponse toClubApplicationAnswersResponse( + ClubApply clubApply, + List questions, + List answers + ) { return ClubApplicationAnswersResponse.of(clubApply, questions, answers); } + private boolean isVisibleAtApplyTime(ClubApplyQuestion question, LocalDateTime appliedAt) { + LocalDateTime createdAt = question.getCreatedAt(); + LocalDateTime deletedAt = question.getDeletedAt(); + return (createdAt.isBefore(appliedAt) || createdAt.isEqual(appliedAt)) + && (deletedAt == null || deletedAt.isAfter(appliedAt)); + } + @Transactional public void approveClubApplication(Integer clubId, Integer applicationId, Integer userId) { Club club = clubRepository.getById(clubId);